fix(agent-status): keep Grok background work visible after a cancel (#35495)

StopCancelled carries no backgroundTasks, so a cancelled Grok turn settled
done while the shells and subagents it left running kept going. Track
Grok's own task ids per pane and settle the cancelled row on each task's
own end.
This commit is contained in:
Kelvin Amoaba
2026-10-29 00:34:00 +00:00
committed by GitHub
parent ed8f62cf94
commit bbb7f2d695
8 changed files with 318 additions and 70 deletions
@@ -0,0 +1,36 @@
{"scenario":"continue-to-run","hook_event_name":"UserPromptSubmit","hookEventName":"user_prompt_submit","sessionId":"01a183ea-2c14-7ca2-b795-9b3bd3c74bfd","timestamp":"2026-10-28T19:25:21.927601+00:00","promptId":"2dd6b250-bad9-4d32-981b-2e04e56e330c","prompt":"Do these in order without asking: (1) start a BACKGROUND shell task running: sleep 150 ; (2) launch a BACKGROUND subagent whose task is to run the bash command sleep 120 and then say ok ; (3) then run"}
{"scenario":"continue-to-run","hook_event_name":"PreToolUse","hookEventName":"pre_tool_use","sessionId":"01a183ea-2c14-7ca2-b795-9b3bd3c74bfd","timestamp":"2026-10-28T19:25:27.971568+00:00","toolName":"run_terminal_command","toolUseId":"call-ff008b34-a2e7-4205-afcf-f8541ac7dce4-6","toolInput":{"command":"sleep 150","description":"Background sleep for 150 seconds","block_until_ms":0}}
{"scenario":"continue-to-run","hook_event_name":"PostToolUse","hookEventName":"post_tool_use","sessionId":"01a183ea-2c14-7ca2-b795-9b3bd3c74bfd","timestamp":"2026-10-28T19:25:29.122111+00:00","toolName":"run_terminal_command","toolUseId":"call-ff008b34-a2e7-4205-afcf-f8541ac7dce4-6","toolInput":{"command":"sleep 150","description":"Background sleep for 150 seconds","block_until_ms":0},"toolResult":{"type":"BackgroundTaskStarted","task_id":"e1d2068e-ada9-4e9b-b7cc-c71abcc1c11f","task_type":"bash","status":"running"}}
{"scenario":"continue-to-run","hook_event_name":"PreToolUse","hookEventName":"pre_tool_use","sessionId":"01a183ea-2c14-7ca2-b795-9b3bd3c74bfd","timestamp":"2026-10-28T19:25:31.370583+00:00","toolName":"spawn_subagent","toolUseId":"call-06ef61bc-f28c-4373-abd1-f25ae36577af-7","toolInput":{"description":"Sleep 120 then say ok","prompt":"Run the bash command `sleep 120` and wait for it to finish. Then reply with exactly: ok","background":true}}
{"scenario":"continue-to-run","hook_event_name":"SubagentStart","hookEventName":"subagent_start","sessionId":"01a183ea-2c14-7ca2-b795-9b3bd3c74bfd","timestamp":"2026-10-28T19:25:31.792357+00:00","subagentId":"01a183f9-8a65-71b1-93c1-fe7b3d92c574","subagentType":"general-purpose","description":"Sleep 120 then say ok"}
{"scenario":"continue-to-run","hook_event_name":"PostToolUse","hookEventName":"post_tool_use","sessionId":"01a183ea-2c14-7ca2-b795-9b3bd3c74bfd","timestamp":"2026-10-28T19:25:31.732458+00:00","toolName":"spawn_subagent","toolUseId":"call-06ef61bc-f28c-4373-abd1-f25ae36577af-7","toolInput":{"description":"Sleep 120 then say ok","prompt":"Run the bash command `sleep 120` and wait for it to finish. Then reply with exactly: ok","background":true},"toolResult":{"type":"Text","text":"Subagent started in background.\nsubagent_id: 01a183f9-8a65-71b1-93c1-fe7b3d92c574\ndescription: Sleep 120 then say ok\n\nWhen you need its result, use get_command_or_subagent_output with task_ids=[\"01a18"}}
{"scenario":"continue-to-run","hook_event_name":"UserPromptSubmit","hookEventName":"user_prompt_submit","sessionId":"01a183f9-8a65-71b1-93c1-fe7b3d92c574","timestamp":"2026-10-28T19:25:32.150390+00:00","promptId":"01a183f9-8c10-7e42-932a-2b9caa86a175","prompt":"Run the bash command `sleep 120` and wait for it to finish. Then reply with exactly: ok","subagentType":"general-purpose"}
{"scenario":"continue-to-run","hook_event_name":"PreToolUse","hookEventName":"pre_tool_use","sessionId":"01a183ea-2c14-7ca2-b795-9b3bd3c74bfd","timestamp":"2026-10-28T19:25:34.592144+00:00","toolName":"run_terminal_command","toolUseId":"call-6739900f-05bc-4d99-9223-fe071b8bfd9a-8","toolInput":{"command":"sleep 60","description":"Foreground sleep for 60 seconds","block_until_ms":75000}}
{"scenario":"continue-to-run","hook_event_name":"PreToolUse","hookEventName":"pre_tool_use","sessionId":"01a183f9-8a65-71b1-93c1-fe7b3d92c574","timestamp":"2026-10-28T19:25:35.045912+00:00","toolName":"run_terminal_command","toolUseId":"call-5d6cc212-dbc5-493c-a99b-e9958957758a-0","toolInput":{"command":"sleep 120","description":"Wait 120 seconds before replying","block_until_ms":130000},"subagentType":"general-purpose"}
{"scenario":"continue-to-run","hook_event_name":"StopCancelled","hookEventName":"stop_cancelled","sessionId":"01a183ea-2c14-7ca2-b795-9b3bd3c74bfd","timestamp":"2026-10-28T19:26:11.283501+00:00","promptId":"2dd6b250-bad9-4d32-981b-2e04e56e330c","reason":"user_interrupt","cancelledBy":"user","cancelTrigger":"ctrl_c"}
{"scenario":"continue-to-run","hook_event_name":"Notification","hookEventName":"notification","sessionId":"01a183ea-2c14-7ca2-b795-9b3bd3c74bfd","timestamp":"2026-10-28T19:27:11.135027+00:00","notificationType":"idle_prompt","message":"Waiting for your next prompt","level":"info"}
{"scenario":"continue-to-run","hook_event_name":"PostToolUse","hookEventName":"post_tool_use","sessionId":"01a183f9-8a65-71b1-93c1-fe7b3d92c574","timestamp":"2026-10-28T19:27:36.050294+00:00","toolName":"run_terminal_command","toolUseId":"call-5d6cc212-dbc5-493c-a99b-e9958957758a-0","toolInput":{"command":"sleep 120","description":"Wait 120 seconds before replying","block_until_ms":130000},"toolResult":{"type":"Bash"},"subagentType":"general-purpose"}
{"scenario":"continue-to-run","hook_event_name":"SubagentStop","hookEventName":"subagent_stop","sessionId":"01a183f9-8a65-71b1-93c1-fe7b3d92c574","timestamp":"2026-10-28T19:27:41.524951+00:00","promptId":"01a183f9-8c10-7e42-932a-2b9caa86a175","subagentId":"01a183f9-8a65-71b1-93c1-fe7b3d92c574","subagentType":"general-purpose","stopHookActive":false}
{"scenario":"continue-to-run","hook_event_name":"SessionEnd","hookEventName":"session_end","sessionId":"01a183f9-8a65-71b1-93c1-fe7b3d92c574","timestamp":"2026-10-28T19:27:42.267006+00:00","subagentType":"general-purpose","reason":"shutdown"}
{"scenario":"continue-to-run","hook_event_name":"UserPromptSubmit","hookEventName":"user_prompt_submit","sessionId":"01a183ea-2c14-7ca2-b795-9b3bd3c74bfd","timestamp":"2026-10-28T19:27:43.030713+00:00","promptId":"subagent-completed-01a183f9-8a65-71b1-93c1-fe7b3d92c574","prompt":"<system-reminder>\nWhile you were idle, 1 background subagent completed:\n- [general-purpose] \"Sleep 120 then say ok\" \u2014 completed successfully (130.5s, 1 tool calls)\n=== Task 01a183f9-8a65-71b1-93c1-fe7"}
{"scenario":"continue-to-run","hook_event_name":"PreToolUse","hookEventName":"pre_tool_use","sessionId":"01a183ea-2c14-7ca2-b795-9b3bd3c74bfd","timestamp":"2026-10-28T19:27:47.340615+00:00","toolName":"run_terminal_command","toolUseId":"call-ff876bcf-280d-4d83-86e9-c56475a845f1-9","toolInput":{"command":"sleep 60","description":"Foreground sleep for 60 seconds","block_until_ms":75000}}
{"scenario":"continue-to-run","hook_event_name":"Notification","hookEventName":"notification","sessionId":"01a183ea-2c14-7ca2-b795-9b3bd3c74bfd","timestamp":"2026-10-28T19:28:00.456613+00:00","notificationType":"task_complete","message":"Background task completed: e1d2068e-ada9-4e9b-b7cc-c71abcc1c11f","level":"info"}
{"scenario":"shell-only","hook_event_name":"UserPromptSubmit","hookEventName":"user_prompt_submit","sessionId":"01a1849d-1f0c-7d30-9007-ab623bb65ea1","timestamp":"2026-10-28T22:24:41.463633+00:00","promptId":"d44bdc20-4ea1-488e-8e77-09383a06ce16","prompt":"Start a BACKGROUND shell task running exactly: sleep 45 ; then run sleep 90 in the FOREGROUND and wait for it. Then reply DONE."}
{"scenario":"shell-only","hook_event_name":"PreToolUse","hookEventName":"pre_tool_use","sessionId":"01a1849d-1f0c-7d30-9007-ab623bb65ea1","timestamp":"2026-10-28T22:24:44.862943+00:00","toolName":"read_file","toolUseId":"call-d5422960-6079-44c0-957d-8b4456d7f4ff-0","toolInput":{"target_file":"/home/user/.grok/bundled/skills/long-running-background-tasks/SKILL.md"}}
{"scenario":"shell-only","hook_event_name":"PostToolUse","hookEventName":"post_tool_use","sessionId":"01a1849d-1f0c-7d30-9007-ab623bb65ea1","timestamp":"2026-10-28T22:24:45.119791+00:00","toolName":"read_file","toolUseId":"call-d5422960-6079-44c0-957d-8b4456d7f4ff-0","toolInput":{"target_file":"/home/user/.grok/bundled/skills/long-running-background-tasks/SKILL.md"},"toolResult":{"type":"ReadFile"}}
{"scenario":"shell-only","hook_event_name":"PreToolUse","hookEventName":"pre_tool_use","sessionId":"01a1849d-1f0c-7d30-9007-ab623bb65ea1","timestamp":"2026-10-28T22:24:52.233728+00:00","toolName":"run_terminal_command","toolUseId":"call-a4d622ed-655b-4aad-b772-b9aa98163c7e-1","toolInput":{"command":"sleep 45","description":"Background sleep 45","block_until_ms":0}}
{"scenario":"shell-only","hook_event_name":"PostToolUse","hookEventName":"post_tool_use","sessionId":"01a1849d-1f0c-7d30-9007-ab623bb65ea1","timestamp":"2026-10-28T22:24:52.872235+00:00","toolName":"run_terminal_command","toolUseId":"call-a4d622ed-655b-4aad-b772-b9aa98163c7e-1","toolInput":{"command":"sleep 45","description":"Background sleep 45","block_until_ms":0},"toolResult":{"type":"BackgroundTaskStarted","task_id":"4ebb49e6-528c-402e-acbd-d9b6e8dc60d8","task_type":"bash","status":"running"}}
{"scenario":"shell-only","hook_event_name":"PreToolUse","hookEventName":"pre_tool_use","sessionId":"01a1849d-1f0c-7d30-9007-ab623bb65ea1","timestamp":"2026-10-28T22:24:57.023367+00:00","toolName":"run_terminal_command","toolUseId":"call-84f6ab73-3a14-424b-b630-a8967aed06fe-2","toolInput":{"command":"sleep 90","description":"Foreground sleep 90 and wait","block_until_ms":120000}}
{"scenario":"shell-only","hook_event_name":"StopCancelled","hookEventName":"stop_cancelled","sessionId":"01a1849d-1f0c-7d30-9007-ab623bb65ea1","timestamp":"2026-10-28T22:25:01.593131+00:00","promptId":"d44bdc20-4ea1-488e-8e77-09383a06ce16","reason":"user_interrupt","cancelledBy":"user","cancelTrigger":"ctrl_c"}
{"scenario":"shell-only","hook_event_name":"Notification","hookEventName":"notification","sessionId":"01a1849d-1f0c-7d30-9007-ab623bb65ea1","timestamp":"2026-10-28T22:25:37.693008+00:00","notificationType":"task_complete","message":"Background task completed: 4ebb49e6-528c-402e-acbd-d9b6e8dc60d8","level":"info"}
{"scenario":"shell-only","hook_event_name":"Notification","hookEventName":"notification","sessionId":"01a1849d-1f0c-7d30-9007-ab623bb65ea1","timestamp":"2026-10-28T22:26:01.474946+00:00","notificationType":"idle_prompt","message":"Waiting for your next prompt","level":"info"}
{"scenario":"stop-running","hook_event_name":"UserPromptSubmit","hookEventName":"user_prompt_submit","sessionId":"01a184a0-009e-7b11-9b47-043c1dc3f59a","timestamp":"2026-10-28T22:28:02.051752+00:00","promptId":"9fe5e4e0-3d00-4a72-bee8-d9465e6dc2b6","prompt":"Launch a BACKGROUND subagent whose task is to run the bash command sleep 100 and then say ok; then run sleep 90 in the FOREGROUND and wait for it. Then reply DONE."}
{"scenario":"stop-running","hook_event_name":"PreToolUse","hookEventName":"pre_tool_use","sessionId":"01a184a0-009e-7b11-9b47-043c1dc3f59a","timestamp":"2026-10-28T22:28:22.490044+00:00","toolName":"spawn_subagent","toolUseId":"call-feca83be-16b1-406f-a879-d962893800b7-0","toolInput":{"description":"Sleep 100 then ok","prompt":"Run this bash command and wait until it finishes: sleep 100\n\nUse the terminal tool with block_until_ms set high enough to wait for the full 100 seconds (at least 110000). Do not background it. When the command has finished, reply with exactly: ok\n\nDo nothing else.","background":true}}
{"scenario":"stop-running","hook_event_name":"SubagentStart","hookEventName":"subagent_start","sessionId":"01a184a0-009e-7b11-9b47-043c1dc3f59a","timestamp":"2026-10-28T22:28:22.595236+00:00","subagentId":"01a184a0-f1aa-70f3-a91c-c37b0930dd99","subagentType":"general-purpose","description":"Sleep 100 then ok"}
{"scenario":"stop-running","hook_event_name":"PostToolUse","hookEventName":"post_tool_use","sessionId":"01a184a0-009e-7b11-9b47-043c1dc3f59a","timestamp":"2026-10-28T22:28:22.591367+00:00","toolName":"spawn_subagent","toolUseId":"call-feca83be-16b1-406f-a879-d962893800b7-0","toolInput":{"description":"Sleep 100 then ok","prompt":"Run this bash command and wait until it finishes: sleep 100\n\nUse the terminal tool with block_until_ms set high enough to wait for the full 100 seconds (at least 110000). Do not background it. When the command has finished, reply with exactly: ok\n\nDo nothing else.","background":true},"toolResult":{"type":"Text","text":"Subagent started in background.\nsubagent_id: 01a184a0-f1aa-70f3-a91c-c37b0930dd99\ndescription: Sleep 100 then ok\n\nWhen you need its result, use get_command_or_subagent_output with task_ids=[\"01a184a0-"}}
{"scenario":"stop-running","hook_event_name":"UserPromptSubmit","hookEventName":"user_prompt_submit","sessionId":"01a184a0-f1aa-70f3-a91c-c37b0930dd99","timestamp":"2026-10-28T22:28:22.733904+00:00","promptId":"01a184a0-f230-7f61-9744-cef56988eda1","prompt":"Run this bash command and wait until it finishes: sleep 100\n\nUse the terminal tool with block_until_ms set high enough to wait for the full 100 seconds (at least 110000). Do not background it. When th","subagentType":"general-purpose"}
{"scenario":"stop-running","hook_event_name":"PreToolUse","hookEventName":"pre_tool_use","sessionId":"01a184a0-f1aa-70f3-a91c-c37b0930dd99","timestamp":"2026-10-28T22:28:24.864071+00:00","toolName":"run_terminal_command","toolUseId":"call-853df35c-570f-4c0d-a4c3-9f2272fd9ac8-0","toolInput":{"command":"sleep 100","description":"Sleep for 100 seconds","block_until_ms":110000},"subagentType":"general-purpose"}
{"scenario":"stop-running","hook_event_name":"PreToolUse","hookEventName":"pre_tool_use","sessionId":"01a184a0-009e-7b11-9b47-043c1dc3f59a","timestamp":"2026-10-28T22:28:25.175975+00:00","toolName":"run_terminal_command","toolUseId":"call-974db590-6eea-4763-9042-223d6cf95698-1","toolInput":{"command":"sleep 90","description":"Wait 90 seconds in the foreground","block_until_ms":120000}}
{"scenario":"stop-running","hook_event_name":"StopCancelled","hookEventName":"stop_cancelled","sessionId":"01a184a0-009e-7b11-9b47-043c1dc3f59a","timestamp":"2026-10-28T22:28:30.214441+00:00","promptId":"9fe5e4e0-3d00-4a72-bee8-d9465e6dc2b6","reason":"user_interrupt","cancelledBy":"user","cancelTrigger":"ctrl_c"}
{"scenario":"stop-running","hook_event_name":"SessionEnd","hookEventName":"session_end","sessionId":"01a184a0-f1aa-70f3-a91c-c37b0930dd99","timestamp":"2026-10-28T22:28:30.242682+00:00","subagentType":"general-purpose","reason":"shutdown"}
{"scenario":"stop-running","hook_event_name":"Notification","hookEventName":"notification","sessionId":"01a184a0-009e-7b11-9b47-043c1dc3f59a","timestamp":"2026-10-28T22:29:30.044480+00:00","notificationType":"idle_prompt","message":"Waiting for your next prompt","level":"info"}
@@ -0,0 +1,99 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { createHookListenerState } from './agent-hook-listener/listener-state'
import { normalizeAndAccept } from './agent-hook-listener-test-harness'
// Grok 1.0.70 hooks captured around a confirmed Ctrl+C cancel; StopCancelled carries no backgroundTasks.
const capturedHooks: Record<string, unknown>[] = readFileSync(
join(__dirname, '__fixtures__', 'grok-cancel-background-hooks.jsonl'),
'utf8'
)
.trim()
.split('\n')
.map((line) => JSON.parse(line))
// Orca's managed Grok hooks do not subscribe SubagentStart/SubagentStop.
const UNSUBSCRIBED = new Set(['subagent_start', 'subagent_stop'])
function replay(scenario: string, edit = (hooks: Record<string, unknown>[]) => hooks) {
const state = createHookListenerState()
const sessions = new Set<string>()
const rows = edit(
capturedHooks.filter(
(hook) => hook.scenario === scenario && !UNSUBSCRIBED.has(String(hook.hookEventName))
)
).flatMap((hook) => {
const event = normalizeAndAccept(state, 'grok', hook)
const row = event?.payload
if (!row) {
return []
}
sessions.add(String(event.providerSession?.id))
const name = hook.subagentType
? `child ${String(hook.hookEventName)}`
: String(hook.notificationType ?? hook.hookEventName)
const status = [row.state, row.workingMode, row.interrupted ? 'interrupted' : undefined]
return [`${name}: ${status.filter(Boolean).join(' ')}`]
})
// A child's end restates the pane; resume must stay on the main session, never the subagent's.
expect(sessions.size).toBe(1)
return rows
}
describe('Grok background work that outlives a cancel', () => {
it('keeps a kept-running subagent and shell visible until each reports its end', () => {
expect(replay('continue-to-run')).toEqual([
'user_prompt_submit: working',
'pre_tool_use: working',
'post_tool_use: working',
'pre_tool_use: working',
'post_tool_use: working',
'pre_tool_use: working',
'stop_cancelled: working',
'idle_prompt: working',
'child session_end: working monitoring',
// Grok's own follow-up turn once the subagent finished.
'user_prompt_submit: working',
'pre_tool_use: working'
])
})
it('holds a cancelled turn on its shell until task_complete names it', () => {
expect(replay('shell-only')).toEqual([
'user_prompt_submit: working',
'pre_tool_use: working',
'post_tool_use: working',
'pre_tool_use: working',
'post_tool_use: working',
'pre_tool_use: working',
'stop_cancelled: working monitoring',
'task_complete: done interrupted',
'idle_prompt: done interrupted'
])
})
it('settles when "Stop running" ends the subagent with the cancel', () => {
expect(replay('stop-running')).toEqual([
'user_prompt_submit: working',
'pre_tool_use: working',
'post_tool_use: working',
'pre_tool_use: working',
'stop_cancelled: working',
'child session_end: done interrupted',
'idle_prompt: done interrupted'
])
})
it('lets a session that ended first stay settled when its subagent ends later', () => {
const rows = replay('continue-to-run', (hooks) => {
const childEnd = hooks.findIndex(
(hook) => hook.subagentType && hook.hookEventName === 'session_end'
)
const { sessionId } = hooks[0]
const mainEnd = { hook_event_name: 'SessionEnd', hookEventName: 'session_end', sessionId }
return [...hooks.slice(0, childEnd), mainEnd, hooks[childEnd]]
})
expect(rows.at(-1)).toBe('session_end: done interrupted')
})
})
@@ -130,35 +130,6 @@ describe('Grok completion observations', () => {
})
})
it('keeps the cancellation on the idle backstop that settles a task the cancel left running', () => {
normalize({
hookEventName: 'UserPromptSubmit',
sessionId: 's-1',
promptId: 'p-1',
prompt: 'go'
})
expect(
normalize({
hookEventName: 'StopCancelled',
sessionId: 's-1',
promptId: 'p-1',
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.
expect(
normalize({
hookEventName: 'Notification',
sessionId: 's-1',
notificationType: 'idle_prompt'
})
).toMatchObject({
state: 'done',
interrupted: true,
mainAgent: { state: 'done', outcome: 'cancellation' }
})
})
// Pins grok-events.ts: finite task predicate and sessionCrons omission; treating monitors/crons as finite must redden.
it.each([
{
@@ -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'
@@ -73,6 +74,8 @@ export type HookListenerState = {
grokActiveTurnByPaneKey: Map<string, GrokActiveTurn>
/** The Grok main agent's own state as last published, so its clock keeps continuity across events. */
grokMainAgentStatusByPaneKey: Map<string, AgentMainAgentStatus>
/** Grok background shells and subagents still running, by Grok's task id; a cancel report omits them. */
grokBackgroundTasksByPaneKey: Map<string, Map<string, AgentChildWorkKind>>
/** Muse child-session filter and session-log cursor per pane. */
musePaneStateByPaneKey: Map<string, MusePaneState>
/** Client-observed OpenCode1 session ownership replaces frozen shared-server stamps before ingest. This map names owners, not status claims. */
@@ -143,6 +146,7 @@ export function createHookListenerState(
codexHeldEphemeralStartByPaneKey: new Map(),
grokActiveTurnByPaneKey: new Map(),
grokMainAgentStatusByPaneKey: new Map(),
grokBackgroundTasksByPaneKey: new Map(),
musePaneStateByPaneKey: new Map(),
opencodeSessionPaneBySessionId: new Map(),
lastLaunchTokenByPaneKey: new Map(),
@@ -239,6 +243,7 @@ export function clearPaneCacheState(state: HookListenerState, paneKey: string):
state.codexHeldEphemeralStartByPaneKey.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)
@@ -290,6 +295,7 @@ export function movePaneCacheState(
movePaneScopedMapEntries(state.codexHeldEphemeralStartByPaneKey, 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)
@@ -305,6 +311,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 clearAllListenerCaches(state: HookListenerState): void {
@@ -332,6 +339,7 @@ export function clearAllListenerCaches(state: HookListenerState): void {
state.codexHeldEphemeralStartByPaneKey.clear()
state.grokActiveTurnByPaneKey.clear()
state.grokMainAgentStatusByPaneKey.clear()
state.grokBackgroundTasksByPaneKey.clear()
state.musePaneStateByPaneKey.clear()
state.opencodeSessionPaneBySessionId.clear()
state.lastLaunchTokenByPaneKey.clear()
@@ -6,6 +6,7 @@ import {
type AgentProviderSessionMetadata
} from '../agent-session-resume'
import type { HookListenerState } from './listener-state'
import { isGrokSubagentEvent } from './providers/grok-tool-fields'
import { withHermesPaneSessionId } from './providers/hermes-events'
import { readString } from './tool-input-preview'
@@ -19,6 +20,11 @@ export function readPaneProviderSession(
if (source === 'codex' && readString(hookPayload, 'agent_id')) {
return null
}
if (source === 'grok' && isGrokSubagentEvent(hookPayload)) {
// A Grok child's session id is its own; the only child row it emits restates the pane's session.
const previous = state.lastStatusByPaneKey.get(paneKey)
return previous?.source === 'grok' ? (previous.providerSession ?? null) : null
}
const providerSession = extractAgentProviderSession(
source,
source === 'hermes' ? withHermesPaneSessionId(state, paneKey, hookPayload) : hookPayload
@@ -0,0 +1,116 @@
import type { AgentChildWorkKind } from '../../agent-status-child-work'
import {
agentChildWorkLiveness,
type AgentChildWorkLiveness
} from '../../agent-status-child-work-liveness'
import { isRecord } from '../../agent-status-child-work-value-guards'
import type { HookListenerState } from '../listener-state'
import { isGrokEvent } from '../provider-event-names'
import { readString } from '../tool-input-preview'
import { getGrokNotificationType } from './grok-tool-fields'
// Grok's own task ids per pane, since StopCancelled carries no inventory. A child session's id is its
// subagent's task id; a finished shell is named only inside its task_complete message.
/** Grok lists only in-flight finite tasks; exclude indefinite monitors so they cannot suppress completion forever. */
export function grokFiniteTaskKind(task: unknown): AgentChildWorkKind | null {
if (!isRecord(task)) {
return null
}
return task.type === 'subagent' ? 'agent' : task.type === 'shell' ? 'command' : null
}
/** The inventory a Grok turn end reports; Stop carries it, StopCancelled (1.0.70) does not. */
export function readGrokInventory(hookPayload: Record<string, unknown>): unknown[] | undefined {
const value = Object.hasOwn(hookPayload, 'backgroundTasks')
? hookPayload.backgroundTasks
: hookPayload.background_tasks
return Array.isArray(value) ? value : undefined
}
function startedShellTaskId(hookPayload: Record<string, unknown>): string | undefined {
const result = hookPayload.toolResult ?? hookPayload.tool_response
return isRecord(result) && result.type === 'BackgroundTaskStarted'
? readString(result, 'task_id')
: undefined
}
function addTask(
state: HookListenerState,
paneKey: string,
id: string,
kind: AgentChildWorkKind
): void {
const tasks = state.grokBackgroundTasksByPaneKey.get(paneKey) ?? new Map()
state.grokBackgroundTasksByPaneKey.set(paneKey, tasks.set(id, kind))
}
/** Returns true when a child session the pane was tracking ends. */
export function trackGrokChildSession(
state: HookListenerState,
paneKey: string,
eventName: unknown,
hookPayload: Record<string, unknown>
): boolean {
const sessionId = readString(hookPayload, 'sessionId') ?? readString(hookPayload, 'session_id')
if (!sessionId) {
return false
}
if (isGrokEvent(eventName, 'user_prompt_submit')) {
addTask(state, paneKey, sessionId, 'agent')
}
return (
isGrokEvent(eventName, 'session_end') &&
state.grokBackgroundTasksByPaneKey.get(paneKey)?.delete(sessionId) === true
)
}
export function trackGrokMainTasks(
state: HookListenerState,
paneKey: string,
eventName: unknown,
hookPayload: Record<string, unknown>
): void {
const inventory = isGrokEvent(eventName, 'stop', 'stop_failure', 'stop_cancelled')
? readGrokInventory(hookPayload)
: undefined
if (inventory) {
state.grokBackgroundTasksByPaneKey.delete(paneKey)
for (const task of inventory) {
const kind = grokFiniteTaskKind(task)
const id = isRecord(task) ? readString(task, 'id') : undefined
if (kind && id) {
addTask(state, paneKey, id, kind)
}
}
return
}
const shellId = isGrokEvent(eventName, 'post_tool_use')
? startedShellTaskId(hookPayload)
: undefined
if (shellId) {
addTask(state, paneKey, shellId, 'command')
return
}
const tasks = state.grokBackgroundTasksByPaneKey.get(paneKey)
if (
tasks &&
isGrokEvent(eventName, 'notification') &&
isGrokEvent(getGrokNotificationType(hookPayload), 'task_complete')
) {
const message = readString(hookPayload, 'message') ?? ''
for (const id of tasks.keys()) {
if (message.includes(id)) {
tasks.delete(id)
}
}
}
}
export function grokBackgroundTaskLiveness(
state: HookListenerState,
paneKey: string
): AgentChildWorkLiveness {
const tasks = state.grokBackgroundTasksByPaneKey.get(paneKey)
return agentChildWorkLiveness(Array.from(tasks?.values() ?? [], (kind) => ({ kind })))
}
@@ -4,7 +4,6 @@ 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,
@@ -16,8 +15,16 @@ import { resolvePrompt, resolveToolState, stripGrokUserQueryWrapper } from '../p
import { extractToolFields, isNewTurnEvent } from '../provider-event-routing'
import { readString } from '../tool-input-preview'
import { isGrokEvent } from '../provider-event-names'
import {
grokBackgroundTaskLiveness,
grokFiniteTaskKind,
readGrokInventory,
trackGrokChildSession,
trackGrokMainTasks
} from './grok-background-tasks'
import {
getGrokNotificationType,
isGrokSubagentEvent,
isGrokPermissionNotification,
isGrokRoutinePermissionPromptNotification
} from './grok-tool-fields'
@@ -36,10 +43,6 @@ function aliasedField(
return { present: false }
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function grokIdentityField(
hookPayload: Record<string, unknown>,
primary: string,
@@ -49,13 +52,6 @@ function grokIdentityField(
return value && value.length <= 512 ? value : undefined
}
function isGrokSubagentEvent(hookPayload: Record<string, unknown>): boolean {
return (
readString(hookPayload, 'subagentType') !== undefined ||
readString(hookPayload, 'subagent_type') !== undefined
)
}
function grokPromptId(hookPayload: Record<string, unknown>): string | undefined {
return normalizeGrokPromptId(hookPayload.promptId ?? hookPayload.prompt_id)
}
@@ -97,22 +93,10 @@ function grokTurnEndApplies(
)
}
/** Grok lists only in-flight finite tasks; exclude indefinite monitors so they cannot suppress 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<string, unknown>
): AgentChildWorkLivenessCandidate[] {
const backgroundTasks = aliasedField(hookPayload, 'backgroundTasks', 'background_tasks')
if (!backgroundTasks.present || !Array.isArray(backgroundTasks.value)) {
return []
}
return backgroundTasks.value.flatMap((task) => {
return (readGrokInventory(hookPayload) ?? []).flatMap((task) => {
const kind = grokFiniteTaskKind(task)
return kind ? [{ kind }] : []
})
@@ -145,11 +129,18 @@ export function normalizeGrokEvent(
hookPayload: Record<string, unknown>,
grokHome?: string
): ParsedAgentStatusPayload | null {
// Why: child sessions reuse their parent's pane route; their lifecycle cannot settle the parent.
if (isGrokSubagentEvent(hookPayload)) {
return null
}
if (isGrokEvent(eventName, 'session_start')) {
const isChild = isGrokSubagentEvent(hookPayload)
const previousMainAgent = state.grokMainAgentStatusByPaneKey.get(paneKey)
// Why: Grok starts no turn when a shell outlives a cancel, so task ends must settle that row here.
const afterCancel =
previousMainAgent?.state === 'done' && previousMainAgent.outcome === 'cancellation'
if (isChild) {
const trackedChildEnded = trackGrokChildSession(state, paneKey, eventName, hookPayload)
// Why: child sessions reuse their parent's pane route; only a tracked child's end restates a cancelled parent.
if (!afterCancel || !trackedChildEnded) {
return null
}
} else if (isGrokEvent(eventName, 'session_start')) {
// SessionStart clears stale turn/main-agent clocks but asserts no work before activity.
clearPaneTurnCacheState(state, paneKey)
return null
@@ -173,10 +164,16 @@ export function normalizeGrokEvent(
const isTurnEnd = isGrokEvent(eventName, 'stop', 'stop_failure', 'stop_cancelled')
const isIdlePrompt =
isGrokEvent(eventName, 'notification') && isGrokEvent(notificationType, 'idle_prompt')
const sessionBoundary = isGrokSessionBoundary(eventName, hookPayload)
const sessionBoundary = !isChild && isGrokSessionBoundary(eventName, hookPayload)
if (isTurnEnd && !grokTurnEndApplies(state, paneKey, hookPayload)) {
return null
}
if (!isChild) {
trackGrokMainTasks(state, paneKey, eventName, hookPayload)
}
if (sessionBoundary) {
state.grokBackgroundTasksByPaneKey.delete(paneKey)
}
let leadState: 'working' | 'waiting' | 'done' | null = null
if (
isGrokEvent(eventName, 'user_prompt_submit', 'post_tool_use', 'post_tool_use_failure') ||
@@ -191,8 +188,11 @@ 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
// Why: after a normal turn Grok wakes itself and restates its inventory when a task ends.
if (!afterCancel) {
return null
}
leadState = 'done'
} else if (
isGrokEvent(eventName, 'notification') &&
isGrokRoutinePermissionPromptNotification(
@@ -217,14 +217,20 @@ export function normalizeGrokEvent(
: isGrokEvent(eventName, 'stop_failure')
? ('failure' as const)
: undefined
// Cancel/failure retains reported tasks until their own end; only session boundaries settle independently of inventory.
// A cancel report omits the inventory, so it and every later restatement of that cancel read the tracked tasks.
const readsTrackedTasks =
isGrokEvent(eventName, 'stop_cancelled') || (afterCancel && !isTurnEnd && leadState === 'done')
const resolution = foldAgentLeadStatus({
leadState,
childWorkLiveness:
isTurnEnd && !sessionBoundary ? grokChildWorkLivenessAfterTurnEnd(hookPayload) : null
childWorkLiveness: sessionBoundary
? null
: readsTrackedTasks
? grokBackgroundTaskLiveness(state, paneKey)
: isTurnEnd
? grokChildWorkLivenessAfterTurnEnd(hookPayload)
: null
})
const stateName = resolution.stateName
const previousMainAgent = state.grokMainAgentStatusByPaneKey.get(paneKey)
// Why: an idle prompt or session end restates the same finished turn, so its verdict stands.
const mainAgentOutcome =
outcome ??
@@ -241,14 +247,13 @@ export function normalizeGrokEvent(
const snapshot = resolveToolState(
state,
paneKey,
extractToolFields('grok', eventName, hookPayload, { grokHome }),
isChild ? {} : extractToolFields('grok', eventName, hookPayload, { grokHome }),
{ resetOnNewTurn: isNewTurnEvent('grok', eventName) }
)
// Why: Grok Notification.message is status UI text, not the prompt; '' preserves the cached UserPromptSubmit.
const effectivePrompt = isGrokEvent(eventName, 'notification')
? ''
: stripGrokUserQueryWrapper(promptText)
const effectivePrompt =
isChild || isGrokEvent(eventName, 'notification') ? '' : stripGrokUserQueryWrapper(promptText)
return normalizeAgentStatusPayload({
state: stateName,
@@ -107,6 +107,13 @@ export function isGrokPermissionNotification(message: string | undefined): boole
)
}
export function isGrokSubagentEvent(hookPayload: Record<string, unknown>): boolean {
return (
readString(hookPayload, 'subagentType') !== undefined ||
readString(hookPayload, 'subagent_type') !== undefined
)
}
export function getGrokNotificationType(hookPayload: Record<string, unknown>): string | undefined {
return (
readString(hookPayload, 'notificationType') ??