@henols/vice-mcp 0.1.4

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/vice-proxy.ts ADDED
@@ -0,0 +1,3093 @@
1
+ #!/usr/bin/env node
2
+ // A stdio MCP server that forwards to the host VICE MCP server over HTTP.
3
+ // Claude Code spawns exactly one copy of this per session (per its own
4
+ // `.mcp.json` `vice` entry) and speaks newline-delimited JSON-RPC 2.0 to it
5
+ // over stdin/stdout. This file owns ONLY that stdio-server-facing half --
6
+ // the HTTP-client half (retry ladder, SSE-body parsing, the vice_disk_list
7
+ // deny-list, epoch-based restart detection) is `call()` and its siblings,
8
+ // imported unchanged from the transport module, now a sibling in this
9
+ // skill's own `scripts/` directory (plan 01.1-04 relocated it from
10
+ // `vice-session`). Re-implementing that half here would duplicate code that
11
+ // has already survived six real host outages; see 01.1-RESEARCH.md's
12
+ // "Don't Hand-Roll" table.
13
+ //
14
+ // Sibling import, no longer cross-skill: `vice-session` has been retired
15
+ // (plan 01.1-04) and its transport module tree lives here now.
16
+ //
17
+ // D-01 / ROLLBACK PATH (Phase 01.6.3, plans 01.6.3-01..04): this file's
18
+ // stdio-server-facing wire layer is `@mastra/mcp`'s `MCPServer` +
19
+ // `startStdio()`, adopted per a developer decision AGAINST 01.6-RESEARCH.md
20
+ // §B6's HIGH-confidence recommendation to stay fully hand-rolled. The
21
+ // ROADMAP rates this adoption "reversible but costly -- not one-way": the
22
+ // ~88-94% of hand-rolled logic below (broker leasing, epoch/liveness,
23
+ // recycle/diagnose, deny-list enforcement, path rewriting, incident
24
+ // capture, the ten broker-state message builders -- enumerated exhaustively
25
+ // in 01.6-PATTERNS.md's "Pattern: The D-01 Seam") never moved and is not
26
+ // part of what a rollback touches.
27
+ //
28
+ // (a) What @mastra/mcp now owns, deleted from this file by the swap:
29
+ // writeMessage() / respond() / errorResponse() / handleInitialize() /
30
+ // handleToolsList() / handleMessage() / handleLine() / the stdin
31
+ // read loop / ProtocolError -- the entire hand-rolled JSON-RPC 2.0
32
+ // framing and protocol-version-negotiation layer (~140-300 lines,
33
+ // 6-12% of the pre-swap file, per RESEARCH.md §B4's structural
34
+ // measurement). Superseded by `new MCPServer({name, version, tools})`
35
+ // + `await server.startStdio()`, plus a `CallToolRequestSchema`
36
+ // override installed via `server.getServer().setRequestHandler(...)`
37
+ // immediately after `startStdio()` resolves (tools/call is NOT
38
+ // answered by MCPServer's own dispatch -- see COVERAGE.md's
39
+ // `tools/call routing skeleton` row for why: MCPServer's dispatch
40
+ // forces `isError:false` on success and prepends "Error: " on
41
+ // failure, neither of which matches this proxy's `{content,isError}`
42
+ // contract or the deny-list's pinned refusal text).
43
+ // (b) Rollback steps, concretely, for a future session that needs to
44
+ // execute this rather than re-derive it:
45
+ // 1. Re-author writeMessage()/respond()/errorResponse()/
46
+ // handleInitialize()/handleToolsList()/handleMessage()/
47
+ // handleLine()/the stdin loop/ProtocolError from
48
+ // 01.6-PATTERNS.md's "Pattern: The D-01 Seam" section, which
49
+ // quotes their pre-swap bodies verbatim, cross-checked against
50
+ // this file's own git history at commits a27628b (the swap that
51
+ // deleted them) and its parent (the last commit where they still
52
+ // existed).
53
+ // 2. Re-point every tool's dispatch: each tool's `execute` body
54
+ // (`forwardToVice(name, args)`, UNCHANGED by the rollback -- it
55
+ // predates and outlives the swap) currently runs inside the
56
+ // `CallToolRequestSchema` override's per-tool lookup; re-wire that
57
+ // same lookup into a single hand-rolled `handleToolsCall()`
58
+ // dispatcher called from the resurrected `handleMessage()`.
59
+ // 3. Remove `@mastra/mcp`/`@mastra/core` from package.json's
60
+ // `dependencies` (added Phase 01.6.3 plan 01) and revert
61
+ // tsconfig.json's `skipLibCheck: true` (added plan 01.6.3-02 --
62
+ // see note below; safe to revert once nothing imports either
63
+ // package, since this project's own files typecheck clean with
64
+ // or without the flag).
65
+ // 4. Re-run the full vice-proxy.test.ts suite; the ~5,300-line suite
66
+ // exercises the wire layer directly (initialize/tools-list/
67
+ // tools-call shapes, the deny-list, malformed-input handling) so
68
+ // a clean rollback shows as 0 new failures against this same
69
+ // suite, not merely "it builds".
70
+ // (c) Recorded, permanent cost of D-01 that a rollback would UNDO:
71
+ // tsconfig.json's `skipLibCheck: true` (plan 01.6.3-02) is a genuine
72
+ // reduction in this directory's own type-checking strictness --
73
+ // @mastra/core@1.55.0 bundles internal ai-sdk-provider/zod-v4
74
+ // declaration files with real cross-version inconsistencies, visible
75
+ // to `tsc` only once anything imports from the package. This
76
+ // project's own source typechecks clean with or without the flag,
77
+ // but the flag means a future third-party dependency's OWN bundled
78
+ // `.d.ts` errors would no longer surface here either -- a protection
79
+ // every other file in this repo had by default before this phase.
80
+ // A rollback restores that protection as a side effect of removing
81
+ // the only import that ever needed the flag.
82
+ import {
83
+ call,
84
+ activeInstance,
85
+ useInstance,
86
+ DENY_LIST,
87
+ denyListRefusalMessage,
88
+ readEpoch,
89
+ beginSession,
90
+ MachineRestartedError,
91
+ mcpHost,
92
+ type ActiveInstance,
93
+ type EpochResult,
94
+ type SessionInfo,
95
+ type ToolInfo,
96
+ } from "./vice.ts";
97
+ // Sibling import, same relocation as above. probeInstance() is the
98
+ // deliberately-fragile liveness check (see that file's own header): one
99
+ // 1500ms-budget round trip, no retry, no dependency on vice.ts's resilient
100
+ // reconnect ladder.
101
+ import { probeInstance, type ProbeResult } from "./vice-probe.ts";
102
+ import { repoRoot } from "./repo-root.ts";
103
+ import { hostPath, SET_ENV_HINT } from "./hostpath.ts";
104
+ // The INVERSE direction (host -> container), for inverting a broker grant's
105
+ // own host-local coordinates before useInstance() ever adopts them (this
106
+ // task, quick-260801-ccn). Consuming this from the proxy -- rather than
107
+ // hand-translating a host path here -- is what keeps the host-path consumer
108
+ // set closed to a fixed, traced list (vice-mcp-selector-docs.test.mjs's
109
+ // assertion 4, amended by this task to include containerpath.ts itself as
110
+ // a fifth, sibling consumer of hostpath.mjs's own knowledge).
111
+ import { containerizeRecord } from "./containerpath.ts";
112
+ // The container-side half of the on-demand broker protocol (Phase 01.2).
113
+ // This module deliberately does NOT import hostpath.mjs itself -- the
114
+ // host-path consumer set stays closed to four production modules
115
+ // (vice-mcp-selector-docs.test.mjs's assertion 4), and this file is already
116
+ // on that list, so any broker-related host path text is built HERE.
117
+ // Tasks 1+2 (this plan) swap acquisition, release AND recycle onto the TCP
118
+ // control session (openBrokerControl()/BrokerControlSession, plan 06's
119
+ // completed client) -- writeRequest/createLease/touchLease/releaseLease/
120
+ // pollGrant/startHeartbeat/requestsDir/newRequestId/writeRecycleRequest/
121
+ // pollRecycleAck are no longer imported: their whole job (write a request,
122
+ // create a lease file, heartbeat its mtime, poll for a grant or an
123
+ // acknowledgement, unlink on release) is now "send one request over the
124
+ // connection already held". RECYCLE_TIMEOUT_MS (the client's own recycle
125
+ // deadline, task 3's renamed successor to the now-deleted
126
+ // RECYCLE_ACK_TIMEOUT_MS) is reused below as the bound the post-kill
127
+ // epoch-and-readiness poll uses -- a concern this swap does not touch.
128
+ import {
129
+ readBrokerLiveness,
130
+ brokerRootDir,
131
+ RECYCLE_TIMEOUT_MS,
132
+ openBrokerControl,
133
+ type BrokerLivenessResult,
134
+ type BrokerControlSession,
135
+ type ControlFailureKind,
136
+ } from "./vice-broker-client.ts";
137
+ // The recycle path's own incident record (plan 01.3-01) -- written BEFORE
138
+ // anything is killed (D-17), never through any network call of its own.
139
+ // incidentAssetPath()/incidentAssetStem() (plan 01.3-03) are the SAME stem-
140
+ // building logic incidentRecordPath() itself uses -- imported here so the
141
+ // evidence gatherer's screenshot and the pre-kill snapshot's name can never
142
+ // drift onto a second, independent naming rule.
143
+ import {
144
+ writeIncidentRecord,
145
+ finaliseIncidentRecord,
146
+ incidentAssetPath,
147
+ incidentAssetStem,
148
+ type IncidentEvidence,
149
+ type IncidentAssetStemOptions,
150
+ } from "./incident-record.ts";
151
+ import { readFileSync } from "node:fs";
152
+ import { fileURLToPath } from "node:url";
153
+ import { dirname, join, relative, resolve } from "node:path";
154
+ // The wire-layer replacement (this plan, D-01): MCPServer owns tools/list's
155
+ // schema-conversion dispatch; the CallToolRequestSchema override installed
156
+ // below (immediately after startStdio(), see that call site's own comment)
157
+ // owns tools/call instead, so this file's own hand-rolled envelope survives
158
+ // unchanged even though the transport underneath it is now the SDK's own
159
+ // StdioServerTransport/Protocol. createTool()/noopObserve are the documented,
160
+ // public @mastra/core/tools API -- see this plan's "Ground truth" section for
161
+ // why a raw JSON Schema needs the rawJsonSchemaAsStandardSchema() adapter
162
+ // below rather than being passed to createTool() directly.
163
+ import { MCPServer } from "@mastra/mcp";
164
+ import { createTool, noopObserve } from "@mastra/core/tools";
165
+ import type { StandardSchemaWithJSON } from "@mastra/core/schema";
166
+ // A real, already-resolved transitive dependency of @mastra/mcp (Plan 01's
167
+ // Task 2 note) -- deliberately NOT added to package.json directly.
168
+ import { CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
169
+
170
+ const HERE_DIR = dirname(fileURLToPath(import.meta.url));
171
+
172
+ // -------------------------------------------------------------- JSON-RPC
173
+ //
174
+ // The boundary types every handler below reads or produces. `params` and
175
+ // `result` are typed `unknown` at this boundary deliberately -- MCP methods
176
+ // each carry their own shape, narrowed at the point each handler actually
177
+ // reads a field (never cast straight to an interface without a runtime
178
+ // check first, matching vice-broker.mts's own isPlainObject() discipline).
179
+ /** A single MCP tool descriptor, as this file's own three synthetic tools
180
+ * and every manifest-sourced tool share the shape (name/description/
181
+ * inputSchema, plus whatever `_meta` handleToolsList() stamps on afterward).
182
+ * Deliberately the same shape as vice.ts's own `ToolInfo` (imported above for
183
+ * `readManifestTools()`'s return), so a manifest tool and a synthetic tool
184
+ * are interchangeable wherever this file combines them. */
185
+ type ToolDefinition = ToolInfo;
186
+
187
+ /** Narrows an `unknown` value to a plain, non-array, non-null object --
188
+ * copied verbatim in shape from vice-broker.mts's own isPlainObject(), the
189
+ * one narrowing idiom this whole conversion phase uses at every JSON
190
+ * boundary rather than casting. */
191
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
192
+ return typeof value === "object" && value !== null && !Array.isArray(value);
193
+ }
194
+
195
+ // -------------------------------------------------------------- never-throw
196
+ //
197
+ // Per RESEARCH.md Pitfall 3: a stdio MCP server is NEVER auto-reconnected by
198
+ // Claude Code once it dies (finding 7), so any uncaught throw here strands
199
+ // the session's emulator access for the rest of the session, silently. This
200
+ // is registered FIRST, before anything else in the module runs, so it is in
201
+ // effect for every line below it -- including the ES-module import above,
202
+ // which already executed by the time this file's own body starts, but every
203
+ // subsequent async operation this file performs is covered.
204
+ //
205
+ // There are TWO correct exit paths, not one (spike-findings-bruce-lee
206
+ // skill, shutdown-and-lease-release.md): a graceful client shutdown
207
+ // delivers SIGINT first (then SIGTERM ~100ms later, then SIGKILL at
208
+ // ~490ms) and never closes stdin; an abrupt client death closes stdin
209
+ // (`end` then `close`) and never signals. Both are handled separately
210
+ // below, by the teardown handler near the bottom of this file. Never
211
+ // `process.exit()` from any handler here or there -- see that handler's own
212
+ // comment for why nothing needs it.
213
+ process.on("uncaughtException", (err) => {
214
+ console.error(`vice-proxy: uncaughtException (ignored, staying alive): ${err && err.stack ? err.stack : err}`);
215
+ });
216
+ process.on("unhandledRejection", (reason) => {
217
+ const stack = reason && (reason as Error).stack ? (reason as Error).stack : reason;
218
+ console.error(`vice-proxy: unhandledRejection (ignored, staying alive): ${stack}`);
219
+ });
220
+ // An EPIPE on `stdout.write()` (Claude Code closing the pipe abruptly) throws
221
+ // SYNCHRONOUSLY with no listener attached -- this is the exact class of the
222
+ // filed, confirmed defect in the official MCP TypeScript SDK,
223
+ // modelcontextprotocol/typescript-sdk#1564. Attaching a listener here turns
224
+ // that into a benign, logged event instead of a crash.
225
+ process.stdout.on("error", (err) => {
226
+ console.error(`vice-proxy: stdout write error (ignored): ${err && err.message ? err.message : err}`);
227
+ });
228
+
229
+ // ------------------------------------------------------------- @mastra/mcp
230
+ //
231
+ // D-01 (this plan): the entire hand-rolled wire layer that used to live here
232
+ // (writeMessage/respond/errorResponse/ProtocolError/handleInitialize/
233
+ // handleToolsList/handleMessage/handleLine/the stdin loop) is gone, replaced
234
+ // by `@mastra/mcp`'s `MCPServer` + `startStdio()` -- see the construction
235
+ // site near the bottom of this file (right after the teardown region) for
236
+ // the tool registry, the MCPServer instance, and the CallToolRequestSchema
237
+ // override that preserves this file's own `{content, isError}` wire
238
+ // contract exactly (see this plan's "Ground truth" section for why
239
+ // MCPServer's OWN tools/call dispatch cannot be used as-is). PROXY_VERSION
240
+ // survives unchanged, reused as MCPServer's own `version` field.
241
+ const PROXY_VERSION = "0.1.0";
242
+
243
+ // --------------------------------------------------------------- tools/list
244
+ //
245
+ // A pure, offline read of the committed schema snapshot (decision D-C).
246
+ // `refresh-manifest.mjs` is the ONLY writer of that file -- this handler
247
+ // never fetches, never awaits a network call, and never throws. Any problem
248
+ // with the snapshot (absent, unparseable, wrong shape) degrades to a
249
+ // well-formed empty `tools` array plus one stderr line naming the path and
250
+ // the reason, never a fetch and never a hang.
251
+ //
252
+ // The output-size ceiling this proxy enforces (task 3's continuation logic)
253
+ // is declared here too, on every tool entry via `_meta`, so the ceiling a
254
+ // caller is TOLD about and the ceiling actually enforced are the same single
255
+ // number -- see OUTPUT_CHAR_CAP below, the one definition both sites read.
256
+ const OUTPUT_CHAR_CAP: number = (() => {
257
+ const n = Number(process.env.VICE_MAX_RESULT_CHARS);
258
+ return Number.isFinite(n) && n > 0 ? n : 500000;
259
+ })();
260
+
261
+ // -------------------------------------------------- output-limit warning
262
+ //
263
+ // D-1.2-H (plan 01.2-03 task 2). MAX_MCP_OUTPUT_TOKENS genuinely governs
264
+ // the CLIENT's own inline-response ceiling (measured at 40-60KB --
265
+ // spike-findings-bruce-lee skill, large-response-chunking.md -- about half
266
+ // the design's original ~100KB assumption; a 64K RAM read is ~192KB as
267
+ // hex, far above either figure). It is read from the client's own process
268
+ // environment, set via `.claude/settings.json`'s `env` block, which this
269
+ // repo's `.gitignore` makes untrackable (`.claude/*`, `.gitignore` lines
270
+ // 62-67) -- the same structural wall plan 01.1-04 hit with
271
+ // `.claude/CLAUDE.md`. It genuinely cannot be committed, so this proxy
272
+ // documents the required value in a tracked file (`tools/README.md`'s
273
+ // "Per-machine setup" section) and makes its OWN inherited environment's
274
+ // view of the setting OBSERVABLE on stderr, rather than silently assuming
275
+ // it is set. This is a WARNING, never a refusal: nothing throws, no call is
276
+ // rejected, and stdout carries only MCP messages (see the stdin-loop
277
+ // comment below) -- exactly one stderr line, at most once per process.
278
+ //
279
+ // Deliberately NOT resolved here, per this task's own instruction: the
280
+ // standing 32KB chunking non-negotiable and this proxy's own 500,000-char
281
+ // `_meta` ceiling (OUTPUT_CHAR_CAP above) are only compatible if a per-tool
282
+ // override is genuinely honoured, which was never measured -- the spike
283
+ // bracketed the inline ceiling at 40-60KB with no override set. Recorded as
284
+ // a deferred item in this plan's SUMMARY (both numbers, the one open
285
+ // question), not fixed by this warning or by changing OUTPUT_CHAR_CAP.
286
+ const REQUIRED_MAX_MCP_OUTPUT_TOKENS = 25000;
287
+ let outputLimitWarned = false;
288
+
289
+ function warnOnceAboutOutputLimit(): void {
290
+ if (outputLimitWarned) return;
291
+ outputLimitWarned = true;
292
+ const raw = process.env.MAX_MCP_OUTPUT_TOKENS;
293
+ const n = Number(raw);
294
+ const sufficient = raw !== undefined && Number.isFinite(n) && n >= REQUIRED_MAX_MCP_OUTPUT_TOKENS;
295
+ if (sufficient) return;
296
+ console.error(
297
+ `vice-proxy: MAX_MCP_OUTPUT_TOKENS is ${raw === undefined ? "not set" : `set to ${raw}`} in this ` +
298
+ `process's environment -- this project requires at least ${REQUIRED_MAX_MCP_OUTPUT_TOKENS}. Set it in ` +
299
+ `.claude/settings.json's "env" block (untracked -- see tools/README.md's "Per-machine setup" ` +
300
+ `section for why and the exact value).`
301
+ );
302
+ }
303
+
304
+ // Two client behaviours this proxy deliberately does NOT rely on, recorded
305
+ // here so a later reader does not reach for either as a solution:
306
+ //
307
+ // 1. MCP_TIMEOUT does NOT extend the startup handshake. The measurement
308
+ // behind that claim tested only a 60s cap against a 10s delay, so it
309
+ // cannot distinguish "honoured but never reached" from "does nothing",
310
+ // and current official documentation describes it as a startup timeout
311
+ // -- genuinely OPEN, not settled. Moot for this proxy either way:
312
+ // handleInitialize() (above) answers with zero host I/O, so there is no
313
+ // slow handshake here that would need extending.
314
+ // 2. Automatic backgrounding of long tool calls does NOT apply to this
315
+ // project's dominant call pattern. It covers only main-conversation
316
+ // calls and explicitly excludes calls originating from subagents, and
317
+ // this project's emulator work runs overwhelmingly through executor
318
+ // waves, which are subagent-driven and share their parent session's
319
+ // single proxy connection. brokerWarmingMessage() (below) is therefore
320
+ // the PRIMARY cold-path mechanism, not a fallback for something the
321
+ // client will handle on this project's behalf.
322
+
323
+ // The synthetic continuation tool (task 3, decision D-E): served entirely
324
+ // inside this proxy, NEVER forwarded to the host, and advertised in every
325
+ // tools/list response exactly like a real tool so an agent can discover it
326
+ // the same way it discovers everything else.
327
+ const RESULT_CONTINUE_TOOL: ToolDefinition = {
328
+ name: "vice_result_continue",
329
+ description:
330
+ "Retrieve the next chunk of an oversized tools/call result. Call with the token named in the " +
331
+ "previous chunk's trailing marker.",
332
+ inputSchema: {
333
+ type: "object",
334
+ properties: {
335
+ token: {
336
+ type: "string",
337
+ description: "the continuation token named in the previous chunk's trailing marker",
338
+ },
339
+ },
340
+ required: ["token"],
341
+ },
342
+ };
343
+
344
+ // The recycle tool (plan 01.3-01, task 1): the only new HOST-SIDE ACTION
345
+ // this phase adds. Served entirely proxy-local -- like RESULT_CONTINUE_TOOL
346
+ // above, it is never in tools-manifest.json (RESEARCH Key Finding 3), so a
347
+ // manifest regenerate can never drop it. Deliberately split from
348
+ // vice_diagnose (D-03): this tool NEVER gates on a verdict, so there is no
349
+ // "confirm"/"mode" argument and no shared state between the two tools to
350
+ // keep in sync -- the separation itself is the safety.
351
+ const RECYCLE_TOOL: ToolDefinition = {
352
+ name: "vice_recycle",
353
+ description:
354
+ "DESTRUCTIVE. Kills and respawns THIS session's own emulator in place, on the same port, via " +
355
+ "the host supervisor's existing respawn loop -- the same instance, not a different one. The " +
356
+ "restart epoch changes, so any run in flight is void and must be resumed from the last recorded " +
357
+ 'milestone snapshot. A self-inflicted checkpoint stop (the emulator merely paused at an armed ' +
358
+ "checkpoint) is NOT a wedge and must not be recycled. Requires a non-empty \"reason\" naming why " +
359
+ "this recycle is happening; that reason is written to a permanent, repo-tracked incident record " +
360
+ "BEFORE anything is killed.",
361
+ inputSchema: {
362
+ type: "object",
363
+ properties: {
364
+ reason: {
365
+ type: "string",
366
+ description: "Why this recycle is happening -- written verbatim into the incident record.",
367
+ },
368
+ },
369
+ required: ["reason"],
370
+ },
371
+ };
372
+
373
+ // The diagnose tool (plan 01.3-02): the read-mostly companion to
374
+ // RECYCLE_TOOL above, served in the same proxy-local synthetic slot. D-03
375
+ // keeps the two structurally unlinked -- no shared verdict/confirm state,
376
+ // and recycle never reads a diagnose verdict.
377
+ const DIAGNOSE_TOOL: ToolDefinition = {
378
+ name: "vice_diagnose",
379
+ description:
380
+ "Read-mostly. Answers which of five states this session's emulator is in -- restarted, " +
381
+ "checkpoint_trap, wedged, stale_read_path, or live -- with the evidence that produced the " +
382
+ "verdict. It may resume the machine once or twice to measure a cycle bracket, so it is never " +
383
+ "something to call reflexively; when it runs a bracket it leaves the machine PAUSED afterward -- " +
384
+ 'resuming is your own next call. A "checkpoint_trap" verdict means the machine stopped ITSELF at ' +
385
+ "an armed checkpoint and must NOT be recycled -- recycling a self-inflicted stop destroys a " +
386
+ "healthy instance.",
387
+ inputSchema: {
388
+ type: "object",
389
+ properties: {},
390
+ },
391
+ };
392
+
393
+ function manifestPath(): string {
394
+ return process.env.VICE_TOOLS_MANIFEST
395
+ ? resolve(process.env.VICE_TOOLS_MANIFEST)
396
+ : join(HERE_DIR, "tools-manifest.json");
397
+ }
398
+
399
+ function readManifestTools(): ToolInfo[] {
400
+ const path = manifestPath();
401
+ let raw: string;
402
+ try {
403
+ raw = readFileSync(path, "utf8");
404
+ } catch (e) {
405
+ console.error(
406
+ `vice-proxy: tools-manifest not readable at ${path} (${(e as Error).message}) -- answering tools/list with an empty tools array`
407
+ );
408
+ return [];
409
+ }
410
+ let parsed: unknown;
411
+ try {
412
+ parsed = JSON.parse(raw);
413
+ } catch (e) {
414
+ console.error(
415
+ `vice-proxy: tools-manifest at ${path} is not valid JSON (${(e as Error).message}) -- answering tools/list with an empty tools array`
416
+ );
417
+ return [];
418
+ }
419
+ const shapeOk =
420
+ isPlainObject(parsed) &&
421
+ Array.isArray(parsed.tools) &&
422
+ parsed.tools.every((t: unknown) => isPlainObject(t) && typeof t.name === "string");
423
+ if (!shapeOk) {
424
+ console.error(
425
+ `vice-proxy: tools-manifest at ${path} has an unexpected shape ("tools" must be an array of objects ` +
426
+ `each carrying a string "name") -- answering tools/list with an empty tools array`
427
+ );
428
+ return [];
429
+ }
430
+ return (parsed as { tools: ToolInfo[] }).tools;
431
+ }
432
+
433
+ // --------------------------------------------------------------- tools/call
434
+ //
435
+ // Delegates every real call to the reused `call()` -- the retry ladder
436
+ // already lives there (Pattern 1). Per Pattern 2, EVERY outcome of a tool
437
+ // invocation attempt -- success or failure -- becomes a well-formed
438
+ // `{content, isError}` result, never a JSON-RPC `error` object. Malformed
439
+ // `tools/call` params (a missing/non-string `name`) are now rejected one
440
+ // layer further out, by the SDK's own `CallToolRequestSchema` zod validation
441
+ // (installed at the construction site near the bottom of this file) --
442
+ // there is no `ProtocolError`/`handleMessage()` pair left in this file to
443
+ // catch that case.
444
+ //
445
+ // Two hazards are enforced HERE, at the proxy seam, as independent layers on
446
+ // top of what `call()` already does internally:
447
+ //
448
+ // 1. vice_disk_list refusal. `call()` already refuses it (throwing a
449
+ // ViceError), but this proxy refuses it FIRST, before any forwarding
450
+ // logic runs and before any network attempt, so the refusal is
451
+ // observable with zero HTTP traffic and a well-formed MCP frame rather
452
+ // than one more layer of catch between the hazard and the answer.
453
+ //
454
+ // 2. Per-call epoch re-check (decision D-D). The proxy does NOT call
455
+ // assertSameMachine() and does NOT probe vice_checkpoint_list -- a
456
+ // state-reading call that pauses the emulated CPU and never resumes it,
457
+ // and the proxy arms no checkpoints of its own to probe with anyway.
458
+ // The narrowed contract is a plain readEpoch() comparison, before AND
459
+ // after every forwarded call: a changed epoch refuses the call (or
460
+ // discards its result, if the change happened mid-call) with a loud,
461
+ // evidence-carrying error naming both epoch values, then adopts the new
462
+ // value as the baseline so the SESSION stays usable -- a restart report
463
+ // is never cached, per criterion 6.
464
+ // NEVER-CACHE-A-NEGATIVE-RESULT INVARIANT (plan 01.1-03 task 1, criterion 6;
465
+ // extended to the broker path by plan 01.2-03 task 1, C11): nothing below
466
+ // this line may memoise "the host is down" -- or, as of this extension,
467
+ // "the broker is absent" -- as a fact that outlives a single tools/call.
468
+ // There is no cached probe verdict, no sticky "last known unreachable" flag,
469
+ // and no early-return short-circuit keyed off a PREVIOUS failure -- every
470
+ // forwarded tools/call re-evaluates reachability from scratch (the epoch
471
+ // check below reads the file fresh every time; the liveness probe added in
472
+ // task 2 does its own fresh network round trip every time; task 3's
473
+ // translation runs fresh every time; ensureBrokerLease()'s
474
+ // readBrokerLiveness() call reads broker.json fresh every time it is
475
+ // reached, never memoised at module scope). This is deliberate and easy to
476
+ // break by a later, performance-minded edit ("let's skip the probe if we
477
+ // just failed one 200ms ago", or "let's remember the broker was absent last
478
+ // call so we don't bother checking again") -- don't, for either path. A
479
+ // cached negative here is exactly the "quiet wrong answer" failure class
480
+ // this codebase rejects elsewhere (MachineRestartedError, the epoch
481
+ // re-check itself): the call after a human starts the broker must just
482
+ // work, with no session restart required.
483
+ let viceSession: SessionInfo | null = null; // beginSession()'s return value, set lazily on the first forwarded call
484
+ let epochBaseline: EpochResult | null = null; // the rolling comparison point; updated on every re-baseline
485
+
486
+ function ensureViceSession(): void {
487
+ if (!viceSession) {
488
+ viceSession = beginSession();
489
+ epochBaseline = viceSession.baseline;
490
+ }
491
+ }
492
+
493
+ function currentEpoch(): EpochResult {
494
+ return readEpoch((viceSession as SessionInfo).epochPath);
495
+ }
496
+
497
+ function epochChanged(baseline: EpochResult | null, current: EpochResult | null): boolean {
498
+ return Boolean(baseline?.present) && Boolean(current?.present) && baseline!.epoch !== current!.epoch;
499
+ }
500
+
501
+ function epochDriftMessage(when: string, baseline: EpochResult, current: EpochResult): string {
502
+ const pidNote = current && current.pid != null ? `, pid ${current.pid}` : "";
503
+ const spawnedNote = current && current.spawned_at ? `, spawned_at ${current.spawned_at}` : "";
504
+ return (
505
+ `vice: treat every result since the previous call as void and redo that work -- epoch drift was ` +
506
+ `detected ${when} (epoch changed from ${baseline.epoch} to ${current.epoch}${pidNote}${spawnedNote}).`
507
+ );
508
+ }
509
+
510
+ /**
511
+ * Compare the rolling baseline against a fresh epoch read. Returns an error
512
+ * MESSAGE string if the comparison proves a restart (and re-baselines to the
513
+ * new value so the next call is not refused again), or `null` if the call
514
+ * may proceed (including the "absent baseline, now present" case, which is
515
+ * adopted silently -- a supervisor merely started, not a restart, mirroring
516
+ * vice.ts's own "only compare when both are present" rule).
517
+ */
518
+ function checkEpochAndRebaseline(when: string): string | null {
519
+ const current = currentEpoch();
520
+ if (epochChanged(epochBaseline, current)) {
521
+ const msg = epochDriftMessage(when, epochBaseline as EpochResult, current);
522
+ epochBaseline = current; // never cache a negative result (criterion 6)
523
+ return msg;
524
+ }
525
+ if (!(epochBaseline as EpochResult).present && current.present) {
526
+ epochBaseline = current;
527
+ }
528
+ return null;
529
+ }
530
+
531
+ interface ErrorTextResult {
532
+ content: { type: "text"; text: string }[];
533
+ isError: true;
534
+ }
535
+
536
+ function isErrorText(text: string): ErrorTextResult {
537
+ return { content: [{ type: "text", text }], isError: true };
538
+ }
539
+
540
+ /** The shape every tools/call outcome takes (Pattern 2): success or failure,
541
+ * never a JSON-RPC `error` object. Shared by handleRecycle(), handleDiagnose(),
542
+ * handleResultContinue(), wrapPossiblyChunked() and handleToolsCall() itself. */
543
+ interface OkTextResult {
544
+ content: { type: "text"; text: string }[];
545
+ isError: false;
546
+ }
547
+ type ToolCallResult = ErrorTextResult | OkTextResult;
548
+
549
+ // ------------------------------------------------------------ vice_recycle
550
+ //
551
+ // Re-baselines the proxy's own epoch tracking after a CONFIRMED recycle.
552
+ // Mirrors ensureBrokerLease()'s own `viceSession = null` re-baseline
553
+ // (further down this file) for the identical reason: a recycle is a
554
+ // DELIBERATE identity change, and without this the very next forwarded
555
+ // call would fail its own epoch drift guard against a baseline that is now
556
+ // stale by construction. Clearing epochBaseline too (not just viceSession)
557
+ // means nothing in between reads the stale value before the next
558
+ // ensureViceSession() call re-populates both from a fresh read.
559
+ function rebaselineEpochAfterRecycle(): void {
560
+ viceSession = null;
561
+ epochBaseline = null;
562
+ }
563
+
564
+ /** Renders a human-facing message for a recycle ack whose kill stage was
565
+ * NOT a successful kill -- named per outcome so an operator reading the
566
+ * result can tell "no grant record" from "unreadable epoch file" from "no
567
+ * pid recorded" from "identity mismatch" without opening the broker log
568
+ * (matches resources/vice-broker.sh's own per-outcome ack strings). */
569
+ function recycleAckOutcomeMessage(ack: Record<string, unknown>): string {
570
+ const outcome = ack && typeof ack.outcome === "string" ? ack.outcome : "unknown";
571
+ const stage = ack && typeof ack.kill_stage === "string" ? ack.kill_stage : "unknown";
572
+ const reason = ack && typeof ack.reason === "string" && ack.reason ? ` (${ack.reason})` : "";
573
+ switch (outcome) {
574
+ case "identity_refused":
575
+ return (
576
+ `vice_recycle: the host refused to signal the target -- its process identity did not match ` +
577
+ `the binary recorded in its own epoch file (kill stage: ${stage}). The instance was NOT ` +
578
+ `killed and is still running.`
579
+ );
580
+ case "target_lookup_failed":
581
+ return `vice_recycle: the host could not resolve this session's own recycle target (kill stage: ${stage})${reason}.`;
582
+ case "grant_lookup_failed":
583
+ return `vice_recycle: the host found no grant record for this session's target (kill stage: ${stage})${reason}.`;
584
+ case "epoch_lookup_failed":
585
+ return `vice_recycle: the host could not read the target's epoch file (kill stage: ${stage})${reason}.`;
586
+ case "pid_lookup_failed":
587
+ return `vice_recycle: the target's own epoch file carries no pid to signal (kill stage: ${stage})${reason}.`;
588
+ default:
589
+ return `vice_recycle: the host reported outcome "${outcome}" (kill stage: ${stage})${reason}.`;
590
+ }
591
+ }
592
+
593
+ /**
594
+ * Handles the destructive vice_recycle tool. Fixed order, and the order is
595
+ * the point (plan 01.3-01 task 1): read the current epoch first; refuse
596
+ * (no incident record, no request) when no broker lease is held yet or an
597
+ * explicit VICE_MCP_URL override is in effect -- there is no broker to ask
598
+ * and no supervisor to respawn either way; write the incident record BEFORE
599
+ * anything else touches the host (D-17); only then write the recycle
600
+ * request; await the ack; on anything other than a successful kill,
601
+ * finalise the record with that outcome and return a well-formed error
602
+ * naming the stage verbatim; on a successful kill, poll for the epoch to
603
+ * move and probe readiness as two SEPARATE facts (T-01.3-03), finalise the
604
+ * record, re-baseline, and return success. Never throws past this point --
605
+ * every branch is a well-formed isError result (a dead stdio proxy is
606
+ * unrecoverable for the session).
607
+ */
608
+ // Declared as `const ... = async function handleRecycle(args) { ... }` (a
609
+ // contextually-typed function EXPRESSION), not `async function
610
+ // handleRecycle(args: ...) { ... }` (a typed declaration): the latter's
611
+ // exact param-list text would drift from vice-proxy.test.mjs's own
612
+ // structural oracle (`indexOf("async function handleRecycle(args)")`),
613
+ // which is off-limits to edit in this plan. The variable's own type
614
+ // annotation gives `args` a real, checked type via TS's ordinary contextual
615
+ // typing for a function expression assigned to a typed const -- verified
616
+ // live this session against a scratch file (see RE-FINDINGS.md) -- so this
617
+ // is real typing, not a suppression: every field read below still narrows
618
+ // `args` the same way every other handler in this file does.
619
+ const handleRecycle: (args: Record<string, unknown>) => Promise<ToolCallResult> = async function handleRecycle(args) {
620
+ const rawReason = args && typeof args.reason === "string" ? args.reason : "";
621
+ const reason = rawReason.trim();
622
+ if (!reason) {
623
+ return isErrorText(
624
+ 'vice_recycle requires a non-empty "reason" string naming why this recycle is happening -- it ' +
625
+ "becomes the incident record's own explanation, written before anything is killed. No record " +
626
+ "and no request were written."
627
+ );
628
+ }
629
+
630
+ const preKillEpoch = readEpoch();
631
+
632
+ if (process.env.VICE_MCP_URL) {
633
+ return isErrorText(
634
+ "vice_recycle: VICE_MCP_URL is set, so this session talks to an explicitly overridden endpoint " +
635
+ "with no broker to ask and no supervisor to respawn it. Recycle only applies to a broker-" +
636
+ "granted instance. No record and no request were written."
637
+ );
638
+ }
639
+ if (!controlSession) {
640
+ return isErrorText(
641
+ "vice_recycle: no broker lease is held yet for this session -- recycle only applies to an " +
642
+ "instance already granted to this session. Make at least one other forwarded call first. " +
643
+ "No record and no request were written."
644
+ );
645
+ }
646
+
647
+ const sessionId = process.env.CLAUDE_CODE_SESSION_ID || null;
648
+ const { port } = activeInstance();
649
+ const epochBefore = preKillEpoch.present ? preKillEpoch.epoch : null;
650
+ const at = new Date().toISOString();
651
+
652
+ // Plan 01.3-03 (D-17, extended): gather the FULL criterion-4 evidence set
653
+ // -- including the best-effort pre-kill snapshot -- BEFORE the record is
654
+ // written. There is no argument, environment variable or branch between
655
+ // here and the record write that can reach the request write with any of
656
+ // this still ungathered; every step above degrades to unavailable rather
657
+ // than aborting, so this line always completes.
658
+ const evidence = await gatherWedgeEvidence({ at, port, epoch: epochBefore });
659
+ evidence.snapshot = await captureSnapshotAttempt({ at, port, epoch: epochBefore });
660
+
661
+ // D-17: the record is written BEFORE the request -- capturing is
662
+ // structurally impossible to skip, not a discipline to remember.
663
+ const recordPath = writeIncidentRecord({
664
+ at,
665
+ port,
666
+ epoch_before: epochBefore,
667
+ reason,
668
+ session_id: sessionId,
669
+ evidence,
670
+ });
671
+
672
+ // Plan 01.6.2-07 task 2: the request write + ack poll are replaced by one
673
+ // recycle request over the connection this session already holds -- the
674
+ // client_pid this session used to send with a recycle request has no
675
+ // successor field on the wire, since the connection itself already
676
+ // identifies which grant this is (broker-control.mts's own T-01.6.2-31
677
+ // discipline: a connection may only recycle the grant it itself holds).
678
+ const recycled = await controlSession.recycle(grantId as string);
679
+ if (!recycled.ok) {
680
+ if (recycled.kind === "broker_gone") {
681
+ // D-14 (plan 08): distinct from an acknowledgement carrying a refusal
682
+ // (T-01.6.2-46) -- the instance's state is unknown in both cases, but
683
+ // the operator's next action differs, a refusal means the target is
684
+ // alive and uncooperative, broker_gone means there is no longer
685
+ // anyone to ask. Reuses sessionMustRestartMessage() -- the SAME
686
+ // fresh-machine vocabulary a forwarded call's own broker-gone path
687
+ // (handleGrantedInstanceUnreachable() above) produces -- rather than
688
+ // a bare transport error string. Deliberately does NOT attempt to
689
+ // open a fresh session and acquire a replacement the way a forwarded
690
+ // call does: the instance THIS recycle was trying to kill is now of
691
+ // genuinely unknown state (the kill request may or may not have
692
+ // reached the broker before the connection dropped), and silently
693
+ // handing back a different "replacement" instance under the name of
694
+ // a recycle result would claim more certainty about that kill than
695
+ // this proxy actually has.
696
+ finaliseIncidentRecord(recordPath, { outcome: "broker_gone" });
697
+ return isErrorText(
698
+ `${sessionMustRestartMessage(recycled)} Incident record: ${recordPath}. This recycle's own kill ` +
699
+ `request may or may not have reached the broker before the connection dropped -- the instance's ` +
700
+ `state is now unknown.`
701
+ );
702
+ }
703
+ if (recycled.kind === "deadline") {
704
+ finaliseIncidentRecord(recordPath, { outcome: "timeout" });
705
+ return isErrorText(
706
+ `vice_recycle: no ack arrived from the host within the timeout (${recycled.message}). Incident ` +
707
+ `record: ${recordPath}. The instance's state is now unknown -- treat it as neither confirmed ` +
708
+ `killed nor confirmed alive.`
709
+ );
710
+ }
711
+ // Any other control-plane failure (protocol/unauthorized/bad_request/
712
+ // denied/internal) -- an unexpected shape from the broker's own
713
+ // response, not exhaustively enumerated here (D-14's full vocabulary is
714
+ // plan 08's); still a well-formed, non-throwing result either way.
715
+ finaliseIncidentRecord(recordPath, { outcome: "internal" });
716
+ return isErrorText(
717
+ `vice_recycle: the recycle request failed (${recycled.kind}: ${recycled.message}). Incident record: ${recordPath}.`
718
+ );
719
+ }
720
+
721
+ const ack = recycled.ack;
722
+ const killStage: string | null = ack.kill_stage;
723
+ const successfulKill = killStage === "already_exited" || killStage === "sigterm" || killStage === "sigkill";
724
+
725
+ if (!successfulKill) {
726
+ finaliseIncidentRecord(recordPath, { outcome: ack.outcome || "refused", kill_stage: killStage });
727
+ return isErrorText(`${recycleAckOutcomeMessage({ ...ack })} Incident record: ${recordPath}.`);
728
+ }
729
+
730
+ // The kill succeeded -- confirm the machine actually came back. The epoch
731
+ // bump and the readiness probe are reported as two SEPARATE facts
732
+ // (T-01.3-03): "the epoch moved" is bookkeeping, "the instance answers"
733
+ // is evidence, and neither substitutes for the other.
734
+ const epochDeadline = Date.now() + RECYCLE_TIMEOUT_MS;
735
+ let afterEpoch = readEpoch();
736
+ const epochMoved = () =>
737
+ afterEpoch.present && (!preKillEpoch.present || (afterEpoch.epoch as number) > (preKillEpoch.epoch as number));
738
+ while (Date.now() < epochDeadline && !epochMoved()) {
739
+ await new Promise((r) => setTimeout(r, 250));
740
+ afterEpoch = readEpoch();
741
+ }
742
+
743
+ const { url, port: instancePort } = activeInstance();
744
+ const probe = await probeInstance({ url, port: instancePort });
745
+
746
+ // 2026-08-05 defect fix: the persisted record's own epoch_after must never
747
+ // carry a stale value equal to epoch_before -- that pair reads as
748
+ // "confirmed unchanged" to a future reader, which is a false claim for a
749
+ // kill that just genuinely succeeded (killStage is one of the three
750
+ // successful-kill stages here, by construction of the guard above). Only
751
+ // the poll loop's own epochMoved() -- not merely afterEpoch.present -- may
752
+ // promote the read into the record; anything else stays the honest `null`
753
+ // ("not yet known", per renderIncidentRecord()'s existing rendering) so a
754
+ // future investigation is never handed a pair that looks complete but
755
+ // isn't.
756
+ finaliseIncidentRecord(recordPath, {
757
+ outcome: "ok",
758
+ kill_stage: killStage,
759
+ epoch_after: epochMoved() ? afterEpoch.epoch : null,
760
+ });
761
+
762
+ // Immediately before returning success -- the deliberate identity change
763
+ // this tool exists to cause would otherwise make every subsequent
764
+ // forwarded call fail the drift guard.
765
+ rebaselineEpochAfterRecycle();
766
+
767
+ const snapshotNote =
768
+ evidence.snapshot && evidence.snapshot.available
769
+ ? `accepted (name: ${(evidence.snapshot.value as { name: string }).name})`
770
+ : `unavailable (${evidence.snapshot && evidence.snapshot.reason ? evidence.snapshot.reason : "no reason recorded"})`;
771
+
772
+ return {
773
+ content: [
774
+ {
775
+ type: "text",
776
+ text:
777
+ `vice_recycle: kill stage "${killStage}". Epoch before: ${preKillEpoch.present ? preKillEpoch.epoch : "unknown"}, ` +
778
+ `epoch after: ${afterEpoch.present ? afterEpoch.epoch : "unknown"} (${epochMoved() ? "moved" : "did not move within the timeout"}). ` +
779
+ `Readiness probe: ${probe.alive ? "the respawned instance answered" : `not yet answering (${probe.reason})`}. ` +
780
+ `Snapshot: ${snapshotNote}. ` +
781
+ `Incident record: ${recordPath}. This run is VOID -- resume from the last recorded milestone snapshot.`,
782
+ },
783
+ ],
784
+ isError: false,
785
+ };
786
+ }
787
+
788
+ // ----------------------------------------------------------- vice_diagnose
789
+ //
790
+ // Plan 01.3-02 task 1: the read-mostly half of this phase, up to but not
791
+ // including the cycle bracket (task 2 wires that in). Every read below goes
792
+ // through the proxy's existing forwarded call() path -- no new host
793
+ // capability, no new protocol, no second route.
794
+
795
+ // The closed, five-member verdict vocabulary, in the order the checks run.
796
+ // Frozen so a future edit cannot quietly widen it -- must_have C1's whole
797
+ // point.
798
+ const DIAGNOSE_VERDICTS = Object.freeze(["restarted", "checkpoint_trap", "wedged", "stale_read_path", "live"]);
799
+
800
+ /** Normalise a checkpoint/register address to a plain number, accepting
801
+ * either a JS number or a hex string ("$1103"/"1103"/"0x1103"). An unprefixed
802
+ * digit string is read as HEX, matching this project's own address
803
+ * convention (every C64 address in this project's docs and RE-FINDINGS.md is
804
+ * hex), never decimal. Returns null, never throws, on anything unresolvable
805
+ * (T-01.3-06: an untrusted payload degrades to "unknown", never a thrown
806
+ * exception). */
807
+ function toAddressNumber(value: unknown): number | null {
808
+ if (typeof value === "number" && Number.isFinite(value)) return value;
809
+ if (typeof value === "string") {
810
+ const s = value.trim().replace(/^\$/, "").replace(/^0x/i, "");
811
+ const n = parseInt(s, 16);
812
+ if (Number.isFinite(n)) return n;
813
+ }
814
+ return null;
815
+ }
816
+
817
+ function formatAddress(n: number | null | undefined): string {
818
+ return n === null || n === undefined ? "unknown" : `$${n.toString(16).toUpperCase().padStart(4, "0")}`;
819
+ }
820
+
821
+ function formatByte(n: number | null | undefined): string {
822
+ return n === null || n === undefined ? "unknown" : `$${n.toString(16).toUpperCase().padStart(2, "0")}`;
823
+ }
824
+
825
+ /** Decode a vice_memory_read result into a plain byte array, accepting
826
+ * either the compact "hex" string encoding (requested below) or the legacy
827
+ * per-byte "bytes" array shape -- an untrusted payload degrades to an empty
828
+ * array, never a thrown exception (T-01.3-06). */
829
+ function bytesFromMemoryReadResult(result: unknown): number[] {
830
+ if (isPlainObject(result) && typeof result.hex === "string") {
831
+ const clean = result.hex.replace(/[^0-9a-fA-F]/g, "");
832
+ const bytes: number[] = [];
833
+ for (let i = 0; i + 1 < clean.length; i += 2) bytes.push(parseInt(clean.slice(i, i + 2), 16));
834
+ return bytes;
835
+ }
836
+ if (isPlainObject(result) && Array.isArray(result.bytes)) {
837
+ return (result.bytes as unknown[])
838
+ .map((b) => (typeof b === "string" ? parseInt(b.replace(/^\$/, ""), 16) : Number(b)))
839
+ .filter((n) => Number.isFinite(n));
840
+ }
841
+ return [];
842
+ }
843
+
844
+ function wordFromBytes(bytes: number[]): number | null {
845
+ return bytes.length >= 2 ? bytes[0] | (bytes[1] << 8) : null;
846
+ }
847
+
848
+ // Bit 1 (HIRAM) of the 6510 processor port at $01. SET -- the KERNAL ROM is
849
+ // banked in, and the RAM IRQ vector pair ($0314/$0315) is what the KERNAL's
850
+ // own dispatch actually reads (RE-FINDINGS.md's own vector-table entry).
851
+ // CLEAR -- the KERNAL is replaced by RAM and the CPU reads the hardware
852
+ // IRQ/BRK vector pair ($FFFE/$FFFF) directly, with no ROM indirection.
853
+ const HIRAM_MASK = 0x02;
854
+
855
+ /** The live-IRQ-handler lookup's own return shape -- shared by
856
+ * gatherCheckpointTrapEvidence() below and by plan 01.3-03's evidence
857
+ * gatherer (gatherWedgeEvidence()). */
858
+ interface IrqHandlerResolution {
859
+ target: number | null;
860
+ pairLabel: string;
861
+ explanation: string;
862
+ }
863
+
864
+ /**
865
+ * The single definition of the live-IRQ-handler lookup (Key Finding 6):
866
+ * three forwarded reads through the normal call() path -- $01, the RAM
867
+ * vector pair, and (only when $01 says the ROMs are banked out) the hardware
868
+ * vector pair. Consumed by the checkpoint-trap check below and, per this
869
+ * plan's own key_links, by plan 01.3-03's evidence gatherer. Memoises
870
+ * NOTHING: a disk swap, a reset or a different game retargets the handler,
871
+ * so a cached address would silently resolve the wrong pair.
872
+ */
873
+ async function resolveLiveIrqHandler(): Promise<IrqHandlerResolution> {
874
+ const portResult = await call("vice_memory_read", { address: "$01", size: 1, encoding: "hex" });
875
+ const portBytes = bytesFromMemoryReadResult(portResult);
876
+ const port01 = portBytes.length > 0 ? portBytes[0] : null;
877
+ const bankedOut = port01 !== null && (port01 & HIRAM_MASK) === 0;
878
+
879
+ const ramResult = await call("vice_memory_read", { address: "$0314", size: 2, encoding: "hex" });
880
+ const ramTarget = wordFromBytes(bytesFromMemoryReadResult(ramResult));
881
+
882
+ if (!bankedOut) {
883
+ return {
884
+ target: ramTarget,
885
+ pairLabel: "the RAM KERNAL IRQ vector pair ($0314/$0315)",
886
+ explanation:
887
+ `$01 read as ${formatByte(port01)} -- the KERNAL ROM is banked in, so the RAM IRQ vector pair ` +
888
+ `($0314/$0315) is the pair this session's IRQ dispatch actually reads; it resolves to ${formatAddress(ramTarget)}.`,
889
+ };
890
+ }
891
+
892
+ const hwResult = await call("vice_memory_read", { address: "$FFFE", size: 2, encoding: "hex" });
893
+ const hwTarget = wordFromBytes(bytesFromMemoryReadResult(hwResult));
894
+ return {
895
+ target: hwTarget,
896
+ pairLabel: "the hardware IRQ/BRK vector pair ($FFFE/$FFFF)",
897
+ explanation:
898
+ `$01 read as ${formatByte(port01)} -- the KERNAL ROM is banked OUT, so the CPU dispatches ` +
899
+ `directly through the hardware IRQ/BRK vector pair ($FFFE/$FFFF) with no ROM indirection; it ` +
900
+ `resolves to ${formatAddress(hwTarget)}.`,
901
+ };
902
+ }
903
+
904
+ /**
905
+ * Enumerate armed checkpoints, read the current PC, resolve the live IRQ
906
+ * handler, and decide the checkpoint-trap verdict on two named shapes
907
+ * (D-14): an enabled, stopping, exec checkpoint sitting exactly at the
908
+ * current PC; or one sitting at the resolved handler entry with a hit count
909
+ * of exactly zero (the corroborating tell that it has never actually
910
+ * fired). Makes NO resume and NO stopwatch call -- the whole point of
911
+ * checking this before any cycle bracket (D-14, T-01.3-08).
912
+ */
913
+ /** A single vice_checkpoint_list entry, typed loosely (matching this
914
+ * codebase's own precedent for a host-written record this proxy never
915
+ * asserts a closed shape on) -- every field is read defensively below,
916
+ * never assumed present. */
917
+ interface CheckpointInfo {
918
+ checkpoint_num?: unknown;
919
+ start?: unknown;
920
+ stop?: unknown;
921
+ exec?: unknown;
922
+ enabled?: unknown;
923
+ hit_count?: unknown;
924
+ [key: string]: unknown;
925
+ }
926
+
927
+ interface CheckpointTrapEvidence {
928
+ isTrap: boolean;
929
+ checkpoints: CheckpointInfo[];
930
+ pc: number | null;
931
+ handler: IrqHandlerResolution;
932
+ trapCheckpoint: CheckpointInfo | null;
933
+ trapReason: "pc" | "handler" | null;
934
+ }
935
+
936
+ async function gatherCheckpointTrapEvidence(): Promise<CheckpointTrapEvidence> {
937
+ const checkpointsResult = await call("vice_checkpoint_list", {});
938
+ const checkpoints: CheckpointInfo[] =
939
+ isPlainObject(checkpointsResult) && Array.isArray(checkpointsResult.checkpoints)
940
+ ? (checkpointsResult.checkpoints as CheckpointInfo[])
941
+ : [];
942
+
943
+ const regs = await call("vice_registers_get", {});
944
+ const pc = isPlainObject(regs) && typeof regs.PC === "number" ? regs.PC : null;
945
+
946
+ const handler = await resolveLiveIrqHandler();
947
+
948
+ const armedStopping = checkpoints.filter((c) => c && c.enabled !== false && c.stop === true && c.exec === true);
949
+
950
+ const atPc = pc !== null ? armedStopping.find((c) => toAddressNumber(c.start) === pc) : undefined;
951
+ const atHandler =
952
+ !atPc && handler.target !== null && handler.target !== undefined
953
+ ? armedStopping.find((c) => toAddressNumber(c.start) === handler.target && c.hit_count === 0)
954
+ : undefined;
955
+
956
+ const trapCheckpoint = atPc || atHandler || null;
957
+ return {
958
+ isTrap: Boolean(trapCheckpoint),
959
+ checkpoints,
960
+ pc,
961
+ handler,
962
+ trapCheckpoint,
963
+ trapReason: atPc ? "pc" : atHandler ? "handler" : null,
964
+ };
965
+ }
966
+
967
+ // The recorded incident this report's own "not guaranteed" paragraph cites --
968
+ // D-15's own caveat, load-bearing per this plan's planning notes: delete,
969
+ // soft reset, hard reset and an explicit single step ALL left the machine
970
+ // frozen in this recorded case.
971
+ const CHECKPOINT_TRAP_INCIDENT_REF =
972
+ ".planning/todos/pending/2026-08-01-vice-registers-frozen-after-reset-during-01-04-task2.md";
973
+
974
+ /** Renders the checkpoint_trap verdict's report -- an explanation, never a
975
+ * remedy (D-15): it names the armed checkpoints, the resolved handler, the
976
+ * PC's relation to the trap, states plainly this is self-inflicted and not a
977
+ * wedge, names the agent's own next moves without performing any of them,
978
+ * and closes with the not-guaranteed paragraph. */
979
+ function renderCheckpointTrapReport(evidence: CheckpointTrapEvidence): string {
980
+ const { checkpoints, pc, handler, trapCheckpoint, trapReason } = evidence;
981
+ const checkpointList =
982
+ checkpoints.length === 0
983
+ ? "none armed"
984
+ : checkpoints
985
+ .map((c) => {
986
+ const addr = formatAddress(toAddressNumber(c && c.start));
987
+ const flag = c && c.stop ? "stop" : "continue";
988
+ const enabled = c && c.enabled === false ? "disabled" : "enabled";
989
+ const hitCount = c && typeof c.hit_count === "number" ? c.hit_count : "unknown";
990
+ return `#${c && c.checkpoint_num} ${addr} (${flag}, ${enabled}, hit_count ${hitCount})`;
991
+ })
992
+ .join("; ");
993
+
994
+ const pcRelation =
995
+ trapReason === "pc"
996
+ ? `exactly at armed checkpoint #${trapCheckpoint!.checkpoint_num} -- that is why the machine is stopped here`
997
+ : trapReason === "handler"
998
+ ? `not at the armed checkpoint's own address, but checkpoint #${trapCheckpoint!.checkpoint_num} sits at ` +
999
+ "the resolved live IRQ handler entry with hit_count 0 -- the corroborating tell that this checkpoint " +
1000
+ "has never actually fired, not merely that it fired between reads"
1001
+ : "no relation established";
1002
+
1003
+ return [
1004
+ "vice_diagnose verdict: checkpoint_trap",
1005
+ "",
1006
+ `Armed checkpoints: ${checkpointList}.`,
1007
+ `Resolved live IRQ handler: ${handler.explanation}`,
1008
+ `Current PC: ${formatAddress(pc)} -- ${pcRelation}.`,
1009
+ "",
1010
+ "This is a self-inflicted stop, not a wedge: the machine paused because an armed checkpoint " +
1011
+ "fired or sits exactly here, not because it stopped retiring cycles on its own. Recycling now " +
1012
+ "would destroy a healthy instance -- no cycle bracket was run to reach this verdict.",
1013
+ "",
1014
+ "Next moves available to you (this report does not perform any of them): vice_checkpoint_delete " +
1015
+ "the offending checkpoint, or vice_checkpoint_toggle it disabled; vice_execution_step past it; " +
1016
+ "then re-run vice_diagnose.",
1017
+ "",
1018
+ "Not guaranteed: deleting the checkpoint is not guaranteed to unfreeze the machine. The recorded " +
1019
+ `incident (${CHECKPOINT_TRAP_INCIDENT_REF}) shows checkpoint delete, then a soft reset, then a hard ` +
1020
+ "reset, then an explicit single step ALL leaving the machine frozen in sequence -- a checkpoint " +
1021
+ "trap may be the onset without being the whole story. If a cycle bracket still measures zero " +
1022
+ "after the checkpoint is gone, the verdict becomes wedged and recycle is the fallback after all.",
1023
+ ].join("\n");
1024
+ }
1025
+
1026
+ /** Renders the restarted verdict's report -- reached from a plain epoch-file
1027
+ * comparison alone, at zero emulator calls (D-14's ordering: this check
1028
+ * costs nothing and runs first). */
1029
+ function renderRestartedReport(beforeEpoch: number | null | undefined, afterEpoch: number | null | undefined): string {
1030
+ return (
1031
+ "vice_diagnose verdict: restarted\n\n" +
1032
+ `The host VICE MCP server's epoch changed from ${beforeEpoch} to ${afterEpoch} -- the emulator ` +
1033
+ "behind this session restarted. This is answered from a plain epoch comparison alone, at zero " +
1034
+ "emulator calls; no checkpoint enumeration was attempted, because a restart is this project's own " +
1035
+ "already-handled case (criterion 1) and re-deriving it here would be a second mechanism. Any run " +
1036
+ "in flight before this point is void."
1037
+ );
1038
+ }
1039
+
1040
+ // Plan 01.3-02 task 2: the cycle bracket, the definitive liveness test, and
1041
+ // the three verdicts that depend on it (wedged, stale_read_path, live).
1042
+
1043
+ // Three polls: the bracket needs the machine to be given real forwarded
1044
+ // round trips to retire cycles across, and three is enough for the counter
1045
+ // to move at any rate worth calling alive.
1046
+ const CYCLE_BRACKET_PINGS = 3;
1047
+ // Two brackets: criterion 2's minimum for a wedged verdict is two
1048
+ // consecutive zeros, and D-04 makes every additional bracket another call to
1049
+ // the tool most correlated with host death. Two is the minimum and the
1050
+ // maximum.
1051
+ const CYCLE_BRACKET_MAX = 2;
1052
+
1053
+ // ~991,000 cycles/s is the measured PAL C64 full-speed rate (RE-FINDINGS.md,
1054
+ // "the only trustworthy VICE liveness test is a cycle bracket"). Printed
1055
+ // only, as an observation beside a measured rate -- D-08 refuses a
1056
+ // degradation threshold, and a constant that is only ever printed cannot
1057
+ // become one by accident.
1058
+ const BASELINE_CYCLES_PER_SECOND = 991000;
1059
+
1060
+ function cyclesFromStopwatchResult(result: unknown): number {
1061
+ if (isPlainObject(result) && typeof result.cycles === "number") return result.cycles;
1062
+ if (isPlainObject(result) && typeof result.previous_cycles === "number") return result.previous_cycles;
1063
+ return 0;
1064
+ }
1065
+
1066
+ interface CycleBracketResult {
1067
+ cycles: number;
1068
+ elapsedMs: number;
1069
+ }
1070
+
1071
+ /**
1072
+ * The single definition of the cycle bracket criterion 2 requires: reset the
1073
+ * stopwatch, resume execution exactly once, poll with ping
1074
+ * CYCLE_BRACKET_PINGS times, pause, read the stopwatch back. Pacing comes
1075
+ * from the forwarded round trips alone -- there is no timer, no delay and no
1076
+ * wall-clock quantity anywhere in it (the standing project rule). Every
1077
+ * stopwatch call in this file lives inside this function's body; the
1078
+ * structural test enforces it. `elapsedMs` is measured only to print an
1079
+ * observational rate afterward -- it decides nothing and paces nothing.
1080
+ */
1081
+ async function runCycleBracket() {
1082
+ await call("vice_cycles_stopwatch", { action: "reset" });
1083
+ const startedAt = Date.now();
1084
+ await call("vice_execution_run", {});
1085
+ for (let i = 0; i < CYCLE_BRACKET_PINGS; i += 1) {
1086
+ await call("vice_ping", {}); // the ping EXECUTION field is never inspected here -- it decides nothing (C1, D-07)
1087
+ }
1088
+ await call("vice_execution_pause", {});
1089
+ const elapsedMs = Date.now() - startedAt;
1090
+ const readResult = await call("vice_cycles_stopwatch", { action: "read" });
1091
+ const cycles = cyclesFromStopwatchResult(readResult);
1092
+ return { cycles, elapsedMs };
1093
+ }
1094
+
1095
+ function registersByteIdentical(a: unknown, b: unknown): boolean {
1096
+ try {
1097
+ return JSON.stringify(a) === JSON.stringify(b);
1098
+ } catch {
1099
+ return false;
1100
+ }
1101
+ }
1102
+
1103
+ interface BracketEvidence {
1104
+ regsBefore: unknown;
1105
+ regsAfter: unknown;
1106
+ bracket1: CycleBracketResult;
1107
+ bracket2: CycleBracketResult | null;
1108
+ finalBracket: CycleBracketResult;
1109
+ }
1110
+
1111
+ /**
1112
+ * Gathers the bracket evidence: a register snapshot at each end, bracket
1113
+ * one, and -- only when bracket one retired exactly zero cycles -- bracket
1114
+ * two. A non-zero first bracket short-circuits (D-04): the answer is already
1115
+ * not wedged, and a second resume buys nothing.
1116
+ */
1117
+ async function gatherBracketEvidence(): Promise<BracketEvidence> {
1118
+ const regsBefore = await call("vice_registers_get", {});
1119
+ const bracket1 = await runCycleBracket();
1120
+ let bracket2: CycleBracketResult | null = null;
1121
+ let finalBracket = bracket1;
1122
+ if (bracket1.cycles === 0) {
1123
+ bracket2 = await runCycleBracket();
1124
+ finalBracket = bracket2;
1125
+ }
1126
+ const regsAfter = await call("vice_registers_get", {});
1127
+ return { regsBefore, regsAfter, bracket1, bracket2, finalBracket };
1128
+ }
1129
+
1130
+ type LivenessVerdict = "wedged" | "stale_read_path" | "live";
1131
+
1132
+ /**
1133
+ * Produces the post-bracket verdict (criterion 2/3). Two consecutive zeros
1134
+ * is wedged and nothing else is. On any non-zero result (whichever bracket
1135
+ * produced it), a byte-identical register snapshot across an advancing
1136
+ * bracket is stale_read_path -- one read path is stale while the machine is
1137
+ * demonstrably not frozen; anything else is live.
1138
+ */
1139
+ function classifyLiveness(evidence: BracketEvidence): LivenessVerdict {
1140
+ const { bracket1, bracket2, regsBefore, regsAfter } = evidence;
1141
+ if (bracket1.cycles === 0 && (!bracket2 || bracket2.cycles === 0)) {
1142
+ return "wedged";
1143
+ }
1144
+ return registersByteIdentical(regsBefore, regsAfter) ? "stale_read_path" : "live";
1145
+ }
1146
+
1147
+ /**
1148
+ * Renders the post-bracket report (wedged/stale_read_path/live). Separates
1149
+ * load-bearing evidence (the restart epoch, already checked; the stopwatch
1150
+ * delta across the bracket) from corroborating evidence (the program
1151
+ * counter, VIC-II state, checkpoint hit counts, a screenshot) explicitly --
1152
+ * criterion 3's own requirement. A status of ok with an execution state of
1153
+ * running is compatible with every one of these verdicts and is therefore
1154
+ * evidence for none of them.
1155
+ */
1156
+ function renderDiagnoseReport(evidence: BracketEvidence, verdict: LivenessVerdict): string {
1157
+ const { bracket1, bracket2, finalBracket } = evidence;
1158
+ const bracketsRun = bracket2 ? 2 : 1;
1159
+ const ratePerSecond =
1160
+ finalBracket.cycles > 0 ? Math.round((finalBracket.cycles / Math.max(finalBracket.elapsedMs, 1)) * 1000) : 0;
1161
+
1162
+ const lines = [
1163
+ `vice_diagnose verdict: ${verdict}`,
1164
+ "",
1165
+ "Load-bearing evidence: the restart epoch (already checked, at zero emulator cost) and the " +
1166
+ `stopwatch cycle delta across the bracket -- bracket 1 retired ${bracket1.cycles} cycles` +
1167
+ (bracket2 ? `, bracket 2 retired ${bracket2.cycles} cycles` : "") +
1168
+ ` (${bracketsRun} bracket${bracketsRun > 1 ? "s" : ""} run, ${bracketsRun} resume call${bracketsRun > 1 ? "s" : ""}).`,
1169
+ "Corroborating evidence only, never load-bearing on its own: the program counter, VIC-II state, " +
1170
+ "checkpoint hit counts, and a screenshot. A status of ok with an execution state of running is " +
1171
+ "compatible with every one of these verdicts and is therefore evidence for none of them.",
1172
+ ];
1173
+
1174
+ if (verdict !== "wedged") {
1175
+ lines.push(
1176
+ `Measured rate this call: ~${finalBracket.cycles} cycles in ~${finalBracket.elapsedMs}ms ` +
1177
+ `(~${ratePerSecond} cycles/s), beside the baseline ~${BASELINE_CYCLES_PER_SECOND} cycles/s ` +
1178
+ "(PAL C64 full speed) -- an observation, never a threshold, and never a verdict of its own."
1179
+ );
1180
+ }
1181
+
1182
+ if (verdict === "stale_read_path") {
1183
+ lines.push(
1184
+ "The register-read path returned a byte-identical snapshot across both ends of an advancing " +
1185
+ "bracket -- that read path is stale, but the machine is demonstrably not frozen."
1186
+ );
1187
+ }
1188
+
1189
+ lines.push(
1190
+ verdict === "wedged"
1191
+ ? "Machine state left: paused, after two zero-cycle brackets. Resuming is your own deliberate next call."
1192
+ : "Machine state left: paused, after the bracket that reached this verdict. Resuming is your own deliberate next call."
1193
+ );
1194
+
1195
+ return lines.join("\n");
1196
+ }
1197
+
1198
+ /**
1199
+ * Handles vice_diagnose. Fixed check order, and the order is the point
1200
+ * (D-14): first the epoch comparison (zero emulator calls), then the
1201
+ * checkpoint-trap check (no resume at all). Never throws past this point --
1202
+ * every branch is a well-formed isError:false or isError:true result.
1203
+ */
1204
+ async function handleDiagnose(_args: Record<string, unknown>): Promise<ToolCallResult> {
1205
+ try {
1206
+ const leaseResult = await ensureBrokerLease();
1207
+ if (!leaseResult.ok) {
1208
+ return isErrorText(leaseResult.message);
1209
+ }
1210
+ ensureViceSession();
1211
+
1212
+ const epochNow = currentEpoch();
1213
+ if (epochChanged(epochBaseline, epochNow)) {
1214
+ const before = (epochBaseline as EpochResult).epoch;
1215
+ epochBaseline = epochNow; // never cache a negative result (criterion 6)
1216
+ return { content: [{ type: "text", text: renderRestartedReport(before, epochNow.epoch) }], isError: false };
1217
+ }
1218
+ if (!(epochBaseline as EpochResult).present && epochNow.present) {
1219
+ epochBaseline = epochNow;
1220
+ }
1221
+
1222
+ const trapEvidence = await gatherCheckpointTrapEvidence();
1223
+ if (trapEvidence.isTrap) {
1224
+ return { content: [{ type: "text", text: renderCheckpointTrapReport(trapEvidence) }], isError: false };
1225
+ }
1226
+
1227
+ // Third and last: the cycle bracket, the definitive liveness test, drives
1228
+ // the three remaining verdicts (D-14's full order: epoch, trap, bracket).
1229
+ const bracketEvidence = await gatherBracketEvidence();
1230
+ const verdict = classifyLiveness(bracketEvidence);
1231
+ return { content: [{ type: "text", text: renderDiagnoseReport(bracketEvidence, verdict) }], isError: false };
1232
+ } catch (e) {
1233
+ if (e instanceof MachineRestartedError) {
1234
+ const current = currentEpoch();
1235
+ epochBaseline = current;
1236
+ return { content: [{ type: "text", text: renderRestartedReport(e.baselineEpoch, e.currentEpoch) }], isError: false };
1237
+ }
1238
+ return isErrorText(
1239
+ `vice_diagnose: an unexpected error occurred while gathering evidence: ${e && (e as Error).message ? (e as Error).message : e}`
1240
+ );
1241
+ }
1242
+ }
1243
+
1244
+ // -------------------------------------------- vice_recycle: evidence gather
1245
+ //
1246
+ // Plan 01.3-03 (criterion 4): the destructive path's own evidence set,
1247
+ // composed ENTIRELY from reads already forwardable through call() -- no new
1248
+ // host capability, no second route. runCycleBracket() and
1249
+ // resolveLiveIrqHandler() are plan 01.3-02's own single definitions, reused
1250
+ // here rather than re-derived (this plan's own key_links) -- criterion 2's
1251
+ // single-bracket-definition guard is a PHASE property, not a plan one.
1252
+
1253
+ /**
1254
+ * Capture-step deadline (T-01.3-10): a TRANSPORT deadline bounding how long
1255
+ * ANY single evidence-gathering step (including the pre-kill snapshot
1256
+ * attempt, task 2) may wait for its own forwarded call(s) before this
1257
+ * wrapper gives up and records an explicit unavailable-with-reason entry.
1258
+ *
1259
+ * This is deliberately DIFFERENT from the project's standing prohibition on
1260
+ * WALL-CLOCK PACING (never sleep to wait for the emulated machine to reach
1261
+ * some state -- synchronise on checkpoint hits and cycle counts instead):
1262
+ * that rule governs synchronising INPUT/WAITS against the emulated game's
1263
+ * own state. This deadline governs a capture step's patience with the
1264
+ * TRANSPORT alone -- exactly the kind of deadline call()'s own
1265
+ * AbortSignal.timeout already applies per forwarded call, just bounding the
1266
+ * WHOLE step (which may issue several forwarded calls, e.g. the bracket) so
1267
+ * one non-answering read can never stall the whole gather, and the
1268
+ * snapshot attempt can never stall the recycle itself (D-19). Overridable
1269
+ * purely so this file's own test suite can exercise a "never answers"
1270
+ * fixture in milliseconds rather than minutes -- production always uses the
1271
+ * generous default.
1272
+ */
1273
+ const CAPTURE_STEP_TIMEOUT_MS = Number(process.env.VICE_RECYCLE_CAPTURE_TIMEOUT_MS || 8000);
1274
+
1275
+ /** captureStep()'s own result shape -- structurally an EvidenceItem
1276
+ * (incident-record.ts), just narrowed to a discriminated union here so a
1277
+ * caller can branch on `available` without an optional-field guess. */
1278
+ type CaptureStepResult<T> = { available: true; value: T } | { available: false; reason: string };
1279
+
1280
+ /**
1281
+ * Runs one evidence-gathering step, turning any rejection, transport
1282
+ * failure or capture-step deadline into an explicit `{ available: false,
1283
+ * reason }` entry rather than letting it abort the whole gather -- the
1284
+ * whole point (D-17, D-19) is that a wedged machine will fail SOME of these
1285
+ * and the record must still exist. Never throws.
1286
+ */
1287
+ async function captureStep<T>(fn: () => Promise<T>): Promise<CaptureStepResult<T>> {
1288
+ let timer: NodeJS.Timeout | undefined;
1289
+ try {
1290
+ const value = await Promise.race([
1291
+ fn(),
1292
+ new Promise<never>((_, reject) => {
1293
+ timer = setTimeout(
1294
+ () => reject(new Error(`capture step deadline of ${CAPTURE_STEP_TIMEOUT_MS}ms exceeded`)),
1295
+ CAPTURE_STEP_TIMEOUT_MS
1296
+ );
1297
+ }),
1298
+ ]);
1299
+ return { available: true, value };
1300
+ } catch (e) {
1301
+ return { available: false, reason: e && (e as Error).message ? (e as Error).message : String(e) };
1302
+ } finally {
1303
+ clearTimeout(timer);
1304
+ }
1305
+ }
1306
+
1307
+ /**
1308
+ * Assembles criterion-4's evidence set for an incident record: one cycle
1309
+ * bracket (runCycleBracket(), plan 01.3-02 -- NEVER a second bracket
1310
+ * definition), the program counter and full register snapshot, the full
1311
+ * checkpoint enumeration (address, enabled flag, stop-or-continue), the
1312
+ * resolved live IRQ handler (resolveLiveIrqHandler(), plan 01.3-02), and a
1313
+ * screenshot written to a path in the incidents directory sharing the
1314
+ * record's own stem. Every step goes through captureStep() above, so no
1315
+ * step can abort the gather.
1316
+ *
1317
+ * `at`/`port`/`epoch` name the SAME triple the caller passes to
1318
+ * writeIncidentRecord(), so the screenshot's path shares that record's stem
1319
+ * (best-effort: the very rare case of a same-millisecond/port/epoch
1320
+ * collision forcing writeIncidentRecord() to append a numeric suffix onto
1321
+ * the actual .md file is not reflected here, since this path is computed
1322
+ * BEFORE that write happens).
1323
+ */
1324
+ async function gatherWedgeEvidence({ at, port, epoch }: IncidentAssetStemOptions): Promise<IncidentEvidence> {
1325
+ const bracket = await captureStep(() => runCycleBracket());
1326
+ const registers = await captureStep(() => call("vice_registers_get", {}));
1327
+ const checkpoints = await captureStep(async () => {
1328
+ const result = await call("vice_checkpoint_list", {});
1329
+ const list: CheckpointInfo[] =
1330
+ isPlainObject(result) && Array.isArray(result.checkpoints) ? (result.checkpoints as CheckpointInfo[]) : [];
1331
+ return list.map((c) => ({
1332
+ checkpoint_num: c && c.checkpoint_num,
1333
+ address: formatAddress(toAddressNumber(c && c.start)),
1334
+ enabled: Boolean(c && c.enabled !== false),
1335
+ flag: c && c.stop ? "stop" : "continue",
1336
+ }));
1337
+ });
1338
+ const irqHandler = await captureStep(() => resolveLiveIrqHandler());
1339
+
1340
+ // The screenshot's path argument must be translated (T-01.3-11's sibling
1341
+ // concern): handleToolsCall() applies rewriteArguments() before
1342
+ // forwarding, and this proxy-local caller does NOT pass through that seam
1343
+ // -- so it is called explicitly here. Skipping this would write the file
1344
+ // to a host path that does not exist and return a success the record
1345
+ // would then be lying about.
1346
+ const screenshotContainerPath = incidentAssetPath({ at, port, epoch, ext: "png" });
1347
+ const screenshot = await captureStep(async () => {
1348
+ const { args: translated } = rewriteArguments({ path: screenshotContainerPath }, "vice_display_screenshot");
1349
+ await call("vice_display_screenshot", translated);
1350
+ return relative(repoRoot(), screenshotContainerPath);
1351
+ });
1352
+
1353
+ return { bracket, registers, checkpoints, irqHandler, screenshot };
1354
+ }
1355
+
1356
+ /**
1357
+ * The best-effort pre-kill snapshot (plan 01.3-03 task 2, D-19): the LAST
1358
+ * capture step, run immediately before the incident record is written. It
1359
+ * takes a NAME, not a path -- vice_snapshot_save's own contract -- so the
1360
+ * file lands in the host emulator's own snapshot directory and nothing
1361
+ * container-side can confirm it landed there. The record therefore says
1362
+ * the ATTEMPT was accepted, never that a file was verified (T-01.3-11): the
1363
+ * wording must not overstate what was established. A rejection, a
1364
+ * transport failure or a capture-step deadline records unavailable with
1365
+ * the reason verbatim and moves on -- it cannot fail or stall the recycle.
1366
+ * The name is built from the SAME timestamp/port/epoch triple the incident
1367
+ * record's own stem uses, so the two artifacts are trivially correlated
1368
+ * later.
1369
+ */
1370
+ async function captureSnapshotAttempt({
1371
+ at,
1372
+ port,
1373
+ epoch,
1374
+ }: IncidentAssetStemOptions): Promise<CaptureStepResult<{ name: string }>> {
1375
+ const name = incidentAssetStem({ at, port, epoch });
1376
+ return captureStep(async () => {
1377
+ await call("vice_snapshot_save", { name, description: "vice_recycle pre-kill evidence capture" });
1378
+ return { name };
1379
+ });
1380
+ }
1381
+
1382
+ // --------------------------------------------------- unreachable diagnostics
1383
+ //
1384
+ // Plan 01.1-03 task 2 / ROADMAP criterion 7. Blocking on withReconnect()'s
1385
+ // ~50s ladder turns a clear diagnosis into an opaque tool timeout, so every
1386
+ // forwarded tools/call gets a pre-flight `probeInstance()` check FIRST (one
1387
+ // 1500ms-budget round trip, no retry -- see vice-probe.ts's own header for
1388
+ // why reusing the resilient ladder here would be wrong). When the probe
1389
+ // reports the emulator unreachable, this classifies the failure into exactly
1390
+ // one of three states, each with its own message and its own fix, each
1391
+ // quoting an absolute host path, each closing off the "just run the
1392
+ // transport module from a shell instead" workaround explicitly.
1393
+ //
1394
+ // This MCP tool surface is the only route to the emulator -- never named
1395
+ // together with a CLI verb here, since plan 01.1-04 installs a durable gate
1396
+ // matching exactly that pattern in documentation.
1397
+ const ONLY_ROUTE_NOTE =
1398
+ "This MCP tool surface is the only route to the emulator. The correct action is to stop and ask " +
1399
+ "the human to start it on the host -- falling back to a direct shell invocation of the underlying " +
1400
+ "transport is not an available workaround.";
1401
+
1402
+ // supervisorHostPath() (the per-instance-supervisor host-path helper) is
1403
+ // GONE, not merely unused (01.6.2-09, T-01.6.2-54/T-01.6.2-59): its three
1404
+ // former consumers below -- neverStartedMessage(), deadOrHungMessage() and
1405
+ // aliveButFailedMessage() -- now resolve brokerHostPath() instead, the SAME
1406
+ // single helper the broker-absent triple already used. There is exactly one
1407
+ // host-path helper left in this file (a structural test in
1408
+ // vice-proxy.test.ts asserts that directly: the resolved path's basename
1409
+ // equals the surviving launcher's filename).
1410
+
1411
+ /** The absolute path of the command a human should run on the HOST to
1412
+ * start/restart access to the emulator -- computed via hostPath() over the
1413
+ * deployed launcher's container path, degrading to the container path plus
1414
+ * SET_ENV_HINT exactly as install-resources.ts's hostLaunchInstructions()
1415
+ * does, so a translation failure still yields something to act on rather
1416
+ * than an empty message. Recomputed fresh every call -- never cached (see
1417
+ * the never-cache-a-negative-result invariant above ensureViceSession()).
1418
+ * Points at resources/vice-launcher.sh's deployed copy -- the one surviving
1419
+ * host script (01.6.2-09). Every message in this file that used to name
1420
+ * either the retiring per-instance supervisor (vice-supervisor.sh) or the
1421
+ * retiring bash broker (vice-broker.sh) now names THIS launcher instead: its
1422
+ * own broker performs both the acquire-on-demand job the bash broker did and
1423
+ * the launch/supervise/respawn-with-backoff job the bash supervisor did. */
1424
+ function brokerHostPath(): string {
1425
+ const root = repoRoot();
1426
+ const target = join(root, "tools", "vice-launcher.sh");
1427
+ try {
1428
+ return hostPath(target, { workspaceRoot: root });
1429
+ } catch {
1430
+ return `${target}\n (host path could not be determined -- ${SET_ENV_HINT})`;
1431
+ }
1432
+ }
1433
+
1434
+ // ------------------------------------------------- broker-absent diagnostics
1435
+ //
1436
+ // Plan 01.2-03 task 1 / must_have C10. A missing broker answers exactly one
1437
+ // generic message two times out of three sends the reader to the wrong fix
1438
+ // -- mirrors the host-unreachable triple above (never-started /
1439
+ // dead-or-hung / alive-but-failed), but answers a DIFFERENT question ("is
1440
+ // the on-demand broker itself reachable" vs "is the host VICE MCP server
1441
+ // reachable"), so both triples stay in place side by side, not one
1442
+ // replacing the other. Every message here quotes brokerHostPath() (an
1443
+ // absolute HOST path, recomputed fresh -- see that function's own comment)
1444
+ // and the single shared ONLY_ROUTE_NOTE definition; no message below writes
1445
+ // its own second only-route sentence. As of 01.6.2-09, the host-unreachable
1446
+ // triple below quotes the exact same brokerHostPath() helper -- there is
1447
+ // only one surviving launcher left to name, so both triples now resolve
1448
+ // identically rather than two different paths.
1449
+
1450
+ /** State: readBrokerLiveness() found no broker.json at all -- the broker has
1451
+ * never been started on this host. Nothing on the other side would ever
1452
+ * read a request, so ensureBrokerLease() returns this BEFORE writing one. */
1453
+ function brokerNeverStartedMessage(): string {
1454
+ return (
1455
+ `vice: the on-demand VICE broker has never been started on this host -- no broker.json ` +
1456
+ `record exists at all. Start it on the host with:\n` +
1457
+ ` ${brokerHostPath()}\n` +
1458
+ ONLY_ROUTE_NOTE
1459
+ );
1460
+ }
1461
+
1462
+ /** State: broker.json exists but its heartbeat is older than the stale
1463
+ * threshold -- the broker process is dead or hung. Quotes the recorded pid
1464
+ * (readBrokerLiveness()'s own field), since checking that pid is the first
1465
+ * thing a human does on the host, mirroring deadOrHungMessage() above. */
1466
+ function brokerDeadOrHungMessage(liveness: BrokerLivenessResult): string {
1467
+ const pidNote = liveness && liveness.pid != null ? ` (pid ${liveness.pid})` : "";
1468
+ return (
1469
+ `vice: the on-demand VICE broker appears to be dead or hung${pidNote} -- its last recorded ` +
1470
+ `heartbeat is older than the stale threshold. Restart it on the host with:\n` +
1471
+ ` ${brokerHostPath()}\n` +
1472
+ ONLY_ROUTE_NOTE
1473
+ );
1474
+ }
1475
+
1476
+ /** State: the broker is alive and a request was polled, but it wrote a
1477
+ * denial rather than a grant. Relays the denial's own `reason` field
1478
+ * VERBATIM -- never paraphrased -- and deliberately carries no RESTART
1479
+ * instruction, for the same reason aliveButFailedMessage() above carries
1480
+ * none: restarting something that is answering correctly is the wrong fix.
1481
+ * Still names an absolute path (the running broker's own launcher, purely
1482
+ * as a reference, mirroring aliveButFailedMessage()'s `hostRef` note) and
1483
+ * the only-route sentence, both required of every broker-absent-adjacent
1484
+ * message this proxy emits. */
1485
+ function brokerLaunchFailedMessage(reason: string): string {
1486
+ const hostRef = brokerHostPath().split("\n")[0];
1487
+ return (
1488
+ `vice: the on-demand VICE broker (running via the host-side launcher at ${hostRef}) declined ` +
1489
+ `to grant an instance for this session: ${reason} ${ONLY_ROUTE_NOTE}`
1490
+ );
1491
+ }
1492
+
1493
+ /** State: the broker is alive and a request was written, but neither a
1494
+ * grant nor a denial appeared before pollGrant()'s own deadline -- an
1495
+ * explicit warming-and-retry result, never a silent hang. A cold x64sc
1496
+ * launch plus boot plus readiness is seconds (spike-findings-bruce-lee
1497
+ * skill), well inside the client's own per-server timeout (.mcp.json's
1498
+ * `timeout` field, task 2), so the correct next action is simply to retry
1499
+ * the SAME call, not to treat this as a failure requiring a different fix. */
1500
+ function brokerWarmingMessage(elapsedMs: number): string {
1501
+ return (
1502
+ `vice: the on-demand VICE broker is still warming up an instance for this session -- no ` +
1503
+ `grant or denial appeared within ${elapsedMs}ms. This is expected for a cold start; retry the same ` +
1504
+ `call now, it should succeed once the instance finishes booting.`
1505
+ );
1506
+ }
1507
+
1508
+ /** State: readBrokerLiveness() just classified broker.json as `alive` (a
1509
+ * FRESH heartbeat), yet openBrokerControl() still failed -- a control-plane
1510
+ * CONNECTIVITY failure, never a dead or hung broker. This is the fix for
1511
+ * the exact incident recorded in
1512
+ * .planning/todos/pending/2026-08-04-proxy-reports-a-live-broker-as-stale-blocking-all-emulator-access.md:
1513
+ * `broker.json` is read from the shared filesystem, not over the control
1514
+ * connection, so the freshness computation had a perfectly good timestamp
1515
+ * and would have returned `alive` -- the failure was one layer later, at
1516
+ * the connect (dialing `0.0.0.0`, the broker's own BIND address, from
1517
+ * inside this container). Reporting that connect failure with the
1518
+ * heartbeat/stale-threshold wording sent the reader chasing a threshold
1519
+ * that was never exceeded, costing that session roughly a dozen tool
1520
+ * calls. This message names the address and port instead: from
1521
+ * `opened.target` when the outcome resolved one (every connect-adjacent
1522
+ * failure kind sets it), degrading to the outcome's own `message` for a
1523
+ * kind that never got that far (missing broker.json fields). States
1524
+ * plainly that `broker.json`'s own `control_host` field is the broker's
1525
+ * BIND address -- valid on the host where the broker wrote it, structurally
1526
+ * undialable from inside this container -- so a reader is pointed at the
1527
+ * connectivity problem, never at broker health. Carries NO secret: not
1528
+ * `control_token`, not any other field of the record, only the resolved
1529
+ * target and the fixed prose below. Follows the broker-absent family's own
1530
+ * stated conventions (quotes `brokerHostPath()` purely as a reference, the
1531
+ * shared `ONLY_ROUTE_NOTE`, never a second only-route sentence) -- mirroring
1532
+ * brokerLaunchFailedMessage() above rather than the never-started/
1533
+ * dead-or-hung pair, since (like a launch denial) the broker here is
1534
+ * alive and answering correctly; restarting it would be the wrong fix. */
1535
+ function brokerControlUnreachableMessage(opened: { kind: ControlFailureKind; message: string; target?: string }, liveness: BrokerLivenessResult): string {
1536
+ const pidNote = liveness && liveness.pid != null ? ` (pid ${liveness.pid})` : "";
1537
+ const hostRef = brokerHostPath().split("\n")[0];
1538
+ const targetNote = opened.target ?? opened.message;
1539
+ return (
1540
+ `vice: the on-demand VICE broker${pidNote} (running via the host-side launcher at ${hostRef}) has ` +
1541
+ `a fresh, healthy heartbeat -- this is NOT a dead or hung broker. This MCP tool surface could not ` +
1542
+ `reach the control plane at ${targetNote}. broker.json's own control_host field records the broker's BIND ` +
1543
+ `address, valid on the host where the broker wrote it and structurally undialable from inside this ` +
1544
+ `container -- a control-plane connectivity failure, not a broker health problem. ${ONLY_ROUTE_NOTE}`
1545
+ );
1546
+ }
1547
+
1548
+ // removeRequestFile() (requests/<id>.json cleanup on a denial or a warming
1549
+ // timeout) is GONE, not merely unused -- its subject directory ceases to
1550
+ // exist under the control-plane acquisition below. There is nothing left to
1551
+ // clean up on a denial or a timeout because nothing was ever written: a
1552
+ // failed acquire() over the control connection leaves no file anywhere, so
1553
+ // the "orphan request the sweeper must reap" problem this helper solved
1554
+ // does not exist in this design.
1555
+
1556
+ // A causeCode-shaped reason string (e.g. "ECONNREFUSED", "ECONNRESET") is
1557
+ // exactly what probeInstance() returns for a connection actively refused --
1558
+ // see its own fallback `causeCode || e.message`. A timeout, an HTTP error
1559
+ // status, or "didn't decode to a recognisable ping" all produce prose
1560
+ // instead, never a bare all-caps E-code, which is what keeps this predicate
1561
+ // precise rather than a loose substring guess.
1562
+ function isConnectionRefusedReason(reason: unknown): boolean {
1563
+ return typeof reason === "string" && /^E[A-Z]+$/.test(reason);
1564
+ }
1565
+
1566
+ function neverStartedMessage(probe: ProbeResult): string {
1567
+ return (
1568
+ `vice: the host VICE MCP server has never been started at this configured path -- no ` +
1569
+ `restart-epoch record exists, and the connection was refused (${probe.reason}). Start it on the host with:\n` +
1570
+ ` ${brokerHostPath()}\n` +
1571
+ ONLY_ROUTE_NOTE
1572
+ );
1573
+ }
1574
+
1575
+ function deadOrHungMessage(probe: ProbeResult, epoch: EpochResult): string {
1576
+ const pidNote =
1577
+ epoch && epoch.present && epoch.pid != null
1578
+ ? ` (pid ${epoch.pid}${epoch.spawned_at ? `, spawned_at ${epoch.spawned_at}` : ""})`
1579
+ : "";
1580
+ return (
1581
+ `vice: the host VICE MCP server appears to be dead or hung${pidNote} -- ${probe.reason}. ` +
1582
+ `Restart it on the host with:\n` +
1583
+ ` ${brokerHostPath()}\n` +
1584
+ ONLY_ROUTE_NOTE
1585
+ );
1586
+ }
1587
+
1588
+ /** Reached only when the pre-flight probe found the host alive but the
1589
+ * forwarded call itself failed (a transport error the retry ladder gave up
1590
+ * on, or a genuine RPC error). Relays the host's own message VERBATIM --
1591
+ * never paraphrased -- and deliberately carries no restart instruction,
1592
+ * since restarting a live, correctly-answering host is the wrong fix for a
1593
+ * rejected tool call. Still names an absolute path and the only-route note
1594
+ * (both required of every unreachable-adjacent message this proxy emits),
1595
+ * worded so as never to suggest the action a restart message would. */
1596
+ function aliveButFailedMessage(errMessage: string): string {
1597
+ const hostRef = brokerHostPath().split("\n")[0];
1598
+ return (
1599
+ `vice: the host VICE MCP server (reachable via the host-side launcher at ${hostRef}) rejected ` +
1600
+ `this call: ${errMessage} ${ONLY_ROUTE_NOTE}`
1601
+ );
1602
+ }
1603
+
1604
+ // RESOLVED RESIDUAL (originally quick-260801-ccn task 3; re-examined by
1605
+ // 01.6.2-09, T-01.6.2-54/T-01.6.2-59): this comment used to record that
1606
+ // aliveButFailedMessage() above still named a SEPARATE per-instance
1607
+ // supervisor path even under a broker-granted session -- a genuine
1608
+ // mismatch, because two different launchers existed. That mismatch is
1609
+ // dissolved, not merely reworded: supervisorHostPath() is deleted, and
1610
+ // aliveButFailedMessage() now resolves the exact same brokerHostPath()
1611
+ // helper every other message in this file uses, so there is only ever one
1612
+ // launcher path to name, regardless of session type. What still holds,
1613
+ // unchanged, is the REASON this message carries no restart instruction: it
1614
+ // answers a different question from both the host-unreachable triple and
1615
+ // the broker-granted message below -- an instance that IS reachable and
1616
+ // answering rejected ONE call -- where no launcher is the fix and a
1617
+ // restart would be the wrong advice on either route (broker-granted or
1618
+ // fixed-port).
1619
+
1620
+ // ------------------------------------------- broker-granted unreachable diagnostics
1621
+ //
1622
+ // Quick task 260801-ccn task 3 (D-5) introduced ONE message here, distinct
1623
+ // from both the host-unreachable triple above and the broker-ABSENT triple
1624
+ // below, for a granted instance that stopped answering: report the fact and
1625
+ // tell a human to go investigate on the host.
1626
+ //
1627
+ // Plan 01.6.2-08 (D-13) turns that report-and-instruct message into a
1628
+ // replace-and-report: a granted instance not answering no longer waits for
1629
+ // a human -- it costs this session exactly one replacement acquisition,
1630
+ // made automatically, and the triggering call still fails LOUDLY (never a
1631
+ // silently substituted result) naming the replacement. See
1632
+ // handleGrantedInstanceUnreachable() and its own three message builders
1633
+ // (machineReplacedMessage()/replacementFailedMessage()/
1634
+ // sessionMustRestartMessage()) further down this file, right after
1635
+ // ensureBrokerLease() -- the function this diagnostic superseded is gone,
1636
+ // not merely unused: brokerHostPath()'s "go investigate on the host"
1637
+ // framing no longer applies once the proxy investigates (replaces) on its
1638
+ // own first.
1639
+
1640
+ // ------------------------------------------------------------ path rewriting
1641
+ //
1642
+ // Decision D-G (plan 01.1-03 task 3 / criterion 9): container->host path
1643
+ // translation moves from every caller's own discipline into this one seam,
1644
+ // which sees every forwarded call. The structural rule: any string argument
1645
+ // value beginning with "/" is an absolute filesystem path. One that resolves
1646
+ // inside the mounted workspace is rewritten to its host form via
1647
+ // hostPath() -- the host emulator can only ever be handed a HOST path,
1648
+ // since it runs on the host, not in this container. One that resolves
1649
+ // outside the workspace is refused outright, before any forwarding, because
1650
+ // a container path is never correct on the host: forwarding it untouched
1651
+ // can only produce a wrong answer with no error, which is exactly the
1652
+ // silent-failure class this criterion exists to eliminate.
1653
+ //
1654
+ // RELATIVE paths: resolved against the workspace root, but ONLY for the
1655
+ // arguments the tools manifest declares to BE paths.
1656
+ //
1657
+ // The original rule left every relative string byte-identical, on the
1658
+ // reasoning that "a relative-looking string is indistinguishable from a
1659
+ // non-path argument (a tool name, a hex address like "$0400", an arbitrary
1660
+ // label) without guessing". That reasoning was sound for a walker with no
1661
+ // schema, and it pointed callers at a SKILL.md "Paths" section for the
1662
+ // absolute-path requirement -- but that SKILL.md was deleted in db9eed3,
1663
+ // leaving the requirement stated nowhere. CLAUDE.md's surviving wording
1664
+ // ("pass container paths and let the tools handle the boundary") promises
1665
+ // the opposite, so callers reasonably passed "disks/foo.d64" and got a bare
1666
+ // "Failed to attach disk image" from the host, with nothing anywhere
1667
+ // indicating the path was the problem. That cost real session time.
1668
+ //
1669
+ // The premise is also no longer true. tools-manifest.json -- the same file
1670
+ // tools/list is served from -- types every argument, and exactly four
1671
+ // declare a path: vice_disk_attach.path, vice_autostart.path,
1672
+ // vice_display_screenshot.path and vice_symbols_load.path. Consulting it
1673
+ // removes the guessing the residual was protecting against: a relative
1674
+ // string in a DECLARED path argument is a path, full stop, and everything
1675
+ // else keeps the byte-identical pass-through unchanged.
1676
+ //
1677
+ // Resolution is against the workspace root, never process.cwd() -- the
1678
+ // proxy is one long-lived process serving the whole session, so its cwd is
1679
+ // meaningless to the caller. (hostpath.mjs:106 resolves against cwd for its
1680
+ // CLI's benefit; that branch is unreachable from here, and deliberately so.)
1681
+ //
1682
+ // STATED RESIDUAL, narrower than before: a relative string in an argument
1683
+ // the manifest does NOT declare as a path is still left byte-identical, and
1684
+ // so is a relative string nested inside an object or array. Both remain
1685
+ // indistinguishable from non-path data. A worktree caller also resolves
1686
+ // against the MAIN workspace root, not its worktree -- correct for the
1687
+ // read-only disk images this serves, and an absolute path still overrides.
1688
+ const PATH_REWRITE_MAX_DEPTH = 10; // bounded so pathological nesting is left alone rather than looping forever
1689
+
1690
+ class PathOutOfWorkspaceError extends Error {}
1691
+ class PathTranslationError extends Error {}
1692
+
1693
+ // The boundary check MUST run against a normalized path, never the raw
1694
+ // string. `startsWith(root)` on an unnormalized value is satisfied by any
1695
+ // string that merely begins with the root's characters, so a lexical `..`
1696
+ // sequence -- "/workspaces/bruce_lee/../../../etc/passwd" -- passes a raw
1697
+ // prefix test and is then handed to hostPath(), which does NOT refuse it:
1698
+ // when relative() normalizes to a leading "..", hostpath.mjs deliberately
1699
+ // falls through to generic mount-based translation instead of throwing (its
1700
+ // own comment says so, for the CLI's benefit). That makes THIS check the only
1701
+ // workspace boundary on the forwarding path, so it has to be the strict one.
1702
+ //
1703
+ // resolve() collapses "." and ".." segments; callers only reach here after
1704
+ // value.startsWith("/") is confirmed, so it is pure normalization and never
1705
+ // pulls in process.cwd().
1706
+ //
1707
+ // STATED RESIDUAL: this is lexical, not physical -- a symlink inside the
1708
+ // workspace whose target lives outside it still translates. realpathSync()
1709
+ // would catch that but requires the file to already exist, which is wrong for
1710
+ // the write-side tools (snapshot_save and friends name a path that does not
1711
+ // exist yet). Lexical normalization is the part that can be enforced for both
1712
+ // directions without breaking writes.
1713
+ function isInsideWorkspace(absPath: string, root: string): boolean {
1714
+ return absPath === root || absPath.startsWith(root.endsWith("/") ? root : root + "/");
1715
+ }
1716
+
1717
+ /**
1718
+ * Recursively walk `value`, applying decision D-G's structural rule to
1719
+ * every string found. Objects and arrays are walked (bounded by
1720
+ * PATH_REWRITE_MAX_DEPTH); numbers, booleans, null, and non-absolute
1721
+ * strings are returned byte-identical. `argPath` accumulates a
1722
+ * human-readable position (e.g. "arguments.path" or "arguments.files[2]")
1723
+ * used in a refusal message so the caller can find exactly which argument
1724
+ * was the problem.
1725
+ */
1726
+ function rewritePathsIn(value: unknown, argPath: string, root: string, depth: number, asWritten?: string): unknown {
1727
+ if (depth > PATH_REWRITE_MAX_DEPTH) return value;
1728
+ if (typeof value === "string") {
1729
+ if (!value.startsWith("/")) return value; // the stated residual: undeclared relative strings untouched
1730
+ // Normalize FIRST, then check, then translate the normalized form -- so a
1731
+ // path that only looks like it is inside the workspace cannot slip through,
1732
+ // and the host is never handed a path still carrying ".." segments.
1733
+ const normalized = resolve(value);
1734
+ // `asWritten` is set only when rewriteArguments() already resolved a
1735
+ // declared-path argument from a relative string. Quoting the resolved
1736
+ // form alone would show the caller a path they never typed, so BOTH
1737
+ // failure branches below name what they wrote and what it became.
1738
+ const escapedRelative = asWritten !== undefined && asWritten !== value;
1739
+ if (!isInsideWorkspace(normalized, root)) {
1740
+ throw new PathOutOfWorkspaceError(
1741
+ (escapedRelative
1742
+ ? `vice: ${argPath} is the relative path "${asWritten}", which resolves to ${normalized} -- ` +
1743
+ `outside the mounted workspace (${root})`
1744
+ : `vice: ${argPath} is an absolute path (${value}) outside the mounted workspace (${root})` +
1745
+ (normalized === value ? "" : `; it resolves to ${normalized}`)) +
1746
+ `. The host emulator can only be handed paths that live inside the mounted workspace -- move the ` +
1747
+ `artifact inside the workspace and call again.`
1748
+ );
1749
+ }
1750
+ try {
1751
+ return hostPath(normalized, { workspaceRoot: root });
1752
+ } catch (e) {
1753
+ // Name what the CALLER wrote first, and the container path it became --
1754
+ // never lead with the host path. The caller reasons in container terms
1755
+ // and cannot act on a host-side location, so quoting only the resolved
1756
+ // form makes a fixable mistake look like an emulator fault.
1757
+ throw new PathTranslationError(
1758
+ `vice: ${argPath} ` +
1759
+ (escapedRelative ? `("${asWritten}", which resolves to ${normalized})` : `(${value})`) +
1760
+ ` could not be translated to a host path: ${(e as Error).message}\n ${SET_ENV_HINT}`
1761
+ );
1762
+ }
1763
+ }
1764
+ if (Array.isArray(value)) {
1765
+ return value.map((v, i) => rewritePathsIn(v, `${argPath}[${i}]`, root, depth + 1));
1766
+ }
1767
+ if (value && typeof value === "object") {
1768
+ const out: Record<string, unknown> = {};
1769
+ for (const [k, v] of Object.entries(value)) {
1770
+ out[k] = rewritePathsIn(v, `${argPath}.${k}`, root, depth + 1);
1771
+ }
1772
+ return out;
1773
+ }
1774
+ return value; // numbers, booleans, null -- byte-identical, never touched
1775
+ }
1776
+
1777
+ const NO_PATH_ARGS: Set<string> = new Set();
1778
+ let PATH_ARGS_BY_TOOL: Map<string, Set<string>> | null = null; // built once per process, from the manifest
1779
+
1780
+ /**
1781
+ * The set of argument names `toolName` declares to be filesystem paths,
1782
+ * read off tools-manifest.json -- the SAME file tools/list is served from,
1783
+ * so this can never become a second, drifting copy of "which arguments are
1784
+ * paths". An argument qualifies when it is declared `type: "string"` and
1785
+ * either is named exactly `path` or opens its description with "Path to" /
1786
+ * "File path" (both tests agree on all four current cases; either alone
1787
+ * would also suffice, and keeping both means a future manifest entry that
1788
+ * satisfies only one is still caught).
1789
+ *
1790
+ * Deliberately name/description-driven rather than a hardcoded tool list:
1791
+ * a manifest refresh that adds a path-taking tool gets the behaviour for
1792
+ * free, which a literal list here would silently miss.
1793
+ */
1794
+ function pathArgsFor(toolName: string): Set<string> {
1795
+ if (!PATH_ARGS_BY_TOOL) {
1796
+ PATH_ARGS_BY_TOOL = new Map();
1797
+ for (const t of readManifestTools()) {
1798
+ const props = isPlainObject(t.inputSchema) ? (t.inputSchema.properties as unknown) : undefined;
1799
+ if (!props || typeof props !== "object") continue;
1800
+ const names = new Set<string>();
1801
+ for (const [k, v] of Object.entries(props as Record<string, unknown>)) {
1802
+ if (!isPlainObject(v) || v.type !== "string") continue;
1803
+ if (k === "path" || /^(path|file path)\b/i.test((v.description as string) || "")) names.add(k);
1804
+ }
1805
+ if (names.size) PATH_ARGS_BY_TOOL.set(t.name, names);
1806
+ }
1807
+ }
1808
+ return PATH_ARGS_BY_TOOL.get(toolName) || NO_PATH_ARGS;
1809
+ }
1810
+
1811
+ /** One `arguments.<key>` resolved from a relative, manifest-declared path
1812
+ * argument to its absolute container form, before hostPath() translation --
1813
+ * the record resolutionNote() below renders for the agent. */
1814
+ interface PathResolution {
1815
+ arg: string;
1816
+ asWritten: string;
1817
+ container: string;
1818
+ }
1819
+
1820
+ /** Rewrite every in-workspace path inside `args` to its host form. A relative
1821
+ * string in a manifest-declared path argument is resolved against the
1822
+ * workspace root first; everything else keeps the byte-identical
1823
+ * pass-through. Throws PathOutOfWorkspaceError / PathTranslationError on the
1824
+ * two refusal cases above; the caller (handleToolsCall) converts either into
1825
+ * an isError:true result rather than letting it escape. */
1826
+ function rewriteArguments(
1827
+ args: Record<string, unknown> | undefined,
1828
+ toolName: string
1829
+ ): { args: Record<string, unknown>; resolutions: PathResolution[] } {
1830
+ const root = repoRoot();
1831
+ const pathArgs = pathArgsFor(toolName);
1832
+ const out: Record<string, unknown> = {};
1833
+ const resolutions: PathResolution[] = [];
1834
+ for (const [k, v] of Object.entries(args || {})) {
1835
+ // Only a top-level, declared-path, non-empty relative string is resolved.
1836
+ // Empty stays empty (resolve() would silently turn "" into the workspace
1837
+ // root, i.e. a directory, which is never what a caller meant).
1838
+ if (pathArgs.has(k) && typeof v === "string" && v !== "" && !v.startsWith("/")) {
1839
+ const container = resolve(root, v);
1840
+ out[k] = rewritePathsIn(container, `arguments.${k}`, root, 1, v);
1841
+ resolutions.push({ arg: k, asWritten: v, container });
1842
+ } else {
1843
+ out[k] = rewritePathsIn(v, `arguments.${k}`, root, 1);
1844
+ }
1845
+ }
1846
+ return { args: out, resolutions };
1847
+ }
1848
+
1849
+ /**
1850
+ * One line naming, in full, every relative path this call resolved -- so the
1851
+ * absolute path actually handed to the emulator is never something the caller
1852
+ * has to infer. Returned to the AGENT, not just stderr: the failure this
1853
+ * prevents ("Failed to attach disk image", with no indication which file was
1854
+ * even attempted) is one the agent has to diagnose, and it cost a real session
1855
+ * before the resolution existed at all. Empty string when nothing was resolved,
1856
+ * so a call that passed absolute paths reads exactly as it always did.
1857
+ */
1858
+ function resolutionNote(resolutions: PathResolution[] | undefined): string {
1859
+ if (!resolutions || !resolutions.length) return "";
1860
+ const parts = resolutions.map((r) => `${r.arg}: "${r.asWritten}" -> ${r.container}`);
1861
+ return `vice: resolved relative path${resolutions.length > 1 ? "s" : ""} against the workspace root -- ${parts.join("; ")}`;
1862
+ }
1863
+
1864
+ // ------------------------------------------------------- oversized results
1865
+ //
1866
+ // Decision D-E: the `_meta["anthropic/maxResultSizeChars"]` declaration
1867
+ // above raises the real limit far past the 25,000-token default, but a
1868
+ // second, proxy-side cap catches whatever still overruns it (a 64K RAM read
1869
+ // in any plausible encoding, per ROADMAP criterion 5). Nothing on this path
1870
+ // may silently shorten a payload -- there is no truncation branch. An
1871
+ // oversized result is split and served in full across an explicit
1872
+ // continuation sequence via one synthetic tool (`vice_result_continue`,
1873
+ // declared above), so the caller can always reassemble the whole payload.
1874
+ //
1875
+ // The store is bounded so a long session cannot grow it without limit: at
1876
+ // most MAX_CONTINUATIONS outstanding sequences, oldest evicted first (a
1877
+ // `Map` preserves insertion order, so its first key is always the oldest).
1878
+ // An evicted or exhausted token fails loudly with advice to narrow the
1879
+ // original call rather than resume it -- there is nothing left to resume.
1880
+ interface ContinuationEntry {
1881
+ chunks: string[];
1882
+ nextIndex: number;
1883
+ totalChunks: number;
1884
+ totalChars: number;
1885
+ }
1886
+
1887
+ const CONTINUATION_STORE: Map<string, ContinuationEntry> = new Map(); // token -> { chunks: string[], nextIndex: number, totalChunks: number, totalChars: number }
1888
+ const MAX_CONTINUATIONS = 5;
1889
+ let continuationCounter = 0;
1890
+
1891
+ function nextContinuationToken(): string {
1892
+ continuationCounter += 1;
1893
+ return `cont-${process.pid}-${Date.now()}-${continuationCounter}`;
1894
+ }
1895
+
1896
+ interface ChunkMarkerArgs {
1897
+ chunkIndex: number;
1898
+ totalChunks: number;
1899
+ totalChars: number;
1900
+ token: string;
1901
+ }
1902
+
1903
+ function chunkMarkerText({ chunkIndex, totalChunks, totalChars, token }: ChunkMarkerArgs): string {
1904
+ if (chunkIndex >= totalChunks) {
1905
+ return (
1906
+ `vice: chunk ${chunkIndex} of ${totalChunks} (last chunk) -- ${totalChars} total characters ` +
1907
+ `served across this continuation sequence.`
1908
+ );
1909
+ }
1910
+ return (
1911
+ `vice: chunk ${chunkIndex} of ${totalChunks} -- ${totalChars} total characters. Call ` +
1912
+ `vice_result_continue with arguments {"token":"${token}"} to retrieve the next chunk.`
1913
+ );
1914
+ }
1915
+
1916
+ /**
1917
+ * Wrap a successful call's serialised text, splitting it across a
1918
+ * continuation sequence if (and only if) it exceeds OUTPUT_CHAR_CAP. Under
1919
+ * the cap, behaves exactly as an unchunked result always has: a single
1920
+ * `content` item, nothing else appended. Over the cap, the FIRST content
1921
+ * item is the pure payload chunk -- byte-for-byte, no marker text mixed in,
1922
+ * so reassembly is a plain concatenation -- and a SECOND content item
1923
+ * carries the marker, naming the exact next call to make.
1924
+ */
1925
+ function wrapPossiblyChunked(text: string): OkTextResult {
1926
+ if (text.length <= OUTPUT_CHAR_CAP) {
1927
+ return { content: [{ type: "text", text }], isError: false };
1928
+ }
1929
+
1930
+ const totalChars = text.length;
1931
+ const pieces: string[] = [];
1932
+ for (let i = 0; i < text.length; i += OUTPUT_CHAR_CAP) {
1933
+ pieces.push(text.slice(i, i + OUTPUT_CHAR_CAP));
1934
+ }
1935
+ const totalChunks = pieces.length;
1936
+ const [first, ...remaining] = pieces;
1937
+
1938
+ const token = nextContinuationToken();
1939
+ while (CONTINUATION_STORE.size >= MAX_CONTINUATIONS) {
1940
+ const oldestToken = CONTINUATION_STORE.keys().next().value as string;
1941
+ CONTINUATION_STORE.delete(oldestToken);
1942
+ }
1943
+ CONTINUATION_STORE.set(token, { chunks: remaining, nextIndex: 2, totalChunks, totalChars });
1944
+
1945
+ return {
1946
+ content: [
1947
+ { type: "text", text: first },
1948
+ { type: "text", text: chunkMarkerText({ chunkIndex: 1, totalChunks, totalChars, token }) },
1949
+ ],
1950
+ isError: false,
1951
+ };
1952
+ }
1953
+
1954
+ /** Handles `vice_result_continue` -- served entirely inside this proxy;
1955
+ * NEVER reaches `call()` or the network. */
1956
+ function handleResultContinue(args: Record<string, unknown>): ToolCallResult {
1957
+ const token = args && typeof args.token === "string" ? args.token : null;
1958
+ if (!token || !CONTINUATION_STORE.has(token)) {
1959
+ return isErrorText(
1960
+ `vice: continuation token "${token}" is unknown or has already expired. Re-issue the ` +
1961
+ `original tools/call with a narrower range instead of resuming.`
1962
+ );
1963
+ }
1964
+ const entry = CONTINUATION_STORE.get(token) as ContinuationEntry;
1965
+ const chunk = entry.chunks.shift() as string;
1966
+ const chunkIndex = entry.nextIndex;
1967
+ entry.nextIndex += 1;
1968
+ const isLast = entry.chunks.length === 0;
1969
+ if (isLast) {
1970
+ CONTINUATION_STORE.delete(token);
1971
+ }
1972
+ return {
1973
+ content: [
1974
+ { type: "text", text: chunk },
1975
+ {
1976
+ type: "text",
1977
+ text: chunkMarkerText({ chunkIndex, totalChunks: entry.totalChunks, totalChars: entry.totalChars, token }),
1978
+ },
1979
+ ],
1980
+ isError: false,
1981
+ };
1982
+ }
1983
+
1984
+ // -------------------------------------------------------------- broker lease
1985
+ //
1986
+ // On-demand acquisition (Phase 01.2): deferred to the FIRST forwarded
1987
+ // tools/call, never to initialize/tools/list, matching the measured "spawn
1988
+ // is eager, acquisition must not be" finding (spike-findings-bruce-lee
1989
+ // skill, proxy-lifecycle-and-process-identity.md) -- a session that never
1990
+ // forwards a call never asks the broker for anything (C3).
1991
+ //
1992
+ // Plan 01.6.2-07: the lease is now the CONTROL CONNECTION itself, not a
1993
+ // file. controlSession holds the open BrokerControlSession (plan 06's
1994
+ // completed client) for this session's lifetime; grantId is the acquired
1995
+ // grant's own id -- the PRIMARY noun of the protocol carries over unchanged
1996
+ // (still promoted from port to request id, since ports are recycled across
1997
+ // sessions under on-demand launch), it is just no longer a filename. Both
1998
+ // null means either no session has been opened yet, or VICE_MCP_URL
1999
+ // overrides the broker entirely. There is no heartbeat timer any more --
2000
+ // nothing needs touching to prove a TCP connection is still alive; it
2001
+ // either is, or the broker's own "close" handler has already torn the
2002
+ // instance down.
2003
+ let controlSession: BrokerControlSession | null = null;
2004
+ let grantId: string | null = null;
2005
+
2006
+ // ----------------------------------------------------- grant containerization
2007
+ //
2008
+ // Quick task 260801-ccn (the inverse of Phase 01.1 criterion 9). The broker
2009
+ // runs on the HOST, legitimately resolves its own repo root, and writes a
2010
+ // grant carrying host-local coordinates: a loopback `url`, and
2011
+ // `epoch_file`/`supervisor_dir` paths rooted at the host's own checkout --
2012
+ // entirely correct from where the broker stands. Nothing inverted them
2013
+ // before this task: loopback meant the CONTAINER's own loopback
2014
+ // (ECONNREFUSED, since nothing listens there) and the host-rooted epoch
2015
+ // path simply never resolved, so every broker-granted instance was silently
2016
+ // unreachable. containerizeGrant() is the seam that fixes this -- called in
2017
+ // ensureBrokerLease() below between session.acquire() returning a grant and
2018
+ // useInstance() adopting it, since that is the LAST point before the
2019
+ // coordinates become the session's identity (D-1).
2020
+ function containerizeGrant(grant: Record<string, unknown>): Record<string, unknown> {
2021
+ const grantId = grant && typeof grant.id === "string" ? grant.id : "(no id)";
2022
+ const port = Number(grant && grant.port);
2023
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
2024
+ // T-mef-01's rule, reused here: nothing downstream can be trusted
2025
+ // without a validated port, so no translation is even attempted --
2026
+ // useInstance() fails on its own terms, exactly as it would have before
2027
+ // this function existed.
2028
+ console.error(
2029
+ `vice-proxy: containerizeGrant ${grantId}: grant.port (${grant && grant.port}) is not a valid integer ` +
2030
+ `port -- skipping translation entirely.`
2031
+ );
2032
+ return grant;
2033
+ }
2034
+
2035
+ const alias = mcpHost();
2036
+ // containerizeRecord() (containerpath.ts) does the translation itself:
2037
+ // `url` through the loopback-rewrite (D-4), `epoch_file`/`supervisor_dir`
2038
+ // through the host->container path inverse (D-2 -- all three fields). An
2039
+ // already container-shaped record (every pre-existing broker test's
2040
+ // tmpdir-rooted VICE_POOL_DIR) matches no known host root and comes back
2041
+ // byte-identical -- D-7's whole point.
2042
+ const { record, changes } = containerizeRecord(grant, {
2043
+ pathFields: ["epoch_file", "supervisor_dir"],
2044
+ urlFields: ["url"],
2045
+ alias,
2046
+ });
2047
+
2048
+ // Safety net (T-ccn-01, T-ccn-02), mirroring the outbound seam's own
2049
+ // posture: never open/connect to an unvalidated string read out of a
2050
+ // grant file. On either failure below, substitute the coordinate DERIVED
2051
+ // FROM THE VALIDATED PORT instead (instanceFor()'s own T-mef-01 rule,
2052
+ // reused here) and report the substitution -- never silently.
2053
+ const root = repoRoot();
2054
+ const fallbackDir = join(brokerRootDir(), String(port));
2055
+ const fallbackEpochFile = join(fallbackDir, "epoch.json");
2056
+ const fallbackUrl = `http://${alias}:${port}/mcp`;
2057
+ const changedFields = new Set(changes.map((c) => c.field));
2058
+ const substituted: Record<string, boolean> = { url: false, epoch_file: false, supervisor_dir: false };
2059
+
2060
+ // T-ccn-01: only a field that was ACTUALLY TRANSLATED (its host root
2061
+ // matched) is re-checked for workspace containment -- an already
2062
+ // container-shaped path was never translated at all (D-7's passthrough)
2063
+ // and is trusted exactly as every pre-existing broker test already relies
2064
+ // on. A translated path escaping the workspace (a lexical ".." sequence
2065
+ // in the grant's own host-rooted field) is exactly what this check
2066
+ // catches.
2067
+ if (changedFields.has("epoch_file") && !isInsideWorkspace(resolve(record.epoch_file as string), root)) {
2068
+ record.epoch_file = fallbackEpochFile;
2069
+ substituted.epoch_file = true;
2070
+ }
2071
+ if (changedFields.has("supervisor_dir") && !isInsideWorkspace(resolve(record.supervisor_dir as string), root)) {
2072
+ record.supervisor_dir = fallbackDir;
2073
+ substituted.supervisor_dir = true;
2074
+ }
2075
+
2076
+ // T-ccn-02: the FINAL url's port must equal the validated grant port,
2077
+ // checked UNCONDITIONALLY (translated or not) -- a grant could simply
2078
+ // declare a mismatched port from the start, translation aside, and that
2079
+ // is exactly the spoofing shape this check exists to catch.
2080
+ let urlPortOk = false;
2081
+ if (typeof record.url === "string") {
2082
+ try {
2083
+ urlPortOk = Number(new URL(record.url).port) === port;
2084
+ } catch {
2085
+ urlPortOk = false;
2086
+ }
2087
+ }
2088
+ if (!urlPortOk) {
2089
+ record.url = fallbackUrl;
2090
+ substituted.url = true;
2091
+ }
2092
+
2093
+ // Exactly ONE stderr line, naming every field's before/after (or
2094
+ // "unchanged") -- this is the signal whose absence made the original bug
2095
+ // invisible; it must never become a line per field (D-2's own reporting
2096
+ // requirement).
2097
+ const parts = ["url", "epoch_file", "supervisor_dir"].map((field) => {
2098
+ const original = grant ? grant[field] : undefined;
2099
+ const final = record[field];
2100
+ if (substituted[field]) {
2101
+ return `${field}: SUBSTITUTED ${JSON.stringify(original)} -> ${JSON.stringify(final)} (port-derived fallback)`;
2102
+ }
2103
+ if (final === original) {
2104
+ return `${field}: unchanged (${JSON.stringify(final)})`;
2105
+ }
2106
+ return `${field}: ${JSON.stringify(original)} -> ${JSON.stringify(final)}`;
2107
+ });
2108
+ console.error(`vice-proxy: containerized grant ${grantId} -- ${parts.join("; ")}`);
2109
+
2110
+ return record;
2111
+ }
2112
+
2113
+ /**
2114
+ * Acquire a broker-granted instance for this session, once. Returns
2115
+ * immediately (no broker traffic at all) when a session is already held,
2116
+ * and immediately when VICE_MCP_URL is set -- an explicit endpoint override
2117
+ * means the caller already chose an instance, which is both the principled
2118
+ * rule and what keeps every pre-existing proxy test passing with no edit.
2119
+ *
2120
+ * Plan 01.6.2-07: the lease is now the CONTROL CONNECTION itself. The prior
2121
+ * ordering constraint here -- create a lease file BEFORE awaiting the grant,
2122
+ * because the host's own sweep tore a grant down whenever its lease file was
2123
+ * absent -- DISSOLVES entirely under this design: the connection is open,
2124
+ * and is therefore already the proof this session holds a claim, before the
2125
+ * acquire request is even sent. There is no window between "a grant exists"
2126
+ * and "a lease exists" for a sweep to land in, because there is no longer a
2127
+ * second artifact for the two to disagree about. D-09's shutdown-deletion
2128
+ * question dissolves the same way, for the same reason: under D-01 the
2129
+ * request/lease/grant/denial directories never exist at all, so there is
2130
+ * nothing to delete on shutdown and nothing to reconcile -- a reader meeting
2131
+ * that earlier decision needs to know it no longer applies, not that it was
2132
+ * quietly dropped.
2133
+ */
2134
+ type BrokerLeaseResult = { ok: true } | { ok: false; message: string };
2135
+
2136
+ async function ensureBrokerLease(): Promise<BrokerLeaseResult> {
2137
+ if (controlSession) return { ok: true };
2138
+ if (process.env.VICE_MCP_URL) return { ok: true }; // explicit override -- broker never contacted
2139
+
2140
+ // Classify liveness FIRST, before ever opening a connection (C10).
2141
+ // never_started and stale both return their message immediately, with no
2142
+ // connection attempted -- there is nothing on the other side to answer
2143
+ // one, so attempting it would only delay the diagnosis. readBrokerLiveness()
2144
+ // re-reads broker.json fresh on every call (see its own implementation in
2145
+ // vice-broker-client.ts); nothing here memoises the verdict, so this is the
2146
+ // broker-path instance of the same never-cache-a-negative-result invariant
2147
+ // the comment above ensureViceSession() already states for the host path --
2148
+ // the call after a human starts the broker just works, with no session
2149
+ // restart required. openBrokerControl() performs this SAME classification
2150
+ // again internally (over its own read of broker.json) before it ever
2151
+ // connects -- a second, independent read, not a second answer to trust
2152
+ // instead of this one; fetching liveness here first is what gives the
2153
+ // diagnoses below (dead-or-hung's own pid) something to quote.
2154
+ const liveness = readBrokerLiveness();
2155
+ if (liveness.state === "never_started") {
2156
+ return { ok: false, message: brokerNeverStartedMessage() };
2157
+ }
2158
+ if (liveness.state === "stale") {
2159
+ return { ok: false, message: brokerDeadOrHungMessage(liveness) };
2160
+ }
2161
+
2162
+ const acquireStartedAt = Date.now();
2163
+ const opened = await openBrokerControl();
2164
+ if (!opened.ok) {
2165
+ // openBrokerControl() re-classifies liveness from its OWN read of
2166
+ // broker.json before ever connecting -- never_started/stale here means
2167
+ // that SECOND read found a genuine race (the broker died between the
2168
+ // classification above and this one), so both route to their usual two
2169
+ // messages, unchanged. EVERY other kind (unreachable_control_plane,
2170
+ // connect_refused, protocol, broker_gone, ...) is reached only when that
2171
+ // second read agreed the broker is alive -- reading those as
2172
+ // dead-or-hung was the exact mis-attribution this plan closes (see
2173
+ // brokerControlUnreachableMessage()'s own header comment for the full
2174
+ // incident record): a connect failure against a healthy heartbeat is a
2175
+ // control-plane CONNECTIVITY problem, not a broker liveness one, so it
2176
+ // gets its own message naming the address and port instead.
2177
+ if (opened.kind === "never_started") {
2178
+ return { ok: false, message: brokerNeverStartedMessage() };
2179
+ }
2180
+ if (opened.kind === "stale") {
2181
+ return { ok: false, message: brokerDeadOrHungMessage(liveness) };
2182
+ }
2183
+ return { ok: false, message: brokerControlUnreachableMessage(opened, liveness) };
2184
+ }
2185
+ const session = opened.session;
2186
+
2187
+ const result = await session.acquire();
2188
+ if (!result.ok) {
2189
+ // No grant is coming for this session -- nothing to hold the connection
2190
+ // open for. Closing it here is the control-plane's entire equivalent of
2191
+ // the old cleanup (releaseLease(id) + removeRequestFile(id)): there was
2192
+ // never a file to remove in the first place.
2193
+ await session.release();
2194
+ if (result.kind === "deadline") {
2195
+ return { ok: false, message: brokerWarmingMessage(Date.now() - acquireStartedAt) };
2196
+ }
2197
+ return { ok: false, message: brokerLaunchFailedMessage(result.message) };
2198
+ }
2199
+
2200
+ // adoptGrant() is the ONE seam that inverts the grant's host-local
2201
+ // coordinates (D-1, quick task 260801-ccn) and adopts them as this
2202
+ // session's active instance -- the LAST point before the coordinates
2203
+ // become the session's identity: the endpoint every later tool call is
2204
+ // sent to, and the path the epoch guard opens. Plan 08 (D-13) reuses this
2205
+ // EXACT function for a replacement acquisition too (see
2206
+ // handleGrantedInstanceUnreachable() below) -- one code path for adopting
2207
+ // an instance, never a second one for a replacement.
2208
+ adoptGrant({ ...result.grant });
2209
+ viceSession = null; // re-baseline: the next ensureViceSession() reads the GRANTED instance's own epoch file
2210
+ controlSession = session;
2211
+ return { ok: true };
2212
+ }
2213
+
2214
+ /**
2215
+ * The ONE adoption seam (D-13): containerize a grant's host-local
2216
+ * coordinates and adopt them as this session's active instance, recording
2217
+ * the grant id. Called by ensureBrokerLease() above for an ORDINARY
2218
+ * acquisition and by handleGrantedInstanceUnreachable() below for BOTH of
2219
+ * its replacement acquisitions (the same-session retry and the
2220
+ * fresh-session retry) -- never a second, parallel adoption path for a
2221
+ * replacement.
2222
+ */
2223
+ function adoptGrant(grant: Record<string, unknown>): void {
2224
+ grantId = typeof grant.id === "string" ? grant.id : null;
2225
+ const containerized = containerizeGrant({ ...grant });
2226
+ useInstance({
2227
+ port: containerized.port as number,
2228
+ url: containerized.url as string,
2229
+ epochFile: containerized.epoch_file as string,
2230
+ pooled: true,
2231
+ });
2232
+ }
2233
+
2234
+ // --------------------------------------- D-13/D-14: replace-and-report
2235
+ //
2236
+ // Plan 01.6.2-08. A granted instance's pre-flight probe failing used to
2237
+ // produce ONE report-and-instruct message (the retired
2238
+ // brokerGrantedUnreachableMessage(), see this file's earlier comment naming
2239
+ // where it lived) and stop there, leaving a human to go investigate on the
2240
+ // host. D-13 changes that into a replace-and-report: the proxy acquires a
2241
+ // replacement itself, immediately, over the SAME control session (only the
2242
+ // emulator instance is suspected dead here, not necessarily the connection
2243
+ // to the broker) -- but still fails the TRIGGERING call LOUDLY, naming the
2244
+ // replacement, rather than silently substituting a result read from a
2245
+ // machine the caller never asked for. A memory read served quietly against
2246
+ // a blank replacement returns zeroed RAM indistinguishable from real data,
2247
+ // which is exactly the hazard the epoch guard elsewhere in this file exists
2248
+ // to catch -- a notice buried inside an otherwise-successful payload is
2249
+ // easy to skim past, so this never returns one.
2250
+ //
2251
+ // D-14 is what happens when that SAME-session replacement attempt itself
2252
+ // discovers the connection is gone (kind "broker_gone"): an ACCEPTED,
2253
+ // KNOWING regression, recorded here rather than left to be rediscovered as
2254
+ // a defect. Before plan 07's transport swap, the only broker dependency
2255
+ // surviving past a grant was a file write (the retiring lease heartbeat)
2256
+ // whose failure was a silent no-op -- broker death was survivable by
2257
+ // construction, because nothing after the grant still needed the broker at
2258
+ // all. Under one held TCP connection, that is no longer true: recycle and
2259
+ // (as of this plan) replacement both need a live connection. That safety
2260
+ // margin is given up DELIBERATELY, per the tolerance decision recorded in
2261
+ // broker-control-plane-over-tcp.md -- the compensation is that a session is
2262
+ // told LOUDLY rather than left to quietly keep working against whatever a
2263
+ // still-reachable granted instance happens to answer, for as long as it
2264
+ // happens to stay reachable.
2265
+ //
2266
+ // Both D-13 and D-14 reuse the SAME machineReplacedMessage() builder (which
2267
+ // itself reuses epochDriftMessage(), the existing voided-run vocabulary the
2268
+ // fixed-port epoch-drift guard already carries) -- one vocabulary for a
2269
+ // voided run, never a second one paralleling it. Neither outcome is ever
2270
+ // cached: controlSession is deliberately left pointing at a known-dead
2271
+ // session on every failure branch below, so the NEXT call's own probe
2272
+ // failure repeats this exact same from-scratch attempt (a fresh
2273
+ // openBrokerControl() reads broker.json fresh every time, never memoised),
2274
+ // rather than short-circuiting on a remembered verdict -- see the
2275
+ // NEVER-CACHE-A-NEGATIVE-RESULT invariant above ensureViceSession().
2276
+
2277
+ /**
2278
+ * D-13/D-14's shared report text: a call was refused because the machine
2279
+ * behind it was REPLACED out from under it. Reuses epochDriftMessage() --
2280
+ * the SAME builder the fixed-port epoch-drift guard already uses -- for the
2281
+ * epoch-comparison sentence, rather than inventing a second wording for
2282
+ * "this is not the machine you had a moment ago" (D-13's own instruction:
2283
+ * no second notion of a voided run). States the three facts an agent needs,
2284
+ * literally: the machine was REPLACED, the replacement is FRESH, and prior
2285
+ * state on the old instance is GONE.
2286
+ *
2287
+ * 2026-08-05 defect fix, two parts, both kept INLINE here (not extracted to
2288
+ * a helper) so this function's own body still literally contains the
2289
+ * `epochDriftMessage(` call the structural test
2290
+ * ("the replaced-machine report is built from the existing voided-run
2291
+ * vocabulary") pins:
2292
+ *
2293
+ * 1. Epoch sentence -- three cases, not two. The old code called
2294
+ * epochDriftMessage() whenever BOTH epochs were merely present,
2295
+ * with no inequality check -- so an unmoved-but-both-present pair
2296
+ * (oldEpoch.epoch === newEpoch.epoch) rendered the literally false
2297
+ * "epoch changed from 1 to 1" (the exact sighting on file). Now:
2298
+ * both present AND different -> epochDriftMessage(), unchanged; both
2299
+ * present but EQUAL -> an honest "did not change" sentence (each
2300
+ * port's epoch file is an independent counter, so a coincidental
2301
+ * match is expected, not evidence of anything -- and a genuinely
2302
+ * reused port can still read stale-equal if the host had not yet
2303
+ * written its post-respawn bump at the moment this was sampled); not
2304
+ * both present -> unchanged from before, "could not both be
2305
+ * compared".
2306
+ * 2. Port sentence -- when oldPort === newPort (a real, legitimate
2307
+ * outcome in this broker's fixed-slot design: a "replacement" can
2308
+ * land back on the exact port it replaced), the OLD wording named
2309
+ * that single port number as both "the old instance (port X)" and
2310
+ * "the replacement instance (port X)" -- two different entities
2311
+ * sharing one label, which a reader cannot reconcile ("one port
2312
+ * cannot be both"). Now branches on whether the port actually
2313
+ * changed: same port says so plainly ("replaced in place"), rather
2314
+ * than implying two distinct ports that happen to print the same
2315
+ * digits.
2316
+ */
2317
+ function machineReplacedMessage(opts: {
2318
+ where: string;
2319
+ reason: string;
2320
+ oldPort: number;
2321
+ oldEpoch: EpochResult;
2322
+ newPort: number;
2323
+ newEpoch: EpochResult;
2324
+ }): string {
2325
+ const { where, reason, oldPort, oldEpoch, newPort, newEpoch } = opts;
2326
+ let driftSentence: string;
2327
+ if (oldEpoch.present && newEpoch.present) {
2328
+ driftSentence =
2329
+ oldEpoch.epoch !== newEpoch.epoch
2330
+ ? epochDriftMessage(where, oldEpoch, newEpoch)
2331
+ : `vice: the epoch recorded ${where} did not change (still ${oldEpoch.epoch}) -- this is NOT evidence ` +
2332
+ `the machine stayed the same: each port's epoch counter is independent, so a coincidental match is ` +
2333
+ `expected between two unrelated files, and a genuinely reused port can still read stale-equal if the ` +
2334
+ `host had not yet recorded its post-respawn bump at the moment this was sampled. Treat every result ` +
2335
+ `since the previous call as void and redo that work regardless -- the replacement itself (below) is ` +
2336
+ `the operative fact here, not this epoch read.`;
2337
+ } else {
2338
+ driftSentence =
2339
+ `vice: treat every result since the previous call as void and redo that work -- the old instance's ` +
2340
+ `epoch and the new instance's epoch could not both be compared ` +
2341
+ `(old epoch present: ${oldEpoch.present}, new epoch present: ${newEpoch.present}).`;
2342
+ }
2343
+ const portSentence =
2344
+ oldPort === newPort
2345
+ ? `The instance behind port ${oldPort} was REPLACED IN PLACE -- the process is a FRESH emulator (this ` +
2346
+ `broker's fixed-slot design can hand the replacement the SAME port back), and all prior state from ` +
2347
+ `before the replacement is GONE (${reason}).`
2348
+ : `The machine was REPLACED, the replacement is a FRESH emulator, and all prior state on the old ` +
2349
+ `instance (port ${oldPort}) is GONE (${reason}).`;
2350
+ return (
2351
+ `${driftSentence} ${portSentence} Make this call again -- it will run on the replacement instance ` +
2352
+ `(port ${newPort}), already acquired and adopted for this session.`
2353
+ );
2354
+ }
2355
+
2356
+ /** D-13: the replacement acquisition itself failed for a reason OTHER than
2357
+ * the broker connection being gone (denied, no_free_port, at_capacity, its
2358
+ * own deadline, ...). Names both failures -- the original unreachability
2359
+ * and the failed replacement -- and does not retry: a retry loop against a
2360
+ * broker that cannot currently grant is how one failure becomes a hang. */
2361
+ function replacementFailedMessage(probe: ProbeResult, failure: { kind: string; message: string }): string {
2362
+ return (
2363
+ `vice: retry this call yourself once the underlying problem is fixed -- no further replacement will ` +
2364
+ `be attempted automatically. The granted instance stopped answering (${probe.reason}), and a ` +
2365
+ `same-session replacement attempt also failed (${failure.kind}: ${failure.message}).`
2366
+ );
2367
+ }
2368
+
2369
+ /** D-14: the broker connection is gone and a fresh one could not be opened
2370
+ * either (or could be opened but could not itself acquire) -- there is
2371
+ * nothing left this proxy can do on its own. Names the broker, not this
2372
+ * proxy, as the cause, and states plainly that no further call in THIS
2373
+ * session can succeed until it is running again. */
2374
+ function sessionMustRestartMessage(failure: { kind: string; message: string }): string {
2375
+ return (
2376
+ `vice: this session must be restarted -- no further call in this session can succeed until the ` +
2377
+ `broker is running again. The on-demand VICE broker connection is gone and a fresh session could ` +
2378
+ `not be opened (${failure.kind}: ${failure.message}). The broker itself is the cause.`
2379
+ );
2380
+ }
2381
+
2382
+ /**
2383
+ * D-13/D-14's entry point, reached only when the pre-flight probe found the
2384
+ * session's granted instance unreachable AND a control session is held.
2385
+ * Exactly one same-session replacement attempt, then (only if THAT attempt
2386
+ * discovers the connection itself is gone) exactly one fresh-session
2387
+ * attempt -- never a loop, never more than these two acquisitions for one
2388
+ * triggering call. Always returns a report string; never a result, even on
2389
+ * the success paths -- see this section's own header comment for why.
2390
+ */
2391
+ async function handleGrantedInstanceUnreachable(probe: ProbeResult, oldEpoch: EpochResult): Promise<string> {
2392
+ const { port: oldPort } = activeInstance();
2393
+ const session = controlSession as BrokerControlSession;
2394
+
2395
+ // Attempt 1: a replacement over the SAME session (D-13). Only the granted
2396
+ // EMULATOR is suspected dead here -- the connection to the broker may
2397
+ // still be perfectly good, and reusing it is the whole point of "costs
2398
+ // one acquisition, not the session."
2399
+ //
2400
+ // Gap closure (plan 14, WR-03 / T-01.6.2-90): release the grant this
2401
+ // session currently holds BEFORE acquiring its replacement -- two
2402
+ // independent reasons, both load-bearing, and order matters for both.
2403
+ //
2404
+ // Reason one: releasing frees the OLD instance's port and capacity slot
2405
+ // FIRST, before the acquire below ever asks for one -- a broker already
2406
+ // sitting at its instance ceiling can still serve this replacement, where
2407
+ // acquiring first could not.
2408
+ //
2409
+ // Reason two, the actual leak this closes: grantId (this proxy's own
2410
+ // module-level grant slot, declared above) is a SINGLE value --
2411
+ // adoptGrant() below simply overwrites it. Acquiring a replacement
2412
+ // without first releasing what is about to be overwritten is what
2413
+ // abandons the prior grant: the broker goes on holding an instance this
2414
+ // proxy no longer remembers asking to release, and every further
2415
+ // lost-machine event on this same session leaks one more, compounding
2416
+ // toward the instance ceiling. No new control-plane message is needed to
2417
+ // close this -- the existing release request already releases exactly
2418
+ // the grant THIS connection currently holds (no target id on the wire at
2419
+ // all), which is precisely the right one, provided it lands before the
2420
+ // slot below is overwritten.
2421
+ //
2422
+ // session.release() performs that release by closing the underlying
2423
+ // connection (a synchronous socket.destroy() under the hood -- D-12: the
2424
+ // connection IS the lease). The acquire immediately below therefore
2425
+ // finds THIS session already gone and answers "broker_gone" -- which is
2426
+ // NOT a new failure mode invented for this fix: it is handled by the
2427
+ // SAME broker-gone branch a few lines down this function already had,
2428
+ // exactly as it already handles any other dead-connection discovery. A
2429
+ // failed release introduces no new branch of its own: release() never
2430
+ // rejects (closing an already-closed socket is an idempotent no-op), and
2431
+ // even if it somehow did, the acquire that follows would classify and
2432
+ // report it exactly the same way.
2433
+ await session.release();
2434
+ grantId = null;
2435
+ const result = await session.acquire();
2436
+ if (result.ok) {
2437
+ adoptGrant({ ...result.grant });
2438
+ viceSession = null;
2439
+ ensureViceSession(); // re-baseline BEFORE returning -- see the never-cache invariant
2440
+ const newInstance = activeInstance();
2441
+ return machineReplacedMessage({
2442
+ where: "at the pre-flight liveness probe",
2443
+ reason: `the granted instance (port ${oldPort}) stopped answering -- ${probe.reason}`,
2444
+ oldPort,
2445
+ oldEpoch,
2446
+ newPort: newInstance.port,
2447
+ newEpoch: currentEpoch(),
2448
+ });
2449
+ }
2450
+
2451
+ if (result.kind !== "broker_gone") {
2452
+ // Bounded: exactly one replacement attempt, and it failed for a reason
2453
+ // that has nothing to do with the connection itself -- report both
2454
+ // failures and stop.
2455
+ return replacementFailedMessage(probe, result);
2456
+ }
2457
+
2458
+ // D-14: attempt 1 itself discovered the control connection is gone --
2459
+ // now the ORDINARY way this branch is reached, since the release just
2460
+ // above always closes it (T-01.6.2-90's own fix, not a regression: the
2461
+ // grant that release protects against leaking is already gone by
2462
+ // construction before this line ever runs). No release is sent over
2463
+ // `session` here, and none is needed: a release cannot be sent over a
2464
+ // connection that is already gone, and connection close IS the release
2465
+ // in this design, kernel-enforced -- the broker's own close handler has
2466
+ // already released the prior grant and killed its instance the moment
2467
+ // that close event fired, whichever branch triggered it. If the broker
2468
+ // itself died instead, there is nothing left to leak into either.
2469
+ // Attempt 2: open a GENUINELY FRESH session -- a brand-new broker.json
2470
+ // read (never the stale record the dead session above was opened
2471
+ // against), never reusing `session`. controlSession is deliberately left
2472
+ // pointing at the dead session on every failure branch below, so a LATER
2473
+ // call's own probe failure repeats this exact same from-scratch sequence.
2474
+ const opened = await openBrokerControl();
2475
+ if (!opened.ok) {
2476
+ return sessionMustRestartMessage(opened);
2477
+ }
2478
+ const freshResult = await opened.session.acquire();
2479
+ if (!freshResult.ok) {
2480
+ await opened.session.release(); // nothing to hold this connection open for
2481
+ return sessionMustRestartMessage(freshResult);
2482
+ }
2483
+ adoptGrant({ ...freshResult.grant });
2484
+ controlSession = opened.session; // the fresh session replaces the dead one, held for the rest of this proxy's life
2485
+ viceSession = null;
2486
+ ensureViceSession();
2487
+ const newInstance = activeInstance();
2488
+ return machineReplacedMessage({
2489
+ where: "after the broker connection itself was found gone and a fresh session was opened",
2490
+ reason: `the broker connection was gone (${result.message})`,
2491
+ oldPort,
2492
+ oldEpoch,
2493
+ newPort: newInstance.port,
2494
+ newEpoch: currentEpoch(),
2495
+ });
2496
+ }
2497
+
2498
+ // ------------------------------------------------ D-16 seam hazard annotation
2499
+ //
2500
+ // Plan 01.3-04. Structurally the OPPOSITE of the deny-list refusal below (the
2501
+ // DENY_LIST.includes(name) branch a little further into this same function):
2502
+ // the refusal fires BEFORE forwarding and the call never reaches the host;
2503
+ // this fires AFTER call() returns a real payload and appends to a
2504
+ // SUCCESSFUL result. The call is never refused and the error flag is never
2505
+ // set (D-16) -- a stopping checkpoint on an IRQ handler is core reverse-
2506
+ // engineering technique that Phase 2's exhaustive trace depends on, so this
2507
+ // warns instead of blocking it, the way the deny list blocks vice_disk_list
2508
+ // (which has no legitimate use at all).
2509
+
2510
+ // The set of capability names whose OWN arguments can express an armed,
2511
+ // stopping, exec checkpoint. Today that is vice_checkpoint_add alone.
2512
+ // Re-enabling an already-armed stopping checkpoint via vice_checkpoint_toggle
2513
+ // or a checkpoint group (vice_checkpoint_group_toggle/_add) can ALSO re-arm
2514
+ // one, but neither call's own arguments carry the stop flag -- only the
2515
+ // id/group being toggled -- so that re-enable path is NOT detectable from
2516
+ // the call alone and is deliberately excluded from this set. That gap is
2517
+ // covered by both tools' own descriptions and by vice_diagnose's checkpoint-
2518
+ // trap check, and it is stated in the annotation text below rather than left
2519
+ // for a reader to discover.
2520
+ const CHECKPOINT_ARMING_TOOLS = new Set(["vice_checkpoint_add"]);
2521
+
2522
+ // Per-session suppression: an address (as rendered by formatAddress(), or an
2523
+ // "unparseable:<raw>" key for an address that could not be parsed) already
2524
+ // warned about this session maps to true. Cleared whenever the observed
2525
+ // epoch changes -- a new machine has seen none of these. currentEpoch() is a
2526
+ // synchronous LOCAL file read (see its own definition above), never a
2527
+ // forwarded call, so consulting it here does not violate the "makes no
2528
+ // forwarded call of its own" requirement below.
2529
+ let seamHazardSeen: Set<string> = new Set();
2530
+ let seamHazardEpochKey: number | null = null;
2531
+
2532
+ function seamHazardObserveEpoch(): void {
2533
+ const epoch = currentEpoch();
2534
+ const key = epoch && epoch.present ? epoch.epoch : null;
2535
+ if (seamHazardEpochKey !== null && key !== seamHazardEpochKey) {
2536
+ seamHazardSeen = new Set(); // a new machine has seen none of these
2537
+ }
2538
+ seamHazardEpochKey = key;
2539
+ }
2540
+
2541
+ /** detectCheckpointArmingHazard()'s own return shape -- consumed only by
2542
+ * renderCheckpointArmingHazard() below. */
2543
+ interface CheckpointArmingHazardDetection {
2544
+ addrLabel: string;
2545
+ repeat: boolean;
2546
+ }
2547
+
2548
+ /**
2549
+ * D-16's hazard annotation. Returns the annotation text for a successful
2550
+ * checkpoint-arming call, or nothing. Returns nothing unless the capability
2551
+ * is in CHECKPOINT_ARMING_TOOLS and the arguments express an exec operation
2552
+ * with the stop flag set -- callers only reach this after a successful
2553
+ * call(), so a rejected arm never reaches here at all (a failed arm has no
2554
+ * hazard to warn about). Makes NO forwarded call of its own (T-01.3-13) --
2555
+ * the detection is entirely over the arguments the agent already supplied.
2556
+ * An unparseable address is still annotated, naming the address as unread
2557
+ * rather than silently skipping: an unparseable address is not evidence of
2558
+ * safety.
2559
+ */
2560
+ function detectCheckpointArmingHazard(
2561
+ name: string,
2562
+ args: Record<string, unknown>
2563
+ ): CheckpointArmingHazardDetection | undefined {
2564
+ if (!CHECKPOINT_ARMING_TOOLS.has(name)) return undefined;
2565
+ // vice_checkpoint_add's own schema: `stop` defaults true, `exec` defaults
2566
+ // true -- an ABSENT field is armed, not merely "true when written out".
2567
+ const stopArmed = !(args && args.stop === false);
2568
+ const execArmed = !(args && args.exec === false);
2569
+ if (!stopArmed || !execArmed) return undefined;
2570
+
2571
+ seamHazardObserveEpoch();
2572
+
2573
+ const addrNum = toAddressNumber(args && args.start);
2574
+ const addrLabel =
2575
+ addrNum === null ? `an unparseable address (raw value: ${JSON.stringify(args && args.start)})` : formatAddress(addrNum);
2576
+ const suppressionKey = addrNum === null ? `unparseable:${JSON.stringify(args && args.start)}` : addrLabel;
2577
+
2578
+ const repeat = seamHazardSeen.has(suppressionKey);
2579
+ if (!repeat) seamHazardSeen.add(suppressionKey);
2580
+ return { addrLabel, repeat };
2581
+ }
2582
+
2583
+ function renderCheckpointArmingHazard(detection: CheckpointArmingHazardDetection): string {
2584
+ const { addrLabel, repeat } = detection;
2585
+ if (repeat) {
2586
+ return (
2587
+ `vice hazard (repeat): a stopping exec checkpoint was armed again at ${addrLabel} -- the full ` +
2588
+ "hazard note for this address was already issued earlier this session; see that note."
2589
+ );
2590
+ }
2591
+ return [
2592
+ `vice hazard: a stopping exec checkpoint was just armed at ${addrLabel}, and the call was NOT ` +
2593
+ "blocked -- it will not be, because this is core reverse-engineering technique.",
2594
+ "",
2595
+ "This shape -- a stopping exec checkpoint armed, then execution resumed -- is common to every recorded " +
2596
+ "freeze on this project. Two variants are on record: a mid-routine stop that froze two independent " +
2597
+ "sessions at an identical program counter, and an IRQ-handler-entry stop whose tell was a hit count " +
2598
+ "of zero on a screen the machine must have been executing.",
2599
+ "",
2600
+ "Whether THIS address is the live IRQ handler is a question vice_diagnose answers, by resolving the " +
2601
+ "vector pair live -- this warning deliberately does not resolve it here, because doing so on every " +
2602
+ "arm would disturb the machine it is protecting.",
2603
+ "",
2604
+ "Recovery, in order: run vice_diagnose first; reach for vice_recycle only when the bracket says wedge " +
2605
+ "with no checkpoint explanation.",
2606
+ "",
2607
+ "Stated residual: re-enabling this checkpoint later via vice_checkpoint_toggle or a checkpoint group " +
2608
+ "carries no stop flag in its own arguments and is therefore NOT annotated by this mechanism -- covered " +
2609
+ "by both tools' own descriptions and by vice_diagnose's checkpoint-trap check instead.",
2610
+ ].join("\n");
2611
+ }
2612
+
2613
+ /**
2614
+ * Plan 01.3-04 task 2: turns task 1's single hazard into the general
2615
+ * mechanism D-06 needs -- a table, so the next confirmed trigger (plan
2616
+ * 01.3-05's bounded hunt) is a single entry rather than new plumbing at this
2617
+ * seam. Each entry:
2618
+ * - id: a short identifier that MUST be named by at least one test in
2619
+ * vice-proxy.test.mjs (this file's own structural completeness test
2620
+ * enforces it) -- an entry that ships without a matching test fails the
2621
+ * suite rather than shipping unproven.
2622
+ * - capabilities: the Set of tool names this entry's own detect() can ever
2623
+ * match against. Used ONLY by the disjointness structural test below
2624
+ * (never for dispatch -- the walk tries every entry against every
2625
+ * call). Every capability named here must be ABSENT from DENY_LIST: a
2626
+ * capability with no legitimate use is refused before forwarding, and
2627
+ * one with a legitimate use is annotated after it, and none is both
2628
+ * (D-16).
2629
+ * - detect(name, args, payload): returns a truthy detection payload, or
2630
+ * nothing. MUST make no forwarded call of its own (T-01.3-13).
2631
+ * - render(detection): returns the annotation text for a truthy
2632
+ * detection.
2633
+ *
2634
+ * Plan 01.3-05 is this table's expected next writer, adding the bounded
2635
+ * hunt's own confirmed trigger as one more entry here -- not new plumbing.
2636
+ */
2637
+ // Method-shorthand syntax deliberately (not `detect: (...) => ...`): TS's
2638
+ // bivariant method-parameter check is what lets each entry's own narrower
2639
+ // detect()/render() pair (e.g. CheckpointArmingHazardDetection, not
2640
+ // `unknown`) slot into this shared, heterogeneous table -- exactly the
2641
+ // polymorphism the table's own doc comment above describes ("the next
2642
+ // confirmed trigger is a single entry"). The production entry below is cast
2643
+ // `as SeamHazardEntry` (not the whole SEAM_HAZARDS declaration -- that
2644
+ // exact line is vice-proxy.test.mjs's own oracle anchor, `indexOf("const
2645
+ // SEAM_HAZARDS = [")`, and must stay byte-identical) so the array's own
2646
+ // inferred element type is this interface, which is what lets the
2647
+ // TEST-ONLY .push() below (a structurally different detect/render pair)
2648
+ // type-check without a second cast at that call site.
2649
+ interface SeamHazardEntry {
2650
+ id: string;
2651
+ capabilities: Set<string>;
2652
+ detect(name: string, args: Record<string, unknown>, payload?: unknown): unknown;
2653
+ render(detection: unknown): string;
2654
+ }
2655
+
2656
+ const SEAM_HAZARDS = [
2657
+ {
2658
+ id: "checkpoint-arming",
2659
+ capabilities: CHECKPOINT_ARMING_TOOLS,
2660
+ detect: detectCheckpointArmingHazard,
2661
+ render: renderCheckpointArmingHazard,
2662
+ } as SeamHazardEntry,
2663
+ ];
2664
+
2665
+ // TEST-ONLY escape hatch (plan 01.3-04 task 2's data-driven proof): proves
2666
+ // the walk below is genuinely data-driven, not hand-wired to the one
2667
+ // production entry above, by injecting a SECOND entry the same way a real
2668
+ // plan 01.3-05 entry would arrive. Matches against vice_ping -- an existing,
2669
+ // universally-forwardable tool -- rather than inventing a synthetic
2670
+ // capability name that would need its own manifest/deny-list bookkeeping.
2671
+ // Never set outside this file's own test suite.
2672
+ if (process.env.VICE_SEAM_HAZARDS_TEST_FIXTURE === "1") {
2673
+ SEAM_HAZARDS.push({
2674
+ id: "test-fixture-synthetic-entry",
2675
+ capabilities: new Set(["vice_ping"]),
2676
+ detect: (name: string) => (name === "vice_ping" ? { fixture: true } : undefined),
2677
+ render: () => "vice-proxy hazard (TEST FIXTURE): synthetic second SEAM_HAZARDS entry, detected and annotated through the same walk.",
2678
+ });
2679
+ }
2680
+
2681
+ /**
2682
+ * Walks SEAM_HAZARDS, concatenating every annotation a successful call
2683
+ * attracts. Short-circuits per entry on a falsy detection -- a call matching
2684
+ * no entry costs one array pass and returns undefined, leaving the payload
2685
+ * untouched.
2686
+ */
2687
+ function renderSeamHazardAnnotations(name: string, args: Record<string, unknown>, payload: unknown): string | undefined {
2688
+ const notes: string[] = [];
2689
+ for (const entry of SEAM_HAZARDS) {
2690
+ const detection = entry.detect(name, args, payload);
2691
+ if (detection) {
2692
+ notes.push(entry.render(detection));
2693
+ }
2694
+ }
2695
+ return notes.length ? notes.join("\n\n") : undefined;
2696
+ }
2697
+
2698
+ // forwardToVice() is the retained BODY of what used to be handleToolsCall()
2699
+ // -- renamed and trimmed of the name/args extraction, the three synthetic-
2700
+ // tool short-circuits, and the deny-list check, all now handled one layer
2701
+ // out by the CallToolRequestSchema override and the tool registry
2702
+ // construction (both near the bottom of this file, right after the
2703
+ // teardown region): each real manifest tool's own buildViceTool() entry
2704
+ // wraps this function as its `execute`, so this is reached only for a name
2705
+ // already known to be a real, non-deny-listed manifest tool with an
2706
+ // already-parsed `args` object. Every function called below is reused
2707
+ // completely unchanged from its pre-swap form.
2708
+ async function forwardToVice(name: string, args: Record<string, unknown>): Promise<ToolCallResult> {
2709
+ const leaseResult = await ensureBrokerLease();
2710
+ if (!leaseResult.ok) {
2711
+ return isErrorText(leaseResult.message);
2712
+ }
2713
+ // No touch-on-every-forwarded-call any more (C6's old mechanism, alongside
2714
+ // the heartbeat timer, both retired under D-12): the connection itself is
2715
+ // the claim, kernel-enforced, with nothing to refresh. Either the socket is
2716
+ // still open, or the broker's own "close" handler has already reclaimed
2717
+ // the instance -- there is no third, ambiguous state a touch could rescue.
2718
+
2719
+ ensureViceSession();
2720
+
2721
+ const beforeDrift = checkEpochAndRebaseline("before forwarding");
2722
+ if (beforeDrift) {
2723
+ // Refused BEFORE any request is serialised -- the whole point of the
2724
+ // pre-forward check.
2725
+ return isErrorText(beforeDrift);
2726
+ }
2727
+
2728
+ // Pre-flight liveness probe (task 2 / criterion 7), ordered AFTER the
2729
+ // deny-list refusal and the epoch comparison above (a refused tool and a
2730
+ // restarted machine both need answering without any network activity at
2731
+ // all) and BEFORE delegating to call() -- see vice-probe.ts's header for
2732
+ // why this is a single 1500ms-budget round trip with no retry, never
2733
+ // wrapped in withReconnect()'s ladder. One call site, not inside a loop.
2734
+ const { url, port } = activeInstance();
2735
+ const probe = await probeInstance({ url, port });
2736
+ if (!probe.alive) {
2737
+ const epoch = currentEpoch();
2738
+ // D-5 (quick-260801-ccn task 3): the lease check runs FIRST, before the
2739
+ // refused-and-no-epoch test below -- under the bug this fixes, BOTH of
2740
+ // that test's arms hold true for a fresh broker grant (a just-granted
2741
+ // instance's own epoch_file rarely has a baseline recorded yet), so a
2742
+ // broker-granted instance was being answered by the RETIRED fixed-port
2743
+ // triple instead of naming the broker. That ordering was the whole
2744
+ // defect.
2745
+ if (controlSession) {
2746
+ // D-13/D-14 (plan 08): a granted instance not answering no longer
2747
+ // gets a report-and-instruct message -- it gets a replace-and-report.
2748
+ // handleGrantedInstanceUnreachable() acquires a replacement over this
2749
+ // same session (or, if the session itself turns out to be gone, a
2750
+ // genuinely fresh one) and returns an ERROR naming the replacement --
2751
+ // never a silently substituted result, even though a working
2752
+ // instance is now held for the NEXT call.
2753
+ return isErrorText(await handleGrantedInstanceUnreachable(probe, epoch));
2754
+ }
2755
+ if (isConnectionRefusedReason(probe.reason) && !epoch.present) {
2756
+ return isErrorText(neverStartedMessage(probe));
2757
+ }
2758
+ // Every other unreachable shape -- refused-with-an-epoch-on-record,
2759
+ // timed out, or something answered but didn't look like VICE -- is
2760
+ // "dead or hung"; probe.reason itself says which, verbatim.
2761
+ return isErrorText(deadOrHungMessage(probe, epoch));
2762
+ }
2763
+
2764
+ // Path translation at the seam (task 3 / decision D-G / criterion 9),
2765
+ // ordered after the deny-list refusal, the epoch comparison and the
2766
+ // liveness probe above, and before delegating to call(). A refusal here
2767
+ // (out-of-workspace absolute path, or a translation failure) is returned
2768
+ // exactly like every other tools/call outcome: a well-formed isError:true
2769
+ // result, never a throw.
2770
+ let translatedArgs: Record<string, unknown>;
2771
+ let pathNote = "";
2772
+ try {
2773
+ const rewritten = rewriteArguments(args, name);
2774
+ translatedArgs = rewritten.args;
2775
+ pathNote = resolutionNote(rewritten.resolutions);
2776
+ } catch (e) {
2777
+ if (e instanceof PathOutOfWorkspaceError || e instanceof PathTranslationError) {
2778
+ return isErrorText(e.message);
2779
+ }
2780
+ throw e; // unexpected -- let the never-throw dispatch one layer up handle it
2781
+ }
2782
+
2783
+ let payload: unknown;
2784
+ try {
2785
+ payload = await call(name, translatedArgs);
2786
+ } catch (e) {
2787
+ if (e instanceof MachineRestartedError) {
2788
+ // call()'s own post-reconnect fast path detected this first -- convert
2789
+ // to the same isError frame shape and re-baseline identically. Two
2790
+ // layers, one observable behaviour.
2791
+ const current = currentEpoch();
2792
+ epochBaseline = current;
2793
+ return isErrorText(
2794
+ `vice: treat every result since the previous call as void and redo that work -- the emulator was ` +
2795
+ `replaced mid-call (epoch changed from ${e.baselineEpoch} to ${e.currentEpoch}). (${e.message})`
2796
+ );
2797
+ }
2798
+ // NEVER rethrow past this point -- a tool-execution failure (transport
2799
+ // error, a rejected RPC) is a normal, expected outcome for this method
2800
+ // and must come back as a well-formed result, not crash the read loop.
2801
+ // The probe above already proved the host alive, so this is the "alive
2802
+ // but the operation failed" state -- relay verbatim, no restart advice.
2803
+ // The path note rides along on the FAILURE too, and this is the case it
2804
+ // was written for: a host-side "Failed to attach disk image" says nothing
2805
+ // about which file was attempted, so naming the resolved absolute path
2806
+ // here is the difference between a one-line fix and an hour spent
2807
+ // suspecting the emulator.
2808
+ const failure = aliveButFailedMessage(e && (e as Error).message ? (e as Error).message : String(e));
2809
+ return isErrorText(pathNote ? `${failure}\n${pathNote}` : failure);
2810
+ }
2811
+
2812
+ const afterDrift = checkEpochAndRebaseline("after the call returned");
2813
+ if (afterDrift) {
2814
+ // A payload read from a machine whose identity changed mid-call is not
2815
+ // trustworthy -- return the restart frame INSTEAD OF the call's result.
2816
+ return isErrorText(afterDrift);
2817
+ }
2818
+
2819
+ const rawText = typeof payload === "string" ? payload : JSON.stringify(payload);
2820
+ // D-16 seam hazard annotation (plan 01.3-04): computed by walking
2821
+ // SEAM_HAZARDS and merged into the TEXT itself, BEFORE wrapPossiblyChunked()
2822
+ // runs, so an oversized annotated result still carries the note inside its
2823
+ // own chunking (T-01.3-15) -- a warning appended AFTER chunking would be
2824
+ // lost off the end. Never routes through isErrorText and never touches the
2825
+ // error flag (D-16, T-01.3-12).
2826
+ const hazardNote = renderSeamHazardAnnotations(name, args, payload);
2827
+ const text = hazardNote ? `${rawText}\n\n${hazardNote}` : rawText;
2828
+ const wrapped = wrapPossiblyChunked(text);
2829
+ // Append the path note as a trailing content item, never mixed into the
2830
+ // payload: wrapPossiblyChunked()'s contract is that the FIRST item is the
2831
+ // payload byte-for-byte, so reassembly stays a plain concatenation. Only
2832
+ // the unchunked shape is annotated -- a chunked result is already carrying
2833
+ // a continuation marker as its second item, and the four tools that can
2834
+ // resolve a path (disk_attach, autostart, display_screenshot, symbols_load)
2835
+ // never produce output anywhere near the cap.
2836
+ if (pathNote && wrapped.content.length === 1) {
2837
+ wrapped.content.push({ type: "text", text: pathNote });
2838
+ }
2839
+ return wrapped;
2840
+ }
2841
+
2842
+ // -------------------------------------------------------------- teardown
2843
+ //
2844
+ // TWO ladders, not one, firing DIFFERENT handlers (spike-findings-bruce-lee
2845
+ // skill, shutdown-and-lease-release.md -- measured, not assumed): a
2846
+ // graceful client ending delivers SIGINT first, then SIGTERM ~100ms later,
2847
+ // then SIGKILL at ~490ms total, and NEVER closes stdin. Abrupt client death
2848
+ // closes stdin (`end` then `close`) and NEVER signals. Each family covers
2849
+ // exactly the ending the other misses, so both are wired below; SIGINT is a
2850
+ // teardown trigger here, not a user Ctrl-C to ignore -- it is the FIRST
2851
+ // signal of every graceful ending.
2852
+ //
2853
+ // The measured numbers this depends on: ~490ms from the first signal to
2854
+ // SIGKILL, on the order of microseconds for closing a socket handle --
2855
+ // roughly as many orders of magnitude of headroom as the retiring lease
2856
+ // file's own unlinkSync had. The entire handler body below calls exactly
2857
+ // one release and AWAITS NOTHING (C5): introducing anything that blocks on
2858
+ // a response here (an await, a fetch, a child process, a round trip to the
2859
+ // broker) reintroduces leaked leases silently, since there would be no time
2860
+ // left for it to complete before SIGKILL cuts the process off. Plan
2861
+ // 01.6.2-07: the lease is now the control connection itself, so "release"
2862
+ // is `socket.destroy()` -- a synchronous, in-process handle close, not a
2863
+ // network round trip; nothing here waits for the broker to acknowledge
2864
+ // anything, matching the retiring unlinkSync's own fire-and-forget shape.
2865
+ // BrokerControlSession.release() is declared `async` (vice-broker-client.ts),
2866
+ // so a synchronous throw inside it becomes a REJECTED PROMISE, not a thrown
2867
+ // exception -- a plain try/catch around a bare, unawaited call would never
2868
+ // see it. `.catch(...)` (not `await`, not `.then(`) is the correct way to
2869
+ // observe that failure without awaiting or chaining a success handler,
2870
+ // and is not itself a promise-awaiting construct: nothing in this region
2871
+ // blocks on the release settling before returning.
2872
+ //
2873
+ // This removes the file's only explicit process.exit( call: nothing needs
2874
+ // it any more. The graceful path is killed by SIGKILL ~490ms after the
2875
+ // first signal regardless of anything this process does, and the abrupt
2876
+ // path exits naturally once stdin is gone and nothing else is listening.
2877
+ //
2878
+ // TEARDOWN-REGION-BEGIN -- vice-proxy.test.mjs's source assertion slices
2879
+ // the file between this marker and its closing counterpart further below,
2880
+ // and asserts that slice contains no promise-awaiting construct and calls
2881
+ // the control session's release function exactly once. Do not move either
2882
+ // marker away from the code each one bounds.
2883
+ let teardownRan = false;
2884
+
2885
+ function releaseLeaseNow(trigger: string): void {
2886
+ if (!controlSession) return;
2887
+ controlSession.release().catch((err: unknown) => {
2888
+ console.error(`vice-proxy: lease_release_failed trigger=${trigger}: ${err && (err as Error).message ? (err as Error).message : err}`);
2889
+ });
2890
+ }
2891
+
2892
+ function onTeardown(trigger: string): void {
2893
+ if (teardownRan) return; // idempotent -- SIGINT then SIGTERM ~100ms later both call in
2894
+ teardownRan = true;
2895
+ releaseLeaseNow(trigger);
2896
+ }
2897
+
2898
+ process.stdin.on("end", () => onTeardown("stdin_end"));
2899
+ process.stdin.on("close", () => onTeardown("stdin_close"));
2900
+ // Registered as three explicit calls, not a loop over an array, so a
2901
+ // durable source-grep for "is SIGINT/SIGTERM/SIGHUP each really wired"
2902
+ // (this task's own acceptance criteria) has a literal string to find for
2903
+ // each one -- SIGINT first, since it is the first signal of every graceful
2904
+ // ending and must never be mistaken for a plain user Ctrl-C to ignore.
2905
+ process.on("SIGINT", () => onTeardown("SIGINT"));
2906
+ process.on("SIGTERM", () => onTeardown("SIGTERM"));
2907
+ process.on("SIGHUP", () => onTeardown("SIGHUP"));
2908
+ // TEARDOWN-REGION-END
2909
+
2910
+ warnOnceAboutOutputLimit(); // D-1.2-H -- one stderr line, at most once per process, never a refusal
2911
+
2912
+ // ------------------------------------------------------- @mastra/mcp seam
2913
+ //
2914
+ // D-01 (this plan): the wire layer is now MCPServer + startStdio(), with
2915
+ // broker leasing, epoch, probe, path rewriting, call() and chunking all
2916
+ // reused completely unchanged inside forwardToVice() above -- only the
2917
+ // top-level caller changed. See this plan's PLAN.md "Ground truth" section
2918
+ // (read directly from @mastra/mcp's compiled source, not its docs) for why
2919
+ // tools/call is answered by the CallToolRequestSchema override below rather
2920
+ // than by MCPServer's own dispatch.
2921
+
2922
+ /**
2923
+ * Adapts a manifest tool's raw JSON Schema into the minimal
2924
+ * StandardSchemaWithJSON shape createTool() requires, matching TODAY's
2925
+ * zero-validation-at-the-proxy behaviour exactly: `~standard.validate`
2926
+ * always succeeds (this proxy has never validated argument shape itself --
2927
+ * the host does), and `~standard.jsonSchema.input()`/`.output()` both
2928
+ * return the SAME schema object verbatim regardless of `target`/`io`, so
2929
+ * tools/list's wire output stays byte-identical to the manifest's own raw
2930
+ * schema (proven by a deep-equal assertion in vice-proxy.test.ts, not
2931
+ * assumed from either library's documentation).
2932
+ */
2933
+ function rawJsonSchemaAsStandardSchema(schema: unknown): StandardSchemaWithJSON {
2934
+ const jsonSchema = isPlainObject(schema) ? schema : { type: "object", properties: {} };
2935
+ return {
2936
+ "~standard": {
2937
+ version: 1,
2938
+ vendor: "vice-proxy",
2939
+ validate: (value: unknown) => ({ value }),
2940
+ jsonSchema: {
2941
+ input: () => jsonSchema,
2942
+ output: () => jsonSchema,
2943
+ },
2944
+ },
2945
+ };
2946
+ }
2947
+
2948
+ /** Turns a `ToolCallResult` (this file's own internal `{content, isError}`
2949
+ * shape) into the SDK's `CallToolResult` wire shape -- a direct, lossless
2950
+ * pass-through, since the two shapes are structurally identical. The whole
2951
+ * point of the CallToolRequestSchema override below building the response
2952
+ * itself is that no translation or mangling happens here. */
2953
+ function toolCallResultToWire(result: ToolCallResult): { content: ToolCallResult["content"]; isError: boolean } {
2954
+ return { content: result.content, isError: result.isError };
2955
+ }
2956
+
2957
+ /** Narrows an `unknown` execute() return value to this file's own
2958
+ * ToolCallResult shape before trusting it. Every tool this file registers
2959
+ * is one this file itself wrote (buildViceTool()'s own `run` callbacks
2960
+ * always return this shape), but the override still checks rather than
2961
+ * casting blind, matching this file's own isPlainObject() discipline. */
2962
+ function isToolCallResult(value: unknown): value is ToolCallResult {
2963
+ return isPlainObject(value) && Array.isArray(value.content) && typeof value.isError === "boolean";
2964
+ }
2965
+
2966
+ /**
2967
+ * Wraps a ToolDefinition (a manifest tool, or one of this file's own three
2968
+ * proxy-local synthetic tools) plus its own runner into a Mastra Tool via
2969
+ * createTool(), reproducing exactly the `_meta` merge handleToolsList() used
2970
+ * to perform at read time (now construction-time, see the registry below).
2971
+ */
2972
+ function buildViceTool(def: ToolDefinition, run: (args: Record<string, unknown>) => Promise<ToolCallResult>) {
2973
+ return createTool({
2974
+ id: def.name,
2975
+ description: def.description ?? "",
2976
+ inputSchema: rawJsonSchemaAsStandardSchema(def.inputSchema),
2977
+ mcp: {
2978
+ _meta: {
2979
+ ...((def._meta as Record<string, unknown> | undefined) || {}),
2980
+ "anthropic/maxResultSizeChars": OUTPUT_CHAR_CAP,
2981
+ },
2982
+ },
2983
+ execute: async (inputData) => run(isPlainObject(inputData) ? inputData : {}),
2984
+ });
2985
+ }
2986
+
2987
+ // Plan 01.6.3-02's tracer proved this mechanism on `vice_ping` alone
2988
+ // (TRACER_MANIFEST_TOOL_NAMES, since removed). Plan 01.6.3-03 widens the
2989
+ // input set to the FULL manifest -- the loop body itself is unchanged from
2990
+ // the tracer: no per-tool special case, only the same DENY_LIST filter
2991
+ // already proven in Plan 02. The manifest also lists the host's own
2992
+ // generic-surface meta-tools (`tools_call`/`tools_list`/`initialize`/
2993
+ // `notifications_initialized`) as ordinary tools; 01.6.3-03 registered them
2994
+ // like any other manifest entry (a real, disclosed generic-dispatch risk it
2995
+ // did not itself widen -- see the CallToolRequestSchema override below for
2996
+ // the historical shape of that risk), and 01.4-01 (tasks 1+2) closed it by
2997
+ // adding all four to DENY_LIST itself, so this SAME skip now filters them
2998
+ // out of `tools` at construction time exactly like vice_disk_list always
2999
+ // was.
3000
+ //
3001
+ // Construction-time, not read-time -- a deliberate, disclosed narrowing this
3002
+ // plan records explicitly: tools/list is now served entirely by MCPServer's
3003
+ // own ListToolsRequestSchema handler (unmodified, not overridden), reading
3004
+ // from this SAME `tools` object, so vice_disk_list's absence from it is the
3005
+ // ONLY enforcement discovery-time needs any more -- no separate filter
3006
+ // function runs at read time. A manifest hot-reload mid-session is
3007
+ // therefore no longer picked up until the proxy restarts; the manifest is
3008
+ // regenerated by a manual, rare build step, never mid-session in practice.
3009
+ const tools: Record<string, ReturnType<typeof buildViceTool>> = {};
3010
+ for (const def of readManifestTools()) {
3011
+ if (DENY_LIST.includes(def.name)) continue;
3012
+ tools[def.name] = buildViceTool(def, (args) => forwardToVice(def.name, args));
3013
+ }
3014
+ tools[RESULT_CONTINUE_TOOL.name] = buildViceTool(RESULT_CONTINUE_TOOL, (args) => Promise.resolve(handleResultContinue(args)));
3015
+ tools[RECYCLE_TOOL.name] = buildViceTool(RECYCLE_TOOL, (args) => handleRecycle(args));
3016
+ tools[DIAGNOSE_TOOL.name] = buildViceTool(DIAGNOSE_TOOL, (args) => handleDiagnose(args));
3017
+
3018
+ const server = new MCPServer({ name: "vice", version: PROXY_VERSION, tools });
3019
+ await server.startStdio();
3020
+ // Installed with ZERO await between this line and the one above (see this
3021
+ // plan's "Ground truth" section for why that ordering is load-bearing --
3022
+ // StdioServerTransport.start() has already wired its 'data' listener by the
3023
+ // time startStdio()'s promise resolves, but Node does not deliver a queued
3024
+ // 'data' event until the next event-loop turn): MCPServer's own tools/call
3025
+ // dispatch always forces isError:false on success and prepends "Error: " on
3026
+ // a thrown failure (read directly from @mastra/mcp's compiled source this
3027
+ // session, not its docs), which matches neither this file's own
3028
+ // {content, isError} contract nor the deny-list's exact refusal wording a
3029
+ // pre-existing test pins verbatim -- so tools/call is answered entirely by
3030
+ // this override, never by MCPServer's own handler. tools/list is NOT
3031
+ // overridden -- MCPServer's own ListToolsRequestSchema handler answers it,
3032
+ // the one piece of genuine library value this swap adopts.
3033
+ server.getServer().setRequestHandler(CallToolRequestSchema, async (request) => {
3034
+ const name = request.params.name;
3035
+ // Layer 1 (unchanged mechanism, now here instead of the retired
3036
+ // handleToolsCall()): call-time deny-list refusal, before any tool lookup
3037
+ // and before any network attempt -- independent from `tools`'s own
3038
+ // construction-time absence of vice_disk_list (layer 2, the
3039
+ // discovery-time enforcement tools/list reads from). Removing either
3040
+ // layer leaves the other standing.
3041
+ if (DENY_LIST.includes(name)) {
3042
+ return {
3043
+ content: [{ type: "text", text: denyListRefusalMessage(name) }],
3044
+ isError: true,
3045
+ };
3046
+ }
3047
+ // CLOSED BY 01.4-01 (tasks 1+2), closing Phase 01.4 criterion 3's
3048
+ // already-recorded open breach concern. This check inspects only the
3049
+ // OUTER `name` -- the literal MCP tool being called -- and always has;
3050
+ // that outer-name-only shape is unchanged by this fix and is NOT itself
3051
+ // the hazard. The hazard was that the manifest also lists the host's own
3052
+ // generic-surface meta-tools (`tools_call`/`tools_list`/`initialize`/
3053
+ // `notifications_initialized`) as ordinary forwardable tools, and
3054
+ // `tools_call` specifically could carry a forbidden name (e.g.
3055
+ // `vice_disk_list`) as a NESTED `arguments.name`, bypassing this exact
3056
+ // guard by never presenting the forbidden name as the OUTER one. All four
3057
+ // meta-tool names are now themselves on DENY_LIST (task 1 added
3058
+ // `tools_list`; task 2 added `tools_call`, `initialize` and
3059
+ // `notifications_initialized` after confirming, via a repo-wide grep, that
3060
+ // none has a sanctioned caller): `tools_call` itself is refused before its
3061
+ // own nested argument is ever read, closing the bypass without teaching
3062
+ // this guard to parse nested argument shapes -- one array, no new
3063
+ // mechanism, exactly 01.4-RESEARCH.md's own Pattern 1 and primary
3064
+ // recommendation. The historical bypass-proving test in
3065
+ // vice-proxy.test.ts is repointed (not deleted) to assert this closure.
3066
+ // Full history in 01.6.3-03-SUMMARY.md and
3067
+ // .planning/todos/pending/2026-08-05-generic-surface-deny-list-gap-tools-call-nested-vice-disk-list.md.
3068
+ const tool = tools[name];
3069
+ if (!tool || !tool.execute) {
3070
+ return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
3071
+ }
3072
+ try {
3073
+ const raw = await tool.execute(request.params.arguments ?? {}, { observe: noopObserve });
3074
+ if (!isToolCallResult(raw)) {
3075
+ return {
3076
+ content: [
3077
+ { type: "text", text: `vice: internal error -- tool "${name}"'s execute() returned an unexpected shape` },
3078
+ ],
3079
+ isError: true,
3080
+ };
3081
+ }
3082
+ return toolCallResultToWire(raw);
3083
+ } catch (e) {
3084
+ // The never-throw discipline this file already lives by (matching the
3085
+ // retired handleToolsCall()'s own "NEVER rethrow past this point"
3086
+ // comment) -- forwardToVice() and the synthetic-tool handlers should
3087
+ // never actually throw in normal operation, but this override must not
3088
+ // depend on that being true.
3089
+ return { content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }], isError: true };
3090
+ }
3091
+ });
3092
+
3093
+ console.error(`vice-proxy: ready, forwarding to ${activeInstance().url} (port ${activeInstance().port})`);