SLOPSHOPPER

Rest in PID

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

newpanebandrowscommandtoast
v1.0.1MITupdated 2026-10-07jwchang0206/rest-in-pid
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · rest-in-pid
│ ┃ Rest in PID ✕ › fix the failing auth test and add an audit log call │ ┃ REST IN PID │ ┃ SESSIONS 0 PROCESSES 0 ZOMBIES 0 ⏺ Read(src/auth.ts) │ ┃ CPU ▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱ 0.0% ⎿ Read 6 lines │ ┃ MEM ▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱ 0 B ⏺ Update(src/auth.ts) │ ┃ TOKENS 0 / 0 ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ Could not start: Rest in PID reads macOS and ⎿ 3 pass, 1 fail │ ┃ Linux processes, not this system's │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ALL QUIET · NO ZOMBIES │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ [ ⌄ SESSIONS · 0 ] │ › /rest-in-pid │ ⎿ rest-in-pid: Rest in PID is open. 0 zombies, 0 processes across │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Rest in PID
REST IN PID SESSIONS 0 PROCESSES 0 ZOMBIES 0 CPU ▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱ 0.0% MEM ▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱ 0 B TOKENS 0 / 0 Could not start: Rest in PID reads macOS and Linux processes, not this system's ALL QUIET · NO ZOMBIES [ ⌄ SESSIONS · 0 ]
README

Rest in PID

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.

The Rest in PID board: a pixel-art night graveyard above counts of sessions, processes and zombies, CPU and memory meters, and tokens in and out, with the Zombies and Sessions sections folded below

Run /rest-in-pid to open the board:

  • Status window: sessions, processes and zombies at a glance; CPU and memory as segmented meters against the whole machine; tokens spent.
  • Zombies: processes whose session is gone, each with a Kill button, plus Kill all (press twice within four seconds to confirm).
  • Sessions: every running Claude Code session, the worktree it works in, its token usage (input with cache reads and writes, and output), and the processes it spawned, with CPU and memory for each.

While zombies exist, a band above the prompt counts them and opens the board, and a toast announces each new one.

A band above the prompt reading "2 zombies left behind by ended sessions", with an Open graveyard button

What counts as a zombie

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:

  • session ended: the session it inherited has no running host any more (the pid is gone or was reused, or no running session holds the id), or it is a Bash tool shell still sourcing a shell snapshot its session deleted (Claude Code deletes a session's snapshot when the session exits);
  • no live session: nothing says which session started it, it sits in a .claude/worktrees/<name> folder, and no running session works there or in that repository's main checkout;
  • worktree deleted: nothing says which session started it, and the worktree folder it sits in no longer exists.

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.

What Kill does

  1. Scans again, so each chosen tree is taken as it runs now: with the children it spawned since the board was drawn, and without anything that has since come to life, such as a shell a new session started in.
  2. Re-reads 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.
  3. Sends SIGTERM to the tree, deepest processes first.
  4. Two seconds later, scans again and sends 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.

What it runs and reads

Everything stays on your machine: the mod makes no network requests.

  • Runs 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.
  • Reads 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.

Install

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.

Develop

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.

License

MIT

Source 7 files
hooks/register.tsx 510 lines
1import { 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}
510
hooks/model.ts 602 lines
1import 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}
602
hooks/panels.ts 386 lines
1import 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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
386
hooks/scan.ts 284 lines
1import 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}
284
hooks/sprites.ts 380 lines
1/** 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}
380
hooks/press.tsx 26 lines
1import 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
26
types/index.d.ts 74 lines
1/** 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