/swarm opens a live tree of subagents: who spawned whom, status, tool-call count and what each is doing. A status pin counts the live ones.

hooks/register.tsx 98 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { buildRows, counts, doingOf, elapsed, isFailed, isLive, summary, type Row, type Seen } from './model'
4
5const PANE = 'swarm-map'
6// Lavender Ultracode family: accent, bright, muted, white for emphasis, red only for failures.
7const ACCENT = '#A99BF5'
8const BRIGHT = '#D6CFFF'
9const MUTED = '#8A84A8'
10const FAIL = '#EB5F57'
11
12let rows: Row[] = []
13let seen: Record<string, Seen> = {}
14let sig = ''
15
16async function poll($: EngineInterface) {
17 const now = await $.clock.now()
18 const list = await $.agent.list()
19 for (const a of list) seen[a.id] ??= { firstAt: now, tools: 0 }
20 rows = buildRows(list, seen, now)
21 const live = counts(rows).live
22 $.ui.status(live > 0 ? `swarm ${live} live` : undefined)
23 // Redraw only when something visible moved; elapsed times tick with each change.
24 const next = JSON.stringify(rows.map(r => [r.id, r.status, r.tools, r.doing]))
25 if (next !== sig) {
26 sig = next
27 $.ui.invalidate('ui.render')
28 }
29}
30
31export const register: Register = on => {
32 on('session.start', async ($, e, next) => {
33 rows = []
34 seen = {}
35 sig = ''
36 await $.command.register({ name: 'swarm', description: 'Live tree of subagents: who spawned whom, status, tool calls, what each is doing' })
37 $.clock.every(3000, () => void poll($).catch(() => undefined))
38 return next(e)
39 })
40
41 on('command.run', { command: 'swarm' }, async $ => {
42 await $.ui.open({ id: PANE, title: 'Swarm' })
43 await poll($).catch(() => undefined)
44 return { text: rows.length ? `Swarm: ${summary(rows)}` : 'Swarm open. No subagents this session yet.' }
45 })
46
47 // Each subagent's loop reports its own tool calls (agentId); the main loop has none.
48 on('tool.call', async ($, e, next) => {
49 if (e.agentId) {
50 const now = await $.clock.now()
51 const s = (seen[e.agentId] ??= { firstAt: now, tools: 0 })
52 s.tools += 1
53 s.doing = doingOf(e.tool, e as unknown as Record<string, unknown>)
54 }
55 return next(e)
56 })
57
58 on('agent.spawn', async ($, e, next) => {
59 const r = await next(e)
60 void poll($).catch(() => undefined)
61 return r
62 })
63
64 on('ui.render', { component: 'Pane', requestId: PANE }, ($, e) => {
65 const { Box, Text } = $.ui.resolve(e)
66 if (rows.length === 0) return <Text color={MUTED}>No subagents yet.</Text>
67 const room = Math.max(3, (e.viewport?.rows ?? 24) - 5)
68 return (
69 <Box flexDirection="column">
70 <Text color={ACCENT} bold>
71 {summary(rows)}
72 </Text>
73 {rows.slice(0, room).map(r => (
74 <Box key={r.id} flexDirection="column">
75 <Text wrap="truncate-end">
76 {' '.repeat(r.depth)}
77 <Text color={isLive(r.status) ? BRIGHT : isFailed(r.status) ? FAIL : MUTED}>{isLive(r.status) ? '●' : isFailed(r.status) ? '✗' : '✓'} </Text>
78 <Text color="#FFFFFF" bold={isLive(r.status)}>
79 {r.label}
80 </Text>
81 <Text color={MUTED}>
82 {' '}
83 {r.type} · {r.tools} calls · {elapsed(r.ageMs)}
84 </Text>
85 </Text>
86 {r.doing ? (
87 <Text color={MUTED} wrap="truncate-end">
88 {' '.repeat(r.depth)} {r.doing}
89 </Text>
90 ) : null}
91 </Box>
92 ))}
93 {rows.length > room ? <Text color={MUTED}>+ {rows.length - room} more</Text> : null}
94 </Box>
95 )
96 })
97}
98hooks/model.ts 87 lines1import type { AgentInfo } from 'claude-code'
2
3// Pure model: no `$`, so the tests drive it directly. Our own small take on a subagent map;
4// it only reads what the engine reports (`$.agent.list()` and each loop's `tool.call`).
5
6export type Seen = { firstAt: number; tools: number; doing?: string }
7export type Row = { id: string; depth: number; label: string; type: string; status: string; tools: number; doing?: string; ageMs: number }
8
9export const isLive = (s: string) => s === 'running' || s === 'pending' || s === 'starting'
10export const isFailed = (s: string) => s === 'failed' || s === 'killed' || s === 'error'
11
12const base = (p: unknown) => (typeof p === 'string' ? (p.split('/').filter(Boolean).pop() ?? p) : '')
13const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
14
15/** A tool call in a few plain words. */
16export const doingOf = (tool: string, input: Record<string, unknown>): string => {
17 const s = (k: string) => (typeof input[k] === 'string' ? (input[k] as string) : '')
18 switch (tool) {
19 case 'Bash':
20 return `running ${clip(s('description') || s('command').split('\n')[0]!, 50)}`
21 case 'Read':
22 return `reading ${base(input.file_path)}`
23 case 'Write':
24 case 'Edit':
25 case 'MultiEdit':
26 return `editing ${base(input.file_path)}`
27 case 'Grep':
28 case 'Glob':
29 return `searching ${clip(s('pattern'), 36)}`
30 case 'WebFetch':
31 case 'WebSearch':
32 return 'on the web'
33 default:
34 return clip(tool.startsWith('mcp__') ? (tool.split('__').pop() ?? tool) : tool, 40)
35 }
36}
37
38/** The spawn tree, depth first, siblings in the order the engine listed them. An unknown parent counts as the root. */
39export const buildRows = (list: readonly AgentInfo[], seen: Readonly<Record<string, Seen>>, now: number): Row[] => {
40 const ids = new Set(list.map(a => a.id))
41 const kids = new Map<string, AgentInfo[]>()
42 for (const a of list) {
43 const p = a.parentId && ids.has(a.parentId) && a.parentId !== a.id ? a.parentId : ''
44 kids.set(p, [...(kids.get(p) ?? []), a])
45 }
46 const rows: Row[] = []
47 const done = new Set<string>()
48 const walk = (parent: string, depth: number) => {
49 for (const a of kids.get(parent) ?? []) {
50 if (done.has(a.id)) continue // a parent cycle must not loop
51 done.add(a.id)
52 const s = seen[a.id]
53 rows.push({
54 id: a.id,
55 depth,
56 label: a.name || a.description || a.type,
57 type: a.type,
58 status: a.status,
59 tools: s?.tools ?? 0,
60 doing: isLive(a.status) ? s?.doing : undefined,
61 ageMs: s ? now - s.firstAt : 0,
62 })
63 walk(a.id, depth + 1)
64 }
65 }
66 walk('', 0)
67 return rows
68}
69
70export const counts = (rows: readonly Row[]) => ({
71 live: rows.filter(r => isLive(r.status)).length,
72 failed: rows.filter(r => isFailed(r.status)).length,
73 done: rows.filter(r => !isLive(r.status) && !isFailed(r.status)).length,
74})
75
76export const summary = (rows: readonly Row[]): string => {
77 const c = counts(rows)
78 return `${c.live} live · ${c.done} done${c.failed ? ` · ${c.failed} failed` : ''}`
79}
80
81export const elapsed = (ms: number): string => {
82 const s = Math.max(0, Math.round(ms / 1000))
83 if (s < 60) return `${s}s`
84 const m = Math.floor(s / 60)
85 return m < 60 ? `${m}m ${String(s % 60).padStart(2, '0')}s` : `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
86}
87