Mission control for subagents and agent teams: who is running, what each is doing, who spawned whom, who is talking to whom, and how they overlap

Mission control for subagents and agent teams: who is running, what each is doing, who spawned whom, who is talking to whom, and how they overlap.

/swarm opens a live pane with an orchestration tree (lead at the top, every subagent nested under whoever spawned it, with its type, model, current action and tool-call count), a timeline of how the agents overlap, arcs and a log for messages between agents, and an activity feed. A status line shows the live count while agents run, and a toast sums things up when the swarm stands down.
In a Claude Code session (2.1.287 or later):
/plugin marketplace add OneWave-AI/claude-code-mods
/plugin install swarm@claude-code-mods
Or load this folder for one session: claude --plugin-dir ./swarm
| Mod | Network | Runs processes | Files | Calls a model | Sends data anywhere |
|---|---|---|---|---|---|
| swarm | No | No | No | No | No |
It reads agent state through $.agent.list and the agent.spawn, tool.call, session.send and session.receive events. Run claude plugin validate ./swarm to list every event it hooks and every call it makes. See the repository README for the full security notes.
Part of claude-code-mods by OneWave AI. MIT licensed.
hooks/register.tsx 350 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Agent, Beat, Lead, Msg } from '../types'
5import { swarmSvg } from './card'
6import {
7 addBeat,
8 addMsg,
9 ago,
10 elapsed,
11 finish,
12 isFailed,
13 isLive,
14 labelOf,
15 MAIN,
16 nameOf,
17 reconcile,
18 resolve,
19 shortModel,
20 statusWord,
21 summary,
22 toolEnd,
23 toolStart,
24 tree,
25 upsert,
26} from './model'
27
28const PANE = 'swarm'
29const TITLE = 'Swarm'
30const POLL_MS = 2000
31
32const GOLD = '#c2a87e'
33const GOLD_HI = '#d4a44a'
34const BLUE = '#61A5FA'
35const RED = '#e5484d'
36const INK = '#0B1121'
37
38const agentsAtom = atom({ plugin: 'swarm', key: 'agents' } as const, [] as Agent[])
39const leadAtom = atom({ plugin: 'swarm', key: 'lead' } as const, { busy: false, tools: 0, lastAt: 0, startedAt: 0 } as Lead)
40const msgsAtom = atom({ plugin: 'swarm', key: 'msgs' } as const, [] as Msg[])
41const beatsAtom = atom({ plugin: 'swarm', key: 'beats' } as const, [] as Beat[])
42const tickAtom = atom({ plugin: 'swarm', key: 'tick' } as const, 0)
43
44let lastStatus: string | undefined
45let wasLive = 0
46
47const beat = async ($: EngineInterface, who: string, kind: Beat['kind'], text: string) => {
48 const at = await $.clock.now()
49 await update($, beatsAtom, list => addBeat(list, { at, who, kind, text }))
50}
51
52/** "◇ swarm 3 live · 5 done" while anything has run this session. */
53const pushStatus = async ($: EngineInterface) => {
54 const agents = await read($, agentsAtom)
55 const now = await $.clock.now()
56 const s = summary(agents, now)
57 const text = agents.length
58 ? `◇ swarm ${s.live ? `${s.live} live · ` : ''}${s.done} done${s.failed ? ` · ${s.failed} failed` : ''}`
59 : undefined
60 if (s.live === 0 && wasLive > 0 && agents.length > 1) {
61 $.ui.toast(`Swarm stood down: ${agents.length} agents, ${s.tools} tool calls, ${s.first !== undefined && s.last !== undefined ? elapsed(s.last - s.first) : ''} wall. /swarm`, {
62 timeoutMs: 7000,
63 })
64 }
65 wasLive = s.live
66 if (text === lastStatus) return
67 lastStatus = text
68 $.ui.status(text)
69}
70
71/** One read of `$.agent.list()` folded into the tree; redraws only when something moved. */
72const poll = async ($: EngineInterface) => {
73 const now = await $.clock.now()
74 const listed = await $.agent.list().catch(() => [])
75 const prev = await read($, agentsAtom)
76 const { agents, finished } = reconcile(prev, listed, now)
77 if (JSON.stringify(agents) !== JSON.stringify(prev)) await update($, agentsAtom, () => agents)
78 for (const a of finished) await beat($, a.name || a.label, isFailed(a.status) ? 'fail' : 'done', `${statusWord(a.status)} in ${elapsed(now - a.startedAt)}, ${a.tools} tool calls`)
79 if (agents.some(a => isLive(a.status))) await update($, tickAtom, n => n + 1)
80 await pushStatus($)
81}
82
83export const register: Register = on => {
84 on('session.start', async ($, e, next) => {
85 await $.command.register({ name: 'swarm', description: 'Swarm: live map of subagents and agent teams, who is doing what, who spawned whom, who is talking' })
86 const now = await $.clock.now()
87 await update($, leadAtom, l => (l.startedAt ? l : { ...l, startedAt: now, lastAt: now }))
88 $.clock.every(POLL_MS, () => void poll($).catch(() => undefined))
89 return next(e)
90 })
91
92 on('command.run', { command: 'swarm' }, async $ => {
93 await $.ui.open({ id: PANE, title: TITLE })
94 await poll($).catch(() => undefined)
95 const agents = await read($, agentsAtom)
96 const s = summary(agents, await $.clock.now())
97 return { text: agents.length ? `Swarm open: ${s.live} live, ${s.done} done, ${s.failed} failed.` : 'Swarm open. No agents yet this session.' }
98 })
99
100 // A spawn: who launched it, what it runs as, on which model.
101 on('agent.spawn', async ($, e, next) => {
102 const r = await next(e)
103 const now = await $.clock.now()
104 if (r.deny !== undefined) {
105 await beat($, e.name || e.description, 'deny', `spawn refused: ${r.deny}`)
106 return r
107 }
108 if (r.agentId) {
109 const id = r.agentId
110 await update($, agentsAtom, list =>
111 upsert(list, id, now, {
112 label: e.description,
113 type: e.fork ? 'fork' : e.subagentType,
114 ...(e.name ? { name: e.name } : {}),
115 ...(e.parentAgentId ? { parentId: e.parentAgentId } : {}),
116 model: r.model,
117 bg: e.background,
118 toolUseId: e.tool_use_id,
119 status: 'running',
120 startedAt: now,
121 }),
122 )
123 const agents = await read($, agentsAtom)
124 const by = e.parentAgentId ? nameOf(agents, e.parentAgentId) : 'lead'
125 await beat($, e.name || e.description, 'spawn', `spawned by ${by} as ${e.fork ? 'fork' : e.subagentType}${r.model ? ` on ${shortModel(r.model)}` : ''}`)
126 await pushStatus($)
127 }
128 return r
129 })
130
131 // Every tool call, in whichever loop: what each agent is doing right now.
132 on('tool.call', async ($, e, next) => {
133 const input = e as unknown as Record<string, unknown>
134 const who = e.agentId
135 const now = await $.clock.now()
136 const doing = labelOf(e.tool, input)
137 if (who) await update($, agentsAtom, list => toolStart(list, who, e.tool, doing, now))
138 else await update($, leadAtom, l => ({ ...l, doing, doingTool: e.tool, busy: true, tools: l.tools + 1, lastAt: now }))
139 let ok = false
140 try {
141 const ran = await next(e)
142 ok = ran.deny === undefined && ran.isError !== true
143 return ran
144 } finally {
145 const end = await $.clock.now()
146 if (who) {
147 await update($, agentsAtom, list => toolEnd(list, who, ok, end))
148 if (!ok) {
149 const agents = await read($, agentsAtom)
150 await beat($, nameOf(agents, who), 'fail', `${doing} failed`)
151 }
152 } else await update($, leadAtom, l => ({ ...l, busy: false, lastAt: end }))
153 // A foreground Agent call returning means that agent is finished.
154 if (e.tool === 'Agent') {
155 const agents = await read($, agentsAtom)
156 const a = agents.find(x => x.toolUseId === e.tool_use_id)
157 if (a && !a.bg && isLive(a.status)) {
158 await update($, agentsAtom, list => finish(list, a.id, ok ? 'completed' : 'failed', end))
159 await beat($, a.name || a.label, ok ? 'done' : 'fail', `${ok ? 'done' : 'failed'} in ${elapsed(end - a.startedAt)}, ${a.tools} tool calls`)
160 await pushStatus($)
161 }
162 }
163 }
164 })
165
166 // Messages between agents, as they leave.
167 on('session.send', async ($, e, next) => {
168 const r = await next(e)
169 if (r.isDelivered) {
170 const at = await $.clock.now()
171 const agents = await read($, agentsAtom)
172 const from = e.agentId ?? MAIN
173 const to = resolve(agents, e.to)
174 await update($, msgsAtom, list => addMsg(list, { at, from, to, text: e.text }))
175 await beat($, nameOf(agents, from), 'msg', `→ ${nameOf(agents, to)}: ${e.text.replace(/\s+/g, ' ')}`)
176 }
177 return r
178 })
179
180 // Teammates in their own panes write to the team inbox; this process never saw them send.
181 on('session.receive', async ($, e, next) => {
182 const r = await next(e)
183 const o = e.origin as { teammate?: string; isVerified?: boolean }
184 if (typeof o.teammate === 'string' && o.isVerified === false) {
185 const at = await $.clock.now()
186 const agents = await read($, agentsAtom)
187 const from = resolve(agents, o.teammate)
188 const to = e.agentId ?? MAIN
189 await update($, msgsAtom, list => addMsg(list, { at, from, to, text: e.text }))
190 await beat($, o.teammate, 'msg', `→ ${nameOf(agents, to)}: ${e.text.replace(/\s+/g, ' ')}`)
191 }
192 return r
193 })
194
195 on('turn.complete', async ($, e, next) => {
196 const id = e.agentId
197 const out = e.usage?.output_tokens ?? 0
198 if (id && out) {
199 const now = await $.clock.now()
200 await update($, agentsAtom, list => {
201 const a = list.find(x => x.id === id)
202 return a ? upsert(list, id, now, { outTokens: a.outTokens + out }) : list
203 })
204 }
205 if (!id) await update($, leadAtom, l => ({ ...l, busy: false, doing: undefined }))
206 return next(e)
207 })
208
209 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
210 const { Box, Text, Button } = $.ui.resolve(e)
211 await read($, tickAtom)
212 const now = await $.clock.now()
213 const agents = await read($, agentsAtom)
214 const lead = await read($, leadAtom)
215 const msgs = await read($, msgsAtom)
216 const beats = await read($, beatsAtom)
217 const clear = () => {
218 void (async () => {
219 const live = (await read($, agentsAtom)).filter(a => isLive(a.status))
220 await update($, agentsAtom, () => live)
221 await update($, msgsAtom, () => [])
222 await update($, beatsAtom, () => [])
223 await pushStatus($)
224 })()
225 }
226 const controls = (
227 <Box key="controls" gap={2} flexWrap="wrap">
228 <Button key="refresh" label="refresh" hotkey="r" onPress={() => void poll($).catch(() => undefined)} />
229 <Button key="clear" label="clear finished" hotkey="c" onPress={clear} />
230 </Box>
231 )
232 const s = summary(agents, now)
233
234 if (e.surface !== 'terminal') {
235 const { Svg } = $.ui.resolve(e)
236 const card = swarmSvg({ now, agents, lead, msgs, beats })
237 const alt = `Swarm: ${s.live} live, ${s.done} done, ${s.failed} failed agents. ${tree(agents)
238 .slice(0, 8)
239 .map(r => `${r.agent.name || r.agent.label} (${statusWord(r.agent.status)}${r.agent.doing ? `, ${r.agent.doing}` : ''})`)
240 .join('; ')}`
241 return (
242 <Box flexDirection="column" gap={1}>
243 <Svg key="swarm" source={card.source} alt={alt} width={card.width} height={card.height} />
244 {controls}
245 </Box>
246 )
247 }
248
249 // Terminal: the same tree in cells.
250 const width = Math.max(36, (e.props.bodyColumns || 60) - 2)
251 const rows = tree(agents)
252 const glyph = (a: Agent) => (isLive(a.status) ? (a.busy ? '●' : '◉') : isFailed(a.status) ? '×' : '✓')
253 const tone = (a: Agent) => (isLive(a.status) ? BLUE : isFailed(a.status) ? RED : GOLD)
254 const t0 = s.first ?? now
255 const span = Math.max(1000, now - t0)
256 const laneW = Math.max(10, Math.min(40, width - 18))
257 const lane = (a: Agent) => {
258 const x1 = Math.floor(((a.startedAt - t0) / span) * laneW)
259 const x2 = Math.max(x1 + 1, Math.ceil((((a.endedAt ?? now) - t0) / span) * laneW))
260 return ' '.repeat(x1) + '━'.repeat(Math.min(laneW, x2) - x1) + ' '.repeat(Math.max(0, laneW - Math.min(laneW, x2)))
261 }
262
263 return (
264 <Box flexDirection="column" paddingX={1} gap={1}>
265 <Box justifyContent="space-between">
266 <Text>
267 <Text bold color={GOLD}>SWARM</Text>
268 <Text dimColor>{` ${agents.length} agents · ${s.tools + lead.tools} tool calls · peak ${s.peak} parallel`}</Text>
269 </Text>
270 {s.live ? <Text backgroundColor={BLUE} color={INK} bold>{` ${s.live} LIVE `}</Text> : <Text dimColor>{agents.length ? 'STOOD DOWN' : 'IDLE'}</Text>}
271 </Box>
272
273 <Box flexDirection="column">
274 <Text dimColor bold>ORCHESTRATION</Text>
275 <Text>
276 <Text color={GOLD_HI}>◆ </Text>
277 <Text bold>Lead</Text>
278 <Text color={lead.busy ? BLUE : undefined} dimColor={!lead.busy}>{` ${(lead.doing ?? 'waiting for you').slice(0, width - 12)}`}</Text>
279 </Text>
280 {rows.length === 0 && <Text dimColor> No agents yet. Spawn subagents or a team and they appear here live.</Text>}
281 {rows.slice(0, 20).map(r => {
282 const a = r.agent
283 const prefix = r.trail.map(t => (t ? '│ ' : ' ')).join('') + (r.last ? '└─' : '├─')
284 const nm = a.name || a.label
285 const right = isLive(a.status) ? elapsed(now - a.startedAt) : statusWord(a.status)
286 const doing = a.doing ?? (isLive(a.status) ? 'thinking' : '')
287 const room = Math.max(8, width - prefix.length - nm.length - right.length - 8)
288 return (
289 <Box key={a.id} justifyContent="space-between">
290 <Text>
291 <Text dimColor>{prefix}</Text>
292 <Text color={tone(a)}>{` ${glyph(a)} `}</Text>
293 <Text bold>{nm.slice(0, 24)}</Text>
294 <Text dimColor>{` ${a.type === 'general-purpose' ? 'general' : a.type}`}</Text>
295 <Text color={isLive(a.status) && a.busy ? BLUE : undefined} dimColor={!(isLive(a.status) && a.busy)}>{` ${doing.slice(0, room)}`}</Text>
296 </Text>
297 <Text color={tone(a)}>{`${a.tools}× ${right}`}</Text>
298 </Box>
299 )
300 })}
301 </Box>
302
303 {rows.length > 0 && (
304 <Box flexDirection="column">
305 <Text dimColor bold>TIMELINE</Text>
306 {rows.slice(0, 12).map(r => (
307 <Text key={r.agent.id}>
308 <Text dimColor>{(r.agent.name || r.agent.label).slice(0, 12).padEnd(13)}</Text>
309 <Text color={tone(r.agent)}>{lane(r.agent)}</Text>
310 </Text>
311 ))}
312 </Box>
313 )}
314
315 {msgs.length > 0 && (
316 <Box flexDirection="column">
317 <Text dimColor bold>{`COMMS ${msgs.length}`}</Text>
318 {msgs
319 .slice(-4)
320 .reverse()
321 .map((m, i) => (
322 <Text key={`m${i}`}>
323 <Text color={GOLD_HI} bold>{`${nameOf(agents, m.from)} → ${nameOf(agents, m.to)}`}</Text>
324 <Text dimColor>{` ${m.text.replace(/\s+/g, ' ').slice(0, Math.max(10, width - 30))} ${ago(now - m.at)}`}</Text>
325 </Text>
326 ))}
327 </Box>
328 )}
329
330 {beats.length > 0 && (
331 <Box flexDirection="column">
332 <Text dimColor bold>ACTIVITY</Text>
333 {beats
334 .slice(-6)
335 .reverse()
336 .map((b, i) => (
337 <Text key={`b${i}`}>
338 <Text color={b.kind === 'fail' || b.kind === 'deny' ? RED : b.kind === 'spawn' ? BLUE : b.kind === 'msg' ? GOLD_HI : GOLD}>{'▍'}</Text>
339 <Text bold>{` ${b.who.slice(0, 18)}`}</Text>
340 <Text dimColor>{` ${b.text.slice(0, Math.max(10, width - b.who.length - 10))} ${ago(now - b.at)}`}</Text>
341 </Text>
342 ))}
343 </Box>
344 )}
345 {controls}
346 </Box>
347 )
348 })
349}
350hooks/card.ts 303 lines1/**
2 * The desktop/editor/mobile view: the whole swarm as one SVG. Loop animations only (no entry animations): the card
3 * redraws on every poll, and an entry animation would replay each time.
4 * Gotcha: a CSS animation on `transform` replaces a `transform=` attribute on the same element. Position with an
5 * outer <g>, animate an inner one.
6 */
7
8import type { Agent, Beat, Lead, Msg } from '../types'
9import { ago, elapsed, isFailed, isLive, MAIN, nameOf, shortModel, statusWord, summary, tree } from './model'
10
11const W = 400
12const PAD = 16
13const PANEL = '#101a2f'
14const LINE = '#22304d'
15const BLUE = '#61A5FA'
16const GOLD = '#c2a87e'
17const GOLD_HI = '#d4a44a'
18const PAPER = '#E8E2D6'
19const RED = '#e5484d'
20const INK = '#0B1121'
21
22const RH = 42
23const IND = 18
24const MAX_ROWS = 16
25
26const esc = (s: string) => s.replace(/[<>&"']/g, c => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' })[c]!)
27const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, Math.max(1, n - 1))}…` : s)
28const r1 = (n: number) => Math.round(n * 10) / 10
29
30const CSS = `
31text{font-family:-apple-system,BlinkMacSystemFont,Inter,'Segoe UI',sans-serif;fill:${PAPER}}
32.m{font-family:'SF Mono',ui-monospace,Menlo,monospace}
33.lbl{font-size:9px;font-weight:700;letter-spacing:1.6px;opacity:.5}
34.dim{opacity:.5}
35.ping{animation:sw-ping 1.8s cubic-bezier(0,0,.2,1) infinite;transform-box:fill-box;transform-origin:center}
36@keyframes sw-ping{0%{transform:scale(1);opacity:.75}100%{transform:scale(3);opacity:0}}
37.march{animation:sw-march .9s linear infinite}
38@keyframes sw-march{to{stroke-dashoffset:-12}}
39.shim{animation:sw-shim 1.3s ease-in-out infinite}
40@keyframes sw-shim{0%,100%{opacity:.5}50%{opacity:1}}
41.breathe{animation:sw-breathe 3.2s ease-in-out infinite}
42@keyframes sw-breathe{0%,100%{opacity:.35}50%{opacity:.9}}
43.spin{animation:sw-spin 10s linear infinite;transform-box:fill-box;transform-origin:center}
44@keyframes sw-spin{to{transform:rotate(360deg)}}
45.dot1{animation:sw-dot 1.4s infinite}.dot2{animation:sw-dot 1.4s .2s infinite}.dot3{animation:sw-dot 1.4s .4s infinite}
46@keyframes sw-dot{0%,80%,100%{opacity:.2}40%{opacity:1}}
47.hl{opacity:0;transition:opacity .15s}.row:hover .hl{opacity:.06}
48.arc{transition:opacity .15s}.arc:hover{opacity:1 !important;stroke-width:2.4}
49.lane:hover .hl{opacity:.06}
50`
51
52export type SwarmView = { now: number; agents: Agent[]; lead: Lead; msgs: Msg[]; beats: Beat[] }
53
54const label = (x: number, y: number, text: string, anchor = 'start') =>
55 `<text class="lbl" x="${x}" y="${y}" text-anchor="${anchor}">${esc(text)}</text>`
56
57const colorOf = (a: Agent) => (isFailed(a.status) ? RED : isLive(a.status) ? BLUE : GOLD)
58
59const stat = (x: number, y: number, value: string, name: string, color: string, live = false) =>
60 `<g transform="translate(${x} ${y})">` +
61 `<text x="0" y="0" font-size="22" font-weight="700" style="fill:${color}" class="m${live ? ' shim' : ''}">${esc(value)}</text>` +
62 `<text class="lbl" x="0" y="14">${esc(name)}</text></g>`
63
64/** The node glyph for one agent. */
65const node = (x: number, y: number, a: Agent) => {
66 const c = colorOf(a)
67 let s = ''
68 if (isLive(a.status)) {
69 s += `<g transform="translate(${x} ${y})"><circle class="ping" r="5" fill="none" stroke="${c}" stroke-width="1.5"/></g>`
70 s += `<circle cx="${x}" cy="${y}" r="5" fill="${c}"/>`
71 } else if (isFailed(a.status)) {
72 s += `<circle cx="${x}" cy="${y}" r="5.5" fill="${INK}" stroke="${RED}" stroke-width="1.5"/>`
73 s += `<path d="M${x - 2.4} ${y - 2.4}L${x + 2.4} ${y + 2.4}M${x + 2.4} ${y - 2.4}L${x - 2.4} ${y + 2.4}" stroke="${RED}" stroke-width="1.5" stroke-linecap="round"/>`
74 } else {
75 s += `<circle cx="${x}" cy="${y}" r="5.5" fill="${INK}" stroke="${GOLD}" stroke-width="1.5"/>`
76 s += `<path d="M${x - 2.4} ${y}L${x - 0.6} ${y + 1.9}L${x + 2.6} ${y - 1.9}" fill="none" stroke="${GOLD}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`
77 }
78 return s
79}
80
81const pill = (x: number, y: number, text: string, color: string) => {
82 const w = text.length * 5.6 + 10
83 return (
84 `<rect x="${x}" y="${y - 9}" width="${r1(w)}" height="13" rx="2" fill="${color}" fill-opacity=".14" stroke="${color}" stroke-opacity=".35"/>` +
85 `<text x="${r1(x + w / 2)}" y="${y + 1}" font-size="8.5" font-weight="700" letter-spacing=".6" text-anchor="middle" style="fill:${color}">${esc(text.toUpperCase())}</text>`
86 )
87}
88
89export const swarmSvg = (v: SwarmView): { source: string; width: number; height: number } => {
90 const { now, agents, lead, msgs, beats } = v
91 const sum = summary(agents, now)
92 const rows = tree(agents)
93 const shown = rows.slice(0, MAX_ROWS)
94 const out: string[] = []
95 let y = PAD
96
97 // Header
98 const busy = sum.live > 0
99 out.push(label(PAD, y + 8, 'SWARM'))
100 out.push(
101 `<text x="${PAD + 54}" y="${y + 8}" font-size="9" font-weight="700" letter-spacing="1.4" style="fill:${busy ? BLUE : GOLD}" class="${busy ? 'shim' : ''}">${busy ? 'ORCHESTRATING' : agents.length ? 'STANDING DOWN' : 'IDLE'}</text>`,
102 )
103 const wall = sum.first !== undefined && sum.last !== undefined ? elapsed(sum.last - sum.first) : '0s'
104 out.push(`<text x="${W - PAD}" y="${y + 8}" font-size="10" text-anchor="end" class="m dim">${esc(wall)} wall</text>`)
105 y += 34
106 const cols = [
107 [String(sum.live), 'LIVE', BLUE, sum.live > 0],
108 [String(sum.done), 'DONE', GOLD, false],
109 [String(sum.failed), 'FAILED', sum.failed ? RED : PAPER, false],
110 [String(sum.tools + lead.tools), 'CALLS', PAPER, false],
111 [String(sum.peak), 'PARALLEL', GOLD_HI, false],
112 ] as const
113 const cw = (W - PAD * 2) / cols.length
114 cols.forEach(([val, name, color, live], i) => out.push(stat(PAD + i * cw, y, val, name, color, live)))
115 y += 28
116
117 // Orchestration tree
118 y += 14
119 out.push(`<line x1="${PAD}" x2="${W - PAD}" y1="${y}" y2="${y}" stroke="${LINE}"/>`)
120 y += 18
121 out.push(label(PAD, y, 'ORCHESTRATION'))
122 out.push(label(W - PAD, y, 'who spawned whom', 'end'))
123 y += 10
124
125 const LX = PAD + 8
126 const col = (d: number) => LX + d * IND
127 const GUT = W - PAD - 6 // comms rail x
128 const yOf = new Map<string, number>()
129
130 // Lead row
131 const leadY = y + 16
132 yOf.set(MAIN, leadY)
133 out.push(`<g transform="translate(${LX} ${leadY})"><g class="spin"><rect x="-6.5" y="-6.5" width="13" height="13" rx="2" fill="none" stroke="${GOLD}" stroke-opacity=".5"/></g></g>`)
134 out.push(`<rect x="${LX - 4}" y="${leadY - 4}" width="8" height="8" rx="1" fill="${GOLD_HI}" transform="rotate(45 ${LX} ${leadY})"/>`)
135 out.push(`<text x="${LX + 16}" y="${leadY - 2}" font-size="12.5" font-weight="700">Lead</text>`)
136 out.push(pill(LX + 52, leadY - 2, 'orchestrator', GOLD))
137 out.push(
138 `<text x="${LX + 16}" y="${leadY + 13}" font-size="10.5" class="${lead.busy ? 'shim' : 'dim'}" style="fill:${lead.busy ? BLUE : PAPER}">${esc(clip(lead.doing ?? 'waiting for you', 46))}</text>`,
139 )
140 out.push(`<text x="${GUT - 20}" y="${leadY - 2}" font-size="10" text-anchor="end" class="m dim">${lead.tools} calls</text>`)
141 y = leadY + RH / 2 + 4
142
143 if (!rows.length) {
144 out.push(
145 `<text x="${LX + 16}" y="${y + 14}" font-size="11" class="dim">No agents yet. Spawn subagents or a team and they</text>` +
146 `<text x="${LX + 16}" y="${y + 29}" font-size="11" class="dim">appear here live, wired to whoever launched them</text>` +
147 `<g font-size="16" font-weight="700" style="fill:${BLUE}"><text class="dot1" x="${LX + 16}" y="${y + 48}">·</text><text class="dot2" x="${LX + 24}" y="${y + 48}">·</text><text class="dot3" x="${LX + 32}" y="${y + 48}">·</text></g>`,
148 )
149 y += 60
150 }
151
152 for (const r of shown) {
153 const a = r.agent
154 const cy = y + RH / 2
155 yOf.set(a.id, cy)
156 const c = colorOf(a)
157 const nx = col(r.depth + 1)
158 const px = col(r.depth)
159 // ancestor rails
160 r.trail.forEach((cont, d) => {
161 if (cont) out.push(`<line x1="${col(d + 1)}" x2="${col(d + 1)}" y1="${y}" y2="${y + RH}" stroke="${LINE}" stroke-width="1.2"/>`)
162 })
163 // own elbow
164 const live = isLive(a.status)
165 const dash = live ? ` stroke-dasharray="4 2" class="march"` : ''
166 out.push(`<path d="M${px} ${y - 4} V${cy - 6} Q${px} ${cy} ${px + 6} ${cy} H${nx - 7}" fill="none" stroke="${live ? BLUE : LINE}" stroke-width="1.4"${dash} stroke-opacity="${live ? 0.9 : 1}"/>`)
167 if (!r.last) out.push(`<line x1="${px}" x2="${px}" y1="${cy - 6}" y2="${y + RH}" stroke="${LINE}" stroke-width="1.2"/>`)
168
169 const tx = nx + 13
170 const room = Math.max(12, Math.floor((GUT - 26 - tx) / 6.3))
171 const nm = a.name || a.label
172 const tip = [nm, a.type, statusWord(a.status), a.model ? shortModel(a.model) : '', `${a.tools} tool calls`, a.errors ? `${a.errors} errors` : '', a.doing ?? '']
173 .filter(Boolean)
174 .join(' · ')
175 out.push(`<g class="row"><title>${esc(tip)}</title><rect class="hl" x="${PAD - 4}" y="${y + 1}" width="${W - PAD * 2 + 8}" height="${RH - 2}" rx="3" fill="${PAPER}"/>`)
176 out.push(node(nx, cy - 5, a))
177 const nameText = clip(nm, Math.min(room, 26))
178 out.push(`<text x="${tx}" y="${cy - 2}" font-size="12" font-weight="700">${esc(nameText)}</text>`)
179 const pillX = tx + nameText.length * 6.6 + 6
180 if (pillX < GUT - 90) out.push(pill(pillX, cy - 2, a.type === 'general-purpose' ? 'general' : clip(a.type, 14), c))
181 const right = isLive(a.status) ? elapsed(now - a.startedAt) : statusWord(a.status)
182 out.push(`<text x="${GUT - 20}" y="${cy - 2}" font-size="10" text-anchor="end" class="m" style="fill:${c}">${esc(right)}</text>`)
183 const busyLine = live && a.busy
184 const doing = a.doing ? (live ? a.doing : `last: ${a.doing}`) : live ? 'thinking' : a.label !== nm ? a.label : ''
185 out.push(
186 `<text x="${tx}" y="${cy + 13}" font-size="10.5" class="${busyLine ? 'shim' : 'dim'}" style="fill:${busyLine ? BLUE : PAPER}">${esc(clip(doing, room))}</text>`,
187 )
188 const meta = [a.tools ? `${a.tools}×` : '', shortModel(a.model)].filter(Boolean).join(' ')
189 out.push(`<text x="${GUT - 20}" y="${cy + 13}" font-size="9.5" text-anchor="end" class="m dim">${esc(meta)}${a.errors ? `<tspan style="fill:${RED}"> ${a.errors}!</tspan>` : ''}</text>`)
190 out.push('</g>')
191 y += RH
192 }
193 if (rows.length > shown.length) {
194 out.push(`<text x="${col(1)}" y="${y + 12}" font-size="10" class="dim">+ ${rows.length - shown.length} more agents</text>`)
195 y += 18
196 }
197
198 // Comms rail: gold arcs between whoever messaged whom
199 const pairs = new Map<string, { m: Msg; n: number }>()
200 for (const m of msgs) {
201 const k = `${m.from}>${m.to}`
202 const p = pairs.get(k)
203 pairs.set(k, { m, n: (p?.n ?? 0) + 1 })
204 }
205 for (const { m, n } of pairs.values()) {
206 const y1 = yOf.get(m.from)
207 const y2 = yOf.get(m.to)
208 if (y1 === undefined || y2 === undefined || y1 === y2) continue
209 const fresh = now - m.at < 20000
210 const bow = Math.min(18, 6 + Math.abs(y2 - y1) / 10)
211 const d = `M${GUT - 8} ${y1 - 5} C${GUT + bow} ${y1 - 5} ${GUT + bow} ${y2 - 5} ${GUT - 8} ${y2 - 5}`
212 const tip = `${nameOf(agents, m.from)} → ${nameOf(agents, m.to)} (${n}): ${m.text}`
213 out.push(
214 `<g><title>${esc(clip(tip, 220))}</title><path class="arc${fresh ? ' march' : ''}" d="${d}" fill="none" stroke="${GOLD_HI}" stroke-width="${Math.min(3, 1 + n * 0.3)}" stroke-linecap="round"${fresh ? ' stroke-dasharray="3 3"' : ''} opacity="${fresh ? 0.95 : 0.45}"/>` +
215 `<circle cx="${GUT - 8}" cy="${y2 - 5}" r="2.4" fill="${GOLD_HI}" opacity="${fresh ? 1 : 0.6}"/></g>`,
216 )
217 }
218
219 // Timeline: how they overlap
220 if (rows.length && sum.first !== undefined) {
221 y += 10
222 out.push(`<line x1="${PAD}" x2="${W - PAD}" y1="${y}" y2="${y}" stroke="${LINE}"/>`)
223 y += 18
224 out.push(label(PAD, y, 'TIMELINE'))
225 out.push(label(W - PAD, y, 'how they overlap', 'end'))
226 y += 10
227 const t0 = sum.first
228 const t1 = Math.max(now, t0 + 1000)
229 const LX0 = PAD + 74
230 const LW = W - PAD - LX0
231 const xOf = (t: number) => LX0 + ((t - t0) / (t1 - t0)) * LW
232 const LH = 11
233 const top = y
234 // quarter grid
235 for (let i = 0; i <= 4; i++) {
236 const gx = LX0 + (LW * i) / 4
237 out.push(`<line x1="${r1(gx)}" x2="${r1(gx)}" y1="${top}" y2="${top + shown.length * (LH + 4)}" stroke="${LINE}" stroke-dasharray="1 3"/>`)
238 }
239 for (const r of shown) {
240 const a = r.agent
241 const c = colorOf(a)
242 const x1 = xOf(a.startedAt)
243 const x2 = Math.max(x1 + 3, xOf(a.endedAt ?? now))
244 out.push(`<g class="lane"><title>${esc(`${a.name || a.label}: ${elapsed((a.endedAt ?? now) - a.startedAt)}, ${a.tools} tool calls`)}</title>`)
245 out.push(`<rect class="hl" x="${PAD - 4}" y="${y - 1}" width="${W - PAD * 2 + 8}" height="${LH + 2}" fill="${PAPER}"/>`)
246 out.push(`<text x="${PAD + r.depth * 6}" y="${y + 9}" font-size="9.5" class="dim">${esc(clip(a.name || a.label, 12 - r.depth))}</text>`)
247 out.push(`<rect x="${r1(x1)}" y="${y + 1}" width="${r1(x2 - x1)}" height="${LH - 2}" rx="2" fill="${c}" fill-opacity="${isLive(a.status) ? 0.85 : 0.55}"${isLive(a.status) ? ' class="shim"' : ''}/>`)
248 out.push('</g>')
249 y += LH + 4
250 }
251 const nx = xOf(now)
252 out.push(`<line x1="${r1(nx)}" x2="${r1(nx)}" y1="${top - 4}" y2="${y}" stroke="${BLUE}" stroke-width="1.2" class="breathe"/>`)
253 out.push(`<text x="${LX0}" y="${y + 10}" font-size="9" class="m dim">${esc(new Date(t0).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }))}</text>`)
254 out.push(`<text x="${W - PAD}" y="${y + 10}" font-size="9" text-anchor="end" class="m" style="fill:${BLUE}">now</text>`)
255 y += 14
256 }
257
258 // Comms feed
259 const recentMsgs = msgs.slice(-4).reverse()
260 if (recentMsgs.length) {
261 y += 10
262 out.push(`<line x1="${PAD}" x2="${W - PAD}" y1="${y}" y2="${y}" stroke="${LINE}"/>`)
263 y += 18
264 out.push(label(PAD, y, 'COMMS'))
265 out.push(label(W - PAD, y, `${msgs.length} messages`, 'end'))
266 y += 6
267 for (const m of recentMsgs) {
268 y += 16
269 const route = `${nameOf(agents, m.from)} → ${nameOf(agents, m.to)}`
270 out.push(`<text x="${PAD}" y="${y}" font-size="10.5"><tspan font-weight="700" style="fill:${GOLD_HI}">${esc(clip(route, 30))}</tspan><tspan class="dim"> ${esc(clip(m.text.replace(/\s+/g, ' '), Math.max(8, 52 - route.length)))}</tspan></text>`)
271 out.push(`<text x="${W - PAD}" y="${y}" font-size="9" text-anchor="end" class="m dim">${ago(now - m.at)}</text>`)
272 }
273 }
274
275 // Activity
276 const recent = beats.slice(-7).reverse()
277 if (recent.length) {
278 y += 14
279 out.push(`<line x1="${PAD}" x2="${W - PAD}" y1="${y}" y2="${y}" stroke="${LINE}"/>`)
280 y += 18
281 out.push(label(PAD, y, 'ACTIVITY'))
282 y += 4
283 for (const b of recent) {
284 y += 15
285 const c = b.kind === 'fail' || b.kind === 'deny' ? RED : b.kind === 'done' ? GOLD : b.kind === 'msg' ? GOLD_HI : b.kind === 'spawn' ? BLUE : PAPER
286 out.push(`<rect x="${PAD}" y="${y - 7}" width="3" height="9" rx="1" fill="${c}"/>`)
287 out.push(`<text x="${PAD + 10}" y="${y}" font-size="10.5"><tspan font-weight="700">${esc(clip(b.who, 18))}</tspan><tspan class="dim"> ${esc(clip(b.text, Math.max(10, 50 - b.who.length)))}</tspan></text>`)
288 out.push(`<text x="${W - PAD}" y="${y}" font-size="9" text-anchor="end" class="m dim">${ago(now - b.at)}</text>`)
289 }
290 }
291
292 const H = y + PAD
293 const source =
294 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">` +
295 `<style>${CSS}</style>` +
296 `<defs><linearGradient id="sw-bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${PANEL}"/><stop offset="1" stop-color="${INK}"/></linearGradient></defs>` +
297 `<rect width="${W}" height="${H}" rx="4" fill="url(#sw-bg)"/>` +
298 `<rect x="0" y="0" width="${W}" height="2" fill="${busy ? BLUE : GOLD}" opacity=".8"${busy ? ' class="shim"' : ''}/>` +
299 out.join('') +
300 `</svg>`
301 return { source, width: W, height: H }
302}
303hooks/model.ts 257 lines1/** Pure swarm logic: no engine, so the tests can drive it directly. */
2
3import type { Agent, Beat, Msg } from '../types'
4
5export const MAIN = 'main'
6const MAX_AGENTS = 60
7const MAX_MSGS = 30
8const MAX_BEATS = 40
9
10export type ListedAgent = { id: string; description: string; type: string; status: string; parentId?: string; spawnedBy?: string; name?: string }
11
12export const isLive = (status: string) => status === 'running' || status === 'pending' || status === 'starting'
13export const isFailed = (status: string) => status === 'failed' || status === 'killed' || status === 'error'
14
15const cap = <T>(list: T[], n: number) => (list.length > n ? list.slice(list.length - n) : list)
16
17export const blank = (id: string, now: number, over: Partial<Agent> = {}): Agent => ({
18 id,
19 label: over.label ?? 'worker',
20 type: over.type ?? 'worker',
21 status: 'running',
22 startedAt: now,
23 tools: 0,
24 errors: 0,
25 busy: false,
26 lastAt: now,
27 mix: {},
28 outTokens: 0,
29 ...over,
30})
31
32/** Insert or merge one agent by id. */
33export const upsert = (list: Agent[], id: string, now: number, patch: Partial<Agent>): Agent[] => {
34 const i = list.findIndex(a => a.id === id)
35 if (i < 0) return cap([...list, blank(id, now, patch)], MAX_AGENTS)
36 const next = list.slice()
37 next[i] = { ...next[i]!, ...patch }
38 return next
39}
40
41/** A tool call opened inside an agent's loop. Unknown ids (workflow workers) get a row of their own. */
42export const toolStart = (list: Agent[], id: string, tool: string, doing: string, now: number): Agent[] => {
43 const base = list.find(a => a.id === id) ?? blank(id, now, { type: 'worker', label: 'worker' })
44 const mix = { ...base.mix, [shortTool(tool)]: (base.mix[shortTool(tool)] ?? 0) + 1 }
45 return upsert(list, id, now, {
46 tools: base.tools + 1,
47 doing,
48 doingTool: tool,
49 busy: true,
50 lastAt: now,
51 mix,
52 })
53}
54
55export const toolEnd = (list: Agent[], id: string, ok: boolean, now: number): Agent[] => {
56 const a = list.find(x => x.id === id)
57 if (!a) return list
58 return upsert(list, id, now, { busy: false, errors: a.errors + (ok ? 0 : 1), lastAt: now })
59}
60
61/** Close an agent: status moves off running, the clock stops. */
62export const finish = (list: Agent[], id: string, status: string, now: number): Agent[] => {
63 const a = list.find(x => x.id === id)
64 if (!a || !isLive(a.status)) return list
65 return upsert(list, id, now, { status, endedAt: now, busy: false })
66}
67
68/**
69 * Fold `$.agent.list()` into what we track. Returns the new list and which ids just finished
70 * (so the caller can toast / log them). An agent missing from the listing keeps its last state.
71 */
72export const reconcile = (list: Agent[], listed: ListedAgent[], now: number): { agents: Agent[]; finished: Agent[] } => {
73 let agents = list
74 const finished: Agent[] = []
75 for (const l of listed) {
76 const had = agents.find(a => a.id === l.id)
77 const teammate = l.type === 'teammate'
78 const patch: Partial<Agent> = {
79 label: l.description || had?.label || l.name || l.type,
80 type: l.type || had?.type || 'worker',
81 status: l.status,
82 ...(l.name ? { name: l.name } : {}),
83 ...(l.parentId ? { parentId: l.parentId } : {}),
84 ...(l.spawnedBy ? { viaPlugin: l.spawnedBy } : {}),
85 }
86 if (had && isLive(had.status) && !isLive(l.status) && !(teammate && l.status === 'idle')) {
87 patch.endedAt = now
88 patch.busy = false
89 finished.push({ ...had, ...patch } as Agent)
90 }
91 if (!had && !isLive(l.status)) patch.endedAt = now
92 agents = upsert(agents, l.id, now, patch)
93 }
94 return { agents, finished }
95}
96
97/** Resolve a SendMessage recipient (a name, an id, "main", "team-lead") to an id we draw. */
98export const resolve = (list: Agent[], to: string): string => {
99 const t = to.trim()
100 if (!t || t === MAIN || t === 'team-lead' || t === 'lead') return MAIN
101 const hit = list.find(a => a.id === t || a.name === t) ?? list.find(a => a.label === t)
102 return hit ? hit.id : t
103}
104
105export const addMsg = (msgs: Msg[], m: Msg) => cap([...msgs, m], MAX_MSGS)
106export const addBeat = (beats: Beat[], b: Beat) => cap([...beats, b], MAX_BEATS)
107
108/** Display name for an id: name, else label, else a short id. */
109export const nameOf = (list: Agent[], id: string): string => {
110 if (id === MAIN) return 'lead'
111 const a = list.find(x => x.id === id)
112 if (!a) return id.length > 10 ? id.slice(0, 8) : id
113 return a.name || a.label
114}
115
116export type Row = { agent: Agent; depth: number; last: boolean; trail: boolean[] }
117
118/**
119 * The orchestration tree, depth-first, children by start time. `trail[d]` says whether the ancestor at depth d
120 * still has siblings below it (so the connector rail keeps going).
121 */
122export const tree = (list: Agent[]): Row[] => {
123 const ids = new Set(list.map(a => a.id))
124 const kids = new Map<string, Agent[]>()
125 for (const a of list) {
126 const p = a.parentId && ids.has(a.parentId) ? a.parentId : MAIN
127 kids.set(p, [...(kids.get(p) ?? []), a])
128 }
129 for (const v of kids.values()) v.sort((x, y) => x.startedAt - y.startedAt)
130 const rows: Row[] = []
131 const walk = (parent: string, depth: number, trail: boolean[]) => {
132 const list = kids.get(parent) ?? []
133 list.forEach((a, i) => {
134 const last = i === list.length - 1
135 rows.push({ agent: a, depth, last, trail })
136 walk(a.id, depth + 1, [...trail, !last])
137 })
138 }
139 walk(MAIN, 0, [])
140 return rows
141}
142
143export type Summary = { live: number; done: number; failed: number; tools: number; peak: number; first?: number; last?: number }
144
145export const summary = (list: Agent[], now: number): Summary => {
146 let live = 0
147 let done = 0
148 let failed = 0
149 let tools = 0
150 for (const a of list) {
151 tools += a.tools
152 if (isLive(a.status)) live++
153 else if (isFailed(a.status)) failed++
154 else done++
155 }
156 const first = list.length ? Math.min(...list.map(a => a.startedAt)) : undefined
157 const last = list.length ? Math.max(...list.map(a => a.endedAt ?? now)) : undefined
158 return { live, done, failed, tools, peak: peakConcurrency(list, now), first, last }
159}
160
161/** Most agents running at the same moment. */
162export const peakConcurrency = (list: Agent[], now: number): number => {
163 const edges: [number, number][] = []
164 for (const a of list) {
165 edges.push([a.startedAt, 1])
166 edges.push([a.endedAt ?? now, -1])
167 }
168 edges.sort((x, y) => x[0] - y[0] || x[1] - y[1])
169 let cur = 0
170 let peak = 0
171 for (const [, d] of edges) {
172 cur += d
173 peak = Math.max(peak, cur)
174 }
175 return peak
176}
177
178export const shortTool = (tool: string) => {
179 if (tool.startsWith('mcp__')) {
180 const parts = tool.split('__')
181 return parts[parts.length - 1] || tool
182 }
183 return tool
184}
185
186const base = (p: unknown) => (typeof p === 'string' ? p.split('/').filter(Boolean).pop() ?? p : '')
187const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
188const host = (u: unknown) => {
189 if (typeof u !== 'string') return ''
190 const m = /^https?:\/\/([^/]+)/.exec(u)
191 return m ? m[1]! : clip(u, 40)
192}
193
194/** A tool call in plain English. */
195export const labelOf = (tool: string, input: Record<string, unknown>): string => {
196 const s = (k: string) => (typeof input[k] === 'string' ? (input[k] as string) : '')
197 switch (tool) {
198 case 'Bash':
199 return `running ${clip(s('description') || s('command').split('\n')[0]!, 60)}`
200 case 'Read':
201 return `reading ${base(input.file_path)}`
202 case 'Write':
203 return `writing ${base(input.file_path)}`
204 case 'Edit':
205 case 'MultiEdit':
206 return `editing ${base(input.file_path)}`
207 case 'NotebookEdit':
208 return `editing ${base(input.notebook_path)}`
209 case 'Grep':
210 return `searching for "${clip(s('pattern'), 36)}"`
211 case 'Glob':
212 return `finding ${clip(s('pattern'), 40)}`
213 case 'WebFetch':
214 return `fetching ${host(input.url)}`
215 case 'WebSearch':
216 return `searching the web: ${clip(s('query'), 40)}`
217 case 'Agent':
218 case 'Task':
219 return `delegating "${clip(s('description') || 'a task', 40)}"`
220 case 'SendMessage':
221 return `messaging ${clip(s('to') || s('recipient'), 24)}`
222 case 'TodoWrite':
223 case 'TaskCreate':
224 case 'TaskUpdate':
225 return 'updating the plan'
226 case 'Skill':
227 return `using skill ${clip(s('skill'), 30)}`
228 default:
229 return `calling ${clip(shortTool(tool), 40)}`
230 }
231}
232
233export const elapsed = (ms: number) => {
234 const s = Math.max(0, Math.round(ms / 1000))
235 if (s < 60) return `${s}s`
236 const m = Math.floor(s / 60)
237 if (m < 60) return `${m}m ${String(s % 60).padStart(2, '0')}s`
238 return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
239}
240
241export const ago = (ms: number) => {
242 const s = Math.max(0, Math.round(ms / 1000))
243 if (s < 5) return 'now'
244 if (s < 60) return `${s}s`
245 if (s < 3600) return `${Math.floor(s / 60)}m`
246 return `${Math.floor(s / 3600)}h`
247}
248
249export const shortModel = (m?: string) => {
250 if (!m) return ''
251 const hit = /(opus|sonnet|haiku|fable)[-\s]?(\d+(?:[-.]\d+)?)?/i.exec(m)
252 if (!hit) return clip(m, 14)
253 return `${hit[1]!.toLowerCase()}${hit[2] ? ` ${hit[2].replace('-', '.')}` : ''}`
254}
255
256export const statusWord = (status: string) => (isLive(status) ? 'live' : isFailed(status) ? status : status === 'completed' ? 'done' : status)
257types/index.d.ts 53 lines1/** One agent loop: a subagent, a fork, a teammate or a workflow worker. Times are epoch ms. */
2export type Agent = {
3 id: string
4 /** The row's label: the Agent call's description. */
5 label: string
6 /** general-purpose, Explore, teammate, workflow... */
7 type: string
8 /** What SendMessage addresses it by. */
9 name?: string
10 parentId?: string
11 model?: string
12 /** running, completed, failed, killed, or another engine status. */
13 status: string
14 startedAt: number
15 endedAt?: number
16 tools: number
17 errors: number
18 /** Plain-English line for the tool in flight (or the last one). */
19 doing?: string
20 doingTool?: string
21 /** True while a tool call is open in this loop. */
22 busy: boolean
23 lastAt: number
24 /** Tool name -> calls. */
25 mix: Record<string, number>
26 outTokens: number
27 bg?: boolean
28 toolUseId?: string
29 /** The plugin that spawned it, when one did. */
30 viaPlugin?: string
31}
32
33/** The main loop: the orchestrator / team lead. */
34export type Lead = { doing?: string; doingTool?: string; busy: boolean; tools: number; lastAt: number; startedAt: number }
35
36/** One message between agents. `from`/`to` are agent ids, or 'main'. */
37export type Msg = { at: number; from: string; to: string; text: string }
38
39/** One line of the activity feed. */
40export type Beat = { at: number; who: string; kind: 'spawn' | 'tool' | 'done' | 'fail' | 'msg' | 'deny'; text: string }
41
42declare module 'claude-code' {
43 interface PluginState {
44 swarm: {
45 agents: Agent[]
46 lead: Lead
47 msgs: Msg[]
48 beats: Beat[]
49 tick: number
50 }
51 }
52}
53