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

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
ps has no peak, it is the highest seen since the mod loaded.top shows it./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.
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).
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).
nohup, daemons) is no longer counted.+ ? means the process table could not be read (for example no ps in a minimal container).claude plugin validate .
claude plugin test .
claude --plugin-dir .hooks/register.ts 326 lines1import { 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}
326hooks/format.ts 29 lines1// 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}
29hooks/pane.tsx 175 lines1import 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}
175hooks/stats.ts 145 lines1// 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)
145types/index.d.ts 34 lines1// 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