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' }) + ]) + }) })