claude-bulkhead 0.2.2 → 0.3.0
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 +51 -0
- package/README.es.md +142 -50
- package/README.ja.md +142 -50
- package/README.ko.md +143 -51
- package/README.md +142 -50
- package/README.pt-BR.md +142 -50
- package/README.zh-CN.md +154 -62
- package/hooks/hooks.json +68 -8
- package/package.json +5 -2
- package/src/agent-hook.js +162 -0
- package/src/agent-setup.js +256 -0
- package/src/agents.js +89 -0
- package/src/allow-cli.js +65 -0
- package/src/allow-hint.js +62 -0
- package/src/allow-once.js +152 -0
- package/src/auto-queue.js +93 -0
- package/src/bash-guards.js +36 -0
- package/src/basics.js +46 -27
- package/src/checkout-guard.js +342 -74
- package/src/cli-util.js +13 -1
- package/src/cli.js +40 -4
- package/src/config-check.js +21 -4
- package/src/config-cli.js +75 -10
- package/src/config.js +106 -9
- package/src/dev-servers.js +361 -61
- package/src/disk-guard.js +8 -8
- package/src/doctor-enforcement.js +273 -0
- package/src/doctor.js +19 -9
- package/src/edit-guard.js +196 -0
- package/src/events.js +30 -5
- package/src/explain.js +251 -0
- package/src/git-info.js +55 -0
- package/src/gitargs.js +82 -0
- package/src/hook.js +38 -6
- package/src/kill-extras.js +219 -0
- package/src/kill-guard.js +228 -77
- package/src/kill-owners.js +116 -0
- package/src/log-cli.js +15 -10
- package/src/mcp.js +131 -0
- package/src/memory-guard.js +126 -24
- package/src/memory.js +21 -2
- package/src/notices.js +95 -0
- package/src/orphan-reaper.js +58 -11
- package/src/port-cli.js +78 -3
- package/src/port-guard.js +80 -68
- package/src/port-kills.js +198 -0
- package/src/pressure.js +75 -0
- package/src/processes.js +44 -12
- package/src/scan-report.js +166 -0
- package/src/scan.js +87 -18
- package/src/secrets-guard.js +301 -0
- package/src/self-allow.js +73 -0
- package/src/session-info.js +121 -0
- package/src/session-state.js +176 -0
- package/src/sessions.js +109 -20
- package/src/shell.js +279 -19
- package/src/slot-cli.js +92 -21
- package/src/slots.js +61 -13
- package/src/stable-port.js +61 -0
- package/src/status.js +55 -17
- package/src/statusline.js +45 -16
- package/src/subagent-guard.js +24 -0
- package/src/top.js +36 -11
- package/src/wait-guard.js +96 -32
- package/src/worktree-cli.js +454 -0
- package/src/worktree-copy.js +82 -0
package/src/cli.js
CHANGED
|
@@ -3,19 +3,32 @@
|
|
|
3
3
|
const USAGE = `usage:
|
|
4
4
|
bulkhead status [--json] sessions, their ports and memory, slots, free RAM
|
|
5
5
|
bulkhead top [--interval=SECONDS] the same, live, full screen (q quits, r refreshes)
|
|
6
|
-
bulkhead log [-n N] [--
|
|
6
|
+
bulkhead log [SPAN] [-n N] [--guard NAME] [--summary] [--json]
|
|
7
7
|
what the guards blocked
|
|
8
8
|
bulkhead scan [--since 30d] [--project TEXT] [-n N] [--json]
|
|
9
9
|
replay your Claude Code transcripts against the guards
|
|
10
|
-
bulkhead
|
|
10
|
+
bulkhead scan --share [--markdown] paste-ready summary of those numbers, counts only
|
|
11
|
+
bulkhead doctor [--json] [--no-remote] check the install, enforcement and this machine
|
|
11
12
|
bulkhead config [--json] effective settings and where each one came from
|
|
13
|
+
bulkhead config --preset quiet|normal|strict [--project]
|
|
14
|
+
fewer or more guards at once
|
|
15
|
+
bulkhead explain [--cwd DIR] [--session-id ID] [--json] -- <command...>
|
|
16
|
+
what every guard would say about a command, nothing runs
|
|
17
|
+
bulkhead allow <code> | --list allow one blocked command once (you run this, not the agent)
|
|
12
18
|
bulkhead port [--near PORT] [--json] a port no process is listening on
|
|
19
|
+
bulkhead port --stable [--name NAME] [--range 3000-3999]
|
|
20
|
+
this worktree's own port, the same every time
|
|
21
|
+
bulkhead worktree add|list|prune one-step worktrees: env files, install, stable port, safe cleanup
|
|
13
22
|
bulkhead reap [--dry-run] [--json] stop what exited sessions left running
|
|
23
|
+
bulkhead hook --agent NAME the guards for Codex, Gemini, Copilot, Cursor and other agents (reads the pre-tool payload on stdin)
|
|
24
|
+
bulkhead setup AGENT [--print|--write] the hook config for that agent: print it, or merge it into the agent's config file
|
|
14
25
|
bulkhead slot run [--gb=N] [--wait=MINUTES] [--label=TEXT] -- <command...>
|
|
15
26
|
bulkhead slot status [--gb=N] same as bulkhead-slot
|
|
16
27
|
bulkhead remote run [--remote=NAME] [--gb=N] [--tail=N] -- <command...>
|
|
17
28
|
run a heavy command on another machine over ssh
|
|
18
|
-
bulkhead remote status|list|add|remove|fetch see bulkhead remote --help
|
|
29
|
+
bulkhead remote status|list|add|remove|fetch see bulkhead remote --help
|
|
30
|
+
bulkhead version print the version
|
|
31
|
+
bulkhead help <command> usage of one command`
|
|
19
32
|
|
|
20
33
|
const COMMANDS = {
|
|
21
34
|
status: './status',
|
|
@@ -24,18 +37,41 @@ const COMMANDS = {
|
|
|
24
37
|
scan: './scan',
|
|
25
38
|
doctor: './doctor',
|
|
26
39
|
config: './config-cli',
|
|
40
|
+
explain: './explain',
|
|
41
|
+
allow: './allow-cli',
|
|
27
42
|
port: './port-cli',
|
|
28
43
|
reap: './reap-cli',
|
|
44
|
+
worktree: './worktree-cli',
|
|
45
|
+
hook: './agent-hook',
|
|
46
|
+
setup: './agent-setup',
|
|
29
47
|
slot: './slot-cli',
|
|
30
48
|
remote: './remote',
|
|
31
49
|
}
|
|
32
50
|
|
|
51
|
+
// The USAGE lines of one command: its own text when its module has one, else its block of the list above.
|
|
52
|
+
function usageOf(cmd) {
|
|
53
|
+
const own = COMMANDS[cmd] && require(COMMANDS[cmd]).USAGE
|
|
54
|
+
if (own) return own
|
|
55
|
+
const out = []
|
|
56
|
+
let on = false
|
|
57
|
+
for (const line of USAGE.split('\n').slice(1)) {
|
|
58
|
+
if (/^ {2}bulkhead \S/.test(line)) on = line.startsWith(` bulkhead ${cmd}`)
|
|
59
|
+
if (on) out.push(line)
|
|
60
|
+
}
|
|
61
|
+
return out.length ? `usage:\n${out.join('\n')}` : null
|
|
62
|
+
}
|
|
63
|
+
|
|
33
64
|
function main(argv) {
|
|
34
65
|
const [cmd, ...rest] = argv
|
|
66
|
+
if (cmd === '--version' || cmd === '-v' || cmd === 'version') return console.log(require('../package.json').version)
|
|
67
|
+
if (cmd === 'help' && rest[0] && COMMANDS[rest[0]]) return console.log(usageOf(rest[0]) || USAGE)
|
|
68
|
+
// remote has its own --help with subcommands
|
|
69
|
+
if (COMMANDS[cmd] && cmd !== 'remote' && (rest.includes('-h') || rest.includes('--help'))) return console.log(usageOf(cmd) || USAGE)
|
|
35
70
|
if (COMMANDS[cmd]) return require(COMMANDS[cmd]).main(rest)
|
|
36
71
|
const help = cmd === '--help' || cmd === '-h' || cmd === 'help'
|
|
72
|
+
if (!help && cmd) console.error(`bulkhead: unknown command "${cmd}"`)
|
|
37
73
|
;(help ? console.log : console.error)(USAGE)
|
|
38
74
|
process.exit(help ? 0 : 1)
|
|
39
75
|
}
|
|
40
76
|
|
|
41
|
-
module.exports = { main, USAGE }
|
|
77
|
+
module.exports = { main, USAGE, COMMANDS }
|
package/src/config-check.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
'use strict'
|
|
2
|
-
// SessionStart
|
|
3
|
-
//
|
|
2
|
+
// SessionStart, one message for the user: the first-run line (once ever), what orphan-reaper stopped
|
|
3
|
+
// since the last message, and a broken config file. The guards ignore a broken file and run on
|
|
4
|
+
// their defaults, so without this nobody would notice a typo.
|
|
4
5
|
const { load } = require('./config')
|
|
5
6
|
const { shortPath } = require('./cli-util')
|
|
7
|
+
const { stateDir } = require('./hook')
|
|
8
|
+
const notices = require('./notices')
|
|
6
9
|
|
|
7
10
|
function notice(cfg) {
|
|
8
11
|
if (!cfg.warnings.length) return null
|
|
@@ -11,6 +14,20 @@ function notice(cfg) {
|
|
|
11
14
|
return `[bulkhead] config problem, defaults are used where it applies:\n${lines.join('\n')}\nRun \`bulkhead config\` or \`bulkhead doctor\` to check.`
|
|
12
15
|
}
|
|
13
16
|
|
|
17
|
+
function part(f) {
|
|
18
|
+
try { return f() } catch { return null }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Each part fails on its own, so a broken one never hides the others.
|
|
22
|
+
function message(input, dir = stateDir()) {
|
|
23
|
+
const parts = [
|
|
24
|
+
part(() => notices.firstRun(dir)),
|
|
25
|
+
part(() => notices.format(notices.claim(dir))),
|
|
26
|
+
part(() => notice(load({ cwd: process.env.CLAUDE_PROJECT_DIR || input.cwd || process.cwd() }))),
|
|
27
|
+
].filter(Boolean)
|
|
28
|
+
return parts.length ? parts.join('\n') : null
|
|
29
|
+
}
|
|
30
|
+
|
|
14
31
|
if (require.main === module) {
|
|
15
32
|
let raw = ''
|
|
16
33
|
process.stdin.setEncoding('utf8')
|
|
@@ -19,11 +36,11 @@ if (require.main === module) {
|
|
|
19
36
|
try {
|
|
20
37
|
let input = {}
|
|
21
38
|
try { input = JSON.parse(raw || '{}') } catch {}
|
|
22
|
-
const msg =
|
|
39
|
+
const msg = message(input)
|
|
23
40
|
if (msg) process.stdout.write(JSON.stringify({ systemMessage: msg }))
|
|
24
41
|
} catch {}
|
|
25
42
|
process.exit(0)
|
|
26
43
|
})
|
|
27
44
|
}
|
|
28
45
|
|
|
29
|
-
module.exports = { notice }
|
|
46
|
+
module.exports = { notice, message }
|
package/src/config-cli.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
const fs = require('fs')
|
|
3
|
-
const
|
|
4
|
-
const
|
|
3
|
+
const os = require('os')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
const { load, KEYS, PRESETS, globalFile, projectFile } = require('./config')
|
|
6
|
+
const { useColor, paint, shortPath, table, rejectArgs, option } = require('./cli-util')
|
|
5
7
|
|
|
6
8
|
function fileState(file, warnings) {
|
|
7
9
|
if (!file) return 'none found'
|
|
@@ -9,19 +11,37 @@ function fileState(file, warnings) {
|
|
|
9
11
|
return warnings.some(w => w.startsWith(file) && /not valid JSON/.test(w)) ? 'INVALID, ignored' : 'loaded'
|
|
10
12
|
}
|
|
11
13
|
|
|
14
|
+
const USAGE = `usage: bulkhead config [--json]
|
|
15
|
+
bulkhead config --preset quiet|normal|strict [--project]
|
|
16
|
+
|
|
17
|
+
Without options, prints every setting, its value and where the value came from.
|
|
18
|
+
--preset writes "preset" into the global config file, or with --project into this project's
|
|
19
|
+
.claude/bulkhead.json, keeping every other key. quiet: only the cross-session guards and the
|
|
20
|
+
rules that cannot be undone. normal: the defaults. strict: everything on, with tighter limits.
|
|
21
|
+
Keys you set yourself and environment variables still win over a preset.`
|
|
22
|
+
|
|
23
|
+
// The two RAM limits left on auto, as the number they work out to on this machine.
|
|
24
|
+
function autoValue(key, totalGb) {
|
|
25
|
+
const { autoLimits } = require('./slots')
|
|
26
|
+
const a = autoLimits(totalGb)
|
|
27
|
+
const gb = key === 'memory.minFreeGb' ? a.minFreeGb : key === 'memory.slotFloorGb' ? a.floorGb : null
|
|
28
|
+
return gb == null ? 'auto' : `auto (${gb} GB on this machine)`
|
|
29
|
+
}
|
|
30
|
+
|
|
12
31
|
// The remotes map is long; the table shows the names and `bulkhead remote list` the rest.
|
|
13
|
-
const show = (v, key) => (v === null ?
|
|
32
|
+
const show = (v, key, totalGb) => (v === null ? autoValue(key, totalGb) : key === 'remotes' ? Object.keys(v).join(', ') || 'none' : JSON.stringify(v))
|
|
14
33
|
|
|
15
|
-
function render(cfg, { color = false, home } = {}) {
|
|
34
|
+
function render(cfg, { color = false, home, totalGb = os.totalmem() / 2 ** 30 } = {}) {
|
|
16
35
|
const out = []
|
|
17
36
|
out.push(paint(color, 'bold', 'Files (project wins over global, environment wins over both)'))
|
|
18
37
|
out.push(` global ${shortPath(cfg.files.global, home)} ${fileState(cfg.files.global, cfg.warnings)}`)
|
|
19
38
|
out.push(` project ${cfg.files.project ? shortPath(cfg.files.project, home) : '<repo>/.claude/bulkhead.json'} ${fileState(cfg.files.project, cfg.warnings)}`)
|
|
39
|
+
out.push(` preset ${cfg.preset || 'normal'} ${cfg.presetFrom && cfg.presetFrom !== 'default' ? `(from ${cfg.presetFrom.startsWith('env ') ? cfg.presetFrom : shortPath(cfg.presetFrom, home)})` : '(default)'}`)
|
|
20
40
|
out.push('')
|
|
21
41
|
const rows = [['KEY', 'VALUE', 'FROM']]
|
|
22
42
|
for (const key of Object.keys(KEYS)) {
|
|
23
43
|
const src = cfg.sources[key]
|
|
24
|
-
rows.push([key, show(cfg.values[key], key), src === 'default' || src.startsWith('env ') ? src : shortPath(src, home)])
|
|
44
|
+
rows.push([key, show(cfg.values[key], key, totalGb), src === 'default' || src.startsWith('env ') || src.startsWith('preset ') ? src : shortPath(src, home)])
|
|
25
45
|
}
|
|
26
46
|
const lines = table(rows)
|
|
27
47
|
out.push(paint(color, 'bold', lines[0]))
|
|
@@ -36,16 +56,61 @@ function render(cfg, { color = false, home } = {}) {
|
|
|
36
56
|
return out.join('\n')
|
|
37
57
|
}
|
|
38
58
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
59
|
+
// Sets "preset" in one config file and leaves every other key alone. A file that is not valid
|
|
60
|
+
// JSON is never overwritten.
|
|
61
|
+
function writePreset(file, name) {
|
|
62
|
+
let obj = {}
|
|
63
|
+
let text = null
|
|
64
|
+
try { text = fs.readFileSync(file, 'utf8') } catch {}
|
|
65
|
+
if (text !== null) {
|
|
66
|
+
try { obj = JSON.parse(text.replace(/^\uFEFF/, '')) } catch (e) { return { error: `${file} is not valid JSON (${e.message}); fix it first` } }
|
|
67
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return { error: `${file} should hold a JSON object` }
|
|
68
|
+
}
|
|
69
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
70
|
+
const tmp = `${file}.${process.pid}.tmp`
|
|
71
|
+
fs.writeFileSync(tmp, JSON.stringify({ ...obj, preset: name }, null, 2) + '\n')
|
|
72
|
+
fs.renameSync(tmp, file)
|
|
73
|
+
return { file }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function setPreset(args, { env, stdout, stderr, cwd }) {
|
|
77
|
+
const value = option(args, 'preset')
|
|
78
|
+
const name = String(value)
|
|
79
|
+
if (!PRESETS[name]) {
|
|
80
|
+
const list = Object.keys(PRESETS).join(', ')
|
|
81
|
+
stderr.write(value === true ? `bulkhead config: --preset needs a name: ${list}\n` : `bulkhead config: unknown preset ${name}; use ${list}\n`)
|
|
82
|
+
process.exitCode = 1
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
const project = args.includes('--project')
|
|
86
|
+
const file = project ? projectFile(cwd) || path.join(path.resolve(cwd), '.claude', 'bulkhead.json') : globalFile(env)
|
|
87
|
+
let res
|
|
88
|
+
try { res = writePreset(file, name) } catch (e) { res = { error: `could not write ${file}: ${e.message}` } }
|
|
89
|
+
if (res.error) {
|
|
90
|
+
stderr.write(`bulkhead config: ${res.error}\n`)
|
|
91
|
+
process.exitCode = 1
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
stdout.write(`preset ${name} written to ${shortPath(file)}${project ? '' : ' (every project; a project file or BULKHEAD_PRESET can still override it)'}\n`)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function main(args, { env = process.env, stdout = process.stdout, stderr = process.stderr, cwd = env.CLAUDE_PROJECT_DIR || process.cwd() } = {}) {
|
|
98
|
+
if (args.includes('--help') || args.includes('-h')) return stdout.write(USAGE + '\n')
|
|
99
|
+
if (rejectArgs('config', args, { flags: ['json', 'project'], valued: ['preset'] }, stderr, USAGE)) return
|
|
100
|
+
if (option(args, 'preset') !== undefined) return setPreset(args, { env, stdout, stderr, cwd })
|
|
101
|
+
if (args.includes('--project')) {
|
|
102
|
+
stderr.write('bulkhead config: --project goes with --preset\n')
|
|
103
|
+
process.exitCode = 1
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
const cfg = load({ env, cwd })
|
|
42
107
|
if (args.includes('--json')) {
|
|
43
108
|
const settings = {}
|
|
44
109
|
for (const key of Object.keys(KEYS)) settings[key] = { value: cfg.values[key], from: cfg.sources[key] }
|
|
45
|
-
stdout.write(JSON.stringify({ files: cfg.files, settings, warnings: cfg.warnings }, null, 2) + '\n')
|
|
110
|
+
stdout.write(JSON.stringify({ preset: cfg.preset, files: cfg.files, settings, warnings: cfg.warnings }, null, 2) + '\n')
|
|
46
111
|
return
|
|
47
112
|
}
|
|
48
113
|
stdout.write(render(cfg, { color: useColor(stdout, env) }) + '\n')
|
|
49
114
|
}
|
|
50
115
|
|
|
51
|
-
module.exports = { main, render }
|
|
116
|
+
module.exports = { main, render, writePreset, USAGE }
|
package/src/config.js
CHANGED
|
@@ -5,7 +5,7 @@ const path = require('path')
|
|
|
5
5
|
const { pathKey } = require('./paths')
|
|
6
6
|
|
|
7
7
|
// Settings come from three places, later ones winning:
|
|
8
|
-
// 1. defaults below
|
|
8
|
+
// 1. defaults below, then the preset ("preset" in a config file or BULKHEAD_PRESET) over them
|
|
9
9
|
// 2. <state dir>/config.json (global, usually ~/.claude/bulkhead/config.json)
|
|
10
10
|
// 3. <project>/.claude/bulkhead.json (project, merged over global)
|
|
11
11
|
// 4. environment variables (always win)
|
|
@@ -19,14 +19,25 @@ const GUARDS = {
|
|
|
19
19
|
'checkout-guard': 'checkoutGuard',
|
|
20
20
|
'disk-guard': 'diskGuard',
|
|
21
21
|
basics: 'basics',
|
|
22
|
+
'edit-guard': 'editGuard',
|
|
23
|
+
'secrets-guard': 'secretsGuard',
|
|
22
24
|
'orphan-reaper': 'orphanReaper',
|
|
25
|
+
'session-state': 'sessionState',
|
|
23
26
|
}
|
|
24
27
|
// rules that can be turned off one at a time, inside a guard that stays on
|
|
25
28
|
const RULES = {
|
|
26
29
|
'rm-root': 'basics.rmRoot',
|
|
27
30
|
'force-push-main': 'basics.forcePushMain',
|
|
28
31
|
'discard-changes': 'basics.discardChanges',
|
|
32
|
+
'protected-delete': 'basics.protectedDelete',
|
|
29
33
|
'agent-author': 'basics.agentAuthor',
|
|
34
|
+
'self-allow': 'basics.selfAllow',
|
|
35
|
+
'mass-kill': 'killGuard.massKill',
|
|
36
|
+
'docker-mass': 'killGuard.dockerMass',
|
|
37
|
+
'device-kill': 'killGuard.deviceKill',
|
|
38
|
+
'app-quit': 'killGuard.appQuit',
|
|
39
|
+
'service-stop': 'killGuard.serviceStop',
|
|
40
|
+
'owner-check': 'killGuard.ownerCheck',
|
|
30
41
|
'stage-all': 'checkoutGuard.stageAll',
|
|
31
42
|
'remove-session-dir': 'checkoutGuard.removeSessionDir',
|
|
32
43
|
}
|
|
@@ -38,9 +49,19 @@ const KEYS = {
|
|
|
38
49
|
'killGuard.enabled': on('kill-guard'),
|
|
39
50
|
'killGuard.allowNames': { type: 'string[]', default: [], doc: 'Program names to take off the shared list, so killing them by name passes.' },
|
|
40
51
|
'killGuard.extraSharedNames': { type: 'string[]', default: [], doc: 'More program names that must not be killed by name.' },
|
|
52
|
+
'killGuard.massKill': bool(true, 'Block pkill -u, kill -1 and kill 0 while another Claude session is alive.'),
|
|
53
|
+
'killGuard.dockerMass': bool(true, 'Block docker prune, stopping every container, pm2 kill and docker compose down in another session\'s folder while another Claude session is alive.'),
|
|
54
|
+
'killGuard.deviceKill': bool(true, 'Block adb kill-server, adb emu kill and simctl shutdown all while another Claude session is alive.'),
|
|
55
|
+
'killGuard.appQuit': bool(true, 'Block quitting shared apps (osascript quit, pkill Slack, killall Finder) while another Claude session is alive.'),
|
|
56
|
+
'killGuard.serviceStop': bool(true, 'Block stopping shared services (brew services stop, launchctl bootout, pkill redis-server) while another Claude session is alive.'),
|
|
57
|
+
'killGuard.ownerCheck': bool(true, 'Block kill <pid> and pkill -f <pattern> when they reach another Claude session\'s processes.'),
|
|
41
58
|
'waitGuard.enabled': on('wait-guard'),
|
|
42
59
|
'waitGuard.limitSeconds': { type: 'number', default: 180, env: 'BULKHEAD_WAIT_MAX_SEC', doc: 'Longest foreground wait allowed, in seconds.' },
|
|
43
60
|
'memoryGuard.enabled': on('memory-guard'),
|
|
61
|
+
'memoryGuard.autoQueue': bool(false, 'Instead of blocking a heavy command, run it through bulkhead-slot so every session waits in one line.'),
|
|
62
|
+
'memoryGuard.estimatesGb': { type: 'numbers', default: null, doc: 'RAM in GB bulkhead assumes per tool when auto-queueing, by program name: { "gradle": 6 }.' },
|
|
63
|
+
'memoryGuard.waitMinutes': { type: 'number', default: 10, doc: 'How long an auto-queued command waits for room before giving up (bulkhead-slot --wait).' },
|
|
64
|
+
'memoryGuard.subagents': bool(false, 'Block starting a new subagent while memory pressure is critical.'),
|
|
44
65
|
'memory.minFreeGb': { type: 'number', default: null, env: 'BULKHEAD_MIN_FREE_GB', doc: 'Free RAM below which memory-guard blocks heavy commands. Default: 3, or 15% of RAM if less.' },
|
|
45
66
|
'memory.slotFloorGb': { type: 'number', default: null, env: 'BULKHEAD_SLOT_FLOOR_GB', doc: 'RAM bulkhead-slot keeps free when it starts a second job. Default: 5, or 25% of RAM if less.' },
|
|
46
67
|
'memory.maxCommitPct': { type: 'number', default: 75, env: 'BULKHEAD_MAX_COMMIT_PCT', doc: 'Windows commit charge above which bulkhead-slot holds a second job.' },
|
|
@@ -54,18 +75,61 @@ const KEYS = {
|
|
|
54
75
|
'basics.protectedBranches': { type: 'string[]', default: ['main', 'master'], doc: 'Branches force-push-main protects.' },
|
|
55
76
|
'basics.rmRoot': bool(true, 'Block rm -rf at roots, top-level system folders, the home directory, folders directly in it (caches excepted) and folders above the repository.'),
|
|
56
77
|
'basics.forcePushMain': bool(true, 'Block force-pushes to the protected branches.'),
|
|
78
|
+
'basics.protectedDelete': bool(true, 'Block deleting a protected branch (git push origin :main, git push --delete, git branch -D).'),
|
|
57
79
|
'basics.discardChanges': bool(true, 'Block git reset --hard and git clean -fd that would lose work.'),
|
|
58
80
|
'basics.agentAuthor': bool(true, 'Block commits authored as Claude or Anthropic (--author, git config user.*, GIT_AUTHOR_*), so the work counts as yours.'),
|
|
81
|
+
'basics.selfAllow': bool(true, 'Block the agent running bulkhead allow or writing the allow state files, so only the user approves a block.'),
|
|
82
|
+
'allowOnce.ttlMinutes': { type: 'number', default: 10, doc: 'Minutes an allow-once code stays valid, and how long an approved command can still be run once.' },
|
|
83
|
+
'editGuard.enabled': on('edit-guard'),
|
|
84
|
+
'editGuard.mode': { type: 'string', default: 'warn', doc: 'What edit-guard does when another live session edited the same file lately: "warn" tells the agent, "ask" asks you, "block" stops the edit.' },
|
|
85
|
+
'editGuard.windowMinutes': { type: 'number', default: 30, doc: 'How recent the other session\'s edit must be, in minutes.' },
|
|
86
|
+
'secretsGuard.enabled': bool(false, 'Turn secrets-guard on or off. Off by default; on in the strict preset.'),
|
|
87
|
+
'secretsGuard.extraPatterns': { type: 'string[]', default: [], doc: 'More files secrets-guard keeps out of the conversation: a file name (*.token) or a path pattern (~/vault/**).' },
|
|
88
|
+
'secretsGuard.allow': { type: 'string[]', default: [], doc: 'Files secrets-guard lets through even though they look secret, by the same patterns.' },
|
|
59
89
|
'orphanReaper.enabled': on('orphan-reaper'),
|
|
60
90
|
'orphanReaper.onSessionEnd': bool(true, 'Also reap right after a session exits, not only when the next session starts.'),
|
|
61
91
|
'orphanReaper.extraNames': { type: 'string[]', default: [], doc: 'More program names orphan-reaper stops once the session that started them has exited.' },
|
|
62
92
|
'orphanReaper.keepNames': { type: 'string[]', default: [], doc: 'Program names orphan-reaper never stops, nor anything they started.' },
|
|
93
|
+
'orphanReaper.keepPatterns': { type: 'string[]', default: [], doc: 'Text that keeps a process when it appears in its command line, such as "ssh -L" or "my-tunnel.sh". Case is ignored.' },
|
|
94
|
+
'sessionState.enabled': on('session-state'),
|
|
95
|
+
'notify.command': { type: 'string', default: null, globalOnly: true, doc: 'Command run (detached) when a session starts waiting for you. Gets BULKHEAD_SESSION_PROJECT, BULKHEAD_SESSION_STATE and BULKHEAD_SESSION_PID. Off by default; global config file only.' },
|
|
96
|
+
'notify.minIntervalSeconds': { type: 'number', default: 30, doc: 'Least seconds between two notifications for the same session.' },
|
|
63
97
|
'log.enabled': bool(true, 'Write blocks to events.jsonl. BULKHEAD_NO_LOG=1 turns it off.'),
|
|
64
98
|
// Global file only (see src/remotes.js for why).
|
|
65
99
|
'remote.default': { type: 'string', default: null, globalOnly: true, doc: 'The remote bulkhead remote uses when several are configured and --remote is not given.' },
|
|
66
100
|
remotes: { type: 'remotes', default: {}, globalOnly: true, doc: 'Machines bulkhead remote can run jobs on: { name: { host, shell, dir, copies, maxJobs, minFreeGb, sshOptions } }.' },
|
|
67
101
|
}
|
|
68
102
|
|
|
103
|
+
// Layered defaults: a preset only changes the default of the keys it names, so any key a config
|
|
104
|
+
// file sets, and any environment variable, still wins. normal is the plain defaults. A value
|
|
105
|
+
// that is a function gets the machine's RAM in GB, so a threshold scales on small machines.
|
|
106
|
+
const round1 = n => Math.round(n * 10) / 10
|
|
107
|
+
const PRESETS = {
|
|
108
|
+
normal: {},
|
|
109
|
+
// only what protects one session from another, plus the two rules that cannot be undone
|
|
110
|
+
quiet: {
|
|
111
|
+
'waitGuard.enabled': false,
|
|
112
|
+
'diskGuard.enabled': false,
|
|
113
|
+
'basics.discardChanges': false,
|
|
114
|
+
'basics.agentAuthor': false,
|
|
115
|
+
},
|
|
116
|
+
// everything on, with tighter limits: waits over a minute, less free RAM tolerated, more protected branches
|
|
117
|
+
strict: {
|
|
118
|
+
'waitGuard.limitSeconds': 60,
|
|
119
|
+
'memory.minFreeGb': gb => Math.min(5, round1(gb * 0.2)),
|
|
120
|
+
'memory.slotFloorGb': gb => Math.min(8, round1(gb * 0.35)),
|
|
121
|
+
'memory.maxCommitPct': 65,
|
|
122
|
+
'basics.protectedBranches': ['main', 'master', 'develop', 'release', 'production'],
|
|
123
|
+
'secretsGuard.enabled': true,
|
|
124
|
+
},
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function presetValues(name, totalGb = os.totalmem() / 2 ** 30) {
|
|
128
|
+
const out = {}
|
|
129
|
+
for (const [k, v] of Object.entries(PRESETS[name] || {})) out[k] = typeof v === 'function' ? v(totalGb) : v
|
|
130
|
+
return out
|
|
131
|
+
}
|
|
132
|
+
|
|
69
133
|
function stateDir(env = process.env) {
|
|
70
134
|
return env.BULKHEAD_STATE_DIR || path.join(os.homedir(), '.claude', 'bulkhead')
|
|
71
135
|
}
|
|
@@ -98,6 +162,7 @@ function typeOk(type, v) {
|
|
|
98
162
|
if (type === 'string') return typeof v === 'string'
|
|
99
163
|
if (type === 'string[]') return Array.isArray(v) && v.every(x => typeof x === 'string')
|
|
100
164
|
if (type === 'number[]') return Array.isArray(v) && v.every(x => Number.isInteger(x) && x > 0 && x < 65536)
|
|
165
|
+
if (type === 'numbers') return Boolean(v) && typeof v === 'object' && !Array.isArray(v) && Object.values(v).every(x => typeof x === 'number' && x > 0)
|
|
101
166
|
return false
|
|
102
167
|
}
|
|
103
168
|
|
|
@@ -122,6 +187,11 @@ function flatten(obj, file, warnings, scope = 'global') {
|
|
|
122
187
|
out.remotes = remotes
|
|
123
188
|
continue
|
|
124
189
|
}
|
|
190
|
+
if (section === 'preset') {
|
|
191
|
+
if (PRESETS[body]) out.preset = body
|
|
192
|
+
else warnings.push(`${file}: "preset" should be one of ${Object.keys(PRESETS).join(', ')}`)
|
|
193
|
+
continue
|
|
194
|
+
}
|
|
125
195
|
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
|
126
196
|
warnings.push(`${file}: "${section}" should be an object`)
|
|
127
197
|
continue
|
|
@@ -159,8 +229,8 @@ function envValue(spec, env) {
|
|
|
159
229
|
return raw
|
|
160
230
|
}
|
|
161
231
|
|
|
162
|
-
// Defaults
|
|
163
|
-
function fileLayer({ env = process.env, cwd = env.CLAUDE_PROJECT_DIR || process.cwd(), read } = {}) {
|
|
232
|
+
// Defaults, then the preset, then both files merged over them. Read once per process.
|
|
233
|
+
function fileLayer({ env = process.env, cwd = env.CLAUDE_PROJECT_DIR || process.cwd(), read, totalGb } = {}) {
|
|
164
234
|
const warnings = []
|
|
165
235
|
const values = {}
|
|
166
236
|
const sources = {}
|
|
@@ -169,16 +239,29 @@ function fileLayer({ env = process.env, cwd = env.CLAUDE_PROJECT_DIR || process.
|
|
|
169
239
|
sources[k] = 'default'
|
|
170
240
|
}
|
|
171
241
|
const files = { global: globalFile(env), project: projectFile(cwd) }
|
|
242
|
+
const parsed = []
|
|
172
243
|
for (const [scope, file] of [['global', files.global], ['project', files.project]]) {
|
|
173
244
|
if (!file) continue
|
|
174
245
|
const got = readFile(file, warnings, read, scope)
|
|
175
|
-
if (
|
|
246
|
+
if (got) parsed.push({ file, got })
|
|
247
|
+
}
|
|
248
|
+
// the project file's preset wins over the global one; BULKHEAD_PRESET wins over both
|
|
249
|
+
let preset = 'normal'
|
|
250
|
+
let presetFrom = 'default'
|
|
251
|
+
for (const { file, got } of parsed) if (got.preset) [preset, presetFrom] = [got.preset, file]
|
|
252
|
+
if (PRESETS[String(env.BULKHEAD_PRESET || '').trim()]) [preset, presetFrom] = [env.BULKHEAD_PRESET.trim(), 'env BULKHEAD_PRESET']
|
|
253
|
+
for (const [k, v] of Object.entries(presetValues(preset, totalGb))) {
|
|
254
|
+
values[k] = v
|
|
255
|
+
sources[k] = `preset ${preset}`
|
|
256
|
+
}
|
|
257
|
+
for (const { file, got } of parsed) {
|
|
176
258
|
for (const [k, v] of Object.entries(got)) {
|
|
259
|
+
if (k === 'preset') continue
|
|
177
260
|
values[k] = v
|
|
178
261
|
sources[k] = file
|
|
179
262
|
}
|
|
180
263
|
}
|
|
181
|
-
return { values, sources, files, warnings }
|
|
264
|
+
return { values, sources, files, warnings, preset, presetFrom }
|
|
182
265
|
}
|
|
183
266
|
|
|
184
267
|
// { values, sources, files, warnings }. sources[key] is 'default', a file path or 'env NAME'.
|
|
@@ -211,16 +294,30 @@ function withEnv(layer, env) {
|
|
|
211
294
|
sources[k] = 'env BULKHEAD_DISABLE'
|
|
212
295
|
}
|
|
213
296
|
}
|
|
214
|
-
return { values, sources, files: layer.files, warnings: layer.warnings }
|
|
297
|
+
return { values, sources, files: layer.files, warnings: layer.warnings, preset: layer.preset, presetFrom: layer.presetFrom }
|
|
215
298
|
}
|
|
216
299
|
|
|
300
|
+
// A hook asks for many settings per call; the files are read once and the environment layer is
|
|
301
|
+
// rebuilt only when one of the variables it reads has changed.
|
|
217
302
|
let cached = null
|
|
303
|
+
let withEnvCache = null
|
|
304
|
+
let envNames = null
|
|
305
|
+
function envKey(env) {
|
|
306
|
+
if (!envNames) envNames = [...new Set(Object.values(KEYS).map(s => s.env).filter(Boolean)), 'BULKHEAD_NO_LOG', 'BULKHEAD_DISABLE']
|
|
307
|
+
return envNames.map(n => (env[n] === undefined ? '\u0000' : env[n])).join('\u0001')
|
|
308
|
+
}
|
|
218
309
|
function current() {
|
|
219
|
-
if (!cached)
|
|
220
|
-
|
|
310
|
+
if (!cached) {
|
|
311
|
+
cached = fileLayer()
|
|
312
|
+
withEnvCache = null
|
|
313
|
+
}
|
|
314
|
+
const key = envKey(process.env)
|
|
315
|
+
if (!withEnvCache || withEnvCache.key !== key) withEnvCache = { key, cfg: withEnv(cached, process.env) }
|
|
316
|
+
return withEnvCache.cfg
|
|
221
317
|
}
|
|
222
318
|
function reset() {
|
|
223
319
|
cached = null
|
|
320
|
+
withEnvCache = null
|
|
224
321
|
}
|
|
225
322
|
|
|
226
323
|
function get(key, cfg = current()) {
|
|
@@ -233,4 +330,4 @@ function isEnabled(name, cfg = current()) {
|
|
|
233
330
|
return key ? cfg.values[key] !== false : true
|
|
234
331
|
}
|
|
235
332
|
|
|
236
|
-
module.exports = { load, current, reset, get, isEnabled, projectFile, globalFile, KEYS, GUARDS, RULES, flatten }
|
|
333
|
+
module.exports = { load, current, reset, get, isEnabled, projectFile, globalFile, KEYS, GUARDS, RULES, PRESETS, presetValues, flatten }
|