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.
- package/CHANGELOG.md +51 -0
- package/README.es.md +142 -50
- package/README.ja.md +142 -50
- package/README.ko.md +143 -51
- package/README.md +142 -50
- package/README.pt-BR.md +142 -50
- package/README.zh-CN.md +154 -62
- package/hooks/hooks.json +68 -8
- package/package.json +5 -2
- package/src/agent-hook.js +162 -0
- package/src/agent-setup.js +256 -0
- package/src/agents.js +89 -0
- package/src/allow-cli.js +65 -0
- package/src/allow-hint.js +62 -0
- package/src/allow-once.js +152 -0
- package/src/auto-queue.js +93 -0
- package/src/bash-guards.js +36 -0
- package/src/basics.js +46 -27
- package/src/checkout-guard.js +342 -74
- package/src/cli-util.js +13 -1
- package/src/cli.js +40 -4
- package/src/config-check.js +21 -4
- package/src/config-cli.js +75 -10
- package/src/config.js +106 -9
- package/src/dev-servers.js +361 -61
- package/src/disk-guard.js +8 -8
- package/src/doctor-enforcement.js +273 -0
- package/src/doctor.js +19 -9
- package/src/edit-guard.js +196 -0
- package/src/events.js +30 -5
- package/src/explain.js +251 -0
- package/src/git-info.js +55 -0
- package/src/gitargs.js +82 -0
- package/src/hook.js +38 -6
- package/src/kill-extras.js +219 -0
- package/src/kill-guard.js +228 -77
- package/src/kill-owners.js +116 -0
- package/src/log-cli.js +15 -10
- package/src/mcp.js +131 -0
- package/src/memory-guard.js +126 -24
- package/src/memory.js +21 -2
- package/src/notices.js +95 -0
- package/src/orphan-reaper.js +58 -11
- package/src/port-cli.js +78 -3
- package/src/port-guard.js +80 -68
- package/src/port-kills.js +198 -0
- package/src/pressure.js +75 -0
- package/src/processes.js +44 -12
- package/src/scan-report.js +166 -0
- package/src/scan.js +87 -18
- package/src/secrets-guard.js +301 -0
- package/src/self-allow.js +73 -0
- package/src/session-info.js +121 -0
- package/src/session-state.js +176 -0
- package/src/sessions.js +109 -20
- package/src/shell.js +279 -19
- package/src/slot-cli.js +92 -21
- package/src/slots.js +61 -13
- package/src/stable-port.js +61 -0
- package/src/status.js +55 -17
- package/src/statusline.js +45 -16
- package/src/subagent-guard.js +24 -0
- package/src/top.js +36 -11
- package/src/wait-guard.js +96 -32
- package/src/worktree-cli.js +454 -0
- package/src/worktree-copy.js +82 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// Kills that are not "a program by name" but still take down other sessions' work: every process
|
|
3
|
+
// of a user, every container, a shared app or service, a device server. Each detector returns
|
|
4
|
+
// [rule, why, check] or null. check(table, env) runs only after the cheap text test matched, and
|
|
5
|
+
// answers with the sentence to show or null. Most of these stop nothing when no other Claude
|
|
6
|
+
// session is alive, so a person working alone is never asked.
|
|
7
|
+
const shell = require('./shell')
|
|
8
|
+
const owners = require('./kill-owners')
|
|
9
|
+
|
|
10
|
+
const APPS = [
|
|
11
|
+
'safari', 'visual studio code', 'terminal', 'iterm2', 'iterm', 'tmux', 'screen', 'warp', 'ghostty', 'alacritty', 'kitty',
|
|
12
|
+
'coresimulator', 'simctl', 'slack', 'finder', 'dock', 'spotify', 'figma', 'notion', 'discord', 'zoom', 'zoom.us', 'webkit',
|
|
13
|
+
'obsidian', 'postman', 'tableplus', 'sublime text', 'intellij idea', 'pycharm', 'webstorm', 'android studio',
|
|
14
|
+
]
|
|
15
|
+
const SERVICES = [
|
|
16
|
+
'redis-server', 'redis', 'mongod', 'mongodb', 'mysqld', 'mysql', 'mariadbd', 'nginx', 'caddy', 'ngrok', 'cloudflared',
|
|
17
|
+
'memcached', 'rabbitmq-server', 'elasticsearch', 'httpd', 'dnsmasq', 'tailscaled', 'minio', 'postgresql',
|
|
18
|
+
]
|
|
19
|
+
const SERVICE_TEXT = /homebrew\.mxcl|postgres|mysql|mariadb|redis|mongo|nginx|caddy|memcache|rabbit|elastic|dnsmasq|tailscale|minio|docker|colima|ngrok|cloudflared|httpd/i
|
|
20
|
+
|
|
21
|
+
function nameIn(list, word) {
|
|
22
|
+
const w = String(word || '').trim().replace(/^["']+|["']+$/g, '').toLowerCase().replace(/\.(?:exe|app)$/, '')
|
|
23
|
+
if (!w || w.length > 100) return false
|
|
24
|
+
return list.some(n => w === n || (n.length >= 5 && w.includes(n)))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const isApp = word => nameIn(APPS, word)
|
|
28
|
+
const isService = word => nameIn(SERVICES, word)
|
|
29
|
+
|
|
30
|
+
const fixed = (rule, why, check) => [rule, why, check]
|
|
31
|
+
|
|
32
|
+
// kill -9 -1, kill 0 and the like: every process the user may signal, or the whole group.
|
|
33
|
+
function killOperands(argv) {
|
|
34
|
+
const pids = []
|
|
35
|
+
let signal = false
|
|
36
|
+
let done = false
|
|
37
|
+
for (let k = 1; k < argv.length; k++) {
|
|
38
|
+
const v = argv[k]
|
|
39
|
+
if (!done && v === '--') { done = true; continue }
|
|
40
|
+
if (!done && (v === '-s' || v === '-n')) {
|
|
41
|
+
// signal 0 only tests that the process exists
|
|
42
|
+
if (/^(?:0|SIG0)$/i.test(argv[k + 1] || '')) return { list: true, pids }
|
|
43
|
+
signal = true
|
|
44
|
+
k++
|
|
45
|
+
continue
|
|
46
|
+
}
|
|
47
|
+
if (!done && v === '-0') return { list: true, pids }
|
|
48
|
+
if (!done && (v === '-l' || v === '-L')) return { list: true, pids }
|
|
49
|
+
if (!done && /^-(?:\d+|[A-Za-z][A-Za-z0-9]*)$/.test(v) && !signal) { signal = true; continue }
|
|
50
|
+
if (/^-?\d+$/.test(v)) pids.push(Number(v))
|
|
51
|
+
}
|
|
52
|
+
return { list: false, pids }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Detectors that read one command. mode is 'sh' or 'ps'.
|
|
56
|
+
function detect(argv, { mode = 'sh', off = new Set(), stmt = [], env = new Map() } = {}) {
|
|
57
|
+
const b = shell.bin(argv[0])
|
|
58
|
+
const on = rule => !off.has(rule)
|
|
59
|
+
if (b === 'kill' || (mode === 'ps' && /^kill$/i.test(argv[0]))) return killCheck(argv, on)
|
|
60
|
+
if (b === 'taskkill') return taskkillCheck(argv, on)
|
|
61
|
+
if (/^(?:stop-process|spps)$/i.test(argv[0])) return stopProcessCheck(argv, on)
|
|
62
|
+
if ((b === 'pkill' || b === 'killall') && on('mass-kill')) {
|
|
63
|
+
const hit = userKill(argv, b)
|
|
64
|
+
if (hit) return hit
|
|
65
|
+
}
|
|
66
|
+
if (/^(?:docker|podman|docker-compose|podman-compose|nerdctl)$/.test(b) && on('docker-mass')) return dockerCheck(argv, b, stmt)
|
|
67
|
+
if (b === 'pm2' && on('docker-mass')) return pm2Check(argv)
|
|
68
|
+
if (b === 'adb' && on('device-kill')) return adbCheck(argv, env)
|
|
69
|
+
if ((b === 'xcrun' || b === 'simctl') && on('device-kill')) return simctlCheck(argv)
|
|
70
|
+
if (b === 'osascript' && on('app-quit')) return osascriptCheck(argv)
|
|
71
|
+
if ((b === 'brew' || b === 'launchctl' || b === 'systemctl' || b === 'service') && on('service-stop')) return serviceCheck(argv, b)
|
|
72
|
+
return null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function killCheck(argv, on) {
|
|
76
|
+
const { list, pids } = killOperands(argv)
|
|
77
|
+
if (list || !pids.length) return null
|
|
78
|
+
if (on('mass-kill') && pids.some(p => p === -1 || p === 0)) {
|
|
79
|
+
return fixed('mass-kill', 'kill -1 and kill 0 signal every process you may signal.', t => owners.othersCheck(t))
|
|
80
|
+
}
|
|
81
|
+
const literal = pids.filter(p => p > 0 || p < -1)
|
|
82
|
+
if (on('owner-check') && literal.length) return fixed('other-session-pid', '', t => owners.pidsCheck(t, literal))
|
|
83
|
+
return null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function taskkillCheck(argv, on) {
|
|
87
|
+
if (!on('owner-check')) return null
|
|
88
|
+
const pids = []
|
|
89
|
+
for (let k = 1; k < argv.length - 1; k++) if (/^(?:\/\/?|-)pid$/i.test(argv[k]) && /^\d+$/.test(argv[k + 1])) pids.push(Number(argv[++k]))
|
|
90
|
+
return pids.length ? fixed('other-session-pid', '', t => owners.pidsCheck(t, pids)) : null
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function stopProcessCheck(argv, on) {
|
|
94
|
+
if (!on('owner-check')) return null
|
|
95
|
+
const pids = []
|
|
96
|
+
for (let k = 1; k < argv.length - 1; k++) {
|
|
97
|
+
if (/^-id$/i.test(argv[k])) for (const n of argv[++k].split(',')) if (/^\d+$/.test(n)) pids.push(Number(n))
|
|
98
|
+
}
|
|
99
|
+
return pids.length ? fixed('other-session-pid', '', t => owners.pidsCheck(t, pids)) : null
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// pkill -u $USER and killall -u name with no program name: everything of that user.
|
|
103
|
+
function userKill(argv, b) {
|
|
104
|
+
const rest = argv.slice(1)
|
|
105
|
+
const user = rest.findIndex(a => /^(?:-u|-U|--user|--uid|--euid)(?:=.*)?$/.test(a))
|
|
106
|
+
if (user < 0) return null
|
|
107
|
+
const valued = new Set(['-u', '-U', '--user', '--uid', '--euid', '-s', '--signal', '-g', '-G', '-P', '-t'])
|
|
108
|
+
const operands = []
|
|
109
|
+
for (let k = 0; k < rest.length; k++) {
|
|
110
|
+
if (valued.has(rest[k])) { k++; continue }
|
|
111
|
+
if (/^-/.test(rest[k])) continue
|
|
112
|
+
operands.push(rest[k])
|
|
113
|
+
}
|
|
114
|
+
if (operands.length) return null
|
|
115
|
+
return fixed('mass-kill', `${b} -u kills every process of that user.`, t => owners.othersCheck(t))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const DOCKER_KILLERS = new Set(['rm', 'stop', 'kill', 'restart', 'rmi', 'pause', 'unpause'])
|
|
119
|
+
|
|
120
|
+
function dockerArgs(argv) {
|
|
121
|
+
const out = []
|
|
122
|
+
for (let k = 1; k < argv.length; k++) {
|
|
123
|
+
if (/^-/.test(argv[k])) { if (/^(?:-H|--host|--context|-c|--config|-p|--project-name|-f|--file|--env-file|--profile|--project-directory)$/.test(argv[k])) k++; continue }
|
|
124
|
+
out.push(argv[k])
|
|
125
|
+
}
|
|
126
|
+
return out
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function dockerCheck(argv, b, stmt) {
|
|
130
|
+
const args = dockerArgs(argv)
|
|
131
|
+
const sub = /compose$/.test(b) ? 'compose' : args[0]
|
|
132
|
+
const second = /compose$/.test(b) ? args[0] : args[1]
|
|
133
|
+
if (sub === 'system' && second === 'prune') return mass('docker system prune removes containers, networks and images for everyone.')
|
|
134
|
+
if (second === 'prune' && /^(?:container|volume)$/.test(sub)) return mass(`docker ${sub} prune removes everyone's unused ${sub}s.`)
|
|
135
|
+
if (second === 'prune' && /^(?:image|builder)$/.test(sub) && argv.some(a => /^(?:-a|-af|-fa|--all)$/.test(a))) return mass(`docker ${sub} prune -a removes everyone's unused ${sub}s.`)
|
|
136
|
+
if (sub === 'compose' || sub === 'stack') {
|
|
137
|
+
if (!/^(?:down|stop|rm|kill|restart)$/.test(second || '')) return null
|
|
138
|
+
const f = argv.findIndex(a => a === '-f' || a === '--file' || a === '--project-directory')
|
|
139
|
+
const dir = f > 0 && argv[f + 1] ? argv[f + 1].replace(/[\\/][^\\/]*\.ya?ml$/, '') : ''
|
|
140
|
+
return fixed('docker-mass', `docker compose ${second} stops the containers of that project.`, (t, env) => owners.sameProjectCheck(t, dir && dir !== argv[f + 1] ? dir : env.cwd))
|
|
141
|
+
}
|
|
142
|
+
const killer = DOCKER_KILLERS.has(sub) || (sub === 'container' && DOCKER_KILLERS.has(second))
|
|
143
|
+
// docker ps -q | xargs docker stop: the ids come from the pipe
|
|
144
|
+
const noIds = killer && !args.slice(sub === 'container' ? 2 : 1).length
|
|
145
|
+
if (noIds && stmt.some(c => listsEveryContainer(c.argv)) && stmt.every(c => c.argv === argv || !c.argv.length || listsEveryContainer(c.argv))) return mass('Stopping or removing every container touches other sessions\' containers.')
|
|
146
|
+
if (killer && argv.some(a => /^(?:\$\(|`)\s*(?:docker|podman)\s+(?:ps|container\s+ls|container\s+list|images|image\s+ls)\b/.test(a) && !/--filter|\s-f\b/.test(a))) {
|
|
147
|
+
return mass('Stopping or removing every container touches other sessions\' containers.')
|
|
148
|
+
}
|
|
149
|
+
return null
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function listsEveryContainer(argv) {
|
|
153
|
+
if (!/^(?:docker|podman)$/.test(shell.bin(argv[0] || ''))) return false
|
|
154
|
+
const a = dockerArgs(argv)
|
|
155
|
+
const listing = a[0] === 'ps' || (a[0] === 'container' && /^(?:ls|list)$/.test(a[1])) || a[0] === 'images' || (a[0] === 'image' && a[1] === 'ls')
|
|
156
|
+
return listing && argv.some(x => /^-[a-z]*q/.test(x) || x === '--quiet') && !argv.some(x => /^--filter|^-f$|^-f.+=/.test(x))
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function mass(why) {
|
|
160
|
+
return fixed('docker-mass', why, t => owners.othersCheck(t))
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function pm2Check(argv) {
|
|
164
|
+
const a = argv.slice(1).filter(x => !/^-/.test(x))
|
|
165
|
+
if (a[0] === 'kill' || (/^(?:stop|delete|restart|reload)$/.test(a[0] || '') && a[1] === 'all')) {
|
|
166
|
+
return fixed('docker-mass', `pm2 ${a[0]}${a[1] ? ' all' : ''} stops every process pm2 runs, for every project.`, t => owners.othersCheck(t))
|
|
167
|
+
}
|
|
168
|
+
return null
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function adbCheck(argv, env) {
|
|
172
|
+
const a = argv.slice(1)
|
|
173
|
+
// -s, -e and -d name one device; so does ANDROID_SERIAL
|
|
174
|
+
const own = a.some(x => /^-[sed]$/.test(x)) || env.has('ANDROID_SERIAL')
|
|
175
|
+
if (a.includes('kill-server') || (a.includes('emu') && a[a.indexOf('emu') + 1] === 'kill' && !own)) {
|
|
176
|
+
return fixed('device-kill', 'adb kill-server and adb emu kill drop every device connection and emulator.', t => owners.othersCheck(t))
|
|
177
|
+
}
|
|
178
|
+
return null
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function simctlCheck(argv) {
|
|
182
|
+
const at = argv.findIndex(a => shell.bin(a) === 'simctl')
|
|
183
|
+
if (at < 0) return null
|
|
184
|
+
const [verb, target] = argv.slice(at + 1)
|
|
185
|
+
if (/^(?:shutdown|erase|delete)$/.test(verb || '') && /^(?:all|booted)$/.test(target || '')) {
|
|
186
|
+
return fixed('device-kill', `simctl ${verb} ${target} reaches every simulator.`, t => owners.othersCheck(t))
|
|
187
|
+
}
|
|
188
|
+
return null
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function osascriptCheck(argv) {
|
|
192
|
+
const text = argv.slice(1).join(' ')
|
|
193
|
+
if (!/\bquit\b/i.test(text)) return null
|
|
194
|
+
for (const m of text.matchAll(/"([^"]{1,80})"/g)) {
|
|
195
|
+
if (isApp(m[1]) || isService(m[1]) || /^(?:google chrome|chromium|firefox|microsoft edge|simulator|xcode|docker|docker desktop|cursor|electron)$/i.test(m[1])) {
|
|
196
|
+
return fixed('app-quit', `Quitting ${m[1]} closes it for everyone.`, t => owners.othersCheck(t))
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return null
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function serviceCheck(argv, b) {
|
|
203
|
+
const a = argv.slice(1)
|
|
204
|
+
const words = a.filter(x => !/^-/.test(x))
|
|
205
|
+
const text = a.join(' ')
|
|
206
|
+
let hit = false
|
|
207
|
+
if (b === 'brew') hit = words[0] === 'services' && /^(?:stop|restart|kill|cleanup)$/.test(words[1] || '') && (a.includes('--all') || isService(words[2]) || SERVICE_TEXT.test(words[2] || ''))
|
|
208
|
+
else if (b === 'launchctl') {
|
|
209
|
+
const verb = words[0]
|
|
210
|
+
if (/^(?:bootout|unload|remove|stop|kill|disable)$/.test(verb || '') || (verb === 'kickstart' && a.includes('-k'))) {
|
|
211
|
+
hit = SERVICE_TEXT.test(text) || (verb === 'bootout' && words.slice(1).every(x => /^(?:gui|user|system)\/\d*$/.test(x)))
|
|
212
|
+
}
|
|
213
|
+
} else if (b === 'systemctl' || b === 'service') {
|
|
214
|
+
hit = /^(?:stop|restart|kill|disable|mask)$/.test(words.find(x => /^(?:stop|restart|kill|disable|mask)$/.test(x)) || '') && SERVICE_TEXT.test(text)
|
|
215
|
+
}
|
|
216
|
+
return hit ? fixed('service-stop', `Stopping or restarting a shared service (${b} ${words.slice(0, 3).join(' ')}) takes it away from every session.`, t => owners.othersCheck(t)) : null
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
module.exports = { detect, isApp, isService, killOperands, APPS, SERVICES }
|