* refactor(agent-status): publish structured sessions into the hook server store Structured (native chat) sessions have no PTY and no hook script, so their status never reached the hook server's store; #19217 gave `worktree ps` its own adapter over the structured feed instead. The feed now writes every projection into that store through a status sink the runtime wires, drops the row when the host closes the session, and `worktree ps` reads the one snapshot like every other agent. Rows carry a `structuredHost` marker and the journal clock; they are never persisted to last-status.json, and the main process does not forward them to the renderer yet, whose feed bridge still owns them until it is retired. Design and the two follow-ups: docs/reference/agent-status-store.md. * chore: drop stray @pnpm/exe lockfile entry An unrelated local pnpm run added @pnpm/exe as a packageManagerDependency with no package.json change, so CI's --frozen-lockfile install failed before any job ran. * docs(agent-status): describe the step that actually landed The design record claimed PR 1 deletes RuntimeAgentRowStore, drops the retained-versus-hook reconciliation, stamps terminalHandle on OSC rows, and tags rows with a source field of 'structured-host'. None of that is true of the shipped code: the retained store and its reconciliation are still in place, and the row field is structuredHost: 'held' | 'owned'. AGENTS.md points every future contributor here before they touch agent status, so split the roadmap into the 1a that landed and the 1b that has not, and name the fields the code actually writes. * fix(agent-status): pair session removal with the status-row forget A session dropped from the host's map without an explicit forget left its row in the store forever: `structuredHostOwned` bypasses the staleness check, so a failed re-attach (the Claude rewind path reaches one) stranded a permanently working agent in `worktree ps` and on mobile with no UI able to clear it. Deletion and forget are now one operation both callers route through. * fix(agent-status): give orcad the store worktree ps reads from `orcad` constructed its runtime with neither `getAgentStatusSnapshot` nor `structuredAgentStatusSink`, so once `worktree ps` sourced rows only from that snapshot the headless host published nowhere and listed nothing. The hook server's store is a module singleton whose import tree never reaches Electron, and its file paths come from `start()`, which orcad never calls. * fix(agent-status): drop a structured row without a renderer clear `dropStructuredStatus` went through `clearPaneState`, which fans a pane clear out to the renderer for a pane key the renderer's own feed bridge still writes - so 'exactly one writer per pane key' held for writes and not for deletes. `dropStatusEntry` routes through the status-drop tap instead, and skips the resume-identity remnant: a structured session has no pane to resume into, and every null-status publish would otherwise re-mint one. * test(agent-status): pin both half-migration structured-row filters Neither the `agentStatus:getSnapshot` filter nor the main-window listener's had a single assertion, so deleting either — the first step of PR 2 — was green everywhere. Also covers the perf skip and the drop's lack of a renderer clear. * docs(agent-status): correct three statements this PR made false The sink JSDoc claimed only tests construct a host without one; `orcad` did. The doc argued a structured row needs no tab mirror 'because headless serve has no renderer', reasoning about exactly the topology the wiring had not reached. The deleted runtime adapter's warning that the pane key must be the DERIVED one - never a bearer handle or minted worker key - was lost with it. * test(agent-status): declare orcad in the hook-row producer census Wiring the hook store into the orcad runtime added a production site that hands hook rows to a consumer, which the census ratchet pins deliberately. --------- Co-authored-by: Merge Sim <sim@local>
14 KiB
Agent status store
Status
Proposed on 2026-09-09 as the follow-up to #19217. It lands in four steps, in this order, each independently shippable:
- main-only: every producer writes into one store and
worktree psreads it, split into 1a (structured sessions join the store) and 1b (the runtime's duplicate retained store is deleted); - renderer: the sidebar becomes a subscriber and stops re-deriving rows;
- shared: one worktree-status rollup and one freshness rule for every reader.
The PR that carries this document is PR 1a. Sections below are grouped under the step that delivers them; only PR 1a has landed.
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 | src/main/runtime/runtime-agent-row-store.ts |
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
applyNormalizedStatuspath, stamped with the authority idmain-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
restoredUnconfirmedstamp that keeps a hydrated row from ever reading as live truth; - it already fans out to both renderer windows over
agentStatus:setandagentStatus:clear, and servesagentStatus: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 |
structuredAgentSessionStatusState(summary.status), the mapping #19217 shared |
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.jsonwould hydrate asrestoredUnconfirmedand then fight the live republish. The serializer skips rows carryingstructuredHost, 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
stays until PR 1b removes the store that feeds 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
structuredHostis 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 andorca serve) sharemain-process-runtime-service.ts, andorcadconstructs its own runtime insrc/main/orcad/orcad-entry.ts. A host missing that wiring lists no agents at all, not just no structured ones, becauseworktree psreads 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.
PR 1b: the runtime's retained row store is deleted
Not yet implemented; RuntimeAgentRowStore and the retained-versus-hook
reconciliation it feeds are both still in place after PR 1a.
RuntimeAgentRowStore keeps the same payload the hook server already holds.
Its only extra is the pty id, used to clear rows on exit and as a fallback key
for the mobile projection. PR 1b will stamp terminalHandle on OSC-ingested
rows from the runtime event's ptyId, and rewrite the three readers over the
hook server's snapshot:
worktree psreadsgetStatusSnapshot()directly;getFreshExplicitalready consults hook rows; it drops the retained input;getFreshForMobilematches on pane key, then onterminalHandle.
One behavior change will follow and is intended: a row the user dismisses on
the desktop disappears from worktree ps and the phone at the same time,
instead of lingering until the pty exits.
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 / donefor rows,working / attention / idlefor structured summaries, mapped once. - The
live / unverifiable / exitedverdicts for remote work. Loss of contact clears nothing; the SSH exemptions in the admission gate stay. - Hydration honesty: a restored non-done row is
restoredUnconfirmedand is never fresh.
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 carryingstructuredHost; assert a hydrated file that somehow contains one is dropped. - Unit: the existing
worktree pssuites pass unchanged, which is the characterization that will show PR 1b's deletion of the retained store changed no listing. - Live: the parity check from #19217 (working, done, close, reload) repeated against the merged store, with both surfaces read from the one row.