@henols/vice-mcp 0.2.2 → 0.2.3

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.
Files changed (77) hide show
  1. package/README.md +2 -2
  2. package/THIRD-PARTY-NOTICES.md +422 -1
  3. package/anno-bank.ts +171 -0
  4. package/anno-cli.ts +1674 -99
  5. package/anno-enum-gen.ts +416 -30
  6. package/anno-export-asm.ts +1175 -89
  7. package/anno-graphics.ts +338 -0
  8. package/anno-hazard-report.ts +1367 -0
  9. package/anno-import.ts +495 -0
  10. package/anno-join.ts +480 -0
  11. package/anno-provenance-ledger.ts +472 -0
  12. package/anno-register.ts +159 -0
  13. package/anno-store-export.ts +661 -0
  14. package/anno-store.ts +518 -2
  15. package/anno-tools.ts +1169 -16
  16. package/anno-types.ts +275 -2
  17. package/backend-detect.mts +124 -312
  18. package/build.ts +3 -1
  19. package/capture-predicate.ts +597 -0
  20. package/channel-lock.ts +349 -0
  21. package/evid-ingest.ts +217 -0
  22. package/evid-reconcile.ts +316 -0
  23. package/host-tool-client.ts +430 -0
  24. package/incident-record.ts +23 -12
  25. package/install-resources.ts +29 -13
  26. package/memmap-lookup.ts +285 -0
  27. package/package.json +27 -8
  28. package/prg-image.ts +1 -2
  29. package/repo-root.ts +87 -3
  30. package/resources/backend-detect.mjs +98 -236
  31. package/resources/broker-control.mjs +189 -16
  32. package/resources/broker-epoch.mjs +1 -1
  33. package/resources/broker-kill.mjs +8 -2
  34. package/resources/broker-launch.mjs +365 -210
  35. package/resources/broker-state.mjs +64 -18
  36. package/resources/container-guard.mjs +1 -1
  37. package/resources/ghidra-project.mjs +790 -0
  38. package/resources/host-tool.mjs +2561 -0
  39. package/resources/vice-broker.mjs +330 -184
  40. package/resources/vice-launcher.sh +127 -9
  41. package/stock-address.ts +1 -1
  42. package/stock-condition.ts +1 -1
  43. package/stock-connect.ts +9 -5
  44. package/stock-derived.ts +29 -37
  45. package/stock-diagnose.ts +200 -36
  46. package/stock-dispatch.ts +179 -77
  47. package/stock-handler.ts +1 -1
  48. package/stock-paths.ts +18 -14
  49. package/stock-petscii.ts +1 -1
  50. package/stock-protocol.ts +1 -1
  51. package/stock-recycle.ts +83 -2
  52. package/stock-reproducible-run.ts +811 -0
  53. package/stock-run-until.ts +100 -1
  54. package/stock-symbols.ts +4 -4
  55. package/stock-timing.ts +1 -1
  56. package/stop-oracle.ts +167 -0
  57. package/text-capability-probe.ts +660 -0
  58. package/text-connect.ts +157 -0
  59. package/text-protocol.ts +810 -0
  60. package/text-tools.ts +778 -0
  61. package/textmon-backtrace.ts +385 -0
  62. package/textmon-cpuhistory.ts +335 -0
  63. package/textmon-memmap.ts +494 -0
  64. package/textmon-profile.ts +458 -0
  65. package/textmon-registers.ts +748 -0
  66. package/tools-manifest.stock.json +864 -3
  67. package/vice-broker-client.ts +189 -42
  68. package/vice-errors.ts +268 -0
  69. package/vice-proxy.ts +339 -2144
  70. package/vsf-slice.ts +640 -0
  71. package/anno-d64.ts +0 -310
  72. package/capability-registry.ts +0 -390
  73. package/refresh-manifest.ts +0 -124
  74. package/tools-manifest.json +0 -1223
  75. package/vice-probe.ts +0 -278
  76. package/vice-sync.ts +0 -336
  77. package/vice.ts +0 -772
package/vice.ts DELETED
@@ -1,772 +0,0 @@
1
- #!/usr/bin/env node
2
- // Single MCP client seam for the host VICE MCP server. Every emulator
3
- // interaction in this project goes through `call()` -- no other file speaks
4
- // MCP JSON-RPC or raw HTTP to the VICE endpoint directly.
5
- //
6
- // Why a seam at all: Phase 1 tooling and Phase 3's verify/runner.mjs both
7
- // depend on this one transport. If the handshake shape ever needs to change
8
- // (session header, SSE framing, a curl fallback), it changes here once.
9
- //
10
- // The deny-list is the other reason this file exists: vice_disk_list crashes
11
- // the shared host MCP server (see CLAUDE.md's hazard note and STATE.md's
12
- // blocker entry). The guard below runs *before* any request is serialised,
13
- // so no caller -- however indirect -- can reach that tool by accident.
14
- import { resolve, join } from "node:path";
15
- import { readFileSync } from "node:fs";
16
-
17
- import { supervisorDir } from "./repo-root.ts";
18
- import { isInsideContainer, type ContainerGuardDeps } from "./container-guard.mts";
19
-
20
- // Renamed from ENDPOINT to DEFAULT_ENDPOINT (D-5): a pool lease redirects
21
- // the seam to a DIFFERENT endpoint at runtime via useInstance() below, so
22
- // this is only the starting value, never assumed to be the active one.
23
- //
24
- // PORT TRIAGE (01.6.2-09, D-18): this 6510 is a KEPT, CORRECT value, not an
25
- // oversight. 6510-6599 is the band reserved by convention for an x64sc a
26
- // human launches on the host for their OWN work; when no broker grant
27
- // exists and no explicit VICE_MCP_URL override is set, this fallback
28
- // describes exactly that human-launched instance -- which is what the
29
- // reserved band is now for. Do not "fix" this into the broker's own
30
- // allocated band (6600+, DEFAULT_BASE_PORT in broker-state.mts) -- that
31
- // would be the broker squatting a port a human wants, the exact defect
32
- // D-18 exists to prevent.
33
- // The host part is resolved through mcpHost() rather than baked in as a
34
- // literal, for the same container-versus-host reason documented on mcpHost()
35
- // below -- this URL was the LAST remaining unconditional
36
- // "host.docker.internal" in the tree. Calling mcpHost() here is legal despite
37
- // it being declared further down: it is a function DECLARATION, so it is
38
- // hoisted, and its own import is initialised before this module body runs.
39
- // Evaluated once at startup, which also warms isInsideContainer()'s cache
40
- // before any tool call needs it.
41
- const DEFAULT_ENDPOINT: string = process.env.VICE_MCP_URL || `http://${mcpHost()}:6510/mcp`;
42
- const DEFAULT_TIMEOUT_MS: number = Number(process.env.VICE_MCP_TIMEOUT_MS || 30000);
43
-
44
- // The address of the host machine -- the ONE definition every consumer that
45
- // needs to build a host-facing URL from a bare port reads, instead of each
46
- // inlining its own `process.env.VICE_MCP_HOST || "host.docker.internal"` copy
47
- // (there were three such copies before this: vice-pool.mjs's instanceFor() and
48
- // defaultInstance(), and vice-session.mjs's readSession()). A FUNCTION, not
49
- // a module-level constant, so it stays sensitive to a runtime env override
50
- // -- vice-pool.test.mjs's own withMcpHostEnv() helper mutated
51
- // process.env.VICE_MCP_HOST across test cases within the SAME process
52
- // (before that file's 2026-08-02 deletion), which a constant captured once
53
- // at import time would have silently stopped honouring.
54
- //
55
- // CONTAINER-AWARE (2026-08-05, developer instruction). The default was
56
- // previously the bare literal "host.docker.internal", which is correct in
57
- // exactly ONE of the two environments this code runs in: it is a
58
- // Docker-provided alias, published into the container by
59
- // .devcontainer/devcontainer.json's `--add-host=host.docker.internal:host-gateway`,
60
- // and it does not resolve on the host at all. Host-bound modules genuinely do
61
- // consume this tree (vice-broker.mts references vice-broker-client), so a
62
- // single unconditional answer was wrong for one side by construction.
63
- //
64
- // Detection is delegated to container-guard.mts's isInsideContainer() rather
65
- // than re-derived -- see that function's own comment for why a second
66
- // detector is a bug waiting to happen here.
67
- //
68
- // Non-container branch is 127.0.0.1 rather than "localhost" DELIBERATELY:
69
- // "localhost" may resolve to ::1 first, and the broker binds 0.0.0.0 --
70
- // IPv4-only (broker-control.mts's documented bind), so an IPv6 loopback
71
- // connect would be refused by a listener that is in fact running. An explicit
72
- // IPv4 literal cannot pick the wrong family. It also classifies as `loopback`
73
- // under vice-broker-client.ts's classifyConnectHost(), which that resolver
74
- // deliberately does NOT refuse, and is not `wildcard_bind`, so it does not
75
- // trip the pre-connect refusal.
76
- export function mcpHost(deps?: ContainerGuardDeps): string {
77
- return process.env.VICE_MCP_HOST || (isInsideContainer(deps) ? "host.docker.internal" : "127.0.0.1");
78
- }
79
-
80
- // Where tools/vice-supervisor.sh (host-only) writes its restart epoch --
81
- // resolved via repo-root.ts's supervisorDir() (never a fixed hop count off
82
- // this file's own location), so the path is correct regardless of the
83
- // caller's cwd AND regardless of how deep this file sits under the repo
84
- // root. Overridable for tests and for anyone running the supervisor with a
85
- // non-default VICE_SUPERVISOR_DIR. Kept exactly as-is (D-5: no behaviour
86
- // change with no pool running) -- this remains the default that
87
- // activeEpochFile below starts from.
88
- export const EPOCH_FILE: string = process.env.VICE_EPOCH_FILE
89
- ? resolve(process.env.VICE_EPOCH_FILE)
90
- : join(supervisorDir(), "epoch.json");
91
-
92
- export interface ActiveInstance {
93
- port: number;
94
- url: string;
95
- epochFile: string;
96
- pooled: boolean;
97
- }
98
-
99
- // -------------------------------------------------------- active instance
100
- //
101
- // Mutable module-level state, deliberately NOT frozen at module load (D-5):
102
- // restart detection has to stay correct PER INSTANCE, which is impossible if
103
- // the epoch path is fixed at import time. useInstance() below is the only
104
- // writer; every other read goes through the functions in this file so a
105
- // lease redirect takes effect everywhere at once (rpc()'s POST target,
106
- // readEpoch()'s default path, beginSession()'s default path).
107
- let activeUrl: string = DEFAULT_ENDPOINT;
108
- let activeEpochFile: string = EPOCH_FILE;
109
- // Derived from DEFAULT_ENDPOINT rather than hardcoded or left null: with no
110
- // lease ever taken (no pool, or a programmatic caller that never calls
111
- // acquire()/useInstance()), this is still a real port identity -- e.g. for
112
- // tools/recover.mjs's snapshotName(), which namespaces by port
113
- // UNCONDITIONALLY (D-4) and must never produce a "no port" name just because
114
- // nothing redirected the seam. Falls back to 6510 only if the URL has no
115
- // parseable port at all.
116
- //
117
- // PORT TRIAGE (01.6.2-09, D-18): kept, same reasoning as DEFAULT_ENDPOINT
118
- // above -- this fallback describes the same human-launched, reserved-band
119
- // (6510-6599) instance, never a broker-allocated one, so 6510 stays correct
120
- // here too.
121
- let activePort: number = (() => {
122
- try {
123
- const p = Number(new URL(DEFAULT_ENDPOINT).port);
124
- return Number.isInteger(p) && p > 0 ? p : 6510;
125
- } catch {
126
- return 6510;
127
- }
128
- })();
129
- // Not part of the seam redirect itself (rpc()/readEpoch() never consult
130
- // this) -- carried purely as identity metadata so a caller like
131
- // tools/recover.mjs's capture record can note whether a dump came from a
132
- // pooled instance or the unpooled default, without needing its own separate
133
- // channel back to whatever acquired the lease. Extra, optional field on
134
- // useInstance()'s object arg -- a caller passing only {port,url,epochFile}
135
- // (the documented minimum) still works exactly as before, defaulting to
136
- // false.
137
- let activePooled = false;
138
-
139
- export interface UseInstanceOptions {
140
- port: number;
141
- url: string;
142
- epochFile: string;
143
- pooled?: boolean;
144
- }
145
-
146
- /**
147
- * Redirect the transport seam to a specific pooled (or fallback) instance.
148
- * MUST reset the MCP handshake (`initialized = false`): the handshake
149
- * belongs to the endpoint it was performed against, and continuing to use a
150
- * "logged in" flag from a DIFFERENT endpoint would silently talk to the new
151
- * instance without ever having initialized a session there. Warns on stderr
152
- * if called while a session is already open against the previous instance,
153
- * since that is a real behaviour change the caller should notice.
154
- */
155
- export function useInstance({ port, url, epochFile, pooled = false }: UseInstanceOptions): void {
156
- if (initialized) {
157
- console.error(
158
- `warn: useInstance(port ${port}) called while a session was already open against ` +
159
- `${activeUrl} -- resetting the handshake. If this is mid-procedure, make sure that was intended.`
160
- );
161
- }
162
- activeUrl = url;
163
- activeEpochFile = epochFile;
164
- activePort = port;
165
- activePooled = pooled;
166
- initialized = false;
167
- }
168
-
169
- /** Read-only accessor: the instance the seam is currently pointed at. */
170
- export function activeInstance(): ActiveInstance {
171
- return { port: activePort, url: activeUrl, epochFile: activeEpochFile, pooled: activePooled };
172
- }
173
-
174
- // Forbidden tool names. Checked by exact string match before any network
175
- // call is made -- see call() below. Never remove vice_disk_list from this
176
- // list; see the project's own hazard note (CLAUDE.md, STATE.md blockers).
177
- //
178
- // "tools_list" added (01.4-01 task 1, the phase's tracer slice): the host's
179
- // own generic-surface meta-tool, which the manifest lists as an ordinary
180
- // forwardable tool. Reuses this exact same array and the exact same two
181
- // enforcement seams (this guard, and vice-proxy.ts's registration-loop skip
182
- // + CallToolRequestSchema override) -- no new mechanism, per 01.4-RESEARCH.md
183
- // Pattern 1 ("one array, no new mechanism"). See denyListRefusalMessage()
184
- // below for why this entry's hazard shape differs from vice_disk_list's own.
185
- //
186
- // "tools_call", "initialize", "notifications_initialized" added (01.4-01
187
- // task 2): closes the fix in full rather than partially, per
188
- // 01.4-RESEARCH.md's own primary recommendation. A repo-wide grep (.claude/,
189
- // .planning/, and this package's own test file) for any SANCTIONED caller of
190
- // any of these four names AS A TOOL -- i.e. dispatched through tools/call,
191
- // not the unrelated MCP-protocol "initialize" JSON-RPC method vice.ts's own
192
- // ensureInitialized() sends, which is the transport handshake, never a tool
193
- // lookup through this DENY_LIST -- found none. Every recorded hit is either
194
- // this phase's own research/todo documents and incident records (excluded by
195
- // the plan's own instruction), or the pre-existing stand-in-host test
196
- // fixture proving the nested-argument bypass this task closes (repointed
197
- // below to assert closure, not deleted). 01.4-RESEARCH.md's Open Question 1
198
- // and Assumption A2 both predicted this: every recorded use on file is an ad
199
- // hoc fallback probe performed because a named tool went missing, not a
200
- // designed dependency -- confirmed still true by this grep.
201
- export const DENY_LIST: readonly string[] = [
202
- "vice_disk_list",
203
- "tools_list",
204
- "tools_call",
205
- "initialize",
206
- "notifications_initialized",
207
- ];
208
-
209
- /**
210
- * Renders an accurate refusal message for a DENY_LIST entry, keyed by hazard
211
- * shape rather than one wording reused verbatim for every entry (01.4-01
212
- * task 1, T-01.4-02): vice_disk_list crashes the shared host VICE MCP server
213
- * directly -- a CRASH hazard, the project's original hazard note. Every
214
- * other DENY_LIST entry is a generic-surface meta-tool (tools_list,
215
- * tools_call, and -- if 01.4-01 task 2's own grep clears them -- initialize
216
- * and notifications_initialized) whose hazard is a different shape: it is a
217
- * confused-deputy BYPASS, because it can carry a forbidden tool name as a
218
- * NESTED argument, sidestepping this exact outer-name-only guard (see
219
- * .planning/todos/pending/2026-08-05-generic-surface-deny-list-gap-tools-call-nested-vice-disk-list.md).
220
- * It does not itself crash anything. Telling an agent the wrong hazard shape
221
- * for what is otherwise the same permanent refusal invites a pointless
222
- * retry -- so this is one array (DENY_LIST) and one message-rendering
223
- * function, reused at every call site, rather than duplicated refusal text
224
- * per site (currently vice.ts's call() guard below and vice-proxy.ts's
225
- * CallToolRequestSchema override; task 2 adds the retooled bypass test as a
226
- * third consumer of this same function's output shape, not a fourth
227
- * inline copy).
228
- */
229
- export function denyListRefusalMessage(toolName: string): string {
230
- if (toolName === "vice_disk_list") {
231
- return (
232
- `${toolName} is permanently forbidden -- it is known to crash the shared host VICE MCP server ` +
233
- `(see CLAUDE.md's hazard note). Recovery requires a manual, host-side restart. Refusing to ` +
234
- `serialise this request; retrying will not help.`
235
- );
236
- }
237
- return (
238
- `${toolName} is permanently forbidden -- it is a generic-surface meta-tool that can carry a ` +
239
- `forbidden tool name as a nested argument, bypassing this exact outer-name-only guard (see ` +
240
- `.planning/todos/pending/2026-08-05-generic-surface-deny-list-gap-tools-call-nested-vice-disk-list.md). ` +
241
- `It does not itself crash the host. Refusing to serialise this request; retrying will not help.`
242
- );
243
- }
244
-
245
- export interface ViceErrorOptions {
246
- code?: number | string;
247
- data?: unknown;
248
- }
249
-
250
- export class ViceError extends Error {
251
- code?: number | string;
252
- data?: unknown;
253
-
254
- constructor(message: string, { code, data }: ViceErrorOptions = {}) {
255
- super(message);
256
- this.name = "ViceError";
257
- this.code = code;
258
- this.data = data;
259
- }
260
- }
261
-
262
- export interface MachineRestartedErrorOptions {
263
- baselineEpoch?: number | null;
264
- currentEpoch?: number | null;
265
- where?: string;
266
- lastToolCall?: string | null;
267
- }
268
-
269
- /**
270
- * Thrown when a reconnect happened and the emulator's identity across that
271
- * reconnect could not be proven -- either the epoch file shows it changed,
272
- * or nothing (no epoch file, no surviving armed checkpoint) could prove it
273
- * didn't (D-3, D-4). Carries the evidence a caller needs to write a void
274
- * note: the epochs compared, where in the pipeline the check ran, and the
275
- * last tool call attempted before detection (see lastToolCall() below).
276
- */
277
- export class MachineRestartedError extends ViceError {
278
- baselineEpoch?: number | null;
279
- currentEpoch?: number | null;
280
- where?: string;
281
- lastToolCall?: string | null;
282
-
283
- constructor(message: string, { baselineEpoch, currentEpoch, where, lastToolCall }: MachineRestartedErrorOptions = {}) {
284
- super(message);
285
- this.name = "MachineRestartedError";
286
- this.baselineEpoch = baselineEpoch;
287
- this.currentEpoch = currentEpoch;
288
- this.where = where;
289
- this.lastToolCall = lastToolCall;
290
- }
291
- }
292
-
293
- let reqId = 0;
294
-
295
- export interface RpcOptions {
296
- timeoutMs?: number;
297
- }
298
-
299
- interface JsonRpcErrorPayload {
300
- code?: number;
301
- message?: string;
302
- data?: unknown;
303
- }
304
-
305
- interface JsonRpcResponsePayload {
306
- error?: JsonRpcErrorPayload;
307
- result?: unknown;
308
- }
309
-
310
- /** True iff `value` is a well-formed, generic JSON object -- not null, not
311
- * an array. Matches vice-broker.mts's / vice-broker-client.ts's own
312
- * isPlainObject() predicate exactly -- the same narrowing discipline this
313
- * module tree uses everywhere a parsed JSON value's fields are touched. */
314
- function isPlainObject(value: unknown): value is Record<string, unknown> {
315
- return typeof value === "object" && value !== null && !Array.isArray(value);
316
- }
317
-
318
- /**
319
- * Raw JSON-RPC round trip. Wraps every call in a client-side abort timeout --
320
- * vice_run_until's own `cycles` timeout is documented as "not yet
321
- * implemented", so nothing upstream protects us from a hung request; this is
322
- * that protection, at the transport layer, for every call this seam makes.
323
- */
324
- async function rpc(method: string, params: unknown, { timeoutMs = DEFAULT_TIMEOUT_MS }: RpcOptions = {}): Promise<unknown> {
325
- const id = ++reqId;
326
- const body = JSON.stringify({ jsonrpc: "2.0", id, method, params });
327
- let res: Response;
328
- try {
329
- res = await fetch(activeUrl, {
330
- method: "POST",
331
- headers: {
332
- "Content-Type": "application/json",
333
- Accept: "application/json, text/event-stream",
334
- },
335
- body,
336
- signal: AbortSignal.timeout(timeoutMs),
337
- });
338
- } catch (e) {
339
- const err = e as Error;
340
- if (err.name === "TimeoutError" || err.name === "AbortError") {
341
- throw new ViceError(
342
- `${method} timed out after ${timeoutMs}ms -- the host VICE MCP server may be hung or unreachable. ` +
343
- `Recovery is a HOST-SIDE restart, which this container cannot perform. Run ` +
344
- `tools/vice-launcher.sh on the HOST -- its broker launches a boot-fresh instance on demand, ` +
345
- `supervises it, and respawns a crashed one with backoff, logging the crash ` +
346
- `for the still-open root-cause investigation (see .planning/STATE.md).`
347
- );
348
- }
349
- throw new ViceError(`transport error calling ${method}: ${err.message}`);
350
- }
351
- const contentType = res.headers.get("content-type") || "";
352
- const text = await res.text();
353
- let payload: JsonRpcResponsePayload;
354
- if (contentType.includes("text/event-stream")) {
355
- // SSE-framed body: parse `data:` lines, take the last JSON payload.
356
- const dataLines = text
357
- .split("\n")
358
- .filter((l) => l.startsWith("data:"))
359
- .map((l) => l.slice(5).trim())
360
- .filter(Boolean);
361
- if (!dataLines.length) {
362
- throw new ViceError(`no data: lines in SSE response for ${method}`);
363
- }
364
- const parsed: unknown = JSON.parse(dataLines[dataLines.length - 1]);
365
- payload = isPlainObject(parsed) ? (parsed as JsonRpcResponsePayload) : {};
366
- } else {
367
- try {
368
- const parsed: unknown = JSON.parse(text);
369
- payload = isPlainObject(parsed) ? (parsed as JsonRpcResponsePayload) : {};
370
- } catch {
371
- throw new ViceError(`non-JSON response for ${method}: ${text.slice(0, 200)}`);
372
- }
373
- }
374
- if (payload.error) {
375
- throw new ViceError(payload.error.message || `RPC error calling ${method}`, {
376
- code: payload.error.code,
377
- data: payload.error.data,
378
- });
379
- }
380
- return payload.result;
381
- }
382
-
383
- let initialized = false;
384
- async function ensureInitialized(): Promise<void> {
385
- if (initialized) return;
386
- await rpc("initialize", {
387
- protocolVersion: "2024-11-05",
388
- capabilities: {},
389
- clientInfo: { name: "vice-recover", version: "1.0" },
390
- });
391
- initialized = true;
392
- }
393
-
394
- // The host server has been observed to drop connections and recover on its own,
395
- // but the outage outlasts a short backoff -- a 6s total budget was measured as
396
- // too short. These values give it ~50s to come back before we declare it dead
397
- // and point the operator at tools/vice-launcher.sh (host-only; this
398
- // container cannot restart it itself) -- its on-demand broker launches a
399
- // fresh instance and respawns a crashed one with backoff automatically
400
- // (01.6.2-09: the retiring per-instance supervisor, vice-supervisor.sh, is
401
- // superseded by this same launcher/broker).
402
- //
403
- // IMPORTANT: under that broker, this retry can now SUCCEED -- against a
404
- // brand-new, blank machine with no disk attached and no checkpoints armed,
405
- // not the one this session started with. That is exactly why
406
- // beginSession()/assertSameMachine() exist below: a retry that starts
407
- // working again is no longer proof that nothing happened. Do not remove the
408
- // identity check while this broker (or any future host-side recovery)
409
- // exists, and do not remove it without also removing the retry -- they are
410
- // one mitigation in two halves, not two independent features.
411
- const RECONNECT_ATTEMPTS = 5;
412
- const RECONNECT_BACKOFF_MS = [2000, 5000, 12000, 30000, 0];
413
- const nap = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
414
-
415
- /**
416
- * A single dropped connection used to be fatal: the error propagated straight
417
- * out and, worse, `initialized` stayed true, so every later call spoke into a
418
- * dead session. The host server has been observed both to drop a connection
419
- * mid-request and to come back moments later, so transport failures are
420
- * retried with backoff and the session handshake is redone.
421
- *
422
- * Only TRANSPORT failures are retried. An RPC-level error (the server answered
423
- * and said no) is a real answer and is never retried -- retrying a rejected
424
- * tool call would just repeat a mistake, and for a tool with side effects could
425
- * repeat it destructively.
426
- *
427
- * Under host-side recovery (tools/vice-launcher.sh's on-demand broker), this
428
- * retry can now SUCCEED -- against a brand-new, blank machine with no disk
429
- * attached, no checkpoints armed and the CPU halted at the BASIC prompt. A
430
- * success here is therefore no longer proof that nothing happened; it is
431
- * exactly the signal the session-identity section below
432
- * (readEpoch/assertSameMachine) exists to catch. Do not remove one half of
433
- * this pairing without the other.
434
- */
435
- async function withReconnect(toolName: string, args: Record<string, unknown>, opts: RpcOptions): Promise<unknown> {
436
- lastCallSummary = summarizeCall(toolName, args);
437
- let lastErr: Error | undefined;
438
- for (let attempt = 0; attempt < RECONNECT_ATTEMPTS; attempt++) {
439
- try {
440
- await ensureInitialized();
441
- return await rpc("tools/call", { name: toolName, arguments: args }, opts);
442
- } catch (e) {
443
- const err = e as Error;
444
- const transport = /transport error|timed out|no data: lines|non-JSON response/i.test(err.message);
445
- if (!transport) throw err;
446
- lastErr = err;
447
- initialized = false; // force a fresh handshake -- the old session is gone
448
- // Session-identity signal (D-3): this is the ONLY place that knows a
449
- // reconnect was forced. Bump the counter every attempt, not just on
450
- // eventual success -- call()'s cheap epoch check and
451
- // assertSameMachine()'s checkpoint-fallback probe both key off this.
452
- reconnectCount++;
453
- if (attempt < RECONNECT_ATTEMPTS - 1) {
454
- console.error(
455
- `warn: ${toolName} transport failure (attempt ${attempt + 1}/${RECONNECT_ATTEMPTS}), ` +
456
- `reconnecting in ${RECONNECT_BACKOFF_MS[attempt]}ms: ${err.message}`
457
- );
458
- await nap(RECONNECT_BACKOFF_MS[attempt]);
459
- }
460
- }
461
- }
462
- throw new ViceError(
463
- `${toolName} failed after ${RECONNECT_ATTEMPTS} transport attempts against ${activeUrl} ` +
464
- `(port ${activePort}): ${lastErr?.message} -- recovery is a HOST-SIDE restart, which this ` +
465
- `container cannot perform. Run tools/vice-launcher.sh on the HOST (see its header comment) -- ` +
466
- `its broker launches a boot-fresh instance on demand, supervises it, and respawns a crashed one ` +
467
- `with backoff, logging the crash for the still-open root-cause investigation.`
468
- );
469
- }
470
-
471
- // ------------------------------------------------------- session identity
472
- //
473
- // WHY this lives in the transport seam, not in tools/recover.mjs: this file
474
- // is the only place that knows a reconnect happened at all. Once
475
- // tools/vice-launcher.sh's on-demand broker is respawning x64sc on the host,
476
- // withReconnect()'s retry-with-backoff above starts SUCCEEDING again -- but
477
- // potentially against a completely different, freshly-booted machine.
478
- // Turning that "quiet success" back into a loud, checkable signal is the
479
- // whole point of this section (D-3, D-4).
480
- //
481
- // Module-level state, deliberately NOT per-call-argument: there is one
482
- // active recovery session per process (recover.mjs's CLI runs one verb at a
483
- // time), so beginSession()/assertSameMachine() read and reset this state
484
- // directly rather than threading it through every call() site.
485
- export interface SessionInfo {
486
- baseline: EpochResult;
487
- epochPath: string;
488
- startedAt: string;
489
- }
490
-
491
- let currentSession: SessionInfo | null = null; // set by beginSession(): { baseline, epochPath, startedAt }
492
- let reconnectCount = 0; // reset by beginSession(); incremented by withReconnect(); "consumed" (reset) by assertSameMachine()
493
- let lastCallSummary: string | null = null; // last tool call attempted, for D-4 evidence in a void note
494
-
495
- /** `${toolName} ${args}`, truncated to ~120 chars -- D-4 wants the last call before a
496
- * detected restart, not a full transcript. */
497
- function summarizeCall(toolName: string, args: unknown): string {
498
- let argsStr: string;
499
- try {
500
- argsStr = JSON.stringify(args);
501
- } catch {
502
- argsStr = String(args);
503
- }
504
- const full = `${toolName} ${argsStr}`;
505
- return full.length > 120 ? `${full.slice(0, 117)}...` : full;
506
- }
507
-
508
- export interface EpochResult {
509
- present: boolean;
510
- epoch: number | null;
511
- spawned_at: string | null;
512
- pid: number | null;
513
- path: string;
514
- reason?: string;
515
- }
516
-
517
- /**
518
- * Read the supervisor's epoch file. Synchronous -- this is a plain, cheap
519
- * file read; the whole point of the epoch check is that it costs zero MCP
520
- * traffic, unlike the checkpoint-fallback probe. NEVER throws: absence is
521
- * normal (no supervisor running at all) and must not be an error (D-3) --
522
- * the harness has to keep working exactly as it does today with no
523
- * supervisor.
524
- *
525
- * Treats the file's contents as untrusted, host-written input (T-jty-01):
526
- * JSON.parse in try/catch, `epoch` must decode to a finite integer, unknown
527
- * fields are ignored, and no path derived from the file's contents is ever
528
- * opened.
529
- */
530
- export function readEpoch(path: string = activeEpochFile): EpochResult {
531
- const absent: EpochResult = { present: false, epoch: null, spawned_at: null, pid: null, path };
532
- let raw: string;
533
- try {
534
- raw = readFileSync(path, "utf8");
535
- } catch {
536
- return { ...absent, reason: "epoch file absent" };
537
- }
538
- let parsed: unknown;
539
- try {
540
- parsed = JSON.parse(raw);
541
- } catch {
542
- return { ...absent, reason: "epoch file present but not valid JSON" };
543
- }
544
- if (!isPlainObject(parsed) || !Number.isInteger(parsed.epoch)) {
545
- return { ...absent, reason: 'epoch file present but its "epoch" field is not a finite integer' };
546
- }
547
- return {
548
- present: true,
549
- epoch: parsed.epoch as number,
550
- spawned_at: typeof parsed.spawned_at === "string" ? parsed.spawned_at : null,
551
- pid: typeof parsed.pid === "number" && Number.isFinite(parsed.pid) ? parsed.pid : null,
552
- path,
553
- };
554
- }
555
-
556
- export interface BeginSessionOptions {
557
- epochPath?: string;
558
- }
559
-
560
- /**
561
- * Start a new identity-tracking session: capture the current epoch as the
562
- * baseline every later check compares against, and zero the reconnect
563
- * counter so a PRIOR session's reconnects (e.g. from a previous `recover()`
564
- * run inside the same `reproduce()` process) don't leak into this one.
565
- */
566
- export function beginSession({ epochPath = activeEpochFile }: BeginSessionOptions = {}): SessionInfo {
567
- const baseline = readEpoch(epochPath);
568
- reconnectCount = 0;
569
- currentSession = { baseline, epochPath, startedAt: new Date().toISOString() };
570
- return currentSession;
571
- }
572
-
573
- /** Read-only accessor: how many transport-forced reconnects since the last
574
- * beginSession() (or the last assertSameMachine() consumption -- see there). */
575
- export function sessionReconnects(): number {
576
- return reconnectCount;
577
- }
578
-
579
- /** Read-only accessor: the last tool call attempted (name + truncated args),
580
- * for D-4 evidence -- populated even for calls that ultimately failed. */
581
- export function lastToolCall(): string | null {
582
- return lastCallSummary;
583
- }
584
-
585
- export type CallFn = (toolName: string, args?: Record<string, unknown>, opts?: RpcOptions) => Promise<unknown>;
586
-
587
- export interface AssertSameMachineOptions {
588
- where: string;
589
- armedCheckpoints?: number[];
590
- reconnected?: boolean;
591
- call?: CallFn;
592
- }
593
-
594
- /**
595
- * Prove (or fail to prove) that the machine behind `session` is still the
596
- * one that was running at `beginSession()` time. See the plan's `<behavior>`
597
- * block for the six rules this implements; in short:
598
- *
599
- * - If the epoch file proves a change (baseline and current both present,
600
- * different values) -> MachineRestartedError, always, reconnect or not.
601
- * - If the epoch file proves NO change (both present, same value) -> pass,
602
- * no further (MCP) check needed.
603
- * - Otherwise (no epoch evidence either way) and no reconnect happened ->
604
- * pass, and no MCP call is made at all -- the whole point of gating the
605
- * checkpoint probe behind `reconnected`.
606
- * - Otherwise (no epoch evidence, but a reconnect DID happen): fall back to
607
- * asking whether a checkpoint the harness itself already armed (never a
608
- * new sentinel -- checkpoint work is itself a crash suspect) is still
609
- * listed. Present -> pass. Absent, or nothing to probe with -> void.
610
- *
611
- * `reconnected` defaults to whether ANY transport-forced reconnect has
612
- * happened since the last beginSession()/assertSameMachine() call --
613
- * calling this function CONSUMES that count (resets it to 0) so a later,
614
- * unrelated assertSameMachine() call (e.g. after this stage's own armed
615
- * checkpoint has since been deleted) doesn't re-trigger a probe against
616
- * checkpoints that are supposed to be gone by then.
617
- */
618
- export async function assertSameMachine(
619
- session: SessionInfo,
620
- {
621
- where,
622
- armedCheckpoints = [],
623
- reconnected = sessionReconnects() > 0,
624
- call: callFn = call,
625
- }: AssertSameMachineOptions
626
- ): Promise<void> {
627
- // Consume the module-level reconnect signal now -- see the doc comment
628
- // above for why this matters for later, unrelated checks in the same
629
- // session.
630
- reconnectCount = 0;
631
-
632
- const currentEpoch = readEpoch(session.epochPath);
633
-
634
- if (session.baseline.present && currentEpoch.present) {
635
- if (currentEpoch.epoch !== session.baseline.epoch) {
636
- throw new MachineRestartedError(
637
- `${where}: the emulator restarted -- epoch changed from ${session.baseline.epoch} to ` +
638
- `${currentEpoch.epoch}. This run is void; re-run it.`,
639
- { baselineEpoch: session.baseline.epoch, currentEpoch: currentEpoch.epoch, where, lastToolCall: lastCallSummary }
640
- );
641
- }
642
- return; // epoch proves this is still the same machine -- no MCP call needed
643
- }
644
-
645
- if (!reconnected) {
646
- return; // nothing to check, and nothing checked -- no MCP call made
647
- }
648
-
649
- // A reconnect happened and the epoch file could not confirm sameness
650
- // (either no broker is running at all, or its epoch file just isn't
651
- // there to compare against). The checkpoint-fallback probe is the only
652
- // identity signal left -- and it deliberately reuses checkpoints the
653
- // harness already armed for its own reasons; arming a new sentinel
654
- // checkpoint was rejected because checkpoint work is itself one of the two
655
- // leading crash suspects (see STATE.md's HAZARD CANDIDATE entry).
656
- if (armedCheckpoints.length === 0) {
657
- throw new MachineRestartedError(
658
- `${where}: a reconnect happened and identity could not be proven -- no epoch file to compare ` +
659
- `and no armed checkpoint to probe. Unproven is not the same as fine; re-run the capture. If ` +
660
- `this recurs, run tools/vice-launcher.sh on the HOST so future runs have an epoch file to check.`,
661
- { baselineEpoch: session.baseline.epoch, currentEpoch: currentEpoch.epoch, where, lastToolCall: lastCallSummary }
662
- );
663
- }
664
-
665
- let listed: unknown;
666
- try {
667
- listed = await callFn("vice_checkpoint_list", {});
668
- } catch (e) {
669
- const err = e as Error;
670
- throw new MachineRestartedError(
671
- `${where}: a reconnect happened and the checkpoint-fallback probe itself failed (${err.message}) -- ` +
672
- `identity could not be proven. Re-run the capture.`,
673
- { baselineEpoch: session.baseline.epoch, currentEpoch: currentEpoch.epoch, where, lastToolCall: lastCallSummary }
674
- );
675
- }
676
- const checkpoints = isPlainObject(listed) && Array.isArray(listed.checkpoints) ? listed.checkpoints : [];
677
- const liveIds = new Set((checkpoints as Array<Record<string, unknown>>).map((c) => c.checkpoint_num as number));
678
- const stillPresent = armedCheckpoints.some((id) => liveIds.has(id));
679
- if (!stillPresent) {
680
- throw new MachineRestartedError(
681
- `${where}: a reconnect happened and none of the harness's own armed checkpoints ` +
682
- `(${armedCheckpoints.join(", ")}) are listed by vice_checkpoint_list -- the emulator restarted. ` +
683
- `This run is void; re-run it.`,
684
- { baselineEpoch: session.baseline.epoch, currentEpoch: currentEpoch.epoch, where, lastToolCall: lastCallSummary }
685
- );
686
- }
687
- // The armed checkpoint survived the reconnect -- demonstrably the same machine.
688
- }
689
-
690
- /**
691
- * Call a vice_* tool by name and return its parsed JSON result.
692
- *
693
- * Refuses any tool on DENY_LIST before any network request is made -- this
694
- * check is the first line of the function body, deliberately, so the deny
695
- * list is enforced even if a future edit reorders the rest of the function.
696
- */
697
- export async function call(toolName: string, args: Record<string, unknown> = {}, opts: RpcOptions = {}): Promise<unknown> {
698
- if (DENY_LIST.includes(toolName)) {
699
- throw new ViceError(denyListRefusalMessage(toolName));
700
- }
701
- const reconnectsBefore = reconnectCount;
702
- const result = await withReconnect(toolName, args, opts);
703
- // Session-identity fast path (D-3, D-4): if THIS call needed a reconnect,
704
- // do the cheap epoch check (a synchronous file read, zero extra MCP
705
- // traffic) right here -- the earliest and loudest possible detection
706
- // point. A changed epoch throws immediately. We deliberately do NOT run
707
- // the checkpoint-fallback probe here: that would be a re-entrant call(),
708
- // and reconnectCount staying > 0 is exactly the module flag
709
- // assertSameMachine() consumes at its next checkpoint instead.
710
- if (reconnectCount > reconnectsBefore && currentSession) {
711
- const nowEpoch = readEpoch(currentSession.epochPath);
712
- if (currentSession.baseline.present && nowEpoch.present && nowEpoch.epoch !== currentSession.baseline.epoch) {
713
- throw new MachineRestartedError(
714
- `${toolName}: the emulator restarted mid-call -- epoch changed from ` +
715
- `${currentSession.baseline.epoch} to ${nowEpoch.epoch} after a reconnect. This run is void; re-run it.`,
716
- {
717
- baselineEpoch: currentSession.baseline.epoch,
718
- currentEpoch: nowEpoch.epoch,
719
- where: `call(${toolName})`,
720
- lastToolCall: lastCallSummary,
721
- }
722
- );
723
- }
724
- }
725
- const content = (result as { content?: Array<{ type?: string; text?: string }> } | undefined)?.content?.[0];
726
- if (!content || content.type !== "text") {
727
- throw new ViceError(`unexpected tool result shape from ${toolName}: ${JSON.stringify(result)}`);
728
- }
729
- try {
730
- return JSON.parse(content.text ?? "");
731
- } catch {
732
- return content.text; // a few tools may return plain text; hand it back verbatim
733
- }
734
- }
735
-
736
- // Alias -- some call sites read more naturally as callTool(...).
737
- export const callTool: typeof call = call;
738
-
739
- export interface ToolInfo {
740
- name: string;
741
- description?: string;
742
- inputSchema?: unknown;
743
- [key: string]: unknown;
744
- }
745
-
746
- export interface ServerInfoPayload {
747
- tools?: ToolInfo[];
748
- [key: string]: unknown;
749
- }
750
-
751
- /**
752
- * The server's tools/list result (name, description, inputSchema per tool),
753
- * with every DENY_LIST tool STRIPPED OUT.
754
- *
755
- * Filtering here rather than at each render site is deliberate: this is the
756
- * single choke point every consumer goes through -- the `tools` CLI verb, its
757
- * `--json` dump, and recover.mjs -- so a forbidden tool is not merely marked
758
- * as forbidden, it never appears at all. An agent cannot be tempted by a tool
759
- * it never learns exists, and a discovery listing that shows a tool the seam
760
- * would refuse anyway is just an invitation to try it.
761
- *
762
- * This does NOT replace the DENY_LIST guard in call(). Discovery filtering
763
- * and call-time refusal are independent layers: one hides the tool, the other
764
- * refuses it even when the name was obtained some other way.
765
- */
766
- export async function serverInfo(): Promise<unknown> {
767
- await ensureInitialized();
768
- const payload = await rpc("tools/list", {});
769
- if (!isPlainObject(payload) || !Array.isArray(payload.tools)) return payload;
770
- const tools = payload.tools as ToolInfo[];
771
- return { ...payload, tools: tools.filter((t) => !DENY_LIST.includes(t?.name)) };
772
- }