SLOPSHOPPER

host

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…

newpanebandcommandprocesstimer
v0.1.0no licenseupdated 2026-10-15ramonfabrega/mods/host
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · host
│ ┃ host ✕ › fix the failing auth test and add an audit log call │ ┃ dev │ ┃ up · 0 cpu ⏺ Read(src/auth.ts) │ ┃ load 0.0 0.0 0.0 ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ cpu ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ⎿ Added 2 lines, removed 1 line │ ┃ claude 0% ⏺ Bash(bun test) │ ┃ mem ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ⎿ 3 pass, 1 fail │ ┃ 0.0G total · comp ? · free ? │ ┃ swap ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ disk ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /host │ ┃ claude 0p · 0.0G · 0% ⎿ host: host band on. │ ┃ ssh none │ ┃ ts — │ ┃ │ ┃ cpu% rss by cpu │ ┃ │ ┃ trees 0.0G · 3 checkouts │ ┃ │ dev up cpu ?% load 0.0 mem ?/0.0G swap none disk ? trees … claude 0p 0.0G ts — h: ⤢ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
dev up cpu ?% load 0.0 mem ?/0.0G swap none disk ? trees … claude 0p 0.0G ts — h: ⤢
Pane · host
dev up · 0 cpu load 0.0 0.0 0.0 cpu ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ?% claude 0% mem ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ? 0.0G total · comp ? · free ? swap ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ none disk ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ? claude 0p · 0.0G · 0% ssh none ts — cpu% rss by cpu trees 0.0G · 3 checkouts
README

mods

Claude Code mods: hooks modules that change the interface itself, one folder per mod, this repo the marketplace that lists them.

modwhat it is
host/host toggles one band above the prompt with the box's health; /host pane the expanded view.

Install

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.

Developing

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.

host

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).

Source 3 files
hooks/register.tsx 471 lines
1import { 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}
471
hooks/parse.ts 240 lines
1// 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}
240
types/index.d.ts 52 lines
1export 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