@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,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
|
+
}
|