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.
- package/CHANGELOG.md +51 -0
- package/README.es.md +142 -50
- package/README.ja.md +142 -50
- package/README.ko.md +143 -51
- package/README.md +142 -50
- package/README.pt-BR.md +142 -50
- package/README.zh-CN.md +154 -62
- package/hooks/hooks.json +68 -8
- package/package.json +5 -2
- package/src/agent-hook.js +162 -0
- package/src/agent-setup.js +256 -0
- package/src/agents.js +89 -0
- package/src/allow-cli.js +65 -0
- package/src/allow-hint.js +62 -0
- package/src/allow-once.js +152 -0
- package/src/auto-queue.js +93 -0
- package/src/bash-guards.js +36 -0
- package/src/basics.js +46 -27
- package/src/checkout-guard.js +342 -74
- package/src/cli-util.js +13 -1
- package/src/cli.js +40 -4
- package/src/config-check.js +21 -4
- package/src/config-cli.js +75 -10
- package/src/config.js +106 -9
- package/src/dev-servers.js +361 -61
- package/src/disk-guard.js +8 -8
- package/src/doctor-enforcement.js +273 -0
- package/src/doctor.js +19 -9
- package/src/edit-guard.js +196 -0
- package/src/events.js +30 -5
- package/src/explain.js +251 -0
- package/src/git-info.js +55 -0
- package/src/gitargs.js +82 -0
- package/src/hook.js +38 -6
- package/src/kill-extras.js +219 -0
- package/src/kill-guard.js +228 -77
- package/src/kill-owners.js +116 -0
- package/src/log-cli.js +15 -10
- package/src/mcp.js +131 -0
- package/src/memory-guard.js +126 -24
- package/src/memory.js +21 -2
- package/src/notices.js +95 -0
- package/src/orphan-reaper.js +58 -11
- package/src/port-cli.js +78 -3
- package/src/port-guard.js +80 -68
- package/src/port-kills.js +198 -0
- package/src/pressure.js +75 -0
- package/src/processes.js +44 -12
- package/src/scan-report.js +166 -0
- package/src/scan.js +87 -18
- package/src/secrets-guard.js +301 -0
- package/src/self-allow.js +73 -0
- package/src/session-info.js +121 -0
- package/src/session-state.js +176 -0
- package/src/sessions.js +109 -20
- package/src/shell.js +279 -19
- package/src/slot-cli.js +92 -21
- package/src/slots.js +61 -13
- package/src/stable-port.js +61 -0
- package/src/status.js +55 -17
- package/src/statusline.js +45 -16
- package/src/subagent-guard.js +24 -0
- package/src/top.js +36 -11
- package/src/wait-guard.js +96 -32
- package/src/worktree-cli.js +454 -0
- package/src/worktree-copy.js +82 -0
package/src/mcp.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const path = require('path')
|
|
3
|
+
|
|
4
|
+
// Stdio MCP servers are child processes of a session, one copy per session. Several sessions
|
|
5
|
+
// with the same server are N identical copies, each with its own memory. This finds them from
|
|
6
|
+
// command lines alone (heuristic, see docs/commands.md):
|
|
7
|
+
// - the program is a launcher (npx, npm exec, pnpm dlx, yarn dlx, bunx, uvx, uv run, pipx, node,
|
|
8
|
+
// bun, deno, python) or the program's own name looks like an MCP server;
|
|
9
|
+
// - and one argument names an MCP package or path: @modelcontextprotocol/..., a package ending in
|
|
10
|
+
// "mcp" or containing "mcp-" / "-mcp" / "mcp-server", or a path whose last two segments do.
|
|
11
|
+
// Options (--mcp-config and the like) never count.
|
|
12
|
+
|
|
13
|
+
const LAUNCHERS = /^(?:npx|npm|pnpm|pnpx|yarn|bunx|bun|uvx|uv|pipx|node|deno|python|python3|python3\.\d+|py|tsx|ts-node|docker)(?:\.exe|\.cmd)?$/i
|
|
14
|
+
const MCP_WORD = /(?:^|[\\/@_.-])mcp(?:$|[\\/@_.-])|mcp-?server|@modelcontextprotocol[\\/]/i
|
|
15
|
+
|
|
16
|
+
function tokens(cmd) {
|
|
17
|
+
return String(cmd || '').match(/"[^"]*"|'[^']*'|\S+/g)?.map(t => t.replace(/^["']|["']$/g, '')) || []
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function programOf(tok) {
|
|
21
|
+
return path.basename(String(tok || '').replace(/\\/g, '/'))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// A path or package name reads as MCP: the last two segments only, so a project folder that
|
|
25
|
+
// happens to be called mcp-something does not make every command under it an MCP server.
|
|
26
|
+
function mentionsMcp(arg) {
|
|
27
|
+
if (!arg || arg.startsWith('-')) return false
|
|
28
|
+
const parts = arg.replace(/\\/g, '/').split('/').filter(Boolean)
|
|
29
|
+
const tail = arg.startsWith('@') && parts.length === 2 ? parts.join('/') : parts.slice(-2).join('/')
|
|
30
|
+
if (MCP_WORD.test(tail)) return true
|
|
31
|
+
// a script deep inside an installed package: the package name decides
|
|
32
|
+
const pkg = /node_modules\/((?:@[\w.-]+\/)?[\w.-]+)/.exec(parts.join('/') + '/')
|
|
33
|
+
return Boolean(pkg && MCP_WORD.test(pkg[1]))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isMcpCommand(cmd) {
|
|
37
|
+
const t = tokens(cmd)
|
|
38
|
+
if (!t.length) return false
|
|
39
|
+
const prog = programOf(t[0])
|
|
40
|
+
if (LAUNCHERS.test(prog)) return t.slice(1).some(mentionsMcp)
|
|
41
|
+
return /(?:^|[-_.])mcp(?:$|[-_.])|mcp-?server/i.test(prog) && !/^(?:claude|node|sh|bash|zsh)(?:\.exe)?$/i.test(prog)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Package name for display: @scope/pkg without its version, else the program or script.
|
|
45
|
+
function serverName(cmd) {
|
|
46
|
+
const t = tokens(cmd).slice(1)
|
|
47
|
+
const arg = t.find(mentionsMcp)
|
|
48
|
+
if (!arg) return programOf(tokens(cmd)[0])
|
|
49
|
+
const scoped = /^(@[\w.-]+\/[\w.-]+?)(?:@[^/\s]*)?$/.exec(arg)
|
|
50
|
+
if (scoped) return scoped[1]
|
|
51
|
+
const bare = /^([\w.-]+?)@(?:latest|next|\d[^/\s]*)$/.exec(arg)
|
|
52
|
+
if (bare) return bare[1]
|
|
53
|
+
const parts = arg.replace(/\\/g, '/').split('/').filter(Boolean)
|
|
54
|
+
const m = /node_modules\/((?:@[\w.-]+\/)?[\w.-]+)/.exec(arg.replace(/\\/g, '/'))
|
|
55
|
+
return m ? m[1] : parts.slice(-2).join('/')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The command line with what differs between sessions removed, so two sessions' copies compare
|
|
59
|
+
// equal: the session's folder, package versions, numbers (ports, pids), ids and temp paths.
|
|
60
|
+
function signature(cmd, cwd) {
|
|
61
|
+
let s = String(cmd || '')
|
|
62
|
+
if (cwd) s = s.split(cwd).join('<cwd>')
|
|
63
|
+
return tokens(s).map((tok, i) => {
|
|
64
|
+
if (i === 0) return programOf(tok).replace(/\.(?:exe|cmd)$/i, '')
|
|
65
|
+
if (/^-{0,2}\d+$/.test(tok) || /^[0-9a-f]{8}-[0-9a-f-]{27,}$/i.test(tok) || /^[0-9a-f]{12,}$/i.test(tok)) return '#'
|
|
66
|
+
if (/^(?:\/private)?\/(?:tmp|var\/folders)\//.test(tok) || /[\\/]Temp[\\/]/i.test(tok)) return '<tmp>'
|
|
67
|
+
const eq = /^(--?[\w-]+)=(.*)$/.exec(tok)
|
|
68
|
+
if (eq && /^\d+$/.test(eq[2])) return `${eq[1]}=#`
|
|
69
|
+
return tok.replace(/(@[\w.-]+\/[\w.-]+|[\w.-]*mcp[\w.-]*)@(?:latest|next|\d[\w.+-]*)$/i, '$1')
|
|
70
|
+
}).join(' ')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// owner: Map pid -> session pid (assignOwners). Returns
|
|
74
|
+
// { instances: [{ session, pid, name, signature, rss, procs }], groups: [{ name, copies, sessions, rss, duplicate }] }
|
|
75
|
+
// An instance is the whole tree of one server (npx, its shell and the node process count once).
|
|
76
|
+
function findMcp(procs, owner, sessions) {
|
|
77
|
+
const byPid = new Map(procs.map(p => [p.pid, p]))
|
|
78
|
+
const kids = new Map()
|
|
79
|
+
for (const p of procs) {
|
|
80
|
+
if (!kids.has(p.ppid)) kids.set(p.ppid, [])
|
|
81
|
+
kids.get(p.ppid).push(p)
|
|
82
|
+
}
|
|
83
|
+
const roots = new Set(sessions.map(s => s.pid))
|
|
84
|
+
const candidate = p => owner.get(p.pid) !== undefined && !roots.has(p.pid) && isMcpCommand(p.cmd)
|
|
85
|
+
const cwdOf = new Map(sessions.map(s => [s.pid, s.cwd]))
|
|
86
|
+
// a server started through npx is a chain (npm exec, sh, node): only its top counts as the instance
|
|
87
|
+
const hasCandidateAncestor = p => {
|
|
88
|
+
let cur = byPid.get(p.ppid)
|
|
89
|
+
for (let i = 0; cur && i < 20 && owner.get(cur.pid) === owner.get(p.pid) && !roots.has(cur.pid); i++) {
|
|
90
|
+
if (candidate(cur)) return true
|
|
91
|
+
cur = byPid.get(cur.ppid)
|
|
92
|
+
}
|
|
93
|
+
return false
|
|
94
|
+
}
|
|
95
|
+
const instances = []
|
|
96
|
+
for (const p of procs) {
|
|
97
|
+
if (!candidate(p)) continue
|
|
98
|
+
if (hasCandidateAncestor(p)) continue
|
|
99
|
+
const stack = [p]
|
|
100
|
+
const seen = new Set()
|
|
101
|
+
let rss = 0
|
|
102
|
+
while (stack.length) {
|
|
103
|
+
const q = stack.pop()
|
|
104
|
+
if (seen.has(q.pid)) continue
|
|
105
|
+
seen.add(q.pid)
|
|
106
|
+
rss += q.rss || 0
|
|
107
|
+
for (const k of kids.get(q.pid) || []) if (owner.get(k.pid) === owner.get(p.pid)) stack.push(k)
|
|
108
|
+
}
|
|
109
|
+
const session = owner.get(p.pid)
|
|
110
|
+
instances.push({ session, pid: p.pid, name: serverName(p.cmd), signature: signature(p.cmd, cwdOf.get(session)), rss, procs: seen.size })
|
|
111
|
+
}
|
|
112
|
+
const bySig = new Map()
|
|
113
|
+
for (const i of instances) {
|
|
114
|
+
if (!bySig.has(i.signature)) bySig.set(i.signature, [])
|
|
115
|
+
bySig.get(i.signature).push(i)
|
|
116
|
+
}
|
|
117
|
+
const groups = [...bySig.values()].map(list => {
|
|
118
|
+
const sessionPids = [...new Set(list.map(i => i.session))].sort((a, b) => a - b)
|
|
119
|
+
return {
|
|
120
|
+
name: list[0].name,
|
|
121
|
+
signature: list[0].signature,
|
|
122
|
+
copies: list.length,
|
|
123
|
+
sessions: sessionPids,
|
|
124
|
+
rss: list.reduce((n, i) => n + i.rss, 0),
|
|
125
|
+
duplicate: list.length >= 2 && sessionPids.length >= 2,
|
|
126
|
+
}
|
|
127
|
+
}).sort((a, b) => b.rss - a.rss)
|
|
128
|
+
return { instances, groups }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
module.exports = { isMcpCommand, serverName, signature, findMcp, mentionsMcp }
|
package/src/memory-guard.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
'use strict'
|
|
2
|
-
const { runHook, blocked } = require('./hook')
|
|
2
|
+
const { runHook, blocked, setting } = require('./hook')
|
|
3
3
|
const { readMemory } = require('./memory')
|
|
4
4
|
const { limits } = require('./slots')
|
|
5
5
|
const shell = require('./shell')
|
|
6
|
+
const queue = require('./auto-queue')
|
|
6
7
|
|
|
7
8
|
// Commands that commonly take several GB of RAM: test runners with workers, full builds,
|
|
8
9
|
// bundlers, emulators and browsers. Each test gets the command's words with wrappers taken off.
|
|
@@ -32,13 +33,16 @@ const HEAVY_BINS = {
|
|
|
32
33
|
webpack: argv => !quiet(argv) && sub(argv) !== 'serve',
|
|
33
34
|
ffmpeg: argv => !quiet(argv) && argv.length > 1,
|
|
34
35
|
cargo: argv => /^(?:build|b|test|t|bench)$/.test(sub(argv)),
|
|
35
|
-
go: argv => sub(argv)
|
|
36
|
+
go: argv => /^(?:test|build)$/.test(sub(argv)) && has(argv, /\/\.\.\.$|^\.\.\.$|^-race$/),
|
|
37
|
+
xcodebuild: argv => has(argv, /^(?:-project|-workspace|-scheme|build|test|archive|build-for-testing|test-without-building)$/),
|
|
38
|
+
swift: argv => /^(?:build|test)$/.test(sub(argv)),
|
|
39
|
+
fastlane: argv => !quiet(argv) && !/^(?:lanes|env|help|actions|action|new_action|init)$/.test(sub(argv)) && argv.length > 1,
|
|
36
40
|
pytest: argv => has(argv, /^(?:-n\d*|--numprocesses(?:=.*)?)$/),
|
|
37
41
|
docker: argv => /^(?:build|buildx)$/.test(sub(argv)) || (sub(argv) === 'compose' && has(argv, /^(?:up|build)$/)),
|
|
38
42
|
'docker-compose': argv => has(argv, /^(?:up|build)$/),
|
|
39
43
|
bazel: argv => /^(?:build|test|run)$/.test(sub(argv)),
|
|
40
44
|
turbo: argv => has(argv, /^(?:build|test)$/),
|
|
41
|
-
nx: argv => /^(?:build|test|run-many|affected)$/.test(sub(argv)),
|
|
45
|
+
nx: argv => /^(?:build|test|e2e|run|run-many|affected)$/.test(sub(argv)),
|
|
42
46
|
}
|
|
43
47
|
const RUNNERS = /^(?:npm|pnpm|yarn|bun)$/
|
|
44
48
|
// npx vitest, pnpm exec jest, yarn dlx, bunx, python -m pytest, node node_modules/.bin/jest
|
|
@@ -49,6 +53,7 @@ function realCommand(argv) {
|
|
|
49
53
|
const at = argv.findIndex((a, k) => k > 0 && !a.startsWith('-'))
|
|
50
54
|
argv = at > 0 ? argv.slice(at) : []
|
|
51
55
|
} else if (RUNNERS.test(b) && /^(?:exec|dlx|x)$/.test(argv[1])) argv = argv.slice(2)
|
|
56
|
+
else if (b === 'bundle' && argv[1] === 'exec') argv = argv.slice(2)
|
|
52
57
|
else if (/^python[\d.]*$|^py$/.test(b) && argv[1] === '-m' && argv[2]) argv = argv.slice(2)
|
|
53
58
|
else if (b === 'node' && argv[1] && /node_modules[\\/]/.test(argv[1])) argv = argv.slice(1)
|
|
54
59
|
else break
|
|
@@ -78,18 +83,41 @@ const LAUNCH = /\b(?:chromium|firefox|webkit|puppeteer)\.launch\b|\blaunchPersis
|
|
|
78
83
|
// Commands that only read or search text: grep -rn vitest package.json
|
|
79
84
|
const INERT = /^(?:git|grep|egrep|rg|ag|echo|printf|cat|less|head|tail|ls|find|which|where|type|select-string|get-content|gc|write-host|write-output)$/i
|
|
80
85
|
|
|
81
|
-
|
|
86
|
+
// Heavy commands that stay up until stopped: a slot would be held for as long as they run.
|
|
87
|
+
const LONG_RUNNING = {
|
|
88
|
+
next: argv => sub(argv) === 'dev',
|
|
89
|
+
emulator: () => true,
|
|
90
|
+
karma: () => true,
|
|
91
|
+
docker: argv => !has(argv, /^(?:-d|--detach)$/),
|
|
92
|
+
'docker-compose': argv => !has(argv, /^(?:-d|--detach)$/),
|
|
93
|
+
}
|
|
94
|
+
const isLong = argv => {
|
|
95
|
+
const real = realCommand(argv)
|
|
96
|
+
const f = real.length && LONG_RUNNING[shell.bin(real[0])]
|
|
97
|
+
return Boolean(f && f(real) && (shell.bin(real[0]) !== 'docker' || sub(real) === 'compose'))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// The first heavy command in the line: { name, argv, long }, plus remote: true when any part of
|
|
101
|
+
// the line runs on another machine. null when nothing is heavy. name keys the GB estimate.
|
|
102
|
+
function findHeavy(cmd, mode = 'sh') {
|
|
82
103
|
const text = shell.prepare(String(cmd || ''), mode)
|
|
104
|
+
let found = null
|
|
105
|
+
let remote = false
|
|
83
106
|
for (const c of shell.commands(text, mode)) {
|
|
84
107
|
const b = shell.bin(c.argv[0])
|
|
85
|
-
if (
|
|
108
|
+
if (c.remote) remote = true
|
|
109
|
+
if (found || INERT.test(b) || c.remote) continue
|
|
86
110
|
if (/^bulkhead(?:-slot)?$/.test(b)) continue
|
|
87
|
-
if (heavyArgv(c.argv))
|
|
88
|
-
|
|
111
|
+
if (heavyArgv(c.argv)) {
|
|
112
|
+
const real = realCommand(c.argv)
|
|
113
|
+
found = { name: shell.bin(real[0]), argv: real, long: isLong(c.argv) }
|
|
114
|
+
} else if (LAUNCH.test(c.argv.join(' '))) found = { name: 'browser', argv: ['browser'], long: false }
|
|
89
115
|
}
|
|
90
|
-
return
|
|
116
|
+
return found && { ...found, remote }
|
|
91
117
|
}
|
|
92
118
|
|
|
119
|
+
const isHeavy = (cmd, mode = 'sh') => Boolean(findHeavy(cmd, mode))
|
|
120
|
+
|
|
93
121
|
const SLOT_FILE = /bulkhead[\\/]+slots|slot-\d+\.json/i
|
|
94
122
|
const WRITES = /(?:>{1,2}\|?|\b(?:touch|rm|del|unlink|mv|cp|Remove-Item|Set-Content|Add-Content|Out-File|New-Item|Clear-Content|Move-Item|Copy-Item|writeFileSync|unlinkSync|rmSync|utimesSync)\b)/i
|
|
95
123
|
|
|
@@ -114,32 +142,106 @@ function hasRemote() {
|
|
|
114
142
|
}
|
|
115
143
|
}
|
|
116
144
|
|
|
117
|
-
|
|
145
|
+
// Claude sessions that are running now, this one included; null when the session files cannot be read.
|
|
146
|
+
function liveSessions() {
|
|
147
|
+
try {
|
|
148
|
+
const { readSessionFiles } = require('./sessions')
|
|
149
|
+
const { staleReason } = require('./slots')
|
|
150
|
+
return [...readSessionFiles().keys()].filter(pid => !staleReason({ pid, at: Date.now() })).length
|
|
151
|
+
} catch {
|
|
152
|
+
return null
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Where the minimum comes from, in a few words.
|
|
157
|
+
function minimumSource(minFreeGb, totalGb) {
|
|
158
|
+
if (setting('memory.minFreeGb', null) != null) return 'set in your config'
|
|
159
|
+
if (minFreeGb >= 3) return 'the 3 GB default'
|
|
160
|
+
return `15% of this machine's ${Math.round(totalGb)} GB`
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function slotLine(cmd, mode, overrides) {
|
|
164
|
+
const found = findHeavy(cmd, mode)
|
|
165
|
+
const gb = queue.estimateGb(found ? found.name : '', overrides)
|
|
166
|
+
const text = String(cmd).trim()
|
|
167
|
+
const arg = text.length <= 160 && !/[\r\n]/.test(text) && mode === 'sh' ? queue.quoters.sh(text) : '<command>'
|
|
168
|
+
return `bulkhead-slot run --gb=${gb} -- ${arg}`
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function check(cmd, memory = () => readMemory(), minFreeGb, mode = 'sh', remote = hasRemote, sessions = liveSessions) {
|
|
118
172
|
if (!cmd) return null
|
|
119
173
|
if (touchesSlotFiles(cmd)) {
|
|
120
174
|
return '[bulkhead] memory-guard: slot files are managed by bulkhead-slot. Do not write, touch or delete them by hand; ' +
|
|
121
175
|
'use `bulkhead-slot run --gb=N -- <command>`, which releases its slot when the command exits. `bulkhead-slot status` shows the holders.'
|
|
122
176
|
}
|
|
123
177
|
if (queued(cmd) || !isHeavy(cmd, mode)) return null
|
|
124
|
-
const { freeGb, totalGb } = memory()
|
|
178
|
+
const { freeGb, totalGb, pressure } = memory()
|
|
125
179
|
if (minFreeGb == null) minFreeGb = limits(totalGb).minFreeGb
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
'
|
|
180
|
+
// macOS says outright how stressed memory is; a low estimate with normal pressure is not a reason to block
|
|
181
|
+
if (freeGb >= minFreeGb && !(pressure >= 2)) return null
|
|
182
|
+
const n = sessions()
|
|
183
|
+
const who = n > 1 ? `${n} Claude sessions are running and may be building or testing too.`
|
|
184
|
+
: n === 1 ? 'Only this session is running, so other programs are using the RAM.' : ''
|
|
185
|
+
const why = pressure >= 2 ? `memory pressure is ${pressure >= 4 ? 'critical' : 'warning'}`
|
|
186
|
+
: `only ${freeGb.toFixed(1)} GB of RAM is free (minimum ${minFreeGb} GB: ${minimumSource(minFreeGb, totalGb)})`
|
|
187
|
+
return `[bulkhead] memory-guard: ${why} and this looks like a heavy command.\n` +
|
|
188
|
+
(who ? who + '\n' : '') +
|
|
189
|
+
'Starting more work at this point is how machines freeze. Wait a minute, use fewer workers (for example --maxWorkers=2), or queue it:\n' +
|
|
190
|
+
` ${slotLine(cmd, mode, setting('memoryGuard.estimatesGb', null))}\n` +
|
|
131
191
|
'bulkhead-slot waits until there is room, runs the command, and frees the slot when it exits.' +
|
|
132
192
|
(remote() ? '\nOr run it on another machine, so it uses that machine\'s RAM:\n bulkhead remote run -- <command>' : '')
|
|
133
193
|
}
|
|
134
194
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
195
|
+
// { rule, message } with the rule name the hook logs, or null.
|
|
196
|
+
function decide(cmd, memory, minFreeGb, mode, remote) {
|
|
197
|
+
const message = check(cmd, memory, minFreeGb, mode, remote)
|
|
198
|
+
return message ? { rule: touchesSlotFiles(cmd) ? 'slot-files' : 'low-memory', message } : null
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// With memoryGuard.autoQueue on, a heavy command is not blocked: the hook rewrites it to run through
|
|
202
|
+
// bulkhead-slot, whatever the free RAM is, so every session waits in the same line. Returns
|
|
203
|
+
// { command, gb, label } or null when the command stays as it is (not heavy, already queued, in
|
|
204
|
+
// the background, on another machine, long-running, or the slot script cannot be found).
|
|
205
|
+
function autoQueue(input, o = {}) {
|
|
206
|
+
const ti = (input && input.tool_input) || {}
|
|
207
|
+
const cmd = ti.command
|
|
208
|
+
if (typeof cmd !== 'string' || !cmd.trim() || ti.run_in_background === true) return null
|
|
209
|
+
const mode = shell.modeFor(input)
|
|
210
|
+
if (queue.wrapped(cmd) || touchesSlotFiles(cmd) || queue.backgrounded(cmd, mode)) return null
|
|
211
|
+
const found = findHeavy(cmd, mode)
|
|
212
|
+
if (!found || found.long || found.remote) return null
|
|
213
|
+
const script = o.script !== undefined ? o.script : queue.slotScript()
|
|
214
|
+
if (!script) return null
|
|
215
|
+
const gb = queue.estimateGb(found.name, o.estimates)
|
|
216
|
+
const label = queue.labelFor(found.argv)
|
|
217
|
+
const command = queue.wrap(cmd, { shell: mode, script, gb, label, wait: o.wait, node: o.node, env: o.env, exists: o.exists, platform: o.platform })
|
|
218
|
+
return command ? { command, gb, label } : null
|
|
143
219
|
}
|
|
144
220
|
|
|
145
|
-
|
|
221
|
+
function queueOutput(input, q) {
|
|
222
|
+
return {
|
|
223
|
+
hookSpecificOutput: {
|
|
224
|
+
hookEventName: 'PreToolUse',
|
|
225
|
+
updatedInput: { ...input.tool_input, command: q.command },
|
|
226
|
+
additionalContext: `bulkhead queued this command (${q.label}, about ${q.gb} GB): it starts when the machine has room and prints its position every 30 s.`,
|
|
227
|
+
},
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function handle(input) {
|
|
232
|
+
const cmd = input.tool_input && input.tool_input.command
|
|
233
|
+
if (typeof cmd !== 'string') return
|
|
234
|
+
// other agents' hooks cannot rewrite a command (bulkhead hook --agent), so they get the plain check
|
|
235
|
+
if (setting('memoryGuard.autoQueue', false) === true && !process.env.BULKHEAD_AGENT && !touchesSlotFiles(cmd)) {
|
|
236
|
+
const q = autoQueue(input, { estimates: setting('memoryGuard.estimatesGb', null), wait: setting('memoryGuard.waitMinutes', 10) })
|
|
237
|
+
// no permissionDecision: the user's permission settings still apply to the rewritten command
|
|
238
|
+
if (q) return { output: queueOutput(input, q) }
|
|
239
|
+
}
|
|
240
|
+
const hit = decide(cmd, undefined, undefined, shell.modeFor(input))
|
|
241
|
+
if (!hit) return
|
|
242
|
+
return blocked('memory-guard', hit.rule, input, hit.message)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (require.main === module) runHook('memory-guard', handle)
|
|
246
|
+
|
|
247
|
+
module.exports = { handle, check, decide, isHeavy, findHeavy, touchesSlotFiles, autoQueue, queueOutput }
|
package/src/memory.js
CHANGED
|
@@ -41,6 +41,17 @@ function parseVmStat(text, totalBytes) {
|
|
|
41
41
|
return { totalGb: totalBytes / GB, freeGb: Math.max(0, freeBytes) / GB, commitPct: null }
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
// macOS: kern.memorystatus_level is the percentage of memory the system counts as free,
|
|
45
|
+
// reclaimable file cache and compressed pages included (what `memory_pressure` prints as
|
|
46
|
+
// "System-wide memory free percentage"). The vm_stat estimate above misses the reclaimable
|
|
47
|
+
// cache and read 7.7 GB free on a 48 GB Mac that `memory_pressure` called 62% free.
|
|
48
|
+
// pressure is kern.memorystatus_vm_pressure_level: 1 normal, 2 warning, 4 critical.
|
|
49
|
+
function parseDarwinLevel(text, totalBytes) {
|
|
50
|
+
const [level, pressure] = String(text).trim().split(/\s+/).map(Number)
|
|
51
|
+
if (!(level >= 0 && level <= 100)) return null
|
|
52
|
+
return { totalGb: totalBytes / GB, freeGb: (totalBytes / GB) * (level / 100), commitPct: null, pressure: [1, 2, 4].includes(pressure) ? pressure : null }
|
|
53
|
+
}
|
|
54
|
+
|
|
44
55
|
// Windows: commit charge can run out while RAM still looks "free", and the machine stalls when it does.
|
|
45
56
|
function parseWindowsCommit(text) {
|
|
46
57
|
const [total, free] = String(text).trim().split(/\s+/).map(Number)
|
|
@@ -57,7 +68,8 @@ function windowsCommitPct(run) {
|
|
|
57
68
|
}
|
|
58
69
|
}
|
|
59
70
|
|
|
60
|
-
|
|
71
|
+
// vm_stat and sysctl answer in milliseconds; only PowerShell is slow, so only it gets the long wait.
|
|
72
|
+
const defaultRun = (file, args) => execFileSync(file, args, { encoding: 'utf8', timeout: /powershell/i.test(file) ? 8000 : 1500, windowsHide: true })
|
|
61
73
|
|
|
62
74
|
// { totalGb, freeGb, commitPct } where commitPct is measured only on Windows and only on request.
|
|
63
75
|
function readMemory({ platform = process.platform, withCommit = false, run = defaultRun, readFile = fs.readFileSync } = {}) {
|
|
@@ -66,11 +78,18 @@ function readMemory({ platform = process.platform, withCommit = false, run = def
|
|
|
66
78
|
try { return parseMeminfo(readFile('/proc/meminfo', 'utf8')) } catch {}
|
|
67
79
|
}
|
|
68
80
|
if (platform === 'darwin') {
|
|
81
|
+
try {
|
|
82
|
+
const m = parseDarwinLevel(run('sysctl', ['-n', 'kern.memorystatus_level', 'kern.memorystatus_vm_pressure_level']), os.totalmem())
|
|
83
|
+
if (m) return m
|
|
84
|
+
} catch {}
|
|
69
85
|
try { return parseVmStat(run('vm_stat', []), os.totalmem()) } catch {}
|
|
86
|
+
// os.freemem() counts only never-used pages, near zero on a healthy Mac: with no usable
|
|
87
|
+
// reading, say everything is free so a guard allows the command instead of blocking it.
|
|
88
|
+
return { totalGb: os.totalmem() / GB, freeGb: os.totalmem() / GB, commitPct: null, pressure: null, unknown: true }
|
|
70
89
|
}
|
|
71
90
|
const mem = { totalGb: os.totalmem() / GB, freeGb: os.freemem() / GB, commitPct: null }
|
|
72
91
|
if (platform === 'win32' && withCommit) mem.commitPct = windowsCommitPct(run)
|
|
73
92
|
return mem
|
|
74
93
|
}
|
|
75
94
|
|
|
76
|
-
module.exports = { readMemory, parseMeminfo, parseVmStat, parseWindowsCommit }
|
|
95
|
+
module.exports = { readMemory, parseMeminfo, parseVmStat, parseDarwinLevel, parseWindowsCommit }
|
package/src/notices.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// What the user sees at SessionStart: what orphan-reaper stopped since the last notice, and one
|
|
3
|
+
// line the first time bulkhead runs. Both live as small files in the state dir. Every function
|
|
4
|
+
// fails open and returns nothing on any error.
|
|
5
|
+
const fs = require('fs')
|
|
6
|
+
const path = require('path')
|
|
7
|
+
const { shortPath } = require('./cli-util')
|
|
8
|
+
|
|
9
|
+
const PENDING = 'reaped-pending.jsonl'
|
|
10
|
+
const FIRST_RUN = 'first-run'
|
|
11
|
+
const MAX_SHOWN = 3
|
|
12
|
+
const MAX_PENDING_BYTES = 64 * 1024
|
|
13
|
+
|
|
14
|
+
const FIRST_RUN_TEXT = 'bulkhead is on: it blocks commands that would hit other Claude Code sessions. See what it did: bulkhead log. Check your setup: bulkhead doctor.'
|
|
15
|
+
|
|
16
|
+
// "vite", "node srv.js", "python3 app.py": the program and the script it runs, not the whole line.
|
|
17
|
+
function label(cmd, name) {
|
|
18
|
+
const words = String(cmd || name || '').trim().match(/"[^"]*"|'[^']*'|\S+/g) || []
|
|
19
|
+
const base = w => String(w).replace(/^["']|["']$/g, '').split(/[\\/]/).pop().replace(/\.exe$/i, '')
|
|
20
|
+
const prog = base(words[0]) || base(name) || 'process'
|
|
21
|
+
const arg = words.slice(1).find(w => !w.startsWith('-'))
|
|
22
|
+
if (!arg) return prog
|
|
23
|
+
const bin = /node_modules[\\/](?:\.bin|[^\\/]+[\\/]bin)[\\/]/.test(arg) ? base(arg).replace(/\.m?js$/, '') : null
|
|
24
|
+
if (bin) return bin
|
|
25
|
+
return `${prog} ${base(arg)}`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// done: the { orphan, pids } list run() stops; ports: [{ port, pid }] from the same process table.
|
|
29
|
+
function items(done, ports = [], projectOf = () => null) {
|
|
30
|
+
return done.map(({ orphan: o, pids }) => {
|
|
31
|
+
const hit = ports.find(l => pids.includes(l.pid))
|
|
32
|
+
return { label: label(o.cmd, o.name), port: hit ? hit.port : null, project: projectOf(o) }
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function append(dir, list, now = Date.now()) {
|
|
37
|
+
if (!list.length) return false
|
|
38
|
+
try {
|
|
39
|
+
const file = path.join(dir, PENDING)
|
|
40
|
+
let size = 0
|
|
41
|
+
try { size = fs.statSync(file).size } catch {}
|
|
42
|
+
if (size > MAX_PENDING_BYTES) return false
|
|
43
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
44
|
+
fs.appendFileSync(file, list.map(i => JSON.stringify({ time: now, ...i })).join('\n') + '\n')
|
|
45
|
+
return true
|
|
46
|
+
} catch {
|
|
47
|
+
return false
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Takes the pending list: renaming first means two sessions starting together never both print it.
|
|
52
|
+
function claim(dir) {
|
|
53
|
+
const file = path.join(dir, PENDING)
|
|
54
|
+
const mine = `${file}.${process.pid}`
|
|
55
|
+
try {
|
|
56
|
+
fs.renameSync(file, mine)
|
|
57
|
+
} catch {
|
|
58
|
+
return []
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
return fs.readFileSync(mine, 'utf8').split('\n').flatMap(l => {
|
|
62
|
+
try { return [JSON.parse(l)] } catch { return [] }
|
|
63
|
+
})
|
|
64
|
+
} catch {
|
|
65
|
+
return []
|
|
66
|
+
} finally {
|
|
67
|
+
try { fs.unlinkSync(mine) } catch {}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function format(list, home) {
|
|
72
|
+
if (!list.length) return null
|
|
73
|
+
const shown = list.slice(0, MAX_SHOWN).map(i => {
|
|
74
|
+
const where = i.project ? ` (${shortPath(i.project, home)})` : ''
|
|
75
|
+
return `${i.label}${i.port ? ` :${i.port}` : ''}${where}`
|
|
76
|
+
})
|
|
77
|
+
const more = list.length > MAX_SHOWN ? `, and ${list.length - MAX_SHOWN} more` : ''
|
|
78
|
+
const n = list.length
|
|
79
|
+
return `bulkhead stopped ${n} leftover${n === 1 ? '' : 's'} from ended sessions: ${shown.join(', ')}${more}. Keep one next time with BULKHEAD_KEEP=1. Details: bulkhead log --guard orphan-reaper`
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// The first-run line once, and only once: the marker is created before anything is printed. A state
|
|
83
|
+
// dir that already has a log belongs to an install from before this notice, and gets no line.
|
|
84
|
+
function firstRun(dir) {
|
|
85
|
+
try {
|
|
86
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
87
|
+
const used = ['guards.log', 'events.jsonl'].some(n => fs.existsSync(path.join(dir, n)))
|
|
88
|
+
fs.writeFileSync(path.join(dir, FIRST_RUN), `${new Date().toISOString()}\n`, { flag: 'wx' })
|
|
89
|
+
return used ? null : FIRST_RUN_TEXT
|
|
90
|
+
} catch {
|
|
91
|
+
return null
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = { label, items, append, claim, format, firstRun, FIRST_RUN_TEXT, PENDING, FIRST_RUN }
|
package/src/orphan-reaper.js
CHANGED
|
@@ -3,7 +3,8 @@ const fs = require('fs')
|
|
|
3
3
|
const os = require('os')
|
|
4
4
|
const path = require('path')
|
|
5
5
|
const { execFileSync, spawn } = require('child_process')
|
|
6
|
-
const { enabled, setting, log, oneLine, recordEvent } = require('./hook')
|
|
6
|
+
const { enabled, setting, log, oneLine, recordEvent, stateDir } = require('./hook')
|
|
7
|
+
const notices = require('./notices')
|
|
7
8
|
const { readSessionFiles, claudeDir } = require('./sessions')
|
|
8
9
|
const { readProcesses } = require('./processes')
|
|
9
10
|
|
|
@@ -64,6 +65,32 @@ const RULES = [
|
|
|
64
65
|
|
|
65
66
|
// Never stopped, nor anything they started, even under an orphaned shell: programs meant to
|
|
66
67
|
// outlive the session, and daemons that other sessions share.
|
|
68
|
+
const KEEP_TOKEN = /(?:^|[\s"'])BULKHEAD_KEEP=(?:1|true|yes)(?=["'\s]|$)/i
|
|
69
|
+
|
|
70
|
+
// ssh options that take a value (in the next word, or the rest of the same word) and the ones that
|
|
71
|
+
// open a forward or a tunnel only
|
|
72
|
+
const SSH_VALUE_OPTS = new Set('BbcDEeFIiJLlmOopQRSWw')
|
|
73
|
+
const SSH_FORWARD_OPTS = new Set('DLRN')
|
|
74
|
+
|
|
75
|
+
// true when an ssh command line opens a port forward or a tunnel only (-L, -R, -D, -N). The value
|
|
76
|
+
// of an option such as `-o StrictHostKeyChecking=no` is skipped, not read as flags.
|
|
77
|
+
function sshForwards(cmd) {
|
|
78
|
+
const words = String(cmd).trim().split(/\s+/).slice(1)
|
|
79
|
+
for (let i = 0; i < words.length; i++) {
|
|
80
|
+
const w = words[i]
|
|
81
|
+
if (!w.startsWith('-')) break
|
|
82
|
+
if (w.startsWith('--')) continue
|
|
83
|
+
for (let j = 1; j < w.length; j++) {
|
|
84
|
+
if (SSH_FORWARD_OPTS.has(w[j])) return true
|
|
85
|
+
if (SSH_VALUE_OPTS.has(w[j])) {
|
|
86
|
+
if (j === w.length - 1) i++
|
|
87
|
+
break
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return false
|
|
92
|
+
}
|
|
93
|
+
|
|
67
94
|
const KEEP = [
|
|
68
95
|
{ what: 'Claude Code itself', test: p => isClaude(p), reason: 'A session started from a tool shell is a live session of its own.' },
|
|
69
96
|
{ what: 'orphan-reaper itself', cmd: /orphan-reaper\.js/, reason: 'The reaper and its waiters.' },
|
|
@@ -79,6 +106,18 @@ const KEEP = [
|
|
|
79
106
|
test: (p, name) => /^(?:vbcscompiler|bazel|buck2d|sccache)$/i.test(name) ||
|
|
80
107
|
/org\.gradle\.launcher\.daemon|GradleDaemon|KotlinCompileDaemon|org\.jetbrains\.kotlin\.daemon|[\\/]nx[\\/]src[\\/]daemon[\\/]|\bturbo(?:\.exe)?["']?\s+daemon\b|MSBuild\.(?:dll|exe)["']?\s.*\/nodemode/i.test(p.cmd),
|
|
81
108
|
reason: 'Builds in every session connect to the same daemon; stopping it breaks theirs.' },
|
|
109
|
+
{ what: 'Anything started with `BULKHEAD_KEEP=1`', test: p => p.keep === true || KEEP_TOKEN.test(p.cmd),
|
|
110
|
+
reason: 'You marked it as meant to outlive the session.' },
|
|
111
|
+
{ what: 'ssh tunnels and port forwards (`ssh -L`, `-R`, `-D`, `-N`), autossh, cloudflared, ngrok, Tailscale, frp',
|
|
112
|
+
test: (p, name) => (/^ssh$/i.test(name) && sshForwards(p.cmd)) || /^(?:autossh|cloudflared|ngrok|tailscale|tailscaled|frpc|frps)$/i.test(name),
|
|
113
|
+
reason: 'A tunnel is opened on purpose and nothing else reconnects it.' },
|
|
114
|
+
{ what: 'Databases and local services (Postgres, MySQL, MariaDB, MongoDB, Redis, Valkey, Memcached, Jupyter, code-server, Syncthing)',
|
|
115
|
+
test: (p, name) => /^(?:postgres|postmaster|mysqld|mysqld_safe|mariadbd|mongod|mongos|redis-server|redis-sentinel|valkey-server|memcached|code-server|syncthing)$/i.test(name) ||
|
|
116
|
+
/(?:^|[\s\\/])jupyter(?:-lab|-notebook|-server|lab)?(?=["'\s]|$)/i.test(p.cmd),
|
|
117
|
+
reason: 'Local services that other projects and sessions use.' },
|
|
118
|
+
{ what: 'Container runtimes (Colima, Lima, OrbStack, Podman)',
|
|
119
|
+
name: /^(?:colima|limactl|lima|orbstack|orbstack helper|podman|vfkit|krunkit)$/i,
|
|
120
|
+
reason: 'One runtime serves every session on the machine.' },
|
|
82
121
|
{ what: 'Shared machine services (adb, the Android emulator, Docker, Ollama, watchman, ssh-agent, gpg-agent)',
|
|
83
122
|
name: /^(?:adb|emulator|emulator64-\w+|qemu-system-[\w-]+|dockerd|docker|containerd|com\.docker\.[\w.-]+|ollama|watchman|ssh-agent|gpg-agent)$/i,
|
|
84
123
|
reason: 'One instance serves every session on the machine.' },
|
|
@@ -111,13 +150,16 @@ function matchesSpec(spec, p, name) {
|
|
|
111
150
|
return Boolean(spec.test)
|
|
112
151
|
}
|
|
113
152
|
|
|
114
|
-
|
|
153
|
+
// keepNames: program names; keepPatterns: text that, found in a command line, keeps the process.
|
|
154
|
+
function keeper(keepNames = [], keepPatterns = []) {
|
|
115
155
|
const extra = new Set(keepNames.map(n => baseName(n).toLowerCase()))
|
|
156
|
+
const patterns = keepPatterns.map(x => String(x).toLowerCase()).filter(Boolean)
|
|
116
157
|
const cache = new Map()
|
|
117
158
|
return p => {
|
|
118
159
|
if (!cache.has(p.pid)) {
|
|
119
160
|
const name = baseName(p.name)
|
|
120
|
-
|
|
161
|
+
const cmd = String(p.cmd || '').toLowerCase()
|
|
162
|
+
cache.set(p.pid, extra.has(name.toLowerCase()) || patterns.some(x => cmd.includes(x)) || KEEP.some(k => matchesSpec(k, p, name)))
|
|
121
163
|
}
|
|
122
164
|
return cache.get(p.pid)
|
|
123
165
|
}
|
|
@@ -264,7 +306,7 @@ function findOrphans({ procs, listening = new Set() }, now = Date.now(), opts =
|
|
|
264
306
|
now,
|
|
265
307
|
session: opts.session || null,
|
|
266
308
|
platform: opts.platform || process.platform,
|
|
267
|
-
isKept: keeper(opts.keepNames),
|
|
309
|
+
isKept: keeper(opts.keepNames, opts.keepPatterns),
|
|
268
310
|
snapshotExists: opts.snapshotExists || snapshotExists,
|
|
269
311
|
liveClaudeStarts: procs.filter(isClaude).map(p => p.start),
|
|
270
312
|
live: liveSnapshots(procs, byPid),
|
|
@@ -344,8 +386,8 @@ function killPids(pids, platform = process.platform) {
|
|
|
344
386
|
// Stops every orphan's tree and returns { orphan, pids } for each. On Windows a second look stops
|
|
345
387
|
// children that a dying process started after the first snapshot; elsewhere they are reparented
|
|
346
388
|
// and the next pass finds them through CLAUDE_PID.
|
|
347
|
-
function reapAll(orphans, procs, { platform = process.platform, keepNames = [], reread = null } = {}) {
|
|
348
|
-
const isKept = keeper(keepNames)
|
|
389
|
+
function reapAll(orphans, procs, { platform = process.platform, keepNames = [], keepPatterns = [], reread = null } = {}) {
|
|
390
|
+
const isKept = keeper(keepNames, keepPatterns)
|
|
349
391
|
const byPid = new Map(procs.map(p => [p.pid, p]))
|
|
350
392
|
const done = []
|
|
351
393
|
const killed = new Map()
|
|
@@ -381,6 +423,7 @@ function settings() {
|
|
|
381
423
|
return {
|
|
382
424
|
extraNames: setting('orphanReaper.extraNames', []),
|
|
383
425
|
keepNames: setting('orphanReaper.keepNames', []),
|
|
426
|
+
keepPatterns: setting('orphanReaper.keepPatterns', []),
|
|
384
427
|
}
|
|
385
428
|
}
|
|
386
429
|
|
|
@@ -394,19 +437,23 @@ function run({ platform = process.platform, dryRun = false, session = null, trig
|
|
|
394
437
|
const snapshot = snap || snapshotNow(platform)
|
|
395
438
|
const orphans = findOrphans(snapshot, Date.now(), { ...opts, session, platform })
|
|
396
439
|
if (dryRun) {
|
|
397
|
-
const isKept = keeper(opts.keepNames)
|
|
440
|
+
const isKept = keeper(opts.keepNames, opts.keepPatterns)
|
|
398
441
|
return orphans.map(o => ({ orphan: o, pids: [...new Set([...descendants(o.top, snapshot.procs, isKept), o.pid])] }))
|
|
399
442
|
}
|
|
400
|
-
const done = reapAll(orphans, snapshot.procs, { platform, keepNames: opts.keepNames, reread: () => snapshotNow(platform) })
|
|
443
|
+
const done = reapAll(orphans, snapshot.procs, { platform, keepNames: opts.keepNames, keepPatterns: opts.keepPatterns, reread: () => snapshotNow(platform) })
|
|
401
444
|
const files = done.length && !project ? readSessionFiles() : new Map()
|
|
402
|
-
|
|
445
|
+
const projectFor = o => project || projectOf(o.owner, files)
|
|
446
|
+
const shown = notices.items(done, snapshot.ports, projectFor)
|
|
447
|
+
done.forEach(({ orphan: o, pids }, i) => {
|
|
403
448
|
log('orphan-reaper', `stopped ${o.rule} pid ${o.pid} (tree ${o.top}, ${pids.length} processes; ${o.why}) ${oneLine(o.cmd || o.name, 160)}`)
|
|
404
449
|
recordEvent('orphan-reaper', o.rule, { session_id: sessionId }, o.cmd || o.name, {
|
|
405
|
-
project:
|
|
450
|
+
project: projectFor(o),
|
|
406
451
|
pid: o.pid,
|
|
452
|
+
port: shown[i].port,
|
|
407
453
|
trigger,
|
|
408
454
|
})
|
|
409
|
-
}
|
|
455
|
+
})
|
|
456
|
+
if (trigger !== 'manual') notices.append(stateDir(), shown)
|
|
410
457
|
return done
|
|
411
458
|
}
|
|
412
459
|
|