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

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.
/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.
Coursework tooling.
| Skill | What it does |
|---|---|
| course-setup | Scaffold a new course folder and clean Canvas file dumps; owns the school.json registry |
| grade-calc | Exact grade math from syllabus weights: current grade, what-ifs, target scores |
| rubric-check | Grade a draft against its assignment spec before submitting |
| study-guide | Turn lecture PDFs into a self-contained interactive HTML study package |
| notebooklm-course-sync | Keep a course's NotebookLM notebook in sync with local files |
| sapling-ai-detector | Scan text for AI-generated content with a per-sentence report |
Client and project delivery.
| Skill | What it does |
|---|---|
| linear-assistant | Create, update, and query Linear issues, projects, milestones, and cycles |
Personal-life tooling.
| Skill | What it does |
|---|---|
| apple-calendar | Read and write Apple Calendar from a machine that reaches it over the network via the apple-calendar MCP server |
Everything cross-cutting.
| Skill | What it does |
|---|---|
| claude-toolkit | Add, sync, and set up this repo's components across machines |
| claude-documentation | Generate consistent README docs for skills, hooks, and sub-agents |
| skill-builder | Build a new skill through a structured, validated process |
| docx | Create, read, and edit Word documents, including tracked changes and comments |
| notebooklm | Full programmatic NotebookLM API: notebooks, sources, artifacts, downloads |
| tailnet | Move 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.
My development workflow chain, split across two sessions with a context clear in between.
| Skill | What it does |
|---|---|
| spec | Turn an idea into an approved specification |
| plan | Break an approved spec into waves of file-disjoint tasks |
| implement | Execute the plan across persistent subagent slots in git worktrees |
| review | Whole-branch review pass with specialist fan-out |
| test | Run 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.
| Skill | What it does |
|---|---|
| skill-creator | Create and improve skills, run evals, benchmark performance, grade a SKILL.md against a structural rubric |
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.
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.
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.
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.
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.
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.
Nothing machine-specific is committed here. Two files live outside the repo:
| File | Used by | Notes |
|---|---|---|
~/.claude/tailnet-servers.json | tailnet | Server registry: addresses, SSH aliases, default destinations. See servers.example.json for the schema |
~/.claude/settings.json | everything | Permissions, env, enabled plugins, status line wiring |
hooks/register.tsx 587 lines1import { 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}
587types/index.d.ts 57 lines1export 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