@henols/vice-mcp 0.2.1 → 0.2.2

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 (57) hide show
  1. package/README.md +2 -1
  2. package/THIRD-PARTY-NOTICES.md +1 -24
  3. package/{r2000-acme-ident.ts → anno-acme-ident.ts} +13 -13
  4. package/anno-cli.ts +1465 -0
  5. package/{r2000-confidence.ts → anno-confidence.ts} +22 -22
  6. package/anno-coverage.ts +2465 -0
  7. package/{r2000-d64.ts → anno-d64.ts} +5 -5
  8. package/anno-derive.ts +590 -0
  9. package/anno-details.ts +169 -0
  10. package/anno-enum-gen.ts +533 -0
  11. package/anno-export-asm.ts +1310 -0
  12. package/anno-index.ts +150 -0
  13. package/{r2000-memmap-render.ts → anno-memmap-render.ts} +236 -95
  14. package/{r2000-regbits-gen.ts → anno-regbits-gen.ts} +20 -15
  15. package/{r2000-regbits.json → anno-regbits.json} +2 -2
  16. package/anno-register.ts +240 -0
  17. package/anno-store.ts +3486 -0
  18. package/anno-symbols.ts +266 -0
  19. package/anno-tools.ts +2111 -0
  20. package/anno-types.ts +1636 -0
  21. package/block-class.ts +201 -0
  22. package/build.ts +1 -1
  23. package/capability-registry.ts +3 -1
  24. package/disasm-decoder.ts +14 -14
  25. package/disasm-opcodes.ts +4 -4
  26. package/disasm-renderer.ts +2 -2
  27. package/hostpath.ts +1 -1
  28. package/install-resources.ts +1 -1
  29. package/package.json +23 -17
  30. package/prg-image.ts +119 -0
  31. package/repo-root.ts +20 -5
  32. package/resources/broker-launch.mjs +8 -4
  33. package/resources/vice-launcher.sh +3 -3
  34. package/stock-address.ts +5 -5
  35. package/stock-cia.ts +2 -2
  36. package/stock-condition.ts +7 -7
  37. package/stock-connect.ts +1 -1
  38. package/stock-dispatch.ts +35 -5
  39. package/stock-execution.ts +5 -3
  40. package/stock-input.ts +9 -9
  41. package/stock-machine.ts +17 -6
  42. package/stock-protocol.ts +16 -11
  43. package/stock-registers.ts +54 -29
  44. package/stock-sprites.ts +3 -3
  45. package/stock-symbols.ts +9 -9
  46. package/stock-timing.ts +1 -1
  47. package/stock-vicii.ts +1 -1
  48. package/version.ts +1 -1
  49. package/vice-proxy.ts +68 -46
  50. package/r2000-cli.ts +0 -1103
  51. package/r2000-enum-gen.ts +0 -574
  52. package/r2000-launch.ts +0 -357
  53. package/r2000-mcp-client.ts +0 -596
  54. package/r2000-project.ts +0 -190
  55. package/r2000-symbols.ts +0 -388
  56. package/r2000-tools.ts +0 -914
  57. package/r2000-verify.ts +0 -184
@@ -1,596 +0,0 @@
1
- #!/usr/bin/env node
2
- // r2000-mcp-client.ts -- the ONE authoritative place in this repo that
3
- // speaks MCP as a CLIENT.
4
- //
5
- // WHY THIS MODULE EXISTS (D-16): every prior phase made this repo an MCP
6
- // *server*, answering `tools/call` from Claude Code. Phase 11's `r2000_*`
7
- // surface requires the opposite role for the first time -- this repo must
8
- // spawn `regenerator2000 --mcp-server-stdio`, send it JSON-RPC requests, and
9
- // trust (or refuse to trust) its answers. That reversal is why this module
10
- // gets the phase's most explicit failure handling: every `r2000_*` tool,
11
- // the enum generator, the memory-map renderer and both symbol-round-trip
12
- // legs all run through this one seam, so a bug here is not local to one
13
- // tool -- it is the phase's single point of failure.
14
- //
15
- // WHAT THIS IS THE ONE AUTHORITATIVE PLACE FOR: spawning
16
- // `regenerator2000 --mcp-server-stdio`, framing/parsing its newline-
17
- // delimited JSON-RPC messages, correlating requests by id, and translating
18
- // every one of its failure modes into a named, typed error. No other
19
- // module may spawn `--mcp-server-stdio` or parse a JSON-RPC frame --
20
- // `r2000-tools.ts` (plan 11-05) knows tool NAMES and the curated allow-list,
21
- // never the protocol; the enum generator (11-06) and memory-map renderer
22
- // (11-08) call through `callR2000()`/`withR2000Session()`, never around them.
23
- //
24
- // CLIENT-SHAPE VERDICT (measured in r2000-mcp-client.test.ts against the
25
- // installed `@mastra/mcp` 1.15.0, Task 2 of this plan): of the five required
26
- // properties (bounded-time failure on an unanswered call, a mid-call exit
27
- // distinct from a timeout, a named ENOENT spawn failure, the spawned
28
- // child's exit code reachable after its session closes, and its stderr
29
- // reachable and attributable to the call), FOUR measured `satisfied` and
30
- // ONE did not. The deciding property is **exit-code reachability**:
31
- // `MCPClient`'s entire public prototype (reflected at test time, not merely
32
- // read from its `.d.ts`) exposes no member for retrieving a spawned child's
33
- // exit status once its session has closed. Per the plan's decision rule
34
- // (use `MCPClient` if and only if ALL FIVE measure `satisfied`), this
35
- // module is therefore a hand-rolled newline-delimited JSON-RPC client, not
36
- // a wrapper over `MCPClient` -- exactly because a lying zero-exit-plus-
37
- // success-text transcript is `r2000-verify.ts`'s own founding incident
38
- // (D-10), and this module's `saveAndVerify()` below exists specifically to
39
- // never repeat it.
40
- //
41
- // WHAT NOT TO DO, named concretely:
42
- // - Never keep a child alive between logical operations (D-17). The
43
- // lifecycle is spawn -> initialize -> call(s) -> (optional save) ->
44
- // stdin close -> exit, once per `withR2000Session()` call. There is no
45
- // long-lived child, no supervision, and no second wedge class to add to
46
- // this project's existing stock-VICE one.
47
- // - Never import the underlying MCP TypeScript SDK package directly. It
48
- // is reachable today only as an undeclared transitive dependency of
49
- // `@mastra/mcp` (hoisted into this project's own `node_modules` by
50
- // npm's current dedup pass, not declared in package.json) -- a direct
51
- // import here would be an ENGINEERING_RULES.md §4 phantom-dependency
52
- // violation waiting for a dedup change to break it with no
53
- // package.json line to explain why.
54
- // - Never import the host/container path-boundary modules here.
55
- // regenerator2000 runs container-side (D-R4, same side as the MCP
56
- // proxy, Rule A16), so no path translation ever applies to any
57
- // argument passed to it -- translating one would be the mirror image
58
- // of the DERIV-07 screenshot-path trap, where a client-side-derived
59
- // path was wrongly translated a second time. This absence is asserted
60
- // structurally by the closed host-path consumer-set test (D-08), not
61
- // merely stated here.
62
- // - Never report `r2000_save_project` as persisted on the strength of its
63
- // own text response (`{"content":[{"type":"text","text":"Project saved
64
- // to <path>"}]}` is a string, not a checksum). `saveAndVerify()` below
65
- // proves persistence independently, by re-reading the project file's
66
- // own content hash from disk before and after -- never by trusting what
67
- // the child said about itself.
68
- import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
69
- import { createInterface } from "node:readline";
70
- import { readFileSync } from "node:fs";
71
- import { createHash } from "node:crypto";
72
-
73
- import { buildMcpServerStdioArgs, assertNoViceFlag } from "./r2000-launch.ts";
74
-
75
- /** The MCP protocol version regenerator2000's stdio handler hardcodes
76
- * server-side (`handler.rs:16`, confirmed by direct call and by
77
- * RESEARCH.md's live reproduction). Not negotiated; sent verbatim on every
78
- * `initialize` request. */
79
- export const R2000_PROTOCOL_VERSION = "2024-11-05";
80
-
81
- /** The single named constant every per-call timeout in this module derives
82
- * from -- never a magic number at a call site. Overridable per call via
83
- * `WithR2000SessionOptions.timeoutMs`. 30s matches this repo's own
84
- * documented "the one call that can be slow" caveat
85
- * (`r2000_search_disassembly` on a large program, plan objective). */
86
- export const DEFAULT_R2000_CALL_TIMEOUT_MS = 30_000;
87
-
88
- // -- Error classes -----------------------------------------------------
89
- //
90
- // Every failure mode this module can produce is a DISTINCT, named class,
91
- // mirroring `vice.ts`'s `ViceError`/`MachineRestartedError` pattern. A
92
- // caller (or a test) must be able to tell these apart by `instanceof`, not
93
- // by parsing message text.
94
-
95
- export interface R2000ClientErrorOptions {
96
- cause?: unknown;
97
- }
98
-
99
- /** Base class for every error this module throws. Never thrown directly --
100
- * always one of the named subclasses below. */
101
- export class R2000ClientError extends Error {
102
- constructor(message: string, { cause }: R2000ClientErrorOptions = {}) {
103
- super(message);
104
- this.name = "R2000ClientError";
105
- if (cause !== undefined) {
106
- (this as { cause?: unknown }).cause = cause;
107
- }
108
- }
109
- }
110
-
111
- /** Thrown when spawning `regenerator2000` itself fails, most commonly
112
- * `ENOENT` (not installed / not on `$PATH`). Mirrors `runR2000()`'s own
113
- * message shape (`r2000-launch.ts`) so both spawn call sites in this repo
114
- * give a caller the identical remediation text. */
115
- export class R2000SpawnError extends R2000ClientError {
116
- constructor(message: string, opts: R2000ClientErrorOptions = {}) {
117
- super(message, opts);
118
- this.name = "R2000SpawnError";
119
- }
120
- }
121
-
122
- export interface R2000ProtocolErrorOptions extends R2000ClientErrorOptions {
123
- code: number;
124
- data?: unknown;
125
- }
126
-
127
- /** Thrown when regenerator2000's own response carries a JSON-RPC `error`
128
- * object, OR a `tools/call` response's `CallToolResult.isError` is `true`.
129
- * Surfaces `code` and `message` verbatim -- never flattened into a generic
130
- * failure -- so a caller can distinguish "unknown tool" from "invalid
131
- * arguments" from "internal error" without re-parsing this module's own
132
- * error message. */
133
- export class R2000ProtocolError extends R2000ClientError {
134
- code: number;
135
- data?: unknown;
136
-
137
- constructor(message: string, { code, data, ...rest }: R2000ProtocolErrorOptions) {
138
- super(message, rest);
139
- this.name = "R2000ProtocolError";
140
- this.code = code;
141
- this.data = data;
142
- }
143
- }
144
-
145
- export interface R2000TimeoutErrorOptions extends R2000ClientErrorOptions {
146
- timeoutMs: number;
147
- }
148
-
149
- /** Thrown when a request receives no response within `timeoutMs` --
150
- * BOUNDED, never an indefinite hang. Distinct from `R2000ChildExitError`:
151
- * this class means "the child is still alive but never answered"; that one
152
- * means "the child is gone". A caller must never need to parse message text
153
- * to tell the two apart. */
154
- export class R2000TimeoutError extends R2000ClientError {
155
- timeoutMs: number;
156
-
157
- constructor(message: string, { timeoutMs, ...rest }: R2000TimeoutErrorOptions) {
158
- super(message, rest);
159
- this.name = "R2000TimeoutError";
160
- this.timeoutMs = timeoutMs;
161
- }
162
- }
163
-
164
- export interface R2000ChildExitErrorOptions extends R2000ClientErrorOptions {
165
- exitCode: number | null;
166
- stderr: string;
167
- }
168
-
169
- /** Thrown when the child process exits while one or more requests are
170
- * still pending an answer -- distinct from `R2000TimeoutError` (which means
171
- * the child is still alive) and from `R2000SessionFailedError` (which means
172
- * every call already succeeded and ONLY the final exit was bad). Carries
173
- * both the exit code and everything captured on stderr, so a caller never
174
- * has to re-derive "why did it die" from a bare non-zero number. */
175
- export class R2000ChildExitError extends R2000ClientError {
176
- exitCode: number | null;
177
- stderr: string;
178
-
179
- constructor(message: string, { exitCode, stderr, ...rest }: R2000ChildExitErrorOptions) {
180
- super(message, rest);
181
- this.name = "R2000ChildExitError";
182
- this.exitCode = exitCode;
183
- this.stderr = stderr;
184
- }
185
- }
186
-
187
- export interface R2000SessionFailedErrorOptions extends R2000ClientErrorOptions {
188
- exitCode: number | null;
189
- stderr: string;
190
- }
191
-
192
- /**
193
- * Thrown when every call inside a `withR2000Session()` block succeeded, but
194
- * the child's FINAL exit code (after stdin was closed) was non-zero. The
195
- * whole session fails in this case -- never just a warning -- because
196
- * `r2000-verify.ts`'s own founding incident (D-10) is the exact opposite
197
- * shape of lie: a zero exit code alongside content that should have failed.
198
- * This class is the mirror image, refusing to let an otherwise-clean
199
- * transcript hide a bad exit.
200
- */
201
- export class R2000SessionFailedError extends R2000ClientError {
202
- exitCode: number | null;
203
- stderr: string;
204
-
205
- constructor(message: string, { exitCode, stderr, ...rest }: R2000SessionFailedErrorOptions) {
206
- super(message, rest);
207
- this.name = "R2000SessionFailedError";
208
- this.exitCode = exitCode;
209
- this.stderr = stderr;
210
- }
211
- }
212
-
213
- export interface R2000SaveNotPersistedErrorOptions extends R2000ClientErrorOptions {
214
- projectPath: string;
215
- beforeHash: string | null;
216
- afterHash: string | null;
217
- }
218
-
219
- /**
220
- * Thrown by `saveAndVerify()` when `r2000_save_project` returns successfully
221
- * but the project file's own content hash, re-read from disk, did not
222
- * change. This is the module's highest-value refusal (T-11-FALSESUCCESS):
223
- * `r2000_save_project`'s success text
224
- * (`{"content":[{"type":"text","text":"Project saved to <path>"}]}`) is a
225
- * string, not a checksum, and this class is what stands between that string
226
- * and a caller believing the save actually happened.
227
- */
228
- export class R2000SaveNotPersistedError extends R2000ClientError {
229
- projectPath: string;
230
- beforeHash: string | null;
231
- afterHash: string | null;
232
-
233
- constructor(message: string, { projectPath, beforeHash, afterHash, ...rest }: R2000SaveNotPersistedErrorOptions) {
234
- super(message, rest);
235
- this.name = "R2000SaveNotPersistedError";
236
- this.projectPath = projectPath;
237
- this.beforeHash = beforeHash;
238
- this.afterHash = afterHash;
239
- }
240
- }
241
-
242
- // -- The wire shape ---------------------------------------------------
243
-
244
- interface JsonRpcRequest {
245
- jsonrpc: "2.0";
246
- id?: number;
247
- method: string;
248
- params?: unknown;
249
- }
250
-
251
- interface JsonRpcResponse {
252
- jsonrpc?: string;
253
- id?: number;
254
- result?: unknown;
255
- error?: { code: number; message: string; data?: unknown };
256
- }
257
-
258
- interface CallToolResultShape {
259
- content?: unknown;
260
- isError?: boolean;
261
- structuredContent?: unknown;
262
- }
263
-
264
- /** The function a caller receives inside `withR2000Session()`'s callback to
265
- * invoke a single `tools/call` against the live session. */
266
- export type R2000Call = (name: string, args?: Record<string, unknown>) => Promise<unknown>;
267
-
268
- export interface WithR2000SessionOptions {
269
- /** Per-request timeout, overriding `DEFAULT_R2000_CALL_TIMEOUT_MS`. */
270
- timeoutMs?: number;
271
- /**
272
- * Overrides the regenerator2000 binary to spawn for this call only.
273
- * Mirrors `r2000-launch.ts`'s own `R2000_BIN` env-var override
274
- * convention, but resolved FRESH on every `withR2000Session()` call
275
- * (`process.env.R2000_BIN` is read at call time here, deliberately never
276
- * imported as a frozen module-level constant the way `r2000-launch.ts`'s
277
- * own `R2000_BIN` export is) -- this repo's own test files are all
278
- * co-located in one `node:test` process per file, sharing one module
279
- * cache, and a stub-server test suite needs to point several different
280
- * stub behaviours at the same spawn call within that one process without
281
- * restarting it. Defaults to `process.env.R2000_BIN ?? "regenerator2000"`,
282
- * the exact same default `r2000-launch.ts` uses.
283
- */
284
- bin?: string;
285
- /**
286
- * Overrides the argv this session spawns with, in place of
287
- * `buildMcpServerStdioArgs({ projectPath })`. Added for 11-08's
288
- * `r2000-symbols.ts` `importLabels()` (D-28): its argv is
289
- * `buildImportLblArgs({ projectPath, lblPath })` --
290
- * `["--import_lbl", lblPath, "--mcp-server-stdio", projectPath]` -- which
291
- * this module's own default argv cannot express (it is fixed to plain
292
- * `--mcp-server-stdio`). A caller-supplied `argv` MUST come from one of
293
- * `r2000-launch.ts`'s fixed builders, never be hand-built at the call site
294
- * (D-07) -- still passed through `assertNoViceFlag()` below like every
295
- * other argv this module builds, so the guard applies regardless of which
296
- * builder produced it. Omitted (the default, every OTHER caller in this
297
- * repo) preserves the original plain-session behaviour exactly.
298
- */
299
- argv?: readonly string[];
300
- }
301
-
302
- interface PendingRequest {
303
- resolve: (value: unknown) => void;
304
- reject: (err: Error) => void;
305
- }
306
-
307
- /**
308
- * Spawns `regenerator2000 --mcp-server-stdio <projectPath>` (via
309
- * `buildMcpServerStdioArgs()`, so `assertNoViceFlag()`'s guard applies here
310
- * too, defense in depth even though this builder can never itself emit
311
- * `--vice`), performs the `initialize` handshake, invokes `fn` with a
312
- * `call(name, args)` function bound to this one session, then closes stdin
313
- * (ending the child's read loop per `mcp/stdio.rs:72`,
314
- * `while reader.read_line(...) > 0`), waits for exit, and resolves ONLY
315
- * after the exit code and captured stderr have been inspected (D-17: one
316
- * session per logical operation, never a long-lived child).
317
- *
318
- * Every failure mode below is a distinct named error class -- see the
319
- * class definitions above for what each one means and how it differs from
320
- * its neighbours.
321
- */
322
- export async function withR2000Session<T>(
323
- projectPath: string,
324
- fn: (call: R2000Call) => Promise<T>,
325
- opts: WithR2000SessionOptions = {}
326
- ): Promise<T> {
327
- const timeoutMs = opts.timeoutMs ?? DEFAULT_R2000_CALL_TIMEOUT_MS;
328
- const bin = opts.bin ?? process.env.R2000_BIN ?? "regenerator2000";
329
- const argv = opts.argv ?? buildMcpServerStdioArgs({ projectPath });
330
- assertNoViceFlag(argv);
331
-
332
- const child = spawn(bin, argv, { stdio: ["pipe", "pipe", "pipe"] }) as ChildProcessWithoutNullStreams;
333
-
334
- await waitForSpawn(child);
335
-
336
- let stderrBuf = "";
337
- child.stderr.on("data", (chunk: Buffer) => {
338
- stderrBuf += chunk.toString("utf8");
339
- });
340
-
341
- const pending = new Map<number, PendingRequest>();
342
- let nextId = 1;
343
- let childExited = false;
344
- let exitCode: number | null = null;
345
-
346
- const exitPromise = new Promise<void>((resolve) => {
347
- child.once("exit", (code) => {
348
- childExited = true;
349
- exitCode = code;
350
- // Any request still awaiting an answer at this point died with the
351
- // child -- distinct from a timeout, since the child (not the clock)
352
- // is what ended it.
353
- for (const [, p] of pending) {
354
- p.reject(
355
- new R2000ChildExitError(
356
- `regenerator2000 exited (code ${code}) with a request still pending an answer -- stderr: ${stderrBuf || "(empty)"}`,
357
- { exitCode: code, stderr: stderrBuf }
358
- )
359
- );
360
- }
361
- pending.clear();
362
- resolve();
363
- });
364
- });
365
-
366
- const rl = createInterface({ input: child.stdout });
367
- rl.on("line", (line) => {
368
- if (!line.trim()) return;
369
- let msg: JsonRpcResponse;
370
- try {
371
- msg = JSON.parse(line) as JsonRpcResponse;
372
- } catch {
373
- // A non-JSON line from regenerator2000's stdout has never been
374
- // observed (RESEARCH.md's live reproduction: exactly one JSON message
375
- // per line, both directions) -- ignored rather than crashing the
376
- // session over stray output, but never mistaken for a response.
377
- return;
378
- }
379
- if (msg.id === undefined || msg.id === null) return; // a notification, not a response we're waiting on
380
- const p = pending.get(msg.id);
381
- if (!p) return; // D-08/CLAUDE.md: an id with no matching pending request is REFUSED, never resolved
382
- pending.delete(msg.id);
383
- if (msg.error) {
384
- p.reject(
385
- new R2000ProtocolError(`r2000 JSON-RPC error ${msg.error.code}: ${msg.error.message}`, {
386
- code: msg.error.code,
387
- data: msg.error.data,
388
- })
389
- );
390
- } else {
391
- p.resolve(msg.result);
392
- }
393
- });
394
-
395
- function send(req: JsonRpcRequest): void {
396
- child.stdin.write(`${JSON.stringify(req)}\n`);
397
- }
398
-
399
- function request(method: string, params?: unknown): Promise<unknown> {
400
- if (childExited) {
401
- return Promise.reject(
402
- new R2000ChildExitError(`regenerator2000 has already exited (code ${exitCode}) -- cannot send "${method}"`, {
403
- exitCode,
404
- stderr: stderrBuf,
405
- })
406
- );
407
- }
408
- const id = nextId++;
409
- return new Promise<unknown>((resolve, reject) => {
410
- const timer = setTimeout(() => {
411
- pending.delete(id);
412
- reject(
413
- new R2000TimeoutError(`r2000 "${method}" received no response within ${timeoutMs}ms`, { timeoutMs })
414
- );
415
- }, timeoutMs);
416
- pending.set(id, {
417
- resolve: (value) => {
418
- clearTimeout(timer);
419
- resolve(value);
420
- },
421
- reject: (err) => {
422
- clearTimeout(timer);
423
- reject(err);
424
- },
425
- });
426
- send({ jsonrpc: "2.0", id, method, params });
427
- });
428
- }
429
-
430
- try {
431
- await request("initialize", {
432
- protocolVersion: R2000_PROTOCOL_VERSION,
433
- capabilities: {},
434
- clientInfo: { name: "vice-mcp", version: "0" },
435
- });
436
- // A notification, per MCP spec -- no id, no response expected.
437
- send({ jsonrpc: "2.0", method: "notifications/initialized" });
438
-
439
- const call: R2000Call = async (name, args = {}) => {
440
- const result = (await request("tools/call", { name, arguments: args })) as CallToolResultShape;
441
- if (result && result.isError) {
442
- throw new R2000ProtocolError(
443
- `r2000 tool "${name}" reported isError: true -- ${JSON.stringify(result.content ?? null)}`,
444
- { code: -1 }
445
- );
446
- }
447
- return result;
448
- };
449
-
450
- let fnResult: T;
451
- try {
452
- fnResult = await fn(call);
453
- } finally {
454
- // Ends the child's read loop (mcp/stdio.rs:72) regardless of whether
455
- // fn() threw -- a session always tries to close cleanly.
456
- child.stdin.end();
457
- await Promise.race([exitPromise, killAfter(child, timeoutMs)]);
458
- rl.close();
459
- }
460
-
461
- // T-11-FALSESUCCESS's mirror image (D-17): every call succeeded, but if
462
- // the FINAL exit was non-zero, the whole session still fails.
463
- if (exitCode !== 0 && exitCode !== null) {
464
- throw new R2000SessionFailedError(
465
- `regenerator2000 exited ${exitCode} after an otherwise-successful call sequence -- stderr: ${stderrBuf || "(empty)"}`,
466
- { exitCode, stderr: stderrBuf }
467
- );
468
- }
469
-
470
- return fnResult;
471
- } catch (err) {
472
- // Make sure a thrown fn()/request() error still closes stdin and reaps
473
- // the child rather than leaking it -- the try/finally above already
474
- // covers the "fn() itself threw" path; this covers "initialize itself
475
- // threw", where the inner try/finally never ran.
476
- if (!childExited) {
477
- try {
478
- child.stdin.end();
479
- } catch {
480
- /* already closed */
481
- }
482
- await Promise.race([exitPromise, killAfter(child, timeoutMs)]);
483
- }
484
- rl.close();
485
- throw err;
486
- }
487
- }
488
-
489
- /** Waits for either a successful spawn (`"spawn"` event, Node >= 15) or a
490
- * spawn failure (`"error"` event), translating `ENOENT` into the same
491
- * remediation text `runR2000()` uses (`r2000-launch.ts`) so both spawn call
492
- * sites in this repo give identical advice. */
493
- function waitForSpawn(child: ChildProcessWithoutNullStreams): Promise<void> {
494
- return new Promise((resolve, reject) => {
495
- const onSpawn = () => {
496
- child.removeListener("error", onError);
497
- resolve();
498
- };
499
- const onError = (err: NodeJS.ErrnoException) => {
500
- child.removeListener("spawn", onSpawn);
501
- if (err.code === "ENOENT") {
502
- reject(
503
- new R2000SpawnError(
504
- `regenerator2000 was not found on PATH -- install it with \`cargo install regenerator2000\` and ` +
505
- `ensure \`regenerator2000\` is on $PATH (or set R2000_BIN to its full path).`,
506
- { cause: err }
507
- )
508
- );
509
- } else {
510
- reject(new R2000SpawnError(`failed to spawn regenerator2000: ${err.message}`, { cause: err }));
511
- }
512
- };
513
- child.once("spawn", onSpawn);
514
- child.once("error", onError);
515
- });
516
- }
517
-
518
- /** Resolves after `ms` and force-kills `child` -- the bound on "wait for
519
- * exit after closing stdin", so a child that never exits cannot hang a
520
- * session forever. Races against the real exit event in both call sites
521
- * above via `Promise.race`. */
522
- function killAfter(child: ChildProcessWithoutNullStreams, ms: number): Promise<void> {
523
- return new Promise((resolve) => {
524
- const timer = setTimeout(() => {
525
- if (!child.killed) child.kill("SIGKILL");
526
- resolve();
527
- }, ms);
528
- child.once("exit", () => {
529
- clearTimeout(timer);
530
- resolve();
531
- });
532
- });
533
- }
534
-
535
- /**
536
- * The single-call convenience wrapper over `withR2000Session()` -- spawns a
537
- * session, makes exactly one `tools/call`, and closes it. Most `r2000_*`
538
- * tool implementations (plan 11-05) need only this; `withR2000Session()`
539
- * itself is for the enum-generation and symbol-round-trip flows that need
540
- * multiple calls (or a save) inside one session.
541
- */
542
- export async function callR2000(
543
- projectPath: string,
544
- name: string,
545
- args: Record<string, unknown> = {},
546
- opts: WithR2000SessionOptions = {}
547
- ): Promise<unknown> {
548
- return withR2000Session(projectPath, (call) => call(name, args), opts);
549
- }
550
-
551
- /** SHA-256 hex digest of a file's current bytes, or `null` if the file does
552
- * not exist (a save's very first call has no "before" state to compare
553
- * against). Never mtime/size -- RESEARCH.md's own D-17 wording is explicit
554
- * that "size + mtime change is not enough". */
555
- function hashFileOrNull(path: string): string | null {
556
- try {
557
- return createHash("sha256").update(readFileSync(path)).digest("hex");
558
- } catch (err) {
559
- if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
560
- throw err;
561
- }
562
- }
563
-
564
- export interface SaveAndVerifyResult {
565
- /** The project file's content hash after the save, independently
566
- * re-read from disk. */
567
- hash: string;
568
- }
569
-
570
- /**
571
- * Calls `r2000_save_project` over the given live session, then proves
572
- * persistence independently by re-reading `projectPath`'s own content hash
573
- * from disk before and after -- NEVER by trusting `r2000_save_project`'s own
574
- * text response (`{"content":[{"type":"text","text":"Project saved to
575
- * <path>"}]}` is a string, not a checksum). Throws
576
- * `R2000SaveNotPersistedError` (naming `projectPath`) when the hash did not
577
- * change, rather than returning a `{ saved: false }` result a caller could
578
- * accidentally ignore -- this IS the phase's highest-value client
579
- * refusal (T-11-FALSESUCCESS), so it fails loudly, not quietly.
580
- */
581
- export async function saveAndVerify(projectPath: string, call: R2000Call): Promise<SaveAndVerifyResult> {
582
- const beforeHash = hashFileOrNull(projectPath);
583
- await call("r2000_save_project", {});
584
- const afterHash = hashFileOrNull(projectPath);
585
-
586
- if (afterHash === beforeHash) {
587
- throw new R2000SaveNotPersistedError(
588
- `r2000_save_project reported success for "${projectPath}" but its content hash on disk is ` +
589
- `unchanged (still ${afterHash ?? "absent -- file does not exist"}) -- refusing to report success ` +
590
- `on the strength of the child's own text response.`,
591
- { projectPath, beforeHash, afterHash }
592
- );
593
- }
594
-
595
- return { hash: afterHash! };
596
- }