@henols/vice-mcp 0.1.9 → 0.1.11

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.
@@ -0,0 +1,327 @@
1
+ #!/usr/bin/env node
2
+ // stock-execution.ts
3
+ //
4
+ // Family C (DIRECT-04/DIRECT-05): pause, resume, step, and the stock-only
5
+ // `vice_execution_until_return`. Ships `handleExecutionPause`,
6
+ // `handleExecutionRun`, `handleExecutionStep`, and
7
+ // `handleExecutionUntilReturn` as `StockSessionHandler`s -- dispatch-table
8
+ // and manifest wiring belong to plans 03-12/03-13, not here.
9
+ //
10
+ // WHY THIS FILE EXISTS: docs/phase0-binmon-findings.md §4 -- ANY inbound byte
11
+ // halts the emulated machine (`monitor_startup_trap()` runs every vsync), so
12
+ // a bare PING (0x81) is the documented, side-effect-minimal way to trigger a
13
+ // halt on demand, and EXIT (0xaa) is the ONLY thing that resumes it. D-05
14
+ // means this client never sends an unrequested EXIT, which in turn means an
15
+ // agent has no round trip that tells it what the machine is doing right now
16
+ // except the wire's own STOPPED/RESUMED events -- stock-runstate.ts's
17
+ // projection (D-06). D-08's idempotence is the load-bearing property this
18
+ // module exists to guarantee: a duplicate resume after an event race must
19
+ // never restart a machine the agent believes it already paused.
20
+ //
21
+ // WHAT NOT TO DO:
22
+ // - Never send EXIT from any handler other than handleExecutionRun (D-05
23
+ // -- the agent's explicit resume request is the only licence). Grep-gated
24
+ // to exactly one `CommandType.Exit` occurrence in this file's code lines.
25
+ // - Never infer the run state from the command this module just sent
26
+ // (D-06) -- always read runStateFor(session.client), never assume.
27
+ // - Never assert "stopped" after a handshake (D-07) -- stock-connect.ts's
28
+ // own PING/EXIT pair is internal bookkeeping, not the user's run state.
29
+ // - Never construct an ok-answer outside stockAnswer() -- that is exactly
30
+ // how an answer ships without the `runState` D-06 requires on every
31
+ // stock tool answer.
32
+ // - The roadmap's "cool resumes down" note (a separate rate limiter on
33
+ // resume frequency) is deliberately NOT implemented here -- D-08's
34
+ // short-circuit is the whole answer (CONTEXT.md's "Not taken"). Revisit
35
+ // only if a resume storm is observed against a real emulator.
36
+ import {
37
+ CommandType,
38
+ advanceInstructionsBody,
39
+ type ParsedResponse,
40
+ type ResolvedResponse,
41
+ type StockFramingError,
42
+ type StockProtocolError,
43
+ type ViceMonitorClient,
44
+ } from "./stock-protocol.ts";
45
+ import { runStateFor, type RunState } from "./stock-runstate.ts";
46
+ import { parseByteCount, StockAddressError } from "./stock-address.ts";
47
+ import { convertWireError, isErrorText, stockAnswer, type StockErrorResult, type StockSessionHandler } from "./stock-handler.ts";
48
+
49
+ /**
50
+ * Awaits exactly one macrotask so a STOPPED/RESUMED frame that arrived in
51
+ * the same socket chunk as a command's reply has been demuxed and projected
52
+ * into stock-runstate.ts's tracker before this module reads it. This is a
53
+ * best-effort ordering nicety, NOT a guarantee: if the event genuinely has
54
+ * not arrived yet, the answer reports whatever the projection honestly
55
+ * holds -- including "unknown" -- and never asserts a state the wire did
56
+ * not report.
57
+ */
58
+ async function settleEvents(): Promise<void> {
59
+ await new Promise<void>((resolve) => setImmediate(resolve));
60
+ }
61
+
62
+ /** True iff `item` is a parsed response/event shape carrying a `.type`
63
+ * discriminant -- the same narrowing stock-runstate.ts uses to filter out
64
+ * the two wire-error classes ViceMonitorClient's 'event' channel can also
65
+ * carry. */
66
+ function hasParsedType(item: ParsedResponse | StockProtocolError | StockFramingError): item is ParsedResponse {
67
+ return "type" in item;
68
+ }
69
+
70
+ /**
71
+ * A scoped, single-call capture of the last STOPPED/RESUMED event's program
72
+ * counter observed while awaiting a step/until-return round trip. This is
73
+ * NOT a second persistent tracker -- RESEARCH.md Pitfall 4 is about
74
+ * attachRunStateTracker()'s own idempotent-attach guarantee, a different
75
+ * concern from a listener that is attached immediately before send() and
76
+ * ALWAYS removed via `finish()` right after settleEvents() resolves, so it
77
+ * never outlives a single handler invocation and never accumulates
78
+ * listeners across calls.
79
+ *
80
+ * REGISTER_INFO-based program-counter extraction is deliberately NOT
81
+ * implemented here: mapping a register id to "this is the PC" requires the
82
+ * register name/id catalog Family A (plans 03-06/03-07) owns, which is not
83
+ * a dependency of this plan. Only a STOPPED/RESUMED event's own
84
+ * `programCounter` field is used.
85
+ */
86
+ function beginProgramCounterCapture(client: ViceMonitorClient): { finish(): number | undefined } {
87
+ let programCounter: number | undefined;
88
+ const listener = (item: ParsedResponse | StockProtocolError | StockFramingError) => {
89
+ if (hasParsedType(item) && (item.type === "stopped" || item.type === "resumed")) {
90
+ programCounter = item.programCounter;
91
+ }
92
+ };
93
+ client.on("event", listener);
94
+ return {
95
+ finish: () => {
96
+ client.off("event", listener);
97
+ return programCounter;
98
+ },
99
+ };
100
+ }
101
+
102
+ /** Reads a program counter off a resolved reply itself, when the parsed
103
+ * shape happens to carry one (it does not, today, for AdvanceInstructions/
104
+ * ExecuteUntilReturn -- both fall through to the "unknown" shape in
105
+ * stock-protocol.ts's parser -- but this stays a narrow, defensive check
106
+ * rather than a hardcoded "never" so a future parser extension is picked up
107
+ * for free). */
108
+ function programCounterFromReply(response: ResolvedResponse): number | undefined {
109
+ return "programCounter" in response && typeof response.programCounter === "number" ? response.programCounter : undefined;
110
+ }
111
+
112
+ /** Refuses an argument object carrying any key outside `allowed`, naming the
113
+ * offending key -- the one place every handler in this file checks its own
114
+ * argument surface, rather than each handler re-deriving the check. */
115
+ function refuseUnexpectedArgs(args: Record<string, unknown>, allowed: string[], toolName: string): StockErrorResult | null {
116
+ const allowedSet = new Set(allowed);
117
+ const extra = Object.keys(args).find((key) => !allowedSet.has(key));
118
+ if (extra === undefined) {
119
+ return null;
120
+ }
121
+ const accepts = allowed.length === 0 ? "no arguments" : `only: ${allowed.join(", ")}`;
122
+ return isErrorText(`${toolName}: unexpected argument "${extra}" -- this tool accepts ${accepts}.`);
123
+ }
124
+
125
+ /**
126
+ * D-07's gate, implemented once for both stepping tools: when `state` is
127
+ * "unknown" nothing on the wire has yet reported a STOPPED or RESUMED
128
+ * transition on this connection, so stepping could either step a halted CPU
129
+ * or race a running one, producing a program counter the agent would
130
+ * otherwise have no reason to distrust. Names the exact next action
131
+ * (`vice_execution_pause`/`vice_execution_run`) and states explicitly that
132
+ * this gate applies ONLY to the execution-control tools -- memory, register
133
+ * and checkpoint tools run freely while the state is unknown -- so an agent
134
+ * reading the refusal does not conclude the whole backend is unusable.
135
+ * Returns `null` (no refusal) for both "running" and "stopped".
136
+ */
137
+ function refuseIfUnknown(state: RunState, toolName: string): StockErrorResult | null {
138
+ if (state !== "unknown") {
139
+ return null;
140
+ }
141
+ return isErrorText(
142
+ `${toolName}: the derived run state is "unknown" -- nothing on the wire has reported a STOPPED or RESUMED ` +
143
+ `transition on this connection yet. Stepping a machine whose state is unknown could either step an already-` +
144
+ `halted CPU or race a still-running one, producing a program counter that should not be trusted. Call ` +
145
+ `vice_execution_pause or vice_execution_run first to establish a known state. This gate applies ONLY to the ` +
146
+ `execution-control tools (pause/run/step/until-return) -- memory, register and checkpoint tools run freely ` +
147
+ `while the state is unknown.`,
148
+ );
149
+ }
150
+
151
+ /**
152
+ * `vice_execution_pause` -- bare PING (0x81), no body. D-08: when the
153
+ * derived state is already "stopped", sends NOTHING and answers with an
154
+ * explicit already-in-that-state marker -- an agent retry after an event
155
+ * race must never issue a second halt. While the state is "running" OR
156
+ * "unknown" the command IS sent: "unknown" has nothing to short-circuit
157
+ * against, so it is deliberately NOT treated as "do nothing".
158
+ */
159
+ export const handleExecutionPause: StockSessionHandler = async (args, session) => {
160
+ const unexpected = refuseUnexpectedArgs(args, [], "vice_execution_pause");
161
+ if (unexpected) {
162
+ return unexpected;
163
+ }
164
+
165
+ const stateBefore = runStateFor(session.client);
166
+
167
+ if (stateBefore === "stopped") {
168
+ return stockAnswer(session.client, {
169
+ requested: "pause",
170
+ sent: false,
171
+ alreadyStopped: true,
172
+ note: "the machine was already halted (derived run state \"stopped\") -- no command was issued",
173
+ stateBefore,
174
+ });
175
+ }
176
+
177
+ try {
178
+ await session.client.send(CommandType.Ping);
179
+ } catch (err) {
180
+ return convertWireError("vice_execution_pause", err);
181
+ }
182
+ await settleEvents();
183
+ return stockAnswer(session.client, { requested: "pause", sent: true, alreadyStopped: false, stateBefore });
184
+ };
185
+
186
+ /**
187
+ * `vice_execution_run` -- EXIT (0xaa), no body. This is the ONE handler in
188
+ * the whole phase permitted to send EXIT, and it does so only because the
189
+ * agent explicitly asked to resume (D-05). D-08: when the derived state is
190
+ * already "running", sends NOTHING and answers with an explicit
191
+ * already-in-that-state marker. While "stopped" or "unknown" the command IS
192
+ * sent.
193
+ */
194
+ export const handleExecutionRun: StockSessionHandler = async (args, session) => {
195
+ const unexpected = refuseUnexpectedArgs(args, [], "vice_execution_run");
196
+ if (unexpected) {
197
+ return unexpected;
198
+ }
199
+
200
+ const stateBefore = runStateFor(session.client);
201
+
202
+ if (stateBefore === "running") {
203
+ return stockAnswer(session.client, {
204
+ requested: "run",
205
+ sent: false,
206
+ alreadyRunning: true,
207
+ note: "the machine was already running (derived run state \"running\") -- no command was issued",
208
+ stateBefore,
209
+ });
210
+ }
211
+
212
+ try {
213
+ await session.client.send(CommandType.Exit);
214
+ } catch (err) {
215
+ return convertWireError("vice_execution_run", err);
216
+ }
217
+ await settleEvents();
218
+ return stockAnswer(session.client, { requested: "run", sent: true, alreadyRunning: false, stateBefore });
219
+ };
220
+
221
+ /**
222
+ * `vice_execution_step` -- ADVANCE_INSTRUCTIONS (0x71), body
223
+ * `stepOver(1) count(u16LE)` via advanceInstructionsBody(). Refuses while
224
+ * the derived run state is "unknown" (D-07, via refuseIfUnknown()).
225
+ *
226
+ * `stepOver: true`'s runtime semantic (skip a JSR's subroutine as one step)
227
+ * is [ASSUMED] -- RESEARCH.md Assumptions Log row A2 -- never probed against
228
+ * a real JSR. See `.planning/todos/pending/2026-08-14-probe-phase3-assumed-wire-details.md`
229
+ * for the outstanding probe debt. This is NOT claimed as verified here.
230
+ */
231
+ export const handleExecutionStep: StockSessionHandler = async (args, session) => {
232
+ const unexpected = refuseUnexpectedArgs(args, ["count", "stepOver"], "vice_execution_step");
233
+ if (unexpected) {
234
+ return unexpected;
235
+ }
236
+
237
+ const stateBefore = runStateFor(session.client);
238
+ const refusal = refuseIfUnknown(stateBefore, "vice_execution_step");
239
+ if (refusal) {
240
+ return refusal;
241
+ }
242
+
243
+ let count: number;
244
+ try {
245
+ count = args.count === undefined ? 1 : parseByteCount(args.count, { max: 0xffff, what: "vice_execution_step: count" });
246
+ } catch (err) {
247
+ return isErrorText(err instanceof StockAddressError ? err.message : `vice_execution_step: ${String(err)}`);
248
+ }
249
+
250
+ if (args.stepOver !== undefined && typeof args.stepOver !== "boolean") {
251
+ return isErrorText(`vice_execution_step: stepOver must be a boolean, got ${typeof args.stepOver}`);
252
+ }
253
+ const stepOver = args.stepOver === true;
254
+
255
+ const body = advanceInstructionsBody({ stepOver, count });
256
+
257
+ const capture = beginProgramCounterCapture(session.client);
258
+ let response: ResolvedResponse;
259
+ try {
260
+ response = await session.client.send(CommandType.AdvanceInstructions, body);
261
+ } catch (err) {
262
+ capture.finish();
263
+ return convertWireError("vice_execution_step", err);
264
+ }
265
+ await settleEvents();
266
+ // WR-02 (03-REVIEW.md): finish() is called UNCONDITIONALLY, and only its
267
+ // RETURN VALUE participates in the `??` fallback. Behind `??` the removal of
268
+ // the 'event' listener would be skipped on any call where the reply itself
269
+ // carried a program counter -- harmless today only because
270
+ // programCounterFromReply() always answers undefined for this opcode, but
271
+ // that function exists precisely so a future parser extension is picked up
272
+ // for free, and the day it is, this handler would leak one listener on the
273
+ // long-lived, reused session.client per call, forever.
274
+ const capturedProgramCounter = capture.finish();
275
+ const programCounter = programCounterFromReply(response) ?? capturedProgramCounter;
276
+
277
+ const payload: Record<string, unknown> = { requested: "step", count, stepOver, stateBefore };
278
+ if (programCounter !== undefined) {
279
+ payload.programCounter = programCounter;
280
+ }
281
+ return stockAnswer(session.client, payload);
282
+ };
283
+
284
+ /**
285
+ * `vice_execution_until_return` -- EXECUTE_UNTIL_RETURN (0x73), EMPTY body
286
+ * (never invent an encoder for this opcode). This tool has NO fork
287
+ * counterpart: the planner's stock-only naming choice, recorded in the
288
+ * plan's own objective under Phase 2's D-07 ("stock advertises tools the
289
+ * fork doesn't"), following the existing `vice_execution_*` convention and
290
+ * reading as the operation rather than as a value. `vice_execution_step`'s
291
+ * `stepOver` is a DIFFERENT operation (step over a call vs. run out of the
292
+ * current one) -- do not later "unify" the two. Refuses while the derived
293
+ * run state is "unknown" (D-07, via refuseIfUnknown()), identically to
294
+ * `vice_execution_step`.
295
+ */
296
+ export const handleExecutionUntilReturn: StockSessionHandler = async (args, session) => {
297
+ const unexpected = refuseUnexpectedArgs(args, [], "vice_execution_until_return");
298
+ if (unexpected) {
299
+ return unexpected;
300
+ }
301
+
302
+ const stateBefore = runStateFor(session.client);
303
+ const refusal = refuseIfUnknown(stateBefore, "vice_execution_until_return");
304
+ if (refusal) {
305
+ return refusal;
306
+ }
307
+
308
+ const capture = beginProgramCounterCapture(session.client);
309
+ let response: ResolvedResponse;
310
+ try {
311
+ response = await session.client.send(CommandType.ExecuteUntilReturn);
312
+ } catch (err) {
313
+ capture.finish();
314
+ return convertWireError("vice_execution_until_return", err);
315
+ }
316
+ await settleEvents();
317
+ // WR-02: unconditional finish(), for exactly the reason handleExecutionStep's
318
+ // own identical line above spells out.
319
+ const capturedProgramCounter = capture.finish();
320
+ const programCounter = programCounterFromReply(response) ?? capturedProgramCounter;
321
+
322
+ const payload: Record<string, unknown> = { requested: "untilReturn", stateBefore };
323
+ if (programCounter !== undefined) {
324
+ payload.programCounter = programCounter;
325
+ }
326
+ return stockAnswer(session.client, payload);
327
+ };
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env node
2
+ // stock-handler.ts
3
+ //
4
+ // THE shared, cycle-free handler contract every Phase 3+ family module
5
+ // (stock-memory.ts, stock-checkpoints.ts, stock-execution.ts, ...) imports:
6
+ // the result types, both error converters, and stockAnswer() -- the ONE
7
+ // place a successful stock answer is constructed.
8
+ //
9
+ // WHY THIS FILE EXISTS: stock-dispatch.ts is THE dispatch table (D-07/D-09)
10
+ // and, starting with a later plan, imports every family module so it can
11
+ // register their handlers. A family module that needs stockAnswer() or
12
+ // convertHandshakeError() cannot import stock-dispatch.ts for them without
13
+ // creating exactly that import cycle (stock-dispatch.ts -> stock-memory.ts
14
+ // -> stock-dispatch.ts). This file is the leaf both sides import instead:
15
+ // stock-dispatch.ts re-exports these names (so Phase 2's existing import
16
+ // surface and its 921-line test file keep working unchanged), and every
17
+ // family module imports them straight from here, never from
18
+ // stock-dispatch.ts.
19
+ //
20
+ // WHAT NOT TO DO:
21
+ // - Never build a `{ content: [...], isError: false }` literal outside
22
+ // stockAnswer() -- that is exactly how an answer ships without
23
+ // `runState`, which D-06 requires on EVERY stock tool answer.
24
+ // - Never write a third error converter. convertHandshakeError() (moved
25
+ // here, unchanged, from stock-dispatch.ts) is the ONE conversion for a
26
+ // failed ensureStockSession()/stockConnect(); convertWireError() (new
27
+ // here) is the ONE conversion for a client.send() rejection. A family
28
+ // module that finds itself writing prose for a wire ErrorCode or a
29
+ // handshake error is re-deriving one of these two -- import instead.
30
+ // - Never import stock-dispatch.ts at RUNTIME from this file -- only a
31
+ // type-only import of StockDispatchDeps is permitted. Under
32
+ // verbatimModuleSyntax an `import type` erases completely at compile
33
+ // time, so it creates no runtime cycle even though stock-dispatch.ts
34
+ // imports this file at runtime.
35
+ import { MonitorOwnershipError } from "./vice-broker-client.ts";
36
+ import { MachineRestartedError } from "./vice.ts";
37
+ import { ErrorCode, StockFramingError, StockProtocolError, StockResponseMismatchError, type ViceMonitorClient } from "./stock-protocol.ts";
38
+ import { runStateFor } from "./stock-runstate.ts";
39
+ import type { StockConnectSession } from "./stock-connect.ts";
40
+ import type { StockDispatchDeps } from "./stock-dispatch.ts";
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Result types -- moved verbatim from stock-dispatch.ts. Structurally
44
+ // IDENTICAL to vice-proxy.ts's own private ToolCallResult (ErrorTextResult |
45
+ // OkTextResult) by field name and type, but declared here rather than
46
+ // imported -- vice-proxy.ts imports stock-dispatch.ts, which imports THIS
47
+ // file, so importing back from vice-proxy.ts would be the exact
48
+ // module-cycle this codebase's own "module-cycle avoidance is deliberate"
49
+ // constraint forbids. TypeScript's structural typing makes the two
50
+ // interchangeable at every call site that matters.
51
+ // ---------------------------------------------------------------------------
52
+
53
+ export interface StockErrorResult {
54
+ content: { type: "text"; text: string }[];
55
+ isError: true;
56
+ }
57
+ export interface StockOkResult {
58
+ content: { type: "text"; text: string }[];
59
+ isError: false;
60
+ }
61
+ export type StockToolResult = StockErrorResult | StockOkResult;
62
+
63
+ export function isErrorText(text: string): StockErrorResult {
64
+ return { content: [{ type: "text", text }], isError: true };
65
+ }
66
+
67
+ /** The shape every Phase 3 family module exports one of per tool. `session`
68
+ * is the SAME StockConnectSession ensureStockSession() itself resolves --
69
+ * no handler resolves a lease or opens a socket of its own. `deps` is
70
+ * threaded through for anything a handler needs beyond the session (e.g. a
71
+ * path-translation root). */
72
+ export type StockSessionHandler = (args: Record<string, unknown>, session: StockConnectSession, deps: StockDispatchDeps) => Promise<StockToolResult>;
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // convertHandshakeError() -- moved verbatim from stock-dispatch.ts. Converts
76
+ // the typed errors ensureStockSession()/stockConnect() can propagate into
77
+ // well-formed refusal text, naming the tool. Never mentions "wedge",
78
+ // "hung", or "unresponsive" -- a monitor-ownership conflict is the broker's
79
+ // own enforcement of a DIFFERENT grant already holding this instance, a
80
+ // state vice-wedge-triage's opening move must not be misdirected by into
81
+ // treating as a wedged emulator.
82
+ // ---------------------------------------------------------------------------
83
+
84
+ export function convertHandshakeError(toolName: string, err: unknown): StockErrorResult {
85
+ if (err instanceof MonitorOwnershipError) {
86
+ return isErrorText(
87
+ `${toolName}: this instance's monitor socket is already claimed by a different grant ` +
88
+ `(grant ${err.holderGrantId ?? "unknown"}, claimed at ${err.holderClaimedAt ?? "unknown"}, port ${err.port ?? "unknown"}) -- ` +
89
+ `only one client may hold the stock monitor socket at a time.`,
90
+ );
91
+ }
92
+ if (err instanceof MachineRestartedError) {
93
+ return isErrorText(
94
+ `${toolName}: the emulator's identity could not be proven across a reconnect ` +
95
+ `(baseline epoch ${String(err.baselineEpoch)}, current epoch ${String(err.currentEpoch)}) -- ` +
96
+ `treat every result since the previous call as void and retry.`,
97
+ );
98
+ }
99
+ const message = err instanceof Error ? err.message : String(err);
100
+ // WR-06: a connect REFUSAL on the stock path has exactly one common cause, and
101
+ // a bare "connect ECONNREFUSED 172.17.0.1:6605" points at none of it. The
102
+ // broker binds VICE's binary monitor to 127.0.0.1 by default -- a deliberate,
103
+ // documented safety posture, since the binmon is unauthenticated and grants
104
+ // full memory read/write -- while the proxy derives its dial host from the
105
+ // CONTAINERIZED instance URL, i.e. host.docker.internal. In the default
106
+ // containerized topology those two never meet, and nothing in the resulting
107
+ // message named the one environment variable that reconciles them. Named
108
+ // here, at the one seam that converts a handshake failure into agent-facing
109
+ // text, rather than in a comment nobody reading the error will see.
110
+ if (/ECONNREFUSED|EHOSTUNREACH|ENETUNREACH/.test(message)) {
111
+ return isErrorText(
112
+ `${toolName}: stock handshake failed -- nothing accepted a binary-monitor connection (${message}). ` +
113
+ `The broker binds VICE's binary monitor to 127.0.0.1 by DEFAULT (the safe posture: the binary monitor is ` +
114
+ `unauthenticated and grants full memory read/write plus process control to anything that can reach it), ` +
115
+ `so a containerized MCP server dialling the host cannot reach it. Set VICE_BROKER_BINMON_HOST on the ` +
116
+ `BROKER's own environment to an address the container can reach, then restart the broker so the emulator ` +
117
+ `is relaunched with the new bind address.`,
118
+ );
119
+ }
120
+ return isErrorText(`${toolName}: stock handshake failed (${message}).`);
121
+ }
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // convertWireError() -- new here (Task 3). The second half of the "one
125
+ // error converter" rule, for the errors a client.send() REJECTION carries
126
+ // rather than a handshake failure. ViceMonitorClient's #dispatch() rejects
127
+ // a pending request with a StockProtocolError on any non-OK wire error
128
+ // code, a StockFramingError on a decode-level fault, or a
129
+ // StockResponseMismatchError when a reply's response type does not match
130
+ // what the command expects -- every family handler needs this and none of
131
+ // them may write its own. Never emits "wedge"/"hung"/"unresponsive": a wire
132
+ // error is not a liveness diagnosis.
133
+ // ---------------------------------------------------------------------------
134
+
135
+ /** Maps each wire ErrorCode to distinct, explanatory text -- a single table,
136
+ * not a scattered set of ad-hoc strings. */
137
+ const WIRE_ERROR_TEXT: Partial<Record<number, string>> = {
138
+ [ErrorCode.ObjectMissing]: "the object named does not exist (e.g. no checkpoint with that number)",
139
+ [ErrorCode.InvalidMemspace]: "invalid memspace -- 0x00 is main, 0x01-0x04 are units 8-11",
140
+ [ErrorCode.InvalidLength]: "the request body length disagreed with what the command expects -- this is a client bug, please report it",
141
+ [ErrorCode.InvalidParameter]: "an argument in the request was invalid for this command",
142
+ [ErrorCode.InvalidApiVersion]: "the binary monitor rejected this request's api_version",
143
+ [ErrorCode.InvalidType]: "this command is not implemented by the connected VICE build",
144
+ [ErrorCode.CmdFailure]: "the command failed inside the monitor with no further diagnostic (a condition syntax error reports exactly this and nothing more)",
145
+ };
146
+
147
+ export function convertWireError(toolName: string, err: unknown): StockErrorResult {
148
+ if (err instanceof StockProtocolError) {
149
+ const text = err.errorCode !== undefined ? WIRE_ERROR_TEXT[err.errorCode] : undefined;
150
+ const codeText = `0x${(err.errorCode ?? 0).toString(16).padStart(2, "0")}`;
151
+ return isErrorText(`${toolName}: ${text ?? `the binary monitor returned error code ${codeText}`} (${err.message}).`);
152
+ }
153
+ if (err instanceof StockResponseMismatchError) {
154
+ return isErrorText(`${toolName}: the binary monitor replied with an unexpected response type (${err.message}).`);
155
+ }
156
+ if (err instanceof StockFramingError) {
157
+ return isErrorText(`${toolName}: the binary monitor's reply could not be decoded (${err.message}).`);
158
+ }
159
+ const message = err instanceof Error ? err.message : String(err);
160
+ return isErrorText(`${toolName}: the command failed (${message}).`);
161
+ }
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // stockAnswer() -- new here (Task 3). The ONE place a successful stock
165
+ // answer is constructed, so D-06's "runState on EVERY stock tool answer" is
166
+ // satisfied by construction rather than by every handler remembering to add
167
+ // it. Reads runStateFor(client) exactly once. A `runState` key already
168
+ // present in `payload` is overwritten by the projection's value -- a
169
+ // handler may never supply its own.
170
+ // ---------------------------------------------------------------------------
171
+
172
+ export function stockAnswer(client: ViceMonitorClient, payload: Record<string, unknown>): StockOkResult {
173
+ const runState = runStateFor(client);
174
+ return { content: [{ type: "text", text: JSON.stringify({ ...payload, runState }) }], isError: false };
175
+ }