claude-bulkhead 0.0.0-stage → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +90 -0
- package/LICENSE +21 -0
- package/README.es.md +498 -0
- package/README.ja.md +498 -0
- package/README.ko.md +498 -0
- package/README.md +497 -2
- package/README.pt-BR.md +498 -0
- package/README.zh-CN.md +498 -0
- package/bin/bulkhead +3 -0
- package/bin/bulkhead-slot +3 -0
- package/bin/bulkhead-statusline +3 -0
- package/hooks/hooks.json +39 -0
- package/package.json +52 -4
- package/src/basics.js +236 -0
- package/src/checkout-guard.js +296 -0
- package/src/cli-util.js +79 -0
- package/src/cli.js +41 -0
- package/src/config-check.js +29 -0
- package/src/config-cli.js +51 -0
- package/src/config.js +236 -0
- package/src/dev-servers.js +223 -0
- package/src/disk-guard.js +200 -0
- package/src/doctor.js +255 -0
- package/src/events.js +108 -0
- package/src/hook.js +94 -0
- package/src/kill-guard.js +435 -0
- package/src/log-cli.js +146 -0
- package/src/memory-guard.js +145 -0
- package/src/memory.js +76 -0
- package/src/orphan-reaper.js +527 -0
- package/src/paths.js +31 -0
- package/src/port-cli.js +59 -0
- package/src/port-guard.js +142 -0
- package/src/processes.js +181 -0
- package/src/reap-cli.js +56 -0
- package/src/remote-sh.js +478 -0
- package/src/remote.js +1034 -0
- package/src/remotes.js +126 -0
- package/src/scan.js +604 -0
- package/src/sessions.js +147 -0
- package/src/shell.js +597 -0
- package/src/slot-cli.js +150 -0
- package/src/slots.js +168 -0
- package/src/status.js +111 -0
- package/src/statusline.js +69 -0
- package/src/top-worker.js +14 -0
- package/src/top.js +305 -0
- package/src/wait-guard.js +263 -0
package/src/scan.js
ADDED
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// bulkhead scan: replays the shell commands in your Claude Code transcripts against the guards,
|
|
3
|
+
// to show what they would have stopped before you install them, or what they stopped since.
|
|
4
|
+
// Read-only: it opens transcripts for reading and writes nothing.
|
|
5
|
+
|
|
6
|
+
const fs = require('fs')
|
|
7
|
+
const os = require('os')
|
|
8
|
+
const path = require('path')
|
|
9
|
+
const { claudeDir } = require('./sessions')
|
|
10
|
+
const { setting } = require('./hook')
|
|
11
|
+
const { useColor, paint, shortPath, option, fit, table, bytes } = require('./cli-util')
|
|
12
|
+
const { parseSince } = require('./log-cli')
|
|
13
|
+
|
|
14
|
+
const USAGE = `usage: bulkhead scan [30d|DATE] [--since 30d|DATE] [--path DIR|FILE] [--project TEXT] [-n N] [--json]
|
|
15
|
+
|
|
16
|
+
Replays the shell commands in your Claude Code transcripts against bulkhead's guards and shows
|
|
17
|
+
what they would have stopped. Read-only. Nothing leaves this machine.
|
|
18
|
+
30d, --since SPAN only commands newer than SPAN: 24h, 7d, 2w, or a date such as 2026-10-01
|
|
19
|
+
--path P transcripts to read (default: ~/.claude/projects)
|
|
20
|
+
--project TEXT only sessions whose folder contains TEXT
|
|
21
|
+
-n N examples to show per rule (default 3, 0 for none)
|
|
22
|
+
--json machine-readable output`
|
|
23
|
+
|
|
24
|
+
// Minutes on either side of a command in which another session counts as active.
|
|
25
|
+
const ACTIVE_WINDOW_MIN = 10
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------------------------
|
|
28
|
+
// Reading transcripts
|
|
29
|
+
|
|
30
|
+
// Every .jsonl under dir (subagent transcripts included), or the file itself.
|
|
31
|
+
function findTranscripts(p, { since = null } = {}) {
|
|
32
|
+
const out = []
|
|
33
|
+
let st
|
|
34
|
+
try { st = fs.statSync(p) } catch { return out }
|
|
35
|
+
if (st.isFile()) return /\.jsonl$/.test(p) ? [{ file: p, size: st.size }] : []
|
|
36
|
+
const walk = (dir, depth) => {
|
|
37
|
+
let entries
|
|
38
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }
|
|
39
|
+
for (const e of entries) {
|
|
40
|
+
const f = path.join(dir, e.name)
|
|
41
|
+
if (e.isDirectory() && depth < 4) walk(f, depth + 1)
|
|
42
|
+
else if (e.isFile() && e.name.endsWith('.jsonl')) {
|
|
43
|
+
try {
|
|
44
|
+
const s = fs.statSync(f)
|
|
45
|
+
if (since == null || s.mtimeMs >= since) out.push({ file: f, size: s.size })
|
|
46
|
+
} catch {}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
walk(p, 0)
|
|
51
|
+
return out
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// A subagent writes <session>/subagents/agent-*.jsonl; it belongs to the parent session.
|
|
55
|
+
function sessionKey(file) {
|
|
56
|
+
const parts = file.split(/[\\/]/)
|
|
57
|
+
const i = parts.lastIndexOf('subagents')
|
|
58
|
+
if (i > 0) return parts[i - 1]
|
|
59
|
+
return path.basename(file, '.jsonl')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const TS = Buffer.from('"timestamp":"')
|
|
63
|
+
const Q = 0x22
|
|
64
|
+
// Timestamp of a transcript line held as bytes; the last "timestamp" key is the line's own.
|
|
65
|
+
function timestampOf(buf) {
|
|
66
|
+
const i = buf.lastIndexOf(TS)
|
|
67
|
+
if (i < 0) return NaN
|
|
68
|
+
const start = i + TS.length
|
|
69
|
+
const end = buf.indexOf(Q, start)
|
|
70
|
+
if (end < 0) return NaN
|
|
71
|
+
return Date.parse(buf.toString('latin1', start, end))
|
|
72
|
+
}
|
|
73
|
+
const B_TOOL_USE = Buffer.from('"tool_use"')
|
|
74
|
+
const B_BASH = Buffer.from('"Bash"')
|
|
75
|
+
const B_PS = Buffer.from('"PowerShell"')
|
|
76
|
+
const B_TOOL_RESULT = Buffer.from('"tool_result"')
|
|
77
|
+
const B_IS_ERROR = Buffer.from('"is_error":true')
|
|
78
|
+
const B_CWD = Buffer.from('"cwd":"')
|
|
79
|
+
const CHUNK = 4 * 1024 * 1024
|
|
80
|
+
|
|
81
|
+
// Calls fn(lineBuffer) for each line of a file, reading big chunks and never decoding to text.
|
|
82
|
+
function eachLine(file, fn, onBytes) {
|
|
83
|
+
const fd = fs.openSync(file, 'r')
|
|
84
|
+
try {
|
|
85
|
+
const chunk = Buffer.allocUnsafe(CHUNK)
|
|
86
|
+
let carry = null
|
|
87
|
+
for (;;) {
|
|
88
|
+
const n = fs.readSync(fd, chunk, 0, CHUNK, null)
|
|
89
|
+
if (n <= 0) break
|
|
90
|
+
let pos = 0
|
|
91
|
+
let nl
|
|
92
|
+
while ((nl = chunk.indexOf(10, pos)) >= 0 && nl < n) {
|
|
93
|
+
let line = chunk.subarray(pos, nl)
|
|
94
|
+
if (carry) { line = Buffer.concat([carry, line]); carry = null }
|
|
95
|
+
fn(line)
|
|
96
|
+
pos = nl + 1
|
|
97
|
+
}
|
|
98
|
+
if (pos < n) carry = carry ? Buffer.concat([carry, chunk.subarray(pos, n)]) : Buffer.from(chunk.subarray(pos, n))
|
|
99
|
+
if (onBytes) onBytes(n)
|
|
100
|
+
}
|
|
101
|
+
if (carry && carry.length) fn(carry)
|
|
102
|
+
} finally { fs.closeSync(fd) }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// What became of a tool call that came back as an error: a PreToolUse hook stopped it, or the
|
|
106
|
+
// person declined it in the permission prompt.
|
|
107
|
+
function outcomeOf(text) {
|
|
108
|
+
const s = typeof text === 'string' ? text : Array.isArray(text) ? text.map(t => (t && t.text) || '').join('\n') : ''
|
|
109
|
+
if (/PreToolUse:\S* hook error|hook (?:blocked|denied)|\[bulkhead\]/i.test(s)) return 'hook'
|
|
110
|
+
if (/doesn't want to proceed|user (?:rejected|declined|denied)|permission (?:for this tool use )?(?:was )?denied/i.test(s)) return 'user'
|
|
111
|
+
return null
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Reads one transcript into `state`: commands, outcomes, and the minutes each session was active.
|
|
115
|
+
async function readTranscript(file, state, { since = null, onBytes = null } = {}) {
|
|
116
|
+
const session = sessionKey(file)
|
|
117
|
+
let lastCwd = null
|
|
118
|
+
eachLine(file, buf => {
|
|
119
|
+
state.lines++
|
|
120
|
+
const ts = timestampOf(buf)
|
|
121
|
+
if (Number.isFinite(ts) && (since == null || ts >= since)) {
|
|
122
|
+
const minutes = state.activity.get(session) || new Map()
|
|
123
|
+
const m = Math.floor(ts / 60000)
|
|
124
|
+
if (!minutes.has(m)) minutes.set(m, lastCwd)
|
|
125
|
+
state.activity.set(session, minutes)
|
|
126
|
+
}
|
|
127
|
+
const call = buf.includes(B_TOOL_USE) && (buf.includes(B_BASH) || buf.includes(B_PS))
|
|
128
|
+
const result = buf.includes(B_TOOL_RESULT) && buf.includes(B_IS_ERROR)
|
|
129
|
+
const hasCwd = buf.includes(B_CWD)
|
|
130
|
+
if (!call && !result && !hasCwd) return
|
|
131
|
+
if (!call && !result) {
|
|
132
|
+
// A line that only carries the folder: read it without parsing the whole line.
|
|
133
|
+
const at = buf.indexOf(B_CWD) + B_CWD.length
|
|
134
|
+
const end = buf.indexOf(Q, at)
|
|
135
|
+
// only when the key appears once: a nested "cwd" inside a tool result needs the parser
|
|
136
|
+
if (end > 0 && buf.indexOf(B_CWD, end) < 0) {
|
|
137
|
+
const raw = buf.toString('utf8', at, end)
|
|
138
|
+
if (!raw.includes('\\')) {
|
|
139
|
+
if (raw) {
|
|
140
|
+
lastCwd = raw
|
|
141
|
+
const minutes = state.activity.get(session)
|
|
142
|
+
if (minutes && Number.isFinite(ts)) minutes.set(Math.floor(ts / 60000), raw)
|
|
143
|
+
}
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
handleLine(buf.toString('utf8'), ts, call, result)
|
|
149
|
+
}, onBytes)
|
|
150
|
+
|
|
151
|
+
function handleLine(line, ts, call, result) {
|
|
152
|
+
let obj
|
|
153
|
+
try { obj = JSON.parse(line) } catch { return }
|
|
154
|
+
if (obj.cwd) {
|
|
155
|
+
lastCwd = obj.cwd
|
|
156
|
+
const minutes = state.activity.get(session)
|
|
157
|
+
if (minutes && Number.isFinite(ts)) minutes.set(Math.floor(ts / 60000), obj.cwd)
|
|
158
|
+
}
|
|
159
|
+
const items = obj.message && Array.isArray(obj.message.content) ? obj.message.content : []
|
|
160
|
+
if (call && obj.type === 'assistant') {
|
|
161
|
+
if (since != null && !(ts >= since)) return
|
|
162
|
+
for (const it of items) {
|
|
163
|
+
if (!it || it.type !== 'tool_use' || (it.name !== 'Bash' && it.name !== 'PowerShell')) continue
|
|
164
|
+
const input = it.input || {}
|
|
165
|
+
if (typeof input.command !== 'string' || !input.command) continue
|
|
166
|
+
state.commands.push({
|
|
167
|
+
id: it.id || null,
|
|
168
|
+
session,
|
|
169
|
+
file,
|
|
170
|
+
tool: it.name,
|
|
171
|
+
command: input.command,
|
|
172
|
+
background: input.run_in_background === true || input.run_in_background === 'true',
|
|
173
|
+
timeout: input.timeout,
|
|
174
|
+
cwd: obj.cwd || lastCwd,
|
|
175
|
+
ts,
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (result && obj.type === 'user') {
|
|
180
|
+
for (const it of items) {
|
|
181
|
+
if (!it || it.type !== 'tool_result' || !it.is_error || !it.tool_use_id) continue
|
|
182
|
+
const o = outcomeOf(it.content)
|
|
183
|
+
if (o) state.outcomes.set(it.tool_use_id, o)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------------------------
|
|
190
|
+
// Replaying
|
|
191
|
+
|
|
192
|
+
// The folder git would call the top of the tree, found by looking for .git on disk. A folder
|
|
193
|
+
// that no longer exists is its own tree.
|
|
194
|
+
const treeCache = new Map()
|
|
195
|
+
function treeRoot(cwd) {
|
|
196
|
+
if (!cwd) return null
|
|
197
|
+
// a transcript copied from another OS: C:\Users\... on macOS is not a path here
|
|
198
|
+
if (process.platform !== 'win32' && /^[a-z]:[\\/]/i.test(cwd)) return cwd.replace(/\\/g, '/')
|
|
199
|
+
if (treeCache.has(cwd)) return treeCache.get(cwd)
|
|
200
|
+
let dir = path.resolve(cwd)
|
|
201
|
+
let found = null
|
|
202
|
+
for (let i = 0; i < 40; i++) {
|
|
203
|
+
try {
|
|
204
|
+
if (fs.existsSync(path.join(dir, '.git'))) { found = dir; break }
|
|
205
|
+
} catch {}
|
|
206
|
+
const up = path.dirname(dir)
|
|
207
|
+
if (up === dir) break
|
|
208
|
+
dir = up
|
|
209
|
+
}
|
|
210
|
+
const root = found || path.resolve(cwd)
|
|
211
|
+
treeCache.set(cwd, root)
|
|
212
|
+
return root
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// git as the guards would have seen it: the tree is known, the branch and status are not, so a
|
|
216
|
+
// question about them throws and the guard lets the command through. One exception: for
|
|
217
|
+
// git add -A and git commit -a (status --porcelain with no other option), checkout-guard asks
|
|
218
|
+
// only after another session was active in the same tree, and that session was most likely
|
|
219
|
+
// editing files there. A dirty pull is not assumed.
|
|
220
|
+
function replayGit({ assumeEdits = false } = {}) {
|
|
221
|
+
return (args, cwd) => {
|
|
222
|
+
if (args[0] === 'rev-parse' && args[1] === '--show-toplevel') return treeRoot(cwd)
|
|
223
|
+
if (assumeEdits && args[0] === 'status' && args.length === 2) return ' M (another session was active here)'
|
|
224
|
+
throw new Error('not replayed')
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Other sessions active within the window around `ts`, as the session files checkout-guard reads.
|
|
229
|
+
function activeOthers(state, session, ts) {
|
|
230
|
+
const files = new Map()
|
|
231
|
+
if (!Number.isFinite(ts)) return files
|
|
232
|
+
if (!state.byMinute) {
|
|
233
|
+
// minute -> [[session, cwd]], so a command looks at 21 minutes instead of every session
|
|
234
|
+
state.byMinute = new Map()
|
|
235
|
+
for (const [other, minutes] of state.activity) {
|
|
236
|
+
for (const [k, cwd] of minutes) {
|
|
237
|
+
if (!cwd) continue
|
|
238
|
+
const list = state.byMinute.get(k)
|
|
239
|
+
if (list) list.push([other, cwd])
|
|
240
|
+
else state.byMinute.set(k, [[other, cwd]])
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
const m = Math.floor(ts / 60000)
|
|
245
|
+
const seen = new Set([session])
|
|
246
|
+
let pid = 1
|
|
247
|
+
for (let k = m - ACTIVE_WINDOW_MIN; k <= m + ACTIVE_WINDOW_MIN; k++) {
|
|
248
|
+
const list = state.byMinute.get(k)
|
|
249
|
+
if (!list) continue
|
|
250
|
+
for (const [other, cwd] of list) {
|
|
251
|
+
if (seen.has(other)) continue
|
|
252
|
+
seen.add(other)
|
|
253
|
+
files.set(pid++, { cwd, sessionId: other, startedAt: null })
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return files
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Every guard rule that would have stopped this command, as [{ guard, rule }].
|
|
260
|
+
function replay(cmd, state, { home = os.homedir(), platform = process.platform, settings = null } = {}) {
|
|
261
|
+
const cfg = settings || { allowNames: setting('killGuard.allowNames', []), extraSharedNames: setting('killGuard.extraSharedNames', []), waitLimit: setting('waitGuard.limitSeconds', 180) }
|
|
262
|
+
const hits = []
|
|
263
|
+
const mode = cmd.tool === 'PowerShell' ? 'ps' : 'sh'
|
|
264
|
+
const text = cmd.command
|
|
265
|
+
const cwd = cmd.cwd || home
|
|
266
|
+
const add = (guard, rule) => { if (!hits.some(h => h.guard === guard && h.rule === rule)) hits.push({ guard, rule }) }
|
|
267
|
+
const tryRun = fn => { try { fn() } catch {} }
|
|
268
|
+
|
|
269
|
+
tryRun(() => {
|
|
270
|
+
const hit = require('./kill-guard').classify(text, { allowNames: cfg.allowNames, extraSharedNames: cfg.extraSharedNames }, mode)
|
|
271
|
+
if (hit) add('kill-guard', hit.rule)
|
|
272
|
+
})
|
|
273
|
+
tryRun(() => {
|
|
274
|
+
if (cmd.background) return
|
|
275
|
+
const v = require('./wait-guard').judge(text, cmd.timeout, cfg.waitLimit, mode)
|
|
276
|
+
if (v) add('wait-guard', v.rule)
|
|
277
|
+
})
|
|
278
|
+
tryRun(() => {
|
|
279
|
+
if (require('./memory-guard').touchesSlotFiles(text)) add('memory-guard', 'slot-files')
|
|
280
|
+
})
|
|
281
|
+
tryRun(() => {
|
|
282
|
+
const hit = require('./disk-guard').check(text, { home, mode })
|
|
283
|
+
if (hit) add('disk-guard', hit.rule)
|
|
284
|
+
})
|
|
285
|
+
tryRun(() => {
|
|
286
|
+
const basics = require('./basics')
|
|
287
|
+
const why = basics.check(text, { cwd, home, platform, run: replayGit(), mode })
|
|
288
|
+
if (why) add('basics', basics.ruleOf(why))
|
|
289
|
+
})
|
|
290
|
+
tryRun(() => {
|
|
291
|
+
const others = activeOthers(state, cmd.session, cmd.ts)
|
|
292
|
+
if (!others.size) return
|
|
293
|
+
const hit = require('./checkout-guard').classify(text, {
|
|
294
|
+
cwd, sessionId: cmd.session, files: () => others, isAlive: () => true, run: replayGit({ assumeEdits: true }), platform, mode,
|
|
295
|
+
home, links: () => [], removeRule: () => true, stageRule: () => true,
|
|
296
|
+
})
|
|
297
|
+
if (hit && hit.rule !== 'worktree-force-links') add('checkout-guard', hit.rule)
|
|
298
|
+
})
|
|
299
|
+
return hits
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// The most sessions running at once, and on how many days two or more overlapped. A session
|
|
303
|
+
// counts as running between two of its events when they are at most ACTIVE_WINDOW_MIN apart.
|
|
304
|
+
function concurrency(state) {
|
|
305
|
+
const perMinute = new Map()
|
|
306
|
+
for (const minutes of state.activity.values()) {
|
|
307
|
+
const sorted = [...minutes.keys()].sort((a, b) => a - b)
|
|
308
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
309
|
+
const next = sorted[i + 1]
|
|
310
|
+
const end = next != null && next - sorted[i] <= ACTIVE_WINDOW_MIN ? next - 1 : sorted[i]
|
|
311
|
+
for (let m = sorted[i]; m <= end; m++) perMinute.set(m, (perMinute.get(m) || 0) + 1)
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
let max = 0
|
|
315
|
+
let at = null
|
|
316
|
+
const days = new Set()
|
|
317
|
+
for (const [m, n] of perMinute) {
|
|
318
|
+
if (n > max || (n === max && at != null && m > at)) { max = n; at = m }
|
|
319
|
+
if (n >= 2) days.add(new Date(m * 60000).toDateString())
|
|
320
|
+
}
|
|
321
|
+
return { max, at: at == null ? null : new Date(at * 60000).toISOString(), overlapDays: days.size }
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ---------------------------------------------------------------------------------------------
|
|
325
|
+
// Masking and summary
|
|
326
|
+
|
|
327
|
+
// Secret-looking values are cut out of the examples before anything is printed. Every pattern
|
|
328
|
+
// is linear in the length of the command: a transcript can hold a command of megabytes.
|
|
329
|
+
const SECRET_NAME = /PASSWORD|PASSWD|SECRET|TOKEN|API_?KEY|ACCESS_?KEY|PRIVATE_?KEY/i
|
|
330
|
+
const SECRETS = [
|
|
331
|
+
[/(^|\s)(-u|--user|--password|--token|--api-key|--secret)(\s+|=)(['"]?)[^\s'"]+\4/gi, '$1$2$3***'],
|
|
332
|
+
[/\b(Bearer|Basic|token)\s+[A-Za-z0-9._~+/=-]{8,}/gi, '$1 ***'],
|
|
333
|
+
// NAME=value where the name says it is a secret; the name is matched once, then tested
|
|
334
|
+
[/\b([A-Za-z_][A-Za-z0-9_]*)=(['"]?)([^\s'"]+)\2/g, (m, name, q) => (SECRET_NAME.test(name) ? `${name}=${q}***${q}` : m)],
|
|
335
|
+
[/\b(?:sk|pk|rk)-[A-Za-z0-9_-]{16,}\b/g, '***'],
|
|
336
|
+
[/\b(?:ghp|gho|ghs|ghu|github_pat)_[A-Za-z0-9_]{16,}\b/g, '***'],
|
|
337
|
+
[/\bAKIA[0-9A-Z]{16}\b/g, '***'],
|
|
338
|
+
[/\bxox[abpors]-[A-Za-z0-9-]{10,}\b/g, '***'],
|
|
339
|
+
[/(https?:\/\/)[^\s/:@]+:[^\s/@]+@/g, '$1***@'],
|
|
340
|
+
]
|
|
341
|
+
function mask(s) {
|
|
342
|
+
let out = String(s)
|
|
343
|
+
for (const [re, to] of SECRETS) out = out.replace(re, to)
|
|
344
|
+
return out
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function summarize(state, hitsByCommand) {
|
|
348
|
+
const rules = new Map()
|
|
349
|
+
const sessions = new Set()
|
|
350
|
+
const projects = new Map()
|
|
351
|
+
let commandsHit = 0
|
|
352
|
+
for (const { cmd, hits } of hitsByCommand) {
|
|
353
|
+
if (!hits.length) continue
|
|
354
|
+
commandsHit++
|
|
355
|
+
sessions.add(cmd.session)
|
|
356
|
+
const proj = treeRoot(cmd.cwd) || '?'
|
|
357
|
+
projects.set(proj, (projects.get(proj) || 0) + 1)
|
|
358
|
+
const outcome = (cmd.id && state.outcomes.get(cmd.id)) || null
|
|
359
|
+
for (const h of hits) {
|
|
360
|
+
const k = `${h.guard}\u0000${h.rule}`
|
|
361
|
+
const r = rules.get(k) || { guard: h.guard, rule: h.rule, count: 0, sessions: new Set(), hook: 0, user: 0, last: null, examples: [] }
|
|
362
|
+
r.count++
|
|
363
|
+
r.sessions.add(cmd.session)
|
|
364
|
+
if (outcome === 'hook') r.hook++
|
|
365
|
+
if (outcome === 'user') r.user++
|
|
366
|
+
if (Number.isFinite(cmd.ts) && (!r.last || cmd.ts > r.last)) r.last = cmd.ts
|
|
367
|
+
r.examples.push({ command: mask(cmd.command), cwd: cmd.cwd, ts: cmd.ts, outcome })
|
|
368
|
+
rules.set(k, r)
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
const list = [...rules.values()].map(r => ({
|
|
372
|
+
...r,
|
|
373
|
+
sessions: r.sessions.size,
|
|
374
|
+
// newest first, one example per distinct command
|
|
375
|
+
examples: dedupe(r.examples.sort((a, b) => (b.ts || 0) - (a.ts || 0))),
|
|
376
|
+
})).sort((a, b) => b.count - a.count || a.guard.localeCompare(b.guard) || a.rule.localeCompare(b.rule))
|
|
377
|
+
return {
|
|
378
|
+
commandsHit,
|
|
379
|
+
sessionsHit: sessions.size,
|
|
380
|
+
rules: list,
|
|
381
|
+
projects: [...projects].map(([project, count]) => ({ project, count })).sort((a, b) => b.count - a.count),
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function dedupe(examples) {
|
|
386
|
+
const seen = new Set()
|
|
387
|
+
return examples.filter(e => {
|
|
388
|
+
const k = e.command.replace(/\s+/g, ' ').trim()
|
|
389
|
+
if (seen.has(k)) return false
|
|
390
|
+
seen.add(k)
|
|
391
|
+
return true
|
|
392
|
+
})
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ---------------------------------------------------------------------------------------------
|
|
396
|
+
// Output
|
|
397
|
+
|
|
398
|
+
const pad2 = n => String(n).padStart(2, '0')
|
|
399
|
+
const MONTHS = 'Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec'.split(' ')
|
|
400
|
+
const day = ms => { const d = new Date(ms); return `${MONTHS[d.getMonth()]} ${pad2(d.getDate())}` }
|
|
401
|
+
const plural = (n, word) => `${n.toLocaleString('en-US')} ${word}${n === 1 ? '' : 's'}`
|
|
402
|
+
|
|
403
|
+
// What each guard protects, said once under its first rule.
|
|
404
|
+
const RULE_NOTES = {
|
|
405
|
+
'kill-guard': 'kills by program name take down every session\'s servers',
|
|
406
|
+
'wait-guard': 'foreground waits hold the session and expire the prompt cache',
|
|
407
|
+
'memory-guard': 'hand-edited slot files leave a slot held by nobody',
|
|
408
|
+
'disk-guard': 'whole-disk and parallel scans stall every session\'s I/O',
|
|
409
|
+
basics: 'mistakes that cannot be undone',
|
|
410
|
+
'checkout-guard': 'another session was active in the same tree at that minute',
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function renderScan(r, { color = false, home = os.homedir(), examples = 3, width = 120 } = {}) {
|
|
414
|
+
const out = []
|
|
415
|
+
const b = s => paint(color, 'bold', s)
|
|
416
|
+
out.push(`${b('bulkhead scan')} replaying your Claude Code sessions against the guards`)
|
|
417
|
+
out.push(` read ${plural(r.files, 'transcript')} (${bytes(r.bytes)}), ${plural(r.sessions, 'session')}, ${plural(r.commands, 'shell command')} in ${r.seconds.toFixed(1)}s`)
|
|
418
|
+
if (r.first) out.push(` span ${day(r.first)} .. ${day(r.last)}${r.sinceLabel ? ` (since ${r.sinceLabel})` : ''}`)
|
|
419
|
+
if (r.concurrency.max >= 2) {
|
|
420
|
+
out.push(` overlap up to ${b(r.concurrency.max)} sessions running at once (${day(Date.parse(r.concurrency.at))}), ` +
|
|
421
|
+
`${plural(r.concurrency.overlapDays, 'day')} with two or more at once`)
|
|
422
|
+
}
|
|
423
|
+
out.push('')
|
|
424
|
+
if (!r.summary.rules.length) {
|
|
425
|
+
out.push(r.commands ? 'Nothing in these transcripts would have been stopped.' : 'No shell commands found.')
|
|
426
|
+
return out.join('\n')
|
|
427
|
+
}
|
|
428
|
+
const rows = [['GUARD', 'RULE', 'HITS', 'SESSIONS', 'STOPPED', 'LAST']]
|
|
429
|
+
for (const x of r.summary.rules) {
|
|
430
|
+
const stopped = x.hook + x.user
|
|
431
|
+
rows.push([x.guard, x.rule, String(x.count).padStart(4), String(x.sessions).padStart(8), stopped ? `${stopped}`.padStart(7) : ' -', x.last ? day(x.last) : ''])
|
|
432
|
+
}
|
|
433
|
+
const lines = table(rows)
|
|
434
|
+
out.push(b(lines[0]), ...lines.slice(1))
|
|
435
|
+
if (examples > 0) {
|
|
436
|
+
let lastGuard = null
|
|
437
|
+
for (const x of r.summary.rules) {
|
|
438
|
+
out.push('')
|
|
439
|
+
const note = x.guard !== lastGuard && RULE_NOTES[x.guard] ? paint(color, 'dim', ` ${RULE_NOTES[x.guard]}`) : ''
|
|
440
|
+
lastGuard = x.guard
|
|
441
|
+
out.push(`${paint(color, x.guard === 'checkout-guard' || x.guard === 'kill-guard' ? 'red' : 'yellow', `${x.guard} ${x.rule}`)} ${plural(x.count, 'time')} in ${plural(x.sessions, 'session')}${note}`)
|
|
442
|
+
for (const e of x.examples.slice(0, examples)) {
|
|
443
|
+
const tag = e.outcome === 'hook' ? ' [stopped by a hook]' : e.outcome === 'user' ? ' [declined by you]' : ''
|
|
444
|
+
const meta = `${shortPath(e.cwd ? treeRoot(e.cwd) : '?', home)} ${e.ts ? day(e.ts) : ''}${tag}`
|
|
445
|
+
const room = Math.max(30, width - meta.length - 8)
|
|
446
|
+
out.push(` $ ${fit(e.command.replace(/\s*\n\s*/g, ' ⏎ '), room)} ${paint(color, 'gray', meta)}`)
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
out.push('')
|
|
451
|
+
const s = r.summary
|
|
452
|
+
const already = s.rules.reduce((n, x) => n + x.hook + x.user, 0)
|
|
453
|
+
out.push(b(`${plural(s.commandsHit, 'command')} in ${plural(s.sessionsHit, 'session')} would have been stopped by bulkhead.`) +
|
|
454
|
+
(already ? ` ${already.toLocaleString('en-US')} of the hits were already stopped by a hook or declined by you.` : ''))
|
|
455
|
+
out.push(paint(color, 'dim', 'A hit means the command had the shape a guard blocks, not that damage was done. port-guard and the low-RAM check depend on'))
|
|
456
|
+
out.push(paint(color, 'dim', 'what was running at that moment and are not replayed. checkout-guard is replayed from which sessions were active in the same folder.'))
|
|
457
|
+
return out.join('\n')
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function toJson(r) {
|
|
461
|
+
return {
|
|
462
|
+
files: r.files,
|
|
463
|
+
bytes: r.bytes,
|
|
464
|
+
sessions: r.sessions,
|
|
465
|
+
commands: r.commands,
|
|
466
|
+
seconds: Number(r.seconds.toFixed(2)),
|
|
467
|
+
first: r.first ? new Date(r.first).toISOString() : null,
|
|
468
|
+
last: r.last ? new Date(r.last).toISOString() : null,
|
|
469
|
+
concurrency: r.concurrency,
|
|
470
|
+
commandsHit: r.summary.commandsHit,
|
|
471
|
+
sessionsHit: r.summary.sessionsHit,
|
|
472
|
+
rules: r.summary.rules.map(x => ({
|
|
473
|
+
guard: x.guard, rule: x.rule, count: x.count, sessions: x.sessions, stoppedByHook: x.hook, declinedByUser: x.user,
|
|
474
|
+
last: x.last ? new Date(x.last).toISOString() : null,
|
|
475
|
+
examples: x.examples.slice(0, 5).map(e => ({ command: e.command, project: treeRoot(e.cwd) || e.cwd, time: e.ts ? new Date(e.ts).toISOString() : null, outcome: e.outcome })),
|
|
476
|
+
})),
|
|
477
|
+
projects: r.summary.projects,
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// ---------------------------------------------------------------------------------------------
|
|
482
|
+
|
|
483
|
+
// Earliest and latest timestamp. A loop, not Math.min(...times): a heavy user has hundreds of
|
|
484
|
+
// thousands of commands and spreading them into a call overflows the stack.
|
|
485
|
+
function timeSpan(commands) {
|
|
486
|
+
let first = null
|
|
487
|
+
let last = null
|
|
488
|
+
for (const c of commands) {
|
|
489
|
+
if (!Number.isFinite(c.ts)) continue
|
|
490
|
+
if (first === null || c.ts < first) first = c.ts
|
|
491
|
+
if (last === null || c.ts > last) last = c.ts
|
|
492
|
+
}
|
|
493
|
+
return { first, last }
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
async function scan({ dir = path.join(claudeDir(), 'projects'), since = null, project = null, home = os.homedir(), platform = process.platform, onProgress = null } = {}) {
|
|
497
|
+
const t0 = Date.now()
|
|
498
|
+
const files = findTranscripts(dir, { since })
|
|
499
|
+
const state = { commands: [], outcomes: new Map(), activity: new Map(), lines: 0 }
|
|
500
|
+
const total = files.reduce((n, f) => n + f.size, 0)
|
|
501
|
+
let done = 0
|
|
502
|
+
let doneFiles = 0
|
|
503
|
+
let tick = Date.now()
|
|
504
|
+
const report = () => {
|
|
505
|
+
if (!onProgress || Date.now() - tick < 500) return
|
|
506
|
+
tick = Date.now()
|
|
507
|
+
onProgress({ files: doneFiles, totalFiles: files.length, bytes: done, totalBytes: total, seconds: (tick - t0) / 1000 })
|
|
508
|
+
}
|
|
509
|
+
for (const f of files) {
|
|
510
|
+
try { await readTranscript(f.file, state, { since, onBytes: n => { done += n; report() } }) } catch {}
|
|
511
|
+
doneFiles++
|
|
512
|
+
report()
|
|
513
|
+
}
|
|
514
|
+
let commands = state.commands
|
|
515
|
+
if (project) commands = commands.filter(c => String(c.cwd || '').toLowerCase().includes(project.toLowerCase()))
|
|
516
|
+
const settings = { allowNames: setting('killGuard.allowNames', []), extraSharedNames: setting('killGuard.extraSharedNames', []), waitLimit: setting('waitGuard.limitSeconds', 180) }
|
|
517
|
+
const tRead = Date.now()
|
|
518
|
+
const hitsByCommand = commands.map(cmd => ({ cmd, hits: replay(cmd, state, { home, platform, settings }) }))
|
|
519
|
+
const { first, last } = timeSpan(commands)
|
|
520
|
+
return {
|
|
521
|
+
files: files.length,
|
|
522
|
+
bytes: files.reduce((n, f) => n + f.size, 0),
|
|
523
|
+
sessions: new Set(commands.map(c => c.session)).size,
|
|
524
|
+
commands: commands.length,
|
|
525
|
+
first,
|
|
526
|
+
last,
|
|
527
|
+
concurrency: concurrency(state),
|
|
528
|
+
summary: summarize(state, hitsByCommand),
|
|
529
|
+
seconds: (Date.now() - t0) / 1000,
|
|
530
|
+
readSeconds: (tRead - t0) / 1000,
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const VALUE_FLAGS = ['since', 'path', 'project']
|
|
535
|
+
const BOOL_FLAGS = ['json']
|
|
536
|
+
// The first bare argument that is not a flag's value: `bulkhead scan 30d`.
|
|
537
|
+
function positionals(args) {
|
|
538
|
+
const out = []
|
|
539
|
+
for (let i = 0; i < args.length; i++) {
|
|
540
|
+
const a = args[i]
|
|
541
|
+
if (a === '-n') { i++; continue }
|
|
542
|
+
if (a.startsWith('--')) {
|
|
543
|
+
if (!a.includes('=') && VALUE_FLAGS.includes(a.slice(2)) && args[i + 1] !== undefined && !args[i + 1].startsWith('--')) i++
|
|
544
|
+
continue
|
|
545
|
+
}
|
|
546
|
+
out.push(a)
|
|
547
|
+
}
|
|
548
|
+
return out
|
|
549
|
+
}
|
|
550
|
+
const positionalSince = args => positionals(args)[0] || null
|
|
551
|
+
function checkArgs(args) {
|
|
552
|
+
for (const a of args) {
|
|
553
|
+
if (!a.startsWith('-') || a === '-n') continue
|
|
554
|
+
const name = a.startsWith('--') ? a.slice(2).split('=')[0] : null
|
|
555
|
+
if (!name || ![...VALUE_FLAGS, ...BOOL_FLAGS].includes(name)) return `unknown option ${a}`
|
|
556
|
+
}
|
|
557
|
+
const pos = positionals(args)
|
|
558
|
+
if (pos.length > 1) return `unexpected argument ${pos[1]}`
|
|
559
|
+
if (pos.length && option(args, 'since') !== undefined) return `give the time span once: --since ${option(args, 'since')} or ${pos[0]}, not both`
|
|
560
|
+
return null
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
async function main(args, { env = process.env, stdout = process.stdout, stderr = process.stderr, now = Date.now() } = {}) {
|
|
564
|
+
if (args.includes('--help') || args.includes('-h')) return stdout.write(USAGE + '\n')
|
|
565
|
+
const bad = checkArgs(args)
|
|
566
|
+
if (bad) {
|
|
567
|
+
stderr.write(`bulkhead scan: ${bad}\n${USAGE}\n`)
|
|
568
|
+
process.exitCode = 1
|
|
569
|
+
return
|
|
570
|
+
}
|
|
571
|
+
const sinceArg = option(args, 'since') || positionalSince(args)
|
|
572
|
+
let since = null
|
|
573
|
+
if (typeof sinceArg === 'string' && sinceArg) {
|
|
574
|
+
since = parseSince(sinceArg, now)
|
|
575
|
+
if (!Number.isFinite(since)) {
|
|
576
|
+
stderr.write(`bulkhead scan: cannot read --since ${sinceArg}; use 24h, 7d, 2w or a date like 2026-10-01\n`)
|
|
577
|
+
process.exitCode = 1
|
|
578
|
+
return
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
const p = option(args, 'path')
|
|
582
|
+
const dir = typeof p === 'string' ? path.resolve(p.replace(/^~(?=[\\/]|$)/, os.homedir())) : path.join(claudeDir(), 'projects')
|
|
583
|
+
if (!fs.existsSync(dir)) {
|
|
584
|
+
stderr.write(`bulkhead scan: ${shortPath(dir)} does not exist. Claude Code keeps transcripts in ~/.claude/projects; pass another folder with --path.\n`)
|
|
585
|
+
process.exitCode = 1
|
|
586
|
+
return
|
|
587
|
+
}
|
|
588
|
+
const proj = option(args, 'project')
|
|
589
|
+
const nIdx = args.indexOf('-n')
|
|
590
|
+
const n = nIdx >= 0 && Number(args[nIdx + 1]) >= 0 ? Math.floor(Number(args[nIdx + 1])) : 3
|
|
591
|
+
let shown = false
|
|
592
|
+
const onProgress = stderr.isTTY ? prog => {
|
|
593
|
+
if (prog.seconds < 3) return
|
|
594
|
+
shown = true
|
|
595
|
+
stderr.write(`\r\x1b[2Kscanning ${prog.files.toLocaleString('en-US')}/${prog.totalFiles.toLocaleString('en-US')} transcripts, ${bytes(prog.bytes)} of ${bytes(prog.totalBytes)}`)
|
|
596
|
+
} : null
|
|
597
|
+
const r = await scan({ dir, since, project: typeof proj === 'string' ? proj : null, onProgress })
|
|
598
|
+
if (shown) stderr.write('\r\x1b[2K')
|
|
599
|
+
r.sinceLabel = typeof sinceArg === 'string' ? sinceArg : null
|
|
600
|
+
if (args.includes('--json')) return stdout.write(JSON.stringify(toJson(r), null, 2) + '\n')
|
|
601
|
+
stdout.write(renderScan(r, { color: useColor(stdout, env), examples: n, width: stdout.columns || 120 }) + '\n')
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
module.exports = { main, scan, timeSpan, replay, renderScan, toJson, mask, outcomeOf, sessionKey, findTranscripts, concurrency, USAGE }
|