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,145 @@
1
+ 'use strict'
2
+ const { runHook, blocked } = require('./hook')
3
+ const { readMemory } = require('./memory')
4
+ const { limits } = require('./slots')
5
+ const shell = require('./shell')
6
+
7
+ // Commands that commonly take several GB of RAM: test runners with workers, full builds,
8
+ // bundlers, emulators and browsers. Each test gets the command's words with wrappers taken off.
9
+ const sub = argv => argv.slice(1).find(a => !a.startsWith('-')) || ''
10
+ const has = (argv, re) => argv.slice(1).some(a => re.test(a))
11
+ const quiet = argv => has(argv, /^(?:--version|-v|-V|--help|-h)$/)
12
+
13
+ // Gradle tasks that do not compile anything; any other task starts a build.
14
+ const GRADLE_LIGHT = /^(?:tasks|help|properties|dependencies|projects|wrapper|clean|init|--stop|--status|--version|-v|--help|-h|-q|--quiet|--offline)$/
15
+ const gradle = argv => argv.slice(1).some(a => !GRADLE_LIGHT.test(a) && !/^-D|^-P/.test(a))
16
+
17
+ const HEAVY_BINS = {
18
+ vitest: argv => !quiet(argv),
19
+ jest: argv => !quiet(argv),
20
+ karma: argv => sub(argv) === 'start' || !sub(argv),
21
+ playwright: argv => sub(argv) === 'test',
22
+ cypress: argv => sub(argv) === 'run',
23
+ gradlew: gradle,
24
+ gradle,
25
+ mvn: argv => has(argv, /^(?:test|package|verify|install|deploy|integration-test)$/),
26
+ mvnw: argv => has(argv, /^(?:test|package|verify|install|deploy|integration-test)$/),
27
+ emulator: argv => has(argv, /^(?:-avd|@.+)$/),
28
+ dotnet: argv => /^(?:build|test|publish)$/.test(sub(argv)),
29
+ next: argv => /^(?:build|dev)$/.test(sub(argv)),
30
+ vite: argv => sub(argv) === 'build',
31
+ tsc: argv => !quiet(argv) && !has(argv, /^--init$/),
32
+ webpack: argv => !quiet(argv) && sub(argv) !== 'serve',
33
+ ffmpeg: argv => !quiet(argv) && argv.length > 1,
34
+ cargo: argv => /^(?:build|b|test|t|bench)$/.test(sub(argv)),
35
+ go: argv => sub(argv) === 'test' && has(argv, /\/\.\.\.$|^\.\.\.$/),
36
+ pytest: argv => has(argv, /^(?:-n\d*|--numprocesses(?:=.*)?)$/),
37
+ docker: argv => /^(?:build|buildx)$/.test(sub(argv)) || (sub(argv) === 'compose' && has(argv, /^(?:up|build)$/)),
38
+ 'docker-compose': argv => has(argv, /^(?:up|build)$/),
39
+ bazel: argv => /^(?:build|test|run)$/.test(sub(argv)),
40
+ turbo: argv => has(argv, /^(?:build|test)$/),
41
+ nx: argv => /^(?:build|test|run-many|affected)$/.test(sub(argv)),
42
+ }
43
+ const RUNNERS = /^(?:npm|pnpm|yarn|bun)$/
44
+ // npx vitest, pnpm exec jest, yarn dlx, bunx, python -m pytest, node node_modules/.bin/jest
45
+ function realCommand(argv) {
46
+ for (let guard = 0; guard < 8 && argv.length; guard++) {
47
+ const b = shell.bin(argv[0])
48
+ if (/^(?:npx|bunx|pnpx)$/.test(b)) {
49
+ const at = argv.findIndex((a, k) => k > 0 && !a.startsWith('-'))
50
+ argv = at > 0 ? argv.slice(at) : []
51
+ } else if (RUNNERS.test(b) && /^(?:exec|dlx|x)$/.test(argv[1])) argv = argv.slice(2)
52
+ else if (/^python[\d.]*$|^py$/.test(b) && argv[1] === '-m' && argv[2]) argv = argv.slice(2)
53
+ else if (b === 'node' && argv[1] && /node_modules[\\/]/.test(argv[1])) argv = argv.slice(1)
54
+ else break
55
+ }
56
+ return argv
57
+ }
58
+
59
+ function heavyArgv(argv) {
60
+ argv = realCommand(argv)
61
+ if (!argv.length) return false
62
+ const b = shell.bin(argv[0])
63
+ if (HEAVY_BINS[b]) return HEAVY_BINS[b](argv)
64
+ // npm test, npm run build, pnpm build, yarn e2e, bun test
65
+ if (RUNNERS.test(b)) {
66
+ let k = 1
67
+ while (argv[k] && argv[k].startsWith('-')) k += /^(?:--prefix|-C|--cwd|--dir|--filter|-F|-w|--workspace)$/.test(argv[k]) ? 2 : 1
68
+ let script = argv[k] || ''
69
+ if (script === 'run' || script === 'run-script') script = argv[k + 1] || ''
70
+ return /^(?:build|test|t|e2e)(?:$|[:-])/.test(script)
71
+ }
72
+ return false
73
+ }
74
+
75
+ // A browser launched from code: chromium.launch(), puppeteer.launch(), launchPersistentContext.
76
+ const LAUNCH = /\b(?:chromium|firefox|webkit|puppeteer)\.launch\b|\blaunchPersistentContext\b/
77
+
78
+ // Commands that only read or search text: grep -rn vitest package.json
79
+ const INERT = /^(?:git|grep|egrep|rg|ag|echo|printf|cat|less|head|tail|ls|find|which|where|type|select-string|get-content|gc|write-host|write-output)$/i
80
+
81
+ function isHeavy(cmd, mode = 'sh') {
82
+ const text = shell.prepare(String(cmd || ''), mode)
83
+ for (const c of shell.commands(text, mode)) {
84
+ const b = shell.bin(c.argv[0])
85
+ if (INERT.test(b) || c.remote) continue
86
+ if (/^bulkhead(?:-slot)?$/.test(b)) continue
87
+ if (heavyArgv(c.argv)) return true
88
+ if (LAUNCH.test(c.argv.join(' '))) return true
89
+ }
90
+ return false
91
+ }
92
+
93
+ const SLOT_FILE = /bulkhead[\\/]+slots|slot-\d+\.json/i
94
+ const WRITES = /(?:>{1,2}\|?|\b(?:touch|rm|del|unlink|mv|cp|Remove-Item|Set-Content|Add-Content|Out-File|New-Item|Clear-Content|Move-Item|Copy-Item|writeFileSync|unlinkSync|rmSync|utimesSync)\b)/i
95
+
96
+ // Hand-editing slot files is how a forgotten background loop kept a slot for hours.
97
+ function touchesSlotFiles(cmd) {
98
+ // 2>/dev/null and 2>&1 only redirect error output; they write nothing to the slot files
99
+ const quietRedirects = String(cmd).replace(/\d?>&\d|\d?>\s*(?:\/dev\/null|\$null|nul)\b/gi, ' ')
100
+ return quietRedirects.split(/\n|;|&&|\|\|/).some(s => SLOT_FILE.test(s) && WRITES.test(s))
101
+ }
102
+
103
+ // A command that runs through bulkhead-slot already waits for room.
104
+ function queued(cmd) {
105
+ return /\bbulkhead(?:-|\s+)slot\b/.test(String(cmd).replace(/'[^']*'|"[^"]*"/g, ' '))
106
+ }
107
+
108
+ // With a remote configured, the block also offers running the command there (docs/remote.md).
109
+ function hasRemote() {
110
+ try {
111
+ return Object.keys(require('./config').get('remotes') || {}).length > 0
112
+ } catch {
113
+ return false
114
+ }
115
+ }
116
+
117
+ function check(cmd, memory = () => readMemory(), minFreeGb, mode = 'sh', remote = hasRemote) {
118
+ if (!cmd) return null
119
+ if (touchesSlotFiles(cmd)) {
120
+ return '[bulkhead] memory-guard: slot files are managed by bulkhead-slot. Do not write, touch or delete them by hand; ' +
121
+ 'use `bulkhead-slot run --gb=N -- <command>`, which releases its slot when the command exits. `bulkhead-slot status` shows the holders.'
122
+ }
123
+ if (queued(cmd) || !isHeavy(cmd, mode)) return null
124
+ const { freeGb, totalGb } = memory()
125
+ if (minFreeGb == null) minFreeGb = limits(totalGb).minFreeGb
126
+ if (freeGb >= minFreeGb) return null
127
+ return `[bulkhead] memory-guard: only ${freeGb.toFixed(1)} GB of RAM is free (minimum ${minFreeGb} GB) and this looks like a heavy command.\n` +
128
+ 'Other sessions are probably building or testing right now. Starting more work at this point is how machines freeze.\n' +
129
+ 'Either wait a minute and retry with fewer workers (for example --maxWorkers=2), or queue it:\n' +
130
+ ' bulkhead-slot run --gb=3 -- <command>\n' +
131
+ 'bulkhead-slot waits until there is room, runs the command, and frees the slot when it exits.' +
132
+ (remote() ? '\nOr run it on another machine, so it uses that machine\'s RAM:\n bulkhead remote run -- <command>' : '')
133
+ }
134
+
135
+ if (require.main === module) {
136
+ runHook('memory-guard', input => {
137
+ const cmd = input.tool_input && input.tool_input.command
138
+ if (typeof cmd !== 'string') return
139
+ const why = check(cmd, undefined, undefined, shell.modeFor(input))
140
+ if (!why) return
141
+ return blocked('memory-guard', touchesSlotFiles(cmd) ? 'slot-files' : 'low-memory', input, why)
142
+ })
143
+ }
144
+
145
+ module.exports = { check, isHeavy, touchesSlotFiles }
package/src/memory.js ADDED
@@ -0,0 +1,76 @@
1
+ 'use strict'
2
+ const fs = require('fs')
3
+ const os = require('os')
4
+ const { execFileSync } = require('child_process')
5
+
6
+ const GB = 2 ** 30
7
+
8
+ function field(text, name) {
9
+ const m = text.match(new RegExp(`^${name}:\\s+(\\d+)`, 'm'))
10
+ return m ? Number(m[1]) : null
11
+ }
12
+
13
+ // Linux: MemAvailable already counts reclaimable cache. Commit is not used because
14
+ // the default overcommit mode makes Committed_AS routinely exceed CommitLimit.
15
+ function parseMeminfo(text) {
16
+ const total = field(text, 'MemTotal')
17
+ let avail = field(text, 'MemAvailable')
18
+ if (avail === null) avail = (field(text, 'MemFree') || 0) + (field(text, 'Buffers') || 0) + (field(text, 'Cached') || 0)
19
+ const swapTotal = field(text, 'SwapTotal')
20
+ const swap = swapTotal ? { swapTotalGb: (swapTotal * 1024) / GB, swapUsedGb: ((swapTotal - (field(text, 'SwapFree') || 0)) * 1024) / GB } : {}
21
+ return { totalGb: (total * 1024) / GB, freeGb: (avail * 1024) / GB, commitPct: null, ...swap }
22
+ }
23
+
24
+ // macOS: os.freemem() only counts pages that are completely unused, which is near zero
25
+ // on a healthy Mac. We approximate Activity Monitor instead:
26
+ // available = total - (app memory + wired + compressed), app memory = anonymous - purgeable.
27
+ // Older vm_stat output has no "Anonymous pages"; then free + inactive + speculative + purgeable.
28
+ function parseVmStat(text, totalBytes) {
29
+ const page = Number((text.match(/page size of (\d+) bytes/) || [])[1]) || 4096
30
+ const n = name => {
31
+ const m = text.match(new RegExp(`^${name}:\\s+(\\d+)`, 'm'))
32
+ return m ? Number(m[1]) : 0
33
+ }
34
+ let freeBytes
35
+ if (/^Anonymous pages:/m.test(text)) {
36
+ const used = n('Anonymous pages') - n('Pages purgeable') + n('Pages wired down') + n('Pages occupied by compressor')
37
+ freeBytes = totalBytes - used * page
38
+ } else {
39
+ freeBytes = (n('Pages free') + n('Pages inactive') + n('Pages speculative') + n('Pages purgeable')) * page
40
+ }
41
+ return { totalGb: totalBytes / GB, freeGb: Math.max(0, freeBytes) / GB, commitPct: null }
42
+ }
43
+
44
+ // Windows: commit charge can run out while RAM still looks "free", and the machine stalls when it does.
45
+ function parseWindowsCommit(text) {
46
+ const [total, free] = String(text).trim().split(/\s+/).map(Number)
47
+ if (!(total > 0) || !Number.isFinite(free)) return null
48
+ return Math.round((100 * (total - free)) / total)
49
+ }
50
+
51
+ function windowsCommitPct(run) {
52
+ try {
53
+ return parseWindowsCommit(run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command',
54
+ '$o = Get-CimInstance Win32_OperatingSystem; "$($o.TotalVirtualMemorySize) $($o.FreeVirtualMemory)"']))
55
+ } catch {
56
+ return null
57
+ }
58
+ }
59
+
60
+ const defaultRun = (file, args) => execFileSync(file, args, { encoding: 'utf8', timeout: 8000, windowsHide: true })
61
+
62
+ // { totalGb, freeGb, commitPct } where commitPct is measured only on Windows and only on request.
63
+ function readMemory({ platform = process.platform, withCommit = false, run = defaultRun, readFile = fs.readFileSync } = {}) {
64
+ if (process.env.BULKHEAD_TEST_MEMORY) return JSON.parse(process.env.BULKHEAD_TEST_MEMORY)
65
+ if (platform === 'linux') {
66
+ try { return parseMeminfo(readFile('/proc/meminfo', 'utf8')) } catch {}
67
+ }
68
+ if (platform === 'darwin') {
69
+ try { return parseVmStat(run('vm_stat', []), os.totalmem()) } catch {}
70
+ }
71
+ const mem = { totalGb: os.totalmem() / GB, freeGb: os.freemem() / GB, commitPct: null }
72
+ if (platform === 'win32' && withCommit) mem.commitPct = windowsCommitPct(run)
73
+ return mem
74
+ }
75
+
76
+ module.exports = { readMemory, parseMeminfo, parseVmStat, parseWindowsCommit }