@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.
- package/README.md +15 -0
- package/THIRD-PARTY-NOTICES.md +113 -0
- package/backend-detect.mts +595 -0
- package/build.ts +1 -0
- package/disasm-decoder.ts +248 -0
- package/disasm-opcodes.ts +464 -0
- package/disasm-renderer.ts +306 -0
- package/package.json +27 -2
- package/refresh-manifest.ts +23 -2
- package/resources/backend-detect.mjs +396 -0
- package/resources/broker-control.mjs +110 -8
- package/resources/broker-kill.mjs +114 -103
- package/resources/broker-launch.mjs +334 -19
- package/resources/broker-state.mjs +11 -0
- package/resources/vice-broker.mjs +185 -14
- package/stock-address.ts +219 -0
- package/stock-checkpoints.ts +794 -0
- package/stock-condition.ts +636 -0
- package/stock-connect.ts +427 -0
- package/stock-derived.ts +122 -0
- package/stock-disassemble.ts +252 -0
- package/stock-dispatch.ts +640 -0
- package/stock-execution.ts +327 -0
- package/stock-handler.ts +175 -0
- package/stock-input.ts +274 -0
- package/stock-machine.ts +357 -0
- package/stock-memory.ts +323 -0
- package/stock-paths.ts +191 -0
- package/stock-petscii.ts +143 -0
- package/stock-protocol.ts +2057 -0
- package/stock-registers.ts +324 -0
- package/stock-runstate.ts +104 -0
- package/tools-manifest.json +1 -9
- package/tools-manifest.stock.json +841 -0
- package/vice-broker-client.ts +233 -7
- package/vice-proxy.ts +202 -17
|
@@ -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
|
+
};
|
package/stock-handler.ts
ADDED
|
@@ -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
|
+
}
|