Skip to content

fix(agent-status): retire a pane's rows only after its agent's exit is verified, and tell every reader - #24322

Closed
brennanb2025 wants to merge 21 commits into
mainfrom
brennanb2025/retirement-emits-pane-clear
Closed

brennanb2025 wants to merge 21 commits into
mainfrom
brennanb2025/retirement-emits-pane-clear

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 18 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​2119 $\color{#cf222e}{\Huge{\mathbf{−}}}$​35 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​2084
Prod 41 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1119 $\color{#cf222e}{\Huge{\mathbf{−}}}$​97 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1022

ELI5

When an agent that Orca launched in a terminal finishes and drops back to the shell, Orca takes away that terminal's "launched agent" authority. Until now that step also quietly deleted the agent's status row on the host, without telling the desktop window. So the sidebar, the dashboard popout, the spinning tab title and the session stats could keep showing an agent that orca worktree ps and the phone had already dropped.

It also deleted the row on a signal that doesn't prove the agent has exited. Some full-screen agents run nested shells that print the same "command finished" marker while the agent is still running. Its row then disappeared from ps and the phone, and froze on the desktop.

Now the authority still ends at once. The row is removed only once Orca has checked that the agent really exited, and every screen is told in the same step.

What Changed

What you see:

  • When an Orca-launched agent exits back to the shell, its row disappears from the sidebar, dashboard, tab title, stats, orca worktree ps and mobile together. Before, the desktop could keep it, including as Done.
  • While a full-screen agent is still running, its row stays everywhere when one of its nested shells prints a "command finished" marker. Before, ps and mobile lost it, and the desktop row froze because later updates from the agent were ignored.
  • When Orca can't tell (a remote host doesn't answer, or an agent runs behind a node wrapper with no process identity), the row stays and a warning is logged. Nothing waits on the check.
  • When the desktop receives a clear for a Done row, it now removes it. Done means the agent's TUI is alive and has finished its turn; an exited agent isn't waiting for you. The dashboard's "finished" retention list is separate and unchanged.
  • The shell keeps the session resumable: after a verified exit the pane keeps the session-only resume record, as the existing "agent process ended" clear already does.

How it works:

  1. Authority and rows are separate steps now. At a command end (the shell's OSC 133;D marker), the runtime still drops the pane's launch token, its restored-authority receipt and its launch agent at once. The hook server then:

    • revokes the pane's authority commitments;
    • forgets the hook observations that vouched for the token;
    • strips the token from the rows it keeps (retirePaneAuthority(..., { authorityOnly: true })).

    It does not lock the pane and does not delete rows.

  2. A verified exit removes the rows. verifyCommandEndAgentExit makes a fresh foreground-process read on the PTY's execution host:

    • an agent in the foreground means live; a shell means exited;
    • anything else falls back to the Claude hook's own pid and start-time check;
    • with no proof either way the verdict is unverifiable.

    Only exited runs the full retirement: lock the pane against late events from the exited process, delete the rows, keep the resume record, emit the pane clear. A PTY exit still retires at once, and now also notifies.

  3. Every host row removal notifies. retirePaneAuthority was the only host removal path that told no reader. It now emits the pane clear for each live row it removes, like clearPaneState and tab close do. A new contract test runs every host removal operation and checks that a reader hears it.

  4. Whether the inherited token still counts is derived, not stored. Every process the shell starts inherits the launch token from its environment, so after a command end the token proves nothing. The three ingest paths (local HTTP, SSH relay, spool replay) now keep an event's token only while one of these is true:

    • the runtime still holds that token for the pane's live PTY (runtime.readPaneLaunchAuthority, injected the same way the presence check is);
    • an authority commitment this host saved still vouches for it.

    A pane the runtime has no live PTY for keeps today's rule. orcad wires no attestation or retirement, so it is unchanged.

  5. The renderer no longer ignores a host clear when its own row is Done.

docs/reference/agent-status-store.md gains a short "Removing a row" paragraph stating the rule.

Why

Why the bug existed. retirePaneAuthority was written as the host half of a retirement the renderer starts after a pane closes, so the renderer had already removed its own rows. The runtime later reused it on every command end for any pane holding launch authority. That includes every agent launched from the UI, orchestration workers, and restored panes that a worktree ps or mobile listing gave a receipt. Nobody did the renderer half there.

Why rows wait for a verified exit and authority does not. The renderer already defers its own row drop at a command end, because a full-screen agent's nested shells leak that marker. The host must not be less careful than its own reader. The launch token is different: it lives on in the shell's environment, so every later process inherits it, and its authority has to end at the marker.

Revert history. This area has been reverted before:

This PR does not defer authority. Only row removal waits, and the three tests pass unchanged. The new test "never vouches for a later process that inherits it, before or after a restart" covers the inherited-token case end to end with real wiring:

  • the runtime refuses the token;
  • the hook server refuses it;
  • after a restart, no saved commitment brings it back.

Alternatives rejected:

  • Keep deleting at the marker and just add the notification. This makes the desktop as wrong as ps and mobile when a nested shell leaks a marker under a live agent.
  • Drop only authority and leave rows to the other exit paths. Only Claude rows carry a process identity, so headless hosts (orca serve) would keep exited agents' rows.
  • A stored list of retired tokens. The runtime already holds the fact (its live launch token per pane), so the check reads that instead of keeping a second copy.

Differences from the reference mechanism. The common pattern is that one owner applies the change and tells every reader in the same step; pane-hosted designs delete live status when the agent's process is gone, after checking the process.

  • Owner and notify in one step: matches.
  • Delete rather than mark stopped: INTENDED. Orca's row says whether a live agent is in the pane, not a conversation record; Done is only for a live TUI.
  • Exit check uses the foreground read, then the hook pid: INTENDED. Orca doesn't spawn the agent, so there is no child-exit event to use.
  • Resume record kept: INTENDED. Same as the existing ended-process clear.
  • unverifiable keeps rows without locking the pane: INTENDED. Losing contact is never evidence of exit (ssh-execution-boundary.md). Those rows still leave by the other paths: the renderer's own drop, PTY exit, the presence check, or staleness.

Wire compatibility. No wire shape changes. The host now sends a pane clear (an existing message type) after retirements that used to be silent, and older renderers and clients already handle it. The only new type is an in-process function between the runtime and the hook server.

PR #24292 (opencode run). It orders its clear before this retirement, which stays correct. That ordering stops mattering once both land; the second to land should update its "why before retiring" comments.

Linked Issue

Follow-up to the review of #24292 (no separate issue).

Visual Proof

N/A. The change is in host status bookkeeping and has no new UI. It is pinned by the runtime tests below, which drive the real hook server through command ends and PTY exits.

Testing

  • I manually tested these changes locally. Not done: no app launch on this machine for this change.
  • Automated tests added/updated
    • src/main/agent-hooks/server-row-removal-notifies-readers.test.ts (new, 13 tests):
      • every host removal operation notifies a reader;
      • retirement emits to the desktop listener and every subscriber;
      • command-end retirement keeps the resume record, locks out late events, and doesn't bring authority back after a restart;
      • authorityOnly keeps the row, emits no clear, doesn't lock the pane, and stops vouching for the token.
    • src/main/runtime/launch-authority-retirement-reader-clear.test.ts (new, 15 tests, real AgentHookServer + OrcaRuntimeService):
      • Claude rows in a launched pane (no listing) and in a restored pane whose authority came from a listing, through both command-finished paths (shell bytes and daemon fact);
      • a Done row; the Claude pid fallback; PTY exit;
      • a nested-shell marker under a live agent, through both paths;
      • an execution host that can't answer;
      • authority dropped at once; the inherited token across a restart; a live token still vouched for.
    • Updated: the two renderer listener tests that pinned "keep Done on a host clear", and two runtime spec expectations (retireAgentHookCompatibilityAuthority is now called with { authorityOnly: true } at a command end).
    • opencode-finished-session-authority.test.ts: unchanged and passing.
    • Ablation: each production hunk was reverted on its own, and every revert fails at least one new or updated test:
      • no pane clear on retirement: 9 tests fail;
      • no resume record: 5;
      • rows retired at the marker (the old behaviour): 8, including both nested-shell cases and the unreachable host;
      • a shell in the foreground not treated as an exit: 5;
      • no Claude pid fallback: 1;
      • token observations kept: 2;
      • token kept on rows: 1;
      • commitments kept: 2;
      • no ingest derivation: 1 (the inherited-token test);
      • renderer Done guard restored: 2.
    • Related suites: 163 files covering retirement, attestation, command-finished and the clear listeners. orca-runtime.test.ts runs the runtime spec harness and passes (1340 tests). In the full combined run, 10 renderer pty-connection-* files failed under load; they pass when run on their own (139 tests). The core suites were re-run after rebasing onto current origin/main.
    • Not covered by a unit test: the one-line setPaneLaunchAuthorityReader hookup in main-process-runtime-service.ts. The tests wire the same call themselves.
    • Typecheck: tc:node and tc:web pass. Changed-code quality gate passes.

Review

Agent skill upstream boundary

  • Not applicable

Notes

  • SSH: a command end comes from the execution host's own bytes. The foreground read goes to that host. A failed read is unverifiable, never exited.
  • Folder workspaces: pane keys don't depend on git; nothing git-specific is touched.
  • Windows and WSL: the foreground read goes through each provider's existing confirmForegroundProcess. A WSL or wrapper process name is neither an agent nor a shell, so it falls back and then reads unverifiable (safe).
  • Mobile and worktree ps: they read the same host store and see the same timing as the desktop now.
  • Performance: one foreground read per command end, and only in a pane that still held launch authority. Authority ends at the first one, so at most one read per launch or receipt.

Checklist

  • This PR is small and focused
  • I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred). Ran tc:node and tc:web, the changed-code quality gate, oxlint and oxfmt on the changed files, and the suites above. Did not run the full pnpm lint, pnpm test or pnpm build.

…s verified, and tell every reader

A command end (OSC 133;D) in a pane holding launch authority deleted the pane's host
status rows without a pane clear, so the desktop window, dashboard popout, tab-title
spinner and session stats kept a row worktree ps and mobile had dropped; and it did so
on a mark a full-screen agent's nested shell can leak while the agent still runs.

Launch authority still ends at the mark (the token lives on in the shell's env): the
runtime drops its token/receipt/launch agent and the hook server revokes commitments,
forgets token observations and strips the token from kept rows. Ingest derives whether
an event's token is still honoured from the runtime's live launch token for the pane or
a surviving commitment. Rows, the pane fence and the reader clear wait for a verified
exit (foreground shell, else the hook's own pid); unverifiable keeps the rows.

retirePaneAuthority now emits the pane clear like every other host removal, and keeps
the resume remnant on a command end. The renderer no longer ignores a host clear for a
Done row.
… ended-process clear

A command end (OSC 133;D) only ended a pane's rows when that pane still held
launch authority, and the first command end consumed that authority. A leaked
133;D under a live full-screen agent therefore spent the one check, and the
agent's real exit later left a Done row in the sidebar, dashboard, worktree ps
and mobile. A shell's name in the foreground also outranked a live agent pid,
and the verdict fenced the pane, so a wrong verdict hid a live agent.

Now every command end of a PTY whose panes hold a live host row asks again,
whatever launched the agent:
- the hook's own agent pid still running keeps the row;
- the spawned shell owning the foreground with no stopped job clears it
  through the store's ended-process clear, keeping the resume identity and
  fencing nothing;
- anything else keeps the row until the next command end asks again.
The clear skips a row that changed while the check ran, a command end that
lands mid-check is checked too, and both the byte path and the daemon path
use the same check. Full retirement stays for pane close and PTY exit.

Launch-token liveness is now derived where authority becomes durable: at
every row write (hook ingest, relay, replay, transcript poll, assistant
retry), when an authority observation is recorded, and when the status file
is persisted. The runtime answers from the PTY record whether or not it is
connected. Command-end revocation no longer rewrites live rows, so a live
Codex transcript poll keeps running.
… Done list

Once the host's pane clear started reaching the renderer for Done rows, a
Done agent that quit to the shell became a sticky "Done" in the sidebar and
dashboard (and unread), while worktree ps and mobile showed nothing: the
clear removed the live row without the retention suppressor that the
renderer's own drop plants, and the renderer's later drop found no row.

The host clears a pane only when its agent ended or the pane went away, so
the clear listener now removes the row with agentGone: it plants the
retention suppressor and drops a retained snapshot already taken for that
pane. Nothing is echoed back to main, which already removed its row.
…nt wiring

The suite dropped the retirement options and never wired the launch-authority
reader, so it stayed green even with the reader wiring deleted. It now wires
the hook server to the runtime the way main-process-runtime-service.ts does.
…xited

An SSH provider has no shell-owns-the-foreground check, so the command-end
verifier always kept an SSH pane's row. A headless host (orca serve, orcad,
orchestration workers) then kept an exited agent's Done row for the life of
the terminal, where it used to be deleted at the command end.

The verifier now reads the relay's fenced foreground evidence, admitted the
way the desktop pane admits it: the shell's own process group in front with
no agent named in it means the agent exited. Anything else keeps the row.
The evidence has no stopped-job signal, so a Ctrl+Z'd SSH agent reads as
exited, as it already does on a mounted desktop pane.
… one place

The desktop, orcad and the specs each spelled out how the runtime reads and
clears the hook server's status store and retires launch authority. The
retirement and ended-process options are optional, so a host that dropped
one still typechecked and every spec stayed green, while every command end
would then fully retire a live agent's pane.

agent-hook-runtime-deps.ts now builds those deps and wires the runtime as
the hook server's launch-authority reader. The desktop, orcad, the
command-end specs and the STA-4557 suite all use it, and a unit test pins
that both options reach the hook server.
…rocess evidence

The terminal daemon, the default local backend, answers its shell confirm
from its byte scanner, which only proves a shell after a full-screen exit,
and every command end resets it. So a Codex run in the normal screen buffer
that quit was never verified: its Done row stayed on hidden desktop panes,
on headless hosts, in worktree ps and on mobile.

When the provider's shell confirm says no, the verifier now reads the
provider's fenced foreground evidence through the existing process
inspection, the same check SSH panes already use: the shell's own process
group in front with no agent named in it. A local node-pty reports no
such evidence, so it keeps its stricter check. A daemon too old to send
evidence keeps the row, as before. The evidence cannot see a stopped job,
so a Ctrl+Z'd agent on a daemon pane reads as exited, as on SSH.

The verifier also logs, per pane, a check or clear that throws instead of
leaving an unhandled rejection.
…s for it

A row the agent wrote before its command ended still carries the launch
token. If the runtime later pruned that pane's PTY record (an SSH record
after an unverifiable disconnect, whose row survives), the next save found
no record, kept the token, and wrote a commitment that vouched for the dead
token again after a restart.

Saving now fails closed: a row's token is hashed and committed only when the
runtime holds it or a commitment of this host still vouches for it. Ingest
keeps passing a token for a pane with no PTY record.
…exit was proven

On every SSH reconnect main asks the relay to replay the status it cached for
each pane. A verified command-end exit no longer fences the pane, so that
replay brought an exited agent's Done row back on every surface; a pane
the user wasn't typing in kept it.

Each ended-process clear now records which session ended (agent type and
provider session). The status disposition, which every ingest path calls,
refuses a replay of that session. Anything newer -- a live event, a new turn,
another session's replay -- is admitted and drops the record, as do
clearPaneState and a size bound. Live late hooks are not refused: an exit
verdict can be wrong, and the agent's next event is how it recovers.

An anchored clear also skips a resume-only remnant, so a Claude exit the pid
check already cleared no longer tells every reader twice.
The host cannot inspect a WSL guest's processes and wsl.exe is not a shell,
so a WSL pane could never prove an exit: a hidden pane, worktree ps and
mobile kept an exited agent's Done row for the life of the terminal, while a
visible WSL pane's desktop drops it at the command end.

For a WSL PTY the host now treats the command end as the exit, as the
desktop pane does, after the agent pid check (which still keeps a live
agent the host can probe). A nested shell's 133;D under a live WSL TUI
drops its row, as the desktop pane already does; that stays until a
guest-side proof exists.
The command-end verifier asked each provider a yes/no shell question, so it
could not tell 'something else is in front' from 'this host cannot tell',
and needed a WSL-only branch. Hosts that cannot tell (a WSL guest, a
Windows daemon or relay, a host that predates foreground evidence) kept an
exited agent's row on hidden panes, worktree ps and mobile, while the
desktop pane and main's old retirement dropped it.

Each provider now answers proveShellForeground with shell, other or
unprovable, and rejects when its host cannot be reached:
- local node-pty: its process-table check; a wsl.exe root is unprovable;
- terminal daemon: its own confirm, then its fenced evidence; Windows and
  daemons without evidence are unprovable;
- SSH: the relay's fenced evidence; a Windows relay or one without
  evidence is unprovable, a client-only answer rejects.
The verifier keeps the row for a live pid, for other and for an
unreachable host, and takes the command end as the exit for shell and
for unprovable, as the desktop pane does. The WSL-specific branch is gone.
The local provider answered 'cannot tell' for any root process outside the
known shell names, so a pane rooted in an unlisted POSIX shell (yash, oksh)
took every command end as an exit although its own process table can say.
Only a wsl.exe root, whose guest the host cannot see, now answers that.
…rity wiring

Both deps are optional, so dropping the launch-authority spread or the
runtime's token-reader wiring from main-process-runtime-service.ts still
typechecked and every spec stayed green.
…exit capture

The daemon and SSH relay answer from a shared process-table capture that can
be up to 500 ms old, or already running. Read right after a command end it
could still show the exiting agent in front, so the row was kept and nothing
asked again until the user ran another command in that pane.

- Evidence whose capture can have begun before the command end no longer
  counts. That, an unreadable table, unadmitted evidence and an unreachable
  host are 'unread' and are asked again past the cache's lifetime, then once
  more; a proven 'other' in front waits for its own command end. A PTY exit
  drops a scheduled re-ask.
- Daemons from v27 until the shell-confirm request existed answer it as an
  unknown request; that alone now falls through to their fenced evidence. Any
  other failure still rejects and keeps the row.
- When the exiting agent's own late hook rewrites its row mid-check, the check
  runs again (bounded); a different session's row is still left alone.
- The verdict names 'host cannot tell' apart from 'exited': clearing the row
  there is a presentation choice, not a proven exit.
The check that refuses a process-table capture from before the command end
bounded the capture's start by the request's start minus its age. The age
runs from the start of the `ps` to the reply, so even a capture the check
itself triggered failed it: every daemon and SSH exit took a second
inspection and cleared about 0.6 s later.

The daemon is on this machine, so its reply arrives as it is sent: the bound
now uses the reply's arrival there. SSH keeps the request-start bound, since
Orca can't tell when the relay's reply left.

After a proven exit, a row rewritten mid-check is now checked again whoever
wrote it (a new agent then reads as in front), so an agent without a session
id gets its second look too; where the host cannot tell, only the same
session's rewrite is. Drops an unreachable reason from the 'something else in
front' set.
…'s process table

QA on macOS (default terminal-daemon backend): a parked pane's Gemini quit to
the shell and its Done row stayed on the sidebar, dashboard and worktree ps,
also after a later command end.

On macOS the daemon spawns the shell under /usr/bin/login for TCC
attribution, so the PTY's root process is login and the shell is its child in
its own process group. The daemon's fenced evidence names the root and the
terminal's foreground group, and the proof required them to be the same
process: never true there, so every answer was 'something else in front' and
the row was kept at every command end.

The daemon runs on this machine, so the proof now takes only the PTY's root
from the daemon and reads this machine's process table fresh: the shallowest
shell under the root holds the foreground and no job is stopped, as the local
provider's check does. That also sees a Ctrl+Z'd agent and ignores a shared
capture taken before the exit. SSH keeps the relay's evidence, whose root is
the shell.
…its root

c37fee5 read the fence's root pid without narrowing the fence's
platform, which tc:node rejects.
…ck window

The keep-row cases now wait for main's own process-table read, and the
clear cases rely on a single inspect to show no re-ask was needed.
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Closing this older retirement implementation and rebuilding the rule on current main. Create an issue for premature row removal and readers disagreeing after an agent exits before closing.

The remaining problem is tracked in #27677.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant