Remove finished and killed claude subagents from the sidebar roster (#8522)

* Remove finished and killed claude subagents from the sidebar roster

Finished one-shot subagents stayed in the pane roster as permanent idle
rows (dozens per ultracode/workflow session), and a subagent killed
without its SubagentStop hook stayed 'working' forever, pinning the pane
working. SubagentStop now removes one-shot entries (teammates only idle:
they are alive and resumable), and a lead Stop's present background_tasks
list is treated as authoritative for non-teammates - unlisted entries are
removed. Teammates are identified by their name-embedding agent ids
(a<name>-<hex>, verified against live hook captures).

* Fix Claude subagent roster reconciliation

* Drop named workflow lanes that share the teammate id shape

Workflow/named one-shot agents report name-embedding lifecycle ids
(afinder-C-<hex>, agent_type = the label), indistinguishable by shape
from resumable teammates - so the roster retained them as idle rows
(observed live: a 32-row 12h-old pile). Unlike teammates they ARE
listed id-exact as subagent-typed background tasks, including inside
their own SubagentStop payload. Use that corroboration to remove them
on stop, reclassify task-listed teammate-shaped entries as one-shots,
and reap teammate-shaped leftovers when a complete inventory lists no
teammate-typed task at all (a teams session always lists its teammates,
even idle ones).
This commit is contained in:
Brennan Benson
2026-07-13 12:47:17 -07:00
committed by GitHub
parent dc4fb2aa03
commit fc458f2b30
5 changed files with 641 additions and 82 deletions
+69 -9
View File
@@ -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'
})
])
})
+17 -4
View File
@@ -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 }
)
}
}
+206 -39
View File
@@ -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-<hex>) 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', () => {
+118 -30
View File
@@ -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<name>-<hex>`)
* while one-shot ids are hyphen-free (`a<hex>`). 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<teammate-name>-<hex>`. 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<label>-<hex>`),
* but unlike real teammates they ARE listed id-exact as `type: "subagent"`
* background tasks — including inside their own SubagentStop payload
* (verified against live hook captures). `listedAsSubagentTask` carries that
* corroboration so a finished workflow lane is removed instead of squatting
* as a phantom idle teammate. */
export function finishClaudeSubagent(
roster: ClaudeSubagentRoster,
id: string,
options?: { listedAsSubagentTask?: boolean }
): void {
const existing = roster.get(id)
if (existing) {
existing.state = 'idle'
if (!existing) {
return
}
if (existing.teammate && options?.listedAsSubagentTask !== true) {
existing.state = 'idle'
return
}
roster.delete(id)
}
/** Read the agent-typed entries of a hook payload's `background_tasks` field.
@@ -96,12 +135,14 @@ export function markClaudeSubagentIdle(roster: ClaudeSubagentRoster, id: string)
export function readClaudeBackgroundAgentTasks(hookPayload: Record<string, unknown>): {
present: boolean
tasks: ClaudeBackgroundAgentTask[]
truncated: boolean
} {
const raw = hookPayload['background_tasks']
if (!Array.isArray(raw)) {
return { present: false, tasks: [] }
return { present: false, tasks: [], truncated: false }
}
const tasks: ClaudeBackgroundAgentTask[] = []
let truncated = false
for (const item of raw) {
if (typeof item !== 'object' || item === null) {
continue
@@ -113,6 +154,12 @@ export function readClaudeBackgroundAgentTasks(hookPayload: Record<string, unkno
if (typeof obj.id !== 'string' || obj.id.trim().length === 0) {
continue
}
if (tasks.length >= AGENT_STATUS_MAX_SUBAGENTS) {
// Why: a capped inventory cannot prove a tracked id is absent; callers
// must retain unlisted rows rather than deleting live overflow tasks.
truncated = true
break
}
tasks.push({
id: obj.id,
agentType: typeof obj.agent_type === 'string' ? obj.agent_type : undefined,
@@ -120,46 +167,65 @@ export function readClaudeBackgroundAgentTasks(hookPayload: Record<string, unkno
running: obj.status === 'running',
teammate: obj.type === 'teammate'
})
if (tasks.length >= AGENT_STATUS_MAX_SUBAGENTS) {
break
}
}
return { present: true, tasks }
return { present: true, tasks, truncated }
}
/** Fold a lead Stop's `background_tasks` into the lifecycle-tracked roster.
*
* Why this is NOT a replace: teammate entries report `status: "running"`
* while the teammate is alive but idle, and their task ids never match the
* `agent_id` used by SubagentStart/SubagentStop — so the list cannot decide
* teammate working-ness or map onto lifecycle-tracked children. Only the
* unambiguous signals are taken:
* The list is authoritative for non-teammate children: a running one-shot is
* always listed under its lifecycle `agent_id` (verified against live hook
* captures), foreground children cannot span a lead Stop, and finished tasks
* are dropped from the list entirely. So:
* - an empty list proves nothing is left alive → clear the roster;
* - an id-exact match (one-shot background subagents reuse `agent_id` as the
* task id) is trusted fully — description enrichment and run state;
* - an id-exact match that is running is trusted fully (state + enrichment);
* one reported not running is finished → remove it;
* - an unmatched RUNNING non-teammate entry is a one-shot subagent this
* listener never saw start (Orca/relay restart mid-run) → recreate it so
* the pane doesn't read done while the child still runs;
* - a roster entry whose id is KNOWN to be a task id
* (backgroundTasksAuthoritative) but is missing from the present list is
* finished → demote it to idle. */
* - a non-teammate roster entry missing from the present list is finished or
* dead (its SubagentStop was killed/lost) → remove it, otherwise it pins
* the pane 'working' forever;
* - teammates are exempt: their task ids never match lifecycle agent_ids, so
* omission proves nothing — except a snapshot-seeded phantom
* (backgroundTasksAuthoritative), which demotes to idle so it cannot gate
* the pane while teams sessions never send an empty list;
* - teammate-SHAPED entries are reclassified as one-shots when a subagent-
* typed task lists their lifecycle id, and are removed on omission when a
* complete inventory lists no teammate-typed task at all (a teams session
* always lists its teammates, even idle ones) — workflow/named one-shot
* lanes share the id shape and must not squat as phantom teammates. */
export function foldClaudeBackgroundTasksIntoRoster(
roster: ClaudeSubagentRoster,
tasks: ClaudeBackgroundAgentTask[],
now: number
now: number,
options?: { inventoryComplete?: boolean }
): void {
if (tasks.length === 0) {
roster.clear()
if (options?.inventoryComplete !== false) {
roster.clear()
}
return
}
const listedIds = new Set<string>()
const hasTeammateTypedTask = tasks.some((task) => task.teammate)
for (const task of tasks) {
listedIds.add(task.id)
const existing = roster.get(task.id)
if (existing) {
existing.state = task.running ? 'working' : 'idle'
if (!task.running) {
roster.delete(task.id)
continue
}
existing.state = 'working'
existing.agentType = task.agentType ?? existing.agentType
existing.description = task.description ?? existing.description
// Why: real teammate lifecycle ids never appear as task ids, so an
// id-exact subagent-typed listing proves this teammate-SHAPED entry is
// a workflow/named one-shot; unflag it so its stop/omission removes it.
if (!task.teammate) {
existing.teammate = undefined
}
continue
}
if (task.teammate || !task.running) {
@@ -176,10 +242,28 @@ export function foldClaudeBackgroundTasksIntoRoster(
created.backgroundTasksAuthoritative = true
}
}
if (options?.inventoryComplete === false) {
return
}
for (const [id, tracked] of roster) {
if (tracked.backgroundTasksAuthoritative && tracked.state === 'working' && !listedIds.has(id)) {
tracked.state = 'idle'
if (listedIds.has(id)) {
continue
}
if (tracked.teammate) {
// Why: a teams session lists its teammates as teammate-typed tasks even
// while they idle. A complete inventory with NONE proves no teammate is
// alive — so teammate-SHAPED leftovers are dead workflow/named one-shots
// (e.g. killed lanes whose SubagentStop was lost) and must go.
if (!hasTeammateTypedTask) {
roster.delete(id)
continue
}
if (tracked.backgroundTasksAuthoritative && tracked.state === 'working') {
tracked.state = 'idle'
}
continue
}
roster.delete(id)
}
}
@@ -205,6 +289,9 @@ export function markClaudeTeammateIdleByName(roster: ClaudeSubagentRoster, name:
continue
}
matchedById = true
// Why: TeammateIdle is teammate-only proof; the flag keeps this entry
// exempt from one-shot removal on SubagentStop and fold omission.
tracked.teammate = true
if (tracked.state !== 'idle') {
tracked.state = 'idle'
changed = true
@@ -215,6 +302,7 @@ export function markClaudeTeammateIdleByName(roster: ClaudeSubagentRoster, name:
}
for (const tracked of roster.values()) {
if (tracked.agentType === name && tracked.state !== 'idle') {
tracked.teammate = true
tracked.state = 'idle'
changed = true
}
@@ -0,0 +1,231 @@
/**
* Regression spec for the two reported sidebar symptoms (live-reproduced in a
* dev instance before the fix):
*
* 1. "Really long idle list" under ultracode: finished workflow subagents
* left permanent `Idle - general-purpose` child rows for the rest of the
* session. Fixed: SubagentStop removes a one-shot subagent from the
* roster; only alive-but-idle teammates keep an idle row.
*
* 2. "Never disappear even when killed from Orca": a subagent killed without
* its SubagentStop hook (SIGKILL'd process tree / lost event) stayed
* `working` forever and pinned the pane working. Fixed: a lead Stop's
* background_tasks is authoritative for non-teammate children — running
* ones are always listed id-exact (verified against live hook captures),
* so an unlisted non-teammate is finished/dead and is removed.
*
* Drives the real production pipeline (normalizeHookPayload) whose
* `payload.subagents` snapshots the sidebar renders 1:1 as child rows.
*/
import { beforeEach, describe, expect, it } from 'vitest'
import {
createHookListenerState,
normalizeHookPayload,
type HookListenerState
} from './agent-hook-listener'
import { makePaneKey } from './stable-pane-id'
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
const PANE_KEY = makePaneKey('tab-1', LEAF_ID)
describe('claude subagent sidebar row lifecycle', () => {
let state: HookListenerState
beforeEach(() => {
state = createHookListenerState()
})
const claudeEvent = (payload: Record<string, unknown>): ReturnType<typeof normalizeHookPayload> =>
normalizeHookPayload(state, 'claude', { paneKey: PANE_KEY, payload }, 'production')
it('drops each finished workflow subagent instead of accumulating idle rows', () => {
claudeEvent({
hook_event_name: 'UserPromptSubmit',
prompt: 'Help me research Vercel sandbox usage (ultracode)'
})
// A Workflow run spawns 21 one-shot agents over a long turn; each stops
// shortly after starting. Pre-fix this accumulated 21 idle rows.
let last: ReturnType<typeof claudeEvent>
for (let i = 0; i < 21; i++) {
claudeEvent({
hook_event_name: 'SubagentStart',
agent_id: `awf0000000000000${String(i).padStart(2, '0')}`,
agent_type: 'general-purpose'
})
last = claudeEvent({
hook_event_name: 'SubagentStop',
agent_id: `awf0000000000000${String(i).padStart(2, '0')}`
})
expect(last?.payload.subagents).toBeUndefined()
}
// Concurrent agents still show while working.
claudeEvent({
hook_event_name: 'SubagentStart',
agent_id: 'aworking0000000001',
agent_type: 'general-purpose'
})
const working = claudeEvent({
hook_event_name: 'SubagentStart',
agent_id: 'aworking0000000002',
agent_type: 'general-purpose'
})
expect(working?.payload.subagents).toHaveLength(2)
expect(working?.payload.state).toBe('working')
})
it('removes a killed subagent whose SubagentStop was never delivered at the next lead Stop', () => {
claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'research task' })
claudeEvent({
hook_event_name: 'SubagentStart',
agent_id: 'akilled0000000001',
agent_type: 'general-purpose'
})
// The child is killed; no SubagentStop ever arrives. The next lead Stop
// lists everything still alive — the killed child is not in it.
const stop = claudeEvent({
hook_event_name: 'Stop',
background_tasks: [
{
id: 'aother00000000001',
type: 'subagent',
status: 'running',
agent_type: 'general-purpose'
}
]
})
expect(stop?.payload.subagents).toEqual([
expect.objectContaining({ id: 'aother00000000001', state: 'working' })
])
// The pane stays working only for the child that is genuinely alive.
expect(stop?.payload.state).toBe('working')
claudeEvent({ hook_event_name: 'SubagentStop', agent_id: 'aother00000000001' })
const finalStop = claudeEvent({ hook_event_name: 'Stop', background_tasks: [] })
expect(finalStop?.payload.state).toBe('done')
expect(finalStop?.payload.subagents).toBeUndefined()
})
it('keeps an alive-but-idle teammate row while dropping finished one-shots', () => {
claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'teams session' })
claudeEvent({
hook_event_name: 'SubagentStart',
agent_id: 'areviewer-6d3cb5b52120b7bf',
agent_type: 'security-reviewer'
})
claudeEvent({
hook_event_name: 'SubagentStart',
agent_id: 'aoneshot000000001',
agent_type: 'general-purpose'
})
claudeEvent({ hook_event_name: 'SubagentStop', agent_id: 'aoneshot000000001' })
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-repro'
})
// Teammate stays (alive + resumable); the finished one-shot is gone.
expect(idled?.payload.subagents).toEqual([
expect.objectContaining({ id: 'areviewer-6d3cb5b52120b7bf', state: 'idle' })
])
// Teams Stops list teammate tasks under unrelated ids and never send an
// empty list; the teammate row must survive them.
const stop = claudeEvent({
hook_event_name: 'Stop',
background_tasks: [{ id: 'tlkjjs0jv', type: 'teammate', status: 'running' }]
})
expect(stop?.payload.state).toBe('done')
expect(stop?.payload.subagents).toEqual([
expect.objectContaining({ id: 'areviewer-6d3cb5b52120b7bf', state: 'idle' })
])
})
it('drops named workflow lanes instead of retaining them as phantom idle teammates', () => {
// Regression for the live-observed 32-row pile: workflow lanes report
// name-embedding ids (afinder-C-<hex>, agent_type = the label), which
// share the teammate id shape but are one-shots.
claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'native chat review (ultracode)' })
claudeEvent({
hook_event_name: 'SubagentStart',
agent_id: 'afinder-C-5d713c0781b7f8d2',
agent_type: 'finder-C'
})
claudeEvent({
hook_event_name: 'SubagentStart',
agent_id: 'acr-triage-1-c5a0588e7a2e4151',
agent_type: 'cr-triage-1'
})
// finder-C finishes; its SubagentStop carries the task inventory that
// lists it id-exact as a subagent — proof it is not a teammate.
const stopped = claudeEvent({
hook_event_name: 'SubagentStop',
agent_id: 'afinder-C-5d713c0781b7f8d2',
agent_type: 'finder-C',
background_tasks: [
{
id: 'afinder-C-5d713c0781b7f8d2',
type: 'subagent',
status: 'running',
agent_type: 'finder-C'
},
{
id: 'acr-triage-1-c5a0588e7a2e4151',
type: 'subagent',
status: 'running',
agent_type: 'cr-triage-1'
}
]
})
expect(stopped?.payload.subagents).toEqual([
expect.objectContaining({ id: 'acr-triage-1-c5a0588e7a2e4151', state: 'working' })
])
// cr-triage-1 is killed (SubagentStop lost). The lead Stop's complete
// inventory lists no teammate-typed task, so the leftover is removed.
const stop = claudeEvent({
hook_event_name: 'Stop',
background_tasks: [
{
id: 'awf0000000000000zz',
type: 'subagent',
status: 'running',
agent_type: 'general-purpose'
}
]
})
expect(stop?.payload.subagents).toEqual([
expect.objectContaining({ id: 'awf0000000000000zz', state: 'working' })
])
})
it('removes aborted subagents on the interrupt Stop so the pane can resolve', () => {
claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'long batch' })
claudeEvent({
hook_event_name: 'SubagentStart',
agent_id: 'aaborted000000001',
agent_type: 'general-purpose'
})
// Esc/Ctrl+C: claude emits SubagentStop for aborted children (verified
// live), then Stop with is_interrupt. Both paths clean the roster.
claudeEvent({ hook_event_name: 'SubagentStop', agent_id: 'aaborted000000001' })
const stop = claudeEvent({
hook_event_name: 'Stop',
is_interrupt: true,
background_tasks: []
})
expect(stop?.payload.state).toBe('done')
expect(stop?.payload.interrupted).toBe(true)
expect(stop?.payload.subagents).toBeUndefined()
})
})