claude-bulkhead 0.0.0-stage → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/LICENSE +21 -0
  3. package/README.es.md +498 -0
  4. package/README.ja.md +498 -0
  5. package/README.ko.md +498 -0
  6. package/README.md +497 -2
  7. package/README.pt-BR.md +498 -0
  8. package/README.zh-CN.md +498 -0
  9. package/bin/bulkhead +3 -0
  10. package/bin/bulkhead-slot +3 -0
  11. package/bin/bulkhead-statusline +3 -0
  12. package/hooks/hooks.json +39 -0
  13. package/package.json +52 -4
  14. package/src/basics.js +236 -0
  15. package/src/checkout-guard.js +296 -0
  16. package/src/cli-util.js +79 -0
  17. package/src/cli.js +41 -0
  18. package/src/config-check.js +29 -0
  19. package/src/config-cli.js +51 -0
  20. package/src/config.js +236 -0
  21. package/src/dev-servers.js +223 -0
  22. package/src/disk-guard.js +200 -0
  23. package/src/doctor.js +255 -0
  24. package/src/events.js +108 -0
  25. package/src/hook.js +94 -0
  26. package/src/kill-guard.js +435 -0
  27. package/src/log-cli.js +146 -0
  28. package/src/memory-guard.js +145 -0
  29. package/src/memory.js +76 -0
  30. package/src/orphan-reaper.js +527 -0
  31. package/src/paths.js +31 -0
  32. package/src/port-cli.js +59 -0
  33. package/src/port-guard.js +142 -0
  34. package/src/processes.js +181 -0
  35. package/src/reap-cli.js +56 -0
  36. package/src/remote-sh.js +478 -0
  37. package/src/remote.js +1034 -0
  38. package/src/remotes.js +126 -0
  39. package/src/scan.js +604 -0
  40. package/src/sessions.js +147 -0
  41. package/src/shell.js +597 -0
  42. package/src/slot-cli.js +150 -0
  43. package/src/slots.js +168 -0
  44. package/src/status.js +111 -0
  45. package/src/statusline.js +69 -0
  46. package/src/top-worker.js +14 -0
  47. package/src/top.js +305 -0
  48. package/src/wait-guard.js +263 -0
@@ -0,0 +1,527 @@
1
+ 'use strict'
2
+ const fs = require('fs')
3
+ const os = require('os')
4
+ const path = require('path')
5
+ const { execFileSync, spawn } = require('child_process')
6
+ const { enabled, setting, log, oneLine, recordEvent } = require('./hook')
7
+ const { readSessionFiles, claudeDir } = require('./sessions')
8
+ const { readProcesses } = require('./processes')
9
+
10
+ // What the reaper may stop once the Claude session that started it is proven gone. Anything that
11
+ // matches none of these is never stopped on its own account. `name` is tested against the program
12
+ // name without .exe, `cmd` against the whole command line. Shells are never matched: their command
13
+ // line names whatever they run, and they are stopped only as the top of an orphan's tree.
14
+ const pkg = names => new RegExp(`(?:[\\\\/]node_modules[\\\\/]|[\\\\/]install[\\\\/]cache[\\\\/])(?:${names})(?:@[^\\\\/\\s]*)?[\\\\/]`, 'i')
15
+ const PYTHON = /^(?:python[\d.]*|pythonw|py|pytest|py\.test)$/i
16
+ const BROWSER = /^(?:chrome|chromium(?:-browser)?|chrome-headless-shell|headless_shell|msedge|firefox|playwright|minibrowser|google chrome.*|microsoft edge.*)$/i
17
+ // Headless, or driven over a pipe by Playwright or Puppeteer (their own temp profiles too).
18
+ const AUTOMATION = /\s-?-headless\b|--remote-debugging-pipe|-juggler-pipe|--inspector-pipe|playwright_\w*profile|puppeteer_dev_\w*profile/i
19
+
20
+ const RULES = [
21
+ { rule: 'test-runner', what: 'Vitest and its worker pool (tinypool)', cmd: pkg('vitest|tinypool'),
22
+ reason: 'One worker per CPU core by default, each holding hundreds of MB; they never exit without their runner.' },
23
+ { rule: 'test-runner', what: 'Jest and jest-worker children', cmd: pkg('jest|jest-cli|jest-worker|@jest[\\\\/]core'),
24
+ reason: 'Same worker-per-core pool; a stuck worker keeps its memory forever.' },
25
+ { rule: 'test-runner', what: 'Mocha, including `--parallel` workers', cmd: pkg('mocha'),
26
+ reason: 'A watch or hung run never exits.' },
27
+ { rule: 'test-runner', what: 'AVA and its workers', cmd: pkg('ava'),
28
+ reason: 'One worker process per test file.' },
29
+ { rule: 'test-runner', what: '`node --test`, `bun test`, `deno test`',
30
+ test: (p, name) => (/^node$/i.test(name) && /\s--test(?:[\s=-]|$)/.test(p.cmd)) || (/^(?:bun|deno)$/i.test(name) && /\stest(?:\s|$)/.test(p.cmd)),
31
+ reason: 'The built-in runners start a child per test file.' },
32
+ { rule: 'test-runner', what: 'Playwright Test runner and workers', cmd: pkg('@playwright[\\\\/]test|playwright|playwright-core'),
33
+ reason: 'Each worker drives its own browser.' },
34
+ { rule: 'test-runner', what: 'pytest', name: PYTHON, cmd: /(?:^|[\s\\/"'])(?:py\.test|pytest)(?:\.exe)?(?=["'\s]|$)|\s-m\s+pytest\b/i,
35
+ reason: 'A hung test keeps the interpreter and its fixtures alive.' },
36
+ { rule: 'test-runner', what: 'pytest-xdist workers (execnet)', name: PYTHON, cmd: /exec\(eval\(sys\.stdin\.readline\(\)\)\)/,
37
+ reason: 'Workers outlive a killed controller; hundreds were reported left behind (anthropics/claude-code#25180).' },
38
+ { rule: 'test-runner', what: '`cargo test` and `cargo nextest`', name: /^cargo$/i, cmd: /\s(?:test|nextest)(?:\s|$)/,
39
+ reason: 'Compiles and runs test binaries, several GB while linking.' },
40
+ { rule: 'test-runner', what: 'Rust test binaries (`target/*/deps/<name>-<hash>`)', cmd: /[\\/]target[\\/](?:[^\\/\s"]+[\\/]){1,2}deps[\\/][\w-]+-[0-9a-f]{16}(?:\.exe)?(?=["\s]|$)/i,
41
+ reason: 'A hung test binary never exits on its own.' },
42
+ { rule: 'test-runner', what: '`go test` and its test binaries (`*.test`)', test: (p, name) => (/^go$/i.test(name) && /\stest(?:\s|$)/.test(p.cmd)) || /\.test$/i.test(name),
43
+ reason: 'Same: a hung test binary never exits on its own.' },
44
+ { rule: 'test-runner', what: '`dotnet test` and testhost', name: /^(?:dotnet|testhost(?:\.x86)?)$/i, cmd: /\stest(?:\s|$)|testhost/i,
45
+ reason: 'The test host stays up while a test hangs.' },
46
+ { rule: 'watcher', what: '`tsc --watch`', cmd: /(?:[\\/]typescript[\\/]bin[\\/]tsc|\btsc(?:\.cmd|\.js)?)["']?\s(?:.*\s)?(?:-w|--watch)(?:\s|$)/i,
47
+ reason: 'Watchers never exit.' },
48
+ { rule: 'watcher', what: 'Vite, webpack, esbuild, Rollup or Parcel with `--watch`', cmd: /\b(?:vite|webpack(?:-cli)?|esbuild|rollup|parcel)\b(?:.*\s)?(?:--watch|-w)(?:[\s=]|$)/i,
49
+ reason: 'Watchers never exit.' },
50
+ { rule: 'watcher', what: 'nodemon, `node --watch`, `tsx watch`, ts-node-dev, chokidar',
51
+ test: (p, name) => /[\\/](?:nodemon|ts-node-dev|chokidar-cli)[\\/]|\bnodemon(?:\.cmd)?\b|\btsx\b\S*\s+watch\b/i.test(p.cmd) || (/^node$/i.test(name) && /\s--watch(?:[\s=-]|$)/.test(p.cmd)),
52
+ reason: 'Watchers never exit, and restart their child on every file change.' },
53
+ { rule: 'builder', what: '`next build`', cmd: /\bnext["']?\s+build\b/i,
54
+ reason: 'Several GB for minutes; a second build in the same tree fails while it runs.' },
55
+ { rule: 'builder', what: 'Turborepo task runs (`turbo run`, `turbo build`, ...)', cmd: /\bturbo(?:\.exe|\.cmd)?["']?\s+(?!daemon\b)\w/i,
56
+ reason: 'Runs many package builds at once.' },
57
+ { rule: 'builder', what: '`vite build`, webpack, Rollup and `tsc` one-shot builds', cmd: /[\\/]vite[\\/]bin[\\/]vite\.js["']?\s+build\b|[\\/]node_modules[\\/](?:webpack|webpack-cli|rollup)[\\/]bin[\\/]|[\\/]typescript[\\/]bin[\\/]tsc\b/i,
58
+ reason: 'Holds its memory until it finishes, while nobody reads the result.' },
59
+ { rule: 'headless-browser', what: 'Headless or automation browsers (Chrome, Chromium, Edge, Firefox, WebKit) started with `--headless`, a debugging pipe, or a Playwright or Puppeteer profile', name: BROWSER, cmd: AUTOMATION, root: true,
60
+ reason: 'Each instance holds hundreds of MB; Playwright and Puppeteer leave them behind when the runner is killed.' },
61
+ { rule: 'dev-server', what: 'Anything listening on a TCP port', listening: true,
62
+ reason: 'Holds the port the next session in that project will want, and its memory.' },
63
+ ]
64
+
65
+ // Never stopped, nor anything they started, even under an orphaned shell: programs meant to
66
+ // outlive the session, and daemons that other sessions share.
67
+ const KEEP = [
68
+ { what: 'Claude Code itself', test: p => isClaude(p), reason: 'A session started from a tool shell is a live session of its own.' },
69
+ { what: 'orphan-reaper itself', cmd: /orphan-reaper\.js/, reason: 'The reaper and its waiters.' },
70
+ { what: 'Editors and IDEs (VS Code, Cursor, Windsurf, Zed, Sublime Text, JetBrains IDEs, Android Studio, Visual Studio, Vim, Emacs)',
71
+ name: /^(?:code|code - insiders|code-insiders|codium|vscodium|cursor|windsurf|zed|subl|sublime_text|atom|idea\d*|pycharm\w*|webstorm\w*|goland\w*|rider\w*|clion\w*|phpstorm\w*|rubymine\w*|datagrip\w*|rustrover\w*|fleet|studio\d*|devenv|notepad\+\+|vim|nvim|gvim|nvim-qt|neovide|emacs|emacsclient|nano|xcode)$/i,
72
+ reason: '`code .` from a tool shell opens your editor; it is yours, not the session\'s.' },
73
+ { what: 'Terminals and multiplexers (Windows Terminal, iTerm2, GNOME Terminal, kitty, WezTerm, tmux, screen, ...)',
74
+ name: /^(?:windowsterminal|wt|openconsole|mintty|conemu\w*|alacritty|kitty|wezterm(?:-gui|-mux-server)?|gnome-terminal(?:-server|\.real)?|konsole|xterm|urxvt|terminator|tilix|iterm2|terminal|ghostty|hyper|tabby|warp|tmux|tmux: server|screen|zellij|explorer)$/i,
75
+ reason: 'A terminal opened from a tool shell is yours, and so is everything you run in it.' },
76
+ { what: 'macOS app bundles that are not automation browsers', test: p => /\.app[\\/]Contents[\\/]MacOS[\\/]/.test(p.cmd) && !AUTOMATION.test(p.cmd),
77
+ reason: 'GUI apps opened with `open` are yours.' },
78
+ { what: 'Shared build daemons (Gradle, Kotlin, MSBuild node reuse, VBCSCompiler, Bazel, Buck2, sccache, Turborepo and Nx daemons)',
79
+ test: (p, name) => /^(?:vbcscompiler|bazel|buck2d|sccache)$/i.test(name) ||
80
+ /org\.gradle\.launcher\.daemon|GradleDaemon|KotlinCompileDaemon|org\.jetbrains\.kotlin\.daemon|[\\/]nx[\\/]src[\\/]daemon[\\/]|\bturbo(?:\.exe)?["']?\s+daemon\b|MSBuild\.(?:dll|exe)["']?\s.*\/nodemode/i.test(p.cmd),
81
+ reason: 'Builds in every session connect to the same daemon; stopping it breaks theirs.' },
82
+ { what: 'Shared machine services (adb, the Android emulator, Docker, Ollama, watchman, ssh-agent, gpg-agent)',
83
+ name: /^(?:adb|emulator|emulator64-\w+|qemu-system-[\w-]+|dockerd|docker|containerd|com\.docker\.[\w.-]+|ollama|watchman|ssh-agent|gpg-agent)$/i,
84
+ reason: 'One instance serves every session on the machine.' },
85
+ ]
86
+
87
+ const SHELL = /^(?:bash|sh|dash|zsh|fish|ksh|cmd|powershell|pwsh|wsl|env|nohup|sudo|timeout|time|nice|setsid)$/i
88
+ const CLAUDE_SHELL = /shell-snapshots[\\/]snapshot-|CLAUDE_CODE_SHELL_LAUNCHER_SCRIPT/
89
+ const SNAPSHOT_NAME = /snapshot-\w+-(\d{13})-\w+\.\w+/
90
+ const POWERSHELL_MIN_AGE_MS = 6 * 3600 * 1000
91
+ // Claude Code writes the shell snapshot a little after the session starts, at its first Bash call.
92
+ const SNAPSHOT_AFTER_START_MS = 5000
93
+ const WAIT_LIMIT_MS = 3 * 60 * 1000
94
+ const POLL_MS = 1000
95
+
96
+ const baseName = name => String(name || '').replace(/\.exe$/i, '')
97
+
98
+ // Generous on purpose: anything that might be Claude ends a parent chain as a live session. The
99
+ // native installer runs versions/<x.y.z> through a `claude` link, so the name alone can differ.
100
+ function isClaude(p) {
101
+ if (/^claude(?:\.exe)?$/i.test(p.name)) return true
102
+ if (/^["']?(?:\S*[\\/])?claude(?:\.exe)?["']?(?:\s|$)|[\\/]claude[\\/]versions[\\/]/i.test(p.cmd)) return true
103
+ return /^(?:node|bun)(?:\.exe)?$/i.test(p.name) && /claude-code|@anthropic-ai[\\/]claude/i.test(p.cmd)
104
+ }
105
+
106
+ function matchesSpec(spec, p, name) {
107
+ if (spec.test && !spec.test(p, name)) return false
108
+ if (spec.name && spec.cmd) return spec.name.test(name) && spec.cmd.test(p.cmd)
109
+ if (spec.name) return spec.name.test(name)
110
+ if (spec.cmd) return spec.cmd.test(p.cmd)
111
+ return Boolean(spec.test)
112
+ }
113
+
114
+ function keeper(keepNames = []) {
115
+ const extra = new Set(keepNames.map(n => baseName(n).toLowerCase()))
116
+ const cache = new Map()
117
+ return p => {
118
+ if (!cache.has(p.pid)) {
119
+ const name = baseName(p.name)
120
+ cache.set(p.pid, extra.has(name.toLowerCase()) || KEEP.some(k => matchesSpec(k, p, name)))
121
+ }
122
+ return cache.get(p.pid)
123
+ }
124
+ }
125
+
126
+ // The first rule a process matches, or null.
127
+ function ruleFor(p, { listening = new Set(), extraNames = [] } = {}) {
128
+ const name = baseName(p.name)
129
+ if (SHELL.test(name)) return null
130
+ for (const r of RULES) {
131
+ if (r.listening) {
132
+ if (listening.has(p.pid)) return r
133
+ continue
134
+ }
135
+ // a browser's renderers and GPU helpers carry --type=; its root process is enough
136
+ if (r.root && /--type=/.test(p.cmd)) continue
137
+ if (matchesSpec(r, p, name)) return r
138
+ }
139
+ const lower = name.toLowerCase()
140
+ if (extraNames.some(n => baseName(n).toLowerCase() === lower)) {
141
+ return { rule: 'extra-name', what: 'Program names in `orphanReaper.extraNames`', reason: 'You listed them.' }
142
+ }
143
+ return null
144
+ }
145
+
146
+ // Where the snapshot file named in a shell's command line lives, or null when that cannot be told
147
+ // for certain. MSYS paths such as /c/Users/... are mapped to C:/Users/... on Windows.
148
+ function snapshotPath(cmd, platform = process.platform) {
149
+ const m = /(["'])([^"']*?shell-snapshots[\\/]snapshot-\w+-\d{13}-\w+\.\w+)\1/.exec(cmd) || /()(\S*shell-snapshots[\\/]snapshot-\w+-\d{13}-\w+\.\w+)/.exec(cmd)
150
+ if (!m) return null
151
+ let p = m[2]
152
+ if (platform === 'win32') {
153
+ p = p.replace(/^\/(?:cygdrive\/)?([a-z])\//i, (_, d) => `${d.toUpperCase()}:/`)
154
+ return /^[a-z]:[\\/]/i.test(p) ? p : null
155
+ }
156
+ return p.startsWith('/') ? p : null
157
+ }
158
+
159
+ // true when the file is there or we cannot tell; false only when its folder exists without it.
160
+ function snapshotExists(file) {
161
+ if (!file) return true
162
+ try {
163
+ if (!fs.statSync(path.dirname(file)).isDirectory()) return true
164
+ } catch {
165
+ return true
166
+ }
167
+ return fs.existsSync(file)
168
+ }
169
+
170
+ // Linux and macOS: Claude Code exports CLAUDE_PID to tool shells and children inherit it.
171
+ // A reused PID shows up as an owner younger than the process, or one that is not Claude.
172
+ function ownerByEnv(p, ctx) {
173
+ const owner = ctx.byPid.get(p.claudePid)
174
+ if (owner && owner.start <= p.start && isClaude(owner)) return null
175
+ const s = ctx.session
176
+ if (s && (p.claudePid !== s.pid || p.start < s.since)) return null
177
+ let top = p
178
+ for (let i = 0; i < 30; i++) {
179
+ if (ctx.isKept(top)) return null
180
+ const par = ctx.byPid.get(top.ppid)
181
+ if (!par || par.pid <= 1 || par.claudePid !== p.claudePid || par.start > top.start) break
182
+ top = par
183
+ }
184
+ return {
185
+ top,
186
+ owner: p.claudePid,
187
+ why: owner ? `Claude PID ${p.claudePid} now belongs to a newer process (${owner.name})` : `its Claude session (PID ${p.claudePid}) has exited`,
188
+ }
189
+ }
190
+
191
+ // Walks ParentProcessId up from p. Windows does not reparent, so a dead session leaves a dangling
192
+ // parent above its tool shell; on Linux and macOS the chain ends at init or launchd. A child is
193
+ // never older than its parent, so an older "parent" means its PID was reused.
194
+ function walkChain(p, byPid, isKept = () => false) {
195
+ let cur = p
196
+ let shellTop = null
197
+ let snapshot = null
198
+ let kept = false
199
+ for (let i = 0; i < 30; i++) {
200
+ if (isKept(cur)) kept = true
201
+ if (CLAUDE_SHELL.test(cur.cmd)) {
202
+ shellTop = cur
203
+ const m = SNAPSHOT_NAME.exec(cur.cmd)
204
+ if (m && !snapshot) snapshot = { ms: Number(m[1]), name: m[0], cmd: cur.cmd }
205
+ }
206
+ const par = byPid.get(cur.ppid)
207
+ if (!par || par.pid <= 1 || par.start > cur.start) return { broken: true, top: shellTop, snapshot, kept, owner: cur.ppid, dangling: cur }
208
+ if (isClaude(par)) return { broken: false, claude: par.pid, snapshot }
209
+ cur = par
210
+ }
211
+ return { broken: false, snapshot }
212
+ }
213
+
214
+ // Snapshot timestamps used by tool shells whose chain still reaches a live Claude process.
215
+ function liveSnapshots(procs, byPid) {
216
+ const out = new Set()
217
+ for (const p of procs) {
218
+ if (!CLAUDE_SHELL.test(p.cmd) || !SNAPSHOT_NAME.test(p.cmd)) continue
219
+ const c = walkChain(p, byPid)
220
+ if (!c.broken && c.claude && c.snapshot) out.add(c.snapshot.ms)
221
+ }
222
+ return out
223
+ }
224
+
225
+ // Windows, and Linux or macOS when the environment is unreadable: the chain has to pass through a
226
+ // Claude Bash or PowerShell tool shell and then end at a parent that no longer exists. A live
227
+ // session whose shell backgrounded a job with `&` leaves the same dangling parent, so more proof
228
+ // is needed before anything is stopped.
229
+ function ownerByChain(p, ctx) {
230
+ const chain = walkChain(p, ctx.byPid, ctx.isKept)
231
+ if (!chain.broken || !chain.top || chain.kept) return null
232
+ const s = ctx.session
233
+ const top = chain.top
234
+ // After SessionEnd: a shell whose missing parent is exactly the Claude process we watched exit,
235
+ // and that started during its lifetime, is that session's.
236
+ if (s && chain.owner === s.pid && chain.dangling.start >= s.since && chain.dangling.start <= s.endedAt) {
237
+ return { top, owner: s.pid, why: `its Claude session (PID ${s.pid}) has just exited` }
238
+ }
239
+ const snap = chain.snapshot
240
+ if (!snap) {
241
+ // PowerShell tool shells carry no snapshot name to tie them to a session
242
+ if (s || ctx.now - p.start < POWERSHELL_MIN_AGE_MS) return null
243
+ return { top, owner: chain.owner, why: `its PowerShell tool shell lost its Claude parent ${Math.floor((ctx.now - p.start) / 3600e3)} h ago` }
244
+ }
245
+ if (s && (snap.ms < s.since - SNAPSHOT_AFTER_START_MS || snap.ms > s.endedAt)) return null
246
+ if (ctx.live.has(snap.ms)) return null
247
+ // Claude Code removes its snapshot file when the session exits cleanly.
248
+ if (!ctx.snapshotExists(snapshotPath(snap.cmd, ctx.platform))) {
249
+ return { top, owner: s ? s.pid : chain.owner, why: `its session has exited and removed ${snap.name}` }
250
+ }
251
+ // The file is still there after a crash, or the session is alive. The snapshot is written at a
252
+ // session's first Bash call, which can be long after it started, so any Claude process started
253
+ // before the snapshot may own it.
254
+ if (ctx.liveClaudeStarts.some(t => t <= snap.ms + SNAPSHOT_AFTER_START_MS)) return null
255
+ return { top, owner: s ? s.pid : chain.owner, why: `no running Claude session is older than ${snap.name}` }
256
+ }
257
+
258
+ // [{ pid, top, name, cmd, owner, rule, what, why }]: one entry per tree to stop. `session` limits
259
+ // the result to one session that has just exited: { pid, since, endedAt }.
260
+ function findOrphans({ procs, listening = new Set() }, now = Date.now(), opts = {}) {
261
+ const byPid = new Map(procs.map(p => [p.pid, p]))
262
+ const ctx = {
263
+ byPid,
264
+ now,
265
+ session: opts.session || null,
266
+ platform: opts.platform || process.platform,
267
+ isKept: keeper(opts.keepNames),
268
+ snapshotExists: opts.snapshotExists || snapshotExists,
269
+ liveClaudeStarts: procs.filter(isClaude).map(p => p.start),
270
+ live: liveSnapshots(procs, byPid),
271
+ }
272
+ const self = opts.self === undefined ? process.pid : opts.self
273
+ const found = []
274
+ const seen = new Set()
275
+ for (const p of procs) {
276
+ if (p.pid === self) continue
277
+ const r = ruleFor(p, { listening, extraNames: opts.extraNames })
278
+ if (!r || ctx.isKept(p)) continue
279
+ const own = p.claudePid ? ownerByEnv(p, ctx) : ownerByChain(p, ctx)
280
+ if (!own || seen.has(own.top.pid)) continue
281
+ seen.add(own.top.pid)
282
+ found.push({ pid: p.pid, top: own.top.pid, name: p.name, cmd: p.cmd, owner: own.owner, rule: r.rule, what: r.what, why: own.why })
283
+ }
284
+ return found
285
+ }
286
+
287
+ // The tree under rootPid, parents first, without any subtree rooted at a kept process. A child
288
+ // must not be older than its parent, which skips processes whose parent PID was reused.
289
+ function descendants(rootPid, procs, isKept = () => false) {
290
+ const byPid = new Map(procs.map(p => [p.pid, p]))
291
+ const kids = new Map()
292
+ for (const p of procs) {
293
+ if (!kids.has(p.ppid)) kids.set(p.ppid, [])
294
+ kids.get(p.ppid).push(p)
295
+ }
296
+ const root = byPid.get(rootPid)
297
+ if (!root) return [rootPid]
298
+ if (isKept(root)) return []
299
+ const out = [root]
300
+ const seen = new Set([rootPid])
301
+ for (let i = 0; i < out.length; i++) {
302
+ for (const c of kids.get(out[i].pid) || []) {
303
+ if (seen.has(c.pid) || c.start < out[i].start || isKept(c)) continue
304
+ seen.add(c.pid)
305
+ out.push(c)
306
+ }
307
+ }
308
+ return out.map(p => p.pid)
309
+ }
310
+
311
+ const sleep = ms => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
312
+
313
+ function alive(pid) {
314
+ try {
315
+ process.kill(pid, 0)
316
+ return true
317
+ } catch (e) {
318
+ return e.code === 'EPERM'
319
+ }
320
+ }
321
+
322
+ // Stops exactly these PIDs. No taskkill /T: it follows parent PIDs without checking start times,
323
+ // so it can reach an unrelated process whose parent PID was reused.
324
+ function killPids(pids, platform = process.platform) {
325
+ if (!pids.length) return
326
+ if (platform === 'win32') {
327
+ for (let i = 0; i < pids.length; i += 32) {
328
+ const args = ['/F']
329
+ for (const pid of pids.slice(i, i + 32)) args.push('/PID', String(pid))
330
+ try { execFileSync('taskkill', args, { stdio: 'ignore', windowsHide: true, timeout: 30000 }) } catch {}
331
+ }
332
+ return
333
+ }
334
+ const order = [...pids].reverse()
335
+ for (const pid of order) {
336
+ try { process.kill(pid, 'SIGTERM') } catch {}
337
+ }
338
+ for (let waited = 0; waited < 3000 && order.some(alive); waited += 100) sleep(100)
339
+ for (const pid of order) {
340
+ if (alive(pid)) try { process.kill(pid, 'SIGKILL') } catch {}
341
+ }
342
+ }
343
+
344
+ // Stops every orphan's tree and returns { orphan, pids } for each. On Windows a second look stops
345
+ // children that a dying process started after the first snapshot; elsewhere they are reparented
346
+ // and the next pass finds them through CLAUDE_PID.
347
+ function reapAll(orphans, procs, { platform = process.platform, keepNames = [], reread = null } = {}) {
348
+ const isKept = keeper(keepNames)
349
+ const byPid = new Map(procs.map(p => [p.pid, p]))
350
+ const done = []
351
+ const killed = new Map()
352
+ for (const o of orphans) {
353
+ const pids = [...new Set([...descendants(o.top, procs, isKept), o.pid])].filter(pid => !killed.has(pid))
354
+ killPids(pids, platform)
355
+ for (const pid of pids) killed.set(pid, byPid.get(pid))
356
+ done.push({ orphan: o, pids })
357
+ }
358
+ if (platform === 'win32' && killed.size && reread) {
359
+ let later = null
360
+ try { later = reread() } catch {}
361
+ if (later) {
362
+ const now = new Map(later.procs.map(p => [p.pid, p]))
363
+ const late = later.procs.filter(p => {
364
+ const parent = killed.get(p.ppid)
365
+ return parent && !now.has(p.ppid) && p.start >= parent.start && !killed.has(p.pid)
366
+ })
367
+ const pids = [...new Set(late.flatMap(p => descendants(p.pid, later.procs, isKept)))]
368
+ killPids(pids, platform)
369
+ }
370
+ }
371
+ return done
372
+ }
373
+
374
+ // The exited session's project, from the session file Claude Code may have left behind.
375
+ function projectOf(ownerPid, files) {
376
+ const f = ownerPid && files.get(ownerPid)
377
+ return (f && f.cwd) || null
378
+ }
379
+
380
+ function settings() {
381
+ return {
382
+ extraNames: setting('orphanReaper.extraNames', []),
383
+ keepNames: setting('orphanReaper.keepNames', []),
384
+ }
385
+ }
386
+
387
+ function snapshotNow(platform) {
388
+ return readProcesses(platform, { withEnv: true })
389
+ }
390
+
391
+ // One pass: find, stop, record. Returns the list of { orphan, pids } (planned only, for a dry run).
392
+ function run({ platform = process.platform, dryRun = false, session = null, trigger = 'startup', sessionId = null, project = null, snap = null } = {}) {
393
+ const opts = settings()
394
+ const snapshot = snap || snapshotNow(platform)
395
+ const orphans = findOrphans(snapshot, Date.now(), { ...opts, session, platform })
396
+ if (dryRun) {
397
+ const isKept = keeper(opts.keepNames)
398
+ return orphans.map(o => ({ orphan: o, pids: [...new Set([...descendants(o.top, snapshot.procs, isKept), o.pid])] }))
399
+ }
400
+ const done = reapAll(orphans, snapshot.procs, { platform, keepNames: opts.keepNames, reread: () => snapshotNow(platform) })
401
+ const files = done.length && !project ? readSessionFiles() : new Map()
402
+ for (const { orphan: o, pids } of done) {
403
+ log('orphan-reaper', `stopped ${o.rule} pid ${o.pid} (tree ${o.top}, ${pids.length} processes; ${o.why}) ${oneLine(o.cmd || o.name, 160)}`)
404
+ recordEvent('orphan-reaper', o.rule, { session_id: sessionId }, o.cmd || o.name, {
405
+ project: project || projectOf(o.owner, files),
406
+ pid: o.pid,
407
+ trigger,
408
+ })
409
+ }
410
+ return done
411
+ }
412
+
413
+ // The Claude process a SessionEnd hook belongs to and when it started, from its session file
414
+ // (Windows writes the exact creation time as procStart), else CLAUDE_PID, else our parent.
415
+ function sessionOf(input, { env = process.env, dir = path.join(claudeDir(), 'sessions'), ppid = process.ppid } = {}) {
416
+ let names = []
417
+ try { names = fs.readdirSync(dir).filter(n => /^\d+\.json$/.test(n)) } catch {}
418
+ const byEnv = Number(env.CLAUDE_PID) || null
419
+ let file = null
420
+ for (const n of names) {
421
+ try {
422
+ const s = JSON.parse(fs.readFileSync(path.join(dir, n), 'utf8'))
423
+ if (s && s.pid && ((input.session_id && s.sessionId === input.session_id) || (!file && s.pid === byEnv))) file = s
424
+ } catch {}
425
+ }
426
+ const pid = (file && Number(file.pid)) || byEnv || ppid
427
+ let since = 0
428
+ if (file) {
429
+ const started = Number(file.startedAt) || 0
430
+ // Windows FILETIME: 100 ns ticks since 1601
431
+ const exact = /^\d{17,19}$/.test(String(file.procStart || '')) ? Number(BigInt(file.procStart) / 10000n) - 11644473600000 : 0
432
+ since = exact && started && Math.abs(exact - started) < 600e3 ? exact : Math.max(0, started - 120e3)
433
+ }
434
+ return { pid, since }
435
+ }
436
+
437
+ // clear and resume keep the same Claude process running.
438
+ const KEEPS_RUNNING = new Set(['clear', 'resume'])
439
+
440
+ // SessionEnd: Claude Code waits at most 1.5 s for this hook and exits right after, so the hook only
441
+ // starts a hidden, detached waiter and returns.
442
+ function onSessionEnd(input, { env = process.env, start = startWaiter } = {}) {
443
+ if (KEEPS_RUNNING.has(input.reason)) return null
444
+ if (setting('orphanReaper.onSessionEnd', true) === false) return null
445
+ const { pid, since } = sessionOf(input, { env })
446
+ if (!pid || pid <= 1) return null
447
+ const args = ['--after-exit', String(pid), '--since', String(since)]
448
+ if (input.session_id) args.push('--session', String(input.session_id))
449
+ const project = env.CLAUDE_PROJECT_DIR || input.cwd
450
+ if (project) args.push('--project', String(project))
451
+ start(args)
452
+ return args
453
+ }
454
+
455
+ function startWaiter(args) {
456
+ const child = spawn(process.execPath, [__filename, ...args], { detached: true, windowsHide: true, stdio: 'ignore', cwd: os.homedir() })
457
+ child.unref()
458
+ }
459
+
460
+ function argValue(argv, name) {
461
+ const i = argv.indexOf(name)
462
+ if (i >= 0) return argv[i + 1]
463
+ const eq = argv.find(a => a.startsWith(name + '='))
464
+ return eq ? eq.slice(name.length + 1) : undefined
465
+ }
466
+
467
+ // The waiter: polls until the session's Claude process exits, then stops that session's orphans.
468
+ function afterExit(argv, platform = process.platform) {
469
+ const pid = Number(argValue(argv, '--after-exit'))
470
+ const since = Number(argValue(argv, '--since')) || 0
471
+ const limit = Number(argValue(argv, '--max-wait')) * 1000 || WAIT_LIMIT_MS
472
+ if (!pid) return
473
+ const t0 = Date.now()
474
+ while (alive(pid) && Date.now() - t0 < limit) sleep(POLL_MS)
475
+ const snap = snapshotNow(platform)
476
+ const still = snap.procs.find(p => p.pid === pid)
477
+ // still our Claude: started before we did. A newer process means the PID was reused.
478
+ if (still && isClaude(still) && still.start <= t0) {
479
+ log('orphan-reaper', `gave up waiting for Claude PID ${pid} after ${Math.round(limit / 1000)} s`)
480
+ return
481
+ }
482
+ const done = run({
483
+ platform, snap, trigger: 'session-end',
484
+ session: { pid, since, endedAt: Date.now() },
485
+ sessionId: argValue(argv, '--session') || null,
486
+ project: argValue(argv, '--project') || null,
487
+ })
488
+ log('orphan-reaper', `session ${pid} exited; stopped ${done.length} orphan tree${done.length === 1 ? '' : 's'}`)
489
+ }
490
+
491
+ const SUPPORTED = ['win32', 'linux', 'darwin']
492
+
493
+ function readStdin() {
494
+ return new Promise(resolve => {
495
+ let raw = ''
496
+ process.stdin.setEncoding('utf8')
497
+ process.stdin.on('data', d => (raw += d))
498
+ process.stdin.on('end', () => resolve(raw))
499
+ process.stdin.on('error', () => resolve(raw))
500
+ })
501
+ }
502
+
503
+ async function main(argv) {
504
+ if (argv.includes('--dry-run')) return require('./reap-cli').main(['--dry-run'])
505
+ if (!enabled('orphan-reaper') || !SUPPORTED.includes(process.platform)) return
506
+ if (argv.includes('--session-end')) {
507
+ let input = {}
508
+ try { input = JSON.parse((await readStdin()) || '{}') } catch {}
509
+ onSessionEnd(input)
510
+ return
511
+ }
512
+ try {
513
+ if (argv.includes('--after-exit') || argv.some(a => a.startsWith('--after-exit='))) afterExit(argv)
514
+ else run()
515
+ } catch (e) {
516
+ log('orphan-reaper', `failed: ${oneLine(e.message, 200)}`)
517
+ }
518
+ }
519
+
520
+ if (require.main === module) {
521
+ Promise.resolve().then(() => main(process.argv.slice(2))).catch(() => {}).then(() => process.exit(0))
522
+ }
523
+
524
+ module.exports = {
525
+ findOrphans, descendants, ruleFor, keeper, run, reapAll, snapshotPath, snapshotExists, sessionOf, onSessionEnd, afterExit, isClaude,
526
+ RULES, KEEP, SUPPORTED, WAIT_LIMIT_MS,
527
+ }
package/src/paths.js ADDED
@@ -0,0 +1,31 @@
1
+ 'use strict'
2
+ const fs = require('fs')
3
+ const path = require('path')
4
+
5
+ // The spelling git and the file system agree on. git prints a repository's real path, while a
6
+ // session's cwd or the home directory may come through a symlink, a junction, macOS /tmp
7
+ // (/private/tmp) or a Windows short name (C:\Users\RUNNER~1). Two spellings of one folder
8
+ // compare equal only after both go through this. The part of the path that exists is resolved;
9
+ // the rest, such as a folder a command is about to create, is kept as written on top of it.
10
+ function realPath(p) {
11
+ const abs = path.resolve(p)
12
+ const rest = []
13
+ let head = abs
14
+ for (;;) {
15
+ try {
16
+ return rest.length ? path.join(fs.realpathSync.native(head), ...rest) : fs.realpathSync.native(head)
17
+ } catch {}
18
+ const up = path.dirname(head)
19
+ if (up === head) return abs
20
+ rest.unshift(path.basename(head))
21
+ head = up
22
+ }
23
+ }
24
+
25
+ // A form of a path for comparing: real, forward slashes, no trailing slash, and on Windows one case.
26
+ function pathKey(p, platform = process.platform) {
27
+ const n = realPath(p).replace(/\\/g, '/').replace(/\/+$/, '') || '/'
28
+ return platform === 'win32' ? n.toLowerCase() : n
29
+ }
30
+
31
+ module.exports = { realPath, pathKey }
@@ -0,0 +1,59 @@
1
+ 'use strict'
2
+ const net = require('net')
3
+ const { option, rejectArgs } = require('./cli-util')
4
+
5
+ const USAGE = `usage: bulkhead port [--near PORT] [--json]
6
+
7
+ Prints the first port at or above PORT (default 3000) that nothing is listening on,
8
+ so a dev server can start there without taking another session's port:
9
+ npm run dev -- --port $(bulkhead port --near 3000)`
10
+
11
+ // Ports browsers refuse to open (the fetch spec's "bad ports"), so a dev server there is useless.
12
+ const BROWSER_BLOCKED = new Set([1719, 1720, 1723, 2049, 3659, 4045, 5060, 5061, 6000, 6566, 6665, 6666, 6667, 6668, 6669, 6697, 10080])
13
+
14
+ // One address at a time: on Windows a server on 127.0.0.1 does not stop a bind to 0.0.0.0 or ::1,
15
+ // so only a port all four accept is really free. Hosts without IPv6 report EADDRNOTAVAIL, which is fine.
16
+ const HOSTS = ['127.0.0.1', '0.0.0.0', '::1', '::']
17
+
18
+ function canBind(host, port) {
19
+ return new Promise(resolve => {
20
+ const s = net.createServer()
21
+ s.unref()
22
+ s.once('error', e => resolve(e.code !== 'EADDRINUSE' && e.code !== 'EACCES'))
23
+ s.listen({ host, port, exclusive: true }, () => s.close(() => resolve(true)))
24
+ })
25
+ }
26
+
27
+ async function isFree(port, bind = canBind) {
28
+ for (const h of HOSTS) if (!(await bind(h, port))) return false
29
+ return true
30
+ }
31
+
32
+ async function findFree(near = 3000, { bind = canBind, span = 1000 } = {}) {
33
+ for (let p = Math.max(1024, near); p < Math.min(65536, near + span); p++) {
34
+ if (BROWSER_BLOCKED.has(p)) continue
35
+ if (await isFree(p, bind)) return p
36
+ }
37
+ return null
38
+ }
39
+
40
+ async function main(args, { stdout = process.stdout, stderr = process.stderr } = {}) {
41
+ if (args.includes('--help') || args.includes('-h')) return stdout.write(USAGE + '\n')
42
+ if (rejectArgs('port', args, { flags: ['json'], valued: ['near'] }, stderr, USAGE)) return
43
+ const nearArg = option(args, 'near')
44
+ const near = nearArg === undefined ? 3000 : Number(nearArg)
45
+ if (!Number.isInteger(near) || near < 1 || near > 65535) {
46
+ stderr.write(`bulkhead port: --near needs a port number, got ${nearArg}\n`)
47
+ process.exitCode = 1
48
+ return
49
+ }
50
+ const port = await findFree(near)
51
+ if (args.includes('--json')) stdout.write(JSON.stringify({ port }) + '\n')
52
+ else if (port) stdout.write(`${port}\n`)
53
+ if (!port) {
54
+ if (!args.includes('--json')) stderr.write(`bulkhead port: no free port between ${near} and ${near + 999}\n`)
55
+ process.exitCode = 1
56
+ }
57
+ }
58
+
59
+ module.exports = { main, findFree, isFree, canBind, BROWSER_BLOCKED }