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,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 }