@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,640 @@
1
+ #!/usr/bin/env node
2
+ // stock-dispatch.ts
3
+ //
4
+ // THE ONE PLACE the stock tool surface is defined and dispatched (D-07,
5
+ // D-09). D-07 makes the two backends' advertised tool lists genuinely
6
+ // different, permanently -- not a runtime filter over one shared list, but
7
+ // a second committed manifest file (tools-manifest.stock.json) this module
8
+ // selects between. D-09 says the stock path must never fall through to the
9
+ // fork's HTTP forward: a tool this file does not dispatch is simply not on
10
+ // the stock manifest, so vice-proxy.ts's tools/list answer never advertises
11
+ // it and there is nothing for a fall-through to catch.
12
+ //
13
+ // This plan (02-09) lands the manifest selector (manifestPathForBackend())
14
+ // and (Task 2, added below) the lease-to-session seam (ensureStockSession(),
15
+ // HeldLease on vice-broker-client.ts). Plan 02-10 adds the dispatch table
16
+ // itself, vice_ping, and the vice-proxy.ts wiring on top of this file.
17
+ //
18
+ // WHAT NOT TO DO:
19
+ // - Never fall through to forwardToVice() from this module or from
20
+ // anything built on top of it -- a stock tool call that reaches here
21
+ // with no dispatch entry must be refused, never silently forwarded to
22
+ // the fork's HTTP transport (D-09).
23
+ // - Never add a second dispatch site in vice-proxy.ts -- this file is the
24
+ // one place a tools/call for the stock backend is routed from.
25
+ // - Never acquire a broker lease here (Task 2's own ensureStockSession()
26
+ // header comment explains this prohibition fully).
27
+ import { resolve, join } from "node:path";
28
+
29
+ import type { ViceBackend } from "./backend-detect.mts";
30
+ import { type HeldLease } from "./vice-broker-client.ts";
31
+ import { stockConnect, stockDisconnect, stockReconnect, type StockConnectSession, type StockConnectDeps } from "./stock-connect.ts";
32
+ import {
33
+ isErrorText,
34
+ convertHandshakeError,
35
+ convertWireError,
36
+ stockAnswer,
37
+ type StockToolResult,
38
+ type StockOkResult,
39
+ type StockErrorResult,
40
+ type StockSessionHandler,
41
+ } from "./stock-handler.ts";
42
+ import { attachRunStateTracker } from "./stock-runstate.ts";
43
+ import { STOCK_DERIVED_TOOLS, type DerivedPureHandler } from "./stock-derived.ts";
44
+
45
+ // The six family modules (plans 03-06 through 03-11) -- each exports its
46
+ // tools as StockSessionHandler-shaped values; this file (D-09) is the ONE
47
+ // place they are registered into STOCK_DISPATCH_TABLE, below. Import order
48
+ // mirrors the table's own family grouping (Task 2, plan 03-12).
49
+ import { handleMemoryRead, handleMemoryWrite, handleMemoryBanks } from "./stock-memory.ts";
50
+ import { handleRegistersGet, handleRegistersSet, handleRegistersAvailable } from "./stock-registers.ts";
51
+ import {
52
+ handleCheckpointAdd,
53
+ handleCheckpointDelete,
54
+ handleCheckpointList,
55
+ handleCheckpointToggle,
56
+ handleCheckpointSetCondition,
57
+ handleWatchAdd,
58
+ forgetConditionsForOtherTargets,
59
+ } from "./stock-checkpoints.ts";
60
+ import { handleExecutionPause, handleExecutionRun, handleExecutionStep, handleExecutionUntilReturn } from "./stock-execution.ts";
61
+ import { handleMachineReset, handleAutostart, handleDiskAttach, handleSnapshotSave, handleSnapshotLoad } from "./stock-machine.ts";
62
+ import { handleKeyboardType, handleKeyboardPetscii, handleJoystickSet } from "./stock-input.ts";
63
+ import { handleDisassemble } from "./stock-disassemble.ts";
64
+
65
+ // Re-exported so Phase 2's existing import surface (and its 921-line test
66
+ // file) keeps working unchanged -- these four names used to be DEFINED
67
+ // here; stock-handler.ts (Task 3, this plan) is now their one true home,
68
+ // broken out so a family module can import them without importing this
69
+ // file back (see that file's own header comment on the cycle this avoids).
70
+ export { isErrorText, convertHandshakeError, convertWireError };
71
+ export type { StockToolResult, StockOkResult, StockErrorResult };
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // manifestPathForBackend() -- the manifest selector.
75
+ // ---------------------------------------------------------------------------
76
+
77
+ /**
78
+ * Resolves which manifest file backs a given backend's advertised tool
79
+ * surface, following the EXACT override precedence vice-proxy.ts's own
80
+ * manifestPath() already establishes: an explicit VICE_TOOLS_MANIFEST value
81
+ * (passed in as `envOverride`, never read from process.env directly here --
82
+ * this function stays a pure, injectable seam) wins for either backend,
83
+ * unchanged; otherwise the backend picks its own committed default file
84
+ * beside `hereDir`. `envOverride` is deliberately a plain parameter, not a
85
+ * process.env read, so this function has no hidden global dependency and a
86
+ * test can drive every combination without mutating the real environment.
87
+ */
88
+ export function manifestPathForBackend(backend: ViceBackend, hereDir: string, envOverride: string | undefined): string {
89
+ if (envOverride) {
90
+ return resolve(envOverride);
91
+ }
92
+ return backend === "stock" ? join(hereDir, "tools-manifest.stock.json") : join(hereDir, "tools-manifest.json");
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // ensureStockSession() -- the lease-to-session seam (Task 2).
97
+ // ---------------------------------------------------------------------------
98
+
99
+ /**
100
+ * Injection contract for ensureStockSession() below. Deliberately the exact
101
+ * widened shape vice-proxy.ts's own ensureBrokerLease() returns after plan
102
+ * 02-10 task 2, so ensureBrokerLease itself is structurally assignable to a
103
+ * LeaseProvider with no adapter function wrapping it:
104
+ * - `{ ok: true; lease: HeldLease | null }` on success. `lease: null` is
105
+ * the VICE_MCP_URL override case, where no broker control session
106
+ * exists to claim through.
107
+ * - `{ ok: false; message: string }` on a broker liveness failure --
108
+ * never_started / dead_or_hung / control_unreachable / warming, in
109
+ * ensureBrokerLease()'s own wording, passed through verbatim.
110
+ */
111
+ export type LeaseProvider = () => Promise<{ ok: true; lease: HeldLease | null } | { ok: false; message: string }>;
112
+
113
+ /**
114
+ * Injected dependencies for ensureStockSession() and, below, every stock
115
+ * dispatch handler. `connect`/`reconnect` exist SOLELY so tests can stub the
116
+ * socket-touching half of this seam -- production code passes neither, and
117
+ * stockConnect/stockReconnect (the real imports) are the defaults. Tests
118
+ * must never stub ensureStockSession itself: that is the wiring under test.
119
+ *
120
+ * `resolvedBinaryPath` (Task 1, plan 02-10) is BACK-03's third field on
121
+ * `vice_ping`'s answer -- `resolvedBackend().binPath`, which since WR-05 is a
122
+ * genuinely resolved ABSOLUTE path whenever the binary could be resolved, and
123
+ * the configured name (e.g. `"x64sc"`) only when it could not. Before WR-05
124
+ * this field was always the raw configured name while both this comment and
125
+ * BACK-03's own field name claimed resolution -- so `vice_ping` on stock
126
+ * reported `"x64sc"`, which inside a container names nothing at all.
127
+ * `binaryPathResolved` carries which of the two cases it is, so the answer
128
+ * never implies resolution it did not achieve. It is a plain string handed down from vice-proxy.ts's
129
+ * OWN single, module-scope call to `resolvedBackend()` (see that file's own
130
+ * "resolve the active backend once" discipline) -- this module must never
131
+ * call `resolvedBackend()`/`probeBackend()` itself, per backend-detect.mts's
132
+ * own "do not call this per tool or per call" prohibition. Omitted entirely
133
+ * (never expected in production) falls back to an empty string rather than
134
+ * throwing.
135
+ */
136
+ export interface StockDispatchDeps {
137
+ ensureLease: LeaseProvider;
138
+ connect?: typeof stockConnect;
139
+ reconnect?: typeof stockReconnect;
140
+ resolvedBinaryPath?: string;
141
+ /** WR-05: whether `resolvedBinaryPath` above is a real resolved absolute path
142
+ * (`true`) or the configured name resolution failed on (`false`). Threaded
143
+ * down from the SAME single `resolvedBackend()` call, never recomputed.
144
+ * Omitted defaults to `false` -- the honest answer when nothing said
145
+ * otherwise. */
146
+ resolvedBinaryPathIsResolved?: boolean;
147
+ }
148
+
149
+ export type EnsureStockSessionOutcome = { ok: true; session: StockConnectSession } | { ok: false; message: string };
150
+
151
+ // The ONE module-level holder for the live stock session, plus the ONE
152
+ // clearing function. Never a second holder, and never a holder of the
153
+ // lease itself (only of the CONNECTED session stockConnect() returned) --
154
+ // the lease is re-obtained from the provider on every call, per
155
+ // ensureStockSession()'s own header comment on why that re-consultation is
156
+ // free.
157
+ let heldSession: StockConnectSession | null = null;
158
+
159
+ /** Discards the held session without touching anything broker-side --
160
+ * stockDisconnect()/releaseMonitor() are a caller's concern (plan 02-10's
161
+ * dispatch seam), not this function's. Exported so a later plan's dispatch
162
+ * seam can force a fresh handshake on a typed error it decides to convert
163
+ * rather than propagate, without reaching into this module's private state
164
+ * any other way. */
165
+ export function clearHeldStockSession(): void {
166
+ heldSession = null;
167
+ }
168
+
169
+ /**
170
+ * The ONE place a stock handler turns a broker-granted lease into a live
171
+ * stockConnect() session, in this load-bearing order:
172
+ *
173
+ * 1. Await deps.ensureLease() FIRST, always -- before anything else in
174
+ * this function runs, and before stockConnect() is ever reached. This
175
+ * is what makes D-13's "the claim precedes every dial" guarantee true
176
+ * for the stock path: ensureBrokerLease()'s own body performs the
177
+ * liveness classification, control-session open, and grant
178
+ * acquisition; nothing here re-derives any part of that.
179
+ * 2. On `{ ok: false }`, return the provider's own `message` verbatim --
180
+ * never re-worded. ensureBrokerLease()'s never_started / dead_or_hung /
181
+ * control_unreachable / warming diagnostics are its own to phrase.
182
+ * 3. On `{ ok: true, lease: null }` (the VICE_MCP_URL override, where no
183
+ * broker control session exists), refuse explicitly: the stock backend
184
+ * cannot claim a monitor socket it has no control session to claim
185
+ * through, so stockConnect() must never be attempted.
186
+ * 4. Otherwise reuse the held session when its targetId matches the
187
+ * lease's -- and only otherwise (no held session, or a targetId
188
+ * mismatch, which means a REPLACEMENT acquisition granted a different
189
+ * instance) TEAR DOWN whatever was held via stockDisconnect() (CR-05:
190
+ * dropping the reference alone leaks a live socket into stock VICE's
191
+ * single client slot) and then call stockConnect() fresh and hold its
192
+ * result.
193
+ * 5. A held session whose underlying socket has already died
194
+ * (`!session.client.connected`) is not silently reused: it is
195
+ * re-established via stockReconnect() (which itself re-proves machine
196
+ * identity via the epoch baseline before re-running the handshake) --
197
+ * a failure there (MachineRestartedError, or anything else) clears the
198
+ * holder before propagating, so a future call re-handshakes from
199
+ * scratch rather than ever retrying against a session known to be bad.
200
+ *
201
+ * The provider is called on EVERY invocation, never cached here:
202
+ * ensureBrokerLease()'s own first line already returns immediately when a
203
+ * control session is already held, so calling it per dispatch is free --
204
+ * and it is the only thing that notices a replacement acquisition
205
+ * happened. Caching the lease in this module instead would be a second,
206
+ * staler copy of state vice-proxy.ts already owns (the "re-deriving a
207
+ * cross-cutting seam locally" anti-pattern, aimed at the lease this time
208
+ * rather than the acquisition itself).
209
+ *
210
+ * This function must NEVER call openBrokerControl(), session.acquire(), or
211
+ * adoptGrant(); NEVER read broker.json; and NEVER construct a host or port
212
+ * from anything but the lease deps.ensureLease() handed it. D-13's
213
+ * guarantee -- nothing reaches a second connect() -- only holds if there is
214
+ * exactly one acquisition, and the monitor_claim inside stockConnect() is
215
+ * made on the control session THAT acquisition produced. A locally-derived
216
+ * control session here would claim on one connection while a different one
217
+ * held the grant -- the "re-deriving a cross-cutting seam locally"
218
+ * anti-pattern with a wedge-shaped failure mode (a refused claim that never
219
+ * arrives, because the connection expecting the refusal is not the one that
220
+ * holds the grant).
221
+ *
222
+ * MonitorOwnershipError and every other typed error stockConnect()/
223
+ * stockReconnect() can throw propagate unchanged out of this function --
224
+ * the never-throw conversion into a well-formed tool result is the dispatch
225
+ * seam's job (plan 02-10), not this function's.
226
+ */
227
+ export async function ensureStockSession(deps: StockDispatchDeps): Promise<EnsureStockSessionOutcome> {
228
+ const connectFn = deps.connect ?? stockConnect;
229
+ const reconnectFn = deps.reconnect ?? stockReconnect;
230
+
231
+ const leaseOutcome = await deps.ensureLease();
232
+ if (!leaseOutcome.ok) {
233
+ return { ok: false, message: leaseOutcome.message };
234
+ }
235
+
236
+ const lease = leaseOutcome.lease;
237
+ if (lease === null) {
238
+ return {
239
+ ok: false,
240
+ message:
241
+ "ensureStockSession: VICE_MCP_URL is set, so there is no broker-managed instance and no broker control " +
242
+ "session to claim a monitor socket through -- the stock backend needs a broker-managed instance in order " +
243
+ "to claim the monitor socket before dialling. Unset VICE_MCP_URL to use the on-demand broker, or connect " +
244
+ "to a broker-managed instance directly.",
245
+ };
246
+ }
247
+
248
+ if (heldSession !== null && heldSession.targetId === lease.targetId) {
249
+ if (heldSession.client.connected) {
250
+ return { ok: true, session: heldSession };
251
+ }
252
+ try {
253
+ heldSession = await reconnectFn(heldSession);
254
+ // D-06/RESEARCH.md Pitfall 4: attach HERE, at the fresh client a
255
+ // reconnect just produced -- never in the `heldSession.client.connected`
256
+ // reuse branch above. The tracker attach is idempotent (a stray extra
257
+ // call on the SAME client is harmless), but a reconnect always hands
258
+ // back a NEW ViceMonitorClient, so this is a genuinely fresh client
259
+ // that has never had one attached. Getting this placement wrong would
260
+ // mean the D-11 trace guard's rate-limiter listener could attach a
261
+ // second time on a client already tracked elsewhere and fire its side
262
+ // effect (a CHECKPOINT_TOGGLE) more than once per real event.
263
+ attachRunStateTracker(heldSession.client);
264
+ return { ok: true, session: heldSession };
265
+ } catch (err) {
266
+ clearHeldStockSession();
267
+ throw err;
268
+ }
269
+ }
270
+
271
+ // No held session, or the lease now names a different targetId -- a
272
+ // replacement acquisition means a different instance underneath, so
273
+ // whatever was held is discarded rather than reused.
274
+ //
275
+ // CR-05 (code review 2026-08-13): DISCARDED, not merely DEREFERENCED. The
276
+ // previous session's ViceMonitorClient is still connected at this point --
277
+ // its socket, its data/close/error listeners, its pending map and its
278
+ // broker-side monitorClient claim all outlive the reference, and the holder
279
+ // is module-private, so nulling it was the last chance anything had to
280
+ // release them. Because stock VICE services exactly ONE binmon client, that
281
+ // leaked socket keeps occupying the instance's single client slot: if the
282
+ // broker later hands the same port out again (a recycle/respawn builds a
283
+ // fresh InstanceRecord, so monitorClient is cleared and a new claim
284
+ // succeeds), the new client's connect() sits unserviced in the backlog with
285
+ // no reply and no EOF -- the state CLAUDE.md says must never be reachable
286
+ // and must never be diagnosed as a hang.
287
+ //
288
+ // stockDisconnect() is the ONE teardown that disconnects the socket AND
289
+ // releases the monitor claim together (its own header comment: a caller must
290
+ // never end up holding one without the other). Best-effort: a teardown
291
+ // failure on the OUTGOING session must not stop the replacement handshake,
292
+ // and the holder is cleared FIRST so a throw can never leave a dead session
293
+ // installed.
294
+ const stale = heldSession;
295
+ heldSession = null;
296
+ if (stale !== null) {
297
+ try {
298
+ await stockDisconnect(stale);
299
+ } catch (err) {
300
+ console.error(`ensureStockSession: tearing down the replaced stock session for target ${stale.targetId} did not complete: ${String(err)}`);
301
+ }
302
+ }
303
+
304
+ const session = await connectFn({
305
+ host: lease.host,
306
+ port: lease.port,
307
+ targetId: lease.targetId,
308
+ brokerControl: lease.brokerControl,
309
+ deps: stockConnectDepsFor(lease, deps),
310
+ });
311
+ // D-06/RESEARCH.md Pitfall 4 (same placement rule as the reconnect branch
312
+ // above): attach the tracker to this BRAND NEW client, immediately after
313
+ // stockConnect() returns it -- never inside stockConnect() itself. The
314
+ // handshake stockConnect() just ran sends its own PING and a CR-02 EXIT;
315
+ // projecting that internal pair as the user's own run state would
316
+ // contradict D-07's honest "unknown" (the agent has not resumed anything
317
+ // yet, and a stale connect-time assumption is exactly what D-07 forbids).
318
+ attachRunStateTracker(session.client);
319
+ heldSession = session;
320
+ // WR-03 (03-REVIEW.md): THE eviction point for stock-checkpoints.ts's
321
+ // targetId-keyed condition registry. Reaching this line means a fresh
322
+ // handshake just installed a new held session, so every OTHER target this
323
+ // process has ever seen is an instance that has already been torn down and
324
+ // can never be consulted again -- without this, that registry (a strong Map,
325
+ // deliberately, so it survives a stockReconnect() to the same machine) would
326
+ // grow one entry per distinct instance for the life of the process, which a
327
+ // broker that recycles/respawns/re-warms routinely makes unbounded.
328
+ //
329
+ // Placed here rather than beside the stockDisconnect() teardown above so it
330
+ // also covers the path where the holder was cleared by a FAILED
331
+ // stockReconnect() and its stale targetId was never handed to a teardown at
332
+ // all. The reuse and reconnect branches return before this line, so a
333
+ // reconnect to the SAME machine never evicts anything.
334
+ forgetConditionsForOtherTargets(session.targetId);
335
+ return { ok: true, session };
336
+ }
337
+
338
+ /**
339
+ * CR-06 (code review 2026-08-13). The ONE place production builds
340
+ * StockConnectDeps. Before this existed, the only production call was
341
+ * `connectFn({ host, port, targetId, brokerControl })` -- no `deps` at all --
342
+ * so two mechanisms this phase built were inert on the real path:
343
+ *
344
+ * - `deps.epochPath` was undefined, so `baselineEpoch` was always null and
345
+ * stockReconnect()'s first branch ALWAYS threw MachineRestartedError.
346
+ * Every transient socket drop told the agent "the emulator's identity
347
+ * could not be proven across a reconnect ... treat every result since the
348
+ * previous call as void", even when the machine never restarted.
349
+ * - `deps.binPath`/`deps.supervisorDir` were undefined, so
350
+ * resolveCapabilities() skipped the cache, re-probed CPUHISTORY_GET on
351
+ * every handshake, and never called writeCapabilityRecord() -- BACK-04's
352
+ * "settle once per binary, at connect time" was not achieved.
353
+ *
354
+ * Neither was visible to the existing tests, because both stub `connect`.
355
+ *
356
+ * Every value here is HANDED DOWN, never resolved locally: the two directories
357
+ * come from the lease vice-proxy.ts built (see HeldLease's own field comments
358
+ * for why they are two DIFFERENT directories), and `binPath` is the same
359
+ * already-settled `resolvedBinaryPath` vice_ping reports -- this module must
360
+ * never call resolvedBackend()/probeBackend() itself.
361
+ *
362
+ * An empty string is treated as ABSENT rather than passed through: the two
363
+ * consumers both branch on truthiness, and passing "" would key a capability
364
+ * cache read on an empty binary path.
365
+ */
366
+ function stockConnectDepsFor(lease: HeldLease, deps: StockDispatchDeps): StockConnectDeps {
367
+ const connectDeps: StockConnectDeps = {};
368
+ if (lease.epochFile) connectDeps.epochPath = lease.epochFile;
369
+ if (lease.supervisorDir) connectDeps.supervisorDir = lease.supervisorDir;
370
+ if (deps.resolvedBinaryPath) connectDeps.binPath = deps.resolvedBinaryPath;
371
+ return connectDeps;
372
+ }
373
+
374
+ // Re-exported so a caller of this seam never needs a second import site for
375
+ // the connect-handshake types it already threads through -- avoids a
376
+ // consumer accidentally importing stock-connect.ts's stockDisconnect
377
+ // directly from two different specifiers.
378
+ export { stockDisconnect };
379
+
380
+ // ---------------------------------------------------------------------------
381
+ // dispatchStock() -- the dispatch table and hard refusal (Task 1, plan 02-10).
382
+ // ---------------------------------------------------------------------------
383
+ //
384
+ // D-09's whole point, restated at the point it is enforced: a tool call that
385
+ // reaches dispatchStock() below either matches a table entry and is answered
386
+ // by name, or matches nothing and is refused by name -- there is no third
387
+ // path, and in particular no fall-through to forwardToVice() (vice-proxy.ts's
388
+ // fork-transport function). This file has no code reference to that name at
389
+ // all; a source-structure test in stock-dispatch.test.ts and a grep gate in
390
+ // this plan's own acceptance criteria both confirm it stays that way.
391
+
392
+ /** One stock dispatch table entry. `deps` is the SAME StockDispatchDeps
393
+ * ensureStockSession() itself takes -- a handler that needs a live session
394
+ * reaches it only through ensureStockSession(deps), never by resolving a
395
+ * lease or opening a socket of its own (that would be a second acquisition
396
+ * path, the exact thing ensureStockSession()'s own header comment
397
+ * prohibits). */
398
+ export type StockHandler = (args: Record<string, unknown>, deps: StockDispatchDeps) => Promise<StockToolResult>;
399
+
400
+ /**
401
+ * withStockSession -- THE ONE adapter every STOCK_DISPATCH_TABLE entry goes
402
+ * through (Task 1, plan 03-12). Before this existed, `viceHandlerPing` was
403
+ * the only table entry and re-implemented, inline, the exact three-step
404
+ * preamble every one of the 24 Phase 3 family handlers also needs: acquire a
405
+ * session through ensureStockSession(deps), convert a thrown handshake error
406
+ * or a `{ ok: false }` refusal into well-formed refusal text, and only THEN
407
+ * hand off to the tool's own logic. This function performs that preamble
408
+ * exactly once, for every tool, so 24 handlers do not each re-implement
409
+ * session-acquisition and error conversion themselves -- a table entry that
410
+ * bypasses this adapter (calling ensureStockSession() or a family handler
411
+ * directly) is a bug, not a variant.
412
+ *
413
+ * Order, exactly as `viceHandlerPing` established:
414
+ * 1. `ensureStockSession(deps)`, wrapped in its own try/catch --
415
+ * `convertHandshakeError(toolName, err)` on a thrown handshake error
416
+ * (MonitorOwnershipError, MachineRestartedError, or anything else
417
+ * stockConnect()/stockReconnect() can propagate).
418
+ * 2. `{ ok: false }` -- returns `outcome.message` verbatim, never re-worded
419
+ * (the provider's own diagnostic, e.g. a broker liveness classification).
420
+ * 3. Otherwise delegates to `handler(args, outcome.session, deps)`, itself
421
+ * wrapped in a SECOND try/catch: anything a family handler lets escape
422
+ * becomes `convertWireError(toolName, err)` rather than an uncaught
423
+ * rejection. vice-proxy.ts's stdio server is never restarted by Claude
424
+ * Code for the rest of the session (T-3-04) -- a single escaped
425
+ * exception here would silently end the session's entire tool surface,
426
+ * not just this one call.
427
+ */
428
+ export function withStockSession(toolName: string, handler: StockSessionHandler): StockHandler {
429
+ return async (args, deps) => {
430
+ let outcome: EnsureStockSessionOutcome;
431
+ try {
432
+ outcome = await ensureStockSession(deps);
433
+ } catch (err) {
434
+ return convertHandshakeError(toolName, err);
435
+ }
436
+
437
+ if (!outcome.ok) {
438
+ return isErrorText(outcome.message);
439
+ }
440
+
441
+ try {
442
+ return await handler(args, outcome.session, deps);
443
+ } catch (err) {
444
+ return convertWireError(toolName, err);
445
+ }
446
+ };
447
+ }
448
+
449
+ /**
450
+ * withDerivedTool -- THE ONE adapter for a tool whose answer is computed
451
+ * CLIENT-SIDE (DERIV-07), sitting immediately beside withStockSession()
452
+ * above. Derived-ness is a property of WHICH ADAPTER wraps a handler, NEVER
453
+ * a routing decision (D-03) -- there is still exactly one
454
+ * STOCK_DISPATCH_TABLE and exactly one dispatchStock( call site in
455
+ * vice-proxy.ts. A derived tool registers into the SAME table a direct tool
456
+ * does, through this adapter instead of withStockSession().
457
+ *
458
+ * Refuses any `toolName` not declared in STOCK_DERIVED_TOOLS -- at CALL
459
+ * TIME, inside the returned handler, never as a module-scope throw (the
460
+ * table literal below is evaluated at import time, and a throw there would
461
+ * kill the whole stdio server before it starts).
462
+ *
463
+ * `needsSession: false` exists because D-05 of Phase 3 makes every touch of
464
+ * the wire a machine halt, so an emulator-free derived tool must not stop
465
+ * the user's running program for nothing (D-04). Its returned handler NEVER
466
+ * calls ensureStockSession() at all -- not a lighter-weight variant of it
467
+ * (04-RESEARCH.md Pitfall 3) -- and invokes `handler(args, deps)` inside a
468
+ * single try/catch converting through convertWireError(), so the
469
+ * never-throw boundary still holds.
470
+ *
471
+ * `needsSession: true` runs the EXACT same three-step preamble
472
+ * withStockSession() runs, reusing the same imported converters -- never a
473
+ * third error converter (stock-handler.ts's standing rule): ensureStockSession(deps)
474
+ * inside its own try/catch -> convertHandshakeError(toolName, err); a
475
+ * `{ ok: false }` outcome returns outcome.message verbatim through
476
+ * isErrorText(), never re-worded; otherwise handler(args, outcome.session, deps)
477
+ * inside a SECOND try/catch -> convertWireError(toolName, err).
478
+ */
479
+ export function withDerivedTool(toolName: string, opts: { needsSession: true }, handler: StockSessionHandler): StockHandler;
480
+ export function withDerivedTool(toolName: string, opts: { needsSession: false }, handler: DerivedPureHandler): StockHandler;
481
+ export function withDerivedTool(
482
+ toolName: string,
483
+ opts: { needsSession: boolean },
484
+ handler: StockSessionHandler | DerivedPureHandler,
485
+ ): StockHandler {
486
+ return async (args, deps) => {
487
+ if (!STOCK_DERIVED_TOOLS.has(toolName)) {
488
+ return isErrorText(`${toolName} is not declared in STOCK_DERIVED_TOOLS -- withDerivedTool refuses any undeclared tool.`);
489
+ }
490
+
491
+ if (!opts.needsSession) {
492
+ try {
493
+ return await (handler as DerivedPureHandler)(args, deps);
494
+ } catch (err) {
495
+ return convertWireError(toolName, err);
496
+ }
497
+ }
498
+
499
+ let outcome: EnsureStockSessionOutcome;
500
+ try {
501
+ outcome = await ensureStockSession(deps);
502
+ } catch (err) {
503
+ return convertHandshakeError(toolName, err);
504
+ }
505
+
506
+ if (!outcome.ok) {
507
+ return isErrorText(outcome.message);
508
+ }
509
+
510
+ try {
511
+ return await (handler as StockSessionHandler)(args, outcome.session, deps);
512
+ } catch (err) {
513
+ return convertWireError(toolName, err);
514
+ }
515
+ };
516
+ }
517
+
518
+ /**
519
+ * The `vice_ping` handler -- BACK-03's answer, on the tool an agent already
520
+ * reaches for first. A plain StockSessionHandler now that withStockSession
521
+ * owns the session-acquisition/error-conversion preamble; this function's
522
+ * only job is to build the answer once a live session already exists.
523
+ * Enriches the ordinary ping answer with the three BACK-03 fields: `backend`
524
+ * (always `"stock"` on this path), `viceVersion` (rendered from the
525
+ * handshake's own version quad), and `resolvedBinaryPath` (threaded down
526
+ * from deps, never resolved here -- see StockDispatchDeps's own header
527
+ * comment on why). Built through stockAnswer() so the answer now also
528
+ * carries `runState` (D-06: every stock answer, and `vice_ping` is a stock
529
+ * answer) alongside every field that was already there.
530
+ */
531
+ const handlePing: StockSessionHandler = async (_args, session, deps) => {
532
+ return stockAnswer(session.client, {
533
+ status: "ok",
534
+ backend: "stock" as const,
535
+ viceVersion: `VICE ${session.versionQuad}`,
536
+ resolvedBinaryPath: deps.resolvedBinaryPath ?? "",
537
+ // WR-05: says outright whether the field above IS a resolved path. Without
538
+ // it, an agent reading `"x64sc"` cannot tell "this is where the binary is"
539
+ // from "this is what we were told to look for, and we could not find it".
540
+ resolvedBinaryPathIsResolved: deps.resolvedBinaryPathIsResolved ?? false,
541
+ capabilities: session.capabilities,
542
+ });
543
+ };
544
+
545
+ /**
546
+ * Deliberately NOT registered below -- each omission is a planner decision,
547
+ * not an oversight (Task 2, plan 03-12):
548
+ * - `vice_checkpoint_set_ignore_count` (D-15)
549
+ * - `vice_snapshot_list` (D-16 -- deleted from both manifests)
550
+ * - `vice_disk_detach` (D-13 -- Phase 7, via the text monitor)
551
+ * - `vice_joystick_tap` (needs a resume plus Phase 7's timing route)
552
+ * - `vice_disk_read_sector` (Phase 5)
553
+ * - `vice_sid_get_state` and the low-level keyboard family (hard losses)
554
+ * - `vice_machine_config_get` / `vice_machine_config_set` (Phase 6)
555
+ * `dispatchStock()`'s miss branch already refuses any of these by name,
556
+ * without reading `deps` -- there is nothing else to add for them here.
557
+ */
558
+
559
+ /** The ONE dispatch table this whole module tree ever defines (D-09) --
560
+ * keyed on manifest tool name. All 24 Phase 3 family tools plus vice_ping
561
+ * are registered here, each through withStockSession (never called
562
+ * directly, never a parallel table, never a second dispatch site in
563
+ * vice-proxy.ts -- grep-gated to exactly one `dispatchStock(` call there,
564
+ * plan 02-10 task 2's own acceptance criteria). A later plan (phases 4-7)
565
+ * adds its own stock entries here as those phases' tools land. */
566
+ const STOCK_DISPATCH_TABLE: Record<string, StockHandler> = {
567
+ vice_ping: withStockSession("vice_ping", handlePing),
568
+
569
+ // memory (DIRECT-01, DIRECT-09)
570
+ vice_memory_read: withStockSession("vice_memory_read", handleMemoryRead),
571
+ vice_memory_write: withStockSession("vice_memory_write", handleMemoryWrite),
572
+ vice_memory_banks: withStockSession("vice_memory_banks", handleMemoryBanks),
573
+
574
+ // registers (DIRECT-02, DIRECT-09)
575
+ vice_registers_get: withStockSession("vice_registers_get", handleRegistersGet),
576
+ vice_registers_set: withStockSession("vice_registers_set", handleRegistersSet),
577
+ // stock-only, no fork counterpart (Phase 2 D-07)
578
+ vice_registers_available: withStockSession("vice_registers_available", handleRegistersAvailable),
579
+
580
+ // checkpoints and watchpoints (DIRECT-03)
581
+ vice_checkpoint_add: withStockSession("vice_checkpoint_add", handleCheckpointAdd),
582
+ vice_checkpoint_delete: withStockSession("vice_checkpoint_delete", handleCheckpointDelete),
583
+ vice_checkpoint_list: withStockSession("vice_checkpoint_list", handleCheckpointList),
584
+ vice_checkpoint_toggle: withStockSession("vice_checkpoint_toggle", handleCheckpointToggle),
585
+ vice_checkpoint_set_condition: withStockSession("vice_checkpoint_set_condition", handleCheckpointSetCondition),
586
+ vice_watch_add: withStockSession("vice_watch_add", handleWatchAdd),
587
+
588
+ // execution (DIRECT-04, DIRECT-05)
589
+ vice_execution_pause: withStockSession("vice_execution_pause", handleExecutionPause),
590
+ vice_execution_run: withStockSession("vice_execution_run", handleExecutionRun),
591
+ vice_execution_step: withStockSession("vice_execution_step", handleExecutionStep),
592
+ // stock-only, no fork counterpart
593
+ vice_execution_until_return: withStockSession("vice_execution_until_return", handleExecutionUntilReturn),
594
+
595
+ // machine and snapshots (DIRECT-06, DIRECT-08)
596
+ vice_machine_reset: withStockSession("vice_machine_reset", handleMachineReset),
597
+ vice_autostart: withStockSession("vice_autostart", handleAutostart),
598
+ vice_disk_attach: withStockSession("vice_disk_attach", handleDiskAttach),
599
+ vice_snapshot_save: withStockSession("vice_snapshot_save", handleSnapshotSave),
600
+ vice_snapshot_load: withStockSession("vice_snapshot_load", handleSnapshotLoad),
601
+
602
+ // input (DIRECT-07)
603
+ vice_keyboard_type: withStockSession("vice_keyboard_type", handleKeyboardType),
604
+ vice_keyboard_petscii: withStockSession("vice_keyboard_petscii", handleKeyboardPetscii),
605
+ vice_joystick_set: withStockSession("vice_joystick_set", handleJoystickSet),
606
+
607
+ // derived (DERIV-07, DISASM-01)
608
+ vice_disassemble: withDerivedTool("vice_disassemble", { needsSession: true }, handleDisassemble),
609
+ };
610
+
611
+ /** Looks up the table entry for `name` -- `undefined` on a miss, never a
612
+ * refusal object itself (that is dispatchStock()'s job, below): this
613
+ * function is the pure lookup half, kept separate so a caller (or a test)
614
+ * can ask "does the stock backend implement this tool" without triggering
615
+ * any dispatch. */
616
+ export function stockHandlerFor(name: string): StockHandler | undefined {
617
+ return STOCK_DISPATCH_TABLE[name];
618
+ }
619
+
620
+ /**
621
+ * The ONE dispatch entry point for the stock backend (D-09). On a hit,
622
+ * delegates to the table entry, unchanged. On a miss, refuses EXPLICITLY --
623
+ * naming the tool, stating the stock backend does not implement it, and
624
+ * naming the fork as the backend that does -- WITHOUT reading `deps` at all
625
+ * (no lease is ever requested for a tool that does not exist on this
626
+ * backend). There is no third branch, and in particular NO fall-through to
627
+ * forwardToVice() anywhere in this file or anything it calls -- that is
628
+ * D-09's whole point, grep-gated to zero occurrences of that name in this
629
+ * file's own code lines.
630
+ */
631
+ export async function dispatchStock(name: string, args: Record<string, unknown>, deps: StockDispatchDeps): Promise<StockToolResult> {
632
+ const handler = stockHandlerFor(name);
633
+ if (!handler) {
634
+ return isErrorText(
635
+ `${name} is not implemented by the stock backend -- the fork backend provides this tool. ` +
636
+ `Set VICE_BACKEND=fork to use it there, or wait for a later phase to extend the stock dispatch table.`,
637
+ );
638
+ }
639
+ return handler(args, deps);
640
+ }