KOZMOS Blackbox: a flight recorder above the prompt. A live Gantt of the current turn: the main loop and each subagent on its own lane, every tool call a bar…

A flight recorder above the prompt: a live Gantt chart of the current turn.
$.agent.list()); the busiest lanes are shown and the rest counted as +N.Terminal: one Raster with a label column (an axis row plus up to three lanes, fewer on a short band), repainted by $.ui.blit eight times a second while the turn runs, so the cursor and running bars move between events. Desktop: one SVG with up to five lanes; between redraws the cursor glides and running bars grow at the axis' pace with CSS.
/blackbox shows or hides it; ✕ hides it. The choice is kept across sessions.
İstemin üstünde bir uçuş kayıt cihazı: o anki turun canlı Gantt şeması.
$.agent.list()'ten); en hareketli şeritler gösterilir, kalanlar +N olarak sayılır.Terminal: etiket sütunlu tek bir Raster (eksen satırı ve en çok üç şerit; kısa bantta daha az), tur sürerken saniyede sekiz kez $.ui.blit ile yeniden boyanır; imleç ve süren çubuklar olaylar arasında da ilerler. Masaüstü: en çok beş şeritli tek SVG; yeniden çizimler arasında imleç ve süren çubuklar CSS ile eksenin hızında ilerler.
/blackbox gösterir veya gizler; ✕ gizler. Seçim oturumlar arasında saklanır.
hooks/register.tsx 219 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { BbBar, BbTurn, BlackboxSnap } from '../types'
5import { recorderSvg } from './svg.ts'
6import { addBar, addTick, axisSpan, MAIN, paint } from './timeline.ts'
7
8const snapAtom = atom({ plugin: 'blackbox', key: 'snap' } as const, null)
9const hiddenAtom = atom({ plugin: 'blackbox', key: 'isHidden' } as const, false)
10
11/** Lanes the terminal shows: an axis row plus three keeps the band at four rows. */
12const TERMINAL_LANES = 3
13const BLIT_MS = 125
14
15// ---------------------------------------------------------------------------
16// The live recording. Module variables start over on a reload.
17
18let turn: BbTurn | null = null
19let isLive = false
20let isHiddenNow = false
21const names = new Map<string, string>()
22let lastSpan = 0
23let lastPublishAt = 0
24let blitTimer: Timer | undefined
25let syncTimer: Timer | undefined
26/** Where the terminal band last drew the Raster, so the timer can repaint it. */
27let mounted: { requestId: string; columns: number; rows: number; lanes: number } | undefined
28
29function copyTurn(t: BbTurn): BbTurn {
30 return { ...t, lanes: [...t.lanes], labels: { ...t.labels }, bars: t.bars.map(b => ({ ...b })), ticks: t.ticks.map(k => ({ ...k })) }
31}
32
33async function publish($: EngineInterface, t: BbTurn): Promise<void> {
34 const now = await $.clock.now()
35 lastPublishAt = now
36 lastSpan = axisSpan((t.endedAt ?? now) - t.startedAt, isLive)
37 const snap: BlackboxSnap = { turn: copyTurn(t), isLive, now }
38 await update($, snapAtom, () => snap)
39}
40
41function laneOf(agentId: string | undefined): string {
42 if (agentId === undefined) return MAIN
43 const name = names.get(agentId)
44 if (turn && !turn.labels[agentId] && name) turn.labels[agentId] = name
45 return agentId
46}
47
48/**
49 * Between events: repaint the terminal Raster of live turn `t` so the cursor
50 * and running bars move. Its timer stops with the turn.
51 */
52async function frame($: EngineInterface, t: BbTurn): Promise<void> {
53 if (!mounted || isHiddenNow) return
54 const now = await $.clock.now()
55 // The Canvas clamps columns the same way at every paint: only the rows can differ.
56 const f = paint(t, now, mounted.columns, mounted.lanes, true)
57 if (f.rows !== mounted.rows) return
58 try {
59 const r = await $.ui.blit({ requestId: mounted.requestId, key: 'blackbox-rec', cells: f.cells })
60 // Refused (the Raster is gone or resized): wait for the band to draw again.
61 if (r.deny !== undefined) mounted = undefined
62 } catch {
63 mounted = undefined
64 }
65}
66
67/** Once a second while turn `t` is live: names for new lanes, and a fresh snapshot when the axis rescales. */
68async function sync($: EngineInterface, t: BbTurn): Promise<void> {
69 const now = await $.clock.now()
70 const unnamed = t.lanes.filter(l => l !== MAIN && !t.labels[l])
71 if (unnamed.length) {
72 try {
73 for (const a of await $.agent.list()) if (a.description) names.set(a.id, a.description)
74 for (const l of unnamed) laneOf(l)
75 } catch {
76 // Unnamed lanes keep their short id.
77 }
78 }
79 const span = axisSpan(now - t.startedAt, true)
80 if (span !== lastSpan || now - lastPublishAt >= 5000 || unnamed.length) await publish($, t)
81}
82
83function stopTimers(): void {
84 blitTimer?.cancel()
85 syncTimer?.cancel()
86 blitTimer = syncTimer = undefined
87}
88
89async function setHidden($: EngineInterface, isHidden: boolean): Promise<void> {
90 isHiddenNow = isHidden
91 await update($, hiddenAtom, () => isHidden)
92 try {
93 await $.store.set('isHidden', isHidden)
94 } catch {
95 // Hidden for this session only.
96 }
97}
98
99async function loadHidden($: EngineInterface): Promise<void> {
100 try {
101 const v = await $.store.get('isHidden')
102 if (typeof v === 'boolean') {
103 isHiddenNow = v
104 await update($, hiddenAtom, () => v)
105 }
106 } catch {
107 // Nothing stored.
108 }
109}
110
111export const register: Register = on => {
112 on('session.start', async ($, e, next) => {
113 const started = await next(e)
114 turn = null
115 isLive = false
116 mounted = undefined
117 stopTimers()
118 await $.command.register({ name: 'blackbox', description: 'KOZMOS: show or hide the Blackbox flight recorder above the prompt', immediate: true })
119 await loadHidden($)
120 return started
121 })
122
123 on('command.run', { command: 'blackbox' }, async $ => {
124 const isHidden = !(await read($, hiddenAtom))
125 await setHidden($, isHidden)
126 return { text: isHidden ? 'Blackbox hidden. /blackbox brings it back.' : 'Blackbox shown: it records each turn as it runs.' }
127 })
128
129 on('turn.start', async ($, e, next) => {
130 const now = await $.clock.now()
131 const t: BbTurn = { turnId: e.turnId, startedAt: now, endedAt: null, lanes: [MAIN], labels: {}, bars: [], ticks: [] }
132 turn = t
133 isLive = true
134 stopTimers()
135 blitTimer = $.clock.every(BLIT_MS, () => void frame($, t).catch(() => undefined))
136 syncTimer = $.clock.every(1000, () => void sync($, t).catch(() => undefined))
137 await publish($, t)
138 return next(e)
139 })
140
141 on('turn.complete', async ($, e, next) => {
142 if (e.agentId === undefined && turn && isLive) {
143 turn.endedAt = await $.clock.now()
144 isLive = false
145 stopTimers()
146 await publish($, turn)
147 }
148 return next(e)
149 })
150
151 on('agent.spawn', async ($, e, next) => {
152 const spawned = await next(e)
153 if (spawned.agentId) names.set(spawned.agentId, e.description || e.subagentType)
154 return spawned
155 }).catch(($, e, next) => next(e))
156
157 on('turn.step', async function* ($, e, next) {
158 if (turn && isLive) {
159 addTick(turn, laneOf(e.agentId), await $.clock.now())
160 void publish($, turn).catch(() => undefined)
161 }
162 return yield* next(e)
163 })
164
165 on('tool.call', async ($, e, next) => {
166 if (!turn || !isLive) return next(e)
167 const recording = turn
168 const bar: BbBar = { lane: laneOf(e.agentId), tool: String(e.tool), s: await $.clock.now(), e: null, isError: false }
169 addBar(recording, bar)
170 void publish($, recording).catch(() => undefined)
171 const ran = await next(e)
172 bar.e = await $.clock.now()
173 bar.isError = ran.isError === true || ran.deny !== undefined
174 if (turn === recording) void publish($, recording).catch(() => undefined)
175 return ran
176 }).catch(($, e, next) => next(e))
177
178 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
179 const drawn = await next(e)
180 if (e.props.hasSurvey || (await read($, hiddenAtom))) return drawn
181 const snap = await read($, snapAtom)
182 if (!snap) return drawn
183 const cols = Math.max(24, (e.props.bodyColumns || 80) - 3)
184
185 if (e.surface === 'terminal') {
186 const { Box, Button, Raster } = $.ui.resolve(e)
187 const now = snap.isLive ? Math.max(snap.now, await $.clock.now()) : snap.now
188 // Fewer lanes when the band is short, so the whole band keeps its room.
189 const lanes = e.props.maxRows >= 10 ? TERMINAL_LANES : e.props.maxRows >= 6 ? 2 : 1
190 const f = paint(snap.turn, now, cols, lanes, snap.isLive)
191 // A module variable, not state: the blit timer's address for this Raster.
192 mounted = { requestId: e.requestId, columns: f.columns, rows: f.rows, lanes }
193 return (
194 <Box flexDirection="column">
195 {drawn}
196 <Box key="blackbox" flexDirection="row">
197 <Raster key="blackbox-rec" columns={f.columns} rows={f.rows} cells={f.cells} />
198 <Button key="blackbox-hide" label="✕" plain dimColor role="dismiss" onPress={() => void setHidden($, true)} />
199 </Box>
200 </Box>
201 )
202 }
203
204 // Every other surface draws Svg.
205 const { Box, Button, Svg } = $.ui.resolve(e)
206 const pic = recorderSvg(snap, cols * 8 - 8)
207 return (
208 <Box flexDirection="column">
209 {drawn}
210 <Box key="blackbox" flexDirection="row">
211 <Svg source={pic.source} alt={pic.alt} width={pic.width} height={pic.height} />
212 <Button key="blackbox-hide" label="✕" plain dimColor role="dismiss" onPress={() => void setHidden($, true)} />
213 </Box>
214 </Box>
215 )
216 })
217}
218
219hooks/svg.ts 112 lines1// Blackbox's desktop drawing: the recording as one SVG. Between redraws the
2// compositor keeps it moving: the now cursor glides to the end of the axis
3// and every running bar grows with it, at exactly the axis' pace.
4import type { BlackboxSnap } from '../types'
5import { KZ, fitText, svg, svgText, textWidth, toolColor, toolName, xml } from './lib/kz.ts'
6import { axisSpan, axisTicks, fmtAxis, fmtElapsed, laneLabel, levels, MAIN, pickLanes } from './timeline.ts'
7
8const CSS = `
9.grid{stroke:#e4e2dc}.trk{fill:#ebe9e4}
10@media (prefers-color-scheme: dark){.grid{stroke:#323230}.trk{fill:#2a2a28}}
11.rec{animation:bbr 1s steps(2,jump-none) infinite}@keyframes bbr{50%{opacity:.15}}
12.live{animation:bbl 1.2s ease-in-out infinite}@keyframes bbl{50%{opacity:.55}}
13`
14
15export const DESKTOP_LANES = 5
16
17export function recorderSvg(snap: BlackboxSnap, width: number): { source: string; width: number; height: number; alt: string } {
18 const { turn, isLive, now } = snap
19 const W = Math.max(320, width)
20 const { lanes, hidden } = pickLanes(turn, DESKTOP_LANES)
21 const labelW = Math.min(150, Math.max(92, W * 0.16))
22 const stripX = labelW + 10
23 const stripW = W - stripX - 12
24 const top = 24
25 const laneH = 19
26 const H = top + lanes.length * laneH + 8
27 const end = turn.endedAt ?? now
28 const elapsed = Math.max(0, end - turn.startedAt)
29 const span = axisSpan(elapsed, isLive)
30 const xOf = (t: number) => stripX + Math.max(0, Math.min(1, (t - turn.startedAt) / span)) * stripW
31 const remaining = Math.max(0.05, (span - elapsed) / 1000)
32 const css: string[] = [CSS]
33 const p: string[] = []
34
35 p.push(`<rect class="p" x="0" y="0" width="${W}" height="${H}" rx="10"/>`)
36 p.push(`<defs><filter id="bbg" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="1.4"/></filter></defs>`)
37 // Header: recorder light and clock, then the axis labels.
38 if (isLive) {
39 p.push(`<circle class="rec" cx="14" cy="13" r="4" fill="${KZ.red}"/>`)
40 p.push(svgText(24, 17, `REC ${fmtElapsed(elapsed)}`, { size: 11, weight: 750, fill: KZ.red }))
41 } else {
42 p.push(`<rect x="10" y="9" width="8" height="8" rx="1.5" class="m" fill="#8e8e8a"/>`)
43 p.push(svgText(24, 17, `LAST TURN ${fmtElapsed(elapsed)}`, { cls: 's', size: 10.5, weight: 700 }))
44 }
45 const tools = turn.bars.length
46 const sub = `${tools} tool${tools === 1 ? '' : 's'}${hidden ? ` · +${hidden}` : ''}`
47 const headW = 24 + textWidth(isLive ? `REC ${fmtElapsed(elapsed)}` : `LAST TURN ${fmtElapsed(elapsed)}`, 11) + 8
48 if (headW + textWidth(sub, 9.5) < labelW + 2) p.push(svgText(labelW + 2, 17, sub, { cls: 'm', size: 9.5, anchor: 'end' }))
49 for (const t of axisTicks(span, Math.max(2, Math.floor(stripW / 64)))) {
50 const x = stripX + (t / span) * stripW
51 p.push(`<line class="grid" x1="${x.toFixed(1)}" y1="${top - 3}" x2="${x.toFixed(1)}" y2="${H - 6}" stroke-width="1" stroke-dasharray="2 3"/>`)
52 p.push(svgText(Math.min(x + 3, W - 14), 16, fmtAxis(t), { cls: 'm', size: 9, anchor: x > W - 30 ? 'end' : 'start' }))
53 }
54
55 const body: string[] = []
56 lanes.forEach((lane, i) => {
57 const y = top + i * laneH
58 const color = lane === MAIN ? KZ.cyan : KZ.violet
59 body.push(`<rect x="8" y="${y + 3}" width="3" height="${laneH - 6}" rx="1.5" fill="${color}"/>`)
60 body.push(svgText(16, y + laneH - 5, fitText(laneLabel(turn, lane), 10.5, labelW - 18), { cls: lane === MAIN ? 't' : 's', size: 10.5, weight: lane === MAIN ? 650 : 500 }))
61 body.push(`<rect class="trk" x="${stripX}" y="${y + laneH / 2 - 1}" width="${stripW}" height="2" rx="1"/>`)
62 for (const t of turn.ticks) {
63 if (t.lane !== lane) continue
64 const x = xOf(t.at).toFixed(1)
65 body.push(`<line x1="${x}" y1="${y + 1.5}" x2="${x}" y2="${y + laneH - 1.5}" stroke="${KZ.violet}" stroke-width="1" opacity=".55"/>`)
66 }
67 let n = 0
68 const mine = turn.bars.filter(b => b.lane === lane)
69 const lv = levels(mine, end)
70 for (const b of mine) {
71 const m = lv.get(b)
72 const bh = m?.overlaps ? (laneH - 6) / 2 - 0.5 : laneH - 6
73 const by = y + 3 + (m?.overlaps && m.level === 1 ? bh + 1 : 0)
74 const x0 = xOf(b.s)
75 const isRunning = b.e === null && isLive
76 const x1 = xOf(b.e ?? end)
77 const w = Math.max(2.5, x1 - x0)
78 const c = b.isError ? KZ.red : toolColor(b.tool)
79 const tip = `<title>${xml(toolName(b.tool))} · ${b.e === null ? 'running' : `${((b.e - b.s) / 1000).toFixed(1)}s`}${b.isError ? ' · failed' : ''}</title>`
80 if (isRunning) {
81 const full = Math.max(w, stripX + stripW - x0)
82 css.push(`@keyframes g${n}${i}{from{transform:scaleX(${(w / full).toFixed(4)})}to{transform:scaleX(1)}}`)
83 body.push(`<rect x="${x0.toFixed(1)}" y="${by}" width="${full.toFixed(1)}" height="${bh}" rx="${Math.min(2.5, bh / 2)}" fill="${c}" filter="url(#bbg)" opacity=".55" style="transform-box:fill-box;transform-origin:left center;animation:g${n}${i} ${remaining.toFixed(2)}s linear both"/>`)
84 body.push(`<rect class="live" x="${x0.toFixed(1)}" y="${by}" width="${full.toFixed(1)}" height="${bh}" rx="${Math.min(2.5, bh / 2)}" fill="${c}" style="transform-box:fill-box;transform-origin:left center;animation:g${n}${i} ${remaining.toFixed(2)}s linear both,bbl 1.2s ease-in-out infinite">${tip}</rect>`)
85 } else {
86 body.push(`<rect x="${x0.toFixed(1)}" y="${by}" width="${w.toFixed(1)}" height="${bh}" rx="${Math.min(2.5, bh / 2)}" fill="${c}"${b.isError ? ` stroke="${KZ.red}" stroke-width="1.2" fill-opacity=".45"` : ''}>${tip}</rect>`)
87 }
88 n++
89 }
90 })
91 p.push(isLive ? body.join('') : `<g opacity=".42">${body.join('')}</g>`)
92
93 if (isLive) {
94 const x = xOf(now)
95 css.push(`@keyframes cur{from{transform:translateX(0)}to{transform:translateX(${(stripX + stripW - x).toFixed(1)}px)}}`)
96 p.push(`<g style="animation:cur ${remaining.toFixed(2)}s linear both">`)
97 p.push(`<line x1="${x.toFixed(1)}" y1="${top - 2}" x2="${x.toFixed(1)}" y2="${H - 5}" stroke="${KZ.magenta}" stroke-width="3" opacity=".35" filter="url(#bbg)"/>`)
98 p.push(`<line x1="${x.toFixed(1)}" y1="${top - 2}" x2="${x.toFixed(1)}" y2="${H - 5}" stroke="${KZ.magenta}" stroke-width="1.4"/>`)
99 p.push(`<path d="M${(x - 4).toFixed(1)} ${top - 6}h8l-4 5z" fill="${KZ.magenta}"/>`)
100 p.push(`</g>`)
101 }
102 return { source: svg(W, H, p.join(''), css.join('')), width: W, height: H, alt: altOf(snap) }
103}
104
105export function altOf(snap: BlackboxSnap): string {
106 const { turn, isLive, now } = snap
107 const elapsed = (turn.endedAt ?? now) - turn.startedAt
108 const agents = turn.lanes.length - 1
109 const failed = turn.bars.filter(b => b.isError).length
110 return `Blackbox: ${isLive ? 'recording' : 'last turn'} ${fmtElapsed(elapsed)}, ${turn.bars.length} tool calls${failed ? ` (${failed} failed)` : ''}, ${turn.ticks.length} model requests, ${agents} subagent${agents === 1 ? '' : 's'}`
111}
112hooks/timeline.ts 193 lines1// Blackbox's flight recording and how it is laid out: pure, no `$`.
2import type { BbBar, BbTurn } from '../types'
3import { Canvas, KZ, mix, toolColor } from './lib/kz.ts'
4
5export const MAIN = 'main'
6const AXIS_STEPS_S = [5, 10, 15, 20, 30, 45, 60, 90, 120, 180, 240, 300, 450, 600, 900, 1200, 1800, 2700, 3600, 5400, 7200, 10800, 14400]
7const LABEL_STEPS_S = [1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600]
8export const MAX_BARS_PER_LANE = 160
9
10/** The axis length for a turn `elapsedMs` old: the next round span with a little room left. */
11export function axisSpan(elapsedMs: number, isLive: boolean): number {
12 if (!isLive) return Math.max(1000, elapsedMs)
13 const s = Math.max(0, elapsedMs) / 1000 * 1.05
14 const step = AXIS_STEPS_S.find(v => v >= s)
15 return (step ?? Math.ceil(s / 3600) * 3600) * 1000
16}
17
18/** Tick times (ms) for an axis `spanMs` long, at most `maxLabels` of them. */
19export function axisTicks(spanMs: number, maxLabels: number): number[] {
20 const span = spanMs / 1000
21 const step = LABEL_STEPS_S.find(v => span / v <= Math.max(1, maxLabels)) ?? 3600
22 const out: number[] = []
23 for (let t = 0; t <= span + 1e-6; t += step) out.push(t * 1000)
24 return out
25}
26
27export function fmtAxis(ms: number): string {
28 const s = Math.round(ms / 1000)
29 if (s < 60) return `${s}s`
30 const m = Math.floor(s / 60)
31 const r = s % 60
32 if (m < 60) return r ? `${m}m${String(r).padStart(2, '0')}` : `${m}m`
33 return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}`
34}
35
36export function fmtElapsed(ms: number): string {
37 const s = Math.max(0, Math.round(ms / 1000))
38 const h = Math.floor(s / 3600)
39 const m = Math.floor((s % 3600) / 60)
40 const ss = String(s % 60).padStart(2, '0')
41 return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
42}
43
44/** Which lanes fit: the main loop first, then the agents busiest lately; the rest are counted. */
45export function pickLanes(turn: BbTurn, maxLanes: number): { lanes: string[]; hidden: number } {
46 const agents = turn.lanes.filter(l => l !== MAIN)
47 const room = Math.max(0, maxLanes - 1)
48 if (agents.length <= room) return { lanes: [MAIN, ...agents], hidden: 0 }
49 const lastActivity = (lane: string): number => {
50 let at = 0
51 for (const b of turn.bars) if (b.lane === lane) at = Math.max(at, b.e ?? Number.MAX_SAFE_INTEGER, b.s)
52 for (const t of turn.ticks) if (t.lane === lane) at = Math.max(at, t.at)
53 return at
54 }
55 const chosen = [...agents].sort((a, b) => lastActivity(b) - lastActivity(a)).slice(0, room)
56 return { lanes: [MAIN, ...agents.filter(a => chosen.includes(a))], hidden: agents.length - chosen.length }
57}
58
59export function laneLabel(turn: BbTurn, lane: string): string {
60 if (lane === MAIN) return 'main'
61 return turn.labels[lane] || `agent ${lane.slice(0, 6)}`
62}
63
64/** Adds a bar, keeping each lane's newest `MAX_BARS_PER_LANE`. */
65export function addBar(turn: BbTurn, bar: BbBar): void {
66 if (!turn.lanes.includes(bar.lane)) turn.lanes.push(bar.lane)
67 turn.bars.push(bar)
68 const mine = turn.bars.filter(b => b.lane === bar.lane)
69 if (mine.length > MAX_BARS_PER_LANE) {
70 const drop = mine[0]
71 turn.bars = turn.bars.filter(b => b !== drop)
72 }
73}
74
75export function addTick(turn: BbTurn, lane: string, at: number): void {
76 if (!turn.lanes.includes(lane)) turn.lanes.push(lane)
77 turn.ticks.push({ lane, at })
78 if (turn.ticks.length > 600) turn.ticks = turn.ticks.slice(-600)
79}
80
81/**
82 * Parallel calls in one lane: each bar gets level 0 (top) or 1 (bottom), and
83 * `overlaps` says whether it shares time with another, so both surfaces can
84 * stack them as two half-height bars instead of painting one over the other.
85 */
86export function levels(bars: readonly BbBar[], end: number): Map<BbBar, { level: 0 | 1; overlaps: boolean }> {
87 const out = new Map<BbBar, { level: 0 | 1; overlaps: boolean }>()
88 const sorted = [...bars].sort((a, b) => a.s - b.s)
89 const busyUntil: [number, number] = [-Infinity, -Infinity]
90 for (const b of sorted) {
91 const level: 0 | 1 = b.s >= busyUntil[0] ? 0 : 1
92 const until = b.e ?? end
93 busyUntil[level] = Math.max(busyUntil[level], until)
94 out.set(b, { level, overlaps: false })
95 }
96 for (const [a, m] of out) {
97 for (const b of sorted) {
98 if (a === b) continue
99 if (a.s < (b.e ?? end) && b.s < (a.e ?? end)) {
100 m.overlaps = true
101 break
102 }
103 }
104 }
105 return out
106}
107
108// ---------------------------------------------------------------------------
109// The terminal picture: one Raster, a label column and the strip.
110
111const TRACK = '#3a3a38'
112const AXIS = '#6b6b67'
113const CURSOR = '#ff4fa3'
114
115export type Frame = { cells: string; columns: number; rows: number }
116
117const dimmed = (c: string, isLive: boolean): string => (isLive ? c : mix(c, '#5a5a58', 0.6))
118
119/**
120 * Paints the recording at `now`: an axis row, then one row per lane. While
121 * live the strip runs to the next round span and a pink cursor marks now.
122 */
123export function paint(turn: BbTurn, now: number, cols: number, maxLanes: number, isLive: boolean): Frame {
124 const { lanes, hidden } = pickLanes(turn, maxLanes)
125 const rows = 1 + lanes.length
126 const c = new Canvas(cols, rows)
127 const labelW = Math.max(8, Math.min(16, Math.floor(cols / 6)))
128 const stripX = labelW + 1
129 const stripW = Math.max(4, c.cols - stripX)
130 const end = turn.endedAt ?? now
131 const elapsed = Math.max(0, end - turn.startedAt)
132 const span = axisSpan(elapsed, isLive)
133 const xOf = (t: number): number => stripX + Math.min(stripW - 1, Math.max(0, Math.floor(((t - turn.startedAt) / span) * stripW)))
134
135 // Header: the recorder's light, the clock, the hidden-lane count.
136 const head = isLive ? `● REC ${fmtElapsed(elapsed)}` : `■ ${fmtElapsed(elapsed)}`
137 c.text(0, 0, head.slice(0, labelW), isLive ? KZ.red : AXIS)
138 if (hidden > 0) c.text(Math.min(labelW - 3, head.length + 1), 0, `+${hidden}`, KZ.violet)
139 for (let x = stripX; x < stripX + stripW; x++) c.set(x, 0, '─', TRACK)
140 const labelled = new Set<number>()
141 for (const t of axisTicks(span, Math.max(1, Math.floor(stripW / 9)))) {
142 const x = stripX + Math.min(stripW - 1, Math.round((t / span) * (stripW - 1)))
143 c.set(x, 0, '┬', AXIS)
144 const label = fmtAxis(t)
145 // Pulled left at the end of the strip, so it always fits.
146 const lx = Math.min(stripX + stripW - label.length, x + 1)
147 c.text(lx, 0, label, AXIS)
148 for (let i = 0; i < label.length; i++) labelled.add(lx + i)
149 }
150
151 lanes.forEach((lane, i) => {
152 const y = i + 1
153 const label = laneLabel(turn, lane)
154 const glyph = lane === MAIN ? '▸ ' : '◈ '
155 const text = (glyph + label).slice(0, labelW)
156 c.text(0, y, text.padEnd(labelW, ' '), dimmed(lane === MAIN ? KZ.cyan : KZ.violet, isLive))
157 c.set(labelW, y, '│', TRACK)
158 for (let x = stripX; x < stripX + stripW; x++) c.set(x, y, '·', TRACK)
159 for (const t of turn.ticks) if (t.lane === lane) c.set(xOf(t.at), y, '╎', dimmed(KZ.violet, isLive))
160 const mine = turn.bars.filter(b => b.lane === lane)
161 const lv = levels(mine, end)
162 const topC = new Map<number, string>()
163 const botC = new Map<number, string>()
164 const solo = new Map<number, { color: string; isError: boolean }>()
165 for (const b of mine) {
166 const x0 = xOf(b.s)
167 const x1 = Math.max(x0, xOf(b.e ?? end))
168 const color = dimmed(b.isError ? KZ.red : toolColor(b.tool), isLive)
169 const m = lv.get(b)
170 for (let x = x0; x <= x1; x++) {
171 if (!m?.overlaps) solo.set(x, { color, isError: b.isError })
172 else (m.level === 0 ? topC : botC).set(x, color)
173 }
174 }
175 for (const [x, v] of solo) if (!topC.has(x) && !botC.has(x)) c.set(x, y, v.isError ? '▓' : '█', v.color)
176 for (const x of new Set([...topC.keys(), ...botC.keys()])) {
177 const t = topC.get(x) ?? solo.get(x)?.color
178 const b = botC.get(x)
179 if (t && b) c.set(x, y, '▀', t, b)
180 else if (t) c.set(x, y, '▀', t)
181 // The cell is a key of one of the maps: no top means a bottom.
182 else c.set(x, y, '▄', b)
183 }
184 })
185
186 if (isLive) {
187 const x = xOf(now)
188 if (!labelled.has(x)) c.set(x, 0, '▼', CURSOR)
189 for (let y = 1; y < rows; y++) c.set(x, y, '┃', CURSOR)
190 }
191 return { cells: c.encode(), columns: c.cols, rows: c.rows }
192}
193hooks/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 32 lines1/** One tool call on the recording: its loop's lane, the tool, start and end (null while running). */
2export type BbBar = { lane: string; tool: string; s: number; e: number | null; isError: boolean }
3
4/** One model request: a thin tick where it was sent. */
5export type BbTick = { lane: string; at: number }
6
7export type BbTurn = {
8 turnId: string
9 startedAt: number
10 /** Null while the turn runs. */
11 endedAt: number | null
12 /** 'main' first, then each subagent id in the order it first showed up. */
13 lanes: string[]
14 /** A subagent's description by its id. */
15 labels: Record<string, string>
16 bars: BbBar[]
17 ticks: BbTick[]
18}
19
20export type BlackboxSnap = {
21 turn: BbTurn
22 isLive: boolean
23 /** When the snapshot was taken. */
24 now: number
25}
26
27declare module 'claude-code' {
28 interface PluginState {
29 blackbox: { snap: BlackboxSnap | null; isHidden: boolean }
30 }
31}
32