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,435 @@
1
+ 'use strict'
2
+ const { runHook, blocked, setting, ASK_USER } = require('./hook')
3
+ const shell = require('./shell')
4
+
5
+ // Programs that other sessions, editors and the user are likely running too.
6
+ const SHARED = [
7
+ 'node', 'python', 'python3', 'py', 'pythonw', 'ruby', 'java', 'javaw', 'dotnet',
8
+ 'chrome', 'chromium', 'chrome-headless-shell', 'msedge', 'firefox',
9
+ 'bash', 'sh', 'zsh', 'powershell', 'pwsh', 'cmd', 'conhost',
10
+ 'git', 'ssh', 'sshd', 'code', 'claude',
11
+ 'npm', 'npx', 'pnpm', 'yarn', 'deno', 'bun', 'tsx', 'ts-node',
12
+ 'gradle', 'adb', 'ffmpeg', 'docker', 'vitest', 'jest', 'next', 'vite', 'esbuild', 'webpack',
13
+ // apps and servers the user or another session has open
14
+ 'electron', 'simulator', 'xcode', 'postgres',
15
+ 'docker desktop', 'code helper', 'cursor', 'cursor helper',
16
+ ]
17
+ const bare = n => String(n).trim().replace(/^["']+|["']+$/g, '').toLowerCase().replace(/\.exe$/, '')
18
+
19
+ // killGuard.allowNames takes names off the list; killGuard.extraSharedNames adds some.
20
+ function nameList({ allowNames = [], extraSharedNames = [] } = {}) {
21
+ const allow = new Set(allowNames.map(bare))
22
+ return [...new Set([...SHARED.filter(n => !allow.has(n)), ...extraSharedNames.map(bare).filter(Boolean)])]
23
+ }
24
+
25
+ const globRe = g => new RegExp('^' + g.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$', 'i')
26
+
27
+ // A process name as Get-Process, Stop-Process -Name and taskkill /IM take it: exact, or a
28
+ // wildcard such as node* that matches a shared name.
29
+ function nameHits(value, names) {
30
+ const v = bare(value)
31
+ if (!v) return false
32
+ if (/[*?]/.test(v)) {
33
+ if (v.length > 200) return false
34
+ const re = globRe(v)
35
+ return names.some(n => re.test(n) || re.test(n + '.exe'))
36
+ }
37
+ return names.includes(v)
38
+ }
39
+
40
+ // pkill -f "python scripts/worker.py" names a script, so only that script matches.
41
+ function scopedPattern(pattern) {
42
+ const first = String(pattern).trim().split(/\s+/)[0] || ''
43
+ 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
+ }
45
+
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))
57
+ let re
58
+ try {
59
+ re = new RegExp(exact ? `^(?:${p})$` : p, 'i')
60
+ } catch {
61
+ return names.includes(bare(p))
62
+ }
63
+ return names.some(n => re.test(n) || (!exact && re.test(n + '.exe')))
64
+ }
65
+
66
+ 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'])
67
+ const KILLALL_VALUE = new Set(['-s', '-u', '-o', '-y', '-n', '--signal', '--user', '--older-than', '--younger-than', '--ns', '--context'])
68
+ // pkill -P <ppid>, -s <sid>, -t <tty>, -g <pgrp> and -F <pidfile> only reach a known group of processes
69
+ const PKILL_SCOPE_OPT = /^(?:-[PstgF]|--(?:parent|session|terminal|pgroup|pidfile))$/
70
+ // -s, -P and -g take numeric IDs; `pkill -s KILL node` mistakes -s for a signal and still kills by name
71
+ const NUMERIC_SCOPE = /^(?:-[Psg]|--(?:parent|session|pgroup))$/
72
+ const IDS = /^(?:\d+|\$[\w{}()$#!?-]+)(?:,(?:\d+|\$[\w{}()$#!?-]+))*$/
73
+ const PKILL_SCOPE = { test: ([o, v]) => PKILL_SCOPE_OPT.test(o) && (!NUMERIC_SCOPE.test(o) || IDS.test(String(v == null ? '' : v))) }
74
+
75
+ // Options and operands of pkill, pgrep or killall.
76
+ function parseKillByName(argv, valued) {
77
+ const opts = []
78
+ const operands = []
79
+ let signal = null
80
+ for (let k = 1; k < argv.length; k++) {
81
+ const v = argv[k]
82
+ if (operands.length || !/^-./.test(v)) {
83
+ if (v === '--' && !operands.length) continue
84
+ operands.push(v)
85
+ continue
86
+ }
87
+ const eq = /^(--[\w-]+)=(.*)$/.exec(v)
88
+ if (eq) { opts.push([eq[1], eq[2]]); if (eq[1] === '--signal') signal = eq[2]; continue }
89
+ if (valued.has(v)) {
90
+ opts.push([v, argv[k + 1]])
91
+ if (v === '--signal' || (v === '-s' && valued === KILLALL_VALUE)) signal = argv[k + 1]
92
+ k++
93
+ continue
94
+ }
95
+ if (/^-(?:\d+|[A-Z][A-Z0-9+-]*|SIG\w+)$/.test(v)) { signal = v.slice(1); continue }
96
+ opts.push([v, null])
97
+ }
98
+ const flags = opts.filter(([o]) => /^-[a-zA-Z]+$/.test(o)).map(([o]) => o.slice(1)).join('')
99
+ return { opts, operands, signal, flags }
100
+ }
101
+
102
+ const zeroSignal = s => s != null && /^(?:0|SIG0)$/i.test(String(s))
103
+
104
+ function checkPkill(argv, names) {
105
+ const b = shell.bin(argv[0])
106
+ if (b === 'pkill') {
107
+ const p = parseKillByName(argv, PKILL_VALUE)
108
+ 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
+ }
112
+ }
113
+ if (b === 'killall') {
114
+ const p = parseKillByName(argv, KILLALL_VALUE)
115
+ if (zeroSignal(p.signal)) return null
116
+ 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']
119
+ }
120
+ }
121
+ return null
122
+ }
123
+
124
+ // A comma list after a flag: -Name node,python or -Name node, python. Real lists are short; the
125
+ // first 256 names are enough, and a longer list is not copied name by name.
126
+ const MAX_LIST = 256
127
+
128
+ function listAfter(argv, k) {
129
+ const out = []
130
+ for (let j = k; j < argv.length && out.length < MAX_LIST; j++) {
131
+ for (const s of argv[j].split(',', MAX_LIST)) {
132
+ const t = s.trim()
133
+ if (t && out.length < MAX_LIST) out.push(t)
134
+ }
135
+ if (!argv[j].endsWith(',') && !(argv[j + 1] || '').startsWith(',')) break
136
+ }
137
+ return out
138
+ }
139
+
140
+ function checkTaskkill(argv, names) {
141
+ if (shell.bin(argv[0]) !== 'taskkill') return null
142
+ let byPid = false
143
+ const images = []
144
+ const filters = []
145
+ for (let k = 1; k < argv.length; k++) {
146
+ const o = argv[k].replace(/^(?:\/\/?|-)/, '').toLowerCase()
147
+ if (argv[k].toLowerCase() === o) continue
148
+ if (o === 'im') images.push(argv[++k] || '')
149
+ else if (o === 'pid') { byPid = true; k++ } else if (o === 'fi') filters.push(argv[++k] || '')
150
+ }
151
+ const hit = images.find(i => nameHits(i, names))
152
+ if (hit) return ['taskkill-im', `taskkill /IM kills every process with that name: ${argv.join(' ')}`]
153
+ for (const f of filters) {
154
+ if (/\bpid\s+(?:eq|ne|gt|lt|ge|le)\b/i.test(f) || byPid) continue
155
+ const img = /\bimagename\s+eq\s+(\S+)/i.exec(f)
156
+ if (img && !nameHits(img[1], names)) continue
157
+ return ['taskkill-filter', `taskkill /FI picks processes by filter, not by PID: ${argv.join(' ')}`]
158
+ }
159
+ return null
160
+ }
161
+
162
+ const STOP = /^(?:stop-process|spps|kill)$/i
163
+ const NAME_FLAG = /^-(?:n|na|nam|name|processname)(?::(.*))?$/i
164
+
165
+ function checkStopProcess(argv, names) {
166
+ if (!STOP.test(argv[0])) return null
167
+ if (argv.some(a => /^-whatif$/i.test(a))) return null
168
+ for (let k = 1; k < argv.length; k++) {
169
+ const m = NAME_FLAG.exec(argv[k])
170
+ if (!m) continue
171
+ 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']
173
+ }
174
+ return null
175
+ }
176
+
177
+ // wmic process where name='node.exe' delete, wmic process where "name like 'node%'" call terminate
178
+ function checkWmic(argv, names) {
179
+ if (shell.bin(argv[0]) !== 'wmic') return null
180
+ const text = argv.slice(1).join(' ')
181
+ if (!/\bprocess\b/i.test(text) || !/\b(?:delete|terminate)\b/i.test(text)) return null
182
+ 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']
184
+ return null
185
+ }
186
+
187
+ // Name='node.exe', name like 'node%' in a WQL filter
188
+ function wmiNameHits(text, names) {
189
+ for (const m of text.matchAll(/\bname\s*(=|like)\s*['"\\]*([^'"\\\s)]+)/gi)) {
190
+ const v = m[1].toLowerCase() === 'like' ? m[2].replace(/%/g, '*').replace(/_/g, '?') : m[2]
191
+ if (nameHits(v, names)) return true
192
+ }
193
+ return false
194
+ }
195
+
196
+ // Where-Object { $_.Name -eq 'node' }, ? ProcessName -like node*, ... and whether the filter
197
+ // narrows the selection to known processes (Id, CommandLine, Path).
198
+ function whereFilter(text, names) {
199
+ const scoped = /\b(?:Id|ProcessId|CommandLine|Path|ExecutablePath|ParentProcessId)\b/i.test(text)
200
+ let byName = false
201
+ for (const m of text.matchAll(/\b(?:Name|ProcessName|ImageName)\b['"]?\s*-[ic]?(eq|like|match|in|contains)\s+([^|}]{1,200})/gi)) {
202
+ const op = m[1].toLowerCase()
203
+ 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))
206
+ }
207
+ return { scoped, byName }
208
+ }
209
+
210
+ // Commands that only print or search text: their words are never a kill or a selection.
211
+ const INERT = /^(?:git|fgrep|rg|ag|echo|printf|cat|less|more|head|tail|select-string|sls|write-host|write-output|write-error|write-warning|write-verbose|out-host|man|help|get-help|which|type|get-command|gcm)$/i
212
+ const PS_SOURCE = /^(?:get-process|gps)$/i
213
+ const PS_SINKS = /^(?:stop-process|spps|invoke-cimmethod|remove-ciminstance|remove-wmiobject)$/i
214
+
215
+ // Does any of these commands select processes by a shared name, and does another one kill what
216
+ // they select? Commands of one statement are judged together: a pipe, $( ) and a loop body all
217
+ // carry the PIDs from one to the other.
218
+ function judgeStatement(cmds, names, mode, vars) {
219
+ let selector = null
220
+ let scoped = false
221
+ let killer = false
222
+ let usesVar = false
223
+ let whereDepth = 0
224
+ const listsProcesses = cmds.some(x => x.argv.length && /^(?:ps|tasklist|wmic|get-process|gps)$/i.test(shell.bin(x.argv[0])))
225
+ for (const c of cmds) {
226
+ const argv = c.argv
227
+ if (!argv.length) continue
228
+ const b = shell.bin(argv[0])
229
+ // direct kills by name
230
+ const direct = checkPkill(argv, names) || checkTaskkill(argv, names) || checkStopProcess(argv, names) || checkWmic(argv, names)
231
+ if (direct) return direct
232
+ if (c.remote) continue
233
+ // killers
234
+ if (isKiller(argv, mode)) killer = true
235
+ for (const w of argv) {
236
+ if (/\.(?:kill|terminate)\s*\(/i.test(w)) killer = true
237
+ if (vars.size && /\$\{?(\w+)/.test(w) && [...w.matchAll(/\$\{?(\w+)/g)].some(m => vars.has(m[1].toLowerCase()))) usesVar = true
238
+ }
239
+ for (let k = 0; k < argv.length - 1; k++) {
240
+ if (/^(?:foreach-object|%)$/i.test(argv[k]) && /^(?:kill|terminate)$/i.test(argv[k + 1])) killer = true
241
+ if (k > 0 && (argv[k - 1] === '{' || argv[k - 1].endsWith('{')) && isKiller(argv.slice(k, k + 8), mode)) killer = true
242
+ }
243
+ // selectors by name
244
+ if (b === 'pgrep') {
245
+ const p = parseKillByName(argv, PKILL_VALUE)
246
+ if (!p.opts.some(ov => PKILL_SCOPE.test(ov)) && p.operands.length &&
247
+ patternHits(p.operands[0], names, { full: /f/.test(p.flags), exact: /x/.test(p.flags) })) selector = selector || 'pgrep-kill'
248
+ } else if (b === 'pidof') {
249
+ if (argv.slice(1).some(a => !a.startsWith('-') && names.includes(bare(a)))) selector = selector || 'pgrep-kill'
250
+ } else if ((b === 'grep' || b === 'egrep' || b === 'findstr') && listsProcesses) {
251
+ if (grepHits(argv, names)) selector = selector || 'ps-grep-kill'
252
+ } else if (b === 'ps' && mode !== 'ps') {
253
+ const i = argv.findIndex(a => a === '-C' || a === '--command')
254
+ if (i > 0 && (argv[i + 1] || '').split(',').some(n => names.includes(bare(n)))) selector = selector || 'ps-grep-kill'
255
+ }
256
+ const srcAt = argv.findIndex(a => PS_SOURCE.test(a) || (mode === 'ps' && /^ps$/i.test(a)))
257
+ if (srcAt >= 0) {
258
+ const rest = argv.slice(srcAt + 1)
259
+ if (rest.some(a => /^-(?:id|inputobject)$/i.test(a))) scoped = true
260
+ const picked = rest[0] && !rest[0].startsWith('-') ? listAfter(rest, 0) : []
261
+ rest.forEach((a, k) => {
262
+ const m = NAME_FLAG.exec(a)
263
+ if (m) for (const n of m[1] ? m[1].split(',', MAX_LIST) : listAfter(rest, k + 1)) picked.push(n)
264
+ })
265
+ if (picked.some(n => nameHits(n, names))) selector = selector || 'get-process-pipe'
266
+ }
267
+ // a Where-Object block that a ( ) inside it split into several commands continues here
268
+ const whereStart = /^(?:where-object|where|\?)$/i.test(argv[0]) || /^\?\{/.test(argv[0])
269
+ if (whereStart || (whereDepth > 0 && !c.sub)) {
270
+ const joined = argv.join(' ')
271
+ if (whereStart) whereDepth = 0
272
+ whereDepth += (joined.match(/\{/g) || []).length - (joined.match(/\}/g) || []).length
273
+ const w = whereFilter(joined, names)
274
+ if (w.scoped) scoped = true
275
+ if (w.byName) selector = selector || 'get-process-pipe'
276
+ }
277
+ if (argv.some(a => /win32_process/i.test(a))) {
278
+ const text = argv.join(' ')
279
+ if (/\b(?:ProcessId|CommandLine|ExecutablePath|ParentProcessId|Handle)\s*(?:=|-eq|-like)/i.test(text)) scoped = true
280
+ if (wmiNameHits(text, names)) selector = selector || 'win32-process-name'
281
+ }
282
+ }
283
+ if (selector && !scoped && killer) return [selector, SELECTOR_WHY[selector]]
284
+ if (usesVar && killer) return ['pgrep-kill', SELECTOR_WHY['pgrep-kill']]
285
+ return selector && !scoped ? { assign: true } : null
286
+ }
287
+
288
+ 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',
293
+ }
294
+
295
+ function isKiller(argv, mode) {
296
+ const b = shell.bin(argv[0])
297
+ if (/^invoke-cimmethod$/i.test(argv[0])) return argv.some(a => /^terminate$/i.test(a))
298
+ if (PS_SINKS.test(argv[0]) || /^(?:taskkill|tskill)$/.test(b)) return !argv.some(a => /^-whatif$/i.test(a))
299
+ if (b === 'kill') {
300
+ // kill -0 and kill -l only test or list
301
+ return !argv.slice(1).some((a, i, r) => a === '-0' || a === '-l' || a === '-L' || (a === '-s' && r[i + 1] === '0'))
302
+ }
303
+ return mode === 'ps' && /^kill$/i.test(argv[0])
304
+ }
305
+
306
+ const GREP_VALUE = new Set(['-e', '-f', '-m', '-A', '-B', '-C', '-d', '-D', '--regexp', '--file', '--max-count', '--after-context', '--before-context', '--context', '--include', '--exclude', '--label', '--color', '--colour'])
307
+
308
+ function grepHits(argv, names) {
309
+ if (shell.bin(argv[0]) === 'findstr') {
310
+ if (argv.some(a => /^\/v$/i.test(a))) return false
311
+ return argv.slice(1).filter(a => !/^\//.test(a)).some(w => w.split(/\s+/).some(x => patternHits(x, names, { full: true })))
312
+ }
313
+ let invert = false
314
+ const patterns = []
315
+ const operands = []
316
+ for (let k = 1; k < argv.length; k++) {
317
+ const v = argv[k]
318
+ if (operands.length || !/^-./.test(v)) { operands.push(v); continue }
319
+ if (v === '-e' || v === '--regexp') { patterns.push(argv[++k] || ''); continue }
320
+ if (GREP_VALUE.has(v)) { k++; continue }
321
+ if (/^-[a-zA-Z]*v[a-zA-Z]*$/.test(v) || v === '--invert-match') invert = true
322
+ }
323
+ if (invert) return false
324
+ if (!patterns.length && operands.length) patterns.push(operands[0])
325
+ return patterns.some(p => patternHits(p, names, { full: true }))
326
+ }
327
+
328
+ // `pids=$(pgrep node)` or `$procs = Get-Process node`: the variable now holds PIDs by name.
329
+ function assignedVar(cmds) {
330
+ const first = cmds.find(c => !c.sub)
331
+ if (!first) return null
332
+ const w = first.words[0]
333
+ if (!w) return null
334
+ const sh = /^([A-Za-z_]\w*)=/.exec(w.v)
335
+ if (sh) return sh[1].toLowerCase()
336
+ const ps = /^\$([A-Za-z_]\w*)$/.exec(first.argv[0])
337
+ if (ps && first.argv[1] === '=') return ps[1].toLowerCase()
338
+ return null
339
+ }
340
+
341
+ // { rule, why } for the first kill by name, or null.
342
+ function classify(cmd, names = {}, mode = 'sh') {
343
+ const list = nameList(names)
344
+ const text = shell.unwrap(shell.prepare(String(cmd || ''), mode), mode)
345
+ return judge(text, list, mode)
346
+ }
347
+
348
+ // 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
350
+
351
+ function judge(text, names, mode) {
352
+ if (!KILL_WORD.test(text)) return null
353
+ const { cmds, unterminated } = shell.lex(text, mode)
354
+ const hit = judgeLexed(text, cmds, names, mode)
355
+ if (hit || !unterminated) return hit
356
+ // 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)
358
+ }
359
+
360
+ function judgeLexed(text, lexed, names, mode) {
361
+ const bySeg = new Map()
362
+ const add = (seg, x) => {
363
+ if (!bySeg.has(seg)) bySeg.set(seg, [])
364
+ bySeg.get(seg).push(x)
365
+ }
366
+ let forF = false
367
+ for (const c of lexed) {
368
+ const s = shell.strip(text, c.words, mode)
369
+ const argv = s.argv.map(w => w.v)
370
+ // cmd's for /f ... in ('tasklist ^| findstr node') do ...: the quoted command runs too
371
+ if (forF && argv.length) {
372
+ forF = false
373
+ const inner = argv.join(' ').replace(/^'|'$/g, '').replace(/\^([|&<>^])/g, '$1')
374
+ if (inner !== argv.join(' ') || argv.length === 1) {
375
+ for (const ic of shell.lex(inner, 'cmd').cmds) {
376
+ const iv = shell.strip(inner, ic.words, 'cmd').argv.map(w => w.v)
377
+ if (iv.length && !INERT.test(shell.bin(iv[0]))) add(c.seg, { argv: iv, words: ic.words, remote: false, sub: true })
378
+ }
379
+ continue
380
+ }
381
+ }
382
+ 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
384
+ add(c.seg, { argv, words: c.words, remote: s.remote, sub: c.sub })
385
+ }
386
+ const vars = new Set()
387
+ for (const cmds of bySeg.values()) {
388
+ const r = judgeStatement(cmds, names, mode, vars)
389
+ if (Array.isArray(r)) return { rule: r[0], why: r[1] }
390
+ if (r && r.assign) {
391
+ const v = assignedVar(cmds)
392
+ if (v) vars.add(v)
393
+ }
394
+ }
395
+ return null
396
+ }
397
+
398
+ function check(cmd, names, mode) {
399
+ const hit = classify(cmd, names, mode)
400
+ return hit ? hit.why : null
401
+ }
402
+
403
+ function howToFindPid(platform) {
404
+ 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')
410
+ }
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
+ })
433
+ }
434
+
435
+ module.exports = { check, classify, message, nameHits, patternHits }
package/src/log-cli.js ADDED
@@ -0,0 +1,146 @@
1
+ 'use strict'
2
+ const { stateDir } = require('./hook')
3
+ const { readEvents, eventsFile } = require('./events')
4
+ const { useColor, paint, shortPath, option, fit, table, rejectArgs } = require('./cli-util')
5
+
6
+ const USAGE = `usage: bulkhead log [-n N] [--since 7d|24h|30m] [--guard NAME] [--summary] [--json]
7
+
8
+ Lists what the guards blocked and what orphan-reaper stopped, newest last.
9
+ -n N how many events to list (default 20; ignored with --summary)
10
+ --since SPAN only events newer than SPAN: 30m, 24h, 7d, 2w, or a date such as 2026-10-01
11
+ --guard NAME only this guard, for example kill-guard
12
+ --summary counts per guard and rule, and per project
13
+ --json machine-readable output`
14
+
15
+ const UNITS = { m: 60e3, h: 3600e3, d: 86400e3, w: 7 * 86400e3 }
16
+
17
+ // Milliseconds since the epoch, or NaN when the span cannot be read.
18
+ function parseSince(s, now = Date.now()) {
19
+ const m = /^(\d+(?:\.\d+)?)\s*([mhdw])$/i.exec(String(s).trim())
20
+ if (m) return now - Number(m[1]) * UNITS[m[2].toLowerCase()]
21
+ return /^\d{4}-\d{2}-\d{2}/.test(String(s)) ? new Date(s).getTime() : NaN
22
+ }
23
+
24
+ // '7d' -> '7 days'; null for dates and anything else.
25
+ function humanSpan(s) {
26
+ const m = /^(\d+(?:\.\d+)?)\s*([mhdw])$/i.exec(String(s || '').trim())
27
+ if (!m) return null
28
+ const unit = { m: 'minute', h: 'hour', d: 'day', w: 'week' }[m[2].toLowerCase()]
29
+ return `${m[1]} ${unit}${Number(m[1]) === 1 ? '' : 's'}`
30
+ }
31
+
32
+ function filter(events, { since, guard } = {}) {
33
+ return events.filter(e => (since == null || Date.parse(e.time) >= since) && (!guard || e.guard === guard))
34
+ }
35
+
36
+ const pad2 = n => String(n).padStart(2, '0')
37
+ const MONTHS = 'Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec'.split(' ')
38
+ function stamp(iso, now = Date.now()) {
39
+ const d = new Date(iso)
40
+ const hm = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`
41
+ return d.toDateString() === new Date(now).toDateString() ? hm : `${MONTHS[d.getMonth()]} ${pad2(d.getDate())} ${hm}`
42
+ }
43
+
44
+ function summarize(events) {
45
+ const byRule = new Map()
46
+ const byProject = new Map()
47
+ const sessions = new Set()
48
+ for (const e of events) {
49
+ const k = `${e.guard}\u0000${e.rule || ''}`
50
+ const r = byRule.get(k) || { guard: e.guard, rule: e.rule || '', count: 0, last: e.time }
51
+ r.count++
52
+ if (e.time > r.last) r.last = e.time
53
+ byRule.set(k, r)
54
+ if (e.project) byProject.set(e.project, (byProject.get(e.project) || 0) + 1)
55
+ if (e.session) sessions.add(e.session)
56
+ }
57
+ const rules = [...byRule.values()].sort((a, b) => b.count - a.count || a.guard.localeCompare(b.guard) || a.rule.localeCompare(b.rule))
58
+ const projects = [...byProject.entries()].map(([project, count]) => ({ project, count })).sort((a, b) => b.count - a.count)
59
+ return {
60
+ total: events.length,
61
+ first: events.length ? events[0].time : null,
62
+ last: events.length ? events[events.length - 1].time : null,
63
+ sessions: sessions.size,
64
+ rules,
65
+ projects,
66
+ }
67
+ }
68
+
69
+ function bar(n, max, width = 24) {
70
+ if (!max) return ''
71
+ return '█'.repeat(Math.max(1, Math.round((n / max) * width)))
72
+ }
73
+
74
+ function renderSummary(sum, { color = false, home, sinceLabel, now = Date.now() } = {}) {
75
+ if (!sum.total) return `no blocks recorded${sinceLabel ? ` in the last ${sinceLabel}` : ''}`
76
+ const out = []
77
+ const span = sinceLabel ? `in the last ${sinceLabel}` : `since ${stamp(sum.first, now)}`
78
+ out.push(paint(color, 'bold', `${sum.total} block${sum.total === 1 ? '' : 's'} ${span}`) +
79
+ `, ${sum.projects.length} project${sum.projects.length === 1 ? '' : 's'}, ${sum.sessions} session${sum.sessions === 1 ? '' : 's'}`)
80
+ out.push('')
81
+ const max = sum.rules[0].count
82
+ const rows = [['GUARD', 'RULE', 'COUNT', 'LAST', '']]
83
+ for (const r of sum.rules) rows.push([r.guard, r.rule, String(r.count).padStart(5), stamp(r.last, now), bar(r.count, max)])
84
+ const lines = table(rows)
85
+ out.push(paint(color, 'bold', lines[0]))
86
+ lines.slice(1).forEach(l => out.push(color ? l.replace(/█+$/, m => paint(true, 'cyan', m)) : l))
87
+ if (sum.projects.length) {
88
+ out.push('')
89
+ const prow = [['PROJECT', 'COUNT']]
90
+ for (const p of sum.projects.slice(0, 8)) prow.push([fit(shortPath(p.project, home), 48), String(p.count).padStart(5)])
91
+ const pl = table(prow)
92
+ out.push(paint(color, 'bold', pl[0]), ...pl.slice(1))
93
+ if (sum.projects.length > 8) out.push(`and ${sum.projects.length - 8} more`)
94
+ }
95
+ return out.join('\n')
96
+ }
97
+
98
+ function renderList(events, { color = false, home, width = 120, now = Date.now() } = {}) {
99
+ if (!events.length) return 'no blocks recorded'
100
+ const rows = [['TIME', 'GUARD', 'RULE', 'SESSION', 'PROJECT', 'COMMAND']]
101
+ for (const e of events) {
102
+ rows.push([stamp(e.time, now), e.guard, e.rule || '', (e.session || '-').slice(0, 8), fit(shortPath(e.project, home), 28), e.command || ''])
103
+ }
104
+ const lines = table(rows)
105
+ return lines.map((l, i) => {
106
+ const cut = fit(l, Math.max(40, width))
107
+ return i === 0 ? paint(color, 'bold', cut) : cut
108
+ }).join('\n')
109
+ }
110
+
111
+ function main(args, { env = process.env, stdout = process.stdout, stderr = process.stderr, now = Date.now(), dir = stateDir() } = {}) {
112
+ if (args.includes('--help') || args.includes('-h')) return stdout.write(USAGE + '\n')
113
+ if (rejectArgs('log', args, { flags: ['json', 'summary'], valued: ['since', 'guard', 'limit'], shorts: ['n'] }, stderr, USAGE)) return
114
+ const sinceArg = option(args, 'since')
115
+ let since = null
116
+ if (sinceArg !== undefined && sinceArg !== '') {
117
+ since = parseSince(sinceArg, now)
118
+ 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`)
120
+ process.exitCode = 1
121
+ return
122
+ }
123
+ }
124
+ 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
130
+ const events = filter(readEvents(dir), { since, guard: typeof guard === 'string' ? guard : null })
131
+ const json = args.includes('--json')
132
+ const color = useColor(stdout, env)
133
+ if (args.includes('--summary')) {
134
+ const sum = summarize(events)
135
+ if (json) return stdout.write(JSON.stringify(sum, null, 2) + '\n')
136
+ return stdout.write(renderSummary(sum, { color, sinceLabel: humanSpan(sinceArg), now }) + '\n')
137
+ }
138
+ const recent = events.slice(-n)
139
+ if (json) return stdout.write(JSON.stringify(recent, null, 2) + '\n')
140
+ if (!recent.length && !events.length && sinceArg === undefined && !guard) {
141
+ return stdout.write(`no blocks recorded yet (${shortPath(eventsFile(dir))})\n`)
142
+ }
143
+ stdout.write(renderList(recent, { color, width: stdout.columns || 160, now }) + '\n')
144
+ }
145
+
146
+ module.exports = { main, parseSince, filter, summarize, renderSummary, renderList }