From 0235a72a0de1b6f2b166e3c4bdc35773be1cf98d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:27:24 -0700 Subject: [PATCH 1/5] refactor(agent-status): one Claude transcript watch per pane, read before every event Generalizes the idle Ctrl+C `agents_killed` watch into the machinery two planned fixes need: a list of reasons to watch (one today, a working agent child), one cursor per pane armed or repointed only from an accepted row, a catch-up before every Claude event (before the desktop's accept-or-roll-back snapshot; the relay has no such step), and a one-second tick that restates the row rebuilt from the listener's records whenever the stored row does not show it. The cancel latch now re-marks a local Claude pane's record cancelled when it holds a late main-agent hook, as it already does for Codex, so the tick has no stale record to restate once the latch window ends. One stamp function for the shell fact; the watch's second copy is gone. The idle Ctrl+C capture fixtures' transcript records carry raw `lines`. --- docs/reference/agent-status-store.md | 26 +- .../claude-cancel-capture.test-fixture.ts | 6 +- ...server-claude-idle-ctrl-c-captures.test.ts | 13 +- .../server-claude-transcript-watch.test.ts | 371 +++++++++++++++++ .../server-relayed-claude-cancel.test.ts | 14 +- .../server/server-ingest-normalization.ts | 4 + src/main/agent-hooks/server/server-state.ts | 2 +- .../server/server-status-retries.ts | 47 ++- .../server/server-status-update.ts | 13 + .../agent-hook-result-retry-scheduler.ts | 62 ++- src/relay/agent-hook-server.ts | 10 +- .../claude-idle-ctrl-c-bg-agent-hooks.jsonl | 4 +- .../claude-idle-ctrl-c-draft-hooks.jsonl | 2 +- ...ude-idle-ctrl-c-finished-agent-hooks.jsonl | 2 +- .../claude-idle-ctrl-c-shell-only-hooks.jsonl | 2 +- ...ent-hook-listener-relay-dependency.test.ts | 2 + src/shared/agent-hook-listener.ts | 5 +- .../claude-transcript-watch-ticks.ts | 69 ++++ .../agent-hook-listener/listener-state.ts | 16 +- .../claude-agents-killed-transcript.test.ts | 158 -------- .../claude-agents-killed-transcript.ts | 126 ------ .../providers/claude-roster-state.ts | 16 +- .../providers/claude-transcript-watch.test.ts | 378 ++++++++++++++++++ .../providers/claude-transcript-watch.ts | 330 +++++++++++++++ .../transcript-poll-policy.ts | 37 +- 25 files changed, 1322 insertions(+), 393 deletions(-) create mode 100644 src/main/agent-hooks/server-claude-transcript-watch.test.ts create mode 100644 src/shared/agent-hook-listener/claude-transcript-watch-ticks.ts delete mode 100644 src/shared/agent-hook-listener/providers/claude-agents-killed-transcript.test.ts delete mode 100644 src/shared/agent-hook-listener/providers/claude-agents-killed-transcript.ts create mode 100644 src/shared/agent-hook-listener/providers/claude-transcript-watch.test.ts create mode 100644 src/shared/agent-hook-listener/providers/claude-transcript-watch.ts diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index 69da79c4e379..930ec5364a32 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -276,15 +276,26 @@ infer the Claude kill from the keypress, which the CLI may swallow (an open `system`/`agents_killed` line in the session transcript, written only when it really killed every running background agent. The listener on the host that runs the session (the desktop for a local pane, the SSH or WSL relay for a -remote one) watches for that line only while the pane has a working agent child -and a transcript path it can read locally: armed at the file's end, so a -resumed or forked session's older lines never count, read incrementally on the -existing transcript-poll timer, and dropped when the children end or the pane -closes. A new line retires the children that started by its timestamp with +remote one) reads it through the pane's Claude transcript watch +(`claude-transcript-watch.ts`). The watch keeps one cursor per pane while any +reason to watch holds (today one: a working agent child) and the pane has a +transcript path that host can read locally. The cursor is armed at the file's +end, so a resumed, forked or cleared session's older lines never count, and it +is created or repointed only from a row the host accepted, at the new file's end +when the session's transcript changes; a change of reasons never resets it. Every +Claude event first catches up on the existing cursor, before the desktop's +accept-or-roll-back snapshot (the relay has no such step), and a one-second tick +reads it too and republishes the row rebuilt from the listener's records when +the stored row does not show it (on the desktop that is the row after the +cancel latch and the permission hold; on a relay, its own cache). It disarms only +after a read that applied nothing with no reason left, or when the pane closes. +A new line retires the children that started by its timestamp with SubagentStop semantics and publishes a `SubagentStop` row attributed to one of them, with no verdict stamped; the row keeps monitoring a surviving shell and settles to done only when nothing is left (`claude-idle-ctrl-c-*` fixtures pin -this). A relay too old to watch leaves the child on the row until the next +this). When the cancel latch holds a late main-agent hook of a local pane, the +listener's record is re-marked cancelled, as for Codex, so the tick has nothing +stale to restate. A relay too old to watch leaves the child on the row until the next Stop's inventory, as before. The synthesized row is the fold of the cancelled main agent with the child work the pane's owner can see: the local listener's roster for a local pane, the row's own subagents and shell fact @@ -442,8 +453,7 @@ call it. runtime execution, native mobile clients, and mixed-version paired clients remain validation gaps. - **Performance budget:** publication stays event-driven with no new polling or - subprocesses (the Claude `agents_killed` watch above reuses the existing - transcript-poll timer and runs only while a pane has working agent children). One mobile projection clones the status snapshot once, builds + subprocesses (the Claude transcript watch above costs one `stat` per event and per second, plus the appended bytes, only while a pane has a reason to watch). One mobile projection clones the status snapshot once, builds pane/handle indexes once, and has a deterministic call-count test; lifecycle cleanup is bounded by the existing status and handle inventories, and orcad tests prove listeners clean up once on failed startup and repeated stop. diff --git a/src/main/agent-hooks/claude-cancel-capture.test-fixture.ts b/src/main/agent-hooks/claude-cancel-capture.test-fixture.ts index f36a2323b572..8d4753aa4f87 100644 --- a/src/main/agent-hooks/claude-cancel-capture.test-fixture.ts +++ b/src/main/agent-hooks/claude-cancel-capture.test-fixture.ts @@ -29,12 +29,14 @@ export type CapturedCancel = { hooks_before_next_typed_prompt: number[] } export type CapturedKill = { kind: 'kill'; t: number; needle: string } -/** Raw `system`/`agents_killed` lines found in the CLI's own session transcript at this instant. */ +/** Raw scrubbed lines the CLI appended to its own session transcript since the previous + * transcript record, in file order; a replay writes them at this position. The idle Ctrl+C + * captures kept only `system`/`agents_killed` lines. */ export type CapturedTranscriptScan = { kind: 'transcript' t: number label: string - agents_killed_records: string[] + lines: string[] } export type CapturedRecord = CapturedHook | CapturedCancel | CapturedKill | CapturedTranscriptScan diff --git a/src/main/agent-hooks/server-claude-idle-ctrl-c-captures.test.ts b/src/main/agent-hooks/server-claude-idle-ctrl-c-captures.test.ts index ef06a707bcde..f80cc18e1825 100644 --- a/src/main/agent-hooks/server-claude-idle-ctrl-c-captures.test.ts +++ b/src/main/agent-hooks/server-claude-idle-ctrl-c-captures.test.ts @@ -79,7 +79,7 @@ function row(server: AgentHookServer) { } function watch(server: AgentHookServer) { - return server._getStateForTests().claudeAgentsKilledCursorByPaneKey.get(PANE) + return server._getStateForTests().claudeTranscriptCursorByPaneKey.get(PANE) } /** A capture replayed on its own clock, with every hook reporting `transcript` as its session file. */ @@ -106,10 +106,10 @@ function replayer( function capturedKill(records: CapturedRecord[], cancelLabel: string, scanLabel: string) { const cancel = cancelLabelled(records, cancelLabel) const scan = records.find((record) => record.kind === 'transcript' && record.label === scanLabel) - if (scan?.kind !== 'transcript' || scan.agents_killed_records.length !== 1) { + if (scan?.kind !== 'transcript' || scan.lines.length !== 1) { throw new Error(`Captured transcript scan ${scanLabel} has no single agents_killed line`) } - const line = scan.agents_killed_records[0] + const line = scan.lines[0] // JSON.parse returns any; the subtype assertion is the shape proof. const parsed: Record = JSON.parse(line) expect(parsed).toMatchObject({ type: 'system', subtype: 'agents_killed' }) @@ -193,8 +193,8 @@ describe('an idle-prompt Ctrl+C with a background shell and a background agent ( }) expect(row(server).interrupted).toBeUndefined() expect(row(server).mainAgent).not.toHaveProperty('outcome') - // Nothing is left to watch for. - expect(watch(server)).toBeUndefined() + // Nothing is left to watch for: the watch disarms after the next read finds nothing. + await vi.waitFor(() => expect(watch(server)).toBeUndefined(), { timeout: 3_000 }) // The next typed turn's Stop restates what the CLI now knows: the shell alone. await replay([11, 12]) @@ -389,7 +389,8 @@ describe('an idle-prompt Ctrl+C after the background agent already finished (cap expect(hookAt(records, 8).payload.hook_event_name).toBe('SubagentStop') await replay([8, 9, 10]) expect(row(server)).toMatchObject({ state: 'done', mainAgent: { state: 'done' } }) - expect(watch(server)).toBeUndefined() + // The watch disarms only after a read, so the last one still serves the ended reason. + await vi.waitFor(() => expect(watch(server)).toBeUndefined(), { timeout: 3_000 }) const settled = row(server) expect(pressCtrlC(server)).toBe(false) diff --git a/src/main/agent-hooks/server-claude-transcript-watch.test.ts b/src/main/agent-hooks/server-claude-transcript-watch.test.ts new file mode 100644 index 000000000000..db654bf07194 --- /dev/null +++ b/src/main/agent-hooks/server-claude-transcript-watch.test.ts @@ -0,0 +1,371 @@ +// The Claude transcript watch's machinery on the desktop host, through the server's own HTTP +// ingress. Built from the Claude Code 2.1.280 idle Ctrl+C capture +// (src/shared/__fixtures__/claude-idle-ctrl-c-bg-agent-hooks.jsonl): its hook bodies and its one +// captured `agents_killed` transcript line. Bodies marked "hand-built" are captured bodies with +// fields changed, or events placed at times the capture did not record; each test says which. +import { appendFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AgentHookServer, _internals } from './server' +import { buildBody, PANE, postHookEvent } from './server.test-fixtures' +import { cancelLabelled, hookAt, loadCapture } from './claude-cancel-capture.test-fixture' + +const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ + getCohortAtEmitMock: vi.fn(), + trackMock: vi.fn() +})) + +vi.mock('../telemetry/client', () => ({ track: trackMock })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: getCohortAtEmitMock })) + +const records = loadCapture('claude-idle-ctrl-c-bg-agent-hooks') +const scan = records.find((record) => record.kind === 'transcript') +const KILL_LINE = scan?.kind === 'transcript' ? scan.lines[0] : undefined +if (!KILL_LINE) { + throw new Error('the capture has no agents_killed line') +} +// JSON.parse returns any; Date.parse checks the one field read. +const KILL_ROW: Record = JSON.parse(KILL_LINE) +const KILLED_AT = Date.parse(String(KILL_ROW.timestamp)) +const T0 = KILLED_AT - cancelLabelled(records, 'CTRL-C-idle-with-bg-shell-and-bg-agent').t * 1000 +const CHILD = 'a2303994f3dfae83c' +/** Enough real time for the watch to have ticked at least twice. */ +const TWO_TICKS_MS = 2_300 + +const temporaryPaths: string[] = [] +const running: AgentHookServer[] = [] + +beforeEach(() => { + _internals.resetCachesForTests() + trackMock.mockReset() + getCohortAtEmitMock.mockReset() + getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) + // Why: the roster and the cancel latch read Date; the watch's ticks stay on real timers. + vi.useFakeTimers({ toFake: ['Date'] }) +}) + +afterEach(() => { + for (const server of running.splice(0)) { + server.stop() + } + for (const path of temporaryPaths.splice(0)) { + rmSync(path, { recursive: true, force: true }) + } + vi.useRealTimers() + vi.restoreAllMocks() +}) + +function transcriptFile(content = ''): string { + const dir = mkdtempSync(join(tmpdir(), 'orca-claude-transcript-watch-')) + temporaryPaths.push(dir) + const path = join(dir, 'session.jsonl') + writeFileSync(path, content) + return path +} + +async function startServer(): Promise { + const server = new AgentHookServer() + running.push(server) + await server.start({ env: 'production' }) + return server +} + +function row(server: AgentHookServer) { + const entry = server.getStatusSnapshotForPane(PANE)[0] + if (!entry) { + throw new Error('the pane has no row') + } + return entry +} + +function cursor(server: AgentHookServer) { + return server._getStateForTests().claudeTranscriptCursorByPaneKey.get(PANE) +} + +async function post(server: AgentHookServer, payload: Record): Promise { + await expect(postHookEvent(server, buildBody(payload))).resolves.toMatchObject({ status: 204 }) +} + +/** Captured hooks on the capture's clock, each naming `transcript` as its session file. */ +async function replay(server: AgentHookServer, transcript: string, indices: number[]) { + for (const index of indices) { + const hook = hookAt(records, index) + vi.setSystemTime(T0 + hook.t * 1000) + await post(server, { ...hook.payload, transcript_path: transcript }) + } +} + +function pressCtrlC(server: AgentHookServer): boolean { + const baseline = row(server) + return server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: baseline.prompt, + baselineAgentType: 'claude', + intent: 'ctrl-c' + }) +} + +/** Only Date is faked, so this waits on the same real timers the watch ticks on. */ +function realDelay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +/** Ctrl+C while the captured second turn runs, with its background agent working. */ +async function cancelledWithWorkingChild(server: AgentHookServer, transcript: string) { + await replay(server, transcript, [0, 1, 2, 3, 4, 5, 6, 7, 8]) + expect(pressCtrlC(server)).toBe(true) + expect(row(server)).toMatchObject({ + state: 'working', + mainAgent: { state: 'done', outcome: 'cancellation' }, + subagents: [expect.objectContaining({ id: CHILD, state: 'working' })] + }) + return row(server) +} + +/** Hand-built timing: the captured main-agent PostToolUse, delivered again after the Ctrl+C. */ +function lateMainAgentPostToolUse(transcript: string): Record { + return { ...hookAt(records, 7).payload, transcript_path: transcript } +} + +describe('catch-up before every Claude event', () => { + it("applies a fact the transcript already holds before the next hook, so that hook's own row shows it", async () => { + const server = await startServer() + const transcript = transcriptFile() + await replay(server, transcript, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) + expect(row(server).subagents).toEqual([ + expect.objectContaining({ id: CHILD, state: 'working' }) + ]) + // Hand-built timing: the next typed prompt lands before any tick could read the kill line. + vi.setSystemTime(KILLED_AT + 120) + appendFileSync(transcript, `${KILL_LINE}\n`) + await replay(server, transcript, [11]) + expect(row(server)).toMatchObject({ state: 'working', mainAgent: { state: 'working' } }) + expect(row(server).subagents).toBeUndefined() + }) + + it('publishes a fact read before an event that produced no row, on the next tick', async () => { + const server = await startServer() + const transcript = transcriptFile() + await replay(server, transcript, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) + vi.setSystemTime(KILLED_AT + 120) + appendFileSync(transcript, `${KILL_LINE}\n`) + // Hand-built: an auto compact's PostCompact, which maps to no row. + await post(server, { + ...hookAt(records, 9).payload, + transcript_path: transcript, + hook_event_name: 'PostCompact', + trigger: 'auto', + background_tasks: undefined + }) + expect(row(server).subagents).toEqual([ + expect.objectContaining({ id: CHILD, state: 'working' }) + ]) + expect(cursor(server)?.offset).toBe(KILL_LINE.length + 1) + await vi.waitFor(() => expect(row(server).subagents).toBeUndefined(), { timeout: 3_000 }) + expect(row(server)).toMatchObject({ + state: 'working', + workingMode: 'monitoring', + mainAgent: { state: 'done' } + }) + }) +}) + +describe('the tick against the cancel latch (W1)', () => { + it('keeps the cancel after the latch window when the latch held a late main-agent hook', async () => { + const server = await startServer() + const transcript = transcriptFile() + const cancelled = await cancelledWithWorkingChild(server, transcript) + vi.setSystemTime(Date.now() + 100) + await post(server, lateMainAgentPostToolUse(transcript)) + // The latch holds tool progress after the cancel. + expect(row(server)).toEqual(cancelled) + // Past the 15 s window, the watch keeps ticking while the child works. + vi.setSystemTime(Date.now() + 20_000) + await realDelay(TWO_TICKS_MS) + expect(row(server)).toMatchObject({ + state: 'working', + mainAgent: { state: 'done', outcome: 'cancellation' }, + subagents: [expect.objectContaining({ id: CHILD, state: 'working' })] + }) + expect(server._getStateForTests().claudeLeadStateByPaneKey.get(PANE)).toMatchObject({ + state: 'done', + outcome: 'cancellation', + stateStartedAt: cancelled.mainAgent?.stateStartedAt + }) + }) +}) + +describe('who may move the cursor', () => { + it('ignores the transcript path of an event the store refused', async () => { + const server = await startServer() + const transcript = transcriptFile() + await cancelledWithWorkingChild(server, transcript) + // Hand-built: a late main-agent hook naming another transcript; the latch refuses it. + const other = transcriptFile() + vi.setSystemTime(Date.now() + 100) + await post(server, lateMainAgentPostToolUse(other)) + expect(cursor(server)?.filePath).toBe(transcript) + vi.setSystemTime(KILLED_AT + 120) + appendFileSync(transcript, `${KILL_LINE}\n`) + await vi.waitFor(() => expect(row(server).subagents).toBeUndefined(), { timeout: 3_000 }) + expect(row(server)).toMatchObject({ mainAgent: { state: 'done', outcome: 'cancellation' } }) + }) + + it('follows an accepted nested `claude -p` to its own file and misses the outer rows meanwhile (W2)', async () => { + const server = await startServer() + const transcript = transcriptFile() + await replay(server, transcript, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) + // Hand-built: a nested `claude -p` in the pane inherits its key; its subagent's start is + // accepted and names the nested session's own transcript. + const nested = transcriptFile() + await post(server, { + ...hookAt(records, 8).payload, + session_id: '00000000-0000-4000-8000-0000000000aa', + transcript_path: nested, + agent_id: 'anested00000000001' + }) + expect(cursor(server)?.filePath).toBe(nested) + vi.setSystemTime(KILLED_AT + 120) + appendFileSync(transcript, `${KILL_LINE}\n`) + await realDelay(TWO_TICKS_MS) + // The outer session's own next event repoints at the outer file's end, past the kill line. + await replay(server, transcript, [11]) + await realDelay(TWO_TICKS_MS) + // Lingers, the safe direction: the killed child still reads working until an inventory. + expect(row(server).subagents).toEqual( + expect.arrayContaining([expect.objectContaining({ id: CHILD, state: 'working' })]) + ) + }) +}) + +describe('a session change on the pane', () => { + it('repoints to the new session file at /clear, and disarms only after a read', async () => { + const server = await startServer() + const transcript = transcriptFile() + await replay(server, transcript, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) + // Hand-built: /clear's SessionStart (source `clear`) for a new session and its own file. + const cleared = transcriptFile('{"type":"user","copied":true}\n') + await post(server, { + ...hookAt(records, 0).payload, + session_id: '00000000-0000-4000-8000-0000000000c1', + transcript_path: cleared, + source: 'clear' + }) + // The session change moves the cursor and keeps what it watched for; the records' own + // SessionStart reset then ends this reason, which only the next read acts on. + expect(cursor(server)).toMatchObject({ + filePath: cleared, + offset: '{"type":"user","copied":true}\n'.length, + reasons: new Set(['agent-child-working']) + }) + await vi.waitFor(() => expect(cursor(server)).toBeUndefined(), { timeout: 3_000 }) + }) +}) + +describe('the tick against the permission hold (W3, main without #22042)', () => { + it("re-offers nothing visible while the store keeps a main agent's permission card", async () => { + const server = await startServer() + const transcript = transcriptFile() + await replay(server, transcript, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) + // Hand-built: a third turn asks permission for one call while a parallel sibling completes. + const base = { ...hookAt(records, 6).payload, transcript_path: transcript } + const turn = { prompt_id: '00000000-0000-4000-8000-0000000000b1' } + const gated = { tool_name: 'Bash', tool_input: { command: 'rm -rf build' } } + await post(server, { ...base, ...turn, hook_event_name: 'UserPromptSubmit', prompt: 'clean' }) + await post(server, { + ...base, + ...turn, + ...gated, + hook_event_name: 'PreToolUse', + tool_use_id: 'toolu_gated' + }) + await post(server, { + ...base, + ...turn, + ...gated, + hook_event_name: 'PermissionRequest', + tool_use_id: undefined + }) + await post(server, { + ...base, + ...turn, + hook_event_name: 'PostToolUse', + tool_name: 'Read', + tool_input: { file_path: 'README.md' }, + tool_use_id: 'toolu_sibling' + }) + const held = row(server) + expect(held).toMatchObject({ state: 'waiting', toolName: 'Bash' }) + // The listener's record says the main agent works; the store keeps the card. + expect(server._getStateForTests().claudeLeadStateByPaneKey.get(PANE)?.state).toBe('working') + vi.setSystemTime(Date.now() + 20_000) + await realDelay(TWO_TICKS_MS) + expect(cursor(server)).toBeDefined() + expect(row(server)).toEqual(held) + }) +}) + +describe('other writers of the stored row, on an armed pane', () => { + it('leaves an inferred question answer as it published it', async () => { + const server = await startServer() + const transcript = transcriptFile() + await replay(server, transcript, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) + // Hand-built: a third turn asks an AskUserQuestion, answered by a typed Escape. + const base = { ...hookAt(records, 6).payload, transcript_path: transcript } + await post(server, { + ...base, + prompt_id: 'p-q', + hook_event_name: 'UserPromptSubmit', + prompt: 'ask' + }) + await post(server, { + ...base, + prompt_id: 'p-q', + hook_event_name: 'PreToolUse', + tool_name: 'AskUserQuestion', + tool_input: { questions: [{ question: 'Which?' }] }, + tool_use_id: 'toolu_q' + }) + expect(row(server)).toMatchObject({ state: 'waiting', toolName: 'AskUserQuestion' }) + const baseline = row(server) + expect( + server.inferQuestionAnswered({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: baseline.prompt, + baselineAgentType: 'claude' + }) + ).toBe(true) + const answered = row(server) + await realDelay(TWO_TICKS_MS) + expect(row(server)).toEqual(answered) + }) + + it('restates the children an OSC repaint dropped, within a tick instead of at the next hook', async () => { + // The one census writer whose row the watch changes; Claude reports by HTTP hooks, so this + // needs something else in the pane to print an OSC 9999 status. + const server = await startServer() + const transcript = transcriptFile() + await replay(server, transcript, [0, 1, 2, 3, 4, 5, 6, 7, 8]) + server.ingestTerminalStatus({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: null, + payload: { state: 'working', prompt: 'osc', agentType: 'claude' } + }) + expect(row(server).subagents).toBeUndefined() + await vi.waitFor( + () => + expect(row(server).subagents).toEqual([ + expect.objectContaining({ id: CHILD, state: 'working' }) + ]), + { timeout: 3_000 } + ) + expect(row(server)).toMatchObject({ state: 'working', mainAgent: { state: 'working' } }) + }) +}) diff --git a/src/main/agent-hooks/server-relayed-claude-cancel.test.ts b/src/main/agent-hooks/server-relayed-claude-cancel.test.ts index f045ca368547..3c3805747fa7 100644 --- a/src/main/agent-hooks/server-relayed-claude-cancel.test.ts +++ b/src/main/agent-hooks/server-relayed-claude-cancel.test.ts @@ -407,7 +407,7 @@ it('keeps a relayed waiting child visible when the main agent is cancelled', () describe('a relayed idle-prompt Ctrl+C that killed a background agent (captured)', () => { const records = loadCapture('claude-idle-ctrl-c-bg-agent-hooks') const scan = records.find((record) => record.kind === 'transcript') - const killedLine = scan?.kind === 'transcript' ? scan.agents_killed_records[0] : undefined + const killedLine = scan?.kind === 'transcript' ? scan.lines[0] : undefined if (!killedLine) { throw new Error('the capture has no agents_killed line') } @@ -531,6 +531,18 @@ describe('a relayed idle-prompt Ctrl+C that killed a background agent (captured) expect(row(pane.desktop).subagents).toBeUndefined() }) + it('catches up on the relay before it normalizes the next hook', async () => { + const pane = await startSshPane(new AgentHookServer()) + const transcript = transcriptFile() + await replay(pane, transcript, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) + // Hand-built timing: the next typed prompt reaches the relay before any tick read the line. + vi.setSystemTime(killedAt + 120) + appendFileSync(transcript, `${killedLine}\n`) + await replay(pane, transcript, [11]) + expect(row(pane.desktop)).toMatchObject({ state: 'working', mainAgent: { state: 'working' } }) + expect(row(pane.desktop).subagents).toBeUndefined() + }) + it('restores no killed child when the desktop restarts', async () => { const userDataPath = temporaryDir('orca-relayed-agents-killed-restart-') const firstDesktop = new AgentHookServer() diff --git a/src/main/agent-hooks/server/server-ingest-normalization.ts b/src/main/agent-hooks/server/server-ingest-normalization.ts index 171a15ac5cc8..8098da1e4de0 100644 --- a/src/main/agent-hooks/server/server-ingest-normalization.ts +++ b/src/main/agent-hooks/server/server-ingest-normalization.ts @@ -1,6 +1,7 @@ import { buildSpoolHookBody, type SpoolRecord } from '../../../shared/agent-hook-spool' import { normalizeHookPayload } from '../../../shared/agent-hook-listener' import { isAgentHookSource, type AgentHookSource } from '../../../shared/agent-hook-relay' +import { catchUpOnClaudeTranscript } from '../../../shared/agent-hook-listener/providers/claude-transcript-watch' import type { NormalizedLocalHook } from './server-types' import { AgentHookServerOpenCodeBinder } from './server-opencode-binder' @@ -40,6 +41,9 @@ export abstract class AgentHookServerIngestNormalization extends AgentHookServer if (!paneKey) { return { event: normalizeHookPayload(this.state, source, body, this.env) } } + // Why before the snapshot: a fact the cursor has passed must not roll back with an event the + // store then refuses. It only reads an existing cursor; spool replays after a restart find none. + catchUpOnClaudeTranscript(this.state, paneKey) const previousRunningTask = this.state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) const previousActiveCron = this.state.claudeActiveSessionCronPaneKeys.has(paneKey) const event = normalizeHookPayload(this.state, source, body, this.env) diff --git a/src/main/agent-hooks/server/server-state.ts b/src/main/agent-hooks/server/server-state.ts index 8f006ba39b84..8ba89b9663bb 100644 --- a/src/main/agent-hooks/server/server-state.ts +++ b/src/main/agent-hooks/server/server-state.ts @@ -218,7 +218,7 @@ export abstract class AgentHookServerState { protected abstract scheduleTranscriptPoll( source: AgentHookSource, body: unknown, - original: AgentHookEventPayload + original: EnrichedAgentHookEventPayload ): void protected abstract scheduleAssistantMessageRetry( source: AgentHookSource, diff --git a/src/main/agent-hooks/server/server-status-retries.ts b/src/main/agent-hooks/server/server-status-retries.ts index 2e98d04a86cb..5894c009f20b 100644 --- a/src/main/agent-hooks/server/server-status-retries.ts +++ b/src/main/agent-hooks/server/server-status-retries.ts @@ -1,15 +1,15 @@ +import { normalizeHookPayload } from '../../../shared/agent-hook-listener' import { hasPendingAgentResultText, preparePendingGrokResultDiscovery } from '../../../shared/agent-hook-listener/grok-result-discovery' import type { AgentHookSource } from '../../../shared/agent-hook-relay' -import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' import { - hookTranscriptPollUpdate, shouldPollHookTranscript, - transcriptPollAnchor + transcriptPollUpdate } from '../../../shared/agent-hook-listener/transcript-poll-policy' import { CodexSubagentPollScheduler } from '../../../shared/codex-subagent-poll-scheduler' +import { ClaudeTranscriptWatch } from '../../../shared/agent-hook-listener/claude-transcript-watch-ticks' import type { EnrichedAgentHookEventPayload } from './server-types' import { ASSISTANT_MESSAGE_RETRY_ATTEMPTS, @@ -21,7 +21,7 @@ import { AgentHookServerStatusUpdate } from './server-status-update' type TranscriptPoll = { source: AgentHookSource body: unknown - original: AgentHookEventPayload + original: EnrichedAgentHookEventPayload } export abstract class AgentHookServerStatusRetries extends AgentHookServerStatusUpdate { @@ -29,9 +29,17 @@ export abstract class AgentHookServerStatusRetries extends AgentHookServerStatus CODEX_SUBAGENT_POLL_MS, (paneKey, poll) => this.runTranscriptPoll(paneKey, poll) ) + private readonly claudeTranscriptWatch = new ClaudeTranscriptWatch({ + state: this.state, + isListening: () => this.server !== null, + publish: (row) => { + this.applyNormalizedStatus(row) + } + }) protected clearAllTranscriptPolls(): void { this.transcriptPollScheduler.clearAll() + this.claudeTranscriptWatch.clearAll() } protected clearAssistantMessageRetry(paneKey: string): void { @@ -45,16 +53,21 @@ export abstract class AgentHookServerStatusRetries extends AgentHookServerStatus protected clearTranscriptPoll(paneKey: string): void { this.transcriptPollScheduler.clear(paneKey) - this.state.claudeAgentsKilledCursorByPaneKey.delete(paneKey) + this.claudeTranscriptWatch.clear(paneKey) } protected scheduleTranscriptPoll( source: AgentHookSource, body: unknown, - original: AgentHookEventPayload + original: EnrichedAgentHookEventPayload ): void { + if (source === 'claude') { + // Why: `original` is the row the store accepted, the only row that may arm or repoint. + this.claudeTranscriptWatch.sync(original) + return + } // Why: a nested CLI of another kind inherits ORCA_PANE_KEY, so clearing here would silently end a live poll. - if (source !== 'codex' && source !== 'muse' && source !== 'claude') { + if (source !== 'codex' && source !== 'muse') { return } this.transcriptPollScheduler.clear(original.paneKey) @@ -66,19 +79,21 @@ export abstract class AgentHookServerStatusRetries extends AgentHookServerStatus private runTranscriptPoll(paneKey: string, poll: TranscriptPoll): void { const { source, body, original } = poll - const anchor = transcriptPollAnchor( - source, - this.state.lastStatusByPaneKey.get(original.paneKey), - original - ) - if (paneKey !== original.paneKey || !this.server || !anchor) { + // Keep the identity check at callback time: a newer event supersedes this + // payload even when its pane still has transcript children. + if ( + paneKey !== original.paneKey || + !this.server || + this.state.lastStatusByPaneKey.get(original.paneKey) !== original + ) { return } - const update = hookTranscriptPollUpdate(this.state, source, body, anchor, this.env) - if (update === null) { + const normalized = normalizeHookPayload(this.state, source, body, this.env) + if (!normalized) { return } - const next = update ? this.applyNormalizedStatus(update) : anchor + const update = transcriptPollUpdate(source, original, normalized) + const next = update ? this.applyNormalizedStatus(update) : original if (next) { this.scheduleTranscriptPoll(source, body, next) } diff --git a/src/main/agent-hooks/server/server-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts index a37cbb02eb44..e1a4d87767f5 100644 --- a/src/main/agent-hooks/server/server-status-update.ts +++ b/src/main/agent-hooks/server/server-status-update.ts @@ -2,6 +2,7 @@ import { reconcileRemoteCodexState, markCodexLeadTurnInterrupted } from '../../../shared/agent-hook-listener/providers/codex-state' +import { markClaudeLeadTurnInterrupted } from '../../../shared/agent-hook-listener/providers/claude-roster-state' import { resolveAgentStatusIdentity, shouldSuppressInheritedTerminalStatus @@ -162,6 +163,18 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA ) { markCodexLeadTurnInterrupted(this.state, attachedPayload.paneKey) } + // Why: a local pane's listener already folded the held event into its record; restore the + // verdict the row keeps, or the Claude transcript watch restates the stale record once the + // latch window ends. A relayed pane's record lives on the relay. + if ( + attachedPayload.payload.agentType === 'claude' && + attachedPayload.payload.state === 'working' && + attachedPayload.connectionId === null + ) { + markClaudeLeadTurnInterrupted(this.state, attachedPayload.paneKey, { + stateStartedAt: previous?.payload.mainAgent?.stateStartedAt + }) + } this.commitStatusRowMutation(rowBefore, previous) return previous } diff --git a/src/relay/agent-hook-result-retry-scheduler.ts b/src/relay/agent-hook-result-retry-scheduler.ts index c4770ece124d..515dda7068d9 100644 --- a/src/relay/agent-hook-result-retry-scheduler.ts +++ b/src/relay/agent-hook-result-retry-scheduler.ts @@ -1,7 +1,7 @@ // Deferred re-normalization timers for late-arriving agent results: the transcript hadn't caught up // when the hook fired, so re-read the same body on a timer and re-apply only if it changed. Both // timer families live in one owner so pane teardown and server stop tear both down in one ordered -// place before the listener caches are cleared. +// place before the listener caches are cleared. The Claude transcript watch is owned here too. import { hasPendingAgentResultText, preparePendingGrokResultDiscovery @@ -11,11 +11,12 @@ import type { AgentHookEventPayload } from '../shared/agent-hook-listener/listen import type { HookListenerState } from '../shared/agent-hook-listener/listener-state' import type { AgentHookSource } from '../shared/agent-hook-relay' import { - hookTranscriptPollUpdate, shouldPollHookTranscript, - transcriptPollAnchor + transcriptPollUpdate } from '../shared/agent-hook-listener/transcript-poll-policy' import { CodexSubagentPollScheduler } from '../shared/codex-subagent-poll-scheduler' +import { ClaudeTranscriptWatch } from '../shared/agent-hook-listener/claude-transcript-watch-ticks' +import { catchUpOnClaudeTranscriptForHookBody } from '../shared/agent-hook-listener/providers/claude-transcript-watch' const ASSISTANT_MESSAGE_RETRY_ATTEMPTS = 5 const ASSISTANT_MESSAGE_RETRY_MS = 50 @@ -42,9 +43,12 @@ export type AgentHookResultRetryHost = { ) => void } +type HookEnvelopeMeta = { env?: string; version?: string } + export class AgentHookResultRetryScheduler { private assistantMessageRetryTimers = new Map>() private transcriptPollScheduler: CodexSubagentPollScheduler + private claudeTranscriptWatch: ClaudeTranscriptWatch private host: AgentHookResultRetryHost constructor(host: AgentHookResultRetryHost) { @@ -53,6 +57,20 @@ export class AgentHookResultRetryScheduler { CODEX_SUBAGENT_POLL_MS, (paneKey, poll) => this.runTranscriptPoll(paneKey, poll) ) + this.claudeTranscriptWatch = new ClaudeTranscriptWatch({ + state: host.state, + isListening: host.isListening, + publish: (row, meta) => host.applyEvent(row, 'claude', meta?.env, meta?.version) + }) + } + + /** A hook body's normalization, after the pane's Claude transcript catch-up. The relay has no + * accept-or-roll-back step, so the catch-up simply runs first. */ + normalizeHook(source: AgentHookSource, body: unknown): AgentHookEventPayload | null { + catchUpOnClaudeTranscriptForHookBody(this.host.state, source, body) + return normalizeHookPayload(this.host.state, source, body, this.host.env, { + deferCompactOwnershipToClient: true + }) } clearAll(): void { @@ -61,6 +79,7 @@ export class AgentHookResultRetryScheduler { } this.assistantMessageRetryTimers.clear() this.transcriptPollScheduler.clearAll() + this.claudeTranscriptWatch.clearAll() } clearAssistantMessageRetry(paneKey: string): void { @@ -74,7 +93,19 @@ export class AgentHookResultRetryScheduler { clearTranscriptPoll(paneKey: string): void { this.transcriptPollScheduler.clear(paneKey) - this.host.state.claudeAgentsKilledCursorByPaneKey.delete(paneKey) + this.claudeTranscriptWatch.clear(paneKey) + } + + /** Called with every row the relay applied, so the watch arms from accepted rows only. */ + syncClaudeTranscriptWatch( + event: AgentHookEventPayload, + source: AgentHookSource, + env: string | undefined, + version: string | undefined + ): void { + if (source === 'claude') { + this.claudeTranscriptWatch.sync(event, { env, version }) + } } scheduleTranscriptPoll( @@ -85,7 +116,7 @@ export class AgentHookResultRetryScheduler { version?: string ): void { // Why: a nested CLI of another kind inherits ORCA_PANE_KEY, so clearing here would silently end a live poll. - if (source !== 'codex' && source !== 'muse' && source !== 'claude') { + if (source !== 'codex' && source !== 'muse') { return } this.transcriptPollScheduler.clear(original.paneKey) @@ -103,22 +134,25 @@ export class AgentHookResultRetryScheduler { private runTranscriptPoll(paneKey: string, poll: TranscriptPoll): void { const { source, body, original, env, version } = poll - const anchor = transcriptPollAnchor( - source, - this.host.state.lastStatusByPaneKey.get(original.paneKey), - original - ) - if (paneKey !== original.paneKey || !this.host.isListening() || !anchor) { + // Keep the identity check at callback time: a newer event supersedes this + // payload even when its pane still has transcript children. + if ( + paneKey !== original.paneKey || + !this.host.isListening() || + this.host.state.lastStatusByPaneKey.get(original.paneKey) !== original + ) { return } - const update = hookTranscriptPollUpdate(this.host.state, source, body, anchor, this.host.env) - if (update === null) { + const event = normalizeHookPayload(this.host.state, source, body, this.host.env) + if (!event) { return } + const update = transcriptPollUpdate(source, original, event) + const next = update ?? original if (update) { this.host.applyEvent(update, source, env, version) } - this.scheduleTranscriptPoll(source, body, update ?? anchor, env, version) + this.scheduleTranscriptPoll(source, body, next, env, version) } scheduleAssistantMessageRetry( diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index e75b11a24505..07f9104a0246 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -18,7 +18,6 @@ import { writeEndpointFile } from '../shared/agent-hook-listener/endpoint-publication' import { HOOK_REQUEST_SLOWLORIS_MS } from '../shared/agent-hook-listener/listener-limits' -import { normalizeHookPayload } from '../shared/agent-hook-listener' import { mergeAgentHookRequestHeaders } from '../shared/agent-hook-listener/hook-envelope' import { readRequestBody } from '../shared/agent-hook-listener/request-body' import { resolveHookSource } from '../shared/agent-hook-listener/source-routing' @@ -275,9 +274,7 @@ export class RelayAgentHookServer { } const body = await readRequestBody(req) const hookBody = mergeAgentHookRequestHeaders(body, req.headers) - const event = normalizeHookPayload(this.state, source, hookBody, this.env, { - deferCompactOwnershipToClient: true - }) + const event = this.retryScheduler.normalizeHook(source, hookBody) if (event) { // TODO: once normalizeHookPayload returns validated env/version, drop bodyEnv/bodyVersion and source them from the listener result. const env = hookBodyEnv(hookBody) @@ -334,6 +331,7 @@ export class RelayAgentHookServer { this.lastEnvelopeMetaByPaneKey.delete(event.paneKey) this.lastEnvelopeMetaByPaneKey.set(event.paneKey, { source, env, version }) this.forward(buildRelayHookEnvelope(event, source, env, version, options)) + this.retryScheduler.syncClaudeTranscriptWatch(event, source, env, version) } private ingestSpoolRecord(record: SpoolRecord): void { @@ -341,9 +339,7 @@ export class RelayAgentHookServer { return } const body = buildSpoolHookBody(record) - const event = normalizeHookPayload(this.state, record.source, body, this.env, { - deferCompactOwnershipToClient: true - }) + const event = this.retryScheduler.normalizeHook(record.source, body) if (!event) { return } diff --git a/src/shared/__fixtures__/claude-idle-ctrl-c-bg-agent-hooks.jsonl b/src/shared/__fixtures__/claude-idle-ctrl-c-bg-agent-hooks.jsonl index dd47f281e339..ed0dd38aa46a 100644 --- a/src/shared/__fixtures__/claude-idle-ctrl-c-bg-agent-hooks.jsonl +++ b/src/shared/__fixtures__/claude-idle-ctrl-c-bg-agent-hooks.jsonl @@ -10,8 +10,8 @@ {"t": 18.938, "kind": "hook", "index": 9, "sleep_procs": ["87670 87662 S 00:11 sleep 600"], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/idle-ctrlc-capture/scratch", "prompt_id": "00000000-0000-4000-8000-000000000002", "permission_mode": "bypassPermissions", "effort": {"level": "high"}, "hook_event_name": "Stop", "stop_hook_active": false, "last_assistant_message": "STARTED", "background_tasks": [{"id": "brm5z5kjo", "type": "shell", "status": "running", "description": "Sleep for 10 minutes in background", "command": "sleep 600"}, {"id": "a2303994f3dfae83c", "type": "subagent", "status": "running", "description": "Run foreground sleep command", "agent_type": "general-purpose"}], "session_crons": []}} {"t": 19.966, "kind": "hook", "index": 10, "sleep_procs": ["87670 87662 S 00:12 sleep 600"], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/idle-ctrlc-capture/scratch", "prompt_id": "00000000-0000-4000-8000-000000000002", "permission_mode": "bypassPermissions", "agent_id": "a2303994f3dfae83c", "agent_type": "general-purpose", "effort": {"level": "high"}, "hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": {"command": "python3 -c \"import time; time.sleep(240)\"", "timeout": 300000, "description": "Sleep for 240 seconds via Python"}, "tool_use_id": "toolu_019wFjm7s8vFmXEf9P57H6pd"}} {"t": 22.372, "kind": "cancel", "label": "CTRL-C-idle-with-bg-shell-and-bg-agent", "interrupted_painted": false, "all_bg_agents_stopped_painted": true, "exit_hint_painted": true, "hooks_before_next_typed_prompt": []} -{"t": 49.711, "kind": "transcript", "label": "after-idle-ctrl-c", "agents_killed_records": ["{\"parentUuid\":\"00000000-0000-4000-8000-000000000003\",\"isSidechain\":false,\"type\":\"system\",\"subtype\":\"agents_killed\",\"timestamp\":\"2026-09-24T19:49:48.826Z\",\"uuid\":\"00000000-0000-4000-8000-000000000004\",\"isMeta\":false,\"userType\":\"external\",\"entrypoint\":\"cli\",\"cwd\":\"/Users/uuuuuuuuuuuuu/orca-qa/idle-ctrlc-capture/scratch\",\"sessionId\":\"00000000-0000-4000-8000-000000000000\",\"version\":\"2.1.280\",\"gitBranch\":\"HEAD\"}"]} +{"t": 49.711, "kind": "transcript", "label": "after-idle-ctrl-c", "lines": ["{\"parentUuid\":\"00000000-0000-4000-8000-000000000003\",\"isSidechain\":false,\"type\":\"system\",\"subtype\":\"agents_killed\",\"timestamp\":\"2026-09-24T19:49:48.826Z\",\"uuid\":\"00000000-0000-4000-8000-000000000004\",\"isMeta\":false,\"userType\":\"external\",\"entrypoint\":\"cli\",\"cwd\":\"/Users/uuuuuuuuuuuuu/orca-qa/idle-ctrlc-capture/scratch\",\"sessionId\":\"00000000-0000-4000-8000-000000000000\",\"version\":\"2.1.280\",\"gitBranch\":\"HEAD\"}"]} {"t": 50.613, "kind": "hook", "index": 11, "sleep_procs": ["87670 87662 S 00:43 sleep 600"], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/idle-ctrlc-capture/scratch", "prompt_id": "00000000-0000-4000-8000-000000000005", "permission_mode": "bypassPermissions", "hook_event_name": "UserPromptSubmit", "prompt": "QA harness step 3: reply with exactly the word OK. Do not use any tools."}} {"t": 52.534, "kind": "hook", "index": 12, "sleep_procs": ["87670 87662 S 00:44 sleep 600"], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/idle-ctrlc-capture/scratch", "prompt_id": "00000000-0000-4000-8000-000000000005", "permission_mode": "bypassPermissions", "effort": {"level": "high"}, "hook_event_name": "Stop", "stop_hook_active": false, "last_assistant_message": "OK", "background_tasks": [{"id": "brm5z5kjo", "type": "shell", "status": "running", "description": "Sleep for 10 minutes in background", "command": "sleep 600"}], "session_crons": []}} -{"t": 55.044, "kind": "transcript", "label": "after-ok-turn", "agents_killed_records": ["{\"parentUuid\":\"00000000-0000-4000-8000-000000000003\",\"isSidechain\":false,\"type\":\"system\",\"subtype\":\"agents_killed\",\"timestamp\":\"2026-09-24T19:49:48.826Z\",\"uuid\":\"00000000-0000-4000-8000-000000000004\",\"isMeta\":false,\"userType\":\"external\",\"entrypoint\":\"cli\",\"cwd\":\"/Users/uuuuuuuuuuuuu/orca-qa/idle-ctrlc-capture/scratch\",\"sessionId\":\"00000000-0000-4000-8000-000000000000\",\"version\":\"2.1.280\",\"gitBranch\":\"HEAD\"}"]} +{"t": 55.044, "kind": "transcript", "label": "after-ok-turn", "lines": []} {"t": 58.781, "kind": "hook", "index": 13, "sleep_procs": [], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/idle-ctrlc-capture/scratch", "prompt_id": "00000000-0000-4000-8000-000000000006", "hook_event_name": "SessionEnd", "reason": "prompt_input_exit"}} diff --git a/src/shared/__fixtures__/claude-idle-ctrl-c-draft-hooks.jsonl b/src/shared/__fixtures__/claude-idle-ctrl-c-draft-hooks.jsonl index dfd5f68ef35f..43a3677713ae 100644 --- a/src/shared/__fixtures__/claude-idle-ctrl-c-draft-hooks.jsonl +++ b/src/shared/__fixtures__/claude-idle-ctrl-c-draft-hooks.jsonl @@ -11,5 +11,5 @@ {"t": 17.524, "kind": "hook", "index": 10, "sleep_procs": ["46805 46796 S 00:10 sleep 600"], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/-private-tmp-orca-claude-draft-capture-scratch/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/private/tmp/orca-claude-draft-capture/scratch", "prompt_id": "00000000-0000-4000-8000-000000000002", "permission_mode": "bypassPermissions", "agent_id": "a22fc8b2c20f56614", "agent_type": "general-purpose", "effort": {"level": "high"}, "hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": {"command": "python3 -c \"import time; time.sleep(240)\"", "timeout": 300000, "description": "Sleep for 240 seconds via Python"}, "tool_use_id": "toolu_01JuQsoRyXDouo8s3pveB6U9"}} {"t": 21.131, "kind": "cancel", "label": "CTRL-C-draft-first", "interrupted_painted": false, "all_bg_agents_stopped_painted": false, "exit_hint_painted": false, "hooks_before_next_typed_prompt": [], "draft_present": true, "ps_before": ["46805 46796 S 00:12 sleep 600", "49081 45140 Ss 00:02 /bin/zsh -c source /Users/uuuuuuuuuuuuu/.claude/shell-snapshots/snapshot-zsh-1790289370336-aps1ar.sh 2>/dev/null || true && setopt NO_EXTENDED_GLOB NO_BARE_GLOB_QUAL 2>/dev/null || true && { \\builtin unalias -- 'unsetenv'; \\builtin unset -f -- 'unsetenv'; } >/dev/null 2>&1 || true && eval 'python3 -c \"import time; time.sleep(240)\"' < /dev/null && pwd -P >| /tmp/claude-17fd-cwd", "49085 49081 S 00:02 /opt/homebrew/Cellar/python@3.14/3.14.6/Frameworks/Python.framework/Versions/3.14/Resources/Python.app/Contents/MacOS/Python -c import time; time.sleep(240)"], "ps_after": ["46805 46796 S 00:26 sleep 600"]} {"t": 33.422, "kind": "cancel", "label": "CTRL-C-draft-second", "interrupted_painted": false, "all_bg_agents_stopped_painted": false, "exit_hint_painted": false, "hooks_before_next_typed_prompt": [], "ps_before": ["46805 46796 S 00:26 sleep 600"], "ps_after": ["46805 46796 S 00:38 sleep 600"]} -{"t": 45.622, "kind": "transcript", "label": "after-draft-ctrl-c", "agents_killed_records": ["{\"parentUuid\":\"00000000-0000-4000-8000-000000000003\",\"isSidechain\":false,\"type\":\"system\",\"subtype\":\"agents_killed\",\"timestamp\":\"2026-09-24T22:36:24.036Z\",\"uuid\":\"00000000-0000-4000-8000-000000000004\",\"isMeta\":false,\"userType\":\"external\",\"entrypoint\":\"cli\",\"cwd\":\"/private/tmp/orca-claude-draft-capture/scratch\",\"sessionId\":\"00000000-0000-4000-8000-000000000000\",\"version\":\"2.1.280\",\"gitBranch\":\"HEAD\"}"]} +{"t": 45.622, "kind": "transcript", "label": "after-draft-ctrl-c", "lines": ["{\"parentUuid\":\"00000000-0000-4000-8000-000000000003\",\"isSidechain\":false,\"type\":\"system\",\"subtype\":\"agents_killed\",\"timestamp\":\"2026-09-24T22:36:24.036Z\",\"uuid\":\"00000000-0000-4000-8000-000000000004\",\"isMeta\":false,\"userType\":\"external\",\"entrypoint\":\"cli\",\"cwd\":\"/private/tmp/orca-claude-draft-capture/scratch\",\"sessionId\":\"00000000-0000-4000-8000-000000000000\",\"version\":\"2.1.280\",\"gitBranch\":\"HEAD\"}"]} {"t": 52.319, "kind": "hook", "index": 11, "sleep_procs": [], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/-private-tmp-orca-claude-draft-capture-scratch/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/private/tmp/orca-claude-draft-capture/scratch", "prompt_id": "00000000-0000-4000-8000-000000000005", "hook_event_name": "SessionEnd", "reason": "other"}} diff --git a/src/shared/__fixtures__/claude-idle-ctrl-c-finished-agent-hooks.jsonl b/src/shared/__fixtures__/claude-idle-ctrl-c-finished-agent-hooks.jsonl index 4f431d7a4c2a..6f5e5cb8ae02 100644 --- a/src/shared/__fixtures__/claude-idle-ctrl-c-finished-agent-hooks.jsonl +++ b/src/shared/__fixtures__/claude-idle-ctrl-c-finished-agent-hooks.jsonl @@ -10,7 +10,7 @@ {"t": 35.513, "kind": "hook", "index": 9, "sleep_procs": [], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/idle-ctrlc-capture/scratch", "prompt_id": "00000000-0000-4000-8000-000000000002", "permission_mode": "bypassPermissions", "hook_event_name": "UserPromptSubmit", "prompt": "\na9a1cf6abd88d6b31\ntoolu_01WkYWYyLfoEAKEhRk7fXxnu\n/private/tmp/claude-501/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000/tasks/a9a1cf6abd88d6b31.output\ncompleted\nAgent \"Run 20s sleep command\" finished\nA task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.\ndone\n26860125667\n"}} {"t": 37.854, "kind": "hook", "index": 10, "sleep_procs": [], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/idle-ctrlc-capture/scratch", "prompt_id": "00000000-0000-4000-8000-000000000002", "permission_mode": "bypassPermissions", "effort": {"level": "high"}, "hook_event_name": "Stop", "stop_hook_active": false, "last_assistant_message": "The background agent finished: it ran the 20-second sleep command and replied \"done.\"", "background_tasks": [], "session_crons": []}} {"t": 40.74, "kind": "cancel", "label": "CTRL-C-idle-after-agent-finished", "interrupted_painted": false, "all_bg_agents_stopped_painted": false, "exit_hint_painted": true, "hooks_before_next_typed_prompt": []} -{"t": 78.259, "kind": "transcript", "label": "after-idle-ctrl-c", "agents_killed_records": []} +{"t": 78.259, "kind": "transcript", "label": "after-idle-ctrl-c", "lines": []} {"t": 79.227, "kind": "hook", "index": 11, "sleep_procs": [], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/idle-ctrlc-capture/scratch", "prompt_id": "00000000-0000-4000-8000-000000000003", "permission_mode": "bypassPermissions", "hook_event_name": "UserPromptSubmit", "prompt": "QA harness step 2: reply with exactly the word OK. Do not use any tools."}} {"t": 80.856, "kind": "hook", "index": 12, "sleep_procs": [], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/idle-ctrlc-capture/scratch", "prompt_id": "00000000-0000-4000-8000-000000000003", "permission_mode": "bypassPermissions", "effort": {"level": "high"}, "hook_event_name": "Stop", "stop_hook_active": false, "last_assistant_message": "OK", "background_tasks": [], "session_crons": []}} {"t": 84.613, "kind": "hook", "index": 13, "sleep_procs": [], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/idle-ctrlc-capture/scratch", "prompt_id": "00000000-0000-4000-8000-000000000004", "hook_event_name": "SessionEnd", "reason": "prompt_input_exit"}} diff --git a/src/shared/__fixtures__/claude-idle-ctrl-c-shell-only-hooks.jsonl b/src/shared/__fixtures__/claude-idle-ctrl-c-shell-only-hooks.jsonl index 8e0124cd816b..a461d16631ce 100644 --- a/src/shared/__fixtures__/claude-idle-ctrl-c-shell-only-hooks.jsonl +++ b/src/shared/__fixtures__/claude-idle-ctrl-c-shell-only-hooks.jsonl @@ -4,7 +4,7 @@ {"t": 9.313, "kind": "hook", "index": 3, "sleep_procs": ["65902 65894 S 00:00 sleep 600"], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/idle-ctrlc-capture/scratch", "prompt_id": "00000000-0000-4000-8000-000000000001", "permission_mode": "bypassPermissions", "effort": {"level": "high"}, "hook_event_name": "PostToolUse", "tool_name": "Bash", "tool_input": {"command": "sleep 600", "description": "Sleep for 10 minutes in background", "run_in_background": true}, "tool_response": {"stdout": "", "stderr": "", "interrupted": false, "isImage": false, "noOutputExpected": false, "backgroundTaskId": "b8khup72b"}, "tool_use_id": "toolu_01NxK33Cfy7D7cs7DhcbyiAE", "duration_ms": 370}} {"t": 11.313, "kind": "hook", "index": 4, "sleep_procs": ["65902 65894 S 00:02 sleep 600"], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/idle-ctrlc-capture/scratch", "prompt_id": "00000000-0000-4000-8000-000000000001", "permission_mode": "bypassPermissions", "effort": {"level": "high"}, "hook_event_name": "Stop", "stop_hook_active": false, "last_assistant_message": "STARTED", "background_tasks": [{"id": "b8khup72b", "type": "shell", "status": "running", "description": "Sleep for 10 minutes in background", "command": "sleep 600"}], "session_crons": []}} {"t": 13.759, "kind": "cancel", "label": "CTRL-C-idle-with-bg-shell-only", "interrupted_painted": false, "all_bg_agents_stopped_painted": false, "exit_hint_painted": true, "hooks_before_next_typed_prompt": []} -{"t": 51.296, "kind": "transcript", "label": "after-idle-ctrl-c", "agents_killed_records": []} +{"t": 51.296, "kind": "transcript", "label": "after-idle-ctrl-c", "lines": []} {"t": 52.477, "kind": "hook", "index": 5, "sleep_procs": ["65902 65894 S 00:43 sleep 600"], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/idle-ctrlc-capture/scratch", "prompt_id": "00000000-0000-4000-8000-000000000002", "permission_mode": "bypassPermissions", "hook_event_name": "UserPromptSubmit", "prompt": "QA harness step 2: reply with exactly the word OK. Do not use any tools."}} {"t": 54.537, "kind": "hook", "index": 6, "sleep_procs": ["65902 65894 S 00:45 sleep 600"], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/idle-ctrlc-capture/scratch", "prompt_id": "00000000-0000-4000-8000-000000000002", "permission_mode": "bypassPermissions", "effort": {"level": "high"}, "hook_event_name": "Stop", "stop_hook_active": false, "last_assistant_message": "OK", "background_tasks": [{"id": "b8khup72b", "type": "shell", "status": "running", "description": "Sleep for 10 minutes in background", "command": "sleep 600"}], "session_crons": []}} {"t": 61.454, "kind": "hook", "index": 7, "sleep_procs": [], "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "transcript_path": "/Users/uuuuuuuuuuuuu/.claude/projects/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/00000000-0000-4000-8000-000000000000.jsonl", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/idle-ctrlc-capture/scratch", "prompt_id": "00000000-0000-4000-8000-000000000003", "hook_event_name": "SessionEnd", "reason": "prompt_input_exit"}} diff --git a/src/shared/agent-hook-listener-relay-dependency.test.ts b/src/shared/agent-hook-listener-relay-dependency.test.ts index 81151f04e8a3..27d2514cc56c 100644 --- a/src/shared/agent-hook-listener-relay-dependency.test.ts +++ b/src/shared/agent-hook-listener-relay-dependency.test.ts @@ -120,11 +120,13 @@ describe('agent hook listener relay dependency boundary', () => { expect(forbidden).toEqual([]) expect([...seeded].map((file) => file.slice(sharedRoot.length + 1)).sort()).toEqual([ 'agent-hook-listener.ts', + 'agent-hook-listener/claude-transcript-watch-ticks.ts', 'agent-hook-listener/endpoint-publication.ts', 'agent-hook-listener/grok-result-discovery.ts', 'agent-hook-listener/hook-envelope.ts', 'agent-hook-listener/listener-limits.ts', 'agent-hook-listener/listener-state.ts', + 'agent-hook-listener/providers/claude-transcript-watch.ts', 'agent-hook-listener/request-body.ts', 'agent-hook-listener/source-routing.ts', 'agent-hook-listener/transcript-poll-policy.ts' diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 73f05c1d7637..38813d671f2b 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -18,6 +18,7 @@ import { extractPromptText } from './agent-hook-listener/prompt-fields' import { normalizeProviderEvent } from './agent-hook-listener/provider-dispatch' import { hasExplicitUserPrompt } from './agent-hook-listener/provider-event-routing' import { hasExplicitAmpPrompt } from './agent-hook-listener/providers/amp-events' +import { claudeRunningNonAgentTask } from './agent-hook-listener/providers/claude-roster-state' import { resolveOpenCodeSharedServerEnvelope, trackOpenCodePaneLaunchToken @@ -201,9 +202,7 @@ export function normalizeHookPayload( toolAgentType: readString(hookPayloadRecord, 'agent_type'), ...(source === 'claude' ? { - claudeRunningNonAgentTask: - state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) || - state.claudeActiveSessionCronPaneKeys.has(paneKey) + claudeRunningNonAgentTask: claudeRunningNonAgentTask(state, paneKey) } : {}), ...(providerSession ? { providerSession } : {}), diff --git a/src/shared/agent-hook-listener/claude-transcript-watch-ticks.ts b/src/shared/agent-hook-listener/claude-transcript-watch-ticks.ts new file mode 100644 index 000000000000..d52ef32138b4 --- /dev/null +++ b/src/shared/agent-hook-listener/claude-transcript-watch-ticks.ts @@ -0,0 +1,69 @@ +// Ticks each armed Claude pane's transcript watch: one second after the pane's last event, then +// every second while armed. Owned by the host that runs the session (main for local panes, the +// relay for SSH and WSL ones), because only that host can read the transcript. +import { CodexSubagentPollScheduler } from '../codex-subagent-poll-scheduler' +import type { AgentHookEventPayload } from './listener-event' +import type { HookListenerState } from './listener-state' +import { + observeClaudeTranscript, + settleClaudeTranscriptWatch, + syncClaudeTranscriptCursor +} from './providers/claude-transcript-watch' + +const CLAUDE_TRANSCRIPT_WATCH_MS = 1_000 + +export type ClaudeTranscriptWatchHost = { + state: HookListenerState + /** Read live, so a tick armed before stop() cannot publish on a downed server. */ + isListening: () => boolean + /** `context` is what the host last handed `sync` for the pane (the relay's hook envelope). */ + publish: (row: AgentHookEventPayload, context: Context | undefined) => void +} + +export class ClaudeTranscriptWatch { + private readonly scheduler: CodexSubagentPollScheduler + + constructor(private readonly host: ClaudeTranscriptWatchHost) { + this.scheduler = new CodexSubagentPollScheduler( + CLAUDE_TRANSCRIPT_WATCH_MS, + (paneKey, context) => this.tick(paneKey, context) + ) + } + + /** Call with every row the host accepted for a pane: arms or repoints its cursor from that row + * and restarts its tick while a cursor exists. */ + sync(accepted: AgentHookEventPayload, context?: Context): void { + if (syncClaudeTranscriptCursor(this.host.state, accepted)) { + this.scheduler.schedule(accepted.paneKey, context) + } else { + this.scheduler.clear(accepted.paneKey) + } + } + + clear(paneKey: string): void { + this.scheduler.clear(paneKey) + this.host.state.claudeTranscriptCursorByPaneKey.delete(paneKey) + } + + clearAll(): void { + this.scheduler.clearAll() + this.host.state.claudeTranscriptCursorByPaneKey.clear() + } + + private tick(paneKey: string, context: Context | undefined): void { + if (!this.host.isListening()) { + return + } + const observation = observeClaudeTranscript(this.host.state, paneKey) + if (observation.kind === 'stop') { + return + } + if (observation.row) { + this.host.publish(observation.row, context) + } + // Why after publishing: arming is re-derived from the records the publish left. + if (settleClaudeTranscriptWatch(this.host.state, paneKey, observation.factApplied)) { + this.scheduler.schedule(paneKey, context) + } + } +} diff --git a/src/shared/agent-hook-listener/listener-state.ts b/src/shared/agent-hook-listener/listener-state.ts index 1f6153e7af91..2523a97fc1d5 100644 --- a/src/shared/agent-hook-listener/listener-state.ts +++ b/src/shared/agent-hook-listener/listener-state.ts @@ -11,8 +11,8 @@ import { } from '../agent-status-legacy-adapter' import type { AgentStatusLegacyIngressCaller } from '../agent-status-legacy-ingress-manifest' import type { ClaudeSubagentRoster } from '../claude-subagent-roster' -import type { JsonlCursor } from '../codex-rollout-jsonl-cursor' import type { CodexSubagentRoster } from '../codex-subagent-roster' +import type { ClaudeTranscriptCursor } from './providers/claude-transcript-watch' import type { CodexSubagentTranscriptState } from '../codex-subagent-transcript' import type { MuseSessionLogState } from '../muse-session-log' import type { AgentHookEventPayload, ToolSnapshot } from './listener-event' @@ -48,9 +48,9 @@ export type HookListenerState = { * conversation was replaced (/clear, relaunch, resume), so claims the old session owned are void * even when no SessionStart arrives — the backstop for the exits that emit no terminating hook. */ claudeSessionOwnerByPaneKey: Map - /** Claude session transcript read from where it ended when armed, only while the pane has working - * agent children; its `agents_killed` record is the CLI's only report that it killed them. */ - claudeAgentsKilledCursorByPaneKey: Map + /** One Claude transcript cursor per pane, read from where the file ended when armed, while any + * reason to watch holds (claude-transcript-watch.ts); in memory only. */ + claudeTranscriptCursorByPaneKey: Map /** Live thread-spawn children per Codex pane. */ codexSubagentRosterByPaneKey: Map /** Incremental parent/child rollout cursors for Codex collaboration v2. */ @@ -116,7 +116,7 @@ export function createHookListenerState( claudeActiveSessionCronPaneKeys: new Set(), claudeConsumedCompactPromptIdByPaneKey: new Map(), claudeSessionOwnerByPaneKey: new Map(), - claudeAgentsKilledCursorByPaneKey: new Map(), + claudeTranscriptCursorByPaneKey: new Map(), codexSubagentRosterByPaneKey: new Map(), codexSubagentTranscriptByPaneKey: new Map(), codexLeadStateByPaneKey: new Map(), @@ -207,7 +207,7 @@ export function clearPaneCacheState(state: HookListenerState, paneKey: string): state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) state.claudeActiveSessionCronPaneKeys.delete(paneKey) state.claudeSessionOwnerByPaneKey.delete(paneKey) - state.claudeAgentsKilledCursorByPaneKey.delete(paneKey) + state.claudeTranscriptCursorByPaneKey.delete(paneKey) state.codexSubagentRosterByPaneKey.delete(paneKey) state.codexSubagentTranscriptByPaneKey.delete(paneKey) state.codexLeadStateByPaneKey.delete(paneKey) @@ -286,7 +286,7 @@ export function movePaneCacheState( movePaneScopedSetEntries(state.claudeRunningNonAgentTaskPaneKeys, fromPaneKey, toPaneKey) movePaneScopedSetEntries(state.claudeActiveSessionCronPaneKeys, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.claudeSessionOwnerByPaneKey, fromPaneKey, toPaneKey) - movePaneScopedMapEntries(state.claudeAgentsKilledCursorByPaneKey, fromPaneKey, toPaneKey) + movePaneScopedMapEntries(state.claudeTranscriptCursorByPaneKey, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.codexSubagentRosterByPaneKey, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.codexSubagentTranscriptByPaneKey, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.codexLeadStateByPaneKey, fromPaneKey, toPaneKey) @@ -341,7 +341,7 @@ export function clearAllListenerCaches(state: HookListenerState): void { state.claudeRunningNonAgentTaskPaneKeys.clear() state.claudeActiveSessionCronPaneKeys.clear() state.claudeSessionOwnerByPaneKey.clear() - state.claudeAgentsKilledCursorByPaneKey.clear() + state.claudeTranscriptCursorByPaneKey.clear() state.codexSubagentRosterByPaneKey.clear() state.codexSubagentTranscriptByPaneKey.clear() state.codexLeadStateByPaneKey.clear() diff --git a/src/shared/agent-hook-listener/providers/claude-agents-killed-transcript.test.ts b/src/shared/agent-hook-listener/providers/claude-agents-killed-transcript.test.ts deleted file mode 100644 index 3f0f536509e4..000000000000 --- a/src/shared/agent-hook-listener/providers/claude-agents-killed-transcript.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { appendFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { upsertWorkingClaudeSubagent } from '../../claude-subagent-roster' -import type { AgentHookEventPayload } from '../listener-event' -import { createHookListenerState, type HookListenerState } from '../listener-state' -import { - claudeTranscriptWatchPath, - pollClaudeAgentsKilled, - syncClaudeAgentsKilledWatch -} from './claude-agents-killed-transcript' -import { getOrCreateClaudeSubagentRoster, setClaudeMainAgentTurnState } from './claude-roster-state' - -const PANE = 'tab-1:11111111-1111-4111-8111-111111111111' -const KILLED = '{"type":"system","subtype":"agents_killed","timestamp":"2026-09-24T19:49:48.826Z"}' -const KILLED_AT = Date.parse('2026-09-24T19:49:48.826Z') -const temporaryPaths: string[] = [] - -afterEach(() => { - for (const path of temporaryPaths.splice(0)) { - rmSync(path, { recursive: true, force: true }) - } -}) - -function transcriptFile(content = ''): string { - const dir = mkdtempSync(join(tmpdir(), 'orca-agents-killed-unit-')) - temporaryPaths.push(dir) - const path = join(dir, 'session.jsonl') - writeFileSync(path, content) - return path -} - -function anchor(transcriptPath: string | undefined, connectionId: string | null = null) { - const event: AgentHookEventPayload = { - paneKey: PANE, - source: 'claude', - launchToken: 'launch-1', - tabId: 'tab-1', - worktreeId: 'wt-1', - connectionId, - ...(transcriptPath - ? { providerSession: { key: 'session_id', id: 'session-1', transcriptPath } } - : {}), - payload: { state: 'working', prompt: 'go', agentType: 'claude' } - } - return event -} - -function paneWithWorkingChild(startedAt = KILLED_AT - 5_000): HookListenerState { - const state = createHookListenerState() - setClaudeMainAgentTurnState(state, PANE, { state: 'done', turnCompletedAt: KILLED_AT - 6_000 }) - upsertWorkingClaudeSubagent(getOrCreateClaudeSubagentRoster(state, PANE), 'a1', {}, startedAt) - return state -} - -describe('claudeTranscriptWatchPath', () => { - it('watches an absolute path on a POSIX host', () => { - expect(claudeTranscriptWatchPath('/home/u/.claude/projects/p/s.jsonl', 'linux')).toBe( - '/home/u/.claude/projects/p/s.jsonl' - ) - expect(claudeTranscriptWatchPath('projects/s.jsonl', 'darwin')).toBeUndefined() - }) - - it('watches only a local drive path on Windows', () => { - expect(claudeTranscriptWatchPath('C:\\Users\\u\\.claude\\s.jsonl', 'win32')).toBe( - 'C:\\Users\\u\\.claude\\s.jsonl' - ) - // A WSL guest path, its UNC form, and a share are the guest relay's or unwatchable. - expect(claudeTranscriptWatchPath('/home/u/.claude/s.jsonl', 'win32')).toBeUndefined() - expect( - claudeTranscriptWatchPath('\\\\wsl.localhost\\Ubuntu\\home\\u\\s.jsonl', 'win32') - ).toBeUndefined() - expect(claudeTranscriptWatchPath('\\\\server\\share\\s.jsonl', 'win32')).toBeUndefined() - expect(claudeTranscriptWatchPath('C:s.jsonl', 'win32')).toBeUndefined() - }) -}) - -describe('syncClaudeAgentsKilledWatch', () => { - it('arms at the end of the transcript while an agent child works', () => { - const transcript = transcriptFile(`${KILLED}\n`) - const state = paneWithWorkingChild() - expect(syncClaudeAgentsKilledWatch(state, anchor(transcript))).toBe(true) - expect(state.claudeAgentsKilledCursorByPaneKey.get(PANE)).toEqual({ - filePath: transcript, - offset: KILLED.length + 1, - carry: '' - }) - // A row that reports no transcript keeps the armed one. - expect(syncClaudeAgentsKilledWatch(state, anchor(undefined))).toBe(true) - }) - - it("never watches a relayed row: the session's own host does", () => { - const state = paneWithWorkingChild() - expect(syncClaudeAgentsKilledWatch(state, anchor(transcriptFile(), 'conn-1'))).toBe(false) - expect(state.claudeAgentsKilledCursorByPaneKey.has(PANE)).toBe(false) - }) - - it('disarms without a working agent child or a readable transcript', () => { - const transcript = transcriptFile() - const state = paneWithWorkingChild() - expect(syncClaudeAgentsKilledWatch(state, anchor(transcript))).toBe(true) - expect(syncClaudeAgentsKilledWatch(state, anchor(join(transcript, 'missing')))).toBe(false) - expect(state.claudeAgentsKilledCursorByPaneKey.has(PANE)).toBe(false) - state.claudeSubagentRosterByPaneKey.delete(PANE) - expect(syncClaudeAgentsKilledWatch(state, anchor(transcript))).toBe(false) - }) -}) - -describe('pollClaudeAgentsKilled', () => { - it("publishes the retirement as the waiting child's own stop and restores its wait", () => { - const transcript = transcriptFile() - const state = paneWithWorkingChild() - upsertWorkingClaudeSubagent( - getOrCreateClaudeSubagentRoster(state, PANE), - 'a2', - {}, - KILLED_AT - 1_000 - ) - const settled = state.claudeLeadStateByPaneKey.get(PANE) - setClaudeMainAgentTurnState(state, PANE, { - state: 'waiting', - waitingAgentId: 'a2', - stateBeforeWait: settled - }) - expect(syncClaudeAgentsKilledWatch(state, anchor(transcript))).toBe(true) - appendFileSync(transcript, `${KILLED}\n`) - - expect(pollClaudeAgentsKilled(state, anchor(transcript))).toMatchObject({ - hookEventName: 'SubagentStop', - toolAgentId: 'a2', - launchToken: 'launch-1', - connectionId: null, - claudeRunningNonAgentTask: false, - payload: { state: 'done', mainAgent: { state: 'done' } } - }) - expect(state.claudeSubagentRosterByPaneKey.has(PANE)).toBe(false) - expect(state.claudeLeadStateByPaneKey.get(PANE)).toMatchObject({ state: 'done' }) - }) - - it('counts a line with an unreadable timestamp against every child tracked by then', () => { - const transcript = transcriptFile() - const state = paneWithWorkingChild(Date.now()) - syncClaudeAgentsKilledWatch(state, anchor(transcript)) - appendFileSync(transcript, '{"type":"system","subtype":"agents_killed"}\n') - expect(pollClaudeAgentsKilled(state, anchor(transcript))).toMatchObject({ toolAgentId: 'a1' }) - }) - - it('retires the children but wakes no main agent it never saw', () => { - const transcript = transcriptFile() - const state = paneWithWorkingChild() - state.claudeLeadStateByPaneKey.delete(PANE) - syncClaudeAgentsKilledWatch(state, anchor(transcript)) - appendFileSync(transcript, `${KILLED}\n`) - expect(pollClaudeAgentsKilled(state, anchor(transcript))).toBeUndefined() - expect(state.claudeSubagentRosterByPaneKey.has(PANE)).toBe(false) - }) -}) diff --git a/src/shared/agent-hook-listener/providers/claude-agents-killed-transcript.ts b/src/shared/agent-hook-listener/providers/claude-agents-killed-transcript.ts deleted file mode 100644 index 7dad2a1e7280..000000000000 --- a/src/shared/agent-hook-listener/providers/claude-agents-killed-transcript.ts +++ /dev/null @@ -1,126 +0,0 @@ -// Claude kills every background agent on an idle-prompt Ctrl+C (or "stop all agents") and fires -// no hook; the only record is an id-less `system`/`agents_killed` line appended to the session -// transcript (claude-idle-ctrl-c-* fixtures). The listener on the host that runs the session -// watches for it while the pane has working agent children, and retires them as SubagentStop would. -import { posix, win32 } from 'node:path' -import { - claudeRosterHasWorkingSubagent, - stopWorkingClaudeSubagentsStartedBy -} from '../../claude-subagent-roster' -import { createJsonlCursorAtEnd, readJsonlCursor } from '../../codex-rollout-jsonl-cursor' -import type { AgentHookEventPayload } from '../listener-event' -import type { HookListenerState } from '../listener-state' -import { clearClaudePendingWaitForAgent } from './claude-roster-state' -import { buildClaudeCachedLeadStatusPayload } from './claude-lifecycle-events' - -/** The transcript path this host can watch, or undefined. */ -export function claudeTranscriptWatchPath( - transcriptPath: string, - platform: NodeJS.Platform = process.platform -): string | undefined { - if (platform === 'win32') { - // Why: a drive-less rooted path is a WSL guest path or a share; a sync stat there can stall - // on 9P/SMB, and a WSL session's own guest relay watches its transcript natively. - return win32.isAbsolute(transcriptPath) && !/^[\\/]/.test(transcriptPath) - ? transcriptPath - : undefined - } - return posix.isAbsolute(transcriptPath) ? transcriptPath : undefined -} - -/** Keep the pane's watch armed only while it can matter; returns whether it is armed. */ -export function syncClaudeAgentsKilledWatch( - state: HookListenerState, - anchor: AgentHookEventPayload -): boolean { - const cursors = state.claudeAgentsKilledCursorByPaneKey - const current = cursors.get(anchor.paneKey) - const reported = anchor.providerSession?.transcriptPath - // Why: a row with no provider session (an OSC repaint) says nothing about the transcript. - const filePath = reported !== undefined ? claudeTranscriptWatchPath(reported) : current?.filePath - if ( - // Why: a relayed row's session runs on another host, whose own listener watches it. - anchor.connectionId !== null || - anchor.payload.agentType !== 'claude' || - !filePath || - !claudeRosterHasWorkingSubagent(state.claudeSubagentRosterByPaneKey.get(anchor.paneKey)) - ) { - cursors.delete(anchor.paneKey) - return false - } - if (current?.filePath === filePath) { - return true - } - // Why: only a line appended after arming may count — a resumed session keeps its old records, - // and a session forked into a new file copies them. - const armed = createJsonlCursorAtEnd(filePath) - if (!armed) { - cursors.delete(anchor.paneKey) - return false - } - cursors.set(anchor.paneKey, armed) - return true -} - -/** Reads what the transcript gained since the last tick; returns the row to publish when an - * `agents_killed` record retired any agent child. */ -export function pollClaudeAgentsKilled( - state: HookListenerState, - anchor: AgentHookEventPayload -): AgentHookEventPayload | undefined { - const { paneKey } = anchor - if (!syncClaudeAgentsKilledWatch(state, anchor)) { - return undefined - } - const cursor = state.claudeAgentsKilledCursorByPaneKey.get(paneKey) - const records = cursor && readJsonlCursor(cursor, (line) => line.includes('"agents_killed"')) - if (!records) { - state.claudeAgentsKilledCursorByPaneKey.delete(paneKey) - return undefined - } - let killedAt: number | undefined - for (const record of records) { - if (record.type !== 'system' || record.subtype !== 'agents_killed') { - continue - } - const stamped = Date.parse(String(record.timestamp)) - // Why: an unreadable stamp still came after arming, so every child tracked by now was killed. - killedAt = Math.max(killedAt ?? -Infinity, Number.isNaN(stamped) ? Infinity : stamped) - } - const roster = state.claudeSubagentRosterByPaneKey.get(paneKey) - if (killedAt === undefined || !roster) { - return undefined - } - const mainAgent = state.claudeLeadStateByPaneKey.get(paneKey) - const waitOwner = mainAgent?.state === 'waiting' ? mainAgent.waitingAgentId : undefined - const retired = stopWorkingClaudeSubagentsStartedBy(roster, killedAt) - if (retired.length === 0) { - return undefined - } - clearClaudePendingWaitForAgent(state, paneKey, (agentId) => retired.includes(agentId)) - if (roster.size === 0) { - state.claudeSubagentRosterByPaneKey.delete(paneKey) - } - // Why: null without a main agent record — the kill ends children, it never wakes a parent. - const payload = buildClaudeCachedLeadStatusPayload(state, 'SubagentStop', paneKey, {}) - if (!payload) { - return undefined - } - return { - paneKey, - source: 'claude', - launchToken: anchor.launchToken, - tabId: anchor.tabId, - worktreeId: anchor.worktreeId, - connectionId: null, - hookEventName: 'SubagentStop', - // Why: a child's own stop releases its held permission prompt and is re-folded under a - // cancel verdict the desktop inferred but this host never learned of. - toolAgentId: waitOwner && retired.includes(waitOwner) ? waitOwner : retired[0], - claudeRunningNonAgentTask: - state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) || - state.claudeActiveSessionCronPaneKeys.has(paneKey), - ...(anchor.providerSession ? { providerSession: anchor.providerSession } : {}), - payload - } -} 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 c8e123398281..ec1f8162e392 100644 --- a/src/shared/agent-hook-listener/providers/claude-roster-state.ts +++ b/src/shared/agent-hook-listener/providers/claude-roster-state.ts @@ -124,6 +124,15 @@ export function updateClaudeRunningNonAgentTask( } } +/** The shell fact every Claude row carries as `claudeRunningNonAgentTask`: a running shell or an + * active session cron. The one stamp for every row the listener or its transcript watch builds. */ +export function claudeRunningNonAgentTask(state: HookListenerState, paneKey: string): boolean { + return ( + state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) || + state.claudeActiveSessionCronPaneKeys.has(paneKey) + ) +} + export type ClaudePaneStatusResolution = AgentLeadStatusResolution /** The only writer of the main agent record. The main agent's clock keeps continuity across @@ -191,11 +200,14 @@ export function resolveClaudePaneStatus( * and leave only when their inventory says so. */ export function markClaudeLeadTurnInterrupted( state: HookListenerState, - paneKey: string + paneKey: string, + /** The published verdict's own clock, when restoring a verdict the store kept. */ + verdict: { stateStartedAt?: number } = {} ): { state: AgentStatusState; workingMode?: AgentWorkingMode; mainAgent?: AgentMainAgentStatus } { const record = setClaudeMainAgentTurnState(state, paneKey, { state: 'done', - outcome: 'cancellation' + outcome: 'cancellation', + ...(verdict.stateStartedAt !== undefined ? { stateStartedAt: verdict.stateStartedAt } : {}) }) const resolved = resolveClaudePaneStatus(state, paneKey, record) const mainAgent = claudeMainAgentStatusForPayload(record) diff --git a/src/shared/agent-hook-listener/providers/claude-transcript-watch.test.ts b/src/shared/agent-hook-listener/providers/claude-transcript-watch.test.ts new file mode 100644 index 000000000000..0a55a4471d16 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/claude-transcript-watch.test.ts @@ -0,0 +1,378 @@ +// Unit contract of the Claude transcript watch. Every input here is hand-built; the captured +// stories are in src/main/agent-hooks/server-claude-idle-ctrl-c-captures.test.ts. +import { appendFileSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { stopClaudeSubagent, upsertWorkingClaudeSubagent } from '../../claude-subagent-roster' +import type { AgentHookEventPayload } from '../listener-event' +import { + createHookListenerState, + seedLegacyAgentStatusForTests, + type HookListenerState +} from '../listener-state' +import { + catchUpOnClaudeTranscript, + claudeTranscriptWatchPath, + observeClaudeTranscript, + settleClaudeTranscriptWatch, + syncClaudeTranscriptCursor +} from './claude-transcript-watch' +import { getOrCreateClaudeSubagentRoster, setClaudeMainAgentTurnState } from './claude-roster-state' +import { buildClaudeCachedLeadStatusPayload } from './claude-lifecycle-events' + +const PANE = 'tab-1:11111111-1111-4111-8111-111111111111' +const KILLED = '{"type":"system","subtype":"agents_killed","timestamp":"2026-09-24T19:49:48.826Z"}' +const KILLED_AT = Date.parse('2026-09-24T19:49:48.826Z') +const temporaryPaths: string[] = [] + +afterEach(() => { + for (const path of temporaryPaths.splice(0)) { + rmSync(path, { recursive: true, force: true }) + } + vi.restoreAllMocks() +}) + +function transcriptFile(content = ''): string { + const dir = mkdtempSync(join(tmpdir(), 'orca-claude-transcript-watch-unit-')) + temporaryPaths.push(dir) + const path = join(dir, 'session.jsonl') + writeFileSync(path, content) + return path +} + +function accepted( + transcriptPath: string | undefined, + overrides: Partial = {} +): AgentHookEventPayload { + return { + paneKey: PANE, + source: 'claude', + launchToken: 'launch-1', + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: null, + ...(transcriptPath + ? { providerSession: { key: 'session_id', id: 'session-1', transcriptPath } } + : {}), + payload: { state: 'working', prompt: 'go', agentType: 'claude' }, + ...overrides + } +} + +function paneWithWorkingChild(startedAt = KILLED_AT - 5_000): HookListenerState { + const state = createHookListenerState() + setClaudeMainAgentTurnState(state, PANE, { state: 'done', turnCompletedAt: KILLED_AT - 6_000 }) + upsertWorkingClaudeSubagent(getOrCreateClaudeSubagentRoster(state, PANE), 'a1', {}, startedAt) + return state +} + +/** Stores the row the listener's records make now, as a host would have published it. */ +function publishRecords(state: HookListenerState, transcript: string): AgentHookEventPayload { + const payload = buildClaudeCachedLeadStatusPayload(state, 'Stop', PANE, {}) + if (!payload) { + throw new Error('no main agent record') + } + const row = accepted(transcript, { + hookEventName: 'Stop', + claudeRunningNonAgentTask: false, + payload + }) + seedLegacyAgentStatusForTests(state, row) + return row +} + +describe('claudeTranscriptWatchPath', () => { + it('watches an absolute path on a POSIX host', () => { + expect(claudeTranscriptWatchPath('/home/u/.claude/projects/p/s.jsonl', 'linux')).toBe( + '/home/u/.claude/projects/p/s.jsonl' + ) + expect(claudeTranscriptWatchPath('projects/s.jsonl', 'darwin')).toBeUndefined() + }) + + it('watches only a local drive path on Windows', () => { + expect(claudeTranscriptWatchPath('C:\\Users\\u\\.claude\\s.jsonl', 'win32')).toBe( + 'C:\\Users\\u\\.claude\\s.jsonl' + ) + // A WSL guest path, its UNC form, and a share are the guest relay's or unwatchable. + expect(claudeTranscriptWatchPath('/home/u/.claude/s.jsonl', 'win32')).toBeUndefined() + expect( + claudeTranscriptWatchPath('\\\\wsl.localhost\\Ubuntu\\home\\u\\s.jsonl', 'win32') + ).toBeUndefined() + expect(claudeTranscriptWatchPath('\\\\server\\share\\s.jsonl', 'win32')).toBeUndefined() + expect(claudeTranscriptWatchPath('C:s.jsonl', 'win32')).toBeUndefined() + }) +}) + +describe('syncClaudeTranscriptCursor', () => { + it('arms at the end of the transcript while a reason holds', () => { + const transcript = transcriptFile(`${KILLED}\n`) + const state = paneWithWorkingChild() + expect(syncClaudeTranscriptCursor(state, accepted(transcript))).toBe(true) + expect(state.claudeTranscriptCursorByPaneKey.get(PANE)).toMatchObject({ + filePath: transcript, + offset: KILLED.length + 1, + carry: '', + reasons: new Set(['agent-child-working']) + }) + // A row that reports no transcript keeps the armed one. + expect(syncClaudeTranscriptCursor(state, accepted(undefined))).toBe(true) + }) + + it('does not arm without a reason, or for a relayed row', () => { + const state = createHookListenerState() + setClaudeMainAgentTurnState(state, PANE, { state: 'done' }) + expect(syncClaudeTranscriptCursor(state, accepted(transcriptFile()))).toBe(false) + const watched = paneWithWorkingChild() + expect( + syncClaudeTranscriptCursor(watched, accepted(transcriptFile(), { connectionId: 'conn-1' })) + ).toBe(false) + expect(watched.claudeTranscriptCursorByPaneKey.has(PANE)).toBe(false) + }) + + it('drops the cursor when the accepted row names a path this host cannot read', () => { + const transcript = transcriptFile() + const state = paneWithWorkingChild() + syncClaudeTranscriptCursor(state, accepted(transcript)) + expect(syncClaudeTranscriptCursor(state, accepted(join(transcript, 'missing')))).toBe(false) + expect(state.claudeTranscriptCursorByPaneKey.has(PANE)).toBe(false) + }) + + it('never resets the cursor when the reasons change', () => { + const transcript = transcriptFile() + const state = paneWithWorkingChild() + syncClaudeTranscriptCursor(state, accepted(transcript)) + appendFileSync(transcript, '{"type":"user"}\n') + // The last reason ends at an event; the cursor stays where it was until a read. + stopClaudeSubagent(getOrCreateClaudeSubagentRoster(state, PANE), 'a1') + expect(syncClaudeTranscriptCursor(state, accepted(transcript))).toBe(true) + expect(state.claudeTranscriptCursorByPaneKey.get(PANE)).toMatchObject({ + offset: 0, + reasons: new Set(['agent-child-working']) + }) + }) + + it('repoints to the new session file on a session change and keeps the reasons it watched', () => { + // `/clear`: SessionStart(clear) names a new session and a new transcript, while work the + // reasons track can outlive the old session. + const before = transcriptFile() + const state = paneWithWorkingChild() + syncClaudeTranscriptCursor(state, accepted(before)) + const after = transcriptFile('{"type":"user","copied":true}\n') + expect( + syncClaudeTranscriptCursor( + state, + accepted(after, { + hookEventName: 'SessionStart', + providerSession: { key: 'session_id', id: 'session-2', transcriptPath: after } + }) + ) + ).toBe(true) + expect(state.claudeTranscriptCursorByPaneKey.get(PANE)).toMatchObject({ + filePath: after, + offset: statSync(after).size, + reasons: new Set(['agent-child-working']) + }) + appendFileSync(after, `${KILLED}\n`) + catchUpOnClaudeTranscript(state, PANE) + expect(state.claudeSubagentRosterByPaneKey.has(PANE)).toBe(false) + }) + + it('repoints on a session change even when the records end every reason there, and disarms only after the next read', () => { + const before = transcriptFile() + const state = paneWithWorkingChild() + syncClaudeTranscriptCursor(state, accepted(before)) + state.claudeSubagentRosterByPaneKey.delete(PANE) + const after = transcriptFile() + expect(syncClaudeTranscriptCursor(state, accepted(after))).toBe(true) + expect(state.claudeTranscriptCursorByPaneKey.get(PANE)?.filePath).toBe(after) + seedLegacyAgentStatusForTests( + state, + accepted(after, { payload: { state: 'done', prompt: '', agentType: 'claude' } }) + ) + expect(observeClaudeTranscript(state, PANE)).toMatchObject({ kind: 'read', factApplied: false }) + expect(settleClaudeTranscriptWatch(state, PANE, false)).toBe(false) + expect(state.claudeTranscriptCursorByPaneKey.has(PANE)).toBe(false) + }) +}) + +describe('catchUpOnClaudeTranscript', () => { + it('applies a fact from the existing cursor and never creates one', () => { + const transcript = transcriptFile() + const state = paneWithWorkingChild() + appendFileSync(transcript, `${KILLED}\n`) + catchUpOnClaudeTranscript(state, PANE) + expect(state.claudeTranscriptCursorByPaneKey.has(PANE)).toBe(false) + expect(state.claudeSubagentRosterByPaneKey.get(PANE)?.get('a1')?.state).toBe('working') + + syncClaudeTranscriptCursor(state, accepted(transcript)) + appendFileSync(transcript, `${KILLED}\n`) + catchUpOnClaudeTranscript(state, PANE) + expect(state.claudeSubagentRosterByPaneKey.has(PANE)).toBe(false) + expect(state.claudeTranscriptCursorByPaneKey.get(PANE)?.unpublished).toEqual({ + hookEventName: 'SubagentStop', + toolAgentId: 'a1' + }) + }) + + it('still reads for a reason that ended after the row was written, before the read', () => { + const transcript = transcriptFile() + const state = paneWithWorkingChild() + syncClaudeTranscriptCursor(state, accepted(transcript)) + appendFileSync(transcript, `${KILLED}\n`) + stopClaudeSubagent(getOrCreateClaudeSubagentRoster(state, PANE), 'a1') + const parse = vi.spyOn(JSON, 'parse') + catchUpOnClaudeTranscript(state, PANE) + expect(parse).toHaveBeenCalledWith(KILLED) + expect(state.claudeTranscriptCursorByPaneKey.get(PANE)?.reasons.size).toBe(0) + }) + + it('never parses a line no watched reason admits', () => { + const transcript = transcriptFile() + const state = paneWithWorkingChild() + syncClaudeTranscriptCursor(state, accepted(transcript)) + // The size of the per-turn snapshot attachment Claude writes, with none of the markers. + const snapshot = JSON.stringify({ type: 'attachment', text: 'x'.repeat(56 * 1024) }) + appendFileSync(transcript, `${snapshot}\n`) + const parse = vi.spyOn(JSON, 'parse') + catchUpOnClaudeTranscript(state, PANE) + expect(parse).not.toHaveBeenCalled() + expect(state.claudeTranscriptCursorByPaneKey.get(PANE)?.offset).toBe(statSync(transcript).size) + }) +}) + +describe('observeClaudeTranscript', () => { + it("publishes the retirement as the waiting child's own stop and restores its wait", () => { + const transcript = transcriptFile() + const state = paneWithWorkingChild() + upsertWorkingClaudeSubagent( + getOrCreateClaudeSubagentRoster(state, PANE), + 'a2', + {}, + KILLED_AT - 1_000 + ) + const settled = state.claudeLeadStateByPaneKey.get(PANE) + setClaudeMainAgentTurnState(state, PANE, { + state: 'waiting', + waitingAgentId: 'a2', + stateBeforeWait: settled + }) + publishRecords(state, transcript) + syncClaudeTranscriptCursor(state, accepted(transcript)) + appendFileSync(transcript, `${KILLED}\n`) + + const observed = observeClaudeTranscript(state, PANE) + expect(observed).toMatchObject({ + kind: 'read', + factApplied: true, + row: { + hookEventName: 'SubagentStop', + toolAgentId: 'a2', + launchToken: 'launch-1', + connectionId: null, + claudeRunningNonAgentTask: false, + payload: { state: 'done', mainAgent: { state: 'done' } } + } + }) + expect(state.claudeSubagentRosterByPaneKey.has(PANE)).toBe(false) + expect(state.claudeLeadStateByPaneKey.get(PANE)).toMatchObject({ state: 'done' }) + if (observed.kind === 'read' && observed.row) { + seedLegacyAgentStatusForTests(state, observed.row) + } + // A read that applied a fact keeps the watch for one more read. + expect(settleClaudeTranscriptWatch(state, PANE, true)).toBe(true) + expect(observeClaudeTranscript(state, PANE)).toEqual({ kind: 'read', factApplied: false }) + expect(settleClaudeTranscriptWatch(state, PANE, false)).toBe(false) + }) + + it('counts a line with an unreadable timestamp against every child tracked by then', () => { + const transcript = transcriptFile() + const state = paneWithWorkingChild(Date.now()) + publishRecords(state, transcript) + syncClaudeTranscriptCursor(state, accepted(transcript)) + appendFileSync(transcript, '{"type":"system","subtype":"agents_killed"}\n') + expect(observeClaudeTranscript(state, PANE)).toMatchObject({ row: { toolAgentId: 'a1' } }) + }) + + it('retires the children but wakes no main agent it never saw', () => { + const transcript = transcriptFile() + const state = paneWithWorkingChild() + publishRecords(state, transcript) + state.claudeLeadStateByPaneKey.delete(PANE) + syncClaudeTranscriptCursor(state, accepted(transcript)) + appendFileSync(transcript, `${KILLED}\n`) + expect(observeClaudeTranscript(state, PANE)).toEqual({ kind: 'read', factApplied: true }) + expect(state.claudeSubagentRosterByPaneKey.has(PANE)).toBe(false) + }) + + it('restates the records when the stored row does not show them, as an observation', () => { + const transcript = transcriptFile() + const state = paneWithWorkingChild() + publishRecords(state, transcript) + syncClaudeTranscriptCursor(state, accepted(transcript)) + expect(observeClaudeTranscript(state, PANE)).toEqual({ kind: 'read', factApplied: false }) + // A change of the records no row carried (an event that produced no row). + stopClaudeSubagent(getOrCreateClaudeSubagentRoster(state, PANE), 'a1') + const observed = observeClaudeTranscript(state, PANE) + expect(observed).toMatchObject({ kind: 'read', row: { payload: { state: 'done' } } }) + expect(observed.kind === 'read' && observed.row?.hookEventName).toBeUndefined() + }) + + it('keeps the hook that raised a wait on a restated waiting row', () => { + const transcript = transcriptFile() + const state = paneWithWorkingChild() + setClaudeMainAgentTurnState(state, PANE, { state: 'waiting' }) + const payload = buildClaudeCachedLeadStatusPayload(state, 'PermissionRequest', PANE, {}) + if (!payload) { + throw new Error('no row') + } + seedLegacyAgentStatusForTests( + state, + accepted(transcript, { + hookEventName: 'PermissionRequest', + toolUseId: 'toolu_1', + claudeRunningNonAgentTask: false, + payload: { ...payload, subagents: undefined } + }) + ) + syncClaudeTranscriptCursor(state, accepted(transcript)) + expect(observeClaudeTranscript(state, PANE)).toMatchObject({ + row: { + hookEventName: 'PermissionRequest', + toolUseId: 'toolu_1', + payload: { state: 'waiting' } + } + }) + }) + + it('never restates a dismissed row, but a fact still does what its missing hook would', () => { + const transcript = transcriptFile() + const state = paneWithWorkingChild() + const row = publishRecords(state, transcript) + seedLegacyAgentStatusForTests(state, { ...row, providerSessionOnly: true }) + syncClaudeTranscriptCursor(state, accepted(transcript)) + state.claudeSubagentRosterByPaneKey.get(PANE)?.set('a0', { + state: 'working', + startedAt: KILLED_AT + 60_000 + }) + expect(observeClaudeTranscript(state, PANE)).toEqual({ kind: 'read', factApplied: false }) + appendFileSync(transcript, `${KILLED}\n`) + expect(observeClaudeTranscript(state, PANE)).toMatchObject({ + row: { hookEventName: 'SubagentStop', toolAgentId: 'a1' } + }) + }) + + it('stops for a pane with no row, and drops the cursor of a row another host or agent owns', () => { + const transcript = transcriptFile() + const state = paneWithWorkingChild() + syncClaudeTranscriptCursor(state, accepted(transcript)) + expect(observeClaudeTranscript(state, PANE)).toEqual({ kind: 'stop' }) + expect(state.claudeTranscriptCursorByPaneKey.has(PANE)).toBe(true) + seedLegacyAgentStatusForTests( + state, + accepted(transcript, { payload: { state: 'working', prompt: '', agentType: 'codex' } }) + ) + expect(observeClaudeTranscript(state, PANE)).toEqual({ kind: 'stop' }) + expect(state.claudeTranscriptCursorByPaneKey.has(PANE)).toBe(false) + }) +}) diff --git a/src/shared/agent-hook-listener/providers/claude-transcript-watch.ts b/src/shared/agent-hook-listener/providers/claude-transcript-watch.ts new file mode 100644 index 000000000000..e4b0c4a3a9b9 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/claude-transcript-watch.ts @@ -0,0 +1,330 @@ +// Claude records some of what it does only in its session transcript, with no hook: today, killing +// every background agent on an idle-prompt Ctrl+C (an id-less `system`/`agents_killed` row, the +// claude-idle-ctrl-c-* fixtures). The host that runs the session keeps one cursor per pane while any +// reason to watch holds, reads it before every Claude event and on a timer, applies what it reads to +// the listener's records as facts, and restates the row those records now make. +import { posix, win32 } from 'node:path' +import type { ParsedAgentStatusPayload } from '../../agent-status-types' +import { + claudeRosterHasWorkingSubagent, + stopWorkingClaudeSubagentsStartedBy +} from '../../claude-subagent-roster' +import { + createJsonlCursorAtEnd, + readJsonlCursor, + type JsonlCursor, + type JsonRecord +} from '../../codex-rollout-jsonl-cursor' +import type { AgentHookEventPayload } from '../listener-event' +import type { HookListenerState } from '../listener-state' +import { buildClaudeCachedLeadStatusPayload } from './claude-lifecycle-events' +import { claudeRunningNonAgentTask, clearClaudePendingWaitForAgent } from './claude-roster-state' + +/** How the row a fact leaves is attributed, since no hook carries it. */ +export type ClaudeTranscriptFactAttribution = { hookEventName: string; toolAgentId?: string } + +type ClaudeTranscriptWatchReason = { + /** Re-derived from the pane's records at every event and read; never stored. */ + holds: (state: HookListenerState, paneKey: string) => boolean + /** Substring test on the raw line: a line no watched reason admits is never parsed. */ + admits: (line: string) => boolean + /** Applies one row to the records; returns its row's attribution, or undefined if nothing changed. */ + apply: ( + state: HookListenerState, + paneKey: string, + row: JsonRecord + ) => ClaudeTranscriptFactAttribution | undefined +} + +/** Retires the working agent children an `agents_killed` row says were killed, as SubagentStop would. */ +function applyAgentsKilled( + state: HookListenerState, + paneKey: string, + row: JsonRecord +): ClaudeTranscriptFactAttribution | undefined { + const roster = state.claudeSubagentRosterByPaneKey.get(paneKey) + if (row.type !== 'system' || row.subtype !== 'agents_killed' || !roster) { + return undefined + } + const stamped = Date.parse(String(row.timestamp)) + const mainAgent = state.claudeLeadStateByPaneKey.get(paneKey) + const waitOwner = mainAgent?.state === 'waiting' ? mainAgent.waitingAgentId : undefined + // Why: an unreadable stamp still came after arming, so every child tracked by now was killed. + const retired = stopWorkingClaudeSubagentsStartedBy( + roster, + Number.isNaN(stamped) ? Infinity : stamped + ) + if (retired.length === 0) { + return undefined + } + clearClaudePendingWaitForAgent(state, paneKey, (agentId) => retired.includes(agentId)) + if (roster.size === 0) { + state.claudeSubagentRosterByPaneKey.delete(paneKey) + } + // Why: a child's own stop releases its held permission prompt, and the desktop re-folds a + // child's stop under a cancel it inferred but this host never learned of. + return { + hookEventName: 'SubagentStop', + toolAgentId: waitOwner && retired.includes(waitOwner) ? waitOwner : retired[0] + } +} + +/** Every reason to watch. A consumer adds one entry: when it holds, its line test, its fact. */ +const CLAUDE_TRANSCRIPT_WATCH_REASONS = { + /** A working agent child, which Claude can kill with no hook. Dies when the roster has none left: + * SubagentStop, an `agents_killed` row, the Stop inventory, SessionStart, pane teardown. */ + 'agent-child-working': { + holds: (state, paneKey) => + claudeRosterHasWorkingSubagent(state.claudeSubagentRosterByPaneKey.get(paneKey)), + admits: (line) => line.includes('"agents_killed"'), + apply: applyAgentsKilled + } +} satisfies Record + +export type ClaudeTranscriptWatchReasonName = keyof typeof CLAUDE_TRANSCRIPT_WATCH_REASONS + +const REASON_NAMES = Object.keys(CLAUDE_TRANSCRIPT_WATCH_REASONS).filter( + (name): name is ClaudeTranscriptWatchReasonName => name in CLAUDE_TRANSCRIPT_WATCH_REASONS +) + +/** The pane's one transcript cursor. */ +export type ClaudeTranscriptCursor = JsonlCursor & { + filePath: string + /** Every reason that held since the last read, so that read still serves a reason that ended + * before it; reset to the reasons holding after each read. */ + reasons: Set + /** A fact read before an event rather than by a tick: the next tick restates its row with this + * attribution even when the event's own row already showed it, since a store may have held that. */ + unpublished?: ClaudeTranscriptFactAttribution +} + +/** The reasons to watch that hold now, re-derived from the pane's records. */ +export function claudeTranscriptWatchReasons( + state: HookListenerState, + paneKey: string +): ClaudeTranscriptWatchReasonName[] { + return REASON_NAMES.filter((name) => CLAUDE_TRANSCRIPT_WATCH_REASONS[name].holds(state, paneKey)) +} + +/** The transcript path this host can watch, or undefined. */ +export function claudeTranscriptWatchPath( + transcriptPath: string, + platform: NodeJS.Platform = process.platform +): string | undefined { + if (platform === 'win32') { + // Why: a drive-less rooted path is a WSL guest path or a share; a sync stat there can stall + // on 9P/SMB, and a WSL session's own guest relay watches its transcript natively. + return win32.isAbsolute(transcriptPath) && !/^[\\/]/.test(transcriptPath) + ? transcriptPath + : undefined + } + return posix.isAbsolute(transcriptPath) ? transcriptPath : undefined +} + +/** Arms or repoints the pane's cursor from a row the host ACCEPTED, never from an event being + * normalized: a nested CLI's refused event must not move the pane's cursor. Returns whether the + * pane has a cursor. A change of reasons never resets it; only a new transcript path repoints it, + * at that file's end, keeping its reasons (a session change is not the end of what they track). */ +export function syncClaudeTranscriptCursor( + state: HookListenerState, + accepted: AgentHookEventPayload +): boolean { + const { paneKey } = accepted + const cursors = state.claudeTranscriptCursorByPaneKey + const current = cursors.get(paneKey) + // Why: a relayed row's session runs on another host, whose own listener watches it; the next + // tick drops a cursor this pane no longer justifies. + if (accepted.connectionId !== null || accepted.payload.agentType !== 'claude') { + return current !== undefined + } + const holding = claudeTranscriptWatchReasons(state, paneKey) + const reported = accepted.providerSession?.transcriptPath + // Why: a row with no provider session (an OSC repaint) says nothing about the transcript. + const filePath = reported !== undefined ? claudeTranscriptWatchPath(reported) : current?.filePath + if (current && current.filePath === filePath) { + holding.forEach((name) => current.reasons.add(name)) + return true + } + if (!current && holding.length === 0) { + return false + } + // Why at the end: a resumed session keeps its old rows, and a forked or cleared one starts a + // file whose copied rows were written before arming. + const armed = filePath ? createJsonlCursorAtEnd(filePath) : undefined + if (!filePath || !armed) { + cursors.delete(paneKey) + return false + } + cursors.set(paneKey, { + ...armed, + filePath, + reasons: new Set([...(current?.reasons ?? []), ...holding]) + }) + return true +} + +/** Reads what the pane's existing cursor gained and applies it as facts, in file order. Never + * creates or repoints the cursor; an unreadable file drops it. Returns the last fact's attribution. */ +function readClaudeTranscript( + state: HookListenerState, + paneKey: string +): ClaudeTranscriptFactAttribution | undefined { + const cursors = state.claudeTranscriptCursorByPaneKey + const cursor = cursors.get(paneKey) + if (!cursor) { + return undefined + } + claudeTranscriptWatchReasons(state, paneKey).forEach((name) => cursor.reasons.add(name)) + const reasons = [...cursor.reasons].map((name) => CLAUDE_TRANSCRIPT_WATCH_REASONS[name]) + const rows = readJsonlCursor(cursor, (line) => reasons.some((reason) => reason.admits(line))) + if (!rows) { + cursors.delete(paneKey) + return undefined + } + let attribution: ClaudeTranscriptFactAttribution | undefined + for (const row of rows) { + for (const reason of reasons) { + attribution = reason.apply(state, paneKey, row) ?? attribution + } + } + cursor.reasons = new Set(claudeTranscriptWatchReasons(state, paneKey)) + return attribution +} + +/** The catch-up every Claude event runs before it is normalized, so the event folds after every + * fact the transcript already recorded. It only reads an existing cursor. */ +export function catchUpOnClaudeTranscript(state: HookListenerState, paneKey: string): void { + const attribution = readClaudeTranscript(state, paneKey) + const cursor = state.claudeTranscriptCursorByPaneKey.get(paneKey) + if (attribution && cursor) { + cursor.unpublished = attribution + } +} + +/** `catchUpOnClaudeTranscript` for a raw hook body, keyed by the pane key it was sent with. */ +export function catchUpOnClaudeTranscriptForHookBody( + state: HookListenerState, + source: string, + body: unknown +): void { + if (source !== 'claude' || typeof body !== 'object' || body === null || !('paneKey' in body)) { + return + } + const paneKey = typeof body.paneKey === 'string' ? body.paneKey.trim() : '' + if (paneKey) { + catchUpOnClaudeTranscript(state, paneKey) + } +} + +/** Whether the stored row already shows what the records say. The card counts only while the row + * waits: it is then the question on screen, otherwise a tool preview a later hook repaints. */ +function claudeRowShowsRecords( + current: AgentHookEventPayload, + payload: ParsedAgentStatusPayload, + runningNonAgentTask: boolean +): boolean { + const shown = current.payload + return ( + shown.state === payload.state && + shown.workingMode === payload.workingMode && + shown.interrupted === payload.interrupted && + shown.mainAgent?.state === payload.mainAgent?.state && + shown.mainAgent?.outcome === payload.mainAgent?.outcome && + JSON.stringify(shown.subagents) === JSON.stringify(payload.subagents) && + // Why: a write that is not the listener's (an OSC repaint, an inferred answer) drops the fact + // on purpose when `mainAgent` moves; restoring it is not the watch's job. + (current.claudeRunningNonAgentTask === undefined || + current.claudeRunningNonAgentTask === runningNonAgentTask) && + (payload.state !== 'waiting' || + (shown.toolName === payload.toolName && + shown.toolInput === payload.toolInput && + shown.interactivePrompt === payload.interactivePrompt)) + ) +} + +export type ClaudeTranscriptObservation = + | { kind: 'stop' } + | { kind: 'read'; factApplied: boolean; row?: AgentHookEventPayload } + +/** One tick: reads the cursor, then rebuilds the row from the records with the hooks' own builder + * and returns it when the stored row does not show it yet, or when a fact named how it is + * attributed. Compares against the host's stored row: on the desktop that is the row after its + * cancel latch and permission hold, on a relay its own cache (no latch, no hold). */ +export function observeClaudeTranscript( + state: HookListenerState, + paneKey: string +): ClaudeTranscriptObservation { + const current = state.lastStatusByPaneKey.get(paneKey) + // Why kept, not dropped: a row removed with no teardown comes back at the next event, whose + // sync resumes the watch from where it stopped. + if (!current) { + return { kind: 'stop' } + } + if (current.payload.agentType !== 'claude' || current.connectionId !== null) { + state.claudeTranscriptCursorByPaneKey.delete(paneKey) + return { kind: 'stop' } + } + const read = readClaudeTranscript(state, paneKey) + const cursor = state.claudeTranscriptCursorByPaneKey.get(paneKey) + const attribution = read ?? cursor?.unpublished + if (cursor) { + delete cursor.unpublished + } + const payload = buildClaudeCachedLeadStatusPayload(state, attribution?.hookEventName, paneKey, {}) + const runningNonAgentTask = claudeRunningNonAgentTask(state, paneKey) + // Why: the watch restates a row, it never brings back one the user dismissed; only a fact, which + // stands for the hook Claude did not send, does what that hook would have. + const unchanged = + !payload || + (!attribution && + (current.providerSessionOnly === true || + claudeRowShowsRecords(current, payload, runningNonAgentTask))) + // Why: a row still waiting keeps the hook that raised the wait, which the store's permission + // rules key on; any other row is an observation, not a new turn. + const hook = + attribution ?? + (payload?.state === 'waiting' && current.hookEventName + ? { + hookEventName: current.hookEventName, + toolAgentId: current.toolAgentId, + toolUseId: current.toolUseId, + toolAgentType: current.toolAgentType + } + : {}) + return { + kind: 'read', + factApplied: read !== undefined, + ...(unchanged || !payload + ? {} + : { + row: { + paneKey, + source: 'claude', + launchToken: current.launchToken, + tabId: current.tabId, + worktreeId: current.worktreeId, + connectionId: null, + ...hook, + claudeRunningNonAgentTask: runningNonAgentTask, + ...(current.providerSession ? { providerSession: current.providerSession } : {}), + payload + } + }) + } +} + +/** Keeps the cursor after a tick only while a reason holds or the read applied a fact, so a fact + * the read found is followed by one more read. Returns whether the pane is still armed. */ +export function settleClaudeTranscriptWatch( + state: HookListenerState, + paneKey: string, + factApplied: boolean +): boolean { + const cursors = state.claudeTranscriptCursorByPaneKey + const cursor = cursors.get(paneKey) + if (cursor && !factApplied && claudeTranscriptWatchReasons(state, paneKey).length === 0) { + cursors.delete(paneKey) + return false + } + return cursor !== undefined +} diff --git a/src/shared/agent-hook-listener/transcript-poll-policy.ts b/src/shared/agent-hook-listener/transcript-poll-policy.ts index ff4646897b7b..d27814cf928d 100644 --- a/src/shared/agent-hook-listener/transcript-poll-policy.ts +++ b/src/shared/agent-hook-listener/transcript-poll-policy.ts @@ -1,15 +1,10 @@ import type { AgentHookSource } from '../agent-hook-relay' -import { normalizeHookPayload } from '../agent-hook-listener' import type { AgentHookEventPayload } from './listener-event' import type { HookListenerState } from './listener-state' -import { - pollClaudeAgentsKilled, - syncClaudeAgentsKilledWatch -} from './providers/claude-agents-killed-transcript' import { hasCodexTranscriptSubagents } from './providers/codex-state' import { hasMuseSessionLog } from './providers/muse-events' -/** Whether a pane should be polled on a timer for state only its transcript shows. */ +/** Whether a pane's last hook body should be re-normalized on a timer to pick up transcript-only state. */ export function shouldPollHookTranscript( state: HookListenerState, source: AgentHookSource, @@ -18,9 +13,6 @@ export function shouldPollHookTranscript( if (source === 'codex') { return hasCodexTranscriptSubagents(state, event.paneKey) } - if (source === 'claude') { - return syncClaudeAgentsKilledWatch(state, event) - } if (source === 'muse') { // Why: Muse's question tool fires no hook, so only its session log shows the wait and its answer. return event.payload.state !== 'done' && hasMuseSessionLog(state, event.paneKey) @@ -28,33 +20,6 @@ export function shouldPollHookTranscript( return false } -/** The row a due poll reads from, or undefined when the poll is stale. Claude's watch follows the - * pane's current row, which an inferred cancel replaces without rescheduling; a re-normalized - * hook body is only valid while its own row is current. */ -export function transcriptPollAnchor( - source: AgentHookSource, - current: T | undefined, - original: T -): T | undefined { - return source === 'claude' || current === original ? current : undefined -} - -/** One due poll: the update to publish, undefined when the transcript added nothing, or null - * when the hook body no longer normalizes and polling should stop. */ -export function hookTranscriptPollUpdate( - state: HookListenerState, - source: AgentHookSource, - body: unknown, - anchor: AgentHookEventPayload, - env: string -): AgentHookEventPayload | undefined | null { - if (source === 'claude') { - return pollClaudeAgentsKilled(state, anchor) - } - const polled = normalizeHookPayload(state, source, body, env) - return polled && transcriptPollUpdate(source, anchor, polled) -} - /** Returns the poll result to publish, or undefined when it carries nothing new. */ export function transcriptPollUpdate( source: AgentHookSource, From bf540461afd74093e501634c844f866b7303ae0a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:33:46 -0700 Subject: [PATCH 2/5] test(claude): a kill the relay read inside a hook the desktop held still retires the child on the desktop --- .../server-relayed-claude-cancel.test.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/main/agent-hooks/server-relayed-claude-cancel.test.ts b/src/main/agent-hooks/server-relayed-claude-cancel.test.ts index 3c3805747fa7..ef9d72c12b46 100644 --- a/src/main/agent-hooks/server-relayed-claude-cancel.test.ts +++ b/src/main/agent-hooks/server-relayed-claude-cancel.test.ts @@ -543,6 +543,27 @@ describe('a relayed idle-prompt Ctrl+C that killed a background agent (captured) expect(row(pane.desktop).subagents).toBeUndefined() }) + it('still retires the child on the desktop when the relay read the kill inside a hook the desktop held', async () => { + const pane = await startSshPane(new AgentHookServer()) + const transcript = transcriptFile() + await replay(pane, transcript, [0, 1, 2, 3, 4, 5, 6, 7, 8]) + expect(pressCtrlC(pane.desktop)).toBe(true) + // Hand-built timing: the idle Ctrl+C's kill line, then a late main-agent hook of the cancelled + // turn reaching the relay before its tick. The relay's catch-up folds the kill into that + // hook's row, which the desktop's latch holds as late tool progress. + vi.setSystemTime(killedAt + 120) + appendFileSync(transcript, `${killedLine}\n`) + await pane.post({ ...hookAt(records, 7).payload, transcript_path: transcript }) + expect(row(pane.desktop).subagents).toEqual([expect.objectContaining({ state: 'working' })]) + // The relay's next tick restates the kill as the child's own stop, which the desktop re-folds. + await vi.waitFor(() => expect(row(pane.desktop).subagents).toBeUndefined(), { timeout: 3_000 }) + expect(row(pane.desktop)).toMatchObject({ + state: 'working', + workingMode: 'monitoring', + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + }) + it('restores no killed child when the desktop restarts', async () => { const userDataPath = temporaryDir('orca-relayed-agents-killed-restart-') const firstDesktop = new AgentHookServer() From 093e4781c2ac5d4e6146430865dd2b0811cec6d2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:17:41 -0700 Subject: [PATCH 3/5] fix(agent-status): resume a moved pane's Claude transcript watch under its new key Moving a pane to another key carried its transcript cursor along but left no tick scheduled for the new key, so an idle Ctrl+C that killed a background agent stayed invisible until the pane's next hook. --- src/main/agent-hooks/server/server-authority-aliases.ts | 3 +++ src/main/agent-hooks/server/server-status-retries.ts | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/src/main/agent-hooks/server/server-authority-aliases.ts b/src/main/agent-hooks/server/server-authority-aliases.ts index fb4890e10a2f..a3d267fa28ee 100644 --- a/src/main/agent-hooks/server/server-authority-aliases.ts +++ b/src/main/agent-hooks/server/server-authority-aliases.ts @@ -227,6 +227,9 @@ export abstract class AgentHookServerAuthorityAliases extends AgentHookServerAut } this.clearAssistantMessageRetry(previousOwnerPaneKey) this.clearTranscriptPoll(previousOwnerPaneKey) + if (transferredStatus) { + this.resumeClaudeTranscriptWatch(transferredStatus) + } // Why: the live process keeps posting the physical source key after detach; persist a chain-safe mapping to the current owner. this.legacyPaneKeyAliases.set(physicalPaneKey, { stablePaneKey: toPaneKey, diff --git a/src/main/agent-hooks/server/server-status-retries.ts b/src/main/agent-hooks/server/server-status-retries.ts index 5894c009f20b..31de96145bcb 100644 --- a/src/main/agent-hooks/server/server-status-retries.ts +++ b/src/main/agent-hooks/server/server-status-retries.ts @@ -56,6 +56,11 @@ export abstract class AgentHookServerStatusRetries extends AgentHookServerStatus this.claudeTranscriptWatch.clear(paneKey) } + /** Resumes a moved pane's Claude transcript watch under the key its row and cursor moved to. */ + protected resumeClaudeTranscriptWatch(moved: EnrichedAgentHookEventPayload): void { + this.claudeTranscriptWatch.sync(moved) + } + protected scheduleTranscriptPoll( source: AgentHookSource, body: unknown, From 7636014dfc87f0271915070dfe27e78e10b83482 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:17:43 -0700 Subject: [PATCH 4/5] fix(agent-status): the cancel latch holds the Claude watch's restatements instead of re-marking the record When Orca inferred a Ctrl+C that Claude did not act on (one that only cleared a typed draft), the latch held the turn's later tool hooks and the old fix wrote the latch's cancel into the listener's record. The turn's own plain Stop then ended as Interrupted instead of Done, on every local Claude pane. The listener's record now keeps the held hooks' evidence, as before the watch. A tick row that carries no transcript fact is tagged `restatesRecords` (stripped at the store's entry, never stored, persisted or sent over the relay) and the latch holds it, so a tick still cannot reopen a held cancel after the window. Rows carrying a fact are untagged and still re-fold under the latch. A still-waiting row the tick restates now keeps the hook that raised its wait unless the fact retired that wait's own child, so a kill read just before a main-agent permission prompt no longer lets a sibling call clear the card. The watch tests that assert nothing changed now first wait for a tick to read past an appended line, so a starved runner fails them instead of passing them. --- docs/reference/agent-status-store.md | 14 +- .../server-claude-transcript-watch.test.ts | 137 +++++++++++++++--- .../server/server-cancel-verdict-latch.ts | 9 +- .../server/server-status-update.ts | 24 +-- .../agent-hook-result-retry-scheduler.ts | 4 +- .../claude-transcript-watch-ticks.ts | 5 +- .../providers/claude-roster-state.ts | 7 +- .../providers/claude-transcript-watch.test.ts | 39 ++++- .../providers/claude-transcript-watch.ts | 31 ++-- 9 files changed, 207 insertions(+), 63 deletions(-) diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index 930ec5364a32..80fd4fee34f4 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -293,9 +293,11 @@ A new line retires the children that started by its timestamp with SubagentStop semantics and publishes a `SubagentStop` row attributed to one of them, with no verdict stamped; the row keeps monitoring a surviving shell and settles to done only when nothing is left (`claude-idle-ctrl-c-*` fixtures pin -this). When the cancel latch holds a late main-agent hook of a local pane, the -listener's record is re-marked cancelled, as for Codex, so the tick has nothing -stale to restate. A relay too old to watch leaves the child on the row until the next +this). A tick row that carries no fact only restates hooks the store already +judged, so the desktop's cancel latch holds it: after a late main-agent hook the +latch refused, the listener's record keeps that hook's evidence (a turn the +Ctrl+C did not stop still ends as a plain done) and the tick cannot reopen the +cancel. A relay too old to watch leaves the child on the row until the next Stop's inventory, as before. The synthesized row is the fold of the cancelled main agent with the child work the pane's owner can see: the local listener's roster for a local pane, the row's own subagents and shell fact @@ -452,8 +454,10 @@ call it. same store and admission code but has no live run here; Linux and Windows runtime execution, native mobile clients, and mixed-version paired clients remain validation gaps. -- **Performance budget:** publication stays event-driven with no new polling or - subprocesses (the Claude transcript watch above costs one `stat` per event and per second, plus the appended bytes, only while a pane has a reason to watch). One mobile projection clones the status snapshot once, builds +- **Performance budget:** publication stays event-driven with no new + subprocesses. The one poll is the Claude transcript watch above: a one-second + tick per pane, only while that pane has a reason to watch, costing one `stat` + per event and per tick plus the appended bytes. One mobile projection clones the status snapshot once, builds pane/handle indexes once, and has a deterministic call-count test; lifecycle cleanup is bounded by the existing status and handle inventories, and orcad tests prove listeners clean up once on failed startup and repeated stop. diff --git a/src/main/agent-hooks/server-claude-transcript-watch.test.ts b/src/main/agent-hooks/server-claude-transcript-watch.test.ts index db654bf07194..6eb58b213c39 100644 --- a/src/main/agent-hooks/server-claude-transcript-watch.test.ts +++ b/src/main/agent-hooks/server-claude-transcript-watch.test.ts @@ -3,12 +3,12 @@ // (src/shared/__fixtures__/claude-idle-ctrl-c-bg-agent-hooks.jsonl): its hook bodies and its one // captured `agents_killed` transcript line. Bodies marked "hand-built" are captured bodies with // fields changed, or events placed at times the capture did not record; each test says which. -import { appendFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { appendFileSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AgentHookServer, _internals } from './server' -import { buildBody, PANE, postHookEvent } from './server.test-fixtures' +import { buildBody, GOOD_PANE, PANE, postHookEvent } from './server.test-fixtures' import { cancelLabelled, hookAt, loadCapture } from './claude-cancel-capture.test-fixture' const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ @@ -30,8 +30,6 @@ const KILL_ROW: Record = JSON.parse(KILL_LINE) const KILLED_AT = Date.parse(String(KILL_ROW.timestamp)) const T0 = KILLED_AT - cancelLabelled(records, 'CTRL-C-idle-with-bg-shell-and-bg-agent').t * 1000 const CHILD = 'a2303994f3dfae83c' -/** Enough real time for the watch to have ticked at least twice. */ -const TWO_TICKS_MS = 2_300 const temporaryPaths: string[] = [] const running: AgentHookServer[] = [] @@ -108,9 +106,12 @@ function pressCtrlC(server: AgentHookServer): boolean { }) } -/** Only Date is faked, so this waits on the same real timers the watch ticks on. */ -function realDelay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)) +/** Hand-built: a line no reason admits, then waits for the tick that reads past it (only Date is + * faked, so ticks run on real timers). Proves a tick ran before a "nothing changed" assertion. */ +async function afterTick(server: AgentHookServer, transcript: string): Promise { + appendFileSync(transcript, '{"type":"user"}\n') + const end = statSync(transcript).size + await vi.waitFor(() => expect(cursor(server)?.offset).toBe(end), { timeout: 3_000 }) } /** Ctrl+C while the captured second turn runs, with its background agent working. */ @@ -180,21 +181,52 @@ describe('the tick against the cancel latch (W1)', () => { const cancelled = await cancelledWithWorkingChild(server, transcript) vi.setSystemTime(Date.now() + 100) await post(server, lateMainAgentPostToolUse(transcript)) - // The latch holds tool progress after the cancel. + // The latch holds tool progress after the cancel; the listener's record keeps the hook. expect(row(server)).toEqual(cancelled) - // Past the 15 s window, the watch keeps ticking while the child works. + expect(server._getStateForTests().claudeLeadStateByPaneKey.get(PANE)?.state).toBe('working') + // Past the 15 s window, a tick restates that record while the child works. vi.setSystemTime(Date.now() + 20_000) - await realDelay(TWO_TICKS_MS) + await afterTick(server, transcript) + expect(row(server)).toEqual(cancelled) + }) + + it('ends a turn the Ctrl+C did not stop as the plain done its own Stop reports', async () => { + const server = await startServer() + const transcript = transcriptFile() + await cancelledWithWorkingChild(server, transcript) + // Hand-built: Claude did not act on the keystroke (one that only cleared a typed draft), so + // the turn goes on; the latch holds its tool hook, and ticks pass the window. + vi.setSystemTime(Date.now() + 100) + await post(server, lateMainAgentPostToolUse(transcript)) + vi.setSystemTime(Date.now() + 20_000) + await afterTick(server, transcript) + await post(server, { ...hookAt(records, 9).payload, transcript_path: transcript }) expect(row(server)).toMatchObject({ state: 'working', - mainAgent: { state: 'done', outcome: 'cancellation' }, + mainAgent: { state: 'done' }, subagents: [expect.objectContaining({ id: CHILD, state: 'working' })] }) - expect(server._getStateForTests().claudeLeadStateByPaneKey.get(PANE)).toMatchObject({ - state: 'done', - outcome: 'cancellation', - stateStartedAt: cancelled.mainAgent?.stateStartedAt + expect(row(server).mainAgent).not.toHaveProperty('outcome') + }) + + it('ends it as a plain done on a pane the watch never armed', async () => { + const server = await startServer() + const transcript = transcriptFile() + await replay(server, transcript, [0, 1, 2]) + expect(cursor(server)).toBeUndefined() + expect(pressCtrlC(server)).toBe(true) + // Hand-built timing: the turn's captured PostToolUse and Stop, after the ignored Ctrl+C. + vi.setSystemTime(Date.now() + 2_000) + await post(server, { ...hookAt(records, 3).payload, transcript_path: transcript }) + vi.setSystemTime(Date.now() + 20_000) + await post(server, { + ...hookAt(records, 4).payload, + transcript_path: transcript, + background_tasks: [] }) + expect(row(server)).toMatchObject({ state: 'done', mainAgent: { state: 'done' } }) + expect(row(server).interrupted).toBeUndefined() + expect(row(server).mainAgent).not.toHaveProperty('outcome') }) }) @@ -230,10 +262,10 @@ describe('who may move the cursor', () => { expect(cursor(server)?.filePath).toBe(nested) vi.setSystemTime(KILLED_AT + 120) appendFileSync(transcript, `${KILL_LINE}\n`) - await realDelay(TWO_TICKS_MS) + await afterTick(server, nested) // The outer session's own next event repoints at the outer file's end, past the kill line. await replay(server, transcript, [11]) - await realDelay(TWO_TICKS_MS) + await afterTick(server, transcript) // Lingers, the safe direction: the killed child still reads working until an inventory. expect(row(server).subagents).toEqual( expect.arrayContaining([expect.objectContaining({ id: CHILD, state: 'working' })]) @@ -241,6 +273,27 @@ describe('who may move the cursor', () => { }) }) +describe('a pane moved to another key', () => { + it('keeps ticking under the new key', async () => { + const server = await startServer() + const transcript = transcriptFile() + await replay(server, transcript, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) + // Hand-built: the pane is detached to a new tab, then the idle Ctrl+C kills the agent. + server.transferPaneAuthority(PANE, GOOD_PANE) + expect(server._getStateForTests().claudeTranscriptCursorByPaneKey.has(GOOD_PANE)).toBe(true) + vi.setSystemTime(KILLED_AT + 120) + appendFileSync(transcript, `${KILL_LINE}\n`) + await vi.waitFor( + () => expect(server.getStatusSnapshotForPane(GOOD_PANE)[0]?.subagents).toBeUndefined(), + { timeout: 3_000 } + ) + expect(server.getStatusSnapshotForPane(GOOD_PANE)[0]).toMatchObject({ + state: 'working', + workingMode: 'monitoring' + }) + }) +}) + describe('a session change on the pane', () => { it('repoints to the new session file at /clear, and disarms only after a read', async () => { const server = await startServer() @@ -302,12 +355,56 @@ describe('the tick against the permission hold (W3, main without #22042)', () => // The listener's record says the main agent works; the store keeps the card. expect(server._getStateForTests().claudeLeadStateByPaneKey.get(PANE)?.state).toBe('working') vi.setSystemTime(Date.now() + 20_000) - await realDelay(TWO_TICKS_MS) - expect(cursor(server)).toBeDefined() + await afterTick(server, transcript) expect(row(server)).toEqual(held) }) }) +describe('a fact read while the main agent waits on a permission prompt', () => { + it('keeps the prompt its hook raised, so a sibling call finishing does not clear the card', async () => { + const server = await startServer() + const transcript = transcriptFile() + await replay(server, transcript, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) + // Hand-built: the kill line, then a third turn whose prompt reads it during catch-up and + // whose first call asks permission before any tick. + vi.setSystemTime(KILLED_AT + 120) + appendFileSync(transcript, `${KILL_LINE}\n`) + const base = { ...hookAt(records, 6).payload, transcript_path: transcript } + const turn = { prompt_id: '00000000-0000-4000-8000-0000000000b1' } + const gated = { tool_name: 'Bash', tool_input: { command: 'rm -rf build' } } + await post(server, { ...base, ...turn, hook_event_name: 'UserPromptSubmit', prompt: 'clean' }) + await post(server, { + ...base, + ...turn, + ...gated, + hook_event_name: 'PreToolUse', + tool_use_id: 'toolu_gated' + }) + await post(server, { + ...base, + ...turn, + ...gated, + hook_event_name: 'PermissionRequest', + tool_use_id: undefined + }) + // The tick that restates the fact is the last read: nothing is left to watch. + await vi.waitFor(() => expect(cursor(server)).toBeUndefined(), { timeout: 3_000 }) + expect(server._getStateForTests().lastStatusByPaneKey.get(PANE)).toMatchObject({ + hookEventName: 'PermissionRequest', + toolUseId: 'toolu_gated' + }) + await post(server, { + ...base, + ...turn, + hook_event_name: 'PostToolUse', + tool_name: 'Read', + tool_input: { file_path: 'README.md' }, + tool_use_id: 'toolu_sibling' + }) + expect(row(server)).toMatchObject({ state: 'waiting', toolName: 'Bash' }) + }) +}) + describe('other writers of the stored row, on an armed pane', () => { it('leaves an inferred question answer as it published it', async () => { const server = await startServer() @@ -341,7 +438,7 @@ describe('other writers of the stored row, on an armed pane', () => { }) ).toBe(true) const answered = row(server) - await realDelay(TWO_TICKS_MS) + await afterTick(server, transcript) expect(row(server)).toEqual(answered) }) diff --git a/src/main/agent-hooks/server/server-cancel-verdict-latch.ts b/src/main/agent-hooks/server/server-cancel-verdict-latch.ts index 8bfc3fe897bf..f4915952e8c6 100644 --- a/src/main/agent-hooks/server/server-cancel-verdict-latch.ts +++ b/src/main/agent-hooks/server/server-cancel-verdict-latch.ts @@ -88,11 +88,13 @@ function restatesAnotherPrompt( * on the provider's own verdict (any settled `mainAgent`) or a new turn (another prompt, an * explicit prompt, a prompt submission, a session start). Child-attributed and replayed events keep the latched main * agent and are re-folded with their own child evidence; late main agent work is held. + * `restatesRecords`: a local Claude transcript watch row that carries no fact. */ export function resolveCancelVerdictLatch( previous: EnrichedAgentHookEventPayload | undefined, incoming: AgentHookEventPayload, - now: number + now: number, + restatesRecords = false ): CancelVerdictLatchDecision { const apply: CancelVerdictLatchDecision = { hold: false, event: incoming } if ( @@ -105,6 +107,11 @@ export function resolveCancelVerdictLatch( ) { return apply } + // Why: it repeats hooks this latch already judged, and the watch offers it every second: held, + // it can never reopen the cancel once the window ends, nor rewrite the row each tick. + if (restatesRecords) { + return HOLD + } const latched = previous.payload.mainAgent // Why: Codex's combine is not this fold; its child events already come reconciled against main's marked record. if ( diff --git a/src/main/agent-hooks/server/server-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts index e1a4d87767f5..abcaefca9cc8 100644 --- a/src/main/agent-hooks/server/server-status-update.ts +++ b/src/main/agent-hooks/server/server-status-update.ts @@ -2,7 +2,6 @@ import { reconcileRemoteCodexState, markCodexLeadTurnInterrupted } from '../../../shared/agent-hook-listener/providers/codex-state' -import { markClaudeLeadTurnInterrupted } from '../../../shared/agent-hook-listener/providers/claude-roster-state' import { resolveAgentStatusIdentity, shouldSuppressInheritedTerminalStatus @@ -22,13 +21,13 @@ import { AgentHookServerStatusApplication } from './server-status-application' export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusApplication { protected applyNormalizedStatus( - incoming: AgentHookEventPayload & { authorityRestartId?: string }, + incoming: AgentHookEventPayload & { authorityRestartId?: string; restatesRecords?: true }, onAccepted?: () => void, origin: AgentStatusObservationOrigin = 'hook', observedAt?: number, mutationBefore?: EnrichedAgentHookEventPayload ): EnrichedAgentHookEventPayload | undefined { - const { authorityRestartId, ...payload } = incoming + const { authorityRestartId, restatesRecords, ...payload } = incoming if (!this.canWriteLegacyStatusRow(payload)) { return undefined } @@ -155,7 +154,12 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA const attachedPayload = attachClaudePermissionToolUseId(previous, identityResolvedPayload) // Why before the permission hold: that hold adopts the event's `mainAgent`, and a relay's // restatement of a main agent the desktop cancelled must not replace the cancel. - const latch = resolveCancelVerdictLatch(previous, attachedPayload, Date.now()) + const latch = resolveCancelVerdictLatch( + previous, + attachedPayload, + Date.now(), + restatesRecords === true + ) if (latch.hold) { if ( attachedPayload.payload.agentType === 'codex' && @@ -163,18 +167,6 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA ) { markCodexLeadTurnInterrupted(this.state, attachedPayload.paneKey) } - // Why: a local pane's listener already folded the held event into its record; restore the - // verdict the row keeps, or the Claude transcript watch restates the stale record once the - // latch window ends. A relayed pane's record lives on the relay. - if ( - attachedPayload.payload.agentType === 'claude' && - attachedPayload.payload.state === 'working' && - attachedPayload.connectionId === null - ) { - markClaudeLeadTurnInterrupted(this.state, attachedPayload.paneKey, { - stateStartedAt: previous?.payload.mainAgent?.stateStartedAt - }) - } this.commitStatusRowMutation(rowBefore, previous) return previous } diff --git a/src/relay/agent-hook-result-retry-scheduler.ts b/src/relay/agent-hook-result-retry-scheduler.ts index 515dda7068d9..3f7163b36130 100644 --- a/src/relay/agent-hook-result-retry-scheduler.ts +++ b/src/relay/agent-hook-result-retry-scheduler.ts @@ -60,7 +60,9 @@ export class AgentHookResultRetryScheduler { this.claudeTranscriptWatch = new ClaudeTranscriptWatch({ state: host.state, isListening: host.isListening, - publish: (row, meta) => host.applyEvent(row, 'claude', meta?.env, meta?.version) + // Why dropped: only the desktop's cancel latch reads it, and a relay envelope names its fields. + publish: ({ restatesRecords: _local, ...row }, meta) => + host.applyEvent(row, 'claude', meta?.env, meta?.version) }) } diff --git a/src/shared/agent-hook-listener/claude-transcript-watch-ticks.ts b/src/shared/agent-hook-listener/claude-transcript-watch-ticks.ts index d52ef32138b4..e43c7027198b 100644 --- a/src/shared/agent-hook-listener/claude-transcript-watch-ticks.ts +++ b/src/shared/agent-hook-listener/claude-transcript-watch-ticks.ts @@ -7,7 +7,8 @@ import type { HookListenerState } from './listener-state' import { observeClaudeTranscript, settleClaudeTranscriptWatch, - syncClaudeTranscriptCursor + syncClaudeTranscriptCursor, + type ClaudeTranscriptRow } from './providers/claude-transcript-watch' const CLAUDE_TRANSCRIPT_WATCH_MS = 1_000 @@ -17,7 +18,7 @@ export type ClaudeTranscriptWatchHost = { /** Read live, so a tick armed before stop() cannot publish on a downed server. */ isListening: () => boolean /** `context` is what the host last handed `sync` for the pane (the relay's hook envelope). */ - publish: (row: AgentHookEventPayload, context: Context | undefined) => void + publish: (row: ClaudeTranscriptRow, context: Context | undefined) => void } export class ClaudeTranscriptWatch { 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 ec1f8162e392..9e91610b9e71 100644 --- a/src/shared/agent-hook-listener/providers/claude-roster-state.ts +++ b/src/shared/agent-hook-listener/providers/claude-roster-state.ts @@ -200,14 +200,11 @@ export function resolveClaudePaneStatus( * and leave only when their inventory says so. */ export function markClaudeLeadTurnInterrupted( state: HookListenerState, - paneKey: string, - /** The published verdict's own clock, when restoring a verdict the store kept. */ - verdict: { stateStartedAt?: number } = {} + paneKey: string ): { state: AgentStatusState; workingMode?: AgentWorkingMode; mainAgent?: AgentMainAgentStatus } { const record = setClaudeMainAgentTurnState(state, paneKey, { state: 'done', - outcome: 'cancellation', - ...(verdict.stateStartedAt !== undefined ? { stateStartedAt: verdict.stateStartedAt } : {}) + outcome: 'cancellation' }) const resolved = resolveClaudePaneStatus(state, paneKey, record) const mainAgent = claudeMainAgentStatusForPayload(record) diff --git a/src/shared/agent-hook-listener/providers/claude-transcript-watch.test.ts b/src/shared/agent-hook-listener/providers/claude-transcript-watch.test.ts index 0a55a4471d16..437a44364271 100644 --- a/src/shared/agent-hook-listener/providers/claude-transcript-watch.test.ts +++ b/src/shared/agent-hook-listener/providers/claude-transcript-watch.test.ts @@ -274,6 +274,8 @@ describe('observeClaudeTranscript', () => { payload: { state: 'done', mainAgent: { state: 'done' } } } }) + // A fact is the hook Claude did not send, not a restatement. + expect(observed).not.toHaveProperty('row.restatesRecords') expect(state.claudeSubagentRosterByPaneKey.has(PANE)).toBe(false) expect(state.claudeLeadStateByPaneKey.get(PANE)).toMatchObject({ state: 'done' }) if (observed.kind === 'read' && observed.row) { @@ -314,7 +316,10 @@ describe('observeClaudeTranscript', () => { // A change of the records no row carried (an event that produced no row). stopClaudeSubagent(getOrCreateClaudeSubagentRoster(state, PANE), 'a1') const observed = observeClaudeTranscript(state, PANE) - expect(observed).toMatchObject({ kind: 'read', row: { payload: { state: 'done' } } }) + expect(observed).toMatchObject({ + kind: 'read', + row: { restatesRecords: true, payload: { state: 'done' } } + }) expect(observed.kind === 'read' && observed.row?.hookEventName).toBeUndefined() }) @@ -345,6 +350,38 @@ describe('observeClaudeTranscript', () => { }) }) + it("keeps a main agent's waiting hook when the fact retired another child", () => { + const transcript = transcriptFile() + const state = paneWithWorkingChild() + setClaudeMainAgentTurnState(state, PANE, { state: 'waiting' }) + const payload = buildClaudeCachedLeadStatusPayload(state, 'PermissionRequest', PANE, {}) + if (!payload) { + throw new Error('no row') + } + seedLegacyAgentStatusForTests( + state, + accepted(transcript, { + hookEventName: 'PermissionRequest', + toolUseId: 'toolu_1', + claudeRunningNonAgentTask: false, + payload + }) + ) + syncClaudeTranscriptCursor(state, accepted(transcript)) + appendFileSync(transcript, `${KILLED}\n`) + const observed = observeClaudeTranscript(state, PANE) + expect(observed).toMatchObject({ + factApplied: true, + row: { + hookEventName: 'PermissionRequest', + toolUseId: 'toolu_1', + toolAgentId: undefined, + payload: { state: 'waiting' } + } + }) + expect(observed.kind === 'read' && observed.row?.payload.subagents).toBeUndefined() + }) + it('never restates a dismissed row, but a fact still does what its missing hook would', () => { const transcript = transcriptFile() const state = paneWithWorkingChild() diff --git a/src/shared/agent-hook-listener/providers/claude-transcript-watch.ts b/src/shared/agent-hook-listener/providers/claude-transcript-watch.ts index e4b0c4a3a9b9..1a49b9e26266 100644 --- a/src/shared/agent-hook-listener/providers/claude-transcript-watch.ts +++ b/src/shared/agent-hook-listener/providers/claude-transcript-watch.ts @@ -242,9 +242,13 @@ function claudeRowShowsRecords( ) } +/** A row the tick rebuilt from the records. `restatesRecords` marks one that carries no fact: it + * only repeats what hooks already offered, so a store that refused those may refuse it too. */ +export type ClaudeTranscriptRow = AgentHookEventPayload & { restatesRecords?: true } + export type ClaudeTranscriptObservation = | { kind: 'stop' } - | { kind: 'read'; factApplied: boolean; row?: AgentHookEventPayload } + | { kind: 'read'; factApplied: boolean; row?: ClaudeTranscriptRow } /** One tick: reads the cursor, then rebuilds the row from the records with the hooks' own builder * and returns it when the stored row does not show it yet, or when a fact named how it is @@ -280,17 +284,19 @@ export function observeClaudeTranscript( (current.providerSessionOnly === true || claudeRowShowsRecords(current, payload, runningNonAgentTask))) // Why: a row still waiting keeps the hook that raised the wait, which the store's permission - // rules key on; any other row is an observation, not a new turn. - const hook = - attribution ?? - (payload?.state === 'waiting' && current.hookEventName - ? { - hookEventName: current.hookEventName, - toolAgentId: current.toolAgentId, - toolUseId: current.toolUseId, - toolAgentType: current.toolAgentType - } - : {}) + // rules key on, unless the fact retired that wait's own child; any other row is an observation. + const keepsWait = + payload?.state === 'waiting' && + current.hookEventName !== undefined && + (!attribution || attribution.toolAgentId !== current.toolAgentId) + const hook = keepsWait + ? { + hookEventName: current.hookEventName, + toolAgentId: current.toolAgentId, + toolUseId: current.toolUseId, + toolAgentType: current.toolAgentType + } + : (attribution ?? {}) return { kind: 'read', factApplied: read !== undefined, @@ -305,6 +311,7 @@ export function observeClaudeTranscript( worktreeId: current.worktreeId, connectionId: null, ...hook, + ...(attribution ? {} : { restatesRecords: true }), claudeRunningNonAgentTask: runningNonAgentTask, ...(current.providerSession ? { providerSession: current.providerSession } : {}), payload From fe3a427c8d692c85f932ce8d61350b29bebe3964 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:35:31 -0700 Subject: [PATCH 5/5] fix(agent-status): a moved pane resumes only a watch it had, and a waiting row keeps its hook Moving a pane now keeps ticking only a transcript cursor an accepted row armed; a row restored from disk and moved before its first hook arms nothing. A row the tick restates while it still waits keeps the hook that raised the wait. The extra check against the retired child never decided anything: a fact that retires the wait's owner has already ended the wait. Adds a relay regression test: after the desktop infers a cancel the relay never learns of, relay ticks past the latch window forward nothing and the desktop keeps the cancel through the child's next hook. --- docs/reference/agent-status-store.md | 3 +- .../server-claude-transcript-watch.test.ts | 22 ++++++++++-- .../server-relayed-claude-cancel.test.ts | 34 ++++++++++++++++++- .../server/server-status-retries.ts | 5 ++- src/relay/agent-hook-server.ts | 4 +++ .../providers/claude-transcript-watch.ts | 8 ++--- 6 files changed, 66 insertions(+), 10 deletions(-) diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index 80fd4fee34f4..00ba6c24c945 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -291,7 +291,8 @@ cancel latch and the permission hold; on a relay, its own cache). It disarms onl after a read that applied nothing with no reason left, or when the pane closes. A new line retires the children that started by its timestamp with SubagentStop semantics and publishes a `SubagentStop` row attributed to one of -them, with no verdict stamped; the row keeps monitoring a surviving shell and +them (a row still waiting on a prompt keeps the hook that raised it), with no +verdict stamped; the row keeps monitoring a surviving shell and settles to done only when nothing is left (`claude-idle-ctrl-c-*` fixtures pin this). A tick row that carries no fact only restates hooks the store already judged, so the desktop's cancel latch holds it: after a late main-agent hook the diff --git a/src/main/agent-hooks/server-claude-transcript-watch.test.ts b/src/main/agent-hooks/server-claude-transcript-watch.test.ts index 6eb58b213c39..78d727bd3bdb 100644 --- a/src/main/agent-hooks/server-claude-transcript-watch.test.ts +++ b/src/main/agent-hooks/server-claude-transcript-watch.test.ts @@ -62,10 +62,10 @@ function transcriptFile(content = ''): string { return path } -async function startServer(): Promise { +async function startServer(options: { userDataPath?: string } = {}): Promise { const server = new AgentHookServer() running.push(server) - await server.start({ env: 'production' }) + await server.start({ env: 'production', ...options }) return server } @@ -292,6 +292,24 @@ describe('a pane moved to another key', () => { workingMode: 'monitoring' }) }) + + it('arms nothing for a pane moved before any hook armed it', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-claude-transcript-watch-restart-')) + temporaryPaths.push(userDataPath) + const transcript = transcriptFile() + const first = new AgentHookServer() + running.push(first) + await first.start({ env: 'production', userDataPath }) + await replay(first, transcript, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) + first.flushStatusPersistSync() + first.stop() + // After a restart the row and its working child come back from disk, with no hook yet. + const server = await startServer({ userDataPath }) + expect(server._getStateForTests().claudeSubagentRosterByPaneKey.has(PANE)).toBe(true) + expect(cursor(server)).toBeUndefined() + server.transferPaneAuthority(PANE, GOOD_PANE) + expect(server._getStateForTests().claudeTranscriptCursorByPaneKey.has(GOOD_PANE)).toBe(false) + }) }) describe('a session change on the pane', () => { diff --git a/src/main/agent-hooks/server-relayed-claude-cancel.test.ts b/src/main/agent-hooks/server-relayed-claude-cancel.test.ts index ef9d72c12b46..501fdf726134 100644 --- a/src/main/agent-hooks/server-relayed-claude-cancel.test.ts +++ b/src/main/agent-hooks/server-relayed-claude-cancel.test.ts @@ -2,7 +2,7 @@ // the hooks go through a real relay-side listener, which owns the provider records, and reach the // desktop only as relayed payloads. The relay never learns of the cancel the desktop infers from // Ctrl+C, so everything it restates afterwards still says the main agent is working. -import { appendFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { appendFileSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -445,6 +445,14 @@ describe('a relayed idle-prompt Ctrl+C that killed a background agent (captured) } } + /** Hand-built: a line no reason admits, then waits for the relay tick that reads past it. */ + async function afterRelayTick(pane: SshPane, transcript: string): Promise { + appendFileSync(transcript, '{"type":"user"}\n') + const end = statSync(transcript).size + const cursors = pane.relay._getStateForTests().claudeTranscriptCursorByPaneKey + await vi.waitFor(() => expect(cursors.get(PANE)?.offset).toBe(end), { timeout: 3_000 }) + } + async function kill(pane: SshPane, transcript: string): Promise { vi.setSystemTime(killedAt + 120) appendFileSync(transcript, `${killedLine}\n`) @@ -531,6 +539,30 @@ describe('a relayed idle-prompt Ctrl+C that killed a background agent (captured) expect(row(pane.desktop).subagents).toBeUndefined() }) + it("keeps the desktop's inferred cancel when the relay ticks past the window with its main agent working", async () => { + const pane = await startSshPane(new AgentHookServer()) + const transcript = transcriptFile() + await replay(pane, transcript, [0, 1, 2, 3, 4, 5, 6, 7, 8]) + expect(pressCtrlC(pane.desktop)).toBe(true) + const cancelled = row(pane.desktop) + // Hand-built timing: a late main-agent hook of the cancelled turn, which the latch holds. + vi.setSystemTime(Date.now() + 100) + await pane.post({ ...hookAt(records, 7).payload, transcript_path: transcript }) + expect(row(pane.desktop)).toEqual(cancelled) + vi.setSystemTime(Date.now() + 20_000) + const forwarded = vi.spyOn(pane.desktop, 'ingestRemote') + await afterRelayTick(pane, transcript) + // The relay's cache already shows its records, so its tick has nothing to restate. + expect(forwarded).not.toHaveBeenCalled() + expect(row(pane.desktop)).toEqual(cancelled) + // The child's next hook still names the relay's working main agent; the desktop re-folds it. + await pane.post({ ...hookAt(records, 10).payload, transcript_path: transcript }) + const refolded = row(pane.desktop) + expect(refolded.mainAgent).toMatchObject({ state: 'done', outcome: 'cancellation' }) + await afterRelayTick(pane, transcript) + expect(row(pane.desktop)).toEqual(refolded) + }) + it('catches up on the relay before it normalizes the next hook', async () => { const pane = await startSshPane(new AgentHookServer()) const transcript = transcriptFile() diff --git a/src/main/agent-hooks/server/server-status-retries.ts b/src/main/agent-hooks/server/server-status-retries.ts index 31de96145bcb..02f8f9844fe0 100644 --- a/src/main/agent-hooks/server/server-status-retries.ts +++ b/src/main/agent-hooks/server/server-status-retries.ts @@ -58,7 +58,10 @@ export abstract class AgentHookServerStatusRetries extends AgentHookServerStatus /** Resumes a moved pane's Claude transcript watch under the key its row and cursor moved to. */ protected resumeClaudeTranscriptWatch(moved: EnrichedAgentHookEventPayload): void { - this.claudeTranscriptWatch.sync(moved) + // Why: a move is no accepted row; only a cursor one armed may keep ticking. + if (this.state.claudeTranscriptCursorByPaneKey.has(moved.paneKey)) { + this.claudeTranscriptWatch.sync(moved) + } } protected scheduleTranscriptPoll( diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index 07f9104a0246..cb23eef8405f 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -245,6 +245,10 @@ export class RelayAgentHookServer { return { port: this.port, token: this.token, endpointFilePath: this.endpointFilePath } } + _getStateForTests(): HookListenerState { + return this.state + } + // ─── Private ────────────────────────────────────────────────────── private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise { diff --git a/src/shared/agent-hook-listener/providers/claude-transcript-watch.ts b/src/shared/agent-hook-listener/providers/claude-transcript-watch.ts index 1a49b9e26266..a20643ecbd78 100644 --- a/src/shared/agent-hook-listener/providers/claude-transcript-watch.ts +++ b/src/shared/agent-hook-listener/providers/claude-transcript-watch.ts @@ -284,11 +284,9 @@ export function observeClaudeTranscript( (current.providerSessionOnly === true || claudeRowShowsRecords(current, payload, runningNonAgentTask))) // Why: a row still waiting keeps the hook that raised the wait, which the store's permission - // rules key on, unless the fact retired that wait's own child; any other row is an observation. - const keepsWait = - payload?.state === 'waiting' && - current.hookEventName !== undefined && - (!attribution || attribution.toolAgentId !== current.toolAgentId) + // rules key on; a fact that retired the wait's owner already ended the wait. Any other row is + // an observation. + const keepsWait = payload?.state === 'waiting' && current.hookEventName !== undefined const hook = keepsWait ? { hookEventName: current.hookEventName,