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