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
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const os = require('os')
|
|
3
|
+
const { runHook, blocked, oneLine, ASK_USER } = require('./hook')
|
|
4
|
+
const shell = require('./shell')
|
|
5
|
+
|
|
6
|
+
// Disk scans that walk a whole drive or home folder, or several folders at once. The disk is
|
|
7
|
+
// shared by every session on the machine: one `du` over the home folder, or ten of them through
|
|
8
|
+
// xargs -P, keeps it busy for minutes, and every other session's builds, tests and git commands
|
|
9
|
+
// crawl until the scans end.
|
|
10
|
+
|
|
11
|
+
// Programs that read every file under the folders they are given.
|
|
12
|
+
const SIZERS = new Set(['du', 'ncdu', 'dust', 'gdu', 'diskus', 'dua', 'duf'])
|
|
13
|
+
const PS_WALKERS = new Set(['get-childitem', 'gci', 'ls', 'dir'])
|
|
14
|
+
|
|
15
|
+
// ~, $HOME and %USERPROFILE% become the home directory. Returns null for anything with a variable
|
|
16
|
+
// we cannot expand, so `du -sh "$DIR"` is not guessed at.
|
|
17
|
+
function expand(arg, home) {
|
|
18
|
+
const a = String(arg)
|
|
19
|
+
.replace(/^~(?=[\\/]|$)/, home)
|
|
20
|
+
.replace(/^(?:\$HOME|\$\{HOME\}|\$env:USERPROFILE|\$env:HOME|%USERPROFILE%|\$USERPROFILE)(?=[\\/]|$)/i, home)
|
|
21
|
+
return /[$%]/.test(a) ? null : a
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const clean = p => p.replace(/\\/g, '/').replace(/\/\*$/, '').replace(/\/+$/, '') || '/'
|
|
25
|
+
|
|
26
|
+
// What a folder argument covers when it is the whole drive, the home folder or every user's home.
|
|
27
|
+
function wholeDisk(arg, home) {
|
|
28
|
+
const a = expand(arg, home)
|
|
29
|
+
if (a === null) return null
|
|
30
|
+
const p = clean(a)
|
|
31
|
+
const h = clean(home)
|
|
32
|
+
const lower = p.toLowerCase()
|
|
33
|
+
if (p === '/' || /^[a-z]:\/?$/i.test(p) || /^\/(?:mnt\/)?[a-z]$/i.test(p) || lower === '/system/volumes/data') return 'the whole drive'
|
|
34
|
+
if (p === h || lower === h.toLowerCase()) return 'your whole home folder'
|
|
35
|
+
if (/^(?:\/users|\/home|[a-z]:\/users|\/(?:mnt\/)?[a-z]\/users)$/i.test(p)) return 'every home folder on the machine'
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Folder operands of a sizer or a recursive listing; options and their values are skipped.
|
|
40
|
+
function operands(argv) {
|
|
41
|
+
const dir = shell.bin(argv[0]) === 'dir'
|
|
42
|
+
const out = []
|
|
43
|
+
for (let i = 1; i < argv.length; i++) {
|
|
44
|
+
const a = argv[i]
|
|
45
|
+
if (a === '--') { out.push(...argv.slice(i + 1)); break }
|
|
46
|
+
if (/^-(?:d|-max-depth|-exclude|X|-exclude-from|B|-block-size|t|-threshold|Depth|Filter|Include|Exclude|Path|LiteralPath)$/i.test(a)) {
|
|
47
|
+
if (/^-(?:Path|LiteralPath)$/i.test(a) && argv[i + 1]) out.push(argv[i + 1])
|
|
48
|
+
i++
|
|
49
|
+
continue
|
|
50
|
+
}
|
|
51
|
+
// cmd's dir takes /s and /a; elsewhere /c is a Git Bash drive and stays an operand
|
|
52
|
+
if (a.startsWith('-') || (dir && /^\/[a-z]$/i.test(a))) continue
|
|
53
|
+
out.push(a)
|
|
54
|
+
}
|
|
55
|
+
return out.length ? out : ['.']
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Get-ChildItem -Recurse, ls -R (in sh, where ls -r only sorts), and cmd's dir /s.
|
|
59
|
+
function recursiveListing(argv, mode) {
|
|
60
|
+
const b = shell.bin(argv[0])
|
|
61
|
+
const args = argv.slice(1)
|
|
62
|
+
if (b === 'get-childitem' || b === 'gci' || (mode === 'ps' && PS_WALKERS.has(b))) return args.some(a => /^-r(?:ecurse)?$/i.test(a))
|
|
63
|
+
if (b === 'ls') return args.some(a => /^-[a-zA-Z]*R/.test(a))
|
|
64
|
+
return b === 'dir' && args.some(a => /^\/s$/i.test(a))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isScan(argv, mode = 'sh') {
|
|
68
|
+
const b = shell.bin(argv[0])
|
|
69
|
+
if (SIZERS.has(b)) return !argv.slice(1).some(a => /^(?:--version|--help)$/.test(a))
|
|
70
|
+
return recursiveListing(argv, mode)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// find / and find ~ without -maxdepth walk every file on the drive.
|
|
74
|
+
function wideFind(argv, home) {
|
|
75
|
+
if (shell.bin(argv[0]) !== 'find') return null
|
|
76
|
+
if (argv.some(a => /^-maxdepth(?:=|$)/.test(a))) return null
|
|
77
|
+
const starts = []
|
|
78
|
+
for (let i = 1; i < argv.length; i++) {
|
|
79
|
+
const a = argv[i]
|
|
80
|
+
if (/^-[HLP]$/.test(a)) continue
|
|
81
|
+
if (a.startsWith('-') || a === '(' || a === '!') break
|
|
82
|
+
starts.push(a)
|
|
83
|
+
}
|
|
84
|
+
for (const s of starts) {
|
|
85
|
+
const what = wholeDisk(s, home)
|
|
86
|
+
if (what) return { what, target: s }
|
|
87
|
+
}
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// xargs -P N in front of the command (the parser takes the wrapper off its words): N > 1, or 0,
|
|
92
|
+
// which means as many as possible. Reads only the command's own prefix, word by word.
|
|
93
|
+
function xargsParallel(text, c) {
|
|
94
|
+
const first = c.words && c.words[0]
|
|
95
|
+
if (!first || first.s <= c.s) return false
|
|
96
|
+
const words = text.slice(c.s, first.s).split(/\s+/).filter(Boolean)
|
|
97
|
+
for (let i = 0; i < words.length; i++) {
|
|
98
|
+
if (shell.bin(words[i]) !== 'xargs') continue
|
|
99
|
+
for (let k = i + 1; k < words.length; k++) {
|
|
100
|
+
const w = words[k]
|
|
101
|
+
let n = null
|
|
102
|
+
if (w === '-P' || w === '--max-procs') n = words[k + 1]
|
|
103
|
+
else if (/^-P\d+$/.test(w)) n = w.slice(2)
|
|
104
|
+
else if (w.startsWith('--max-procs=')) n = w.slice(12)
|
|
105
|
+
if (n != null && /^\d+$/.test(n) && Number(n) !== 1) return true
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return false
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// parallel du -sh, rush 'du -sh {}', ForEach-Object -Parallel { Get-ChildItem $_ -Recurse }:
|
|
112
|
+
// the scan is an argument of a command that runs it many times at once.
|
|
113
|
+
function fanOut(argv, mode) {
|
|
114
|
+
const b = shell.bin(argv[0])
|
|
115
|
+
const runner = /^(?:parallel|rush|xjobs)$/.test(b) ||
|
|
116
|
+
((b === 'foreach-object' || b === '%') && argv.some(a => /^-parallel$/i.test(a)))
|
|
117
|
+
if (!runner) return false
|
|
118
|
+
for (let i = 1; i < argv.length; i++) {
|
|
119
|
+
const words = argv.slice(i).join(' ').replace(/[{}'"]/g, ' ').trim().split(/\s+/)
|
|
120
|
+
if (isScan(words, mode === 'ps' ? 'ps' : mode)) return true
|
|
121
|
+
}
|
|
122
|
+
return false
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// The scan runs in parallel: through xargs -P N (N > 1), GNU parallel, ForEach-Object -Parallel,
|
|
126
|
+
// Start-Job, or as one of several scans sent to the background with &.
|
|
127
|
+
function parallel(text, scans, mode) {
|
|
128
|
+
for (const c of scans) if (xargsParallel(text, c)) return 'through xargs -P'
|
|
129
|
+
if (mode === 'ps' && /\bStart-(?:Thread)?Job\b/i.test(text)) return 'as parallel PowerShell jobs'
|
|
130
|
+
const bg = scans.filter(c => /^[ \t]*&(?!&)/.test(text.slice(c.e)))
|
|
131
|
+
if (bg.length >= 2 || (bg.length === 1 && scans.length >= 2)) return 'as several background jobs'
|
|
132
|
+
// for d in */; do du -sh "$d" & done: one command in the text, many processes
|
|
133
|
+
if (bg.length === 1) {
|
|
134
|
+
const head = text.slice(0, bg[0].e)
|
|
135
|
+
const loop = head.search(/\b(?:for|while|until)\b/)
|
|
136
|
+
if (loop >= 0 && /\bdo\b/.test(head.slice(loop))) return 'as a background job in a loop'
|
|
137
|
+
}
|
|
138
|
+
return null
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function check(cmd, { home = os.homedir(), mode = 'sh' } = {}) {
|
|
142
|
+
if (typeof cmd !== 'string' || !cmd) return null
|
|
143
|
+
const text = shell.prepare(cmd, mode)
|
|
144
|
+
const cmds = shell.commands(text, mode).filter(c => !c.remote)
|
|
145
|
+
// offsets of commands inside bash -c '...' point into the inner text, so only top-level
|
|
146
|
+
// commands are checked for being run in parallel
|
|
147
|
+
const scans = []
|
|
148
|
+
for (const c of cmds) {
|
|
149
|
+
const seg = oneLine(c.argv.join(' '), 120)
|
|
150
|
+
const found = wideFind(c.argv, home)
|
|
151
|
+
if (found) {
|
|
152
|
+
return {
|
|
153
|
+
rule: 'disk-wide-find',
|
|
154
|
+
why: `[bulkhead] disk-guard: \`${seg}\` walks ${found.what} (${found.target}) with no depth limit.\n` +
|
|
155
|
+
'Other sessions share this disk; a walk over millions of files slows their builds, tests and git for minutes.\n' +
|
|
156
|
+
'Search the folder you mean (find ./src -name ...), or add -maxdepth 3. To find a file by name, try `mdfind`, `locate` or `git ls-files` first.',
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (c.depth === 0 && fanOut(c.argv, mode)) {
|
|
160
|
+
return {
|
|
161
|
+
rule: 'parallel-disk-scan',
|
|
162
|
+
why: parallelMessage(cmd, 'several at a time'),
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (!isScan(c.argv, mode)) continue
|
|
166
|
+
if (c.depth === 0) scans.push(c)
|
|
167
|
+
for (const op of operands(c.argv)) {
|
|
168
|
+
const what = wholeDisk(op, home)
|
|
169
|
+
if (what) {
|
|
170
|
+
return {
|
|
171
|
+
rule: 'disk-wide-scan',
|
|
172
|
+
why: `[bulkhead] disk-guard: \`${seg}\` reads ${what} (${op}).\n` +
|
|
173
|
+
'Other sessions share this disk; sizing everything keeps it busy for minutes and slows their work.\n' +
|
|
174
|
+
'Measure one folder at a time, in the foreground: du -sh ./node_modules, or du -sh ~/Library/Caches. Start from the folders you already suspect.',
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (!scans.length) return null
|
|
180
|
+
const how = parallel(text, scans, mode)
|
|
181
|
+
if (!how) return null
|
|
182
|
+
return { rule: 'parallel-disk-scan', why: parallelMessage(cmd, how) }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function parallelMessage(cmd, how) {
|
|
186
|
+
return `[bulkhead] disk-guard: \`${oneLine(cmd, 120)}\` runs disk scans ${how}.\n` +
|
|
187
|
+
'Several scans at once keep the disk at 100% and freeze the other sessions, and they finish no sooner than one at a time.\n' +
|
|
188
|
+
'Run one du at a time, in the foreground: du -sh <one folder>.'
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (require.main === module) {
|
|
192
|
+
runHook('disk-guard', input => {
|
|
193
|
+
const cmd = input.tool_input && input.tool_input.command
|
|
194
|
+
const hit = check(cmd, { mode: shell.modeFor(input) })
|
|
195
|
+
if (!hit) return
|
|
196
|
+
return blocked('disk-guard', hit.rule, input, `${hit.why}\n${ASK_USER}`)
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
module.exports = { check, wholeDisk, isScan }
|
package/src/doctor.js
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const fs = require('fs')
|
|
3
|
+
const os = require('os')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
const { spawnSync, execFileSync } = require('child_process')
|
|
6
|
+
const { stateDir } = require('./hook')
|
|
7
|
+
const config = require('./config')
|
|
8
|
+
const { logSize, readEvents } = require('./events')
|
|
9
|
+
const { readMemory } = require('./memory')
|
|
10
|
+
const { readProcesses } = require('./processes')
|
|
11
|
+
const { readSessionFiles, findSessions, claudeDir } = require('./sessions')
|
|
12
|
+
const { limits, readSlots, slotDir, staleReason } = require('./slots')
|
|
13
|
+
const { useColor, paint, shortPath, bytes, rejectArgs } = require('./cli-util')
|
|
14
|
+
const remote = require('./remote')
|
|
15
|
+
|
|
16
|
+
const ROOT = path.resolve(__dirname, '..')
|
|
17
|
+
const SMALL_MACHINE_GB = 24
|
|
18
|
+
|
|
19
|
+
const ok = (id, summary) => ({ id, status: 'OK', summary })
|
|
20
|
+
const warn = (id, summary) => ({ id, status: 'WARN', summary })
|
|
21
|
+
const fail = (id, summary) => ({ id, status: 'FAIL', summary })
|
|
22
|
+
|
|
23
|
+
function checkNode(version = process.versions.node) {
|
|
24
|
+
const major = Number(version.split('.')[0])
|
|
25
|
+
return major >= 18 ? ok('node', `v${version}`) : fail('node', `v${version}; bulkhead needs Node 18 or later on PATH`)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function checkPlugin(root = ROOT) {
|
|
29
|
+
const file = path.join(root, 'hooks', 'hooks.json')
|
|
30
|
+
let hooks
|
|
31
|
+
try {
|
|
32
|
+
hooks = JSON.parse(fs.readFileSync(file, 'utf8')).hooks
|
|
33
|
+
} catch (e) {
|
|
34
|
+
return fail('plugin', `${shortPath(file)} cannot be read: ${e.message}`)
|
|
35
|
+
}
|
|
36
|
+
const missing = []
|
|
37
|
+
const counts = []
|
|
38
|
+
for (const [event, groups] of Object.entries(hooks || {})) {
|
|
39
|
+
let n = 0
|
|
40
|
+
for (const g of groups) {
|
|
41
|
+
for (const h of g.hooks || []) {
|
|
42
|
+
n++
|
|
43
|
+
for (const a of h.args || []) {
|
|
44
|
+
const script = String(a).replace('${CLAUDE_PLUGIN_ROOT}', root)
|
|
45
|
+
if (/\.js$/.test(script) && !fs.existsSync(script)) missing.push(path.relative(root, script))
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
counts.push(`${n} ${event}`)
|
|
50
|
+
}
|
|
51
|
+
if (missing.length) return fail('plugin', `hooks.json names scripts that are missing: ${missing.join(', ')}`)
|
|
52
|
+
return ok('plugin', `${shortPath(root)}, hooks: ${counts.join(', ')}`)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function checkStateDir(dir = stateDir()) {
|
|
56
|
+
const probe = path.join(dir, `.doctor-${process.pid}`)
|
|
57
|
+
try {
|
|
58
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
59
|
+
fs.writeFileSync(probe, 'ok')
|
|
60
|
+
fs.unlinkSync(probe)
|
|
61
|
+
return ok('state dir', `${shortPath(dir)} is writable`)
|
|
62
|
+
} catch (e) {
|
|
63
|
+
return fail('state dir', `${shortPath(dir)} is not writable (${e.code || e.message}); slots and the event log cannot work`)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function checkConfig(cfg) {
|
|
68
|
+
const files = [cfg.files.global, cfg.files.project].filter(f => f && fs.existsSync(f))
|
|
69
|
+
const out = []
|
|
70
|
+
if (!cfg.warnings.length) out.push(ok('config', files.length ? `loaded ${files.map(f => shortPath(f)).join(', ')}` : 'no config files, defaults in use'))
|
|
71
|
+
for (const w of cfg.warnings) out.push(warn('config', w.replace(/^(\S+)/, m => shortPath(m))))
|
|
72
|
+
return out
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function why(source) {
|
|
76
|
+
if (source === 'default') return 'default'
|
|
77
|
+
if (source.startsWith('env ')) return source
|
|
78
|
+
return shortPath(source)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function checkGuards(cfg, platform = process.platform) {
|
|
82
|
+
const out = []
|
|
83
|
+
for (const [name, section] of Object.entries(config.GUARDS)) {
|
|
84
|
+
const key = `${section}.enabled`
|
|
85
|
+
const on = cfg.values[key] !== false
|
|
86
|
+
if (!on) {
|
|
87
|
+
out.push(ok(name, `off (${why(cfg.sources[key])})`))
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
if (name === 'orphan-reaper' && !['win32', 'linux', 'darwin'].includes(platform)) {
|
|
91
|
+
out.push(warn(name, `on, but ${platform} is not supported, so it does nothing`))
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
let note = 'on'
|
|
95
|
+
const offRules = Object.entries(config.RULES)
|
|
96
|
+
.filter(([, k]) => k.startsWith(section + '.') && cfg.values[k] === false)
|
|
97
|
+
.map(([r, k]) => `${r} off (${why(cfg.sources[k])})`)
|
|
98
|
+
if (offRules.length) note += `, ${offRules.join(', ')}`
|
|
99
|
+
if (name === 'basics') note += `, protected branches: ${cfg.values['basics.protectedBranches'].join(', ')}`
|
|
100
|
+
if (name === 'wait-guard') note += `, limit ${cfg.values['waitGuard.limitSeconds']}s`
|
|
101
|
+
if (name === 'kill-guard') {
|
|
102
|
+
const allow = cfg.values['killGuard.allowNames']
|
|
103
|
+
const extra = cfg.values['killGuard.extraSharedNames']
|
|
104
|
+
if (allow.length) note += `, allowed by name: ${allow.join(', ')}`
|
|
105
|
+
if (extra.length) note += `, also protected: ${extra.join(', ')}`
|
|
106
|
+
}
|
|
107
|
+
if (name === 'orphan-reaper') {
|
|
108
|
+
note += cfg.values['orphanReaper.onSessionEnd'] === false ? ', at session start only' : ', at session start and after each session ends'
|
|
109
|
+
if (cfg.values['orphanReaper.extraNames'].length) note += `, also stops: ${cfg.values['orphanReaper.extraNames'].join(', ')}`
|
|
110
|
+
if (cfg.values['orphanReaper.keepNames'].length) note += `, never stops: ${cfg.values['orphanReaper.keepNames'].join(', ')}`
|
|
111
|
+
}
|
|
112
|
+
if (name === 'port-guard' && cfg.values['portGuard.ignorePorts'].length) note += `, ignoring ports ${cfg.values['portGuard.ignorePorts'].join(', ')}`
|
|
113
|
+
out.push(ok(name, note))
|
|
114
|
+
}
|
|
115
|
+
return out
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function checkProcesses({ read = () => readProcesses(), files = () => readSessionFiles(), now = () => Date.now(), platform = process.platform } = {}) {
|
|
119
|
+
const t0 = now()
|
|
120
|
+
let snap
|
|
121
|
+
try {
|
|
122
|
+
snap = read()
|
|
123
|
+
} catch (e) {
|
|
124
|
+
return fail('processes', `the process reader failed on ${platform}: ${String(e.message).split('\n')[0]}. port-guard and status cannot see other sessions`)
|
|
125
|
+
}
|
|
126
|
+
const ms = now() - t0
|
|
127
|
+
if (!snap) return warn('processes', `no process reader for ${platform}; port-guard, top and status see no sessions`)
|
|
128
|
+
const sessions = findSessions(snap, files())
|
|
129
|
+
const text = `${snap.procs.length} processes, ${snap.ports.length} listening ports, ${sessions.length} Claude session${sessions.length === 1 ? '' : 's'} in ${(ms / 1000).toFixed(1)}s`
|
|
130
|
+
return ms > 8000 ? warn('processes', `${text}; slow, so port-guard may hit its 20s timeout on a busy machine`) : ok('processes', text)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function checkSessionsDir(dir = path.join(claudeDir(), 'sessions')) {
|
|
134
|
+
return fs.existsSync(dir)
|
|
135
|
+
? ok('sessions', `${shortPath(dir)} (${readSessionFiles(dir).size} session files)`)
|
|
136
|
+
: warn('sessions', `${shortPath(dir)} does not exist; checkout-guard cannot see other sessions and project names show as ?`)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function checkMemory(mem = readMemory({ withCommit: true })) {
|
|
140
|
+
const lim = limits(mem.totalGb)
|
|
141
|
+
const parts = [
|
|
142
|
+
`${mem.freeGb.toFixed(1)} of ${mem.totalGb.toFixed(1)} GB free`,
|
|
143
|
+
mem.commitPct != null ? `commit ${mem.commitPct}%` : null,
|
|
144
|
+
`memory-guard blocks heavy commands below ${lim.minFreeGb} GB`,
|
|
145
|
+
mem.totalGb < SMALL_MACHINE_GB ? 'bulkhead-slot runs one heavy job at a time' : `bulkhead-slot keeps ${lim.floorGb} GB free for a second job`,
|
|
146
|
+
mem.commitPct != null ? `and waits while commit is above ${lim.maxCommitPct}%` : null,
|
|
147
|
+
].filter(Boolean)
|
|
148
|
+
return mem.freeGb < lim.minFreeGb ? warn('memory', `${parts.join(', ')}; free RAM is below the minimum right now`) : ok('memory', parts.join(', '))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function checkSlots(dir = slotDir(stateDir())) {
|
|
152
|
+
const held = readSlots(dir)
|
|
153
|
+
const stale = held.filter(s => staleReason(s))
|
|
154
|
+
if (stale.length) return warn('slots', `${held.length} held, ${stale.length} stale (${stale.map(s => `${s.label}: ${staleReason(s)}`).join('; ')}); the next bulkhead-slot run reclaims them`)
|
|
155
|
+
return ok('slots', held.length ? `${held.length} held` : 'none held')
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function checkLog(cfg, dir = stateDir()) {
|
|
159
|
+
const key = 'log.enabled'
|
|
160
|
+
if (cfg.values[key] === false) return ok('event log', `off (${why(cfg.sources[key])})`)
|
|
161
|
+
const { bytes: size, files } = logSize(dir)
|
|
162
|
+
if (!files) return ok('event log', `${shortPath(path.join(dir, 'events.jsonl'))}, empty so far`)
|
|
163
|
+
const n = readEvents(dir).length
|
|
164
|
+
return ok('event log', `${shortPath(path.join(dir, 'events.jsonl'))}, ${n} events, ${bytes(size)} (rotates at 1 MB)`)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Runs kill-guard the way Claude Code does, in a clean state dir, to prove hooks start and block.
|
|
168
|
+
function checkHookRun(root = ROOT) {
|
|
169
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'bulkhead-doctor-'))
|
|
170
|
+
try {
|
|
171
|
+
const t0 = Date.now()
|
|
172
|
+
const r = spawnSync(process.execPath, [path.join(root, 'src', 'kill-guard.js')], {
|
|
173
|
+
input: JSON.stringify({ tool_name: 'Bash', tool_input: { command: 'pkill node' } }),
|
|
174
|
+
encoding: 'utf8',
|
|
175
|
+
timeout: 10000,
|
|
176
|
+
env: { ...process.env, BULKHEAD_STATE_DIR: tmp, CLAUDE_PROJECT_DIR: tmp, BULKHEAD_DISABLE: '', BULKHEAD_NO_LOG: '1' },
|
|
177
|
+
})
|
|
178
|
+
const ms = Date.now() - t0
|
|
179
|
+
if (r.status === 2 && /kill-guard/.test(r.stderr)) return ok('hook run', `kill-guard blocked a test command in ${ms} ms`)
|
|
180
|
+
return fail('hook run', `kill-guard did not block a test command (exit ${r.status}${r.error ? `, ${r.error.message}` : ''})`)
|
|
181
|
+
} finally {
|
|
182
|
+
try { fs.rmSync(tmp, { recursive: true, force: true }) } catch {}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function checkGit() {
|
|
187
|
+
try {
|
|
188
|
+
const v = execFileSync('git', ['--version'], { encoding: 'utf8', timeout: 5000, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] }).trim()
|
|
189
|
+
return ok('git', v.replace(/^git version /, ''))
|
|
190
|
+
} catch {
|
|
191
|
+
return warn('git', 'git is not on PATH; checkout-guard and the git rules in basics pass everything')
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// One line per configured remote: reachable, bash, git and tar there, its folder writable.
|
|
196
|
+
// Nothing runs when no remote is configured, so doctor stays fast.
|
|
197
|
+
function checkRemotes(cfg, { env = process.env, probe = (r, ssh) => remote.probeSync(r, { ssh }) } = {}) {
|
|
198
|
+
const all = Object.values(cfg.values.remotes || {})
|
|
199
|
+
if (!all.length) return []
|
|
200
|
+
const tools = remote.localTools(env)
|
|
201
|
+
const out = []
|
|
202
|
+
const missing = ['ssh', 'tar', 'git'].filter(t => !tools[t])
|
|
203
|
+
if (missing.length) out.push(fail('remote tools', `${missing.join(', ')} not on PATH on this machine; bulkhead remote needs them`))
|
|
204
|
+
else out.push(ok('remote tools', `ssh ${shortPath(tools.ssh.join(' '))}, tar ${shortPath(tools.tar)}`))
|
|
205
|
+
if (!tools.ssh) return out
|
|
206
|
+
for (const r of all) {
|
|
207
|
+
const d = remote.describeProbe(r, probe(r, tools.ssh))
|
|
208
|
+
out.push({ id: `remote ${r.name}`, status: d.status, summary: d.line.slice(r.name.length + 2) })
|
|
209
|
+
}
|
|
210
|
+
return out
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function runAll({ remotes = true } = {}) {
|
|
214
|
+
const cfg = config.load()
|
|
215
|
+
return [
|
|
216
|
+
checkNode(),
|
|
217
|
+
checkPlugin(),
|
|
218
|
+
checkStateDir(),
|
|
219
|
+
...checkConfig(cfg),
|
|
220
|
+
...checkGuards(cfg),
|
|
221
|
+
checkHookRun(),
|
|
222
|
+
checkProcesses(),
|
|
223
|
+
checkSessionsDir(),
|
|
224
|
+
checkMemory(),
|
|
225
|
+
checkSlots(),
|
|
226
|
+
checkLog(cfg),
|
|
227
|
+
checkGit(),
|
|
228
|
+
...(remotes ? checkRemotes(cfg) : []),
|
|
229
|
+
]
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function render(checks, { color = false } = {}) {
|
|
233
|
+
const style = { OK: 'green', WARN: 'yellow', FAIL: 'red' }
|
|
234
|
+
const width = Math.max(...checks.map(c => c.id.length))
|
|
235
|
+
const lines = checks.map(c => `${paint(color, style[c.status], c.status.padEnd(4))} ${c.id.padEnd(width)} ${c.summary}`)
|
|
236
|
+
const count = s => checks.filter(c => c.status === s).length
|
|
237
|
+
lines.push('', `${count('OK')} OK, ${count('WARN')} WARN, ${count('FAIL')} FAIL`)
|
|
238
|
+
return lines.join('\n')
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function main(args, { stdout = process.stdout, env = process.env, checks = () => runAll({ remotes: !args.includes('--no-remote') }) } = {}) {
|
|
242
|
+
if (rejectArgs('doctor', args, { flags: ['json', 'no-remote'], valued: [] }, process.stderr, null)) return
|
|
243
|
+
const list = checks()
|
|
244
|
+
if (args.includes('--json')) {
|
|
245
|
+
stdout.write(JSON.stringify({ ok: !list.some(c => c.status === 'FAIL'), checks: list }, null, 2) + '\n')
|
|
246
|
+
} else {
|
|
247
|
+
stdout.write(render(list, { color: useColor(stdout, env) }) + '\n')
|
|
248
|
+
}
|
|
249
|
+
if (list.some(c => c.status === 'FAIL')) process.exitCode = 1
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
module.exports = {
|
|
253
|
+
main, render, runAll, checkNode, checkPlugin, checkStateDir, checkConfig, checkGuards, checkProcesses, checkSessionsDir,
|
|
254
|
+
checkMemory, checkSlots, checkLog, checkHookRun, checkGit, checkRemotes,
|
|
255
|
+
}
|
package/src/events.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const fs = require('fs')
|
|
3
|
+
const path = require('path')
|
|
4
|
+
|
|
5
|
+
// One JSON line per block in <state dir>/events.jsonl. Writing happens only on the block
|
|
6
|
+
// path, costs one stat and one append, and never throws.
|
|
7
|
+
const MAX_BYTES = 1024 * 1024
|
|
8
|
+
const MAX_COMMAND = 200
|
|
9
|
+
|
|
10
|
+
function eventsFile(dir) {
|
|
11
|
+
return path.join(dir, 'events.jsonl')
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function loggingOff(env = process.env) {
|
|
15
|
+
return /^(?:1|true|yes)$/i.test(String(env.BULKHEAD_NO_LOG || '').trim())
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function truncate(s, max = MAX_COMMAND) {
|
|
19
|
+
if (s == null) return null
|
|
20
|
+
const one = String(s).replace(/\s+/g, ' ').trim()
|
|
21
|
+
return one.length > max ? one.slice(0, max - 1) + '…' : one
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// event: { guard, rule, session, project, command, ...extra }. Returns true when written.
|
|
25
|
+
function record(dir, event, { env = process.env, now = Date.now(), maxBytes = MAX_BYTES } = {}) {
|
|
26
|
+
if (loggingOff(env)) return false
|
|
27
|
+
try {
|
|
28
|
+
const { guard, rule, session, project, command, ...extra } = event
|
|
29
|
+
const line = JSON.stringify({
|
|
30
|
+
time: new Date(now).toISOString(),
|
|
31
|
+
guard,
|
|
32
|
+
rule: rule || null,
|
|
33
|
+
session: session || null,
|
|
34
|
+
project: project || null,
|
|
35
|
+
command: truncate(command),
|
|
36
|
+
...extra,
|
|
37
|
+
}) + '\n'
|
|
38
|
+
const file = eventsFile(dir)
|
|
39
|
+
let size = -1
|
|
40
|
+
try { size = fs.statSync(file).size } catch {}
|
|
41
|
+
if (size < 0) fs.mkdirSync(dir, { recursive: true })
|
|
42
|
+
else if (size + line.length > maxBytes) {
|
|
43
|
+
// two hooks may rotate at once; the loser's rename fails and it appends to the new file
|
|
44
|
+
try { fs.renameSync(file, file + '.1') } catch {}
|
|
45
|
+
}
|
|
46
|
+
fs.appendFileSync(file, line)
|
|
47
|
+
return true
|
|
48
|
+
} catch {
|
|
49
|
+
return false
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function parseLines(text) {
|
|
54
|
+
const out = []
|
|
55
|
+
for (const line of text.split('\n')) {
|
|
56
|
+
if (!line.trim()) continue
|
|
57
|
+
try {
|
|
58
|
+
const e = JSON.parse(line)
|
|
59
|
+
if (e && e.time && e.guard) out.push(e)
|
|
60
|
+
} catch {}
|
|
61
|
+
}
|
|
62
|
+
return out
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// All events from the rotated file and the current one, oldest first.
|
|
66
|
+
function readEvents(dir) {
|
|
67
|
+
const file = eventsFile(dir)
|
|
68
|
+
const out = []
|
|
69
|
+
for (const f of [file + '.1', file]) {
|
|
70
|
+
try { for (const e of parseLines(fs.readFileSync(f, 'utf8'))) out.push(e) } catch {}
|
|
71
|
+
}
|
|
72
|
+
return out.sort((a, b) => (a.time < b.time ? -1 : a.time > b.time ? 1 : 0))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The last `n` events without reading the whole file, for `bulkhead top`.
|
|
76
|
+
function tailEvents(dir, n = 5, bytes = 64 * 1024) {
|
|
77
|
+
const file = eventsFile(dir)
|
|
78
|
+
let fd
|
|
79
|
+
try {
|
|
80
|
+
fd = fs.openSync(file, 'r')
|
|
81
|
+
const size = fs.fstatSync(fd).size
|
|
82
|
+
const start = Math.max(0, size - bytes)
|
|
83
|
+
const buf = Buffer.alloc(size - start)
|
|
84
|
+
fs.readSync(fd, buf, 0, buf.length, start)
|
|
85
|
+
const lines = buf.toString('utf8').split('\n')
|
|
86
|
+
if (start > 0) lines.shift()
|
|
87
|
+
const events = parseLines(lines.join('\n'))
|
|
88
|
+
if (events.length >= n || start > 0) return events.slice(-n)
|
|
89
|
+
} catch {
|
|
90
|
+
} finally {
|
|
91
|
+
if (fd !== undefined) try { fs.closeSync(fd) } catch {}
|
|
92
|
+
}
|
|
93
|
+
return readEvents(dir).slice(-n)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function logSize(dir) {
|
|
97
|
+
let bytes = 0
|
|
98
|
+
let files = 0
|
|
99
|
+
for (const f of [eventsFile(dir), eventsFile(dir) + '.1']) {
|
|
100
|
+
try {
|
|
101
|
+
bytes += fs.statSync(f).size
|
|
102
|
+
files++
|
|
103
|
+
} catch {}
|
|
104
|
+
}
|
|
105
|
+
return { bytes, files }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
module.exports = { record, readEvents, tailEvents, logSize, eventsFile, loggingOff, truncate, MAX_BYTES }
|
package/src/hook.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const fs = require('fs')
|
|
3
|
+
const os = require('os')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
|
|
6
|
+
// Fixed, not CLAUDE_PLUGIN_DATA: the bulkhead-slot CLI runs from the Bash tool, which
|
|
7
|
+
// does not receive that variable, and both sides must see the same slot files.
|
|
8
|
+
function stateDir() {
|
|
9
|
+
return process.env.BULKHEAD_STATE_DIR || path.join(os.homedir(), '.claude', 'bulkhead')
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// BULKHEAD_DISABLE first, then the config files. A broken config file never turns a guard off.
|
|
13
|
+
function enabled(name) {
|
|
14
|
+
const off = (process.env.BULKHEAD_DISABLE || '').split(/[\s,]+/).filter(Boolean)
|
|
15
|
+
if (off.includes(name) || off.includes('all')) return false
|
|
16
|
+
try {
|
|
17
|
+
return require('./config').isEnabled(name)
|
|
18
|
+
} catch {
|
|
19
|
+
return true
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// A value from the config files or its environment variable (see config.js).
|
|
24
|
+
function setting(key, fallback) {
|
|
25
|
+
try {
|
|
26
|
+
const v = require('./config').get(key)
|
|
27
|
+
return v == null ? fallback : v
|
|
28
|
+
} catch {
|
|
29
|
+
return fallback
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function envNumber(name, fallback) {
|
|
34
|
+
const n = Number(process.env[name])
|
|
35
|
+
return process.env[name] && Number.isFinite(n) ? n : fallback
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function log(name, line) {
|
|
39
|
+
try {
|
|
40
|
+
const dir = stateDir()
|
|
41
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
42
|
+
fs.appendFileSync(path.join(dir, 'guards.log'), `${new Date().toISOString()} ${name} ${line}\n`)
|
|
43
|
+
} catch {}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Records a block in guards.log and events.jsonl and returns the hook result that stops the call.
|
|
47
|
+
// One line for the agent and the person reading it: a block can be intentional, and the answer
|
|
48
|
+
// to that is a question to the user, not a way around the guard.
|
|
49
|
+
const ASK_USER = 'If the user wants this on purpose, ask them: they can allow it in .claude/bulkhead.json (see docs/configuration.md).'
|
|
50
|
+
|
|
51
|
+
function blocked(name, rule, input, message, command = input && input.tool_input && input.tool_input.command) {
|
|
52
|
+
log(name, `blocked ${rule}: ${oneLine(command || '')}`)
|
|
53
|
+
recordEvent(name, rule, input, command)
|
|
54
|
+
return { block: message }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function recordEvent(name, rule, input = {}, command = null, extra = {}) {
|
|
58
|
+
if (setting('log.enabled', true) === false) return
|
|
59
|
+
require('./events').record(stateDir(), {
|
|
60
|
+
guard: name,
|
|
61
|
+
rule,
|
|
62
|
+
session: input.session_id || null,
|
|
63
|
+
project: process.env.CLAUDE_PROJECT_DIR || input.cwd || null,
|
|
64
|
+
command,
|
|
65
|
+
...extra,
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function oneLine(s, max = 300) {
|
|
70
|
+
return String(s).replace(/\s+/g, ' ').trim().slice(0, max)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Reads the hook payload from stdin and hands it to `handler`. The handler returns
|
|
74
|
+
// nothing to allow, { block: message } to stop the tool call (exit 2), or
|
|
75
|
+
// { output: object } to print JSON, or a promise of either. Any error lets the call through.
|
|
76
|
+
function runHook(name, handler) {
|
|
77
|
+
if (!enabled(name)) return
|
|
78
|
+
let raw = ''
|
|
79
|
+
process.stdin.setEncoding('utf8')
|
|
80
|
+
process.stdin.on('data', d => (raw += d))
|
|
81
|
+
process.stdin.on('end', () => {
|
|
82
|
+
Promise.resolve().then(() => handler(JSON.parse(raw || '{}'))).then(result => {
|
|
83
|
+
if (result && result.block) {
|
|
84
|
+
process.stderr.write(result.block.endsWith('\n') ? result.block : result.block + '\n')
|
|
85
|
+
process.exit(2)
|
|
86
|
+
}
|
|
87
|
+
if (result && result.output) process.stdout.write(JSON.stringify(result.output))
|
|
88
|
+
process.exit(0)
|
|
89
|
+
}, () => process.exit(0))
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = {
|
|
94
|
+
ASK_USER, runHook, enabled, setting, envNumber, log, oneLine, stateDir, blocked, recordEvent }
|