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.
- package/CHANGELOG.md +141 -0
- package/LICENSE +21 -0
- package/README.es.md +590 -0
- package/README.ja.md +590 -0
- package/README.ko.md +590 -0
- package/README.md +589 -2
- package/README.pt-BR.md +590 -0
- package/README.zh-CN.md +590 -0
- package/bin/bulkhead +3 -0
- package/bin/bulkhead-slot +3 -0
- package/bin/bulkhead-statusline +3 -0
- package/hooks/hooks.json +99 -0
- package/package.json +55 -4
- package/src/agent-hook.js +162 -0
- package/src/agent-setup.js +256 -0
- package/src/agents.js +89 -0
- package/src/allow-cli.js +65 -0
- package/src/allow-hint.js +62 -0
- package/src/allow-once.js +152 -0
- package/src/auto-queue.js +93 -0
- package/src/bash-guards.js +36 -0
- package/src/basics.js +255 -0
- package/src/checkout-guard.js +564 -0
- package/src/cli-util.js +91 -0
- package/src/cli.js +77 -0
- package/src/config-check.js +46 -0
- package/src/config-cli.js +116 -0
- package/src/config.js +333 -0
- package/src/dev-servers.js +523 -0
- package/src/disk-guard.js +200 -0
- package/src/doctor-enforcement.js +273 -0
- package/src/doctor.js +265 -0
- package/src/edit-guard.js +196 -0
- package/src/events.js +133 -0
- package/src/explain.js +251 -0
- package/src/git-info.js +55 -0
- package/src/gitargs.js +82 -0
- package/src/hook.js +126 -0
- package/src/kill-extras.js +219 -0
- package/src/kill-guard.js +586 -0
- package/src/kill-owners.js +116 -0
- package/src/log-cli.js +151 -0
- package/src/mcp.js +131 -0
- package/src/memory-guard.js +247 -0
- package/src/memory.js +95 -0
- package/src/notices.js +95 -0
- package/src/orphan-reaper.js +574 -0
- package/src/paths.js +31 -0
- package/src/port-cli.js +134 -0
- package/src/port-guard.js +154 -0
- package/src/port-kills.js +198 -0
- package/src/pressure.js +75 -0
- package/src/processes.js +213 -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-report.js +166 -0
- package/src/scan.js +673 -0
- package/src/secrets-guard.js +301 -0
- package/src/self-allow.js +73 -0
- package/src/session-info.js +121 -0
- package/src/session-state.js +176 -0
- package/src/sessions.js +236 -0
- package/src/shell.js +857 -0
- package/src/slot-cli.js +221 -0
- package/src/slots.js +216 -0
- package/src/stable-port.js +61 -0
- package/src/status.js +149 -0
- package/src/statusline.js +98 -0
- package/src/subagent-guard.js +24 -0
- package/src/top-worker.js +14 -0
- package/src/top.js +330 -0
- package/src/wait-guard.js +327 -0
- package/src/worktree-cli.js +454 -0
- 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 }
|