From 2a4f8e65d8d3f17c467e5dc93ea30a29d62afad6 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:55:06 -0700 Subject: [PATCH] fix(agent-hooks): stop Claude agent-hook runs from minting phantom subagent rows (#33298) Claude tool events from agent-hook runs (`hook-agent-` ids, which never send SubagentStop) no longer mint phantom "Working - Agent" subagent rows. Fixes #27775. Supersedes #27776, #15326. Co-authored-by: DamHV <251416543+DamHV@users.noreply.github.com> --- ...ent-hook-listener-claude-subagents.test.ts | 29 +++++++++++++++++++ .../providers/claude-events.ts | 6 +++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/shared/agent-hook-listener-claude-subagents.test.ts b/src/shared/agent-hook-listener-claude-subagents.test.ts index b3f12bbc444..78be111905a 100644 --- a/src/shared/agent-hook-listener-claude-subagents.test.ts +++ b/src/shared/agent-hook-listener-claude-subagents.test.ts @@ -221,6 +221,35 @@ describe('shared agent-hook-listener', () => { expect(stopped?.payload.state).toBe('done') }) + it('does not mint rows for agent-hook runs on the lead or inside a subagent', () => { + claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'go' }) + // Why: payloads captured from Claude Code 2.1.310; a hook run inside a subagent inherits its agent_type. + const leadHook = claudeEvent({ + hook_event_name: 'PreToolUse', + agent_id: 'hook-agent-f91f48da-b4db-45e3-a8a1-3debdaa4f2f5', + tool_name: 'Read', + tool_input: { file_path: '/tmp/README.md' } + }) + expect(leadHook?.payload.state).toBe('working') + expect(leadHook?.payload.subagents ?? []).toEqual([]) + + claudeEvent({ + hook_event_name: 'SubagentStart', + agent_id: 'aa34e9e23451d1f6b', + agent_type: 'general-purpose' + }) + const childHook = claudeEvent({ + hook_event_name: 'PreToolUse', + agent_id: 'hook-agent-5179ea04-58c0-440a-b861-04ead9f87aaa', + agent_type: 'general-purpose', + tool_name: 'Read', + tool_input: { file_path: '/tmp/README.md' } + }) + expect(childHook?.payload.subagents).toEqual([ + expect.objectContaining({ id: 'aa34e9e23451d1f6b' }) + ]) + }) + it('parks a teammate as a persistent idle row across its stop/idle/lead-Stop cycle', () => { // Why: the interactive agent-teams shape observed live on 2.1.217 — // lifecycle events use `a-` agent ids while background_tasks diff --git a/src/shared/agent-hook-listener/providers/claude-events.ts b/src/shared/agent-hook-listener/providers/claude-events.ts index 018133949bc..f6b455cc6de 100644 --- a/src/shared/agent-hook-listener/providers/claude-events.ts +++ b/src/shared/agent-hook-listener/providers/claude-events.ts @@ -33,6 +33,8 @@ import { buildClaudeStatusPayload } from './claude-status-build' import { trackClaudeTaskNotificationDelivery } from './claude-task-notification-delivery' import { recordClaudeTeammateSpawn } from './claude-teammate-spawn' +const CLAUDE_HOOK_AGENT_ID_PREFIX = 'hook-agent-' + export function normalizeClaudeEvent( state: HookListenerState, eventName: unknown, @@ -179,7 +181,9 @@ export function normalizeClaudeEvent( eventName === 'PostToolUseFailure') ? eventAgentId : undefined - if (eventAgentId && (subagentOriginId || isWaitingInducing)) { + // Why: `"type": "agent"` hook runs emit tool hooks (agent_type only if inherited) but never SubagentStop, so their row would stay working forever; real children may be untyped and start-less. + const isClaudeHookAgent = eventAgentId?.startsWith(CLAUDE_HOOK_AGENT_ID_PREFIX) === true + if (eventAgentId && (subagentOriginId || isWaitingInducing) && !isClaudeHookAgent) { upsertWorkingClaudeSubagent( getOrCreateClaudeSubagentRoster(state, paneKey), eventAgentId,