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:
parent
2e48495273
commit
31cff75a7b
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -255,6 +255,9 @@ describe('ClaudeHookService.installRemote', () => {
|
|||
'UserPromptSubmit',
|
||||
'Stop',
|
||||
'StopFailure',
|
||||
'SubagentStart',
|
||||
'SubagentStop',
|
||||
'TeammateIdle',
|
||||
'PreToolUse',
|
||||
'PostToolUse',
|
||||
'PostToolUseFailure',
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
Loading…
Reference in New Issue