From d5b1ef85bd714ae60620991f3a3fccf37c3c70d1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:59:34 -0700 Subject: [PATCH 1/8] fix(agent-status): let Grok's own hooks decide a cancel instead of keypress inference Measured on Grok 1.0.41 (fixture grok-cancel-subagent-dialog-hooks.jsonl): Esc never cancels a turn and fires no hook, and Ctrl+C with subagents running opens a dialog that cancels nothing until answered - so no keypress may infer a Grok cancel; the renderer skips the request and the server refuses it. Grok's real stop_cancelled carries no backgroundTasks inventory, so turn ends now fold with the inventory Grok last reported, kept per pane and adjusted by subagent lifecycle hooks; a killed subagent's own SessionEnd re-derives the row it held open. Grok child hooks no longer stamp the child's session id over the pane's resume identity, and child-attributed rows preserve the root providerSession the way Codex children already do. --- ...s => agent-cancel-capture.test-fixture.ts} | 6 +- .../server-claude-cancel-captures.test.ts | 2 +- .../server-grok-cancel-captures.test.ts | 169 ++++++++++++++++++ .../agent-hooks/server-grok-cancel.test.ts | 30 ++-- .../server-relayed-claude-cancel.test.ts | 2 +- .../server/server-status-inference.ts | 6 + .../server/server-status-update.ts | 15 +- .../agent-interrupt-inference.test.ts | 21 +++ .../agent-interrupt-inference.ts | 5 +- .../grok-cancel-subagent-dialog-hooks.jsonl | 25 +++ ...rok-cancel-subagent-dialog-hooks.meta.json | 11 ++ src/shared/agent-hook-listener.ts | 20 ++- .../agent-hook-listener/listener-state.ts | 12 ++ .../providers/grok-events.ts | 104 +++-------- .../providers/grok-task-inventory.ts | 157 ++++++++++++++++ src/shared/agent-interrupt-intent.ts | 13 ++ 16 files changed, 496 insertions(+), 102 deletions(-) rename src/main/agent-hooks/{claude-cancel-capture.test-fixture.ts => agent-cancel-capture.test-fixture.ts} (90%) create mode 100644 src/main/agent-hooks/server-grok-cancel-captures.test.ts create mode 100644 src/shared/__fixtures__/grok-cancel-subagent-dialog-hooks.jsonl create mode 100644 src/shared/__fixtures__/grok-cancel-subagent-dialog-hooks.meta.json create mode 100644 src/shared/agent-hook-listener/providers/grok-task-inventory.ts diff --git a/src/main/agent-hooks/claude-cancel-capture.test-fixture.ts b/src/main/agent-hooks/agent-cancel-capture.test-fixture.ts similarity index 90% rename from src/main/agent-hooks/claude-cancel-capture.test-fixture.ts rename to src/main/agent-hooks/agent-cancel-capture.test-fixture.ts index 839145a352c2..cbe380e62a5e 100644 --- a/src/main/agent-hooks/claude-cancel-capture.test-fixture.ts +++ b/src/main/agent-hooks/agent-cancel-capture.test-fixture.ts @@ -1,6 +1,6 @@ -// Loads the Claude Code 2.1.280 cancel captures (src/shared/__fixtures__/claude-cancel-*-hooks.jsonl, -// sidecars beside them): hook payloads recorded over a real PTY, merged in time order with the -// driver's cancel and kill markers. +// Loads the agent-CLI cancel captures (src/shared/__fixtures__/claude-cancel-*-hooks.jsonl and +// grok-cancel-*-hooks.jsonl, sidecars beside them): hook payloads recorded over a real PTY, +// merged in time order with the driver's cancel and kill markers. import { readFileSync } from 'node:fs' import { join } from 'node:path' import { AGENT_INTERRUPT_SETTLE_MS } from '../../shared/agent-interrupt-intent' 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 196c41fbea7f..59ae09dca505 100644 --- a/src/main/agent-hooks/server-claude-cancel-captures.test.ts +++ b/src/main/agent-hooks/server-claude-cancel-captures.test.ts @@ -17,7 +17,7 @@ import { hookSupersedesCancel, loadCapture, type CapturedHook -} from './claude-cancel-capture.test-fixture' +} from './agent-cancel-capture.test-fixture' const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ getCohortAtEmitMock: vi.fn(), diff --git a/src/main/agent-hooks/server-grok-cancel-captures.test.ts b/src/main/agent-hooks/server-grok-cancel-captures.test.ts new file mode 100644 index 000000000000..5a1bec6e0dbc --- /dev/null +++ b/src/main/agent-hooks/server-grok-cancel-captures.test.ts @@ -0,0 +1,169 @@ +// A cancel never hides live work. These stories replay hook payloads recorded from Grok 1.0.41 +// over a real PTY (src/shared/__fixtures__/grok-cancel-subagent-dialog-hooks.jsonl, sidecar +// beside it) through the server's own HTTP ingress, and stand in for the renderer at the moments +// the driver pressed a cancel key. The capture established that Esc mid-turn fires no hook (the +// turn keeps running), that Ctrl+C with subagents running opens a dialog and fires nothing until +// answered, that `stop_cancelled` carries NO backgroundTasks inventory, and that a killed +// subagent's only trace is its own SessionEnd (sessionId equal to SubagentStart's subagentId); +// the rules below are written against those payloads, not a remembered screen. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AgentHookServer, _internals } from './server' +import { buildBody, PANE, postHookEvent } from './server.test-fixtures' +import { + cancelLabelled, + hookAt, + loadCapture, + type CapturedHook +} from './agent-cancel-capture.test-fixture' + +const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ + getCohortAtEmitMock: vi.fn(), + trackMock: vi.fn() +})) + +vi.mock('../telemetry/client', () => ({ track: trackMock })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: getCohortAtEmitMock })) + +beforeEach(() => { + _internals.resetCachesForTests() + trackMock.mockReset() + getCohortAtEmitMock.mockReset() + getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) +}) + +afterEach(() => vi.restoreAllMocks()) + +const records = loadCapture('grok-cancel-subagent-dialog-hooks') + +async function startServer(): Promise { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + return server +} + +function row(server: AgentHookServer) { + const entry = server.getStatusSnapshotForPane(PANE)[0] + if (!entry) { + throw new Error('the pane has no row') + } + return entry +} + +async function post(server: AgentHookServer, hook: CapturedHook): Promise { + await expect(postHookEvent(server, buildBody(hook.payload), '/hook/grok')).resolves.toMatchObject( + { status: 204 } + ) +} + +async function replay(server: AgentHookServer, indices: number[]): Promise { + for (const index of indices) { + await post(server, hookAt(records, index)) + } +} + +/** The renderer's part of a cancel: capture the row as the baseline and ask the server to infer + * the interrupt, exactly as it would after the settle window. */ +function pressCancelKey(server: AgentHookServer, intent: 'plain-escape' | 'ctrl-c'): boolean { + const baseline = row(server) + return server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: baseline.prompt, + baselineAgentType: 'grok', + intent + }) +} + +describe('a Grok cancel with a background shell and subagent (captured)', () => { + it('never marks the row from a keypress, and folds every turn end with the reported inventory', async () => { + const server = await startServer() + try { + // Turn 1 starts a background shell and a background subagent, then ends. Its `stop` + // (hook 8) lists both; the subagent is agent work, so the settled lead reads working. + await replay(server, [0, 1, 2, 3, 4, 5, 6, 7, 8]) + expect(hookAt(records, 8).payload.backgroundTasks).toMatchObject([ + { type: 'shell', status: 'running' }, + { type: 'subagent', status: 'running' } + ]) + expect(row(server)).toMatchObject({ state: 'working', mainAgent: { state: 'done' } }) + + // Turn 2 runs a foreground command; the lead turn owns the row again. + await replay(server, [9, 10, 11, 12]) + expect(row(server)).toMatchObject({ state: 'working', mainAgent: { state: 'working' } }) + + // Esc mid-turn: the capture shows no main-session hook and live processes — the turn kept + // running. The renderer's plain-escape inference must be refused. + const escCancel = cancelLabelled(records, 'ESC-mid-turn') + expect(escCancel.interrupted_painted).toBe(false) + const beforeEsc = row(server) + expect(pressCancelKey(server, 'plain-escape')).toBe(false) + expect(row(server)).toEqual(beforeEsc) + + // Ctrl+C mid-turn: Grok opened its "Subagents are still running" dialog and fired nothing. + // The keypress proves nothing either way; the dialog was answered "Continue to run". + expect(pressCancelKey(server, 'ctrl-c')).toBe(false) + expect(row(server)).toEqual(beforeEsc) + + // The subagent's own poll (hook 13) maps to nothing. + await replay(server, [13]) + expect(row(server)).toMatchObject({ state: 'working', mainAgent: { state: 'working' } }) + + // Grok's own stop_cancelled (hook 14) carries NO inventory; ps in the capture shows the + // shell and subagent still alive. The cancel verdict lands on the main agent while the + // inventory Grok last reported keeps the row working. + expect(hookAt(records, 14).payload).not.toHaveProperty('backgroundTasks') + expect(hookAt(records, 14).sleep_procs).toHaveLength(2) + await replay(server, [14]) + expect(row(server)).toMatchObject({ + state: 'working', + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + // Why: `interrupted` restates the verdict only on a settled row; the held-open row carries it on `mainAgent.outcome`. + expect(row(server).interrupted).toBeUndefined() + expect(row(server).workingMode).toBeUndefined() + } finally { + server.stop() + } + }) + + it("a killed subagent's own SessionEnd re-derives the row the inventory held open", async () => { + const server = await startServer() + try { + await replay(server, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]) + + // Turn 3, then Ctrl+C answered "Stop running" (default): the same inventory-less + // stop_cancelled. Grok has not yet told the pane the subagent died, so the row still works. + await replay(server, [15, 16]) + expect(pressCancelKey(server, 'ctrl-c')).toBe(false) + await replay(server, [17]) + expect(row(server)).toMatchObject({ + state: 'working', + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + + // The task_complete notification (hook 18) proves nothing about the rest of the inventory. + await replay(server, [18]) + expect(row(server)).toMatchObject({ state: 'working' }) + + // The subagent's SessionEnd (hook 19) drops it from the inventory; only the background + // shell remains, so the cancelled row re-derives to monitoring — through the cancel-verdict + // hold, which releases on Grok's own settled main agent. The child's session id must not + // replace the pane's resume identity. + expect(hookAt(records, 19).payload.subagentType).toBe('general-purpose') + await replay(server, [19]) + expect(row(server)).toMatchObject({ + state: 'working', + workingMode: 'monitoring', + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + expect(row(server).providerSession?.id).toBe(hookAt(records, 8).payload.session_id) + + // The main session's own end settles the pane whatever the inventory says. + await replay(server, [20]) + expect(row(server)).toMatchObject({ state: 'done', mainAgent: { state: 'done' } }) + } finally { + server.stop() + } + }) +}) diff --git a/src/main/agent-hooks/server-grok-cancel.test.ts b/src/main/agent-hooks/server-grok-cancel.test.ts index 62fd17495b4b..c913bc793e60 100644 --- a/src/main/agent-hooks/server-grok-cancel.test.ts +++ b/src/main/agent-hooks/server-grok-cancel.test.ts @@ -1,5 +1,9 @@ -// Grok 1.0.41, measured: Ctrl+C mid-turn fires `stop_cancelled` listing the finite tasks the turn -// left running, and Ctrl+C at the idle prompt kills no background task. +// Grok 1.0.41, measured (src/shared/__fixtures__/grok-cancel-subagent-dialog-hooks.jsonl): +// no keypress cancels a Grok turn by itself — Esc only paints a toast, and Ctrl+C with subagents +// running opens a dialog that can be answered "continue" — so Orca never infers a Grok cancel +// from keys. Every real cancel fires Grok's own `stop_cancelled`, which carries NO backgroundTasks +// inventory, so it folds with the inventory Grok last reported. Ctrl+C at the idle prompt kills +// no background task. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AgentHookServer, _internals } from './server' import { buildBody, PANE } from './server.test-fixtures' @@ -93,7 +97,7 @@ describe('a Grok cancel never hides a running task', () => { } }) - it("shows the task when Grok's own cancel hook trails the inferred cancel", async () => { + it('refuses a key-inferred cancel mid-turn and folds the inventory-less stop_cancelled', async () => { const server = new AgentHookServer() await server.start({ env: 'production' }) try { @@ -105,24 +109,28 @@ describe('a Grok cancel never hides a running task', () => { }) expect(row(server)).toMatchObject({ state: 'working', mainAgent: { state: 'working' } }) - // Why: the inference can win the settle race; the row cannot see the task behind a working main agent. - expect(pressCtrlC(server)).toBe(true) - expect(row(server)).toMatchObject({ - state: 'done', - mainAgent: { state: 'done', outcome: 'cancellation' } - }) + // Why: Ctrl+C mid-turn can open Grok's subagents dialog and cancel nothing; only Grok's + // own hook is cancel evidence. + const before = row(server) + expect(pressCtrlC(server)).toBe(false) + expect(row(server)).toEqual(before) + // Grok's real stop_cancelled carries no backgroundTasks key; the fold keeps the + // inventory the last `stop` reported instead of settling the row. await postGrokHook(server, { hookEventName: 'stop_cancelled', promptId: 'prompt-2', - stopHookActive: false, - backgroundTasks: [RUNNING_TASK] + reason: 'user_interrupt', + cancelledBy: 'user', + cancelTrigger: 'ctrl_c' }) expect(row(server)).toMatchObject({ state: 'working', workingMode: 'monitoring', mainAgent: { state: 'done', outcome: 'cancellation' } }) + // Why: `interrupted` restates the verdict only on a settled row; a held-open row carries it on `mainAgent.outcome`. + expect(row(server).interrupted).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 ce93f46f8861..11d9deb08d82 100644 --- a/src/main/agent-hooks/server-relayed-claude-cancel.test.ts +++ b/src/main/agent-hooks/server-relayed-claude-cancel.test.ts @@ -9,7 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { RelayAgentHookServer } from '../../relay/agent-hook-server' import { AgentHookServer, _internals } from './server' import { buildBody, PANE } from './server.test-fixtures' -import { hookAt, loadCapture, type CapturedHook } from './claude-cancel-capture.test-fixture' +import { hookAt, loadCapture, type CapturedHook } from './agent-cancel-capture.test-fixture' const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ getCohortAtEmitMock: vi.fn(), diff --git a/src/main/agent-hooks/server/server-status-inference.ts b/src/main/agent-hooks/server/server-status-inference.ts index 7dbe26208900..5973a76ffeea 100644 --- a/src/main/agent-hooks/server/server-status-inference.ts +++ b/src/main/agent-hooks/server/server-status-inference.ts @@ -6,6 +6,7 @@ import { markCodexLeadTurnInterrupted } from '../../../shared/agent-hook-listene import { isAgentInterruptInputIntent, isNavigationEscapeIntent, + providerReportsOwnCancel, requiresDoubleEscapeInterrupt, type AgentInterruptInferenceRequest } from '../../../shared/agent-interrupt-intent' @@ -46,6 +47,11 @@ export abstract class AgentHookServerStatusInference extends AgentHookServerRowO if (agentType === 'droid' && request.intent === 'ctrl-c') { return false } + // Why: re-checked here, not only in the renderer, so a stale or direct inference request + // cannot fabricate a cancel onto an agent whose own hook is the only cancel evidence. + if (providerReportsOwnCancel(agentType)) { + return false + } // Why: these agents use the first Escape as a TUI cancel that can leave the turn running; only a double Escape infers an interrupt. if (requiresDoubleEscapeInterrupt(agentType, request.intent) && request.inputCount !== 2) { return false diff --git a/src/main/agent-hooks/server/server-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts index a37cbb02eb44..ee376b928112 100644 --- a/src/main/agent-hooks/server/server-status-update.ts +++ b/src/main/agent-hooks/server/server-status-update.ts @@ -99,17 +99,22 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA ) } : terminalOwnedPayload - const previousCodexRoot = - stateReconciledPayload.payload.agentType === 'codex' && + const childAttributedRootAgentType = + stateReconciledPayload.payload.agentType === 'codex' || + stateReconciledPayload.payload.agentType === 'grok' + ? stateReconciledPayload.payload.agentType + : undefined + const previousChildAttributedRoot = + childAttributedRootAgentType && stateReconciledPayload.toolAgentId && - previous?.payload.agentType === 'codex' + previous?.payload.agentType === childAttributedRootAgentType ? previous : undefined const preservedProviderSession = !stateReconciledPayload.providerSession - ? previousCodexRoot?.providerSession + ? previousChildAttributedRoot?.providerSession : undefined const preservedRootModel = !stateReconciledPayload.payload.model - ? previousCodexRoot?.payload.model + ? previousChildAttributedRoot?.payload.model : undefined // Why: an SSH relay restart forgets root-only fields; child hooks must not erase durable resume/model identity. const rootContextPreservingPayload = diff --git a/src/renderer/src/components/terminal-pane/agent-interrupt-inference.test.ts b/src/renderer/src/components/terminal-pane/agent-interrupt-inference.test.ts index 920b9f55405d..a160ecbb5338 100644 --- a/src/renderer/src/components/terminal-pane/agent-interrupt-inference.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-interrupt-inference.test.ts @@ -216,6 +216,27 @@ describe('agent interrupt inference', () => { entry = undefined }) + // Why: Grok reports its own cancels (stop_cancelled); Esc never cancels and Ctrl+C can open a + // dialog that keeps the turn running, so neither keypress may request an inference. + it.each(['plain-escape', 'ctrl-c'] as const)('does not infer %s for Grok', (intent) => { + vi.useFakeTimers() + let entry: AgentStatusEntry | undefined = makeEntry({ agentType: 'grok' }) + const inferInterrupt = vi.fn() + const tracker = createAgentInterruptInference({ + paneKey: PANE_KEY, + getStatusEntry: () => entry, + inferInterrupt, + now: () => 1_100 + }) + + tracker.observeInputIntent(intent) + vi.advanceTimersByTime(500) + + expect(inferInterrupt).not.toHaveBeenCalled() + tracker.dispose() + entry = undefined + }) + it.each(['opencode', 'opencode2', 'copilot'] as const)( 'infers immediately on double Escape for %s', (agentType) => { diff --git a/src/renderer/src/components/terminal-pane/agent-interrupt-inference.ts b/src/renderer/src/components/terminal-pane/agent-interrupt-inference.ts index 0b39565b35c4..c5768411c201 100644 --- a/src/renderer/src/components/terminal-pane/agent-interrupt-inference.ts +++ b/src/renderer/src/components/terminal-pane/agent-interrupt-inference.ts @@ -5,6 +5,7 @@ import { import { AGENT_INTERRUPT_SETTLE_MS, isNavigationEscapeIntent, + providerReportsOwnCancel, requiresDoubleEscapeInterrupt, type AgentInterruptInferenceRequest, type AgentInterruptInputIntent @@ -54,7 +55,9 @@ function shouldIgnoreInterruptIntent( agentType: AgentStatusEntry['agentType'], intent: AgentInterruptInputIntent ): boolean { - return agentType === 'droid' && intent === 'ctrl-c' + // Why: Droid's Ctrl+C exits the CLI (PTY lifecycle handles it); an agent that reports its own + // cancels leaves nothing for a keypress to prove on either intent. + return (agentType === 'droid' && intent === 'ctrl-c') || providerReportsOwnCancel(agentType) } /** Why: skip a round-trip main will refuse anyway. Scoped to 'working' so Claude's diff --git a/src/shared/__fixtures__/grok-cancel-subagent-dialog-hooks.jsonl b/src/shared/__fixtures__/grok-cancel-subagent-dialog-hooks.jsonl new file mode 100644 index 000000000000..14e99df58ee9 --- /dev/null +++ b/src/shared/__fixtures__/grok-cancel-subagent-dialog-hooks.jsonl @@ -0,0 +1,25 @@ +{"t": 0.0, "kind": "hook", "index": 0, "sleep_procs": [], "payload": {"hookEventName": "session_start", "sessionId": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "workspaceRoot": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "timestamp": "2026-09-24T20:32:20.672441+00:00", "permissionMode": "bypassPermissions", "source": "new", "hook_event_name": "SessionStart", "session_id": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "permission_mode": "bypassPermissions"}} +{"t": 5.291, "kind": "hook", "index": 1, "sleep_procs": [], "payload": {"hookEventName": "user_prompt_submit", "sessionId": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "workspaceRoot": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "timestamp": "2026-09-24T20:32:25.962196+00:00", "transcriptPath": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "promptId": "b6fce791-fd48-4b0e-a20c-ca8130c8c240", "permissionMode": "bypassPermissions", "prompt": "QA harness step 1: make exactly two tool calls, then end your turn. (1) run_terminal_command with background set to true and command exactly `sleep 375`. (2) spawn_subagent with run_in_background true and this exact prompt: 'Run this exact command in the foreground with run_terminal_command, wait for it to finish, then reply done: sleep 314'. Do not wait for or poll either task. Reply with exactly the word STARTED and end your turn immediately.", "hook_event_name": "UserPromptSubmit", "session_id": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "transcript_path": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "permission_mode": "bypassPermissions"}} +{"t": 12.524, "kind": "hook", "index": 2, "sleep_procs": [], "payload": {"hookEventName": "pre_tool_use", "sessionId": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "workspaceRoot": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "timestamp": "2026-09-24T20:32:33.153978+00:00", "transcriptPath": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "permissionMode": "bypassPermissions", "toolName": "run_terminal_command", "toolUseId": "call-73a5d7da-7209-472d-acd6-7776cf81251d-0", "toolInput": {"command": "sleep 375", "description": "Background sleep for QA harness step 1", "timeout": 0, "background": true}, "toolInputTruncated": false, "hook_event_name": "PreToolUse", "session_id": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "transcript_path": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "permission_mode": "bypassPermissions", "tool_name": "run_terminal_command", "tool_input": {"command": "sleep 375", "description": "Background sleep for QA harness step 1", "timeout": 0, "background": true}, "tool_use_id": "call-73a5d7da-7209-472d-acd6-7776cf81251d-0"}} +{"t": 12.816, "kind": "hook", "index": 3, "sleep_procs": [], "payload": {"hookEventName": "pre_tool_use", "sessionId": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "workspaceRoot": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "timestamp": "2026-09-24T20:32:33.545281+00:00", "transcriptPath": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "permissionMode": "bypassPermissions", "toolName": "spawn_subagent", "toolUseId": "call-73a5d7da-7209-472d-acd6-7776cf81251d-1", "toolInput": {"description": "Foreground sleep then done", "prompt": "Run this exact command in the foreground with run_terminal_command, wait for it to finish, then reply done: sleep 314", "background": true}, "toolInputTruncated": false, "hook_event_name": "PreToolUse", "session_id": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "transcript_path": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "permission_mode": "bypassPermissions", "tool_name": "spawn_subagent", "tool_input": {"description": "Foreground sleep then done", "prompt": "Run this exact command in the foreground with run_terminal_command, wait for it to finish, then reply done: sleep 314", "background": true}, "tool_use_id": "call-73a5d7da-7209-472d-acd6-7776cf81251d-1"}} +{"t": 13.143, "kind": "hook", "index": 4, "sleep_procs": ["67236 67220 S 00:01 sleep 375"], "payload": {"hookEventName": "post_tool_use", "sessionId": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "workspaceRoot": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "timestamp": "2026-09-24T20:32:33.866237+00:00", "transcriptPath": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "permissionMode": "bypassPermissions", "toolName": "spawn_subagent", "toolUseId": "call-73a5d7da-7209-472d-acd6-7776cf81251d-1", "toolInput": {"description": "Foreground sleep then done", "prompt": "Run this exact command in the foreground with run_terminal_command, wait for it to finish, then reply done: sleep 314", "background": true}, "toolResult": {"type": "Text", "text": "Subagent started in background.\nsubagent_id: 01a0d51e-b238-7320-b72a-2da75aabf851\ndescription: Foreground sleep then done\n\nWhen you need its result, use get_command_or_subagent_output with task_ids=[\"01a0d51e-b238-7320-b72a-2da75aabf851\"] and a positive timeout_ms."}, "toolInputTruncated": false, "toolResultTruncated": false, "durationMs": 17, "isBackgrounded": false, "hook_event_name": "PostToolUse", "session_id": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "transcript_path": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "permission_mode": "bypassPermissions", "tool_name": "spawn_subagent", "tool_input": {"description": "Foreground sleep then done", "prompt": "Run this exact command in the foreground with run_terminal_command, wait for it to finish, then reply done: sleep 314", "background": true}, "tool_response": {"type": "Text", "text": "Subagent started in background.\nsubagent_id: 01a0d51e-b238-7320-b72a-2da75aabf851\ndescription: Foreground sleep then done\n\nWhen you need its result, use get_command_or_subagent_output with task_ids=[\"01a0d51e-b238-7320-b72a-2da75aabf851\"] and a positive timeout_ms."}, "tool_use_id": "call-73a5d7da-7209-472d-acd6-7776cf81251d-1", "duration_ms": 17}} +{"t": 13.172, "kind": "hook", "index": 5, "sleep_procs": ["67236 67220 S 00:01 sleep 375"], "payload": {"hookEventName": "subagent_start", "sessionId": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "workspaceRoot": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "timestamp": "2026-09-24T20:32:33.891749+00:00", "transcriptPath": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "permissionMode": "bypassPermissions", "subagentId": "01a0d51e-b238-7320-b72a-2da75aabf851", "subagentType": "general-purpose", "description": "Foreground sleep then done", "hook_event_name": "SubagentStart", "session_id": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "transcript_path": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "permission_mode": "bypassPermissions"}} +{"t": 13.324, "kind": "hook", "index": 6, "sleep_procs": ["67236 67220 S 00:01 sleep 375"], "payload": {"hookEventName": "user_prompt_submit", "sessionId": "01a0d51e-b238-7320-b72a-2da75aabf851", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "workspaceRoot": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "timestamp": "2026-09-24T20:32:34.066968+00:00", "promptId": "01a0d51e-b2fb-7903-803f-37f81ec6b97d", "permissionMode": "bypassPermissions", "prompt": "Run this exact command in the foreground with run_terminal_command, wait for it to finish, then reply done: sleep 314", "subagentType": "general-purpose", "hook_event_name": "UserPromptSubmit", "session_id": "01a0d51e-b238-7320-b72a-2da75aabf851", "permission_mode": "bypassPermissions"}} +{"t": 13.422, "kind": "hook", "index": 7, "sleep_procs": ["67236 67220 S 00:01 sleep 375"], "payload": {"hookEventName": "post_tool_use", "sessionId": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "workspaceRoot": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "timestamp": "2026-09-24T20:32:34.154199+00:00", "transcriptPath": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "permissionMode": "bypassPermissions", "toolName": "run_terminal_command", "toolUseId": "call-73a5d7da-7209-472d-acd6-7776cf81251d-0", "toolInput": {"command": "sleep 375", "description": "Background sleep for QA harness step 1", "timeout": 0, "background": true}, "toolResult": {"type": "BackgroundTaskStarted", "task_id": "01a0d51e-b249-74c2-affa-9d146d6fd871", "task_type": "bash", "output_file": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/terminal/call-73a5d7da-7209-472d-acd6-7776cf81251d-0.log", "status": "running", "command": "sleep 375", "summary": "Background task 01a0d51e-b249-74c2-affa-9d146d6fd871 started", "retrieval_hint": "Use get_command_or_subagent_output with task_ids=[\"01a0d51e-b249-74c2-affa-9d146d6fd871\"] when you need the output.", "pid": 67220}, "toolInputTruncated": false, "toolResultTruncated": false, "durationMs": 27, "isBackgrounded": false, "hook_event_name": "PostToolUse", "session_id": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "transcript_path": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "permission_mode": "bypassPermissions", "tool_name": "run_terminal_command", "tool_input": {"command": "sleep 375", "description": "Background sleep for QA harness step 1", "timeout": 0, "background": true}, "tool_response": {"type": "BackgroundTaskStarted", "task_id": "01a0d51e-b249-74c2-affa-9d146d6fd871", "task_type": "bash", "output_file": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/terminal/call-73a5d7da-7209-472d-acd6-7776cf81251d-0.log", "status": "running", "command": "sleep 375", "summary": "Background task 01a0d51e-b249-74c2-affa-9d146d6fd871 started", "retrieval_hint": "Use get_command_or_subagent_output with task_ids=[\"01a0d51e-b249-74c2-affa-9d146d6fd871\"] when you need the output.", "pid": 67220}, "tool_use_id": "call-73a5d7da-7209-472d-acd6-7776cf81251d-0", "duration_ms": 27}} +{"t": 15.95, "kind": "hook", "index": 8, "sleep_procs": ["67236 67220 S 00:03 sleep 375"], "payload": {"hookEventName": "stop", "sessionId": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "workspaceRoot": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "timestamp": "2026-09-24T20:32:36.629092+00:00", "transcriptPath": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "promptId": "b6fce791-fd48-4b0e-a20c-ca8130c8c240", "permissionMode": "bypassPermissions", "reason": "end_turn", "stopHookActive": false, "lastAssistantMessage": "STARTED", "backgroundTasks": [{"id": "01a0d51e-b249-74c2-affa-9d146d6fd871", "type": "shell", "status": "running", "command": "sleep 375"}, {"id": "01a0d51e-b238-7320-b72a-2da75aabf851", "type": "subagent", "status": "running", "description": "Foreground sleep then done", "agentType": "general-purpose"}], "sessionCrons": [], "hook_event_name": "Stop", "session_id": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "transcript_path": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "permission_mode": "bypassPermissions"}} +{"t": 20.047, "kind": "hook", "index": 9, "sleep_procs": ["67236 67220 S 00:07 sleep 375"], "payload": {"hookEventName": "user_prompt_submit", "sessionId": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "workspaceRoot": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "timestamp": "2026-09-24T20:32:40.784164+00:00", "transcriptPath": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "promptId": "4e9542a8-8bf1-470f-ad2d-38175d5d7172", "permissionMode": "bypassPermissions", "prompt": "QA harness step 2: run_terminal_command in the FOREGROUND (background false) with command exactly `sleep 244`, wait for it to finish, then reply with exactly the word OK.", "hook_event_name": "UserPromptSubmit", "session_id": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "transcript_path": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "permission_mode": "bypassPermissions"}} +{"t": 23.401, "kind": "hook", "index": 10, "sleep_procs": ["67236 67220 S 00:11 sleep 375"], "payload": {"hookEventName": "pre_tool_use", "sessionId": "01a0d51e-b238-7320-b72a-2da75aabf851", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "workspaceRoot": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "timestamp": "2026-09-24T20:32:44.139687+00:00", "transcriptPath": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-b238-7320-b72a-2da75aabf851/updates.jsonl", "permissionMode": "bypassPermissions", "toolName": "run_terminal_command", "toolUseId": "call-904a2f95-71c3-405e-8228-984c8d51a056-0", "toolInput": {"command": "sleep 314", "description": "Sleep 314 seconds in the foreground", "timeout": 330000}, "toolInputTruncated": false, "subagentType": "general-purpose", "hook_event_name": "PreToolUse", "session_id": "01a0d51e-b238-7320-b72a-2da75aabf851", "transcript_path": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-b238-7320-b72a-2da75aabf851/updates.jsonl", "permission_mode": "bypassPermissions", "tool_name": "run_terminal_command", "tool_input": {"command": "sleep 314", "description": "Sleep 314 seconds in the foreground", "timeout": 330000}, "tool_use_id": "call-904a2f95-71c3-405e-8228-984c8d51a056-0"}} +{"t": 37.857, "kind": "hook", "index": 11, "sleep_procs": ["67236 67220 S 00:25 sleep 375", "69039 69030 S 00:14 sleep 314"], "payload": {"hookEventName": "pre_tool_use", "sessionId": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "workspaceRoot": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "timestamp": "2026-09-24T20:32:58.494694+00:00", "transcriptPath": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "permissionMode": "bypassPermissions", "toolName": "run_terminal_command", "toolUseId": "call-99b78098-24a7-4a1d-b234-801dad908cc9-2", "toolInput": {"command": "sleep 244", "description": "Foreground sleep for QA harness step 2", "timeout": 300000, "background": false}, "toolInputTruncated": false, "hook_event_name": "PreToolUse", "session_id": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "transcript_path": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "permission_mode": "bypassPermissions", "tool_name": "run_terminal_command", "tool_input": {"command": "sleep 244", "description": "Foreground sleep for QA harness step 2", "timeout": 300000, "background": false}, "tool_use_id": "call-99b78098-24a7-4a1d-b234-801dad908cc9-2"}} +{"t": 38.835, "kind": "hook", "index": 12, "sleep_procs": ["67236 67220 S 00:26 sleep 375", "69039 69030 S 00:15 sleep 314", "71313 71302 S 00:00 sleep 244"], "payload": {"hookEventName": "post_tool_use", "sessionId": "01a0d51e-b238-7320-b72a-2da75aabf851", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "workspaceRoot": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "timestamp": "2026-09-24T20:32:59.536733+00:00", "transcriptPath": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-b238-7320-b72a-2da75aabf851/updates.jsonl", "permissionMode": "bypassPermissions", "toolName": "run_terminal_command", "toolUseId": "call-904a2f95-71c3-405e-8228-984c8d51a056-0", "toolInput": {"command": "sleep 314", "description": "Sleep 314 seconds in the foreground", "timeout": 330000}, "toolResult": {"type": "BackgroundTaskStarted", "task_id": "call-904a2f95-71c3-405e-8228-984c8d51a056-0", "task_type": "bash", "output_file": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-b238-7320-b72a-2da75aabf851/terminal/call-904a2f95-71c3-405e-8228-984c8d51a056-0.log", "status": "running", "command": "sleep 314", "summary": "Command \"Sleep 314 seconds in the foreground\" has been automatically moved to background because it exceeded auto-background timeout limit of 15s. Process is still running.", "retrieval_hint": "Use get_command_or_subagent_output with task_ids=[\"call-904a2f95-71c3-405e-8228-984c8d51a056-0\"] when you need the output.", "pid": 69030}, "toolInputTruncated": false, "toolResultTruncated": false, "durationMs": 15124, "isBackgrounded": false, "subagentType": "general-purpose", "hook_event_name": "PostToolUse", "session_id": "01a0d51e-b238-7320-b72a-2da75aabf851", "transcript_path": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-b238-7320-b72a-2da75aabf851/updates.jsonl", "permission_mode": "bypassPermissions", "tool_name": "run_terminal_command", "tool_input": {"command": "sleep 314", "description": "Sleep 314 seconds in the foreground", "timeout": 330000}, "tool_response": {"type": "BackgroundTaskStarted", "task_id": "call-904a2f95-71c3-405e-8228-984c8d51a056-0", "task_type": "bash", "output_file": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-b238-7320-b72a-2da75aabf851/terminal/call-904a2f95-71c3-405e-8228-984c8d51a056-0.log", "status": "running", "command": "sleep 314", "summary": "Command \"Sleep 314 seconds in the foreground\" has been automatically moved to background because it exceeded auto-background timeout limit of 15s. Process is still running.", "retrieval_hint": "Use get_command_or_subagent_output with task_ids=[\"call-904a2f95-71c3-405e-8228-984c8d51a056-0\"] when you need the output.", "pid": 69030}, "tool_use_id": "call-904a2f95-71c3-405e-8228-984c8d51a056-0", "duration_ms": 15124}} +{"t": 41.105, "kind": "cancel", "label": "ESC-mid-turn", "interrupted_painted": false, "hooks_before_next_typed_prompt": [13, 14]} +{"t": 43.01, "kind": "hook", "index": 13, "sleep_procs": ["67236 67220 S 00:30 sleep 375", "69039 69030 S 00:19 sleep 314", "71313 71302 S 00:04 sleep 244"], "payload": {"hookEventName": "pre_tool_use", "sessionId": "01a0d51e-b238-7320-b72a-2da75aabf851", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "workspaceRoot": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "timestamp": "2026-09-24T20:33:03.684413+00:00", "transcriptPath": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-b238-7320-b72a-2da75aabf851/updates.jsonl", "permissionMode": "bypassPermissions", "toolName": "get_command_or_subagent_output", "toolUseId": "call-031cd906-ee9d-478a-9295-67878d649f16-1", "toolInput": {"task_ids": ["call-904a2f95-71c3-405e-8228-984c8d51a056-0"], "timeout_ms": 330000}, "toolInputTruncated": false, "subagentType": "general-purpose", "hook_event_name": "PreToolUse", "session_id": "01a0d51e-b238-7320-b72a-2da75aabf851", "transcript_path": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-b238-7320-b72a-2da75aabf851/updates.jsonl", "permission_mode": "bypassPermissions", "tool_name": "get_command_or_subagent_output", "tool_input": {"task_ids": ["call-904a2f95-71c3-405e-8228-984c8d51a056-0"], "timeout_ms": 330000}, "tool_use_id": "call-031cd906-ee9d-478a-9295-67878d649f16-1"}} +{"t": 46.426, "kind": "cancel", "label": "CTRL-C-dialog-continue-to-run", "interrupted_painted": true, "hooks_before_next_typed_prompt": [14]} +{"t": 48.092, "kind": "hook", "index": 14, "sleep_procs": ["67236 67220 S 00:36 sleep 375", "69039 69030 S 00:25 sleep 314"], "payload": {"hookEventName": "stop_cancelled", "sessionId": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "workspaceRoot": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "timestamp": "2026-09-24T20:33:08.711269+00:00", "transcriptPath": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "promptId": "4e9542a8-8bf1-470f-ad2d-38175d5d7172", "permissionMode": "bypassPermissions", "reason": "user_interrupt", "cancelledBy": "user", "cancelTrigger": "ctrl_c", "lastAssistantMessage": "I'll run `sleep 244` in the foreground and wait until it finishes.", "hook_event_name": "StopCancelled", "session_id": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "transcript_path": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "permission_mode": "bypassPermissions"}} +{"t": 61.72, "kind": "hook", "index": 15, "sleep_procs": ["67236 67220 S 00:49 sleep 375", "69039 69030 S 00:38 sleep 314"], "payload": {"hookEventName": "user_prompt_submit", "sessionId": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "workspaceRoot": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "timestamp": "2026-09-24T20:33:22.175449+00:00", "transcriptPath": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "promptId": "1f66f610-0ec3-4f3d-b3bd-1d8f82e0fcc3", "permissionMode": "bypassPermissions", "prompt": "QA harness step 3: run_terminal_command in the FOREGROUND (background false) with command exactly `sleep 199`, wait for it to finish, then reply with exactly the word OK.", "hook_event_name": "UserPromptSubmit", "session_id": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "transcript_path": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "permission_mode": "bypassPermissions"}} +{"t": 77.445, "kind": "hook", "index": 16, "sleep_procs": ["67236 67220 S 01:05 sleep 375", "69039 69030 S 00:54 sleep 314"], "payload": {"hookEventName": "pre_tool_use", "sessionId": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "workspaceRoot": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "timestamp": "2026-09-24T20:33:38.175901+00:00", "transcriptPath": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "permissionMode": "bypassPermissions", "toolName": "run_terminal_command", "toolUseId": "call-4b774e97-767e-4643-8643-84bef2251bcb-3", "toolInput": {"command": "sleep 199", "description": "Foreground sleep for QA harness step 3", "timeout": 250000, "background": false}, "toolInputTruncated": false, "hook_event_name": "PreToolUse", "session_id": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "transcript_path": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "permission_mode": "bypassPermissions", "tool_name": "run_terminal_command", "tool_input": {"command": "sleep 199", "description": "Foreground sleep for QA harness step 3", "timeout": 250000, "background": false}, "tool_use_id": "call-4b774e97-767e-4643-8643-84bef2251bcb-3"}} +{"t": 80.669, "kind": "cancel", "label": "CTRL-C-dialog-stop-running", "interrupted_painted": true, "hooks_before_next_typed_prompt": [17, 18, 19, 20, 21]} +{"t": 82.052, "kind": "hook", "index": 17, "sleep_procs": ["67236 67220 S 01:09 sleep 375"], "payload": {"hookEventName": "stop_cancelled", "sessionId": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "workspaceRoot": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "timestamp": "2026-09-24T20:33:42.754037+00:00", "transcriptPath": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "promptId": "1f66f610-0ec3-4f3d-b3bd-1d8f82e0fcc3", "permissionMode": "bypassPermissions", "reason": "user_interrupt", "cancelledBy": "user", "cancelTrigger": "ctrl_c", "lastAssistantMessage": "I'll run `sleep 199` in the foreground and wait until it finishes.", "hook_event_name": "StopCancelled", "session_id": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "transcript_path": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "permission_mode": "bypassPermissions"}} +{"t": 82.124, "kind": "hook", "index": 18, "sleep_procs": ["67236 67220 S 01:10 sleep 375"], "payload": {"hookEventName": "notification", "sessionId": "01a0d51e-b238-7320-b72a-2da75aabf851", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "workspaceRoot": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "timestamp": "2026-09-24T20:33:42.785616+00:00", "transcriptPath": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-b238-7320-b72a-2da75aabf851/updates.jsonl", "permissionMode": "bypassPermissions", "notificationType": "task_complete", "message": "Background task completed: call-904a2f95-71c3-405e-8228-984c8d51a056-0", "level": "info", "hook_event_name": "Notification", "session_id": "01a0d51e-b238-7320-b72a-2da75aabf851", "transcript_path": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-b238-7320-b72a-2da75aabf851/updates.jsonl", "permission_mode": "bypassPermissions"}} +{"t": 82.494, "kind": "hook", "index": 19, "sleep_procs": ["67236 67220 S 01:10 sleep 375"], "payload": {"hookEventName": "session_end", "sessionId": "01a0d51e-b238-7320-b72a-2da75aabf851", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "workspaceRoot": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "timestamp": "2026-09-24T20:33:43.132539+00:00", "transcriptPath": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-b238-7320-b72a-2da75aabf851/updates.jsonl", "permissionMode": "bypassPermissions", "reason": "shutdown", "subagentType": "general-purpose", "hook_event_name": "SessionEnd", "session_id": "01a0d51e-b238-7320-b72a-2da75aabf851", "transcript_path": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-b238-7320-b72a-2da75aabf851/updates.jsonl", "permission_mode": "bypassPermissions"}} +{"t": 98.702, "kind": "hook", "index": 20, "sleep_procs": ["67236 67220 S 01:26 sleep 375"], "payload": {"hookEventName": "session_end", "sessionId": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "workspaceRoot": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "timestamp": "2026-09-24T20:33:59.387975+00:00", "transcriptPath": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "permissionMode": "bypassPermissions", "reason": "shutdown", "hook_event_name": "SessionEnd", "session_id": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "transcript_path": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "permission_mode": "bypassPermissions"}} +{"t": 98.969, "kind": "hook", "index": 21, "sleep_procs": ["67236 67220 S 01:26 sleep 375"], "payload": {"hookEventName": "stop", "sessionId": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "workspaceRoot": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "timestamp": "2026-09-24T20:33:59.716314+00:00", "transcriptPath": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "permissionMode": "bypassPermissions", "reason": "shutdown", "stopHookActive": false, "hook_event_name": "Stop", "session_id": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "transcript_path": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "permission_mode": "bypassPermissions"}} diff --git a/src/shared/__fixtures__/grok-cancel-subagent-dialog-hooks.meta.json b/src/shared/__fixtures__/grok-cancel-subagent-dialog-hooks.meta.json new file mode 100644 index 000000000000..0754d0eb1e41 --- /dev/null +++ b/src/shared/__fixtures__/grok-cancel-subagent-dialog-hooks.meta.json @@ -0,0 +1,11 @@ +{ + "source_scenario": "cancel-with-subagent-dialog", + "grok_version": "grok 1.0.41 (4220f3b224a6) [stable]", + "argv": ["grok", "--always-approve"], + "platform": "darwin", + "cols": 120, + "rows": 40, + "recorded_at": "2026-09-24", + "clock": "t is seconds since the first record; hook records keep the ps snapshot taken inside the hook", + "note": "Grok driven over a real PTY with every hook logged from stdin by a temporary global hook file. Turn 1 starts a background shell (sleep 375) and a background subagent (whose own turn runs sleep 314 in its foreground), then ends: its `stop` lists both in backgroundTasks. Turn 2 runs a foreground sleep 244; Esc mid-turn fires NO hook (the TUI shows a 'Press Ctrl+c to cancel the turn' toast and the turn keeps running); Ctrl+C mid-turn opens the 'Subagents are still running. Stop them?' dialog (still no hook), and answering '2 Continue to run' fires `stop_cancelled` (reason user_interrupt, cancelTrigger ctrl_c) with NO backgroundTasks key while the shell and subagent keep running (ps proves both alive; only the foreground tool died). Turn 3 repeats with sleep 199 and answers '1 Stop running': the same inventory-less `stop_cancelled`, with the subagent and foreground tool already dead at the hook instant, followed by the subagent's `session_end` (subagentType set, sessionId equal to the subagentId from SubagentStart). Nothing restates the inventory until the next turn's stop; quitting fires the main session_end + a stop with reason shutdown. Scrubbed: username replaced by a same-length placeholder." +} diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 73f05c1d7637..5d8b7b0d7039 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -50,9 +50,11 @@ export function normalizeHookPayload( readFirstString(record, ['hook_event_name', 'hookEventName', 'hook_type', 'hookType']) ?? hookPayloadRecord.hook_event_name ?? hookPayloadRecord.hookEventName - // Codex child hooks expose the child's session_id on the parent's pane. + // Codex and Grok child hooks expose the child's session_id on the parent's pane. const providerSession = - source === 'codex' && readString(hookPayloadRecord, 'agent_id') + (source === 'codex' && readString(hookPayloadRecord, 'agent_id')) || + (source === 'grok' && + readFirstString(hookPayloadRecord, ['subagentType', 'subagent_type']) !== undefined) ? null : extractAgentProviderSession(source, hookPayloadRecord) // Why (#21359): the shared OpenCode server stamps every post with its own @@ -193,7 +195,19 @@ export function normalizeHookPayload( grokPromptBoundary: grokActiveTurn ? true : undefined, compactTrigger, toolUseId: readFirstString(hookPayloadRecord, ['tool_use_id', 'toolUseId']), - toolAgentId: readFirstString(hookPayloadRecord, ['agent_id', 'agentId']), + toolAgentId: + readFirstString(hookPayloadRecord, ['agent_id', 'agentId']) ?? + // Why: Grok child hooks carry subagentType; their id is subagentId, or the child's own + // session id (measured equal on the child's SessionEnd, the only signal a killed one leaves). + (source === 'grok' && + readFirstString(hookPayloadRecord, ['subagentType', 'subagent_type']) !== undefined + ? readFirstString(hookPayloadRecord, [ + 'subagentId', + 'subagent_id', + 'sessionId', + 'session_id' + ]) + : undefined), teammateName: source === 'claude' && eventName === 'TeammateIdle' ? readString(hookPayloadRecord, 'teammate_name') diff --git a/src/shared/agent-hook-listener/listener-state.ts b/src/shared/agent-hook-listener/listener-state.ts index 53848ca707a8..aa2445cf8f89 100644 --- a/src/shared/agent-hook-listener/listener-state.ts +++ b/src/shared/agent-hook-listener/listener-state.ts @@ -1,4 +1,5 @@ import type { AgentMainAgentStatus } from '../agent-status-types' +import type { AgentChildWorkKind } from '../agent-status-child-work' import type { ClaudeLeadTurnState, CodexLeadTurnState } from './main-agent-turn-state' export type { ClaudeLeadTurnState, CodexLeadTurnState } from './main-agent-turn-state' @@ -57,6 +58,12 @@ export type HookListenerState = { grokActiveTurnByPaneKey: Map /** The Grok main agent's own state as last published, so its clock keeps continuity across events. */ grokMainAgentStatusByPaneKey: Map + /** Grok's background-task inventory per pane (task id -> kind), as Grok last reported it: a turn + * end carrying `backgroundTasks` restates it whole, SubagentStart/SubagentStop and a child's + * SessionEnd adjust the subagent entries, and a session boundary clears it. Kept because Grok's + * own cancel hook (`stop_cancelled`) carries no inventory (measured, 1.0.41: + * src/shared/__fixtures__/grok-cancel-subagent-dialog-hooks.jsonl). */ + grokBackgroundTasksByPaneKey: Map> /** Muse child-session filter and session-log cursor per pane. */ musePaneStateByPaneKey: Map /** @@ -117,6 +124,7 @@ export function createHookListenerState( codexLeadStateByPaneKey: new Map(), grokActiveTurnByPaneKey: new Map(), grokMainAgentStatusByPaneKey: new Map(), + grokBackgroundTasksByPaneKey: new Map(), musePaneStateByPaneKey: new Map(), opencodeSessionPaneBySessionId: new Map(), lastLaunchTokenByPaneKey: new Map() @@ -207,6 +215,7 @@ export function clearPaneCacheState(state: HookListenerState, paneKey: string): state.codexLeadStateByPaneKey.delete(paneKey) state.grokActiveTurnByPaneKey.delete(paneKey) state.grokMainAgentStatusByPaneKey.delete(paneKey) + state.grokBackgroundTasksByPaneKey.delete(paneKey) state.musePaneStateByPaneKey.delete(paneKey) unbindOpenCodeSessionsOfPane(state, paneKey) deletePaneScopedCacheEntry(state.lastLaunchTokenByPaneKey, paneKey) @@ -285,6 +294,7 @@ export function movePaneCacheState( movePaneScopedMapEntries(state.codexLeadStateByPaneKey, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.grokActiveTurnByPaneKey, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.grokMainAgentStatusByPaneKey, fromPaneKey, toPaneKey) + movePaneScopedMapEntries(state.grokBackgroundTasksByPaneKey, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.musePaneStateByPaneKey, fromPaneKey, toPaneKey) moveOpenCodeSessionBindings(state, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.lastLaunchTokenByPaneKey, fromPaneKey, toPaneKey) @@ -297,6 +307,7 @@ export function clearPaneTurnCacheState(state: HookListenerState, paneKey: strin state.ampCompletedCacheKeys.delete(paneKey) state.grokActiveTurnByPaneKey.delete(paneKey) state.grokMainAgentStatusByPaneKey.delete(paneKey) + state.grokBackgroundTasksByPaneKey.delete(paneKey) } export function deletePaneScopedCacheEntry(map: Map, paneKey: string): void { @@ -339,6 +350,7 @@ export function clearAllListenerCaches(state: HookListenerState): void { state.codexLeadStateByPaneKey.clear() state.grokActiveTurnByPaneKey.clear() state.grokMainAgentStatusByPaneKey.clear() + state.grokBackgroundTasksByPaneKey.clear() state.opencodeSessionPaneBySessionId.clear() state.lastLaunchTokenByPaneKey.clear() } diff --git a/src/shared/agent-hook-listener/providers/grok-events.ts b/src/shared/agent-hook-listener/providers/grok-events.ts index e44d7e097964..80392d376b84 100644 --- a/src/shared/agent-hook-listener/providers/grok-events.ts +++ b/src/shared/agent-hook-listener/providers/grok-events.ts @@ -4,13 +4,13 @@ import { } from '../../agent-status-types' import { isAskUserQuestionTool } from '../../agent-question-answered-intent' import { continueMainAgentStatus, foldAgentLeadStatus } from '../../agent-lead-status-fold' -import type { AgentChildWorkKind } from '../../agent-status-child-work' -import { - agentChildWorkLiveness, - type AgentChildWorkLiveness, - type AgentChildWorkLivenessCandidate -} from '../../agent-status-child-work-liveness' import { clearPaneTurnCacheState, type HookListenerState } from '../listener-state' +import { + grokChildWorkLivenessAfterTurnEnd, + grokIdentityField, + normalizeGrokSubagentLifecycleEvent, + restateGrokTaskInventory +} from './grok-task-inventory' import { normalizeGrokPromptId } from '../listener-limits' import { resolvePrompt, resolveToolState, stripGrokUserQueryWrapper } from '../prompt-fields' import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' @@ -22,33 +22,6 @@ import { isGrokRoutinePermissionPromptNotification } from './grok-tool-fields' -function aliasedField( - payload: Record, - primary: string, - alias: string -): { present: boolean; value?: unknown } { - if (Object.hasOwn(payload, primary)) { - return { present: true, value: payload[primary] } - } - if (Object.hasOwn(payload, alias)) { - return { present: true, value: payload[alias] } - } - return { present: false } -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null -} - -function grokIdentityField( - hookPayload: Record, - primary: string, - alias: string -): string | undefined { - const value = readString(hookPayload, primary) ?? readString(hookPayload, alias) - return value && value.length <= 512 ? value : undefined -} - function isGrokSubagentEvent(hookPayload: Record): boolean { return ( readString(hookPayload, 'subagentType') !== undefined || @@ -97,42 +70,6 @@ function grokTurnEndApplies( ) } -/** A finite `backgroundTasks[]` entry as child work. Grok lists only in-flight tasks, so none - * carries a settled state. Monitors are left out: they can run indefinitely and would hold the - * pane (and silence its completion) forever. */ -function grokFiniteTaskKind(task: unknown): AgentChildWorkKind | null { - if (!isRecord(task)) { - return null - } - return task.type === 'subagent' ? 'agent' : task.type === 'shell' ? 'command' : null -} - -function grokRunningFiniteTasks( - hookPayload: Record -): AgentChildWorkLivenessCandidate[] { - const backgroundTasks = aliasedField(hookPayload, 'backgroundTasks', 'background_tasks') - if (!backgroundTasks.present || !Array.isArray(backgroundTasks.value)) { - return [] - } - return backgroundTasks.value.flatMap((task) => { - const kind = grokFiniteTaskKind(task) - return kind ? [{ kind }] : [] - }) -} - -/** What a turn end leaves running behind the main agent. A background subagent is agent work - * and keeps the pane `working`; a shell, or a still-active stop hook holding the turn, is watch - * work and reads as monitoring. */ -function grokChildWorkLivenessAfterTurnEnd( - hookPayload: Record -): AgentChildWorkLiveness { - const stopHookActive = aliasedField(hookPayload, 'stopHookActive', 'stop_hook_active') - return ( - agentChildWorkLiveness(grokRunningFiniteTasks(hookPayload)) ?? - (stopHookActive.value === true ? 'monitoring' : null) - ) -} - function isGrokSessionBoundary(eventName: unknown, hookPayload: Record): boolean { if (isGrokEvent(eventName, 'session_end')) { return true @@ -149,9 +86,13 @@ export function normalizeGrokEvent( hookPayload: Record, grokHome?: string ): ParsedAgentStatusPayload | null { - // Why: child sessions reuse their parent's pane route; their lifecycle cannot settle the parent. - if (isGrokSubagentEvent(hookPayload)) { - return null + // Why: child sessions reuse their parent's pane route; their lifecycle cannot settle the + // parent, but it maintains the task inventory and may re-derive a row that inventory holds open. + if ( + isGrokSubagentEvent(hookPayload) || + isGrokEvent(eventName, 'subagent_start', 'subagent_stop') + ) { + return normalizeGrokSubagentLifecycleEvent(state, eventName, paneKey, hookPayload) } if (isGrokEvent(eventName, 'session_start')) { // Why: SessionStart resets stale per-turn state but must not create a working row before any prompt/tool event. @@ -222,14 +163,23 @@ export function normalizeGrokEvent( : isGrokEvent(eventName, 'stop_failure') ? ('failure' as const) : undefined - // Why: every turn end reports what it left running, and a task leaves only when it reports its - // own end or the session ends — a cancelled or failed turn with a still-running task reads - // monitoring exactly like a plain `stop`. Only a session boundary settles the pane whatever - // the inventory says. + // Why: a turn end that carries the inventory restates it; `stop_cancelled` carries none + // (measured), so the cancelled turn folds with the inventory Grok last reported — a cancelled + // or failed turn with a still-running task reads monitoring or working exactly like a plain + // `stop`. Only a session boundary settles the pane whatever the inventory says, and it clears + // the inventory with the process that owned it. + if (isTurnEnd) { + restateGrokTaskInventory(state, paneKey, hookPayload) + } + if (sessionBoundary) { + state.grokBackgroundTasksByPaneKey.delete(paneKey) + } const resolution = foldAgentLeadStatus({ leadState, childWorkLiveness: - isTurnEnd && !sessionBoundary ? grokChildWorkLivenessAfterTurnEnd(hookPayload) : null + isTurnEnd && !sessionBoundary + ? grokChildWorkLivenessAfterTurnEnd(state, paneKey, hookPayload) + : null }) const stateName = resolution.stateName const previousMainAgent = state.grokMainAgentStatusByPaneKey.get(paneKey) diff --git a/src/shared/agent-hook-listener/providers/grok-task-inventory.ts b/src/shared/agent-hook-listener/providers/grok-task-inventory.ts new file mode 100644 index 000000000000..f6b98a0b65ce --- /dev/null +++ b/src/shared/agent-hook-listener/providers/grok-task-inventory.ts @@ -0,0 +1,157 @@ +// Grok's per-pane background-task inventory: what Grok last reported running behind the main +// agent, and the child-lifecycle adjustments to it. Kept apart from grok-events so the event +// mapping stays about turn state. Measured against Grok 1.0.41: +// src/shared/__fixtures__/grok-cancel-subagent-dialog-hooks.jsonl. +import { + normalizeAgentStatusPayload, + type ParsedAgentStatusPayload +} from '../../agent-status-types' +import { foldAgentLeadStatus } from '../../agent-lead-status-fold' +import type { AgentChildWorkKind } from '../../agent-status-child-work' +import { + agentChildWorkLiveness, + type AgentChildWorkLiveness, + type AgentChildWorkLivenessCandidate +} from '../../agent-status-child-work-liveness' +import type { HookListenerState } from '../listener-state' +import { resolvePrompt, resolveToolState } from '../prompt-fields' +import { readString } from '../tool-input-preview' +import { isGrokEvent } from '../provider-event-names' + +function aliasedField( + payload: Record, + primary: string, + alias: string +): { present: boolean; value?: unknown } { + if (Object.hasOwn(payload, primary)) { + return { present: true, value: payload[primary] } + } + if (Object.hasOwn(payload, alias)) { + return { present: true, value: payload[alias] } + } + return { present: false } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +export function grokIdentityField( + hookPayload: Record, + primary: string, + alias: string +): string | undefined { + const value = readString(hookPayload, primary) ?? readString(hookPayload, alias) + return value && value.length <= 512 ? value : undefined +} + +/** A finite `backgroundTasks[]` entry as child work. Grok lists only in-flight tasks, so none + * carries a settled state. Monitors are left out: they can run indefinitely and would hold the + * pane (and silence its completion) forever. */ +function grokFiniteTaskKind(task: unknown): AgentChildWorkKind | null { + if (!isRecord(task)) { + return null + } + return task.type === 'subagent' ? 'agent' : task.type === 'shell' ? 'command' : null +} + +/** A hook that carries the `backgroundTasks` key restates the pane's inventory whole; one without + * it (measured: `stop_cancelled`) leaves the last reported inventory standing. */ +export function restateGrokTaskInventory( + state: HookListenerState, + paneKey: string, + hookPayload: Record +): void { + const backgroundTasks = aliasedField(hookPayload, 'backgroundTasks', 'background_tasks') + if (!backgroundTasks.present || !Array.isArray(backgroundTasks.value)) { + return + } + const inventory = new Map() + backgroundTasks.value.forEach((task, index) => { + const kind = grokFiniteTaskKind(task) + if (!kind) { + return + } + const id = isRecord(task) ? readString(task, 'id') : undefined + inventory.set(id ?? `unidentified-task-${index}`, kind) + }) + if (inventory.size === 0) { + state.grokBackgroundTasksByPaneKey.delete(paneKey) + } else { + state.grokBackgroundTasksByPaneKey.set(paneKey, inventory) + } +} + +/** What the turn's end leaves running behind the main agent, from the inventory Grok last + * reported. A background subagent is agent work and keeps the pane `working`; a shell, or a + * still-active stop hook holding the turn, is watch work and reads as monitoring. */ +export function grokChildWorkLivenessAfterTurnEnd( + state: HookListenerState, + paneKey: string, + hookPayload: Record +): AgentChildWorkLiveness { + const stopHookActive = aliasedField(hookPayload, 'stopHookActive', 'stop_hook_active') + const candidates: AgentChildWorkLivenessCandidate[] = [ + ...(state.grokBackgroundTasksByPaneKey.get(paneKey)?.values() ?? []) + ].map((kind) => ({ kind })) + return agentChildWorkLiveness(candidates) ?? (stopHookActive.value === true ? 'monitoring' : null) +} + +/** A child's lifecycle cannot settle the parent, but it does adjust the task inventory: a + * SubagentStart joins it, and a subagent's own end (SubagentStop, or the child's SessionEnd — + * the only signal a killed subagent leaves, its session id equal to the SubagentStart's + * subagentId) drops it. When that inventory is the only thing holding a settled row open, the + * drop re-derives the row so the pane does not keep reporting work Grok no longer runs. */ +export function normalizeGrokSubagentLifecycleEvent( + state: HookListenerState, + eventName: unknown, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + const subagentId = + grokIdentityField(hookPayload, 'subagentId', 'subagent_id') ?? + grokIdentityField(hookPayload, 'sessionId', 'session_id') + if (!subagentId) { + return null + } + if (isGrokEvent(eventName, 'subagent_start')) { + const inventory = + state.grokBackgroundTasksByPaneKey.get(paneKey) ?? new Map() + inventory.set(subagentId, 'agent') + state.grokBackgroundTasksByPaneKey.set(paneKey, inventory) + return null + } + if (!isGrokEvent(eventName, 'subagent_stop', 'subagent_end', 'session_end')) { + return null + } + const inventory = state.grokBackgroundTasksByPaneKey.get(paneKey) + if (!inventory?.delete(subagentId)) { + return null + } + if (inventory.size === 0) { + state.grokBackgroundTasksByPaneKey.delete(paneKey) + } + const mainAgent = state.grokMainAgentStatusByPaneKey.get(paneKey) + // Why: a live lead turn already owns the row; only a row the inventory holds open re-derives. + if (mainAgent?.state !== 'done') { + return null + } + const resolution = foldAgentLeadStatus({ + leadState: 'done', + childWorkLiveness: grokChildWorkLivenessAfterTurnEnd(state, paneKey, hookPayload) + }) + const snapshot = resolveToolState(state, paneKey, {}, { resetOnNewTurn: false }) + return normalizeAgentStatusPayload({ + state: resolution.stateName, + prompt: resolvePrompt(state, paneKey, '', { resetOnNewTurn: false }), + agentType: 'grok', + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + interactivePrompt: snapshot.interactivePrompt, + lastAssistantMessage: snapshot.lastAssistantMessage, + lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput, + ...(resolution.workingMode ? { workingMode: resolution.workingMode } : {}), + ...(mainAgent.outcome === 'cancellation' ? { interrupted: true } : {}), + mainAgent + }) +} diff --git a/src/shared/agent-interrupt-intent.ts b/src/shared/agent-interrupt-intent.ts index 24f6f179e73b..c8759bd1a700 100644 --- a/src/shared/agent-interrupt-intent.ts +++ b/src/shared/agent-interrupt-intent.ts @@ -41,6 +41,19 @@ export function isNavigationEscapeIntent( ) } +// Why: Grok cancels only through its own TUI flow and reports every real cancel itself with a +// `stop_cancelled` hook. Esc mid-turn never cancels (it paints a "Press Ctrl+c to cancel the +// turn" toast and fires no hook), and Ctrl+C with subagents running opens a "Stop them?" dialog +// that keeps the turn running until answered — and can be answered "continue". Measured on +// 1.0.41: src/shared/__fixtures__/grok-cancel-subagent-dialog-hooks.jsonl. So no keypress is +// evidence a Grok turn ended; only Grok's own hook may cancel the row. +const SELF_REPORTED_CANCEL_AGENT_TYPES: ReadonlySet = new Set(['grok']) + +/** True when this agent reports its own cancels, so no keypress may infer one. */ +export function providerReportsOwnCancel(agentType: AgentType | undefined): boolean { + return agentType !== undefined && SELF_REPORTED_CANCEL_AGENT_TYPES.has(agentType) +} + // Why: these TUIs spend the first Escape on a cancel that can leave the turn running — // opencode2 also dismisses its Subagents dock with it — so only the second Escape on the // same turn is evidence of an interrupt. Shared so the renderer gate and the server From 838f277fa6523c5b82b7e32c1de27ed0d89ffa02 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:16:57 -0700 Subject: [PATCH 2/8] fix(agent-status): subscribe Orca's Grok hook to SubagentStart/SubagentStop Live check found the gap: a subagent spawned in the very turn that gets cancelled is in no stop inventory yet, and Orca's managed Grok hook did not subscribe to subagent lifecycle events, so the cancel settled the row while the subagent ran. The managed config now registers both events (install rewrites managed entries wholesale on every startup, so existing installs converge without a migration), and the same-turn spawn-then-cancel flow is pinned. --- .../agent-hooks/server-grok-cancel.test.ts | 45 +++++++++++++++++++ src/main/grok/grok-hook-config.ts | 4 ++ src/main/grok/hook-service.test.ts | 5 +++ 3 files changed, 54 insertions(+) diff --git a/src/main/agent-hooks/server-grok-cancel.test.ts b/src/main/agent-hooks/server-grok-cancel.test.ts index c913bc793e60..171e00dac7b0 100644 --- a/src/main/agent-hooks/server-grok-cancel.test.ts +++ b/src/main/agent-hooks/server-grok-cancel.test.ts @@ -135,4 +135,49 @@ describe('a Grok cancel never hides a running task', () => { server.stop() } }) + + // Measured live: a subagent spawned in the very turn that gets cancelled appears in NO stop + // inventory yet; only its SubagentStart hook can put it in the fold. + it('folds a subagent the cancelled turn itself spawned, and settles when it ends', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + await postGrokHook(server, { + hookEventName: 'user_prompt_submit', + promptId: 'prompt-1', + prompt: 'spawn a subagent then run a command' + }) + await postGrokHook(server, { + hookEventName: 'subagent_start', + subagentId: 'sub-1', + subagentType: 'general-purpose' + }) + await postGrokHook(server, { + hookEventName: 'stop_cancelled', + promptId: 'prompt-1', + reason: 'user_interrupt', + cancelledBy: 'user', + cancelTrigger: 'ctrl_c' + }) + expect(row(server)).toMatchObject({ + state: 'working', + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + + // The subagent's own end (child session id equals the subagentId) settles the row. + await postGrokHook(server, { + hookEventName: 'session_end', + reason: 'shutdown', + subagentType: 'general-purpose', + sessionId: 'sub-1' + }) + expect(row(server)).toMatchObject({ + state: 'done', + interrupted: true, + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + } finally { + server.stop() + } + }) }) diff --git a/src/main/grok/grok-hook-config.ts b/src/main/grok/grok-hook-config.ts index 5482ba0accd4..f35d55e104d0 100644 --- a/src/main/grok/grok-hook-config.ts +++ b/src/main/grok/grok-hook-config.ts @@ -15,6 +15,10 @@ export const GROK_EVENTS = [ { eventName: 'StopCancelled', definition: { hooks: [{ type: 'command', command: '' }] } }, { eventName: 'StopFailure', definition: { hooks: [{ type: 'command', command: '' }] } }, { eventName: 'SessionEnd', definition: { hooks: [{ type: 'command', command: '' }] } }, + // Why: the background-task inventory needs a subagent's start and end; a cancelled turn's + // stop_cancelled carries no inventory, and a killed subagent's only other trace is SessionEnd. + { eventName: 'SubagentStart', definition: { hooks: [{ type: 'command', command: '' }] } }, + { eventName: 'SubagentStop', definition: { hooks: [{ type: 'command', command: '' }] } }, { // Why: Orca needs the pre-event to show in-flight tools and detect ask_user_question waits; // PostToolUse arrives only after both states have ended. diff --git a/src/main/grok/hook-service.test.ts b/src/main/grok/hook-service.test.ts index fe6d16a46647..5a89947c5cc8 100644 --- a/src/main/grok/hook-service.test.ts +++ b/src/main/grok/hook-service.test.ts @@ -256,6 +256,8 @@ describe('GrokHookService', () => { 'Stop', 'StopCancelled', 'StopFailure', + 'SubagentStart', + 'SubagentStop', 'UserPromptSubmit' ].sort() ) @@ -270,6 +272,9 @@ describe('GrokHookService', () => { expect(config.hooks.StopCancelled[0].matcher).toBeUndefined() expect(config.hooks.StopFailure[0].matcher).toBeUndefined() expect(config.hooks.Notification[0].matcher).toBeUndefined() + // Why: a Subagent matcher tests the subagent type; the inventory needs every one. + expect(config.hooks.SubagentStart[0].matcher).toBeUndefined() + expect(config.hooks.SubagentStop[0].matcher).toBeUndefined() // Why: assert the shipped helper still matches what install wrote (regression // guard if GROK_TOOL_EVENT_MATCHER drifts from install). expect(getGrokToolEventMatcherForTests()).toBe('.*') From dc5ff0b8d2014bd5720bc81757a95f59df762f32 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:28:53 -0700 Subject: [PATCH 3/8] fix(agent-status): fold Grok's idle restatement with the reported inventory Measured live: idle_prompt fires on 'turn over, user idle' while a subagent and command still run, and the old idle backstop settled the held-open cancelled row. The idle restatement now folds with the same inventory as a turn end; the row settles when the inventory drops the work (subagent end, next reported inventory) or the session ends. --- .../agent-hook-listener-grok-completion.test.ts | 17 +++++++++++++++-- .../providers/grok-events.ts | 6 ++++-- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/shared/agent-hook-listener-grok-completion.test.ts b/src/shared/agent-hook-listener-grok-completion.test.ts index 263c9e01a03f..7bc4c95bb1d3 100644 --- a/src/shared/agent-hook-listener-grok-completion.test.ts +++ b/src/shared/agent-hook-listener-grok-completion.test.ts @@ -130,7 +130,7 @@ describe('Grok completion observations', () => { }) }) - it('keeps the cancellation on the idle backstop that settles a task the cancel left running', () => { + it('keeps a task the cancel left running through the idle restatement, and its verdict', () => { normalize({ hookEventName: 'UserPromptSubmit', sessionId: 's-1', @@ -145,13 +145,26 @@ describe('Grok completion observations', () => { backgroundTasks: [{ id: 'task-1', type: 'shell', status: 'running' }] }) ).toMatchObject({ state: 'working', workingMode: 'monitoring' }) - // A settled row without `interrupted` would announce the cancelled turn as a clean finish. + // Why: idle_prompt fires on "turn over, user idle" even while tasks run (measured live), so it + // restates the verdict without settling the row the inventory holds open. expect( normalize({ hookEventName: 'Notification', sessionId: 's-1', notificationType: 'idle_prompt' }) + ).toMatchObject({ + state: 'working', + workingMode: 'monitoring', + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + // A session boundary settles the pane whatever the inventory says, still reading interrupted. + expect( + normalize({ + hookEventName: 'SessionEnd', + sessionId: 's-1', + reason: 'shutdown' + }) ).toMatchObject({ state: 'done', interrupted: true, diff --git a/src/shared/agent-hook-listener/providers/grok-events.ts b/src/shared/agent-hook-listener/providers/grok-events.ts index 80392d376b84..9d50f385b4fb 100644 --- a/src/shared/agent-hook-listener/providers/grok-events.ts +++ b/src/shared/agent-hook-listener/providers/grok-events.ts @@ -177,7 +177,9 @@ export function normalizeGrokEvent( const resolution = foldAgentLeadStatus({ leadState, childWorkLiveness: - isTurnEnd && !sessionBoundary + // Why: idle_prompt means "turn over, user idle", not "tasks done" (measured: it fires with + // a subagent still running), so the idle restatement folds with the same inventory. + (isTurnEnd || isIdlePrompt) && !sessionBoundary ? grokChildWorkLivenessAfterTurnEnd(state, paneKey, hookPayload) : null }) @@ -220,7 +222,7 @@ export function normalizeGrokEvent( lastAssistantMessage: snapshot.lastAssistantMessage, lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput, ...(resolution.workingMode ? { workingMode: resolution.workingMode } : {}), - // Why: derived from the main agent, so the idle backstop that settles a cancelled turn held open by a task still reads interrupted. + // Why: derived from the main agent, so any later restatement that settles a cancelled turn still reads interrupted. ...(mainAgent.outcome === 'cancellation' ? { interrupted: true } : {}), ...(sessionBoundary ? { sessionBoundary: true } : {}), mainAgent From 09bfb40c44154e8ec0faee04b08456b5ac245ba4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:50:24 -0700 Subject: [PATCH 4/8] fix(agent-hooks): converge WSL Grok hook home --- .../managed-hook-detection-commands.test.ts | 12 ++++- .../managed-hook-detection-commands.ts | 8 +++- src/main/agent-hooks/managed-hook-runtime.ts | 30 ++++--------- src/main/agent-hooks/wsl-hook-fs-adapter.ts | 5 ++- .../wsl-hook-relay-manager.test.ts | 31 ++++++++++++- src/relay/grok-home-probe.ts | 44 +++++++++++++++++++ src/relay/preflight-handler.test.ts | 30 ++++++++++++- src/relay/preflight-handler.ts | 27 ++++-------- src/relay/preflight-shell.ts | 18 ++++++++ src/shared/grok-session-paths.ts | 29 +++++++++++- 10 files changed, 185 insertions(+), 49 deletions(-) create mode 100644 src/relay/grok-home-probe.ts create mode 100644 src/relay/preflight-shell.ts diff --git a/src/main/agent-hooks/managed-hook-detection-commands.test.ts b/src/main/agent-hooks/managed-hook-detection-commands.test.ts index 15f4c041860f..29ad6170e148 100644 --- a/src/main/agent-hooks/managed-hook-detection-commands.test.ts +++ b/src/main/agent-hooks/managed-hook-detection-commands.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest' import { buildManagedHookDetectionCommands, - detectedManagedHookAgents + detectedManagedHookAgents, + readManagedHookDetectionResult } from './managed-hook-detection-commands' describe('managed hook detection commands', () => { @@ -30,4 +31,13 @@ describe('managed hook detection commands', () => { }) expect(commands.find((command) => command.id === 'codex')?.reportVersion).toBeUndefined() }) + + it('accepts only a bounded absolute POSIX Grok home from the relay', () => { + expect( + readManagedHookDetectionResult({ agents: ['grok'], grokHome: '/srv/grok///' }).grokHome + ).toBe('/srv/grok') + expect( + readManagedHookDetectionResult({ agents: ['grok'], grokHome: '../grok' }).grokHome + ).toBeNull() + }) }) diff --git a/src/main/agent-hooks/managed-hook-detection-commands.ts b/src/main/agent-hooks/managed-hook-detection-commands.ts index f9507160e276..edc3698c6379 100644 --- a/src/main/agent-hooks/managed-hook-detection-commands.ts +++ b/src/main/agent-hooks/managed-hook-detection-commands.ts @@ -8,6 +8,7 @@ import { normalizeDisabledTuiAgents } from '../../shared/tui-agent-selection' import type { GlobalSettings } from '../../shared/global-settings-types' import type { TuiAgentDetectionCommand } from '../ipc/tui-agent-detection-commands' import { parseClaudeCliVersion } from '../claude/claude-session-end-hook-capability' +import { normalizeGrokHomePath } from '../../shared/grok-session-paths' export type ManagedHookDetectionSettings = Partial< Pick @@ -49,9 +50,10 @@ export function detectedManagedHookAgents(values: unknown): AgentHookTarget[] { export function readManagedHookDetectionResult(value: unknown): { agents: AgentHookTarget[] claudeVersion: string | null + grokHome: string | null } { if (value === null || typeof value !== 'object') { - return { agents: [], claudeVersion: null } + return { agents: [], claudeVersion: null, grokHome: null } } const agents = detectedManagedHookAgents('agents' in value ? value.agents : null) const versions = 'versions' in value ? value.versions : null @@ -59,10 +61,12 @@ export function readManagedHookDetectionResult(value: unknown): { versions !== null && typeof versions === 'object' && 'claude' in versions ? versions.claude : null + const rawGrokHome = 'grokHome' in value ? value.grokHome : null return { agents, claudeVersion: parseClaudeCliVersion( typeof rawClaudeVersion === 'string' ? rawClaudeVersion : null - ) + ), + grokHome: normalizeGrokHomePath(typeof rawGrokHome === 'string' ? rawGrokHome : '') } } diff --git a/src/main/agent-hooks/managed-hook-runtime.ts b/src/main/agent-hooks/managed-hook-runtime.ts index 99a2c68be89f..57fba7924926 100644 --- a/src/main/agent-hooks/managed-hook-runtime.ts +++ b/src/main/agent-hooks/managed-hook-runtime.ts @@ -10,9 +10,13 @@ import { readManagedHookHostIdentity, scopeManagedHookHostIdentity } from './managed-hook-owner-identity' +import { + defaultGrokHomePath, + GROK_HOME_PATH_MAX_LENGTH, + normalizeGrokHomePath +} from '../../shared/grok-session-paths' const execFileAsync = promisify(execFile) -const GROK_HOME_MAX_LENGTH = 4096 const GROK_HOME_PROBE_TIMEOUT_MS = 8_000 export type ManagedHookInstallSummary = { @@ -20,10 +24,6 @@ export type ManagedHookInstallSummary = { errors: number } -function defaultGrokHome(home: string): string { - return `${home.replace(/\/+$/, '') || home}/.grok` -} - function hasControlCharacter(value: string): boolean { return Array.from(value).some((character) => { const code = character.charCodeAt(0) @@ -31,20 +31,6 @@ function hasControlCharacter(value: string): boolean { }) } -function normalizeGrokHome(candidate: string): string | null { - if ( - candidate.length === 0 || - candidate.length > GROK_HOME_MAX_LENGTH || - candidate !== candidate.trim() || - !candidate.startsWith('/') || - candidate.includes('\\') || - hasControlCharacter(candidate) - ) { - return null - } - return candidate.replace(/\/+$/, '') || '/' -} - function resolveLoginShell(): string { const candidate = process.env.SHELL || userInfo().shell || '/bin/sh' if (!candidate.startsWith('/') || candidate.includes('\\') || hasControlCharacter(candidate)) { @@ -54,7 +40,7 @@ function resolveLoginShell(): string { } export async function resolveRelayGrokHome(home: string, signal?: AbortSignal): Promise { - const fallback = defaultGrokHome(home) + const fallback = defaultGrokHomePath(home) try { const shell = resolveLoginShell() const shellName = basename(shell) @@ -63,10 +49,10 @@ export async function resolveRelayGrokHome(home: string, signal?: AbortSignal): // GROK_HOME without opening two additional SSH exec channels. const { stdout } = await execFileAsync( shell, - [mode, `printenv GROK_HOME | head -c ${GROK_HOME_MAX_LENGTH + 1}`], + [mode, `printenv GROK_HOME | head -c ${GROK_HOME_PATH_MAX_LENGTH + 1}`], { encoding: 'utf8', timeout: GROK_HOME_PROBE_TIMEOUT_MS, signal } ) - return normalizeGrokHome(stdout.split(/\r?\n/, 1)[0] ?? '') ?? fallback + return normalizeGrokHomePath(stdout.split(/\r?\n/, 1)[0] ?? '') ?? fallback } catch { signal?.throwIfAborted() return fallback diff --git a/src/main/agent-hooks/wsl-hook-fs-adapter.ts b/src/main/agent-hooks/wsl-hook-fs-adapter.ts index 71699f57d284..1e5593be4508 100644 --- a/src/main/agent-hooks/wsl-hook-fs-adapter.ts +++ b/src/main/agent-hooks/wsl-hook-fs-adapter.ts @@ -31,6 +31,7 @@ export async function installWslGuestHooks(options: { options let agents let claudeVersion: string | null = null + let grokHomeDir: string | null = null try { const detected = readManagedHookDetectionResult( await mux.request('preflight.detectAgents', { @@ -39,6 +40,7 @@ export async function installWslGuestHooks(options: { ) agents = detected.agents claudeVersion = detected.claudeVersion + grokHomeDir = detected.grokHome } catch (error) { warn( `[agent-hooks] WSL agent detection for '${distro}' failed: ${ @@ -69,7 +71,8 @@ export async function installWslGuestHooks(options: { const remoteAgents = agents.filter((agent) => agent !== 'codex') const results = await installHooks(createWslHookSftpAdapter(mux), guestHome, { agents: remoteAgents, - ...(claudeVersion ? { claudeVersion } : {}) + ...(claudeVersion ? { claudeVersion } : {}), + ...(grokHomeDir ? { grokHomeDir } : {}) }) const failed = results.filter((r) => r.state === 'error').length if (failed > 0) { 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 c2f86ea80081..80eec89a949c 100644 --- a/src/main/agent-hooks/wsl-hook-relay-manager.test.ts +++ b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts @@ -186,9 +186,15 @@ describe('WslHookRelayManager', () => { registerInstallPlugins?: boolean detectedAgents?: string[] claudeVersion?: string + grokHome?: string } = {} ): MultiplexerTransport { - const { registerInstallPlugins = true, detectedAgents = ['codex'], claudeVersion } = options + const { + registerInstallPlugins = true, + detectedAgents = ['codex'], + claudeVersion, + grokHome + } = options const harness = createGuestHarness() harnesses.push(harness) registerWslHookFsHandlers(harness.guestDispatcher, home) @@ -197,7 +203,8 @@ describe('WslHookRelayManager', () => { })) harness.guestDispatcher.onRequest('preflight.detectAgents', async () => ({ agents: detectedAgents, - ...(claudeVersion ? { versions: { claude: claudeVersion } } : {}) + ...(claudeVersion ? { versions: { claude: claudeVersion } } : {}), + ...(grokHome ? { grokHome } : {}) })) // A guest bundle predating the plugin overlay omits this handler (-32601). if (registerInstallPlugins) { @@ -293,6 +300,26 @@ describe('WslHookRelayManager', () => { manager.disposeAll() }) + it('passes the guest login-shell Grok home to the remote installer', async () => { + const installHooks = vi.fn(async () => []) + const { manager, deps } = createManager({ + installHooks, + waitForSentinel: vi.fn(async () => + guestTransport({ detectedAgents: ['grok'], grokHome: '/srv/grok' }) + ) + }) + + await manager.ensureForDistro('Ubuntu') + await vi.waitFor(() => + expect(installHooks).toHaveBeenCalledWith(expect.anything(), home, { + agents: ['grok'], + grokHomeDir: '/srv/grok' + }) + ) + expect(deps.installCodex).not.toHaveBeenCalled() + 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/relay/grok-home-probe.ts b/src/relay/grok-home-probe.ts new file mode 100644 index 000000000000..5b7b63a12598 --- /dev/null +++ b/src/relay/grok-home-probe.ts @@ -0,0 +1,44 @@ +import { execFile } from 'node:child_process' +import { homedir, userInfo } from 'node:os' +import { promisify } from 'node:util' +import path from 'node:path' +import { buildRelayCommandEnv } from './relay-command-env' +import { + defaultGrokHomePath, + GROK_HOME_PATH_MAX_LENGTH, + normalizeGrokHomePath +} from '../shared/grok-session-paths' + +const execFileAsync = promisify(execFile) +const GROK_HOME_PROBE_TIMEOUT_MS = 8_000 + +function hasControlCharacter(value: string): boolean { + return Array.from(value).some((character) => { + const code = character.charCodeAt(0) + return code <= 0x1f || code === 0x7f + }) +} + +export async function resolveGrokHomeForRelay(): Promise { + const fallback = defaultGrokHomePath(homedir()) + try { + const shell = process.env.SHELL || userInfo().shell || '/bin/sh' + if (!shell.startsWith('/') || shell.includes('\\') || hasControlCharacter(shell)) { + return fallback + } + const shellName = path.basename(shell) + const mode = shellName === 'sh' || shellName === 'dash' ? '-c' : '-lc' + const { stdout } = await execFileAsync( + shell, + [mode, `printenv GROK_HOME | head -c ${GROK_HOME_PATH_MAX_LENGTH + 1}`], + { + encoding: 'utf-8', + env: buildRelayCommandEnv(process.env, process.platform), + timeout: GROK_HOME_PROBE_TIMEOUT_MS + } + ) + return normalizeGrokHomePath(stdout.split(/\r?\n/, 1)[0] ?? '') ?? fallback + } catch { + return fallback + } +} diff --git a/src/relay/preflight-handler.test.ts b/src/relay/preflight-handler.test.ts index 211c85bceb32..584b2a582cce 100644 --- a/src/relay/preflight-handler.test.ts +++ b/src/relay/preflight-handler.test.ts @@ -18,7 +18,7 @@ const { isGitBashAvailableMock: vi.fn() })) -vi.mock('child_process', () => { +vi.mock('node:child_process', () => { const execFileWithPromisify = Object.assign(vi.fn(), { [Symbol.for('nodejs.util.promisify.custom')]: execFileAsyncMock }) @@ -240,6 +240,34 @@ describe('hasAbsoluteCommandPath', () => { }) describe('PreflightHandler', () => { + it('reports the login-shell Grok home when Grok is detected', async () => { + execFileAsyncMock.mockImplementation(async (_file, args) => { + const script = String(args[1]) + if (script.includes('GROK_HOME')) { + return { stdout: '/srv/grok\n' } + } + return { stdout: '__ORCA_AGENT_PATH__/home/dev/.local/bin/grok\n' } + }) + const requestHandlers = new Map) => Promise>() + const dispatcher = { + onRequest: vi.fn( + (method: string, handler: (params: Record) => Promise) => { + requestHandlers.set(method, handler) + } + ) + } + new PreflightHandler(dispatcher as never) + + await expect( + requestHandlers.get('preflight.detectAgents')!({ + commands: [{ id: 'grok', cmd: 'grok' }] + }) + ).resolves.toEqual({ + agents: ['grok'], + grokHome: '/srv/grok' + }) + }) + it('reports a requested version from the resolved execution-host binary', async () => { execFileAsyncMock.mockResolvedValue({ stdout: '__ORCA_AGENT_PATH__/home/dev/.local/bin/claude\n' diff --git a/src/relay/preflight-handler.ts b/src/relay/preflight-handler.ts index b84166e76ba4..85736b9faca4 100644 --- a/src/relay/preflight-handler.ts +++ b/src/relay/preflight-handler.ts @@ -1,5 +1,4 @@ import { execFile } from 'node:child_process' -import { userInfo } from 'node:os' import { promisify } from 'node:util' import path, { win32 } from 'node:path' import type { RelayDispatcher } from './dispatcher' @@ -9,6 +8,8 @@ import { isWslAvailableAsync, listWslDistrosAsync } from '../main/wsl' import { isGitBashAvailable } from '../main/git-bash' import { buildPosixCommandPathLookupScript } from '../shared/posix-command-path-lookup' import { runProcess } from '../shared/child-process/run-process' +import { resolveGrokHomeForRelay } from './grok-home-probe' +import { resolveAccountLoginShell } from './preflight-shell' const execFileAsync = promisify(execFile) @@ -59,6 +60,7 @@ export class PreflightHandler { private async detectAgents(params: Record): Promise<{ agents: string[] versions?: Record + grokHome?: string }> { const commands = params.commands as AgentDetectionCommand[] if (!Array.isArray(commands)) { @@ -105,10 +107,14 @@ export class PreflightHandler { versions[command.id] = version } } + const grokHome = detectedCommands.some((command) => command.id === 'grok') + ? await resolveGrokHomeForRelay() + : null return { agents: [...new Set(detectedCommands.map(({ id }) => id))], - ...(Object.keys(versions).length > 0 ? { versions } : {}) + ...(Object.keys(versions).length > 0 ? { versions } : {}), + ...(grokHome ? { grokHome } : {}) } } @@ -298,23 +304,6 @@ function buildFishCommandLookupScript(command: string): string { ].join('\n') } -function resolveAccountLoginShell( - platform: NodeJS.Platform, - accountLoginShell?: string | null -): string | null { - if (accountLoginShell !== undefined) { - return accountLoginShell - } - if (platform === 'win32') { - return null - } - try { - return userInfo().shell ?? null - } catch { - return null - } -} - function pickTrustedPosixShell( env: NodeJS.ProcessEnv, accountLoginShell: string | null diff --git a/src/relay/preflight-shell.ts b/src/relay/preflight-shell.ts new file mode 100644 index 000000000000..d6b4021841fc --- /dev/null +++ b/src/relay/preflight-shell.ts @@ -0,0 +1,18 @@ +import { userInfo } from 'node:os' + +export function resolveAccountLoginShell( + platform: NodeJS.Platform, + accountLoginShell?: string | null +): string | null { + if (accountLoginShell !== undefined) { + return accountLoginShell + } + if (platform === 'win32') { + return null + } + try { + return userInfo().shell ?? null + } catch { + return null + } +} diff --git a/src/shared/grok-session-paths.ts b/src/shared/grok-session-paths.ts index b40db0174b47..4e467fe7c67b 100644 --- a/src/shared/grok-session-paths.ts +++ b/src/shared/grok-session-paths.ts @@ -16,6 +16,7 @@ export { } from './grok-session-path-lookup-queue' export const GROK_CHAT_HISTORY_FILE = 'chat_history.jsonl' +export const GROK_HOME_PATH_MAX_LENGTH = 4096 // Why: Grok URL-encodes the cwd for the sessions group directory. When that // encoded name exceeds 255 bytes it switches to a slug+hash layout. export const GROK_ENCODED_CWD_DIR_MAX_BYTES = 255 @@ -28,6 +29,32 @@ export type GrokSessionPathEnv = | NodeJS.ProcessEnv | Partial> +function hasControlCharacter(value: string): boolean { + return Array.from(value).some((character) => { + const code = character.charCodeAt(0) + return code <= 0x1f || code === 0x7f + }) +} + +/** Validate the POSIX path carried between a remote shell and the installer. */ +export function normalizeGrokHomePath(candidate: string): string | null { + if ( + candidate.length === 0 || + candidate.length > GROK_HOME_PATH_MAX_LENGTH || + candidate !== candidate.trim() || + !candidate.startsWith('/') || + candidate.includes('\\') || + hasControlCharacter(candidate) + ) { + return null + } + return candidate.replace(/\/+$/, '') || '/' +} + +export function defaultGrokHomePath(home: string): string { + return `${home.replace(/\/+$/, '') || home}/.grok` +} + /** Official ids are UUIDs; keep legacy/test token ids while rejecting paths. */ export function isSafeGrokSessionId(sessionId: string): boolean { return ( @@ -42,7 +69,7 @@ export function resolveGrokHomeDir( env: GrokSessionPathEnv = process.env, homeDir: string = homedir() ): string { - return resolveAbsoluteDirOverride(env.GROK_HOME, join(homeDir, '.grok')) + return resolveAbsoluteDirOverride(env.GROK_HOME, defaultGrokHomePath(homeDir)) } export function resolveGrokSessionsDir( From 2fe9e5465c7d3402d49d7cd18b0d47dbcdb3ec83 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:08:18 -0700 Subject: [PATCH 5/8] fix(relay): use guarded process runner for Grok probe --- src/relay/grok-home-probe.ts | 24 +++++++++++------------- src/relay/preflight-handler.test.ts | 16 +++++++++++----- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/src/relay/grok-home-probe.ts b/src/relay/grok-home-probe.ts index 5b7b63a12598..8c8b8a59d1aa 100644 --- a/src/relay/grok-home-probe.ts +++ b/src/relay/grok-home-probe.ts @@ -1,6 +1,4 @@ -import { execFile } from 'node:child_process' import { homedir, userInfo } from 'node:os' -import { promisify } from 'node:util' import path from 'node:path' import { buildRelayCommandEnv } from './relay-command-env' import { @@ -8,8 +6,8 @@ import { GROK_HOME_PATH_MAX_LENGTH, normalizeGrokHomePath } from '../shared/grok-session-paths' +import { runProcess } from '../shared/child-process/run-process' -const execFileAsync = promisify(execFile) const GROK_HOME_PROBE_TIMEOUT_MS = 8_000 function hasControlCharacter(value: string): boolean { @@ -28,16 +26,16 @@ export async function resolveGrokHomeForRelay(): Promise { } const shellName = path.basename(shell) const mode = shellName === 'sh' || shellName === 'dash' ? '-c' : '-lc' - const { stdout } = await execFileAsync( - shell, - [mode, `printenv GROK_HOME | head -c ${GROK_HOME_PATH_MAX_LENGTH + 1}`], - { - encoding: 'utf-8', - env: buildRelayCommandEnv(process.env, process.platform), - timeout: GROK_HOME_PROBE_TIMEOUT_MS - } - ) - return normalizeGrokHomePath(stdout.split(/\r?\n/, 1)[0] ?? '') ?? fallback + const result = await runProcess({ + program: shell, + args: [mode, `printenv GROK_HOME | head -c ${GROK_HOME_PATH_MAX_LENGTH + 1}`], + env: buildRelayCommandEnv(process.env, process.platform), + timeoutMs: GROK_HOME_PROBE_TIMEOUT_MS + }) + if (result.code !== 0 || result.timedOut) { + return fallback + } + return normalizeGrokHomePath(result.stdout.split(/\r?\n/, 1)[0] ?? '') ?? fallback } catch { return fallback } diff --git a/src/relay/preflight-handler.test.ts b/src/relay/preflight-handler.test.ts index 584b2a582cce..08760a1ba147 100644 --- a/src/relay/preflight-handler.test.ts +++ b/src/relay/preflight-handler.test.ts @@ -241,13 +241,19 @@ describe('hasAbsoluteCommandPath', () => { describe('PreflightHandler', () => { it('reports the login-shell Grok home when Grok is detected', async () => { - execFileAsyncMock.mockImplementation(async (_file, args) => { - const script = String(args[1]) - if (script.includes('GROK_HOME')) { - return { stdout: '/srv/grok\n' } - } + execFileAsyncMock.mockImplementation(async () => { return { stdout: '__ORCA_AGENT_PATH__/home/dev/.local/bin/grok\n' } }) + runProcessMock.mockImplementation(async ({ args }: { args?: readonly string[] }) => { + const script = String(args?.[1] ?? '') + return { + code: 0, + signal: null, + stdout: script.includes('GROK_HOME') ? '/srv/grok\n' : '', + stderr: '', + timedOut: false + } + }) const requestHandlers = new Map) => Promise>() const dispatcher = { onRequest: vi.fn( From f9426829d66920bdcd739361e7490a65c8846f4b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:40:09 -0700 Subject: [PATCH 6/8] revert: move the WSL GROK_HOME hook-home fix out of this PR It fixes a separate install bug unrelated to cancel handling; tracked on its own. --- .../managed-hook-detection-commands.test.ts | 12 +----- .../managed-hook-detection-commands.ts | 8 +--- src/main/agent-hooks/managed-hook-runtime.ts | 30 +++++++++---- src/main/agent-hooks/wsl-hook-fs-adapter.ts | 5 +-- .../wsl-hook-relay-manager.test.ts | 31 +------------- src/relay/grok-home-probe.ts | 42 ------------------- src/relay/preflight-handler.test.ts | 36 +--------------- src/relay/preflight-handler.ts | 27 ++++++++---- src/relay/preflight-shell.ts | 18 -------- src/shared/grok-session-paths.ts | 29 +------------ 10 files changed, 49 insertions(+), 189 deletions(-) delete mode 100644 src/relay/grok-home-probe.ts delete mode 100644 src/relay/preflight-shell.ts diff --git a/src/main/agent-hooks/managed-hook-detection-commands.test.ts b/src/main/agent-hooks/managed-hook-detection-commands.test.ts index 29ad6170e148..15f4c041860f 100644 --- a/src/main/agent-hooks/managed-hook-detection-commands.test.ts +++ b/src/main/agent-hooks/managed-hook-detection-commands.test.ts @@ -1,8 +1,7 @@ import { describe, expect, it } from 'vitest' import { buildManagedHookDetectionCommands, - detectedManagedHookAgents, - readManagedHookDetectionResult + detectedManagedHookAgents } from './managed-hook-detection-commands' describe('managed hook detection commands', () => { @@ -31,13 +30,4 @@ describe('managed hook detection commands', () => { }) expect(commands.find((command) => command.id === 'codex')?.reportVersion).toBeUndefined() }) - - it('accepts only a bounded absolute POSIX Grok home from the relay', () => { - expect( - readManagedHookDetectionResult({ agents: ['grok'], grokHome: '/srv/grok///' }).grokHome - ).toBe('/srv/grok') - expect( - readManagedHookDetectionResult({ agents: ['grok'], grokHome: '../grok' }).grokHome - ).toBeNull() - }) }) diff --git a/src/main/agent-hooks/managed-hook-detection-commands.ts b/src/main/agent-hooks/managed-hook-detection-commands.ts index edc3698c6379..f9507160e276 100644 --- a/src/main/agent-hooks/managed-hook-detection-commands.ts +++ b/src/main/agent-hooks/managed-hook-detection-commands.ts @@ -8,7 +8,6 @@ import { normalizeDisabledTuiAgents } from '../../shared/tui-agent-selection' import type { GlobalSettings } from '../../shared/global-settings-types' import type { TuiAgentDetectionCommand } from '../ipc/tui-agent-detection-commands' import { parseClaudeCliVersion } from '../claude/claude-session-end-hook-capability' -import { normalizeGrokHomePath } from '../../shared/grok-session-paths' export type ManagedHookDetectionSettings = Partial< Pick @@ -50,10 +49,9 @@ export function detectedManagedHookAgents(values: unknown): AgentHookTarget[] { export function readManagedHookDetectionResult(value: unknown): { agents: AgentHookTarget[] claudeVersion: string | null - grokHome: string | null } { if (value === null || typeof value !== 'object') { - return { agents: [], claudeVersion: null, grokHome: null } + return { agents: [], claudeVersion: null } } const agents = detectedManagedHookAgents('agents' in value ? value.agents : null) const versions = 'versions' in value ? value.versions : null @@ -61,12 +59,10 @@ export function readManagedHookDetectionResult(value: unknown): { versions !== null && typeof versions === 'object' && 'claude' in versions ? versions.claude : null - const rawGrokHome = 'grokHome' in value ? value.grokHome : null return { agents, claudeVersion: parseClaudeCliVersion( typeof rawClaudeVersion === 'string' ? rawClaudeVersion : null - ), - grokHome: normalizeGrokHomePath(typeof rawGrokHome === 'string' ? rawGrokHome : '') + ) } } diff --git a/src/main/agent-hooks/managed-hook-runtime.ts b/src/main/agent-hooks/managed-hook-runtime.ts index 57fba7924926..99a2c68be89f 100644 --- a/src/main/agent-hooks/managed-hook-runtime.ts +++ b/src/main/agent-hooks/managed-hook-runtime.ts @@ -10,13 +10,9 @@ import { readManagedHookHostIdentity, scopeManagedHookHostIdentity } from './managed-hook-owner-identity' -import { - defaultGrokHomePath, - GROK_HOME_PATH_MAX_LENGTH, - normalizeGrokHomePath -} from '../../shared/grok-session-paths' const execFileAsync = promisify(execFile) +const GROK_HOME_MAX_LENGTH = 4096 const GROK_HOME_PROBE_TIMEOUT_MS = 8_000 export type ManagedHookInstallSummary = { @@ -24,6 +20,10 @@ export type ManagedHookInstallSummary = { errors: number } +function defaultGrokHome(home: string): string { + return `${home.replace(/\/+$/, '') || home}/.grok` +} + function hasControlCharacter(value: string): boolean { return Array.from(value).some((character) => { const code = character.charCodeAt(0) @@ -31,6 +31,20 @@ function hasControlCharacter(value: string): boolean { }) } +function normalizeGrokHome(candidate: string): string | null { + if ( + candidate.length === 0 || + candidate.length > GROK_HOME_MAX_LENGTH || + candidate !== candidate.trim() || + !candidate.startsWith('/') || + candidate.includes('\\') || + hasControlCharacter(candidate) + ) { + return null + } + return candidate.replace(/\/+$/, '') || '/' +} + function resolveLoginShell(): string { const candidate = process.env.SHELL || userInfo().shell || '/bin/sh' if (!candidate.startsWith('/') || candidate.includes('\\') || hasControlCharacter(candidate)) { @@ -40,7 +54,7 @@ function resolveLoginShell(): string { } export async function resolveRelayGrokHome(home: string, signal?: AbortSignal): Promise { - const fallback = defaultGrokHomePath(home) + const fallback = defaultGrokHome(home) try { const shell = resolveLoginShell() const shellName = basename(shell) @@ -49,10 +63,10 @@ export async function resolveRelayGrokHome(home: string, signal?: AbortSignal): // GROK_HOME without opening two additional SSH exec channels. const { stdout } = await execFileAsync( shell, - [mode, `printenv GROK_HOME | head -c ${GROK_HOME_PATH_MAX_LENGTH + 1}`], + [mode, `printenv GROK_HOME | head -c ${GROK_HOME_MAX_LENGTH + 1}`], { encoding: 'utf8', timeout: GROK_HOME_PROBE_TIMEOUT_MS, signal } ) - return normalizeGrokHomePath(stdout.split(/\r?\n/, 1)[0] ?? '') ?? fallback + return normalizeGrokHome(stdout.split(/\r?\n/, 1)[0] ?? '') ?? fallback } catch { signal?.throwIfAborted() return fallback diff --git a/src/main/agent-hooks/wsl-hook-fs-adapter.ts b/src/main/agent-hooks/wsl-hook-fs-adapter.ts index 1e5593be4508..71699f57d284 100644 --- a/src/main/agent-hooks/wsl-hook-fs-adapter.ts +++ b/src/main/agent-hooks/wsl-hook-fs-adapter.ts @@ -31,7 +31,6 @@ export async function installWslGuestHooks(options: { options let agents let claudeVersion: string | null = null - let grokHomeDir: string | null = null try { const detected = readManagedHookDetectionResult( await mux.request('preflight.detectAgents', { @@ -40,7 +39,6 @@ export async function installWslGuestHooks(options: { ) agents = detected.agents claudeVersion = detected.claudeVersion - grokHomeDir = detected.grokHome } catch (error) { warn( `[agent-hooks] WSL agent detection for '${distro}' failed: ${ @@ -71,8 +69,7 @@ export async function installWslGuestHooks(options: { const remoteAgents = agents.filter((agent) => agent !== 'codex') const results = await installHooks(createWslHookSftpAdapter(mux), guestHome, { agents: remoteAgents, - ...(claudeVersion ? { claudeVersion } : {}), - ...(grokHomeDir ? { grokHomeDir } : {}) + ...(claudeVersion ? { claudeVersion } : {}) }) const failed = results.filter((r) => r.state === 'error').length if (failed > 0) { 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 80eec89a949c..c2f86ea80081 100644 --- a/src/main/agent-hooks/wsl-hook-relay-manager.test.ts +++ b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts @@ -186,15 +186,9 @@ describe('WslHookRelayManager', () => { registerInstallPlugins?: boolean detectedAgents?: string[] claudeVersion?: string - grokHome?: string } = {} ): MultiplexerTransport { - const { - registerInstallPlugins = true, - detectedAgents = ['codex'], - claudeVersion, - grokHome - } = options + const { registerInstallPlugins = true, detectedAgents = ['codex'], claudeVersion } = options const harness = createGuestHarness() harnesses.push(harness) registerWslHookFsHandlers(harness.guestDispatcher, home) @@ -203,8 +197,7 @@ describe('WslHookRelayManager', () => { })) harness.guestDispatcher.onRequest('preflight.detectAgents', async () => ({ agents: detectedAgents, - ...(claudeVersion ? { versions: { claude: claudeVersion } } : {}), - ...(grokHome ? { grokHome } : {}) + ...(claudeVersion ? { versions: { claude: claudeVersion } } : {}) })) // A guest bundle predating the plugin overlay omits this handler (-32601). if (registerInstallPlugins) { @@ -300,26 +293,6 @@ describe('WslHookRelayManager', () => { manager.disposeAll() }) - it('passes the guest login-shell Grok home to the remote installer', async () => { - const installHooks = vi.fn(async () => []) - const { manager, deps } = createManager({ - installHooks, - waitForSentinel: vi.fn(async () => - guestTransport({ detectedAgents: ['grok'], grokHome: '/srv/grok' }) - ) - }) - - await manager.ensureForDistro('Ubuntu') - await vi.waitFor(() => - expect(installHooks).toHaveBeenCalledWith(expect.anything(), home, { - agents: ['grok'], - grokHomeDir: '/srv/grok' - }) - ) - expect(deps.installCodex).not.toHaveBeenCalled() - 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/relay/grok-home-probe.ts b/src/relay/grok-home-probe.ts deleted file mode 100644 index 8c8b8a59d1aa..000000000000 --- a/src/relay/grok-home-probe.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { homedir, userInfo } from 'node:os' -import path from 'node:path' -import { buildRelayCommandEnv } from './relay-command-env' -import { - defaultGrokHomePath, - GROK_HOME_PATH_MAX_LENGTH, - normalizeGrokHomePath -} from '../shared/grok-session-paths' -import { runProcess } from '../shared/child-process/run-process' - -const GROK_HOME_PROBE_TIMEOUT_MS = 8_000 - -function hasControlCharacter(value: string): boolean { - return Array.from(value).some((character) => { - const code = character.charCodeAt(0) - return code <= 0x1f || code === 0x7f - }) -} - -export async function resolveGrokHomeForRelay(): Promise { - const fallback = defaultGrokHomePath(homedir()) - try { - const shell = process.env.SHELL || userInfo().shell || '/bin/sh' - if (!shell.startsWith('/') || shell.includes('\\') || hasControlCharacter(shell)) { - return fallback - } - const shellName = path.basename(shell) - const mode = shellName === 'sh' || shellName === 'dash' ? '-c' : '-lc' - const result = await runProcess({ - program: shell, - args: [mode, `printenv GROK_HOME | head -c ${GROK_HOME_PATH_MAX_LENGTH + 1}`], - env: buildRelayCommandEnv(process.env, process.platform), - timeoutMs: GROK_HOME_PROBE_TIMEOUT_MS - }) - if (result.code !== 0 || result.timedOut) { - return fallback - } - return normalizeGrokHomePath(result.stdout.split(/\r?\n/, 1)[0] ?? '') ?? fallback - } catch { - return fallback - } -} diff --git a/src/relay/preflight-handler.test.ts b/src/relay/preflight-handler.test.ts index 08760a1ba147..211c85bceb32 100644 --- a/src/relay/preflight-handler.test.ts +++ b/src/relay/preflight-handler.test.ts @@ -18,7 +18,7 @@ const { isGitBashAvailableMock: vi.fn() })) -vi.mock('node:child_process', () => { +vi.mock('child_process', () => { const execFileWithPromisify = Object.assign(vi.fn(), { [Symbol.for('nodejs.util.promisify.custom')]: execFileAsyncMock }) @@ -240,40 +240,6 @@ describe('hasAbsoluteCommandPath', () => { }) describe('PreflightHandler', () => { - it('reports the login-shell Grok home when Grok is detected', async () => { - execFileAsyncMock.mockImplementation(async () => { - return { stdout: '__ORCA_AGENT_PATH__/home/dev/.local/bin/grok\n' } - }) - runProcessMock.mockImplementation(async ({ args }: { args?: readonly string[] }) => { - const script = String(args?.[1] ?? '') - return { - code: 0, - signal: null, - stdout: script.includes('GROK_HOME') ? '/srv/grok\n' : '', - stderr: '', - timedOut: false - } - }) - const requestHandlers = new Map) => Promise>() - const dispatcher = { - onRequest: vi.fn( - (method: string, handler: (params: Record) => Promise) => { - requestHandlers.set(method, handler) - } - ) - } - new PreflightHandler(dispatcher as never) - - await expect( - requestHandlers.get('preflight.detectAgents')!({ - commands: [{ id: 'grok', cmd: 'grok' }] - }) - ).resolves.toEqual({ - agents: ['grok'], - grokHome: '/srv/grok' - }) - }) - it('reports a requested version from the resolved execution-host binary', async () => { execFileAsyncMock.mockResolvedValue({ stdout: '__ORCA_AGENT_PATH__/home/dev/.local/bin/claude\n' diff --git a/src/relay/preflight-handler.ts b/src/relay/preflight-handler.ts index 85736b9faca4..b84166e76ba4 100644 --- a/src/relay/preflight-handler.ts +++ b/src/relay/preflight-handler.ts @@ -1,4 +1,5 @@ import { execFile } from 'node:child_process' +import { userInfo } from 'node:os' import { promisify } from 'node:util' import path, { win32 } from 'node:path' import type { RelayDispatcher } from './dispatcher' @@ -8,8 +9,6 @@ import { isWslAvailableAsync, listWslDistrosAsync } from '../main/wsl' import { isGitBashAvailable } from '../main/git-bash' import { buildPosixCommandPathLookupScript } from '../shared/posix-command-path-lookup' import { runProcess } from '../shared/child-process/run-process' -import { resolveGrokHomeForRelay } from './grok-home-probe' -import { resolveAccountLoginShell } from './preflight-shell' const execFileAsync = promisify(execFile) @@ -60,7 +59,6 @@ export class PreflightHandler { private async detectAgents(params: Record): Promise<{ agents: string[] versions?: Record - grokHome?: string }> { const commands = params.commands as AgentDetectionCommand[] if (!Array.isArray(commands)) { @@ -107,14 +105,10 @@ export class PreflightHandler { versions[command.id] = version } } - const grokHome = detectedCommands.some((command) => command.id === 'grok') - ? await resolveGrokHomeForRelay() - : null return { agents: [...new Set(detectedCommands.map(({ id }) => id))], - ...(Object.keys(versions).length > 0 ? { versions } : {}), - ...(grokHome ? { grokHome } : {}) + ...(Object.keys(versions).length > 0 ? { versions } : {}) } } @@ -304,6 +298,23 @@ function buildFishCommandLookupScript(command: string): string { ].join('\n') } +function resolveAccountLoginShell( + platform: NodeJS.Platform, + accountLoginShell?: string | null +): string | null { + if (accountLoginShell !== undefined) { + return accountLoginShell + } + if (platform === 'win32') { + return null + } + try { + return userInfo().shell ?? null + } catch { + return null + } +} + function pickTrustedPosixShell( env: NodeJS.ProcessEnv, accountLoginShell: string | null diff --git a/src/relay/preflight-shell.ts b/src/relay/preflight-shell.ts deleted file mode 100644 index d6b4021841fc..000000000000 --- a/src/relay/preflight-shell.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { userInfo } from 'node:os' - -export function resolveAccountLoginShell( - platform: NodeJS.Platform, - accountLoginShell?: string | null -): string | null { - if (accountLoginShell !== undefined) { - return accountLoginShell - } - if (platform === 'win32') { - return null - } - try { - return userInfo().shell ?? null - } catch { - return null - } -} diff --git a/src/shared/grok-session-paths.ts b/src/shared/grok-session-paths.ts index 4e467fe7c67b..b40db0174b47 100644 --- a/src/shared/grok-session-paths.ts +++ b/src/shared/grok-session-paths.ts @@ -16,7 +16,6 @@ export { } from './grok-session-path-lookup-queue' export const GROK_CHAT_HISTORY_FILE = 'chat_history.jsonl' -export const GROK_HOME_PATH_MAX_LENGTH = 4096 // Why: Grok URL-encodes the cwd for the sessions group directory. When that // encoded name exceeds 255 bytes it switches to a slug+hash layout. export const GROK_ENCODED_CWD_DIR_MAX_BYTES = 255 @@ -29,32 +28,6 @@ export type GrokSessionPathEnv = | NodeJS.ProcessEnv | Partial> -function hasControlCharacter(value: string): boolean { - return Array.from(value).some((character) => { - const code = character.charCodeAt(0) - return code <= 0x1f || code === 0x7f - }) -} - -/** Validate the POSIX path carried between a remote shell and the installer. */ -export function normalizeGrokHomePath(candidate: string): string | null { - if ( - candidate.length === 0 || - candidate.length > GROK_HOME_PATH_MAX_LENGTH || - candidate !== candidate.trim() || - !candidate.startsWith('/') || - candidate.includes('\\') || - hasControlCharacter(candidate) - ) { - return null - } - return candidate.replace(/\/+$/, '') || '/' -} - -export function defaultGrokHomePath(home: string): string { - return `${home.replace(/\/+$/, '') || home}/.grok` -} - /** Official ids are UUIDs; keep legacy/test token ids while rejecting paths. */ export function isSafeGrokSessionId(sessionId: string): boolean { return ( @@ -69,7 +42,7 @@ export function resolveGrokHomeDir( env: GrokSessionPathEnv = process.env, homeDir: string = homedir() ): string { - return resolveAbsoluteDirOverride(env.GROK_HOME, defaultGrokHomePath(homeDir)) + return resolveAbsoluteDirOverride(env.GROK_HOME, join(homeDir, '.grok')) } export function resolveGrokSessionsDir( From 3dccd7fc9064bf05f5d7166f21cd6df8ffeab154 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:30:31 -0700 Subject: [PATCH 7/8] fix(agent-status): settle a held-open Grok row on its own task_complete hook A Grok cancel arms a wake barrier: a background shell that outlives the cancel finishes without a follow-up turn or `stop`, so the row kept reading "monitoring" after the work was gone. Grok fires a task_complete Notification for every finished task; the pane's inventory now drops that id and re-derives a row the inventory held open, sharing one path with the subagent-end drop. A main-session PostToolUse whose result is a bash BackgroundTaskStarted adds the shell, so a shell started in the cancelled turn itself holds the row too. --- .../server-grok-cancel-captures.test.ts | 53 +++++- .../agent-hooks/server-grok-cancel.test.ts | 61 +++++++ ...-hook-listener-grok-task-inventory.test.ts | 171 ++++++++++++++++++ .../agent-hook-listener/listener-state.ts | 11 +- .../providers/grok-events.ts | 8 +- .../providers/grok-task-inventory.ts | 115 +++++++++--- 6 files changed, 384 insertions(+), 35 deletions(-) create mode 100644 src/shared/agent-hook-listener-grok-task-inventory.test.ts diff --git a/src/main/agent-hooks/server-grok-cancel-captures.test.ts b/src/main/agent-hooks/server-grok-cancel-captures.test.ts index 5a1bec6e0dbc..2e5e9a728b9d 100644 --- a/src/main/agent-hooks/server-grok-cancel-captures.test.ts +++ b/src/main/agent-hooks/server-grok-cancel-captures.test.ts @@ -61,6 +61,15 @@ async function replay(server: AgentHookServer, indices: number[]): Promise } } +function shellTaskId(payload: Record): string { + const tasks = payload.backgroundTasks + const shell = Array.isArray(tasks) ? tasks.find((task) => task?.type === 'shell') : undefined + if (typeof shell?.id !== 'string') { + throw new Error('the capture lists no shell task') + } + return shell.id +} + /** The renderer's part of a cancel: capture the row as the baseline and ask the server to infer * the interrupt, exactly as it would after the settle window. */ function pressCancelKey(server: AgentHookServer, intent: 'plain-escape' | 'ctrl-c'): boolean { @@ -142,7 +151,7 @@ describe('a Grok cancel with a background shell and subagent (captured)', () => mainAgent: { state: 'done', outcome: 'cancellation' } }) - // The task_complete notification (hook 18) proves nothing about the rest of the inventory. + // The child's own task_complete (hook 18) names a task the pane's inventory never held. await replay(server, [18]) expect(row(server)).toMatchObject({ state: 'working' }) @@ -166,4 +175,46 @@ describe('a Grok cancel with a background shell and subagent (captured)', () => server.stop() } }) + + // Why: a cancel arms Grok's wake barrier, so the surviving shell's end runs no follow-up turn + // and no `stop`; its own task_complete Notification is the only hook that says it is gone. + it("the surviving shell's own task_complete settles the cancelled row", async () => { + const server = await startServer() + try { + await replay( + server, + Array.from({ length: 20 }, (_, index) => index) + ) + expect(row(server)).toMatchObject({ + state: 'working', + workingMode: 'monitoring', + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + + // Grok's task_complete for the main session's shell: the captured child notification's + // shape (hook 18), re-addressed to the main session and the shell hook 8 listed. + const main = hookAt(records, 17).payload + const shellId = shellTaskId(hookAt(records, 8).payload) + await post(server, { + ...hookAt(records, 18), + payload: { + ...hookAt(records, 18).payload, + sessionId: main.sessionId, + session_id: main.session_id, + transcriptPath: main.transcriptPath, + transcript_path: main.transcript_path, + message: `Background task completed: ${shellId}` + } + }) + expect(row(server)).toMatchObject({ + state: 'done', + interrupted: true, + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + expect(row(server).workingMode).toBeUndefined() + expect(row(server).providerSession?.id).toBe(main.session_id) + } finally { + server.stop() + } + }) }) diff --git a/src/main/agent-hooks/server-grok-cancel.test.ts b/src/main/agent-hooks/server-grok-cancel.test.ts index 171e00dac7b0..4554c263076c 100644 --- a/src/main/agent-hooks/server-grok-cancel.test.ts +++ b/src/main/agent-hooks/server-grok-cancel.test.ts @@ -180,4 +180,65 @@ describe('a Grok cancel never hides a running task', () => { server.stop() } }) + + // Grok 1.0.41: a shell started in the turn that gets cancelled is in no `stop` inventory yet, + // survives the cancel, and its end wakes no follow-up turn; only its own start and end hooks + // bracket it. Shapes copied from the captured PostToolUse (hook 7) and task_complete (hook 18). + it('folds a shell the cancelled turn itself started, and settles on its task_complete', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + await postGrokHook(server, { + hookEventName: 'user_prompt_submit', + promptId: 'prompt-1', + prompt: 'start a background shell then run a command' + }) + const started = { + type: 'BackgroundTaskStarted', + task_id: 'task-7', + task_type: 'bash', + status: 'running', + command: 'sleep 375' + } + await postGrokHook(server, { + hookEventName: 'post_tool_use', + toolName: 'run_terminal_command', + toolUseId: 'call-1', + toolInput: { command: 'sleep 375', background: true }, + toolResult: started, + hook_event_name: 'PostToolUse', + tool_name: 'run_terminal_command', + tool_response: started + }) + expect(row(server)).toMatchObject({ state: 'working', mainAgent: { state: 'working' } }) + expect(row(server).workingMode).toBeUndefined() + + await postGrokHook(server, { + hookEventName: 'stop_cancelled', + promptId: 'prompt-1', + reason: 'user_interrupt', + cancelledBy: 'user', + cancelTrigger: 'ctrl_c' + }) + expect(row(server)).toMatchObject({ + state: 'working', + workingMode: 'monitoring', + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + + await postGrokHook(server, { + hookEventName: 'notification', + notificationType: 'task_complete', + message: 'Background task completed: task-7', + level: 'info' + }) + expect(row(server)).toMatchObject({ + state: 'done', + interrupted: true, + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + } finally { + server.stop() + } + }) }) diff --git a/src/shared/agent-hook-listener-grok-task-inventory.test.ts b/src/shared/agent-hook-listener-grok-task-inventory.test.ts new file mode 100644 index 000000000000..0c641b3bca4c --- /dev/null +++ b/src/shared/agent-hook-listener-grok-task-inventory.test.ts @@ -0,0 +1,171 @@ +// Grok's background-task inventory between `stop` reports: a shell's own start (PostToolUse +// BackgroundTaskStarted) and end (Notification task_complete) hooks. Measured on 1.0.41: +// src/shared/__fixtures__/grok-cancel-subagent-dialog-hooks.jsonl and +// grok-background-completion-hooks.jsonl. +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { beforeEach, describe, expect, it } from 'vitest' +import { normalizeHookPayload } from './agent-hook-listener' +import { + createHookListenerState, + type HookListenerState +} from './agent-hook-listener/listener-state' +import { PANE_KEY } from './agent-hook-listener-test-harness' + +function readCapture(name: string): unknown[] { + return readFileSync(join(__dirname, '__fixtures__', `${name}.jsonl`), 'utf8') + .trim() + .split('\n') + .map((raw): unknown => JSON.parse(raw)) +} + +function isPayload(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function capturedHookPayload(index: number): Record { + const line = readCapture('grok-cancel-subagent-dialog-hooks').find( + (record) => + typeof record === 'object' && + record !== null && + 'kind' in record && + record.kind === 'hook' && + 'index' in record && + record.index === index + ) + const payload = + typeof line === 'object' && line !== null && 'payload' in line ? line.payload : undefined + if (!isPayload(payload)) { + throw new Error(`Captured Grok hook ${index} not found`) + } + return payload +} + +const taskComplete = (taskId: string) => ({ + hookEventName: 'notification', + sessionId: 's-1', + notificationType: 'task_complete', + message: `Background task completed: ${taskId}`, + level: 'info' +}) + +describe('Grok background-task inventory between stop reports', () => { + let state: HookListenerState + + beforeEach(() => { + state = createHookListenerState() + }) + + function normalize(payload: Record) { + return normalizeHookPayload(state, 'grok', { paneKey: PANE_KEY, payload }, 'production') + ?.payload + } + + function settleWithShell(promptId: string): void { + normalize({ hookEventName: 'user_prompt_submit', sessionId: 's-1', promptId, prompt: 'go' }) + expect( + normalize({ + hookEventName: 'stop', + sessionId: 's-1', + promptId, + reason: 'end_turn', + backgroundTasks: [ + { id: 'task-1', type: 'shell', status: 'running', command: 'sleep 30' }, + { id: 'monitor-1', type: 'monitor', status: 'running', description: 'watch' } + ] + }) + ).toMatchObject({ state: 'working', workingMode: 'monitoring' }) + } + + it('drops a shell that completes during a live turn without changing the row', () => { + settleWithShell('prompt-1') + normalize({ + hookEventName: 'user_prompt_submit', + sessionId: 's-1', + promptId: 'prompt-2', + prompt: 'more' + }) + // The live turn owns the row; the completion only updates the inventory. + expect(normalize(taskComplete('task-1'))).toBeUndefined() + // The inventory-less cancel then finds nothing left running. + expect( + normalize({ + hookEventName: 'stop_cancelled', + sessionId: 's-1', + promptId: 'prompt-2', + reason: 'user_interrupt' + }) + ).toMatchObject({ + state: 'done', + interrupted: true, + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + }) + + it('ignores a completion for a task the inventory never held, and for a monitor', () => { + settleWithShell('prompt-1') + expect(normalize(taskComplete('monitor-1'))).toBeUndefined() + expect(normalize(taskComplete('task-unknown'))).toBeUndefined() + expect( + normalize({ ...taskComplete('task-1'), message: 'Background task finished: task-1' }) + ).toBeUndefined() + // The shell still holds the settled row. + expect( + normalize({ + hookEventName: 'notification', + sessionId: 's-1', + notificationType: 'idle_prompt' + }) + ).toMatchObject({ state: 'working', workingMode: 'monitoring' }) + // Its own completion settles it; a plain stop carries no cancel verdict. + const settled = normalize(taskComplete('task-1')) + expect(settled).toMatchObject({ state: 'done', mainAgent: { state: 'done' } }) + expect(settled?.interrupted).toBeUndefined() + }) + + it("does not add a subagent's own background shell to the parent's inventory", () => { + const childStarted = capturedHookPayload(12) + expect(childStarted.subagentType).toBe('general-purpose') + expect(childStarted.toolResult).toMatchObject({ + type: 'BackgroundTaskStarted', + task_type: 'bash' + }) + normalize({ + hookEventName: 'user_prompt_submit', + sessionId: 's-1', + promptId: 'prompt-1', + prompt: 'go' + }) + expect(normalize(childStarted)).toBeUndefined() + expect( + normalize({ + hookEventName: 'stop_cancelled', + sessionId: 's-1', + promptId: 'prompt-1', + reason: 'user_interrupt' + }) + ).toMatchObject({ state: 'done', interrupted: true }) + }) + + // Why: Grok may run no follow-up turn after a completion (a cancel, a goal loop, auto-wake off), + // so the task's own end settles the row; when it does wake, the woken turn reopens it. + it('settles on the captured task_complete and reopens for the woken follow-up turn', () => { + const states = readCapture('grok-background-completion-hooks') + .filter(isPayload) + .slice(0, 8) + .map((payload) => { + const row = normalize(payload) + return [payload.hookEventName, payload.notificationType, row?.state, row?.workingMode] + }) + expect(states).toEqual([ + ['session_start', undefined, undefined, undefined], + ['user_prompt_submit', undefined, 'working', undefined], + ['pre_tool_use', undefined, 'working', undefined], + ['post_tool_use', undefined, 'working', undefined], + ['stop', undefined, 'working', 'monitoring'], + ['notification', 'task_complete', 'done', undefined], + ['user_prompt_submit', undefined, 'working', undefined], + ['stop', undefined, 'done', undefined] + ]) + }) +}) diff --git a/src/shared/agent-hook-listener/listener-state.ts b/src/shared/agent-hook-listener/listener-state.ts index aa2445cf8f89..2b266d00d174 100644 --- a/src/shared/agent-hook-listener/listener-state.ts +++ b/src/shared/agent-hook-listener/listener-state.ts @@ -58,11 +58,12 @@ export type HookListenerState = { grokActiveTurnByPaneKey: Map /** The Grok main agent's own state as last published, so its clock keeps continuity across events. */ grokMainAgentStatusByPaneKey: Map - /** Grok's background-task inventory per pane (task id -> kind), as Grok last reported it: a turn - * end carrying `backgroundTasks` restates it whole, SubagentStart/SubagentStop and a child's - * SessionEnd adjust the subagent entries, and a session boundary clears it. Kept because Grok's - * own cancel hook (`stop_cancelled`) carries no inventory (measured, 1.0.41: - * src/shared/__fixtures__/grok-cancel-subagent-dialog-hooks.jsonl). */ + /** Grok's background-task inventory per pane (task id -> kind): a turn end carrying + * `backgroundTasks` restates it whole; a shell's BackgroundTaskStarted result or a SubagentStart + * adds an entry, and its own task_complete, SubagentStop or child SessionEnd drops it; a session + * boundary or pane close clears it. Kept because Grok's own cancel hook (`stop_cancelled`) + * carries no inventory and a cancel suppresses the follow-up turn whose `stop` would restate it + * (measured, 1.0.41: src/shared/__fixtures__/grok-cancel-subagent-dialog-hooks.jsonl). */ grokBackgroundTasksByPaneKey: Map> /** Muse child-session filter and session-log cursor per pane. */ musePaneStateByPaneKey: Map diff --git a/src/shared/agent-hook-listener/providers/grok-events.ts b/src/shared/agent-hook-listener/providers/grok-events.ts index 9d50f385b4fb..4823922c4eb5 100644 --- a/src/shared/agent-hook-listener/providers/grok-events.ts +++ b/src/shared/agent-hook-listener/providers/grok-events.ts @@ -9,6 +9,8 @@ import { grokChildWorkLivenessAfterTurnEnd, grokIdentityField, normalizeGrokSubagentLifecycleEvent, + normalizeGrokTaskCompleteNotification, + recordGrokBackgroundTaskStarted, restateGrokTaskInventory } from './grok-task-inventory' import { normalizeGrokPromptId } from '../listener-limits' @@ -104,6 +106,9 @@ export function normalizeGrokEvent( if (isGrokEvent(eventName, 'user_prompt_submit')) { recordGrokTurn(state, paneKey, hookPayload) } + if (isGrokEvent(eventName, 'post_tool_use')) { + recordGrokBackgroundTaskStarted(state, paneKey, hookPayload) + } const notificationMessage = readString(hookPayload, 'message') const notificationType = getGrokNotificationType(hookPayload) @@ -137,8 +142,7 @@ export function normalizeGrokEvent( isGrokEvent(eventName, 'notification') && isGrokEvent(notificationType, 'task_complete') ) { - // Why: one task finishing does not prove that every finite task and follow-up turn settled. - return null + return normalizeGrokTaskCompleteNotification(state, paneKey, hookPayload) } else if ( isGrokEvent(eventName, 'notification') && isGrokRoutinePermissionPromptNotification( diff --git a/src/shared/agent-hook-listener/providers/grok-task-inventory.ts b/src/shared/agent-hook-listener/providers/grok-task-inventory.ts index f6b98a0b65ce..6e74bc9700c5 100644 --- a/src/shared/agent-hook-listener/providers/grok-task-inventory.ts +++ b/src/shared/agent-hook-listener/providers/grok-task-inventory.ts @@ -1,7 +1,8 @@ // Grok's per-pane background-task inventory: what Grok last reported running behind the main -// agent, and the child-lifecycle adjustments to it. Kept apart from grok-events so the event -// mapping stays about turn state. Measured against Grok 1.0.41: -// src/shared/__fixtures__/grok-cancel-subagent-dialog-hooks.jsonl. +// agent, plus each task's own start and end hooks between reports. Kept apart from grok-events so +// the event mapping stays about turn state. Measured against Grok 1.0.41: +// src/shared/__fixtures__/grok-cancel-subagent-dialog-hooks.jsonl and +// grok-background-completion-hooks.jsonl. import { normalizeAgentStatusPayload, type ParsedAgentStatusPayload @@ -36,13 +37,15 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null } +const MAX_GROK_ID_LENGTH = 512 + export function grokIdentityField( hookPayload: Record, primary: string, alias: string ): string | undefined { const value = readString(hookPayload, primary) ?? readString(hookPayload, alias) - return value && value.length <= 512 ? value : undefined + return value && value.length <= MAX_GROK_ID_LENGTH ? value : undefined } /** A finite `backgroundTasks[]` entry as child work. Grok lists only in-flight tasks, so none @@ -97,35 +100,49 @@ export function grokChildWorkLivenessAfterTurnEnd( return agentChildWorkLiveness(candidates) ?? (stopHookActive.value === true ? 'monitoring' : null) } -/** A child's lifecycle cannot settle the parent, but it does adjust the task inventory: a - * SubagentStart joins it, and a subagent's own end (SubagentStop, or the child's SessionEnd — - * the only signal a killed subagent leaves, its session id equal to the SubagentStart's - * subagentId) drops it. When that inventory is the only thing holding a settled row open, the - * drop re-derives the row so the pane does not keep reporting work Grok no longer runs. */ -export function normalizeGrokSubagentLifecycleEvent( +// Why: Grok's own literal for its task_complete Notification; the id after it is the task's id. +const GROK_TASK_COMPLETE_MESSAGE_PREFIX = 'Background task completed: ' + +function addGrokInventoryTask( + state: HookListenerState, + paneKey: string, + taskId: string, + kind: AgentChildWorkKind +): void { + const inventory = + state.grokBackgroundTasksByPaneKey.get(paneKey) ?? new Map() + inventory.set(taskId, kind) + state.grokBackgroundTasksByPaneKey.set(paneKey, inventory) +} + +/** A main-session background shell joins the inventory at its start, so a turn cancelled before + * any `stop` restated it still folds with it. Monitors return a different result and stay out. */ +export function recordGrokBackgroundTaskStarted( state: HookListenerState, - eventName: unknown, paneKey: string, hookPayload: Record -): ParsedAgentStatusPayload | null { - const subagentId = - grokIdentityField(hookPayload, 'subagentId', 'subagent_id') ?? - grokIdentityField(hookPayload, 'sessionId', 'session_id') - if (!subagentId) { - return null - } - if (isGrokEvent(eventName, 'subagent_start')) { - const inventory = - state.grokBackgroundTasksByPaneKey.get(paneKey) ?? new Map() - inventory.set(subagentId, 'agent') - state.grokBackgroundTasksByPaneKey.set(paneKey, inventory) - return null +): void { + const result = hookPayload.toolResult ?? hookPayload.toolResponse ?? hookPayload.tool_response + if (!isRecord(result) || result.type !== 'BackgroundTaskStarted' || result.task_type !== 'bash') { + return } - if (!isGrokEvent(eventName, 'subagent_stop', 'subagent_end', 'session_end')) { - return null + const taskId = readString(result, 'task_id') + if (!taskId || taskId.length > MAX_GROK_ID_LENGTH) { + return } + addGrokInventoryTask(state, paneKey, taskId, 'command') +} + +/** Drops a task that reported its own end. When the inventory was all that held a settled row + * open, re-derives the row: a cancel suppresses the follow-up turn whose `stop` would. */ +function dropGrokFinishedTask( + state: HookListenerState, + paneKey: string, + taskId: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { const inventory = state.grokBackgroundTasksByPaneKey.get(paneKey) - if (!inventory?.delete(subagentId)) { + if (!inventory?.delete(taskId)) { return null } if (inventory.size === 0) { @@ -155,3 +172,47 @@ export function normalizeGrokSubagentLifecycleEvent( mainAgent }) } + +/** Grok fires task_complete for every finished shell or monitor, woken or not. Only an id the + * inventory holds changes anything; child-owned tasks and monitors never enter it. */ +export function normalizeGrokTaskCompleteNotification( + state: HookListenerState, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + const message = readString(hookPayload, 'message') + if (!message?.startsWith(GROK_TASK_COMPLETE_MESSAGE_PREFIX)) { + return null + } + const taskId = message.slice(GROK_TASK_COMPLETE_MESSAGE_PREFIX.length).trim() + if (!taskId || taskId.length > MAX_GROK_ID_LENGTH) { + return null + } + return dropGrokFinishedTask(state, paneKey, taskId, hookPayload) +} + +/** A child's lifecycle cannot settle the parent, but it does adjust the task inventory: a + * SubagentStart joins it, and a subagent's own end (SubagentStop, or the child's SessionEnd — + * the only signal a killed subagent leaves, its session id equal to the SubagentStart's + * subagentId) drops it. */ +export function normalizeGrokSubagentLifecycleEvent( + state: HookListenerState, + eventName: unknown, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + const subagentId = + grokIdentityField(hookPayload, 'subagentId', 'subagent_id') ?? + grokIdentityField(hookPayload, 'sessionId', 'session_id') + if (!subagentId) { + return null + } + if (isGrokEvent(eventName, 'subagent_start')) { + addGrokInventoryTask(state, paneKey, subagentId, 'agent') + return null + } + if (!isGrokEvent(eventName, 'subagent_stop', 'subagent_end', 'session_end')) { + return null + } + return dropGrokFinishedTask(state, paneKey, subagentId, hookPayload) +} From dcb3162ef4a91d056d4fb44e50a041d442cc3883 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:03:57 -0700 Subject: [PATCH 8/8] fix(agent-status): announce a Grok turn that goes idle with work still running Folding Grok's idle_prompt with the task inventory kept a row honest while a shell or subagent runs, but it also removed the only event that announced a turn that leaves long-running work behind (a dev server): the row stayed working and the user never heard the turn finished. The idle restatement now stamps the main agent's finished turn with turnCompletedAt (its own done clock) while child work holds the row open, the same field Claude's held-open turns carry. The renderer already announces a stamped working row once and pairs the later all-clear with it, so the turn announces when Grok goes idle and the task_complete / subagent-end all-clear stays silent. Later restatements of that turn carry the stamp; a new prompt drops it with the main agent's done state; a cancelled turn earns none. The stamp is earned at idle, not at the stop: Grok wakes itself when its background work ends and that woken turn is the one that announces, so stamping the stop would ping twice for one request. --- .../agent-hooks/server-grok-cancel.test.ts | 28 ++ ...tion-background-turn-notifications.test.ts | 261 +++++++++++++----- ...-hook-listener-grok-task-inventory.test.ts | 31 ++- .../agent-hook-listener/listener-state.ts | 9 +- .../main-agent-turn-state.ts | 8 +- .../providers/grok-events.ts | 40 ++- .../providers/grok-task-inventory.ts | 7 +- 7 files changed, 302 insertions(+), 82 deletions(-) diff --git a/src/main/agent-hooks/server-grok-cancel.test.ts b/src/main/agent-hooks/server-grok-cancel.test.ts index 4554c263076c..b13a69a96be6 100644 --- a/src/main/agent-hooks/server-grok-cancel.test.ts +++ b/src/main/agent-hooks/server-grok-cancel.test.ts @@ -242,3 +242,31 @@ describe('a Grok cancel never hides a running task', () => { } }) }) + +describe('a plain Grok stop a task holds open', () => { + it('earns its turn stamp at the idle restatement and pairs the all-clear with it', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + await startTaskThenSettle(server) + expect(row(server).turnCompletedAt).toBeUndefined() + + await postGrokHook(server, { hookEventName: 'notification', notificationType: 'idle_prompt' }) + expect(row(server)).toMatchObject({ state: 'working', workingMode: 'monitoring' }) + const turnCompletedAt = row(server).turnCompletedAt + expect(turnCompletedAt).toEqual(expect.any(Number)) + expect(turnCompletedAt).toBe(row(server).mainAgent?.stateStartedAt) + + await postGrokHook(server, { + hookEventName: 'notification', + notificationType: 'task_complete', + message: 'Background task completed: task-1', + level: 'info' + }) + expect(row(server)).toMatchObject({ state: 'done', turnCompletedAt }) + expect(row(server).interrupted).toBeUndefined() + } finally { + server.stop() + } + }) +}) diff --git a/src/renderer/src/hooks/agent-hook-completion-background-turn-notifications.test.ts b/src/renderer/src/hooks/agent-hook-completion-background-turn-notifications.test.ts index 256ae62d0581..aa7081d6f24b 100644 --- a/src/renderer/src/hooks/agent-hook-completion-background-turn-notifications.test.ts +++ b/src/renderer/src/hooks/agent-hook-completion-background-turn-notifications.test.ts @@ -58,87 +58,90 @@ function pinStateStartedAt( } } -describe('Claude background-turn completion notifications', () => { - beforeEach(() => { - vi.resetModules() - vi.useFakeTimers() - vi.setSystemTime(1_700_000_000_000) - dispatchTerminalNotification.mockClear() - dispatchAgentHookTerminalLifecycle.mockClear() - mockStoreState = { - settings: { - experimentalTerminalAttention: false, - notifications: { - enabled: true, - agentTaskComplete: true - } - }, - ptyIdsByTabId: { - 'tab-1': ['pty-1'] - }, - suppressedPtyExitIds: {}, - tabsByWorktree: { - [WORKTREE_ID]: [{ id: 'tab-1', ptyId: 'pty-1' }] - }, - terminalLayoutsByTabId: {}, - agentLaunchConfigByPaneKey: {}, - agentStatusByPaneKey: {}, - getAgentLaunchConfigForStatusEntry: () => undefined - } - }) +beforeEach(() => { + vi.resetModules() + vi.useFakeTimers() + vi.setSystemTime(1_700_000_000_000) + dispatchTerminalNotification.mockClear() + dispatchAgentHookTerminalLifecycle.mockClear() + mockStoreState = { + settings: { + experimentalTerminalAttention: false, + notifications: { + enabled: true, + agentTaskComplete: true + } + }, + ptyIdsByTabId: { + 'tab-1': ['pty-1'] + }, + suppressedPtyExitIds: {}, + tabsByWorktree: { + [WORKTREE_ID]: [{ id: 'tab-1', ptyId: 'pty-1' }] + }, + terminalLayoutsByTabId: {}, + agentLaunchConfigByPaneKey: {}, + agentStatusByPaneKey: {}, + getAgentLaunchConfigForStatusEntry: () => undefined + } +}) - afterEach(() => { - vi.useRealTimers() - }) +afterEach(() => { + vi.useRealTimers() +}) - async function play(events: { at: number; payload: Record }[]): Promise<{ - banners: { at: number; body: string | undefined; stateStartedAt: number | undefined }[] - rows: HookRow[] - }> { - const { observeAgentHookCompletionForNotification } = - await import('./agent-hook-completion-notifications') - const listener = createHookListenerState() - const rows: HookRow[] = [] - let previous: HookRow | undefined - const banners: { - at: number - body: string | undefined - stateStartedAt: number | undefined - }[] = [] +async function play( + events: { at: number; payload: Record }[], + source: 'claude' | 'grok' = 'claude' +): Promise<{ + banners: { at: number; body: string | undefined; stateStartedAt: number | undefined }[] + rows: HookRow[] +}> { + const { observeAgentHookCompletionForNotification } = + await import('./agent-hook-completion-notifications') + const listener = createHookListenerState() + const rows: HookRow[] = [] + let previous: HookRow | undefined + const banners: { + at: number + body: string | undefined + stateStartedAt: number | undefined + }[] = [] - dispatchTerminalNotification.mockImplementation((_worktreeId, event) => { - banners.push({ - at: Date.now(), - body: event.agentStatusSnapshot?.lastAssistantMessage, - stateStartedAt: event.agentStatusSnapshot?.stateStartedAt - }) + dispatchTerminalNotification.mockImplementation((_worktreeId, event) => { + banners.push({ + at: Date.now(), + body: event.agentStatusSnapshot?.lastAssistantMessage, + stateStartedAt: event.agentStatusSnapshot?.stateStartedAt }) + }) - for (const event of events) { - vi.setSystemTime(event.at) - const normalized = normalizeHookPayload( - listener, - 'claude', - { paneKey: PANE_KEY, payload: event.payload }, - 'production' - ) - if (!normalized) { - continue - } - const row = pinStateStartedAt(previous, normalized.payload, event.at) - previous = row - rows.push(row) - observeAgentHookCompletionForNotification({ - paneKey: PANE_KEY, - worktreeId: WORKTREE_ID, - payload: row - }) - vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + for (const event of events) { + vi.setSystemTime(event.at) + const normalized = normalizeHookPayload( + listener, + source, + { paneKey: PANE_KEY, payload: event.payload }, + 'production' + ) + if (!normalized) { + continue } - - return { banners, rows } + const row = pinStateStartedAt(previous, normalized.payload, event.at) + previous = row + rows.push(row) + observeAgentHookCompletionForNotification({ + paneKey: PANE_KEY, + worktreeId: WORKTREE_ID, + payload: row + }) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) } + return { banners, rows } +} + +describe('Claude background-turn completion notifications', () => { it('announces a subagent turn at Stop with that turn’s text, not when the child later drains', async () => { const { banners, rows } = await play([ { @@ -267,3 +270,115 @@ describe('Claude background-turn completion notifications', () => { expect(new Set(banners.map((banner) => banner.stateStartedAt)).size).toBe(3) }) }) + +describe('Grok background-turn completion notifications', () => { + const T0 = 1_700_000_000_000 + const prompt = (at: number, promptId: string) => ({ + at, + payload: { hookEventName: 'user_prompt_submit', sessionId: 's-1', promptId, prompt: 'go' } + }) + const shellStarted = (at: number, taskId: string) => ({ + at, + payload: { + hookEventName: 'post_tool_use', + sessionId: 's-1', + toolName: 'run_terminal_cmd', + toolResult: { type: 'BackgroundTaskStarted', task_type: 'bash', task_id: taskId } + } + }) + const stop = (at: number, promptId: string, text: string, taskIds: string[]) => ({ + at, + payload: { + hookEventName: 'stop', + sessionId: 's-1', + promptId, + reason: 'end_turn', + lastAssistantMessage: text, + backgroundTasks: taskIds.map((id) => ({ id, type: 'shell', status: 'running' })) + } + }) + const notification = (at: number, notificationType: string, message = '') => ({ + at, + payload: { hookEventName: 'notification', sessionId: 's-1', notificationType, message } + }) + + // Grok wakes itself when background work ends and that woken turn announces, so a turn that + // leaves work running announces when Grok goes idle with it still running, and only once. + it('announces a turn that leaves a shell running once, when Grok goes idle', async () => { + const { banners, rows } = await play( + [ + prompt(T0, 'p1'), + shellStarted(T0 + 1_000, 'shell-1'), + stop(T0 + 5_000, 'p1', 'Dev server is up.', ['shell-1']), + // Grok's idle restatement ~60 s later, the shell still running. + notification(T0 + 65_000, 'idle_prompt'), + notification(T0 + 120_000, 'task_complete', 'Background task completed: shell-1'), + notification(T0 + 180_000, 'idle_prompt') + ], + 'grok' + ) + + expect(rows.slice(2).map((row) => [row.state, row.workingMode, row.turnCompletedAt])).toEqual([ + ['working', 'monitoring', undefined], + ['working', 'monitoring', T0 + 5_000], + ['done', undefined, T0 + 5_000], + ['done', undefined, T0 + 5_000] + ]) + expect(banners).toEqual([ + { at: T0 + 65_000, body: 'Dev server is up.', stateStartedAt: T0 + 5_000 } + ]) + }) + + it('stamps nothing on a cancelled turn a shell holds open', async () => { + const { rows } = await play( + [ + prompt(T0, 'p1'), + shellStarted(T0 + 1_000, 'shell-1'), + { + at: T0 + 5_000, + payload: { + hookEventName: 'stop_cancelled', + sessionId: 's-1', + promptId: 'p1', + reason: 'user_interrupt' + } + }, + notification(T0 + 65_000, 'idle_prompt'), + notification(T0 + 120_000, 'task_complete', 'Background task completed: shell-1') + ], + 'grok' + ) + + expect(rows.slice(2).map((row) => [row.state, row.workingMode, row.interrupted])).toEqual([ + ['working', 'monitoring', undefined], + ['working', 'monitoring', undefined], + ['done', undefined, true] + ]) + expect(rows.every((row) => row.turnCompletedAt === undefined)).toBe(true) + }) + + it('starts each new prompt unstamped, so the next turn announces on its own', async () => { + const { banners, rows } = await play( + [ + prompt(T0, 'p1'), + shellStarted(T0 + 1_000, 'shell-1'), + stop(T0 + 5_000, 'p1', 'Dev server is up.', ['shell-1']), + notification(T0 + 20_000, 'idle_prompt'), + notification(T0 + 25_000, 'task_complete', 'Background task completed: shell-1'), + prompt(T0 + 30_000, 'p2'), + stop(T0 + 35_000, 'p2', 'Route added.', []) + ], + 'grok' + ) + + expect(rows.slice(5).map((row) => [row.state, row.turnCompletedAt])).toEqual([ + ['working', undefined], + ['done', undefined] + ]) + // The unstamped done announces after the hook quiet window; the stamped one at the idle. + expect(banners.map((banner) => [banner.body, banner.stateStartedAt])).toEqual([ + ['Dev server is up.', T0 + 5_000], + ['Route added.', T0 + 35_000] + ]) + }) +}) diff --git a/src/shared/agent-hook-listener-grok-task-inventory.test.ts b/src/shared/agent-hook-listener-grok-task-inventory.test.ts index 0c641b3bca4c..3f9700c58051 100644 --- a/src/shared/agent-hook-listener-grok-task-inventory.test.ts +++ b/src/shared/agent-hook-listener-grok-task-inventory.test.ts @@ -4,7 +4,7 @@ // grok-background-completion-hooks.jsonl. import { readFileSync } from 'node:fs' import { join } from 'node:path' -import { beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { normalizeHookPayload } from './agent-hook-listener' import { createHookListenerState, @@ -56,6 +56,10 @@ describe('Grok background-task inventory between stop reports', () => { state = createHookListenerState() }) + afterEach(() => { + vi.useRealTimers() + }) + function normalize(payload: Record) { return normalizeHookPayload(state, 'grok', { paneKey: PANE_KEY, payload }, 'production') ?.payload @@ -147,6 +151,31 @@ describe('Grok background-task inventory between stop reports', () => { ).toMatchObject({ state: 'done', interrupted: true }) }) + it('stamps the held-open turn at idle with its stop time, and a re-delivered stop keeps it', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + normalize({ hookEventName: 'user_prompt_submit', sessionId: 's-1', promptId: 'p1' }) + const stop = { + hookEventName: 'stop', + sessionId: 's-1', + promptId: 'p1', + reason: 'end_turn', + backgroundTasks: [{ id: 'task-1', type: 'shell', status: 'running' }] + } + vi.setSystemTime(2_000) + expect(normalize(stop)?.turnCompletedAt).toBeUndefined() + vi.setSystemTime(62_000) + expect( + normalize({ + hookEventName: 'notification', + sessionId: 's-1', + notificationType: 'idle_prompt' + }) + ).toMatchObject({ state: 'working', turnCompletedAt: 2_000 }) + vi.setSystemTime(62_250) + expect(normalize(stop)?.turnCompletedAt).toBe(2_000) + }) + // Why: Grok may run no follow-up turn after a completion (a cancel, a goal loop, auto-wake off), // so the task's own end settles the row; when it does wake, the woken turn reopens it. it('settles on the captured task_complete and reopens for the woken follow-up turn', () => { diff --git a/src/shared/agent-hook-listener/listener-state.ts b/src/shared/agent-hook-listener/listener-state.ts index 2b266d00d174..ef3daf167acf 100644 --- a/src/shared/agent-hook-listener/listener-state.ts +++ b/src/shared/agent-hook-listener/listener-state.ts @@ -1,6 +1,9 @@ -import type { AgentMainAgentStatus } from '../agent-status-types' import type { AgentChildWorkKind } from '../agent-status-child-work' -import type { ClaudeLeadTurnState, CodexLeadTurnState } from './main-agent-turn-state' +import type { + ClaudeLeadTurnState, + CodexLeadTurnState, + GrokMainAgentTurnState +} from './main-agent-turn-state' export type { ClaudeLeadTurnState, CodexLeadTurnState } from './main-agent-turn-state' import { @@ -57,7 +60,7 @@ export type HookListenerState = { /** Newest Grok turn per pane, used to reject end reports that arrive after a replacement prompt. */ grokActiveTurnByPaneKey: Map /** The Grok main agent's own state as last published, so its clock keeps continuity across events. */ - grokMainAgentStatusByPaneKey: Map + grokMainAgentStatusByPaneKey: Map /** Grok's background-task inventory per pane (task id -> kind): a turn end carrying * `backgroundTasks` restates it whole; a shell's BackgroundTaskStarted result or a SubagentStart * adds an entry, and its own task_complete, SubagentStop or child SessionEnd drops it; a session 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 c077860f8467..41c5ab83e4f2 100644 --- a/src/shared/agent-hook-listener/main-agent-turn-state.ts +++ b/src/shared/agent-hook-listener/main-agent-turn-state.ts @@ -1,4 +1,4 @@ -import type { AgentStatusState } from '../agent-status-types' +import type { AgentMainAgentStatus, AgentStatusState } from '../agent-status-types' import type { AgentJournalTurnOutcome } from '../agent-turn-outcome' /** The Claude main agent's own turn record, published on every row as `mainAgent`. */ @@ -33,3 +33,9 @@ export type CodexLeadTurnState = { stateStartedAt: number model?: string } + +/** The Grok main agent's own record: the `mainAgent` it publishes, plus the same turn stamp Claude's carries. */ +export type GrokMainAgentTurnState = AgentMainAgentStatus & { + /** See ClaudeLeadTurnState.turnCompletedAt. Always the stamped done run's own `stateStartedAt`. */ + turnCompletedAt?: number +} diff --git a/src/shared/agent-hook-listener/providers/grok-events.ts b/src/shared/agent-hook-listener/providers/grok-events.ts index 4823922c4eb5..a937bff57f94 100644 --- a/src/shared/agent-hook-listener/providers/grok-events.ts +++ b/src/shared/agent-hook-listener/providers/grok-events.ts @@ -1,10 +1,16 @@ import { normalizeAgentStatusPayload, + type AgentMainAgentStatus, type ParsedAgentStatusPayload } from '../../agent-status-types' import { isAskUserQuestionTool } from '../../agent-question-answered-intent' -import { continueMainAgentStatus, foldAgentLeadStatus } from '../../agent-lead-status-fold' +import { + continueMainAgentStatus, + foldAgentLeadStatus, + isAgentStatusHeldOpenByChildWork +} from '../../agent-lead-status-fold' import { clearPaneTurnCacheState, type HookListenerState } from '../listener-state' +import type { GrokMainAgentTurnState } from '../main-agent-turn-state' import { grokChildWorkLivenessAfterTurnEnd, grokIdentityField, @@ -72,6 +78,25 @@ function grokTurnEndApplies( ) } +/** The finished turn's stamp, earned when Grok goes idle while child work still holds the row open + * — not at its `stop`: Grok wakes itself when that work ends, and the woken turn is the one that + * announces. A cancel earns none. It is the done run's own clock, so every later restatement of + * that turn carries the same value. */ +function grokTurnCompletedAt( + previous: GrokMainAgentTurnState | undefined, + mainAgent: AgentMainAgentStatus, + idleHeldOpen: boolean +): number | undefined { + if (mainAgent.state !== 'done' || mainAgent.outcome === 'cancellation') { + return undefined + } + if (idleHeldOpen) { + return mainAgent.stateStartedAt + } + // Why: only a done record holds a stamp, so a new turn (any non-done state) has already dropped it. + return previous?.turnCompletedAt +} + function isGrokSessionBoundary(eventName: unknown, hookPayload: Record): boolean { if (isGrokEvent(eventName, 'session_end')) { return true @@ -200,7 +225,17 @@ export function normalizeGrokEvent( { state: leadState, outcome: mainAgentOutcome }, Date.now() ) - state.grokMainAgentStatusByPaneKey.set(paneKey, mainAgent) + const turnCompletedAt = sessionBoundary + ? undefined + : grokTurnCompletedAt( + previousMainAgent, + mainAgent, + isIdlePrompt && isAgentStatusHeldOpenByChildWork({ state: stateName, mainAgent }) + ) + state.grokMainAgentStatusByPaneKey.set(paneKey, { + ...mainAgent, + ...(turnCompletedAt !== undefined ? { turnCompletedAt } : {}) + }) const snapshot = resolveToolState( state, @@ -229,6 +264,7 @@ export function normalizeGrokEvent( // Why: derived from the main agent, so any later restatement that settles a cancelled turn still reads interrupted. ...(mainAgent.outcome === 'cancellation' ? { interrupted: true } : {}), ...(sessionBoundary ? { sessionBoundary: true } : {}), + ...(turnCompletedAt !== undefined ? { turnCompletedAt } : {}), mainAgent }) } diff --git a/src/shared/agent-hook-listener/providers/grok-task-inventory.ts b/src/shared/agent-hook-listener/providers/grok-task-inventory.ts index 6e74bc9700c5..27a1c4ab4689 100644 --- a/src/shared/agent-hook-listener/providers/grok-task-inventory.ts +++ b/src/shared/agent-hook-listener/providers/grok-task-inventory.ts @@ -148,11 +148,12 @@ function dropGrokFinishedTask( if (inventory.size === 0) { state.grokBackgroundTasksByPaneKey.delete(paneKey) } - const mainAgent = state.grokMainAgentStatusByPaneKey.get(paneKey) + const record = state.grokMainAgentStatusByPaneKey.get(paneKey) // Why: a live lead turn already owns the row; only a row the inventory holds open re-derives. - if (mainAgent?.state !== 'done') { + if (record?.state !== 'done') { return null } + const { turnCompletedAt, ...mainAgent } = record const resolution = foldAgentLeadStatus({ leadState: 'done', childWorkLiveness: grokChildWorkLivenessAfterTurnEnd(state, paneKey, hookPayload) @@ -169,6 +170,8 @@ function dropGrokFinishedTask( lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput, ...(resolution.workingMode ? { workingMode: resolution.workingMode } : {}), ...(mainAgent.outcome === 'cancellation' ? { interrupted: true } : {}), + // Why: the turn already announced at the idle that stamped it; this restatement pairs with that. + ...(turnCompletedAt !== undefined ? { turnCompletedAt } : {}), mainAgent }) }