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
@@ -0,0 +1,142 @@
1
+ 'use strict'
2
+ const net = require('net')
3
+ const os = require('os')
4
+ const { runHook, blocked, setting, oneLine } = require('./hook')
5
+ const { detect } = require('./dev-servers')
6
+ const { readProcesses } = require('./processes')
7
+ const { readSessionFiles, findSessions, ownSession } = require('./sessions')
8
+ const shell = require('./shell')
9
+
10
+ // The command with heredoc bodies, commit messages and shell wrappers (bash -c "...") resolved.
11
+ const opened = (cmd, mode = 'sh') => shell.unwrap(shell.prepare(String(cmd || ''), mode), mode)
12
+
13
+ function freePort(port, taken) {
14
+ for (let p = port + 1; p < port + 200 && p < 65536; p++) if (!taken.has(p)) return p
15
+ return null
16
+ }
17
+
18
+ function shortPath(p) {
19
+ const home = os.homedir()
20
+ return p && p.toLowerCase().startsWith(home.toLowerCase()) ? '~' + p.slice(home.length).replace(/\\/g, '/') : p || 'unknown directory'
21
+ }
22
+
23
+ // A connect attempt takes a few milliseconds; reading the process list takes a second or two
24
+ // on Windows, so we only read it when something answers on the port.
25
+ function listening(port, timeoutMs = 300) {
26
+ const one = host => new Promise(resolve => {
27
+ const sock = net.connect({ host, port })
28
+ const done = ok => { sock.destroy(); resolve(ok) }
29
+ sock.setTimeout(timeoutMs, () => done(false))
30
+ sock.once('connect', () => done(true))
31
+ sock.once('error', () => done(false))
32
+ })
33
+ return Promise.all([one('127.0.0.1'), one('::1')]).then(r => r.some(Boolean))
34
+ }
35
+
36
+ // Resolves to a block message, or null. Anything we cannot pin down is allowed.
37
+ async function check(cmd, {
38
+ cwd, probe = listening, snapshot = () => readProcesses(), files = () => readSessionFiles(), self = ownSession, ignore = setting('portGuard.ignorePorts', []), mode = 'sh',
39
+ } = {}) {
40
+ const server = detect(opened(cmd, mode), { cwd })
41
+ if (!server || !server.port || ignore.includes(server.port)) return null
42
+ if (!(await probe(server.port))) return null
43
+ const snap = snapshot()
44
+ if (!snap) return null
45
+ const holder = snap.ports.find(l => l.port === server.port)
46
+ if (!holder) return null
47
+ const owner = findSessions(snap, files()).find(s => s.ports.some(l => l.pid === holder.pid))
48
+ if (!owner) return null
49
+ const me = self(snap.procs)
50
+ if (!me || me === owner.pid) return null
51
+ const name = (snap.procs.find(p => p.pid === holder.pid) || {}).name || 'a process'
52
+ const free = freePort(server.port, new Set(snap.ports.map(l => l.port)))
53
+ return `[bulkhead] port-guard: port ${server.port} is already used by another Claude session ` +
54
+ `(session pid ${owner.pid}, ${shortPath(owner.cwd)}): ${name} pid ${holder.pid}.\n` +
55
+ 'Leave that server alone; it is the other session\'s work. If you start yours anyway, it either fails or moves to the ' +
56
+ `next port, and requests to localhost:${server.port} reach the other project.\n` +
57
+ (free ? `Port ${free} is free:\n ${server.hint(free)}` : `Pick a free port and pass it explicitly; \`bulkhead port --near ${server.port}\` prints one.`)
58
+ }
59
+
60
+ const numbers = s => (String(s).match(/\d+/g) || []).map(Number).filter(n => n > 0 && n < 65536)
61
+ const KILLS = /\b(?:kill|taskkill|Stop-Process|spps)\b(?!-port)/i
62
+
63
+ // Ports a kill-by-port command aims at, or [] when the command does not kill by port.
64
+ // Statements are split on ; && || and newlines, but not on pipes, which carry the PID along.
65
+ function killPorts(cmd) {
66
+ const ports = new Set()
67
+ const add = list => list.forEach(p => ports.add(p))
68
+ for (const st of String(cmd || '').split(/\n|;|&&|\|\|/)) {
69
+ // npx kill-port 3000 3001, npx kill-port --port 3000,3001
70
+ const kp = /\bkill-port\b((?:\s+(?:--port\b|-p\b|[\d,]+)(?:[=\s]+[\d,]+)?)+)/i.exec(st)
71
+ if (kp) add(numbers(kp[1]))
72
+ // fuser -k 3000/tcp, fuser -k -n tcp 3000
73
+ if (/\bfuser\b/.test(st) && /(?:^|\s)-[a-zA-Z]*k/.test(st)) {
74
+ add([...st.matchAll(/\b(\d+)\/tcp\b/gi)].map(m => Number(m[1])))
75
+ const n = /-n\s+tcp\s+(\d+)/i.exec(st)
76
+ if (n) add([Number(n[1])])
77
+ }
78
+ // lsof -ti :3000 | xargs kill -9, kill $(lsof -t -i:3000)
79
+ if (/\blsof\b/.test(st) && /\blsof\b[^|)`]*\s-[a-zA-Z]*t/.test(st) && KILLS.test(st.replace(/\blsof\b[^|)`]*/g, ' '))) {
80
+ add([...st.matchAll(/-[a-zA-Z]*i\s*(?:tcp|TCP)?:(\d+)/g)].map(m => Number(m[1])))
81
+ }
82
+ // Stop-Process -Id (Get-NetTCPConnection -LocalPort 3000).OwningProcess
83
+ if (/Get-NetTCPConnection/i.test(st) && KILLS.test(st)) {
84
+ const lp = /-LocalPort\s+((?:\d+\s*,\s*)*\d+)/i.exec(st)
85
+ if (lp) add(numbers(lp[1]))
86
+ }
87
+ // netstat -ano | findstr :3000 ... taskkill /PID, for /f ... in ('netstat -aon ^| findstr :3000') do taskkill
88
+ if (/\bnetstat\b/i.test(st) && KILLS.test(st)) {
89
+ const f = /\b(?:findstr|grep|Select-String)\b[^|]*?:(\d+)/i.exec(st)
90
+ if (f) add([Number(f[1])])
91
+ }
92
+ }
93
+ return [...ports].filter(p => p > 0 && p < 65536)
94
+ }
95
+
96
+ // Resolves to a block message when a kill by port would hit another live session's server.
97
+ async function checkKill(cmd, {
98
+ probe = listening, snapshot = () => readProcesses(), files = () => readSessionFiles(), self = ownSession, ignore = setting('portGuard.ignorePorts', []), mode = 'sh',
99
+ } = {}) {
100
+ const ports = killPorts(opened(cmd, mode)).filter(p => !ignore.includes(p))
101
+ if (!ports.length) return null
102
+ const live = []
103
+ for (const p of ports) if (await probe(p)) live.push(p)
104
+ if (!live.length) return null
105
+ const snap = snapshot()
106
+ if (!snap) return null
107
+ const sessions = findSessions(snap, files())
108
+ const me = self(snap.procs)
109
+ if (!me) return null
110
+ for (const port of live) {
111
+ for (const holder of snap.ports.filter(l => l.port === port)) {
112
+ const owner = sessions.find(s => s.ports.some(l => l.pid === holder.pid))
113
+ if (!owner || owner.pid === me) continue
114
+ const name = (snap.procs.find(p => p.pid === holder.pid) || {}).name || 'a process'
115
+ const free = freePort(port, new Set(snap.ports.map(l => l.port)))
116
+ return {
117
+ port,
118
+ message: `[bulkhead] port-guard: \`${oneLine(cmd, 120)}\` would stop ${name} pid ${holder.pid}, the server another Claude session ` +
119
+ `runs on port ${port} (session pid ${owner.pid}, ${shortPath(owner.cwd)}).\n` +
120
+ 'That server is the other session\'s work. Stopping it breaks whatever that session is running or testing.\n' +
121
+ (free ? `If you need a server of your own, start it on a free port instead: ${free} is free.\n` : 'If you need a server of your own, start it on another port.\n') +
122
+ `\`bulkhead port --near ${port}\` prints a port no session holds.\n` +
123
+ 'To stop a process you started yourself, stop it by its PID.',
124
+ }
125
+ }
126
+ }
127
+ return null
128
+ }
129
+
130
+ if (require.main === module) {
131
+ runHook('port-guard', async input => {
132
+ const cmd = input.tool_input && input.tool_input.command
133
+ if (typeof cmd !== 'string' || !cmd) return
134
+ const mode = shell.modeFor(input)
135
+ const why = await check(cmd, { cwd: input.cwd, mode })
136
+ if (why) return blocked('port-guard', 'port-in-use', input, why)
137
+ const kill = await checkKill(cmd, { mode })
138
+ if (kill) return blocked('port-guard', 'kill-by-port', input, kill.message)
139
+ })
140
+ }
141
+
142
+ module.exports = { check, checkKill, killPorts, freePort, listening }
@@ -0,0 +1,181 @@
1
+ 'use strict'
2
+ const fs = require('fs')
3
+ const path = require('path')
4
+ const { execFileSync } = require('child_process')
5
+
6
+ // Every reader returns { procs: [{ pid, ppid, name, cmd, start, rss, claudePid }], listening: Set<pid>,
7
+ // ports: [{ port, pid }] }. start is milliseconds since the epoch, rss is bytes, and claudePid is null
8
+ // when the environment is unreadable.
9
+
10
+ const WINDOWS_QUERY = `
11
+ $procs = Get-CimInstance Win32_Process | ForEach-Object {
12
+ $t = 0; if ($_.CreationDate) { $t = ([DateTimeOffset]$_.CreationDate).ToUnixTimeMilliseconds() }
13
+ [pscustomobject]@{ pid = $_.ProcessId; ppid = $_.ParentProcessId; name = $_.Name; cmd = $_.CommandLine; start = $t; rss = $_.WorkingSetSize }
14
+ }
15
+ $listen = @(Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | ForEach-Object { [pscustomobject]@{ pid = $_.OwningProcess; port = $_.LocalPort } })
16
+ [pscustomobject]@{ procs = @($procs); listen = $listen } | ConvertTo-Json -Compress -Depth 3
17
+ `
18
+
19
+ // IPv4 and IPv6 sockets on the same port show up twice.
20
+ function uniquePorts(list) {
21
+ const seen = new Map()
22
+ for (const l of list) seen.set(`${l.pid}:${l.port}`, { port: Number(l.port), pid: Number(l.pid) })
23
+ return [...seen.values()].sort((a, b) => a.port - b.port)
24
+ }
25
+
26
+ function parseWindows(json) {
27
+ const data = JSON.parse(json)
28
+ const procs = [].concat(data.procs || []).map(p => ({
29
+ pid: p.pid, ppid: p.ppid, name: p.name || '', cmd: p.cmd || '', start: Number(p.start) || 0, rss: Number(p.rss) || 0, claudePid: null,
30
+ }))
31
+ const ports = uniquePorts([].concat(data.listen || []))
32
+ return { procs, listening: new Set(ports.map(l => l.pid)), ports }
33
+ }
34
+
35
+ function readWindows(run = execFileSync) {
36
+ const out = run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', WINDOWS_QUERY],
37
+ { encoding: 'utf8', windowsHide: true, timeout: 60000, maxBuffer: 64 * 1024 * 1024 })
38
+ return parseWindows(out)
39
+ }
40
+
41
+ // /proc/<pid>/stat: "pid (comm) state ppid ... starttime(22nd field) vsize rss(24th, in pages)".
42
+ // comm may contain spaces and parens.
43
+ function parseProcStat(text) {
44
+ const close = text.lastIndexOf(')')
45
+ const name = text.slice(text.indexOf('(') + 1, close)
46
+ const rest = text.slice(close + 2).split(' ')
47
+ return { name, ppid: Number(rest[1]), startTicks: Number(rest[19]), rssPages: Number(rest[21]) || 0 }
48
+ }
49
+
50
+ // Socket inode -> local port for sockets in LISTEN state (0A) from /proc/net/tcp and tcp6.
51
+ function parseNetTcp(text) {
52
+ const inodes = new Map()
53
+ for (const line of text.split('\n').slice(1)) {
54
+ const f = line.trim().split(/\s+/)
55
+ if (f.length > 9 && f[3] === '0A') inodes.set(f[9], parseInt(f[1].split(':').pop(), 16))
56
+ }
57
+ return inodes
58
+ }
59
+
60
+ function envValue(environ, key) {
61
+ for (const kv of environ.split('\0')) if (kv.startsWith(key + '=')) return kv.slice(key.length + 1)
62
+ return null
63
+ }
64
+
65
+ const realFs = {
66
+ read: p => fs.readFileSync(p, 'utf8'),
67
+ list: p => fs.readdirSync(p),
68
+ link: p => fs.readlinkSync(p),
69
+ }
70
+
71
+ function readLinux(io = realFs, clockTicks = 100, pageSize = 4096) {
72
+ const bootMs = Number(io.read('/proc/stat').match(/^btime (\d+)/m)[1]) * 1000
73
+ const tryRead = p => { try { return io.read(p) } catch { return null } }
74
+ const listenInodes = new Map()
75
+ for (const f of ['/proc/net/tcp', '/proc/net/tcp6']) {
76
+ const t = tryRead(f)
77
+ if (t) for (const [inode, port] of parseNetTcp(t)) listenInodes.set(inode, port)
78
+ }
79
+ const procs = []
80
+ const ports = []
81
+ for (const entry of io.list('/proc').filter(e => /^\d+$/.test(e))) {
82
+ const stat = tryRead(`/proc/${entry}/stat`)
83
+ if (!stat) continue
84
+ const pid = Number(entry)
85
+ const s = parseProcStat(stat)
86
+ const argv = (tryRead(`/proc/${entry}/cmdline`) || '').split('\0')
87
+ const cmd = argv.join(' ').trim()
88
+ // comm is the main thread's name, and Node 24 names its main thread "MainThread": a node
89
+ // process, Claude Code's included, would no longer read as node. argv[0] names the program.
90
+ if (s.name === 'MainThread' && argv[0]) s.name = path.posix.basename(argv[0])
91
+ const environ = tryRead(`/proc/${entry}/environ`)
92
+ const claudePid = environ && Number(envValue(environ, 'CLAUDE_PID'))
93
+ procs.push({
94
+ pid, ppid: s.ppid, name: s.name, cmd, start: bootMs + (s.startTicks / clockTicks) * 1000,
95
+ rss: s.rssPages * pageSize, claudePid: claudePid || null,
96
+ })
97
+ if (!listenInodes.size) continue
98
+ let fds = []
99
+ try { fds = io.list(`/proc/${entry}/fd`) } catch {}
100
+ for (const fd of fds) {
101
+ let target = ''
102
+ try { target = io.link(path.posix.join(`/proc/${entry}/fd`, fd)) } catch {}
103
+ const m = /^socket:\[(\d+)\]$/.exec(target)
104
+ if (m && listenInodes.has(m[1])) ports.push({ pid, port: listenInodes.get(m[1]) })
105
+ }
106
+ }
107
+ const unique = uniquePorts(ports)
108
+ return { procs, listening: new Set(unique.map(l => l.pid)), ports: unique }
109
+ }
110
+
111
+ // `ps -o pid=,ppid=,rss=,lstart=,args=`: lstart is always five words, e.g. "Mon Oct 13 09:12:01 2026".
112
+ function parsePs(text, names = new Map()) {
113
+ const procs = []
114
+ for (const line of text.split('\n')) {
115
+ const m = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\w+ +\w+ +\d+ +[\d:]+ +\d+)\s*(.*)$/.exec(line)
116
+ if (!m) continue
117
+ const pid = Number(m[1])
118
+ const cmd = m[5].trim()
119
+ procs.push({
120
+ pid, ppid: Number(m[2]), name: names.get(pid) || path.basename(cmd.split(' ')[0] || ''), cmd,
121
+ start: new Date(m[4].replace(/ +/g, ' ')).getTime() || 0, rss: Number(m[3]) * 1024, claudePid: null,
122
+ })
123
+ }
124
+ return procs
125
+ }
126
+
127
+ // `lsof -nP -iTCP -sTCP:LISTEN -Fn`: "p<pid>" starts a process, "n<addr>:<port>" names a socket.
128
+ function parseLsof(text) {
129
+ const ports = []
130
+ let pid = 0
131
+ for (const line of text.split('\n')) {
132
+ if (line[0] === 'p') pid = Number(line.slice(1))
133
+ else if (line[0] === 'n') {
134
+ const port = Number(line.slice(line.lastIndexOf(':') + 1))
135
+ if (pid && port) ports.push({ pid, port })
136
+ }
137
+ }
138
+ return uniquePorts(ports)
139
+ }
140
+
141
+ // `ps -E` appends each process's environment to its command line. macOS shows it only for the
142
+ // user's own processes, which are the only ones a Claude tool shell can start. A CLAUDE_PID that
143
+ // is not a whole word (no space or end after the digits) is ignored.
144
+ function parsePsEnv(text) {
145
+ const out = new Map()
146
+ for (const line of text.split('\n')) {
147
+ const m = /^\s*(\d+)\s.*\sCLAUDE_PID=(\d+)(?=\s|$)/.exec(line)
148
+ if (m) out.set(Number(m[1]), Number(m[2]))
149
+ }
150
+ return out
151
+ }
152
+
153
+ function readMac(run = execFileSync, { withEnv = false } = {}) {
154
+ const opts = { encoding: 'utf8', timeout: 15000, maxBuffer: 64 * 1024 * 1024, env: { ...process.env, LC_ALL: 'C' } }
155
+ const names = new Map()
156
+ for (const line of run('ps', ['-axo', 'pid=,ucomm='], opts).split('\n')) {
157
+ const m = /^\s*(\d+)\s+(.*)$/.exec(line)
158
+ if (m) names.set(Number(m[1]), m[2].trim())
159
+ }
160
+ const procs = parsePs(run('ps', ['-axww', '-o', 'pid=,ppid=,rss=,lstart=,args='], opts), names)
161
+ if (withEnv) {
162
+ let env = new Map()
163
+ try { env = parsePsEnv(run('ps', ['-axwwE', '-o', 'pid=,command='], opts)) } catch {}
164
+ for (const p of procs) p.claudePid = env.get(p.pid) || null
165
+ }
166
+ let ports = []
167
+ // lsof exits 1 when nothing is listening
168
+ try { ports = parseLsof(run('lsof', ['-nP', '-iTCP', '-sTCP:LISTEN', '-Fn'], opts)) } catch (e) { ports = parseLsof(String(e.stdout || '')) }
169
+ return { procs, listening: new Set(ports.map(l => l.pid)), ports }
170
+ }
171
+
172
+ function readProcesses(platform = process.platform, { withEnv = false } = {}) {
173
+ if (platform === 'win32') return readWindows()
174
+ if (platform === 'linux') return readLinux()
175
+ if (platform === 'darwin') return readMac(execFileSync, { withEnv })
176
+ return null
177
+ }
178
+
179
+ module.exports = {
180
+ readProcesses, parseWindows, readWindows, parseProcStat, parseNetTcp, readLinux, envValue, parsePs, parseLsof, readMac, parsePsEnv,
181
+ }
@@ -0,0 +1,56 @@
1
+ 'use strict'
2
+ const { useColor, paint, fit } = require('./cli-util')
3
+
4
+ const USAGE = `usage: bulkhead reap [--dry-run] [--json]
5
+
6
+ Stops what Claude Code sessions that have exited left running: dev servers and anything else
7
+ listening, test runners and their workers, watchers, builders and automation browsers started
8
+ from a Claude tool shell. orphan-reaper does the same in the background when a session starts
9
+ and right after one ends; this runs it now.
10
+ --dry-run list what would be stopped and why, stop nothing
11
+ --json machine-readable output`
12
+
13
+ function main(args, { stdout = process.stdout, stderr = process.stderr, platform = process.platform, run } = {}) {
14
+ if (args.includes('--help') || args.includes('-h')) return stdout.write(USAGE + '\n')
15
+ const unknown = args.filter(a => !['--dry-run', '--json'].includes(a))
16
+ if (unknown.length) {
17
+ stderr.write(`bulkhead reap: unknown option ${unknown[0]}\n${USAGE}\n`)
18
+ process.exitCode = 1
19
+ return
20
+ }
21
+ const reaper = require('./orphan-reaper')
22
+ const dryRun = args.includes('--dry-run')
23
+ const json = args.includes('--json')
24
+ if (!reaper.SUPPORTED.includes(platform)) {
25
+ if (json) stdout.write(JSON.stringify({ supported: false, platform, dryRun, orphans: [] }) + '\n')
26
+ else stderr.write(`bulkhead reap: ${platform} is not supported\n`)
27
+ process.exitCode = 1
28
+ return
29
+ }
30
+ let done
31
+ try {
32
+ done = (run || reaper.run)({ platform, dryRun, trigger: 'manual' })
33
+ } catch (e) {
34
+ stderr.write(`bulkhead reap: could not read the process table: ${String(e.message).split('\n')[0]}\n`)
35
+ process.exitCode = 1
36
+ return
37
+ }
38
+ if (json) {
39
+ const orphans = done.map(({ orphan: o, pids }) => ({ pid: o.pid, name: o.name, rule: o.rule, what: o.what, why: o.why, owner: o.owner, top: o.top, pids, command: o.cmd }))
40
+ stdout.write(JSON.stringify({ supported: true, dryRun, orphans }, null, 2) + '\n')
41
+ return
42
+ }
43
+ if (!done.length) return stdout.write('nothing to stop: no process left by an exited Claude session\n')
44
+ const color = useColor(stdout)
45
+ const width = Math.max(60, (stdout.columns || 120) - 4)
46
+ const lines = []
47
+ for (const { orphan: o, pids } of done) {
48
+ lines.push(`${paint(color, 'bold', dryRun ? 'would stop' : 'stopped')} ${o.pid} ${paint(color, 'yellow', o.rule)} ${fit(o.cmd || o.name, width - 30)}`)
49
+ lines.push(paint(color, 'dim', ` ${o.what}; ${o.why}; ${pids.length} process${pids.length === 1 ? '' : 'es'} from ${o.top}`))
50
+ }
51
+ const n = done.length
52
+ lines.push(dryRun ? `${n} tree${n === 1 ? '' : 's'} would be stopped. Run \`bulkhead reap\` to stop ${n === 1 ? 'it' : 'them'}.` : `${n} tree${n === 1 ? '' : 's'} stopped; see \`bulkhead log --guard orphan-reaper\`.`)
53
+ stdout.write(lines.join('\n') + '\n')
54
+ }
55
+
56
+ module.exports = { main, USAGE }