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.
- package/CHANGELOG.md +328 -0
- package/ENTERPRISE.md +927 -0
- package/LICENSE +120 -0
- package/README.md +1245 -0
- package/ROADMAP.md +556 -0
- package/bin/acuvo-mcp.mjs +208 -0
- package/bin/acuvo.mjs +3108 -0
- package/lib/acceptance-consent.mjs +168 -0
- package/lib/acceptance.mjs +859 -0
- package/lib/account.mjs +226 -0
- package/lib/acuvo-dir.mjs +72 -0
- package/lib/acuvo-models.mjs +141 -0
- package/lib/apply-patch.mjs +570 -0
- package/lib/ask-user.mjs +173 -0
- package/lib/audit.mjs +530 -0
- package/lib/auto-lease.mjs +174 -0
- package/lib/background.mjs +842 -0
- package/lib/best-of.mjs +334 -0
- package/lib/board.mjs +232 -0
- package/lib/breaker.mjs +93 -0
- package/lib/budget.mjs +1375 -0
- package/lib/builtin-skills.mjs +135 -0
- package/lib/cache-floor.mjs +204 -0
- package/lib/chain.mjs +303 -0
- package/lib/changed-paths.mjs +84 -0
- package/lib/chat.mjs +434 -0
- package/lib/checkpoint.mjs +637 -0
- package/lib/child-lifetime.mjs +71 -0
- package/lib/cli-args.mjs +1255 -0
- package/lib/code-review.mjs +1382 -0
- package/lib/colour.mjs +82 -0
- package/lib/command.mjs +2847 -0
- package/lib/compact.mjs +1151 -0
- package/lib/completion.mjs +515 -0
- package/lib/creative-engines.mjs +779 -0
- package/lib/db-inspect.mjs +1624 -0
- package/lib/delete.mjs +111 -0
- package/lib/design-loop.mjs +570 -0
- package/lib/diff-preview.mjs +1044 -0
- package/lib/doctor.mjs +2139 -0
- package/lib/dropped.mjs +216 -0
- package/lib/edit-diagnostics.mjs +277 -0
- package/lib/edit.mjs +460 -0
- package/lib/env-file.mjs +250 -0
- package/lib/escalate.mjs +702 -0
- package/lib/evaluate.mjs +284 -0
- package/lib/fetch-text.mjs +952 -0
- package/lib/fleet-budget.mjs +256 -0
- package/lib/gh.mjs +1536 -0
- package/lib/git.mjs +1341 -0
- package/lib/github.mjs +261 -0
- package/lib/h2.mjs +194 -0
- package/lib/handoff.mjs +417 -0
- package/lib/hooks.mjs +626 -0
- package/lib/http-probe.mjs +907 -0
- package/lib/image-director.mjs +322 -0
- package/lib/image-edit.mjs +522 -0
- package/lib/imagegen.mjs +998 -0
- package/lib/interrupt.mjs +234 -0
- package/lib/learned.mjs +353 -0
- package/lib/lease-watch.mjs +115 -0
- package/lib/lease.mjs +868 -0
- package/lib/localize.mjs +834 -0
- package/lib/log-tail.mjs +1052 -0
- package/lib/login.mjs +157 -0
- package/lib/lsp.mjs +1613 -0
- package/lib/mcp-consent.mjs +377 -0
- package/lib/mcp-defaults.mjs +780 -0
- package/lib/mcp-server.mjs +1343 -0
- package/lib/mcp.mjs +1263 -0
- package/lib/media.mjs +1283 -0
- package/lib/memory-workspace.mjs +179 -0
- package/lib/model-json.mjs +132 -0
- package/lib/model-tier.mjs +171 -0
- package/lib/model.mjs +1445 -0
- package/lib/parallel.mjs +144 -0
- package/lib/perchance.mjs +210 -0
- package/lib/plan-coherence.mjs +1461 -0
- package/lib/plan-ledger.mjs +981 -0
- package/lib/plan.mjs +461 -0
- package/lib/policy.mjs +783 -0
- package/lib/prefix-order.mjs +38 -0
- package/lib/project-memory.mjs +127 -0
- package/lib/prompt.mjs +109 -0
- package/lib/python.mjs +862 -0
- package/lib/rcfile.mjs +853 -0
- package/lib/read-window.mjs +743 -0
- package/lib/refute-tools.mjs +34 -0
- package/lib/refute.mjs +806 -0
- package/lib/repl-driver.mjs +264 -0
- package/lib/repl.mjs +324 -0
- package/lib/replay.mjs +1218 -0
- package/lib/repo-map.mjs +1101 -0
- package/lib/report.mjs +419 -0
- package/lib/search-rank.mjs +99 -0
- package/lib/search.mjs +659 -0
- package/lib/secret-paths.mjs +54 -0
- package/lib/session.mjs +1017 -0
- package/lib/skills.mjs +703 -0
- package/lib/slash.mjs +356 -0
- package/lib/spawn-argv.mjs +1151 -0
- package/lib/spend.mjs +250 -0
- package/lib/steer.mjs +280 -0
- package/lib/stream.mjs +253 -0
- package/lib/stuck.mjs +712 -0
- package/lib/subagent.mjs +749 -0
- package/lib/terminal-graphics.mjs +171 -0
- package/lib/tool-prefix.mjs +226 -0
- package/lib/tool-shortlist.mjs +162 -0
- package/lib/tools.mjs +2333 -0
- package/lib/tsserver.mjs +423 -0
- package/lib/turn.mjs +5672 -0
- package/lib/untrusted-block.mjs +271 -0
- package/lib/verify-claim.mjs +299 -0
- package/lib/vision.mjs +330 -0
- package/lib/voice-task.mjs +561 -0
- package/lib/warm-provider.mjs +255 -0
- package/lib/websearch.mjs +401 -0
- package/lib/workspace.mjs +928 -0
- package/lib/write-approval.mjs +235 -0
- package/lib/write-many.mjs +162 -0
- package/package.json +62 -0
- package/scripts/bundle.mjs +768 -0
- package/scripts/cache-floor.mjs +176 -0
- package/scripts/machine.mjs +226 -0
- package/scripts/test.mjs +139 -0
- package/skills/accessibility.md +87 -0
- package/skills/acuvo-design-system.md +123 -0
- package/skills/animation.md +84 -0
- package/skills/api-design.md +82 -0
- package/skills/auth-and-sessions.md +78 -0
- package/skills/build-with-a-framework.md +101 -0
- package/skills/colour-and-contrast.md +112 -0
- package/skills/creative-engines.md +81 -0
- package/skills/css-layout.md +85 -0
- package/skills/data-and-charts.md +77 -0
- package/skills/debugging.md +76 -0
- package/skills/designing-by-looking.md +84 -0
- package/skills/error-handling.md +78 -0
- package/skills/forms-and-validation.md +93 -0
- package/skills/nextjs-app-router.md +75 -0
- package/skills/page-composition.md +103 -0
- package/skills/performance.md +77 -0
- package/skills/plan-before-building.md +52 -0
- package/skills/planning-and-delegating.md +72 -0
- package/skills/refactoring.md +70 -0
- package/skills/security-basics.md +76 -0
- package/skills/state-management.md +73 -0
- package/skills/supabase-multitenant.md +72 -0
- package/skills/typescript-strict.md +90 -0
- package/skills/typography.md +135 -0
- package/skills/verify-your-own-work.md +62 -0
- package/skills/web-app-quality.md +62 -0
- 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
|
+
}
|