From 634c43787c177c8a9aaa4bf6435f4cc87c71e840 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Tue, 6 Oct 2026 08:02:18 +0000 Subject: [PATCH] Fix premature Claude automation completion and inherited CI failures (#24878) * fix(agent-status): keep a Claude pane working until owed task wake-ups arrive Claude wakes the main agent for each background task that ends, after the task stops running. "Nothing running" was read as done, so automations closed the terminal before the final turn. Fixes #23942 * fix(agent-status): keep owed task wake-ups through failed turns, the cap and nested exits A failed turn's task list now marks a vanished shell owed like a normal turn end does. Past the cap only an already-announced sub-agent is forgotten. A process exit clears what is owed only where the server admits it from the pane's owner. * refactor(agent-status): move pane-scoped cache entry helpers out of listener-state listener-state.ts passed the 300-line limit once main's and this branch's additions met. * fix(agent-status): verify Claude task wake-up completion on its execution host * fix(agent-status): canonicalize Claude background task identifiers * Preserve out-of-order Claude task notification evidence * Verify retained released parser in cross-version checkout test * Reuse watcher directory-cache enumeration without losing fresh keys --------- Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> --- config/reliability-gates.jsonc | 109 ++++ .../server-claude-cancel-captures.test.ts | 16 + ...er-claude-owed-notification-expiry.test.ts | 114 ++++ ...erver-claude-task-wakeup-lifecycle.test.ts | 573 ++++++++++++++++++ .../server-relayed-claude-cancel.test.ts | 19 +- .../server/server-cancel-verdict-latch.ts | 134 +--- .../server/server-claude-status-rules.ts | 13 +- src/main/agent-hooks/server/server-cleanup.ts | 1 + .../agent-hooks/server/server-constants.ts | 6 - .../server/server-ingest-remote.ts | 2 + .../agent-hooks/server/server-lifecycle.ts | 1 + .../agent-hooks/server/server-persistence.ts | 4 + .../server-remote-envelope-normalization.ts | 1 + .../server/server-row-child-work-fold.ts | 29 +- .../server/server-status-identity.ts | 14 +- .../server/server-status-inference.ts | 53 ++ .../server/server-status-update.ts | 32 + src/main/agent-hooks/server/server-types.ts | 3 +- src/main/agent-hooks/wsl-hook-relay-deps.ts | 8 + src/main/agent-hooks/wsl-hook-relay-link.ts | 9 +- .../wsl-hook-relay-manager.test.ts | 19 + .../agent-hooks/wsl-hook-relay-manager.ts | 1 + .../session-scanner-claude-subagents.ts | 18 +- src/main/orcad/orcad-entry.ts | 1 + .../claude-ready-task-wakeup.meta.json | 11 + .../__fixtures__/claude-ready-task-wakeup.txt | 1 + .../claude-task-wakeup-indexed-read.test.ts | 83 +++ ...-authoritative-terminal-wait-permission.ts | 16 +- src/main/runtime/orca-runtime-state-fields.ts | 6 + .../orchestration/worker-provider-session.ts | 4 +- .../tui-idle-claude-task-wakeup.test.ts | 136 +++++ src/main/runtime/tui-idle-hook-lane.test.ts | 4 +- src/main/runtime/tui-idle-hook-lane.ts | 31 + ...gent-hook-interrupt-reconciliation.test.ts | 84 +++ ...ssh-agent-hook-interrupt-reconciliation.ts | 22 + src/main/ssh/ssh-relay-session.ts | 12 + .../startup/main-process-runtime-service.ts | 1 + src/relay/agent-hook-envelope-build.ts | 1 + src/relay/agent-hook-event-admission.ts | 91 +++ ...gent-hook-interrupt-reconciliation.test.ts | 166 +++++ .../agent-hook-interrupt-reconciliation.ts | 145 +++++ src/relay/agent-hook-request.ts | 1 + .../agent-hook-result-retry-scheduler.ts | 18 + ...er-claude-owed-notification-expiry.test.ts | 69 +++ src/relay/agent-hook-server.ts | 95 ++- src/relay/relay-agent-hook-runtime.ts | 4 + src/relay/wsl-agent-hook-relay.ts | 4 + .../right-sidebar/file-explorer-watch-path.ts | 5 +- .../file-explorer-watch-reconcile.test.ts | 87 ++- .../file-explorer-watch-reconcile.ts | 9 +- .../file-explorer-watcher-reconcile.ts | 6 +- .../claude-task-notification-hooks.jsonl | 186 ++++++ .../claude-task-notification-hooks.meta.json | 24 + src/shared/agent-hook-cancel-verdict-latch.ts | 145 +++++ .../agent-hook-interrupt-reconciliation.ts | 20 + ...claude-reordered-task-notification.test.ts | 134 ++++ ...-listener-claude-task-notification.test.ts | 553 +++++++++++++++++ src/shared/agent-hook-listener.ts | 5 +- .../agent-hook-listener/listener-event.ts | 4 +- .../agent-hook-listener/listener-state.ts | 62 +- .../main-agent-turn-state.ts | 11 +- .../pane-scoped-cache-entries.ts | 50 ++ .../providers/claude-events.ts | 28 +- .../providers/claude-lifecycle-events.ts | 35 +- .../providers/claude-pane-hold-evidence.ts | 88 +++ .../providers/claude-roster-state.ts | 54 +- .../providers/claude-status-build.ts | 2 + .../claude-task-notification-delivery.ts | 65 ++ src/shared/agent-hook-relay.ts | 4 +- src/shared/agent-hook-row-child-work-fold.ts | 25 + src/shared/agent-status-types.ts | 15 +- .../claude-background-task-inventory.ts | 17 +- .../claude-owed-notification-expiry-timers.ts | 83 +++ ...wed-notification-resource-contract.test.ts | 93 +++ src/shared/claude-owed-task-notifications.ts | 254 ++++++++ src/shared/claude-task-notification-text.ts | 22 + .../release-checkout.unit.test.ts | 22 +- 77 files changed, 3926 insertions(+), 367 deletions(-) create mode 100644 src/main/agent-hooks/server-claude-owed-notification-expiry.test.ts create mode 100644 src/main/agent-hooks/server-claude-task-wakeup-lifecycle.test.ts create mode 100644 src/main/runtime/__fixtures__/claude-ready-task-wakeup.meta.json create mode 100644 src/main/runtime/__fixtures__/claude-ready-task-wakeup.txt create mode 100644 src/main/runtime/claude-task-wakeup-indexed-read.test.ts create mode 100644 src/main/runtime/tui-idle-claude-task-wakeup.test.ts create mode 100644 src/main/ssh/ssh-agent-hook-interrupt-reconciliation.test.ts create mode 100644 src/main/ssh/ssh-agent-hook-interrupt-reconciliation.ts create mode 100644 src/relay/agent-hook-event-admission.ts create mode 100644 src/relay/agent-hook-interrupt-reconciliation.test.ts create mode 100644 src/relay/agent-hook-interrupt-reconciliation.ts create mode 100644 src/relay/agent-hook-server-claude-owed-notification-expiry.test.ts create mode 100644 src/shared/__fixtures__/claude-task-notification-hooks.jsonl create mode 100644 src/shared/__fixtures__/claude-task-notification-hooks.meta.json create mode 100644 src/shared/agent-hook-cancel-verdict-latch.ts create mode 100644 src/shared/agent-hook-interrupt-reconciliation.ts create mode 100644 src/shared/agent-hook-listener-claude-reordered-task-notification.test.ts create mode 100644 src/shared/agent-hook-listener-claude-task-notification.test.ts create mode 100644 src/shared/agent-hook-listener/pane-scoped-cache-entries.ts create mode 100644 src/shared/agent-hook-listener/providers/claude-pane-hold-evidence.ts create mode 100644 src/shared/agent-hook-listener/providers/claude-task-notification-delivery.ts create mode 100644 src/shared/agent-hook-row-child-work-fold.ts create mode 100644 src/shared/claude-owed-notification-expiry-timers.ts create mode 100644 src/shared/claude-owed-notification-resource-contract.test.ts create mode 100644 src/shared/claude-owed-task-notifications.ts create mode 100644 src/shared/claude-task-notification-text.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index b8fa9cefb45..6df235c0a0f 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -10,6 +10,115 @@ } }, "gates": [ + { + "id": "agent-status.claude-task-wakeup-cycle", + "title": "Claude task wake-ups cannot finish an automation before its lead turn", + "maturity": "experimental", + "protection": "partial", + "owner": "agent-status", + "layer": "execution-host-hooks-and-runtime-wait", + "surfaces": [ + "canonical agent status", + "terminal automation completion", + "local and relayed cancellation" + ], + "platforms": [ + "macos", + "linux", + "windows" + ], + "providers": [ + "local", + "ssh", + "wsl", + "remote-runtime" + ], + "coveredPlatforms": [ + "macos" + ], + "coveredProviders": [ + "local", + "remote-runtime" + ], + "coverageNotes": "Real HTTP hook ingress and relay forwarding, production runtime automation observer, and captured Claude native ready bytes run locally on macOS. SSH/WSL transport ownership is simulated; physical remote hosts and native Windows/Linux are not verified.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/pull/24878", + "https://github.com/stablyai/orca/issues/23942" + ], + "invariant": "An ended tracked task stays pending until its wake-up and finishing turn end; missing wake-ups expire only after 60 idle seconds. Cancellation remains owned by the executing host, duplicate delivery cannot create a fresh completion veto, and fresh native rest can recover a lost finishing Stop.", + "oracle": "Replay captured hook ordering through production HTTP entry points and native ready bytes through the actual automation observer. Check local/relay state, cancellation/new-turn fencing, fresh versus retained rest, bounded task/timer ownership, and zero global status reads during indexed runtime waits.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 node node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/agent-hooks/server-claude-task-wakeup-lifecycle.test.ts src/main/runtime/tui-idle-claude-task-wakeup.test.ts src/main/runtime/claude-task-wakeup-indexed-read.test.ts src/shared/claude-owed-notification-resource-contract.test.ts src/shared/agent-hook-listener-claude-task-notification.test.ts src/shared/agent-hook-listener-claude-reordered-task-notification.test.ts src/relay/agent-hook-interrupt-reconciliation.test.ts src/main/ssh/ssh-agent-hook-interrupt-reconciliation.test.ts" + ], + "testFiles": [ + "src/main/agent-hooks/server-claude-task-wakeup-lifecycle.test.ts", + "src/main/runtime/tui-idle-claude-task-wakeup.test.ts", + "src/main/runtime/claude-task-wakeup-indexed-read.test.ts", + "src/shared/claude-owed-notification-resource-contract.test.ts", + "src/shared/agent-hook-listener-claude-task-notification.test.ts", + "src/shared/agent-hook-listener-claude-reordered-task-notification.test.ts", + "src/relay/agent-hook-interrupt-reconciliation.test.ts", + "src/main/ssh/ssh-agent-hook-interrupt-reconciliation.test.ts" + ], + "assertionRefs": [ + { + "file": "src/main/agent-hooks/server-claude-task-wakeup-lifecycle.test.ts", + "assertions": [ + "does not reopen notification debt for a duplicate child-end post", + "waits on captured ready bytes through task finishing or missing wake-up expiry: %s" + ] + }, + { + "file": "src/main/runtime/claude-task-wakeup-indexed-read.test.ts", + "assertions": [ + "reads only its pane with 512 unrelated agents, including an empty index: pending=%s" + ] + }, + { + "file": "src/shared/claude-owed-notification-resource-contract.test.ts", + "assertions": [ + "caps live task records without evicting a tracked running task", + "shares one timer across 512 panes and publishes nothing for closed owners" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-10-06", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 node node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/agent-hooks/server-claude-task-wakeup-lifecycle.test.ts src/main/runtime/tui-idle-claude-task-wakeup.test.ts src/main/runtime/claude-task-wakeup-indexed-read.test.ts src/shared/claude-owed-notification-resource-contract.test.ts src/shared/agent-hook-listener-claude-task-notification.test.ts src/shared/agent-hook-listener-claude-reordered-task-notification.test.ts src/relay/agent-hook-interrupt-reconciliation.test.ts src/main/ssh/ssh-agent-hook-interrupt-reconciliation.test.ts", + "result": "passed", + "durationSeconds": 8.62, + "summary": "Exact stored command with repository Vitest configuration passes 148 tests across eight files including out-of-order launch/end/delivery controls. Includes real HTTP/relay ingress, 12 blank/padded Agent/Bash/Monitor IDs, captured-native-rest automation completion, duplicate/cancellation controls, owner/phase gates, timer/task bounds and indexed reads. Three upstream interrupt suites pass 28 more tests separately." + } + ], + "runtimeBudget": { + "p95Seconds": 30, + "scope": "Focused local host/relay/runtime suite; CI p95 and platform soak not established." + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Deterministic local fake-clock replay and real HTTP ingress pass; no 100-run or cross-platform soak claim." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "Identical corrected production automation test with actual Claude launch ownership prematurely completes on both parent main and the original PR; repaired candidate holds through task finishing. Same endpoint captures fail 12 of 18 parent tests. Indexed runtime counters fail before targeted reads and pass after." + }, + "performanceBudget": { + "required": true, + "evidence": "At most 256 tracked tasks per pane, one shared host deadline timer across 512 panes, no publication after pane cleanup, and zero full-store reads with 512 irrelevant agents on the indexed automation wait path. Uses existing hook-server pane index; no reader cache or polling loop added." + }, + "knownGaps": [ + "Missing notifications intentionally postpone completion up to 60 idle seconds plus the existing runtime polling interval.", + "Older remote hosts without optional phase/revision metadata retain their previous behavior.", + "Physical SSH/WSL, native Windows/Linux, packaged Electron and an authenticated end-to-end Claude automation run are unverified." + ], + "promotionCriteria": [ + "Collect cross-platform and physical remote-host evidence plus the policy soak threshold without relaxing owner, phase, deadline or resource assertions." + ], + "demotionRule": "Remain experimental until platform and soak evidence; investigate failures without extending deadlines or accepting foreign/stale ownership." + }, { "id": "profile-storage.sqlite-authority-without-automatic-json", "title": "SQLite remains authoritative after automatic JSON snapshots are retired", diff --git a/src/main/agent-hooks/server-claude-cancel-captures.test.ts b/src/main/agent-hooks/server-claude-cancel-captures.test.ts index 196c41fbea7..11d9f7b69dd 100644 --- a/src/main/agent-hooks/server-claude-cancel-captures.test.ts +++ b/src/main/agent-hooks/server-claude-cancel-captures.test.ts @@ -9,6 +9,7 @@ // closes the /btw composer) and infers a cancel only from Ctrl+C, so each `cancel` record is // replayed as the Ctrl+C inference the renderer would have sent. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { CLAUDE_OWED_TASK_NOTIFICATION_LEASE_MS } from '../../shared/claude-owed-task-notifications' import { AgentHookServer, _internals } from './server' import { buildBody, PANE, postHookEvent } from './server.test-fixtures' import { @@ -329,6 +330,11 @@ describe('a Claude cancel with a live subagent (captured)', () => { }) it('settles the drained row as a stopped turn, never a completed one', async () => { + // Why shouldAdvanceTime: the hooks are real loopback POSTs, which need the clock to move. + vi.useFakeTimers({ + shouldAdvanceTime: true, + toFake: ['setTimeout', 'clearTimeout', 'Date', 'performance'] + }) const server = await startServer() try { for (const index of [0, 1, 2, 3, 4, 5, 6, 7, 8]) { @@ -346,6 +352,15 @@ describe('a Claude cancel with a live subagent (captured)', () => { payload: { ...hookAt(records, index).payload, hook_event_name: 'SubagentStop' } }) } + // Claude owes the main agent a task notification for the launched child, so the drained row + // is held, unstamped, until it arrives or stops being waited for. + expect(row(server)).toMatchObject({ + state: 'working', + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + expect(row(server).turnCompletedAt).toBeUndefined() + + vi.advanceTimersByTime(CLAUDE_OWED_TASK_NOTIFICATION_LEASE_MS) expect(row(server)).toMatchObject({ state: 'done', interrupted: true, @@ -354,6 +369,7 @@ describe('a Claude cancel with a live subagent (captured)', () => { expect(row(server).turnCompletedAt).toBeUndefined() } finally { server.stop() + vi.useRealTimers() } }) }) diff --git a/src/main/agent-hooks/server-claude-owed-notification-expiry.test.ts b/src/main/agent-hooks/server-claude-owed-notification-expiry.test.ts new file mode 100644 index 00000000000..ce86479d2b5 --- /dev/null +++ b/src/main/agent-hooks/server-claude-owed-notification-expiry.test.ts @@ -0,0 +1,114 @@ +// A task notification that never arrives must not hold the pane working for the rest of the +// session: no hook fires at the end of the lease, so the server restates the row itself. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { CLAUDE_OWED_TASK_NOTIFICATION_LEASE_MS } from '../../shared/claude-owed-task-notifications' +import { AgentHookServer, _internals } from './server' +import { buildBody, PANE, postHookEvent, RUNNING_SHELL } from './server.test-fixtures' + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: vi.fn(() => ({ nth_repo_added: 2 })) +})) + +beforeEach(() => { + _internals.resetCachesForTests() + // Why shouldAdvanceTime: the hooks are real loopback POSTs, which need the clock to move. + vi.useFakeTimers({ + shouldAdvanceTime: true, + toFake: ['setTimeout', 'clearTimeout', 'Date', 'performance'] + }) +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('Claude owed task notification expiry on the hook server', () => { + it('settles a pane whose launched shell vanished without a notification', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + const states: string[] = [] + server.setListener((event) => states.push(event.payload.state)) + const post = (payload: Record) => + postHookEvent(server, buildBody({ session_id: 'session-1', ...payload })) + try { + await post({ hook_event_name: 'UserPromptSubmit', prompt: 'start the dev server' }) + await post({ + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_response: { backgroundTaskId: RUNNING_SHELL.id } + }) + await post({ hook_event_name: 'Stop', background_tasks: [RUNNING_SHELL] }) + await post({ hook_event_name: 'UserPromptSubmit', prompt: 'thanks' }) + await post({ hook_event_name: 'Stop', background_tasks: [] }) + expect(server.getStatusSnapshotForPane(PANE)[0]?.state).toBe('working') + + vi.advanceTimersByTime(CLAUDE_OWED_TASK_NOTIFICATION_LEASE_MS) + + expect(server.getStatusSnapshotForPane(PANE)[0]?.state).toBe('done') + expect(states.at(-1)).toBe('done') + } finally { + server.stop() + } + }) + + it('drops what was owed when the owning Claude process exits', async () => { + const server = await heldByAnOwedShell() + try { + await server.post({ hook_event_name: 'SessionEnd', reason: 'prompt_input_exit' }, OWNER) + vi.advanceTimersByTime(CLAUDE_OWED_TASK_NOTIFICATION_LEASE_MS) + + // Only resume identity is left; nothing restates the exited agent as live. + expect(server.row()).toMatchObject({ providerSessionOnly: true }) + } finally { + server.stop() + } + }) + + it('keeps what is owed when a nested Claude in the same pane exits', async () => { + const server = await heldByAnOwedShell() + try { + // The nested process is not the pane's owner, so its exit is refused. + await server.post({ hook_event_name: 'SessionEnd', reason: 'prompt_input_exit' }, 5151) + expect(server.row()).toMatchObject({ state: 'working' }) + expect(server.row().providerSessionOnly).toBeUndefined() + + vi.advanceTimersByTime(CLAUDE_OWED_TASK_NOTIFICATION_LEASE_MS) + expect(server.row()).toMatchObject({ state: 'done' }) + } finally { + server.stop() + } + }) +}) + +const OWNER = 4242 + +/** A pane whose owning Claude launched a background shell that then vanished unannounced. */ +async function heldByAnOwedShell() { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + const agentProcess = (pid: number) => + JSON.stringify({ pid, platform: 'darwin', startTime: 'Fri Oct 2 12:00:00 2026' }) + const post = (payload: Record, pid = OWNER) => + postHookEvent( + server, + buildBody({ session_id: 'session-1', ...payload }, { agentProcess: agentProcess(pid) }) + ) + await post({ hook_event_name: 'UserPromptSubmit', prompt: 'start the dev server' }) + await post({ + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_response: { backgroundTaskId: RUNNING_SHELL.id } + }) + await post({ hook_event_name: 'Stop', background_tasks: [RUNNING_SHELL] }) + await post({ hook_event_name: 'UserPromptSubmit', prompt: 'thanks' }) + await post({ hook_event_name: 'Stop', background_tasks: [] }) + const row = () => { + const entry = server.getStatusSnapshotForPane(PANE)[0] + if (!entry) { + throw new Error('the pane has no row') + } + return entry + } + return { post, row, stop: () => server.stop() } +} diff --git a/src/main/agent-hooks/server-claude-task-wakeup-lifecycle.test.ts b/src/main/agent-hooks/server-claude-task-wakeup-lifecycle.test.ts new file mode 100644 index 00000000000..a270b462202 --- /dev/null +++ b/src/main/agent-hooks/server-claude-task-wakeup-lifecycle.test.ts @@ -0,0 +1,573 @@ +import { readFileSync, mkdtempSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { RelayAgentHookServer } from '../../relay/agent-hook-server' +import { AgentHookServer, _internals } from './server' +import { createRuntimeAutomationRunTerminalObserver } from '../automations/runtime-terminal-run-observer' +import { + createTranscriptPane, + TRANSCRIPT_PANE_PTY_ID +} from '../runtime/agent-transcript-pane-test-harness' +import { buildBody, PANE, postHookEvent } from './server.test-fixtures' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn(() => ({})) })) + +type CapturedHook = { scenario: string; kind: string; payload?: Record } +const hooks: CapturedHook[] = readFileSync( + join(import.meta.dirname, '../../shared/__fixtures__/claude-task-notification-hooks.jsonl'), + 'utf8' +) + .trim() + .split('\n') + .map((line) => JSON.parse(line)) +const SESSION = '00000000-0000-4000-8000-000000000000' + +beforeEach(() => { + _internals.resetCachesForTests() +}) +afterEach(() => vi.useRealTimers()) + +async function host(remote: boolean, launchToken?: string) { + const desktop = new AgentHookServer() + const dir = mkdtempSync(join(tmpdir(), 'orca-task-wakeup-')) + const relay = remote + ? new RelayAgentHookServer({ + endpointDir: dir, + forward: (envelope) => desktop.ingestRemote(envelope, 'ssh-owner') + }) + : null + const unsubscribeInterrupt = relay + ? desktop.subscribeRemoteInterruptRequests(({ request }) => relay.inferInterrupt(request)) + : null + await desktop.start({ env: 'production' }) + await relay?.start({ publishEndpoint: false }) + const post = async (payload: Record) => { + const body = buildBody({ session_id: SESSION, ...payload }, { launchToken }) + const response = relay + ? await fetch(`http://127.0.0.1:${relay.getCoordinates().port}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': relay.getCoordinates().token + }, + body: JSON.stringify(body) + }) + : await postHookEvent(desktop, body) + expect(response.status).toBe(204) + } + const row = () => desktop.getStatusSnapshotForPane(PANE)[0] + const stop = () => { + unsubscribeInterrupt?.() + relay?.stop() + desktop.stop() + rmSync(dir, { recursive: true, force: true }) + } + return { desktop, post, row, stop } +} + +describe.each([false, true])('Claude wake-up production ingress remote=%s', (remote) => { + it.each(['Agent', 'Bash', 'Monitor'])( + 'rejects a blank %s launch without phantom debt', + async (tool) => { + const server = await host(remote) + try { + await server.post({ hook_event_name: 'UserPromptSubmit', prompt: 'launch' }) + await server.post({ + hook_event_name: 'PostToolUse', + tool_name: tool, + tool_response: + tool === 'Agent' + ? { isAsync: true, agentId: ' \t ' } + : tool === 'Monitor' + ? { taskId: ' \t ' } + : { backgroundTaskId: ' \t ' } + }) + if (tool === 'Agent') { + await server.post({ hook_event_name: 'SubagentStop', agent_id: ' \t ' }) + } + await server.post({ hook_event_name: 'Stop', background_tasks: [] }) + expect(server.row()?.state).toBe('done') + expect(server.row()?.claudeTaskWakeupPending).toBeUndefined() + } finally { + server.stop() + } + } + ) + + it.each(['Agent', 'Bash', 'Monitor'])( + 'matches a padded %s launch to its canonical wake-up', + async (tool) => { + const server = await host(remote) + try { + await server.post({ hook_event_name: 'UserPromptSubmit', prompt: 'launch' }) + await server.post({ + hook_event_name: 'PostToolUse', + tool_name: tool, + tool_response: + tool === 'Agent' + ? { isAsync: true, agentId: ' a1 ' } + : tool === 'Monitor' + ? { taskId: ' a1 ' } + : { backgroundTaskId: ' a1 ' } + }) + await server.post({ + hook_event_name: 'Stop', + background_tasks: [ + { id: 'a1', type: tool === 'Agent' ? 'subagent' : 'shell', status: 'running' } + ] + }) + expect(server.row()?.claudeTaskWakeupPending).toBeUndefined() + await server.post( + tool === 'Agent' + ? { hook_event_name: 'SubagentStop', agent_id: 'a1' } + : { hook_event_name: 'Stop', background_tasks: [] } + ) + expect(server.row()?.claudeTaskWakeupPending).toBe('notification') + await server.post({ + hook_event_name: 'UserPromptSubmit', + prompt: 'a1completed' + }) + expect(server.row()?.claudeTaskWakeupPending).toBe('finishing-turn') + await server.post({ hook_event_name: 'Stop', background_tasks: [] }) + expect(server.row()?.state).toBe('done') + } finally { + server.stop() + } + } + ) + + it.each([ + 'one-subagent', + 'two-subagents-together', + 'subagent-resumed', + 'subagent-with-own-shell', + 'two-shells-together' + ])('%s never exposes completion before the final captured wake-up', async (scenario) => { + const server = await host(remote) + const records = hooks.filter((record) => record.scenario === scenario && record.payload) + const finalWakeup = records.findLastIndex( + (record) => + record.payload?.hook_event_name === 'UserPromptSubmit' && + String(record.payload.prompt).startsWith('') + ) + expect(finalWakeup).toBeGreaterThan(0) + const prematureCompletion: number[] = [] + try { + for (const [index, record] of records.entries()) { + await server.post(record.payload!) + const row = server.row() + if (index < finalWakeup && row?.state === 'done' && !row.sessionBoundary) { + prematureCompletion.push(index) + } + } + // Every status subscriber must avoid a false whole-pane completion. + expect(prematureCompletion).toEqual([]) + expect(server.row()).toMatchObject({ + state: 'done', + connectionId: remote ? 'ssh-owner' : null + }) + } finally { + server.stop() + } + }) + + it('does not reopen notification debt for a duplicate child-end post', async () => { + const server = await host(remote) + try { + await server.post({ hook_event_name: 'UserPromptSubmit', prompt: 'delegate' }) + await server.post({ hook_event_name: 'SubagentStart', agent_id: 'a1' }) + await server.post({ + hook_event_name: 'PostToolUse', + tool_name: 'Agent', + tool_response: { isAsync: true, agentId: 'a1' } + }) + await server.post({ hook_event_name: 'SubagentStop', agent_id: 'a1' }) + await server.post({ + hook_event_name: 'UserPromptSubmit', + prompt: 'a1completed' + }) + await server.post({ hook_event_name: 'Stop', background_tasks: [] }) + expect(server.row()?.state).toBe('done') + await server.post({ hook_event_name: 'SubagentStop', agent_id: 'a1' }) + expect(server.row()?.state).toBe('done') + await server.post({ + hook_event_name: 'UserPromptSubmit', + prompt: 'a1completed' + }) + expect(server.row()?.claudeTaskWakeupPending).toBeUndefined() + } finally { + server.stop() + } + }) + + it('accepts a delayed wake-up and waits for its main-agent finishing turn', async () => { + vi.useFakeTimers({ + shouldAdvanceTime: true, + toFake: ['Date', 'performance', 'setTimeout', 'clearTimeout'] + }) + const server = await host(remote) + try { + await server.post({ hook_event_name: 'UserPromptSubmit', prompt: 'delegate then finish' }) + await server.post({ hook_event_name: 'SubagentStart', agent_id: 'a1' }) + await server.post({ + hook_event_name: 'PostToolUse', + tool_name: 'Agent', + tool_response: { isAsync: true, agentId: 'a1' } + }) + await server.post({ + hook_event_name: 'Stop', + background_tasks: [{ id: 'a1', type: 'subagent', status: 'running' }] + }) + await server.post({ hook_event_name: 'SubagentStop', agent_id: 'a1' }) + vi.advanceTimersByTime(5_000) + expect(server.row()?.state).toBe('working') + await server.post({ + hook_event_name: 'UserPromptSubmit', + prompt: 'a1completed' + }) + vi.advanceTimersByTime(120_000) + expect(server.row()).toMatchObject({ state: 'working', mainAgent: { state: 'working' } }) + await server.post({ hook_event_name: 'Stop', background_tasks: [] }) + expect(server.row()?.state).toBe('done') + } finally { + server.stop() + } + }) +}) + +describe.each([false, true])('Claude cancellation with an owed wake-up remote=%s', (remote) => { + it.each([false, true])( + 'keeps cancellation bounded unless a new prompt opens a turn: %s', + async (newTurn) => { + vi.useFakeTimers({ + shouldAdvanceTime: true, + toFake: ['Date', 'performance', 'setTimeout', 'clearTimeout'] + }) + const server = await host(remote) + try { + await server.post({ hook_event_name: 'UserPromptSubmit', prompt: 'delegate' }) + await server.post({ hook_event_name: 'SubagentStart', agent_id: 'a1' }) + await server.post({ + hook_event_name: 'PostToolUse', + tool_name: 'Agent', + tool_response: { isAsync: true, agentId: 'a1' } + }) + await server.post({ hook_event_name: 'SubagentStop', agent_id: 'a1' }) + const baseline = server.row()! + expect( + server.desktop.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: baseline.prompt, + baselineAgentType: 'claude', + intent: 'ctrl-c' + }) + ).toBe(true) + expect(server.row()).toMatchObject({ + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + await server.post({ + hook_event_name: 'PostToolUse', + tool_name: 'Read', + tool_response: { content: 'late result' } + }) + if (newTurn) { + await server.post({ hook_event_name: 'UserPromptSubmit', prompt: 'start a fresh turn' }) + } + vi.advanceTimersByTime(120_000) + if (newTurn) { + expect(server.row()).toMatchObject({ state: 'working', mainAgent: { state: 'working' } }) + expect(server.row()?.mainAgent).not.toHaveProperty('outcome') + await server.post({ hook_event_name: 'Stop', background_tasks: [] }) + vi.advanceTimersByTime(60_000) + expect(server.row()?.state).toBe('done') + } else { + expect(server.row()).toMatchObject({ + state: 'done', + interrupted: true, + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + } + } finally { + server.stop() + } + } + ) +}) + +describe.each([false, true])('real automation Claude wake-up oracle remote=%s', (remote) => { + it.each([ + 'stop', + 'missing-notification', + 'native-idle', + 'cancel', + 'duplicate', + 'shell-duplicate', + 'end-before-launch', + 'notification-before-launch', + 'notification-before-end' + ])( + 'waits on captured ready bytes through task finishing or missing wake-up expiry: %s', + async (ending) => { + const server = await host(remote, 'transcript-launch') + const controller = new AbortController() + const ready = readFileSync( + join(import.meta.dirname, '../runtime/__fixtures__/claude-ready-task-wakeup.txt'), + 'utf8' + ) + const pane = await createTranscriptPane( + { + paneTitle: 'Terminal', + foregroundProcess: 'claude', + data: '', + launchAgent: 'claude', + ...(remote ? { connectionId: 'ssh-owner' } : {}) + }, + { getAgentStatusSnapshot: () => server.desktop.getStatusSnapshot() } + ) + const observer = createRuntimeAutomationRunTerminalObserver(pane.runtime) + const settled = vi.fn() + vi.useFakeTimers({ + shouldAdvanceTime: true, + toFake: [ + 'Date', + 'performance', + 'setTimeout', + 'clearTimeout', + 'setInterval', + 'clearInterval' + ] + }) + try { + await server.post({ hook_event_name: 'UserPromptSubmit', prompt: 'delegate then finish' }) + const earlyDelivery = ending.startsWith('notification-before-') + const watch = () => { + const watching = observer.observeCompletion(pane.handle, { signal: controller.signal }) + void watching.then(settled, () => {}) + } + if (!earlyDelivery) { + watch() + await vi.advanceTimersByTimeAsync(300) + } else { + pane.runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, ready, Date.now()) + await vi.advanceTimersByTimeAsync(1) + } + const shell = ending === 'shell-duplicate' + const notification = { + hook_event_name: 'UserPromptSubmit', + prompt: 'a1completed' + } + const reordered = [ + 'end-before-launch', + 'notification-before-launch', + 'notification-before-end' + ].includes(ending) + if (!shell) { + await server.post({ hook_event_name: 'SubagentStart', agent_id: 'a1' }) + } + if (ending === 'notification-before-end') { + await server.post(notification) + } + if (reordered) { + await server.post({ hook_event_name: 'SubagentStop', agent_id: 'a1' }) + } + if (ending === 'notification-before-launch') { + await server.post(notification) + } + await server.post({ + hook_event_name: 'PostToolUse', + tool_name: shell ? 'Bash' : 'Agent', + tool_response: shell ? { backgroundTaskId: 'a1' } : { isAsync: true, agentId: 'a1' } + }) + if (ending !== 'cancel' && !ending.startsWith('notification-before-')) { + await server.post({ + hook_event_name: 'Stop', + background_tasks: reordered + ? [] + : [{ id: 'a1', type: shell ? 'shell' : 'subagent', status: 'running' }] + }) + } + if (!reordered) { + await server.post( + shell + ? { hook_event_name: 'Stop', background_tasks: [] } + : { hook_event_name: 'SubagentStop', agent_id: 'a1' } + ) + } + if (ending === 'cancel') { + const baseline = server.row()! + expect( + server.desktop.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: baseline.prompt, + baselineAgentType: 'claude', + intent: 'ctrl-c' + }) + ).toBe(true) + } + expect(server.row()?.mainAgent?.state).toBe( + ending.startsWith('notification-before-') ? 'working' : 'done' + ) + if (earlyDelivery) { + watch() + } else { + pane.runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, ready, Date.now()) + } + await vi.advanceTimersByTimeAsync(100) + expect(settled).not.toHaveBeenCalled() + if (ending === 'missing-notification' || ending === 'cancel') { + await vi.advanceTimersByTimeAsync(59_000) + expect(settled).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(4_200) + expect(server.row()?.state).toBe('done') + } else { + await server.post({ + hook_event_name: 'UserPromptSubmit', + prompt: 'a1completed' + }) + await server.post({ + hook_event_name: 'UserPromptSubmit', + prompt: 'a1completed' + }) + await vi.advanceTimersByTimeAsync(120_000) + expect(settled).not.toHaveBeenCalled() + expect(server.row()?.claudeTaskWakeupPending).toBe('finishing-turn') + if (ending === 'native-idle') { + pane.runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, ready, Date.now()) + } else { + await server.post({ hook_event_name: 'Stop', background_tasks: [] }) + if (ending === 'duplicate' || ending === 'shell-duplicate') { + await server.post({ + hook_event_name: 'UserPromptSubmit', + prompt: 'a1completed' + }) + expect(server.row()?.claudeTaskWakeupPending).toBeUndefined() + } + } + await vi.advanceTimersByTimeAsync(2_100) + } + expect(settled).toHaveBeenCalledWith(expect.objectContaining({ status: 'completed' })) + if (ending === 'missing-notification') { + await server.post({ + hook_event_name: 'UserPromptSubmit', + prompt: 'a1completed' + }) + expect(server.row()?.claudeTaskWakeupPending).toBe('finishing-turn') + await server.post({ hook_event_name: 'Stop', background_tasks: [] }) + expect(server.row()?.claudeTaskWakeupPending).toBeUndefined() + } + } finally { + controller.abort() + server.stop() + } + } + ) +}) + +describe('Claude finishing turn permission restoration', () => { + it.each(['lead', 'child'])( + 'retains the cycle through a %s question and clears it on Stop or a fresh prompt', + async (owner) => { + const server = await host(false) + try { + await server.post({ hook_event_name: 'UserPromptSubmit', prompt: 'delegate' }) + await server.post({ hook_event_name: 'SubagentStart', agent_id: 'a1' }) + await server.post({ + hook_event_name: 'PostToolUse', + tool_name: 'Agent', + tool_response: { isAsync: true, agentId: 'a1' } + }) + await server.post({ hook_event_name: 'SubagentStop', agent_id: 'a1' }) + await server.post({ + hook_event_name: 'UserPromptSubmit', + prompt: 'a1completed' + }) + expect(server.row()?.claudeTaskWakeupPending).toBe('finishing-turn') + const child = owner === 'child' ? { agent_id: 'child-question' } : {} + await server.post({ + hook_event_name: 'PreToolUse', + tool_name: 'AskUserQuestion', + tool_use_id: 'question', + ...child + }) + expect(server.row()).toMatchObject({ + state: 'waiting', + claudeTaskWakeupPending: 'finishing-turn' + }) + const baseline = server.row()! + expect( + server.desktop.inferQuestionAnswered({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: baseline.prompt, + baselineAgentType: 'claude' + }) + ).toBe(true) + await server.post({ hook_event_name: 'PostToolUse', tool_name: 'Read', tool_response: {} }) + expect(server.row()).toMatchObject({ + mainAgent: { state: 'working' }, + claudeTaskWakeupPending: 'finishing-turn' + }) + if (owner === 'lead') { + await server.post({ hook_event_name: 'UserPromptSubmit', prompt: 'a fresh typed turn' }) + expect(server.row()?.claudeTaskWakeupPending).toBeUndefined() + } + await server.post({ hook_event_name: 'Stop', background_tasks: [] }) + expect(server.row()?.claudeTaskWakeupPending).toBeUndefined() + } finally { + server.stop() + } + } + ) + + it('pairs a sticky child permission with the current lead cycle instead of retaining a stale phase', async () => { + const server = await host(false) + try { + await server.post({ hook_event_name: 'UserPromptSubmit', prompt: 'delegate' }) + await server.post({ hook_event_name: 'SubagentStart', agent_id: 'a1' }) + await server.post({ + hook_event_name: 'PostToolUse', + tool_name: 'Agent', + tool_response: { isAsync: true, agentId: 'a1' } + }) + await server.post({ hook_event_name: 'SubagentStop', agent_id: 'a1' }) + await server.post({ + hook_event_name: 'UserPromptSubmit', + prompt: 'a1completed' + }) + await server.post({ + hook_event_name: 'PermissionRequest', + agent_id: 'b1', + tool_name: 'Bash', + tool_use_id: 'child-tool' + }) + await server.post({ hook_event_name: 'PostToolUse', tool_name: 'Read', tool_response: {} }) + expect(server.row()).toMatchObject({ + state: 'waiting', + claudeTaskWakeupPending: 'finishing-turn', + mainAgent: { state: 'working' } + }) + await server.post({ + hook_event_name: 'Stop', + background_tasks: [{ id: 'b1', type: 'subagent', status: 'running' }] + }) + expect(server.row()).toMatchObject({ state: 'waiting', mainAgent: { state: 'done' } }) + expect(server.row()?.claudeTaskWakeupPending).toBeUndefined() + } finally { + server.stop() + } + }) +}) 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 ce93f46f886..6a30fa4b0f0 100644 --- a/src/main/agent-hooks/server-relayed-claude-cancel.test.ts +++ b/src/main/agent-hooks/server-relayed-claude-cancel.test.ts @@ -142,12 +142,12 @@ describe('a relayed Claude cancel with a live subagent (captured)', () => { mainAgent: { state: 'done', outcome: 'cancellation' } }) - // Both children finish on the remote; with nothing left running the cancelled row settles. + // Both children finish on the remote. Claude still owes the main agent a task notification + // for the launched one, so the relay holds the row and the desktop keeps its cancel over it. await pane.post(subagentStop(4)) await pane.post(subagentStop(9)) expect(row(pane.desktop)).toMatchObject({ - state: 'done', - interrupted: true, + state: 'working', mainAgent: { state: 'done', outcome: 'cancellation' } }) expect(row(pane.desktop).subagents).toBeUndefined() @@ -185,10 +185,10 @@ describe('a relayed Claude cancel with a live subagent (captured)', () => { mainAgent: { state: 'done', outcome: 'cancellation' } }) + // The child's end takes its card down; its owed task notification holds the cancelled row. await pane.post(subagentStop(4)) expect(row(pane.desktop)).toMatchObject({ - state: 'done', - interrupted: true, + state: 'working', mainAgent: { state: 'done', outcome: 'cancellation' } }) }) @@ -270,8 +270,15 @@ describe('a relayed Claude cancel with a live subagent (captured)', () => { // Hydration seeds the desktop's own roster from the saved row, relayed or not. expect(desktop._getStateForTests().claudeSubagentRosterByPaneKey.has(PANE)).toBe(true) - // The child finishes on the remote, then a new turn starts and is cancelled. + // The child finishes on the remote and Claude tells the main agent, which ends that turn; + // then a new turn starts and is cancelled. await pane.post(subagentStop(4)) + expect(row(desktop)).toMatchObject({ state: 'working' }) + await pane.post({ + hook_event_name: 'UserPromptSubmit', + prompt: `\n${String(hookAt(records, 4).payload.agent_id)}\ncompleted` + }) + await pane.post({ hook_event_name: 'Stop', background_tasks: [] }) expect(row(desktop)).toMatchObject({ state: 'done' }) await pane.post(hookAt(records, 7).payload) expect(row(desktop)).toMatchObject({ state: 'working', mainAgent: { state: 'working' } }) 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 8bfc3fe897b..76a4eb9c6c0 100644 --- a/src/main/agent-hooks/server/server-cancel-verdict-latch.ts +++ b/src/main/agent-hooks/server/server-cancel-verdict-latch.ts @@ -1,132 +1,2 @@ -import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' -import type { AgentMainAgentStatus } from '../../../shared/agent-status-types' -import { INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS } from './server-constants' -import { foldMainAgentWithRowChildWork } from './server-row-child-work-fold' -import { isToolProgressWorkingAfterInterrupt } from './server-status-identity' -import type { EnrichedAgentHookEventPayload } from './server-types' - -export type CancelVerdictLatchDecision = - | { hold: true } - | { hold: false; event: AgentHookEventPayload } - -const HOLD: CancelVerdictLatchDecision = { hold: true } - -/** Derived from the row, never stored: a row whose main agent reads cancelled (or, from a host too - * old to publish `mainAgent`, a done row flagged interrupted). */ -function isCancelVerdictLatched(previous: EnrichedAgentHookEventPayload): boolean { - const mainAgent = previous.payload.mainAgent - return mainAgent - ? mainAgent.outcome === 'cancellation' - : previous.payload.state === 'done' && previous.payload.interrupted === true -} - -/** An event that restates child work can be re-folded with it; one that carries none has nothing to add. */ -function carriesChildWork(event: AgentHookEventPayload): boolean { - return event.payload.subagents !== undefined || event.claudeRunningNonAgentTask !== undefined -} - -function refoldUnderLatchedMainAgent( - previous: EnrichedAgentHookEventPayload, - latched: AgentMainAgentStatus, - incoming: AgentHookEventPayload -): AgentHookEventPayload { - // Why: a child's own attention state is child work, not the main agent's; only `working` is the stale restatement. - const resolved = foldMainAgentWithRowChildWork( - incoming.payload.state === 'working' ? latched.state : incoming.payload.state, - { - claudeRunningNonAgentTask: - incoming.claudeRunningNonAgentTask ?? previous.claudeRunningNonAgentTask, - payload: incoming.payload - } - ) - const { - workingMode: _workingMode, - interrupted: _interrupted, - turnCompletedAt: _turnCompletedAt, - ...rest - } = incoming.payload - return { - ...incoming, - payload: { - ...rest, - state: resolved.stateName, - ...(resolved.workingMode ? { workingMode: resolved.workingMode } : {}), - ...(resolved.stateName === 'done' ? { interrupted: true } : {}), - mainAgent: latched - } - } -} - -/** A main agent's own prompt submission always opens a turn, including a harness-injected one that - * keeps the cached prompt (the task notification Claude starts when background work ends). */ -function opensNewTurn(event: AgentHookEventPayload): boolean { - return ( - event.hookEventName === 'SessionStart' || - (event.hookEventName === 'UserPromptSubmit' && - event.toolAgentId === undefined && - event.isReplay !== true) - ) -} - -/** A child's own event: one naming its agent id, or a teammate's idle, which names it by `teammate_name` only. */ -function isChildAttributed(event: AgentHookEventPayload): boolean { - return event.toolAgentId !== undefined || event.hookEventName === 'TeammateIdle' -} - -/** A child restates its listener's cached prompt, which a restarted relay has lost; empty there is unknown, not another turn. */ -function restatesAnotherPrompt( - previous: EnrichedAgentHookEventPayload, - incoming: AgentHookEventPayload -): boolean { - const prompt = incoming.payload.prompt - return prompt !== previous.payload.prompt && (prompt !== '' || !isChildAttributed(incoming)) -} - -/** - * The store's hold on a cancel verdict against restatements that predate it: a relay never learns - * of the cancel the desktop infers, and TUIs emit late same-turn hooks after Ctrl+C. The latch dies - * 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. - */ -export function resolveCancelVerdictLatch( - previous: EnrichedAgentHookEventPayload | undefined, - incoming: AgentHookEventPayload, - now: number -): CancelVerdictLatchDecision { - const apply: CancelVerdictLatchDecision = { hold: false, event: incoming } - if ( - !previous || - !isCancelVerdictLatched(previous) || - previous.payload.agentType !== incoming.payload.agentType || - restatesAnotherPrompt(previous, incoming) || - incoming.payload.mainAgent?.state === 'done' || - opensNewTurn(incoming) - ) { - return apply - } - 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 ( - latched && - incoming.payload.agentType !== 'codex' && - incoming.payload.state !== 'done' && - (isChildAttributed(incoming) || incoming.isReplay === true) && - carriesChildWork(incoming) - ) { - return { hold: false, event: refoldUnderLatchedMainAgent(previous, latched, incoming) } - } - const withinWindow = now - previous.receivedAt <= INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS - if (incoming.payload.state === 'done') { - return previous.payload.state === 'done' && withinWindow ? HOLD : apply - } - if ( - incoming.payload.state === 'working' && - (incoming.isReplay === true || - isToolProgressWorkingAfterInterrupt(incoming) || - (incoming.hasExplicitPrompt !== true && withinWindow)) - ) { - return HOLD - } - return apply -} +export { resolveCancelVerdictLatch } from '../../../shared/agent-hook-cancel-verdict-latch' +export type { CancelVerdictLatchDecision } from '../../../shared/agent-hook-cancel-verdict-latch' diff --git a/src/main/agent-hooks/server/server-claude-status-rules.ts b/src/main/agent-hooks/server/server-claude-status-rules.ts index 0ed16928968..3dd25ee5292 100644 --- a/src/main/agent-hooks/server/server-claude-status-rules.ts +++ b/src/main/agent-hooks/server/server-claude-status-rules.ts @@ -36,7 +36,13 @@ export function withHeldChildWaitMainAgent( } const runningNonAgentTask = pairedClaudeNonAgentWork(previous, next) const mainAgentChanged = !mainAgentStatusEqual(previous.payload.mainAgent, mainAgent) - if (!mainAgentChanged && runningNonAgentTask === previous.claudeRunningNonAgentTask) { + const pendingWakeup = next.payload.claudeTaskWakeupPending + const wakeupChanged = pendingWakeup !== previous.payload.claudeTaskWakeupPending + if ( + !mainAgentChanged && + !wakeupChanged && + runningNonAgentTask === previous.claudeRunningNonAgentTask + ) { return previous } const { claudeRunningNonAgentTask: _unpaired, ...unpaired } = previous @@ -45,7 +51,10 @@ export function withHeldChildWaitMainAgent( ...(runningNonAgentTask !== undefined ? { claudeRunningNonAgentTask: runningNonAgentTask } : {}), - payload: mainAgentChanged ? { ...previous.payload, mainAgent } : previous.payload + payload: + mainAgentChanged || wakeupChanged + ? { ...previous.payload, mainAgent, claudeTaskWakeupPending: pendingWakeup } + : previous.payload } } diff --git a/src/main/agent-hooks/server/server-cleanup.ts b/src/main/agent-hooks/server/server-cleanup.ts index 252c995a95c..862589a0750 100644 --- a/src/main/agent-hooks/server/server-cleanup.ts +++ b/src/main/agent-hooks/server/server-cleanup.ts @@ -283,6 +283,7 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen this.state.claudeLeadStateByPaneKey.delete(paneKey) this.state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) this.state.claudeActiveSessionCronPaneKeys.delete(paneKey) + this.state.claudeLaunchedBackgroundTasksByPaneKey.delete(paneKey) this.state.claudeSessionOwnerByPaneKey.delete(paneKey) } } diff --git a/src/main/agent-hooks/server/server-constants.ts b/src/main/agent-hooks/server/server-constants.ts index 7fa7901ae73..fd7eb2d7771 100644 --- a/src/main/agent-hooks/server/server-constants.ts +++ b/src/main/agent-hooks/server/server-constants.ts @@ -5,18 +5,12 @@ export const LAST_STATUS_FILE_NAME = 'last-status.json' export const ASSISTANT_MESSAGE_RETRY_ATTEMPTS = 5 export const ASSISTANT_MESSAGE_RETRY_MS = 50 export const CODEX_SUBAGENT_POLL_MS = 1_000 -export const INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS = 15_000 // Why: starts at 2 — pre-merge v1 lacked receivedAt/stateStartedAt (never shipped); a mismatched version hydrates empty (treated as corrupt). export const LAST_STATUS_FILE_VERSION = 2 // Why: trailing-edge debounce so a burst of hook events yields one disk write, not N; quit-time flushStatusPersistSync() guarantees the final flush. export const STATUS_PERSIST_DEBOUNCE_MS = 250 -export const TOOL_PROGRESS_HOOK_EVENTS = new Set([ - 'PreToolUse', - 'PostToolUse', - 'PostToolUseFailure' -]) export const AGENT_PROMPT_SENT_AGENT_KINDS = new Set(AGENT_KIND_VALUES) // Why: bound file growth from PTYs that never re-attach; 7 days is the "still relevant?" horizon beyond which entries shouldn't resurrect on hydrate. diff --git a/src/main/agent-hooks/server/server-ingest-remote.ts b/src/main/agent-hooks/server/server-ingest-remote.ts index 787f3ed0cef..cd9c867bc62 100644 --- a/src/main/agent-hooks/server/server-ingest-remote.ts +++ b/src/main/agent-hooks/server/server-ingest-remote.ts @@ -1,3 +1,4 @@ +import { normalizeHostTurnRevision } from '../../../shared/agent-hook-interrupt-reconciliation' import { readAgentProcessPresence } from '../../../shared/agent-process-presence' import { track } from '../../telemetry/client' import { normalizeAgentStatusPayload } from '../../../shared/agent-status-types' @@ -258,6 +259,7 @@ export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestS ...(restartedAuthority?.authorityRestartId ? { authorityRestartId: restartedAuthority.authorityRestartId } : {}), + hostTurnRevision: normalizeHostTurnRevision(envelope.hostTurnRevision), launchToken: statusDisposition === 'restart' ? undefined : envelope.launchToken, tabId, worktreeId, diff --git a/src/main/agent-hooks/server/server-lifecycle.ts b/src/main/agent-hooks/server/server-lifecycle.ts index 825b51b8a63..4a8c54fe935 100644 --- a/src/main/agent-hooks/server/server-lifecycle.ts +++ b/src/main/agent-hooks/server/server-lifecycle.ts @@ -236,6 +236,7 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerStatusHook } this.assistantMessageRetryTimers.clear() this.clearAllTranscriptPolls() + this.claudeOwedNotificationExpiry.clearAll() this.endpointDir = null this.endpointFilePathCache = null this.endpointFileWritten = false diff --git a/src/main/agent-hooks/server/server-persistence.ts b/src/main/agent-hooks/server/server-persistence.ts index 74a37c441f2..2a97b01fa69 100644 --- a/src/main/agent-hooks/server/server-persistence.ts +++ b/src/main/agent-hooks/server/server-persistence.ts @@ -43,6 +43,7 @@ export abstract class AgentHookServerPersistence extends AgentHookServerHydratio // A terminal handle belongs to the runtime that issued it; a hydrated one could only // rejoin a row to somebody else's terminal. terminalHandle: _terminalHandle, + hostTurnRevision: _hostTurnRevision, launchToken, ...persistedPayload } = enrichedPayload @@ -51,8 +52,11 @@ export abstract class AgentHookServerPersistence extends AgentHookServerHydratio : this.hydratedLaunchTokenHashByPaneKey.get(paneKey) // `payload.mainAgent` rides inside the payload; the legacy `claudeLeadBoundaryChildOnly` flag it // replaced is read at hydrate and never written again. + const { claudeTaskWakeupPending: _pendingWakeup, ...persistedStatus } = + persistedPayload.payload entries[paneKey] = { ...persistedPayload, + payload: persistedStatus, ...(launchTokenHash ? { launchTokenHash } : {}) } const commitment = this.toAuthorityEvidence(payload, launchTokenHash) diff --git a/src/main/agent-hooks/server/server-remote-envelope-normalization.ts b/src/main/agent-hooks/server/server-remote-envelope-normalization.ts index 0f7f009d1ad..a9cb5f607af 100644 --- a/src/main/agent-hooks/server/server-remote-envelope-normalization.ts +++ b/src/main/agent-hooks/server/server-remote-envelope-normalization.ts @@ -12,6 +12,7 @@ export type RemoteAgentStatusEnvelope = { env?: string version?: string launchToken?: string + hostTurnRevision?: unknown hasExplicitPrompt?: boolean promptInteractionKey?: string agentPresence?: unknown diff --git a/src/main/agent-hooks/server/server-row-child-work-fold.ts b/src/main/agent-hooks/server/server-row-child-work-fold.ts index 30c00b25945..dee08dbc89c 100644 --- a/src/main/agent-hooks/server/server-row-child-work-fold.ts +++ b/src/main/agent-hooks/server/server-row-child-work-fold.ts @@ -1,28 +1 @@ -import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' -import { - foldAgentLeadStatus, - type AgentLeadStatusResolution -} from '../../../shared/agent-lead-status-fold' -import { agentChildWorkLiveness } from '../../../shared/agent-status-child-work-liveness' -import type { AgentStatusState, AgentSubagentSnapshot } from '../../../shared/agent-status-types' - -type RowChildWork = Pick & { - payload: { subagents?: readonly AgentSubagentSnapshot[] } -} - -/** Fold a main agent state with the child work a row itself carries: its subagent snapshots and the - * shell/cron fact restated beside them. For a relayed pane that is all the desktop can see, because - * the provider records live on the relay. */ -export function foldMainAgentWithRowChildWork( - leadState: AgentStatusState, - row: RowChildWork -): AgentLeadStatusResolution { - const childWorkLiveness = agentChildWorkLiveness([ - ...(row.payload.subagents?.map((child) => ({ kind: 'agent' as const, state: child.state })) ?? - []), - ...(row.claudeRunningNonAgentTask - ? [{ kind: 'command' as const, state: 'working' as const }] - : []) - ]) - return foldAgentLeadStatus({ leadState, childWorkLiveness }) -} +export { foldMainAgentWithRowChildWork } from '../../../shared/agent-hook-row-child-work-fold' diff --git a/src/main/agent-hooks/server/server-status-identity.ts b/src/main/agent-hooks/server/server-status-identity.ts index 4400b43efee..6332ce5fde1 100644 --- a/src/main/agent-hooks/server/server-status-identity.ts +++ b/src/main/agent-hooks/server/server-status-identity.ts @@ -1,7 +1,6 @@ import { createHash } from 'node:crypto' import type { AgentKind } from '../../../shared/telemetry-events' -import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' import { getAgentResumeArgv, type AgentProviderSessionMetadata @@ -9,7 +8,7 @@ import { import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../shared/stable-pane-id' import type { AgentStatusIpcPayload, AgentType } from '../../../shared/agent-status-types' import type { EnrichedAgentHookEventPayload } from './server-types' -import { AGENT_PROMPT_SENT_AGENT_KINDS, TOOL_PROGRESS_HOOK_EVENTS } from './server-constants' +import { AGENT_PROMPT_SENT_AGENT_KINDS } from './server-constants' import { MAX_PANE_KEY_LEN } from '../../../shared/agent-hook-listener/listener-limits' export function agentTypeToPromptSentAgentKind(agentType: AgentType | undefined): AgentKind { @@ -75,16 +74,7 @@ export function toAgentStatusIpcPayload( } } -export function isToolProgressWorkingAfterInterrupt(next: AgentHookEventPayload): boolean { - if (next.payload.state !== 'working') { - return false - } - if (next.payload.agentType !== 'claude' && next.payload.agentType !== 'codex') { - return false - } - // Why: a same-prompt retry is another UserPromptSubmit, while late post-Ctrl+C progress arrives as tool lifecycle work. - return next.hookEventName !== undefined && TOOL_PROGRESS_HOOK_EVENTS.has(next.hookEventName) -} +export { isToolProgressWorkingAfterInterrupt } from '../../../shared/agent-hook-cancel-verdict-latch' export function paneCacheKeyTabId(key: string): string | null { const paneKey = key.split('\0', 1)[0] ?? key diff --git a/src/main/agent-hooks/server/server-status-inference.ts b/src/main/agent-hooks/server/server-status-inference.ts index 6eae3499a39..c3e31150cf8 100644 --- a/src/main/agent-hooks/server/server-status-inference.ts +++ b/src/main/agent-hooks/server/server-status-inference.ts @@ -1,3 +1,4 @@ +import type { RemoteAgentInterruptDispatch } from '../../../shared/agent-hook-interrupt-reconciliation' import { markClaudeLeadTurnInterrupted, clearClaudeAnsweredQuestionWait @@ -20,6 +21,15 @@ import { AgentHookServerRowOwnership } from './server-row-ownership' import { foldMainAgentWithRowChildWork } from './server-row-child-work-fold' export abstract class AgentHookServerStatusInference extends AgentHookServerRowOwnership { + private remoteInterruptListeners = new Set<(command: RemoteAgentInterruptDispatch) => void>() + + subscribeRemoteInterruptRequests( + listener: (command: RemoteAgentInterruptDispatch) => void + ): () => void { + this.remoteInterruptListeners.add(listener) + return () => this.remoteInterruptListeners.delete(listener) + } + inferInterrupt(request: AgentInterruptInferenceRequest): boolean { if (!isValidPaneKey(request.paneKey)) { return false @@ -103,6 +113,9 @@ export abstract class AgentHookServerStatusInference extends AgentHookServerRowO worktreeId: existing.worktreeId, connectionId: existing.connectionId, providerSession: existing.providerSession, + launchToken: existing.launchToken, + hostTurnRevision: existing.hostTurnRevision, + source: existing.source, // Why: a cancel leaves the shell fact as it was; dropping it would stop restart from seeding // the cancelled main agent, so a child's later drain could never settle the row. ...(existing.claudeRunningNonAgentTask !== undefined @@ -110,6 +123,12 @@ export abstract class AgentHookServerStatusInference extends AgentHookServerRowO : {}), payload: { state, + claudeTaskWakeupPending: + state !== 'done' + ? local + ? local.claudeTaskWakeupPending + : payload.claudeTaskWakeupPending + : undefined, ...(workingMode ? { workingMode } : {}), prompt: payload.prompt, agentType, @@ -129,6 +148,31 @@ export abstract class AgentHookServerStatusInference extends AgentHookServerRowO if (!inferred) { return false } + if ( + agentType === 'claude' && + request.intent === 'ctrl-c' && + existing.connectionId && + existing.hostTurnRevision && + existing.providerSession + ) { + const command: RemoteAgentInterruptDispatch = { + connectionId: existing.connectionId, + request: { + paneKey: existing.paneKey, + hostTurnRevision: existing.hostTurnRevision, + launchToken: existing.launchToken, + providerSession: existing.providerSession, + intent: 'ctrl-c' + } + } + for (const listener of this.remoteInterruptListeners) { + try { + listener(command) + } catch (error) { + console.warn('[agent-hooks] remote interrupt dispatch failed', error) + } + } + } console.debug('[agent-hooks] inferred interrupted agent status', { paneKey: inferred.paneKey, agentType, @@ -178,8 +222,17 @@ export abstract class AgentHookServerStatusInference extends AgentHookServerRowO worktreeId: existing.worktreeId, connectionId: existing.connectionId, providerSession: existing.providerSession, + launchToken: existing.launchToken, + hostTurnRevision: existing.hostTurnRevision, + source: existing.source, payload: { state: restored.state, + claudeTaskWakeupPending: + restored.state !== 'done' + ? existing.connectionId + ? payload.claudeTaskWakeupPending + : restored.claudeTaskWakeupPending + : undefined, ...(restored.workingMode ? { workingMode: restored.workingMode } : {}), prompt: payload.prompt, agentType: payload.agentType, diff --git a/src/main/agent-hooks/server/server-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts index ebb87c3c9c2..03ec7c77850 100644 --- a/src/main/agent-hooks/server/server-status-update.ts +++ b/src/main/agent-hooks/server/server-status-update.ts @@ -10,6 +10,8 @@ import { import type { EnrichedAgentHookEventPayload } from './server-types' import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' import type { AgentStatusObservationOrigin } from '../../../shared/agent-status-observation' +import { ClaudeOwedNotificationExpiryTimers } from '../../../shared/claude-owed-notification-expiry-timers' +import { setClaudeMainAgentTurnState } from '../../../shared/agent-hook-listener/providers/claude-roster-state' import { attachClaudePermissionToolUseId, pairedClaudeNonAgentWork, @@ -21,6 +23,26 @@ import { resolveCancelVerdictLatch } from './server-cancel-verdict-latch' import { AgentHookServerStatusApplication } from './server-status-application' export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusApplication { + protected readonly claudeOwedNotificationExpiry = new ClaudeOwedNotificationExpiryTimers( + this.state + ) + + // Why here: every stored row passes through, including a cancel inference and a pane move. + protected override commitStatusRowMutation( + before: EnrichedAgentHookEventPayload | null | undefined, + after: EnrichedAgentHookEventPayload | null | undefined, + emit = true + ): boolean { + if (after) { + this.claudeOwedNotificationExpiry.arm(after.paneKey, (row) => { + if (this.server) { + this.applyNormalizedStatus(row) + } + }) + } + return super.commitStatusRowMutation(before, after, emit) + } + protected applyNormalizedStatus( incoming: AgentHookEventPayload & { authorityRestartId?: string }, onAccepted?: () => void, @@ -171,6 +193,16 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA // restatement of a main agent the desktop cancelled must not replace the cancel. const latch = resolveCancelVerdictLatch(previous, attachedPayload, Date.now()) if (latch.hold) { + if ( + attachedPayload.connectionId === null && + attachedPayload.payload.agentType === 'claude' && + previous?.connectionId === null && + previous.payload.mainAgent?.state === 'done' && + this.sameTerminalOwner(previous, attachedPayload) + ) { + // A refused late hook already mutated the local producer; keep its idle lease clock. + setClaudeMainAgentTurnState(this.state, attachedPayload.paneKey, previous.payload.mainAgent) + } if ( attachedPayload.payload.agentType === 'codex' && attachedPayload.payload.state === 'working' diff --git a/src/main/agent-hooks/server/server-types.ts b/src/main/agent-hooks/server/server-types.ts index 79b4e569cdd..8ebde5d39e2 100644 --- a/src/main/agent-hooks/server/server-types.ts +++ b/src/main/agent-hooks/server/server-types.ts @@ -31,12 +31,13 @@ export type EnrichedAgentHookEventPayload = AgentHookEventPayload & { } // `claudeRunningNonAgentTask` is persisted on purpose: it is the one child-work fact the row's -// `mainAgent` cannot express (a shell beside the agents), and hydration reads it to decide whether a +// `mainAgent` cannot express (a shell, a cron or an owed task notification beside the agents), and hydration reads it to decide whether a // settled main agent may be seeded. It replaced the derived `claudeLeadBoundaryChildOnly` flag. export type PersistedAgentHookEventPayload = Omit< EnrichedAgentHookEventPayload, | 'authorityRestartId' | 'launchToken' + | 'hostTurnRevision' | 'promptInteractionKey' | 'restoredUnconfirmed' // Why: revision counters are in-memory and the authority id is regenerated per process, so diff --git a/src/main/agent-hooks/wsl-hook-relay-deps.ts b/src/main/agent-hooks/wsl-hook-relay-deps.ts index 77b2390747c..0955acda34b 100644 --- a/src/main/agent-hooks/wsl-hook-relay-deps.ts +++ b/src/main/agent-hooks/wsl-hook-relay-deps.ts @@ -1,6 +1,7 @@ // DI seam for WslHookRelayManager: the full dependency contract plus the // production wiring. Tests construct the manager with fakes for everything // that spawns wsl.exe or touches the live agentHookServer. +import { bindRemoteClaudeInterruptReconciliation } from '../ssh/ssh-agent-hook-interrupt-reconciliation' import { createHash } from 'node:crypto' import { readFileSync } from 'node:fs' @@ -64,6 +65,11 @@ export type WslHookRelayManagerDeps = { runInstall: typeof runWslInstallProcess waitForSentinel: typeof waitForWslRelaySentinel ingest: (envelope: Record, connectionId: string) => void + bindInterruptReconciliation?: ( + mux: Parameters[1], + connectionId: string, + isCurrent: () => boolean + ) => () => void installHooks: typeof installRemoteManagedAgentHooks installCodex: (runtimeHomePath: string, distro: string) => Promise managedHookSettings: () => ManagedHookDetectionSettings @@ -112,6 +118,8 @@ export const defaultWslHookRelayDeps: WslHookRelayManagerDeps = { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: envelope is the wire-deserialized notification; ingestRemote independently re-validates paneKey's type before trusting anything here. return agentHookServer.ingestRemote(capped as IngestEnvelope, connectionId) }, + bindInterruptReconciliation: (mux, connectionId, isCurrent) => + bindRemoteClaudeInterruptReconciliation(agentHookServer, mux, connectionId, isCurrent), installHooks: installRemoteManagedAgentHooks, installCodex: (runtimeHomePath, distro) => codexHookService.installForRuntimeHomeSerialized(runtimeHomePath, { diff --git a/src/main/agent-hooks/wsl-hook-relay-link.ts b/src/main/agent-hooks/wsl-hook-relay-link.ts index 2e9fede9512..a5c9eb2cc24 100644 --- a/src/main/agent-hooks/wsl-hook-relay-link.ts +++ b/src/main/agent-hooks/wsl-hook-relay-link.ts @@ -13,6 +13,11 @@ export type WslRelayLinkOptions = { child: ChildProcessWithoutNullStreams distro: string ingest: (envelope: Record, connectionId: string) => void + bindInterruptReconciliation?: ( + mux: SshChannelMultiplexer, + connectionId: string, + isCurrent: () => boolean + ) => () => void warn: (message: string) => void /** Called exactly once when the link dies — from EITHER a mux dispose * (protocol error, keepalive timeout) or the child exiting. A mux death @@ -24,6 +29,8 @@ export type WslRelayLinkOptions = { export function wireWslRelayLink(options: WslRelayLinkOptions): void { const { mux, child, distro, ingest, warn, onDead } = options const connectionId = wslHookRelayConnectionId(distro) + let dead = false + const unbindInterrupt = options.bindInterruptReconciliation?.(mux, connectionId, () => !dead) mux.onNotification((method, params) => { if (method !== AGENT_HOOK_NOTIFICATION_METHOD) { @@ -43,12 +50,12 @@ export function wireWslRelayLink(options: WslRelayLinkOptions): void { ingest(params, connectionId) }) - let dead = false const die = (reason: string): void => { if (dead) { return } dead = true + unbindInterrupt?.() mux.dispose() child.kill() onDead(reason) diff --git a/src/main/agent-hooks/wsl-hook-relay-manager.test.ts b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts index c2f86ea8008..c4707af0f5a 100644 --- a/src/main/agent-hooks/wsl-hook-relay-manager.test.ts +++ b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts @@ -293,6 +293,25 @@ describe('WslHookRelayManager', () => { manager.disposeAll() }) + it('unbinds owner interrupt reconciliation when the WSL transport is retired', async () => { + const unbind = vi.fn() + const bind = vi.fn>( + (_mux, _connectionId, _isCurrent) => unbind + ) + const { manager } = createManager({ bindInterruptReconciliation: bind }) + manager.ensureForDistro('Ubuntu', codexHome) + try { + await vi.waitFor(() => expect(bind).toHaveBeenCalledOnce()) + expect(bind.mock.lastCall?.[1]).toBe('wsl:Ubuntu') + expect(bind.mock.lastCall?.[2]()).toBe(true) + manager.disposeAll() + expect(unbind).toHaveBeenCalledOnce() + expect(bind.mock.lastCall?.[2]()).toBe(false) + } finally { + manager.disposeAll() + } + }) + it('waits for guest materialization when an explicit Pi or OMP launch needs it', async () => { const { manager } = createManager({}) await expect( diff --git a/src/main/agent-hooks/wsl-hook-relay-manager.ts b/src/main/agent-hooks/wsl-hook-relay-manager.ts index 2a2bdd4bba6..f07f248c8fa 100644 --- a/src/main/agent-hooks/wsl-hook-relay-manager.ts +++ b/src/main/agent-hooks/wsl-hook-relay-manager.ts @@ -226,6 +226,7 @@ export class WslHookRelayManager { const mux = new SshChannelMultiplexer(transport) state.mux = mux wireWslRelayLink({ + bindInterruptReconciliation: this.deps.bindInterruptReconciliation, mux, child, distro: state.distro, diff --git a/src/main/ai-vault/session-scanner-claude-subagents.ts b/src/main/ai-vault/session-scanner-claude-subagents.ts index a2dc5745948..86b72807251 100644 --- a/src/main/ai-vault/session-scanner-claude-subagents.ts +++ b/src/main/ai-vault/session-scanner-claude-subagents.ts @@ -6,6 +6,10 @@ import type { AiVaultSubagentListResult, AiVaultSubagentRunStatus } from '../../shared/ai-vault-types' +import { + CLAUDE_TASK_NOTIFICATION_MARKER, + readClaudeTaskNotification +} from '../../shared/claude-task-notification-text' import { openTranscriptReadStream, wslGatedReaddir, @@ -40,15 +44,12 @@ const SUBAGENT_PARSE_CONCURRENCY = 8 // stays reserved for live transcript probes. const SUBAGENT_FS_PRIORITY = 'scan' -const TASK_NOTIFICATION_MARKER = '' const TOOL_USE_RESULT_MARKER = '"toolUseResult"' // A sync-Task toolUseResult sets a status only when it carries an agentId. Tool // output records (Read/Bash) also carry "toolUseResult" and are the largest lines // in a transcript, so gating on this second marker keeps the status pass from // JSON-parsing ~all of the file's bytes on every on-demand fetch. const TOOL_USE_RESULT_AGENT_ID_MARKER = '"agentId"' -const TASK_ID_PATTERN = /([^<]+)<\/task-id>/ -const TASK_STATUS_PATTERN = /([a-z_]+)<\/status>/ // Statuses reported by parent-transcript records // (background Tasks) and toolUseResult records (synchronous Tasks). @@ -214,7 +215,7 @@ async function collectSubagentTaskStatuses(parentFilePath: string): Promise agentHookServer.getStatusSnapshot().filter((entry) => entry.providerSessionOnly !== true), + getAgentStatusSnapshotForPane: (paneKey) => agentHookServer.getStatusSnapshotForPane(paneKey), getAgentProviderSessionSnapshot: () => agentHookServer.getStatusSnapshot(), getAgentProviderSessionRowsForPane: (paneKey) => agentHookServer.getStatusSnapshotForPane(paneKey), diff --git a/src/main/runtime/__fixtures__/claude-ready-task-wakeup.meta.json b/src/main/runtime/__fixtures__/claude-ready-task-wakeup.meta.json new file mode 100644 index 00000000000..5d0c9b7bfc8 --- /dev/null +++ b/src/main/runtime/__fixtures__/claude-ready-task-wakeup.meta.json @@ -0,0 +1,11 @@ +{ + "capturedAt": "2026-10-06T04:06:33.762Z", + "platform": "darwin", + "command": [ + "claude" + ], + "cols": 120, + "rows": 40, + "note": "Claude Code 2.1.291; native ready-title capture in existing workspace; no prompt submitted", + "exitCode": 129 +} diff --git a/src/main/runtime/__fixtures__/claude-ready-task-wakeup.txt b/src/main/runtime/__fixtures__/claude-ready-task-wakeup.txt new file mode 100644 index 00000000000..2cb8cfa37bb --- /dev/null +++ b/src/main/runtime/__fixtures__/claude-ready-task-wakeup.txt @@ -0,0 +1 @@ +78[?25h[?2004h[?2031h[?1004h]0;✳ Claude Code[?1049h[?1000h[?1002h[?1003h[?1006h[?25l[?25l ClaudeCodev2.1.291 Opus5(1Mcontext)withmediumeffort·APIUsageBilling ~/orca/workspaces/orca/kelvin-10-04 ◐medium·/effort ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ❯ Try"refactorremote-session-scanner-types.ts" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ⏵⏵bypasspermissionson(shift+tabtocycle)·←foragents[?25h]11;?[?25l ⚠claude.aiconnectorsaredisabledbecauseANTHROPIC_API_KEYoranotherauthsourceissetandtakes precedence v… 4 agents[?25h[>0q[?u[?25l ▝▝▝▝[?25h[?25l Opus51M󰉋kelvin-10-04󰊢xxxxxxxx/kelvin-10-04●-·-tokens[?25h[?25l ▜██████▘ ▝▝▝▝[?25h[?25l ▗▟▛▛▄ ▜██████▘ ▝▝▝▝[?25h[?25l  ▐▛▛ ·▜██████·[?25h[?25l ~~[?25h[?25l ▐▛███▛█ ▝▜██▀  ▝▝ ▝▝ [?25h[?25l ▂▂[?25h[?25l ▛▛[?25h \ No newline at end of file diff --git a/src/main/runtime/claude-task-wakeup-indexed-read.test.ts b/src/main/runtime/claude-task-wakeup-indexed-read.test.ts new file mode 100644 index 00000000000..f966c0dce51 --- /dev/null +++ b/src/main/runtime/claude-task-wakeup-indexed-read.test.ts @@ -0,0 +1,83 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' +import { + createTranscriptPane, + TRANSCRIPT_PANE_PTY_ID, + waitForTranscriptIdle +} from './agent-transcript-pane-test-harness' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +const PANE = 'tab-1:11111111-1111-4111-8111-111111111111' +const ready = readFileSync( + join(import.meta.dirname, '__fixtures__/claude-ready-task-wakeup.txt'), + 'utf8' +) + +function owedRow(paneKey: string): AgentStatusIpcPayload { + const now = Date.now() + return { + paneKey, + state: 'working', + mainAgent: { state: 'done', stateStartedAt: now }, + agentType: 'claude', + prompt: '', + connectionId: null, + launchToken: 'transcript-launch', + claudeTaskWakeupPending: 'notification', + providerSession: { key: 'session_id', id: 'session-a' }, + observation: { + origin: 'hook', + authorityId: 'host-a', + incarnation: 1, + revision: 1, + observedAt: now + }, + receivedAt: now, + stateStartedAt: now + } +} + +afterEach(() => vi.useRealTimers()) + +describe('Claude readiness indexed status reads', () => { + it.each([true, false])( + 'reads only its pane with 512 unrelated agents, including an empty index: pending=%s', + async (pending) => { + const unrelated = Array.from({ length: 512 }, (_, index) => owedRow(`other:${index}`)) + const row = owedRow(PANE) + const full = vi.fn(() => [row, ...unrelated]) + const indexed = vi.fn((paneKey: string) => (pending && paneKey === PANE ? [row] : [])) + const pane = await createTranscriptPane( + { + paneTitle: 'Terminal', + foregroundProcess: 'claude', + data: ready, + launchAgent: 'claude' + }, + { getAgentStatusSnapshot: full, getAgentStatusSnapshotForPane: indexed } + ) + row.receivedAt = Date.now() + full.mockClear() + indexed.mockClear() + try { + const waiting = waitForTranscriptIdle(pane, 2_000) + await (pending + ? expect(waiting).rejects.toThrow(/timeout/) + : expect(waiting).resolves.toMatchObject({ satisfied: true })) + expect(indexed).toHaveBeenCalled() + expect(new Set(indexed.mock.calls.map(([paneKey]) => paneKey))).toEqual(new Set([PANE])) + expect(full).not.toHaveBeenCalled() + } finally { + pane.runtime.onPtyExit(TRANSCRIPT_PANE_PTY_ID, 0) + } + } + ) +}) diff --git a/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts b/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts index 29c1a636ffa..1f66feb4681 100644 --- a/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts +++ b/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts @@ -16,6 +16,7 @@ import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import type { TuiAgent } from '../../shared/tui-agent' import type { TerminalAgent } from '../../shared/terminal-agent' import type { AgentPromptActivity } from './agent-prompt-submission-verification' +import { hasExplicitIdleTitle } from './tui-idle-evidence' import { readTuiIdleHookTurn, type TuiIdleHookTurn } from './tui-idle-hook-lane' export class OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission extends OrcaRuntimeWithAgentPromptRequestCorrelation { @@ -96,8 +97,7 @@ export class OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission extends O /** The pane's main-agent turn from the hook server's store, for tui-idle's hook lane. */ protected readTuiIdleHookTurnForPty(ptyId: string, agent: TuiAgent): TuiIdleHookTurn | null { const pty = this.ptysById.get(ptyId) - const hookRows = this.getAgentStatusSnapshotFn?.() - if (!pty || !hookRows) { + if (!pty) { return null } const handles = this.getExistingTerminalHandlesForPtyId(ptyId) @@ -105,11 +105,23 @@ export class OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission extends O if (pty.paneKey) { paneKeys.add(pty.paneKey) } + const readPane = this.getAgentStatusSnapshotForPaneFn + const hookRows = readPane + ? [...new Set([...paneKeys].flatMap((key) => readPane(key)))] + : this.getAgentStatusSnapshotFn?.() + if (!hookRows) { + return null + } return readTuiIdleHookTurn({ agent, handles, paneKeys, hookRows, + connectionId: pty.connectionId, + wslDistro: pty.wslDistro, + launchToken: pty.launchToken, + titleObservedAtEpochMs: pty.lastOscTitleEpochMs, + hasExplicitIdleTitle: hasExplicitIdleTitle(pty), respawnedAt: this.agentPromptExplicitStatusFloorByPtyId.get(ptyId), lastInputAt: this.terminalRunFacts.readLastInputAt(ptyId), resolveBlockedText: (state, row) => diff --git a/src/main/runtime/orca-runtime-state-fields.ts b/src/main/runtime/orca-runtime-state-fields.ts index 404ddb35da3..0ab5fd6da06 100644 --- a/src/main/runtime/orca-runtime-state-fields.ts +++ b/src/main/runtime/orca-runtime-state-fields.ts @@ -46,6 +46,10 @@ import { RuntimeMachineName } from './runtime-machine-name' export class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCommands { protected readonly prepareClaudeAuth?: PrepareClaudeAuth + protected readonly getAgentStatusSnapshotForPaneFn: + | ((paneKey: string) => AgentStatusIpcPayload[]) + | null + protected readonly machineName = new RuntimeMachineName( () => this.store?.getSettings?.().machineName ) @@ -64,6 +68,7 @@ export class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCommands { // terminal output. worktree.ps reads this at query time so mobile shows the // same inline agent rows the desktop sidebar does — same source, 1:1. getAgentStatusSnapshot?: () => AgentStatusIpcPayload[] + getAgentStatusSnapshotForPane?: (paneKey: string) => AgentStatusIpcPayload[] /** Where structured (native chat) sessions publish into that same store, so the snapshot * above lists them like every other agent. */ structuredAgentStatusSink?: StructuredAgentSessionStatusSink @@ -220,6 +225,7 @@ export class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCommands { this.stats = stats } this.getAgentStatusSnapshotFn = deps?.getAgentStatusSnapshot ?? null + this.getAgentStatusSnapshotForPaneFn = deps?.getAgentStatusSnapshotForPane ?? null this.structuredAgentStatusSinkFn = deps?.structuredAgentStatusSink ?? null this.readObservedAgentStatusPaneIdentityFn = deps?.readObservedAgentStatusPaneIdentity ?? (() => ({ kind: 'unobserved' })) diff --git a/src/main/runtime/orchestration/worker-provider-session.ts b/src/main/runtime/orchestration/worker-provider-session.ts index 39c572364b9..6c0e69f8d94 100644 --- a/src/main/runtime/orchestration/worker-provider-session.ts +++ b/src/main/runtime/orchestration/worker-provider-session.ts @@ -18,7 +18,7 @@ export function selectExactWorkerProviderSession(args: { .filter( (entry) => entry.paneKey === args.paneKey && - connectionMatches(entry.connectionId, args.connectionId, args.wslDistro) && + terminalHostConnectionMatches(entry.connectionId, args.connectionId, args.wslDistro) && (!args.launchToken || entry.launchToken === args.launchToken) && entry.providerSessionOnly !== true && entry.providerSession !== undefined && @@ -50,7 +50,7 @@ function attestedWslDistro( return distro && connectionId === wslHookRelayConnectionId(distro) ? distro : undefined } -function connectionMatches( +export function terminalHostConnectionMatches( entryConnectionId: string | null, expectedConnectionId: string | null | undefined, wslDistro: string | null | undefined diff --git a/src/main/runtime/tui-idle-claude-task-wakeup.test.ts b/src/main/runtime/tui-idle-claude-task-wakeup.test.ts new file mode 100644 index 00000000000..a1505b503e2 --- /dev/null +++ b/src/main/runtime/tui-idle-claude-task-wakeup.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest' +import { + AGENT_STATUS_STALE_AFTER_MS, + normalizeAgentStatusPayload, + type AgentStatusIpcPayload +} from '../../shared/agent-status-types' +import { wslHookRelayConnectionId } from '../../shared/wsl-hook-relay-contract' +import { evaluateHookTurn, readTuiIdleHookTurn } from './tui-idle-hook-lane' + +const PANE = 'tab:11111111-1111-4111-8111-111111111111' + +function pendingRow(overrides: Partial = {}): AgentStatusIpcPayload { + const now = Date.now() + return { + paneKey: PANE, + state: 'working', + mainAgent: { state: 'done', stateStartedAt: now }, + prompt: '', + agentType: 'claude', + connectionId: 'host-a', + launchToken: 'launch-a', + claudeTaskWakeupPending: 'notification', + providerSession: { key: 'session_id', id: 'session-a' }, + observation: { + origin: 'hook', + authorityId: 'host-a', + incarnation: 1, + revision: 1, + observedAt: now + }, + receivedAt: now, + stateStartedAt: now, + ...overrides + } +} + +function verdict(row: AgentStatusIpcPayload, blocked = false, titleObservedAtEpochMs?: number) { + return evaluateHookTurn('claude', () => + readTuiIdleHookTurn({ + agent: 'claude', + handles: [], + paneKeys: [PANE], + hookRows: [row], + connectionId: 'host-a', + launchToken: 'launch-a', + titleObservedAtEpochMs, + hasExplicitIdleTitle: titleObservedAtEpochMs !== undefined, + resolveBlockedText: () => (blocked ? 'agent-interactive-prompt' : null) + }) + ) +} + +describe('Claude task wake-up readiness authority', () => { + it('vetoes rest from its own fresh hook without promoting ordinary Claude done to ready', () => { + expect(verdict(pendingRow())).toEqual({ kind: 'working' }) + expect(verdict(pendingRow({ state: 'done', claudeTaskWakeupPending: undefined }))).toBeNull() + expect(verdict(pendingRow({ claudeTaskWakeupPending: undefined }))).toBeNull() + }) + + it('reports an opaque child permission wait during the finishing turn', () => { + expect(verdict(pendingRow({ state: 'waiting' }), true)).toEqual({ + kind: 'blocked', + reason: 'agent-interactive-prompt' + }) + }) + + it.each([ + { connectionId: 'host-b' }, + { launchToken: 'old-launch' }, + { agentType: 'codex' }, + { providerSession: undefined }, + { providerSessionOnly: true }, + { restoredUnconfirmed: true }, + { observation: undefined }, + { paneKey: 'another-pane' } + ])('does not take authority from an unmatched or unverifiable row: %j', (overrides) => { + expect(verdict(pendingRow(overrides))).toBeNull() + }) + + it('allows a finishing turn’s fresh native rest when its Stop was lost, while owing still holds', () => { + const finishing = pendingRow({ claudeTaskWakeupPending: 'finishing-turn', turnStartedAt: 10 }) + expect(verdict(finishing, false, 9)).toEqual({ kind: 'working' }) + expect(verdict(finishing, false, 10)).toEqual({ kind: 'working' }) + expect(verdict(finishing, false, 11)).toBeNull() + expect(verdict(pendingRow(), false, 11)).toEqual({ kind: 'working' }) + const replay = pendingRow({ claudeTaskWakeupPending: 'finishing-turn' }) + expect(verdict(replay)).toEqual({ kind: 'working' }) + expect(verdict(replay, false, replay.receivedAt)).toEqual({ kind: 'working' }) + expect(verdict(replay, false, replay.receivedAt + 1)).toBeNull() + }) + + it('joins native, SSH and WSL execution hosts without accepting another distro', () => { + for (const [connectionId, wslDistro, rowConnectionId, held] of [ + [null, null, null, true], + ['ssh-a', 'Ubuntu', 'ssh-a', true], + [null, 'Ubuntu', wslHookRelayConnectionId('Ubuntu'), true], + [null, 'Ubuntu', wslHookRelayConnectionId('Debian'), false], + ['ssh-a', 'Ubuntu', wslHookRelayConnectionId('Ubuntu'), false] + ] as const) { + const turn = readTuiIdleHookTurn({ + agent: 'claude', + handles: [], + paneKeys: [PANE], + hookRows: [pendingRow({ connectionId: rowConnectionId })], + connectionId, + wslDistro, + launchToken: 'launch-a', + resolveBlockedText: () => null + }) + expect(evaluateHookTurn('claude', () => turn)).toEqual(held ? { kind: 'working' } : null) + } + }) + + it('uses evidence age rather than a reconnect receipt clock', () => { + expect( + verdict(pendingRow({ evidenceObservedAt: Date.now() - AGENT_STATUS_STALE_AFTER_MS - 1 })) + ).toBeNull() + }) + + it('accepts only known phases on a nonterminal Claude payload', () => { + expect(normalizeAgentStatusPayload(pendingRow())).toHaveProperty( + 'claudeTaskWakeupPending', + 'notification' + ) + for (const fields of [ + { claudeTaskWakeupPending: false }, + { claudeTaskWakeupPending: 'true' }, + { state: 'done' }, + { agentType: 'codex' } + ]) { + expect(normalizeAgentStatusPayload({ ...pendingRow(), ...fields })).not.toHaveProperty( + 'claudeTaskWakeupPending' + ) + } + }) +}) diff --git a/src/main/runtime/tui-idle-hook-lane.test.ts b/src/main/runtime/tui-idle-hook-lane.test.ts index c282fd1fb85..457cd11b933 100644 --- a/src/main/runtime/tui-idle-hook-lane.test.ts +++ b/src/main/runtime/tui-idle-hook-lane.test.ts @@ -315,13 +315,13 @@ describe('evaluateTuiIdle hook lane', () => { ).toEqual({ kind: 'pending', quietForeground: 'closed' }) }) - it('never reads hooks for identity-only claude', () => { + it('ignores an ordinary done hook for identity-only claude', () => { const readHookTurn = vi.fn(() => DONE) expect(evaluateTuiIdle(input({ agent: 'claude', readHookTurn }))).toEqual({ kind: 'pending', quietForeground: 'closed' }) - expect(readHookTurn).not.toHaveBeenCalled() + expect(readHookTurn).toHaveBeenCalledOnce() }) }) diff --git a/src/main/runtime/tui-idle-hook-lane.ts b/src/main/runtime/tui-idle-hook-lane.ts index e8ef4c1e724..f66b6ef98dc 100644 --- a/src/main/runtime/tui-idle-hook-lane.ts +++ b/src/main/runtime/tui-idle-hook-lane.ts @@ -3,6 +3,7 @@ import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-type import type { TuiAgent } from '../../shared/tui-agent' import { hookAuthority } from './agent-state-rules/agent-state-rules-engine' import { selectFreshExplicitAgentStatusRow } from './runtime-hook-agent-row-selection' +import { terminalHostConnectionMatches } from './orchestration/worker-provider-session' type HookTurnState = 'done' | 'working' | 'permission' @@ -14,6 +15,7 @@ type HookTurnState = 'done' | 'working' | 'permission' export type TuiIdleHookTurn = { state: HookTurnState blockedReason: RuntimeTerminalWaitBlockedReason | null + taskWakeupPending?: true } /** @@ -42,6 +44,11 @@ export type TuiIdleHookTurnRead = { handles: Iterable paneKeys: Iterable hookRows: readonly AgentStatusIpcPayload[] + connectionId?: string | null + wslDistro?: string | null + launchToken?: string | null + titleObservedAtEpochMs?: number | null + hasExplicitIdleTitle?: boolean /** When the PTY respawned: every row from before it is the previous process's. */ respawnedAt?: number /** When input last reached the pane, typed or sent: a `done` from before it cannot speak for @@ -73,8 +80,23 @@ export function readTuiIdleHookTurn(read: TuiIdleHookTurnRead): TuiIdleHookTurn const blockedReason = read.resolveBlockedText(state, row) // Why the hook alone blocks: a question or custom modal paints no dialog text the arbiter knows, // and these hooks report its answer. Input since may have answered it before the hook arrived. + const taskWakeupPending = + read.agent === 'claude' && + row.state !== 'done' && + (row.claudeTaskWakeupPending === 'notification' || + (row.claudeTaskWakeupPending === 'finishing-turn' && + !( + read.hasExplicitIdleTitle && + typeof read.titleObservedAtEpochMs === 'number' && + read.titleObservedAtEpochMs > (row.turnStartedAt ?? row.receivedAt) + ))) && + row.observation?.origin === 'hook' && + row.providerSession?.key === 'session_id' && + terminalHostConnectionMatches(row.connectionId, read.connectionId ?? null, read.wslDistro) && + (!read.launchToken || row.launchToken === read.launchToken) return { state, + ...(taskWakeupPending ? { taskWakeupPending: true as const } : {}), blockedReason: blockedReason ?? (state === 'permission' && !predatesInput ? 'agent-interactive-prompt' : null) @@ -99,6 +121,15 @@ export function evaluateHookTurn( agent: TuiAgent | null | undefined, readHookTurn: () => TuiIdleHookTurn | null ): TuiIdleHookVerdict | null { + // A ready composer can still owe a task wake-up; only that owner-produced fact vetoes Claude rest. + if (agent === 'claude') { + const turn = readHookTurn() + return turn?.taskWakeupPending + ? turn.blockedReason + ? { kind: 'blocked', reason: turn.blockedReason } + : { kind: 'working' } + : null + } const authority = hookAuthority(agent) if (authority === 'identity-only') { return null diff --git a/src/main/ssh/ssh-agent-hook-interrupt-reconciliation.test.ts b/src/main/ssh/ssh-agent-hook-interrupt-reconciliation.test.ts new file mode 100644 index 00000000000..2dff37d963b --- /dev/null +++ b/src/main/ssh/ssh-agent-hook-interrupt-reconciliation.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, vi } from 'vitest' +import { AgentHookServer } from '../agent-hooks/server' +import { makePaneKey } from '../../shared/stable-pane-id' +import { AGENT_HOOK_INFER_INTERRUPT_METHOD } from '../../shared/agent-hook-interrupt-reconciliation' +import { bindRemoteClaudeInterruptReconciliation } from './ssh-agent-hook-interrupt-reconciliation' + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn(() => ({})) })) +const PANE = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') +const REVISION = '11111111-1111-4111-8111-111111111112' + +function host(revision: string | undefined) { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + source: 'claude', + launchToken: 'launch-a', + hostTurnRevision: revision, + providerSession: { key: 'session_id', id: 'session-a' }, + hookEventName: 'UserPromptSubmit', + payload: { + state: 'working', + prompt: 'do work', + agentType: 'claude', + mainAgent: { state: 'working', stateStartedAt: Date.now() } + } + }, + 'ssh-owner' + ) + const infer = () => { + const row = server.getStatusSnapshotForPane(PANE)[0] + if (!row) { + throw new Error('Missing row') + } + return server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: row.receivedAt, + baselineStateStartedAt: row.stateStartedAt, + baselinePrompt: row.prompt, + baselineAgentType: row.agentType, + intent: 'ctrl-c' + }) + } + return { server, infer } +} + +describe('remote interrupt mux and version fences', () => { + it.each(['current', 'foreign', 'replaced', 'disposed', 'unsubscribed', 'legacy'])( + '%s mux dispatches only with a matching current host proof', + (scope) => { + const { server, infer } = host(scope === 'legacy' ? undefined : REVISION) + const request = vi.fn().mockResolvedValue({ applied: true }) + const mux = { request, isDisposed: () => scope === 'disposed' } + const unsubscribe = bindRemoteClaudeInterruptReconciliation( + server, + mux, + scope === 'foreign' ? 'other-host' : 'ssh-owner', + () => scope !== 'replaced' + ) + try { + if (scope === 'unsubscribed') { + unsubscribe() + } + expect(infer()).toBe(true) + if (scope === 'current') { + expect(request).toHaveBeenCalledExactlyOnceWith(AGENT_HOOK_INFER_INTERRUPT_METHOD, { + paneKey: PANE, + hostTurnRevision: REVISION, + launchToken: 'launch-a', + providerSession: { key: 'session_id', id: 'session-a' }, + intent: 'ctrl-c' + }) + } else { + expect(request).not.toHaveBeenCalled() + } + } finally { + unsubscribe() + server.stop() + } + } + ) +}) diff --git a/src/main/ssh/ssh-agent-hook-interrupt-reconciliation.ts b/src/main/ssh/ssh-agent-hook-interrupt-reconciliation.ts new file mode 100644 index 00000000000..8e45623840c --- /dev/null +++ b/src/main/ssh/ssh-agent-hook-interrupt-reconciliation.ts @@ -0,0 +1,22 @@ +import type { AgentHookServer } from '../agent-hooks/server' +import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' +import { AGENT_HOOK_INFER_INTERRUPT_METHOD } from '../../shared/agent-hook-interrupt-reconciliation' + +/** A host revision proves this relay supports the command; older relays keep the legacy path. */ +export function bindRemoteClaudeInterruptReconciliation( + server: Pick, + mux: Pick, + connectionId: string, + isCurrent: () => boolean +): () => void { + return server.subscribeRemoteInterruptRequests((command) => { + if (command.connectionId !== connectionId || !isCurrent() || mux.isDisposed()) { + return + } + void mux.request(AGENT_HOOK_INFER_INTERRUPT_METHOD, command.request).catch((error) => { + if (isCurrent() && !mux.isDisposed()) { + console.warn('[agent-hooks] remote interrupt reconciliation failed', error) + } + }) + }) +} diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 63516856036..5ff25c7a7b4 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -26,6 +26,7 @@ import { SshFilesystemProvider } from '../providers/ssh-filesystem-provider' import { isMethodNotFoundError } from './ssh-filesystem-stream-reader' import { SshGitProvider } from '../providers/ssh-git-provider' import { selectOpenCodePluginSources } from '../agent-hooks/opencode-plugin-settings' +import { bindRemoteClaudeInterruptReconciliation } from './ssh-agent-hook-interrupt-reconciliation' import { agentHookServer } from '../agent-hooks/server' import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' import { @@ -323,6 +324,7 @@ export class SshRelaySession { private abortController: AbortController | null = null private muxDisposeCleanup: (() => void) | null = null // Why: hold the notification-handler disposer so teardownProviders can release it on reconnect/shutdown (symmetric with muxDisposeCleanup). + private remoteInterruptCleanup: (() => void) | null = null private muxNotificationCleanup: (() => void) | null = null private pluginSettingsCleanup: (() => void) | null = null private pluginInstallRetryTimer: ReturnType | null = null @@ -1709,6 +1711,13 @@ export class SshRelaySession { } // Why: capture the disposer so teardownProviders can release this handler and re-wiring can't double-register it. this.muxNotificationCleanup?.() + this.remoteInterruptCleanup?.() + this.remoteInterruptCleanup = bindRemoteClaudeInterruptReconciliation( + agentHookServer, + mux, + this.targetId, + () => this.mux === mux + ) this.muxNotificationCleanup = mux.onNotification((method, params) => { if (method !== AGENT_HOOK_NOTIFICATION_METHOD) { return @@ -1725,6 +1734,7 @@ export class SshRelaySession { agentHookServer.ingestRemote( { paneKey: envelope.paneKey, + hostTurnRevision: envelope.hostTurnRevision, launchToken: typeof envelope.launchToken === 'string' ? envelope.launchToken : undefined, tabId: typeof envelope.tabId === 'string' ? envelope.tabId : undefined, worktreeId: typeof envelope.worktreeId === 'string' ? envelope.worktreeId : undefined, @@ -1808,6 +1818,8 @@ export class SshRelaySession { this.leavePlainSshMode() this.muxNotificationCleanup?.() this.muxNotificationCleanup = null + this.remoteInterruptCleanup?.() + this.remoteInterruptCleanup = null for (const cleanup of this.ptyRecoveryNotificationCleanups) { cleanup() } diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index bf9a8ad33a5..6c2945e0cb5 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -97,6 +97,7 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { // Why: worktree.ps pulls hook-reported agent status (same source as the desktop sidebar) at query time so mobile shows the same agents. getAgentStatusSnapshot: () => agentHookServer.getStatusSnapshot().filter((entry) => entry.providerSessionOnly !== true), + getAgentStatusSnapshotForPane: (paneKey) => agentHookServer.getStatusSnapshotForPane(paneKey), // Why: structured chats have no hooks, so the host writes their projections here itself; the // snapshot above then lists them for the CLI and mobile without a second store. structuredAgentStatusSink: { diff --git a/src/relay/agent-hook-envelope-build.ts b/src/relay/agent-hook-envelope-build.ts index e85a634f9cb..1a11c0d9cea 100644 --- a/src/relay/agent-hook-envelope-build.ts +++ b/src/relay/agent-hook-envelope-build.ts @@ -21,6 +21,7 @@ export function buildRelayHookEnvelope( : {}), agentPresence: event.agentPresence, paneKey: event.paneKey, + ...(event.hostTurnRevision ? { hostTurnRevision: event.hostTurnRevision } : {}), ...(event.launchToken ? { launchToken: event.launchToken } : {}), tabId: event.tabId, worktreeId: event.worktreeId, diff --git a/src/relay/agent-hook-event-admission.ts b/src/relay/agent-hook-event-admission.ts new file mode 100644 index 00000000000..50cb84adccd --- /dev/null +++ b/src/relay/agent-hook-event-admission.ts @@ -0,0 +1,91 @@ +import { transitionHookPresence } from '../shared/agent-hook-presence-transition' +import { isSameAgentProcess } from '../shared/agent-process-presence' +import { cacheRelayLegacyAgentStatus } from '../shared/agent-status-legacy-relay-cache' +import type { HookListenerState } from '../shared/agent-hook-listener/listener-state' +import type { AgentHookEventPayload } from '../shared/agent-hook-listener/listener-event' +import type { AgentHookSource } from '../shared/agent-hook-relay' +import { buildRelayHookEnvelope } from './agent-hook-envelope-build' +import type { RelayHookForward } from './agent-hook-server-contract' +import { MAX_CACHED_PANES, type CachedPaneEnvelopeMeta } from './agent-hook-cached-pane-status' +import { + reconcileRelayClaudeCancel, + withRelayClaudeTurnRevision +} from './agent-hook-interrupt-reconciliation' + +type RelayHookAdmissionHost = { + state: HookListenerState + metadata: Map + isCanonicalPane: (paneKey: string) => boolean + isPaneSurfaceRetired: (paneKey: string) => boolean + clearPaneState: (paneKey: string) => void + clearAssistantMessageRetry: (paneKey: string) => void + forward: RelayHookForward + checkAgentPresence: (paneKey: string) => Promise +} + +export function applyRelayHookEvent( + host: RelayHookAdmissionHost, + incoming: AgentHookEventPayload, + source: AgentHookSource, + env?: string, + version?: string, + options: { isReplay?: boolean; checkPresence?: boolean } = {} +): AgentHookEventPayload | undefined { + if (host.isCanonicalPane(incoming.paneKey)) { + return undefined + } + const previous = host.state.lastStatusByPaneKey.get(incoming.paneKey) + const cancellation = reconcileRelayClaudeCancel(host.state, previous, incoming, source) + if (cancellation.hold) { + return previous + } + const transitioned = transitionHookPresence(cancellation.event, previous) + if (!transitioned) { + return undefined + } + const event = withRelayClaudeTurnRevision( + previous, + transitioned.agentPresence?.ended + ? { ...transitioned, providerSessionOnly: true } + : transitioned, + source + ) + // Why: this post came from a process still running inside a pane whose tab the user closed. + // Caching or forwarding it makes every connected client advertise a live, resumable agent pane + // that no tab owns — the advertisement that ends up auto-typing a second `--resume` onto a + // transcript the orphan is still writing (#12447). Drop the stale cache with it. + if (host.isPaneSurfaceRetired(event.paneKey)) { + host.clearPaneState(event.paneKey) + return undefined + } + if (event.payload.state !== 'done' || event.payload.lastAssistantMessage) { + host.clearAssistantMessageRetry(event.paneKey) + } + // Why: keep PostCompact identity in the replay cache so the client can re-run ownership when + // it reconnects. Stripping it would let a cold relay replay a completion as an ordinary `done` + // row and resurrect a pane that the client had already retired. + if ( + !cacheRelayLegacyAgentStatus(host.state, event, MAX_CACHED_PANES, (paneKey) => + host.clearPaneState(paneKey) + ) + ) { + return undefined + } + host.metadata.delete(event.paneKey) + host.metadata.set(event.paneKey, { source, env, version }) + host.forward(buildRelayHookEnvelope(event, source, env, version, options)) + const sender = incoming.agentPresence?.process + const owner = event.agentPresence + // Why: a live hook proves its own process alive; only another process's hook casts doubt on the owner. + if ( + options.checkPresence !== false && + sender && + owner?.process && + !owner.ended && + !isSameAgentProcess(sender, owner.process) + ) { + void host.checkAgentPresence(event.paneKey) + } + // Why: retries compare against the cached row by identity, so they must hold that exact row. + return host.state.lastStatusByPaneKey.get(event.paneKey) +} diff --git a/src/relay/agent-hook-interrupt-reconciliation.test.ts b/src/relay/agent-hook-interrupt-reconciliation.test.ts new file mode 100644 index 00000000000..9499022ae6d --- /dev/null +++ b/src/relay/agent-hook-interrupt-reconciliation.test.ts @@ -0,0 +1,166 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import type { AgentHookRelayEnvelope } from '../shared/agent-hook-relay' +import type { RemoteAgentInterruptRequest } from '../shared/agent-hook-interrupt-reconciliation' +import { makePaneKey } from '../shared/stable-pane-id' +import { RelayAgentHookServer } from './agent-hook-server' + +const PANE = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') + +async function fixture() { + const dir = mkdtempSync(join(tmpdir(), 'relay-cancel-fence-')) + let retired = false + let ownerLaunch = 'launch-a' + const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() + const server = new RelayAgentHookServer({ + endpointDir: dir, + forward, + getAgentLaunchToken: () => ownerLaunch, + isPaneSurfaceRetired: () => retired + }) + await server.start({ publishEndpoint: false }) + const post = async (payload: Record) => { + const { port, token } = server.getCoordinates() + const response = await fetch(`http://127.0.0.1:${port}/hook/claude`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Orca-Agent-Hook-Token': token }, + body: JSON.stringify({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + launchToken: 'launch-a', + payload: { session_id: 'session-a', ...payload } + }) + }) + expect(response.status).toBe(204) + } + await post({ hook_event_name: 'UserPromptSubmit', prompt: 'start work' }) + const row = forward.mock.lastCall?.[0] + if (!row?.hostTurnRevision || !row.providerSession) { + throw new Error('Missing host proof') + } + const command: RemoteAgentInterruptRequest = { + paneKey: PANE, + hostTurnRevision: row.hostTurnRevision, + launchToken: row.launchToken, + providerSession: row.providerSession, + intent: 'ctrl-c' + } + return { + server, + post, + command, + forward, + retire: () => { + retired = true + }, + replaceLaunch: () => { + ownerLaunch = 'launch-b' + }, + close: () => { + server.stop() + rmSync(dir, { recursive: true, force: true }) + } + } +} + +describe('relay interrupt owner reconciliation', () => { + it.each(['revision', 'launch', 'session', 'intent', 'pane'])( + 'refuses a wrong %s proof', + async (field) => { + const host = await fixture() + try { + const invalid = { + ...host.command, + ...(field === 'revision' + ? { hostTurnRevision: '00000000-0000-0000-0000-000000000000' } + : {}), + ...(field === 'launch' ? { launchToken: 'launch-b' } : {}), + ...(field === 'session' + ? { providerSession: { key: 'session_id', id: 'session-b' } } + : {}), + ...(field === 'intent' ? { intent: 'plain-escape' } : {}), + ...(field === 'pane' ? { paneKey: 'other-pane' } : {}) + } + expect(host.server.inferInterrupt(invalid)).toBe(false) + expect(host.forward.mock.lastCall?.[0].payload.mainAgent?.state).toBe('working') + } finally { + host.close() + } + } + ) + + it.each([ + 'retired', + 'launch-replaced', + 'new-prompt', + 'same-prompt', + 'changed-own-prompt', + 'new-session', + 'task-wakeup', + 'stopped-host' + ])('refuses a command after %s supersedes the row', async (change) => { + const host = await fixture() + try { + if (change === 'retired') { + host.retire() + } + if (change === 'launch-replaced') { + host.replaceLaunch() + } + if (change === 'new-prompt') { + await host.post({ hook_event_name: 'UserPromptSubmit', prompt: 'new work' }) + } + if (change === 'same-prompt') { + await host.post({ hook_event_name: 'UserPromptSubmit', prompt: 'start work' }) + } + if (change === 'changed-own-prompt') { + await host.post({ + hook_event_name: 'PostToolUse', + tool_name: 'Read', + prompt: 'another request' + }) + } + if (change === 'new-session') { + await host.post({ + hook_event_name: 'SessionStart', + source: 'startup', + session_id: 'session-b' + }) + } + if (change === 'task-wakeup') { + await host.post({ + hook_event_name: 'UserPromptSubmit', + prompt: 'a1completed' + }) + } + if (change === 'stopped-host') { + host.server.stop() + } + expect(host.server.inferInterrupt(host.command)).toBe(false) + } finally { + host.close() + } + }) + + it('acknowledges the current owner once and keeps a later genuine prompt working', async () => { + const host = await fixture() + try { + await host.post({ hook_event_name: 'PostToolUse', tool_name: 'Read' }) + expect(host.server.inferInterrupt(host.command)).toBe(true) + expect(host.forward.mock.lastCall?.[0].payload).toMatchObject({ + state: 'done', + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + expect(host.server.inferInterrupt(host.command)).toBe(false) + await host.post({ hook_event_name: 'PostToolUse', tool_name: 'Read' }) + expect(host.forward.mock.lastCall?.[0].payload.mainAgent?.outcome).toBe('cancellation') + await host.post({ hook_event_name: 'UserPromptSubmit', prompt: 'new work' }) + expect(host.forward.mock.lastCall?.[0].payload.mainAgent?.state).toBe('working') + } finally { + host.close() + } + }) +}) diff --git a/src/relay/agent-hook-interrupt-reconciliation.ts b/src/relay/agent-hook-interrupt-reconciliation.ts new file mode 100644 index 00000000000..5840282be04 --- /dev/null +++ b/src/relay/agent-hook-interrupt-reconciliation.ts @@ -0,0 +1,145 @@ +import { randomUUID } from 'node:crypto' +import { AGENT_STATUS_STALE_AFTER_MS } from '../shared/agent-status-types' +import { isRecord } from '../shared/agent-status-child-work-value-guards' +import { normalizeAgentProviderSession } from '../shared/agent-session-resume' +import { normalizeHostTurnRevision } from '../shared/agent-hook-interrupt-reconciliation' +import { + opensNewTurn, + restatesAnotherPrompt, + resolveCancelVerdictLatch, + type CancelVerdictLatchDecision +} from '../shared/agent-hook-cancel-verdict-latch' +import { + markClaudeLeadTurnInterrupted, + setClaudeMainAgentTurnState +} from '../shared/agent-hook-listener/providers/claude-roster-state' +import { claudeRowHasUnlistedLiveWork } from '../shared/agent-hook-listener/providers/claude-pane-hold-evidence' +import type { HookListenerState } from '../shared/agent-hook-listener/listener-state' +import type { AgentHookEventPayload } from '../shared/agent-hook-listener/listener-event' +import type { AgentHookSource } from '../shared/agent-hook-relay' +import type { CachedPaneEnvelopeMeta } from './agent-hook-cached-pane-status' + +type RelayInterruptHost = { + state: HookListenerState + isListening: boolean + getMetadata: (paneKey: string) => CachedPaneEnvelopeMeta | undefined + getAgentLaunchToken: (paneKey: string) => string | undefined + isPaneBlocked: (paneKey: string) => boolean + apply: ( + event: AgentHookEventPayload, + meta: CachedPaneEnvelopeMeta + ) => AgentHookEventPayload | undefined + armExpiry: (paneKey: string, meta: CachedPaneEnvelopeMeta) => void +} + +export function inferRelayClaudeInterrupt(host: RelayInterruptHost, request: unknown): boolean { + if (!isRecord(request) || request.intent !== 'ctrl-c' || typeof request.paneKey !== 'string') { + return false + } + const row = host.state.lastStatusByPaneKey.get(request.paneKey) + const meta = host.getMetadata(request.paneKey) + const session = normalizeAgentProviderSession(request.providerSession) + const expectedLaunchToken = host.getAgentLaunchToken(request.paneKey) + if ( + !host.isListening || + !row || + !meta || + meta.source !== 'claude' || + host.isPaneBlocked(request.paneKey) || + Date.now() - (row.hostEvidenceObservedAt ?? 0) > AGENT_STATUS_STALE_AFTER_MS || + !normalizeHostTurnRevision(request.hostTurnRevision) || + row.hostTurnRevision !== request.hostTurnRevision || + row.launchToken !== request.launchToken || + (expectedLaunchToken !== undefined && row.launchToken !== expectedLaunchToken) || + !session || + row.providerSession?.id !== session.id || + row.providerSession.key !== session.key || + row.providerSessionOnly || + row.isReplay || + row.agentPresence?.ended || + row.payload.agentType !== 'claude' || + row.payload.state !== 'working' || + row.payload.mainAgent?.state !== 'working' + ) { + return false + } + const cancelled = markClaudeLeadTurnInterrupted(host.state, row.paneKey) + const { workingMode: _workingMode, interrupted: _interrupted, ...payload } = row.payload + const accepted = host.apply( + { + ...row, + hookEventName: undefined, + hasExplicitPrompt: undefined, + hostEvidenceObservedAt: Date.now(), + claudeRunningNonAgentTask: claudeRowHasUnlistedLiveWork(host.state, row.paneKey), + payload: { + ...payload, + ...cancelled, + ...(cancelled.state === 'done' ? { interrupted: true } : {}) + } + }, + meta + ) + if (!accepted) { + return false + } + host.armExpiry(row.paneKey, meta) + return true +} + +export function reconcileRelayClaudeCancel( + state: HookListenerState, + previous: AgentHookEventPayload | undefined, + incoming: AgentHookEventPayload, + source: AgentHookSource +): CancelVerdictLatchDecision { + if ( + source === 'claude' && + previous?.payload.agentType === 'claude' && + previous.launchToken === incoming.launchToken && + previous.providerSession?.id === incoming.providerSession?.id && + previous.providerSession?.key === incoming.providerSession?.key + ) { + const latch = resolveCancelVerdictLatch( + { + ...previous, + receivedAt: previous.hostEvidenceObservedAt ?? Date.now() + }, + incoming, + Date.now() + ) + if (latch.hold) { + if (previous.payload.mainAgent?.state === 'done') { + setClaudeMainAgentTurnState(state, incoming.paneKey, previous.payload.mainAgent) + } + return { hold: true } + } + return { hold: false, event: latch.event } + } + return { hold: false, event: incoming } +} + +export function withRelayClaudeTurnRevision( + previous: AgentHookEventPayload | undefined, + incoming: AgentHookEventPayload, + source: AgentHookSource +): AgentHookEventPayload { + return { + ...incoming, + ...(incoming.agentPresence?.ended ? { providerSessionOnly: true } : {}), + ...(source === 'claude' + ? { + hostTurnRevision: + previous?.hostTurnRevision && + previous.launchToken === incoming.launchToken && + previous.providerSession?.id === incoming.providerSession?.id && + previous.providerSession?.key === incoming.providerSession?.key && + !opensNewTurn(incoming) && + !restatesAnotherPrompt(previous, incoming) + ? previous.hostTurnRevision + : randomUUID() + } + : {}), + hostEvidenceObservedAt: incoming.hostEvidenceObservedAt ?? Date.now() + } +} diff --git a/src/relay/agent-hook-request.ts b/src/relay/agent-hook-request.ts index 92e12e5fc64..c95cdc9e3dd 100644 --- a/src/relay/agent-hook-request.ts +++ b/src/relay/agent-hook-request.ts @@ -84,6 +84,7 @@ export async function handleRelayHookRequest( } options.retryScheduler.scheduleAssistantMessageRetry(source, hookBody, stored, env, version) options.retryScheduler.scheduleTranscriptPoll(source, hookBody, stored, env, version) + options.retryScheduler.armClaudeOwedNotificationExpiry(source, stored.paneKey, env, version) } } res.writeHead(204) diff --git a/src/relay/agent-hook-result-retry-scheduler.ts b/src/relay/agent-hook-result-retry-scheduler.ts index 79f0b876a96..0ba0b6f270f 100644 --- a/src/relay/agent-hook-result-retry-scheduler.ts +++ b/src/relay/agent-hook-result-retry-scheduler.ts @@ -17,6 +17,7 @@ import { transcriptPollUpdate } from '../shared/agent-hook-listener/transcript-poll-policy' import { AgentTranscriptPollScheduler } from '../shared/agent-transcript-poll-scheduler' +import { ClaudeOwedNotificationExpiryTimers } from '../shared/claude-owed-notification-expiry-timers' const ASSISTANT_MESSAGE_RETRY_ATTEMPTS = 5 const ASSISTANT_MESSAGE_RETRY_MS = 50 @@ -47,9 +48,11 @@ export class AgentHookResultRetryScheduler { private assistantMessageRetryTimers = new Map>() private transcriptPollScheduler: AgentTranscriptPollScheduler private host: AgentHookResultRetryHost + private claudeOwedNotificationExpiry: ClaudeOwedNotificationExpiryTimers constructor(host: AgentHookResultRetryHost) { this.host = host + this.claudeOwedNotificationExpiry = new ClaudeOwedNotificationExpiryTimers(host.state) this.transcriptPollScheduler = new AgentTranscriptPollScheduler( CODEX_SUBAGENT_POLL_MS, (paneKey, poll) => this.runTranscriptPoll(paneKey, poll) @@ -62,6 +65,21 @@ export class AgentHookResultRetryScheduler { } this.assistantMessageRetryTimers.clear() this.transcriptPollScheduler.clearAll() + this.claudeOwedNotificationExpiry.clearAll() + } + + /** No hook fires when Claude never sends an owed task notification; restate the row ourselves. */ + armClaudeOwedNotificationExpiry( + source: AgentHookSource, + paneKey: string, + env?: string, + version?: string + ): void { + this.claudeOwedNotificationExpiry.arm(paneKey, (row) => { + if (this.host.isListening()) { + this.host.applyEvent(row, source, env, version) + } + }) } clearAssistantMessageRetry(paneKey: string): void { diff --git a/src/relay/agent-hook-server-claude-owed-notification-expiry.test.ts b/src/relay/agent-hook-server-claude-owed-notification-expiry.test.ts new file mode 100644 index 00000000000..12d250984b5 --- /dev/null +++ b/src/relay/agent-hook-server-claude-owed-notification-expiry.test.ts @@ -0,0 +1,69 @@ +// The relay owns a remote pane's Claude records, so it — not the desktop — must restate a pane +// that was held working by a task notification Claude never sent. +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentHookRelayEnvelope } from '../shared/agent-hook-relay' +import { CLAUDE_OWED_TASK_NOTIFICATION_LEASE_MS } from '../shared/claude-owed-task-notifications' +import { makePaneKey } from '../shared/stable-pane-id' +import { RelayAgentHookServer } from './agent-hook-server' + +const PANE_KEY = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') +const SHELL = { id: 'b1', type: 'shell', status: 'running' } + +describe('Claude owed task notification expiry on the relay', () => { + let dir: string + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'relay-hook-expiry-')) + // Why shouldAdvanceTime: the hooks are real loopback POSTs, which need the clock to move. + vi.useFakeTimers({ + shouldAdvanceTime: true, + toFake: ['setTimeout', 'clearTimeout', 'Date', 'performance'] + }) + }) + afterEach(() => { + vi.useRealTimers() + rmSync(dir, { recursive: true, force: true }) + }) + + it('forwards the settled row when a launched shell vanished without a notification', async () => { + const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() + const server = new RelayAgentHookServer({ endpointDir: dir, forward }) + await server.start() + const { port, token } = server.getCoordinates() + const post = (payload: Record) => + fetch(`http://127.0.0.1:${port}/hook/claude`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Orca-Agent-Hook-Token': token }, + body: JSON.stringify({ + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { session_id: 'session-1', ...payload } + }) + }) + try { + await post({ hook_event_name: 'UserPromptSubmit', prompt: 'start the dev server' }) + await post({ + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_response: { backgroundTaskId: SHELL.id } + }) + await post({ hook_event_name: 'Stop', background_tasks: [SHELL] }) + await post({ hook_event_name: 'UserPromptSubmit', prompt: 'thanks' }) + await post({ hook_event_name: 'Stop', background_tasks: [] }) + expect(forward.mock.lastCall?.[0].payload.state).toBe('working') + + vi.advanceTimersByTime(CLAUDE_OWED_TASK_NOTIFICATION_LEASE_MS) + + expect(forward.mock.lastCall?.[0]).toMatchObject({ + paneKey: PANE_KEY, + claudeRunningNonAgentTask: false, + payload: { state: 'done' } + }) + } finally { + server.stop() + } + }) +}) diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index 019ddc3df6c..1006eef6d12 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -1,3 +1,5 @@ +import { inferRelayClaudeInterrupt } from './agent-hook-interrupt-reconciliation' +import { applyRelayHookEvent } from './agent-hook-event-admission' import { RelayAgentHookCanonicalStatus } from './agent-hook-canonical-status' import type { RelayHookForward, @@ -10,9 +12,7 @@ export type { RelayHookServerStartOptions } from './agent-hook-server-contract' import { handleRelayHookRequest } from './agent-hook-request' -import { transitionHookPresence } from '../shared/agent-hook-presence-transition' import { RelayAgentPresence } from './relay-agent-presence' -import { isSameAgentProcess } from '../shared/agent-process-presence' import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' import { randomUUID } from 'node:crypto' import { join } from 'node:path' @@ -27,7 +27,6 @@ import { createHookListenerState, type HookListenerState } from '../shared/agent-hook-listener/listener-state' -import { cacheRelayLegacyAgentStatus } from '../shared/agent-status-legacy-relay-cache' import { getEndpointFileName, writeEndpointFile @@ -43,7 +42,7 @@ import { buildRelayHookPtyEnv, defaultEndpointDir } from './agent-hook-endpoint- import { buildRelayHookEnvelope } from './agent-hook-envelope-build' import { drainRelayHookSpool, ingestRelayHookSpoolRecord } from './agent-hook-spool-ingest' import { AgentHookResultRetryScheduler } from './agent-hook-result-retry-scheduler' -import { MAX_CACHED_PANES, selectReplayableCachedPanes } from './agent-hook-cached-pane-status' +import { selectReplayableCachedPanes } from './agent-hook-cached-pane-status' export class RelayAgentHookServer extends RelayAgentHookCanonicalStatus { private server: ReturnType | null = null @@ -204,6 +203,29 @@ export class RelayAgentHookServer extends RelayAgentHookCanonicalStatus { return replayable.length + this.replayCanonicalHooks() } + inferInterrupt(request: unknown): boolean { + return inferRelayClaudeInterrupt( + { + state: this.state, + isListening: this.server !== null, + getMetadata: (paneKey) => this.lastEnvelopeMetaByPaneKey.get(paneKey), + getAgentLaunchToken: this.getAgentLaunchToken, + isPaneBlocked: (paneKey) => + this.isCanonicalPane(paneKey) || this.isPaneSurfaceRetired(paneKey), + apply: (event, meta) => + this.applyEvent(event, meta.source, meta.env, meta.version, { checkPresence: false }), + armExpiry: (paneKey, meta) => + this.retryScheduler.armClaudeOwedNotificationExpiry( + meta.source, + paneKey, + meta.env, + meta.version + ) + }, + request + ) + } + checkAgentPresence(paneKey: string): Promise { const row = this.state.lastStatusByPaneKey.get(paneKey) const meta = this.lastEnvelopeMetaByPaneKey.get(paneKey) @@ -268,57 +290,24 @@ export class RelayAgentHookServer extends RelayAgentHookCanonicalStatus { version?: string, options: { isReplay?: boolean; checkPresence?: boolean } = {} ): AgentHookEventPayload | undefined { - if (this.isCanonicalPane(incoming.paneKey)) { - return undefined - } - const transitioned = transitionHookPresence( + return applyRelayHookEvent( + { + state: this.state, + metadata: this.lastEnvelopeMetaByPaneKey, + isCanonicalPane: (paneKey) => this.isCanonicalPane(paneKey), + isPaneSurfaceRetired: this.isPaneSurfaceRetired, + clearPaneState: (paneKey) => this.clearPaneState(paneKey), + clearAssistantMessageRetry: (paneKey) => + this.retryScheduler.clearAssistantMessageRetry(paneKey), + forward: this.forward, + checkAgentPresence: (paneKey) => this.checkAgentPresence(paneKey) + }, incoming, - this.state.lastStatusByPaneKey.get(incoming.paneKey) + source, + env, + version, + options ) - if (!transitioned) { - return undefined - } - const event = transitioned.agentPresence?.ended - ? { ...transitioned, providerSessionOnly: true } - : transitioned - // Why: this post came from a process still running inside a pane whose tab the user closed. - // Caching or forwarding it makes every connected client advertise a live, resumable agent pane - // that no tab owns — the advertisement that ends up auto-typing a second `--resume` onto a - // transcript the orphan is still writing (#12447). Drop the stale cache with it. - if (this.isPaneSurfaceRetired(event.paneKey)) { - this.clearPaneState(event.paneKey) - return undefined - } - if (event.payload.state !== 'done' || event.payload.lastAssistantMessage) { - this.retryScheduler.clearAssistantMessageRetry(event.paneKey) - } - // Why: keep PostCompact identity in the replay cache so the client can re-run ownership when - // it reconnects. Stripping it would let a cold relay replay a completion as an ordinary `done` - // row and resurrect a pane that the client had already retired. - if ( - !cacheRelayLegacyAgentStatus(this.state, event, MAX_CACHED_PANES, (paneKey) => - this.clearPaneState(paneKey) - ) - ) { - return undefined - } - this.lastEnvelopeMetaByPaneKey.delete(event.paneKey) - this.lastEnvelopeMetaByPaneKey.set(event.paneKey, { source, env, version }) - this.forward(buildRelayHookEnvelope(event, source, env, version, options)) - const sender = incoming.agentPresence?.process - const owner = event.agentPresence - // Why: a live hook proves its own process alive; only another process's hook casts doubt on the owner. - if ( - options.checkPresence !== false && - sender && - owner?.process && - !owner.ended && - !isSameAgentProcess(sender, owner.process) - ) { - void this.checkAgentPresence(event.paneKey) - } - // Why: retries compare against the cached row by identity, so they must hold that exact row. - return this.state.lastStatusByPaneKey.get(event.paneKey) } private ingestSpoolRecord(record: SpoolRecord): void { diff --git a/src/relay/relay-agent-hook-runtime.ts b/src/relay/relay-agent-hook-runtime.ts index c991b3fde71..1c2f223c974 100644 --- a/src/relay/relay-agent-hook-runtime.ts +++ b/src/relay/relay-agent-hook-runtime.ts @@ -1,3 +1,4 @@ +import { AGENT_HOOK_INFER_INTERRUPT_METHOD } from '../shared/agent-hook-interrupt-reconciliation' import { homedir } from 'node:os' import type { RelayDispatcher } from './dispatcher' import type { PtyEnvAugmenter, PtyHandler } from './pty-handler' @@ -187,6 +188,9 @@ export class RelayAgentHookRuntime { } private registerHandlers(): void { + this.dispatcher.onRequest(AGENT_HOOK_INFER_INTERRUPT_METHOD, async (params) => ({ + applied: this.hookServer.inferInterrupt(params) + })) this.dispatcher.onRequest(AGENT_HOOK_REQUEST_REPLAY_METHOD, async () => ({ replayed: this.hookServer.replayCachedPayloadsForPanes() })) diff --git a/src/relay/wsl-agent-hook-relay.ts b/src/relay/wsl-agent-hook-relay.ts index 7f3ee8e4062..7e5be9960d4 100644 --- a/src/relay/wsl-agent-hook-relay.ts +++ b/src/relay/wsl-agent-hook-relay.ts @@ -10,6 +10,7 @@ // WSL's Windows→WSL forwarder grab the freed Windows-side port and blackhole // stale Windows-side hook posts — so unlike the SSH relay there is no grace // period and no daemon socket. +import { AGENT_HOOK_INFER_INTERRUPT_METHOD } from '../shared/agent-hook-interrupt-reconciliation' import { homedir } from 'node:os' import { RELAY_SENTINEL } from './protocol' @@ -70,6 +71,9 @@ async function main(): Promise { preferredPort: windowsPort, forward: (envelope) => publishAgentHookEnvelope(dispatcher, envelope) }) + dispatcher.onRequest(AGENT_HOOK_INFER_INTERRUPT_METHOD, async (params) => ({ + applied: hookServer.inferInterrupt(params) + })) new PreflightHandler(dispatcher) dispatcher.onRequest(AGENT_HOOK_REQUEST_REPLAY_METHOD, async () => ({ diff --git a/src/renderer/src/components/right-sidebar/file-explorer-watch-path.ts b/src/renderer/src/components/right-sidebar/file-explorer-watch-path.ts index 9988d9a9387..cb6bf74cbff 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-watch-path.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-watch-path.ts @@ -43,10 +43,11 @@ export function canonicalizeFileExplorerWatchPath( } export function createCachedDirPathIndex( - cache: Record + cache: Record, + keys: readonly string[] = Object.keys(cache) ): ReadonlyMap { const index = new Map() - for (const key of Object.keys(cache)) { + for (const key of keys) { const normalizedKey = normalizeRuntimePathForComparison(key) if (!index.has(normalizedKey)) { index.set(normalizedKey, key) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-watch-reconcile.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-watch-reconcile.test.ts index 6a19338c294..631f08b2601 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-watch-reconcile.test.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-watch-reconcile.test.ts @@ -75,6 +75,89 @@ describe('processFileExplorerFsPayload update reconciliation', () => { expect(Object.keys(cache)).toEqual(['C:\\Repo\\Keep', '/srv/repo/old/keep']) }) + it('purges every Windows cache key even when the normalized path index collapses aliases', () => { + const root = 'C:\\Repo' + let cache: Record = { + [root]: cacheWithChildren([]), + 'C:\\Repo\\Old': cacheWithChildren([]), + 'c:\\repo\\OLD': cacheWithChildren([]), + 'C:\\Repo\\New': cacheWithChildren([]), + 'c:\\repo\\NEW': cacheWithChildren([]), + 'C:\\Repo\\Keep': cacheWithChildren([]) + } + type DirCacheUpdate = Parameters[0]>[0] + processFileExplorerFsPayload({ + payload: { + worktreePath: root, + events: [ + { + kind: 'rename', + oldAbsolutePath: 'c:\\repo\\old', + absolutePath: 'c:\\repo\\new', + isDirectory: true + } + ] + }, + currentWorktreePath: root, + worktreeId: 'wt-1', + cache, + expanded: new Set(), + setDirCache: (update: DirCacheUpdate) => { + cache = typeof update === 'function' ? update(cache) : update + }, + setSelectedPath: vi.fn(), + refreshDir: vi.fn(), + refreshTree: vi.fn() + }) + expect(Object.keys(cache)).toEqual([root, 'C:\\Repo\\Keep']) + }) + + it('uses the newer cache when the functional purge runs after another update', () => { + const root = '/repo' + const cache: Record = { + [root]: cacheWithChildren([]), + '/repo/old': cacheWithChildren([]), + '/repo/new': cacheWithChildren([]) + } + type DirCacheUpdate = Parameters[0]>[0] + let pending: DirCacheUpdate | undefined + processFileExplorerFsPayload({ + payload: { + worktreePath: root, + events: [ + { + kind: 'rename', + oldAbsolutePath: '/repo/old', + absolutePath: '/repo/new', + isDirectory: true + } + ] + }, + currentWorktreePath: root, + worktreeId: 'wt-1', + cache, + expanded: new Set(), + setDirCache: (update) => { + pending = update + }, + setSelectedPath: vi.fn(), + refreshDir: vi.fn(), + refreshTree: vi.fn() + }) + expect(typeof pending).toBe('function') + const newer = { + ...cache, + '/repo/added': cacheWithChildren([]), + '/repo/old/added-child': cacheWithChildren([]) + } + if (typeof pending !== 'function') { + throw new Error('Expected a queued functional cache purge') + } + const result = pending(newer) + expect(Object.keys(result)).toEqual([root, '/repo/added']) + expect(result['/repo/added']).toBe(newer['/repo/added']) + }) + it('refreshes a cached parent when Windows reports a new file as update', () => { const root = 'C:\\Repo' const refreshDir = processUpdate({ @@ -431,8 +514,8 @@ describe('processFileExplorerFsPayload update reconciliation', () => { } expect(setDirCache).toHaveBeenCalledOnce() - // One index scan for linked-directory detection, then one purge scan for the entire batch. - expect(keyVisits).toBe(entryCount * 2) + // The alias index and purge share one unchanged-cache key snapshot. + expect(keyVisits).toBe(entryCount) expect(expandedPathReads).toBe(expandedPaths.length) expect(remainingExpanded).toEqual(new Set()) }) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-watch-reconcile.ts b/src/renderer/src/components/right-sidebar/file-explorer-watch-reconcile.ts index c665f6fb27f..9427bd97a06 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-watch-reconcile.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-watch-reconcile.ts @@ -85,9 +85,10 @@ export function processFileExplorerFsPayload(args: ProcessFileExplorerFsPayloadA const dirsToRefresh = new Set() const childPathIndexes = new Map>() let hasLinkedCache: boolean | undefined + let cachedDirKeys: string[] | undefined let cachedDirPathIndex: ReadonlyMap | undefined const cachePathIndex = (): ReadonlyMap => - (cachedDirPathIndex ??= createCachedDirPathIndex(cache)) + (cachedDirPathIndex ??= createCachedDirPathIndex(cache, (cachedDirKeys ??= Object.keys(cache)))) const cachedDirsToPurge = new Set() const reconciledRenameSources = new Set() let needsFullRefresh = false @@ -236,7 +237,11 @@ export function processFileExplorerFsPayload(args: ProcessFileExplorerFsPayloadA } } - purgeDirCacheSubtrees(setDirCache, cachedDirsToPurge) + purgeDirCacheSubtrees( + setDirCache, + cachedDirsToPurge, + cachedDirKeys ? { cache, keys: cachedDirKeys } : undefined + ) purgeExpandedDirsSubtrees(worktreeId, cachedDirsToPurge) if (needsFullRefresh) { diff --git a/src/renderer/src/components/right-sidebar/file-explorer-watcher-reconcile.ts b/src/renderer/src/components/right-sidebar/file-explorer-watcher-reconcile.ts index 6072cdb201f..df9533c1969 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-watcher-reconcile.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-watcher-reconcile.ts @@ -41,7 +41,8 @@ function createSubtreeMatcher(paths: ReadonlySet): (candidatePath: strin export function purgeDirCacheSubtrees( setDirCache: Dispatch>>, - deletedPaths: ReadonlySet + deletedPaths: ReadonlySet, + snapshot?: { cache: Record; keys: readonly string[] } ): void { if (deletedPaths.size === 0) { return @@ -50,7 +51,8 @@ export function purgeDirCacheSubtrees( setDirCache((prev) => { let changed = false const next: Record = {} - for (const key of Object.keys(prev)) { + const keys = prev === snapshot?.cache ? snapshot.keys : Object.keys(prev) + for (const key of keys) { if (shouldPurge(key)) { changed = true } else { diff --git a/src/shared/__fixtures__/claude-task-notification-hooks.jsonl b/src/shared/__fixtures__/claude-task-notification-hooks.jsonl new file mode 100644 index 00000000000..6b974cfa52d --- /dev/null +++ b/src/shared/__fixtures__/claude-task-notification-hooks.jsonl @@ -0,0 +1,186 @@ +{"scenario": "one-subagent", "t": 0.0, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SessionStart", "source": "startup"}} +{"scenario": "one-subagent", "t": 0.492, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "UserPromptSubmit", "prompt": "Use the Agent tool ONCE in the FOREGROUND (do NOT set run_in_background; wait for its result) to start a sub-agent whose only job is to run the Bash command `sleep 8` in the foreground and then reply 'slept'. When the tool returns its result, reply with exactly FG-DONE and end your turn. If you are later notified of anything, reply with exactly NOTED and use no tools."}} +{"scenario": "one-subagent", "t": 2.848, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PreToolUse", "tool_name": "Agent", "tool_input": {"description": "Sleep 8 seconds", "prompt": "Run the Bash command `sleep 8` in the foreground (do not run it in the background). After it completes, reply with exactly: slept", "subagent_type": "general-purpose"}}} +{"scenario": "one-subagent", "t": 2.914, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PostToolUse", "tool_name": "Agent", "tool_input": {"description": "Sleep 8 seconds", "prompt": "Run the Bash command `sleep 8` in the foreground (do not run it in the background). After it completes, reply with exactly: slept", "subagent_type": "general-purpose"}, "tool_response": {"isAsync": true, "status": "async_launched", "agentId": "a9de070bc47af3cd6"}}} +{"scenario": "one-subagent", "t": 2.914, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SubagentStart", "agent_id": "a9de070bc47af3cd6", "agent_type": "general-purpose"}} +{"scenario": "one-subagent", "t": 4.549, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PreToolUse", "agent_id": "a9de070bc47af3cd6", "agent_type": "general-purpose", "tool_name": "Bash", "tool_input": {"command": "sleep 8", "description": "Pause for eight seconds"}}} +{"scenario": "one-subagent", "t": 6.89, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "Stop", "background_tasks": [{"id": "a9de070bc47af3cd6", "type": "subagent", "status": "running", "description": "Sleep 8 seconds", "agent_type": "general-purpose"}], "session_crons": [], "stop_hook_active": false}} +{"scenario": "one-subagent", "t": 8.327, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SubagentStop", "agent_id": "a1c8ae44a05de4a8b", "agent_type": "", "background_tasks": [{"id": "a9de070bc47af3cd6", "type": "subagent", "status": "running", "description": "Sleep 8 seconds", "agent_type": "general-purpose"}], "session_crons": [], "stop_hook_active": false}} +{"scenario": "one-subagent", "t": 12.777, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PostToolUse", "agent_id": "a9de070bc47af3cd6", "agent_type": "general-purpose", "tool_name": "Bash", "tool_input": {"command": "sleep 8", "description": "Pause for eight seconds"}, "tool_response": {"stdout": "", "stderr": "", "interrupted": false, "isImage": false, "noOutputExpected": false}}} +{"scenario": "one-subagent", "t": 13.709, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SubagentStop", "agent_id": "a9de070bc47af3cd6", "agent_type": "general-purpose", "background_tasks": [{"id": "a9de070bc47af3cd6", "type": "subagent", "status": "running", "description": "Sleep 8 seconds", "agent_type": "general-purpose"}], "session_crons": [], "stop_hook_active": false}} +{"scenario": "one-subagent", "t": 13.803, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "UserPromptSubmit", "prompt": "\na9de070bc47af3cd6\ntoolu_01KFs26t3jTSeHBkdTUM5FCx\n/tmp/tasks/output\ncompleted\nAgent \"Sleep 8 seconds\" 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.\nslept\n30014110874\n"}} +{"scenario": "one-subagent", "t": 14.716, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "Stop", "background_tasks": [], "session_crons": [], "stop_hook_active": false}} +{"scenario": "one-subagent", "t": 15.899, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SubagentStop", "agent_id": "a22ca717f8f4b3627", "agent_type": "", "background_tasks": [], "session_crons": [], "stop_hook_active": false}} +{"scenario": "two-subagents-together", "t": 0.0, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SessionStart", "source": "startup"}} +{"scenario": "two-subagents-together", "t": 0.124, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "UserPromptSubmit", "prompt": "In ONE message, use the Agent tool twice to start TWO background sub-agents. Each sub-agent's only job is to run the shell command `sleep 12` using the Bash tool and then reply 'slept'. Do not wait for them with any tool: end your turn right away saying 'waiting'. Each time you are notified that one finished, reply with exactly NOTED and use no tools."}} +{"scenario": "two-subagents-together", "t": 2.99, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PreToolUse", "tool_name": "Agent", "tool_input": {"description": "Sleep 12 then reply", "prompt": "Run the shell command `sleep 12` using the Bash tool. Once it completes, reply with exactly: slept", "subagent_type": "general-purpose"}}} +{"scenario": "two-subagents-together", "t": 3.026, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SubagentStart", "agent_id": "ae0d714969e71ad04", "agent_type": "general-purpose"}} +{"scenario": "two-subagents-together", "t": 3.026, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PostToolUse", "tool_name": "Agent", "tool_input": {"description": "Sleep 12 then reply", "prompt": "Run the shell command `sleep 12` using the Bash tool. Once it completes, reply with exactly: slept", "subagent_type": "general-purpose"}, "tool_response": {"isAsync": true, "status": "async_launched", "agentId": "ae0d714969e71ad04", "description": "Sleep 12 then reply", "resolvedModel": "claude-opus-5-5", "prompt": "Run the shell command `sleep 12` using the Bash tool. Once it completes, reply with exactly: slept", "outputFile": "/tmp/scratch", "canReadOutputFile": true}}} +{"scenario": "two-subagents-together", "t": 3.572, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PreToolUse", "tool_name": "Agent", "tool_input": {"description": "Sleep 12 then reply", "prompt": "Run the shell command `sleep 12` using the Bash tool. Once it completes, reply with exactly: slept", "subagent_type": "general-purpose"}}} +{"scenario": "two-subagents-together", "t": 3.606, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PostToolUse", "tool_name": "Agent", "tool_input": {"description": "Sleep 12 then reply", "prompt": "Run the shell command `sleep 12` using the Bash tool. Once it completes, reply with exactly: slept", "subagent_type": "general-purpose"}, "tool_response": {"isAsync": true, "status": "async_launched", "agentId": "a79351e06c03e3d00", "description": "Sleep 12 then reply", "resolvedModel": "claude-opus-5-5", "prompt": "Run the shell command `sleep 12` using the Bash tool. Once it completes, reply with exactly: slept", "outputFile": "/tmp/scratch", "canReadOutputFile": true}}} +{"scenario": "two-subagents-together", "t": 3.606, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SubagentStart", "agent_id": "a79351e06c03e3d00", "agent_type": "general-purpose"}} +{"scenario": "two-subagents-together", "t": 4.585, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PreToolUse", "agent_id": "ae0d714969e71ad04", "agent_type": "general-purpose", "tool_name": "Bash", "tool_input": {"command": "sleep 12", "timeout": 30000, "description": "Pause for twelve seconds"}}} +{"scenario": "two-subagents-together", "t": 4.976, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "Stop", "background_tasks": [{"id": "ae0d714969e71ad04", "type": "subagent", "status": "running", "description": "Sleep 12 then reply", "agent_type": "general-purpose"}, {"id": "a79351e06c03e3d00", "type": "subagent", "status": "running", "description": "Sleep 12 then reply", "agent_type": "general-purpose"}], "session_crons": [], "stop_hook_active": false}} +{"scenario": "two-subagents-together", "t": 5.244, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PreToolUse", "agent_id": "a79351e06c03e3d00", "agent_type": "general-purpose", "tool_name": "Bash", "tool_input": {"command": "sleep 12", "timeout": 30000, "description": "Pause for twelve seconds"}}} +{"scenario": "two-subagents-together", "t": 7.66, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SubagentStop", "agent_id": "a33bbd77e74a2b906", "agent_type": "", "background_tasks": [{"id": "ae0d714969e71ad04", "type": "subagent", "status": "running", "description": "Sleep 12 then reply", "agent_type": "general-purpose"}, {"id": "a79351e06c03e3d00", "type": "subagent", "status": "running", "description": "Sleep 12 then reply", "agent_type": "general-purpose"}], "session_crons": [], "stop_hook_active": false}} +{"scenario": "two-subagents-together", "t": 16.744, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PostToolUse", "agent_id": "ae0d714969e71ad04", "agent_type": "general-purpose", "tool_name": "Bash", "tool_input": {"command": "sleep 12", "timeout": 30000, "description": "Pause for twelve seconds"}, "tool_response": {"stdout": "", "stderr": "", "interrupted": false, "isImage": false, "noOutputExpected": false}}} +{"scenario": "two-subagents-together", "t": 17.312, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PostToolUse", "agent_id": "a79351e06c03e3d00", "agent_type": "general-purpose", "tool_name": "Bash", "tool_input": {"command": "sleep 12", "timeout": 30000, "description": "Pause for twelve seconds"}, "tool_response": {"stdout": "", "stderr": "", "interrupted": false, "isImage": false, "noOutputExpected": false}}} +{"scenario": "two-subagents-together", "t": 17.796, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SubagentStop", "agent_id": "ae0d714969e71ad04", "agent_type": "general-purpose", "background_tasks": [{"id": "ae0d714969e71ad04", "type": "subagent", "status": "running", "description": "Sleep 12 then reply", "agent_type": "general-purpose"}, {"id": "a79351e06c03e3d00", "type": "subagent", "status": "running", "description": "Sleep 12 then reply", "agent_type": "general-purpose"}], "session_crons": [], "stop_hook_active": false}} +{"scenario": "two-subagents-together", "t": 17.846, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "UserPromptSubmit", "prompt": "\nae0d714969e71ad04\ntoolu_016RFFeEjqTcGB1LuRWuvezR\n/tmp/tasks/output\ncompleted\nAgent \"Sleep 12 then reply\" 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.\nslept\n30081114819\n"}} +{"scenario": "two-subagents-together", "t": 18.368, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SubagentStop", "agent_id": "a79351e06c03e3d00", "agent_type": "general-purpose", "background_tasks": [{"id": "a79351e06c03e3d00", "type": "subagent", "status": "running", "description": "Sleep 12 then reply", "agent_type": "general-purpose"}], "session_crons": [], "stop_hook_active": false}} +{"scenario": "two-subagents-together", "t": 19.091, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "Stop", "background_tasks": [], "session_crons": [], "stop_hook_active": false}} +{"scenario": "two-subagents-together", "t": 19.13, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "UserPromptSubmit", "prompt": "\na79351e06c03e3d00\ntoolu_01JjUxhy3YkX9VDb3qmmc9T6\n/tmp/tasks/output\ncompleted\nAgent \"Sleep 12 then reply\" 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.\nslept\n30081114794\n"}} +{"scenario": "two-subagents-together", "t": 20.227, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "Stop", "background_tasks": [], "session_crons": [], "stop_hook_active": false}} +{"scenario": "two-subagents-together", "t": 21.236, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SubagentStop", "agent_id": "a0a145e7601dfa144", "agent_type": "", "background_tasks": [], "session_crons": [], "stop_hook_active": false}} +{"scenario": "two-subagents-together", "t": 22.481, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SubagentStop", "agent_id": "aa6a5eda2c6917333", "agent_type": "", "background_tasks": [], "session_crons": [], "stop_hook_active": false}} +{"scenario": "subagent-ends-mid-turn", "t": 0.0, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SessionStart", "source": "startup"}} +{"scenario": "subagent-ends-mid-turn", "t": 0.144, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "UserPromptSubmit", "prompt": "First, use the Agent tool with run_in_background true to start ONE background sub-agent whose only job is to reply 'hi' using no tools at all. Then, in a SEPARATE later message, run the Bash command `sleep 15` in the FOREGROUND (run_in_background false). After the sleep returns, end your turn saying 'waiting'. Whenever you are notified that the sub-agent finished, reply with exactly NOTED and use no tools."}} +{"scenario": "subagent-ends-mid-turn", "t": 2.283, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PreToolUse", "tool_name": "Agent", "tool_input": {"description": "Reply hi", "prompt": "Reply with exactly 'hi'. Do not use any tools at all.", "subagent_type": "general-purpose"}}} +{"scenario": "subagent-ends-mid-turn", "t": 2.355, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SubagentStart", "agent_id": "aa48751ee72b94471", "agent_type": "general-purpose"}} +{"scenario": "subagent-ends-mid-turn", "t": 2.357, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PostToolUse", "tool_name": "Agent", "tool_input": {"description": "Reply hi", "prompt": "Reply with exactly 'hi'. Do not use any tools at all.", "subagent_type": "general-purpose"}, "tool_response": {"isAsync": true, "status": "async_launched", "agentId": "aa48751ee72b94471"}}} +{"scenario": "subagent-ends-mid-turn", "t": 3.214, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SubagentStop", "agent_id": "aa48751ee72b94471", "agent_type": "general-purpose", "background_tasks": [{"id": "aa48751ee72b94471", "type": "subagent", "status": "running", "description": "Reply hi", "agent_type": "general-purpose"}], "session_crons": [], "stop_hook_active": false}} +{"scenario": "subagent-ends-mid-turn", "t": 4.287, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": {"command": "sleep 15", "description": "Pause for 15 seconds", "run_in_background": false}}} +{"scenario": "subagent-ends-mid-turn", "t": 19.496, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PostToolUse", "tool_name": "Bash", "tool_input": {"command": "sleep 15", "description": "Pause for 15 seconds", "run_in_background": false}, "tool_response": {"stdout": "", "stderr": "", "interrupted": false, "isImage": false, "noOutputExpected": false}}} +{"scenario": "subagent-ends-mid-turn", "t": 19.559, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "UserPromptSubmit", "prompt": "\naa48751ee72b94471\ntoolu_01PUbiiVjzY3fxhtnvfuAMEP\n/tmp/tasks/output\ncompleted\nAgent \"Reply hi\" 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.\nhi\n275010942\n"}} +{"scenario": "subagent-ends-mid-turn", "t": 20.408, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "Stop", "background_tasks": [], "session_crons": [], "stop_hook_active": false}} +{"scenario": "subagent-ends-mid-turn", "t": 21.87, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SubagentStop", "agent_id": "a5f5c9250cb7fa01e", "agent_type": "", "background_tasks": [], "session_crons": [], "stop_hook_active": false}} +{"scenario": "subagent-task-stopped", "t": 0.0, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SessionStart", "source": "startup"}} +{"scenario": "subagent-task-stopped", "t": 0.171, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "UserPromptSubmit", "prompt": "Step 1: use the Agent tool to start ONE background sub-agent whose only job is to run this exact command with the Bash tool in the foreground: python3 -c \"import time; time.sleep(50)\" and then reply 'slept'. Step 2: run the Bash command `sleep 5` in the foreground. Step 3: stop that background sub-agent with the TaskStop tool, passing the sub-agent's agent id as the task id (load TaskStop via ToolSearch first if needed). Step 4: end your turn saying 'stopped'. If you are later notified of anything, reply with exactly NOTED and use no tools."}} +{"scenario": "subagent-task-stopped", "t": 2.871, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PreToolUse", "tool_name": "Agent", "tool_input": {"description": "Run sleep command", "prompt": "Your only job: run this exact command with the Bash tool in the foreground (not in background):\n\npython3 -c \"import time; time.sleep(50)\"\n\nThen reply with exactly: slept", "subagent_type": "general-purpose"}}} +{"scenario": "subagent-task-stopped", "t": 2.946, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SubagentStart", "agent_id": "a75c1b93839bab530", "agent_type": "general-purpose"}} +{"scenario": "subagent-task-stopped", "t": 2.95, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PostToolUse", "tool_name": "Agent", "tool_input": {"description": "Run sleep command", "prompt": "Your only job: run this exact command with the Bash tool in the foreground (not in background):\n\npython3 -c \"import time; time.sleep(50)\"\n\nThen reply with exactly: slept", "subagent_type": "general-purpose"}, "tool_response": {"isAsync": true, "status": "async_launched", "agentId": "a75c1b93839bab530"}}} +{"scenario": "subagent-task-stopped", "t": 3.293, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PreToolUse", "tool_name": "ToolSearch", "tool_input": {"query": "select:TaskStop", "max_results": 5}}} +{"scenario": "subagent-task-stopped", "t": 3.348, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PostToolUse", "tool_name": "ToolSearch", "tool_input": {"query": "select:TaskStop", "max_results": 5}, "tool_response": {"matches": ["TaskStop"], "query": "select:TaskStop", "total_deferred_tools": 95}}} +{"scenario": "subagent-task-stopped", "t": 4.721, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PreToolUse", "agent_id": "a75c1b93839bab530", "agent_type": "general-purpose", "tool_name": "Bash", "tool_input": {"command": "python3 -c \"import time; time.sleep(50)\"", "timeout": 120000, "description": "Sleep for 50 seconds"}}} +{"scenario": "subagent-task-stopped", "t": 4.864, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": {"command": "sleep 5", "description": "Wait five seconds"}}} +{"scenario": "subagent-task-stopped", "t": 9.955, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PostToolUse", "tool_name": "Bash", "tool_input": {"command": "sleep 5", "description": "Wait five seconds"}, "tool_response": {"stdout": "", "stderr": "", "interrupted": false, "isImage": false, "noOutputExpected": false}}} +{"scenario": "subagent-task-stopped", "t": 11.231, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PreToolUse", "tool_name": "TaskStop", "tool_input": {"task_id": "a75c1b93839bab530"}}} +{"scenario": "subagent-task-stopped", "t": 11.276, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PostToolUse", "tool_name": "TaskStop", "tool_input": {"task_id": "a75c1b93839bab530"}, "tool_response": {"message": "Successfully stopped task: a75c1b93839bab530 (Run sleep command)", "task_id": "a75c1b93839bab530", "task_type": "local_agent", "command": "Run sleep command"}}} +{"scenario": "subagent-task-stopped", "t": 11.316, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "UserPromptSubmit", "prompt": "\na75c1b93839bab530\ntoolu_01WEniJ3ojaJBmLTwa4Wrkgh\n/tmp/tasks/output\nkilled\nAgent \"Run sleep command\" was stopped by Claude\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.\n"}} +{"scenario": "subagent-task-stopped", "t": 12.213, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "Stop", "background_tasks": [], "session_crons": [], "stop_hook_active": false}} +{"scenario": "subagent-task-stopped", "t": 14.115, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SubagentStop", "agent_id": "a4f5953abcb8cf145", "agent_type": "", "background_tasks": [], "session_crons": [], "stop_hook_active": false}} +{"scenario": "subagent-task-stopped", "t": 72.27, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "Notification", "notification_type": "idle_prompt"}} +{"scenario": "subagent-resumed", "t": 0.0, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SessionStart", "source": "startup"}} +{"scenario": "subagent-resumed", "t": 0.205, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "UserPromptSubmit", "prompt": "Step 1: use the Agent tool with run_in_background true to start ONE background sub-agent whose only job is to reply 'hi' using no tools at all. End your turn right away saying 'waiting'. Step 2: when you are notified that it finished, RESUME that same sub-agent by sending it the follow-up message 'reply hi-again using no tools' with the SendMessage tool addressed to its agent id (load SendMessage via ToolSearch first if needed), then end your turn right away saying 'waiting2'. Step 3: when you are notified a second time, reply with exactly FINAL and use no tools."}} +{"scenario": "subagent-resumed", "t": 2.32, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PreToolUse", "tool_name": "Agent", "tool_input": {"description": "Reply hi", "prompt": "Reply with exactly 'hi'. Do not use any tools at all.", "subagent_type": "general-purpose"}}} +{"scenario": "subagent-resumed", "t": 2.388, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SubagentStart", "agent_id": "a019312d4410bdebc", "agent_type": "general-purpose"}} +{"scenario": "subagent-resumed", "t": 2.391, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PostToolUse", "tool_name": "Agent", "tool_input": {"description": "Reply hi", "prompt": "Reply with exactly 'hi'. Do not use any tools at all.", "subagent_type": "general-purpose"}, "tool_response": {"isAsync": true, "status": "async_launched", "agentId": "a019312d4410bdebc", "description": "Reply hi", "resolvedModel": "claude-opus-5-5", "prompt": "Reply with exactly 'hi'. Do not use any tools at all.", "outputFile": "/tmp/scratch", "canReadOutputFile": true}}} +{"scenario": "subagent-resumed", "t": 3.381, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SubagentStop", "agent_id": "a019312d4410bdebc", "agent_type": "general-purpose", "background_tasks": [{"id": "a019312d4410bdebc", "type": "subagent", "status": "running", "description": "Reply hi", "agent_type": "general-purpose"}], "session_crons": [], "stop_hook_active": false}} +{"scenario": "subagent-resumed", "t": 3.769, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "Stop", "background_tasks": [], "session_crons": [], "stop_hook_active": false}} +{"scenario": "subagent-resumed", "t": 3.85, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "UserPromptSubmit", "prompt": "\na019312d4410bdebc\ntoolu_01XXrcy3K2JqXvDV54fB2ZAi\n/tmp/tasks/output\ncompleted\nAgent \"Reply hi\" 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.\nhi\n2754701074\n"}} +{"scenario": "subagent-resumed", "t": 4.88, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SubagentStop", "agent_id": "a23e9996a26050b5b", "agent_type": "", "background_tasks": [], "session_crons": [], "stop_hook_active": false}} +{"scenario": "subagent-resumed", "t": 5.594, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PreToolUse", "tool_name": "ToolSearch", "tool_input": {"query": "select:SendMessage", "max_results": 5}}} +{"scenario": "subagent-resumed", "t": 5.653, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PostToolUse", "tool_name": "ToolSearch", "tool_input": {"query": "select:SendMessage", "max_results": 5}, "tool_response": {"matches": ["SendMessage"], "query": "select:SendMessage", "total_deferred_tools": 95}}} +{"scenario": "subagent-resumed", "t": 7.43, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PreToolUse", "tool_name": "SendMessage", "tool_input": {"to": "a019312d4410bdebc", "summary": "Ask sub-agent to reply hi-again", "message": "reply hi-again using no tools", "type": "message", "recipient": "a019312d4410bdebc", "content": "reply hi-again using no tools"}}} +{"scenario": "subagent-resumed", "t": 7.507, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PostToolUse", "tool_name": "SendMessage", "tool_input": {"to": "a019312d4410bdebc", "summary": "Ask sub-agent to reply hi-again", "message": "reply hi-again using no tools"}, "tool_response": {"success": true, "message": "Resuming agent a019312", "resumedAgentId": "a019312d4410bdebc", "pin": {"id": "a019312d4410bdebc", "name": "a019312d4410bdebc", "ref": "102f56"}}}} +{"scenario": "subagent-resumed", "t": 7.507, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SubagentStart", "agent_id": "a019312d4410bdebc", "agent_type": "general-purpose"}} +{"scenario": "subagent-resumed", "t": 8.485, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "Stop", "background_tasks": [{"id": "a019312d4410bdebc", "type": "subagent", "status": "running", "description": "Reply hi", "agent_type": "general-purpose"}], "session_crons": [], "stop_hook_active": false}} +{"scenario": "subagent-resumed", "t": 9.52, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SubagentStop", "agent_id": "a3ee220a4348e8c4c", "agent_type": "", "background_tasks": [{"id": "a019312d4410bdebc", "type": "subagent", "status": "running", "description": "Reply hi", "agent_type": "general-purpose"}], "session_crons": [], "stop_hook_active": false}} +{"scenario": "subagent-resumed", "t": 11.162, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SubagentStop", "agent_id": "a019312d4410bdebc", "agent_type": "general-purpose", "background_tasks": [{"id": "a019312d4410bdebc", "type": "subagent", "status": "running", "description": "Reply hi", "agent_type": "general-purpose"}], "session_crons": [], "stop_hook_active": false}} +{"scenario": "subagent-resumed", "t": 11.211, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "UserPromptSubmit", "prompt": "\na019312d4410bdebc\ntoolu_01N9GxJ3N13QYVS6QRBFABzg\n/tmp/tasks/output\ncompleted\nAgent \"Reply hi\" 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.\nhi-again\n2888508838\n"}} +{"scenario": "subagent-resumed", "t": 12.063, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "Stop", "background_tasks": [], "session_crons": [], "stop_hook_active": false}} +{"scenario": "subagent-resumed", "t": 12.969, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SubagentStop", "agent_id": "aac0eff65c903f6ea", "agent_type": "", "background_tasks": [], "session_crons": [], "stop_hook_active": false}} +{"scenario": "subagent-resumed", "t": 72.114, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "Notification", "notification_type": "idle_prompt"}} +{"scenario": "subagent-with-own-shell", "t": 0.0, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SessionStart", "source": "startup"}} +{"scenario": "subagent-with-own-shell", "t": 0.168, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "UserPromptSubmit", "prompt": "Use the Agent tool with run_in_background true to start ONE background sub-agent whose only job is: call the Bash tool with run_in_background true and the command `sleep 12`, then immediately (without waiting for or checking on that shell) reply 'launched'. Do not wait for the sub-agent with any tool: end your turn right away saying 'waiting'. Whenever you are later notified of anything, reply with exactly NOTED and use no tools."}} +{"scenario": "subagent-with-own-shell", "t": 2.86, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PreToolUse", "tool_name": "Agent", "tool_input": {"description": "Launch background sleep", "prompt": "Your only job: call the Bash tool with run_in_background set to true and the command `sleep 12`. Then immediately, without waiting for or checking on that shell in any way, reply with exactly: launched", "subagent_type": "general-purpose"}}} +{"scenario": "subagent-with-own-shell", "t": 2.897, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PostToolUse", "tool_name": "Agent", "tool_input": {"description": "Launch background sleep", "prompt": "Your only job: call the Bash tool with run_in_background set to true and the command `sleep 12`. Then immediately, without waiting for or checking on that shell in any way, reply with exactly: launched", "subagent_type": "general-purpose"}, "tool_response": {"isAsync": true, "status": "async_launched", "agentId": "aacdc6c2ad867851c", "description": "Launch background sleep", "resolvedModel": "claude-opus-5-5", "prompt": "Your only job: call the Bash tool with run_in_background set to true and the command `sleep 12`. Then immediately, without waiting for or checking on that shell in any way, reply with exactly: launched", "outputFile": "/tmp/scratch", "canReadOutputFile": true}}} +{"scenario": "subagent-with-own-shell", "t": 2.898, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SubagentStart", "agent_id": "aacdc6c2ad867851c", "agent_type": "general-purpose"}} +{"scenario": "subagent-with-own-shell", "t": 4.272, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "Stop", "background_tasks": [{"id": "aacdc6c2ad867851c", "type": "subagent", "status": "running", "description": "Launch background sleep", "agent_type": "general-purpose"}], "session_crons": [], "stop_hook_active": false}} +{"scenario": "subagent-with-own-shell", "t": 4.758, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PreToolUse", "agent_id": "aacdc6c2ad867851c", "agent_type": "general-purpose", "tool_name": "Bash", "tool_input": {"command": "sleep 12", "description": "Sleep for 12 seconds", "run_in_background": true}}} +{"scenario": "subagent-with-own-shell", "t": 4.88, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PostToolUse", "agent_id": "aacdc6c2ad867851c", "agent_type": "general-purpose", "tool_name": "Bash", "tool_input": {"command": "sleep 12", "description": "Sleep for 12 seconds", "run_in_background": true}, "tool_response": {"stdout": "", "stderr": "", "interrupted": false, "isImage": false, "noOutputExpected": false, "backgroundTaskId": "bfpn0539a"}}} +{"scenario": "subagent-with-own-shell", "t": 5.988, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SubagentStop", "agent_id": "aacdc6c2ad867851c", "agent_type": "general-purpose", "background_tasks": [{"id": "aacdc6c2ad867851c", "type": "subagent", "status": "running", "description": "Launch background sleep", "agent_type": "general-purpose"}, {"id": "bfpn0539a", "type": "shell", "status": "running", "description": "Sleep for 12 seconds", "command": "sleep 12"}], "session_crons": [], "stop_hook_active": false}} +{"scenario": "subagent-with-own-shell", "t": 6.039, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "UserPromptSubmit", "prompt": "\naacdc6c2ad867851c\ntoolu_01EZsiR1pyPBgp8WDDHV2Fkz\n/tmp/tasks/output\ncompleted\nAgent \"Launch background sleep\" finished\nThis agent stopped with background work of its own still running. It may resume on its own when that work completes or reports, and the same task-id notifies again if it does; the result below may be interim.\nlaunched\n3030113140\n"}} +{"scenario": "subagent-with-own-shell", "t": 7.5, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "Stop", "background_tasks": [{"id": "bfpn0539a", "type": "shell", "status": "running", "description": "Sleep for 12 seconds", "command": "sleep 12"}], "session_crons": [], "stop_hook_active": false}} +{"scenario": "subagent-with-own-shell", "t": 7.58, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SubagentStop", "agent_id": "a2d72510805d62eb4", "agent_type": "", "background_tasks": [{"id": "bfpn0539a", "type": "shell", "status": "running", "description": "Sleep for 12 seconds", "command": "sleep 12"}], "session_crons": [], "stop_hook_active": false}} +{"scenario": "subagent-with-own-shell", "t": 10.603, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SubagentStop", "agent_id": "a15fd95c88da2c8e3", "agent_type": "", "background_tasks": [{"id": "bfpn0539a", "type": "shell", "status": "running", "description": "Sleep for 12 seconds", "command": "sleep 12"}], "session_crons": [], "stop_hook_active": false}} +{"scenario": "subagent-with-own-shell", "t": 16.919, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SubagentStart", "agent_id": "aacdc6c2ad867851c", "agent_type": "general-purpose"}} +{"scenario": "subagent-with-own-shell", "t": 19.068, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SubagentStop", "agent_id": "aacdc6c2ad867851c", "agent_type": "general-purpose", "background_tasks": [{"id": "aacdc6c2ad867851c", "type": "subagent", "status": "running", "description": "Launch background sleep", "agent_type": "general-purpose"}], "session_crons": [], "stop_hook_active": false}} +{"scenario": "subagent-with-own-shell", "t": 19.105, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "UserPromptSubmit", "prompt": "\naacdc6c2ad867851c\n/tmp/tasks/output\ncompleted\nAgent \"Launch background sleep\" 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.\nThe background 12-second sleep finished with exit code 0. I have no task instructions beyond that, so nothing else has been run or changed.\n32047116204\n"}} +{"scenario": "subagent-with-own-shell", "t": 20.452, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "Stop", "background_tasks": [], "session_crons": [], "stop_hook_active": false}} +{"scenario": "subagent-with-own-shell", "t": 24.506, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SubagentStop", "agent_id": "a4a50f1869933f003", "agent_type": "", "background_tasks": [], "session_crons": [], "stop_hook_active": false}} +{"scenario": "subagent-with-own-shell", "t": 80.483, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "Notification", "notification_type": "idle_prompt"}} +{"scenario": "subagent-then-typed-prompt", "t": 0.0, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SessionStart", "source": "startup"}} +{"scenario": "subagent-then-typed-prompt", "t": 0.174, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "UserPromptSubmit", "prompt": "First, use the Agent tool with run_in_background true to start ONE background sub-agent whose only job is to reply 'hi' using no tools at all. Then, in a SEPARATE later message, run the Bash command `sleep 20` in the FOREGROUND. After the sleep returns, end your turn saying 'waiting'. Whenever you are notified that the sub-agent finished, reply with exactly NOTED and use no tools."}} +{"scenario": "subagent-then-typed-prompt", "t": 3.177, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PreToolUse", "tool_name": "Agent", "tool_input": {"description": "Reply hi", "prompt": "Reply with exactly 'hi'. Do not use any tools at all.", "subagent_type": "general-purpose"}}} +{"scenario": "subagent-then-typed-prompt", "t": 3.215, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SubagentStart", "agent_id": "adf09581799534295", "agent_type": "general-purpose"}} +{"scenario": "subagent-then-typed-prompt", "t": 3.215, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PostToolUse", "tool_name": "Agent", "tool_input": {"description": "Reply hi", "prompt": "Reply with exactly 'hi'. Do not use any tools at all.", "subagent_type": "general-purpose"}, "tool_response": {"isAsync": true, "status": "async_launched", "agentId": "adf09581799534295", "description": "Reply hi", "resolvedModel": "claude-opus-5-5", "prompt": "Reply with exactly 'hi'. Do not use any tools at all.", "outputFile": "/tmp/scratch", "canReadOutputFile": true}}} +{"scenario": "subagent-then-typed-prompt", "t": 4.193, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SubagentStop", "agent_id": "adf09581799534295", "agent_type": "general-purpose", "background_tasks": [{"id": "adf09581799534295", "type": "subagent", "status": "running", "description": "Reply hi", "agent_type": "general-purpose"}], "session_crons": [], "stop_hook_active": false}} +{"scenario": "subagent-then-typed-prompt", "t": 5.909, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": {"command": "sleep 20", "description": "Pause for 20 seconds"}}} +{"scenario": "subagent-then-typed-prompt", "t": 11.956, "kind": "key", "keys": "reply with exactly TYPED and use no tools"} +{"scenario": "subagent-then-typed-prompt", "t": 12.96, "kind": "key", "keys": "\\r"} +{"scenario": "subagent-then-typed-prompt", "t": 13.005, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "UserPromptSubmit", "prompt": "reply with exactly TYPED and use no tools"}} +{"scenario": "subagent-then-typed-prompt", "t": 26.066, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PostToolUse", "tool_name": "Bash", "tool_input": {"command": "sleep 20", "description": "Pause for 20 seconds"}, "tool_response": {"stdout": "", "stderr": "", "interrupted": false, "isImage": false, "noOutputExpected": false}}} +{"scenario": "subagent-then-typed-prompt", "t": 26.099, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "UserPromptSubmit", "prompt": "\nadf09581799534295\ntoolu_014hr4SdDEoT8n6qhCom4h3A\n/tmp/tasks/output\ncompleted\nAgent \"Reply hi\" 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.\nhi\n2857101029\n"}} +{"scenario": "subagent-then-typed-prompt", "t": 31.406, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "Stop", "background_tasks": [], "session_crons": [], "stop_hook_active": false}} +{"scenario": "subagent-then-typed-prompt", "t": 33.568, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SubagentStop", "agent_id": "ae6d5f245b81c9a49", "agent_type": "", "background_tasks": [], "session_crons": [], "stop_hook_active": false}} +{"scenario": "subagent-idle-ctrl-c", "t": 0.0, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SessionStart", "source": "startup"}} +{"scenario": "subagent-idle-ctrl-c", "t": 0.133, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "UserPromptSubmit", "prompt": "Use the Agent tool to start ONE background sub-agent whose only job is to run the shell command `sleep 15` and then reply 'slept'. Do not wait for it with any tool: end your turn right away saying 'waiting'. When you are notified that it finished, reply with exactly FINAL-STEP-DONE."}} +{"scenario": "subagent-idle-ctrl-c", "t": 3.054, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PreToolUse", "tool_name": "Agent", "tool_input": {"description": "Sleep 15 seconds", "prompt": "Run the shell command `sleep 15` using the Bash tool. Once it completes, reply with exactly: slept", "subagent_type": "general-purpose"}}} +{"scenario": "subagent-idle-ctrl-c", "t": 3.097, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PostToolUse", "tool_name": "Agent", "tool_input": {"description": "Sleep 15 seconds", "prompt": "Run the shell command `sleep 15` using the Bash tool. Once it completes, reply with exactly: slept", "subagent_type": "general-purpose"}, "tool_response": {"isAsync": true, "status": "async_launched", "agentId": "a1f4f42abf029ee4c", "description": "Sleep 15 seconds", "resolvedModel": "claude-opus-5-5", "prompt": "Run the shell command `sleep 15` using the Bash tool. Once it completes, reply with exactly: slept", "outputFile": "/tmp/scratch", "canReadOutputFile": true}}} +{"scenario": "subagent-idle-ctrl-c", "t": 3.097, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SubagentStart", "agent_id": "a1f4f42abf029ee4c", "agent_type": "general-purpose"}} +{"scenario": "subagent-idle-ctrl-c", "t": 4.364, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "Stop", "background_tasks": [{"id": "a1f4f42abf029ee4c", "type": "subagent", "status": "running", "description": "Sleep 15 seconds", "agent_type": "general-purpose"}], "session_crons": [], "stop_hook_active": false}} +{"scenario": "subagent-idle-ctrl-c", "t": 4.453, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PreToolUse", "agent_id": "a1f4f42abf029ee4c", "agent_type": "general-purpose", "tool_name": "Bash", "tool_input": {"command": "sleep 15", "description": "Pause for 15 seconds"}}} +{"scenario": "subagent-idle-ctrl-c", "t": 5.82, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SubagentStop", "agent_id": "a60ad984e5794b415", "agent_type": "", "background_tasks": [{"id": "a1f4f42abf029ee4c", "type": "subagent", "status": "running", "description": "Sleep 15 seconds", "agent_type": "general-purpose"}], "session_crons": [], "stop_hook_active": false}} +{"scenario": "subagent-idle-ctrl-c", "t": 8.736, "kind": "key", "keys": "\\x03"} +{"scenario": "subagent-idle-ctrl-c", "t": 24.755, "kind": "key", "keys": "reply with exactly OK and use no tools\\r"} +{"scenario": "subagent-idle-ctrl-c", "t": 24.821, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "UserPromptSubmit", "prompt": "reply with exactly OK and use no tools"}} +{"scenario": "subagent-idle-ctrl-c", "t": 27.166, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "Stop", "background_tasks": [], "session_crons": [], "stop_hook_active": false}} +{"scenario": "subagent-idle-ctrl-c", "t": 31.082, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SubagentStop", "agent_id": "a2e39595c6b1cb85c", "agent_type": "", "background_tasks": [], "session_crons": [], "stop_hook_active": false}} +{"scenario": "two-shells-together", "t": 0.0, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SessionStart", "source": "startup"}} +{"scenario": "two-shells-together", "t": 0.175, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "UserPromptSubmit", "prompt": "In ONE message, call the Bash tool twice, each with run_in_background true and the command `sleep 12`. Do not wait for them or check on them with any tool: end your turn right away saying 'waiting'. Each time you are notified that something finished, reply with exactly NOTED and use no tools."}} +{"scenario": "two-shells-together", "t": 3.064, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": {"command": "sleep 12", "description": "Sleep for 12 seconds", "run_in_background": true}}} +{"scenario": "two-shells-together", "t": 3.253, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PostToolUse", "tool_name": "Bash", "tool_input": {"command": "sleep 12", "description": "Sleep for 12 seconds", "run_in_background": true}, "tool_response": {"stdout": "", "stderr": "", "interrupted": false, "isImage": false, "noOutputExpected": false, "backgroundTaskId": "bg22aqcct"}}} +{"scenario": "two-shells-together", "t": 3.309, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": {"command": "sleep 12", "description": "Sleep for 12 seconds", "run_in_background": true}}} +{"scenario": "two-shells-together", "t": 3.375, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PostToolUse", "tool_name": "Bash", "tool_input": {"command": "sleep 12", "description": "Sleep for 12 seconds", "run_in_background": true}, "tool_response": {"stdout": "", "stderr": "", "interrupted": false, "isImage": false, "noOutputExpected": false, "backgroundTaskId": "bnwcbo10t"}}} +{"scenario": "two-shells-together", "t": 4.631, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "Stop", "background_tasks": [{"id": "bg22aqcct", "type": "shell", "status": "running", "description": "Sleep for 12 seconds", "command": "sleep 12"}, {"id": "bnwcbo10t", "type": "shell", "status": "running", "description": "Sleep for 12 seconds", "command": "sleep 12"}], "session_crons": [], "stop_hook_active": false}} +{"scenario": "two-shells-together", "t": 6.377, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SubagentStop", "agent_id": "a40d091d903bdd89e", "agent_type": "", "background_tasks": [{"id": "bg22aqcct", "type": "shell", "status": "running", "description": "Sleep for 12 seconds", "command": "sleep 12"}, {"id": "bnwcbo10t", "type": "shell", "status": "running", "description": "Sleep for 12 seconds", "command": "sleep 12"}], "session_crons": [], "stop_hook_active": false}} +{"scenario": "two-shells-together", "t": 15.312, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "UserPromptSubmit", "prompt": "\nbg22aqcct\ntoolu_01XC5LcULKypVym7jctLRuHU\n/tmp/tasks/output\ncompleted\nBackground command \"Sleep for 12 seconds\" completed (exit code 0)\n"}} +{"scenario": "two-shells-together", "t": 16.704, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "Stop", "background_tasks": [], "session_crons": [], "stop_hook_active": false}} +{"scenario": "two-shells-together", "t": 16.774, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "UserPromptSubmit", "prompt": "\nbnwcbo10t\ntoolu_01EJvZEqapadhQDB1rLJPjdW\n/tmp/tasks/output\ncompleted\nBackground command \"Sleep for 12 seconds\" completed (exit code 0)\n"}} +{"scenario": "two-shells-together", "t": 17.976, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "Stop", "background_tasks": [], "session_crons": [], "stop_hook_active": false}} +{"scenario": "two-shells-together", "t": 19.61, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SubagentStop", "agent_id": "a6bd481f4c8a6e1fe", "agent_type": "", "background_tasks": [], "session_crons": [], "stop_hook_active": false}} +{"scenario": "two-shells-together", "t": 20.085, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SubagentStop", "agent_id": "a3b3fb6d79b479730", "agent_type": "", "background_tasks": [], "session_crons": [], "stop_hook_active": false}} +{"scenario": "shell-ends-mid-turn", "t": 0.0, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SessionStart", "source": "startup"}} +{"scenario": "shell-ends-mid-turn", "t": 0.158, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "UserPromptSubmit", "prompt": "Step 1: call the Bash tool with run_in_background true and the command `sleep 2`. Step 2, in a SEPARATE later message: run the Bash command `sleep 10` in the FOREGROUND. Step 3: when it returns, make NO further tool call and end your turn saying 'waiting'. Whenever you are later notified of anything, reply with exactly NOTED and use no tools."}} +{"scenario": "shell-ends-mid-turn", "t": 2.582, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": {"command": "sleep 2", "description": "Sleep for 2 seconds in background", "run_in_background": true}}} +{"scenario": "shell-ends-mid-turn", "t": 2.822, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PostToolUse", "tool_name": "Bash", "tool_input": {"command": "sleep 2", "description": "Sleep for 2 seconds in background", "run_in_background": true}, "tool_response": {"stdout": "", "stderr": "", "interrupted": false, "isImage": false, "noOutputExpected": false, "backgroundTaskId": "bwaw14qyq"}}} +{"scenario": "shell-ends-mid-turn", "t": 5.268, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": {"command": "sleep 10", "description": "Sleep for 10 seconds"}}} +{"scenario": "shell-ends-mid-turn", "t": 15.36, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PostToolUse", "tool_name": "Bash", "tool_input": {"command": "sleep 10", "description": "Sleep for 10 seconds"}, "tool_response": {"stdout": "", "stderr": "", "interrupted": false, "isImage": false, "noOutputExpected": false}}} +{"scenario": "shell-ends-mid-turn", "t": 15.418, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "UserPromptSubmit", "prompt": "\nbwaw14qyq\ntoolu_018Xetg6XUdPVK3DifKgjsKy\n/tmp/tasks/output\ncompleted\nBackground command \"Sleep for 2 seconds in background\" completed (exit code 0)\n"}} +{"scenario": "shell-ends-mid-turn", "t": 24.609, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "Stop", "background_tasks": [], "session_crons": [], "stop_hook_active": false}} +{"scenario": "shell-ends-mid-turn", "t": 26.699, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SubagentStop", "agent_id": "afb87e3f017b4d2e0", "agent_type": "", "background_tasks": [], "session_crons": [], "stop_hook_active": false}} +{"scenario": "shell-task-stopped", "t": 0.0, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SessionStart", "source": "startup"}} +{"scenario": "shell-task-stopped", "t": 0.279, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "UserPromptSubmit", "prompt": "Step 1: call the Bash tool with run_in_background true and the command `sleep 60`. Step 2: run the Bash command `sleep 4` in the foreground. Step 3: stop that background shell with the TaskStop tool passing the shell's task id (load TaskStop via ToolSearch first if needed; otherwise KillShell). Step 4: end your turn saying 'stopped'. Whenever you are later notified of anything, reply with exactly NOTED and use no tools."}} +{"scenario": "shell-task-stopped", "t": 2.787, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": {"command": "sleep 60", "description": "Sleep for 60 seconds in background", "run_in_background": true}}} +{"scenario": "shell-task-stopped", "t": 2.977, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PostToolUse", "tool_name": "Bash", "tool_input": {"command": "sleep 60", "description": "Sleep for 60 seconds in background", "run_in_background": true}, "tool_response": {"stdout": "", "stderr": "", "interrupted": false, "isImage": false, "noOutputExpected": false, "backgroundTaskId": "bldvwpquq"}}} +{"scenario": "shell-task-stopped", "t": 3.012, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PreToolUse", "tool_name": "ToolSearch", "tool_input": {"query": "select:TaskStop", "max_results": 5}}} +{"scenario": "shell-task-stopped", "t": 3.067, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PostToolUse", "tool_name": "ToolSearch", "tool_input": {"query": "select:TaskStop", "max_results": 5}, "tool_response": {"matches": ["TaskStop"], "query": "select:TaskStop", "total_deferred_tools": 51}}} +{"scenario": "shell-task-stopped", "t": 5.262, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": {"command": "sleep 4", "description": "Sleep for 4 seconds"}}} +{"scenario": "shell-task-stopped", "t": 9.349, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PostToolUse", "tool_name": "Bash", "tool_input": {"command": "sleep 4", "description": "Sleep for 4 seconds"}, "tool_response": {"stdout": "", "stderr": "", "interrupted": false, "isImage": false, "noOutputExpected": false}}} +{"scenario": "shell-task-stopped", "t": 11.296, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PreToolUse", "tool_name": "TaskStop", "tool_input": {"task_id": "bldvwpquq"}}} +{"scenario": "shell-task-stopped", "t": 11.352, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PostToolUse", "tool_name": "TaskStop", "tool_input": {"task_id": "bldvwpquq"}, "tool_response": {"message": "Successfully stopped task: bldvwpquq (sleep 60)", "task_id": "bldvwpquq", "task_type": "local_bash", "command": "sleep 60"}}} +{"scenario": "shell-task-stopped", "t": 12.42, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "Stop", "background_tasks": [], "session_crons": [], "stop_hook_active": false}} +{"scenario": "shell-task-stopped", "t": 16.549, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SubagentStop", "agent_id": "af7453ab372dbce81", "agent_type": "", "background_tasks": [], "session_crons": [], "stop_hook_active": false}} +{"scenario": "shell-task-stopped", "t": 72.463, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "Notification", "notification_type": "idle_prompt"}} +{"scenario": "monitor-events", "t": 0.0, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SessionStart", "source": "startup"}} +{"scenario": "monitor-events", "t": 0.177, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "UserPromptSubmit", "prompt": "Use the Monitor tool (load it via ToolSearch first if needed) to start a background monitor running this command: for i in 1 2 3; do echo tick $i; sleep 4; done Then end your turn right away saying 'waiting'. Whenever you are later notified of anything, reply with exactly NOTED and use no tools."}} +{"scenario": "monitor-events", "t": 2.605, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PreToolUse", "tool_name": "ToolSearch", "tool_input": {"query": "select:Monitor", "max_results": 5}}} +{"scenario": "monitor-events", "t": 2.637, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PostToolUse", "tool_name": "ToolSearch", "tool_input": {"query": "select:Monitor", "max_results": 5}, "tool_response": {"matches": ["Monitor"], "query": "select:Monitor", "total_deferred_tools": 93}}} +{"scenario": "monitor-events", "t": 5.229, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PreToolUse", "tool_name": "Monitor", "tool_input": {"description": "tick counter (3 ticks, 4s apart)", "timeout_ms": 300000, "command": "for i in 1 2 3; do echo tick $i; sleep 4; done"}}} +{"scenario": "monitor-events", "t": 5.358, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "PostToolUse", "tool_name": "Monitor", "tool_input": {"description": "tick counter (3 ticks, 4s apart)", "timeout_ms": 300000, "command": "for i in 1 2 3; do echo tick $i; sleep 4; done"}, "tool_response": {"taskId": "bgpq100i8", "timeoutMs": 300000, "persistent": false}}} +{"scenario": "monitor-events", "t": 6.882, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "Stop", "background_tasks": [{"id": "bgpq100i8", "type": "shell", "status": "running", "description": "tick counter (3 ticks, 4s apart)", "command": "for i in 1 2 3; do echo tick $i; sleep 4; done"}], "session_crons": [], "stop_hook_active": false}} +{"scenario": "monitor-events", "t": 6.925, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "UserPromptSubmit", "prompt": "\nbgpq100i8\nMonitor event: \"tick counter (3 ticks, 4s apart)\"\ntick 1\n"}} +{"scenario": "monitor-events", "t": 8.419, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "Stop", "background_tasks": [{"id": "bgpq100i8", "type": "shell", "status": "running", "description": "tick counter (3 ticks, 4s apart)", "command": "for i in 1 2 3; do echo tick $i; sleep 4; done"}], "session_crons": [], "stop_hook_active": false}} +{"scenario": "monitor-events", "t": 9.585, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "UserPromptSubmit", "prompt": "\nbgpq100i8\nMonitor event: \"tick counter (3 ticks, 4s apart)\"\ntick 2\n"}} +{"scenario": "monitor-events", "t": 11.085, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "Stop", "background_tasks": [{"id": "bgpq100i8", "type": "shell", "status": "running", "description": "tick counter (3 ticks, 4s apart)", "command": "for i in 1 2 3; do echo tick $i; sleep 4; done"}], "session_crons": [], "stop_hook_active": false}} +{"scenario": "monitor-events", "t": 11.514, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SubagentStop", "agent_id": "a67782e85a715898c", "agent_type": "", "background_tasks": [{"id": "bgpq100i8", "type": "shell", "status": "running", "description": "tick counter (3 ticks, 4s apart)", "command": "for i in 1 2 3; do echo tick $i; sleep 4; done"}], "session_crons": [], "stop_hook_active": false}} +{"scenario": "monitor-events", "t": 12.09, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SubagentStop", "agent_id": "ab6abd1c51e1ae44e", "agent_type": "", "background_tasks": [{"id": "bgpq100i8", "type": "shell", "status": "running", "description": "tick counter (3 ticks, 4s apart)", "command": "for i in 1 2 3; do echo tick $i; sleep 4; done"}], "session_crons": [], "stop_hook_active": false}} +{"scenario": "monitor-events", "t": 13.601, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "UserPromptSubmit", "prompt": "\nbgpq100i8\nMonitor event: \"tick counter (3 ticks, 4s apart)\"\ntick 3\n"}} +{"scenario": "monitor-events", "t": 15.093, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "Stop", "background_tasks": [{"id": "bgpq100i8", "type": "shell", "status": "running", "description": "tick counter (3 ticks, 4s apart)", "command": "for i in 1 2 3; do echo tick $i; sleep 4; done"}], "session_crons": [], "stop_hook_active": false}} +{"scenario": "monitor-events", "t": 15.591, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SubagentStop", "agent_id": "aa831d7c74c49bdf6", "agent_type": "", "background_tasks": [{"id": "bgpq100i8", "type": "shell", "status": "running", "description": "tick counter (3 ticks, 4s apart)", "command": "for i in 1 2 3; do echo tick $i; sleep 4; done"}], "session_crons": [], "stop_hook_active": false}} +{"scenario": "monitor-events", "t": 17.404, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "UserPromptSubmit", "prompt": "\nbgpq100i8\ntoolu_01HgGuzk7GejduijUyRrLgbt\n/tmp/tasks/output\ncompleted\nMonitor \"tick counter (3 ticks, 4s apart)\" stream ended\n"}} +{"scenario": "monitor-events", "t": 18.753, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "Stop", "background_tasks": [], "session_crons": [], "stop_hook_active": false}} +{"scenario": "monitor-events", "t": 19.85, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SubagentStop", "agent_id": "ab4cee1a0c4b6501c", "agent_type": "", "background_tasks": [], "session_crons": [], "stop_hook_active": false}} +{"scenario": "monitor-events", "t": 22.291, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "SubagentStop", "agent_id": "a86c8e418e39ccbe7", "agent_type": "", "background_tasks": [], "session_crons": [], "stop_hook_active": false}} +{"scenario": "monitor-events", "t": 78.784, "kind": "hook", "payload": {"session_id": "00000000-0000-4000-8000-000000000000", "hook_event_name": "Notification", "notification_type": "idle_prompt"}} diff --git a/src/shared/__fixtures__/claude-task-notification-hooks.meta.json b/src/shared/__fixtures__/claude-task-notification-hooks.meta.json new file mode 100644 index 00000000000..293de2b1e4a --- /dev/null +++ b/src/shared/__fixtures__/claude-task-notification-hooks.meta.json @@ -0,0 +1,24 @@ +{ + "claude_version": "2.1.287 (Claude Code)", + "argv": ["claude", "--settings", "", "--dangerously-skip-permissions", ""], + "platform": "darwin", + "cols": 120, + "rows": 40, + "recorded_at": "2026-10-02", + "clock": "t is seconds since the scenario's first hook", + "scenarios": { + "one-subagent": "One Agent call; the main agent ends its turn and is woken when the sub-agent ends.", + "two-subagents-together": "Two sub-agents end within 600 ms; a main-agent Stop with an empty inventory precedes the second notification.", + "subagent-ends-mid-turn": "The sub-agent ends while the main agent is still in its launching turn; the notification follows the next tool call.", + "subagent-task-stopped": "The main agent stops its sub-agent with TaskStop: a `killed` notification, no SubagentStop.", + "subagent-resumed": "A finished sub-agent is resumed with SendMessage; the same id stops and notifies a second time.", + "subagent-with-own-shell": "A sub-agent launches a background shell and stops; the shell's end resumes the sub-agent, never the main agent.", + "subagent-then-typed-prompt": "A prompt is typed while a notification is still queued behind a foreground tool.", + "subagent-idle-ctrl-c": "Ctrl+C at the idle prompt kills the background sub-agent with no hook and no notification; a typed prompt follows.", + "two-shells-together": "Two background shells end together; a main-agent Stop with an empty inventory precedes the second notification.", + "shell-ends-mid-turn": "A background shell ends and is notified before the launching turn's Stop.", + "shell-task-stopped": "The main agent stops its shell with TaskStop: no notification at all.", + "monitor-events": "A Monitor task sends `` prompts under its task id while running, then a final one with a ``." + }, + "note": "Claude Code driven over a real PTY with every hook logged. Payloads are reduced to the fields the capture hook kept (event name, agent ids, tool name/input/response, prompt, task and cron inventories, stop_hook_active, source, notification_type); a launch response longer than the capture limit is reduced to isAsync/status/agentId. Scrubbed: session ids zeroed, home and scratch paths replaced." +} diff --git a/src/shared/agent-hook-cancel-verdict-latch.ts b/src/shared/agent-hook-cancel-verdict-latch.ts new file mode 100644 index 00000000000..2fce388da01 --- /dev/null +++ b/src/shared/agent-hook-cancel-verdict-latch.ts @@ -0,0 +1,145 @@ +import type { AgentHookEventPayload } from './agent-hook-listener/listener-event' +import type { AgentMainAgentStatus } from './agent-status-types' +import { foldMainAgentWithRowChildWork } from './agent-hook-row-child-work-fold' + +const INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS = 15_000 +const TOOL_PROGRESS_HOOK_EVENTS = new Set(['PreToolUse', 'PostToolUse', 'PostToolUseFailure']) + +export function isToolProgressWorkingAfterInterrupt(next: AgentHookEventPayload): boolean { + if (next.payload.state !== 'working') { + return false + } + if (next.payload.agentType !== 'claude' && next.payload.agentType !== 'codex') { + return false + } + // Why: a same-prompt retry is another UserPromptSubmit, while late post-Ctrl+C progress arrives as tool lifecycle work. + return next.hookEventName !== undefined && TOOL_PROGRESS_HOOK_EVENTS.has(next.hookEventName) +} + +type CancelledRow = AgentHookEventPayload & { receivedAt: number } + +export type CancelVerdictLatchDecision = + | { hold: true } + | { hold: false; event: AgentHookEventPayload } + +const HOLD: CancelVerdictLatchDecision = { hold: true } + +/** Derived from the row, never stored: a row whose main agent reads cancelled (or, from a host too + * old to publish `mainAgent`, a done row flagged interrupted). */ +function isCancelVerdictLatched(previous: CancelledRow): boolean { + const mainAgent = previous.payload.mainAgent + return mainAgent + ? mainAgent.outcome === 'cancellation' + : previous.payload.state === 'done' && previous.payload.interrupted === true +} + +/** An event that restates child work can be re-folded with it; one that carries none has nothing to add. */ +function carriesChildWork(event: AgentHookEventPayload): boolean { + return event.payload.subagents !== undefined || event.claudeRunningNonAgentTask !== undefined +} + +function refoldUnderLatchedMainAgent( + previous: CancelledRow, + latched: AgentMainAgentStatus, + incoming: AgentHookEventPayload +): AgentHookEventPayload { + // Why: a child's own attention state is child work, not the main agent's; only `working` is the stale restatement. + const resolved = foldMainAgentWithRowChildWork( + incoming.payload.state === 'working' ? latched.state : incoming.payload.state, + { + claudeRunningNonAgentTask: + incoming.claudeRunningNonAgentTask ?? previous.claudeRunningNonAgentTask, + payload: incoming.payload + } + ) + const { + workingMode: _workingMode, + interrupted: _interrupted, + turnCompletedAt: _turnCompletedAt, + ...rest + } = incoming.payload + return { + ...incoming, + payload: { + ...rest, + state: resolved.stateName, + ...(resolved.workingMode ? { workingMode: resolved.workingMode } : {}), + ...(resolved.stateName === 'done' ? { interrupted: true } : {}), + mainAgent: latched + } + } +} + +/** A main agent's own prompt submission always opens a turn, including a harness-injected one that + * keeps the cached prompt (the task notification Claude starts when background work ends). */ +export function opensNewTurn(event: AgentHookEventPayload): boolean { + return ( + event.hookEventName === 'SessionStart' || + (event.hookEventName === 'UserPromptSubmit' && + event.toolAgentId === undefined && + event.isReplay !== true) + ) +} + +/** A child's own event: one naming its agent id, or a teammate's idle, which names it by `teammate_name` only. */ +function isChildAttributed(event: AgentHookEventPayload): boolean { + return event.toolAgentId !== undefined || event.hookEventName === 'TeammateIdle' +} + +/** A child restates its listener's cached prompt, which a restarted relay has lost; empty there is unknown, not another turn. */ +export function restatesAnotherPrompt( + previous: Pick, + incoming: AgentHookEventPayload +): boolean { + const prompt = incoming.payload.prompt + return prompt !== previous.payload.prompt && (prompt !== '' || !isChildAttributed(incoming)) +} + +/** + * The store's hold on a cancel verdict against restatements that predate it: a relay never learns + * of the cancel the desktop infers, and TUIs emit late same-turn hooks after Ctrl+C. The latch dies + * 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. + */ +export function resolveCancelVerdictLatch( + previous: CancelledRow | undefined, + incoming: AgentHookEventPayload, + now: number +): CancelVerdictLatchDecision { + const apply: CancelVerdictLatchDecision = { hold: false, event: incoming } + if ( + !previous || + !isCancelVerdictLatched(previous) || + previous.payload.agentType !== incoming.payload.agentType || + restatesAnotherPrompt(previous, incoming) || + incoming.payload.mainAgent?.state === 'done' || + opensNewTurn(incoming) + ) { + return apply + } + 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 ( + latched && + incoming.payload.agentType !== 'codex' && + incoming.payload.state !== 'done' && + (isChildAttributed(incoming) || incoming.isReplay === true) && + carriesChildWork(incoming) + ) { + return { hold: false, event: refoldUnderLatchedMainAgent(previous, latched, incoming) } + } + const withinWindow = now - previous.receivedAt <= INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS + if (incoming.payload.state === 'done') { + return previous.payload.state === 'done' && withinWindow ? HOLD : apply + } + if ( + incoming.payload.state === 'working' && + (incoming.isReplay === true || + isToolProgressWorkingAfterInterrupt(incoming) || + (incoming.hasExplicitPrompt !== true && withinWindow)) + ) { + return HOLD + } + return apply +} diff --git a/src/shared/agent-hook-interrupt-reconciliation.ts b/src/shared/agent-hook-interrupt-reconciliation.ts new file mode 100644 index 00000000000..713a0d49d1c --- /dev/null +++ b/src/shared/agent-hook-interrupt-reconciliation.ts @@ -0,0 +1,20 @@ +import type { AgentProviderSessionMetadata } from './agent-session-resume' + +export const AGENT_HOOK_INFER_INTERRUPT_METHOD = 'agent_hook.inferInterrupt' as const + +export type RemoteAgentInterruptRequest = { + paneKey: string + hostTurnRevision: string + launchToken?: string + providerSession: AgentProviderSessionMetadata + intent: 'ctrl-c' +} + +export type RemoteAgentInterruptDispatch = { + connectionId: string + request: RemoteAgentInterruptRequest +} + +export function normalizeHostTurnRevision(value: unknown): string | undefined { + return typeof value === 'string' && /^[0-9a-f-]{36}$/i.test(value) ? value : undefined +} diff --git a/src/shared/agent-hook-listener-claude-reordered-task-notification.test.ts b/src/shared/agent-hook-listener-claude-reordered-task-notification.test.ts new file mode 100644 index 00000000000..cb58126ab2b --- /dev/null +++ b/src/shared/agent-hook-listener-claude-reordered-task-notification.test.ts @@ -0,0 +1,134 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { normalizeHookPayload } from './agent-hook-listener' +import { createHookListenerState } from './agent-hook-listener/listener-state' +import { makePaneKey } from './stable-pane-id' + +const PANE_KEY = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') +const SESSION = '00000000-0000-4000-8000-000000000000' +const notification = { + hook_event_name: 'UserPromptSubmit', + prompt: 'a1completed' +} +const launch = { + hook_event_name: 'PostToolUse', + tool_name: 'Agent', + tool_response: { isAsync: true, agentId: 'a1' } +} + +function listener() { + const state = createHookListenerState() + const post = (payload: Record) => + normalizeHookPayload( + state, + 'claude', + { paneKey: PANE_KEY, payload: { session_id: SESSION, ...payload } }, + 'production' + ) + post({ hook_event_name: 'UserPromptSubmit', prompt: 'delegate' }) + return { state, post } +} + +describe('out-of-order Claude child notifications', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + it.each(['unknown', 'restored', 'wrong-session', 'unanchored'])( + 'does not turn %s child-end evidence into a main notification obligation', + (evidence) => { + const { state, post } = listener() + if (evidence !== 'unknown') { + post({ hook_event_name: 'SubagentStart', agent_id: 'a1' }) + } + if (evidence === 'restored') { + const child = state.claudeSubagentRosterByPaneKey.get(PANE_KEY)?.get('a1') + if (child) { + child.restoredFromSnapshot = true + } + } else if (evidence === 'unanchored') { + state.claudeSessionOwnerByPaneKey.delete(PANE_KEY) + } + post({ + hook_event_name: 'SubagentStop', + agent_id: 'a1', + ...(evidence === 'wrong-session' ? { session_id: 'old-session' } : {}) + }) + expect(state.claudeLaunchedBackgroundTasksByPaneKey.get(PANE_KEY)?.has('a1')).not.toBe(true) + post(launch) + expect( + post({ hook_event_name: 'Stop', background_tasks: [] })?.payload.claudeTaskWakeupPending + ).toBeUndefined() + } + ) + + it.each(['unknown', 'restored'])( + 'does not use an %s child as an early notification receipt', + (evidence) => { + const { state, post } = listener() + if (evidence === 'restored') { + state.claudeSubagentRosterByPaneKey.set( + PANE_KEY, + new Map([['a1', { state: 'working', startedAt: Date.now(), restoredFromSnapshot: true }]]) + ) + } + post(notification) + expect(state.claudeLaunchedBackgroundTasksByPaneKey.get(PANE_KEY)?.has('a1')).not.toBe(true) + } + ) + + it('keeps an unmatched nested child end provisional until its own main launch confirms it', () => { + const { state, post } = listener() + post({ hook_event_name: 'SubagentStart', agent_id: 'a1' }) + post({ hook_event_name: 'SubagentStop', agent_id: 'a1' }) + expect( + state.claudeLaunchedBackgroundTasksByPaneKey.get(PANE_KEY)?.get('a1')?.launchUnconfirmed + ).toBe(true) + expect(post({ hook_event_name: 'Stop', background_tasks: [] })?.payload.state).toBe('done') + post(launch) + expect( + post({ hook_event_name: 'Stop', background_tasks: [] })?.payload.claudeTaskWakeupPending + ).toBe('notification') + }) + + it('does not refresh the observed end on duplicate end or parent launch posts', () => { + const { state, post } = listener() + post({ hook_event_name: 'SubagentStart', agent_id: 'a1' }) + post({ hook_event_name: 'SubagentStop', agent_id: 'a1' }) + const firstEnd = state.claudeLaunchedBackgroundTasksByPaneKey + .get(PANE_KEY) + ?.get('a1')?.notificationOwedAt + vi.advanceTimersByTime(5_000) + post({ hook_event_name: 'SubagentStop', agent_id: 'a1' }) + post(launch) + post(launch) + expect( + state.claudeLaunchedBackgroundTasksByPaneKey.get(PANE_KEY)?.get('a1')?.notificationOwedAt + ).toBe(firstEnd) + }) + + it.each(['before-end', 'after-end', 'after-launch'])( + 'preserves a delivered notification %s through late end/launch, and rearms a real same-id start', + (order) => { + const { post } = listener() + post({ hook_event_name: 'SubagentStart', agent_id: 'a1' }) + if (order === 'after-launch') { + post(launch) + } + if (order === 'before-end') { + post(notification) + } + post({ hook_event_name: 'SubagentStop', agent_id: 'a1' }) + if (order !== 'before-end') { + post(notification) + } + post(launch) + post({ hook_event_name: 'SubagentStop', agent_id: 'a1' }) + expect(post({ hook_event_name: 'Stop', background_tasks: [] })?.payload.state).toBe('done') + post({ hook_event_name: 'SubagentStart', agent_id: 'a1' }) + post({ hook_event_name: 'SubagentStop', agent_id: 'a1' }) + post(launch) + expect( + post({ hook_event_name: 'Stop', background_tasks: [] })?.payload.claudeTaskWakeupPending + ).toBe('notification') + } + ) +}) diff --git a/src/shared/agent-hook-listener-claude-task-notification.test.ts b/src/shared/agent-hook-listener-claude-task-notification.test.ts new file mode 100644 index 00000000000..f02ef4eca00 --- /dev/null +++ b/src/shared/agent-hook-listener-claude-task-notification.test.ts @@ -0,0 +1,553 @@ +// Replays hook payloads recorded from Claude Code 2.1.287 over a real PTY +// (__fixtures__/claude-task-notification-hooks.jsonl, sidecar beside it). Claude owes the main +// agent one for every background task that ends, and delivers it after the +// task stops running — so "nothing running" is not "finished" (#23942). +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { normalizeHookPayload } from './agent-hook-listener' +import type { AgentHookEventPayload } from './agent-hook-listener/listener-event' +import { + clearPaneCacheState, + createHookListenerState, + movePaneCacheState +} from './agent-hook-listener/listener-state' +import { cacheRelayLegacyAgentStatus } from './agent-status-legacy-relay-cache' +import { markClaudeLeadTurnInterrupted } from './agent-hook-listener/providers/claude-roster-state' +import { ClaudeOwedNotificationExpiryTimers } from './claude-owed-notification-expiry-timers' +import { CLAUDE_OWED_TASK_NOTIFICATION_LEASE_MS } from './claude-owed-task-notifications' +import { makePaneKey } from './stable-pane-id' + +type CapturedRecord = { + scenario: string + t: number + kind: 'hook' | 'key' + payload?: Record +} + +const PANE_KEY = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') +const records: CapturedRecord[] = readFileSync( + join(__dirname, '__fixtures__', 'claude-task-notification-hooks.jsonl'), + 'utf8' +) + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)) +const scenarios = [...new Set(records.map((record) => record.scenario))] + +function isTaskNotification(payload: Record): boolean { + return ( + payload.hook_event_name === 'UserPromptSubmit' && + typeof payload.prompt === 'string' && + payload.prompt.startsWith('') + ) +} + +/** Pane state after each hook that published one, in capture order. */ +function replay(scenario: string): { t: number; index: number; state: string }[] { + const state = createHookListenerState() + const published: { t: number; index: number; state: string }[] = [] + records.forEach((record, index) => { + if (record.scenario !== scenario || !record.payload) { + return + } + const event = normalizeHookPayload( + state, + 'claude', + { paneKey: PANE_KEY, payload: record.payload }, + 'production' + ) + if (event && event.payload.sessionBoundary !== true) { + published.push({ t: record.t, index, state: event.payload.state }) + } + }) + return published +} + +describe('Claude background task notifications', () => { + const lastNotificationIndexOf = (scenario: string): number => + records.findLastIndex( + (record) => + record.scenario === scenario && record.payload && isTaskNotification(record.payload) + ) + // Why filtered: a scenario with no notification would pass this for any implementation. + const notifyingScenarios = scenarios.filter((scenario) => lastNotificationIndexOf(scenario) >= 0) + + it.each(notifyingScenarios)( + '%s: never reads done while a notification is still to come', + (scenario) => { + const lastNotificationIndex = lastNotificationIndexOf(scenario) + const early = replay(scenario).filter( + (row) => row.state === 'done' && row.index < lastNotificationIndex + ) + expect(early.map((row) => row.t)).toEqual([]) + } + ) + + it.each(scenarios)('%s: settles to done once everything is delivered', (scenario) => { + expect(replay(scenario).at(-1)?.state).toBe('done') + }) +}) + +describe('Claude owed task notifications outside the captures', () => { + const SESSION = '00000000-0000-4000-8000-000000000000' + const shell = (id: string) => ({ id, type: 'shell', status: 'running' }) + + function listener() { + const state = createHookListenerState() + const post = (payload: Record) => + normalizeHookPayload( + state, + 'claude', + { paneKey: PANE_KEY, payload: { session_id: SESSION, ...payload } }, + 'production' + ) + post({ hook_event_name: 'UserPromptSubmit', prompt: 'run both' }) + return post + } + + const launchAgent = (post: (payload: Record) => unknown, agentId: string) => { + post({ hook_event_name: 'SubagentStart', agent_id: agentId, agent_type: 'general-purpose' }) + post({ + hook_event_name: 'PostToolUse', + tool_name: 'Agent', + tool_response: { isAsync: true, status: 'async_launched', agentId } + }) + } + + it('publishes an owed notification as live work the child list does not show', () => { + const post = listener() + launchAgent(post, 'a1') + post({ hook_event_name: 'SubagentStop', agent_id: 'a1' }) + + const stopped = post({ hook_event_name: 'Stop', background_tasks: [] }) + + // A restart seeds a settled main agent only from a row that says no such work exists. + expect(stopped).toMatchObject({ + claudeRunningNonAgentTask: true, + payload: { state: 'working' } + }) + expect(stopped?.payload.subagents).toBeUndefined() + }) + + it('does not announce a turn end the main agent is about to resume from', () => { + const post = listener() + launchAgent(post, 'a1') + post({ hook_event_name: 'SubagentStop', agent_id: 'a1' }) + + // A stamped working row is announced at once; this Stop is ~100 ms from the wake-up turn. + const owedOnly = post({ hook_event_name: 'Stop', background_tasks: [] }) + expect(owedOnly?.payload.turnCompletedAt).toBeUndefined() + + launchAgent(post, 'a2') + const childRunning = post({ + hook_event_name: 'Stop', + background_tasks: [{ id: 'a2', type: 'subagent', status: 'running' }] + }) + expect(childRunning?.payload.turnCompletedAt).toEqual(expect.any(Number)) + }) + + it('drops what a replaced conversation was owed', () => { + const post = listener() + launchAgent(post, 'a1') + post({ hook_event_name: 'SubagentStop', agent_id: 'a1' }) + + post({ hook_event_name: 'UserPromptSubmit', prompt: 'hello', session_id: 'next-session' }) + const stopped = post({ + hook_event_name: 'Stop', + background_tasks: [], + session_id: 'next-session' + }) + + expect(stopped?.payload.state).toBe('done') + }) + + it('owes again when a notified sub-agent resumes by itself and ends a second time', () => { + const post = listener() + launchAgent(post, 'a1') + post({ hook_event_name: 'SubagentStop', agent_id: 'a1' }) + post({ + hook_event_name: 'UserPromptSubmit', + prompt: '\na1\ncompleted' + }) + post({ hook_event_name: 'Stop', background_tasks: [] }) + + // Its own background work ending resumes it: a SubagentStart with no launching tool call. + post({ hook_event_name: 'SubagentStart', agent_id: 'a1', agent_type: 'general-purpose' }) + + expect(post({ hook_event_name: 'SubagentStop', agent_id: 'a1' })?.payload.state).toBe('working') + }) + + it('keeps waiting on a Monitor whose event turns carry no status', () => { + const post = listener() + post({ hook_event_name: 'PostToolUse', tool_name: 'Monitor', tool_response: { taskId: 'b1' } }) + post({ hook_event_name: 'Stop', background_tasks: [shell('b1')] }) + post({ + hook_event_name: 'UserPromptSubmit', + prompt: '\nb1\ntick 1' + }) + + expect(post({ hook_event_name: 'Stop', background_tasks: [] })?.payload.state).toBe('working') + post({ + hook_event_name: 'UserPromptSubmit', + prompt: '\nb1\ncompleted' + }) + expect(post({ hook_event_name: 'Stop', background_tasks: [] })?.payload.state).toBe('done') + }) + + it('does not read a Stop that carries no inventory as a shell having ended', () => { + const post = listener() + post({ + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_response: { backgroundTaskId: 'b1' } + }) + + // A Claude build without `background_tasks` says nothing about the shell either way. + expect(post({ hook_event_name: 'Stop' })?.payload.state).toBe('done') + }) + + it('past its cap forgets a launched task nobody is waiting on, never an owed one', () => { + const state = createHookListenerState() + const post = (payload: Record) => + normalizeHookPayload(state, 'claude', { paneKey: PANE_KEY, payload }, 'production') + post({ hook_event_name: 'UserPromptSubmit', prompt: 'fan out' }) + launchAgent(post, 'a1') + post({ hook_event_name: 'SubagentStop', agent_id: 'a1' }) + for (let index = 0; index < 300; index += 1) { + post({ + hook_event_name: 'PostToolUse', + tool_name: 'Agent', + tool_response: { isAsync: true, status: 'async_launched', agentId: `a${index + 2}` } + }) + } + + expect(state.claudeLaunchedBackgroundTasksByPaneKey.get(PANE_KEY)?.size).toBe(256) + expect(post({ hook_event_name: 'Stop', background_tasks: [] })?.payload.state).toBe('working') + }) + + it('at its cap makes room by forgetting a sub-agent already announced', () => { + const state = createHookListenerState() + const post = (payload: Record) => + normalizeHookPayload(state, 'claude', { paneKey: PANE_KEY, payload }, 'production') + post({ hook_event_name: 'UserPromptSubmit', prompt: 'fan out' }) + launchAgent(post, 'a1') + post({ hook_event_name: 'SubagentStop', agent_id: 'a1' }) + post({ + hook_event_name: 'UserPromptSubmit', + prompt: '\na1\ncompleted' + }) + for (let index = 2; index <= 257; index += 1) { + post({ + hook_event_name: 'PostToolUse', + tool_name: 'Agent', + tool_response: { isAsync: true, status: 'async_launched', agentId: `a${index}` } + }) + } + post({ + hook_event_name: 'Stop', + background_tasks: [{ id: 'a257', type: 'subagent', status: 'running' }] + }) + + expect(post({ hook_event_name: 'SubagentStop', agent_id: 'a257' })?.payload.state).toBe( + 'working' + ) + }) + + it('owes a vanished shell on a failed turn end too', () => { + const post = listener() + post({ + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_response: { backgroundTaskId: 'b1' } + }) + post({ hook_event_name: 'Stop', background_tasks: [shell('b1')] }) + post({ hook_event_name: 'UserPromptSubmit', prompt: 'and now?' }) + + expect(post({ hook_event_name: 'StopFailure', background_tasks: [] })?.payload.state).toBe( + 'working' + ) + }) + + it('past its cap never forgets a task that is still running', () => { + const state = createHookListenerState() + const post = (payload: Record) => + normalizeHookPayload(state, 'claude', { paneKey: PANE_KEY, payload }, 'production') + post({ hook_event_name: 'UserPromptSubmit', prompt: 'fan out' }) + launchAgent(post, 'a1') + post({ + hook_event_name: 'Stop', + background_tasks: [{ id: 'a1', type: 'subagent', status: 'running' }] + }) + for (let index = 0; index < 300; index += 1) { + post({ + hook_event_name: 'PostToolUse', + tool_name: 'Agent', + tool_response: { isAsync: true, status: 'async_launched', agentId: `a${index + 2}` } + }) + } + post({ + hook_event_name: 'Stop', + background_tasks: [{ id: 'a1', type: 'subagent', status: 'running' }] + }) + + expect(post({ hook_event_name: 'SubagentStop', agent_id: 'a1' })?.payload.state).toBe('working') + }) + + it('does not take a status tag printed by a Monitor for the task having ended', () => { + const post = listener() + post({ hook_event_name: 'PostToolUse', tool_name: 'Monitor', tool_response: { taskId: 'b1' } }) + post({ hook_event_name: 'Stop', background_tasks: [shell('b1')] }) + post({ + hook_event_name: 'UserPromptSubmit', + prompt: + '\nb1\nMonitor event: "health"\nup' + }) + + expect(post({ hook_event_name: 'Stop', background_tasks: [] })?.payload.state).toBe('working') + }) + + it('does not wait on a teammate the roster parks idle, whose turn end is not a finish', () => { + const post = listener() + launchAgent(post, 'areviewer-6d3cb5b52120b7bf') + post({ hook_event_name: 'TeammateIdle', teammate_name: 'reviewer' }) + post({ hook_event_name: 'SubagentStop', agent_id: 'areviewer-6d3cb5b52120b7bf' }) + + expect(post({ hook_event_name: 'Stop', background_tasks: [] })?.payload.state).toBe('done') + }) +}) + +describe('Claude owed task notifications that never arrive', () => { + const SESSION = '00000000-0000-4000-8000-000000000000' + const MOVED_PANE_KEY = makePaneKey('tab-2', '22222222-2222-4222-8222-222222222222') + const shell = (id: string) => ({ id, type: 'shell', status: 'running' }) + const LEASE = CLAUDE_OWED_TASK_NOTIFICATION_LEASE_MS + + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-10-02T12:00:00Z')) + }) + afterEach(() => { + vi.useRealTimers() + }) + + /** A listener whose host stores each row and restates it at expiry, as main and the relay do. */ + function host() { + const state = createHookListenerState() + const restated: AgentHookEventPayload[] = [] + const store = (row: AgentHookEventPayload) => + cacheRelayLegacyAgentStatus(state, row, 16, (paneKey) => clearPaneCacheState(state, paneKey)) + const timers = new ClaudeOwedNotificationExpiryTimers(state) + const publish = (row: AgentHookEventPayload) => { + restated.push(row) + store(row) + } + const post = ( + payload: Record, + paneKey = PANE_KEY, + body: Record = {} + ) => { + const event = normalizeHookPayload( + state, + 'claude', + { paneKey, ...body, payload: { session_id: SESSION, ...payload } }, + 'production' + ) + if (event) { + store(event) + } + timers.arm(paneKey, publish) + return event + } + post({ hook_event_name: 'UserPromptSubmit', prompt: 'start the dev server' }) + return { state, post, restated, timers, store, arm: () => timers.arm(PANE_KEY, publish) } + } + + /** The main agent launched shell b1, ended its turn, and b1 then vanished unannounced. */ + function shellGoneUnannounced() { + const h = host() + h.post({ + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_response: { backgroundTaskId: 'b1' } + }) + h.post({ hook_event_name: 'Stop', background_tasks: [shell('b1')] }) + h.post({ hook_event_name: 'UserPromptSubmit', prompt: 'thanks' }) + return { ...h, stopped: h.post({ hook_event_name: 'Stop', background_tasks: [] }) } + } + + it('restates the pane done once an idle main agent was owed for the whole lease', () => { + const { stopped, restated } = shellGoneUnannounced() + expect(stopped?.payload.state).toBe('working') + + vi.advanceTimersByTime(LEASE - 1) + expect(restated).toEqual([]) + vi.advanceTimersByTime(1) + + expect(restated).toHaveLength(1) + expect(restated[0]).toMatchObject({ + paneKey: PANE_KEY, + claudeRunningNonAgentTask: false, + payload: { state: 'done', agentType: 'claude' } + }) + expect(restated[0].payload.workingMode).toBeUndefined() + }) + + it('waits out the remainder when its timer fires before the wall clock reaches the deadline', () => { + const { restated } = shellGoneUnannounced() + const deadline = Date.now() + LEASE + // Node can run a timer a millisecond early by the wall clock. + const wallClock = vi.spyOn(Date, 'now').mockReturnValue(deadline - 1) + vi.advanceTimersByTime(LEASE) + expect(restated).toEqual([]) + + wallClock.mockReturnValue(deadline) + vi.advanceTimersByTime(1) + + expect(restated.map((row) => row.payload.state)).toEqual(['done']) + wallClock.mockRestore() + }) + + it("restates under the stored row's launch token and owner, as a row with no hook event", () => { + const { post, restated } = host() + const tokened = (payload: Record) => + post(payload, PANE_KEY, { launchToken: 'launch-1' }) + tokened({ + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_response: { backgroundTaskId: 'b1' } + }) + tokened({ hook_event_name: 'Stop', background_tasks: [] }) + + vi.advanceTimersByTime(LEASE) + + expect(restated[0]).toMatchObject({ launchToken: 'launch-1', payload: { state: 'done' } }) + expect(restated[0].hookEventName).toBeUndefined() + }) + + it('keeps the cancel verdict readable by older clients when a cancelled turn settles', () => { + const { state, post, restated, store, arm } = host() + post({ + hook_event_name: 'PostToolUse', + tool_name: 'Agent', + tool_response: { isAsync: true, status: 'async_launched', agentId: 'a1' } + }) + post({ hook_event_name: 'SubagentStop', agent_id: 'a1' }) + // The server's Ctrl+C inference: the turn is cancelled, the owed notification holds the row. + const cancelled = markClaudeLeadTurnInterrupted(state, PANE_KEY) + const row = state.lastStatusByPaneKey.get(PANE_KEY)! + store({ ...row, payload: { ...row.payload, ...cancelled } }) + arm() + expect(cancelled.state).toBe('working') + + vi.advanceTimersByTime(LEASE) + + expect(restated[0].payload).toMatchObject({ state: 'done', interrupted: true }) + }) + + it('does not run the lease while the main agent is in a turn', () => { + const { post, restated } = host() + post({ hook_event_name: 'SubagentStart', agent_id: 'a1', agent_type: 'general-purpose' }) + post({ + hook_event_name: 'PostToolUse', + tool_name: 'Agent', + tool_response: { isAsync: true, status: 'async_launched', agentId: 'a1' } + }) + post({ hook_event_name: 'SubagentStop', agent_id: 'a1' }) + + // The notification stays queued behind a long foreground tool. + vi.advanceTimersByTime(LEASE * 5) + const stopped = post({ hook_event_name: 'Stop', background_tasks: [] }) + + expect(restated).toEqual([]) + expect(stopped?.payload.state).toBe('working') + vi.advanceTimersByTime(LEASE) + expect(restated.map((row) => row.payload.state)).toEqual(['done']) + }) + + it('gives a task that ends long after the main agent went idle its own full lease', () => { + const { post, restated } = host() + post({ hook_event_name: 'SubagentStart', agent_id: 'a1', agent_type: 'general-purpose' }) + post({ + hook_event_name: 'PostToolUse', + tool_name: 'Agent', + tool_response: { isAsync: true, status: 'async_launched', agentId: 'a1' } + }) + post({ + hook_event_name: 'Stop', + background_tasks: [{ id: 'a1', type: 'subagent', status: 'running' }] + }) + + // The reported shape: the sub-agent outlives the main agent's turn by minutes. + vi.advanceTimersByTime(LEASE * 5) + const ended = post({ hook_event_name: 'SubagentStop', agent_id: 'a1' }) + + expect(ended?.payload.state).toBe('working') + vi.advanceTimersByTime(LEASE - 1) + expect(restated).toEqual([]) + vi.advanceTimersByTime(1) + expect(restated.map((row) => row.payload.state)).toEqual(['done']) + }) + + it('stops waiting on the next hook too, when no timer restated the pane', () => { + const { post, timers } = shellGoneUnannounced() + timers.clearAll() + vi.advanceTimersByTime(LEASE) + + // Claude's prompt-suggestion helper: an unannounced SubagentStop after every main-agent Stop. + expect(post({ hook_event_name: 'SubagentStop', agent_id: 'ahelper' })?.payload.state).toBe( + 'done' + ) + }) + + it('does not wait again for a notification it already gave up on', () => { + const { post } = shellGoneUnannounced() + vi.advanceTimersByTime(LEASE) + + post({ hook_event_name: 'UserPromptSubmit', prompt: 'one more thing' }) + + expect(post({ hook_event_name: 'Stop', background_tasks: [] })?.payload.state).toBe('done') + }) + + it('says nothing when the notification arrived in time', () => { + const { post, restated } = shellGoneUnannounced() + post({ + hook_event_name: 'UserPromptSubmit', + prompt: '\nb1\ncompleted' + }) + post({ hook_event_name: 'Stop', background_tasks: [] }) + + vi.advanceTimersByTime(LEASE * 2) + + expect(restated).toEqual([]) + }) + + it.each([ + [ + 'the pane closes', + (state: ReturnType) => clearPaneCacheState(state, PANE_KEY) + ], + ['Claude starts a new session', undefined] + ] as const)('drops what was owed when %s', (_label, close) => { + const { state, post, restated } = shellGoneUnannounced() + if (close) { + close(state) + } else { + post({ hook_event_name: 'SessionStart', source: 'clear' }) + } + post({ hook_event_name: 'UserPromptSubmit', prompt: 'fresh start' }) + + expect(post({ hook_event_name: 'Stop', background_tasks: [] })?.payload.state).toBe('done') + vi.advanceTimersByTime(LEASE) + expect(restated).toEqual([]) + }) + + it('keeps what is owed when the pane moves to another key', () => { + const { state, post } = shellGoneUnannounced() + movePaneCacheState(state, PANE_KEY, MOVED_PANE_KEY) + + expect( + post({ hook_event_name: 'SubagentStop', agent_id: 'ahelper' }, MOVED_PANE_KEY)?.payload.state + ).toBe('working') + }) +}) diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index b885bb6959e..bd10cf8ab61 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -25,6 +25,7 @@ import { suppressOpenCodeSharedServerPost, trackOpenCodePaneLaunchToken } from './agent-hook-listener/opencode-session-registry' +import { claudeRowHasUnlistedLiveWork } from './agent-hook-listener/providers/claude-pane-hold-evidence' import { readString } from './agent-hook-listener/tool-input-preview' /** Canonical transport-agnostic normalization entry shared by main and relay listeners. */ const CLAUDE_EXIT_SESSION_END_REASONS = new Set([ @@ -291,9 +292,7 @@ export function normalizeHookPayload( toolAgentType: readString(hookPayloadRecord, 'agent_type'), ...(source === 'claude' ? { - claudeRunningNonAgentTask: - state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) || - state.claudeActiveSessionCronPaneKeys.has(paneKey) + claudeRunningNonAgentTask: claudeRowHasUnlistedLiveWork(state, paneKey) } : {}), ...(providerSession ? { providerSession } : {}), diff --git a/src/shared/agent-hook-listener/listener-event.ts b/src/shared/agent-hook-listener/listener-event.ts index 8157b47c798..e2e2303e3c2 100644 --- a/src/shared/agent-hook-listener/listener-event.ts +++ b/src/shared/agent-hook-listener/listener-event.ts @@ -10,6 +10,8 @@ export type AgentHookEventPayload = { source?: AgentHookSource /** Ephemeral Orca launch identity stamped into the PTY env for this process. */ launchToken?: string + /** Host-minted live turn revision for guarded interrupt commands; never persisted. */ + hostTurnRevision?: string tabId?: string worktreeId?: string /** SSH connection the event arrived on, or null for local. Only `ingestRemote` can stamp it — the loopback HTTP path has no mux identity — and receivers key off it to drop @@ -43,7 +45,7 @@ export type AgentHookEventPayload = { providerSessionOnly?: boolean /** True when this event is a relay cache replay rather than a live hook. */ isReplay?: boolean - /** Transport-only Claude background-work evidence used to reject false input-based interrupts. */ + /** Claude live work the child list does not show (a shell, a cron, an owed task notification); rejects false input-based interrupts. */ claudeRunningNonAgentTask?: boolean /** Row projected from a structured session the host holds: `owned` while its provider child * runs here, `held` once the child is gone but the session is still open. Never persisted. */ diff --git a/src/shared/agent-hook-listener/listener-state.ts b/src/shared/agent-hook-listener/listener-state.ts index 1b851a67e51..842499ba6c3 100644 --- a/src/shared/agent-hook-listener/listener-state.ts +++ b/src/shared/agent-hook-listener/listener-state.ts @@ -10,11 +10,18 @@ import { type AgentStatusLegacyAdmissionMode } from '../agent-status-legacy-adapter' import type { AgentStatusLegacyIngressCaller } from '../agent-status-legacy-ingress-manifest' +import type { ClaudeLaunchedBackgroundTasks } from '../claude-owed-task-notifications' import type { ClaudeSubagentRoster } from '../claude-subagent-roster' import type { CodexSubagentRoster } from '../codex-subagent-roster' import type { CodexSubagentTranscriptState } from '../codex-subagent-transcript' import type { MuseSessionLogState } from '../muse-session-log' import type { AgentHookEventPayload, ToolSnapshot } from './listener-event' +import { + deletePaneScopedCacheEntry, + deletePaneScopedSetEntry, + movePaneScopedMapEntries, + movePaneScopedSetEntries +} from './pane-scoped-cache-entries' import type { JcodeUserPromptEvidence } from '../jcode-session-files' import { moveOpenCodeSessionBindings, @@ -44,6 +51,8 @@ export type HookListenerState = { claudeRunningNonAgentTaskPaneKeys: Set /** Panes whose latest authoritative Claude cron inventory still has a scheduled job. */ claudeActiveSessionCronPaneKeys: Set + /** Background tasks each pane's main agent launched, and which of them still owe it a notification. */ + claudeLaunchedBackgroundTasksByPaneKey: Map /** Compact whose completion each pane already applied, so relay duplicates can't refresh the row. */ claudeConsumedCompactPromptIdByPaneKey: Map /** Claude `session_id` that last reported on the pane from a LEAD event. A different id means the @@ -114,6 +123,7 @@ export function createHookListenerState( claudeUnconfirmedRestoredStatusPaneKeys: new Set(), claudeRunningNonAgentTaskPaneKeys: new Set(), claudeActiveSessionCronPaneKeys: new Set(), + claudeLaunchedBackgroundTasksByPaneKey: new Map(), claudeConsumedCompactPromptIdByPaneKey: new Map(), claudeSessionOwnerByPaneKey: new Map(), codexSubagentRosterByPaneKey: new Map(), @@ -206,6 +216,7 @@ export function clearPaneCacheState(state: HookListenerState, paneKey: string): state.claudeUnconfirmedRestoredStatusPaneKeys.delete(paneKey) state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) state.claudeActiveSessionCronPaneKeys.delete(paneKey) + state.claudeLaunchedBackgroundTasksByPaneKey.delete(paneKey) state.claudeSessionOwnerByPaneKey.delete(paneKey) state.codexSubagentRosterByPaneKey.delete(paneKey) state.codexSubagentTranscriptByPaneKey.delete(paneKey) @@ -231,40 +242,13 @@ export function paneHasStateClaims(state: HookListenerState, paneKey: string): b state.claudeLeadStateByPaneKey.has(paneKey) || state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) || state.claudeActiveSessionCronPaneKeys.has(paneKey) || + state.claudeLaunchedBackgroundTasksByPaneKey.has(paneKey) || state.claudeSessionOwnerByPaneKey.has(paneKey) || state.codexSubagentRosterByPaneKey.has(paneKey) || state.codexLeadStateByPaneKey.has(paneKey) ) } -export function movePaneScopedMapEntries( - map: Map, - fromPaneKey: string, - toPaneKey: string -): void { - for (const [key, value] of Array.from(map.entries())) { - if (key !== fromPaneKey && !key.startsWith(`${fromPaneKey}\0`)) { - continue - } - map.delete(key) - map.set(`${toPaneKey}${key.slice(fromPaneKey.length)}`, value) - } -} - -export function movePaneScopedSetEntries( - set: Set, - fromPaneKey: string, - toPaneKey: string -): void { - for (const key of Array.from(set)) { - if (key !== fromPaneKey && !key.startsWith(`${fromPaneKey}\0`)) { - continue - } - set.delete(key) - set.add(`${toPaneKey}${key.slice(fromPaneKey.length)}`) - } -} - export function movePaneCacheState( state: HookListenerState, fromPaneKey: string, @@ -285,6 +269,7 @@ export function movePaneCacheState( movePaneScopedSetEntries(state.claudeUnconfirmedRestoredStatusPaneKeys, fromPaneKey, toPaneKey) movePaneScopedSetEntries(state.claudeRunningNonAgentTaskPaneKeys, fromPaneKey, toPaneKey) movePaneScopedSetEntries(state.claudeActiveSessionCronPaneKeys, fromPaneKey, toPaneKey) + movePaneScopedMapEntries(state.claudeLaunchedBackgroundTasksByPaneKey, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.claudeSessionOwnerByPaneKey, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.codexSubagentRosterByPaneKey, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.codexSubagentTranscriptByPaneKey, fromPaneKey, toPaneKey) @@ -306,26 +291,6 @@ export function clearPaneTurnCacheState(state: HookListenerState, paneKey: strin state.grokMainAgentStatusByPaneKey.delete(paneKey) } -export function deletePaneScopedCacheEntry(map: Map, paneKey: string): void { - map.delete(paneKey) - const scopedPrefix = `${paneKey}\0` - for (const key of map.keys()) { - if (key.startsWith(scopedPrefix)) { - map.delete(key) - } - } -} - -export function deletePaneScopedSetEntry(set: Set, paneKey: string): void { - set.delete(paneKey) - const scopedPrefix = `${paneKey}\0` - for (const key of set) { - if (key.startsWith(scopedPrefix)) { - set.delete(key) - } - } -} - export function clearAllListenerCaches(state: HookListenerState): void { state.lastPromptByPaneKey.clear() state.lastToolByPaneKey.clear() @@ -341,6 +306,7 @@ export function clearAllListenerCaches(state: HookListenerState): void { state.claudeUnconfirmedRestoredStatusPaneKeys.clear() state.claudeRunningNonAgentTaskPaneKeys.clear() state.claudeActiveSessionCronPaneKeys.clear() + state.claudeLaunchedBackgroundTasksByPaneKey.clear() state.claudeSessionOwnerByPaneKey.clear() state.codexSubagentRosterByPaneKey.clear() state.codexSubagentTranscriptByPaneKey.clear() diff --git a/src/shared/agent-hook-listener/main-agent-turn-state.ts b/src/shared/agent-hook-listener/main-agent-turn-state.ts index 31f4134f099..af7fdf8cc61 100644 --- a/src/shared/agent-hook-listener/main-agent-turn-state.ts +++ b/src/shared/agent-hook-listener/main-agent-turn-state.ts @@ -4,6 +4,8 @@ import type { AgentTurnOutcome } from '../agent-turn-outcome' /** The Claude main agent's own turn record, published on every row as `mainAgent`. */ export type ClaudeLeadTurnState = { state: AgentStatusState + /** A known background task opened this foreground finishing turn. */ + taskWakeupTurn?: true /** The recorded verdict on the turn this record closed (the provider's, or a `cancellation` * Orca inferred from the interrupt keystroke, or whatever a persisted row it was seeded from * held); only meaningful while `state` is done. `cancellation` is what the fold reads as an @@ -20,10 +22,17 @@ export type ClaudeLeadTurnState = { /** Main agent state a child-induced wait displaced, restored when the wait clears; can't invent 'working' since the done-gate only downgrades done→working, never back. */ stateBeforeWait?: Pick< ClaudeLeadTurnState, - 'state' | 'outcome' | 'stateStartedAt' | 'turnCompletedAt' + 'state' | 'outcome' | 'stateStartedAt' | 'turnCompletedAt' | 'taskWakeupTurn' > } +/** When the main agent's own turn last ended, while it has not started another. */ +export function mainAgentIdleSince( + record: Pick | undefined +): number | undefined { + return record?.state === 'done' ? record.stateStartedAt : undefined +} + /** The Codex root's own record, folded with its roster into the combined `state`. A child's wait * lives on the roster entry, never here, so this record is always the root's own truth. */ export type CodexLeadTurnState = { diff --git a/src/shared/agent-hook-listener/pane-scoped-cache-entries.ts b/src/shared/agent-hook-listener/pane-scoped-cache-entries.ts new file mode 100644 index 00000000000..6e4f65ab615 --- /dev/null +++ b/src/shared/agent-hook-listener/pane-scoped-cache-entries.ts @@ -0,0 +1,50 @@ +// Why keyed this way: a pane's caches are stored under its pane key and under `\0` +// sub-keys, so moving or deleting a pane must take both. + +export function movePaneScopedMapEntries( + map: Map, + fromPaneKey: string, + toPaneKey: string +): void { + for (const [key, value] of Array.from(map.entries())) { + if (key !== fromPaneKey && !key.startsWith(`${fromPaneKey}\0`)) { + continue + } + map.delete(key) + map.set(`${toPaneKey}${key.slice(fromPaneKey.length)}`, value) + } +} + +export function movePaneScopedSetEntries( + set: Set, + fromPaneKey: string, + toPaneKey: string +): void { + for (const key of Array.from(set)) { + if (key !== fromPaneKey && !key.startsWith(`${fromPaneKey}\0`)) { + continue + } + set.delete(key) + set.add(`${toPaneKey}${key.slice(fromPaneKey.length)}`) + } +} + +export function deletePaneScopedCacheEntry(map: Map, paneKey: string): void { + map.delete(paneKey) + const scopedPrefix = `${paneKey}\0` + for (const key of map.keys()) { + if (key.startsWith(scopedPrefix)) { + map.delete(key) + } + } +} + +export function deletePaneScopedSetEntry(set: Set, paneKey: string): void { + set.delete(paneKey) + const scopedPrefix = `${paneKey}\0` + for (const key of set) { + if (key.startsWith(scopedPrefix)) { + set.delete(key) + } + } +} diff --git a/src/shared/agent-hook-listener/providers/claude-events.ts b/src/shared/agent-hook-listener/providers/claude-events.ts index 7ea3138fce5..6c77996421d 100644 --- a/src/shared/agent-hook-listener/providers/claude-events.ts +++ b/src/shared/agent-hook-listener/providers/claude-events.ts @@ -25,7 +25,12 @@ import { updateClaudeRunningNonAgentTask, voidClaimsOfReplacedClaudeSession } from './claude-roster-state' +import { + claudePaneHasRunningChildWork, + claudeRowHasUnlistedLiveWork +} from './claude-pane-hold-evidence' import { buildClaudeStatusPayload } from './claude-status-build' +import { trackClaudeTaskNotificationDelivery } from './claude-task-notification-delivery' export function normalizeClaudeEvent( state: HookListenerState, @@ -65,6 +70,7 @@ export function normalizeClaudeEvent( state.claudeSubagentRosterByPaneKey.delete(paneKey) state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) state.claudeActiveSessionCronPaneKeys.delete(paneKey) + state.claudeLaunchedBackgroundTasksByPaneKey.delete(paneKey) // Why: a new session's main agent starts its own clock, not the old session's last Stop. setClaudeMainAgentTurnState(state, paneKey, { state: 'done', stateStartedAt: Date.now() }) return buildClaudeStatusPayload(state, eventName, promptText, paneKey, hookPayload, { @@ -92,6 +98,9 @@ export function normalizeClaudeEvent( ? ('failure' as const) : undefined const backgroundTasks = readClaudeBackgroundAgentTasks(hookPayload) + const isTaskWakeup = + eventAgentId === undefined && + trackClaudeTaskNotificationDelivery(state, paneKey, eventName, hookPayload, backgroundTasks) const sessionCrons = hookPayload['session_crons'] const sessionCronInventoryPresent = Array.isArray(sessionCrons) const hasActiveSessionCron = sessionCronInventoryPresent && sessionCrons.length > 0 @@ -236,6 +245,7 @@ export function normalizeClaudeEvent( // pause after a cancelled turn must not erase them when the wait clears. ...(previousLead.outcome ? { outcome: previousLead.outcome } : {}), stateStartedAt: previousLead.stateStartedAt, + ...(previousLead.taskWakeupTurn ? { taskWakeupTurn: true as const } : {}), // Why: a child's permission pause displaces an already-finished lead; keep the end time so the later drain is still that turn's tail. ...(previousLead.turnCompletedAt !== undefined ? { turnCompletedAt: previousLead.turnCompletedAt } @@ -260,7 +270,14 @@ export function normalizeClaudeEvent( } } - const resolvedStatus = resolveClaudePaneStatus(state, paneKey, { state: reportedStateName }) + const taskWakeupTurn = + eventName === 'UserPromptSubmit' + ? isTaskWakeup + : !isTurnBoundary && !isManualCompactCompletion && previousLead?.taskWakeupTurn === true + const resolvedStatus = resolveClaudePaneStatus(state, paneKey, { + state: reportedStateName, + ...(taskWakeupTurn ? { taskWakeupTurn: true as const } : {}) + }) // Why: #15202's compact-completion guard reads the resolved state; this branch replaced the // resolver with one that also reports workingMode, so bridge rather than resolve twice. const effectiveState = resolvedStatus.stateName @@ -275,12 +292,16 @@ export function normalizeClaudeEvent( isAgentStatusHeldOpenByChildWork({ state: resolvedStatus.stateName, mainAgent: { state: reportedStateName } - }) + }) && + // Why: a turn held only by an owed notification resumes within ~100 ms; announcing it would + // add a premature "finished" before the wake-up turn's own. + claudePaneHasRunningChildWork(state, paneKey) ? Date.now() : undefined setClaudeMainAgentTurnState(state, paneKey, { state: reportedStateName, + ...(taskWakeupTurn ? { taskWakeupTurn: true as const } : {}), ...(outcome ? { outcome } : {}), ...(isWaitingInducing && eventAgentId ? { waitingAgentId: eventAgentId } : {}), ...(isAskUserQuestionWait && waitingToolUseId !== undefined ? { waitingToolUseId } : {}), @@ -295,8 +316,7 @@ export function normalizeClaudeEvent( resolvedStatus.stateName === 'working' && claudeRosterHasRestoredSnapshotSubagent(effectiveRoster) && !claudeRosterHasRuntimeWorkingSubagent(effectiveRoster) && - !state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) && - !state.claudeActiveSessionCronPaneKeys.has(paneKey) + !claudeRowHasUnlistedLiveWork(state, paneKey) ) { // Why: a legacy or partial Stop confirms the lead boundary, not a child restored from disk; keep the child-only gate eligible for reconciliation. state.claudeUnconfirmedRestoredStatusPaneKeys.add(paneKey) diff --git a/src/shared/agent-hook-listener/providers/claude-lifecycle-events.ts b/src/shared/agent-hook-listener/providers/claude-lifecycle-events.ts index 621c77ddab5..8622d1b5bef 100644 --- a/src/shared/agent-hook-listener/providers/claude-lifecycle-events.ts +++ b/src/shared/agent-hook-listener/providers/claude-lifecycle-events.ts @@ -1,4 +1,9 @@ import type { ParsedAgentStatusPayload } from '../../agent-status-types' +import { + markClaudeBackgroundAgentRunning, + oweClaudeAgentTaskNotification, + recordClaudeUnconfirmedAgentEnd +} from '../../claude-owed-task-notifications' import { claudeRosterHasRestoredSnapshotSubagent, claudeRosterHasRuntimeWorkingSubagent, @@ -8,6 +13,7 @@ import { upsertWorkingClaudeSubagent } from '../../claude-subagent-roster' import type { HookListenerState } from '../listener-state' +import { claudeRowHasUnlistedLiveWork } from './claude-pane-hold-evidence' import { readString } from '../tool-input-preview' import { clearClaudePendingWaitForAgent, @@ -28,6 +34,10 @@ export function normalizeClaudeSubagentLifecycleEvent( if (!lifecycleId) { return null } + const sessionOwner = state.claudeSessionOwnerByPaneKey.get(paneKey) + const currentSession = + sessionOwner !== undefined && sessionOwner === readString(hookPayload, 'session_id') + const compatibleSession = sessionOwner === undefined || currentSession const cachedLead = state.claudeLeadStateByPaneKey.get(paneKey) const ownsUnbackedWait = cachedLead?.state === 'waiting' && @@ -60,6 +70,12 @@ export function normalizeClaudeSubagentLifecycleEvent( } else { const agentId = lifecycleId if (eventName === 'SubagentStart') { + if (compatibleSession) { + markClaudeBackgroundAgentRunning( + state.claudeLaunchedBackgroundTasksByPaneKey.get(paneKey), + agentId + ) + } roster = getOrCreateClaudeSubagentRoster(state, paneKey) upsertWorkingClaudeSubagent( roster, @@ -76,18 +92,25 @@ export function normalizeClaudeSubagentLifecycleEvent( stopClaudeSubagent(roster, agentId) endedChildWork = wasWorking && roster.get(agentId)?.state !== 'working' } + // Why the roster's verdict: it already tells a finish from a teammate's turn end (parked idle). + if (compatibleSession && roster?.get(agentId)?.state !== 'idle') { + const tasks = state.claudeLaunchedBackgroundTasksByPaneKey.get(paneKey) ?? new Map() + if (currentSession && endedRuntimeChildWork) { + recordClaudeUnconfirmedAgentEnd(tasks, agentId, Date.now()) + state.claudeLaunchedBackgroundTasksByPaneKey.set(paneKey, tasks) + } + oweClaudeAgentTaskNotification(tasks, agentId, Date.now()) + } // Why: a blocked child that dies without another tool event would pin its permission/question wait on the pane forever — nothing else references that agent again. clearClaudePendingWaitForAgent(state, paneKey, (waitingAgentId) => waitingAgentId === agentId) } } const workingChildEvidence = claudeRosterHasRuntimeWorkingSubagent(roster) const hasUnconfirmedChild = claudeRosterHasRestoredSnapshotSubagent(roster) - // Why: a shell or cron the inventory positively reported is live evidence whatever verdict - // ended the main agent's turn; a cancel never discounts it. + // Why: a shell or cron the inventory positively reported, or a notification this runtime saw + // become owed, is live evidence whatever verdict ended the main agent's turn. const hasConfirmedDoneGate = - cachedLead?.state === 'done' && - (state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) || - state.claudeActiveSessionCronPaneKeys.has(paneKey)) + cachedLead?.state === 'done' && claudeRowHasUnlistedLiveWork(state, paneKey) const restoredOnlyDoneGate = cachedLead?.state === 'done' && !hasConfirmedDoneGate && hasUnconfirmedChild if (roster?.size === 0) { @@ -135,7 +158,7 @@ export function buildClaudeCachedLeadStatusPayload( // Why: draining the last background child is this turn's all-clear; the builder repeats the // record's turn stamp so a consumer can pair it with the announcement already sent. return buildClaudeStatusPayload(state, eventName, '', paneKey, hookPayload, { - ...resolveClaudePaneStatus(state, paneKey, { state: leadState }), + ...resolveClaudePaneStatus(state, paneKey, { ...lead, state: leadState }), updateToolSnapshot: false }) } diff --git a/src/shared/agent-hook-listener/providers/claude-pane-hold-evidence.ts b/src/shared/agent-hook-listener/providers/claude-pane-hold-evidence.ts new file mode 100644 index 00000000000..176f6f6723a --- /dev/null +++ b/src/shared/agent-hook-listener/providers/claude-pane-hold-evidence.ts @@ -0,0 +1,88 @@ +import { foldAgentLeadStatus, type AgentLeadStatusResolution } from '../../agent-lead-status-fold' +import { agentChildWorkLivenessFromEvidence } from '../../agent-status-child-work-liveness' +import { claudeLiveOwedTaskNotificationKinds } from '../../claude-owed-task-notifications' +import { claudeRosterHasWorkingSubagent } from '../../claude-subagent-roster' +import type { ClaudeLeadTurnState, HookListenerState } from '../listener-state' +import { mainAgentIdleSince } from '../main-agent-turn-state' + +/** Everything that can hold a pane whose main agent's own turn is over. */ +type ClaudePaneHoldEvidence = { + /** A sub-agent the roster tracks as working. */ + runningAgent: boolean + /** A running shell or a scheduled session cron. */ + runningNonAgent: boolean + /** An ended sub-agent / shell whose notification Claude has not delivered yet. */ + owedAgent: boolean + owedShell: boolean +} + +/** The one reading of a pane's hold evidence; the fold, the row's flag and the turn-end + * announcement must not each keep their own list. `mainAgent` is its state as of this event, + * which the caller may not have recorded yet. */ +export function claudePaneHoldEvidence( + state: HookListenerState, + paneKey: string, + mainAgent: Pick | undefined = state.claudeLeadStateByPaneKey.get( + paneKey + ) +): ClaudePaneHoldEvidence { + const owed = claudeLiveOwedTaskNotificationKinds( + state.claudeLaunchedBackgroundTasksByPaneKey.get(paneKey), + mainAgent?.state === 'done', + mainAgentIdleSince(state.claudeLeadStateByPaneKey.get(paneKey)) + ) + return { + runningAgent: claudeRosterHasWorkingSubagent(state.claudeSubagentRosterByPaneKey.get(paneKey)), + runningNonAgent: + state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) || + state.claudeActiveSessionCronPaneKeys.has(paneKey), + owedAgent: owed.agent, + owedShell: owed.shell + } +} + +/** Live work a Claude row's child list does not show. Restated beside the row so a restart or a + * relayed reader cannot settle a main agent that is about to be woken. */ +export function claudeRowHasUnlistedLiveWork(state: HookListenerState, paneKey: string): boolean { + const held = claudePaneHoldEvidence(state, paneKey) + return held.runningNonAgent || held.owedAgent || held.owedShell +} + +/** Child work that is running right now and may run long, as opposed to a notification the main + * agent is about to be woken by. Only this earns a held turn end its completion announcement. */ +export function claudePaneHasRunningChildWork(state: HookListenerState, paneKey: string): boolean { + const held = claudePaneHoldEvidence(state, paneKey) + return held.runningAgent || held.runningNonAgent +} + +export type ClaudePaneStatusResolution = AgentLeadStatusResolution & { + claudeTaskWakeupPending?: 'notification' | 'finishing-turn' +} + +export function resolveClaudePaneStatus( + state: HookListenerState, + paneKey: string, + lead: Pick +): ClaudePaneStatusResolution { + // Why: a task that stopped running is not over until Claude has told the main agent, which + // starts another main-agent turn; so an owed notification holds the pane like running work. + const held = claudePaneHoldEvidence(state, paneKey, lead) + const own = lead.waitingAgentId !== undefined ? lead.stateBeforeWait : lead + return { + ...foldAgentLeadStatus({ + leadState: lead.state, + childWorkLiveness: agentChildWorkLivenessFromEvidence({ + // A child's permission wait displaces the main agent record itself (`waitingAgentId`, + // `stateBeforeWait`) instead of living on the roster, so the roster never carries one. + hasWaitingChildWork: false, + hasLiveAgentWork: held.runningAgent || held.owedAgent, + hasLiveNonAgentWork: held.runningNonAgent || held.owedShell + }) + }), + ...(held.owedAgent || held.owedShell + ? { claudeTaskWakeupPending: 'notification' as const } + : own?.state !== 'done' && own?.taskWakeupTurn + ? { claudeTaskWakeupPending: 'finishing-turn' as const } + : {}) + } +} 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 c8e12339828..bf25ec71cb1 100644 --- a/src/shared/agent-hook-listener/providers/claude-roster-state.ts +++ b/src/shared/agent-hook-listener/providers/claude-roster-state.ts @@ -4,21 +4,19 @@ import type { AgentSubagentSnapshot, AgentWorkingMode } from '../../agent-status-types' +import { continueMainAgentStatus, mainAgentTurnInterrupted } from '../../agent-lead-status-fold' import { - continueMainAgentStatus, - mainAgentTurnInterrupted, - foldAgentLeadStatus, - type AgentLeadStatusResolution -} from '../../agent-lead-status-fold' -import { agentChildWorkLivenessFromEvidence } from '../../agent-status-child-work-liveness' -import { - claudeRosterHasWorkingSubagent, reapUnconfirmedRestoredClaudeSubagents, type ClaudeSubagentRoster } from '../../claude-subagent-roster' import type { AgentHookEventPayload } from '../listener-event' import type { ClaudeLeadTurnState, HookListenerState } from '../listener-state' import { readString } from '../tool-input-preview' +import { resolveClaudePaneStatus } from './claude-pane-hold-evidence' +export { + resolveClaudePaneStatus, + type ClaudePaneStatusResolution +} from './claude-pane-hold-evidence' /** Lead events that may re-anchor a pane's owning session. Allow-list, not a deny-list: a payload we * can't attribute (unknown name, child event missing its agent_id) must void nothing. */ @@ -84,6 +82,8 @@ export function voidClaimsOfReplacedClaudeSession( return } state.claudeActiveSessionCronPaneKeys.delete(paneKey) + // Why: the replaced conversation's notifications will never reach this one. + state.claudeLaunchedBackgroundTasksByPaneKey.delete(paneKey) const roster = state.claudeSubagentRosterByPaneKey.get(paneKey) if (!roster) { return @@ -124,8 +124,6 @@ export function updateClaudeRunningNonAgentTask( } } -export type ClaudePaneStatusResolution = AgentLeadStatusResolution - /** The only writer of the main agent record. The main agent's clock keeps continuity across * same-state writes; a caller restoring a stash passes the stashed instant and wins. */ export function setClaudeMainAgentTurnState( @@ -161,27 +159,6 @@ export function claudeMainAgentStatusForPayload( } } -export function resolveClaudePaneStatus( - state: HookListenerState, - paneKey: string, - lead: Pick -): ClaudePaneStatusResolution { - return foldAgentLeadStatus({ - leadState: lead.state, - childWorkLiveness: agentChildWorkLivenessFromEvidence({ - // A child's permission wait displaces the main agent record itself (`waitingAgentId`, - // `stateBeforeWait`) instead of living on the roster, so the roster never carries one. - hasWaitingChildWork: false, - hasLiveAgentWork: claudeRosterHasWorkingSubagent( - state.claudeSubagentRosterByPaneKey.get(paneKey) - ), - hasLiveNonAgentWork: - state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) || - state.claudeActiveSessionCronPaneKeys.has(paneKey) - }) - }) -} - /** The SERVER inferred a cancel of a LOCAL pane outside the hook stream (Ctrl+C with no Stop; * current Claude sends no hook on a cancel, and a bare Esc is never inferred for Claude): record * the main agent's verdict and fold it with the child work the turn left running, exactly as a @@ -192,7 +169,12 @@ export function resolveClaudePaneStatus( export function markClaudeLeadTurnInterrupted( state: HookListenerState, paneKey: string -): { state: AgentStatusState; workingMode?: AgentWorkingMode; mainAgent?: AgentMainAgentStatus } { +): { + state: AgentStatusState + workingMode?: AgentWorkingMode + mainAgent?: AgentMainAgentStatus + claudeTaskWakeupPending?: 'notification' | 'finishing-turn' +} { const record = setClaudeMainAgentTurnState(state, paneKey, { state: 'done', outcome: 'cancellation' @@ -201,6 +183,7 @@ export function markClaudeLeadTurnInterrupted( const mainAgent = claudeMainAgentStatusForPayload(record) return { state: resolved.stateName, + claudeTaskWakeupPending: resolved.claudeTaskWakeupPending, ...(resolved.workingMode ? { workingMode: resolved.workingMode } : {}), ...(mainAgent ? { mainAgent } : {}) } @@ -319,11 +302,15 @@ export function clearClaudeAnsweredQuestionWait( interrupted?: true workingMode?: AgentWorkingMode mainAgent?: AgentMainAgentStatus + claudeTaskWakeupPending?: 'notification' | 'finishing-turn' } { const lead = state.claudeLeadStateByPaneKey.get(paneKey) const stash = lead?.state === 'waiting' - ? (lead.stateBeforeWait ?? { state: 'working' as const }) + ? (lead.stateBeforeWait ?? { + state: 'working' as const, + ...(lead.taskWakeupTurn ? { taskWakeupTurn: true as const } : {}) + }) : { state: 'working' as const } const restored = setClaudeMainAgentTurnState(state, paneKey, { ...stash }) const publishedMainAgent = claudeMainAgentStatusForPayload(restored) @@ -340,6 +327,7 @@ export function clearClaudeAnsweredQuestionWait( const resolved = resolveClaudePaneStatus(state, paneKey, restored) return { state: resolved.stateName, + claudeTaskWakeupPending: resolved.claudeTaskWakeupPending, ...(resolved.workingMode ? { workingMode: resolved.workingMode } : {}), ...(mainAgentTurnInterrupted(restored) ? { interrupted: true as const } : {}), ...(restored.turnCompletedAt !== undefined diff --git a/src/shared/agent-hook-listener/providers/claude-status-build.ts b/src/shared/agent-hook-listener/providers/claude-status-build.ts index 9f6423c401c..e5d8b496d8c 100644 --- a/src/shared/agent-hook-listener/providers/claude-status-build.ts +++ b/src/shared/agent-hook-listener/providers/claude-status-build.ts @@ -22,6 +22,7 @@ export function buildClaudeStatusPayload( workingMode?: AgentWorkingMode updateToolSnapshot: boolean sessionBoundary?: boolean + claudeTaskWakeupPending?: ParsedAgentStatusPayload['claudeTaskWakeupPending'] } ): ParsedAgentStatusPayload | null { // Why: child-driven refreshes are roster bookkeeping, not lead tool activity; read the cached snapshot without merging so they can't clear a live AskUserQuestion card or clobber the tool preview. @@ -40,6 +41,7 @@ export function buildClaudeStatusPayload( return normalizeAgentStatusPayload({ state: options.stateName, workingMode: options.workingMode, + claudeTaskWakeupPending: options.claudeTaskWakeupPending, // Why: only lead-origin events may reset the prompt cache; a child-driven refresh must not blank the lead's prompt label. prompt: resolvePrompt(state, paneKey, promptText, { resetOnNewTurn: options.updateToolSnapshot && isNewTurnEvent('claude', eventName) diff --git a/src/shared/agent-hook-listener/providers/claude-task-notification-delivery.ts b/src/shared/agent-hook-listener/providers/claude-task-notification-delivery.ts new file mode 100644 index 00000000000..f502f8b1154 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/claude-task-notification-delivery.ts @@ -0,0 +1,65 @@ +import type { readClaudeBackgroundAgentTasks } from '../../claude-background-task-inventory' +import { + forgetStoppedClaudeShellTask, + oweClaudeShellTaskNotifications, + readClaudeBackgroundTaskLaunch, + recordClaudeBackgroundTaskLaunch, + recordClaudeUnconfirmedAgentNotification, + settleClaudeTaskNotification +} from '../../claude-owed-task-notifications' +import { readClaudeTaskNotification } from '../../claude-task-notification-text' +import type { HookListenerState } from '../listener-state' +import { readString } from '../tool-input-preview' + +/** Fold one MAIN-agent event into the pane's record of launched background tasks. A sub-agent's + * own launches are excluded by the caller: their notifications wake that sub-agent, not the pane. */ +export function trackClaudeTaskNotificationDelivery( + state: HookListenerState, + paneKey: string, + eventName: unknown, + hookPayload: Record, + inventory: ReturnType +): boolean { + let tasks = state.claudeLaunchedBackgroundTasksByPaneKey.get(paneKey) + if (eventName === 'PostToolUse') { + const toolName = readString(hookPayload, 'tool_name') + const response = hookPayload['tool_response'] + const launch = readClaudeBackgroundTaskLaunch(toolName, response) + if (launch) { + const launched = tasks ?? new Map() + state.claudeLaunchedBackgroundTasksByPaneKey.set(paneKey, launched) + recordClaudeBackgroundTaskLaunch(launched, launch) + } else if (tasks && toolName === 'TaskStop' && typeof response === 'object' && response) { + const stoppedId = readString({ ...response }, 'task_id') + if (stoppedId) { + forgetStoppedClaudeShellTask(tasks, stoppedId) + } + } + return false + } + if (eventName === 'UserPromptSubmit') { + const notification = readClaudeTaskNotification(readString(hookPayload, 'prompt') ?? '') + const child = notification + ? state.claudeSubagentRosterByPaneKey.get(paneKey)?.get(notification.taskId) + : undefined + if (notification && child?.state === 'working' && child.restoredFromSnapshot !== true) { + tasks ??= new Map() + recordClaudeUnconfirmedAgentNotification(tasks, notification.taskId) + state.claudeLaunchedBackgroundTasksByPaneKey.set(paneKey, tasks) + } + const task = notification ? tasks?.get(notification.taskId) : undefined + const knownTask = task !== undefined && task.notificationDelivered !== true + const lead = state.claudeLeadStateByPaneKey.get(paneKey) + const own = lead?.waitingAgentId !== undefined ? lead.stateBeforeWait : lead + // Why: a repeated notification cannot end its still-unfinished foreground cycle. + const continuesWakeupTurn = + notification !== null && own?.state !== 'done' && own?.taskWakeupTurn === true + if (tasks && notification?.status) { + settleClaudeTaskNotification(tasks, notification.taskId) + } + return knownTask || continuesWakeupTurn + } else if (tasks && (eventName === 'Stop' || eventName === 'StopFailure') && inventory.present) { + oweClaudeShellTaskNotifications(tasks, new Set(inventory.runningNonAgentTaskIds), Date.now()) + } + return false +} diff --git a/src/shared/agent-hook-relay.ts b/src/shared/agent-hook-relay.ts index d02ce9b7776..814f7448476 100644 --- a/src/shared/agent-hook-relay.ts +++ b/src/shared/agent-hook-relay.ts @@ -84,6 +84,8 @@ export type AgentHookRelayEnvelope = { agentPresence?: AgentProcessPresence /** Ephemeral Orca launch identity stamped into the PTY env for this process. */ launchToken?: string + /** Optional support proof and exact host turn fence for explicit interrupt reconciliation. */ + hostTurnRevision?: string tabId?: string worktreeId?: string /** Always `null` on the wire — relay does not know Orca's local connectionId. */ @@ -116,7 +118,7 @@ export type AgentHookRelayEnvelope = { providerSessionOnly?: boolean /** True when the relay is replaying its cache after Orca reconnects. */ isReplay?: boolean - /** Claude background-work evidence for input-interrupt inference on the receiving host. */ + /** Claude live work the child list does not show (a shell, a cron, an owed task notification), for input-interrupt inference on the receiving host. */ claudeRunningNonAgentTask?: boolean /** Forwarded from the agent CLI POST body. The relay default is `remote`, * which marks transport location rather than dev/prod build env. */ diff --git a/src/shared/agent-hook-row-child-work-fold.ts b/src/shared/agent-hook-row-child-work-fold.ts new file mode 100644 index 00000000000..7c08450f451 --- /dev/null +++ b/src/shared/agent-hook-row-child-work-fold.ts @@ -0,0 +1,25 @@ +import type { AgentHookEventPayload } from './agent-hook-listener/listener-event' +import { foldAgentLeadStatus, type AgentLeadStatusResolution } from './agent-lead-status-fold' +import { agentChildWorkLiveness } from './agent-status-child-work-liveness' +import type { AgentStatusState, AgentSubagentSnapshot } from './agent-status-types' + +type RowChildWork = Pick & { + payload: { subagents?: readonly AgentSubagentSnapshot[] } +} + +/** Fold a main agent state with the child work a row itself carries: its subagent snapshots and the + * shell/cron fact restated beside them. For a relayed pane that is all the desktop can see, because + * the provider records live on the relay. */ +export function foldMainAgentWithRowChildWork( + leadState: AgentStatusState, + row: RowChildWork +): AgentLeadStatusResolution { + const childWorkLiveness = agentChildWorkLiveness([ + ...(row.payload.subagents?.map((child) => ({ kind: 'agent' as const, state: child.state })) ?? + []), + ...(row.claudeRunningNonAgentTask + ? [{ kind: 'command' as const, state: 'working' as const }] + : []) + ]) + return foldAgentLeadStatus({ leadState, childWorkLiveness }) +} diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 3d5ef9458e6..5edf46fe788 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -199,6 +199,8 @@ export type AgentStatusPayload = { /** The main agent's own state and last-turn verdict. See AgentMainAgentStatus. Producers publish it * beside the combined `state`; a reader that predates it keeps reading `state`. */ mainAgent?: AgentMainAgentStatus + /** The execution host awaits a launched Claude task’s wake-up or its finishing turn. */ + claudeTaskWakeupPending?: 'notification' | 'finishing-turn' } /** @@ -237,7 +239,10 @@ export function pickParsedAgentStatusPayload( ...(row.sessionBoundary !== undefined ? { sessionBoundary: row.sessionBoundary } : {}), ...(row.turnCompletedAt !== undefined ? { turnCompletedAt: row.turnCompletedAt } : {}), ...(row.subagents !== undefined ? { subagents: row.subagents } : {}), - ...(row.mainAgent !== undefined ? { mainAgent: row.mainAgent } : {}) + ...(row.mainAgent !== undefined ? { mainAgent: row.mainAgent } : {}), + ...(row.claudeTaskWakeupPending !== undefined + ? { claudeTaskWakeupPending: row.claudeTaskWakeupPending } + : {}) } } @@ -344,7 +349,13 @@ function normalizeAgentStatusObject(parsed: unknown): ParsedAgentStatusPayload | sessionBoundary: obj.sessionBoundary === true && state === 'done' ? true : undefined, turnCompletedAt: normalizeTurnCompletedAtField(obj.turnCompletedAt, state), subagents: normalizeAgentSubagentsField(obj.subagents), - mainAgent: normalizeMainAgentStatusField(obj.mainAgent) + mainAgent: normalizeMainAgentStatusField(obj.mainAgent), + ...(obj.agentType === 'claude' && + state !== 'done' && + (obj.claudeTaskWakeupPending === 'notification' || + obj.claudeTaskWakeupPending === 'finishing-turn') + ? { claudeTaskWakeupPending: obj.claudeTaskWakeupPending } + : {}) } } diff --git a/src/shared/claude-background-task-inventory.ts b/src/shared/claude-background-task-inventory.ts index 451d9f2c7c6..ed94e821ce5 100644 --- a/src/shared/claude-background-task-inventory.ts +++ b/src/shared/claude-background-task-inventory.ts @@ -45,14 +45,23 @@ export function readClaudeBackgroundAgentTasks(hookPayload: Record 0) { + runningNonAgentTaskIds.push(id) + } } if (!isAgentTask) { continue @@ -95,5 +108,5 @@ export function readClaudeBackgroundAgentTasks(hookPayload: Record void + +/** Wakes the execution host (main for local panes, the relay for remote ones) when a pane held + * `working` only by owed Claude task notifications must be restated: nothing arrives when a + * notification is never sent. A firing re-reads the pane, so a wakeup left behind by a closed or + * moved pane is a no-op and needs no teardown of its own. */ +export class ClaudeOwedNotificationExpiryTimers { + private readonly wakeups = new CodexSubagentPollScheduler(0, (paneKey, publish) => + this.restate(paneKey, publish) + ) + + constructor(private readonly state: HookListenerState) {} + + /** Call whenever a pane's row is stored; `publish` applies the restated row as the host would. */ + arm(paneKey: string, publish: PublishRow): void { + const deadline = this.deadline(paneKey) + if (deadline === undefined) { + this.wakeups.clear(paneKey) + return + } + // Why capped: a wall clock stepped far back would otherwise ask for a delay setTimeout cannot + // hold; restate() re-checks the deadline, so waking early only re-arms. + const delay = Math.min( + CLAUDE_OWED_TASK_NOTIFICATION_LEASE_MS, + Math.max(0, deadline - Date.now()) + ) + this.wakeups.schedule(paneKey, publish, delay) + } + + clearAll(): void { + this.wakeups.clearAll() + } + + /** Only an idle main agent has one: the lease does not run while it is in a turn. */ + private deadline(paneKey: string): number | undefined { + const idleSince = mainAgentIdleSince(this.state.claudeLeadStateByPaneKey.get(paneKey)) + return idleSince === undefined + ? undefined + : claudeOwedTaskNotificationDeadline( + this.state.claudeLaunchedBackgroundTasksByPaneKey.get(paneKey), + idleSince + ) + } + + private restate(paneKey: string, publish: PublishRow): void { + const deadline = this.deadline(paneKey) + if (deadline === undefined) { + return + } + // Why: a timer can fire before the wall clock reaches the deadline the lease is judged by. + if (deadline > Date.now()) { + this.arm(paneKey, publish) + return + } + const row = this.state.lastStatusByPaneKey.get(paneKey) + // Why the builder a child's end re-emits through: the fold it runs is what gives up on the + // expired notifications, and the row must read exactly like every other Claude row. + const payload = buildClaudeCachedLeadStatusPayload(this.state, undefined, paneKey, {}) + if (!row || !payload) { + return + } + // Why the stored row's envelope: its launch token and owner must reach the same fences a + // hook's row does; like a transcript-poll restatement, it is no hook event and no new prompt. + publish({ + ...row, + hasExplicitPrompt: undefined, + hookEventName: undefined, + claudeRunningNonAgentTask: claudeRowHasUnlistedLiveWork(this.state, paneKey), + payload + }) + } +} diff --git a/src/shared/claude-owed-notification-resource-contract.test.ts b/src/shared/claude-owed-notification-resource-contract.test.ts new file mode 100644 index 00000000000..30f8f5fb426 --- /dev/null +++ b/src/shared/claude-owed-notification-resource-contract.test.ts @@ -0,0 +1,93 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createHookListenerState, clearPaneCacheState } from './agent-hook-listener/listener-state' +import { ClaudeOwedNotificationExpiryTimers } from './claude-owed-notification-expiry-timers' +import { + CLAUDE_OWED_TASK_NOTIFICATION_LEASE_MS, + oweClaudeAgentTaskNotification, + recordClaudeBackgroundTaskLaunch, + recordClaudeUnconfirmedAgentEnd, + claudeOwedTaskNotificationDeadline, + claudeLiveOwedTaskNotificationKinds, + type ClaudeLaunchedBackgroundTasks +} from './claude-owed-task-notifications' + +describe('Claude notification resource bounds', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + it('caps live task records without evicting a tracked running task', () => { + const tasks: ClaudeLaunchedBackgroundTasks = new Map() + for (let index = 0; index < 4096; index += 1) { + recordClaudeBackgroundTaskLaunch(tasks, { id: `agent-${index}`, kind: 'agent' }) + } + expect(tasks.size).toBe(256) + expect(tasks.has('agent-0')).toBe(true) + oweClaudeAgentTaskNotification(tasks, 'agent-0', Date.now()) + expect(tasks.get('agent-0')?.notificationOwedAt).toBe(Date.now()) + }) + + it('evicts unmatched child ends before confirmed work and activates only a confirmed parent launch', () => { + const tasks: ClaudeLaunchedBackgroundTasks = new Map() + recordClaudeBackgroundTaskLaunch(tasks, { id: 'main-task', kind: 'agent' }) + oweClaudeAgentTaskNotification(tasks, 'main-task', Date.now()) + for (let index = 0; index < 4096; index += 1) { + recordClaudeUnconfirmedAgentEnd(tasks, `nested-${index}`, Date.now()) + } + expect(tasks.size).toBe(256) + expect(tasks.get('main-task')?.notificationOwedAt).toBe(Date.now()) + recordClaudeBackgroundTaskLaunch(tasks, { id: 'later-main-task', kind: 'agent' }) + expect(tasks.has('later-main-task')).toBe(true) + expect(tasks.has('main-task')).toBe(true) + const provisional: ClaudeLaunchedBackgroundTasks = new Map() + recordClaudeUnconfirmedAgentEnd(provisional, 'late-launch', Date.now()) + expect(claudeLiveOwedTaskNotificationKinds(provisional, true, Date.now())).toEqual({ + agent: false, + shell: false + }) + expect(claudeOwedTaskNotificationDeadline(provisional, Date.now())).toBeUndefined() + vi.advanceTimersByTime(100) + recordClaudeBackgroundTaskLaunch(provisional, { id: 'late-launch', kind: 'agent' }) + expect(claudeLiveOwedTaskNotificationKinds(provisional, true, Date.now()).agent).toBe(true) + }) + + it('shares one timer across 512 panes and publishes nothing for closed owners', () => { + const state = createHookListenerState() + const timers = new ClaudeOwedNotificationExpiryTimers(state) + const publish = vi.fn() + for (let index = 0; index < 512; index += 1) { + const paneKey = `pane-${index}` + const tasks: ClaudeLaunchedBackgroundTasks = new Map() + recordClaudeBackgroundTaskLaunch(tasks, { id: 'agent', kind: 'agent' }) + oweClaudeAgentTaskNotification(tasks, 'agent', Date.now()) + state.claudeLaunchedBackgroundTasksByPaneKey.set(paneKey, tasks) + state.claudeLeadStateByPaneKey.set(paneKey, { state: 'done', stateStartedAt: Date.now() }) + timers.arm(paneKey, publish) + } + expect(vi.getTimerCount()).toBe(1) + for (const paneKey of state.claudeLaunchedBackgroundTasksByPaneKey.keys()) { + clearPaneCacheState(state, paneKey) + } + vi.advanceTimersByTime(CLAUDE_OWED_TASK_NOTIFICATION_LEASE_MS) + expect(publish).not.toHaveBeenCalled() + expect(state.claudeLaunchedBackgroundTasksByPaneKey.size).toBe(0) + expect(vi.getTimerCount()).toBe(0) + timers.clearAll() + }) + + it('clears the shared deadline timer when its host stops', () => { + const state = createHookListenerState() + const tasks: ClaudeLaunchedBackgroundTasks = new Map() + recordClaudeBackgroundTaskLaunch(tasks, { id: 'agent', kind: 'agent' }) + oweClaudeAgentTaskNotification(tasks, 'agent', Date.now()) + state.claudeLaunchedBackgroundTasksByPaneKey.set('pane', tasks) + state.claudeLeadStateByPaneKey.set('pane', { state: 'done', stateStartedAt: Date.now() }) + const timers = new ClaudeOwedNotificationExpiryTimers(state) + const publish = vi.fn() + timers.arm('pane', publish) + expect(vi.getTimerCount()).toBe(1) + timers.clearAll() + expect(vi.getTimerCount()).toBe(0) + vi.advanceTimersByTime(CLAUDE_OWED_TASK_NOTIFICATION_LEASE_MS) + expect(publish).not.toHaveBeenCalled() + }) +}) diff --git a/src/shared/claude-owed-task-notifications.ts b/src/shared/claude-owed-task-notifications.ts new file mode 100644 index 00000000000..8034032e7a4 --- /dev/null +++ b/src/shared/claude-owed-task-notifications.ts @@ -0,0 +1,254 @@ +/** Background tasks the MAIN agent launched in this session, keyed by the task id Claude reports at + * launch and repeats in the task's ``. + * + * Why: Claude owes the main agent one notification for every background task that ends, and + * delivers it after the task stops running — up to a whole main-agent turn later, and after a Stop + * whose inventory is already empty. A pane with a notification still owed is not finished (#23942). + * Captures: __fixtures__/claude-task-notification-hooks.jsonl. */ +export type ClaudeLaunchedBackgroundTasks = Map + +type ClaudeLaunchedBackgroundTask = { + kind: 'agent' | 'shell' + /** A current child ended before its MAIN-agent launch identified the parent. */ + launchUnconfirmed?: true + /** When the task was seen to end; set while its notification has not reached the main agent. */ + notificationOwedAt?: number + /** A sub-agent already announced (or given up on); kept only because it can be resumed. */ + settled?: true + /** A delivered notification, distinct from a lease that merely gave up waiting. */ + notificationDelivered?: true +} + +/** How long an idle main agent is still expected to be woken. Claude's Stop is never final and no + * hook says its queue is empty, so an owed notification is an expectation, not an obligation: + * captured deliveries land 40–130 ms after the main agent goes idle, and hooks are separate posts + * that can be lost or arrive out of order. Matches Claude's own idle threshold (its idle_prompt + * fired 60 s after the last Stop in every capture that idled that long). */ +export const CLAUDE_OWED_TASK_NOTIFICATION_LEASE_MS = 60_000 + +/** A session can launch tasks without bound; past this, settled ones are forgotten first, and a + * launch that finds none is not tracked rather than displacing a task still being waited on. */ +const CLAUDE_LAUNCHED_BACKGROUND_TASK_LIMIT = 256 + +/** The task id a main-agent PostToolUse reports for work it left running in the background. */ +export function readClaudeBackgroundTaskLaunch( + toolName: string | undefined, + toolResponse: unknown +): { id: string; kind: ClaudeLaunchedBackgroundTask['kind'] } | null { + if (typeof toolResponse !== 'object' || toolResponse === null) { + return null + } + const response: Record = { ...toolResponse } + const agentId = + response.isAsync === true && typeof response.agentId === 'string' ? response.agentId.trim() : '' + if (agentId.length > 0) { + return { id: agentId, kind: 'agent' } + } + // Why: a Monitor is launched as `taskId` but listed and notified exactly like a shell. + const rawShellId = + response.backgroundTaskId ?? (toolName === 'Monitor' ? response.taskId : undefined) + const shellId = typeof rawShellId === 'string' ? rawShellId.trim() : '' + return shellId.length > 0 ? { id: shellId, kind: 'shell' } : null +} + +export function recordClaudeBackgroundTaskLaunch( + tasks: ClaudeLaunchedBackgroundTasks, + launch: { id: string; kind: ClaudeLaunchedBackgroundTask['kind'] } +): void { + const previous = tasks.get(launch.id) + if (previous) { + if (previous.kind === launch.kind) { + previous.launchUnconfirmed = undefined + } + return + } + if (makeClaudeTaskRecordRoom(tasks)) { + tasks.set(launch.id, { kind: launch.kind }) + } +} + +function makeClaudeTaskRecordRoom(tasks: ClaudeLaunchedBackgroundTasks): boolean { + if (tasks.size < CLAUDE_LAUNCHED_BACKGROUND_TASK_LIMIT) { + return true + } + let settledId: string | undefined + for (const [id, task] of tasks) { + if (task.launchUnconfirmed) { + tasks.delete(id) + return true + } + if (settledId === undefined && task.settled) { + settledId = id + } + } + if (settledId === undefined) { + return false + } + tasks.delete(settledId) + return true +} + +/** Child lifecycle posts may beat the parent's launch; only the later launch makes this debt. */ +export function recordClaudeUnconfirmedAgentEnd( + tasks: ClaudeLaunchedBackgroundTasks, + agentId: string, + now: number +): void { + if (!tasks.has(agentId) && makeClaudeTaskRecordRoom(tasks)) { + tasks.set(agentId, { kind: 'agent', launchUnconfirmed: true, notificationOwedAt: now }) + } +} + +/** An own notification can arrive before both launch and end while the runtime child is known. */ +export function recordClaudeUnconfirmedAgentNotification( + tasks: ClaudeLaunchedBackgroundTasks, + agentId: string +): void { + if (!tasks.has(agentId) && makeClaudeTaskRecordRoom(tasks)) { + tasks.set(agentId, { kind: 'agent', launchUnconfirmed: true }) + } +} + +/** A recorded sub-agent resumed under the same task id; its next end may notify again. */ +export function markClaudeBackgroundAgentRunning( + tasks: ClaudeLaunchedBackgroundTasks | undefined, + agentId: string +): void { + const task = tasks?.get(agentId) + if (task?.kind === 'agent') { + task.notificationOwedAt = undefined + task.settled = undefined + task.notificationDelivered = undefined + } +} + +/** A launched sub-agent finished: Claude now owes the main agent its notification. */ +export function oweClaudeAgentTaskNotification( + tasks: ClaudeLaunchedBackgroundTasks | undefined, + agentId: string, + now: number +): void { + const task = tasks?.get(agentId) + if (task?.kind === 'agent' && task.settled !== true) { + task.notificationOwedAt ??= now + task.settled = undefined + } +} + +/** A shell's end fires no hook: a main-agent inventory that stopped listing a launched + * shell is the only sign it ended. */ +export function oweClaudeShellTaskNotifications( + tasks: ClaudeLaunchedBackgroundTasks, + runningShellIds: ReadonlySet, + now: number +): void { + for (const [id, task] of tasks) { + if (task.kind === 'shell' && !runningShellIds.has(id)) { + task.notificationOwedAt ??= now + } + } +} + +/** The notification arrived. A sub-agent can be resumed and notify again under the same id, so it + * stays known; a shell is over, and can be notified before any inventory showed it gone. */ +export function settleClaudeTaskNotification( + tasks: ClaudeLaunchedBackgroundTasks, + taskId: string +): void { + const task = tasks.get(taskId) + if (task) { + stopOwingClaudeTaskNotification(tasks, taskId, task) + if (task.kind === 'agent') { + task.notificationDelivered = true + } + } +} + +function stopOwingClaudeTaskNotification( + tasks: ClaudeLaunchedBackgroundTasks, + taskId: string, + task: ClaudeLaunchedBackgroundTask +): void { + if (task.kind === 'agent') { + task.notificationOwedAt = undefined + task.settled = true + } else { + tasks.delete(taskId) + } +} + +/** The lease runs only while the main agent is idle: a notification queued behind a running turn + * is delivered when that turn yields, however long it takes. */ +function claudeOwedTaskNotificationExpiry(owedAt: number, mainAgentIdleSince: number): number { + return Math.max(owedAt, mainAgentIdleSince) + CLAUDE_OWED_TASK_NOTIFICATION_LEASE_MS +} + +/** Stop waiting for notifications an idle main agent was owed for a whole lease. */ +function dropExpiredClaudeOwedTaskNotifications( + tasks: ClaudeLaunchedBackgroundTasks, + mainAgentIdleSince: number, + now: number +): void { + for (const [id, task] of tasks) { + if ( + task.launchUnconfirmed !== true && + task.notificationOwedAt !== undefined && + claudeOwedTaskNotificationExpiry(task.notificationOwedAt, mainAgentIdleSince) <= now + ) { + stopOwingClaudeTaskNotification(tasks, id, task) + } + } +} + +/** When the last notification still owed to an idle main agent stops being waited for. */ +export function claudeOwedTaskNotificationDeadline( + tasks: ClaudeLaunchedBackgroundTasks | undefined, + mainAgentIdleSince: number +): number | undefined { + let deadline: number | undefined + for (const task of tasks?.values() ?? []) { + if (task.launchUnconfirmed !== true && task.notificationOwedAt !== undefined) { + const expiry = claudeOwedTaskNotificationExpiry(task.notificationOwedAt, mainAgentIdleSince) + deadline = deadline === undefined ? expiry : Math.max(deadline, expiry) + } + } + return deadline +} + +/** The main agent stopped the task itself. Claude sends no notification for a shell it was told to + * stop; a stopped sub-agent still gets one (`killed`) and no turn end, so it never becomes owed. */ +export function forgetStoppedClaudeShellTask( + tasks: ClaudeLaunchedBackgroundTasks, + taskId: string +): void { + if (tasks.get(taskId)?.kind === 'shell') { + tasks.delete(taskId) + } +} + +function claudeOwedTaskNotificationKinds(tasks: ClaudeLaunchedBackgroundTasks | undefined): { + agent: boolean + shell: boolean +} { + const owed = { agent: false, shell: false } + for (const task of tasks?.values() ?? []) { + if (task.launchUnconfirmed !== true && task.notificationOwedAt !== undefined) { + owed[task.kind] = true + } + } + return owed +} + +/** What a pane is still owed, after giving up on what an idle main agent was owed for a whole + * lease. `mainAgentIdleSince` is undefined when the main agent is going idle on this very event. */ +export function claudeLiveOwedTaskNotificationKinds( + tasks: ClaudeLaunchedBackgroundTasks | undefined, + mainAgentIdle: boolean, + mainAgentIdleSince: number | undefined, + now = Date.now() +): { agent: boolean; shell: boolean } { + if (tasks && mainAgentIdle) { + dropExpiredClaudeOwedTaskNotifications(tasks, mainAgentIdleSince ?? now, now) + } + return claudeOwedTaskNotificationKinds(tasks) +} diff --git a/src/shared/claude-task-notification-text.ts b/src/shared/claude-task-notification-text.ts new file mode 100644 index 00000000000..4292a8f7dd1 --- /dev/null +++ b/src/shared/claude-task-notification-text.ts @@ -0,0 +1,22 @@ +/** Claude tells a parent agent about a background task with a user turn that starts with this tag. */ +export const CLAUDE_TASK_NOTIFICATION_MARKER = '' +const TASK_ID_PATTERN = /([^<]+)<\/task-id>/ +const TASK_STATUS_PATTERN = /([a-z_]+)<\/status>/ + +/** `status` is present only once the task has ended: a Monitor's `` turns reuse the tag and + * the task id while the task is still running (captured on Claude Code 2.1.287). Claude writes it + * before ``; anything after is task output, which may itself contain a status tag. */ +export function readClaudeTaskNotification( + text: string +): { taskId: string; status?: string } | null { + if (!text.startsWith(CLAUDE_TASK_NOTIFICATION_MARKER)) { + return null + } + const taskId = TASK_ID_PATTERN.exec(text)?.[1]?.trim() + if (!taskId) { + return null + } + const summaryAt = text.indexOf('') + const status = TASK_STATUS_PATTERN.exec(summaryAt === -1 ? text : text.slice(0, summaryAt))?.[1] + return { taskId, ...(status ? { status } : {}) } +} diff --git a/tests/e2e/cross-version-wire/release-checkout.unit.test.ts b/tests/e2e/cross-version-wire/release-checkout.unit.test.ts index 64f42bed5cd..52c750b0f77 100644 --- a/tests/e2e/cross-version-wire/release-checkout.unit.test.ts +++ b/tests/e2e/cross-version-wire/release-checkout.unit.test.ts @@ -300,8 +300,9 @@ describe('release checkout materialization', () => { expect(relative(cacheRoot, checkout.root)).not.toMatch(/^\.\./) }, 180_000) - // Released sources resolve the pinned test-only dependency through the repo's node_modules. - it('loads release source that imports a package the current production tree dropped', async () => { + // v1.4.221 still needs the pinned test-only parser for its dense match path. + // Default cache root: package resolution must walk up into the repo's node_modules. + it('runs the released dense parser with its retained test dependency', async () => { const checkout = await materializeReleaseCheckout('v1.4.221') const ripgrep = await importReleaseCheckoutModule( checkout, @@ -323,6 +324,23 @@ describe('release checkout materialization', () => { type: 'match', data: { submatches: [] } }) + const match = { + type: 'match', + data: { + path: { text: 'src/example.ts' }, + lines: { text: 'hit hit\n' }, + line_number: 7, + submatches: [ + { start: 0, end: 3 }, + { start: 4, end: 7 } + ] + } + } + expect(callExport('parseDenseRipgrepMatchJson', JSON.stringify(match), 1, 8)).toEqual({ + ...match, + data: { ...match.data, submatches: [{ start: 0, end: 3 }] } + }) + expect(() => callExport('parseDenseRipgrepMatchJson', '{"type":', 1, 8)).toThrow() }, 180_000) it('keeps an import live while another colliding release label materializes', async () => {