feat(agent-status): show Claude subagent child rows and gate premature done (#8211)

* feat(agent-status): show Claude subagent child rows and gate premature done

A Claude pane that spawned background subagents/teammates showed a green
done check the moment the lead's turn ended, even while a background
review loop was still running. Orca now tracks the pane's live children
from Claude hook events and:

- keeps the pane 'working' while at least one child is working (Stop is
  gated; Claude wakes the lead when a child finishes, so the pane
  resolves to done on the follow-up Stop with an empty roster)
- renders the children as indented child rows under the pane's sidebar
  row (name/type + working/idle dot), reusing the existing lineage UI

Tracking is lifecycle-primary: SubagentStart/SubagentStop/TeammateIdle
(newly registered hooks) plus child-origin tool events (they carry
agent_id) own the roster. Stop's background_tasks is folded only where
unambiguous — verified live on Claude Code 2.1.207 that teammates report
status "running" while idle-alive and their task ids never match
lifecycle agent_ids, so the list cannot decide teammate working-ness.

Child-origin events no longer overwrite the lead's tool/prompt caches
(a live AskUserQuestion card survives child churn); a child's own
PermissionRequest records waitingAgentId so only that child's progress
or death clears the wait. The interrupted flag survives the gated
window, inferred interrupts sync the lead record and refuse while a
child works, and hydration reseeds the roster after a restart.

* fix(agent-status): drop identity icon on subagent child rows

The child's agentType carries its NAME (e.g. "pr-reviewer"), which is not
an iconable agent and rendered the unknown "?" glyph. Nesting under the
parent row already conveys identity.

* fix(agent-status): restore displaced lead state and reconcile phantom subagents

Four review findings from the adversarial pass on the subagent child-row
feature:

- Stash the lead state a child-induced wait displaces
  (ClaudeLeadTurnState.stateBeforeWait) and restore it when the wait
  clears, instead of inventing 'working' — a lead that had already
  stopped left the pane spinning forever after the roster drained,
  since the done-gate only ever downgrades done → working.
- Tag snapshot-seeded and background_tasks-recreated roster entries
  (backgroundTasksAuthoritative) and demote them when a PRESENT
  background_tasks list omits their id. A phantom child seeded before a
  restart could otherwise gate the pane 'working' indefinitely in teams
  sessions, whose task list is never empty. Live activity clears the
  tag so lifecycle-tracked teammates keep their state.
- Match teammate ids with a hyphen-free suffix after `a<name>-` so
  TeammateIdle for "lane" cannot idle "lane-hooks"'s rows or clear its
  pending permission wait.
- Route turn-boundary events (Stop/StopFailure/UserPromptSubmit) that
  carry a KNOWN child agent_id through the child-driven re-emit instead
  of adopting them as lead state, and tie the prompt-cache new-turn
  reset to lead-origin events so child refreshes can't blank the
  prompt label.

---------

Co-authored-by: Brennan Benson <brennanbenson@Brennans-MacBook-Pro.local>
This commit is contained in:
Brennan Benson 2026-07-10 23:34:49 -07:00 committed by GitHub
parent 2e48495273
commit 31cff75a7b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 2004 additions and 220 deletions

View File

@ -143,6 +143,89 @@ describe('AgentHookServer listener replay', () => {
}
})
it('does not infer an interrupt while a subagent child is still working', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
try {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: {
state: 'working',
prompt: 'review loop',
agentType: 'claude',
// Why: a working pane can be child-driven (lead already idle).
// Ctrl+C does not stop background children, so no terminal done
// may be inferred while one is still running.
subagents: [{ id: 'a1', state: 'working', startedAt: 900 }]
}
},
'conn-1'
)
const baseline = server.getStatusSnapshot()[0]
vi.setSystemTime(1_500)
const applied = server.inferInterrupt({
paneKey: PANE,
baselineUpdatedAt: baseline.receivedAt,
baselineStateStartedAt: baseline.stateStartedAt,
baselinePrompt: 'review loop',
baselineAgentType: 'claude',
intent: 'ctrl-c'
})
expect(applied).toBe(false)
expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' })
} finally {
vi.useRealTimers()
}
})
it('carries idle subagent rows through an inferred interrupt', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
try {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: {
state: 'working',
prompt: 'wrap up',
agentType: 'claude',
subagents: [{ id: 'a1', state: 'idle', startedAt: 900, agentType: 'probe1' }]
}
},
'conn-1'
)
const baseline = server.getStatusSnapshot()[0]
vi.setSystemTime(1_500)
const applied = server.inferInterrupt({
paneKey: PANE,
baselineUpdatedAt: baseline.receivedAt,
baselineStateStartedAt: baseline.stateStartedAt,
baselinePrompt: 'wrap up',
baselineAgentType: 'claude',
intent: 'ctrl-c'
})
expect(applied).toBe(true)
expect(server.getStatusSnapshot()[0]).toMatchObject({
state: 'done',
interrupted: true,
subagents: [expect.objectContaining({ id: 'a1', state: 'idle' })]
})
} finally {
vi.useRealTimers()
}
})
it('preserves an inferred interrupted row when OpenCode immediately reports SessionIdle', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)

View File

@ -26,12 +26,14 @@ import {
getEndpointFileName,
hasPendingAgentResultText,
HOOK_REQUEST_SLOWLORIS_MS,
markClaudeLeadTurnInterrupted,
MAX_PANE_KEY_LEN,
normalizeHookPayload,
parseFormEncodedBody,
readRequestBody,
resolveHookSource,
preparePendingGrokResultDiscovery,
seedClaudeSubagentRosterFromSnapshots,
warnOnHookEnvOrVersionMismatch,
writeEndpointFile,
type AgentHookEventPayload,
@ -253,6 +255,10 @@ function toAgentStatusIpcPayload(entry: EnrichedAgentHookEventPayload): AgentSta
}
}
// Why: OSC-only dedupe (ingestTerminalStatus). Deliberately omits `subagents`:
// OSC payloads never carry them, and including the field would make every
// hook-cached entry with child rows non-equivalent — the OSC ping would then
// apply and wipe the roster. Do not reuse this for hook-path comparisons.
function equivalentParsedAgentStatusPayload(
a: ParsedAgentStatusPayload,
b: ParsedAgentStatusPayload
@ -555,7 +561,20 @@ export class AgentHookServer {
) {
return false
}
// Why: a 'working' pane can be child-driven (lead already idle, background
// subagent running). Ctrl+C at the TUI does not stop background children,
// so inferring a terminal done here would wrongly retire live child rows;
// their own hook events keep the row truthful instead.
if (payload.subagents?.some((subagent) => subagent.state === 'working')) {
return false
}
// Why: keep the listener's Claude lead-turn record in sync — a later
// child lifecycle event would otherwise re-emit the stale pre-interrupt
// 'working' lead state and resurrect the cancelled pane.
if (agentType === 'claude') {
markClaudeLeadTurnInterrupted(this.state, existing.paneKey)
}
const inferred = this.applyNormalizedStatus({
paneKey: existing.paneKey,
tabId: existing.tabId,
@ -566,7 +585,10 @@ export class AgentHookServer {
state: 'done',
prompt: payload.prompt,
agentType,
interrupted: true
interrupted: true,
// Why: idle children are display state; dropping them on an inferred
// interrupt would blank the child rows a later hook would restore.
...(payload.subagents ? { subagents: payload.subagents } : {})
}
})
console.debug('[agent-hooks] inferred interrupted agent status', {
@ -1558,6 +1580,17 @@ export class AgentHookServer {
const entry = sanitizeHydratedEntry(resolvedPaneKey, rawResolvedEntry)
if (entry && entry.receivedAt >= ttlCutoff) {
this.state.lastStatusByPaneKey.set(resolvedPaneKey, entry)
// Why: the in-memory subagent roster died with the previous process.
// Reseed it from the persisted snapshot so the next teammate-bearing
// Stop (whose task ids never match lifecycle ids) doesn't silently
// drop the replayed child rows.
if (entry.payload.subagents) {
seedClaudeSubagentRosterFromSnapshots(
this.state,
resolvedPaneKey,
entry.payload.subagents
)
}
hydrated += 1
} else {
dropped += 1

View File

@ -255,6 +255,9 @@ describe('ClaudeHookService.installRemote', () => {
'UserPromptSubmit',
'Stop',
'StopFailure',
'SubagentStart',
'SubagentStop',
'TeammateIdle',
'PreToolUse',
'PostToolUse',
'PostToolUseFailure',

View File

@ -32,6 +32,14 @@ export const CLAUDE_EVENTS = [
// Why: OpenClaude skips normal Stop hooks after API/model errors and emits
// StopFailure instead; without this hook Orca leaves the turn spinning.
{ eventName: 'StopFailure', definition: { hooks: [{ type: 'command', command: '' }] } },
// Why: subagent/teammate lifecycle feeds the sidebar's child rows and keeps
// a pane 'working' while background children outlive the lead's turn.
// TeammateIdle is required because idle-but-alive teammates still report
// status "running" in Stop's background_tasks.
// Older Claude builds ignore unregistered event names (StopFailure precedent).
{ eventName: 'SubagentStart', definition: { hooks: [{ type: 'command', command: '' }] } },
{ eventName: 'SubagentStop', definition: { hooks: [{ type: 'command', command: '' }] } },
{ eventName: 'TeammateIdle', definition: { hooks: [{ type: 'command', command: '' }] } },
// Why: PreToolUse gives the dashboard a live readout of the in-flight tool
// (name + input preview) before it completes.
{

View File

@ -50,6 +50,12 @@ function formatTimeAgo(ts: number, now: number): string {
// drift away from the true transition moment. For past dones, stateHistory
// entries already store the per-transition `startedAt` so we read it directly.
function lastEnteredDoneAt(agent: DashboardAgentRowData): number | null {
// Why: idle subagent child rows are alive-but-idle (teammates persist
// between turns) — reading their synthetic entry as "done Xm ago" would
// mislabel a live teammate as finished.
if (agent.rowSource === 'subagent' && agent.state === 'idle') {
return null
}
const entry = agent.entry
if (entry.state === 'done') {
return entry.stateStartedAt
@ -163,9 +169,11 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
const handleActivate = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
onActivate(agent.tab.id, agent.paneKey)
// Why: subagent child rows have no pane of their own; they focus the
// parent pane whose session spawned them.
onActivate(agent.tab.id, agent.activationPaneKey ?? agent.paneKey)
},
[onActivate, agent.tab.id, agent.paneKey]
[onActivate, agent.tab.id, agent.activationPaneKey, agent.paneKey]
)
const handleSendTargetClickCapture = useCallback(
(e: React.MouseEvent) => {
@ -332,8 +340,11 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
on the top row. The sub-rows (tool step, assistant response) are
about the same agent and do not need the icon repeated next to
them keeping the icon only on the prompt row lets the sub-rows
indent under the prompt text cleanly. */}
{!hideIdentityIcon && (
indent under the prompt text cleanly. Subagent child rows carry
the child's NAME in agentType (not an iconable agent it would
render the unknown "?" glyph), and nesting under the parent
already conveys identity. */}
{!hideIdentityIcon && agent.rowSource !== 'subagent' && (
<span className="inline-flex shrink-0" title={identityTitle}>
<AgentIcon agent={agentTypeToIconAgent(agent.agentType)} size={14} />
</span>
@ -385,6 +396,7 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
relativeTimestamp={relativeTimestamp}
expanded={expanded}
hideExpand={hideExpand}
hideDismiss={agent.rowSource === 'subagent'}
sendTargetStatus={sendTargetStatus}
onDismiss={onDismiss}
onToggleExpanded={handleToggleExpanded}

View File

@ -8,6 +8,9 @@ type DashboardAgentRowTrailingControlsProps = {
relativeTimestamp: string | null
expanded: boolean
hideExpand: boolean
/** Subagent child rows have no store entry of their own to dismiss
* offering the X would be a silent no-op. */
hideDismiss?: boolean
sendTargetStatus?: 'eligible' | 'disabled' | 'sending'
onDismiss: (paneKey: string) => void
onToggleExpanded: () => void
@ -19,6 +22,7 @@ export function DashboardAgentRowTrailingControls({
relativeTimestamp,
expanded,
hideExpand,
hideDismiss = false,
sendTargetStatus,
onDismiss,
onToggleExpanded,
@ -86,9 +90,17 @@ export function DashboardAgentRowTrailingControls({
<span>{translate('auto.components.dashboard.DashboardAgentRow.912e136cd9', 'Send')}</span>
</button>
)}
{!sendTargetStatus && hideDismiss && relativeTimestamp !== null && (
<span
className="pointer-events-none shrink-0 text-[10px] leading-none text-muted-foreground/60"
aria-hidden
>
{relativeTimestamp}
</span>
)}
{/* Why: timestamp and dismiss-X share one slot. On no-hover devices the X
is visible by default, so the timestamp must yield there too. */}
{!sendTargetStatus && relativeTimestamp !== null && (
{!sendTargetStatus && !hideDismiss && relativeTimestamp !== null && (
<span className="relative grid grid-cols-1 grid-rows-1 shrink-0 items-center justify-items-end">
<span
className={cn(
@ -120,7 +132,7 @@ export function DashboardAgentRowTrailingControls({
</button>
</span>
)}
{!sendTargetStatus && relativeTimestamp === null && (
{!sendTargetStatus && !hideDismiss && relativeTimestamp === null && (
<button
type="button"
onClick={handleDismiss}

View File

@ -15,12 +15,17 @@ import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsuppor
// ─── Shared data types ────────────────────────────────────────────────────────
export type DashboardAgentRow = {
/** Row identity. For 'subagent' rows this is a synthetic key (the child has
* no PTY) unique for React/lineage maps but never parsed as a pane key. */
paneKey: string
entry: AgentStatusEntry
tab: TerminalTab
agentType: AgentType
rowSource?: 'live' | 'retained'
rowSource?: 'live' | 'retained' | 'subagent'
state: AgentStatusState | 'idle'
/** Pane to focus when the row is activated, when it differs from paneKey.
* Subagent rows have no pane of their own and activate their parent's. */
activationPaneKey?: string
/** When this agent first began reporting status. Derived from the oldest
* stateHistory entry, falling back to updatedAt when no history exists yet.
* Used to sort agents by when they started. */

View File

@ -701,4 +701,76 @@ describe('applyAgentRowLineage', () => {
expect(ordered[1].lineage).toMatchObject({ depth: 1, childCount: 1 })
expect(ordered[2].lineage).toMatchObject({ depth: 1, childCount: 0 })
})
it('derives indented child rows for a live entry with in-process subagents', () => {
const entry = makeEntry(PANE_KEY_1, 1000, {
state: 'working',
prompt: 'review the PR',
subagents: [
{
id: 'a1',
state: 'working',
startedAt: 1500,
agentType: 'general-purpose',
description: 'Review loop'
},
{ id: 'r1', state: 'idle', startedAt: 1600, agentType: 'code-reviewer' }
]
})
const rows = buildWorktreeAgentRows({
tabs: [makeTab('tab-1')],
entries: [entry],
retained: [],
now: 2000
})
const children = rows.filter((row) => row.rowSource === 'subagent')
expect(children).toHaveLength(2)
expect(children[0]).toMatchObject({
state: 'working',
agentType: 'general-purpose',
activationPaneKey: PANE_KEY_1,
startedAt: 1500
})
expect(children[0].entry.prompt).toBe('Review loop')
expect(children[1]).toMatchObject({ state: 'idle', agentType: 'code-reviewer' })
const ordered = applyAgentRowLineage(rows)
expect(ordered[0].paneKey).toBe(PANE_KEY_1)
expect(ordered[0].lineage).toMatchObject({ depth: 0, childCount: 2 })
expect(ordered[1].lineage).toMatchObject({ depth: 1, isFirstSibling: true })
expect(ordered[2].lineage).toMatchObject({ depth: 1, isLastSibling: true })
})
it('decays working subagent child rows to idle when the parent status is stale', () => {
const entry = makeEntry(PANE_KEY_1, 1000, {
state: 'working',
subagents: [{ id: 'a1', state: 'working', startedAt: 1000 }]
})
const rows = buildWorktreeAgentRows({
tabs: [makeTab('tab-1')],
entries: [entry],
retained: [],
now: 1000 + AGENT_STATUS_STALE_AFTER_MS + 1
})
const child = rows.find((row) => row.rowSource === 'subagent')
expect(child?.state).toBe('idle')
})
it('does not derive subagent child rows for retained snapshots', () => {
const retained = makeRetained(PANE_KEY_1, 'wt-1', 1000, {
entry: makeEntry(PANE_KEY_1, 1000, {
subagents: [{ id: 'a1', state: 'idle', startedAt: 1000 }]
})
})
const rows = buildWorktreeAgentRows({
tabs: [makeTab('tab-1')],
entries: [],
retained: [retained],
now: 2000
})
expect(rows.some((row) => row.rowSource === 'subagent')).toBe(false)
})
})

View File

@ -22,6 +22,7 @@ import {
buildTitleDerivedAgentRows,
resolveAgentTypeFromTerminalTitle
} from './worktree-title-derived-agent-rows'
import { buildSubagentChildRows } from './worktree-subagent-child-rows'
import { resolveCompatibleAgentTypeForOwner } from '../../../../shared/agent-title-owner'
import { compareWorktreeAgentRows } from './worktree-agent-row-order'
import {
@ -248,6 +249,7 @@ export function buildWorktreeAgentRows(args: {
state: shouldDecay ? 'idle' : rowEntry.state,
startedAt
})
rows.push(...buildSubagentChildRows({ parentEntry: rowEntry, tab, parentIsFresh: isFresh }))
seenPaneKeys.add(rowEntry.paneKey)
}
}
@ -289,6 +291,7 @@ export function buildWorktreeAgentRows(args: {
state: shouldDecay ? 'idle' : rowEntry.state,
startedAt
})
rows.push(...buildSubagentChildRows({ parentEntry: rowEntry, tab, parentIsFresh: isFresh }))
seenPaneKeys.add(rowEntry.paneKey)
}

View File

@ -27,6 +27,11 @@ function formatShortTimeAgo(ts: number, now: number): string {
}
function lastEnteredDoneAt(agent: DashboardAgentRowData): number | null {
// Why: idle subagent child rows are alive-but-idle (teammates persist
// between turns), not finished — fall through to the started-at timestamp.
if (agent.rowSource === 'subagent' && agent.state === 'idle') {
return null
}
const entry = agent.entry
if (entry.state === 'done') {
return entry.stateStartedAt
@ -115,6 +120,10 @@ export const CompactAgentRow = React.memo(function CompactAgentRow({
typeof childAgentCount === 'number' &&
childAgentCount > 0 &&
typeof onToggleChildAgents === 'function'
// Why: subagent child rows carry the child's NAME (e.g. "pr-reviewer") in
// agentType, which is not an iconable agent and would render the unknown
// "?" glyph. Nesting under the parent already conveys identity.
const hideIcon = hideIdentityIcon || agent.rowSource === 'subagent'
const dotState = getAgentDotState(agent)
const primary = getCompactAgentPrimary(agent)
const isLineageChild = agent.lineage?.depth === 1
@ -125,9 +134,11 @@ export const CompactAgentRow = React.memo(function CompactAgentRow({
const handleActivate = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
onActivate(agent.tab.id, agent.paneKey)
// Why: subagent child rows have no pane of their own; they focus the
// parent pane whose session spawned them.
onActivate(agent.tab.id, agent.activationPaneKey ?? agent.paneKey)
},
[agent.paneKey, agent.tab.id, onActivate]
[agent.activationPaneKey, agent.paneKey, agent.tab.id, onActivate]
)
const handleSendTargetClickCapture = useCallback(
(e: React.MouseEvent) => {
@ -189,7 +200,7 @@ export const CompactAgentRow = React.memo(function CompactAgentRow({
<span className="size-4 shrink-0" aria-hidden />
) : null}
<AgentStateDot state={dotState} size="sm" />
{!hideIdentityIcon && (
{!hideIcon && (
<span className="inline-flex shrink-0" title={formatAgentTypeLabel(agent.agentType)}>
<AgentIcon agent={agentTypeToIconAgent(agent.agentType)} size={13} />
</span>

View File

@ -0,0 +1,63 @@
import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import type { TerminalTab } from '../../../../shared/types'
/** Row-identity key for an in-process subagent child row. The NUL separator
* cannot appear in real pane keys, so synthetic keys can never collide with
* one. Never parsed back activation goes through `activationPaneKey` /
* `orchestration.parentPaneKey` instead. */
function subagentRowKey(parentPaneKey: string, subagentId: string): string {
return `${parentPaneKey}\u0000subagent:${subagentId}`
}
/**
* Derive indented child rows for the live in-process subagents/teammates a
* pane's agent has spawned (entry.subagents, reported via agent hooks). These
* children have no PTY or tab of their own: the rows reuse the parent's tab,
* activate the parent's pane, and link into the existing lineage tree through
* `orchestration.parentPaneKey`.
*/
export function buildSubagentChildRows(args: {
parentEntry: AgentStatusEntry
tab: TerminalTab
/** Freshness of the parent's hook stream. A stale parent means the child
* working states are equally stale, so they decay to idle together. */
parentIsFresh: boolean
}): DashboardAgentRow[] {
const subagents = args.parentEntry.subagents
if (!subagents || subagents.length === 0) {
return []
}
return subagents.map((subagent) => {
const working = subagent.state === 'working' && args.parentIsFresh
const startedAt = subagent.startedAt > 0 ? subagent.startedAt : args.parentEntry.stateStartedAt
const paneKey = subagentRowKey(args.parentEntry.paneKey, subagent.id)
const entry: AgentStatusEntry = {
state: working ? 'working' : 'done',
prompt: subagent.description ?? '',
updatedAt: args.parentEntry.updatedAt,
stateStartedAt: startedAt,
agentType: subagent.agentType,
paneKey,
worktreeId: args.parentEntry.worktreeId,
tabId: args.parentEntry.tabId,
stateHistory: [],
orchestration: {
taskId: `subagent:${subagent.id}`,
dispatchId: `subagent:${subagent.id}`,
displayName: subagent.description,
parentPaneKey: args.parentEntry.paneKey
}
}
return {
paneKey,
entry,
tab: args.tab,
agentType: subagent.agentType ?? 'unknown',
rowSource: 'subagent' as const,
state: working ? ('working' as const) : ('idle' as const),
activationPaneKey: args.parentEntry.paneKey,
startedAt
}
})
}

View File

@ -2935,7 +2935,10 @@ export function useIpcEvents(): void {
// here silently dropped the native question card on web/mobile clients.
interactivePrompt: data.interactivePrompt,
lastAssistantMessage: data.lastAssistantMessage,
interrupted: data.interrupted
interrupted: data.interrupted,
// Why: same trap as interactivePrompt — this rebuild is a field
// whitelist, so the subagent child rows vanish if omitted here.
subagents: data.subagents
})
if (!payload) {
return 'dropped'

View File

@ -4,6 +4,7 @@ import type { AppState } from '../types'
import {
AGENT_STATUS_STALE_AFTER_MS,
AGENT_STATE_HISTORY_MAX,
agentSubagentsEqual,
type AgentStateHistoryEntry,
type AgentStatusEntry,
type AgentStatusOrchestrationContext,
@ -1323,6 +1324,12 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
// metadata expires. Only final done rows keep the previous lineage
// fallback so completed children stay grouped.
orchestration,
// Why: reuse the previous array reference when the roster is
// unchanged so subscribers comparing by identity skip re-renders on
// high-frequency same-roster pings.
subagents: agentSubagentsEqual(existing?.subagents, payload.subagents)
? existing?.subagents
: payload.subagents,
...(providerSession ? { providerSession } : {}),
// Why: interrupted lives on `done` only. parseAgentStatusPayload
// already clamps it to `undefined` for non-done states, so writing
@ -1370,6 +1377,7 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
entry.toolInput !== existing.toolInput ||
entry.lastAssistantMessage !== existing.lastAssistantMessage ||
entry.orchestration !== existing.orchestration ||
entry.subagents !== existing.subagents ||
entry.providerSession !== existing.providerSession ||
entry.interrupted !== existing.interrupted)
const retentionRelevantChange = sortRelevantChange || doneRetentionFieldsChanged

View File

@ -6,9 +6,12 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync }
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
clearPaneCacheState,
createHookListenerState,
getEndpointFileName,
hasPendingAgentResultText,
markClaudeLeadTurnInterrupted,
seedClaudeSubagentRosterFromSnapshots,
HOOK_REQUEST_MAX_BYTES,
isShellSafeEndpointValue,
normalizeHookPayload,
@ -2146,6 +2149,466 @@ describe('shared agent-hook-listener', () => {
expect(done?.payload.lastAssistantMessage).toBe('Hermes is wired up.')
})
describe('claude subagent tracking', () => {
const claudeEvent = (
payload: Record<string, unknown>,
paneKey: string = PANE_KEY
): ReturnType<typeof normalizeHookPayload> =>
normalizeHookPayload(state, 'claude', { paneKey, payload }, 'production')
it('keeps Stop as done when background_tasks is empty', () => {
claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'ship it' })
const stop = claudeEvent({ hook_event_name: 'Stop', background_tasks: [] })
expect(stop?.payload.state).toBe('done')
expect(stop?.payload.subagents).toBeUndefined()
})
it('reports Stop as working while a background subagent is still running', () => {
claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'review the PR' })
claudeEvent({
hook_event_name: 'SubagentStart',
agent_id: 'a1',
agent_type: 'general-purpose'
})
const stop = claudeEvent({
hook_event_name: 'Stop',
background_tasks: [
{
id: 'a1',
type: 'subagent',
status: 'running',
description: 'Review loop',
agent_type: 'general-purpose'
}
]
})
expect(stop?.payload.state).toBe('working')
expect(stop?.payload.subagents).toEqual([
{
id: 'a1',
state: 'working',
startedAt: expect.any(Number),
agentType: 'general-purpose',
description: 'Review loop'
}
])
// Why: the child finishing wakes the lead; its final Stop reports an
// empty roster and the pane resolves to done with no child rows left.
claudeEvent({ hook_event_name: 'SubagentStop', agent_id: 'a1' })
const finalStop = claudeEvent({ hook_event_name: 'Stop', background_tasks: [] })
expect(finalStop?.payload.state).toBe('done')
expect(finalStop?.payload.subagents).toBeUndefined()
})
it('emits a status refresh with the lead state on subagent lifecycle events', () => {
claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'kick off reviewers' })
claudeEvent({ hook_event_name: 'Stop', background_tasks: [] })
const spawned = claudeEvent({
hook_event_name: 'SubagentStart',
agent_id: 'r1',
agent_type: 'code-reviewer'
})
// Why: lead already stopped, but a live child means the pane is working.
expect(spawned?.payload.state).toBe('working')
expect(spawned?.payload.prompt).toBe('kick off reviewers')
expect(spawned?.payload.subagents).toEqual([
{
id: 'r1',
state: 'working',
startedAt: expect.any(Number),
agentType: 'code-reviewer',
description: undefined
}
])
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' })
])
})
it('keeps gating on tracked children when background_tasks is absent (older Claude)', () => {
claudeEvent({
hook_event_name: 'SubagentStart',
agent_id: 'a1',
agent_type: 'general-purpose'
})
const stop = claudeEvent({ hook_event_name: 'Stop' })
expect(stop?.payload.state).toBe('working')
expect(stop?.payload.subagents).toEqual([expect.objectContaining({ id: 'a1' })])
})
it('marks subagent-origin tool events as child activity without adopting them as lead state', () => {
claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'go' })
claudeEvent({ hook_event_name: 'Stop', background_tasks: [] })
// Why: hook events from inside a subagent carry agent_id; they must keep
// the child row live but not overwrite what the lead was last doing.
const childTool = claudeEvent({
hook_event_name: 'PreToolUse',
agent_id: 'a9',
agent_type: 'general-purpose',
tool_name: 'Bash',
tool_input: { command: 'pnpm test' }
})
expect(childTool?.payload.state).toBe('working')
expect(childTool?.payload.subagents).toEqual([
expect.objectContaining({ id: 'a9', state: 'working' })
])
const stopped = claudeEvent({ hook_event_name: 'SubagentStop', agent_id: 'a9' })
// Why: the lead's own last state was done, so with no working children
// the pane settles back to done rather than a phantom working spinner.
expect(stopped?.payload.state).toBe('done')
})
it('resolves teams-mode teammates to done despite background_tasks reporting running', () => {
// Why: this is the interactive agent-teams shape observed live —
// lifecycle events use `a<name>-<hex>` agent ids while background_tasks
// uses unrelated task ids and keeps idle-but-alive teammates as
// status "running". The unmatched task entry must neither create a
// duplicate child row nor keep the pane spinning forever.
claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'spawn probe' })
claudeEvent({
hook_event_name: 'SubagentStart',
agent_id: 'aprobe1-6d3cb5b52120b7bf',
agent_type: 'probe1'
})
const spawnStop = claudeEvent({
hook_event_name: 'Stop',
background_tasks: [
{
id: 'tlkjjs0jv',
type: 'teammate',
status: 'running',
description: 'Run the shell command: sleep 25.'
}
]
})
expect(spawnStop?.payload.state).toBe('working')
expect(spawnStop?.payload.subagents).toEqual([
expect.objectContaining({ id: 'aprobe1-6d3cb5b52120b7bf', state: 'working' })
])
claudeEvent({
hook_event_name: 'SubagentStop',
agent_id: 'aprobe1-6d3cb5b52120b7bf',
agent_type: 'probe1'
})
const idled = claudeEvent({
hook_event_name: 'TeammateIdle',
teammate_name: 'probe1',
team_name: 'session-56c87269'
})
expect(idled?.payload.subagents).toEqual([
expect.objectContaining({ id: 'aprobe1-6d3cb5b52120b7bf', state: 'idle' })
])
const wakeStop = claudeEvent({
hook_event_name: 'Stop',
background_tasks: [
{
id: 'tlkjjs0jv',
type: 'teammate',
status: 'running',
description: 'Run the shell command: sleep 25.'
}
]
})
expect(wakeStop?.payload.state).toBe('done')
expect(wakeStop?.payload.subagents).toEqual([
expect.objectContaining({ id: 'aprobe1-6d3cb5b52120b7bf', state: 'idle' })
])
})
it('scopes subagent rosters per pane', () => {
claudeEvent(
{ hook_event_name: 'SubagentStart', agent_id: 'a1', agent_type: 'general-purpose' },
PANE_KEY
)
const otherPane = makePaneKey('tab-2', '22222222-2222-4222-8222-222222222222')
claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'other' }, otherPane)
const otherStop = claudeEvent({ hook_event_name: 'Stop', background_tasks: [] }, otherPane)
expect(otherStop?.payload.state).toBe('done')
expect(otherStop?.payload.subagents).toBeUndefined()
})
it('clears roster state when the pane cache is cleared', () => {
claudeEvent({
hook_event_name: 'SubagentStart',
agent_id: 'a1',
agent_type: 'general-purpose'
})
clearPaneCacheState(state, PANE_KEY)
const stop = claudeEvent({ hook_event_name: 'Stop' })
expect(stop?.payload.state).toBe('done')
expect(stop?.payload.subagents).toBeUndefined()
})
it('does not clear a live AskUserQuestion card on subagent lifecycle events', () => {
const question = claudeEvent({
hook_event_name: 'PreToolUse',
tool_name: 'AskUserQuestion',
tool_input: { questions: [{ question: 'Pick', options: ['a', 'b'] }] }
})
expect(question?.payload.state).toBe('waiting')
const spawned = claudeEvent({
hook_event_name: 'SubagentStart',
agent_id: 'a1',
agent_type: 'general-purpose'
})
expect(spawned?.payload.state).toBe('waiting')
expect(spawned?.payload.interactivePrompt).toBe(question?.payload.interactivePrompt)
// Why: child-origin tool events must not overwrite the lead's cached
// question card or read as the lead's own working state either.
const childTool = claudeEvent({
hook_event_name: 'PreToolUse',
agent_id: 'a1',
agent_type: 'general-purpose',
tool_name: 'Bash',
tool_input: { command: 'sleep 5' }
})
expect(childTool?.payload.state).toBe('waiting')
expect(childTool?.payload.interactivePrompt).toBe(question?.payload.interactivePrompt)
expect(childTool?.payload.toolName).toBe('AskUserQuestion')
})
it('preserves the interrupted flag across a gated working window', () => {
claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'long job' })
claudeEvent({
hook_event_name: 'SubagentStart',
agent_id: 'a1',
agent_type: 'general-purpose'
})
const interruptedStop = claudeEvent({ hook_event_name: 'Stop', is_interrupt: true })
// Why: the child is still running, so the pane stays working and the
// parse layer clamps `interrupted` off this intermediate emit.
expect(interruptedStop?.payload.state).toBe('working')
expect(interruptedStop?.payload.interrupted).toBeUndefined()
const drained = claudeEvent({ hook_event_name: 'SubagentStop', agent_id: 'a1' })
expect(drained?.payload.state).toBe('done')
// Why: the user's cancellation must survive to the terminal done so the
// row reads "Interrupted by user" instead of a normal completion.
expect(drained?.payload.interrupted).toBe(true)
})
it('releases a child-owned wait when the blocked child stops without another tool event', () => {
claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'guarded task' })
const blocked = claudeEvent({
hook_event_name: 'PermissionRequest',
agent_id: 'a-blocked',
agent_type: 'general-purpose',
tool_name: 'Bash',
tool_input: { command: 'rm -rf build' }
})
expect(blocked?.payload.state).toBe('waiting')
// Why: the blocked child dying (killed, errored) must not pin the
// permission wait on the pane forever.
const stopped = claudeEvent({ hook_event_name: 'SubagentStop', agent_id: 'a-blocked' })
expect(stopped?.payload.state).toBe('working')
})
it('restores a finished lead to done after a child permission wait clears', () => {
claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'bg task' })
claudeEvent({
hook_event_name: 'SubagentStart',
agent_id: 'a1',
agent_type: 'general-purpose'
})
claudeEvent({
hook_event_name: 'Stop',
background_tasks: [{ id: 'a1', type: 'subagent', status: 'running' }]
})
const blocked = claudeEvent({
hook_event_name: 'PermissionRequest',
agent_id: 'a1',
tool_name: 'Bash',
tool_input: { command: 'rm -rf build' }
})
expect(blocked?.payload.state).toBe('waiting')
const approved = claudeEvent({
hook_event_name: 'PreToolUse',
agent_id: 'a1',
tool_name: 'Bash',
tool_input: { command: 'rm -rf build' }
})
expect(approved?.payload.state).toBe('working')
// Why: the lead already stopped before the wait; draining the child
// must resolve to done, not pin the pane on an invented 'working'.
const drained = claudeEvent({ hook_event_name: 'SubagentStop', agent_id: 'a1' })
expect(drained?.payload.state).toBe('done')
})
it('resolves to done when a blocked child dies after the lead finished', () => {
claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'bg task' })
claudeEvent({
hook_event_name: 'SubagentStart',
agent_id: 'a1',
agent_type: 'general-purpose'
})
claudeEvent({
hook_event_name: 'Stop',
background_tasks: [{ id: 'a1', type: 'subagent', status: 'running' }]
})
claudeEvent({
hook_event_name: 'PermissionRequest',
agent_id: 'a1',
tool_name: 'Bash',
tool_input: { command: 'sleep 999' }
})
const stopped = claudeEvent({ hook_event_name: 'SubagentStop', agent_id: 'a1' })
expect(stopped?.payload.state).toBe('done')
})
it('demotes a snapshot-seeded child missing from a present background_tasks list', () => {
seedClaudeSubagentRosterFromSnapshots(state, PANE_KEY, [
{ id: 'a77', state: 'working', startedAt: 1000, agentType: 'general-purpose' }
])
claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'after restart' })
// Why: teams sessions never send an EMPTY list — the alive teammate
// entry must not keep a phantom pre-restart child gating the pane.
const stop = claudeEvent({
hook_event_name: 'Stop',
background_tasks: [
{ id: 'tlkjjs0jv', type: 'teammate', status: 'running', description: 'alive' }
]
})
expect(stop?.payload.state).toBe('done')
expect(stop?.payload.subagents).toEqual([
expect.objectContaining({ id: 'a77', state: 'idle' })
])
})
it('keeps a snapshot-seeded child working while background_tasks still lists it', () => {
seedClaudeSubagentRosterFromSnapshots(state, PANE_KEY, [
{ id: 'a77', state: 'working', startedAt: 1000, agentType: 'general-purpose' }
])
claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'after restart' })
const stop = claudeEvent({
hook_event_name: 'Stop',
background_tasks: [{ id: 'a77', type: 'subagent', status: 'running' }]
})
expect(stop?.payload.state).toBe('working')
expect(stop?.payload.subagents).toEqual([
expect.objectContaining({ id: 'a77', state: 'working' })
])
})
it('does not adopt a known child turn-boundary event as the lead state', () => {
claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'go' })
claudeEvent({
hook_event_name: 'SubagentStart',
agent_id: 'a1',
agent_type: 'general-purpose'
})
// Why: a CLI that stops converting child Stops to SubagentStop must not
// retire the pane while the lead still works.
const childStop = claudeEvent({ hook_event_name: 'Stop', agent_id: 'a1' })
expect(childStop?.payload.state).toBe('working')
expect(childStop?.payload.prompt).toBe('go')
const leadStop = claudeEvent({ hook_event_name: 'Stop', background_tasks: [] })
expect(leadStop?.payload.state).toBe('done')
})
it('scopes TeammateIdle to the exact teammate name for hyphen-prefix names', () => {
claudeEvent({
hook_event_name: 'SubagentStart',
agent_id: 'alane-hooks-6d3cb5b5',
agent_type: 'lane-hooks'
})
// Why: teammate "lane" must not idle "lane-hooks"'s rows via the
// `a<name>-` prefix — the id suffix after the name is hyphen-free hex.
const idledOther = claudeEvent({
hook_event_name: 'TeammateIdle',
teammate_name: 'lane',
team_name: 'session-x'
})
expect(idledOther?.payload.subagents).toEqual([
expect.objectContaining({ id: 'alane-hooks-6d3cb5b5', state: 'working' })
])
const idled = claudeEvent({
hook_event_name: 'TeammateIdle',
teammate_name: 'lane-hooks',
team_name: 'session-x'
})
expect(idled?.payload.subagents).toEqual([
expect.objectContaining({ id: 'alane-hooks-6d3cb5b5', state: 'idle' })
])
})
it('keeps an inferred interrupt terminal across later child lifecycle events', () => {
claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'cancel me' })
claudeEvent({
hook_event_name: 'SubagentStart',
agent_id: 'aprobe-1',
agent_type: 'probe'
})
claudeEvent({ hook_event_name: 'SubagentStop', agent_id: 'aprobe-1' })
markClaudeLeadTurnInterrupted(state, PANE_KEY)
const idled = claudeEvent({
hook_event_name: 'TeammateIdle',
teammate_name: 'probe',
team_name: 'session-x'
})
expect(idled?.payload.state).toBe('done')
expect(idled?.payload.interrupted).toBe(true)
})
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' }
])
claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'after restart' })
const stop = claudeEvent({
hook_event_name: 'Stop',
background_tasks: [
{ id: 'tlkjjs0jv', type: 'teammate', status: 'running', description: 'alive teammate' }
]
})
expect(stop?.payload.state).toBe('done')
expect(stop?.payload.subagents).toEqual([
expect.objectContaining({ id: 'aprobe2-abc', state: 'idle' })
])
})
it('rebuilds a running one-shot subagent from background_tasks after restart', () => {
// Why: fresh listener state (post-restart) has no roster; a Stop that
// reports a running non-teammate task must resurrect the child row and
// keep the pane working rather than declaring done.
claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'resume' })
const stop = claudeEvent({
hook_event_name: 'Stop',
background_tasks: [
{
id: 'a77',
type: 'subagent',
status: 'running',
description: 'long build',
agent_type: 'general-purpose'
}
]
})
expect(stop?.payload.state).toBe('working')
expect(stop?.payload.subagents).toEqual([
expect.objectContaining({ id: 'a77', state: 'working', description: 'long build' })
])
})
})
describe('writeEndpointFile', () => {
let dir: string
beforeEach(() => {

View File

@ -27,7 +27,24 @@ import {
} from 'node:fs'
import { isAbsolute, join } from 'node:path'
import { parseAgentStatusPayload, type ParsedAgentStatusPayload } from './agent-status-types'
import {
normalizeAgentStatusPayload,
parseAgentStatusPayload,
type AgentStatusState,
type AgentSubagentSnapshot,
type ParsedAgentStatusPayload
} from './agent-status-types'
import {
claudeRosterHasWorkingSubagent,
claudeRosterToSnapshots,
claudeTeammateIdMatchesName,
foldClaudeBackgroundTasksIntoRoster,
markClaudeSubagentIdle,
markClaudeTeammateIdleByName,
readClaudeBackgroundAgentTasks,
upsertWorkingClaudeSubagent,
type ClaudeSubagentRoster
} from './claude-subagent-roster'
import { ORCA_HOOK_PROTOCOL_VERSION } from './agent-hook-types'
import { REMOTE_AGENT_HOOK_ENV, type AgentHookSource } from './agent-hook-relay'
import {
@ -87,6 +104,30 @@ export type HookListenerState = {
lastStatusByPaneKey: Map<string, AgentHookEventPayload>
antigravityCompletedTranscriptByPaneKey: Map<string, string>
ampCompletedCacheKeys: Set<string>
/** Live subagents/teammates per Claude pane. Survives turn boundaries
* background children outlive the lead turn that spawned them. */
claudeSubagentRosterByPaneKey: Map<string, ClaudeSubagentRoster>
/** Last state derived from the LEAD session's own events (subagent-origin
* events carry `agent_id` and are excluded). Needed so a SubagentStop can
* re-emit the pane status without inventing a lead state. `interrupted`
* persists here because a gated 'working' emit clamps the flag away, and
* the eventual done (when the last child drains) must still carry it. */
claudeLeadStateByPaneKey: Map<string, ClaudeLeadTurnState>
}
export type ClaudeLeadTurnState = {
state: AgentStatusState
interrupted?: true
/** Set when the waiting state was induced by a subagent's PermissionRequest
* or AskUserQuestion (those payloads carry `agent_id`). Only that agent's
* next tool activity may clear the wait other children's churn must not
* dismiss a pending human-input card. */
waitingAgentId?: string
/** The lead state a child-induced wait displaced. Restored when the wait
* clears the lead may have already finished its turn, and inventing
* 'working' would leave the pane spinning after the roster drains (the
* done-gate only ever downgrades done working, never back). */
stateBeforeWait?: Pick<ClaudeLeadTurnState, 'state' | 'interrupted'>
}
export function createHookListenerState(): HookListenerState {
@ -97,7 +138,9 @@ export function createHookListenerState(): HookListenerState {
lastToolByPaneKey: new Map(),
lastStatusByPaneKey: new Map(),
antigravityCompletedTranscriptByPaneKey: new Map(),
ampCompletedCacheKeys: new Set()
ampCompletedCacheKeys: new Set(),
claudeSubagentRosterByPaneKey: new Map(),
claudeLeadStateByPaneKey: new Map()
}
}
@ -107,6 +150,8 @@ export function clearPaneCacheState(state: HookListenerState, paneKey: string):
deletePaneScopedCacheEntry(state.lastStatusByPaneKey, paneKey)
deletePaneScopedCacheEntry(state.antigravityCompletedTranscriptByPaneKey, paneKey)
deletePaneScopedSetEntry(state.ampCompletedCacheKeys, paneKey)
state.claudeSubagentRosterByPaneKey.delete(paneKey)
state.claudeLeadStateByPaneKey.delete(paneKey)
}
function clearPaneTurnCacheState(state: HookListenerState, paneKey: string): void {
@ -144,6 +189,8 @@ export function clearAllListenerCaches(state: HookListenerState): void {
state.ampCompletedCacheKeys.clear()
state.warnedVersions.clear()
state.warnedEnvs.clear()
state.claudeSubagentRosterByPaneKey.clear()
state.claudeLeadStateByPaneKey.clear()
}
/** Emit warn-once diagnostics for cross-build (`version`) and dev-vs-prod
@ -2281,6 +2328,139 @@ function extractToolFields(
}
}
function getOrCreateClaudeSubagentRoster(
state: HookListenerState,
paneKey: string
): ClaudeSubagentRoster {
let roster = state.claudeSubagentRosterByPaneKey.get(paneKey)
if (!roster) {
roster = new Map()
state.claudeSubagentRosterByPaneKey.set(paneKey, roster)
}
return roster
}
/** SubagentStart/SubagentStop/TeammateIdle don't map to a pane state by
* themselves; they update the roster and re-emit the lead's last known state
* with the fresh child list so the sidebar reflects spawn/finish immediately
* (a background child can outlive the lead turn by minutes with no other
* hook traffic). */
function normalizeClaudeSubagentLifecycleEvent(
state: HookListenerState,
eventName: 'SubagentStart' | 'SubagentStop' | 'TeammateIdle',
paneKey: string,
hookPayload: Record<string, unknown>
): ParsedAgentStatusPayload | null {
const roster = getOrCreateClaudeSubagentRoster(state, paneKey)
if (eventName === 'TeammateIdle') {
const teammateName = readString(hookPayload, 'teammate_name')
if (!teammateName) {
return null
}
markClaudeTeammateIdleByName(roster, teammateName)
clearClaudePendingWaitForAgent(state, paneKey, (waitingAgentId) =>
claudeTeammateIdMatchesName(waitingAgentId, teammateName)
)
} else {
const agentId = readString(hookPayload, 'agent_id')
if (!agentId) {
return null
}
if (eventName === 'SubagentStart') {
upsertWorkingClaudeSubagent(
roster,
agentId,
{ agentType: readString(hookPayload, 'agent_type') },
Date.now()
)
} else {
markClaudeSubagentIdle(roster, 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.
clearClaudePendingWaitForAgent(state, paneKey, (waitingAgentId) => waitingAgentId === agentId)
}
}
return buildClaudeChildDrivenStatusPayload(state, eventName, paneKey, hookPayload)
}
/** Sync the Claude lead-turn record when the SERVER infers an interrupt
* outside the hook stream (Ctrl+C with a missed Stop hook). Without this, a
* later child lifecycle event would re-emit the stale pre-interrupt lead
* state and resurrect a cancelled pane. */
export function markClaudeLeadTurnInterrupted(state: HookListenerState, paneKey: string): void {
state.claudeLeadStateByPaneKey.set(paneKey, { state: 'done', interrupted: true })
}
/** Rebuild a pane's roster from a persisted status snapshot during hydration.
* Restart loses the in-memory roster while the renderer replays the child
* rows from disk; without reseeding, the next teammate-bearing Stop (whose
* task ids never match lifecycle ids) would silently drop those rows. Stale
* seeds self-heal: an empty background_tasks clears, activity refreshes. */
export function seedClaudeSubagentRosterFromSnapshots(
state: HookListenerState,
paneKey: string,
snapshots: readonly AgentSubagentSnapshot[]
): void {
if (snapshots.length === 0 || state.claudeSubagentRosterByPaneKey.has(paneKey)) {
return
}
const roster = getOrCreateClaudeSubagentRoster(state, paneKey)
for (const snapshot of snapshots) {
roster.set(snapshot.id, {
state: snapshot.state === 'working' ? 'working' : 'idle',
startedAt: snapshot.startedAt,
agentType: snapshot.agentType,
description: snapshot.description,
// 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.
backgroundTasksAuthoritative: true
})
}
}
/** Drop a child-owned waiting state when that child stops/idles, restoring
* the lead state the wait displaced. Without a stash (the wait was the
* pane's first observed lead event) fall back to 'working' and let the next
* lead event resolve it a transient spinner beats a permanently stuck
* card. */
function clearClaudePendingWaitForAgent(
state: HookListenerState,
paneKey: string,
ownsWait: (waitingAgentId: string) => boolean
): void {
const lead = state.claudeLeadStateByPaneKey.get(paneKey)
if (lead?.state !== 'waiting' || !lead.waitingAgentId || !ownsWait(lead.waitingAgentId)) {
return
}
state.claudeLeadStateByPaneKey.set(paneKey, lead.stateBeforeWait ?? { state: 'working' })
}
/** Emit a pane status refresh driven by child activity (lifecycle events and
* child-origin tool events): the lead's cached state is re-emitted gated up
* to 'working' while a child works without touching the lead's tool/prompt
* caches, so a live AskUserQuestion card or permission wait survives child
* churn. */
function buildClaudeChildDrivenStatusPayload(
state: HookListenerState,
eventName: unknown,
paneKey: string,
hookPayload: Record<string, unknown>
): ParsedAgentStatusPayload | null {
// Why: default 'working' — a spawn is proof of activity even before the
// lead's first state-bearing event (e.g. Orca restarted mid-session).
const lead = state.claudeLeadStateByPaneKey.get(paneKey)
const leadState = lead?.state ?? 'working'
const roster = state.claudeSubagentRosterByPaneKey.get(paneKey)
return buildClaudeStatusPayload(state, eventName, '', paneKey, hookPayload, {
stateName:
leadState === 'done' && claudeRosterHasWorkingSubagent(roster) ? 'working' : leadState,
updateToolSnapshot: false,
interrupted: lead?.interrupted
})
}
function normalizeClaudeEvent(
state: HookListenerState,
eventName: unknown,
@ -2288,6 +2468,14 @@ function normalizeClaudeEvent(
paneKey: string,
hookPayload: Record<string, unknown>
): ParsedAgentStatusPayload | null {
if (
eventName === 'SubagentStart' ||
eventName === 'SubagentStop' ||
eventName === 'TeammateIdle'
) {
return normalizeClaudeSubagentLifecycleEvent(state, eventName, paneKey, hookPayload)
}
// Why: Claude's AskUserQuestion tool is auto-allowed, so it emits PreToolUse
// (not PermissionRequest) while blocked on a human answer — Claude posts a
// Notification instead of PermissionRequest, and Orca does not register the
@ -2312,30 +2500,157 @@ function normalizeClaudeEvent(
return null
}
const snapshot = resolveToolState(
state,
paneKey,
extractToolFields('claude', eventName, hookPayload),
{ resetOnNewTurn: isNewTurnEvent('claude', eventName) }
)
const eventAgentId = readString(hookPayload, 'agent_id')
// Why: hook events originating inside a subagent/teammate carry `agent_id`;
// the lead session's own events don't. Subagent tool activity keeps that
// child's row live but must not be mistaken for the lead's turn state, and
// must not overwrite the lead's tool/prompt caches (a live AskUserQuestion
// card would vanish when a background child ran its next tool). Two
// exceptions take the full path below: waiting-inducing events (a child's
// PermissionRequest/AskUserQuestion needs the human's attention on this
// pane), and the blocked child's own next tool event (approval granted —
// the wait must clear exactly as it does for the lead).
const isWaitingInducing = stateName === 'waiting'
const subagentOriginId =
!isWaitingInducing &&
(eventName === 'PreToolUse' ||
eventName === 'PostToolUse' ||
eventName === 'PostToolUseFailure')
? eventAgentId
: undefined
if (eventAgentId && (subagentOriginId || isWaitingInducing)) {
upsertWorkingClaudeSubagent(
getOrCreateClaudeSubagentRoster(state, paneKey),
eventAgentId,
{ agentType: readString(hookPayload, 'agent_type') },
Date.now()
)
}
if (subagentOriginId) {
const lead = state.claudeLeadStateByPaneKey.get(paneKey)
if (lead?.state !== 'waiting' || lead.waitingAgentId !== subagentOriginId) {
return buildClaudeChildDrivenStatusPayload(state, eventName, paneKey, hookPayload)
}
// Why: approval granted — update the tool snapshot exactly as the lead's
// own next tool event would (dropping the pending card), but restore the
// lead state the wait displaced instead of adopting this child event as
// the lead's 'working': the lead may already be done, and the done-gate
// never upgrades working back to done once the roster drains.
const restored = lead.stateBeforeWait ?? { state: 'working' as const }
state.claudeLeadStateByPaneKey.set(paneKey, restored)
const roster = state.claudeSubagentRosterByPaneKey.get(paneKey)
return buildClaudeStatusPayload(state, eventName, promptText, paneKey, hookPayload, {
stateName:
restored.state === 'done' && claudeRosterHasWorkingSubagent(roster)
? 'working'
: restored.state,
updateToolSnapshot: true,
interrupted: restored.interrupted
})
}
// Why: lead events never carry agent_id, so a known child's id on a
// turn-boundary event (a CLI that stops converting child Stops to
// SubagentStop) must not retire or resurrect the pane as if the lead
// spoke — re-emit it as child activity instead.
if (
eventAgentId &&
!isWaitingInducing &&
state.claudeSubagentRosterByPaneKey.get(paneKey)?.has(eventAgentId)
) {
return buildClaudeChildDrivenStatusPayload(state, eventName, paneKey, hookPayload)
}
if (eventName === 'Stop' || eventName === 'StopFailure') {
// Why: background_tasks is only trusted where unambiguous (empty list,
// id-exact matches, unmatched running one-shot subagents) — see
// foldClaudeBackgroundTasksIntoRoster. The lifecycle events own teammate
// state; teammates report "running" here even while idle. Older Claude
// builds without the field keep the incrementally tracked roster.
const backgroundTasks = readClaudeBackgroundAgentTasks(hookPayload)
if (backgroundTasks.present) {
foldClaudeBackgroundTasksIntoRoster(
getOrCreateClaudeSubagentRoster(state, paneKey),
backgroundTasks.tasks,
Date.now()
)
}
}
const interrupted =
eventName === 'Stop' && hookPayload['is_interrupt'] === true ? true : undefined
// Why: a child-induced wait displaces the lead's own state; stash it so
// clearing the wait restores reality (the lead may already be done). A
// second child wait carries the ORIGINAL stash forward, not the
// intermediate waiting state.
const previousLead = state.claudeLeadStateByPaneKey.get(paneKey)
const stateBeforeWait =
isWaitingInducing && eventAgentId && previousLead
? previousLead.state === 'waiting'
? previousLead.stateBeforeWait
: {
state: previousLead.state,
...(previousLead.interrupted ? { interrupted: true as const } : {})
}
: undefined
state.claudeLeadStateByPaneKey.set(paneKey, {
state: stateName,
...(interrupted ? { interrupted } : {}),
...(isWaitingInducing && eventAgentId ? { waitingAgentId: eventAgentId } : {}),
...(stateBeforeWait ? { stateBeforeWait } : {})
})
return parseAgentStatusPayload(
JSON.stringify({
state: stateName,
prompt: resolvePrompt(state, paneKey, promptText, {
// Why: the lead ending its turn is not "done" while spawned subagents or
// teammates are still running — that reads as a finished ✅ in the sidebar
// while a background review loop is mid-flight. Claude wakes the lead when
// a child finishes, so a later Stop with an empty roster resolves to done.
const roster = state.claudeSubagentRosterByPaneKey.get(paneKey)
const effectiveState =
stateName === 'done' && claudeRosterHasWorkingSubagent(roster) ? 'working' : stateName
return buildClaudeStatusPayload(state, eventName, promptText, paneKey, hookPayload, {
stateName: effectiveState,
updateToolSnapshot: true,
interrupted
})
}
function buildClaudeStatusPayload(
state: HookListenerState,
eventName: unknown,
promptText: string,
paneKey: string,
hookPayload: Record<string, unknown>,
options: { stateName: AgentStatusState; updateToolSnapshot: boolean; interrupted?: boolean }
): ParsedAgentStatusPayload | null {
// Why: child-driven refreshes are roster bookkeeping, not lead tool
// activity. Read the cached tool snapshot without merging so they can't
// clear a live AskUserQuestion card or clobber the in-flight tool preview.
const snapshot = options.updateToolSnapshot
? resolveToolState(state, paneKey, extractToolFields('claude', eventName, hookPayload), {
resetOnNewTurn: isNewTurnEvent('claude', eventName)
}),
agentType: 'claude',
toolName: snapshot.toolName,
toolInput: snapshot.toolInput,
interactivePrompt: snapshot.interactivePrompt,
lastAssistantMessage: snapshot.lastAssistantMessage,
interrupted
})
)
})
: (state.lastToolByPaneKey.get(paneKey) ?? {})
// Why: normalizeAgentStatusPayload validates the object directly — the
// JSON stringify/parse round trip the other normalizers use is pure
// overhead on this hot per-hook-event path. The normalizer clamps
// `interrupted` to done-state payloads, so a gated 'working' emit drops it
// while claudeLeadStateByPaneKey preserves it for the eventual done.
return normalizeAgentStatusPayload({
state: options.stateName,
// Why: only lead-origin events (updateToolSnapshot) may reset the prompt
// cache; a child-driven refresh must not blank the lead's prompt label.
prompt: resolvePrompt(state, paneKey, promptText, {
resetOnNewTurn: options.updateToolSnapshot && isNewTurnEvent('claude', eventName)
}),
agentType: 'claude',
toolName: snapshot.toolName,
toolInput: snapshot.toolInput,
interactivePrompt: snapshot.interactivePrompt,
lastAssistantMessage: snapshot.lastAssistantMessage,
interrupted: options.interrupted,
subagents: claudeRosterToSnapshots(state.claudeSubagentRosterByPaneKey.get(paneKey))
})
}
// Why: Devin uses Claude-compatible hook payload shapes but has its own

View File

@ -0,0 +1,213 @@
// ─── Agent status field normalization ───────────────────────────────────────
// String normalizers shared by every agent-status payload field: trim/fold to
// a single line for previews, preserve structure for multiline bodies, and
// truncate without splitting surrogate pairs. Extracted from
// agent-status-types.ts, which owns the payload shapes and per-field caps.
import {
compactDispatchPromptForStatus,
isOrcaDispatchStatusPrompt
} from './orca-dispatch-status-prompt'
/** Maximum character length for the prompt field. Truncated on parse. */
export const AGENT_STATUS_MAX_FIELD_LENGTH = 200
const SINGLE_LINE_FIELD_SCAN_OVERHEAD = 64
const SINGLE_LINE_FIELD_SCAN_MULTIPLIER = 8
// Why: when truncation lands mid surrogate-pair (emoji / astral chars), the
// high surrogate would be left dangling and render as the Unicode replacement
// glyph. Drop the lone high surrogate so the result is always a valid UTF-16
// sequence. Shared by the single-line and multiline normalizers so the
// protection can't drift between them.
function truncatePreservingSurrogates(value: string, maxLength: number): string {
if (value.length < maxLength) {
return value
}
let truncated = value.length === maxLength ? value : value.slice(0, maxLength)
const lastCode = truncated.charCodeAt(truncated.length - 1)
if (lastCode >= 0xd800 && lastCode <= 0xdbff) {
truncated = truncated.slice(0, -1)
}
return truncated
}
/** Normalize a status field: trim, collapse to single line, truncate. */
function normalizeField(value: unknown, maxLength: number = AGENT_STATUS_MAX_FIELD_LENGTH): string {
if (typeof value !== 'string') {
return ''
}
return normalizeSingleLinePreview(value, maxLength)
}
/** Normalize the agent prompt field, compacting Orca dispatch preambles. */
export function normalizePromptField(value: unknown): string {
if (typeof value !== 'string') {
return ''
}
if (isOrcaDispatchStatusPrompt(value)) {
return compactDispatchPromptForStatus(
value,
AGENT_STATUS_MAX_FIELD_LENGTH,
normalizeSingleLinePreview
)
}
return normalizeSingleLinePreview(value, AGENT_STATUS_MAX_FIELD_LENGTH)
}
function normalizeSingleLinePreview(value: string, maxLength: number): string {
// Why: hook prompt/tool fields are previews. Bound the source scan before
// folding line breaks so paste-sized status text cannot run a full regex
// replacement just to keep a small dashboard label.
const scanEnd = Math.min(
value.length,
maxLength * SINGLE_LINE_FIELD_SCAN_MULTIPLIER + SINGLE_LINE_FIELD_SCAN_OVERHEAD
)
let index = 0
while (index < scanEnd && isEcmaTrimWhitespace(value.charCodeAt(index))) {
index++
}
let normalized = ''
let lineSeparatorRun = false
while (index < scanEnd && normalized.length < maxLength) {
const code = value.charCodeAt(index)
if (isSingleLineSeparator(code)) {
if (code === 13 && value.charCodeAt(index + 1) === 10) {
index++
}
if (!lineSeparatorRun) {
normalized += ' '
}
lineSeparatorRun = true
index++
continue
}
normalized += value[index]
lineSeparatorRun = false
index++
}
if (normalized.length < maxLength) {
normalized = trimTrailingWhitespace(normalized)
}
return truncatePreservingSurrogates(normalized, maxLength)
}
// Why: assistant messages are a multi-paragraph "what did the agent say"
// body that the dashboard renders with `whitespace-pre-wrap`. Collapsing
// newlines here would erase structure the UI is designed to show. Still
// normalize `\r\n` → `\n` and cap paragraph gaps at one blank line to keep
// the bound meaningful, but otherwise preserve line breaks.
function normalizeMultilineField(value: unknown, maxLength: number): string {
if (typeof value !== 'string') {
return ''
}
// Why: fold Unicode line/paragraph separators (U+2028, U+2029) into ordinary
// `\n` before the blank-line-run cap. These code points render as real line
// breaks under `whitespace-pre-wrap`, so leaving them untouched would let a
// buggy/malicious agent bypass the `\n{3,}` → `\n\n` safeguard by spamming
// arbitrarily many U+2029 paragraph breaks. Matches the single-line
// normalizer's treatment of the same code points, keeping the two paths in
// sync. Step order preserved: `\r\n` → `\n`, bare `\r` → `\n`,
// U+2028/U+2029 → `\n`, then collapse blank-line runs.
const { start, end } = getTrimmedStringBounds(value)
let normalized = ''
let newlineRun = 0
for (let index = start; index < end && normalized.length < maxLength; index++) {
const code = value.charCodeAt(index)
if (code === 13 || code === 10 || code === 0x2028 || code === 0x2029) {
if (code === 13 && value.charCodeAt(index + 1) === 10) {
index++
}
if (newlineRun < 2) {
normalized += '\n'
}
newlineRun++
continue
}
normalized += value[index]
newlineRun = 0
}
return truncatePreservingSurrogates(normalized, maxLength)
}
function getTrimmedStringBounds(value: string): { start: number; end: number } {
let start = 0
let end = value.length
while (start < end && isEcmaTrimWhitespace(value.charCodeAt(start))) {
start++
}
while (end > start && isEcmaTrimWhitespace(value.charCodeAt(end - 1))) {
end--
}
return { start, end }
}
function trimTrailingWhitespace(value: string): string {
let end = value.length
while (end > 0 && isEcmaTrimWhitespace(value.charCodeAt(end - 1))) {
end--
}
return end === value.length ? value : value.slice(0, end)
}
function isSingleLineSeparator(code: number): boolean {
return code === 13 || code === 10 || code === 0x2028 || code === 0x2029
}
function isEcmaTrimWhitespace(code: number): boolean {
return (
code === 0x20 ||
(code >= 0x09 && code <= 0x0d) ||
code === 0xa0 ||
code === 0x1680 ||
(code >= 0x2000 && code <= 0x200a) ||
code === 0x2028 ||
code === 0x2029 ||
code === 0x202f ||
code === 0x205f ||
code === 0x3000 ||
code === 0xfeff
)
}
// Why: tool/assistant fields are optional on the entry (absence = "no update
// for this field"). We only surface them when the caller actually provided a
// string value so a missing field doesn't overwrite the prior cached state.
// Why: interactivePrompt carries raw JSON (`{ questions: [...] }`) that clients
// JSON.parse to render a structured card. Unlike the other normalizers we must
// NOT trim, collapse newlines, or fold blank-line runs — any of those would
// corrupt the JSON or alter option text inside it. Only guard the length cap
// (preserving surrogate pairs) and drop empty strings to undefined.
export function normalizeInteractivePromptField(
value: unknown,
maxLength: number
): string | undefined {
if (typeof value !== 'string' || value.length === 0) {
return undefined
}
const truncated = truncatePreservingSurrogates(value, maxLength)
return truncated.length > 0 ? truncated : undefined
}
export function normalizeOptionalField(value: unknown, maxLength: number): string | undefined {
if (typeof value !== 'string') {
return undefined
}
const normalized = normalizeField(value, maxLength)
return normalized.length > 0 ? normalized : undefined
}
export function normalizeOptionalMultilineField(
value: unknown,
maxLength: number
): string | undefined {
if (typeof value !== 'string') {
return undefined
}
const normalized = normalizeMultilineField(value, maxLength)
return normalized.length > 0 ? normalized : undefined
}

View File

@ -1,8 +1,10 @@
import { afterEach, describe, it, expect, vi } from 'vitest'
import {
agentSubagentsEqual,
parseAgentStatusPayload,
normalizeAgentStatusPayload,
AGENT_STATUS_MAX_FIELD_LENGTH,
AGENT_STATUS_MAX_SUBAGENTS,
AGENT_STATUS_TOOL_NAME_MAX_LENGTH,
AGENT_STATUS_TOOL_INPUT_MAX_LENGTH,
AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH,
@ -477,4 +479,56 @@ Fix dispatch fallback preview for normalized status prompts`
expect(secondLast >= 0xd800 && secondLast <= 0xdbff).toBe(true)
}
})
it('normalizes the subagents field, dropping invalid entries and bounding count', () => {
const result = parseAgentStatusPayload(
JSON.stringify({
state: 'working',
subagents: [
{ id: 'a1', state: 'working', startedAt: 100, agentType: 'general-purpose' },
{ id: 'r1', state: 'idle', startedAt: 'nope', description: 'line\none' },
{ id: '', state: 'working', startedAt: 1 },
{ id: 'bad-state', state: 'running', startedAt: 1 },
'garbage',
...Array.from({ length: AGENT_STATUS_MAX_SUBAGENTS + 5 }, (_, i) => ({
id: `extra-${i}`,
state: 'idle',
startedAt: i
}))
]
})
)
expect(result?.subagents?.length).toBe(AGENT_STATUS_MAX_SUBAGENTS)
expect(result?.subagents?.[0]).toEqual({
id: 'a1',
state: 'working',
startedAt: 100,
agentType: 'general-purpose',
description: undefined
})
// Why: non-finite startedAt coerces to 0; descriptions fold to one line.
expect(result?.subagents?.[1]).toMatchObject({
id: 'r1',
startedAt: 0,
description: 'line one'
})
})
it('omits subagents when absent or empty', () => {
expect(parseAgentStatusPayload('{"state":"done"}')?.subagents).toBeUndefined()
expect(parseAgentStatusPayload('{"state":"done","subagents":[]}')?.subagents).toBeUndefined()
})
})
describe('agentSubagentsEqual', () => {
const snapshot = { id: 'a1', state: 'working' as const, startedAt: 1 }
it('compares structurally and treats undefined/empty as distinct from populated', () => {
expect(agentSubagentsEqual(undefined, undefined)).toBe(true)
expect(agentSubagentsEqual([snapshot], [{ ...snapshot }])).toBe(true)
expect(agentSubagentsEqual([snapshot], [{ ...snapshot, state: 'idle' }])).toBe(false)
expect(agentSubagentsEqual([snapshot], undefined)).toBe(false)
expect(agentSubagentsEqual(undefined, [snapshot])).toBe(false)
expect(agentSubagentsEqual([snapshot], [snapshot, { ...snapshot, id: 'b' }])).toBe(false)
})
})

View File

@ -7,9 +7,13 @@
import type { AgentProviderSessionMetadata } from './agent-session-resume'
import {
compactDispatchPromptForStatus,
isOrcaDispatchStatusPrompt
} from './orca-dispatch-status-prompt'
normalizeInteractivePromptField,
normalizeOptionalField,
normalizeOptionalMultilineField,
normalizePromptField
} from './agent-status-field-normalization'
export { AGENT_STATUS_MAX_FIELD_LENGTH } from './agent-status-field-normalization'
export const AGENT_STATUS_STATES = ['working', 'blocked', 'waiting', 'done'] as const
export type AgentStatusState = (typeof AGENT_STATUS_STATES)[number]
@ -76,6 +80,22 @@ export type AgentStatusOrchestrationContext = {
orchestrationRunId?: string
}
export type AgentSubagentState = 'working' | 'idle'
/** A live in-process subagent/teammate spawned by the pane's agent session
* (reported by Claude's SubagentStart/SubagentStop hooks and the
* `background_tasks` field on Stop). Rendered as an indented child row under
* the owning pane's sidebar row these children have no PTY of their own. */
export type AgentSubagentSnapshot = {
/** Provider-assigned id (Claude hook `agent_id`). */
id: string
agentType?: string
description?: string
state: AgentSubagentState
/** Timestamp (ms) when this subagent was first observed. */
startedAt: number
}
export type AgentStatusEntry = {
state: AgentStatusState
/** The user's most recent prompt, when the hook payload carried one.
@ -130,6 +150,9 @@ export type AgentStatusEntry = {
* Why: parent/child agent hierarchy is pane-level state, not worktree
* lineage; workers often run in the same worktree as their coordinator. */
orchestration?: AgentStatusOrchestrationContext
/** Live in-process subagents/teammates of this pane's session. Absent when
* none are tracked; the sidebar derives indented child rows from it. */
subagents?: AgentSubagentSnapshot[]
/** Provider-owned conversation/session id captured from hook payloads.
* Used only for exact CLI resume; Orca terminal ids are not agent-session ids. */
providerSession?: AgentProviderSessionMetadata
@ -163,6 +186,8 @@ export type AgentStatusPayload = {
interactivePrompt?: string
lastAssistantMessage?: string
interrupted?: boolean
/** Live subagents/teammates of the reporting session. See AgentStatusEntry. */
subagents?: AgentSubagentSnapshot[]
}
/**
@ -199,8 +224,6 @@ export type AgentStatusIpcPayload = ParsedAgentStatusPayload & {
providerSession?: AgentProviderSessionMetadata
}
/** Maximum character length for the prompt field. Truncated on parse. */
export const AGENT_STATUS_MAX_FIELD_LENGTH = 200
/** Maximum character length for the toolName field. */
export const AGENT_STATUS_TOOL_NAME_MAX_LENGTH = 60
/** Maximum character length for the toolInput preview. */
@ -236,9 +259,6 @@ export function isFreshNonDoneAgentStatus(
return Boolean(entry && entry.state !== 'done' && now - entry.updatedAt <= staleAfterMs)
}
const SINGLE_LINE_FIELD_SCAN_OVERHEAD = 64
const SINGLE_LINE_FIELD_SCAN_MULTIPLIER = 8
// Why: typed as ReadonlySet<string> so .has() accepts any string without
// requiring `state as AgentStatusState` at the check site. The narrowing
// cast stays on the return line, where it's actually proven safe.
@ -246,195 +266,79 @@ const VALID_STATES: ReadonlySet<string> = new Set<string>(AGENT_STATUS_STATES)
/** Maximum character length for the agentType label. Truncated on parse. */
export const AGENT_TYPE_MAX_LENGTH = 40
// Why: when truncation lands mid surrogate-pair (emoji / astral chars), the
// high surrogate would be left dangling and render as the Unicode replacement
// glyph. Drop the lone high surrogate so the result is always a valid UTF-16
// sequence. Shared by the single-line and multiline normalizers so the
// protection can't drift between them.
function truncatePreservingSurrogates(value: string, maxLength: number): string {
if (value.length < maxLength) {
return value
/** Maximum subagent child rows carried per status entry. Bounds per-pane cache
* and IPC fanout against a runaway spawner. */
export const AGENT_STATUS_MAX_SUBAGENTS = 32
const AGENT_SUBAGENT_ID_MAX_LENGTH = 64
function normalizeSubagentSnapshot(value: unknown): AgentSubagentSnapshot | null {
if (typeof value !== 'object' || value === null) {
return null
}
let truncated = value.length === maxLength ? value : value.slice(0, maxLength)
const lastCode = truncated.charCodeAt(truncated.length - 1)
if (lastCode >= 0xd800 && lastCode <= 0xdbff) {
truncated = truncated.slice(0, -1)
const obj = value as Record<string, unknown>
if (typeof obj.id !== 'string') {
return null
}
const id = obj.id.trim()
if (id.length === 0 || id.length > AGENT_SUBAGENT_ID_MAX_LENGTH) {
return null
}
if (obj.state !== 'working' && obj.state !== 'idle') {
return null
}
return {
id,
state: obj.state,
startedAt:
typeof obj.startedAt === 'number' && Number.isFinite(obj.startedAt) ? obj.startedAt : 0,
agentType: normalizeOptionalField(obj.agentType, AGENT_TYPE_MAX_LENGTH),
description: normalizeOptionalField(obj.description, AGENT_STATUS_TOOL_INPUT_MAX_LENGTH)
}
return truncated
}
/** Normalize a status field: trim, collapse to single line, truncate. */
function normalizeField(value: unknown, maxLength: number = AGENT_STATUS_MAX_FIELD_LENGTH): string {
if (typeof value !== 'string') {
return ''
}
return normalizeSingleLinePreview(value, maxLength)
}
/** Normalize the agent prompt field, compacting Orca dispatch preambles. */
function normalizePromptField(value: unknown): string {
if (typeof value !== 'string') {
return ''
}
if (isOrcaDispatchStatusPrompt(value)) {
return compactDispatchPromptForStatus(
value,
AGENT_STATUS_MAX_FIELD_LENGTH,
normalizeSingleLinePreview
)
}
return normalizeSingleLinePreview(value, AGENT_STATUS_MAX_FIELD_LENGTH)
}
function normalizeSingleLinePreview(value: string, maxLength: number): string {
// Why: hook prompt/tool fields are previews. Bound the source scan before
// folding line breaks so paste-sized status text cannot run a full regex
// replacement just to keep a small dashboard label.
const scanEnd = Math.min(
value.length,
maxLength * SINGLE_LINE_FIELD_SCAN_MULTIPLIER + SINGLE_LINE_FIELD_SCAN_OVERHEAD
)
let index = 0
while (index < scanEnd && isEcmaTrimWhitespace(value.charCodeAt(index))) {
index++
}
let normalized = ''
let lineSeparatorRun = false
while (index < scanEnd && normalized.length < maxLength) {
const code = value.charCodeAt(index)
if (isSingleLineSeparator(code)) {
if (code === 13 && value.charCodeAt(index + 1) === 10) {
index++
}
if (!lineSeparatorRun) {
normalized += ' '
}
lineSeparatorRun = true
index++
continue
}
normalized += value[index]
lineSeparatorRun = false
index++
}
if (normalized.length < maxLength) {
normalized = trimTrailingWhitespace(normalized)
}
return truncatePreservingSurrogates(normalized, maxLength)
}
// Why: assistant messages are a multi-paragraph "what did the agent say"
// body that the dashboard renders with `whitespace-pre-wrap`. Collapsing
// newlines here would erase structure the UI is designed to show. Still
// normalize `\r\n` → `\n` and cap paragraph gaps at one blank line to keep
// the bound meaningful, but otherwise preserve line breaks.
function normalizeMultilineField(value: unknown, maxLength: number): string {
if (typeof value !== 'string') {
return ''
}
// Why: fold Unicode line/paragraph separators (U+2028, U+2029) into ordinary
// `\n` before the blank-line-run cap. These code points render as real line
// breaks under `whitespace-pre-wrap`, so leaving them untouched would let a
// buggy/malicious agent bypass the `\n{3,}` → `\n\n` safeguard by spamming
// arbitrarily many U+2029 paragraph breaks. Matches the single-line
// normalizer's treatment of the same code points, keeping the two paths in
// sync. Step order preserved: `\r\n` → `\n`, bare `\r` → `\n`,
// U+2028/U+2029 → `\n`, then collapse blank-line runs.
const { start, end } = getTrimmedStringBounds(value)
let normalized = ''
let newlineRun = 0
for (let index = start; index < end && normalized.length < maxLength; index++) {
const code = value.charCodeAt(index)
if (code === 13 || code === 10 || code === 0x2028 || code === 0x2029) {
if (code === 13 && value.charCodeAt(index + 1) === 10) {
index++
}
if (newlineRun < 2) {
normalized += '\n'
}
newlineRun++
continue
}
normalized += value[index]
newlineRun = 0
}
return truncatePreservingSurrogates(normalized, maxLength)
}
function getTrimmedStringBounds(value: string): { start: number; end: number } {
let start = 0
let end = value.length
while (start < end && isEcmaTrimWhitespace(value.charCodeAt(start))) {
start++
}
while (end > start && isEcmaTrimWhitespace(value.charCodeAt(end - 1))) {
end--
}
return { start, end }
}
function trimTrailingWhitespace(value: string): string {
let end = value.length
while (end > 0 && isEcmaTrimWhitespace(value.charCodeAt(end - 1))) {
end--
}
return end === value.length ? value : value.slice(0, end)
}
function isSingleLineSeparator(code: number): boolean {
return code === 13 || code === 10 || code === 0x2028 || code === 0x2029
}
function isEcmaTrimWhitespace(code: number): boolean {
return (
code === 0x20 ||
(code >= 0x09 && code <= 0x0d) ||
code === 0xa0 ||
code === 0x1680 ||
(code >= 0x2000 && code <= 0x200a) ||
code === 0x2028 ||
code === 0x2029 ||
code === 0x202f ||
code === 0x205f ||
code === 0x3000 ||
code === 0xfeff
)
}
// Why: tool/assistant fields are optional on the entry (absence = "no update
// for this field"). We only surface them when the caller actually provided a
// string value so a missing field doesn't overwrite the prior cached state.
// Why: interactivePrompt carries raw JSON (`{ questions: [...] }`) that clients
// JSON.parse to render a structured card. Unlike the other normalizers we must
// NOT trim, collapse newlines, or fold blank-line runs — any of those would
// corrupt the JSON or alter option text inside it. Only guard the length cap
// (preserving surrogate pairs) and drop empty strings to undefined.
function normalizeInteractivePromptField(value: unknown, maxLength: number): string | undefined {
if (typeof value !== 'string' || value.length === 0) {
function normalizeSubagentsField(value: unknown): AgentSubagentSnapshot[] | undefined {
if (!Array.isArray(value) || value.length === 0) {
return undefined
}
const truncated = truncatePreservingSurrogates(value, maxLength)
return truncated.length > 0 ? truncated : undefined
}
function normalizeOptionalField(value: unknown, maxLength: number): string | undefined {
if (typeof value !== 'string') {
return undefined
const normalized: AgentSubagentSnapshot[] = []
for (const item of value) {
const snapshot = normalizeSubagentSnapshot(item)
if (snapshot) {
normalized.push(snapshot)
if (normalized.length >= AGENT_STATUS_MAX_SUBAGENTS) {
break
}
}
}
const normalized = normalizeField(value, maxLength)
return normalized.length > 0 ? normalized : undefined
}
function normalizeOptionalMultilineField(value: unknown, maxLength: number): string | undefined {
if (typeof value !== 'string') {
return undefined
/** Structural equality for subagent lists so stores can reuse the previous
* array reference (and skip fanout) when nothing actually changed. */
export function agentSubagentsEqual(
a: AgentSubagentSnapshot[] | undefined,
b: AgentSubagentSnapshot[] | undefined
): boolean {
if (a === b) {
return true
}
const normalized = normalizeMultilineField(value, maxLength)
return normalized.length > 0 ? normalized : undefined
if (!a || !b || a.length !== b.length) {
return !a && !b
}
for (let i = 0; i < a.length; i++) {
const x = a[i]
const y = b[i]
if (
x.id !== y.id ||
x.state !== y.state ||
x.startedAt !== y.startedAt ||
x.agentType !== y.agentType ||
x.description !== y.description
) {
return false
}
}
return true
}
/**
@ -480,7 +384,8 @@ function normalizeAgentStatusObject(parsed: unknown): ParsedAgentStatusPayload |
),
// Why: only meaningful on `done`. Coerce to undefined on other states so
// the field doesn't leak stale truth through state transitions.
interrupted: obj.interrupted === true && state === 'done' ? true : undefined
interrupted: obj.interrupted === true && state === 'done' ? true : undefined,
subagents: normalizeSubagentsField(obj.subagents)
}
}

View File

@ -0,0 +1,261 @@
import { describe, expect, it } from 'vitest'
import { AGENT_STATUS_MAX_SUBAGENTS } from './agent-status-types'
import {
claudeRosterHasWorkingSubagent,
claudeRosterToSnapshots,
claudeTeammateIdMatchesName,
foldClaudeBackgroundTasksIntoRoster,
markClaudeSubagentIdle,
markClaudeTeammateIdleByName,
readClaudeBackgroundAgentTasks,
upsertWorkingClaudeSubagent,
type ClaudeSubagentRoster
} from './claude-subagent-roster'
describe('claude-subagent-roster', () => {
it('tracks spawn and stop as working → idle', () => {
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
}
])
})
it('re-marks an idle subagent working without resetting startedAt', () => {
const roster: ClaudeSubagentRoster = new Map()
upsertWorkingClaudeSubagent(roster, 'a1', {}, 100)
markClaudeSubagentIdle(roster, 'a1')
upsertWorkingClaudeSubagent(roster, 'a1', { description: 'round two' }, 200)
expect(roster.get('a1')).toMatchObject({
state: 'working',
startedAt: 100,
description: 'round two'
})
})
it('ignores unknown ids on markClaudeSubagentIdle', () => {
const roster: ClaudeSubagentRoster = new Map()
markClaudeSubagentIdle(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, `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')
upsertWorkingClaudeSubagent(roster, 'replacement', {}, 1000)
expect(roster.has('replacement')).toBe(true)
expect(roster.has('a3')).toBe(false)
expect(roster.size).toBe(AGENT_STATUS_MAX_SUBAGENTS)
})
it('reads only agent-typed background_tasks entries', () => {
const { present, tasks } = readClaudeBackgroundAgentTasks({
background_tasks: [
{
id: 'a1',
type: 'subagent',
status: 'running',
description: 'review loop',
agent_type: 'general-purpose'
},
{ id: 't1', type: 'teammate', status: 'idle', agent_type: 'code-reviewer' },
{ id: 's1', type: 'shell', status: 'running', description: 'npm run dev' },
{ id: '', type: 'subagent', status: 'running' },
'garbage'
]
})
expect(present).toBe(true)
expect(tasks).toEqual([
{
id: 'a1',
agentType: 'general-purpose',
description: 'review loop',
running: true,
teammate: false
},
{
id: 't1',
agentType: 'code-reviewer',
description: undefined,
running: false,
teammate: true
}
])
})
it('reports background_tasks as absent when missing or malformed', () => {
expect(readClaudeBackgroundAgentTasks({}).present).toBe(false)
expect(readClaudeBackgroundAgentTasks({ background_tasks: 'nope' }).present).toBe(false)
})
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)
foldClaudeBackgroundTasksIntoRoster(
roster,
[
{
id: 'a1',
agentType: 'general-purpose',
description: 'review loop',
running: true,
teammate: false
},
// Why: teammate task ids never match lifecycle agent_ids; unmatched
// teammate entries must not create phantom duplicate children.
{
id: 'tlkjjs0jv',
agentType: undefined,
description: 'teammate task',
running: true,
teammate: true
}
],
200
)
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' })
})
it('recreates unmatched running one-shot subagents after a listener restart', () => {
const roster: ClaudeSubagentRoster = new Map()
foldClaudeBackgroundTasksIntoRoster(
roster,
[
{
id: 'a9',
agentType: 'general-purpose',
description: 'long build',
running: true,
teammate: false
},
{
id: 'gone',
agentType: undefined,
description: undefined,
running: false,
teammate: false
}
],
500
)
expect(roster.get('a9')).toMatchObject({ state: 'working', startedAt: 500 })
// Why: a finished unmatched one-shot leaves no reason to add an idle row.
expect(roster.has('gone')).toBe(false)
})
it('clears the roster when background_tasks reports nothing alive', () => {
const roster: ClaudeSubagentRoster = new Map()
upsertWorkingClaudeSubagent(roster, 'a1', {}, 100)
foldClaudeBackgroundTasksIntoRoster(roster, [], 100)
expect(roster.size).toBe(0)
})
it('demotes task-id-authoritative entries missing from a present list', () => {
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)
foldClaudeBackgroundTasksIntoRoster(
roster,
[
{ id: 'other', agentType: undefined, description: undefined, running: true, teammate: true }
],
200
)
expect(roster.get('a-phantom')).toMatchObject({ state: 'idle' })
expect(roster.get('ateam-xyz')).toMatchObject({ state: 'working' })
})
it('marks fold-recreated entries as task-id-authoritative for later folds', () => {
const roster: ClaudeSubagentRoster = new Map()
foldClaudeBackgroundTasksIntoRoster(
roster,
[{ id: 'a9', agentType: undefined, description: undefined, running: true, teammate: false }],
100
)
foldClaudeBackgroundTasksIntoRoster(
roster,
[
{ id: 'other', agentType: undefined, description: undefined, running: true, teammate: true }
],
200
)
expect(roster.get('a9')).toMatchObject({ state: 'idle' })
})
it('stops demoting an entry once live activity re-tracks it', () => {
const roster: ClaudeSubagentRoster = new Map()
roster.set('a-seeded', { state: 'working', startedAt: 100, backgroundTasksAuthoritative: true })
upsertWorkingClaudeSubagent(roster, 'a-seeded', {}, 150)
foldClaudeBackgroundTasksIntoRoster(
roster,
[
{ id: 'other', agentType: undefined, description: undefined, running: true, teammate: true }
],
200
)
expect(roster.get('a-seeded')).toMatchObject({ state: 'working' })
})
it('matches teammate ids by name only up to the hyphen-free suffix', () => {
expect(claudeTeammateIdMatchesName('aprobe1-6d3cb5b5', 'probe1')).toBe(true)
expect(claudeTeammateIdMatchesName('alane-hooks-6d3cb5b5', 'lane-hooks')).toBe(true)
expect(claudeTeammateIdMatchesName('alane-hooks-6d3cb5b5', 'lane')).toBe(false)
expect(claudeTeammateIdMatchesName('aprobe1-6d3cb5b5', 'probe')).toBe(false)
expect(claudeTeammateIdMatchesName('aprobe1', 'probe1')).toBe(false)
})
it('marks teammates idle by name via agent_type or agent_id prefix', () => {
const roster: ClaudeSubagentRoster = new Map()
upsertWorkingClaudeSubagent(roster, 'aprobe1-6d3cb5b5', { agentType: 'probe1' }, 100)
upsertWorkingClaudeSubagent(roster, 'aother-123', { agentType: 'other' }, 100)
expect(markClaudeTeammateIdleByName(roster, 'probe1')).toBe(true)
expect(roster.get('aprobe1-6d3cb5b5')?.state).toBe('idle')
expect(roster.get('aother-123')?.state).toBe('working')
// Why: repeat idles are no-ops so lifecycle refreshes don't churn state.
expect(markClaudeTeammateIdleByName(roster, 'probe1')).toBe(false)
expect(markClaudeTeammateIdleByName(roster, 'ghost')).toBe(false)
})
it('serializes snapshots deterministically ordered by startedAt then id', () => {
const roster: ClaudeSubagentRoster = new Map()
upsertWorkingClaudeSubagent(roster, 'b', {}, 200)
upsertWorkingClaudeSubagent(roster, 'z', {}, 100)
upsertWorkingClaudeSubagent(roster, 'a', {}, 100)
expect(claudeRosterToSnapshots(roster)?.map((s) => s.id)).toEqual(['a', 'z', 'b'])
expect(claudeRosterToSnapshots(new Map())).toBeUndefined()
})
})

View File

@ -0,0 +1,257 @@
import { AGENT_STATUS_MAX_SUBAGENTS, type AgentSubagentSnapshot } from './agent-status-types'
/** Mirrors the wire-normalization id cap in agent-status-types. Enforced at
* upsert so an over-long id can't gate the pane 'working' while being
* invisible in the emitted snapshots (which drop such ids). */
const CLAUDE_SUBAGENT_ID_MAX_LENGTH = 64
/** Live subagents/teammates tracked for one Claude pane, keyed by the
* provider-assigned `agent_id` from SubagentStart/SubagentStop payloads. */
export type ClaudeSubagentRoster = Map<string, TrackedClaudeSubagent>
export type TrackedClaudeSubagent = {
agentType?: string
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. */
backgroundTasksAuthoritative?: boolean
}
/** One agent entry from the `background_tasks` array Claude attaches to Stop
* (and SubagentStop) hook payloads. Non-agent task types (background shells,
* crons) are filtered out at read time. */
export type ClaudeBackgroundAgentTask = {
id: string
agentType?: string
description?: string
running: boolean
/** True for `type: "teammate"` entries, whose ids never match lifecycle
* agent_ids and whose "running" status persists while idle. */
teammate: boolean
}
export function upsertWorkingClaudeSubagent(
roster: ClaudeSubagentRoster,
id: string,
fields: { agentType?: string; description?: string },
now: number
): void {
if (id.length === 0 || id.length > CLAUDE_SUBAGENT_ID_MAX_LENGTH) {
return
}
const existing = roster.get(id)
if (existing) {
existing.state = 'working'
existing.agentType = fields.agentType ?? existing.agentType
existing.description = fields.description ?? existing.description
// 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.
existing.backgroundTasksAuthoritative = undefined
return
}
if (roster.size >= AGENT_STATUS_MAX_SUBAGENTS && !evictOldestIdleClaudeSubagent(roster)) {
return
}
roster.set(id, {
state: 'working',
startedAt: now,
agentType: fields.agentType,
description: fields.description
})
}
function evictOldestIdleClaudeSubagent(roster: ClaudeSubagentRoster): boolean {
let oldestId: string | null = null
let oldestStartedAt = Infinity
for (const [id, tracked] of roster) {
if (tracked.state === 'idle' && tracked.startedAt < oldestStartedAt) {
oldestId = id
oldestStartedAt = tracked.startedAt
}
}
if (oldestId === null) {
return false
}
roster.delete(oldestId)
return true
}
export function markClaudeSubagentIdle(roster: ClaudeSubagentRoster, id: string): void {
const existing = roster.get(id)
if (existing) {
existing.state = 'idle'
}
}
/** Read the agent-typed entries of a hook payload's `background_tasks` field.
* `present: false` means the field was absent/malformed (older Claude builds),
* so callers must keep their tracked roster instead of clearing it. */
export function readClaudeBackgroundAgentTasks(hookPayload: Record<string, unknown>): {
present: boolean
tasks: ClaudeBackgroundAgentTask[]
} {
const raw = hookPayload['background_tasks']
if (!Array.isArray(raw)) {
return { present: false, tasks: [] }
}
const tasks: ClaudeBackgroundAgentTask[] = []
for (const item of raw) {
if (typeof item !== 'object' || item === null) {
continue
}
const obj = item as Record<string, unknown>
if (obj.type !== 'subagent' && obj.type !== 'teammate') {
continue
}
if (typeof obj.id !== 'string' || obj.id.trim().length === 0) {
continue
}
tasks.push({
id: obj.id,
agentType: typeof obj.agent_type === 'string' ? obj.agent_type : undefined,
description: typeof obj.description === 'string' ? obj.description : undefined,
running: obj.status === 'running',
teammate: obj.type === 'teammate'
})
if (tasks.length >= AGENT_STATUS_MAX_SUBAGENTS) {
break
}
}
return { present: true, tasks }
}
/** 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:
* - 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 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. */
export function foldClaudeBackgroundTasksIntoRoster(
roster: ClaudeSubagentRoster,
tasks: ClaudeBackgroundAgentTask[],
now: number
): void {
if (tasks.length === 0) {
roster.clear()
return
}
const listedIds = new Set<string>()
for (const task of tasks) {
listedIds.add(task.id)
const existing = roster.get(task.id)
if (existing) {
existing.state = task.running ? 'working' : 'idle'
existing.agentType = task.agentType ?? existing.agentType
existing.description = task.description ?? existing.description
continue
}
if (task.teammate || !task.running) {
continue
}
upsertWorkingClaudeSubagent(
roster,
task.id,
{ agentType: task.agentType, description: task.description },
now
)
const created = roster.get(task.id)
if (created) {
created.backgroundTasksAuthoritative = true
}
}
for (const [id, tracked] of roster) {
if (tracked.backgroundTasksAuthoritative && tracked.state === 'working' && !listedIds.has(id)) {
tracked.state = 'idle'
}
}
}
/** Whether a lifecycle agent id belongs to the named teammate. Teammate ids
* embed the name as `a<name>-<hex>`; requiring a hyphen-free suffix keeps
* teammate "rev" from matching "rev-two"'s ids (`arev-two-<hex>`), while a
* hyphenated name still matches its own ids exactly. */
export function claudeTeammateIdMatchesName(id: string, name: string): boolean {
const prefix = `a${name}-`
return id.startsWith(prefix) && !id.slice(prefix.length).includes('-')
}
/** Mark a teammate idle from a TeammateIdle hook, which is keyed by name.
* Named teammates embed their name in `agent_id` (`a<name>-<hex>`); prefer
* that exact signal. Fall back to `agent_type === name` only when no id
* matches, so a one-shot subagent whose agent_type happens to collide with a
* teammate's name isn't wrongly idled alongside it. */
export function markClaudeTeammateIdleByName(roster: ClaudeSubagentRoster, name: string): boolean {
let matchedById = false
let changed = false
for (const [id, tracked] of roster) {
if (!claudeTeammateIdMatchesName(id, name)) {
continue
}
matchedById = true
if (tracked.state !== 'idle') {
tracked.state = 'idle'
changed = true
}
}
if (matchedById) {
return changed
}
for (const tracked of roster.values()) {
if (tracked.agentType === name && tracked.state !== 'idle') {
tracked.state = 'idle'
changed = true
}
}
return changed
}
export function claudeRosterHasWorkingSubagent(roster: ClaudeSubagentRoster | undefined): boolean {
if (!roster) {
return false
}
for (const tracked of roster.values()) {
if (tracked.state === 'working') {
return true
}
}
return false
}
export function claudeRosterToSnapshots(
roster: ClaudeSubagentRoster | undefined
): AgentSubagentSnapshot[] | undefined {
if (!roster || roster.size === 0) {
return undefined
}
const snapshots: AgentSubagentSnapshot[] = []
for (const [id, tracked] of roster) {
snapshots.push({
id,
state: tracked.state,
startedAt: tracked.startedAt,
agentType: tracked.agentType,
description: tracked.description
})
}
// Why: hook arrival order is not stable across reconciles; sort so equal
// rosters serialize identically and downstream equality checks can dedupe.
snapshots.sort((a, b) => a.startedAt - b.startedAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
return snapshots
}