SLOPSHOPPER

Session Load

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

newpanebandcommandtoastprocess
v0.2.0MITupdated 2026-10-26chcliu/session-load
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-load
› fix the failing auth test and add an audit log call ● session-load: session-load ci: SyntaxError: JSON Parse error: Unexpected EOF ⏺ 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 › /load ⎿ session-load: Session Load: compact view, kept for every session. /load verbose switches back. CPU 0% ░░░░░░░░░░░░░░░░░░░░ ▁ load 0.0/1 MEM n/a ░░░░░░░░░░░░░░░░░░░░ ▁ nothing from Claude Code sessions nothing from Claude Code sessions Click a column › to pause or stop its processes · Tip: /load verbose for taller charts ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
CPU 0% ░░░░░░░░░░░░░░░░░░░░ ▁ load 0.0/1 MEM n/a ░░░░░░░░░░░░░░░░░░░░ ▁ nothing from Claude Code sessions nothing from Claude Code sessions Click a column › to pause or stop its processes · Tip: /load verbose for taller charts ⟨Claude Code's own drawing⟩
README

Session Load

Session Load icon

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.

What you see

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:

  • Left running (yellow): processes still running after their session ended, grouped by the worktree they ran in.
  • Claude Code's own helpers (grey): the agents view windows, the background job manager, the spare session kept ready for your next job, and the Claude Mac app.

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.

A session's processes: pause and interrupt

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.

  • Pause / Resume freezes a process and everything under it (to free the CPU) and later lets it continue where it was. A paused process keeps its memory, and a command an agent is waiting on can time out while paused.
  • Interrupt stops a process and everything under it the way Ctrl-C does. It asks Yes or No first.

The Claude Code session itself, its terminal wrapper and Claude Code's own helpers are listed but never paused or interrupted.

Commands

  • /load compact or /load verbose picks a view; /load alone switches to the other one.
  • The choice is kept for every session. A new install starts verbose.

Install

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.

Requirements

  • macOS. It reads CPU and memory with macOS commands (memory_pressure, sysctl). On other systems those commands are missing and the band does not appear.
  • Claude Code 2.1.309 or later. It was built and tested on 2.1.309.
  • The GitHub CLI (gh), signed in, for the CI rows. Without it, everything else still works and the CI rows stay empty.

What it runs, reads and sends

Everything happens on your machine. It runs these commands, as you, and reads only their output:

CommandWhenWhy
ps -Ao pid=,ppid=,pcpu=,rss=,etime=,stat=,args=every 3 secondsCPU, memory, state and command line of every process, to group them by session and find running tests and builds
memory_pressureevery 3 secondsthe machine's free memory
sysctl -n vm.loadavgevery 3 secondsload average
sysctl -n hw.ncpu hw.memsizeonce per sessionthe number of cores and the size of RAM
lsof -nP -iTCP -sTCP:LISTEN -F pnevery 15 secondswhich processes listen on which local ports, to show each session's servers
gh run list --status in_progress ... in the session's folderevery 30 secondsGitHub Actions runs in progress for that repository
gh run list --workflow <name> --status success ...at most every 30 minutes per workflowhow long that workflow's last 10 successful runs took, for the estimate
kill -STOP, kill -CONT or kill -INT with process IDsonly when you press Pause, Resume, or Interrupt and then Yesto 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:

  • every 15 seconds, the JSON files in ~/.claude/sessions: Claude Code's list of running sessions (process ID, session ID, folder and name), to name each session
  • once per session, $HOME and this session's ID, to find that folder and mark your own session ▸
  • the session's running subagents

What it keeps:

  • In Claude Code's own plugin storage on your machine: how long each command and subagent type took on its last 10 runs (the command text and durations), used for the estimates.
  • In the same plugin storage: which view you chose with /load.
  • For the session: the latest readings and the last 90 seconds of CPU and memory.

What it sends, and where

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.

Limits

  • It recognises sessions from ~/.claude/sessions and Claude Code's process names, which are not a documented interface. A Claude Code update can change them.
  • Estimates come from earlier runs of the same command, so the first run of a command shows only elapsed time. Failed runs count toward the estimate too.
  • The claude agents screen itself can't show mods. The band appears inside each session, including one you open from the agents view.
  • Outside fullscreen mode, the band's buttons are reached with the keyboard (ctrl+x tab, then Tab and Enter), not the mouse.

License

MIT

Source 2 files
hooks/register.tsx 927 lines
1import 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}
927
types/index.d.ts 67 lines
1// 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