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/port-cli.js
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
const net = require('net')
|
|
3
|
+
const { sequence, keyFor, parseRange, DEFAULT_RANGE } = require('./stable-port')
|
|
3
4
|
const { option, rejectArgs } = require('./cli-util')
|
|
4
5
|
|
|
5
6
|
const USAGE = `usage: bulkhead port [--near PORT] [--json]
|
|
7
|
+
bulkhead port --stable [--name NAME] [--range 3000-3999] [--json]
|
|
6
8
|
|
|
7
9
|
Prints the first port at or above PORT (default 3000) that nothing is listening on,
|
|
8
10
|
so a dev server can start there without taking another session's port:
|
|
9
|
-
npm run dev -- --port $(bulkhead port --near 3000)
|
|
11
|
+
npm run dev -- --port $(bulkhead port --near 3000)
|
|
12
|
+
|
|
13
|
+
--stable gives this git worktree its own port, the same every time: it is picked from a hash of
|
|
14
|
+
the worktree's root folder (or of --name), and moves on to the next port in a fixed order only
|
|
15
|
+
when something else is listening there. It says which session holds a port it skipped.
|
|
16
|
+
npm run dev -- --port $(bulkhead port --stable)`
|
|
10
17
|
|
|
11
18
|
// Ports browsers refuse to open (the fetch spec's "bad ports"), so a dev server there is useless.
|
|
12
19
|
const BROWSER_BLOCKED = new Set([1719, 1720, 1723, 2049, 3659, 4045, 5060, 5061, 6000, 6566, 6665, 6666, 6667, 6668, 6669, 6697, 10080])
|
|
@@ -37,9 +44,77 @@ async function findFree(near = 3000, { bind = canBind, span = 1000 } = {}) {
|
|
|
37
44
|
return null
|
|
38
45
|
}
|
|
39
46
|
|
|
47
|
+
// The key a worktree's stable port comes from: --name, else the root of the git worktree, else the folder.
|
|
48
|
+
function stableKey(name, cwd = process.cwd()) {
|
|
49
|
+
if (name) return { key: `name:${name}`, root: null, name }
|
|
50
|
+
const { gitInfo } = require('./git-info')
|
|
51
|
+
const g = gitInfo(cwd)
|
|
52
|
+
const root = g ? g.root : cwd
|
|
53
|
+
return { key: keyFor(root), root, name: null }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Who listens on each busy port: { port: { pid, name, session: text|null } }, one process scan.
|
|
57
|
+
function defaultOwners(ports) {
|
|
58
|
+
const out = {}
|
|
59
|
+
try {
|
|
60
|
+
const { readProcesses } = require('./processes')
|
|
61
|
+
const { readSessionFiles, findSessions } = require('./sessions')
|
|
62
|
+
const { describeSession } = require('./session-info')
|
|
63
|
+
const snap = readProcesses()
|
|
64
|
+
if (!snap) return out
|
|
65
|
+
const sessions = findSessions(snap, readSessionFiles())
|
|
66
|
+
for (const port of ports) {
|
|
67
|
+
const l = snap.ports.find(x => x.port === port)
|
|
68
|
+
if (!l) continue
|
|
69
|
+
const owner = sessions.find(s => s.ports.some(x => x.pid === l.pid))
|
|
70
|
+
const proc = snap.procs.find(x => x.pid === l.pid)
|
|
71
|
+
out[port] = { pid: l.pid, name: proc ? proc.name : null, session: owner ? describeSession(owner) : null }
|
|
72
|
+
}
|
|
73
|
+
} catch {}
|
|
74
|
+
return out
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// { port, preferred, skipped: [{ port, pid, name, session }] }; port is null when the range is full.
|
|
78
|
+
async function findStable(key, range = DEFAULT_RANGE, { bind = canBind, owners = defaultOwners } = {}) {
|
|
79
|
+
const busy = []
|
|
80
|
+
let port = null
|
|
81
|
+
for (const p of sequence(key, range)) {
|
|
82
|
+
if (await isFree(p, bind)) { port = p; break }
|
|
83
|
+
busy.push(p)
|
|
84
|
+
}
|
|
85
|
+
const who = busy.length ? owners(busy.slice(0, 5)) : {}
|
|
86
|
+
return { port, preferred: sequence(key, range)[0], skipped: busy.slice(0, 5).map(p => ({ port: p, pid: null, name: null, session: null, ...who[p] })) }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function stable(args, { stdout, stderr }) {
|
|
90
|
+
const rangeArg = option(args, 'range')
|
|
91
|
+
const range = rangeArg === undefined ? DEFAULT_RANGE : parseRange(rangeArg)
|
|
92
|
+
if (!range) {
|
|
93
|
+
stderr.write(`bulkhead port: --range needs LOW-HIGH between 1024 and 65535, got ${rangeArg}\n`)
|
|
94
|
+
process.exitCode = 1
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
const name = option(args, 'name')
|
|
98
|
+
const k = stableKey(typeof name === 'string' ? name : null)
|
|
99
|
+
const r = await findStable(k.key, range)
|
|
100
|
+
if (args.includes('--json')) {
|
|
101
|
+
stdout.write(JSON.stringify({ port: r.port, preferred: r.preferred, range, root: k.root, name: k.name, skipped: r.skipped }) + '\n')
|
|
102
|
+
} else {
|
|
103
|
+
if (r.port) stdout.write(`${r.port}\n`)
|
|
104
|
+
for (const s of r.skipped) {
|
|
105
|
+
stderr.write(`bulkhead port: ${s.port} is in use by ${s.session || (s.name ? `${s.name} pid ${s.pid}` : 'another process')}` + (s.port === r.skipped[r.skipped.length - 1].port && r.port ? `; using ${r.port}\n` : '\n'))
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (!r.port) {
|
|
109
|
+
if (!args.includes('--json')) stderr.write(`bulkhead port: no free port between ${range.min} and ${range.max}\n`)
|
|
110
|
+
process.exitCode = 1
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
40
114
|
async function main(args, { stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
41
115
|
if (args.includes('--help') || args.includes('-h')) return stdout.write(USAGE + '\n')
|
|
42
|
-
if (rejectArgs('port', args, { flags: ['json'], valued: ['near'] }, stderr, USAGE)) return
|
|
116
|
+
if (rejectArgs('port', args, { flags: ['json', 'stable'], valued: ['near', 'name', 'range'] }, stderr, USAGE)) return
|
|
117
|
+
if (args.includes('--stable')) return stable(args, { stdout, stderr })
|
|
43
118
|
const nearArg = option(args, 'near')
|
|
44
119
|
const near = nearArg === undefined ? 3000 : Number(nearArg)
|
|
45
120
|
if (!Number.isInteger(near) || near < 1 || near > 65535) {
|
|
@@ -56,4 +131,4 @@ async function main(args, { stdout = process.stdout, stderr = process.stderr } =
|
|
|
56
131
|
}
|
|
57
132
|
}
|
|
58
133
|
|
|
59
|
-
module.exports = { main, findFree, isFree, canBind, BROWSER_BLOCKED }
|
|
134
|
+
module.exports = { main, findStable, stableKey, findFree, isFree, canBind, BROWSER_BLOCKED, USAGE }
|
package/src/port-guard.js
CHANGED
|
@@ -1,25 +1,54 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
const net = require('net')
|
|
3
|
-
const os = require('os')
|
|
4
3
|
const { runHook, blocked, setting, oneLine } = require('./hook')
|
|
5
4
|
const { detect } = require('./dev-servers')
|
|
5
|
+
const { killPorts } = require('./port-kills')
|
|
6
6
|
const { readProcesses } = require('./processes')
|
|
7
7
|
const { readSessionFiles, findSessions, ownSession } = require('./sessions')
|
|
8
|
+
const { agentLabel } = require('./agents')
|
|
8
9
|
const shell = require('./shell')
|
|
9
10
|
|
|
11
|
+
const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'ash', 'fish', 'eval', 'source', 'xargs'])
|
|
12
|
+
|
|
13
|
+
// A heredoc is code for the shell only when a shell (or eval, source) reads it; python, node, cat,
|
|
14
|
+
// a while loop and the like get it as data, and a command inside that text is not run.
|
|
15
|
+
function readsShell(prefix) {
|
|
16
|
+
return prefix.split(/[;&|(]/).pop().trim().split(/\s+/).some(w => SHELLS.has(shell.bin(w)))
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function withoutDataHeredocs(src) {
|
|
20
|
+
const docs = shell.heredocs(src, (prefix, after) => !readsShell(prefix) && !/\|\s*(?:[\w.-]+\s+)*(?:sh|bash|zsh|dash|ksh|fish|source|xargs)\b/.test(after))
|
|
21
|
+
if (!docs.length) return src
|
|
22
|
+
let out = ''
|
|
23
|
+
let last = 0
|
|
24
|
+
for (const d of docs) {
|
|
25
|
+
out += src.slice(last, d.start) + ' ' + d.rest
|
|
26
|
+
last = d.end
|
|
27
|
+
}
|
|
28
|
+
return out + src.slice(last)
|
|
29
|
+
}
|
|
30
|
+
|
|
10
31
|
// 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)
|
|
32
|
+
const opened = (cmd, mode = 'sh') => shell.unwrap(withoutDataHeredocs(shell.prepare(String(cmd || ''), mode)), mode)
|
|
12
33
|
|
|
13
34
|
function freePort(port, taken) {
|
|
14
35
|
for (let p = port + 1; p < port + 200 && p < 65536; p++) if (!taken.has(p)) return p
|
|
15
36
|
return null
|
|
16
37
|
}
|
|
17
38
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
39
|
+
// The port this worktree always gets (see bulkhead port --stable), when it is free; only called after a block.
|
|
40
|
+
function preferredPort(cwd, taken) {
|
|
41
|
+
try {
|
|
42
|
+
const { pickFree, keyFor } = require('./stable-port')
|
|
43
|
+
const { gitInfo } = require('./git-info')
|
|
44
|
+
const g = gitInfo(cwd)
|
|
45
|
+
return g ? pickFree(keyFor(g.root), taken) : null
|
|
46
|
+
} catch { return null }
|
|
21
47
|
}
|
|
22
48
|
|
|
49
|
+
// Loaded only on the block path, which keeps the allow path as fast as it was.
|
|
50
|
+
const describeSession = (...a) => require('./session-info').describeSession(...a)
|
|
51
|
+
|
|
23
52
|
// A connect attempt takes a few milliseconds; reading the process list takes a second or two
|
|
24
53
|
// on Windows, so we only read it when something answers on the port.
|
|
25
54
|
function listening(port, timeoutMs = 300) {
|
|
@@ -34,63 +63,42 @@ function listening(port, timeoutMs = 300) {
|
|
|
34
63
|
}
|
|
35
64
|
|
|
36
65
|
// Resolves to a block message, or null. Anything we cannot pin down is allowed.
|
|
37
|
-
async function check(cmd, {
|
|
66
|
+
async function check(cmd, opts) {
|
|
67
|
+
const hit = await inspect(cmd, opts)
|
|
68
|
+
return hit ? hit.message : null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// The same decision with its evidence: { rule, message, port, pid, name, owner: { pid, cwd } }.
|
|
72
|
+
async function inspect(cmd, {
|
|
38
73
|
cwd, probe = listening, snapshot = () => readProcesses(), files = () => readSessionFiles(), self = ownSession, ignore = setting('portGuard.ignorePorts', []), mode = 'sh',
|
|
39
74
|
} = {}) {
|
|
40
75
|
const server = detect(opened(cmd, mode), { cwd })
|
|
41
|
-
|
|
42
|
-
if (!
|
|
76
|
+
const ports = server ? (server.ports || [server.port]).filter(p => p && !ignore.includes(p)) : []
|
|
77
|
+
if (!ports.length) return null
|
|
78
|
+
const live = []
|
|
79
|
+
for (const p of ports) if (await probe(p)) live.push(p)
|
|
80
|
+
if (!live.length) return null
|
|
43
81
|
const snap = snapshot()
|
|
44
82
|
if (!snap) return null
|
|
45
|
-
const
|
|
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
|
|
83
|
+
const sessions = findSessions(snap, files())
|
|
49
84
|
const me = self(snap.procs)
|
|
50
|
-
if (!me
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
}
|
|
85
|
+
if (!me) return null
|
|
86
|
+
for (const port of live) {
|
|
87
|
+
const holder = snap.ports.find(l => l.port === port)
|
|
88
|
+
const owner = holder && sessions.find(s => s.ports.some(l => l.pid === holder.pid))
|
|
89
|
+
if (!owner || me === owner.pid) continue
|
|
90
|
+
const name = (snap.procs.find(p => p.pid === holder.pid) || {}).name || 'a process'
|
|
91
|
+
const taken = new Set(snap.ports.map(l => l.port))
|
|
92
|
+
const stable = preferredPort(cwd, taken)
|
|
93
|
+
const free = stable || freePort(port, taken)
|
|
94
|
+
const message = `[bulkhead] port-guard: port ${port} is already used by another ${agentLabel(owner.agent)} session ` +
|
|
95
|
+
`(${describeSession(owner)}): ${name} pid ${holder.pid}.\n` +
|
|
96
|
+
'Leave that server alone; it is the other session\'s work. If you start yours anyway, it either fails or moves to the ' +
|
|
97
|
+
`next port, and requests to localhost:${port} reach the other project.\n` +
|
|
98
|
+
(free ? `Port ${free} is free${stable ? ' (this worktree\'s own port)' : ''}:\n ${server.hint(free)}` : `Pick a free port and pass it explicitly; \`bulkhead port --near ${port}\` prints one.`)
|
|
99
|
+
return { rule: 'port-in-use', message, port, pid: holder.pid, name, owner: { pid: owner.pid, cwd: owner.cwd } }
|
|
92
100
|
}
|
|
93
|
-
return
|
|
101
|
+
return null
|
|
94
102
|
}
|
|
95
103
|
|
|
96
104
|
// Resolves to a block message when a kill by port would hit another live session's server.
|
|
@@ -114,9 +122,13 @@ async function checkKill(cmd, {
|
|
|
114
122
|
const name = (snap.procs.find(p => p.pid === holder.pid) || {}).name || 'a process'
|
|
115
123
|
const free = freePort(port, new Set(snap.ports.map(l => l.port)))
|
|
116
124
|
return {
|
|
125
|
+
rule: 'kill-by-port',
|
|
117
126
|
port,
|
|
118
|
-
|
|
119
|
-
|
|
127
|
+
pid: holder.pid,
|
|
128
|
+
name,
|
|
129
|
+
owner: { pid: owner.pid, cwd: owner.cwd },
|
|
130
|
+
message: `[bulkhead] port-guard: \`${oneLine(cmd, 120)}\` would stop ${name} pid ${holder.pid}, the server another ${agentLabel(owner.agent)} session ` +
|
|
131
|
+
`runs on port ${port} (${describeSession(owner)}).\n` +
|
|
120
132
|
'That server is the other session\'s work. Stopping it breaks whatever that session is running or testing.\n' +
|
|
121
133
|
(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
134
|
`\`bulkhead port --near ${port}\` prints a port no session holds.\n` +
|
|
@@ -127,16 +139,16 @@ async function checkKill(cmd, {
|
|
|
127
139
|
return null
|
|
128
140
|
}
|
|
129
141
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
if (kill) return blocked('port-guard', 'kill-by-port', input, kill.message)
|
|
139
|
-
})
|
|
142
|
+
async function handle(input) {
|
|
143
|
+
const cmd = input.tool_input && input.tool_input.command
|
|
144
|
+
if (typeof cmd !== 'string' || !cmd) return
|
|
145
|
+
const mode = shell.modeFor(input)
|
|
146
|
+
const why = await inspect(cmd, { cwd: input.cwd, mode })
|
|
147
|
+
if (why) return blocked('port-guard', why.rule, input, why.message)
|
|
148
|
+
const kill = await checkKill(cmd, { mode })
|
|
149
|
+
if (kill) return blocked('port-guard', 'kill-by-port', input, kill.message)
|
|
140
150
|
}
|
|
141
151
|
|
|
142
|
-
|
|
152
|
+
if (require.main === module) runHook('port-guard', handle)
|
|
153
|
+
|
|
154
|
+
module.exports = { handle, check, inspect, checkKill, killPorts, freePort, listening }
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// Finds the ports a command stops: `npx kill-port 3000`, `fuser -k 3000/tcp`, `fkill :3000`, and a
|
|
3
|
+
// lookup of the PID by port that feeds a kill, however the shell joins the two:
|
|
4
|
+
// lsof -ti :3000 | xargs kill -9
|
|
5
|
+
// lsof -i :3000 | grep LISTEN | awk '{print $2}' | xargs kill
|
|
6
|
+
// kill -9 $(lsof -i :3000 | awk 'NR>1{print $2}')
|
|
7
|
+
// for p in $(lsof -ti :3000); do kill $p; done
|
|
8
|
+
// PID=$(lsof -ti :3000); kill $PID
|
|
9
|
+
// netstat -anv | grep '\.3000 ' | awk '{print $9}' | xargs kill
|
|
10
|
+
// ss -ltnp 'sport = :3000' | ... | xargs kill
|
|
11
|
+
// The text is read with the quote-aware lexer in shell.js, so a port in an echo or a grep pattern is not a kill.
|
|
12
|
+
const shell = require('./shell')
|
|
13
|
+
|
|
14
|
+
const numbers = s => (String(s).match(/\d+/g) || []).map(Number).filter(n => n > 0 && n < 65536)
|
|
15
|
+
const validPort = n => Number.isInteger(n) && n > 0 && n < 65536
|
|
16
|
+
const KILLS = /\b(?:kill|taskkill|Stop-Process|spps)\b(?!-port)/i
|
|
17
|
+
const KILL_HINT = /kill|fuser|Stop-Process|spps/i
|
|
18
|
+
const KILLERS = new Set(['kill', 'taskkill', 'stop-process', 'spps'])
|
|
19
|
+
const GREPS = new Set(['grep', 'egrep', 'fgrep', 'rg', 'ag', 'awk', 'gawk', 'sed', 'findstr', 'select-string'])
|
|
20
|
+
const PORT_KILLERS = /^(?:kill-port|fkill)$/
|
|
21
|
+
|
|
22
|
+
// Windows forms: Stop-Process on Get-NetTCPConnection, netstat | findstr :N, for /f ... taskkill.
|
|
23
|
+
// Statements are split on ; && || and newlines, but not on pipes, which carry the PID along.
|
|
24
|
+
function windowsKillPorts(cmd) {
|
|
25
|
+
const ports = []
|
|
26
|
+
for (const st of String(cmd || '').split(/\n|;|&&|\|\|/)) {
|
|
27
|
+
if (/Get-NetTCPConnection/i.test(st) && KILLS.test(st)) {
|
|
28
|
+
const lp = /-LocalPort\s+((?:\d+\s*,\s*)*\d+)/i.exec(st)
|
|
29
|
+
if (lp) ports.push(...numbers(lp[1]))
|
|
30
|
+
}
|
|
31
|
+
if (/\bnetstat\b/i.test(st) && KILLS.test(st)) {
|
|
32
|
+
const f = /\b(?:findstr|grep|Select-String)\b[^|]*?:(\d+)/i.exec(st)
|
|
33
|
+
if (f) ports.push(Number(f[1]))
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return ports
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Values a shell variable can have here: NAME=3000, for NAME in 3000 3001. Unknown names stay as written.
|
|
40
|
+
function knownVars(lexed) {
|
|
41
|
+
const vars = new Map()
|
|
42
|
+
for (const c of lexed) {
|
|
43
|
+
if (c.sub) continue
|
|
44
|
+
const w = c.words.map(x => x.v)
|
|
45
|
+
const at = /^(?:export|declare|local|readonly)$/.test(w[0]) ? 1 : 0
|
|
46
|
+
const m = /^(\w+)=([\w.:/-]*)$/.exec(w[at] || '')
|
|
47
|
+
if (m) vars.set(m[1], [m[2]])
|
|
48
|
+
if (w[0] === 'for' && w[2] === 'in' && /^\w+$/.test(w[1])) vars.set(w[1], w.slice(3).filter(x => !/[$`]/.test(x)))
|
|
49
|
+
}
|
|
50
|
+
return vars
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function expand(word, vars, depth = 0) {
|
|
54
|
+
const m = /\$(?:\{(\w+)\}|(\w+))/.exec(word)
|
|
55
|
+
if (!m || depth > 3) return [word]
|
|
56
|
+
const values = vars.get(m[1] || m[2])
|
|
57
|
+
if (!values || !values.length) return [word]
|
|
58
|
+
return values.slice(0, 8).flatMap(v => expand(word.slice(0, m.index) + v + word.slice(m.index + m[0].length), vars, depth + 1))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// lsof -i :3000, -iTCP:3000, -i tcp:3000, -ti:3000, -i @host:3000, -i :3000,3001
|
|
62
|
+
function lsofPorts(args, vars) {
|
|
63
|
+
const out = []
|
|
64
|
+
for (let i = 0; i < args.length; i++) {
|
|
65
|
+
const m = /^-[a-zA-Z]*?i(.*)$/.exec(args[i])
|
|
66
|
+
if (!m) continue
|
|
67
|
+
for (const w of expand(m[1] !== '' ? m[1] : args[i + 1] || '', vars)) {
|
|
68
|
+
const hit = /:([\d,-]+)$/.exec(w)
|
|
69
|
+
if (hit) out.push(...numbers(hit[1]))
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return out.filter(validPort)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ss -ltnp 'sport = :3000': any :N or sport N in the words
|
|
76
|
+
function socketPorts(args, vars) {
|
|
77
|
+
const out = []
|
|
78
|
+
for (const w of args.flatMap(a => expand(a, vars))) {
|
|
79
|
+
for (const m of w.matchAll(/:(\d{1,5})(?!\d)/g)) out.push(Number(m[1]))
|
|
80
|
+
const sp = /\b[sd]?port\s*(?:==?|eq)?\s*:?(\d{1,5})(?!\d)/.exec(w)
|
|
81
|
+
if (sp) out.push(Number(sp[1]))
|
|
82
|
+
}
|
|
83
|
+
return out.filter(validPort)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// A port number in a grep, awk or findstr pattern: ':3000', '\.3000 ' (macOS netstat), 3000. Not part of an IP address.
|
|
87
|
+
function patternPorts(args, vars) {
|
|
88
|
+
const out = []
|
|
89
|
+
for (const raw of args.filter(a => !a.startsWith('-')).flatMap(a => expand(a, vars))) {
|
|
90
|
+
const w = raw.replace(/\\/g, '')
|
|
91
|
+
for (const m of w.matchAll(/\d{2,5}/g)) {
|
|
92
|
+
const before = w[m.index - 1] || ''
|
|
93
|
+
const after = w[m.index + m[0].length] || ''
|
|
94
|
+
const afterDot = after === '.' && /\d/.test(w[m.index + m[0].length + 1] || '')
|
|
95
|
+
const beforeDot = before === '.' && /\d/.test(w[m.index - 2] || '')
|
|
96
|
+
if (/\d/.test(before) || /\d/.test(after) || afterDot || beforeDot) continue
|
|
97
|
+
out.push(Number(m[0]))
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return out.filter(validPort)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function fuserPorts(args, vars) {
|
|
104
|
+
if (!args.some(a => /^-[a-zA-Z]*k[a-zA-Z]*$/.test(a) || a === '--kill')) return []
|
|
105
|
+
const tcp = args.some((a, i) => /^(?:-n|--namespace)$/.test(a) && /^tcp$/i.test(args[i + 1] || ''))
|
|
106
|
+
const out = []
|
|
107
|
+
for (const a of args.flatMap(x => expand(x, vars))) {
|
|
108
|
+
const m = /^(\d+)\/tcp$/i.exec(a)
|
|
109
|
+
if (m) out.push(Number(m[1]))
|
|
110
|
+
else if (tcp && /^\d+$/.test(a)) out.push(Number(a))
|
|
111
|
+
}
|
|
112
|
+
return out.filter(validPort)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// npx kill-port 3000 3001, kill-port --port 3000,3001
|
|
116
|
+
function killPortPorts(args, vars) {
|
|
117
|
+
const out = []
|
|
118
|
+
for (const a of args.flatMap(x => expand(x, vars))) {
|
|
119
|
+
if (/^(?:--port=)?[\d,]+$/.test(a)) out.push(...numbers(a))
|
|
120
|
+
}
|
|
121
|
+
return out.filter(validPort)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// fkill :3000 (a bare number is a PID)
|
|
125
|
+
const fkillPorts = (args, vars) => args.flatMap(a => expand(a, vars)).map(a => /^:(\d+)$/.exec(a)).filter(Boolean).map(m => Number(m[1])).filter(validPort)
|
|
126
|
+
|
|
127
|
+
// What one statement (a pipeline, a $( ), a loop) says: the ports it kills outright (fuser -k,
|
|
128
|
+
// kill-port, fkill), the ports it looks up, and whether it kills a PID.
|
|
129
|
+
function statementPorts(list, vars) {
|
|
130
|
+
const direct = []
|
|
131
|
+
const explicit = []
|
|
132
|
+
const patterns = []
|
|
133
|
+
let lookup = false
|
|
134
|
+
let kills = false
|
|
135
|
+
for (const argv of list) {
|
|
136
|
+
const b = shell.bin(argv[0])
|
|
137
|
+
const args = argv.slice(1)
|
|
138
|
+
const at = PORT_KILLERS.test(b) ? 0 : argv.findIndex((a, i) => i > 0 && i < 6 && PORT_KILLERS.test(shell.bin(a)))
|
|
139
|
+
if (b === 'lsof') { lookup = true; explicit.push(...lsofPorts(args, vars)) } else if (b === 'netstat' || b === 'ss') { lookup = true; explicit.push(...socketPorts(args, vars)) } else if (GREPS.has(b)) patterns.push(...patternPorts(args, vars))
|
|
140
|
+
else if (b === 'fuser') direct.push(...fuserPorts(args, vars))
|
|
141
|
+
else if (at >= 0 && /^(?:kill-port|fkill|npx|bunx|pnpx|pnpm|yarn|npm|bun)$/.test(b)) {
|
|
142
|
+
const rest = argv.slice(at + 1)
|
|
143
|
+
direct.push(...(shell.bin(argv[at]) === 'fkill' ? fkillPorts(rest, vars) : killPortPorts(rest, vars)))
|
|
144
|
+
} else if (KILLERS.has(b) && !args.some(a => /^-(?:0|l|L)$/.test(a))) kills = true
|
|
145
|
+
}
|
|
146
|
+
// lsof with no port of its own and netstat are narrowed by a later grep or awk
|
|
147
|
+
const looked = explicit.length ? explicit : lookup ? patterns : []
|
|
148
|
+
return { direct, looked: [...new Set(looked)], kills }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function shellKillPorts(text) {
|
|
152
|
+
const lexed = shell.lex(text).cmds
|
|
153
|
+
const vars = knownVars(lexed)
|
|
154
|
+
const segs = new Map()
|
|
155
|
+
for (const c of shell.commands(text)) {
|
|
156
|
+
if (!segs.has(c.seg)) segs.set(c.seg, [])
|
|
157
|
+
segs.get(c.seg).push(c.argv)
|
|
158
|
+
}
|
|
159
|
+
// variables a statement fills from a command: PID=$(lsof -ti :3000)
|
|
160
|
+
const assigned = new Map()
|
|
161
|
+
for (const c of lexed) {
|
|
162
|
+
if (c.sub) continue
|
|
163
|
+
const m = /^(?:\w+\s+)?(\w+)=(?:\$\(|`)/.exec(c.words.slice(0, 2).map(w => w.v).join(' '))
|
|
164
|
+
if (m) assigned.set(c.seg, (assigned.get(c.seg) || []).concat(m[1]))
|
|
165
|
+
}
|
|
166
|
+
const found = new Set()
|
|
167
|
+
const add = list => { for (const p of list) found.add(p) }
|
|
168
|
+
const carried = new Map() // variable -> ports it came from
|
|
169
|
+
for (const seg of [...segs.keys()].sort((a, b) => a - b)) {
|
|
170
|
+
const list = segs.get(seg)
|
|
171
|
+
const info = statementPorts(list, vars)
|
|
172
|
+
add(info.direct)
|
|
173
|
+
if (info.kills) {
|
|
174
|
+
add(info.looked)
|
|
175
|
+
if (carried.size) {
|
|
176
|
+
for (const argv of list) {
|
|
177
|
+
for (const w of argv) for (const m of w.matchAll(/\$\{?(\w+)/g)) if (carried.has(m[1])) add(carried.get(m[1]))
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (info.looked.length) for (const name of assigned.get(seg) || []) carried.set(name, info.looked)
|
|
182
|
+
}
|
|
183
|
+
return [...found]
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Ports a kill-by-port command aims at, or [] when the command does not kill by port.
|
|
187
|
+
function killPorts(cmd) {
|
|
188
|
+
const text = String(cmd || '')
|
|
189
|
+
const ports = new Set(windowsKillPorts(text))
|
|
190
|
+
if (KILL_HINT.test(text)) {
|
|
191
|
+
try {
|
|
192
|
+
for (const p of shellKillPorts(text)) ports.add(p)
|
|
193
|
+
} catch { /* a command we cannot read is not a kill we can name */ }
|
|
194
|
+
}
|
|
195
|
+
return [...ports].filter(validPort)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
module.exports = { killPorts }
|
package/src/pressure.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const fs = require('fs')
|
|
3
|
+
const path = require('path')
|
|
4
|
+
const { execFileSync } = require('child_process')
|
|
5
|
+
|
|
6
|
+
// Is the machine under critical memory pressure right now? Cheap, and cached for a few seconds
|
|
7
|
+
// because every subagent spawn asks.
|
|
8
|
+
// macOS kern.memorystatus_vm_pressure_level 4 (1 normal, 2 warning, 4 critical)
|
|
9
|
+
// Linux /proc/pressure/memory: processes stalled on memory (full avg10 >= 10, or some avg10 >= 50);
|
|
10
|
+
// without PSI, MemAvailable under 5% of RAM
|
|
11
|
+
// Windows available physical memory under 8%
|
|
12
|
+
const CACHE_MS = 5000
|
|
13
|
+
const LINUX_FULL = 10
|
|
14
|
+
const LINUX_SOME = 50
|
|
15
|
+
const WINDOWS_FREE_PCT = 8
|
|
16
|
+
const LOW_FREE_PCT = 5
|
|
17
|
+
|
|
18
|
+
const sysctl = () => execFileSync('sysctl', ['-n', 'kern.memorystatus_vm_pressure_level'], { encoding: 'utf8', timeout: 1500 })
|
|
19
|
+
|
|
20
|
+
function parsePsi(text) {
|
|
21
|
+
const avg = kind => {
|
|
22
|
+
const m = String(text).match(new RegExp(`^${kind} .*?avg10=([\\d.]+)`, 'm'))
|
|
23
|
+
return m ? Number(m[1]) : null
|
|
24
|
+
}
|
|
25
|
+
return { some: avg('some'), full: avg('full') }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function freePct(mem) {
|
|
29
|
+
return mem.totalGb > 0 ? (100 * mem.freeGb) / mem.totalGb : 100
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// { critical, detail } from one reading, or null when nothing could be read (then nothing is blocked).
|
|
33
|
+
function measure({ platform = process.platform, run = sysctl, readFile = fs.readFileSync, readMem = () => require('./memory').readMemory() } = {}) {
|
|
34
|
+
try {
|
|
35
|
+
if (process.env.BULKHEAD_TEST_PRESSURE) return JSON.parse(process.env.BULKHEAD_TEST_PRESSURE)
|
|
36
|
+
if (platform === 'darwin') {
|
|
37
|
+
const level = Number(String(run()).trim())
|
|
38
|
+
if (!Number.isFinite(level)) return null
|
|
39
|
+
return { critical: level >= 4, detail: `macOS memory pressure level ${level}` }
|
|
40
|
+
}
|
|
41
|
+
if (platform === 'linux') {
|
|
42
|
+
let psi = null
|
|
43
|
+
try { psi = parsePsi(readFile('/proc/pressure/memory', 'utf8')) } catch {}
|
|
44
|
+
if (psi && psi.some !== null) {
|
|
45
|
+
const critical = (psi.full || 0) >= LINUX_FULL || psi.some >= LINUX_SOME
|
|
46
|
+
return { critical, detail: `memory stall avg10 some ${psi.some}% full ${psi.full || 0}%` }
|
|
47
|
+
}
|
|
48
|
+
const mem = readMem()
|
|
49
|
+
return { critical: freePct(mem) < LOW_FREE_PCT, detail: `${mem.freeGb.toFixed(1)} GB free` }
|
|
50
|
+
}
|
|
51
|
+
const mem = readMem()
|
|
52
|
+
return { critical: freePct(mem) < (platform === 'win32' ? WINDOWS_FREE_PCT : LOW_FREE_PCT), detail: `${mem.freeGb.toFixed(1)} GB of ${mem.totalGb.toFixed(1)} GB free` }
|
|
53
|
+
} catch {
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// measure() with the answer kept in <state dir>/pressure.json for CACHE_MS.
|
|
59
|
+
function current({ stateDir, now = Date.now(), ...readers } = {}) {
|
|
60
|
+
const file = stateDir && path.join(stateDir, 'pressure.json')
|
|
61
|
+
try {
|
|
62
|
+
const c = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
63
|
+
if (now - c.at >= 0 && now - c.at < CACHE_MS) return c.value
|
|
64
|
+
} catch {}
|
|
65
|
+
const value = measure(readers)
|
|
66
|
+
if (file && value) {
|
|
67
|
+
try {
|
|
68
|
+
fs.mkdirSync(stateDir, { recursive: true })
|
|
69
|
+
fs.writeFileSync(file, JSON.stringify({ at: now, value }))
|
|
70
|
+
} catch {}
|
|
71
|
+
}
|
|
72
|
+
return value
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = { measure, current, parsePsi, CACHE_MS }
|