SLOPSHOPPER

relay

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…

newpanecommandagents
★ 4v0.1.0MITupdated 2026-10-09ersinkoc/claude-mods/mods/relay
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · relay
│ ┃ KOZMOS · Relay ✕ › fix the failing auth test and add an audit log call │ ┃ ⇄ RELAY │ ┃ No model requests timed yet. Each request of ⏺ Read(src/auth.ts) │ ┃ the main loop and of subagents lands here ⎿ Read 6 lines │ ┃ with its latency and throughput. ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /relay │ ⎿ relay: Relay open. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · KOZMOS · Relay
⇄ RELAY No model requests timed yet. Each request of the main loop and of subagents lands here with its latency and throughput.
README

KOZMOS Relay

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.

  • Header: p50 and p95 latency, average tokens/sec, average time to first token, overall cache-read share, tokens in and out.
  • Latency histogram (<1 s … 64 s+), vertical bars on the terminal, a column chart on the desktop.
  • Tokens/sec over time: a braille graph on the terminal, an SVG area line on the desktop.
  • Model mix as a stacked bar with a legend, cache-read share per request, the slowest five requests and the last twelve with all their numbers.
  • At most 300 requests kept; requests that got no response are counted.

/relay toggles the pane, /relay stats prints the numbers. Set autoOpen to open it on session start.

Türkçe

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.

  • Başlık: p50 ve p95 gecikme, ortalama token/sn, ilk token'a kadar ortalama süre, genel önbellek okuma payı, giren ve çıkan token.
  • Gecikme histogramı (<1 sn … 64 sn+): terminalde dikey çubuklar, masaüstünde sütun grafiği.
  • Zaman içinde token/sn: terminalde braille grafik, masaüstünde SVG alan çizgisi.
  • Lejantlı yığılmış çubukla model karışımı, istek başına önbellek okuma payı, en yavaş beş istek ve tüm sayılarıyla son on iki istek.
  • En fazla 300 istek tutulur; yanıt alamayan istekler sayılır.

/relay paneli açar/kapatır, /relay stats sayıları yazar. Oturum başında açılması için autoOpen ayarını açın.

Source 4 files
hooks/register.tsx 247 lines
1import { 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}
247
hooks/lib/kz.ts 510 lines
1// GENERATED by scripts/sync-shared.mjs from shared/kz.ts. Do not edit here.
2// KOZMOS shared kit. Source of truth: shared/kz.ts at the bundle root.
3// `node scripts/sync-shared.mjs` copies it into every mod as hooks/lib/kz.ts,
4// because a hooks module may only import files inside its own plugin.
5// Edit the root copy, never a mod's copy.
6
7// ---------------------------------------------------------------------------
8// Palette: one neon family across every KOZMOS mod.
9
10export const KZ = {
11  violet: '#a78bfa',
12  magenta: '#f472b6',
13  cyan: '#22d3ee',
14  teal: '#2dd4bf',
15  green: '#4ade80',
16  lime: '#a3e635',
17  yellow: '#facc15',
18  amber: '#fb923c',
19  red: '#f87171',
20  blue: '#60a5fa',
21  clay: '#d97757',
22  ink: '#1f1e1d',
23  mist: '#9ca3af',
24} as const
25
26/** Tool families, colored the same in every mod. */
27export function toolColor(tool: string): string {
28  const t = String(tool)
29  if (t === 'Bash' || t === 'PowerShell') return KZ.green
30  if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit' || t === 'MultiEdit') return KZ.yellow
31  if (t === 'Read' || t === 'Glob' || t === 'Grep' || t === 'LSP') return KZ.blue
32  if (t === 'Agent' || t === 'Task' || t === 'Workflow') return KZ.violet
33  if (t.startsWith('Web')) return KZ.cyan
34  if (t.startsWith('Todo') || t.startsWith('Task')) return KZ.teal
35  if (t.startsWith('mcp__')) return KZ.magenta
36  return KZ.mist
37}
38
39/** A short glyph per tool family. */
40export function toolGlyph(tool: string): string {
41  const t = String(tool)
42  if (t === 'Bash' || t === 'PowerShell') return '$'
43  if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit') return '✎'
44  if (t === 'Read') return '◉'
45  if (t === 'Glob' || t === 'Grep') return '⌕'
46  if (t === 'Agent') return '◈'
47  if (t.startsWith('Web')) return '◍'
48  if (t.startsWith('Todo') || t.startsWith('Task')) return '☑'
49  if (t.startsWith('mcp__')) return '⬡'
50  return '•'
51}
52
53// ---------------------------------------------------------------------------
54// Colors.
55
56export function hexToRgb(hex: string): [number, number, number] {
57  const h = hex.replace('#', '')
58  const n = parseInt(h.length === 3 ? h.split('').map(c => c + c).join('') : h, 16)
59  return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
60}
61
62export function rgbToHex(r: number, g: number, b: number): string {
63  const c = (v: number) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')
64  return `#${c(r)}${c(g)}${c(b)}`
65}
66
67export function mix(a: string, b: string, t: number): string {
68  const [r1, g1, b1] = hexToRgb(a)
69  const [r2, g2, b2] = hexToRgb(b)
70  const k = clamp01(t)
71  return rgbToHex(r1 + (r2 - r1) * k, g1 + (g2 - g1) * k, b1 + (b2 - b1) * k)
72}
73
74/** Green at 0, yellow at 0.6, red at 1: the heat of a gauge. */
75export function heat(t: number): string {
76  const k = clamp01(t)
77  return k < 0.6 ? mix(KZ.green, KZ.yellow, k / 0.6) : mix(KZ.yellow, KZ.red, (k - 0.6) / 0.4)
78}
79
80/** A smooth rainbow for hues 0..1 (for auroras, plasma and the like). */
81export function hue(h: number, s = 0.75, l = 0.6): string {
82  const k = ((h % 1) + 1) % 1
83  const a = s * Math.min(l, 1 - l)
84  const f = (n: number) => {
85    const x = (n + k * 12) % 12
86    return l - a * Math.max(-1, Math.min(x - 3, 9 - x, 1))
87  }
88  return rgbToHex(f(0) * 255, f(8) * 255, f(4) * 255)
89}
90
91export function hexToInt(hex: string): number {
92  const [r, g, b] = hexToRgb(hex)
93  return (r << 16) | (g << 8) | b
94}
95
96// ---------------------------------------------------------------------------
97// Numbers and text.
98
99export const clamp01 = (v: number): number => (Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0)
100
101export function fmtTokens(n: number): string {
102  if (!Number.isFinite(n)) return '—'
103  // Each unit starts where the one below would round up to 1000 of itself.
104  if (n >= 999.5e6) return `${(n / 1e9).toFixed(1)}B`
105  if (n >= 999_500) return `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M`
106  if (n >= 999.5) return `${(n / 1e3).toFixed(n >= 1e4 ? 0 : 1)}k`
107  return `${Math.round(n)}`
108}
109
110export function fmtUsd(usd: number): string {
111  if (!Number.isFinite(usd)) return '$—'
112  if (usd < 0.01 && usd > 0) return '<$0.01'
113  return usd < 100 ? `$${usd.toFixed(2)}` : `$${Math.round(usd)}`
114}
115
116/** 0:42, 3:07, 1:02:33. */
117export function fmtClock(ms: number): string {
118  const s = Math.max(0, Math.round(ms / 1000))
119  const h = Math.floor(s / 3600)
120  const m = Math.floor((s % 3600) / 60)
121  const ss = String(s % 60).padStart(2, '0')
122  return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
123}
124
125/** 42s, 7m, 2h41m, 3d4h: compact durations for countdowns. */
126export function fmtSpan(ms: number): string {
127  const s = Math.max(0, Math.round(ms / 1000))
128  if (s < 60) return `${s}s`
129  const m = Math.floor(s / 60)
130  if (m < 60) return `${m}m`
131  const h = Math.floor(m / 60)
132  if (h < 48) return `${h}h${String(m % 60).padStart(2, '0')}m`
133  return `${Math.floor(h / 24)}d${h % 24}h`
134}
135
136export function fmtPct(p: number | undefined): string {
137  return p === undefined || !Number.isFinite(p) ? '—' : `${Math.round(p)}%`
138}
139
140export function clip(s: string, max: number): string {
141  const one = s.replace(/\s+/g, ' ').trim()
142  return one.length > max ? one.slice(0, Math.max(1, max - 1)) + '…' : one
143}
144
145export function padEnd(s: string, n: number): string {
146  return s.length >= n ? s.slice(0, n) : s + ' '.repeat(n - s.length)
147}
148
149export function padStart(s: string, n: number): string {
150  return s.length >= n ? s.slice(s.length - n) : ' '.repeat(n - s.length) + s
151}
152
153/** `claude-opus-5-5[1m]` → `Opus 5.5`. */
154export function modelName(id: string | undefined): string {
155  if (!id) return '—'
156  const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
157  if (!m) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '')
158  const fam = m[1]!
159  return `${fam.charAt(0).toUpperCase()}${fam.slice(1).toLowerCase()} ${m[2]}${m[3] ? '.' + m[3] : ''}`
160}
161
162/** Last path segment, either slash. */
163export function baseName(p: string): string {
164  const parts = p.split(/[\\/]/).filter(Boolean)
165  return parts[parts.length - 1] ?? p
166}
167
168/** A stable small hash for seeding. */
169export function hash(s: string): number {
170  let h = 2166136261
171  for (let i = 0; i < s.length; i++) {
172    h ^= s.charCodeAt(i)
173    h = Math.imul(h, 16777619)
174  }
175  return h >>> 0
176}
177
178/** Deterministic 0..1 noise from two ints. */
179export function noise(x: number, y: number): number {
180  const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
181  return s - Math.floor(s)
182}
183
184/** A seeded PRNG (mulberry32). */
185export function rng(seed: number): () => number {
186  let a = seed >>> 0
187  return () => {
188    a = (a + 0x6d2b79f5) >>> 0
189    let t = a
190    t = Math.imul(t ^ (t >>> 15), t | 1)
191    t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
192    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
193  }
194}
195
196// ---------------------------------------------------------------------------
197// Text gauges for the terminal.
198
199const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
200
201/** A smooth bar with eighth blocks: `█████▍    `. */
202export function bar(ratio: number, width: number, empty = '░'): string {
203  const w = Math.max(1, Math.floor(width))
204  const exact = clamp01(ratio) * w
205  const full = Math.floor(exact)
206  const part = EIGHTHS[Math.floor((exact - full) * 8)] ?? ''
207  const used = full + (part ? 1 : 0)
208  return '█'.repeat(full) + part + empty.repeat(Math.max(0, w - used))
209}
210
211/** A segmented gauge: `▰▰▰▱▱▱`. */
212export function pips(ratio: number, width: number, on = '▰', off = '▱'): string {
213  const w = Math.max(1, Math.floor(width))
214  const n = Math.round(clamp01(ratio) * w)
215  return on.repeat(n) + off.repeat(w - n)
216}
217
218const SPARK = '▁▂▃▄▅▆▇█'
219
220/** A sparkline of the last `width` values, scaled to their own max (or `max`). */
221export function sparkline(values: readonly number[], width: number, max?: number): string {
222  const tail = values.slice(-Math.max(1, width))
223  const top = max ?? Math.max(1e-9, ...tail)
224  const line = tail.map(v => SPARK[Math.min(7, Math.max(0, Math.round((v / top) * 7)))] ?? '▁').join('')
225  return line.padStart(width, ' ')
226}
227
228/** Braille line graph, 2 samples per cell, `rows` cells tall. */
229export function brailleGraph(values: readonly number[], width: number, rows: number, max?: number): string[] {
230  const samples = values.slice(-(width * 2))
231  const top = max ?? Math.max(1e-9, ...samples)
232  const dotsTall = rows * 4
233  const grid: number[][] = Array.from({ length: rows }, () => Array.from({ length: width }, () => 0))
234  const offset = width * 2 - samples.length
235  // Dot bits per column, from the bottom row of a cell up.
236  const LEFT = [0x40, 0x04, 0x02, 0x01]
237  const RIGHT = [0x80, 0x20, 0x10, 0x08]
238  samples.forEach((v, i) => {
239    const x = offset + i
240    const col = Math.floor(x / 2)
241    const isRight = x % 2 === 1
242    const h = Math.round(clamp01(v / top) * (dotsTall - 1))
243    for (let d = 0; d <= h; d++) {
244      const row = rows - 1 - Math.floor(d / 4)
245      const bits = isRight ? RIGHT : LEFT
246      const cellRow = grid[row]
247      if (cellRow && col >= 0) cellRow[col] = (cellRow[col] ?? 0) | bits[d % 4]!
248    }
249  })
250  return grid.map(r => r.map(b => String.fromCharCode(0x2800 + b)).join(''))
251}
252
253// ---------------------------------------------------------------------------
254// Raster: a grid of colored cells, packed as the engine wants it.
255
256const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
257
258export function toBase64(bytes: Uint8Array): string {
259  let out = ''
260  let i = 0
261  for (; i + 2 < bytes.length; i += 3) {
262    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8) | bytes[i + 2]!
263    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + B64[n & 63]!
264  }
265  const rest = bytes.length - i
266  if (rest === 1) {
267    const n = bytes[i]! << 16
268    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + '=='
269  } else if (rest === 2) {
270    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8)
271    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + '='
272  }
273  return out
274}
275
276/** The terminal's own default color, for a cell's foreground or background. */
277export const DEFAULT_COLOR = 0x01000000
278
279export class Canvas {
280  readonly cols: number
281  readonly rows: number
282  private readonly words: Uint32Array
283
284  constructor(cols: number, rows: number) {
285    this.cols = Math.max(1, Math.min(512, Math.floor(cols)))
286    this.rows = Math.max(1, Math.min(256, Math.floor(rows)))
287    this.words = new Uint32Array(this.cols * this.rows * 3)
288    this.clear()
289  }
290
291  clear(bg: number = DEFAULT_COLOR): void {
292    for (let i = 0; i < this.cols * this.rows; i++) {
293      this.words[i * 3] = 0x20
294      this.words[i * 3 + 1] = DEFAULT_COLOR
295      this.words[i * 3 + 2] = bg
296    }
297  }
298
299  /** Puts one width-1 character; colors are '#rrggbb' or a packed int. */
300  set(x: number, y: number, ch: string, fg?: string | number, bg?: string | number): void {
301    const cx = Math.floor(x)
302    const cy = Math.floor(y)
303    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
304    const i = (cy * this.cols + cx) * 3
305    const code = ch.codePointAt(0) ?? 0x20
306    this.words[i] = code > 0xffff || code < 0x20 ? 0x20 : code
307    if (fg !== undefined) this.words[i + 1] = typeof fg === 'number' ? fg : hexToInt(fg)
308    if (bg !== undefined) this.words[i + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
309  }
310
311  /** Writes a string left to right, clipped to the canvas. */
312  text(x: number, y: number, s: string, fg?: string | number, bg?: string | number): void {
313    let cx = x
314    for (const ch of s) {
315      this.set(cx, y, ch, fg, bg)
316      cx++
317    }
318  }
319
320  /** Paints a cell's background only, keeping its character. */
321  paint(x: number, y: number, bg: string | number): void {
322    const cx = Math.floor(x)
323    const cy = Math.floor(y)
324    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
325    this.words[(cy * this.cols + cx) * 3 + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
326  }
327
328  /** Two vertical pixels per cell with the upper-half block: `py` is in half-cells. */
329  pixel(x: number, py: number, color: string): void {
330    const cx = Math.floor(x)
331    const cy = Math.floor(py / 2)
332    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
333    const i = (cy * this.cols + cx) * 3
334    const c = hexToInt(color)
335    const isTop = Math.floor(py) % 2 === 0
336    if (this.words[i] !== 0x2580) {
337      this.words[i] = 0x2580
338      this.words[i + 1] = DEFAULT_COLOR
339      this.words[i + 2] = DEFAULT_COLOR
340    }
341    if (isTop) this.words[i + 1] = c
342    else this.words[i + 2] = c
343  }
344
345  /** The `cells` prop of a Raster. */
346  encode(): string {
347    return toBase64(new Uint8Array(this.words.buffer))
348  }
349}
350
351// ---------------------------------------------------------------------------
352// SVG for the desktop: one drawing per row (the desktop wraps siblings).
353
354export const FONT = "-apple-system,BlinkMacSystemFont,'Segoe UI',Inter,sans-serif"
355export const MONO = "ui-monospace,'Cascadia Code','SF Mono',Consolas,monospace"
356
357const XML_ESC: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
358
359export function xml(s: string): string {
360  return s.replace(/[&<>"']/g, c => XML_ESC[c]!)
361}
362
363/** Theme-aware classes every KOZMOS drawing shares: t text, s secondary, m muted, k track, p panel. */
364export const SVG_BASE_CSS = `
365.t{fill:#1f1f1f}.s{fill:#5f5f5c}.m{fill:#8e8e8a}.k{fill:#e7e5e0}.p{fill:#f5f4f1}.ln{stroke:#e1dfda}
366@media (prefers-color-scheme: dark){.t{fill:#ededed}.s{fill:#b4b4b0}.m{fill:#7c7c78}.k{fill:#2d2d2b}.p{fill:#232322}.ln{stroke:#363634}}
367.pulse{animation:kzp 1.6s ease-in-out infinite}@keyframes kzp{50%{opacity:.35}}
368.spin{transform-box:fill-box;transform-origin:center;animation:kzs 2s linear infinite}@keyframes kzs{to{transform:rotate(360deg)}}
369@media (prefers-reduced-motion: reduce){*{animation:none!important}}
370`
371
372export function svg(width: number, height: number, body: string, css = ''): string {
373  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><style>${SVG_BASE_CSS}${css}</style>${body}</svg>`
374}
375
376/** Rough width of UI text in px, for fitting labels. */
377export function textWidth(s: string, size: number): number {
378  let w = 0
379  for (const ch of s) w += (/[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-Z@%MWmw]/.test(ch) ? 0.72 : 0.56) * size
380  return w
381}
382
383export function fitText(s: string, size: number, maxW: number): string {
384  if (textWidth(s, size) <= maxW) return s
385  let out = ''
386  for (const ch of s) {
387    if (textWidth(out + ch + '…', size) > maxW) break
388    out += ch
389  }
390  return out + '…'
391}
392
393/** A rounded progress bar as SVG markup. */
394export function svgBar(x: number, y: number, w: number, h: number, ratio: number, color: string): string {
395  const fw = Math.max(0, Math.min(w, w * clamp01(ratio)))
396  return `<rect class="k" x="${x}" y="${y}" width="${w}" height="${h}" rx="${h / 2}"/>` +
397    (fw > 0 ? `<rect x="${x}" y="${y}" width="${Math.max(h, fw)}" height="${h}" rx="${h / 2}" fill="${color}"/>` : '')
398}
399
400/** SVG text helper. */
401export function svgText(x: number, y: number, s: string, opts: { cls?: string; size?: number; weight?: number; anchor?: 'start' | 'middle' | 'end'; fill?: string; mono?: boolean } = {}): string {
402  const { cls = 't', size = 12, weight = 400, anchor = 'start', fill, mono = false } = opts
403  return `<text ${fill ? `fill="${fill}"` : `class="${cls}"`} x="${x}" y="${y}" font-family="${mono ? MONO : FONT}" font-size="${size}" font-weight="${weight}" text-anchor="${anchor}" font-variant-numeric="tabular-nums">${xml(s)}</text>`
404}
405
406/** Pixel width a desktop pane or band gives a drawing for `columns` reported columns. */
407export function pxOf(columns: number | undefined, fallback = 60, slack = 8): number {
408  return Math.max(200, Math.min(1600, (columns || fallback) * 8 - slack))
409}
410
411// ---------------------------------------------------------------------------
412// Prices, USD per million tokens: input, output, cache read, cache write (5 min).
413// Anthropic first-party rates as of 2026-10. Used only where the engine reports
414// tokens and not money (a subagent's own spend); the session total comes from
415// $.session.usage().cost, which is the engine's.
416
417const PRICES: [RegExp, [number, number, number, number]][] = [
418  [/fable|mythos/i, [10, 50, 0.25, 12.5]],
419  [/opus-5-5/i, [4, 20, 0.2, 5]],
420  [/opus/i, [5, 25, 0.5, 6.25]],
421  [/sonnet-5/i, [2, 10, 0.2, 2.5]],
422  [/sonnet/i, [3, 15, 0.3, 3.75]],
423  [/haiku-5/i, [0.1, 0.5, 0.01, 0.125]],
424  [/haiku/i, [1, 5, 0.1, 1.25]],
425]
426
427export type Usage = {
428  input_tokens?: number
429  output_tokens?: number
430  cache_read_input_tokens?: number
431  cache_creation_input_tokens?: number
432}
433
434export function priceOf(model: string): [number, number, number, number] {
435  return PRICES.find(([re]) => re.test(model))?.[1] ?? [4, 20, 0.2, 5]
436}
437
438export function costOf(model: string, u: Usage | null | undefined): number {
439  if (!u) return 0
440  const [i, o, r, w] = priceOf(model)
441  return ((u.input_tokens ?? 0) * i + (u.output_tokens ?? 0) * o + (u.cache_read_input_tokens ?? 0) * r + (u.cache_creation_input_tokens ?? 0) * w) / 1e6
442}
443
444export function tokensOf(u: Usage | null | undefined): number {
445  if (!u) return 0
446  return (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
447}
448
449/** The context a request filled: everything it read plus what it wrote. */
450export function contextOf(u: Usage | null | undefined): number {
451  return tokensOf(u)
452}
453
454export function windowOf(model: string): number {
455  return /haiku-4/i.test(model) ? 200_000 : 1_000_000
456}
457
458// ---------------------------------------------------------------------------
459// Rate limits.
460
461export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
462
463export function limitLabel(kind: string): string {
464  if (kind === 'five_hour') return '5h'
465  if (kind === 'seven_day') return '7d'
466  if (kind === 'spend_limit') return '$'
467  return kind.replace(/_/g, ' ')
468}
469
470/** Milliseconds until a window resets, or undefined. */
471export function untilReset(l: Limit, now: number): number | undefined {
472  if (!l.resetsAt) return undefined
473  const t = Date.parse(l.resetsAt)
474  return Number.isFinite(t) ? Math.max(0, t - now) : undefined
475}
476
477/** The length of a window in ms, for burn-rate math. */
478export function windowMs(kind: string): number | undefined {
479  if (kind === 'five_hour') return 5 * 3600_000
480  if (kind === 'seven_day') return 7 * 24 * 3600_000
481  return undefined
482}
483
484// ---------------------------------------------------------------------------
485// Tool calls, described in a few words.
486
487/** What a tool call is about: the command, the file, the pattern, the agent's task. */
488export function toolDetail(tool: string, input: unknown): string {
489  const e = (input ?? {}) as Record<string, unknown>
490  const s = (k: string) => (typeof e[k] === 'string' ? (e[k] as string) : '')
491  const t = String(tool)
492  if (t === 'Bash' || t === 'PowerShell') return clip(s('description') || s('command'), 60)
493  if (s('file_path')) return baseName(s('file_path'))
494  if (s('notebook_path')) return baseName(s('notebook_path'))
495  if (t === 'Agent') return clip(s('description') || s('subagent_type'), 60)
496  if (s('pattern')) return clip(s('pattern'), 60)
497  if (s('url')) return clip(s('url').replace(/^https?:\/\//, ''), 60)
498  if (s('query')) return clip(s('query'), 60)
499  if (s('subject')) return clip(s('subject'), 60)
500  if (Array.isArray(e.todos)) return `${(e.todos as unknown[]).length} todos`
501  if (t.startsWith('mcp__')) return t.split('__').slice(1).join(' · ')
502  return ''
503}
504
505/** `mcp__server__tool` → `server·tool`; built-ins unchanged. */
506export function toolName(tool: string): string {
507  const t = String(tool)
508  return t.startsWith('mcp__') ? t.split('__').slice(1).join('·') : t
509}
510
hooks/lab.ts 311 lines
1// 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}
311
types/index.d.ts 40 lines
1/** 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