claude-bulkhead 0.0.0-stage → 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 (76) hide show
  1. package/CHANGELOG.md +141 -0
  2. package/LICENSE +21 -0
  3. package/README.es.md +590 -0
  4. package/README.ja.md +590 -0
  5. package/README.ko.md +590 -0
  6. package/README.md +589 -2
  7. package/README.pt-BR.md +590 -0
  8. package/README.zh-CN.md +590 -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 +99 -0
  13. package/package.json +55 -4
  14. package/src/agent-hook.js +162 -0
  15. package/src/agent-setup.js +256 -0
  16. package/src/agents.js +89 -0
  17. package/src/allow-cli.js +65 -0
  18. package/src/allow-hint.js +62 -0
  19. package/src/allow-once.js +152 -0
  20. package/src/auto-queue.js +93 -0
  21. package/src/bash-guards.js +36 -0
  22. package/src/basics.js +255 -0
  23. package/src/checkout-guard.js +564 -0
  24. package/src/cli-util.js +91 -0
  25. package/src/cli.js +77 -0
  26. package/src/config-check.js +46 -0
  27. package/src/config-cli.js +116 -0
  28. package/src/config.js +333 -0
  29. package/src/dev-servers.js +523 -0
  30. package/src/disk-guard.js +200 -0
  31. package/src/doctor-enforcement.js +273 -0
  32. package/src/doctor.js +265 -0
  33. package/src/edit-guard.js +196 -0
  34. package/src/events.js +133 -0
  35. package/src/explain.js +251 -0
  36. package/src/git-info.js +55 -0
  37. package/src/gitargs.js +82 -0
  38. package/src/hook.js +126 -0
  39. package/src/kill-extras.js +219 -0
  40. package/src/kill-guard.js +586 -0
  41. package/src/kill-owners.js +116 -0
  42. package/src/log-cli.js +151 -0
  43. package/src/mcp.js +131 -0
  44. package/src/memory-guard.js +247 -0
  45. package/src/memory.js +95 -0
  46. package/src/notices.js +95 -0
  47. package/src/orphan-reaper.js +574 -0
  48. package/src/paths.js +31 -0
  49. package/src/port-cli.js +134 -0
  50. package/src/port-guard.js +154 -0
  51. package/src/port-kills.js +198 -0
  52. package/src/pressure.js +75 -0
  53. package/src/processes.js +213 -0
  54. package/src/reap-cli.js +56 -0
  55. package/src/remote-sh.js +478 -0
  56. package/src/remote.js +1034 -0
  57. package/src/remotes.js +126 -0
  58. package/src/scan-report.js +166 -0
  59. package/src/scan.js +673 -0
  60. package/src/secrets-guard.js +301 -0
  61. package/src/self-allow.js +73 -0
  62. package/src/session-info.js +121 -0
  63. package/src/session-state.js +176 -0
  64. package/src/sessions.js +236 -0
  65. package/src/shell.js +857 -0
  66. package/src/slot-cli.js +221 -0
  67. package/src/slots.js +216 -0
  68. package/src/stable-port.js +61 -0
  69. package/src/status.js +149 -0
  70. package/src/statusline.js +98 -0
  71. package/src/subagent-guard.js +24 -0
  72. package/src/top-worker.js +14 -0
  73. package/src/top.js +330 -0
  74. package/src/wait-guard.js +327 -0
  75. package/src/worktree-cli.js +454 -0
  76. package/src/worktree-copy.js +82 -0
@@ -0,0 +1,327 @@
1
+ 'use strict'
2
+ const { runHook, setting, blocked, ASK_USER } = require('./hook')
3
+ const shell = require('./shell')
4
+
5
+ const DEFAULT_TOOL_TIMEOUT_S = 120
6
+
7
+ // Shell, PowerShell and cmd.exe waits in seconds: sleep 300, Start-Sleep 300, timeout /t 300,
8
+ // ping -n 301 127.0.0.1, [Threading.Thread]::Sleep(300000).
9
+ function sleeps(cmd) {
10
+ const out = []
11
+ for (const m of cmd.matchAll(/(?:^|[\s;&|({])sleep\s+(?:(\d+(?:\.\d+)?)([smhd]?)\b|(inf(?:inity)?)\b)/g)) {
12
+ if (m[3]) { out.push(Infinity); continue }
13
+ const n = parseFloat(m[1])
14
+ out.push(n * ({ m: 60, h: 3600, d: 86400 }[m[2]] || 1))
15
+ }
16
+ for (const m of cmd.matchAll(/Start-Sleep\s+(?:(-s(?:econds)?|-m(?:illiseconds)?)\s+)?(\d+(?:\.\d+)?)/gi)) {
17
+ const n = parseFloat(m[2])
18
+ out.push(m[1] && /^-m/i.test(m[1]) ? n / 1000 : n)
19
+ }
20
+ for (const m of cmd.matchAll(/(?:^|[\s;&|({])timeout(?:\.exe)?\s+\/t\s+(-?\d+)/gi)) {
21
+ out.push(Number(m[1]) < 0 ? Infinity : Number(m[1]))
22
+ }
23
+ for (const m of cmd.matchAll(/\[(?:System\.)?Threading\.Thread\]::Sleep\(\s*(\d+)\s*\)/gi)) out.push(Number(m[1]) / 1000)
24
+ for (const m of cmd.matchAll(/(?:^|[\s;&|({])ping(?:\.exe)?\b([^\n;&|)]*)/gi)) {
25
+ const w = pingWait(m[1])
26
+ if (w) out.push(w)
27
+ }
28
+ return out
29
+ }
30
+
31
+ // ping -n 301 waits 300 s on Windows, ping -c 300 -i 1 about 300 s elsewhere, ping -t forever.
32
+ function pingWait(args) {
33
+ if (/(?:^|\s)-t\b/.test(args) && process.platform === 'win32') return Infinity
34
+ const n = /(?:^|\s)-n\s+(\d+)(?![.\d])/.exec(args)
35
+ if (n) return Math.max(0, Number(n[1]) - 1)
36
+ const c = /(?:^|\s)-c\s*(\d+)/.exec(args)
37
+ const i = /(?:^|\s)-i\s*(\d+(?:\.\d+)?)/.exec(args)
38
+ if (c) return Math.max(0, Number(c[1]) - 1) * (i ? Number(i[1]) : 1)
39
+ const deadline = /(?:^|\s)-w\s*(\d+)/.exec(args)
40
+ if (deadline && process.platform !== 'win32') return Number(deadline[1])
41
+ return process.platform === 'win32' ? 3 : Infinity
42
+ }
43
+
44
+ // Commands that follow a log or a stream until they are stopped.
45
+ function follows(argv) {
46
+ const b = shell.bin(argv[0])
47
+ const rest = argv.slice(1)
48
+ if (b === 'tail') return rest.some(a => /^-[a-zA-Z0-9]*[fF]/.test(a) || a === '--follow' || a.startsWith('--follow='))
49
+ if (/^(?:get-content|gc|cat|type)$/i.test(argv[0])) return rest.some(a => /^-wait$/i.test(a))
50
+ if (b === 'journalctl') return rest.some(a => /^-[a-zA-Z]*f/.test(a) || a === '--follow')
51
+ if (/^(?:docker|podman|kubectl|oc|docker-compose)$/.test(b) && rest.includes('logs')) return rest.some(a => a === '-f' || a === '--follow')
52
+ return false
53
+ }
54
+
55
+ // Code with its string literals and comments blanked: a sleep inside a quoted file body or a
56
+ // comment is text, not a wait. lang is 'python', 'js' or 'secs' (ruby, perl, php).
57
+ function blankText(code, lang) {
58
+ code = String(code).slice(0, 200000)
59
+ let out = ''
60
+ let i = 0
61
+ const n = code.length
62
+ while (i < n) {
63
+ const c = code[i]
64
+ if ((c === '#' && lang !== 'js') || (c === '/' && code[i + 1] === '/' && lang === 'js')) {
65
+ const nl = code.indexOf('\n', i)
66
+ i = nl < 0 ? n : nl
67
+ continue
68
+ }
69
+ if (c === '/' && code[i + 1] === '*' && lang === 'js') {
70
+ const end = code.indexOf('*/', i + 2)
71
+ i = end < 0 ? n : end + 2
72
+ continue
73
+ }
74
+ if (c === '"' || c === "'" || c === '`') {
75
+ const q = lang === 'python' && code.startsWith(c.repeat(3), i) ? c.repeat(3) : c
76
+ i += q.length
77
+ while (i < n && !code.startsWith(q, i)) {
78
+ if (code[i] === '\\') i++
79
+ else if (q.length === 1 && q !== '`' && code[i] === '\n') break
80
+ i++
81
+ }
82
+ i += q.length
83
+ out += '""'
84
+ continue
85
+ }
86
+ out += c
87
+ i++
88
+ }
89
+ return out
90
+ }
91
+
92
+ // A loop inside an interpreter's code: Infinity for while loops, the count for range(N),
93
+ // N.times or for (i = 0; i < N; ...), and 1 for no loop.
94
+ function codeLoops(code) {
95
+ if (/\bwhile\b|\bloop\s+do\b|\bfor\s*\(\s*;\s*;|\bsetInterval\s*\(/.test(code)) return Infinity
96
+ let best = 1
97
+ for (const m of code.matchAll(/\brange\(\s*(?:(\d+)\s*,\s*)?(\d+)\s*\)/g)) best = Math.max(best, Number(m[2]) - Number(m[1] || 0))
98
+ for (const m of code.matchAll(/\b(\d+)\.times\b/g)) best = Math.max(best, Number(m[1]))
99
+ for (const m of code.matchAll(/\bfor\s*\([^;)]{0,80};\s*\w+\s*<=?\s*(\d+)/g)) best = Math.max(best, Number(m[1]))
100
+ return best
101
+ }
102
+
103
+ // Worst-case wait in seconds of python -c, node -e, ruby -e, perl -e or php -r code, or of a
104
+ // python or node heredoc: time.sleep(300) and asyncio.sleep(300) wait seconds, sleep(500) and
105
+ // setTimeout(f, 500) in JavaScript wait milliseconds. Strings that code hands to a shell
106
+ // (os.system('sleep 300'), execSync("sleep 300")) are shell waits.
107
+ function codeWait(code, lang = 'secs') {
108
+ const text = blankText(code, lang)
109
+ const s = shell.embeddedShell(code).flatMap(sleeps)
110
+ const unit = lang === 'js' ? 1000 : 1
111
+ for (const m of text.matchAll(/\bsleep\s*\(?\s*(\d+(?:\.\d+)?)\s*\)?/g)) s.push(Number(m[1]) / unit)
112
+ for (const m of text.matchAll(/\bsetTimeout\s*\(([^;]{0,300}?),\s*(\d+(?:\.\d+)?)\s*\)/g)) s.push(Number(m[2]) / 1000)
113
+ if (!s.length) return 0
114
+ const loops = codeLoops(text)
115
+ return loops > 1 ? loops * s.reduce((a, b) => Math.max(a, b), 0) : s.reduce((a, b) => a + b, 0)
116
+ }
117
+
118
+ const INTERPRETER_WORD = /\b(?:python[\d.]*|py|pythonw|node|nodejs|deno|bun|ruby|perl|php|lua)\b/
119
+ const INTERPRETER = /^(?:python[\d.]*|py|pythonw|node|deno|bun|ruby|perl|php)$/
120
+ const CODE_FLAG = { python: /^-c$/, node: /^(?:-e|--eval|-p|--print)$/, ruby: /^-e$/, perl: /^-[Ee]$/, php: /^-r$/ }
121
+ const langOf = b => (/^py/.test(b) ? 'python' : /^(?:node|nodejs|deno|bun)$/.test(b) ? 'js' : 'secs')
122
+
123
+ // { code, lang } of an interpreter command line, or null.
124
+ function codeOf(argv) {
125
+ const b = shell.bin(argv[0])
126
+ if (!INTERPRETER.test(b)) return null
127
+ const kind = /^py/.test(b) ? 'python' : /^(?:node|deno|bun)$/.test(b) ? 'node' : b
128
+ if (/^(?:deno|bun)$/.test(b) && argv[1] === 'eval') return argv[2] ? { code: argv[2], lang: 'js' } : null
129
+ const i = argv.findIndex((a, k) => k > 0 && CODE_FLAG[kind].test(a))
130
+ return i > 0 && argv[i + 1] ? { code: argv[i + 1], lang: langOf(b) } : null
131
+ }
132
+
133
+ // Inert commands only print or search text: echo "sleep 600", git log -S "sleep 300".
134
+ const INERT = /^(?:echo|printf|git|grep|egrep|rg|write-host|write-output|select-string|sls)$/i
135
+ const BACKGROUND = /^(?:start-job|sajb|start-threadjob)$/i
136
+
137
+ // Blanks out inert commands, keeping every offset, and collects interpreter code waits.
138
+ function scan(text, mode) {
139
+ const { cmds } = shell.lex(text, mode)
140
+ const cut = []
141
+ const code = []
142
+ let follow = false
143
+ for (const c of cmds) {
144
+ const s = shell.strip(text, c.words, mode)
145
+ if (!s.argv.length) continue
146
+ const argv = s.argv.map(w => w.v)
147
+ // inert commands, and PowerShell jobs, which run in the background like `&`
148
+ // and `docker exec -d` or `docker run -d`, which return as soon as the container starts the command
149
+ const detached = s.remote && argv.some(v => v === '-d' || v === '--detach')
150
+ if (INERT.test(shell.bin(argv[0])) || BACKGROUND.test(argv[0]) || detached) { cut.push([s.argv[0].s, c.e]); continue }
151
+ const src = codeOf(argv)
152
+ if (src) code.push(codeWait(src.code, src.lang))
153
+ if (follows(argv)) follow = true
154
+ }
155
+ if (!cut.length) return { text, code, follow }
156
+ cut.sort((a, b) => a[0] - b[0])
157
+ let out = ''
158
+ let last = 0
159
+ for (const [s, e] of cut) {
160
+ if (e <= last) continue
161
+ const from = Math.max(s, last)
162
+ out += text.slice(last, from) + ' '.repeat(e - from)
163
+ last = e
164
+ }
165
+ return { text: out + text.slice(last), code, follow }
166
+ }
167
+
168
+ // Upper bound on iterations, or Infinity when the loop has no fixed count.
169
+ function loopCount(cmd) {
170
+ if (/\b(until|while)\b/i.test(cmd)) return Infinity
171
+ let best = null
172
+ const take = n => (best = best === null ? n : Math.max(best, n))
173
+ for (const m of cmd.matchAll(/\bseq\s+(?:(\d+)\s+)?(?:(\d+)\s+)?(\d+)/g)) {
174
+ const nums = [m[1], m[2], m[3]].filter(Boolean).map(Number)
175
+ if (nums.length === 1) take(nums[0])
176
+ else if (nums.length === 2) take(nums[1] - nums[0] + 1)
177
+ else take(Math.floor((nums[2] - nums[0]) / nums[1]) + 1)
178
+ }
179
+ for (const m of cmd.matchAll(/\{(\d+)\.\.(\d+)\}/g)) take(Number(m[2]) - Number(m[1]) + 1)
180
+ // for x in a b c; do: the list runs to the next `;`. A `for` inside a list already read is
181
+ // skipped, so the text is read once however many `for`s it holds.
182
+ let listEnd = -1
183
+ for (const m of cmd.matchAll(/\bfor\s+\w+\s+in\s+/g)) {
184
+ const from = m.index + m[0].length
185
+ if (from <= listEnd) continue
186
+ listEnd = cmd.indexOf(';', from)
187
+ if (listEnd < 0) break
188
+ const list = cmd.slice(from, listEnd)
189
+ if (!/[$`(]/.test(list) && /^;\s*do\b/.test(cmd.slice(listEnd, listEnd + 16))) take(list.trim().split(/\s+/).length)
190
+ }
191
+ // bounded repeat: an unbounded [^;]* rescans to the end of the text from every `for (`
192
+ for (const m of cmd.matchAll(/\bfor\s*\(\(?[^;]{0,200};\s*\$?\w+\s*(?:<=?|-lt|-le)\s*(\d+)/gi)) take(Number(m[1]))
193
+ for (const m of cmd.matchAll(/\b(\d+)\.\.(\d+)\b/g)) take(Number(m[2]) - Number(m[1]) + 1)
194
+ return best === null ? Infinity : best
195
+ }
196
+
197
+ // for/while/until ... do, PowerShell loops and ForEach-Object. Two plain searches instead of
198
+ // /\b(for|while|until)\b[\s\S]*?\bdo\b/, which rescans to the end from every unmatched `for`.
199
+ function hasLoop(cmd) {
200
+ const head = /\b(?:for|while|until)\b/.exec(cmd)
201
+ return (head && /\bdo\b/.test(cmd.slice(head.index))) ||
202
+ /\b(while|for|foreach|do)\s*[({]/i.test(cmd) ||
203
+ /ForEach-Object|\|\s*%\s*\{/i.test(cmd)
204
+ }
205
+
206
+ // Is the end of this text inside a for, while or until loop that has not reached its done?
207
+ function inLoop(before) {
208
+ const opened = (before.match(/\b(?:for|while|until)\b/g) || []).length
209
+ const closed = (before.match(/\bdone\b/g) || []).length
210
+ return opened > closed
211
+ }
212
+
213
+ const QUOTED = /"[^"]*"|'[^']*'/g
214
+
215
+ const OPENERS = new Set(['do', 'if', 'case'])
216
+ const CLOSERS = new Set(['done', 'fi', 'esac'])
217
+
218
+ // Splits a shell line into top-level lists, each with the operator that ends it
219
+ // (';', '\n', '&' or ''). && and || stay inside a list, as in bash, where `a && b &`
220
+ // backgrounds both. Quotes, loops, groups and subshells are kept whole.
221
+ function topLevelLists(cmd) {
222
+ const out = []
223
+ // what is open: 'p' parenthesis, 'b' brace, 'w' loop, if or case. A `)` that ends a case pattern
224
+ // (haeon) cmd;;) has no ( to close and must not close the case.
225
+ const open = []
226
+ let start = 0
227
+ let quote = null
228
+ const push = (end, op) => {
229
+ out.push({ text: cmd.slice(start, end), op })
230
+ start = end + op.length
231
+ }
232
+ for (let i = 0; i < cmd.length; i++) {
233
+ const c = cmd[i]
234
+ if (quote) {
235
+ if (c === quote) quote = null
236
+ else if (c === '\\' && quote === '"') i++
237
+ continue
238
+ }
239
+ if (c === '\\') { i++; continue }
240
+ if (c === '"' || c === "'") { quote = c; continue }
241
+ if (c === '(' || c === '{') { open.push(c === '(' ? 'p' : 'b'); continue }
242
+ if (c === ')' || c === '}') {
243
+ if (open[open.length - 1] === (c === ')' ? 'p' : 'b')) open.pop()
244
+ continue
245
+ }
246
+ if (/[a-z]/i.test(c) && (i === 0 || /[\s;&|]/.test(cmd[i - 1]))) {
247
+ const word = /^[\w.-]+/.exec(cmd.slice(i, i + 64))[0]
248
+ if (OPENERS.has(word)) open.push('w')
249
+ else if (CLOSERS.has(word) && open[open.length - 1] === 'w') open.pop()
250
+ i += word.length - 1
251
+ continue
252
+ }
253
+ if (open.length) continue
254
+ if (c === ';' || c === '\n') push(i, c)
255
+ else if (c === '&') {
256
+ const prev = cmd[i - 1]
257
+ const next = cmd[i + 1]
258
+ // &&, >&, &>, |&, 2>&1 are not background operators
259
+ if (next === '&') { i++; continue }
260
+ if (prev === '>' || prev === '|' || prev === '<' || next === '>') continue
261
+ push(i, '&')
262
+ }
263
+ }
264
+ if (start < cmd.length) out.push({ text: cmd.slice(start), op: '' })
265
+ return out
266
+ }
267
+
268
+ // A list ending in a single & runs as a background job: the tool call returns without waiting
269
+ // for it, so its sleeps cost nothing. A later `wait` brings them back into the foreground.
270
+ function dropBackgrounded(cmd) {
271
+ const lists = topLevelLists(cmd)
272
+ if (!lists.some(l => l.op === '&')) return cmd
273
+ if (lists.some(l => l.op !== '&' && /^\s*wait\b/.test(l.text))) return cmd
274
+ return lists.filter(l => l.op !== '&').map(l => l.text).join('\n')
275
+ }
276
+
277
+ // Worst-case wait in seconds, capped by the tool timeout. Returns null when within the limit.
278
+ function judge(cmd, timeoutMs, maxSec = 180, mode = 'sh') {
279
+ const raw = String(cmd || '')
280
+ // a heredoc that a shell runs, here or over ssh, is shell text; one that python or node reads is code
281
+ const opened = shell.unwrap(shell.prepare(raw, mode, ['shell', 'remote']), mode, 0, { remote: true })
282
+ const { text, code, follow } = scan(dropBackgrounded(opened), mode)
283
+ // python3 - <<'EOF' ... EOF: the body is the interpreter's code, in its own language. Its waits
284
+ // are added to the shell's, not multiplied by a shell loop that ended before it.
285
+ let after = 0
286
+ if (raw.includes('<<') && INTERPRETER_WORD.test(raw)) {
287
+ for (const d of shell.heredocs(raw, (prefix, rest) => shell.consumerOf(prefix, rest, mode).kind === 'interp')) {
288
+ const w = codeWait(d.body, langOf(shell.consumerOf(d.prefix, d.rest, mode).bin))
289
+ if (inLoop(raw.slice(0, d.start))) code.push(w)
290
+ else after += w
291
+ }
292
+ }
293
+ // Quoted text is an argument, not shell code: a `for (` inside node -e "..." is not a loop.
294
+ const bare = text.replace(QUOTED, '_')
295
+ const s = sleeps(bare).concat(code.filter(n => n > 0))
296
+ if (follow) s.push(Infinity)
297
+ if (!s.length && !after) return null
298
+ const wrapped = bare.match(/\b(?:g?timeout)\s+(?:-\w+\s+)*(\d+)s?\b/)
299
+ if (wrapped && Number(wrapped[1]) <= maxSec) return null
300
+ const cap = Number(timeoutMs) > 0 ? Number(timeoutMs) / 1000 : DEFAULT_TOOL_TIMEOUT_S
301
+ const loop = hasLoop(bare)
302
+ const inShell = !s.length ? 0 : loop ? loopCount(bare) * s.reduce((a, b) => Math.max(a, b), 0) : s.reduce((a, b) => a + b, 0)
303
+ const worst = inShell + after
304
+ const est = Math.min(worst, cap)
305
+ return est > maxSec ? { est, maxSec, rule: follow && !loop ? 'follow' : loop ? 'poll-loop' : 'long-sleep' } : null
306
+ }
307
+
308
+ function message({ est, maxSec }) {
309
+ return `[bulkhead] wait-guard: this command can sit in the foreground for about ${Math.round(est)}s (limit ${maxSec}s).\n` +
310
+ 'A foreground wait holds the session: it cannot answer the user or do anything else until the command returns. ' +
311
+ 'The prompt cache also expires after about 5 minutes without a request, so the request after a long wait writes the whole context to the cache again instead of reading it.\n' +
312
+ 'Run the same loop with run_in_background: true and end your turn; you are notified once when it exits. For example:\n' +
313
+ ' until [ -f out/DONE ]; do sleep 15; done; tail -20 out/log.txt\n' +
314
+ 'For a one-off check, skip the sleep and read the output file now.'
315
+ }
316
+
317
+ function handle(input) {
318
+ const ti = input.tool_input || {}
319
+ if (ti.run_in_background || typeof ti.command !== 'string') return
320
+ const verdict = judge(ti.command, ti.timeout, setting('waitGuard.limitSeconds', 180), shell.modeFor(input))
321
+ if (!verdict) return
322
+ return blocked('wait-guard', verdict.rule, input, `${message(verdict)}\n${ASK_USER}`)
323
+ }
324
+
325
+ if (require.main === module) runHook('wait-guard', handle)
326
+
327
+ module.exports = { handle, judge, message, topLevelLists, dropBackgrounded }