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/tools.mjs ADDED
@@ -0,0 +1,2333 @@
1
+ /**
2
+ * THE TOOL REGISTRY — one declaration, read by the model and by the dispatcher.
3
+ *
4
+ * ── WHY A REGISTRY FOR THREE TOOLS ──────────────────────────────────────────
5
+ * Because the architecture is ONE capability registry, TWO clients. The web
6
+ * console already has 160-odd tools whose executor is a cloud sandbox; this CLI
7
+ * is the same idea with a LOCAL executor. Three tools is the first slice, not
8
+ * the design — and the whole point of the slice is to establish the seam where
9
+ * more get added, rather than to hardcode three `if` branches that the second
10
+ * client would have to fork.
11
+ *
12
+ * ⚠️ THE SCHEMA AND THE DISPATCH LIVE IN ONE FILE ON PURPOSE. The recurring bug
13
+ * in tool-calling systems is a model that has been TOLD about a tool the
14
+ * dispatcher does not implement (or that takes a differently-named argument) —
15
+ * a silent capability hole, because the model dutifully calls it and the turn
16
+ * quietly reports "unknown tool". Keeping the JSON Schema next to the code that
17
+ * reads the arguments means the drift has to be committed deliberately, and
18
+ * `console/lib/acuvo-code-workspace.test.ts` asserts every declared tool has a
19
+ * handler and vice versa.
20
+ */
21
+
22
+ // ⚠️ The ONLY direct filesystem use in this file, and it is for the OFFER, not
23
+ // for a tool: `languagesPresent` below has to look at the workspace to decide
24
+ // whether a language server could ever answer here. Every tool still reads and
25
+ // writes through the executor.
26
+ import { readdirSync, existsSync } from 'node:fs';
27
+ import { join } from 'node:path';
28
+
29
+ import { executeRunCommand } from './command.mjs';
30
+ /**
31
+ * ⚠️ FROM THE LEAF, NOT FROM `git.mjs`. The credential list moved to
32
+ * `secret-paths.mjs` so `workspace.mjs` could use it for `move_file` without
33
+ * creating a cycle the bundler cannot order. See that file's header.
34
+ */
35
+ import { refusedCommitPath } from './secret-paths.mjs';
36
+ import { generateImage, imageToolSchema, imageConfig } from './imagegen.mjs';
37
+ import { listEngines, listEnginesToolSchema } from './creative-engines.mjs';
38
+ import { refusedWriteResult } from './write-approval.mjs';
39
+ import { findFiles, searchText, searchToolSchemas } from './search.mjs';
40
+ import { editThroughExecutor, editToolSchema, applyEdit } from './edit.mjs';
41
+ import { deleteToolSchema } from './delete.mjs';
42
+ import { evaluateSnippet, evaluateToolSchema } from './evaluate.mjs';
43
+ import {
44
+ gitStatus, gitDiff, gitLog, gitCommit, gitBranch, gitPush,
45
+ gitToolSchemas, gitPushToolNames, pushEnabled, ALLOW_PUSH_ENV,
46
+ } from './git.mjs';
47
+ import { speak, transcribe, makeDocument, readDocument, readTable, mediaToolSchemas, mediaToolNames } from './media.mjs';
48
+ import { editImage, expandImage, imageEditToolSchemas, imageEditToolNames } from './image-edit.mjs';
49
+ /**
50
+ * ⭐ `designPass` IS A STRICT SUPERSET OF `seePage`, deliberately, so wiring it
51
+ * is a SWAP rather than a migration: `ok`, `path`, `screenshot`,
52
+ * `screenshotBytes`, `viewport`, `findings` and `looked` are untouched, and
53
+ * every existing consumer (report.mjs, parallel.mjs, turn.mjs) keeps working.
54
+ * What it adds is the ~89-token `verdict` the model actually acts on, plus a
55
+ * `trustworthy` flag so a render that cannot be believed is never phrased as
56
+ * an all-clear.
57
+ *
58
+ * ⚠️ `seePage` IS NO LONGER IMPORTED HERE, AND THAT IS THE POINT. Leaving both
59
+ * in scope is exactly how this package ended up with a hardened `editFile()`
60
+ * while the dispatcher called the unhardened one — two paths to one capability,
61
+ * and the wrong one wired. design-loop.mjs calls media.mjs's `seePage` itself;
62
+ * it wraps the transport, it does not fork it.
63
+ */
64
+ import { designPass } from './design-loop.mjs';
65
+ import { planStart, planStep, planStatus, planToolSchemas, planFileFor } from './plan-ledger.mjs';
66
+ import { skillsToolSchemas } from './skills.mjs';
67
+ import { discoverAllSkills, loadAnySkill } from './builtin-skills.mjs';
68
+ import { remember, forget, learnedToolSchemas } from './learned.mjs';
69
+ import { lspToolSchemas, runLspTool, discoverLanguageServer, LANGUAGE_SERVERS, LSP_TOOL_NAMES } from './lsp.mjs';
70
+ import { backgroundToolSchemas, runBackgroundTool, BACKGROUND_TOOL_NAMES } from './background.mjs';
71
+ import { httpProbeToolSchemas, runHttpProbeTool, HTTP_PROBE_TOOL_NAMES } from './http-probe.mjs';
72
+ /**
73
+ * ── ⭐⭐ FOUR MODULES, 5,409 LINES, REACHABLE FROM NOTHING UNTIL NOW ─────────
74
+ *
75
+ * `code-review` (1,382), `db-inspect` (1,624), `gh` (1,351) and `log-tail`
76
+ * (1,052) were all written, tested and never given a door. The wiring-reach
77
+ * guard has been naming them for weeks and CI has been red on it for days.
78
+ *
79
+ * ⭐ Every one already shipped its own `*ToolSchemas()` and its own executor —
80
+ * the same shape `http-probe` uses — so this is a registration, not a rewrite.
81
+ * That is exactly why leaving them dark was so expensive: the work was done.
82
+ */
83
+ import { codeReviewToolSchemas, executeReviewCode } from './code-review.mjs';
84
+ import { dbToolSchemas, inspectDatabase} from './db-inspect.mjs';
85
+ import { ghToolSchemas, executeGh } from './gh.mjs';
86
+ import { logTailToolSchemas, runLogTailTool } from './log-tail.mjs';
87
+ import { tsserverAvailable, runTsserverTool, handlesFile as tsHandlesFile } from './tsserver.mjs';
88
+ import { replToolSchemas, runReplTool, REPL_TOOL_NAMES } from './repl.mjs';
89
+ import { listSessions, sessionToolSchemas } from './session.mjs';
90
+ import { askUserToolSchemas } from './ask-user.mjs';
91
+ import { writeManyToolSchemas, writeMany } from './write-many.mjs';
92
+ /**
93
+ * ⭐⭐ THE ENGINE WAS FINISHED AND UNREACHED. `apply-patch.mjs` shipped with 13
94
+ * tests and two mutation-proven properties on 2026-08-19 and was imported by
95
+ * nothing on the runtime path — the defect `wiring-reach.test.mjs` exists for.
96
+ *
97
+ * ⭐ IT IS THE TOP REMAINING COST LEVER, measured on a real build: output is
98
+ * $0.045 of $0.080 — 56% of the spend, ~53,000 tokens — and a prompt cache
99
+ * (already 83.2%, 100% steady-state) can never discount output. The output is
100
+ * dominated by re-emitting whole files, and a patch is 10-50x smaller.
101
+ */
102
+ import { applyPatchToolSchemas, planPatch, commitPatch } from './apply-patch.mjs';
103
+ import { declareAcceptance, checkAcceptance, acceptanceToolSchemas } from './acceptance.mjs';
104
+ import { fetchText, fetchToolSchemas } from './fetch-text.mjs';
105
+ import { webSearch, formatResults, webSearchToolSchemas } from './websearch.mjs';
106
+ import { readImage, visionToolSchemas } from './vision.mjs';
107
+ import { readWindow, readWindowToolSchemas } from './read-window.mjs';
108
+ import { runProgram, spawnArgvToolSchemas } from './spawn-argv.mjs';
109
+ import { runSubagent, subagentToolSchemas } from './subagent.mjs';
110
+
111
+ /**
112
+ * The sentinel `workspace.mjs` gives an executor with no disk (the browser
113
+ * builder's Map-backed one). Spelled once here because SIX of the tools below
114
+ * have to refuse on it, and six copies of a magic string is how one of them
115
+ * ends up spelled `"(memory)"` with different brackets.
116
+ */
117
+ const MEMORY_ROOT = '(memory)';
118
+
119
+ /** OpenAI-shaped tool definitions. OpenRouter, Groq, Cerebras and Gemini's
120
+ * compatibility endpoint all speak this, which is why the console's transport
121
+ * uses the same shape. */
122
+ export const TOOL_SCHEMAS = [
123
+ {
124
+ type: 'function',
125
+ function: {
126
+ name: 'read_file',
127
+ description:
128
+ 'Read a UTF-8 text file from the workspace. Paths are relative to the workspace root; anything outside it is refused.',
129
+ parameters: {
130
+ type: 'object',
131
+ properties: {
132
+ path: { type: 'string', description: 'Workspace-relative path, e.g. "src/index.js".' },
133
+ },
134
+ required: ['path'],
135
+ },
136
+ },
137
+ },
138
+ {
139
+ type: 'function',
140
+ function: {
141
+ name: 'write_file',
142
+ /**
143
+ * ⚠️ THE OLD TEXT SAID "there is no patch mode", AND THAT BECAME FALSE THE
144
+ * MOMENT `apply_patch` WAS WIRED. A description that denies a capability
145
+ * IS the capability not existing — `run_command`'s shell note above says
146
+ * the same thing, and it cost that flag its whole effect for weeks.
147
+ *
148
+ * ⭐ The pointer is here, not only on `apply_patch`, because this is the
149
+ * verb the model is already reaching for when the cheaper one applies.
150
+ */
151
+ description:
152
+ 'Create a new UTF-8 text file, or replace an existing one outright, creating parent directories as needed. '
153
+ + 'Write the COMPLETE file contents — anything you omit is deleted. '
154
+ + 'If the file ALREADY EXISTS, prefer apply_patch: re-emitting a whole file spends output tokens, which are '
155
+ + '56% of a run\'s cost and the one part a prompt cache cannot discount, and a patch also measures 9x fewer '
156
+ + 'editing errors. Use this verb for a new file, or when genuinely rewriting one end to end.',
157
+ parameters: {
158
+ type: 'object',
159
+ properties: {
160
+ path: { type: 'string', description: 'Workspace-relative path, e.g. "src/index.js".' },
161
+ content: { type: 'string', description: 'The complete new contents of the file.' },
162
+ },
163
+ required: ['path', 'content'],
164
+ },
165
+ },
166
+ },
167
+ {
168
+ type: 'function',
169
+ function: {
170
+ name: 'list_dir',
171
+ description: 'List the entries of a directory in the workspace. Use "." for the workspace root.',
172
+ parameters: {
173
+ type: 'object',
174
+ properties: {
175
+ path: { type: 'string', description: 'Workspace-relative directory, or "." for the root.' },
176
+ },
177
+ required: [],
178
+ },
179
+ },
180
+ },
181
+ {
182
+ type: 'function',
183
+ function: {
184
+ name: 'run_command',
185
+ description: [
186
+ 'Run ONE allowlisted command in the workspace and get its exit code, stdout and stderr back.',
187
+ 'This is how you VERIFY what you wrote — a non-zero exit code is the fact you fix in the next round.',
188
+ 'Allowed: `node <file>`, `node --test <file-or-dir>`, `npm test`, `npm run <script>`,',
189
+ '`npx vitest run [paths]`, `tsc --noEmit`.',
190
+ 'There is NO SHELL: pipes, &&, ;, quotes, redirection, backticks, $() and every other program',
191
+ '(rm, curl, git, python, …) are refused. Run one plain command per call.',
192
+ ].join(' '),
193
+ parameters: {
194
+ type: 'object',
195
+ properties: {
196
+ command: {
197
+ type: 'string',
198
+ description: 'One command, e.g. "node --test src/math.test.js" or "npm test".',
199
+ },
200
+ },
201
+ required: ['command'],
202
+ },
203
+ },
204
+ },
205
+ ];
206
+
207
+ // ⚠️ The image tool is appended to the registry rather than declared inline, so
208
+ // its schema and its `imageConfig` gate live together in imagegen.mjs — one file
209
+ // owns whether the capability exists and what it looks like.
210
+ /**
211
+ * ── ⭐⭐ RENAMING WAS IMPOSSIBLE, NOT MERELY EXPENSIVE ──────────────────────
212
+ *
213
+ * Without this verb the only rename was read + write + delete: three rounds of
214
+ * a five-round default, and the file's whole content through the context twice.
215
+ * MEASURED against the real executor, two ordinary files cannot do it at all —
216
+ * a 250KB source file ("over the 200000-byte read limit") and any binary
217
+ * ("logo.png" is refused as binary, which is the good outcome; the alternative
218
+ * is silent corruption). So an agent could not rename a large module or move an
219
+ * image into `assets/`, and the only explanation it got was a read error about
220
+ * a file it never wanted to read.
221
+ *
222
+ * The refusals live on `executor.moveFile`, where the credential-laundering
223
+ * rule and the directory rule are argued in full.
224
+ */
225
+ TOOL_SCHEMAS.push({
226
+ type: 'function',
227
+ function: {
228
+ name: 'move_file',
229
+ description: [
230
+ 'Rename or move ONE file inside the workspace, creating parent directories as needed.',
231
+ 'Use this instead of read_file + write_file + delete_file: it is one round instead of three,',
232
+ 'it does not put the file through your context, and it is the ONLY way to move a binary file',
233
+ 'or one larger than the read limit.',
234
+ 'One file per call: no globs, no directories — move a directory\'s files individually, or use `git mv` yourself.',
235
+ 'It refuses to overwrite an existing destination unless you pass overwrite: true.',
236
+ ].join(' '),
237
+ parameters: {
238
+ type: 'object',
239
+ properties: {
240
+ from: { type: 'string', description: 'Workspace-relative path of the existing file, e.g. "src/old.ts".' },
241
+ to: { type: 'string', description: 'Workspace-relative destination, e.g. "src/lib/new.ts".' },
242
+ overwrite: { type: 'boolean', description: 'Replace the destination if it already exists. Defaults to false.' },
243
+ },
244
+ required: ['from', 'to'],
245
+ },
246
+ },
247
+ });
248
+ TOOL_SCHEMAS.push(editToolSchema());
249
+ TOOL_SCHEMAS.push(deleteToolSchema());
250
+ // ⭐ Kills the `node -e` round tax structurally — see evaluate.mjs.
251
+ TOOL_SCHEMAS.push(evaluateToolSchema());
252
+ TOOL_SCHEMAS.push(...searchToolSchemas()); // find_files · search_code
253
+ TOOL_SCHEMAS.push(...gitToolSchemas());
254
+ TOOL_SCHEMAS.push(imageToolSchema());
255
+ /**
256
+ * ⭐ `list_engines` — WHAT AN ENGINE COSTS, WITHOUT SPENDING ONE TO FIND OUT.
257
+ * Declared unconditionally: unlike the media half it needs no endpoint of our
258
+ * hosting to be USEFUL, because "prices unavailable, and here is why" is a real
259
+ * answer that a model can act on. It is the only creative verb that asks the
260
+ * gateway for prices; the render verbs read the cache it leaves behind.
261
+ */
262
+ TOOL_SCHEMAS.push(listEnginesToolSchema());
263
+ /**
264
+ * ⭐ THE NATIVE MEDIA HALF. Declared always (so the drift guard can see them) but
265
+ * OFFERED only where the endpoint is configured — see toolNamesForRounds.
266
+ *
267
+ * ⚠️ WHAT `see_page` IS NOT. This comment used to claim it was "the one no other
268
+ * terminal agent has: every competitor writes a page and is blind to what it
269
+ * looks like". That is FALSE and was struck from README.md on 2026-08-10 for the
270
+ * same reason: Playwright MCP and Chrome DevTools MCP are free, one install
271
+ * away, and give any MCP-speaking agent a browser.
272
+ *
273
+ * ⭐ THE DEFENSIBLE CLAIM IS THE RETURN VALUE, NOT THE BROWSER. Handing a model
274
+ * the screenshot costs ~3,072 image tokens per look; `see_page` renders the page
275
+ * and returns an ~89-token verdict — 34x less for the thing the next round
276
+ * actually acts on. Anyone can take the photograph; the compression is the
277
+ * product.
278
+ */
279
+ TOOL_SCHEMAS.push(...mediaToolSchemas({
280
+ // Declaration is unconditional; the per-turn OFFER is what gates on config.
281
+ RENDER_AUDIT_URL: 'declared', MODAL_TTS_URL: 'declared',
282
+ MODAL_TRANSCRIBE_URL: 'declared', MODAL_PRESS_URL: 'declared',
283
+ // ⭐ The INPUT half — read_document · read_table. Same unconditional
284
+ // declaration for the same reason: the drift guard must not be able to see a
285
+ // different tool list on a machine that happens to have different env.
286
+ MODAL_DOC_READ_URL: 'declared', MODAL_TABLE_READ_URL: 'declared',
287
+ }));
288
+ /**
289
+ * ⭐ CHANGING a picture rather than re-rolling it — edit_image · expand_image.
290
+ * Declared unconditionally like the rest of the media half; the offer gates on
291
+ * config, and edit_image needs BOTH acuvo-select and acuvo-flux-studio.
292
+ */
293
+ TOOL_SCHEMAS.push(...imageEditToolSchemas({
294
+ MODAL_SELECT_URL: 'declared', MODAL_FLUX_URL: 'declared',
295
+ }));
296
+
297
+ /**
298
+ * ── ⭐ THE MODULES THAT WERE BUILT FOR THIS SEAM AND NEVER PLUGGED INTO IT ──
299
+ *
300
+ * Each of these shipped finished, documented and tested, exporting a
301
+ * `*ToolSchemas()` written against this exact registration point — and each was
302
+ * imported by nothing on the runtime path. A capability that no user can reach
303
+ * is not a capability; it is 7,397 lines of very well-commented dead weight.
304
+ *
305
+ * ⚠️ DECLARED UNCONDITIONALLY, EXACTLY LIKE MEDIA, AND FOR THE SAME REASON: the
306
+ * drift guard compares this list against the dispatcher's cases, and a schema
307
+ * that only exists on some machines makes that guard machine-dependent. What
308
+ * varies per machine is the OFFER, decided in `toolNamesForRounds` below.
309
+ */
310
+ TOOL_SCHEMAS.push(...planToolSchemas()); // plan_start · plan_step · plan_status
311
+ TOOL_SCHEMAS.push(...learnedToolSchemas()); // remember · forget
312
+ TOOL_SCHEMAS.push(...subagentToolSchemas()); // delegate
313
+ TOOL_SCHEMAS.push(...sessionToolSchemas()); // list_sessions
314
+ TOOL_SCHEMAS.push(...askUserToolSchemas()); // ask_user
315
+ TOOL_SCHEMAS.push(...writeManyToolSchemas()); // write_files
316
+ /**
317
+ * ⚠️ DECLARED HERE, IN THE UNCONDITIONAL BLOCK, because declaration order IS the
318
+ * prompt-cache prefix (see the note further down: moving the conditional groups
319
+ * last took the shared prefix from 69.2% to 93.3%). A new schema appended after
320
+ * a conditional group would push every later tool's identical bytes into a cold
321
+ * read whenever that group's presence changed.
322
+ */
323
+ TOOL_SCHEMAS.push(...applyPatchToolSchemas()); // apply_patch
324
+ TOOL_SCHEMAS.push(...acceptanceToolSchemas()); // declare_acceptance · check_acceptance
325
+ TOOL_SCHEMAS.push(...fetchToolSchemas()); // fetch_url
326
+ TOOL_SCHEMAS.push(...webSearchToolSchemas()); // web_search
327
+ TOOL_SCHEMAS.push(...visionToolSchemas()); // read_image
328
+ TOOL_SCHEMAS.push(...readWindowToolSchemas()); // read_lines · read_around
329
+ TOOL_SCHEMAS.push(...backgroundToolSchemas()); // start_process · check_process · stop_process
330
+ TOOL_SCHEMAS.push(...httpProbeToolSchemas()); // call_endpoint
331
+ /**
332
+ * ⚠️ `review_code` and `inspect_db` are declared UNCONDITIONALLY because they
333
+ * read what is already on disk — no endpoint of ours, no process, no key. A
334
+ * workspace with no database simply gets "no schema found", which is a real
335
+ * answer a model can act on rather than a dead button.
336
+ */
337
+ TOOL_SCHEMAS.push(...codeReviewToolSchemas()); // review_code
338
+ /**
339
+ * ⚠️ The gh and log verbs ride with `allowRun` — see `toolNamesForRounds`. gh
340
+ * spawns the `gh` binary; the log verbs can only read a process `start_process`
341
+ * started, and that is refused under `--no-run`.
342
+ */
343
+ TOOL_SCHEMAS.push(...ghToolSchemas()); // gh_issue, gh_pr, gh_run
344
+ TOOL_SCHEMAS.push(...logTailToolSchemas()); // read_log, wait_for_output, summarize_log
345
+ TOOL_SCHEMAS.push(...replToolSchemas()); // repl · repl_reset
346
+
347
+ /**
348
+ * ── ⚠️⭐ AND THE TENTH ONE, WHICH WAS LEFT OUT AS "A PRODUCT DECISION" ──────
349
+ *
350
+ * `spawn-argv.mjs` (801 lines) was the one tool-shaped orphan that the wiring
351
+ * pass deliberately skipped, on the grounds that `run_program` is a SECOND verb
352
+ * onto process spawning and someone had to decide whether this CLI should have
353
+ * two. Deciding it is this pass's job, and the decision is yes, for two reasons
354
+ * that are measurements rather than preferences.
355
+ *
356
+ * ⭐ IT IS NOT A SECOND DOOR — IT IS THE SAME DOOR WITH THE PARSER REMOVED.
357
+ * `run_command` takes a STRING and must guess, from the string alone, whether a
358
+ * quote is the model composing a second command or the model passing a value.
359
+ * It cannot tell, so it refuses the character — correctly, and that is exactly
360
+ * why the string is the wrong input. `runProgram` takes `program` + `args[]`,
361
+ * spawns with `shell: false`, and asks `command.mjs` about every pre-boundary
362
+ * flag rather than keeping a second copy of the flag lists. Same
363
+ * `ALLOWED_BINARIES` (node · npm · npx · tsc), same `buildInvocation`, same
364
+ * `spawnBounded`, same `scrubEnvironment` — plus it additionally deletes
365
+ * `NODE_OPTIONS` and `NODE_TEST_CONTEXT`, which `run_command` does not.
366
+ *
367
+ * ⚠️ AND IT IS A STRICT SUBSET OF THE ALLOWLIST, NEVER A WIDENING.
368
+ * `.acuvo/commands.json` may only ADD presets (`parseCommandsConfig` refuses
369
+ * anything else), so the four fixed binaries here can never exceed what
370
+ * `run_command` would have permitted on the same machine. The one real
371
+ * asymmetry is the other way: a user who enabled the `python` preset reaches it
372
+ * through `run_command` only, and that is stated in the README.
373
+ *
374
+ * ⚠️ WHAT ITS ABSENCE COST, from spawn-argv.mjs's own measured header: three
375
+ * probe runs hit the string wall and two SHIPPED A WRONG ARTIFACT because of
376
+ * it — `node bin/todo.js add "buy milk"`, `node bin/todo.js list --all` and
377
+ * `node --test test/*.test.mjs` were all refused, so the agent could never
378
+ * execute the code paths it had just written and documented what it imagined
379
+ * the output was instead. That is the single most expensive failure this
380
+ * package has, and the fix was sitting in the tree unimported.
381
+ */
382
+ TOOL_SCHEMAS.push(...spawnArgvToolSchemas()); // run_program
383
+
384
+ /**
385
+ * ── ⭐⭐⭐ DECLARATION ORDER IS THE PROMPT-CACHE PREFIX ─────────────────────
386
+ *
387
+ * `toolSchemasFor` returns `TOOL_SCHEMAS.filter(...)`, so the WIRE ORDER is
388
+ * the order of these pushes, not the order the caller asked for. Every tool
389
+ * declared AFTER a conditional group is re-sent cold whenever that group's
390
+ * presence changes, even though its bytes are identical.
391
+ *
392
+ * ⚠️ MEASURED 2026-08-20 between two real project shapes — one plain, one with
393
+ * a migrations directory:
394
+ *
395
+ * conditional groups mid-list (before) 69.2% shared prefix (34,561 B)
396
+ * conditional groups LAST (now) 93.3% shared prefix (46,608 B)
397
+ *
398
+ * ⭐ ~12,000 bytes — roughly 3,000 tokens — that a user switching between
399
+ * project shapes was paying for at cold-read prices on every first round.
400
+ *
401
+ * ⚠️ AND IT ONLY WORKS BECAUSE THE SYSTEM PROMPT DOES NOT VARY. Measured the
402
+ * same day: `systemPrompt` is byte-identical across both shapes (3,861 chars),
403
+ * so the tool block really is where divergence begins. If the prompt ever
404
+ * starts carrying project detail, it moves in front of this and the ordering
405
+ * below stops buying anything — check that before trusting these numbers.
406
+ *
407
+ * ⭐ This is the same lever recorded in `project_acuvo_byte_order_is_the_cache
408
+ * _lever` (25.8% -> 95.6% by moving one line): put the stable bytes first and
409
+ * everything that varies last.
410
+ */
411
+ TOOL_SCHEMAS.push(...skillsToolSchemas()); // read_skill — present only when the project HAS skills
412
+ TOOL_SCHEMAS.push(...lspToolSchemas()); // find_definition · find_references · check_types · list_symbols — needs a language server
413
+ TOOL_SCHEMAS.push(...dbToolSchemas()); // inspect_db, sample_db_rows — needs schema evidence
414
+
415
+ export const TOOL_NAMES = TOOL_SCHEMAS.map((t) => t.function.name);
416
+
417
+ /**
418
+ * ── ⚠️ WHAT A SINGLE-SHOT TURN IS ALLOWED TO OFFER THE MODEL ───────────────
419
+ *
420
+ * MEASURED, three live runs against `deepseek/deepseek-v3.2`, 2026-08-09, all
421
+ * three tools declared and the system prompt explicitly saying reads cannot
422
+ * reach it this turn:
423
+ *
424
+ * run 1 "let me check the version.js file" → 1 read_file, 0 writes
425
+ * run 2 (contents now pre-loaded) → 1 write_file ✓
426
+ * run 3 "let me check the directory structure" → 1 list_dir, 0 writes
427
+ *
428
+ * Two of three turns were spent fetching context the CLI had ALREADY put in the
429
+ * prompt. That is not a prompt-wording problem — coder models are trained on
430
+ * agentic loops and reach for the tools they can see, and no amount of shouting
431
+ * in a system prompt outranks a tool definition sitting in the payload.
432
+ *
433
+ * ⭐ AND THE DEEPER POINT IS THIS REPO'S OWN RULE: a control that presents
434
+ * itself and does nothing is worse than one that is absent. In a turn with no
435
+ * second round, a `read_file` result has nowhere to go — so declaring it is a
436
+ * DEAD BUTTON, and the model pressing it is the predictable consequence rather
437
+ * than a surprise.
438
+ *
439
+ * ⚠️ THE CAPABILITY IS NOT REMOVED, ONLY THE OFFER. `read_file` and `list_dir`
440
+ * are implemented, dispatched, tested, and used every single run — the CLI's own
441
+ * `gatherWorkspaceContext` reads the tree and the small files THROUGH THIS SAME
442
+ * EXECUTOR before the model is asked anything. What changes here is who gets to
443
+ * call them: the deterministic gather, not the model. When the multi-round turn
444
+ * lands, it passes `TOOL_SCHEMAS` instead of this and the reads become live in
445
+ * one line.
446
+ */
447
+ /**
448
+ * ⭐ `write_files` IS HERE FOR THE SAME REASON `write_file` IS: its result lands
449
+ * on disk, so it has somewhere to go even with no second round. "Create these
450
+ * five files" is an ordinary one-round request, and withholding the plural form
451
+ * would push it back into `evaluate`, which is exactly where a bulk write is
452
+ * invisible to the leases and to the change count.
453
+ */
454
+ export const SINGLE_SHOT_TOOL_NAMES = ['write_file', 'write_files'];
455
+
456
+ /**
457
+ * ── ⭐ AND WHAT A MULTI-ROUND SESSION OFFERS — THE SAME RULE, INVERTED ──────
458
+ *
459
+ * The note above says a read tool is a DEAD BUTTON when its result has nowhere
460
+ * to go. The corollary is that the moment a second round exists, the button is
461
+ * live and withholding it is the defect: the loop's whole premise is that the
462
+ * model sees what happened and reacts, and "what happened" includes the file it
463
+ * needed that was too large for the deterministic gather.
464
+ *
465
+ * So the offer is not a fixed list, it is a FUNCTION OF THE ROUND BUDGET. One
466
+ * round → write only. More than one → everything, because everything can now
467
+ * come back. `run_command` is the reason the loop exists at all and is the one
468
+ * entry here that can execute code; `--no-run` withholds it without collapsing
469
+ * the loop, which is the honest middle setting for a task you have not read yet.
470
+ */
471
+ /**
472
+ * ── ⚠️ GATE ON AVAILABILITY, NOT ON PRESENCE ────────────────────────────────
473
+ *
474
+ * `imageConfig(env).configured` is the precedent and it is a good one: the file
475
+ * that owns a capability owns the question "does it exist here", and the offer
476
+ * asks it rather than assuming. These two do the same for the two capabilities
477
+ * whose dependency lives OUTSIDE this package — a directory the user wrote, and
478
+ * a language server someone installed.
479
+ *
480
+ * ⚠️ NEVER THROWS, BY CONSTRUCTION AND THEN AGAIN BY CATCH. Both callees
481
+ * document that they never throw ("not installed" is the expected answer). The
482
+ * try/catch is not distrust of them, it is the rule that computing the OFFER can
483
+ * never be what kills a run: an unreadable directory on a locked-down machine
484
+ * must cost the user one tool, not the whole session.
485
+ */
486
+ export function skillsAvailable(root) {
487
+ if (typeof root !== 'string' || root === '' || root === MEMORY_ROOT) return false;
488
+ try {
489
+ const found = discoverAllSkills(root);
490
+ return found.ok === true && found.skills.length > 0;
491
+ } catch {
492
+ return false;
493
+ }
494
+ }
495
+
496
+ /**
497
+ * ── ⚠️⚠️ AN INSTALLED SERVER IS NOT ENOUGH. THE PROJECT HAS TO SPEAK IT. ─────
498
+ *
499
+ * The first version of this gate asked one question — is ANY language server
500
+ * installed — and it shipped the exact dead button it was written to prevent.
501
+ * MEASURED ON THIS MACHINE, 2026-08-11, integrating the four lanes: this
502
+ * package is zero-dependency JavaScript, `typescript-language-server` is not
503
+ * installed, and `find_definition` / `find_references` / `check_types` /
504
+ * `list_symbols` were all offered anyway — because `rust-analyzer` happens to
505
+ * sit in `~/.cargo/bin` from unrelated work. Every one of those tools, called
506
+ * on any file in this repo, can only answer "typescript-language-server is not
507
+ * installed". Four buttons, none of them wired to anything reachable.
508
+ *
509
+ * ⭐ THE GATE IS THE INTERSECTION: a server that is installed AND a language
510
+ * this workspace actually contains. Both halves are necessary and neither is
511
+ * sufficient — a Rust repo on a machine with only pyright is the same dead
512
+ * button seen from the other side.
513
+ *
514
+ * ⚠️ AND IT IS STILL "ANY MATCH", NOT "EVERY MATCH". A polyglot repo with Go
515
+ * and TypeScript and only `gopls` installed keeps the four tools, because they
516
+ * are per-FILE and `.go` files are genuinely served. Withholding a working
517
+ * capability because a SECOND language is unserved would be the opposite error,
518
+ * and the not-installed path returns lsp.mjs's own install instruction, which
519
+ * is a good answer to get for the one file that cannot be served.
520
+ *
521
+ * Pure `existsSync` / PATH probing plus at most two shallow `readdir`s. No
522
+ * process is spawned, and nothing recurses into the tree.
523
+ */
524
+ /**
525
+ * ── ⚠️⚠️ THREE TOOLS WERE DECLARED AND NEVER NAMED ─────────────────────────
526
+ *
527
+ * Measured 2026-08-20: `TOOL_SCHEMAS` declares 63 tools and
528
+ * `toolNamesForRounds` offered 47 at every round budget. Sixteen were absent,
529
+ * and thirteen of those are honestly environment-gated — LSP, the media
530
+ * secret, an explicit render URL, the git-push opt-in.
531
+ *
532
+ * ⭐ THREE WERE NOT GATED ON ANYTHING. `review_code`, `inspect_db` and
533
+ * `sample_db_rows` are declared UNCONDITIONALLY, directly above a comment
534
+ * saying why: *"they read what is already on disk — no endpoint of ours, no
535
+ * process, no key."* Nothing ever put their names in the list, so the model
536
+ * could not call them.
537
+ *
538
+ * ⚠️ AND ONE OF THEM IS ADVERTISED TO THE USER. `code-review.mjs` prints
539
+ * *"run `review_code` on the file for the full list"* — a hint pointing at a
540
+ * verb the model has never been offered.
541
+ *
542
+ * ⚠️⚠️ AND THE GATE HAS TO BE CHEAP. My first version called
543
+ * `readSchemaFromWorkspace`, which recursively globs every .sql file in the
544
+ * tree and PARSES what it finds: **478ms per call**, once per turn. It took
545
+ * the CLI suite from 111s to 285s with one run cancelled. A gate costing half
546
+ * a second to decide whether to offer a tool is worse than a missing tool.
547
+ *
548
+ * ⭐ So it probes a handful of CONVENTIONAL locations with `existsSync` and
549
+ * memoises per root. The trade is stated rather than hidden: a project keeping
550
+ * SQL somewhere unconventional will not be offered the tools. That is a miss,
551
+ * not a break — the tools are an offer, and 478ms of every turn is not payable.
552
+ *
553
+ * Verified working before wiring: `inspect_db` returned `ok:true` with real
554
+ * tables read off disk, and `review_code` found an `eval-non-literal` with
555
+ * severity, confidence and a reason.
556
+ *
557
+ * ⚠️ THEY ARE NOT FREE — 418 + 535 + 300 tokens against an 11,235-token
558
+ * surface, on every turn. So the DB pair follows the pattern this file already
559
+ * uses for skills and LSP: offer it where there is evidence it can answer.
560
+ * `review_code` rides always; it applies to any source file, and 418 tokens is
561
+ * the cheapest of the three.
562
+ */
563
+ const DB_EVIDENCE_PATHS = Object.freeze([
564
+ 'prisma/schema.prisma',
565
+ 'supabase/schema.sql', 'supabase/migrations',
566
+ 'migrations', 'db/migrations', 'database/migrations', 'drizzle',
567
+ 'schema.sql', 'db/schema.sql',
568
+ ]);
569
+ const DB_EVIDENCE_ENV = Object.freeze(['DATABASE_URL', 'POSTGRES_URL', 'SUPABASE_DB_URL', 'MYSQL_URL']);
570
+ /** Memoised per root: the answer cannot change inside one turn. */
571
+ const dbEvidenceCache = new Map();
572
+
573
+ export function dbEvidence(root, env = process.env) {
574
+ if (typeof root !== 'string' || root === '' || root === MEMORY_ROOT) return false;
575
+ for (const key of DB_EVIDENCE_ENV) if ((env[key] ?? '').trim()) return true;
576
+ const cached = dbEvidenceCache.get(root);
577
+ if (cached !== undefined) return cached;
578
+ let found = false;
579
+ for (const rel of DB_EVIDENCE_PATHS) {
580
+ try { if (existsSync(join(root, rel))) { found = true; break; } } catch { /* unreadable is not evidence */ }
581
+ }
582
+ dbEvidenceCache.set(root, found);
583
+ return found;
584
+ }
585
+
586
+ export function lspAvailable(root, env = process.env) {
587
+ if (typeof root !== 'string' || root === '' || root === MEMORY_ROOT) return false;
588
+ try {
589
+ const present = languagesPresent(root);
590
+ if (present.size === 0) return false;
591
+ for (const language of Object.keys(LANGUAGE_SERVERS)) {
592
+ if (!present.has(language)) continue;
593
+ if (discoverLanguageServer(root, language, { env }).ok === true) return true;
594
+ }
595
+ } catch {
596
+ return false;
597
+ }
598
+ return false;
599
+ }
600
+
601
+ /**
602
+ * Which of the four languages this workspace visibly contains.
603
+ *
604
+ * ⚠️ CHEAP BY CONSTRUCTION, because it runs on the offer path of every
605
+ * multi-round session. One `readdir` of the root, plus one of each of up to
606
+ * `LANG_PROBE_DIRS` first-level directories. It never recurses, never reads a
607
+ * file, and never spawns anything — a project whose only Python lives four
608
+ * levels down is a MISS, and a miss costs four tools rather than correctness.
609
+ *
610
+ * ⭐ TWO SIGNALS, BOTH CHEAP AND EITHER SUFFICES. A manifest (`Cargo.toml`,
611
+ * `go.mod`, `package.json`, `pyproject.toml`) is the strong one and catches the
612
+ * monorepo whose source is all in `crates/` or `src/`. An extension seen in the
613
+ * shallow walk is the weak one and catches the script folder with no manifest
614
+ * at all. Requiring both would fail the common cases in opposite directions.
615
+ */
616
+ const LANG_PROBE_DIRS = 12;
617
+ const LANG_PROBE_SKIP = new Set(['node_modules', '.git', 'dist', 'build', 'out', 'target', 'vendor', '.next', 'coverage', '__pycache__']);
618
+ const LANG_MANIFESTS = {
619
+ typescript: ['package.json', 'tsconfig.json', 'jsconfig.json', 'deno.json'],
620
+ python: ['pyproject.toml', 'requirements.txt', 'setup.py', 'setup.cfg', 'Pipfile'],
621
+ rust: ['Cargo.toml'],
622
+ go: ['go.mod', 'go.work'],
623
+ };
624
+ /** extension → language, built from the registry so the two can never disagree. */
625
+ const LANG_BY_EXTENSION = (() => {
626
+ const map = new Map();
627
+ for (const [language, spec] of Object.entries(LANGUAGE_SERVERS)) {
628
+ for (const ext of Object.keys(spec.extensions ?? {})) map.set(ext.toLowerCase(), language);
629
+ }
630
+ return map;
631
+ })();
632
+
633
+ export function languagesPresent(root) {
634
+ const found = new Set();
635
+ const note = (name) => {
636
+ for (const [language, manifests] of Object.entries(LANG_MANIFESTS)) {
637
+ if (manifests.includes(name)) found.add(language);
638
+ }
639
+ const dot = name.lastIndexOf('.');
640
+ if (dot > 0) {
641
+ const language = LANG_BY_EXTENSION.get(name.slice(dot).toLowerCase());
642
+ if (language) found.add(language);
643
+ }
644
+ };
645
+
646
+ let entries;
647
+ try {
648
+ entries = readdirSync(root, { withFileTypes: true });
649
+ } catch {
650
+ return found;
651
+ }
652
+ const dirs = [];
653
+ for (const e of entries) {
654
+ if (e.isDirectory()) {
655
+ if (!e.name.startsWith('.') && !LANG_PROBE_SKIP.has(e.name) && dirs.length < LANG_PROBE_DIRS) dirs.push(e.name);
656
+ continue;
657
+ }
658
+ note(e.name);
659
+ }
660
+ for (const dir of dirs) {
661
+ // A manifest one level down counts too: `packages/api/package.json` and
662
+ // `crates/core/Cargo.toml` are how real repositories are shaped.
663
+ let children;
664
+ try {
665
+ children = readdirSync(join(root, dir), { withFileTypes: true });
666
+ } catch {
667
+ continue;
668
+ }
669
+ for (const c of children) {
670
+ if (!c.isDirectory()) note(c.name);
671
+ }
672
+ }
673
+ return found;
674
+ }
675
+
676
+ /**
677
+ * @param {number} maxRounds
678
+ * @param {{ allowRun?: boolean, env?: Record<string, any>, root?: string }} [opts]
679
+ *
680
+ * ⚠️ `root` DEFAULTS TO `process.cwd()` AND THAT IS A KNOWN SEAM, not a
681
+ * preference. `bin/acuvo.mjs` computes `resolve(opts.dir ?? process.cwd())`, so
682
+ * for every run without `--dir` the default is exactly right; with `--dir` it
683
+ * probes the wrong tree until `turn.mjs` passes `root: executor.root` (one word,
684
+ * another lane's file). The failure it can produce is small and one-directional
685
+ * — `read_skill` offered in a `--dir` run because the CURRENT directory has
686
+ * skills — and it is stated here rather than left to be discovered.
687
+ */
688
+ export function toolNamesForRounds(maxRounds, { allowRun = true, env = process.env, root = process.cwd(), subagent = false, interactive = false } = {}) {
689
+ /**
690
+ * ── ⭐ `generate_image` IS OFFERED IN BOTH SHAPES, AND ONLY WHEN IT EXISTS ──
691
+ * It is available even in a single-shot run — "build me a landing page with a
692
+ * hero image" is a complete, one-round request and withholding the image would
693
+ * make the answer worse for no reason. It writes a file, so unlike a read tool
694
+ * its result has somewhere to go even with no second round.
695
+ *
696
+ * ⚠️ IT IS NOT "the one capability no other coding agent has" — that claim
697
+ * stood here until 2026-08-11 and it was never true. An MCP-speaking agent is
698
+ * one `npx` away from an image server, the same way it is one away from a
699
+ * browser. What is ours is that it needs NO key, NO account and NO config
700
+ * (see the default below), and that the result lands as a file in the
701
+ * workspace rather than as a URL the model has to describe.
702
+ *
703
+ * ⚠️ GATED ON THE SERVICE BEING CONFIGURED — but read what "configured" means
704
+ * before trusting this comment, because its previous version was FALSE and the
705
+ * README repeated the falsehood.
706
+ *
707
+ * `imageConfig` defaults to an XXIautomate-hosted endpoint when the variable is
708
+ * UNSET, so on a bare machine `configured` is TRUE and the tool IS offered.
709
+ * That is deliberate — a capability you must discover and configure is one most
710
+ * people never see — but it means every installed copy can send a prompt to our
711
+ * infrastructure, which is a disclosure obligation and not an implementation
712
+ * detail. It is now stated outright in README.md under "generate_image is
713
+ * different".
714
+ *
715
+ * ⭐ The gate that still bites: `PERCHANCE_IMAGE_URL=` (explicitly empty) means
716
+ * OFF, and then the model is never told the capability exists — because
717
+ * offering a tool that can only return "no image service is set up" teaches it
718
+ * to try, wait, and apologise, which is a dead button by another name.
719
+ */
720
+ const withImage = (names) => (imageConfig(env).configured ? [...names, 'generate_image'] : names);
721
+
722
+ if (maxRounds <= 1) return withImage([...SINGLE_SHOT_TOOL_NAMES]);
723
+ /**
724
+ * ⭐ SEARCH IS IN THE MULTI-ROUND OFFER AND NOT THE SINGLE-SHOT ONE, for the
725
+ * same reason the read tools are not: a search result has nowhere to go when
726
+ * the turn ends immediately after it. With a second round it becomes the most
727
+ * valuable tool here — it is what turns "writes files" into "works in your
728
+ * codebase", because a model that cannot find a function will invent a
729
+ * plausible file and write over the wrong one.
730
+ */
731
+ /**
732
+ * ⭐ `edit_file` sits beside write_file, and the ORDER here is a hint the model
733
+ * reads: edit before write, because for a file that already exists write_file
734
+ * is a destructive operation wearing the costume of an edit.
735
+ */
736
+ /**
737
+ * ⭐ `delete_file` IS HERE BECAUSE ITS ABSENCE CAPTURED A WHOLE SESSION.
738
+ * Measured 2026-08-09: with no delete verb the model wrote 0 bytes over its
739
+ * scratch file, then spent every remaining round trying to remove it and
740
+ * never reached the commit it had been asked for. See delete.mjs.
741
+ */
742
+ const names = ['read_file', 'write_file', 'edit_file', 'delete_file', 'move_file', 'list_dir', 'find_files', 'search_text'];
743
+ /**
744
+ * ⭐ `run_program` SITS IMMEDIATELY AFTER `run_command`, AND THE ORDER IS THE
745
+ * HINT — the same reason `edit_file` sits before `write_file`. A model reading
746
+ * the offer top-down meets the string runner first (right for `npm test`) and
747
+ * the argv runner second (right the moment an argument has a space, a quote or
748
+ * a leading dash). Both are gated on `allowRun`: it spawns a process, so
749
+ * `--no-run` must withhold it or the flag is a lie by a side door.
750
+ */
751
+ if (allowRun) names.push('run_command', 'run_program', 'evaluate');
752
+ /**
753
+ * ── ⭐⭐ BACKGROUND — MULTI-ROUND ONLY, AND GATED ON `allowRun` ────────────
754
+ *
755
+ * Multi-round because the whole shape is "start it now, look at it later": in
756
+ * a single-shot turn there is no later, and a server started in the last
757
+ * round of a run is killed by the teardown before anything can use it — the
758
+ * dead button this file refuses to ship, in its most expensive form.
759
+ *
760
+ * ⚠️ And it spawns a process, so `--no-run` must withhold it or the flag is a
761
+ * lie by a side door — the same rule `run_program` and `evaluate` obey.
762
+ */
763
+ if (allowRun) names.push(...BACKGROUND_TOOL_NAMES);
764
+ /**
765
+ * ── ⭐⭐ AND THE VERB THAT MAKES A STARTED SERVER WORTH STARTING ───────────
766
+ *
767
+ * `start_process` gave the agent a dev server it could not talk to.
768
+ * `check_process` answers one question — "is anything listening" — and
769
+ * `fetch_url` refuses loopback by design, so the agent could build an API and
770
+ * never once call it. `POST /users` returning 201 is the difference between
771
+ * "wrote a route" and "the route works".
772
+ *
773
+ * ⚠️ TIED TO `allowRun`, AND TIED TO `start_process` SPECIFICALLY. It can only
774
+ * reach a port a process from THIS run started and the OS has confirmed that
775
+ * process holds (`portVerified`); with `--no-run` there are no such processes,
776
+ * so listing it would be a button that can only ever refuse.
777
+ *
778
+ * ⚠️ AND MULTI-ROUND FOR THE SAME REASON AS THE SERVER IT CALLS: in a
779
+ * single-shot turn there is no round in which the server is already up.
780
+ */
781
+ if (allowRun) names.push(...HTTP_PROBE_TOOL_NAMES);
782
+ // ⚠️ Same rule, same reason: gh spawns a binary, and the log verbs can only
783
+ // read a process `start_process` started — which --no-run refuses.
784
+ if (allowRun) names.push('gh_issue', 'gh_pr', 'gh_run', 'read_log', 'wait_for_output', 'summarize_log');
785
+ /**
786
+ * ── ⭐⭐ THE REPL — MULTI-ROUND ONLY, AND OBVIOUSLY SO ─────────────────────
787
+ *
788
+ * Its whole value is that call N+1 sees what call N defined. In a single-shot
789
+ * turn there is no call N+1, so it is the dead button this file refuses to
790
+ * ship — and worse than most, because it would spend the only round starting a
791
+ * process instead of doing the task.
792
+ *
793
+ * ⚠️ `allowRun` because it executes the user's JavaScript for real.
794
+ */
795
+ if (allowRun) names.push(...REPL_TOOL_NAMES);
796
+ /**
797
+ * ── ⭐ GIT IS MULTI-ROUND ONLY, AND `git_commit` IS GATED ON `allowRun` ─────
798
+ *
799
+ * `git_status` and `git_diff` are reads: their result has nowhere to go in a
800
+ * single-shot turn, so offering them there would be the dead button this file
801
+ * already refuses to ship. With a second round they are the most valuable
802
+ * reads here — a diff is how the model checks its own edit landed, which is
803
+ * the one verification it currently cannot perform without running code.
804
+ *
805
+ * ⚠️ AND COMMIT RIDES WITH `--no-run`, WHICH IS NOT AN OBVIOUS PAIRING. The
806
+ * flag reads as "do not execute anything", and a user who passes it is saying
807
+ * they have not read the task yet. A commit is not code execution, but it IS
808
+ * the one irreversible-looking thing in the package — a wrong commit is
809
+ * recoverable and does not feel it. So the cautious flag withholds the
810
+ * cautious verb, and reading the repo stays available either way.
811
+ */
812
+ // ⭐ Media joins the multi-round offer, gated on real configuration. A tool
813
+ // whose service is absent is never mentioned — the model must not spend a
814
+ // round discovering what the schema could have told it for free.
815
+ names.push(...mediaToolNames(env));
816
+ // ⚠️ MULTI-ROUND ONLY, and not for the usual "a read has nowhere to go"
817
+ // reason: an edit is a 3-second-to-6-minute GPU job whose whole point is that
818
+ // the model then LOOKS at the result. Offering it on a single-shot turn buys
819
+ // the render and throws away the check.
820
+ names.push(...imageEditToolNames(env));
821
+ names.push('git_status', 'git_diff', 'git_log');
822
+ if (allowRun) names.push('git_commit');
823
+ /**
824
+ * ⭐ `git_branch` RIDES WITH COMMIT, and for the weaker half of the same
825
+ * reason. It executes git and changes repository state, so `--no-run`
826
+ * withholds it — but on its own it touches no remote and destroys nothing,
827
+ * which is why it needs no gate beyond that. Without it the agent could
828
+ * commit and had no way to keep the commit off the branch it started on.
829
+ */
830
+ if (allowRun) names.push('git_branch');
831
+ /**
832
+ * ── ⚠️⚠️ PUSH IS OFF UNLESS AN OPERATOR NAMED IT ──────────────────────────
833
+ * `ACUVO_ALLOW_PUSH=1`, checked in `git.mjs`. This is the same shape as the
834
+ * media tools directly above — a capability whose configuration is absent is
835
+ * never mentioned — and it is deliberately a SECOND gate on top of
836
+ * `allowRun`, because push is the only verb in the package whose effect is
837
+ * visible to people who are not at this keyboard.
838
+ *
839
+ * ⭐ AND IT COSTS ZERO TOKENS WHEN OFF. The schema exists in the registry;
840
+ * `toolSchemasFor` only serialises the names in this list.
841
+ */
842
+ names.push(...gitPushToolNames(env, { allowRun }));
843
+
844
+ /**
845
+ * ── ⭐ THE WINDOWED READS — MULTI-ROUND ONLY, LIKE EVERY OTHER READ ────────
846
+ * `read_lines` and `read_around` are `read_file` with an honest truncation
847
+ * story: read_file cuts the MIDDLE out of a large file and says nothing the
848
+ * model can act on, these cut the END and hand back `nextOffset`. Same
849
+ * dead-button rule as read_file, so the same placement — a window of a file
850
+ * has nowhere to go when the turn ends immediately after it.
851
+ */
852
+ names.push('read_lines', 'read_around');
853
+
854
+ /**
855
+ * ── ⭐ `list_engines` — MULTI-ROUND ONLY, AND FOR THE STRONGEST VERSION OF
856
+ * THE DEAD-BUTTON RULE ────────────────────────────────────────────────────
857
+ *
858
+ * Its answer exists to change the NEXT call — "ultra costs 48 credits an
859
+ * image, the core one costs 4, which do you want". In a single-shot turn
860
+ * there is no next call, so it would burn the only round finding out a price
861
+ * it can never use.
862
+ *
863
+ * ⚠️ NOT GATED ON `imageConfig`. It answers a question about the ACCOUNT, not
864
+ * about whether a render endpoint is configured on this machine — and "what
865
+ * would this cost me" is a fair question to ask before setting anything up.
866
+ */
867
+ names.push('list_engines');
868
+
869
+ /**
870
+ * ── ⭐ THE PLAN LEDGER — MULTI-ROUND ONLY, AND OBVIOUSLY SO ────────────────
871
+ * Its entire value is the banner on every LATER tool result: "2/5 done, 3
872
+ * rounds left". With one round there is no later, so `plan_start` in a
873
+ * single-shot turn is a file written for a reader who never arrives — the
874
+ * dead button in its purest form.
875
+ */
876
+ names.push('plan_start', 'plan_step', 'plan_status');
877
+
878
+ /**
879
+ * ── ⭐ SESSIONS — READ-ONLY, MULTI-ROUND ONLY ─────────────────────────────
880
+ * `list_sessions` cannot resume anything (resume is an operator action, from
881
+ * the command line, between runs — see session.mjs's header on replayed side
882
+ * effects). It is a read, and reads need a next round.
883
+ */
884
+ names.push('list_sessions');
885
+
886
+ /**
887
+ * ── ⭐ FETCH — MULTI-ROUND ONLY ───────────────────────────────────────────
888
+ * A page of documentation is context for the NEXT decision. Fetched in a turn
889
+ * with no next decision it is a paid round that changes nothing.
890
+ * Not gated on configuration because there is none: GET only, no headers,
891
+ * private and loopback addresses refused, 10 fetches per run. It either
892
+ * reaches the internet or returns a sentence saying it could not.
893
+ */
894
+ names.push('fetch_url');
895
+
896
+ /**
897
+ * ── ⭐⭐ SEARCH — MULTI-ROUND ONLY, FOR THE SAME REASON ────────────────────
898
+ *
899
+ * `fetch_url` could read a page it was TOLD about; it could not FIND one. A
900
+ * search result is not an answer, it is a pointer to the round that reads it,
901
+ * so in a single-round turn it is a paid call that changes nothing.
902
+ *
903
+ * ⭐ AND IT IS THE HALF THAT STOPS THE GUESSING. A model that cannot look up
904
+ * an option name invents one, confidently, and the invention compiles.
905
+ * Keyless: DuckDuckGo plus the StackOverflow API, capped per run.
906
+ */
907
+ names.push('web_search');
908
+
909
+ /**
910
+ * ── ⭐⭐ EYES — MULTI-ROUND ONLY, AND FOR A SHARPER REASON THAN THE OTHERS ──
911
+ *
912
+ * Looking is only worth paying for if there is a round left to ACT on what
913
+ * was seen. A single-round turn that renders something, looks at it, and then
914
+ * stops has bought a description nobody can use.
915
+ */
916
+ names.push('read_image');
917
+
918
+ /**
919
+ * ── ⚠️ ACCEPTANCE RIDES WITH `allowRun`, AND BOTH VERBS TOGETHER ──────────
920
+ *
921
+ * `check_acceptance` EXECUTES COMMANDS — it takes a runner and runs every
922
+ * declared criterion. Offering it under `--no-run` would make that flag a lie
923
+ * by a side door, exactly as `evaluate` would (see the test that pins it).
924
+ *
925
+ * ⭐ And `declare_acceptance` goes with it rather than staying behind. Alone
926
+ * it is a promise nothing can keep: the model records "npm test must pass",
927
+ * no round can ever run it, and the run ends having declared a criterion it
928
+ * never checked — which reads as verification and is not. Two halves of one
929
+ * capability; neither is worth offering without the other.
930
+ */
931
+ if (allowRun) names.push('declare_acceptance', 'check_acceptance');
932
+
933
+ /**
934
+ * ── ⭐ SKILLS — GATED ON THE DIRECTORY EXISTING AND HAVING SOMETHING IN IT ─
935
+ * skills.mjs states the rule itself: "a read_skill in a project with no skills
936
+ * is a dead button". Most projects have none, so this is the common case and
937
+ * the tool is usually absent — correctly. There is no `list_skills` because
938
+ * the catalogue belongs in the system prompt (turn.mjs's job, not this file's).
939
+ */
940
+ /**
941
+ * ⭐ DECLARED SINCE FOREVER, NAMED SINCE 2026-08-20. `review_code` reads a
942
+ * file already on disk — no key, no endpoint, no process — and `code-review
943
+ * .mjs` has been telling users to "run `review_code` on the file" the whole
944
+ * time. 418 tokens.
945
+ */
946
+ names.push('review_code');
947
+ /**
948
+ * ⚠️ THE DB PAIR IS GATED ON EVIDENCE, not offered blindly: 835 tokens on
949
+ * every turn is real money in a package whose binding constraint is the token
950
+ * budget. Same shape as `skillsAvailable` and `lspAvailable` above.
951
+ */
952
+ if (dbEvidence(root, env)) names.push('inspect_db', 'sample_db_rows');
953
+ if (skillsAvailable(root)) names.push('read_skill');
954
+
955
+ /**
956
+ * ── ⭐⭐ ASK_USER — OFFERED ONLY WHEN THERE IS SOMEBODY TO ASK ─────────────
957
+ *
958
+ * ⚠️ ABSENCE, NOT REFUSAL. `prompt.mjs`'s `createAsker` returns null unless
959
+ * stdin and stdout are BOTH terminals, and that null arrives here as
960
+ * `interactive: false`. In CI, a pipe or a task runner the tool is simply not
961
+ * in the list — which is stronger than a tool that is offered and always
962
+ * refuses, because a schema costs tokens every single round and invites the
963
+ * model to spend one discovering the button is dead. Same rule the file
964
+ * already applies to `read_skill` in a project with no skills.
965
+ *
966
+ * ⚠️ MULTI-ROUND ONLY, for this file's standing reason: in a single-shot turn
967
+ * the answer arrives as a tool result with nowhere to go, and the round it
968
+ * costs would be the only round there was. An agent that spends its one round
969
+ * asking a question it can no longer act on is strictly worse than one that
970
+ * guessed.
971
+ */
972
+ if (interactive) names.push('ask_user');
973
+
974
+ /**
975
+ * ⭐ `write_files` RIDES WITH THE WRITE CAPABILITY, not with a round budget.
976
+ * It is `write_file` for more than one file; anywhere the model may write, it
977
+ * may write several. Offering it only in long runs would leave the bulk edit
978
+ * exactly where it was — inside `evaluate`, where nothing can see it.
979
+ */
980
+ names.push('write_files');
981
+
982
+ /**
983
+ * ── ⭐⭐ `apply_patch` — MULTI-ROUND ONLY, AND THAT IS NOT AN OVERSIGHT ─────
984
+ *
985
+ * ⭐ WHY IT IS OFFERED AT ALL: output is 56% of a build's spend ($0.045 of
986
+ * $0.080, ~53,000 tokens) and a prompt cache — already at 83.2% and 100%
987
+ * steady-state — cannot discount output at all. The output is dominated by
988
+ * re-emitting whole files, and a patch is 10-50x smaller. It is also the
989
+ * accuracy fix: flexible patch application measures 9x fewer editing errors.
990
+ *
991
+ * ⚠️ WHY NOT IN `SINGLE_SHOT_TOOL_NAMES`: a patch's context lines must match
992
+ * the file ON DISK, and when they do not the only repair is the next round.
993
+ * A one-round turn has none, so it would be precisely the dead button this
994
+ * file spends four hundred lines refusing to ship — and `write_file` still
995
+ * works there, so withholding it costs the user nothing.
996
+ */
997
+ names.push('apply_patch');
998
+
999
+ /**
1000
+ * ── ⭐ REMEMBER / FORGET — ALWAYS OFFERED IN A MULTI-ROUND TURN ────────────
1001
+ *
1002
+ * Unlike `read_skill` there is nothing to gate on: an empty memory is the
1003
+ * NORMAL starting state and the whole point is that the agent fills it. A
1004
+ * project with no learned facts is exactly where remembering the first one
1005
+ * matters most.
1006
+ *
1007
+ * ⚠️ MULTI-ROUND ONLY, for this file's standing reason. In a single-shot turn
1008
+ * the run ends before anything could act on what was recorded, so `remember`
1009
+ * would be a button whose result has nowhere to go — and worse, it would spend
1010
+ * the one round on bookkeeping instead of the task.
1011
+ *
1012
+ * ⚠️ `forget` RIDES WITH IT and is not optional. A wrong memory is worse than
1013
+ * no memory, and shipping the write verb without the correction verb means the
1014
+ * only way to fix a bad fact is to edit a file by hand.
1015
+ */
1016
+ names.push('remember', 'forget');
1017
+
1018
+ /**
1019
+ * ── ⭐ DELEGATE — MULTI-ROUND ONLY, AND NEVER TO A SUBAGENT ────────────────
1020
+ *
1021
+ * ⚠️ A helper's answer arrives as a tool result, so in a single-round turn it
1022
+ * has nowhere to go — the dead button this file refuses to ship. And the
1023
+ * round it costs would be the only round there was.
1024
+ *
1025
+ * ⚠️ `subagent: true` REMOVES IT ENTIRELY. `SUBAGENT_TOOL_NAMES` already omits
1026
+ * it, so this is the second lock rather than the only one: the offer a
1027
+ * subagent computes and the list it is handed must agree, or a future refactor
1028
+ * that starts calling this function for helpers quietly reopens recursion.
1029
+ */
1030
+ if (!subagent) names.push('delegate');
1031
+
1032
+ /**
1033
+ * ── ⭐ LSP — GATED ON A LANGUAGE SERVER BEING INSTALLED ───────────────────
1034
+ * On a machine with no server these four tools can only ever return "install
1035
+ * it with npm i -D …", which teaches the model to try, wait and apologise.
1036
+ * `lspAvailable` probes PATH and node_modules; it spawns nothing.
1037
+ */
1038
+ /**
1039
+ * ── ⭐⭐ TWO WAYS TO SERVE THE SAME FOUR TOOLS ──────────────────────────────
1040
+ *
1041
+ * `lspAvailable` needs `typescript-language-server`, a package almost nobody
1042
+ * installs — measured false on a real Next.js app AND a real API server, so
1043
+ * these four shipped dark on every machine including the author's.
1044
+ *
1045
+ * ⭐ `tsserverAvailable` needs only `typescript`, which every TypeScript
1046
+ * project already has because it is what compiles the project. It answers for
1047
+ * TS and JS; a real language server still wins when present because it also
1048
+ * covers Python, Rust and Go. Either way the model sees the same four tools
1049
+ * returning the same shapes and never learns which one answered.
1050
+ */
1051
+ if (lspAvailable(root, env) || tsserverAvailable(root)) names.push(...LSP_TOOL_NAMES);
1052
+
1053
+ return withImage(names);
1054
+ }
1055
+
1056
+ /** The subset of the registry to put in a request payload. */
1057
+ /**
1058
+ * ⚠️⭐ THE DESCRIPTION HAS TO TELL THE TRUTH ABOUT THE MODE IT IS RUNNING IN.
1059
+ * `run_command`'s static text says "There is NO SHELL: pipes, &&, ; … are
1060
+ * refused". Left unchanged under `--shell` that is a LIE THAT DISABLES THE
1061
+ * FEATURE: the model reads it, believes pipes are impossible, and never tries —
1062
+ * so the flag the operator deliberately turned on does nothing, and the failure
1063
+ * is invisible because nothing errors. A capability the model is told it does
1064
+ * not have is a capability it does not have.
1065
+ */
1066
+ const SHELL_RUN_DESCRIPTION = [
1067
+ 'Run a command in the workspace through a real shell and get its exit code, stdout and stderr back.',
1068
+ 'This is how you VERIFY what you wrote — a non-zero exit code is the fact you fix in the next round.',
1069
+ 'A SHELL IS AVAILABLE in this run: pipes, &&, ||, ;, quoting, redirection and $(...) all work,',
1070
+ 'and you may run any program installed on this machine (python, go, cargo, git, curl, make, …).',
1071
+ 'The working directory is the workspace root.',
1072
+ 'Prefer one command per call so a failure names itself; chain only when the steps are genuinely one step.',
1073
+ ].join(' ');
1074
+
1075
+ export function toolSchemasFor(names, { shell = false } = {}) {
1076
+ const wanted = new Set(names);
1077
+ const picked = TOOL_SCHEMAS.filter((t) => wanted.has(t.function.name));
1078
+ if (!shell) return picked;
1079
+ return picked.map((t) => (t.function.name === 'run_command'
1080
+ ? { ...t, function: { ...t.function, description: SHELL_RUN_DESCRIPTION } }
1081
+ : t));
1082
+ }
1083
+
1084
+ /**
1085
+ * Tool arguments arrive as a STRING of JSON that a model wrote, so malformed
1086
+ * JSON is a normal Tuesday rather than an exceptional condition. Parsing it in
1087
+ * one guarded place means no handler has to think about it.
1088
+ *
1089
+ * Pure, and separately tested — this is the function that decides whether a
1090
+ * fumbled argument blob crashes the CLI or produces a sentence the model could
1091
+ * have acted on if there were a next round.
1092
+ */
1093
+ export function parseToolArguments(raw) {
1094
+ if (raw === undefined || raw === null || raw === '') return { ok: true, args: {} };
1095
+ if (typeof raw === 'object') return { ok: true, args: raw };
1096
+ if (typeof raw !== 'string') return { ok: false, error: 'tool arguments were neither a string nor an object' };
1097
+ let parsed;
1098
+ try {
1099
+ parsed = JSON.parse(raw);
1100
+ } catch (err) {
1101
+ return { ok: false, error: `tool arguments were not valid JSON: ${err instanceof Error ? err.message : String(err)}` };
1102
+ }
1103
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
1104
+ return { ok: false, error: 'tool arguments must be a JSON object' };
1105
+ }
1106
+ return { ok: true, args: parsed };
1107
+ }
1108
+
1109
+ /**
1110
+ * Run one tool call against a local executor.
1111
+ *
1112
+ * Returns `{ id, name, args, result, mutated }`. `mutated` is what the summary
1113
+ * counts — it is set by the DISPATCHER rather than inferred from the tool name
1114
+ * downstream, so a future tool that also touches disk cannot be missed by a
1115
+ * summary that only knows the string 'write_file'.
1116
+ *
1117
+ * ⚠️ ASYNC AS OF `run_command`, AND ALL OF IT RATHER THAN HALF. Three of these
1118
+ * handlers are synchronous and one cannot be, and the tempting shape — a sync
1119
+ * dispatcher plus a separate async path for the one tool — is how a registry
1120
+ * grows two front doors and then two sets of rules. One dispatcher, one
1121
+ * contract, `await` at the single call site.
1122
+ *
1123
+ * ⚠️ `round` IS OPTIONAL AND ITS ABSENCE IS SAFE. The plan ledger prints a
1124
+ * countdown ("· round 3 of 8") when it is told where in the budget it is, and
1125
+ * omits the clause entirely when it is not — plan-ledger.mjs says outright that
1126
+ * a WRONG countdown is worse than no countdown, so the degradation is the
1127
+ * designed one. `turn.mjs` passing `{ round: { roundIndex: round, maxRounds } }`
1128
+ * turns it on; nothing breaks until it does.
1129
+ *
1130
+ * ⚠️ `allowRun` DEFAULTS TO TRUE so today's callers are byte-identical. Passing
1131
+ * `false` refuses `check_acceptance` at the DISPATCHER, not just at the offer —
1132
+ * a model can emit a call for a tool it was never shown, and that one executes
1133
+ * commands.
1134
+ *
1135
+ * ⚠️ AND `id` IS CARRIED THROUGH, which is not bookkeeping: a multi-round turn
1136
+ * has to send each result back as a `tool` message keyed by the id the model
1137
+ * gave the call, and a mismatched or missing id makes the whole conversation
1138
+ * rejected by the provider rather than merely confused.
1139
+ */
1140
+ /**
1141
+ * ── ⭐⭐ THE WRITE REVIEW SEAM ───────────────────────────────────────────────
1142
+ *
1143
+ * Returns a refusal RESULT when the person said no, and `null` when the write
1144
+ * should proceed. `null` is the do-nothing answer so that every caller without
1145
+ * an approver — which is every existing caller, the MCP server, the acceptance
1146
+ * harness and every test — is byte-identical.
1147
+ *
1148
+ * ⚠️ IT READS `before` THROUGH THE EXECUTOR, never through `fs`. The in-memory
1149
+ * executor that lets the browser builder run this loop has no filesystem, and a
1150
+ * direct read here would make the gate silently wrong in exactly the place the
1151
+ * registry exists to serve.
1152
+ */
1153
+ async function gateWrite(approveWrite, executor, path, after, extra = {}) {
1154
+ if (typeof approveWrite !== 'function') return null;
1155
+ let before = null;
1156
+ let exists = false;
1157
+ try {
1158
+ const read = executor.readFile(path);
1159
+ if (read && read.ok !== false && typeof read.content === 'string') {
1160
+ before = read.content;
1161
+ exists = true;
1162
+ }
1163
+ } catch { /* unreadable is "new file" for review purposes, never a hard failure */ }
1164
+
1165
+ const decision = await approveWrite({ path, before, after, exists, ...extra });
1166
+ if (decision?.allowed === false) return refusedWriteResult(path);
1167
+ return null;
1168
+ }
1169
+
1170
+ export async function executeToolCall(call, executor, {
1171
+ commandTimeoutMs,
1172
+ /**
1173
+ * ⭐ The write reviewer, built per run by `createWriteApprover`. Optional and
1174
+ * defaulted to null, so nothing changes for a caller that does not pass one.
1175
+ */
1176
+ approveWrite = null,
1177
+ /** The bulk counterpart — one question for a whole `write_files` batch. */
1178
+ approveBatch = null,
1179
+ round = null,
1180
+ allowRun = true,
1181
+ /**
1182
+ * ⚠️ `delegate` IS THE FIRST TOOL THAT NEEDS TO CALL A MODEL ITSELF, so the
1183
+ * dispatcher needs the credentials the turn loop already holds. Optional and
1184
+ * defaulted, so every existing call site is byte-identical in behaviour — the
1185
+ * tool simply refuses when nobody passed it, which is the honest failure.
1186
+ *
1187
+ * `depth` is how a subagent knows it is one: the turn loop passes 0, and a
1188
+ * subagent's own dispatcher is handed 1, which `runSubagent` refuses.
1189
+ */
1190
+ config = null,
1191
+ depth = 0,
1192
+ /**
1193
+ * ⚠️ DEFAULTS TO FALSE, so every existing call site keeps the locked
1194
+ * allowlist unchanged. A capability this large must be reachable only by a
1195
+ * caller that NAMES it — never by one that merely forgot to pass a flag.
1196
+ */
1197
+ shell = false,
1198
+ /**
1199
+ * ⚠️ THE BUDGETED ASKER, OR NULL. The per-run allowance lives in
1200
+ * `ask-user.mjs`'s closure and is created ONCE by the turn loop, because this
1201
+ * dispatcher is a pure switch over a single call and has no memory of the
1202
+ * round before it. Threading a counter through every tool in this file for
1203
+ * the sake of one would be the wrong trade; the turn loop already owns
1204
+ * per-run state.
1205
+ */
1206
+ ask = null,
1207
+ /**
1208
+ * ⚠️ THE PARENT'S BUDGET, so `delegate` cannot spend outside it. The helper's
1209
+ * ceiling is whatever the parent has LEFT, and its cost is charged back the
1210
+ * moment it returns — otherwise the run's stated ceiling is a claim about the
1211
+ * parent only, while the model can spawn unbounded helpers at will.
1212
+ */
1213
+ budget = null,
1214
+ /**
1215
+ * ⚠️ INJECTABLE, like every other outward call in this package. Without it the
1216
+ * only way to test the delegate path is to spend real money on a real model,
1217
+ * which means in practice it is not tested — and the two things being pinned
1218
+ * here (the helper gets a ceiling, and its spend is charged back) are about
1219
+ * MONEY, which is the last place to accept "we checked it once by hand".
1220
+ */
1221
+ subagentImpl = null,
1222
+ } = {}) {
1223
+ const name = call?.function?.name;
1224
+ const id = call?.id ?? null;
1225
+ const parsed = parseToolArguments(call?.function?.arguments);
1226
+ if (!parsed.ok) return { id, name, args: {}, result: { ok: false, error: parsed.error }, mutated: false };
1227
+ const args = parsed.args;
1228
+
1229
+ switch (name) {
1230
+ case 'read_file': {
1231
+ /**
1232
+ * ── ⚠️⚠️ THE FOURTH PATH TO THIS FILE, AND THE ONLY UNHARDENED ONE ────
1233
+ *
1234
+ * `turn.mjs`'s automatic pre-load, `search.mjs`, `repo-map.mjs` and
1235
+ * `session.mjs` all refuse credential files using this same shared list.
1236
+ * The model-driven read used none of it. Probed 2026-08-13: `.env`,
1237
+ * `.env.local`, `id_rsa`, `server.pem`, `.npmrc`, `credentials.yml` and
1238
+ * `secrets.json` all came back in full, while `search_text` for the same
1239
+ * canary correctly matched only the source file — so search had been
1240
+ * fixed and read had not.
1241
+ *
1242
+ * ⚠️ A TOOL RESULT GOES STRAIGHT INTO THE PROMPT, which goes to a
1243
+ * third-party provider. `turn.mjs:112` calls this class "THE WORST BUG
1244
+ * THIS PACKAGE HAS HAD" — and fixed it in exactly one of the four places
1245
+ * it lives.
1246
+ *
1247
+ * ⭐ THE GUARD IS HERE, ON THE MODEL'S DOOR, NOT IN THE EXECUTOR. Learned
1248
+ * an hour earlier on the `.acuvo/` guard: a model-facing refusal pushed
1249
+ * down into shared plumbing broke seven tests of legitimate internal work.
1250
+ * `--doctor` still needs to see whether a `.env` exists.
1251
+ */
1252
+ const credential = refusedCommitPath(String(args.path ?? ''));
1253
+ if (credential) {
1254
+ return {
1255
+ id,
1256
+ name,
1257
+ args,
1258
+ result: {
1259
+ ok: false,
1260
+ error: `${args.path} looks like a credential file, so it is not read into the conversation — `
1261
+ + 'anything returned here becomes part of the prompt sent to the model provider. If you need a '
1262
+ + 'value from it, ask the owner to paste just that value, or tell them which key you need and why.',
1263
+ },
1264
+ mutated: false,
1265
+ };
1266
+ }
1267
+ return { id, name, args, result: executor.readFile(args.path), mutated: false };
1268
+ }
1269
+ case 'write_file': {
1270
+ const gate = await gateWrite(approveWrite, executor, args.path, args.content);
1271
+ if (gate) return { id, name, args, result: gate, mutated: false };
1272
+ const result = executor.writeFile(args.path, args.content);
1273
+ return { id, name, args, result, mutated: result.ok === true };
1274
+ }
1275
+ /**
1276
+ * ⭐ THE BULK EDIT, MADE GOVERNED. The model already does bulk edits — it
1277
+ * writes a loop inside `evaluate`, which is the right instinct and is
1278
+ * invisible to leases, to the change count and to collision detection. This
1279
+ * is the same operation through `executor.writeFile`, so every guard
1280
+ * applies. See lib/write-many.mjs.
1281
+ *
1282
+ * ⚠️ `mutated` is true when ANY file landed. A call that wrote 44 of 45 did
1283
+ * real work, and reporting it as untouched would put the summary back where
1284
+ * `evaluate` had it.
1285
+ */
1286
+ case 'write_files': {
1287
+ /**
1288
+ * ⚠️ THE BULK CASE IS GATED ONCE, ON THE WHOLE BATCH, and that is a
1289
+ * deliberate choice rather than a shortcut. Asking per file turns one
1290
+ * intent — "apply this refactor" — into forty prompts, and a prompt
1291
+ * answered forty times is answered without reading by the third. The
1292
+ * batch carries its file list so the person sees the scope.
1293
+ */
1294
+ const batch = (args?.files ?? []).filter((f) => f && typeof f.path === 'string');
1295
+ if (typeof approveBatch === 'function' && batch.length > 0) {
1296
+ // ⚠️ `before` read through the EXECUTOR, so the in-memory backend the
1297
+ // browser builder uses answers the same as the filesystem one.
1298
+ const writes = batch.map((f) => {
1299
+ let before = null; let exists = false;
1300
+ try {
1301
+ const r = executor.readFile(f.path);
1302
+ if (r && r.ok !== false && typeof r.content === 'string') { before = r.content; exists = true; }
1303
+ } catch { /* unreadable is "new file" for review purposes */ }
1304
+ return { path: f.path, before, after: f.content, exists };
1305
+ });
1306
+ const verdict = await approveBatch(writes);
1307
+ if (verdict?.allowed === false) {
1308
+ return { id, name, args, result: refusedWriteResult(`${batch.length} files`), mutated: false };
1309
+ }
1310
+ }
1311
+ const result = writeMany(executor, args);
1312
+ return {
1313
+ id, name, args, result,
1314
+ mutated: (result.written?.length ?? 0) > 0,
1315
+ mutatedPath: result.written?.length === 1 ? result.written[0].path : undefined,
1316
+ };
1317
+ }
1318
+ /**
1319
+ * ── ⭐⭐⭐ THE CHEAP EDIT. Same door, same gates, a tenth of the output. ───
1320
+ *
1321
+ * ⚠️ IT WRITES THROUGH `executor.writeFile` AND `executor.deleteFile` AND
1322
+ * NOWHERE ELSE, which is what buys — for free and without a line of code
1323
+ * here — the file leases, the `.acuvo/` leash, the `node_modules`/`.git`
1324
+ * refusals, `--dry-run`, and the `--plan` read-only executor that replaces
1325
+ * exactly those two methods with refusals. A bulk verb with its own path to
1326
+ * disk would have defeated all five at once.
1327
+ *
1328
+ * ⚠️ AND IT IS GATED BEFORE ANY OF THEM RUN. `planPatch` computes the whole
1329
+ * changeset in memory first, so the approval question can carry every path —
1330
+ * a person asked "apply this refactor?" must be shown the scope, not asked
1331
+ * once per file until they stop reading.
1332
+ */
1333
+ case 'apply_patch': {
1334
+ const plan = planPatch(executor, args.patch);
1335
+ if (!plan.ok) return { id, name, args, result: { ok: false, error: plan.error }, mutated: false };
1336
+
1337
+ /**
1338
+ * ⚠️ THE SAME BATCH GATE `write_files` USES, and deliberately the same
1339
+ * one rather than a second: `approveMany` decides risk across the whole
1340
+ * list and a patch is by construction one intent. `planPatch` has already
1341
+ * read every `before` THROUGH THE EXECUTOR, so the in-memory backend
1342
+ * answers this identically to the filesystem one.
1343
+ */
1344
+ if (typeof approveBatch === 'function' && plan.batch.length > 0) {
1345
+ const verdict = await approveBatch(plan.batch);
1346
+ if (verdict?.allowed === false) {
1347
+ return {
1348
+ id, name, args, mutated: false,
1349
+ result: refusedWriteResult(`${plan.batch.length} file${plan.batch.length === 1 ? '' : 's'} in one patch`),
1350
+ };
1351
+ }
1352
+ }
1353
+
1354
+ const result = commitPatch(executor, plan);
1355
+ /**
1356
+ * ⚠️ `written[]` IS `write_files`' SHAPE ON PURPOSE. `changed-paths.mjs`
1357
+ * reads it, and through it so do report.mjs, parallel.mjs, best-of.mjs and
1358
+ * handoff.mjs — a new `applied[]` field would have needed an arm in every
1359
+ * one of them, which is the three-way disagreement that file was written
1360
+ * to end. And on a rollback that could not fully restore, `written[]`
1361
+ * carries the still-modified paths, so the summary owns them.
1362
+ */
1363
+ return {
1364
+ id, name, args, result,
1365
+ mutated: (result.written?.length ?? 0) > 0,
1366
+ mutatedPath: result.written?.length === 1 ? result.written[0].path : undefined,
1367
+ };
1368
+ }
1369
+ case 'list_dir':
1370
+ return { id, name, args, result: executor.listDir(args.path ?? '.'), mutated: false };
1371
+ case 'edit_file': {
1372
+ /**
1373
+ * ⚠️⚠️ THE COMMENT HERE WAS RIGHT AND THE CODE WAS NOT (fixed 2026-08-19).
1374
+ * It said "gated on the RESULTING content, which `editThroughExecutor`
1375
+ * computes" — and then passed `null`, because `editThroughExecutor` is not
1376
+ * called until the line below the gate.
1377
+ *
1378
+ * `approvalDecision` derives `existsAfter = after !== null && after !==
1379
+ * undefined`, so a null `after` means **every edit was classified as the
1380
+ * file being DELETED**: `high` risk, and a prompt reading "app.js is being
1381
+ * DELETED" for a one-line change. Under `ACUVO_APPROVE=always` — the
1382
+ * documented unattended mode — high-risk writes are hard-blocked, so
1383
+ * `edit_file` could never land at all.
1384
+ *
1385
+ * ⭐ `applyEdit` is PURE, so the resulting content is computable before
1386
+ * anything is written. Now the person sees the real change and the risk
1387
+ * rules measure the real file, which is what the comment promised.
1388
+ *
1389
+ * ⚠️ A failed preview gates on `null` deliberately: `editThroughExecutor`
1390
+ * is about to return that same failure, and asking someone to approve a
1391
+ * write that cannot happen is noise. Nothing is written either way.
1392
+ */
1393
+ const beforeRead = executor.readFile(args.path);
1394
+ const preview = beforeRead.ok
1395
+ ? applyEdit(beforeRead.content, args.old_string, args.new_string)
1396
+ : null;
1397
+ const editedAfter = preview && preview.ok ? preview.content : null;
1398
+ const gate = await gateWrite(approveWrite, executor, args.path, editedAfter, { kind: 'edited', edit: args });
1399
+ if (gate) return { id, name, args, result: gate, mutated: false };
1400
+ // ⭐ Through the EXECUTOR, not through fs — this is the line that lets the
1401
+ // browser builder run the same loop the CLI does.
1402
+ const result = editThroughExecutor(executor, args.path, args.old_string, args.new_string);
1403
+ return { id, name, args, result, mutated: result.ok === true };
1404
+ }
1405
+ case 'delete_file': {
1406
+ const result = executor.deleteFile(args.path);
1407
+ return { id, name, args, result, mutated: result.ok === true };
1408
+ }
1409
+ case 'move_file': {
1410
+ /**
1411
+ * ⚠️ AN EXECUTOR WITHOUT `moveFile` MUST SAY SO, NOT CRASH. The browser
1412
+ * builder implements this dispatcher's verbs over a Map, and it gained
1413
+ * `deleteFile` only because someone remembered. A `TypeError: not a
1414
+ * function` mid-round costs the round and tells the model nothing it can
1415
+ * act on; a sentence tells it to use write_file + delete_file instead.
1416
+ */
1417
+ if (typeof executor.moveFile !== 'function') {
1418
+ return {
1419
+ id, name, args, mutated: false,
1420
+ result: { ok: false, error: 'this executor cannot move files. Use write_file to create the new path and delete_file to remove the old one.' },
1421
+ };
1422
+ }
1423
+ const result = executor.moveFile(args.from, args.to, { overwrite: args.overwrite === true });
1424
+ return { id, name, args, result, mutated: result.ok === true };
1425
+ }
1426
+ case 'find_files':
1427
+ return { id, name, args, result: findFiles(executor.root, args.pattern, { offset: args.offset }), mutated: false };
1428
+ case 'search_text':
1429
+ return { id, name, args, result: searchText(executor.root, args.pattern, { glob: args.glob, offset: args.offset }), mutated: false };
1430
+ case 'see_page': {
1431
+ const result = await designPass(executor.root, args.path, { dryRun: executor.dryRun });
1432
+ /**
1433
+ * ⚠️ mutated: it writes a screenshot into .acuvo/ — the summary must own up
1434
+ * to every file that appears on disk, including ones the user did not ask for.
1435
+ *
1436
+ * ⚠️⚠️ BUT `result.path` IS THE PAGE IT READ, NOT THE FILE IT WROTE, and
1437
+ * every consumer of a mutating record reads `result.path`. Observed live:
1438
+ * looking at `index.html` printed `replaced index.html (0 bytes)` — a
1439
+ * report that the agent had BLANKED the user's file, when all it did was
1440
+ * take a photograph of it. `parallel.mjs` reads the same field, so two
1441
+ * tasks that merely looked at one page would be reported as colliding
1442
+ * over it.
1443
+ *
1444
+ * ⭐ So the written path is stated explicitly. This is the same lesson
1445
+ * `delete_file` taught: a new tool whose result shape differs from
1446
+ * `write_file`'s breaks every downstream reader that assumed one shape.
1447
+ */
1448
+ return {
1449
+ id, name, args, result,
1450
+ mutated: result.ok === true && Boolean(result.screenshot),
1451
+ mutatedPath: result.screenshot ?? null,
1452
+ };
1453
+ }
1454
+ case 'speak': {
1455
+ const result = await speak(executor.root, args.text, args.path, { dryRun: executor.dryRun, engine: args.engine ?? null });
1456
+ return { id, name, args, result, mutated: result.ok === true };
1457
+ }
1458
+ /**
1459
+ * ── ⭐⭐ THE PRICE QUESTION, ASKED BEFORE THE MONEY MOVES ────────────────
1460
+ *
1461
+ * ⚠️ `mutated: false` — it writes nothing into the workspace. It does
1462
+ * refresh a cache under HOME, which is deliberately NOT counted: the
1463
+ * "N files written" line is about the user's tree, and a credential-adjacent
1464
+ * cache file appearing in it would be noise in the one honest number in the
1465
+ * summary. (Same reasoning `evaluate` uses for its temp file.)
1466
+ *
1467
+ * ⭐ IT IS THE ONE CREATIVE VERB ALLOWED TO GO TO THE NETWORK FOR PRICES.
1468
+ * `generate_image` and `speak` read the cache and never ask, so a render
1469
+ * never pays for a round trip — the question is asked by the verb that
1470
+ * exists to answer it.
1471
+ */
1472
+ case 'list_engines': {
1473
+ const result = await listEngines({ medium: args.medium ?? 'all' });
1474
+ return { id, name, args, result, mutated: false };
1475
+ }
1476
+ case 'transcribe': {
1477
+ const result = await transcribe(executor.root, args.path, { dryRun: executor.dryRun });
1478
+ return { id, name, args, result, mutated: false };
1479
+ }
1480
+ case 'make_document': {
1481
+ const result = await makeDocument(executor.root, args.path, args.out, args.format, { dryRun: executor.dryRun });
1482
+ return { id, name, args, result, mutated: result.ok === true };
1483
+ }
1484
+ /**
1485
+ * ⚠️ BOTH READERS ARE `mutated: false`. They write nothing — and the "N files
1486
+ * written" line is the one honest number in the summary, so a read that
1487
+ * inflates it turns the summary into an estimate. Same rule `evaluate`
1488
+ * already follows for its temp file.
1489
+ */
1490
+ case 'read_document': {
1491
+ const result = await readDocument(executor.root, args.path, {
1492
+ ocr: args.ocr, fromPage: args.from_page, maxPages: args.max_pages,
1493
+ });
1494
+ return { id, name, args, result, mutated: false };
1495
+ }
1496
+ case 'read_table': {
1497
+ const result = await readTable(executor.root, args.path, { page: args.page });
1498
+ return { id, name, args, result, mutated: false };
1499
+ }
1500
+ /**
1501
+ * ⚠️ `mutated: true`, and `mutatedPath` is the NEW file. Both of these write
1502
+ * an image the run must account for — and neither touches its source, so
1503
+ * reporting `args.path` here would say the original changed when it did not.
1504
+ * That is the exact bug `see_page` shipped: "replaced index.html (0 bytes)"
1505
+ * for a tool that only took a photograph of it.
1506
+ */
1507
+ case 'edit_image': {
1508
+ const result = await editImage(executor.root, args.path, args.target, args.replacement, {
1509
+ dryRun: executor.dryRun, out: args.out,
1510
+ });
1511
+ return { id, name, args, result, mutated: result.ok === true, mutatedPath: result.path ?? null };
1512
+ }
1513
+ case 'expand_image': {
1514
+ const result = await expandImage(executor.root, args.path, args.aspect, {
1515
+ dryRun: executor.dryRun, out: args.out, prompt: args.prompt,
1516
+ });
1517
+ return { id, name, args, result, mutated: result.ok === true, mutatedPath: result.path ?? null };
1518
+ }
1519
+ case 'generate_image': {
1520
+ /**
1521
+ * ⚠️ `mutated: true` — this DOES write a file into the workspace, unlike
1522
+ * run_command. The summary's "N files written" must count it, or a user
1523
+ * gets an image on disk that the run never mentioned.
1524
+ */
1525
+ /**
1526
+ * ⚠️ `engine` IS PASSED THROUGH RATHER THAN DEFAULTED HERE. The default
1527
+ * belongs in one place (`checkEngine`), and it is the CORE engine — a
1528
+ * dispatcher that picked an engine would be the software choosing to
1529
+ * spend somebody's credits, which is the exact thing the rule forbids.
1530
+ */
1531
+ const result = await generateImage({
1532
+ prompt: args.prompt, width: args.width, height: args.height, engine: args.engine ?? null, executor,
1533
+ });
1534
+ return { id, name, args, result, mutated: result.ok === true };
1535
+ }
1536
+ case 'evaluate': {
1537
+ const result = await evaluateSnippet({ executor, source: args.source, timeoutMs: commandTimeoutMs });
1538
+ /**
1539
+ * ⚠️ `mutated: false`. It writes a temp file and deletes it again, so no
1540
+ * file the user cares about changed — counting it would put a phantom
1541
+ * entry in the "files written" line, which is the one honest number in
1542
+ * the summary.
1543
+ */
1544
+ return { id, name, args, result, mutated: false };
1545
+ }
1546
+ case 'run_command': {
1547
+ /**
1548
+ * ── ⚠️⚠️ `--no-run` WAS ENFORCED AT THE OFFER AND NOT HERE ─────────────
1549
+ *
1550
+ * `run_program`, ONE CASE BELOW, already checks `allowRun` at the
1551
+ * dispatcher, and its comment spells out exactly why: "a model can emit a
1552
+ * call for a tool it was never shown (a resumed session, a stale
1553
+ * conversation, a provider echoing an old tool list), and the flag has to
1554
+ * hold at the point the process would actually start."
1555
+ *
1556
+ * ⚠️ EVERY WORD OF THAT APPLIES TO `run_command`, WHICH IS THE ONE THE
1557
+ * MODEL REACHES FOR CONSTANTLY, and it was the one without the check.
1558
+ * Reproduced: `executeToolCall({name:'run_command', command:'npm install
1559
+ * evil-package'}, executor, {allowRun:false})` returned `{ok:true,
1560
+ * exitCode:0}` and the executor really ran it.
1561
+ *
1562
+ * ⚠️ AND `executor.runCommand` MAKES IT WORSE, not better. The browser
1563
+ * builder's own runner is reached on the line below without passing
1564
+ * through `executeRunCommand` at all — so whatever gate lives downstream
1565
+ * is not on that path. A flag whose enforcement depends on which executor
1566
+ * is installed is not a flag.
1567
+ *
1568
+ * ⭐ The sentence is `run_program`'s, changed only from "program" to
1569
+ * "command": one flag must not grow two explanations.
1570
+ */
1571
+ if (allowRun === false) {
1572
+ return {
1573
+ id, name, args, mutated: false,
1574
+ result: { ok: false, error: 'this run was started with --no-run, so no command is executed. Report what you changed and say plainly that nothing was verified.' },
1575
+ };
1576
+ }
1577
+ /**
1578
+ * ⭐ AN EXECUTOR MAY OWN ITS OWN RUNNER. The CLI does not — it uses the
1579
+ * allowlisted local spawner below, which is the thing `command.mjs`
1580
+ * exists to keep safe. The browser builder DOES: its files never touch a
1581
+ * server disk, so "run" means shipping the map to the Modal sandbox.
1582
+ * Same tool, same loop, two very different executions.
1583
+ */
1584
+ const result = typeof executor.runCommand === 'function'
1585
+ ? await executor.runCommand(args.command)
1586
+ : await executeRunCommand({ command: args.command, executor, timeoutMs: commandTimeoutMs, shell });
1587
+ /**
1588
+ * ⚠️ `mutated: false` EVEN THOUGH A COMMAND CAN WRITE FILES. `mutated`
1589
+ * feeds the "N files written" line, and that line names paths the
1590
+ * EXECUTOR wrote — a build's output is real but unattributable, and
1591
+ * inventing a count for it would make the one honest number in the
1592
+ * summary an estimate. What the command did is reported separately, in
1593
+ * full, as its own output.
1594
+ */
1595
+ return { id, name, args, result, mutated: false };
1596
+ }
1597
+
1598
+ /**
1599
+ * ── ⭐⭐ `run_program` — THE SAME SPAWN, WITH A REAL ARGV ─────────────────
1600
+ *
1601
+ * Three guards before anything is spawned, and each one exists because the
1602
+ * module cannot check it itself: `runProgram` takes a `root` string, not an
1603
+ * executor, so `dryRun`, the memory sentinel and `allowRun` are facts only
1604
+ * this dispatcher holds.
1605
+ *
1606
+ * ⚠️ THE DRY-RUN SENTENCE IS `executeRunCommand`'s, deliberately reworded
1607
+ * only where it must be. `--dry-run` promises the disk is untouched and a
1608
+ * program is free to write to it; the promise can only be kept by refusing
1609
+ * here, and refusing with a DIFFERENT explanation for the same flag would
1610
+ * teach the model that the two runners have two policies.
1611
+ *
1612
+ * ⚠️ `allowRun` IS ENFORCED AT THE DISPATCHER as well as at the offer — the
1613
+ * `check_acceptance` argument, one tool over: a model can emit a call for a
1614
+ * tool it was never shown (a resumed session, a stale conversation, a
1615
+ * provider echoing an old tool list), and the flag has to hold at the point
1616
+ * the process would actually start.
1617
+ *
1618
+ * ⚠️ THE MEMORY EXECUTOR IS REFUSED RATHER THAN ROUTED TO ITS OWN RUNNER.
1619
+ * `run_command` hands a STRING to `executor.runCommand`, and that is the
1620
+ * whole contract the browser builder's Modal sandbox implements — there is
1621
+ * no argv-shaped entry point on the other side. Silently joining the array
1622
+ * back into a string here would re-introduce the exact quoting ambiguity
1623
+ * this tool exists to remove, and it would do it invisibly. So the refusal
1624
+ * names the alternative, like every other memory guard in this file.
1625
+ */
1626
+ case 'run_program': {
1627
+ if (allowRun === false) {
1628
+ return {
1629
+ id, name, args, mutated: false,
1630
+ result: { ok: false, error: 'this run was started with --no-run, so no program is executed. Report what you changed and say plainly that nothing was verified.' },
1631
+ };
1632
+ }
1633
+ if (executor.dryRun) {
1634
+ return {
1635
+ id, name, args, mutated: false,
1636
+ result: { ok: false, error: 'this is a --dry-run, so no program is executed (a program could write to disk, which a dry run promises not to do)' },
1637
+ };
1638
+ }
1639
+ if (executor.root === MEMORY_ROOT) {
1640
+ return {
1641
+ id, name, args, mutated: false,
1642
+ result: { ok: false, error: 'this workspace is held in memory rather than on disk, so run_program is unavailable here — use run_command, which this executor runs in its own sandbox.' },
1643
+ };
1644
+ }
1645
+ const result = await runProgram({
1646
+ root: executor.root, program: args.program, args: args.args, timeoutMs: args.timeoutMs ?? commandTimeoutMs,
1647
+ });
1648
+ /**
1649
+ * ⚠️ `mutated: false`, the `run_command` precedent exactly: a program can
1650
+ * write files, and those writes are real but unattributable. `mutated`
1651
+ * feeds the "N files written" line, which names paths the EXECUTOR wrote,
1652
+ * and inventing a count for a build's output would make the one honest
1653
+ * number in the summary an estimate.
1654
+ */
1655
+ return { id, name, args, result, mutated: false };
1656
+ }
1657
+
1658
+ /**
1659
+ * ── ⭐ THE PLAN LEDGER ────────────────────────────────────────────────────
1660
+ *
1661
+ * ⚠️ `mutated: false`, AND THIS IS THE `evaluate` PRECEDENT RATHER THAN THE
1662
+ * `see_page` ONE. `mutated` feeds the "N files written" line — the one
1663
+ * honest number in the summary — and that line names files the USER cares
1664
+ * about. `.acuvo/plan.json` is the agent's own bookkeeping; counting it
1665
+ * would put a phantom entry in every multi-step run's report.
1666
+ *
1667
+ * ⭐ And `parallel.mjs` is the second, sharper reason. It reads mutated
1668
+ * records to detect two tasks colliding over a file. Every parallel task in
1669
+ * one workspace writes the SAME `.acuvo/plan.json`, so counting it would
1670
+ * report a conflict on literally every parallel pair — a guard that fires
1671
+ * always is a guard that gets ignored.
1672
+ *
1673
+ * `round` is forwarded verbatim: plan-ledger decides what to do with a
1674
+ * missing budget, not this dispatcher.
1675
+ */
1676
+ case 'plan_start':
1677
+ return { id, name, args, result: planStart(executor.root, args, { ...(round ?? {}), planFile: planFileFor(executor.holder) }), mutated: false };
1678
+ case 'plan_step':
1679
+ return { id, name, args, result: planStep(executor.root, args, { ...(round ?? {}), planFile: planFileFor(executor.holder) }), mutated: false };
1680
+ case 'plan_status':
1681
+ // ⚠️ Takes the ROUND options as its second argument, not model arguments —
1682
+ // there is nothing here a model could pass. See planStatus's own note.
1683
+ return { id, name, args, result: planStatus(executor.root, { ...(round ?? {}), planFile: planFileFor(executor.holder) }), mutated: false };
1684
+
1685
+ /**
1686
+ * ── ⭐ SKILLS — the project's own written procedure ───────────────────────
1687
+ * There is no path argument and no way to reach a file that is not a skill;
1688
+ * `loadSkill` resolves the NAME against the discovered catalogue rather than
1689
+ * joining it onto a directory, which is why `../../.ssh/id_rsa` is simply a
1690
+ * name that matches nothing.
1691
+ */
1692
+ case 'read_skill':
1693
+ return { id, name, args, result: loadAnySkill(executor.root, args.name), mutated: false };
1694
+
1695
+ /**
1696
+ * ── ⭐⭐ DELEGATE — A HELPER WITH ITS OWN HEAD ────────────────────────────
1697
+ *
1698
+ * ⚠️ `mutated: false` is a FACT here, not a convention: a subagent is
1699
+ * offered no verb that can change anything, and `allowRun: false` locks the
1700
+ * dispatcher behind the offer.
1701
+ *
1702
+ * ⚠️ IT REFUSES WITHOUT CREDENTIALS RATHER THAN GUESSING. A caller that did
1703
+ * not thread `config` gets a sentence naming the cause; inventing a model
1704
+ * config here would spend the owner's money on a shape nobody chose.
1705
+ */
1706
+ case 'delegate': {
1707
+ if (!config?.apiKey) {
1708
+ return {
1709
+ id, name, args, mutated: false,
1710
+ result: { ok: false, error: 'delegation is unavailable in this run — no model credentials reached the dispatcher' },
1711
+ };
1712
+ }
1713
+ /**
1714
+ * ⚠️ `depth` IS THE CALLER'S DEPTH, NOT THE CALLEE'S — and passing
1715
+ * `depth + 1` here made the top-level `delegate` refuse ITSELF with
1716
+ * "a helper cannot delegate again (depth 1)". Every unit test passed,
1717
+ * because all thirteen called `runSubagent` directly and none came
1718
+ * through this dispatcher. ⭐ The real run found it in one command.
1719
+ */
1720
+ /**
1721
+ * ⭐ THE HELPER INHERITS THE PARENT'S REMAINDER. Not a fraction — a
1722
+ * fraction is an invented constant somebody has to defend, whereas the
1723
+ * remainder makes the arithmetic self-evident: a helper cannot spend
1724
+ * money the run does not have, so the TOTAL stays the number the user
1725
+ * typed. `Infinity` (a `--budget none` run) becomes `null`, i.e.
1726
+ * unbounded, which is exactly the behaviour that run asked for.
1727
+ */
1728
+ const left = budget?.canContinue?.()?.remainingUsd;
1729
+ const share = Number.isFinite(left) ? left : null;
1730
+
1731
+ const result = await (subagentImpl ? subagentImpl : runSubagent)({
1732
+ task: args.task,
1733
+ /**
1734
+ * ⭐ THE BRIEF. Passed RAW, and folded into the prompt by
1735
+ * `subagent.mjs:briefFor` — one place decides how a helper is briefed,
1736
+ * so the dispatcher cannot grow a second, differently-worded version of
1737
+ * the same paragraph. A helper that receives only a task string spends
1738
+ * its four rounds rediscovering what the parent already knows.
1739
+ */
1740
+ context: args.context,
1741
+ executor,
1742
+ config,
1743
+ depth,
1744
+ maxRounds: args.maxRounds,
1745
+ commandTimeoutMs,
1746
+ budgetUsd: share,
1747
+ fleetGate: budget?.fleetGate ?? null,
1748
+ /**
1749
+ * ⭐ THE BUILD MODE. `runSubagent` re-checks `=== true` itself; passing
1750
+ * the raw argument through means the string "false" — which a model
1751
+ * emits about once in fifty when a schema says boolean — is decided in
1752
+ * ONE place rather than differently in two.
1753
+ */
1754
+ write: args.write,
1755
+ /**
1756
+ * ⚠️ AND SO IS `verify`, WHICH DECIDES WHETHER A PROCESS STARTS. Same
1757
+ * rule for the same reason: `runSubagent` requires `=== true` AND
1758
+ * `write === true`, so no reading of a stray string can turn a research
1759
+ * question into a command run.
1760
+ */
1761
+ verify: args.verify,
1762
+ });
1763
+
1764
+ /**
1765
+ * ⚠️⚠️ CHARGED BACK EVEN WHEN THE HELPER FAILED. `runSubagent` returns
1766
+ * `costUsd` on both paths precisely because a helper that crashed after
1767
+ * three rounds still spent three rounds of money. Recording only the
1768
+ * successes would let a run of failing delegations cost an unbounded
1769
+ * amount while the parent's ledger insisted nothing had happened.
1770
+ */
1771
+ /**
1772
+ * ── ⚠️⚠️ AND `> 0` THREW AWAY THE FALLBACK THAT WAS BUILT FOR THIS ──────
1773
+ *
1774
+ * MEASURED: `subagent.mjs:213` is `costUsd: Number.isFinite(usage?.cost)
1775
+ * ? usage.cost : 0` — so a provider that reports tokens but no `cost`
1776
+ * yields **0**, the `> 0` guard skipped `record` entirely, and the TOKENS
1777
+ * went in the bin with it. The helper is capped at 6 rounds
1778
+ * (`subagent.mjs:81`) with a 12,000-token reply ceiling
1779
+ * (`model.mjs:138`) — up to ~72k output tokens per call, and the parent
1780
+ * may delegate every round. All of it was free in the governor's book, so
1781
+ * the parent kept spending against a ceiling it had already crossed.
1782
+ *
1783
+ * ⭐ `budget.record` ALREADY KNOWS WHAT TO DO — `budget.mjs:396-404` prices
1784
+ * from tokens when no cost is reported, and from the projection when there
1785
+ * is neither. Those two branches were unreachable from here. So the rule is
1786
+ * now: report what we actually know, and let the one module that owns
1787
+ * pricing do the pricing.
1788
+ *
1789
+ * ⚠️ `costUsd` IS OMITTED, NOT PASSED AS 0, when nothing was reported.
1790
+ * `record` treats any finite `>= 0` cost as REPORTED and stops looking —
1791
+ * passing the zero would re-close the fallback from one line further down.
1792
+ *
1793
+ * ⚠️ AND A HELPER THAT NEVER RAN A ROUND IS CHARGED NOTHING. Charging a
1794
+ * projected round for a crash that happened before the first model call
1795
+ * would be inventing money, which is the opposite failure and just as bad.
1796
+ */
1797
+ if (budget) {
1798
+ const cost = Number.isFinite(result?.costUsd) ? result.costUsd : 0;
1799
+ const tokens = Number.isFinite(result?.tokens) ? result.tokens : 0;
1800
+ const rounds = Number.isFinite(result?.roundsUsed) ? result.roundsUsed : 0;
1801
+ if (cost > 0) budget.record({ costUsd: cost, tokens });
1802
+ else if (tokens > 0) budget.record({ tokens });
1803
+ else if (rounds > 0) budget.record({});
1804
+ }
1805
+ /**
1806
+ * ── ⚠️⚠️ `mutated` IS NO LONGER ALWAYS FALSE, AND THAT IS THE WHOLE
1807
+ * DIFFERENCE BETWEEN A FEATURE AND A HALF-CONNECTED ONE ─────────────────
1808
+ *
1809
+ * A building helper's files are ON DISK by the time this returns. Every
1810
+ * downstream reader keys off this flag and nothing else:
1811
+ *
1812
+ * · the run summary counts `executed.filter(e => e.mutated)` — a false
1813
+ * here prints "NOTHING WAS WRITTEN" over real edits;
1814
+ * · `parallel.mjs:84` skips any record where it is false, so two
1815
+ * terminals could delegate writes to one file and the collision
1816
+ * report would be empty;
1817
+ * · `best-of.mjs:166` skips it too, so a winning attempt's delegated
1818
+ * files would never be copied out of the attempt directory.
1819
+ *
1820
+ * ⭐ AND THE RESULT REPORTS `written[{path,bytes,previousBytes,created}]`
1821
+ * — `write_files`' EXISTING shape (`write-many.mjs:132`), not a new one.
1822
+ * A first version returned bare strings and a real run printed
1823
+ * `replaced src/calc.test.mjs (0 bytes)` for a file that was CREATED at
1824
+ * 510 bytes, because `report.mjs:describeChange` reads `bytes`/`created`
1825
+ * off the result and found neither. Reusing the shape means every reader
1826
+ * that already understood a bulk write understands this for free, and
1827
+ * `changed-paths.mjs` needs no delegate-specific arm at all.
1828
+ *
1829
+ * `mutatedPath` follows the same convention exactly (`tools.mjs:954` —
1830
+ * set only when there is exactly one file).
1831
+ */
1832
+ const built = Array.isArray(result?.written) ? result.written : [];
1833
+ return {
1834
+ id,
1835
+ name,
1836
+ args,
1837
+ result,
1838
+ mutated: built.length > 0,
1839
+ mutatedPath: built.length === 1 ? built[0].path : undefined,
1840
+ };
1841
+ }
1842
+
1843
+ /**
1844
+ * ── ⭐ WHAT THIS RUN LEARNED, KEPT FOR THE NEXT ONE ───────────────────────
1845
+ *
1846
+ * ⚠️ `mutated: false` DELIBERATELY, following `plan_start`'s precedent. These
1847
+ * write `.acuvo/memory/*.md`, but `mutated` feeds the "N files written" line,
1848
+ * which names files the USER cares about — and it is what `parallel.mjs`
1849
+ * reads to detect two tasks colliding over a path. Every parallel task in one
1850
+ * workspace writes into the same memory directory, so counting it would
1851
+ * report a conflict on literally every parallel pair. A guard that fires
1852
+ * always is a guard that gets ignored.
1853
+ *
1854
+ * ⚠️ The memory executor has no disk. `learned.mjs` reaches for `fs`
1855
+ * directly, so it is refused there by name rather than half-working.
1856
+ */
1857
+ case 'remember':
1858
+ case 'forget': {
1859
+ if (executor.root === MEMORY_ROOT) {
1860
+ return {
1861
+ id, name, args, mutated: false,
1862
+ result: { ok: false, error: `${name} needs a real workspace on disk — this run has none` },
1863
+ };
1864
+ }
1865
+ const result = name === 'remember'
1866
+ ? remember(executor.root, args)
1867
+ : forget(executor.root, args.name);
1868
+ return { id, name, args, result, mutated: false };
1869
+ }
1870
+
1871
+ /**
1872
+ * ── ⭐ SESSIONS — read-only, and deliberately the only session verb ───────
1873
+ * No resume tool exists and none may be added here: a model that can rewrite
1874
+ * its own message history mid-run replays side effects. Resume is an
1875
+ * operator action taken between runs.
1876
+ */
1877
+ case 'list_sessions':
1878
+ return { id, name, args, result: listSessions(executor.root, { limit: args.limit }), mutated: false };
1879
+
1880
+ /**
1881
+ * ── ⭐⭐ ASK_USER — THE ONLY TOOL WHOSE RESULT COMES FROM A PERSON ────────
1882
+ *
1883
+ * ⚠️ `mutated: false`. It writes nothing. The flag feeds the "N files
1884
+ * written" line and `parallel.mjs`'s collision detector, and a question is
1885
+ * neither a file nor a conflict.
1886
+ *
1887
+ * ⚠️ THE REFUSAL WHEN `ask` IS MISSING IS A REAL PATH, not defensive
1888
+ * padding. The offer is gated on `interactive` in `toolNamesForRounds`, but
1889
+ * a model can name any tool in the schema list, and a library caller may
1890
+ * dispatch without one. Saying so plainly — rather than throwing — keeps
1891
+ * the run alive and tells the model exactly what to do instead.
1892
+ */
1893
+ case 'ask_user': {
1894
+ if (typeof ask !== 'function') {
1895
+ return {
1896
+ id,
1897
+ name,
1898
+ args,
1899
+ mutated: false,
1900
+ result: {
1901
+ ok: true,
1902
+ answer: '(nobody is available to ask — this run has no terminal attached). '
1903
+ + 'Make the most reasonable choice, continue, and state the assumption you took in your final message.',
1904
+ answered: false,
1905
+ },
1906
+ };
1907
+ }
1908
+ return { id, name, args, result: await ask(args.question), mutated: false };
1909
+ }
1910
+
1911
+ /**
1912
+ * ── ⭐ ACCEPTANCE — make the verdict be about the command the USER named ──
1913
+ *
1914
+ * ⚠️ `check_acceptance` IS THE ONE NEW TOOL THAT EXECUTES CODE, so it is
1915
+ * refused when the caller says `allowRun: false`. The offer already withholds
1916
+ * it under `--no-run`; this closes the door a model could still knock on.
1917
+ *
1918
+ * ⚠️ THE RUNNER IS INJECTED, NEVER IMPORTED BY acceptance.mjs — that module
1919
+ * starts no process by itself, which is what keeps ONE audited gate
1920
+ * (`executeRunCommand`: allowlist, no shell, scrubbed env) rather than two.
1921
+ * The executor's own runner wins where it has one, exactly as `run_command`
1922
+ * does above, so the browser builder checks criteria in its sandbox.
1923
+ *
1924
+ * `mutated: false` on both: `declare_acceptance` writes `.acuvo/acceptance.json`
1925
+ * (bookkeeping, same reasoning as the plan ledger) and `check_acceptance`
1926
+ * writes nothing at all.
1927
+ */
1928
+ case 'declare_acceptance':
1929
+ return { id, name, args, result: declareAcceptance(executor.root, { commands: args.commands }), mutated: false };
1930
+ case 'check_acceptance': {
1931
+ if (allowRun === false) {
1932
+ return {
1933
+ id, name, args, mutated: false,
1934
+ result: { ok: false, error: 'this run was started with --no-run, so acceptance criteria cannot be executed. Report what you changed and say plainly that nothing was verified.' },
1935
+ };
1936
+ }
1937
+ const runner = (command) => (typeof executor.runCommand === 'function'
1938
+ ? executor.runCommand(command)
1939
+ : executeRunCommand({ command, executor, timeoutMs: commandTimeoutMs }));
1940
+ const result = await checkAcceptance({ root: executor.root, runner });
1941
+ return { id, name, args, result, mutated: false };
1942
+ }
1943
+
1944
+ /**
1945
+ * ── ⭐ FETCH — a public GET, rendered as text ─────────────────────────────
1946
+ *
1947
+ * ⚠️ THE MODEL'S ARGUMENTS ARE SPREAD IN WHOLE, ON PURPOSE. `fetchText`
1948
+ * refuses unknown keys BY NAME ("fetch_url does not accept \"headers\"") and
1949
+ * that refusal only works if it can see what was passed. Picking out url,
1950
+ * offset and limit here would silently drop a `headers` the model believed
1951
+ * it had sent — the worse of the two failures, and it is documented as such
1952
+ * in fetch-text.mjs.
1953
+ *
1954
+ * `root` is added for the on-disk cache; a memory workspace gets no cache
1955
+ * and the module already handles that.
1956
+ */
1957
+ case 'fetch_url':
1958
+ return { id, name, args, result: await fetchText({ ...args, root: executor.root }), mutated: false };
1959
+
1960
+ /**
1961
+ * ── ⭐⭐ SEARCH — find the page, then read it ─────────────────────────────
1962
+ *
1963
+ * ⚠️ THE RENDERED TEXT RIDES ALONGSIDE THE STRUCTURE, not instead of it.
1964
+ * `formatResults` is what makes a fallback announce itself ("duckduckgo
1965
+ * failed (served a bot check) — coverage is narrower than usual"), and that
1966
+ * sentence is the whole reason a degraded search does not read like a
1967
+ * confident one. The raw `results` stay on the object for anything that
1968
+ * wants to program against them.
1969
+ */
1970
+ /**
1971
+ * ── ⭐⭐ EYES ────────────────────────────────────────────────────────────
1972
+ *
1973
+ * ⚠️ THIS DOES NOT ATTACH THE IMAGE TO THE CODER MODEL. The default model
1974
+ * is text-only; handed an image it does not fail loudly, it answers anyway
1975
+ * from the filename and the surrounding conversation. A confident sentence
1976
+ * about a picture nobody looked at is worse than silence, because it ENDS
1977
+ * the investigation. vision.mjs makes its own call to a model that can see.
1978
+ */
1979
+ /**
1980
+ * ── ⚠️⚠️ AND IT SPENDS MONEY THE GOVERNOR COULD NOT SEE ──────────────────
1981
+ *
1982
+ * `vision.mjs` makes its OWN model call and returns `costUsd`
1983
+ * (`vision.mjs:261`) — and nothing read it. The only bound was a COUNT:
1984
+ * `MAX_LOOKS_PER_PROCESS = 12` (`vision.mjs:47`). A count is not a ceiling.
1985
+ * Vision calls are the expensive per-token kind, twelve looks is a real
1986
+ * number for a design loop, and `--budget` is the one differentiator this
1987
+ * package actually claims — so a run could cross the number the user typed
1988
+ * twelve times over and report having stayed inside it.
1989
+ *
1990
+ * ⭐ Charged back through the same `budget.record` the model rounds use, so
1991
+ * `acuvo spend`, the audit ledger and the projection all see one number.
1992
+ * ⚠️ Only on a LOOK that happened: a refusal (no key, over the cap, unreadable
1993
+ * file) returns `ok: false` and costs nothing, and charging for it would make
1994
+ * the ledger a work of fiction in the cheapest possible direction.
1995
+ *
1996
+ * ⚠️ THE DOLLAR IS EXACT; THE TOKEN COUNT IS NOT RECORDED, and that is stated
1997
+ * rather than papered over. `vision.mjs:253-262` returns `costUsd` and
1998
+ * `approxImageTokens` but never `usage.total_tokens` — and
1999
+ * `approxImageTokens` is an ESTIMATE OF THE IMAGE, not the round's usage, so
2000
+ * feeding it to `budget.record` would corrupt the one honest token total with
2001
+ * a different quantity wearing the same name. The ceiling is expressed in
2002
+ * dollars and the dollars are right; the token counter under-reports a look,
2003
+ * which is a gap in `vision.mjs`'s return shape, not one to fake here.
2004
+ */
2005
+ case 'read_image': {
2006
+ const result = await readImage({ ...args, root: executor.root });
2007
+ if (budget && result?.ok === true && Number.isFinite(result.costUsd) && result.costUsd > 0) {
2008
+ budget.record({ costUsd: result.costUsd });
2009
+ }
2010
+ return { id, name, args, result, mutated: false };
2011
+ }
2012
+
2013
+ case 'web_search': {
2014
+ const result = await webSearch(args);
2015
+ return {
2016
+ id,
2017
+ name,
2018
+ args,
2019
+ result: result.ok ? { ...result, text: formatResults(result) } : result,
2020
+ mutated: false,
2021
+ };
2022
+ }
2023
+
2024
+ /**
2025
+ * ── ⭐ WINDOWED READS ────────────────────────────────────────────────────
2026
+ *
2027
+ * ⚠️ THE TOOL NAME IS PASSED EXPLICITLY rather than inferred. `readWindow`
2028
+ * can guess from the presence of `pattern`, but the guess exists for direct
2029
+ * callers and tests — a dispatcher that knows which tool was called and
2030
+ * declines to say so is choosing to be wrong occasionally for no gain.
2031
+ *
2032
+ * ⚠️ Refused on a memory workspace with a sentence that says why, exactly as
2033
+ * git is below: `resolveInWorkspace('(memory)', …)` would resolve a real
2034
+ * relative directory named "(memory)" under the process's cwd and fail with
2035
+ * an ENOENT about a path that does not describe anything the model did.
2036
+ */
2037
+ case 'read_lines':
2038
+ case 'read_around': {
2039
+ if (executor.root === MEMORY_ROOT) {
2040
+ return {
2041
+ id, name, args, mutated: false,
2042
+ result: { ok: false, error: 'this workspace is held in memory rather than on disk, so windowed reads are unavailable here — use read_file, which reads through the executor.' },
2043
+ };
2044
+ }
2045
+ return { id, name, args, result: readWindow(executor.root, args, name), mutated: false };
2046
+ }
2047
+
2048
+ /**
2049
+ * ── ⭐ THE LANGUAGE SERVER ───────────────────────────────────────────────
2050
+ *
2051
+ * One entry point for all four verbs, so the registration cannot drift from
2052
+ * the schemas — `runLspTool` owns the name→function mapping and lives beside
2053
+ * them. `opts` is left empty: timeouts, server lifetime and the shutdown
2054
+ * grace are lsp.mjs's decisions, and a dispatcher that started overriding
2055
+ * them would become a second place those numbers live.
2056
+ *
2057
+ * ⚠️ `mutated: false` on all four. A language server opens documents in its
2058
+ * own memory; nothing on disk changes.
2059
+ */
2060
+ /**
2061
+ * ── ⚠️ BACKGROUND PROCESSES RIDE WITH `allowRun`, AND MUTATE THE DISK ────
2062
+ *
2063
+ * ⚠️ `mutated: false` ON ALL THREE, AND THE FIRST VERSION GOT THIS WRONG.
2064
+ * The reasoning for `true` was sound — a dev server writes `.next/`, logs and
2065
+ * caches within a second — but `turn.mjs` reads `mutated` to mean "this
2066
+ * record NAMES A FILE", and a process names none. It crashed a real run with
2067
+ * `Cannot read properties of undefined` after the agent had already finished
2068
+ * the task. `turn.mjs` is now hardened against a pathless record too, but the
2069
+ * honest value here is `false`: the AGENT wrote no file, and what a process
2070
+ * it started did to `.next/` is not something this summary can enumerate.
2071
+ */
2072
+ case 'repl':
2073
+ case 'repl_reset': {
2074
+ /**
2075
+ * ── ⚠️⚠️ `--no-run` HELD AT THE OFFER AND NOWHERE ELSE. MEASURED. ────────
2076
+ *
2077
+ * `run_program` (this file, the `allowRun === false` guard above) states
2078
+ * the rule and the reason: *"a model can emit a call for a tool it was
2079
+ * never shown (a resumed session, a stale conversation, a provider echoing
2080
+ * an old tool list), and the flag has to hold at the point the process
2081
+ * would actually start."* `repl` and `start_process` were withheld from
2082
+ * the OFFER (`toolNamesForRounds`, the `allowRun` pushes) and then
2083
+ * dispatched anyway if the call arrived.
2084
+ *
2085
+ * MEASURED 2026-08-14 through the real `executeToolCall` with
2086
+ * `allowRun: false`:
2087
+ * run_program → refused, correctly
2088
+ * repl → RAN THE CODE, `1+1` came back as 2
2089
+ * start_process → STARTED A REAL SERVER, pid 780, still running after
2090
+ *
2091
+ * ⭐ `start_process` is the sharper one, because a background process
2092
+ * OUTLIVES the round: `--no-run` could return having left a server bound
2093
+ * to a port. "Nothing was executed" is the one promise this flag makes.
2094
+ *
2095
+ * ⚠️ `repl_reset` IS NOT GATED, AND THAT IS THE POINT OF SPLITTING THEM.
2096
+ * It executes nothing — it KILLS the child process. Refusing the cleanup
2097
+ * verb because of a flag about running things would strand exactly what
2098
+ * the flag exists to prevent.
2099
+ */
2100
+ if (allowRun === false && name === 'repl') {
2101
+ return {
2102
+ id, name, args, mutated: false,
2103
+ result: { ok: false, error: 'this run was started with --no-run, so no code is executed — the REPL runs the workspace\'s JavaScript for real. Report what you changed and say plainly that nothing was verified.' },
2104
+ };
2105
+ }
2106
+ if (executor.root === MEMORY_ROOT) {
2107
+ return {
2108
+ id, name, args, mutated: false,
2109
+ result: { ok: false, error: 'this workspace is held in memory rather than on disk, so there is no directory for a REPL to run in.' },
2110
+ };
2111
+ }
2112
+ /**
2113
+ * ⚠️ `mutated: false` — the REPL can of course write files if the user's
2114
+ * code does, but the RECORD names no path, and `turn.mjs` reads `mutated`
2115
+ * to mean "this record names a file". Claiming otherwise crashed a real
2116
+ * run when `start_process` did it this morning.
2117
+ */
2118
+ return { id, name, args, result: await runReplTool(name, args, { executor }), mutated: false };
2119
+ }
2120
+
2121
+ case 'start_process':
2122
+ case 'check_process':
2123
+ case 'stop_process': {
2124
+ /**
2125
+ * ⚠️⚠️ THE SAME GAP AS `repl`, AND WORSE — see the note there for the
2126
+ * measurement. A background process is the one thing in this package that
2127
+ * OUTLIVES the round that started it, so a `--no-run` run could finish,
2128
+ * report that nothing was executed, and leave a server holding a port.
2129
+ * MEASURED: pid 780, still in the registry after the call returned.
2130
+ *
2131
+ * ⚠️ ONLY THE VERB THAT STARTS SOMETHING IS GATED. `check_process` reads a
2132
+ * buffer and `stop_process` KILLS a process — refusing those under a flag
2133
+ * that means "do not run things" would leave a live process unreachable,
2134
+ * which is the orphan this module's header says the repo has already paid
2135
+ * for twice.
2136
+ */
2137
+ if (allowRun === false && name === 'start_process') {
2138
+ return {
2139
+ id, name, args, mutated: false,
2140
+ result: { ok: false, error: 'this run was started with --no-run, so no process is started — and a background process would outlive this run holding a port. Report what you changed and say plainly that nothing was verified.' },
2141
+ };
2142
+ }
2143
+ if (executor.root === MEMORY_ROOT) {
2144
+ return {
2145
+ id, name, args, mutated: false,
2146
+ result: { ok: false, error: 'this workspace is held in memory rather than on disk, so there is no directory for a process to run in.' },
2147
+ };
2148
+ }
2149
+ return {
2150
+ id, name, args,
2151
+ result: await runBackgroundTool(name, args, { executor, shell }),
2152
+ mutated: false,
2153
+ };
2154
+ }
2155
+
2156
+ case 'review_code':
2157
+ // ⚠️ Synchronous by design — it reads and analyses, it never spawns.
2158
+ return { id, name, args, result: executeReviewCode(args, { root: executor.root, executor }), mutated: false };
2159
+ case 'inspect_db':
2160
+ case 'sample_db_rows':
2161
+ return { id, name, args, result: await inspectDatabase(executor.root, { ...args, sample: name === 'sample_db_rows' }), mutated: false };
2162
+ case 'gh_issue':
2163
+ case 'gh_pr':
2164
+ case 'gh_run': {
2165
+ /**
2166
+ * ⚠️ The noun is derived from the verb rather than taken from `args`, so a
2167
+ * model cannot reach `gh_run`'s surface by passing `noun: 'run'` to
2168
+ * `gh_issue`. The tool name IS the permission.
2169
+ */
2170
+ const noun = name.slice('gh_'.length);
2171
+ return { id, name, args, result: await executeGh(executor.root, noun, args), mutated: false };
2172
+ }
2173
+ case 'read_log':
2174
+ case 'wait_for_output':
2175
+ case 'summarize_log':
2176
+ return { id, name, args, result: await runLogTailTool(name, args, { executor }), mutated: false };
2177
+ case 'call_endpoint': {
2178
+ /**
2179
+ * ── ⚠️ `--no-run` REACHES HERE TOO, AND FOR A LESS OBVIOUS REASON ──────
2180
+ *
2181
+ * This does not spawn anything, so the usual argument does not apply. It
2182
+ * is withheld anyway because it can ONLY reach a server `start_process`
2183
+ * started, `start_process` is refused under `--no-run`, and a verb that
2184
+ * can only ever answer "there is no such process" is the dead button this
2185
+ * file refuses to ship. Saying so plainly beats a confusing refusal from
2186
+ * the registry check.
2187
+ */
2188
+ if (allowRun === false) {
2189
+ return {
2190
+ id, name, args, mutated: false,
2191
+ result: {
2192
+ ok: false,
2193
+ error: 'this run was started with --no-run, so no server was started and there is nothing local to call. '
2194
+ + 'Report what you changed and say plainly that nothing was verified.',
2195
+ },
2196
+ };
2197
+ }
2198
+ /**
2199
+ * ⚠️ `mutated: false` — a POST changes the SERVER's state, never a file in
2200
+ * this workspace, and `turn.mjs` reads `mutated` to mean "this record
2201
+ * names a path". Claiming otherwise crashed a real run when
2202
+ * `start_process` did it.
2203
+ */
2204
+ return { id, name, args, result: await runHttpProbeTool(name, args, { executor }), mutated: false };
2205
+ }
2206
+
2207
+ case 'find_definition':
2208
+ case 'find_references':
2209
+ case 'check_types':
2210
+ case 'list_symbols': {
2211
+ if (executor.root === MEMORY_ROOT) {
2212
+ return {
2213
+ id, name, args, mutated: false,
2214
+ result: { ok: false, error: 'this workspace is held in memory rather than on disk, and a language server can only read real files — use search_text and read_file instead.' },
2215
+ };
2216
+ }
2217
+ /**
2218
+ * ⚠️ THE REAL LANGUAGE SERVER WINS WHEN IT EXISTS — it covers four
2219
+ * languages, tsserver covers two. But `lspAvailable` being false is the
2220
+ * common case, and falling through to tsserver is what makes these tools
2221
+ * reachable at all. ⚠️ And tsserver is only tried for files it can
2222
+ * actually answer about: handing it a `.py` would produce a confident
2223
+ * refusal from the wrong component.
2224
+ */
2225
+ if (!lspAvailable(executor.root) && tsHandlesFile(args.file)) {
2226
+ return { id, name, args, result: await runTsserverTool(executor.root, name, args), mutated: false };
2227
+ }
2228
+ return { id, name, args, result: await runLspTool(executor.root, name, args), mutated: false };
2229
+ }
2230
+
2231
+ /**
2232
+ * ⚠️ GIT NEEDS A REAL REPOSITORY ON A REAL DISK. A memory workspace has
2233
+ * neither, and `git -C "(memory)"` would fail with something incoherent.
2234
+ * Refused by capability, with a sentence that says why — the model gets
2235
+ * another round and must not spend it retrying.
2236
+ */
2237
+ case 'git_status':
2238
+ case 'git_diff':
2239
+ case 'git_log':
2240
+ case 'git_commit':
2241
+ case 'git_branch':
2242
+ case 'git_push':
2243
+ if (executor.root === '(memory)') {
2244
+ return {
2245
+ id, name, args, mutated: false,
2246
+ result: { ok: false, error: 'this workspace is not backed by a git repository, so git commands are unavailable here' },
2247
+ };
2248
+ }
2249
+ /**
2250
+ * ⚠️ THE GATES HOLD AT THE DISPATCHER, NOT ONLY AT THE OFFER. This file
2251
+ * already paid for that lesson twice — `repl` RAN CODE and
2252
+ * `start_process` STARTED A SERVER under `--no-run`, because their gate
2253
+ * lived in `toolNamesForRounds` alone (see
2254
+ * `test/no-run-holds-at-dispatcher.test.mjs`). A resumed session or a
2255
+ * provider echoing a stale tool list is all it takes.
2256
+ */
2257
+ if (allowRun === false && (name === 'git_commit' || name === 'git_branch' || name === 'git_push')) {
2258
+ return {
2259
+ id, name, args, mutated: false,
2260
+ result: { ok: false, error: `--no-run was passed, so ${name} is not available in this run` },
2261
+ };
2262
+ }
2263
+ // ⚠️ `executor.env` is not set by `createLocalExecutor`; it exists so a
2264
+ // test can hand in a plain `{ root, env }` executor and drive this branch
2265
+ // without mutating the real process environment.
2266
+ if (name === 'git_push' && !pushEnabled(executor.env ?? process.env)) {
2267
+ return {
2268
+ id, name, args, mutated: false,
2269
+ result: { ok: false, error: `pushing is turned off. The operator has to enable it by name: ${ALLOW_PUSH_ENV}=1. Commit the work and hand the branch over instead.` },
2270
+ };
2271
+ }
2272
+ return dispatchGit(name, id, args, executor);
2273
+ default:
2274
+ return {
2275
+ id,
2276
+ name: name ?? '(unnamed)',
2277
+ args,
2278
+ result: { ok: false, error: `unknown tool "${name}" — this CLI implements ${TOOL_NAMES.join(', ')}` },
2279
+ mutated: false,
2280
+ };
2281
+ }
2282
+ }
2283
+
2284
+ /** The git verbs, split out so the capability guard above reads in one glance. */
2285
+ async function dispatchGit(name, id, args, executor) {
2286
+ switch (name) {
2287
+ case 'git_status':
2288
+ return { id, name, args, result: await gitStatus(executor.root), mutated: false };
2289
+ case 'git_diff':
2290
+ return { id, name, args, result: await gitDiff(executor.root, { path: args.path, staged: args.staged === true }), mutated: false };
2291
+ case 'git_log':
2292
+ return { id, name, args, result: await gitLog(executor.root, { count: args.count, path: args.path }), mutated: false };
2293
+ case 'git_branch':
2294
+ return {
2295
+ id, name, args, mutated: false,
2296
+ result: await gitBranch(executor.root, { name: args.name, dryRun: executor.dryRun }),
2297
+ };
2298
+ case 'git_push':
2299
+ return {
2300
+ id, name, args, mutated: false,
2301
+ result: await gitPush(executor.root, {
2302
+ remote: args.remote,
2303
+ openPullRequest: args.openPullRequest === true,
2304
+ pullRequestTitle: args.pullRequestTitle,
2305
+ pullRequestBody: args.pullRequestBody,
2306
+ pullRequestBase: args.pullRequestBase,
2307
+ dryRun: executor.dryRun,
2308
+ env: executor.env ?? process.env,
2309
+ }),
2310
+ };
2311
+ case 'git_commit': {
2312
+ const result = await gitCommit(executor.root, {
2313
+ message: args.message, paths: args.paths, dryRun: executor.dryRun,
2314
+ });
2315
+ /**
2316
+ * ⚠️ `mutated: false` AND THAT IS DELIBERATE. `mutated` feeds the
2317
+ * "N files written" line, which names files this run CHANGED ON DISK.
2318
+ * A commit changes no file contents — counting it would inflate the one
2319
+ * honest number in the summary with files that were already written and
2320
+ * already counted, reporting each of them twice.
2321
+ */
2322
+ return { id, name, args, result, mutated: false };
2323
+ }
2324
+ default:
2325
+ return {
2326
+ id,
2327
+ name: name ?? '(unnamed)',
2328
+ args,
2329
+ result: { ok: false, error: `unknown tool "${name}" — this CLI implements ${TOOL_NAMES.join(', ')}` },
2330
+ mutated: false,
2331
+ };
2332
+ }
2333
+ }