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.
Files changed (66) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.es.md +142 -50
  3. package/README.ja.md +142 -50
  4. package/README.ko.md +143 -51
  5. package/README.md +142 -50
  6. package/README.pt-BR.md +142 -50
  7. package/README.zh-CN.md +154 -62
  8. package/hooks/hooks.json +68 -8
  9. package/package.json +5 -2
  10. package/src/agent-hook.js +162 -0
  11. package/src/agent-setup.js +256 -0
  12. package/src/agents.js +89 -0
  13. package/src/allow-cli.js +65 -0
  14. package/src/allow-hint.js +62 -0
  15. package/src/allow-once.js +152 -0
  16. package/src/auto-queue.js +93 -0
  17. package/src/bash-guards.js +36 -0
  18. package/src/basics.js +46 -27
  19. package/src/checkout-guard.js +342 -74
  20. package/src/cli-util.js +13 -1
  21. package/src/cli.js +40 -4
  22. package/src/config-check.js +21 -4
  23. package/src/config-cli.js +75 -10
  24. package/src/config.js +106 -9
  25. package/src/dev-servers.js +361 -61
  26. package/src/disk-guard.js +8 -8
  27. package/src/doctor-enforcement.js +273 -0
  28. package/src/doctor.js +19 -9
  29. package/src/edit-guard.js +196 -0
  30. package/src/events.js +30 -5
  31. package/src/explain.js +251 -0
  32. package/src/git-info.js +55 -0
  33. package/src/gitargs.js +82 -0
  34. package/src/hook.js +38 -6
  35. package/src/kill-extras.js +219 -0
  36. package/src/kill-guard.js +228 -77
  37. package/src/kill-owners.js +116 -0
  38. package/src/log-cli.js +15 -10
  39. package/src/mcp.js +131 -0
  40. package/src/memory-guard.js +126 -24
  41. package/src/memory.js +21 -2
  42. package/src/notices.js +95 -0
  43. package/src/orphan-reaper.js +58 -11
  44. package/src/port-cli.js +78 -3
  45. package/src/port-guard.js +80 -68
  46. package/src/port-kills.js +198 -0
  47. package/src/pressure.js +75 -0
  48. package/src/processes.js +44 -12
  49. package/src/scan-report.js +166 -0
  50. package/src/scan.js +87 -18
  51. package/src/secrets-guard.js +301 -0
  52. package/src/self-allow.js +73 -0
  53. package/src/session-info.js +121 -0
  54. package/src/session-state.js +176 -0
  55. package/src/sessions.js +109 -20
  56. package/src/shell.js +279 -19
  57. package/src/slot-cli.js +92 -21
  58. package/src/slots.js +61 -13
  59. package/src/stable-port.js +61 -0
  60. package/src/status.js +55 -17
  61. package/src/statusline.js +45 -16
  62. package/src/subagent-guard.js +24 -0
  63. package/src/top.js +36 -11
  64. package/src/wait-guard.js +96 -32
  65. package/src/worktree-cli.js +454 -0
  66. 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] [--since 7d] [--guard NAME] [--summary] [--json]
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 doctor [--json] [--no-remote] check the install, settings and this machine
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 }
@@ -1,8 +1,11 @@
1
1
  'use strict'
2
- // SessionStart: one notice when a bulkhead config file is broken. The guards ignore a broken
3
- // file and run on their defaults, so without this nobody would notice a typo.
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 = notice(load({ cwd: process.env.CLAUDE_PROJECT_DIR || input.cwd || process.cwd() }))
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 { load, KEYS } = require('./config')
4
- const { useColor, paint, shortPath, table, rejectArgs } = require('./cli-util')
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 ? 'auto' : key === 'remotes' ? Object.keys(v).join(', ') || 'none' : JSON.stringify(v))
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
- function main(args, { env = process.env, stdout = process.stdout } = {}) {
40
- if (rejectArgs('config', args, { flags: ['json'], valued: [] }, process.stderr, null)) return
41
- const cfg = load({ env })
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 with both files merged over them. Read once per process.
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 (!got) continue
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) cached = fileLayer()
220
- return withEnv(cached, process.env)
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 }