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
@@ -4,7 +4,8 @@ const path = require('path')
4
4
  const { execFileSync } = require('child_process')
5
5
  const os = require('os')
6
6
  const { runHook, blocked, oneLine, enabled, ASK_USER } = require('./hook')
7
- const { readSessionFiles } = require('./sessions')
7
+ const { readSessionFiles, readLiveSessionFiles, PID_REUSE_SLACK_MS } = require('./sessions')
8
+ const { agentLabel } = require('./agents')
8
9
  const shell = require('./shell')
9
10
  const { rmTargets } = require('./basics')
10
11
 
@@ -13,50 +14,121 @@ function gitCommands(cmd, mode = 'sh') {
13
14
  return shell.commands(shell.prepare(String(cmd || ''), mode), mode).filter(c => !c.remote && shell.bin(c.argv[0]) === 'git')
14
15
  }
15
16
 
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) {
17
+ const { gitCall: parseGitCall, gitEnv, treeOf, parseArgs } = require('./gitargs')
18
+
19
+ // git <sub> args, global options (-C, --git-dir, --work-tree, -c, --no-pager, ...) pulled out.
20
+ // Returns null for anything that is not git. Takes the words of one command, or a command line
21
+ // (read as its first command).
22
+ function gitCall(seg, cwd, env) {
19
23
  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) }
24
+ return parseGitCall(t, cwd, env)
29
25
  }
30
26
 
31
27
  const DONE = /^--(?:abort|continue|skip|quit)$/
32
28
 
29
+ // A long option may be shortened as long as it stays unambiguous: --upd is --update.
30
+ const longIs = (o, full, min = 3) => [...o.long].some(n => n.length >= min && full.startsWith(n))
31
+
32
+ // A pathspec that excludes (:!x, :^x, :(exclude)x) narrows nothing down.
33
+ const excluding = p => /^:(?:[!^]|\([^)]*\bexclude\b[^)]*\))/.test(p)
34
+
35
+ // A list of changed files that git prints, as an argument: $(git ls-files -m), $(git diff --name-only).
36
+ // Naming a folder or file ($(git ls-files -m src)) limits the list to it.
37
+ function changedList(p) {
38
+ if (!/^(?:\$\(|`)/.test(p)) return false
39
+ const inner = shell.commands(p.replace(/^(?:\$\(|`)/, '').replace(/(?:\)|`)$/, ''))[0]
40
+ const call = inner && parseGitCall(inner.argv, '.')
41
+ const o = call && parseArgs(call.args)
42
+ if (!call || o.pos.length) return false
43
+ if (call.sub === 'diff') return o.long.has('name-only') || o.long.has('name-status')
44
+ if (call.sub === 'status') return o.long.has('porcelain') || o.long.has('short') || o.short.has('s')
45
+ return call.sub === 'ls-files'
46
+ }
47
+
48
+ // A pathspec that stands for the whole tree: ., ./, *, ./*, .*, :/, :(top), :(glob)**, $PWD,
49
+ // $(pwd), $(git rev-parse --show-toplevel), and a list of every changed file.
50
+ function wholeTree(p) {
51
+ if (/^(?:\$\{?PWD\}?|\$\(\s*pwd\s*\)|`\s*pwd\s*`)\/?$/.test(p)) return true
52
+ if (/^(?:\$\(|`)\s*git\s+rev-parse\s+--show-toplevel\s*(?:\)|`)\/?$/.test(p)) return true
53
+ if (changedList(p)) return true
54
+ const rest = p.replace(/^:(?:\/|\((?:[a-z]+,?)+\))?/i, '').replace(/^(?:\.\/+)+/, '').replace(/\/+$/, '')
55
+ return /^(?:|\.|\*\*?|\.\*|\*\/\*\*)$/.test(rest)
56
+ }
57
+
58
+ // A pathspec written to leave the folder git runs in: it can still name the whole tree.
59
+ const farPath = p => /^(?:[\\/]|~|\.\.|[A-Za-z]:)/.test(p)
60
+
61
+ // A pathspec that covers many files: a glob, a folder, a magic pathspec.
62
+ function broad(p, dir, isDir) {
63
+ if (/[*?[]|^:|[\\/]$/.test(p)) return true
64
+ return isDir(path.resolve(dir, p))
65
+ }
66
+ const dirOf = p => { try { return fs.statSync(p).isDirectory() } catch { return false } }
67
+
68
+ // The pathspecs a checkout or restore overwrites files from, or [] when it switches something.
69
+ function restoreSpecs({ sub, args, dir = '.' }, exists) {
70
+ if (sub === 'restore') {
71
+ const o = parseArgs(args, { valuedShort: 's', valuedLong: ['source', 'pathspec-from-file'] })
72
+ // --staged alone only unstages; the files on disk stay as they are
73
+ const worktree = o.short.has('W') || o.long.has('worktree')
74
+ if ((o.short.has('S') || o.long.has('staged')) && !worktree) return []
75
+ return o.pos
76
+ }
77
+ const o = parseArgs(args, { valuedShort: 'bBt', valuedLong: ['orphan', 'conflict', 'pathspec-from-file', 'track'] })
78
+ if (o.after.length) return o.after
79
+ if (o.short.has('b') || o.short.has('B') || o.long.has('orphan')) return []
80
+ // git checkout src/app.js restores a file; only a branch or commit moves the tree
81
+ return o.pos.length && o.pos.every(n => exists(path.resolve(dir, n))) ? o.pos : []
82
+ }
83
+
33
84
  // 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) : []
85
+ // 'dirty-only' means it matters only when there are uncommitted changes. Three more answers need
86
+ // a look at the repository first and are settled by classify: 'add-paths' (does a far pathspec
87
+ // name the whole tree), 'restore-paths' (does a folder or glob cover uncommitted changes) and
88
+ // 'unstages' (is anything staged).
89
+ function changesTree({ sub, args, dir = '.' }, exists = fs.existsSync, isDir = dirOf) {
90
+ // git <cmd> --help and -h only print usage
91
+ const before = args.indexOf('--') >= 0 ? args.slice(0, args.indexOf('--')) : args
92
+ if (before.includes('--help') || before.includes('-h')) return null
37
93
  switch (sub) {
38
94
  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
95
+ const o = parseArgs(args, { valuedShort: 'bBt', valuedLong: ['orphan', 'conflict', 'pathspec-from-file', 'track'] })
96
+ const specs = restoreSpecs({ sub, args, dir }, exists)
97
+ const restores = () => (specs.some(wholeTree) ? 'restores every file' : specs.some(p => broad(p, dir, isDir)) ? 'restore-paths' : null)
98
+ if (o.after.length) return restores()
99
+ if (args.includes('-') || o.short.has('b') || o.short.has('B') || o.long.has('orphan')) return 'switches the branch'
100
+ if (o.pos.some(wholeTree)) return 'restores every file'
101
+ if (specs.length) return restores()
102
+ if (o.pos.length) return 'switches the branch'
103
+ // git checkout -f with nothing to switch to throws the changes away
104
+ return o.short.has('f') || o.long.has('force') ? 'restores every file' : null
46
105
  }
47
106
  case 'switch':
48
107
  return 'switches the branch'
49
108
  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
109
+ const specs = restoreSpecs({ sub, args, dir }, exists)
110
+ if (specs.some(wholeTree)) return 'restores every file'
111
+ return specs.some(p => broad(p, dir, isDir)) ? 'restore-paths' : null
112
+ }
113
+ case 'stash': {
114
+ const first = args[0]
115
+ if (/^(?:list|show|create|store)$/.test(first || '')) return null
116
+ const explicit = first === 'push'
117
+ if (first && !explicit && !first.startsWith('-')) return /^(?:save|pop|apply|drop|clear|branch)$/.test(first) ? 'stashes or unstashes changes' : null
118
+ // git stash push -- src/a.js moves only those files: allowed
119
+ const o = parseArgs(explicit ? args.slice(1) : args, { valuedShort: 'm', valuedLong: ['message', 'pathspec-from-file'] })
120
+ return o.pos.length || o.long.has('pathspec-from-file') ? null : 'stashes or unstashes changes'
121
+ }
122
+ case 'reset': {
123
+ const o = parseArgs(args)
124
+ if (o.long.has('hard') || o.long.has('merge') || o.long.has('keep')) return 'resets the files'
125
+ if (o.long.has('soft') || o.long.has('patch') || o.short.has('p')) return null
126
+ // a word with a variable may be a list of files; it is not guessed at
127
+ if (o.pos.some(p => p.includes('$'))) return null
128
+ const specs = o.after.length ? o.after : o.pos.length > 1 ? o.pos.slice(1) : o.pos.length === 1 && exists(path.resolve(dir, o.pos[0])) ? o.pos : []
129
+ if (specs.length && !specs.some(wholeTree)) return null
130
+ return 'unstages'
55
131
  }
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
132
  case 'clean':
61
133
  if (args.some(a => /^-[a-z]*n/.test(a) || a === '--dry-run')) return null
62
134
  return args.some(a => /^-[a-z]*f/.test(a) || a === '--force') ? 'deletes untracked files' : null
@@ -66,12 +138,56 @@ function changesTree({ sub, args, dir = '.' }, exists = fs.existsSync) {
66
138
  return args.some(a => DONE.test(a)) ? null : 'merges into the branch'
67
139
  case 'pull':
68
140
  return 'dirty-only'
141
+ case 'cherry-pick':
142
+ return args.some(a => DONE.test(a)) ? null : 'applies commits to the tree'
143
+ case 'revert':
144
+ return args.some(a => DONE.test(a)) ? null : 'reverts commits in the tree'
145
+ case 'am':
146
+ return args.some(a => DONE.test(a) || a === '--show-current-patch' || a === '--retry') ? null : 'applies patches to the tree'
147
+ case 'apply': {
148
+ const o = parseArgs(args, { valuedLong: ['directory', 'exclude', 'include', 'whitespace', 'reject'] })
149
+ // --cached works on the index only; --check and the stat options write nothing
150
+ const readOnly = ['cached', 'check', 'stat', 'numstat', 'summary'].some(n => o.long.has(n))
151
+ return readOnly && !o.long.has('index') ? null : 'applies a patch to the tree'
152
+ }
153
+ case 'checkout-index': {
154
+ const o = parseArgs(args)
155
+ // --prefix writes the copy somewhere else
156
+ if (o.long.has('prefix')) return null
157
+ return (o.short.has('a') || o.long.has('all')) && (o.short.has('f') || o.long.has('force')) ? 'overwrites the files from the index' : null
158
+ }
159
+ case 'read-tree': {
160
+ const o = parseArgs(args)
161
+ return o.short.has('u') && (o.long.has('reset') || o.short.has('m')) ? 'rewrites the files' : null
162
+ }
163
+ case 'submodule': {
164
+ const o = parseArgs(args.slice(1))
165
+ return args[0] === 'update' && (o.short.has('f') || o.long.has('force')) ? 'resets the submodule files' : null
166
+ }
167
+ case 'rm': {
168
+ const o = parseArgs(args)
169
+ if (o.short.has('n') || o.long.has('dry-run') || o.long.has('cached')) return null
170
+ // a folder, or the whole tree; named files and narrow globs (sample-*.jpg) are an ordinary cleanup
171
+ return o.pos.some(p => wholeTree(p) || (o.short.has('r') && broad(p, dir, isDir) && !/[*?[]/.test(p.replace(/^.*\//, '')))) ? 'removes files' : null
172
+ }
69
173
  // 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
174
+ // including the files the other session is in the middle of editing. A pathspec narrows it:
175
+ // git add -A src/ is the files in src/.
176
+ case 'add': {
177
+ const o = parseArgs(args, { valuedLong: ['pathspec-from-file'] })
178
+ if (o.short.has('n') || longIs(o, 'dry-run')) return null
179
+ const specs = o.pos.filter(p => !excluding(p))
180
+ if (specs.some(wholeTree)) return 'stage-all'
181
+ if (o.long.has('pathspec-from-file')) return null
182
+ const all = o.short.has('A') || o.short.has('u') || longIs(o, 'all') || longIs(o, 'update')
183
+ if (!specs.length) return all ? 'stage-all' : null
184
+ return specs.some(farPath) ? 'add-paths' : null
185
+ }
186
+ case 'commit': {
187
+ const o = parseArgs(args, { valuedShort: 'mFCct', valuedLong: ['message', 'file', 'reuse-message', 'reedit-message', 'fixup', 'squash', 'author', 'date', 'cleanup', 'template', 'trailer'] })
188
+ if (o.long.has('dry-run')) return null
189
+ return o.short.has('a') || longIs(o, 'all') ? 'stage-all' : null
190
+ }
75
191
  default:
76
192
  return null
77
193
  }
@@ -90,6 +206,33 @@ function alive(pid) {
90
206
  }
91
207
  }
92
208
 
209
+ // When a process started, in ms, or 0 when it cannot be read.
210
+ function startOf(pid, platform = process.platform) {
211
+ try {
212
+ if (platform === 'win32') {
213
+ const out = execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', `(Get-Process -Id ${Number(pid)}).StartTime.ToUniversalTime().ToString('o')`],
214
+ { encoding: 'utf8', timeout: 5000, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] })
215
+ return Date.parse(out.trim()) || 0
216
+ }
217
+ const out = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], { encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'], env: { ...process.env, LC_ALL: 'C' } })
218
+ return Date.parse(out.trim()) || 0
219
+ } catch {
220
+ return 0
221
+ }
222
+ }
223
+
224
+ // Is this session file's process still running? A file left behind by a session that died can
225
+ // name a pid another program now holds. The cheap check is that the pid exists; `deep` also
226
+ // compares the process start with the time the file says the session started, which costs a
227
+ // process lookup, so callers ask it only for the few sessions in the tree at hand. A start time
228
+ // that cannot be read counts as the same process.
229
+ function liveSession(pid, file, deep = false, startTime = startOf) {
230
+ if (!alive(pid)) return false
231
+ if (!deep || !file || !file.startedAt) return true
232
+ const started = startTime(pid)
233
+ return !started || Math.abs(started - file.startedAt) < PID_REUSE_SLACK_MS
234
+ }
235
+
93
236
  // git prints the real path of the tree; a session may have started through a symlink, a junction
94
237
  // or a Windows short name (C:\Users\RUNNER~1), so both sides are resolved before comparing.
95
238
  const real = p => { try { return fs.realpathSync.native(p) } catch { return path.resolve(p) } }
@@ -97,6 +240,9 @@ const key = (p, platform) => {
97
240
  const n = real(p).replace(/\\/g, '/').replace(/\/+$/, '')
98
241
  return platform === 'win32' ? n.toLowerCase() : n
99
242
  }
243
+ // Loaded only when a guard blocks.
244
+ const describeSession = s => require('./session-info').describeSession(s)
245
+
100
246
  const inside = (child, parent) => child === parent || child.startsWith(parent + '/')
101
247
 
102
248
  // Other live sessions whose project directory is in the same working tree. A session started in
@@ -105,7 +251,7 @@ function sharers(top, sessionId, { files, isAlive, run, platform }) {
105
251
  const topKey = key(top, platform)
106
252
  const out = []
107
253
  for (const [pid, f] of files) {
108
- if (!f.cwd || f.sessionId === sessionId || !isAlive(pid)) continue
254
+ if (!f.cwd || f.sessionId === sessionId || !isAlive(pid, f, false)) continue
109
255
  const cwdKey = key(f.cwd, platform)
110
256
  if (!inside(cwdKey, topKey)) continue
111
257
  if (cwdKey !== topKey) {
@@ -113,31 +259,52 @@ function sharers(top, sessionId, { files, isAlive, run, platform }) {
113
259
  try { theirs = run(['rev-parse', '--show-toplevel'], f.cwd) } catch { continue }
114
260
  if (key(theirs, platform) !== topKey) continue
115
261
  }
262
+ if (!isAlive(pid, f, true)) continue
116
263
  out.push({ pid, ...f })
117
264
  }
118
265
  return out
119
266
  }
120
267
 
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
268
  // git worktree remove <path> and rm -rf <path>: the folders the command deletes.
129
- function removedDirs(c, cwd, home) {
269
+ function removedDirs(c, cwd, home, known = true) {
130
270
  const call = gitCall(c.argv, cwd)
131
271
  if (call && call.sub === 'worktree' && call.args[0] === 'remove') {
132
272
  const rest = call.args.slice(1)
133
273
  const target = rest.find(a => !a.startsWith('-'))
134
274
  return target ? [{ dir: path.resolve(call.dir, expandHome(target, home)), how: 'git worktree remove', force: rest.some(a => a === '-f' || a === '--force') }] : []
135
275
  }
136
- return rmTargets(c.argv)
276
+ return [...rmTargets(c.argv), ...findDeletes(c.argv)]
137
277
  .map(t => expandHome(t, home))
138
278
  .map(t => expandPwd(t, cwd))
139
- .filter(t => t && !/[$%*?`]/.test(t))
140
- .map(t => ({ dir: path.resolve(cwd, t), how: 'deleting', force: true }))
279
+ .map(t => deleted(t, cwd, known))
280
+ .filter(Boolean)
281
+ }
282
+
283
+ // find <paths> -delete and find <paths> -exec rm: what they remove, when nothing narrows the
284
+ // files they reach (-name, -path, -mtime, -empty ...). find . -type f -delete is the whole tree.
285
+ const FIND_FILTER = /^-(?:i?name|i?path|i?wholename|i?regex|i?lname|newer\w*|[mac]time|[mac]min|empty|size|user|group|perm|samefile|inum|links|executable|readable|writable|used)$/
286
+ function findDeletes(t) {
287
+ if (shell.bin(t[0] || '') !== 'find') return []
288
+ let i = 1
289
+ while (/^-(?:[HLP]|O\d|D)$/.test(t[i] || '')) i++
290
+ const start = []
291
+ while (i < t.length && !/^(?:-|!$|\()/.test(t[i])) start.push(t[i++])
292
+ const expr = t.slice(i)
293
+ const removes = expr.includes('-delete') || expr.some((a, k) => /^-(?:exec|execdir|ok|okdir)$/.test(a) && /^(?:rm|unlink|shred|rmdir)$/.test(shell.bin(expr[k + 1] || '')))
294
+ return removes && !expr.some(a => FIND_FILTER.test(a)) ? (start.length ? start : ['.']) : []
295
+ }
296
+
297
+ // What one deletion target removes: the folder it names; every file in the folder for dir/*; the
298
+ // repository's whole folder for .git (without its history the folder is no longer a repository).
299
+ function deleted(t, cwd, known = true) {
300
+ // after a cd we cannot follow, a relative target means a folder we do not know
301
+ if (!t || (!known && !path.isAbsolute(t))) return null
302
+ const star = /^(?:(.*)[\\/])?\.?\*+$/.exec(t)
303
+ if (star && !/[$%*?`]/.test(star[1] || '')) return { dir: path.resolve(cwd, star[1] || '.'), how: 'deleting', force: true, what: 'everything in' }
304
+ if (/[$%*?`]/.test(t)) return null
305
+ const dir = path.resolve(cwd, t)
306
+ if (path.basename(dir) === '.git') return { dir: path.dirname(dir), how: 'deleting', force: true, what: 'the git data of' }
307
+ return { dir, how: 'deleting', force: true, what: '' }
141
308
  }
142
309
 
143
310
  // $PWD, ${PWD}, $(pwd) and `pwd` are the folder the command runs in.
@@ -171,17 +338,76 @@ function linksIn(dir) {
171
338
  return out
172
339
  }
173
340
 
341
+ // git ls-files -m | xargs git add: git add is fed every changed file. A list limited by a
342
+ // pathspec (git ls-files -m src) is not.
343
+ function fedByChangedList(all, c, call, text, cwd) {
344
+ if (c.depth !== 0 || !/(?:^|\s)xargs(?:\s|$)/.test(text.slice(c.s, c.e)) || parseArgs(call.args).pos.length) return false
345
+ return all.some(p => {
346
+ if (p === c || p.seg !== c.seg || p.depth !== 0 || p.s >= c.s) return false
347
+ const src = gitCall(p.argv, cwd)
348
+ if (!src) return false
349
+ const o = parseArgs(src.args)
350
+ if (o.after.length) return false
351
+ if (src.sub === 'ls-files') return !o.pos.length
352
+ if (src.sub === 'diff') return o.long.has('name-only') || o.long.has('name-status')
353
+ return src.sub === 'status' && (o.long.has('porcelain') || o.short.has('s') || o.long.has('short'))
354
+ })
355
+ }
356
+
357
+ // The paths in `git status --porcelain` output (the new name of a rename). The output has been
358
+ // trimmed, so the first line can have lost the space that starts its status.
359
+ function changedPaths(status) {
360
+ return status.split('\n').map(l => (/^[ MADRCTU?!]{1,2} (.+)$/.exec(l) || [])[1]).filter(Boolean).map(f => f.split(' -> ').pop().replace(/^"|"$/g, ''))
361
+ }
362
+
363
+ // Does a pathspec (as typed in folder `dir`) cover a changed file (path from the tree top)?
364
+ function covers(spec, file, dir, top, platform) {
365
+ const abs = path.join(top, file)
366
+ if (/^:[^/(]/.test(spec) || /^:\((?!top\))/.test(spec)) return true
367
+ const top2 = /^:(?:\/|\(top\))/.test(spec)
368
+ const body = spec.replace(/^:(?:\/|\([^)]*\))?/, '')
369
+ const base = top2 ? top : dir
370
+ if (/[*?[]/.test(body)) {
371
+ const re = new RegExp('^' + path.resolve(base, body).replace(/\\/g, '/').replace(/[.+^${}()|\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$')
372
+ const sep = abs.replace(/\\/g, '/')
373
+ return sep.split('/').some((_, i, a) => re.test(a.slice(0, i + 1).join('/') || '/'))
374
+ }
375
+ return inside(key(abs, platform), key(path.resolve(base, body), platform))
376
+ }
377
+
378
+ // The folder each command runs in: the hook's folder, moved by the cd commands before it. A cd
379
+ // to a folder we cannot work out (a variable, `cd -`, a folder that does not exist), or a ( cd x )
380
+ // subshell, makes the folder unknown: the command is then read from the hook's folder, but
381
+ // relative deletions are skipped.
382
+ function cwdsOf(all, text, cwd, home, folderExists = fs.existsSync) {
383
+ let cur = cwd
384
+ let known = !/\(\s*(?:cd|pushd)\b/.test(text)
385
+ return all.map(c => {
386
+ const here = { cwd: cur, known }
387
+ if (c.depth === 0 && known && /^(?:cd|pushd|chdir)$/.test(shell.bin(c.argv[0]))) {
388
+ const t = c.argv.slice(1).find(a => !a.startsWith('-') || a === '-')
389
+ const dest = t === undefined ? home : expandPwd(expandHome(t, home), cur)
390
+ const next = path.resolve(cur, dest)
391
+ // a folder that is not there yet (made by this very command) or a Git Bash style /c/... path on Windows
392
+ if (t === '-' || /[$`%]/.test(dest) || !folderExists(next)) known = false
393
+ else cur = next
394
+ }
395
+ return here
396
+ })
397
+ }
398
+
174
399
  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'),
400
+ cwd = process.cwd(), sessionId = null, files = () => readLiveSessionFiles({ files: readSessionFiles() }), isAlive = liveSession, run = git, platform = process.platform, mode = 'sh',
401
+ home = os.homedir(), links = linksIn, removeRule = () => enabled('remove-session-dir'), stageRule = () => enabled('stage-all'), isDir = dirOf, folderExists = fs.existsSync,
177
402
  } = {}) {
178
403
  const text = shell.prepare(String(cmd || ''), mode)
179
404
  const all = shell.commands(text, mode).filter(c => !c.remote)
180
405
  let live = null
181
- const liveOthers = () => live || (live = new Map([...files()].filter(([pid, f]) => f.cwd && f.sessionId !== sessionId && isAlive(pid))))
406
+ const liveOthers = () => live || (live = new Map([...files()].filter(([pid, f]) => f.cwd && f.sessionId !== sessionId && isAlive(pid, f, false))))
182
407
 
183
- for (const c of all) {
184
- const removals = removedDirs(c, cwd, home)
408
+ const where = cwdsOf(all, text, cwd, home, folderExists)
409
+ for (const [i, c] of all.entries()) {
410
+ const removals = removedDirs(c, where[i].cwd, home, where[i].known)
185
411
  if (!removals.length) continue
186
412
  const seg = oneLine(c.argv.join(' '), 120)
187
413
  for (const r of removals) {
@@ -201,12 +427,13 @@ function classify(cmd, {
201
427
  if (!others.size) continue
202
428
  const target = key(r.dir, platform)
203
429
  // 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))
430
+ const hit = [...others].find(([pid, f]) => inside(key(f.cwd, platform), target) && isAlive(pid, f, true))
205
431
  if (hit) {
206
432
  const [pid, f] = hit
207
433
  return {
208
434
  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` +
435
+ owner: { pid, cwd: f.cwd },
436
+ why: `[bulkhead] checkout-guard: \`${seg}\` removes ${r.what ? r.what + ' ' : ''}${r.dir}, and another ${agentLabel(f.agent)} session works in it (${describeSession({ pid, cwd: f.cwd })}).\n` +
210
437
  'Its files, its dev server and its next commit would disappear under it.\n' +
211
438
  '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
439
  }
@@ -215,28 +442,66 @@ function classify(cmd, {
215
442
  }
216
443
 
217
444
  if (!sessionId) return null
218
- for (const c of all) {
445
+ for (const [i, c] of all.entries()) {
219
446
  if (shell.bin(c.argv[0]) !== 'git') continue
220
- const call = gitCall(c.argv, cwd)
447
+ const call = gitCall(c.argv, where[i].cwd, c.depth === 0 ? gitEnv(text.slice(c.s, c.e)) : {})
221
448
  const seg = c.argv.join(' ')
222
449
  if (!call) continue
223
- let what = changesTree(call)
224
- if (!what || (what === 'stage-all' && !stageRule())) continue
450
+ let what = changesTree(call, fs.existsSync, isDir)
451
+ if (!what && call.sub === 'add' && fedByChangedList(all, c, call, text, cwd)) what = 'stage-all'
452
+ const staging = what === 'stage-all' || what === 'add-paths'
453
+ if (!what || (staging && !stageRule())) continue
225
454
  if (!liveOthers().size) continue
455
+ const gitRun = args => run([...call.opts, ...args], call.dir)
226
456
  let top
227
- try { top = run(['rev-parse', '--show-toplevel'], call.dir) } catch { continue }
457
+ try { top = treeOf(call, run) } catch { continue }
228
458
  const others = sharers(top, sessionId, { files: liveOthers(), isAlive, run, platform })
229
459
  if (!others.length) continue
230
460
  const o = others[0]
231
- const who = `another Claude session works in the same tree (pid ${o.pid}, started ${when(o.startedAt)}` +
461
+ const who = `another ${agentLabel(o.agent)} session works in the same tree (${describeSession(o)}` +
232
462
  (others.length > 1 ? `, and ${others.length - 1} more` : '') + ')'
463
+ if (what === 'add-paths') {
464
+ // a pathspec such as /work/shop or .. can still be the whole tree
465
+ const topKey = key(top, platform)
466
+ const far = parseArgs(call.args).pos.filter(p => !excluding(p) && farPath(p))
467
+ if (!far.some(p => key(path.resolve(call.dir, expandHome(p, home)), platform) === topKey)) continue
468
+ what = 'stage-all'
469
+ }
470
+ if (what === 'restore-paths') {
471
+ let status = ''
472
+ try { status = gitRun(['status', '--porcelain']) } catch { continue }
473
+ const specs = restoreSpecs(call, fs.existsSync)
474
+ const lost = changedPaths(status).filter(f => specs.some(sp => covers(sp, f, call.dir, top, platform)))
475
+ if (!lost.length) continue
476
+ return {
477
+ rule: `git-${call.sub}`,
478
+ why: `[bulkhead] checkout-guard: \`${oneLine(seg, 120)}\` overwrites ${lost.length} file${lost.length === 1 ? '' : 's'} with uncommitted changes in ${top} (${lost.slice(0, 3).join(', ')}${lost.length > 3 ? ', ...' : ''}), ` +
479
+ `and ${who}.\n` +
480
+ 'Some of those changes are probably its work, and they would be lost.\n' +
481
+ 'Restore the single files you changed, by name: git restore src/a.ts, or leave the folder alone.',
482
+ }
483
+ }
484
+ if (what === 'unstages') {
485
+ let out = ''
486
+ try { out = gitRun(['diff', '--cached', '--name-only']) } catch { continue }
487
+ const names = out.split('\n').filter(Boolean)
488
+ if (!names.length) continue
489
+ return {
490
+ rule: 'git-reset',
491
+ why: `[bulkhead] checkout-guard: \`${oneLine(seg, 120)}\` unstages ${names.length} file${names.length === 1 ? '' : 's'} in ${top} (${names.slice(0, 3).join(', ')}${names.length > 3 ? ', ...' : ''}), ` +
492
+ `and ${who}.\n` +
493
+ 'That staged work is probably its own, and it would have to stage it again.\n' +
494
+ 'Unstage only your files, by name: git reset -- src/a.ts (or git restore --staged src/a.ts).',
495
+ }
496
+ }
233
497
  if (what === 'stage-all') {
234
498
  let status = ''
235
- try { status = run(['status', '--porcelain'], call.dir) } catch { continue }
499
+ try { status = gitRun(['status', '--porcelain']) } catch { continue }
236
500
  const changed = status.split('\n').filter(Boolean)
237
501
  if (!changed.length) continue
238
502
  return {
239
503
  rule: 'stage-all',
504
+ owner: { pid: o.pid, cwd: o.cwd },
240
505
  why: `[bulkhead] checkout-guard: \`${oneLine(seg, 120)}\` stages every change in ${top} (${changed.length} file${changed.length === 1 ? '' : 's'}), ` +
241
506
  `and ${who}.\n` +
242
507
  'Some of those changes are probably its half-finished work, and they would go into your commit.\n' +
@@ -245,7 +510,7 @@ function classify(cmd, {
245
510
  }
246
511
  if (what === 'dirty-only') {
247
512
  let status = ''
248
- try { status = run(['status', '--porcelain', '--untracked-files=no'], call.dir) } catch { continue }
513
+ try { status = gitRun(['status', '--porcelain', '--untracked-files=no']) } catch { continue }
249
514
  if (!status) continue
250
515
  what = 'merges into a tree with uncommitted changes'
251
516
  }
@@ -253,6 +518,7 @@ function classify(cmd, {
253
518
  if (call.sub === 'pull') {
254
519
  return {
255
520
  rule: 'git-pull',
521
+ owner: { pid: o.pid, cwd: o.cwd },
256
522
  why: `[bulkhead] checkout-guard: \`${oneLine(seg, 120)}\` ${what} in ${top}, and ${who}.\n` +
257
523
  'A pull rewrites the files that session has open, and a conflict would stop halfway through its edits.\n' +
258
524
  'Wait until it has committed or stashed its changes, or pull from a clone no other session is using.',
@@ -260,9 +526,11 @@ function classify(cmd, {
260
526
  }
261
527
  return {
262
528
  rule: `git-${call.sub}`,
529
+ owner: { pid: o.pid, cwd: o.cwd },
263
530
  why: `[bulkhead] checkout-guard: \`${oneLine(seg, 120)}\` ${what} in ${top}, and ${who}.\n` +
264
531
  'Its files and branch would change under it. Work in your own worktree instead:\n' +
265
- ` git worktree add ../${name}-<task> -b <branch>`,
532
+ ` git worktree add ../${name}-<task> -b <branch>\n` +
533
+ 'or in one step, with .env files, dependencies and a free port: bulkhead worktree add <name>',
266
534
  }
267
535
  }
268
536
  return null
@@ -273,16 +541,16 @@ function check(cmd, opts) {
273
541
  return hit ? hit.why : null
274
542
  }
275
543
 
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
- })
544
+ function handle(input) {
545
+ const cmd = input.tool_input && input.tool_input.command
546
+ if (!cmd) return
547
+ const hit = classify(cmd, { cwd: input.cwd || process.cwd(), sessionId: input.session_id || null, mode: shell.modeFor(input) })
548
+ if (!hit) return
549
+ return blocked('checkout-guard', hit.rule, input, `${hit.why}\n${ASK_USER}`)
284
550
  }
285
551
 
552
+ if (require.main === module) runHook('checkout-guard', handle)
553
+
286
554
  // `git-checkout`, `git-stash` and so on: the first git command that moves the tree.
287
555
  function ruleOf(cmd, cwd = '.', mode = 'sh') {
288
556
  for (const c of gitCommands(cmd, mode)) {
@@ -293,4 +561,4 @@ function ruleOf(cmd, cwd = '.', mode = 'sh') {
293
561
  return 'git'
294
562
  }
295
563
 
296
- module.exports = { check, classify, changesTree, gitCall, ruleOf, linksIn }
564
+ module.exports = { handle, check, classify, changesTree, gitCall, ruleOf, linksIn, liveSession }
package/src/cli-util.js CHANGED
@@ -27,6 +27,18 @@ function option(args, name) {
27
27
  return undefined
28
28
  }
29
29
 
30
+ // A number option, `--name N` or `--name=N`: { value } (the fallback when the option is absent)
31
+ // or { error } with the message to print. Values at or below `min` are refused unless `inclusive`.
32
+ function numberOption(args, name, { fallback, min = 0, inclusive = false, max = Infinity, tooBig } = {}) {
33
+ const raw = option(args, name)
34
+ if (raw === undefined) return { value: fallback }
35
+ const n = raw === true || String(raw).trim() === '' ? NaN : Number(raw)
36
+ if (!Number.isFinite(n)) return { error: `--${name} needs a number, got ${raw === true ? 'nothing' : raw}` }
37
+ if (n < min || (n === min && !inclusive)) return { error: `--${name} must be ${inclusive ? 'at least' : 'above'} ${min}, got ${raw}` }
38
+ if (n > max) return { error: tooBig ? tooBig(n) : `--${name} must be at most ${max}, got ${raw}` }
39
+ return { value: n }
40
+ }
41
+
30
42
  // Cut to `width` visible characters, ending in … when anything was dropped.
31
43
  function fit(s, width) {
32
44
  s = String(s == null ? '' : s)
@@ -76,4 +88,4 @@ function rejectArgs(cmd, args, spec, stderr, usage) {
76
88
  return true
77
89
  }
78
90
 
79
- module.exports = { unknownArg, rejectArgs, useColor, paint, shortPath, option, fit, table, bytes }
91
+ module.exports = { unknownArg, rejectArgs, useColor, paint, shortPath, option, numberOption, fit, table, bytes }