Files
orca/docs/reference/agent-status-store.md
T
Brennan Benson a0e24905f6 fix(agent-status): a cancel never hides live work (#22476)
* fix(agent-status): a cancel never hides live work

After the user cancels a turn, a background shell, scheduled check or
subagent that is still running keeps reading as it truly is in both
lanes. The fold no longer takes a verdict input; the cancellation
survives only as lead.outcome, restated as the row's interrupted flag on
a settled row for readers that predate lead.

* fix(agent-status): keep a cancel's verdict and clock on every settle path

A Grok turn cancelled while a task ran now reads monitoring, and the
idle_prompt backstop that later settles it restated done without the
row's `interrupted` flag, so notification readers announced the
cancelled turn as a clean finish. Derive `interrupted` from the main
agent's outcome, as the Claude builder already does.

The inferred Claude cancel now folds through the host's local main
agent record, which a relayed pane never refreshes, so a second cancel
on an SSH pane inherited the first cancel's clock. The caller admits
only a working main agent, so the cancel always starts a new done clock.

* fix(agent-status): keep the shell fact on an inferred cancel so restart can seed it

An inferred Ctrl+C cancel beside a working subagent publishes a row held
open by child work, but the synthesized event dropped the row's paired
claudeRunningNonAgentTask fact because mainAgent changed. Hydration seeds
a settled main agent only when that fact says no shell ran, so after a
restart the child's drain left the row working with no mainAgent. Carry
the fact forward: a cancel does not change what the shell inventory said.

* fix(agent-status): a Ctrl+C at an idle main agent's prompt cancels nothing

Every row that publishes the main agent fact now admits an inferred cancel
only while that main agent is working. Grok's Ctrl+C at the idle prompt
leaves its background task running, so settling the monitoring row to
done hid live work. Rows without the fact keep the evidence guard, and
Codex keeps it too because its synthesized row is a plain done.

* fix(agent-status): fold a relayed pane's cancel from its row, not the desktop's records

The inferred Claude cancel read and wrote the desktop's own listener
records for every pane. For an SSH pane those records are not the relay's:
hydration seeds them from the saved row and nothing reaps them, so a
subagent that finished on the remote after a desktop restart kept a
cancelled row spinning with nothing running. A local pane still records
the verdict on its listener and folds its own roster; a relayed pane
folds only the child work its row carries. The relayed-pane parameter and
forced clock the shared record path grew for this are gone.

* fix(agent-status): hold a cancel verdict in the store until a new turn or the provider's own

A relay never learns of the cancel the desktop infers from Ctrl+C, so its
next child hook or reconnect replay restated the main agent as working and
flipped the row back. The late-hook suppression that guarded this keyed on
a done row flagged interrupted, which a cancel held open by a shell or
subagent no longer is; it also dropped Grok's own stop_cancelled when the
inference won the settle race, hiding the task that hook reported.

The suppression is replaced by a latch derived from the row: its main
agent reads cancelled (or, from an older host, a done row flagged
interrupted). A settled incoming main agent, another prompt, an explicit
prompt or a session start releases it. Child and replayed events keep the
latched main agent and are re-folded with their own child evidence; late
main agent work is held as before, and Codex keeps its record re-mark.

* test(agent-status): pin Codex's evidence guard beside the main agent fact

* fix(agent-status): a prompt submission ends the cancel verdict latch

The task notification Claude starts when background work ends is a real
turn, but it keeps the cached prompt and carries no explicit prompt, so
within 15 s of a cancel the latch held its prompt submission and every
tool event after it: the turn read as monitoring under a cancelled main
agent until its Stop. The captured shell cancel has exactly this: the
notification lands 0.17 s after the cancel key.

* fix(agent-status): derive a Codex row's interrupted flag from its main agent

The cancel verdict latch lets any settled mainAgent through, so a late
root Stop after an inferred Codex cancel now applies where the old
same-prompt window held it. It restates the cancellation on mainAgent
but, unlike Claude and Grok rows, carried no interrupted flag, so mobile,
the dashboard and notification text read the cancelled turn as finished.
Codex rows (local and relayed) now derive the flag from the main agent
record, like the other providers that publish one.

* docs(agent-status): describe cancel admission for every provider and the store's cancel-verdict hold

* docs(agent-status): correct the idle-prompt Ctrl+C claim to the measured CLI behavior

* fix(agent-status): preserve waiting relay children on cancel

* fix(agent-status): resolve the cancel hold before a child's permission card adopts a relayed main agent

The permission-card hold took the incoming event's mainAgent before the cancel hold ran,
so on an SSH pane a child's next tool under a sticky card restated the relay's stale
working main agent and dropped the cancellation the desktop had inferred.

* test(agent-status): pin that a cancelled turn's drained subagent settles as stopped, not completed

* fix(agent-status): keep a cancel through a restarted relay's child hook and a teammate's idle

A relay that restarts after a desktop-inferred cancel has lost its prompt cache,
so the child's next hook arrived with an empty prompt, read as a new turn, and
replaced the cancelled main agent with none; the row then stayed working after
every child stopped. A child's empty prompt is now unknown, not another turn; a
non-empty different one still releases, since it is the listener's newer prompt.

TeammateIdle names its child by teammate_name and carries no agent id, so the
latch treated it as the main agent's and let the late-hook window apply it after
15 s, reviving the cancelled turn. It is now re-folded as child work.
2026-09-24 21:24:59 -07:00

31 KiB

Agent status store

Status

The current boundary is PR 2A: structured sessions use the hook server's fully scoped canonical store; unbound PTY/relay evidence remains in an isolated legacy adapter. Do not remove the renderer bridge or its publication filters in this slice: they still carry native-chat child rows.

The sections below record the original 2026-09-09 rollout. Its PR 1a and PR 1b have landed; its proposed PR 2/3 sequence is superseded by that boundary:

  1. main-only: every producer writes into one store and worktree ps reads it, split into 1a (structured sessions join the store) and 1b (the runtime's duplicate retained store is deleted);
  2. renderer: the sidebar becomes a subscriber and stops re-deriving rows;
  3. shared: one worktree-status rollup and one freshness rule for every reader.

The problem this solves

Orca shows "what is this agent doing" in four places: the desktop sidebar, the orca worktree ps command, the mobile app, and the agent dashboard. Before #19217 those readers did not even share their inputs. After #19217 they share the structured-session mapping and nothing else.

An audit on 2026-09-09 found six producers and three consumers, and three separate copies of the same row inside the main process alone:

Main-process copy Keyed by Owned by Persisted Evicted
hook server lastStatusByPaneKey paneKey src/main/agent-hooks/server.ts last-status.json tab close, pty exit, hydrate
runtime RuntimeAgentRowStore paneKey runtime-agent-row-store.ts (deleted in PR 1b) no pty exit only
structured feed published sessionId src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts no never (a broadcast cache)

The second copy is a duplicate write: the OSC status parsed in main is forwarded to the hook server and retained in the runtime store from the same call (orca-runtime-create-terminal-side-effect-command-code-detector.ts). The third copy is keyed differently and never reaches the hook server at all, which is why worktree ps grew its own adapter for it in #19217.

Each reader then applies its own precedence and freshness rules, so the same pane can legitimately read differently on the desktop, on the phone, and in the CLI.

The rule

The execution host owns agent status, in one store, and every reader subscribes to it. This follows the boundary in ssh-execution-boundary.md: the host that runs the process is the only party that can observe it, and the client is never authoritative for execution state.

Three consequences:

  • One store per execution host. A remote host keeps its own store and the client mirrors it down, as the web-session mirror already does. Mirroring is not merging: a client never writes its observations back to a host.
  • Precedence is decided once, at write time, with provenance recorded on the row. Readers never re-adjudicate hook versus terminal versus structured.
  • Readers keep only presentation policy and user facts: the 30-minute display decay, acknowledgements, dismissals, unread. Those stay reader-side but become one shared implementation (PR 3).

The store already exists

The hook server's state is that store today for every PTY-based agent. The audit established:

  • hook HTTP posts, the WSL and SSH relay receivers, and main's own OSC parse all converge on the same applyNormalizedStatus path, stamped with the authority id main-agent-hooks;
  • it alone holds pane authority: launch tokens and their hashed commitments, retired-pane fences, pane-key aliases, per-connection ordering watermarks, and the evidence-age map that must outlive a transport clear;
  • it alone persists, with a seven-day hydrate window and the restoredUnconfirmed stamp that keeps a hydrated row from ever reading as live truth;
  • it already fans out to both renderer windows over agentStatus:set and agentStatus:clear, and serves agentStatus:getSnapshot.

Nothing else in main carries those guarantees, and building a second store with them would be the wrong direction. So the design is not "add a store". It is: route the two producers that bypass the hook server through it, then delete the copies.

PR 1a: structured sessions publish into the store

No renderer behavior changes. The sidebar keeps receiving the same IPC events it receives today, plus structured-session rows it currently derives itself.

Structured sessions publish into the hook server

The structured feed keeps its job of projecting a session's journal into a summary and streaming it to subscribers. On every publish it additionally ingests the summary into the hook server as a status row:

Row field From
paneKey structuredAgentSessionPaneKey(tabId, sessionId), the key the renderer already uses; its leaf is UUID-shaped so pane-key validation accepts it
tabId structuredAgentSessionTabId(sessionId)
worktreeId summary.workspaceId (a folder workspace id is a valid value)
state structuredAgentSessionAgentStatus(summary).state: the lead's own status folded with its live backgroundTasks, so a settled lead whose subagent still runs reads working
workingMode 'monitoring' from the same fold when watch loops are the only live child work; omitted otherwise, which clears it on the row
mainAgent the main agent's own state before the fold, its last-turn verdict (summary.turnOutcome, present only while idle) and its own clock; see "The main agent fact" below
structuredHost 'owned' while summary.hostExecutionOwned is set, otherwise 'held'; worktree ps derives its row's structuredHostOwned from it
prompt, tool, last message, model, provider session the summary's fields

Sessions with no persisted turn (status === null) produce no row, matching what the chat shows. When the host revokes live ownership the row is re-set without the flag; when the host closes or evicts the session the row is dropped. Both already exist as feed events (revokeLive and the roster filter in liveSessionSummaries); PR 1 turns them into store writes.

Dropping the session from the host's map and dropping its row are one operation, forgetStructuredAgentSession. The store keeps a row until told, and a host-owned row bypasses the staleness check, so a deletion path that forgot the row would strand a permanently working-looking agent.

Two rules the ingest must keep:

  • Never persist a structured row. The journal is the durable truth for a structured session and the host republishes on restore. A structured row in last-status.json would hydrate as restoredUnconfirmed and then fight the live republish. The serializer skips rows carrying structuredHost, and hydrate drops any such row found on disk. Applying one therefore also skips the persist schedule: the walk and stringify could only reproduce the file that is already on disk, once per debounce window for every streaming chat.
  • Never let it fight a hook row. A structured session has no PTY, so no hook or OSC event carries its pane key. The ingest still goes through the disposition gate so a retired pane key is refused like any other.

Applying one does still run both status fan-outs, and that is intended rather than incidental. notifyStatusChangeListeners is what feeds agentAwakeService's power-save blocker, and subscribeEnrichedStatus is what feeds AgentSessionTransitionRecorder's stats, so joining the store enrolls native chats in both. A working native chat is real work and should hold the machine awake exactly like a PTY agent does.

The drop side routes through dropStatusEntry, not clearPaneState: a pane-status-clear reaches the renderer, and until PR 2 the renderer's own feed bridge is that pane key's writer. It also passes preserveResumeIdentity: false — the providerSessionOnly remnant a dismissed pane keeps exists so the agent can be resumed in that pane, and a structured session has no pane and keeps its resume identity in the record store. Like every other dropStatusEntry caller, it emits no pane clear, so a session dropped mid-working leaves AgentSessionTransitionRecorder holding an open stats session until its LRU evicts it; that gap is shared with the user-dismissal path and is not specific to structured rows.

The ingest lives in the feed, not in structured-agent-session-host.ts, which sits at the file-length cap.

worktree ps becomes a reader

The structured adapter added in #19217 is deleted, and structured rows reach worktree ps through the same snapshot as every other row. The retained-versus-hook reconciliation in collectRuntimeWorktreePtyAgentSources stayed until PR 1b removed the store that fed it. What this step settles is the admission gate that decides which rows a worktree listing may show:

  • a hook or OSC row needs its tab mirrored or a connected pty, as today, and SSH rows stay exempt because their tabs may exist only remotely;
  • a row carrying structuredHost is admitted while the host holds the session, and the host's drop on close is what removes it. No tab-mirror requirement: a structured session's tab lives in the renderer's own tab state, and a headless host has no renderer to mirror it from. That argument only holds if the headless host is itself wired to the store, which is a separate obligation per entry point: the Electron hosts (desktop and orca serve) share main-process-runtime-service.ts, and orcad constructs its own runtime in src/main/orcad/orcad-entry.ts. A host missing that wiring lists no agents at all, not just no structured ones, because worktree ps reads the same snapshot for every row.

The freshness bypass for host-owned structured rows already exists in isFreshNonDoneAgentStatus; with the flag now on the row it becomes the only path, and the hand-rolled check in runtime-worktree-agent-rows.ts goes.

Wire compatibility

AgentStatusIpcPayload gains one optional field, structuredHost, and the worktree ps row gains structuredHostOwned. Under rule 1 of remote-wire-compatibility.md both are safe: an old client ignores them. worktree ps rows keep their shape and vocabulary, so the mobile app sees no change.

Until PR 2 the main process does not forward structured rows to the renderer over agentStatus:set or agentStatus:getSnapshot. The renderer's feed bridge still writes those rows itself, and forwarding them too would give one pane key two writers. Removing that filter is the first step of PR 2.

The main agent fact

Claude, Codex and Grok hook rows and structured-session rows publish the combined state and, beside it, the main agent's own state as payload.mainAgent. Other agents' rows and terminal-title-only rows carry none, and readers fall back to state:

mainAgent?: { state: AgentStatusState; outcome?: AgentJournalTurnOutcome; stateStartedAt: number }

state still answers "what should the user see" and folds live child work in, so a settled main agent whose subagent still runs reads working. mainAgent answers "what is the main agent itself doing", which the fold used to destroy at publish time; every guard that reconstructed a fragment of it (fromChildWork, the persisted claudeLeadBoundaryChildOnly flag) now reads mainAgent instead of a stored copy. A Claude row whose mainAgent is done while a child agent still works (including a child's permission wait) refuses OSC, which carries no child identity; the children's own lifecycle hooks settle it. outcome is the recorded verdict on the main agent's most recent finished turn, present only while mainAgent.state is done. It is reported by the provider, or is a cancellation Orca inferred from the user's own interrupt keystroke (the journal's turn outcome, by contrast, is never inferred). A plain end of turn carries none, because absent means unknown and a provider that omits its interrupt flag must not turn a cancel into a success. In the Claude hook lane the cancellation comes primarily from Orca's own inferred interrupt (markClaudeLeadTurnInterrupted), because current Claude sends no hook at all on a cancel and no is_interrupt on Stop; that flag on a turn boundary remains a secondary source for builds that send it, and StopFailure maps to failure.

Admission is one function, normalizeAgentStatusPayload, on the relay wire, IPC and disk. A malformed mainAgent drops the field and keeps the row. Old hosts send none and readers fall back to state. Hook rows persist it inside the payload; hydration maps an older row's claudeLeadBoundaryChildOnly: true onto mainAgent: { state: 'done' } when the row has no mainAgent, and never writes the flag again. Hydration seeds the Claude main agent record straight from a saved mainAgent that is done, so the children's drain can still settle the row after a restart. claudeRunningNonAgentTask is persisted alongside because it is the one child-work fact mainAgent cannot express: a shell running beside the main agent, whose liveness hydration does not restore. Hydration seeds only a row that says false; a row silent about it stays unseeded. The row builder pairs the two facts in one place: a listener event restates the shell fact, and any other write (an OSC repaint, an inferred answer) keeps it only while mainAgent is unchanged. A child's sticky permission prompt still records the main agent's own progress and background evidence in the held row, and pushes the held row to subscribers when mainAgent changes.

Every lane, Codex included, combines through the fold. A child waiting on a human is a fold input (childWorkLiveness: 'waiting', derived from the child's own waiting state; a child's blocked means it failed and stays live work) and makes the row wait whatever the main agent is doing, unless the main agent is itself asking. Only the Codex hook lane feeds that input today. Known divergences, pinned by name in the parity table (src/shared/main-agent-status-parity.test.ts) where they are reachable, so a reader does not mistake them for drift:

  • The Claude hook lane holds a child's permission wait in one slot on the displaced main agent record (waitingAgentId, stateBeforeWait), not on the child. It publishes the displaced state as mainAgent, but the next main agent event overwrites the slot, so the row stops reading waiting while the child is still asking, and a second asking child replaces the first.
  • The structured lane has no per-child wait: a child's pending prompt makes the session attention, which reads as the main agent's own blocked.
  • The Codex hook lane drops its roster on a root Stop when it tracks no child transcripts, so a still-running or still-asking child stops holding the row.

How the main agent's turn ended is not a fold input. A cancel is a verdict on the main agent, carried as mainAgent.outcome: 'cancellation' (and, for readers that predate mainAgent, as the row's interrupted flag on a done row); it never retires a shell, scheduled check or subagent the turn left running. That work leaves the row only when its own inventory omits it or the session ends, so a cancelled turn with a still-running shell reads monitoring in every lane, and the parity table in src/shared/main-agent-status-parity.test.ts drives that story through all of them. The same rule governs the cancel Orca infers from Ctrl+C: for any row that publishes mainAgent, the inference is admitted only when mainAgent.state is working, so Orca does not treat a Ctrl+C at the idle prompt of a row held open by child work as a turn cancel (Codex also keeps the child-evidence guard, and a row without mainAgent keeps only that guard). The keypress itself is not inert, though: measured live, Claude 2.1.280 stops its background subagents on a single idle-prompt Ctrl+C (shells survive) and Codex 0.156.1 quits outright, so refusing the inference can leave the row showing a subagent its CLI already stopped. The synthesized row is the fold of the cancelled main agent with the child work the pane's owner can see: the local listener's roster for a local pane, the row's own subagents and shell fact for a relayed one, whose provider records live on the relay.

The store holds that verdict against restatements that predate it (server-cancel-verdict-latch.ts), because a relay never learns of a cancel the desktop infers and some TUIs emit late same-turn hooks. The hold is read off the row (mainAgent.outcome: 'cancellation'), never stored beside it, and dies on a new turn (a main agent prompt submission, a changed or explicit prompt, a session start) or the provider's own settled mainAgent. Child and replayed events under the hold keep the cancelled main agent and are re-folded with their own child evidence.

PR 1b: the runtime's retained row store is deleted

Landed. RuntimeAgentRowStore is gone, and with it the retained-versus-hook reconciliation in collectRuntimeWorktreePtyAgentSources. The hook server's store is now the only main-process copy of a PTY agent's row.

The five call sites

Call site Before After
orca-runtime-create-terminal-side-effect-command-code-detector.ts retain() second write of the OSC payload already sent to the hook server deleted; the event now carries the pane's terminalHandle and the hook ingest keeps the only copy
...command-code-detector.ts clearPty() drops rows on pty exit deleted; pane teardown already clears the hook row
orca-runtime-get-worktree-ps.ts values() fed retainedSnapshots deleted; the reader keeps only hookSnapshots
orca-runtime-serialize-agent-prompt-submission.ts getFreshExplicit() retained row first, hook rows second selectFreshExplicitAgentStatus, hook rows only
orca-runtime-prune-mobile-session-tab-group-layout.ts getFreshForMobile() pane key, then pty id selectFreshAgentRowForMobileTab: pane key, then terminalHandle

Both readers moved into runtime-hook-agent-row-selection.ts, which also owns RuntimeAgentRowSnapshot now that nothing retains one.

terminalHandle is the row's join back to its terminal

The retained store's only real extra was the pty id, and two readers used it. The plan said to stamp the event's ptyId into terminalHandle; that was wrong. A terminal handle (term_<uuid>) and a pty id are different identifiers, and getFreshExplicit was already comparing hook rows against a real handle. What landed instead:

  • AgentHookEventPayload and the runtime's terminal-status event gained an optional terminalHandle. The detector resolves it once per chunk through getAgentStatusTerminalHandleForPaneKey — the same lookup the renderer-facing IPC boundary already runs for every row, so the two surfaces cannot disagree about which terminal a pane is.
  • applyNormalizedStatus carries the handle forward when an incoming event resolves none. Only main's OSC parse can resolve one, so an HTTP hook post for the same pane would otherwise erase it.
  • It is never persisted. A handle belongs to the runtime that issued it, and a hydrated one could only rejoin a row to somebody else's terminal.
  • toAgentStatusIpcPayload publishes it, which also makes getFreshExplicit's long-dead handle comparison live: the runtime reads raw snapshot rows, and before this nothing ever stamped the field on them.

worktree ps uses it too. ConnectedPtyEvidence traded its flat ptyIds set for ptyIdByTerminalHandle, so a row still resolves the connected PTY behind it — which is both the working-terminal rollup's match key and the last rescue for a row whose pane binding was nulled by a controller incarnation change.

The change detector had to move with the store

retain() was not only a store: its boolean return was the signal that republished session.tabs for a status-only transition, which no title change covers (#7970). hook-status-session-tabs-invalidation.ts already mirrors that projection change set, including restore provenance and terminal-handle joins, so the replacement was to route the signal off the store rather than build a second comparator. installHookStatusSessionTabsRepublish now owns all three arms — enriched status, pane clear, and the status-drop tap a dismissal emits — and both hosts install it.

Both hosts, not just the desktop one

orcad constructed its runtime with no onTerminalAgentStatus, so main's OSC parse never reached the store there and the retained copy was the only carrier. Deleting it without wiring orcad would have made a headless host list no PTY agents at all. orcad-entry.ts now binds the producer and installs the republish signal, alongside the snapshot and structured sink it already had.

The intended behavior change

A row the user dismisses on the desktop leaves worktree ps and the phone at once, instead of lingering until the pty exits. One store means one dismissal.

Legacy numeric pane keys remain a bounded compatibility case. Persisted layouts register aliases to their stable leaf owners; an in-process OSC observation may also retain a numeric key only when the runtime supplies the matching tab, PTY, and terminal handle. HTTP and relay ingress still require a stable key or a registered alias, and numeric rows are never persisted.

PR 2: the renderer subscribes

With structured rows arriving over agentStatus:set, the renderer's StructuredAgentSessionStatusBridge no longer needs to write status; its unmount cleanup becomes a tab-close signal to the host. The IPC applicator is the single writer for observed status. The 2026-09-09 audit sorted the other writers:

Writer Disposition
Command Code output seeds, parked-pane seeds, pty-exit removal delete; main already emits the same facts
structured bridge status writes delete; main now publishes the row
launch placeholder seeds (a user launched an agent with a prompt) keep for now; main holds the launch config and can seed later
dismissal, acknowledgement, unmount keep; user facts and component lifecycle
remote-runtime OSC parse (bytes never transit local main) keep, fenced behind the host's published row once the host is new enough; rule 3 of the wire doc applies
web-session mirror receipt clock keep; the decay rule needs both clocks from one machine

The Command Code done-settle window is renderer policy with no main equivalent. PR 2 either moves it into main's detector or leaves it, and says which.

PR 3: one rollup, one clock

The worktree card status is derived three times: lib/worktree-status.ts in the renderer, runtime-worktree-status-projection.ts in main, and agent-row-display.ts in mobile, which hand-copies the 30-minute constant. PR 3 moves the rollup and the decay into src/shared and makes all three call it.

What does not change

  • The hook scripts, the OSC 9999 wire format, and the relay protocol.
  • The status vocabulary. working / blocked / done for rows, working / attention / idle for structured summaries, mapped once.
  • The live / unverifiable / exited verdicts for remote work. Loss of contact clears nothing; the SSH exemptions in the admission gate stay.
  • Hydration honesty: a restored non-done row is restoredUnconfirmed and is never fresh.

PR 1b reliability contract

  • Invariant (agent-session.status-host-ownership): each execution host has one agent-status store; OSC, hooks, and structured sessions write it, while desktop, worktree ps, and mobile only project it. Dismissal, certified PTY exit, and provider-generation replacement remove the same row everywhere; transport loss alone removes nothing.
  • Failure source: the deleted runtime row store duplicated OSC observations, keyed them by a different terminal identity, and outlived a dismissal from the hook store. Relay replay could also make old evidence look fresh when readers used its new delivery timestamp.
  • Oracle: one OSC observation appears through the hook snapshot in worktree ps and mobile, and one store dismissal removes it from both without stopping the PTY. Focused tests also require leaf/incarnation-handle rejoin, legacy numeric-pane compatibility, certified-exit and provider-generation cleanup, evidence-age freshness, and exactly-once startup/stop teardown.
  • Gate: terminal-performance.osc-status-scan-budget covers the unchanged bounded OSC parser and the runtime projection. There is not yet a dedicated blocking multi-surface status-store gate; the focused suites below are the accepted gap until they accumulate reliability-gate soak evidence.
  • Provider/platform coverage: local and daemon-backed PTYs are covered by runtime tests, and SSH relay loss/replay semantics by relay integration tests. The projection is shared by git worktrees and folder workspaces. WSL uses the same store and admission code but has no live run here; Linux and Windows runtime execution, native mobile clients, and mixed-version paired clients remain validation gaps.
  • Performance budget: publication stays event-driven with no new polling or subprocesses. One mobile projection clones the status snapshot once, builds pane/handle indexes once, and has a deterministic call-count test; lifecycle cleanup is bounded by the existing status and handle inventories, and orcad tests prove listeners clean up once on failed startup and repeated stop.
  • Diagnostics: existing hook-listener errors name the pane and PTY, while status-store tests pin delivery versus evidence clocks. No new telemetry or raw terminal data is emitted.
  • Residual gaps: rendered Electron/mobile behavior, live SSH reconnect, and Linux/Windows/WSL execution require the platform QA pass. The current cross-version gate does not cover session.tabs content.

Verification

  • Unit: ingest a structured summary and read it back through getStatusSnapshot, worktree ps, and the mobile projection; assert the serializer never writes a row carrying structuredHost; assert a hydrated file that somehow contains one is dropped.
  • Unit: the worktree ps suites written against the retained store are rewired to a real AgentHookServer (agent-status-store-wiring.test-fixture.ts) rather than deleted, so each still asserts the listing behavior it named. The dismissal change is pinned end to end in orca-runtime-tests/worktree-ps-agent-row-dismissal.spec.ts, which fails with the retained store restored.
  • Live: the parity check from #19217 (working, done, close, reload) repeated against the merged store, with both surfaces read from the one row.

Retired OMP pane recovery

A desktop renderer retirement carries an optional UUID through the existing agentStatus:retirePaneAuthority IPC message. The hook server retains it with its bounded retirement fence. A validated live OMP new turn consumes that UUID and echoes authorityRestartId only in the live notification. Cached rows, persistence and startup replay never carry the acknowledgement. Older peers omit or ignore it and retain explicit attach restoration.

The renderer keeps the UUID in its existing non-persisted retirement tombstone; every re-retirement mints a new one. A matching acknowledgement may clear that tombstone only with a successful status write for the existing pane and matching workspace/connection. Closed tombstones remain true, including after the tab LRU evicts its entry. Closing a retired physical alias revokes its whole group. This is control-plane retirement correlation, not a second agent-status store.

Fallback restores the hook server's recorded status aliases through the existing attach-restoration path. The accepted renderer write restores the matching status alias routes too, preserving group membership for the next retirement. It does not restore orchestration or launch credentials. It is scoped to the requesting desktop renderer. A different window's retirement UUID cannot be cleared by the acknowledgement, and web mirrors keep their existing host-snapshot/attach behavior.