claude-bulkhead 0.0.0-stage → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +90 -0
- package/LICENSE +21 -0
- package/README.es.md +498 -0
- package/README.ja.md +498 -0
- package/README.ko.md +498 -0
- package/README.md +497 -2
- package/README.pt-BR.md +498 -0
- package/README.zh-CN.md +498 -0
- package/bin/bulkhead +3 -0
- package/bin/bulkhead-slot +3 -0
- package/bin/bulkhead-statusline +3 -0
- package/hooks/hooks.json +39 -0
- package/package.json +52 -4
- package/src/basics.js +236 -0
- package/src/checkout-guard.js +296 -0
- package/src/cli-util.js +79 -0
- package/src/cli.js +41 -0
- package/src/config-check.js +29 -0
- package/src/config-cli.js +51 -0
- package/src/config.js +236 -0
- package/src/dev-servers.js +223 -0
- package/src/disk-guard.js +200 -0
- package/src/doctor.js +255 -0
- package/src/events.js +108 -0
- package/src/hook.js +94 -0
- package/src/kill-guard.js +435 -0
- package/src/log-cli.js +146 -0
- package/src/memory-guard.js +145 -0
- package/src/memory.js +76 -0
- package/src/orphan-reaper.js +527 -0
- package/src/paths.js +31 -0
- package/src/port-cli.js +59 -0
- package/src/port-guard.js +142 -0
- package/src/processes.js +181 -0
- package/src/reap-cli.js +56 -0
- package/src/remote-sh.js +478 -0
- package/src/remote.js +1034 -0
- package/src/remotes.js +126 -0
- package/src/scan.js +604 -0
- package/src/sessions.js +147 -0
- package/src/shell.js +597 -0
- package/src/slot-cli.js +150 -0
- package/src/slots.js +168 -0
- package/src/status.js +111 -0
- package/src/statusline.js +69 -0
- package/src/top-worker.js +14 -0
- package/src/top.js +305 -0
- package/src/wait-guard.js +263 -0
package/src/slot-cli.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const fs = require('fs')
|
|
3
|
+
const path = require('path')
|
|
4
|
+
const { spawn } = require('child_process')
|
|
5
|
+
const { stateDir, log } = require('./hook')
|
|
6
|
+
const { readMemory } = require('./memory')
|
|
7
|
+
const { take, release, readSlots, staleReason, slotDir, admit, queueDir, enqueue } = require('./slots')
|
|
8
|
+
|
|
9
|
+
const USAGE = `usage:
|
|
10
|
+
bulkhead-slot run [--gb=N] [--wait=MINUTES] [--label=TEXT] -- <command...>
|
|
11
|
+
bulkhead-slot status [--gb=N]
|
|
12
|
+
|
|
13
|
+
run waits until the machine has room for a job of N GB (default 3), runs the
|
|
14
|
+
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.`
|
|
16
|
+
|
|
17
|
+
function option(args, name, fallback) {
|
|
18
|
+
const hit = args.find(a => a.startsWith(`--${name}=`))
|
|
19
|
+
return hit ? hit.slice(name.length + 3) : fallback
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function gbOption(args) {
|
|
23
|
+
const n = Number(option(args, 'gb', 3))
|
|
24
|
+
return n > 0 && n <= 256 ? n : 3
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function findGitBash(env = process.env, exists = fs.existsSync) {
|
|
28
|
+
const candidates = [
|
|
29
|
+
env.CLAUDE_CODE_GIT_BASH_PATH,
|
|
30
|
+
env.ProgramFiles && path.join(env.ProgramFiles, 'Git', 'bin', 'bash.exe'),
|
|
31
|
+
env['ProgramFiles(x86)'] && path.join(env['ProgramFiles(x86)'], 'Git', 'bin', 'bash.exe'),
|
|
32
|
+
env.LOCALAPPDATA && path.join(env.LOCALAPPDATA, 'Programs', 'Git', 'bin', 'bash.exe'),
|
|
33
|
+
]
|
|
34
|
+
return candidates.find(f => f && exists(f)) || null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const posixQuote = a => (/^[\w@%+=:,./-]+$/.test(a) ? a : `'${a.replace(/'/g, `'\\''`)}'`)
|
|
38
|
+
const cmdQuote = a => (/[\s"]/.test(a) ? `"${a.replace(/"/g, '""')}"` : a)
|
|
39
|
+
|
|
40
|
+
// One argument is a shell line as typed; several are an argv we quote back together,
|
|
41
|
+
// so `-- bash -c "a && b"` keeps its quotes.
|
|
42
|
+
function shellCommand(args, platform = process.platform, gitBash = null) {
|
|
43
|
+
if (platform === 'win32' && !gitBash) {
|
|
44
|
+
return { file: process.env.ComSpec || 'cmd.exe', args: ['/d', '/s', '/c', args.length === 1 ? args[0] : args.map(cmdQuote).join(' ')] }
|
|
45
|
+
}
|
|
46
|
+
const line = args.length === 1 ? args[0] : args.map(posixQuote).join(' ')
|
|
47
|
+
return { file: platform === 'win32' ? gitBash : '/bin/sh', args: ['-c', line] }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function measure(withCommit) {
|
|
51
|
+
return readMemory({ withCommit })
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function run(args) {
|
|
55
|
+
const sep = args.indexOf('--')
|
|
56
|
+
if (sep < 0 || sep === args.length - 1) {
|
|
57
|
+
console.error(USAGE)
|
|
58
|
+
return process.exit(1)
|
|
59
|
+
}
|
|
60
|
+
const opts = args.slice(0, sep)
|
|
61
|
+
const command = args.slice(sep + 1)
|
|
62
|
+
const gb = gbOption(opts)
|
|
63
|
+
const label = option(opts, 'label', command.join(' ').slice(0, 80))
|
|
64
|
+
const deadline = Date.now() + Number(option(opts, 'wait', 30)) * 60000
|
|
65
|
+
const dir = slotDir(stateDir())
|
|
66
|
+
let announced = false
|
|
67
|
+
let queued = null
|
|
68
|
+
const unqueue = () => {
|
|
69
|
+
if (queued) try { fs.unlinkSync(queued) } catch {}
|
|
70
|
+
queued = null
|
|
71
|
+
}
|
|
72
|
+
process.on('exit', unqueue)
|
|
73
|
+
|
|
74
|
+
const attempt = () => {
|
|
75
|
+
const got = take(dir, { label, gb, pid: process.pid }, measure, (slot, why) => log('slot', `reclaimed ${slot.label}: ${why}`))
|
|
76
|
+
if (got.file) {
|
|
77
|
+
unqueue()
|
|
78
|
+
return start(got.file)
|
|
79
|
+
}
|
|
80
|
+
if (Date.now() > deadline) {
|
|
81
|
+
console.error(`[bulkhead-slot] gave up waiting: ${got.reason}. Holders: ${holders(dir)}`)
|
|
82
|
+
return process.exit(3)
|
|
83
|
+
}
|
|
84
|
+
if (!announced) {
|
|
85
|
+
console.error(`[bulkhead-slot] waiting: ${got.reason}. Holders: ${holders(dir)}`)
|
|
86
|
+
announced = true
|
|
87
|
+
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(sig, () => process.exit(130))
|
|
88
|
+
}
|
|
89
|
+
queued = enqueue(queueDir(stateDir()), { label, gb, pid: process.pid, at: queuedAt, reason: got.reason })
|
|
90
|
+
setTimeout(attempt, 15000)
|
|
91
|
+
}
|
|
92
|
+
const queuedAt = Date.now()
|
|
93
|
+
|
|
94
|
+
const start = file => {
|
|
95
|
+
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.removeAllListeners(sig)
|
|
96
|
+
console.error(`[bulkhead-slot] running ${label} (${gb} GB)`)
|
|
97
|
+
const { file: shell, args: shellArgs } = shellCommand(command, process.platform, process.platform === 'win32' ? findGitBash() : null)
|
|
98
|
+
const child = spawn(shell, shellArgs, { stdio: 'inherit', windowsHide: true })
|
|
99
|
+
const done = code => {
|
|
100
|
+
release(file)
|
|
101
|
+
process.exit(code)
|
|
102
|
+
}
|
|
103
|
+
child.on('exit', (code, signal) => done(code ?? (signal ? 1 : 0)))
|
|
104
|
+
child.on('error', e => {
|
|
105
|
+
console.error(`[bulkhead-slot] could not start: ${e.message}`)
|
|
106
|
+
done(1)
|
|
107
|
+
})
|
|
108
|
+
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
109
|
+
process.on(sig, () => {
|
|
110
|
+
try { child.kill(sig) } catch {}
|
|
111
|
+
done(1)
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
attempt()
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function holders(dir) {
|
|
120
|
+
return readSlots(dir).map(s => `${s.label} (${s.gb} GB, pid ${s.pid})`).join(', ') || 'none'
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function status(args) {
|
|
124
|
+
const dir = slotDir(stateDir())
|
|
125
|
+
const gb = gbOption(args)
|
|
126
|
+
const held = readSlots(dir)
|
|
127
|
+
if (!held.length) console.log('no slots held')
|
|
128
|
+
for (const s of held) {
|
|
129
|
+
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}` : ''}`)
|
|
132
|
+
}
|
|
133
|
+
const live = held.filter(s => !staleReason(s))
|
|
134
|
+
const mem = measure(live.length > 0)
|
|
135
|
+
const reason = admit({ held: live, mem, gb })
|
|
136
|
+
const commit = mem.commitPct == null ? '' : `, commit ${mem.commitPct}%`
|
|
137
|
+
console.log(`free ${mem.freeGb.toFixed(1)} of ${mem.totalGb.toFixed(1)} GB${commit}. A ${gb} GB job would ${reason ? `wait: ${reason}` : 'start now'}.`)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function main(argv) {
|
|
141
|
+
const [cmd, ...rest] = argv
|
|
142
|
+
if (cmd === 'run') return run(rest)
|
|
143
|
+
if (cmd === 'status') return status(rest)
|
|
144
|
+
console.error(USAGE)
|
|
145
|
+
process.exit(cmd === '--help' || cmd === '-h' ? 0 : 1)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (require.main === module) main(process.argv.slice(2))
|
|
149
|
+
|
|
150
|
+
module.exports = { main, shellCommand, findGitBash }
|
package/src/slots.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const fs = require('fs')
|
|
3
|
+
const os = require('os')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
const { setting } = require('./hook')
|
|
6
|
+
|
|
7
|
+
const MAX_SLOTS = 8
|
|
8
|
+
const SETTLE_MS = 150 * 1000
|
|
9
|
+
const MAX_AGE_MS = 12 * 3600 * 1000
|
|
10
|
+
const SMALL_MACHINE_GB = 24
|
|
11
|
+
|
|
12
|
+
// 3 GB free and a 5 GB floor are right for 32 GB; an 8 GB laptop would never get there.
|
|
13
|
+
// So the defaults are 15% and 25% of total RAM, capped at those values.
|
|
14
|
+
const round1 = n => Math.round(n * 10) / 10
|
|
15
|
+
|
|
16
|
+
function limits(totalGb = os.totalmem() / 2 ** 30) {
|
|
17
|
+
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))),
|
|
20
|
+
maxCommitPct: setting('memory.maxCommitPct', 75),
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Returns '' when a job of `gb` may start, otherwise the reason it has to wait.
|
|
25
|
+
// Jobs younger than SETTLE_MS are counted at their full declared size because
|
|
26
|
+
// they have not reached their peak yet.
|
|
27
|
+
function admit({ held, mem, gb, now = Date.now(), limit = limits(mem.totalGb) }) {
|
|
28
|
+
const free = mem.freeGb
|
|
29
|
+
if (!held.length) {
|
|
30
|
+
return free >= limit.minFreeGb ? '' : `free RAM ${free.toFixed(1)} GB is below ${limit.minFreeGb} GB`
|
|
31
|
+
}
|
|
32
|
+
if (mem.totalGb < SMALL_MACHINE_GB) return `this machine has ${Math.round(mem.totalGb)} GB; one heavy job at a time`
|
|
33
|
+
if (held.length >= MAX_SLOTS) return `all ${MAX_SLOTS} slots are taken`
|
|
34
|
+
const pending = held.filter(s => now - s.at < SETTLE_MS).reduce((sum, s) => sum + s.gb, 0)
|
|
35
|
+
const left = free - pending - gb
|
|
36
|
+
if (left < limit.floorGb) {
|
|
37
|
+
return `free ${free.toFixed(1)} GB` + (pending ? ` - starting jobs ${pending} GB` : '') +
|
|
38
|
+
` - this job ${gb} GB = ${left.toFixed(1)} GB, below the ${limit.floorGb} GB floor`
|
|
39
|
+
}
|
|
40
|
+
if (mem.commitPct != null && mem.commitPct > limit.maxCommitPct) return `commit charge ${mem.commitPct}% is above ${limit.maxCommitPct}%`
|
|
41
|
+
return ''
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function alive(pid) {
|
|
45
|
+
try {
|
|
46
|
+
process.kill(pid, 0)
|
|
47
|
+
return true
|
|
48
|
+
} catch (e) {
|
|
49
|
+
return e.code === 'EPERM'
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function slotDir(stateDir) {
|
|
54
|
+
return path.join(stateDir, 'slots')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function readSlots(dir) {
|
|
58
|
+
let names = []
|
|
59
|
+
try { names = fs.readdirSync(dir) } catch {}
|
|
60
|
+
const out = []
|
|
61
|
+
for (const name of names.filter(n => /^slot-\d+\.json$/.test(n))) {
|
|
62
|
+
const file = path.join(dir, name)
|
|
63
|
+
try {
|
|
64
|
+
out.push({ file, ...JSON.parse(fs.readFileSync(file, 'utf8')) })
|
|
65
|
+
} catch {
|
|
66
|
+
// half-written or corrupt: treat as held with no owner so reclaim() removes it
|
|
67
|
+
out.push({ file, label: '?', gb: 0, at: 0, pid: 0 })
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return out
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function staleReason(slot, now = Date.now(), isAlive = alive) {
|
|
74
|
+
if (!slot.pid || !isAlive(slot.pid)) return `owner ${slot.pid || '?'} is gone`
|
|
75
|
+
if (now - slot.at > MAX_AGE_MS) return 'older than 12 hours'
|
|
76
|
+
return null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function reclaim(dir, onReclaim = () => {}) {
|
|
80
|
+
for (const slot of readSlots(dir)) {
|
|
81
|
+
const why = staleReason(slot)
|
|
82
|
+
if (!why) continue
|
|
83
|
+
try {
|
|
84
|
+
fs.unlinkSync(slot.file)
|
|
85
|
+
onReclaim(slot, why)
|
|
86
|
+
} catch {}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// mkdir is atomic on every platform we support, so it doubles as a cross-process lock.
|
|
91
|
+
function withLock(dir, fn, timeoutMs = 15000) {
|
|
92
|
+
const lock = path.join(dir, '.lock')
|
|
93
|
+
const until = Date.now() + timeoutMs
|
|
94
|
+
for (;;) {
|
|
95
|
+
try {
|
|
96
|
+
fs.mkdirSync(lock)
|
|
97
|
+
break
|
|
98
|
+
} catch {
|
|
99
|
+
try {
|
|
100
|
+
if (Date.now() - fs.statSync(lock).mtimeMs > 30000) fs.rmdirSync(lock)
|
|
101
|
+
} catch {}
|
|
102
|
+
if (Date.now() > until) return null
|
|
103
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
return fn()
|
|
108
|
+
} finally {
|
|
109
|
+
try { fs.rmdirSync(lock) } catch {}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Returns { file } on success or { reason } when the job has to wait.
|
|
114
|
+
function take(dir, { label, gb, pid }, measure, onReclaim) {
|
|
115
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
116
|
+
const result = withLock(dir, () => {
|
|
117
|
+
reclaim(dir, onReclaim)
|
|
118
|
+
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() })
|
|
122
|
+
for (let i = 1; i <= MAX_SLOTS; i++) {
|
|
123
|
+
const file = path.join(dir, `slot-${i}.json`)
|
|
124
|
+
try {
|
|
125
|
+
fs.writeFileSync(file, body + '\n', { flag: 'wx' })
|
|
126
|
+
return { file }
|
|
127
|
+
} catch {}
|
|
128
|
+
}
|
|
129
|
+
return { reason: `all ${MAX_SLOTS} slots are taken` }
|
|
130
|
+
})
|
|
131
|
+
return result || { reason: 'another session is holding the slot lock' }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Jobs waiting in bulkhead-slot run, one file per waiting process. Only for display: admission
|
|
135
|
+
// never reads them, and a file whose process is gone is skipped.
|
|
136
|
+
function queueDir(stateDir) {
|
|
137
|
+
return path.join(stateDir, 'queue')
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function enqueue(dir, entry) {
|
|
141
|
+
try {
|
|
142
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
143
|
+
const file = path.join(dir, `${entry.pid}.json`)
|
|
144
|
+
fs.writeFileSync(file, JSON.stringify(entry) + '\n')
|
|
145
|
+
return file
|
|
146
|
+
} catch {
|
|
147
|
+
return null
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function readQueue(dir, isAlive = alive) {
|
|
152
|
+
let names = []
|
|
153
|
+
try { names = fs.readdirSync(dir) } catch {}
|
|
154
|
+
const out = []
|
|
155
|
+
for (const name of names.filter(n => /^\d+\.json$/.test(n))) {
|
|
156
|
+
try {
|
|
157
|
+
const q = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8'))
|
|
158
|
+
if (q.pid && isAlive(q.pid)) out.push(q)
|
|
159
|
+
} catch {}
|
|
160
|
+
}
|
|
161
|
+
return out.sort((a, b) => a.at - b.at)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function release(file) {
|
|
165
|
+
try { fs.unlinkSync(file) } catch {}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
module.exports = { admit, take, release, reclaim, readSlots, staleReason, slotDir, limits, queueDir, enqueue, readQueue }
|
package/src/status.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const os = require('os')
|
|
3
|
+
const path = require('path')
|
|
4
|
+
const { stateDir } = require('./hook')
|
|
5
|
+
const { rejectArgs } = require('./cli-util')
|
|
6
|
+
const { readMemory } = require('./memory')
|
|
7
|
+
const { readProcesses } = require('./processes')
|
|
8
|
+
const { readSessionFiles, findSessions } = require('./sessions')
|
|
9
|
+
const { readSlots, slotDir, staleReason, readQueue, queueDir } = require('./slots')
|
|
10
|
+
|
|
11
|
+
const GB = 2 ** 30
|
|
12
|
+
const MONTHS = 'Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec'.split(' ')
|
|
13
|
+
|
|
14
|
+
// A failed process scan (no powershell, no /proc) still leaves slots and memory to show.
|
|
15
|
+
const orNull = f => { try { return f() } catch { return null } }
|
|
16
|
+
|
|
17
|
+
function collect({
|
|
18
|
+
snapshot = orNull(() => readProcesses()),
|
|
19
|
+
files = readSessionFiles(),
|
|
20
|
+
slots = readSlots(slotDir(stateDir())).filter(s => !staleReason(s)),
|
|
21
|
+
queue = readQueue(queueDir(stateDir())),
|
|
22
|
+
mem = readMemory({ withCommit: true }),
|
|
23
|
+
now = Date.now(),
|
|
24
|
+
} = {}) {
|
|
25
|
+
return {
|
|
26
|
+
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 })),
|
|
30
|
+
memory: mem,
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const pad2 = n => String(n).padStart(2, '0')
|
|
35
|
+
|
|
36
|
+
function when(ms, now) {
|
|
37
|
+
const d = new Date(ms)
|
|
38
|
+
const time = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`
|
|
39
|
+
return d.toDateString() === new Date(now).toDateString() ? time : `${MONTHS[d.getMonth()]} ${d.getDate()} ${time}`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function size(bytes) {
|
|
43
|
+
return bytes >= GB ? `${(bytes / GB).toFixed(1)} GB` : `${Math.round(bytes / 2 ** 20)} MB`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function shortPath(p, home) {
|
|
47
|
+
if (!p) return '?'
|
|
48
|
+
return home && p.toLowerCase().startsWith(home.toLowerCase()) ? '~' + p.slice(home.length).replace(/\\/g, '/') : p
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function table(rows) {
|
|
52
|
+
const widths = rows[0].map((_, i) => Math.max(...rows.map(r => (r[i] == null ? 0 : String(r[i]).length))))
|
|
53
|
+
return rows.map(r => r.map((c, i) => (i === r.length - 1 ? String(c) : String(c).padEnd(widths[i]))).join(' ').trimEnd())
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function render(st, { color = false, home = os.homedir() } = {}) {
|
|
57
|
+
const head = s => (color ? `\x1b[1m${s}\x1b[0m` : s)
|
|
58
|
+
const out = []
|
|
59
|
+
if (st.sessions.length) {
|
|
60
|
+
const rows = [['SESSION', 'STARTED', 'RSS', 'PROJECT']]
|
|
61
|
+
const portsAfter = new Map()
|
|
62
|
+
for (const s of st.sessions) {
|
|
63
|
+
rows.push([s.pid, when(s.start, st.at), size(s.rss), shortPath(s.cwd, home)])
|
|
64
|
+
portsAfter.set(rows.length - 1, s.ports)
|
|
65
|
+
}
|
|
66
|
+
const lines = table(rows)
|
|
67
|
+
out.push(head(lines[0]))
|
|
68
|
+
for (let i = 1; i < lines.length; i++) {
|
|
69
|
+
out.push(lines[i])
|
|
70
|
+
for (const p of portsAfter.get(i)) out.push(` :${p.port} ${p.name} pid ${p.pid}`)
|
|
71
|
+
}
|
|
72
|
+
} else {
|
|
73
|
+
out.push('no Claude sessions found')
|
|
74
|
+
}
|
|
75
|
+
out.push('')
|
|
76
|
+
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])])
|
|
79
|
+
out.push(head(lines[0]), ...lines.slice(1), '')
|
|
80
|
+
} else {
|
|
81
|
+
out.push('no heavy jobs in bulkhead-slot', '')
|
|
82
|
+
}
|
|
83
|
+
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])])
|
|
86
|
+
out.push(head(lines[0]), ...lines.slice(1), '')
|
|
87
|
+
}
|
|
88
|
+
const m = st.memory
|
|
89
|
+
out.push(`${m.freeGb.toFixed(1)} GB free of ${m.totalGb.toFixed(1)} GB` + (m.commitPct == null ? '' : `, commit ${m.commitPct}%`))
|
|
90
|
+
return out.join('\n')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function toJson(st) {
|
|
94
|
+
return {
|
|
95
|
+
at: new Date(st.at).toISOString(),
|
|
96
|
+
sessions: st.sessions.map(s => ({ ...s, start: new Date(s.start).toISOString() })),
|
|
97
|
+
slots: st.slots.map(s => ({ ...s, at: new Date(s.at).toISOString() })),
|
|
98
|
+
queue: (st.queue || []).map(q => ({ ...q, at: new Date(q.at).toISOString() })),
|
|
99
|
+
memory: st.memory,
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function main(args, env = process.env, stdout = process.stdout) {
|
|
104
|
+
if (rejectArgs('status', args, { flags: ['json'], valued: [] }, process.stderr, null)) return
|
|
105
|
+
const st = collect()
|
|
106
|
+
if (args.includes('--json')) return stdout.write(JSON.stringify(toJson(st), null, 2) + '\n')
|
|
107
|
+
const color = Boolean(stdout.isTTY) && !('NO_COLOR' in env) && env.TERM !== 'dumb'
|
|
108
|
+
stdout.write(render(st, { color }) + '\n')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
module.exports = { collect, render, toJson, main, size, when }
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const fs = require('fs')
|
|
3
|
+
const path = require('path')
|
|
4
|
+
const { stateDir } = require('./hook')
|
|
5
|
+
const { readMemory } = require('./memory')
|
|
6
|
+
const { execFileSync } = require('child_process')
|
|
7
|
+
const { readSessionFiles, isClaudeCli } = require('./sessions')
|
|
8
|
+
const { readSlots, slotDir, staleReason } = require('./slots')
|
|
9
|
+
|
|
10
|
+
const CACHE_MS = 5000
|
|
11
|
+
|
|
12
|
+
function alive(pid) {
|
|
13
|
+
try {
|
|
14
|
+
process.kill(pid, 0)
|
|
15
|
+
return true
|
|
16
|
+
} catch (e) {
|
|
17
|
+
return e.code === 'EPERM'
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
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 } = {}) {
|
|
25
|
+
if (platform === 'win32') return null
|
|
26
|
+
let out
|
|
27
|
+
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 }
|
|
30
|
+
let n = 0
|
|
31
|
+
for (const l of out.split('\n')) {
|
|
32
|
+
const m = /^\s*(\d+)\s+(.*)$/.exec(l)
|
|
33
|
+
if (!m) continue
|
|
34
|
+
const cmd = m[2]
|
|
35
|
+
if (isClaudeCli({ name: (cmd.split(' ')[0] || '').split(/[\\/]/).pop(), cmd })) n++
|
|
36
|
+
}
|
|
37
|
+
return n
|
|
38
|
+
}
|
|
39
|
+
|
|
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)
|
|
44
|
+
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`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function cached(file, now = Date.now(), compute = line) {
|
|
49
|
+
try {
|
|
50
|
+
const c = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
51
|
+
if (now - c.at < CACHE_MS && now >= c.at) return c.text
|
|
52
|
+
} catch {}
|
|
53
|
+
const text = compute()
|
|
54
|
+
try {
|
|
55
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
56
|
+
fs.writeFileSync(file, JSON.stringify({ at: now, text }))
|
|
57
|
+
} catch {}
|
|
58
|
+
return text
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function main() {
|
|
62
|
+
let text = ''
|
|
63
|
+
try {
|
|
64
|
+
text = cached(path.join(stateDir(), 'statusline.json'))
|
|
65
|
+
} catch {}
|
|
66
|
+
process.stdout.write(text + '\n')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { line, cached, main, claudeCount }
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// Runs the process scan for `bulkhead top` off the main thread. On Windows the scan is a
|
|
3
|
+
// PowerShell call of a second or more, and the screen and keys must stay responsive meanwhile.
|
|
4
|
+
const { parentPort } = require('worker_threads')
|
|
5
|
+
const { collect } = require('./status')
|
|
6
|
+
|
|
7
|
+
parentPort.on('message', () => {
|
|
8
|
+
const started = Date.now()
|
|
9
|
+
try {
|
|
10
|
+
parentPort.postMessage({ ok: true, st: collect(), ms: Date.now() - started })
|
|
11
|
+
} catch (e) {
|
|
12
|
+
parentPort.postMessage({ ok: false, error: e.message, ms: Date.now() - started })
|
|
13
|
+
}
|
|
14
|
+
})
|