SLOPSHOPPER

gearbox

KOZMOS Gearbox: tool analytics for the session. Per tool its calls, errors and error rate, average, p95 and max duration and total time as a sorted bar chart…

newpaneguardcommandagents
★ 4v0.1.0MITupdated 2026-10-09ersinkoc/claude-mods/mods/gearbox
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · gearbox
│ ┃ KOZMOS · Gearbox ✕ › fix the failing auth test and add an audit log call │ ┃ ⚙ 9 calls ✖ 1 err Σ 180ms │ ┃ main ██████████████████████████████ sub 9/0 ⏺ Read(src/auth.ts) │ ┃ 9/min ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ ⎿ Read 6 lines │ ┃ calls ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ TOOL CALLS ERR% P95 TOTAL[ ⇅ sort: ⏺ Bash(bun test) │ ┃ $ Bash 4 25% 20ms 80ms ████████… ⎿ 3 pass, 1 fail │ ┃ ✎ Write 2 · 20ms 40ms ████████… │ ┃ ✎ Edit 1 · 20ms 20ms █████▎ … ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ⌕ Grep 1 · 20ms 20ms █████▎ … │ ┃ ◉ Read 1 · 20ms 20ms █████▎ … ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ ⏱ SLOWEST › /gearbox │ ┃ 20ms Read auth.ts ⎿ gearbox: Gearbox open. │ ┃ 20ms Grep refresh\( │ ┃ 20ms Edit auth.ts │ ┃ 20ms Write audit.ts │ ┃ 20ms Write cache.ts │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · KOZMOS · Gearbox
⚙ 9 calls ✖ 1 err Σ 180ms main ██████████████████████████████ sub 9/0 9/min ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸ calls ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸ TOOL CALLS ERR% P95 TOTAL [ ⇅ sort: total time ] $ Bash 4 25% 20ms 80ms █████████████████████ ✎ Write 2 · 20ms 40ms ██████████▌ ✎ Edit 1 · 20ms 20ms █████▎ ⌕ Grep 1 · 20ms 20ms █████▎ ◉ Read 1 · 20ms 20ms █████▎ ⏱ SLOWEST 20ms Read auth.ts 20ms Grep refresh\( 20ms Edit auth.ts 20ms Write audit.ts 20ms Write cache.ts
README

KOZMOS Gearbox

Tool analytics for the session in a sidebar. Every tool call is timed, by the main loop and by subagents alike.

  • Per tool: calls, errors and error rate, average, p95 and max duration, and total time, with a bar in the tool's family color.
  • A sort button (hotkey s) cycles the order and the bars between total time, calls and errors.
  • The five slowest calls with their detail (the command, the file, the pattern) and which subagent made them.
  • Calls per minute over the session (up to two hours) as a braille graph.
  • Main loop against subagents: a split bar of calls, with their tool time.

Terminal: an aligned table with bars (average and max columns from 58 columns wide). Desktop: header tiles, a calls-per-minute area chart, an SVG bar chart whose bars grow in and show the errored share in red, and a ranked list of the slowest calls.

  • /gearbox toggles the sidebar; autoOpen (default off) opens it at start.

Türkçe

Oturumun araç analizini kenar çubuğunda gösterir. Ana döngünün de alt ajanların da her araç çağrısı ölçülür.

  • Araç başına: çağrı, hata ve hata oranı, ortalama, p95 ve en uzun süre, toplam süre; aracın ailesinin renginde bir çubukla.
  • Sıralama düğmesi (kısayol s) sırayı ve çubukları toplam süre, çağrı ve hata arasında döndürür.
  • En yavaş beş çağrı; ayrıntısı (komut, dosya, desen) ve yapan alt ajanla.
  • Oturum boyunca dakika başına çağrı (en fazla iki saat), braille grafik olarak.
  • Ana döngü ve alt ajanlar: çağrıları bölen bir çubuk ve araç süreleri.

Terminal: çubuklu, hizalı bir tablo (58 sütundan genişte ortalama ve en uzun sütunlarıyla). Masaüstü: başlık kutucukları, dakika başına çağrı alan grafiği, büyüyerek gelen ve hatalı payı kırmızı gösteren SVG çubuk grafik ve en yavaş çağrıların sıralı listesi.

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