Track the processes Claude Code sessions and worktrees spawn, and lay their zombies to rest.

A Claude Code mod that keeps a pixel-art graveyard of the processes your Claude Code sessions and worktrees spawned, and lays their zombies to rest.

Run /rest-in-pid to open the board:
While zombies exist, a band above the prompt counts them and opens the board, and a toast announces each new one.

A process belongs to a live session when it descends from that session's host process, or when it inherited the host's CLAUDE_PID and that host was already running when it started (a process that reused the pid since starts later). The session id decides nothing there, since /clear gives a running session a new one. An MCP server inherits CLAUDE_CODE_SESSION_ID alone, so it belongs to the running session holding that id. It is a zombie when:
.claude/worktrees/<name> folder, and no running session works there or in that repository's main checkout;What a live session runs inside, such as its terminal, a tmux server or an editor, is never a zombie, even when a session that has since ended started it. Nor are the OS's and apps' own binaries, apart from the toolchains kept among them: JDKs under /Library/Java, Xcode's developer tools, and /usr/lib/jvm on Linux.
Claude Code's retention sweep deletes shell snapshots older than cleanupPeriodDays (30 by default) whether their session still runs or not, so only a younger snapshot's absence counts.
macOS hides the environment of its own binaries (/bin/zsh, /usr/bin/tail and the like), so a process with no evidence of its own follows its process group: each Bash tool command runs in a group of its own, and a member goes with a sibling that is a zombie, unless the group also holds something live or not yours. Linux shows every process's environment in /proc, so there this matters less. Only an orphan, alone or under other leftovers, can be a zombie: one launchd adopted on macOS, or init or the user's systemd on Linux. A shell in your own terminal or editor never is.
What it cannot see: a lone macOS binary whose session left no other trace, such as sleep 600 & with no sibling, or a shell loop from a session that crashed, since a crash leaves the snapshot behind.
Its descendants are part of its tree, and every process in it counts. A <defunct> (Unix zombie) process counts too but is never signalled: it is already dead, and goes once its parent does.
Live sessions come from sessions/<pid>.json in Claude Code's folder (~/.claude, or CLAUDE_CONFIG_DIR when it is set). An entry vouches for its pid only while the process start time matches, so a reused pid never revives a dead session. A Claude Code process with no entry still counts as live, so its children are never mistaken for orphans.
ps and keeps only the pids that still have the same start time and command, belong to you, and are not a session host or this session.SIGTERM to the tree, deepest processes first.SIGKILL, after the same check, to whatever survived and to anything that rose meanwhile in a process group it signalled, such as a child a dying process spawned.Docker containers are not host processes and are out of scope.
Everything stays on your machine: the mod makes no network requests.
ps for the process list, lsof for a leftover's working folder, sysctl for the core count and memory, /bin/sh with id and uname for its own session, user and system, find, dd and head to read session transcripts from where the last scan stopped, and kill only when you press Kill. On Linux, /bin/sh with tr and readlink reads /proc/<pid>/environ and /proc/<pid>/cwd in place of ps eww and lsof, and getconf with /proc/meminfo stands in for sysctl.sessions/*.json in Claude Code's folder for the running sessions, and each session's transcript under its projects, keeping only the token usage of each response. From Claude Code's settings it reads cleanupPeriodDays alone. For processes outside every live session it reads the environment, keeping only CLAUDE_PID and CLAUDE_CODE_SESSION_ID, and checks whether their worktree folder or shell snapshot still exists.Run these two commands in a terminal (your shell, not a Claude Code prompt):
claude plugin marketplace add jwchang0206/rest-in-pid
claude plugin install rest-in-pid@jwchang0206
The plugin then loads in Claude Code in the terminal and in the Code tab of the Desktop app, which read the same settings. Start a new session, or run /reload-plugins in one that was already open. A new release arrives once the version in plugin.json goes up (CHANGES.md says what each one changed). To take it:
claude plugin marketplace update jwchang0206
claude plugin update rest-in-pid@jwchang0206
Mods need Claude Code 2.1.287 or later in the terminal, or 2.1.286 in the Desktop app.
Runs on macOS and Linux, not Windows. On Linux it needs the procps ps (busybox's lacks the columns), and CPU is each process's average since it started, which is what procps reports, rather than the last few seconds.
For one session from a checkout: claude --plugin-dir ./rest-in-pid.
claude plugin validate .
claude plugin test .
hooks/model.ts holds the parsing and classification as pure functions, which tests/model.test.ts covers. tests/scan.test.ts checks how a scan reads transcripts and remembers environments, and tests/pane.test.tsx drives the whole mod with fake ps output on the terminal and desktop surfaces.
hooks/register.tsx 510 lines1import { atom, read, update } from 'claude-code'
2import type { Elements, Register, RenderElement, RenderSurface } from 'claude-code'
3
4import type { Proc, SessionGroup, Snapshot, Zombie } from '../types'
5import { count, killOrder, laidToRest, memory, stillSame, zombieTotal } from './model'
6import { clip, header, icons, NIGHT, note, notice, pill, screen, strip, table as processTable } from './panels'
7import type { Drawing, Line } from './panels'
8import { contextOf, readPs, scan } from './scan'
9import type { Context, Envs, Io, Readings } from './scan'
10import { raster, title } from './sprites'
11import type { Sprite } from './sprites'
12
13const PANE = 'rest-in-pid'
14const TICK_MS = 5_000
15/** While the pane is closed only every SLOW_TICKS-th tick scans: enough to keep the status line honest. */
16const SLOW_TICKS = 6
17const ARMED_MS = 4_000
18/** How long SIGTERM gets before SIGKILL follows. */
19const GRACE_MS = 2_000
20const ROWS_SHOWN = 8
21/** A card's right-hand column, for its button or badge: one width, so every card's text lines up. */
22const ACTION_CELLS = 12
23/** Card border and padding, in cells. */
24const CARD_CHROME = 4
25/** Roughly how many CSS pixels a desktop draws per cell; only sizes drawings, which shrink to fit. */
26const CELL_PX = 8
27const TERMINAL_SEGMENTS = 16
28
29const snapshot = atom({ plugin: 'rest-in-pid', key: 'snapshot' } as const, null)
30const isArmed = atom({ plugin: 'rest-in-pid', key: 'isArmed' } as const, false)
31const killing = atom({ plugin: 'rest-in-pid', key: 'killing' } as const, [])
32/** False once the surface would not seat the pane: the command's own row then draws the board. */
33const isPlaced = atom({ plugin: 'rest-in-pid', key: 'isPlaced' } as const, true)
34/** Which sections are open. Both start closed; a press on a section's heading opens it. */
35const zombiesOpen = atom({ plugin: 'rest-in-pid', key: 'zombiesOpen' } as const, false)
36const sessionsOpen = atom({ plugin: 'rest-in-pid', key: 'sessionsOpen' } as const, false)
37/** The zombie count alone, for the band: it redraws when the count moves, not on every scan. */
38const zombieCount = atom({ plugin: 'rest-in-pid', key: 'zombieCount' } as const, 0)
39
40/** Pastel windows: a rose one for each zombie, a lavender one for each session. */
41const CARD = {
42 zombie: { borderColor: '#f2a7bf', backgroundColor: '#fff3f6' },
43 session: { borderColor: '#cdc0f2', backgroundColor: '#f8f5ff' },
44}
45
46type Common = Pick<Elements['desktop'], 'Box' | 'Text' | 'Button'>
47type Table = Elements[keyof Elements]
48/** How a surface draws the board's parts: drawings where it has Svg, padded text where it is a terminal. */
49type Kit = {
50 screen: (snap: Snapshot) => RenderElement
51 /** A section's heading, which opens and closes it. */
52 toggle: (section: Section, label: string, isOpen: boolean) => RenderElement
53 /** The heading that stands in for the zombies when there are none: nothing to open. */
54 quiet: (label: string) => RenderElement
55 note: (message: string, tone: 'soft' | 'alarm') => RenderElement
56 header: (icon: Sprite, title: string, subtitle: string) => RenderElement
57 table: (lines: readonly Line[], more: number) => RenderElement
58 pill: (status: string) => RenderElement
59}
60type Section = 'zombies' | 'sessions'
61type View = {
62 snap: Snapshot | null
63 isArmed: boolean
64 killing: readonly number[]
65 open: Readonly<Record<Section, boolean>>
66}
67type Actions = {
68 refresh: () => Promise<void>
69 kill: (roots: number[]) => Promise<void>
70 killAll: () => Promise<void>
71 toggle: (section: Section) => Promise<void>
72}
73
74const idle = async () => {}
75const IDLE: Actions = { refresh: idle, kill: idle, killAll: idle, toggle: idle }
76
77const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null
78/** Which section a press region asked to toggle, if the message is one of theirs. */
79const sectionOf = (data: unknown): Section | null =>
80 isRecord(data) && (data.toggle === 'zombies' || data.toggle === 'sessions') ? data.toggle : null
81
82const plural = (n: number, one: string, many: string): string => `${n} ${n === 1 ? one : many}`
83const keyOf = (zombie: Zombie): string => `${zombie.root.pid}:${zombie.root.started}`
84const share = (part: number, whole: number): number => (whole > 0 ? part / whole : 0)
85/** Whether a scan found nothing new: writing an equal snapshot would only redraw the board for nothing. */
86const isSameScan = (a: Snapshot, b: Snapshot): boolean =>
87 JSON.stringify({ ...a, scannedAt: 0 }) === JSON.stringify({ ...b, scannedAt: 0 })
88const empty = (scannedAt: number): Snapshot => ({
89 scannedAt,
90 groups: [],
91 zombies: [],
92 totals: { procs: 0, cpu: 0, rssKb: 0, tokensIn: 0, tokensOut: 0 },
93 machine: { cores: 0, memoryKb: 0 },
94 error: null,
95})
96
97function origin(zombie: Zombie): string {
98 switch (zombie.reason) {
99 case 'session-ended':
100 return 'Session ended'
101 case 'no-live-session':
102 return 'No session left'
103 case 'worktree-deleted':
104 return 'Worktree deleted'
105 }
106}
107
108const lineOf = (proc: Proc, depth: number): Line => ({
109 pid: proc.pid,
110 cpu: proc.isDefunct ? '-' : `${proc.cpu.toFixed(1)}%`,
111 mem: proc.isDefunct ? '-' : memory(proc.rssKb),
112 command: `${' '.repeat(Math.max(0, depth - 1))}${depth > 0 ? '└ ' : ''}${proc.label}${proc.isDefunct ? ' (defunct)' : ''}`,
113 isFaint: proc.isDefunct,
114})
115
116function zombieCard(els: Common, kit: Kit, zombie: Zombie, view: View, act: Actions): RenderElement {
117 const { Box, Button } = els
118 const { root, children } = zombie
119 const procs = [root, ...children]
120 const shown = procs.slice(0, ROWS_SHOWN)
121 return (
122 <Box key={`zombie-${root.pid}`} flexDirection="column" borderStyle="round" {...CARD.zombie} paddingX={1}>
123 <Box flexDirection="row" justifyContent="space-between" alignItems="center">
124 {kit.header(icons.zombie, zombie.session?.name ?? 'Unknown session', `${origin(zombie)} · ${zombie.place}`)}
125 <Box width={ACTION_CELLS} justifyContent="flex-end">
126 {view.killing.includes(root.pid) ? (
127 kit.pill('killing')
128 ) : (
129 <Button key={`kill-${root.pid}`} label="🪦 Kill" onPress={() => void act.kill([root.pid])} />
130 )}
131 </Box>
132 </Box>
133 {kit.table(shown.map(proc => lineOf(proc, proc.depth)), procs.length - shown.length)}
134 </Box>
135 )
136}
137
138function sessionCard(els: Common, kit: Kit, group: SessionGroup): RenderElement {
139 const { Box } = els
140 const { session, procs } = group
141 const shown = procs.slice(0, ROWS_SHOWN)
142 const subtitle = `${session.place} · ${count(session.tokensIn)} / ${count(session.tokensOut)} tokens`
143 return (
144 <Box key={`session-${session.pid}`} flexDirection="column" borderStyle="round" {...CARD.session} paddingX={1}>
145 <Box flexDirection="row" justifyContent="space-between" alignItems="center">
146 {kit.header(icons.terminal, session.name, subtitle)}
147 <Box width={ACTION_CELLS} justifyContent="flex-end">
148 {kit.pill(session.status)}
149 </Box>
150 </Box>
151 {procs.length > 0 &&
152 kit.table(shown.map(proc => lineOf(proc, proc.depth)), procs.length - shown.length)}
153 </Box>
154 )
155}
156
157function board(els: Common, kit: Kit, view: View, act: Actions): RenderElement {
158 const { Box, Button } = els
159 const { snap, open } = view
160 if (snap === null) return kit.note('Digging up processes…', 'soft')
161 const n = zombieTotal(snap.zombies)
162 return (
163 <Box flexDirection="column" gap={1}>
164 {kit.screen(snap)}
165 {snap.error !== null && kit.note(snap.error, 'alarm')}
166 <Box flexDirection="row" justifyContent="space-between" alignItems="center">
167 {n > 0 ? kit.toggle('zombies', `ZOMBIES · ${n}`, open.zombies) : kit.quiet('ALL QUIET · NO ZOMBIES')}
168 {n > 0 && (
169 <Button
170 key="kill-all"
171 label={view.isArmed ? `🪦 Really kill all ${n}?` : '🪦 Kill all'}
172 onPress={() => void act.killAll()}
173 />
174 )}
175 </Box>
176 {open.zombies && snap.zombies.map(zombie => zombieCard(els, kit, zombie, view, act))}
177 <Box flexDirection="row">
178 {kit.toggle('sessions', `SESSIONS · ${snap.groups.length}`, open.sessions)}
179 </Box>
180 {open.sessions && snap.groups.map(group => sessionCard(els, kit, group))}
181 </Box>
182 )
183}
184
185/**
186 * The kit for the surface drawing. Decided by `surface`, never by probing the
187 * element table: a table answers `in` for elements its surface cannot draw.
188 */
189function kitOf(surface: RenderSurface, table: Table, columns: number, act: Actions): Kit {
190 const cardPx = Math.max(160, (columns - CARD_CHROME) * CELL_PX)
191 if (surface !== 'terminal' && 'Svg' in table) {
192 const { Svg } = table
193 // At its own size: an Svg given none stretches to its slot, text and all.
194 const fixed = (drawing: Drawing) => (
195 <Svg source={drawing.source} width={drawing.width} height={drawing.height} alt={drawing.alt} />
196 )
197 return {
198 // The screen alone fills the pane's width, scaling as one picture.
199 screen: snap => {
200 const picture = screen(snap)
201 return <Svg source={picture.source} alt={picture.alt} />
202 },
203 toggle: (section, label, isOpen) => {
204 const heading = fixed(strip(section, label, isOpen ? 'open' : 'closed'))
205 if (surface !== 'desktop' || !('Client' in table)) {
206 const { Button } = table
207 return <Button key={`toggle-${section}`} label={`${isOpen ? '⌃' : '⌄'} ${label}`} onPress={() => void act.toggle(section)} />
208 }
209 // The drawn strip with a see-through region over it that takes the press (./press.tsx).
210 const { Box, Client } = table
211 return (
212 <Box key={`toggle-${section}`} position="relative">
213 {heading}
214 <Box position="absolute" top={0} left={0} right={0} bottom={0}>
215 <Client key={`press-${section}`} module="./press.tsx" props={{ section }} width="100%" height="100%" />
216 </Box>
217 </Box>
218 )
219 },
220 quiet: label => fixed(strip('quiet', label, null)),
221 note: (message, tone) => fixed(note(message, tone)),
222 header: (icon, title, subtitle) => fixed(header(icon, title, subtitle, cardPx - ACTION_CELLS * CELL_PX)),
223 table: (lines, more) => fixed(processTable(lines, more, cardPx)),
224 pill: status => fixed(pill(status)),
225 }
226 }
227 const { Box, Text, Button } = table
228 const width = Math.max(20, columns - CARD_CHROME)
229 const bar = (fraction: number) => {
230 const lit =
231 fraction <= 0 ? 0 : Math.max(1, Math.min(TERMINAL_SEGMENTS, Math.round(fraction * TERMINAL_SEGMENTS)))
232 return '▰'.repeat(lit) + '▱'.repeat(TERMINAL_SEGMENTS - lit)
233 }
234 return {
235 screen: snap => {
236 const { totals, machine } = snap
237 const cells = raster(title())
238 return (
239 <Box flexDirection="column">
240 {'Raster' in table && columns >= cells.columns ? (
241 <table.Raster key="title" {...cells} />
242 ) : (
243 <Text bold color={NIGHT.value}>
244 REST IN PID
245 </Text>
246 )}
247 <Text>{`SESSIONS ${snap.groups.length} PROCESSES ${totals.procs} ZOMBIES ${zombieTotal(snap.zombies)}`}</Text>
248 <Text>{`CPU ${bar(share(totals.cpu, machine.cores * 100))} ${totals.cpu.toFixed(1)}%`}</Text>
249 <Text>{`MEM ${bar(share(totals.rssKb, machine.memoryKb))} ${memory(totals.rssKb)}`}</Text>
250 <Text dimColor>{`TOKENS ${count(totals.tokensIn)} / ${count(totals.tokensOut)}`}</Text>
251 </Box>
252 )
253 },
254 toggle: (section, label, isOpen) => (
255 <Button key={`toggle-${section}`} label={`${isOpen ? '⌃' : '⌄'} ${label}`} onPress={() => void act.toggle(section)} />
256 ),
257 quiet: label => <Text bold>{label}</Text>,
258 note: (message, tone) => (tone === 'alarm' ? <Text color="error">{message}</Text> : <Text dimColor>{message}</Text>),
259 header: (_icon, title, subtitle) => (
260 <Box flexDirection="column">
261 <Text bold>{clip(title, width - ACTION_CELLS)}</Text>
262 <Text dimColor>{clip(subtitle, width - ACTION_CELLS)}</Text>
263 </Box>
264 ),
265 table: (lines, more) => (
266 <Box flexDirection="column">
267 <Text dimColor>{`${'PID'.padStart(6)} ${'CPU'.padStart(6)} ${'MEM'.padStart(8)} COMMAND`}</Text>
268 {lines.map(line => (
269 <Text dimColor={line.isFaint}>
270 {clip(`${String(line.pid).padStart(6)} ${line.cpu.padStart(6)} ${line.mem.padStart(8)} ${line.command}`, width)}
271 </Text>
272 ))}
273 {more > 0 && <Text dimColor>{`${' '.repeat(28)}+ ${more} more`}</Text>}
274 </Box>
275 ),
276 pill: status => <Text bold>{status}</Text>,
277 }
278}
279
280export const register: Register = on => {
281 // Built by session.start around its `$`: the engine takes `$` only spelled out at a call site.
282 let actions: Actions | undefined
283
284 on('session.start', async ($, e, next) => {
285 await $.command.register({
286 name: 'rest-in-pid',
287 description: 'Show the processes your Claude Code sessions spawned, and lay their zombies to rest',
288 })
289 const io: Io = {
290 run: (argv, init) => $.process.run(argv, init),
291 exists: path => $.fs.exists(path),
292 list: path => $.fs.list(path),
293 read: path => $.fs.read(path),
294 stat: path => $.fs.stat(path),
295 home: () => $.env.get('HOME'),
296 configDir: () => $.env.get('CLAUDE_CONFIG_DIR'),
297 settings: () => $.settings.read(),
298 now: () => $.clock.now(),
299 }
300 const readings: Readings = new Map()
301 const envs: Envs = new Map()
302 // Earlier versions pinned a status line; the band above the prompt says it now.
303 $.ui.status(undefined)
304 let context: Context | undefined
305 let scanning: Promise<Snapshot> | undefined
306 let ticks = 0
307 let known: ReadonlySet<string> | undefined
308
309 const announce = (snap: Snapshot) => {
310 const before = known
311 known = new Set(snap.zombies.map(keyOf))
312 const risen = before === undefined ? [] : snap.zombies.filter(zombie => !before.has(keyOf(zombie)))
313 const first = risen[0]
314 if (first === undefined) return
315 const total = zombieTotal(risen)
316 $.ui.toast(
317 total === 1
318 ? `A zombie rose in ${first.place}: ${first.root.label}. /rest-in-pid`
319 : `${total} zombies rose. /rest-in-pid`,
320 )
321 }
322
323 /** One scan at a time: a caller arriving mid-scan shares it, so two never read a transcript at once. */
324 const scanNow = (self: Context): Promise<Snapshot> =>
325 (scanning ??= scan(io, self, readings, envs).finally(() => {
326 scanning = undefined
327 }))
328
329 const refresh = async () => {
330 if (context === undefined) return
331 try {
332 const found = await scanNow(context)
333 const previous = await read($, snapshot)
334 if (previous === null || !isSameScan(previous, found)) await update($, snapshot, () => found)
335 const total = zombieTotal(found.zombies)
336 if ((await read($, zombieCount)) !== total) {
337 await update($, zombieCount, () => total)
338 }
339 announce(found)
340 } catch (error) {
341 const message = `Scan failed: ${error instanceof Error ? error.message : String(error)}`
342 const at = await $.clock.now()
343 await update($, snapshot, previous => ({ ...(previous ?? empty(at)), error: message }))
344 }
345 }
346
347 /** Signals what still runs of these zombies' trees, each pid checked against `ps` once more right before. */
348 const signal = async (self: Context, found: Snapshot, zombies: readonly Zombie[], kind: '-TERM' | '-KILL') => {
349 const hostPids = found.groups.flatMap(group => (group.session.pid === null ? [] : [group.session.pid]))
350 const guard = { selfUid: self.selfUid, protectedPids: new Set([self.selfHostPid, ...hostPids]) }
351 const targets = stillSame(killOrder(zombies), await readPs(io, self.os), guard)
352 if (targets.length > 0) await $.process.run(['/bin/kill', kind, ...targets.map(String)])
353 return targets
354 }
355
356 const kill = async (roots: number[]) => {
357 const self = context
358 const board = await read($, snapshot)
359 if (self === undefined || board === null) return
360 // The board can be seconds old: judge again, so each tree is taken as it runs now, with the
361 // children it spawned since and without what has come to life since.
362 const chosen = new Set(board.zombies.filter(zombie => roots.includes(zombie.root.pid)).map(keyOf))
363 const found = await scanNow(self)
364 const doomed = found.zombies.filter(zombie => chosen.has(keyOf(zombie)))
365 const termed = await signal(self, found, doomed, '-TERM')
366 if (termed.length === 0) {
367 $.ui.toast('Those zombies were already gone.')
368 await refresh()
369 return
370 }
371 await update($, killing, list => [...new Set([...list, ...roots])])
372 $.clock.after(GRACE_MS, () => {
373 void (async () => {
374 try {
375 // What outlived SIGTERM, and what a dying process spawned: it rose since, in a group just signalled.
376 const signalled = doomed
377 .flatMap(zombie => [zombie.root, ...zombie.children])
378 .filter(proc => termed.includes(proc.pid))
379 const outlived = new Set(signalled.map(proc => `${proc.pid}:${proc.started}`))
380 const groups = new Set(signalled.map(proc => proc.pgid))
381 const before = new Set(found.zombies.map(keyOf))
382 const later = await scanNow(self)
383 const stubborn = later.zombies.filter(
384 zombie => outlived.has(keyOf(zombie)) || (!before.has(keyOf(zombie)) && groups.has(zombie.root.pgid)),
385 )
386 const killed = await signal(self, later, stubborn, '-KILL')
387 const cleared = laidToRest([...doomed, ...stubborn], new Set([...termed, ...killed]))
388 $.ui.toast(`Killed ${plural(cleared, 'zombie', 'zombies')}`)
389 } finally {
390 await update($, killing, list => list.filter(pid => !roots.includes(pid)))
391 await refresh()
392 }
393 })()
394 })
395 }
396
397 const killAll = async () => {
398 if (!(await read($, isArmed))) {
399 await update($, isArmed, () => true)
400 $.clock.after(ARMED_MS, () => void update($, isArmed, () => false))
401 return
402 }
403 await update($, isArmed, () => false)
404 const snap = await read($, snapshot)
405 await kill(snap?.zombies.map(zombie => zombie.root.pid) ?? [])
406 }
407
408 // One write per atom: the engine lists the state a module writes from its source, so each is named outright.
409 const toggle = async (section: Section) => {
410 if (section === 'zombies') await update($, zombiesOpen, isOpen => !isOpen)
411 else await update($, sessionsOpen, isOpen => !isOpen)
412 }
413
414 actions = { refresh, kill, killAll, toggle }
415 try {
416 context = await contextOf(io)
417 } catch (error) {
418 const message = `Could not start: ${error instanceof Error ? error.message : String(error)}`
419 const at = await $.clock.now()
420 await update($, snapshot, () => ({ ...empty(at), error: message }))
421 }
422 $.clock.every(TICK_MS, () => {
423 ticks += 1
424 void (async () => {
425 const isOpen = (await $.ui.panes()).some(pane => pane.id === PANE)
426 if (isOpen || ticks % SLOW_TICKS === 0) await refresh()
427 })()
428 })
429 $.clock.after(1_000, () => void refresh())
430 return next(e)
431 })
432
433 on('command.run', { command: 'rest-in-pid' }, async $ => {
434 const opened = await $.ui.open({ id: PANE, title: 'Rest in PID' })
435 await update($, isPlaced, () => opened.isPlaced)
436 await actions?.refresh()
437 const snap = await read($, snapshot)
438 const found =
439 snap === null
440 ? 'Still digging.'
441 : `${plural(zombieTotal(snap.zombies), 'zombie', 'zombies')}, ${plural(snap.totals.procs, 'process', 'processes')} across ${plural(snap.groups.length, 'session', 'sessions')}.`
442 return {
443 text: opened.isPlaced
444 ? `Rest in PID is open. ${found}`
445 : `Rest in PID could not place its pane (${opened.reason}). ${found}`,
446 }
447 })
448
449 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
450 const view: View = {
451 snap: await read($, snapshot),
452 isArmed: await read($, isArmed),
453 killing: await read($, killing),
454 open: { zombies: await read($, zombiesOpen), sessions: await read($, sessionsOpen) },
455 }
456 const table = $.ui.resolve(e)
457 return board(table, kitOf(e.surface, table, e.props.bodyColumns, actions ?? IDLE), view, actions ?? IDLE)
458 })
459
460 on('ui.render', { component: 'CommandOutput', props: { command: 'rest-in-pid' } }, async ($, e, next) => {
461 if (await read($, isPlaced)) return next(e)
462 const view: View = {
463 snap: await read($, snapshot),
464 isArmed: await read($, isArmed),
465 killing: await read($, killing),
466 open: { zombies: await read($, zombiesOpen), sessions: await read($, sessionsOpen) },
467 }
468 const table = $.ui.resolve(e)
469 return board(table, kitOf(e.surface, table, e.viewport?.columns ?? 80, actions ?? IDLE), view, actions ?? IDLE)
470 })
471
472 // A press on a section's drawn heading arrives from its see-through region (./press.tsx).
473 on('ui.message', async (_, e, next) => {
474 const section = sectionOf(e.data)
475 if (!e.element.startsWith('press-') || section === null) return next(e)
476 await actions?.toggle(section)
477 return {}
478 })
479
480 // The way in once something rises: the status line is text alone, this band draws the zombie.
481 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
482 const zombies = await read($, zombieCount)
483 if (zombies === 0 || e.props.hasSurvey) return next(e)
484 const surface = $.ui.resolve(e)
485 const { Box, Text, Button } = surface
486 const message = `${plural(zombies, 'zombie', 'zombies')} left behind by ended sessions`
487 const line = notice(message)
488 return (
489 <Box flexDirection="row" justifyContent="space-between" alignItems="center">
490 {e.surface !== 'terminal' && 'Svg' in surface ? (
491 <surface.Svg source={line.source} width={line.width} height={line.height} alt={line.alt} />
492 ) : (
493 <Text color="error">{message}</Text>
494 )}
495 <Button
496 key="open-graveyard"
497 label="Open graveyard"
498 onPress={() =>
499 void (async () => {
500 const opened = await $.ui.open({ id: PANE, title: 'Rest in PID', focus: true })
501 await update($, isPlaced, () => opened.isPlaced)
502 if (!opened.isPlaced) $.ui.toast(`The pane cannot open here (${opened.reason}). Run /rest-in-pid instead.`)
503 })()
504 }
505 />
506 </Box>
507 )
508 })
509}
510hooks/model.ts 602 lines1import type { Proc, Session, SessionGroup, Totals, Zombie, ZombieReason } from '../types'
2
3export type PsRow = {
4 pid: number
5 ppid: number
6 /** Process group: each Bash tool command's shell leads one, and its children stay in it. */
7 pgid: number
8 uid: number
9 stat: string
10 cpu: number
11 rssKb: number
12 /** lstart with its whitespace squashed; UTC when `ps` ran with TZ=UTC. */
13 started: string
14 command: string
15}
16
17/** A `~/.claude/sessions/<pid>.json` entry. The file can outlive a crashed session. */
18export type HostEntry = {
19 pid: number
20 sessionId: string
21 cwd: string
22 name: string
23 status: string
24 procStart: string
25}
26
27/**
28 * What a process inherited from the Claude Code session that spawned it: a Bash tool's
29 * child gets the host's pid, an MCP server the session id alone.
30 */
31export type ProcEnv = { claudePid: number; sessionId: string | null } | { claudePid: null; sessionId: string }
32
33export type Scan = {
34 rows: PsRow[]
35 entries: HostEntry[]
36 env: Map<number, ProcEnv>
37 cwd: Map<number, string>
38 missingWorktrees: Set<string>
39 missingSnapshots: Set<string>
40 selfUid: number
41 os: Os
42}
43
44export type Tally = { seen: Set<string>; tokensIn: number; tokensOut: number }
45
46type Guard = { selfUid: number; protectedPids: ReadonlySet<number> }
47
48type Host = { pid: number; cwd: string; session: Session }
49type Item = { row: PsRow; depth: number }
50type Claim =
51 | { kind: 'live'; hostPid: number }
52 | { kind: 'zombie'; reason: ZombieReason; sessionId: string | null; dir: string }
53
54/** The systems the mod reads. Windows has no ps, process groups or /proc. */
55export type Os = 'darwin' | 'linux'
56
57/**
58 * What differs between the systems: which binaries are the OS's or an app's
59 * (never a session's child, never ours to signal), the toolchains among them
60 * that sessions do run, whose environment the OS hides, and who adopts an orphan.
61 */
62type Rules = { system: RegExp; toolchain: RegExp; hiddenEnv: RegExp | null; isReaper: (row: PsRow) => boolean }
63/** The user's systemd, wherever it is installed: NixOS keeps it in the store. */
64const SYSTEMD_USER = /^\S*\/systemd --user\b/
65const RULES: Record<Os, Rules> = {
66 darwin: {
67 system: /^\/(System|Applications|Library|usr\/libexec|usr\/sbin|sbin)\//,
68 toolchain:
69 /^\/(Library\/Java|Library\/Developer\/CommandLineTools|Library\/Frameworks\/Python\.framework|Applications\/Xcode[^/]*\.app\/Contents\/Developer)\//,
70 // `ps eww` cannot read the environment of macOS's own binaries.
71 hiddenEnv: /^\/(bin|usr\/bin)\//,
72 // macOS has no subreapers: launchd adopts every orphan.
73 isReaper: row => row.pid === 1,
74 },
75 linux: {
76 system: /^\/(usr\/lib|usr\/libexec|usr\/share|usr\/sbin|sbin|lib|opt|snap)\//,
77 toolchain: /^\/(usr\/lib\/jvm|usr\/lib\/dotnet|usr\/share\/dotnet)\//,
78 // /proc/<pid>/environ is readable for every process of the same user.
79 hiddenEnv: null,
80 // An orphan goes to its nearest living subreaper, such as the user's systemd, or to init.
81 isReaper: row => row.pid === 1 || SYSTEMD_USER.test(row.command),
82 },
83}
84
85const PS_LINE =
86 /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+([\d.]+)\s+(\d+)\s+(\w{3} \w{3}\s+\d+ [\d:]{8} \d{4})\s+(.*)$/
87/** This mod's own probes, which run as children of the session hosting it. */
88const PROBE =
89 /^(ps -axww |ps axww |ps eww |lsof -b -w -a -d cwd |find \S+\/projects -maxdepth 2 -name |\/bin\/kill |\/bin\/sh -c echo |\/bin\/sh -c getconf |\/bin\/sh -c for p; do |tr \\0\\n |readlink \/proc\/\d+\/cwd$|id -u|uname -s|sysctl -n |\/bin\/sh -c \{ dd bs=1 skip=|dd bs=1 skip=\d+ count=0$|head -c 4194304$)/
90const WORKTREE = /^(.*?\/\.claude\/worktrees\/[^/]+)(?:\/|$)/
91/**
92 * A Bash tool shell, or a subshell it forked (which keeps its command line), sourcing its
93 * session's shell snapshot. Claude Code deletes the snapshot when the session exits.
94 */
95const SNAPSHOT =
96 /^\/\S*\/(?:zsh|bash|sh) -c source (\/\S+\/shell-snapshots\/snapshot-(?:zsh|bash|sh)-\d+-[a-z0-9]+(?:-[\w-]+)?\.sh) /
97const COMMAND_MAX = 400
98const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
99
100const squash = (text: string): string => text.trim().replace(/\s+/g, ' ')
101const basename = (path: string): string => path.slice(path.lastIndexOf('/') + 1)
102const isRecord = (value: unknown): value is Record<string, unknown> =>
103 typeof value === 'object' && value !== null
104const textOf = (value: unknown): string => (typeof value === 'string' ? value : '')
105const numberOf = (value: unknown): number => (typeof value === 'number' ? value : 0)
106const sum = <T>(list: readonly T[], of: (item: T) => number): number =>
107 list.reduce((total, item) => total + of(item), 0)
108const isWithin = (path: string, dir: string): boolean => path === dir || path.startsWith(`${dir}/`)
109
110/** A squashed lstart ("Tue Oct 6 14:13:53 2026") as milliseconds, to order two starts; NaN when it does not read. */
111function timeOf(started: string): number {
112 const [, month = '', day, clock = '', year] = started.split(' ')
113 const [hours, minutes, seconds] = clock.split(':').map(Number)
114 const index = MONTHS.indexOf(month)
115 return index < 0 ? Number.NaN : Date.UTC(Number(year), index, Number(day), hours, minutes, seconds)
116}
117
118/** A Claude Code host: the desktop's bundled binary, a native `claude`, or the npm CLI. */
119const isHostCommand = (command: string): boolean =>
120 basename(squash(command.split(' -')[0] ?? '')) === 'claude' ||
121 command.includes('@anthropic-ai/claude-code/cli')
122
123const isCandidate = (row: PsRow, selfUid: number, rules: Rules): boolean =>
124 row.pid > 1 &&
125 row.uid === selfUid &&
126 (!rules.system.test(row.command) || rules.toolchain.test(row.command)) &&
127 !PROBE.test(row.command)
128
129export function parsePs(stdout: string): PsRow[] {
130 const rows: PsRow[] = []
131 for (const line of stdout.split('\n')) {
132 const match = PS_LINE.exec(line)
133 if (match === null) continue
134 const [, pid, ppid, pgid, uid, stat = '', cpu, rss, started = '', command = ''] = match
135 rows.push({
136 pid: Number(pid),
137 ppid: Number(ppid),
138 pgid: Number(pgid),
139 uid: Number(uid),
140 stat,
141 cpu: Number(cpu),
142 rssKb: Number(rss),
143 started: squash(started),
144 command,
145 })
146 }
147 return rows
148}
149
150export function parseHost(json: string): HostEntry | null {
151 let value: unknown
152 try {
153 value = JSON.parse(json)
154 } catch {
155 return null
156 }
157 if (!isRecord(value) || typeof value.pid !== 'number' || typeof value.sessionId !== 'string') {
158 return null
159 }
160 return {
161 pid: value.pid,
162 sessionId: value.sessionId,
163 cwd: textOf(value.cwd),
164 name: textOf(value.name),
165 status: textOf(value.status),
166 procStart: squash(textOf(value.procStart)),
167 }
168}
169
170/**
171 * Each line is the pid, the command on macOS (`ps eww`), then the environment the process
172 * started with. The command `commands` knows is cut off first, so an argument reading
173 * `CLAUDE_PID=…` never passes for the environment.
174 */
175export function parseEnv(stdout: string, commands: ReadonlyMap<number, string>): Map<number, ProcEnv> {
176 const found = new Map<number, ProcEnv>()
177 for (const line of stdout.split('\n')) {
178 const head = /^\s*(\d+)\s/.exec(line)
179 if (head === null) continue
180 const pid = Number(head[1])
181 const rest = line.slice(head[0].length)
182 const command = commands.get(pid)
183 const env = ` ${command !== undefined && rest.startsWith(command) ? rest.slice(command.length) : rest}`
184 const claudePid = /\sCLAUDE_PID=(\d+)(?:\s|$)/.exec(env)?.[1]
185 const sessionId = /\sCLAUDE_CODE_SESSION_ID=([\w-]+)(?:\s|$)/.exec(env)?.[1]
186 if (claudePid !== undefined) found.set(pid, { claudePid: Number(claudePid), sessionId: sessionId ?? null })
187 else if (sessionId !== undefined) found.set(pid, { claudePid: null, sessionId })
188 }
189 return found
190}
191
192/** `lsof -Fpn` output: a `p<pid>` line, then an `n<path>` line for its cwd. */
193export function parseCwd(stdout: string): Map<number, string> {
194 const found = new Map<number, string>()
195 let pid = 0
196 for (const line of stdout.split('\n')) {
197 if (line.startsWith('p')) pid = Number(line.slice(1))
198 else if (line.startsWith('n') && pid > 0) found.set(pid, line.slice(1))
199 }
200 return found
201}
202
203/**
204 * Adds each API response's usage once. A transcript writes one line per
205 * content block, and every line of a response repeats the same usage.
206 */
207export function tallyUsage(lines: string, tally: Tally): void {
208 for (const line of lines.split('\n')) {
209 if (!line.includes('"usage"')) continue
210 let row: unknown
211 try {
212 row = JSON.parse(line)
213 } catch {
214 continue
215 }
216 if (!isRecord(row) || row.type !== 'assistant' || !isRecord(row.message)) continue
217 const { id, usage } = row.message
218 if (typeof id !== 'string' || !isRecord(usage) || tally.seen.has(id)) continue
219 tally.seen.add(id)
220 tally.tokensIn +=
221 numberOf(usage.input_tokens) +
222 numberOf(usage.cache_creation_input_tokens) +
223 numberOf(usage.cache_read_input_tokens)
224 tally.tokensOut += numberOf(usage.output_tokens)
225 }
226}
227
228export const worktreeOf = (path: string): string | null => WORKTREE.exec(path)?.[1] ?? null
229
230export const snapshotOf = (command: string): string | null => SNAPSHOT.exec(command)?.[1] ?? null
231
232/** When Claude Code wrote a shell snapshot: the epoch milliseconds in its name. */
233export const bornOf = (snapshot: string): number => Number(/\/snapshot-[a-z]+-(\d+)-/.exec(snapshot)?.[1] ?? 0)
234
235function placeOf(path: string): string {
236 const root = worktreeOf(path)
237 if (root === null) return basename(path)
238 const [repo = '', name = ''] = root.split('/.claude/worktrees/')
239 return `${basename(repo)} › ${name}`
240}
241
242function labelOf(command: string): string {
243 const label = command
244 .split(' ')
245 .filter(Boolean)
246 .map(word => (word.includes('/') ? basename(word) || word : word))
247 .join(' ')
248 return label.length > 72 ? `${label.slice(0, 71)}…` : label
249}
250
251function toProc(row: PsRow, depth: number): Proc {
252 return {
253 pid: row.pid,
254 ppid: row.ppid,
255 pgid: row.pgid,
256 started: row.started,
257 command: row.command.slice(0, COMMAND_MAX),
258 label: labelOf(row.command),
259 cpu: row.cpu,
260 rssKb: row.rssKb,
261 isDefunct: row.stat.includes('Z'),
262 depth,
263 }
264}
265
266/**
267 * The sessions running now. A registry entry vouches for its pid only while
268 * the start time matches, so a reused pid never revives a dead session, and
269 * wherever the binary was installed; a Claude Code process with no entry
270 * still counts, so its children are never taken for orphans.
271 */
272function hostsOf(
273 rows: readonly PsRow[],
274 entries: readonly HostEntry[],
275 selfUid: number,
276 rules: Rules,
277): Map<number, Host> {
278 const hosts = new Map<number, Host>()
279 for (const row of rows) {
280 if (row.uid !== selfUid) continue
281 const entry = entries.find(one => one.pid === row.pid && one.procStart === row.started)
282 if (entry === undefined && (rules.system.test(row.command) || !isHostCommand(row.command))) continue
283 hosts.set(row.pid, {
284 pid: row.pid,
285 cwd: entry?.cwd ?? '',
286 session: {
287 sessionId: entry?.sessionId ?? '',
288 pid: row.pid,
289 name: entry === undefined ? `claude ${row.pid}` : entry.name || `session ${entry.sessionId.slice(0, 8)}`,
290 status: entry?.status || 'running',
291 place: entry === undefined ? 'unregistered session' : placeOf(entry.cwd),
292 tokensIn: 0,
293 tokensOut: 0,
294 },
295 })
296 }
297 return hosts
298}
299
300function childrenOf(rows: readonly PsRow[]): Map<number, PsRow[]> {
301 const kids = new Map<number, PsRow[]>()
302 for (const row of rows) {
303 if (row.pid === row.ppid || PROBE.test(row.command)) continue
304 const list = kids.get(row.ppid)
305 if (list === undefined) kids.set(row.ppid, [row])
306 else list.push(row)
307 }
308 return kids
309}
310
311/** `pid`'s descendants depth-first, stopping at another session's host. */
312function descend(pid: number, kids: Map<number, PsRow[]>, hosts: Map<number, Host>, depth: number): Item[] {
313 return (kids.get(pid) ?? []).flatMap(row =>
314 hosts.has(row.pid) ? [] : [{ row, depth }, ...descend(row.pid, kids, hosts, depth + 1)],
315 )
316}
317
318function liveTrees(rows: readonly PsRow[], entries: readonly HostEntry[], selfUid: number, rules: Rules) {
319 const hosts = hostsOf(rows, entries, selfUid, rules)
320 const kids = childrenOf(rows)
321 const owner = new Map<number, number>()
322 const order = new Map<number, Item[]>()
323 for (const host of hosts.values()) {
324 const items = descend(host.pid, kids, hosts, 0)
325 for (const item of items) owner.set(item.row.pid, host.pid)
326 order.set(host.pid, items)
327 }
328 // What a live session runs inside (a terminal, tmux, an editor) is in use, however it started:
329 // it is never a zombie, nor in one's tree.
330 const byPid = new Map(rows.map(row => [row.pid, row]))
331 for (const host of hosts.values()) {
332 let up = byPid.get(byPid.get(host.pid)?.ppid ?? 0)
333 for (let hop = 0; up !== undefined && up.pid > 1 && !rules.isReaper(up) && hop < 64; hop += 1) {
334 if (!hosts.has(up.pid) && !owner.has(up.pid)) owner.set(up.pid, host.pid)
335 up = byPid.get(up.ppid)
336 }
337 }
338 return { hosts, kids, owner, order, byPid }
339}
340
341/** Processes outside every live session's tree: the ones whose env and cwd are worth reading. */
342export function candidatesOf(
343 rows: readonly PsRow[],
344 entries: readonly HostEntry[],
345 selfUid: number,
346 os: Os,
347): number[] {
348 const rules = RULES[os]
349 const { hosts, owner } = liveTrees(rows, entries, selfUid, rules)
350 return rows
351 .filter(row => !owner.has(row.pid) && !hosts.has(row.pid) && isCandidate(row, selfUid, rules))
352 .map(row => row.pid)
353}
354
355/**
356 * Sorts every process into a live session or a zombie. Ancestry decides
357 * first, then the inherited CLAUDE_PID, then a shell snapshot its session
358 * deleted, and the worktree a process sits in last, since a session often
359 * works outside its own folder. A process with none of these follows its group.
360 */
361export function classify(scan: Scan): { groups: SessionGroup[]; zombies: Zombie[] } {
362 const { rows, env, cwd } = scan
363 const rules = RULES[scan.os]
364 const { hosts, kids, owner, order, byPid } = liveTrees(rows, scan.entries, scan.selfUid, rules)
365 // Who adopts an orphan: launchd on macOS; init or the nearest subreaper on Linux.
366 const reapers = new Set([1, ...rows.filter(row => rules.isReaper(row)).map(row => row.pid)])
367 // A session works in its own folder, and one in a repository's main checkout in its worktrees
368 // too, while they exist.
369 const hostIn = (root: string) => {
370 const repo = root.slice(0, root.indexOf('/.claude/worktrees/'))
371 const isGone = scan.missingWorktrees.has(root)
372 return [...hosts.values()].find(
373 host =>
374 isWithin(host.cwd, root) || (!isGone && isWithin(host.cwd, repo) && worktreeOf(host.cwd) === null),
375 )
376 }
377
378 const claimOf = (row: PsRow): Claim | null => {
379 const dir = cwd.get(row.pid) ?? ''
380 const inherited = env.get(row.pid)
381 if (inherited !== undefined) {
382 // Its host is the one it names if that was already running when this process started: a
383 // process that reused the pid since started later. The session id names a Bash tool child's
384 // host no better, since /clear gives a running host a new one; an MCP server has no other.
385 const host =
386 inherited.claudePid === null
387 ? [...hosts.values()].find(one => one.session.sessionId === inherited.sessionId)
388 : hosts.get(inherited.claudePid)
389 const isItsHost = host !== undefined && !(timeOf(byPid.get(host.pid)?.started ?? '') > timeOf(row.started))
390 return isItsHost
391 ? { kind: 'live', hostPid: host.pid }
392 : { kind: 'zombie', reason: 'session-ended', sessionId: inherited.sessionId, dir }
393 }
394 const snapshot = snapshotOf(row.command)
395 if (snapshot !== null && scan.missingSnapshots.has(snapshot)) {
396 return { kind: 'zombie', reason: 'session-ended', sessionId: null, dir }
397 }
398 // Without the env, only an orphan may be judged by its folder: a person's own shell in a
399 // worktree always has a live parent (a terminal, login, tmux, an editor).
400 const root = reapers.has(row.ppid) ? worktreeOf(dir) : null
401 if (root === null) return null
402 const host = hostIn(root)
403 if (host !== undefined) return { kind: 'live', hostPid: host.pid }
404 const reason = scan.missingWorktrees.has(root) ? 'worktree-deleted' : 'no-live-session'
405 return { kind: 'zombie', reason, sessionId: null, dir: root }
406 }
407
408 const undead = new Map<number, Extract<Claim, { kind: 'zombie' }>>()
409 for (const row of rows) {
410 if (owner.has(row.pid) || hosts.has(row.pid) || !isCandidate(row, scan.selfUid, rules)) continue
411 const claim = claimOf(row)
412 if (claim === null) continue
413 if (claim.kind === 'zombie') {
414 undead.set(row.pid, claim)
415 continue
416 }
417 // Detached from its session's tree (adopted by a reaper), but the session still runs.
418 const items = [{ row, depth: 0 }, ...descend(row.pid, kids, hosts, 1)].filter(
419 item => !owner.has(item.row.pid),
420 )
421 for (const item of items) owner.set(item.row.pid, claim.hostPid)
422 order.get(claim.hostPid)?.push(...items)
423 }
424
425 // A process group is one Bash tool command, and POSIX keeps a group's id from reuse while any
426 // member lives. A member with no evidence of its own (macOS hides the env of its own binaries)
427 // goes with a dead sibling, unless something in the group is still owned or not ours.
428 const jobs = new Map<number, PsRow[]>()
429 for (const row of rows) {
430 const job = jobs.get(row.pgid)
431 if (job === undefined) jobs.set(row.pgid, [row])
432 else job.push(row)
433 }
434 for (const job of jobs.values()) {
435 const claim = job.map(row => undead.get(row.pid)).find(found => found !== undefined)
436 const isLeftover = job.every(
437 row => !owner.has(row.pid) && !hosts.has(row.pid) && isCandidate(row, scan.selfUid, rules),
438 )
439 if (claim === undefined || !isLeftover) continue
440 for (const row of job) if (!undead.has(row.pid)) undead.set(row.pid, claim)
441 }
442
443 // A shell from /bin whose env macOS keeps from us: a `zsh -c` a dead session left running.
444 const isBareShell = (row: PsRow) =>
445 row.uid === scan.selfUid &&
446 rules.hiddenEnv !== null &&
447 rules.hiddenEnv.test(row.command) &&
448 !env.has(row.pid) &&
449 !owner.has(row.pid)
450 /** The orphan atop `row`, when every process between them is the same leftover; null otherwise. */
451 const orphanOf = (row: PsRow): PsRow | null => {
452 let current = row
453 for (let hop = 0; hop < 64; hop += 1) {
454 if (reapers.has(current.ppid)) return current
455 const parent = byPid.get(current.ppid)
456 if (parent === undefined || !(undead.has(parent.pid) || isBareShell(parent))) return null
457 current = parent
458 }
459 return null
460 }
461 const tops = new Map<number, Extract<Claim, { kind: 'zombie' }>>()
462 for (const row of rows) {
463 const claim = undead.get(row.pid)
464 if (claim === undefined || owner.has(row.pid)) continue
465 const top = orphanOf(row)
466 if (top !== null && !tops.has(top.pid)) tops.set(top.pid, undead.get(top.pid) ?? claim)
467 }
468
469 const zombies: Zombie[] = []
470 const inTree = new Set<number>()
471 for (const row of rows) {
472 const claim = tops.get(row.pid)
473 if (claim === undefined || inTree.has(row.pid)) continue
474 const items = [{ row, depth: 0 }, ...descend(row.pid, kids, hosts, 1)].filter(
475 item => !owner.has(item.row.pid) && !inTree.has(item.row.pid),
476 )
477 for (const item of items) inTree.add(item.row.pid)
478 const entry =
479 claim.sessionId === null ? undefined : scan.entries.find(one => one.sessionId === claim.sessionId)
480 const place = entry !== undefined ? placeOf(entry.cwd) : placeOf(claim.dir) || 'an unknown folder'
481 zombies.push({
482 root: toProc(row, 0),
483 children: items.slice(1).map(item => toProc(item.row, item.depth)),
484 reason: claim.reason,
485 place,
486 session:
487 claim.sessionId === null
488 ? null
489 : {
490 sessionId: claim.sessionId,
491 pid: null,
492 name: entry?.name || `session ${claim.sessionId.slice(0, 8)}`,
493 status: 'ended',
494 place,
495 tokensIn: 0,
496 tokensOut: 0,
497 },
498 })
499 }
500
501 const groups = [...hosts.values()].map(host => ({
502 session: host.session,
503 procs: (order.get(host.pid) ?? []).map(item => toProc(item.row, item.depth)),
504 }))
505 groups.sort(
506 (a, b) =>
507 Number(b.session.status === 'busy') - Number(a.session.status === 'busy') ||
508 a.session.name.localeCompare(b.session.name),
509 )
510 zombies.sort((a, b) => a.place.localeCompare(b.place) || a.root.pid - b.root.pid)
511 return { groups, zombies }
512}
513
514export function totalsOf(groups: readonly SessionGroup[], zombies: readonly Zombie[]): Totals {
515 const procs = [
516 ...groups.flatMap(group => group.procs),
517 ...zombies.flatMap(zombie => [zombie.root, ...zombie.children]),
518 ]
519 const sessions = new Map<string, Session>()
520 for (const session of [
521 ...groups.map(group => group.session),
522 ...zombies.flatMap(zombie => (zombie.session === null ? [] : [zombie.session])),
523 ]) {
524 sessions.set(session.sessionId || `pid:${session.pid}`, session)
525 }
526 return {
527 procs: procs.length,
528 cpu: sum(procs, proc => proc.cpu),
529 rssKb: sum(procs, proc => proc.rssKb),
530 tokensIn: sum([...sessions.values()], session => session.tokensIn),
531 tokensOut: sum([...sessions.values()], session => session.tokensOut),
532 }
533}
534
535/**
536 * How many zombies the board counts: every process in the zombie trees, a
537 * zombie's children included, so the number matches the rows. A defunct one
538 * counts too: it is never signalled, but it goes once its parent does.
539 */
540export const zombieTotal = (zombies: readonly Zombie[]): number =>
541 zombies.reduce((total, zombie) => total + 1 + zombie.children.length, 0)
542
543/** How many rows a kill cleared: the signalled pids, and the defunct children they left to a reaper. */
544export function laidToRest(zombies: readonly Zombie[], signalled: ReadonlySet<number>): number {
545 const cleared = zombies
546 .flatMap(zombie => [zombie.root, ...zombie.children])
547 .filter(proc => signalled.has(proc.pid) || (proc.isDefunct && signalled.has(proc.ppid)))
548 return new Set(cleared.map(proc => proc.pid)).size
549}
550
551/**
552 * The living members of these zombies' trees, deepest first, so no
553 * parent dies before its children and hands them to a reaper mid-kill.
554 * A defunct process is already dead: it goes once its parent does.
555 */
556export function killOrder(zombies: readonly Zombie[]): Proc[] {
557 return zombies
558 .flatMap(zombie => [zombie.root, ...zombie.children])
559 .filter(proc => !proc.isDefunct)
560 .sort((a, b) => b.depth - a.depth)
561}
562
563/** The planned pids that still name the very process the board showed, and that are ours to signal. */
564export function stillSame(planned: readonly Proc[], rows: readonly PsRow[], guard: Guard): number[] {
565 const now = new Map(rows.map(row => [row.pid, row]))
566 return planned
567 .filter(proc => {
568 const row = now.get(proc.pid)
569 return (
570 row !== undefined &&
571 row.pid > 1 &&
572 row.uid === guard.selfUid &&
573 !guard.protectedPids.has(row.pid) &&
574 !isHostCommand(row.command) &&
575 !row.stat.includes('Z') &&
576 row.started === proc.started &&
577 row.command.slice(0, COMMAND_MAX) === proc.command
578 )
579 })
580 .map(proc => proc.pid)
581}
582
583export function bytes(count: number): string {
584 const units = ['B', 'KB', 'MB', 'GB', 'TB']
585 let value = count
586 let unit = 0
587 while (value >= 1024 && unit < units.length - 1) {
588 value /= 1024
589 unit += 1
590 }
591 return `${unit === 0 || value >= 10 ? Math.round(value) : value.toFixed(1)} ${units[unit]}`
592}
593
594export const memory = (kb: number): string => bytes(kb * 1024)
595
596export function count(value: number): string {
597 if (value >= 1e9) return `${(value / 1e9).toFixed(1)}B`
598 if (value >= 1e6) return `${(value / 1e6).toFixed(1)}M`
599 if (value >= 1e3) return `${(value / 1e3).toFixed(1)}k`
600 return String(value)
601}
602hooks/panels.ts 386 lines1import type { Snapshot } from '../types'
2import { count, memory, zombieTotal } from './model'
3import { ACTORS, ICON_COIN, ICON_GEAR, ICON_TERMINAL, ICON_ZOMBIE, meter, pixels, scene } from './sprites'
4import type { Sprite } from './sprites'
5
6/**
7 * Everything the board shows, bar its buttons, is drawn here as SVG in one
8 * monospace face: the surfaces draw Text in their own proportional font, and
9 * no prop changes it.
10 */
11
12/** A drawing and the size it is meant to be shown at, in CSS pixels. */
13export type Drawing = { source: string; width: number; height: number; alt: string }
14
15/** One process row of a card's table. */
16export type Line = { pid: number; cpu: string; mem: string; command: string; isFaint: boolean }
17
18/** The night palette of the screen at the top. */
19export const NIGHT = {
20 ground: '#1d1934',
21 slot: '#2a2448',
22 slotFrame: '#3d3570',
23 frame: '#4c4480',
24 label: '#a99fd0',
25 value: '#f3e6c4',
26 alarm: '#ff8a7a',
27 calm: '#93cf87',
28}
29
30/**
31 * The pastel palette of the cards and headings. They keep their own colours
32 * on either theme, so their ink is their own too.
33 */
34const DAY = {
35 ink: '#3d2f4a',
36 soft: '#7d6f8c',
37 faint: '#a094b4',
38 rule: '#e3dcf2',
39 alarm: '#c2413a',
40}
41
42/** Badge looks: a session's state, and a zombie being killed. */
43const BADGES: Readonly<Record<string, { ink: string; ground: string; mark: string }>> = {
44 busy: { ink: '#2c7a4b', ground: '#d4f2dd', mark: '●' },
45 running: { ink: '#2c7a4b', ground: '#d4f2dd', mark: '●' },
46 waiting: { ink: '#8a5d00', ground: '#ffefc4', mark: '◐' },
47 idle: { ink: '#6f6385', ground: '#ebe6f6', mark: '○' },
48 killing: { ink: '#8a3a4e', ground: '#ffe0e8', mark: '◌' },
49}
50
51/** Section strips: the icon, the tint, and the ink of each. */
52type Strip = 'zombies' | 'sessions' | 'quiet'
53const STRIPS: Readonly<Record<Strip, { icon: Sprite; ground: string; ink: string }>> = {
54 zombies: { icon: ICON_ZOMBIE, ground: '#ffe3ea', ink: '#7a2f45' },
55 sessions: { icon: ICON_TERMINAL, ground: '#ebe5ff', ink: '#4a3d7a' },
56 quiet: { icon: ICON_ZOMBIE, ground: '#dcf2e3', ink: '#2c6b45' },
57}
58
59const MONO = "ui-monospace, 'SF Mono', SFMono-Regular, Menlo, Monaco, Consolas, monospace"
60/** One monospace column is about 0.6em wide; CJK and emoji take two. */
61const ADVANCE = 0.6
62const WIDE = /[ᄀ-ᅟ⺀-가-힣豈-︰-﹏-⦆¢-₩]|\p{Extended_Pictographic}/u
63
64/** The screen: the graveyard scene is 128 pixels drawn three times over. */
65const SCALE = 3
66const SCREEN_W = 384
67const BANNER_H = 132
68const PAD = 10
69const GAP = 8
70const SLOT_H = 68
71const METER_SEGMENTS = 24
72const GAUGE_H = 22
73/** A gauge's reading in three fixed stops, so both rows line up at value, slash and total. */
74const VALUE_END = 298
75const SLASH_AT = 307
76const TOTAL_START = 316
77const ICON_BOX = 24
78const TITLE_SIZE = 13
79const SUBTITLE_SIZE = 11
80const BODY_SIZE = 12
81
82/**
83 * Ambient motion, all of it in CSS inside the drawing: it runs without the mod
84 * drawing anything again, and stops for anyone who asked for less motion.
85 */
86const MOTION =
87 '<style>' +
88 '.twinkle{animation:twinkle 2.4s steps(1,end) infinite}' +
89 '@keyframes twinkle{0%,100%{opacity:1}50%{opacity:.3}}' +
90 '.glow{animation:glow 3.2s ease-in-out infinite}' +
91 '@keyframes glow{0%,100%{opacity:.2}50%{opacity:1}}' +
92 '@media (prefers-reduced-motion:reduce){.twinkle,.glow{animation:none}}' +
93 '</style>'
94
95/** The still scene never changes, so it is drawn once. */
96const SCENE = pixels(scene(), 0, 0, SCALE)
97const fragments = new Map<Sprite, string>()
98/** A 12-pixel icon at twice its size, drawn once and placed by translation after that. */
99const icon = (sprite: Sprite, x: number, y: number): string => {
100 let fragment = fragments.get(sprite)
101 if (fragment === undefined) {
102 fragment = pixels(sprite, 0, 0, 2)
103 fragments.set(sprite, fragment)
104 }
105 return `<g transform="translate(${x} ${y})">${fragment}</g>`
106}
107
108const columnsOf = (text: string): number =>
109 [...text].reduce((total, char) => total + (WIDE.test(char) ? 2 : 1), 0)
110
111/** `text` cut to `columns`, an ellipsis standing for the rest. */
112export function clip(text: string, columns: number): string {
113 if (columnsOf(text) <= columns) return text
114 let kept = ''
115 let used = 0
116 for (const char of text) {
117 const width = WIDE.test(char) ? 2 : 1
118 if (used + width > columns - 1) break
119 kept += char
120 used += width
121 }
122 return `${kept}…`
123}
124
125const escape = (text: string): string =>
126 text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
127
128type Style = { size: number; fill: string; isBold?: boolean; anchor?: 'start' | 'middle' | 'end'; spacing?: number }
129
130function text(x: number, y: number, content: string, style: Style): string {
131 const bold = style.isBold === true ? ' font-weight="bold"' : ''
132 const anchor = style.anchor !== undefined && style.anchor !== 'start' ? ` text-anchor="${style.anchor}"` : ''
133 const spacing = style.spacing !== undefined ? ` letter-spacing="${style.spacing}"` : ''
134 return (
135 `<text x="${x}" y="${y}" font-family="${MONO}" font-size="${style.size}" fill="${style.fill}"${bold}${anchor}${spacing}>` +
136 `${escape(content)}</text>`
137 )
138}
139
140const drawing = (width: number, height: number, body: string, alt: string): Drawing => ({
141 source: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">${body}</svg>`,
142 width,
143 height,
144 alt,
145})
146
147const share = (part: number, whole: number): number => (whole > 0 ? part / whole : 0)
148
149type Counter = { icon: Sprite; value: string; label: string; tone: string }
150
151/** A slot of the status window: icon over value over label, all on one centre line. */
152function slot(x: number, y: number, width: number, counter: Counter): string {
153 const centre = x + width / 2
154 return (
155 `<rect x="${x}" y="${y}" width="${width}" height="${SLOT_H}" rx="8" fill="${NIGHT.slot}" stroke="${NIGHT.slotFrame}"/>` +
156 icon(counter.icon, centre - ICON_BOX / 2, y + 9) +
157 text(centre, y + 50, counter.value, { size: 17, fill: counter.tone, isBold: true, anchor: 'middle' }) +
158 text(centre, y + 62, counter.label, { size: 9, fill: NIGHT.label, anchor: 'middle', spacing: 1 })
159 )
160}
161
162/** The tokens slot: the coin on the centre line, each figure over its own label, a rule between them. */
163function tokens(x: number, y: number, width: number, spentIn: string, spentOut: string): string {
164 const centre = x + width / 2
165 const halves: Array<[number, string, string]> = [
166 [x + width / 4, spentIn, 'TOKENS IN'],
167 [x + (width * 3) / 4, spentOut, 'TOKENS OUT'],
168 ]
169 return (
170 `<rect x="${x}" y="${y}" width="${width}" height="${SLOT_H}" rx="8" fill="${NIGHT.slot}" stroke="${NIGHT.slotFrame}"/>` +
171 icon(ICON_COIN, centre - ICON_BOX / 2, y + 9) +
172 `<rect x="${centre - 0.5}" y="${y + 38}" width="1" height="${SLOT_H - 46}" fill="${NIGHT.slotFrame}"/>` +
173 halves
174 .map(
175 ([middle, value, label]) =>
176 text(middle, y + 50, value, { size: 17, fill: NIGHT.value, isBold: true, anchor: 'middle' }) +
177 text(middle, y + 62, label, { size: 9, fill: NIGHT.label, anchor: 'middle', spacing: 1 }),
178 )
179 .join('')
180 )
181}
182
183/** A meter row: label, segments, then value / total, each on its own stop. */
184function gauge(y: number, label: string, fraction: number, value: string, total: string): string {
185 const baseline = y + 13
186 return (
187 text(PAD + 2, baseline, label, { size: 11, fill: NIGHT.label, isBold: true }) +
188 pixels(meter(fraction, METER_SEGMENTS), PAD + 36, y + 3, 2) +
189 text(VALUE_END, baseline, value, { size: BODY_SIZE, fill: NIGHT.value, isBold: true, anchor: 'end' }) +
190 (total === ''
191 ? ''
192 : text(SLASH_AT, baseline, '/', { size: BODY_SIZE, fill: NIGHT.label, anchor: 'middle' }) +
193 text(TOTAL_START, baseline, total, { size: BODY_SIZE, fill: NIGHT.label }))
194 )
195}
196
197/** What moves over the scene: stars and fireflies, each out of step with the next. */
198function actors(): string {
199 const offset = (index: number, period: number) => `animation-delay:-${((index * 0.37) % 1) * period}s`
200 const stars = ACTORS.stars.map(
201 (star, index) =>
202 `<g class="twinkle" style="${offset(index, 2.4)}">${pixels(star.sprite, star.x * SCALE, star.y * SCALE, SCALE)}</g>`,
203 )
204 const flies = ACTORS.fireflies.map(
205 (fly, index) =>
206 `<g class="glow" style="${offset(index, 3.2)}">${pixels(fly.sprite, fly.x * SCALE, fly.y * SCALE, SCALE)}</g>`,
207 )
208 return [...stars, ...flies].join('')
209}
210
211/** The scene and what moves over it never change, so they are drawn once. */
212const BACKDROP = SCENE + actors()
213
214let lastScreen: { key: string; drawing: Drawing } | undefined
215
216/**
217 * The graveyard and its status window as one picture, so nothing in it can
218 * drift out of line. Built again only when what it shows has changed.
219 */
220export function screen(snap: Snapshot): Drawing {
221 const { totals, machine } = snap
222 const zombies = zombieTotal(snap.zombies)
223 const key = JSON.stringify([snap.groups.length, zombies, totals, machine])
224 if (lastScreen?.key === key) return lastScreen.drawing
225 const third = (SCREEN_W - PAD * 2 - GAP * 2) / 3
226 const full = SCREEN_W - PAD * 2
227 const parts = [BACKDROP]
228 let y = BANNER_H + PAD
229 const counters: Counter[] = [
230 { icon: ICON_TERMINAL, value: String(snap.groups.length), label: 'SESSIONS', tone: NIGHT.value },
231 { icon: ICON_GEAR, value: String(totals.procs), label: 'PROCESSES', tone: NIGHT.value },
232 { icon: ICON_ZOMBIE, value: String(zombies), label: 'ZOMBIES', tone: zombies > 0 ? NIGHT.alarm : NIGHT.calm },
233 ]
234 counters.forEach((counter, index) => parts.push(slot(PAD + index * (third + GAP), y, third, counter)))
235 y += SLOT_H + GAP + 2
236 const cores = machine.cores > 0 ? `${machine.cores} cores` : ''
237 const ram = machine.memoryKb > 0 ? memory(machine.memoryKb) : ''
238 parts.push(gauge(y, 'CPU', share(totals.cpu, machine.cores * 100), `${totals.cpu.toFixed(1)}%`, cores))
239 y += GAUGE_H
240 parts.push(gauge(y, 'MEM', share(totals.rssKb, machine.memoryKb), memory(totals.rssKb), ram))
241 y += GAUGE_H + GAP
242 parts.push(tokens(PAD, y, full, count(totals.tokensIn), count(totals.tokensOut)))
243 const height = y + SLOT_H + PAD
244 const body =
245 MOTION +
246 `<defs><clipPath id="screen"><rect width="${SCREEN_W}" height="${height}" rx="12"/></clipPath></defs>` +
247 `<g clip-path="url(#screen)"><rect width="${SCREEN_W}" height="${height}" fill="${NIGHT.ground}"/>${parts.join('')}</g>` +
248 `<rect x="0.75" y="0.75" width="${SCREEN_W - 1.5}" height="${height - 1.5}" rx="11.5" fill="none" stroke="${NIGHT.frame}" stroke-width="1.5"/>`
249 const alt =
250 `${snap.groups.length} sessions, ${totals.procs} processes, ${zombies} zombies; ` +
251 `CPU ${totals.cpu.toFixed(1)}%, memory ${memory(totals.rssKb)}; ` +
252 `tokens ${count(totals.tokensIn)} in, ${count(totals.tokensOut)} out`
253 lastScreen = { key, drawing: drawing(SCREEN_W, height, body, alt) }
254 return lastScreen.drawing
255}
256
257const STRIP_H = 30
258const STRIP_PAD = 8
259const LABEL_SPACING = 0.5
260const CHEVRON_W = 10
261
262/**
263 * A section's heading: a tinted strip with its icon and label, and, when the
264 * section folds, a chevron at its right end (down while closed, up when open).
265 * It reads the same on a light or a dark page.
266 */
267export function strip(kind: Strip, label: string, fold: 'open' | 'closed' | null): Drawing {
268 const look = STRIPS[kind]
269 const labelX = STRIP_PAD + ICON_BOX + 10
270 // Letter spacing widens every column, so it counts toward the width too.
271 const labelW = columnsOf(label) * (TITLE_SIZE * ADVANCE + LABEL_SPACING) - LABEL_SPACING
272 const chevronX = labelX + labelW + 14
273 const width = Math.ceil((fold === null ? labelX + labelW : chevronX + CHEVRON_W) + STRIP_PAD + 2)
274 const middle = STRIP_H / 2
275 const chevron =
276 fold === null
277 ? ''
278 : `<path d="M${chevronX} ${fold === 'closed' ? middle - 2 : middle + 2}l${CHEVRON_W / 2} ${fold === 'closed' ? 4 : -4}l${CHEVRON_W / 2} ${fold === 'closed' ? -4 : 4}" ` +
279 `fill="none" stroke="${look.ink}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>`
280 const body =
281 `<rect width="${width}" height="${STRIP_H}" rx="8" fill="${look.ground}"/>` +
282 icon(look.icon, STRIP_PAD, (STRIP_H - ICON_BOX) / 2) +
283 text(labelX, 20, label, { size: TITLE_SIZE, fill: look.ink, isBold: true, spacing: LABEL_SPACING }) +
284 chevron
285 const state = fold === null ? '' : fold === 'open' ? ', open' : ', closed'
286 return drawing(width, STRIP_H, body, `${label}${state}`)
287}
288
289/** A line of text on the page: an empty state, an error, a wait. */
290export function note(message: string, tone: 'soft' | 'alarm'): Drawing {
291 const width = Math.ceil(columnsOf(message) * BODY_SIZE * ADVANCE + 4)
292 const fill = tone === 'alarm' ? DAY.alarm : DAY.soft
293 return drawing(width, 18, text(0, 13, message, { size: BODY_SIZE, fill }), message)
294}
295
296/** A card's head: its icon in a fixed box, so every title starts on the same line, then title over subtitle. */
297export function header(sprite: Sprite, title: string, subtitle: string, widthPx: number): Drawing {
298 const textX = ICON_BOX + 10
299 const room = Math.max(40, widthPx - textX)
300 const titleText = clip(title, Math.floor(room / (TITLE_SIZE * ADVANCE)))
301 const subtitleText = clip(subtitle, Math.floor(room / (SUBTITLE_SIZE * ADVANCE)))
302 const used = Math.max(
303 columnsOf(titleText) * TITLE_SIZE * ADVANCE,
304 columnsOf(subtitleText) * SUBTITLE_SIZE * ADVANCE,
305 )
306 const width = Math.ceil(textX + used + 2)
307 const body =
308 icon(sprite, 0, 6) +
309 text(textX, 16, titleText, { size: TITLE_SIZE, fill: DAY.ink, isBold: true }) +
310 text(textX, 31, subtitleText, { size: SUBTITLE_SIZE, fill: DAY.soft })
311 return drawing(width, 36, body, `${title}. ${subtitle}`)
312}
313
314const TABLE_SIZE = 11
315const ROW_H = 17
316const COLUMNS = [
317 { title: 'PID', width: 6 },
318 { title: 'CPU', width: 6 },
319 { title: 'MEM', width: 8 },
320] as const
321
322/** A card's processes: numbers flush right in fixed columns, the command after them, then how many more. */
323export function table(lines: readonly Line[], more: number, widthPx: number): Drawing {
324 const advance = TABLE_SIZE * ADVANCE
325 const gap = 2 * advance
326 const edges: number[] = []
327 let x = 0
328 for (const column of COLUMNS) {
329 x += column.width * advance
330 edges.push(x)
331 x += gap
332 }
333 const commandX = x
334 const commandColumns = Math.max(12, Math.floor((widthPx - commandX) / advance))
335 const width = Math.ceil(commandX + commandColumns * advance)
336 const head =
337 COLUMNS.map((column, index) =>
338 text(edges[index] ?? 0, 11, column.title, { size: 9, fill: DAY.faint, anchor: 'end', spacing: 0.5 }),
339 ).join('') +
340 text(commandX, 11, 'COMMAND', { size: 9, fill: DAY.faint, spacing: 0.5 }) +
341 `<rect x="0" y="16" width="${width}" height="1" fill="${DAY.rule}"/>`
342 const rows = lines.map((line, index) => {
343 const y = 31 + index * ROW_H
344 const ink = line.isFaint ? DAY.faint : DAY.ink
345 const cells = [String(line.pid), line.cpu, line.mem]
346 return (
347 cells
348 .map((cell, column) => text(edges[column] ?? 0, y, cell, { size: TABLE_SIZE, fill: ink, anchor: 'end' }))
349 .join('') + text(commandX, y, clip(line.command, commandColumns), { size: TABLE_SIZE, fill: ink })
350 )
351 })
352 if (more > 0) {
353 rows.push(text(commandX, 31 + lines.length * ROW_H, `+ ${more} more`, { size: TABLE_SIZE, fill: DAY.faint }))
354 }
355 const height = 20 + rows.length * ROW_H
356 return drawing(width, height, head + rows.join(''), `${lines.length + more} processes`)
357}
358
359/** A badge in a card's right-hand column, every badge as wide as the longest so they line up. */
360export function pill(status: string): Drawing {
361 const look = BADGES[status] ?? BADGES.idle ?? { ink: DAY.soft, ground: DAY.rule, mark: '○' }
362 const label = `${look.mark} ${status}`
363 const width = Math.ceil(columnsOf('◐ waiting') * 11 * ADVANCE + 16)
364 const body =
365 `<rect width="${width}" height="20" rx="10" fill="${look.ground}"/>` +
366 text(width / 2, 14, label, { size: 11, fill: look.ink, isBold: true, anchor: 'middle' })
367 return drawing(width, 20, body, status)
368}
369
370const NOTICE_SIZE = 13
371/** Zombie red that reads on a light or a dark prompt band. */
372const NOTICE_INK = '#d0584f'
373
374/**
375 * The zombie's face and a line of text as one drawing, so the two share a
376 * centre line; a Text beside an Svg sits on its own baseline instead.
377 */
378export function notice(message: string): Drawing {
379 const textX = ICON_BOX + 8
380 const width = Math.ceil(textX + columnsOf(message) * NOTICE_SIZE * ADVANCE + 2)
381 const body = icon(ICON_ZOMBIE, 0, 0) + text(textX, 17, message, { size: NOTICE_SIZE, fill: NOTICE_INK, isBold: true })
382 return drawing(width, ICON_BOX, body, message)
383}
384
385export const icons = { zombie: ICON_ZOMBIE, terminal: ICON_TERMINAL } as const
386hooks/scan.ts 284 lines1import type { FsEntry, FsStat, ProcessRunInit, ProcessRunResult } from 'claude-code'
2
3import type { Machine, Snapshot } from '../types'
4import {
5 bornOf,
6 candidatesOf,
7 classify,
8 parseCwd,
9 parseEnv,
10 parseHost,
11 parsePs,
12 snapshotOf,
13 tallyUsage,
14 totalsOf,
15 worktreeOf,
16} from './model'
17import type { HostEntry, Os, ProcEnv, PsRow, Tally } from './model'
18
19/**
20 * The session host this mod runs in, the user it runs as, the folder Claude Code keeps its
21 * sessions and transcripts in, their system, and the machine.
22 */
23export type Context = { configDir: string; os: Os; selfUid: number; selfHostPid: number; machine: Machine }
24
25/**
26 * The host calls a scan makes. The engine only takes `$` spelled out at each
27 * call site, so the hook that owns `$` hands these over as closures.
28 */
29export type Io = {
30 run: (argv: readonly string[], init?: ProcessRunInit) => Promise<ProcessRunResult>
31 exists: (path: string) => Promise<boolean>
32 list: (path: string) => Promise<FsEntry[]>
33 read: (path: string) => Promise<string>
34 stat: (path: string) => Promise<FsStat>
35 home: () => Promise<string | undefined>
36 /** CLAUDE_CONFIG_DIR, which Claude Code uses in place of ~/.claude when it is set. */
37 configDir: () => Promise<string | undefined>
38 /** The settings Claude Code runs under, merged over every source. */
39 settings: () => Promise<Readonly<Record<string, unknown>>>
40 now: () => Promise<number>
41}
42
43/** A transcript counted so far: where it is, how many bytes were read, and what they added up to. */
44type Reading = Tally & { path: string | null; offset: number; checkedAt: number }
45export type Readings = Map<string, Reading>
46/**
47 * The session each process inherited, by pid, start time and command. `ps eww`
48 * shows the environment a process started with, which only an exec replaces,
49 * and an exec changes the command.
50 */
51export type Envs = Map<string, ProcEnv | null>
52
53/** `ps` prints lstart in the local zone, while the session registry writes procStart in UTC. */
54const UTC = { TZ: 'UTC', LC_ALL: 'C' }
55/** The engine hands back this much of a command's output, and reads the rest only to drop it. */
56const OUTPUT_CAP = 4_194_304
57const SESSION_ID = /^[\w-]+$/
58const MISSING_TRANSCRIPT_RETRY_MS = 60_000
59const DAY_MS = 86_400_000
60/** Claude Code's cleanupPeriodDays when no settings name one. */
61const RETENTION_DAYS = 30
62// ponytail: at most 8 x 4 MiB of a transcript per scan; a longer backlog finishes over the next scans.
63const CHUNKS_PER_SCAN = 8
64/** Linux: each pid, then the environment it started with, from /proc (what `ps eww` reads on macOS). */
65const LINUX_ENVS = `for p; do printf '%s ' "$p"; tr '\\0\\n' ' ' < "/proc/$p/environ" 2>/dev/null; echo; done`
66/** Linux: each pid's working folder from /proc, as the `p<pid>` and `n<path>` lines lsof prints. */
67const LINUX_CWDS = `for p; do d=$(readlink "/proc/$p/cwd" 2>/dev/null) && printf 'p%s\\nn%s\\n' "$p" "$d"; done`
68
69export async function contextOf(io: Io): Promise<Context> {
70 const configDir = (await io.configDir()) || `${(await io.home()) ?? ''}/.claude`
71 const { stdout } = await io.run(['/bin/sh', '-c', 'echo "$PPID $(id -u) $(uname -s)"'])
72 const [host = '0', uid = '-1', system = ''] = stdout.trim().split(' ')
73 const os = osOf(system)
74 return { configDir, os, selfHostPid: Number(host), selfUid: Number(uid), machine: await machineOf(io, os) }
75}
76
77function osOf(system: string): Os {
78 if (system === 'Darwin') return 'darwin'
79 if (system === 'Linux') return 'linux'
80 throw new Error(`Rest in PID reads macOS and Linux processes, not ${system || 'this system'}'s`)
81}
82
83/** The cores and memory the meters measure against; zeros when the probe fails. */
84async function machineOf(io: Io, os: Os): Promise<Machine> {
85 if (os === 'darwin') {
86 const facts = await optional(io.run(['sysctl', '-n', 'hw.ncpu', 'hw.memsize']))
87 const [cores = 0, memoryBytes = 0] = facts.trim().split('\n').map(Number)
88 return { cores, memoryKb: Math.round(memoryBytes / 1024) }
89 }
90 const facts = await optional(io.run(['/bin/sh', '-c', 'getconf _NPROCESSORS_ONLN; grep MemTotal /proc/meminfo']))
91 return { cores: Number(facts.split('\n')[0]) || 0, memoryKb: Number(/MemTotal:\s+(\d+)/.exec(facts)?.[1] ?? 0) }
92}
93
94/** procps takes BSD options only without the dash; macOS ps takes either. */
95export async function readPs(io: Io, os: Os): Promise<PsRow[]> {
96 const fields = 'pid=,ppid=,pgid=,uid=,stat=,%cpu=,rss=,lstart=,command='
97 const argv = os === 'darwin' ? ['ps', '-axww', '-o', fields] : ['ps', 'axww', '-o', fields]
98 const { stdout } = await io.run(argv, { env: UTC })
99 return parsePs(stdout)
100}
101
102/** Output of a probe that may be missing or fail (lsof is not on every host); nothing then. */
103async function optional(run: Promise<ProcessRunResult>): Promise<string> {
104 try {
105 return (await run).stdout
106 } catch {
107 return ''
108 }
109}
110
111/**
112 * The paths among `paths` that no longer exist. A path the host will not check
113 * (it rejects a network location) counts as present, so it never makes a zombie.
114 */
115async function missing(io: Io, paths: ReadonlyArray<string | null>): Promise<Set<string>> {
116 const gone = new Set<string>()
117 for (const path of new Set(paths)) {
118 if (path !== null && !(await io.exists(path).catch(() => true))) gone.add(path)
119 }
120 return gone
121}
122
123/**
124 * Before when a shell snapshot may be gone while its session still runs: Claude Code's retention
125 * sweep deletes the ones older than cleanupPeriodDays either way.
126 */
127async function sweptBefore(io: Io, now: number): Promise<number> {
128 const settings = await io.settings().catch((): Readonly<Record<string, unknown>> => ({}))
129 const days = settings.cleanupPeriodDays
130 return now - (typeof days === 'number' && days >= 0 ? days : RETENTION_DAYS) * DAY_MS
131}
132
133async function readEntries(io: Io, configDir: string): Promise<HostEntry[]> {
134 const dir = `${configDir}/sessions`
135 if (!(await io.exists(dir))) return []
136 const files = (await io.list(dir)).filter(file => file.kind === 'file' && file.name.endsWith('.json'))
137 const entries = await Promise.all(files.map(async file => parseHost(await io.read(`${dir}/${file.name}`))))
138 return entries.filter((entry): entry is HostEntry => entry !== null)
139}
140
141async function readingOf(io: Io, configDir: string, sessionId: string, readings: Readings) {
142 const now = await io.now()
143 let reading = readings.get(sessionId)
144 if (reading === undefined || (reading.path === null && now - reading.checkedAt > MISSING_TRANSCRIPT_RETRY_MS)) {
145 const found = await io.run([
146 'find',
147 `${configDir}/projects`,
148 '-maxdepth',
149 '2',
150 '-name',
151 `${sessionId}.jsonl`,
152 ])
153 reading = {
154 path: found.stdout.split('\n')[0] || null,
155 offset: 0,
156 checkedAt: now,
157 seen: new Set(),
158 tokensIn: 0,
159 tokensOut: 0,
160 }
161 readings.set(sessionId, reading)
162 }
163 if (reading.path === null) return reading
164 const { size } = await io.stat(reading.path)
165 if (size < reading.offset) Object.assign(reading, { offset: 0, seen: new Set(), tokensIn: 0, tokensOut: 0 })
166 for (let chunk = 0; chunk < CHUNKS_PER_SCAN && reading.offset < size; chunk += 1) {
167 // dd seeks to the offset and head stops at what the engine keeps: `tail -c +N` copies byte
168 // by byte, and the engine would read the whole rest of the file every time.
169 const { stdout } = await io.run([
170 '/bin/sh',
171 '-c',
172 `{ dd bs=1 skip="$1" count=0 2>/dev/null; head -c ${OUTPUT_CAP}; } < "$2"`,
173 'sh',
174 String(reading.offset),
175 reading.path,
176 ])
177 const end = stdout.lastIndexOf('\n')
178 if (end < 0) break
179 // Whole lines only: the byte count stays exact, and a cut line is read again next time.
180 const lines = stdout.slice(0, end + 1)
181 reading.offset += new TextEncoder().encode(lines).length
182 tallyUsage(lines, reading)
183 }
184 return reading
185}
186
187const envKey = (row: PsRow): string => `${row.pid} ${row.started} ${row.command}`
188
189/** The command that prints each pid followed by the environment it started with. */
190const envProbe = (os: Os, pids: readonly string[]): string[] =>
191 os === 'darwin'
192 ? ['ps', 'eww', '-o', 'pid=,command=', '-p', pids.join(',')]
193 : ['/bin/sh', '-c', LINUX_ENVS, 'sh', ...pids]
194
195/** The command that prints each pid's working folder, as lsof's `p<pid>` and `n<path>` lines. */
196const cwdProbe = (os: Os, pids: readonly string[]): string[] =>
197 os === 'darwin'
198 ? // -b skips the stat and readlink calls that can block (the kernel names the cwd either way).
199 ['lsof', '-b', '-w', '-a', '-d', 'cwd', '-Fpn', '-p', pids.join(',')]
200 : ['/bin/sh', '-c', LINUX_CWDS, 'sh', ...pids]
201
202/**
203 * The session each candidate inherited. Only the ones not read before are
204 * asked about; a pid the probe did not print (gone, or the probe failed) is
205 * asked again.
206 */
207async function readEnvs(
208 io: Io,
209 os: Os,
210 rows: readonly PsRow[],
211 candidates: ReadonlySet<number>,
212 envs: Envs,
213): Promise<Map<number, ProcEnv>> {
214 const unread = rows.filter(row => candidates.has(row.pid) && !envs.has(envKey(row)))
215 const pids = unread.map(row => String(row.pid))
216 const stdout = pids.length === 0 ? '' : await optional(io.run(envProbe(os, pids)))
217 const found = parseEnv(stdout, new Map(unread.map(row => [row.pid, row.command])))
218 const printed = new Set(stdout.split('\n').map(line => Number(/^\s*(\d+)\s/.exec(line)?.[1])))
219 const kept: Envs = new Map()
220 const env = new Map<number, ProcEnv>()
221 for (const row of rows) {
222 const key = envKey(row)
223 if (!candidates.has(row.pid) || (!envs.has(key) && !printed.has(row.pid))) continue
224 const value = envs.has(key) ? (envs.get(key) ?? null) : (found.get(row.pid) ?? null)
225 kept.set(key, value)
226 if (value !== null) env.set(row.pid, value)
227 }
228 // Only the processes still running stay remembered.
229 envs.clear()
230 for (const [key, value] of kept) envs.set(key, value)
231 return env
232}
233
234export async function scan(io: Io, context: Context, readings: Readings, envs: Envs): Promise<Snapshot> {
235 const { os } = context
236 const [rows, entries, swept] = await Promise.all([
237 readPs(io, os),
238 readEntries(io, context.configDir),
239 io.now().then(now => sweptBefore(io, now)),
240 ])
241 const candidates = new Set(candidatesOf(rows, entries, context.selfUid, os))
242 const pids = [...candidates].map(String)
243 const [env, cwdOut] = await Promise.all([
244 readEnvs(io, os, rows, candidates, envs),
245 pids.length === 0 ? '' : optional(io.run(cwdProbe(os, pids))),
246 ])
247 const cwd = parseCwd(cwdOut)
248 const { groups, zombies } = classify({
249 rows,
250 entries,
251 env,
252 cwd,
253 missingWorktrees: await missing(io, [...cwd.values()].map(worktreeOf)),
254 // Only a snapshot the retention sweep could not have taken says its session exited.
255 missingSnapshots: await missing(
256 io,
257 rows
258 .filter(row => candidates.has(row.pid))
259 .map(row => snapshotOf(row.command))
260 .map(snapshot => (snapshot !== null && bornOf(snapshot) >= swept ? snapshot : null)),
261 ),
262 selfUid: context.selfUid,
263 os,
264 })
265 const sessions = [
266 ...groups.map(group => group.session),
267 ...zombies.flatMap(zombie => (zombie.session === null ? [] : [zombie.session])),
268 ]
269 for (const session of sessions) {
270 if (!SESSION_ID.test(session.sessionId)) continue
271 const reading = await readingOf(io, context.configDir, session.sessionId, readings).catch(() => undefined)
272 session.tokensIn = reading?.tokensIn ?? 0
273 session.tokensOut = reading?.tokensOut ?? 0
274 }
275 return {
276 scannedAt: await io.now(),
277 groups,
278 zombies,
279 totals: totalsOf(groups, zombies),
280 machine: context.machine,
281 error: null,
282 }
283}
284hooks/sprites.ts 380 lines1/** A picture as rows of palette keys; `.` is see-through. */
2export type Sprite = readonly string[]
3
4/**
5 * One moonlit palette. The scene is night on either theme, a framed game
6 * screen, so it needs no light and dark pair.
7 */
8const PALETTE: Readonly<Record<string, string>> = {
9 k: '#1c1830', // outline
10 K: '#3b2f4f', // brows, scar, mouth, title shadow
11 '1': '#15132b', // sky, top band
12 '2': '#1b1838',
13 '3': '#221e45',
14 '4': '#2b2552',
15 '5': '#352c5c',
16 '6': '#433566', // sky, horizon
17 m: '#f7e8a8', // moon
18 M: '#fff8d6', // moon rim light
19 n: '#cdb56f', // moon inner edge
20 y: '#fff3c4', // bright stars
21 Y: '#8e86c8', // dim stars
22 C: '#5a5196', // moonlit cloud edge
23 B: '#3a3370', // cloud wisp
24 h: '#211c3f', // far hills
25 H: '#2c2650', // far hill rim
26 q: '#16122b', // castle
27 L: '#ffd36b', // lit window, flame
28 t: '#110e22', // bats
29 i: '#1d2a3a', // near hills
30 I: '#27384a', // near hill rim
31 g: '#2f5a48', // grass
32 G: '#468a66', // moonlit grass tips
33 d: '#3a2e3f', // soil
34 D: '#2b2131', // soil specks
35 s: '#8e88b0', // stone
36 S: '#666088', // stone shade
37 w: '#c9c4e6', // stone in moonlight
38 f: '#c8f7a8', // fireflies
39 z: '#a3d99b', // zombie skin
40 e: '#ffffff', // eye shine, fang
41 c: '#f2a3b3', // rosy cheeks
42 j: '#a9c8f0', // shirt
43 x: '#1e3328', // terminal screen
44 X: '#9be59a', // phosphor prompt
45 v: '#f3e6c4', // title, wax, bone
46 V: '#d6c49a', // wax shade
47 F: '#f6c453', // coin
48 R: '#c98e2c', // coin rim
49 p: '#93cf87', // meter fill
50 P: '#f6c453', // meter fill, busy
51 Q: '#ff8a7a', // meter fill, hot
52 u: '#2f2a52', // meter track
53 U: '#5b5290', // meter frame
54}
55const SKY = ['1', '2', '3', '4', '5', '6']
56/** Keys a terminal leaves to its own background. */
57const BACKDROP = new Set(['.', ...SKY])
58const TERMINAL_DEFAULT = 0x01000000
59
60/** A headstone with a carved cross, lit from the moon's side. */
61const GRAVE: Sprite = [
62 '....kkkkk....',
63 '..kksssswkk..',
64 '.kSsssSssswk.',
65 '.kSssSSSsswk.',
66 '.kSsssSssswk.',
67 '.kSsssSssswk.',
68 '.kSssssssswk.',
69 '.kSssssssswk.',
70 '.kSssssssssk.',
71 '.kSSsssssssk.',
72 '.kSSSssssssk.',
73 'kkkkkkkkkkkkk',
74 'kSSSSSsssswwk',
75 'kkkkkkkkkkkkk',
76]
77
78/** Up on the hill, the doctor's castle: three spires, one lit window. */
79const CASTLE: Sprite = [
80 '.......q........',
81 '......qqq.......',
82 '..q...qqq...q...',
83 '.qqq..qLq..qqq..',
84 '.qqq.qqqqq.qqq..',
85 '.qqqqqqqqqqqqq..',
86 '.qqqqqqqqqqqqq..',
87 'qqqqqqqqqqqqqqqq',
88]
89
90const BAT: Sprite = ['t.....t', 'tt.t.tt', '.ttttt.']
91const WISP: Sprite = ['.....CCCCCC.........', '..CCBBBBBBBCCCC.....', '......BBBBBBBBBBBB..']
92const TWINKLE: Sprite = ['.y.', 'yyy', '.y.']
93const FIREFLY: Sprite = ['f']
94
95/** The icon set: twelve pixels square, outlined, lit from the top left, so any two line up. */
96/** The same zombie's face: the mark for whatever was left behind. */
97export const ICON_ZOMBIE: Sprite = [
98 '....k..k....',
99 '..kkkkkkkk..',
100 '.kzzzzzKzzk.',
101 'kzzzzzKKKzzk',
102 'kzeKzzzzeKzk',
103 'kzKKzzzzKKzk',
104 'kczzzzzzzzck',
105 'kzzzzKwKzzzk',
106 '.kzzzzzzzzk.',
107 '..kkzzzzkk..',
108 '...kjjjjk...',
109 '..kjjjjjjk..',
110]
111/** A beige terminal with a green prompt: a Claude Code session at work. */
112export const ICON_TERMINAL: Sprite = [
113 '.kkkkkkkkkk.',
114 'kvvvvvvvvvVk',
115 'kvkkkkkkkkVk',
116 'kvkxxxxxxkVk',
117 'kvkXxxxxxkVk',
118 'kvkxXxxxxkVk',
119 'kvkXxXXxxkVk',
120 'kvkkkkkkkkVk',
121 'kVVVVVVVVVVk',
122 '.kkkkVVkkkk.',
123 '...kVVVVk...',
124 '..kkkkkkkk..',
125]
126/** A cog: a running process. */
127export const ICON_GEAR: Sprite = [
128 '.....kk.....',
129 '..kkkwwkkk..',
130 '.kwwkwskssk.',
131 '.kwwwsssssk.',
132 '.kkwskksskk.',
133 'kwwsk..ksSSk',
134 'kwssk..kSSSk',
135 '.kksskkSSkk.',
136 '.kssssSSSSk.',
137 '.ksskSSkSSk.',
138 '..kkkSSkkk..',
139 '.....kk.....',
140]
141/** A coin: tokens spent. */
142export const ICON_COIN: Sprite = [
143 '.....kk.....',
144 '...kRRRRk...',
145 '..kRvvvFRk..',
146 '.kRvvvRFFRk.',
147 '.RvvvRRFFFR.',
148 'kRvvFRRFFFRk',
149 'kRvFFRRFFFRk',
150 '.RFFFRRFFFR.',
151 '.kRFFRRFFRk.',
152 '..kRFFFFRk..',
153 '...kRRRRk...',
154 '.....kk.....',
155]
156const FONT: Readonly<Record<string, Sprite>> = {
157 D: ['####.', '#...#', '#...#', '#...#', '####.'],
158 E: ['#####', '#....', '####.', '#....', '#####'],
159 I: ['###', '.#.', '.#.', '.#.', '###'],
160 N: ['#...#', '##..#', '#.#.#', '#..##', '#...#'],
161 P: ['####.', '#...#', '####.', '#....', '#....'],
162 R: ['####.', '#...#', '####.', '#..#.', '#...#'],
163 S: ['.####', '#....', '.###.', '....#', '####.'],
164 T: ['#####', '..#..', '..#..', '..#..', '..#..'],
165 ' ': ['..', '..', '..', '..', '..'],
166}
167
168const BANNER_W = 128
169const BANNER_H = 44
170const GRASS = 34
171const GRAVES = [4, 38, 72, 106]
172const STARS: ReadonlyArray<readonly [number, number]> = [
173 [66, 3],
174 [80, 16],
175 [44, 12],
176 [123, 21],
177]
178const SPECKS: ReadonlyArray<readonly [number, number]> = [
179 [72, 9],
180 [92, 4],
181 [56, 13],
182 [8, 14],
183 [40, 19],
184 [100, 22],
185 [62, 21],
186 [16, 21],
187]
188const FIREFLIES: ReadonlyArray<readonly [number, number]> = [
189 [37, 27],
190 [71, 24],
191 [104, 29],
192 [2, 26],
193]
194
195type Canvas = string[][]
196
197const blank = (width: number, height: number, key = '.'): Canvas =>
198 Array.from({ length: height }, () => Array.from({ length: width }, () => key))
199
200function paint(canvas: Canvas, x: number, y: number, key: string): void {
201 const row = canvas[y]
202 if (row !== undefined && x >= 0 && x < row.length) row[x] = key
203}
204
205function stamp(canvas: Canvas, sprite: Sprite, left: number, top: number): void {
206 sprite.forEach((row, y) => {
207 ;[...row].forEach((key, x) => {
208 if (key !== '.') paint(canvas, left + x, top + y, key)
209 })
210 })
211}
212
213const flat = (canvas: Canvas): Sprite => canvas.map(row => row.join(''))
214
215/** A fixed 0-99 noise per pixel, so speckles look scattered yet never move between draws. */
216const scatter = (x: number, y: number): number => (((x * 73856093) ^ (y * 19349663)) >>> 0) % 100
217
218/** A crescent lit from the right: a disc with an offset disc bitten out of it. */
219function crescent(size: number): Sprite {
220 const r = size / 2
221 const canvas = blank(size, size)
222 for (let y = 0; y < size; y += 1) {
223 for (let x = 0; x < size; x += 1) {
224 const cx = x + 0.5 - r
225 const cy = y + 0.5 - r
226 const fromCentre = Math.hypot(cx, cy)
227 const fromBite = Math.hypot(cx + 0.45 * r, cy + 0.2 * r)
228 const bite = 0.85 * r
229 if (fromCentre > r || fromBite <= bite) continue
230 const key = fromBite <= bite + 1.2 ? 'n' : r - fromCentre < 1.2 && cx > 0 ? 'M' : 'm'
231 paint(canvas, x, y, key)
232 }
233 }
234 return flat(canvas)
235}
236
237/** `text` in the 5-pixel font, filled `fill`, outlined in `k` over a one-pixel drop shadow. */
238function lettering(text: string, fill: string): Sprite {
239 const glyphs = [...text].map(letter => FONT[letter] ?? FONT[' '] ?? [])
240 const inner = glyphs.reduce((width, glyph) => width + (glyph[0]?.length ?? 0) + 1, -1)
241 const canvas = blank(inner + 3, 5 + 3)
242 const ink: Array<[number, number]> = []
243 let left = 1
244 for (const glyph of glyphs) {
245 glyph.forEach((row, y) => {
246 ;[...row].forEach((bit, x) => {
247 if (bit === '#') ink.push([left + x, 1 + y])
248 })
249 })
250 left += (glyph[0]?.length ?? 0) + 1
251 }
252 const around = (key: string, shift: number) => {
253 for (const [x, y] of ink) {
254 for (let dy = -1; dy <= 1; dy += 1) {
255 for (let dx = -1; dx <= 1; dx += 1) paint(canvas, x + dx, y + dy + shift, key)
256 }
257 }
258 }
259 around('K', 1)
260 around('k', 0)
261 for (const [x, y] of ink) paint(canvas, x, y, fill)
262 return flat(canvas)
263}
264
265export const title = (): Sprite => lettering('REST IN PID', 'v')
266
267const farHill = (x: number) => 24 + Math.round(2.5 * Math.sin(x / 10) + 1.5 * Math.sin(x / 4.7))
268const nearHill = (x: number) => 29 + Math.round(2 * Math.sin(x / 7 + 1) + 1.5 * Math.sin(x / 3.1))
269
270/** The still part of the graveyard: sky, moon, hills, castle, title, graves and ground. */
271export function scene(): Sprite {
272 const canvas = blank(BANNER_W, BANNER_H, '6')
273 for (let y = 0; y < GRASS; y += 1) {
274 const band = Math.min(Math.floor(y / 6), SKY.length - 1)
275 for (let x = 0; x < BANNER_W; x += 1) {
276 // A dithered seam between bands, as SNES skies were banded.
277 const isSeam = band > 0 && y % 6 === 0 && (x + y) % 2 === 0
278 paint(canvas, x, y, SKY[isSeam ? band - 1 : band] ?? '6')
279 }
280 }
281 for (const [x, y] of SPECKS) paint(canvas, x, y, 'Y')
282 stamp(canvas, crescent(15), 106, 2)
283 stamp(canvas, WISP, 94, 12)
284 stamp(canvas, BAT, 84, 5)
285 stamp(canvas, BAT, 95, 1)
286 for (let x = 0; x < BANNER_W; x += 1) {
287 const far = farHill(x)
288 for (let y = far; y < GRASS; y += 1) paint(canvas, x, y, y === far ? 'H' : 'h')
289 }
290 stamp(canvas, CASTLE, 90, farHill(97) - CASTLE.length + 1)
291 for (let x = 0; x < BANNER_W; x += 1) {
292 const near = nearHill(x)
293 for (let y = near; y < GRASS; y += 1) paint(canvas, x, y, y === near ? 'I' : 'i')
294 }
295 stamp(canvas, title(), 4, 3)
296 for (const x of GRAVES) stamp(canvas, GRAVE, x, GRASS - GRAVE.length)
297 for (let x = 0; x < BANNER_W; x += 1) {
298 if (scatter(x, 1) < 35) paint(canvas, x, GRASS - 1, 'G')
299 for (let y = GRASS; y < BANNER_H; y += 1) {
300 const key =
301 y < GRASS + 2 ? (scatter(x, y) < 25 ? 'G' : 'g') : y === GRASS + 2 || scatter(x, y) < 9 ? 'D' : 'd'
302 paint(canvas, x, y, key)
303 }
304 }
305 return flat(canvas)
306}
307
308/** What moves over the scene, each sprite with where its top-left pixel goes. */
309export const ACTORS = {
310 stars: STARS.map(([x, y]) => ({ sprite: TWINKLE, x: x - 1, y: y - 1 })),
311 fireflies: FIREFLIES.map(([x, y]) => ({ sprite: FIREFLY, x, y })),
312}
313
314/** A segmented meter, as a status window draws HP: green, amber past 60%, coral past 85%. */
315export function meter(fraction: number, segments: number): Sprite {
316 const width = segments * 4 + 3
317 const canvas = blank(width, 8, 'u')
318 for (let x = 0; x < width; x += 1) {
319 paint(canvas, x, 0, 'U')
320 paint(canvas, x, 7, 'U')
321 }
322 for (let y = 0; y < 8; y += 1) {
323 paint(canvas, 0, y, 'U')
324 paint(canvas, width - 1, y, 'U')
325 }
326 const lit = fraction <= 0 ? 0 : Math.max(1, Math.min(segments, Math.round(fraction * segments)))
327 const fill = fraction > 0.85 ? 'Q' : fraction > 0.6 ? 'P' : 'p'
328 for (let segment = 0; segment < segments; segment += 1) {
329 for (let y = 2; y < 6; y += 1) {
330 for (let dx = 0; dx < 3; dx += 1) paint(canvas, 2 + segment * 4 + dx, y, segment < lit ? fill : 'U')
331 }
332 }
333 return flat(canvas)
334}
335
336/**
337 * A sprite as SVG paths, one per colour, placed at (x, y) and scaled: what
338 * every drawing is built from. One path per colour keeps a whole board far
339 * below the 131,072-character Svg cap.
340 */
341export function pixels(sprite: Sprite, x: number, y: number, scale: number): string {
342 const paths = new Map<string, string>()
343 sprite.forEach((row, top) => {
344 for (let left = 0; left < row.length; ) {
345 const key = row[left] ?? '.'
346 let run = 1
347 while (row[left + run] === key) run += 1
348 if (key !== '.') paths.set(key, `${paths.get(key) ?? ''}M${left} ${top}h${run}v1h-${run}z`)
349 left += run
350 }
351 })
352 const body = [...paths].map(([key, d]) => `<path fill="${PALETTE[key] ?? '#ff00ff'}" d="${d}"/>`).join('')
353 return `<g transform="translate(${x} ${y}) scale(${scale})" shape-rendering="crispEdges">${body}</g>`
354}
355
356/** Two pixels per terminal cell: the upper one as a half block's ink, the lower one as its background. */
357export function raster(sprite: Sprite): { columns: number; rows: number; cells: string } {
358 const columns = sprite[0]?.length ?? 0
359 const rows = Math.ceil(sprite.length / 2)
360 const colour = (key: string | undefined) =>
361 key === undefined || BACKDROP.has(key) ? TERMINAL_DEFAULT : parseInt((PALETTE[key] ?? '#ffffff').slice(1), 16)
362 const words = new Uint32Array(columns * rows * 3)
363 for (let y = 0; y < rows; y += 1) {
364 for (let x = 0; x < columns; x += 1) {
365 const top = colour(sprite[y * 2]?.[x])
366 const bottom = colour(sprite[y * 2 + 1]?.[x])
367 const cell =
368 top !== TERMINAL_DEFAULT
369 ? [0x2580, top, bottom]
370 : bottom !== TERMINAL_DEFAULT
371 ? [0x2584, bottom, TERMINAL_DEFAULT]
372 : [0x20, TERMINAL_DEFAULT, TERMINAL_DEFAULT]
373 words.set(cell, (y * columns + x) * 3)
374 }
375 }
376 let binary = ''
377 for (const byte of new Uint8Array(words.buffer)) binary += String.fromCharCode(byte)
378 return { columns, rows, cells: btoa(binary) }
379}
380hooks/press.tsx 26 lines1import type { ClientModule } from 'claude-code'
2
3/**
4 * A see-through region laid over a section's drawn heading, so the whole
5 * strip toggles its section: an Svg takes no press, and a Button would draw
6 * its own chrome over the design. A click, or Enter once it has the focus,
7 * tells the hooks module which section to open or close.
8 */
9const Press: ClientModule<{ section: string }, true> = (props, surface) => {
10 if (surface.state === undefined) {
11 const toggle = () => surface.post({ toggle: props.section })
12 // A press lands on the button going down, which only happens inside the region.
13 surface.onPointer(event => {
14 if (event.type === 'down' && event.button === 'left') toggle()
15 })
16 surface.onKey(event => {
17 if (event.key === 'return' || event.key === ' ') toggle()
18 })
19 surface.setState(true)
20 }
21 const { Box } = surface.elements
22 return <Box width="100%" height="100%" />
23}
24
25export default Press
26types/index.d.ts 74 lines1/** One process on the board: a live session's own, or part of a zombie's tree. */
2export type Proc = {
3 pid: number
4 ppid: number
5 /** Its process group, which a child spawned while it dies shares. */
6 pgid: number
7 /** `ps` lstart in UTC. With `pid` and `command` it is what a kill checks again. */
8 started: string
9 command: string
10 label: string
11 cpu: number
12 rssKb: number
13 isDefunct: boolean
14 depth: number
15}
16
17export type Session = {
18 sessionId: string
19 /** The session's host process while it runs; null once it ended. */
20 pid: number | null
21 name: string
22 status: string
23 place: string
24 tokensIn: number
25 tokensOut: number
26}
27
28export type SessionGroup = { session: Session; procs: Proc[] }
29
30export type ZombieReason = 'session-ended' | 'no-live-session' | 'worktree-deleted'
31
32export type Zombie = {
33 root: Proc
34 /** The root's descendants, depth-first. */
35 children: Proc[]
36 reason: ZombieReason
37 session: Session | null
38 place: string
39}
40
41export type Totals = {
42 procs: number
43 cpu: number
44 rssKb: number
45 tokensIn: number
46 tokensOut: number
47}
48
49/** What the CPU and memory meters measure against; 0 where the host would not say. */
50export type Machine = { cores: number; memoryKb: number }
51
52export type Snapshot = {
53 scannedAt: number
54 groups: SessionGroup[]
55 zombies: Zombie[]
56 totals: Totals
57 machine: Machine
58 error: string | null
59}
60
61declare module 'claude-code' {
62 interface PluginState {
63 'rest-in-pid': {
64 snapshot: Snapshot | null
65 isArmed: boolean
66 killing: number[]
67 isPlaced: boolean
68 zombieCount: number
69 zombiesOpen: boolean
70 sessionsOpen: boolean
71 }
72 }
73}
74