KOZMOS Relay: the API latency lab in a sidebar. Every model request of the main loop and its subagents with latency, time to first token, model, effort…

The API latency lab in a sidebar. Every model request (turn.step) of the main loop and of subagents is timed from the start of the step to its result, with the time to its first streamed chunk, and read for its model, effort, input / output / cache-read / cache-write tokens, output tokens per second (over the streaming part) and stop reason.
/relay toggles the pane, /relay stats prints the numbers. Set autoOpen to open it on session start.
Kenar çubuğunda API gecikme laboratuvarı. Ana döngünün ve alt ajanların her model isteği (turn.step), adımın başından sonucuna kadar ve ilk akış parçasına kadar geçen süreyle ölçülür; modeli, efor düzeyi, girdi / çıktı / önbellek okuma / önbellek yazma token'ları, saniyedeki çıktı token'ı (akış kısmı üzerinden) ve durma nedeni okunur.
/relay paneli açar/kapatır, /relay stats sayıları yazar. Oturum başında açılması için autoOpen ayarını açın.
hooks/register.tsx 247 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, TurnStepInput, TurnStepResult } from 'claude-code'
3
4import type { RelayReq, RelaySnap } from '../types'
5import { KZ, brailleGraph, fmtTokens, modelName, padEnd, pxOf, sparkline } from './lib/kz.ts'
6import {
7 BUCKET_LABELS, MAX_REQS, cacheSvg, cacheShare, clockOf, emptySnap, fmtMs, fmtTps, headerSvg, histogramSvg, latencyHeat, mixColor,
8 mixSvg, recentSvg, reqOf, slowestSvg, statsOf, stopColor, stopLabel, throughputSvg, vbars,
9} from './lab.ts'
10
11const PANE = 'kz-relay'
12const TITLE = 'KOZMOS · Relay'
13const RECENT = 12
14const snapAtom = atom({ plugin: 'relay', key: 'snap' } as const, null)
15
16// Module state, published to $.state after each change (session start, a
17// recorded request: every publish follows one).
18let live: RelaySnap = emptySnap()
19let isSeeded = false
20const agentNames = new Map<string, string>()
21
22/** After a hot reload: carry on from what the last module published. */
23async function seed($: EngineInterface): Promise<void> {
24 if (isSeeded) return
25 isSeeded = true
26 const kept = await read($, snapAtom)
27 if (kept) live = structuredClone(kept)
28}
29
30async function publish($: EngineInterface): Promise<void> {
31 const next = structuredClone(live)
32 await update($, snapAtom, () => next)
33}
34
35async function nowOf($: EngineInterface): Promise<number> {
36 try {
37 return await $.clock.now()
38 } catch {
39 return Date.now()
40 }
41}
42
43async function whoOf($: EngineInterface, agentId: string | undefined): Promise<string> {
44 if (agentId === undefined) return 'main'
45 const known = agentNames.get(agentId)
46 if (known) return known
47 try {
48 for (const a of await $.agent.list()) agentNames.set(a.id, a.description || a.type)
49 } catch {
50 // The roster is not readable now; fall back to the id.
51 }
52 return agentNames.get(agentId) ?? `agent ${agentId.slice(0, 6)}`
53}
54
55async function recordStep($: EngineInterface, e: TurnStepInput, r: TurnStepResult, at: number, first: number | undefined): Promise<void> {
56 await seed($)
57 const req: RelayReq = reqOf(at, await nowOf($), first, e, r, await whoOf($, e.agentId))
58 live.reqs = [...live.reqs.filter(x => x.id !== req.id), req].slice(-MAX_REQS)
59 live.total++
60 if (r.stopReason === null) live.failed++
61 await publish($)
62}
63
64async function toggle($: EngineInterface): Promise<boolean> {
65 if ((await $.ui.panes()).some(p => p.id === PANE)) {
66 await $.ui.close({ id: PANE })
67 return false
68 }
69 await $.ui.open({ id: PANE, title: TITLE })
70 return true
71}
72
73function summary(s: RelaySnap): string {
74 if (!s.reqs.length) return 'Relay: no model requests timed yet.'
75 const st = statsOf(s)
76 const lines = [
77 `Relay: ${s.total} requests · p50 ${fmtMs(st.p50)} · p95 ${fmtMs(st.p95)} · ${fmtTps(st.avgTps)} tok/s · cache ${Math.round(st.cache * 100)}%`,
78 ` models: ${st.mix.map(m => `${m.model} ${m.n}`).join(', ')}`,
79 ' slowest:',
80 ...st.slowest.map((r, i) => ` ${i + 1}. ${fmtMs(r.ms).padStart(6)} ${modelName(r.model)}${r.effort ? ` (${r.effort})` : ''} · ${r.who} · ${stopLabel(r.stop)}`),
81 ]
82 return lines.join('\n')
83}
84
85export const register: Register = (on, options) => {
86 on('session.start', async ($, e, next) => {
87 const started = await next(e)
88 live = emptySnap()
89 isSeeded = true
90 agentNames.clear()
91 await $.command.register({ name: 'relay', description: 'KOZMOS: toggle the Relay API-latency sidebar (/relay stats prints the numbers)', argumentHint: '[stats]', immediate: true })
92 await publish($).catch(() => undefined)
93 if (options.autoOpen === true) void $.ui.open({ id: PANE, title: TITLE }).catch(() => undefined)
94 return started
95 })
96
97 on('command.run', { command: 'relay' }, async ($, e) => {
98 if (e.args.trim() === 'stats') {
99 await seed($)
100 return { text: summary(live) }
101 }
102 return { text: (await toggle($)) ? 'Relay open.' : 'Relay closed.' }
103 })
104
105 on('agent.spawn', async ($, e, next) => {
106 const r = await next(e)
107 if (r.deny === undefined && r.agentId) agentNames.set(r.agentId, e.description || e.subagentType)
108 return r
109 }).catch(($, e, next) => next(e))
110
111 on('turn.step', async function* ($, e, next) {
112 const at = await nowOf($)
113 let first: number | undefined
114 const stream = next(e)
115 for await (const chunk of stream) {
116 if (first === undefined && (chunk.kind === 'text' || chunk.kind === 'thinking' || chunk.kind === 'tool')) first = await nowOf($)
117 yield chunk
118 }
119 const r = await stream.result
120 await recordStep($, e, r, at, first).catch(() => undefined)
121 return r
122 })
123
124 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
125 const snap = (await read($, snapAtom)) ?? emptySnap()
126 const st = statsOf(snap)
127 const ui = $.ui.resolve(e)
128 const { Box, Text } = ui
129
130 if ('Svg' in ui && e.surface !== 'terminal') {
131 const { Svg } = ui
132 const W = pxOf(e.props.bodyColumns, 44)
133 const cards: [string, { source: string; height: number }, string][] = [
134 ['head', headerSvg(snap, st, W), `Relay: ${snap.total} requests, p50 ${fmtMs(st.p50)}, p95 ${fmtMs(st.p95)}, ${fmtTps(st.avgTps)} tokens per second`],
135 ['hist', histogramSvg(st, W), `Latency histogram: ${st.hist.map((n, i) => `${BUCKET_LABELS[i]}s ${n}`).join(', ')}`],
136 ['tps', throughputSvg(snap.reqs, W), `Tokens per second over the last requests, average ${fmtTps(st.avgTps)}`],
137 ['mix', mixSvg(st, W), `Model mix: ${st.mix.map(m => `${m.model} ${m.n}`).join(', ') || 'none'}`],
138 ['cache', cacheSvg(snap.reqs, W), `Cache-read share per request, overall ${Math.round(st.cache * 100)}%`],
139 ['slow', slowestSvg(st, W), `Slowest requests: ${st.slowest.map(r => fmtMs(r.ms)).join(', ') || 'none'}`],
140 ['recent', recentSvg(snap.reqs, st, W, RECENT), `The last ${Math.min(RECENT, snap.reqs.length)} requests`],
141 ]
142 return (
143 <Box flexDirection="column" gap={1}>
144 {cards.map(([key, c, alt]) => <Svg key={key} source={c.source} alt={alt} width={W} height={c.height} />)}
145 </Box>
146 )
147 }
148
149 const cols = Math.max(30, e.props.bodyColumns || 44)
150 const cell = Math.max(2, Math.min(6, Math.floor((cols - 2) / st.hist.length)))
151 const bars = vbars(st.hist, 4)
152 const tps = snap.reqs.map(r => r.tps).filter((v): v is number => v !== undefined)
153 const graphW = Math.max(10, cols - 2)
154 const graph = brailleGraph(tps, graphW, 3)
155 const tpsTop = Math.max(0, ...tps)
156 const mixW = Math.max(10, cols - 2)
157 const section = (key: string, label: string, right = '') => (
158 <Box key={key} flexDirection="row" justifyContent="space-between" marginTop={1}>
159 <Text bold dimColor>{label}</Text>
160 <Text dimColor>{right}</Text>
161 </Box>
162 )
163 if (!snap.reqs.length) {
164 return (
165 <Box flexDirection="column">
166 <Text bold color={KZ.violet}>⇄ RELAY</Text>
167 <Text dimColor>No model requests timed yet. Each request of the main loop and of subagents lands here with its latency and throughput.</Text>
168 </Box>
169 )
170 }
171 return (
172 <Box flexDirection="column">
173 <Box flexDirection="row" justifyContent="space-between">
174 <Text bold color={KZ.violet}>⇄ RELAY</Text>
175 <Text dimColor>{snap.total} requests</Text>
176 </Box>
177 <Text wrap="truncate-end">
178 <Text dimColor>p50 </Text><Text color={st.p50 === undefined ? KZ.mist : latencyHeat(st.p50)} bold>{fmtMs(st.p50)}</Text>
179 <Text dimColor> · p95 </Text><Text color={st.p95 === undefined ? KZ.mist : latencyHeat(st.p95)} bold>{fmtMs(st.p95)}</Text>
180 <Text dimColor> · </Text><Text color={KZ.cyan}>{fmtTps(st.avgTps)} t/s</Text>
181 <Text dimColor> · ttft </Text><Text color={KZ.violet}>{fmtMs(st.avgTtft)}</Text>
182 </Text>
183 <Text wrap="truncate-end" dimColor>
184 cache {Math.round(st.cache * 100)}% · {fmtTokens(st.tokensIn)} in · {fmtTokens(st.tokensOut)} out{snap.failed ? ` · ${snap.failed} without response` : ''}
185 </Text>
186
187 {section('s-lat', 'LATENCY', 'seconds')}
188 {bars.map((line, r) => (
189 <Text key={`hb${r}`}>
190 {[...line].map((ch, i) => (
191 <Text key={`hb${r}-${i}`} color={latencyHeat(i === 0 ? 500 : 1000 * 2 ** (i - 1) * 1.5)}>{ch.repeat(Math.max(1, cell - 1))} </Text>
192 ))}
193 </Text>
194 ))}
195 <Text dimColor>{BUCKET_LABELS.map(l => padEnd(l, cell)).join('')}</Text>
196
197 {section('s-tps', 'TOKENS / SEC', tps.length ? `max ${fmtTps(tpsTop)}` : '')}
198 {tps.length
199 ? graph.map((line, i) => <Text key={`tp${i}`} color={KZ.cyan}>{line}</Text>)
200 : <Text dimColor>no streamed output yet</Text>}
201
202 {section('s-mix', 'MODEL MIX', `${st.mix.length}`)}
203 <Text>
204 {st.mix.map(m => <Text key={`mx-${m.model}`} color={m.color}>{'█'.repeat(Math.max(1, Math.round((m.n / Math.max(1, st.n)) * mixW)))}</Text>)}
205 </Text>
206 {st.mix.map(m => (
207 <Text key={`ml-${m.model}`} wrap="truncate-end">
208 <Text color={m.color}>● </Text>
209 <Text>{m.model}</Text>
210 <Text dimColor> {m.n} · {Math.round((m.n / Math.max(1, st.n)) * 100)}%</Text>
211 </Text>
212 ))}
213
214 {section('s-cache', 'CACHE READ / REQUEST', `${Math.round(st.cache * 100)}%`)}
215 <Text color={KZ.teal}>{sparkline(snap.reqs.filter(r => r.stop !== null).map(cacheShare), Math.max(8, cols - 2), 1)}</Text>
216
217 {section('s-slow', 'SLOWEST 5')}
218 {st.slowest.map((r, i) => (
219 <Text key={`sl-${r.id}`} wrap="truncate-end">
220 <Text dimColor>{i + 1} </Text>
221 <Text color={latencyHeat(r.ms)} bold>{fmtMs(r.ms).padStart(6)} </Text>
222 <Text color={mixColor(st, r.model)}>{modelName(r.model)}</Text>
223 <Text dimColor>{r.effort ? ` · ${r.effort}` : ''} · {fmtTokens(r.output)} out · {r.who}</Text>
224 </Text>
225 ))}
226
227 {section('s-recent', 'RECENT')}
228 {snap.reqs.slice(-RECENT).reverse().map(r => (
229 <Box key={`rc-${r.id}`} flexDirection="column">
230 <Text wrap="truncate-end">
231 <Text color={stopColor(r.stop)}>● </Text>
232 <Text dimColor>{clockOf(r.at)} </Text>
233 <Text color={mixColor(st, r.model)}>{modelName(r.model)}</Text>
234 <Text dimColor>{r.effort ? ` ${r.effort}` : ''} </Text>
235 <Text color={latencyHeat(r.ms)} bold>{fmtMs(r.ms)}</Text>
236 {r.tps !== undefined ? <Text color={KZ.cyan}> {fmtTps(r.tps)}t/s</Text> : ''}
237 </Text>
238 <Text dimColor wrap="truncate-end">
239 {' '}in {fmtTokens(r.input)} · cache {fmtTokens(r.cacheRead)}/{fmtTokens(r.cacheWrite)} · out {fmtTokens(r.output)} · {stopLabel(r.stop)} · {r.who}
240 </Text>
241 </Box>
242 ))}
243 </Box>
244 )
245 })
246}
247hooks/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}
510hooks/lab.ts 311 lines1// Relay: pure helpers (no `$`): request stats, histogram, model mix and the
2// desktop chart cards.
3import type { RelayReq, RelaySnap } from '../types'
4import { KZ, clamp01, fitText, fmtTokens, heat, modelName, svg, svgBar, svgText, xml } from './lib/kz.ts'
5
6export const MAX_REQS = 300
7
8export const emptySnap = (): RelaySnap => ({ reqs: [], total: 0, failed: 0 })
9
10/** Bucket edges in seconds: <1, 1–2, 2–4, … 32–64, 64+. */
11export const EDGES = [1, 2, 4, 8, 16, 32, 64] as const
12export const BUCKET_LABELS = ['<1', '1', '2', '4', '8', '16', '32', '64+'] as const
13
14const PALETTE = [KZ.violet, KZ.cyan, KZ.amber, KZ.green, KZ.magenta, KZ.blue, KZ.teal, KZ.lime]
15
16type StepUsage = { input_tokens?: number; output_tokens?: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number; model?: string } | null
17
18/** One request from what `turn.step` gave: the timing and the response's usage. */
19export function reqOf(
20 at: number, end: number, first: number | undefined,
21 step: { turnId: string; index: number; model: string; effort?: string | number },
22 result: { stopReason: string | null; usage: StepUsage }, who: string,
23): RelayReq {
24 const u = result.usage
25 const ms = Math.max(0, end - at)
26 const ttft = first === undefined ? undefined : Math.max(0, first - at)
27 const output = u?.output_tokens ?? 0
28 const streamMs = ms - (ttft ?? 0)
29 return {
30 id: `${step.turnId}:${step.index}`,
31 at,
32 ms,
33 ttft,
34 model: u?.model || step.model,
35 effort: step.effort === undefined ? undefined : String(step.effort),
36 input: u?.input_tokens ?? 0,
37 output,
38 cacheRead: u?.cache_read_input_tokens ?? 0,
39 cacheWrite: u?.cache_creation_input_tokens ?? 0,
40 tps: u && output > 0 && streamMs >= 100 ? output / (streamMs / 1000) : undefined,
41 stop: result.stopReason,
42 who,
43 }
44}
45
46export const isAnswered = (r: RelayReq): boolean => r.stop !== null
47
48/** Share of the prompt the cache served: read / (input + read + write). */
49export function cacheShare(r: RelayReq): number {
50 const all = r.input + r.cacheRead + r.cacheWrite
51 return all > 0 ? r.cacheRead / all : 0
52}
53
54export function percentile(sorted: readonly number[], q: number): number | undefined {
55 if (!sorted.length) return undefined
56 return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(q * sorted.length) - 1))]
57}
58
59/** The histogram bucket of a request's latency: the first edge it is under, else the last bucket. */
60export function bucketOf(r: RelayReq): number {
61 const i = EDGES.findIndex(e => r.ms / 1000 < e)
62 return i < 0 ? EDGES.length : i
63}
64
65export type Stats = {
66 n: number
67 p50?: number
68 p95?: number
69 avgTps?: number
70 avgTtft?: number
71 cache: number
72 tokensIn: number
73 tokensOut: number
74 hist: number[]
75 mix: { model: string; n: number; color: string }[]
76 slowest: RelayReq[]
77}
78
79export function statsOf(s: RelaySnap): Stats {
80 const ok = s.reqs.filter(isAnswered)
81 const ms = ok.map(r => r.ms).sort((a, b) => a - b)
82 const tps = ok.map(r => r.tps).filter((v): v is number => v !== undefined)
83 const ttft = ok.map(r => r.ttft).filter((v): v is number => v !== undefined)
84 const prompt = ok.reduce((n, r) => n + r.input + r.cacheRead + r.cacheWrite, 0)
85 const read = ok.reduce((n, r) => n + r.cacheRead, 0)
86 const buckets = ok.map(bucketOf)
87 const hist = Array.from({ length: EDGES.length + 1 }, (_, k) => buckets.filter(b => b === k).length)
88 const byModel = new Map<string, number>()
89 for (const r of s.reqs) byModel.set(modelName(r.model), (byModel.get(modelName(r.model)) ?? 0) + 1)
90 const mix = [...byModel.entries()].sort((a, b) => b[1] - a[1]).map(([model, n], i) => ({ model, n, color: PALETTE[i % PALETTE.length]! }))
91 return {
92 n: s.reqs.length,
93 p50: percentile(ms, 0.5),
94 p95: percentile(ms, 0.95),
95 avgTps: tps.length ? tps.reduce((a, b) => a + b, 0) / tps.length : undefined,
96 avgTtft: ttft.length ? ttft.reduce((a, b) => a + b, 0) / ttft.length : undefined,
97 cache: prompt > 0 ? read / prompt : 0,
98 tokensIn: ok.reduce((n, r) => n + r.input + r.cacheRead + r.cacheWrite, 0),
99 tokensOut: ok.reduce((n, r) => n + r.output, 0),
100 hist,
101 mix,
102 slowest: [...ok].sort((a, b) => b.ms - a.ms).slice(0, 5),
103 }
104}
105
106export function mixColor(stats: Stats, model: string): string {
107 return stats.mix.find(m => m.model === modelName(model))?.color ?? KZ.mist
108}
109
110/** 420ms, 4.2s, 1m07s. */
111export function fmtMs(ms: number | undefined): string {
112 if (ms === undefined) return '—'
113 if (ms < 1000) return `${Math.round(ms)}ms`
114 if (ms < 60_000) return `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)}s`
115 const s = Math.round(ms / 1000)
116 return `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`
117}
118
119export const fmtTps = (v: number | undefined): string => (v === undefined ? '—' : `${v < 10 ? v.toFixed(1) : Math.round(v)}`)
120
121export function clockOf(at: number): string {
122 const d = new Date(at)
123 const p = (n: number) => String(n).padStart(2, '0')
124 return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
125}
126
127/** Latency color: green under 2 s, red at a minute. */
128export const latencyHeat = (ms: number): string => heat(Math.log2(Math.max(1, ms / 1000)) / 6)
129
130export function stopColor(stop: string | null): string {
131 if (stop === null) return KZ.red
132 if (stop === 'end_turn') return KZ.green
133 if (stop === 'tool_use') return KZ.cyan
134 if (stop === 'max_tokens' || stop === 'model_context_window_exceeded') return KZ.amber
135 if (stop === 'refusal') return KZ.red
136 return KZ.mist
137}
138
139export const stopLabel = (stop: string | null): string => (stop === null ? 'no response' : stop.replace(/_/g, ' '))
140
141/** Vertical bars from eighth blocks: `rows` strings, bottom row last, one char per value. */
142export function vbars(values: readonly number[], rows: number): string[] {
143 const top = Math.max(1, ...values)
144 const out: string[] = []
145 for (let r = rows - 1; r >= 0; r--) {
146 out.push(values.map(v => {
147 const level = (v / top) * rows * 8 - r * 8
148 return v > 0 && r === 0 && level < 1 ? '▁' : ' ▁▂▃▄▅▆▇█'.charAt(Math.max(0, Math.min(8, Math.round(level))))
149 }).join(''))
150 }
151 return out
152}
153
154// ---------------------------------------------------------------------------
155// Desktop cards: each one Svg.
156
157const card = (W: number, H: number, title: string, right = ''): string =>
158 `<rect class="p" width="${W}" height="${H}" rx="12"/>` +
159 svgText(12, 19, title, { cls: 's', size: 10, weight: 700 }) +
160 (right ? svgText(W - 12, 19, right, { cls: 'm', size: 10, anchor: 'end' }) : '')
161
162export function headerSvg(s: RelaySnap, st: Stats, W: number): { source: string; height: number } {
163 const H = 112
164 const parts: string[] = []
165 parts.push(`<defs><linearGradient id="rg" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="${KZ.violet}" stop-opacity=".18"/><stop offset="1" stop-color="${KZ.cyan}" stop-opacity=".05"/></linearGradient></defs>`)
166 parts.push(`<rect class="p" width="${W}" height="${H}" rx="14"/><rect width="${W}" height="${H}" rx="14" fill="url(#rg)"/>`)
167 // Relay glyph: two nodes and a travelling packet.
168 parts.push(`<line x1="16" y1="28" x2="40" y2="28" stroke="${KZ.violet}" stroke-width="2" stroke-dasharray="3 3"/><circle cx="14" cy="28" r="4" fill="${KZ.violet}"/><circle cx="42" cy="28" r="4" fill="${KZ.cyan}"/>`)
169 parts.push(`<circle r="2.4" fill="#fff" cy="28"><animate attributeName="cx" from="16" to="40" dur="1.4s" repeatCount="indefinite"/></circle>`)
170 parts.push(svgText(54, 26, 'RELAY', { size: 13, weight: 800 }))
171 parts.push(svgText(54, 40, `${s.total} requests · ${fmtTokens(st.tokensIn)} in · ${fmtTokens(st.tokensOut)} out`, { cls: 'm', size: 10.5 }))
172 const tiles: [string, string, string][] = [
173 ['P50', fmtMs(st.p50), st.p50 === undefined ? KZ.mist : latencyHeat(st.p50)],
174 ['P95', fmtMs(st.p95), st.p95 === undefined ? KZ.mist : latencyHeat(st.p95)],
175 ['TOK/S', fmtTps(st.avgTps), KZ.cyan],
176 ['TTFT', fmtMs(st.avgTtft), KZ.violet],
177 ['CACHE', `${Math.round(st.cache * 100)}%`, KZ.teal],
178 ]
179 const tw = (W - 28 - 4 * 5) / 5
180 tiles.forEach(([k, v, c], i) => {
181 const x = 14 + i * (tw + 5)
182 parts.push(`<rect class="k" x="${x}" y="54" width="${tw}" height="44" rx="9" opacity=".7"/>`)
183 parts.push(svgText(x + tw / 2, 69, k, { cls: 's', size: 8.5, weight: 650, anchor: 'middle' }))
184 parts.push(svgText(x + tw / 2, 88, v, { size: 13, weight: 750, anchor: 'middle', fill: c }))
185 })
186 return { source: svg(W, H, parts.join('')), height: H }
187}
188
189export function histogramSvg(st: Stats, W: number): { source: string; height: number } {
190 const H = 118
191 const parts = [card(W, H, 'LATENCY', `${st.hist.reduce((a, b) => a + b, 0)} answered`)]
192 const top = Math.max(1, ...st.hist)
193 const gx = 12
194 const gw = W - 24
195 const bw = gw / st.hist.length
196 const base = 92
197 const gh = 62
198 st.hist.forEach((n, i) => {
199 const hgt = n ? Math.max(3, (n / top) * gh) : 0
200 const c = heat(i / (st.hist.length - 1))
201 parts.push(`<rect class="k" x="${(gx + i * bw + 2).toFixed(1)}" y="${base - gh}" width="${(bw - 4).toFixed(1)}" height="${gh}" rx="4" opacity=".45"/>`)
202 if (hgt) parts.push(`<rect x="${(gx + i * bw + 2).toFixed(1)}" y="${(base - hgt).toFixed(1)}" width="${(bw - 4).toFixed(1)}" height="${hgt.toFixed(1)}" rx="4" fill="${c}"><title>${n} requests</title></rect>`)
203 if (n) parts.push(svgText(gx + i * bw + bw / 2, base - hgt - 3, String(n), { size: 9, weight: 700, anchor: 'middle', fill: c }))
204 parts.push(svgText(gx + i * bw + bw / 2, base + 13, `${BUCKET_LABELS[i]!}${i === 0 ? 's' : ''}`, { cls: 'm', size: 9, anchor: 'middle' }))
205 })
206 return { source: svg(W, H, parts.join('')), height: H }
207}
208
209export function throughputSvg(reqs: readonly RelayReq[], W: number): { source: string; height: number } {
210 const H = 110
211 const vals = reqs.map(r => r.tps).filter((v): v is number => v !== undefined).slice(-120)
212 const top = Math.max(1, ...vals)
213 const parts = [card(W, H, 'TOKENS / SEC', vals.length ? `max ${fmtTps(top)}` : 'waiting for a response')]
214 const gx = 12
215 const gw = W - 24
216 const gy = 30
217 const gh = 64
218 parts.push(`<line class="ln" x1="${gx}" y1="${gy + gh}" x2="${gx + gw}" y2="${gy + gh}" stroke-width="1"/>`)
219 parts.push(`<line class="ln" x1="${gx}" y1="${gy + gh / 2}" x2="${gx + gw}" y2="${gy + gh / 2}" stroke-width="1" stroke-dasharray="2 4"/>`)
220 if (vals.length >= 2) {
221 const step = gw / (vals.length - 1)
222 const xy = vals.map((v, i) => `${(gx + i * step).toFixed(1)},${(gy + gh - (v / top) * gh).toFixed(1)}`)
223 parts.push(`<defs><linearGradient id="tp" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${KZ.cyan}" stop-opacity=".45"/><stop offset="1" stop-color="${KZ.cyan}" stop-opacity="0"/></linearGradient></defs>`)
224 parts.push(`<path d="M${xy.join('L')}L${gx + gw},${gy + gh}L${gx},${gy + gh}Z" fill="url(#tp)"/>`)
225 parts.push(`<path d="M${xy.join('L')}" fill="none" stroke="${KZ.cyan}" stroke-width="1.8" stroke-linejoin="round"/>`)
226 const last = xy[xy.length - 1]!.split(',')
227 parts.push(`<circle cx="${last[0]}" cy="${last[1]}" r="3.2" fill="${KZ.cyan}" class="pulse"/>`)
228 } else if (vals.length === 1) {
229 parts.push(`<circle cx="${gx + gw}" cy="${gy + gh - gh}" r="3" fill="${KZ.cyan}"/>`)
230 }
231 parts.push(svgText(gx, H - 4, vals.length ? `last ${vals.length} requests` : '', { cls: 'm', size: 8.5 }))
232 return { source: svg(W, H, parts.join('')), height: H }
233}
234
235export function mixSvg(st: Stats, W: number): { source: string; height: number } {
236 const rows = Math.max(1, Math.ceil(st.mix.length / 2))
237 const H = 56 + rows * 16
238 const parts = [card(W, H, 'MODEL MIX', `${st.mix.length} model${st.mix.length === 1 ? '' : 's'}`)]
239 let x = 12
240 const bw = W - 24
241 if (st.n) {
242 for (const m of st.mix) {
243 const w = (m.n / st.n) * bw
244 parts.push(`<rect x="${x.toFixed(1)}" y="28" width="${Math.max(1, w - 2).toFixed(1)}" height="12" rx="4" fill="${m.color}"/>`)
245 x += w
246 }
247 } else {
248 parts.push(`<rect class="k" x="12" y="28" width="${bw}" height="12" rx="4"/>`)
249 }
250 st.mix.forEach((m, i) => {
251 const cx = 12 + (i % 2) * (bw / 2)
252 const cy = 58 + Math.floor(i / 2) * 16
253 parts.push(`<circle cx="${cx + 4}" cy="${cy - 4}" r="4" fill="${m.color}"/>`)
254 parts.push(svgText(cx + 13, cy, fitText(`${m.model} · ${m.n} · ${Math.round((m.n / Math.max(1, st.n)) * 100)}%`, 10.5, bw / 2 - 18), { size: 10.5 }))
255 })
256 return { source: svg(W, H, parts.join('')), height: H }
257}
258
259export function cacheSvg(reqs: readonly RelayReq[], W: number): { source: string; height: number } {
260 const H = 88
261 const last = reqs.filter(isAnswered).slice(-60)
262 const parts = [card(W, H, 'CACHE-READ SHARE PER REQUEST', last.length ? `last ${last.length}` : '')]
263 const gx = 12
264 const gw = W - 24
265 const gy = 28
266 const gh = 46
267 const bw = Math.min(14, gw / Math.max(1, last.length))
268 last.forEach((r, i) => {
269 const k = cacheShare(r)
270 const hgt = Math.max(1.5, k * gh)
271 const x = gx + gw - (last.length - i) * bw
272 parts.push(`<rect class="k" x="${(x + 0.5).toFixed(1)}" y="${gy}" width="${Math.max(1, bw - 1.5).toFixed(1)}" height="${gh}" rx="2" opacity=".5"/>`)
273 parts.push(`<rect x="${(x + 0.5).toFixed(1)}" y="${(gy + gh - hgt).toFixed(1)}" width="${Math.max(1, bw - 1.5).toFixed(1)}" height="${hgt.toFixed(1)}" rx="2" fill="${KZ.teal}" opacity="${(0.45 + k * 0.55).toFixed(2)}"><title>${Math.round(k * 100)}% read from cache</title></rect>`)
274 })
275 if (!last.length) parts.push(svgText(W / 2, gy + 28, 'no answered requests yet', { cls: 'm', size: 10.5, anchor: 'middle' }))
276 return { source: svg(W, H, parts.join('')), height: H }
277}
278
279export function slowestSvg(st: Stats, W: number): { source: string; height: number } {
280 const H = 32 + Math.max(1, st.slowest.length) * 22
281 const parts = [card(W, H, 'SLOWEST 5')]
282 const top = Math.max(1, ...st.slowest.map(r => r.ms))
283 st.slowest.forEach((r, i) => {
284 const y = 34 + i * 22
285 parts.push(svgText(12, y + 9, `${i + 1}`, { cls: 'm', size: 10, weight: 700 }))
286 parts.push(svgText(26, y + 9, fitText(`${modelName(r.model)}${r.effort ? ` · ${r.effort}` : ''} · ${r.who}`, 10.5, W * 0.48), { size: 10.5, weight: 600, fill: mixColor(st, r.model) }))
287 parts.push(svgBar(W * 0.55, y + 2, W * 0.45 - 70, 7, clamp01(r.ms / top), latencyHeat(r.ms)))
288 parts.push(svgText(W - 12, y + 9, fmtMs(r.ms), { size: 10.5, weight: 700, anchor: 'end' }))
289 parts.push(svgText(26, y + 19, fitText(`${fmtTokens(r.input + r.cacheRead + r.cacheWrite)} in → ${fmtTokens(r.output)} out · ${stopLabel(r.stop)} · ${clockOf(r.at)}`, 9, W - 40), { cls: 'm', size: 9 }))
290 })
291 if (!st.slowest.length) parts.push(svgText(W / 2, 46, 'nothing timed yet', { cls: 'm', size: 10.5, anchor: 'middle' }))
292 return { source: svg(W, H, parts.join('')), height: H }
293}
294
295export function recentSvg(reqs: readonly RelayReq[], all: Stats, W: number, count: number): { source: string; height: number } {
296 const rows = reqs.slice(-count).reverse()
297 const H = 30 + Math.max(1, rows.length) * 30
298 const parts = [card(W, H, 'RECENT REQUESTS')]
299 rows.forEach((r, i) => {
300 const y = 30 + i * 30
301 if (i) parts.push(`<line class="ln" x1="12" y1="${y - 2}" x2="${W - 12}" y2="${y - 2}" stroke-width="1"/>`)
302 parts.push(`<circle cx="16" cy="${y + 9}" r="3.5" fill="${stopColor(r.stop)}"><title>${xml(stopLabel(r.stop))}</title></circle>`)
303 parts.push(svgText(26, y + 12, fitText(`${modelName(r.model)}${r.effort ? ` · ${r.effort}` : ''}`, 11, W * 0.42), { size: 11, weight: 650, fill: mixColor(all, r.model) }))
304 parts.push(svgText(W - 12, y + 12, `${fmtMs(r.ms)}${r.tps !== undefined ? ` · ${fmtTps(r.tps)} t/s` : ''}`, { size: 11, weight: 700, anchor: 'end', fill: latencyHeat(r.ms) }))
305 const meta = `${clockOf(r.at)} · in ${fmtTokens(r.input)} · cache ${fmtTokens(r.cacheRead)}/${fmtTokens(r.cacheWrite)} · out ${fmtTokens(r.output)} · ${stopLabel(r.stop)} · ${r.who}`
306 parts.push(svgText(26, y + 24, fitText(meta, 9, W - 38), { cls: 'm', size: 9 }))
307 })
308 if (!rows.length) parts.push(svgText(W / 2, 48, 'no model requests yet', { cls: 'm', size: 10.5, anchor: 'middle' }))
309 return { source: svg(W, H, parts.join('')), height: H }
310}
311types/index.d.ts 40 lines1/** One model request (a `turn.step`): how long it took and what it moved. */
2export type RelayReq = {
3 /** `turnId:index`. */
4 id: string
5 /** When the request started, `$.clock.now()` milliseconds. */
6 at: number
7 /** From the step's start to its result. */
8 ms: number
9 /** From the step's start to the first text, thinking or tool chunk. */
10 ttft?: number
11 /** The model that answered (the usage's), else the one the request named. */
12 model: string
13 effort?: string
14 input: number
15 output: number
16 cacheRead: number
17 cacheWrite: number
18 /** Output tokens per second over the streaming part (after the first chunk). */
19 tps?: number
20 /** Why the model stopped; null when no response arrived. */
21 stop: string | null
22 /** `main`, or the subagent's description. */
23 who: string
24}
25
26export type RelaySnap = {
27 /** Oldest first, at most 300. */
28 reqs: RelayReq[]
29 /** Requests over the whole session. */
30 total: number
31 /** Requests that got no response (failed or interrupted). */
32 failed: number
33}
34
35declare module 'claude-code' {
36 interface PluginState {
37 relay: { snap: RelaySnap | null }
38 }
39}
40