The box this session runs on: one band above the prompt with cpu, load, memory, swap traffic, disk, worktree trees, claude processes, ssh peers, tailscale; a…

Claude Code mods: hooks modules that change the interface itself, one folder per mod, this repo the marketplace that lists them.
| mod | what it is |
|---|---|
host | /host toggles one band above the prompt with the box's health; /host pane the expanded view. |
At the prompt of a terminal session:
/plugin install host --marketplace ramonfabrega/mods
Answer y to add the marketplace, then pick a scope. From a shell:
claude plugin install host --marketplace ramonfabrega/mods
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with ramonfabrega/mods, then Add plugin.
The checkout itself is a marketplace, so the installed copy IS the working copy: add it as a directory marketplace and edits land with /reload-plugins, no version bump, no reinstall.
claude plugin marketplace add ~/code/fun/mods
claude plugin install host@mods
Each mod type-checks and tests on its own:
claude plugin validate host
claude plugin test host
bunx --bun tsc -p host # after the engine has laid host/.claude-plugin/types/
The mods are written with Claude Code's plugin-authoring skill.
One band, not a pane: hostname, uptime, cpu%, load (dim), mem used/total, swap fill + swap TRAFFIC, disk free, worktree trees size·count (every .claude/worktrees under ~/code, plus ~/.codex/worktrees), claude-shaped processes, ssh peers, tailscale state. Segments drop by priority when the band is narrower than the terminal.
Behind the band, a pane: the same numbers as meters, the memory and swap breakdown, every worktree dir by size, the ssh peers and tailscale peers by name, and the hungriest processes by cpu and by rss. Same sampler, two drawings. The band's last glyph (⤢) opens it: click it in the fullscreen layout, or ctrl+x tab then h from the prompt. /host pane does the same typed, /host off closes both, and the pane closes on Esc or its close mark. The surface places it: docked beside the transcript in fullscreen from 110 columns, inline above the prompt otherwise. Both toggles are remembered across sessions.
Why those numbers: on macOS the load average counts threads and misleads, "memory used" is mostly cache, and swap USED is a high-water mark only a reboot clears. So the band colors cpu% and swap traffic, which say what the box is doing now, and dims load.
macOS only (sysctl, top -l 1, vm_stat, df, who, the Tailscale app binary).
hooks/register.tsx 471 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren, Timer } from 'claude-code'
3
4import type { HostSnap, Proc, TreeSnap } from '../types'
5import {
6 bar,
7 g1,
8 gb,
9 parseDf,
10 parseDu,
11 parseProcs,
12 parsePs,
13 parseSysctl,
14 parseTailscale,
15 parseTop,
16 parseTreeCounts,
17 parseUptime,
18 parseVmStatSwap,
19 parseWho,
20 rate,
21} from './parse'
22
23const PAGE = 16_384
24
25const FAST_MS = 5_000
26const SLOW_MS = 300_000
27const STORE_ON = 'host.on'
28const STORE_PANE = 'host.pane'
29const PANE = 'host'
30const TOP_N = 12
31// The dock width the pane asks for: a request the person's own drag overrides.
32const PANE_COLS = 40
33const PANE_ROWS = 44
34// Trees rows the pane shows before folding the rest into one line.
35const TREES_SHOWN = 5
36const TAILSCALE = '/Applications/Tailscale.app/Contents/MacOS/Tailscale'
37
38const isOn = atom({ plugin: 'host', key: 'isOn' } as const, false)
39const isPane = atom({ plugin: 'host', key: 'isPane' } as const, false)
40const snap = atom({ plugin: 'host', key: 'snap' } as const, null)
41const trees = atom({ plugin: 'host', key: 'trees' } as const, null)
42
43// Module-owned: a reload starts them over, and session.start restarts them.
44let timers: Timer[] = []
45// The previous sample's swap counters, for the rate; null until one is taken.
46let prevSwap: { at: number; pages: number } | null = null
47
48async function run($: EngineInterface, argv: readonly string[], timeoutMs = 10_000): Promise<string | null> {
49 try {
50 const { stdout } = await $.process.run(argv, { timeoutMs })
51 return stdout
52 } catch {
53 return null
54 }
55}
56
57async function sampleFast($: EngineInterface): Promise<void> {
58 const [sys, top, vm, df, up, who, ps, host, ts] = await Promise.all([
59 run($, ['sysctl', '-n', 'vm.loadavg', 'hw.ncpu', 'hw.memsize', 'vm.swapusage']),
60 run($, ['top', '-l', '1', '-n', '0', '-s', '0']),
61 run($, ['vm_stat']),
62 run($, ['df', '-k', '/System/Volumes/Data']),
63 run($, ['uptime']),
64 run($, ['who']),
65 run($, ['ps', '-axo', 'rss=,pcpu=,comm=']),
66 run($, ['hostname', '-s']),
67 run($, [TAILSCALE, 'status', '--json']),
68 ])
69 const at = await $.clock.now()
70 const s = parseSysctl(sys ?? '')
71 const t = parseTop(top ?? '')
72
73 // Swap traffic: pages in + out since the previous sample, as bytes per second.
74 const counters = parseVmStatSwap(vm ?? '')
75 let ioBytesPerS: number | null = null
76 if (counters) {
77 const pages = counters.ins + counters.outs
78 if (prevSwap && at > prevSwap.at) {
79 ioBytesPerS = ((pages - prevSwap.pages) * PAGE) / ((at - prevSwap.at) / 1000)
80 }
81 prevSwap = { at, pages }
82 }
83
84 const next: HostSnap = {
85 at,
86 host: (host ?? '').trim() || 'host',
87 up: parseUptime(up ?? ''),
88 load: s.load,
89 cpus: s.cpus,
90 cpuBusy: t.cpuBusy,
91 mem: { totalG: s.memsizeG, usedG: t.usedG, compressedG: t.compressedG, unusedG: t.unusedG },
92 swap: s.swap ? { ...s.swap, ioBytesPerS } : null,
93 disk: parseDf(df ?? ''),
94 claude: parsePs(ps ?? ''),
95 top: parseProcs(ps ?? '', TOP_N),
96 ssh: parseWho(who ?? ''),
97 tailscale: ts ? parseTailscale(ts) : null,
98 }
99 await update($, snap, () => next)
100}
101
102async function sampleSlow($: EngineInterface): Promise<void> {
103 const home = await $.env.get('HOME')
104 if (!home) return
105 const found = await run($, [
106 'find', `${home}/code`, '-mindepth', '4', '-maxdepth', '4', '-type', 'd',
107 '-name', 'worktrees', '-path', '*/.claude/worktrees',
108 ])
109 const dirs = (found ?? '').trim().split('\n').filter(Boolean)
110 const codex = `${home}/.codex/worktrees`
111 if ((await run($, ['test', '-d', codex])) !== null) dirs.push(codex)
112 const at = await $.clock.now()
113 if (dirs.length === 0) {
114 await update($, trees, () => ({ at, totalK: 0, count: 0, dirs: [] }))
115 return
116 }
117 const [du, inner] = await Promise.all([
118 run($, ['du', '-sk', ...dirs], 120_000),
119 run($, ['find', ...dirs, '-mindepth', '1', '-maxdepth', '1', '-type', 'd']),
120 ])
121 const counts = parseTreeCounts(inner ?? '')
122 const count = Object.values(counts).reduce((n, c) => n + c, 0)
123 const sizes = parseDu(du ?? '')
124 const next: TreeSnap = { at, count, totalK: sizes.totalK, dirs: sizes.dirs.map(d => ({ ...d, count: counts[d.name] ?? 0 })) }
125 await update($, trees, () => next)
126}
127
128function stop(): void {
129 for (const t of timers) t.cancel()
130 timers = []
131}
132
133function start($: EngineInterface): void {
134 stop()
135 void sampleFast($)
136 void sampleSlow($)
137 timers = [
138 $.clock.every(FAST_MS, () => void sampleFast($)),
139 $.clock.every(SLOW_MS, () => void sampleSlow($)),
140 ]
141}
142
143// The sampler runs while anything shows it: the band, the pane, or both.
144async function sync($: EngineInterface): Promise<void> {
145 const wanted = (await read($, isOn)) || (await read($, isPane))
146 if (wanted && timers.length === 0) start($)
147 else if (!wanted) stop()
148}
149
150async function turnBand($: EngineInterface, enabled: boolean): Promise<void> {
151 await update($, isOn, () => enabled)
152 await $.store.set(STORE_ON, enabled)
153 await sync($)
154}
155
156async function turnPane($: EngineInterface, enabled: boolean): Promise<void> {
157 await update($, isPane, () => enabled)
158 await $.store.set(STORE_PANE, enabled)
159 await sync($)
160 if (enabled) await $.ui.open({ id: PANE, title: 'host', columns: PANE_COLS, rows: PANE_ROWS })
161 else await $.ui.close({ id: PANE })
162}
163
164const hot = (pct: number | null, warn: number, bad: number) =>
165 pct === null ? undefined : pct >= bad ? 'red' : pct >= warn ? 'yellow' : undefined
166
167/** The thresholds the band and the pane share: one color per figure. */
168function colors(s: HostSnap) {
169 const memPct = s.mem.usedG !== null && s.mem.totalG > 0 ? (s.mem.usedG / s.mem.totalG) * 100 : null
170 // Swap FILL is a high-water mark that only a reboot clears; TRAFFIC is the pressure now.
171 const swapIo = s.swap?.ioBytesPerS ?? null
172 const swapIoK = swapIo === null ? null : swapIo / 1024
173 return {
174 memPct,
175 swapIo,
176 cpu: hot(s.cpuBusy, 75, 90),
177 mem: hot(memPct, 90, 97),
178 // yellow past 1 MB/s of swap traffic, red past 10: the box is paging now
179 swap: hot(swapIoK, 1024, 10_240),
180 disk: hot(s.disk?.pct ?? null, 85, 93),
181 ts: s.tailscale && s.tailscale.state !== 'Running' ? 'yellow' : undefined,
182 }
183}
184
185type Seg = { key: string; label: string; value: string; color?: string; dim?: boolean; prio: number }
186
187export const register: Register = on => {
188 on('session.start', async ($, e, next) => {
189 await $.command.register({
190 name: 'host',
191 description: 'Toggle the host band: cpu, load, memory, swap, disk, worktrees, claude processes. `pane` toggles the expanded view, `off` closes both.',
192 argumentHint: '[pane|off]',
193 })
194 if ((await $.store.get(STORE_ON)) === true) await turnBand($, true)
195 if ((await $.store.get(STORE_PANE)) === true) await turnPane($, true)
196 return next(e)
197 })
198
199 on('command.run', { command: 'host' }, async ($, e) => {
200 const arg = (e.args ?? '').trim()
201 const bandOn = (await read($, isOn)) === true
202 const paneOn = (await read($, isPane)) === true
203 switch (arg) {
204 case '':
205 await turnBand($, !bandOn)
206 return { text: bandOn ? 'host band off.' : 'host band on.' }
207 case 'pane':
208 await turnPane($, !paneOn)
209 return { text: paneOn ? 'host pane closed.' : 'host pane open.' }
210 case 'off':
211 if (bandOn) await turnBand($, false)
212 if (paneOn) await turnPane($, false)
213 return { text: 'host off.' }
214 default:
215 return { text: '/host toggles the band, /host pane the expanded view, /host off closes both.' }
216 }
217 })
218
219 // The person's close mark or Esc: forget the pane so it stays closed next session.
220 on('ui.close', { id: PANE }, async ($, e, next) => {
221 if (e.origin.kind === 'person') {
222 try {
223 await update($, isPane, () => false)
224 await $.store.set(STORE_PANE, false)
225 await sync($)
226 } catch {
227 // The close goes through regardless; the pane reopens next session at worst.
228 }
229 }
230 return next(e)
231 })
232
233 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
234 const enabled = await read($, isOn)
235 if (!enabled || e.props.hasSurvey) return next(e)
236
237 const { Box, Button, Text } = $.ui.resolve(e)
238 const s = await read($, snap)
239 const t = await read($, trees)
240 const paneOn = (await read($, isPane)) === true
241 // ⤢ opens the pane, ⤡ closes it: a click, or ctrl+x tab then h.
242 const expand = (
243 <Button
244 key="pane"
245 plain
246 hotkey="h"
247 label={paneOn ? '⤡' : '⤢'}
248 onPress={() => void turnPane($, !paneOn)}
249 />
250 )
251 if (!s) {
252 return (
253 <Box>
254 <Text dimColor>host: sampling… </Text>
255 {expand}
256 </Box>
257 )
258 }
259
260 const c = colors(s)
261 const swapValue = s.swap
262 ? `${(s.swap.usedM / 1024).toFixed(1)}G${c.swapIo !== null && c.swapIo > 0 ? ` ${rate(c.swapIo)}` : ''}`
263 : 'none'
264
265 // Higher prio survives a narrow band. Display order is the array's.
266 const segs: Seg[] = [
267 { key: 'host', label: '', value: s.host, prio: 2 },
268 { key: 'up', label: 'up', value: s.up, prio: 1 },
269 { key: 'cpu', label: 'cpu', value: `${s.cpuBusy ?? '?'}%`, color: c.cpu, prio: 7 },
270 { key: 'load', label: 'load', value: s.load[0].toFixed(1), dim: true, prio: 4 },
271 { key: 'mem', label: 'mem', value: `${g1(s.mem.usedG)}/${g1(s.mem.totalG)}`, color: c.mem, prio: 8 },
272 { key: 'swap', label: 'swap', value: swapValue, color: c.swap, prio: 9 },
273 { key: 'disk', label: 'disk', value: s.disk ? `${gb(s.disk.availK)} free` : '?', color: c.disk, prio: 10 },
274 { key: 'trees', label: 'trees', value: t ? `${gb(t.totalK)}·${t.count}` : '…', prio: 6 },
275 { key: 'claude', label: 'claude', value: `${s.claude.procs}p ${gb(s.claude.rssK)}`, prio: 5 },
276 { key: 'ssh', label: 'ssh', value: String(s.ssh.length), prio: 0 },
277 {
278 key: 'ts',
279 label: 'ts',
280 value: s.tailscale ? (s.tailscale.state === 'Running' ? 'ok' : s.tailscale.state) : '—',
281 color: c.ts,
282 prio: 3,
283 },
284 ]
285
286 const SEP = ' '
287 // The button draws as `h: ⤢`: four cells, plus its separator.
288 const BUTTON = 4 + SEP.length
289 const width = (x: Seg) => (x.label ? x.label.length + 1 : 0) + x.value.length
290 const cols = (e.props.bodyColumns || 80) - BUTTON
291 let shown = [...segs]
292 const total = () => shown.reduce((n, x) => n + width(x), 0) + SEP.length * (shown.length - 1)
293 while (shown.length > 1 && total() > cols) {
294 const least = shown.reduce((a, b) => (b.prio < a.prio ? b : a))
295 shown = shown.filter(x => x !== least)
296 }
297
298 return (
299 <Box>
300 {shown.map((x, i) => (
301 <Text key={x.key}>
302 {i > 0 ? SEP : ''}
303 {x.label ? <Text dimColor>{x.label} </Text> : null}
304 <Text color={x.color} dimColor={x.dim === true}>
305 {x.value}
306 </Text>
307 </Text>
308 ))}
309 <Text>{SEP}</Text>
310 {expand}
311 </Box>
312 )
313 })
314
315 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
316 const { Box, Text } = $.ui.resolve(e)
317 const s = await read($, snap)
318 const t = await read($, trees)
319 if (!s) {
320 return (
321 <Box>
322 <Text dimColor>host: sampling…</Text>
323 </Box>
324 )
325 }
326
327 // A snapshot written by an older build of this module outlives a reload
328 // (state is the host's): fields this build added may be missing for one tick.
329 const top = s.top ?? { cpu: [], rss: [] }
330 const ts = s.tailscale
331 const peers = ts?.peers ?? []
332 const online = peers.filter(p => p.isOnline)
333
334 const c = colors(s)
335 const cols = Math.max(20, e.props.bodyColumns || 40)
336
337 // A narrow column: label, bar and figure on one row, the detail dimmed
338 // beneath it. Every row is budgeted against the width and truncated.
339 const LABEL = 5
340 const FIGURE = 6
341 const bw = cols - LABEL - FIGURE - 1
342 const cell = (w: number, text: RenderChildren, opts: { dim?: boolean; color?: string; right?: true } = {}) => (
343 <Box width={w} justifyContent={opts.right ? 'flex-end' : 'flex-start'}>
344 <Text dimColor={opts.dim === true} color={opts.color} wrap="truncate">
345 {text}
346 </Text>
347 </Box>
348 )
349 const meter = (name: string, pct: number | null, color: string | undefined, figure: string, detail: string) => (
350 <Box flexDirection="column" key={name}>
351 <Box>
352 {cell(LABEL, name, { dim: true })}
353 {cell(bw, bar(pct ?? 0, bw), { color })}
354 {cell(FIGURE + 1, figure, { color, right: true })}
355 </Box>
356 <Box>
357 <Box width={LABEL} />
358 {cell(cols - LABEL, detail, { dim: true })}
359 </Box>
360 </Box>
361 )
362 const line = (name: string, body: RenderChildren) => (
363 <Box key={name}>
364 {cell(8, name, { dim: true })}
365 {cell(cols - 8, body)}
366 </Box>
367 )
368
369 const swapPct = s.swap && s.swap.totalM > 0 ? (s.swap.usedM / s.swap.totalM) * 100 : null
370
371 // Trees: name, share bar, size, checkouts; the bar takes what the width leaves.
372 const allTrees = (t?.dirs ?? []).map(d => ({ ...d, count: d.count ?? 0 }))
373 const treeRows = allTrees.slice(0, TREES_SHOWN)
374 const treeRest = allTrees.slice(TREES_SHOWN)
375 const restK = treeRest.reduce((n, d) => n + d.k, 0)
376 const nameW = Math.min(12, Math.max(4, ...treeRows.map(d => d.name.length)))
377 const SIZE = 6
378 const COUNT = 3
379 const shareW = Math.max(3, Math.min(16, cols - nameW - SIZE - COUNT - 3))
380
381 // Processes: the rows that fit, by cpu, then by rss when there is room for both.
382 const treesBlock = 1 + 1 + treeRows.length + (treeRest.length ? 1 : 0)
383 const fixed = 3 + 1 + 8 + 1 + 3 + 1 + treesBlock
384 const room = Math.max(5, e.props.scroll.bodyRows - fixed)
385 const both = room >= 15
386 const n = Math.min(TOP_N, both ? Math.floor((room - 3) / 2) : room - 1)
387 const CPU = 5
388 const RSS = 6
389 const procs = (title: string, rows: Proc[]) => (
390 <Box flexDirection="column" key={title}>
391 <Box>
392 {cell(CPU, 'cpu%', { dim: true, right: true })}
393 {cell(RSS, 'rss', { dim: true, right: true })}
394 {cell(cols - CPU - RSS - 2, <Text> {title}</Text>, { dim: true })}
395 </Box>
396 {rows.map((p, i) => (
397 <Box key={String(i)}>
398 {cell(CPU, p.cpu.toFixed(1), { color: p.cpu >= 100 ? 'yellow' : undefined, right: true })}
399 {cell(RSS, g1(p.rssK / 1024 ** 2), { right: true })}
400 {cell(cols - CPU - RSS - 2, <Text> {p.name}</Text>)}
401 </Box>
402 ))}
403 </Box>
404 )
405
406 return (
407 <Box flexDirection="column">
408 {cell(cols, <Text bold>{s.host}</Text>)}
409 {cell(cols, <Text><Text dimColor>up </Text>{s.up}<Text dimColor> · {s.cpus} cpu</Text></Text>)}
410 {cell(cols, <Text><Text dimColor>load </Text>{s.load.map(x => x.toFixed(1)).join(' ')}</Text>)}
411 <Text> </Text>
412 {meter('cpu', s.cpuBusy, c.cpu, `${s.cpuBusy ?? '?'}%`, `claude ${s.claude.cpu}%`)}
413 {meter(
414 'mem',
415 c.memPct,
416 c.mem,
417 g1(s.mem.usedG),
418 `${g1(s.mem.totalG)} total · comp ${g1(s.mem.compressedG)} · free ${g1(s.mem.unusedG)}`,
419 )}
420 {meter(
421 'swap',
422 swapPct,
423 c.swap,
424 s.swap ? `${(s.swap.usedM / 1024).toFixed(1)}G` : 'none',
425 s.swap ? `${(s.swap.totalM / 1024).toFixed(0)}G total · io ${c.swapIo === null ? '?' : rate(c.swapIo)}` : '',
426 )}
427 {meter('disk', s.disk?.pct ?? null, c.disk, s.disk ? `${s.disk.pct}%` : '?', s.disk ? `${gb(s.disk.availK)} free` : '')}
428 <Text> </Text>
429 {line('claude', `${s.claude.procs}p · ${gb(s.claude.rssK)} · ${s.claude.cpu}%`)}
430 {line('ssh', s.ssh.length ? String(s.ssh.length) : <Text dimColor>none</Text>)}
431 {line(
432 'ts',
433 ts ? (
434 <Text>
435 <Text color={c.ts}>{ts.state === 'Running' ? 'ok' : ts.state}</Text>
436 <Text dimColor> · </Text>
437 {online.length}/{peers.length}
438 <Text dimColor> online</Text>
439 </Text>
440 ) : (
441 <Text dimColor>—</Text>
442 ),
443 )}
444 <Text> </Text>
445 {procs('by cpu', top.cpu.slice(0, n))}
446 {both ? <Text> </Text> : null}
447 {both ? procs('by rss', top.rss.slice(0, n)) : null}
448 <Text> </Text>
449 {line('trees', t ? <Text>{gb(t.totalK)}<Text dimColor> · {t.count} checkouts</Text></Text> : <Text dimColor>…</Text>)}
450 {treeRows.map(d => (
451 <Box key={d.name}>
452 {cell(nameW + 1, d.name)}
453 {cell(shareW + 1, bar(t && t.totalK > 0 ? (d.k / t.totalK) * 100 : 0, shareW), { dim: true })}
454 {cell(SIZE, gb(d.k), { right: true })}
455 {cell(COUNT + 1, String(d.count), { dim: true, right: true })}
456 </Box>
457 ))}
458 {treeRest.length ? (
459 <Box>
460 {cell(nameW + 1, `+${treeRest.length}`, { dim: true })}
461 {cell(shareW + 1, bar(t && t.totalK > 0 ? (restK / t.totalK) * 100 : 0, shareW), { dim: true })}
462 {cell(SIZE, gb(restK), { dim: true, right: true })}
463 {cell(COUNT + 1, String(treeRest.reduce((n, d) => n + d.count, 0)), { dim: true, right: true })}
464 </Box>
465 ) : null}
466 <Text> </Text>
467 </Box>
468 )
469 })
470}
471hooks/parse.ts 240 lines1// Pure parsers over the host commands' stdout. No `$` here, so the test kit
2// (which has no process) can hold every one of them to a captured sample.
3
4export type Sysctl = {
5 load: [number, number, number]
6 cpus: number
7 memsizeG: number
8 swap: { totalM: number; usedM: number } | null
9}
10
11/** `sysctl -n vm.loadavg hw.ncpu hw.memsize vm.swapusage`, one value per line. */
12export function parseSysctl(out: string): Sysctl {
13 const [loadLine = '', cpuLine = '', memLine = '', swapLine = ''] = out.trim().split('\n')
14 const nums = loadLine.match(/[\d.]+/g) ?? []
15 const load: [number, number, number] = [
16 Number(nums[0] ?? 0),
17 Number(nums[1] ?? 0),
18 Number(nums[2] ?? 0),
19 ]
20 const swapM = swapLine.match(/total = ([\d.]+)M\s+used = ([\d.]+)M/)
21 return {
22 load,
23 cpus: Number(cpuLine) || 0,
24 memsizeG: Number(memLine) / 1024 ** 3,
25 swap: swapM ? { totalM: Number(swapM[1]), usedM: Number(swapM[2]) } : null,
26 }
27}
28
29export type Top = {
30 cpuBusy: number | null
31 usedG: number | null
32 compressedG: number | null
33 unusedG: number | null
34}
35
36/** The header of `top -l 1 -n 0 -s 0`: the CPU usage and PhysMem lines. */
37export function parseTop(out: string): Top {
38 const idle = out.match(/CPU usage:.*?([\d.]+)% idle/)
39 const phys = out.match(/PhysMem: (\S+) used \((?:\S+ wired)?,?\s*(\S+) compressor\), (\S+) unused/)
40 return {
41 cpuBusy: idle ? Math.max(0, Math.round(100 - Number(idle[1]))) : null,
42 usedG: phys ? sizeToG(phys[1] ?? '') : null,
43 compressedG: phys ? sizeToG(phys[2] ?? '') : null,
44 unusedG: phys ? sizeToG(phys[3] ?? '') : null,
45 }
46}
47
48/** "8459M" → 8.26, "10G" → 10, "512K" → 0.0005 */
49export function sizeToG(s: string): number | null {
50 const m = s.match(/^([\d.]+)([KMGT])$/)
51 if (!m) return null
52 const n = Number(m[1])
53 switch (m[2]) {
54 case 'K': return n / 1024 ** 2
55 case 'M': return n / 1024
56 case 'G': return n
57 case 'T': return n * 1024
58 }
59 return null
60}
61
62/** `df -k <path>`: the one data row. */
63export function parseDf(out: string): { totalK: number; availK: number; pct: number } | null {
64 const row = out.trim().split('\n')[1]
65 if (!row) return null
66 const cols = row.split(/\s+/)
67 const totalK = Number(cols[1])
68 const availK = Number(cols[3])
69 const pct = Number((cols[4] ?? '').replace('%', ''))
70 if (!Number.isFinite(totalK) || !Number.isFinite(availK)) return null
71 return { totalK, availK, pct }
72}
73
74/** `uptime`'s "up ..." clause → "40d 8h", "3h 21m", "5m". */
75export function parseUptime(out: string): string {
76 const m = out.match(/up\s+(.*?),\s+\d+ users?/)
77 const clause = m?.[1] ?? out
78 const days = clause.match(/(\d+) days?/)
79 const hm = clause.match(/(\d+):(\d{2})/)
80 const mins = clause.match(/(\d+) mins?/)
81 const parts: string[] = []
82 if (days) parts.push(`${days[1]}d`)
83 if (hm) {
84 parts.push(`${Number(hm[1])}h`)
85 if (!days) parts.push(`${Number(hm[2])}m`)
86 } else if (mins) parts.push(`${mins[1]}m`)
87 return parts.join(' ') || clause.trim()
88}
89
90/** `who`: the rows with a remote host in parentheses → "ttys000 ← host". */
91export function parseWho(out: string): string[] {
92 return out
93 .trim()
94 .split('\n')
95 .map(line => line.match(/^\S+\s+(\S+)\s+.*\((.+)\)\s*$/))
96 .filter((m): m is RegExpMatchArray => m !== null)
97 .map(m => `${m[1]} ← ${m[2]}`)
98}
99
100/** `ps -axo rss=,pcpu=,comm=`: the claude-shaped processes summed. */
101export function parsePs(out: string): { procs: number; rssK: number; cpu: number } {
102 let procs = 0
103 let rssK = 0
104 let cpu = 0
105 for (const line of out.trim().split('\n')) {
106 const m = line.trim().match(/^(\d+)\s+([\d.]+)\s+(.+)$/)
107 if (!m) continue
108 const base = (m[3] ?? '').split('/').pop() ?? ''
109 if (!/^(claude|bun|node)/.test(base)) continue
110 procs += 1
111 rssK += Number(m[1])
112 cpu += Number(m[2])
113 }
114 return { procs, rssK, cpu: Math.round(cpu) }
115}
116
117export type Proc = { name: string; cpu: number; rssK: number }
118
119/**
120 * `ps -axo rss=,pcpu=,comm=` again: the `n` hungriest processes by cpu and by
121 * rss, each named by its basename (an `.app` bundle by the app's name).
122 */
123export function parseProcs(out: string, n: number): { cpu: Proc[]; rss: Proc[] } {
124 const procs: Proc[] = []
125 for (const line of out.trim().split('\n')) {
126 const m = line.trim().match(/^(\d+)\s+([\d.]+)\s+(.+)$/)
127 if (!m) continue
128 const path = m[3] ?? ''
129 const app = path.match(/\/([^/]+)\.app\//)
130 const name = app?.[1] ?? path.split('/').pop() ?? path
131 procs.push({ name, cpu: Number(m[2]), rssK: Number(m[1]) })
132 }
133 const cpu = [...procs].sort((a, b) => b.cpu - a.cpu).slice(0, n)
134 const rss = [...procs].sort((a, b) => b.rssK - a.rssK).slice(0, n)
135 return { cpu, rss }
136}
137
138/**
139 * `du -sk <dirs>`: each row "k<TAB>/…/<repo>/.claude/worktrees" → by repo,
140 * largest first; "…/.codex/worktrees" is the one row named `codex`.
141 */
142export function parseDu(out: string): { totalK: number; dirs: { name: string; k: number }[] } {
143 const dirs = out
144 .trim()
145 .split('\n')
146 .map(line => line.match(/^(\d+)\s+(.+)$/))
147 .filter((m): m is RegExpMatchArray => m !== null)
148 .map(m => ({ name: treeName(m[2] ?? ''), k: Number(m[1]) }))
149 .sort((a, b) => b.k - a.k)
150 return { totalK: dirs.reduce((n, d) => n + d.k, 0), dirs }
151}
152
153/** "/…/<repo>/.claude/worktrees[/…]" → repo; "/…/.codex/worktrees[/…]" → codex. */
154export function treeName(path: string): string {
155 const parts = path.split('/')
156 const codex = parts.lastIndexOf('.codex')
157 const claude = parts.lastIndexOf('.claude')
158 return codex >= 0 ? 'codex' : parts[claude - 1] ?? path
159}
160
161/** `find <dirs> -mindepth 1 -maxdepth 1 -type d`: checkouts per repo. */
162export function parseTreeCounts(out: string): Record<string, number> {
163 const counts: Record<string, number> = {}
164 for (const line of out.trim().split('\n')) {
165 if (!line) continue
166 const name = treeName(line)
167 counts[name] = (counts[name] ?? 0) + 1
168 }
169 return counts
170}
171
172/** `vm_stat`'s lifetime swap counters, in pages. */
173export function parseVmStatSwap(out: string): { ins: number; outs: number } | null {
174 const ins = out.match(/^Swapins:\s+(\d+)/m)
175 const outs = out.match(/^Swapouts:\s+(\d+)/m)
176 if (!ins || !outs) return null
177 return { ins: Number(ins[1]), outs: Number(outs[1]) }
178}
179
180/** bytes per second → "0", "12K/s", "2.1M/s" */
181export function rate(bytesPerS: number): string {
182 if (bytesPerS < 1024) return bytesPerS <= 0 ? '0' : `${Math.round(bytesPerS)}B/s`
183 if (bytesPerS < 1024 ** 2) return `${Math.round(bytesPerS / 1024)}K/s`
184 return `${(bytesPerS / 1024 ** 2).toFixed(1)}M/s`
185}
186
187export type Tailscale = {
188 state: string
189 dns: string
190 /** every peer the node knows, online first, then by name */
191 peers: { name: string; isOnline: boolean }[]
192}
193
194/** `tailscale status --json`: the backend state, the node's DNS name, its peers. */
195export function parseTailscale(json: string): Tailscale | null {
196 try {
197 const v: unknown = JSON.parse(json)
198 if (typeof v !== 'object' || v === null) return null
199 const o = v as {
200 BackendState?: unknown
201 Self?: { DNSName?: unknown }
202 Peer?: Record<string, { HostName?: unknown; Online?: unknown }>
203 }
204 const state = typeof o.BackendState === 'string' ? o.BackendState : 'unknown'
205 const dns = typeof o.Self?.DNSName === 'string' ? o.Self.DNSName.replace(/\.$/, '') : ''
206 const peers = Object.values(o.Peer ?? {})
207 .map(p => ({
208 name: typeof p.HostName === 'string' ? p.HostName : '?',
209 isOnline: p.Online === true,
210 }))
211 .sort((a, b) => Number(b.isOnline) - Number(a.isOnline) || a.name.localeCompare(b.name))
212 return { state, dns, peers }
213 } catch {
214 return null
215 }
216}
217
218// ─── formatting ────────────────────────────────────────────────────────────
219
220export function gb(k: number): string {
221 const g = k / 1024 ** 2
222 return g >= 100 ? `${Math.round(g)}G` : g >= 10 ? `${g.toFixed(0)}G` : `${g.toFixed(1)}G`
223}
224
225export function g1(g: number | null): string {
226 if (g === null) return '?'
227 return g >= 100 ? `${Math.round(g)}G` : g >= 10 ? `${g.toFixed(0)}G` : `${g.toFixed(1)}G`
228}
229
230export function bar(pct: number, width: number): string {
231 const w = Math.max(1, width)
232 const on = Math.round((Math.min(100, Math.max(0, pct)) / 100) * w)
233 return '█'.repeat(on) + '░'.repeat(w - on)
234}
235
236export function ago(ms: number): string {
237 const s = Math.max(0, Math.round(ms / 1000))
238 return s < 60 ? `${s}s ago` : `${Math.round(s / 60)}m ago`
239}
240types/index.d.ts 52 lines1export type HostSnap = {
2 /** ms since the epoch when the fast sample was taken */
3 at: number
4 host: string
5 /** "40d 8h", "3h 21m", "5m" */
6 up: string
7 load: [number, number, number]
8 cpus: number
9 /** percent busy (100 - idle), null when top gave nothing */
10 cpuBusy: number | null
11 mem: { totalG: number; usedG: number | null; compressedG: number | null; unusedG: number | null }
12 /** usedM is the high-water fill; ioBytesPerS is swap traffic since the last sample, null on the first */
13 swap: { totalM: number; usedM: number; ioBytesPerS: number | null } | null
14 disk: { totalK: number; availK: number; pct: number } | null
15 claude: { procs: number; rssK: number; cpu: number }
16 /** the hungriest processes, by cpu and by rss, for the pane */
17 top: { cpu: Proc[]; rss: Proc[] }
18 /** "ttys000 ← 100.64.0.7" */
19 ssh: string[]
20 tailscale: Tailscale | null
21}
22
23export type Proc = { name: string; cpu: number; rssK: number }
24
25export type Tailscale = {
26 state: string
27 dns: string
28 peers: { name: string; isOnline: boolean }[]
29}
30
31/** The slow sample: every `.claude/worktrees` under ~/code, by repo. */
32export type TreeSnap = {
33 at: number
34 totalK: number
35 /** worktree checkouts counted across every dir */
36 count: number
37 dirs: { name: string; k: number; count: number }[]
38}
39
40declare module 'claude-code' {
41 interface PluginState {
42 host: {
43 /** the band above the prompt */
44 isOn: boolean
45 /** the pane, the expanded view */
46 isPane: boolean
47 snap: HostSnap | null
48 trees: TreeSnap | null
49 }
50 }
51}
52