@henols/vice-mcp 0.1.12 → 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/stock-connect.ts CHANGED
@@ -30,7 +30,17 @@
30
30
  // a connect() that silently sits unserviced in the backlog (PROTO-08,
31
31
  // D-13, vice-broker-client.ts's own MonitorOwnershipError header
32
32
  // comment).
33
- import { ViceMonitorClient, CommandType, ErrorCode, StockProtocolError } from "./stock-protocol.ts";
33
+ import {
34
+ ViceMonitorClient,
35
+ CommandType,
36
+ ErrorCode,
37
+ StockProtocolError,
38
+ StockFramingError,
39
+ StockDesyncError,
40
+ StockResponseMismatchError,
41
+ StockConnectionClosedError,
42
+ StockRequestTimeoutError,
43
+ } from "./stock-protocol.ts";
34
44
  import { readCapabilityRecord, writeCapabilityRecord, type CapabilityDeps } from "./backend-detect.mts";
35
45
  import { MachineRestartedError, ViceError, readEpoch, type EpochResult } from "./vice.ts";
36
46
  import {
@@ -74,9 +84,12 @@ export interface StockCapabilities {
74
84
  * request body but VICE stores it internally in a uint16_t
75
85
  * (monitor_binary.c:1492) -- any count >= 65536 wraps silently server-side.
76
86
  * Clamp client-side rather than ever sending an unclamped value. This
77
- * handshake only ever probes with count 0 (it wants the capability answer,
78
- * not any history), but the clamp is a general guard for any future caller
79
- * of this same request shape. */
87
+ * handshake now probes with count 1, never 0 -- real VICE
88
+ * (monitor_binary.c:1491-1497) rejects `requested_count < 1` with
89
+ * InvalidParameter (0x81), so 0 is a malformed request, not a valid "give me
90
+ * the newest entry" request (confirmed live, see 07-RESEARCH.md Pitfall 8).
91
+ * The clamp remains a general guard for any future caller of this same
92
+ * request shape. */
80
93
  const CPU_HISTORY_MAX_COUNT = 65535;
81
94
 
82
95
  /** WR-02/WR-12: bounded at BOTH ends, and against non-finite input. A bare
@@ -93,26 +106,87 @@ export function clampCpuHistoryCount(count: number): number {
93
106
  return Math.min(Math.max(Math.trunc(count), 0), CPU_HISTORY_MAX_COUNT);
94
107
  }
95
108
 
96
- /** Sends CPUHISTORY_GET (0x86) with memspace=main and a zero, clamped count,
97
- * and maps the wire outcome to CpuHistoryCapability's three-way answer --
98
- * 0x00 OK -> "available", 0x83 INVALID_TYPE -> "absent" (the pre-3.10 case),
99
- * 0x8f CMD_FAILURE -> "not_compiled_in" (the distinct compiled-without-
100
- * support case). Any other rejection (a timeout, a closed socket, an
101
- * unrecognized error code) is not this function's to interpret -- it
102
- * propagates unchanged. */
103
- async function probeCpuHistory(client: ViceMonitorClient): Promise<CpuHistoryCapability> {
104
- const count = clampCpuHistoryCount(0);
109
+ /** Sends CPUHISTORY_GET (0x86) with memspace=main and a clamped count of 1
110
+ * (the minimum real VICE accepts -- monitor_binary.c:1491-1497 rejects
111
+ * `requested_count < 1` with InvalidParameter, confirmed live in
112
+ * 07-RESEARCH.md Pitfall 8; count=1 is also probe-binmon.mjs's own
113
+ * already-verified value), and maps the wire outcome to
114
+ * CpuHistoryCapability's three-way answer -- 0x00 OK -> "available", 0x83
115
+ * INVALID_TYPE -> "absent" (the pre-3.10 case), 0x8f CMD_FAILURE ->
116
+ * "not_compiled_in" (the distinct compiled-without-support case), 0x81
117
+ * INVALID_PARAMETER -> "absent" (with a well-formed count=1 request, this
118
+ * code can no longer originate from this client's own malformed count -- it
119
+ * means the connected build's CPUHISTORY_GET rejected a minimal well-formed
120
+ * request, so the route is unusable on this build; collapsing to "absent"
121
+ * rather than a fourth capability value matches resolveCapabilities()'s own
122
+ * cache comment, which already documents "absent" and "not_compiled_in" as
123
+ * both meaning "never attempt CPUHISTORY_GET again", differing only in
124
+ * why).
125
+ *
126
+ * CR-01 (07-VERIFICATION.md gap 1, live-reproduced against a genuine VICE
127
+ * >= 3.10 build): a build that DOES support the opcode answers with a real,
128
+ * well-formed CPUHISTORY_GET frame -- `StockFramingError | response type
129
+ * 0x86 body is 52 byte(s), needs at least 65` -- that this client's parser
130
+ * cannot yet decode (the layout fix is a separate plan, 07-12). A decode
131
+ * failure is not a wire error code, so it is NOT a StockProtocolError, and
132
+ * before this fix it fell through to the final `throw err` below and killed
133
+ * the entire handshake. The rule this function now enforces: a *decode*
134
+ * failure (StockFramingError, StockDesyncError, StockResponseMismatchError
135
+ * -- the monitor answered with SOME frame, so the opcode demonstrably
136
+ * exists, but this client could not read the answer) resolves to "absent",
137
+ * exactly like the pre-3.10 case above, because "absent" already means
138
+ * "never attempt CPUHISTORY_GET again" to every consumer. A *transport*
139
+ * failure (StockConnectionClosedError, StockRequestTimeoutError, or any
140
+ * other unrecognized error) is a different thing entirely -- the connection
141
+ * itself is unusable, not just this one opcode -- and must still propagate
142
+ * unchanged so stockConnect()'s own retry/restart posture can see it. Do
143
+ * NOT widen this into a bare `catch { return "absent"; }`: that would also
144
+ * swallow a dead socket and hand back a capability answer for a session
145
+ * nobody could actually establish.
146
+ *
147
+ * CR-01 (07-REVIEW.md re-review): the three decode classes above answer a
148
+ * capability VALUE but they are NOT an observation of the build's
149
+ * capability -- the server answered, this client could not read the answer.
150
+ * So the probe now reports its PROVENANCE alongside the value, and
151
+ * resolveCapabilities() persists only `"wire"`-sourced answers. Persisting a
152
+ * decode failure wrote `cpuHistoryAvailable: false` to a record whose only
153
+ * invalidation key was the VICE version quad, so a single transient desync
154
+ * -- or any parser bug -- permanently disabled Route A for that binary and
155
+ * no shipped parser fix could ever invalidate it. Keep the two concepts
156
+ * separate: `capability` is what this process should do next, `source` is
157
+ * whether anyone actually established it. */
158
+ interface CpuHistoryProbe {
159
+ /** What this process should do about Route A, right now. */
160
+ capability: CpuHistoryCapability;
161
+ /** `"wire"` -- the monitor's own answer, decoded: a fact about this build,
162
+ * safe to persist. `"decode_failure"` -- the monitor answered with a frame
163
+ * this client could not decode: usable in-process, NEVER persistable. */
164
+ source: "wire" | "decode_failure";
165
+ }
166
+
167
+ async function probeCpuHistory(client: ViceMonitorClient): Promise<CpuHistoryProbe> {
168
+ const count = clampCpuHistoryCount(1);
105
169
  const body = Buffer.alloc(5);
106
170
  body[0] = 0x00; // memspace: main
107
171
  body.writeUInt32LE(count, 1);
108
172
  try {
109
173
  await client.send(CommandType.CpuHistoryGet, body);
110
- return "available";
174
+ return { capability: "available", source: "wire" };
111
175
  } catch (err) {
112
176
  if (err instanceof StockProtocolError) {
113
- if (err.errorCode === ErrorCode.InvalidType) return "absent"; // 0x83 -- opcode absent, pre-3.10
114
- if (err.errorCode === ErrorCode.CmdFailure) return "not_compiled_in"; // 0x8f -- compiled without support
177
+ if (err.errorCode === ErrorCode.InvalidType) return { capability: "absent", source: "wire" }; // 0x83 -- opcode absent, pre-3.10
178
+ if (err.errorCode === ErrorCode.CmdFailure) return { capability: "not_compiled_in", source: "wire" }; // 0x8f -- compiled without support
179
+ if (err.errorCode === ErrorCode.InvalidParameter) return { capability: "absent", source: "wire" }; // 0x81 -- minimal well-formed request rejected by this build
115
180
  }
181
+ // CR-01: the opcode answered with a real frame this client could not
182
+ // decode -- the opcode EXISTS, Route A is merely unusable by THIS
183
+ // client. An in-process "absent" is right; a persisted one is not.
184
+ if (err instanceof StockFramingError) return { capability: "absent", source: "decode_failure" };
185
+ if (err instanceof StockDesyncError) return { capability: "absent", source: "decode_failure" };
186
+ if (err instanceof StockResponseMismatchError) return { capability: "absent", source: "decode_failure" };
187
+ // Anything else -- notably StockConnectionClosedError and
188
+ // StockRequestTimeoutError -- is a transport failure, not a capability
189
+ // answer, and must still fail the handshake.
116
190
  throw err;
117
191
  }
118
192
  }
@@ -145,11 +219,54 @@ async function resolveCapabilities(client: ViceMonitorClient, versionQuad: strin
145
219
  }
146
220
  }
147
221
 
148
- const cpuHistory = await probeCpuHistory(client);
149
- if (deps.binPath) {
150
- writeCap(deps.binPath, { versionQuad, cpuHistoryAvailable: cpuHistory === "available" }, { supervisorDir: deps.supervisorDir });
222
+ // CR-01: probeCpuHistory() is guarded against decode failures, but this
223
+ // call site guards against anything ELSE this function is not prepared to
224
+ // interpret -- so no uninterpreted error can reach stockConnect()'s fatal
225
+ // catch. Real transport/instance conditions (a closed socket, a timed-out
226
+ // request, or a proven machine restart) still rethrow, because those mean
227
+ // the handshake itself must fail, not just this one capability. Anything
228
+ // unclassifiable degrades to "absent" WITHOUT writing a capability cache
229
+ // record -- a capability answer nobody could actually establish must
230
+ // never be persisted for the next connect.
231
+ //
232
+ // CR-01 (07-REVIEW.md re-review) narrows this catch in one direction and
233
+ // fixes the write below. A StockProtocolError this module does not
234
+ // classify is rethrown rather than laundered into a capability answer:
235
+ // the set that reaches here includes InvalidApiVersion (0x82), and an
236
+ // api-version rejection is the one condition step 3 of the handshake
237
+ // exists to make FATAL -- reporting it as "Route A is absent" hides a
238
+ // connection that is not actually usable. The residual catch-all below
239
+ // therefore covers only genuinely untyped failures (a bug in this client,
240
+ // not a wire answer), which still degrade without persisting.
241
+ let probe: CpuHistoryProbe;
242
+ try {
243
+ probe = await probeCpuHistory(client);
244
+ } catch (err) {
245
+ if (err instanceof StockConnectionClosedError || err instanceof StockRequestTimeoutError || err instanceof MachineRestartedError) {
246
+ throw err;
247
+ }
248
+ if (err instanceof StockProtocolError) {
249
+ // An unclassified WIRE error code. probeCpuHistory() already maps
250
+ // every code that means "Route A is unusable"; anything else is a
251
+ // condition this client does not understand, and a handshake that
252
+ // cannot understand the server's refusal must fail, not guess.
253
+ throw err;
254
+ }
255
+ console.error(`resolveCapabilities: unclassified error resolving CPUHISTORY_GET capability, falling back to "absent": ${String(err)}`);
256
+ return { cpuHistory: "absent" };
257
+ }
258
+ // CR-01: persist ONLY a wire-sourced answer. A decode failure means the
259
+ // opcode exists and this client could not read the reply -- writing
260
+ // `cpuHistoryAvailable: false` for it would pin Route A off for this
261
+ // binary until VICE itself is upgraded, because the record's only other
262
+ // invalidation key is the version quad. writeCapabilityRecord() stamps
263
+ // CAPABILITY_SCHEMA_VERSION so a shipped parser change invalidates
264
+ // records written by the older parser; that is the second half of the
265
+ // same fix and cannot substitute for this one.
266
+ if (deps.binPath && probe.source === "wire") {
267
+ writeCap(deps.binPath, { versionQuad, cpuHistoryAvailable: probe.capability === "available" }, { supervisorDir: deps.supervisorDir });
151
268
  }
152
- return { cpuHistory };
269
+ return { cpuHistory: probe.capability };
153
270
  }
154
271
 
155
272
  // ---------------------------------------------------------------------------
package/stock-derived.ts CHANGED
@@ -18,21 +18,42 @@
18
18
  // hazard. There, NOT translating an emulator-side path is the bug -- four
19
19
  // tools carry a filename stock VICE opens on the HOST, and stock-paths.ts's
20
20
  // whole job is making sure that translation happens. HERE, translating a
21
- // CLIENT-SIDE-DERIVED path is the bug: rewriteArguments() runs INSIDE the
22
- // fork-forwarding function at vice-proxy.ts:2773, before call() -- a derived
23
- // tool sitting behind call() would receive HOST-translated paths and act on
24
- // them INSIDE THE CONTAINER (ROADMAP Phase 4 Notes, CLAUDE.md). The
25
- // derived-tool seam this file anchors exists so a derived tool's handler is
26
- // reached BEFORE that fork-forwarding function ever runs
27
- // rewriteArguments() at all.
21
+ // CLIENT-SIDE-DERIVED path is the bug: `forwardToVice()` calls
22
+ // `rewriteArguments(args, name)` itself, before it ever delegates to
23
+ // `call()` -- so a derived tool sitting behind `call()` would receive
24
+ // HOST-translated paths and act on them INSIDE THE CONTAINER (ROADMAP Phase 4
25
+ // Notes, CLAUDE.md). The derived-tool seam this file anchors exists so a
26
+ // derived tool's handler is reached BEFORE `forwardToVice()` runs
27
+ // `rewriteArguments()` at all.
28
28
  //
29
- // SECOND CONSUMER, named now so Phase 5's edit is a one-liner:
30
- // gatherWedgeEvidence() (vice-proxy.ts:1343) calls rewriteArguments() itself
31
- // at line 1367. On the stock backend, PERFORMING that translation becomes
32
- // the bug -- its own comment inverts. Phase 5 criterion 5 owns that fix; it
33
- // is deliberately NOT repointed here (it is currently unreachable on stock
34
- // anyway: handleRecycle() is backend-aware and refused by name after CR-07,
35
- // and vice_display_screenshot does not exist on stock until Phase 5).
29
+ // SECOND CONSUMER: `gatherWedgeEvidence()` in vice-proxy.ts calls
30
+ // `rewriteArguments()` itself, for `vice_display_screenshot`. On the stock
31
+ // backend, PERFORMING that translation becomes the bug -- its own comment
32
+ // inverts. Phase 5 criterion 5 owns that fix; it is deliberately NOT
33
+ // repointed here.
34
+ //
35
+ // CITATION STYLE, deliberate (07-REVIEW.md WR-12): the two call sites above
36
+ // are named by SYMBOL (`forwardToVice()`'s own `rewriteArguments(args, name)`
37
+ // call; `gatherWedgeEvidence()`'s own call), never by line number. This header
38
+ // used to cite `vice-proxy.ts:2773`, `:1343` and `:1367`; by the time WR-12 was
39
+ // filed the real lines were 2889, 1344 and 1368 -- 07-16 edited vice-proxy.ts
40
+ // without re-verifying them, and this header is the in-tree statement of
41
+ // CLAUDE.md's derived-tool constraint, so its citations are load-bearing. Line
42
+ // numbers in this file drift every phase; grep for the symbol instead.
43
+ //
44
+ // WHY THE HAZARD IS UNREACHABLE ON STOCK TODAY -- restated in terms of what
45
+ // actually enforces it (WR-12 again; the previous reason had gone stale):
46
+ // `buildBackendAwareTool()` routes EVERY stock tool call to
47
+ // `dispatchStock()`, so `forwardToVice()`, `handleRecycle()` and
48
+ // `gatherWedgeEvidence()` are reachable only on the fork arm. That is a
49
+ // structural property of the registration, not of any tool's name. The reason
50
+ // this header used to give -- "handleRecycle() is backend-aware and refused by
51
+ // name after CR-07, and vice_display_screenshot does not exist on stock until
52
+ // Phase 5" -- is now WRONG on its first clause: Phase 7 implemented
53
+ // `vice_recycle` on stock (`handleRecycleStock`) and registered it in
54
+ // STOCK_DERIVED_TOOLS below. The conclusion held; the stated reason did not,
55
+ // which is worse than no reason for a constraint a future reader will
56
+ // re-derive.
36
57
  //
37
58
  // WHAT NOT TO DO:
38
59
  // - Never `import` hostpath.ts from this file, or from any module listed
@@ -50,6 +71,11 @@
50
71
  // - Never re-implement session acquisition here -- withDerivedTool() in
51
72
  // stock-dispatch.ts delegates to the one ensureStockSession() the 25
52
73
  // direct tools use.
74
+ // - Never add a name to STOCK_DERIVED_TOOLS without adding its module to
75
+ // package.json's files[] in the SAME commit (Phase 3 Rule 2) --
76
+ // withDerivedTool() refuses on this set, and a declared-but-unshipped
77
+ // module fails at module load in the published tarball rather than at
78
+ // dispatch.
53
79
  import { ViceError, type ViceErrorOptions } from "./vice.ts";
54
80
  import type { StockToolResult } from "./stock-handler.ts";
55
81
  import type { StockDispatchDeps } from "./stock-dispatch.ts";
@@ -76,6 +102,18 @@ export class DerivedToolError extends ViceError {
76
102
 
77
103
  export const STOCK_DERIVED_TOOLS: ReadonlySet<string> = new Set([
78
104
  "vice_disassemble", // Phase 4, DERIV-07's first consumer (04-05) -- client-side 6510 disassembler
105
+ "vice_memory_search", // Phase 5, DERIV-01
106
+ "vice_memory_compare", // Phase 5, DERIV-01
107
+ "vice_symbols_load", // Phase 5, DERIV-04
108
+ "vice_symbols_lookup", // Phase 5, DERIV-04
109
+ "vice_vicii_get_state", // Phase 5, DERIV-05
110
+ "vice_cia_get_state", // Phase 5, DERIV-05
111
+ "vice_sprite_get", // Phase 5, DERIV-06
112
+ "vice_sprite_inspect", // Phase 5, DERIV-06
113
+ "vice_cycles_stopwatch", // Phase 7, TIME-01
114
+ "vice_run_until", // Phase 7, TIME-02
115
+ "vice_diagnose", // Phase 7, TIME-04
116
+ "vice_recycle", // Phase 7, TIME-04
79
117
  ]);
80
118
 
81
119
  /**
@@ -85,6 +123,21 @@ export const STOCK_DERIVED_TOOLS: ReadonlySet<string> = new Set([
85
123
  * threaded down for anything the handler needs beyond the session (matching
86
124
  * StockSessionHandler's own `deps` parameter).
87
125
  *
126
+ * THE ONE DECLARED EXCEPTION (Phase 7, plan 07-09): `vice_diagnose`
127
+ * (`handleDiagnoseStock`, stock-diagnose.ts) is registered with
128
+ * `needsSession: false` yet DOES reach the wire -- it calls
129
+ * `ensureStockSession(deps)` itself, inside its own try/catch, rather than
130
+ * through withDerivedTool()'s session preamble. This is deliberate, not a
131
+ * contradiction of the rule above: a thrown MonitorOwnershipError or
132
+ * MachineRestartedError during acquisition must become one of
133
+ * vice_diagnose's own five VERDICTS (`monitor_held_elsewhere`, `restarted`)
134
+ * rather than withDerivedTool()'s generic refusal text -- the exact string
135
+ * those verdicts exist to replace. Every other `needsSession: false` handler
136
+ * in STOCK_DERIVED_TOOLS still structurally cannot reach the wire; this is
137
+ * the one named handler that opts out of that guarantee, and it does so by
138
+ * calling the SAME ensureStockSession() every session-needing handler
139
+ * calls, never a lighter-weight variant of it.
140
+ *
88
141
  * `StockDispatchDeps` is imported `type`-only from stock-dispatch.ts -- under
89
142
  * verbatimModuleSyntax an `import type` erases completely at compile time,
90
143
  * so it creates no runtime cycle even though stock-dispatch.ts imports THIS