From cbd24b2872e70c52b5c438c6fc0da655982aae1e Mon Sep 17 00:00:00 2001 From: David Garcia Date: Sat, 24 Oct 2026 09:33:46 +0200 Subject: [PATCH] fix(agent-status): drop Claude teammates stopped with TaskStop from the sidebar (#32048) * fix(agent-status): drop Claude teammates stopped with TaskStop from the sidebar Named background agents run as in-process teammates. TaskStop fires no lifecycle hook, and their background_tasks entries use ids unrelated to the lifecycle agent_id, so an idle teammate row was kept as long as any teammate task remained listed, even after that teammate was stopped. Remember the description of the lead's Agent call that spawned each teammate (Claude lists the teammate's background_tasks entry under it), and at a lead Stop with a complete inventory drop a teammate row whose description is no longer listed. Rows without that evidence keep the previous behavior. Fixes #31818 Co-Authored-By: Claude Opus 5.5 * fix(agent-status): keep a teammate's spawn description when its spawn result lands first Hook requests are handled unordered, so the lead's Agent PostToolUse can arrive before the teammate's SubagentStart. Create the row as idle in that case so the description is kept; SubagentStart then revives it to working, and an idle row never gates the pane if the teammate's stop came first. Moves the spawn recorder into claude-teammate-spawn.ts to keep claude-events.ts within the file length limit. Co-Authored-By: Claude Opus 5.5 * fix(agent-status): retain teammates with clipped task descriptions Claude caps background task descriptions and appends a clipping marker. Match that visible prefix conservatively when reconciling a teammate's full spawn description, so an inventory update cannot hide a live teammate. Keep ambiguous descriptions and unknown inventories safe, with coverage through the production hook normalizer. * fix(agent-status): escape clipping marker in daemon bundle * fix(agent-status): retire stopped teammates only when the inventory is fully attributable Claude registers a teammate task under the whitespace-collapsed spawn description, so compare normalized descriptions. Infer a stop from absence only when every listed teammate matches a recorded spawn; unknown or differently formatted entries keep rows. --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Neil --- .../providers/claude-events.ts | 4 + .../providers/claude-teammate-spawn.ts | 41 +++++ src/shared/claude-subagent-roster.ts | 88 ++++++++++- .../claude-subagent-row-lifecycle.test.ts | 146 ++++++++++++++++++ 4 files changed, 277 insertions(+), 2 deletions(-) create mode 100644 src/shared/agent-hook-listener/providers/claude-teammate-spawn.ts diff --git a/src/shared/agent-hook-listener/providers/claude-events.ts b/src/shared/agent-hook-listener/providers/claude-events.ts index 6c77996421d..018133949bc 100644 --- a/src/shared/agent-hook-listener/providers/claude-events.ts +++ b/src/shared/agent-hook-listener/providers/claude-events.ts @@ -31,6 +31,7 @@ import { } from './claude-pane-hold-evidence' import { buildClaudeStatusPayload } from './claude-status-build' import { trackClaudeTaskNotificationDelivery } from './claude-task-notification-delivery' +import { recordClaudeTeammateSpawn } from './claude-teammate-spawn' export function normalizeClaudeEvent( state: HookListenerState, @@ -101,6 +102,9 @@ export function normalizeClaudeEvent( const isTaskWakeup = eventAgentId === undefined && trackClaudeTaskNotificationDelivery(state, paneKey, eventName, hookPayload, backgroundTasks) + if (eventName === 'PostToolUse' && eventAgentId === undefined) { + recordClaudeTeammateSpawn(state, paneKey, hookPayload) + } const sessionCrons = hookPayload['session_crons'] const sessionCronInventoryPresent = Array.isArray(sessionCrons) const hasActiveSessionCron = sessionCronInventoryPresent && sessionCrons.length > 0 diff --git a/src/shared/agent-hook-listener/providers/claude-teammate-spawn.ts b/src/shared/agent-hook-listener/providers/claude-teammate-spawn.ts new file mode 100644 index 00000000000..c8c9d3bcfa3 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/claude-teammate-spawn.ts @@ -0,0 +1,41 @@ +import { + isClaudeTeammateLifecycleId, + recordClaudeTeammateTaskDescription +} from '../../claude-subagent-roster' +import type { HookListenerState } from '../listener-state' +import { readString } from '../tool-input-preview' +import { getOrCreateClaudeSubagentRoster } from './claude-roster-state' + +/** A lead Agent call that spawned a teammate (captured on 2.1.308) names the description Claude + * lists the teammate's `background_tasks` entry under. */ +export function recordClaudeTeammateSpawn( + state: HookListenerState, + paneKey: string, + hookPayload: Record +): void { + const input = hookPayload['tool_input'] + const response = hookPayload['tool_response'] + if ( + readString(hookPayload, 'tool_name') !== 'Agent' || + typeof input !== 'object' || + input === null || + typeof response !== 'object' || + response === null || + readString({ ...response }, 'status') !== 'teammate_spawned' + ) { + return + } + const agentId = readString({ ...response }, 'agent_id') + // Why: Claude registers the teammate task under the whitespace-collapsed spawn description. + const description = readString({ ...input }, 'description') + ?.replace(/\s+/g, ' ') + .trim() + if (agentId && description && isClaudeTeammateLifecycleId(agentId)) { + recordClaudeTeammateTaskDescription( + getOrCreateClaudeSubagentRoster(state, paneKey), + agentId, + { agentType: readString({ ...response }, 'name'), description }, + Date.now() + ) + } +} diff --git a/src/shared/claude-subagent-roster.ts b/src/shared/claude-subagent-roster.ts index 2ea71d279aa..e5405fff1fb 100644 --- a/src/shared/claude-subagent-roster.ts +++ b/src/shared/claude-subagent-roster.ts @@ -50,6 +50,10 @@ export type TrackedClaudeSubagent = { * teammate-shaped. Never cleared: the listing mode of an id can't change * mid-life. */ listedAsSubagentTask?: true + /** The `description` of the lead's Agent call that spawned this teammate. Claude lists the + * teammate's `background_tasks` entry under that same description (its id is unrelated), so a + * complete inventory without it means the teammate was stopped (`TaskStop` fires no hook). */ + teammateTaskDescription?: string } /** Whether a hook ends one child's turn. An API error (e.g. a 429) skips the @@ -139,7 +143,8 @@ export function stopClaudeSubagent(roster: ClaudeSubagentRoster, id: string): vo * subagent-listed, the list still shows teammate-typed tasks, AND it is * working or TeammateIdle-confirmed: that is a named teammate whose id * simply never appears, and removing it would drop the pane's done-gate - * (working) or its parked idle row (confirmed). */ + * (working) or its parked idle row (confirmed) — unless its spawn description is + * no longer listed among the teammate-typed tasks, which means it was stopped. */ export function foldClaudeBackgroundTasksIntoRoster( roster: ClaudeSubagentRoster, tasks: ClaudeBackgroundAgentTask[], @@ -154,6 +159,7 @@ export function foldClaudeBackgroundTasksIntoRoster( } const listedIds = new Set() const hasTeammateTypedTask = tasks.some((task) => task.teammate) + const listedTeammateDescriptions = readProvableClaudeTeammateDescriptions(roster, tasks) for (const task of tasks) { if (task.teammate) { continue @@ -204,7 +210,15 @@ export function foldClaudeBackgroundTasksIntoRoster( // Why: an idle row that no TeammateIdle ever confirmed is a finished // workflow lane wearing a teammate-shaped id — reap it here or the // pre-#8825 idle pile rebuilds one lane per lead turn. - (tracked.state === 'working' || tracked.confirmedTeammate === true) + (tracked.state === 'working' || tracked.confirmedTeammate === true) && + !( + listedTeammateDescriptions !== null && + tracked.teammateTaskDescription !== undefined && + !isClaudeTeammateDescriptionListed( + tracked.teammateTaskDescription, + listedTeammateDescriptions + ) + ) ) { continue } @@ -213,6 +227,76 @@ export function foldClaudeBackgroundTasksIntoRoster( } } +/** Listed teammate descriptions, or null when they cannot prove which tracked teammate stopped. + * Why: a stop is inferred from absence, so every listed teammate must match a recorded spawn + * description; an undescribed, unknown, or differently formatted entry could be a live row. */ +function readProvableClaudeTeammateDescriptions( + roster: ClaudeSubagentRoster, + tasks: ClaudeBackgroundAgentTask[] +): ReadonlySet | null { + const recorded: string[] = [] + for (const tracked of roster.values()) { + if (tracked.teammateTaskDescription !== undefined) { + recorded.push(tracked.teammateTaskDescription) + } + } + const listed = new Set() + for (const task of tasks) { + if (!task.teammate) { + continue + } + const description = task.description + if (!description || !recorded.some((r) => claudeTeammateDescriptionMatches(r, description))) { + return null + } + listed.add(description) + } + return listed +} + +function claudeTeammateDescriptionMatches(recorded: string, listed: string): boolean { + if (recorded === listed) { + return true + } + // Claude clips inventory descriptions; ambiguous prefix matches must retain every possible owner. + const clippingMarker = /\u2026 \[\+\d+ chars\]$/.exec(listed) + return clippingMarker !== null && recorded.startsWith(listed.slice(0, clippingMarker.index)) +} + +function isClaudeTeammateDescriptionListed( + description: string, + listedDescriptions: ReadonlySet +): boolean { + for (const listed of listedDescriptions) { + if (claudeTeammateDescriptionMatches(description, listed)) { + return true + } + } + return false +} + +/** Remember which `background_tasks` description lists a teammate the lead's Agent call spawned. */ +export function recordClaudeTeammateTaskDescription( + roster: ClaudeSubagentRoster, + agentId: string, + fields: { agentType?: string; description: string }, + now: number +): void { + if (!isClaudeTeammateLifecycleId(agentId) || agentId.length > CLAUDE_SUBAGENT_ID_MAX_LENGTH) { + return + } + // Why idle: hook requests are unordered, so this can precede the SubagentStart that revives the + // row to working, or follow the teammate's own stop. Idle never gates the pane, and an + // unconfirmed idle row is reaped at the next complete lead-Stop fold. + const tracked = roster.get(agentId) ?? { + state: 'idle', + startedAt: now, + agentType: fields.agentType + } + tracked.teammateTaskDescription = fields.description + roster.set(agentId, tracked) +} + /** Drop restored rows that no current-runtime child activity has confirmed. */ export function reapUnconfirmedRestoredClaudeSubagents(roster: ClaudeSubagentRoster): boolean { let changed = false diff --git a/src/shared/claude-subagent-row-lifecycle.test.ts b/src/shared/claude-subagent-row-lifecycle.test.ts index 81da35317a7..03959f89b48 100644 --- a/src/shared/claude-subagent-row-lifecycle.test.ts +++ b/src/shared/claude-subagent-row-lifecycle.test.ts @@ -53,6 +53,30 @@ describe('claude subagent sidebar row lifecycle', () => { const claudeEvent = (payload: Record): ReturnType => normalizeHookPayload(state, 'claude', { paneKey: PANE_KEY, payload }, 'production') + const spawnTeammate = ( + name: string, + agentId: string, + description: string, + order: 'start-first' | 'spawn-result-first' = 'start-first' + ): void => { + const start = () => + claudeEvent({ hook_event_name: 'SubagentStart', agent_id: agentId, agent_type: name }) + const spawnResult = () => + claudeEvent({ + hook_event_name: 'PostToolUse', + tool_name: 'Agent', + tool_input: { description, prompt: `Reply ${name}`, name }, + tool_response: { status: 'teammate_spawned', agent_id: agentId, name } + }) + if (order === 'start-first') { + start() + spawnResult() + } else { + spawnResult() + start() + } + } + it('drops each finished workflow subagent instead of accumulating idle rows', () => { claudeEvent({ hook_event_name: 'UserPromptSubmit', @@ -413,4 +437,126 @@ describe('claude subagent sidebar row lifecycle', () => { expect.objectContaining({ id: 'asecond-5e6f7a8b', state: 'working' }) ]) }) + + it('drops teammates stopped with TaskStop while another teammate stays alive', () => { + // Shapes captured live on 2.1.308: TaskStop fires no lifecycle hook, and each teammate's + // background_tasks entry carries its spawn description under an unrelated `t…` id. + const alphaTask = { id: 'tjv37r4y4', type: 'teammate', status: 'running', description: 'Alpha' } + const betaTask = { id: 'tjint303j', type: 'teammate', status: 'running', description: 'Beta' } + const gammaTask = { id: 'te1pn74jo', type: 'teammate', status: 'running', description: 'Gamma' } + claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'start alpha, beta and gamma' }) + spawnTeammate('alpha', 'aalpha-b1567bb51740a755', 'Alpha') + // Hook requests are handled unordered, so the spawn result can land before SubagentStart. + spawnTeammate('beta', 'abeta-1917b198710ec27a', 'Beta', 'spawn-result-first') + spawnTeammate('gamma', 'agamma-8889833b0c898959', 'Gamma') + for (const [name, agentId] of [ + ['alpha', 'aalpha-b1567bb51740a755'], + ['gamma', 'agamma-8889833b0c898959'] + ]) { + claudeEvent({ hook_event_name: 'SubagentStop', agent_id: agentId, agent_type: name }) + claudeEvent({ hook_event_name: 'TeammateIdle', agent_id: agentId, teammate_name: name }) + } + const listed = claudeEvent({ + hook_event_name: 'Stop', + background_tasks: [alphaTask, betaTask, gammaTask] + }) + expect(listed?.payload.state).toBe('working') + expect(listed?.payload.subagents).toHaveLength(3) + + // The lead stops idle alpha and still-working beta; only gamma's entry is listed after. + claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'stop alpha and beta' }) + const afterStop = claudeEvent({ hook_event_name: 'Stop', background_tasks: [gammaTask] }) + expect(afterStop?.payload.state).toBe('done') + expect(afterStop?.payload.subagents).toEqual([ + expect.objectContaining({ id: 'agamma-8889833b0c898959', state: 'idle' }) + ]) + }) + + it.each([ + { + kind: 'clipped', + spawned: `Review ${'x'.repeat(1100)}`, + listed: `Review ${'x'.repeat(993)}… [+107 chars]` + }, + { kind: 'whitespace-collapsed', spawned: ' Review\n the diff ', listed: 'Review the diff' } + ])( + 'keeps a working teammate whose inventory description was $kind while reaping a stopped sibling', + ({ spawned, listed }) => { + spawnTeammate('alpha', 'aalpha-b1567bb51740a755', 'Alpha') + spawnTeammate('beta', 'abeta-1917b198710ec27a', spawned) + + const stop = claudeEvent({ + hook_event_name: 'Stop', + background_tasks: [ + { id: 'tbeta', type: 'teammate', status: 'running', description: listed } + ] + }) + expect(stop?.payload.subagents).toEqual([ + expect.objectContaining({ id: 'abeta-1917b198710ec27a', state: 'working' }) + ]) + expect(stop?.payload.state).toBe('working') + } + ) + + it('keeps every teammate when a listed teammate matches no recorded spawn description', () => { + spawnTeammate('alpha', 'aalpha-b1567bb51740a755', 'Alpha') + spawnTeammate('beta', 'abeta-1917b198710ec27a', 'Beta') + + const stop = claudeEvent({ + hook_event_name: 'Stop', + background_tasks: [ + { id: 'tbeta', type: 'teammate', status: 'running', description: 'Beta' }, + { id: 'tother', type: 'teammate', status: 'running', description: 'Beta, reworded' } + ] + }) + expect(stop?.payload.subagents).toEqual([ + expect.objectContaining({ id: 'aalpha-b1567bb51740a755', state: 'working' }), + expect.objectContaining({ id: 'abeta-1917b198710ec27a', state: 'working' }) + ]) + }) + + it.each(['identical descriptions', 'clipped descriptions'])('keeps ambiguous %s', (kind) => { + const prefix = 'Review '.repeat(150) + const description = kind === 'identical descriptions' ? 'Review code' : prefix + spawnTeammate('alpha', 'aalpha-b1567bb51740a755', description) + spawnTeammate( + 'beta', + 'abeta-1917b198710ec27a', + kind === 'identical descriptions' ? description : `${prefix} with tests` + ) + const listed = + kind === 'identical descriptions' ? description : `${prefix.slice(0, 1000)}… [+61 chars]` + + const stop = claudeEvent({ + hook_event_name: 'Stop', + background_tasks: [ + { id: 'tsurvivor', type: 'teammate', status: 'running', description: listed } + ] + }) + expect(stop?.payload.subagents).toEqual([ + expect.objectContaining({ id: 'aalpha-b1567bb51740a755', state: 'working' }), + expect.objectContaining({ id: 'abeta-1917b198710ec27a', state: 'working' }) + ]) + expect(stop?.payload.state).toBe('working') + }) + + it.each([ + { evidence: 'missing inventory', tasks: undefined }, + { + evidence: 'undescribed teammate', + tasks: [{ id: 'tother', type: 'teammate', status: 'running' }] + }, + { + evidence: 'partial inventory', + tasks: [{ id: 'tother', type: 'teammate', status: 'running', description: 'Other' }, null] + } + ])('keeps a confirmed idle teammate with $evidence', ({ tasks }) => { + spawnTeammate('alpha', 'aalpha-b1567bb51740a755', 'Alpha') + claudeEvent({ hook_event_name: 'TeammateIdle', teammate_name: 'alpha' }) + + const stop = claudeEvent({ hook_event_name: 'Stop', background_tasks: tasks }) + expect(stop?.payload.subagents).toEqual([ + expect.objectContaining({ id: 'aalpha-b1567bb51740a755', state: 'idle' }) + ]) + }) })