Skip to content

Program status (OSC 7501) via wr-agent: show when an agent is blocked on the user #358

Closed

Description

@joelmoss

Blocked by: a Lakr233/libghostty-spm release built from Ghostty a4aacd918 or later, so both engine pins can move together. ghostty-org/ghostty#14560 merged 2026-10-06. Updated by Claude Code at the owner's request, 2026-10-09.

Problem

The pane busy indicator only knows "working" or "idle", from OSC 9;4 (GHOSTTY_ACTION_PROGRESS_REPORT, macapp/WorkroomApp/Core/GhosttyRuntimeAdapter.swift). Claude Code emits it once at start and end; Codex never emits it. Workroom can't tell a pane that is working from one that is waiting on the user (permission prompt, question, login), which matters most when several agents run side by side.

Upstream

OSC 7501, the program status protocol (spec) lets a program report state=idle|working|done|blocked|error|clear, plus kind=permission|question|auth for blocked, app=, a base64 msg=, a progress percentage and hierarchical ids.

ghostty#14560 adds it to libghostty-vt only, as a GHOSTTY_TERMINAL_OPT_PROGRAM_STATUS callback in include/ghostty/vt/terminal.h. It does not touch the apprt: there is no GHOSTTY_ACTION_* in include/ghostty.h, and src/termio/stream_handler.zig ignores .program_status. Bumping GhosttyKit in the macapp therefore gains nothing.

Approach

wr-agent already links libghostty-vt (feature terminal-state, always enabled for app builds by macapp/scripts/build-agent.sh), so the daemon is the natural place to consume it:

  1. Once #14560 merges, bump the Ghostty checkout used by scripts/build-ghostty-vt.sh.
  2. Register the program-status callback in wr-agent. The library keeps no records: wr-agent keeps one record per id per session and applies the spec's lifetime rules (a full reset reports a clear of every record).
  3. Carry the current state to the app over the existing session protocol.
  4. Show it in the sidebar/tab strip: at least a "needs you" badge for blocked (with kind and msg in the tooltip), and done/error.

Benefits of doing it daemon-side: works for local and remote/boxd hosts alike, and the state survives client reattach because wr-agent holds the record.

Notes

  • The terminal only answers the support query (OSC 7501 ; ?) while the callback is set. Programs that probe first stay silent until wr-agent registers it.
  • app= could later replace some of the argv0 / foreground-pgid agent detection, for programs that send it.
  • Consider whether blocked should count as busy for the boxd keep-awake heartbeat (Far-side lifecycle shim: keep a busy box awake through the provider (boxd first) #257).
  • Emitter support (Claude Code, Codex, Terraform, Homebrew) is proof-of-concept per the announcement; check what has actually shipped before building UI.
  • The C API may change while #14560 is a draft.

Estimate: about a day once upstream merges.


🤖 Left by Claude Code

Activity

  1. joelmoss commented on Oct 6, 2026

    @joelmoss
    OwnerAuthor

    Emitter check: Claude Code does not emit OSC 7501 yet

    Checked 2026-10-06 against Claude Code 2.1.292, the current npm latest (stable is 2.1.285), by reading the shipped binary (~/.local/share/claude/versions/2.1.292). Builds 2.1.289–2.1.291 are the same.

    • No OSC 7501. Claude Code keeps its OSC codes in one table, and 7501 isn't in it:
      var bg={SET_TITLE_AND_ICON:0,SET_ICON:1,SET_TITLE:2,SET_COLOR:4,SET_CWD:7,HYPERLINK:8,ITERM2:9,SET_FG_COLOR:10,SET_BG_COLOR:11,SET_CURSOR_COLOR:12,CLIPBOARD:52,KITTY:99,RESET_COLOR:104,RESET_FG_COLOR:110,RESET_BG_COLOR:111,RESET_CURSOR_COLOR:112,SEMANTIC_PROMPT:133,GHOSTTY:777,ITERM2_PROPRIETARY:1337,TAB_STATUS:21337};
      
      The literal 7501 appears 3 times in the binary, all inside hashes or unrelated constants. kind=permission and program_status don't appear at all.
    • Control. The OSC 9;4 progress emitter is built from the same table (${bg.ITERM2};${aSt.PROGRESS};…, behind the terminalProgressBarEnabled setting, default on). So a 7501 emitter would show up here, and a string search alone wouldn't have found 9;4 either.
    • Related: OSC 21337 TAB_STATUS. Claude Code does emit OSC 21337 ; indicator=<#rgb>;status=<text>;status-color=<#rgb> (it can also parse it). There is also a showStatusInTerminalTab setting; I haven't checked whether it controls this sequence. This is a per-tab status line Claude Code ships today. It may be worth handling as an interim signal before 7501 lands, but libghostty 1.x doesn't surface it, so wr-agent would have to parse it.

    The proof-of-concept Claude Code integration mentioned in the OSC 7501 announcement hasn't shipped in a release. Before building UI for blocked, recheck the latest Claude Code for 7501 in that bg={…} table.


    🤖 Left by Claude Code

  2. joelmoss commented on Oct 6, 2026

    @joelmoss
    OwnerAuthor

    Design notes from the Rex docs

    Source: Rex: Program status. Rex is a terminal from Superlogical that already consumes OSC 7501. These rules apply directly to wr-agent's record store.

    • Record lifetime. On process exit or a new shell prompt (OSC 133 A), drop working and blocked records; dropping idle is optional. done and error records survive both events, so the user can still find them. Programs are told to report done/error just before they exit.
    • Each report replaces its record completely. Programs repeat app/title every time. A report with no state is ignored.
    • Hierarchical ids (build/test is a child of build). A program can be idle at its prompt while one of its workers is blocked, so a "needs you" badge has to roll up the whole tree, not only the root record.
    • Map OSC 9;4 onto the root record, until the first 7501 report. After that, ignore 9;4 until a full reset. Suggestion: build the record model now and feed today's OSC 9;4 into it (from GHOSTTY_ACTION_PROGRESS_REPORT, or via wr-agent). The indicator then upgrades by itself when 7501 arrives, and most of this issue no longer has to wait on ghostty#14560.
    • Feature detection. A program sends OSC 7501 ; ? ST followed by CSI c (DA1). If the DA1 reply arrives first, the program treats 7501 as unsupported.

    Open question: who answers the support query?

    The macapp's GhosttyKit app layer ignores 7501, so it won't answer ?, but it will answer DA1. That means wr-agent's libghostty-vt has to answer ? itself and write the reply to the PTY before the client's DA1 reply comes back over the session socket. Check two things:

    1. Whether wr-agent's shadow terminal writes any query responses to the PTY today, or drops them because the client answers everything.
    2. That the ordering holds for remote/boxd sessions.

    If either goes wrong, programs that check first will decide 7501 isn't supported and stay silent.

    Correction to my earlier comment

    Rex calls OSC 21337 iTerm2's earlier take on the same idea. (The form Claude Code emits is key=value, not JSON.)


    🤖 Left by Claude Code

  3. joelmoss commented on Oct 6, 2026

    @joelmoss
    OwnerAuthor

    Answer: wr-agent's shadow terminal writes nothing back to the PTY today

    Checked on feat/boxd-in-app @ 827db07 against the pinned libghostty-vt (~/.cache/workroom/ghostty-vt/src-c4e16970a).

    • ShadowTerminal::new (vcs/crates/wr-agent/src/terminal.rs) sets exactly one option, GHOSTTY_TERMINAL_OPT_CONTINUATION_MAX_BYTES. It's the only ghostty_terminal_set call in wr-agent/src.
    • It never sets GHOSTTY_TERMINAL_OPT_WRITE_PTY. Per terminal.h: "By default, sequences that require output (queries, device status reports) are silently ignored." So every query reply the shadow would produce (DA1, DSR, DECRQM, colours…) is dropped. Only the attached client answers queries.

    So today nothing answers OSC 7501 ; ?. Even after ghostty#14560 lands, registering the program-status callback alone won't make programs that check first start reporting. wr-agent also needs a WRITE_PTY callback.

    What the fix needs

    1. Filter WRITE_PTY to the 7501 reply only. Pass through bytes starting with ESC ] 7501 ; and drop the rest. Forwarding every reply would double-answer DA1/DSR/colour queries alongside the client. The duplicate replies would land in the program's input as garbage, the same failure class as the replay-buffer query leak fixed earlier.
    2. Don't write to the PTY from inside the callback. shadow.write runs in the PTY reader (session.rs ~L1484) while holding both the attached lock and the shadow lock. A PTY write that blocks there would stall the drain for every client. Queue the bytes in the callback and write them after the locks are released.
    3. Suppress replies while re-feeding. The shadow is also fed bytes that aren't live program output: the persisted screen at handoff (shadow.write(screen) ~L1063) and a paint at ~L2864. Replies triggered during those must not reach the PTY.

    Ordering is fine as built

    The reader feeds the shadow before it captures the client list and forwards the bytes (session.rs ~L1484–1492). So wr-agent's reply to ? is generated before the client has even received the CSI c that follows it, and the client's DA1 answer can't overtake it. This holds for remote/boxd sessions too, since the shadow sits next to the PTY. Still worth one integration test once 7501 exists.

    Also: OSC 9;4 is available to wr-agent today

    The pinned libghostty-vt already exposes GHOSTTY_TERMINAL_OPT_PROGRESS_REPORT. wr-agent can own the record model and the 9;4-to-root-record mapping now, for local and remote sessions alike, without waiting on #14560.

    Separate gap

    With no client attached, nobody answers DA1 or any other query. A program started in a detached session that checks for 7501 waits for its DA1 timeout. That's out of scope here.


    🤖 Left by Claude Code

  4. joelmoss commented on Oct 6, 2026

    @joelmoss
    OwnerAuthor

    Scope for ready-for-agent

    This label covers only the work that doesn't need ghostty#14560. Build it against the libghostty-vt already pinned (src-c4e16970a).

    In scope now:

    1. In wr-agent, register GHOSTTY_TERMINAL_OPT_PROGRESS_REPORT (OSC 9;4) on the shadow terminal, and keep a per-session program-status record model shaped for OSC 7501: states idle|working|done|blocked|error, hierarchical ids, and the lifetime rules from the Rex notes above. Today OSC 9;4 maps onto the root record.
    2. Apply the lifetime rules on process exit and on a new prompt (OSC 133 A): drop working/blocked, keep done/error. 9;4's REMOVE state counts as idle.
    3. Carry the current state to the app over the existing session protocol, and drive the existing busy indicator from it, so local and remote/boxd sessions behave the same and the state survives reattach. Keep the current GHOSTTY_ACTION_PROGRESS_REPORT path working for sessions that aren't daemon-backed.
    4. Tests, using throwaway sessions only:
      • a 9;4 SET followed by REMOVE ends up in the right record state;
      • the state survives detach + reattach;
      • process exit drops working.

    Out of scope, still blocked on ghostty#14560:

    • the OSC 7501 parser callback;
    • the filtered WRITE_PTY reply to OSC 7501 ; ? (see the comment above);
    • the blocked/kind "needs you" badge UI;
    • app=-based agent identity.

    When #14560 merges, move the remaining work back to needs-triage or split it into its own issue.


    🤖 Left by Claude Code

  5. joelmoss commented on Oct 6, 2026

    @joelmoss
    OwnerAuthor

    Repro: the busy state is lost on reattach (confirmed at the wr-agent level)

    Why not the fixture app: fixture launches never use persistent sessions. TerminalPersistentSessionPolicy.swift:25 is (isAvailable || hasExistingSession) && !isRunCommand && !isFixture, so a fixture pane can't reattach. A non-fixture launch would share the Dev bundle's real session store and agent, so I didn't use one.

    What I ran instead: the release wr-agent (protocol 7, terminal-state yes) on a throwaway socket in /tmp, with SHELL=/bin/sh, a throwaway HOME, and a Python PTY as the client:

    1. Client A attaches to a new session and runs printf '\033]9;4;3;\033\\'; echo WORKING; sleep 60. That's the same indeterminate "working" OSC 9;4 Claude Code sends at the start of a turn, followed by a long-running "turn".
    2. Client A is killed mid-"turn". The session keeps running (list shows it detached, running sleep).
    3. Client B attaches to the same session.
    real ESC ] 9;4 bytes screen text
    Client A (live) yes (\x1b]9;4;3) yes
    Client B (reattach replay, 5693 bytes) no yes

    The only OSC in B's replay is OSC 4 (palette). A first check falsely matched ]9;4 against the echoed command text, so the result above is checked against the real ESC byte. Client A's row shows the check can fail.

    What the app does with that (code reading, not driven in the GUI): the busy indicator comes only from GHOSTTY_ACTION_PROGRESS_REPORT (GhosttySurfaceView.onProgressReport, then TerminalSessions.updateProgress), and that state lives in app memory per surface. After an app relaunch, a reattached pane starts with no busy state, and the replay can't restore it. So a Claude Code turn in progress at relaunch shows idle until Claude Code next sends 9;4, which is the end of the turn.

    Impact: this affects reattach after an app relaunch (local and remote/boxd). It doesn't affect a pane that re-attaches within the same app run, since that surface keeps its state.

    Fix this supports: the ready-now scope above. wr-agent records the 9;4 state (GHOSTTY_TERMINAL_OPT_PROGRESS_REPORT) and sends it to the client on attach. Either synthesise a 9;4 escape into the replay, or add a protocol field. Synthesising follows the existing pattern of re-synthesising state on attach. The protocol field fits better with the OSC 7501 record model later.


    🤖 Left by Claude Code

  6. joelmoss commented on Oct 6, 2026

    @joelmoss
    OwnerAuthor

    Scope update

    The reattach bug is split out into #359 (ready-for-agent): wr-agent records the last OSC 9;4 report and replays it on attach. That was the only user-visible gain in this issue's "ready now" scope.

    The rest of the ready-now work was only a record model shaped for OSC 7501, which has no user-visible gain until OSC 7501 actually reaches us. So this issue goes back to needs-triage and stays blocked on ghostty#14560 and on Claude Code (or another agent) shipping OSC 7501. #359's 9;4 state is what the record model will replace.


    🤖 Left by Claude Code

  7. 13 remaining items

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions