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
@@ -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 }