SLOPSHOPPER

hivemind

KOZMOS Hivemind: the agent swarm as a live tree. The main loop at the root and every subagent beneath its parent, each with a status glyph, type and task…

newpaneguardcommandtimeragents
★ 4v0.1.0MITupdated 2026-10-09ersinkoc/claude-mods/mods/hivemind
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · hivemind
│ ┃ KOZMOS · Hivemind ✕ › fix the failing auth test and add an audit log call │ ┃ ◇ 0 running ✓ 0 done ✖ 0 failed │ ┃ Σ agents $0.00 · 0 tok · 0 req ⏺ Read(src/auth.ts) │ ┃ ──────────────────────────────────────────── ⎿ Read 6 lines │ ┃ ─────────── ⏺ Update(src/auth.ts) │ ┃ ⏸ ◆ main 0:01 ⎿ Added 2 lines, removed 1 line │ ┃ Opus 5.5 ⏺ Bash(bun test) │ ┃ ↳ $ Bash Show environment file · 40ms ⎿ 3 pass, 1 fail │ ┃ no subagents yet: the swarm grows here as │ ┃ the Agent tool spawns them ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /hivemind │ ⎿ hivemind: Hivemind open. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · KOZMOS · Hivemind
◇ 0 running ✓ 0 done ✖ 0 failed Σ agents $0.00 · 0 tok · 0 req ─────────────────────────────────────────────────────── ⏸ ◆ main 0:01 Opus 5.5 ↳ $ Bash Show environment file · 40ms no subagents yet: the swarm grows here as the Agent tool spawns them
README

KOZMOS Hivemind

The agent swarm as a live tree in a sidebar. ◆ main sits at the root and every subagent hangs under the loop that spawned it.

Each node shows:

  • a status glyph: a spinning ◐◓◑◒ while it runs, ✓ done, ✖ failed, ⏸ waiting or idle
  • the subagent type and its task description (⇢bg for background agents)
  • model and effort, elapsed time, requests made, tokens and an estimated spend
  • the context fill of its last request as a small bar
  • the tool it runs now (with its detail and a timer) or the last one it ran

The header counts running, done and failed agents, totals their estimated spend, tokens and requests, and draws a sparkline of how many ran at once. ▾ hide finished (hotkey f) folds away finished branches.

On the desktop the header is a row of tiles with an activity area chart, and the tree is one SVG with connector lines (flowing while a child runs), pulsing halos, spinning arcs and mini context bars. Light and dark themes both work.

  • /hivemind toggles the sidebar.
  • autoOpen (default off) opens it when a session starts.

Estimated spend uses list prices per model; the session total is the engine's and lives in KOZMOS Bridge.

Türkçe

Ajan sürüsünü kenar çubuğunda canlı bir ağaç olarak gösterir. Kökte ◆ main durur, her alt ajan onu başlatan döngünün altına asılır.

Her düğümde şunlar var:

  • durum simgesi: çalışırken dönen ◐◓◑◒, bitince ✓, hata olursa ✖, beklerken ⏸
  • alt ajan türü ve görev açıklaması (arka plandakiler için ⇢bg)
  • model ve effort, geçen süre, istek sayısı, token ve tahmini maliyet
  • son isteğin bağlam doluluğu, küçük bir çubuk olarak
  • şu an çalıştırdığı araç (ayrıntısı ve sayacıyla) ya da son çalıştırdığı

Başlık çalışan, biten ve başarısız ajanları sayar, tahmini maliyetlerini, token ve isteklerini toplar, aynı anda kaç ajan çalıştığını bir kıvılcım grafiğiyle çizer. ▾ hide finished (kısayol f) biten dalları katlar.

Masaüstünde başlık bir kutucuk sırası ve etkinlik grafiğidir; ağaç bağlantı çizgileri (çocuk çalışırken akan), nabız gibi atan halkalar, dönen yaylar ve küçük bağlam çubuklarıyla tek bir SVG'dir. Açık ve koyu temada çalışır.

  • /hivemind kenar çubuğunu açar/kapatır.
  • autoOpen (varsayılan kapalı) oturum başlarken açar.
Source 3 files
hooks/register.tsx 567 lines
1import { atom, read, update } from 'claude-code'
2import type { AgentStatus, EngineInterface, Register, Timer } from 'claude-code'
3
4import type { HiveLastTool, HiveNode, HiveSnap, HiveStatus, HiveTool } from '../types'
5import {
6  KZ, bar, clamp01, contextOf, costOf, fitText, fmtClock, fmtTokens, fmtUsd, heat, modelName, pxOf, sparkline,
7  svg, svgBar, svgText, tokensOf, toolColor, toolDetail, toolGlyph, toolName, windowOf,
8} from './lib/kz.ts'
9
10const PANE = 'kz-hivemind'
11const TITLE = 'KOZMOS · Hivemind'
12const ROOT = 'main'
13const SPIN = '◐◓◑◒'
14
15const snapAtom = atom({ plugin: 'hivemind', key: 'snap' } as const, null)
16const viewAtom = atom({ plugin: 'hivemind', key: 'view' } as const, { showDone: true })
17
18// ---------------------------------------------------------------------------
19// The live swarm. Module state starts over on a reload; session.start resets it
20// and $.agent.list fills back what it can.
21
22type Known = HiveNode & { isKnown: boolean }
23
24const newNode = (id: string, at: number): Known => ({
25  id, type: '', description: '', status: 'running', startedAt: at, steps: 0, tokens: 0, usd: 0, tools: 0, isKnown: false,
26})
27
28let root: Known = { ...newNode(ROOT, 0), type: 'main', status: 'waiting', isKnown: true }
29let nodes = new Map<string, Known>()
30let history: number[] = []
31let lastPublished = ''
32let ticker: Timer | undefined
33let tickCount = 0
34
35function nodeFor(id: string | undefined, at: number): Known {
36  if (id === undefined) return root
37  let n = nodes.get(id)
38  if (!n) {
39    n = newNode(id, at)
40    nodes.set(id, n)
41  }
42  return n
43}
44
45const isRunning = (s: HiveStatus) => s === 'running'
46const runningAgents = () => [...nodes.values()].filter(n => n.isKnown && isRunning(n.status)).length
47
48function snapshot(now: number): HiveSnap {
49  const strip = ({ isKnown: _k, ...n }: Known): HiveNode => n
50  return {
51    root: strip(root),
52    agents: [...nodes.values()].filter(n => n.isKnown).sort((a, b) => a.startedAt - b.startedAt).map(strip),
53    history: [...history],
54    now,
55  }
56}
57
58let pubSeq = 0
59
60async function publish($: EngineInterface): Promise<void> {
61  const seq = ++pubSeq
62  const now = await $.clock.now()
63  // A later publish has the newer picture: this one stands down.
64  if (seq !== pubSeq) return
65  const snap = snapshot(now)
66  const anyRunning = isRunning(root.status) || runningAgents() > 0
67  const key = JSON.stringify({ ...snap, now: anyRunning ? now : 0 })
68  if (key !== lastPublished) {
69    lastPublished = key
70    await update($, snapAtom, () => snap)
71  }
72  // A one-second clock only while something runs, for the elapsed timers and
73  // the spinner frames.
74  if (anyRunning && !ticker) ticker = $.clock.every(1000, () => void onTick($).catch(() => undefined))
75  else if (!anyRunning && ticker) {
76    ticker.cancel()
77    ticker = undefined
78  }
79}
80
81async function onTick($: EngineInterface): Promise<void> {
82  tickCount++
83  history = [...history, runningAgents()].slice(-120)
84  if (tickCount % 3 === 0) await reconcile($)
85  await publish($)
86}
87
88const fromList = (s: AgentStatus): HiveStatus =>
89  s === 'pending' || s === 'running' ? 'running'
90    : s === 'waiting' || s === 'idle' ? 'waiting'
91      : s === 'completed' ? 'done'
92        : 'failed' // failed, killed
93
94/** The engine's own roster wins on status and parentage. */
95async function reconcile($: EngineInterface): Promise<void> {
96  let list: Awaited<ReturnType<EngineInterface['agent']['list']>>
97  try {
98    list = await $.agent.list()
99  } catch {
100    return
101  }
102  const at = await $.clock.now()
103  for (const a of list) {
104    const n = nodeFor(a.id, at)
105    n.isKnown = true
106    n.type = n.type || a.type
107    n.description = n.description || a.description
108    if (a.parentId !== undefined) n.parentId = a.parentId
109    const st = fromList(a.status)
110    // A loop that ended by our own turn.complete stays ended until it steps again.
111    if (st === 'running' && n.endedAt !== undefined) continue
112    if (st !== n.status) {
113      n.status = st
114      if (st === 'done' || st === 'failed') n.endedAt ??= at
115    }
116  }
117}
118
119async function toggle($: EngineInterface): Promise<boolean> {
120  if ((await $.ui.panes()).some(p => p.id === PANE)) {
121    await $.ui.close({ id: PANE })
122    return false
123  }
124  await $.ui.open({ id: PANE, title: TITLE })
125  return true
126}
127
128export const register: Register = (on, options) => {
129  on('session.start', async ($, e, next) => {
130    const started = await next(e)
131    const at = await $.clock.now()
132    root = { ...newNode(ROOT, at), type: 'main', description: 'the session', status: 'waiting', isKnown: true }
133    nodes = new Map()
134    history = []
135    lastPublished = ''
136    ticker?.cancel()
137    ticker = undefined
138    await $.command.register({ name: 'hivemind', description: 'KOZMOS: toggle the Hivemind agent-tree sidebar', immediate: true })
139    try {
140      root.model = await $.session.model()
141    } catch {
142      // Filled by the first turn.step.
143    }
144    await reconcile($)
145    await publish($)
146    if (options.autoOpen === true) void $.ui.open({ id: PANE, title: TITLE })
147    return started
148  })
149
150  on('command.run', { command: 'hivemind' }, async $ => ({
151    text: (await toggle($)) ? 'Hivemind open.' : 'Hivemind closed.',
152  }))
153
154  on('turn.start', async ($, e, next) => {
155    const at = await $.clock.now()
156    root.status = 'running'
157    root.startedAt = at
158    root.endedAt = undefined
159    await publish($)
160    return next(e)
161  })
162
163  on('agent.spawn', async ($, e, next) => {
164    const r = await next(e)
165    if (r.deny === undefined && r.agentId) {
166      const n = nodeFor(r.agentId, await $.clock.now())
167      n.isKnown = true
168      n.type = e.subagentType
169      n.description = e.description
170      n.parentId = e.parentAgentId
171      n.model = n.model ?? r.model
172      n.isBackground = e.background
173      if (n.endedAt === undefined) n.status = 'running'
174      await publish($)
175    }
176    return r
177  }).catch(($, e, next) => next(e))
178
179  on('turn.step', async function* ($, e, next) {
180    const at = await $.clock.now()
181    const n = nodeFor(e.agentId, at)
182    n.model = e.model || n.model
183    if (e.effort !== undefined) n.effort = String(e.effort)
184    if (e.agentId !== undefined && n.endedAt !== undefined) {
185      // A teammate woke up for another turn.
186      n.endedAt = undefined
187      n.status = 'running'
188      n.startedAt = at
189    }
190    if (e.agentId !== undefined && n.status !== 'running') n.status = 'running'
191    await publish($)
192    const r = yield* next(e)
193    if (r.usage) {
194      const model = r.usage.model || e.model
195      n.steps++
196      n.tokens += tokensOf(r.usage)
197      n.usd += costOf(model, r.usage)
198      n.ctxPct = (100 * contextOf(r.usage)) / windowOf(model)
199      await publish($)
200    }
201    return r
202  })
203
204  on('turn.complete', async ($, e, next) => {
205    const at = await $.clock.now()
206    const n = nodeFor(e.agentId, at)
207    n.tool = undefined
208    if (e.agentId === undefined) {
209      n.status = 'waiting'
210      n.endedAt = at
211    } else {
212      n.status = e.reason === 'answer' ? 'done' : 'failed'
213      n.endedAt = at
214      void reconcile($).then(() => publish($)).catch(() => undefined)
215    }
216    await publish($)
217    return next(e)
218  })
219
220  on('tool.call', async ($, e, next) => {
221    const at = await $.clock.now()
222    const name = String(e.tool)
223    const detail = toolDetail(name, e)
224    const n = nodeFor(e.agentId, at)
225    n.tool = { id: e.tool_use_id, name, detail, startedAt: at }
226    n.tools++
227    await publish($)
228    const ran = await next(e)
229    const ms = (await $.clock.now()) - at
230    n.lastTool = { name, detail, ms, isError: ran.isError === true || ran.deny !== undefined }
231    if (n.tool?.id === e.tool_use_id) n.tool = undefined
232    await publish($)
233    return ran
234  }).catch(($, e, next) => next(e))
235
236  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
237    const snap = (await read($, snapAtom)) ?? snapshot(await $.clock.now())
238    const view = await read($, viewAtom)
239    const ui = $.ui.resolve(e)
240    const { Box, Text, Button } = ui
241    const { rows, hidden } = flatten(snap, view.showDone)
242    const finished = snap.agents.filter(a => a.status === 'done' || a.status === 'failed').length
243    const toggleDone = finished > 0
244      ? (
245          <Button
246            key="hm-done"
247            hotkey="f"
248            dimColor
249            label={view.showDone ? `▾ hide finished (${finished})` : `▸ show finished (${hidden})`}
250            onPress={() => void update($, viewAtom, v => ({ ...v, showDone: !v.showDone }))}
251          />
252        )
253      : null
254
255    if ('Svg' in ui && e.surface !== 'terminal') {
256      const { Svg } = ui
257      const W = pxOf(e.props.bodyColumns, 48)
258      const head = headerSvg(snap, W)
259      const tree = treeSvg(snap, rows, W)
260      return (
261        <Box flexDirection="column">
262          <Svg source={head.source} alt={altHeader(snap)} width={W} height={head.height} />
263          <Svg source={tree.source} alt={altTree(rows)} width={W} height={tree.height} />
264          {toggleDone}
265        </Box>
266      )
267    }
268
269    const cols = Math.max(30, e.props.bodyColumns || 44)
270    const t = counts(snap)
271    const frame = SPIN.charAt(Math.floor(snap.now / 1000) % 4)
272    return (
273      <Box flexDirection="column">
274        <Box flexDirection="row" justifyContent="space-between">
275          <Text wrap="truncate-end">
276            <Text color={KZ.violet} bold>{t.running ? frame : '◇'} {t.running}</Text><Text dimColor> running  </Text>
277            <Text color={KZ.green} bold>✓ {t.done}</Text><Text dimColor> done  </Text>
278            <Text color={t.failed ? KZ.red : KZ.mist} bold>✖ {t.failed}</Text><Text dimColor> failed</Text>
279          </Text>
280        </Box>
281        <Box flexDirection="row" justifyContent="space-between">
282          <Text wrap="truncate-end">
283            <Text dimColor>Σ agents </Text><Text color={KZ.yellow}>{fmtUsd(t.usd)}</Text><Text dimColor> · {fmtTokens(t.tokens)} tok · {t.steps} req</Text>
284          </Text>
285          {snap.history.some(v => v > 0)
286            ? <Text color={KZ.violet}>{sparkline(snap.history, Math.max(6, Math.min(24, cols - 38)))}</Text>
287            : null}
288        </Box>
289        <Text dimColor>{'─'.repeat(Math.max(4, cols - 1))}</Text>
290        {rows.map(r => termNode(ui, r, snap.now, cols))}
291        {snap.agents.length === 0
292          ? <Text dimColor>  no subagents yet: the swarm grows here as the Agent tool spawns them</Text>
293          : null}
294        {toggleDone}
295      </Box>
296    )
297  })
298}
299
300// ---------------------------------------------------------------------------
301// Tree shape, shared by both surfaces.
302
303type Row = { node: HiveNode; depth: number; lead: string; cont: string; parentRow?: number }
304
305function counts(s: HiveSnap): { running: number; done: number; failed: number; usd: number; tokens: number; steps: number } {
306  const c = { running: 0, done: 0, failed: 0, usd: 0, tokens: 0, steps: 0 }
307  for (const a of s.agents) {
308    if (a.status === 'running') c.running++
309    else if (a.status === 'done') c.done++
310    else if (a.status === 'failed') c.failed++
311    c.usd += a.usd
312    c.tokens += a.tokens
313    c.steps += a.steps
314  }
315  return c
316}
317
318function flatten(s: HiveSnap, showDone: boolean): { rows: Row[]; hidden: number } {
319  const ids = new Set(s.agents.map(a => a.id))
320  const kids = new Map<string, HiveNode[]>()
321  for (const a of s.agents) {
322    const p = a.parentId && ids.has(a.parentId) ? a.parentId : ROOT
323    kids.set(p, [...(kids.get(p) ?? []), a])
324  }
325  const memo = new Map<string, boolean>()
326  const visible = (n: HiveNode): boolean => {
327    const hit = memo.get(n.id)
328    if (hit !== undefined) return hit
329    memo.set(n.id, false) // guards a cycle
330    const v = showDone || (n.status !== 'done' && n.status !== 'failed') || (kids.get(n.id) ?? []).some(visible)
331    memo.set(n.id, v)
332    return v
333  }
334  const rows: Row[] = [{ node: s.root, depth: 0, lead: '', cont: '' }]
335  let hidden = 0
336  const seen = new Set<string>([ROOT])
337  const walk = (id: string, depth: number, carry: string, parentRow: number) => {
338    const all = kids.get(id) ?? []
339    const shown = all.filter(visible)
340    hidden += all.length - shown.length
341    shown.forEach((n, i) => {
342      if (seen.has(n.id)) return
343      seen.add(n.id)
344      const isLast = i === shown.length - 1
345      rows.push({ node: n, depth, lead: carry + (isLast ? '└─ ' : '├─ '), cont: carry + (isLast ? '   ' : '│  '), parentRow })
346      walk(n.id, depth + 1, carry + (isLast ? '   ' : '│  '), rows.length - 1)
347    })
348  }
349  walk(ROOT, 1, '', 0)
350  return { rows, hidden }
351}
352
353const statusColor = (st: HiveStatus) => (st === 'running' ? KZ.violet : st === 'done' ? KZ.green : st === 'failed' ? KZ.red : KZ.mist)
354
355function glyphOf(n: HiveNode, now: number): string {
356  if (n.status === 'running') return SPIN.charAt(Math.floor(now / 1000) % 4)
357  return n.status === 'done' ? '✓' : n.status === 'failed' ? '✖' : '⏸'
358}
359
360const elapsedOf = (n: HiveNode, now: number) => (n.endedAt ?? now) - n.startedAt
361
362type ToolTag = { name: string; detail: string; color: string; tail: string; isLive: boolean }
363
364/** The tool a node runs now, else the one it ran last, as both surfaces show it. */
365function toolTag(tool: HiveTool | undefined, last: HiveLastTool | undefined, now: number): ToolTag | undefined {
366  if (tool) return { name: tool.name, detail: tool.detail, color: toolColor(tool.name), tail: fmtClock(now - tool.startedAt), isLive: true }
367  if (last) return { name: last.name, detail: last.detail, color: last.isError ? KZ.red : KZ.mist, tail: `${last.ms}ms`, isLive: false }
368  return undefined
369}
370
371// ---------------------------------------------------------------------------
372// Terminal.
373
374function termNode(ui: ReturnType<EngineInterface['ui']['resolve']>, r: Row, now: number, cols: number) {
375  const { Box, Text } = ui
376  const n = r.node
377  const c = statusColor(n.status)
378  const isRoot = r.depth === 0
379  const label = isRoot ? '◆ main' : n.type || 'agent'
380  const ctx = clamp01((n.ctxPct ?? 0) / 100)
381  const meta = [
382    n.model ? modelName(n.model) : '',
383    n.effort ?? '',
384    n.steps ? `${n.steps} req` : '',
385    n.tokens ? `${fmtTokens(n.tokens)} tok` : '',
386    n.usd ? fmtUsd(n.usd) : '',
387  ].filter(Boolean).join(' · ')
388  const t = toolTag(n.tool, n.lastTool, now)
389  const ind = r.cont + '  '
390  return (
391    <Box key={`n-${n.id}`} flexDirection="column">
392      <Box flexDirection="row" justifyContent="space-between">
393        <Text wrap="truncate-end">
394          <Text dimColor>{r.lead}</Text>
395          <Text color={c} bold>{glyphOf(n, now)} </Text>
396          <Text color={isRoot ? KZ.violet : KZ.cyan} bold>{label}</Text>
397          {n.description && !isRoot ? <Text> {n.description}</Text> : null}
398          {n.isBackground ? <Text dimColor> ⇢bg</Text> : null}
399        </Text>
400        <Text bold={n.status === 'running'} dimColor={n.status !== 'running'}> {fmtClock(elapsedOf(n, now))}</Text>
401      </Box>
402      <Text wrap="truncate-end">
403        <Text dimColor>{ind}{meta || '—'}</Text>
404        {n.ctxPct !== undefined
405          ? <Text> <Text color={heat(ctx * 1.1)}>{bar(ctx, Math.max(3, Math.min(8, cols - meta.length - ind.length - 10)))}</Text><Text dimColor> {Math.round(n.ctxPct)}%</Text></Text>
406          : null}
407      </Text>
408      {t
409        ? (
410            <Text wrap="truncate-end">
411              <Text dimColor>{ind}↳ </Text>
412              <Text color={t.color}>{toolGlyph(t.name)} {toolName(t.name)}</Text>
413              <Text dimColor> {t.detail} · {t.tail}</Text>
414            </Text>
415          )
416        : null}
417    </Box>
418  )
419}
420
421// ---------------------------------------------------------------------------
422// Desktop: a header card and the whole tree as one SVG each.
423
424const CSS = `
425.halo{animation:hmh 1.8s ease-out infinite;transform-box:fill-box;transform-origin:center}
426@keyframes hmh{0%{opacity:.55;transform:scale(.6)}100%{opacity:0;transform:scale(1.9)}}
427.flow{stroke-dasharray:3 4;animation:hmf 1.2s linear infinite}@keyframes hmf{to{stroke-dashoffset:-14}}
428`
429
430function altHeader(s: HiveSnap): string {
431  const c = counts(s)
432  return `${c.running} agents running, ${c.done} done, ${c.failed} failed; agents spent about ${fmtUsd(c.usd)} over ${fmtTokens(c.tokens)} tokens`
433}
434
435function altTree(rows: Row[]): string {
436  return rows.map(r => `${r.depth ? r.node.type : 'main'} ${r.node.status}${r.node.description && r.depth ? `: ${r.node.description}` : ''}`).join('; ')
437}
438
439function headerSvg(s: HiveSnap, W: number): { source: string; height: number } {
440  const c = counts(s)
441  const p: string[] = []
442  const tiles: [string, string, string, boolean][] = [
443    ['RUNNING', String(c.running), KZ.violet, c.running > 0],
444    ['DONE', String(c.done), KZ.green, false],
445    ['FAILED', String(c.failed), c.failed ? KZ.red : KZ.mist, false],
446    ['SPEND', fmtUsd(c.usd), KZ.yellow, false],
447  ]
448  const gap = 6
449  const perRow = W >= 360 ? 4 : 2
450  const tw = (W - gap * (perRow - 1)) / perRow
451  const th = 56
452  tiles.forEach(([k, v, col, live], i) => {
453    const x = (i % perRow) * (tw + gap)
454    const y = Math.floor(i / perRow) * (th + gap)
455    p.push(`<rect class="p" x="${x}" y="${y}" width="${tw}" height="${th}" rx="12"/>`)
456    p.push(`<circle cx="${x + 14}" cy="${y + 17}" r="4" fill="${col}"${live ? ' class="pulse"' : ''}/>`)
457    p.push(svgText(x + 24, y + 21, k, { cls: 's', size: 9.5, weight: 650 }))
458    p.push(svgText(x + 12, y + 44, v, { size: 18, weight: 700, fill: k === 'SPEND' || live ? col : undefined }))
459  })
460  let y = Math.ceil(tiles.length / perRow) * (th + gap)
461  // Activity strip: running subagents over the last two minutes.
462  const h = 40
463  p.push(`<rect class="p" x="0" y="${y}" width="${W}" height="${h}" rx="12"/>`)
464  p.push(svgText(12, y + 16, 'SWARM', { cls: 's', size: 9.5, weight: 650 }))
465  p.push(svgText(12, y + 31, `${fmtTokens(c.tokens)} tok · ${c.steps} req`, { cls: 'm', size: 10.5 }))
466  const hist = s.history.length > 1 ? s.history : [0, 0]
467  const top = Math.max(1, ...hist)
468  const gx = 120
469  const gw = W - gx - 12
470  // The pane is at least 200 px wide (pxOf): the strip always has room.
471  const step = gw / Math.max(1, hist.length - 1)
472  const pts = hist.map((v, i) => `${(gx + i * step).toFixed(1)},${(y + h - 8 - (v / top) * (h - 16)).toFixed(1)}`)
473  p.push(`<defs><linearGradient id="hmG" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${KZ.violet}" stop-opacity=".5"/><stop offset="1" stop-color="${KZ.violet}" stop-opacity="0"/></linearGradient></defs>`)
474  p.push(`<path d="M${pts.join('L')}L${gx + gw},${y + h - 8}L${gx},${y + h - 8}Z" fill="url(#hmG)"/>`)
475  p.push(`<path d="M${pts.join('L')}" stroke="${KZ.violet}" stroke-width="1.5" fill="none" stroke-linejoin="round"/>`)
476  y += h
477  return { source: svg(W, y, p.join(''), CSS), height: y }
478}
479
480function statusMark(n: HiveNode, cx: number, cy: number): string {
481  const c = statusColor(n.status)
482  if (n.status === 'running') {
483    return `<circle cx="${cx}" cy="${cy}" r="7" fill="${c}" class="halo"/>` +
484      `<circle cx="${cx}" cy="${cy}" r="6" fill="none" stroke="${c}" stroke-opacity=".25" stroke-width="2"/>` +
485      `<path class="spin" d="M${cx} ${cy - 6}A6 6 0 0 1 ${cx + 6} ${cy}" fill="none" stroke="${c}" stroke-width="2.2" stroke-linecap="round"/>` +
486      `<circle cx="${cx}" cy="${cy}" r="2" fill="${c}"/>`
487  }
488  if (n.status === 'done') {
489    return `<circle cx="${cx}" cy="${cy}" r="6.5" fill="${c}"/>` +
490      `<path d="M${cx - 3} ${cy}l2 2.2 4-4.4" stroke="#fff" stroke-width="1.6" fill="none" stroke-linecap="round" stroke-linejoin="round"/>`
491  }
492  if (n.status === 'failed') {
493    return `<circle cx="${cx}" cy="${cy}" r="6.5" fill="${c}"/>` +
494      `<path d="M${cx - 2.5} ${cy - 2.5}l5 5M${cx + 2.5} ${cy - 2.5}l-5 5" stroke="#fff" stroke-width="1.6" stroke-linecap="round"/>`
495  }
496  return `<circle cx="${cx}" cy="${cy}" r="6" fill="none" stroke="${c}" stroke-width="1.6"/>` +
497    `<path d="M${cx - 1.8} ${cy - 2.5}v5M${cx + 1.8} ${cy - 2.5}v5" stroke="${c}" stroke-width="1.5" stroke-linecap="round"/>`
498}
499
500function treeSvg(s: HiveSnap, rows: Row[], W: number): { source: string; height: number } {
501  const pad = 12
502  const indent = 18
503  const p: string[] = []
504  const lines: string[] = []
505  const ys: number[] = []
506  let y = 10
507  rows.forEach((r, i) => {
508    const n = r.node
509    const hasTool = (n.tool ?? n.lastTool) !== undefined
510    const h = hasTool ? 58 : 42
511    const cx = pad + 8 + r.depth * indent
512    const cy = y + 13
513    ys[i] = cy
514    if (r.parentRow !== undefined) {
515      // The parent is one level up and drawn already (rows run depth-first).
516      const py = ys[r.parentRow]!
517      const px = pad + 8 + (r.depth - 1) * indent
518      lines.push(`<path class="ln${n.status === 'running' ? ' flow' : ''}" d="M${px} ${py + 8}V${cy - 4}Q${px} ${cy} ${px + 4} ${cy}H${cx - 9}" fill="none" stroke-width="1.4"${n.status === 'running' ? ` style="stroke:${KZ.violet};stroke-opacity:.6"` : ''}/>`)
519    }
520    if (n.status === 'running') p.push(`<rect x="${cx - 12}" y="${y - 2}" width="${W - cx + 10}" height="${h - 4}" rx="9" fill="${KZ.violet}" opacity=".07"/>`)
521    p.push(statusMark(n, cx, cy))
522    const x0 = cx + 14
523    const right = W - pad
524    const elapsed = fmtClock(elapsedOf(n, s.now))
525    p.push(svgText(right, cy + 4, elapsed, { size: 11, anchor: 'end', mono: true, cls: n.status === 'running' ? 't' : 'm', weight: n.status === 'running' ? 600 : 400 }))
526    const title = r.depth === 0 ? '◆ main' : n.type || 'agent'
527    const titleW = Math.min(140, title.length * 7.4 + 4)
528    p.push(svgText(x0, cy + 4, fitText(title, 12.5, titleW + 10), { size: 12.5, weight: 700, fill: r.depth === 0 ? KZ.violet : undefined }))
529    if (r.depth > 0 && n.description) {
530      p.push(svgText(x0 + titleW + 8, cy + 4, fitText(n.description, 12, Math.max(20, right - 52 - x0 - titleW - 8)), { cls: 's', size: 12 }))
531    }
532    const meta = [
533      n.model ? modelName(n.model) : '',
534      n.effort ?? '',
535      n.steps ? `${n.steps} req` : '',
536      n.tokens ? `${fmtTokens(n.tokens)} tok` : '',
537      n.usd ? fmtUsd(n.usd) : '',
538      n.isBackground ? 'background' : '',
539    ].filter(Boolean).join(' · ')
540    const barW = 46
541    const hasCtx = n.ctxPct !== undefined
542    p.push(svgText(x0, cy + 21, fitText(meta || '—', 10.5, right - x0 - (hasCtx ? barW + 44 : 0)), { cls: 'm', size: 10.5 }))
543    if (n.ctxPct !== undefined) {
544      const ratio = clamp01(n.ctxPct / 100)
545      p.push(svgBar(right - barW - 30, cy + 15, barW, 5, ratio, heat(ratio * 1.1)))
546      p.push(svgText(right, cy + 21, `${Math.round(n.ctxPct)}%`, { cls: 's', size: 10, anchor: 'end' }))
547    }
548    const t = toolTag(n.tool, n.lastTool, s.now)
549    if (t) {
550      const tag = `${toolGlyph(t.name)} ${toolName(t.name)}`
551      const tagW = Math.min(150, tag.length * 6.4 + 14)
552      p.push(`<rect x="${x0}" y="${cy + 28}" width="${tagW}" height="16" rx="8" fill="${t.color}" opacity="${t.isLive ? 0.2 : 0.12}"${t.isLive ? ' class="pulse"' : ''}/>`)
553      p.push(svgText(x0 + 7, cy + 40, fitText(tag, 10, tagW - 10), { size: 10, weight: 650, fill: t.color }))
554      p.push(svgText(x0 + tagW + 6, cy + 40, fitText(t.detail, 10.5, Math.max(10, right - x0 - tagW - 60)), { cls: 's', size: 10.5, mono: true }))
555      p.push(svgText(right, cy + 40, t.tail, { cls: 'm', size: 10, anchor: 'end' }))
556    }
557    y += h
558  })
559  if (rows.length === 1) {
560    p.push(svgText(pad + 4, y + 8, 'No subagents yet. The swarm grows here as agents spawn.', { cls: 'm', size: 11 }))
561    y += 18
562  }
563  const height = y + 4
564  const body = `<rect class="p" x="0" y="0" width="${W}" height="${height}" rx="12"/>${lines.join('')}${p.join('')}`
565  return { source: svg(W, height, body, CSS), height }
566}
567
hooks/lib/kz.ts 510 lines
1// GENERATED by scripts/sync-shared.mjs from shared/kz.ts. Do not edit here.
2// KOZMOS shared kit. Source of truth: shared/kz.ts at the bundle root.
3// `node scripts/sync-shared.mjs` copies it into every mod as hooks/lib/kz.ts,
4// because a hooks module may only import files inside its own plugin.
5// Edit the root copy, never a mod's copy.
6
7// ---------------------------------------------------------------------------
8// Palette: one neon family across every KOZMOS mod.
9
10export const KZ = {
11  violet: '#a78bfa',
12  magenta: '#f472b6',
13  cyan: '#22d3ee',
14  teal: '#2dd4bf',
15  green: '#4ade80',
16  lime: '#a3e635',
17  yellow: '#facc15',
18  amber: '#fb923c',
19  red: '#f87171',
20  blue: '#60a5fa',
21  clay: '#d97757',
22  ink: '#1f1e1d',
23  mist: '#9ca3af',
24} as const
25
26/** Tool families, colored the same in every mod. */
27export function toolColor(tool: string): string {
28  const t = String(tool)
29  if (t === 'Bash' || t === 'PowerShell') return KZ.green
30  if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit' || t === 'MultiEdit') return KZ.yellow
31  if (t === 'Read' || t === 'Glob' || t === 'Grep' || t === 'LSP') return KZ.blue
32  if (t === 'Agent' || t === 'Task' || t === 'Workflow') return KZ.violet
33  if (t.startsWith('Web')) return KZ.cyan
34  if (t.startsWith('Todo') || t.startsWith('Task')) return KZ.teal
35  if (t.startsWith('mcp__')) return KZ.magenta
36  return KZ.mist
37}
38
39/** A short glyph per tool family. */
40export function toolGlyph(tool: string): string {
41  const t = String(tool)
42  if (t === 'Bash' || t === 'PowerShell') return '$'
43  if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit') return '✎'
44  if (t === 'Read') return '◉'
45  if (t === 'Glob' || t === 'Grep') return '⌕'
46  if (t === 'Agent') return '◈'
47  if (t.startsWith('Web')) return '◍'
48  if (t.startsWith('Todo') || t.startsWith('Task')) return '☑'
49  if (t.startsWith('mcp__')) return '⬡'
50  return '•'
51}
52
53// ---------------------------------------------------------------------------
54// Colors.
55
56export function hexToRgb(hex: string): [number, number, number] {
57  const h = hex.replace('#', '')
58  const n = parseInt(h.length === 3 ? h.split('').map(c => c + c).join('') : h, 16)
59  return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
60}
61
62export function rgbToHex(r: number, g: number, b: number): string {
63  const c = (v: number) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')
64  return `#${c(r)}${c(g)}${c(b)}`
65}
66
67export function mix(a: string, b: string, t: number): string {
68  const [r1, g1, b1] = hexToRgb(a)
69  const [r2, g2, b2] = hexToRgb(b)
70  const k = clamp01(t)
71  return rgbToHex(r1 + (r2 - r1) * k, g1 + (g2 - g1) * k, b1 + (b2 - b1) * k)
72}
73
74/** Green at 0, yellow at 0.6, red at 1: the heat of a gauge. */
75export function heat(t: number): string {
76  const k = clamp01(t)
77  return k < 0.6 ? mix(KZ.green, KZ.yellow, k / 0.6) : mix(KZ.yellow, KZ.red, (k - 0.6) / 0.4)
78}
79
80/** A smooth rainbow for hues 0..1 (for auroras, plasma and the like). */
81export function hue(h: number, s = 0.75, l = 0.6): string {
82  const k = ((h % 1) + 1) % 1
83  const a = s * Math.min(l, 1 - l)
84  const f = (n: number) => {
85    const x = (n + k * 12) % 12
86    return l - a * Math.max(-1, Math.min(x - 3, 9 - x, 1))
87  }
88  return rgbToHex(f(0) * 255, f(8) * 255, f(4) * 255)
89}
90
91export function hexToInt(hex: string): number {
92  const [r, g, b] = hexToRgb(hex)
93  return (r << 16) | (g << 8) | b
94}
95
96// ---------------------------------------------------------------------------
97// Numbers and text.
98
99export const clamp01 = (v: number): number => (Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0)
100
101export function fmtTokens(n: number): string {
102  if (!Number.isFinite(n)) return '—'
103  // Each unit starts where the one below would round up to 1000 of itself.
104  if (n >= 999.5e6) return `${(n / 1e9).toFixed(1)}B`
105  if (n >= 999_500) return `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M`
106  if (n >= 999.5) return `${(n / 1e3).toFixed(n >= 1e4 ? 0 : 1)}k`
107  return `${Math.round(n)}`
108}
109
110export function fmtUsd(usd: number): string {
111  if (!Number.isFinite(usd)) return '$—'
112  if (usd < 0.01 && usd > 0) return '<$0.01'
113  return usd < 100 ? `$${usd.toFixed(2)}` : `$${Math.round(usd)}`
114}
115
116/** 0:42, 3:07, 1:02:33. */
117export function fmtClock(ms: number): string {
118  const s = Math.max(0, Math.round(ms / 1000))
119  const h = Math.floor(s / 3600)
120  const m = Math.floor((s % 3600) / 60)
121  const ss = String(s % 60).padStart(2, '0')
122  return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
123}
124
125/** 42s, 7m, 2h41m, 3d4h: compact durations for countdowns. */
126export function fmtSpan(ms: number): string {
127  const s = Math.max(0, Math.round(ms / 1000))
128  if (s < 60) return `${s}s`
129  const m = Math.floor(s / 60)
130  if (m < 60) return `${m}m`
131  const h = Math.floor(m / 60)
132  if (h < 48) return `${h}h${String(m % 60).padStart(2, '0')}m`
133  return `${Math.floor(h / 24)}d${h % 24}h`
134}
135
136export function fmtPct(p: number | undefined): string {
137  return p === undefined || !Number.isFinite(p) ? '—' : `${Math.round(p)}%`
138}
139
140export function clip(s: string, max: number): string {
141  const one = s.replace(/\s+/g, ' ').trim()
142  return one.length > max ? one.slice(0, Math.max(1, max - 1)) + '…' : one
143}
144
145export function padEnd(s: string, n: number): string {
146  return s.length >= n ? s.slice(0, n) : s + ' '.repeat(n - s.length)
147}
148
149export function padStart(s: string, n: number): string {
150  return s.length >= n ? s.slice(s.length - n) : ' '.repeat(n - s.length) + s
151}
152
153/** `claude-opus-5-5[1m]` → `Opus 5.5`. */
154export function modelName(id: string | undefined): string {
155  if (!id) return '—'
156  const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
157  if (!m) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '')
158  const fam = m[1]!
159  return `${fam.charAt(0).toUpperCase()}${fam.slice(1).toLowerCase()} ${m[2]}${m[3] ? '.' + m[3] : ''}`
160}
161
162/** Last path segment, either slash. */
163export function baseName(p: string): string {
164  const parts = p.split(/[\\/]/).filter(Boolean)
165  return parts[parts.length - 1] ?? p
166}
167
168/** A stable small hash for seeding. */
169export function hash(s: string): number {
170  let h = 2166136261
171  for (let i = 0; i < s.length; i++) {
172    h ^= s.charCodeAt(i)
173    h = Math.imul(h, 16777619)
174  }
175  return h >>> 0
176}
177
178/** Deterministic 0..1 noise from two ints. */
179export function noise(x: number, y: number): number {
180  const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
181  return s - Math.floor(s)
182}
183
184/** A seeded PRNG (mulberry32). */
185export function rng(seed: number): () => number {
186  let a = seed >>> 0
187  return () => {
188    a = (a + 0x6d2b79f5) >>> 0
189    let t = a
190    t = Math.imul(t ^ (t >>> 15), t | 1)
191    t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
192    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
193  }
194}
195
196// ---------------------------------------------------------------------------
197// Text gauges for the terminal.
198
199const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
200
201/** A smooth bar with eighth blocks: `█████▍    `. */
202export function bar(ratio: number, width: number, empty = '░'): string {
203  const w = Math.max(1, Math.floor(width))
204  const exact = clamp01(ratio) * w
205  const full = Math.floor(exact)
206  const part = EIGHTHS[Math.floor((exact - full) * 8)] ?? ''
207  const used = full + (part ? 1 : 0)
208  return '█'.repeat(full) + part + empty.repeat(Math.max(0, w - used))
209}
210
211/** A segmented gauge: `▰▰▰▱▱▱`. */
212export function pips(ratio: number, width: number, on = '▰', off = '▱'): string {
213  const w = Math.max(1, Math.floor(width))
214  const n = Math.round(clamp01(ratio) * w)
215  return on.repeat(n) + off.repeat(w - n)
216}
217
218const SPARK = '▁▂▃▄▅▆▇█'
219
220/** A sparkline of the last `width` values, scaled to their own max (or `max`). */
221export function sparkline(values: readonly number[], width: number, max?: number): string {
222  const tail = values.slice(-Math.max(1, width))
223  const top = max ?? Math.max(1e-9, ...tail)
224  const line = tail.map(v => SPARK[Math.min(7, Math.max(0, Math.round((v / top) * 7)))] ?? '▁').join('')
225  return line.padStart(width, ' ')
226}
227
228/** Braille line graph, 2 samples per cell, `rows` cells tall. */
229export function brailleGraph(values: readonly number[], width: number, rows: number, max?: number): string[] {
230  const samples = values.slice(-(width * 2))
231  const top = max ?? Math.max(1e-9, ...samples)
232  const dotsTall = rows * 4
233  const grid: number[][] = Array.from({ length: rows }, () => Array.from({ length: width }, () => 0))
234  const offset = width * 2 - samples.length
235  // Dot bits per column, from the bottom row of a cell up.
236  const LEFT = [0x40, 0x04, 0x02, 0x01]
237  const RIGHT = [0x80, 0x20, 0x10, 0x08]
238  samples.forEach((v, i) => {
239    const x = offset + i
240    const col = Math.floor(x / 2)
241    const isRight = x % 2 === 1
242    const h = Math.round(clamp01(v / top) * (dotsTall - 1))
243    for (let d = 0; d <= h; d++) {
244      const row = rows - 1 - Math.floor(d / 4)
245      const bits = isRight ? RIGHT : LEFT
246      const cellRow = grid[row]
247      if (cellRow && col >= 0) cellRow[col] = (cellRow[col] ?? 0) | bits[d % 4]!
248    }
249  })
250  return grid.map(r => r.map(b => String.fromCharCode(0x2800 + b)).join(''))
251}
252
253// ---------------------------------------------------------------------------
254// Raster: a grid of colored cells, packed as the engine wants it.
255
256const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
257
258export function toBase64(bytes: Uint8Array): string {
259  let out = ''
260  let i = 0
261  for (; i + 2 < bytes.length; i += 3) {
262    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8) | bytes[i + 2]!
263    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + B64[n & 63]!
264  }
265  const rest = bytes.length - i
266  if (rest === 1) {
267    const n = bytes[i]! << 16
268    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + '=='
269  } else if (rest === 2) {
270    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8)
271    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + '='
272  }
273  return out
274}
275
276/** The terminal's own default color, for a cell's foreground or background. */
277export const DEFAULT_COLOR = 0x01000000
278
279export class Canvas {
280  readonly cols: number
281  readonly rows: number
282  private readonly words: Uint32Array
283
284  constructor(cols: number, rows: number) {
285    this.cols = Math.max(1, Math.min(512, Math.floor(cols)))
286    this.rows = Math.max(1, Math.min(256, Math.floor(rows)))
287    this.words = new Uint32Array(this.cols * this.rows * 3)
288    this.clear()
289  }
290
291  clear(bg: number = DEFAULT_COLOR): void {
292    for (let i = 0; i < this.cols * this.rows; i++) {
293      this.words[i * 3] = 0x20
294      this.words[i * 3 + 1] = DEFAULT_COLOR
295      this.words[i * 3 + 2] = bg
296    }
297  }
298
299  /** Puts one width-1 character; colors are '#rrggbb' or a packed int. */
300  set(x: number, y: number, ch: string, fg?: string | number, bg?: string | number): void {
301    const cx = Math.floor(x)
302    const cy = Math.floor(y)
303    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
304    const i = (cy * this.cols + cx) * 3
305    const code = ch.codePointAt(0) ?? 0x20
306    this.words[i] = code > 0xffff || code < 0x20 ? 0x20 : code
307    if (fg !== undefined) this.words[i + 1] = typeof fg === 'number' ? fg : hexToInt(fg)
308    if (bg !== undefined) this.words[i + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
309  }
310
311  /** Writes a string left to right, clipped to the canvas. */
312  text(x: number, y: number, s: string, fg?: string | number, bg?: string | number): void {
313    let cx = x
314    for (const ch of s) {
315      this.set(cx, y, ch, fg, bg)
316      cx++
317    }
318  }
319
320  /** Paints a cell's background only, keeping its character. */
321  paint(x: number, y: number, bg: string | number): void {
322    const cx = Math.floor(x)
323    const cy = Math.floor(y)
324    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
325    this.words[(cy * this.cols + cx) * 3 + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
326  }
327
328  /** Two vertical pixels per cell with the upper-half block: `py` is in half-cells. */
329  pixel(x: number, py: number, color: string): void {
330    const cx = Math.floor(x)
331    const cy = Math.floor(py / 2)
332    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
333    const i = (cy * this.cols + cx) * 3
334    const c = hexToInt(color)
335    const isTop = Math.floor(py) % 2 === 0
336    if (this.words[i] !== 0x2580) {
337      this.words[i] = 0x2580
338      this.words[i + 1] = DEFAULT_COLOR
339      this.words[i + 2] = DEFAULT_COLOR
340    }
341    if (isTop) this.words[i + 1] = c
342    else this.words[i + 2] = c
343  }
344
345  /** The `cells` prop of a Raster. */
346  encode(): string {
347    return toBase64(new Uint8Array(this.words.buffer))
348  }
349}
350
351// ---------------------------------------------------------------------------
352// SVG for the desktop: one drawing per row (the desktop wraps siblings).
353
354export const FONT = "-apple-system,BlinkMacSystemFont,'Segoe UI',Inter,sans-serif"
355export const MONO = "ui-monospace,'Cascadia Code','SF Mono',Consolas,monospace"
356
357const XML_ESC: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
358
359export function xml(s: string): string {
360  return s.replace(/[&<>"']/g, c => XML_ESC[c]!)
361}
362
363/** Theme-aware classes every KOZMOS drawing shares: t text, s secondary, m muted, k track, p panel. */
364export const SVG_BASE_CSS = `
365.t{fill:#1f1f1f}.s{fill:#5f5f5c}.m{fill:#8e8e8a}.k{fill:#e7e5e0}.p{fill:#f5f4f1}.ln{stroke:#e1dfda}
366@media (prefers-color-scheme: dark){.t{fill:#ededed}.s{fill:#b4b4b0}.m{fill:#7c7c78}.k{fill:#2d2d2b}.p{fill:#232322}.ln{stroke:#363634}}
367.pulse{animation:kzp 1.6s ease-in-out infinite}@keyframes kzp{50%{opacity:.35}}
368.spin{transform-box:fill-box;transform-origin:center;animation:kzs 2s linear infinite}@keyframes kzs{to{transform:rotate(360deg)}}
369@media (prefers-reduced-motion: reduce){*{animation:none!important}}
370`
371
372export function svg(width: number, height: number, body: string, css = ''): string {
373  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><style>${SVG_BASE_CSS}${css}</style>${body}</svg>`
374}
375
376/** Rough width of UI text in px, for fitting labels. */
377export function textWidth(s: string, size: number): number {
378  let w = 0
379  for (const ch of s) w += (/[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-Z@%MWmw]/.test(ch) ? 0.72 : 0.56) * size
380  return w
381}
382
383export function fitText(s: string, size: number, maxW: number): string {
384  if (textWidth(s, size) <= maxW) return s
385  let out = ''
386  for (const ch of s) {
387    if (textWidth(out + ch + '…', size) > maxW) break
388    out += ch
389  }
390  return out + '…'
391}
392
393/** A rounded progress bar as SVG markup. */
394export function svgBar(x: number, y: number, w: number, h: number, ratio: number, color: string): string {
395  const fw = Math.max(0, Math.min(w, w * clamp01(ratio)))
396  return `<rect class="k" x="${x}" y="${y}" width="${w}" height="${h}" rx="${h / 2}"/>` +
397    (fw > 0 ? `<rect x="${x}" y="${y}" width="${Math.max(h, fw)}" height="${h}" rx="${h / 2}" fill="${color}"/>` : '')
398}
399
400/** SVG text helper. */
401export function svgText(x: number, y: number, s: string, opts: { cls?: string; size?: number; weight?: number; anchor?: 'start' | 'middle' | 'end'; fill?: string; mono?: boolean } = {}): string {
402  const { cls = 't', size = 12, weight = 400, anchor = 'start', fill, mono = false } = opts
403  return `<text ${fill ? `fill="${fill}"` : `class="${cls}"`} x="${x}" y="${y}" font-family="${mono ? MONO : FONT}" font-size="${size}" font-weight="${weight}" text-anchor="${anchor}" font-variant-numeric="tabular-nums">${xml(s)}</text>`
404}
405
406/** Pixel width a desktop pane or band gives a drawing for `columns` reported columns. */
407export function pxOf(columns: number | undefined, fallback = 60, slack = 8): number {
408  return Math.max(200, Math.min(1600, (columns || fallback) * 8 - slack))
409}
410
411// ---------------------------------------------------------------------------
412// Prices, USD per million tokens: input, output, cache read, cache write (5 min).
413// Anthropic first-party rates as of 2026-10. Used only where the engine reports
414// tokens and not money (a subagent's own spend); the session total comes from
415// $.session.usage().cost, which is the engine's.
416
417const PRICES: [RegExp, [number, number, number, number]][] = [
418  [/fable|mythos/i, [10, 50, 0.25, 12.5]],
419  [/opus-5-5/i, [4, 20, 0.2, 5]],
420  [/opus/i, [5, 25, 0.5, 6.25]],
421  [/sonnet-5/i, [2, 10, 0.2, 2.5]],
422  [/sonnet/i, [3, 15, 0.3, 3.75]],
423  [/haiku-5/i, [0.1, 0.5, 0.01, 0.125]],
424  [/haiku/i, [1, 5, 0.1, 1.25]],
425]
426
427export type Usage = {
428  input_tokens?: number
429  output_tokens?: number
430  cache_read_input_tokens?: number
431  cache_creation_input_tokens?: number
432}
433
434export function priceOf(model: string): [number, number, number, number] {
435  return PRICES.find(([re]) => re.test(model))?.[1] ?? [4, 20, 0.2, 5]
436}
437
438export function costOf(model: string, u: Usage | null | undefined): number {
439  if (!u) return 0
440  const [i, o, r, w] = priceOf(model)
441  return ((u.input_tokens ?? 0) * i + (u.output_tokens ?? 0) * o + (u.cache_read_input_tokens ?? 0) * r + (u.cache_creation_input_tokens ?? 0) * w) / 1e6
442}
443
444export function tokensOf(u: Usage | null | undefined): number {
445  if (!u) return 0
446  return (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
447}
448
449/** The context a request filled: everything it read plus what it wrote. */
450export function contextOf(u: Usage | null | undefined): number {
451  return tokensOf(u)
452}
453
454export function windowOf(model: string): number {
455  return /haiku-4/i.test(model) ? 200_000 : 1_000_000
456}
457
458// ---------------------------------------------------------------------------
459// Rate limits.
460
461export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
462
463export function limitLabel(kind: string): string {
464  if (kind === 'five_hour') return '5h'
465  if (kind === 'seven_day') return '7d'
466  if (kind === 'spend_limit') return '$'
467  return kind.replace(/_/g, ' ')
468}
469
470/** Milliseconds until a window resets, or undefined. */
471export function untilReset(l: Limit, now: number): number | undefined {
472  if (!l.resetsAt) return undefined
473  const t = Date.parse(l.resetsAt)
474  return Number.isFinite(t) ? Math.max(0, t - now) : undefined
475}
476
477/** The length of a window in ms, for burn-rate math. */
478export function windowMs(kind: string): number | undefined {
479  if (kind === 'five_hour') return 5 * 3600_000
480  if (kind === 'seven_day') return 7 * 24 * 3600_000
481  return undefined
482}
483
484// ---------------------------------------------------------------------------
485// Tool calls, described in a few words.
486
487/** What a tool call is about: the command, the file, the pattern, the agent's task. */
488export function toolDetail(tool: string, input: unknown): string {
489  const e = (input ?? {}) as Record<string, unknown>
490  const s = (k: string) => (typeof e[k] === 'string' ? (e[k] as string) : '')
491  const t = String(tool)
492  if (t === 'Bash' || t === 'PowerShell') return clip(s('description') || s('command'), 60)
493  if (s('file_path')) return baseName(s('file_path'))
494  if (s('notebook_path')) return baseName(s('notebook_path'))
495  if (t === 'Agent') return clip(s('description') || s('subagent_type'), 60)
496  if (s('pattern')) return clip(s('pattern'), 60)
497  if (s('url')) return clip(s('url').replace(/^https?:\/\//, ''), 60)
498  if (s('query')) return clip(s('query'), 60)
499  if (s('subject')) return clip(s('subject'), 60)
500  if (Array.isArray(e.todos)) return `${(e.todos as unknown[]).length} todos`
501  if (t.startsWith('mcp__')) return t.split('__').slice(1).join(' · ')
502  return ''
503}
504
505/** `mcp__server__tool` → `server·tool`; built-ins unchanged. */
506export function toolName(tool: string): string {
507  const t = String(tool)
508  return t.startsWith('mcp__') ? t.split('__').slice(1).join('·') : t
509}
510
types/index.d.ts 42 lines
1export type HiveStatus = 'running' | 'done' | 'failed' | 'waiting'
2
3export type HiveTool = { id: string; name: string; detail: string; startedAt: number }
4
5export type HiveLastTool = { name: string; detail: string; ms: number; isError: boolean }
6
7export type HiveNode = {
8  id: string
9  parentId?: string
10  type: string
11  description: string
12  status: HiveStatus
13  model?: string
14  effort?: string
15  startedAt: number
16  endedAt?: number
17  steps: number
18  tokens: number
19  usd: number
20  ctxPct?: number
21  tools: number
22  tool?: HiveTool
23  lastTool?: HiveLastTool
24  isBackground?: boolean
25}
26
27export type HiveSnap = {
28  root: HiveNode
29  agents: HiveNode[]
30  /** Running subagents, one sample per second while anything runs. */
31  history: number[]
32  now: number
33}
34
35export type HiveView = { showDone: boolean }
36
37declare module 'claude-code' {
38  interface PluginState {
39    hivemind: { snap: HiveSnap | null; view: HiveView }
40  }
41}
42