SLOPSHOPPER

ghost-proc-guard

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…

newpaneguardcommandtoaststatus
v0.1.0MITupdated 2026-10-02thieung/claude-mods/ghost-proc-guard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ghost-proc-guard
│ ┃ Dev processes ✕ › fix the failing auth test and add an audit log call │ ┃ Started by Claude │ ┃ None tracked yet. ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Other servers in this project ⏺ Update(src/auth.ts) │ ┃ None. ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ [ Refresh ] [ Forget stopped ] ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /procs │ ⎿ ghost-proc-guard: Dev processes pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Dev processes
Started by Claude None tracked yet. Other servers in this project None. [ Refresh ] [ Forget stopped ]
README

ghost-proc-guard

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.

What it does

  • Refuses duplicate launches. When Claude runs a dev command through Bash (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.
  • Monorepos. For a workspace runner (turbo, 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.
  • Worktrees. Each server is placed in its git worktree, including worktrees nested in the main checkout (.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.
  • Ownership. Each launch runs with GHOST_PROC_GUARD_SESSION=<session id> in its environment, so every server it starts is attributed to that session, even after it is orphaned.
  • /procs. A pane lists the servers Claude started (this session and earlier ones) and the other servers in the project, one row per app, with its main port and inspector port. Stop sends 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.

Limits

  • Only commands Claude runs through its Bash tool are checked; what you type in your own terminal is not.
  • Ports computed in code, such as port: Number(process.env.PORT), are not read; the tool default is assumed.
  • Workspace globs are expanded one level (packages/*); ** globs are skipped.
  • Tested on macOS. Session ownership is read with BSD ps -E; for system binaries whose environment macOS hides, the guard falls back to matching by port and folder.
  • The transcript shows the command Claude wrote. The environment prefix is added underneath.
  • If the guard's own check fails, the command runs as if the mod were not installed.

What it runs, reads and sends

Nothing leaves your machine. The mod makes no network calls.

  • Programs it starts, each as a fixed argument list with no shell:
  • 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.
  • Files it reads: 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.
  • What it reads from the conversation: the command text of each Bash call, to recognise dev launches.
  • Hooks that can change a call: the Bash 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.
  • What it keeps: the servers Claude started (process ids, ports, command, folder, session id, start time) in the plugin's store under your Claude Code configuration folder, so a later session can list leftovers; and the pane's lists in the session's plugin state.

Install

claude plugin marketplace add thieung/claude-mods
claude plugin install ghost-proc-guard@thieung-mods

Requires Claude Code 2.1.287 or later.

Source 3 files
hooks/register.tsx 529 lines
1import { 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}
529
hooks/detect.ts 386 lines
1// 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}
386
types/index.d.ts 31 lines
1export 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