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