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.
Files changed (66) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.es.md +142 -50
  3. package/README.ja.md +142 -50
  4. package/README.ko.md +143 -51
  5. package/README.md +142 -50
  6. package/README.pt-BR.md +142 -50
  7. package/README.zh-CN.md +154 -62
  8. package/hooks/hooks.json +68 -8
  9. package/package.json +5 -2
  10. package/src/agent-hook.js +162 -0
  11. package/src/agent-setup.js +256 -0
  12. package/src/agents.js +89 -0
  13. package/src/allow-cli.js +65 -0
  14. package/src/allow-hint.js +62 -0
  15. package/src/allow-once.js +152 -0
  16. package/src/auto-queue.js +93 -0
  17. package/src/bash-guards.js +36 -0
  18. package/src/basics.js +46 -27
  19. package/src/checkout-guard.js +342 -74
  20. package/src/cli-util.js +13 -1
  21. package/src/cli.js +40 -4
  22. package/src/config-check.js +21 -4
  23. package/src/config-cli.js +75 -10
  24. package/src/config.js +106 -9
  25. package/src/dev-servers.js +361 -61
  26. package/src/disk-guard.js +8 -8
  27. package/src/doctor-enforcement.js +273 -0
  28. package/src/doctor.js +19 -9
  29. package/src/edit-guard.js +196 -0
  30. package/src/events.js +30 -5
  31. package/src/explain.js +251 -0
  32. package/src/git-info.js +55 -0
  33. package/src/gitargs.js +82 -0
  34. package/src/hook.js +38 -6
  35. package/src/kill-extras.js +219 -0
  36. package/src/kill-guard.js +228 -77
  37. package/src/kill-owners.js +116 -0
  38. package/src/log-cli.js +15 -10
  39. package/src/mcp.js +131 -0
  40. package/src/memory-guard.js +126 -24
  41. package/src/memory.js +21 -2
  42. package/src/notices.js +95 -0
  43. package/src/orphan-reaper.js +58 -11
  44. package/src/port-cli.js +78 -3
  45. package/src/port-guard.js +80 -68
  46. package/src/port-kills.js +198 -0
  47. package/src/pressure.js +75 -0
  48. package/src/processes.js +44 -12
  49. package/src/scan-report.js +166 -0
  50. package/src/scan.js +87 -18
  51. package/src/secrets-guard.js +301 -0
  52. package/src/self-allow.js +73 -0
  53. package/src/session-info.js +121 -0
  54. package/src/session-state.js +176 -0
  55. package/src/sessions.js +109 -20
  56. package/src/shell.js +279 -19
  57. package/src/slot-cli.js +92 -21
  58. package/src/slots.js +61 -13
  59. package/src/stable-port.js +61 -0
  60. package/src/status.js +55 -17
  61. package/src/statusline.js +45 -16
  62. package/src/subagent-guard.js +24 -0
  63. package/src/top.js +36 -11
  64. package/src/wait-guard.js +96 -32
  65. package/src/worktree-cli.js +454 -0
  66. 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
- 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'
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
- if (!server || !server.port || ignore.includes(server.port)) return null
42
- if (!(await probe(server.port))) return null
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 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
83
+ const sessions = findSessions(snap, files())
49
84
  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
- }
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 [...ports].filter(p => p > 0 && p < 65536)
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
- 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` +
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
- 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
- })
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
- module.exports = { check, checkKill, killPorts, freePort, listening }
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 }
@@ -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 }