acuvo-code 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/CHANGELOG.md +328 -0
  2. package/ENTERPRISE.md +927 -0
  3. package/LICENSE +120 -0
  4. package/README.md +1245 -0
  5. package/ROADMAP.md +556 -0
  6. package/bin/acuvo-mcp.mjs +208 -0
  7. package/bin/acuvo.mjs +3108 -0
  8. package/lib/acceptance-consent.mjs +168 -0
  9. package/lib/acceptance.mjs +859 -0
  10. package/lib/account.mjs +226 -0
  11. package/lib/acuvo-dir.mjs +72 -0
  12. package/lib/acuvo-models.mjs +141 -0
  13. package/lib/apply-patch.mjs +570 -0
  14. package/lib/ask-user.mjs +173 -0
  15. package/lib/audit.mjs +530 -0
  16. package/lib/auto-lease.mjs +174 -0
  17. package/lib/background.mjs +842 -0
  18. package/lib/best-of.mjs +334 -0
  19. package/lib/board.mjs +232 -0
  20. package/lib/breaker.mjs +93 -0
  21. package/lib/budget.mjs +1375 -0
  22. package/lib/builtin-skills.mjs +135 -0
  23. package/lib/cache-floor.mjs +204 -0
  24. package/lib/chain.mjs +303 -0
  25. package/lib/changed-paths.mjs +84 -0
  26. package/lib/chat.mjs +434 -0
  27. package/lib/checkpoint.mjs +637 -0
  28. package/lib/child-lifetime.mjs +71 -0
  29. package/lib/cli-args.mjs +1255 -0
  30. package/lib/code-review.mjs +1382 -0
  31. package/lib/colour.mjs +82 -0
  32. package/lib/command.mjs +2847 -0
  33. package/lib/compact.mjs +1151 -0
  34. package/lib/completion.mjs +515 -0
  35. package/lib/creative-engines.mjs +779 -0
  36. package/lib/db-inspect.mjs +1624 -0
  37. package/lib/delete.mjs +111 -0
  38. package/lib/design-loop.mjs +570 -0
  39. package/lib/diff-preview.mjs +1044 -0
  40. package/lib/doctor.mjs +2139 -0
  41. package/lib/dropped.mjs +216 -0
  42. package/lib/edit-diagnostics.mjs +277 -0
  43. package/lib/edit.mjs +460 -0
  44. package/lib/env-file.mjs +250 -0
  45. package/lib/escalate.mjs +702 -0
  46. package/lib/evaluate.mjs +284 -0
  47. package/lib/fetch-text.mjs +952 -0
  48. package/lib/fleet-budget.mjs +256 -0
  49. package/lib/gh.mjs +1536 -0
  50. package/lib/git.mjs +1341 -0
  51. package/lib/github.mjs +261 -0
  52. package/lib/h2.mjs +194 -0
  53. package/lib/handoff.mjs +417 -0
  54. package/lib/hooks.mjs +626 -0
  55. package/lib/http-probe.mjs +907 -0
  56. package/lib/image-director.mjs +322 -0
  57. package/lib/image-edit.mjs +522 -0
  58. package/lib/imagegen.mjs +998 -0
  59. package/lib/interrupt.mjs +234 -0
  60. package/lib/learned.mjs +353 -0
  61. package/lib/lease-watch.mjs +115 -0
  62. package/lib/lease.mjs +868 -0
  63. package/lib/localize.mjs +834 -0
  64. package/lib/log-tail.mjs +1052 -0
  65. package/lib/login.mjs +157 -0
  66. package/lib/lsp.mjs +1613 -0
  67. package/lib/mcp-consent.mjs +377 -0
  68. package/lib/mcp-defaults.mjs +780 -0
  69. package/lib/mcp-server.mjs +1343 -0
  70. package/lib/mcp.mjs +1263 -0
  71. package/lib/media.mjs +1283 -0
  72. package/lib/memory-workspace.mjs +179 -0
  73. package/lib/model-json.mjs +132 -0
  74. package/lib/model-tier.mjs +171 -0
  75. package/lib/model.mjs +1445 -0
  76. package/lib/parallel.mjs +144 -0
  77. package/lib/perchance.mjs +210 -0
  78. package/lib/plan-coherence.mjs +1461 -0
  79. package/lib/plan-ledger.mjs +981 -0
  80. package/lib/plan.mjs +461 -0
  81. package/lib/policy.mjs +783 -0
  82. package/lib/prefix-order.mjs +38 -0
  83. package/lib/project-memory.mjs +127 -0
  84. package/lib/prompt.mjs +109 -0
  85. package/lib/python.mjs +862 -0
  86. package/lib/rcfile.mjs +853 -0
  87. package/lib/read-window.mjs +743 -0
  88. package/lib/refute-tools.mjs +34 -0
  89. package/lib/refute.mjs +806 -0
  90. package/lib/repl-driver.mjs +264 -0
  91. package/lib/repl.mjs +324 -0
  92. package/lib/replay.mjs +1218 -0
  93. package/lib/repo-map.mjs +1101 -0
  94. package/lib/report.mjs +419 -0
  95. package/lib/search-rank.mjs +99 -0
  96. package/lib/search.mjs +659 -0
  97. package/lib/secret-paths.mjs +54 -0
  98. package/lib/session.mjs +1017 -0
  99. package/lib/skills.mjs +703 -0
  100. package/lib/slash.mjs +356 -0
  101. package/lib/spawn-argv.mjs +1151 -0
  102. package/lib/spend.mjs +250 -0
  103. package/lib/steer.mjs +280 -0
  104. package/lib/stream.mjs +253 -0
  105. package/lib/stuck.mjs +712 -0
  106. package/lib/subagent.mjs +749 -0
  107. package/lib/terminal-graphics.mjs +171 -0
  108. package/lib/tool-prefix.mjs +226 -0
  109. package/lib/tool-shortlist.mjs +162 -0
  110. package/lib/tools.mjs +2333 -0
  111. package/lib/tsserver.mjs +423 -0
  112. package/lib/turn.mjs +5672 -0
  113. package/lib/untrusted-block.mjs +271 -0
  114. package/lib/verify-claim.mjs +299 -0
  115. package/lib/vision.mjs +330 -0
  116. package/lib/voice-task.mjs +561 -0
  117. package/lib/warm-provider.mjs +255 -0
  118. package/lib/websearch.mjs +401 -0
  119. package/lib/workspace.mjs +928 -0
  120. package/lib/write-approval.mjs +235 -0
  121. package/lib/write-many.mjs +162 -0
  122. package/package.json +62 -0
  123. package/scripts/bundle.mjs +768 -0
  124. package/scripts/cache-floor.mjs +176 -0
  125. package/scripts/machine.mjs +226 -0
  126. package/scripts/test.mjs +139 -0
  127. package/skills/accessibility.md +87 -0
  128. package/skills/acuvo-design-system.md +123 -0
  129. package/skills/animation.md +84 -0
  130. package/skills/api-design.md +82 -0
  131. package/skills/auth-and-sessions.md +78 -0
  132. package/skills/build-with-a-framework.md +101 -0
  133. package/skills/colour-and-contrast.md +112 -0
  134. package/skills/creative-engines.md +81 -0
  135. package/skills/css-layout.md +85 -0
  136. package/skills/data-and-charts.md +77 -0
  137. package/skills/debugging.md +76 -0
  138. package/skills/designing-by-looking.md +84 -0
  139. package/skills/error-handling.md +78 -0
  140. package/skills/forms-and-validation.md +93 -0
  141. package/skills/nextjs-app-router.md +75 -0
  142. package/skills/page-composition.md +103 -0
  143. package/skills/performance.md +77 -0
  144. package/skills/plan-before-building.md +52 -0
  145. package/skills/planning-and-delegating.md +72 -0
  146. package/skills/refactoring.md +70 -0
  147. package/skills/security-basics.md +76 -0
  148. package/skills/state-management.md +73 -0
  149. package/skills/supabase-multitenant.md +72 -0
  150. package/skills/typescript-strict.md +90 -0
  151. package/skills/typography.md +135 -0
  152. package/skills/verify-your-own-work.md +62 -0
  153. package/skills/web-app-quality.md +62 -0
  154. package/skills/working-in-the-background.md +64 -0
@@ -0,0 +1,264 @@
1
+ /**
2
+ * ── THE PROCESS THAT HOLDS THE STATE ───────────────────────────────────────
3
+ *
4
+ * Spawned once per REPL session and talked to over stdin/stdout in JSON lines.
5
+ * It is deliberately NOT `node -i`: the interactive REPL is built for a human,
6
+ * so its output is prompts, echoes and continuation state, and every agent that
7
+ * has tried to drive one ends up writing a parser for `> ` that breaks on the
8
+ * first multi-line paste. A protocol we own is smaller than that parser, and
9
+ * cannot be confused by output that happens to contain a prompt character.
10
+ *
11
+ * ⚠️ IT RUNS IN THE WORKSPACE, so `await import('./lib/thing.mjs')` reaches the
12
+ * project being worked on. A REPL that cannot open the project it is sitting in
13
+ * is a calculator.
14
+ *
15
+ * Protocol, one JSON object per line each way:
16
+ * in { id, code, timeoutMs }
17
+ * out { id, ok, value, error, logs, ms, defined }
18
+ */
19
+
20
+ import { createInterface } from 'node:readline';
21
+ import { inspect } from 'node:util';
22
+ import { Script, constants as vmConstants } from 'node:vm';
23
+ import { resolve } from 'node:path';
24
+ import { pathToFileURL } from 'node:url';
25
+
26
+ const MAX_VALUE_CHARS = 4_000;
27
+ const MAX_LOG_CHARS = 4_000;
28
+
29
+ /** Names this session is carrying, so the model never has to guess its own state. */
30
+ const defined = new Set();
31
+
32
+ /**
33
+ * ⚠️⚠️ DECLARATIONS BECOME ASSIGNMENTS, AND THE FIRST VERSION DID NOT DO THIS.
34
+ *
35
+ * It wrapped each submission in an AsyncFunction body. Measured immediately:
36
+ * `const a = 21` then `a * 2` gave **"a is not defined"** — a `const` inside a
37
+ * function body dies with the call, so the one property this tool exists for was
38
+ * the one it did not have. A stateful REPL that forgets is `evaluate` with extra
39
+ * moving parts.
40
+ *
41
+ * Top-level await forces a wrapper, and a wrapper re-introduces that scope
42
+ * problem, so a leading declaration is rewritten onto `globalThis` instead.
43
+ *
44
+ * ⚠️ DELIBERATELY CONSERVATIVE: simple `const x =`, `let x =`, `function f`,
45
+ * `class C`. Destructuring (`const { a, b } = …`) is left alone and therefore
46
+ * does NOT persist — said plainly in the tool description rather than
47
+ * half-handled, because a transform that is wrong about `const [a, ...rest] = x`
48
+ * would corrupt the user's code rather than fail it.
49
+ */
50
+ function liftDeclarations(src) {
51
+ const names = [];
52
+ const out = src
53
+ .replace(/^[ \t]*(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/gm, (_m, name) => {
54
+ names.push(name);
55
+ return `globalThis.${name} =`;
56
+ })
57
+ .replace(/^[ \t]*(function|class)\s+([A-Za-z_$][\w$]*)/gm, (_m, kind, name) => {
58
+ names.push(name);
59
+ return `globalThis.${name} = ${kind} ${name}`;
60
+ });
61
+ return { code: out, names };
62
+ }
63
+
64
+ /**
65
+ * ⚠️ RELATIVE IMPORTS RESOLVE AGAINST THIS FILE, NOT THE WORKSPACE. Measured:
66
+ * `await import("./lib/budget.mjs")` returned ERR_MODULE_NOT_FOUND pointing at
67
+ * this driver's own directory. The specifier is rewritten to an absolute file
68
+ * URL from the working directory, which IS the workspace.
69
+ */
70
+ function absolutiseImports(src) {
71
+ return src.replace(/\bimport\(\s*(['"])(\.[^'"]*)\1\s*\)/g, (_m, q, spec) => {
72
+ const url = pathToFileURL(resolve(process.cwd(), spec)).href;
73
+ return `import(${q}${url}${q})`;
74
+ });
75
+ }
76
+
77
+ function render(value) {
78
+ if (value === undefined) return 'undefined';
79
+ try {
80
+ const text = typeof value === 'string'
81
+ ? value
82
+ : inspect(value, { depth: 3, maxArrayLength: 50, breakLength: 100 });
83
+ return text.length > MAX_VALUE_CHARS ? `${text.slice(0, MAX_VALUE_CHARS)}\n… (truncated)` : text;
84
+ } catch (e) {
85
+ return `[unrenderable: ${e?.message ?? e}]`;
86
+ }
87
+ }
88
+
89
+ /**
90
+ * ⭐ THE LAST EXPRESSION IS RETURNED. `const x = 2` yielding undefined is
91
+ * correct; `x * 3` yielding nothing would make the tool write-only. Same
92
+ * heuristic every REPL uses — a lone expression is returned, anything that
93
+ * starts with a statement keyword runs as a block.
94
+ */
95
+ function looksLikeExpression(src) {
96
+ const t = src.trim();
97
+ if (t === '') return false;
98
+ if (/^(const|let|var|function|class|import|export|return|if|for|while|switch|try|throw|do|globalThis\.)/.test(t)) return false;
99
+ if (/;\s*\S/.test(t)) return false;
100
+ if (t.includes('\n')) return false;
101
+ return true;
102
+ }
103
+
104
+ const SCRIPT_OPTIONS = {
105
+ filename: 'repl',
106
+ /**
107
+ * ⚠️ WITHOUT THIS, `await import(...)` THROWS
108
+ * ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING — code compiled through `vm` has no
109
+ * module loader attached, so the single most valuable thing this REPL does
110
+ * (open the project) failed while everything else worked.
111
+ * `USE_MAIN_CONTEXT_DEFAULT_LOADER` borrows the real one, so imports resolve
112
+ * exactly as they would in any other file, with no flags.
113
+ */
114
+ importModuleDynamically: vmConstants.USE_MAIN_CONTEXT_DEFAULT_LOADER,
115
+ };
116
+
117
+ /**
118
+ * ── ⭐⭐ SEVERAL STATEMENTS NOW RETURN THE LAST ONE'S VALUE ──────────────────
119
+ *
120
+ * `const z = 5; z*2` used to answer `undefined`. Documented, not broken — but
121
+ * ROUNDS ARE THE UNIT OF COST HERE, and it cost the model a whole extra round to
122
+ * fetch a value it had already computed. That is the most expensive kind of
123
+ * "documented".
124
+ *
125
+ * ⭐ AND THE FIX NEEDS NO PARSER, WHICH IS THE ONLY REASON IT IS SAFE. The
126
+ * obvious implementation — find the last statement and prefix it with `return` —
127
+ * is string surgery on JavaScript, and it corrupts the user's code the first
128
+ * time a `;` appears inside a string literal or a submission ends in a `//`
129
+ * comment. **V8 already computes this**: it is the completion value of a Script,
130
+ * the exact thing `node -i` prints, and asking for it is free.
131
+ *
132
+ * ⚠️ WRAPPED IN A BLOCK, NOT RUN BARE, AND THAT BRACE IS LOAD-BEARING. MEASURED
133
+ * both ways: run bare, a script-level `const` becomes a GLOBAL LEXICAL binding,
134
+ * so the second submission of `const {q,w} = {q:1,w:2}` dies with "Identifier
135
+ * 'q' has already been declared" — a stateful tool poisoned by its own history.
136
+ * Inside a block those declarations are block-scoped and die with the
137
+ * submission, exactly as they do inside today's function wrapper, and the
138
+ * completion value still propagates out. Measured: bare 1st=3 2nd=THREW;
139
+ * block 1st=3 2nd=3.
140
+ *
141
+ * ⚠️⚠️ THE FALLBACK IS COMPILE-ONLY, AND THAT IS THE SAFETY PROPERTY. Some
142
+ * submissions are legal in a function body and illegal in a block — `return 9`
143
+ * is the common one. Those are caught HERE, at `new Script`, before a single
144
+ * byte of user code has run, so re-compiling them the old way cannot repeat a
145
+ * side effect. VERIFIED by counting: a fallback submission that increments a
146
+ * counter leaves it at 1, not 2. Anything that worked before still works, and
147
+ * the failure direction is "behaves exactly as it did yesterday".
148
+ *
149
+ * ⚠️ `await` KEEPS THE WRAPPER, so a submission containing one still answers
150
+ * `undefined` unless it says `return`. A completion value does not exist inside
151
+ * a function body, and top-level await does not exist outside one; there is no
152
+ * arrangement that gives both. The tool description says `return` out loud
153
+ * instead of pretending otherwise.
154
+ *
155
+ * ⭐ FREE BUG FIX ON THE WAY PAST: today's wrapper is built on ONE LINE, so
156
+ * `const p = 2\n// trailing comment` compiled to `(async () => { globalThis.p =
157
+ * 2\n// trailing comment })()` — the closing brace landed INSIDE the comment and
158
+ * the submission died with "Unexpected end of input", an error with nothing to
159
+ * do with the user's code. Both forms now put the braces on their own lines.
160
+ */
161
+ /**
162
+ * ⚠️ THE NARROWING THAT KEEPS EVERY SINGLE-STATEMENT ANSWER EXACTLY AS IT WAS.
163
+ *
164
+ * These are the same two clauses `looksLikeExpression` uses to REJECT — a `;`
165
+ * with something after it, or a newline — so between them the two functions
166
+ * partition submissions into three cases with no overlap and no gap: one
167
+ * expression, one statement, several statements. Only the third case changes.
168
+ *
169
+ * ⭐ IT IS ALSO THE CASE THE DEFECT NAMED: "one expression returns its value;
170
+ * several statements run as a block". So `const q = 2` still answers
171
+ * `undefined` — a lone declaration, and the answer the existing test pins — and
172
+ * it stays cheap: a single `const big = readFileSync(…)` does not start dumping
173
+ * a file into the reply as its assignment value.
174
+ */
175
+ function looksMultiStatement(src) {
176
+ const t = src.trim();
177
+ return /;\s*\S/.test(t) || t.includes('\n');
178
+ }
179
+
180
+ function compileSubmission(src) {
181
+ if (!looksLikeExpression(src) && looksMultiStatement(src)) {
182
+ try {
183
+ return { script: new Script(`{\n${src}\n}`, SCRIPT_OPTIONS), completion: true };
184
+ } catch {
185
+ /* not legal as a block — fall through to the wrapper, having run nothing */
186
+ }
187
+ }
188
+ const body = looksLikeExpression(src) ? `return (${src});` : src;
189
+ return { script: new Script(`(async () => {\n${body}\n})()`, SCRIPT_OPTIONS), completion: false };
190
+ }
191
+
192
+ async function run(code, timeoutMs) {
193
+ const logs = [];
194
+ const original = { log: console.log, error: console.error, warn: console.warn };
195
+ const capture = (...args) => {
196
+ logs.push(args.map((a) => (typeof a === 'string' ? a : render(a))).join(' '));
197
+ };
198
+ console.log = capture;
199
+ console.error = capture;
200
+ console.warn = capture;
201
+
202
+ const started = Date.now();
203
+ try {
204
+ const lifted = liftDeclarations(absolutiseImports(code));
205
+ for (const n of lifted.names) defined.add(n);
206
+
207
+ /**
208
+ * ⭐ RUN IN *THIS* CONTEXT, either way. The real global is shared — so
209
+ * `process`, `fetch` and timers behave exactly as they do in the code under
210
+ * test — and the lifted declarations are already `globalThis.x =`, so they
211
+ * outlive whichever wrapper was chosen.
212
+ *
213
+ * ⚠️ `vm.createContext` would also persist state and would quietly DIFFER:
214
+ * a different global means a different `process`, and the value of this tool
215
+ * is that what happens here is what happens there.
216
+ */
217
+ const compiled = compileSubmission(lifted.code);
218
+ const result = await Promise.race([
219
+ compiled.script.runInThisContext(),
220
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`timed out after ${timeoutMs}ms`)), timeoutMs)),
221
+ ]);
222
+
223
+ return { ok: true, value: render(result), logs, ms: Date.now() - started };
224
+ } catch (e) {
225
+ /**
226
+ * ⚠️ THE STACK IS KEPT. Returning only `e.message` for a TypeError deep in
227
+ * the project makes debugging harder than a console.log would have been.
228
+ */
229
+ return {
230
+ ok: false,
231
+ error: String(e?.stack ?? e ?? 'unknown error').slice(0, MAX_VALUE_CHARS),
232
+ logs,
233
+ ms: Date.now() - started,
234
+ };
235
+ } finally {
236
+ console.log = original.log;
237
+ console.error = original.error;
238
+ console.warn = original.warn;
239
+ }
240
+ }
241
+
242
+ const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
243
+
244
+ rl.on('line', async (line) => {
245
+ const text = line.trim();
246
+ if (text === '') return;
247
+ let msg;
248
+ try { msg = JSON.parse(text); } catch { return; }
249
+ if (msg?.code === undefined) return;
250
+
251
+ const out = await run(String(msg.code), Number(msg.timeoutMs) || 15_000);
252
+ out.id = msg.id;
253
+ out.defined = [...defined];
254
+ const joined = (out.logs ?? []).join('\n');
255
+ out.logs = joined.length > MAX_LOG_CHARS ? `${joined.slice(0, MAX_LOG_CHARS)}\n… (truncated)` : joined;
256
+ process.stdout.write(`${JSON.stringify(out)}\n`);
257
+ });
258
+
259
+ /**
260
+ * ⚠️ A CLOSED STDIN MEANS THE PARENT IS GONE. Exit rather than linger as an
261
+ * orphan holding the workspace open — the failure this repo has already paid for
262
+ * twice, once with a pid that ran until reboot.
263
+ */
264
+ rl.on('close', () => process.exit(0));
package/lib/repl.mjs ADDED
@@ -0,0 +1,324 @@
1
+ /**
2
+ * ── ⭐⭐ A REPL THE AGENT CONVERSES WITH — AND NOBODY ELSE HAS ONE ───────────
3
+ *
4
+ * `evaluate` writes a snippet to a file and runs it in a fresh process. So does
5
+ * every other terminal coding agent: Claude Code, Cursor, Codex and Aider all
6
+ * execute statelessly, and none of them can hold a value between two thoughts.
7
+ *
8
+ * ⚠️ THAT IS NOT HOW ANYBODY DEBUGS. A human loads the module, looks at a value,
9
+ * pokes the object, tries the fix on the live thing. Statelessly, the agent must
10
+ * RE-DERIVE THE ENTIRE SETUP to check one field — which is exactly why models
11
+ * rewrite a whole script to answer a question a single expression would settle,
12
+ * and why each of those rewrites is another paid round.
13
+ *
14
+ * Measured shape of the win: reading a config, parsing it, and inspecting one
15
+ * nested key is three `evaluate` calls that each re-read and re-parse, or three
16
+ * lines in one REPL session where line 2 can see line 1.
17
+ *
18
+ * ── WHY THIS IS RARE RATHER THAN MERELY MISSING ─────────────────────────────
19
+ *
20
+ * It is rare because it is annoying: `node -i` is built for a human, so driving
21
+ * it means parsing prompts, echoes and continuation state, and that parser
22
+ * breaks on the first multi-line paste. `repl-driver.mjs` sidesteps all of it
23
+ * with a JSON-lines protocol we own — which is less code than the parser would
24
+ * have been, and cannot be confused by output that happens to contain `> `.
25
+ *
26
+ * ⚠️ SAME GOVERNANCE AS EVERY OTHER PROCESS. `--no-run` withholds it, a dry run
27
+ * refuses it, it is killed on exit and on Ctrl-C with the whole tree, and there
28
+ * is a hard cap on how long one expression may run. A stateful process is more
29
+ * useful than a stateless one and exactly as dangerous, so it gets exactly the
30
+ * same locks.
31
+ */
32
+
33
+ import { spawn } from 'node:child_process';
34
+ import { exitIsDeferred } from './interrupt.mjs';
35
+ import { fileURLToPath } from 'node:url';
36
+ import { detachChild, deleteIfCurrent } from './child-lifetime.mjs';
37
+
38
+ import { killProcessTree, childEnvironment } from './command.mjs';
39
+
40
+ const DRIVER = fileURLToPath(new URL('./repl-driver.mjs', import.meta.url));
41
+
42
+ /** One expression should not be able to hang the run. */
43
+ export const DEFAULT_EVAL_TIMEOUT_MS = 15_000;
44
+ export const MAX_EVAL_TIMEOUT_MS = 60_000;
45
+
46
+ /** Source cap, matching `evaluate` — past this, it belongs in a file. */
47
+ export const MAX_CODE_CHARS = 4_000;
48
+
49
+ /** ⚠️ ONE SESSION PER WORKSPACE. Two would mean two truths about the same state. */
50
+ const sessions = new Map();
51
+ let hooked = false;
52
+
53
+ function installExitHooks() {
54
+ if (hooked) return;
55
+ hooked = true;
56
+ const killAll = () => {
57
+ for (const s of sessions.values()) releaseSession(s);
58
+ sessions.clear();
59
+ };
60
+ process.once('exit', killAll);
61
+ for (const [sig, code] of [['SIGINT', 130], ['SIGTERM', 143], ['SIGBREAK', 149]]) {
62
+ try { process.once(sig, () => { killAll(); if (!exitIsDeferred()) process.exit(code); }); } catch { /* no SIGBREAK off Windows */ }
63
+ }
64
+ }
65
+
66
+ function start(root, { spawnImpl = spawn, env = process.env } = {}) {
67
+ const child = spawnImpl(process.execPath, [DRIVER], {
68
+ cwd: root,
69
+ stdio: ['pipe', 'pipe', 'pipe'],
70
+ windowsHide: true,
71
+ detached: process.platform !== 'win32',
72
+ /**
73
+ * ── ⚠️⚠️⚠️ THIS SPAWN HAD NO `env`, SO IT INHERITED EVERY SECRET ────────
74
+ *
75
+ * Without an `env` option the child gets `process.env` whole — the
76
+ * OpenRouter key, the Acuvo account token, AWS credentials, everything on
77
+ * the machine. And the repl exists to run **model-written JavaScript**, so
78
+ * one `repl` call could read them all back out. That bypassed the entire
79
+ * `run_command` allowlist, which is the control this package leans on
80
+ * hardest: a program the model may not run is irrelevant if it can just
81
+ * `process.env` its way past it.
82
+ *
83
+ * ⭐ EVERY SIBLING SPAWNER ALREADY DID THIS RIGHT — `evaluate.mjs:224`,
84
+ * `background.mjs`, `command.mjs` all pass `childEnvironment(...)`. The repl
85
+ * was the one that did not, which is exactly how a security control with
86
+ * one uncovered door reads as present while being absent.
87
+ *
88
+ * `childEnvironment` scrubs secret-shaped names and sets
89
+ * `npm_config_ignore_scripts` unless npm is genuinely the program.
90
+ */
91
+ env: childEnvironment({ file: process.execPath, args: [DRIVER] }, env),
92
+ });
93
+
94
+ const session = { child, root, buffer: '', pending: new Map(), seq: 0, defined: [] };
95
+
96
+ child.stdout?.setEncoding?.('utf8');
97
+ child.stdout?.on?.('data', (chunk) => {
98
+ session.buffer += chunk;
99
+ let nl;
100
+ while ((nl = session.buffer.indexOf('\n')) !== -1) {
101
+ const line = session.buffer.slice(0, nl);
102
+ session.buffer = session.buffer.slice(nl + 1);
103
+ if (!line.trim()) continue;
104
+ let msg;
105
+ try { msg = JSON.parse(line); } catch { continue; }
106
+ const entry = session.pending.get(msg.id);
107
+ if (entry) { session.pending.delete(msg.id); clearTimeout(entry.timer); entry.resolve(msg); }
108
+ }
109
+ });
110
+
111
+ child.stderr?.setEncoding?.('utf8');
112
+ child.stderr?.on?.('data', () => { /* the driver reports errors as data, not on stderr */ });
113
+
114
+ /**
115
+ * ⚠️⚠️ A DEAD CHILD MUST NOT EVICT ITS OWN SUCCESSOR.
116
+ *
117
+ * `exit` arrives asynchronously — after `replReset` has already deleted this
118
+ * entry and, very often, after a later `replEval` has registered a REPLACEMENT
119
+ * session under the same root. An unconditional `sessions.delete(root)` then
120
+ * removes the LIVE session's entry, so the next `replReset` reports "no REPL
121
+ * was running" and nothing ever releases that child. It holds the event loop
122
+ * open for the life of the process.
123
+ *
124
+ * Measured 2026-08-12: reset → eval → reset hangs forever; a single reset
125
+ * exits in 0.4s. It is what hung the suite.
126
+ */
127
+ child.on?.('exit', () => {
128
+ for (const [, e] of session.pending) {
129
+ clearTimeout(e.timer);
130
+ e.resolve({ ok: false, error: 'the REPL process exited — its state is gone. The next repl call starts a fresh one.' });
131
+ }
132
+ session.pending.clear();
133
+ deleteIfCurrent(sessions, root, session);
134
+ });
135
+
136
+ /**
137
+ * ⭐ A REPL CHILD MUST NEVER DECIDE WHEN ITS OWNER EXITS. Without this, a
138
+ * process that started a REPL and never reset it can never exit, and
139
+ * `installExitHooks`'s cleanup deadlocks: it runs on 'exit', which cannot fire.
140
+ */
141
+ detachChild(child);
142
+
143
+ installExitHooks();
144
+ sessions.set(root, session);
145
+ return session;
146
+ }
147
+
148
+ /**
149
+ * Evaluate one expression or block, keeping everything it defines.
150
+ *
151
+ * @returns {Promise<object>}
152
+ */
153
+ export async function replEval(root, code, { timeoutMs = DEFAULT_EVAL_TIMEOUT_MS, dryRun = false, spawnImpl } = {}) {
154
+ if (dryRun) {
155
+ return { ok: false, error: 'this is a --dry-run, so nothing is evaluated (a REPL runs your code for real)' };
156
+ }
157
+ if (typeof code !== 'string' || code.trim() === '') {
158
+ return { ok: false, error: 'code is required — the JavaScript to evaluate' };
159
+ }
160
+ if (code.length > MAX_CODE_CHARS) {
161
+ return {
162
+ ok: false,
163
+ error: `the code is ${code.length} characters, over the ${MAX_CODE_CHARS} limit — write it to a real file and import it here instead`,
164
+ };
165
+ }
166
+
167
+ const bounded = Math.min(Math.max(500, timeoutMs), MAX_EVAL_TIMEOUT_MS);
168
+ const session = sessions.get(root) ?? start(root, { spawnImpl });
169
+ const fresh = session.seq === 0;
170
+ session.seq += 1;
171
+ const id = session.seq;
172
+
173
+ const reply = await new Promise((resolve) => {
174
+ const timer = setTimeout(() => {
175
+ session.pending.delete(id);
176
+ /**
177
+ * ⚠️ A TIMEOUT KILLS THE SESSION, and it has to. The driver is
178
+ * single-threaded, so an expression stuck in a loop means every later
179
+ * call would queue behind it forever — a REPL that answers nothing is
180
+ * worse than no REPL, because the model keeps waiting instead of trying
181
+ * something else.
182
+ */
183
+ try { killProcessTree(session.child); } catch { /* gone */ }
184
+ sessions.delete(root);
185
+ resolve({
186
+ ok: false,
187
+ error: `the expression did not finish within ${bounded}ms, so the REPL was stopped and its state discarded. `
188
+ + 'An infinite loop or a hanging await is the usual cause.',
189
+ });
190
+ }, bounded + 500);
191
+ session.pending.set(id, { resolve, timer });
192
+ try {
193
+ session.child.stdin.write(`${JSON.stringify({ id, code, timeoutMs: bounded })}\n`);
194
+ } catch (e) {
195
+ clearTimeout(timer);
196
+ session.pending.delete(id);
197
+ resolve({ ok: false, error: `could not reach the REPL: ${e?.message ?? e}` });
198
+ }
199
+ });
200
+
201
+ if (reply.defined) session.defined = reply.defined;
202
+ return {
203
+ ...reply,
204
+ /**
205
+ * ⭐ THE NAMES IT IS CARRYING, returned every time. The one thing a model
206
+ * cannot know about a stateful tool is what state it is in, and guessing is
207
+ * how it re-declares a const and gets a confusing error.
208
+ */
209
+ defined: session.defined,
210
+ startedFresh: fresh,
211
+ };
212
+ }
213
+
214
+ /**
215
+ * ── ⚠️⚠️ STOPPING MUST NOT BE A RACE ────────────────────────────────────────
216
+ *
217
+ * `killProcessTree` is ASYNCHRONOUS on Windows: it spawns `taskkill /T /F` and
218
+ * returns before the child is gone. Killing alone is therefore not enough to
219
+ * let THIS process exit — we opened three pipes to that child, and Node keeps
220
+ * an event loop alive for them. A process with no work left still cannot die.
221
+ *
222
+ * ⭐ That is not a theoretical window. It hung the full suite on 2026-08-12
223
+ * with every test already passed, and it only bites under load — when the
224
+ * machine is busy, the reaper is slow, which is exactly when the suite is
225
+ * slowest and a hang is least distinguishable from work.
226
+ *
227
+ * ⚠️ AND KILLING FIRST IS BACKWARDS. `taskkill` guts the child mid-flight and
228
+ * leaves this side's pipes half-open, so destroying them afterwards does not
229
+ * return the libuv handles. Measured: `replReset` alone leaves one ProcessWrap
230
+ * and five PipeWrap behind, and that one test hangs its own file.
231
+ *
232
+ * ⭐ THE DRIVER ALREADY KNOWS HOW TO DIE. `repl-driver.mjs` ends with
233
+ * `rl.on('close', () => process.exit(0))` — closing its stdin IS the shutdown
234
+ * signal. Ask nicely, unref so we never wait, and keep the reaper only as a
235
+ * fallback for a driver wedged so badly it stopped reading stdin. Cooperative
236
+ * shutdown is what actually releases the handles; the kill is insurance.
237
+ */
238
+ const STOP_GRACE_MS = 2_000;
239
+
240
+ function releaseSession(session) {
241
+ const child = session.child;
242
+ // 1. The shutdown signal the driver is already listening for.
243
+ try { child?.stdin?.end?.(); } catch { /* already closed */ }
244
+ // 2. Never let a child we have finished with hold this process open.
245
+ try { child?.unref?.(); } catch { /* a stubbed child in a test */ }
246
+ // 3. Insurance only. Unref'd, so if we are exiting anyway it never fires and
247
+ // the OS reaps the child — nothing waits on this.
248
+ try {
249
+ const t = setTimeout(() => {
250
+ try { killProcessTree(child); } catch { /* already gone */ }
251
+ }, STOP_GRACE_MS);
252
+ t.unref?.();
253
+ } catch { /* no timers in this context */ }
254
+ }
255
+
256
+ /** Throw the session away. Idempotent — resetting nothing is a success. */
257
+ export function replReset(root) {
258
+ const session = sessions.get(root);
259
+ if (!session) return { ok: true, reset: false, note: 'no REPL was running, so there was nothing to clear' };
260
+ releaseSession(session);
261
+ sessions.delete(root);
262
+ return { ok: true, reset: true, note: 'the REPL was stopped and every variable it held is gone' };
263
+ }
264
+
265
+ /** For tests and teardown. */
266
+ export function replStopAll() {
267
+ for (const s of sessions.values()) releaseSession(s);
268
+ sessions.clear();
269
+ }
270
+
271
+ export const REPL_TOOL_NAMES = ['repl', 'repl_reset'];
272
+
273
+ export function replToolSchemas() {
274
+ return [
275
+ {
276
+ type: 'function',
277
+ function: {
278
+ name: 'repl',
279
+ description: [
280
+ 'Evaluate JavaScript in a session that REMEMBERS what you defined — the next call can see it.',
281
+ 'Top-level await works, so `const m = await import("./lib/thing.mjs")` then `m.parse("x")` on the next call',
282
+ 'is two lines instead of two whole scripts.',
283
+ 'Use this to INVESTIGATE: load a module, look at a real value, poke an object, try the fix on the live thing',
284
+ 'before you write it to a file. It runs in the workspace, so relative imports reach the project.',
285
+ 'You get the value of the LAST expression back, so `const z = 5; z*2` answers 10 — do the work and read',
286
+ 'the answer in ONE call rather than spending a second round fetching a value you already computed.',
287
+ 'The one exception is a submission that uses await: say `return` for the value you want',
288
+ '(`const m = await import("./lib/x.mjs"); return m.parse("a")`).',
289
+ ].join(' '),
290
+ parameters: {
291
+ type: 'object',
292
+ properties: {
293
+ /**
294
+ * ⚠️ THE `await` CAVEAT IS ON THE PARAMETER TOO, not only in the
295
+ * description. A model reading one and not the other is the reason a
296
+ * documented-but-surprising behaviour costs a round.
297
+ */
298
+ code: { type: 'string', description: 'JavaScript. The last expression\'s value comes back — `const z = 5; z*2` answers 10. If the code uses await, write `return <expression>` for the value you want.' },
299
+ },
300
+ required: ['code'],
301
+ },
302
+ },
303
+ },
304
+ {
305
+ type: 'function',
306
+ function: {
307
+ name: 'repl_reset',
308
+ description: 'Throw away the REPL session and everything it holds. Use it when the state has become confusing, or after changing a file you already imported — imports are cached, so a stale module is the one real trap here.',
309
+ parameters: { type: 'object', properties: {} },
310
+ },
311
+ },
312
+ ];
313
+ }
314
+
315
+ export async function runReplTool(name, args = {}, { executor } = {}) {
316
+ switch (name) {
317
+ case 'repl':
318
+ return replEval(executor.root, String(args.code ?? ''), { dryRun: executor.dryRun });
319
+ case 'repl_reset':
320
+ return replReset(executor.root);
321
+ default:
322
+ return { ok: false, error: `"${name}" is not a repl tool` };
323
+ }
324
+ }