@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.
- package/README.md +2 -1
- package/THIRD-PARTY-NOTICES.md +1 -24
- package/{r2000-acme-ident.ts → anno-acme-ident.ts} +13 -13
- package/anno-cli.ts +1465 -0
- package/{r2000-confidence.ts → anno-confidence.ts} +22 -22
- package/anno-coverage.ts +2465 -0
- package/{r2000-d64.ts → anno-d64.ts} +5 -5
- package/anno-derive.ts +590 -0
- package/anno-details.ts +169 -0
- package/anno-enum-gen.ts +533 -0
- package/anno-export-asm.ts +1310 -0
- package/anno-index.ts +150 -0
- package/{r2000-memmap-render.ts → anno-memmap-render.ts} +236 -95
- package/{r2000-regbits-gen.ts → anno-regbits-gen.ts} +20 -15
- package/{r2000-regbits.json → anno-regbits.json} +2 -2
- package/anno-register.ts +240 -0
- package/anno-store.ts +3486 -0
- package/anno-symbols.ts +266 -0
- package/anno-tools.ts +2111 -0
- package/anno-types.ts +1636 -0
- package/block-class.ts +201 -0
- package/build.ts +1 -1
- package/capability-registry.ts +3 -1
- package/disasm-decoder.ts +14 -14
- package/disasm-opcodes.ts +4 -4
- package/disasm-renderer.ts +2 -2
- package/hostpath.ts +1 -1
- package/install-resources.ts +1 -1
- package/package.json +23 -17
- package/prg-image.ts +119 -0
- package/repo-root.ts +20 -5
- package/resources/broker-launch.mjs +8 -4
- package/resources/vice-launcher.sh +3 -3
- package/stock-address.ts +5 -5
- package/stock-cia.ts +2 -2
- package/stock-condition.ts +7 -7
- package/stock-connect.ts +1 -1
- package/stock-dispatch.ts +35 -5
- package/stock-execution.ts +5 -3
- package/stock-input.ts +9 -9
- package/stock-machine.ts +17 -6
- package/stock-protocol.ts +16 -11
- package/stock-registers.ts +54 -29
- package/stock-sprites.ts +3 -3
- package/stock-symbols.ts +9 -9
- package/stock-timing.ts +1 -1
- package/stock-vicii.ts +1 -1
- package/version.ts +1 -1
- package/vice-proxy.ts +68 -46
- package/r2000-cli.ts +0 -1103
- package/r2000-enum-gen.ts +0 -574
- package/r2000-launch.ts +0 -357
- package/r2000-mcp-client.ts +0 -596
- package/r2000-project.ts +0 -190
- package/r2000-symbols.ts +0 -388
- package/r2000-tools.ts +0 -914
- package/r2000-verify.ts +0 -184
package/r2000-mcp-client.ts
DELETED
|
@@ -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
|
-
}
|