SLOPSHOPPER

Swarm

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

newpaneguardcommandtoaststatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · swarm
│ ┃ Swarm ✕ › fix the failing auth test and add an audit log call │ ┃ SWARM 0 agents · 9 tool calls · peak 0 IDL │ ┃ parallel ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ORCHESTRATION ⏺ Update(src/auth.ts) │ ┃ ◆ Lead waiting for you ⎿ Added 2 lines, removed 1 line │ ┃ No agents yet. Spawn subagents or a team ⏺ Bash(bun test) │ ┃ and they appear here live. ⎿ 3 pass, 1 fail │ ┃ │ ┃ [ refresh ] [ clear finished ] ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /swarm │ ⎿ swarm: Swarm open. No agents yet this session. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Swarm
SWARM 0 agents · 9 tool calls · peak 0 parallel IDLE ORCHESTRATION ◆ Lead waiting for you No agents yet. Spawn subagents or a team and they appear here live. [ refresh ] [ clear finished ]
README

swarm

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

/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.

Install

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

What it reaches

ModNetworkRuns processesFilesCalls a modelSends data anywhere
swarmNoNoNoNoNo

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.

Source 4 files
hooks/register.tsx 350 lines
1import { 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}
350
hooks/card.ts 303 lines
1/**
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 => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&#39;' })[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}
303
hooks/model.ts 257 lines
1/** 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)
257
types/index.d.ts 53 lines
1/** 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