SLOPSHOPPER

processes

Sidebar section listing the background and detached processes Claude starts, with their ports and a kill button

newguardtoastprocesstimer
★ 1v0.1.0no licenseupdated 2026-10-10hunterbrewer04/claude-toolkit/plugins/processes
A shopper browsing a rack in a slop shop
README

BrewKit

My personal Claude Code setup, packaged as a plugin marketplace so I can install exactly the pieces a given machine needs.

This is a public snapshot of how I extend Claude Code. It ships 11 plugins covering 20 skills, 1 sub-agent, 4 hooks, 5 mods, and a custom status line.

Most pieces follow my own paths and conventions, so treat them as reference patterns to borrow from rather than drop-in installs.

Install

/plugin marketplace add hunterbrewer04/claude-toolkit
/plugin install school@brewkit

Install only the plugins that machine actually needs. Nothing here assumes the others are present.

Plugins

school

Coursework tooling.

SkillWhat it does
course-setupScaffold a new course folder and clean Canvas file dumps; owns the school.json registry
grade-calcExact grade math from syllabus weights: current grade, what-ifs, target scores
rubric-checkGrade a draft against its assignment spec before submitting
study-guideTurn lecture PDFs into a self-contained interactive HTML study package
notebooklm-course-syncKeep a course's NotebookLM notebook in sync with local files
sapling-ai-detectorScan text for AI-generated content with a per-sentence report

work

Client and project delivery.

SkillWhat it does
linear-assistantCreate, update, and query Linear issues, projects, milestones, and cycles

personal

Personal-life tooling.

SkillWhat it does
apple-calendarRead and write Apple Calendar from a machine that reaches it over the network via the apple-calendar MCP server

misc

Everything cross-cutting.

SkillWhat it does
claude-toolkitAdd, sync, and set up this repo's components across machines
claude-documentationGenerate consistent README docs for skills, hooks, and sub-agents
skill-builderBuild a new skill through a structured, validated process
docxCreate, read, and edit Word documents, including tracked changes and comments
notebooklmFull programmatic NotebookLM API: notebooks, sources, artifacts, downloads
tailnetMove files to tailnet servers, serve files over Tailscale, Taildrop to a phone

Also ships three hooks that apply everywhere: a PreToolUse guard against committing .env files, a SessionStart agent-state tracker, and a Stop desktop notification.

dev-flow

My development workflow chain, split across two sessions with a context clear in between.

SkillWhat it does
specTurn an idea into an approved specification
planBreak an approved spec into waves of file-disjoint tasks
implementExecute the plan across persistent subagent slots in git worktrees
reviewWhole-branch review pass with specialist fan-out
testRun the plan's verification section, then commit and open the PR

Includes the code-reviewer sub-agent used by the review step, and a SessionStart resume hook.

meta-builders

SkillWhat it does
skill-creatorCreate and improve skills, run evals, benchmark performance, grade a SKILL.md against a structural rubric

sidebar

A mod: a docked sidebar for the widget mods. Widgets publish their section and handle their own buttons; the sidebar only draws, so a new widget needs no change here. The layout option picks how: tabs (the default) gives each widget its own pane, shown as tabs titled with the widget's badge (Checklist 93%), opened when the widget has something, focused when it asks for attention, and closed when it empties; stacked draws every section in one pane that opens itself on attention. The order option sets the tab order or the stacking. /sidebar opens it, /sidebar close hides it.

checklist

A mod: a live checklist of the current plan, drawn as a section in the sidebar. Claude posts the steps through its own checklist tool and checks each one off as it finishes, subagents working a step show under it with their status and tool-call count, and the status line carries done/total. /checklist prints the list, /checklist clear empties it.

questions

A mod: questions Claude asks without stopping the work. Each one comes with the default Claude goes with meanwhile and gets an answer field in the sidebar. An answer reaches Claude on its next step, with a note to redo anything built on the default. Questions still open at the end of a turn get a toast.

processes

A mod: the background commands and dev servers Claude started this session, shown in the sidebar with their ports and uptime and a button that kills one. Nothing is ever killed automatically; anything still running when the session ends gets a toast.

review-pair

A mod: a second Claude session that reviews every commit the session you are working in makes. The first commit in a repo asks once (always, not now, never). On always, a Sonnet reviewer opens in a herdr pane beside you (or as a background session outside herdr) and gets each new commit range. Blockers go back to the builder, which fixes them, for up to two rounds; nits and clean reviews go to a Review tab in the sidebar, and nits are posted as one PR comment when the builder opens a PR. The reviewer cannot use the Edit or Write tools and closes when the last builder in that repo ends. /review-pair shows the status; on, off, forget and stop change it.

Status line

statusline/ holds an agnoster-inspired three-row status line: where you are, what you are running, and what you are burning. Point statusLine.command in settings.json at statusline-command.sh.

Machine-local configuration

Nothing machine-specific is committed here. Two files live outside the repo:

FileUsed byNotes
~/.claude/tailnet-servers.jsontailnetServer registry: addresses, SSH aliases, default destinations. See servers.example.json for the schema
~/.claude/settings.jsoneverythingPermissions, env, enabled plugins, status line wiring
Source 2 files
hooks/register.tsx 587 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { ProcessBook, ProcessEntry, SidebarRow, SidebarSection } from '../types'
5
6const SIDEBAR = 'sidebar'
7const KILL_PREFIX = 'processes:kill:'
8const EMPTY: ProcessBook = { seq: 0, entries: [] }
9const DETACHED: readonly RegExp[] = [/&\s*$/, /&\s*;/, /\bnohup\b/, /\bdisown\b/, /\bsetsid\b/]
10
11const POLL_MS = 3000
12// Offsets from the Bash call at which a detached server is looked for.
13const SCANS_MS: readonly number[] = [2000, 5000, 10000]
14const BEFORE_MS = 1500
15const LINGER_MS = 30000
16const FORCE_AFTER_MS = 3000
17const UNRESOLVED_MS = 30000
18const NAME_WIDTH = 30
19const ANCESTRY_DEPTH = 64
20
21const book = atom({ plugin: 'processes', key: 'book' } as const, EMPTY)
22const section = { plugin: 'processes', key: 'section' } as const
23
24type Row = { pid: number; ppid: number; pgid: number; command: string }
25type Listener = { pid: number; port: number }
26type Snapshot = { rows: Map<number, Row>; listeners: Listener[]; claude: number | undefined }
27type Before = { listeners: Set<string>; groups: Set<number> }
28type Draft = {
29  command: string
30  source: ProcessEntry['source']
31  taskId?: string
32  pid?: number
33  pgid?: number
34  processCommand?: string
35  ports?: number[]
36}
37
38// The poll timer is not drawn, so it lives in the module. A hot reload cancels
39// it and resets this, so any hook below restarts it on demand (see resume).
40let poller: Timer | undefined
41// Found once from the spawning process of `$.process.run`; see claudeOf.
42let claudePid: number | undefined
43// PIDs alive just before a background Bash call, by task id. The task's own
44// processes are new, so findTask skips these. Lost on reload, which only widens the search.
45const taskPids = new Map<string, Set<number>>()
46
47export const register: Register = on => {
48  on('session.start', async ($, e, next) => {
49    await publish($)
50    await resume($)
51
52    return next(e)
53  }).catch(($, e, next) => next(e))
54
55  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
56    await resume($).catch(() => undefined)
57    const command = squash(typeof e.command === 'string' ? e.command : '')
58    const detaching = DETACHED.some(re => re.test(command))
59    const before = detaching ? await beforeDetach($) : undefined
60    const pids = e.run_in_background === true ? await pidSet($) : undefined
61    const ran = await next(e)
62    try {
63      const taskId = (ran.result as { backgroundTaskId?: unknown } | undefined)?.backgroundTaskId
64      if (ran.deny === undefined && typeof taskId === 'string') {
65        if (pids !== undefined) taskPids.set(taskId, pids)
66        await track($, { command, source: 'background', taskId })
67      } else if (before !== undefined) {
68        scanDetached($, command, before, 0, 0)
69      }
70    } catch {
71      // Tracking is best effort; the Bash result already stands.
72    }
73
74    return ran
75  }).catch(($, e, next) => next(e))
76
77  on('ui.press', { plugin: SIDEBAR }, async ($, e, next) => {
78    if (!e.element.startsWith(KILL_PREFIX)) return next(e)
79    await resume($)
80    await kill($, e.element.slice(KILL_PREFIX.length))
81
82    return next(e)
83  }).catch(($, e, next) => next(e))
84
85  on('session.end', async ($, e, next) => {
86    const { entries } = await read($, book)
87    const running = entries.filter(isRunning)
88    if (running.length > 0) $.ui.toast(`Still running: ${running.map(describe).join(', ')}`)
89    if (e.reason === 'clear') await clearExited($)
90
91    return next(e)
92  }).catch(($, e, next) => next(e))
93}
94
95function isRunning(entry: ProcessEntry): boolean {
96  return entry.status === 'running'
97}
98
99// Starts the poller when entries are running and no timer is active, which
100// also covers a hot reload, after which `session.start` does not fire.
101async function resume($: EngineInterface): Promise<void> {
102  if (poller !== undefined) return
103  const { entries } = await read($, book)
104  if (entries.some(isRunning)) startPolling($)
105}
106
107async function track($: EngineInterface, draft: Draft): Promise<void> {
108  const now = await $.clock.now()
109  await update($, book, current => {
110    const seq = current.seq + 1
111    const entry: ProcessEntry = {
112      id: `p${seq}`,
113      command: draft.command,
114      source: draft.source,
115      ports: draft.ports ?? [],
116      startedAt: now,
117      status: 'running',
118      ...(draft.taskId === undefined ? {} : { taskId: draft.taskId }),
119      ...(draft.pid === undefined ? {} : { pid: draft.pid }),
120      ...(draft.pgid === undefined ? {} : { pgid: draft.pgid }),
121      ...(draft.processCommand === undefined ? {} : { processCommand: draft.processCommand }),
122    }
123    return { seq, entries: [...current.entries, entry] }
124  })
125  await publish($)
126  startPolling($)
127}
128
129async function kill($: EngineInterface, id: string): Promise<void> {
130  const { entries } = await read($, book)
131  const entry = entries.find(one => one.id === id && isRunning(one))
132  if (entry === undefined) return
133
134  if (entry.source === 'background' && entry.taskId !== undefined) {
135    if (await stopTask($, entry.taskId)) {
136      await markExited($, id)
137      return
138    }
139  }
140
141  const snap = await snapshot($)
142  const check = verify(entry, snap)
143  if (check === 'gone') {
144    await markExited($, id)
145    return
146  }
147  if (check === 'unsafe' || entry.pgid === undefined) {
148    $.ui.toast(`Could not stop ${clip(entry.command, NAME_WIDTH)}`)
149    return
150  }
151  await $.process.run(['kill', '-TERM', '--', `-${entry.pgid}`]).catch(() => undefined)
152  // The entry stays running until a poll sees it gone.
153  startPolling($)
154  $.clock.after(FORCE_AFTER_MS, () => void forceGroup($, id))
155}
156
157// Whether the entry's stored process is still the one found: same pid, same
158// group, same command line. A reused pid reads as gone, and nothing is signalled
159// without knowing Claude Code's own pid.
160function verify(entry: ProcessEntry, snap: Snapshot): 'ok' | 'gone' | 'unsafe' {
161  if (entry.pid === undefined || entry.pgid === undefined || entry.processCommand === undefined) return 'unsafe'
162  if (!isSafeGroup(entry.pgid, snap)) return 'unsafe'
163  const row = snap.rows.get(entry.pid)
164  if (row === undefined || row.pgid !== entry.pgid || row.command !== entry.processCommand) return 'gone'
165
166  return 'ok'
167}
168
169async function stopTask($: EngineInterface, taskId: string): Promise<boolean> {
170  const ran = await $.tool.call({ tool: 'TaskStop', task_id: taskId }).catch(() => undefined)
171
172  return ran !== undefined && ran.deny === undefined && ran.isError !== true
173}
174
175async function forceGroup($: EngineInterface, id: string): Promise<void> {
176  const { entries } = await read($, book)
177  const entry = entries.find(one => one.id === id && isRunning(one))
178  if (entry?.pgid === undefined) return
179  const snap = await snapshot($)
180  if (verify(entry, snap) !== 'ok') return
181  await $.process.run(['kill', '-KILL', '--', `-${entry.pgid}`]).catch(() => undefined)
182}
183
184async function markExited($: EngineInterface, id: string): Promise<void> {
185  const now = await $.clock.now()
186  const after = await update($, book, current => ({
187    ...current,
188    entries: current.entries.map(entry => (entry.id === id && isRunning(entry) ? exited(entry, now) : entry)),
189  }))
190  lingerExited($, after.entries, now)
191  await publish($)
192}
193
194// `/clear` drops what has exited; processes still running stay listed.
195async function clearExited($: EngineInterface): Promise<void> {
196  const after = await update($, book, current => ({ ...current, entries: current.entries.filter(isRunning) }))
197  if (after.entries.length === 0) stopPolling()
198  await publish($)
199}
200
201function startPolling($: EngineInterface): void {
202  if (poller !== undefined) return
203  poller = $.clock.every(POLL_MS, () => void poll($))
204}
205
206function stopPolling(): void {
207  poller?.cancel()
208  poller = undefined
209}
210
211async function poll($: EngineInterface): Promise<void> {
212  try {
213    const { entries } = await read($, book)
214    if (!entries.some(isRunning)) {
215      stopPolling()
216      return
217    }
218    const snap = await snapshot($)
219    const now = await $.clock.now()
220    const after = await update($, book, current => {
221      const claimed = new Set(current.entries.flatMap(entry => (isRunning(entry) && entry.pgid !== undefined ? [entry.pgid] : [])))
222      return { ...current, entries: current.entries.map(entry => refresh(entry, snap, now, claimed)) }
223    })
224    for (const entry of after.entries) {
225      if (entry.taskId !== undefined && (entry.pgid !== undefined || !isRunning(entry))) taskPids.delete(entry.taskId)
226    }
227    lingerExited($, after.entries, now)
228    await publish($)
229    if (!after.entries.some(isRunning)) stopPolling()
230  } catch {
231    // A failed poll leaves the entries as they were; the next tick tries again.
232  }
233}
234
235// Exited entries drop 30 seconds after they exited. Only entries that ended at
236// `now` are scheduled, so an earlier exit is never scheduled twice.
237function lingerExited($: EngineInterface, entries: ProcessEntry[], now: number): void {
238  for (const entry of entries) {
239    if (entry.status === 'exited' && entry.endedAt === now) {
240      $.clock.after(LINGER_MS, () => void drop($, entry.id, now))
241    }
242  }
243}
244
245async function drop($: EngineInterface, id: string, endedAt: number): Promise<void> {
246  await update($, book, current => ({
247    ...current,
248    entries: current.entries.filter(entry => !(entry.id === id && entry.endedAt === endedAt)),
249  }))
250  await publish($)
251}
252
253// What a detached server is compared against: the listeners and process groups
254// that existed before the Bash call. Undefined when either look-up does not
255// finish in time, and that call then adopts nothing.
256async function beforeDetach($: EngineInterface): Promise<Before | undefined> {
257  const [ps, lsof] = await Promise.all([
258    attempt($, ['ps', '-axwwo', 'pid=,ppid=,pgid=,command='], BEFORE_MS),
259    attempt($, ['lsof', '-nP', '-iTCP', '-sTCP:LISTEN', '-F', 'pcn'], BEFORE_MS),
260  ])
261  if (ps === undefined || lsof === undefined) return undefined
262  const groups = new Set([...parseRows(ps).values()].map(row => row.pgid))
263
264  return { listeners: new Set(parseListeners(lsof).map(listenerKey)), groups }
265}
266
267async function pidSet($: EngineInterface): Promise<Set<number> | undefined> {
268  const ps = await attempt($, ['ps', '-axwwo', 'pid=,ppid=,pgid=,command='], BEFORE_MS)
269
270  return ps === undefined ? undefined : new Set(parseRows(ps).keys())
271}
272
273// Scans at each offset in SCANS_MS, stopping at the first scan that adopts something.
274function scanDetached($: EngineInterface, command: string, before: Before, index: number, elapsed: number): void {
275  const at = SCANS_MS[index]
276  if (at === undefined) return
277  $.clock.after(at - elapsed, async () => {
278    const found = await settleDetached($, command, before)
279    if (!found) scanDetached($, command, before, index + 1, at)
280  })
281}
282
283async function settleDetached($: EngineInterface, command: string, before: Before): Promise<boolean> {
284  try {
285    const cwd = await sessionCwd($)
286    if (cwd === undefined) return false
287    const snap = await snapshot($)
288    if (snap.claude === undefined) return false
289    const { entries } = await read($, book)
290    const fresh = snap.listeners.filter(
291      listener =>
292        !before.listeners.has(listenerKey(listener)) &&
293        !entries.some(entry => isRunning(entry) && entry.pid === listener.pid),
294    )
295    let found = false
296    for (const pid of [...new Set(fresh.map(listener => listener.pid))]) {
297      const row = snap.rows.get(pid)
298      if (row === undefined || before.groups.has(row.pgid) || !isSafeGroup(row.pgid, snap)) continue
299      if (!isBashGroup(row, snap)) continue
300      const dir = await cwdOf($, pid)
301      if (dir === undefined || !(dir === cwd || dir.startsWith(`${cwd}/`))) continue
302      const ports = uniquePorts(fresh.filter(listener => listener.pid === pid).map(listener => listener.port))
303      await track($, { command, source: 'detached', pid, pgid: row.pgid, processCommand: row.command, ports })
304      found = true
305    }
306
307    return found
308  } catch {
309    // The scan is best effort; a failed scan adds no entry.
310    return false
311  }
312}
313
314// A Bash tool shell leads its own process group, and what it detaches keeps
315// that group with init as its parent once the shell exits. So the group's
316// leader must be under Claude Code or gone, and the topmost process of the
317// group above this one must be under Claude Code or orphaned to init. A server
318// started from the person's own terminal fails both.
319function isBashGroup(row: Row, snap: Snapshot): boolean {
320  const leader = snap.rows.get(row.pgid)
321  if (leader !== undefined && !isUnder(leader, snap)) return false
322  let top = row
323  for (let depth = 0; depth < ANCESTRY_DEPTH; depth += 1) {
324    const parent = snap.rows.get(top.ppid)
325    if (parent === undefined || parent.pgid !== row.pgid) break
326    top = parent
327  }
328
329  return top.ppid === 1 || isUnder(top, snap)
330}
331
332function refresh(entry: ProcessEntry, snap: Snapshot, now: number, claimed: Set<number>): ProcessEntry {
333  if (!isRunning(entry)) return entry
334
335  let root = entry.pid
336  let group = entry.pgid
337  let processCommand = entry.processCommand
338  if (entry.source === 'background' && group === undefined) {
339    const found = findTask(entry, snap, claimed)
340    if (found === undefined) return now - entry.startedAt > UNRESOLVED_MS ? exited(entry, now) : entry
341    root = found.pid
342    group = found.pgid
343    processCommand = found.command
344    claimed.add(found.pgid)
345  }
346
347  const own = root === undefined ? undefined : snap.rows.get(root)
348  const roots =
349    entry.source === 'background' && group !== undefined
350      ? [...snap.rows.values()].filter(row => row.pgid === group).map(row => row.pid)
351      : own !== undefined && (group === undefined || own.pgid === group)
352        ? [own.pid]
353        : []
354  if (roots.length === 0) return exited(entry, now)
355
356  const tree = descendants(roots, snap.rows)
357  const ports = uniquePorts(snap.listeners.filter(listener => tree.has(listener.pid)).map(listener => listener.port))
358
359  return {
360    ...entry,
361    pid: root,
362    pgid: group,
363    ports,
364    ...(processCommand === undefined ? {} : { processCommand }),
365  }
366}
367
368// A background task is the earliest new process under Claude Code, in a group
369// no other entry holds, whose command line (with the Bash tool's `eval '...'`
370// quoting undone) contains the task's whole command.
371function findTask(entry: ProcessEntry, snap: Snapshot, claimed: Set<number>): Row | undefined {
372  if (entry.command === '' || snap.claude === undefined) return undefined
373  const old = entry.taskId === undefined ? undefined : taskPids.get(entry.taskId)
374  const hits = [...snap.rows.values()]
375    .filter(
376      row =>
377        row.pid !== snap.claude &&
378        !claimed.has(row.pgid) &&
379        !(old?.has(row.pid) ?? false) &&
380        isUnder(row, snap) &&
381        unquote(row.command).includes(entry.command),
382    )
383    .sort((a, b) => a.pid - b.pid)
384
385  return hits[0]
386}
387
388// The Bash tool runs a command as `eval '<command>'`, writing each `'` in it
389// as `'"'"'`. Undo that so ps text compares with the command as Claude wrote it.
390function unquote(text: string): string {
391  return squash(text.split(`'"'"'`).join(`'`).split(`'\\''`).join(`'`))
392}
393
394// Without Claude Code's pid nothing counts as under it.
395function isUnder(row: Row, snap: Snapshot): boolean {
396  if (snap.claude === undefined) return false
397  let pid = row.ppid
398  for (let depth = 0; depth < ANCESTRY_DEPTH && pid > 1; depth += 1) {
399    if (pid === snap.claude) return true
400    pid = snap.rows.get(pid)?.ppid ?? 0
401  }
402
403  return false
404}
405
406// Never signal the process group Claude Code itself is in, or init's, or any
407// group while Claude Code's pid is unknown.
408function isSafeGroup(group: number, snap: Snapshot): boolean {
409  if (snap.claude === undefined || group <= 1 || group === snap.claude) return false
410  const claudeGroup = snap.rows.get(snap.claude)?.pgid
411
412  return group !== claudeGroup
413}
414
415function descendants(roots: number[], rows: Map<number, Row>): Set<number> {
416  const children = new Map<number, number[]>()
417  for (const row of rows.values()) {
418    children.set(row.ppid, [...(children.get(row.ppid) ?? []), row.pid])
419  }
420  const seen = new Set<number>(roots)
421  const queue = [...roots]
422  for (let next = queue.shift(); next !== undefined; next = queue.shift()) {
423    for (const child of children.get(next) ?? []) {
424      if (!seen.has(child)) {
425        seen.add(child)
426        queue.push(child)
427      }
428    }
429  }
430
431  return seen
432}
433
434async function snapshot($: EngineInterface): Promise<Snapshot> {
435  const rows = parseRows(await output($, ['ps', '-axwwo', 'pid=,ppid=,pgid=,command=']))
436  const listeners = parseListeners(await output($, ['lsof', '-nP', '-iTCP', '-sTCP:LISTEN', '-F', 'pcn']))
437
438  return { rows, listeners, claude: await claudeOf($, rows) }
439}
440
441// `$.process.run` spawns `sh` from the Claude Code process, so the `sh` reports
442// that process as its parent. Walk up from there until a command names claude,
443// which covers any wrapper in between. Falls back to the spawning process.
444async function claudeOf($: EngineInterface, rows: Map<number, Row>): Promise<number | undefined> {
445  if (claudePid !== undefined) return claudePid
446  const spawned = Number.parseInt((await output($, ['sh', '-c', 'echo $PPID'])).trim(), 10)
447  if (!Number.isInteger(spawned) || spawned <= 1) return undefined
448  if (rows.size === 0) return spawned
449
450  let pid = spawned
451  for (let depth = 0; depth < 8; depth += 1) {
452    const row = rows.get(pid)
453    if (row === undefined) break
454    if (/claude/i.test(row.command)) {
455      claudePid = pid
456      return pid
457    }
458    if (row.ppid <= 1) break
459    pid = row.ppid
460  }
461  claudePid = spawned
462
463  return spawned
464}
465
466function parseRows(out: string): Map<number, Row> {
467  const rows = new Map<number, Row>()
468  for (const line of out.split('\n')) {
469    const match = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(.*)$/.exec(line)
470    if (match === null) continue
471    const pid = Number(match[1])
472    rows.set(pid, { pid, ppid: Number(match[2]), pgid: Number(match[3]), command: match[4] ?? '' })
473  }
474
475  return rows
476}
477
478function parseListeners(out: string): Listener[] {
479  const found: Listener[] = []
480  let pid: number | undefined
481  for (const line of out.split('\n')) {
482    if (line.startsWith('p')) {
483      pid = Number(line.slice(1))
484    } else if (line.startsWith('n') && pid !== undefined) {
485      const port = /:(\d+)$/.exec(line)
486      if (port?.[1] !== undefined) found.push({ pid, port: Number(port[1]) })
487    }
488  }
489
490  return found
491}
492
493function listenerKey(listener: Listener): string {
494  return `${listener.pid}:${listener.port}`
495}
496
497async function cwdOf($: EngineInterface, pid: number): Promise<string | undefined> {
498  const out = await output($, ['lsof', '-a', '-p', String(pid), '-d', 'cwd', '-Fn'])
499  const line = out.split('\n').find(one => one.startsWith('n'))
500
501  return line === undefined ? undefined : line.slice(1)
502}
503
504async function sessionCwd($: EngineInterface): Promise<string | undefined> {
505  const dir = (await output($, ['pwd', '-P'])).trim()
506
507  return dir === '' ? undefined : dir
508}
509
510// Runs a host command and returns its stdout. A command that cannot run gives ''.
511async function output($: EngineInterface, argv: string[]): Promise<string> {
512  return (await attempt($, argv, 10000)) ?? ''
513}
514
515// Runs a host command within `timeoutMs`; undefined when it fails or times out.
516async function attempt($: EngineInterface, argv: string[], timeoutMs: number): Promise<string | undefined> {
517  try {
518    const ran = await $.process.run(argv, { timeoutMs })
519
520    return ran.stdout
521  } catch {
522    return undefined
523  }
524}
525
526function exited(entry: ProcessEntry, now: number): ProcessEntry {
527  return { ...entry, status: 'exited', endedAt: now, ports: [] }
528}
529
530async function publish($: EngineInterface): Promise<void> {
531  const now = await $.clock.now()
532  const { entries } = await read($, book)
533  await $.state.set(section, toSection(entries, now))
534}
535
536function toSection(entries: ProcessEntry[], now: number): SidebarSection {
537  const up = entries.filter(isRunning).length
538  const rows = entries.map((entry): SidebarRow => {
539    const running = isRunning(entry)
540    const ports = entry.ports.map(port => `:${port}`).join(' ')
541    const uptime = running ? formatUptime(now - entry.startedAt) : 'exited'
542    const text = [clip(entry.command, NAME_WIDTH), ports, uptime].filter(part => part !== '').join('  ')
543
544    return {
545      kind: 'item',
546      icon: running ? '●' : '○',
547      tone: running ? 'accent' : 'muted',
548      text,
549      ...(running ? { action: { key: `${KILL_PREFIX}${entry.id}`, label: 'x' } } : {}),
550    }
551  })
552
553  return {
554    version: 1,
555    title: 'Processes',
556    badge: `${up} up`,
557    isEmpty: entries.length === 0,
558    attention: 0,
559    rows,
560  }
561}
562
563function describe(entry: ProcessEntry): string {
564  return `${clip(entry.command, NAME_WIDTH)}${entry.ports.map(port => ` :${port}`).join('')}`
565}
566
567function formatUptime(ms: number): string {
568  const seconds = Math.floor(ms / 1000)
569  if (seconds < 60) return `${seconds}s`
570  const minutes = Math.floor(seconds / 60)
571  if (minutes < 60) return `${minutes}m ${seconds % 60}s`
572
573  return `${Math.floor(minutes / 60)}h ${minutes % 60}m`
574}
575
576function uniquePorts(ports: number[]): number[] {
577  return [...new Set(ports)].sort((a, b) => a - b)
578}
579
580function squash(text: string): string {
581  return text.replace(/\s+/g, ' ').trim()
582}
583
584function clip(text: string, max: number): string {
585  return text.length > max ? `${text.slice(0, max - 1)}…` : text
586}
587
types/index.d.ts 57 lines
1export type SidebarTone = 'accent' | 'done' | 'muted' | 'error' | 'warn' | 'text'
2
3export type SidebarRow =
4  | { kind: 'progress'; fraction: number; label?: string }
5  | {
6      kind: 'item'
7      icon: string
8      tone?: SidebarTone
9      number?: number
10      text: string
11      state?: 'normal' | 'active' | 'done'
12      sub?: { icon?: string; tone?: SidebarTone; text: string }
13      action?: { key: string; label: string }
14    }
15  | { kind: 'input'; key: string; placeholder?: string; hint?: string }
16  | { kind: 'button'; key: string; label: string; primary?: boolean }
17  | { kind: 'text'; text: string; tone?: SidebarTone; bold?: boolean }
18  | { kind: 'divider'; label: string }
19
20export type SidebarSection = {
21  version: 1
22  title: string
23  badge?: string
24  tone?: SidebarTone
25  isEmpty: boolean
26  attention: number
27  rows: SidebarRow[]
28}
29
30export type ProcessEntry = {
31  id: string
32  command: string
33  source: 'background' | 'detached'
34  /** The Bash background task id, for background entries. */
35  taskId?: string
36  pid?: number
37  pgid?: number
38  /** The ps command line of `pid` when it was found, checked again before any signal. */
39  processCommand?: string
40  ports: number[]
41  startedAt: number
42  endedAt?: number
43  status: 'running' | 'exited'
44}
45
46export type ProcessBook = {
47  /** Last id handed out; ids are never reused within a session. */
48  seq: number
49  entries: ProcessEntry[]
50}
51
52declare module 'claude-code' {
53  interface PluginState {
54    processes: { book: ProcessBook; section: SidebarSection }
55  }
56}
57