Merge pull request #14045 from NousResearch/bb/subagent-observability

feat(tui): subagent spawn observability overlay
This commit is contained in:
brooklyn! 2026-04-22 12:21:25 -05:00 committed by GitHub
commit bc5da42b2c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 4550 additions and 473 deletions

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,410 @@
import { describe, expect, it } from 'vitest'
import {
buildSubagentTree,
descendantIds,
flattenTree,
fmtCost,
fmtDuration,
fmtTokens,
formatSummary,
hotnessBucket,
peakHotness,
sparkline,
topLevelSubagents,
treeTotals,
widthByDepth
} from '../lib/subagentTree.js'
import type { SubagentProgress } from '../types.js'
const makeItem = (overrides: Partial<SubagentProgress> & Pick<SubagentProgress, 'id' | 'index'>): SubagentProgress => ({
depth: 0,
goal: overrides.id,
notes: [],
parentId: null,
status: 'running',
taskCount: 1,
thinking: [],
toolCount: 0,
tools: [],
...overrides
})
describe('aggregate: tokens, cost, files, hotness', () => {
it('sums tokens and cost across subtree', () => {
const items = [
makeItem({ costUsd: 0.01, id: 'p', index: 0, inputTokens: 1000, outputTokens: 500 }),
makeItem({
costUsd: 0.005,
depth: 1,
id: 'c1',
index: 0,
inputTokens: 500,
outputTokens: 100,
parentId: 'p'
}),
makeItem({
costUsd: 0.008,
depth: 1,
id: 'c2',
index: 1,
inputTokens: 300,
outputTokens: 200,
parentId: 'p'
})
]
const tree = buildSubagentTree(items)
expect(tree[0]!.aggregate).toMatchObject({
costUsd: 0.023,
inputTokens: 1800,
outputTokens: 800
})
})
it('counts files read + written across subtree', () => {
const items = [
makeItem({ filesRead: ['a.ts', 'b.ts'], id: 'p', index: 0 }),
makeItem({ depth: 1, filesWritten: ['c.ts'], id: 'c', index: 0, parentId: 'p' })
]
const tree = buildSubagentTree(items)
expect(tree[0]!.aggregate.filesTouched).toBe(3)
})
it('hotness = totalTools / totalDuration', () => {
const items = [
makeItem({
durationSeconds: 10,
id: 'p',
index: 0,
status: 'completed',
toolCount: 20
})
]
const tree = buildSubagentTree(items)
expect(tree[0]!.aggregate.hotness).toBeCloseTo(2)
})
it('hotness is zero when duration is zero', () => {
const items = [makeItem({ id: 'p', index: 0, toolCount: 10 })]
const tree = buildSubagentTree(items)
expect(tree[0]!.aggregate.hotness).toBe(0)
})
})
describe('hotnessBucket + peakHotness', () => {
it('peakHotness walks subtree', () => {
const items = [
makeItem({ durationSeconds: 100, id: 'p', index: 0, status: 'completed', toolCount: 1 }),
makeItem({
depth: 1,
durationSeconds: 1,
id: 'c',
index: 0,
parentId: 'p',
status: 'completed',
toolCount: 5
})
]
const tree = buildSubagentTree(items)
expect(peakHotness(tree)).toBeGreaterThan(2)
})
it('hotnessBucket clamps and normalizes', () => {
expect(hotnessBucket(0, 10, 4)).toBe(0)
expect(hotnessBucket(10, 10, 4)).toBe(3)
expect(hotnessBucket(5, 10, 4)).toBe(2)
expect(hotnessBucket(100, 10, 4)).toBe(3) // clamped
expect(hotnessBucket(5, 0, 4)).toBe(0) // guard against divide-by-zero
})
})
describe('fmtCost + fmtTokens', () => {
it('fmtCost handles ranges', () => {
expect(fmtCost(0)).toBe('')
expect(fmtCost(0.001)).toBe('<$0.01')
expect(fmtCost(0.42)).toBe('$0.42')
expect(fmtCost(1.23)).toBe('$1.23')
expect(fmtCost(12.5)).toBe('$12.5')
})
it('fmtTokens handles ranges', () => {
expect(fmtTokens(0)).toBe('0')
expect(fmtTokens(542)).toBe('542')
expect(fmtTokens(1234)).toBe('1.2k')
expect(fmtTokens(45678)).toBe('46k')
})
})
describe('formatSummary with tokens + cost', () => {
it('includes token + cost when present', () => {
expect(
formatSummary({
activeCount: 0,
costUsd: 0.42,
descendantCount: 3,
filesTouched: 0,
hotness: 0,
inputTokens: 8000,
maxDepthFromHere: 2,
outputTokens: 2000,
totalDuration: 30,
totalTools: 14
})
).toBe('d2 · 3 agents · 14 tools · 30s · 10k tok · $0.42')
})
})
describe('buildSubagentTree', () => {
it('returns empty list for empty input', () => {
expect(buildSubagentTree([])).toEqual([])
})
it('treats flat list as top-level when no parentId is given', () => {
const items = [makeItem({ id: 'a', index: 0 }), makeItem({ id: 'b', index: 1 }), makeItem({ id: 'c', index: 2 })]
const tree = buildSubagentTree(items)
expect(tree).toHaveLength(3)
expect(tree.map(n => n.item.id)).toEqual(['a', 'b', 'c'])
expect(tree.every(n => n.children.length === 0)).toBe(true)
})
it('nests children under their parent by subagent_id', () => {
const items = [
makeItem({ id: 'parent', index: 0 }),
makeItem({ depth: 1, id: 'child-1', index: 0, parentId: 'parent' }),
makeItem({ depth: 1, id: 'child-2', index: 1, parentId: 'parent' })
]
const tree = buildSubagentTree(items)
expect(tree).toHaveLength(1)
expect(tree[0]!.children).toHaveLength(2)
expect(tree[0]!.children.map(n => n.item.id)).toEqual(['child-1', 'child-2'])
})
it('builds multi-level nesting', () => {
const items = [
makeItem({ id: 'p', index: 0 }),
makeItem({ depth: 1, id: 'c', index: 0, parentId: 'p' }),
makeItem({ depth: 2, id: 'gc', index: 0, parentId: 'c' })
]
const tree = buildSubagentTree(items)
expect(tree[0]!.children[0]!.children[0]!.item.id).toBe('gc')
expect(tree[0]!.aggregate.maxDepthFromHere).toBe(2)
expect(tree[0]!.aggregate.descendantCount).toBe(2)
})
it('promotes orphaned children (missing parent) to top level', () => {
const items = [makeItem({ id: 'a', index: 0 }), makeItem({ depth: 1, id: 'orphan', index: 1, parentId: 'ghost' })]
const tree = buildSubagentTree(items)
expect(tree).toHaveLength(2)
expect(tree.map(n => n.item.id)).toEqual(['a', 'orphan'])
})
it('stable sort: children ordered by (depth, index) not insert order', () => {
const items = [
makeItem({ id: 'p', index: 0 }),
makeItem({ depth: 1, id: 'c3', index: 2, parentId: 'p' }),
makeItem({ depth: 1, id: 'c1', index: 0, parentId: 'p' }),
makeItem({ depth: 1, id: 'c2', index: 1, parentId: 'p' })
]
const tree = buildSubagentTree(items)
expect(tree[0]!.children.map(n => n.item.id)).toEqual(['c1', 'c2', 'c3'])
})
})
describe('aggregate', () => {
it('sums tool counts and durations across subtree', () => {
const items = [
makeItem({ durationSeconds: 10, id: 'p', index: 0, status: 'completed', toolCount: 5 }),
makeItem({ depth: 1, durationSeconds: 4, id: 'c1', index: 0, parentId: 'p', status: 'completed', toolCount: 3 }),
makeItem({ depth: 1, durationSeconds: 2, id: 'c2', index: 1, parentId: 'p', status: 'completed', toolCount: 1 })
]
const tree = buildSubagentTree(items)
expect(tree[0]!.aggregate).toMatchObject({
activeCount: 0,
descendantCount: 2,
totalDuration: 16,
totalTools: 9
})
})
it('counts queued + running as active', () => {
const items = [
makeItem({ id: 'p', index: 0, status: 'running' }),
makeItem({ depth: 1, id: 'c1', index: 0, parentId: 'p', status: 'queued' }),
makeItem({ depth: 1, id: 'c2', index: 1, parentId: 'p', status: 'completed' })
]
const tree = buildSubagentTree(items)
expect(tree[0]!.aggregate.activeCount).toBe(2)
})
})
describe('widthByDepth', () => {
it('returns empty array for empty tree', () => {
expect(widthByDepth([])).toEqual([])
})
it('tallies nodes at each depth', () => {
const items = [
makeItem({ id: 'p1', index: 0 }),
makeItem({ id: 'p2', index: 1 }),
makeItem({ depth: 1, id: 'c1', index: 0, parentId: 'p1' }),
makeItem({ depth: 1, id: 'c2', index: 1, parentId: 'p1' }),
makeItem({ depth: 1, id: 'c3', index: 0, parentId: 'p2' }),
makeItem({ depth: 2, id: 'gc1', index: 0, parentId: 'c1' })
]
expect(widthByDepth(buildSubagentTree(items))).toEqual([2, 3, 1])
})
})
describe('treeTotals', () => {
it('folds a full tree into a single rollup', () => {
const items = [
makeItem({ id: 'p1', index: 0, toolCount: 5 }),
makeItem({ id: 'p2', index: 1, toolCount: 2 }),
makeItem({ depth: 1, id: 'c', index: 0, parentId: 'p1', toolCount: 3 })
]
const totals = treeTotals(buildSubagentTree(items))
expect(totals.descendantCount).toBe(3)
expect(totals.totalTools).toBe(10)
expect(totals.maxDepthFromHere).toBe(2)
})
it('returns zeros for empty tree', () => {
expect(treeTotals([])).toEqual({
activeCount: 0,
costUsd: 0,
descendantCount: 0,
filesTouched: 0,
hotness: 0,
inputTokens: 0,
maxDepthFromHere: 0,
outputTokens: 0,
totalDuration: 0,
totalTools: 0
})
})
})
describe('flattenTree + descendantIds', () => {
const items = [
makeItem({ id: 'p', index: 0 }),
makeItem({ depth: 1, id: 'c1', index: 0, parentId: 'p' }),
makeItem({ depth: 2, id: 'gc', index: 0, parentId: 'c1' }),
makeItem({ depth: 1, id: 'c2', index: 1, parentId: 'p' })
]
it('flattens in visit order (depth-first, pre-order)', () => {
const tree = buildSubagentTree(items)
expect(flattenTree(tree).map(n => n.item.id)).toEqual(['p', 'c1', 'gc', 'c2'])
})
it('collects descendant ids excluding the node itself', () => {
const tree = buildSubagentTree(items)
expect(descendantIds(tree[0]!)).toEqual(['c1', 'gc', 'c2'])
})
})
describe('sparkline', () => {
it('returns empty string for empty input', () => {
expect(sparkline([])).toBe('')
})
it('renders zeroes as spaces (not bottom glyph)', () => {
expect(sparkline([0, 0])).toBe(' ')
})
it('scales to the max value', () => {
const out = sparkline([1, 8])
expect(out).toHaveLength(2)
expect(out[1]).toBe('█')
})
it('sparse widths render as expected', () => {
const out = sparkline([2, 3, 7, 4])
expect(out).toHaveLength(4)
expect([...out].every(ch => /[\s▁-█]/.test(ch))).toBe(true)
})
})
describe('formatSummary', () => {
const emptyTotals = {
activeCount: 0,
costUsd: 0,
descendantCount: 0,
filesTouched: 0,
hotness: 0,
inputTokens: 0,
maxDepthFromHere: 0,
outputTokens: 0,
totalDuration: 0,
totalTools: 0
}
it('collapses zero-valued components', () => {
expect(formatSummary({ ...emptyTotals, descendantCount: 1 })).toBe('d0 · 1 agent')
})
it('emits rich summary with all pieces', () => {
expect(
formatSummary({
...emptyTotals,
activeCount: 2,
descendantCount: 7,
maxDepthFromHere: 3,
totalDuration: 134,
totalTools: 124
})
).toBe('d3 · 7 agents · 124 tools · 2m 14s · ⚡2')
})
})
describe('fmtDuration', () => {
it('formats under a minute as plain seconds', () => {
expect(fmtDuration(0)).toBe('0s')
expect(fmtDuration(42)).toBe('42s')
expect(fmtDuration(59.4)).toBe('59s')
})
it('formats whole minutes without trailing seconds', () => {
expect(fmtDuration(60)).toBe('1m')
expect(fmtDuration(180)).toBe('3m')
})
it('mixes minutes and seconds', () => {
expect(fmtDuration(134)).toBe('2m 14s')
expect(fmtDuration(605)).toBe('10m 5s')
})
})
describe('topLevelSubagents', () => {
it('returns items with no parent', () => {
const items = [makeItem({ id: 'a', index: 0 }), makeItem({ id: 'b', index: 1 })]
expect(topLevelSubagents(items).map(s => s.id)).toEqual(['a', 'b'])
})
it('excludes children whose parent is present', () => {
const items = [
makeItem({ id: 'p', index: 0 }),
makeItem({ depth: 1, id: 'c', index: 0, parentId: 'p' })
]
expect(topLevelSubagents(items).map(s => s.id)).toEqual(['p'])
})
it('promotes orphans whose parent is missing', () => {
const items = [makeItem({ id: 'a', index: 0 }), makeItem({ depth: 1, id: 'orphan', index: 1, parentId: 'ghost' })]
expect(topLevelSubagents(items).map(s => s.id)).toEqual(['a', 'orphan'])
})
})

View file

@ -1,11 +1,13 @@
import { STREAM_BATCH_MS } from '../config/timing.js' import { STREAM_BATCH_MS } from '../config/timing.js'
import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js' import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js'
import type { CommandsCatalogResponse, GatewayEvent, GatewaySkin } from '../gatewayTypes.js' import type { CommandsCatalogResponse, DelegationStatusResponse, GatewayEvent, GatewaySkin } from '../gatewayTypes.js'
import { rpcErrorMessage } from '../lib/rpc.js' import { rpcErrorMessage } from '../lib/rpc.js'
import { topLevelSubagents } from '../lib/subagentTree.js'
import { formatToolCall, stripAnsi } from '../lib/text.js' import { formatToolCall, stripAnsi } from '../lib/text.js'
import { fromSkin } from '../theme.js' import { fromSkin } from '../theme.js'
import type { Msg, SubagentProgress } from '../types.js' import type { Msg, SubagentProgress } from '../types.js'
import { applyDelegationStatus, getDelegationState } from './delegationStore.js'
import type { GatewayEventHandlerContext } from './interfaces.js' import type { GatewayEventHandlerContext } from './interfaces.js'
import { patchOverlayState } from './overlayStore.js' import { patchOverlayState } from './overlayStore.js'
import { turnController } from './turnController.js' import { turnController } from './turnController.js'
@ -53,6 +55,55 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
let pendingThinkingStatus = '' let pendingThinkingStatus = ''
let thinkingStatusTimer: null | ReturnType<typeof setTimeout> = null let thinkingStatusTimer: null | ReturnType<typeof setTimeout> = null
// Inject the disk-save callback into turnController so recordMessageComplete
// can fire-and-forget a persist without having to plumb a gateway ref around.
turnController.persistSpawnTree = async (subagents, sessionId) => {
try {
const startedAt = subagents.reduce<number>((min, s) => {
if (!s.startedAt) {
return min
}
return min === 0 ? s.startedAt : Math.min(min, s.startedAt)
}, 0)
const top = topLevelSubagents(subagents)
.map(s => s.goal)
.filter(Boolean)
.slice(0, 2)
const label = top.length ? top.join(' · ') : `${subagents.length} subagents`
await rpc('spawn_tree.save', {
finished_at: Date.now() / 1000,
label: label.slice(0, 120),
session_id: sessionId ?? 'default',
started_at: startedAt ? startedAt / 1000 : null,
subagents
})
} catch {
// Persistence is best-effort; in-memory history is the authoritative
// same-session source. A write failure doesn't block the turn.
}
}
// Refresh delegation caps at most every 5s so the status bar HUD can
// render a /warning close to the configured cap without spamming the RPC.
let lastDelegationFetchAt = 0
const refreshDelegationStatus = (force = false) => {
const now = Date.now()
if (!force && now - lastDelegationFetchAt < 5000) {
return
}
lastDelegationFetchAt = now
rpc<DelegationStatusResponse>('delegation.status', {})
.then(r => applyDelegationStatus(r))
.catch(() => {})
}
const setStatus = (status: string) => { const setStatus = (status: string) => {
pendingThinkingStatus = '' pendingThinkingStatus = ''
@ -85,7 +136,12 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
}, ms) }, ms)
} }
const keepCompletedElseRunning = (s: SubagentProgress['status']) => (s === 'completed' ? s : 'running') // Terminal statuses are never overwritten by late-arriving live events —
// otherwise a stale `subagent.start` / `spawn_requested` can clobber a
// `failed` or `interrupted` terminal state (Copilot review #14045).
const isTerminalStatus = (s: SubagentProgress['status']) => s === 'completed' || s === 'failed' || s === 'interrupted'
const keepTerminalElseRunning = (s: SubagentProgress['status']) => (isTerminalStatus(s) ? s : 'running')
const handleReady = (skin?: GatewaySkin) => { const handleReady = (skin?: GatewaySkin) => {
if (skin) { if (skin) {
@ -260,32 +316,28 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
turnController.recordToolStart(ev.payload.tool_id, ev.payload.name ?? 'tool', ev.payload.context ?? '') turnController.recordToolStart(ev.payload.tool_id, ev.payload.name ?? 'tool', ev.payload.context ?? '')
return return
case 'tool.complete': {
const inlineDiffText =
ev.payload.inline_diff && getUiState().inlineDiffs ? stripAnsi(String(ev.payload.inline_diff)).trim() : ''
case 'tool.complete': turnController.recordToolComplete(
{ ev.payload.tool_id,
const inlineDiffText = ev.payload.name,
ev.payload.inline_diff && getUiState().inlineDiffs ? stripAnsi(String(ev.payload.inline_diff)).trim() : '' ev.payload.error,
inlineDiffText ? '' : ev.payload.summary
turnController.recordToolComplete( )
ev.payload.tool_id,
ev.payload.name,
ev.payload.error,
inlineDiffText ? '' : ev.payload.summary
)
if (!inlineDiffText) {
return
}
// Keep inline diffs attached to the assistant completion body so
// they render in the same message flow, not as a standalone system
// artifact that can look out-of-place around tool rows.
turnController.queueInlineDiff(inlineDiffText)
if (!inlineDiffText) {
return return
} }
// Keep inline diffs attached to the assistant completion body so
// they render in the same message flow, not as a standalone system
// artifact that can look out-of-place around tool rows.
turnController.queueInlineDiff(inlineDiffText)
return return
}
case 'clarify.request': case 'clarify.request':
patchOverlayState({ patchOverlayState({
@ -329,8 +381,23 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
return return
case 'subagent.spawn_requested':
// Child built but not yet running (waiting on ThreadPoolExecutor slot).
// Preserve completed state if a later event races in before this one.
turnController.upsertSubagent(ev.payload, c => (isTerminalStatus(c.status) ? {} : { status: 'queued' }))
// Prime the status-bar HUD: fetch caps (once every 5s) so we can
// warn as depth/concurrency approaches the configured ceiling.
if (getDelegationState().maxSpawnDepth === null) {
refreshDelegationStatus(true)
} else {
refreshDelegationStatus()
}
return
case 'subagent.start': case 'subagent.start':
turnController.upsertSubagent(ev.payload, () => ({ status: 'running' })) turnController.upsertSubagent(ev.payload, c => (isTerminalStatus(c.status) ? {} : { status: 'running' }))
return return
case 'subagent.thinking': { case 'subagent.thinking': {
@ -340,10 +407,16 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
return return
} }
turnController.upsertSubagent(ev.payload, c => ({ // Update-only: never resurrect subagents whose spawn_requested/start
status: keepCompletedElseRunning(c.status), // we missed or that already flushed via message.complete.
thinking: pushThinking(c.thinking, text) turnController.upsertSubagent(
})) ev.payload,
c => ({
status: keepTerminalElseRunning(c.status),
thinking: pushThinking(c.thinking, text)
}),
{ createIfMissing: false }
)
return return
} }
@ -354,10 +427,14 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
ev.payload.tool_preview ?? ev.payload.text ?? '' ev.payload.tool_preview ?? ev.payload.text ?? ''
) )
turnController.upsertSubagent(ev.payload, c => ({ turnController.upsertSubagent(
status: keepCompletedElseRunning(c.status), ev.payload,
tools: pushTool(c.tools, line) c => ({
})) status: keepTerminalElseRunning(c.status),
tools: pushTool(c.tools, line)
}),
{ createIfMissing: false }
)
return return
} }
@ -369,20 +446,28 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
return return
} }
turnController.upsertSubagent(ev.payload, c => ({ turnController.upsertSubagent(
notes: pushNote(c.notes, text), ev.payload,
status: keepCompletedElseRunning(c.status) c => ({
})) notes: pushNote(c.notes, text),
status: keepTerminalElseRunning(c.status)
}),
{ createIfMissing: false }
)
return return
} }
case 'subagent.complete': case 'subagent.complete':
turnController.upsertSubagent(ev.payload, c => ({ turnController.upsertSubagent(
durationSeconds: ev.payload.duration_seconds ?? c.durationSeconds, ev.payload,
status: ev.payload.status ?? 'completed', c => ({
summary: ev.payload.summary || ev.payload.text || c.summary durationSeconds: ev.payload.duration_seconds ?? c.durationSeconds,
})) status: ev.payload.status ?? 'completed',
summary: ev.payload.summary || ev.payload.text || c.summary
}),
{ createIfMissing: false }
)
return return

View file

@ -0,0 +1,77 @@
import { atom } from 'nanostores'
import type { DelegationStatusResponse } from '../gatewayTypes.js'
export interface DelegationState {
// Last known caps from `delegation.status` RPC. null until fetched.
maxConcurrentChildren: null | number
maxSpawnDepth: null | number
// True when spawning is globally paused (see tools/delegate_tool.py).
paused: boolean
// Monotonic clock of the last successful status fetch.
updatedAt: null | number
}
const buildState = (): DelegationState => ({
maxConcurrentChildren: null,
maxSpawnDepth: null,
paused: false,
updatedAt: null
})
export const $delegationState = atom<DelegationState>(buildState())
export const getDelegationState = () => $delegationState.get()
export const patchDelegationState = (next: Partial<DelegationState>) =>
$delegationState.set({ ...$delegationState.get(), ...next })
export const resetDelegationState = () => $delegationState.set(buildState())
// ── Overlay accordion open-state ──────────────────────────────────────
//
// Lifted out of OverlaySection's local useState so collapse choices
// survive:
// - navigating to a different subagent (Detail remounts)
// - switching list ↔ detail mode (Detail unmounts in list mode)
// - walking history (←/→)
// Keyed by section title; missing entries fall back to the section's
// `defaultOpen` prop.
export const $overlaySectionsOpen = atom<Record<string, boolean>>({})
export const toggleOverlaySection = (title: string, defaultOpen: boolean) => {
const state = $overlaySectionsOpen.get()
const current = title in state ? state[title]! : defaultOpen
$overlaySectionsOpen.set({ ...state, [title]: !current })
}
export const getOverlaySectionOpen = (title: string, defaultOpen: boolean): boolean => {
const state = $overlaySectionsOpen.get()
return title in state ? state[title]! : defaultOpen
}
/** Merge a raw RPC response into the store. Tolerant of partial/omitted fields. */
export const applyDelegationStatus = (r: DelegationStatusResponse | null | undefined) => {
if (!r) {
return
}
const patch: Partial<DelegationState> = { updatedAt: Date.now() }
if (typeof r.max_spawn_depth === 'number') {
patch.maxSpawnDepth = r.max_spawn_depth
}
if (typeof r.max_concurrent_children === 'number') {
patch.maxConcurrentChildren = r.max_concurrent_children
}
if (typeof r.paused === 'boolean') {
patch.paused = r.paused
}
patchDelegationState(patch)
}

View file

@ -53,6 +53,8 @@ export interface GatewayProviderProps {
} }
export interface OverlayState { export interface OverlayState {
agents: boolean
agentsInitialHistoryIndex: number
approval: ApprovalReq | null approval: ApprovalReq | null
clarify: ClarifyReq | null clarify: ClarifyReq | null
confirm: ConfirmReq | null confirm: ConfirmReq | null

View file

@ -3,6 +3,8 @@ import { atom, computed } from 'nanostores'
import type { OverlayState } from './interfaces.js' import type { OverlayState } from './interfaces.js'
const buildOverlayState = (): OverlayState => ({ const buildOverlayState = (): OverlayState => ({
agents: false,
agentsInitialHistoryIndex: 0,
approval: null, approval: null,
clarify: null, clarify: null,
confirm: null, confirm: null,
@ -18,8 +20,8 @@ export const $overlayState = atom<OverlayState>(buildOverlayState())
export const $isBlocked = computed( export const $isBlocked = computed(
$overlayState, $overlayState,
({ approval, clarify, confirm, modelPicker, pager, picker, secret, skillsHub, sudo }) => ({ agents, approval, clarify, confirm, modelPicker, pager, picker, secret, skillsHub, sudo }) =>
Boolean(approval || clarify || confirm || modelPicker || pager || picker || secret || skillsHub || sudo) Boolean(agents || approval || clarify || confirm || modelPicker || pager || picker || secret || skillsHub || sudo)
) )
export const getOverlayState = () => $overlayState.get() export const getOverlayState = () => $overlayState.get()
@ -27,4 +29,23 @@ export const getOverlayState = () => $overlayState.get()
export const patchOverlayState = (next: Partial<OverlayState> | ((state: OverlayState) => OverlayState)) => export const patchOverlayState = (next: Partial<OverlayState> | ((state: OverlayState) => OverlayState)) =>
$overlayState.set(typeof next === 'function' ? next($overlayState.get()) : { ...$overlayState.get(), ...next }) $overlayState.set(typeof next === 'function' ? next($overlayState.get()) : { ...$overlayState.get(), ...next })
/** Full reset — used by session/turn teardown and tests. */
export const resetOverlayState = () => $overlayState.set(buildOverlayState()) export const resetOverlayState = () => $overlayState.set(buildOverlayState())
/**
* Soft reset: drop FLOW-scoped overlays (approval / clarify / confirm / sudo
* / secret / pager) but PRESERVE user-toggled ones agents dashboard, model
* picker, skills hub, session picker. Those are opened deliberately and
* shouldn't vanish when a turn ends. Called from turnController.idle() on
* every turn completion / interrupt; the old "reset everything" behaviour
* silently closed /agents the moment delegation finished.
*/
export const resetFlowOverlays = () =>
$overlayState.set({
...buildOverlayState(),
agents: $overlayState.get().agents,
agentsInitialHistoryIndex: $overlayState.get().agentsInitialHistoryIndex,
modelPicker: $overlayState.get().modelPicker,
picker: $overlayState.get().picker,
skillsHub: $overlayState.get().skillsHub
})

View file

@ -1,6 +1,14 @@
import type { SlashExecResponse, ToolsConfigureResponse } from '../../../gatewayTypes.js' import type {
DelegationPauseResponse,
SlashExecResponse,
SpawnTreeListResponse,
SpawnTreeLoadResponse,
ToolsConfigureResponse
} from '../../../gatewayTypes.js'
import type { PanelSection } from '../../../types.js' import type { PanelSection } from '../../../types.js'
import { applyDelegationStatus, getDelegationState } from '../../delegationStore.js'
import { patchOverlayState } from '../../overlayStore.js' import { patchOverlayState } from '../../overlayStore.js'
import { getSpawnHistory, pushDiskSnapshot, setDiffPair, type SpawnSnapshot } from '../../spawnHistoryStore.js'
import type { SlashCommand } from '../types.js' import type { SlashCommand } from '../types.js'
interface SkillInfo { interface SkillInfo {
@ -42,6 +50,163 @@ interface SkillsBrowseResponse {
} }
export const opsCommands: SlashCommand[] = [ export const opsCommands: SlashCommand[] = [
{
aliases: ['tasks'],
help: 'open the spawn-tree dashboard (live audit + kill/pause controls)',
name: 'agents',
run: (arg, ctx) => {
const sub = arg.trim().toLowerCase()
// Stay compatible with the gateway `/agents [pause|resume|status]` CLI —
// explicit subcommands skip the overlay and act directly so scripts and
// multi-step flows can drive it without entering interactive mode.
if (sub === 'pause' || sub === 'resume' || sub === 'unpause') {
const paused = sub === 'pause'
ctx.gateway.gw
.request<DelegationPauseResponse>('delegation.pause', { paused })
.then(r => {
applyDelegationStatus({ paused: r?.paused })
ctx.transcript.sys(`delegation · ${r?.paused ? 'paused' : 'resumed'}`)
})
.catch(ctx.guardedErr)
return
}
if (sub === 'status') {
const d = getDelegationState()
ctx.transcript.sys(
`delegation · ${d.paused ? 'paused' : 'active'} · caps d${d.maxSpawnDepth ?? '?'}/${d.maxConcurrentChildren ?? '?'}`
)
return
}
patchOverlayState({ agents: true, agentsInitialHistoryIndex: 0 })
}
},
{
help: 'replay a completed spawn tree · `/replay [N|last|list|load <path>]`',
name: 'replay',
run: (arg, ctx) => {
const history = getSpawnHistory()
const raw = arg.trim()
const lower = raw.toLowerCase()
// ── Disk-backed listing ─────────────────────────────────────
if (lower === 'list' || lower === 'ls') {
ctx.gateway
.rpc<SpawnTreeListResponse>('spawn_tree.list', {
limit: 30,
session_id: ctx.sid ?? 'default'
})
.then(
ctx.guarded<SpawnTreeListResponse>(r => {
const entries = r.entries ?? []
if (!entries.length) {
return ctx.transcript.sys('no archived spawn trees on disk for this session')
}
const rows: [string, string][] = entries.map(e => {
const ts = e.finished_at ? new Date(e.finished_at * 1000).toLocaleString() : '?'
const label = e.label || `${e.count} subagents`
return [`${ts} · ${e.count}×`, `${label}\n ${e.path}`]
})
ctx.transcript.panel('Archived spawn trees', [{ rows }])
})
)
.catch(ctx.guardedErr)
return
}
// ── Disk-backed load by path ─────────────────────────────────
if (lower.startsWith('load ')) {
const path = raw.slice(5).trim()
if (!path) {
return ctx.transcript.sys('usage: /replay load <path>')
}
ctx.gateway
.rpc<SpawnTreeLoadResponse>('spawn_tree.load', { path })
.then(
ctx.guarded<SpawnTreeLoadResponse>(r => {
if (!r.subagents?.length) {
return ctx.transcript.sys('snapshot empty or unreadable')
}
// Push onto the in-memory history so the overlay picks it up
// by index 1 just like any other snapshot.
pushDiskSnapshot(r, path)
patchOverlayState({ agents: true, agentsInitialHistoryIndex: 1 })
})
)
.catch(ctx.guardedErr)
return
}
// ── In-memory nav (same-session) ─────────────────────────────
if (!history.length) {
return ctx.transcript.sys('no completed spawn trees this session · try /replay list')
}
let index = 1
if (raw && lower !== 'last') {
const parsed = parseInt(raw, 10)
if (Number.isNaN(parsed) || parsed < 1 || parsed > history.length) {
return ctx.transcript.sys(`replay: index out of range 1..${history.length} · use /replay list for disk`)
}
index = parsed
}
patchOverlayState({ agents: true, agentsInitialHistoryIndex: index })
}
},
{
help: 'diff two completed spawn trees · `/replay-diff <baseline> <candidate>` (indexes from /replay list or history N)',
name: 'replay-diff',
run: (arg, ctx) => {
const parts = arg.trim().split(/\s+/).filter(Boolean)
if (parts.length !== 2) {
return ctx.transcript.sys('usage: /replay-diff <a> <b> (e.g. /replay-diff 1 2 for last two)')
}
const [a, b] = parts
const history = getSpawnHistory()
const resolve = (token: string): null | SpawnSnapshot => {
const n = parseInt(token!, 10)
if (Number.isFinite(n) && n >= 1 && n <= history.length) {
return history[n - 1] ?? null
}
return null
}
const baseline = resolve(a!)
const candidate = resolve(b!)
if (!baseline || !candidate) {
return ctx.transcript.sys(`replay-diff: could not resolve indices · history has ${history.length} entries`)
}
setDiffPair({ baseline, candidate })
patchOverlayState({ agents: true, agentsInitialHistoryIndex: 0 })
}
},
{ {
help: 'browse, inspect, install skills', help: 'browse, inspect, install skills',
name: 'skills', name: 'skills',

View file

@ -0,0 +1,139 @@
import { atom } from 'nanostores'
import type { SpawnTreeLoadResponse } from '../gatewayTypes.js'
import type { SubagentProgress } from '../types.js'
export interface SpawnSnapshot {
finishedAt: number
fromDisk?: boolean
id: string
label: string
path?: string
sessionId: null | string
startedAt: number
subagents: SubagentProgress[]
}
export interface SpawnDiffPair {
baseline: SpawnSnapshot
candidate: SpawnSnapshot
}
const HISTORY_LIMIT = 10
export const $spawnHistory = atom<SpawnSnapshot[]>([])
export const $spawnDiff = atom<null | SpawnDiffPair>(null)
export const getSpawnHistory = () => $spawnHistory.get()
export const getSpawnDiff = () => $spawnDiff.get()
export const clearSpawnHistory = () => $spawnHistory.set([])
export const clearDiffPair = () => $spawnDiff.set(null)
export const setDiffPair = (pair: SpawnDiffPair) => $spawnDiff.set(pair)
/**
* Commit a finished turn's spawn tree to history. Keeps the last 10
* non-empty snapshots empty turns (no subagents) are dropped.
*
* Why in-memory? The primary investigation loop is "I just ran a fan-out,
* it misbehaved, let me look at what happened" same-session debugging.
* Disk persistence across process restarts is a natural extension but
* adds RPC surface for a less-common path.
*/
export const pushSnapshot = (
subagents: readonly SubagentProgress[],
meta: { sessionId?: null | string; startedAt?: null | number }
) => {
if (!subagents.length) {
return
}
const now = Date.now()
const started = meta.startedAt ?? Math.min(...subagents.map(s => s.startedAt ?? now))
const snap: SpawnSnapshot = {
finishedAt: now,
id: `snap-${now.toString(36)}`,
label: summarizeLabel(subagents),
sessionId: meta.sessionId ?? null,
startedAt: Number.isFinite(started) ? started : now,
subagents: subagents.map(item => ({ ...item }))
}
const next = [snap, ...$spawnHistory.get()].slice(0, HISTORY_LIMIT)
$spawnHistory.set(next)
}
function summarizeLabel(subagents: readonly SubagentProgress[]): string {
const top = subagents
.filter(s => s.parentId == null || subagents.every(o => o.id !== s.parentId))
.slice(0, 2)
.map(s => s.goal || 'subagent')
.join(' · ')
return top || `${subagents.length} agent${subagents.length === 1 ? '' : 's'}`
}
/**
* Push a disk-loaded snapshot onto the front of the history stack so the
* overlay can pick it up at index 1 via /replay load. Normalises the
* server payload (arbitrary list) into the same SubagentProgress shape
* used for live data defensive against cross-version reads.
*/
export const pushDiskSnapshot = (r: SpawnTreeLoadResponse, path: string) => {
const raw = Array.isArray(r.subagents) ? r.subagents : []
const normalised = raw.map(normaliseSubagent)
if (!normalised.length) {
return
}
const snap: SpawnSnapshot = {
finishedAt: (r.finished_at ?? Date.now() / 1000) * 1000,
fromDisk: true,
id: `disk-${path}`,
label: r.label || `${normalised.length} subagents`,
path,
sessionId: r.session_id ?? null,
startedAt: (r.started_at ?? r.finished_at ?? Date.now() / 1000) * 1000,
subagents: normalised
}
const next = [snap, ...$spawnHistory.get()].slice(0, HISTORY_LIMIT)
$spawnHistory.set(next)
}
function normaliseSubagent(raw: unknown): SubagentProgress {
const o = raw as Record<string, unknown>
const s = (v: unknown) => (typeof v === 'string' ? v : undefined)
const n = (v: unknown) => (typeof v === 'number' ? v : undefined)
const arr = <T>(v: unknown): T[] | undefined => (Array.isArray(v) ? (v as T[]) : undefined)
return {
apiCalls: n(o.apiCalls),
costUsd: n(o.costUsd),
depth: typeof o.depth === 'number' ? o.depth : 0,
durationSeconds: n(o.durationSeconds),
filesRead: arr<string>(o.filesRead),
filesWritten: arr<string>(o.filesWritten),
goal: s(o.goal) ?? 'subagent',
id: s(o.id) ?? `sa-${Math.random().toString(36).slice(2, 8)}`,
index: typeof o.index === 'number' ? o.index : 0,
inputTokens: n(o.inputTokens),
iteration: n(o.iteration),
model: s(o.model),
notes: (arr<string>(o.notes) ?? []).filter(x => typeof x === 'string'),
outputTail: arr(o.outputTail) as SubagentProgress['outputTail'],
outputTokens: n(o.outputTokens),
parentId: s(o.parentId) ?? null,
reasoningTokens: n(o.reasoningTokens),
startedAt: n(o.startedAt),
status: (s(o.status) as SubagentProgress['status']) ?? 'completed',
summary: s(o.summary),
taskCount: typeof o.taskCount === 'number' ? o.taskCount : 1,
thinking: (arr<string>(o.thinking) ?? []).filter(x => typeof x === 'string'),
toolCount: typeof o.toolCount === 'number' ? o.toolCount : 0,
tools: (arr<string>(o.tools) ?? []).filter(x => typeof x === 'string'),
toolsets: arr<string>(o.toolsets)
}
}

View file

@ -10,8 +10,9 @@ import {
} from '../lib/text.js' } from '../lib/text.js'
import type { ActiveTool, ActivityItem, Msg, SubagentProgress } from '../types.js' import type { ActiveTool, ActivityItem, Msg, SubagentProgress } from '../types.js'
import { resetOverlayState } from './overlayStore.js' import { resetFlowOverlays } from './overlayStore.js'
import { patchTurnState, resetTurnState } from './turnStore.js' import { pushSnapshot } from './spawnHistoryStore.js'
import { getTurnState, patchTurnState, resetTurnState } from './turnStore.js'
import { getUiState, patchUiState } from './uiStore.js' import { getUiState, patchUiState } from './uiStore.js'
const INTERRUPT_COOLDOWN_MS = 1500 const INTERRUPT_COOLDOWN_MS = 1500
@ -41,6 +42,7 @@ class TurnController {
lastStatusNote = '' lastStatusNote = ''
pendingInlineDiffs: string[] = [] pendingInlineDiffs: string[] = []
persistedToolLabels = new Set<string>() persistedToolLabels = new Set<string>()
persistSpawnTree?: (subagents: SubagentProgress[], sessionId: null | string) => Promise<void>
protocolWarned = false protocolWarned = false
reasoningText = '' reasoningText = ''
segmentMessages: Msg[] = [] segmentMessages: Msg[] = []
@ -90,7 +92,7 @@ class TurnController {
turnTrail: [] turnTrail: []
}) })
patchUiState({ busy: false }) patchUiState({ busy: false })
resetOverlayState() resetFlowOverlays()
} }
interruptTurn({ appendMessage, gw, sid, sys }: InterruptDeps) { interruptTurn({ appendMessage, gw, sid, sys }: InterruptDeps) {
@ -189,9 +191,7 @@ class TurnController {
// leading "┊ review diff" header written by `_emit_inline_diff` for the // leading "┊ review diff" header written by `_emit_inline_diff` for the
// terminal printer). That header only makes sense as stdout dressing, // terminal printer). That header only makes sense as stdout dressing,
// not inside a markdown ```diff block. // not inside a markdown ```diff block.
const text = diffText const text = diffText.replace(/^\s*┊[^\n]*\n?/, '').trim()
.replace(/^\s*┊[^\n]*\n?/, '')
.trim()
if (!text || this.pendingInlineDiffs.includes(text)) { if (!text || this.pendingInlineDiffs.includes(text)) {
return return
@ -249,12 +249,15 @@ class TurnController {
// markdown fence of its own — otherwise we render two stacked diff // markdown fence of its own — otherwise we render two stacked diff
// blocks for the same edit. // blocks for the same edit.
const assistantAlreadyHasDiff = /```(?:diff|patch)\b/i.test(finalText) const assistantAlreadyHasDiff = /```(?:diff|patch)\b/i.test(finalText)
const remainingInlineDiffs = assistantAlreadyHasDiff const remainingInlineDiffs = assistantAlreadyHasDiff
? [] ? []
: this.pendingInlineDiffs.filter(diff => !finalText.includes(diff)) : this.pendingInlineDiffs.filter(diff => !finalText.includes(diff))
const inlineDiffBlock = remainingInlineDiffs.length const inlineDiffBlock = remainingInlineDiffs.length
? `\`\`\`diff\n${remainingInlineDiffs.join('\n\n')}\n\`\`\`` ? `\`\`\`diff\n${remainingInlineDiffs.join('\n\n')}\n\`\`\``
: '' : ''
const mergedText = [finalText, inlineDiffBlock].filter(Boolean).join('\n\n') const mergedText = [finalText, inlineDiffBlock].filter(Boolean).join('\n\n')
const existingReasoning = this.reasoningText.trim() || String(payload.reasoning ?? '').trim() const existingReasoning = this.reasoningText.trim() || String(payload.reasoning ?? '').trim()
const savedReasoning = [existingReasoning, existingReasoning ? '' : split.reasoning].filter(Boolean).join('\n\n') const savedReasoning = [existingReasoning, existingReasoning ? '' : split.reasoning].filter(Boolean).join('\n\n')
@ -276,6 +279,20 @@ class TurnController {
const wasInterrupted = this.interrupted const wasInterrupted = this.interrupted
// Archive the turn's spawn tree to history BEFORE idle() drops subagents
// from turnState. Lets /replay and the overlay's history nav pull up
// finished fan-outs without a round-trip to disk.
const finishedSubagents = getTurnState().subagents
const sessionId = getUiState().sid
if (finishedSubagents.length > 0) {
pushSnapshot(finishedSubagents, { sessionId, startedAt: null })
// Fire-and-forget disk persistence so /replay survives process restarts.
// The same snapshot lives in memory via spawnHistoryStore for immediate
// recall — disk is the long-term archive.
void this.persistSpawnTree?.(finishedSubagents, sessionId)
}
this.idle() this.idle()
this.clearReasoning() this.clearReasoning()
this.turnTools = [] this.turnTools = []
@ -443,33 +460,82 @@ class TurnController {
patchTurnState({ activity: [], outcome: '', subagents: [], toolTokens: 0, tools: [], turnTrail: [] }) patchTurnState({ activity: [], outcome: '', subagents: [], toolTokens: 0, tools: [], turnTrail: [] })
} }
upsertSubagent(p: SubagentEventPayload, patch: (current: SubagentProgress) => Partial<SubagentProgress>) { upsertSubagent(
const id = `sa:${p.task_index}:${p.goal || 'subagent'}` p: SubagentEventPayload,
patch: (current: SubagentProgress) => Partial<SubagentProgress>,
opts: { createIfMissing?: boolean } = { createIfMissing: true }
) {
// Stable id: prefer the server-issued subagent_id (survives nested
// grandchildren + cross-tree joins). Fall back to the composite key
// for older gateways that omit the field — those produce a flat list.
const id = p.subagent_id || `sa:${p.task_index}:${p.goal || 'subagent'}`
patchTurnState(state => { patchTurnState(state => {
const existing = state.subagents.find(item => item.id === id) const existing = state.subagents.find(item => item.id === id)
// Late events (subagent.complete/tool/progress arriving after message.complete
// has already fired idle()) would otherwise resurrect a finished
// subagent into turn.subagents and block the "finished" title on the
// /agents overlay. When `createIfMissing` is false we drop silently.
if (!existing && !opts.createIfMissing) {
return state
}
const base: SubagentProgress = existing ?? { const base: SubagentProgress = existing ?? {
depth: p.depth ?? 0,
goal: p.goal, goal: p.goal,
id, id,
index: p.task_index, index: p.task_index,
model: p.model,
notes: [], notes: [],
parentId: p.parent_id ?? null,
startedAt: Date.now(),
status: 'running', status: 'running',
taskCount: p.task_count ?? 1, taskCount: p.task_count ?? 1,
thinking: [], thinking: [],
tools: [] toolCount: p.tool_count ?? 0,
tools: [],
toolsets: p.toolsets
} }
// Map snake_case payload keys onto camelCase state. Only overwrite
// when the event actually carries the field; `??` preserves prior
// values across streaming events that emit partial payloads.
const outputTail = p.output_tail
? p.output_tail.map(e => ({
isError: Boolean(e.is_error),
preview: String(e.preview ?? ''),
tool: String(e.tool ?? 'tool')
}))
: base.outputTail
const next: SubagentProgress = { const next: SubagentProgress = {
...base, ...base,
apiCalls: p.api_calls ?? base.apiCalls,
costUsd: p.cost_usd ?? base.costUsd,
depth: p.depth ?? base.depth,
filesRead: p.files_read ?? base.filesRead,
filesWritten: p.files_written ?? base.filesWritten,
goal: p.goal || base.goal, goal: p.goal || base.goal,
inputTokens: p.input_tokens ?? base.inputTokens,
iteration: p.iteration ?? base.iteration,
model: p.model ?? base.model,
outputTail,
outputTokens: p.output_tokens ?? base.outputTokens,
parentId: p.parent_id ?? base.parentId,
reasoningTokens: p.reasoning_tokens ?? base.reasoningTokens,
taskCount: p.task_count ?? base.taskCount, taskCount: p.task_count ?? base.taskCount,
toolCount: p.tool_count ?? base.toolCount,
toolsets: p.toolsets ?? base.toolsets,
...patch(base) ...patch(base)
} }
// Stable order: by spawn (depth, parent, index) rather than insert time.
// Without it, grandchildren can shuffle relative to siblings when
// events arrive out of order under high concurrency.
const subagents = existing const subagents = existing
? state.subagents.map(item => (item.id === id ? next : item)) ? state.subagents.map(item => (item.id === id ? next : item))
: [...state.subagents, next].sort((a, b) => a.index - b.index) : [...state.subagents, next].sort((a, b) => a.depth - b.depth || a.index - b.index)
return { ...state, subagents } return { ...state, subagents }
}) })

View file

@ -74,6 +74,10 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
if (overlay.picker) { if (overlay.picker) {
return patchOverlayState({ picker: false }) return patchOverlayState({ picker: false })
} }
if (overlay.agents) {
return patchOverlayState({ agents: false })
}
} }
const cycleQueue = (dir: 1 | -1) => { const cycleQueue = (dir: 1 | -1) => {
@ -180,6 +184,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
if (isCtrl(key, ch, 'c')) { if (isCtrl(key, ch, 'c')) {
cancelOverlayFromCtrlC() cancelOverlayFromCtrlC()
} }
return return
} }
@ -290,6 +295,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
if (key.upArrow && !cState.inputBuf.length) { if (key.upArrow && !cState.inputBuf.length) {
const inputSel = getInputSelection() const inputSel = getInputSelection()
const cursor = inputSel && inputSel.start === inputSel.end ? inputSel.start : null const cursor = inputSel && inputSel.start === inputSel.end ? inputSel.start : null
const noLineAbove = const noLineAbove =
!cState.input || (cursor !== null && cState.input.lastIndexOf('\n', Math.max(0, cursor - 1)) < 0) !cState.input || (cursor !== null && cState.input.lastIndexOf('\n', Math.max(0, cursor - 1)) < 0)

File diff suppressed because it is too large Load diff

View file

@ -1,10 +1,14 @@
import { Box, type ScrollBoxHandle, Text } from '@hermes/ink' import { Box, type ScrollBoxHandle, Text } from '@hermes/ink'
import { type ReactNode, type RefObject, useCallback, useEffect, useState, useSyncExternalStore } from 'react' import { useStore } from '@nanostores/react'
import { type ReactNode, type RefObject, useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'
import { $delegationState } from '../app/delegationStore.js'
import { $turnState } from '../app/turnStore.js'
import { FACES } from '../content/faces.js' import { FACES } from '../content/faces.js'
import { VERBS } from '../content/verbs.js' import { VERBS } from '../content/verbs.js'
import { fmtDuration } from '../domain/messages.js' import { fmtDuration } from '../domain/messages.js'
import { stickyPromptFromViewport } from '../domain/viewport.js' import { stickyPromptFromViewport } from '../domain/viewport.js'
import { buildSubagentTree, treeTotals, widthByDepth } from '../lib/subagentTree.js'
import { fmtK } from '../lib/text.js' import { fmtK } from '../lib/text.js'
import type { Theme } from '../theme.js' import type { Theme } from '../theme.js'
import type { Msg, Usage } from '../types.js' import type { Msg, Usage } from '../types.js'
@ -60,6 +64,67 @@ function ctxBar(pct: number | undefined, w = 10) {
return '█'.repeat(filled) + '░'.repeat(w - filled) return '█'.repeat(filled) + '░'.repeat(w - filled)
} }
function SpawnHud({ t }: { t: Theme }) {
// Tight HUD that only appears when the session is actually fanning out.
// Colour escalates to warn/error as depth or concurrency approaches the cap.
const delegation = useStore($delegationState)
const turn = useStore($turnState)
const tree = useMemo(() => buildSubagentTree(turn.subagents), [turn.subagents])
const totals = useMemo(() => treeTotals(tree), [tree])
if (!totals.descendantCount && !delegation.paused) {
return null
}
const maxDepth = delegation.maxSpawnDepth
const maxConc = delegation.maxConcurrentChildren
const depth = Math.max(0, totals.maxDepthFromHere)
const active = totals.activeCount
// `max_concurrent_children` is a per-parent cap, not a global one.
// `activeCount` sums every running agent across the tree and would
// over-warn for multi-orchestrator runs. The widest level of the tree
// is a closer proxy to "most concurrent spawns that could be hitting a
// single parent's slot budget".
const widestLevel = widthByDepth(tree).reduce((a, b) => Math.max(a, b), 0)
const depthRatio = maxDepth ? depth / maxDepth : 0
const concRatio = maxConc ? widestLevel / maxConc : 0
const ratio = Math.max(depthRatio, concRatio)
const color = delegation.paused || ratio >= 1 ? t.color.error : ratio >= 0.66 ? t.color.warn : t.color.dim
const pieces: string[] = []
if (delegation.paused) {
pieces.push('⏸ paused')
}
if (totals.descendantCount > 0) {
const depthLabel = maxDepth ? `${depth}/${maxDepth}` : `${depth}`
pieces.push(`d${depthLabel}`)
if (active > 0) {
// Label pairs the widest-level count (drives concRatio above) with
// the total active count for context. `W/cap` triggers the warn,
// `+N` is everything else currently running across the tree.
const extra = Math.max(0, active - widestLevel)
const widthLabel = maxConc ? `${widestLevel}/${maxConc}` : `${widestLevel}`
const suffix = extra > 0 ? `+${extra}` : ''
pieces.push(`${widthLabel}${suffix}`)
}
}
const atCap = depthRatio >= 1 || concRatio >= 1
return (
<Text color={color}>
{atCap ? ' │ ⚠ ' : ' │ '}
{pieces.join(' ')}
</Text>
)
}
function SessionDuration({ startedAt }: { startedAt: number }) { function SessionDuration({ startedAt }: { startedAt: number }) {
const [now, setNow] = useState(() => Date.now()) const [now, setNow] = useState(() => Date.now())
@ -145,6 +210,7 @@ export function StatusRule({
<SessionDuration startedAt={sessionStartedAt} /> <SessionDuration startedAt={sessionStartedAt} />
</Text> </Text>
) : null} ) : null}
<SpawnHud t={t} />
{voiceLabel ? <Text color={t.color.dim}> {voiceLabel}</Text> : null} {voiceLabel ? <Text color={t.color.dim}> {voiceLabel}</Text> : null}
{bgCount > 0 ? <Text color={t.color.dim}> {bgCount} bg</Text> : null} {bgCount > 0 ? <Text color={t.color.dim}> {bgCount} bg</Text> : null}
{showCost && typeof usage.cost_usd === 'number' ? ( {showCost && typeof usage.cost_usd === 'number' ? (

View file

@ -2,13 +2,15 @@ import { AlternateScreen, Box, NoSelect, ScrollBox, Text } from '@hermes/ink'
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
import { memo } from 'react' import { memo } from 'react'
import { useGateway } from '../app/gatewayContext.js'
import type { AppLayoutProgressProps, AppLayoutProps } from '../app/interfaces.js' import type { AppLayoutProgressProps, AppLayoutProps } from '../app/interfaces.js'
import { $isBlocked } from '../app/overlayStore.js' import { $isBlocked, $overlayState, patchOverlayState } from '../app/overlayStore.js'
import { $uiState } from '../app/uiStore.js' import { $uiState } from '../app/uiStore.js'
import { PLACEHOLDER } from '../content/placeholders.js' import { PLACEHOLDER } from '../content/placeholders.js'
import type { Theme } from '../theme.js' import type { Theme } from '../theme.js'
import type { DetailsMode } from '../types.js' import type { DetailsMode } from '../types.js'
import { AgentsOverlay } from './agentsOverlay.js'
import { GoodVibesHeart, StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js' import { GoodVibesHeart, StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js'
import { FloatingOverlays, PromptZone } from './appOverlays.js' import { FloatingOverlays, PromptZone } from './appOverlays.js'
import { Banner, Panel, SessionPanel } from './branding.js' import { Banner, Panel, SessionPanel } from './branding.js'
@ -256,6 +258,21 @@ const ComposerPane = memo(function ComposerPane({
) )
}) })
const AgentsOverlayPane = memo(function AgentsOverlayPane() {
const { gw } = useGateway()
const ui = useStore($uiState)
const overlay = useStore($overlayState)
return (
<AgentsOverlay
gw={gw}
initialHistoryIndex={overlay.agentsInitialHistoryIndex}
onClose={() => patchOverlayState({ agents: false, agentsInitialHistoryIndex: 0 })}
t={ui.theme}
/>
)
})
export const AppLayout = memo(function AppLayout({ export const AppLayout = memo(function AppLayout({
actions, actions,
composer, composer,
@ -264,22 +281,30 @@ export const AppLayout = memo(function AppLayout({
status, status,
transcript transcript
}: AppLayoutProps) { }: AppLayoutProps) {
const overlay = useStore($overlayState)
return ( return (
<AlternateScreen mouseTracking={mouseTracking}> <AlternateScreen mouseTracking={mouseTracking}>
<Box flexDirection="column" flexGrow={1}> <Box flexDirection="column" flexGrow={1}>
<Box flexDirection="row" flexGrow={1}> <Box flexDirection="row" flexGrow={1}>
<TranscriptPane actions={actions} composer={composer} progress={progress} transcript={transcript} /> {overlay.agents ? (
<AgentsOverlayPane />
) : (
<TranscriptPane actions={actions} composer={composer} progress={progress} transcript={transcript} />
)}
</Box> </Box>
<PromptZone {!overlay.agents && (
cols={composer.cols} <PromptZone
onApprovalChoice={actions.answerApproval} cols={composer.cols}
onClarifyAnswer={actions.answerClarify} onApprovalChoice={actions.answerApproval}
onSecretSubmit={actions.answerSecret} onClarifyAnswer={actions.answerClarify}
onSudoSubmit={actions.answerSudo} onSecretSubmit={actions.answerSecret}
/> onSudoSubmit={actions.answerSudo}
/>
)}
<ComposerPane actions={actions} composer={composer} status={status} /> {!overlay.agents && <ComposerPane actions={actions} composer={composer} status={status} />}
</Box> </Box>
</AlternateScreen> </AlternateScreen>
) )

View file

@ -615,14 +615,7 @@ export function TextInput({
return return
} }
if ( if ((k.ctrl && inp === 'c') || k.tab || (k.shift && k.tab) || k.pageUp || k.pageDown || k.escape) {
(k.ctrl && inp === 'c') ||
k.tab ||
(k.shift && k.tab) ||
k.pageUp ||
k.pageDown ||
k.escape
) {
return return
} }

View file

@ -1,8 +1,19 @@
import { Box, NoSelect, Text } from '@hermes/ink' import { Box, NoSelect, Text } from '@hermes/ink'
import { memo, useEffect, useMemo, useState, type ReactNode } from 'react' import { memo, type ReactNode, useEffect, useMemo, useState } from 'react'
import spinners, { type BrailleSpinnerName } from 'unicode-animations' import spinners, { type BrailleSpinnerName } from 'unicode-animations'
import { THINKING_COT_MAX } from '../config/limits.js' import { THINKING_COT_MAX } from '../config/limits.js'
import {
buildSubagentTree,
fmtCost,
fmtTokens,
formatSummary as formatSpawnSummary,
hotnessBucket,
peakHotness,
sparkline,
treeTotals,
widthByDepth
} from '../lib/subagentTree.js'
import { import {
compactPreview, compactPreview,
estimateTokensRough, estimateTokensRough,
@ -14,7 +25,7 @@ import {
toolTrailLabel toolTrailLabel
} from '../lib/text.js' } from '../lib/text.js'
import type { Theme } from '../theme.js' import type { Theme } from '../theme.js'
import type { ActiveTool, ActivityItem, DetailsMode, SubagentProgress, ThinkingMode } from '../types.js' import type { ActiveTool, ActivityItem, DetailsMode, SubagentNode, SubagentProgress, ThinkingMode } from '../types.js'
const THINK: BrailleSpinnerName[] = ['helix', 'breathe', 'orbit', 'dna', 'waverows', 'snake', 'pulse'] const THINK: BrailleSpinnerName[] = ['helix', 'breathe', 'orbit', 'dna', 'waverows', 'snake', 'pulse']
const TOOL: BrailleSpinnerName[] = ['cascade', 'scan', 'diagswipe', 'fillsweep', 'rain', 'columns', 'sparkle'] const TOOL: BrailleSpinnerName[] = ['cascade', 'scan', 'diagswipe', 'fillsweep', 'rain', 'columns', 'sparkle']
@ -106,6 +117,8 @@ function TreeNode({
header, header,
open, open,
rails = [], rails = [],
stemColor,
stemDim,
t t
}: { }: {
branch: TreeBranch branch: TreeBranch
@ -113,11 +126,13 @@ function TreeNode({
header: ReactNode header: ReactNode
open: boolean open: boolean
rails?: TreeRails rails?: TreeRails
stemColor?: string
stemDim?: boolean
t: Theme t: Theme
}) { }) {
return ( return (
<Box flexDirection="column"> <Box flexDirection="column">
<TreeRow branch={branch} rails={rails} t={t}> <TreeRow branch={branch} rails={rails} stemColor={stemColor} stemDim={stemDim} t={t}>
{header} {header}
</TreeRow> </TreeRow>
{open ? children?.(nextTreeRails(rails, branch)) : null} {open ? children?.(nextTreeRails(rails, branch)) : null}
@ -239,16 +254,31 @@ function Chevron({
) )
} }
function heatColor(node: SubagentNode, peak: number, theme: Theme): string | undefined {
const palette = [theme.color.bronze, theme.color.amber, theme.color.gold, theme.color.warn, theme.color.error]
const idx = hotnessBucket(node.aggregate.hotness, peak, palette.length)
// Below the median bucket we keep the default dim stem so cool branches
// fade into the chrome — only "hot" branches draw the eye.
if (idx < 2) {
return undefined
}
return palette[idx]
}
function SubagentAccordion({ function SubagentAccordion({
branch, branch,
expanded, expanded,
item, node,
peak,
rails = [], rails = [],
t t
}: { }: {
branch: TreeBranch branch: TreeBranch
expanded: boolean expanded: boolean
item: SubagentProgress node: SubagentNode
peak: number
rails?: TreeRails rails?: TreeRails
t: Theme t: Theme
}) { }) {
@ -257,6 +287,7 @@ function SubagentAccordion({
const [openThinking, setOpenThinking] = useState(expanded) const [openThinking, setOpenThinking] = useState(expanded)
const [openTools, setOpenTools] = useState(expanded) const [openTools, setOpenTools] = useState(expanded)
const [openNotes, setOpenNotes] = useState(expanded) const [openNotes, setOpenNotes] = useState(expanded)
const [openKids, setOpenKids] = useState(expanded)
useEffect(() => { useEffect(() => {
if (!expanded) { if (!expanded) {
@ -268,6 +299,7 @@ function SubagentAccordion({
setOpenThinking(true) setOpenThinking(true)
setOpenTools(true) setOpenTools(true)
setOpenNotes(true) setOpenNotes(true)
setOpenKids(true)
}, [expanded]) }, [expanded])
const expandAll = () => { const expandAll = () => {
@ -276,8 +308,13 @@ function SubagentAccordion({
setOpenThinking(true) setOpenThinking(true)
setOpenTools(true) setOpenTools(true)
setOpenNotes(true) setOpenNotes(true)
setOpenKids(true)
} }
const item = node.item
const children = node.children
const aggregate = node.aggregate
const statusTone: 'dim' | 'error' | 'warn' = const statusTone: 'dim' | 'error' | 'warn' =
item.status === 'failed' ? 'error' : item.status === 'interrupted' ? 'warn' : 'dim' item.status === 'failed' ? 'error' : item.status === 'interrupted' ? 'warn' : 'dim'
@ -286,10 +323,60 @@ function SubagentAccordion({
const title = `${prefix}${open ? goalLabel : compactPreview(goalLabel, 60)}` const title = `${prefix}${open ? goalLabel : compactPreview(goalLabel, 60)}`
const summary = compactPreview((item.summary || '').replace(/\s+/g, ' ').trim(), 72) const summary = compactPreview((item.summary || '').replace(/\s+/g, ' ').trim(), 72)
const suffix = // Suffix packs branch rollup: status · elapsed · per-branch tool/agent/token/cost.
item.status === 'running' // Emphasises the numbers the user can't easily eyeball from a flat list.
? 'running' const statusLabel = item.status === 'queued' ? 'queued' : item.status === 'running' ? 'running' : String(item.status)
: `${item.status}${item.durationSeconds ? ` · ${fmtElapsed(item.durationSeconds * 1000)}` : ''}`
const rollupBits: string[] = [statusLabel]
if (item.durationSeconds) {
rollupBits.push(fmtElapsed(item.durationSeconds * 1000))
}
const localTools = item.toolCount ?? 0
const subtreeTools = aggregate.totalTools - localTools
if (localTools > 0) {
rollupBits.push(`${localTools} tool${localTools === 1 ? '' : 's'}`)
}
const localTokens = (item.inputTokens ?? 0) + (item.outputTokens ?? 0)
if (localTokens > 0) {
rollupBits.push(`${fmtTokens(localTokens)} tok`)
}
const localCost = item.costUsd ?? 0
if (localCost > 0) {
rollupBits.push(fmtCost(localCost))
}
const filesLocal = (item.filesWritten?.length ?? 0) + (item.filesRead?.length ?? 0)
if (filesLocal > 0) {
rollupBits.push(`${filesLocal}`)
}
if (children.length > 0) {
rollupBits.push(`${aggregate.descendantCount}`)
if (subtreeTools > 0) {
rollupBits.push(`+${subtreeTools}t sub`)
}
const subCost = aggregate.costUsd - localCost
if (subCost >= 0.01) {
rollupBits.push(`+${fmtCost(subCost)} sub`)
}
if (aggregate.activeCount > 0 && item.status !== 'running') {
rollupBits.push(`${aggregate.activeCount}`)
}
}
const suffix = rollupBits.join(' · ')
const thinkingText = item.thinking.join('\n') const thinkingText = item.thinking.join('\n')
const hasThinking = Boolean(thinkingText) const hasThinking = Boolean(thinkingText)
@ -418,6 +505,50 @@ function SubagentAccordion({
}) })
} }
if (children.length > 0) {
// Nested grandchildren — rendered recursively via SubagentAccordion,
// sharing the same keybindings / expand semantics as top-level nodes.
sections.push({
header: (
<Chevron
count={children.length}
onClick={shift => {
if (shift) {
expandAll()
} else {
setOpenKids(v => !v)
}
}}
open={showChildren || openKids}
suffix={`d${item.depth + 1} · ${aggregate.descendantCount} total`}
t={t}
title="Spawned"
/>
),
key: 'subagents',
open: showChildren || openKids,
render: childRails => (
<Box flexDirection="column">
{children.map((child, i) => (
<SubagentAccordion
branch={i === children.length - 1 ? 'last' : 'mid'}
expanded={expanded || deep}
key={child.item.id}
node={child}
peak={peak}
rails={childRails}
t={t}
/>
))}
</Box>
)
})
}
// Heatmap: amber→error gradient on the stem when this branch is "hot"
// (high tools/sec) relative to the whole tree's peak.
const stem = heatColor(node, peak, t)
return ( return (
<TreeNode <TreeNode
branch={branch} branch={branch}
@ -447,6 +578,8 @@ function SubagentAccordion({
} }
open={open} open={open}
rails={rails} rails={rails}
stemColor={stem}
stemDim={stem == null}
t={t} t={t}
> >
{childRails => ( {childRails => (
@ -598,6 +731,16 @@ export const ToolTrail = memo(function ToolTrail({
const cot = useMemo(() => thinkingPreview(reasoning, 'full', THINKING_COT_MAX), [reasoning]) const cot = useMemo(() => thinkingPreview(reasoning, 'full', THINKING_COT_MAX), [reasoning])
// Spawn-tree derivations must live above any early return so React's
// rules-of-hooks sees a stable call order. Cheap O(N) builds memoised
// by subagent-list identity.
const spawnTree = useMemo(() => buildSubagentTree(subagents), [subagents])
const spawnPeak = useMemo(() => peakHotness(spawnTree), [spawnTree])
const spawnTotals = useMemo(() => treeTotals(spawnTree), [spawnTree])
const spawnWidths = useMemo(() => widthByDepth(spawnTree), [spawnTree])
const spawnSpark = useMemo(() => sparkline(spawnWidths), [spawnWidths])
const spawnSummaryLabel = useMemo(() => formatSpawnSummary(spawnTotals), [spawnTotals])
if ( if (
!busy && !busy &&
!trail.length && !trail.length &&
@ -753,12 +896,13 @@ export const ToolTrail = memo(function ToolTrail({
const renderSubagentList = (rails: boolean[]) => ( const renderSubagentList = (rails: boolean[]) => (
<Box flexDirection="column"> <Box flexDirection="column">
{subagents.map((item, index) => ( {spawnTree.map((node, index) => (
<SubagentAccordion <SubagentAccordion
branch={index === subagents.length - 1 ? 'last' : 'mid'} branch={index === spawnTree.length - 1 ? 'last' : 'mid'}
expanded={detailsMode === 'expanded' || deepSubagents} expanded={detailsMode === 'expanded' || deepSubagents}
item={item} key={node.item.id}
key={item.id} node={node}
peak={spawnPeak}
rails={rails} rails={rails}
t={t} t={t}
/> />
@ -881,10 +1025,14 @@ export const ToolTrail = memo(function ToolTrail({
} }
if (hasSubagents && !inlineDelegateKey) { if (hasSubagents && !inlineDelegateKey) {
// Spark + summary give a one-line read on the branch shape before
// opening the subtree. `/agents` opens the full-screen audit overlay.
const suffix = spawnSpark ? `${spawnSummaryLabel} ${spawnSpark} (/agents)` : `${spawnSummaryLabel} (/agents)`
sections.push({ sections.push({
header: ( header: (
<Chevron <Chevron
count={subagents.length} count={spawnTotals.descendantCount}
onClick={shift => { onClick={shift => {
if (shift) { if (shift) {
expandAll() expandAll()
@ -895,8 +1043,9 @@ export const ToolTrail = memo(function ToolTrail({
} }
}} }}
open={detailsMode === 'expanded' || openSubagents} open={detailsMode === 'expanded' || openSubagents}
suffix={suffix}
t={t} t={t}
title="Subagents" title="Spawn tree"
/> />
), ),
key: 'subagents', key: 'subagents',

View file

@ -280,15 +280,85 @@ export interface ReloadMcpResponse {
// ── Subagent events ────────────────────────────────────────────────── // ── Subagent events ──────────────────────────────────────────────────
export interface SubagentEventPayload { export interface SubagentEventPayload {
api_calls?: number
cost_usd?: number
depth?: number
duration_seconds?: number duration_seconds?: number
files_read?: string[]
files_written?: string[]
goal: string goal: string
status?: 'completed' | 'failed' | 'interrupted' | 'running' input_tokens?: number
iteration?: number
model?: string
output_tail?: { is_error?: boolean; preview?: string; tool?: string }[]
output_tokens?: number
parent_id?: null | string
reasoning_tokens?: number
status?: 'completed' | 'failed' | 'interrupted' | 'queued' | 'running'
subagent_id?: string
summary?: string summary?: string
task_count?: number task_count?: number
task_index: number task_index: number
text?: string text?: string
tool_count?: number
tool_name?: string tool_name?: string
tool_preview?: string tool_preview?: string
toolsets?: string[]
}
// ── Delegation control RPCs ──────────────────────────────────────────
export interface DelegationStatusResponse {
active?: {
depth?: number
goal?: string
model?: null | string
parent_id?: null | string
started_at?: number
status?: string
subagent_id?: string
tool_count?: number
}[]
max_concurrent_children?: number
max_spawn_depth?: number
paused?: boolean
}
export interface DelegationPauseResponse {
paused?: boolean
}
export interface SubagentInterruptResponse {
found?: boolean
subagent_id?: string
}
// ── Spawn-tree snapshots ─────────────────────────────────────────────
export interface SpawnTreeListEntry {
count: number
finished_at?: number
label?: string
path: string
session_id?: string
started_at?: number | null
}
export interface SpawnTreeListResponse {
entries?: SpawnTreeListEntry[]
}
export interface SpawnTreeLoadResponse {
finished_at?: number
label?: string
session_id?: string
started_at?: null | number
subagents?: unknown[]
}
export interface SpawnTreeSaveResponse {
path?: string
session_id?: string
} }
export type GatewayEvent = export type GatewayEvent =
@ -320,6 +390,7 @@ export type GatewayEvent =
| { payload: { env_var: string; prompt: string; request_id: string }; session_id?: string; type: 'secret.request' } | { payload: { env_var: string; prompt: string; request_id: string }; session_id?: string; type: 'secret.request' }
| { payload: { task_id: string; text: string }; session_id?: string; type: 'background.complete' } | { payload: { task_id: string; text: string }; session_id?: string; type: 'background.complete' }
| { payload: { text: string }; session_id?: string; type: 'btw.complete' } | { payload: { text: string }; session_id?: string; type: 'btw.complete' }
| { payload: SubagentEventPayload; session_id?: string; type: 'subagent.spawn_requested' }
| { payload: SubagentEventPayload; session_id?: string; type: 'subagent.start' } | { payload: SubagentEventPayload; session_id?: string; type: 'subagent.start' }
| { payload: SubagentEventPayload; session_id?: string; type: 'subagent.thinking' } | { payload: SubagentEventPayload; session_id?: string; type: 'subagent.thinking' }
| { payload: SubagentEventPayload; session_id?: string; type: 'subagent.tool' } | { payload: SubagentEventPayload; session_id?: string; type: 'subagent.tool' }

View file

@ -0,0 +1,355 @@
import type { SubagentAggregate, SubagentNode, SubagentProgress } from '../types.js'
const ROOT_KEY = '__root__'
/**
* Reconstruct the subagent spawn tree from a flat event-ordered list.
*
* Grouping is by `parentId`; a missing `parentId` (or one pointing at an
* unknown subagent) is treated as a top-level spawn of the current turn.
* Children within a parent are sorted by `depth` then `index` same key
* used in `turnController.upsertSubagent`, so render order matches spawn
* order regardless of network reordering of gateway events.
*
* Older gateways omit `parentId`; every subagent is then a top-level node
* and the tree renders flat matching pre-observability behaviour.
*/
export function buildSubagentTree(items: readonly SubagentProgress[]): SubagentNode[] {
if (!items.length) {
return []
}
const byParent = new Map<string, SubagentProgress[]>()
const known = new Set<string>()
for (const item of items) {
known.add(item.id)
}
for (const item of items) {
const parentKey = item.parentId && known.has(item.parentId) ? item.parentId : ROOT_KEY
const bucket = byParent.get(parentKey) ?? []
bucket.push(item)
byParent.set(parentKey, bucket)
}
for (const bucket of byParent.values()) {
bucket.sort((a, b) => a.depth - b.depth || a.index - b.index)
}
const build = (item: SubagentProgress): SubagentNode => {
const kids = byParent.get(item.id) ?? []
const children = kids.map(build)
return { aggregate: aggregate(item, children), children, item }
}
return (byParent.get(ROOT_KEY) ?? []).map(build)
}
/**
* Roll up counts for a node's whole subtree. Kept pure so the live view
* and the post-hoc replay can share the same renderer unchanged.
*
* `hotness` = tools per second across the subtree a crude proxy for
* "how much work is happening in this branch". Used to colour tree rails
* in the overlay / inline view so the eye spots the expensive branch.
*/
export function aggregate(item: SubagentProgress, children: readonly SubagentNode[]): SubagentAggregate {
let totalTools = item.toolCount ?? 0
let totalDuration = item.durationSeconds ?? 0
let descendantCount = 0
let activeCount = isRunning(item) ? 1 : 0
let maxDepthFromHere = 0
let inputTokens = item.inputTokens ?? 0
let outputTokens = item.outputTokens ?? 0
let costUsd = item.costUsd ?? 0
let filesTouched = (item.filesRead?.length ?? 0) + (item.filesWritten?.length ?? 0)
for (const child of children) {
totalTools += child.aggregate.totalTools
totalDuration += child.aggregate.totalDuration
descendantCount += child.aggregate.descendantCount + 1
activeCount += child.aggregate.activeCount
maxDepthFromHere = Math.max(maxDepthFromHere, child.aggregate.maxDepthFromHere + 1)
inputTokens += child.aggregate.inputTokens
outputTokens += child.aggregate.outputTokens
costUsd += child.aggregate.costUsd
filesTouched += child.aggregate.filesTouched
}
const hotness = totalDuration > 0 ? totalTools / totalDuration : 0
return {
activeCount,
costUsd,
descendantCount,
filesTouched,
hotness,
inputTokens,
maxDepthFromHere,
outputTokens,
totalDuration,
totalTools
}
}
/**
* Count of subagents at each depth level, indexed by depth (0 = top level).
* Drives the inline sparkline (`▁▃▇▅`) and the status-bar HUD.
*/
export function widthByDepth(tree: readonly SubagentNode[]): number[] {
const widths: number[] = []
const walk = (nodes: readonly SubagentNode[], depth: number) => {
if (!nodes.length) {
return
}
widths[depth] = (widths[depth] ?? 0) + nodes.length
for (const node of nodes) {
walk(node.children, depth + 1)
}
}
walk(tree, 0)
return widths
}
/**
* Flat totals across the full tree feeds the summary chip header.
*/
export function treeTotals(tree: readonly SubagentNode[]): SubagentAggregate {
let totalTools = 0
let totalDuration = 0
let descendantCount = 0
let activeCount = 0
let maxDepthFromHere = 0
let inputTokens = 0
let outputTokens = 0
let costUsd = 0
let filesTouched = 0
for (const node of tree) {
totalTools += node.aggregate.totalTools
totalDuration += node.aggregate.totalDuration
descendantCount += node.aggregate.descendantCount + 1
activeCount += node.aggregate.activeCount
maxDepthFromHere = Math.max(maxDepthFromHere, node.aggregate.maxDepthFromHere + 1)
inputTokens += node.aggregate.inputTokens
outputTokens += node.aggregate.outputTokens
costUsd += node.aggregate.costUsd
filesTouched += node.aggregate.filesTouched
}
const hotness = totalDuration > 0 ? totalTools / totalDuration : 0
return {
activeCount,
costUsd,
descendantCount,
filesTouched,
hotness,
inputTokens,
maxDepthFromHere,
outputTokens,
totalDuration,
totalTools
}
}
/**
* Flatten the tree into visit order useful for keyboard navigation and
* for "kill subtree" walks that fire one RPC per descendant.
*/
export function flattenTree(tree: readonly SubagentNode[]): SubagentNode[] {
const out: SubagentNode[] = []
const walk = (nodes: readonly SubagentNode[]) => {
for (const node of nodes) {
out.push(node)
walk(node.children)
}
}
walk(tree)
return out
}
/**
* Collect every descendant's id for a given node (excluding the node itself).
*/
export function descendantIds(node: SubagentNode): string[] {
const ids: string[] = []
const walk = (children: readonly SubagentNode[]) => {
for (const child of children) {
ids.push(child.item.id)
walk(child.children)
}
}
walk(node.children)
return ids
}
export function isRunning(item: Pick<SubagentProgress, 'status'>): boolean {
return item.status === 'running' || item.status === 'queued'
}
const SPARK_RAMP = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'] as const
/**
* 8-step unicode bar sparkline from a positive-integer array. Zeroes render
* as spaces so a sparse tree doesn't read as equal activity at every depth.
*/
export function sparkline(values: readonly number[]): string {
if (!values.length) {
return ''
}
const max = Math.max(...values)
if (max <= 0) {
return ' '.repeat(values.length)
}
return values
.map(v => {
if (v <= 0) {
return ' '
}
const idx = Math.min(SPARK_RAMP.length - 1, Math.max(0, Math.ceil((v / max) * (SPARK_RAMP.length - 1))))
return SPARK_RAMP[idx]
})
.join('')
}
/**
* Format totals into a compact one-line summary: `d2 · 7 agents · 124 tools · 2m 14s`
*/
export function formatSummary(totals: SubagentAggregate): string {
const pieces = [`d${Math.max(0, totals.maxDepthFromHere)}`]
pieces.push(`${totals.descendantCount} agent${totals.descendantCount === 1 ? '' : 's'}`)
if (totals.totalTools > 0) {
pieces.push(`${totals.totalTools} tool${totals.totalTools === 1 ? '' : 's'}`)
}
if (totals.totalDuration > 0) {
pieces.push(fmtDuration(totals.totalDuration))
}
const tokens = totals.inputTokens + totals.outputTokens
if (tokens > 0) {
pieces.push(`${fmtTokens(tokens)} tok`)
}
if (totals.costUsd > 0) {
pieces.push(fmtCost(totals.costUsd))
}
if (totals.activeCount > 0) {
pieces.push(`${totals.activeCount}`)
}
return pieces.join(' · ')
}
/** Compact dollar amount: `$0.02`, `$1.34`, `$12.4` — never > 5 chars beyond the `$`. */
export function fmtCost(usd: number): string {
if (!Number.isFinite(usd) || usd <= 0) {
return ''
}
if (usd < 0.01) {
return '<$0.01'
}
if (usd < 10) {
return `$${usd.toFixed(2)}`
}
return `$${usd.toFixed(1)}`
}
/** Compact token count: `12k`, `1.2k`, `542`. */
export function fmtTokens(n: number): string {
if (!Number.isFinite(n) || n <= 0) {
return '0'
}
if (n < 1000) {
return String(Math.round(n))
}
if (n < 10_000) {
return `${(n / 1000).toFixed(1)}k`
}
return `${Math.round(n / 1000)}k`
}
/**
* `Ns` / `Nm` / `Nm Ss` formatter for seconds. Shared with the agents
* overlay so the timeline + list + summary all speak the same dialect.
*/
export function fmtDuration(seconds: number): string {
if (seconds < 60) {
return `${Math.max(0, Math.round(seconds))}s`
}
const m = Math.floor(seconds / 60)
const s = Math.round(seconds - m * 60)
return s === 0 ? `${m}m` : `${m}m ${s}s`
}
/**
* A subagent is top-level if it has no `parentId`, or its parent isn't in
* the same snapshot (orphaned by a pruned mid-flight root). Same rule
* `buildSubagentTree` uses keep call sites consistent across the live
* view, disk label, and diff pane.
*/
export function topLevelSubagents(items: readonly SubagentProgress[]): SubagentProgress[] {
const ids = new Set(items.map(s => s.id))
return items.filter(s => !s.parentId || !ids.has(s.parentId))
}
/**
* Normalize a node's hotness into a palette index 0..N-1 where N = buckets.
* Higher hotness = "hotter" colour. Normalized against the tree's peak hotness
* so a uniformly slow tree still shows gradient across its busiest branches.
*/
export function hotnessBucket(hotness: number, peakHotness: number, buckets: number): number {
if (!Number.isFinite(hotness) || hotness <= 0 || peakHotness <= 0 || buckets <= 1) {
return 0
}
const ratio = Math.min(1, hotness / peakHotness)
return Math.min(buckets - 1, Math.max(0, Math.round(ratio * (buckets - 1))))
}
export function peakHotness(tree: readonly SubagentNode[]): number {
let peak = 0
const walk = (nodes: readonly SubagentNode[]) => {
for (const node of nodes) {
peak = Math.max(peak, node.aggregate.hotness)
walk(node.children)
}
}
walk(tree)
return peak
}

View file

@ -94,7 +94,12 @@ export const DARK_THEME: Theme = {
amber: '#FFBF00', amber: '#FFBF00',
bronze: '#CD7F32', bronze: '#CD7F32',
cornsilk: '#FFF8DC', cornsilk: '#FFF8DC',
dim: '#B8860B', // Bumped from the old `#B8860B` darkgoldenrod (~53% luminance) which
// read as barely-visible on dark terminals for long body text. The
// new value sits ~60% luminance — readable without losing the "muted /
// secondary" semantic. Field labels still use `label` (65%) which
// stays brighter so hierarchy holds.
dim: '#CC9B1F',
completionBg: '#FFFFFF', completionBg: '#FFFFFF',
completionCurrentBg: mix('#FFFFFF', '#FFBF00', 0.25), completionCurrentBg: mix('#FFFFFF', '#FFBF00', 0.25),
@ -104,8 +109,11 @@ export const DARK_THEME: Theme = {
warn: '#ffa726', warn: '#ffa726',
prompt: '#FFF8DC', prompt: '#FFF8DC',
sessionLabel: '#B8860B', // sessionLabel/sessionBorder intentionally track the `dim` value — they
sessionBorder: '#B8860B', // are "same role, same colour" by design. fromSkin's banner_dim fallback
// relies on this pairing (#11300).
sessionLabel: '#CC9B1F',
sessionBorder: '#CC9B1F',
statusBg: '#1a1a2e', statusBg: '#1a1a2e',
statusFg: '#C0C0C0', statusFg: '#C0C0C0',

View file

@ -12,16 +12,72 @@ export interface ActivityItem {
} }
export interface SubagentProgress { export interface SubagentProgress {
apiCalls?: number
costUsd?: number
depth: number
durationSeconds?: number durationSeconds?: number
filesRead?: string[]
filesWritten?: string[]
goal: string goal: string
id: string id: string
index: number index: number
inputTokens?: number
iteration?: number
model?: string
notes: string[] notes: string[]
status: 'completed' | 'failed' | 'interrupted' | 'running' outputTail?: SubagentOutputEntry[]
outputTokens?: number
parentId: null | string
reasoningTokens?: number
startedAt?: number
status: 'completed' | 'failed' | 'interrupted' | 'queued' | 'running'
summary?: string summary?: string
taskCount: number taskCount: number
thinking: string[] thinking: string[]
toolCount: number
tools: string[] tools: string[]
toolsets?: string[]
}
export interface SubagentOutputEntry {
isError: boolean
preview: string
tool: string
}
export interface SubagentNode {
aggregate: SubagentAggregate
children: SubagentNode[]
item: SubagentProgress
}
export interface SubagentAggregate {
activeCount: number
costUsd: number
descendantCount: number
filesTouched: number
hotness: number
inputTokens: number
maxDepthFromHere: number
outputTokens: number
totalDuration: number
totalTools: number
}
export interface DelegationStatus {
active: {
depth?: number
goal?: string
model?: null | string
parent_id?: null | string
started_at?: number
status?: string
subagent_id?: string
tool_count?: number
}[]
max_concurrent_children?: number
max_spawn_depth?: number
paused: boolean
} }
export interface ApprovalReq { export interface ApprovalReq {