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.
- package/CHANGELOG.md +90 -0
- package/LICENSE +21 -0
- package/README.es.md +498 -0
- package/README.ja.md +498 -0
- package/README.ko.md +498 -0
- package/README.md +497 -2
- package/README.pt-BR.md +498 -0
- package/README.zh-CN.md +498 -0
- package/bin/bulkhead +3 -0
- package/bin/bulkhead-slot +3 -0
- package/bin/bulkhead-statusline +3 -0
- package/hooks/hooks.json +39 -0
- package/package.json +52 -4
- package/src/basics.js +236 -0
- package/src/checkout-guard.js +296 -0
- package/src/cli-util.js +79 -0
- package/src/cli.js +41 -0
- package/src/config-check.js +29 -0
- package/src/config-cli.js +51 -0
- package/src/config.js +236 -0
- package/src/dev-servers.js +223 -0
- package/src/disk-guard.js +200 -0
- package/src/doctor.js +255 -0
- package/src/events.js +108 -0
- package/src/hook.js +94 -0
- package/src/kill-guard.js +435 -0
- package/src/log-cli.js +146 -0
- package/src/memory-guard.js +145 -0
- package/src/memory.js +76 -0
- package/src/orphan-reaper.js +527 -0
- package/src/paths.js +31 -0
- package/src/port-cli.js +59 -0
- package/src/port-guard.js +142 -0
- package/src/processes.js +181 -0
- package/src/reap-cli.js +56 -0
- package/src/remote-sh.js +478 -0
- package/src/remote.js +1034 -0
- package/src/remotes.js +126 -0
- package/src/scan.js +604 -0
- package/src/sessions.js +147 -0
- package/src/shell.js +597 -0
- package/src/slot-cli.js +150 -0
- package/src/slots.js +168 -0
- package/src/status.js +111 -0
- package/src/statusline.js +69 -0
- package/src/top-worker.js +14 -0
- package/src/top.js +305 -0
- 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 }
|