Stops Claude from starting a second copy of your dev servers on the next free port (monorepos and worktrees included), tracks the servers it starts, and stops…

A Claude Code mod that keeps one set of dev servers per checkout. Without it, a session that forgets its server is running starts another one, the tool moves to the next free port, and orphaned servers pile up across sessions.
npm run dev, pnpm dev, turbo run dev, vite, next dev, astro dev, wrangler dev, uvicorn, python -m http.server, and similar), the guard works out which ports the launch will bind. It refuses the launch if one of those ports is taken, or if a server already runs from the same folder. Claude reads who owns the server (command, folder, launching session, process ids) and what to do next: reuse it, stop it if this session started it, or ask you.pnpm -r or --filter, nx, lerna, npm workspaces) it reads every package that runs the task, applies turbo's --filter, and takes each package's port from the script flag, then the tool's config (server.port in vite or astro config, [dev] port in wrangler.toml or .jsonc), then the tool's default..claude/worktrees/*). A launch that needs a port another worktree holds is refused with a warning not to reuse a server that runs other code. A server in a nested worktree on its own ports does not block the main checkout.GHOST_PROC_GUARD_SESSION=<session id> in its environment, so every server it starts is attributed to that session, even after it is orphaned.TERM to the app's whole process tree; a second press sends KILL. Claude's own processes and your interactive shells are never touched. A new session shows a toast about servers earlier sessions left running, and the status line counts the running ones.If you really want a second server, confirm it and Claude prefixes the command with GHOST_PROC_GUARD=allow.
worktree-guard in this marketplace names the process holding a port after a command fails with EADDRINUSE; ghost-proc-guard refuses the launch before it starts. They can run together.
port: Number(process.env.PORT), are not read; the tool default is assumed.packages/*); ** globs are skipped.ps -E; for system binaries whose environment macOS hides, the guard falls back to matching by port and folder.Nothing leaves your machine. The mod makes no network calls.
lsof -nP -iTCP -sTCP:LISTEN -Fpcn and lsof -a -d cwd -p <pids> -Fpn, to list listening processes and their working folders.ps -A -o pid=,ppid=,command=, to fold processes into apps by their parent chain.ps -wwE -o pid=,command= -p <pids> on listening processes, to read the GHOST_PROC_GUARD_SESSION marker. The output includes those processes' environment; only the marker is kept.sh -c 'echo $PPID', to find Claude's own process so it is never folded into an app or stopped.git -C <folder> worktree list --porcelain, to place servers in worktrees.kill -TERM or kill -KILL on an app's processes, only when you press Stop.package.json and pnpm-workspace.yaml in the launch folder, and in each workspace package its package.json plus vite.config.*, astro.config.* and wrangler.toml / .json / .jsonc. It writes no project files.tool.call hook refuses a launch whose ports are taken, and prefixes an allowed launch with export GHOST_PROC_GUARD_SESSION=<session id>;. session.start registers /procs; command.run and ui.render serve the pane.claude plugin marketplace add thieung/claude-mods
claude plugin install ghost-proc-guard@thieung-mods
Requires Claude Code 2.1.287 or later.
hooks/register.tsx 529 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Proc } from '../types'
5import {
6 ancestors,
7 appPort,
8 CONFIG_FILES,
9 descendants,
10 detectDevCommand,
11 groupByPid,
12 groupByRoot,
13 isUnder,
14 matchesFilters,
15 parseFilters,
16 parseWorktrees,
17 workspaceGlobs,
18 workspaceTask,
19 worktreeOf,
20 MARKER,
21 mainPort,
22 parseCwds,
23 parseListeners,
24 parseMarkers,
25 parsePs,
26 resolvePath,
27 rootOf,
28 shownPorts,
29 type DevCommand,
30 type Group,
31 type Tree,
32} from './detect'
33
34type Api = EngineInterface
35
36const PANE = 'ghost-proc-guard'
37const STORE_KEY = 'procs'
38const BYPASS = /\bGHOST_PROC_GUARD=allow\b/
39const SERVER_NAMES = /^(node|bun|deno|python\d?(\.\d+)?|ruby|php|java|go|uvicorn|gunicorn|workerd)/i
40const PENDING_MS = 60_000
41
42const tracked = atom({ plugin: 'ghost-proc-guard', key: 'tracked' } as const, [])
43const others = atom({ plugin: 'ghost-proc-guard', key: 'others' } as const, [])
44const pending = atom({ plugin: 'ghost-proc-guard', key: 'pending' } as const, [])
45
46async function cwdsOf($: Api, pids: number[]): Promise<Map<number, string>> {
47 if (pids.length === 0) return new Map()
48 const out = await $.process.run(['lsof', '-a', '-d', 'cwd', '-p', pids.join(','), '-Fpn'])
49 return parseCwds(out.stdout)
50}
51
52/** pid -> the Claude session that launched it, read from the marker in its environment. */
53async function markersOf($: Api, pids: number[]): Promise<Map<number, string>> {
54 if (pids.length === 0) return new Map()
55 return parseMarkers((await $.process.run(['ps', '-wwE', '-o', 'pid=,command=', '-p', pids.join(',')])).stdout)
56}
57
58/** The session a group was launched by, when any of its processes carries the marker. */
59function launcherOf(g: Group, markers: ReadonlyMap<number, string>): string | undefined {
60 return [g.root, ...g.members].map(pid => markers.get(pid)).find(Boolean)
61}
62
63/** Prefixes a launch so every process it starts carries this session's id. */
64function marked(command: string, sessionId: string): string {
65 return `export ${MARKER}=${sessionId}; ${command}`
66}
67
68async function processTree($: Api): Promise<Tree> {
69 return parsePs((await $.process.run(['ps', '-A', '-o', 'pid=,ppid=,command='])).stdout)
70}
71
72/** Claude's own process and everything above it: never climbed into, never stopped. */
73async function selfChain($: Api, tree: Tree): Promise<Set<number>> {
74 const self = Number((await $.process.run(['sh', '-c', 'echo $PPID'])).stdout.trim())
75 return new Set(Number.isFinite(self) && self > 1 ? [self, ...ancestors(self, tree)] : [])
76}
77
78type Snapshot = {
79 tree: Tree
80 stop: Set<number>
81 cwds: Map<number, string>
82 groups: Group[]
83 rootOf: (pid: number) => number
84}
85
86/** Listening processes folded into apps, plus what it took to fold them. `extra` pids get roots too. */
87async function snapshot($: Api, extra: readonly number[] = []): Promise<Snapshot> {
88 const [listening, tree] = await Promise.all([
89 $.process.run(['lsof', '-nP', '-iTCP', '-sTCP:LISTEN', '-Fpcn']),
90 processTree($),
91 ])
92 const stop = await selfChain($, tree)
93 const holders = groupByPid(parseListeners(listening.stdout)).filter(h => !stop.has(h.pid))
94 const wanted = new Set<number>()
95 for (const pid of [...holders.map(h => h.pid), ...extra.filter(pid => tree.has(pid))]) {
96 wanted.add(pid)
97 for (const up of ancestors(pid, tree, stop)) wanted.add(up)
98 }
99 const cwds = await cwdsOf($, [...wanted])
100 const root = (pid: number) => rootOf(pid, tree, cwds, stop)
101 return { tree, stop, cwds, groups: groupByRoot(holders, root), rootOf: root }
102}
103
104function appOf(snap: Snapshot, g: Group): { cwd: string; command: string } {
105 return {
106 cwd: snap.cwds.get(g.root) ?? snap.cwds.get(g.members[0]) ?? '',
107 command: snap.tree.get(g.root)?.command ?? g.names[0] ?? '',
108 }
109}
110
111/** Every live pid of the app: its root and all below it, Claude's chain excluded. */
112function appPids(root: number, tree: Tree, stop: ReadonlySet<number>): number[] {
113 return [root, ...descendants(root, tree)].filter(pid => tree.has(pid) && !stop.has(pid))
114}
115
116async function readText($: Api, path: string): Promise<string | undefined> {
117 try {
118 const text = await $.fs.read(path)
119 return typeof text === 'string' ? text : undefined
120 } catch {
121 return undefined
122 }
123}
124
125async function readJson($: Api, path: string): Promise<Record<string, unknown> | undefined> {
126 const text = await readText($, path)
127 try {
128 return text === undefined ? undefined : JSON.parse(text)
129 } catch {
130 return undefined
131 }
132}
133
134async function listDir($: Api, path: string): Promise<{ name: string; kind: string }[]> {
135 try {
136 return await $.fs.list(path)
137 } catch {
138 return []
139 }
140}
141
142function scriptOf(pkg: Record<string, unknown> | undefined, name: string): string | undefined {
143 const body = (pkg?.scripts as Record<string, unknown> | undefined)?.[name]
144 return typeof body === 'string' && body.trim() !== '' ? body : undefined
145}
146
147/** The port one package's script will bind, reading its tool's config file when the script names none. */
148async function packagePort($: Api, dir: string, body: string): Promise<number | undefined> {
149 const present = new Set((await listDir($, dir)).map(entry => entry.name))
150 const configs: { name: string; text: string }[] = []
151 for (const [name, tool] of CONFIG_FILES) {
152 if (!present.has(name) || !tool.test(body)) continue
153 const text = await readText($, resolvePath(dir, name))
154 if (text !== undefined) configs.push({ name, text })
155 }
156 return appPort(body, configs)
157}
158
159/** Package folders a workspace glob names (`packages/*`, `apps/web`); deeper globs are not expanded. */
160async function expandGlob($: Api, root: string, glob: string): Promise<string[]> {
161 if (!glob.includes('*')) return [resolvePath(root, glob)]
162 const m = glob.match(/^([^*]*?)\/?\*$/)
163 if (!m) return []
164 const base = resolvePath(root, m[1] || '.')
165 return (await listDir($, base)).filter(entry => entry.kind === 'dir').map(entry => resolvePath(base, entry.name))
166}
167
168/**
169 * Every port a launch is expected to bind: the command's own, the script's,
170 * or for a workspace runner one per package that runs the task.
171 */
172async function expectedPorts($: Api, dev: DevCommand, command: string): Promise<number[]> {
173 if (dev.port !== undefined) return [dev.port]
174 const rootPkg = await readJson($, resolvePath(dev.cwd, 'package.json'))
175 let task = dev.task
176 let runnerText = command
177 if (task === undefined) {
178 const body = dev.script ? scriptOf(rootPkg, dev.script) : undefined
179 if (body === undefined) return []
180 task = workspaceTask(body)
181 if (task === undefined) {
182 const port = await packagePort($, dev.cwd, body)
183 return port === undefined ? [] : [port]
184 }
185 runnerText = `${body} ${command}`
186 }
187
188 const globs = workspaceGlobs(await readText($, resolvePath(dev.cwd, 'pnpm-workspace.yaml')), rootPkg)
189 const filters = parseFilters(runnerText)
190 const ports = new Set<number>()
191 for (const glob of globs) {
192 for (const dir of await expandGlob($, dev.cwd, glob)) {
193 const pkg = await readJson($, resolvePath(dir, 'package.json'))
194 const body = scriptOf(pkg, task)
195 const name = typeof pkg?.name === 'string' ? pkg.name : undefined
196 if (body === undefined || !matchesFilters(name, dir.slice(dev.cwd.length + 1), filters)) continue
197 const port = await packagePort($, dir, body)
198 if (port !== undefined) ports.add(port)
199 }
200 }
201 return [...ports].sort((a, b) => a - b)
202}
203
204async function worktreesOf($: Api, cwd: string): Promise<string[]> {
205 try {
206 const out = await $.process.run(['git', '-C', cwd, 'worktree', 'list', '--porcelain'])
207 return out.exitCode === 0 ? parseWorktrees(out.stdout) : []
208 } catch {
209 return []
210 }
211}
212
213async function stored($: Api): Promise<Proc[]> {
214 return ((await $.store.get(STORE_KEY)) as Proc[] | undefined) ?? []
215}
216
217function portsLabel(p: { port?: number; ports?: number[] }): string {
218 const [main, ...rest] = shownPorts(p)
219 return `port ${main ?? '?'}${rest.length > 0 ? ` (also ${rest.join(', ')})` : ''}`
220}
221
222function shortPath(path: string): string {
223 const parts = path.split('/').filter(Boolean)
224 return parts.length <= 2 ? path : `…/${parts.slice(-2).join('/')}`
225}
226
227function earliest(a?: number, b?: number): number | undefined {
228 return a === undefined ? b : b === undefined ? a : Math.min(a, b)
229}
230
231async function persist($: Api, list: Proc[]): Promise<void> {
232 await $.store.set(STORE_KEY, list)
233 await update($, tracked, () => list)
234 const running = list.filter(p => p.isAlive).length
235 $.ui.status(running > 0 ? `procs: ${running} running · /procs` : undefined)
236}
237
238/** Cheap liveness pass for the timer: an app lives while its root or a listening member does. */
239async function refreshAlive($: Api): Promise<void> {
240 const tree = await processTree($)
241 const list = await stored($)
242 const next = list.map(p => ({ ...p, isAlive: [p.pid, ...(p.members ?? [])].some(pid => tree.has(pid)) }))
243 if (next.some((p, i) => p.isAlive !== list[i].isAlive)) await persist($, next)
244}
245
246/**
247 * Full pass: liveness, plus folding entries that belong to one app (records
248 * kept per listening process by earlier versions, or a root seen late) into one.
249 */
250async function refreshTracked($: Api): Promise<Proc[]> {
251 const list = await stored($)
252 const snap = await snapshot($, list.flatMap(p => [p.pid, ...(p.members ?? [])]))
253 const byRoot = new Map<number, Proc>()
254 const dead: Proc[] = []
255
256 for (const p of list) {
257 const live = [p.pid, ...(p.members ?? [])].find(pid => snap.tree.has(pid))
258 if (live === undefined) {
259 if (!dead.some(d => d.pid === p.pid)) dead.push({ ...p, isAlive: false })
260 continue
261 }
262 const root = snap.rootOf(live)
263 const group = snap.groups.find(g => g.root === root)
264 const seen = byRoot.get(root)
265 const ports = [...new Set([...(seen?.ports ?? []), ...(group?.ports ?? p.ports ?? (p.port ? [p.port] : []))])].sort((a, b) => a - b)
266 byRoot.set(root, {
267 ...(seen ?? p),
268 pid: root,
269 members: group?.members ?? [...new Set([...(seen?.members ?? []), ...(p.members ?? [p.pid])])],
270 ports,
271 port: ports.length > 0 ? mainPort(ports) : p.port,
272 cwd: snap.cwds.get(root) ?? p.cwd,
273 startedAt: earliest(seen?.startedAt, p.startedAt),
274 termSentAt: seen?.termSentAt ?? p.termSentAt,
275 isAlive: true,
276 })
277 }
278
279 const next = [...dead, ...byRoot.values()]
280 await persist($, next)
281 return next
282}
283
284async function refreshOthers($: Api): Promise<void> {
285 const root = await $.session.root()
286 const mine = await stored($)
287 const known = new Set(mine.flatMap(p => [p.pid, ...(p.members ?? [])]))
288 const snap = await snapshot($)
289 const list: Proc[] = snap.groups
290 .filter(g => !known.has(g.root) && !g.members.some(pid => known.has(pid)))
291 .filter(g => g.names.some(name => SERVER_NAMES.test(name)))
292 .map(g => ({ g, app: appOf(snap, g) }))
293 .filter(({ app }) => isUnder(app.cwd, root))
294 .map(({ g, app }) => ({ pid: g.root, members: g.members, port: g.port, ports: g.ports, command: app.command, cwd: app.cwd, isAlive: true }))
295 await update($, others, () => list)
296}
297
298/** Turns pending launches into tracked apps as their listeners show up. */
299async function settlePending($: Api): Promise<void> {
300 const waiting = await read($, pending)
301 if (waiting.length === 0) return
302 const now = await $.clock.now()
303 const snap = await snapshot($)
304 const sessionId = await $.session.id()
305 const list = await stored($)
306 const byRoot = new Map(list.map(p => [p.pid, p]))
307 const markers = await markersOf($, snap.groups.flatMap(g => [g.root, ...g.members]))
308 const added: Proc[] = []
309 let isChanged = false
310
311 for (const job of waiting) {
312 const before = new Set(job.before)
313 for (const g of snap.groups) {
314 const app = appOf(snap, g)
315 const isNew = g.members.some(pid => !before.has(pid))
316 const launcher = launcherOf(g, markers)
317 const isOurs =
318 launcher !== undefined
319 ? launcher === sessionId
320 : g.ports.some(p => (job.ports ?? [job.port]).includes(p)) || isUnder(app.cwd, job.cwd)
321 if (!isNew || !isOurs) continue
322 const seen = byRoot.get(g.root)
323 if (seen === undefined) {
324 const proc: Proc = {
325 pid: g.root,
326 members: g.members,
327 port: g.port,
328 ports: g.ports,
329 command: job.command,
330 cwd: app.cwd || job.cwd,
331 sessionId,
332 startedAt: now,
333 isAlive: true,
334 }
335 byRoot.set(g.root, proc)
336 added.push(proc)
337 } else if (seen.sessionId === sessionId && g.ports.some(p => !(seen.ports ?? []).includes(p))) {
338 byRoot.set(g.root, { ...seen, members: g.members, ports: g.ports, port: g.port })
339 isChanged = true
340 }
341 }
342 }
343
344 // Monorepos start their apps at different speeds: keep watching each launch for its whole window.
345 await update($, pending, () => waiting.filter(job => job.until > now))
346 if (added.length > 0 || isChanged) await persist($, [...byRoot.values()])
347 if (added.length > 0) {
348 $.ui.toast(`ghost-proc-guard: tracking ${added.map(p => `:${p.port} (${shortPath(p.cwd)})`).join(', ')}`)
349 }
350}
351
352/** Stops the whole app: TERM first, KILL on a second press while it lingers. */
353async function stopApp($: Api, proc: Proc): Promise<void> {
354 const tree = await processTree($)
355 const self = await selfChain($, tree)
356 const pids = [...new Set([...appPids(proc.pid, tree, self), ...(proc.members ?? []).filter(pid => tree.has(pid) && !self.has(pid))])]
357 if (pids.length > 0) await $.process.run(['kill', proc.termSentAt ? '-KILL' : '-TERM', ...pids.map(String)])
358 const list = await stored($)
359 if (list.some(p => p.pid === proc.pid)) {
360 const termSentAt = await $.clock.now()
361 await persist($, list.map(p => (p.pid === proc.pid ? { ...p, termSentAt } : p)))
362 }
363 await $.clock.sleep(800)
364 await refreshTracked($)
365 await refreshOthers($)
366}
367
368async function forgetDead($: Api): Promise<void> {
369 const list = await refreshTracked($)
370 await persist($, list.filter(p => p.isAlive))
371}
372
373export const register: Register = on => {
374 on('session.start', async ($, e, next) => {
375 await $.command.register({
376 name: 'procs',
377 description: 'List dev servers started by Claude sessions and stop leftovers',
378 })
379
380 const list = await refreshTracked($)
381 const root = await $.session.root()
382 const sessionId = await $.session.id()
383 const leftovers = list.filter(p => p.isAlive && p.sessionId !== sessionId && isUnder(p.cwd, root))
384 if (leftovers.length > 0) {
385 $.ui.toast(
386 `ghost-proc-guard: ${leftovers.length} dev server(s) from earlier sessions still running here (${leftovers
387 .map(p => `:${p.port}`)
388 .join(', ')}). /procs to review.`,
389 )
390 }
391
392 $.clock.every(3000, () => {
393 void (async () => {
394 await settlePending($)
395 const current = await read($, tracked)
396 if (current.some(p => p.isAlive)) await refreshAlive($)
397 })()
398 })
399
400 return next(e)
401 })
402
403 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
404 const dev = detectDevCommand(e.command, await $.session.cwd())
405 if (!dev) return next(e)
406 const sessionId = await $.session.id()
407 if (BYPASS.test(e.command)) return next({ ...e, command: marked(e.command, sessionId) })
408
409 const [ports, worktrees, snap] = await Promise.all([
410 expectedPorts($, dev, e.command),
411 worktreesOf($, dev.cwd),
412 snapshot($),
413 ])
414 const here = worktreeOf(dev.cwd, worktrees)
415
416 const onPort = snap.groups.filter(g => g.ports.some(p => ports.includes(p)))
417 // A worktree nested in this folder (`.claude/worktrees/x`) is another checkout, not part of this launch's app.
418 const sameDir = snap.groups.filter(g => {
419 const cwd = appOf(snap, g).cwd
420 return !onPort.includes(g) && g.names.some(name => SERVER_NAMES.test(name)) && isUnder(cwd, dev.cwd) && worktreeOf(cwd, worktrees) === here
421 })
422
423 if (onPort.length > 0 || sameDir.length > 0) {
424 const mine = new Map((await stored($)).flatMap(p => [p.pid, ...(p.members ?? [])].map(pid => [pid, p] as const)))
425 const busy = [...onPort, ...sameDir]
426 const markers = await markersOf($, busy.flatMap(g => [g.root, ...g.members]))
427 const lines = busy.map(g => {
428 const app = appOf(snap, g)
429 const pids = appPids(g.root, snap.tree, snap.stop)
430 const owner = mine.get(g.root) ?? g.members.map(pid => mine.get(pid)).find(Boolean)
431 const launcher = owner?.sessionId ?? launcherOf(g, markers)
432 const who = launcher === sessionId ? 'this session' : `Claude session ${launcher?.slice(0, 8)}`
433 const by = launcher ? ` (started by ${who})` : ''
434 const tree = worktreeOf(app.cwd, worktrees)
435 const elsewhere = tree !== undefined && tree !== here ? ` [another worktree: ${tree}]` : ''
436 return `- ${app.command.slice(0, 120)} on ${portsLabel(g)} [cwd ${app.cwd || '?'}]${elsewhere}${by}; processes: ${pids.join(' ')}`
437 })
438 const isOtherWorktree = busy.some(g => {
439 const tree = worktreeOf(appOf(snap, g).cwd, worktrees)
440 return tree !== undefined && tree !== here
441 })
442 const taken = [...new Set(onPort.flatMap(g => g.ports.filter(p => ports.includes(p))))].sort((a, b) => a - b)
443 return {
444 deny: [
445 `ghost-proc-guard: ${taken.length > 0 ? `Port${taken.length > 1 ? 's' : ''} ${taken.join(', ')} ${taken.length > 1 ? 'are' : 'is'} already in use.` : `A server is already listening from ${dev.cwd}.`} Not starting another dev server.`,
446 ...lines,
447 ...(isOtherWorktree
448 ? [
449 'That server runs the code of another worktree, so reusing it would test the wrong checkout.',
450 'Ask the user whether to stop it, or give this worktree its own fixed ports (each app\'s port setting) instead of letting the tools pick a free one.',
451 ]
452 : []),
453 ...(isOtherWorktree ? [] : ['Next step: if that server belongs to this project or worktree, reuse it instead of starting a new one or moving to another port.']),
454 'If it is stale and this session started it, stop all of its processes (kill -TERM <processes>) and run the command again. If another session or the user owns it, ask the user before stopping it.',
455 'If the user confirms a second server is really wanted, prefix the command with GHOST_PROC_GUARD=allow.',
456 ].join('\n'),
457 }
458 }
459
460 const ran = await next({ ...e, command: marked(e.command, sessionId) })
461 const now = await $.clock.now()
462 const listening = snap.groups.flatMap(g => g.members)
463 await update($, pending, list => [
464 ...list,
465 { port: ports[0], ports, cwd: dev.cwd, command: e.command.slice(0, 200), before: listening, until: now + PENDING_MS },
466 ])
467 return ran
468 })
469
470 on('command.run', { command: 'procs' }, async $ => {
471 await refreshTracked($)
472 await refreshOthers($)
473 await $.ui.open({ id: PANE, title: 'Dev processes' })
474 return { text: 'Dev processes pane opened.' }
475 })
476
477 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
478 const { Box, Text, Button } = $.ui.resolve(e)
479 const sessionId = await $.session.id()
480 const mine = await read($, tracked)
481 const rest = await read($, others)
482 const waiting = await read($, pending)
483
484 const row = (p: Proc, tag: string) => {
485 const [main, ...rest] = shownPorts(p)
486 return (
487 <Box key={`row-${p.pid}`} flexDirection="row" gap={1}>
488 <Text color={p.isAlive ? 'green' : undefined} dimColor={!p.isAlive}>
489 {p.isAlive ? '●' : '○'} :{main ?? '?'}
490 {rest.length > 0 ? ` (${rest.map(x => `:${x}`).join(' ')})` : ''} pid {p.pid}
491 </Text>
492 <Text dimColor>
493 {tag} {shortPath(p.cwd)}
494 </Text>
495 {p.isAlive && (
496 <Button key={`stop-${p.pid}`} onPress={() => stopApp($, p)}>
497 {p.termSentAt ? 'Kill' : 'Stop'}
498 </Button>
499 )}
500 </Box>
501 )
502 }
503
504 return (
505 <Box flexDirection="column" gap={1}>
506 <Box flexDirection="column">
507 <Text bold>Started by Claude</Text>
508 {mine.length === 0 && <Text dimColor>None tracked yet.</Text>}
509 {mine.map(p => row(p, p.sessionId === sessionId ? 'this session' : `session ${p.sessionId?.slice(0, 8) ?? '?'}`))}
510 {waiting.length > 0 && <Text dimColor>Watching {waiting.length} launch(es) for new servers…</Text>}
511 </Box>
512 <Box flexDirection="column">
513 <Text bold>Other servers in this project</Text>
514 {rest.length === 0 && <Text dimColor>None.</Text>}
515 {rest.map(p => row(p, 'untracked'))}
516 </Box>
517 <Box flexDirection="row" gap={1}>
518 <Button key="refresh" hotkey="r" onPress={async () => { await refreshTracked($); await refreshOthers($) }}>
519 Refresh
520 </Button>
521 <Button key="forget" hotkey="f" onPress={() => forgetDead($)}>
522 Forget stopped
523 </Button>
524 </Box>
525 </Box>
526 )
527 })
528}
529hooks/detect.ts 386 lines1// Pure helpers: recognise dev-server commands, infer their port, parse lsof output.
2
3const SCRIPT_RUN = /\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(dev|start|serve|preview|storybook)\b/
4
5// Order matters: more specific patterns first.
6const DEFAULTS: ReadonlyArray<readonly [RegExp, number]> = [
7 [/\bvite\s+preview\b/, 4173],
8 [/\bvite(?:\s+(?:dev|serve))?(?:\s+-|\s*$|\s+[^b\s])/, 5173],
9 [/\bnext\s+(?:dev|start)\b/, 3000],
10 [/\bastro\s+(?:dev|preview)\b/, 4321],
11 [/\bnuxi?\s+dev\b/, 3000],
12 [/\bremix\s+dev\b/, 3000],
13 [/\bstorybook\s+dev\b/, 6006],
14 [/\bwrangler\s+dev\b/, 8787],
15 [/\bexpo\s+start\b/, 8081],
16 [/\bwebpack(?:-dev-server|\s+serve)\b/, 8080],
17 [/\bparcel(?:\s+serve)?\s+\S+\.html\b/, 1234],
18 [/\brails\s+s(?:erver)?\b/, 3000],
19 [/\buvicorn\b/, 8000],
20 [/\bflask\s+run\b/, 5000],
21 [/\bpython3?\s+-m\s+http\.server\b/, 8000],
22 [/\bphp\s+-S\b/, 8000],
23 [/\bhugo\s+server\b/, 1313],
24 [/\bjekyll\s+serve\b/, 4000],
25]
26
27export type DevCommand = {
28 /** Directory the command runs in, after a leading `cd dir &&`. */
29 cwd: string
30 /** Port the server will bind, when it can be told from the command. */
31 port?: number
32 /** `npm run <script>` style: the script whose body decides the port. */
33 script?: string
34 /** A workspace runner (`turbo run dev`, `pnpm -r dev`): the task each package runs. */
35 task?: string
36}
37
38/** Recognises a command that starts a long-running dev server. */
39export function detectDevCommand(command: string, sessionCwd: string): DevCommand | undefined {
40 const { cwd, rest } = splitCd(stripQuoted(stripHeredocs(command)), sessionCwd)
41 const task = workspaceTask(rest)
42 if (task !== undefined) return { cwd, task }
43 const script = rest.match(SCRIPT_RUN)?.[1]
44 const port = explicitPort(rest) ?? defaultPort(rest)
45 if (script === undefined && port === undefined) return undefined
46 return { cwd, port, script: port === undefined ? script : undefined }
47}
48
49/** Infers the port from a package.json script body (`vite --port 5175`). */
50export function portFromScript(body: string): number | undefined {
51 return explicitPort(body) ?? defaultPort(body)
52}
53
54export function explicitPort(text: string): number | undefined {
55 const m =
56 text.match(/(?:--port[=\s]+|\s-p\s+|\bPORT=)(\d{2,5})\b/) ??
57 text.match(/\bhttp\.server\s+(\d{2,5})\b/) ??
58 text.match(/\bphp\s+-S\s+\S*:(\d{2,5})\b/) ??
59 text.match(/--bind[=\s]+\S*:(\d{2,5})\b/)
60 return m ? Number(m[1]) : undefined
61}
62
63export function defaultPort(text: string): number | undefined {
64 for (const [re, port] of DEFAULTS) if (re.test(text)) return port
65 return undefined
66}
67
68/** Drops heredoc bodies (`<<EOF ... EOF`, `<<-'X' ... X`), keeping the command lines around them. */
69export function stripHeredocs(command: string): string {
70 const lines = command.split('\n')
71 const out: string[] = []
72 let end: string | undefined
73 let isTabbed = false
74 for (const line of lines) {
75 if (end !== undefined) {
76 if ((isTabbed ? line.replace(/^\t+/, '') : line) === end) end = undefined
77 continue
78 }
79 out.push(line)
80 const m = line.match(/<<(-?)\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\2/)
81 if (m) {
82 isTabbed = m[1] === '-'
83 end = m[3]
84 }
85 }
86 return out.join('\n')
87}
88
89/**
90 * Drops quoted arguments (`printf '...'`, `grep "npm run dev"`): text handed to a
91 * program, not a command run. A string after `-c` is a command, so it stays.
92 */
93export function stripQuoted(command: string): string {
94 return command.replace(/(?<!-c\s+)(['"])(?:\\.|(?!\1)[\s\S])*\1/g, "''")
95}
96
97/** The variable a guarded launch carries, inherited by every process it starts. */
98export const MARKER = 'GHOST_PROC_GUARD_SESSION'
99
100/** Parses `ps -wwE -o pid=,command=` into pid -> the session id its environment carries. */
101export function parseMarkers(out: string): Map<number, string> {
102 const map = new Map<number, string>()
103 const re = new RegExp(`(?:^|\\s)${MARKER}=(\\S+)`)
104 for (const line of out.split('\n')) {
105 const pid = Number(line.trim().split(/\s+/)[0])
106 const m = line.match(re)
107 if (pid > 0 && m) map.set(pid, m[1])
108 }
109 return map
110}
111
112function splitCd(command: string, cwd: string): { cwd: string; rest: string } {
113 const m = command.trim().match(/^cd\s+("[^"]+"|'[^']+'|\S+)\s*&&\s*([\s\S]*)$/)
114 if (!m) return { cwd, rest: command }
115 const dir = m[1].replace(/^["']|["']$/g, '')
116 return { cwd: resolvePath(cwd, dir), rest: m[2] }
117}
118
119export function resolvePath(base: string, path: string): string {
120 if (path.startsWith('/')) return normalise(path)
121 return normalise(`${base}/${path}`)
122}
123
124function normalise(path: string): string {
125 const out: string[] = []
126 for (const part of path.split('/')) {
127 if (part === '' || part === '.') continue
128 if (part === '..') out.pop()
129 else out.push(part)
130 }
131 return `/${out.join('/')}`
132}
133
134export function isUnder(path: string, root: string): boolean {
135 return path === root || path.startsWith(root.endsWith('/') ? root : `${root}/`)
136}
137
138export type Listener = { pid: number; name: string; port: number }
139
140/** Parses `lsof -nP -iTCP -sTCP:LISTEN -Fpcn`: one entry per (pid, port). */
141export function parseListeners(out: string): Listener[] {
142 const seen = new Set<string>()
143 const list: Listener[] = []
144 let pid = 0
145 let name = ''
146 for (const line of out.split('\n')) {
147 const tag = line[0]
148 const value = line.slice(1)
149 if (tag === 'p') pid = Number(value)
150 else if (tag === 'c') name = value
151 else if (tag === 'n') {
152 const port = Number(value.slice(value.lastIndexOf(':') + 1))
153 const id = `${pid}:${port}`
154 if (Number.isFinite(port) && port > 0 && !seen.has(id)) {
155 seen.add(id)
156 list.push({ pid, name, port })
157 }
158 }
159 }
160 return list
161}
162
163/** Parses `lsof -a -d cwd -p a,b -Fpn` into pid -> cwd. */
164export function parseCwds(out: string): Map<number, string> {
165 const map = new Map<number, string>()
166 let pid = 0
167 for (const line of out.split('\n')) {
168 if (line[0] === 'p') pid = Number(line.slice(1))
169 else if (line[0] === 'n') map.set(pid, line.slice(1))
170 }
171 return map
172}
173
174export type Holder = { pid: number; name: string; port: number; ports: number[] }
175
176/** One entry per process: its lowest port as the main one, every port it holds sorted. */
177export function groupByPid(list: readonly Listener[]): Holder[] {
178 const byPid = new Map<number, Holder>()
179 for (const l of list) {
180 const one = byPid.get(l.pid) ?? { pid: l.pid, name: l.name, port: l.port, ports: [] }
181 if (!one.ports.includes(l.port)) one.ports.push(l.port)
182 one.ports.sort((a, b) => a - b)
183 one.port = one.ports[0]
184 byPid.set(l.pid, one)
185 }
186 return [...byPid.values()]
187}
188
189export type Tree = Map<number, { ppid: number; command: string }>
190
191/** Parses `ps -A -o pid=,ppid=,command=`. */
192export function parsePs(out: string): Tree {
193 const tree: Tree = new Map()
194 for (const line of out.split('\n')) {
195 const m = line.trim().match(/^(\d+)\s+(\d+)\s+(.*)$/)
196 if (m) tree.set(Number(m[1]), { ppid: Number(m[2]), command: m[3] })
197 }
198 return tree
199}
200
201const MAX_DEPTH = 12
202
203/** The chain of parents above `pid`, nearest first, stopping before any pid in `stop`. */
204export function ancestors(pid: number, tree: Tree, stop: ReadonlySet<number> = new Set()): number[] {
205 const chain: number[] = []
206 let cur = tree.get(pid)?.ppid
207 while (cur !== undefined && cur > 1 && !stop.has(cur) && chain.length < MAX_DEPTH) {
208 chain.push(cur)
209 cur = tree.get(cur)?.ppid
210 }
211 return chain
212}
213
214/** A login or bare interactive shell: someone's terminal, never part of an app. */
215function isInteractiveShell(command: string): boolean {
216 const cmd = command.trim()
217 return cmd.startsWith('-') || /^(\S*\/)?(zsh|bash|sh|fish|dash|nu|tmux)$/.test(cmd)
218}
219
220/**
221 * The top of the run of processes above `pid` sharing its working directory:
222 * the process that launched the app (wrangler above its workerds, pnpm above
223 * vite). Never climbs into `stop` (Claude's own chain) or an interactive shell.
224 */
225export function rootOf(pid: number, tree: Tree, cwds: ReadonlyMap<number, string>, stop: ReadonlySet<number>): number {
226 const cwd = cwds.get(pid)
227 if (cwd === undefined) return pid
228 let root = pid
229 for (const parent of ancestors(pid, tree, stop)) {
230 const info = tree.get(parent)
231 if (!info || cwds.get(parent) !== cwd || isInteractiveShell(info.command)) break
232 root = parent
233 }
234 return root
235}
236
237/** Every process below `root`, depth first. */
238export function descendants(root: number, tree: Tree): number[] {
239 const children = new Map<number, number[]>()
240 for (const [pid, { ppid }] of tree) children.set(ppid, [...(children.get(ppid) ?? []), pid])
241 const out: number[] = []
242 const walk = (pid: number) => {
243 for (const child of children.get(pid) ?? []) {
244 out.push(child)
245 walk(child)
246 }
247 }
248 walk(root)
249 return out
250}
251
252/** Ports the OS hands out on demand: a runtime's internal sockets, never one people browse to. */
253export const EPHEMERAL_FROM = 49152
254
255/** The ports worth showing: the main one first, then the other fixed ones (inspector and the like). */
256export function shownPorts(p: { port?: number; ports?: readonly number[] }): number[] {
257 const fixed = (p.ports ?? []).filter(x => x < EPHEMERAL_FROM && x !== p.port)
258 return p.port === undefined ? fixed : [p.port, ...fixed]
259}
260
261/** Prefers a fixed port (below the ephemeral range) as the one people browse to. */
262export function mainPort(ports: readonly number[]): number {
263 const sorted = [...ports].sort((a, b) => a - b)
264 return sorted.find(p => p < EPHEMERAL_FROM) ?? sorted[0]
265}
266
267export type Group = { root: number; members: number[]; names: string[]; ports: number[]; port: number }
268
269/** Folds listening processes into one entry per app root. */
270export function groupByRoot(holders: readonly Holder[], root: (pid: number) => number): Group[] {
271 const byRoot = new Map<number, Group>()
272 for (const h of holders) {
273 const key = root(h.pid)
274 const g = byRoot.get(key) ?? { root: key, members: [], names: [], ports: [], port: 0 }
275 g.members.push(h.pid)
276 g.names.push(h.name)
277 for (const p of h.ports) if (!g.ports.includes(p)) g.ports.push(p)
278 g.ports.sort((a, b) => a - b)
279 g.port = mainPort(g.ports)
280 byRoot.set(key, g)
281 }
282 return [...byRoot.values()]
283}
284
285const TASK = '(dev|start|serve|preview)'
286const RUNNERS: readonly RegExp[] = [
287 new RegExp(`\\bturbo\\s+(?:run\\s+)?${TASK}\\b`),
288 new RegExp(`\\bnx\\s+run-many\\b[^\\n;&|]*--targets?[=\\s]${TASK}\\b`),
289 new RegExp(`\\blerna\\s+run\\s+${TASK}\\b`),
290 new RegExp(`\\bpnpm\\b[^\\n;&|]*?\\s(?:-r|--recursive|--filter[=\\s]\\S+|-F\\s+\\S+)\\b[^\\n;&|]*?\\s(?:run\\s+)?${TASK}\\b`),
291 new RegExp(`\\b(?:npm|yarn)\\s+run\\s+${TASK}\\s+(?:--workspaces?\\b|-ws\\b)`),
292]
293
294/** The task a workspace runner starts in every package (`turbo run dev` -> `dev`). */
295export function workspaceTask(text: string): string | undefined {
296 for (const re of RUNNERS) {
297 const m = text.match(re)
298 if (m) return m[1]
299 }
300 return undefined
301}
302
303export type Filters = { include: string[]; exclude: string[] }
304
305/** Reads `--filter=x`, `--filter !x`, `-F x` (turbo's `...` dependency marks dropped). */
306export function parseFilters(text: string): Filters {
307 const filters: Filters = { include: [], exclude: [] }
308 for (const m of text.matchAll(/(?:--filter[=\s]+|(?:^|\s)-F\s+)(['"]?)([^\s'"]+)\1/g)) {
309 const value = m[2].replace(/^\.\.\.|\.\.\.$/g, '')
310 if (value.startsWith('!')) filters.exclude.push(value.slice(1))
311 else filters.include.push(value)
312 }
313 return filters
314}
315
316function globMatch(glob: string, value: string): boolean {
317 const re = new RegExp(`^${glob.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')}$`)
318 return re.test(value)
319}
320
321/** Whether a package (by name or `./relative/dir`) passes the runner's filters. */
322export function matchesFilters(name: string | undefined, dir: string, f: Filters): boolean {
323 const hits = (g: string) => (name !== undefined && globMatch(g, name)) || globMatch(g.replace(/^\.\//, ''), dir)
324 if (f.include.length > 0 && !f.include.some(hits)) return false
325 return !f.exclude.some(hits)
326}
327
328/** Workspace globs from pnpm-workspace.yaml or package.json `workspaces`. */
329export function workspaceGlobs(pnpmYaml: string | undefined, pkg: unknown): string[] {
330 const globs: string[] = []
331 if (pnpmYaml !== undefined) {
332 let isInPackages = false
333 for (const line of pnpmYaml.split('\n')) {
334 if (/^packages\s*:/.test(line)) isInPackages = true
335 else if (/^\S/.test(line)) isInPackages = false
336 else if (isInPackages) {
337 const m = line.match(/^\s*-\s*['"]?([^'"#\s]+)['"]?/)
338 if (m && !m[1].startsWith('!')) globs.push(m[1])
339 }
340 }
341 }
342 const ws = (pkg as { workspaces?: unknown } | undefined)?.workspaces
343 const list = Array.isArray(ws) ? ws : (ws as { packages?: unknown } | undefined)?.packages
344 if (Array.isArray(list)) for (const g of list) if (typeof g === 'string' && !g.startsWith('!')) globs.push(g)
345 return [...new Set(globs.map(g => g.replace(/^\.\//, '').replace(/\/$/, '')))]
346}
347
348/** Config files that can fix a dev server's port, by the tool that reads them. */
349export const CONFIG_FILES: ReadonlyArray<readonly [string, RegExp]> = [
350 ['vite.config.ts', /\bvite\b/], ['vite.config.mts', /\bvite\b/], ['vite.config.js', /\bvite\b/], ['vite.config.mjs', /\bvite\b/],
351 ['astro.config.mjs', /\bastro\b/], ['astro.config.ts', /\bastro\b/], ['astro.config.js', /\bastro\b/],
352 ['wrangler.toml', /\bwrangler\b/], ['wrangler.jsonc', /\bwrangler\b/], ['wrangler.json', /\bwrangler\b/],
353]
354
355/** The port a config file pins (vite/astro `port:`, wrangler `[dev] port`). */
356export function configPort(name: string, text: string): number | undefined {
357 const m = name.endsWith('.toml')
358 ? text.match(/^\[dev\][^[]*?^\s*port\s*=\s*(\d{2,5})/m)
359 : name.startsWith('wrangler.')
360 ? text.match(/"dev"\s*:\s*\{[^}]*"port"\s*:\s*(\d{2,5})/)
361 : text.match(/\bport\s*:\s*(\d{2,5})/)
362 return m ? Number(m[1]) : undefined
363}
364
365/** One app's port: a flag in its script, else its tool's config file, else the tool's default. */
366export function appPort(body: string, configs: ReadonlyArray<{ name: string; text: string }>): number | undefined {
367 const explicit = explicitPort(body)
368 if (explicit !== undefined) return explicit
369 for (const { name, text } of configs) {
370 const tool = CONFIG_FILES.find(([file]) => file === name)?.[1]
371 const port = tool?.test(body) ? configPort(name, text) : undefined
372 if (port !== undefined) return port
373 }
374 return defaultPort(body)
375}
376
377/** Paths from `git worktree list --porcelain`. */
378export function parseWorktrees(porcelain: string): string[] {
379 return porcelain.split('\n').filter(l => l.startsWith('worktree ')).map(l => l.slice('worktree '.length).trim())
380}
381
382/** The worktree a path belongs to: the deepest one containing it (worktrees may nest in the main one). */
383export function worktreeOf(path: string, worktrees: readonly string[]): string | undefined {
384 return worktrees.filter(w => isUnder(path, w)).sort((a, b) => b.length - a.length)[0]
385}
386types/index.d.ts 31 lines1export type Proc = {
2 pid: number
3 port?: number
4 /** Every port the process listens on, sorted; `port` is the first. */
5 ports?: number[]
6 /** The processes of the app that listen on its ports; `pid` is the app's root. */
7 members?: number[]
8 command: string
9 cwd: string
10 sessionId?: string
11 startedAt?: number
12 isAlive: boolean
13 termSentAt?: number
14}
15
16export type Pending = {
17 port?: number
18 /** Every port the launch is expected to bind (one per app of a monorepo). */
19 ports?: number[]
20 cwd: string
21 command: string
22 before: number[]
23 until: number
24}
25
26declare module 'claude-code' {
27 interface PluginState {
28 'ghost-proc-guard': { tracked: Proc[]; others: Proc[]; pending: Pending[] }
29 }
30}
31