claude-bulkhead 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.es.md +142 -50
  3. package/README.ja.md +142 -50
  4. package/README.ko.md +143 -51
  5. package/README.md +142 -50
  6. package/README.pt-BR.md +142 -50
  7. package/README.zh-CN.md +154 -62
  8. package/hooks/hooks.json +68 -8
  9. package/package.json +5 -2
  10. package/src/agent-hook.js +162 -0
  11. package/src/agent-setup.js +256 -0
  12. package/src/agents.js +89 -0
  13. package/src/allow-cli.js +65 -0
  14. package/src/allow-hint.js +62 -0
  15. package/src/allow-once.js +152 -0
  16. package/src/auto-queue.js +93 -0
  17. package/src/bash-guards.js +36 -0
  18. package/src/basics.js +46 -27
  19. package/src/checkout-guard.js +342 -74
  20. package/src/cli-util.js +13 -1
  21. package/src/cli.js +40 -4
  22. package/src/config-check.js +21 -4
  23. package/src/config-cli.js +75 -10
  24. package/src/config.js +106 -9
  25. package/src/dev-servers.js +361 -61
  26. package/src/disk-guard.js +8 -8
  27. package/src/doctor-enforcement.js +273 -0
  28. package/src/doctor.js +19 -9
  29. package/src/edit-guard.js +196 -0
  30. package/src/events.js +30 -5
  31. package/src/explain.js +251 -0
  32. package/src/git-info.js +55 -0
  33. package/src/gitargs.js +82 -0
  34. package/src/hook.js +38 -6
  35. package/src/kill-extras.js +219 -0
  36. package/src/kill-guard.js +228 -77
  37. package/src/kill-owners.js +116 -0
  38. package/src/log-cli.js +15 -10
  39. package/src/mcp.js +131 -0
  40. package/src/memory-guard.js +126 -24
  41. package/src/memory.js +21 -2
  42. package/src/notices.js +95 -0
  43. package/src/orphan-reaper.js +58 -11
  44. package/src/port-cli.js +78 -3
  45. package/src/port-guard.js +80 -68
  46. package/src/port-kills.js +198 -0
  47. package/src/pressure.js +75 -0
  48. package/src/processes.js +44 -12
  49. package/src/scan-report.js +166 -0
  50. package/src/scan.js +87 -18
  51. package/src/secrets-guard.js +301 -0
  52. package/src/self-allow.js +73 -0
  53. package/src/session-info.js +121 -0
  54. package/src/session-state.js +176 -0
  55. package/src/sessions.js +109 -20
  56. package/src/shell.js +279 -19
  57. package/src/slot-cli.js +92 -21
  58. package/src/slots.js +61 -13
  59. package/src/stable-port.js +61 -0
  60. package/src/status.js +55 -17
  61. package/src/statusline.js +45 -16
  62. package/src/subagent-guard.js +24 -0
  63. package/src/top.js +36 -11
  64. package/src/wait-guard.js +96 -32
  65. package/src/worktree-cli.js +454 -0
  66. package/src/worktree-copy.js +82 -0
package/src/top.js CHANGED
@@ -5,7 +5,10 @@ const { stateDir } = require('./hook')
5
5
  const { tailEvents } = require('./events')
6
6
  const { limits } = require('./slots')
7
7
  const { size, when } = require('./status')
8
- const { useColor, paint, shortPath, option, fit, rejectArgs } = require('./cli-util')
8
+ const { useColor, paint, fit, rejectArgs, numberOption } = require('./cli-util')
9
+ const { homeShort, fitPath, stateLabel, needsUserFirst, branchLabel, mcpLine } = require('./session-info')
10
+
11
+ const shortPath = (p, home) => homeShort(p, home) || '(unknown)'
9
12
 
10
13
  const USAGE = `usage: bulkhead top [--interval=SECONDS] [--once]
11
14
 
@@ -43,9 +46,11 @@ const cell = (s, width, right = false) => {
43
46
  // project column shrinks. Widths are fixed except the project and ports columns.
44
47
  const COLUMNS = [
45
48
  { key: 'pid', title: 'PID', width: 7 },
49
+ { key: 'state', title: 'STATE', width: 20 },
46
50
  { 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 },
51
+ { key: 'project', title: 'PROJECT', width: 20, flex: true },
52
+ { key: 'branch', title: 'BRANCH', width: 18, drop: 5 },
53
+ { key: 'rss', title: 'RSS', width: 7, right: true, drop: 6 },
49
54
  { key: 'bar', title: '', width: 10, drop: 1 },
50
55
  { key: 'procs', title: 'PROCS', width: 5, right: true, drop: 4 },
51
56
  { key: 'ports', title: 'PORTS', width: 6, grow: 22, drop: 3 },
@@ -54,11 +59,14 @@ const COLUMNS = [
54
59
  function layout(width) {
55
60
  let cols = COLUMNS.slice()
56
61
  const total = cs => cs.reduce((n, c) => n + c.width, 0) + (cs.length - 1) * 2
57
- for (const d of [1, 2, 3, 4]) {
62
+ for (const d of [1, 2, 3, 4, 5, 6]) {
58
63
  if (total(cols) <= width) break
59
64
  cols = cols.filter(c => c.drop !== d)
60
65
  }
61
66
  cols = cols.map(c => ({ ...c }))
67
+ // the state text shrinks (needs perm…) before the project column is squeezed out
68
+ const state = cols.find(c => c.key === 'state')
69
+ if (state && total(cols) > width) state.width = Math.max(7, state.width - (total(cols) - width))
62
70
  let spare = width - total(cols)
63
71
  const ports = cols.find(c => c.key === 'ports')
64
72
  if (ports && spare > 0) {
@@ -112,24 +120,31 @@ function frame(view, { width = 100, height = 40, color = false, home = os.homedi
112
120
  lines.push('')
113
121
 
114
122
  // sessions
115
- const sessions = st.sessions
123
+ const sessions = needsUserFirst(st.sessions)
116
124
  const cols = layout(width)
117
- lines.push(c('bold', fit(`SESSIONS ${sessions.length}`, width)))
125
+ const needy = sessions.filter(s => s.state === 'permission' || s.state === 'waiting').length
126
+ lines.push(c('bold', fit(`SESSIONS ${sessions.length}`, width)) + (needy && width >= 24 + 12 ? c('yellow', ` ${needy} waiting for you`) : ''))
118
127
  lines.push(c('dim', cols.map(col => cell(col.title, col.width, col.right)).join(' ').trimEnd()))
119
128
  const maxRss = Math.max(1, ...sessions.map(s => s.rss))
120
129
 
121
130
  // what has to fit below the session rows
122
131
  const slotRows = st.slots.length + (st.queue || []).length
123
132
  const events = view.events || []
124
- const below = 2 + (slotRows ? slotRows + 1 : 1) + 2 + Math.max(1, events.length)
133
+ const dupes = (st.mcp || []).filter(g => g.duplicate)
134
+ const below = 2 + (slotRows ? slotRows + 1 : 1) + 2 + Math.max(1, events.length) + (dupes.length ? dupes.length + 2 : 0)
125
135
  const room = Math.max(1, height - lines.length - below)
126
136
  const shown = sessions.length > room ? sessions.slice(0, Math.max(0, room - 1)) : sessions
127
137
  for (const s of shown) {
128
138
  const parts = cols.map(col => {
129
139
  switch (col.key) {
130
140
  case 'pid': return cell(s.pid, col.width)
141
+ case 'state': return c(s.state === 'permission' ? 'red' : s.state === 'waiting' ? 'yellow' : s.state === 'working' ? 'green' : 'dim', cell(stateLabel(s, now), col.width))
131
142
  case 'started': return cell(when(s.start, now), col.width)
132
- case 'project': return cell(shortPath(s.cwd, home), col.width)
143
+ case 'branch': return cell(branchLabel(s), col.width)
144
+ case 'project': {
145
+ const tag = s.agent && s.agent !== 'claude' ? `[${s.agent}] ` : ''
146
+ return cell(tag + fitPath(shortPath(s.cwd, home), Math.max(4, col.width - tag.length)), col.width)
147
+ }
133
148
  case 'rss': return cell(size(s.rss), col.width, true)
134
149
  case 'bar': return c('cyan', meter(s.rss / maxRss, col.width))
135
150
  case 'procs': return cell(s.children == null ? '-' : s.children, col.width, true)
@@ -142,6 +157,11 @@ function frame(view, { width = 100, height = 40, color = false, home = os.homedi
142
157
  if (shown.length < sessions.length) lines.push(c('dim', fit(`... ${sessions.length - shown.length} more`, width)))
143
158
  if (!sessions.length) lines.push(c('dim', 'no Claude sessions found'))
144
159
  lines.push('')
160
+ if (dupes.length) {
161
+ lines.push(c('bold', 'DUPLICATE MCP SERVERS'))
162
+ for (const g of dupes) lines.push(c('yellow', fit(`${mcpLine(g, size)} sessions ${g.sessions.join(', ')}`, width)))
163
+ lines.push('')
164
+ }
145
165
 
146
166
  // slots and queue
147
167
  const queue = st.queue || []
@@ -296,10 +316,15 @@ function snapshot({ stdout = process.stdout, env = process.env } = {}) {
296
316
  function main(args, { stdin = process.stdin, stdout = process.stdout, env = process.env } = {}) {
297
317
  if (args.includes('--help') || args.includes('-h')) return stdout.write(USAGE + '\n')
298
318
  if (rejectArgs('top', args, { flags: ['once'], valued: ['interval'] }, process.stderr, USAGE)) return
319
+ const every = numberOption(args, 'interval', { min: 0.5, inclusive: true })
320
+ if (every.error) {
321
+ process.stderr.write(`bulkhead top: ${every.error}\n`)
322
+ process.exitCode = 1
323
+ return
324
+ }
299
325
  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()
326
+ const interval = every.value ? every.value * 1000 : defaultInterval()
302
327
  return live({ stdin, stdout, env, interval }).then(() => process.exit(0))
303
328
  }
304
329
 
305
- module.exports = { main, frame, layout, live, snapshot, workerScanner, defaultInterval, ALT_ON, ALT_OFF, SHOW }
330
+ module.exports = { main, frame, layout, live, snapshot, workerScanner, defaultInterval, ALT_ON, ALT_OFF, SHOW, USAGE }
package/src/wait-guard.js CHANGED
@@ -52,6 +52,43 @@ function follows(argv) {
52
52
  return false
53
53
  }
54
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
+
55
92
  // A loop inside an interpreter's code: Infinity for while loops, the count for range(N),
56
93
  // N.times or for (i = 0; i < N; ...), and 1 for no loop.
57
94
  function codeLoops(code) {
@@ -63,28 +100,34 @@ function codeLoops(code) {
63
100
  return best
64
101
  }
65
102
 
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)
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)
72
113
  if (!s.length) return 0
73
- const loops = codeLoops(code)
114
+ const loops = codeLoops(text)
74
115
  return loops > 1 ? loops * s.reduce((a, b) => Math.max(a, b), 0) : s.reduce((a, b) => a + b, 0)
75
116
  }
76
117
 
118
+ const INTERPRETER_WORD = /\b(?:python[\d.]*|py|pythonw|node|nodejs|deno|bun|ruby|perl|php|lua)\b/
77
119
  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
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')
80
122
 
123
+ // { code, lang } of an interpreter command line, or null.
81
124
  function codeOf(argv) {
82
125
  const b = shell.bin(argv[0])
83
126
  if (!INTERPRETER.test(b)) return null
84
127
  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
128
+ if (/^(?:deno|bun)$/.test(b) && argv[1] === 'eval') return argv[2] ? { code: argv[2], lang: 'js' } : null
86
129
  const i = argv.findIndex((a, k) => k > 0 && CODE_FLAG[kind].test(a))
87
- return i > 0 ? argv[i + 1] || null : null
130
+ return i > 0 && argv[i + 1] ? { code: argv[i + 1], lang: langOf(b) } : null
88
131
  }
89
132
 
90
133
  // Inert commands only print or search text: echo "sleep 600", git log -S "sleep 300".
@@ -106,7 +149,7 @@ function scan(text, mode) {
106
149
  const detached = s.remote && argv.some(v => v === '-d' || v === '--detach')
107
150
  if (INERT.test(shell.bin(argv[0])) || BACKGROUND.test(argv[0]) || detached) { cut.push([s.argv[0].s, c.e]); continue }
108
151
  const src = codeOf(argv)
109
- if (src) code.push(codeWait(src))
152
+ if (src) code.push(codeWait(src.code, src.lang))
110
153
  if (follows(argv)) follow = true
111
154
  }
112
155
  if (!cut.length) return { text, code, follow }
@@ -160,6 +203,13 @@ function hasLoop(cmd) {
160
203
  /ForEach-Object|\|\s*%\s*\{/i.test(cmd)
161
204
  }
162
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
+
163
213
  const QUOTED = /"[^"]*"|'[^']*'/g
164
214
 
165
215
  const OPENERS = new Set(['do', 'if', 'case'])
@@ -170,7 +220,9 @@ const CLOSERS = new Set(['done', 'fi', 'esac'])
170
220
  // backgrounds both. Quotes, loops, groups and subshells are kept whole.
171
221
  function topLevelLists(cmd) {
172
222
  const out = []
173
- let depth = 0
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 = []
174
226
  let start = 0
175
227
  let quote = null
176
228
  const push = (end, op) => {
@@ -186,16 +238,19 @@ function topLevelLists(cmd) {
186
238
  }
187
239
  if (c === '\\') { i++; continue }
188
240
  if (c === '"' || c === "'") { quote = c; continue }
189
- if (c === '(' || c === '{') { depth++; continue }
190
- if (c === ')' || c === '}') { depth = Math.max(0, depth - 1); 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
+ }
191
246
  if (/[a-z]/i.test(c) && (i === 0 || /[\s;&|]/.test(cmd[i - 1]))) {
192
247
  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)
248
+ if (OPENERS.has(word)) open.push('w')
249
+ else if (CLOSERS.has(word) && open[open.length - 1] === 'w') open.pop()
195
250
  i += word.length - 1
196
251
  continue
197
252
  }
198
- if (depth) continue
253
+ if (open.length) continue
199
254
  if (c === ';' || c === '\n') push(i, c)
200
255
  else if (c === '&') {
201
256
  const prev = cmd[i - 1]
@@ -221,22 +276,31 @@ function dropBackgrounded(cmd) {
221
276
 
222
277
  // Worst-case wait in seconds, capped by the tool timeout. Returns null when within the limit.
223
278
  function judge(cmd, timeoutMs, maxSec = 180, mode = 'sh') {
224
- const opened = shell.unwrap(shell.prepare(String(cmd || ''), mode), mode, 0, { remote: true })
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 })
225
282
  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))
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
+ }
229
292
  }
230
293
  // Quoted text is an argument, not shell code: a `for (` inside node -e "..." is not a loop.
231
294
  const bare = text.replace(QUOTED, '_')
232
295
  const s = sleeps(bare).concat(code.filter(n => n > 0))
233
296
  if (follow) s.push(Infinity)
234
- if (!s.length) return null
297
+ if (!s.length && !after) return null
235
298
  const wrapped = bare.match(/\b(?:g?timeout)\s+(?:-\w+\s+)*(\d+)s?\b/)
236
299
  if (wrapped && Number(wrapped[1]) <= maxSec) return null
237
300
  const cap = Number(timeoutMs) > 0 ? Number(timeoutMs) / 1000 : DEFAULT_TOOL_TIMEOUT_S
238
301
  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)
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
240
304
  const est = Math.min(worst, cap)
241
305
  return est > maxSec ? { est, maxSec, rule: follow && !loop ? 'follow' : loop ? 'poll-loop' : 'long-sleep' } : null
242
306
  }
@@ -250,14 +314,14 @@ function message({ est, maxSec }) {
250
314
  'For a one-off check, skip the sleep and read the output file now.'
251
315
  }
252
316
 
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
- })
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}`)
261
323
  }
262
324
 
263
- module.exports = { judge, message, topLevelLists, dropBackgrounded }
325
+ if (require.main === module) runHook('wait-guard', handle)
326
+
327
+ module.exports = { handle, judge, message, topLevelLists, dropBackgrounded }