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
package/src/basics.js ADDED
@@ -0,0 +1,236 @@
1
+ 'use strict'
2
+ const os = require('os')
3
+ const path = require('path')
4
+ const { execFileSync } = require('child_process')
5
+ const { runHook, enabled, setting, blocked, oneLine, ASK_USER } = require('./hook')
6
+ const shell = require('./shell')
7
+ const { realPath } = require('./paths')
8
+
9
+ // Narrow rules for mistakes that cannot be undone. Each can be turned off on its own:
10
+ // rm-root, force-push-main, discard-changes, agent-author.
11
+
12
+ // ~, $HOME and %USERPROFILE% become the home directory; /c/... becomes C:/... on Windows.
13
+ // Returns null for anything with a variable we cannot expand.
14
+ function expand(arg, home, platform) {
15
+ let a = arg
16
+ .replace(/^~(?=[\\/]|$)/, home)
17
+ .replace(/^(?:\$HOME|\$\{HOME\}|\$env:USERPROFILE|\$env:HOME|%USERPROFILE%|\$USERPROFILE)(?=[\\/]|$)/i, home)
18
+ if (/[$%]/.test(a)) return null
19
+ if (platform === 'win32') a = a.replace(/^\/([a-z])(?=\/|$)/i, '$1:')
20
+ return a
21
+ }
22
+
23
+ const pathFor = platform => (platform === 'win32' ? path.win32 : path.posix)
24
+
25
+ const norm = (p, platform) => {
26
+ const n = pathFor(platform).resolve(p).replace(/\\/g, '/').replace(/\/+$/, '') || '/'
27
+ return platform === 'win32' ? n.toLowerCase() : n
28
+ }
29
+ // git prints the repository's real path; the cwd and home may be spelled through a link or a
30
+ // Windows short name (C:\Users\RUNNER~1). Only those two folders are resolved, never a target:
31
+ // a target can be on an automounted or network path, and a hook must not wait on it. A hook
32
+ // process asks for the same two folders again and again, so the answers are kept.
33
+ const realCache = new Map()
34
+ function realOnce(p) {
35
+ if (!realCache.has(p)) {
36
+ if (realCache.size > 64) realCache.clear()
37
+ realCache.set(p, realPath(p))
38
+ }
39
+ return realCache.get(p)
40
+ }
41
+ const isAncestorOrSame = (a, b) => b === a || b.startsWith(a.endsWith('/') ? a : a + '/')
42
+
43
+ function rmTargets(t) {
44
+ const cmd = path.basename(t[0] || '').replace(/\.exe$/i, '').toLowerCase()
45
+ const args = t.slice(1)
46
+ if (cmd === 'rm') {
47
+ // options end at `--`; everything after it is a path, even one that starts with a dash
48
+ const dash = args.indexOf('--')
49
+ const opts = (dash < 0 ? args : args.slice(0, dash)).filter(a => a.startsWith('-'))
50
+ const operands = dash < 0 ? args.filter(a => !a.startsWith('-')) : [...args.slice(0, dash).filter(a => !a.startsWith('-')), ...args.slice(dash + 1)]
51
+ const recursive = opts.some(a => /^-[a-zA-Z]*[rR]/.test(a) || a === '--recursive')
52
+ const force = opts.some(a => /^-[a-zA-Z]*f/.test(a) || a === '--force')
53
+ return recursive && force ? operands : []
54
+ }
55
+ if (/^(?:remove-item|ri|rmdir|rd|del|erase)$/.test(cmd) && args.some(a => /^-r(?:ecurse)?$/i.test(a)) && args.some(a => /^-fo(?:rce)?$/i.test(a))) {
56
+ return args.filter(a => !a.startsWith('-'))
57
+ }
58
+ if (/^(?:rd|rmdir)$/.test(cmd) && args.some(a => /^\/s$/i.test(a))) return args.filter(a => !/^\/[sq]$/i.test(a))
59
+ return []
60
+ }
61
+
62
+ // Folders directly in the home directory that are only caches and can always be rebuilt.
63
+ const HOME_CACHES = /^\.?(?:cache|caches|tmp|temp|npm|pnpm-store|yarn|bun|gradle|m2|nuget|cargo|rustup|deno|expo|Trash|node-gyp|pyenv|bundle|cocoapods)$/i
64
+
65
+ // The dangerous targets: a filesystem or drive root, a top-level folder such as /usr or /srv,
66
+ // the home directory or anything above it, a folder directly in it (~/Documents, ~/backups;
67
+ // caches excepted), and any directory above the repository the command runs in.
68
+ function dangerousTarget(arg, { cwd, home, platform, repoRoot }) {
69
+ const expanded = expand(arg, home, platform)
70
+ if (expanded === null) return null
71
+ const dir = expanded.replace(/[\\/]\*$/, '') || '/'
72
+ const rel = expanded === '*' ? '.' : dir
73
+ // as written, and on this machine also from the real cwd and home: rm resolves .. from the
74
+ // folder the cwd really is, not from the link it was entered through
75
+ const views = [{ cwd, home }]
76
+ if (platform === process.platform) views.push({ cwd: realOnce(cwd), home: realOnce(home) })
77
+ for (const v of views) {
78
+ const abs = norm(pathFor(platform).resolve(v.cwd, rel), platform)
79
+ if (/^(?:\/|[a-z]:\/?)$/i.test(abs) || /^[a-z]:$/i.test(abs)) return 'a filesystem root'
80
+ const homeKey = norm(v.home, platform)
81
+ if (isAncestorOrSame(abs, homeKey)) return 'your home directory or a folder above it'
82
+ const parent = pathFor('linux').dirname(abs)
83
+ if (parent === homeKey && !HOME_CACHES.test(pathFor('linux').basename(abs))) return 'a folder directly in your home directory'
84
+ if ((parent === '/' || /^[a-z]:\/?$/i.test(parent)) && !isAncestorOrSame(abs, norm(v.cwd, platform))) return 'a top-level folder of the drive'
85
+ // a folder inside the current directory cannot be above the repository
86
+ if (abs !== norm(v.cwd, platform) && isAncestorOrSame(norm(v.cwd, platform), abs)) continue
87
+ const root = repoRoot()
88
+ if (root && abs !== norm(root, platform) && isAncestorOrSame(abs, norm(root, platform))) return 'a folder above this repository'
89
+ }
90
+ return null
91
+ }
92
+
93
+ function git(args, cwd) {
94
+ return execFileSync('git', args, { cwd, encoding: 'utf8', timeout: 5000, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] }).trim()
95
+ }
96
+
97
+ function gitCwd(t, cwd) {
98
+ const i = t.indexOf('-C')
99
+ return i > 0 && t[i + 1] ? path.resolve(cwd, t[i + 1]) : cwd
100
+ }
101
+
102
+ function gitSub(t) {
103
+ const rest = []
104
+ for (let i = 1; i < t.length; i++) {
105
+ if (t[i] === '-C' || t[i] === '-c') i++
106
+ else rest.push(t[i])
107
+ }
108
+ return rest
109
+ }
110
+
111
+ function forcePushToMain(rest, branch, protectedBranches = ['main', 'master']) {
112
+ if (rest[0] !== 'push') return false
113
+ const args = rest.slice(1)
114
+ // --force-with-lease and --force-if-includes are the safe forms and do not count
115
+ const force = args.some(a => a === '--force' || /^-[a-zA-Z]*f[a-zA-Z]*$/.test(a) || a.startsWith('+'))
116
+ if (!force) return false
117
+ const refs = args.filter(a => !a.startsWith('-')).slice(1).map(r => r.replace(/^\+/, '').split(':').pop())
118
+ const targets = refs.length ? refs.map(r => (r === 'HEAD' ? branch() : r)) : [branch()]
119
+ return targets.some(r => protectedBranches.includes(String(r || '').replace(/^refs\/heads\//, '')))
120
+ }
121
+
122
+ // An identity that is the agent, not the person: Claude <noreply@anthropic.com> and the like.
123
+ const AGENT_ID = /\bclaude\b|anthropic/i
124
+
125
+ // The --author value of git commit, the value git config sets for user.name or user.email, or a
126
+ // -c user.name=... before the subcommand.
127
+ function agentAuthor(t, rest) {
128
+ for (let i = 1; i < t.length; i++) {
129
+ if (t[i] === '-c' && /^user\.(?:name|email)=/i.test(t[i + 1] || '') && AGENT_ID.test(t[i + 1].split('=').slice(1).join('='))) return t[i + 1]
130
+ }
131
+ if (rest[0] === 'commit' || rest[0] === 'merge' || rest[0] === 'cherry-pick' || rest[0] === 'revert') {
132
+ for (let i = 1; i < rest.length; i++) {
133
+ const a = rest[i]
134
+ const v = a.startsWith('--author=') ? a.slice(9) : a === '--author' ? rest[i + 1] : null
135
+ if (v && AGENT_ID.test(v)) return `--author ${v}`
136
+ }
137
+ }
138
+ if (rest[0] === 'config') {
139
+ const args = rest.slice(1).filter(a => !/^--(?:global|local|system|worktree|replace-all|add)$/.test(a))
140
+ if (/^user\.(?:name|email)$/i.test(args[0] || '') && args[1] && AGENT_ID.test(args[1])) return `${args[0]} ${args[1]}`
141
+ }
142
+ return null
143
+ }
144
+
145
+ // GIT_AUTHOR_NAME=Claude git commit, export GIT_COMMITTER_EMAIL=noreply@anthropic.com, $env:GIT_AUTHOR_NAME = "Claude"
146
+ const AGENT_ENV = /\bGIT_(?:AUTHOR|COMMITTER)_(?:NAME|EMAIL)\s*=\s*["']?([^"'\s;&|]*)/gi
147
+ function agentEnv(text) {
148
+ for (const m of text.matchAll(AGENT_ENV)) if (AGENT_ID.test(m[1])) return m[0]
149
+ return null
150
+ }
151
+
152
+ function agentMessage(what) {
153
+ return `[bulkhead] basics: \`${oneLine(what, 120)}\` makes the agent the author of the commit.\n` +
154
+ 'Commits authored as Claude or Anthropic do not count as the user\'s work on GitHub (contribution graph, achievements), and later blame points at a bot.\n' +
155
+ 'Commit with the git identity already configured: leave user.name and user.email alone and drop --author. A Co-Authored-By line in the message is fine.'
156
+ }
157
+
158
+ function check(cmd, {
159
+ cwd = process.cwd(), home = os.homedir(), platform = process.platform, run = git,
160
+ protectedBranches = setting('basics.protectedBranches', ['main', 'master']), mode = 'sh',
161
+ } = {}) {
162
+ // each rule's switch and the repository root are read once, however many commands the line has
163
+ const once = fn => { let v; let done = false; return () => (done ? v : ((done = true), (v = fn()))) }
164
+ const rmRoot = once(() => enabled('rm-root'))
165
+ const forcePush = once(() => enabled('force-push-main'))
166
+ const discard = once(() => enabled('discard-changes'))
167
+ const author = once(() => enabled('agent-author'))
168
+ const prepared = shell.prepare(String(cmd || ''), mode)
169
+ const cmds = shell.commands(prepared, mode).filter(x => !x.remote)
170
+ if (author() && /GIT_(?:AUTHOR|COMMITTER)_/i.test(prepared)) {
171
+ // only the shell's own words: a heredoc body handed to python or node is code, not a variable
172
+ const runsGit = cmds.some(c => shell.bin(c.argv[0]) === 'git')
173
+ for (const c of cmds) {
174
+ const own = c.depth === 0 ? prepared.slice(c.s, c.e) : c.argv.join(' ')
175
+ const env = agentEnv(own)
176
+ if (env && (runsGit || /^(?:export|set|setx|\$env:)/i.test(own.trim()))) return agentMessage(env)
177
+ }
178
+ }
179
+ const repoRoot = once(() => { try { return run(['rev-parse', '--show-toplevel'], cwd) } catch { return null } })
180
+ for (const c of cmds) {
181
+ const t = c.argv
182
+ if (!t.length) continue
183
+ const seg = t.join(' ')
184
+ const targets = rmTargets(t)
185
+ if (targets.length && rmRoot()) {
186
+ for (const target of targets) {
187
+ const what = dangerousTarget(target, { cwd, home, platform, repoRoot })
188
+ if (what) {
189
+ return `[bulkhead] basics: \`${oneLine(seg, 120)}\` deletes ${what} (${target}).\n` +
190
+ 'Name the folder you mean inside the project instead, for example rm -rf ./dist.'
191
+ }
192
+ }
193
+ }
194
+ if (path.basename(t[0]).replace(/\.exe$/i, '') !== 'git') continue
195
+ const dir = gitCwd(t, cwd)
196
+ const rest = gitSub(t)
197
+ const who = author() && agentAuthor(t, rest)
198
+ if (who) return agentMessage(who)
199
+ if (rest[0] === 'push' && forcePush() && forcePushToMain(rest, () => { try { return run(['rev-parse', '--abbrev-ref', 'HEAD'], dir) } catch { return '' } }, protectedBranches)) {
200
+ return `[bulkhead] basics: force-pushing to ${protectedBranches.join(' or ')} rewrites history other people and sessions have pulled.\n` +
201
+ 'Push to a branch, or use --force-with-lease if you are sure.'
202
+ }
203
+ const reset = rest[0] === 'reset' && rest.includes('--hard')
204
+ const clean = rest[0] === 'clean' && rest.some(a => /^-[a-z]*f/.test(a) || a === '--force') && rest.some(a => /^-[a-z]*[dx]/.test(a))
205
+ if ((reset || clean) && discard()) {
206
+ let status
207
+ try { status = run(['status', '--porcelain'], dir) } catch { continue }
208
+ const lines = status.split('\n').filter(Boolean)
209
+ const lost = reset ? lines.filter(l => !l.startsWith('??')) : lines.filter(l => l.startsWith('??'))
210
+ if (lost.length) {
211
+ return `[bulkhead] basics: \`${oneLine(seg, 120)}\` would throw away ${lost.length} ${reset ? 'uncommitted change' : 'untracked file'}${lost.length === 1 ? '' : 's'}` +
212
+ ` (${lost.slice(0, 3).map(l => l.slice(3)).join(', ')}${lost.length > 3 ? ', ...' : ''}).\n` +
213
+ 'Commit them or run `git stash push -u` first, so they can be brought back.'
214
+ }
215
+ }
216
+ }
217
+ return null
218
+ }
219
+
220
+ if (require.main === module) {
221
+ runHook('basics', input => {
222
+ const cmd = input.tool_input && input.tool_input.command
223
+ const why = typeof cmd === 'string' && cmd && check(cmd, { cwd: input.cwd || process.cwd(), mode: shell.modeFor(input) })
224
+ if (!why) return
225
+ return blocked('basics', ruleOf(why), input, `${why}\n${ASK_USER}`)
226
+ })
227
+ }
228
+
229
+ function ruleOf(message) {
230
+ if (/makes the agent the author/.test(message)) return 'agent-author'
231
+ if (/force-pushing/.test(message)) return 'force-push-main'
232
+ if (/would throw away/.test(message)) return 'discard-changes'
233
+ return 'rm-root'
234
+ }
235
+
236
+ module.exports = { check, rmTargets, dangerousTarget, forcePushToMain, ruleOf, agentAuthor }
@@ -0,0 +1,296 @@
1
+ 'use strict'
2
+ const fs = require('fs')
3
+ const path = require('path')
4
+ const { execFileSync } = require('child_process')
5
+ const os = require('os')
6
+ const { runHook, blocked, oneLine, enabled, ASK_USER } = require('./hook')
7
+ const { readSessionFiles } = require('./sessions')
8
+ const shell = require('./shell')
9
+ const { rmTargets } = require('./basics')
10
+
11
+ // The git commands a line runs on this machine, wrappers (bash -c, env, VAR=value) opened.
12
+ function gitCommands(cmd, mode = 'sh') {
13
+ return shell.commands(shell.prepare(String(cmd || ''), mode), mode).filter(c => !c.remote && shell.bin(c.argv[0]) === 'git')
14
+ }
15
+
16
+ // git <sub> args, with -C <dir> pulled out. Returns null for anything that is not git.
17
+ // Takes the words of one command, or a command line (read as its first command).
18
+ function gitCall(seg, cwd) {
19
+ const t = Array.isArray(seg) ? seg : ((shell.commands(seg)[0] || {}).argv || [])
20
+ if (shell.bin(t[0] || '') !== 'git') return null
21
+ let dir = cwd
22
+ const rest = []
23
+ for (let i = 1; i < t.length; i++) {
24
+ if (t[i] === '-C' && t[i + 1]) dir = path.resolve(dir, t[++i])
25
+ else if (t[i] === '-c') i++
26
+ else rest.push(t[i])
27
+ }
28
+ return { dir, sub: rest[0], args: rest.slice(1) }
29
+ }
30
+
31
+ const DONE = /^--(?:abort|continue|skip|quit)$/
32
+
33
+ // What the command does to a shared working tree, or null when it leaves it alone.
34
+ // 'dirty-only' means it matters only when there are uncommitted changes.
35
+ function changesTree({ sub, args, dir = '.' }, exists = fs.existsSync) {
36
+ const paths = args.indexOf('--') >= 0 ? args.slice(args.indexOf('--') + 1) : []
37
+ switch (sub) {
38
+ case 'checkout': {
39
+ if (paths.length) return paths.includes('.') ? 'restores every file' : null
40
+ if (args.includes('-')) return 'switches the branch'
41
+ const names = args.filter(a => !a.startsWith('-'))
42
+ if (names.includes('.')) return 'restores every file'
43
+ // git checkout src/app.js restores a file; only a branch or commit moves the tree
44
+ if (!args.some(a => /^-[bB]$/.test(a)) && names.length && names.every(n => exists(path.resolve(dir, n)))) return null
45
+ return names.length ? 'switches the branch' : null
46
+ }
47
+ case 'switch':
48
+ return 'switches the branch'
49
+ case 'restore': {
50
+ // --staged alone only unstages; the files on disk stay as they are
51
+ const staged = args.some(a => a === '--staged' || /^-[a-zA-Z]*S/.test(a))
52
+ const worktree = args.some(a => a === '--worktree' || /^-[a-zA-Z]*W/.test(a))
53
+ if (staged && !worktree) return null
54
+ return args.includes('.') ? 'restores every file' : null
55
+ }
56
+ case 'stash':
57
+ return !args[0] || /^(?:push|save|pop|apply|-u|--include-untracked|-k|--keep-index|-m)$/.test(args[0]) ? 'stashes or unstashes changes' : null
58
+ case 'reset':
59
+ return args.includes('--hard') ? 'resets the files' : null
60
+ case 'clean':
61
+ if (args.some(a => /^-[a-z]*n/.test(a) || a === '--dry-run')) return null
62
+ return args.some(a => /^-[a-z]*f/.test(a) || a === '--force') ? 'deletes untracked files' : null
63
+ case 'rebase':
64
+ return args.some(a => DONE.test(a)) ? null : 'rebases the branch'
65
+ case 'merge':
66
+ return args.some(a => DONE.test(a)) ? null : 'merges into the branch'
67
+ case 'pull':
68
+ return 'dirty-only'
69
+ // git add -A, git add ., git commit -a: everything changed in the tree goes into one commit,
70
+ // including the files the other session is in the middle of editing
71
+ case 'add':
72
+ return args.some(a => /^(?:-A|--all|-u|--update|\.|:\/|:)$/.test(a) || /^-[a-zA-Z]*[Au]$/.test(a) || a === './') ? 'stage-all' : null
73
+ case 'commit':
74
+ return args.some(a => a === '--all' || /^-[a-zA-Z]*a[a-zA-Z]*$/.test(a)) ? 'stage-all' : null
75
+ default:
76
+ return null
77
+ }
78
+ }
79
+
80
+ function git(args, cwd) {
81
+ return execFileSync('git', args, { cwd, encoding: 'utf8', timeout: 5000, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] }).trim()
82
+ }
83
+
84
+ function alive(pid) {
85
+ try {
86
+ process.kill(pid, 0)
87
+ return true
88
+ } catch (e) {
89
+ return e.code === 'EPERM'
90
+ }
91
+ }
92
+
93
+ // git prints the real path of the tree; a session may have started through a symlink, a junction
94
+ // or a Windows short name (C:\Users\RUNNER~1), so both sides are resolved before comparing.
95
+ const real = p => { try { return fs.realpathSync.native(p) } catch { return path.resolve(p) } }
96
+ const key = (p, platform) => {
97
+ const n = real(p).replace(/\\/g, '/').replace(/\/+$/, '')
98
+ return platform === 'win32' ? n.toLowerCase() : n
99
+ }
100
+ const inside = (child, parent) => child === parent || child.startsWith(parent + '/')
101
+
102
+ // Other live sessions whose project directory is in the same working tree. A session started in
103
+ // a subfolder of this tree could still be in a nested worktree or submodule, so git decides those.
104
+ function sharers(top, sessionId, { files, isAlive, run, platform }) {
105
+ const topKey = key(top, platform)
106
+ const out = []
107
+ for (const [pid, f] of files) {
108
+ if (!f.cwd || f.sessionId === sessionId || !isAlive(pid)) continue
109
+ const cwdKey = key(f.cwd, platform)
110
+ if (!inside(cwdKey, topKey)) continue
111
+ if (cwdKey !== topKey) {
112
+ let theirs
113
+ try { theirs = run(['rev-parse', '--show-toplevel'], f.cwd) } catch { continue }
114
+ if (key(theirs, platform) !== topKey) continue
115
+ }
116
+ out.push({ pid, ...f })
117
+ }
118
+ return out
119
+ }
120
+
121
+ function when(ms) {
122
+ if (!ms) return 'unknown time'
123
+ const d = new Date(ms)
124
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ` +
125
+ `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
126
+ }
127
+
128
+ // git worktree remove <path> and rm -rf <path>: the folders the command deletes.
129
+ function removedDirs(c, cwd, home) {
130
+ const call = gitCall(c.argv, cwd)
131
+ if (call && call.sub === 'worktree' && call.args[0] === 'remove') {
132
+ const rest = call.args.slice(1)
133
+ const target = rest.find(a => !a.startsWith('-'))
134
+ return target ? [{ dir: path.resolve(call.dir, expandHome(target, home)), how: 'git worktree remove', force: rest.some(a => a === '-f' || a === '--force') }] : []
135
+ }
136
+ return rmTargets(c.argv)
137
+ .map(t => expandHome(t, home))
138
+ .map(t => expandPwd(t, cwd))
139
+ .filter(t => t && !/[$%*?`]/.test(t))
140
+ .map(t => ({ dir: path.resolve(cwd, t), how: 'deleting', force: true }))
141
+ }
142
+
143
+ // $PWD, ${PWD}, $(pwd) and `pwd` are the folder the command runs in.
144
+ function expandPwd(arg, cwd) {
145
+ return String(arg).replace(/^(?:\$PWD|\$\{PWD\}|\$\(\s*pwd\s*\)|`\s*pwd\s*`)(?=[\\/]|$)/, cwd.replace(/\\/g, '/'))
146
+ }
147
+
148
+ function expandHome(arg, home) {
149
+ return String(arg)
150
+ .replace(/^~(?=[\\/]|$)/, home)
151
+ .replace(/^(?:\$HOME|\$\{HOME\}|\$env:USERPROFILE|%USERPROFILE%)(?=[\\/]|$)/i, home)
152
+ }
153
+
154
+ // Links in the first two levels of a folder (node_modules linked to the main checkout is the
155
+ // usual one). On Windows, git worktree remove --force has followed a junction and deleted what
156
+ // it pointed to.
157
+ function linksIn(dir) {
158
+ const out = []
159
+ const walk = (d, depth) => {
160
+ let entries
161
+ try { entries = fs.readdirSync(d, { withFileTypes: true }) } catch { return }
162
+ for (const e of entries) {
163
+ if (e.name === '.git') continue
164
+ const p = path.join(d, e.name)
165
+ if (e.isSymbolicLink()) out.push(p)
166
+ else if (e.isDirectory() && depth < 2) walk(p, depth + 1)
167
+ if (out.length >= 3) return
168
+ }
169
+ }
170
+ walk(dir, 1)
171
+ return out
172
+ }
173
+
174
+ function classify(cmd, {
175
+ cwd = process.cwd(), sessionId = null, files = () => readSessionFiles(), isAlive = alive, run = git, platform = process.platform, mode = 'sh',
176
+ home = os.homedir(), links = linksIn, removeRule = () => enabled('remove-session-dir'), stageRule = () => enabled('stage-all'),
177
+ } = {}) {
178
+ const text = shell.prepare(String(cmd || ''), mode)
179
+ const all = shell.commands(text, mode).filter(c => !c.remote)
180
+ let live = null
181
+ const liveOthers = () => live || (live = new Map([...files()].filter(([pid, f]) => f.cwd && f.sessionId !== sessionId && isAlive(pid))))
182
+
183
+ for (const c of all) {
184
+ const removals = removedDirs(c, cwd, home)
185
+ if (!removals.length) continue
186
+ const seg = oneLine(c.argv.join(' '), 120)
187
+ for (const r of removals) {
188
+ if (r.how === 'git worktree remove' && r.force && platform === 'win32') {
189
+ const found = links(r.dir)
190
+ if (found.length) {
191
+ return {
192
+ rule: 'worktree-force-links',
193
+ why: `[bulkhead] checkout-guard: \`${seg}\` force-removes a worktree that holds links (${found.map(f => path.relative(r.dir, f)).join(', ')}).\n` +
194
+ 'On Windows, git worktree remove --force can follow a junction and delete what it points to, such as the main checkout\'s node_modules.\n' +
195
+ 'Remove the links first (rmdir <link> removes a junction, not its target), check none are left, then run git worktree remove without --force.',
196
+ }
197
+ }
198
+ }
199
+ if (!sessionId || !removeRule()) continue
200
+ const others = liveOthers()
201
+ if (!others.size) continue
202
+ const target = key(r.dir, platform)
203
+ // a deletion of the folder a session works in, or of a folder above it
204
+ const hit = [...others].find(([, f]) => inside(key(f.cwd, platform), target))
205
+ if (hit) {
206
+ const [pid, f] = hit
207
+ return {
208
+ rule: 'remove-session-dir',
209
+ why: `[bulkhead] checkout-guard: \`${seg}\` removes ${r.dir}, and another Claude session works in it (pid ${pid}, started ${when(f.startedAt)}, in ${f.cwd}).\n` +
210
+ 'Its files, its dev server and its next commit would disappear under it.\n' +
211
+ 'Leave this folder to that session. If it is really finished, wait until it exits (`bulkhead status` lists the live sessions), then remove it.',
212
+ }
213
+ }
214
+ }
215
+ }
216
+
217
+ if (!sessionId) return null
218
+ for (const c of all) {
219
+ if (shell.bin(c.argv[0]) !== 'git') continue
220
+ const call = gitCall(c.argv, cwd)
221
+ const seg = c.argv.join(' ')
222
+ if (!call) continue
223
+ let what = changesTree(call)
224
+ if (!what || (what === 'stage-all' && !stageRule())) continue
225
+ if (!liveOthers().size) continue
226
+ let top
227
+ try { top = run(['rev-parse', '--show-toplevel'], call.dir) } catch { continue }
228
+ const others = sharers(top, sessionId, { files: liveOthers(), isAlive, run, platform })
229
+ if (!others.length) continue
230
+ const o = others[0]
231
+ const who = `another Claude session works in the same tree (pid ${o.pid}, started ${when(o.startedAt)}` +
232
+ (others.length > 1 ? `, and ${others.length - 1} more` : '') + ')'
233
+ if (what === 'stage-all') {
234
+ let status = ''
235
+ try { status = run(['status', '--porcelain'], call.dir) } catch { continue }
236
+ const changed = status.split('\n').filter(Boolean)
237
+ if (!changed.length) continue
238
+ return {
239
+ rule: 'stage-all',
240
+ why: `[bulkhead] checkout-guard: \`${oneLine(seg, 120)}\` stages every change in ${top} (${changed.length} file${changed.length === 1 ? '' : 's'}), ` +
241
+ `and ${who}.\n` +
242
+ 'Some of those changes are probably its half-finished work, and they would go into your commit.\n' +
243
+ 'Stage only the files you changed, by name: git add src/a.ts src/b.ts, or commit them directly with git commit -m "..." -- src/a.ts src/b.ts.',
244
+ }
245
+ }
246
+ if (what === 'dirty-only') {
247
+ let status = ''
248
+ try { status = run(['status', '--porcelain', '--untracked-files=no'], call.dir) } catch { continue }
249
+ if (!status) continue
250
+ what = 'merges into a tree with uncommitted changes'
251
+ }
252
+ const name = path.basename(top)
253
+ if (call.sub === 'pull') {
254
+ return {
255
+ rule: 'git-pull',
256
+ why: `[bulkhead] checkout-guard: \`${oneLine(seg, 120)}\` ${what} in ${top}, and ${who}.\n` +
257
+ 'A pull rewrites the files that session has open, and a conflict would stop halfway through its edits.\n' +
258
+ 'Wait until it has committed or stashed its changes, or pull from a clone no other session is using.',
259
+ }
260
+ }
261
+ return {
262
+ rule: `git-${call.sub}`,
263
+ why: `[bulkhead] checkout-guard: \`${oneLine(seg, 120)}\` ${what} in ${top}, and ${who}.\n` +
264
+ 'Its files and branch would change under it. Work in your own worktree instead:\n' +
265
+ ` git worktree add ../${name}-<task> -b <branch>`,
266
+ }
267
+ }
268
+ return null
269
+ }
270
+
271
+ function check(cmd, opts) {
272
+ const hit = classify(cmd, opts)
273
+ return hit ? hit.why : null
274
+ }
275
+
276
+ if (require.main === module) {
277
+ runHook('checkout-guard', input => {
278
+ const cmd = input.tool_input && input.tool_input.command
279
+ if (!cmd) return
280
+ const hit = classify(cmd, { cwd: input.cwd || process.cwd(), sessionId: input.session_id || null, mode: shell.modeFor(input) })
281
+ if (!hit) return
282
+ return blocked('checkout-guard', hit.rule, input, `${hit.why}\n${ASK_USER}`)
283
+ })
284
+ }
285
+
286
+ // `git-checkout`, `git-stash` and so on: the first git command that moves the tree.
287
+ function ruleOf(cmd, cwd = '.', mode = 'sh') {
288
+ for (const c of gitCommands(cmd, mode)) {
289
+ const call = gitCall(c.argv, cwd)
290
+ const what = call && changesTree(call)
291
+ if (what) return what === 'stage-all' ? 'stage-all' : `git-${call.sub}`
292
+ }
293
+ return 'git'
294
+ }
295
+
296
+ module.exports = { check, classify, changesTree, gitCall, ruleOf, linksIn }
@@ -0,0 +1,79 @@
1
+ 'use strict'
2
+ const os = require('os')
3
+
4
+ // Small helpers shared by the bulkhead subcommands.
5
+
6
+ function useColor(stream = process.stdout, env = process.env) {
7
+ return Boolean(stream && stream.isTTY) && !('NO_COLOR' in env) && env.TERM !== 'dumb'
8
+ }
9
+
10
+ const CODES = { bold: 1, dim: 2, red: 31, green: 32, yellow: 33, blue: 34, cyan: 36, gray: 90 }
11
+ function paint(on, style, s) {
12
+ return on ? `\x1b[${CODES[style]}m${s}\x1b[0m` : String(s)
13
+ }
14
+
15
+ function shortPath(p, home = os.homedir()) {
16
+ if (!p) return '?'
17
+ const s = String(p)
18
+ return home && s.toLowerCase().startsWith(home.toLowerCase()) ? '~' + s.slice(home.length).replace(/\\/g, '/') : s.replace(/\\/g, '/')
19
+ }
20
+
21
+ // --name value or --name=value. Returns undefined when absent, true for a bare flag at the end.
22
+ function option(args, name) {
23
+ for (let i = 0; i < args.length; i++) {
24
+ if (args[i] === `--${name}`) return args[i + 1] !== undefined && !args[i + 1].startsWith('--') ? args[i + 1] : true
25
+ if (args[i].startsWith(`--${name}=`)) return args[i].slice(name.length + 3)
26
+ }
27
+ return undefined
28
+ }
29
+
30
+ // Cut to `width` visible characters, ending in … when anything was dropped.
31
+ function fit(s, width) {
32
+ s = String(s == null ? '' : s)
33
+ if (width <= 0) return ''
34
+ return s.length > width ? s.slice(0, Math.max(0, width - 1)) + '…' : s
35
+ }
36
+
37
+ // Columns padded to their widest cell; the last column is not padded.
38
+ function table(rows, gap = ' ') {
39
+ const widths = []
40
+ for (const r of rows) r.forEach((c, i) => (widths[i] = Math.max(widths[i] || 0, String(c).length)))
41
+ return rows.map(r => r.map((c, i) => (i === r.length - 1 ? String(c) : String(c).padEnd(widths[i]))).join(gap).trimEnd())
42
+ }
43
+
44
+ function bytes(n) {
45
+ if (n >= 2 ** 30) return `${(n / 2 ** 30).toFixed(1)} GB`
46
+ if (n >= 2 ** 20) return `${Math.round(n / 2 ** 20)} MB`
47
+ if (n >= 1024) return `${Math.round(n / 1024)} KB`
48
+ return `${n} B`
49
+ }
50
+
51
+ // The first argument a subcommand does not know, as a message, or null. flags take no value,
52
+ // valued ones take `--name value` or `--name=value`, shorts are like { n: true } for `-n 5`.
53
+ function unknownArg(args, { flags = [], valued = [], shorts = [] } = {}) {
54
+ for (let i = 0; i < args.length; i++) {
55
+ const a = args[i]
56
+ if (a === '--help' || a === '-h') continue
57
+ if (a.startsWith('--')) {
58
+ const name = a.slice(2).split('=')[0]
59
+ if (flags.includes(name)) continue
60
+ if (valued.includes(name)) { if (!a.includes('=') && args[i + 1] !== undefined && !args[i + 1].startsWith('--')) i++; continue }
61
+ return `unknown option ${a}`
62
+ }
63
+ if (a.length === 2 && a[0] === '-' && shorts.includes(a[1])) { i++; continue }
64
+ return a.startsWith('-') ? `unknown option ${a}` : `unexpected argument ${a}`
65
+ }
66
+ return null
67
+ }
68
+
69
+ // Writes the complaint and the usage text and sets the exit code; true when args were rejected.
70
+ function rejectArgs(cmd, args, spec, stderr, usage) {
71
+ const bad = unknownArg(args, spec)
72
+ if (!bad) return false
73
+ const line = usage || `usage: bulkhead ${cmd} ${[...spec.flags, ...spec.valued].map(f => `[--${f}${spec.valued.includes(f) ? ' X' : ''}]`).join(' ')}`
74
+ stderr.write(`bulkhead ${cmd}: ${bad}\n${line}\n`)
75
+ process.exitCode = 1
76
+ return true
77
+ }
78
+
79
+ module.exports = { unknownArg, rejectArgs, useColor, paint, shortPath, option, fit, table, bytes }
package/src/cli.js ADDED
@@ -0,0 +1,41 @@
1
+ 'use strict'
2
+
3
+ const USAGE = `usage:
4
+ bulkhead status [--json] sessions, their ports and memory, slots, free RAM
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]
7
+ what the guards blocked
8
+ bulkhead scan [--since 30d] [--project TEXT] [-n N] [--json]
9
+ replay your Claude Code transcripts against the guards
10
+ bulkhead doctor [--json] [--no-remote] check the install, settings and this machine
11
+ bulkhead config [--json] effective settings and where each one came from
12
+ bulkhead port [--near PORT] [--json] a port no process is listening on
13
+ bulkhead reap [--dry-run] [--json] stop what exited sessions left running
14
+ bulkhead slot run [--gb=N] [--wait=MINUTES] [--label=TEXT] -- <command...>
15
+ bulkhead slot status [--gb=N] same as bulkhead-slot
16
+ bulkhead remote run [--remote=NAME] [--gb=N] [--tail=N] -- <command...>
17
+ run a heavy command on another machine over ssh
18
+ bulkhead remote status|list|add|remove|fetch see bulkhead remote --help`
19
+
20
+ const COMMANDS = {
21
+ status: './status',
22
+ top: './top',
23
+ log: './log-cli',
24
+ scan: './scan',
25
+ doctor: './doctor',
26
+ config: './config-cli',
27
+ port: './port-cli',
28
+ reap: './reap-cli',
29
+ slot: './slot-cli',
30
+ remote: './remote',
31
+ }
32
+
33
+ function main(argv) {
34
+ const [cmd, ...rest] = argv
35
+ if (COMMANDS[cmd]) return require(COMMANDS[cmd]).main(rest)
36
+ const help = cmd === '--help' || cmd === '-h' || cmd === 'help'
37
+ ;(help ? console.log : console.error)(USAGE)
38
+ process.exit(help ? 0 : 1)
39
+ }
40
+
41
+ module.exports = { main, USAGE }