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…

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:
◐◓◑◒ while it runs, ✓ done, ✖ failed, ⏸ waiting or idle⇢bg for background agents)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.
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:
◐◓◑◒, bitince ✓, hata olursa ✖, beklerken ⏸⇢bg)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.hooks/register.tsx 567 lines1import { 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}
567hooks/lib/kz.ts 510 lines1// 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> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
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}
510types/index.d.ts 42 lines1export 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