SLOPSHOPPER

proc-stats

Status line and task-manager pane with Claude Code's memory, CPU and uptime, and those of the processes it started

newpanecommandstatusprocesstimer
v0.2.0no licenseupdated 2026-10-09DragosMocrii/proc-stats
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · proc-stats
│ ┃ Processes ✕ › fix the failing auth test and add an audit log call │ ┃ no engine pid in "" │ ┃ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /proc-stats │ ⎿ proc-stats: Processes pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ proc-stats: proc-stats: no engine pid in ""

Draws

Pane · Processes
no engine pid in ""
README

proc-stats

A Claude Code mod that pins a status line with the session's own resource use, and opens a task-manager pane of it on /proc-stats:

mem 484MB · peak 530MB · cpu 2.4% · up 12m 4s

While the session has processes running under it (Bash commands, watchers, test runs, local MCP servers), their share is added after a +:

mem (484 + 215)MB · peak 530MB · cpu (2.4 + 98.7)% · up 12m 4s
  • mem: resident memory of Claude Code, plus every process below it.
  • peak: Claude Code's highest resident memory. On macOS, where ps has no peak, it is the highest seen since the mod loaded.
  • cpu: share of one core since the last reading, as top shows it.
  • up: how long Claude Code has been running.

Processes pane

/proc-stats opens a task-manager pane: Claude Code's own row, every process below it as a tree (PID, command, memory, CPU, how long it has run), and totals for its child processes and for everything together.

PID     COMMAND                                   MEM     CPU    TIME
187782  Claude Code                             484MB    2.4%  12m 4s
211146  └ bash -c timeout 15 python3 -c ...       4MB    0.0%     20s
211147    └ python3 -c ...                      200MB   98.7%     20s

        Child processes (2)                     204MB   98.7%
        Total                                   688MB  101.1%

TIME, then PID, give way on a narrow pane.

Platforms

Both views refresh every second on Linux, every 2 seconds on macOS and every 5 on Windows, and work on Linux (/proc and ps), macOS (ps) and Windows (PowerShell).

Install

At the prompt of a Claude Code terminal session:

/plugin install proc-stats --marketplace DragosMocrii/proc-stats

Answer y to add the marketplace, then choose a scope (user scope loads it in every session).

Limits

  • A process that detaches from Claude Code (nohup, daemons) is no longer counted.
  • A command that starts and ends between two readings is not counted.
  • + ? means the process table could not be read (for example no ps in a minimal container).

Development

claude plugin validate .
claude plugin test .
claude --plugin-dir .
Source 5 files
hooks/register.ts 326 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ProcRow, Reading, Snapshot } from '../types'
5import { formatBytes, formatDuration, formatPair, formatPercent } from './format'
6import { drawPane } from './pane'
7import {
8  descendants,
9  isComplete,
10  parseCmdline,
11  parseMacFields,
12  parseProcStat,
13  parseProcStatus,
14  parsePowerShell,
15  parsePsTable,
16  powerShellSample,
17  toRow,
18  windowsProc,
19} from './stats'
20import type { Platform, Proc, Sample } from './stats'
21
22const PANE = 'proc-stats'
23const COMMAND = 'proc-stats'
24// Each reading starts ps (PowerShell on Windows), which costs more on a busy Mac and most on Windows.
25const INTERVAL_MS: Record<Platform, number> = { linux: 1000, mac: 2000, windows: 5000 }
26// A build can start hundreds of workers; the pane needs no more than this.
27const MAX_ROWS = 500
28
29const reading = atom({ plugin: 'proc-stats', key: 'reading' } as const, {} as Reading)
30const isOpen = atom({ plugin: 'proc-stats', key: 'isOpen' } as const, false)
31const OPEN = { id: PANE, title: 'Processes' }
32
33// The engine, and the processes it started (undefined when the table cannot be read).
34export type Timed = { engine: Sample; children?: Proc[]; wallMs: number }
35
36// CPU a process spent since the last reading, and as a share of one core. A pid
37// whose process is younger than the one seen before was reused: a new process,
38// counted whole over the time it has run.
39export const procCpu = (now: Proc, was: Proc | undefined, elapsed: number) => {
40  const isSame = was !== undefined && now.uptimeSeconds >= was.uptimeSeconds - 1
41  if (isSame) {
42    const delta = Math.max(0, now.cpuSeconds - was.cpuSeconds)
43
44    return { delta, percent: (delta / elapsed) * 100 }
45  }
46  const window = Math.max(1, Math.min(elapsed, now.uptimeSeconds))
47
48  return { delta: now.cpuSeconds, percent: (now.cpuSeconds / window) * 100 }
49}
50
51// Depth-first from the engine, each level by pid.
52export const treeOrder = (procs: Proc[], root: number) => {
53  const byParent = new Map<number, Proc[]>()
54  for (const proc of procs) byParent.set(proc.ppid, [...(byParent.get(proc.ppid) ?? []), proc])
55  for (const list of byParent.values()) list.sort((a, b) => a.pid - b.pid)
56  const ordered: { proc: Proc; depth: number }[] = []
57  const seen = new Set<number>()
58  const visit = (pid: number, depth: number) => {
59    for (const proc of byParent.get(pid) ?? []) {
60      if (seen.has(proc.pid)) continue
61      seen.add(proc.pid)
62      ordered.push({ proc, depth })
63      visit(proc.pid, depth + 1)
64    }
65  }
66  visit(root, 0)
67  // A process whose parent ended between reads is kept, at the top level.
68  for (const proc of procs) if (!seen.has(proc.pid)) ordered.push({ proc, depth: 0 })
69
70  return ordered
71}
72
73export const buildSnapshot = (
74  platform: Platform,
75  pid: number,
76  now: Timed,
77  before: Timed | undefined,
78): Snapshot => {
79  const elapsed = before ? (now.wallMs - before.wallMs) / 1000 : 0
80  const isMeasured = before !== undefined && elapsed > 0
81  const was = new Map((before?.children ?? []).map(proc => [proc.pid, proc]))
82  const canCompare = isMeasured && before?.children !== undefined
83  let childDelta = 0
84  const rows = now.children
85    ? treeOrder(now.children, pid).map(({ proc, depth }): ProcRow => {
86        const cpu = canCompare ? procCpu(proc, was.get(proc.pid), elapsed) : undefined
87        childDelta += cpu?.delta ?? 0
88
89        return {
90          pid: proc.pid,
91          ppid: proc.ppid,
92          depth,
93          command: proc.command,
94          rssKb: proc.rssKb,
95          cpuPercent: cpu ? cpu.percent : null,
96          uptimeSeconds: proc.uptimeSeconds,
97        }
98      })
99    : null
100
101  return {
102    platform,
103    pid,
104    engine: {
105      rssKb: now.engine.rssKb,
106      peakKb: now.engine.peakKb,
107      cpuPercent:
108        isMeasured && before
109          ? (Math.max(0, now.engine.cpuSeconds - before.engine.cpuSeconds) / elapsed) * 100
110          : null,
111      uptimeSeconds: now.engine.uptimeSeconds,
112    },
113    children: rows,
114    childCount: rows?.length ?? 0,
115    childKb: rows?.reduce((sum, row) => sum + row.rssKb, 0) ?? 0,
116    childCpuPercent: canCompare ? (childDelta / elapsed) * 100 : null,
117  }
118}
119
120// What the pane is handed: the totals over every process, the rows capped.
121export const capRows = (snapshot: Snapshot): Snapshot => ({
122  ...snapshot,
123  children: snapshot.children?.slice(0, MAX_ROWS) ?? null,
124})
125
126// The children's share shows only while there are any; `?` when the table could not be read.
127export const statusLine = (snapshot: Snapshot) => {
128  const { engine, children, childCpuPercent } = snapshot
129  const hasChildren = children === null || snapshot.childCount > 0
130  let cpu = '…'
131  if (engine.cpuPercent !== null) {
132    const own = engine.cpuPercent.toFixed(1)
133    cpu = hasChildren
134      ? `(${own} + ${childCpuPercent === null ? '?' : childCpuPercent.toFixed(1)})%`
135      : formatPercent(engine.cpuPercent)
136  }
137  const childKb = children === null ? undefined : snapshot.childKb
138
139  return `mem ${formatPair(engine.rssKb, hasChildren ? childKb : 0)} · peak ${formatBytes(engine.peakKb)} · cpu ${cpu} · up ${formatDuration(engine.uptimeSeconds)}`
140}
141
142const detectPlatform = async ($: EngineInterface): Promise<Platform> => {
143  if ((await $.env.get('OS')) === 'Windows_NT') return 'windows'
144  const { stdout } = await $.process.run(['uname', '-s'])
145
146  return stdout.trim() === 'Darwin' ? 'mac' : 'linux'
147}
148
149// The engine starts each command itself, so a command's parent is the engine.
150const enginePid = async ($: EngineInterface, platform: Platform) => {
151  const { stdout } =
152    platform === 'windows'
153      ? await $.process.run([
154          'powershell',
155          '-NoProfile',
156          '-Command',
157          '(Get-CimInstance Win32_Process -Filter "ProcessId=$PID").ParentProcessId',
158        ])
159      : await $.process.run(['sh', '-c', 'echo $PPID'])
160  const pid = Number(stdout.trim())
161  if (!Number.isInteger(pid) || pid <= 0) throw new Error(`no engine pid in "${stdout.trim()}"`)
162
163  return pid
164}
165
166// `exec` gives ps the shell's pid, printed first, so it can leave itself out.
167const psTable = async ($: EngineInterface, columns: string) => {
168  const { stdout } = await $.process.run(['sh', '-c', `echo $$; exec ps -A -o ${columns}`])
169
170  return parsePsTable(stdout)
171}
172
173const linuxProc = async ($: EngineInterface, pid: number, uptime: string): Promise<Proc | undefined> => {
174  try {
175    const [status, stat, cmdline] = await Promise.all([
176      $.fs.read(`/proc/${pid}/status`),
177      $.fs.read(`/proc/${pid}/stat`),
178      // Its own failure only costs the arguments: the name from stat stands in.
179      $.fs.read(`/proc/${pid}/cmdline`).catch(() => ''),
180    ])
181    // A zombie has no VmRSS and no command line.
182    const { rssKb } = parseProcStatus(status)
183    const { name, ...rest } = parseProcStat(stat, uptime)
184
185    return {
186      pid,
187      ...rest,
188      rssKb: Number.isFinite(rssKb) ? rssKb : 0,
189      command: parseCmdline(cmdline) || name,
190    }
191  } catch {
192    // Ended since the table was read.
193    return undefined
194  }
195}
196
197const readLinux = async ($: EngineInterface, pid: number) => {
198  const [status, stat, uptime] = await Promise.all([
199    $.fs.read(`/proc/${pid}/status`),
200    $.fs.read(`/proc/${pid}/stat`),
201    $.fs.read('/proc/uptime'),
202  ])
203  const { rssKb, peakKb } = parseProcStatus(status)
204  const { cpuSeconds, uptimeSeconds } = parseProcStat(stat, uptime)
205  const engine = { rssKb, peakKb, cpuSeconds, uptimeSeconds }
206  try {
207    const { selfPid, rows } = await psTable($, 'pid=,ppid=')
208    const pids = descendants(rows.map(toRow), pid, selfPid)
209    const procs = await Promise.all(pids.map(child => linuxProc($, child, uptime)))
210
211    return { engine, children: procs.filter(proc => proc !== undefined) }
212  } catch {
213    return { engine }
214  }
215}
216
217const readMac = async ($: EngineInterface, pid: number, peakSeenKb: number) => {
218  const { selfPid, rows } = await psTable($, 'pid=,ppid=,rss=,time=,etime=,command=')
219  const own = rows.find(fields => Number(fields[0]) === pid)
220  if (!own) throw new Error(`process ${pid} not listed`)
221  const { rssKb, cpuSeconds, uptimeSeconds } = parseMacFields(own)
222  const pids = new Set(descendants(rows.map(toRow), pid, selfPid))
223  const children = rows.filter(fields => pids.has(Number(fields[0]))).map(parseMacFields)
224
225  // ps has no peak, so the highest resident size seen stands in for it.
226  return { engine: { rssKb, cpuSeconds, uptimeSeconds, peakKb: Math.max(peakSeenKb, rssKb) }, children }
227}
228
229const readWindows = async ($: EngineInterface, pid: number) => {
230  const { stdout } = await $.process.run(['powershell', '-NoProfile', '-Command', powerShellSample(pid)])
231  const { engine, selfPid, rows } = parsePowerShell(stdout)
232  const pids = new Set(descendants(rows.map(toRow), pid, selfPid))
233
234  return { engine, children: rows.filter(fields => pids.has(Number(fields[0]))).map(windowsProc) }
235}
236
237const readProcesses = (
238  $: EngineInterface,
239  platform: Platform,
240  pid: number,
241  peakSeenKb: number,
242): Promise<{ engine: Sample; children?: Proc[] }> => {
243  switch (platform) {
244    case 'linux':
245      return readLinux($, pid)
246    case 'mac':
247      return readMac($, pid, peakSeenKb)
248    case 'windows':
249      return readWindows($, pid)
250  }
251}
252
253// One loop feeds both views: the status line, and the reading the pane draws.
254const startSampling = async ($: EngineInterface) => {
255  const set = (next: Reading) => update($, reading, () => next)
256
257  try {
258    const platform = await detectPlatform($)
259    const pid = await enginePid($, platform)
260    let before: Timed | undefined
261    let isBusy = false
262
263    const sample = async () => {
264      if (isBusy) return
265      isBusy = true
266      try {
267        const sample = await readProcesses($, platform, pid, before?.engine.peakKb ?? 0)
268        if (!isComplete(sample.engine)) throw new Error('unreadable sample')
269        const now = { ...sample, wallMs: await $.clock.now() }
270        const snapshot = buildSnapshot(platform, pid, now, before)
271        $.ui.status(statusLine(snapshot))
272        await set({ snapshot: capRows(snapshot) })
273        before = now
274      } catch {
275        const error = `cannot read process ${pid} on ${platform}`
276        $.ui.status(`proc-stats: ${error}`)
277        await set({ error })
278      } finally {
279        isBusy = false
280      }
281    }
282
283    $.clock.every(INTERVAL_MS[platform], () => void sample())
284    await sample()
285  } catch (error) {
286    const text = error instanceof Error ? error.message : 'unavailable'
287    $.ui.status(`proc-stats: ${text}`)
288    await set({ error: text })
289  }
290}
291
292export const register: Register = on => {
293  on('session.start', async ($, e, next) => {
294    const started = await next(e)
295    await $.command.register({
296      name: COMMAND,
297      description: 'Show Claude Code and the processes it started in a task-manager pane',
298    })
299    void startSampling($)
300    // A reload closes the pane (an unload no hook hears); put it back if it was open.
301    if (await read($, isOpen)) void $.ui.open(OPEN)
302
303    return started
304  })
305
306  on('command.run', { command: COMMAND }, async $ => {
307    const opened = await $.ui.open(OPEN)
308    await update($, isOpen, () => true)
309
310    return { text: opened.isPlaced ? 'Processes pane opened.' : 'Processes pane could not be placed.' }
311  })
312
313  // Diagnostic: says why the pane closed. An unload closes it before any hook hears.
314  on('ui.close', { id: PANE }, async ($, e, next) => {
315    $.ui.log(`proc-stats: Processes pane closed (${e.origin.kind})`)
316    const closed = await next(e)
317    await update($, isOpen, () => false)
318
319    return closed
320  })
321
322  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) =>
323    drawPane($.ui.resolve(e), await read($, reading), e.props.bodyColumns),
324  )
325}
326
hooks/format.ts 29 lines
1// Text for numbers, shared by the status line and the pane.
2
3export const formatBytes = (kb: number) =>
4  kb >= 1024 * 1024 ? `${(kb / 1024 / 1024).toFixed(2)}GB` : `${Math.round(kb / 1024)}MB`
5
6// With children, both numbers in the larger one's unit: `(484 + 15)MB`.
7export const formatPair = (kb: number, childKb: number | undefined) => {
8  if (childKb === 0) return formatBytes(kb)
9  const isGb = Math.max(kb, childKb ?? 0) >= 1024 * 1024
10  const one = (n: number) => (isGb ? (n / 1024 / 1024).toFixed(2) : String(Math.round(n / 1024)))
11
12  return `(${one(kb)} + ${childKb === undefined ? '?' : one(childKb)})${isGb ? 'GB' : 'MB'}`
13}
14
15export const formatPercent = (percent: number | null) =>
16  percent === null ? '…' : `${percent.toFixed(1)}%`
17
18export const formatDuration = (seconds: number) => {
19  const s = Math.max(0, Math.floor(seconds))
20  const d = Math.floor(s / 86400)
21  const h = Math.floor((s % 86400) / 3600)
22  const m = Math.floor((s % 3600) / 60)
23  if (d > 0) return `${d}d ${h}h`
24  if (h > 0) return `${h}h ${m}m`
25  if (m > 0) return `${m}m ${s % 60}s`
26
27  return `${s}s`
28}
29
hooks/pane.tsx 175 lines
1import type { Elements, RenderSurface } from 'claude-code'
2
3import type { ProcRow, Reading, Snapshot } from '../types'
4import { formatBytes, formatDuration, formatPercent } from './format'
5
6const PID_WIDTH = 7
7const NUMBER_WIDTH = 7
8const TIME_WIDTH = 7
9const MIN_COMMAND = 12
10// Space around the pane's contents, in cells: each side, and below. Rows pad
11// their own sides too, so a stripe reaches past the text it holds.
12const PADDING_X = 1
13const ROW_PADDING_X = 1
14const PADDING_BOTTOM = 1
15// Theme keys, so both follow the person's light or dark theme.
16const HEADER_COLOR = 'claude'
17const HEADER_TEXT = 'inverseText'
18const STRIPE_COLOR = 'subtle'
19
20// The columns that fit: TIME goes first, then PID, the command keeping the rest.
21export const paneColumns = (bodyColumns: number) => {
22  const fixed = (showPid: boolean, showTime: boolean) =>
23    (showPid ? PID_WIDTH + 1 : 0) + (NUMBER_WIDTH + 1) * 2 + (showTime ? TIME_WIDTH + 1 : 0)
24  const showTime = bodyColumns - fixed(true, true) >= MIN_COMMAND
25  const showPid = bodyColumns - fixed(true, false) >= MIN_COMMAND || showTime
26
27  return {
28    showPid,
29    showTime,
30    commandWidth: Math.max(MIN_COMMAND, bodyColumns - fixed(showPid, showTime)),
31  }
32}
33
34export type Line = {
35  pid: string
36  command: string
37  mem: string
38  cpu: string
39  time: string
40  style: 'header' | 'own' | 'child' | 'total' | 'note'
41}
42
43// Tree lines for rows in tree order: `├ ` or `└ ` before each, and `│ ` under
44// every ancestor that has a sibling still to come.
45export const treePrefixes = (rows: { depth: number }[]) => {
46  const isLastAt: boolean[] = []
47
48  return rows.map(({ depth }, index) => {
49    const next = rows.slice(index + 1).find(row => row.depth <= depth)
50    const isLast = next === undefined || next.depth < depth
51    const prefix = isLastAt.slice(0, depth).map(last => (last ? '  ' : '│ ')).join('')
52    isLastAt[depth] = isLast
53
54    return `${prefix}${isLast ? '└' : '├'} `
55  })
56}
57
58const childLine = (row: ProcRow, prefix: string): Line => ({
59  pid: String(row.pid),
60  command: `${prefix}${row.command}`,
61  mem: formatBytes(row.rssKb),
62  cpu: formatPercent(row.cpuPercent),
63  time: formatDuration(row.uptimeSeconds),
64  style: 'child',
65})
66
67// Zebra rows: every other process row, Claude Code's own counted first.
68export const stripes = (lines: Line[]) => {
69  let row = 0
70
71  return lines.map(line => (line.style === 'own' || line.style === 'child') && row++ % 2 === 1)
72}
73
74// Claude Code first, the processes below it as a tree, then the totals.
75export const paneLines = (snapshot: Snapshot): Line[] => {
76  const { engine, children, childCpuPercent } = snapshot
77  const lines: Line[] = [
78    { pid: 'PID', command: 'COMMAND', mem: 'MEM', cpu: 'CPU', time: 'TIME', style: 'header' },
79    {
80      pid: String(snapshot.pid),
81      command: 'Claude Code',
82      mem: formatBytes(engine.rssKb),
83      cpu: formatPercent(engine.cpuPercent),
84      time: formatDuration(engine.uptimeSeconds),
85      style: 'own',
86    },
87  ]
88  if (children === null) {
89    const note = 'Child process list unavailable'
90
91    return [...lines, { pid: '', command: note, mem: '', cpu: '', time: '', style: 'note' }]
92  }
93  const prefixes = treePrefixes(children)
94  lines.push(...children.map((row, index) => childLine(row, prefixes[index] ?? '')))
95  if (children.length === 0) {
96    const note = 'No child processes'
97
98    return [...lines, { pid: '', command: note, mem: '', cpu: '', time: '', style: 'note' }]
99  }
100  const { childCount, childKb } = snapshot
101  const both =
102    engine.cpuPercent !== null && childCpuPercent !== null ? engine.cpuPercent + childCpuPercent : null
103  const hidden = childCount - children.length
104  if (hidden > 0) {
105    lines.push({ pid: '', command: `… ${hidden} more`, mem: '', cpu: '', time: '', style: 'note' })
106  }
107
108  return [
109    ...lines,
110    { pid: '', command: `Child processes (${childCount})`, mem: formatBytes(childKb), cpu: formatPercent(childCpuPercent), time: '', style: 'total' },
111    { pid: '', command: 'Total', mem: formatBytes(engine.rssKb + childKb), cpu: formatPercent(both), time: '', style: 'total' },
112  ]
113}
114
115export const drawPane = (
116  { Box, Text }: Elements[RenderSurface],
117  { snapshot, error }: Reading,
118  bodyColumns: number,
119) => {
120  if (!snapshot) {
121    return (
122      <Box paddingX={PADDING_X} paddingBottom={PADDING_BOTTOM}>
123        <Text dimColor>{error ?? 'Reading…'}</Text>
124      </Box>
125    )
126  }
127  const { showPid, showTime, commandWidth } = paneColumns(bodyColumns - (PADDING_X + ROW_PADDING_X) * 2)
128  const lines = paneLines(snapshot)
129  const striped = stripes(lines)
130  const cell = (text: string, width: number, line: Line, background: string | undefined, isRight = false) => (
131    <Box width={width} justifyContent={isRight ? 'flex-end' : 'flex-start'}>
132      <Text
133        wrap="truncate-end"
134        backgroundColor={background}
135        color={line.style === 'header' ? HEADER_TEXT : undefined}
136        bold={line.style === 'header' || line.style === 'own' || line.style === 'total'}
137        dimColor={line.style === 'note'}
138      >
139        {text}
140      </Text>
141    </Box>
142  )
143
144  return (
145    <Box flexDirection="column" paddingX={PADDING_X} paddingBottom={PADDING_BOTTOM}>
146      <Box marginBottom={1} paddingX={ROW_PADDING_X}>
147        <Text dimColor>
148          peak {formatBytes(snapshot.engine.peakKb)} · {snapshot.platform}
149          {snapshot.platform === 'mac' ? ' (peak is the highest seen)' : ''}
150        </Text>
151      </Box>
152      {lines.map((line, index) => {
153        const background =
154          line.style === 'header' ? HEADER_COLOR : striped[index] ? STRIPE_COLOR : undefined
155
156        return (
157          <Box
158            flexDirection="row"
159            columnGap={1}
160            paddingX={ROW_PADDING_X}
161            backgroundColor={background}
162            marginTop={line.style === 'total' && line.command !== 'Total' ? 1 : 0}
163          >
164            {showPid && cell(line.pid, PID_WIDTH, line, background)}
165            {cell(line.command, commandWidth, line, background)}
166            {cell(line.mem, NUMBER_WIDTH, line, background, true)}
167            {cell(line.cpu, NUMBER_WIDTH, line, background, true)}
168            {showTime && cell(line.time, TIME_WIDTH, line, background, true)}
169          </Box>
170        )
171      })}
172    </Box>
173  )
174}
175
hooks/stats.ts 145 lines
1// Platform parsers: the engine process's own numbers, and the process table
2// its descendants are found in.
3
4export type Platform = 'linux' | 'mac' | 'windows'
5
6export type Sample = { rssKb: number; peakKb: number; cpuSeconds: number; uptimeSeconds: number }
7
8// One process below the engine.
9export type Proc = {
10  pid: number
11  ppid: number
12  command: string
13  rssKb: number
14  cpuSeconds: number
15  uptimeSeconds: number
16}
17
18export type Row = { pid: number; ppid: number }
19
20// USER_HZ: clock ticks per second in /proc/<pid>/stat; 100 on every mainstream Linux build.
21const TICKS_PER_SECOND = 100
22
23// Linux: VmRSS and VmHWM (peak resident set) from /proc/<pid>/status.
24export const parseProcStatus = (text: string) => {
25  const kb = (key: string) => Number(new RegExp(`^${key}:\\s+(\\d+) kB`, 'm').exec(text)?.[1] ?? NaN)
26
27  return { rssKb: kb('VmRSS'), peakKb: kb('VmHWM') }
28}
29
30// Linux: the name sits in parentheses and may itself hold spaces and parentheses.
31export const parseProcStat = (stat: string, systemUptime: string) => {
32  const name = stat.slice(stat.indexOf('(') + 1, stat.lastIndexOf(')'))
33  const rest = stat.slice(stat.lastIndexOf(')') + 2).split(' ')
34  const startSeconds = Number(rest[19]) / TICKS_PER_SECOND
35
36  return {
37    name,
38    ppid: Number(rest[1]),
39    cpuSeconds: (Number(rest[11]) + Number(rest[12])) / TICKS_PER_SECOND,
40    uptimeSeconds: Number(systemUptime.split(' ')[0]) - startSeconds,
41  }
42}
43
44// Linux: /proc/<pid>/cmdline is NUL-separated; empty for a zombie.
45export const parseCmdline = (text: string) => text.split('\0').filter(Boolean).join(' ')
46
47// ps times: `[[dd-]hh:]mm:ss[.cc]` (etime, and time on Linux) or `mmm:ss.cc` (time on macOS).
48export const parsePsTime = (text: string) => {
49  const [days, clock] = text.includes('-') ? text.split('-') : ['0', text]
50  const seconds = (clock ?? '')
51    .split(':')
52    .reduce((total, part) => total * 60 + Number(part), 0)
53
54  return Number(days) * 86400 + seconds
55}
56
57const lines = (text: string) =>
58  text
59    .split(/\r?\n/)
60    .map(line => line.trim().split(/\s+/))
61    .filter(fields => fields[0] !== '')
62
63// `echo $$; exec ps -A -o pid=,ppid=,...`: the first line is ps's own pid,
64// which is a child of the engine too and is left out.
65export const parsePsTable = (text: string) => {
66  const [first = [], ...rest] = lines(text)
67
68  return { selfPid: Number(first[0]), rows: rest }
69}
70
71export const toRow = (fields: string[]): Row => ({ pid: Number(fields[0]), ppid: Number(fields[1]) })
72
73// macOS: `pid ppid rss time etime command`, the command last since it holds spaces.
74export const parseMacFields = (fields: string[]): Proc => ({
75  pid: Number(fields[0]),
76  ppid: Number(fields[1]),
77  rssKb: Number(fields[2]),
78  cpuSeconds: parsePsTime(fields[3] ?? ''),
79  uptimeSeconds: parsePsTime(fields[4] ?? ''),
80  command: fields.slice(5).join(' '),
81})
82
83// Windows: the engine's bytes, peak bytes, CPU and uptime seconds; PowerShell's
84// own pid; then `pid ppid bytes cpu100ns ageSeconds command` per process.
85export const parsePowerShell = (text: string) => {
86  const [engine = [], self = [], ...rest] = lines(text)
87  const [rss, peak, cpu, up] = engine.map(Number)
88
89  return {
90    engine: {
91      rssKb: (rss ?? NaN) / 1024,
92      peakKb: (peak ?? NaN) / 1024,
93      cpuSeconds: cpu ?? NaN,
94      uptimeSeconds: up ?? NaN,
95    },
96    selfPid: Number(self[0]),
97    rows: rest,
98  }
99}
100
101export const windowsProc = (fields: string[]): Proc => ({
102  pid: Number(fields[0]),
103  ppid: Number(fields[1]),
104  rssKb: Number(fields[2]) / 1024,
105  cpuSeconds: Number(fields[3]) / 1e7,
106  uptimeSeconds: Number(fields[4]),
107  command: fields.slice(5).join(' '),
108})
109
110export const powerShellSample = (pid: number) =>
111  [
112    `$c = [cultureinfo]::InvariantCulture`,
113    `$now = Get-Date`,
114    `$p = Get-Process -Id ${pid}`,
115    `[string]::Format($c, '{0} {1} {2:F2} {3:F0}', $p.WorkingSet64, $p.PeakWorkingSet64,` +
116      ` $p.TotalProcessorTime.TotalSeconds, ($now - $p.StartTime).TotalSeconds)`,
117    `$PID`,
118    `Get-CimInstance Win32_Process | ForEach-Object {` +
119      ` $cmd = if ($_.CommandLine) { $_.CommandLine -replace '\\s+', ' ' } else { $_.Name };` +
120      ` $age = if ($_.CreationDate) { ($now - $_.CreationDate).TotalSeconds } else { 0 };` +
121      ` [string]::Format($c, '{0} {1} {2} {3} {4:F0} {5}', $_.ProcessId, $_.ParentProcessId,` +
122      ` $_.WorkingSetSize, $_.UserModeTime + $_.KernelModeTime, $age, $cmd) }`,
123  ].join('; ')
124
125// Every process below `root`, leaving out `exclude` and what it started.
126export const descendants = (rows: Row[], root: number, exclude: number) => {
127  const children = new Map<number, number[]>()
128  for (const { pid, ppid } of rows) {
129    if (pid === ppid) continue
130    children.set(ppid, [...(children.get(ppid) ?? []), pid])
131  }
132  const found = new Set<number>()
133  const queue = [...(children.get(root) ?? [])]
134  while (queue.length > 0) {
135    const pid = queue.shift()!
136    if (pid === exclude || pid === root || found.has(pid)) continue
137    found.add(pid)
138    queue.push(...(children.get(pid) ?? []))
139  }
140
141  return [...found]
142}
143
144export const isComplete = (sample: Sample) => Object.values(sample).every(Number.isFinite)
145
types/index.d.ts 34 lines
1// One process below Claude Code, in tree order.
2export type ProcRow = {
3  pid: number
4  ppid: number
5  // 0 for a direct child of Claude Code.
6  depth: number
7  command: string
8  rssKb: number
9  // Null on the first reading, which has nothing to measure from.
10  cpuPercent: number | null
11  uptimeSeconds: number
12}
13
14export type Snapshot = {
15  platform: string
16  pid: number
17  engine: { rssKb: number; peakKb: number; cpuPercent: number | null; uptimeSeconds: number }
18  // Null when the process table could not be read; capped for the pane.
19  children: ProcRow[] | null
20  // Over every process found, capped rows included.
21  childCount: number
22  childKb: number
23  childCpuPercent: number | null
24}
25
26export type Reading = { snapshot?: Snapshot; error?: string }
27
28declare module 'claude-code' {
29  interface PluginState {
30    // isOpen: whether the person has the pane open, so a reload can reopen it.
31    'proc-stats': { reading: Reading; isOpen: boolean }
32  }
33}
34