Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
4c31837
feat(agent-status): publish the lead agent's own state beside the com…
brennanb2025 Sep 23, 2026
f85e424
fix(agent-status): make Orca's inferred interrupt the primary source …
brennanb2025 Sep 23, 2026
c644a1c
docs(agent-status): the child-only boundary comment now describes the…
brennanb2025 Sep 23, 2026
662d101
rename the lead fact to mainAgent: the main agent's own state
brennanb2025 Sep 23, 2026
2b24918
docs(agent-status): the inferred cancel comes from Ctrl+C, not Esc
brennanb2025 Sep 23, 2026
a0a59fb
fix(agent-status): an inferred interrupt keeps an already settled mai…
brennanb2025 Sep 23, 2026
8bbe6d4
fix(agent-status): a child-induced wait publishes the main agent stat…
brennanb2025 Sep 23, 2026
699837b
fix(agent-status): decide child-held Claude rows from the saved main …
brennanb2025 Sep 23, 2026
e501902
fix(agent-status): keep a finished turn's main agent verdict and cloc…
brennanb2025 Sep 23, 2026
c45d833
test(agent-status): publish the Grok verdict restatement past the lat…
brennanb2025 Sep 23, 2026
fc0fb44
docs(agent-status): describe hydrate seeding and the OSC refusal from…
brennanb2025 Sep 23, 2026
59b54ac
fix(agent-status): push a held child permission row when its main age…
brennanb2025 Sep 23, 2026
b9c5ebb
fix(agent-status): keep the shell fact on a held child permission row…
brennanb2025 Sep 23, 2026
5ffe00f
docs(agent-status): note the held child permission row carries the sh…
brennanb2025 Sep 23, 2026
ded33b7
fix(agent-status): pair the Claude shell fact with the main agent at …
brennanb2025 Sep 23, 2026
5f4bc28
test(agent-status): pin that restart never seeds a main agent from a …
brennanb2025 Sep 23, 2026
bef80c8
docs(agent-status): the row builder pairs the shell fact with the mai…
brennanb2025 Sep 23, 2026
824185d
test(agent-status): name the legacy-row case parameter for what it holds
brennanb2025 Sep 23, 2026
df41109
docs(agent-status): name which rows carry the main agent fact
brennanb2025 Sep 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions docs/reference/agent-status-store.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ ingests the summary into the hook server as a status row:
| `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 |

Expand Down Expand Up @@ -187,6 +188,64 @@ 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`:

```ts
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.

Two combining rules remain outside the shared fold and are named so a reader
does not mistake them for drift:

- Codex keeps `codexRosterEffectiveState` for its combined `state` (a waiting
child wins, a settled root with any live child reads `working`, never
monitoring) and publishes `mainAgent` from its root record; moving that combine
onto the fold needs a waiting-child input the fold does not have yet.
- A cancelled turn with a still-running shell reads `done` in the hook lane
and `monitoring` in the structured lane. The parity table in
`src/shared/main-agent-status-parity.test.ts` pins this as a known
divergence; the cancel policy that removes it flips that row.

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

Landed. `RuntimeAgentRowStore` is gone, and with it the retained-versus-hook
Expand Down
51 changes: 51 additions & 0 deletions src/main/agent-hooks/server-ingest-structured-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,3 +378,54 @@ describe('structured rows and last-status.json', () => {
}
})
})

describe('the main agent fact on a structured row', () => {
it('publishes the main agent beside the folded state, on the journal clock with continuity', () => {
const server = new AgentHookServer()
server.ingestStructuredStatus(
summary({ status: 'idle', backgroundTasks: [{ id: 'c', kind: 'agent', state: 'working' }] }),
SUBJECT
)
expect(server.getStatusSnapshot()[0]).toMatchObject({
state: 'working',
mainAgent: { state: 'done', stateStartedAt: OBSERVED_AT }
})
expect(server.getStatusSnapshot()[0]?.mainAgent).not.toHaveProperty('outcome')

// The main agent is still done while its child drains: the main agent's clock does not move.
server.ingestStructuredStatus(
summary({ status: 'idle', updatedAt: OBSERVED_AT + 5, turnOutcome: 'failure' }),
SUBJECT
)
expect(server.getStatusSnapshot()[0]).toMatchObject({
state: 'done',
stateStartedAt: OBSERVED_AT + 5,
mainAgent: { state: 'done', outcome: 'failure', stateStartedAt: OBSERVED_AT }
})

server.ingestStructuredStatus(
summary({ status: 'working', updatedAt: OBSERVED_AT + 9 }),
SUBJECT
)
expect(server.getStatusSnapshot()[0]?.mainAgent).toEqual({
state: 'working',
stateStartedAt: OBSERVED_AT + 9
})
})

it('reaches the enriched fanout every legacy subscriber reads', () => {
const server = new AgentHookServer()
const enriched = vi.fn()
server.subscribeEnrichedStatus(enriched)
server.ingestStructuredStatus(summary({ status: 'idle', turnOutcome: 'cancellation' }), SUBJECT)
expect(enriched).toHaveBeenCalledWith(
expect.objectContaining({
paneKey: STRUCTURED_PANE,
payload: expect.objectContaining({
state: 'done',
mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: OBSERVED_AT }
})
})
)
})
})
45 changes: 45 additions & 0 deletions src/main/agent-hooks/server-ingest-terminal-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,3 +443,48 @@ describe('AgentHookServer ingestTerminalStatus', () => {
expect(server.getStatusSnapshot()).toEqual([])
})
})

describe('the main agent fact across an OSC repaint', () => {
it('carries the hook row main agent while OSC repaints the same state, and drops it on a state edge', () => {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
source: 'claude',
hookEventName: 'Stop',
payload: {
state: 'working',
workingMode: 'monitoring',
prompt: 'watch the build',
agentType: 'claude',
mainAgent: { state: 'done', stateStartedAt: 10 }
}
},
'conn-1'
)
server.ingestTerminalStatus({
paneKey: PANE,
connectionId: 'conn-1',
payload: {
state: 'working',
prompt: 'watch the build',
agentType: 'claude',
toolName: 'Bash'
}
})
expect(server.getStatusSnapshot()[0]).toMatchObject({
state: 'working',
toolName: 'Bash',
mainAgent: { state: 'done', stateStartedAt: 10 }
})

// OSC cannot date a turn edge: a different state is a main agent it has no fact about.
server.ingestTerminalStatus({
paneKey: PANE,
connectionId: 'conn-1',
payload: { state: 'done', prompt: 'watch the build', agentType: 'claude' }
})
expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done' })
expect(server.getStatusSnapshot()[0]).not.toHaveProperty('mainAgent')
})
})
81 changes: 81 additions & 0 deletions src/main/agent-hooks/server-interrupt-inference-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,3 +362,84 @@ describe('AgentHookServer listener replay', () => {
}
})
})

describe('the main agent fact on an inferred interrupt', () => {
it('publishes the synthesized done as a cancelled main agent turn', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
try {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', prompt: 'long task', agentType: 'claude' }
},
'conn-1'
)
const baseline = server.getStatusSnapshot()[0]
vi.setSystemTime(1_500)
expect(
server.inferInterrupt({
paneKey: PANE,
baselineUpdatedAt: baseline.receivedAt,
baselineStateStartedAt: baseline.stateStartedAt,
baselinePrompt: 'long task',
baselineAgentType: 'claude',
intent: 'ctrl-c'
})
).toBe(true)
expect(server.getStatusSnapshot()[0]).toMatchObject({
state: 'done',
interrupted: true,
mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: 1_500 }
})
} finally {
vi.useRealTimers()
}
})

it('keeps an already settled main agent behind a watch loop as it was', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
try {
const server = new AgentHookServer()
const settled = { state: 'done' as const, stateStartedAt: 800 }
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: {
state: 'working',
workingMode: 'monitoring',
prompt: 'watch it',
agentType: 'grok',
mainAgent: settled
}
},
'conn-1'
)
const baseline = server.getStatusSnapshot()[0]
vi.setSystemTime(1_500)
expect(
server.inferInterrupt({
paneKey: PANE,
baselineUpdatedAt: baseline.receivedAt,
baselineStateStartedAt: baseline.stateStartedAt,
baselinePrompt: 'watch it',
baselineAgentType: 'grok',
intent: 'ctrl-c'
})
).toBe(true)
expect(server.getStatusSnapshot()[0]).toMatchObject({
state: 'done',
interrupted: true,
mainAgent: settled
})
} finally {
vi.useRealTimers()
}
})
})
Loading
Loading