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/kill-guard.js CHANGED
@@ -1,6 +1,8 @@
1
1
  'use strict'
2
- const { runHook, blocked, setting, ASK_USER } = require('./hook')
2
+ const { runHook, blocked, setting, enabled, ASK_USER } = require('./hook')
3
3
  const shell = require('./shell')
4
+ const owners = require('./kill-owners')
5
+ const extras = require('./kill-extras')
4
6
 
5
7
  // Programs that other sessions, editors and the user are likely running too.
6
8
  const SHARED = [
@@ -13,6 +15,13 @@ const SHARED = [
13
15
  // apps and servers the user or another session has open
14
16
  'electron', 'simulator', 'xcode', 'postgres',
15
17
  'docker desktop', 'code helper', 'cursor', 'cursor helper',
18
+ // other coding agents, whose sessions are as shared as Claude's
19
+ 'codex', 'gemini', 'copilot', 'opencode', 'cline', 'droid', 'cursor-agent',
20
+ // dev servers and their children, by the names they show in ps and Activity Monitor
21
+ 'next-server', 'next-router-worker', 'next-render-worker', 'uvicorn', 'gunicorn', 'rails', 'puma', 'flask', 'expo', 'metro',
22
+ 'storybook', 'turbo', 'tsc', 'nodemon', 'qemu', 'emulator', 'playwright', 'puppeteer', 'ms-playwright', 'php',
23
+ 'headless_shell', 'headless-shell', 'chrome-headless', 'google chrome', 'google chrome helper', 'chrome helper', 'chromedriver',
24
+ 'http.server',
16
25
  ]
17
26
  const bare = n => String(n).trim().replace(/^["']+|["']+$/g, '').toLowerCase().replace(/\.exe$/, '')
18
27
 
@@ -43,24 +52,61 @@ function scopedPattern(pattern) {
43
52
  return /[\\/]|\.(?:py|js|mjs|cjs|ts|mts|rb|php|sh|jar|ps1)\b/i.test(first) || /[\\/]|\.(?:py|js|mjs|cjs|ts|rb|php|sh|jar|ps1)\b/i.test(pattern)
44
53
  }
45
54
 
46
- // A pkill, pgrep or grep pattern. Without -f it is a regular expression matched against the
47
- // process name; with -f (and for grep over ps output) against the whole command line, where the
48
- // first word decides which program it hits. [n]ode is node.
49
- function patternHits(pattern, names, { full = false, exact = false } = {}) {
50
- let p = String(pattern || '').trim()
51
- if (!p || p.length > 500) return false
52
- if (scopedPattern(p)) return false
53
- if (full) p = p.split(/\s+/)[0]
54
- if (!p) return false
55
- // a plain word names one program: pkill node does not reach ts-node
56
- if (!/[.*+?^${}()|[\]\\]/.test(p.replace(/\.exe$/i, ''))) return names.includes(bare(p))
55
+ // pkill -f "ssh -N -L 9333": a program name followed by arguments that name one job (a flag, a
56
+ // number, a path, a host). "next dev" and "node server" have none, so they still match every one.
57
+ function specificPattern(pattern) {
58
+ return pattern.trim().split(/\s+/).slice(1).some(t => /^-|[\d:=/\\@~]|\.\w/.test(t))
59
+ }
60
+
61
+ const REGEX_CHARS = /[.*+?^${}()|[\]\\]/
62
+ // pkill -f dev, -f server: words that stand for "whatever dev server is running"
63
+ const DEV_WORDS = new Set(['dev', 'server', 'serve', 'start', 'runserver', 'http.server'])
64
+ const DEV_TAIL = 'dev next server serve start run'
65
+
66
+ // node22, nodejs and python3.11 are the shared name with a version
67
+ function sharedWord(lw, names) {
68
+ if (names.includes(lw)) return true
69
+ const base = lw.replace(/^nodejs$/, 'node').replace(/^(node|python|ruby|java)[\d.]+$/, '$1')
70
+ return base !== lw && names.includes(base)
71
+ }
72
+
73
+ // One word of a pkill, killall or grep pattern against the shared names. Without -x the pattern
74
+ // is a regular expression searched in the name, so a part of a name ("ode") reaches it too.
75
+ function wordHits(w, names, { full, exact }) {
76
+ if (!w) return false
77
+ if (sharedWord(bare(w), names)) return true
78
+ if (!REGEX_CHARS.test(w.replace(/\.exe$/i, ''))) {
79
+ const lw = bare(w)
80
+ if (sharedWord(lw, names) || (full && DEV_WORDS.has(lw))) return true
81
+ // a name the user took off the list (allowNames) is not searched inside other names either
82
+ return !exact && lw.length >= 3 && !SHARED.includes(lw) && names.some(n => n.includes(lw))
83
+ }
57
84
  let re
58
85
  try {
59
- re = new RegExp(exact ? `^(?:${p})$` : p, 'i')
86
+ re = new RegExp(exact ? `^(?:${w})$` : w, 'i')
60
87
  } catch {
61
- return names.includes(bare(p))
88
+ return names.includes(bare(w))
62
89
  }
63
- return names.some(n => re.test(n) || (!exact && re.test(n + '.exe')))
90
+ if (names.some(n => re.test(n) || (!exact && re.test(n + '.exe')))) return true
91
+ // pkill -f "node.*next": a name, then whatever a dev server's command line says
92
+ return full && !exact && names.some(n => re.test(`${n} ${DEV_TAIL}`))
93
+ }
94
+
95
+ // A pkill, pgrep or grep pattern. Without -f it is a regular expression matched against the
96
+ // process name; with -f (and for grep over ps output) against the whole command line, where the
97
+ // first word of each alternative decides which program it hits. [n]ode is node.
98
+ function patternHits(pattern, names, { full = false, exact = false } = {}) {
99
+ const p = String(pattern || '').trim()
100
+ if (!p || p.length > 500) return false
101
+ if (!full) return !scopedPattern(p) && wordHits(p, names, { full, exact })
102
+ return p.split('|', 20).some(alt => {
103
+ alt = alt.trim()
104
+ if (!alt || scopedPattern(alt)) return false
105
+ // "Google Chrome Helper": a name of several words
106
+ if (/\s/.test(alt) && names.includes(bare(alt))) return true
107
+ if (specificPattern(alt)) return false
108
+ return wordHits(alt.split(/\s+/)[0], names, { full, exact }) || wordHits(alt, names, { full, exact })
109
+ })
64
110
  }
65
111
 
66
112
  const PKILL_VALUE = new Set(['-g', '-G', '-P', '-s', '-t', '-u', '-U', '-F', '--signal', '--pgroup', '--group', '--parent', '--session', '--terminal', '--euid', '--uid', '--pidfile', '--ns', '--nslist', '--env'])
@@ -101,26 +147,58 @@ function parseKillByName(argv, valued) {
101
147
 
102
148
  const zeroSignal = s => s != null && /^(?:0|SIG0)$/i.test(String(s))
103
149
 
104
- function checkPkill(argv, names) {
150
+ const NAME_WHY = 'pkill/killall matches every process with that name.'
151
+ const escapeRe = w => String(w).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
152
+
153
+ // A name that is no shared program but belongs to an app or a service the other sessions may be
154
+ // using: it counts only while another Claude session is alive.
155
+ function softName(pattern, full) {
156
+ const words = full ? String(pattern).split('|', 20).map(a => a.trim()) : [String(pattern)]
157
+ for (const w of words) {
158
+ for (const cand of full ? [w, w.split(/\s+/)[0]] : [w]) {
159
+ if (extras.isApp(cand)) return { rule: 'app-quit', why: `Quitting ${cand} closes it for everyone.` }
160
+ if (extras.isService(cand)) return { rule: 'service-stop', why: `Stopping ${cand} takes it away from every session.` }
161
+ }
162
+ }
163
+ return null
164
+ }
165
+
166
+ // pkill, killall: [rule, why] to block now, [rule, why, check] to block when check(table) says so.
167
+ function checkPkill(argv, names, { stdin = [], off = new Set() } = {}) {
105
168
  const b = shell.bin(argv[0])
106
169
  if (b === 'pkill') {
107
170
  const p = parseKillByName(argv, PKILL_VALUE)
108
171
  if (zeroSignal(p.signal) || p.opts.some(ov => PKILL_SCOPE.test(ov))) return null
109
- if (p.operands.length && patternHits(p.operands[0], names, { full: /f/.test(p.flags) || p.opts.some(([o]) => o === '--full'), exact: /x/.test(p.flags) })) {
110
- return ['pkill', 'pkill/killall matches every process with that name']
111
- }
172
+ const operands = p.operands.length ? p.operands : stdin.slice(0, 1)
173
+ if (!operands.length) return null
174
+ const full = /f/.test(p.flags) || p.opts.some(([o]) => o === '--full')
175
+ const exact = /x/.test(p.flags)
176
+ return byName(operands[0], names, { full, exact }, off)
112
177
  }
113
178
  if (b === 'killall') {
114
179
  const p = parseKillByName(argv, KILLALL_VALUE)
115
180
  if (zeroSignal(p.signal)) return null
116
181
  const regex = /r/.test(p.flags) || p.opts.some(([o]) => o === '--regexp')
117
- if (p.operands.slice(0, 100).some(o =>(regex ? patternHits(o, names, { exact: true }) : names.includes(bare(o))))) {
118
- return ['pkill', 'pkill/killall matches every process with that name']
182
+ const operands = (p.operands.length ? p.operands : stdin).slice(0, 100)
183
+ let later = null
184
+ for (const o of operands) {
185
+ const hit = regex ? patternHits(o, names, { exact: true }) : sharedWord(bare(o), names)
186
+ if (hit) return ['pkill', NAME_WHY]
187
+ later = later || byName(regex ? o : escapeRe(o), names, { full: false, exact: true }, off, !regex && o)
119
188
  }
189
+ return later
120
190
  }
121
191
  return null
122
192
  }
123
193
 
194
+ function byName(pattern, names, { full, exact }, off, plain) {
195
+ if (patternHits(pattern, names, { full, exact })) return ['pkill', NAME_WHY]
196
+ const soft = softName(plain || pattern, full)
197
+ if (soft && !off.has(soft.rule)) return [soft.rule, soft.why, t => owners.othersCheck(t)]
198
+ if (off.has('owner-check')) return null
199
+ return ['other-session-match', '', t => owners.patternCheck(t, pattern, { full, exact })]
200
+ }
201
+
124
202
  // A comma list after a flag: -Name node,python or -Name node, python. Real lists are short; the
125
203
  // first 256 names are enough, and a longer list is not copied name by name.
126
204
  const MAX_LIST = 256
@@ -149,12 +227,12 @@ function checkTaskkill(argv, names) {
149
227
  else if (o === 'pid') { byPid = true; k++ } else if (o === 'fi') filters.push(argv[++k] || '')
150
228
  }
151
229
  const hit = images.find(i => nameHits(i, names))
152
- if (hit) return ['taskkill-im', `taskkill /IM kills every process with that name: ${argv.join(' ')}`]
230
+ if (hit) return ['taskkill-im', `taskkill /IM kills every process with that name: ${argv.join(' ')}.`]
153
231
  for (const f of filters) {
154
232
  if (/\bpid\s+(?:eq|ne|gt|lt|ge|le)\b/i.test(f) || byPid) continue
155
233
  const img = /\bimagename\s+eq\s+(\S+)/i.exec(f)
156
234
  if (img && !nameHits(img[1], names)) continue
157
- return ['taskkill-filter', `taskkill /FI picks processes by filter, not by PID: ${argv.join(' ')}`]
235
+ return ['taskkill-filter', `taskkill /FI picks processes by filter, not by PID: ${argv.join(' ')}.`]
158
236
  }
159
237
  return null
160
238
  }
@@ -169,7 +247,7 @@ function checkStopProcess(argv, names) {
169
247
  const m = NAME_FLAG.exec(argv[k])
170
248
  if (!m) continue
171
249
  const list = m[1] ? m[1].split(',') : listAfter(argv, k + 1)
172
- if (list.some(n => nameHits(n, names))) return ['stop-process-name', 'Stop-Process -Name kills every process with that name']
250
+ if (list.some(n => nameHits(n, names))) return ['stop-process-name', 'Stop-Process -Name kills every process with that name.']
173
251
  }
174
252
  return null
175
253
  }
@@ -180,7 +258,7 @@ function checkWmic(argv, names) {
180
258
  const text = argv.slice(1).join(' ')
181
259
  if (!/\bprocess\b/i.test(text) || !/\b(?:delete|terminate)\b/i.test(text)) return null
182
260
  if (/\b(?:processid|commandline|executablepath|parentprocessid)\b/i.test(text)) return null
183
- if (wmiNameHits(text, names)) return ['wmic-name', 'wmic process where name=... deletes every process with that name']
261
+ if (wmiNameHits(text, names)) return ['wmic-name', 'wmic process where name=... deletes every process with that name.']
184
262
  return null
185
263
  }
186
264
 
@@ -197,14 +275,14 @@ function wmiNameHits(text, names) {
197
275
  // narrows the selection to known processes (Id, CommandLine, Path).
198
276
  function whereFilter(text, names) {
199
277
  const scoped = /\b(?:Id|ProcessId|CommandLine|Path|ExecutablePath|ParentProcessId)\b/i.test(text)
200
- let byName = false
278
+ let named = false
201
279
  for (const m of text.matchAll(/\b(?:Name|ProcessName|ImageName)\b['"]?\s*-[ic]?(eq|like|match|in|contains)\s+([^|}]{1,200})/gi)) {
202
280
  const op = m[1].toLowerCase()
203
281
  const values = m[2].split(/[\s,]+/).map(s => s.replace(/^[('"]+|[)'"]+$/g, '')).filter(Boolean)
204
- if (op === 'match') byName = byName || values.slice(0, 1).some(v => patternHits(v, names))
205
- else byName = byName || values.some(v => nameHits(v, names))
282
+ if (op === 'match') named = named || values.slice(0, 1).some(v => patternHits(v, names))
283
+ else named = named || values.some(v => nameHits(v, names))
206
284
  }
207
- return { scoped, byName }
285
+ return { scoped, byName: named }
208
286
  }
209
287
 
210
288
  // Commands that only print or search text: their words are never a kill or a selection.
@@ -215,21 +293,31 @@ const PS_SINKS = /^(?:stop-process|spps|invoke-cimmethod|remove-ciminstance|remo
215
293
  // Does any of these commands select processes by a shared name, and does another one kill what
216
294
  // they select? Commands of one statement are judged together: a pipe, $( ) and a loop body all
217
295
  // carry the PIDs from one to the other.
218
- function judgeStatement(cmds, names, mode, vars) {
296
+ function judgeStatement(cmds, names, mode, vars, cfg, deferred) {
219
297
  let selector = null
220
298
  let scoped = false
221
299
  let killer = false
222
300
  let usesVar = false
223
301
  let whereDepth = 0
224
302
  const listsProcesses = cmds.some(x => x.argv.length && /^(?:ps|tasklist|wmic|get-process|gps)$/i.test(shell.bin(x.argv[0])))
303
+ // echo node | xargs pkill, pkill <<< node: what the pipe or here-string supplies
304
+ const stdin = []
305
+ for (const c of cmds) {
306
+ const at = c.argv.indexOf('<<<')
307
+ if (at > 0) { stdin.push(...c.argv.slice(at + 1)); c.argv = c.argv.slice(0, at) }
308
+ else if (c.inert) stdin.push(...c.argv.slice(1).filter(a => !/^-[neE]+$/.test(a)))
309
+ }
225
310
  for (const c of cmds) {
226
311
  const argv = c.argv
227
- if (!argv.length) continue
312
+ if (!argv.length || c.inert) continue
228
313
  const b = shell.bin(argv[0])
229
314
  // direct kills by name
230
- const direct = checkPkill(argv, names) || checkTaskkill(argv, names) || checkStopProcess(argv, names) || checkWmic(argv, names)
231
- if (direct) return direct
315
+ const direct = checkPkill(argv, names, { stdin, off: cfg.off }) || checkTaskkill(argv, names) || checkStopProcess(argv, names) || checkWmic(argv, names)
316
+ if (direct && !direct[2]) return direct
317
+ if (direct) deferred.push(direct)
232
318
  if (c.remote) continue
319
+ const more = extras.detect(argv, { mode, off: cfg.off, stmt: cmds, env: cfg.env || new Map() })
320
+ if (more && !direct) deferred.push(more)
233
321
  // killers
234
322
  if (isKiller(argv, mode)) killer = true
235
323
  for (const w of argv) {
@@ -286,10 +374,10 @@ function judgeStatement(cmds, names, mode, vars) {
286
374
  }
287
375
 
288
376
  const SELECTOR_WHY = {
289
- 'pgrep-kill': 'pgrep <name> feeding kill matches every process with that name',
290
- 'ps-grep-kill': 'ps | grep <name> | kill matches every process with that name',
291
- 'get-process-pipe': 'Get-Process <name> | Stop-Process kills every process with that name',
292
- 'win32-process-name': 'Win32_Process selected by Name alone is terminated',
377
+ 'pgrep-kill': 'pgrep <name> feeding kill matches every process with that name.',
378
+ 'ps-grep-kill': 'ps | grep <name> | kill matches every process with that name.',
379
+ 'get-process-pipe': 'Get-Process <name> | Stop-Process kills every process with that name.',
380
+ 'win32-process-name': 'Win32_Process selected by Name alone is terminated.',
293
381
  }
294
382
 
295
383
  function isKiller(argv, mode) {
@@ -338,35 +426,88 @@ function assignedVar(cmds) {
338
426
  return null
339
427
  }
340
428
 
341
- // { rule, why } for the first kill by name, or null.
342
- function classify(cmd, names = {}, mode = 'sh') {
429
+ // Interpreter code that starts shell commands from string literals: python3 -c "...os.system('...')",
430
+ // a python or node heredoc, perl -e. The strings are read as shell commands.
431
+ const INTERPRETER_WORD = /\b(?:python[\d.]*|py|pythonw|node|nodejs|deno|bun|ruby|perl|php)\b/
432
+ const SHELLING_OUT = /system|exec|spawn|popen|subprocess|passthru|`|%x/
433
+ const CODE_FLAGS = /^(?:-c|-e|-E|-r|-p|--eval|--print)$/
434
+
435
+ function embedded(cmd, mode) {
436
+ if (!INTERPRETER_WORD.test(cmd) || !SHELLING_OUT.test(cmd)) return []
437
+ const codes = []
438
+ for (const c of shell.commands(shell.prepare(cmd, mode, ['shell', 'interp']), mode)) {
439
+ if (!/^(?:python[\d.]*|py|pythonw|node|nodejs|deno|bun|ruby|perl|php)$/.test(shell.bin(c.argv[0]))) continue
440
+ const i = c.argv.findIndex((a, k) => k > 0 && CODE_FLAGS.test(a))
441
+ if (i > 0 && c.argv[i + 1]) codes.push(c.argv[i + 1])
442
+ }
443
+ if (cmd.includes('<<')) for (const d of shell.heredocs(cmd, (p, a) => shell.consumerOf(p, a, mode).kind === 'interp')) codes.push(d.body)
444
+ return codes.flatMap(shell.embeddedShell)
445
+ }
446
+
447
+ // Rules that stop only while another Claude session is alive, and the table checks, are settled
448
+ // here: ctx.table() reads the process table once, and only now.
449
+ function resolve(hit, ctx) {
450
+ if (!hit || !hit.deferred) return hit
451
+ if (!ctx) return null
452
+ const t = ctx.table()
453
+ if (!t) return null
454
+ for (const d of hit.deferred) {
455
+ const detail = d.check(t, ctx)
456
+ if (detail) return { rule: d.rule, why: [d.why, detail].filter(Boolean).join(' ') }
457
+ }
458
+ return null
459
+ }
460
+
461
+ // { rule, why } for the first kill, or null. ctx (see kill-owners makeContext) lets the checks
462
+ // that need the process table run; without it only what the text alone shows is judged.
463
+ function classify(cmd, names = {}, mode = 'sh', ctx = null) {
343
464
  const list = nameList(names)
465
+ const cfg = { off: ctx ? ctx.off : new Set() }
344
466
  const text = shell.unwrap(shell.prepare(String(cmd || ''), mode), mode)
345
- return judge(text, list, mode)
467
+ let hit = judge(text, list, mode, cfg)
468
+ if (!hit || hit.deferred) {
469
+ for (const inner of embedded(String(cmd || ''), mode)) {
470
+ const more = judge(shell.unwrap(shell.prepare(inner, mode), mode), list, mode, cfg)
471
+ if (more && !more.deferred) { hit = more; break }
472
+ if (more) hit = { deferred: [...(hit ? hit.deferred : []), ...more.deferred] }
473
+ }
474
+ }
475
+ return resolve(hit, ctx)
346
476
  }
347
477
 
348
478
  // Every kill this guard knows names one of these words, so text without them needs no parsing.
349
- const KILL_WORD = /kill|stop-process|spps|terminate|remove-ciminstance|rcim|remove-wmiobject|rwmi|wmic/i
479
+ const KILL_WORD = /kill|stop-process|spps|terminate|remove-ciminstance|rcim|remove-wmiobject|rwmi|wmic|docker|podman|nerdctl|pm2|simctl|osascript|brew|launchctl|systemctl|service|adb\b/i
350
480
 
351
- function judge(text, names, mode) {
481
+ function judge(text, names, mode, cfg) {
352
482
  if (!KILL_WORD.test(text)) return null
353
483
  const { cmds, unterminated } = shell.lex(text, mode)
354
- const hit = judgeLexed(text, cmds, names, mode)
484
+ const hit = judgeLexed(text, cmds, names, mode, cfg)
355
485
  if (hit || !unterminated) return hit
356
486
  // a quote that never closes: read the text again without quotes, as the shell never would
357
- return judgeLexed(text, shell.lex(text.replace(/["'`]/g, ' '), mode).cmds, names, mode)
487
+ return judgeLexed(text, shell.lex(text.replace(/["'`]/g, ' '), mode).cmds, names, mode, cfg)
358
488
  }
359
489
 
360
- function judgeLexed(text, lexed, names, mode) {
490
+ function judgeLexed(text, lexed, names, mode, cfg) {
361
491
  const bySeg = new Map()
362
492
  const add = (seg, x) => {
363
493
  if (!bySeg.has(seg)) bySeg.set(seg, [])
364
494
  bySeg.get(seg).push(x)
365
495
  }
366
496
  let forF = false
497
+ const assigned = new Map()
498
+ cfg.env = assigned
367
499
  for (const c of lexed) {
368
500
  const s = shell.strip(text, c.words, mode)
501
+ for (const w of c.words) {
502
+ if (/^(?:export|declare|typeset|local)$/.test(w.v)) continue
503
+ const m = /^([A-Za-z_]\w*)=([^$`]*)$/.exec(w.v)
504
+ if (m) assigned.set(m[1], m[2])
505
+ else break
506
+ }
369
507
  const argv = s.argv.map(w => w.v)
508
+ // cmd=pkill; $cmd node
509
+ const ref = argv.length && /^\$\{?(\w+)\}?$/.exec(argv[0])
510
+ if (ref && assigned.has(ref[1]) && assigned.get(ref[1])) argv.splice(0, 1, ...assigned.get(ref[1]).split(/\s+/).filter(Boolean))
370
511
  // cmd's for /f ... in ('tasklist ^| findstr node') do ...: the quoted command runs too
371
512
  if (forF && argv.length) {
372
513
  forF = false
@@ -380,19 +521,24 @@ function judgeLexed(text, lexed, names, mode) {
380
521
  }
381
522
  }
382
523
  if (/^for$/i.test(argv[0] || '') && /^\/f$/i.test(argv[1] || '') && /^in$/i.test(argv[argv.length - 1])) forF = true
383
- if (argv.length && INERT.test(shell.bin(argv[0]))) continue
524
+ if (argv.length && INERT.test(shell.bin(argv[0]))) {
525
+ // echo node | xargs pkill: what an echo prints is what the next command reads
526
+ if (/^(?:echo|printf)$/.test(shell.bin(argv[0]))) add(c.seg, { argv, words: c.words, remote: false, sub: c.sub, inert: true })
527
+ continue
528
+ }
384
529
  add(c.seg, { argv, words: c.words, remote: s.remote, sub: c.sub })
385
530
  }
386
531
  const vars = new Set()
532
+ const deferred = []
387
533
  for (const cmds of bySeg.values()) {
388
- const r = judgeStatement(cmds, names, mode, vars)
534
+ const r = judgeStatement(cmds, names, mode, vars, cfg, deferred)
389
535
  if (Array.isArray(r)) return { rule: r[0], why: r[1] }
390
536
  if (r && r.assign) {
391
537
  const v = assignedVar(cmds)
392
538
  if (v) vars.add(v)
393
539
  }
394
540
  }
395
- return null
541
+ return deferred.length ? { deferred: deferred.map(([rule, why, fn]) => ({ rule, why, check: fn })) } : null
396
542
  }
397
543
 
398
544
  function check(cmd, names, mode) {
@@ -400,36 +546,41 @@ function check(cmd, names, mode) {
400
546
  return hit ? hit.why : null
401
547
  }
402
548
 
403
- function howToFindPid(platform) {
549
+ // What to do instead, by what was blocked.
550
+ const FIX = {
551
+ 'docker-mass': 'Stop only your own containers, by name: docker stop <your container> (docker ps --filter name=<your project>).',
552
+ 'device-kill': 'Target your own device: adb -s <your device> emu kill, xcrun simctl shutdown <your device UDID>.',
553
+ 'app-quit': 'Close only what you opened: kill <your own pid>.',
554
+ 'service-stop': 'Stop only a service you started yourself, by its PID: kill <your own pid>.',
555
+ }
556
+
557
+ function howToFix(platform, rule) {
558
+ if (FIX[rule]) return FIX[rule]
404
559
  if (platform === 'win32') {
405
- return [
406
- 'By port: netstat -ano | findstr :<port> (last column is the PID)',
407
- "By project: Get-CimInstance Win32_Process | ? { $_.CommandLine -like '*<your project dir>*' } | select ProcessId, CommandLine",
408
- 'Then: taskkill /PID <pid> /T /F',
409
- ].join('\n')
560
+ return 'Stop only what you started: taskkill /PID <your own pid> /T /F (netstat -ano | findstr :<port> finds a port owner; ' +
561
+ "Get-CimInstance Win32_Process | ? { $_.CommandLine -like '*<your project dir>*' } finds your project's processes)."
410
562
  }
411
- return [
412
- 'By port: lsof -ti :<port>',
413
- "By project: pgrep -af '<your project dir>'",
414
- 'Then: kill <pid> (or kill -- -<pgid> for a process group you started)',
415
- ].join('\n')
416
- }
417
-
418
- function message(why, platform = process.platform) {
419
- return `[bulkhead] kill-guard: ${why}\n` +
420
- 'Other Claude sessions, subagents and the user run these programs on this machine too. ' +
421
- 'Killing by name takes down their dev servers, test runs and builds, and can end your own session.\n' +
422
- 'Stop only the processes you started, by PID:\n' + howToFindPid(platform)
423
- }
424
-
425
- if (require.main === module) {
426
- runHook('kill-guard', input => {
427
- const cmd = input.tool_input && input.tool_input.command
428
- if (typeof cmd !== 'string' || !cmd) return
429
- const hit = classify(cmd, { allowNames: setting('killGuard.allowNames', []), extraSharedNames: setting('killGuard.extraSharedNames', []) }, shell.modeFor(input))
430
- if (!hit) return
431
- return blocked('kill-guard', hit.rule, input, `${message(hit.why)}\n${ASK_USER}`)
432
- })
563
+ return "Stop only what you started: kill <your own pid> (lsof -ti :<port> or pgrep -af '<your project dir>' finds it), or pkill -f '<your project dir>'."
564
+ }
565
+
566
+ function message(why, platform = process.platform, rule) {
567
+ const sentence = /[.!?]$/.test(why) ? why : why + '.'
568
+ return `[bulkhead] kill-guard: ${sentence}\n${howToFix(platform, rule)}`
433
569
  }
434
570
 
435
- module.exports = { check, classify, message, nameHits, patternHits }
571
+ // Rules with their own switch (config keys killGuard.<name>), on inside a guard that is on.
572
+ const RULES = ['mass-kill', 'docker-mass', 'device-kill', 'app-quit', 'service-stop', 'owner-check']
573
+
574
+ function handle(input) {
575
+ const cmd = input.tool_input && input.tool_input.command
576
+ if (typeof cmd !== 'string' || !cmd) return
577
+ const off = RULES.filter(rule => !enabled(rule))
578
+ const ctx = owners.makeContext({ cwd: input.cwd || process.cwd(), off })
579
+ const hit = classify(cmd, { allowNames: setting('killGuard.allowNames', []), extraSharedNames: setting('killGuard.extraSharedNames', []) }, shell.modeFor(input), ctx)
580
+ if (!hit) return
581
+ return blocked('kill-guard', hit.rule, input, `${message(hit.why, process.platform, hit.rule)}\n${ASK_USER}`)
582
+ }
583
+
584
+ if (require.main === module) runHook('kill-guard', handle)
585
+
586
+ module.exports = { handle, check, classify, message, nameHits, patternHits, nameList, RULES }
@@ -0,0 +1,116 @@
1
+ 'use strict'
2
+ // Who owns what on this machine, for the kill checks that need to know. The process table is read
3
+ // at most once per hook and only when a command asks for it: kill with literal PIDs, pkill -f with
4
+ // a specific pattern, and the mass kills that matter only while another session is alive.
5
+ const os = require('os')
6
+ const path = require('path')
7
+ const processes = require('./processes')
8
+ const sessions = require('./sessions')
9
+
10
+ function shortPath(p) {
11
+ const home = os.homedir()
12
+ const s = String(p || '')
13
+ return home && (s === home || s.startsWith(home + path.sep)) ? '~' + s.slice(home.length) : s
14
+ }
15
+
16
+ // A lazy view of the machine. Nothing is read until table() is called; a failed read gives null
17
+ // and every check then lets the command through.
18
+ function makeContext({ snapshot, files, self, cwd = process.cwd(), off = [] } = {}) {
19
+ let cached
20
+ return {
21
+ cwd,
22
+ off: new Set(off),
23
+ table() {
24
+ if (cached === undefined) {
25
+ try {
26
+ cached = loadTable({
27
+ snapshot: snapshot || (() => processes.readProcesses(process.platform, { withPorts: false })),
28
+ files: files || (() => sessions.readSessionFiles()),
29
+ self: self || (procs => sessions.ownSession(procs)),
30
+ })
31
+ } catch {
32
+ cached = null
33
+ }
34
+ }
35
+ return cached
36
+ },
37
+ }
38
+ }
39
+
40
+ function loadTable({ snapshot, files, self }) {
41
+ const snap = snapshot()
42
+ if (!snap || !Array.isArray(snap.procs)) return null
43
+ const list = sessions.findSessions(snap, files())
44
+ const roots = new Set(list.map(s => s.pid))
45
+ const me = self(snap.procs)
46
+ return {
47
+ procs: snap.procs,
48
+ byPid: new Map(snap.procs.map(p => [p.pid, p])),
49
+ owner: sessions.assignOwners(snap.procs, roots),
50
+ info: new Map(list.map(s => [s.pid, s])),
51
+ me,
52
+ // without knowing which session this is, no session can be called another one
53
+ others: me ? list.filter(s => s.pid !== me) : [],
54
+ }
55
+ }
56
+
57
+ function label(t, pid) {
58
+ const s = t.info.get(pid)
59
+ return s && s.cwd ? `${shortPath(s.cwd)} (pid ${pid})` : `pid ${pid}`
60
+ }
61
+
62
+ // The other session a process belongs to, or null.
63
+ function otherOwner(t, pid) {
64
+ const o = t.owner.get(pid)
65
+ return o && o !== t.me && t.info.has(o) ? o : null
66
+ }
67
+
68
+ // Another live session exists: a sentence naming one, or null.
69
+ function othersCheck(t) {
70
+ return t && t.others.length ? `Another Claude session is running here: ${label(t, t.others[0].pid)}.` : null
71
+ }
72
+
73
+ // A literal PID that is another session's process, or any Claude session's own process.
74
+ function pidsCheck(t, pids) {
75
+ if (!t || !t.me) return null
76
+ for (const raw of pids) {
77
+ const pid = Math.abs(raw)
78
+ const p = t.byPid.get(pid)
79
+ if (!p) continue
80
+ if (pid === t.me) return `PID ${pid} is your own Claude session.`
81
+ const o = otherOwner(t, pid)
82
+ if (o) return `PID ${pid} (${p.name}) belongs to the Claude session ${label(t, o)}.`
83
+ }
84
+ return null
85
+ }
86
+
87
+ // A pkill or killall pattern that would reach another session's processes.
88
+ function patternCheck(t, pattern, { full = false, exact = false } = {}) {
89
+ if (!t || !t.me || !pattern || String(pattern).length > 500) return null
90
+ let re
91
+ try {
92
+ re = new RegExp(exact ? `^(?:${pattern})$` : pattern, 'i')
93
+ } catch {
94
+ return null
95
+ }
96
+ for (const p of t.procs) {
97
+ const o = otherOwner(t, p.pid)
98
+ if (!o) continue
99
+ if (re.test(full ? p.cmd : p.name)) return `That pattern matches PID ${p.pid} (${p.name}) of the Claude session ${label(t, o)}.`
100
+ }
101
+ return null
102
+ }
103
+
104
+ // docker compose down in a folder another live session works in.
105
+ function sameProjectCheck(t, dir) {
106
+ if (!t || !t.others.length) return null
107
+ const here = path.resolve(dir)
108
+ const hit = t.others.find(s => {
109
+ if (!s.cwd) return false
110
+ const there = path.resolve(s.cwd)
111
+ return there === here || there.startsWith(here + path.sep) || here.startsWith(there + path.sep)
112
+ })
113
+ return hit ? `Claude session ${label(t, hit.pid)} works in the same project.` : null
114
+ }
115
+
116
+ module.exports = { makeContext, loadTable, label, otherOwner, othersCheck, pidsCheck, patternCheck, sameProjectCheck, shortPath }
package/src/log-cli.js CHANGED
@@ -1,9 +1,9 @@
1
1
  'use strict'
2
2
  const { stateDir } = require('./hook')
3
3
  const { readEvents, eventsFile } = require('./events')
4
- const { useColor, paint, shortPath, option, fit, table, rejectArgs } = require('./cli-util')
4
+ const { useColor, paint, shortPath, option, numberOption, fit, table, rejectArgs } = require('./cli-util')
5
5
 
6
- const USAGE = `usage: bulkhead log [-n N] [--since 7d|24h|30m] [--guard NAME] [--summary] [--json]
6
+ const USAGE = `usage: bulkhead log [SPAN] [-n N] [--since 7d|24h|30m] [--guard NAME] [--summary] [--json]
7
7
 
8
8
  Lists what the guards blocked and what orphan-reaper stopped, newest last.
9
9
  -n N how many events to list (default 20; ignored with --summary)
@@ -110,23 +110,28 @@ function renderList(events, { color = false, home, width = 120, now = Date.now()
110
110
 
111
111
  function main(args, { env = process.env, stdout = process.stdout, stderr = process.stderr, now = Date.now(), dir = stateDir() } = {}) {
112
112
  if (args.includes('--help') || args.includes('-h')) return stdout.write(USAGE + '\n')
113
+ // `bulkhead log 24h` reads as `--since 24h`, the way /bulkhead:log takes its argument
114
+ if (args.length && /^(\d+[mhdw]|\d{4}-\d{2}-\d{2})$/.test(args[0])) args = ['--since', args[0], ...args.slice(1)]
113
115
  if (rejectArgs('log', args, { flags: ['json', 'summary'], valued: ['since', 'guard', 'limit'], shorts: ['n'] }, stderr, USAGE)) return
114
116
  const sinceArg = option(args, 'since')
115
117
  let since = null
116
118
  if (sinceArg !== undefined && sinceArg !== '') {
117
- since = parseSince(sinceArg, now)
119
+ since = sinceArg === true ? NaN : parseSince(sinceArg, now)
118
120
  if (!Number.isFinite(since)) {
119
- stderr.write(`bulkhead log: cannot read --since ${sinceArg}; use 30m, 24h, 7d, 2w or a date like 2026-10-01\n`)
121
+ stderr.write(`bulkhead log: ${sinceArg === true ? '--since needs a value' : `cannot read --since ${sinceArg}`}; use 30m, 24h, 7d, 2w or a date like 2026-10-01\n`)
120
122
  process.exitCode = 1
121
123
  return
122
124
  }
123
125
  }
124
126
  const guard = option(args, 'guard')
125
- const nArg = (() => {
126
- const i = args.indexOf('-n')
127
- return i >= 0 ? args[i + 1] : option(args, 'limit')
128
- })()
129
- const n = Number(nArg) > 0 ? Math.floor(Number(nArg)) : 20
127
+ const i = args.indexOf('-n')
128
+ const count = numberOption(i >= 0 ? ['--n', args[i + 1]] : args, i >= 0 ? 'n' : 'limit', { fallback: 20 })
129
+ if (count.error) {
130
+ stderr.write(`bulkhead log: ${count.error.replace('--n ', '-n ')}\n`)
131
+ process.exitCode = 1
132
+ return
133
+ }
134
+ const n = Math.max(1, Math.floor(count.value))
130
135
  const events = filter(readEvents(dir), { since, guard: typeof guard === 'string' ? guard : null })
131
136
  const json = args.includes('--json')
132
137
  const color = useColor(stdout, env)
@@ -143,4 +148,4 @@ function main(args, { env = process.env, stdout = process.stdout, stderr = proce
143
148
  stdout.write(renderList(recent, { color, width: stdout.columns || 160, now }) + '\n')
144
149
  }
145
150
 
146
- module.exports = { main, parseSince, filter, summarize, renderSummary, renderList }
151
+ module.exports = { main, parseSince, filter, summarize, renderSummary, renderList, USAGE }