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
package/src/top.js ADDED
@@ -0,0 +1,305 @@
1
+ 'use strict'
2
+ const os = require('os')
3
+ const path = require('path')
4
+ const { stateDir } = require('./hook')
5
+ const { tailEvents } = require('./events')
6
+ const { limits } = require('./slots')
7
+ const { size, when } = require('./status')
8
+ const { useColor, paint, shortPath, option, fit, rejectArgs } = require('./cli-util')
9
+
10
+ const USAGE = `usage: bulkhead top [--interval=SECONDS] [--once]
11
+
12
+ A live view of the Claude Code sessions on this machine, their memory, child processes
13
+ and ports, the bulkhead-slot jobs and queue, free RAM, and the latest blocks.
14
+ Keys: q or Ctrl-C quits, r refreshes now.
15
+ When the output is not a terminal, or with --once, it prints one snapshot and exits.`
16
+
17
+ const ESC = '\x1b['
18
+ const ALT_ON = `${ESC}?1049h`
19
+ const ALT_OFF = `${ESC}?1049l`
20
+ const HIDE = `${ESC}?25l`
21
+ const SHOW = `${ESC}?25h`
22
+ const HOME = `${ESC}H`
23
+ const CLEAR_EOL = `${ESC}K`
24
+ const CLEAR_EOS = `${ESC}J`
25
+ const SPINNER = '|/-\\'
26
+
27
+ function defaultInterval(platform = process.platform) {
28
+ return platform === 'win32' ? 4000 : 2000
29
+ }
30
+
31
+ function meter(fraction, width) {
32
+ const n = Math.max(0, Math.min(width, Math.round(fraction * width)))
33
+ return '█'.repeat(n) + '░'.repeat(width - n)
34
+ }
35
+
36
+ // Plain cell of exactly `width` columns.
37
+ const cell = (s, width, right = false) => {
38
+ const t = fit(s, width)
39
+ return right ? t.padStart(width) : t.padEnd(width)
40
+ }
41
+
42
+ // Session table columns. When the terminal is narrow, columns go in `drop` order, then the
43
+ // project column shrinks. Widths are fixed except the project and ports columns.
44
+ const COLUMNS = [
45
+ { key: 'pid', title: 'PID', width: 7 },
46
+ { key: 'started', title: 'STARTED', width: 12, drop: 2 },
47
+ { key: 'project', title: 'PROJECT', width: 12, flex: true },
48
+ { key: 'rss', title: 'RSS', width: 7, right: true },
49
+ { key: 'bar', title: '', width: 10, drop: 1 },
50
+ { key: 'procs', title: 'PROCS', width: 5, right: true, drop: 4 },
51
+ { key: 'ports', title: 'PORTS', width: 6, grow: 22, drop: 3 },
52
+ ]
53
+
54
+ function layout(width) {
55
+ let cols = COLUMNS.slice()
56
+ const total = cs => cs.reduce((n, c) => n + c.width, 0) + (cs.length - 1) * 2
57
+ for (const d of [1, 2, 3, 4]) {
58
+ if (total(cols) <= width) break
59
+ cols = cols.filter(c => c.drop !== d)
60
+ }
61
+ cols = cols.map(c => ({ ...c }))
62
+ let spare = width - total(cols)
63
+ const ports = cols.find(c => c.key === 'ports')
64
+ if (ports && spare > 0) {
65
+ const add = Math.min(spare, ports.grow - ports.width)
66
+ ports.width += add
67
+ spare -= add
68
+ }
69
+ const project = cols.find(c => c.key === 'project')
70
+ project.width = Math.max(4, project.width + spare)
71
+ return cols
72
+ }
73
+
74
+ // One screen as an array of lines, each at most `width` visible columns.
75
+ function frame(view, { width = 100, height = 40, color = false, home = os.homedir() } = {}) {
76
+ width = Math.max(24, width)
77
+ const c = (style, s) => paint(color, style, s)
78
+ const lines = []
79
+ const st = view.st
80
+ const now = view.now || Date.now()
81
+ const d = new Date(now)
82
+ const clock = [d.getHours(), d.getMinutes(), d.getSeconds()].map(n => String(n).padStart(2, '0')).join(':')
83
+
84
+ // header
85
+ const state = view.scanning ? `scanning ${SPINNER[(view.tick || 0) % 4]}` : view.lastScanMs != null ? `scan ${(view.lastScanMs / 1000).toFixed(1)}s` : ''
86
+ const left = `bulkhead top ${clock}`
87
+ const right = `${state} every ${Math.round(view.interval / 1000)}s q quit r refresh`
88
+ const gap = width - left.length - right.length
89
+ if (gap >= 2) lines.push(c('bold', left) + ' '.repeat(gap) + (view.scanning ? c('yellow', right.slice(0, state.length)) + right.slice(state.length) : right))
90
+ else lines.push(c('bold', fit(`${left} ${state}`, width)))
91
+
92
+ if (!st) {
93
+ lines.push('', view.error ? c('red', fit(`scan failed: ${view.error}`, width)) : 'reading processes...')
94
+ return lines
95
+ }
96
+
97
+ // memory
98
+ const m = st.memory
99
+ const lim = limits(m.totalGb)
100
+ const used = m.totalGb - m.freeGb
101
+ const low = m.freeGb < lim.minFreeGb
102
+ const tight = m.freeGb < lim.floorGb
103
+ const extra = [
104
+ m.commitPct != null ? `commit ${m.commitPct}%` : null,
105
+ m.swapTotalGb ? `swap ${m.swapUsedGb.toFixed(1)}/${m.swapTotalGb.toFixed(1)} GB` : null,
106
+ ].filter(Boolean).join(' ')
107
+ const memText = `${m.freeGb.toFixed(1)} GB free of ${m.totalGb.toFixed(1)} GB` + (extra ? ` ${extra}` : '')
108
+ const barWidth = Math.max(0, Math.min(30, width - memText.length - 7))
109
+ const barStyle = low ? 'red' : tight ? 'yellow' : 'green'
110
+ lines.push((barWidth >= 5 ? `RAM ${c(barStyle, meter(used / m.totalGb, barWidth))} ` : 'RAM ') + fit(memText, width - (barWidth >= 5 ? barWidth + 6 : 4)))
111
+ if (low) lines.push(c('red', fit(`below ${lim.minFreeGb} GB: memory-guard is blocking heavy commands`, width)))
112
+ lines.push('')
113
+
114
+ // sessions
115
+ const sessions = st.sessions
116
+ const cols = layout(width)
117
+ lines.push(c('bold', fit(`SESSIONS ${sessions.length}`, width)))
118
+ lines.push(c('dim', cols.map(col => cell(col.title, col.width, col.right)).join(' ').trimEnd()))
119
+ const maxRss = Math.max(1, ...sessions.map(s => s.rss))
120
+
121
+ // what has to fit below the session rows
122
+ const slotRows = st.slots.length + (st.queue || []).length
123
+ const events = view.events || []
124
+ const below = 2 + (slotRows ? slotRows + 1 : 1) + 2 + Math.max(1, events.length)
125
+ const room = Math.max(1, height - lines.length - below)
126
+ const shown = sessions.length > room ? sessions.slice(0, Math.max(0, room - 1)) : sessions
127
+ for (const s of shown) {
128
+ const parts = cols.map(col => {
129
+ switch (col.key) {
130
+ case 'pid': return cell(s.pid, col.width)
131
+ case 'started': return cell(when(s.start, now), col.width)
132
+ case 'project': return cell(shortPath(s.cwd, home), col.width)
133
+ case 'rss': return cell(size(s.rss), col.width, true)
134
+ case 'bar': return c('cyan', meter(s.rss / maxRss, col.width))
135
+ case 'procs': return cell(s.children == null ? '-' : s.children, col.width, true)
136
+ case 'ports': return cell(s.ports.map(p => `:${p.port}`).join(' ') || '-', col.width)
137
+ }
138
+ return ''
139
+ })
140
+ lines.push(parts.join(' ').trimEnd())
141
+ }
142
+ if (shown.length < sessions.length) lines.push(c('dim', fit(`... ${sessions.length - shown.length} more`, width)))
143
+ if (!sessions.length) lines.push(c('dim', 'no Claude sessions found'))
144
+ lines.push('')
145
+
146
+ // slots and queue
147
+ const queue = st.queue || []
148
+ lines.push(c('bold', fit(`HEAVY JOBS ${st.slots.length} running, ${queue.length} waiting`, width)))
149
+ for (const s of st.slots) {
150
+ lines.push(fit(`${cell(s.slot, 7)} ${cell(`${s.gb} GB`, 6)} ${cell(s.pid, 7)} ${cell(`${Math.round((now - s.at) / 60000)} min`, 7)} ${s.label}`, width))
151
+ }
152
+ queue.forEach((q, i) => {
153
+ lines.push(c('yellow', fit(`${cell(`wait #${i + 1}`, 7)} ${cell(`${q.gb} GB`, 6)} ${cell(q.pid, 7)} ${cell(`${Math.round((now - q.at) / 60000)} min`, 7)} ${q.label} (${q.reason})`, width)))
154
+ })
155
+ if (!slotRows) lines.push(c('dim', 'none'))
156
+ lines.push('')
157
+
158
+ // recent blocks
159
+ lines.push(c('bold', 'RECENT BLOCKS'))
160
+ if (!events.length) lines.push(c('dim', 'none recorded'))
161
+ for (const e of events.slice().reverse()) {
162
+ const t = new Date(e.time)
163
+ const hm = `${String(t.getHours()).padStart(2, '0')}:${String(t.getMinutes()).padStart(2, '0')}`
164
+ lines.push(fit(`${hm} ${cell(e.guard, 14)} ${cell(e.rule || '', 14)} ${cell(shortPath(e.project, home), 18)} ${e.command || ''}`, width))
165
+ }
166
+ return lines.slice(0, Math.max(1, height))
167
+ }
168
+
169
+ // The process scan in a worker thread, so the screen and keys never wait for it.
170
+ function workerScanner() {
171
+ const { Worker } = require('worker_threads')
172
+ let worker = null
173
+ let pending = null
174
+ const start = () => {
175
+ worker = new Worker(path.join(__dirname, 'top-worker.js'))
176
+ worker.unref()
177
+ worker.on('message', msg => {
178
+ const p = pending
179
+ pending = null
180
+ if (p) (msg.ok ? p.resolve(msg) : p.reject(Object.assign(new Error(msg.error), { ms: msg.ms })))
181
+ })
182
+ worker.on('error', e => {
183
+ const p = pending
184
+ pending = null
185
+ worker = null
186
+ if (p) p.reject(e)
187
+ })
188
+ }
189
+ return {
190
+ scan() {
191
+ if (!worker) start()
192
+ return new Promise((resolve, reject) => {
193
+ pending = { resolve, reject }
194
+ worker.postMessage('scan')
195
+ })
196
+ },
197
+ close() {
198
+ if (worker) worker.terminate()
199
+ worker = null
200
+ },
201
+ }
202
+ }
203
+
204
+ // Live loop. Resolves when the user quits. Every dependency can be replaced for tests.
205
+ function live({
206
+ stdin = process.stdin, stdout = process.stdout, env = process.env, interval = defaultInterval(),
207
+ scanner = workerScanner(), events = () => tailEvents(stateDir(), 5), proc = process,
208
+ } = {}) {
209
+ const color = useColor(stdout, env)
210
+ const view = { st: null, scanning: false, lastScanMs: null, interval, tick: 0, events: [], error: null }
211
+ let timer = null
212
+ let clock = null
213
+ let closed = false
214
+ let again = false
215
+ let resolveDone
216
+ const done = new Promise(r => (resolveDone = r))
217
+
218
+ const draw = () => {
219
+ if (closed) return
220
+ view.now = Date.now()
221
+ const lines = frame(view, { width: stdout.columns || 80, height: stdout.rows || 24, color })
222
+ stdout.write(HOME + lines.map(l => l + CLEAR_EOL).join('\r\n') + CLEAR_EOS)
223
+ }
224
+
225
+ const refresh = () => {
226
+ if (closed) return
227
+ if (view.scanning) { again = true; return }
228
+ view.scanning = true
229
+ draw()
230
+ scanner.scan().then(msg => {
231
+ view.st = msg.st
232
+ view.lastScanMs = msg.ms
233
+ view.error = null
234
+ }, e => {
235
+ view.error = e.message
236
+ }).then(() => {
237
+ view.scanning = false
238
+ try { view.events = events() } catch { view.events = [] }
239
+ draw()
240
+ if (again) {
241
+ again = false
242
+ refresh()
243
+ }
244
+ })
245
+ }
246
+
247
+ const cleanup = () => {
248
+ if (closed) return
249
+ closed = true
250
+ clearInterval(timer)
251
+ clearInterval(clock)
252
+ try { scanner.close() } catch {}
253
+ stdin.removeListener('data', onKey)
254
+ stdout.removeListener('resize', draw)
255
+ try { if (stdin.isTTY && stdin.setRawMode) stdin.setRawMode(false) } catch {}
256
+ try { stdin.pause() } catch {}
257
+ stdout.write(SHOW + ALT_OFF)
258
+ for (const [sig, fn] of signals) proc.removeListener(sig, fn)
259
+ proc.removeListener('exit', cleanup)
260
+ resolveDone()
261
+ }
262
+
263
+ const onKey = data => {
264
+ const s = String(data)
265
+ if (s === 'q' || s === 'Q' || s === '\x03' || s === '\x1b' || s === '\x04') cleanup()
266
+ else if (s === 'r' || s === 'R') refresh()
267
+ }
268
+
269
+ const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'].map(sig => [sig, () => { cleanup(); proc.exit(130) }])
270
+ for (const [sig, fn] of signals) proc.on(sig, fn)
271
+ proc.on('exit', cleanup)
272
+
273
+ stdout.write(ALT_ON + HIDE + `${ESC}2J`)
274
+ try { if (stdin.isTTY && stdin.setRawMode) stdin.setRawMode(true) } catch {}
275
+ stdin.setEncoding && stdin.setEncoding('utf8')
276
+ stdin.on('data', onKey)
277
+ stdin.resume()
278
+ stdout.on('resize', draw)
279
+ timer = setInterval(refresh, interval)
280
+ clock = setInterval(() => {
281
+ view.tick++
282
+ draw()
283
+ }, 1000)
284
+ refresh()
285
+ return done
286
+ }
287
+
288
+ function snapshot({ stdout = process.stdout, env = process.env } = {}) {
289
+ const { collect } = require('./status')
290
+ const started = Date.now()
291
+ const st = collect()
292
+ const view = { st, scanning: false, lastScanMs: Date.now() - started, interval: defaultInterval(), events: tailEvents(stateDir(), 5) }
293
+ stdout.write(frame(view, { width: stdout.columns || 100, height: Infinity, color: useColor(stdout, env) }).join('\n') + '\n')
294
+ }
295
+
296
+ function main(args, { stdin = process.stdin, stdout = process.stdout, env = process.env } = {}) {
297
+ if (args.includes('--help') || args.includes('-h')) return stdout.write(USAGE + '\n')
298
+ if (rejectArgs('top', args, { flags: ['once'], valued: ['interval'] }, process.stderr, USAGE)) return
299
+ if (args.includes('--once') || !stdout.isTTY || !stdin.isTTY) return snapshot({ stdout, env })
300
+ const sec = Number(option(args, 'interval'))
301
+ const interval = sec >= 0.5 ? sec * 1000 : defaultInterval()
302
+ return live({ stdin, stdout, env, interval }).then(() => process.exit(0))
303
+ }
304
+
305
+ module.exports = { main, frame, layout, live, snapshot, workerScanner, defaultInterval, ALT_ON, ALT_OFF, SHOW }
@@ -0,0 +1,263 @@
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
+ // A loop inside an interpreter's code: Infinity for while loops, the count for range(N),
56
+ // N.times or for (i = 0; i < N; ...), and 1 for no loop.
57
+ function codeLoops(code) {
58
+ if (/\bwhile\b|\bloop\s+do\b|\bfor\s*\(\s*;\s*;|\bsetInterval\s*\(/.test(code)) return Infinity
59
+ let best = 1
60
+ 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))
61
+ for (const m of code.matchAll(/\b(\d+)\.times\b/g)) best = Math.max(best, Number(m[1]))
62
+ for (const m of code.matchAll(/\bfor\s*\([^;)]{0,80};\s*\w+\s*<=?\s*(\d+)/g)) best = Math.max(best, Number(m[1]))
63
+ return best
64
+ }
65
+
66
+ // Worst-case wait in seconds of python -c, node -e, ruby -e, perl -e or php -r code:
67
+ // sleep(300), time.sleep(300), sleep 300, setTimeout(f, 300000).
68
+ function codeWait(code) {
69
+ const s = []
70
+ for (const m of code.matchAll(/\bsleep\s*\(?\s*(\d+(?:\.\d+)?)\s*\)?/g)) s.push(Number(m[1]))
71
+ for (const m of code.matchAll(/\bsetTimeout\s*\(([^;]{0,300}?),\s*(\d+(?:\.\d+)?)\s*\)/g)) s.push(Number(m[2]) / 1000)
72
+ if (!s.length) return 0
73
+ const loops = codeLoops(code)
74
+ return loops > 1 ? loops * s.reduce((a, b) => Math.max(a, b), 0) : s.reduce((a, b) => a + b, 0)
75
+ }
76
+
77
+ const INTERPRETER = /^(?:python[\d.]*|py|pythonw|node|deno|bun|ruby|perl|php)$/
78
+ const INTERPRETER_HEREDOC = /(?:^|[\s;&|(])(?:python[\d.]*|py|node|deno|bun|ruby|perl|php)(?:\.exe)?(?:\s+-)?\s*$/
79
+ const CODE_FLAG = { python: /^-c$/, node: /^(?:-e|--eval|-p|--print)$/, ruby: /^-e$/, perl: /^-[Ee]$/, php: /^-r$/ }
80
+
81
+ function codeOf(argv) {
82
+ const b = shell.bin(argv[0])
83
+ if (!INTERPRETER.test(b)) return null
84
+ const kind = /^py/.test(b) ? 'python' : /^(?:node|deno|bun)$/.test(b) ? 'node' : b
85
+ if (/^(?:deno|bun)$/.test(b) && argv[1] === 'eval') return argv[2] || null
86
+ const i = argv.findIndex((a, k) => k > 0 && CODE_FLAG[kind].test(a))
87
+ return i > 0 ? argv[i + 1] || null : null
88
+ }
89
+
90
+ // Inert commands only print or search text: echo "sleep 600", git log -S "sleep 300".
91
+ const INERT = /^(?:echo|printf|git|grep|egrep|rg|write-host|write-output|select-string|sls)$/i
92
+ const BACKGROUND = /^(?:start-job|sajb|start-threadjob)$/i
93
+
94
+ // Blanks out inert commands, keeping every offset, and collects interpreter code waits.
95
+ function scan(text, mode) {
96
+ const { cmds } = shell.lex(text, mode)
97
+ const cut = []
98
+ const code = []
99
+ let follow = false
100
+ for (const c of cmds) {
101
+ const s = shell.strip(text, c.words, mode)
102
+ if (!s.argv.length) continue
103
+ const argv = s.argv.map(w => w.v)
104
+ // inert commands, and PowerShell jobs, which run in the background like `&`
105
+ // and `docker exec -d` or `docker run -d`, which return as soon as the container starts the command
106
+ const detached = s.remote && argv.some(v => v === '-d' || v === '--detach')
107
+ if (INERT.test(shell.bin(argv[0])) || BACKGROUND.test(argv[0]) || detached) { cut.push([s.argv[0].s, c.e]); continue }
108
+ const src = codeOf(argv)
109
+ if (src) code.push(codeWait(src))
110
+ if (follows(argv)) follow = true
111
+ }
112
+ if (!cut.length) return { text, code, follow }
113
+ cut.sort((a, b) => a[0] - b[0])
114
+ let out = ''
115
+ let last = 0
116
+ for (const [s, e] of cut) {
117
+ if (e <= last) continue
118
+ const from = Math.max(s, last)
119
+ out += text.slice(last, from) + ' '.repeat(e - from)
120
+ last = e
121
+ }
122
+ return { text: out + text.slice(last), code, follow }
123
+ }
124
+
125
+ // Upper bound on iterations, or Infinity when the loop has no fixed count.
126
+ function loopCount(cmd) {
127
+ if (/\b(until|while)\b/i.test(cmd)) return Infinity
128
+ let best = null
129
+ const take = n => (best = best === null ? n : Math.max(best, n))
130
+ for (const m of cmd.matchAll(/\bseq\s+(?:(\d+)\s+)?(?:(\d+)\s+)?(\d+)/g)) {
131
+ const nums = [m[1], m[2], m[3]].filter(Boolean).map(Number)
132
+ if (nums.length === 1) take(nums[0])
133
+ else if (nums.length === 2) take(nums[1] - nums[0] + 1)
134
+ else take(Math.floor((nums[2] - nums[0]) / nums[1]) + 1)
135
+ }
136
+ for (const m of cmd.matchAll(/\{(\d+)\.\.(\d+)\}/g)) take(Number(m[2]) - Number(m[1]) + 1)
137
+ // for x in a b c; do: the list runs to the next `;`. A `for` inside a list already read is
138
+ // skipped, so the text is read once however many `for`s it holds.
139
+ let listEnd = -1
140
+ for (const m of cmd.matchAll(/\bfor\s+\w+\s+in\s+/g)) {
141
+ const from = m.index + m[0].length
142
+ if (from <= listEnd) continue
143
+ listEnd = cmd.indexOf(';', from)
144
+ if (listEnd < 0) break
145
+ const list = cmd.slice(from, listEnd)
146
+ if (!/[$`(]/.test(list) && /^;\s*do\b/.test(cmd.slice(listEnd, listEnd + 16))) take(list.trim().split(/\s+/).length)
147
+ }
148
+ // bounded repeat: an unbounded [^;]* rescans to the end of the text from every `for (`
149
+ for (const m of cmd.matchAll(/\bfor\s*\(\(?[^;]{0,200};\s*\$?\w+\s*(?:<=?|-lt|-le)\s*(\d+)/gi)) take(Number(m[1]))
150
+ for (const m of cmd.matchAll(/\b(\d+)\.\.(\d+)\b/g)) take(Number(m[2]) - Number(m[1]) + 1)
151
+ return best === null ? Infinity : best
152
+ }
153
+
154
+ // for/while/until ... do, PowerShell loops and ForEach-Object. Two plain searches instead of
155
+ // /\b(for|while|until)\b[\s\S]*?\bdo\b/, which rescans to the end from every unmatched `for`.
156
+ function hasLoop(cmd) {
157
+ const head = /\b(?:for|while|until)\b/.exec(cmd)
158
+ return (head && /\bdo\b/.test(cmd.slice(head.index))) ||
159
+ /\b(while|for|foreach|do)\s*[({]/i.test(cmd) ||
160
+ /ForEach-Object|\|\s*%\s*\{/i.test(cmd)
161
+ }
162
+
163
+ const QUOTED = /"[^"]*"|'[^']*'/g
164
+
165
+ const OPENERS = new Set(['do', 'if', 'case'])
166
+ const CLOSERS = new Set(['done', 'fi', 'esac'])
167
+
168
+ // Splits a shell line into top-level lists, each with the operator that ends it
169
+ // (';', '\n', '&' or ''). && and || stay inside a list, as in bash, where `a && b &`
170
+ // backgrounds both. Quotes, loops, groups and subshells are kept whole.
171
+ function topLevelLists(cmd) {
172
+ const out = []
173
+ let depth = 0
174
+ let start = 0
175
+ let quote = null
176
+ const push = (end, op) => {
177
+ out.push({ text: cmd.slice(start, end), op })
178
+ start = end + op.length
179
+ }
180
+ for (let i = 0; i < cmd.length; i++) {
181
+ const c = cmd[i]
182
+ if (quote) {
183
+ if (c === quote) quote = null
184
+ else if (c === '\\' && quote === '"') i++
185
+ continue
186
+ }
187
+ if (c === '\\') { i++; continue }
188
+ if (c === '"' || c === "'") { quote = c; continue }
189
+ if (c === '(' || c === '{') { depth++; continue }
190
+ if (c === ')' || c === '}') { depth = Math.max(0, depth - 1); continue }
191
+ if (/[a-z]/i.test(c) && (i === 0 || /[\s;&|]/.test(cmd[i - 1]))) {
192
+ const word = /^[\w.-]+/.exec(cmd.slice(i, i + 64))[0]
193
+ if (OPENERS.has(word)) depth++
194
+ else if (CLOSERS.has(word)) depth = Math.max(0, depth - 1)
195
+ i += word.length - 1
196
+ continue
197
+ }
198
+ if (depth) continue
199
+ if (c === ';' || c === '\n') push(i, c)
200
+ else if (c === '&') {
201
+ const prev = cmd[i - 1]
202
+ const next = cmd[i + 1]
203
+ // &&, >&, &>, |&, 2>&1 are not background operators
204
+ if (next === '&') { i++; continue }
205
+ if (prev === '>' || prev === '|' || prev === '<' || next === '>') continue
206
+ push(i, '&')
207
+ }
208
+ }
209
+ if (start < cmd.length) out.push({ text: cmd.slice(start), op: '' })
210
+ return out
211
+ }
212
+
213
+ // A list ending in a single & runs as a background job: the tool call returns without waiting
214
+ // for it, so its sleeps cost nothing. A later `wait` brings them back into the foreground.
215
+ function dropBackgrounded(cmd) {
216
+ const lists = topLevelLists(cmd)
217
+ if (!lists.some(l => l.op === '&')) return cmd
218
+ if (lists.some(l => l.op !== '&' && /^\s*wait\b/.test(l.text))) return cmd
219
+ return lists.filter(l => l.op !== '&').map(l => l.text).join('\n')
220
+ }
221
+
222
+ // Worst-case wait in seconds, capped by the tool timeout. Returns null when within the limit.
223
+ function judge(cmd, timeoutMs, maxSec = 180, mode = 'sh') {
224
+ const opened = shell.unwrap(shell.prepare(String(cmd || ''), mode), mode, 0, { remote: true })
225
+ const { text, code, follow } = scan(dropBackgrounded(opened), mode)
226
+ // python3 - <<'EOF' ... EOF: the body is the interpreter's code
227
+ if (opened.includes('<<')) {
228
+ for (const d of shell.heredocs(opened, prefix => INTERPRETER_HEREDOC.test(prefix))) code.push(codeWait(d.body))
229
+ }
230
+ // Quoted text is an argument, not shell code: a `for (` inside node -e "..." is not a loop.
231
+ const bare = text.replace(QUOTED, '_')
232
+ const s = sleeps(bare).concat(code.filter(n => n > 0))
233
+ if (follow) s.push(Infinity)
234
+ if (!s.length) return null
235
+ const wrapped = bare.match(/\b(?:g?timeout)\s+(?:-\w+\s+)*(\d+)s?\b/)
236
+ if (wrapped && Number(wrapped[1]) <= maxSec) return null
237
+ const cap = Number(timeoutMs) > 0 ? Number(timeoutMs) / 1000 : DEFAULT_TOOL_TIMEOUT_S
238
+ const loop = hasLoop(bare)
239
+ const worst = loop ? loopCount(bare) * s.reduce((a, b) => Math.max(a, b), 0) : s.reduce((a, b) => a + b, 0)
240
+ const est = Math.min(worst, cap)
241
+ return est > maxSec ? { est, maxSec, rule: follow && !loop ? 'follow' : loop ? 'poll-loop' : 'long-sleep' } : null
242
+ }
243
+
244
+ function message({ est, maxSec }) {
245
+ return `[bulkhead] wait-guard: this command can sit in the foreground for about ${Math.round(est)}s (limit ${maxSec}s).\n` +
246
+ 'A foreground wait holds the session: it cannot answer the user or do anything else until the command returns. ' +
247
+ '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' +
248
+ 'Run the same loop with run_in_background: true and end your turn; you are notified once when it exits. For example:\n' +
249
+ ' until [ -f out/DONE ]; do sleep 15; done; tail -20 out/log.txt\n' +
250
+ 'For a one-off check, skip the sleep and read the output file now.'
251
+ }
252
+
253
+ if (require.main === module) {
254
+ runHook('wait-guard', input => {
255
+ const ti = input.tool_input || {}
256
+ if (ti.run_in_background || typeof ti.command !== 'string') return
257
+ const verdict = judge(ti.command, ti.timeout, setting('waitGuard.limitSeconds', 180), shell.modeFor(input))
258
+ if (!verdict) return
259
+ return blocked('wait-guard', verdict.rule, input, `${message(verdict)}\n${ASK_USER}`)
260
+ })
261
+ }
262
+
263
+ module.exports = { judge, message, topLevelLists, dropBackgrounded }