claude-bulkhead 0.2.2 → 0.3.0

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 (66) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.es.md +142 -50
  3. package/README.ja.md +142 -50
  4. package/README.ko.md +143 -51
  5. package/README.md +142 -50
  6. package/README.pt-BR.md +142 -50
  7. package/README.zh-CN.md +154 -62
  8. package/hooks/hooks.json +68 -8
  9. package/package.json +5 -2
  10. package/src/agent-hook.js +162 -0
  11. package/src/agent-setup.js +256 -0
  12. package/src/agents.js +89 -0
  13. package/src/allow-cli.js +65 -0
  14. package/src/allow-hint.js +62 -0
  15. package/src/allow-once.js +152 -0
  16. package/src/auto-queue.js +93 -0
  17. package/src/bash-guards.js +36 -0
  18. package/src/basics.js +46 -27
  19. package/src/checkout-guard.js +342 -74
  20. package/src/cli-util.js +13 -1
  21. package/src/cli.js +40 -4
  22. package/src/config-check.js +21 -4
  23. package/src/config-cli.js +75 -10
  24. package/src/config.js +106 -9
  25. package/src/dev-servers.js +361 -61
  26. package/src/disk-guard.js +8 -8
  27. package/src/doctor-enforcement.js +273 -0
  28. package/src/doctor.js +19 -9
  29. package/src/edit-guard.js +196 -0
  30. package/src/events.js +30 -5
  31. package/src/explain.js +251 -0
  32. package/src/git-info.js +55 -0
  33. package/src/gitargs.js +82 -0
  34. package/src/hook.js +38 -6
  35. package/src/kill-extras.js +219 -0
  36. package/src/kill-guard.js +228 -77
  37. package/src/kill-owners.js +116 -0
  38. package/src/log-cli.js +15 -10
  39. package/src/mcp.js +131 -0
  40. package/src/memory-guard.js +126 -24
  41. package/src/memory.js +21 -2
  42. package/src/notices.js +95 -0
  43. package/src/orphan-reaper.js +58 -11
  44. package/src/port-cli.js +78 -3
  45. package/src/port-guard.js +80 -68
  46. package/src/port-kills.js +198 -0
  47. package/src/pressure.js +75 -0
  48. package/src/processes.js +44 -12
  49. package/src/scan-report.js +166 -0
  50. package/src/scan.js +87 -18
  51. package/src/secrets-guard.js +301 -0
  52. package/src/self-allow.js +73 -0
  53. package/src/session-info.js +121 -0
  54. package/src/session-state.js +176 -0
  55. package/src/sessions.js +109 -20
  56. package/src/shell.js +279 -19
  57. package/src/slot-cli.js +92 -21
  58. package/src/slots.js +61 -13
  59. package/src/stable-port.js +61 -0
  60. package/src/status.js +55 -17
  61. package/src/statusline.js +45 -16
  62. package/src/subagent-guard.js +24 -0
  63. package/src/top.js +36 -11
  64. package/src/wait-guard.js +96 -32
  65. package/src/worktree-cli.js +454 -0
  66. package/src/worktree-copy.js +82 -0
package/src/mcp.js ADDED
@@ -0,0 +1,131 @@
1
+ 'use strict'
2
+ const path = require('path')
3
+
4
+ // Stdio MCP servers are child processes of a session, one copy per session. Several sessions
5
+ // with the same server are N identical copies, each with its own memory. This finds them from
6
+ // command lines alone (heuristic, see docs/commands.md):
7
+ // - the program is a launcher (npx, npm exec, pnpm dlx, yarn dlx, bunx, uvx, uv run, pipx, node,
8
+ // bun, deno, python) or the program's own name looks like an MCP server;
9
+ // - and one argument names an MCP package or path: @modelcontextprotocol/..., a package ending in
10
+ // "mcp" or containing "mcp-" / "-mcp" / "mcp-server", or a path whose last two segments do.
11
+ // Options (--mcp-config and the like) never count.
12
+
13
+ const LAUNCHERS = /^(?:npx|npm|pnpm|pnpx|yarn|bunx|bun|uvx|uv|pipx|node|deno|python|python3|python3\.\d+|py|tsx|ts-node|docker)(?:\.exe|\.cmd)?$/i
14
+ const MCP_WORD = /(?:^|[\\/@_.-])mcp(?:$|[\\/@_.-])|mcp-?server|@modelcontextprotocol[\\/]/i
15
+
16
+ function tokens(cmd) {
17
+ return String(cmd || '').match(/"[^"]*"|'[^']*'|\S+/g)?.map(t => t.replace(/^["']|["']$/g, '')) || []
18
+ }
19
+
20
+ function programOf(tok) {
21
+ return path.basename(String(tok || '').replace(/\\/g, '/'))
22
+ }
23
+
24
+ // A path or package name reads as MCP: the last two segments only, so a project folder that
25
+ // happens to be called mcp-something does not make every command under it an MCP server.
26
+ function mentionsMcp(arg) {
27
+ if (!arg || arg.startsWith('-')) return false
28
+ const parts = arg.replace(/\\/g, '/').split('/').filter(Boolean)
29
+ const tail = arg.startsWith('@') && parts.length === 2 ? parts.join('/') : parts.slice(-2).join('/')
30
+ if (MCP_WORD.test(tail)) return true
31
+ // a script deep inside an installed package: the package name decides
32
+ const pkg = /node_modules\/((?:@[\w.-]+\/)?[\w.-]+)/.exec(parts.join('/') + '/')
33
+ return Boolean(pkg && MCP_WORD.test(pkg[1]))
34
+ }
35
+
36
+ function isMcpCommand(cmd) {
37
+ const t = tokens(cmd)
38
+ if (!t.length) return false
39
+ const prog = programOf(t[0])
40
+ if (LAUNCHERS.test(prog)) return t.slice(1).some(mentionsMcp)
41
+ return /(?:^|[-_.])mcp(?:$|[-_.])|mcp-?server/i.test(prog) && !/^(?:claude|node|sh|bash|zsh)(?:\.exe)?$/i.test(prog)
42
+ }
43
+
44
+ // Package name for display: @scope/pkg without its version, else the program or script.
45
+ function serverName(cmd) {
46
+ const t = tokens(cmd).slice(1)
47
+ const arg = t.find(mentionsMcp)
48
+ if (!arg) return programOf(tokens(cmd)[0])
49
+ const scoped = /^(@[\w.-]+\/[\w.-]+?)(?:@[^/\s]*)?$/.exec(arg)
50
+ if (scoped) return scoped[1]
51
+ const bare = /^([\w.-]+?)@(?:latest|next|\d[^/\s]*)$/.exec(arg)
52
+ if (bare) return bare[1]
53
+ const parts = arg.replace(/\\/g, '/').split('/').filter(Boolean)
54
+ const m = /node_modules\/((?:@[\w.-]+\/)?[\w.-]+)/.exec(arg.replace(/\\/g, '/'))
55
+ return m ? m[1] : parts.slice(-2).join('/')
56
+ }
57
+
58
+ // The command line with what differs between sessions removed, so two sessions' copies compare
59
+ // equal: the session's folder, package versions, numbers (ports, pids), ids and temp paths.
60
+ function signature(cmd, cwd) {
61
+ let s = String(cmd || '')
62
+ if (cwd) s = s.split(cwd).join('<cwd>')
63
+ return tokens(s).map((tok, i) => {
64
+ if (i === 0) return programOf(tok).replace(/\.(?:exe|cmd)$/i, '')
65
+ if (/^-{0,2}\d+$/.test(tok) || /^[0-9a-f]{8}-[0-9a-f-]{27,}$/i.test(tok) || /^[0-9a-f]{12,}$/i.test(tok)) return '#'
66
+ if (/^(?:\/private)?\/(?:tmp|var\/folders)\//.test(tok) || /[\\/]Temp[\\/]/i.test(tok)) return '<tmp>'
67
+ const eq = /^(--?[\w-]+)=(.*)$/.exec(tok)
68
+ if (eq && /^\d+$/.test(eq[2])) return `${eq[1]}=#`
69
+ return tok.replace(/(@[\w.-]+\/[\w.-]+|[\w.-]*mcp[\w.-]*)@(?:latest|next|\d[\w.+-]*)$/i, '$1')
70
+ }).join(' ')
71
+ }
72
+
73
+ // owner: Map pid -> session pid (assignOwners). Returns
74
+ // { instances: [{ session, pid, name, signature, rss, procs }], groups: [{ name, copies, sessions, rss, duplicate }] }
75
+ // An instance is the whole tree of one server (npx, its shell and the node process count once).
76
+ function findMcp(procs, owner, sessions) {
77
+ const byPid = new Map(procs.map(p => [p.pid, p]))
78
+ const kids = new Map()
79
+ for (const p of procs) {
80
+ if (!kids.has(p.ppid)) kids.set(p.ppid, [])
81
+ kids.get(p.ppid).push(p)
82
+ }
83
+ const roots = new Set(sessions.map(s => s.pid))
84
+ const candidate = p => owner.get(p.pid) !== undefined && !roots.has(p.pid) && isMcpCommand(p.cmd)
85
+ const cwdOf = new Map(sessions.map(s => [s.pid, s.cwd]))
86
+ // a server started through npx is a chain (npm exec, sh, node): only its top counts as the instance
87
+ const hasCandidateAncestor = p => {
88
+ let cur = byPid.get(p.ppid)
89
+ for (let i = 0; cur && i < 20 && owner.get(cur.pid) === owner.get(p.pid) && !roots.has(cur.pid); i++) {
90
+ if (candidate(cur)) return true
91
+ cur = byPid.get(cur.ppid)
92
+ }
93
+ return false
94
+ }
95
+ const instances = []
96
+ for (const p of procs) {
97
+ if (!candidate(p)) continue
98
+ if (hasCandidateAncestor(p)) continue
99
+ const stack = [p]
100
+ const seen = new Set()
101
+ let rss = 0
102
+ while (stack.length) {
103
+ const q = stack.pop()
104
+ if (seen.has(q.pid)) continue
105
+ seen.add(q.pid)
106
+ rss += q.rss || 0
107
+ for (const k of kids.get(q.pid) || []) if (owner.get(k.pid) === owner.get(p.pid)) stack.push(k)
108
+ }
109
+ const session = owner.get(p.pid)
110
+ instances.push({ session, pid: p.pid, name: serverName(p.cmd), signature: signature(p.cmd, cwdOf.get(session)), rss, procs: seen.size })
111
+ }
112
+ const bySig = new Map()
113
+ for (const i of instances) {
114
+ if (!bySig.has(i.signature)) bySig.set(i.signature, [])
115
+ bySig.get(i.signature).push(i)
116
+ }
117
+ const groups = [...bySig.values()].map(list => {
118
+ const sessionPids = [...new Set(list.map(i => i.session))].sort((a, b) => a - b)
119
+ return {
120
+ name: list[0].name,
121
+ signature: list[0].signature,
122
+ copies: list.length,
123
+ sessions: sessionPids,
124
+ rss: list.reduce((n, i) => n + i.rss, 0),
125
+ duplicate: list.length >= 2 && sessionPids.length >= 2,
126
+ }
127
+ }).sort((a, b) => b.rss - a.rss)
128
+ return { instances, groups }
129
+ }
130
+
131
+ module.exports = { isMcpCommand, serverName, signature, findMcp, mentionsMcp }
@@ -1,8 +1,9 @@
1
1
  'use strict'
2
- const { runHook, blocked } = require('./hook')
2
+ const { runHook, blocked, setting } = require('./hook')
3
3
  const { readMemory } = require('./memory')
4
4
  const { limits } = require('./slots')
5
5
  const shell = require('./shell')
6
+ const queue = require('./auto-queue')
6
7
 
7
8
  // Commands that commonly take several GB of RAM: test runners with workers, full builds,
8
9
  // bundlers, emulators and browsers. Each test gets the command's words with wrappers taken off.
@@ -32,13 +33,16 @@ const HEAVY_BINS = {
32
33
  webpack: argv => !quiet(argv) && sub(argv) !== 'serve',
33
34
  ffmpeg: argv => !quiet(argv) && argv.length > 1,
34
35
  cargo: argv => /^(?:build|b|test|t|bench)$/.test(sub(argv)),
35
- go: argv => sub(argv) === 'test' && has(argv, /\/\.\.\.$|^\.\.\.$/),
36
+ go: argv => /^(?:test|build)$/.test(sub(argv)) && has(argv, /\/\.\.\.$|^\.\.\.$|^-race$/),
37
+ xcodebuild: argv => has(argv, /^(?:-project|-workspace|-scheme|build|test|archive|build-for-testing|test-without-building)$/),
38
+ swift: argv => /^(?:build|test)$/.test(sub(argv)),
39
+ fastlane: argv => !quiet(argv) && !/^(?:lanes|env|help|actions|action|new_action|init)$/.test(sub(argv)) && argv.length > 1,
36
40
  pytest: argv => has(argv, /^(?:-n\d*|--numprocesses(?:=.*)?)$/),
37
41
  docker: argv => /^(?:build|buildx)$/.test(sub(argv)) || (sub(argv) === 'compose' && has(argv, /^(?:up|build)$/)),
38
42
  'docker-compose': argv => has(argv, /^(?:up|build)$/),
39
43
  bazel: argv => /^(?:build|test|run)$/.test(sub(argv)),
40
44
  turbo: argv => has(argv, /^(?:build|test)$/),
41
- nx: argv => /^(?:build|test|run-many|affected)$/.test(sub(argv)),
45
+ nx: argv => /^(?:build|test|e2e|run|run-many|affected)$/.test(sub(argv)),
42
46
  }
43
47
  const RUNNERS = /^(?:npm|pnpm|yarn|bun)$/
44
48
  // npx vitest, pnpm exec jest, yarn dlx, bunx, python -m pytest, node node_modules/.bin/jest
@@ -49,6 +53,7 @@ function realCommand(argv) {
49
53
  const at = argv.findIndex((a, k) => k > 0 && !a.startsWith('-'))
50
54
  argv = at > 0 ? argv.slice(at) : []
51
55
  } else if (RUNNERS.test(b) && /^(?:exec|dlx|x)$/.test(argv[1])) argv = argv.slice(2)
56
+ else if (b === 'bundle' && argv[1] === 'exec') argv = argv.slice(2)
52
57
  else if (/^python[\d.]*$|^py$/.test(b) && argv[1] === '-m' && argv[2]) argv = argv.slice(2)
53
58
  else if (b === 'node' && argv[1] && /node_modules[\\/]/.test(argv[1])) argv = argv.slice(1)
54
59
  else break
@@ -78,18 +83,41 @@ const LAUNCH = /\b(?:chromium|firefox|webkit|puppeteer)\.launch\b|\blaunchPersis
78
83
  // Commands that only read or search text: grep -rn vitest package.json
79
84
  const INERT = /^(?:git|grep|egrep|rg|ag|echo|printf|cat|less|head|tail|ls|find|which|where|type|select-string|get-content|gc|write-host|write-output)$/i
80
85
 
81
- function isHeavy(cmd, mode = 'sh') {
86
+ // Heavy commands that stay up until stopped: a slot would be held for as long as they run.
87
+ const LONG_RUNNING = {
88
+ next: argv => sub(argv) === 'dev',
89
+ emulator: () => true,
90
+ karma: () => true,
91
+ docker: argv => !has(argv, /^(?:-d|--detach)$/),
92
+ 'docker-compose': argv => !has(argv, /^(?:-d|--detach)$/),
93
+ }
94
+ const isLong = argv => {
95
+ const real = realCommand(argv)
96
+ const f = real.length && LONG_RUNNING[shell.bin(real[0])]
97
+ return Boolean(f && f(real) && (shell.bin(real[0]) !== 'docker' || sub(real) === 'compose'))
98
+ }
99
+
100
+ // The first heavy command in the line: { name, argv, long }, plus remote: true when any part of
101
+ // the line runs on another machine. null when nothing is heavy. name keys the GB estimate.
102
+ function findHeavy(cmd, mode = 'sh') {
82
103
  const text = shell.prepare(String(cmd || ''), mode)
104
+ let found = null
105
+ let remote = false
83
106
  for (const c of shell.commands(text, mode)) {
84
107
  const b = shell.bin(c.argv[0])
85
- if (INERT.test(b) || c.remote) continue
108
+ if (c.remote) remote = true
109
+ if (found || INERT.test(b) || c.remote) continue
86
110
  if (/^bulkhead(?:-slot)?$/.test(b)) continue
87
- if (heavyArgv(c.argv)) return true
88
- if (LAUNCH.test(c.argv.join(' '))) return true
111
+ if (heavyArgv(c.argv)) {
112
+ const real = realCommand(c.argv)
113
+ found = { name: shell.bin(real[0]), argv: real, long: isLong(c.argv) }
114
+ } else if (LAUNCH.test(c.argv.join(' '))) found = { name: 'browser', argv: ['browser'], long: false }
89
115
  }
90
- return false
116
+ return found && { ...found, remote }
91
117
  }
92
118
 
119
+ const isHeavy = (cmd, mode = 'sh') => Boolean(findHeavy(cmd, mode))
120
+
93
121
  const SLOT_FILE = /bulkhead[\\/]+slots|slot-\d+\.json/i
94
122
  const WRITES = /(?:>{1,2}\|?|\b(?:touch|rm|del|unlink|mv|cp|Remove-Item|Set-Content|Add-Content|Out-File|New-Item|Clear-Content|Move-Item|Copy-Item|writeFileSync|unlinkSync|rmSync|utimesSync)\b)/i
95
123
 
@@ -114,32 +142,106 @@ function hasRemote() {
114
142
  }
115
143
  }
116
144
 
117
- function check(cmd, memory = () => readMemory(), minFreeGb, mode = 'sh', remote = hasRemote) {
145
+ // Claude sessions that are running now, this one included; null when the session files cannot be read.
146
+ function liveSessions() {
147
+ try {
148
+ const { readSessionFiles } = require('./sessions')
149
+ const { staleReason } = require('./slots')
150
+ return [...readSessionFiles().keys()].filter(pid => !staleReason({ pid, at: Date.now() })).length
151
+ } catch {
152
+ return null
153
+ }
154
+ }
155
+
156
+ // Where the minimum comes from, in a few words.
157
+ function minimumSource(minFreeGb, totalGb) {
158
+ if (setting('memory.minFreeGb', null) != null) return 'set in your config'
159
+ if (minFreeGb >= 3) return 'the 3 GB default'
160
+ return `15% of this machine's ${Math.round(totalGb)} GB`
161
+ }
162
+
163
+ function slotLine(cmd, mode, overrides) {
164
+ const found = findHeavy(cmd, mode)
165
+ const gb = queue.estimateGb(found ? found.name : '', overrides)
166
+ const text = String(cmd).trim()
167
+ const arg = text.length <= 160 && !/[\r\n]/.test(text) && mode === 'sh' ? queue.quoters.sh(text) : '<command>'
168
+ return `bulkhead-slot run --gb=${gb} -- ${arg}`
169
+ }
170
+
171
+ function check(cmd, memory = () => readMemory(), minFreeGb, mode = 'sh', remote = hasRemote, sessions = liveSessions) {
118
172
  if (!cmd) return null
119
173
  if (touchesSlotFiles(cmd)) {
120
174
  return '[bulkhead] memory-guard: slot files are managed by bulkhead-slot. Do not write, touch or delete them by hand; ' +
121
175
  'use `bulkhead-slot run --gb=N -- <command>`, which releases its slot when the command exits. `bulkhead-slot status` shows the holders.'
122
176
  }
123
177
  if (queued(cmd) || !isHeavy(cmd, mode)) return null
124
- const { freeGb, totalGb } = memory()
178
+ const { freeGb, totalGb, pressure } = memory()
125
179
  if (minFreeGb == null) minFreeGb = limits(totalGb).minFreeGb
126
- if (freeGb >= minFreeGb) return null
127
- return `[bulkhead] memory-guard: only ${freeGb.toFixed(1)} GB of RAM is free (minimum ${minFreeGb} GB) and this looks like a heavy command.\n` +
128
- 'Other sessions are probably building or testing right now. Starting more work at this point is how machines freeze.\n' +
129
- 'Either wait a minute and retry with fewer workers (for example --maxWorkers=2), or queue it:\n' +
130
- ' bulkhead-slot run --gb=3 -- <command>\n' +
180
+ // macOS says outright how stressed memory is; a low estimate with normal pressure is not a reason to block
181
+ if (freeGb >= minFreeGb && !(pressure >= 2)) return null
182
+ const n = sessions()
183
+ const who = n > 1 ? `${n} Claude sessions are running and may be building or testing too.`
184
+ : n === 1 ? 'Only this session is running, so other programs are using the RAM.' : ''
185
+ const why = pressure >= 2 ? `memory pressure is ${pressure >= 4 ? 'critical' : 'warning'}`
186
+ : `only ${freeGb.toFixed(1)} GB of RAM is free (minimum ${minFreeGb} GB: ${minimumSource(minFreeGb, totalGb)})`
187
+ return `[bulkhead] memory-guard: ${why} and this looks like a heavy command.\n` +
188
+ (who ? who + '\n' : '') +
189
+ 'Starting more work at this point is how machines freeze. Wait a minute, use fewer workers (for example --maxWorkers=2), or queue it:\n' +
190
+ ` ${slotLine(cmd, mode, setting('memoryGuard.estimatesGb', null))}\n` +
131
191
  'bulkhead-slot waits until there is room, runs the command, and frees the slot when it exits.' +
132
192
  (remote() ? '\nOr run it on another machine, so it uses that machine\'s RAM:\n bulkhead remote run -- <command>' : '')
133
193
  }
134
194
 
135
- if (require.main === module) {
136
- runHook('memory-guard', input => {
137
- const cmd = input.tool_input && input.tool_input.command
138
- if (typeof cmd !== 'string') return
139
- const why = check(cmd, undefined, undefined, shell.modeFor(input))
140
- if (!why) return
141
- return blocked('memory-guard', touchesSlotFiles(cmd) ? 'slot-files' : 'low-memory', input, why)
142
- })
195
+ // { rule, message } with the rule name the hook logs, or null.
196
+ function decide(cmd, memory, minFreeGb, mode, remote) {
197
+ const message = check(cmd, memory, minFreeGb, mode, remote)
198
+ return message ? { rule: touchesSlotFiles(cmd) ? 'slot-files' : 'low-memory', message } : null
199
+ }
200
+
201
+ // With memoryGuard.autoQueue on, a heavy command is not blocked: the hook rewrites it to run through
202
+ // bulkhead-slot, whatever the free RAM is, so every session waits in the same line. Returns
203
+ // { command, gb, label } or null when the command stays as it is (not heavy, already queued, in
204
+ // the background, on another machine, long-running, or the slot script cannot be found).
205
+ function autoQueue(input, o = {}) {
206
+ const ti = (input && input.tool_input) || {}
207
+ const cmd = ti.command
208
+ if (typeof cmd !== 'string' || !cmd.trim() || ti.run_in_background === true) return null
209
+ const mode = shell.modeFor(input)
210
+ if (queue.wrapped(cmd) || touchesSlotFiles(cmd) || queue.backgrounded(cmd, mode)) return null
211
+ const found = findHeavy(cmd, mode)
212
+ if (!found || found.long || found.remote) return null
213
+ const script = o.script !== undefined ? o.script : queue.slotScript()
214
+ if (!script) return null
215
+ const gb = queue.estimateGb(found.name, o.estimates)
216
+ const label = queue.labelFor(found.argv)
217
+ const command = queue.wrap(cmd, { shell: mode, script, gb, label, wait: o.wait, node: o.node, env: o.env, exists: o.exists, platform: o.platform })
218
+ return command ? { command, gb, label } : null
143
219
  }
144
220
 
145
- module.exports = { check, isHeavy, touchesSlotFiles }
221
+ function queueOutput(input, q) {
222
+ return {
223
+ hookSpecificOutput: {
224
+ hookEventName: 'PreToolUse',
225
+ updatedInput: { ...input.tool_input, command: q.command },
226
+ additionalContext: `bulkhead queued this command (${q.label}, about ${q.gb} GB): it starts when the machine has room and prints its position every 30 s.`,
227
+ },
228
+ }
229
+ }
230
+
231
+ function handle(input) {
232
+ const cmd = input.tool_input && input.tool_input.command
233
+ if (typeof cmd !== 'string') return
234
+ // other agents' hooks cannot rewrite a command (bulkhead hook --agent), so they get the plain check
235
+ if (setting('memoryGuard.autoQueue', false) === true && !process.env.BULKHEAD_AGENT && !touchesSlotFiles(cmd)) {
236
+ const q = autoQueue(input, { estimates: setting('memoryGuard.estimatesGb', null), wait: setting('memoryGuard.waitMinutes', 10) })
237
+ // no permissionDecision: the user's permission settings still apply to the rewritten command
238
+ if (q) return { output: queueOutput(input, q) }
239
+ }
240
+ const hit = decide(cmd, undefined, undefined, shell.modeFor(input))
241
+ if (!hit) return
242
+ return blocked('memory-guard', hit.rule, input, hit.message)
243
+ }
244
+
245
+ if (require.main === module) runHook('memory-guard', handle)
246
+
247
+ module.exports = { handle, check, decide, isHeavy, findHeavy, touchesSlotFiles, autoQueue, queueOutput }
package/src/memory.js CHANGED
@@ -41,6 +41,17 @@ function parseVmStat(text, totalBytes) {
41
41
  return { totalGb: totalBytes / GB, freeGb: Math.max(0, freeBytes) / GB, commitPct: null }
42
42
  }
43
43
 
44
+ // macOS: kern.memorystatus_level is the percentage of memory the system counts as free,
45
+ // reclaimable file cache and compressed pages included (what `memory_pressure` prints as
46
+ // "System-wide memory free percentage"). The vm_stat estimate above misses the reclaimable
47
+ // cache and read 7.7 GB free on a 48 GB Mac that `memory_pressure` called 62% free.
48
+ // pressure is kern.memorystatus_vm_pressure_level: 1 normal, 2 warning, 4 critical.
49
+ function parseDarwinLevel(text, totalBytes) {
50
+ const [level, pressure] = String(text).trim().split(/\s+/).map(Number)
51
+ if (!(level >= 0 && level <= 100)) return null
52
+ return { totalGb: totalBytes / GB, freeGb: (totalBytes / GB) * (level / 100), commitPct: null, pressure: [1, 2, 4].includes(pressure) ? pressure : null }
53
+ }
54
+
44
55
  // Windows: commit charge can run out while RAM still looks "free", and the machine stalls when it does.
45
56
  function parseWindowsCommit(text) {
46
57
  const [total, free] = String(text).trim().split(/\s+/).map(Number)
@@ -57,7 +68,8 @@ function windowsCommitPct(run) {
57
68
  }
58
69
  }
59
70
 
60
- const defaultRun = (file, args) => execFileSync(file, args, { encoding: 'utf8', timeout: 8000, windowsHide: true })
71
+ // vm_stat and sysctl answer in milliseconds; only PowerShell is slow, so only it gets the long wait.
72
+ const defaultRun = (file, args) => execFileSync(file, args, { encoding: 'utf8', timeout: /powershell/i.test(file) ? 8000 : 1500, windowsHide: true })
61
73
 
62
74
  // { totalGb, freeGb, commitPct } where commitPct is measured only on Windows and only on request.
63
75
  function readMemory({ platform = process.platform, withCommit = false, run = defaultRun, readFile = fs.readFileSync } = {}) {
@@ -66,11 +78,18 @@ function readMemory({ platform = process.platform, withCommit = false, run = def
66
78
  try { return parseMeminfo(readFile('/proc/meminfo', 'utf8')) } catch {}
67
79
  }
68
80
  if (platform === 'darwin') {
81
+ try {
82
+ const m = parseDarwinLevel(run('sysctl', ['-n', 'kern.memorystatus_level', 'kern.memorystatus_vm_pressure_level']), os.totalmem())
83
+ if (m) return m
84
+ } catch {}
69
85
  try { return parseVmStat(run('vm_stat', []), os.totalmem()) } catch {}
86
+ // os.freemem() counts only never-used pages, near zero on a healthy Mac: with no usable
87
+ // reading, say everything is free so a guard allows the command instead of blocking it.
88
+ return { totalGb: os.totalmem() / GB, freeGb: os.totalmem() / GB, commitPct: null, pressure: null, unknown: true }
70
89
  }
71
90
  const mem = { totalGb: os.totalmem() / GB, freeGb: os.freemem() / GB, commitPct: null }
72
91
  if (platform === 'win32' && withCommit) mem.commitPct = windowsCommitPct(run)
73
92
  return mem
74
93
  }
75
94
 
76
- module.exports = { readMemory, parseMeminfo, parseVmStat, parseWindowsCommit }
95
+ module.exports = { readMemory, parseMeminfo, parseVmStat, parseDarwinLevel, parseWindowsCommit }
package/src/notices.js ADDED
@@ -0,0 +1,95 @@
1
+ 'use strict'
2
+ // What the user sees at SessionStart: what orphan-reaper stopped since the last notice, and one
3
+ // line the first time bulkhead runs. Both live as small files in the state dir. Every function
4
+ // fails open and returns nothing on any error.
5
+ const fs = require('fs')
6
+ const path = require('path')
7
+ const { shortPath } = require('./cli-util')
8
+
9
+ const PENDING = 'reaped-pending.jsonl'
10
+ const FIRST_RUN = 'first-run'
11
+ const MAX_SHOWN = 3
12
+ const MAX_PENDING_BYTES = 64 * 1024
13
+
14
+ const FIRST_RUN_TEXT = 'bulkhead is on: it blocks commands that would hit other Claude Code sessions. See what it did: bulkhead log. Check your setup: bulkhead doctor.'
15
+
16
+ // "vite", "node srv.js", "python3 app.py": the program and the script it runs, not the whole line.
17
+ function label(cmd, name) {
18
+ const words = String(cmd || name || '').trim().match(/"[^"]*"|'[^']*'|\S+/g) || []
19
+ const base = w => String(w).replace(/^["']|["']$/g, '').split(/[\\/]/).pop().replace(/\.exe$/i, '')
20
+ const prog = base(words[0]) || base(name) || 'process'
21
+ const arg = words.slice(1).find(w => !w.startsWith('-'))
22
+ if (!arg) return prog
23
+ const bin = /node_modules[\\/](?:\.bin|[^\\/]+[\\/]bin)[\\/]/.test(arg) ? base(arg).replace(/\.m?js$/, '') : null
24
+ if (bin) return bin
25
+ return `${prog} ${base(arg)}`
26
+ }
27
+
28
+ // done: the { orphan, pids } list run() stops; ports: [{ port, pid }] from the same process table.
29
+ function items(done, ports = [], projectOf = () => null) {
30
+ return done.map(({ orphan: o, pids }) => {
31
+ const hit = ports.find(l => pids.includes(l.pid))
32
+ return { label: label(o.cmd, o.name), port: hit ? hit.port : null, project: projectOf(o) }
33
+ })
34
+ }
35
+
36
+ function append(dir, list, now = Date.now()) {
37
+ if (!list.length) return false
38
+ try {
39
+ const file = path.join(dir, PENDING)
40
+ let size = 0
41
+ try { size = fs.statSync(file).size } catch {}
42
+ if (size > MAX_PENDING_BYTES) return false
43
+ fs.mkdirSync(dir, { recursive: true })
44
+ fs.appendFileSync(file, list.map(i => JSON.stringify({ time: now, ...i })).join('\n') + '\n')
45
+ return true
46
+ } catch {
47
+ return false
48
+ }
49
+ }
50
+
51
+ // Takes the pending list: renaming first means two sessions starting together never both print it.
52
+ function claim(dir) {
53
+ const file = path.join(dir, PENDING)
54
+ const mine = `${file}.${process.pid}`
55
+ try {
56
+ fs.renameSync(file, mine)
57
+ } catch {
58
+ return []
59
+ }
60
+ try {
61
+ return fs.readFileSync(mine, 'utf8').split('\n').flatMap(l => {
62
+ try { return [JSON.parse(l)] } catch { return [] }
63
+ })
64
+ } catch {
65
+ return []
66
+ } finally {
67
+ try { fs.unlinkSync(mine) } catch {}
68
+ }
69
+ }
70
+
71
+ function format(list, home) {
72
+ if (!list.length) return null
73
+ const shown = list.slice(0, MAX_SHOWN).map(i => {
74
+ const where = i.project ? ` (${shortPath(i.project, home)})` : ''
75
+ return `${i.label}${i.port ? ` :${i.port}` : ''}${where}`
76
+ })
77
+ const more = list.length > MAX_SHOWN ? `, and ${list.length - MAX_SHOWN} more` : ''
78
+ const n = list.length
79
+ return `bulkhead stopped ${n} leftover${n === 1 ? '' : 's'} from ended sessions: ${shown.join(', ')}${more}. Keep one next time with BULKHEAD_KEEP=1. Details: bulkhead log --guard orphan-reaper`
80
+ }
81
+
82
+ // The first-run line once, and only once: the marker is created before anything is printed. A state
83
+ // dir that already has a log belongs to an install from before this notice, and gets no line.
84
+ function firstRun(dir) {
85
+ try {
86
+ fs.mkdirSync(dir, { recursive: true })
87
+ const used = ['guards.log', 'events.jsonl'].some(n => fs.existsSync(path.join(dir, n)))
88
+ fs.writeFileSync(path.join(dir, FIRST_RUN), `${new Date().toISOString()}\n`, { flag: 'wx' })
89
+ return used ? null : FIRST_RUN_TEXT
90
+ } catch {
91
+ return null
92
+ }
93
+ }
94
+
95
+ module.exports = { label, items, append, claim, format, firstRun, FIRST_RUN_TEXT, PENDING, FIRST_RUN }
@@ -3,7 +3,8 @@ const fs = require('fs')
3
3
  const os = require('os')
4
4
  const path = require('path')
5
5
  const { execFileSync, spawn } = require('child_process')
6
- const { enabled, setting, log, oneLine, recordEvent } = require('./hook')
6
+ const { enabled, setting, log, oneLine, recordEvent, stateDir } = require('./hook')
7
+ const notices = require('./notices')
7
8
  const { readSessionFiles, claudeDir } = require('./sessions')
8
9
  const { readProcesses } = require('./processes')
9
10
 
@@ -64,6 +65,32 @@ const RULES = [
64
65
 
65
66
  // Never stopped, nor anything they started, even under an orphaned shell: programs meant to
66
67
  // outlive the session, and daemons that other sessions share.
68
+ const KEEP_TOKEN = /(?:^|[\s"'])BULKHEAD_KEEP=(?:1|true|yes)(?=["'\s]|$)/i
69
+
70
+ // ssh options that take a value (in the next word, or the rest of the same word) and the ones that
71
+ // open a forward or a tunnel only
72
+ const SSH_VALUE_OPTS = new Set('BbcDEeFIiJLlmOopQRSWw')
73
+ const SSH_FORWARD_OPTS = new Set('DLRN')
74
+
75
+ // true when an ssh command line opens a port forward or a tunnel only (-L, -R, -D, -N). The value
76
+ // of an option such as `-o StrictHostKeyChecking=no` is skipped, not read as flags.
77
+ function sshForwards(cmd) {
78
+ const words = String(cmd).trim().split(/\s+/).slice(1)
79
+ for (let i = 0; i < words.length; i++) {
80
+ const w = words[i]
81
+ if (!w.startsWith('-')) break
82
+ if (w.startsWith('--')) continue
83
+ for (let j = 1; j < w.length; j++) {
84
+ if (SSH_FORWARD_OPTS.has(w[j])) return true
85
+ if (SSH_VALUE_OPTS.has(w[j])) {
86
+ if (j === w.length - 1) i++
87
+ break
88
+ }
89
+ }
90
+ }
91
+ return false
92
+ }
93
+
67
94
  const KEEP = [
68
95
  { what: 'Claude Code itself', test: p => isClaude(p), reason: 'A session started from a tool shell is a live session of its own.' },
69
96
  { what: 'orphan-reaper itself', cmd: /orphan-reaper\.js/, reason: 'The reaper and its waiters.' },
@@ -79,6 +106,18 @@ const KEEP = [
79
106
  test: (p, name) => /^(?:vbcscompiler|bazel|buck2d|sccache)$/i.test(name) ||
80
107
  /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
108
  reason: 'Builds in every session connect to the same daemon; stopping it breaks theirs.' },
109
+ { what: 'Anything started with `BULKHEAD_KEEP=1`', test: p => p.keep === true || KEEP_TOKEN.test(p.cmd),
110
+ reason: 'You marked it as meant to outlive the session.' },
111
+ { what: 'ssh tunnels and port forwards (`ssh -L`, `-R`, `-D`, `-N`), autossh, cloudflared, ngrok, Tailscale, frp',
112
+ test: (p, name) => (/^ssh$/i.test(name) && sshForwards(p.cmd)) || /^(?:autossh|cloudflared|ngrok|tailscale|tailscaled|frpc|frps)$/i.test(name),
113
+ reason: 'A tunnel is opened on purpose and nothing else reconnects it.' },
114
+ { what: 'Databases and local services (Postgres, MySQL, MariaDB, MongoDB, Redis, Valkey, Memcached, Jupyter, code-server, Syncthing)',
115
+ test: (p, name) => /^(?:postgres|postmaster|mysqld|mysqld_safe|mariadbd|mongod|mongos|redis-server|redis-sentinel|valkey-server|memcached|code-server|syncthing)$/i.test(name) ||
116
+ /(?:^|[\s\\/])jupyter(?:-lab|-notebook|-server|lab)?(?=["'\s]|$)/i.test(p.cmd),
117
+ reason: 'Local services that other projects and sessions use.' },
118
+ { what: 'Container runtimes (Colima, Lima, OrbStack, Podman)',
119
+ name: /^(?:colima|limactl|lima|orbstack|orbstack helper|podman|vfkit|krunkit)$/i,
120
+ reason: 'One runtime serves every session on the machine.' },
82
121
  { what: 'Shared machine services (adb, the Android emulator, Docker, Ollama, watchman, ssh-agent, gpg-agent)',
83
122
  name: /^(?:adb|emulator|emulator64-\w+|qemu-system-[\w-]+|dockerd|docker|containerd|com\.docker\.[\w.-]+|ollama|watchman|ssh-agent|gpg-agent)$/i,
84
123
  reason: 'One instance serves every session on the machine.' },
@@ -111,13 +150,16 @@ function matchesSpec(spec, p, name) {
111
150
  return Boolean(spec.test)
112
151
  }
113
152
 
114
- function keeper(keepNames = []) {
153
+ // keepNames: program names; keepPatterns: text that, found in a command line, keeps the process.
154
+ function keeper(keepNames = [], keepPatterns = []) {
115
155
  const extra = new Set(keepNames.map(n => baseName(n).toLowerCase()))
156
+ const patterns = keepPatterns.map(x => String(x).toLowerCase()).filter(Boolean)
116
157
  const cache = new Map()
117
158
  return p => {
118
159
  if (!cache.has(p.pid)) {
119
160
  const name = baseName(p.name)
120
- cache.set(p.pid, extra.has(name.toLowerCase()) || KEEP.some(k => matchesSpec(k, p, name)))
161
+ const cmd = String(p.cmd || '').toLowerCase()
162
+ cache.set(p.pid, extra.has(name.toLowerCase()) || patterns.some(x => cmd.includes(x)) || KEEP.some(k => matchesSpec(k, p, name)))
121
163
  }
122
164
  return cache.get(p.pid)
123
165
  }
@@ -264,7 +306,7 @@ function findOrphans({ procs, listening = new Set() }, now = Date.now(), opts =
264
306
  now,
265
307
  session: opts.session || null,
266
308
  platform: opts.platform || process.platform,
267
- isKept: keeper(opts.keepNames),
309
+ isKept: keeper(opts.keepNames, opts.keepPatterns),
268
310
  snapshotExists: opts.snapshotExists || snapshotExists,
269
311
  liveClaudeStarts: procs.filter(isClaude).map(p => p.start),
270
312
  live: liveSnapshots(procs, byPid),
@@ -344,8 +386,8 @@ function killPids(pids, platform = process.platform) {
344
386
  // Stops every orphan's tree and returns { orphan, pids } for each. On Windows a second look stops
345
387
  // children that a dying process started after the first snapshot; elsewhere they are reparented
346
388
  // 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)
389
+ function reapAll(orphans, procs, { platform = process.platform, keepNames = [], keepPatterns = [], reread = null } = {}) {
390
+ const isKept = keeper(keepNames, keepPatterns)
349
391
  const byPid = new Map(procs.map(p => [p.pid, p]))
350
392
  const done = []
351
393
  const killed = new Map()
@@ -381,6 +423,7 @@ function settings() {
381
423
  return {
382
424
  extraNames: setting('orphanReaper.extraNames', []),
383
425
  keepNames: setting('orphanReaper.keepNames', []),
426
+ keepPatterns: setting('orphanReaper.keepPatterns', []),
384
427
  }
385
428
  }
386
429
 
@@ -394,19 +437,23 @@ function run({ platform = process.platform, dryRun = false, session = null, trig
394
437
  const snapshot = snap || snapshotNow(platform)
395
438
  const orphans = findOrphans(snapshot, Date.now(), { ...opts, session, platform })
396
439
  if (dryRun) {
397
- const isKept = keeper(opts.keepNames)
440
+ const isKept = keeper(opts.keepNames, opts.keepPatterns)
398
441
  return orphans.map(o => ({ orphan: o, pids: [...new Set([...descendants(o.top, snapshot.procs, isKept), o.pid])] }))
399
442
  }
400
- const done = reapAll(orphans, snapshot.procs, { platform, keepNames: opts.keepNames, reread: () => snapshotNow(platform) })
443
+ const done = reapAll(orphans, snapshot.procs, { platform, keepNames: opts.keepNames, keepPatterns: opts.keepPatterns, reread: () => snapshotNow(platform) })
401
444
  const files = done.length && !project ? readSessionFiles() : new Map()
402
- for (const { orphan: o, pids } of done) {
445
+ const projectFor = o => project || projectOf(o.owner, files)
446
+ const shown = notices.items(done, snapshot.ports, projectFor)
447
+ done.forEach(({ orphan: o, pids }, i) => {
403
448
  log('orphan-reaper', `stopped ${o.rule} pid ${o.pid} (tree ${o.top}, ${pids.length} processes; ${o.why}) ${oneLine(o.cmd || o.name, 160)}`)
404
449
  recordEvent('orphan-reaper', o.rule, { session_id: sessionId }, o.cmd || o.name, {
405
- project: project || projectOf(o.owner, files),
450
+ project: projectFor(o),
406
451
  pid: o.pid,
452
+ port: shown[i].port,
407
453
  trigger,
408
454
  })
409
- }
455
+ })
456
+ if (trigger !== 'manual') notices.append(stateDir(), shown)
410
457
  return done
411
458
  }
412
459