SLOPSHOPPER

process-concierge

Gives every dev server, watcher and background process the agent starts an owner, a port and a stop button, and catches duplicate starts

newpanebandguardcommandprocess
v0.1.2MITupdated 2026-10-03ccdwyer/process-concierge
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · process-concierge
│ ┃ Processes ✕ › fix the failing auth test and add an audit log call │ ┃ [ refresh ] │ ┃ Nothing tracked yet. Background jobs the ⏺ Read(src/auth.ts) │ ┃ agent starts show up here. ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /procs │ ⎿ process-concierge: Process Concierge: no tracked jobs are runnin │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Processes
[ refresh ] Nothing tracked yet. Background jobs the agent starts show up here.
README

Process Concierge

A Claude Code mod that gives every dev server, watcher and background job the agent starts an owner, a port and a stop button, so abandoned processes stop piling up.

  • Runs what the agent starts under a supervisor. A background job (run_in_background, a trailing &, nohup, or a known dev server or watcher) is launched through a small shell script, bin/pc-run, that leads a new process group for that job.
  • A band above the prompt: ⚙ 2 running · :3000 :8081 · /procs, drawn above any other mod's band.
  • /procs opens a pane listing each job with its ports, age, process count, CPU, memory and folder, and a stop button. Stop all from this session, stop earlier sessions' jobs and clear finished sit at the top. /procs stop-all and /procs clean do the same from the prompt. Other listeners on the machine are listed below, for reference only.
  • No duplicates. When the agent tries to start a dev server that is already running in the same folder, or on a port something already holds, the call is refused and the model is told what to reuse.
  • Jobs left over from earlier sessions show up on the next start, and can still be stopped.

How ownership works

Process Concierge doesn't guess which processes belong to a job. It makes each job's processes a group it can prove it owns:

  1. The command is rewritten to launch through the supervisor, and is never split. The whole command line is handed unchanged to the supervisor, which runs it in your shell, so node api.js & node worker.js (in a run_in_background call) or cd web && npm run dev is one group with one stop button. A foreground line is wrapped when nothing in it is sent to the background, or when its only & is the one at the very end (cd web && npm run dev > dev.log 2>&1 &). Each supervisor has a random token.
  2. The supervisor checks in. It uses perl (part of macOS) to put itself in a new process group it leads. Every helper it runs is called by absolute path (/bin/ps, /bin/date, …). It first clears what could make it run code your command never named: PERL5OPT/PERL5LIB, exported shell functions, and SHELLOPTS/BASHOPTS (an inherited job-control or errexit setting would otherwise move the job out of its group). It turns job control off itself too. Your job still gets PERL5OPT and PERL5LIB back. It writes its pid and start time to ~/.cache/process-concierge/ledger/<token>.run (folder mode 700). It then runs the job in your own shell ($SHELL if it's zsh or bash, else /bin/sh; zsh runs with -f, so no startup files), with your stdin, umask and normal signal handling. It lets go of its own copies of your terminal output, so a caller waiting for output to end isn't held open, and it stays alive until every process in the group has exited.
  3. Stop never signals a pid from the mod. Pressing stop writes <token>.stop. The supervisor sees it, sends SIGTERM to its own group, and after 3 seconds SIGKILL. It leads the group and is alive while it does this, so the group id can't belong to anything else. Before writing the stop file, the mod confirms the supervisor is still running, with the same pid and start time and its token in its command line.
  4. Stop is confirmed, not assumed. A job is reported stopped only once its supervisor is gone. If it's still running after 12 seconds, the job goes back to running with a note. If processes of the group outlive the supervisor (for example a sudo child that ignores the signal), they're listed and left alone.

Anything not launched through the supervisor is never stopped. Your own servers on the machine are listed read-only. A line the mod can't read reliably runs exactly as written and is neither recorded nor refused, because refusing a command it can't read could block one that never starts a server:

  • lines the shell would reject (node app.js & &, npm run dev &&)
  • comments, backslash escapes, $'…' quoting, or quotes glued to other text (set''sid)
  • subshells, groups, command substitutions, heredocs, or if/for blocks
  • exec, eval, source, cd -, pushd +N, or a command word that is a variable ($X dev)
  • any line that calls setsid, by path or inside double quotes too
  • every job on a machine without /usr/bin/perl, /usr/bin/env or /bin/ps

A readable foreground line with an & in the middle (npm run dev & sleep 2 && curl …) can't be wrapped without changing what the call returns, so it runs as written with no stop button; it is still checked for duplicates.

Task mode. A run_in_background call, or a foreground long runner, ends when its launching shell goes away, so interrupting the call still stops the job. A job sent to the background with & keeps running after the call, as it would without the mod.

Permissions. The model asked for the original command, so your Bash permission rules apply to that command, not to the wrapper. The mod answers the permission check for a command it rewrote with the decision for the original, and only while that call is in flight.

Known dev servers and watchers are recognised by command:

  • npm/pnpm/yarn/bun dev/start/serve/watch
  • Vite, Next, Expo, Metro, webpack serve
  • tsc -w, Jest and Vitest watch
  • Rails, python -m http.server, uvicorn, Flask, Docker Compose, and more

Ports come from lsof (read every few seconds at most, and fresh when you open /procs or a job starts), so this targets macOS and Linux. The mod runs ps, lsof and rm by absolute system path, never through your PATH. Commands are stored with env assignments removed and secret-looking values hidden; the duplicate check keeps only a digest of the command.

Limits:

  • A plain command that Claude Code moves to the background after a timeout wasn't launched under the supervisor, so it gets no stop button.
  • A job that starts its own new session (setsid inside a script) leaves the group and isn't stopped with it.
  • A supervised job runs in a fresh shell. Aliases, shell functions (including exported bash functions) and zsh startup files from your setup aren't available to it, though exported variables and your PATH are.
  • Within one command, a job is checked for duplicates only against jobs of earlier lists sent to the background with &, or other commands of the same pipeline. npm run dev || npm run dev & is allowed; npm run dev & npm run dev is refused.
  • A wrapped foreground list runs in the supervisor's shell, so a cd in it doesn't change the folder of later Bash calls (it wouldn't for a line ending in & either).
  • go run and programs named serve are always treated as long runners, so a one-shot go run ./migrate.go is supervised like a server.

What it hooks

Events this mod hooks, as claude plugin validate reads the module:

  • session.start, session.end
  • command.run for /procs
  • tool.check for Bash: the permission decision for a rewritten command is the decision for the original
  • tool.call for Bash: rewrites a job to launch under the supervisor, refuses duplicates, notes the job for the model
  • ui.render for the AbovePrompt band and the /procs pane

Engine calls it makes: $.process.run (ps, lsof, rm of its own ledger files), $.fs.read/$.fs.write/$.fs.list (the ledger), $.fs.stat, $.tool.check, $.store, $.state, $.clock, $.command.register, $.session.cwd/$.session.id, $.env.get (HOME), $.ui.open/$.ui.resolve.

Privacy

It runs entirely on your machine and sends nothing over the network. It runs ps and lsof to measure jobs, launches jobs through its own bin/pc-run script (which uses perl to start a process group), and keeps a small ledger of supervisor pids under ~/.cache/process-concierge. Tracked jobs (command with secrets hidden, folder, ports) are kept in Claude Code's local plugin store so it can show leftovers from earlier sessions.

The mod collects no analytics or telemetry, and its author receives no data from it.

Full policy: PRIVACY.md.

Install

/plugin marketplace add ccdwyer/claude-mods
/plugin install process-concierge@ccdwyer-mods
/reload-plugins

Develop

bash tests/pc-run-real.sh runs the supervisor for real (inherited job control, a hostile PATH/PERL5OPT, exit status); the plugin tests mock the OS.

claude plugin validate .
claude plugin test .

License

MIT

Source 3 files
hooks/register.tsx 775 lines
1import type { EngineInterface, Register, ToolCallResult } from 'claude-code'
2
3import type { Foreign, Proc, View } from '../types'
4import type { Job, Mode, PsRow } from './procs'
5import {
6  TOKEN, age, commandOf, detaches, display, groupMembers, identity, jobs, longRunner, parseExit, parseLedger, parseLsof,
7  parsePs, requestedPort, short, startDir, supervisorRow, wrap,
8} from './procs'
9
10type Engine = EngineInterface
11
12const PANE = 'process-concierge'
13const VIEW = { plugin: 'process-concierge', key: 'view' } as const
14const STORE_PREFIX = 'proc:'
15// How often live jobs are re-checked while the session is open.
16const TICK_MS = 15_000
17// When a just-started job is looked for again, after the immediate look.
18const RETRIES_MS = [1_500, 5_000, 15_000]
19// A job whose supervisor has not checked in by then ran without it (for example, no perl on this machine).
20const CONFIRM_WITHIN_MS = 30_000
21// A job still coming up holds its port and name only this long.
22const STARTING_HOLD_MS = 15_000
23// When a stop is judged: after the supervisor's 3s grace and its SIGKILL, then once more, finally.
24const SETTLE_MS = [4_500, 12_000]
25// A stopping job with no settling timer left (a reload) is judged by the regular refresh after this long.
26const STOP_ORPHANED_MS = 15_000
27// Finished records are forgotten after this long.
28const KEEP_FINISHED_MS = 6 * 3600_000
29// `ps` and `lsof` print dates in the C locale, so every reader parses the same text.
30const C_LOCALE = { LC_ALL: 'C' }
31
32// The session's own memory. The store is the durable copy; a reload rebuilds this from it.
33let procs: Proc[] = []
34let sessionId = ''
35let home = ''
36let ledgerDir = ''
37let script = ''
38// The shell the Bash tool runs commands in, so a supervised job is run by the same one.
39let shell = '/bin/sh'
40// System helpers by absolute path, found once at load: a program earlier on PATH (a project's own `ps`) never runs.
41const BIN = { ps: '', lsof: '', rm: '' }
42const CANDIDATES: Record<keyof typeof BIN, string[]> = {
43  ps: ['/bin/ps', '/usr/bin/ps'],
44  lsof: ['/usr/sbin/lsof', '/usr/bin/lsof', '/sbin/lsof'],
45  rm: ['/bin/rm', '/usr/bin/rm'],
46}
47// The listener table, kept for a few seconds: lsof can take a while on macOS, and every Bash call asks.
48let lsofCache: { at: number; ports: Map<number, number[]> } | null = null
49const LSOF_TTL_MS = 5_000
50let counter = 0
51let ticker: { cancel: () => void } | null = null
52// Each rewritten command, to the arguments the model asked for and the exact arguments the call carries after
53// the rewrite: the permission check for exactly that rewritten call is the check for the original.
54const rewrites = new Map<string, { original: Record<string, unknown>; rewritten: Record<string, unknown> }>()
55
56// Equal as permission inputs: every argument the same, a missing one and an undefined one alike.
57function sameArgs(a: Record<string, unknown>, b: Record<string, unknown>): boolean {
58  const keys = new Set([...Object.keys(a), ...Object.keys(b)])
59  for (const k of keys) if (JSON.stringify(a[k]) !== JSON.stringify(b[k])) return false
60  return true
61}
62
63type Ran = { exitCode: number; stdout: string; stderr: string; truncated: boolean }
64
65async function run($: Engine, argv: string[], timeoutMs = 5_000): Promise<Ran> {
66  try {
67    const r = await $.process.run(argv, { timeoutMs, env: C_LOCALE })
68    return { exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr, truncated: r.isStdoutTruncated }
69  } catch (err) {
70    return { exitCode: -1, stdout: '', stderr: String(err).slice(0, 200), truncated: false }
71  }
72}
73
74// The whole process table, or null when it could not be read in full.
75async function psRows($: Engine): Promise<PsRow[] | null> {
76  if (BIN.ps === '') return null
77  const r = await run($, [BIN.ps, '-axww', '-o', 'pid=,ppid=,pgid=,pcpu=,rss=,lstart=,command='])
78  if (r.exitCode !== 0 || r.truncated) return null
79  const rows = parsePs(r.stdout)
80  return rows.length > 0 ? rows : null
81}
82
83// Every TCP listener on the machine: pid -> ports.
84async function listening($: Engine, fresh = false): Promise<Map<number, number[]>> {
85  if (BIN.lsof === '') return new Map()
86  const now = await $.clock.now()
87  if (!fresh && lsofCache !== null && now - lsofCache.at < LSOF_TTL_MS) return lsofCache.ports
88  const r = await run($, [BIN.lsof, '-nP', '-iTCP', '-sTCP:LISTEN', '-Fpn'])
89  // lsof exits 1 when nothing listens.
90  const ports = r.exitCode === 0 || r.exitCode === 1 ? parseLsof(r.stdout) : new Map<number, number[]>()
91  lsofCache = { at: now, ports }
92  return ports
93}
94
95// Whether one process is still the one a ledger names: 'same', 'gone' (no such pid, or another start time), or
96// 'unknown' when the look itself failed (then nothing is deleted).
97async function stillThere($: Engine, pid: number, lstart: string): Promise<'same' | 'gone' | 'unknown'> {
98  if (BIN.ps === '' || !Number.isInteger(pid) || pid <= 0) return 'unknown'
99  const r = await run($, [BIN.ps, '-o', 'lstart=', '-p', String(pid)], 3_000)
100  const now = r.stdout.replace(/\s+/g, ' ').trim()
101  // ps exits 1 with no output when the pid does not exist.
102  if (r.exitCode === 1 && now === '') return 'gone'
103  if (r.exitCode !== 0) return 'unknown'
104  return now === lstart.replace(/\s+/g, ' ').trim() ? 'same' : 'gone'
105}
106
107async function realDir($: Engine, dir: string): Promise<string> {
108  try {
109    const stat = await $.fs.stat(dir, { resolve: true })
110    return stat.realPath ?? dir
111  } catch {
112    return dir
113  }
114}
115
116async function exists($: Engine, path: string): Promise<boolean> {
117  try {
118    return await $.fs.exists(path)
119  } catch {
120    return false
121  }
122}
123
124async function digest(text: string): Promise<string> {
125  const bytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
126  return [...new Uint8Array(bytes).slice(0, 12)].map(b => b.toString(16).padStart(2, '0')).join('')
127}
128
129function newToken(): string {
130  return [...crypto.getRandomValues(new Uint8Array(12))].map(b => b.toString(16).padStart(2, '0')).join('')
131}
132
133async function readFile($: Engine, path: string): Promise<string | null> {
134  try {
135    return await $.fs.read(path)
136  } catch {
137    return null
138  }
139}
140
141// Remove a job's ledger files. The token is checked to be ours in shape, so the paths can name nothing else.
142async function removeLedger($: Engine, token: string): Promise<void> {
143  if (!TOKEN.test(token) || ledgerDir === '') return
144  const files = ['run', 'exit', 'stop', 'code', 'ps'].map(ext => `${ledgerDir}/${token}.${ext}`)
145  if (BIN.rm === '') return
146  await run($, [BIN.rm, '-f', '--', ...files], 3_000)
147}
148
149const alive = (p: Proc) => p.status === 'running' || p.status === 'starting' || p.status === 'stopping'
150
151// What holds a port and a job name: anything running, and a just-started job for a short while.
152function occupies(p: Proc, now: number): boolean {
153  if (p.status === 'running' || p.status === 'stopping') return true
154  return p.status === 'starting' && now - p.startedAt < STARTING_HOLD_MS
155}
156
157function visible(): Proc[] {
158  return procs.filter(p => alive(p) || p.sessionId === sessionId || p.status === 'unsupervised')
159}
160
161let foreign: Foreign[] = []
162
163async function publish($: Engine, note = ''): Promise<void> {
164  const view: View = { sessionId, procs: visible(), foreign, updatedAt: await $.clock.now(), note }
165  try {
166    await $.state.set(VIEW, view)
167  } catch {
168    // A failed redraw must never fail the caller.
169  }
170}
171
172async function persist($: Engine, p: Proc): Promise<void> {
173  try {
174    await $.store.set(`${STORE_PREFIX}${p.id}`, p)
175  } catch {
176    // The store is best effort.
177  }
178}
179
180async function forget($: Engine, id: string): Promise<void> {
181  const p = procs.find(x => x.id === id)
182  procs = procs.filter(x => x.id !== id)
183  if (p !== undefined) await removeLedger($, p.token)
184  try {
185    await $.store.delete(`${STORE_PREFIX}${id}`)
186  } catch {
187    // Already gone.
188  }
189}
190
191function isProc(v: unknown): v is Proc {
192  const p = v as Proc
193  return typeof p === 'object' && p !== null && typeof p.id === 'string' && typeof p.token === 'string' && Array.isArray(p.members) && Array.isArray(p.keys)
194}
195
196async function loadStore($: Engine): Promise<void> {
197  const loaded: Proc[] = []
198  try {
199    for (const key of await $.store.keys()) {
200      if (!key.startsWith(STORE_PREFIX)) continue
201      const v = await $.store.get(key)
202      if (isProc(v)) loaded.push(v)
203      // Records from before the supervisor (no token) can't be stopped safely: drop them.
204      else await $.store.delete(key)
205    }
206  } catch {
207    // Start empty.
208  }
209  const ids = new Set(procs.map(p => p.id))
210  procs = [...procs, ...loaded.filter(p => !ids.has(p.id))]
211}
212
213// Supervisors in the ledger with no record (a record lost to a crash): shown, and stoppable, as earlier jobs.
214async function adoptFromLedger($: Engine, rows: PsRow[]): Promise<void> {
215  let names: string[] = []
216  try {
217    names = (await $.fs.list(ledgerDir)).map(entry => entry.name)
218  } catch {
219    return
220  }
221  const known = new Set(procs.map(p => p.token))
222  const now = await $.clock.now()
223  // Candidates first, then the bound: exit records and temp files never crowd a live supervisor out, and a
224  // `.run` whose supervisor is gone (left by one killed outright) is removed, so dead ones can't crowd it out either.
225  const candidates = names.flatMap(name => {
226    const m = name.match(/^([0-9a-f]{16,64})\.run$/)
227    return m === null || known.has(m[1] as string) ? [] : [{ name, token: m[1] as string }]
228  })
229  let adopted = 0
230  for (const { name, token } of candidates.slice(0, 2_000)) {
231    if (adopted >= 200) break
232    const led = parseLedger((await readFile($, `${ledgerDir}/${name}`)) ?? '')
233    const row = led === null ? null : supervisorRow(rows, led.pid, led.lstart, token)
234    if (led === null || row === null) {
235      // Absent from the boot-time table: look again at that one pid (it may have checked in since), and delete
236      // the ledger only when that look says it is gone.
237      if (led !== null && !rows.some(r => r.pid === led.pid && r.lstart === led.lstart) && (await stillThere($, led.pid, led.lstart)) === 'gone') {
238        await removeLedger($, token)
239      }
240      continue
241    }
242    adopted += 1
243    counter += 1
244    const line = commandOf(row.command)
245    const cwd = await realDir($, led.cwd)
246    // Every job the recovered line starts, each at the directory it runs in, for the duplicate check.
247    const found = jobs(line, true)
248    const planned: Planned[] = []
249    for (const job of found) planned.push(await planAt($, line, job, cwd))
250    const main = found.find(j => longRunner(j.text) !== null) ?? found[found.length - 1]
251    procs.push({
252      id: `ledger-${token.slice(0, 8)}-${counter}`, token, sessionId: 'earlier', command: display(main?.text ?? line),
253      keys: planned.map(x => x.key), wants: planned.flatMap(x => (x.port === null ? [] : [x.port])), cwd, startedAt: Date.parse(led.lstart) || now, mode: led.mode === 'detached' ? 'detached' : 'task',
254      supervisor: { pid: led.pid, lstart: led.lstart }, members: [], ports: [], status: 'running', cpu: 0, memMb: 0,
255      exitCode: null, checkedAt: now, note: '',
256    })
257  }
258}
259
260// How a job whose supervisor is gone ended, and anything of its group left behind (shown, never signalled).
261async function finish($: Engine, p: Proc, rows: PsRow[], stopped: boolean): Promise<void> {
262  const ended = parseExit((await readFile($, `${ledgerDir}/${p.token}.exit`)) ?? '')
263  p.exitCode = ended.code
264  p.status = stopped || ended.how !== 'exited' ? 'stopped' : 'exited'
265  const left = p.supervisor === null ? [] : groupMembers(rows, p.supervisor.pid)
266  p.members = []
267  p.ports = []
268  p.cpu = 0
269  p.memMb = 0
270  p.note =
271    left.length > 0
272      ? `${left.length} process${left.length === 1 ? '' : 'es'} (pid ${left.map(r => r.pid).slice(0, 4).join(',')}) outlived the supervisor; Process Concierge does not signal them`
273      : ended.how === 'killed'
274        ? 'it ignored SIGTERM, so the supervisor used SIGKILL'
275        : ''
276}
277
278// Re-check every live record: its supervisor, the group's ports, CPU and memory. A caller that already read the
279// process table passes it in, so one tool call reads it once.
280// `fresh`: read the listener table now rather than the few-seconds-old copy (a person looking, a start confirming).
281async function refresh($: Engine, rowsIn?: PsRow[] | null, portsIn?: Map<number, number[]>, fresh = false): Promise<void> {
282  try {
283    sessionId = await $.session.id()
284  } catch {
285    // Keep the last known id.
286  }
287  const now = await $.clock.now()
288  for (const p of procs.filter(x => !alive(x) && now - x.checkedAt > KEEP_FINISHED_MS)) await forget($, p.id)
289  const rows = rowsIn === undefined ? await psRows($) : rowsIn
290  // A failed look changes nothing: absence is never inferred from a failed read.
291  if (rows === null) return
292  const ports = portsIn ?? (await listening($, fresh))
293  const ours = new Set<number>()
294  // The newest table: a second look taken for one record serves every record after it.
295  let latest = rows
296  for (const p of procs.filter(alive)) {
297    let view = latest
298    if (p.status === 'starting') {
299      const led = parseLedger((await readFile($, `${ledgerDir}/${p.token}.run`)) ?? '')
300      let row = led === null ? null : supervisorRow(view, led.pid, led.lstart, p.token)
301      // The supervisor may have checked in after the table was read: look again before judging it gone.
302      if (led !== null && row === null) {
303        const fresh = await psRows($)
304        if (fresh === null) continue
305        latest = fresh
306        view = fresh
307        row = supervisorRow(view, led.pid, led.lstart, p.token)
308      }
309      if (led !== null && row !== null) {
310        p.supervisor = { pid: led.pid, lstart: led.lstart }
311        p.status = 'running'
312      } else if (led !== null) {
313        // It checked in and is already gone: a short job.
314        p.supervisor = { pid: led.pid, lstart: led.lstart }
315        await finish($, p, view, false)
316      } else if ((await readFile($, `${ledgerDir}/${p.token}.exit`)) !== null) {
317        // Checked in and finished between two looks: the supervisor removed its `.run` when it ended.
318        await finish($, p, view, false)
319      } else if (now - p.startedAt > CONFIRM_WITHIN_MS) {
320        p.status = 'unsupervised'
321        p.note = 'it ran without the supervisor (perl missing, or the group could not be created), so it has no stop button'
322      }
323    }
324    if ((p.status === 'running' || p.status === 'stopping') && p.supervisor !== null) {
325      const sup = supervisorRow(view, p.supervisor.pid, p.supervisor.lstart, p.token)
326      if (sup === null) {
327        // The stop workflow owns a stopping job's ending; a running one that vanished simply finished. A stop
328        // whose settling timers were lost (a reload) is finished here once its supervisor is confirmed gone.
329        if (p.status === 'running') await finish($, p, view, false)
330        else if (now - (p.stopAt ?? 0) > STOP_ORPHANED_MS) await finish($, p, view, true)
331      } else {
332        const members = groupMembers(view, sup.pid)
333        const pids = [sup.pid, ...members.map(r => r.pid)]
334        for (const pid of pids) ours.add(pid)
335        p.members = members.map(r => r.pid).slice(0, 50)
336        p.ports = [...new Set(pids.flatMap(pid => ports.get(pid) ?? []))].sort((a, b) => a - b)
337        p.cpu = Math.round(members.reduce((sum, r) => sum + r.cpu, 0) * 10) / 10
338        p.memMb = Math.round(members.reduce((sum, r) => sum + r.rssKb, 0) / 1024)
339      }
340    }
341    p.checkedAt = now
342    await persist($, p)
343  }
344  const byPid = new Map(rows.map(r => [r.pid, r]))
345  foreign = [...ports.entries()]
346    .filter(([pid]) => !ours.has(pid))
347    .slice(0, 8)
348    .map(([pid, held]) => ({ pid, ports: held.slice(0, 4), command: short(display(byPid.get(pid)?.command ?? '?'), 80) }))
349  await publish($)
350}
351
352// Ask a job's supervisor to stop its group. Nothing is signalled from here: the supervisor reads the stop file
353// and signals the group it leads.
354async function stopProc($: Engine, id: string): Promise<string> {
355  const p = procs.find(x => x.id === id)
356  if (p === undefined || p.status !== 'running' || p.supervisor === null) return 'not running'
357  const rows = await psRows($)
358  if (rows === null) {
359    p.note = 'could not read the process list; nothing was asked to stop. Try again.'
360    await publish($, p.note)
361    return p.note
362  }
363  if (supervisorRow(rows, p.supervisor.pid, p.supervisor.lstart, p.token) === null) {
364    await finish($, p, rows, false)
365    await persist($, p)
366    await publish($)
367    return 'already gone'
368  }
369  try {
370    await $.fs.write(`${ledgerDir}/${p.token}.stop`, 'stop\n')
371  } catch (err) {
372    p.note = `could not ask it to stop (${String(err).slice(0, 80)}); it is still running`
373    await persist($, p)
374    await publish($, p.note)
375    return p.note
376  }
377  p.status = 'stopping'
378  p.stopAt = await $.clock.now()
379  p.note = ''
380  await persist($, p)
381  await publish($, `Stopping ${short(p.command, 40)}…`)
382  SETTLE_MS.forEach((ms, i) => {
383    $.clock.after(ms, () => {
384      void settle($, id, i === SETTLE_MS.length - 1)
385    })
386  })
387  return 'asked to stop'
388}
389
390// The stop workflow's own look: only here does a stopping job become stopped, or go back to running.
391async function settle($: Engine, id: string, final: boolean): Promise<void> {
392  const p = procs.find(x => x.id === id)
393  if (p === undefined || p.status !== 'stopping' || p.supervisor === null) return
394  const rows = await psRows($)
395  if (rows === null) {
396    if (final) {
397      p.status = 'running'
398      p.note = 'could not read the process list to confirm the stop; press stop again'
399      await persist($, p)
400      await publish($, p.note)
401    }
402    return
403  }
404  if (supervisorRow(rows, p.supervisor.pid, p.supervisor.lstart, p.token) !== null) {
405    if (!final) return
406    p.status = 'running'
407    p.note = 'asked to stop, but it is still running; press stop again'
408  } else {
409    await finish($, p, rows, true)
410  }
411  await persist($, p)
412  await publish($)
413}
414
415async function stopMany($: Engine, which: 'session' | 'orphans'): Promise<string> {
416  const targets = procs.filter(p => p.status === 'running' && p.supervisor !== null && (which === 'session' ? p.sessionId === sessionId : p.sessionId !== sessionId))
417  for (const p of targets) await stopProc($, p.id)
418  return `${targets.length} asked to stop`
419}
420
421async function dismissFinished($: Engine): Promise<void> {
422  for (const p of procs.filter(x => !alive(x))) await forget($, p.id)
423  await publish($)
424}
425
426function describe(p: Proc): string {
427  const pid = p.supervisor?.pid ?? '?'
428  const ports = p.ports.length > 0 ? ` on ${p.ports.map(n => `:${n}`).join(' ')}` : ''
429  return `${short(p.command, 100)}${ports} (supervisor pid ${pid}, started ${age(Date.now() - p.startedAt)} ago in ${p.cwd})`
430}
431
432function summary(list: Proc[]): string {
433  const live = list.filter(p => p.status === 'running' || p.status === 'stopping')
434  const head = live.length === 0 ? 'Process Concierge: no tracked jobs are running.' : `Process Concierge: ${live.length} running.`
435  const lines = list.map(p => `- [${p.status}] ${describe(p)}${p.sessionId === sessionId ? '' : ' [earlier session]'}${p.note ? ` (${p.note})` : ''}`)
436  const others = foreign.map(f => `- [not ours, shown only] pid ${f.pid} on ${f.ports.map(n => `:${n}`).join(' ')}: ${f.command}`)
437  return [head, ...lines, ...others].join('\n')
438}
439
440type Planned = { text: string; index: number; list: number; dir: string; key: string; port: number | null; concurrent: boolean }
441
442// What a new job would collide with: a listener or a reservation on the port it asks for, the same job
443// already running in that folder, or an earlier job of the same command.
444function conflictFor(x: Planned, listeners: Map<number, number[]>, rows: PsRow[] | null, now: number, ports: number[], earlier: Planned[]): string | null {
445  const want = x.port
446  if (want !== null) {
447    if (ports.includes(want)) return `this command starts two things on port ${want} at once`
448    for (const [pid, held] of listeners) {
449      if (!held.includes(want)) continue
450      const owner = procs.find(p => occupies(p, now) && (p.supervisor?.pid === pid || p.members.includes(pid)))
451      if (owner !== undefined) return `port ${want} is already served by a job the agent started: ${describe(owner)}`
452      const row = rows?.find(r => r.pid === pid)
453      return `port ${want} is already in use by pid ${pid}${row ? ` (${short(display(row.command), 60)})` : ''}, which was not started through Process Concierge`
454    }
455    const reserving = procs.find(p => occupies(p, now) && (p.wants.includes(want) || p.ports.includes(want)))
456    if (reserving !== undefined) return `port ${want} is taken by a server the agent just started in ${reserving.cwd}, which is still coming up`
457  }
458  if (earlier.some(y => y.key === x.key && (x.port === null || y.port === null || x.port === y.port))) return 'this command starts the same job twice at once'
459  for (const p of procs) {
460    if (!occupies(p, now) || !p.keys.includes(x.key)) continue
461    if (x.port !== null && !p.wants.includes(x.port) && !p.ports.includes(x.port)) continue
462    return p.status === 'starting'
463      ? `the same command was just started in ${p.cwd} and is still coming up`
464      : `this is already running: ${describe(p)}`
465  }
466  return null
467}
468
469function newProc(x: Planned, also: Planned[], now: number, token: string, mode: Mode, supervised: boolean): Proc {
470  counter += 1
471  const wants = [x, ...also].flatMap(y => (y.port === null ? [] : [y.port]))
472  return {
473    id: `${sessionId.slice(0, 8)}-${now}-${counter}`, token, sessionId, command: display(x.text),
474    keys: [x.key, ...also.map(y => y.key)], wants,
475    cwd: x.dir, startedAt: now, mode, supervisor: null, members: [], ports: [],
476    status: supervised ? 'starting' : 'unsupervised', cpu: 0, memMb: 0, exitCode: null, checkedAt: now,
477    note: supervised ? '' : 'started in shell syntax Process Concierge does not split safely, so it is shown without a stop button',
478  }
479}
480
481// A job of `cmd`, at the directory it runs in when `cmd` starts in `cwd`.
482async function planAt($: Engine, cmd: string, job: Job, cwd: string): Promise<Planned> {
483  const dir = await realDir($, startDir(cmd, job.index, cwd, home))
484  return { text: job.text, index: job.index, list: job.list, dir, key: await digest(`${dir}\u0000${identity(job.text)}`), port: requestedPort(job.text), concurrent: job.concurrent }
485}
486
487// After a call returns with jobs left running: look for their supervisors now and a few more times.
488async function track($: Engine, ids: string[], taskId: string | undefined): Promise<string> {
489  for (const p of procs.filter(x => ids.includes(x.id))) p.taskId = taskId
490  await refresh($, undefined, undefined, true)
491  for (const ms of RETRIES_MS) {
492    $.clock.after(ms, () => {
493      void refresh($, undefined, undefined, true)
494    })
495  }
496  const mine = procs.filter(x => ids.includes(x.id))
497  const supervised = mine.filter(x => x.status !== 'unsupervised')
498  return (
499    `Process Concierge is tracking ${mine.length === 1 ? 'this background job' : `${mine.length} background jobs`}` +
500    (supervised.length > 0 ? ' (run under its supervisor, bin/pc-run, so the user can stop it from /procs)' : '') +
501    `. Before starting it again, check whether it is still running.`
502  )
503}
504
505function withNote(ran: ToolCallResult, note: string): ToolCallResult {
506  if (ran.deny !== undefined) return ran
507  return { ...ran, context: [...(ran.context ?? []), note] }
508}
509
510type BashResult = { backgroundTaskId?: string; backgroundEndsWithFinalResponse?: boolean } | undefined
511
512async function startTicker($: Engine): Promise<void> {
513  ticker?.cancel()
514  ticker = $.clock.every(TICK_MS, () => {
515    void refresh($)
516  })
517}
518
519async function boot($: Engine): Promise<void> {
520  sessionId = await $.session.id()
521  home = (await $.env.get('HOME')) ?? ''
522  ledgerDir = home === '' ? '' : `${home}/.cache/process-concierge/ledger`
523  const userShell = (await $.env.get('SHELL')) ?? ''
524  shell = /^\/[\w/.-]*\/(zsh|bash)$/.test(userShell) ? userShell : '/bin/sh'
525  // The supervisor needs perl (to lead a process group) and env (to start it clean); without them nothing is
526  // rewritten, and jobs are shown without a stop button.
527  for (const key of Object.keys(CANDIDATES) as (keyof typeof BIN)[]) {
528    BIN[key] = ''
529    for (const path of CANDIDATES[key]) {
530      if (await exists($, path)) {
531        BIN[key] = path
532        break
533      }
534    }
535  }
536  const ready = (await exists($, '/usr/bin/perl')) && (await exists($, '/usr/bin/env')) && (await exists($, '/bin/ps'))
537  script = ready ? `${$.plugin.root}/bin/pc-run` : ''
538  await loadStore($)
539  // A stop under way when the module last loaded: resume judging it.
540  for (const p of procs.filter(x => x.status === 'stopping')) {
541    $.clock.after(SETTLE_MS[0] as number, () => {
542      void settle($, p.id, true)
543    })
544  }
545  const rows = await psRows($)
546  if (rows !== null && ledgerDir !== '') await adoptFromLedger($, rows)
547  await refresh($, rows)
548  await startTicker($)
549}
550
551export const register: Register = on => {
552  on('session.start', async ($, e, next) => {
553    try {
554      await $.command.register({
555        name: 'procs',
556        description: 'Process Concierge: show the jobs the agent started, with ports and stop buttons',
557        argumentHint: '[stop-all | clean]',
558        immediate: true,
559      })
560      await boot($)
561    } catch {
562      // Never hold up the session.
563    }
564    return next(e)
565  })
566
567  on('session.end', async ($, e, next) => {
568    // /clear and resume keep the process alive on a new session: keep watching.
569    const reason = String(e.reason)
570    if (reason !== 'clear' && reason !== 'resume') {
571      ticker?.cancel()
572      ticker = null
573    }
574    return next(e)
575  })
576
577  on('command.run', { command: 'procs' }, async ($, e) => {
578    const arg = String(e.args ?? '').trim()
579    if (ticker === null) await boot($)
580    if (arg === 'stop-all') return { text: `Process Concierge: ${await stopMany($, 'session')} from this session.` }
581    if (arg === 'clean') return { text: `Process Concierge: ${await stopMany($, 'orphans')} from earlier sessions.` }
582    await refresh($, undefined, undefined, true)
583    await $.ui.open({ id: PANE, title: 'Processes' })
584    return { text: summary(procs) }
585  })
586
587  // The permission decision for a command this mod rewrote is the decision for the command the model asked for.
588  on('tool.check', { tool: 'Bash' }, async ($, e, next) => {
589    const input = (e.input ?? {}) as Record<string, unknown>
590    const saved = typeof input.command === 'string' ? rewrites.get(input.command) : undefined
591    // Only exactly the call this mod rewrote, every other argument unchanged, is answered for the original.
592    if (saved === undefined || !sameArgs(input, saved.rewritten)) return next(e)
593    return $.tool.check({ tool: 'Bash', input: saved.original })
594  })
595
596  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
597    const cmd = String(e.command ?? '')
598    const background = e.run_in_background === true
599    if (!background && longRunner(cmd) === null && !detaches(cmd)) return next(e)
600    if (ledgerDir === '') return next(e)
601
602    // A command left unwrapped (unreadable to this mod, or no supervisor tools) is neither recorded nor refused.
603    let wrapped: ReturnType<typeof wrap>
604    try {
605      wrapped = script === '' ? { command: cmd, jobs: [], unsafe: [] } : wrap(cmd, background, script, ledgerDir, shell, newToken)
606    } catch {
607      wrapped = { command: cmd, jobs: [], unsafe: [] }
608    }
609    if (wrapped.jobs.length === 0 && wrapped.unsafe.length === 0) return next(e)
610
611    const ids: string[] = []
612    let command = cmd
613    try {
614      const rows = await psRows($)
615      const listeners = await listening($)
616      await refresh($, rows, listeners)
617      const cwd = await $.session.cwd()
618      const planned: { x: Planned; also: Planned[]; token: string; mode: Mode; supervised: boolean }[] = []
619      const all = jobs(cmd, background)
620      for (const job of wrapped.jobs) {
621        const also: Planned[] = []
622        for (const extra of job.also) also.push(await planAt($, cmd, extra, cwd))
623        const own = all.find(j => j.index === job.index) ?? { text: job.text, index: job.index, concurrent: false, list: 0 }
624        planned.push({ x: await planAt($, cmd, own, cwd), also, token: job.token, mode: job.mode, supervised: true })
625      }
626      // Jobs of a line that reads reliably but was not wrapped (an `&` mid-line): checked for duplicates, never recorded.
627      const checkOnly: Planned[] = []
628      for (const job of wrapped.unsafe) checkOnly.push(await planAt($, cmd, job, cwd))
629      const now = await $.clock.now()
630      // From here to the reservation there is no await: check and reserve are one step. Within this one command
631      // a job collides only with an earlier one still running beside it: one whose shell list was sent to the
632      // background with `&` (`npm run dev || npm run dev` starts the second only if the first failed).
633      // Jobs of one shell list run one after another (`a || b`); a later list runs beside an earlier one only if
634      // that earlier list was sent to the background. So each job is checked against the jobs of earlier
635      // backgrounded lists, and a list's own jobs are added only after the whole list is checked.
636      const ports: number[] = []
637      const earlier: Planned[] = []
638      const inOrder = [...planned.flatMap(({ x, also }) => [x, ...also]), ...checkOnly].sort((a, b) => a.index - b.index)
639      const lists = [...new Set(inOrder.map(y => y.list))]
640      for (const list of lists) {
641        const members = inOrder.filter(y => y.list === list)
642        for (const y of members) {
643          const conflict = conflictFor(y, listeners, rows, now, ports, earlier)
644          if (conflict !== null) {
645            return {
646              deny:
647                `Process Concierge: not started, because ${conflict}. ` +
648                `Reuse the running one. If it really must restart, the user can stop it in /procs first.`,
649            }
650          }
651        }
652        for (const y of members.filter(z => z.concurrent)) {
653          if (y.port !== null) ports.push(y.port)
654          earlier.push(y)
655        }
656      }
657      for (const { x, also, token, mode, supervised } of planned) {
658        const p = newProc(x, also, now, token, mode, supervised)
659        procs.push(p)
660        ids.push(p.id)
661      }
662      if (wrapped.command !== cmd) {
663        const { tool: _tool, tool_use_id: _id, agentId: _agent, consent: _consent, ...args } = e as unknown as Record<string, unknown>
664        rewrites.set(wrapped.command, { original: args, rewritten: { ...args, command: wrapped.command } })
665        command = wrapped.command
666      }
667    } catch {
668      // Tracking is best effort; the command always runs, unwrapped if wrapping failed.
669      command = cmd
670    }
671
672    let ran: ToolCallResult
673    try {
674      ran = await next(command === cmd ? e : { ...e, command })
675    } finally {
676      rewrites.delete(command)
677    }
678    try {
679      const result = ran.result as BashResult
680      const backgrounded = ran.deny === undefined && result?.backgroundTaskId !== undefined && result.backgroundEndsWithFinalResponse !== true
681      // A detached job outlives the call; a task-mode job does only when the call itself went to the background.
682      const keep = (p: Proc) => ran.deny === undefined && (backgrounded || p.mode === 'detached')
683      for (const id of ids) {
684        const p = procs.find(x => x.id === id)
685        if (p !== undefined && !keep(p)) await forget($, id)
686      }
687      const kept = ids.filter(id => procs.some(x => x.id === id))
688      if (kept.length === 0) {
689        await publish($)
690        return ran
691      }
692      return withNote(ran, await track($, kept, result?.backgroundTaskId))
693    } catch {
694      return ran
695    }
696  })
697
698  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
699    const below = await next(e)
700    if (e.props.hasSurvey) return below
701    const { value } = await $.state.get(VIEW)
702    const live = (value?.procs ?? []).filter(p => p.status === 'running' || p.status === 'stopping')
703    if (live.length === 0) return below
704    const { Box, Text } = $.ui.resolve(e)
705    const ports = [...new Set(live.flatMap(p => p.ports))].sort((a, b) => a - b)
706    const orphans = live.filter(p => p.sessionId !== value?.sessionId).length
707    const portText = ports.length > 0 ? ` · ${ports.slice(0, 6).map(n => `:${n}`).join(' ')}` : ''
708    const orphanText = orphans > 0 ? ` · ${orphans} from earlier sessions` : ''
709    return (
710      <Box flexDirection="column">
711        <Text dimColor>
712          ⚙ {live.length} running{portText}{orphanText} · /procs
713        </Text>
714        {below}
715      </Box>
716    )
717  })
718
719  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
720    const { Box, Text, Button } = $.ui.resolve(e)
721    const { value } = await $.state.get(VIEW)
722    const list = value?.procs ?? []
723    const others = value?.foreign ?? []
724    const now = value?.updatedAt ?? 0
725    const cols = Math.max(40, e.props.bodyColumns ?? 80)
726    const stoppable = (p: Proc) => p.status === 'running' && p.supervisor !== null
727    const mine = list.filter(p => stoppable(p) && p.sessionId === value?.sessionId).length
728    const orphans = list.filter(p => stoppable(p) && p.sessionId !== value?.sessionId).length
729    const finished = list.filter(p => !alive(p)).length
730    return (
731      <Box flexDirection="column">
732        <Box>
733          <Button key="refresh" label="refresh" onPress={() => refresh($, undefined, undefined, true)} />
734          {mine > 0 ? <Button key="stop-all" label={`stop all from this session (${mine})`} onPress={() => stopMany($, 'session')} /> : null}
735          {orphans > 0 ? <Button key="clean" label={`stop earlier sessions' jobs (${orphans})`} onPress={() => stopMany($, 'orphans')} /> : null}
736          {finished > 0 ? <Button key="dismiss" label="clear finished" onPress={() => dismissFinished($)} /> : null}
737        </Box>
738        {value?.note ? <Text dimColor>{value.note}</Text> : null}
739        {list.length === 0 ? <Text dimColor>Nothing tracked yet. Background jobs the agent starts show up here.</Text> : null}
740        {list.map(p => {
741          const glyph = p.status === 'running' ? '●' : p.status === 'starting' ? '◌' : p.status === 'stopping' ? '◐' : '○'
742          const ports = p.ports.length > 0 ? p.ports.map(n => `:${n}`).join(' ') : 'no port'
743          const who = p.sessionId === value?.sessionId ? '' : ' · earlier session'
744          const code = p.exitCode !== null && !alive(p) ? ` · exit ${p.exitCode}` : ''
745          return (
746            <Box key={p.id} flexDirection="column" marginTop={1}>
747              <Text color={p.status === 'running' ? 'green' : undefined} dimColor={p.status !== 'running'}>
748                {glyph} {short(p.command, cols - 12)}
749              </Text>
750              <Text dimColor>
751                {'  '}{ports} · {p.status}{code} {age(now - p.startedAt)} · {p.members.length} proc · {p.cpu}% cpu · {p.memMb} MB{who}
752              </Text>
753              {p.note ? <Text color="yellow">{'  '}{short(p.note, cols - 4)}</Text> : null}
754              <Box>
755                <Text dimColor>{'  '}{short(p.cwd, cols - 16)} </Text>
756                {stoppable(p) ? <Button key={`stop-${p.id}`} label="stop" onPress={() => stopProc($, p.id)} /> : null}
757              </Box>
758            </Box>
759          )
760        })}
761        {others.length > 0 ? (
762          <Box flexDirection="column" marginTop={1}>
763            <Text dimColor>Other listeners (not started through Process Concierge, shown only):</Text>
764            {others.map(f => (
765              <Text key={`f-${f.pid}`} dimColor>
766                {'  '}pid {f.pid} · {f.ports.map(n => `:${n}`).join(' ')} · {short(f.command, cols - 24)}
767              </Text>
768            ))}
769          </Box>
770        ) : null}
771      </Box>
772    )
773  })
774}
775
hooks/procs.ts 587 lines
1// Pure helpers: reading shell commands, wrapping jobs in the supervisor, and reading `ps`, `lsof` and ledger
2// output. No engine calls here.
3
4export type PsRow = { pid: number; ppid: number; pgid: number; cpu: number; rssKb: number; lstart: string; command: string }
5
6const PM_SCRIPT = '(dev|start|serve|watch|preview|storybook)(:[\\w.-]+)?(?=$|\\s)'
7
8// Programs that keep running until stopped: dev servers, watchers, bundlers.
9const LONG_RUNNERS: RegExp[] = [
10  new RegExp(`^(npm|pnpm|yarn|bun) (run )?${PM_SCRIPT}`),
11  /^(vite|nuxt dev|astro dev|remix dev|gatsby develop|ng serve|vue-cli-service serve)(?=$|\s)/, /^next (dev|start)(?=$|\s)/,
12  /^(expo start|react-native start|metro)(?=$|\s)/,
13  /^webpack (serve|--watch|-w)(?=$|\s)/, /^webpack-dev-server(?=$|\s)/,
14  /^tsc\b.*(\s-w(?=$|\s)|--watch)/, /^(jest|vitest)\b.*(\s--watch|\s-w(?=$|\s)|--watchAll)/, /^vitest$/, /^vitest (dev|watch)(?=$|\s)/,
15  /^nodemon(?=$|\s)/, /^ts-node-dev(?=$|\s)/, /^tsx watch(?=$|\s)/, /^node\b.*\s--watch(?=$|\s)/,
16  /^(rails (s|server)|bin\/rails (s|server))(?=$|\s)/,
17  /^python[0-9.]* -m http\.server(?=$|\s)/, /^(uvicorn|gunicorn|hypercorn)(?=$|\s)/, /^(flask run|python[0-9.]* manage\.py runserver)(?=$|\s)/,
18  /^docker( |-)compose (up|watch)(?=$|\s)/, /^(http-server|serve|live-server|browser-sync)(?=$|\s)/,
19  /^cargo (watch|run)(?=$|\s)/, /^(air|reflex)(?=$|\s)/, /^go run(?=$|\s)/, /^(hugo server|jekyll serve|mkdocs serve)(?=$|\s)/,
20]
21
22// Wrappers that start the real program, and which of their flags take an operand.
23const WRAPPERS: Record<string, Set<string>> = {
24  nohup: new Set(), setsid: new Set(), exec: new Set(), command: new Set(), caffeinate: new Set(['-t', '-w']),
25  time: new Set(['-o']), nice: new Set(['-n']), env: new Set(['-u', '-C', '-S', '-P']),
26  sudo: new Set(['-u', '-g', '-h', '-p', '-C', '-D', '-r', '-t', '-U']),
27}
28const RUNNERS = new Set(['npx', 'bunx'])
29const RUNNER_VALUE_FLAGS = new Set(['--package', '-p', '--call', '-c'])
30const PACKAGE_MANAGERS = new Set(['npm', 'pnpm', 'yarn', 'bun'])
31// Package-manager flags placed before the script that take an operand.
32const PM_VALUE_FLAGS = new Set(['--prefix', '-C', '--dir', '--cwd', '--filter', '-F', '--workspace', '-w', '--loglevel'])
33// Package-manager flags that change the directory the script runs in.
34const PM_DIR_FLAGS = new Set(['--prefix', '-C', '--dir', '--cwd'])
35// Programs whose first positional is a subcommand, so the next positional also identifies the job.
36const SUBCOMMANDS = new Set(['go', 'cargo', 'docker', 'docker-compose', 'rails', 'flask', 'hugo', 'jekyll', 'mkdocs', 'tsx', 'vitest', 'webpack'])
37// Programs whose -p means a remote port, not a local listen port.
38const REMOTE_PORT_PROGRAMS = new Set(['ssh', 'scp', 'sftp', 'rsync', 'mosh', 'psql', 'mysql', 'redis-cli'])
39// Flags of the programs themselves that take a separate operand, so the operand is not the program's identity.
40const VALUE_FLAGS = new Set([
41  '--port', '-p', '--host', '-H', '--config', '-c', '--mode', '--prefix', '-C', '--filter', '-F', '--cwd', '--dir',
42  '--bind', '-b', '--listen', '-l', '--env', '-e', '--app', '--workspace', '-w',
43])
44
45// Blank out quoted text (keeping length) so operators inside quotes are never read as shell syntax.
46export function unquote(cmd: string): string {
47  let out = ''
48  let quote = ''
49  for (const c of cmd) {
50    if (quote !== '') {
51      out += c === quote ? c : ' '
52      if (c === quote) quote = ''
53    } else {
54      if (c === '"' || c === "'") quote = c
55      out += c
56    }
57  }
58  return out
59}
60
61// `list` numbers the shell lists (separated by `;`, `&` and newlines): a list ended by `&` runs in a
62// background subshell, so a `cd` inside it never reaches the lists after it.
63export type Segment = { text: string; background: boolean; list: number; listBackground: boolean; piped: boolean; start: number; end: number }
64
65// Split a command line into simple commands on &&, ||, ;, |, & and newlines, outside quotes.
66// `background` marks the ones a single `&` sends to the background.
67export function segments(cmd: string): Segment[] {
68  const mask = unquote(cmd)
69  const out: Segment[] = []
70  let start = 0
71  let list = 0
72  // A pipeline's commands all run at the same time: each is marked `piped`.
73  let pipedIn = false
74  const push = (end: number, background: boolean, endsList: boolean, pipeOut = false) => {
75    const raw = cmd.slice(start, end)
76    const text = raw.trim()
77    const lead = raw.length - raw.trimStart().length
78    if (text !== '') out.push({ text, background, list, listBackground: false, piped: pipeOut || pipedIn, start: start + lead, end: start + lead + text.length })
79    pipedIn = pipeOut
80    if (endsList) {
81      for (const s of out) if (s.list === list) s.listBackground = background
82      list += 1
83    }
84  }
85  for (let i = 0; i < mask.length; i += 1) {
86    const c = mask[i]
87    const two = mask.slice(i, i + 2)
88    if (two === '&&' || two === '||' || two === '|&') {
89      push(i, false, false, two === '|&')
90      i += 1
91      start = i + 1
92    } else if (c === '&' && mask[i - 1] !== '>' && mask[i + 1] !== '>') {
93      push(i, true, true)
94      start = i + 1
95    } else if (c === ';' || c === '\n') {
96      push(i, false, true)
97      start = i + 1
98    } else if (c === '|' && mask[i - 1] !== '>') {
99      push(i, false, false, true)
100      start = i + 1
101    }
102  }
103  push(mask.length, false, true)
104  return out
105}
106
107const strip = (w: string) => w.replace(/^["']|["']$/g, '')
108
109// The words of a simple command with env assignments, redirects, wrappers, runner and package-manager
110// flags (and their operands) removed.
111export function words(segment: string): string[] {
112  const raw = segment.match(/"[^"]*"|'[^']*'|\S+/g) ?? []
113  const out: string[] = []
114  let wrapper: Set<string> | null = null
115  for (let i = 0; i < raw.length; i += 1) {
116    const w = raw[i] as string
117    if (/^\d*[<>]/.test(w)) {
118      if (/^\d*[<>]+&?$/.test(w)) i += 1
119      continue
120    }
121    if (out.length === 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(w)) continue
122    if (out.length === 0 && Object.hasOwn(WRAPPERS, w)) {
123      wrapper = WRAPPERS[w] ?? null
124      continue
125    }
126    if (out.length === 0 && w.startsWith('-')) {
127      if (wrapper !== null && wrapper.has(w)) i += 1
128      continue
129    }
130    // Runner flags (`npx --yes vite`) and package-manager flags before the script (`npm --prefix web run dev`).
131    const first = out[0] === undefined ? '' : base(out[0])
132    if (out.length === 1 && w.startsWith('-') && RUNNERS.has(first)) {
133      if (RUNNER_VALUE_FLAGS.has(w)) i += 1
134      continue
135    }
136    if (out.length === 1 && w.startsWith('-') && PACKAGE_MANAGERS.has(first)) {
137      if (PM_VALUE_FLAGS.has(w)) i += 1
138      continue
139    }
140    out.push(strip(w))
141  }
142  if (out.length > 1 && RUNNERS.has(base(out[0] as string))) out.shift()
143  if (out.length > 2 && PACKAGE_MANAGERS.has(out[0] as string) && (out[1] === 'exec' || out[1] === 'dlx')) out.splice(0, 2)
144  return out
145}
146
147export const base = (w: string) => (w.split('/').pop() ?? w).replace(/\.(m?js|cjs|ts|py)$/, '')
148
149function shape(segment: string): string {
150  return words(segment).map((w, i) => (i === 0 ? base(w) : w)).join(' ')
151}
152
153export function isLongRunner(segment: string): boolean {
154  const s = shape(segment)
155  // A detached compose/run exits at once: the containers are docker's, not a process this mod can stop.
156  if (/^docker( |-)compose (up|start)\b/.test(s) && /(^|\s)(-d|--detach)(?=$|\s)/.test(s)) return false
157  return LONG_RUNNERS.some(re => re.test(s))
158}
159
160// The simple command that starts the long-running program, if any.
161export function longRunner(cmd: string): string | null {
162  for (const s of segments(cmd)) if (isLongRunner(s.text)) return s.text
163  return null
164}
165
166// True when the command sends something to the background: a single `&`, or nohup/setsid/disown as a command.
167export function detaches(cmd: string): boolean {
168  for (const s of segments(cmd)) {
169    if (s.background) return true
170    const first = (s.text.match(/\S+/) ?? [''])[0]
171    if (first === 'nohup' || first === 'setsid' || first === 'disown') return true
172  }
173  return false
174}
175
176// `concurrent`: the job's shell list was sent to the background, so what follows it runs at the same time.
177export type Job = { text: string; index: number; concurrent: boolean; list: number }
178
179const NOT_A_JOB = /^(cd|pushd|popd|export|set|source|\.)(\s|$)/
180
181// The segments a command launches that need tracking: each backgrounded or detached one, each long runner,
182// and for a backgrounded tool call the last real command. `index` is the segment's position, so two identical
183// commands in different folders stay two jobs.
184export function jobs(cmd: string, wholeInBackground: boolean): Job[] {
185  const segs = segments(cmd).map((s, index) => ({ ...s, index })).filter(s => !NOT_A_JOB.test(s.text))
186  const picked = segs.filter(s => s.background || isLongRunner(s.text) || /^(nohup|setsid)\s/.test(s.text))
187  if (picked.length === 0 && wholeInBackground && segs.length > 0) picked.push(segs[segs.length - 1] as (typeof segs)[number])
188  return picked.map(s => ({ text: s.text, index: s.index, concurrent: s.listBackground || s.piped, list: s.list }))
189}
190
191// A port the segment asks for on its command line (not through PORT=): the process shows it there too.
192function argvPort(segment: string): number | null {
193  const ws = words(segment)
194  if (ws.length === 0 || REMOTE_PORT_PROGRAMS.has(base(ws[0] as string))) return null
195  const text = ws.join(' ')
196  const pats = [
197    /(?:^|\s)--port[= ](\d{2,5})(?=$|\s)/, /(?:^|\s)-p[= ]?(?:[\d.]+:)?(\d{2,5})(?::\d+)?(?=$|\s)/,
198    /http\.server\s+(\d{2,5})(?=$|\s)/, /(?:^|\s)--listen[= ](?:\S*:)?(\d{2,5})(?=$|\s)/,
199    /(?:^|\s)(?:--bind|-b)[= ]?\S*:(\d{2,5})(?=$|\s)/, /(?:^|\s)runserver\s+(?:\S*:)?(\d{2,5})(?=$|\s)/,
200  ]
201  for (const re of pats) {
202    const m = text.match(re)
203    if (m !== null) {
204      const n = Number(m[1])
205      if (n > 0 && n < 65536) return n
206    }
207  }
208  return null
209}
210
211// A port the segment asks for, when it names one (command line, or a PORT= assignment).
212export function requestedPort(segment: string): number | null {
213  const fromArgv = argvPort(segment)
214  if (fromArgv !== null) return fromArgv
215  const ws = words(segment)
216  if (ws.length > 0 && REMOTE_PORT_PROGRAMS.has(base(ws[0] as string))) return null
217  const m = segment.match(/(?:^|\s)PORT=(\d{2,5})(?=$|\s)/)
218  const n = m === null ? 0 : Number(m[1])
219  return n > 0 && n < 65536 ? n : null
220}
221
222// Words a matching process's command line must contain: the program, what identifies its job, and the port
223// it was given on its command line.
224export function matchWords(segment: string): string[] {
225  const ws = words(segment)
226  if (ws.length === 0) return []
227  const prog = base(ws[0] as string)
228  const out = [prog]
229  const rest = ws.slice(1)
230  const wanted = SUBCOMMANDS.has(prog) ? 2 : 1
231  let found = 0
232  for (let i = 0; i < rest.length && found < wanted; i += 1) {
233    const w = rest[i] as string
234    // Package managers: `npm run dev` and `npm dev` both show the script name.
235    if (PACKAGE_MANAGERS.has(prog) && (w === 'run' || w === 'run-script')) continue
236    if (w === '-m') {
237      const mod = rest[i + 1]
238      if (mod !== undefined) out.push(mod)
239      break
240    }
241    if (w === '--') break
242    if (w.startsWith('-')) {
243      if ((VALUE_FLAGS.has(w) || SECRET_NAME.test(w)) && !w.includes('=') && rest[i + 1] !== undefined) i += 1
244      continue
245    }
246    if (/^\d+$/.test(w)) continue
247    out.push(base(w))
248    found += 1
249  }
250  const port = PACKAGE_MANAGERS.has(prog) ? null : argvPort(segment)
251  if (port !== null) out.push(String(port))
252  return out
253}
254
255// What counts as "the same job" for the duplicate check, before the folder: the package script whatever the
256// package manager and flags, or the program and its identifying words without the port.
257export function identity(segment: string): string {
258  const ws = words(segment)
259  if (ws.length === 0) return ''
260  const prog = base(ws[0] as string)
261  if (PACKAGE_MANAGERS.has(prog)) {
262    const script = ws.slice(1).find(w => w !== 'run' && w !== 'run-script' && !w.startsWith('-'))
263    return `pkg ${script ?? ''}`
264  }
265  const port = argvPort(segment)
266  return matchWords(segment).filter(w => port === null || w !== String(port)).join(' ')
267}
268
269const SECRET_NAME = /token|secret|password|passwd|pass|key|auth|credential/i
270const SECRET_FLAG = /(--?[\w-]*(?:token|secret|password|passwd|pass|key|auth|credential)[\w-]*)([= ])("[^"]*"|'[^']*'|\S+)/gi
271const SECRET_ENV = /\b([A-Za-z_]*(?:TOKEN|SECRET|PASSWORD|PASSWD|KEY|AUTH|CREDENTIAL)[A-Za-z_]*)=("[^"]*"|'[^']*'|\S+)/gi
272
273// Hide secret-looking flag values, assignments and URL passwords, quoted values included.
274export function redact(text: string): string {
275  return text
276    .replace(SECRET_FLAG, '$1$2…')
277    .replace(SECRET_ENV, '$1=…')
278    .replace(/(\w+:\/\/[^\s:/@]+):[^\s@/]+@/g, '$1:…@')
279}
280
281// The env-free text of a segment, with secrets hidden.
282export function display(segment: string): string {
283  const ws = segment.match(/"[^"]*"|'[^']*'|\S+/g) ?? []
284  let i = 0
285  while (i < ws.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(ws[i] as string)) i += 1
286  return redact(ws.slice(i).join(' ')).slice(0, 300)
287}
288
289// Normalise a path: collapse `.`, `..` and duplicate or trailing slashes.
290export function normalize(path: string): string {
291  const abs = path.startsWith('/')
292  const parts: string[] = []
293  for (const part of path.split('/')) {
294    if (part === '' || part === '.') continue
295    if (part === '..') parts.pop()
296    else parts.push(part)
297  }
298  return `${abs ? '/' : ''}${parts.join('/')}` || '/'
299}
300
301function resolveDir(dir: string, target: string, home: string): string {
302  let t = strip(target)
303  if (t === '~' || t.startsWith('~/')) t = home + t.slice(1)
304  return normalize(t.startsWith('/') ? t : `${dir}/${t}`)
305}
306
307// The directory the segment at `index` runs in: every `cd`/`pushd` before it, applied in order, then any
308// directory its own `env -C` or package-manager `--prefix`/`-C`/`--dir`/`--cwd` names.
309export function startDir(cmd: string, index: number, cwd: string, home: string): string {
310  let dir = normalize(cwd)
311  const stack: string[] = []
312  const segs = segments(cmd)
313  const target = segs[index]
314  for (let i = 0; i < index && i < segs.length; i += 1) {
315    const seg = segs[i] as Segment
316    // A `cd` in an earlier list that ran in the background changed only that subshell's folder.
317    if (seg.list !== target?.list && seg.listBackground) continue
318    const m = seg.text.match(/^(cd|pushd|popd)(?:\s+("[^"]+"|'[^']+'|\S+))?\s*$/)
319    if (m === null) continue
320    const verb = m[1] as string
321    const arg = m[2]
322    if (verb === 'popd') dir = stack.pop() ?? dir
323    else if (arg === undefined) dir = verb === 'cd' && home !== '' ? normalize(home) : dir
324    else {
325      if (verb === 'pushd') stack.push(dir)
326      dir = resolveDir(dir, arg, home)
327    }
328  }
329  const own = segs[index]?.text ?? ''
330  const raw = own.match(/"[^"]*"|'[^']*'|\S+/g) ?? []
331  let envSeen = false
332  for (let i = 0; i < raw.length; i += 1) {
333    const w = raw[i] as string
334    // Arguments after `--` belong to the script, never to npm or env.
335    if (w === '--') break
336    if (base(w) === 'env') {
337      envSeen = true
338      continue
339    }
340    const next = raw[i + 1]
341    if (envSeen && (w === '-C' || w === '--chdir') && next !== undefined) dir = resolveDir(dir, next, home)
342    else if (envSeen && /^--chdir=/.test(w)) dir = resolveDir(dir, w.slice('--chdir='.length), home)
343    else if (envSeen && /^-C./.test(w)) dir = resolveDir(dir, w.slice(2), home)
344    else if (PM_DIR_FLAGS.has(w) && next !== undefined && raw.slice(0, i).some(x => PACKAGE_MANAGERS.has(base(x)))) dir = resolveDir(dir, next, home)
345  }
346  return dir
347}
348
349const LSTART = /^([A-Z][a-z]{2}\s+[A-Z][a-z]{2}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}\s+\d{4})\s+(.*)$/
350
351// `LC_ALL=C ps -axww -o pid=,ppid=,pgid=,pcpu=,rss=,lstart=,command=`
352export function parsePs(text: string): PsRow[] {
353  const rows: PsRow[] = []
354  for (const line of text.split('\n')) {
355    const m = line.trim().match(/^(\d+)\s+(\d+)\s+(\d+)\s+([\d.]+)\s+(\d+)\s+(.*)$/)
356    if (m === null) continue
357    const rest = (m[6] as string).match(LSTART)
358    if (rest === null) continue
359    rows.push({
360      pid: Number(m[1]), ppid: Number(m[2]), pgid: Number(m[3]), cpu: Number(m[4]), rssKb: Number(m[5]),
361      lstart: (rest[1] as string).replace(/\s+/g, ' '), command: rest[2] as string,
362    })
363  }
364  return rows
365}
366
367// `lsof -nP -iTCP -sTCP:LISTEN -Fpn`: pid -> listening ports.
368export function parseLsof(text: string): Map<number, number[]> {
369  const out = new Map<number, number[]>()
370  let pid = 0
371  for (const line of text.split('\n')) {
372    if (line.startsWith('p')) pid = Number(line.slice(1))
373    else if (line.startsWith('n') && pid > 0) {
374      const m = line.match(/:(\d+)$/)
375      if (m === null) continue
376      const list = out.get(pid) ?? []
377      const port = Number(m[1])
378      if (!list.includes(port)) list.push(port)
379      out.set(pid, list)
380    }
381  }
382  return out
383}
384
385// `lsof -a -d cwd -Fpn -p …`: pid -> working directory.
386export function parseCwds(text: string): Map<number, string> {
387  const out = new Map<number, string>()
388  let pid = 0
389  for (const line of text.split('\n')) {
390    if (line.startsWith('p')) pid = Number(line.slice(1))
391    else if (line.startsWith('n') && pid > 0) out.set(pid, line.slice(1))
392  }
393  return out
394}
395
396// ---- The supervisor -------------------------------------------------------------------------------------
397
398export type Mode = 'task' | 'detached'
399
400// Single-quote a word for /bin/sh.
401export function q(text: string): string {
402  return `'${text.replace(/'/g, "'\\''")}'`
403}
404
405export const TOKEN = /^[0-9a-f]{16,64}$/
406
407// The command line that runs `command` under the supervisor script, in `shell` (the shell the Bash tool uses).
408export function supervised(script: string, token: string, ledger: string, mode: Mode, shell: string, command: string): string {
409  // SHELLOPTS/BASHOPTS are dropped before /bin/sh starts, so inherited errexit or noexec can't stop the supervisor.
410  return `/usr/bin/env -u SHELLOPTS -u BASHOPTS /bin/sh ${q(script)} ${token} ${q(ledger)} ${mode} ${q(shell)} -- ${q(command)}`
411}
412
413// Shell syntax this mod does not split safely: a job inside it is left as it is (and shown without a stop button).
414const UNSAFE = /[(){}`]|\$\(|<<|(^|[;&|]\s*)(if|then|else|elif|fi|for|while|until|do|done|case|esac|function)\b/
415
416// The text with single-quoted spans blanked: what `$` expansions remain live.
417function withoutSingleQuotes(text: string): string {
418  let out = ''
419  let quote = ''
420  for (const c of text) {
421    if (quote === "'") {
422      out += c === "'" ? c : ' '
423      if (c === "'") quote = ''
424    } else {
425      if (quote === '' && c === "'") quote = "'"
426      else if (c === '"') quote = quote === '"' ? '' : '"'
427      out += c
428    }
429  }
430  return out
431}
432
433// Lexical constructs this mod does not split safely: an unquoted `#` comment, any backslash escape, `$'…'` /
434// `$"…"` quoting, and a quoted span glued to other word text (`set''sid`, `"set"sid`), where the word the
435// shell runs is not the text as written.
436export function lexicallyUnsafe(cmd: string): boolean {
437  if (cmd.includes('\\')) return true
438  if (cmd.includes("$'") || cmd.includes('$"')) return true
439  if (/(^|[\s;&|])#/.test(unquote(cmd))) return true
440  return gluedQuotes(cmd)
441}
442
443// True when a quoted span starts right after word text or ends right before it. `--port="3000"` is fine (`=`).
444function gluedQuotes(cmd: string): boolean {
445  const word = /[A-Za-z0-9_./-]/
446  let quote = ''
447  for (let i = 0; i < cmd.length; i += 1) {
448    const c = cmd[i] as string
449    if (quote === '') {
450      if (c === '"' || c === "'") {
451        if (i > 0 && word.test(cmd[i - 1] as string)) return true
452        quote = c
453      }
454    } else if (c === quote) {
455      quote = ''
456      if (i + 1 < cmd.length && word.test(cmd[i + 1] as string)) return true
457    }
458  }
459  return false
460}
461
462// A command word the shell computes (`$X npm run dev`, `"$RUNNER" dev`): what runs can't be read from the text.
463function dynamicCommand(cmd: string): boolean {
464  for (const seg of segments(cmd)) {
465    const ws = seg.text.match(/"[^"]*"|'[^']*'|\S+/g) ?? []
466    const first = ws.find(w => !/^[A-Za-z_]\w*=/.test(w)) ?? ''
467    if (/^["']?\$/.test(first)) return true
468  }
469  return false
470}
471
472// Everything that keeps a command line from being handed to the supervisor whole.
473export function wrapUnsafe(cmd: string): boolean {
474  const plain = unquote(cmd)
475  const live = withoutSingleQuotes(cmd)
476  return (
477    lexicallyUnsafe(cmd) || /<</.test(plain) || UNSAFE.test(plain) || /\$\(|`/.test(live) || dynamicCommand(cmd) ||
478    // `setsid` as a word anywhere, by path too, and inside double quotes (a `bash -c "setsid …"`).
479    /(^|[\s;&|("])(\S*\/)?setsid(?=$|[\s;&|)"])/.test(live) ||
480    segments(cmd).some(s => /^(exec|eval|source|\.)(\s|$)/.test(s.text) || /^cd\s+-(\s|$)/.test(s.text) || /^(pushd|popd)\s+[+-]/.test(s.text))
481  )
482}
483
484// The text between the segments is exactly one operator each, nothing before the first, and after the last
485// whatever `tail` allows: a leftover operator (`node app.js & &`, `npm run dev ||`) means the shell would fail.
486function wellFormed(cmd: string, segs: Segment[], tail: RegExp): boolean {
487  const first = segs[0]
488  const last = segs[segs.length - 1]
489  if (first === undefined || last === undefined) return false
490  if (!/^\s*$/.test(cmd.slice(0, first.start)) || !tail.test(cmd.slice(last.end))) return false
491  for (let i = 1; i < segs.length; i += 1) {
492    const between = cmd.slice((segs[i - 1] as Segment).end, (segs[i] as Segment).start)
493    const op = between.replace(/\s+/g, '')
494    if (op === '' ? !between.includes('\n') : !/^(&&|\|\||\||\|&|;|&)$/.test(op)) return false
495  }
496  return true
497}
498
499export type Wrapped = { text: string; index: number; token: string; mode: Mode; also: Job[] }
500export type Wrap = { command: string; jobs: Wrapped[]; unsafe: Job[] }
501
502// Rewrite a command so the jobs it starts run under the supervisor, never splitting the command line: the user's
503// shell runs the original text unchanged inside one supervised process group.
504// - a run-in-background call is wrapped whole (task mode: the background task lasts as long as its group);
505// - a foreground call is wrapped whole when nothing in it is backgrounded (task mode), or when its only
506//   background `&` is the one at the very end (detached: the supervisor goes to the background in its place).
507// Anything else (an `&` mid-line, comments, escapes, glued or ANSI-C quotes, subshells, groups, substitutions,
508// heredocs, control keywords, computed command words, `exec`, `setsid`, `cd -`) is left untouched, and then
509// nothing about it is recorded or refused: running a command this mod can't read is the safe failure.
510export function wrap(cmd: string, wholeInBackground: boolean, script: string, ledger: string, shell: string, token: () => string): Wrap {
511  const picked = jobs(cmd, wholeInBackground)
512  if (picked.length === 0) return { command: cmd, jobs: [], unsafe: [] }
513  const segs = segments(cmd)
514  // Left as it is. `unsafe` then holds the jobs only when the line reads reliably (the duplicate check may still
515  // refuse it); for a line this mod can't read, or one the shell would reject, it is empty: nothing is checked.
516  const readable = !wrapUnsafe(cmd) && wellFormed(cmd, segs, /^\s*&?\s*$/)
517  const untouched = { command: cmd, jobs: [], unsafe: readable ? picked : [] }
518  if (!readable) return untouched
519  const main = picked.find(j => isLongRunner(j.text)) ?? (picked[picked.length - 1] as Job)
520  const also = picked.filter(j => j !== main)
521  const t = token()
522  const one = (mode: Mode) => [{ text: main.text, index: main.index, token: t, mode, also }]
523  if (wholeInBackground) return { command: supervised(script, t, ledger, 'task', shell, cmd), jobs: one('task'), unsafe: [] }
524  if (!segs.some(s => s.listBackground)) {
525    return { command: supervised(script, t, ledger, 'task', shell, cmd.trim()), jobs: one('task'), unsafe: [] }
526  }
527  // Backgrounded: only the last shell list, ended by the line's one `&`.
528  const lastList = (segs[segs.length - 1] as Segment).list
529  if (segs.some(s => s.listBackground !== (s.list === lastList)) || !wellFormed(cmd, segs, /^\s*&\s*$/)) return untouched
530  const first = segs.find(s => s.list === lastList) as Segment
531  if (segs.some(s => s.list !== lastList)) return untouched
532  const inner = cmd.slice(first.start, (segs[segs.length - 1] as Segment).end)
533  return { command: `${supervised(script, t, ledger, 'detached', shell, inner)} &`, jobs: one('detached'), unsafe: [] }
534}
535
536export type Ledger = { pid: number; lstart: string; mode: string; cwd: string }
537
538// The supervisor's `TOKEN.run` file.
539export function parseLedger(text: string): Ledger | null {
540  const get = (k: string) => (text.match(new RegExp(`^${k}=(.*)$`, 'm')) ?? [])[1]
541  const pid = Number(get('pid'))
542  const lstart = (get('lstart') ?? '').trim().replace(/\s+/g, ' ')
543  if (!Number.isInteger(pid) || pid <= 1 || lstart === '') return null
544  return { pid, lstart, mode: get('mode') ?? '', cwd: get('cwd') ?? '' }
545}
546
547// The supervisor's `TOKEN.exit` file: how the job ended.
548export function parseExit(text: string): { how: 'exited' | 'stopped' | 'killed'; code: number | null } {
549  const exit = (text.match(/^exit=(.*)$/m) ?? [])[1] ?? ''
550  if (exit === 'killed') return { how: 'killed', code: null }
551  const num = (v: string | undefined) => (v !== undefined && /^\d+$/.test(v) ? Number(v) : null)
552  if (exit === 'stopped') return { how: 'stopped', code: num((text.match(/^code=(\d+)$/m) ?? [])[1]) }
553  return { how: 'exited', code: num(exit) }
554}
555
556// The live supervisor this record names: same pid, same start time, and its command line carries our script
557// and this job's token. Anything else (gone, or a reused pid) is not ours, and is never signalled.
558export function supervisorRow(rows: PsRow[], pid: number, lstart: string, token: string): PsRow | null {
559  const row = rows.find(r => r.pid === pid)
560  if (row === undefined || row.lstart !== lstart) return null
561  return row.command.includes('pc-run') && row.command.includes(` ${token} `) ? row : null
562}
563
564// The processes in a supervisor's group, the supervisor itself left out.
565export function groupMembers(rows: PsRow[], leader: number): PsRow[] {
566  return rows.filter(r => r.pgid === leader && r.pid !== leader)
567}
568
569// The job a supervisor runs, read back from its own command line (for one found in the ledger with no record).
570export function commandOf(supervisorCommand: string): string {
571  const at = supervisorCommand.indexOf(' -- ')
572  return at < 0 ? '' : supervisorCommand.slice(at + 4)
573}
574
575export function age(ms: number): string {
576  const s = Math.max(0, Math.round(ms / 1000))
577  if (s < 60) return `${s}s`
578  if (s < 3600) return `${Math.floor(s / 60)}m`
579  if (s < 86400) return `${Math.floor(s / 3600)}h${Math.floor((s % 3600) / 60)}m`
580  return `${Math.floor(s / 86400)}d`
581}
582
583export function short(text: string, room: number): string {
584  const line = text.replace(/\s+/g, ' ').trim()
585  return line.length > room ? `${line.slice(0, Math.max(1, room - 1))}…` : line
586}
587
types/index.d.ts 54 lines
1export type ProcStatus = 'starting' | 'running' | 'stopping' | 'stopped' | 'exited' | 'unsupervised'
2
3// The supervisor (bin/pc-run) that leads a job's process group: the only process this mod ever asks to stop.
4export type Supervisor = { pid: number; lstart: string }
5
6// One job the agent started. Jobs launched through the supervisor can be stopped; anything else is shown only.
7export type Proc = {
8  id: string
9  // The job's supervisor token: names its ledger files and appears in the supervisor's command line.
10  token: string
11  sessionId: string
12  // The launched command with env assignments removed and secret-looking values hidden.
13  command: string
14  // Digests of folder + job identity: what counts as "the same server" for the duplicate check. A
15  // run-in-background line that starts several servers under one supervisor has one per server.
16  keys: string[]
17  // The ports the command asked for, where it named them.
18  wants: number[]
19  // The folder the job runs in, symlinks resolved.
20  cwd: string
21  startedAt: number
22  // 'task' ends with the call that launched it; 'detached' was sent to the background with `&`.
23  mode: 'task' | 'detached'
24  supervisor: Supervisor | null
25  // Pids in the supervisor's process group at the last look (display only; never signalled).
26  members: number[]
27  ports: number[]
28  status: ProcStatus
29  cpu: number
30  memMb: number
31  exitCode: number | null
32  taskId?: string
33  stopAt?: number
34  checkedAt: number
35  note: string
36}
37
38// A listener on the machine that this mod did not start: shown read-only.
39export type Foreign = { pid: number; ports: number[]; command: string }
40
41export type View = {
42  sessionId: string
43  procs: Proc[]
44  foreign: Foreign[]
45  updatedAt: number
46  note: string
47}
48
49declare module 'claude-code' {
50  interface PluginState {
51    'process-concierge': { view: View }
52  }
53}
54