From 4c3183795e02f0944ee7e20db85308a311c2232f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 00:26:37 -0700 Subject: [PATCH 01/19] feat(agent-status): publish the lead agent's own state beside the combined row state Every status producer folded the main agent's state together with live child work into one `state`, so a lead that had finished while a subagent still ran read `working` and its own state was lost. The row now also carries `lead: { state, outcome?, stateStartedAt }`, admitted by the one payload normalizer on the relay wire, IPC and disk, and published from the Claude hook lane, the structured host ingest and renderer bridge, Grok (now on the shared fold) and Codex (own combine kept). The persisted child-only boundary flag is derived from `lead` plus child evidence and no longer written; old rows map onto `lead` at hydrate. Combined `state` and `workingMode` are unchanged for every reader; a cross-lane parity table pins that, with the cancelled-turn watch-loop story recorded as a known divergence. --- docs/reference/agent-status-store.md | 42 +++ .../server-ingest-structured-status.test.ts | 51 +++ .../server-ingest-terminal-status.test.ts | 45 +++ ...ver-interrupt-inference-validation.test.ts | 38 ++ .../server-last-status-lead-boundary.test.ts | 46 +-- .../server-last-status-lead-fact.test.ts | 194 ++++++++++ .../server-last-status-write.test.ts | 9 +- .../server/server-claude-status-rules.ts | 52 +-- .../agent-hooks/server/server-hydration.ts | 3 +- .../server/server-ingest-structured.ts | 18 +- .../server/server-ingest-terminal.ts | 17 +- .../server/server-persistence-validation.ts | 33 +- .../agent-hooks/server/server-persistence.ts | 5 +- .../server/server-status-inference.ts | 7 +- .../server/server-status-update.ts | 23 +- src/main/agent-hooks/server/server-types.ts | 6 +- .../terminal-handle-row-identity.test.ts | 8 +- .../structured-agent-session-status-feed.ts | 1 + .../runtime-agent-row-lead-projection.test.ts | 32 ++ ...tructuredAgentSessionStatusBridge.test.tsx | 60 +++ .../StructuredAgentSessionStatusBridge.tsx | 17 +- .../normalize-agent-status-event.ts | 3 +- .../slices/agent-status-lead-fact.test.ts | 60 +++ .../slices/agent-status-live-entry-builder.ts | 3 + .../slices/agent-status-live-entry-lead.ts | 23 ++ ...ent-hook-listener-claude-subagents.test.ts | 15 +- ...nt-hook-listener-claude-turn-state.test.ts | 51 +++ .../agent-hook-listener-codex-lead.test.ts | 53 +++ ...stener-extraction-characterization.test.ts | 6 +- .../agent-hook-listener/lead-turn-state.ts | 33 ++ .../agent-hook-listener/listener-state.ts | 32 +- .../providers/claude-events.ts | 33 +- .../providers/claude-lifecycle-events.ts | 7 +- .../providers/claude-roster-state.ts | 97 +++-- .../providers/claude-status-build.ts | 6 +- .../providers/codex-events.ts | 11 +- .../providers/codex-state.ts | 64 +++- .../providers/grok-events.ts | 62 ++- src/shared/agent-lead-status-fold.test.ts | 46 ++- src/shared/agent-lead-status-fold.ts | 29 +- src/shared/agent-lead-status-parity.test.ts | 355 ++++++++++++++++++ src/shared/agent-lead-status.ts | 31 ++ src/shared/agent-session-journal-types.ts | 10 +- src/shared/agent-session-wire.ts | 14 +- src/shared/agent-status-types.test.ts | 52 +++ src/shared/agent-status-types.ts | 42 ++- src/shared/agent-turn-outcome.ts | 12 + ...uctured-agent-session-agent-status.test.ts | 40 +- .../structured-agent-session-agent-status.ts | 22 +- ...tructured-agent-session-projection.test.ts | 40 ++ .../structured-agent-session-projection.ts | 14 +- 51 files changed, 1735 insertions(+), 238 deletions(-) create mode 100644 src/main/agent-hooks/server-last-status-lead-fact.test.ts create mode 100644 src/main/runtime/runtime-agent-row-lead-projection.test.ts create mode 100644 src/renderer/src/store/slices/agent-status-lead-fact.test.ts create mode 100644 src/renderer/src/store/slices/agent-status-live-entry-lead.ts create mode 100644 src/shared/agent-hook-listener-codex-lead.test.ts create mode 100644 src/shared/agent-hook-listener/lead-turn-state.ts create mode 100644 src/shared/agent-lead-status-parity.test.ts create mode 100644 src/shared/agent-lead-status.ts create mode 100644 src/shared/agent-turn-outcome.ts diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index 97fde3f7b912..94bbee128362 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -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 | +| `lead` | the lead's own state before the fold, its last-turn verdict (`summary.turnOutcome`, present only while idle) and its own clock; see "The lead 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 | @@ -187,6 +188,47 @@ 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 lead fact + +Every producer publishes the row's combined `state` and, beside it, the lead +agent's own state as `payload.lead`: + +```ts +lead?: { state: AgentStatusState; outcome?: AgentJournalTurnOutcome; stateStartedAt: number } +``` + +`state` still answers "what should the user see" and folds live child work in, +so a settled lead whose subagent still runs reads `working`. `lead` answers +"what is the lead itself doing", which the fold used to destroy at publish +time; every guard that reconstructed a fragment of it (`fromChildWork`, the +persisted `claudeLeadBoundaryChildOnly` flag) is now derived from `lead` plus +the row's child evidence instead of stored. `outcome` is the provider's verdict +on the lead's most recent finished turn, present only while `lead.state` is +`done`; 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. + +Admission is one function, `normalizeAgentStatusPayload`, on the relay wire, +IPC and disk. A malformed `lead` 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 `lead: { state: 'done' }` when the row has no `lead`, and never writes the +flag again. `claudeRunningNonAgentTask` is persisted alongside because it is +the one child-work fact `lead` cannot express: whether a shell was running +beside the child agents, which decides whether a settled lead may be seeded +at hydrate. + +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 `lead` 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/agent-lead-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 diff --git a/src/main/agent-hooks/server-ingest-structured-status.test.ts b/src/main/agent-hooks/server-ingest-structured-status.test.ts index 1b732f7021f1..bb09156483f0 100644 --- a/src/main/agent-hooks/server-ingest-structured-status.test.ts +++ b/src/main/agent-hooks/server-ingest-structured-status.test.ts @@ -378,3 +378,54 @@ describe('structured rows and last-status.json', () => { } }) }) + +describe('the lead fact on a structured row', () => { + it('publishes the lead 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', + lead: { state: 'done', stateStartedAt: OBSERVED_AT } + }) + expect(server.getStatusSnapshot()[0]?.lead).not.toHaveProperty('outcome') + + // The lead is still done while its child drains: the lead'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, + lead: { state: 'done', outcome: 'failure', stateStartedAt: OBSERVED_AT } + }) + + server.ingestStructuredStatus( + summary({ status: 'working', updatedAt: OBSERVED_AT + 9 }), + SUBJECT + ) + expect(server.getStatusSnapshot()[0]?.lead).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', + lead: { state: 'done', outcome: 'cancellation', stateStartedAt: OBSERVED_AT } + }) + }) + ) + }) +}) diff --git a/src/main/agent-hooks/server-ingest-terminal-status.test.ts b/src/main/agent-hooks/server-ingest-terminal-status.test.ts index f54a5c268026..bb910b26669a 100644 --- a/src/main/agent-hooks/server-ingest-terminal-status.test.ts +++ b/src/main/agent-hooks/server-ingest-terminal-status.test.ts @@ -443,3 +443,48 @@ describe('AgentHookServer ingestTerminalStatus', () => { expect(server.getStatusSnapshot()).toEqual([]) }) }) + +describe('the lead fact across an OSC repaint', () => { + it('carries the hook row lead 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', + lead: { 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', + lead: { state: 'done', stateStartedAt: 10 } + }) + + // OSC cannot date a turn edge: a different state is a lead 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('lead') + }) +}) diff --git a/src/main/agent-hooks/server-interrupt-inference-validation.test.ts b/src/main/agent-hooks/server-interrupt-inference-validation.test.ts index 971da33b22c1..4a78a03d0c0a 100644 --- a/src/main/agent-hooks/server-interrupt-inference-validation.test.ts +++ b/src/main/agent-hooks/server-interrupt-inference-validation.test.ts @@ -362,3 +362,41 @@ describe('AgentHookServer listener replay', () => { } }) }) + +describe('the lead fact on an inferred interrupt', () => { + it('publishes the synthesized done as a cancelled lead 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, + lead: { state: 'done', outcome: 'cancellation', stateStartedAt: 1_500 } + }) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/agent-hooks/server-last-status-lead-boundary.test.ts b/src/main/agent-hooks/server-last-status-lead-boundary.test.ts index 33e177910aaf..9ba363bae093 100644 --- a/src/main/agent-hooks/server-last-status-lead-boundary.test.ts +++ b/src/main/agent-hooks/server-last-status-lead-boundary.test.ts @@ -3,8 +3,14 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { AgentHookServer, _internals } from './server' +import { isClaudeLeadBoundaryHeldByChildrenOnly } from './server/server-claude-status-rules' import { buildBody, postHookEvent, PANE, RUNNING_SHELL } from './server.test-fixtures' +function heldByChildrenOnly(server: AgentHookServer): boolean | undefined { + const row = server._getStateForTests().lastStatusByPaneKey.get(PANE) + return row ? isClaudeLeadBoundaryHeldByChildrenOnly(row) : undefined +} + const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ getCohortAtEmitMock: vi.fn(), trackMock: vi.fn() @@ -186,24 +192,12 @@ describe('Persisted Claude lead boundaries', () => { buildBody({ hook_event_name: 'SubagentStart', agent_id: 'achildb' }) ) await postHookEvent(firstServer, buildBody({ hook_event_name: 'Stop' })) - expect( - ( - firstServer._getStateForTests().lastStatusByPaneKey.get(PANE) as - | { claudeLeadBoundaryChildOnly?: true } - | undefined - )?.claudeLeadBoundaryChildOnly - ).toBe(true) + expect(heldByChildrenOnly(firstServer)).toBe(true) await postHookEvent( firstServer, buildBody({ hook_event_name: 'SubagentStop', agent_id: 'achilda' }) ) - expect( - ( - firstServer._getStateForTests().lastStatusByPaneKey.get(PANE) as - | { claudeLeadBoundaryChildOnly?: true } - | undefined - )?.claudeLeadBoundaryChildOnly - ).toBe(true) + expect(heldByChildrenOnly(firstServer)).toBe(true) firstServer.flushStatusPersistSync() firstServer.stop() @@ -253,13 +247,7 @@ describe('Persisted Claude lead boundaries', () => { buildBody({ hook_event_name: 'PreToolUse', tool_name: 'Read' }) ) expect(firstServer.getStatusSnapshot()[0]).toMatchObject({ state: 'waiting', toolName: 'Bash' }) - expect( - ( - firstServer._getStateForTests().lastStatusByPaneKey.get(PANE) as - | { claudeLeadBoundaryChildOnly?: true } - | undefined - )?.claudeLeadBoundaryChildOnly - ).toBeUndefined() + expect(heldByChildrenOnly(firstServer)).toBe(false) await postHookEvent( firstServer, buildBody({ hook_event_name: 'SubagentStop', agent_id: 'achild-a' }) @@ -349,24 +337,12 @@ describe('Persisted Claude lead boundaries', () => { buildBody({ hook_event_name: 'PreToolUse', tool_name: 'Read' }) ) expect(firstServer.getStatusSnapshot()[0]).toMatchObject({ state: 'waiting', toolName: 'Bash' }) - expect( - ( - firstServer._getStateForTests().lastStatusByPaneKey.get(PANE) as - | { claudeLeadBoundaryChildOnly?: true } - | undefined - )?.claudeLeadBoundaryChildOnly - ).toBeUndefined() + expect(heldByChildrenOnly(firstServer)).toBe(false) await postHookEvent( firstServer, buildBody({ hook_event_name: 'SubagentStop', agent_id: 'achilda' }) ) - expect( - ( - firstServer._getStateForTests().lastStatusByPaneKey.get(PANE) as - | { claudeLeadBoundaryChildOnly?: true } - | undefined - )?.claudeLeadBoundaryChildOnly - ).toBeUndefined() + expect(heldByChildrenOnly(firstServer)).toBe(false) firstServer.flushStatusPersistSync() firstServer.stop() diff --git a/src/main/agent-hooks/server-last-status-lead-fact.test.ts b/src/main/agent-hooks/server-last-status-lead-fact.test.ts new file mode 100644 index 000000000000..86197029f4a9 --- /dev/null +++ b/src/main/agent-hooks/server-last-status-lead-fact.test.ts @@ -0,0 +1,194 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { AgentHookServer, _internals } from './server' +import { buildBody, postHookEvent, recentTs, PANE } from './server.test-fixtures' + +const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ + getCohortAtEmitMock: vi.fn(), + trackMock: vi.fn() +})) + +vi.mock('../telemetry/client', () => ({ track: trackMock })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: getCohortAtEmitMock })) + +beforeEach(() => { + _internals.resetCachesForTests() + trackMock.mockReset() + getCohortAtEmitMock.mockReset() + getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +// The lead fact rides inside the persisted payload. Without these pins the field would look +// shipped while dying at every restart: hydration rebuilds only the fields it is taught. +describe('The lead fact across a restart', () => { + let userDataPath: string + + beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'orca-lead-fact-')) + }) + + afterEach(() => { + rmSync(userDataPath, { recursive: true, force: true }) + }) + + function lastStatusPath(): string { + return join(userDataPath, 'agent-hooks', 'last-status.json') + } + + function writeEntry(entry: Record): void { + mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) + writeFileSync( + lastStatusPath(), + JSON.stringify({ + version: 2, + entries: { [PANE]: { paneKey: PANE, tabId: 'tab-1', ...entry } } + }) + ) + } + + it('round-trips a settled lead held open by a child through disk and back', async () => { + const firstServer = new AgentHookServer() + await firstServer.start({ env: 'production', userDataPath }) + await postHookEvent( + firstServer, + buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'finish after child' }) + ) + await postHookEvent( + firstServer, + buildBody({ hook_event_name: 'SubagentStart', agent_id: 'arestored-child' }) + ) + await postHookEvent(firstServer, buildBody({ hook_event_name: 'Stop', is_interrupt: true })) + const live = firstServer.getStatusSnapshot()[0] + expect(live).toMatchObject({ + state: 'working', + lead: { state: 'done', outcome: 'cancellation', stateStartedAt: expect.any(Number) } + }) + firstServer.flushStatusPersistSync() + firstServer.stop() + const file = JSON.parse(readFileSync(lastStatusPath(), 'utf8')) + expect(file.entries[PANE].payload.lead).toEqual(live?.lead) + expect(file.entries[PANE]).not.toHaveProperty('claudeLeadBoundaryChildOnly') + + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'working', + restoredUnconfirmed: true, + lead: live?.lead + }) + // The seeded lead record is what lets the child's drain settle the pane, verdict intact. + await postHookEvent( + server, + buildBody({ hook_event_name: 'SubagentStop', agent_id: 'arestored-child' }) + ) + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'done', + interrupted: true, + lead: { state: 'done', outcome: 'cancellation', stateStartedAt: live?.lead?.stateStartedAt } + }) + } finally { + server.stop() + } + }) + + it('maps the legacy child-only flag onto an absent lead, dated by the turn end', async () => { + const receivedAt = recentTs() + writeEntry({ + receivedAt, + stateStartedAt: receivedAt - 5_000, + claudeLeadBoundaryChildOnly: true, + payload: { + state: 'working', + prompt: 'legacy row', + agentType: 'claude', + turnCompletedAt: receivedAt - 1_000, + subagents: [{ id: 'arestored-child', state: 'working', startedAt: receivedAt - 4_000 }] + } + }) + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'working', + lead: { state: 'done', stateStartedAt: receivedAt - 1_000 } + }) + await postHookEvent( + server, + buildBody({ hook_event_name: 'SubagentStop', agent_id: 'arestored-child' }) + ) + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'done', + lead: { state: 'done' } + }) + } finally { + server.stop() + } + }) + + it('prefers a persisted lead over the legacy flag when a row carries both', async () => { + const receivedAt = recentTs() + const lead = { state: 'done', outcome: 'cancellation', stateStartedAt: receivedAt - 2_000 } + writeEntry({ + receivedAt, + stateStartedAt: receivedAt - 5_000, + claudeLeadBoundaryChildOnly: true, + payload: { + state: 'working', + prompt: 'both', + agentType: 'claude', + turnCompletedAt: receivedAt - 1_000, + lead, + subagents: [{ id: 'arestored-child', state: 'working', startedAt: receivedAt - 4_000 }] + } + }) + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + expect(server.getStatusSnapshot()[0]?.lead).toEqual(lead) + } finally { + server.stop() + } + }) + + it('drops a malformed persisted lead and keeps the row', async () => { + const receivedAt = recentTs() + writeEntry({ + receivedAt, + stateStartedAt: receivedAt - 5_000, + payload: { state: 'done', prompt: 'survived', agentType: 'claude', lead: 'done' } + }) + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + const row = server.getStatusSnapshot()[0] + expect(row).toMatchObject({ state: 'done', prompt: 'survived' }) + expect(row?.lead).toBeUndefined() + } finally { + server.stop() + } + }) + + it('never invents a lead for a non-Claude row from the legacy flag', async () => { + const receivedAt = recentTs() + writeEntry({ + receivedAt, + stateStartedAt: receivedAt - 5_000, + claudeLeadBoundaryChildOnly: true, + payload: { state: 'working', prompt: 'codex row', agentType: 'codex' } + }) + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + expect(server.getStatusSnapshot()[0]?.lead).toBeUndefined() + } finally { + server.stop() + } + }) +}) diff --git a/src/main/agent-hooks/server-last-status-write.test.ts b/src/main/agent-hooks/server-last-status-write.test.ts index b0bfb7db9296..a81ffd49be97 100644 --- a/src/main/agent-hooks/server-last-status-write.test.ts +++ b/src/main/agent-hooks/server-last-status-write.test.ts @@ -87,8 +87,13 @@ describe('Last-status persistence', () => { expect(file.entries[PANE].launchTokenHash).toBe( createHash('sha256').update('launch-bearer-must-not-persist').digest('hex') ) - expect(file.entries[PANE].claudeRunningNonAgentTask).toBeUndefined() - expect(readFileSync(lastStatusPath(), 'utf8')).not.toContain('claudeRunningNonAgentTask') + // The shell fact is persisted: hydration reads it to decide whether a settled lead held + // open by children may be seeded, which `lead` alone cannot say (see server-types.ts). + expect(file.entries[PANE].claudeRunningNonAgentTask).toBe(true) + expect(file.entries[PANE].payload.lead).toEqual({ + state: 'done', + stateStartedAt: expect.any(Number) + }) expect(readFileSync(lastStatusPath(), 'utf8')).not.toContain('launch-bearer-must-not-persist') } finally { server.stop() diff --git a/src/main/agent-hooks/server/server-claude-status-rules.ts b/src/main/agent-hooks/server/server-claude-status-rules.ts index 9607e4a9dc83..efdfb6668acc 100644 --- a/src/main/agent-hooks/server/server-claude-status-rules.ts +++ b/src/main/agent-hooks/server/server-claude-status-rules.ts @@ -1,44 +1,24 @@ +import { isAgentStatusHeldOpenByChildWork } from '../../../shared/agent-lead-status-fold' import { claudeTeammateIdMatchesName } from '../../../shared/claude-subagent-roster' import { isAskUserQuestionTool } from '../../../shared/agent-question-answered-intent' import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' import type { EnrichedAgentHookEventPayload } from './server-types' -export function attachClaudeChildOnlyBoundary( - previous: EnrichedAgentHookEventPayload | undefined, - next: AgentHookEventPayload -): AgentHookEventPayload & { claudeLeadBoundaryChildOnly?: true } { - const establishesBoundary = - next.payload.agentType === 'claude' && - (next.hookEventName === 'Stop' || next.hookEventName === 'StopFailure') && - !next.toolAgentId && - next.payload.state === 'working' && - next.payload.subagents?.some((subagent) => subagent.state === 'working') === true && - next.claudeRunningNonAgentTask === false - const carriesBoundary = - previous?.claudeLeadBoundaryChildOnly === true && - next.payload.agentType === 'claude' && - next.claudeRunningNonAgentTask === false && - (next.toolAgentId !== undefined || - next.hookEventName === 'SubagentStart' || - next.hookEventName === 'SubagentStop' || - next.hookEventName === 'TeammateIdle') - return establishesBoundary || carriesBoundary - ? { ...next, claudeLeadBoundaryChildOnly: true } - : next -} - -export function invalidateClaudeChildOnlyBoundary( - previous: EnrichedAgentHookEventPayload | undefined, - next: AgentHookEventPayload -): EnrichedAgentHookEventPayload | undefined { - if ( - previous?.claudeLeadBoundaryChildOnly !== true || - attachClaudeChildOnlyBoundary(previous, next).claudeLeadBoundaryChildOnly === true - ) { - return previous - } - const { claudeLeadBoundaryChildOnly: _boundary, ...withoutBoundary } = previous - return withoutBoundary +/** The Claude lead has settled and child agents alone hold the row `working`. Derived from the + * row's `lead` fact and its child evidence, never stored: the persisted flag this replaced was a + * second copy of `lead.state === 'done'` that could disagree with it. A running shell beside the + * agents disqualifies a live row; a hydrated row no longer carries that fact and errs toward + * keeping its hook-written state. */ +export function isClaudeLeadBoundaryHeldByChildrenOnly( + row: Pick +): boolean { + return ( + row.payload.agentType === 'claude' && + isAgentStatusHeldOpenByChildWork(row.payload) && + row.payload.state === 'working' && + row.payload.subagents?.some((subagent) => subagent.state === 'working') === true && + row.claudeRunningNonAgentTask !== true + ) } export function shouldKeepClaudePermissionVisible( diff --git a/src/main/agent-hooks/server/server-hydration.ts b/src/main/agent-hooks/server/server-hydration.ts index cd88a46cb9f3..70f922b16737 100644 --- a/src/main/agent-hooks/server/server-hydration.ts +++ b/src/main/agent-hooks/server/server-hydration.ts @@ -10,6 +10,7 @@ import { } from '../../../shared/agent-hook-listener/providers/claude-roster-state' import { seedCodexStateFromSnapshot } from '../../../shared/agent-hook-listener/providers/codex-state' import { AGENT_STATUS_PERSISTED_HYDRATION_MODE } from '../../../shared/agent-status-legacy-adapter' +import { isClaudeLeadBoundaryHeldByChildrenOnly } from './server-claude-status-rules' import { HYDRATE_MAX_AGE_MS, LAST_STATUS_FILE_VERSION } from './server-constants' import type { LastStatusFile } from './server-types' import { @@ -124,7 +125,7 @@ export abstract class AgentHookServerHydration extends AgentHookServerReaping { seedCodexStateFromSnapshot(this.state, resolvedPaneKey, entry.payload) } else if (entry.payload.agentType === 'claude') { seedClaudeLeadTurnFromPersistedStatus(this.state, resolvedPaneKey, entry, { - childOnlyBoundary: entry.claudeLeadBoundaryChildOnly === true + childOnlyBoundary: isClaudeLeadBoundaryHeldByChildrenOnly(entry) }) if (entry.payload.subagents) { seedClaudeSubagentRosterFromSnapshots( diff --git a/src/main/agent-hooks/server/server-ingest-structured.ts b/src/main/agent-hooks/server/server-ingest-structured.ts index cc7751511d4e..910ae08161eb 100644 --- a/src/main/agent-hooks/server/server-ingest-structured.ts +++ b/src/main/agent-hooks/server/server-ingest-structured.ts @@ -9,6 +9,10 @@ import { structuredAgentSessionPaneKey, structuredAgentSessionTabId } from '../../../shared/structured-agent-session-projection' +import { + continueAgentLeadStatus, + isAgentStatusHeldOpenByChildWork +} from '../../../shared/agent-lead-status-fold' import { structuredAgentSessionAgentStatus } from '../../../shared/structured-agent-session-agent-status' import { structuredStatusLegacyEvent } from './server-structured-status-row' import { AgentHookServerIngestTerminal } from './server-ingest-terminal' @@ -35,10 +39,15 @@ export abstract class AgentHookServerIngestStructured extends AgentHookServerIng } const previous = this.canonicalStatusStore.getParent(parsed) const priorStatus = previous?.status - const { state, workingMode, fromChildWork } = structuredAgentSessionAgentStatus({ + const agentStatus = structuredAgentSessionAgentStatus({ status: summary.status, - backgroundTasks: summary.backgroundTasks + backgroundTasks: summary.backgroundTasks, + turnOutcome: summary.turnOutcome }) + const { state, workingMode } = agentStatus + // The lead's own clock keeps continuity the same way the combined row's does below, dated + // by the journal: a restart's republish is not a new lead state either. + const lead = continueAgentLeadStatus(priorStatus?.lead, agentStatus.lead, summary.updatedAt) const tabId = structuredAgentSessionTabId(parsed.sessionId) const paneKey = structuredAgentSessionPaneKey(tabId, parsed.sessionId) if (this.state.lastStatusByPaneKey.has(paneKey)) { @@ -55,6 +64,7 @@ export abstract class AgentHookServerIngestStructured extends AgentHookServerIng ...(summary.providerSession ? { providerSession: summary.providerSession } : {}), state, ...(workingMode ? { workingMode } : {}), + lead, prompt: summary.latestPrompt, agentType: summary.agent, ...(summary.model ? { model: summary.model } : {}), @@ -68,7 +78,9 @@ export abstract class AgentHookServerIngestStructured extends AgentHookServerIng // cannot date child work: it stopped when the lead did, and reading it as the evidence age // retires a genuinely live roster at the 30-minute staleness window. Only then does the // host's own observation clock stand in, matching what the hook lane stamps for its rows. - evidenceObservedAt: fromChildWork ? observedAt : summary.updatedAt, + evidenceObservedAt: isAgentStatusHeldOpenByChildWork({ state, lead }) + ? observedAt + : summary.updatedAt, // Continuity is the whole published work identity: `state` alone no longer means "a turn is // running", so monitoring that becomes a real turn must restart the clock, not inherit it. stateStartedAt: diff --git a/src/main/agent-hooks/server/server-ingest-terminal.ts b/src/main/agent-hooks/server/server-ingest-terminal.ts index e7e681156591..ab7a78c4c3fc 100644 --- a/src/main/agent-hooks/server/server-ingest-terminal.ts +++ b/src/main/agent-hooks/server/server-ingest-terminal.ts @@ -4,6 +4,7 @@ import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../shared/stable- import { terminalStatusPayloadMatchesHook } from '../../../shared/agent-terminal-status-equivalence' import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types' import type { EnrichedAgentHookEventPayload } from './server-types' +import { isClaudeLeadBoundaryHeldByChildrenOnly } from './server-claude-status-rules' import { AgentHookServerIngestNormalization } from './server-ingest-normalization' export abstract class AgentHookServerIngestTerminal extends AgentHookServerIngestNormalization { @@ -82,11 +83,11 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges | EnrichedAgentHookEventPayload | undefined if ( - previous?.claudeLeadBoundaryChildOnly === true && - previous.payload.agentType === 'claude' && + previous && + isClaudeLeadBoundaryHeldByChildrenOnly(previous) && event.payload.agentType === 'claude' ) { - // Why: OSC has no child identity or lead boundary, so it cannot replace a persisted child-only proof before the lifecycle hook arrives. + // Why: OSC has no child identity or lead boundary, so it cannot replace a child-only proof before the lifecycle hook arrives. if (mutationBefore !== undefined) { this.commitStatusRowMutation(mutationBefore, previous) this.emitEnrichedStatus(previous) @@ -130,6 +131,14 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges (previous.payload.state !== 'done' || event.payload.state === 'done') ? previous.providerSession : undefined + // Why: OSC carries no lead fact. While it repaints the state the hook row already holds, the + // lead behind that state is unchanged too; a different state is a turn edge OSC cannot date. + const preservedLead = + previous?.payload.lead && + previous.payload.state === event.payload.state && + (claimedAgentType === undefined || claimedAgentType === previous.payload.agentType) + ? previous.payload.lead + : undefined // Why: OSC status is a runtime observation, not a prompt boundary; keep prompt-sent telemetry tied to native hooks. this.applyNormalizedStatus( { @@ -139,7 +148,7 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges connectionId, ...(preservedProviderSession ? { providerSession: preservedProviderSession } : {}), ...(terminalHandle ? { terminalHandle } : {}), - payload: event.payload + payload: preservedLead ? { ...event.payload, lead: preservedLead } : event.payload }, undefined, 'osc', diff --git a/src/main/agent-hooks/server/server-persistence-validation.ts b/src/main/agent-hooks/server/server-persistence-validation.ts index d47fe0cddebe..f2baa3b43d39 100644 --- a/src/main/agent-hooks/server/server-persistence-validation.ts +++ b/src/main/agent-hooks/server/server-persistence-validation.ts @@ -31,6 +31,30 @@ export function dropHydratedIdleClaudeSubagents( } } +/** Rows written before `lead` existed persisted `claudeLeadBoundaryChildOnly: true` instead: the + * lead had settled and child agents alone held the row `working`. That is `lead.state === 'done'` + * stored as a boolean, so it only fills an absent `lead`; a row carrying both keeps `lead`. The + * flag stays readable until every user's file has been rewritten without it. */ +function fillLeadFromLegacyChildOnlyBoundary( + payload: ParsedAgentStatusPayload, + record: Record, + stateStartedAt: number +): ParsedAgentStatusPayload { + if ( + payload.lead !== undefined || + record.claudeLeadBoundaryChildOnly !== true || + payload.agentType !== 'claude' + ) { + return payload + } + return { + ...payload, + // Why: the gated working row stamps the lead's end as `turnCompletedAt`; the row clock is the + // nearest fact an older row that lacks it can offer. + lead: { state: 'done', stateStartedAt: payload.turnCompletedAt ?? stateStartedAt } + } +} + export function sanitizeHydratedEntry( paneKey: string, rawEntry: unknown @@ -84,10 +108,11 @@ export function sanitizeHydratedEntry( } else { return null } - const payload = normalizeAgentStatusPayload(record.payload) - if (!payload) { + const normalizedPayload = normalizeAgentStatusPayload(record.payload) + if (!normalizedPayload) { return null } + const payload = fillLeadFromLegacyChildOnlyBoundary(normalizedPayload, record, stateStartedAt) const providerSession = normalizeAgentProviderSession(record.providerSession) ?? undefined const providerSessionOnly = record.providerSessionOnly === true const retainedForLiveness = record.retainedForLiveness === true @@ -127,7 +152,9 @@ export function sanitizeHydratedEntry( toolAgentId: typeof record.toolAgentId === 'string' ? record.toolAgentId : undefined, teammateName: typeof record.teammateName === 'string' ? record.teammateName : undefined, toolAgentType: typeof record.toolAgentType === 'string' ? record.toolAgentType : undefined, - claudeLeadBoundaryChildOnly: record.claudeLeadBoundaryChildOnly === true ? true : undefined, + ...(typeof record.claudeRunningNonAgentTask === 'boolean' + ? { claudeRunningNonAgentTask: record.claudeRunningNonAgentTask } + : {}), providerSession, providerSessionOnly: providerSessionOnly ? true : undefined, retainedForLiveness: retainedForLiveness ? true : undefined, diff --git a/src/main/agent-hooks/server/server-persistence.ts b/src/main/agent-hooks/server/server-persistence.ts index 6d811216f9f0..2e91231b8f97 100644 --- a/src/main/agent-hooks/server/server-persistence.ts +++ b/src/main/agent-hooks/server/server-persistence.ts @@ -32,9 +32,7 @@ export abstract class AgentHookServerPersistence extends AgentHookServerHydratio if (enrichedPayload.structuredHost) { continue } - const childOnlyBoundary = enrichedPayload.claudeLeadBoundaryChildOnly === true const { - claudeRunningNonAgentTask: _claudeRunningNonAgentTask, promptInteractionKey: _promptInteractionKey, // Why: never persisted — hydrate re-stamps it, so a stored copy could only drift. restoredUnconfirmed: _restoredUnconfirmed, @@ -51,9 +49,10 @@ export abstract class AgentHookServerPersistence extends AgentHookServerHydratio const launchTokenHash = launchToken?.trim() ? createHash('sha256').update(launchToken.trim()).digest('hex') : this.hydratedLaunchTokenHashByPaneKey.get(paneKey) + // `payload.lead` rides inside the payload; the legacy `claudeLeadBoundaryChildOnly` flag it + // replaced is read at hydrate and never written again. entries[paneKey] = { ...persistedPayload, - ...(childOnlyBoundary ? { claudeLeadBoundaryChildOnly: true } : {}), ...(launchTokenHash ? { launchTokenHash } : {}) } const commitment = this.toAuthorityEvidence(payload, launchTokenHash) diff --git a/src/main/agent-hooks/server/server-status-inference.ts b/src/main/agent-hooks/server/server-status-inference.ts index 3f57e8d87d29..9bd80ff0fa03 100644 --- a/src/main/agent-hooks/server/server-status-inference.ts +++ b/src/main/agent-hooks/server/server-status-inference.ts @@ -108,7 +108,9 @@ export abstract class AgentHookServerStatusInference extends AgentHookServerRowO ...(payload.model ? { model: payload.model } : {}), interrupted: true, // Why: idle children are display state; dropping them on an inferred interrupt blanks rows a later hook would restore. - ...(payload.subagents ? { subagents: payload.subagents } : {}) + ...(payload.subagents ? { subagents: payload.subagents } : {}), + // Why: the synthesized row is the lead's own end; its verdict is the interrupt that produced it. + lead: { state: 'done', outcome: 'cancellation', stateStartedAt: Date.now() } } }) if (!inferred) { @@ -172,7 +174,8 @@ export abstract class AgentHookServerStatusInference extends AgentHookServerRowO ...(restored.turnCompletedAt !== undefined ? { turnCompletedAt: restored.turnCompletedAt } : {}), - ...(payload.subagents ? { subagents: payload.subagents } : {}) + ...(payload.subagents ? { subagents: payload.subagents } : {}), + lead: restored.lead } }) if (!inferred) { diff --git a/src/main/agent-hooks/server/server-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts index 1b4a0e759603..9d9c8c00699d 100644 --- a/src/main/agent-hooks/server/server-status-update.ts +++ b/src/main/agent-hooks/server/server-status-update.ts @@ -11,9 +11,7 @@ import type { EnrichedAgentHookEventPayload } from './server-types' import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' import type { AgentStatusObservationOrigin } from '../../../shared/agent-status-observation' import { - attachClaudeChildOnlyBoundary, attachClaudePermissionToolUseId, - invalidateClaudeChildOnlyBoundary, shouldKeepClaudePermissionVisible } from './server-claude-status-rules' import { isStaleGrokTurnEnd } from './server-grok-status-rules' @@ -36,7 +34,8 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA // Why: the prompt boundary is authoritative even when text is unchanged; its next OSC working row must not inherit the prior cron/background turn stamp. this.activeHookTurnCompletedAtByPaneKey.delete(payload.paneKey) } - let previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Main admits enriched legacy rows; the shared view declares their base event type. + const previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as | EnrichedAgentHookEventPayload | undefined const rowBefore = mutationBefore ?? previous @@ -122,19 +121,6 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA : stateReconciledPayload.payload } : stateReconciledPayload - const boundaryReconciledPrevious = invalidateClaudeChildOnlyBoundary( - previous, - rootContextPreservingPayload - ) - if (boundaryReconciledPrevious !== previous) { - previous = boundaryReconciledPrevious - if (previous) { - if (!this.writeLegacyStatusRow(previous)) { - return undefined - } - this.scheduleStatusPersist() - } - } const identity = resolveAgentStatusIdentity({ existing: previous ? { @@ -165,7 +151,6 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA payload: { ...rootContextPreservingPayload.payload, agentType: identity.agentType } } const effectivePayload = attachClaudePermissionToolUseId(previous, identityResolvedPayload) - const boundaryAwarePayload = attachClaudeChildOnlyBoundary(previous, effectivePayload) if (previous && shouldKeepClaudePermissionVisible(previous, effectivePayload)) { this.commitStatusRowMutation(rowBefore, previous) return previous @@ -212,8 +197,8 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA // Why carried forward only within one host: main's OSC parse resolves the handle, so a later // hook must not erase its terminal join; a connection change must not inherit another host's. const enriched = { - ...this.attachStatusTiming(boundaryAwarePayload, now, observedAt), - observation: this.stampObservation(boundaryAwarePayload, origin, observedAt ?? now) + ...this.attachStatusTiming(effectivePayload, now, observedAt), + observation: this.stampObservation(effectivePayload, origin, observedAt ?? now) } if ( typeof enriched.payload.turnCompletedAt === 'number' && diff --git a/src/main/agent-hooks/server/server-types.ts b/src/main/agent-hooks/server/server-types.ts index 8f5edf421063..4cf5b7a3e850 100644 --- a/src/main/agent-hooks/server/server-types.ts +++ b/src/main/agent-hooks/server/server-types.ts @@ -25,14 +25,14 @@ export type EnrichedAgentHookEventPayload = AgentHookEventPayload & { restoredUnconfirmed?: true /** User-hidden resume identity retained solely for destructive liveness checks. */ retainedForLiveness?: true - /** Persisted proof that a lead boundary was held working only by child agents. */ - claudeLeadBoundaryChildOnly?: true } +// `claudeRunningNonAgentTask` is persisted on purpose: it is the one child-work fact the row's +// `lead` cannot express (a shell beside the agents), and hydration reads it to decide whether a +// settled lead may be seeded. It replaced the derived `claudeLeadBoundaryChildOnly` flag. export type PersistedAgentHookEventPayload = Omit< EnrichedAgentHookEventPayload, | 'authorityRestartId' - | 'claudeRunningNonAgentTask' | 'launchToken' | 'promptInteractionKey' | 'restoredUnconfirmed' diff --git a/src/main/agent-hooks/terminal-handle-row-identity.test.ts b/src/main/agent-hooks/terminal-handle-row-identity.test.ts index 3fba9484afb2..b915d27d877a 100644 --- a/src/main/agent-hooks/terminal-handle-row-identity.test.ts +++ b/src/main/agent-hooks/terminal-handle-row-identity.test.ts @@ -210,9 +210,15 @@ describe('the terminal handle a status row is stamped with', () => { if (!row) { throw new Error('expected seeded status row') } + // A settled lead whose only live work is a child agent: the boundary is derived from these facts. const childOnlyRow = { ...row, - claudeLeadBoundaryChildOnly: true + claudeRunningNonAgentTask: false, + payload: { + ...row.payload, + lead: { state: 'done' as const, stateStartedAt: 1 }, + subagents: [{ id: 'child-1', state: 'working' as const, startedAt: 1 }] + } } seedLegacyAgentStatusForTests(server._getStateForTests(), childOnlyRow) enriched.mockClear() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts index cecd5455ca7d..6ec254f1032e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts @@ -71,6 +71,7 @@ function summariesEqual(a: AgentSessionStatusSummary, b: AgentSessionStatusSumma a.toolName === b.toolName && a.toolInput === b.toolInput && a.lastAssistantMessage === b.lastAssistantMessage && + a.turnOutcome === b.turnOutcome && agentSessionBackgroundTasksEqual(a.backgroundTasks, b.backgroundTasks) && agentProviderSessionsEqual(undefined, a.providerSession, b.providerSession) ) diff --git a/src/main/runtime/runtime-agent-row-lead-projection.test.ts b/src/main/runtime/runtime-agent-row-lead-projection.test.ts new file mode 100644 index 000000000000..4db636e37e2b --- /dev/null +++ b/src/main/runtime/runtime-agent-row-lead-projection.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' +import { selectRuntimeHookAgentRowForPane } from './runtime-mobile-agent-status-projection' + +const PANE = 'tab-1:11111111-1111-4111-8111-111111111111' + +function row(over: Partial = {}): AgentStatusIpcPayload { + const now = Date.now() + return { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: null, + receivedAt: now, + stateStartedAt: now - 1_000, + state: 'working', + prompt: 'ship it', + agentType: 'claude', + lead: { state: 'done', outcome: 'cancellation', stateStartedAt: now - 2_000 }, + ...over + } +} + +// The mobile projection narrows a row through `pickParsedAgentStatusPayload`; this pins that +// the lead fact survives the narrowing. The `worktree ps` row keeps its own explicit shape. +describe('the lead fact through the runtime projections', () => { + it('reaches the mobile live row', () => { + const source = row() + const selected = selectRuntimeHookAgentRowForPane([source]) + expect(selected.live?.payload.lead).toEqual(source.lead) + }) +}) diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx index 746817523e44..c5908354a0bd 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx @@ -697,3 +697,63 @@ describe('StructuredAgentSessionStatusBridge', () => { expect(mocks.setAgentStatus).not.toHaveBeenCalled() }) }) + +describe('the lead fact the bridge writes', () => { + beforeEach(() => { + vi.clearAllMocks() + resetStructuredAgentSessionStatusFeedsForTests() + mocks.subscribeStatus.mockResolvedValue({ unsubscribe: mocks.unsubscribe }) + mocks.supportsCapability.mockResolvedValue(true) + mocks.store?.setState({ + agentStatusByPaneKey: {}, + testRuntimeOwner: null, + unifiedTabsByWorktree: { 'wt-1': [structuredTab] } + }) + }) + + afterEach(() => { + cleanup() + resetStructuredAgentSessionStatusFeedsForTests() + }) + + it('stamps the lead beside the folded state, with its verdict and its own clock', async () => { + render() + await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) + + act(() => + feed().emit({ + type: 'snapshot', + sessions: [ + summary({ + status: 'idle', + updatedAt: 1, + turnOutcome: 'cancellation', + backgroundTasks: [{ id: 'shell-1', kind: 'command', state: 'working' }] + }) + ] + }) + ) + expect(statuses()).toEqual([ + expect.objectContaining({ + state: 'working', + workingMode: 'monitoring', + lead: { state: 'done', outcome: 'cancellation', stateStartedAt: 1 } + }) + ]) + + // The shell drains: the row settles, the lead was done all along, so its clock holds. + act(() => + feed().emit({ + type: 'status', + session: summary({ status: 'idle', updatedAt: 2, turnOutcome: 'cancellation' }) + }) + ) + expect(statuses()).toEqual([ + expect.objectContaining({ + state: 'done', + stateStartedAt: 2, + lead: { state: 'done', outcome: 'cancellation', stateStartedAt: 1 } + }) + ]) + }) +}) diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx index e6a1c966ec20..74b2f4fb6705 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx @@ -6,7 +6,11 @@ import { agentChildWorkProjectionCandidateFromBackgroundTask, projectAgentChildWorkLegacySubagents } from '../../../../shared/agent-status-child-work-projection' -import { agentSubagentsEqual } from '../../../../shared/agent-status-types' +import { + continueAgentLeadStatus, + isAgentStatusHeldOpenByChildWork +} from '../../../../shared/agent-lead-status-fold' +import { agentLeadStatusEqual, agentSubagentsEqual } from '../../../../shared/agent-status-types' import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' import { structuredAgentSessionAgentStatus } from '../../../../shared/structured-agent-session-agent-status' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' @@ -62,11 +66,16 @@ function projectStatus( // Shared with `worktree ps`, so the CLI and this row cannot disagree about one session. const agentStatus = structuredAgentSessionAgentStatus({ status: summary.status, - backgroundTasks: summary.backgroundTasks + backgroundTasks: summary.backgroundTasks, + turnOutcome: summary.turnOutcome }) + const current = store.agentStatusByPaneKey?.[paneKey] + // Same continuity rule as the host ingest, on the lead's own clock. + const lead = continueAgentLeadStatus(current?.lead, agentStatus.lead, summary.updatedAt) const desired = { state: agentStatus.state, ...(agentStatus.workingMode ? { workingMode: agentStatus.workingMode } : {}), + lead, prompt: summary.latestPrompt, agentType: tab.agentSessionAgent, // The host projects these from the journal so the row reads like a hook-reported one: @@ -78,10 +87,10 @@ function projectStatus( ...(subagents ? { subagents, subagentObservation: observation } : {}), sessionBoundary: false } as const - const current = store.agentStatusByPaneKey?.[paneKey] if ( current?.state === desired.state && current.workingMode === desired.workingMode && + agentLeadStatusEqual(current.lead, desired.lead) && current.prompt === desired.prompt && current.agentType === desired.agentType && // A row keeps the last model it was told about, so only a reported one can differ. @@ -124,7 +133,7 @@ function projectStatus( : summary.updatedAt, // Same rule as the host ingest: the journal clock stopped when the lead's turn did, so a // row held open by child work alone is dated by when this client saw it instead. - evidenceObservedAt: agentStatus.fromChildWork ? Date.now() : summary.updatedAt + evidenceObservedAt: isAgentStatusHeldOpenByChildWork(desired) ? Date.now() : summary.updatedAt }, { tabId: tab.id, worktreeId: tab.worktreeId }, { diff --git a/src/renderer/src/hooks/ipc-events/normalize-agent-status-event.ts b/src/renderer/src/hooks/ipc-events/normalize-agent-status-event.ts index 56d46657fe75..cb3a9f022c88 100644 --- a/src/renderer/src/hooks/ipc-events/normalize-agent-status-event.ts +++ b/src/renderer/src/hooks/ipc-events/normalize-agent-status-event.ts @@ -22,7 +22,8 @@ export function normalizeAgentStatusEvent( interrupted: data.interrupted, sessionBoundary: data.sessionBoundary, turnCompletedAt: data.turnCompletedAt, - subagents: data.subagents + subagents: data.subagents, + lead: data.lead }) } diff --git a/src/renderer/src/store/slices/agent-status-lead-fact.test.ts b/src/renderer/src/store/slices/agent-status-lead-fact.test.ts new file mode 100644 index 000000000000..2fd1d47b62ce --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-lead-fact.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { normalizeAgentStatusEvent } from '../../hooks/ipc-events/normalize-agent-status-event' +import { createTestStore } from './store-test-helpers' + +const PANE = 'tab-1:11111111-1111-4111-8111-111111111111' + +describe('the lead fact on a renderer status entry', () => { + it('lands on the entry from the IPC payload and is reused by reference when unchanged', () => { + const store = createTestStore() + const lead = { state: 'done' as const, outcome: 'failure' as const, stateStartedAt: 5 } + store + .getState() + .setAgentStatus(PANE, { state: 'working', prompt: 'go', agentType: 'claude', lead }) + const first = store.getState().agentStatusByPaneKey[PANE] + expect(first.lead).toEqual(lead) + + store.getState().setAgentStatus(PANE, { + state: 'working', + prompt: 'go', + agentType: 'claude', + toolName: 'Read', + lead: { ...lead } + }) + expect(store.getState().agentStatusByPaneKey[PANE].lead).toBe(first.lead) + }) + + it('keeps the lead behind an unchanged state when a writer carries none, and drops it on a state edge', () => { + const store = createTestStore() + const lead = { state: 'done' as const, stateStartedAt: 5 } + store + .getState() + .setAgentStatus(PANE, { state: 'working', prompt: 'go', agentType: 'claude', lead }) + // A renderer-side OSC parse repaints the state with no lead fact of its own. + store.getState().setAgentStatus(PANE, { + state: 'working', + prompt: 'go', + agentType: 'claude', + toolName: 'Bash' + }) + expect(store.getState().agentStatusByPaneKey[PANE].lead).toEqual(lead) + + store.getState().setAgentStatus(PANE, { state: 'done', prompt: 'go', agentType: 'claude' }) + expect(store.getState().agentStatusByPaneKey[PANE].lead).toBeUndefined() + }) + + it('survives the IPC event normalizer', () => { + const lead = { state: 'working' as const, stateStartedAt: 9 } + expect( + normalizeAgentStatusEvent({ + paneKey: PANE, + connectionId: null, + receivedAt: 10, + stateStartedAt: 9, + state: 'working', + prompt: 'go', + lead + })?.lead + ).toEqual(lead) + }) +}) diff --git a/src/renderer/src/store/slices/agent-status-live-entry-builder.ts b/src/renderer/src/store/slices/agent-status-live-entry-builder.ts index 843b3b42eb10..55a6a26c20ae 100644 --- a/src/renderer/src/store/slices/agent-status-live-entry-builder.ts +++ b/src/renderer/src/store/slices/agent-status-live-entry-builder.ts @@ -1,4 +1,5 @@ import type { AppState } from '../types' +import { resolveAgentStatusLiveEntryLead } from './agent-status-live-entry-lead' import { AGENT_STATE_HISTORY_MAX, agentSubagentsEqual, @@ -217,6 +218,7 @@ export function buildAgentStatusLiveEntry( : undefined) ?? matchedRegistryLaunchConfig ?? matchedSleepingLaunchConfig + const lead = resolveAgentStatusLiveEntryLead(existing, payload, identity.agentType) const entry: AgentStatusEntry = { state: payload.state, workingMode: payload.workingMode, @@ -261,6 +263,7 @@ export function buildAgentStatusLiveEntry( subagents: agentSubagentsEqual(existing?.subagents, payload.subagents) ? existing?.subagents : payload.subagents, + ...(lead ? { lead } : {}), ...(providerSession ? { providerSession } : {}), ...(metadata?.terminalResumeEligible === false ? { terminalResumeEligible: false as const } diff --git a/src/renderer/src/store/slices/agent-status-live-entry-lead.ts b/src/renderer/src/store/slices/agent-status-live-entry-lead.ts new file mode 100644 index 000000000000..8a0f955e92c8 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-live-entry-lead.ts @@ -0,0 +1,23 @@ +import { + agentLeadStatusEqual, + type AgentLeadStatus, + type AgentStatusEntry, + type AgentStatusPayload +} from '../../../../shared/agent-status-types' + +/** The lead fact a live entry carries. A writer with no lead fact of its own (OSC bytes, launch + * seeds) repaints the state the row already holds, and the lead behind an unchanged state is + * unchanged too — the same rule main's OSC ingest applies. The existing object is reused when + * nothing changed so subscribers can compare by reference. */ +export function resolveAgentStatusLiveEntryLead( + existing: AgentStatusEntry | undefined, + payload: Pick, + agentType: AgentStatusEntry['agentType'] +): AgentLeadStatus | undefined { + const next = + payload.lead ?? + (existing?.state === payload.state && existing.agentType === agentType + ? existing.lead + : undefined) + return agentLeadStatusEqual(existing?.lead, next) ? existing?.lead : next +} diff --git a/src/shared/agent-hook-listener-claude-subagents.test.ts b/src/shared/agent-hook-listener-claude-subagents.test.ts index 00d95471d72a..215c5fd6c1eb 100644 --- a/src/shared/agent-hook-listener-claude-subagents.test.ts +++ b/src/shared/agent-hook-listener-claude-subagents.test.ts @@ -663,7 +663,10 @@ describe('shared agent-hook-listener', () => { expect(wait?.payload.state).toBe('waiting') expect(wait?.payload.interactivePrompt).toBeDefined() - expect(clearClaudeAnsweredQuestionWait(state, PANE_KEY)).toEqual({ state: 'working' }) + expect(clearClaudeAnsweredQuestionWait(state, PANE_KEY)).toEqual({ + state: 'working', + lead: { state: 'working', stateStartedAt: expect.any(Number) } + }) // Why: a child-driven refresh re-emits the cached lead state; the linger // bug would come back if it could resurrect the dismissed question. @@ -714,10 +717,13 @@ describe('shared agent-hook-listener', () => { // emitted state is gated up to working only while that child still runs. expect(clearClaudeAnsweredQuestionWait(state, PANE_KEY)).toEqual({ state: 'working', - turnCompletedAt: expect.any(Number) + turnCompletedAt: expect.any(Number), + // The lead's own fact rides beside the gated state: it finished, no verdict was given. + lead: { state: 'done', stateStartedAt: expect.any(Number) } }) expect(state.claudeLeadStateByPaneKey.get(PANE_KEY)).toEqual({ state: 'done', + stateStartedAt: expect.any(Number), turnCompletedAt: expect.any(Number) }) @@ -726,7 +732,10 @@ describe('shared agent-hook-listener', () => { }) it('falls back to working when no lead record exists', () => { - expect(clearClaudeAnsweredQuestionWait(state, PANE_KEY)).toEqual({ state: 'working' }) + expect(clearClaudeAnsweredQuestionWait(state, PANE_KEY)).toEqual({ + state: 'working', + lead: { state: 'working', stateStartedAt: expect.any(Number) } + }) }) }) }) diff --git a/src/shared/agent-hook-listener-claude-turn-state.test.ts b/src/shared/agent-hook-listener-claude-turn-state.test.ts index f7e40a52461c..b06b9a76910f 100644 --- a/src/shared/agent-hook-listener-claude-turn-state.test.ts +++ b/src/shared/agent-hook-listener-claude-turn-state.test.ts @@ -400,3 +400,54 @@ describe('shared agent-hook-listener', () => { expect(latestPrompt).toBe('prompt 39') }) }) + +describe('the lead verdict across a child-induced wait', () => { + let state: HookListenerState + + beforeEach(() => { + state = createHookListenerState() + }) + + function claude(payload: Record) { + return normalizeHookPayload(state, 'claude', { paneKey: PANE_KEY, payload }, 'production') + ?.payload + } + + it('restores the cancelled verdict when a child permission pause clears', () => { + claude({ hook_event_name: 'UserPromptSubmit', prompt: 'go' }) + claude({ hook_event_name: 'SubagentStart', agent_id: 'a1' }) + const cancelled = claude({ hook_event_name: 'Stop', is_interrupt: true }) + expect(cancelled?.lead).toEqual({ + state: 'done', + outcome: 'cancellation', + stateStartedAt: expect.any(Number) + }) + const settledAt = cancelled?.lead?.stateStartedAt + + const wait = claude({ + hook_event_name: 'PermissionRequest', + agent_id: 'a1', + tool_name: 'Bash', + tool_input: { command: 'rm -rf build' } + }) + expect(wait).toMatchObject({ state: 'waiting', lead: { state: 'waiting' } }) + expect(wait?.lead).not.toHaveProperty('outcome') + + // The child's pause displaced the lead; clearing it must give the verdict and clock back. + const drained = claude({ hook_event_name: 'SubagentStop', agent_id: 'a1' }) + expect(drained).toMatchObject({ + state: 'done', + interrupted: true, + lead: { state: 'done', outcome: 'cancellation', stateStartedAt: settledAt } + }) + }) + + it('clears the verdict when a new turn starts', () => { + claude({ hook_event_name: 'UserPromptSubmit', prompt: 'go' }) + claude({ hook_event_name: 'StopFailure', error: 'invalid_request' }) + expect(claude({ hook_event_name: 'UserPromptSubmit', prompt: 'again' })?.lead).toEqual({ + state: 'working', + stateStartedAt: expect.any(Number) + }) + }) +}) diff --git a/src/shared/agent-hook-listener-codex-lead.test.ts b/src/shared/agent-hook-listener-codex-lead.test.ts new file mode 100644 index 000000000000..e7a1e3839aec --- /dev/null +++ b/src/shared/agent-hook-listener-codex-lead.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { + createHookListenerState, + type HookListenerState +} from './agent-hook-listener/listener-state' +import { + reconcileRemoteCodexState, + seedCodexStateFromSnapshot +} from './agent-hook-listener/providers/codex-state' +import { PANE_KEY } from './agent-hook-listener-test-harness' + +describe('the Codex root record seeded from a durable row', () => { + let state: HookListenerState + + beforeEach(() => { + state = createHookListenerState() + }) + + it("takes the row's own lead fact over the inferred aggregate", () => { + seedCodexStateFromSnapshot(state, PANE_KEY, { + state: 'waiting', + model: 'gpt-5.4', + subagents: [{ id: 'child', state: 'working', startedAt: 1 }], + lead: { state: 'done', outcome: 'cancellation', stateStartedAt: 42 } + }) + expect(state.codexLeadStateByPaneKey.get(PANE_KEY)).toEqual({ + state: 'done', + outcome: 'cancellation', + stateStartedAt: 42, + model: 'gpt-5.4' + }) + }) + + it('still infers the root state from an older row that carries no lead', () => { + seedCodexStateFromSnapshot(state, PANE_KEY, { + state: 'waiting', + subagents: [{ id: 'child', state: 'waiting', startedAt: 1 }] + }) + expect(state.codexLeadStateByPaneKey.get(PANE_KEY)).toMatchObject({ state: 'working' }) + }) + + it("republishes a relayed row with the lead fact main holds, not the relay's", () => { + const reconciled = reconcileRemoteCodexState( + state, + PANE_KEY, + 'Stop', + undefined, + { state: 'done', prompt: 'ship', agentType: 'codex' }, + undefined + ) + expect(reconciled.lead).toEqual({ state: 'done', stateStartedAt: expect.any(Number) }) + }) +}) diff --git a/src/shared/agent-hook-listener-extraction-characterization.test.ts b/src/shared/agent-hook-listener-extraction-characterization.test.ts index c521b6c7439d..06b7f271697d 100644 --- a/src/shared/agent-hook-listener-extraction-characterization.test.ts +++ b/src/shared/agent-hook-listener-extraction-characterization.test.ts @@ -213,8 +213,8 @@ describe('agent hook extraction boundaries', () => { state.ampCompletedCacheKeys.add(PANE) state.ampCompletedCacheKeys.add(scoped) state.ampCompletedCacheKeys.add(sibling) - state.claudeLeadStateByPaneKey.set(PANE, { state: 'working' }) - state.codexLeadStateByPaneKey.set(PANE, { state: 'working' }) + state.claudeLeadStateByPaneKey.set(PANE, { state: 'working', stateStartedAt: 1 }) + state.codexLeadStateByPaneKey.set(PANE, { state: 'working', stateStartedAt: 1 }) state.grokActiveTurnByPaneKey.set(PANE, { promptId: 'prompt-1' }) clearPaneCacheState(state, PANE) @@ -264,7 +264,7 @@ describe('agent hook extraction boundaries', () => { state.warnedEnvs.add('development->production') state.lastPromptByPaneKey.set(PANE, 'prompt') state.claudeRunningNonAgentTaskPaneKeys.add(PANE) - state.codexLeadStateByPaneKey.set(PANE, { state: 'working' }) + state.codexLeadStateByPaneKey.set(PANE, { state: 'working', stateStartedAt: 1 }) state.grokActiveTurnByPaneKey.set(PANE, { promptId: 'prompt-1' }) clearAllListenerCaches(state) diff --git a/src/shared/agent-hook-listener/lead-turn-state.ts b/src/shared/agent-hook-listener/lead-turn-state.ts new file mode 100644 index 000000000000..f550391eb99c --- /dev/null +++ b/src/shared/agent-hook-listener/lead-turn-state.ts @@ -0,0 +1,33 @@ +import type { AgentStatusState } from '../agent-status-types' +import type { AgentJournalTurnOutcome } from '../agent-turn-outcome' + +/** The Claude lead's own turn record, published on every row as `lead`. */ +export type ClaudeLeadTurnState = { + state: AgentStatusState + /** The provider's verdict on the turn this record closed; only meaningful while `state` is done. + * `cancellation` is what the fold reads as an interrupt. */ + outcome?: AgentJournalTurnOutcome + /** When `state` first appeared; the lead's own clock, distinct from the gated row's. */ + stateStartedAt: number + /** Subagent that induced the wait; only its next tool activity may clear it, so other children's churn can't dismiss a pending human-input card. */ + waitingAgentId?: string + /** Tool call that owns the wait; late completions from parallel sibling tools must not dismiss its card. */ + waitingToolUseId?: string + /** End time of the lead turn closed while background inventory kept the pane `working`. Repeated on the later all-clear `done`. */ + turnCompletedAt?: number + /** Lead state a child-induced wait displaced, restored when the wait clears; can't invent 'working' since the done-gate only downgrades done→working, never back. */ + stateBeforeWait?: Pick< + ClaudeLeadTurnState, + 'state' | 'outcome' | 'stateStartedAt' | 'turnCompletedAt' + > +} + +/** The Codex root's own record; its combined `state` still comes from `codexRosterEffectiveState`. */ +export type CodexLeadTurnState = { + state: 'working' | 'waiting' | 'done' + /** The turn verdict the server inferred; Codex's own Stop hook carries none. */ + outcome?: AgentJournalTurnOutcome + /** When `state` first appeared; the root's own clock, published as `lead.stateStartedAt`. */ + stateStartedAt: number + model?: string +} diff --git a/src/shared/agent-hook-listener/listener-state.ts b/src/shared/agent-hook-listener/listener-state.ts index 3e4ed0cae5d6..94895bbc6669 100644 --- a/src/shared/agent-hook-listener/listener-state.ts +++ b/src/shared/agent-hook-listener/listener-state.ts @@ -1,4 +1,7 @@ -import type { AgentStatusState } from '../agent-status-types' +import type { AgentLeadStatus } from '../agent-status-types' +import type { ClaudeLeadTurnState, CodexLeadTurnState } from './lead-turn-state' + +export type { ClaudeLeadTurnState, CodexLeadTurnState } from './lead-turn-state' import { AGENT_STATUS_2A_CURRENT_PRODUCER_MODE, createAgentStatusLegacyAdapter, @@ -30,7 +33,7 @@ export type HookListenerState = { ampCompletedCacheKeys: Set /** Live subagents/teammates per Claude pane; survives turn boundaries since background children outlive the lead turn. */ claudeSubagentRosterByPaneKey: Map - /** Last state from the LEAD session's own events (subagent events carry agent_id, excluded), so a SubagentStop can re-emit pane status; `interrupted` persists so the eventual done still carries it. */ + /** Last state from the LEAD session's own events (subagent events carry agent_id, excluded), so a SubagentStop can re-emit pane status; `outcome` persists so the eventual done still carries it. Published on every row as `lead`. */ claudeLeadStateByPaneKey: Map /** One-normalization provenance marker for a status backed only by restored child state. */ claudeUnconfirmedRestoredStatusPaneKeys: Set @@ -52,6 +55,8 @@ export type HookListenerState = { codexLeadStateByPaneKey: Map /** Newest Grok turn per pane, used to reject end reports that arrive after a replacement prompt. */ grokActiveTurnByPaneKey: Map + /** The Grok lead's own state as last published, so its clock keeps continuity across events. */ + grokLeadStatusByPaneKey: Map /** Muse child-session filter and session-log cursor per pane. */ musePaneStateByPaneKey: Map /** @@ -78,24 +83,6 @@ export type GrokActiveTurn = { sessionId?: string } -export type ClaudeLeadTurnState = { - state: AgentStatusState - interrupted?: true - /** Subagent that induced the wait; only its next tool activity may clear it, so other children's churn can't dismiss a pending human-input card. */ - waitingAgentId?: string - /** Tool call that owns the wait; late completions from parallel sibling tools must not dismiss its card. */ - waitingToolUseId?: string - /** End time of the lead turn closed while background inventory kept the pane `working`. Repeated on the later all-clear `done`. */ - turnCompletedAt?: number - /** Lead state a child-induced wait displaced, restored when the wait clears; can't invent 'working' since the done-gate only downgrades done→working, never back. */ - stateBeforeWait?: Pick -} - -export type CodexLeadTurnState = { - state: 'working' | 'waiting' | 'done' - model?: string -} - const legacyStatusAdapterByState = new WeakMap() function legacyStatusAdapter(state: HookListenerState): AgentStatusLegacyAdapter { @@ -129,6 +116,7 @@ export function createHookListenerState( codexSubagentTranscriptByPaneKey: new Map(), codexLeadStateByPaneKey: new Map(), grokActiveTurnByPaneKey: new Map(), + grokLeadStatusByPaneKey: new Map(), musePaneStateByPaneKey: new Map(), opencodeSessionPaneBySessionId: new Map(), lastLaunchTokenByPaneKey: new Map() @@ -218,6 +206,7 @@ export function clearPaneCacheState(state: HookListenerState, paneKey: string): state.codexSubagentTranscriptByPaneKey.delete(paneKey) state.codexLeadStateByPaneKey.delete(paneKey) state.grokActiveTurnByPaneKey.delete(paneKey) + state.grokLeadStatusByPaneKey.delete(paneKey) state.musePaneStateByPaneKey.delete(paneKey) unbindOpenCodeSessionsOfPane(state, paneKey) deletePaneScopedCacheEntry(state.lastLaunchTokenByPaneKey, paneKey) @@ -295,6 +284,7 @@ export function movePaneCacheState( movePaneScopedMapEntries(state.codexSubagentTranscriptByPaneKey, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.codexLeadStateByPaneKey, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.grokActiveTurnByPaneKey, fromPaneKey, toPaneKey) + movePaneScopedMapEntries(state.grokLeadStatusByPaneKey, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.musePaneStateByPaneKey, fromPaneKey, toPaneKey) moveOpenCodeSessionBindings(state, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.lastLaunchTokenByPaneKey, fromPaneKey, toPaneKey) @@ -306,6 +296,7 @@ export function clearPaneTurnCacheState(state: HookListenerState, paneKey: strin state.antigravityCompletedTranscriptByPaneKey.delete(paneKey) state.ampCompletedCacheKeys.delete(paneKey) state.grokActiveTurnByPaneKey.delete(paneKey) + state.grokLeadStatusByPaneKey.delete(paneKey) } export function deletePaneScopedCacheEntry(map: Map, paneKey: string): void { @@ -347,6 +338,7 @@ export function clearAllListenerCaches(state: HookListenerState): void { state.codexSubagentTranscriptByPaneKey.clear() state.codexLeadStateByPaneKey.clear() state.grokActiveTurnByPaneKey.clear() + state.grokLeadStatusByPaneKey.clear() state.opencodeSessionPaneBySessionId.clear() state.lastLaunchTokenByPaneKey.clear() } diff --git a/src/shared/agent-hook-listener/providers/claude-events.ts b/src/shared/agent-hook-listener/providers/claude-events.ts index d094afd33678..19e9d7090d39 100644 --- a/src/shared/agent-hook-listener/providers/claude-events.ts +++ b/src/shared/agent-hook-listener/providers/claude-events.ts @@ -17,8 +17,10 @@ import { normalizeClaudeSubagentLifecycleEvent } from './claude-lifecycle-events' import { + claudeLeadTurnInterrupted, getOrCreateClaudeSubagentRoster, resolveClaudePaneStatus, + setClaudeLeadTurnState, updateClaudeRunningNonAgentTask, voidClaimsOfReplacedClaudeSession } from './claude-roster-state' @@ -62,7 +64,7 @@ export function normalizeClaudeEvent( state.claudeSubagentRosterByPaneKey.delete(paneKey) state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) state.claudeActiveSessionCronPaneKeys.delete(paneKey) - state.claudeLeadStateByPaneKey.set(paneKey, { state: 'done' }) + setClaudeLeadTurnState(state, paneKey, { state: 'done' }) return buildClaudeStatusPayload(state, eventName, promptText, paneKey, hookPayload, { stateName: 'done', updateToolSnapshot: true, @@ -75,9 +77,16 @@ export function normalizeClaudeEvent( const interrupted = isTurnBoundary && ((eventAgentId === undefined && hookPayload['is_interrupt'] === true) || - previousLead?.interrupted === true) + claudeLeadTurnInterrupted(previousLead)) ? true : undefined + // Why: the provider's verdict, never inferred — a plain Stop stays absent, because an older + // Claude that omits `is_interrupt` would otherwise turn a cancel into a false success. + const outcome = interrupted + ? ('cancellation' as const) + : isTurnBoundary && eventName === 'StopFailure' + ? ('failure' as const) + : undefined const backgroundTasks = readClaudeBackgroundAgentTasks(hookPayload) const sessionCrons = hookPayload['session_crons'] const sessionCronInventoryPresent = Array.isArray(sessionCrons) @@ -185,12 +194,15 @@ export function normalizeClaudeEvent( } // Why: approval granted — update the tool snapshot (drop the pending card) as the lead's own next tool event would. // Restore the stashed lead state, not this child's 'working': the lead may already be done, and the done-gate never upgrades working back to done once the roster drains. - const restored = lead.stateBeforeWait ?? { state: 'working' as const } - state.claudeLeadStateByPaneKey.set(paneKey, restored) + const restored = setClaudeLeadTurnState( + state, + paneKey, + lead.stateBeforeWait ?? { state: 'working' as const } + ) return buildClaudeStatusPayload(state, eventName, promptText, paneKey, hookPayload, { ...resolveClaudePaneStatus(state, paneKey, restored), updateToolSnapshot: true, - interrupted: restored.interrupted, + interrupted: claudeLeadTurnInterrupted(restored), turnCompletedAt: restored.turnCompletedAt }) } @@ -223,7 +235,10 @@ export function normalizeClaudeEvent( ? previousLead.stateBeforeWait : { state: previousLead.state, - ...(previousLead.interrupted ? { interrupted: true as const } : {}), + // Why: the verdict and the lead's own clock are that turn's facts; a child's permission + // pause after a cancelled turn must not erase them when the wait clears. + ...(previousLead.outcome ? { outcome: previousLead.outcome } : {}), + stateStartedAt: previousLead.stateStartedAt, // Why: a child's permission pause displaces an already-finished lead; keep the end time so the later drain is still that turn's tail. ...(previousLead.turnCompletedAt !== undefined ? { turnCompletedAt: previousLead.turnCompletedAt } @@ -255,7 +270,7 @@ export function normalizeClaudeEvent( const resolvedStatus = resolveClaudePaneStatus(state, paneKey, { state: reportedStateName, - interrupted + outcome }) // Why: #15202's compact-completion guard reads the resolved state; this branch replaced the // resolver with one that also reports workingMode, so bridge rather than resolve twice. @@ -270,9 +285,9 @@ export function normalizeClaudeEvent( ? Date.now() : undefined - state.claudeLeadStateByPaneKey.set(paneKey, { + setClaudeLeadTurnState(state, paneKey, { state: reportedStateName, - ...(interrupted ? { interrupted } : {}), + ...(outcome ? { outcome } : {}), ...(isWaitingInducing && eventAgentId ? { waitingAgentId: eventAgentId } : {}), ...(isAskUserQuestionWait && waitingToolUseId !== undefined ? { waitingToolUseId } : {}), ...(stateBeforeWait ? { stateBeforeWait } : {}), diff --git a/src/shared/agent-hook-listener/providers/claude-lifecycle-events.ts b/src/shared/agent-hook-listener/providers/claude-lifecycle-events.ts index 2e616ddbb8be..b2ab91beb06e 100644 --- a/src/shared/agent-hook-listener/providers/claude-lifecycle-events.ts +++ b/src/shared/agent-hook-listener/providers/claude-lifecycle-events.ts @@ -10,6 +10,7 @@ import { import type { HookListenerState } from '../listener-state' import { readString } from '../tool-input-preview' import { + claudeLeadTurnInterrupted, clearClaudePendingWaitForAgent, getOrCreateClaudeSubagentRoster, resolveClaudePaneStatus @@ -84,7 +85,7 @@ export function normalizeClaudeSubagentLifecycleEvent( const hasUnconfirmedChild = claudeRosterHasRestoredSnapshotSubagent(roster) const hasConfirmedDoneGate = cachedLead?.state === 'done' && - cachedLead.interrupted !== true && + !claudeLeadTurnInterrupted(cachedLead) && (state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) || state.claudeActiveSessionCronPaneKeys.has(paneKey)) const restoredOnlyDoneGate = @@ -134,10 +135,10 @@ export function buildClaudeCachedLeadStatusPayload( return buildClaudeStatusPayload(state, eventName, '', paneKey, hookPayload, { ...resolveClaudePaneStatus(state, paneKey, { state: leadState, - interrupted: lead?.interrupted + outcome: lead?.outcome }), updateToolSnapshot: false, - interrupted: lead?.interrupted, + interrupted: claudeLeadTurnInterrupted(lead), // Why: draining the last background child is this turn's all-clear; the stamp lets a consumer pair it with the announcement already sent. turnCompletedAt: lead?.turnCompletedAt }) diff --git a/src/shared/agent-hook-listener/providers/claude-roster-state.ts b/src/shared/agent-hook-listener/providers/claude-roster-state.ts index a557ac4c31f2..da6545a54067 100644 --- a/src/shared/agent-hook-listener/providers/claude-roster-state.ts +++ b/src/shared/agent-hook-listener/providers/claude-roster-state.ts @@ -1,5 +1,13 @@ -import type { AgentSubagentSnapshot, AgentWorkingMode } from '../../agent-status-types' -import { foldAgentLeadStatus, type AgentLeadStatusResolution } from '../../agent-lead-status-fold' +import type { + AgentLeadStatus, + AgentSubagentSnapshot, + AgentWorkingMode +} from '../../agent-status-types' +import { + continueAgentLeadStatus, + foldAgentLeadStatus, + type AgentLeadStatusResolution +} from '../../agent-lead-status-fold' import { agentChildWorkLivenessFromEvidence } from '../../agent-status-child-work-liveness' import { claudeRosterHasWorkingSubagent, @@ -118,14 +126,49 @@ export function updateClaudeRunningNonAgentTask( export type ClaudePaneStatusResolution = AgentLeadStatusResolution +/** A cancelled turn is the one verdict the display fold still reads. */ +export function claudeLeadTurnInterrupted( + lead: Pick | undefined +): boolean { + return lead?.outcome === 'cancellation' +} + +/** The only writer of the lead record. The lead's clock keeps continuity across same-state + * writes; a caller restoring a stash passes the stashed instant and wins. */ +export function setClaudeLeadTurnState( + state: HookListenerState, + paneKey: string, + next: Omit & { stateStartedAt?: number }, + now = Date.now() +): ClaudeLeadTurnState { + const previous = state.claudeLeadStateByPaneKey.get(paneKey) + const { state: nextState, outcome, stateStartedAt, ...rest } = next + const lead: ClaudeLeadTurnState = { + ...rest, + ...continueAgentLeadStatus(previous, { state: nextState, outcome, stateStartedAt }, now) + } + state.claudeLeadStateByPaneKey.set(paneKey, lead) + return lead +} + +/** The `lead` fact a row publishes from its record: nothing invented, so a pane whose lead was + * never observed publishes none and readers fall back to the combined `state`. */ +export function claudeLeadStatusForPayload(lead: ClaudeLeadTurnState): AgentLeadStatus { + return { + state: lead.state, + ...(lead.state === 'done' && lead.outcome ? { outcome: lead.outcome } : {}), + stateStartedAt: lead.stateStartedAt + } +} + export function resolveClaudePaneStatus( state: HookListenerState, paneKey: string, - lead: Pick + lead: Pick ): ClaudePaneStatusResolution { return foldAgentLeadStatus({ leadState: lead.state, - interrupted: lead.interrupted === true, + interrupted: claudeLeadTurnInterrupted(lead), childWorkLiveness: agentChildWorkLivenessFromEvidence({ hasLiveAgentWork: claudeRosterHasWorkingSubagent( state.claudeSubagentRosterByPaneKey.get(paneKey) @@ -138,7 +181,7 @@ export function resolveClaudePaneStatus( } /** Sync the Claude lead-turn record when the SERVER infers an interrupt outside the hook stream (Ctrl+C with a missed Stop); else a later child lifecycle event resurrects the cancelled pane. */ export function markClaudeLeadTurnInterrupted(state: HookListenerState, paneKey: string): void { - state.claudeLeadStateByPaneKey.set(paneKey, { state: 'done', interrupted: true }) + setClaudeLeadTurnState(state, paneKey, { state: 'done', outcome: 'cancellation' }) state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) state.claudeActiveSessionCronPaneKeys.delete(paneKey) } @@ -177,10 +220,18 @@ export function seedClaudeLeadTurnFromPersistedStatus( status: Pick, options: { childOnlyBoundary: boolean } ): void { - if (options.childOnlyBoundary && status.payload.agentType === 'claude') { - state.claudeLeadStateByPaneKey.set(paneKey, { + const lead = status.payload.lead + // Why: the persisted `lead` is the fact; a row old enough to lack it was mapped from its + // legacy child-only flag at hydrate, so both shapes arrive here as `lead.state === 'done'`. + if ( + options.childOnlyBoundary && + status.payload.agentType === 'claude' && + lead?.state === 'done' + ) { + setClaudeLeadTurnState(state, paneKey, { state: 'done', - ...(status.payload.interrupted === true ? { interrupted: true } : {}), + ...(lead.outcome ? { outcome: lead.outcome } : {}), + stateStartedAt: lead.stateStartedAt, ...(status.payload.turnCompletedAt !== undefined ? { turnCompletedAt: status.payload.turnCompletedAt } : {}) @@ -226,7 +277,7 @@ export function clearClaudePendingWaitForAgent( if (lead?.state !== 'waiting' || !lead.waitingAgentId || !ownsWait(lead.waitingAgentId)) { return } - state.claudeLeadStateByPaneKey.set(paneKey, lead.stateBeforeWait ?? { state: 'working' }) + setClaudeLeadTurnState(state, paneKey, lead.stateBeforeWait ?? { state: 'working' }) const previousTool = state.lastToolByPaneKey.get(paneKey) state.lastToolByPaneKey.set( paneKey, @@ -243,15 +294,18 @@ export function clearClaudePendingWaitForAgent( export function clearClaudeAnsweredQuestionWait( state: HookListenerState, paneKey: string -): Pick & { +): Pick & { + interrupted?: true workingMode?: AgentWorkingMode + lead: AgentLeadStatus } { const lead = state.claudeLeadStateByPaneKey.get(paneKey) - const restored = + const stash = lead?.state === 'waiting' ? (lead.stateBeforeWait ?? { state: 'working' as const }) : { state: 'working' as const } - state.claudeLeadStateByPaneKey.set(paneKey, { ...restored }) + const restored = setClaudeLeadTurnState(state, paneKey, { ...stash }) + const publishedLead = claudeLeadStatusForPayload(restored) const previousTool = state.lastToolByPaneKey.get(paneKey) state.lastToolByPaneKey.set( paneKey, @@ -263,14 +317,13 @@ export function clearClaudeAnsweredQuestionWait( : {} ) const resolved = resolveClaudePaneStatus(state, paneKey, restored) - return resolved.stateName === restored.state && resolved.workingMode === undefined - ? restored - : { - state: resolved.stateName, - ...(resolved.workingMode ? { workingMode: resolved.workingMode } : {}), - ...(restored.interrupted ? { interrupted: true as const } : {}), - ...(restored.turnCompletedAt !== undefined - ? { turnCompletedAt: restored.turnCompletedAt } - : {}) - } + return { + state: resolved.stateName, + ...(resolved.workingMode ? { workingMode: resolved.workingMode } : {}), + ...(claudeLeadTurnInterrupted(restored) ? { interrupted: true as const } : {}), + ...(restored.turnCompletedAt !== undefined + ? { turnCompletedAt: restored.turnCompletedAt } + : {}), + lead: publishedLead + } } diff --git a/src/shared/agent-hook-listener/providers/claude-status-build.ts b/src/shared/agent-hook-listener/providers/claude-status-build.ts index e865a5c841db..0e83c7cce218 100644 --- a/src/shared/agent-hook-listener/providers/claude-status-build.ts +++ b/src/shared/agent-hook-listener/providers/claude-status-build.ts @@ -8,6 +8,7 @@ import { claudeRosterToSnapshots } from '../../claude-subagent-roster' import { resolvePrompt, resolveToolState } from '../prompt-fields' import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' import type { HookListenerState } from '../listener-state' +import { claudeLeadStatusForPayload } from './claude-roster-state' export function buildClaudeStatusPayload( state: HookListenerState, @@ -31,6 +32,7 @@ export function buildClaudeStatusPayload( }) : (state.lastToolByPaneKey.get(paneKey) ?? {}) + const leadRecord = state.claudeLeadStateByPaneKey.get(paneKey) // Why: validate directly — the JSON stringify/parse round trip other normalizers use is pure overhead on this hot per-hook path. // The normalizer clamps `interrupted` to done payloads, so a gated 'working' emit drops it; claudeLeadStateByPaneKey preserves it for the eventual done. return normalizeAgentStatusPayload({ @@ -49,6 +51,8 @@ export function buildClaudeStatusPayload( interrupted: options.interrupted, sessionBoundary: options.sessionBoundary, turnCompletedAt: options.turnCompletedAt, - subagents: claudeRosterToSnapshots(state.claudeSubagentRosterByPaneKey.get(paneKey)) + subagents: claudeRosterToSnapshots(state.claudeSubagentRosterByPaneKey.get(paneKey)), + // Why: every path writes the lead record before building, so the row's `lead` is that record. + lead: leadRecord ? claudeLeadStatusForPayload(leadRecord) : undefined }) } diff --git a/src/shared/agent-hook-listener/providers/codex-events.ts b/src/shared/agent-hook-listener/providers/codex-events.ts index ad2ce3db92f2..258b4f449f19 100644 --- a/src/shared/agent-hook-listener/providers/codex-events.ts +++ b/src/shared/agent-hook-listener/providers/codex-events.ts @@ -22,9 +22,11 @@ import { resolvePrompt, resolveToolState } from '../prompt-fields' import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' import { readString } from '../tool-input-preview' import { + codexLeadStatusForPayload, getOrCreateCodexSubagentRoster, getOrCreateCodexSubagentTranscriptState, - hasCodexTranscriptSubagents + hasCodexTranscriptSubagents, + setCodexLeadTurnState } from './codex-state' export function buildCodexStatusPayload( @@ -54,7 +56,8 @@ export function buildCodexStatusPayload( interactivePrompt: snapshot.interactivePrompt, lastAssistantMessage: snapshot.lastAssistantMessage, lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput, - subagents: codexRosterToSnapshots(state.codexSubagentRosterByPaneKey.get(paneKey)) + subagents: codexRosterToSnapshots(state.codexSubagentRosterByPaneKey.get(paneKey)), + lead: codexLeadStatusForPayload(lead) }) } @@ -224,12 +227,14 @@ export function normalizeCodexEvent( stateName ) const previousLead = state.codexLeadStateByPaneKey.get(paneKey) - state.codexLeadStateByPaneKey.set(paneKey, { + setCodexLeadTurnState(state, paneKey, { state: ownedState, model: normalizeOptionalField(hookPayload['model'], AGENT_MODEL_MAX_LENGTH) ?? (eventName === 'SessionStart' ? undefined : previousLead?.model) }) + // The combined state keeps Codex's own rule (a waiting child wins, a done root with any live + // child reads working); folding it onto `foldAgentLeadStatus` is a separate slice. const effectiveState = codexRosterEffectiveState( state.codexSubagentRosterByPaneKey.get(paneKey), ownedState diff --git a/src/shared/agent-hook-listener/providers/codex-state.ts b/src/shared/agent-hook-listener/providers/codex-state.ts index 9add18220992..5417a28ca585 100644 --- a/src/shared/agent-hook-listener/providers/codex-state.ts +++ b/src/shared/agent-hook-listener/providers/codex-state.ts @@ -1,4 +1,5 @@ -import type { ParsedAgentStatusPayload } from '../../agent-status-types' +import type { AgentLeadStatus, ParsedAgentStatusPayload } from '../../agent-status-types' +import { continueAgentLeadStatus } from '../../agent-lead-status-fold' import { codexRosterEffectiveState, codexRosterToSnapshots, @@ -41,18 +42,63 @@ export function hasCodexTranscriptSubagents(state: HookListenerState, paneKey: s return hasTrackedCodexTranscriptSubagents(state.codexSubagentTranscriptByPaneKey.get(paneKey)) } +/** The only writer of the root record; the root's clock keeps continuity across same-state writes. */ +export function setCodexLeadTurnState( + state: HookListenerState, + paneKey: string, + next: Omit & { stateStartedAt?: number }, + now = Date.now() +): CodexLeadTurnState { + const previous = state.codexLeadStateByPaneKey.get(paneKey) + const continued = continueAgentLeadStatus(previous, next, now) + const lead: CodexLeadTurnState = { + state: next.state, + ...(continued.outcome ? { outcome: continued.outcome } : {}), + stateStartedAt: continued.stateStartedAt, + model: next.model + } + state.codexLeadStateByPaneKey.set(paneKey, lead) + return lead +} + +/** The `lead` fact a Codex row publishes. Its combined `state` still comes from + * `codexRosterEffectiveState`, whose waiting-child rule the shared fold cannot express yet; + * moving that combine onto the fold is a separate slice with its own story table. */ +export function codexLeadStatusForPayload( + lead: CodexLeadTurnState | undefined +): AgentLeadStatus | undefined { + return lead + ? { + state: lead.state, + ...(lead.state === 'done' && lead.outcome ? { outcome: lead.outcome } : {}), + stateStartedAt: lead.stateStartedAt + } + : undefined +} + export function seedCodexStateFromSnapshot( state: HookListenerState, paneKey: string, - payload: Pick + payload: Pick ): void { const snapshots = payload.subagents ?? [] if (snapshots.length > 0 && !state.codexSubagentRosterByPaneKey.has(paneKey)) { seedCodexSubagentRoster(getOrCreateCodexSubagentRoster(state, paneKey), snapshots) } if (!state.codexLeadStateByPaneKey.has(paneKey)) { + const lead = payload.lead // Why: child hooks after restart omit the root model; seed it from durable status before they can overwrite the cache. - state.codexLeadStateByPaneKey.set(paneKey, { + // A row that carries the root's own state is the fact; only an older row makes us infer it. + if (lead && lead.state !== 'blocked') { + setCodexLeadTurnState(state, paneKey, { + state: lead.state, + ...(lead.outcome ? { outcome: lead.outcome } : {}), + stateStartedAt: lead.stateStartedAt, + model: payload.model + }) + return + } + setCodexLeadTurnState(state, paneKey, { // Why: a child wait drives the aggregate waiting state, so it is not evidence that the root itself was waiting. state: payload.state === 'done' @@ -69,7 +115,11 @@ export function seedCodexStateFromSnapshot( /** Sync the Codex lead record when the server infers an interrupt, so delayed child events cannot restore stale working state. */ export function markCodexLeadTurnInterrupted(state: HookListenerState, paneKey: string): void { const lead = state.codexLeadStateByPaneKey.get(paneKey) - state.codexLeadStateByPaneKey.set(paneKey, { state: 'done', model: lead?.model }) + setCodexLeadTurnState(state, paneKey, { + state: 'done', + outcome: 'cancellation', + model: lead?.model + }) } export function codexLeadStateForHookEvent( @@ -130,7 +180,7 @@ export function reconcileRemoteCodexState( } if (leadState) { const previousLead = state.codexLeadStateByPaneKey.get(paneKey) - state.codexLeadStateByPaneKey.set(paneKey, { + setCodexLeadTurnState(state, paneKey, { state: leadState, model: payload.model ?? previousLead?.model }) @@ -152,6 +202,8 @@ export function reconcileRemoteCodexState( prompt, state: codexRosterEffectiveState(roster, lead.state), model: lead.model ?? payload.model, - subagents: codexRosterToSnapshots(roster) + subagents: codexRosterToSnapshots(roster), + // Why: main's cache outlives a relay restart, so it is the lead fact for a relayed row too. + lead: codexLeadStatusForPayload(lead) } } diff --git a/src/shared/agent-hook-listener/providers/grok-events.ts b/src/shared/agent-hook-listener/providers/grok-events.ts index d80ba4bf94e7..c67457669977 100644 --- a/src/shared/agent-hook-listener/providers/grok-events.ts +++ b/src/shared/agent-hook-listener/providers/grok-events.ts @@ -3,6 +3,8 @@ import { type ParsedAgentStatusPayload } from '../../agent-status-types' import { isAskUserQuestionTool } from '../../agent-question-answered-intent' +import { continueAgentLeadStatus, foldAgentLeadStatus } from '../../agent-lead-status-fold' +import type { AgentChildWorkLiveness } from '../../agent-status-child-work-liveness' import { clearPaneTurnCacheState, type HookListenerState } from '../listener-state' import { normalizeGrokPromptId } from '../listener-limits' import { resolvePrompt, resolveToolState, stripGrokUserQueryWrapper } from '../prompt-fields' @@ -103,9 +105,16 @@ function grokHasRunningFiniteTask(hookPayload: Record): boolean }) } -function grokStopKeepsWorking(hookPayload: Record): boolean { +/** What a plain `stop` leaves running behind the lead. Grok reports its finite tasks without a + * kind the roster could classify as agent work, and a still-active stop hook holds the turn the + * same way, so both read as watch work: the pane stays `working` in monitoring mode. */ +function grokChildWorkLivenessAfterStop( + hookPayload: Record +): AgentChildWorkLiveness { const stopHookActive = aliasedField(hookPayload, 'stopHookActive', 'stop_hook_active') return stopHookActive.value === true || grokHasRunningFiniteTask(hookPayload) + ? 'monitoring' + : null } function isGrokSessionBoundary(eventName: unknown, hookPayload: Record): boolean { @@ -130,6 +139,7 @@ export function normalizeGrokEvent( } if (isGrokEvent(eventName, 'session_start')) { // Why: SessionStart resets stale per-turn state but must not create a working row before any prompt/tool event. + // The lead clock goes with it: a new process is a new lead. clearPaneTurnCacheState(state, paneKey) return null } @@ -156,22 +166,16 @@ export function normalizeGrokEvent( if (isTurnEnd && !grokTurnEndApplies(state, paneKey, hookPayload)) { return null } - let stateName: 'working' | 'waiting' | 'done' | null = null + let leadState: 'working' | 'waiting' | 'done' | null = null if ( isGrokEvent(eventName, 'user_prompt_submit', 'post_tool_use', 'post_tool_use_failure') || (isGrokEvent(eventName, 'pre_tool_use') && !isUserInputPreTool) ) { - stateName = 'working' + leadState = 'working' } else if (isUserInputPreTool) { - stateName = 'waiting' - } else if ( - isGrokEvent(eventName, 'stop') && - !sessionBoundary && - grokStopKeepsWorking(hookPayload) - ) { - stateName = 'working' + leadState = 'waiting' } else if (isTurnEnd || isGrokEvent(eventName, 'session_end') || isIdlePrompt) { - stateName = 'done' + leadState = 'done' } else if ( isGrokEvent(eventName, 'notification') && isGrokEvent(notificationType, 'task_complete') @@ -191,11 +195,34 @@ export function normalizeGrokEvent( isGrokEvent(eventName, 'notification') && isGrokPermissionNotification(notificationMessage) ) { - stateName = 'waiting' + leadState = 'waiting' } - if (!stateName) { + if (!leadState) { return null } + // Why: the provider's verdict, never inferred — a plain `stop` stays absent. + const outcome = isGrokEvent(eventName, 'stop_cancelled') + ? ('cancellation' as const) + : isGrokEvent(eventName, 'stop_failure') + ? ('failure' as const) + : undefined + // Only a plain end-of-turn `stop` reports what it left running; a cancel, a failure and a + // session boundary settle the pane whatever the inventory says, as they always have. + const resolution = foldAgentLeadStatus({ + leadState, + interrupted: outcome === 'cancellation', + childWorkLiveness: + isGrokEvent(eventName, 'stop') && !sessionBoundary + ? grokChildWorkLivenessAfterStop(hookPayload) + : null + }) + const stateName = resolution.stateName + const lead = continueAgentLeadStatus( + state.grokLeadStatusByPaneKey.get(paneKey), + { state: leadState, outcome }, + Date.now() + ) + state.grokLeadStatusByPaneKey.set(paneKey, lead) const snapshot = resolveToolState( state, @@ -220,10 +247,9 @@ export function normalizeGrokEvent( interactivePrompt: snapshot.interactivePrompt, lastAssistantMessage: snapshot.lastAssistantMessage, lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput, - ...(stateName === 'working' && isGrokEvent(eventName, 'stop') - ? { workingMode: 'monitoring' as const } - : {}), - ...(isGrokEvent(eventName, 'stop_cancelled') ? { interrupted: true } : {}), - ...(sessionBoundary ? { sessionBoundary: true } : {}) + ...(resolution.workingMode ? { workingMode: resolution.workingMode } : {}), + ...(outcome === 'cancellation' ? { interrupted: true } : {}), + ...(sessionBoundary ? { sessionBoundary: true } : {}), + lead }) } diff --git a/src/shared/agent-lead-status-fold.test.ts b/src/shared/agent-lead-status-fold.test.ts index d39c5d78cb6a..e5db63abd218 100644 --- a/src/shared/agent-lead-status-fold.test.ts +++ b/src/shared/agent-lead-status-fold.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { foldAgentLeadStatus } from './agent-lead-status-fold' +import { + continueAgentLeadStatus, + foldAgentLeadStatus, + isAgentStatusHeldOpenByChildWork +} from './agent-lead-status-fold' describe('foldAgentLeadStatus', () => { it('keeps a lead that is not settled, whatever its children do', () => { @@ -43,3 +47,43 @@ describe('foldAgentLeadStatus', () => { ).toEqual({ stateName: 'done' }) }) }) + +describe('isAgentStatusHeldOpenByChildWork', () => { + it('is true only when a settled lead sits under a row that is not settled', () => { + expect(isAgentStatusHeldOpenByChildWork({ state: 'working', lead: { state: 'done' } })).toBe( + true + ) + expect(isAgentStatusHeldOpenByChildWork({ state: 'done', lead: { state: 'done' } })).toBe(false) + expect(isAgentStatusHeldOpenByChildWork({ state: 'working', lead: { state: 'working' } })).toBe( + false + ) + // No lead fact means no claim: an old host's row is never read as child-held. + expect(isAgentStatusHeldOpenByChildWork({ state: 'working' })).toBe(false) + }) +}) + +describe('continueAgentLeadStatus', () => { + it('keeps the clock across an unchanged state and restarts it on a change', () => { + const first = continueAgentLeadStatus(undefined, { state: 'working' }, 10) + expect(first).toEqual({ state: 'working', stateStartedAt: 10 }) + expect(continueAgentLeadStatus(first, { state: 'working' }, 20)).toEqual({ + state: 'working', + stateStartedAt: 10 + }) + expect(continueAgentLeadStatus(first, { state: 'done', outcome: 'failure' }, 30)).toEqual({ + state: 'done', + outcome: 'failure', + stateStartedAt: 30 + }) + }) + + it('lets a caller that knows the instant win, and never carries a verdict onto a live state', () => { + expect(continueAgentLeadStatus(undefined, { state: 'done', stateStartedAt: 4 }, 30)).toEqual({ + state: 'done', + stateStartedAt: 4 + }) + expect( + continueAgentLeadStatus(undefined, { state: 'working', outcome: 'cancellation' }, 30) + ).toEqual({ state: 'working', stateStartedAt: 30 }) + }) +}) diff --git a/src/shared/agent-lead-status-fold.ts b/src/shared/agent-lead-status-fold.ts index 95ad86f5596a..c35872cda876 100644 --- a/src/shared/agent-lead-status-fold.ts +++ b/src/shared/agent-lead-status-fold.ts @@ -1,5 +1,5 @@ import type { AgentChildWorkLiveness } from './agent-status-child-work-liveness' -import type { AgentStatusState, AgentWorkingMode } from './agent-status-types' +import type { AgentLeadStatus, AgentStatusState, AgentWorkingMode } from './agent-status-types' export type AgentLeadStatusFoldInput = { /** The lead's own turn state. Anything but `done` wins outright. */ @@ -33,3 +33,30 @@ export function foldAgentLeadStatus(input: AgentLeadStatusFoldInput): AgentLeadS } return { stateName: 'done' } } + +/** The lead settled and live child work is the only thing holding the row open. Derived, + * never stored: a stored copy could disagree with the two facts it is made of. */ +export function isAgentStatusHeldOpenByChildWork(row: { + state: AgentStatusState + lead?: Pick +}): boolean { + return row.lead?.state === 'done' && row.state !== 'done' +} + +/** The lead's clock follows the same continuity rule as the row's: an unchanged lead state + * keeps the instant it first appeared, a changed one starts at `now`. A caller that knows + * the real instant (a restored stash, a journal record) passes it and wins. */ +export function continueAgentLeadStatus( + previous: Pick | undefined, + next: { state: AgentStatusState; outcome?: AgentLeadStatus['outcome']; stateStartedAt?: number }, + now: number +): AgentLeadStatus { + const stateStartedAt = + next.stateStartedAt ?? + (previous && previous.state === next.state ? previous.stateStartedAt : now) + return { + state: next.state, + ...(next.state === 'done' && next.outcome ? { outcome: next.outcome } : {}), + stateStartedAt + } +} diff --git a/src/shared/agent-lead-status-parity.test.ts b/src/shared/agent-lead-status-parity.test.ts new file mode 100644 index 000000000000..d5a00c3a7730 --- /dev/null +++ b/src/shared/agent-lead-status-parity.test.ts @@ -0,0 +1,355 @@ +// One story table driven through every lane that publishes a lead agent's status. Each lane +// derives child liveness from its own evidence, but the published `{ state, workingMode, lead }` +// must be what the shared fold says for that lead and that evidence — a producer that folds +// differently is caught here structurally, not by review. +import { beforeEach, describe, expect, it } from 'vitest' +import { normalizeHookPayload } from './agent-hook-listener' +import { + createHookListenerState, + type HookListenerState +} from './agent-hook-listener/listener-state' +import { PANE_KEY } from './agent-hook-listener-test-harness' +import { foldAgentLeadStatus } from './agent-lead-status-fold' +import type { AgentSessionBackgroundTask } from './agent-session-background-task-wire' +import { + agentChildWorkLiveness, + agentChildWorkLivenessFromEvidence, + type AgentChildWorkLiveness +} from './agent-status-child-work-liveness' +import type { + AgentLeadStatus, + AgentStatusState, + AgentWorkingMode, + ParsedAgentStatusPayload +} from './agent-status-types' +import { codexRosterEffectiveState, seedCodexSubagentRoster } from './codex-subagent-roster' +import { structuredAgentSessionAgentStatus } from './structured-agent-session-agent-status' +import type { AgentJournalTurnOutcome } from './agent-turn-outcome' + +type Published = { + state: AgentStatusState + workingMode?: AgentWorkingMode + lead: Omit +} + +const RUNNING_SHELL = { id: 'shell-1', type: 'shell', status: 'running' } +const RUNNING_AGENT = { id: 'agent-1', type: 'subagent', status: 'running' } +const AGENT_TASK: AgentSessionBackgroundTask = { id: 'agent-1', kind: 'agent', state: 'working' } +const SHELL_TASK: AgentSessionBackgroundTask = { id: 'shell-1', kind: 'command', state: 'working' } + +function published(payload: ParsedAgentStatusPayload | null | undefined): Published { + if (!payload?.lead) { + throw new Error('the lane published no lead fact') + } + const { stateStartedAt: _clock, ...lead } = payload.lead + return { + state: payload.state, + ...(payload.workingMode ? { workingMode: payload.workingMode } : {}), + lead + } +} + +/** The lead's own state and verdict, restated as the fold's inputs. */ +function refold(lead: Published['lead'], childWorkLiveness: AgentChildWorkLiveness): Published { + const resolution = foldAgentLeadStatus({ + leadState: lead.state, + interrupted: lead.outcome === 'cancellation', + childWorkLiveness + }) + return { + state: resolution.stateName, + ...(resolution.workingMode ? { workingMode: resolution.workingMode } : {}), + lead + } +} + +type Story = { + name: string + claude?: { events: Record[]; expect: Published } + structured?: { + status: 'working' | 'attention' | 'idle' + backgroundTasks?: AgentSessionBackgroundTask[] + turnOutcome?: AgentJournalTurnOutcome + expect: Published + } + grok?: { events: Record[]; expect: Published } + codex?: { events: Record[]; expect: Published } +} + +const STORIES: Story[] = [ + { + name: 'main agent working', + claude: { + events: [{ hook_event_name: 'UserPromptSubmit', prompt: 'go' }], + expect: { state: 'working', lead: { state: 'working' } } + }, + structured: { status: 'working', expect: { state: 'working', lead: { state: 'working' } } }, + grok: { + events: [{ hookEventName: 'user_prompt_submit', prompt: 'go' }], + expect: { state: 'working', lead: { state: 'working' } } + }, + codex: { + events: [{ hook_event_name: 'UserPromptSubmit', prompt: 'go' }], + expect: { state: 'working', lead: { state: 'working' } } + } + }, + { + name: 'done with a live subagent', + claude: { + events: [ + { hook_event_name: 'UserPromptSubmit', prompt: 'go' }, + { hook_event_name: 'SubagentStart', agent_id: 'agent-1' }, + { hook_event_name: 'Stop', background_tasks: [RUNNING_AGENT] } + ], + expect: { state: 'working', lead: { state: 'done' } } + }, + structured: { + status: 'idle', + backgroundTasks: [AGENT_TASK], + expect: { state: 'working', lead: { state: 'done' } } + }, + grok: { + events: [ + { hookEventName: 'user_prompt_submit', prompt: 'go' }, + { hookEventName: 'stop', reason: 'end_turn', backgroundTasks: [RUNNING_AGENT] } + ], + // Grok reports no task kind the roster can classify as agent work, so its live subagent + // reads as watch work. Today's label, kept on purpose; a Grok-specific follow-up. + expect: { state: 'working', workingMode: 'monitoring', lead: { state: 'done' } } + }, + codex: { + // A root Stop with no transcript-tracked children clears the roster (Codex 0.144 could omit + // child Stop hooks), so the child proves it is still alive with its next tool event. + events: [ + { hook_event_name: 'UserPromptSubmit', prompt: 'go' }, + { hook_event_name: 'SubagentStart', agent_id: 'agent-1' }, + { hook_event_name: 'Stop' }, + { hook_event_name: 'PreToolUse', agent_id: 'agent-1', tool_name: 'shell' } + ], + expect: { state: 'working', lead: { state: 'done' } } + } + }, + { + name: 'done with only a watch loop', + claude: { + events: [ + { hook_event_name: 'UserPromptSubmit', prompt: 'go' }, + { hook_event_name: 'Stop', background_tasks: [RUNNING_SHELL] } + ], + expect: { state: 'working', workingMode: 'monitoring', lead: { state: 'done' } } + }, + structured: { + status: 'idle', + backgroundTasks: [SHELL_TASK], + expect: { state: 'working', workingMode: 'monitoring', lead: { state: 'done' } } + }, + grok: { + events: [ + { hookEventName: 'user_prompt_submit', prompt: 'go' }, + { hookEventName: 'stop', reason: 'end_turn', backgroundTasks: [RUNNING_SHELL] } + ], + expect: { state: 'working', workingMode: 'monitoring', lead: { state: 'done' } } + } + }, + { + name: 'blocked with a live subagent', + claude: { + events: [ + { hook_event_name: 'UserPromptSubmit', prompt: 'go' }, + { hook_event_name: 'SubagentStart', agent_id: 'agent-1' }, + { hook_event_name: 'PermissionRequest', tool_name: 'Bash', tool_input: { command: 'rm' } } + ], + // The hook lane's vocabulary for "the lead needs a human" is `waiting`. + expect: { state: 'waiting', lead: { state: 'waiting' } } + }, + structured: { + status: 'attention', + backgroundTasks: [AGENT_TASK], + expect: { state: 'blocked', lead: { state: 'blocked' } } + }, + grok: { + events: [ + { hookEventName: 'user_prompt_submit', prompt: 'go' }, + { hookEventName: 'pre_tool_use', toolName: 'ask_user_question' } + ], + expect: { state: 'waiting', lead: { state: 'waiting' } } + }, + codex: { + events: [ + { hook_event_name: 'UserPromptSubmit', prompt: 'go' }, + { hook_event_name: 'SubagentStart', agent_id: 'agent-1' }, + { hook_event_name: 'PermissionRequest', tool_name: 'shell' } + ], + expect: { state: 'waiting', lead: { state: 'waiting' } } + } + }, + { + name: 'failed turn', + claude: { + events: [ + { hook_event_name: 'UserPromptSubmit', prompt: 'go' }, + { hook_event_name: 'StopFailure', error: 'invalid_request' } + ], + expect: { state: 'done', lead: { state: 'done', outcome: 'failure' } } + }, + structured: { + status: 'idle', + turnOutcome: 'failure', + expect: { state: 'done', lead: { state: 'done', outcome: 'failure' } } + }, + grok: { + events: [ + { hookEventName: 'user_prompt_submit', prompt: 'go' }, + { hookEventName: 'stop_failure' } + ], + expect: { state: 'done', lead: { state: 'done', outcome: 'failure' } } + } + }, + { + // KNOWN DIVERGENCE, pinned on purpose. The hook lane hides a still-running shell after an + // interrupted turn; the structured lane never feeds the verdict into the fold and keeps + // showing the shell. The cancel policy (PR C) flips the hook-lane rows to monitoring and + // must update this story, not delete it. + name: 'interrupted with a watch loop (known divergence: CLI done / structured monitoring)', + claude: { + events: [ + { hook_event_name: 'UserPromptSubmit', prompt: 'go' }, + { hook_event_name: 'Stop', is_interrupt: true, background_tasks: [RUNNING_SHELL] } + ], + expect: { state: 'done', lead: { state: 'done', outcome: 'cancellation' } } + }, + structured: { + status: 'idle', + turnOutcome: 'cancellation', + backgroundTasks: [SHELL_TASK], + expect: { + state: 'working', + workingMode: 'monitoring', + lead: { state: 'done', outcome: 'cancellation' } + } + }, + grok: { + events: [ + { hookEventName: 'user_prompt_submit', prompt: 'go' }, + { hookEventName: 'stop_cancelled', backgroundTasks: [RUNNING_SHELL] } + ], + expect: { state: 'done', lead: { state: 'done', outcome: 'cancellation' } } + } + } +] + +/** Codex never reports a blocked root; the combine's input type says so. */ +function codexLeadState(state: AgentStatusState): 'working' | 'waiting' | 'done' { + if (state === 'blocked') { + throw new Error('Codex published a blocked lead') + } + return state +} + +/** The stories a lane takes part in, as `it.each` rows. */ +function storiesFor( + lane: K +): [string, NonNullable][] { + const rows: [string, NonNullable][] = [] + for (const story of STORIES) { + const entry = story[lane] + if (entry !== undefined) { + rows.push([story.name, entry]) + } + } + return rows +} + +describe('lead status parity across lanes', () => { + let state: HookListenerState + + beforeEach(() => { + state = createHookListenerState() + }) + + function drive( + source: 'claude' | 'grok' | 'codex', + events: Record[] + ): ParsedAgentStatusPayload { + let last: ParsedAgentStatusPayload | null = null + for (const payload of events) { + const event = normalizeHookPayload( + state, + source, + { paneKey: PANE_KEY, payload }, + 'production' + ) + last = event?.payload ?? last + } + if (!last) { + throw new Error('the lane published nothing') + } + return last + } + + /** The hook lane's child evidence: the roster on the row, the shell and cron sets in memory. */ + function claudeChildWorkLiveness(payload: ParsedAgentStatusPayload): AgentChildWorkLiveness { + return agentChildWorkLivenessFromEvidence({ + hasLiveAgentWork: payload.subagents?.some((child) => child.state === 'working') === true, + hasLiveNonAgentWork: + state.claudeRunningNonAgentTaskPaneKeys.has(PANE_KEY) || + state.claudeActiveSessionCronPaneKeys.has(PANE_KEY) + }) + } + + describe('Claude hook lane', () => { + it.each(storiesFor('claude'))('%s', (_name, lane) => { + const payload = drive('claude', lane.events) + const row = published(payload) + expect(row).toEqual(lane.expect) + expect(row).toEqual(refold(row.lead, claudeChildWorkLiveness(payload))) + }) + }) + + describe('structured lane', () => { + it.each(storiesFor('structured'))('%s', (_name, lane) => { + const row = structuredAgentSessionAgentStatus({ + status: lane.status, + backgroundTasks: lane.backgroundTasks, + turnOutcome: lane.turnOutcome + }) + expect(row).toEqual(lane.expect) + // This lane never feeds the verdict into the fold: refold with the verdict masked. + const masked = { state: row.lead.state } + expect(refold(masked, agentChildWorkLiveness(lane.backgroundTasks))).toEqual({ + ...row, + lead: masked + }) + }) + }) + + describe('Grok hook lane', () => { + it.each(storiesFor('grok'))('%s', (_name, lane) => { + const payload = drive('grok', lane.events) + const row = published(payload) + expect(row).toEqual(lane.expect) + // Grok's child evidence lives only on its final plain `stop`: a finite task or an active + // stop hook is watch work, and nothing else ever holds the pane. + const last = lane.events.at(-1) ?? {} + const tasks = Array.isArray(last.backgroundTasks) ? last.backgroundTasks : [] + const liveness: AgentChildWorkLiveness = + last.hookEventName === 'stop' && (tasks.length > 0 || last.stopHookActive === true) + ? 'monitoring' + : null + expect(row).toEqual(refold(row.lead, liveness)) + }) + }) + + describe('Codex hook lane (own combine, not the shared fold)', () => { + it.each(storiesFor('codex'))('%s', (_name, lane) => { + const payload = drive('codex', lane.events) + const row = published(payload) + expect(row).toEqual(lane.expect) + // Codex keeps `codexRosterEffectiveState` until its combine moves onto the fold: a + // waiting child wins, a settled root with any live child reads working, no monitoring. + const roster = new Map() + seedCodexSubagentRoster(roster, payload.subagents ?? []) + expect(row.state).toBe(codexRosterEffectiveState(roster, codexLeadState(row.lead.state))) + }) + }) +}) diff --git a/src/shared/agent-lead-status.ts b/src/shared/agent-lead-status.ts new file mode 100644 index 000000000000..f550dab7f547 --- /dev/null +++ b/src/shared/agent-lead-status.ts @@ -0,0 +1,31 @@ +import type { AgentStatusState } from './agent-status-types' +import type { AgentJournalTurnOutcome } from './agent-turn-outcome' + +/** The main (lead) agent's OWN state, kept apart from the row's combined `state`. The row's + * `state` answers "what should the user see" and folds live child work in, so a settled lead + * whose subagent still runs reads `working`; this answers "what is the lead itself doing". + * Persisted on disk and carried on every wire, so its shape is permanent. */ +export type AgentLeadStatus = { + state: AgentStatusState + /** The provider's verdict on the lead's most recent finished turn. Present only while + * `state` is `done`; a new turn clears it. ABSENT MEANS UNKNOWN — a plain Stop never + * infers `success`, because an older provider that omits its interrupt flag would turn + * a cancel into a false success. */ + outcome?: AgentJournalTurnOutcome + /** When the lead's own `state` first appeared (ms). The row's `stateStartedAt` dates the + * combined state instead, so the two differ while child work holds the row open. */ + stateStartedAt: number +} + +export function agentLeadStatusEqual( + a: AgentLeadStatus | undefined, + b: AgentLeadStatus | undefined +): boolean { + if (a === b) { + return true + } + if (!a || !b) { + return false + } + return a.state === b.state && a.outcome === b.outcome && a.stateStartedAt === b.stateStartedAt +} diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index 8a49117eeea7..88c024a19624 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -8,6 +8,7 @@ // journal rather than skipping or compacting past it. import type { AgentType } from './agent-status-types' +import type { AgentJournalTurnOutcome } from './agent-turn-outcome' import type { NativeChatToolMetadata } from './native-chat-tool-identity' import type { NativeChatBlock, NativeChatRole } from './native-chat-types' @@ -193,12 +194,9 @@ export const AGENT_JOURNAL_TURN_LIFECYCLE_STATES = [ ] as const export type AgentJournalTurnLifecycleState = (typeof AGENT_JOURNAL_TURN_LIFECYCLE_STATES)[number] -/** What the PROVIDER said became of a turn, kept separate from the lifecycle - * state so the four arms above stay a report on what the HOST observed. - * `cancellation` is a stop somebody asked for, `failure` is the provider's own - * error, and the two are never interchangeable: only `failure` is a fault. */ -export const AGENT_JOURNAL_TURN_OUTCOMES = ['success', 'failure', 'cancellation'] as const -export type AgentJournalTurnOutcome = (typeof AGENT_JOURNAL_TURN_OUTCOMES)[number] +// The provider verdict vocabulary lives in agent-turn-outcome.ts so the agent-status +// row can share it without importing the journal; re-exported to keep one import site. +export { AGENT_JOURNAL_TURN_OUTCOMES, type AgentJournalTurnOutcome } from './agent-turn-outcome' export type AgentJournalTurnLifecycle = { turnId: string diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index 0bd97464af4b..d73a9ae09a5a 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -219,6 +219,11 @@ export type AgentSessionStatusSummary = { toolInput?: string /** Preview of the newest assistant prose, so a settled row says what the agent said. */ lastAssistantMessage?: string + /** The provider's verdict on the newest settled root turn. Present only while `status` is + * `idle`: a running or attention-blocked turn has no verdict yet, and a stale one must not + * ride along. Absent means UNKNOWN, never success. Optional for mixed-version hosts; the + * agent-status row publishes it as `lead.outcome`. */ + turnOutcome?: AgentJournalTurnOutcome /** Live provider-owned background tasks, so session lists can render * subagent children without holding a journal reader open. Optional for * mixed-version hosts. */ @@ -239,9 +244,12 @@ export type AgentSessionStatusEvent = /** * One root turn reaching a terminal outcome, derived by the EXECUTION HOST at journal commit. * - * Deliberately not a field on `AgentSessionStatusSummary`: that summary carries no turn identity - * and no outcome, it is re-broadcast on every status change, and adding an outcome would make - * every status consumer a completion consumer. A completion is a rare edge, not a state. + * This is the EDGE, with turn identity; `AgentSessionStatusSummary.turnOutcome` is the STATE. + * The summary carries the verdict only while the session is idle, as a fact about the lead's + * last turn that a status reader may act on (attention alerts, the `lead.outcome` row field), + * and never a turn id: a reader that needs to know WHICH turn finished, or to react exactly once + * per finish, subscribes here. Re-broadcasting the summary on every status change therefore + * repeats a state, not a completion. * * `outcome` is A0's provider verdict and is never inferred — a turn the host only observed ending * carries no outcome and produces no event at all, because absent means UNKNOWN, not success. diff --git a/src/shared/agent-status-types.test.ts b/src/shared/agent-status-types.test.ts index f79f3c974f36..ff3796ea257c 100644 --- a/src/shared/agent-status-types.test.ts +++ b/src/shared/agent-status-types.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, it, expect, vi } from 'vitest' import { + agentLeadStatusEqual, agentSubagentsEqual, isFreshNonDoneAgentStatus, parseAgentStatusPayload, @@ -707,3 +708,54 @@ describe('WellKnownAgentType', () => { expect(custom).toBe('some-in-house-agent') }) }) + +describe('the lead field on a status payload', () => { + it('admits a well-formed lead with its verdict only while the lead is done', () => { + expect( + parseAgentStatusPayload( + '{"state":"working","lead":{"state":"done","outcome":"cancellation","stateStartedAt":5}}' + )?.lead + ).toEqual({ state: 'done', outcome: 'cancellation', stateStartedAt: 5 }) + // A verdict belongs to a finished turn; one riding on a live lead state is stale. + expect( + parseAgentStatusPayload( + '{"state":"working","lead":{"state":"working","outcome":"failure","stateStartedAt":5}}' + )?.lead + ).toEqual({ state: 'working', stateStartedAt: 5 }) + expect( + parseAgentStatusPayload( + '{"state":"done","lead":{"state":"done","outcome":"maybe","stateStartedAt":5}}' + )?.lead + ).toEqual({ state: 'done', stateStartedAt: 5 }) + }) + + it('drops a malformed lead but never the row it rides on', () => { + for (const lead of [ + '"done"', + '{"state":"running","stateStartedAt":5}', + '{"state":"done"}', + '{"state":"done","stateStartedAt":"5"}', + '{"stateStartedAt":5}', + 'null' + ]) { + const parsed = parseAgentStatusPayload( + `{"state":"working","prompt":"keep me","lead":${lead}}` + ) + expect(parsed, lead).toMatchObject({ state: 'working', prompt: 'keep me' }) + expect(parsed?.lead, lead).toBeUndefined() + } + }) + + it('is carried by the client-visible projection and compared structurally', () => { + const lead = { state: 'done' as const, stateStartedAt: 7 } + expect(pickParsedAgentStatusPayload({ state: 'working', prompt: '', lead }).lead).toEqual(lead) + expect(pickParsedAgentStatusPayload({ state: 'working', prompt: '' })).not.toHaveProperty( + 'lead' + ) + expect(agentLeadStatusEqual(lead, { ...lead })).toBe(true) + expect(agentLeadStatusEqual(lead, { ...lead, outcome: 'failure' })).toBe(false) + expect(agentLeadStatusEqual(lead, { ...lead, stateStartedAt: 8 })).toBe(false) + expect(agentLeadStatusEqual(undefined, undefined)).toBe(true) + expect(agentLeadStatusEqual(lead, undefined)).toBe(false) + }) +}) diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index b863aa39c0c6..d6f7b432cbe8 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -3,6 +3,8 @@ // a narrow interrupt fallback synthesizes a final `done` when an agent misses its cancellation hook. import type { AgentProviderSessionMetadata } from './agent-session-resume' +import type { AgentLeadStatus } from './agent-lead-status' +import { isAgentJournalTurnOutcome } from './agent-turn-outcome' import type { OrchestrationFleetAttention } from './orchestration-fleet-attention' import type { AgentStatusRowFacets } from './agent-status-observation' import type { TuiAgent } from './tui-agent' @@ -22,10 +24,12 @@ export type { AgentStatusIpcPayload, MigrationUnsupportedPtyEntry } from './agent-status-ipc-payload' +export { agentLeadStatusEqual, type AgentLeadStatus } from './agent-lead-status' export const AGENT_STATUS_STATES = ['working', 'blocked', 'waiting', 'done'] as const export type AgentStatusState = (typeof AGENT_STATUS_STATES)[number] export type AgentWorkingMode = 'monitoring' + // Why: agent types aren't a fixed set (custom agents exist); any non-empty string is // accepted — the well-known names are the launchable TuiAgent ids plus the 'unknown' // sentinel (no agent identified yet), a convenience union for pattern-matching. @@ -149,6 +153,9 @@ export type AgentStatusEntry = { /** Live in-process subagents/teammates of this pane's session. Absent when * none are tracked; the sidebar derives indented child rows from it. */ subagents?: AgentSubagentSnapshot[] + /** The lead's own state; absent from old hosts and from writers that carry no lead fact + * (OSC, launch seeds), where readers fall back to `state`. */ + lead?: AgentLeadStatus /** Provider-owned conversation/session id captured from hook payloads. * Used only for exact CLI resume; Orca terminal ids are not agent-session ids. */ providerSession?: AgentProviderSessionMetadata @@ -193,6 +200,9 @@ export type AgentStatusPayload = { turnCompletedAt?: number /** Live in-process children of the reporting session. See AgentStatusEntry. */ subagents?: AgentSubagentSnapshot[] + /** The lead's own state and last-turn verdict. See AgentLeadStatus. Producers publish it + * beside the combined `state`; a reader that predates it keeps reading `state`. */ + lead?: AgentLeadStatus } /** @@ -230,7 +240,8 @@ export function pickParsedAgentStatusPayload( ...(row.interrupted !== undefined ? { interrupted: row.interrupted } : {}), ...(row.sessionBoundary !== undefined ? { sessionBoundary: row.sessionBoundary } : {}), ...(row.turnCompletedAt !== undefined ? { turnCompletedAt: row.turnCompletedAt } : {}), - ...(row.subagents !== undefined ? { subagents: row.subagents } : {}) + ...(row.subagents !== undefined ? { subagents: row.subagents } : {}), + ...(row.lead !== undefined ? { lead: row.lead } : {}) } } @@ -259,6 +270,10 @@ export { // Why: ReadonlySet so .has() accepts any string without a cast here; the narrowing cast stays on the return line where it's proven safe. const VALID_STATES: ReadonlySet = new Set(AGENT_STATUS_STATES) + +export function isAgentStatusState(value: unknown): value is AgentStatusState { + return typeof value === 'string' && VALID_STATES.has(value) +} /** Maximum character length for the agentType label. Truncated on parse. */ export const AGENT_TYPE_MAX_LENGTH = 40 export const AGENT_MODEL_MAX_LENGTH = 120 @@ -321,6 +336,28 @@ function normalizeSubagentsField(value: unknown): AgentSubagentSnapshot[] | unde return normalized.length > 0 ? normalized : undefined } +/** A malformed `lead` drops the FIELD, never the row: the combined `state` is still valid + * evidence, and readers fall back to it exactly as they do for a host that predates the field. */ +function normalizeLeadStatusField(value: unknown): AgentLeadStatus | undefined { + if (typeof value !== 'object' || value === null) { + return undefined + } + const obj = value as Record + const state = obj.state + if (!isAgentStatusState(state)) { + return undefined + } + if (typeof obj.stateStartedAt !== 'number' || !Number.isFinite(obj.stateStartedAt)) { + return undefined + } + return { + state, + // Why: a verdict belongs to a finished turn; anything riding on a live state is stale. + ...(state === 'done' && isAgentJournalTurnOutcome(obj.outcome) ? { outcome: obj.outcome } : {}), + stateStartedAt: obj.stateStartedAt + } +} + /** Structural equality for subagent lists so stores can reuse the previous * array reference (and skip fanout) when nothing actually changed. */ export function agentSubagentsEqual( @@ -397,7 +434,8 @@ function normalizeAgentStatusObject(parsed: unknown): ParsedAgentStatusPayload | interrupted: obj.interrupted === true && state === 'done' ? true : undefined, sessionBoundary: obj.sessionBoundary === true && state === 'done' ? true : undefined, turnCompletedAt: normalizeTurnCompletedAtField(obj.turnCompletedAt, state), - subagents: normalizeSubagentsField(obj.subagents) + subagents: normalizeSubagentsField(obj.subagents), + lead: normalizeLeadStatusField(obj.lead) } } diff --git a/src/shared/agent-turn-outcome.ts b/src/shared/agent-turn-outcome.ts new file mode 100644 index 000000000000..bb07a6500bc1 --- /dev/null +++ b/src/shared/agent-turn-outcome.ts @@ -0,0 +1,12 @@ +/** What the PROVIDER said became of a turn, kept separate from any lifecycle + * state so those stay a report on what the HOST observed. `cancellation` is a + * stop somebody asked for, `failure` is the provider's own error, and the two + * are never interchangeable: only `failure` is a fault. Shared by the journal's + * turn record and the agent-status row's `lead` fact so one vocabulary serves + * both; absent always means UNKNOWN, never success. */ +export const AGENT_JOURNAL_TURN_OUTCOMES = ['success', 'failure', 'cancellation'] as const +export type AgentJournalTurnOutcome = (typeof AGENT_JOURNAL_TURN_OUTCOMES)[number] + +export function isAgentJournalTurnOutcome(value: unknown): value is AgentJournalTurnOutcome { + return AGENT_JOURNAL_TURN_OUTCOMES.some((known) => known === value) +} diff --git a/src/shared/structured-agent-session-agent-status.test.ts b/src/shared/structured-agent-session-agent-status.test.ts index 6df9d473c22e..c381d6f9c6aa 100644 --- a/src/shared/structured-agent-session-agent-status.test.ts +++ b/src/shared/structured-agent-session-agent-status.test.ts @@ -14,20 +14,20 @@ describe('structuredAgentSessionAgentStatus', () => { it('maps a lead that is still working or needs attention without consulting children', () => { expect(structuredAgentSessionAgentStatus({ status: 'working' })).toEqual({ state: 'working', - fromChildWork: false + lead: { state: 'working' } }) expect( structuredAgentSessionAgentStatus({ status: 'attention', backgroundTasks: [task({ kind: 'command' })] }) - ).toEqual({ state: 'blocked', fromChildWork: false }) + ).toEqual({ state: 'blocked', lead: { state: 'blocked' } }) }) - it('keeps an idle lead working while a subagent runs', () => { + it('keeps an idle lead working while a subagent runs, and says the lead itself is done', () => { expect( structuredAgentSessionAgentStatus({ status: 'idle', backgroundTasks: [task()] }) - ).toEqual({ state: 'working', fromChildWork: true }) + ).toEqual({ state: 'working', lead: { state: 'done' } }) }) it('reads an idle lead with only a backgrounded shell as monitoring', () => { @@ -36,14 +36,14 @@ describe('structuredAgentSessionAgentStatus', () => { status: 'idle', backgroundTasks: [task({ kind: 'command', description: 'sleep 180' })] }) - ).toEqual({ state: 'working', workingMode: 'monitoring', fromChildWork: true }) + ).toEqual({ state: 'working', workingMode: 'monitoring', lead: { state: 'done' } }) }) it('keeps an idle lead working while a subagent is blocked or out of contact', () => { for (const state of ['waiting', 'blocked', 'unverifiable'] as const) { expect( structuredAgentSessionAgentStatus({ status: 'idle', backgroundTasks: [task({ state })] }) - ).toEqual({ state: 'working', fromChildWork: true }) + ).toEqual({ state: 'working', lead: { state: 'done' } }) } }) @@ -54,7 +54,7 @@ describe('structuredAgentSessionAgentStatus', () => { expect(structuredAgentSessionAgentStatus({ status: 'idle', backgroundTasks })).toEqual({ state: 'working', workingMode: 'monitoring', - fromChildWork: true + lead: { state: 'done' } }) expect( projectAgentChildWorkLegacySubagents( @@ -72,10 +72,32 @@ describe('structuredAgentSessionAgentStatus', () => { task({ id: 'shell', kind: 'command', state: 'idle' }) ] }) - ).toEqual({ state: 'done', fromChildWork: false }) + ).toEqual({ state: 'done', lead: { state: 'done' } }) expect(structuredAgentSessionAgentStatus({ status: 'idle' })).toEqual({ state: 'done', - fromChildWork: false + lead: { state: 'done' } }) }) + + // The verdict is a fact about a finished turn; the fold never reads it, so a cancelled turn with + // a watch loop still reads monitoring here (the hook lane's known divergence, until PR C). + it('carries the turn verdict on the lead only while the lead is done', () => { + expect( + structuredAgentSessionAgentStatus({ status: 'idle', turnOutcome: 'cancellation' }) + ).toEqual({ state: 'done', lead: { state: 'done', outcome: 'cancellation' } }) + expect( + structuredAgentSessionAgentStatus({ + status: 'idle', + turnOutcome: 'cancellation', + backgroundTasks: [task({ kind: 'command' })] + }) + ).toEqual({ + state: 'working', + workingMode: 'monitoring', + lead: { state: 'done', outcome: 'cancellation' } + }) + expect( + structuredAgentSessionAgentStatus({ status: 'working', turnOutcome: 'failure' }) + ).toEqual({ state: 'working', lead: { state: 'working' } }) + }) }) diff --git a/src/shared/structured-agent-session-agent-status.ts b/src/shared/structured-agent-session-agent-status.ts index 937c68506455..e45d2c7bef97 100644 --- a/src/shared/structured-agent-session-agent-status.ts +++ b/src/shared/structured-agent-session-agent-status.ts @@ -1,15 +1,15 @@ import type { AgentSessionStatusSummary } from './agent-session-wire' import { foldAgentLeadStatus } from './agent-lead-status-fold' import { agentChildWorkLiveness } from './agent-status-child-work-liveness' -import type { AgentStatusState, AgentWorkingMode } from './agent-status-types' +import type { AgentLeadStatus, AgentStatusState, AgentWorkingMode } from './agent-status-types' import type { StructuredAgentSessionProjectedStatus } from './structured-agent-session-projection' export type StructuredAgentSessionAgentStatus = { state: AgentStatusState workingMode?: AgentWorkingMode - /** The lead had settled and live child work is the only thing holding this row open. The - * journal cannot date such a row: its clock stopped when the lead's turn did. */ - fromChildWork: boolean + /** The lead's own state and last-turn verdict, before child work is folded in. The caller + * stamps the clock: this projection has no view of when the lead's state first appeared. */ + lead: Omit } /** The lead state one projected session status stands for, before child work is folded in. */ @@ -23,22 +23,26 @@ function structuredAgentSessionLeadState( * work folded in the same way the hook lane folds a subagent roster. Shared across the * process boundary so `worktree ps`, mobile and the sidebar cannot disagree about one session. */ export function structuredAgentSessionAgentStatus( - summary: Pick & { + summary: Pick & { status: StructuredAgentSessionProjectedStatus } ): StructuredAgentSessionAgentStatus { const leadState = structuredAgentSessionLeadState(summary.status) const resolution = foldAgentLeadStatus({ leadState, - // Inert, not decided: a projected session status has no interrupted member, so this lane - // cannot express one. The hook lane's guard exists to distrust a stale inventory snapshot; - // here the task list is the provider's live roster and a settled task leaves it on its own. + // Known divergence from the hook lane, kept on purpose until the cancel policy lands: that + // lane hides a still-running shell after an interrupted turn (`updateClaudeRunningNonAgentTask` + // calls it a live-shell judgement), so a cancelled turn with a watch loop reads `done` there + // and `monitoring` here. This lane never feeds the verdict into the fold — see `lead.outcome`. interrupted: false, childWorkLiveness: agentChildWorkLiveness(summary.backgroundTasks) }) return { state: resolution.stateName, ...(resolution.workingMode ? { workingMode: resolution.workingMode } : {}), - fromChildWork: leadState === 'done' && resolution.stateName !== 'done' + lead: { + state: leadState, + ...(leadState === 'done' && summary.turnOutcome ? { outcome: summary.turnOutcome } : {}) + } } } diff --git a/src/shared/structured-agent-session-projection.test.ts b/src/shared/structured-agent-session-projection.test.ts index 7b515f325a08..9c3a64a90106 100644 --- a/src/shared/structured-agent-session-projection.test.ts +++ b/src/shared/structured-agent-session-projection.test.ts @@ -701,3 +701,43 @@ describe("producer linkage — a subagent's output never speaks for the parent", ).toBe('legacy child line') }) }) + +describe('the turn verdict on the status summary', () => { + const user = item('u1', 1, { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'go' }] + }) + + it('carries the newest settled turn verdict only while the session is idle', () => { + const running = item('turn-running', 2, { + kind: 'turn', + turnId: 'turn-1', + state: 'running' + }) + expect(projectStructuredAgentSessionStatusSummary([user, running])).not.toHaveProperty( + 'turnOutcome' + ) + const cancelled = item('turn-cancelled', 3, { + kind: 'turn', + turnId: 'turn-1', + state: 'interrupted', + outcome: 'cancellation' + }) + expect(projectStructuredAgentSessionStatusSummary([user, cancelled])).toMatchObject({ + status: 'idle', + turnOutcome: 'cancellation' + }) + }) + + it('reports no verdict for a settled turn the provider never judged', () => { + const completed = item('turn-completed', 2, { + kind: 'turn', + turnId: 'turn-1', + state: 'completed' + }) + expect(projectStructuredAgentSessionStatusSummary([user, completed])).not.toHaveProperty( + 'turnOutcome' + ) + }) +}) diff --git a/src/shared/structured-agent-session-projection.ts b/src/shared/structured-agent-session-projection.ts index 5f45d001c181..4b5bfb5d0982 100644 --- a/src/shared/structured-agent-session-projection.ts +++ b/src/shared/structured-agent-session-projection.ts @@ -7,9 +7,11 @@ import { AGENT_JOURNAL_MESSAGE_SEND_MODES, type AgentJournalMessageSendMode, type AgentJournalRenderItem, - type AgentJournalSubmission + type AgentJournalSubmission, + type AgentJournalTurnOutcome } from './agent-session-journal-types' import { isRootAgentJournalItem } from './agent-session-journal-producer' +import { readAgentJournalTurnOutcome } from './agent-session-turn-record' import { AGENT_STATUS_TOOL_INPUT_MAX_LENGTH, AGENT_STATUS_TOOL_NAME_MAX_LENGTH @@ -17,6 +19,7 @@ import { import { describeToolInput } from './native-chat-tool-summary' import { activeStructuredAgentSessionTurnId, + newestStructuredAgentSessionTurn, statusStructuredAgentSessionToolCall } from './structured-agent-session-live-turn' import { @@ -308,6 +311,8 @@ export type StructuredAgentSessionStatusProjection = { toolName?: string toolInput?: string lastAssistantMessage?: string + /** The newest settled turn's provider verdict; present only while `status` is idle. */ + turnOutcome?: AgentJournalTurnOutcome } /** One projection shared by host and client: null status means "no turn yet", not idle. @@ -344,12 +349,17 @@ export function projectStructuredAgentSessionStatusSummary( latestStructuredAgentSessionAssistantMessage(items), AGENT_STATUS_MAX_FIELD_LENGTH ) + // A verdict is a fact about a finished turn: only an idle session has one to report, and + // `readAgentJournalTurnOutcome` already answers null for anything it cannot place. + const turnOutcome = + status === 'idle' ? readAgentJournalTurnOutcome(newestStructuredAgentSessionTurn(items)) : null return { status, latestPrompt: normalizePromptField(latestStructuredAgentSessionPrompt(items)), ...(toolName ? { toolName } : {}), ...(toolInput ? { toolInput } : {}), - ...(lastAssistantMessage ? { lastAssistantMessage } : {}) + ...(lastAssistantMessage ? { lastAssistantMessage } : {}), + ...(turnOutcome ? { turnOutcome } : {}) } } From f85e4242777efdfe814a863f43d054fd6eb0f63d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 02:53:00 -0700 Subject: [PATCH 02/19] fix(agent-status): make Orca's inferred interrupt the primary source of a Claude lead cancellation Current Claude Code sends no hook at all on a cancel and no is_interrupt on Stop, so the cancellation enters the lead record from the server's inferred interrupt and rides into the next real Stop; is_interrupt on a turn boundary stays as the secondary source for builds that send it. Comments, the store reference and the parity table say so; no suppression changes. --- docs/reference/agent-status-store.md | 5 +++ ...nt-hook-listener-claude-turn-state.test.ts | 33 +++++++++++++++++++ .../providers/claude-events.ts | 7 ++-- .../providers/claude-roster-state.ts | 2 +- src/shared/agent-lead-status-parity.test.ts | 24 ++++++++++++-- 5 files changed, 66 insertions(+), 5 deletions(-) diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index 94bbee128362..d349cefb2f8e 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -206,6 +206,11 @@ the row's child evidence instead of stored. `outcome` is the provider's verdict on the lead's most recent finished turn, present only while `lead.state` is `done`; 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 `lead` drops the field and keeps the row. Old hosts diff --git a/src/shared/agent-hook-listener-claude-turn-state.test.ts b/src/shared/agent-hook-listener-claude-turn-state.test.ts index b06b9a76910f..0728f1227ada 100644 --- a/src/shared/agent-hook-listener-claude-turn-state.test.ts +++ b/src/shared/agent-hook-listener-claude-turn-state.test.ts @@ -4,6 +4,7 @@ import { type HookListenerState } from './agent-hook-listener/listener-state' import { normalizeHookPayload } from './agent-hook-listener' +import { markClaudeLeadTurnInterrupted } from './agent-hook-listener/providers/claude-roster-state' import { clearGrokSessionPathLookupCacheForTests } from './grok-session-paths' import { CLAUDE_PREVIOUS_PROMPT_ID, @@ -451,3 +452,35 @@ describe('the lead verdict across a child-induced wait', () => { }) }) }) + +describe('the lead verdict from an inferred interrupt', () => { + let state: HookListenerState + + beforeEach(() => { + state = createHookListenerState() + }) + + function claude(payload: Record) { + return normalizeHookPayload(state, 'claude', { paneKey: PANE_KEY, payload }, 'production') + ?.payload + } + + // Current Claude sends no hook on a cancel and no `is_interrupt` on Stop, so the cancellation + // enters the lead record from Orca's inferred interrupt and rides into the next real Stop. + it('carries the inferred cancellation into the next plain Stop', () => { + claude({ hook_event_name: 'UserPromptSubmit', prompt: 'go' }) + markClaudeLeadTurnInterrupted(state, PANE_KEY) + expect(state.claudeLeadStateByPaneKey.get(PANE_KEY)).toMatchObject({ + state: 'done', + outcome: 'cancellation' + }) + const settledAt = state.claudeLeadStateByPaneKey.get(PANE_KEY)?.stateStartedAt + + const stop = claude({ hook_event_name: 'Stop' }) + expect(stop).toMatchObject({ + state: 'done', + interrupted: true, + lead: { state: 'done', outcome: 'cancellation', stateStartedAt: settledAt } + }) + }) +}) diff --git a/src/shared/agent-hook-listener/providers/claude-events.ts b/src/shared/agent-hook-listener/providers/claude-events.ts index 19e9d7090d39..fd355a94ad5e 100644 --- a/src/shared/agent-hook-listener/providers/claude-events.ts +++ b/src/shared/agent-hook-listener/providers/claude-events.ts @@ -80,8 +80,11 @@ export function normalizeClaudeEvent( claudeLeadTurnInterrupted(previousLead)) ? true : undefined - // Why: the provider's verdict, never inferred — a plain Stop stays absent, because an older - // Claude that omits `is_interrupt` would otherwise turn a cancel into a false success. + // Why: a verdict, never a guess — a plain Stop stays absent, so a cancel can never read as a + // success. Current Claude sends NO hook on a cancel and no `is_interrupt` on Stop, so the + // cancellation normally arrives through Orca's own inferred interrupt + // (`markClaudeLeadTurnInterrupted`) and is carried forward here; `is_interrupt` on a turn + // boundary is kept as the secondary source for builds that do send it. const outcome = interrupted ? ('cancellation' as const) : isTurnBoundary && eventName === 'StopFailure' diff --git a/src/shared/agent-hook-listener/providers/claude-roster-state.ts b/src/shared/agent-hook-listener/providers/claude-roster-state.ts index da6545a54067..bc3a14868a63 100644 --- a/src/shared/agent-hook-listener/providers/claude-roster-state.ts +++ b/src/shared/agent-hook-listener/providers/claude-roster-state.ts @@ -179,7 +179,7 @@ export function resolveClaudePaneStatus( }) }) } -/** Sync the Claude lead-turn record when the SERVER infers an interrupt outside the hook stream (Ctrl+C with a missed Stop); else a later child lifecycle event resurrects the cancelled pane. */ +/** Sync the Claude lead-turn record when the SERVER infers an interrupt outside the hook stream (Ctrl+C or Esc with no Stop, which is what current Claude does on every cancel); else a later child lifecycle event resurrects the cancelled pane. This is the primary source of `lead.outcome: 'cancellation'` in the CLI lane. */ export function markClaudeLeadTurnInterrupted(state: HookListenerState, paneKey: string): void { setClaudeLeadTurnState(state, paneKey, { state: 'done', outcome: 'cancellation' }) state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) diff --git a/src/shared/agent-lead-status-parity.test.ts b/src/shared/agent-lead-status-parity.test.ts index d5a00c3a7730..cf14ee00c92f 100644 --- a/src/shared/agent-lead-status-parity.test.ts +++ b/src/shared/agent-lead-status-parity.test.ts @@ -4,6 +4,7 @@ // differently is caught here structurally, not by review. import { beforeEach, describe, expect, it } from 'vitest' import { normalizeHookPayload } from './agent-hook-listener' +import { markClaudeLeadTurnInterrupted } from './agent-hook-listener/providers/claude-roster-state' import { createHookListenerState, type HookListenerState @@ -33,6 +34,8 @@ type Published = { } const RUNNING_SHELL = { id: 'shell-1', type: 'shell', status: 'running' } +/** Not a hook: current Claude sends none on a cancel, so Orca infers it from the keystroke. */ +const ORCA_INFERRED_INTERRUPT = { orca_inferred_interrupt: true } const RUNNING_AGENT = { id: 'agent-1', type: 'subagent', status: 'running' } const AGENT_TASK: AgentSessionBackgroundTask = { id: 'agent-1', kind: 'agent', state: 'working' } const SHELL_TASK: AgentSessionBackgroundTask = { id: 'shell-1', kind: 'command', state: 'working' } @@ -209,12 +212,14 @@ const STORIES: Story[] = [ // KNOWN DIVERGENCE, pinned on purpose. The hook lane hides a still-running shell after an // interrupted turn; the structured lane never feeds the verdict into the fold and keeps // showing the shell. The cancel policy (PR C) flips the hook-lane rows to monitoring and - // must update this story, not delete it. + // must update this story, not delete it. The Claude row here is the primary path: Orca's + // inferred cancel, carried by the lead record into the next Stop, which lists the shell. name: 'interrupted with a watch loop (known divergence: CLI done / structured monitoring)', claude: { events: [ { hook_event_name: 'UserPromptSubmit', prompt: 'go' }, - { hook_event_name: 'Stop', is_interrupt: true, background_tasks: [RUNNING_SHELL] } + ORCA_INFERRED_INTERRUPT, + { hook_event_name: 'Stop', background_tasks: [RUNNING_SHELL] } ], expect: { state: 'done', lead: { state: 'done', outcome: 'cancellation' } } }, @@ -235,6 +240,17 @@ const STORIES: Story[] = [ ], expect: { state: 'done', lead: { state: 'done', outcome: 'cancellation' } } } + }, + { + // Secondary source: a build that does send `is_interrupt` on its Stop. Same known divergence. + name: 'interrupted by a Stop that carries is_interrupt, with a watch loop (older builds)', + claude: { + events: [ + { hook_event_name: 'UserPromptSubmit', prompt: 'go' }, + { hook_event_name: 'Stop', is_interrupt: true, background_tasks: [RUNNING_SHELL] } + ], + expect: { state: 'done', lead: { state: 'done', outcome: 'cancellation' } } + } } ] @@ -273,6 +289,10 @@ describe('lead status parity across lanes', () => { ): ParsedAgentStatusPayload { let last: ParsedAgentStatusPayload | null = null for (const payload of events) { + if (payload === ORCA_INFERRED_INTERRUPT) { + markClaudeLeadTurnInterrupted(state, PANE_KEY) + continue + } const event = normalizeHookPayload( state, source, From c644a1c359a892065db1ddab719b1b8a6d43a4d1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 03:12:11 -0700 Subject: [PATCH 03/19] docs(agent-status): the child-only boundary comment now describes the persisted shell fact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old sentence said a hydrated row no longer carries the shell fact, which is the opposite of the mechanism: claudeRunningNonAgentTask is persisted precisely so hydration can read it, and only a pre-lead row lacks it — reading as shell-free, the same assertion its legacy flag made at write time. --- src/main/agent-hooks/server/server-claude-status-rules.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/agent-hooks/server/server-claude-status-rules.ts b/src/main/agent-hooks/server/server-claude-status-rules.ts index efdfb6668acc..f697710ca15e 100644 --- a/src/main/agent-hooks/server/server-claude-status-rules.ts +++ b/src/main/agent-hooks/server/server-claude-status-rules.ts @@ -7,8 +7,9 @@ import type { EnrichedAgentHookEventPayload } from './server-types' /** The Claude lead has settled and child agents alone hold the row `working`. Derived from the * row's `lead` fact and its child evidence, never stored: the persisted flag this replaced was a * second copy of `lead.state === 'done'` that could disagree with it. A running shell beside the - * agents disqualifies a live row; a hydrated row no longer carries that fact and errs toward - * keeping its hook-written state. */ + * agents disqualifies the row; that fact rides `claudeRunningNonAgentTask` (persisted since the + * flag stopped being written), and a row old enough to lack it reads as shell-free — which is + * exactly what its legacy child-only flag asserted at write time. */ export function isClaudeLeadBoundaryHeldByChildrenOnly( row: Pick ): boolean { From 662d101d077081178580a8d6b4631b80e385b95a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:22:26 -0700 Subject: [PATCH 04/19] rename the lead fact to mainAgent: the main agent's own state --- docs/reference/agent-status-store.md | 30 +++--- .../server-ingest-structured-status.test.ts | 16 +-- .../server-ingest-terminal-status.test.ts | 12 +-- ...ver-interrupt-inference-validation.test.ts | 6 +- .../server-last-status-lead-boundary.test.ts | 4 +- ...erver-last-status-main-agent-fact.test.ts} | 46 ++++---- .../server-last-status-write.test.ts | 6 +- .../server/server-claude-status-rules.ts | 8 +- .../agent-hooks/server/server-hydration.ts | 4 +- .../server/server-ingest-structured.ts | 16 +-- .../server/server-ingest-terminal.ts | 20 ++-- .../server/server-persistence-validation.ts | 20 ++-- .../agent-hooks/server/server-persistence.ts | 2 +- .../server/server-status-inference.ts | 6 +- src/main/agent-hooks/server/server-types.ts | 4 +- .../terminal-handle-row-identity.test.ts | 4 +- ...e-agent-row-main-agent-projection.test.ts} | 8 +- ...tructuredAgentSessionStatusBridge.test.tsx | 10 +- .../StructuredAgentSessionStatusBridge.tsx | 16 +-- .../normalize-agent-status-event.ts | 2 +- .../slices/agent-status-live-entry-builder.ts | 6 +- .../slices/agent-status-live-entry-lead.ts | 23 ---- .../agent-status-live-entry-main-agent.ts | 23 ++++ ...s => agent-status-main-agent-fact.test.ts} | 32 +++--- ...ent-hook-listener-claude-subagents.test.ts | 8 +- ...nt-hook-listener-claude-turn-state.test.ts | 22 ++-- ...nt-hook-listener-codex-main-agent.test.ts} | 10 +- .../agent-hook-listener/listener-state.ts | 22 ++-- ...turn-state.ts => main-agent-turn-state.ts} | 10 +- .../providers/claude-events.ts | 16 +-- .../providers/claude-lifecycle-events.ts | 6 +- .../providers/claude-roster-state.ts | 68 ++++++------ .../providers/claude-status-build.ts | 8 +- .../providers/codex-events.ts | 8 +- .../providers/codex-state.ts | 54 +++++----- .../providers/grok-events.ts | 14 +-- src/shared/agent-lead-status-fold.test.ts | 30 +++--- src/shared/agent-lead-status-fold.ts | 22 ++-- src/shared/agent-session-wire.ts | 6 +- src/shared/agent-status-types.test.ts | 48 +++++---- src/shared/agent-status-types.ts | 20 ++-- src/shared/agent-turn-outcome.ts | 2 +- ...st.ts => main-agent-status-parity.test.ts} | 100 ++++++++++-------- ...nt-lead-status.ts => main-agent-status.ts} | 18 ++-- ...uctured-agent-session-agent-status.test.ts | 26 ++--- .../structured-agent-session-agent-status.ts | 12 +-- 46 files changed, 444 insertions(+), 410 deletions(-) rename src/main/agent-hooks/{server-last-status-lead-fact.test.ts => server-last-status-main-agent-fact.test.ts} (76%) rename src/main/runtime/{runtime-agent-row-lead-projection.test.ts => runtime-agent-row-main-agent-projection.test.ts} (72%) delete mode 100644 src/renderer/src/store/slices/agent-status-live-entry-lead.ts create mode 100644 src/renderer/src/store/slices/agent-status-live-entry-main-agent.ts rename src/renderer/src/store/slices/{agent-status-lead-fact.test.ts => agent-status-main-agent-fact.test.ts} (57%) rename src/shared/{agent-hook-listener-codex-lead.test.ts => agent-hook-listener-codex-main-agent.test.ts} (78%) rename src/shared/agent-hook-listener/{lead-turn-state.ts => main-agent-turn-state.ts} (70%) rename src/shared/{agent-lead-status-parity.test.ts => main-agent-status-parity.test.ts} (77%) rename src/shared/{agent-lead-status.ts => main-agent-status.ts} (64%) diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index d349cefb2f8e..73484af465ef 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -101,7 +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 | -| `lead` | the lead's own state before the fold, its last-turn verdict (`summary.turnOutcome`, present only while idle) and its own clock; see "The lead fact" below | +| `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 | @@ -188,22 +188,22 @@ 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 lead fact +### The main agent fact -Every producer publishes the row's combined `state` and, beside it, the lead -agent's own state as `payload.lead`: +Every producer publishes the row's combined `state` and, beside it, the main +agent's own state as `payload.mainAgent`: ```ts -lead?: { state: AgentStatusState; outcome?: AgentJournalTurnOutcome; stateStartedAt: number } +mainAgent?: { state: AgentStatusState; outcome?: AgentJournalTurnOutcome; stateStartedAt: number } ``` `state` still answers "what should the user see" and folds live child work in, -so a settled lead whose subagent still runs reads `working`. `lead` answers -"what is the lead itself doing", which the fold used to destroy at publish +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) is now derived from `lead` plus +persisted `claudeLeadBoundaryChildOnly` flag) is now derived from `mainAgent` plus the row's child evidence instead of stored. `outcome` is the provider's verdict -on the lead's most recent finished turn, present only while `lead.state` is +on the main agent's most recent finished turn, present only while `mainAgent.state` is `done`; 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 @@ -213,13 +213,13 @@ 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 `lead` drops the field and keeps the row. Old hosts +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 `lead: { state: 'done' }` when the row has no `lead`, and never writes the +onto `mainAgent: { state: 'done' }` when the row has no `mainAgent`, and never writes the flag again. `claudeRunningNonAgentTask` is persisted alongside because it is -the one child-work fact `lead` cannot express: whether a shell was running -beside the child agents, which decides whether a settled lead may be seeded +the one child-work fact `mainAgent` cannot express: whether a shell was running +beside the child agents, which decides whether a settled main agent may be seeded at hydrate. Two combining rules remain outside the shared fold and are named so a reader @@ -227,11 +227,11 @@ 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 `lead` from its root record; moving that combine + 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/agent-lead-status-parity.test.ts` pins this as a known + `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 diff --git a/src/main/agent-hooks/server-ingest-structured-status.test.ts b/src/main/agent-hooks/server-ingest-structured-status.test.ts index bb09156483f0..0dea55ea1654 100644 --- a/src/main/agent-hooks/server-ingest-structured-status.test.ts +++ b/src/main/agent-hooks/server-ingest-structured-status.test.ts @@ -379,8 +379,8 @@ describe('structured rows and last-status.json', () => { }) }) -describe('the lead fact on a structured row', () => { - it('publishes the lead beside the folded state, on the journal clock with continuity', () => { +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' }] }), @@ -388,11 +388,11 @@ describe('the lead fact on a structured row', () => { ) expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working', - lead: { state: 'done', stateStartedAt: OBSERVED_AT } + mainAgent: { state: 'done', stateStartedAt: OBSERVED_AT } }) - expect(server.getStatusSnapshot()[0]?.lead).not.toHaveProperty('outcome') + expect(server.getStatusSnapshot()[0]?.mainAgent).not.toHaveProperty('outcome') - // The lead is still done while its child drains: the lead's clock does not move. + // 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 @@ -400,14 +400,14 @@ describe('the lead fact on a structured row', () => { expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done', stateStartedAt: OBSERVED_AT + 5, - lead: { state: 'done', outcome: 'failure', stateStartedAt: OBSERVED_AT } + mainAgent: { state: 'done', outcome: 'failure', stateStartedAt: OBSERVED_AT } }) server.ingestStructuredStatus( summary({ status: 'working', updatedAt: OBSERVED_AT + 9 }), SUBJECT ) - expect(server.getStatusSnapshot()[0]?.lead).toEqual({ + expect(server.getStatusSnapshot()[0]?.mainAgent).toEqual({ state: 'working', stateStartedAt: OBSERVED_AT + 9 }) @@ -423,7 +423,7 @@ describe('the lead fact on a structured row', () => { paneKey: STRUCTURED_PANE, payload: expect.objectContaining({ state: 'done', - lead: { state: 'done', outcome: 'cancellation', stateStartedAt: OBSERVED_AT } + mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: OBSERVED_AT } }) }) ) diff --git a/src/main/agent-hooks/server-ingest-terminal-status.test.ts b/src/main/agent-hooks/server-ingest-terminal-status.test.ts index bb910b26669a..bbb6b9ae478e 100644 --- a/src/main/agent-hooks/server-ingest-terminal-status.test.ts +++ b/src/main/agent-hooks/server-ingest-terminal-status.test.ts @@ -444,8 +444,8 @@ describe('AgentHookServer ingestTerminalStatus', () => { }) }) -describe('the lead fact across an OSC repaint', () => { - it('carries the hook row lead while OSC repaints the same state, and drops it on a state edge', () => { +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( { @@ -457,7 +457,7 @@ describe('the lead fact across an OSC repaint', () => { workingMode: 'monitoring', prompt: 'watch the build', agentType: 'claude', - lead: { state: 'done', stateStartedAt: 10 } + mainAgent: { state: 'done', stateStartedAt: 10 } } }, 'conn-1' @@ -475,16 +475,16 @@ describe('the lead fact across an OSC repaint', () => { expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working', toolName: 'Bash', - lead: { state: 'done', stateStartedAt: 10 } + mainAgent: { state: 'done', stateStartedAt: 10 } }) - // OSC cannot date a turn edge: a different state is a lead it has no fact about. + // 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('lead') + expect(server.getStatusSnapshot()[0]).not.toHaveProperty('mainAgent') }) }) diff --git a/src/main/agent-hooks/server-interrupt-inference-validation.test.ts b/src/main/agent-hooks/server-interrupt-inference-validation.test.ts index 4a78a03d0c0a..424e4733f289 100644 --- a/src/main/agent-hooks/server-interrupt-inference-validation.test.ts +++ b/src/main/agent-hooks/server-interrupt-inference-validation.test.ts @@ -363,8 +363,8 @@ describe('AgentHookServer listener replay', () => { }) }) -describe('the lead fact on an inferred interrupt', () => { - it('publishes the synthesized done as a cancelled lead turn', () => { +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 { @@ -393,7 +393,7 @@ describe('the lead fact on an inferred interrupt', () => { expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done', interrupted: true, - lead: { state: 'done', outcome: 'cancellation', stateStartedAt: 1_500 } + mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: 1_500 } }) } finally { vi.useRealTimers() diff --git a/src/main/agent-hooks/server-last-status-lead-boundary.test.ts b/src/main/agent-hooks/server-last-status-lead-boundary.test.ts index 9ba363bae093..b146fb6d8548 100644 --- a/src/main/agent-hooks/server-last-status-lead-boundary.test.ts +++ b/src/main/agent-hooks/server-last-status-lead-boundary.test.ts @@ -3,12 +3,12 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { AgentHookServer, _internals } from './server' -import { isClaudeLeadBoundaryHeldByChildrenOnly } from './server/server-claude-status-rules' +import { isClaudeMainAgentBoundaryHeldByChildrenOnly } from './server/server-claude-status-rules' import { buildBody, postHookEvent, PANE, RUNNING_SHELL } from './server.test-fixtures' function heldByChildrenOnly(server: AgentHookServer): boolean | undefined { const row = server._getStateForTests().lastStatusByPaneKey.get(PANE) - return row ? isClaudeLeadBoundaryHeldByChildrenOnly(row) : undefined + return row ? isClaudeMainAgentBoundaryHeldByChildrenOnly(row) : undefined } const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ diff --git a/src/main/agent-hooks/server-last-status-lead-fact.test.ts b/src/main/agent-hooks/server-last-status-main-agent-fact.test.ts similarity index 76% rename from src/main/agent-hooks/server-last-status-lead-fact.test.ts rename to src/main/agent-hooks/server-last-status-main-agent-fact.test.ts index 86197029f4a9..775dc6d2c5d1 100644 --- a/src/main/agent-hooks/server-last-status-lead-fact.test.ts +++ b/src/main/agent-hooks/server-last-status-main-agent-fact.test.ts @@ -24,13 +24,13 @@ afterEach(() => { vi.restoreAllMocks() }) -// The lead fact rides inside the persisted payload. Without these pins the field would look +// The main agent fact rides inside the persisted payload. Without these pins the field would look // shipped while dying at every restart: hydration rebuilds only the fields it is taught. -describe('The lead fact across a restart', () => { +describe('The main agent fact across a restart', () => { let userDataPath: string beforeEach(() => { - userDataPath = mkdtempSync(join(tmpdir(), 'orca-lead-fact-')) + userDataPath = mkdtempSync(join(tmpdir(), 'orca-main-agent-fact-')) }) afterEach(() => { @@ -52,7 +52,7 @@ describe('The lead fact across a restart', () => { ) } - it('round-trips a settled lead held open by a child through disk and back', async () => { + it('round-trips a settled main agent held open by a child through disk and back', async () => { const firstServer = new AgentHookServer() await firstServer.start({ env: 'production', userDataPath }) await postHookEvent( @@ -67,12 +67,12 @@ describe('The lead fact across a restart', () => { const live = firstServer.getStatusSnapshot()[0] expect(live).toMatchObject({ state: 'working', - lead: { state: 'done', outcome: 'cancellation', stateStartedAt: expect.any(Number) } + mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: expect.any(Number) } }) firstServer.flushStatusPersistSync() firstServer.stop() const file = JSON.parse(readFileSync(lastStatusPath(), 'utf8')) - expect(file.entries[PANE].payload.lead).toEqual(live?.lead) + expect(file.entries[PANE].payload.mainAgent).toEqual(live?.mainAgent) expect(file.entries[PANE]).not.toHaveProperty('claudeLeadBoundaryChildOnly') const server = new AgentHookServer() @@ -81,9 +81,9 @@ describe('The lead fact across a restart', () => { expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working', restoredUnconfirmed: true, - lead: live?.lead + mainAgent: live?.mainAgent }) - // The seeded lead record is what lets the child's drain settle the pane, verdict intact. + // The seeded main agent record is what lets the child's drain settle the pane, verdict intact. await postHookEvent( server, buildBody({ hook_event_name: 'SubagentStop', agent_id: 'arestored-child' }) @@ -91,14 +91,18 @@ describe('The lead fact across a restart', () => { expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done', interrupted: true, - lead: { state: 'done', outcome: 'cancellation', stateStartedAt: live?.lead?.stateStartedAt } + mainAgent: { + state: 'done', + outcome: 'cancellation', + stateStartedAt: live?.mainAgent?.stateStartedAt + } }) } finally { server.stop() } }) - it('maps the legacy child-only flag onto an absent lead, dated by the turn end', async () => { + it('maps the legacy child-only flag onto an absent main agent, dated by the turn end', async () => { const receivedAt = recentTs() writeEntry({ receivedAt, @@ -117,7 +121,7 @@ describe('The lead fact across a restart', () => { try { expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working', - lead: { state: 'done', stateStartedAt: receivedAt - 1_000 } + mainAgent: { state: 'done', stateStartedAt: receivedAt - 1_000 } }) await postHookEvent( server, @@ -125,16 +129,16 @@ describe('The lead fact across a restart', () => { ) expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done', - lead: { state: 'done' } + mainAgent: { state: 'done' } }) } finally { server.stop() } }) - it('prefers a persisted lead over the legacy flag when a row carries both', async () => { + it('prefers a persisted main agent over the legacy flag when a row carries both', async () => { const receivedAt = recentTs() - const lead = { state: 'done', outcome: 'cancellation', stateStartedAt: receivedAt - 2_000 } + const mainAgent = { state: 'done', outcome: 'cancellation', stateStartedAt: receivedAt - 2_000 } writeEntry({ receivedAt, stateStartedAt: receivedAt - 5_000, @@ -144,38 +148,38 @@ describe('The lead fact across a restart', () => { prompt: 'both', agentType: 'claude', turnCompletedAt: receivedAt - 1_000, - lead, + mainAgent, subagents: [{ id: 'arestored-child', state: 'working', startedAt: receivedAt - 4_000 }] } }) const server = new AgentHookServer() await server.start({ env: 'production', userDataPath }) try { - expect(server.getStatusSnapshot()[0]?.lead).toEqual(lead) + expect(server.getStatusSnapshot()[0]?.mainAgent).toEqual(mainAgent) } finally { server.stop() } }) - it('drops a malformed persisted lead and keeps the row', async () => { + it('drops a malformed persisted main agent and keeps the row', async () => { const receivedAt = recentTs() writeEntry({ receivedAt, stateStartedAt: receivedAt - 5_000, - payload: { state: 'done', prompt: 'survived', agentType: 'claude', lead: 'done' } + payload: { state: 'done', prompt: 'survived', agentType: 'claude', mainAgent: 'done' } }) const server = new AgentHookServer() await server.start({ env: 'production', userDataPath }) try { const row = server.getStatusSnapshot()[0] expect(row).toMatchObject({ state: 'done', prompt: 'survived' }) - expect(row?.lead).toBeUndefined() + expect(row?.mainAgent).toBeUndefined() } finally { server.stop() } }) - it('never invents a lead for a non-Claude row from the legacy flag', async () => { + it('never invents a main agent for a non-Claude row from the legacy flag', async () => { const receivedAt = recentTs() writeEntry({ receivedAt, @@ -186,7 +190,7 @@ describe('The lead fact across a restart', () => { const server = new AgentHookServer() await server.start({ env: 'production', userDataPath }) try { - expect(server.getStatusSnapshot()[0]?.lead).toBeUndefined() + expect(server.getStatusSnapshot()[0]?.mainAgent).toBeUndefined() } finally { server.stop() } diff --git a/src/main/agent-hooks/server-last-status-write.test.ts b/src/main/agent-hooks/server-last-status-write.test.ts index a81ffd49be97..c1dcee3f6975 100644 --- a/src/main/agent-hooks/server-last-status-write.test.ts +++ b/src/main/agent-hooks/server-last-status-write.test.ts @@ -87,10 +87,10 @@ describe('Last-status persistence', () => { expect(file.entries[PANE].launchTokenHash).toBe( createHash('sha256').update('launch-bearer-must-not-persist').digest('hex') ) - // The shell fact is persisted: hydration reads it to decide whether a settled lead held - // open by children may be seeded, which `lead` alone cannot say (see server-types.ts). + // The shell fact is persisted: hydration reads it to decide whether a settled main agent held + // open by children may be seeded, which `mainAgent` alone cannot say (see server-types.ts). expect(file.entries[PANE].claudeRunningNonAgentTask).toBe(true) - expect(file.entries[PANE].payload.lead).toEqual({ + expect(file.entries[PANE].payload.mainAgent).toEqual({ state: 'done', stateStartedAt: expect.any(Number) }) diff --git a/src/main/agent-hooks/server/server-claude-status-rules.ts b/src/main/agent-hooks/server/server-claude-status-rules.ts index f697710ca15e..df30ce88c492 100644 --- a/src/main/agent-hooks/server/server-claude-status-rules.ts +++ b/src/main/agent-hooks/server/server-claude-status-rules.ts @@ -4,13 +4,13 @@ import { isAskUserQuestionTool } from '../../../shared/agent-question-answered-i import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' import type { EnrichedAgentHookEventPayload } from './server-types' -/** The Claude lead has settled and child agents alone hold the row `working`. Derived from the - * row's `lead` fact and its child evidence, never stored: the persisted flag this replaced was a - * second copy of `lead.state === 'done'` that could disagree with it. A running shell beside the +/** The Claude main agent has settled and child agents alone hold the row `working`. Derived from the + * row's `mainAgent` fact and its child evidence, never stored: the persisted flag this replaced was a + * second copy of `mainAgent.state === 'done'` that could disagree with it. A running shell beside the * agents disqualifies the row; that fact rides `claudeRunningNonAgentTask` (persisted since the * flag stopped being written), and a row old enough to lack it reads as shell-free — which is * exactly what its legacy child-only flag asserted at write time. */ -export function isClaudeLeadBoundaryHeldByChildrenOnly( +export function isClaudeMainAgentBoundaryHeldByChildrenOnly( row: Pick ): boolean { return ( diff --git a/src/main/agent-hooks/server/server-hydration.ts b/src/main/agent-hooks/server/server-hydration.ts index 70f922b16737..81a09dfc3ad1 100644 --- a/src/main/agent-hooks/server/server-hydration.ts +++ b/src/main/agent-hooks/server/server-hydration.ts @@ -10,7 +10,7 @@ import { } from '../../../shared/agent-hook-listener/providers/claude-roster-state' import { seedCodexStateFromSnapshot } from '../../../shared/agent-hook-listener/providers/codex-state' import { AGENT_STATUS_PERSISTED_HYDRATION_MODE } from '../../../shared/agent-status-legacy-adapter' -import { isClaudeLeadBoundaryHeldByChildrenOnly } from './server-claude-status-rules' +import { isClaudeMainAgentBoundaryHeldByChildrenOnly } from './server-claude-status-rules' import { HYDRATE_MAX_AGE_MS, LAST_STATUS_FILE_VERSION } from './server-constants' import type { LastStatusFile } from './server-types' import { @@ -125,7 +125,7 @@ export abstract class AgentHookServerHydration extends AgentHookServerReaping { seedCodexStateFromSnapshot(this.state, resolvedPaneKey, entry.payload) } else if (entry.payload.agentType === 'claude') { seedClaudeLeadTurnFromPersistedStatus(this.state, resolvedPaneKey, entry, { - childOnlyBoundary: isClaudeLeadBoundaryHeldByChildrenOnly(entry) + childOnlyBoundary: isClaudeMainAgentBoundaryHeldByChildrenOnly(entry) }) if (entry.payload.subagents) { seedClaudeSubagentRosterFromSnapshots( diff --git a/src/main/agent-hooks/server/server-ingest-structured.ts b/src/main/agent-hooks/server/server-ingest-structured.ts index 910ae08161eb..b57b4d620409 100644 --- a/src/main/agent-hooks/server/server-ingest-structured.ts +++ b/src/main/agent-hooks/server/server-ingest-structured.ts @@ -10,7 +10,7 @@ import { structuredAgentSessionTabId } from '../../../shared/structured-agent-session-projection' import { - continueAgentLeadStatus, + continueMainAgentStatus, isAgentStatusHeldOpenByChildWork } from '../../../shared/agent-lead-status-fold' import { structuredAgentSessionAgentStatus } from '../../../shared/structured-agent-session-agent-status' @@ -45,9 +45,13 @@ export abstract class AgentHookServerIngestStructured extends AgentHookServerIng turnOutcome: summary.turnOutcome }) const { state, workingMode } = agentStatus - // The lead's own clock keeps continuity the same way the combined row's does below, dated - // by the journal: a restart's republish is not a new lead state either. - const lead = continueAgentLeadStatus(priorStatus?.lead, agentStatus.lead, summary.updatedAt) + // The main agent's own clock keeps continuity the same way the combined row's does below, dated + // by the journal: a restart's republish is not a new main agent state either. + const mainAgent = continueMainAgentStatus( + priorStatus?.mainAgent, + agentStatus.mainAgent, + summary.updatedAt + ) const tabId = structuredAgentSessionTabId(parsed.sessionId) const paneKey = structuredAgentSessionPaneKey(tabId, parsed.sessionId) if (this.state.lastStatusByPaneKey.has(paneKey)) { @@ -64,7 +68,7 @@ export abstract class AgentHookServerIngestStructured extends AgentHookServerIng ...(summary.providerSession ? { providerSession: summary.providerSession } : {}), state, ...(workingMode ? { workingMode } : {}), - lead, + mainAgent, prompt: summary.latestPrompt, agentType: summary.agent, ...(summary.model ? { model: summary.model } : {}), @@ -78,7 +82,7 @@ export abstract class AgentHookServerIngestStructured extends AgentHookServerIng // cannot date child work: it stopped when the lead did, and reading it as the evidence age // retires a genuinely live roster at the 30-minute staleness window. Only then does the // host's own observation clock stand in, matching what the hook lane stamps for its rows. - evidenceObservedAt: isAgentStatusHeldOpenByChildWork({ state, lead }) + evidenceObservedAt: isAgentStatusHeldOpenByChildWork({ state, mainAgent }) ? observedAt : summary.updatedAt, // Continuity is the whole published work identity: `state` alone no longer means "a turn is diff --git a/src/main/agent-hooks/server/server-ingest-terminal.ts b/src/main/agent-hooks/server/server-ingest-terminal.ts index ab7a78c4c3fc..64c11d48b442 100644 --- a/src/main/agent-hooks/server/server-ingest-terminal.ts +++ b/src/main/agent-hooks/server/server-ingest-terminal.ts @@ -4,7 +4,7 @@ import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../shared/stable- import { terminalStatusPayloadMatchesHook } from '../../../shared/agent-terminal-status-equivalence' import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types' import type { EnrichedAgentHookEventPayload } from './server-types' -import { isClaudeLeadBoundaryHeldByChildrenOnly } from './server-claude-status-rules' +import { isClaudeMainAgentBoundaryHeldByChildrenOnly } from './server-claude-status-rules' import { AgentHookServerIngestNormalization } from './server-ingest-normalization' export abstract class AgentHookServerIngestTerminal extends AgentHookServerIngestNormalization { @@ -84,10 +84,10 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges | undefined if ( previous && - isClaudeLeadBoundaryHeldByChildrenOnly(previous) && + isClaudeMainAgentBoundaryHeldByChildrenOnly(previous) && event.payload.agentType === 'claude' ) { - // Why: OSC has no child identity or lead boundary, so it cannot replace a child-only proof before the lifecycle hook arrives. + // Why: OSC has no child identity or main agent boundary, so it cannot replace a child-only proof before the lifecycle hook arrives. if (mutationBefore !== undefined) { this.commitStatusRowMutation(mutationBefore, previous) this.emitEnrichedStatus(previous) @@ -131,13 +131,13 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges (previous.payload.state !== 'done' || event.payload.state === 'done') ? previous.providerSession : undefined - // Why: OSC carries no lead fact. While it repaints the state the hook row already holds, the - // lead behind that state is unchanged too; a different state is a turn edge OSC cannot date. - const preservedLead = - previous?.payload.lead && + // Why: OSC carries no main agent fact. While it repaints the state the hook row already holds, the + // main agent behind that state is unchanged too; a different state is a turn edge OSC cannot date. + const preservedMainAgent = + previous?.payload.mainAgent && previous.payload.state === event.payload.state && (claimedAgentType === undefined || claimedAgentType === previous.payload.agentType) - ? previous.payload.lead + ? previous.payload.mainAgent : undefined // Why: OSC status is a runtime observation, not a prompt boundary; keep prompt-sent telemetry tied to native hooks. this.applyNormalizedStatus( @@ -148,7 +148,9 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges connectionId, ...(preservedProviderSession ? { providerSession: preservedProviderSession } : {}), ...(terminalHandle ? { terminalHandle } : {}), - payload: preservedLead ? { ...event.payload, lead: preservedLead } : event.payload + payload: preservedMainAgent + ? { ...event.payload, mainAgent: preservedMainAgent } + : event.payload }, undefined, 'osc', diff --git a/src/main/agent-hooks/server/server-persistence-validation.ts b/src/main/agent-hooks/server/server-persistence-validation.ts index f2baa3b43d39..09c25e293d6a 100644 --- a/src/main/agent-hooks/server/server-persistence-validation.ts +++ b/src/main/agent-hooks/server/server-persistence-validation.ts @@ -31,17 +31,17 @@ export function dropHydratedIdleClaudeSubagents( } } -/** Rows written before `lead` existed persisted `claudeLeadBoundaryChildOnly: true` instead: the - * lead had settled and child agents alone held the row `working`. That is `lead.state === 'done'` - * stored as a boolean, so it only fills an absent `lead`; a row carrying both keeps `lead`. The +/** Rows written before `mainAgent` existed persisted `claudeLeadBoundaryChildOnly: true` instead: the + * main agent had settled and child agents alone held the row `working`. That is `mainAgent.state === 'done'` + * stored as a boolean, so it only fills an absent `mainAgent`; a row carrying both keeps `mainAgent`. The * flag stays readable until every user's file has been rewritten without it. */ -function fillLeadFromLegacyChildOnlyBoundary( +function fillMainAgentFromLegacyChildOnlyBoundary( payload: ParsedAgentStatusPayload, record: Record, stateStartedAt: number ): ParsedAgentStatusPayload { if ( - payload.lead !== undefined || + payload.mainAgent !== undefined || record.claudeLeadBoundaryChildOnly !== true || payload.agentType !== 'claude' ) { @@ -49,9 +49,9 @@ function fillLeadFromLegacyChildOnlyBoundary( } return { ...payload, - // Why: the gated working row stamps the lead's end as `turnCompletedAt`; the row clock is the + // Why: the gated working row stamps the main agent's end as `turnCompletedAt`; the row clock is the // nearest fact an older row that lacks it can offer. - lead: { state: 'done', stateStartedAt: payload.turnCompletedAt ?? stateStartedAt } + mainAgent: { state: 'done', stateStartedAt: payload.turnCompletedAt ?? stateStartedAt } } } @@ -112,7 +112,11 @@ export function sanitizeHydratedEntry( if (!normalizedPayload) { return null } - const payload = fillLeadFromLegacyChildOnlyBoundary(normalizedPayload, record, stateStartedAt) + const payload = fillMainAgentFromLegacyChildOnlyBoundary( + normalizedPayload, + record, + stateStartedAt + ) const providerSession = normalizeAgentProviderSession(record.providerSession) ?? undefined const providerSessionOnly = record.providerSessionOnly === true const retainedForLiveness = record.retainedForLiveness === true diff --git a/src/main/agent-hooks/server/server-persistence.ts b/src/main/agent-hooks/server/server-persistence.ts index 2e91231b8f97..74a37c441f29 100644 --- a/src/main/agent-hooks/server/server-persistence.ts +++ b/src/main/agent-hooks/server/server-persistence.ts @@ -49,7 +49,7 @@ export abstract class AgentHookServerPersistence extends AgentHookServerHydratio const launchTokenHash = launchToken?.trim() ? createHash('sha256').update(launchToken.trim()).digest('hex') : this.hydratedLaunchTokenHashByPaneKey.get(paneKey) - // `payload.lead` rides inside the payload; the legacy `claudeLeadBoundaryChildOnly` flag it + // `payload.mainAgent` rides inside the payload; the legacy `claudeLeadBoundaryChildOnly` flag it // replaced is read at hydrate and never written again. entries[paneKey] = { ...persistedPayload, diff --git a/src/main/agent-hooks/server/server-status-inference.ts b/src/main/agent-hooks/server/server-status-inference.ts index 9bd80ff0fa03..318fef110ed0 100644 --- a/src/main/agent-hooks/server/server-status-inference.ts +++ b/src/main/agent-hooks/server/server-status-inference.ts @@ -109,8 +109,8 @@ export abstract class AgentHookServerStatusInference extends AgentHookServerRowO interrupted: true, // Why: idle children are display state; dropping them on an inferred interrupt blanks rows a later hook would restore. ...(payload.subagents ? { subagents: payload.subagents } : {}), - // Why: the synthesized row is the lead's own end; its verdict is the interrupt that produced it. - lead: { state: 'done', outcome: 'cancellation', stateStartedAt: Date.now() } + // Why: the synthesized row is the main agent's own end; its verdict is the interrupt that produced it. + mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: Date.now() } } }) if (!inferred) { @@ -175,7 +175,7 @@ export abstract class AgentHookServerStatusInference extends AgentHookServerRowO ? { turnCompletedAt: restored.turnCompletedAt } : {}), ...(payload.subagents ? { subagents: payload.subagents } : {}), - lead: restored.lead + mainAgent: restored.mainAgent } }) if (!inferred) { diff --git a/src/main/agent-hooks/server/server-types.ts b/src/main/agent-hooks/server/server-types.ts index 4cf5b7a3e850..d14667977986 100644 --- a/src/main/agent-hooks/server/server-types.ts +++ b/src/main/agent-hooks/server/server-types.ts @@ -28,8 +28,8 @@ export type EnrichedAgentHookEventPayload = AgentHookEventPayload & { } // `claudeRunningNonAgentTask` is persisted on purpose: it is the one child-work fact the row's -// `lead` cannot express (a shell beside the agents), and hydration reads it to decide whether a -// settled lead may be seeded. It replaced the derived `claudeLeadBoundaryChildOnly` flag. +// `mainAgent` cannot express (a shell beside the agents), and hydration reads it to decide whether a +// settled main agent may be seeded. It replaced the derived `claudeLeadBoundaryChildOnly` flag. export type PersistedAgentHookEventPayload = Omit< EnrichedAgentHookEventPayload, | 'authorityRestartId' diff --git a/src/main/agent-hooks/terminal-handle-row-identity.test.ts b/src/main/agent-hooks/terminal-handle-row-identity.test.ts index b915d27d877a..3c361595a24c 100644 --- a/src/main/agent-hooks/terminal-handle-row-identity.test.ts +++ b/src/main/agent-hooks/terminal-handle-row-identity.test.ts @@ -210,13 +210,13 @@ describe('the terminal handle a status row is stamped with', () => { if (!row) { throw new Error('expected seeded status row') } - // A settled lead whose only live work is a child agent: the boundary is derived from these facts. + // A settled main agent whose only live work is a child agent: the boundary is derived from these facts. const childOnlyRow = { ...row, claudeRunningNonAgentTask: false, payload: { ...row.payload, - lead: { state: 'done' as const, stateStartedAt: 1 }, + mainAgent: { state: 'done' as const, stateStartedAt: 1 }, subagents: [{ id: 'child-1', state: 'working' as const, startedAt: 1 }] } } diff --git a/src/main/runtime/runtime-agent-row-lead-projection.test.ts b/src/main/runtime/runtime-agent-row-main-agent-projection.test.ts similarity index 72% rename from src/main/runtime/runtime-agent-row-lead-projection.test.ts rename to src/main/runtime/runtime-agent-row-main-agent-projection.test.ts index 4db636e37e2b..97d8a3f6b8c3 100644 --- a/src/main/runtime/runtime-agent-row-lead-projection.test.ts +++ b/src/main/runtime/runtime-agent-row-main-agent-projection.test.ts @@ -16,17 +16,17 @@ function row(over: Partial = {}): AgentStatusIpcPayload { state: 'working', prompt: 'ship it', agentType: 'claude', - lead: { state: 'done', outcome: 'cancellation', stateStartedAt: now - 2_000 }, + mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: now - 2_000 }, ...over } } // The mobile projection narrows a row through `pickParsedAgentStatusPayload`; this pins that -// the lead fact survives the narrowing. The `worktree ps` row keeps its own explicit shape. -describe('the lead fact through the runtime projections', () => { +// the main agent fact survives the narrowing. The `worktree ps` row keeps its own explicit shape. +describe('the main agent fact through the runtime projections', () => { it('reaches the mobile live row', () => { const source = row() const selected = selectRuntimeHookAgentRowForPane([source]) - expect(selected.live?.payload.lead).toEqual(source.lead) + expect(selected.live?.payload.mainAgent).toEqual(source.mainAgent) }) }) diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx index c5908354a0bd..aeb5db3300f7 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx @@ -698,7 +698,7 @@ describe('StructuredAgentSessionStatusBridge', () => { }) }) -describe('the lead fact the bridge writes', () => { +describe('the main agent fact the bridge writes', () => { beforeEach(() => { vi.clearAllMocks() resetStructuredAgentSessionStatusFeedsForTests() @@ -716,7 +716,7 @@ describe('the lead fact the bridge writes', () => { resetStructuredAgentSessionStatusFeedsForTests() }) - it('stamps the lead beside the folded state, with its verdict and its own clock', async () => { + it('stamps the main agent beside the folded state, with its verdict and its own clock', async () => { render() await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) @@ -737,11 +737,11 @@ describe('the lead fact the bridge writes', () => { expect.objectContaining({ state: 'working', workingMode: 'monitoring', - lead: { state: 'done', outcome: 'cancellation', stateStartedAt: 1 } + mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: 1 } }) ]) - // The shell drains: the row settles, the lead was done all along, so its clock holds. + // The shell drains: the row settles, the main agent was done all along, so its clock holds. act(() => feed().emit({ type: 'status', @@ -752,7 +752,7 @@ describe('the lead fact the bridge writes', () => { expect.objectContaining({ state: 'done', stateStartedAt: 2, - lead: { state: 'done', outcome: 'cancellation', stateStartedAt: 1 } + mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: 1 } }) ]) }) diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx index 74b2f4fb6705..8cd966ef9af7 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx @@ -7,10 +7,10 @@ import { projectAgentChildWorkLegacySubagents } from '../../../../shared/agent-status-child-work-projection' import { - continueAgentLeadStatus, + continueMainAgentStatus, isAgentStatusHeldOpenByChildWork } from '../../../../shared/agent-lead-status-fold' -import { agentLeadStatusEqual, agentSubagentsEqual } from '../../../../shared/agent-status-types' +import { mainAgentStatusEqual, agentSubagentsEqual } from '../../../../shared/agent-status-types' import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' import { structuredAgentSessionAgentStatus } from '../../../../shared/structured-agent-session-agent-status' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' @@ -70,12 +70,16 @@ function projectStatus( turnOutcome: summary.turnOutcome }) const current = store.agentStatusByPaneKey?.[paneKey] - // Same continuity rule as the host ingest, on the lead's own clock. - const lead = continueAgentLeadStatus(current?.lead, agentStatus.lead, summary.updatedAt) + // Same continuity rule as the host ingest, on the main agent's own clock. + const mainAgent = continueMainAgentStatus( + current?.mainAgent, + agentStatus.mainAgent, + summary.updatedAt + ) const desired = { state: agentStatus.state, ...(agentStatus.workingMode ? { workingMode: agentStatus.workingMode } : {}), - lead, + mainAgent, prompt: summary.latestPrompt, agentType: tab.agentSessionAgent, // The host projects these from the journal so the row reads like a hook-reported one: @@ -90,7 +94,7 @@ function projectStatus( if ( current?.state === desired.state && current.workingMode === desired.workingMode && - agentLeadStatusEqual(current.lead, desired.lead) && + mainAgentStatusEqual(current.mainAgent, desired.mainAgent) && current.prompt === desired.prompt && current.agentType === desired.agentType && // A row keeps the last model it was told about, so only a reported one can differ. diff --git a/src/renderer/src/hooks/ipc-events/normalize-agent-status-event.ts b/src/renderer/src/hooks/ipc-events/normalize-agent-status-event.ts index cb3a9f022c88..ee9504659ce6 100644 --- a/src/renderer/src/hooks/ipc-events/normalize-agent-status-event.ts +++ b/src/renderer/src/hooks/ipc-events/normalize-agent-status-event.ts @@ -23,7 +23,7 @@ export function normalizeAgentStatusEvent( sessionBoundary: data.sessionBoundary, turnCompletedAt: data.turnCompletedAt, subagents: data.subagents, - lead: data.lead + mainAgent: data.mainAgent }) } diff --git a/src/renderer/src/store/slices/agent-status-live-entry-builder.ts b/src/renderer/src/store/slices/agent-status-live-entry-builder.ts index 55a6a26c20ae..64bd5cc1a777 100644 --- a/src/renderer/src/store/slices/agent-status-live-entry-builder.ts +++ b/src/renderer/src/store/slices/agent-status-live-entry-builder.ts @@ -1,5 +1,5 @@ import type { AppState } from '../types' -import { resolveAgentStatusLiveEntryLead } from './agent-status-live-entry-lead' +import { resolveAgentStatusLiveEntryMainAgent } from './agent-status-live-entry-main-agent' import { AGENT_STATE_HISTORY_MAX, agentSubagentsEqual, @@ -218,7 +218,7 @@ export function buildAgentStatusLiveEntry( : undefined) ?? matchedRegistryLaunchConfig ?? matchedSleepingLaunchConfig - const lead = resolveAgentStatusLiveEntryLead(existing, payload, identity.agentType) + const mainAgent = resolveAgentStatusLiveEntryMainAgent(existing, payload, identity.agentType) const entry: AgentStatusEntry = { state: payload.state, workingMode: payload.workingMode, @@ -263,7 +263,7 @@ export function buildAgentStatusLiveEntry( subagents: agentSubagentsEqual(existing?.subagents, payload.subagents) ? existing?.subagents : payload.subagents, - ...(lead ? { lead } : {}), + ...(mainAgent ? { mainAgent } : {}), ...(providerSession ? { providerSession } : {}), ...(metadata?.terminalResumeEligible === false ? { terminalResumeEligible: false as const } diff --git a/src/renderer/src/store/slices/agent-status-live-entry-lead.ts b/src/renderer/src/store/slices/agent-status-live-entry-lead.ts deleted file mode 100644 index 8a0f955e92c8..000000000000 --- a/src/renderer/src/store/slices/agent-status-live-entry-lead.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { - agentLeadStatusEqual, - type AgentLeadStatus, - type AgentStatusEntry, - type AgentStatusPayload -} from '../../../../shared/agent-status-types' - -/** The lead fact a live entry carries. A writer with no lead fact of its own (OSC bytes, launch - * seeds) repaints the state the row already holds, and the lead behind an unchanged state is - * unchanged too — the same rule main's OSC ingest applies. The existing object is reused when - * nothing changed so subscribers can compare by reference. */ -export function resolveAgentStatusLiveEntryLead( - existing: AgentStatusEntry | undefined, - payload: Pick, - agentType: AgentStatusEntry['agentType'] -): AgentLeadStatus | undefined { - const next = - payload.lead ?? - (existing?.state === payload.state && existing.agentType === agentType - ? existing.lead - : undefined) - return agentLeadStatusEqual(existing?.lead, next) ? existing?.lead : next -} diff --git a/src/renderer/src/store/slices/agent-status-live-entry-main-agent.ts b/src/renderer/src/store/slices/agent-status-live-entry-main-agent.ts new file mode 100644 index 000000000000..1f4dd7b000ba --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-live-entry-main-agent.ts @@ -0,0 +1,23 @@ +import { + mainAgentStatusEqual, + type AgentMainAgentStatus, + type AgentStatusEntry, + type AgentStatusPayload +} from '../../../../shared/agent-status-types' + +/** The main agent fact a live entry carries. A writer with no main agent fact of its own (OSC bytes, launch + * seeds) repaints the state the row already holds, and the main agent behind an unchanged state is + * unchanged too — the same rule main's OSC ingest applies. The existing object is reused when + * nothing changed so subscribers can compare by reference. */ +export function resolveAgentStatusLiveEntryMainAgent( + existing: AgentStatusEntry | undefined, + payload: Pick, + agentType: AgentStatusEntry['agentType'] +): AgentMainAgentStatus | undefined { + const next = + payload.mainAgent ?? + (existing?.state === payload.state && existing.agentType === agentType + ? existing.mainAgent + : undefined) + return mainAgentStatusEqual(existing?.mainAgent, next) ? existing?.mainAgent : next +} diff --git a/src/renderer/src/store/slices/agent-status-lead-fact.test.ts b/src/renderer/src/store/slices/agent-status-main-agent-fact.test.ts similarity index 57% rename from src/renderer/src/store/slices/agent-status-lead-fact.test.ts rename to src/renderer/src/store/slices/agent-status-main-agent-fact.test.ts index 2fd1d47b62ce..6fab1dd216d3 100644 --- a/src/renderer/src/store/slices/agent-status-lead-fact.test.ts +++ b/src/renderer/src/store/slices/agent-status-main-agent-fact.test.ts @@ -4,47 +4,47 @@ import { createTestStore } from './store-test-helpers' const PANE = 'tab-1:11111111-1111-4111-8111-111111111111' -describe('the lead fact on a renderer status entry', () => { +describe('the main agent fact on a renderer status entry', () => { it('lands on the entry from the IPC payload and is reused by reference when unchanged', () => { const store = createTestStore() - const lead = { state: 'done' as const, outcome: 'failure' as const, stateStartedAt: 5 } + const mainAgent = { state: 'done' as const, outcome: 'failure' as const, stateStartedAt: 5 } store .getState() - .setAgentStatus(PANE, { state: 'working', prompt: 'go', agentType: 'claude', lead }) + .setAgentStatus(PANE, { state: 'working', prompt: 'go', agentType: 'claude', mainAgent }) const first = store.getState().agentStatusByPaneKey[PANE] - expect(first.lead).toEqual(lead) + expect(first.mainAgent).toEqual(mainAgent) store.getState().setAgentStatus(PANE, { state: 'working', prompt: 'go', agentType: 'claude', toolName: 'Read', - lead: { ...lead } + mainAgent: { ...mainAgent } }) - expect(store.getState().agentStatusByPaneKey[PANE].lead).toBe(first.lead) + expect(store.getState().agentStatusByPaneKey[PANE].mainAgent).toBe(first.mainAgent) }) - it('keeps the lead behind an unchanged state when a writer carries none, and drops it on a state edge', () => { + it('keeps the main agent behind an unchanged state when a writer carries none, and drops it on a state edge', () => { const store = createTestStore() - const lead = { state: 'done' as const, stateStartedAt: 5 } + const mainAgent = { state: 'done' as const, stateStartedAt: 5 } store .getState() - .setAgentStatus(PANE, { state: 'working', prompt: 'go', agentType: 'claude', lead }) - // A renderer-side OSC parse repaints the state with no lead fact of its own. + .setAgentStatus(PANE, { state: 'working', prompt: 'go', agentType: 'claude', mainAgent }) + // A renderer-side OSC parse repaints the state with no main agent fact of its own. store.getState().setAgentStatus(PANE, { state: 'working', prompt: 'go', agentType: 'claude', toolName: 'Bash' }) - expect(store.getState().agentStatusByPaneKey[PANE].lead).toEqual(lead) + expect(store.getState().agentStatusByPaneKey[PANE].mainAgent).toEqual(mainAgent) store.getState().setAgentStatus(PANE, { state: 'done', prompt: 'go', agentType: 'claude' }) - expect(store.getState().agentStatusByPaneKey[PANE].lead).toBeUndefined() + expect(store.getState().agentStatusByPaneKey[PANE].mainAgent).toBeUndefined() }) it('survives the IPC event normalizer', () => { - const lead = { state: 'working' as const, stateStartedAt: 9 } + const mainAgent = { state: 'working' as const, stateStartedAt: 9 } expect( normalizeAgentStatusEvent({ paneKey: PANE, @@ -53,8 +53,8 @@ describe('the lead fact on a renderer status entry', () => { stateStartedAt: 9, state: 'working', prompt: 'go', - lead - })?.lead - ).toEqual(lead) + mainAgent + })?.mainAgent + ).toEqual(mainAgent) }) }) diff --git a/src/shared/agent-hook-listener-claude-subagents.test.ts b/src/shared/agent-hook-listener-claude-subagents.test.ts index 215c5fd6c1eb..ec5542058884 100644 --- a/src/shared/agent-hook-listener-claude-subagents.test.ts +++ b/src/shared/agent-hook-listener-claude-subagents.test.ts @@ -665,7 +665,7 @@ describe('shared agent-hook-listener', () => { expect(clearClaudeAnsweredQuestionWait(state, PANE_KEY)).toEqual({ state: 'working', - lead: { state: 'working', stateStartedAt: expect.any(Number) } + mainAgent: { state: 'working', stateStartedAt: expect.any(Number) } }) // Why: a child-driven refresh re-emits the cached lead state; the linger @@ -718,8 +718,8 @@ describe('shared agent-hook-listener', () => { expect(clearClaudeAnsweredQuestionWait(state, PANE_KEY)).toEqual({ state: 'working', turnCompletedAt: expect.any(Number), - // The lead's own fact rides beside the gated state: it finished, no verdict was given. - lead: { state: 'done', stateStartedAt: expect.any(Number) } + // The main agent's own fact rides beside the gated state: it finished, no verdict was given. + mainAgent: { state: 'done', stateStartedAt: expect.any(Number) } }) expect(state.claudeLeadStateByPaneKey.get(PANE_KEY)).toEqual({ state: 'done', @@ -734,7 +734,7 @@ describe('shared agent-hook-listener', () => { it('falls back to working when no lead record exists', () => { expect(clearClaudeAnsweredQuestionWait(state, PANE_KEY)).toEqual({ state: 'working', - lead: { state: 'working', stateStartedAt: expect.any(Number) } + mainAgent: { state: 'working', stateStartedAt: expect.any(Number) } }) }) }) diff --git a/src/shared/agent-hook-listener-claude-turn-state.test.ts b/src/shared/agent-hook-listener-claude-turn-state.test.ts index 0728f1227ada..584d8dbefd6c 100644 --- a/src/shared/agent-hook-listener-claude-turn-state.test.ts +++ b/src/shared/agent-hook-listener-claude-turn-state.test.ts @@ -402,7 +402,7 @@ describe('shared agent-hook-listener', () => { }) }) -describe('the lead verdict across a child-induced wait', () => { +describe('the main agent verdict across a child-induced wait', () => { let state: HookListenerState beforeEach(() => { @@ -418,12 +418,12 @@ describe('the lead verdict across a child-induced wait', () => { claude({ hook_event_name: 'UserPromptSubmit', prompt: 'go' }) claude({ hook_event_name: 'SubagentStart', agent_id: 'a1' }) const cancelled = claude({ hook_event_name: 'Stop', is_interrupt: true }) - expect(cancelled?.lead).toEqual({ + expect(cancelled?.mainAgent).toEqual({ state: 'done', outcome: 'cancellation', stateStartedAt: expect.any(Number) }) - const settledAt = cancelled?.lead?.stateStartedAt + const settledAt = cancelled?.mainAgent?.stateStartedAt const wait = claude({ hook_event_name: 'PermissionRequest', @@ -431,29 +431,29 @@ describe('the lead verdict across a child-induced wait', () => { tool_name: 'Bash', tool_input: { command: 'rm -rf build' } }) - expect(wait).toMatchObject({ state: 'waiting', lead: { state: 'waiting' } }) - expect(wait?.lead).not.toHaveProperty('outcome') + expect(wait).toMatchObject({ state: 'waiting', mainAgent: { state: 'waiting' } }) + expect(wait?.mainAgent).not.toHaveProperty('outcome') - // The child's pause displaced the lead; clearing it must give the verdict and clock back. + // The child's pause displaced the main agent; clearing it must give the verdict and clock back. const drained = claude({ hook_event_name: 'SubagentStop', agent_id: 'a1' }) expect(drained).toMatchObject({ state: 'done', interrupted: true, - lead: { state: 'done', outcome: 'cancellation', stateStartedAt: settledAt } + mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: settledAt } }) }) it('clears the verdict when a new turn starts', () => { claude({ hook_event_name: 'UserPromptSubmit', prompt: 'go' }) claude({ hook_event_name: 'StopFailure', error: 'invalid_request' }) - expect(claude({ hook_event_name: 'UserPromptSubmit', prompt: 'again' })?.lead).toEqual({ + expect(claude({ hook_event_name: 'UserPromptSubmit', prompt: 'again' })?.mainAgent).toEqual({ state: 'working', stateStartedAt: expect.any(Number) }) }) }) -describe('the lead verdict from an inferred interrupt', () => { +describe('the main agent verdict from an inferred interrupt', () => { let state: HookListenerState beforeEach(() => { @@ -466,7 +466,7 @@ describe('the lead verdict from an inferred interrupt', () => { } // Current Claude sends no hook on a cancel and no `is_interrupt` on Stop, so the cancellation - // enters the lead record from Orca's inferred interrupt and rides into the next real Stop. + // enters the main agent record from Orca's inferred interrupt and rides into the next real Stop. it('carries the inferred cancellation into the next plain Stop', () => { claude({ hook_event_name: 'UserPromptSubmit', prompt: 'go' }) markClaudeLeadTurnInterrupted(state, PANE_KEY) @@ -480,7 +480,7 @@ describe('the lead verdict from an inferred interrupt', () => { expect(stop).toMatchObject({ state: 'done', interrupted: true, - lead: { state: 'done', outcome: 'cancellation', stateStartedAt: settledAt } + mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: settledAt } }) }) }) diff --git a/src/shared/agent-hook-listener-codex-lead.test.ts b/src/shared/agent-hook-listener-codex-main-agent.test.ts similarity index 78% rename from src/shared/agent-hook-listener-codex-lead.test.ts rename to src/shared/agent-hook-listener-codex-main-agent.test.ts index e7a1e3839aec..a83e992388c5 100644 --- a/src/shared/agent-hook-listener-codex-lead.test.ts +++ b/src/shared/agent-hook-listener-codex-main-agent.test.ts @@ -16,12 +16,12 @@ describe('the Codex root record seeded from a durable row', () => { state = createHookListenerState() }) - it("takes the row's own lead fact over the inferred aggregate", () => { + it("takes the row's own mainAgent fact over the inferred aggregate", () => { seedCodexStateFromSnapshot(state, PANE_KEY, { state: 'waiting', model: 'gpt-5.4', subagents: [{ id: 'child', state: 'working', startedAt: 1 }], - lead: { state: 'done', outcome: 'cancellation', stateStartedAt: 42 } + mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: 42 } }) expect(state.codexLeadStateByPaneKey.get(PANE_KEY)).toEqual({ state: 'done', @@ -31,7 +31,7 @@ describe('the Codex root record seeded from a durable row', () => { }) }) - it('still infers the root state from an older row that carries no lead', () => { + it('still infers the root state from an older row that carries no main agent', () => { seedCodexStateFromSnapshot(state, PANE_KEY, { state: 'waiting', subagents: [{ id: 'child', state: 'waiting', startedAt: 1 }] @@ -39,7 +39,7 @@ describe('the Codex root record seeded from a durable row', () => { expect(state.codexLeadStateByPaneKey.get(PANE_KEY)).toMatchObject({ state: 'working' }) }) - it("republishes a relayed row with the lead fact main holds, not the relay's", () => { + it("republishes a relayed row with the mainAgent fact main holds, not the relay's", () => { const reconciled = reconcileRemoteCodexState( state, PANE_KEY, @@ -48,6 +48,6 @@ describe('the Codex root record seeded from a durable row', () => { { state: 'done', prompt: 'ship', agentType: 'codex' }, undefined ) - expect(reconciled.lead).toEqual({ state: 'done', stateStartedAt: expect.any(Number) }) + expect(reconciled.mainAgent).toEqual({ state: 'done', stateStartedAt: expect.any(Number) }) }) }) diff --git a/src/shared/agent-hook-listener/listener-state.ts b/src/shared/agent-hook-listener/listener-state.ts index 94895bbc6669..53848ca707a8 100644 --- a/src/shared/agent-hook-listener/listener-state.ts +++ b/src/shared/agent-hook-listener/listener-state.ts @@ -1,7 +1,7 @@ -import type { AgentLeadStatus } from '../agent-status-types' -import type { ClaudeLeadTurnState, CodexLeadTurnState } from './lead-turn-state' +import type { AgentMainAgentStatus } from '../agent-status-types' +import type { ClaudeLeadTurnState, CodexLeadTurnState } from './main-agent-turn-state' -export type { ClaudeLeadTurnState, CodexLeadTurnState } from './lead-turn-state' +export type { ClaudeLeadTurnState, CodexLeadTurnState } from './main-agent-turn-state' import { AGENT_STATUS_2A_CURRENT_PRODUCER_MODE, createAgentStatusLegacyAdapter, @@ -33,7 +33,7 @@ export type HookListenerState = { ampCompletedCacheKeys: Set /** Live subagents/teammates per Claude pane; survives turn boundaries since background children outlive the lead turn. */ claudeSubagentRosterByPaneKey: Map - /** Last state from the LEAD session's own events (subagent events carry agent_id, excluded), so a SubagentStop can re-emit pane status; `outcome` persists so the eventual done still carries it. Published on every row as `lead`. */ + /** Last state from the LEAD session's own events (subagent events carry agent_id, excluded), so a SubagentStop can re-emit pane status; `outcome` persists so the eventual done still carries it. Published on every row as `mainAgent`. */ claudeLeadStateByPaneKey: Map /** One-normalization provenance marker for a status backed only by restored child state. */ claudeUnconfirmedRestoredStatusPaneKeys: Set @@ -55,8 +55,8 @@ export type HookListenerState = { codexLeadStateByPaneKey: Map /** Newest Grok turn per pane, used to reject end reports that arrive after a replacement prompt. */ grokActiveTurnByPaneKey: Map - /** The Grok lead's own state as last published, so its clock keeps continuity across events. */ - grokLeadStatusByPaneKey: Map + /** The Grok main agent's own state as last published, so its clock keeps continuity across events. */ + grokMainAgentStatusByPaneKey: Map /** Muse child-session filter and session-log cursor per pane. */ musePaneStateByPaneKey: Map /** @@ -116,7 +116,7 @@ export function createHookListenerState( codexSubagentTranscriptByPaneKey: new Map(), codexLeadStateByPaneKey: new Map(), grokActiveTurnByPaneKey: new Map(), - grokLeadStatusByPaneKey: new Map(), + grokMainAgentStatusByPaneKey: new Map(), musePaneStateByPaneKey: new Map(), opencodeSessionPaneBySessionId: new Map(), lastLaunchTokenByPaneKey: new Map() @@ -206,7 +206,7 @@ export function clearPaneCacheState(state: HookListenerState, paneKey: string): state.codexSubagentTranscriptByPaneKey.delete(paneKey) state.codexLeadStateByPaneKey.delete(paneKey) state.grokActiveTurnByPaneKey.delete(paneKey) - state.grokLeadStatusByPaneKey.delete(paneKey) + state.grokMainAgentStatusByPaneKey.delete(paneKey) state.musePaneStateByPaneKey.delete(paneKey) unbindOpenCodeSessionsOfPane(state, paneKey) deletePaneScopedCacheEntry(state.lastLaunchTokenByPaneKey, paneKey) @@ -284,7 +284,7 @@ export function movePaneCacheState( movePaneScopedMapEntries(state.codexSubagentTranscriptByPaneKey, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.codexLeadStateByPaneKey, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.grokActiveTurnByPaneKey, fromPaneKey, toPaneKey) - movePaneScopedMapEntries(state.grokLeadStatusByPaneKey, fromPaneKey, toPaneKey) + movePaneScopedMapEntries(state.grokMainAgentStatusByPaneKey, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.musePaneStateByPaneKey, fromPaneKey, toPaneKey) moveOpenCodeSessionBindings(state, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.lastLaunchTokenByPaneKey, fromPaneKey, toPaneKey) @@ -296,7 +296,7 @@ export function clearPaneTurnCacheState(state: HookListenerState, paneKey: strin state.antigravityCompletedTranscriptByPaneKey.delete(paneKey) state.ampCompletedCacheKeys.delete(paneKey) state.grokActiveTurnByPaneKey.delete(paneKey) - state.grokLeadStatusByPaneKey.delete(paneKey) + state.grokMainAgentStatusByPaneKey.delete(paneKey) } export function deletePaneScopedCacheEntry(map: Map, paneKey: string): void { @@ -338,7 +338,7 @@ export function clearAllListenerCaches(state: HookListenerState): void { state.codexSubagentTranscriptByPaneKey.clear() state.codexLeadStateByPaneKey.clear() state.grokActiveTurnByPaneKey.clear() - state.grokLeadStatusByPaneKey.clear() + state.grokMainAgentStatusByPaneKey.clear() state.opencodeSessionPaneBySessionId.clear() state.lastLaunchTokenByPaneKey.clear() } diff --git a/src/shared/agent-hook-listener/lead-turn-state.ts b/src/shared/agent-hook-listener/main-agent-turn-state.ts similarity index 70% rename from src/shared/agent-hook-listener/lead-turn-state.ts rename to src/shared/agent-hook-listener/main-agent-turn-state.ts index f550391eb99c..04556ce959f4 100644 --- a/src/shared/agent-hook-listener/lead-turn-state.ts +++ b/src/shared/agent-hook-listener/main-agent-turn-state.ts @@ -1,21 +1,21 @@ import type { AgentStatusState } from '../agent-status-types' import type { AgentJournalTurnOutcome } from '../agent-turn-outcome' -/** The Claude lead's own turn record, published on every row as `lead`. */ +/** The Claude main agent's own turn record, published on every row as `mainAgent`. */ export type ClaudeLeadTurnState = { state: AgentStatusState /** The provider's verdict on the turn this record closed; only meaningful while `state` is done. * `cancellation` is what the fold reads as an interrupt. */ outcome?: AgentJournalTurnOutcome - /** When `state` first appeared; the lead's own clock, distinct from the gated row's. */ + /** When `state` first appeared; the main agent's own clock, distinct from the gated row's. */ stateStartedAt: number /** Subagent that induced the wait; only its next tool activity may clear it, so other children's churn can't dismiss a pending human-input card. */ waitingAgentId?: string /** Tool call that owns the wait; late completions from parallel sibling tools must not dismiss its card. */ waitingToolUseId?: string - /** End time of the lead turn closed while background inventory kept the pane `working`. Repeated on the later all-clear `done`. */ + /** End time of the main agent turn closed while background inventory kept the pane `working`. Repeated on the later all-clear `done`. */ turnCompletedAt?: number - /** Lead state a child-induced wait displaced, restored when the wait clears; can't invent 'working' since the done-gate only downgrades done→working, never back. */ + /** Main agent state a child-induced wait displaced, restored when the wait clears; can't invent 'working' since the done-gate only downgrades done→working, never back. */ stateBeforeWait?: Pick< ClaudeLeadTurnState, 'state' | 'outcome' | 'stateStartedAt' | 'turnCompletedAt' @@ -27,7 +27,7 @@ export type CodexLeadTurnState = { state: 'working' | 'waiting' | 'done' /** The turn verdict the server inferred; Codex's own Stop hook carries none. */ outcome?: AgentJournalTurnOutcome - /** When `state` first appeared; the root's own clock, published as `lead.stateStartedAt`. */ + /** When `state` first appeared; the root's own clock, published as `mainAgent.stateStartedAt`. */ stateStartedAt: number model?: string } diff --git a/src/shared/agent-hook-listener/providers/claude-events.ts b/src/shared/agent-hook-listener/providers/claude-events.ts index fd355a94ad5e..fb2c024c5ece 100644 --- a/src/shared/agent-hook-listener/providers/claude-events.ts +++ b/src/shared/agent-hook-listener/providers/claude-events.ts @@ -17,10 +17,10 @@ import { normalizeClaudeSubagentLifecycleEvent } from './claude-lifecycle-events' import { - claudeLeadTurnInterrupted, + claudeMainAgentTurnInterrupted, getOrCreateClaudeSubagentRoster, resolveClaudePaneStatus, - setClaudeLeadTurnState, + setClaudeMainAgentTurnState, updateClaudeRunningNonAgentTask, voidClaimsOfReplacedClaudeSession } from './claude-roster-state' @@ -64,7 +64,7 @@ export function normalizeClaudeEvent( state.claudeSubagentRosterByPaneKey.delete(paneKey) state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) state.claudeActiveSessionCronPaneKeys.delete(paneKey) - setClaudeLeadTurnState(state, paneKey, { state: 'done' }) + setClaudeMainAgentTurnState(state, paneKey, { state: 'done' }) return buildClaudeStatusPayload(state, eventName, promptText, paneKey, hookPayload, { stateName: 'done', updateToolSnapshot: true, @@ -77,7 +77,7 @@ export function normalizeClaudeEvent( const interrupted = isTurnBoundary && ((eventAgentId === undefined && hookPayload['is_interrupt'] === true) || - claudeLeadTurnInterrupted(previousLead)) + claudeMainAgentTurnInterrupted(previousLead)) ? true : undefined // Why: a verdict, never a guess — a plain Stop stays absent, so a cancel can never read as a @@ -197,7 +197,7 @@ export function normalizeClaudeEvent( } // Why: approval granted — update the tool snapshot (drop the pending card) as the lead's own next tool event would. // Restore the stashed lead state, not this child's 'working': the lead may already be done, and the done-gate never upgrades working back to done once the roster drains. - const restored = setClaudeLeadTurnState( + const restored = setClaudeMainAgentTurnState( state, paneKey, lead.stateBeforeWait ?? { state: 'working' as const } @@ -205,7 +205,7 @@ export function normalizeClaudeEvent( return buildClaudeStatusPayload(state, eventName, promptText, paneKey, hookPayload, { ...resolveClaudePaneStatus(state, paneKey, restored), updateToolSnapshot: true, - interrupted: claudeLeadTurnInterrupted(restored), + interrupted: claudeMainAgentTurnInterrupted(restored), turnCompletedAt: restored.turnCompletedAt }) } @@ -238,7 +238,7 @@ export function normalizeClaudeEvent( ? previousLead.stateBeforeWait : { state: previousLead.state, - // Why: the verdict and the lead's own clock are that turn's facts; a child's permission + // Why: the verdict and the main agent's own clock are that turn's facts; a child's permission // pause after a cancelled turn must not erase them when the wait clears. ...(previousLead.outcome ? { outcome: previousLead.outcome } : {}), stateStartedAt: previousLead.stateStartedAt, @@ -288,7 +288,7 @@ export function normalizeClaudeEvent( ? Date.now() : undefined - setClaudeLeadTurnState(state, paneKey, { + setClaudeMainAgentTurnState(state, paneKey, { state: reportedStateName, ...(outcome ? { outcome } : {}), ...(isWaitingInducing && eventAgentId ? { waitingAgentId: eventAgentId } : {}), diff --git a/src/shared/agent-hook-listener/providers/claude-lifecycle-events.ts b/src/shared/agent-hook-listener/providers/claude-lifecycle-events.ts index b2ab91beb06e..4d3f70a2b997 100644 --- a/src/shared/agent-hook-listener/providers/claude-lifecycle-events.ts +++ b/src/shared/agent-hook-listener/providers/claude-lifecycle-events.ts @@ -10,7 +10,7 @@ import { import type { HookListenerState } from '../listener-state' import { readString } from '../tool-input-preview' import { - claudeLeadTurnInterrupted, + claudeMainAgentTurnInterrupted, clearClaudePendingWaitForAgent, getOrCreateClaudeSubagentRoster, resolveClaudePaneStatus @@ -85,7 +85,7 @@ export function normalizeClaudeSubagentLifecycleEvent( const hasUnconfirmedChild = claudeRosterHasRestoredSnapshotSubagent(roster) const hasConfirmedDoneGate = cachedLead?.state === 'done' && - !claudeLeadTurnInterrupted(cachedLead) && + !claudeMainAgentTurnInterrupted(cachedLead) && (state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) || state.claudeActiveSessionCronPaneKeys.has(paneKey)) const restoredOnlyDoneGate = @@ -138,7 +138,7 @@ export function buildClaudeCachedLeadStatusPayload( outcome: lead?.outcome }), updateToolSnapshot: false, - interrupted: claudeLeadTurnInterrupted(lead), + interrupted: claudeMainAgentTurnInterrupted(lead), // Why: draining the last background child is this turn's all-clear; the stamp lets a consumer pair it with the announcement already sent. turnCompletedAt: lead?.turnCompletedAt }) diff --git a/src/shared/agent-hook-listener/providers/claude-roster-state.ts b/src/shared/agent-hook-listener/providers/claude-roster-state.ts index bc3a14868a63..fe5cb29860f1 100644 --- a/src/shared/agent-hook-listener/providers/claude-roster-state.ts +++ b/src/shared/agent-hook-listener/providers/claude-roster-state.ts @@ -1,10 +1,10 @@ import type { - AgentLeadStatus, + AgentMainAgentStatus, AgentSubagentSnapshot, AgentWorkingMode } from '../../agent-status-types' import { - continueAgentLeadStatus, + continueMainAgentStatus, foldAgentLeadStatus, type AgentLeadStatusResolution } from '../../agent-lead-status-fold' @@ -127,15 +127,15 @@ export function updateClaudeRunningNonAgentTask( export type ClaudePaneStatusResolution = AgentLeadStatusResolution /** A cancelled turn is the one verdict the display fold still reads. */ -export function claudeLeadTurnInterrupted( - lead: Pick | undefined +export function claudeMainAgentTurnInterrupted( + record: Pick | undefined ): boolean { - return lead?.outcome === 'cancellation' + return record?.outcome === 'cancellation' } -/** The only writer of the lead record. The lead's clock keeps continuity across same-state - * writes; a caller restoring a stash passes the stashed instant and wins. */ -export function setClaudeLeadTurnState( +/** The only writer of the main agent record. The main agent's clock keeps continuity across + * same-state writes; a caller restoring a stash passes the stashed instant and wins. */ +export function setClaudeMainAgentTurnState( state: HookListenerState, paneKey: string, next: Omit & { stateStartedAt?: number }, @@ -143,21 +143,21 @@ export function setClaudeLeadTurnState( ): ClaudeLeadTurnState { const previous = state.claudeLeadStateByPaneKey.get(paneKey) const { state: nextState, outcome, stateStartedAt, ...rest } = next - const lead: ClaudeLeadTurnState = { + const record: ClaudeLeadTurnState = { ...rest, - ...continueAgentLeadStatus(previous, { state: nextState, outcome, stateStartedAt }, now) + ...continueMainAgentStatus(previous, { state: nextState, outcome, stateStartedAt }, now) } - state.claudeLeadStateByPaneKey.set(paneKey, lead) - return lead + state.claudeLeadStateByPaneKey.set(paneKey, record) + return record } -/** The `lead` fact a row publishes from its record: nothing invented, so a pane whose lead was - * never observed publishes none and readers fall back to the combined `state`. */ -export function claudeLeadStatusForPayload(lead: ClaudeLeadTurnState): AgentLeadStatus { +/** The `mainAgent` fact a row publishes from its record: nothing invented, so a pane whose main + * agent was never observed publishes none and readers fall back to the combined `state`. */ +export function claudeMainAgentStatusForPayload(record: ClaudeLeadTurnState): AgentMainAgentStatus { return { - state: lead.state, - ...(lead.state === 'done' && lead.outcome ? { outcome: lead.outcome } : {}), - stateStartedAt: lead.stateStartedAt + state: record.state, + ...(record.state === 'done' && record.outcome ? { outcome: record.outcome } : {}), + stateStartedAt: record.stateStartedAt } } @@ -168,7 +168,7 @@ export function resolveClaudePaneStatus( ): ClaudePaneStatusResolution { return foldAgentLeadStatus({ leadState: lead.state, - interrupted: claudeLeadTurnInterrupted(lead), + interrupted: claudeMainAgentTurnInterrupted(lead), childWorkLiveness: agentChildWorkLivenessFromEvidence({ hasLiveAgentWork: claudeRosterHasWorkingSubagent( state.claudeSubagentRosterByPaneKey.get(paneKey) @@ -179,9 +179,9 @@ export function resolveClaudePaneStatus( }) }) } -/** Sync the Claude lead-turn record when the SERVER infers an interrupt outside the hook stream (Ctrl+C or Esc with no Stop, which is what current Claude does on every cancel); else a later child lifecycle event resurrects the cancelled pane. This is the primary source of `lead.outcome: 'cancellation'` in the CLI lane. */ +/** Sync the Claude lead-turn record when the SERVER infers an interrupt outside the hook stream (Ctrl+C or Esc with no Stop, which is what current Claude does on every cancel); else a later child lifecycle event resurrects the cancelled pane. This is the primary source of `mainAgent.outcome: 'cancellation'` in the CLI lane. */ export function markClaudeLeadTurnInterrupted(state: HookListenerState, paneKey: string): void { - setClaudeLeadTurnState(state, paneKey, { state: 'done', outcome: 'cancellation' }) + setClaudeMainAgentTurnState(state, paneKey, { state: 'done', outcome: 'cancellation' }) state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) state.claudeActiveSessionCronPaneKeys.delete(paneKey) } @@ -220,18 +220,18 @@ export function seedClaudeLeadTurnFromPersistedStatus( status: Pick, options: { childOnlyBoundary: boolean } ): void { - const lead = status.payload.lead - // Why: the persisted `lead` is the fact; a row old enough to lack it was mapped from its - // legacy child-only flag at hydrate, so both shapes arrive here as `lead.state === 'done'`. + const mainAgent = status.payload.mainAgent + // Why: the persisted `mainAgent` is the fact; a row old enough to lack it was mapped from its + // legacy child-only flag at hydrate, so both shapes arrive here as `mainAgent.state === 'done'`. if ( options.childOnlyBoundary && status.payload.agentType === 'claude' && - lead?.state === 'done' + mainAgent?.state === 'done' ) { - setClaudeLeadTurnState(state, paneKey, { + setClaudeMainAgentTurnState(state, paneKey, { state: 'done', - ...(lead.outcome ? { outcome: lead.outcome } : {}), - stateStartedAt: lead.stateStartedAt, + ...(mainAgent.outcome ? { outcome: mainAgent.outcome } : {}), + stateStartedAt: mainAgent.stateStartedAt, ...(status.payload.turnCompletedAt !== undefined ? { turnCompletedAt: status.payload.turnCompletedAt } : {}) @@ -277,7 +277,7 @@ export function clearClaudePendingWaitForAgent( if (lead?.state !== 'waiting' || !lead.waitingAgentId || !ownsWait(lead.waitingAgentId)) { return } - setClaudeLeadTurnState(state, paneKey, lead.stateBeforeWait ?? { state: 'working' }) + setClaudeMainAgentTurnState(state, paneKey, lead.stateBeforeWait ?? { state: 'working' }) const previousTool = state.lastToolByPaneKey.get(paneKey) state.lastToolByPaneKey.set( paneKey, @@ -297,15 +297,15 @@ export function clearClaudeAnsweredQuestionWait( ): Pick & { interrupted?: true workingMode?: AgentWorkingMode - lead: AgentLeadStatus + mainAgent: AgentMainAgentStatus } { const lead = state.claudeLeadStateByPaneKey.get(paneKey) const stash = lead?.state === 'waiting' ? (lead.stateBeforeWait ?? { state: 'working' as const }) : { state: 'working' as const } - const restored = setClaudeLeadTurnState(state, paneKey, { ...stash }) - const publishedLead = claudeLeadStatusForPayload(restored) + const restored = setClaudeMainAgentTurnState(state, paneKey, { ...stash }) + const publishedMainAgent = claudeMainAgentStatusForPayload(restored) const previousTool = state.lastToolByPaneKey.get(paneKey) state.lastToolByPaneKey.set( paneKey, @@ -320,10 +320,10 @@ export function clearClaudeAnsweredQuestionWait( return { state: resolved.stateName, ...(resolved.workingMode ? { workingMode: resolved.workingMode } : {}), - ...(claudeLeadTurnInterrupted(restored) ? { interrupted: true as const } : {}), + ...(claudeMainAgentTurnInterrupted(restored) ? { interrupted: true as const } : {}), ...(restored.turnCompletedAt !== undefined ? { turnCompletedAt: restored.turnCompletedAt } : {}), - lead: publishedLead + mainAgent: publishedMainAgent } } diff --git a/src/shared/agent-hook-listener/providers/claude-status-build.ts b/src/shared/agent-hook-listener/providers/claude-status-build.ts index 0e83c7cce218..9facff06d397 100644 --- a/src/shared/agent-hook-listener/providers/claude-status-build.ts +++ b/src/shared/agent-hook-listener/providers/claude-status-build.ts @@ -8,7 +8,7 @@ import { claudeRosterToSnapshots } from '../../claude-subagent-roster' import { resolvePrompt, resolveToolState } from '../prompt-fields' import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' import type { HookListenerState } from '../listener-state' -import { claudeLeadStatusForPayload } from './claude-roster-state' +import { claudeMainAgentStatusForPayload } from './claude-roster-state' export function buildClaudeStatusPayload( state: HookListenerState, @@ -32,7 +32,7 @@ export function buildClaudeStatusPayload( }) : (state.lastToolByPaneKey.get(paneKey) ?? {}) - const leadRecord = state.claudeLeadStateByPaneKey.get(paneKey) + const mainAgentRecord = state.claudeLeadStateByPaneKey.get(paneKey) // Why: validate directly — the JSON stringify/parse round trip other normalizers use is pure overhead on this hot per-hook path. // The normalizer clamps `interrupted` to done payloads, so a gated 'working' emit drops it; claudeLeadStateByPaneKey preserves it for the eventual done. return normalizeAgentStatusPayload({ @@ -52,7 +52,7 @@ export function buildClaudeStatusPayload( sessionBoundary: options.sessionBoundary, turnCompletedAt: options.turnCompletedAt, subagents: claudeRosterToSnapshots(state.claudeSubagentRosterByPaneKey.get(paneKey)), - // Why: every path writes the lead record before building, so the row's `lead` is that record. - lead: leadRecord ? claudeLeadStatusForPayload(leadRecord) : undefined + // Why: every path writes the main agent record before building, so the row's `mainAgent` is that record. + mainAgent: mainAgentRecord ? claudeMainAgentStatusForPayload(mainAgentRecord) : undefined }) } diff --git a/src/shared/agent-hook-listener/providers/codex-events.ts b/src/shared/agent-hook-listener/providers/codex-events.ts index 258b4f449f19..83607b9c6432 100644 --- a/src/shared/agent-hook-listener/providers/codex-events.ts +++ b/src/shared/agent-hook-listener/providers/codex-events.ts @@ -22,11 +22,11 @@ import { resolvePrompt, resolveToolState } from '../prompt-fields' import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' import { readString } from '../tool-input-preview' import { - codexLeadStatusForPayload, + codexMainAgentStatusForPayload, getOrCreateCodexSubagentRoster, getOrCreateCodexSubagentTranscriptState, hasCodexTranscriptSubagents, - setCodexLeadTurnState + setCodexMainAgentTurnState } from './codex-state' export function buildCodexStatusPayload( @@ -57,7 +57,7 @@ export function buildCodexStatusPayload( lastAssistantMessage: snapshot.lastAssistantMessage, lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput, subagents: codexRosterToSnapshots(state.codexSubagentRosterByPaneKey.get(paneKey)), - lead: codexLeadStatusForPayload(lead) + mainAgent: codexMainAgentStatusForPayload(lead) }) } @@ -227,7 +227,7 @@ export function normalizeCodexEvent( stateName ) const previousLead = state.codexLeadStateByPaneKey.get(paneKey) - setCodexLeadTurnState(state, paneKey, { + setCodexMainAgentTurnState(state, paneKey, { state: ownedState, model: normalizeOptionalField(hookPayload['model'], AGENT_MODEL_MAX_LENGTH) ?? diff --git a/src/shared/agent-hook-listener/providers/codex-state.ts b/src/shared/agent-hook-listener/providers/codex-state.ts index 5417a28ca585..06c72df10088 100644 --- a/src/shared/agent-hook-listener/providers/codex-state.ts +++ b/src/shared/agent-hook-listener/providers/codex-state.ts @@ -1,5 +1,5 @@ -import type { AgentLeadStatus, ParsedAgentStatusPayload } from '../../agent-status-types' -import { continueAgentLeadStatus } from '../../agent-lead-status-fold' +import type { AgentMainAgentStatus, ParsedAgentStatusPayload } from '../../agent-status-types' +import { continueMainAgentStatus } from '../../agent-lead-status-fold' import { codexRosterEffectiveState, codexRosterToSnapshots, @@ -43,35 +43,35 @@ export function hasCodexTranscriptSubagents(state: HookListenerState, paneKey: s } /** The only writer of the root record; the root's clock keeps continuity across same-state writes. */ -export function setCodexLeadTurnState( +export function setCodexMainAgentTurnState( state: HookListenerState, paneKey: string, next: Omit & { stateStartedAt?: number }, now = Date.now() ): CodexLeadTurnState { const previous = state.codexLeadStateByPaneKey.get(paneKey) - const continued = continueAgentLeadStatus(previous, next, now) - const lead: CodexLeadTurnState = { + const continued = continueMainAgentStatus(previous, next, now) + const record: CodexLeadTurnState = { state: next.state, ...(continued.outcome ? { outcome: continued.outcome } : {}), stateStartedAt: continued.stateStartedAt, model: next.model } - state.codexLeadStateByPaneKey.set(paneKey, lead) - return lead + state.codexLeadStateByPaneKey.set(paneKey, record) + return record } -/** The `lead` fact a Codex row publishes. Its combined `state` still comes from +/** The `mainAgent` fact a Codex row publishes. Its combined `state` still comes from * `codexRosterEffectiveState`, whose waiting-child rule the shared fold cannot express yet; * moving that combine onto the fold is a separate slice with its own story table. */ -export function codexLeadStatusForPayload( - lead: CodexLeadTurnState | undefined -): AgentLeadStatus | undefined { - return lead +export function codexMainAgentStatusForPayload( + record: CodexLeadTurnState | undefined +): AgentMainAgentStatus | undefined { + return record ? { - state: lead.state, - ...(lead.state === 'done' && lead.outcome ? { outcome: lead.outcome } : {}), - stateStartedAt: lead.stateStartedAt + state: record.state, + ...(record.state === 'done' && record.outcome ? { outcome: record.outcome } : {}), + stateStartedAt: record.stateStartedAt } : undefined } @@ -79,26 +79,26 @@ export function codexLeadStatusForPayload( export function seedCodexStateFromSnapshot( state: HookListenerState, paneKey: string, - payload: Pick + payload: Pick ): void { const snapshots = payload.subagents ?? [] if (snapshots.length > 0 && !state.codexSubagentRosterByPaneKey.has(paneKey)) { seedCodexSubagentRoster(getOrCreateCodexSubagentRoster(state, paneKey), snapshots) } if (!state.codexLeadStateByPaneKey.has(paneKey)) { - const lead = payload.lead + const mainAgent = payload.mainAgent // Why: child hooks after restart omit the root model; seed it from durable status before they can overwrite the cache. // A row that carries the root's own state is the fact; only an older row makes us infer it. - if (lead && lead.state !== 'blocked') { - setCodexLeadTurnState(state, paneKey, { - state: lead.state, - ...(lead.outcome ? { outcome: lead.outcome } : {}), - stateStartedAt: lead.stateStartedAt, + if (mainAgent && mainAgent.state !== 'blocked') { + setCodexMainAgentTurnState(state, paneKey, { + state: mainAgent.state, + ...(mainAgent.outcome ? { outcome: mainAgent.outcome } : {}), + stateStartedAt: mainAgent.stateStartedAt, model: payload.model }) return } - setCodexLeadTurnState(state, paneKey, { + setCodexMainAgentTurnState(state, paneKey, { // Why: a child wait drives the aggregate waiting state, so it is not evidence that the root itself was waiting. state: payload.state === 'done' @@ -115,7 +115,7 @@ export function seedCodexStateFromSnapshot( /** Sync the Codex lead record when the server infers an interrupt, so delayed child events cannot restore stale working state. */ export function markCodexLeadTurnInterrupted(state: HookListenerState, paneKey: string): void { const lead = state.codexLeadStateByPaneKey.get(paneKey) - setCodexLeadTurnState(state, paneKey, { + setCodexMainAgentTurnState(state, paneKey, { state: 'done', outcome: 'cancellation', model: lead?.model @@ -180,7 +180,7 @@ export function reconcileRemoteCodexState( } if (leadState) { const previousLead = state.codexLeadStateByPaneKey.get(paneKey) - setCodexLeadTurnState(state, paneKey, { + setCodexMainAgentTurnState(state, paneKey, { state: leadState, model: payload.model ?? previousLead?.model }) @@ -203,7 +203,7 @@ export function reconcileRemoteCodexState( state: codexRosterEffectiveState(roster, lead.state), model: lead.model ?? payload.model, subagents: codexRosterToSnapshots(roster), - // Why: main's cache outlives a relay restart, so it is the lead fact for a relayed row too. - lead: codexLeadStatusForPayload(lead) + // Why: main's cache outlives a relay restart, so it is the main agent fact for a relayed row too. + mainAgent: codexMainAgentStatusForPayload(lead) } } diff --git a/src/shared/agent-hook-listener/providers/grok-events.ts b/src/shared/agent-hook-listener/providers/grok-events.ts index c67457669977..bf46033cf7c7 100644 --- a/src/shared/agent-hook-listener/providers/grok-events.ts +++ b/src/shared/agent-hook-listener/providers/grok-events.ts @@ -3,7 +3,7 @@ import { type ParsedAgentStatusPayload } from '../../agent-status-types' import { isAskUserQuestionTool } from '../../agent-question-answered-intent' -import { continueAgentLeadStatus, foldAgentLeadStatus } from '../../agent-lead-status-fold' +import { continueMainAgentStatus, foldAgentLeadStatus } from '../../agent-lead-status-fold' import type { AgentChildWorkLiveness } from '../../agent-status-child-work-liveness' import { clearPaneTurnCacheState, type HookListenerState } from '../listener-state' import { normalizeGrokPromptId } from '../listener-limits' @@ -105,7 +105,7 @@ function grokHasRunningFiniteTask(hookPayload: Record): boolean }) } -/** What a plain `stop` leaves running behind the lead. Grok reports its finite tasks without a +/** What a plain `stop` leaves running behind the main agent. Grok reports its finite tasks without a * kind the roster could classify as agent work, and a still-active stop hook holds the turn the * same way, so both read as watch work: the pane stays `working` in monitoring mode. */ function grokChildWorkLivenessAfterStop( @@ -139,7 +139,7 @@ export function normalizeGrokEvent( } if (isGrokEvent(eventName, 'session_start')) { // Why: SessionStart resets stale per-turn state but must not create a working row before any prompt/tool event. - // The lead clock goes with it: a new process is a new lead. + // The main agent clock goes with it: a new process is a new main agent. clearPaneTurnCacheState(state, paneKey) return null } @@ -217,12 +217,12 @@ export function normalizeGrokEvent( : null }) const stateName = resolution.stateName - const lead = continueAgentLeadStatus( - state.grokLeadStatusByPaneKey.get(paneKey), + const mainAgent = continueMainAgentStatus( + state.grokMainAgentStatusByPaneKey.get(paneKey), { state: leadState, outcome }, Date.now() ) - state.grokLeadStatusByPaneKey.set(paneKey, lead) + state.grokMainAgentStatusByPaneKey.set(paneKey, mainAgent) const snapshot = resolveToolState( state, @@ -250,6 +250,6 @@ export function normalizeGrokEvent( ...(resolution.workingMode ? { workingMode: resolution.workingMode } : {}), ...(outcome === 'cancellation' ? { interrupted: true } : {}), ...(sessionBoundary ? { sessionBoundary: true } : {}), - lead + mainAgent }) } diff --git a/src/shared/agent-lead-status-fold.test.ts b/src/shared/agent-lead-status-fold.test.ts index e5db63abd218..75ab3da89dd1 100644 --- a/src/shared/agent-lead-status-fold.test.ts +++ b/src/shared/agent-lead-status-fold.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { - continueAgentLeadStatus, + continueMainAgentStatus, foldAgentLeadStatus, isAgentStatusHeldOpenByChildWork } from './agent-lead-status-fold' @@ -49,28 +49,30 @@ describe('foldAgentLeadStatus', () => { }) describe('isAgentStatusHeldOpenByChildWork', () => { - it('is true only when a settled lead sits under a row that is not settled', () => { - expect(isAgentStatusHeldOpenByChildWork({ state: 'working', lead: { state: 'done' } })).toBe( - true - ) - expect(isAgentStatusHeldOpenByChildWork({ state: 'done', lead: { state: 'done' } })).toBe(false) - expect(isAgentStatusHeldOpenByChildWork({ state: 'working', lead: { state: 'working' } })).toBe( + it('is true only when a settled main agent sits under a row that is not settled', () => { + expect( + isAgentStatusHeldOpenByChildWork({ state: 'working', mainAgent: { state: 'done' } }) + ).toBe(true) + expect(isAgentStatusHeldOpenByChildWork({ state: 'done', mainAgent: { state: 'done' } })).toBe( false ) - // No lead fact means no claim: an old host's row is never read as child-held. + expect( + isAgentStatusHeldOpenByChildWork({ state: 'working', mainAgent: { state: 'working' } }) + ).toBe(false) + // No main agent fact means no claim: an old host's row is never read as child-held. expect(isAgentStatusHeldOpenByChildWork({ state: 'working' })).toBe(false) }) }) -describe('continueAgentLeadStatus', () => { +describe('continueMainAgentStatus', () => { it('keeps the clock across an unchanged state and restarts it on a change', () => { - const first = continueAgentLeadStatus(undefined, { state: 'working' }, 10) + const first = continueMainAgentStatus(undefined, { state: 'working' }, 10) expect(first).toEqual({ state: 'working', stateStartedAt: 10 }) - expect(continueAgentLeadStatus(first, { state: 'working' }, 20)).toEqual({ + expect(continueMainAgentStatus(first, { state: 'working' }, 20)).toEqual({ state: 'working', stateStartedAt: 10 }) - expect(continueAgentLeadStatus(first, { state: 'done', outcome: 'failure' }, 30)).toEqual({ + expect(continueMainAgentStatus(first, { state: 'done', outcome: 'failure' }, 30)).toEqual({ state: 'done', outcome: 'failure', stateStartedAt: 30 @@ -78,12 +80,12 @@ describe('continueAgentLeadStatus', () => { }) it('lets a caller that knows the instant win, and never carries a verdict onto a live state', () => { - expect(continueAgentLeadStatus(undefined, { state: 'done', stateStartedAt: 4 }, 30)).toEqual({ + expect(continueMainAgentStatus(undefined, { state: 'done', stateStartedAt: 4 }, 30)).toEqual({ state: 'done', stateStartedAt: 4 }) expect( - continueAgentLeadStatus(undefined, { state: 'working', outcome: 'cancellation' }, 30) + continueMainAgentStatus(undefined, { state: 'working', outcome: 'cancellation' }, 30) ).toEqual({ state: 'working', stateStartedAt: 30 }) }) }) diff --git a/src/shared/agent-lead-status-fold.ts b/src/shared/agent-lead-status-fold.ts index c35872cda876..c74ae7df1f67 100644 --- a/src/shared/agent-lead-status-fold.ts +++ b/src/shared/agent-lead-status-fold.ts @@ -1,5 +1,5 @@ import type { AgentChildWorkLiveness } from './agent-status-child-work-liveness' -import type { AgentLeadStatus, AgentStatusState, AgentWorkingMode } from './agent-status-types' +import type { AgentMainAgentStatus, AgentStatusState, AgentWorkingMode } from './agent-status-types' export type AgentLeadStatusFoldInput = { /** The lead's own turn state. Anything but `done` wins outright. */ @@ -34,23 +34,27 @@ export function foldAgentLeadStatus(input: AgentLeadStatusFoldInput): AgentLeadS return { stateName: 'done' } } -/** The lead settled and live child work is the only thing holding the row open. Derived, +/** The main agent settled and live child work is the only thing holding the row open. Derived, * never stored: a stored copy could disagree with the two facts it is made of. */ export function isAgentStatusHeldOpenByChildWork(row: { state: AgentStatusState - lead?: Pick + mainAgent?: Pick }): boolean { - return row.lead?.state === 'done' && row.state !== 'done' + return row.mainAgent?.state === 'done' && row.state !== 'done' } -/** The lead's clock follows the same continuity rule as the row's: an unchanged lead state +/** The main agent's clock follows the same continuity rule as the row's: an unchanged main agent state * keeps the instant it first appeared, a changed one starts at `now`. A caller that knows * the real instant (a restored stash, a journal record) passes it and wins. */ -export function continueAgentLeadStatus( - previous: Pick | undefined, - next: { state: AgentStatusState; outcome?: AgentLeadStatus['outcome']; stateStartedAt?: number }, +export function continueMainAgentStatus( + previous: Pick | undefined, + next: { + state: AgentStatusState + outcome?: AgentMainAgentStatus['outcome'] + stateStartedAt?: number + }, now: number -): AgentLeadStatus { +): AgentMainAgentStatus { const stateStartedAt = next.stateStartedAt ?? (previous && previous.state === next.state ? previous.stateStartedAt : now) diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index d73a9ae09a5a..eae648968e0f 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -222,7 +222,7 @@ export type AgentSessionStatusSummary = { /** The provider's verdict on the newest settled root turn. Present only while `status` is * `idle`: a running or attention-blocked turn has no verdict yet, and a stale one must not * ride along. Absent means UNKNOWN, never success. Optional for mixed-version hosts; the - * agent-status row publishes it as `lead.outcome`. */ + * agent-status row publishes it as `mainAgent.outcome`. */ turnOutcome?: AgentJournalTurnOutcome /** Live provider-owned background tasks, so session lists can render * subagent children without holding a journal reader open. Optional for @@ -245,8 +245,8 @@ export type AgentSessionStatusEvent = * One root turn reaching a terminal outcome, derived by the EXECUTION HOST at journal commit. * * This is the EDGE, with turn identity; `AgentSessionStatusSummary.turnOutcome` is the STATE. - * The summary carries the verdict only while the session is idle, as a fact about the lead's - * last turn that a status reader may act on (attention alerts, the `lead.outcome` row field), + * The summary carries the verdict only while the session is idle, as a fact about the main agent's + * last turn that a status reader may act on (attention alerts, the `mainAgent.outcome` row field), * and never a turn id: a reader that needs to know WHICH turn finished, or to react exactly once * per finish, subscribes here. Re-broadcasting the summary on every status change therefore * repeats a state, not a completion. diff --git a/src/shared/agent-status-types.test.ts b/src/shared/agent-status-types.test.ts index ff3796ea257c..55da23c2bb58 100644 --- a/src/shared/agent-status-types.test.ts +++ b/src/shared/agent-status-types.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, it, expect, vi } from 'vitest' import { - agentLeadStatusEqual, + mainAgentStatusEqual, agentSubagentsEqual, isFreshNonDoneAgentStatus, parseAgentStatusPayload, @@ -709,28 +709,28 @@ describe('WellKnownAgentType', () => { }) }) -describe('the lead field on a status payload', () => { - it('admits a well-formed lead with its verdict only while the lead is done', () => { +describe('the main agent field on a status payload', () => { + it('admits a well-formed main agent with its verdict only while the main agent is done', () => { expect( parseAgentStatusPayload( - '{"state":"working","lead":{"state":"done","outcome":"cancellation","stateStartedAt":5}}' - )?.lead + '{"state":"working","mainAgent":{"state":"done","outcome":"cancellation","stateStartedAt":5}}' + )?.mainAgent ).toEqual({ state: 'done', outcome: 'cancellation', stateStartedAt: 5 }) - // A verdict belongs to a finished turn; one riding on a live lead state is stale. + // A verdict belongs to a finished turn; one riding on a live main agent state is stale. expect( parseAgentStatusPayload( - '{"state":"working","lead":{"state":"working","outcome":"failure","stateStartedAt":5}}' - )?.lead + '{"state":"working","mainAgent":{"state":"working","outcome":"failure","stateStartedAt":5}}' + )?.mainAgent ).toEqual({ state: 'working', stateStartedAt: 5 }) expect( parseAgentStatusPayload( - '{"state":"done","lead":{"state":"done","outcome":"maybe","stateStartedAt":5}}' - )?.lead + '{"state":"done","mainAgent":{"state":"done","outcome":"maybe","stateStartedAt":5}}' + )?.mainAgent ).toEqual({ state: 'done', stateStartedAt: 5 }) }) - it('drops a malformed lead but never the row it rides on', () => { - for (const lead of [ + it('drops a malformed main agent but never the row it rides on', () => { + for (const mainAgent of [ '"done"', '{"state":"running","stateStartedAt":5}', '{"state":"done"}', @@ -739,23 +739,25 @@ describe('the lead field on a status payload', () => { 'null' ]) { const parsed = parseAgentStatusPayload( - `{"state":"working","prompt":"keep me","lead":${lead}}` + `{"state":"working","prompt":"keep me","mainAgent":${mainAgent}}` ) - expect(parsed, lead).toMatchObject({ state: 'working', prompt: 'keep me' }) - expect(parsed?.lead, lead).toBeUndefined() + expect(parsed, mainAgent).toMatchObject({ state: 'working', prompt: 'keep me' }) + expect(parsed?.mainAgent, mainAgent).toBeUndefined() } }) it('is carried by the client-visible projection and compared structurally', () => { - const lead = { state: 'done' as const, stateStartedAt: 7 } - expect(pickParsedAgentStatusPayload({ state: 'working', prompt: '', lead }).lead).toEqual(lead) + const mainAgent = { state: 'done' as const, stateStartedAt: 7 } + expect( + pickParsedAgentStatusPayload({ state: 'working', prompt: '', mainAgent }).mainAgent + ).toEqual(mainAgent) expect(pickParsedAgentStatusPayload({ state: 'working', prompt: '' })).not.toHaveProperty( - 'lead' + 'mainAgent' ) - expect(agentLeadStatusEqual(lead, { ...lead })).toBe(true) - expect(agentLeadStatusEqual(lead, { ...lead, outcome: 'failure' })).toBe(false) - expect(agentLeadStatusEqual(lead, { ...lead, stateStartedAt: 8 })).toBe(false) - expect(agentLeadStatusEqual(undefined, undefined)).toBe(true) - expect(agentLeadStatusEqual(lead, undefined)).toBe(false) + expect(mainAgentStatusEqual(mainAgent, { ...mainAgent })).toBe(true) + expect(mainAgentStatusEqual(mainAgent, { ...mainAgent, outcome: 'failure' })).toBe(false) + expect(mainAgentStatusEqual(mainAgent, { ...mainAgent, stateStartedAt: 8 })).toBe(false) + expect(mainAgentStatusEqual(undefined, undefined)).toBe(true) + expect(mainAgentStatusEqual(mainAgent, undefined)).toBe(false) }) }) diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index d6f7b432cbe8..e198f366b2b4 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -3,7 +3,7 @@ // a narrow interrupt fallback synthesizes a final `done` when an agent misses its cancellation hook. import type { AgentProviderSessionMetadata } from './agent-session-resume' -import type { AgentLeadStatus } from './agent-lead-status' +import type { AgentMainAgentStatus } from './main-agent-status' import { isAgentJournalTurnOutcome } from './agent-turn-outcome' import type { OrchestrationFleetAttention } from './orchestration-fleet-attention' import type { AgentStatusRowFacets } from './agent-status-observation' @@ -24,7 +24,7 @@ export type { AgentStatusIpcPayload, MigrationUnsupportedPtyEntry } from './agent-status-ipc-payload' -export { agentLeadStatusEqual, type AgentLeadStatus } from './agent-lead-status' +export { mainAgentStatusEqual, type AgentMainAgentStatus } from './main-agent-status' export const AGENT_STATUS_STATES = ['working', 'blocked', 'waiting', 'done'] as const export type AgentStatusState = (typeof AGENT_STATUS_STATES)[number] @@ -153,9 +153,9 @@ export type AgentStatusEntry = { /** Live in-process subagents/teammates of this pane's session. Absent when * none are tracked; the sidebar derives indented child rows from it. */ subagents?: AgentSubagentSnapshot[] - /** The lead's own state; absent from old hosts and from writers that carry no lead fact + /** The main agent's own state; absent from old hosts and from writers that carry no main agent fact * (OSC, launch seeds), where readers fall back to `state`. */ - lead?: AgentLeadStatus + mainAgent?: AgentMainAgentStatus /** Provider-owned conversation/session id captured from hook payloads. * Used only for exact CLI resume; Orca terminal ids are not agent-session ids. */ providerSession?: AgentProviderSessionMetadata @@ -200,9 +200,9 @@ export type AgentStatusPayload = { turnCompletedAt?: number /** Live in-process children of the reporting session. See AgentStatusEntry. */ subagents?: AgentSubagentSnapshot[] - /** The lead's own state and last-turn verdict. See AgentLeadStatus. Producers publish it + /** The main agent's own state and last-turn verdict. See AgentMainAgentStatus. Producers publish it * beside the combined `state`; a reader that predates it keeps reading `state`. */ - lead?: AgentLeadStatus + mainAgent?: AgentMainAgentStatus } /** @@ -241,7 +241,7 @@ export function pickParsedAgentStatusPayload( ...(row.sessionBoundary !== undefined ? { sessionBoundary: row.sessionBoundary } : {}), ...(row.turnCompletedAt !== undefined ? { turnCompletedAt: row.turnCompletedAt } : {}), ...(row.subagents !== undefined ? { subagents: row.subagents } : {}), - ...(row.lead !== undefined ? { lead: row.lead } : {}) + ...(row.mainAgent !== undefined ? { mainAgent: row.mainAgent } : {}) } } @@ -336,9 +336,9 @@ function normalizeSubagentsField(value: unknown): AgentSubagentSnapshot[] | unde return normalized.length > 0 ? normalized : undefined } -/** A malformed `lead` drops the FIELD, never the row: the combined `state` is still valid +/** A malformed `mainAgent` drops the FIELD, never the row: the combined `state` is still valid * evidence, and readers fall back to it exactly as they do for a host that predates the field. */ -function normalizeLeadStatusField(value: unknown): AgentLeadStatus | undefined { +function normalizeMainAgentStatusField(value: unknown): AgentMainAgentStatus | undefined { if (typeof value !== 'object' || value === null) { return undefined } @@ -435,7 +435,7 @@ function normalizeAgentStatusObject(parsed: unknown): ParsedAgentStatusPayload | sessionBoundary: obj.sessionBoundary === true && state === 'done' ? true : undefined, turnCompletedAt: normalizeTurnCompletedAtField(obj.turnCompletedAt, state), subagents: normalizeSubagentsField(obj.subagents), - lead: normalizeLeadStatusField(obj.lead) + mainAgent: normalizeMainAgentStatusField(obj.mainAgent) } } diff --git a/src/shared/agent-turn-outcome.ts b/src/shared/agent-turn-outcome.ts index bb07a6500bc1..4f532b212b49 100644 --- a/src/shared/agent-turn-outcome.ts +++ b/src/shared/agent-turn-outcome.ts @@ -2,7 +2,7 @@ * state so those stay a report on what the HOST observed. `cancellation` is a * stop somebody asked for, `failure` is the provider's own error, and the two * are never interchangeable: only `failure` is a fault. Shared by the journal's - * turn record and the agent-status row's `lead` fact so one vocabulary serves + * turn record and the agent-status row's `mainAgent` fact so one vocabulary serves * both; absent always means UNKNOWN, never success. */ export const AGENT_JOURNAL_TURN_OUTCOMES = ['success', 'failure', 'cancellation'] as const export type AgentJournalTurnOutcome = (typeof AGENT_JOURNAL_TURN_OUTCOMES)[number] diff --git a/src/shared/agent-lead-status-parity.test.ts b/src/shared/main-agent-status-parity.test.ts similarity index 77% rename from src/shared/agent-lead-status-parity.test.ts rename to src/shared/main-agent-status-parity.test.ts index cf14ee00c92f..25112754034c 100644 --- a/src/shared/agent-lead-status-parity.test.ts +++ b/src/shared/main-agent-status-parity.test.ts @@ -1,6 +1,6 @@ -// One story table driven through every lane that publishes a lead agent's status. Each lane -// derives child liveness from its own evidence, but the published `{ state, workingMode, lead }` -// must be what the shared fold says for that lead and that evidence — a producer that folds +// One story table driven through every lane that publishes a main agent's status. Each lane +// derives child liveness from its own evidence, but the published `{ state, workingMode, mainAgent }` +// must be what the shared fold says for that main agent and that evidence — a producer that folds // differently is caught here structurally, not by review. import { beforeEach, describe, expect, it } from 'vitest' import { normalizeHookPayload } from './agent-hook-listener' @@ -18,7 +18,7 @@ import { type AgentChildWorkLiveness } from './agent-status-child-work-liveness' import type { - AgentLeadStatus, + AgentMainAgentStatus, AgentStatusState, AgentWorkingMode, ParsedAgentStatusPayload @@ -30,7 +30,7 @@ import type { AgentJournalTurnOutcome } from './agent-turn-outcome' type Published = { state: AgentStatusState workingMode?: AgentWorkingMode - lead: Omit + mainAgent: Omit } const RUNNING_SHELL = { id: 'shell-1', type: 'shell', status: 'running' } @@ -41,28 +41,31 @@ const AGENT_TASK: AgentSessionBackgroundTask = { id: 'agent-1', kind: 'agent', s const SHELL_TASK: AgentSessionBackgroundTask = { id: 'shell-1', kind: 'command', state: 'working' } function published(payload: ParsedAgentStatusPayload | null | undefined): Published { - if (!payload?.lead) { - throw new Error('the lane published no lead fact') + if (!payload?.mainAgent) { + throw new Error('the lane published no main agent fact') } - const { stateStartedAt: _clock, ...lead } = payload.lead + const { stateStartedAt: _clock, ...mainAgent } = payload.mainAgent return { state: payload.state, ...(payload.workingMode ? { workingMode: payload.workingMode } : {}), - lead + mainAgent } } -/** The lead's own state and verdict, restated as the fold's inputs. */ -function refold(lead: Published['lead'], childWorkLiveness: AgentChildWorkLiveness): Published { +/** The main agent's own state and verdict, restated as the fold's inputs. */ +function refold( + mainAgent: Published['mainAgent'], + childWorkLiveness: AgentChildWorkLiveness +): Published { const resolution = foldAgentLeadStatus({ - leadState: lead.state, - interrupted: lead.outcome === 'cancellation', + leadState: mainAgent.state, + interrupted: mainAgent.outcome === 'cancellation', childWorkLiveness }) return { state: resolution.stateName, ...(resolution.workingMode ? { workingMode: resolution.workingMode } : {}), - lead + mainAgent } } @@ -84,16 +87,19 @@ const STORIES: Story[] = [ name: 'main agent working', claude: { events: [{ hook_event_name: 'UserPromptSubmit', prompt: 'go' }], - expect: { state: 'working', lead: { state: 'working' } } + expect: { state: 'working', mainAgent: { state: 'working' } } + }, + structured: { + status: 'working', + expect: { state: 'working', mainAgent: { state: 'working' } } }, - structured: { status: 'working', expect: { state: 'working', lead: { state: 'working' } } }, grok: { events: [{ hookEventName: 'user_prompt_submit', prompt: 'go' }], - expect: { state: 'working', lead: { state: 'working' } } + expect: { state: 'working', mainAgent: { state: 'working' } } }, codex: { events: [{ hook_event_name: 'UserPromptSubmit', prompt: 'go' }], - expect: { state: 'working', lead: { state: 'working' } } + expect: { state: 'working', mainAgent: { state: 'working' } } } }, { @@ -104,12 +110,12 @@ const STORIES: Story[] = [ { hook_event_name: 'SubagentStart', agent_id: 'agent-1' }, { hook_event_name: 'Stop', background_tasks: [RUNNING_AGENT] } ], - expect: { state: 'working', lead: { state: 'done' } } + expect: { state: 'working', mainAgent: { state: 'done' } } }, structured: { status: 'idle', backgroundTasks: [AGENT_TASK], - expect: { state: 'working', lead: { state: 'done' } } + expect: { state: 'working', mainAgent: { state: 'done' } } }, grok: { events: [ @@ -118,7 +124,7 @@ const STORIES: Story[] = [ ], // Grok reports no task kind the roster can classify as agent work, so its live subagent // reads as watch work. Today's label, kept on purpose; a Grok-specific follow-up. - expect: { state: 'working', workingMode: 'monitoring', lead: { state: 'done' } } + expect: { state: 'working', workingMode: 'monitoring', mainAgent: { state: 'done' } } }, codex: { // A root Stop with no transcript-tracked children clears the roster (Codex 0.144 could omit @@ -129,7 +135,7 @@ const STORIES: Story[] = [ { hook_event_name: 'Stop' }, { hook_event_name: 'PreToolUse', agent_id: 'agent-1', tool_name: 'shell' } ], - expect: { state: 'working', lead: { state: 'done' } } + expect: { state: 'working', mainAgent: { state: 'done' } } } }, { @@ -139,19 +145,19 @@ const STORIES: Story[] = [ { hook_event_name: 'UserPromptSubmit', prompt: 'go' }, { hook_event_name: 'Stop', background_tasks: [RUNNING_SHELL] } ], - expect: { state: 'working', workingMode: 'monitoring', lead: { state: 'done' } } + expect: { state: 'working', workingMode: 'monitoring', mainAgent: { state: 'done' } } }, structured: { status: 'idle', backgroundTasks: [SHELL_TASK], - expect: { state: 'working', workingMode: 'monitoring', lead: { state: 'done' } } + expect: { state: 'working', workingMode: 'monitoring', mainAgent: { state: 'done' } } }, grok: { events: [ { hookEventName: 'user_prompt_submit', prompt: 'go' }, { hookEventName: 'stop', reason: 'end_turn', backgroundTasks: [RUNNING_SHELL] } ], - expect: { state: 'working', workingMode: 'monitoring', lead: { state: 'done' } } + expect: { state: 'working', workingMode: 'monitoring', mainAgent: { state: 'done' } } } }, { @@ -162,20 +168,20 @@ const STORIES: Story[] = [ { hook_event_name: 'SubagentStart', agent_id: 'agent-1' }, { hook_event_name: 'PermissionRequest', tool_name: 'Bash', tool_input: { command: 'rm' } } ], - // The hook lane's vocabulary for "the lead needs a human" is `waiting`. - expect: { state: 'waiting', lead: { state: 'waiting' } } + // The hook lane's vocabulary for "the main agent needs a human" is `waiting`. + expect: { state: 'waiting', mainAgent: { state: 'waiting' } } }, structured: { status: 'attention', backgroundTasks: [AGENT_TASK], - expect: { state: 'blocked', lead: { state: 'blocked' } } + expect: { state: 'blocked', mainAgent: { state: 'blocked' } } }, grok: { events: [ { hookEventName: 'user_prompt_submit', prompt: 'go' }, { hookEventName: 'pre_tool_use', toolName: 'ask_user_question' } ], - expect: { state: 'waiting', lead: { state: 'waiting' } } + expect: { state: 'waiting', mainAgent: { state: 'waiting' } } }, codex: { events: [ @@ -183,7 +189,7 @@ const STORIES: Story[] = [ { hook_event_name: 'SubagentStart', agent_id: 'agent-1' }, { hook_event_name: 'PermissionRequest', tool_name: 'shell' } ], - expect: { state: 'waiting', lead: { state: 'waiting' } } + expect: { state: 'waiting', mainAgent: { state: 'waiting' } } } }, { @@ -193,19 +199,19 @@ const STORIES: Story[] = [ { hook_event_name: 'UserPromptSubmit', prompt: 'go' }, { hook_event_name: 'StopFailure', error: 'invalid_request' } ], - expect: { state: 'done', lead: { state: 'done', outcome: 'failure' } } + expect: { state: 'done', mainAgent: { state: 'done', outcome: 'failure' } } }, structured: { status: 'idle', turnOutcome: 'failure', - expect: { state: 'done', lead: { state: 'done', outcome: 'failure' } } + expect: { state: 'done', mainAgent: { state: 'done', outcome: 'failure' } } }, grok: { events: [ { hookEventName: 'user_prompt_submit', prompt: 'go' }, { hookEventName: 'stop_failure' } ], - expect: { state: 'done', lead: { state: 'done', outcome: 'failure' } } + expect: { state: 'done', mainAgent: { state: 'done', outcome: 'failure' } } } }, { @@ -213,7 +219,7 @@ const STORIES: Story[] = [ // interrupted turn; the structured lane never feeds the verdict into the fold and keeps // showing the shell. The cancel policy (PR C) flips the hook-lane rows to monitoring and // must update this story, not delete it. The Claude row here is the primary path: Orca's - // inferred cancel, carried by the lead record into the next Stop, which lists the shell. + // inferred cancel, carried by the main agent record into the next Stop, which lists the shell. name: 'interrupted with a watch loop (known divergence: CLI done / structured monitoring)', claude: { events: [ @@ -221,7 +227,7 @@ const STORIES: Story[] = [ ORCA_INFERRED_INTERRUPT, { hook_event_name: 'Stop', background_tasks: [RUNNING_SHELL] } ], - expect: { state: 'done', lead: { state: 'done', outcome: 'cancellation' } } + expect: { state: 'done', mainAgent: { state: 'done', outcome: 'cancellation' } } }, structured: { status: 'idle', @@ -230,7 +236,7 @@ const STORIES: Story[] = [ expect: { state: 'working', workingMode: 'monitoring', - lead: { state: 'done', outcome: 'cancellation' } + mainAgent: { state: 'done', outcome: 'cancellation' } } }, grok: { @@ -238,7 +244,7 @@ const STORIES: Story[] = [ { hookEventName: 'user_prompt_submit', prompt: 'go' }, { hookEventName: 'stop_cancelled', backgroundTasks: [RUNNING_SHELL] } ], - expect: { state: 'done', lead: { state: 'done', outcome: 'cancellation' } } + expect: { state: 'done', mainAgent: { state: 'done', outcome: 'cancellation' } } } }, { @@ -249,15 +255,15 @@ const STORIES: Story[] = [ { hook_event_name: 'UserPromptSubmit', prompt: 'go' }, { hook_event_name: 'Stop', is_interrupt: true, background_tasks: [RUNNING_SHELL] } ], - expect: { state: 'done', lead: { state: 'done', outcome: 'cancellation' } } + expect: { state: 'done', mainAgent: { state: 'done', outcome: 'cancellation' } } } } ] /** Codex never reports a blocked root; the combine's input type says so. */ -function codexLeadState(state: AgentStatusState): 'working' | 'waiting' | 'done' { +function codexMainAgentState(state: AgentStatusState): 'working' | 'waiting' | 'done' { if (state === 'blocked') { - throw new Error('Codex published a blocked lead') + throw new Error('Codex published a blocked main agent') } return state } @@ -276,7 +282,7 @@ function storiesFor( return rows } -describe('lead status parity across lanes', () => { +describe('mainAgent status parity across lanes', () => { let state: HookListenerState beforeEach(() => { @@ -322,7 +328,7 @@ describe('lead status parity across lanes', () => { const payload = drive('claude', lane.events) const row = published(payload) expect(row).toEqual(lane.expect) - expect(row).toEqual(refold(row.lead, claudeChildWorkLiveness(payload))) + expect(row).toEqual(refold(row.mainAgent, claudeChildWorkLiveness(payload))) }) }) @@ -335,10 +341,10 @@ describe('lead status parity across lanes', () => { }) expect(row).toEqual(lane.expect) // This lane never feeds the verdict into the fold: refold with the verdict masked. - const masked = { state: row.lead.state } + const masked = { state: row.mainAgent.state } expect(refold(masked, agentChildWorkLiveness(lane.backgroundTasks))).toEqual({ ...row, - lead: masked + mainAgent: masked }) }) }) @@ -356,7 +362,7 @@ describe('lead status parity across lanes', () => { last.hookEventName === 'stop' && (tasks.length > 0 || last.stopHookActive === true) ? 'monitoring' : null - expect(row).toEqual(refold(row.lead, liveness)) + expect(row).toEqual(refold(row.mainAgent, liveness)) }) }) @@ -369,7 +375,9 @@ describe('lead status parity across lanes', () => { // waiting child wins, a settled root with any live child reads working, no monitoring. const roster = new Map() seedCodexSubagentRoster(roster, payload.subagents ?? []) - expect(row.state).toBe(codexRosterEffectiveState(roster, codexLeadState(row.lead.state))) + expect(row.state).toBe( + codexRosterEffectiveState(roster, codexMainAgentState(row.mainAgent.state)) + ) }) }) }) diff --git a/src/shared/agent-lead-status.ts b/src/shared/main-agent-status.ts similarity index 64% rename from src/shared/agent-lead-status.ts rename to src/shared/main-agent-status.ts index f550dab7f547..962d79920017 100644 --- a/src/shared/agent-lead-status.ts +++ b/src/shared/main-agent-status.ts @@ -1,25 +1,25 @@ import type { AgentStatusState } from './agent-status-types' import type { AgentJournalTurnOutcome } from './agent-turn-outcome' -/** The main (lead) agent's OWN state, kept apart from the row's combined `state`. The row's - * `state` answers "what should the user see" and folds live child work in, so a settled lead - * whose subagent still runs reads `working`; this answers "what is the lead itself doing". +/** The main agent's OWN state, kept apart from the row's combined `state`. The row's + * `state` answers "what should the user see" and folds live child work in, so a settled main agent + * whose subagent still runs reads `working`; this answers "what is the main agent itself doing". * Persisted on disk and carried on every wire, so its shape is permanent. */ -export type AgentLeadStatus = { +export type AgentMainAgentStatus = { state: AgentStatusState - /** The provider's verdict on the lead's most recent finished turn. Present only while + /** The provider's verdict on the main agent's most recent finished turn. Present only while * `state` is `done`; a new turn clears it. ABSENT MEANS UNKNOWN — a plain Stop never * infers `success`, because an older provider that omits its interrupt flag would turn * a cancel into a false success. */ outcome?: AgentJournalTurnOutcome - /** When the lead's own `state` first appeared (ms). The row's `stateStartedAt` dates the + /** When the main agent's own `state` first appeared (ms). The row's `stateStartedAt` dates the * combined state instead, so the two differ while child work holds the row open. */ stateStartedAt: number } -export function agentLeadStatusEqual( - a: AgentLeadStatus | undefined, - b: AgentLeadStatus | undefined +export function mainAgentStatusEqual( + a: AgentMainAgentStatus | undefined, + b: AgentMainAgentStatus | undefined ): boolean { if (a === b) { return true diff --git a/src/shared/structured-agent-session-agent-status.test.ts b/src/shared/structured-agent-session-agent-status.test.ts index c381d6f9c6aa..15dc6995748b 100644 --- a/src/shared/structured-agent-session-agent-status.test.ts +++ b/src/shared/structured-agent-session-agent-status.test.ts @@ -14,20 +14,20 @@ describe('structuredAgentSessionAgentStatus', () => { it('maps a lead that is still working or needs attention without consulting children', () => { expect(structuredAgentSessionAgentStatus({ status: 'working' })).toEqual({ state: 'working', - lead: { state: 'working' } + mainAgent: { state: 'working' } }) expect( structuredAgentSessionAgentStatus({ status: 'attention', backgroundTasks: [task({ kind: 'command' })] }) - ).toEqual({ state: 'blocked', lead: { state: 'blocked' } }) + ).toEqual({ state: 'blocked', mainAgent: { state: 'blocked' } }) }) - it('keeps an idle lead working while a subagent runs, and says the lead itself is done', () => { + it('keeps an idle main agent working while a subagent runs, and says the main agent itself is done', () => { expect( structuredAgentSessionAgentStatus({ status: 'idle', backgroundTasks: [task()] }) - ).toEqual({ state: 'working', lead: { state: 'done' } }) + ).toEqual({ state: 'working', mainAgent: { state: 'done' } }) }) it('reads an idle lead with only a backgrounded shell as monitoring', () => { @@ -36,14 +36,14 @@ describe('structuredAgentSessionAgentStatus', () => { status: 'idle', backgroundTasks: [task({ kind: 'command', description: 'sleep 180' })] }) - ).toEqual({ state: 'working', workingMode: 'monitoring', lead: { state: 'done' } }) + ).toEqual({ state: 'working', workingMode: 'monitoring', mainAgent: { state: 'done' } }) }) it('keeps an idle lead working while a subagent is blocked or out of contact', () => { for (const state of ['waiting', 'blocked', 'unverifiable'] as const) { expect( structuredAgentSessionAgentStatus({ status: 'idle', backgroundTasks: [task({ state })] }) - ).toEqual({ state: 'working', lead: { state: 'done' } }) + ).toEqual({ state: 'working', mainAgent: { state: 'done' } }) } }) @@ -54,7 +54,7 @@ describe('structuredAgentSessionAgentStatus', () => { expect(structuredAgentSessionAgentStatus({ status: 'idle', backgroundTasks })).toEqual({ state: 'working', workingMode: 'monitoring', - lead: { state: 'done' } + mainAgent: { state: 'done' } }) expect( projectAgentChildWorkLegacySubagents( @@ -72,19 +72,19 @@ describe('structuredAgentSessionAgentStatus', () => { task({ id: 'shell', kind: 'command', state: 'idle' }) ] }) - ).toEqual({ state: 'done', lead: { state: 'done' } }) + ).toEqual({ state: 'done', mainAgent: { state: 'done' } }) expect(structuredAgentSessionAgentStatus({ status: 'idle' })).toEqual({ state: 'done', - lead: { state: 'done' } + mainAgent: { state: 'done' } }) }) // The verdict is a fact about a finished turn; the fold never reads it, so a cancelled turn with // a watch loop still reads monitoring here (the hook lane's known divergence, until PR C). - it('carries the turn verdict on the lead only while the lead is done', () => { + it('carries the turn verdict on the main agent only while the main agent is done', () => { expect( structuredAgentSessionAgentStatus({ status: 'idle', turnOutcome: 'cancellation' }) - ).toEqual({ state: 'done', lead: { state: 'done', outcome: 'cancellation' } }) + ).toEqual({ state: 'done', mainAgent: { state: 'done', outcome: 'cancellation' } }) expect( structuredAgentSessionAgentStatus({ status: 'idle', @@ -94,10 +94,10 @@ describe('structuredAgentSessionAgentStatus', () => { ).toEqual({ state: 'working', workingMode: 'monitoring', - lead: { state: 'done', outcome: 'cancellation' } + mainAgent: { state: 'done', outcome: 'cancellation' } }) expect( structuredAgentSessionAgentStatus({ status: 'working', turnOutcome: 'failure' }) - ).toEqual({ state: 'working', lead: { state: 'working' } }) + ).toEqual({ state: 'working', mainAgent: { state: 'working' } }) }) }) diff --git a/src/shared/structured-agent-session-agent-status.ts b/src/shared/structured-agent-session-agent-status.ts index e45d2c7bef97..78a455c4cfb4 100644 --- a/src/shared/structured-agent-session-agent-status.ts +++ b/src/shared/structured-agent-session-agent-status.ts @@ -1,15 +1,15 @@ import type { AgentSessionStatusSummary } from './agent-session-wire' import { foldAgentLeadStatus } from './agent-lead-status-fold' import { agentChildWorkLiveness } from './agent-status-child-work-liveness' -import type { AgentLeadStatus, AgentStatusState, AgentWorkingMode } from './agent-status-types' +import type { AgentMainAgentStatus, AgentStatusState, AgentWorkingMode } from './agent-status-types' import type { StructuredAgentSessionProjectedStatus } from './structured-agent-session-projection' export type StructuredAgentSessionAgentStatus = { state: AgentStatusState workingMode?: AgentWorkingMode - /** The lead's own state and last-turn verdict, before child work is folded in. The caller - * stamps the clock: this projection has no view of when the lead's state first appeared. */ - lead: Omit + /** The main agent's own state and last-turn verdict, before child work is folded in. The caller + * stamps the clock: this projection has no view of when the main agent's state first appeared. */ + mainAgent: Omit } /** The lead state one projected session status stands for, before child work is folded in. */ @@ -33,14 +33,14 @@ export function structuredAgentSessionAgentStatus( // Known divergence from the hook lane, kept on purpose until the cancel policy lands: that // lane hides a still-running shell after an interrupted turn (`updateClaudeRunningNonAgentTask` // calls it a live-shell judgement), so a cancelled turn with a watch loop reads `done` there - // and `monitoring` here. This lane never feeds the verdict into the fold — see `lead.outcome`. + // and `monitoring` here. This lane never feeds the verdict into the fold — see `mainAgent.outcome`. interrupted: false, childWorkLiveness: agentChildWorkLiveness(summary.backgroundTasks) }) return { state: resolution.stateName, ...(resolution.workingMode ? { workingMode: resolution.workingMode } : {}), - lead: { + mainAgent: { state: leadState, ...(leadState === 'done' && summary.turnOutcome ? { outcome: summary.turnOutcome } : {}) } From 2b24918248bd6dc59f7ba1e8d5a9a1b11745f86f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:51:22 -0700 Subject: [PATCH 05/19] docs(agent-status): the inferred cancel comes from Ctrl+C, not Esc --- src/shared/agent-hook-listener/providers/claude-roster-state.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shared/agent-hook-listener/providers/claude-roster-state.ts b/src/shared/agent-hook-listener/providers/claude-roster-state.ts index fe5cb29860f1..ce626b311c1b 100644 --- a/src/shared/agent-hook-listener/providers/claude-roster-state.ts +++ b/src/shared/agent-hook-listener/providers/claude-roster-state.ts @@ -179,7 +179,7 @@ export function resolveClaudePaneStatus( }) }) } -/** Sync the Claude lead-turn record when the SERVER infers an interrupt outside the hook stream (Ctrl+C or Esc with no Stop, which is what current Claude does on every cancel); else a later child lifecycle event resurrects the cancelled pane. This is the primary source of `mainAgent.outcome: 'cancellation'` in the CLI lane. */ +/** Sync the Claude lead-turn record when the SERVER infers an interrupt outside the hook stream (Ctrl+C with no Stop; current Claude sends no hook on a cancel, and a bare Esc is never inferred for Claude); else a later child lifecycle event resurrects the cancelled pane. This is the primary source of `mainAgent.outcome: 'cancellation'` in the CLI lane. */ export function markClaudeLeadTurnInterrupted(state: HookListenerState, paneKey: string): void { setClaudeMainAgentTurnState(state, paneKey, { state: 'done', outcome: 'cancellation' }) state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) From a0a59fb05629b8cdc42aef0affbbf77c51b95e3b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:29:52 -0700 Subject: [PATCH 06/19] fix(agent-status): an inferred interrupt keeps an already settled main agent, and the row verdict docs name its inferred source --- docs/reference/agent-status-store.md | 11 +++-- ...ver-interrupt-inference-validation.test.ts | 43 +++++++++++++++++++ .../server/server-status-inference.ts | 8 +++- .../main-agent-turn-state.ts | 3 +- .../providers/claude-events.ts | 2 +- .../providers/grok-events.ts | 2 +- src/shared/agent-session-journal-types.ts | 2 +- src/shared/agent-turn-outcome.ts | 14 +++--- src/shared/main-agent-status.ts | 5 ++- 9 files changed, 72 insertions(+), 18 deletions(-) diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index 73484af465ef..bb385487f75f 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -202,10 +202,13 @@ so a settled main agent whose subagent still runs reads `working`. `mainAgent` a "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) is now derived from `mainAgent` plus -the row's child evidence instead of stored. `outcome` is the provider's verdict -on the main agent's most recent finished turn, present only while `mainAgent.state` is -`done`; 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. +the row's child evidence instead of stored. `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 diff --git a/src/main/agent-hooks/server-interrupt-inference-validation.test.ts b/src/main/agent-hooks/server-interrupt-inference-validation.test.ts index 424e4733f289..5818aa607bd7 100644 --- a/src/main/agent-hooks/server-interrupt-inference-validation.test.ts +++ b/src/main/agent-hooks/server-interrupt-inference-validation.test.ts @@ -399,4 +399,47 @@ describe('the main agent fact on an inferred interrupt', () => { 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() + } + }) }) diff --git a/src/main/agent-hooks/server/server-status-inference.ts b/src/main/agent-hooks/server/server-status-inference.ts index 318fef110ed0..02b61595832d 100644 --- a/src/main/agent-hooks/server/server-status-inference.ts +++ b/src/main/agent-hooks/server/server-status-inference.ts @@ -109,8 +109,12 @@ export abstract class AgentHookServerStatusInference extends AgentHookServerRowO interrupted: true, // Why: idle children are display state; dropping them on an inferred interrupt blanks rows a later hook would restore. ...(payload.subagents ? { subagents: payload.subagents } : {}), - // Why: the synthesized row is the main agent's own end; its verdict is the interrupt that produced it. - mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: Date.now() } + // Why: the interrupt ends a running main agent's turn; one a watch loop held open had already + // settled, so it keeps its own clock and verdict. + mainAgent: + payload.mainAgent?.state === 'done' + ? payload.mainAgent + : { state: 'done', outcome: 'cancellation', stateStartedAt: Date.now() } } }) if (!inferred) { diff --git a/src/shared/agent-hook-listener/main-agent-turn-state.ts b/src/shared/agent-hook-listener/main-agent-turn-state.ts index 04556ce959f4..2a9e261d31e1 100644 --- a/src/shared/agent-hook-listener/main-agent-turn-state.ts +++ b/src/shared/agent-hook-listener/main-agent-turn-state.ts @@ -4,7 +4,8 @@ import type { AgentJournalTurnOutcome } from '../agent-turn-outcome' /** The Claude main agent's own turn record, published on every row as `mainAgent`. */ export type ClaudeLeadTurnState = { state: AgentStatusState - /** The provider's verdict on the turn this record closed; only meaningful while `state` is done. + /** The recorded verdict on the turn this record closed (the provider's, or a `cancellation` + * Orca inferred from the interrupt keystroke); only meaningful while `state` is done. * `cancellation` is what the fold reads as an interrupt. */ outcome?: AgentJournalTurnOutcome /** When `state` first appeared; the main agent's own clock, distinct from the gated row's. */ diff --git a/src/shared/agent-hook-listener/providers/claude-events.ts b/src/shared/agent-hook-listener/providers/claude-events.ts index fb2c024c5ece..f3de6cd66621 100644 --- a/src/shared/agent-hook-listener/providers/claude-events.ts +++ b/src/shared/agent-hook-listener/providers/claude-events.ts @@ -80,7 +80,7 @@ export function normalizeClaudeEvent( claudeMainAgentTurnInterrupted(previousLead)) ? true : undefined - // Why: a verdict, never a guess — a plain Stop stays absent, so a cancel can never read as a + // Why: absent means unknown — a plain Stop never becomes `success`, so a cancel can never read as a // success. Current Claude sends NO hook on a cancel and no `is_interrupt` on Stop, so the // cancellation normally arrives through Orca's own inferred interrupt // (`markClaudeLeadTurnInterrupted`) and is carried forward here; `is_interrupt` on a turn diff --git a/src/shared/agent-hook-listener/providers/grok-events.ts b/src/shared/agent-hook-listener/providers/grok-events.ts index bf46033cf7c7..45317c9473d4 100644 --- a/src/shared/agent-hook-listener/providers/grok-events.ts +++ b/src/shared/agent-hook-listener/providers/grok-events.ts @@ -200,7 +200,7 @@ export function normalizeGrokEvent( if (!leadState) { return null } - // Why: the provider's verdict, never inferred — a plain `stop` stays absent. + // Why: only Grok's own cancel and failure events carry a verdict — a plain `stop` stays absent. const outcome = isGrokEvent(eventName, 'stop_cancelled') ? ('cancellation' as const) : isGrokEvent(eventName, 'stop_failure') diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index 88c024a19624..d1b71c8fc2ec 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -194,7 +194,7 @@ export const AGENT_JOURNAL_TURN_LIFECYCLE_STATES = [ ] as const export type AgentJournalTurnLifecycleState = (typeof AGENT_JOURNAL_TURN_LIFECYCLE_STATES)[number] -// The provider verdict vocabulary lives in agent-turn-outcome.ts so the agent-status +// The turn verdict vocabulary lives in agent-turn-outcome.ts so the agent-status // row can share it without importing the journal; re-exported to keep one import site. export { AGENT_JOURNAL_TURN_OUTCOMES, type AgentJournalTurnOutcome } from './agent-turn-outcome' diff --git a/src/shared/agent-turn-outcome.ts b/src/shared/agent-turn-outcome.ts index 4f532b212b49..32fa936a457e 100644 --- a/src/shared/agent-turn-outcome.ts +++ b/src/shared/agent-turn-outcome.ts @@ -1,9 +1,11 @@ -/** What the PROVIDER said became of a turn, kept separate from any lifecycle - * state so those stay a report on what the HOST observed. `cancellation` is a - * stop somebody asked for, `failure` is the provider's own error, and the two - * are never interchangeable: only `failure` is a fault. Shared by the journal's - * turn record and the agent-status row's `mainAgent` fact so one vocabulary serves - * both; absent always means UNKNOWN, never success. */ +/** The verdict on what became of a turn, kept separate from any lifecycle state + * so those stay a report on what the HOST observed. `cancellation` is a stop + * somebody asked for, `failure` is the provider's own error, and the two are + * never interchangeable: only `failure` is a fault. Shared by the journal's turn + * record, which holds only the provider's verdict and never infers one, and the + * agent-status row's `mainAgent.outcome`, which also records a `cancellation` + * Orca inferred from the user's own interrupt keystroke. Absent always means + * UNKNOWN, never success. */ export const AGENT_JOURNAL_TURN_OUTCOMES = ['success', 'failure', 'cancellation'] as const export type AgentJournalTurnOutcome = (typeof AGENT_JOURNAL_TURN_OUTCOMES)[number] diff --git a/src/shared/main-agent-status.ts b/src/shared/main-agent-status.ts index 962d79920017..5ca2051e8d4b 100644 --- a/src/shared/main-agent-status.ts +++ b/src/shared/main-agent-status.ts @@ -7,8 +7,9 @@ import type { AgentJournalTurnOutcome } from './agent-turn-outcome' * Persisted on disk and carried on every wire, so its shape is permanent. */ export type AgentMainAgentStatus = { state: AgentStatusState - /** The provider's verdict on the main agent's most recent finished turn. Present only while - * `state` is `done`; a new turn clears it. ABSENT MEANS UNKNOWN — a plain Stop never + /** The recorded verdict on the main agent's most recent finished turn: reported by the + * provider, or `cancellation` inferred from the user's own interrupt keystroke. Present only + * while `state` is `done`; a new turn clears it. ABSENT MEANS UNKNOWN — a plain Stop never * infers `success`, because an older provider that omits its interrupt flag would turn * a cancel into a false success. */ outcome?: AgentJournalTurnOutcome From 8bbe6d4e09ff23a23537836a134df108ec162abe Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:34:38 -0700 Subject: [PATCH 07/19] fix(agent-status): a child-induced wait publishes the main agent state it displaced --- ...nt-hook-listener-claude-turn-state.test.ts | 30 +++++++++++++++++-- .../providers/claude-roster-state.ts | 21 ++++++++----- 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/src/shared/agent-hook-listener-claude-turn-state.test.ts b/src/shared/agent-hook-listener-claude-turn-state.test.ts index 584d8dbefd6c..ad81b70a7b1a 100644 --- a/src/shared/agent-hook-listener-claude-turn-state.test.ts +++ b/src/shared/agent-hook-listener-claude-turn-state.test.ts @@ -431,8 +431,11 @@ describe('the main agent verdict across a child-induced wait', () => { tool_name: 'Bash', tool_input: { command: 'rm -rf build' } }) - expect(wait).toMatchObject({ state: 'waiting', mainAgent: { state: 'waiting' } }) - expect(wait?.mainAgent).not.toHaveProperty('outcome') + // The child's wait is child work: the main agent is still the cancelled turn, on its own clock. + expect(wait).toMatchObject({ + state: 'waiting', + mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: settledAt } + }) // The child's pause displaced the main agent; clearing it must give the verdict and clock back. const drained = claude({ hook_event_name: 'SubagentStop', agent_id: 'a1' }) @@ -443,6 +446,29 @@ describe('the main agent verdict across a child-induced wait', () => { }) }) + it('publishes the displaced main agent, not the child, while a child owns the wait', () => { + claude({ hook_event_name: 'UserPromptSubmit', prompt: 'go' }) + const running = claude({ hook_event_name: 'SubagentStart', agent_id: 'a1' }) + const childWait = claude({ + hook_event_name: 'PermissionRequest', + agent_id: 'a1', + tool_name: 'Bash', + tool_input: { command: 'rm -rf build' } + }) + expect(childWait).toMatchObject({ + state: 'waiting', + mainAgent: { state: 'working', stateStartedAt: running?.mainAgent?.stateStartedAt } + }) + + // A wait the main agent raised itself is its own state. + const ownWait = claude({ + hook_event_name: 'PermissionRequest', + tool_name: 'Bash', + tool_input: { command: 'rm -rf dist' } + }) + expect(ownWait).toMatchObject({ state: 'waiting', mainAgent: { state: 'waiting' } }) + }) + it('clears the verdict when a new turn starts', () => { claude({ hook_event_name: 'UserPromptSubmit', prompt: 'go' }) claude({ hook_event_name: 'StopFailure', error: 'invalid_request' }) diff --git a/src/shared/agent-hook-listener/providers/claude-roster-state.ts b/src/shared/agent-hook-listener/providers/claude-roster-state.ts index ce626b311c1b..d47b2a7fc427 100644 --- a/src/shared/agent-hook-listener/providers/claude-roster-state.ts +++ b/src/shared/agent-hook-listener/providers/claude-roster-state.ts @@ -152,12 +152,19 @@ export function setClaudeMainAgentTurnState( } /** The `mainAgent` fact a row publishes from its record: nothing invented, so a pane whose main - * agent was never observed publishes none and readers fall back to the combined `state`. */ -export function claudeMainAgentStatusForPayload(record: ClaudeLeadTurnState): AgentMainAgentStatus { + * agent was never observed publishes none and readers fall back to the combined `state`. + * A child-induced wait occupies the record but is child work, so the main agent is the state it displaced. */ +export function claudeMainAgentStatusForPayload( + record: ClaudeLeadTurnState +): AgentMainAgentStatus | undefined { + const own = record.waitingAgentId !== undefined ? record.stateBeforeWait : record + if (!own) { + return undefined + } return { - state: record.state, - ...(record.state === 'done' && record.outcome ? { outcome: record.outcome } : {}), - stateStartedAt: record.stateStartedAt + state: own.state, + ...(own.state === 'done' && own.outcome ? { outcome: own.outcome } : {}), + stateStartedAt: own.stateStartedAt } } @@ -297,7 +304,7 @@ export function clearClaudeAnsweredQuestionWait( ): Pick & { interrupted?: true workingMode?: AgentWorkingMode - mainAgent: AgentMainAgentStatus + mainAgent?: AgentMainAgentStatus } { const lead = state.claudeLeadStateByPaneKey.get(paneKey) const stash = @@ -324,6 +331,6 @@ export function clearClaudeAnsweredQuestionWait( ...(restored.turnCompletedAt !== undefined ? { turnCompletedAt: restored.turnCompletedAt } : {}), - mainAgent: publishedMainAgent + ...(publishedMainAgent ? { mainAgent: publishedMainAgent } : {}) } } From 699837be12947e6135ce4a5c761a8ce345e24a57 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:31:00 -0700 Subject: [PATCH 08/19] fix(agent-status): decide child-held Claude rows from the saved main agent fact Restart seeds the Claude main agent from the row's saved mainAgent whenever it settled and no shell held the row, instead of re-deriving a child-only shape. OSC cannot settle or repaint a row child agents hold open, including a row waiting on a child's permission prompt. A sticky child permission prompt still records the main agent's own progress, and OSC repaints and inferred answers keep the shell fact beside the main agent they preserve. --- ...-last-status-child-held-main-agent.test.ts | 275 ++++++++++++++++++ .../server-last-status-lead-boundary.test.ts | 18 +- ...server-last-status-main-agent-fact.test.ts | 48 +++ .../server/server-claude-status-rules.ts | 34 +-- .../agent-hooks/server/server-hydration.ts | 5 +- .../server/server-ingest-terminal.ts | 16 +- .../server/server-status-inference.ts | 4 + .../server/server-status-update.ts | 14 +- .../providers/claude-roster-state.ts | 12 +- 9 files changed, 382 insertions(+), 44 deletions(-) create mode 100644 src/main/agent-hooks/server-last-status-child-held-main-agent.test.ts diff --git a/src/main/agent-hooks/server-last-status-child-held-main-agent.test.ts b/src/main/agent-hooks/server-last-status-child-held-main-agent.test.ts new file mode 100644 index 000000000000..0505e2c8dc9b --- /dev/null +++ b/src/main/agent-hooks/server-last-status-child-held-main-agent.test.ts @@ -0,0 +1,275 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { AgentHookServer, _internals } from './server' +import { buildBody, postHookEvent, PANE, RUNNING_SHELL } from './server.test-fixtures' + +const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ + getCohortAtEmitMock: vi.fn(), + trackMock: vi.fn() +})) + +vi.mock('../telemetry/client', () => ({ track: trackMock })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: getCohortAtEmitMock })) + +beforeEach(() => { + _internals.resetCachesForTests() + trackMock.mockReset() + getCohortAtEmitMock.mockReset() + getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +const CHILD_PERMISSION = { + hook_event_name: 'PermissionRequest', + agent_id: 'achild-a', + tool_name: 'Bash', + tool_input: { command: 'rm scratch' } +} + +// A Claude row whose main agent settled while child agents still work (or wait on a prompt) is +// decided by the row's `mainAgent` fact: OSC cannot settle it, and restart restores the settled +// main agent so the children's own lifecycle hooks can. +describe('Claude rows held open by child agents', () => { + let userDataPath: string + + beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'orca-child-held-main-agent-')) + }) + + afterEach(() => { + rmSync(userDataPath, { recursive: true, force: true }) + }) + + async function startServer(): Promise { + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + return server + } + + async function restart(server: AgentHookServer): Promise { + server.flushStatusPersistSync() + server.stop() + return startServer() + } + + async function post(server: AgentHookServer, payload: Record): Promise { + await postHookEvent(server, buildBody(payload)) + } + + async function settleMainAgentBeside(server: AgentHookServer, childIds: string[]): Promise { + await post(server, { hook_event_name: 'UserPromptSubmit', prompt: 'delegate' }) + for (const id of childIds) { + await post(server, { hook_event_name: 'SubagentStart', agent_id: id }) + } + await post(server, { hook_event_name: 'Stop' }) + } + + function osc(server: AgentHookServer, state: 'working' | 'done'): void { + server.ingestTerminalStatus({ + paneKey: PANE, + connectionId: null, + payload: { state, prompt: '', agentType: 'claude' } + }) + } + + function row(server: AgentHookServer) { + return server.getStatusSnapshot()[0] + } + + it('keeps a child permission prompt visible when OSC reports done', async () => { + const server = await startServer() + try { + await settleMainAgentBeside(server, ['achild-a']) + await post(server, CHILD_PERMISSION) + osc(server, 'done') + + expect(row(server)).toMatchObject({ + state: 'waiting', + toolName: 'Bash', + mainAgent: { state: 'done' } + }) + } finally { + server.stop() + } + }) + + it('keeps a child-held working row when OSC reports done', async () => { + const server = await startServer() + try { + await settleMainAgentBeside(server, ['achild-a']) + osc(server, 'done') + + expect(row(server)).toMatchObject({ + state: 'working', + mainAgent: { state: 'done' }, + subagents: [expect.objectContaining({ id: 'achild-a', state: 'working' })] + }) + } finally { + server.stop() + } + }) + + it('settles a restored child permission wait once the child is approved and stops', async () => { + let server = await startServer() + await settleMainAgentBeside(server, ['achild-a']) + await post(server, CHILD_PERMISSION) + server = await restart(server) + try { + await post(server, { + hook_event_name: 'PreToolUse', + agent_id: 'achild-a', + tool_name: 'Bash', + tool_input: { command: 'rm scratch' }, + tool_use_id: 'toolu-approved' + }) + expect(row(server)?.state).toBe('working') + await post(server, { hook_event_name: 'SubagentStop', agent_id: 'achild-a' }) + + expect(row(server)).toMatchObject({ state: 'done', mainAgent: { state: 'done' } }) + expect(row(server)?.restoredUnconfirmed).toBeUndefined() + } finally { + server.stop() + } + }) + + it('settles a restored drained row after a later child starts and stops', async () => { + let server = await startServer() + await settleMainAgentBeside(server, ['achild-a']) + await post(server, { hook_event_name: 'SubagentStop', agent_id: 'achild-a' }) + server = await restart(server) + try { + await post(server, { hook_event_name: 'SubagentStart', agent_id: 'achild-b' }) + expect(row(server)?.state).toBe('working') + await post(server, { hook_event_name: 'SubagentStop', agent_id: 'achild-b' }) + + expect(row(server)).toMatchObject({ state: 'done', mainAgent: { state: 'done' } }) + } finally { + server.stop() + } + }) + + it('settles a restored plain done row after a later child starts and stops', async () => { + let server = await startServer() + await settleMainAgentBeside(server, []) + server = await restart(server) + try { + await post(server, { hook_event_name: 'SubagentStart', agent_id: 'achild-b' }) + expect(row(server)?.state).toBe('working') + await post(server, { hook_event_name: 'SubagentStop', agent_id: 'achild-b' }) + + expect(row(server)).toMatchObject({ state: 'done', mainAgent: { state: 'done' } }) + } finally { + server.stop() + } + }) + + it('does not settle a restored row whose main agent resumed behind a child permission', async () => { + let server = await startServer() + await settleMainAgentBeside(server, ['achild-a', 'achild-b']) + await post(server, CHILD_PERMISSION) + await post(server, { hook_event_name: 'PreToolUse', tool_name: 'Read' }) + expect(row(server)).toMatchObject({ + state: 'waiting', + toolName: 'Bash', + mainAgent: { state: 'working' } + }) + server = await restart(server) + try { + await post(server, { hook_event_name: 'SubagentStop', agent_id: 'achild-a' }) + await post(server, { hook_event_name: 'SubagentStop', agent_id: 'achild-b' }) + + expect(row(server)).toMatchObject({ state: 'working', restoredUnconfirmed: true }) + } finally { + server.stop() + } + }) + + it('does not settle a restored row a running shell held beside its child', async () => { + let server = await startServer() + await post(server, { hook_event_name: 'UserPromptSubmit', prompt: 'delegate' }) + await post(server, { hook_event_name: 'SubagentStart', agent_id: 'achild-a' }) + await post(server, { + hook_event_name: 'Stop', + background_tasks: [{ id: 'achild-a', type: 'subagent', status: 'running' }, RUNNING_SHELL] + }) + server = await restart(server) + try { + await post(server, { hook_event_name: 'SubagentStop', agent_id: 'achild-a' }) + + expect(row(server)).toMatchObject({ state: 'working', restoredUnconfirmed: true }) + } finally { + server.stop() + } + }) + + // The shell fact rides beside `mainAgent`; a row rewritten without it would read as shell-free. + async function expectShellHeldRowStaysOpenAfterRestart( + server: AgentHookServer, + childEvents: Record[] + ): Promise { + const restarted = await restart(server) + try { + for (const event of childEvents) { + await post(restarted, event) + } + + expect(row(restarted)?.state).toBe('working') + } finally { + restarted.stop() + } + } + + it('keeps the shell fact when OSC repaints a shell-held row', async () => { + const server = await startServer() + await post(server, { hook_event_name: 'UserPromptSubmit', prompt: 'build in background' }) + await post(server, { hook_event_name: 'Stop', background_tasks: [RUNNING_SHELL] }) + server.ingestTerminalStatus({ + paneKey: PANE, + connectionId: null, + payload: { state: 'working', prompt: 'title text', agentType: 'claude' } + }) + expect(row(server)).toMatchObject({ state: 'working', prompt: 'title text' }) + + await expectShellHeldRowStaysOpenAfterRestart(server, [ + { hook_event_name: 'SubagentStart', agent_id: 'achild-b' }, + { hook_event_name: 'SubagentStop', agent_id: 'achild-b' } + ]) + }) + + it('keeps the shell fact when an answered child question is inferred', async () => { + const server = await startServer() + await post(server, { hook_event_name: 'UserPromptSubmit', prompt: 'delegate' }) + await post(server, { hook_event_name: 'SubagentStart', agent_id: 'achild-a' }) + await post(server, { + hook_event_name: 'Stop', + background_tasks: [{ id: 'achild-a', type: 'subagent', status: 'running' }, RUNNING_SHELL] + }) + await post(server, { + hook_event_name: 'PreToolUse', + agent_id: 'achild-a', + tool_name: 'AskUserQuestion', + tool_use_id: 'toolu-question' + }) + const waiting = row(server) + expect(waiting).toMatchObject({ state: 'waiting', toolName: 'AskUserQuestion' }) + expect( + server.inferQuestionAnswered({ + paneKey: PANE, + baselineUpdatedAt: waiting?.receivedAt ?? 0, + baselineStateStartedAt: waiting?.stateStartedAt ?? 0, + baselinePrompt: waiting?.prompt ?? '', + baselineAgentType: 'claude' + }) + ).toBe(true) + expect(row(server)).toMatchObject({ state: 'working', mainAgent: { state: 'done' } }) + + await expectShellHeldRowStaysOpenAfterRestart(server, [ + { hook_event_name: 'SubagentStop', agent_id: 'achild-a' } + ]) + }) +}) diff --git a/src/main/agent-hooks/server-last-status-lead-boundary.test.ts b/src/main/agent-hooks/server-last-status-lead-boundary.test.ts index b146fb6d8548..7b7307d79159 100644 --- a/src/main/agent-hooks/server-last-status-lead-boundary.test.ts +++ b/src/main/agent-hooks/server-last-status-lead-boundary.test.ts @@ -3,12 +3,10 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { AgentHookServer, _internals } from './server' -import { isClaudeMainAgentBoundaryHeldByChildrenOnly } from './server/server-claude-status-rules' import { buildBody, postHookEvent, PANE, RUNNING_SHELL } from './server.test-fixtures' -function heldByChildrenOnly(server: AgentHookServer): boolean | undefined { - const row = server._getStateForTests().lastStatusByPaneKey.get(PANE) - return row ? isClaudeMainAgentBoundaryHeldByChildrenOnly(row) : undefined +function mainAgentState(server: AgentHookServer): string | undefined { + return server.getStatusSnapshot()[0]?.mainAgent?.state } const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ @@ -192,12 +190,14 @@ describe('Persisted Claude lead boundaries', () => { buildBody({ hook_event_name: 'SubagentStart', agent_id: 'achildb' }) ) await postHookEvent(firstServer, buildBody({ hook_event_name: 'Stop' })) - expect(heldByChildrenOnly(firstServer)).toBe(true) + expect(firstServer.getStatusSnapshot()[0]?.state).toBe('working') + expect(mainAgentState(firstServer)).toBe('done') await postHookEvent( firstServer, buildBody({ hook_event_name: 'SubagentStop', agent_id: 'achilda' }) ) - expect(heldByChildrenOnly(firstServer)).toBe(true) + expect(firstServer.getStatusSnapshot()[0]?.state).toBe('working') + expect(mainAgentState(firstServer)).toBe('done') firstServer.flushStatusPersistSync() firstServer.stop() @@ -247,7 +247,7 @@ describe('Persisted Claude lead boundaries', () => { buildBody({ hook_event_name: 'PreToolUse', tool_name: 'Read' }) ) expect(firstServer.getStatusSnapshot()[0]).toMatchObject({ state: 'waiting', toolName: 'Bash' }) - expect(heldByChildrenOnly(firstServer)).toBe(false) + expect(mainAgentState(firstServer)).toBe('working') await postHookEvent( firstServer, buildBody({ hook_event_name: 'SubagentStop', agent_id: 'achild-a' }) @@ -337,12 +337,12 @@ describe('Persisted Claude lead boundaries', () => { buildBody({ hook_event_name: 'PreToolUse', tool_name: 'Read' }) ) expect(firstServer.getStatusSnapshot()[0]).toMatchObject({ state: 'waiting', toolName: 'Bash' }) - expect(heldByChildrenOnly(firstServer)).toBe(false) + expect(mainAgentState(firstServer)).toBe('working') await postHookEvent( firstServer, buildBody({ hook_event_name: 'SubagentStop', agent_id: 'achilda' }) ) - expect(heldByChildrenOnly(firstServer)).toBe(false) + expect(mainAgentState(firstServer)).toBe('working') firstServer.flushStatusPersistSync() firstServer.stop() diff --git a/src/main/agent-hooks/server-last-status-main-agent-fact.test.ts b/src/main/agent-hooks/server-last-status-main-agent-fact.test.ts index 775dc6d2c5d1..df9a977b25ec 100644 --- a/src/main/agent-hooks/server-last-status-main-agent-fact.test.ts +++ b/src/main/agent-hooks/server-last-status-main-agent-fact.test.ts @@ -136,6 +136,54 @@ describe('The main agent fact across a restart', () => { } }) + it.each([ + ['a child permission wait', 'waiting', ['PreToolUse', 'SubagentStop']], + ['a drained row', 'done', ['SubagentStart', 'SubagentStop']] + ] as const)( + 'settles a legacy flagged row holding %s once its child finishes', + async (_shape, state, childEvents) => { + const receivedAt = recentTs() + writeEntry({ + receivedAt, + stateStartedAt: receivedAt - 5_000, + claudeLeadBoundaryChildOnly: true, + payload: { + state, + prompt: 'legacy row', + agentType: 'claude', + ...(state === 'waiting' + ? { + toolName: 'Bash', + subagents: [{ id: 'achild', state: 'working', startedAt: receivedAt - 4_000 }] + } + : {}) + } + }) + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + for (const hookEventName of childEvents) { + await postHookEvent( + server, + buildBody({ + hook_event_name: hookEventName, + agent_id: 'achild', + ...(hookEventName === 'PreToolUse' + ? { tool_name: 'Bash', tool_use_id: 'toolu-approved' } + : {}) + }) + ) + } + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'done', + mainAgent: { state: 'done' } + }) + } finally { + server.stop() + } + } + ) + it('prefers a persisted main agent over the legacy flag when a row carries both', async () => { const receivedAt = recentTs() const mainAgent = { state: 'done', outcome: 'cancellation', stateStartedAt: receivedAt - 2_000 } diff --git a/src/main/agent-hooks/server/server-claude-status-rules.ts b/src/main/agent-hooks/server/server-claude-status-rules.ts index df30ce88c492..65c8c78ac18c 100644 --- a/src/main/agent-hooks/server/server-claude-status-rules.ts +++ b/src/main/agent-hooks/server/server-claude-status-rules.ts @@ -1,25 +1,25 @@ -import { isAgentStatusHeldOpenByChildWork } from '../../../shared/agent-lead-status-fold' +import { mainAgentStatusEqual } from '../../../shared/main-agent-status' import { claudeTeammateIdMatchesName } from '../../../shared/claude-subagent-roster' import { isAskUserQuestionTool } from '../../../shared/agent-question-answered-intent' import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' import type { EnrichedAgentHookEventPayload } from './server-types' -/** The Claude main agent has settled and child agents alone hold the row `working`. Derived from the - * row's `mainAgent` fact and its child evidence, never stored: the persisted flag this replaced was a - * second copy of `mainAgent.state === 'done'` that could disagree with it. A running shell beside the - * agents disqualifies the row; that fact rides `claudeRunningNonAgentTask` (persisted since the - * flag stopped being written), and a row old enough to lack it reads as shell-free — which is - * exactly what its legacy child-only flag asserted at write time. */ -export function isClaudeMainAgentBoundaryHeldByChildrenOnly( - row: Pick -): boolean { - return ( - row.payload.agentType === 'claude' && - isAgentStatusHeldOpenByChildWork(row.payload) && - row.payload.state === 'working' && - row.payload.subagents?.some((subagent) => subagent.state === 'working') === true && - row.claudeRunningNonAgentTask !== true - ) +/** A child's permission prompt stays visible over the main agent's own progress, but the row must + * still carry that progress: restart seeds the main agent from it, and a stale `done` would let the + * children's drain settle a row whose main agent is working. Returns `previous` when nothing changed. */ +export function withHeldChildWaitMainAgent( + previous: EnrichedAgentHookEventPayload, + next: AgentHookEventPayload +): EnrichedAgentHookEventPayload { + const mainAgent = next.payload.mainAgent + if ( + !previous.toolAgentId || + !mainAgent || + mainAgentStatusEqual(previous.payload.mainAgent, mainAgent) + ) { + return previous + } + return { ...previous, payload: { ...previous.payload, mainAgent } } } export function shouldKeepClaudePermissionVisible( diff --git a/src/main/agent-hooks/server/server-hydration.ts b/src/main/agent-hooks/server/server-hydration.ts index 81a09dfc3ad1..140a48cfeb49 100644 --- a/src/main/agent-hooks/server/server-hydration.ts +++ b/src/main/agent-hooks/server/server-hydration.ts @@ -10,7 +10,6 @@ import { } from '../../../shared/agent-hook-listener/providers/claude-roster-state' import { seedCodexStateFromSnapshot } from '../../../shared/agent-hook-listener/providers/codex-state' import { AGENT_STATUS_PERSISTED_HYDRATION_MODE } from '../../../shared/agent-status-legacy-adapter' -import { isClaudeMainAgentBoundaryHeldByChildrenOnly } from './server-claude-status-rules' import { HYDRATE_MAX_AGE_MS, LAST_STATUS_FILE_VERSION } from './server-constants' import type { LastStatusFile } from './server-types' import { @@ -124,9 +123,7 @@ export abstract class AgentHookServerHydration extends AgentHookServerReaping { if (entry.payload.agentType === 'codex') { seedCodexStateFromSnapshot(this.state, resolvedPaneKey, entry.payload) } else if (entry.payload.agentType === 'claude') { - seedClaudeLeadTurnFromPersistedStatus(this.state, resolvedPaneKey, entry, { - childOnlyBoundary: isClaudeMainAgentBoundaryHeldByChildrenOnly(entry) - }) + seedClaudeLeadTurnFromPersistedStatus(this.state, resolvedPaneKey, entry) if (entry.payload.subagents) { seedClaudeSubagentRosterFromSnapshots( this.state, diff --git a/src/main/agent-hooks/server/server-ingest-terminal.ts b/src/main/agent-hooks/server/server-ingest-terminal.ts index 64c11d48b442..d84be62ee661 100644 --- a/src/main/agent-hooks/server/server-ingest-terminal.ts +++ b/src/main/agent-hooks/server/server-ingest-terminal.ts @@ -4,7 +4,7 @@ import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../shared/stable- import { terminalStatusPayloadMatchesHook } from '../../../shared/agent-terminal-status-equivalence' import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types' import type { EnrichedAgentHookEventPayload } from './server-types' -import { isClaudeMainAgentBoundaryHeldByChildrenOnly } from './server-claude-status-rules' +import { isAgentStatusHeldOpenByChildWork } from '../../../shared/agent-lead-status-fold' import { AgentHookServerIngestNormalization } from './server-ingest-normalization' export abstract class AgentHookServerIngestTerminal extends AgentHookServerIngestNormalization { @@ -83,11 +83,13 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges | EnrichedAgentHookEventPayload | undefined if ( - previous && - isClaudeMainAgentBoundaryHeldByChildrenOnly(previous) && - event.payload.agentType === 'claude' + previous?.payload.agentType === 'claude' && + event.payload.agentType === 'claude' && + isAgentStatusHeldOpenByChildWork(previous.payload) && + previous.payload.subagents?.some((subagent) => subagent.state === 'working') === true ) { - // Why: OSC has no child identity or main agent boundary, so it cannot replace a child-only proof before the lifecycle hook arrives. + // Why: OSC carries no child identity, so it cannot settle or repaint a row child agents hold open + // (working, or waiting on a child's prompt); their lifecycle hooks will. if (mutationBefore !== undefined) { this.commitStatusRowMutation(mutationBefore, previous) this.emitEnrichedStatus(previous) @@ -148,6 +150,10 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges connectionId, ...(preservedProviderSession ? { providerSession: preservedProviderSession } : {}), ...(terminalHandle ? { terminalHandle } : {}), + // Why: restart seeds a settled main agent unless a shell held the row; keep that fact beside it. + ...(preservedMainAgent && previous?.claudeRunningNonAgentTask !== undefined + ? { claudeRunningNonAgentTask: previous.claudeRunningNonAgentTask } + : {}), payload: preservedMainAgent ? { ...event.payload, mainAgent: preservedMainAgent } : event.payload diff --git a/src/main/agent-hooks/server/server-status-inference.ts b/src/main/agent-hooks/server/server-status-inference.ts index 02b61595832d..2263e1571031 100644 --- a/src/main/agent-hooks/server/server-status-inference.ts +++ b/src/main/agent-hooks/server/server-status-inference.ts @@ -169,6 +169,10 @@ export abstract class AgentHookServerStatusInference extends AgentHookServerRowO worktreeId: existing.worktreeId, connectionId: existing.connectionId, providerSession: existing.providerSession, + // Why: restart seeds a settled main agent unless a shell held the row; keep that fact beside it. + ...(existing.claudeRunningNonAgentTask !== undefined + ? { claudeRunningNonAgentTask: existing.claudeRunningNonAgentTask } + : {}), payload: { state: restored.state, ...(restored.workingMode ? { workingMode: restored.workingMode } : {}), diff --git a/src/main/agent-hooks/server/server-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts index 9d9c8c00699d..8fcc4ac323f4 100644 --- a/src/main/agent-hooks/server/server-status-update.ts +++ b/src/main/agent-hooks/server/server-status-update.ts @@ -12,7 +12,8 @@ import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/ import type { AgentStatusObservationOrigin } from '../../../shared/agent-status-observation' import { attachClaudePermissionToolUseId, - shouldKeepClaudePermissionVisible + shouldKeepClaudePermissionVisible, + withHeldChildWaitMainAgent } from './server-claude-status-rules' import { isStaleGrokTurnEnd } from './server-grok-status-rules' import { isToolProgressWorkingAfterInterrupt } from './server-status-identity' @@ -152,8 +153,15 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA } const effectivePayload = attachClaudePermissionToolUseId(previous, identityResolvedPayload) if (previous && shouldKeepClaudePermissionVisible(previous, effectivePayload)) { - this.commitStatusRowMutation(rowBefore, previous) - return previous + const held = withHeldChildWaitMainAgent(previous, effectivePayload) + if (held !== previous) { + if (!this.writeLegacyStatusRow(held)) { + return undefined + } + this.scheduleStatusPersist() + } + this.commitStatusRowMutation(rowBefore, held) + return held } // Why: some TUIs emit a delayed tool/working hook after Ctrl+C stopped the turn; don't let it resurrect the row. if ( diff --git a/src/shared/agent-hook-listener/providers/claude-roster-state.ts b/src/shared/agent-hook-listener/providers/claude-roster-state.ts index d47b2a7fc427..7d83c9e16a66 100644 --- a/src/shared/agent-hook-listener/providers/claude-roster-state.ts +++ b/src/shared/agent-hook-listener/providers/claude-roster-state.ts @@ -221,19 +221,19 @@ export function seedClaudeSubagentRosterFromSnapshots( } } +/** Restore a settled main agent so its children's drain can still complete the row after a restart. + * A running shell's liveness is not restored, so a row it held stays unseeded and never falsely settles. */ export function seedClaudeLeadTurnFromPersistedStatus( state: HookListenerState, paneKey: string, - status: Pick, - options: { childOnlyBoundary: boolean } + status: Pick ): void { const mainAgent = status.payload.mainAgent - // Why: the persisted `mainAgent` is the fact; a row old enough to lack it was mapped from its - // legacy child-only flag at hydrate, so both shapes arrive here as `mainAgent.state === 'done'`. + // Why: a row old enough to lack `mainAgent` was mapped from its legacy child-only flag at hydrate. if ( - options.childOnlyBoundary && status.payload.agentType === 'claude' && - mainAgent?.state === 'done' + mainAgent?.state === 'done' && + status.claudeRunningNonAgentTask !== true ) { setClaudeMainAgentTurnState(state, paneKey, { state: 'done', From e501902c259befa6f8daef069f56219fe3034601 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:37:08 -0700 Subject: [PATCH 09/19] fix(agent-status): keep a finished turn's main agent verdict and clock with that turn A Claude SessionStart restarts the main agent's clock instead of inheriting the previous session's last Stop. A Grok idle prompt or session end, and a late Codex root Stop after an inferred cancel, restate the same finished turn, so they keep its recorded verdict; only a new turn clears it. --- .../server-main-agent-turn-verdicts.test.ts | 152 ++++++++++++++++++ .../providers/claude-events.ts | 3 +- .../providers/codex-events.ts | 2 + .../providers/codex-state.ts | 12 ++ .../providers/grok-events.ts | 11 +- 5 files changed, 177 insertions(+), 3 deletions(-) create mode 100644 src/main/agent-hooks/server-main-agent-turn-verdicts.test.ts diff --git a/src/main/agent-hooks/server-main-agent-turn-verdicts.test.ts b/src/main/agent-hooks/server-main-agent-turn-verdicts.test.ts new file mode 100644 index 000000000000..1a7e6a5e266c --- /dev/null +++ b/src/main/agent-hooks/server-main-agent-turn-verdicts.test.ts @@ -0,0 +1,152 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AgentHookServer, _internals } from './server' +import { buildBody, postHookEvent, PANE } from './server.test-fixtures' + +const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ + getCohortAtEmitMock: vi.fn(), + trackMock: vi.fn() +})) + +vi.mock('../telemetry/client', () => ({ track: trackMock })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: getCohortAtEmitMock })) + +beforeEach(() => { + _internals.resetCachesForTests() + trackMock.mockReset() + getCohortAtEmitMock.mockReset() + getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) + // Only the wall clock is faked, so the hook server's real sockets keep working. + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(1_000_000) +}) + +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() +}) + +// A finished turn's verdict and clock belong to that turn: an event restating the same finished +// turn keeps them, and only a new turn or a new session replaces them. +describe('main agent turn verdicts and clocks', () => { + let server: AgentHookServer + + beforeEach(async () => { + server = new AgentHookServer() + await server.start({ env: 'production' }) + }) + + afterEach(() => { + server.stop() + }) + + async function post(path: string, payload: Record): Promise { + const response = await postHookEvent(server, buildBody(payload), path) + expect(response.status).toBe(204) + } + + function inferCtrlC(agentType: 'codex'): void { + const baseline = server.getStatusSnapshot()[0] + expect( + server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: baseline.prompt, + baselineAgentType: agentType, + intent: 'ctrl-c' + }) + ).toBe(true) + } + + it('starts a new Claude session with its own main agent clock', async () => { + await post('/hook/claude', { hook_event_name: 'UserPromptSubmit', prompt: 'first' }) + await post('/hook/claude', { hook_event_name: 'Stop' }) + expect(server.getStatusSnapshot()[0]?.mainAgent).toEqual({ + state: 'done', + stateStartedAt: 1_000_000 + }) + + vi.setSystemTime(1_060_000) + await post('/hook/claude', { hook_event_name: 'SessionStart', source: 'clear' }) + + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'done', + mainAgent: { state: 'done', stateStartedAt: 1_060_000 } + }) + }) + + it.each(['idle_prompt notification', 'session end'] as const)( + 'keeps a cancelled Grok turn verdict when a %s restates done', + async (restatement) => { + const turn = { sessionId: 'session-1', promptId: 'prompt-1' } + await post('/hook/grok', { hookEventName: 'user_prompt_submit', ...turn, prompt: 'go' }) + await post('/hook/grok', { hookEventName: 'stop_cancelled', ...turn }) + const cancelled = server.getStatusSnapshot()[0]?.mainAgent + expect(cancelled).toMatchObject({ state: 'done', outcome: 'cancellation' }) + + vi.setSystemTime(1_005_000) + await post( + '/hook/grok', + restatement === 'session end' + ? { hookEventName: 'session_end', ...turn } + : { hookEventName: 'notification', ...turn, notificationType: 'idle_prompt' } + ) + + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done', mainAgent: cancelled }) + } + ) + + it('keeps an inferred Codex cancellation across a late root Stop', async () => { + await post('/hook/codex', { hook_event_name: 'UserPromptSubmit', prompt: 'long task' }) + vi.setSystemTime(1_001_000) + inferCtrlC('codex') + const cancelled = server.getStatusSnapshot()[0]?.mainAgent + expect(cancelled).toMatchObject({ state: 'done', outcome: 'cancellation' }) + + vi.setSystemTime(1_002_000) + await post('/hook/codex', { hook_event_name: 'Stop' }) + // Past the late-event suppression window, a child's activity republishes the main agent. + vi.setSystemTime(1_060_000) + await post('/hook/codex', { hook_event_name: 'SubagentStart', agent_id: 'child-1' }) + + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working', mainAgent: cancelled }) + }) + + it('keeps an inferred Codex cancellation across a late relayed root Stop', () => { + const relayed = ( + hookEventName: string, + payload: Record, + extra: Record = {} + ) => + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hookEventName, + ...extra, + payload: { prompt: 'long task', agentType: 'codex', ...payload } + }, + 'conn-1' + ) + relayed('UserPromptSubmit', { state: 'working' }, { hasExplicitPrompt: true }) + vi.setSystemTime(1_001_000) + inferCtrlC('codex') + const cancelled = server.getStatusSnapshot()[0]?.mainAgent + expect(cancelled).toMatchObject({ state: 'done', outcome: 'cancellation' }) + + vi.setSystemTime(1_002_000) + relayed('Stop', { state: 'done' }) + vi.setSystemTime(1_060_000) + relayed( + 'SubagentStart', + { + state: 'working', + subagents: [{ id: 'child-1', state: 'working', startedAt: 1_060_000 }] + }, + { toolAgentId: 'child-1' } + ) + + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working', mainAgent: cancelled }) + }) +}) diff --git a/src/shared/agent-hook-listener/providers/claude-events.ts b/src/shared/agent-hook-listener/providers/claude-events.ts index f3de6cd66621..5d39b50aa63e 100644 --- a/src/shared/agent-hook-listener/providers/claude-events.ts +++ b/src/shared/agent-hook-listener/providers/claude-events.ts @@ -64,7 +64,8 @@ export function normalizeClaudeEvent( state.claudeSubagentRosterByPaneKey.delete(paneKey) state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) state.claudeActiveSessionCronPaneKeys.delete(paneKey) - setClaudeMainAgentTurnState(state, paneKey, { state: 'done' }) + // Why: a new session's main agent starts its own clock, not the old session's last Stop. + setClaudeMainAgentTurnState(state, paneKey, { state: 'done', stateStartedAt: Date.now() }) return buildClaudeStatusPayload(state, eventName, promptText, paneKey, hookPayload, { stateName: 'done', updateToolSnapshot: true, diff --git a/src/shared/agent-hook-listener/providers/codex-events.ts b/src/shared/agent-hook-listener/providers/codex-events.ts index 83607b9c6432..d056b2de4dbc 100644 --- a/src/shared/agent-hook-listener/providers/codex-events.ts +++ b/src/shared/agent-hook-listener/providers/codex-events.ts @@ -23,6 +23,7 @@ import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' import { readString } from '../tool-input-preview' import { codexMainAgentStatusForPayload, + codexOutcomeRestatedByStop, getOrCreateCodexSubagentRoster, getOrCreateCodexSubagentTranscriptState, hasCodexTranscriptSubagents, @@ -229,6 +230,7 @@ export function normalizeCodexEvent( const previousLead = state.codexLeadStateByPaneKey.get(paneKey) setCodexMainAgentTurnState(state, paneKey, { state: ownedState, + ...codexOutcomeRestatedByStop(previousLead, ownedState), model: normalizeOptionalField(hookPayload['model'], AGENT_MODEL_MAX_LENGTH) ?? (eventName === 'SessionStart' ? undefined : previousLead?.model) diff --git a/src/shared/agent-hook-listener/providers/codex-state.ts b/src/shared/agent-hook-listener/providers/codex-state.ts index 06c72df10088..bd86f77f8d85 100644 --- a/src/shared/agent-hook-listener/providers/codex-state.ts +++ b/src/shared/agent-hook-listener/providers/codex-state.ts @@ -61,6 +61,17 @@ export function setCodexMainAgentTurnState( return record } +/** A root Stop that lands on an already finished turn (late, after an inferred cancel) restates + * that turn, so it keeps the recorded verdict; only a new turn clears it. */ +export function codexOutcomeRestatedByStop( + previous: CodexLeadTurnState | undefined, + nextState: CodexLeadTurnState['state'] +): Pick { + return nextState === 'done' && previous?.state === 'done' && previous.outcome + ? { outcome: previous.outcome } + : {} +} + /** The `mainAgent` fact a Codex row publishes. Its combined `state` still comes from * `codexRosterEffectiveState`, whose waiting-child rule the shared fold cannot express yet; * moving that combine onto the fold is a separate slice with its own story table. */ @@ -182,6 +193,7 @@ export function reconcileRemoteCodexState( const previousLead = state.codexLeadStateByPaneKey.get(paneKey) setCodexMainAgentTurnState(state, paneKey, { state: leadState, + ...codexOutcomeRestatedByStop(previousLead, leadState), model: payload.model ?? previousLead?.model }) } diff --git a/src/shared/agent-hook-listener/providers/grok-events.ts b/src/shared/agent-hook-listener/providers/grok-events.ts index 45317c9473d4..4b384a173d13 100644 --- a/src/shared/agent-hook-listener/providers/grok-events.ts +++ b/src/shared/agent-hook-listener/providers/grok-events.ts @@ -217,9 +217,16 @@ export function normalizeGrokEvent( : null }) const stateName = resolution.stateName + const previousMainAgent = state.grokMainAgentStatusByPaneKey.get(paneKey) + // Why: an idle prompt or session end restates the same finished turn, so its verdict stands. + const mainAgentOutcome = + outcome ?? + (!isTurnEnd && leadState === 'done' && previousMainAgent?.state === 'done' + ? previousMainAgent.outcome + : undefined) const mainAgent = continueMainAgentStatus( - state.grokMainAgentStatusByPaneKey.get(paneKey), - { state: leadState, outcome }, + previousMainAgent, + { state: leadState, outcome: mainAgentOutcome }, Date.now() ) state.grokMainAgentStatusByPaneKey.set(paneKey, mainAgent) From c45d833b15705884bd7857ca85ccc88515186398 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:39:25 -0700 Subject: [PATCH 10/19] test(agent-status): publish the Grok verdict restatement past the late-event window --- src/main/agent-hooks/server-main-agent-turn-verdicts.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/agent-hooks/server-main-agent-turn-verdicts.test.ts b/src/main/agent-hooks/server-main-agent-turn-verdicts.test.ts index 1a7e6a5e266c..abd8a78791f0 100644 --- a/src/main/agent-hooks/server-main-agent-turn-verdicts.test.ts +++ b/src/main/agent-hooks/server-main-agent-turn-verdicts.test.ts @@ -84,7 +84,8 @@ describe('main agent turn verdicts and clocks', () => { const cancelled = server.getStatusSnapshot()[0]?.mainAgent expect(cancelled).toMatchObject({ state: 'done', outcome: 'cancellation' }) - vi.setSystemTime(1_005_000) + // Past the late-event suppression window, so the restatement itself is published. + vi.setSystemTime(1_030_000) await post( '/hook/grok', restatement === 'session end' From fc0fb44977af07524ef81d8a6b039c6b1c87484f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:45:14 -0700 Subject: [PATCH 11/19] docs(agent-status): describe hydrate seeding and the OSC refusal from the saved main agent fact --- docs/reference/agent-status-store.md | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index bb385487f75f..36a06fb2a0c2 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -201,8 +201,10 @@ mainAgent?: { state: AgentStatusState; outcome?: AgentJournalTurnOutcome; stateS 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) is now derived from `mainAgent` plus -the row's child evidence instead of stored. `outcome` is the recorded verdict on +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 @@ -220,10 +222,14 @@ IPC and disk. A malformed `mainAgent` drops the field and keeps the row. Old hos 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. `claudeRunningNonAgentTask` is persisted alongside because it is -the one child-work fact `mainAgent` cannot express: whether a shell was running -beside the child agents, which decides whether a settled main agent may be seeded -at hydrate. +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, so such a row is not seeded. Writers that +restate a row without their own inventory (an OSC repaint, an inferred answer) carry +it forward beside the `mainAgent` they keep. A child's sticky permission prompt +still records the main agent's own progress in the held row's `mainAgent`. Two combining rules remain outside the shared fold and are named so a reader does not mistake them for drift: From 59b54ac42a43ba33eafd161b17f843118e68ca89 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:49:14 -0700 Subject: [PATCH 12/19] fix(agent-status): push a held child permission row when its main agent changes --- ...-last-status-child-held-main-agent.test.ts | 22 +++++++++++++++++++ .../server/server-status-update.ts | 4 ++++ 2 files changed, 26 insertions(+) diff --git a/src/main/agent-hooks/server-last-status-child-held-main-agent.test.ts b/src/main/agent-hooks/server-last-status-child-held-main-agent.test.ts index 0505e2c8dc9b..210cfe0e2250 100644 --- a/src/main/agent-hooks/server-last-status-child-held-main-agent.test.ts +++ b/src/main/agent-hooks/server-last-status-child-held-main-agent.test.ts @@ -168,6 +168,28 @@ describe('Claude rows held open by child agents', () => { } }) + it('pushes the held child permission row when the main agent behind it changes', async () => { + const server = await startServer() + try { + await settleMainAgentBeside(server, ['achild-a']) + await post(server, CHILD_PERMISSION) + const pushed: { state: string; mainAgent?: { state: string } }[] = [] + server.subscribeEnrichedStatus((enriched) => pushed.push(enriched.payload)) + await post(server, { hook_event_name: 'PreToolUse', tool_name: 'Read' }) + expect(pushed).toEqual([ + expect.objectContaining({ + state: 'waiting', + mainAgent: expect.objectContaining({ state: 'working' }) + }) + ]) + // The main agent keeps working: nothing it publishes changed, so nothing is pushed. + await post(server, { hook_event_name: 'PreToolUse', tool_name: 'Grep' }) + expect(pushed).toHaveLength(1) + } finally { + server.stop() + } + }) + it('does not settle a restored row whose main agent resumed behind a child permission', async () => { let server = await startServer() await settleMainAgentBeside(server, ['achild-a', 'achild-b']) diff --git a/src/main/agent-hooks/server/server-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts index 8fcc4ac323f4..788f01afde5d 100644 --- a/src/main/agent-hooks/server/server-status-update.ts +++ b/src/main/agent-hooks/server/server-status-update.ts @@ -161,6 +161,10 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA this.scheduleStatusPersist() } this.commitStatusRowMutation(rowBefore, held) + // Why: pushed readers must see the new `mainAgent` a snapshot reader already does. + if (held !== previous) { + this.emitEnrichedStatus(held) + } return held } // Why: some TUIs emit a delayed tool/working hook after Ctrl+C stopped the turn; don't let it resurrect the row. From b9c5ebbb5d339b13713ed8af433f85e646c77199 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:55:36 -0700 Subject: [PATCH 13/19] fix(agent-status): keep the shell fact on a held child permission row so restart does not settle it --- ...-last-status-child-held-main-agent.test.ts | 27 +++++++++++++++++++ .../server/server-claude-status-rules.ts | 23 +++++++++++----- .../server/server-status-update.ts | 2 +- 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/src/main/agent-hooks/server-last-status-child-held-main-agent.test.ts b/src/main/agent-hooks/server-last-status-child-held-main-agent.test.ts index 210cfe0e2250..3ddffbd63ae8 100644 --- a/src/main/agent-hooks/server-last-status-child-held-main-agent.test.ts +++ b/src/main/agent-hooks/server-last-status-child-held-main-agent.test.ts @@ -229,6 +229,33 @@ describe('Claude rows held open by child agents', () => { } }) + it('does not settle a restored row whose main agent left a shell running behind a child permission', async () => { + let server = await startServer() + await post(server, { hook_event_name: 'UserPromptSubmit', prompt: 'delegate' }) + await post(server, { hook_event_name: 'SubagentStart', agent_id: 'achild-a' }) + await post(server, CHILD_PERMISSION) + await post(server, { + hook_event_name: 'Stop', + background_tasks: [{ id: 'achild-a', type: 'subagent', status: 'running' }, RUNNING_SHELL] + }) + expect(row(server)).toMatchObject({ state: 'waiting', mainAgent: { state: 'done' } }) + server = await restart(server) + try { + await post(server, { + hook_event_name: 'PreToolUse', + agent_id: 'achild-a', + tool_name: 'Bash', + tool_input: { command: 'rm scratch' }, + tool_use_id: 'toolu-approved' + }) + await post(server, { hook_event_name: 'SubagentStop', agent_id: 'achild-a' }) + + expect(row(server)?.state).toBe('working') + } finally { + server.stop() + } + }) + // The shell fact rides beside `mainAgent`; a row rewritten without it would read as shell-free. async function expectShellHeldRowStaysOpenAfterRestart( server: AgentHookServer, diff --git a/src/main/agent-hooks/server/server-claude-status-rules.ts b/src/main/agent-hooks/server/server-claude-status-rules.ts index 65c8c78ac18c..604bca04ddd7 100644 --- a/src/main/agent-hooks/server/server-claude-status-rules.ts +++ b/src/main/agent-hooks/server/server-claude-status-rules.ts @@ -6,20 +6,29 @@ import type { EnrichedAgentHookEventPayload } from './server-types' /** A child's permission prompt stays visible over the main agent's own progress, but the row must * still carry that progress: restart seeds the main agent from it, and a stale `done` would let the - * children's drain settle a row whose main agent is working. Returns `previous` when nothing changed. */ + * children's drain settle a row whose main agent is working. Returns `previous` when nothing changed, + * and keeps `previous.payload` when only the unpublished shell fact did. */ export function withHeldChildWaitMainAgent( previous: EnrichedAgentHookEventPayload, next: AgentHookEventPayload ): EnrichedAgentHookEventPayload { const mainAgent = next.payload.mainAgent - if ( - !previous.toolAgentId || - !mainAgent || - mainAgentStatusEqual(previous.payload.mainAgent, mainAgent) - ) { + if (!previous.toolAgentId || !mainAgent) { + return previous + } + // Why: restart seeding reads the shell fact beside `mainAgent`; a stale one settles a shell-held row. + const runningNonAgentTask = next.claudeRunningNonAgentTask ?? previous.claudeRunningNonAgentTask + const mainAgentChanged = !mainAgentStatusEqual(previous.payload.mainAgent, mainAgent) + if (!mainAgentChanged && runningNonAgentTask === previous.claudeRunningNonAgentTask) { return previous } - return { ...previous, payload: { ...previous.payload, mainAgent } } + return { + ...previous, + ...(runningNonAgentTask !== undefined + ? { claudeRunningNonAgentTask: runningNonAgentTask } + : {}), + payload: mainAgentChanged ? { ...previous.payload, mainAgent } : previous.payload + } } export function shouldKeepClaudePermissionVisible( diff --git a/src/main/agent-hooks/server/server-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts index 788f01afde5d..d3dfd249e183 100644 --- a/src/main/agent-hooks/server/server-status-update.ts +++ b/src/main/agent-hooks/server/server-status-update.ts @@ -162,7 +162,7 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA } this.commitStatusRowMutation(rowBefore, held) // Why: pushed readers must see the new `mainAgent` a snapshot reader already does. - if (held !== previous) { + if (held.payload !== previous.payload) { this.emitEnrichedStatus(held) } return held From 5ffe00ffa00fc3530f01c968a0acf55d58f15b0a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:57:00 -0700 Subject: [PATCH 14/19] docs(agent-status): note the held child permission row carries the shell fact and is pushed --- docs/reference/agent-status-store.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index 36a06fb2a0c2..62de2897ccdb 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -229,7 +229,8 @@ child-work fact `mainAgent` cannot express: a shell running beside the main agen whose liveness hydration does not restore, so such a row is not seeded. Writers that restate a row without their own inventory (an OSC repaint, an inferred answer) carry it forward beside the `mainAgent` they keep. A child's sticky permission prompt -still records the main agent's own progress in the held row's `mainAgent`. +still records the main agent's own progress in the held row's `mainAgent`, with the +shell fact beside it, 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: From ded33b7ee0335c4390e66be6849dd926e5a5fc57 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:52:05 -0700 Subject: [PATCH 15/19] fix(agent-status): pair the Claude shell fact with the main agent at the one row-build point Every non-hook rewrite (terminal-title repaint, inferred answer, held child permission) had to re-carry the shell fact beside `mainAgent`, and each one that forgot let a restart settle a row while a shell still ran. The row builder now pairs the fact once: a listener event restates it, any other write keeps it only while `mainAgent` is unchanged. Restart seeds a settled main agent only when the row says no shell ran, and legacy child-only rows map to that explicitly. A held child permission now also accepts the main agent event's background evidence, as it already accepts its `mainAgent`, so the child's drain no longer settles a row a shell still holds. The renderer keeps a previous `mainAgent` only for writers that never carry one, so a hook row without it matches the host snapshot. --- ...-last-status-child-held-main-agent.test.ts | 54 +++++++++++++++++++ .../server/server-claude-status-rules.ts | 21 ++++++-- .../server/server-ingest-terminal.ts | 4 -- .../server/server-persistence-validation.ts | 32 ++++++----- .../server/server-status-inference.ts | 4 -- .../server/server-status-update.ts | 16 +++++- .../agent-status-live-entry-main-agent.ts | 20 +++++-- .../agent-status-main-agent-fact.test.ts | 41 +++++++++++++- .../providers/claude-roster-state.ts | 5 +- 9 files changed, 163 insertions(+), 34 deletions(-) diff --git a/src/main/agent-hooks/server-last-status-child-held-main-agent.test.ts b/src/main/agent-hooks/server-last-status-child-held-main-agent.test.ts index 3ddffbd63ae8..c5b77fd086a0 100644 --- a/src/main/agent-hooks/server-last-status-child-held-main-agent.test.ts +++ b/src/main/agent-hooks/server-last-status-child-held-main-agent.test.ts @@ -256,6 +256,60 @@ describe('Claude rows held open by child agents', () => { } }) + it('keeps a row open for the shell a main agent left behind a child permission', async () => { + const server = await startServer() + try { + await post(server, { hook_event_name: 'UserPromptSubmit', prompt: 'delegate' }) + await post(server, { hook_event_name: 'SubagentStart', agent_id: 'achild-a' }) + await post(server, CHILD_PERMISSION) + await post(server, { + hook_event_name: 'Stop', + background_tasks: [{ id: 'achild-a', type: 'subagent', status: 'running' }, RUNNING_SHELL] + }) + await post(server, { + hook_event_name: 'PreToolUse', + agent_id: 'achild-a', + tool_name: 'Bash', + tool_input: { command: 'rm scratch' }, + tool_use_id: 'toolu-approved' + }) + await post(server, { hook_event_name: 'SubagentStop', agent_id: 'achild-a' }) + + expect(row(server)?.state).toBe('working') + } finally { + server.stop() + } + }) + + it('settles a restored row after an inferred child answer when no shell ran', async () => { + let server = await startServer() + await settleMainAgentBeside(server, ['achild-a']) + await post(server, { + hook_event_name: 'PreToolUse', + agent_id: 'achild-a', + tool_name: 'AskUserQuestion', + tool_use_id: 'toolu-question' + }) + const waiting = row(server) + expect( + server.inferQuestionAnswered({ + paneKey: PANE, + baselineUpdatedAt: waiting?.receivedAt ?? 0, + baselineStateStartedAt: waiting?.stateStartedAt ?? 0, + baselinePrompt: waiting?.prompt ?? '', + baselineAgentType: 'claude' + }) + ).toBe(true) + server = await restart(server) + try { + await post(server, { hook_event_name: 'SubagentStop', agent_id: 'achild-a' }) + + expect(row(server)).toMatchObject({ state: 'done', mainAgent: { state: 'done' } }) + } finally { + server.stop() + } + }) + // The shell fact rides beside `mainAgent`; a row rewritten without it would read as shell-free. async function expectShellHeldRowStaysOpenAfterRestart( server: AgentHookServer, diff --git a/src/main/agent-hooks/server/server-claude-status-rules.ts b/src/main/agent-hooks/server/server-claude-status-rules.ts index 604bca04ddd7..d8136837047f 100644 --- a/src/main/agent-hooks/server/server-claude-status-rules.ts +++ b/src/main/agent-hooks/server/server-claude-status-rules.ts @@ -4,6 +4,21 @@ import { isAskUserQuestionTool } from '../../../shared/agent-question-answered-i import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' import type { EnrichedAgentHookEventPayload } from './server-types' +/** The shell fact a Claude row stores beside its `mainAgent`; restart seeds a settled main agent only + * when it reads `false`. The listener restates it on every event it produces; any other write keeps + * the previous fact only while `mainAgent` is unchanged, since the fact was observed with that one. */ +export function pairedClaudeNonAgentWork( + previous: EnrichedAgentHookEventPayload | undefined, + next: AgentHookEventPayload +): boolean | undefined { + if (next.claudeRunningNonAgentTask !== undefined) { + return next.claudeRunningNonAgentTask + } + return previous && mainAgentStatusEqual(previous.payload.mainAgent, next.payload.mainAgent) + ? previous.claudeRunningNonAgentTask + : undefined +} + /** A child's permission prompt stays visible over the main agent's own progress, but the row must * still carry that progress: restart seeds the main agent from it, and a stale `done` would let the * children's drain settle a row whose main agent is working. Returns `previous` when nothing changed, @@ -16,14 +31,14 @@ export function withHeldChildWaitMainAgent( if (!previous.toolAgentId || !mainAgent) { return previous } - // Why: restart seeding reads the shell fact beside `mainAgent`; a stale one settles a shell-held row. - const runningNonAgentTask = next.claudeRunningNonAgentTask ?? previous.claudeRunningNonAgentTask + const runningNonAgentTask = pairedClaudeNonAgentWork(previous, next) const mainAgentChanged = !mainAgentStatusEqual(previous.payload.mainAgent, mainAgent) if (!mainAgentChanged && runningNonAgentTask === previous.claudeRunningNonAgentTask) { return previous } + const { claudeRunningNonAgentTask: _unpaired, ...unpaired } = previous return { - ...previous, + ...unpaired, ...(runningNonAgentTask !== undefined ? { claudeRunningNonAgentTask: runningNonAgentTask } : {}), diff --git a/src/main/agent-hooks/server/server-ingest-terminal.ts b/src/main/agent-hooks/server/server-ingest-terminal.ts index d84be62ee661..9a83465d3b44 100644 --- a/src/main/agent-hooks/server/server-ingest-terminal.ts +++ b/src/main/agent-hooks/server/server-ingest-terminal.ts @@ -150,10 +150,6 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges connectionId, ...(preservedProviderSession ? { providerSession: preservedProviderSession } : {}), ...(terminalHandle ? { terminalHandle } : {}), - // Why: restart seeds a settled main agent unless a shell held the row; keep that fact beside it. - ...(preservedMainAgent && previous?.claudeRunningNonAgentTask !== undefined - ? { claudeRunningNonAgentTask: previous.claudeRunningNonAgentTask } - : {}), payload: preservedMainAgent ? { ...event.payload, mainAgent: preservedMainAgent } : event.payload diff --git a/src/main/agent-hooks/server/server-persistence-validation.ts b/src/main/agent-hooks/server/server-persistence-validation.ts index 09c25e293d6a..f88766217c11 100644 --- a/src/main/agent-hooks/server/server-persistence-validation.ts +++ b/src/main/agent-hooks/server/server-persistence-validation.ts @@ -3,6 +3,7 @@ import { createHash } from 'node:crypto' import { normalizeAgentProviderSession } from '../../../shared/agent-session-resume' import { normalizeAgentStatusPayload, + type AgentMainAgentStatus, type ParsedAgentStatusPayload } from '../../../shared/agent-status-types' import { isAgentHookSource } from '../../../shared/agent-hook-relay' @@ -35,24 +36,21 @@ export function dropHydratedIdleClaudeSubagents( * main agent had settled and child agents alone held the row `working`. That is `mainAgent.state === 'done'` * stored as a boolean, so it only fills an absent `mainAgent`; a row carrying both keeps `mainAgent`. The * flag stays readable until every user's file has been rewritten without it. */ -function fillMainAgentFromLegacyChildOnlyBoundary( +function legacyChildOnlyBoundaryMainAgent( payload: ParsedAgentStatusPayload, record: Record, stateStartedAt: number -): ParsedAgentStatusPayload { +): AgentMainAgentStatus | undefined { if ( payload.mainAgent !== undefined || record.claudeLeadBoundaryChildOnly !== true || payload.agentType !== 'claude' ) { - return payload - } - return { - ...payload, - // Why: the gated working row stamps the main agent's end as `turnCompletedAt`; the row clock is the - // nearest fact an older row that lacks it can offer. - mainAgent: { state: 'done', stateStartedAt: payload.turnCompletedAt ?? stateStartedAt } + return undefined } + // Why: the gated working row stamps the main agent's end as `turnCompletedAt`; the row clock is the + // nearest fact an older row that lacks it can offer. + return { state: 'done', stateStartedAt: payload.turnCompletedAt ?? stateStartedAt } } export function sanitizeHydratedEntry( @@ -112,11 +110,21 @@ export function sanitizeHydratedEntry( if (!normalizedPayload) { return null } - const payload = fillMainAgentFromLegacyChildOnlyBoundary( + const legacyBoundaryMainAgent = legacyChildOnlyBoundaryMainAgent( normalizedPayload, record, stateStartedAt ) + const payload = legacyBoundaryMainAgent + ? { ...normalizedPayload, mainAgent: legacyBoundaryMainAgent } + : normalizedPayload + const claudeRunningNonAgentTask = + typeof record.claudeRunningNonAgentTask === 'boolean' + ? record.claudeRunningNonAgentTask + : // Why: the legacy flag was only ever written while no shell ran beside the children. + legacyBoundaryMainAgent + ? false + : undefined const providerSession = normalizeAgentProviderSession(record.providerSession) ?? undefined const providerSessionOnly = record.providerSessionOnly === true const retainedForLiveness = record.retainedForLiveness === true @@ -156,9 +164,7 @@ export function sanitizeHydratedEntry( toolAgentId: typeof record.toolAgentId === 'string' ? record.toolAgentId : undefined, teammateName: typeof record.teammateName === 'string' ? record.teammateName : undefined, toolAgentType: typeof record.toolAgentType === 'string' ? record.toolAgentType : undefined, - ...(typeof record.claudeRunningNonAgentTask === 'boolean' - ? { claudeRunningNonAgentTask: record.claudeRunningNonAgentTask } - : {}), + ...(claudeRunningNonAgentTask !== undefined ? { claudeRunningNonAgentTask } : {}), providerSession, providerSessionOnly: providerSessionOnly ? true : undefined, retainedForLiveness: retainedForLiveness ? true : undefined, diff --git a/src/main/agent-hooks/server/server-status-inference.ts b/src/main/agent-hooks/server/server-status-inference.ts index 2263e1571031..02b61595832d 100644 --- a/src/main/agent-hooks/server/server-status-inference.ts +++ b/src/main/agent-hooks/server/server-status-inference.ts @@ -169,10 +169,6 @@ export abstract class AgentHookServerStatusInference extends AgentHookServerRowO worktreeId: existing.worktreeId, connectionId: existing.connectionId, providerSession: existing.providerSession, - // Why: restart seeds a settled main agent unless a shell held the row; keep that fact beside it. - ...(existing.claudeRunningNonAgentTask !== undefined - ? { claudeRunningNonAgentTask: existing.claudeRunningNonAgentTask } - : {}), payload: { state: restored.state, ...(restored.workingMode ? { workingMode: restored.workingMode } : {}), diff --git a/src/main/agent-hooks/server/server-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts index d3dfd249e183..65fd1acc7143 100644 --- a/src/main/agent-hooks/server/server-status-update.ts +++ b/src/main/agent-hooks/server/server-status-update.ts @@ -12,6 +12,7 @@ import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/ import type { AgentStatusObservationOrigin } from '../../../shared/agent-status-observation' import { attachClaudePermissionToolUseId, + pairedClaudeNonAgentWork, shouldKeepClaudePermissionVisible, withHeldChildWaitMainAgent } from './server-claude-status-rules' @@ -154,6 +155,11 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA const effectivePayload = attachClaudePermissionToolUseId(previous, identityResolvedPayload) if (previous && shouldKeepClaudePermissionVisible(previous, effectivePayload)) { const held = withHeldChildWaitMainAgent(previous, effectivePayload) + // Why: a child's prompt leaves the main agent running, so the held row takes its `mainAgent` and + // must take the same event's background evidence; a main agent's own prompt blocks it, so not there. + if (previous.toolAgentId) { + onAccepted?.() + } if (held !== previous) { if (!this.writeLegacyStatusRow(held)) { return undefined @@ -208,9 +214,15 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA } // Why carried forward only within one host: main's OSC parse resolves the handle, so a later // hook must not erase its terminal join; a connection change must not inherit another host's. + const { claudeRunningNonAgentTask: _unpaired, ...unpairedPayload } = effectivePayload + const runningNonAgentTask = pairedClaudeNonAgentWork(previous, effectivePayload) + const pairedPayload = + runningNonAgentTask === undefined + ? unpairedPayload + : { ...unpairedPayload, claudeRunningNonAgentTask: runningNonAgentTask } const enriched = { - ...this.attachStatusTiming(effectivePayload, now, observedAt), - observation: this.stampObservation(effectivePayload, origin, observedAt ?? now) + ...this.attachStatusTiming(pairedPayload, now, observedAt), + observation: this.stampObservation(pairedPayload, origin, observedAt ?? now) } if ( typeof enriched.payload.turnCompletedAt === 'number' && diff --git a/src/renderer/src/store/slices/agent-status-live-entry-main-agent.ts b/src/renderer/src/store/slices/agent-status-live-entry-main-agent.ts index 1f4dd7b000ba..cb356805c518 100644 --- a/src/renderer/src/store/slices/agent-status-live-entry-main-agent.ts +++ b/src/renderer/src/store/slices/agent-status-live-entry-main-agent.ts @@ -1,9 +1,19 @@ import { mainAgentStatusEqual, type AgentMainAgentStatus, - type AgentStatusEntry, - type AgentStatusPayload + type AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { AgentStatusObservationOrigin } from '../../../../shared/agent-status-observation' +import type { AgentStatusPayload } from './agent-status-contract' + +/** Byte- and launch-derived writers never carry a main agent fact; a hook row without one means the + * host has none, so keeping ours would diverge from what the host serves mobile and the CLI. */ +const MAIN_AGENT_BLIND_ORIGINS: ReadonlySet = new Set([ + 'osc', + 'title', + 'launch', + 'process' +]) /** The main agent fact a live entry carries. A writer with no main agent fact of its own (OSC bytes, launch * seeds) repaints the state the row already holds, and the main agent behind an unchanged state is @@ -11,12 +21,14 @@ import { * nothing changed so subscribers can compare by reference. */ export function resolveAgentStatusLiveEntryMainAgent( existing: AgentStatusEntry | undefined, - payload: Pick, + payload: Pick, agentType: AgentStatusEntry['agentType'] ): AgentMainAgentStatus | undefined { + const blindWriter = + payload.observation !== undefined && MAIN_AGENT_BLIND_ORIGINS.has(payload.observation.origin) const next = payload.mainAgent ?? - (existing?.state === payload.state && existing.agentType === agentType + (blindWriter && existing?.state === payload.state && existing.agentType === agentType ? existing.mainAgent : undefined) return mainAgentStatusEqual(existing?.mainAgent, next) ? existing?.mainAgent : next diff --git a/src/renderer/src/store/slices/agent-status-main-agent-fact.test.ts b/src/renderer/src/store/slices/agent-status-main-agent-fact.test.ts index 6fab1dd216d3..bd00eaa50158 100644 --- a/src/renderer/src/store/slices/agent-status-main-agent-fact.test.ts +++ b/src/renderer/src/store/slices/agent-status-main-agent-fact.test.ts @@ -4,6 +4,17 @@ import { createTestStore } from './store-test-helpers' const PANE = 'tab-1:11111111-1111-4111-8111-111111111111' +function osc(revision: number) { + return { + origin: 'osc' as const, + authorityId: 'renderer', + incarnation: 0, + revision, + observedAt: revision, + kind: 'snapshot' as const + } +} + describe('the main agent fact on a renderer status entry', () => { it('lands on the entry from the IPC payload and is reused by reference when unchanged', () => { const store = createTestStore() @@ -35,11 +46,37 @@ describe('the main agent fact on a renderer status entry', () => { state: 'working', prompt: 'go', agentType: 'claude', - toolName: 'Bash' + toolName: 'Bash', + observation: osc(1) }) expect(store.getState().agentStatusByPaneKey[PANE].mainAgent).toEqual(mainAgent) - store.getState().setAgentStatus(PANE, { state: 'done', prompt: 'go', agentType: 'claude' }) + store + .getState() + .setAgentStatus(PANE, { + state: 'done', + prompt: 'go', + agentType: 'claude', + observation: osc(2) + }) + expect(store.getState().agentStatusByPaneKey[PANE].mainAgent).toBeUndefined() + }) + + it('drops the main agent when a hook row carries none, matching the host snapshot', () => { + const store = createTestStore() + store.getState().setAgentStatus(PANE, { + state: 'working', + prompt: 'go', + agentType: 'claude', + mainAgent: { state: 'done', stateStartedAt: 5 } + }) + // The host lost its main agent record (unseeded restart, relay restart, old relay). + store.getState().setAgentStatus(PANE, { + state: 'working', + prompt: 'go', + agentType: 'claude', + toolName: 'Bash' + }) expect(store.getState().agentStatusByPaneKey[PANE].mainAgent).toBeUndefined() }) diff --git a/src/shared/agent-hook-listener/providers/claude-roster-state.ts b/src/shared/agent-hook-listener/providers/claude-roster-state.ts index 7d83c9e16a66..80c68037390b 100644 --- a/src/shared/agent-hook-listener/providers/claude-roster-state.ts +++ b/src/shared/agent-hook-listener/providers/claude-roster-state.ts @@ -222,7 +222,8 @@ export function seedClaudeSubagentRosterFromSnapshots( } /** Restore a settled main agent so its children's drain can still complete the row after a restart. - * A running shell's liveness is not restored, so a row it held stays unseeded and never falsely settles. */ + * A running shell's liveness is not restored, so only a row that says no shell ran is seeded; one + * that says nothing (rewritten without the fact) stays unseeded rather than falsely settling. */ export function seedClaudeLeadTurnFromPersistedStatus( state: HookListenerState, paneKey: string, @@ -233,7 +234,7 @@ export function seedClaudeLeadTurnFromPersistedStatus( if ( status.payload.agentType === 'claude' && mainAgent?.state === 'done' && - status.claudeRunningNonAgentTask !== true + status.claudeRunningNonAgentTask === false ) { setClaudeMainAgentTurnState(state, paneKey, { state: 'done', From 5f4bc28192c3b9e8fb735c9ef1fb1fa001f23924 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:54:39 -0700 Subject: [PATCH 16/19] test(agent-status): pin that restart never seeds a main agent from a row silent about its shell --- ...server-last-status-main-agent-fact.test.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/main/agent-hooks/server-last-status-main-agent-fact.test.ts b/src/main/agent-hooks/server-last-status-main-agent-fact.test.ts index df9a977b25ec..b503e930b288 100644 --- a/src/main/agent-hooks/server-last-status-main-agent-fact.test.ts +++ b/src/main/agent-hooks/server-last-status-main-agent-fact.test.ts @@ -184,6 +184,36 @@ describe('The main agent fact across a restart', () => { } ) + it('does not restore a settled main agent from a row that does not say whether a shell ran', async () => { + const receivedAt = recentTs() + writeEntry({ + receivedAt, + stateStartedAt: receivedAt - 5_000, + payload: { + state: 'working', + prompt: 'unknown shell', + agentType: 'claude', + mainAgent: { state: 'done', stateStartedAt: receivedAt - 2_000 }, + subagents: [{ id: 'achild', state: 'working', startedAt: receivedAt - 4_000 }] + } + }) + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + await postHookEvent( + server, + buildBody({ hook_event_name: 'SubagentStop', agent_id: 'achild' }) + ) + + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'working', + restoredUnconfirmed: true + }) + } finally { + server.stop() + } + }) + it('prefers a persisted main agent over the legacy flag when a row carries both', async () => { const receivedAt = recentTs() const mainAgent = { state: 'done', outcome: 'cancellation', stateStartedAt: receivedAt - 2_000 } From bef80c82c1e9bbaf8250362dad48ed30211eb634 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:05:00 -0700 Subject: [PATCH 17/19] docs(agent-status): the row builder pairs the shell fact with the main agent, and restart seeds only on an explicit no-shell --- docs/reference/agent-status-store.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index 62de2897ccdb..b670fff0c30b 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -226,11 +226,12 @@ 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, so such a row is not seeded. Writers that -restate a row without their own inventory (an OSC repaint, an inferred answer) carry -it forward beside the `mainAgent` they keep. A child's sticky permission prompt -still records the main agent's own progress in the held row's `mainAgent`, with the -shell fact beside it, and pushes the held row to subscribers when `mainAgent` changes. +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: From 824185d3f83107719bfd7f2acc38dea4a3f5ca4b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:12:18 -0700 Subject: [PATCH 18/19] test(agent-status): name the legacy-row case parameter for what it holds --- src/main/agent-hooks/server-last-status-main-agent-fact.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/agent-hooks/server-last-status-main-agent-fact.test.ts b/src/main/agent-hooks/server-last-status-main-agent-fact.test.ts index b503e930b288..9d5345dc4235 100644 --- a/src/main/agent-hooks/server-last-status-main-agent-fact.test.ts +++ b/src/main/agent-hooks/server-last-status-main-agent-fact.test.ts @@ -141,7 +141,7 @@ describe('The main agent fact across a restart', () => { ['a drained row', 'done', ['SubagentStart', 'SubagentStop']] ] as const)( 'settles a legacy flagged row holding %s once its child finishes', - async (_shape, state, childEvents) => { + async (_heldRow, state, childEvents) => { const receivedAt = recentTs() writeEntry({ receivedAt, From df41109e4a28199dbbf8113714cfbf1f47e86bdb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:08:50 -0700 Subject: [PATCH 19/19] docs(agent-status): name which rows carry the main agent fact --- docs/reference/agent-status-store.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index b670fff0c30b..3bec9b9e9358 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -190,8 +190,9 @@ pane key two writers. Removing that filter is the first step of PR 2. ### The main agent fact -Every producer publishes the row's combined `state` and, beside it, the main -agent's own state as `payload.mainAgent`: +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 }