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.
- package/CHANGELOG.md +51 -0
- package/README.es.md +142 -50
- package/README.ja.md +142 -50
- package/README.ko.md +143 -51
- package/README.md +142 -50
- package/README.pt-BR.md +142 -50
- package/README.zh-CN.md +154 -62
- package/hooks/hooks.json +68 -8
- package/package.json +5 -2
- 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 +46 -27
- package/src/checkout-guard.js +342 -74
- package/src/cli-util.js +13 -1
- package/src/cli.js +40 -4
- package/src/config-check.js +21 -4
- package/src/config-cli.js +75 -10
- package/src/config.js +106 -9
- package/src/dev-servers.js +361 -61
- package/src/disk-guard.js +8 -8
- package/src/doctor-enforcement.js +273 -0
- package/src/doctor.js +19 -9
- package/src/edit-guard.js +196 -0
- package/src/events.js +30 -5
- package/src/explain.js +251 -0
- package/src/git-info.js +55 -0
- package/src/gitargs.js +82 -0
- package/src/hook.js +38 -6
- package/src/kill-extras.js +219 -0
- package/src/kill-guard.js +228 -77
- package/src/kill-owners.js +116 -0
- package/src/log-cli.js +15 -10
- package/src/mcp.js +131 -0
- package/src/memory-guard.js +126 -24
- package/src/memory.js +21 -2
- package/src/notices.js +95 -0
- package/src/orphan-reaper.js +58 -11
- package/src/port-cli.js +78 -3
- package/src/port-guard.js +80 -68
- package/src/port-kills.js +198 -0
- package/src/pressure.js +75 -0
- package/src/processes.js +44 -12
- package/src/scan-report.js +166 -0
- package/src/scan.js +87 -18
- 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 +109 -20
- package/src/shell.js +279 -19
- package/src/slot-cli.js +92 -21
- package/src/slots.js +61 -13
- package/src/stable-port.js +61 -0
- package/src/status.js +55 -17
- package/src/statusline.js +45 -16
- package/src/subagent-guard.js +24 -0
- package/src/top.js +36 -11
- package/src/wait-guard.js +96 -32
- package/src/worktree-cli.js +454 -0
- 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 {
|
|
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.
|
|
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
|
|
19
|
-
return
|
|
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
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
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() +
|
|
85
|
+
const deadline = Date.now() + w.value * 60000
|
|
65
86
|
const dir = slotDir(stateDir())
|
|
66
|
-
|
|
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 },
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
90
|
-
setTimeout(
|
|
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
|
|
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
|
-
|
|
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',
|
|
19
|
-
floorGb: setting('memory.slotFloorGb',
|
|
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
|
-
|
|
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
|
-
//
|
|
114
|
-
|
|
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
|
|
120
|
-
|
|
121
|
-
|
|
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
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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', ['-
|
|
29
|
-
} catch {
|
|
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 =
|
|
42
|
+
const m = PS_LINE.exec(l)
|
|
33
43
|
if (!m) continue
|
|
34
|
-
const cmd = m[
|
|
35
|
-
if (isClaudeCli({ name: (cmd.split(' ')[0] || '').split(/[\\/]/).pop(), cmd }))
|
|
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.
|
|
41
|
-
// well under the
|
|
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
|
-
|
|
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
|
-
|
|
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 }
|