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/slot-cli.js CHANGED
@@ -1,10 +1,16 @@
1
1
  'use strict'
2
2
  const fs = require('fs')
3
+ const os = require('os')
3
4
  const path = require('path')
4
5
  const { spawn } = require('child_process')
5
6
  const { stateDir, log } = require('./hook')
6
7
  const { readMemory } = require('./memory')
7
- const { take, release, readSlots, staleReason, slotDir, admit, queueDir, enqueue } = require('./slots')
8
+ const { option: sharedOption, numberOption } = require('./cli-util')
9
+ const { take, release, readSlots, staleReason, slotDir, admit, queueDir, enqueue, readQueue, waitersBefore, signature } = require('./slots')
10
+
11
+ const POLL_MS = 1000
12
+ const RECHECK_MS = 5000
13
+ const PROGRESS_MS = Number(process.env.BULKHEAD_SLOT_PROGRESS_MS) || 30000
8
14
 
9
15
  const USAGE = `usage:
10
16
  bulkhead-slot run [--gb=N] [--wait=MINUTES] [--label=TEXT] -- <command...>
@@ -12,16 +18,27 @@ const USAGE = `usage:
12
18
 
13
19
  run waits until the machine has room for a job of N GB (default 3), runs the
14
20
  command, and releases the slot when it exits, even on failure. The exit code is
15
- the command's. status shows who holds slots and whether a new job would start.`
21
+ the command's. While it waits it prints one line to stderr every 30 seconds with its
22
+ position in line. --wait is in minutes (default 30); set it below the Bash tool's timeout
23
+ if the caller must not wait longer. status shows who holds slots, which session and
24
+ project each job belongs to, who waits, and whether a new job would start.`
16
25
 
26
+ // --name=value or --name value
17
27
  function option(args, name, fallback) {
18
- const hit = args.find(a => a.startsWith(`--${name}=`))
19
- return hit ? hit.slice(name.length + 3) : fallback
28
+ const v = sharedOption(args, name)
29
+ return typeof v === 'string' ? v : fallback
30
+ }
31
+
32
+ // The job size, or an error to print: --gb 4 and --gb=4 both work, nothing else is guessed.
33
+ function gbOption(args, totalGb = os.totalmem() / 2 ** 30) {
34
+ return numberOption(args, 'gb', {
35
+ fallback: 3, max: totalGb, tooBig: n => `--gb ${n} is more than this machine's RAM (${Math.round(totalGb * 10) / 10} GB)`,
36
+ })
20
37
  }
21
38
 
22
- function gbOption(args) {
23
- const n = Number(option(args, 'gb', 3))
24
- return n > 0 && n <= 256 ? n : 3
39
+ function bad(message) {
40
+ console.error(`bulkhead-slot: ${message}`)
41
+ process.exit(1)
25
42
  }
26
43
 
27
44
  function findGitBash(env = process.env, exists = fs.existsSync) {
@@ -59,20 +76,40 @@ function run(args) {
59
76
  }
60
77
  const opts = args.slice(0, sep)
61
78
  const command = args.slice(sep + 1)
62
- const gb = gbOption(opts)
79
+ const g = gbOption(opts)
80
+ if (g.error) return bad(g.error)
81
+ const gb = g.value
82
+ const w = numberOption(opts, 'wait', { fallback: 30, inclusive: true })
83
+ if (w.error) return bad(w.error)
63
84
  const label = option(opts, 'label', command.join(' ').slice(0, 80))
64
- const deadline = Date.now() + Number(option(opts, 'wait', 30)) * 60000
85
+ const deadline = Date.now() + w.value * 60000
65
86
  const dir = slotDir(stateDir())
66
- let announced = false
87
+ const me = { pid: process.pid, at: Date.now() }
88
+ const qdir = queueDir(stateDir())
67
89
  let queued = null
90
+ let lastMem = null
91
+ let lastSig = null
92
+ let lastFull = 0
93
+ let lastLine = 0
94
+ let lastReason = ''
68
95
  const unqueue = () => {
69
96
  if (queued) try { fs.unlinkSync(queued) } catch {}
70
97
  queued = null
71
98
  }
72
99
  process.on('exit', unqueue)
100
+ const measureKept = withCommit => (lastMem = measure(withCommit))
101
+
102
+ // position, what is held, what is free: enough for the agent to see the job has not hung
103
+ const progress = (verb, reason) => {
104
+ const line = waitLine(dir, qdir, me, lastMem)
105
+ console.error(`[bulkhead-slot] ${verb} (${line}): ${reason}`)
106
+ lastLine = Date.now()
107
+ }
73
108
 
74
109
  const attempt = () => {
75
- const got = take(dir, { label, gb, pid: process.pid }, measure, (slot, why) => log('slot', `reclaimed ${slot.label}: ${why}`))
110
+ const got = take(dir, { label, gb, pid: process.pid }, measureKept, (slot, why) => log('slot', `reclaimed ${slot.label}: ${why}`), waitersBefore(qdir, me))
111
+ lastFull = Date.now()
112
+ lastReason = got.reason
76
113
  if (got.file) {
77
114
  unqueue()
78
115
  return start(got.file)
@@ -81,15 +118,23 @@ function run(args) {
81
118
  console.error(`[bulkhead-slot] gave up waiting: ${got.reason}. Holders: ${holders(dir)}`)
82
119
  return process.exit(3)
83
120
  }
84
- if (!announced) {
85
- console.error(`[bulkhead-slot] waiting: ${got.reason}. Holders: ${holders(dir)}`)
86
- announced = true
121
+ const first = !queued
122
+ queued = enqueue(qdir, { label, gb, pid: process.pid, at: me.at, reason: got.reason })
123
+ if (first) {
124
+ progress('waiting', `${got.reason}. Holders: ${holders(dir)}`)
87
125
  for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(sig, () => process.exit(130))
88
126
  }
89
- queued = enqueue(queueDir(stateDir()), { label, gb, pid: process.pid, at: queuedAt, reason: got.reason })
90
- setTimeout(attempt, 15000)
127
+ lastSig = signature(dir, qdir, me)
128
+ setTimeout(poll, POLL_MS)
129
+ }
130
+
131
+ // Once a second only the slot files and the waiters ahead are read; the memory check and the
132
+ // lock are taken when one of them changed, and every few seconds for the free-RAM wait.
133
+ const poll = () => {
134
+ if (Date.now() - lastFull >= RECHECK_MS || Date.now() > deadline || signature(dir, qdir, me) !== lastSig) return attempt()
135
+ if (Date.now() - lastLine >= PROGRESS_MS) progress('still waiting', lastReason)
136
+ setTimeout(poll, POLL_MS)
91
137
  }
92
- const queuedAt = Date.now()
93
138
 
94
139
  const start = file => {
95
140
  for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.removeAllListeners(sig)
@@ -116,20 +161,46 @@ function run(args) {
116
161
  attempt()
117
162
  }
118
163
 
164
+ // "#2 of 3 in line, 8 GB held by 2 jobs, 6.1 GB free"
165
+ function waitLine(dir, qdir, me, mem) {
166
+ const live = readSlots(dir).filter(s => !staleReason(s))
167
+ const ahead = waitersBefore(qdir, me).length
168
+ const held = live.reduce((n, s) => n + (s.gb || 0), 0)
169
+ return `#${ahead + 1} in line, ${held} GB held by ${live.length} job${live.length === 1 ? '' : 's'}` +
170
+ (mem ? `, ${mem.freeGb.toFixed(1)} GB free` : '')
171
+ }
172
+
119
173
  function holders(dir) {
120
174
  return readSlots(dir).map(s => `${s.label} (${s.gb} GB, pid ${s.pid})`).join(', ') || 'none'
121
175
  }
122
176
 
177
+ // "session 812, ~/src/app": which Claude session a job runs under. Empty when the process scan fails.
178
+ function whose(jobs) {
179
+ try {
180
+ const st = require('./status').collect({ slots: jobs.slots, queue: jobs.queue, mem: { freeGb: 0, totalGb: 0 } })
181
+ const text = j => (j.session ? `, session ${j.session}${j.project ? ` in ${j.project}` : ''}` : '')
182
+ return { slot: i => text(st.slots[i]), wait: i => text(st.queue[i]) }
183
+ } catch {
184
+ return { slot: () => '', wait: () => '' }
185
+ }
186
+ }
187
+
123
188
  function status(args) {
124
189
  const dir = slotDir(stateDir())
125
- const gb = gbOption(args)
190
+ const g = gbOption(args)
191
+ if (g.error) return bad(g.error)
192
+ const gb = g.value
126
193
  const held = readSlots(dir)
194
+ const waiting = readQueue(queueDir(stateDir()))
195
+ const owner = whose({ slots: held.filter(s => !staleReason(s)), queue: waiting })
196
+ const { duration } = require('./status')
127
197
  if (!held.length) console.log('no slots held')
198
+ let liveIndex = 0
128
199
  for (const s of held) {
129
200
  const why = staleReason(s)
130
- const mins = Math.round((Date.now() - s.at) / 60000)
131
- console.log(`${path.basename(s.file)}: ${s.label} (${s.gb} GB, pid ${s.pid}, ${mins} min)${why ? ` stale: ${why}` : ''}`)
201
+ console.log(`${path.basename(s.file)}: ${s.label} (${s.gb} GB, pid ${s.pid}, ${duration(Date.now() - s.at)}${why ? '' : owner.slot(liveIndex++)})${why ? ` stale: ${why}` : ''}`)
132
202
  }
203
+ waiting.forEach((q, i) => console.log(`waiting #${i + 1}: ${q.label} (${q.gb} GB, pid ${q.pid}, waited ${duration(Date.now() - q.at)}${owner.wait(i)})${q.reason ? ` - ${q.reason}` : ''}`))
133
204
  const live = held.filter(s => !staleReason(s))
134
205
  const mem = measure(live.length > 0)
135
206
  const reason = admit({ held: live, mem, gb })
@@ -147,4 +218,4 @@ function main(argv) {
147
218
 
148
219
  if (require.main === module) main(process.argv.slice(2))
149
220
 
150
- module.exports = { main, shellCommand, findGitBash }
221
+ module.exports = { main, shellCommand, findGitBash, USAGE }
package/src/slots.js CHANGED
@@ -13,10 +13,16 @@ const SMALL_MACHINE_GB = 24
13
13
  // So the defaults are 15% and 25% of total RAM, capped at those values.
14
14
  const round1 = n => Math.round(n * 10) / 10
15
15
 
16
+ // What the two RAM limits are when the config leaves them on auto.
17
+ function autoLimits(totalGb = os.totalmem() / 2 ** 30) {
18
+ return { minFreeGb: Math.min(3, round1(totalGb * 0.15)), floorGb: Math.min(5, round1(totalGb * 0.25)) }
19
+ }
20
+
16
21
  function limits(totalGb = os.totalmem() / 2 ** 30) {
22
+ const auto = autoLimits(totalGb)
17
23
  return {
18
- minFreeGb: setting('memory.minFreeGb', Math.min(3, round1(totalGb * 0.15))),
19
- floorGb: setting('memory.slotFloorGb', Math.min(5, round1(totalGb * 0.25))),
24
+ minFreeGb: setting('memory.minFreeGb', auto.minFreeGb),
25
+ floorGb: setting('memory.slotFloorGb', auto.floorGb),
20
26
  maxCommitPct: setting('memory.maxCommitPct', 75),
21
27
  }
22
28
  }
@@ -87,6 +93,27 @@ function reclaim(dir, onReclaim = () => {}) {
87
93
  }
88
94
  }
89
95
 
96
+ // A lock older than 30 s was left by a process that died. Renaming it away is atomic, so when two
97
+ // waiters both see it only one rename succeeds; a plain rmdir let both remove it and both enter.
98
+ // If what was moved turns out to be fresh (another waiter had already replaced the stale lock),
99
+ // it is put back.
100
+ const STALE_LOCK_MS = 30000
101
+ function breakStale(lock, now = Date.now()) {
102
+ try {
103
+ if (now - fs.statSync(lock).mtimeMs <= STALE_LOCK_MS) return false
104
+ const moved = `${lock}.stale-${process.pid}-${Math.random().toString(36).slice(2)}`
105
+ fs.renameSync(lock, moved)
106
+ if (now - fs.statSync(moved).mtimeMs <= STALE_LOCK_MS) {
107
+ try { fs.renameSync(moved, lock) } catch { fs.rmdirSync(moved) }
108
+ return false
109
+ }
110
+ fs.rmdirSync(moved)
111
+ return true
112
+ } catch {
113
+ return false
114
+ }
115
+ }
116
+
90
117
  // mkdir is atomic on every platform we support, so it doubles as a cross-process lock.
91
118
  function withLock(dir, fn, timeoutMs = 15000) {
92
119
  const lock = path.join(dir, '.lock')
@@ -96,9 +123,7 @@ function withLock(dir, fn, timeoutMs = 15000) {
96
123
  fs.mkdirSync(lock)
97
124
  break
98
125
  } catch {
99
- try {
100
- if (Date.now() - fs.statSync(lock).mtimeMs > 30000) fs.rmdirSync(lock)
101
- } catch {}
126
+ breakStale(lock)
102
127
  if (Date.now() > until) return null
103
128
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100)
104
129
  }
@@ -110,23 +135,33 @@ function withLock(dir, fn, timeoutMs = 15000) {
110
135
  }
111
136
  }
112
137
 
113
- // Returns { file } on success or { reason } when the job has to wait.
114
- function take(dir, { label, gb, pid }, measure, onReclaim) {
138
+ // The oldest waiter that would fit right now goes first. A bigger job that does not fit yet does
139
+ // not hold up smaller ones behind it.
140
+ function waiterAhead(waiters, held, mem, now) {
141
+ return waiters.find(w => !admit({ held, mem, gb: w.gb, now }))
142
+ }
143
+
144
+ // Returns { file } on success or { reason } when the job has to wait. `waiters` are the queue
145
+ // entries that were waiting before this job (see readQueue); `extra` is written into the slot file.
146
+ function take(dir, { label, gb, pid, ...extra }, measure, onReclaim, waiters = []) {
115
147
  fs.mkdirSync(dir, { recursive: true })
116
148
  const result = withLock(dir, () => {
117
149
  reclaim(dir, onReclaim)
118
150
  const held = readSlots(dir)
119
- const reason = admit({ held, mem: measure(held.length > 0), gb })
120
- if (reason) return { reason }
121
- const body = JSON.stringify({ label, gb, pid, at: Date.now() })
151
+ const mem = measure(held.length > 0)
152
+ const first = waiterAhead(waiters, held, mem, Date.now())
153
+ if (first) return { reason: `${first.label} has been waiting longer and goes first`, mem }
154
+ const reason = admit({ held, mem, gb })
155
+ if (reason) return { reason, mem }
156
+ const body = JSON.stringify({ label, gb, pid, at: Date.now(), ...extra })
122
157
  for (let i = 1; i <= MAX_SLOTS; i++) {
123
158
  const file = path.join(dir, `slot-${i}.json`)
124
159
  try {
125
160
  fs.writeFileSync(file, body + '\n', { flag: 'wx' })
126
- return { file }
161
+ return { file, mem }
127
162
  } catch {}
128
163
  }
129
- return { reason: `all ${MAX_SLOTS} slots are taken` }
164
+ return { reason: `all ${MAX_SLOTS} slots are taken`, mem }
130
165
  })
131
166
  return result || { reason: 'another session is holding the slot lock' }
132
167
  }
@@ -161,8 +196,21 @@ function readQueue(dir, isAlive = alive) {
161
196
  return out.sort((a, b) => a.at - b.at)
162
197
  }
163
198
 
199
+ // Waiters older than this job, oldest first. Ties on the timestamp go to the lower PID.
200
+ function waitersBefore(dir, me, isAlive = alive) {
201
+ return readQueue(dir, isAlive).filter(q => q.pid !== me.pid && (q.at < me.at || (q.at === me.at && q.pid < me.pid)))
202
+ .sort((a, b) => a.at - b.at || a.pid - b.pid)
203
+ }
204
+
205
+ // Cheap change detector for the 1 s poll: the live slot files and the waiters ahead. When it is
206
+ // the same as last time, nothing that could let this job in has happened.
207
+ function signature(slotsDir, queuesDir, me, isAlive = alive) {
208
+ const held = readSlots(slotsDir).filter(s => s.pid && isAlive(s.pid)).map(s => path.basename(s.file)).sort()
209
+ return held.join(',') + '|' + waitersBefore(queuesDir, me, isAlive).map(q => q.pid).join(',')
210
+ }
211
+
164
212
  function release(file) {
165
213
  try { fs.unlinkSync(file) } catch {}
166
214
  }
167
215
 
168
- module.exports = { admit, take, release, reclaim, readSlots, staleReason, slotDir, limits, queueDir, enqueue, readQueue }
216
+ module.exports = { autoLimits, breakStale, withLock, waitersBefore, waiterAhead, signature, admit, take, release, reclaim, readSlots, staleReason, slotDir, limits, queueDir, enqueue, readQueue }
@@ -0,0 +1,61 @@
1
+ 'use strict'
2
+ const crypto = require('crypto')
3
+ const path = require('path')
4
+
5
+ const DEFAULT_RANGE = { min: 3000, max: 3999 }
6
+ // Ports browsers refuse to open; the same list port-cli.js uses.
7
+ const BROWSER_BLOCKED = new Set([1719, 1720, 1723, 2049, 3659, 4045, 5060, 5061, 6000, 6566, 6665, 6666, 6667, 6668, 6669, 6697, 10080])
8
+
9
+ // "3000-3999" as { min, max }, or null.
10
+ function parseRange(text) {
11
+ const m = /^(\d+)-(\d+)$/.exec(String(text || '').trim())
12
+ if (!m) return null
13
+ const min = Number(m[1])
14
+ const max = Number(m[2])
15
+ return min >= 1024 && max <= 65535 && min <= max ? { min, max } : null
16
+ }
17
+
18
+ // The string a worktree root is hashed as: its resolved path, lowercased on Windows.
19
+ function keyFor(root, platform = process.platform) {
20
+ const p = path.resolve(root)
21
+ return platform === 'win32' ? p.toLowerCase() : p
22
+ }
23
+
24
+ // The same key always starts at the same port: first 4 bytes of its sha1, as a uint32, modulo the range size.
25
+ function startPort(key, range = DEFAULT_RANGE) {
26
+ const size = range.max - range.min + 1
27
+ return range.min + (crypto.createHash('sha1').update(String(key)).digest().readUInt32BE(0) % size)
28
+ }
29
+
30
+ // Offset of a root's start port inside a range of `size` ports.
31
+ function startOffset(root, size, platform = process.platform) {
32
+ return startPort(keyFor(root, platform), { min: 0, max: size - 1 })
33
+ }
34
+
35
+ // The fixed order of candidates: the start, then upward, wrapping inside the range, browser-blocked ports left out.
36
+ function sequence(key, range = DEFAULT_RANGE) {
37
+ const size = range.max - range.min + 1
38
+ const first = startPort(key, range) - range.min
39
+ const out = []
40
+ for (let i = 0; i < size; i++) {
41
+ const p = range.min + ((first + i) % size)
42
+ if (!BROWSER_BLOCKED.has(p)) out.push(p)
43
+ }
44
+ return out
45
+ }
46
+
47
+ // Synchronous variant for callers that already know the busy ports (port-guard).
48
+ function pickFree(key, taken = new Set(), range = DEFAULT_RANGE) {
49
+ for (const p of sequence(key, range)) if (!taken.has(p)) return p
50
+ return null
51
+ }
52
+
53
+ // The stable port of a worktree root: its start port, or the next free one in the fixed order.
54
+ // null when the whole range is busy. `bind(host, port)` resolves true when the bind works.
55
+ async function stablePort(root, range = DEFAULT_RANGE, { bind, platform = process.platform } = {}) {
56
+ const { isFree, canBind } = require('./port-cli')
57
+ for (const p of sequence(keyFor(root, platform), range)) if (await isFree(p, bind || canBind)) return p
58
+ return null
59
+ }
60
+
61
+ module.exports = { stablePort, startOffset, pickFree, sequence, startPort, keyFor, parseRange, DEFAULT_RANGE, BROWSER_BLOCKED }
package/src/status.js CHANGED
@@ -5,7 +5,8 @@ const { stateDir } = require('./hook')
5
5
  const { rejectArgs } = require('./cli-util')
6
6
  const { readMemory } = require('./memory')
7
7
  const { readProcesses } = require('./processes')
8
- const { readSessionFiles, findSessions } = require('./sessions')
8
+ const { readSessionFiles, findSessions, assignOwners, isClaudeCli } = require('./sessions')
9
+ const { enrich, fitPath, homeShort, stateLabel, branchLabel, mcpLine } = require('./session-info')
9
10
  const { readSlots, slotDir, staleReason, readQueue, queueDir } = require('./slots')
10
11
 
11
12
  const GB = 2 ** 30
@@ -21,16 +22,35 @@ function collect({
21
22
  queue = readQueue(queueDir(stateDir())),
22
23
  mem = readMemory({ withCommit: true }),
23
24
  now = Date.now(),
25
+ enrichOpts = {},
24
26
  } = {}) {
27
+ const found = snapshot ? enrich(findSessions(snapshot, files), snapshot, files, enrichOpts) : { sessions: [], mcp: [] }
28
+ const sessions = found.sessions
29
+ // the Claude session a job runs under: its process is somewhere below the session's process
30
+ const owner = snapshot ? assignOwners(snapshot.procs, new Set(snapshot.procs.filter(isClaudeCli).map(p => p.pid))) : new Map()
31
+ const whose = pid => {
32
+ const session = owner.get(pid) || null
33
+ const hit = sessions.find(x => x.pid === session)
34
+ return { session, project: (hit && hit.cwd) || null }
35
+ }
25
36
  return {
26
37
  at: now,
27
- sessions: snapshot ? findSessions(snapshot, files) : [],
28
- slots: slots.map(s => ({ slot: path.basename(s.file || '', '.json'), label: s.label, gb: s.gb, pid: s.pid, at: s.at })),
29
- queue: queue.map(q => ({ label: q.label, gb: q.gb, pid: q.pid, at: q.at, reason: q.reason })),
38
+ sessions,
39
+ mcp: found.mcp,
40
+ slots: slots.map(s => ({ slot: path.basename(s.file || '', '.json'), label: s.label, gb: s.gb, pid: s.pid, at: s.at, ...whose(s.pid) })),
41
+ queue: queue.map(q => ({ label: q.label, gb: q.gb, pid: q.pid, at: q.at, reason: q.reason, ...whose(q.pid) })),
30
42
  memory: mem,
31
43
  }
32
44
  }
33
45
 
46
+ // 45 s, 12 min, 2 h 5 min
47
+ function duration(ms) {
48
+ const s = Math.max(0, Math.round(ms / 1000))
49
+ if (s < 60) return `${s} s`
50
+ const m = Math.floor(s / 60)
51
+ return m < 60 ? `${m} min` : `${Math.floor(m / 60)} h ${m % 60} min`
52
+ }
53
+
34
54
  const pad2 = n => String(n).padStart(2, '0')
35
55
 
36
56
  function when(ms, now) {
@@ -44,8 +64,7 @@ function size(bytes) {
44
64
  }
45
65
 
46
66
  function shortPath(p, home) {
47
- if (!p) return '?'
48
- return home && p.toLowerCase().startsWith(home.toLowerCase()) ? '~' + p.slice(home.length).replace(/\\/g, '/') : p
67
+ return homeShort(p, home) || '(unknown)'
49
68
  }
50
69
 
51
70
  function table(rows) {
@@ -53,36 +72,54 @@ function table(rows) {
53
72
  return rows.map(r => r.map((c, i) => (i === r.length - 1 ? String(c) : String(c).padEnd(widths[i]))).join(' ').trimEnd())
54
73
  }
55
74
 
56
- function render(st, { color = false, home = os.homedir() } = {}) {
75
+ function render(st, { color = false, home = os.homedir(), width = Infinity } = {}) {
57
76
  const head = s => (color ? `\x1b[1m${s}\x1b[0m` : s)
77
+ const paintState = (s, text) => (!color ? text : s.state === 'permission' ? `\x1b[31m${text}\x1b[0m` : s.state === 'waiting' ? `\x1b[33m${text}\x1b[0m` : text)
58
78
  const out = []
59
79
  if (st.sessions.length) {
60
- const rows = [['SESSION', 'STARTED', 'RSS', 'PROJECT']]
80
+ // the AGENT column only appears once a session is not Claude Code, so a Claude-only list reads as before
81
+ const withAgent = st.sessions.some(s => s.agent && s.agent !== 'claude')
82
+ const titles = ['SESSION', 'STATE', 'STARTED', 'RSS', 'BRANCH', 'PROJECT']
83
+ if (withAgent) titles.splice(1, 0, 'AGENT')
84
+ const rows = [titles]
85
+ const stateCol = withAgent ? 2 : 1
86
+ const projectCol = titles.length - 1
61
87
  const portsAfter = new Map()
62
88
  for (const s of st.sessions) {
63
- rows.push([s.pid, when(s.start, st.at), size(s.rss), shortPath(s.cwd, home)])
89
+ const cells = [s.pid, stateLabel(s, st.at), when(s.start, st.at), size(s.rss), branchLabel(s), shortPath(s.cwd, home)]
90
+ if (withAgent) cells.splice(1, 0, s.agent || 'claude')
91
+ rows.push(cells)
64
92
  portsAfter.set(rows.length - 1, s.ports)
65
93
  }
94
+ // the project column takes what the other columns leave, never less than 12 characters
95
+ const widths = rows[0].slice(0, -1).map((_, i) => Math.max(...rows.map(r => String(r[i]).length)))
96
+ const room = Math.max(12, width - widths.reduce((n, w) => n + w + 2, 0))
97
+ for (const r of rows.slice(1)) r[projectCol] = fitPath(r[projectCol], room)
66
98
  const lines = table(rows)
67
99
  out.push(head(lines[0]))
68
100
  for (let i = 1; i < lines.length; i++) {
69
- out.push(lines[i])
101
+ const s = st.sessions[i - 1]
102
+ const stateText = rows[i][stateCol]
103
+ out.push(color && s.state ? lines[i].replace(stateText, paintState(s, stateText)) : lines[i])
70
104
  for (const p of portsAfter.get(i)) out.push(` :${p.port} ${p.name} pid ${p.pid}`)
71
105
  }
106
+ for (const m of (st.mcp || []).filter(x => x.duplicate)) {
107
+ out.push(`duplicate MCP: ${mcpLine(m, size)} (sessions ${m.sessions.join(', ')})`)
108
+ }
72
109
  } else {
73
110
  out.push('no Claude sessions found')
74
111
  }
75
112
  out.push('')
76
113
  if (st.slots.length) {
77
- const lines = table([['SLOT', 'GB', 'PID', 'AGE', 'COMMAND'],
78
- ...st.slots.map(s => [s.slot, s.gb, s.pid, `${Math.round((st.at - s.at) / 60000)} min`, s.label])])
114
+ const lines = table([['SLOT', 'GB', 'PID', 'AGE', 'SESSION', 'PROJECT', 'COMMAND'],
115
+ ...st.slots.map(s => [s.slot, s.gb, s.pid, duration(st.at - s.at), s.session || '-', s.project ? shortPath(s.project, home) : '-', s.label])])
79
116
  out.push(head(lines[0]), ...lines.slice(1), '')
80
117
  } else {
81
118
  out.push('no heavy jobs in bulkhead-slot', '')
82
119
  }
83
120
  if (st.queue && st.queue.length) {
84
- const lines = table([['WAITING', 'GB', 'PID', 'FOR', 'COMMAND'],
85
- ...st.queue.map((q, i) => [`#${i + 1}`, q.gb, q.pid, `${Math.round((st.at - q.at) / 60000)} min`, q.label])])
121
+ const lines = table([['WAITING', 'GB', 'PID', 'FOR', 'SESSION', 'PROJECT', 'COMMAND'],
122
+ ...st.queue.map((q, i) => [`#${i + 1}`, q.gb, q.pid, duration(st.at - q.at), q.session || '-', q.project ? shortPath(q.project, home) : '-', q.label])])
86
123
  out.push(head(lines[0]), ...lines.slice(1), '')
87
124
  }
88
125
  const m = st.memory
@@ -93,7 +130,8 @@ function render(st, { color = false, home = os.homedir() } = {}) {
93
130
  function toJson(st) {
94
131
  return {
95
132
  at: new Date(st.at).toISOString(),
96
- sessions: st.sessions.map(s => ({ ...s, start: new Date(s.start).toISOString() })),
133
+ sessions: st.sessions.map(({ stateSince, ...s }) => ({ ...s, start: new Date(s.start).toISOString(), stateSince: stateSince ? new Date(stateSince).toISOString() : null })),
134
+ mcp: st.mcp || [],
97
135
  slots: st.slots.map(s => ({ ...s, at: new Date(s.at).toISOString() })),
98
136
  queue: (st.queue || []).map(q => ({ ...q, at: new Date(q.at).toISOString() })),
99
137
  memory: st.memory,
@@ -105,7 +143,7 @@ function main(args, env = process.env, stdout = process.stdout) {
105
143
  const st = collect()
106
144
  if (args.includes('--json')) return stdout.write(JSON.stringify(toJson(st), null, 2) + '\n')
107
145
  const color = Boolean(stdout.isTTY) && !('NO_COLOR' in env) && env.TERM !== 'dumb'
108
- stdout.write(render(st, { color }) + '\n')
146
+ stdout.write(render(st, { color, width: stdout.isTTY ? stdout.columns || Infinity : Infinity }) + '\n')
109
147
  }
110
148
 
111
- module.exports = { collect, render, toJson, main, size, when }
149
+ module.exports = { collect, render, toJson, main, size, when, duration }
package/src/statusline.js CHANGED
@@ -5,6 +5,7 @@ const { stateDir } = require('./hook')
5
5
  const { readMemory } = require('./memory')
6
6
  const { execFileSync } = require('child_process')
7
7
  const { readSessionFiles, isClaudeCli } = require('./sessions')
8
+ const { readStates } = require('./session-state')
8
9
  const { readSlots, slotDir, staleReason } = require('./slots')
9
10
 
10
11
  const CACHE_MS = 5000
@@ -18,31 +19,56 @@ function alive(pid) {
18
19
  }
19
20
  }
20
21
 
21
- // Claude Code CLI processes running now, from one `ps` listing (about 20 ms), the same test
22
- // `bulkhead status` uses. A session file outlives its process and the OS reuses pids, so counting
23
- // files whose pid is merely alive overcounts. null when there is no cheap listing (Windows).
24
- function claudeCount({ platform = process.platform, run = execFileSync } = {}) {
22
+ const PS_LINE = /^\s*(\d+)\s+(\w+ +\w+ +\d+ +[\d:]+ +\d+)\s+(.*)$/
23
+ const START_SLACK_MS = 120 * 1000
24
+
25
+ // Sessions running now: the session files, narrowed by one `ps` for just those pids (a few
26
+ // milliseconds, not a listing of every process). A file outlives its process and the OS reuses
27
+ // pids, so a pid counts only when it is a Claude Code CLI that started when the file says.
28
+ // null when there is no cheap check (Windows), and the caller counts live pids instead.
29
+ function claudeCount({ platform = process.platform, run = execFileSync, files = new Map(), isAlive = alive } = {}) {
25
30
  if (platform === 'win32') return null
31
+ const pids = [...files.keys()].filter(isAlive)
32
+ if (!pids.length) return 0
26
33
  let out
27
34
  try {
28
- out = run('ps', ['-axww', '-o', 'pid=,args='], { encoding: 'utf8', timeout: 2000, maxBuffer: 64 * 1024 * 1024, env: { ...process.env, LC_ALL: 'C' }, stdio: ['ignore', 'pipe', 'ignore'] })
29
- } catch { return null }
35
+ out = run('ps', ['-ww', '-o', 'pid=,lstart=,args=', '-p', pids.join(',')], { encoding: 'utf8', timeout: 2000, env: { ...process.env, LC_ALL: 'C' }, stdio: ['ignore', 'pipe', 'ignore'] })
36
+ } catch (e) {
37
+ out = e && e.stdout ? String(e.stdout) : null // ps exits 1 when one of the pids is gone
38
+ if (out == null) return null
39
+ }
30
40
  let n = 0
31
41
  for (const l of out.split('\n')) {
32
- const m = /^\s*(\d+)\s+(.*)$/.exec(l)
42
+ const m = PS_LINE.exec(l)
33
43
  if (!m) continue
34
- const cmd = m[2]
35
- if (isClaudeCli({ name: (cmd.split(' ')[0] || '').split(/[\\/]/).pop(), cmd })) n++
44
+ const cmd = m[3]
45
+ if (!isClaudeCli({ name: (cmd.split(' ')[0] || '').split(/[\\/]/).pop(), cmd })) continue
46
+ const f = files.get(Number(m[1]))
47
+ const started = new Date(m[2].replace(/ +/g, ' ')).getTime()
48
+ if (f && f.startedAt && started && Math.abs(f.startedAt - started) >= START_SLACK_MS) continue
49
+ n++
50
+ }
51
+ return n
52
+ }
53
+
54
+ // Live sessions that are waiting for you, from the state files the session-state hook writes.
55
+ function waitingCount(files, states = readStates(), isAlive = alive) {
56
+ let n = 0
57
+ for (const rec of states.values()) {
58
+ if (rec.state === 'working') continue
59
+ const hit = [...files].find(([pid, f]) => (f.sessionId && f.sessionId === rec.session) || pid === rec.ppid)
60
+ if (hit && isAlive(hit[0])) n++
36
61
  }
37
62
  return n
38
63
  }
39
64
 
40
- // Only cheap reads: session files, slot files and free RAM. No process scan, so it stays
41
- // well under the 300 ms the status line debounce allows.
42
- function line({ files = readSessionFiles(), slots = readSlots(slotDir(stateDir())), mem = readMemory(), isAlive = alive, count = null } = {}) {
43
- const sessions = count != null ? count : (claudeCount() ?? [...files.keys()].filter(isAlive).length)
65
+ // Only cheap reads: session files, slot files, state files and free RAM. One small `ps`, so
66
+ // it stays well under the 100 ms the status line debounce allows.
67
+ function line({ files = readSessionFiles(), slots = readSlots(slotDir(stateDir())), mem = readMemory(), isAlive = alive, count = null, waiting = null } = {}) {
68
+ const sessions = count != null ? count : (claudeCount({ files, isAlive }) ?? [...files.keys()].filter(isAlive).length)
44
69
  const heavy = slots.filter(s => !staleReason(s, Date.now(), isAlive)).length
45
- return `${sessions} session${sessions === 1 ? '' : 's'} ${heavy} heavy ${mem.freeGb.toFixed(1)} GB free`
70
+ const wait = waiting != null ? waiting : waitingCount(files, undefined, isAlive)
71
+ return `${sessions} session${sessions === 1 ? '' : 's'}${wait ? ` ${wait} waiting` : ''} ${heavy} heavy ${mem.freeGb.toFixed(1)} GB free`
46
72
  }
47
73
 
48
74
  function cached(file, now = Date.now(), compute = line) {
@@ -53,7 +79,10 @@ function cached(file, now = Date.now(), compute = line) {
53
79
  const text = compute()
54
80
  try {
55
81
  fs.mkdirSync(path.dirname(file), { recursive: true })
56
- fs.writeFileSync(file, JSON.stringify({ at: now, text }))
82
+ // tmp and rename: sessions share this file and must never read half of it
83
+ const tmp = `${file}.${process.pid}.tmp`
84
+ fs.writeFileSync(tmp, JSON.stringify({ at: now, text }))
85
+ fs.renameSync(tmp, file)
57
86
  } catch {}
58
87
  return text
59
88
  }
@@ -66,4 +95,4 @@ function main() {
66
95
  process.stdout.write(text + '\n')
67
96
  }
68
97
 
69
- module.exports = { line, cached, main, claudeCount }
98
+ module.exports = { line, cached, main, claudeCount, waitingCount }
@@ -0,0 +1,24 @@
1
+ 'use strict'
2
+ const { runHook, blocked, setting, stateDir } = require('./hook')
3
+ const pressure = require('./pressure')
4
+
5
+ // With memoryGuard.subagents on, a new subagent is not started while memory pressure is critical:
6
+ // every subagent is a whole Claude process plus the commands it runs. Runs on the Agent tool
7
+ // (Task in older Claude Code versions).
8
+ function check(input, read = () => pressure.current({ stateDir: stateDir() })) {
9
+ const p = read()
10
+ if (!p || !p.critical) return null
11
+ return `[bulkhead] subagent-guard: memory pressure is critical (${p.detail}). Do not start another subagent now.\n` +
12
+ 'Finish the work you have, or wait for the running subagents to return, then try again in a minute. `bulkhead status` shows free RAM.'
13
+ }
14
+
15
+ if (require.main === module) {
16
+ runHook('memory-guard', input => {
17
+ if (setting('memoryGuard.subagents', false) !== true) return
18
+ const why = check(input)
19
+ if (!why) return
20
+ return blocked('subagent-guard', 'subagent-pressure', input, why, input.tool_input && input.tool_input.description)
21
+ })
22
+ }
23
+
24
+ module.exports = { check }