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

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.
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.⚙ 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.Process Concierge doesn't guess which processes belong to a job. It makes each job's processes a group it can prove it owns:
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.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.<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.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:
node app.js & &, npm run dev &&)$'…' quoting, or quotes glued to other text (set''sid)if/for blocksexec, eval, source, cd -, pushd +N, or a command word that is a variable ($X dev)setsid, by path or inside double quotes too/usr/bin/perl, /usr/bin/env or /bin/psA 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:
dev/start/serve/watchtsc -w, Jest and Vitest watchpython -m http.server, uvicorn, Flask, Docker Compose, and morePorts 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:
setsid inside a script) leaves the group and isn't stopped with it.PATH are.&, or other commands of the same pipeline. npm run dev || npm run dev & is allowed; npm run dev & npm run dev is refused.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.Events this mod hooks, as claude plugin validate reads the module:
session.start, session.endcommand.run for /procstool.check for Bash: the permission decision for a rewritten command is the decision for the originaltool.call for Bash: rewrites a job to launch under the supervisor, refuses duplicates, notes the job for the modelui.render for the AbovePrompt band and the /procs paneEngine 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.
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.
/plugin marketplace add ccdwyer/claude-mods
/plugin install process-concierge@ccdwyer-mods
/reload-plugins
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 .
MIT
hooks/register.tsx 775 lines1import 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}
775hooks/procs.ts 587 lines1// 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}
587types/index.d.ts 54 lines1export 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