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
|
@@ -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 }
|
package/src/processes.js
ADDED
|
@@ -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
|
+
}
|
package/src/reap-cli.js
ADDED
|
@@ -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 }
|