KOZMOS Vitals: a btop-style machine monitor sidebar. CPU with a braille history graph, RAM bar and history, disk use, NVIDIA GPU load, memory and temperature…

A btop-style machine monitor in a sidebar pane. /vitals toggles it.
C: on Windows, / elsewhere.nvidia-smi, when it answers; the section is left out otherwise.It samples every 2 s only while the pane is open (one PowerShell call on Windows, /proc + df + ps on Linux, top on macOS). Terminal: braille graphs as colored Rasters, each row tinted by the height it stands for (green low, red high), plus eighth-block bars. Desktop: one SVG card per section with gradient area charts, a dashed grid and a pulsing live dot, in light and dark.
Settings: autoOpen (default false) opens the pane when a session starts.
Kenar panelinde btop tarzı bir makine izleyicisi. /vitals açıp kapatır.
C:, diğerlerinde /.nvidia-smi yanıt verirse kullanım, VRAM ve sıcaklık; yoksa bölüm gösterilmez.Yalnızca panel açıkken 2 saniyede bir örnekler (Windows'ta tek bir PowerShell çağrısı, Linux'ta /proc + df + ps, macOS'ta top). Terminal: renkli Raster olarak braille grafikler, her satır temsil ettiği yüksekliğe göre renklenir (altta yeşil, üstte kırmızı), ve sekizlik blok çubuklar. Masaüstü: her bölüm için gradyanlı alan grafikleri, kesikli ızgara ve nabız gibi atan canlı nokta içeren bir SVG kart; açık ve koyu temada.
Ayar: autoOpen (varsayılan false) oturum başlarken paneli açar.
hooks/register.tsx 379 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { VitalsSnap } from '../types'
5import { KZ, bar, clamp01, fitText, fmtClock, fmtPct, heat, mix, pxOf, svg, svgBar, svgText } from './lib/kz.ts'
6import { MAC_MEMSIZE, MAC_TOP, NVIDIA_SMI, WIN_SYS, fmtBytes, parseMacTop, parseMeminfo, parseNvidia, parseProcStat, parseWinSys, platformFrom } from './lib/probe.ts'
7import type { CpuTicks, Platform, SysSnap } from './lib/probe.ts'
8import { DF_ROOT, HISTORY, PS_PIDS, avg, brailleCanvas, chartPoints, countLines, parseDf, pushHist, ratioOf, smoothPaths, tempRatio } from './meter.ts'
9
10const PANE = 'kz-vitals'
11const TITLE = 'KOZMOS · Vitals'
12const SAMPLE_MS = 2000
13const snapAtom = atom({ plugin: 'vitals', key: 'snap' } as const, null)
14
15const blank = (now: number): VitalsSnap => ({
16 platform: 'linux', cpuHist: [], memHist: [], diskLabel: '/', gpuHist: [], samples: 0,
17 sessionStart: now, toolCalls: 0, agentsRunning: 0, agentsDone: 0, now,
18})
19
20let live: VitalsSnap = blank(0)
21let platform: Platform | undefined
22let cpuTicks: CpuTicks | undefined
23let hasGpu: boolean | undefined
24let isBusy = false
25
26async function isOpen($: EngineInterface): Promise<boolean> {
27 return (await $.ui.panes()).some(p => p.id === PANE)
28}
29
30async function toggle($: EngineInterface): Promise<boolean> {
31 if (await isOpen($)) {
32 await $.ui.close({ id: PANE })
33 return false
34 }
35 await $.ui.open({ id: PANE, title: TITLE })
36 await sample($).catch(() => undefined)
37 return true
38}
39
40// ---------------------------------------------------------------------------
41// Probes.
42
43async function sh($: EngineInterface, argv: readonly string[], timeoutMs: number): Promise<string | undefined> {
44 try {
45 const r = await $.process.run(argv, { timeoutMs })
46 return r.exitCode === 0 ? r.stdout : undefined
47 } catch {
48 return undefined
49 }
50}
51
52/** The host's platform, probed once per module load. */
53async function detectPlatform($: EngineInterface): Promise<Platform> {
54 if (platform === undefined) {
55 const os = await $.env.get('OS')
56 platform = platformFrom(os, os ? '' : await sh($, ['uname', '-s'], 2000))
57 }
58 return platform
59}
60
61async function readSys($: EngineInterface, platform: Platform): Promise<SysSnap> {
62 let snap: SysSnap = {}
63 if (platform === 'win') {
64 snap = parseWinSys((await sh($, WIN_SYS, 8000)) ?? '')
65 } else {
66 if (platform === 'mac') {
67 const top = (await sh($, MAC_TOP, 6000)) ?? ''
68 snap = parseMacTop(top, (await sh($, MAC_MEMSIZE, 2000)) ?? '')
69 } else {
70 try {
71 const { cpu, ticks } = parseProcStat(await $.fs.read('/proc/stat'), cpuTicks)
72 cpuTicks = ticks
73 snap = { cpu, ...parseMeminfo(await $.fs.read('/proc/meminfo')) }
74 } catch {
75 snap = {}
76 }
77 }
78 Object.assign(snap, parseDf((await sh($, DF_ROOT, 3000)) ?? ''))
79 snap.procs = countLines((await sh($, PS_PIDS, 3000)) ?? '')
80 }
81 if (hasGpu !== false) {
82 const out = await sh($, NVIDIA_SMI, 4000)
83 hasGpu = out !== undefined
84 snap.gpu = out === undefined ? undefined : parseNvidia(out)
85 }
86 return snap
87}
88
89async function readClaude($: EngineInterface): Promise<void> {
90 try {
91 live.sessionStart = (await $.session.usage()).startedAt
92 } catch {
93 // Keep the start this module saw.
94 }
95 try {
96 const agents = await $.agent.list()
97 live.agentsRunning = agents.filter(a => a.status === 'running' || a.status === 'pending' || a.status === 'waiting').length
98 live.agentsDone = agents.filter(a => a.status === 'completed' || a.status === 'idle').length
99 } catch {
100 // Left as it was.
101 }
102}
103
104async function sample($: EngineInterface): Promise<void> {
105 if (isBusy) return
106 isBusy = true
107 try {
108 const host = await detectPlatform($)
109 const s = await readSys($, host)
110 await readClaude($)
111 live.platform = host
112 live.diskLabel = live.platform === 'win' ? 'C:' : '/'
113 live.cpu = s.cpu
114 live.cpuHist = pushHist(live.cpuHist, s.cpu)
115 live.memUsed = s.memUsed
116 live.memTotal = s.memTotal
117 live.memHist = pushHist(live.memHist, s.memTotal ? ((s.memUsed ?? 0) / s.memTotal) * 100 : undefined)
118 live.diskUsed = s.diskUsed
119 live.diskTotal = s.diskTotal
120 live.procs = s.procs
121 live.gpu = s.gpu ? { name: s.gpu.name, util: s.gpu.util, memUsed: s.gpu.memUsed, memTotal: s.gpu.memTotal, temp: s.gpu.temp } : undefined
122 live.gpuHist = pushHist(live.gpuHist, s.gpu?.util)
123 live.samples++
124 await publish($)
125 } finally {
126 isBusy = false
127 }
128}
129
130// Each sample counts itself, so every publish carries a new picture.
131async function publish($: EngineInterface): Promise<void> {
132 live.now = await $.clock.now()
133 const snap: VitalsSnap = { ...live, cpuHist: [...live.cpuHist], memHist: [...live.memHist], gpuHist: [...live.gpuHist] }
134 await update($, snapAtom, () => snap)
135}
136
137async function tick($: EngineInterface): Promise<void> {
138 if (await isOpen($)) await sample($)
139}
140
141// ---------------------------------------------------------------------------
142
143export const register: Register = (on, options) => {
144 on('session.start', async ($, e, next) => {
145 const started = await next(e)
146 live = blank(await $.clock.now())
147 cpuTicks = undefined
148 isBusy = false
149 await $.command.register({ name: 'vitals', description: 'KOZMOS: toggle the Vitals machine monitor sidebar', immediate: true })
150 $.clock.every(SAMPLE_MS, () => void tick($).catch(() => undefined))
151 if (options.autoOpen === true) void $.ui.open({ id: PANE, title: TITLE })
152 return started
153 })
154
155 on('command.run', { command: 'vitals' }, async $ => ({
156 text: (await toggle($)) ? 'Vitals open.' : 'Vitals closed.',
157 }))
158
159 on('tool.call', async ($, e, next) => {
160 live.toolCalls++
161 return next(e)
162 }).catch(($, e, next) => next(e))
163
164 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
165 const snap = (await read($, snapAtom)) ?? blank(await $.clock.now())
166 const cols = Math.max(28, e.props.bodyColumns || 40)
167
168 // Every surface but the terminal draws Svg cards; the terminal draws Raster graphs.
169 if (e.surface !== 'terminal') {
170 const { Box, Svg } = $.ui.resolve(e)
171 const W = pxOf(cols, 44)
172 const cards = desktopCards(snap, W)
173 return (
174 <Box flexDirection="column">
175 {cards.map(c => <Svg key={c.key} source={c.source} alt={c.alt} width={W} height={c.height} />)}
176 </Box>
177 )
178 }
179
180 const { Box, Text, Raster } = $.ui.resolve(e)
181 const label = (key: string, name: string, value: RenderChildren, right: RenderChildren) => (
182 <Box key={key} flexDirection="row" justifyContent="space-between" marginTop={1}>
183 <Text wrap="truncate-end"><Text bold color={KZ.violet}>{name}</Text> {value}</Text>
184 <Box flexShrink={0} marginLeft={1}><Text dimColor>{right}</Text></Box>
185 </Box>
186 )
187 const graph = (key: string, values: readonly number[], rows: number, tint?: (l: number) => string) => {
188 const c = brailleCanvas(values, cols, rows, 100, tint)
189 return <Raster key={key} columns={c.cols} rows={c.rows} cells={c.encode()} />
190 }
191 const barW = Math.max(6, cols - 24)
192 const meter = (key: string, name: string, ratio: number, text: string) => (
193 <Box key={key} flexDirection="row">
194 <Box width={5} flexShrink={0}><Text dimColor>{name}</Text></Box>
195 <Text color={heat(ratio)}>{bar(ratio, barW)}</Text>
196 <Text> {text}</Text>
197 </Box>
198 )
199
200 const cpuAvg = avg(snap.cpuHist.slice(-30))
201 const cpuPeak = Math.max(0, ...snap.cpuHist)
202 const memRatio = ratioOf(snap.memUsed, snap.memTotal)
203 const diskRatio = ratioOf(snap.diskUsed, snap.diskTotal)
204 const elapsed = snap.now - snap.sessionStart
205 const memTint = (l: number) => mix(KZ.teal, KZ.violet, l)
206 const gpuTint = (l: number) => mix(KZ.green, KZ.magenta, l)
207
208 return (
209 <Box flexDirection="column">
210 <Box flexDirection="row" justifyContent="space-between">
211 <Text bold color={KZ.cyan}>▌VITALS</Text>
212 <Text dimColor>{snap.samples ? <Text color={KZ.green}>● </Text> : '○ '}{snap.platform} · {SAMPLE_MS / 1000}s · {Math.min(snap.cpuHist.length, HISTORY)} pts</Text>
213 </Box>
214 {label('cpu', 'CPU', <Text bold color={heat((snap.cpu ?? 0) / 100)}>{fmtPct(snap.cpu)}</Text>, `avg ${fmtPct(cpuAvg)} · peak ${fmtPct(cpuPeak)}`)}
215 {graph('cpu-g', snap.cpuHist, 4)}
216 {label('ram', 'RAM', <Text bold color={heat(memRatio)}>{fmtPct(memRatio * 100)}</Text>, `${fmtBytes(snap.memUsed)} / ${fmtBytes(snap.memTotal)}`)}
217 {meter('ram-b', '', memRatio, '')}
218 {graph('ram-g', snap.memHist, 2, memTint)}
219 {label('disk', `DISK ${snap.diskLabel}`, <Text bold color={heat(diskRatio)}>{snap.diskTotal ? fmtPct(diskRatio * 100) : '—'}</Text>, `${fmtBytes(snap.diskUsed)} / ${fmtBytes(snap.diskTotal)}`)}
220 {snap.diskTotal ? meter('disk-b', '', diskRatio, '') : null}
221 {snap.gpu
222 ? [
223 label('gpu', 'GPU', <Text bold color={heat(snap.gpu.util / 100)}>{fmtPct(snap.gpu.util)}</Text>, snap.gpu.name),
224 meter('gpu-u', 'util', snap.gpu.util / 100, ''),
225 meter('gpu-m', 'vram', snap.gpu.memTotal ? snap.gpu.memUsed / snap.gpu.memTotal : 0, `${fmtBytes(snap.gpu.memUsed)}`),
226 <Box key="gpu-t" flexDirection="row">
227 <Box width={5} flexShrink={0}><Text dimColor>temp</Text></Box>
228 <Text color={heat(tempRatio(snap.gpu.temp))}>{bar(tempRatio(snap.gpu.temp), barW)}</Text>
229 <Text bold color={heat(tempRatio(snap.gpu.temp))}> {Math.round(snap.gpu.temp)}°C</Text>
230 </Box>,
231 graph('gpu-g', snap.gpuHist, 2, gpuTint),
232 ]
233 : null}
234 <Box marginTop={1} flexDirection="row" justifyContent="space-between">
235 <Text><Text bold color={KZ.violet}>PROC</Text> {snap.procs ?? '—'}</Text>
236 <Text dimColor>{snap.samples} samples</Text>
237 </Box>
238 <Box marginTop={1}>
239 <Text color={KZ.mist} dimColor>{'─'.repeat(Math.max(4, cols))}</Text>
240 </Box>
241 <Text wrap="truncate-end">
242 <Text color={KZ.clay}>✻ </Text>
243 <Text color={KZ.cyan}>⏱ {fmtClock(elapsed)}</Text>
244 <Text dimColor> · </Text>
245 <Text color={KZ.yellow}>⚒ {snap.toolCalls} tools</Text>
246 <Text dimColor> · </Text>
247 <Text color={snap.agentsRunning ? KZ.violet : KZ.mist}>◈ {snap.agentsRunning} agents</Text>
248 </Text>
249 </Box>
250 )
251 })
252}
253
254// ---------------------------------------------------------------------------
255// Desktop: one SVG per section.
256
257type Card = { key: string; source: string; alt: string; height: number }
258
259const CSS = `
260.live{transform-box:fill-box;transform-origin:center;animation:vtlive 1.6s ease-out infinite}@keyframes vtlive{0%{opacity:.8;transform:scale(1)}100%{opacity:0;transform:scale(3)}}
261.sweep{animation:vtsweep 2s ease-in-out infinite}@keyframes vtsweep{0%,100%{opacity:.0}50%{opacity:.5}}
262`
263
264/** An area chart with a gradient, light grid and a live dot at the newest sample. */
265function areaChart(id: string, values: readonly number[], x: number, y: number, w: number, h: number, color: string): string {
266 const out: string[] = []
267 out.push(`<defs><linearGradient id="${id}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${color}" stop-opacity=".55"/><stop offset=".7" stop-color="${color}" stop-opacity=".12"/><stop offset="1" stop-color="${color}" stop-opacity="0"/></linearGradient></defs>`)
268 for (const g of [0.25, 0.5, 0.75]) out.push(`<line class="ln" x1="${x}" x2="${x + w}" y1="${(y + h - g * h).toFixed(1)}" y2="${(y + h - g * h).toFixed(1)}" stroke-width="1" stroke-dasharray="2 4"/>`)
269 out.push(`<line class="ln" x1="${x}" x2="${x + w}" y1="${y + h}" y2="${y + h}" stroke-width="1"/>`)
270 if (values.length >= 2) {
271 const pts = chartPoints(values, x, y, w, h, 100)
272 const { line, area } = smoothPaths(pts, y + h)
273 out.push(`<path d="${area}" fill="url(#${id})"/>`)
274 out.push(`<path d="${line}" fill="none" stroke="${color}" stroke-width="1.8" stroke-linejoin="round" stroke-linecap="round"/>`)
275 const [lx, ly] = pts[pts.length - 1]!
276 out.push(`<line x1="${lx.toFixed(1)}" x2="${lx.toFixed(1)}" y1="${y}" y2="${y + h}" stroke="${color}" stroke-width="1" class="sweep"/>`)
277 out.push(`<circle class="live" cx="${lx.toFixed(1)}" cy="${ly.toFixed(1)}" r="3.5" fill="${color}"/>`)
278 out.push(`<circle cx="${lx.toFixed(1)}" cy="${ly.toFixed(1)}" r="3.5" fill="${color}" stroke="#fff" stroke-width="1.2"/>`)
279 } else {
280 out.push(svgText(x + w / 2, y + h / 2 + 4, 'sampling…', { cls: 'm', size: 10.5, anchor: 'middle' }))
281 }
282 return out.join('')
283}
284
285function header(W: number, title: string, value: string, valueColor: string, sub: string, pad: number): string {
286 return svgText(pad, 22, title, { cls: 's', size: 10.5, weight: 700 }) +
287 svgText(pad, 46, value, { size: 24, weight: 750, fill: valueColor }) +
288 svgText(W - pad, 22, sub, { cls: 'm', size: 10.5, anchor: 'end' })
289}
290
291function desktopCards(s: VitalsSnap, W: number): Card[] {
292 const pad = 14
293 const inner = W - pad * 2
294 const cards: Card[] = []
295
296 // CPU.
297 {
298 const H = 150
299 const c = heat((s.cpu ?? 0) / 100)
300 const body = `<rect class="p" x="0" y="0" width="${W}" height="${H}" rx="14"/>` +
301 header(W, 'CPU', fmtPct(s.cpu), c, `avg ${fmtPct(avg(s.cpuHist.slice(-30)))} · peak ${fmtPct(Math.max(0, ...s.cpuHist))}`, pad) +
302 svgText(W - pad, 46, `${s.procs ?? '—'} processes`, { cls: 's', size: 11, anchor: 'end' }) +
303 areaChart('vtCpu', s.cpuHist, pad, 60, inner, H - 72, c)
304 cards.push({ key: 'cpu', source: svg(W, H, body, CSS), alt: `CPU ${fmtPct(s.cpu)}, ${s.procs ?? 'unknown'} processes`, height: H })
305 }
306
307 // RAM.
308 {
309 const H = 122
310 const r = ratioOf(s.memUsed, s.memTotal)
311 const body = `<rect class="p" x="0" y="0" width="${W}" height="${H}" rx="14"/>` +
312 header(W, 'MEMORY', fmtPct(s.memTotal ? r * 100 : undefined), mix(KZ.teal, KZ.violet, r), `${fmtBytes(s.memUsed)} / ${fmtBytes(s.memTotal)}`, pad) +
313 svgBar(W / 2, 38, inner / 2 - 0, 8, r, mix(KZ.teal, KZ.violet, r)) +
314 areaChart('vtRam', s.memHist, pad, 62, inner, H - 72, KZ.violet)
315 cards.push({ key: 'ram', source: svg(W, H, body, CSS), alt: `Memory ${fmtBytes(s.memUsed)} of ${fmtBytes(s.memTotal)}`, height: H })
316 }
317
318 // Disk: a ring gauge.
319 {
320 const H = 84
321 const r = ratioOf(s.diskUsed, s.diskTotal)
322 const R = 26
323 const C = 2 * Math.PI * R
324 const cx = pad + R + 2
325 const cy = H / 2
326 const c = heat(r)
327 const body = `<rect class="p" x="0" y="0" width="${W}" height="${H}" rx="14"/>` +
328 `<circle cx="${cx}" cy="${cy}" r="${R}" fill="none" stroke="${KZ.mist}" stroke-opacity=".22" stroke-width="7"/>` +
329 `<circle cx="${cx}" cy="${cy}" r="${R}" fill="none" stroke="${c}" stroke-width="7" stroke-linecap="round" stroke-dasharray="${(C * clamp01(r)).toFixed(1)} ${C.toFixed(1)}" transform="rotate(-90 ${cx} ${cy})"/>` +
330 svgText(cx, cy + 4, s.diskTotal ? fmtPct(r * 100) : '—', { size: 12, weight: 700, anchor: 'middle' }) +
331 svgText(cx + R + 18, cy - 6, `DISK ${s.diskLabel}`, { cls: 's', size: 10.5, weight: 700 }) +
332 svgText(cx + R + 18, cy + 13, s.diskTotal ? `${fmtBytes(s.diskUsed)} used of ${fmtBytes(s.diskTotal)}` : 'not measured', { size: 12, weight: 600 }) +
333 (s.diskTotal ? svgText(W - pad, cy + 13, `${fmtBytes(s.diskTotal * (1 - r))} free`, { cls: 'm', size: 10.5, anchor: 'end' }) : '')
334 cards.push({ key: 'disk', source: svg(W, H, body, CSS), alt: `Disk ${s.diskLabel} ${s.diskTotal ? fmtPct(r * 100) : 'unknown'}`, height: H })
335 }
336
337 // GPU, when nvidia-smi answers.
338 if (s.gpu) {
339 const g = s.gpu
340 const H = 168
341 const memR = g.memTotal ? g.memUsed / g.memTotal : 0
342 const tr = tempRatio(g.temp)
343 const gw = (inner - 16) / 3
344 const gauge = (i: number, label: string, ratio: number, text: string, color: string) => {
345 const x = pad + i * (gw + 8)
346 return svgText(x, 66, label, { cls: 'm', size: 9.5, weight: 700 }) +
347 svgText(x + gw, 66, text, { size: 11, weight: 650, anchor: 'end', fill: color }) +
348 svgBar(x, 72, gw, 6, ratio, color)
349 }
350 const body = `<rect class="p" x="0" y="0" width="${W}" height="${H}" rx="14"/>` +
351 header(W, 'GPU', fmtPct(g.util), heat(g.util / 100), fitText(g.name, 10.5, inner * 0.6), pad) +
352 gauge(0, 'UTIL', g.util / 100, fmtPct(g.util), heat(g.util / 100)) +
353 gauge(1, 'VRAM', memR, fmtBytes(g.memUsed), mix(KZ.teal, KZ.violet, memR)) +
354 gauge(2, 'TEMP', tr, `${Math.round(g.temp)}°C`, heat(tr)) +
355 areaChart('vtGpu', s.gpuHist, pad, 90, inner, H - 100, KZ.magenta)
356 cards.push({ key: 'gpu', source: svg(W, H, body, CSS), alt: `GPU ${g.name} ${fmtPct(g.util)}, ${Math.round(g.temp)} degrees`, height: H })
357 }
358
359 // The Claude side.
360 {
361 const H = 64
362 const tw = (W - 12) / 3
363 const tiles: [string, string, string][] = [
364 ['SESSION', fmtClock(s.now - s.sessionStart), KZ.cyan],
365 ['TOOL CALLS', String(s.toolCalls), KZ.yellow],
366 ['AGENTS', `${s.agentsRunning} running`, s.agentsRunning ? KZ.violet : KZ.mist],
367 ]
368 const body = tiles.map(([k, v, c], i) => {
369 const x = i * (tw + 6)
370 return `<rect class="p" x="${x}" y="0" width="${tw}" height="${H}" rx="14"/>` +
371 `<rect x="${x + 10}" y="12" width="3" height="${H - 24}" rx="1.5" fill="${c}"${i === 2 && s.agentsRunning ? ' class="pulse"' : ''}/>` +
372 svgText(x + 20, 26, k, { cls: 's', size: 9.5, weight: 700 }) +
373 svgText(x + 20, 46, fitText(v, 15, tw - 28), { size: 15, weight: 700 })
374 }).join('')
375 cards.push({ key: 'claude', source: svg(W, H, body, CSS), alt: `Session ${fmtClock(s.now - s.sessionStart)}, ${s.toolCalls} tool calls, ${s.agentsRunning} agents running`, height: H })
376 }
377 return cards
378}
379hooks/lib/kz.ts 510 lines1// GENERATED by scripts/sync-shared.mjs from shared/kz.ts. Do not edit here.
2// KOZMOS shared kit. Source of truth: shared/kz.ts at the bundle root.
3// `node scripts/sync-shared.mjs` copies it into every mod as hooks/lib/kz.ts,
4// because a hooks module may only import files inside its own plugin.
5// Edit the root copy, never a mod's copy.
6
7// ---------------------------------------------------------------------------
8// Palette: one neon family across every KOZMOS mod.
9
10export const KZ = {
11 violet: '#a78bfa',
12 magenta: '#f472b6',
13 cyan: '#22d3ee',
14 teal: '#2dd4bf',
15 green: '#4ade80',
16 lime: '#a3e635',
17 yellow: '#facc15',
18 amber: '#fb923c',
19 red: '#f87171',
20 blue: '#60a5fa',
21 clay: '#d97757',
22 ink: '#1f1e1d',
23 mist: '#9ca3af',
24} as const
25
26/** Tool families, colored the same in every mod. */
27export function toolColor(tool: string): string {
28 const t = String(tool)
29 if (t === 'Bash' || t === 'PowerShell') return KZ.green
30 if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit' || t === 'MultiEdit') return KZ.yellow
31 if (t === 'Read' || t === 'Glob' || t === 'Grep' || t === 'LSP') return KZ.blue
32 if (t === 'Agent' || t === 'Task' || t === 'Workflow') return KZ.violet
33 if (t.startsWith('Web')) return KZ.cyan
34 if (t.startsWith('Todo') || t.startsWith('Task')) return KZ.teal
35 if (t.startsWith('mcp__')) return KZ.magenta
36 return KZ.mist
37}
38
39/** A short glyph per tool family. */
40export function toolGlyph(tool: string): string {
41 const t = String(tool)
42 if (t === 'Bash' || t === 'PowerShell') return '$'
43 if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit') return '✎'
44 if (t === 'Read') return '◉'
45 if (t === 'Glob' || t === 'Grep') return '⌕'
46 if (t === 'Agent') return '◈'
47 if (t.startsWith('Web')) return '◍'
48 if (t.startsWith('Todo') || t.startsWith('Task')) return '☑'
49 if (t.startsWith('mcp__')) return '⬡'
50 return '•'
51}
52
53// ---------------------------------------------------------------------------
54// Colors.
55
56export function hexToRgb(hex: string): [number, number, number] {
57 const h = hex.replace('#', '')
58 const n = parseInt(h.length === 3 ? h.split('').map(c => c + c).join('') : h, 16)
59 return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
60}
61
62export function rgbToHex(r: number, g: number, b: number): string {
63 const c = (v: number) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')
64 return `#${c(r)}${c(g)}${c(b)}`
65}
66
67export function mix(a: string, b: string, t: number): string {
68 const [r1, g1, b1] = hexToRgb(a)
69 const [r2, g2, b2] = hexToRgb(b)
70 const k = clamp01(t)
71 return rgbToHex(r1 + (r2 - r1) * k, g1 + (g2 - g1) * k, b1 + (b2 - b1) * k)
72}
73
74/** Green at 0, yellow at 0.6, red at 1: the heat of a gauge. */
75export function heat(t: number): string {
76 const k = clamp01(t)
77 return k < 0.6 ? mix(KZ.green, KZ.yellow, k / 0.6) : mix(KZ.yellow, KZ.red, (k - 0.6) / 0.4)
78}
79
80/** A smooth rainbow for hues 0..1 (for auroras, plasma and the like). */
81export function hue(h: number, s = 0.75, l = 0.6): string {
82 const k = ((h % 1) + 1) % 1
83 const a = s * Math.min(l, 1 - l)
84 const f = (n: number) => {
85 const x = (n + k * 12) % 12
86 return l - a * Math.max(-1, Math.min(x - 3, 9 - x, 1))
87 }
88 return rgbToHex(f(0) * 255, f(8) * 255, f(4) * 255)
89}
90
91export function hexToInt(hex: string): number {
92 const [r, g, b] = hexToRgb(hex)
93 return (r << 16) | (g << 8) | b
94}
95
96// ---------------------------------------------------------------------------
97// Numbers and text.
98
99export const clamp01 = (v: number): number => (Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0)
100
101export function fmtTokens(n: number): string {
102 if (!Number.isFinite(n)) return '—'
103 // Each unit starts where the one below would round up to 1000 of itself.
104 if (n >= 999.5e6) return `${(n / 1e9).toFixed(1)}B`
105 if (n >= 999_500) return `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M`
106 if (n >= 999.5) return `${(n / 1e3).toFixed(n >= 1e4 ? 0 : 1)}k`
107 return `${Math.round(n)}`
108}
109
110export function fmtUsd(usd: number): string {
111 if (!Number.isFinite(usd)) return '$—'
112 if (usd < 0.01 && usd > 0) return '<$0.01'
113 return usd < 100 ? `$${usd.toFixed(2)}` : `$${Math.round(usd)}`
114}
115
116/** 0:42, 3:07, 1:02:33. */
117export function fmtClock(ms: number): string {
118 const s = Math.max(0, Math.round(ms / 1000))
119 const h = Math.floor(s / 3600)
120 const m = Math.floor((s % 3600) / 60)
121 const ss = String(s % 60).padStart(2, '0')
122 return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
123}
124
125/** 42s, 7m, 2h41m, 3d4h: compact durations for countdowns. */
126export function fmtSpan(ms: number): string {
127 const s = Math.max(0, Math.round(ms / 1000))
128 if (s < 60) return `${s}s`
129 const m = Math.floor(s / 60)
130 if (m < 60) return `${m}m`
131 const h = Math.floor(m / 60)
132 if (h < 48) return `${h}h${String(m % 60).padStart(2, '0')}m`
133 return `${Math.floor(h / 24)}d${h % 24}h`
134}
135
136export function fmtPct(p: number | undefined): string {
137 return p === undefined || !Number.isFinite(p) ? '—' : `${Math.round(p)}%`
138}
139
140export function clip(s: string, max: number): string {
141 const one = s.replace(/\s+/g, ' ').trim()
142 return one.length > max ? one.slice(0, Math.max(1, max - 1)) + '…' : one
143}
144
145export function padEnd(s: string, n: number): string {
146 return s.length >= n ? s.slice(0, n) : s + ' '.repeat(n - s.length)
147}
148
149export function padStart(s: string, n: number): string {
150 return s.length >= n ? s.slice(s.length - n) : ' '.repeat(n - s.length) + s
151}
152
153/** `claude-opus-5-5[1m]` → `Opus 5.5`. */
154export function modelName(id: string | undefined): string {
155 if (!id) return '—'
156 const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
157 if (!m) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '')
158 const fam = m[1]!
159 return `${fam.charAt(0).toUpperCase()}${fam.slice(1).toLowerCase()} ${m[2]}${m[3] ? '.' + m[3] : ''}`
160}
161
162/** Last path segment, either slash. */
163export function baseName(p: string): string {
164 const parts = p.split(/[\\/]/).filter(Boolean)
165 return parts[parts.length - 1] ?? p
166}
167
168/** A stable small hash for seeding. */
169export function hash(s: string): number {
170 let h = 2166136261
171 for (let i = 0; i < s.length; i++) {
172 h ^= s.charCodeAt(i)
173 h = Math.imul(h, 16777619)
174 }
175 return h >>> 0
176}
177
178/** Deterministic 0..1 noise from two ints. */
179export function noise(x: number, y: number): number {
180 const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
181 return s - Math.floor(s)
182}
183
184/** A seeded PRNG (mulberry32). */
185export function rng(seed: number): () => number {
186 let a = seed >>> 0
187 return () => {
188 a = (a + 0x6d2b79f5) >>> 0
189 let t = a
190 t = Math.imul(t ^ (t >>> 15), t | 1)
191 t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
192 return ((t ^ (t >>> 14)) >>> 0) / 4294967296
193 }
194}
195
196// ---------------------------------------------------------------------------
197// Text gauges for the terminal.
198
199const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
200
201/** A smooth bar with eighth blocks: `█████▍ `. */
202export function bar(ratio: number, width: number, empty = '░'): string {
203 const w = Math.max(1, Math.floor(width))
204 const exact = clamp01(ratio) * w
205 const full = Math.floor(exact)
206 const part = EIGHTHS[Math.floor((exact - full) * 8)] ?? ''
207 const used = full + (part ? 1 : 0)
208 return '█'.repeat(full) + part + empty.repeat(Math.max(0, w - used))
209}
210
211/** A segmented gauge: `▰▰▰▱▱▱`. */
212export function pips(ratio: number, width: number, on = '▰', off = '▱'): string {
213 const w = Math.max(1, Math.floor(width))
214 const n = Math.round(clamp01(ratio) * w)
215 return on.repeat(n) + off.repeat(w - n)
216}
217
218const SPARK = '▁▂▃▄▅▆▇█'
219
220/** A sparkline of the last `width` values, scaled to their own max (or `max`). */
221export function sparkline(values: readonly number[], width: number, max?: number): string {
222 const tail = values.slice(-Math.max(1, width))
223 const top = max ?? Math.max(1e-9, ...tail)
224 const line = tail.map(v => SPARK[Math.min(7, Math.max(0, Math.round((v / top) * 7)))] ?? '▁').join('')
225 return line.padStart(width, ' ')
226}
227
228/** Braille line graph, 2 samples per cell, `rows` cells tall. */
229export function brailleGraph(values: readonly number[], width: number, rows: number, max?: number): string[] {
230 const samples = values.slice(-(width * 2))
231 const top = max ?? Math.max(1e-9, ...samples)
232 const dotsTall = rows * 4
233 const grid: number[][] = Array.from({ length: rows }, () => Array.from({ length: width }, () => 0))
234 const offset = width * 2 - samples.length
235 // Dot bits per column, from the bottom row of a cell up.
236 const LEFT = [0x40, 0x04, 0x02, 0x01]
237 const RIGHT = [0x80, 0x20, 0x10, 0x08]
238 samples.forEach((v, i) => {
239 const x = offset + i
240 const col = Math.floor(x / 2)
241 const isRight = x % 2 === 1
242 const h = Math.round(clamp01(v / top) * (dotsTall - 1))
243 for (let d = 0; d <= h; d++) {
244 const row = rows - 1 - Math.floor(d / 4)
245 const bits = isRight ? RIGHT : LEFT
246 const cellRow = grid[row]
247 if (cellRow && col >= 0) cellRow[col] = (cellRow[col] ?? 0) | bits[d % 4]!
248 }
249 })
250 return grid.map(r => r.map(b => String.fromCharCode(0x2800 + b)).join(''))
251}
252
253// ---------------------------------------------------------------------------
254// Raster: a grid of colored cells, packed as the engine wants it.
255
256const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
257
258export function toBase64(bytes: Uint8Array): string {
259 let out = ''
260 let i = 0
261 for (; i + 2 < bytes.length; i += 3) {
262 const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8) | bytes[i + 2]!
263 out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + B64[n & 63]!
264 }
265 const rest = bytes.length - i
266 if (rest === 1) {
267 const n = bytes[i]! << 16
268 out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + '=='
269 } else if (rest === 2) {
270 const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8)
271 out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + '='
272 }
273 return out
274}
275
276/** The terminal's own default color, for a cell's foreground or background. */
277export const DEFAULT_COLOR = 0x01000000
278
279export class Canvas {
280 readonly cols: number
281 readonly rows: number
282 private readonly words: Uint32Array
283
284 constructor(cols: number, rows: number) {
285 this.cols = Math.max(1, Math.min(512, Math.floor(cols)))
286 this.rows = Math.max(1, Math.min(256, Math.floor(rows)))
287 this.words = new Uint32Array(this.cols * this.rows * 3)
288 this.clear()
289 }
290
291 clear(bg: number = DEFAULT_COLOR): void {
292 for (let i = 0; i < this.cols * this.rows; i++) {
293 this.words[i * 3] = 0x20
294 this.words[i * 3 + 1] = DEFAULT_COLOR
295 this.words[i * 3 + 2] = bg
296 }
297 }
298
299 /** Puts one width-1 character; colors are '#rrggbb' or a packed int. */
300 set(x: number, y: number, ch: string, fg?: string | number, bg?: string | number): void {
301 const cx = Math.floor(x)
302 const cy = Math.floor(y)
303 if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
304 const i = (cy * this.cols + cx) * 3
305 const code = ch.codePointAt(0) ?? 0x20
306 this.words[i] = code > 0xffff || code < 0x20 ? 0x20 : code
307 if (fg !== undefined) this.words[i + 1] = typeof fg === 'number' ? fg : hexToInt(fg)
308 if (bg !== undefined) this.words[i + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
309 }
310
311 /** Writes a string left to right, clipped to the canvas. */
312 text(x: number, y: number, s: string, fg?: string | number, bg?: string | number): void {
313 let cx = x
314 for (const ch of s) {
315 this.set(cx, y, ch, fg, bg)
316 cx++
317 }
318 }
319
320 /** Paints a cell's background only, keeping its character. */
321 paint(x: number, y: number, bg: string | number): void {
322 const cx = Math.floor(x)
323 const cy = Math.floor(y)
324 if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
325 this.words[(cy * this.cols + cx) * 3 + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
326 }
327
328 /** Two vertical pixels per cell with the upper-half block: `py` is in half-cells. */
329 pixel(x: number, py: number, color: string): void {
330 const cx = Math.floor(x)
331 const cy = Math.floor(py / 2)
332 if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
333 const i = (cy * this.cols + cx) * 3
334 const c = hexToInt(color)
335 const isTop = Math.floor(py) % 2 === 0
336 if (this.words[i] !== 0x2580) {
337 this.words[i] = 0x2580
338 this.words[i + 1] = DEFAULT_COLOR
339 this.words[i + 2] = DEFAULT_COLOR
340 }
341 if (isTop) this.words[i + 1] = c
342 else this.words[i + 2] = c
343 }
344
345 /** The `cells` prop of a Raster. */
346 encode(): string {
347 return toBase64(new Uint8Array(this.words.buffer))
348 }
349}
350
351// ---------------------------------------------------------------------------
352// SVG for the desktop: one drawing per row (the desktop wraps siblings).
353
354export const FONT = "-apple-system,BlinkMacSystemFont,'Segoe UI',Inter,sans-serif"
355export const MONO = "ui-monospace,'Cascadia Code','SF Mono',Consolas,monospace"
356
357const XML_ESC: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
358
359export function xml(s: string): string {
360 return s.replace(/[&<>"']/g, c => XML_ESC[c]!)
361}
362
363/** Theme-aware classes every KOZMOS drawing shares: t text, s secondary, m muted, k track, p panel. */
364export const SVG_BASE_CSS = `
365.t{fill:#1f1f1f}.s{fill:#5f5f5c}.m{fill:#8e8e8a}.k{fill:#e7e5e0}.p{fill:#f5f4f1}.ln{stroke:#e1dfda}
366@media (prefers-color-scheme: dark){.t{fill:#ededed}.s{fill:#b4b4b0}.m{fill:#7c7c78}.k{fill:#2d2d2b}.p{fill:#232322}.ln{stroke:#363634}}
367.pulse{animation:kzp 1.6s ease-in-out infinite}@keyframes kzp{50%{opacity:.35}}
368.spin{transform-box:fill-box;transform-origin:center;animation:kzs 2s linear infinite}@keyframes kzs{to{transform:rotate(360deg)}}
369@media (prefers-reduced-motion: reduce){*{animation:none!important}}
370`
371
372export function svg(width: number, height: number, body: string, css = ''): string {
373 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><style>${SVG_BASE_CSS}${css}</style>${body}</svg>`
374}
375
376/** Rough width of UI text in px, for fitting labels. */
377export function textWidth(s: string, size: number): number {
378 let w = 0
379 for (const ch of s) w += (/[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-Z@%MWmw]/.test(ch) ? 0.72 : 0.56) * size
380 return w
381}
382
383export function fitText(s: string, size: number, maxW: number): string {
384 if (textWidth(s, size) <= maxW) return s
385 let out = ''
386 for (const ch of s) {
387 if (textWidth(out + ch + '…', size) > maxW) break
388 out += ch
389 }
390 return out + '…'
391}
392
393/** A rounded progress bar as SVG markup. */
394export function svgBar(x: number, y: number, w: number, h: number, ratio: number, color: string): string {
395 const fw = Math.max(0, Math.min(w, w * clamp01(ratio)))
396 return `<rect class="k" x="${x}" y="${y}" width="${w}" height="${h}" rx="${h / 2}"/>` +
397 (fw > 0 ? `<rect x="${x}" y="${y}" width="${Math.max(h, fw)}" height="${h}" rx="${h / 2}" fill="${color}"/>` : '')
398}
399
400/** SVG text helper. */
401export function svgText(x: number, y: number, s: string, opts: { cls?: string; size?: number; weight?: number; anchor?: 'start' | 'middle' | 'end'; fill?: string; mono?: boolean } = {}): string {
402 const { cls = 't', size = 12, weight = 400, anchor = 'start', fill, mono = false } = opts
403 return `<text ${fill ? `fill="${fill}"` : `class="${cls}"`} x="${x}" y="${y}" font-family="${mono ? MONO : FONT}" font-size="${size}" font-weight="${weight}" text-anchor="${anchor}" font-variant-numeric="tabular-nums">${xml(s)}</text>`
404}
405
406/** Pixel width a desktop pane or band gives a drawing for `columns` reported columns. */
407export function pxOf(columns: number | undefined, fallback = 60, slack = 8): number {
408 return Math.max(200, Math.min(1600, (columns || fallback) * 8 - slack))
409}
410
411// ---------------------------------------------------------------------------
412// Prices, USD per million tokens: input, output, cache read, cache write (5 min).
413// Anthropic first-party rates as of 2026-10. Used only where the engine reports
414// tokens and not money (a subagent's own spend); the session total comes from
415// $.session.usage().cost, which is the engine's.
416
417const PRICES: [RegExp, [number, number, number, number]][] = [
418 [/fable|mythos/i, [10, 50, 0.25, 12.5]],
419 [/opus-5-5/i, [4, 20, 0.2, 5]],
420 [/opus/i, [5, 25, 0.5, 6.25]],
421 [/sonnet-5/i, [2, 10, 0.2, 2.5]],
422 [/sonnet/i, [3, 15, 0.3, 3.75]],
423 [/haiku-5/i, [0.1, 0.5, 0.01, 0.125]],
424 [/haiku/i, [1, 5, 0.1, 1.25]],
425]
426
427export type Usage = {
428 input_tokens?: number
429 output_tokens?: number
430 cache_read_input_tokens?: number
431 cache_creation_input_tokens?: number
432}
433
434export function priceOf(model: string): [number, number, number, number] {
435 return PRICES.find(([re]) => re.test(model))?.[1] ?? [4, 20, 0.2, 5]
436}
437
438export function costOf(model: string, u: Usage | null | undefined): number {
439 if (!u) return 0
440 const [i, o, r, w] = priceOf(model)
441 return ((u.input_tokens ?? 0) * i + (u.output_tokens ?? 0) * o + (u.cache_read_input_tokens ?? 0) * r + (u.cache_creation_input_tokens ?? 0) * w) / 1e6
442}
443
444export function tokensOf(u: Usage | null | undefined): number {
445 if (!u) return 0
446 return (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
447}
448
449/** The context a request filled: everything it read plus what it wrote. */
450export function contextOf(u: Usage | null | undefined): number {
451 return tokensOf(u)
452}
453
454export function windowOf(model: string): number {
455 return /haiku-4/i.test(model) ? 200_000 : 1_000_000
456}
457
458// ---------------------------------------------------------------------------
459// Rate limits.
460
461export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
462
463export function limitLabel(kind: string): string {
464 if (kind === 'five_hour') return '5h'
465 if (kind === 'seven_day') return '7d'
466 if (kind === 'spend_limit') return '$'
467 return kind.replace(/_/g, ' ')
468}
469
470/** Milliseconds until a window resets, or undefined. */
471export function untilReset(l: Limit, now: number): number | undefined {
472 if (!l.resetsAt) return undefined
473 const t = Date.parse(l.resetsAt)
474 return Number.isFinite(t) ? Math.max(0, t - now) : undefined
475}
476
477/** The length of a window in ms, for burn-rate math. */
478export function windowMs(kind: string): number | undefined {
479 if (kind === 'five_hour') return 5 * 3600_000
480 if (kind === 'seven_day') return 7 * 24 * 3600_000
481 return undefined
482}
483
484// ---------------------------------------------------------------------------
485// Tool calls, described in a few words.
486
487/** What a tool call is about: the command, the file, the pattern, the agent's task. */
488export function toolDetail(tool: string, input: unknown): string {
489 const e = (input ?? {}) as Record<string, unknown>
490 const s = (k: string) => (typeof e[k] === 'string' ? (e[k] as string) : '')
491 const t = String(tool)
492 if (t === 'Bash' || t === 'PowerShell') return clip(s('description') || s('command'), 60)
493 if (s('file_path')) return baseName(s('file_path'))
494 if (s('notebook_path')) return baseName(s('notebook_path'))
495 if (t === 'Agent') return clip(s('description') || s('subagent_type'), 60)
496 if (s('pattern')) return clip(s('pattern'), 60)
497 if (s('url')) return clip(s('url').replace(/^https?:\/\//, ''), 60)
498 if (s('query')) return clip(s('query'), 60)
499 if (s('subject')) return clip(s('subject'), 60)
500 if (Array.isArray(e.todos)) return `${(e.todos as unknown[]).length} todos`
501 if (t.startsWith('mcp__')) return t.split('__').slice(1).join(' · ')
502 return ''
503}
504
505/** `mcp__server__tool` → `server·tool`; built-ins unchanged. */
506export function toolName(tool: string): string {
507 const t = String(tool)
508 return t.startsWith('mcp__') ? t.split('__').slice(1).join('·') : t
509}
510hooks/lib/probe.ts 203 lines1// GENERATED by scripts/sync-shared.mjs from shared/probe.ts. Do not edit here.
2// KOZMOS shared probes: git and the machine, as argv lists and pure parsers.
3// Source of truth: shared/probe.ts; synced into each mod as hooks/lib/probe.ts.
4//
5// The engine only lets `$` flow into functions declared in the hooks file
6// itself, never across an import. So this file never touches `$`: a mod runs
7// the argv with `$.process.run` (or reads the path with `$.fs.read`) in a
8// top-level function of its own and hands the text to a parser here:
9//
10// async function readGit($: EngineInterface, cwd: string): Promise<GitSnap> {
11// const st = await $.process.run(GIT_STATUS, { cwd, timeoutMs: 5000 }).catch(() => undefined)
12// if (!st || st.exitCode !== 0) return NO_GIT
13// const head = await $.process.run(GIT_HEAD, { cwd, timeoutMs: 3000 }).catch(() => undefined)
14// return parseGitStatus(st.stdout, head?.exitCode === 0 ? head.stdout : '')
15// }
16
17export type Platform = 'win' | 'mac' | 'linux'
18
19/** From `$.env.get('OS')` and, off Windows, `uname -s`. */
20export function platformFrom(osEnv: string | undefined, uname: string | undefined): Platform {
21 if (/windows/i.test(osEnv ?? '')) return 'win'
22 return /darwin/i.test(uname ?? '') ? 'mac' : 'linux'
23}
24
25// ---------------------------------------------------------------------------
26// Git.
27
28export const GIT_STATUS = ['git', 'status', '--porcelain=v2', '--branch', '--show-stash'] as const
29export const GIT_HEAD = ['git', 'log', '-1', '--format=%h%x09%s%x09%cr'] as const
30export const gitLogArgv = (n: number): string[] => ['git', 'log', `-${n}`, '--format=%h%x09%s%x09%cr%x09%an%x09%ct']
31export const GIT_NUMSTAT = ['git', 'diff', '--numstat', 'HEAD'] as const
32
33export type GitSnap = {
34 isRepo: boolean
35 branch: string
36 isDetached: boolean
37 upstream?: string
38 ahead: number
39 behind: number
40 staged: number
41 unstaged: number
42 untracked: number
43 conflicts: number
44 head?: { sha: string; subject: string; when: string }
45 stash: number
46}
47
48export const NO_GIT: GitSnap = { isRepo: false, branch: '', isDetached: false, ahead: 0, behind: 0, staged: 0, unstaged: 0, untracked: 0, conflicts: 0, stash: 0 }
49
50/** Parses `GIT_STATUS` output, and `GIT_HEAD` output when given. */
51export function parseGitStatus(status: string, headLine = ''): GitSnap {
52 const snap: GitSnap = { ...NO_GIT, isRepo: true }
53 for (const line of status.split('\n')) {
54 if (line.startsWith('# branch.head ')) {
55 const b = line.slice(14).trim()
56 snap.isDetached = b === '(detached)'
57 snap.branch = b
58 } else if (line.startsWith('# branch.upstream ')) snap.upstream = line.slice(18).trim()
59 else if (line.startsWith('# branch.ab ')) {
60 const m = /\+(\d+) -(\d+)/.exec(line)
61 snap.ahead = Number(m?.[1] ?? 0)
62 snap.behind = Number(m?.[2] ?? 0)
63 } else if (line.startsWith('# stash ')) snap.stash = Number(line.slice(8)) || 0
64 else if (line.startsWith('1 ') || line.startsWith('2 ')) {
65 const xy = line.slice(2, 4)
66 if (xy[0] !== '.') snap.staged++
67 if (xy[1] !== '.') snap.unstaged++
68 } else if (line.startsWith('u ')) snap.conflicts++
69 else if (line.startsWith('? ')) snap.untracked++
70 }
71 const h = headLine.trim()
72 if (h) {
73 const [sha, subject = '', when = ''] = h.split('\t') as [string, ...string[]]
74 snap.head = { sha, subject, when }
75 }
76 if (snap.isDetached && snap.head) snap.branch = snap.head.sha
77 return snap
78}
79
80export type GitCommit = { sha: string; subject: string; when: string; author: string; at: number }
81
82/** Parses `gitLogArgv(n)` output. `at` is the commit time in ms. */
83export function parseGitLog(out: string): GitCommit[] {
84 return out
85 .split('\n')
86 .filter(Boolean)
87 .map(l => {
88 const [sha, subject = '', when = '', author = '', ct = '0'] = l.split('\t') as [string, ...string[]]
89 return { sha, subject, when, author, at: Number(ct) * 1000 }
90 })
91}
92
93export type GitFileStat = { path: string; added: number; removed: number }
94
95/** Parses `GIT_NUMSTAT` output (binary files count as 0/0). */
96export function parseNumstat(out: string): GitFileStat[] {
97 return out
98 .split('\n')
99 .filter(Boolean)
100 .map(l => {
101 const [a, d = '0', ...rest] = l.split('\t') as [string, ...string[]]
102 return { path: rest.join('\t'), added: Number(a) || 0, removed: Number(d) || 0 }
103 })
104}
105
106// ---------------------------------------------------------------------------
107// The machine.
108
109export type SysSnap = {
110 cpu?: number // percent, all cores
111 memUsed?: number // bytes
112 memTotal?: number // bytes
113 diskUsed?: number
114 diskTotal?: number
115 procs?: number
116 gpu?: GpuSnap
117}
118
119export type GpuSnap = { util: number; memUsed: number; memTotal: number; temp: number; name: string }
120
121/** Windows: one PowerShell call answering CPU, memory, C: and process count as JSON. */
122export const WIN_SYS = [
123 'powershell', '-NoProfile', '-NonInteractive', '-Command',
124 '$o=Get-CimInstance Win32_OperatingSystem;' +
125 '$c=(Get-CimInstance Win32_Processor | Measure-Object -Property LoadPercentage -Average).Average;' +
126 "$d=Get-CimInstance Win32_LogicalDisk -Filter \"DeviceID='C:'\";" +
127 '$p=(Get-Process).Count;' +
128 '[pscustomobject]@{cpu=$c;free=$o.FreePhysicalMemory;total=$o.TotalVisibleMemorySize;dfree=$d.FreeSpace;dsize=$d.Size;procs=$p} | ConvertTo-Json -Compress',
129] as const
130
131export function parseWinSys(out: string): SysSnap {
132 const snap: SysSnap = {}
133 try {
134 const j = JSON.parse(out || '{}') as Record<string, number | null>
135 if (typeof j.cpu === 'number') snap.cpu = j.cpu
136 if (typeof j.total === 'number' && typeof j.free === 'number') {
137 snap.memTotal = j.total * 1024
138 snap.memUsed = (j.total - j.free) * 1024
139 }
140 if (typeof j.dsize === 'number' && typeof j.dfree === 'number') {
141 snap.diskTotal = j.dsize
142 snap.diskUsed = j.dsize - j.dfree
143 }
144 if (typeof j.procs === 'number') snap.procs = j.procs
145 } catch {
146 // Leave the fields out.
147 }
148 return snap
149}
150
151export type CpuTicks = { idle: number; total: number }
152
153/** Linux: `/proc/stat`'s first line, against the previous reading. */
154export function parseProcStat(stat: string, prev: CpuTicks | undefined): { cpu?: number; ticks: CpuTicks } {
155 const nums = stat.split('\n')[0]!.trim().split(/\s+/).slice(1).map(Number)
156 const idle = (nums[3] ?? 0) + (nums[4] ?? 0)
157 const total = nums.reduce((a, b) => a + (Number.isFinite(b) ? b : 0), 0)
158 const ticks = { idle, total }
159 if (!prev || total <= prev.total) return { ticks }
160 return { cpu: 100 * (1 - (idle - prev.idle) / (total - prev.total)), ticks }
161}
162
163/** Linux: `/proc/meminfo`. */
164export function parseMeminfo(mem: string): Pick<SysSnap, 'memUsed' | 'memTotal'> {
165 const kb = (k: string) => Number(new RegExp(`${k}:\\s+(\\d+)`).exec(mem)?.[1] ?? 0) * 1024
166 const memTotal = kb('MemTotal')
167 return memTotal ? { memTotal, memUsed: memTotal - kb('MemAvailable') } : {}
168}
169
170/** macOS: `top -l 1 -n 0` and `sysctl -n hw.memsize`. */
171export const MAC_TOP = ['top', '-l', '1', '-n', '0'] as const
172export const MAC_MEMSIZE = ['sysctl', '-n', 'hw.memsize'] as const
173
174export function parseMacTop(top: string, memsize: string): SysSnap {
175 const snap: SysSnap = {}
176 const m = /CPU usage: ([\d.]+)% user, ([\d.]+)% sys/.exec(top)
177 if (m) snap.cpu = Number(m[1]) + Number(m[2])
178 const pm = /PhysMem: ([\d.]+)([GM]) used/.exec(top)
179 if (pm) snap.memUsed = Number(pm[1]) * (pm[2] === 'G' ? 1024 ** 3 : 1024 ** 2)
180 const total = Number(memsize.trim())
181 if (total) snap.memTotal = total
182 return snap
183}
184
185/** NVIDIA GPUs, any platform. */
186export const NVIDIA_SMI = ['nvidia-smi', '--query-gpu=utilization.gpu,memory.used,memory.total,temperature.gpu,name', '--format=csv,noheader,nounits'] as const
187
188export function parseNvidia(out: string): GpuSnap | undefined {
189 const line = out.split('\n')[0]!
190 if (!line.trim()) return undefined
191 const [util, used = '0', total = '0', temp = '0', name = 'GPU'] = line.split(',').map(s => s.trim()) as [string, ...string[]]
192 return { util: Number(util), memUsed: Number(used) * 1024 ** 2, memTotal: Number(total) * 1024 ** 2, temp: Number(temp), name }
193}
194
195export function fmtBytes(n: number | undefined): string {
196 if (n === undefined || !Number.isFinite(n)) return '—'
197 // Each unit starts where the one below would round up to 1024 of itself.
198 if (n >= 1023.95 * 1024 ** 3) return `${(n / 1024 ** 4).toFixed(1)}T`
199 if (n >= 1023.5 * 1024 ** 2) return `${(n / 1024 ** 3).toFixed(1)}G`
200 if (n >= 1023.5 * 1024) return `${Math.round(n / 1024 ** 2)}M`
201 return `${Math.round(n / 1024)}K`
202}
203hooks/meter.ts 93 lines1// vitals' own pure helpers: unix probes the shared kit lacks, history, and
2// the chart geometry. No `$` here.
3
4import { Canvas, brailleGraph, heat } from './lib/kz.ts'
5
6/** How many samples a history keeps (2 s apart: four minutes). */
7export const HISTORY = 120
8
9export function pushHist(hist: readonly number[], v: number | undefined): number[] {
10 return v === undefined || !Number.isFinite(v) ? [...hist] : [...hist, v].slice(-HISTORY)
11}
12
13/** Unix: `df -kP /`, the second line. */
14export const DF_ROOT = ['df', '-kP', '/'] as const
15
16export function parseDf(out: string): { diskUsed?: number; diskTotal?: number } {
17 const line = out.split('\n').filter(Boolean)[1]
18 if (!line) return {}
19 const f = line.trim().split(/\s+/)
20 const total = Number(f[1])
21 const used = Number(f[2])
22 return Number.isFinite(total) && total > 0 && Number.isFinite(used) ? { diskTotal: total * 1024, diskUsed: used * 1024 } : {}
23}
24
25/** Unix: one pid per line. */
26export const PS_PIDS = ['ps', '-A', '-o', 'pid='] as const
27
28export function countLines(out: string): number | undefined {
29 const n = out.split('\n').filter(l => l.trim()).length
30 return n > 0 ? n : undefined
31}
32
33export function avg(xs: readonly number[]): number {
34 return xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0
35}
36
37/** Used over total, 0 when the total is unknown; an unknown use counts as none. */
38export function ratioOf(used: number | undefined, total: number | undefined): number {
39 return total ? (used ?? 0) / total : 0
40}
41
42/** Temperature tone: cool under 50 °C, hot from 85 °C. */
43export function tempRatio(c: number): number {
44 return Math.max(0, Math.min(1, (c - 40) / 45))
45}
46
47/**
48 * A braille graph as Raster cells, colored like btop: each row by the height
49 * it stands for (green low, red high), on the terminal's own background.
50 */
51export function brailleCanvas(values: readonly number[], cols: number, rows: number, max = 100, tint?: (level: number) => string): Canvas {
52 const c = new Canvas(cols, rows)
53 const lines = brailleGraph(values, cols, rows, max)
54 const tone = tint ?? heat
55 lines.forEach((line, r) => {
56 const level = (rows - r - 0.5) / rows
57 const color = tone(level)
58 let x = 0
59 for (const ch of line) {
60 c.set(x, r, ch, color)
61 x++
62 }
63 })
64 return c
65}
66
67/**
68 * The points of an area chart over a fixed history: the newest sample sits
69 * at the right edge and older ones walk left, as a strip chart scrolls.
70 */
71export function chartPoints(values: readonly number[], x: number, y: number, w: number, h: number, max: number): [number, number][] {
72 const step = w / (HISTORY - 1)
73 const top = Math.max(1e-9, max)
74 const n = values.length
75 return values.map((v, i) => [x + w - (n - 1 - i) * step, y + h - Math.max(0, Math.min(1, v / top)) * h])
76}
77
78/** Line and area paths through points, with gentle cubic smoothing. */
79export function smoothPaths(pts: readonly [number, number][], baseY: number): { line: string; area: string } {
80 if (pts.length === 0) return { line: '', area: '' }
81 const f = (n: number) => n.toFixed(1)
82 let d = `M${f(pts[0]![0])},${f(pts[0]![1])}`
83 for (let i = 1; i < pts.length; i++) {
84 const [x0, y0] = pts[i - 1]!
85 const [x1, y1] = pts[i]!
86 const mx = (x0 + x1) / 2
87 d += `C${f(mx)},${f(y0)} ${f(mx)},${f(y1)} ${f(x1)},${f(y1)}`
88 }
89 const first = pts[0]!
90 const last = pts[pts.length - 1]!
91 return { line: d, area: `${d}L${f(last[0])},${f(baseY)}L${f(first[0])},${f(baseY)}Z` }
92}
93types/index.d.ts 30 lines1export type VitalsGpu = { name: string; util: number; memUsed: number; memTotal: number; temp: number }
2
3export type VitalsSnap = {
4 platform: 'win' | 'mac' | 'linux'
5 cpu?: number
6 cpuHist: number[]
7 memUsed?: number
8 memTotal?: number
9 /** RAM use in percent, one per sample. */
10 memHist: number[]
11 diskLabel: string
12 diskUsed?: number
13 diskTotal?: number
14 procs?: number
15 gpu?: VitalsGpu
16 gpuHist: number[]
17 samples: number
18 sessionStart: number
19 toolCalls: number
20 agentsRunning: number
21 agentsDone: number
22 now: number
23}
24
25declare module 'claude-code' {
26 interface PluginState {
27 vitals: { snap: VitalsSnap | null }
28 }
29}
30