See which Claude Code sessions use your Mac's CPU and memory, and how far along their tests, builds and CI runs are

![]()
A Claude Code mod for people who run many Claude Code sessions at once on a Mac. A band above the prompt shows how much CPU and memory your machine uses, which sessions use it, and how far along their tests, builds and CI runs are. From the band you can open any session's processes and pause or interrupt them.
Verbose view (the default): the machine's totals, then column charts for CPU and memory, one column per session in its own colour, largest on the left. Memory shows both size and share of RAM. Under each name: the session's listening ports (its local servers), for example :3330. A tip under the charts says how to switch to compact.
Compact view (/load compact): the same, with shorter, narrower columns so more sessions fit in less space: the machine's totals, then short column charts for CPU and memory, one column per session in its own colour, largest on the left.
CPU 63% ████████████▌░░░░░░░ ▅▆▆▅ load 2.5/14 MEM 36% ███████▎░░░░░░░░░░░░ ▃▃▃ 8.6G of 24.0G
37% 1.1G 5% 710M 3%
████████ 5% 2% 1% ████████ ▂▂▂▂▂▂▂▂ 50M 0.2% 30M 0.1%
████████ ▂▂▂▂▂▂▂▂ ▁▁▁▁▁▁▁▁ ▁▁▁▁▁▁▁▁ ████████ ████████ ▁▁▁▁▁▁▁▁ ▁▁▁▁▁▁▁▁
▸my-sess… › feature-x › Left run… › Agents v… › ▸my-sess… › feature-x › Left run… › Agents v… ›
:3330 old-branch 2 windows o… :3330 old-branch 2 windows o…
⚠ Left running after their session ended: old-branch (flutter_tester, 50M)
Click a column › to pause or stop its processes · Tip: /load verbose for taller charts
─── running (2) ────────────────────────────────────────────────────────────────────
▶ run █████░░░░░ 50% my-session · pnpm test --filter api 1m00s of ~2m00s, ~1m00s left
▶ CI ██░░░░░░░░ 20% CI · my-branch 2m00s of ~10m00s, ~8m00s left
What counts toward a session: the session itself and everything it started (shells, test runs, dev servers, subagent tools). Your own session is marked ▸. Other groups:
Apps that have nothing to do with Claude Code (browsers, Docker and so on) are left out.
Running work lists every test, lint, build or deploy command on the machine with the session it runs in, in-progress GitHub Actions runs for the session's repository, and the session's subagents. Percent done and time left come from how long the same command or workflow took before.
Every column is clickable, and its name ends in ›. Under the pointer its bars turn orange and its value and name light up. Click it (or, outside fullscreen mode, press ctrl+x tab, then Tab to it and Enter) and its processes open in a pane at the side, with CPU, memory, uptime and ports. Esc closes the pane.
The Claude Code session itself, its terminal wrapper and Claude Code's own helpers are listed but never paused or interrupted.
/load compact or /load verbose picks a view; /load alone switches to the other one.At the prompt of a Claude Code terminal session:
/plugin install session-load --marketplace chcliu/session-load
Or from your shell:
claude plugin install session-load --marketplace chcliu/session-load
In the desktop app, click + next to the prompt box, then Plugins, Add marketplace with chcliu/session-load, then Add plugin.
Sessions that are already open pick it up after /reload-plugins.
memory_pressure, sysctl). On other systems those commands are missing and the band does not appear.gh), signed in, for the CI rows. Without it, everything else still works and the CI rows stay empty.Everything happens on your machine. It runs these commands, as you, and reads only their output:
| Command | When | Why |
|---|---|---|
ps -Ao pid=,ppid=,pcpu=,rss=,etime=,stat=,args= | every 3 seconds | CPU, memory, state and command line of every process, to group them by session and find running tests and builds |
memory_pressure | every 3 seconds | the machine's free memory |
sysctl -n vm.loadavg | every 3 seconds | load average |
sysctl -n hw.ncpu hw.memsize | once per session | the number of cores and the size of RAM |
lsof -nP -iTCP -sTCP:LISTEN -F pn | every 15 seconds | which processes listen on which local ports, to show each session's servers |
gh run list --status in_progress ... in the session's folder | every 30 seconds | GitHub Actions runs in progress for that repository |
gh run list --workflow <name> --status success ... | at most every 30 minutes per workflow | how long that workflow's last 10 successful runs took, for the estimate |
kill -STOP, kill -CONT or kill -INT with process IDs | only when you press Pause, Resume, or Interrupt and then Yes | to pause, resume or interrupt the process you chose and the processes under it |
It runs no shell and no other programs.
It also reads, through Claude Code's own file and session interfaces:
~/.claude/sessions: Claude Code's list of running sessions (process ID, session ID, folder and name), to name each session$HOME and this session's ID, to find that folder and mark your own session ▸What it keeps:
/load.Only one thing leaves your machine: the gh run list calls above, which go to GitHub's API (api.github.com) through your own gh sign-in. They send the repository name (from the session's folder), the workflow name, and the run filters shown in the table, and read back run status and start and end times. They send nothing about your processes, sessions or files.
It writes no files, runs no server of its own, and sends nothing to the author or anyone else.
~/.claude/sessions and Claude Code's process names, which are not a documented interface. A Claude Code update can change them.claude agents screen itself can't show mods. The band appears inside each session, including one you open from the agents view.MIT
hooks/register.tsx 927 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import type { Job, ProcessRow, Snapshot, UsageRow, ViewMode } from '../types'
4
5const SNAPSHOT = { plugin: 'session-load', key: 'machine' } as const
6const VIEW = { plugin: 'session-load', key: 'view' } as const
7const CONFIRMING = { plugin: 'session-load', key: 'confirming' } as const
8
9const POLL_MS = 3000
10const CI_POLL_MS = 30000
11const CI_HISTORY_TTL_MS = 30 * 60 * 1000
12const SESSIONS_POLL_MS = 15000
13const PORTS_POLL_MS = 15000
14const HISTORY_KEY = 'durations'
15// The view chosen with /load, kept for every session; a new install starts verbose.
16const VIEW_KEY = 'view'
17const DEFAULT_VIEW: ViewMode = 'verbose'
18const HISTORY_SIZE = 10
19// Runs shorter than this are not worth learning an estimate from.
20const MIN_RECORD_MS = 5000
21// 30 polls of 3s: the trend lines cover the last 90 seconds.
22const TREND_SAMPLES = 30
23const WIDE_COLUMNS = 90
24// Columns of the verbose charts and of the shorter, narrower compact ones.
25const FULL_CHART = { height: 3, columnWidth: 15, barWidth: 10 }
26const COMPACT_CHART = { height: 2, columnWidth: 12, barWidth: 8 }
27const TOTAL_BAR_WIDTH = 20
28const MAX_JOBS = 6
29// Buttons in the row that opens a session's processes.
30const GROUP_PROCESSES = 25
31const PANE_PREFIX = 'sl-'
32// Text colours of the side pane, which is dark on every theme.
33const PANE_TEXT = '#e4e4e4'
34const PANE_DIM = '#a8a8a8'
35const PANE_ALERT = '#ff8787'
36// One colour per session, the same in every view; yellow and grey belong to leftovers and Claude's helpers.
37const SESSION_COLORS = ['cyan', 'magenta', 'blue', 'green', '#5fafff', '#d787ff', '#87d7af', '#ff87af']
38
39// Command lines that count as "work in progress" (tests, lint, builds, deploys).
40const WORK_PATTERNS = [
41 /\b(pnpm|npm|yarn|bun)( run)? (test|lint|build|typecheck|e2e|db:test)[\w:-]*/,
42 /\bturbo (run )?[\w:-]+/,
43 /\b(jest|vitest|mocha|pytest|eslint|tsc)\b/,
44 /\bplaywright test\b/,
45 /\bflutter (test|build|drive)\b/,
46 /\bdart test\b/,
47 /\b(xcodebuild|gradlew?)\b/,
48 /\bcargo (test|build)\b/,
49 /\bgo test\b/,
50 /\bgh (run watch|pr checks)\b/,
51 /[\w-]*deploy[\w-]*\.sh\b/,
52]
53// Long-lived watchers and editor helpers that would otherwise match forever.
54const IGNORE_PATTERN = /--watch|\bwatch\b|tsserver|Code Helper|Visual Studio Code|Cursor\.app|language-server/
55
56type PsRow = { pid: number; ppid: number; cpu: number; rssKb: number; elapsedMs: number; isPaused: boolean; args: string }
57type History = Record<string, number[]>
58type Tracked = { key: string; elapsedMs: number }
59type Signal = 'INT' | 'STOP' | 'CONT'
60
61const parseElapsed = (etime: string): number => {
62 const [days = '0', clock = ''] = etime.includes('-') ? etime.split('-') : ['0', etime]
63 const parts = clock.split(':').map(Number).reverse()
64 const [sec = 0, min = 0, hr = 0] = parts
65
66 return (((Number(days) * 24 + hr) * 60 + min) * 60 + sec) * 1000
67}
68
69const parsePs = (stdout: string): PsRow[] =>
70 stdout
71 .split('\n')
72 .map(line => line.trim().match(/^(\d+)\s+(\d+)\s+([\d.]+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(.*)$/))
73 .filter((m): m is RegExpMatchArray => m !== null)
74 .map(([, pid = '', ppid = '', cpu = '', rss = '', etime = '', stat = '', args = '']) => ({
75 pid: Number(pid),
76 ppid: Number(ppid),
77 cpu: Number(cpu),
78 rssKb: Number(rss),
79 elapsedMs: parseElapsed(etime),
80 // `T` is a process stopped by a signal: paused.
81 isPaused: stat.startsWith('T'),
82 args,
83 }))
84
85// `lsof -F pn` prints a `p<pid>` line, then an `n<address>:<port>` line per listening socket.
86const parsePorts = (stdout: string) => {
87 const ports = new Map<number, number[]>()
88 let pid = 0
89 for (const line of stdout.split('\n')) {
90 if (line.startsWith('p')) pid = Number(line.slice(1))
91 if (!line.startsWith('n') || !pid) continue
92 const port = Number(line.slice(line.lastIndexOf(':') + 1))
93 if (port) ports.set(pid, [...new Set([...(ports.get(pid) ?? []), port])])
94 }
95
96 return ports
97}
98
99const basename = (path: string) => path.split('/').pop() ?? path
100
101const processName = (args: string): string => {
102 const app = args.match(/\/([^/]+)\.app\//)
103 if (app?.[1]) return app[1]
104 const [argv0 = '', ...rest] = args.split(' ')
105 const exe = basename(argv0)
106 // `node .../.bin/next dev` reads as `next dev`: the script and what it was asked to do.
107 if (/^(node|python3?|ruby|java|bun|deno)$/.test(exe)) {
108 const at = rest.findIndex(arg => !arg.startsWith('-'))
109 const script = rest[at]
110 if (script) return [basename(script), ...rest.slice(at + 1)].join(' ').slice(0, 48).trim()
111 }
112
113 return exe
114}
115
116const colorOf = (id: string) => {
117 let hash = 0
118 for (const char of id) hash = (hash * 31 + char.charCodeAt(0)) >>> 0
119
120 return SESSION_COLORS[hash % SESSION_COLORS.length] ?? 'cyan'
121}
122
123type ClaudeSession = { pid: number; sessionId: string; cwd: string; name?: string }
124
125// Claude's own processes outside every session, two lines each under their column.
126const claudeLabel = {
127 agents: (windows: number) => `Agents view\n${windows} window${windows === 1 ? '' : 's'} open`,
128 manager: 'Background\njob manager',
129 spare: 'Spare session\nfor next job',
130 desktop: 'Claude Mac app\nchat, Code tab',
131 other: 'Other Claude\nCode process',
132}
133
134// A folder name ends where a shell command does: at a space, a quote, `;`, `&`, `|` or `)`.
135const worktreeOf = (path: string) => path.match(/worktrees\/([^/\s'";&|)]+)/)?.[1]
136
137const sessionLabel = (session: ClaudeSession) => {
138 // A session nobody named carries its short id as its name.
139 const isNamed = session.name !== undefined && !/^[0-9a-f]{8}$/.test(session.name)
140 if (isNamed) return session.name!
141
142 return worktreeOf(session.cwd) ?? `${basename(session.cwd)} ${session.sessionId.slice(0, 8)}`
143}
144
145// ~/.claude/sessions holds one JSON file per running session, named for its process.
146const parseSessions = (files: string[]): ClaudeSession[] =>
147 files.flatMap(text => {
148 try {
149 const entry = JSON.parse(text) as Partial<ClaudeSession>
150 return typeof entry.pid === 'number' && entry.sessionId && entry.cwd
151 ? [{ pid: entry.pid, sessionId: entry.sessionId, cwd: entry.cwd, name: entry.name }]
152 : []
153 } catch {
154 return []
155 }
156 })
157
158type GroupRef = { id: string; name: string; kind: UsageRow['kind'] }
159
160// Each process counts toward the first Claude Code session above it. Processes outside every
161// session count only when they are Claude's own, or were left running in a session's repo.
162const sessionUsage = (rows: PsRow[], sessions: ClaudeSession[], ownPid: number, cores: number, totalMemMb: number, ports: Map<number, number[]>) => {
163 const byPid = new Map(rows.map(row => [row.pid, row]))
164 const sessionByPid = new Map(sessions.map(session => [session.pid, session]))
165 // A background session runs inside a terminal wrapper of its own: the wrapper is the session's too.
166 for (const session of sessions) {
167 const wrapper = byPid.get(byPid.get(session.pid)?.ppid ?? -1)
168 if (wrapper?.args.includes('--bg-pty-host')) sessionByPid.set(wrapper.pid, session)
169 }
170 const agentWindows = rows.filter(row => /(^|\/)claude agents\b/.test(row.args)).length
171 const repoRoots = [...new Set(sessions.map(session => session.cwd.split('/.claude/worktrees/')[0] ?? session.cwd))]
172
173 const groupOf = (row: PsRow): GroupRef | null => {
174 let claudeRole: keyof typeof claudeLabel | undefined
175 let top = row
176 for (let current: PsRow | undefined = row, depth = 0; current && depth < 64; current = byPid.get(current.ppid), depth += 1) {
177 const session = sessionByPid.get(current.pid)
178 if (session) return { id: `session:${session.sessionId}`, name: sessionLabel(session), kind: session.pid === ownPid ? 'current' : 'session' }
179 // The nearest Claude process above names what this one is part of.
180 if (!claudeRole && current.args.includes('/Claude.app/')) claudeRole = 'desktop'
181 if (!claudeRole && /(^|\/)claude( |$)/.test(current.args)) {
182 claudeRole = /(^|\/)claude agents\b/.test(current.args)
183 ? 'agents'
184 : /--bg-(pty-host|spare)\b/.test(current.args)
185 ? 'spare'
186 : /(^|\/)claude daemon\b/.test(current.args)
187 ? 'manager'
188 : 'other'
189 }
190 top = current
191 if (current.ppid === current.pid) break
192 }
193 if (claudeRole === 'agents') return { id: 'claude:agents', name: claudeLabel.agents(agentWindows), kind: 'claude' }
194 if (claudeRole) return { id: `claude:${claudeRole}`, name: claudeLabel[claudeRole], kind: 'claude' }
195 const root = repoRoots.find(path => top.args.includes(path))
196 if (root) {
197 const where = worktreeOf(top.args) ?? basename(root)
198 return { id: `leftover:${where}`, name: `Left running\n${where}`, kind: 'leftover' }
199 }
200
201 return null
202 }
203
204 const groups = new Map<string, UsageRow>()
205 const groupOfPid = new Map<number, GroupRef>()
206 const protectedPids = new Set<number>()
207 for (const row of rows) {
208 const ref = groupOf(row)
209 if (!ref) continue
210 groupOfPid.set(row.pid, ref)
211 const color = ref.kind === 'claude' ? 'inactive' : ref.kind === 'leftover' ? 'warning' : colorOf(ref.id)
212 const entry = groups.get(ref.id) ?? { ...ref, color, cpu: 0, memMb: 0, memShare: 0, ports: [], processes: [] }
213 const memMb = row.rssKb / 1024
214 const isProtected = ref.kind === 'claude' || sessionByPid.has(row.pid)
215 if (isProtected) protectedPids.add(row.pid)
216 const rowPorts = ports.get(row.pid) ?? []
217 entry.cpu += row.cpu / cores
218 entry.memMb += memMb
219 entry.memShare = totalMemMb > 0 ? (entry.memMb / totalMemMb) * 100 : 0
220 entry.ports = [...new Set([...entry.ports, ...rowPorts])].sort((a, b) => a - b)
221 entry.processes.push({
222 pid: row.pid,
223 name: sessionByPid.has(row.pid)
224 ? row.args.includes('--bg-pty-host') ? 'terminal wrapper' : 'Claude Code session'
225 : (workLabel(row.args) ?? processName(row.args)),
226 cpu: row.cpu / cores,
227 memMb,
228 elapsedMs: row.elapsedMs,
229 isPaused: row.isPaused,
230 ports: rowPorts,
231 isProtected,
232 })
233 groups.set(ref.id, entry)
234 }
235 for (const group of groups.values()) {
236 group.processes = group.processes.sort((a, b) => b.cpu - a.cpu || b.memMb - a.memMb).slice(0, GROUP_PROCESSES)
237 }
238
239 return { groups: [...groups.values()], groupOfPid, protectedPids }
240}
241
242const workLabel = (args: string): string | null => {
243 if (IGNORE_PATTERN.test(args)) return null
244 for (const pattern of WORK_PATTERNS) {
245 const match = args.match(pattern)
246 if (match?.index !== undefined) {
247 // A match inside a path (`.../jest/bin/jest.js`) reads as the script's name and what follows it.
248 const tokenStart = args.lastIndexOf(' ', match.index) + 1
249 const tokenEnd = args.indexOf(' ', match.index)
250 const token = args.slice(tokenStart, tokenEnd === -1 ? undefined : tokenEnd)
251 const text = token.includes('/')
252 ? basename(token).replace(/\.[cm]?js$/, '') + (tokenEnd === -1 ? '' : args.slice(tokenEnd, tokenEnd + 40))
253 : args.slice(match.index, match.index + 60)
254 const [command = ''] = text.split(/['"]|&&|\|\||;|\\012/)
255 const worktree = worktreeOf(args)
256 const app = args.match(/\/(?:apps|packages)\/([^/\s'";&|)]+)/)?.[1]
257 const where = [worktree, app].filter(Boolean).join('/')
258
259 return where ? `${command.trim()} (${where})` : command.trim()
260 }
261 }
262
263 return null
264}
265
266// One row per piece of work: a matched process whose ancestor also matched is part of that work.
267const workProcesses = (rows: PsRow[]) => {
268 const byPid = new Map(rows.map(row => [row.pid, row]))
269 const labels = new Map<number, string>()
270 for (const row of rows) {
271 const label = workLabel(row.args)
272 if (label) labels.set(row.pid, label)
273 }
274 const hasMatchedAncestor = (row: PsRow) => {
275 for (let parent = byPid.get(row.ppid); parent; parent = byPid.get(parent.ppid)) {
276 if (labels.has(parent.pid)) return true
277 if (parent.ppid === parent.pid) break
278 }
279
280 return false
281 }
282
283 const children = new Map<number, number[]>()
284 for (const row of rows) children.set(row.ppid, [...(children.get(row.ppid) ?? []), row.pid])
285 // `npm exec jest` names no path; its `jest.js` child names the worktree and app.
286 const labelWithContext = (pid: number): string | undefined => {
287 const own = labels.get(pid)
288 if (own?.endsWith(')')) return own
289 for (const child of children.get(pid) ?? []) {
290 const found = labelWithContext(child)
291 if (found) return found
292 }
293
294 return undefined
295 }
296
297 return rows
298 .filter(row => labels.has(row.pid) && !hasMatchedAncestor(row))
299 .map(row => ({ pid: row.pid, label: labelWithContext(row.pid) ?? labels.get(row.pid)!, elapsedMs: row.elapsedMs }))
300}
301
302// A process and everything under it, leaving out Claude Code's own processes and what runs under them.
303const treeOf = (rows: PsRow[], pid: number, protectedPids: Set<number>) => {
304 const children = new Map<number, number[]>()
305 for (const row of rows) children.set(row.ppid, [...(children.get(row.ppid) ?? []), row.pid])
306 const tree: number[] = []
307 const pending = [pid]
308 while (pending.length > 0) {
309 const next = pending.pop()!
310 if (protectedPids.has(next) || tree.includes(next)) continue
311 tree.push(next)
312 pending.push(...(children.get(next) ?? []))
313 }
314
315 return tree
316}
317
318const median = (values: number[] | undefined): number | null => {
319 if (!values?.length) return null
320 const sorted = [...values].sort((a, b) => a - b)
321
322 return sorted[Math.floor(sorted.length / 2)] ?? null
323}
324
325const formatDuration = (ms: number): string => {
326 const total = Math.round(ms / 1000)
327 const hours = Math.floor(total / 3600)
328 const m = Math.floor((total % 3600) / 60)
329 const s = total % 60
330 if (hours > 0) return `${hours}h${String(m).padStart(2, '0')}m`
331 if (m > 0) return `${m}m${String(s).padStart(2, '0')}s`
332
333 return `${s}s`
334}
335
336const formatMem = (mb: number) => (mb >= 1024 ? `${(mb / 1024).toFixed(1)}G` : `${Math.round(mb)}M`)
337const formatPercent = (value: number) => `${value > 0 && value < 1 ? value.toFixed(1) : Math.round(value)}%`
338const formatPorts = (ports: number[]) => ports.map(port => `:${port}`).join(' ')
339const oneLine = (name: string) => name.replace('\n', ' · ')
340
341const progressBar = (percent: number, cells = 10) => {
342 const filled = Math.round((percent / 100) * cells)
343
344 return '█'.repeat(filled) + '░'.repeat(cells - filled)
345}
346
347const PARTIAL = '▏▎▍▌▋▊▉'
348
349// A horizontal bar in eighths of a cell, the rest of its width shaded.
350const meter = (fraction: number, width: number) => {
351 const cells = Math.max(0, Math.min(1, fraction)) * width
352 const full = Math.floor(cells)
353 const part = Math.round((cells - full) * 8)
354 const tail = part > 0 && full < width ? (PARTIAL[part - 1] ?? '') : ''
355 const drawn = '█'.repeat(full) + tail
356
357 return drawn + '░'.repeat(Math.max(0, width - drawn.length))
358}
359
360const SPARK = '▁▂▃▄▅▆▇█'
361
362const sparkline = (values: number[], width: number) =>
363 values
364 .slice(-width)
365 .map(v => SPARK[Math.max(0, Math.min(7, Math.floor((v / 100) * 8)))])
366 .join('')
367
368const fit = (text: string, width: number) =>
369 text.length > width ? `${text.slice(0, width - 1)}…` : text.padEnd(width, ' ')
370
371const clip = (text: string, width: number) => (text.length > width ? `${text.slice(0, width - 1)}…` : text)
372
373type ChartSize = typeof FULL_CHART
374
375type ColumnLine = { kind: 'label' | 'bar' | 'blank'; before: string; core: string; after: string }
376
377// A string centred in `width`, split so its own text can be styled apart from the padding.
378const centered = (text: string, width: number) => {
379 const core = text.length > width ? `${text.slice(0, width - 1)}…` : text
380 const left = Math.floor((width - core.length) / 2)
381
382 return { before: ' '.repeat(left), core, after: ' '.repeat(width - left - core.length) }
383}
384
385// One column of a chart, top line first: the value just above the bar's top, the bar below it.
386const columnLines = (fraction: number, label: string, size: ChartSize): ColumnLine[] => {
387 const eighths = fraction > 0 ? Math.max(1, Math.round(fraction * size.height * 8)) : 0
388 const filledRows = Math.ceil(eighths / 8)
389
390 return Array.from({ length: size.height + 1 }, (_, index) => size.height - index).map(rowFromBottom => {
391 if (rowFromBottom === filledRows) return { kind: 'label', ...centered(label, size.columnWidth) }
392 if (rowFromBottom > filledRows) return { kind: 'blank', before: ' '.repeat(size.columnWidth), core: '', after: '' }
393 const cell = Math.min(8, eighths - rowFromBottom * 8)
394
395 return { kind: 'bar', ...centered((SPARK[cell - 1] ?? ' ').repeat(size.barWidth), size.columnWidth) }
396 })
397}
398
399const paneIdOf = (group: UsageRow) => `${PANE_PREFIX}${group.id.replace(/[^A-Za-z0-9_-]/g, '-')}`.slice(0, 64)
400
401const loadColor = (percent: number) => (percent >= 80 ? 'error' : percent >= 50 ? 'warning' : 'success')
402
403// Claude's own helpers and the session's own process are listed, never signalled.
404const isSignallable = (group: UsageRow, process: ProcessRow) => group.kind !== 'claude' && !process.isProtected
405
406let lastRows: PsRow[] = []
407let protectedPids = new Set<number>()
408
409// Sends `signal` to a process and everything under it (Ctrl-C, pause or resume), then says what happened.
410const sendSignal = async ($: EngineInterface, pid: number, name: string, signal: Signal) => {
411 const tree = treeOf(lastRows, pid, protectedPids)
412 if (tree.length === 0) return
413 const result = await $.process.run(['kill', `-${signal}`, ...tree.map(String)])
414 const verb = signal === 'INT' ? 'Interrupted' : signal === 'STOP' ? 'Paused' : 'Resumed'
415 const others = tree.length > 1 ? ` and ${tree.length - 1} process${tree.length === 2 ? '' : 'es'} under it` : ''
416 $.ui.toast(result.exitCode === 0 ? `${verb} ${name}${others}` : `Could not signal ${name}: ${result.stderr.trim() || 'it may have ended'}`)
417}
418
419export const register: Register = on => {
420 let history: History = {}
421 let cores = 1
422 let totalMemMb = 0
423 let ownPid = 0
424 let sessions: ClaudeSession[] = []
425 let ports = new Map<number, number[]>()
426 let ciJobs: Job[] = []
427 const ciHistory = new Map<string, { estimateMs: number | null; fetchedAt: number }>()
428 const trackedShells = new Map<number, Tracked>()
429 const trackedAgents = new Map<string, { key: string; firstSeenAt: number }>()
430
431 on('session.start', async ($, e, next) => {
432 const started = await next(e)
433
434 const saveDuration = async (key: string, ms: number) => {
435 if (ms < MIN_RECORD_MS) return
436 history = { ...history, [key]: [...(history[key] ?? []), ms].slice(-HISTORY_SIZE) }
437 await $.store.set(HISTORY_KEY, history)
438 }
439
440 const pollCi = async () => {
441 const runs = await $.process.run(
442 ['gh', 'run', 'list', '--status', 'in_progress', '--limit', '3', '--json', 'databaseId,workflowName,headBranch,startedAt'],
443 { cwd: e.cwd, timeoutMs: 15000 },
444 )
445 if (runs.exitCode !== 0) {
446 ciJobs = []
447 return
448 }
449 const now = await $.clock.now()
450 const parsed = JSON.parse(runs.stdout) as { databaseId: number; workflowName: string; headBranch: string; startedAt: string }[]
451 const jobs: Job[] = []
452 for (const run of parsed) {
453 let cached = ciHistory.get(run.workflowName)
454 if (!cached || now - cached.fetchedAt > CI_HISTORY_TTL_MS) {
455 const past = await $.process.run(
456 ['gh', 'run', 'list', '--workflow', run.workflowName, '--status', 'success', '--limit', '10', '--json', 'startedAt,updatedAt'],
457 { cwd: e.cwd, timeoutMs: 15000 },
458 )
459 const durations =
460 past.exitCode === 0
461 ? (JSON.parse(past.stdout) as { startedAt: string; updatedAt: string }[]).map(
462 r => Date.parse(r.updatedAt) - Date.parse(r.startedAt),
463 )
464 : []
465 cached = { estimateMs: median(durations.filter(d => d > 0)), fetchedAt: now }
466 ciHistory.set(run.workflowName, cached)
467 }
468 jobs.push({
469 id: `ci:${run.databaseId}`,
470 kind: 'ci',
471 label: `${run.workflowName} · ${run.headBranch}`,
472 elapsedMs: now - Date.parse(run.startedAt),
473 estimateMs: cached.estimateMs,
474 })
475 }
476 ciJobs = jobs
477 }
478
479 const poll = async () => {
480 const [ps, pressure, load, now, agents] = await Promise.all([
481 $.process.run(['ps', '-Ao', 'pid=,ppid=,pcpu=,rss=,etime=,stat=,args=']),
482 $.process.run(['memory_pressure']),
483 $.process.run(['sysctl', '-n', 'vm.loadavg']),
484 $.clock.now(),
485 $.agent.list(),
486 ])
487 const rows = parsePs(ps.stdout)
488 const usage = sessionUsage(rows, sessions, ownPid, cores, totalMemMb, ports)
489 lastRows = rows
490 protectedPids = usage.protectedPids
491
492 const shells = workProcesses(rows)
493 const livePids = new Set(shells.map(s => s.pid))
494 for (const [pid, tracked] of trackedShells) {
495 if (!livePids.has(pid)) {
496 trackedShells.delete(pid)
497 await saveDuration(tracked.key, tracked.elapsedMs)
498 }
499 }
500 for (const shell of shells) {
501 trackedShells.set(shell.pid, { key: `shell:${shell.label}`, elapsedMs: shell.elapsedMs })
502 }
503
504 const running = agents.filter(agent => agent.status === 'running')
505 const runningIds = new Set(running.map(agent => agent.id))
506 for (const [id, tracked] of trackedAgents) {
507 if (!runningIds.has(id)) {
508 trackedAgents.delete(id)
509 await saveDuration(tracked.key, now - tracked.firstSeenAt)
510 }
511 }
512 for (const agent of running) {
513 if (!trackedAgents.has(agent.id)) {
514 trackedAgents.set(agent.id, { key: `agent:${agent.type}`, firstSeenAt: now })
515 }
516 }
517
518 const groupTag = (ref: GroupRef | undefined) =>
519 ref && { name: oneLine(ref.name), color: usage.groups.find(group => group.id === ref.id)?.color ?? 'inactive' }
520 const own = usage.groups.find(group => group.kind === 'current')
521 const jobs: Job[] = [
522 ...shells.map(shell => ({
523 id: `shell:${shell.pid}`,
524 kind: 'shell' as const,
525 label: shell.label,
526 group: groupTag(usage.groupOfPid.get(shell.pid)),
527 elapsedMs: shell.elapsedMs,
528 estimateMs: median(history[`shell:${shell.label}`]),
529 })),
530 ...ciJobs,
531 // Subagents are this session's own.
532 ...running.map(agent => {
533 const tracked = trackedAgents.get(agent.id)!
534 return {
535 id: `agent:${agent.id}`,
536 kind: 'agent' as const,
537 label: `${agent.type} · ${agent.description}`,
538 group: own && { name: own.name, color: own.color },
539 elapsedMs: now - tracked.firstSeenAt,
540 estimateMs: median(history[tracked.key]),
541 }
542 }),
543 ]
544
545 const [load1 = 0] = load.stdout.replace(/[{}]/g, '').trim().split(/\s+/).map(Number)
546 const freeMatch = pressure.stdout.match(/free percentage:\s*(\d+)%/)
547 const cpuPercent = Math.min(100, rows.reduce((sum, row) => sum + row.cpu, 0) / cores)
548 const memFreePercent = freeMatch ? Number(freeMatch[1]) : null
549 const { value: previous } = await $.state.get(SNAPSHOT)
550 const fresh: Snapshot = {
551 cpuPercent,
552 load: load1,
553 cores,
554 memFreePercent,
555 totalMemMb,
556 cpuHistory: [...(previous?.cpuHistory ?? []), cpuPercent].slice(-TREND_SAMPLES),
557 memHistory: [...(previous?.memHistory ?? []), memFreePercent === null ? 0 : 100 - memFreePercent].slice(-TREND_SAMPLES),
558 groups: usage.groups,
559 jobs,
560 }
561 await $.state.set(SNAPSHOT, fresh)
562 }
563
564 const pollSessions = async () => {
565 const folder = `${home}/.claude/sessions`
566 const entries = await $.fs.list(folder)
567 const files = await Promise.all(
568 entries
569 .filter(entry => entry.kind === 'file' && entry.name.endsWith('.json'))
570 .map(entry => $.fs.read(`${folder}/${entry.name}`).catch(() => '')),
571 )
572 sessions = parseSessions(files)
573 // This session's own process is the one the session list names with its id.
574 ownPid = sessions.find(session => session.sessionId === ownSessionId)?.pid ?? 0
575 }
576
577 // lsof exits 1 when nothing listens; what it printed is still the answer.
578 const pollPorts = async () => {
579 const listening = await $.process.run(['lsof', '-nP', '-iTCP', '-sTCP:LISTEN', '-F', 'pn'])
580 ports = parsePorts(listening.stdout)
581 }
582
583 const guarded = (label: string, fn: () => Promise<void>) => () => {
584 fn().catch(error => $.ui.log(`session-load ${label}: ${String(error)}`, { to: 'debug' }))
585 }
586
587 history = ((await $.store.get(HISTORY_KEY)) as History | undefined) ?? {}
588 const [ncpu = '1', memsize = '0'] = (await $.process.run(['sysctl', '-n', 'hw.ncpu', 'hw.memsize'])).stdout.trim().split('\n')
589 cores = Number(ncpu) || 1
590 totalMemMb = Number(memsize) / 1024 / 1024 || 0
591 const home = (await $.env.get('HOME')) ?? ''
592 const ownSessionId = await $.session.id()
593 const saved = (await $.store.get(VIEW_KEY)) as ViewMode | undefined
594 await $.state.set(VIEW, saved === 'compact' ? 'compact' : DEFAULT_VIEW)
595 await pollSessions().catch(error => $.ui.log(`session-load sessions: ${String(error)}`, { to: 'debug' }))
596 await pollPorts().catch(error => $.ui.log(`session-load ports: ${String(error)}`, { to: 'debug' }))
597 await $.command
598 .register({
599 name: 'load',
600 description: 'Session Load: verbose or compact band (/load verbose, /load compact)',
601 argumentHint: '[verbose | compact]',
602 immediate: true,
603 })
604 .catch(error => $.ui.log(`session-load /load: ${String(error)}`, { to: 'debug' }))
605
606 const tick = guarded('poll', poll)
607 const ciTick = guarded('ci', pollCi)
608 tick()
609 ciTick()
610 $.clock.every(POLL_MS, tick)
611 $.clock.every(CI_POLL_MS, ciTick)
612 $.clock.every(SESSIONS_POLL_MS, guarded('sessions', pollSessions))
613 $.clock.every(PORTS_POLL_MS, guarded('ports', pollPorts))
614
615 return started
616 })
617
618 on('command.run', { command: 'load' }, async ($, e) => {
619 const asked = e.args.trim()
620 const { value: current = DEFAULT_VIEW } = await $.state.get(VIEW)
621 // `/load` alone switches to the other view.
622 const view: ViewMode = asked === 'verbose' || asked === 'compact' ? asked : current === 'verbose' ? 'compact' : 'verbose'
623 await $.state.set(VIEW, view)
624 await $.store.set(VIEW_KEY, view)
625
626 return { text: `Session Load: ${view} view, kept for every session. /load ${view === 'verbose' ? 'compact' : 'verbose'} switches back.` }
627 })
628
629 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
630 const { value: snap } = await $.state.get(SNAPSHOT)
631 if (e.props.hasSurvey || !snap) {
632 return next(e)
633 }
634 const { value: view = DEFAULT_VIEW } = await $.state.get(VIEW)
635
636 const { Box, Button, Text } = $.ui.resolve(e)
637 const columns = e.props.bodyColumns
638 const isWide = columns >= WIDE_COLUMNS
639 const panelWidth = isWide ? Math.floor((columns - 3) / 2) : columns
640 const memUsed = snap.memFreePercent === null ? null : 100 - snap.memFreePercent
641 const showFull = view === 'verbose'
642 const groups = snap.groups.filter(group => group.cpu > 0 || group.memMb > 0)
643 const leftovers = groups.filter(group => group.kind === 'leftover')
644 const shown = snap.jobs.slice(0, MAX_JOBS)
645
646 const nameOf = (group: UsageRow) => (group.kind === 'current' ? `▸${group.name}` : group.name)
647 // A press on any part of a column opens its process list in a pane at the side, which takes the
648 // focus, so the column pressed is not left highlighted.
649 const open = (group: UsageRow) => () => {
650 void $.state.set(CONFIRMING, null)
651 void $.ui.open({ id: paneIdOf(group), title: oneLine(group.name), focus: true })
652 }
653
654 const cpuTotal = (
655 <Text key="cpu-total" wrap="truncate-end">
656 <Text bold>CPU </Text>
657 <Text color={loadColor(snap.cpuPercent)} bold>
658 {formatPercent(snap.cpuPercent).padStart(4)}
659 </Text>{' '}
660 <Text color={loadColor(snap.cpuPercent)}>{meter(snap.cpuPercent / 100, TOTAL_BAR_WIDTH)}</Text>{' '}
661 <Text color={loadColor(snap.cpuPercent)}>{sparkline(snap.cpuHistory, 12)}</Text>
662 <Text dimColor>
663 {' '}
664 load {snap.load.toFixed(1)}/{snap.cores}
665 </Text>
666 </Text>
667 )
668 const memTotal = (
669 <Text key="mem-total" wrap="truncate-end">
670 <Text bold>MEM </Text>
671 {memUsed === null ? (
672 <Text dimColor> n/a</Text>
673 ) : (
674 <Text color={loadColor(memUsed)} bold>
675 {formatPercent(memUsed).padStart(4)}
676 </Text>
677 )}{' '}
678 <Text color={loadColor(memUsed ?? 0)}>{meter((memUsed ?? 0) / 100, TOTAL_BAR_WIDTH)}</Text>{' '}
679 <Text color={loadColor(memUsed ?? 0)}>{sparkline(snap.memHistory, 12)}</Text>
680 {memUsed !== null && snap.totalMemMb > 0 && (
681 <Text dimColor>
682 {' '}
683 {formatMem((snap.totalMemMb * memUsed) / 100)} of {formatMem(snap.totalMemMb)}
684 </Text>
685 )}
686 </Text>
687 )
688 const totals = (
689 <Box key="totals" flexDirection={isWide ? 'row' : 'column'} columnGap={3}>
690 <Box width={panelWidth}>{cpuTotal}</Box>
691 <Box width={panelWidth}>{memTotal}</Box>
692 </Box>
693 )
694
695 // Under each column: the name, then the session's ports, or the rest of a long name.
696 const labelsOf = (group: UsageRow, columnWidth: number): [string, string] => {
697 const [first = '', second = ''] = nameOf(group).split('\n')
698
699 // The name leaves room for a space and the arrow; the line below needs only the gap.
700 return [clip(first, columnWidth - 3), clip(second || formatPorts(group.ports), columnWidth - 1)]
701 }
702
703 // Columns, largest on the left: the value on top, the name below. Every line of a column is one
704 // press target, and the whole column lights while the pointer is over any of it.
705 const chart = (measure: 'cpu' | 'mem', rows: UsageRow[], value: (group: UsageRow) => number, format: (group: UsageRow) => string, size: ChartSize) => {
706 const count = Math.max(1, Math.floor(panelWidth / size.columnWidth))
707 const picked = [...rows].sort((a, b) => value(b) - value(a)).filter(group => value(group) > 0).slice(0, count)
708 if (picked.length === 0) return <Text key={`none-${measure}`} dimColor> nothing from Claude Code sessions</Text>
709 const max = Math.max(...picked.map(value))
710
711 return (
712 <Box key={`chart-${measure}`} flexDirection="row">
713 {picked.map(group => {
714 const [first, second] = labelsOf(group, size.columnWidth)
715 const lines: ColumnLine[] = [
716 ...columnLines(value(group) / max, format(group), size),
717 { kind: 'label', ...centered(`${first} ›`, size.columnWidth) },
718 { kind: 'blank', ...centered(second, size.columnWidth) },
719 ]
720 // The whole column is one button. Under the pointer it is not inverted into one block:
721 // the bars turn Claude's orange, which shows on light and dark backgrounds, and the value and the name light up as chips.
722 const scope = `column:${measure}:${group.id}`
723 const hoverOf = (kind: ColumnLine['kind']) =>
724 kind === 'bar' ? { scope, color: 'claude' } : kind === 'label' ? { scope, inverse: true, bold: true } : { scope }
725
726 return (
727 <Box key={group.id} width={size.columnWidth}>
728 <Button key={`${measure}:${group.id}`} plain label={oneLine(group.name)} hover={{ scope, inverse: false }} onPress={open(group)}>
729 {lines.map((line, index) => {
730 const end = index < lines.length - 1 ? '\n' : ''
731 return (
732 <Text key={`${measure}:${group.id}:${index}`} color={group.color} dimColor={group.kind === 'claude'}>
733 {line.before}
734 <Text hover={hoverOf(line.kind)}>{line.core}</Text>
735 {line.after}
736 {end}
737 </Text>
738 )
739 })}
740 </Button>
741 </Box>
742 )
743 })}
744 </Box>
745 )
746 }
747
748 const charts = (size: ChartSize) => (
749 <Box key="charts" flexDirection={isWide ? 'row' : 'column'} columnGap={3}>
750 <Box flexDirection="column" width={panelWidth}>
751 {chart('cpu', groups, group => group.cpu, group => formatPercent(group.cpu), size)}
752 </Box>
753 <Box flexDirection="column" width={panelWidth}>
754 {chart('mem', groups, group => group.memMb, group => `${formatMem(group.memMb)} ${formatPercent(group.memShare)}`, size)}
755 </Box>
756 </Box>
757 )
758
759 // What a leftover holds, by process name: `flutter_tester ×4`.
760 const summary = (group: UsageRow) => {
761 const counts = new Map<string, number>()
762 for (const process of group.processes) counts.set(process.name, (counts.get(process.name) ?? 0) + 1)
763 const names = [...counts].map(([name, count]) => (count > 1 ? `${name} ×${count}` : name))
764
765 return `${names.slice(0, 2).join(', ')}${names.length > 2 ? ', …' : ''}, ${formatMem(group.memMb)}${group.ports.length > 0 ? ` ${formatPorts(group.ports)}` : ''}`
766 }
767
768 return (
769 <Box flexDirection="column">
770 <Box flexDirection="column" marginBottom={1}>
771 {totals}
772 {charts(showFull ? FULL_CHART : COMPACT_CHART)}
773 {leftovers.length > 0 && (
774 <Text key="leftovers" color="warning" wrap="truncate-end">
775 {'⚠ Left running after their session ended: '}
776 {leftovers.map(group => `${group.name.split('\n')[1]} (${summary(group)})`).join(' ')}
777 </Text>
778 )}
779 <Text dimColor wrap="truncate-end">
780 {' Click a column › to pause or stop its processes · '}
781 {showFull ? 'Tip: /load compact for a smaller band' : 'Tip: /load verbose for taller charts'}
782 </Text>
783 {shown.length > 0 && (
784 <Text dimColor wrap="truncate-end">
785 {`─── running (${snap.jobs.length}) `.padEnd(columns, '─')}
786 </Text>
787 )}
788 {shown.map(job => {
789 const kind = job.kind === 'ci' ? 'CI ' : job.kind === 'agent' ? 'agent' : 'run '
790 const elapsed = formatDuration(job.elapsedMs)
791 const where = job.group && <Text color={job.group.color}>{job.group.name} · </Text>
792 if (job.estimateMs === null) {
793 return (
794 <Text key={job.id} wrap="truncate-end">
795 <Text color="suggestion">▶ {kind} </Text>
796 {where}
797 {job.label}
798 <Text dimColor> {elapsed} elapsed, no estimate yet (learns after first run)</Text>
799 </Text>
800 )
801 }
802 const percent = Math.min(99, Math.round((job.elapsedMs / job.estimateMs) * 100))
803 const isOver = job.elapsedMs > job.estimateMs
804 const remaining = isOver
805 ? `over by ${formatDuration(job.elapsedMs - job.estimateMs)}`
806 : `~${formatDuration(job.estimateMs - job.elapsedMs)} left`
807
808 return (
809 <Text key={job.id} wrap="truncate-end">
810 <Text color="suggestion">▶ {kind} </Text>
811 <Text color={isOver ? 'warning' : 'success'}>
812 {progressBar(percent)} {String(percent).padStart(2)}%
813 </Text>{' '}
814 {where}
815 {job.label}
816 <Text dimColor>
817 {' '}
818 {elapsed} of ~{formatDuration(job.estimateMs)}, {remaining}
819 </Text>
820 </Text>
821 )
822 })}
823 {snap.jobs.length > shown.length && (
824 <Text dimColor> +{snap.jobs.length - shown.length} more running</Text>
825 )}
826 </Box>
827 {/* what other plugins and the engine draw above the prompt stays, below this band */}
828 {await next(e)}
829 </Box>
830 )
831 })
832
833 // A group's processes, in a pane at the side: Pause or Resume, and Interrupt behind a Yes or No.
834 // The pane draws on a dark background whatever the theme, so every run of text carries a light
835 // colour of its own rather than the theme's, which is dark on a light theme.
836 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
837 if (!e.requestId.startsWith(PANE_PREFIX)) return next(e)
838 const { value: snap } = await $.state.get(SNAPSHOT)
839 const { value: confirming = null } = await $.state.get(CONFIRMING)
840 const { Box, Button, Text } = $.ui.resolve(e)
841 const group = snap?.groups.find(one => paneIdOf(one) === e.requestId)
842 if (!group) return <Text color={PANE_DIM}>Nothing of this session is running now.</Text>
843
844 const width = e.props.bodyColumns
845 const nameWidth = Math.max(16, Math.min(40, width - 48))
846 const ask = (pid: number | null) => () => {
847 void $.state.set(CONFIRMING, pid)
848 }
849
850 return (
851 <Box flexDirection="column">
852 <Text color={group.color} bold wrap="truncate-end">
853 {oneLine(group.name)}
854 </Text>
855 <Text color={PANE_DIM} wrap="truncate-end">
856 CPU {formatPercent(group.cpu)} · MEM {formatMem(group.memMb)} ({formatPercent(group.memShare)} of RAM)
857 {group.ports.length > 0 ? ` · listening on ${formatPorts(group.ports)}` : ''}
858 </Text>
859 <Text color={PANE_DIM}>{'─'.repeat(Math.max(10, width - 2))}</Text>
860 <Text color={PANE_DIM} wrap="truncate-end">
861 {fit('PROCESS', nameWidth)}
862 {'CPU'.padStart(7)}
863 {'MEM'.padStart(8)}
864 {'UP'.padStart(9)}
865 </Text>
866 {group.processes.map(process => {
867 const canSignal = isSignallable(group, process)
868 if (confirming === process.pid && canSignal) {
869 const under = treeOf(lastRows, process.pid, protectedPids).length - 1
870 return (
871 <Box key={`confirm:${process.pid}`} flexDirection="row" columnGap={1}>
872 <Text color={PANE_TEXT} wrap="truncate-end">
873 Interrupt {process.name}
874 {under > 0 ? ` and ${under} process${under === 1 ? '' : 'es'} under it` : ''}? Like Ctrl-C.
875 </Text>
876 <Button
877 key={`yes:${process.pid}`}
878 label="Yes"
879 onPress={() => {
880 void $.state.set(CONFIRMING, null)
881 void sendSignal($, process.pid, process.name, 'INT')
882 }}
883 >
884 <Text color={PANE_ALERT}>Yes</Text>
885 </Button>
886 <Button key={`no:${process.pid}`} label="No" onPress={ask(null)}>
887 <Text color={PANE_TEXT}>No</Text>
888 </Button>
889 </Box>
890 )
891 }
892 const note = process.isPaused ? 'paused' : process.isProtected && group.kind !== 'claude' ? 'the session itself' : formatPorts(process.ports)
893
894 return (
895 <Box key={`proc:${process.pid}`} flexDirection="row" columnGap={1}>
896 <Text color={PANE_TEXT} wrap="truncate-end">
897 {fit(process.name, nameWidth)}
898 {formatPercent(process.cpu).padStart(7)}
899 {formatMem(process.memMb).padStart(8)}
900 {formatDuration(process.elapsedMs).padStart(9)}
901 <Text color={PANE_DIM}>{note ? ` ${note}` : ''}</Text>
902 </Text>
903 {canSignal && (
904 <Button
905 key={`pause:${process.pid}`}
906 label={process.isPaused ? 'Resume' : 'Pause'}
907 onPress={() => {
908 void sendSignal($, process.pid, process.name, process.isPaused ? 'CONT' : 'STOP')
909 }}
910 >
911 <Text color={PANE_TEXT}>{process.isPaused ? 'Resume' : 'Pause'}</Text>
912 </Button>
913 )}
914 {canSignal && (
915 <Button key={`int:${process.pid}`} label="Interrupt" onPress={ask(process.pid)}>
916 <Text color={PANE_ALERT}>Interrupt</Text>
917 </Button>
918 )}
919 </Box>
920 )
921 })}
922 {group.kind === 'claude' && <Text color={PANE_DIM}>Claude Code's own processes are listed here, not paused or interrupted.</Text>}
923 </Box>
924 )
925 })
926}
927types/index.d.ts 67 lines1// One process inside a group, as its pane lists it.
2export type ProcessRow = {
3 pid: number
4 name: string
5 // Share of the whole machine, 0 to 100.
6 cpu: number
7 memMb: number
8 elapsedMs: number
9 isPaused: boolean
10 ports: number[]
11 // The session's own Claude process, its terminal wrapper, or Claude's own helper: never signalled.
12 isProtected: boolean
13}
14
15// One Claude Code session's processes, or a group outside every session.
16export type UsageRow = {
17 // Stable across polls: the pane id and the colour come from it.
18 id: string
19 name: string
20 kind: 'current' | 'session' | 'claude' | 'leftover'
21 color: string
22 // Share of the whole machine, 0 to 100.
23 cpu: number
24 memMb: number
25 // Share of the machine's RAM, 0 to 100.
26 memShare: number
27 ports: number[]
28 processes: ProcessRow[]
29}
30
31export type Job = {
32 id: string
33 kind: 'shell' | 'ci' | 'agent'
34 label: string
35 // The group the work runs in, for shell and agent jobs.
36 group?: { name: string; color: string }
37 elapsedMs: number
38 estimateMs: number | null
39}
40
41export type Snapshot = {
42 cpuPercent: number
43 load: number
44 cores: number
45 memFreePercent: number | null
46 totalMemMb: number
47 // Oldest first, one sample per poll.
48 cpuHistory: number[]
49 memHistory: number[]
50 groups: UsageRow[]
51 jobs: Job[]
52}
53
54// Which layout the band draws.
55export type ViewMode = 'verbose' | 'compact'
56
57declare module 'claude-code' {
58 interface PluginState {
59 'session-load': {
60 machine: Snapshot | null
61 view: ViewMode
62 // The process whose Interrupt waits for Yes or No.
63 confirming: number | null
64 }
65 }
66}
67