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.
Files changed (48) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/LICENSE +21 -0
  3. package/README.es.md +498 -0
  4. package/README.ja.md +498 -0
  5. package/README.ko.md +498 -0
  6. package/README.md +497 -2
  7. package/README.pt-BR.md +498 -0
  8. package/README.zh-CN.md +498 -0
  9. package/bin/bulkhead +3 -0
  10. package/bin/bulkhead-slot +3 -0
  11. package/bin/bulkhead-statusline +3 -0
  12. package/hooks/hooks.json +39 -0
  13. package/package.json +52 -4
  14. package/src/basics.js +236 -0
  15. package/src/checkout-guard.js +296 -0
  16. package/src/cli-util.js +79 -0
  17. package/src/cli.js +41 -0
  18. package/src/config-check.js +29 -0
  19. package/src/config-cli.js +51 -0
  20. package/src/config.js +236 -0
  21. package/src/dev-servers.js +223 -0
  22. package/src/disk-guard.js +200 -0
  23. package/src/doctor.js +255 -0
  24. package/src/events.js +108 -0
  25. package/src/hook.js +94 -0
  26. package/src/kill-guard.js +435 -0
  27. package/src/log-cli.js +146 -0
  28. package/src/memory-guard.js +145 -0
  29. package/src/memory.js +76 -0
  30. package/src/orphan-reaper.js +527 -0
  31. package/src/paths.js +31 -0
  32. package/src/port-cli.js +59 -0
  33. package/src/port-guard.js +142 -0
  34. package/src/processes.js +181 -0
  35. package/src/reap-cli.js +56 -0
  36. package/src/remote-sh.js +478 -0
  37. package/src/remote.js +1034 -0
  38. package/src/remotes.js +126 -0
  39. package/src/scan.js +604 -0
  40. package/src/sessions.js +147 -0
  41. package/src/shell.js +597 -0
  42. package/src/slot-cli.js +150 -0
  43. package/src/slots.js +168 -0
  44. package/src/status.js +111 -0
  45. package/src/statusline.js +69 -0
  46. package/src/top-worker.js +14 -0
  47. package/src/top.js +305 -0
  48. package/src/wait-guard.js +263 -0
@@ -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
+ })