diff --git a/src/shared/agent-hook-listener.test.ts b/src/shared/agent-hook-listener.test.ts index a593d3bcae9..efafcaa5b06 100644 --- a/src/shared/agent-hook-listener.test.ts +++ b/src/shared/agent-hook-listener.test.ts @@ -26,6 +26,7 @@ import { clearGrokSessionPathLookupCacheForTests, findGrokChatHistoryBySessionId } from './grok-session-paths' +import { AGENT_STATUS_MAX_SUBAGENTS } from './agent-status-types' import { makePaneKey } from './stable-pane-id' const LEAF_ID = '11111111-1111-4111-8111-111111111111' @@ -2309,9 +2310,9 @@ describe('shared agent-hook-listener', () => { const stopped = claudeEvent({ hook_event_name: 'SubagentStop', agent_id: 'r1' }) expect(stopped?.payload.state).toBe('done') - expect(stopped?.payload.subagents).toEqual([ - expect.objectContaining({ id: 'r1', state: 'idle' }) - ]) + // Why: a finished one-shot leaves the sidebar instead of squatting as a + // permanent idle row for the rest of the session. + expect(stopped?.payload.subagents).toBeUndefined() }) it('keeps gating on tracked children when background_tasks is absent (older Claude)', () => { @@ -2408,6 +2409,35 @@ describe('shared agent-hook-listener', () => { ]) }) + it('keeps a teammate whose name differs from its configured agent type', () => { + claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'spawn reviewer' }) + claudeEvent({ + hook_event_name: 'SubagentStart', + agent_id: 'areviewer-6d3cb5b52120b7bf', + agent_type: 'security-reviewer' + }) + + // Why: teammate name and agent type are separate Agent-tool inputs; the + // lifecycle id embeds the former while the hook reports the latter. + claudeEvent({ + hook_event_name: 'SubagentStop', + agent_id: 'areviewer-6d3cb5b52120b7bf', + agent_type: 'security-reviewer' + }) + const idled = claudeEvent({ + hook_event_name: 'TeammateIdle', + teammate_name: 'reviewer', + team_name: 'session-x' + }) + expect(idled?.payload.subagents).toEqual([ + expect.objectContaining({ + id: 'areviewer-6d3cb5b52120b7bf', + agentType: 'security-reviewer', + state: 'idle' + }) + ]) + }) + it('scopes subagent rosters per pane', () => { claudeEvent( { hook_event_name: 'SubagentStart', agent_id: 'a1', agent_type: 'general-purpose' }, @@ -2555,7 +2585,7 @@ describe('shared agent-hook-listener', () => { expect(stopped?.payload.state).toBe('done') }) - it('demotes a snapshot-seeded child missing from a present background_tasks list', () => { + it('removes a snapshot-seeded child missing from a present background_tasks list', () => { seedClaudeSubagentRosterFromSnapshots(state, PANE_KEY, [ { id: 'a77', state: 'working', startedAt: 1000, agentType: 'general-purpose' } ]) @@ -2569,9 +2599,7 @@ describe('shared agent-hook-listener', () => { ] }) expect(stop?.payload.state).toBe('done') - expect(stop?.payload.subagents).toEqual([ - expect.objectContaining({ id: 'a77', state: 'idle' }) - ]) + expect(stop?.payload.subagents).toBeUndefined() }) it('keeps a snapshot-seeded child working while background_tasks still lists it', () => { @@ -2589,6 +2617,29 @@ describe('shared agent-hook-listener', () => { ]) }) + it('keeps a live child omitted by the background task snapshot cap', () => { + claudeEvent({ + hook_event_name: 'SubagentStart', + agent_id: 'alive-after-cap', + agent_type: 'general-purpose' + }) + const stop = claudeEvent({ + hook_event_name: 'Stop', + background_tasks: Array.from({ length: AGENT_STATUS_MAX_SUBAGENTS + 1 }, (_, index) => ({ + id: index === AGENT_STATUS_MAX_SUBAGENTS ? 'alive-after-cap' : `a${index}`, + type: 'subagent', + status: 'running' + })) + }) + + // Why: the inventory was capped before this id, so omission cannot + // prove the lifecycle-tracked child finished or was killed. + expect(stop?.payload.subagents).toContainEqual( + expect.objectContaining({ id: 'alive-after-cap', state: 'working' }) + ) + expect(stop?.payload.state).toBe('working') + }) + it('does not adopt a known child turn-boundary event as the lead state', () => { claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'go' }) claudeEvent({ @@ -2654,7 +2705,12 @@ describe('shared agent-hook-listener', () => { it('seeds the roster from persisted snapshots so a teammate-bearing Stop keeps child rows', () => { seedClaudeSubagentRosterFromSnapshots(state, PANE_KEY, [ - { id: 'aprobe2-abc', state: 'idle', startedAt: 1000, agentType: 'probe2' } + { + id: 'aprobe2-6d3cb5b52120b7bf', + state: 'idle', + startedAt: 1000, + agentType: 'security-reviewer' + } ]) claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'after restart' }) const stop = claudeEvent({ @@ -2665,7 +2721,11 @@ describe('shared agent-hook-listener', () => { }) expect(stop?.payload.state).toBe('done') expect(stop?.payload.subagents).toEqual([ - expect.objectContaining({ id: 'aprobe2-abc', state: 'idle' }) + expect.objectContaining({ + id: 'aprobe2-6d3cb5b52120b7bf', + agentType: 'security-reviewer', + state: 'idle' + }) ]) }) diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index ce3e2f720a4..1e3728eaef7 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -38,8 +38,9 @@ import { claudeRosterHasWorkingSubagent, claudeRosterToSnapshots, claudeTeammateIdMatchesName, + finishClaudeSubagent, foldClaudeBackgroundTasksIntoRoster, - markClaudeSubagentIdle, + isClaudeTeammateLifecycleId, markClaudeTeammateIdleByName, readClaudeBackgroundAgentTasks, upsertWorkingClaudeSubagent, @@ -2411,7 +2412,14 @@ function normalizeClaudeSubagentLifecycleEvent( Date.now() ) } else { - markClaudeSubagentIdle(roster, agentId) + // Why: SubagentStop carries the session's task inventory; a stopping + // agent listed id-exact as a subagent task is a workflow/named one-shot + // (teammate lifecycle ids never appear there), not a resumable teammate. + const stopTasks = readClaudeBackgroundAgentTasks(hookPayload) + finishClaudeSubagent(roster, agentId, { + listedAsSubagentTask: + stopTasks.present && stopTasks.tasks.some((task) => !task.teammate && task.id === agentId) + }) // Why: a blocked child that dies (killed, errored) without another tool // event would otherwise pin its permission/question wait on the pane // forever — nothing else references that agent again. @@ -2449,9 +2457,13 @@ export function seedClaudeSubagentRosterFromSnapshots( startedAt: snapshot.startedAt, agentType: snapshot.agentType, description: snapshot.description, + // Why: teammate name and agent type can differ, but the provider id + // shape survives persistence and keeps the row across restart folds. + ...(isClaudeTeammateLifecycleId(snapshot.id) ? { teammate: true as const } : {}), // Why: the seed can be a phantom (child finished while Orca was down, // its SubagentStop lost). Let a PRESENT background_tasks list that - // omits the id demote it instead of gating the pane 'working' forever. + // omits the id remove it (or demote a teammate) instead of gating the + // pane 'working' forever. backgroundTasksAuthoritative: true }) } @@ -2609,7 +2621,8 @@ function normalizeClaudeEvent( foldClaudeBackgroundTasksIntoRoster( getOrCreateClaudeSubagentRoster(state, paneKey), backgroundTasks.tasks, - Date.now() + Date.now(), + { inventoryComplete: !backgroundTasks.truncated } ) } } diff --git a/src/shared/claude-subagent-roster.test.ts b/src/shared/claude-subagent-roster.test.ts index e8a4cb6cb1e..99cd9bbb8dc 100644 --- a/src/shared/claude-subagent-roster.test.ts +++ b/src/shared/claude-subagent-roster.test.ts @@ -4,8 +4,8 @@ import { claudeRosterHasWorkingSubagent, claudeRosterToSnapshots, claudeTeammateIdMatchesName, + finishClaudeSubagent, foldClaudeBackgroundTasksIntoRoster, - markClaudeSubagentIdle, markClaudeTeammateIdleByName, readClaudeBackgroundAgentTasks, upsertWorkingClaudeSubagent, @@ -13,55 +13,136 @@ import { } from './claude-subagent-roster' describe('claude-subagent-roster', () => { - it('tracks spawn and stop as working → idle', () => { + it('removes a finished one-shot subagent on stop', () => { const roster: ClaudeSubagentRoster = new Map() upsertWorkingClaudeSubagent(roster, 'a1', { agentType: 'general-purpose' }, 100) expect(claudeRosterHasWorkingSubagent(roster)).toBe(true) - markClaudeSubagentIdle(roster, 'a1') - expect(claudeRosterHasWorkingSubagent(roster)).toBe(false) - expect(claudeRosterToSnapshots(roster)).toEqual([ - { - id: 'a1', - state: 'idle', - startedAt: 100, - agentType: 'general-purpose', - description: undefined - } - ]) + // Why: retaining finished one-shots as idle rows piled up dozens of dead + // "Idle - general-purpose" sidebar rows over a long workflow session. + finishClaudeSubagent(roster, 'a1') + expect(roster.size).toBe(0) + expect(claudeRosterToSnapshots(roster)).toBeUndefined() }) - it('re-marks an idle subagent working without resetting startedAt', () => { + it('idles a teammate on stop instead of removing it', () => { const roster: ClaudeSubagentRoster = new Map() - upsertWorkingClaudeSubagent(roster, 'a1', {}, 100) - markClaudeSubagentIdle(roster, 'a1') - upsertWorkingClaudeSubagent(roster, 'a1', { description: 'round two' }, 200) - expect(roster.get('a1')).toMatchObject({ + upsertWorkingClaudeSubagent(roster, 'aprobe1-6d3cb5b5', { agentType: 'probe1' }, 100) + finishClaudeSubagent(roster, 'aprobe1-6d3cb5b5') + expect(roster.get('aprobe1-6d3cb5b5')).toMatchObject({ state: 'idle', teammate: true }) + }) + + it('removes a finished workflow lane despite its teammate-shaped id when task-listed', () => { + const roster: ClaudeSubagentRoster = new Map() + // Why: workflow lanes get name-embedding ids (afinder-C-) like + // teammates, but their SubagentStop payload lists them id-exact as a + // subagent task — real teammate lifecycle ids never appear there. + upsertWorkingClaudeSubagent( + roster, + 'afinder-C-5d713c0781b7f8d2', + { agentType: 'finder-C' }, + 100 + ) + finishClaudeSubagent(roster, 'afinder-C-5d713c0781b7f8d2', { listedAsSubagentTask: true }) + expect(roster.size).toBe(0) + }) + + it('reclassifies a task-listed teammate-shaped entry as a one-shot', () => { + const roster: ClaudeSubagentRoster = new Map() + upsertWorkingClaudeSubagent( + roster, + 'av1-streaming-0b1c2d3e', + { agentType: 'v1-streaming' }, + 100 + ) + foldClaudeBackgroundTasksIntoRoster( + roster, + [ + { + id: 'av1-streaming-0b1c2d3e', + agentType: 'v1-streaming', + description: undefined, + running: true, + teammate: false + }, + { + id: 'tteam1', + agentType: undefined, + description: undefined, + running: true, + teammate: true + } + ], + 200 + ) + // A later stop without its own inventory still removes it: the fold + // already proved the id is a task id, not a teammate. + finishClaudeSubagent(roster, 'av1-streaming-0b1c2d3e') + expect(roster.has('av1-streaming-0b1c2d3e')).toBe(false) + }) + + it('removes teammate-shaped leftovers when a complete inventory lists no teammates', () => { + const roster: ClaudeSubagentRoster = new Map() + upsertWorkingClaudeSubagent(roster, 'acr-triage-1-c5a0588e', { agentType: 'cr-triage-1' }, 100) + finishClaudeSubagent(roster, 'acr-triage-1-c5a0588e') + expect(roster.get('acr-triage-1-c5a0588e')).toMatchObject({ state: 'idle' }) + upsertWorkingClaudeSubagent(roster, 'afix-main-11223344', { agentType: 'fix-main' }, 150) + + // Why: a teams session lists its teammates (even idle) as teammate-typed + // tasks; an inventory with none proves these name-shaped rows are dead + // workflow lanes, not resumable teammates. + foldClaudeBackgroundTasksIntoRoster( + roster, + [ + { + id: 'aunrelated0000001', + agentType: 'general-purpose', + description: undefined, + running: true, + teammate: false + } + ], + 200 + ) + expect(roster.has('acr-triage-1-c5a0588e')).toBe(false) + expect(roster.has('afix-main-11223344')).toBe(false) + expect(roster.has('aunrelated0000001')).toBe(true) + }) + + it('re-marks an idle teammate working without resetting startedAt', () => { + const roster: ClaudeSubagentRoster = new Map() + upsertWorkingClaudeSubagent(roster, 'aprobe1-6d3cb5b5', { agentType: 'probe1' }, 100) + finishClaudeSubagent(roster, 'aprobe1-6d3cb5b5') + upsertWorkingClaudeSubagent(roster, 'aprobe1-6d3cb5b5', { description: 'round two' }, 200) + expect(roster.get('aprobe1-6d3cb5b5')).toMatchObject({ state: 'working', startedAt: 100, description: 'round two' }) }) - it('ignores unknown ids on markClaudeSubagentIdle', () => { + it('ignores unknown ids on finishClaudeSubagent', () => { const roster: ClaudeSubagentRoster = new Map() - markClaudeSubagentIdle(roster, 'ghost') + finishClaudeSubagent(roster, 'ghost') expect(roster.size).toBe(0) }) it('caps roster size, evicting the oldest idle entry first', () => { const roster: ClaudeSubagentRoster = new Map() - for (let i = 0; i < AGENT_STATUS_MAX_SUBAGENTS; i++) { + upsertWorkingClaudeSubagent(roster, 'aprobe1-6d3cb5b5', { agentType: 'probe1' }, 0) + for (let i = 1; i < AGENT_STATUS_MAX_SUBAGENTS; i++) { upsertWorkingClaudeSubagent(roster, `a${i}`, {}, i) } // Why: all working — a new spawn cannot evict live children and is dropped. upsertWorkingClaudeSubagent(roster, 'overflow', {}, 999) expect(roster.has('overflow')).toBe(false) - markClaudeSubagentIdle(roster, 'a3') + // Why: only teammates hold idle entries now; an idle teammate is the + // eviction pool when a burst of new spawns hits the cap. + finishClaudeSubagent(roster, 'aprobe1-6d3cb5b5') upsertWorkingClaudeSubagent(roster, 'replacement', {}, 1000) expect(roster.has('replacement')).toBe(true) - expect(roster.has('a3')).toBe(false) + expect(roster.has('aprobe1-6d3cb5b5')).toBe(false) expect(roster.size).toBe(AGENT_STATUS_MAX_SUBAGENTS) }) @@ -105,11 +186,21 @@ describe('claude-subagent-roster', () => { expect(readClaudeBackgroundAgentTasks({ background_tasks: 'nope' }).present).toBe(false) }) + it('marks a background task inventory truncated after the snapshot cap', () => { + const tasks = Array.from({ length: AGENT_STATUS_MAX_SUBAGENTS + 1 }, (_, index) => ({ + id: `a${index}`, + type: 'subagent', + status: 'running' + })) + const result = readClaudeBackgroundAgentTasks({ background_tasks: tasks }) + expect(result.tasks).toHaveLength(AGENT_STATUS_MAX_SUBAGENTS) + expect(result.truncated).toBe(true) + }) + it('folds background_tasks in without trusting ambiguous entries', () => { const roster: ClaudeSubagentRoster = new Map() upsertWorkingClaudeSubagent(roster, 'a1', {}, 100) - markClaudeSubagentIdle(roster, 'a1') - upsertWorkingClaudeSubagent(roster, 'ateam-xyz', { agentType: 'reviewer' }, 150) + upsertWorkingClaudeSubagent(roster, 'ateam-6d3cb5b5', { agentType: 'security-reviewer' }, 150) foldClaudeBackgroundTasksIntoRoster( roster, @@ -137,7 +228,56 @@ describe('claude-subagent-roster', () => { expect(roster.size).toBe(2) // Why: id-exact matches are one-shot subagents whose run state IS reliable. expect(roster.get('a1')).toMatchObject({ state: 'working', description: 'review loop' }) - expect(roster.get('ateam-xyz')).toMatchObject({ state: 'working', agentType: 'reviewer' }) + // Why: the working lifecycle-tracked teammate is not listed by id, but + // omission proves nothing for teammates — it must survive the fold. + expect(roster.get('ateam-6d3cb5b5')).toMatchObject({ + state: 'working', + agentType: 'security-reviewer' + }) + }) + + it('removes an id-matched task reported not running', () => { + const roster: ClaudeSubagentRoster = new Map() + upsertWorkingClaudeSubagent(roster, 'a1', { agentType: 'general-purpose' }, 100) + foldClaudeBackgroundTasksIntoRoster( + roster, + [{ id: 'a1', agentType: undefined, description: undefined, running: false, teammate: false }], + 200 + ) + expect(roster.size).toBe(0) + }) + + it('removes a killed one-shot missing from a present list', () => { + const roster: ClaudeSubagentRoster = new Map() + // Why: a running one-shot is always listed id-exact at a lead Stop, so a + // working non-teammate missing from the list is dead (SubagentStop lost); + // keeping it pinned the pane 'working' forever. + upsertWorkingClaudeSubagent(roster, 'akilled0000000001', { agentType: 'general-purpose' }, 100) + foldClaudeBackgroundTasksIntoRoster( + roster, + [ + { id: 'other', agentType: undefined, description: undefined, running: true, teammate: true } + ], + 200 + ) + expect(roster.size).toBe(0) + }) + + it('retains an unlisted live child when the background task inventory was truncated', () => { + const roster: ClaudeSubagentRoster = new Map() + upsertWorkingClaudeSubagent(roster, 'alive-after-cap', {}, 100) + const parsed = readClaudeBackgroundAgentTasks({ + background_tasks: Array.from({ length: AGENT_STATUS_MAX_SUBAGENTS + 1 }, (_, index) => ({ + id: index === AGENT_STATUS_MAX_SUBAGENTS ? 'alive-after-cap' : `a${index}`, + type: 'subagent', + status: 'running' + })) + }) + + foldClaudeBackgroundTasksIntoRoster(roster, parsed.tasks, 200, { + inventoryComplete: !parsed.truncated + }) + expect(roster.has('alive-after-cap')).toBe(true) }) it('recreates unmatched running one-shot subagents after a listener restart', () => { @@ -174,17 +314,14 @@ describe('claude-subagent-roster', () => { expect(roster.size).toBe(0) }) - it('demotes task-id-authoritative entries missing from a present list', () => { + it('removes non-teammate authoritative entries and keeps live teammates on omission', () => { const roster: ClaudeSubagentRoster = new Map() - // Why: seeded/bt-sourced ids ARE task ids; absence from a present list - // proves the task finished. Lifecycle-tracked ids (teammates) prove - // nothing by absence and must keep their state. roster.set('a-phantom', { state: 'working', startedAt: 100, backgroundTasksAuthoritative: true }) - upsertWorkingClaudeSubagent(roster, 'ateam-xyz', { agentType: 'reviewer' }, 150) + upsertWorkingClaudeSubagent(roster, 'ateam-6d3cb5b5', { agentType: 'security-reviewer' }, 150) foldClaudeBackgroundTasksIntoRoster( roster, @@ -193,11 +330,33 @@ describe('claude-subagent-roster', () => { ], 200 ) - expect(roster.get('a-phantom')).toMatchObject({ state: 'idle' }) - expect(roster.get('ateam-xyz')).toMatchObject({ state: 'working' }) + expect(roster.has('a-phantom')).toBe(false) + expect(roster.get('ateam-6d3cb5b5')).toMatchObject({ state: 'working' }) }) - it('marks fold-recreated entries as task-id-authoritative for later folds', () => { + it('demotes a seeded teammate phantom missing from a present list to idle', () => { + const roster: ClaudeSubagentRoster = new Map() + // Why: a teammate seeded from a pre-restart snapshot may be dead, but its + // task id never appears in background_tasks — demote instead of delete so + // a live idle teammate keeps its row while a phantom stops gating the pane. + roster.set('aprobe1-6d3cb5b5', { + state: 'working', + startedAt: 100, + agentType: 'probe1', + teammate: true, + backgroundTasksAuthoritative: true + }) + foldClaudeBackgroundTasksIntoRoster( + roster, + [ + { id: 'other', agentType: undefined, description: undefined, running: true, teammate: true } + ], + 200 + ) + expect(roster.get('aprobe1-6d3cb5b5')).toMatchObject({ state: 'idle', teammate: true }) + }) + + it('removes fold-recreated entries missing from a later present list', () => { const roster: ClaudeSubagentRoster = new Map() foldClaudeBackgroundTasksIntoRoster( roster, @@ -211,13 +370,21 @@ describe('claude-subagent-roster', () => { ], 200 ) - expect(roster.get('a9')).toMatchObject({ state: 'idle' }) + expect(roster.has('a9')).toBe(false) }) - it('stops demoting an entry once live activity re-tracks it', () => { + it('keeps a re-tracked working teammate missing from a present list', () => { const roster: ClaudeSubagentRoster = new Map() - roster.set('a-seeded', { state: 'working', startedAt: 100, backgroundTasksAuthoritative: true }) - upsertWorkingClaudeSubagent(roster, 'a-seeded', {}, 150) + roster.set('aprobe1-6d3cb5b5', { + state: 'working', + startedAt: 100, + agentType: 'probe1', + teammate: true, + backgroundTasksAuthoritative: true + }) + // Why: live activity clears the authoritative flag; a busy teammate must + // not be demoted by a Stop that (as always) omits its lifecycle id. + upsertWorkingClaudeSubagent(roster, 'aprobe1-6d3cb5b5', { agentType: 'probe1' }, 150) foldClaudeBackgroundTasksIntoRoster( roster, @@ -226,7 +393,7 @@ describe('claude-subagent-roster', () => { ], 200 ) - expect(roster.get('a-seeded')).toMatchObject({ state: 'working' }) + expect(roster.get('aprobe1-6d3cb5b5')).toMatchObject({ state: 'working' }) }) it('matches teammate ids by name only up to the hyphen-free suffix', () => { diff --git a/src/shared/claude-subagent-roster.ts b/src/shared/claude-subagent-roster.ts index b4b4f7ef946..b0b945a76e2 100644 --- a/src/shared/claude-subagent-roster.ts +++ b/src/shared/claude-subagent-roster.ts @@ -14,12 +14,18 @@ export type TrackedClaudeSubagent = { description?: string state: 'working' | 'idle' startedAt: number - /** The id came from background_tasks or a persisted snapshot, not live - * lifecycle events, so a PRESENT list omitting it proves the task is gone - * (a phantom seeded before restart would otherwise gate the pane 'working' - * forever — teams sessions never send an empty list). Cleared once live - * activity re-tracks the id, so a seeded-but-alive teammate is demoted at - * most until its next tool event. */ + /** Known agent-teams teammate: its id embeds its name (`a-`) + * while one-shot ids are hyphen-free (`a`). Teammates are long-lived — + * SubagentStop means "finished a task", not "gone" — and their lifecycle + * ids never appear in background_tasks, so omission proves nothing. */ + teammate?: true + /** The id came from a persisted snapshot or background_tasks, not live + * lifecycle events. Only matters for teammate-shaped seeds now: a PRESENT + * list omitting a seeded-working teammate demotes it to idle so a phantom + * seeded before restart can't gate the pane 'working' forever (teams + * sessions never send an empty list). Cleared once live activity re-tracks + * the id. Non-teammate entries omitted from a present list are removed + * outright regardless of this flag. */ backgroundTasksAuthoritative?: boolean } @@ -36,6 +42,14 @@ export type ClaudeBackgroundAgentTask = { teammate: boolean } +/** Agent-team lifecycle ids are `a-`. The teammate name + * and agent type are independent spawn fields, so the id shape is the only + * reliable discriminator available on SubagentStart/SubagentStop hooks. */ +export function isClaudeTeammateLifecycleId(id: string): boolean { + const separator = id.lastIndexOf('-') + return separator > 1 && id.startsWith('a') && /^[0-9a-f]+$/i.test(id.slice(separator + 1)) +} + export function upsertWorkingClaudeSubagent( roster: ClaudeSubagentRoster, id: string, @@ -45,11 +59,15 @@ export function upsertWorkingClaudeSubagent( if (id.length === 0 || id.length > CLAUDE_SUBAGENT_ID_MAX_LENGTH) { return } + const teammate = isClaudeTeammateLifecycleId(id) const existing = roster.get(id) if (existing) { existing.state = 'working' existing.agentType = fields.agentType ?? existing.agentType existing.description = fields.description ?? existing.description + if (teammate) { + existing.teammate = true + } // Why: live activity proves the lifecycle stream owns this id again; // background_tasks absence must stop demoting it (teammate ids never // appear there). The fold re-tags its own recreations after this call. @@ -63,7 +81,8 @@ export function upsertWorkingClaudeSubagent( state: 'working', startedAt: now, agentType: fields.agentType, - description: fields.description + description: fields.description, + ...(teammate ? { teammate: true as const } : {}) }) } @@ -83,11 +102,31 @@ function evictOldestIdleClaudeSubagent(roster: ClaudeSubagentRoster): boolean { return true } -export function markClaudeSubagentIdle(roster: ClaudeSubagentRoster, id: string): void { +/** SubagentStop: a finished one-shot subagent leaves the sidebar immediately — + * retaining it as an idle row made long workflow/ultracode sessions pile up + * dozens of dead rows. Teammates only idle (alive + resumable): SubagentStop + * fires each time a teammate finishes a task, not just at shutdown. + * + * Workflow/named one-shots share the teammate id shape (`a