@henols/vice-mcp 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.md +2 -2
  2. package/THIRD-PARTY-NOTICES.md +422 -1
  3. package/anno-bank.ts +171 -0
  4. package/anno-cli.ts +1674 -99
  5. package/anno-enum-gen.ts +416 -30
  6. package/anno-export-asm.ts +1175 -89
  7. package/anno-graphics.ts +338 -0
  8. package/anno-hazard-report.ts +1367 -0
  9. package/anno-import.ts +495 -0
  10. package/anno-join.ts +480 -0
  11. package/anno-provenance-ledger.ts +472 -0
  12. package/anno-register.ts +159 -0
  13. package/anno-store-export.ts +661 -0
  14. package/anno-store.ts +518 -2
  15. package/anno-tools.ts +1169 -16
  16. package/anno-types.ts +275 -2
  17. package/backend-detect.mts +124 -312
  18. package/build.ts +3 -1
  19. package/capture-predicate.ts +597 -0
  20. package/channel-lock.ts +349 -0
  21. package/evid-ingest.ts +217 -0
  22. package/evid-reconcile.ts +316 -0
  23. package/host-tool-client.ts +430 -0
  24. package/incident-record.ts +23 -12
  25. package/install-resources.ts +29 -13
  26. package/memmap-lookup.ts +285 -0
  27. package/package.json +27 -8
  28. package/prg-image.ts +1 -2
  29. package/repo-root.ts +87 -3
  30. package/resources/backend-detect.mjs +98 -236
  31. package/resources/broker-control.mjs +189 -16
  32. package/resources/broker-epoch.mjs +1 -1
  33. package/resources/broker-kill.mjs +8 -2
  34. package/resources/broker-launch.mjs +365 -210
  35. package/resources/broker-state.mjs +64 -18
  36. package/resources/container-guard.mjs +1 -1
  37. package/resources/ghidra-project.mjs +790 -0
  38. package/resources/host-tool.mjs +2561 -0
  39. package/resources/vice-broker.mjs +330 -184
  40. package/resources/vice-launcher.sh +127 -9
  41. package/stock-address.ts +1 -1
  42. package/stock-condition.ts +1 -1
  43. package/stock-connect.ts +9 -5
  44. package/stock-derived.ts +29 -37
  45. package/stock-diagnose.ts +200 -36
  46. package/stock-dispatch.ts +179 -77
  47. package/stock-handler.ts +1 -1
  48. package/stock-paths.ts +18 -14
  49. package/stock-petscii.ts +1 -1
  50. package/stock-protocol.ts +1 -1
  51. package/stock-recycle.ts +83 -2
  52. package/stock-reproducible-run.ts +811 -0
  53. package/stock-run-until.ts +100 -1
  54. package/stock-symbols.ts +4 -4
  55. package/stock-timing.ts +1 -1
  56. package/stop-oracle.ts +167 -0
  57. package/text-capability-probe.ts +660 -0
  58. package/text-connect.ts +157 -0
  59. package/text-protocol.ts +810 -0
  60. package/text-tools.ts +778 -0
  61. package/textmon-backtrace.ts +385 -0
  62. package/textmon-cpuhistory.ts +335 -0
  63. package/textmon-memmap.ts +494 -0
  64. package/textmon-profile.ts +458 -0
  65. package/textmon-registers.ts +748 -0
  66. package/tools-manifest.stock.json +864 -3
  67. package/vice-broker-client.ts +189 -42
  68. package/vice-errors.ts +268 -0
  69. package/vice-proxy.ts +339 -2144
  70. package/vsf-slice.ts +640 -0
  71. package/anno-d64.ts +0 -310
  72. package/capability-registry.ts +0 -390
  73. package/refresh-manifest.ts +0 -124
  74. package/tools-manifest.json +0 -1223
  75. package/vice-probe.ts +0 -278
  76. package/vice-sync.ts +0 -336
  77. package/vice.ts +0 -772
package/vice-proxy.ts CHANGED
@@ -51,7 +51,8 @@
51
51
  // deleted them) and its parent (the last commit where they still
52
52
  // existed).
53
53
  // 2. Re-point every tool's dispatch: each tool's `execute` body
54
- // (`forwardToVice(name, args)`, UNCHANGED by the rollback -- it
54
+ // (`stockDispatch.dispatchStock(def.name, args, ...)` as of the
55
+ // fork-backend removal -- UNCHANGED by this rollback either way, it
55
56
  // predates and outlives the swap) currently runs inside the
56
57
  // `CallToolRequestSchema` override's per-tool lookup; re-wire that
57
58
  // same lookup into a single hand-rolled `handleToolsCall()`
@@ -79,27 +80,15 @@
79
80
  // every other file in this repo had by default before this phase.
80
81
  // A rollback restores that protection as a side effect of removing
81
82
  // 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";
83
+ // The lease-state accessors and shared error hierarchy (used by BOTH
84
+ // backends, since buildHeldLease() reads activeInstance() on every stock
85
+ // tool call too) live in vice-errors.ts. The fork's own HTTP/JSON-RPC
86
+ // transport module (its outer-name refusal array, the session-identity
87
+ // apparatus, `call()`/`callTool`, `serverInfo()`) is gone entirely: every
88
+ // remaining tool dispatch in this file goes through stockDispatch, never
89
+ // through a fork transport.
90
+ import { activeInstance, useInstance, mcpHost, type ActiveInstance, type ToolInfo } from "./vice-errors.ts";
91
+ import { repoRoot, toolsDir } from "./repo-root.ts";
103
92
  // The single version-resolution seam (quick-260819-tsz, D-5) -- PROXY_VERSION
104
93
  // below is the only consumer in this file; see version.ts's own header for
105
94
  // why this file must never re-derive any part of the algorithm itself.
@@ -109,15 +98,15 @@ import { hostPath, SET_ENV_HINT } from "./hostpath.ts";
109
98
  // own host-local coordinates before useInstance() ever adopts them (this
110
99
  // task, quick-260801-ccn). Consuming this from the proxy -- rather than
111
100
  // hand-translating a host path here -- is what keeps the host-path consumer
112
- // set closed to a fixed, traced list of exactly five production modules
113
- // (containerpath.ts, install-resources.ts, stock-paths.ts, vice-proxy.ts,
114
- // vice-sync.ts), pinned by hostpath-consumers.test.ts.
101
+ // set closed to a fixed, traced list of exactly four production modules
102
+ // (containerpath.ts, install-resources.ts, stock-paths.ts, vice-proxy.ts),
103
+ // pinned by hostpath-consumers.test.ts.
115
104
  import { containerizeRecord } from "./containerpath.ts";
116
105
  // The container-side half of the on-demand broker protocol (Phase 01.2).
117
106
  // This module deliberately does NOT import hostpath.mjs itself -- the
118
- // host-path consumer set stays closed to exactly five production modules
119
- // (containerpath.ts, install-resources.ts, stock-paths.ts, vice-proxy.ts,
120
- // vice-sync.ts), pinned by hostpath-consumers.test.ts, and this file is
107
+ // host-path consumer set stays closed to exactly four production modules
108
+ // (containerpath.ts, install-resources.ts, stock-paths.ts, vice-proxy.ts),
109
+ // pinned by hostpath-consumers.test.ts, and this file is
121
110
  // already on that list, so any broker-related host path text is built HERE.
122
111
  // Tasks 1+2 (this plan) swap acquisition, release AND recycle onto the TCP
123
112
  // control session (openBrokerControl()/BrokerControlSession, plan 06's
@@ -130,33 +119,28 @@ import { containerizeRecord } from "./containerpath.ts";
130
119
  // deadline, task 3's renamed successor to the now-deleted
131
120
  // RECYCLE_ACK_TIMEOUT_MS) is reused below as the bound the post-kill
132
121
  // epoch-and-readiness poll uses -- a concern this swap does not touch.
122
+ // RECYCLE_TIMEOUT_MS is no longer imported here: the fork-only generic
123
+ // forwarding function and its own wedge-evidence gatherer that used it for
124
+ // their post-kill epoch/readiness poll are deleted -- vice_recycle's
125
+ // stock implementation (stock-recycle.ts, reached via stockDispatch) owns
126
+ // that timeout itself now. The incident-record import (writeIncidentRecord,
127
+ // finaliseIncidentRecord, incidentAssetPath, incidentAssetStem,
128
+ // IncidentEvidence, IncidentAssetStemOptions) is gone for the same reason:
129
+ // its only caller was the fork-only handleRecycle() body that wrote a
130
+ // pre-kill incident record over call() -- stock-recycle.ts's own
131
+ // handleRecycleStock() does this natively now, never through this file.
133
132
  import {
134
133
  readBrokerLiveness,
135
134
  brokerRootDir,
136
- RECYCLE_TIMEOUT_MS,
137
135
  openBrokerControl,
138
136
  type BrokerLivenessResult,
139
137
  type BrokerControlSession,
140
138
  type ControlFailureKind,
141
139
  type HeldLease,
142
140
  } from "./vice-broker-client.ts";
143
- // The recycle path's own incident record (plan 01.3-01) -- written BEFORE
144
- // anything is killed (D-17), never through any network call of its own.
145
- // incidentAssetPath()/incidentAssetStem() (plan 01.3-03) are the SAME stem-
146
- // building logic incidentRecordPath() itself uses -- imported here so the
147
- // evidence gatherer's screenshot and the pre-kill snapshot's name can never
148
- // drift onto a second, independent naming rule.
149
- import {
150
- writeIncidentRecord,
151
- finaliseIncidentRecord,
152
- incidentAssetPath,
153
- incidentAssetStem,
154
- type IncidentEvidence,
155
- type IncidentAssetStemOptions,
156
- } from "./incident-record.ts";
157
141
  import { readFileSync } from "node:fs";
158
142
  import { fileURLToPath } from "node:url";
159
- import { dirname, join, relative, resolve } from "node:path";
143
+ import { dirname, join, resolve } from "node:path";
160
144
  // The wire-layer replacement (this plan, D-01): MCPServer owns tools/list's
161
145
  // schema-conversion dispatch; the CallToolRequestSchema override installed
162
146
  // below (immediately after startStdio(), see that call site's own comment)
@@ -180,11 +164,6 @@ import { CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
180
164
  // and every call site.
181
165
  import * as backendDetect from "./backend-detect.mts";
182
166
  import * as stockDispatch from "./stock-dispatch.ts";
183
- // Plan 08-02: the single per-backend capability lookup (BACK-05), consumed
184
- // only inside the CallToolRequestSchema override's tools[name] miss branch
185
- // below, strictly after the DENY_LIST check -- see the comment at that call
186
- // site for why the ordering is load-bearing.
187
- import { capabilityRefusalMessage } from "./capability-registry.ts";
188
167
  // Plan 29-01: the curated anno_* tool surface's DEFINITIONS, imported
189
168
  // STATICALLY -- registration below happens synchronously at module scope, so
190
169
  // a dynamic import cannot serve it. This costs nothing at module load: no
@@ -206,11 +185,11 @@ import { ANNO_TOOL_DEFINITIONS, runAnnoTool } from "./anno-tools.ts";
206
185
  // work in all three routes.
207
186
  //
208
187
  // This branch runs as the first executable statement of the module body,
209
- // deliberately ABOVE `ACTIVE_BACKEND`'s backend probe (which shells out to a
210
- // binary's `--help`), above the manifest read, and above
188
+ // deliberately ABOVE `RESOLVED_BINARY`'s own path resolution (which stats the
189
+ // binary), above the manifest read, and above
211
190
  // `new MCPServer(...)`/`server.startStdio()` far below -- a CLI invocation
212
- // must never open a socket, never probe a binary, and never write a byte of
213
- // JSON-RPC to stdout. WHAT NOT TO DO: never let this branch fall through
191
+ // must never open a socket, never touch a binary's filesystem identity, and
192
+ // never write a byte of JSON-RPC to stdout. WHAT NOT TO DO: never let this branch fall through
214
193
  // into the server path, and never print anything on stdout on the server
215
194
  // path that a CLI caller could confuse for `anno` output.
216
195
  //
@@ -312,13 +291,16 @@ if (process.argv[2] === "anno") {
312
291
 
313
292
  const HERE_DIR = dirname(fileURLToPath(import.meta.url));
314
293
 
315
- // D-01/BACK-01: the backend is settled exactly ONCE here, at module scope --
316
- // never re-settled per tool or per call. Every seam below (manifest
317
- // selection, the tools construction loop's dispatch choice, and the final
318
- // ready log line) reads THIS constant, never the raw environment variable
319
- // and never a second detection call.
294
+ // FORKRM-01 (plan 52-06): there is one backend now, so there is nothing left
295
+ // to select between here -- this used to settle a backend verdict constant
296
+ // once, at module scope, for the manifest selection, the tools construction
297
+ // loop's dispatch choice, the final ready log line, and a cross-check
298
+ // against the broker's own verdict. All four backend-conditional call sites
299
+ // now pass the literal `"stock"` directly; the only thing still resolved
300
+ // here is the binary's own PATH, kept under a narrowly-named constant
301
+ // instead of a backend-shaped object.
320
302
  //
321
- // `ACTIVE_BACKEND.binPath` is what `vice_ping`'s `resolvedBinaryPath` field
303
+ // `RESOLVED_BINARY.binPath` is what `vice_ping`'s `resolvedBinaryPath` field
322
304
  // reports (see stock-dispatch.ts's `handlePing()`). It is resolved exactly
323
305
  // ONCE here, at MCP-server process startup, by a bare `x64sc` `$PATH` probe
324
306
  // run in THIS process's own environment -- it is NOT re-probed per request
@@ -334,7 +316,7 @@ const HERE_DIR = dirname(fileURLToPath(import.meta.url));
334
316
  // (2026-08-19 finding, closed as a documentation fix by Phase 15 plan 15-09
335
317
  // rather than a per-request requery, which would be a behavioural change out
336
318
  // of a disposition phase's remit).
337
- const ACTIVE_BACKEND = backendDetect.resolvedBackend();
319
+ const RESOLVED_BINARY = backendDetect.resolvedBackend();
338
320
 
339
321
  // -------------------------------------------------------------- JSON-RPC
340
322
  //
@@ -569,12 +551,13 @@ const DIAGNOSE_TOOL: ToolDefinition = {
569
551
  };
570
552
 
571
553
  // Edit 1 (plan 02-10): delegates to stock-dispatch.ts's own selector function
572
- // -- the ONE manifest site this file keeps, now backend-aware. The existing
573
- // malformed-manifest fallbacks in readManifestTools() below are untouched: a
574
- // missing or unreadable stock manifest still answers tools/list with an
575
- // empty array rather than crashing the server.
554
+ // -- the ONE manifest site this file keeps. FORKRM-01: always resolves the
555
+ // stock manifest now, since there is nothing else to select between; the
556
+ // existing malformed-manifest fallbacks in readManifestTools() below are
557
+ // untouched: a missing or unreadable stock manifest still answers tools/list
558
+ // with an empty array rather than crashing the server.
576
559
  function manifestPath(): string {
577
- return stockDispatch.manifestPathForBackend(ACTIVE_BACKEND.backend, HERE_DIR, process.env.VICE_TOOLS_MANIFEST);
560
+ return stockDispatch.manifestPathForBackend(HERE_DIR, process.env.VICE_TOOLS_MANIFEST);
578
561
  }
579
562
 
580
563
  function readManifestTools(): ToolInfo[] {
@@ -613,101 +596,27 @@ function readManifestTools(): ToolInfo[] {
613
596
 
614
597
  // --------------------------------------------------------------- tools/call
615
598
  //
616
- // Delegates every real call to the reused `call()` -- the retry ladder
617
- // already lives there (Pattern 1). Per Pattern 2, EVERY outcome of a tool
618
- // invocation attempt -- success or failure -- becomes a well-formed
619
- // `{content, isError}` result, never a JSON-RPC `error` object. Malformed
620
- // `tools/call` params (a missing/non-string `name`) are now rejected one
621
- // layer further out, by the SDK's own `CallToolRequestSchema` zod validation
622
- // (installed at the construction site near the bottom of this file) --
623
- // there is no `ProtocolError`/`handleMessage()` pair left in this file to
624
- // catch that case.
625
- //
626
- // Two hazards are enforced HERE, at the proxy seam, as independent layers on
627
- // top of what `call()` already does internally:
599
+ // Every advertised tool dispatches through stockDispatch.dispatchStock(),
600
+ // which owns its own reconnect and epoch-drift handling (stock-connect.ts).
601
+ // This proxy layer performs no per-call epoch re-check of its own -- the
602
+ // generic forwarding function that once needed one here is gone. Malformed
603
+ // `tools/call` params (a missing/non-string `name`) are rejected one layer
604
+ // further out, by the SDK's own `CallToolRequestSchema` zod validation
605
+ // (installed at the construction site near the bottom of this file).
628
606
  //
629
- // 1. vice_disk_list refusal. `call()` already refuses it (throwing a
630
- // ViceError), but this proxy refuses it FIRST, before any forwarding
631
- // logic runs and before any network attempt, so the refusal is
632
- // observable with zero HTTP traffic and a well-formed MCP frame rather
633
- // than one more layer of catch between the hazard and the answer.
634
- //
635
- // 2. Per-call epoch re-check (decision D-D). The proxy does NOT call
636
- // assertSameMachine() and does NOT probe vice_checkpoint_list -- a
637
- // state-reading call that pauses the emulated CPU and never resumes it,
638
- // and the proxy arms no checkpoints of its own to probe with anyway.
639
- // The narrowed contract is a plain readEpoch() comparison, before AND
640
- // after every forwarded call: a changed epoch refuses the call (or
641
- // discards its result, if the change happened mid-call) with a loud,
642
- // evidence-carrying error naming both epoch values, then adopts the new
643
- // value as the baseline so the SESSION stays usable -- a restart report
644
- // is never cached, per criterion 6.
645
607
  // NEVER-CACHE-A-NEGATIVE-RESULT INVARIANT (plan 01.1-03 task 1, criterion 6;
646
608
  // extended to the broker path by plan 01.2-03 task 1, C11): nothing below
647
- // this line may memoise "the host is down" -- or, as of this extension,
648
- // "the broker is absent" -- as a fact that outlives a single tools/call.
649
- // There is no cached probe verdict, no sticky "last known unreachable" flag,
650
- // and no early-return short-circuit keyed off a PREVIOUS failure -- every
651
- // forwarded tools/call re-evaluates reachability from scratch (the epoch
652
- // check below reads the file fresh every time; the liveness probe added in
653
- // task 2 does its own fresh network round trip every time; task 3's
654
- // translation runs fresh every time; ensureBrokerLease()'s
655
- // readBrokerLiveness() call reads broker.json fresh every time it is
656
- // reached, never memoised at module scope). This is deliberate and easy to
657
- // break by a later, performance-minded edit ("let's skip the probe if we
658
- // just failed one 200ms ago", or "let's remember the broker was absent last
659
- // call so we don't bother checking again") -- don't, for either path. A
660
- // cached negative here is exactly the "quiet wrong answer" failure class
661
- // this codebase rejects elsewhere (MachineRestartedError, the epoch
662
- // re-check itself): the call after a human starts the broker must just
663
- // work, with no session restart required.
664
- let viceSession: SessionInfo | null = null; // beginSession()'s return value, set lazily on the first forwarded call
665
- let epochBaseline: EpochResult | null = null; // the rolling comparison point; updated on every re-baseline
666
-
667
- function ensureViceSession(): void {
668
- if (!viceSession) {
669
- viceSession = beginSession();
670
- epochBaseline = viceSession.baseline;
671
- }
672
- }
673
-
674
- function currentEpoch(): EpochResult {
675
- return readEpoch((viceSession as SessionInfo).epochPath);
676
- }
677
-
678
- function epochChanged(baseline: EpochResult | null, current: EpochResult | null): boolean {
679
- return Boolean(baseline?.present) && Boolean(current?.present) && baseline!.epoch !== current!.epoch;
680
- }
681
-
682
- function epochDriftMessage(when: string, baseline: EpochResult, current: EpochResult): string {
683
- const pidNote = current && current.pid != null ? `, pid ${current.pid}` : "";
684
- const spawnedNote = current && current.spawned_at ? `, spawned_at ${current.spawned_at}` : "";
685
- return (
686
- `vice: treat every result since the previous call as void and redo that work -- epoch drift was ` +
687
- `detected ${when} (epoch changed from ${baseline.epoch} to ${current.epoch}${pidNote}${spawnedNote}).`
688
- );
689
- }
690
-
691
- /**
692
- * Compare the rolling baseline against a fresh epoch read. Returns an error
693
- * MESSAGE string if the comparison proves a restart (and re-baselines to the
694
- * new value so the next call is not refused again), or `null` if the call
695
- * may proceed (including the "absent baseline, now present" case, which is
696
- * adopted silently -- a supervisor merely started, not a restart, mirroring
697
- * vice.ts's own "only compare when both are present" rule).
698
- */
699
- function checkEpochAndRebaseline(when: string): string | null {
700
- const current = currentEpoch();
701
- if (epochChanged(epochBaseline, current)) {
702
- const msg = epochDriftMessage(when, epochBaseline as EpochResult, current);
703
- epochBaseline = current; // never cache a negative result (criterion 6)
704
- return msg;
705
- }
706
- if (!(epochBaseline as EpochResult).present && current.present) {
707
- epochBaseline = current;
708
- }
709
- return null;
710
- }
609
+ // this line may memoise "the broker is absent" as a fact that outlives a
610
+ // single tools/call. There is no cached probe verdict, no sticky "last known
611
+ // unreachable" flag, and no early-return short-circuit keyed off a PREVIOUS
612
+ // failure -- ensureBrokerLease()'s readBrokerLiveness() call reads
613
+ // broker.json fresh every time it is reached, never memoised at module
614
+ // scope. This is deliberate and easy to break by a later, performance-minded
615
+ // edit ("let's remember the broker was absent last call so we don't bother
616
+ // checking again") -- don't. A cached negative here is exactly the "quiet
617
+ // wrong answer" failure class this codebase rejects elsewhere
618
+ // (MachineRestartedError): the call after a human starts the broker must
619
+ // just work, with no session restart required.
711
620
 
712
621
  interface ErrorTextResult {
713
622
  content: { type: "text"; text: string }[];
@@ -720,857 +629,83 @@ function isErrorText(text: string): ErrorTextResult {
720
629
 
721
630
  /** The shape every tools/call outcome takes (Pattern 2): success or failure,
722
631
  * never a JSON-RPC `error` object. Shared by handleRecycle(), handleDiagnose(),
723
- * handleResultContinue(), wrapPossiblyChunked() and handleToolsCall() itself. */
632
+ * handleResultContinue(), wrapPossiblyChunked() and the CallToolRequestSchema
633
+ * override itself (near the bottom of this file). */
724
634
  interface OkTextResult {
725
635
  content: { type: "text"; text: string }[];
726
636
  isError: false;
727
637
  }
728
638
  type ToolCallResult = ErrorTextResult | OkTextResult;
729
639
 
730
- // ------------------------------------------------------------ vice_recycle
640
+ // -------------------------------------------------------- dispatchStockFor
731
641
  //
732
- // Re-baselines the proxy's own epoch tracking after a CONFIRMED recycle.
733
- // Mirrors ensureBrokerLease()'s own `viceSession = null` re-baseline
734
- // (further down this file) for the identical reason: a recycle is a
735
- // DELIBERATE identity change, and without this the very next forwarded
736
- // call would fail its own epoch drift guard against a baseline that is now
737
- // stale by construction. Clearing epochBaseline too (not just viceSession)
738
- // means nothing in between reads the stale value before the next
739
- // ensureViceSession() call re-populates both from a fresh read.
740
- function rebaselineEpochAfterRecycle(): void {
741
- viceSession = null;
742
- epochBaseline = null;
743
- }
744
-
745
- /** Renders a human-facing message for a recycle ack whose kill stage was
746
- * NOT a successful kill -- named per outcome so an operator reading the
747
- * result can tell "no grant record" from "unreadable epoch file" from "no
748
- * pid recorded" from "identity mismatch" without opening the broker log
749
- * (matches resources/vice-broker.sh's own per-outcome ack strings). */
750
- function recycleAckOutcomeMessage(ack: Record<string, unknown>): string {
751
- const outcome = ack && typeof ack.outcome === "string" ? ack.outcome : "unknown";
752
- const stage = ack && typeof ack.kill_stage === "string" ? ack.kill_stage : "unknown";
753
- const reason = ack && typeof ack.reason === "string" && ack.reason ? ` (${ack.reason})` : "";
754
- switch (outcome) {
755
- case "identity_refused":
756
- return (
757
- `vice_recycle: the host refused to signal the target -- its process identity did not match ` +
758
- `the binary recorded in its own epoch file (kill stage: ${stage}). The instance was NOT ` +
759
- `killed and is still running.`
760
- );
761
- case "target_lookup_failed":
762
- return `vice_recycle: the host could not resolve this session's own recycle target (kill stage: ${stage})${reason}.`;
763
- case "grant_lookup_failed":
764
- return `vice_recycle: the host found no grant record for this session's target (kill stage: ${stage})${reason}.`;
765
- case "epoch_lookup_failed":
766
- return `vice_recycle: the host could not read the target's epoch file (kill stage: ${stage})${reason}.`;
767
- case "pid_lookup_failed":
768
- return `vice_recycle: the target's own epoch file carries no pid to signal (kill stage: ${stage})${reason}.`;
769
- default:
770
- return `vice_recycle: the host reported outcome "${outcome}" (kill stage: ${stage})${reason}.`;
771
- }
772
- }
773
-
774
- /**
775
- * Handles the destructive vice_recycle tool. Fixed order, and the order is
776
- * the point (plan 01.3-01 task 1): read the current epoch first; refuse
777
- * (no incident record, no request) when no broker lease is held yet or an
778
- * explicit VICE_MCP_URL override is in effect -- there is no broker to ask
779
- * and no supervisor to respawn either way; write the incident record BEFORE
780
- * anything else touches the host (D-17); only then write the recycle
781
- * request; await the ack; on anything other than a successful kill,
782
- * finalise the record with that outcome and return a well-formed error
783
- * naming the stage verbatim; on a successful kill, poll for the epoch to
784
- * move and probe readiness as two SEPARATE facts (T-01.3-03), finalise the
785
- * record, re-baseline, and return success. Never throws past this point --
786
- * every branch is a well-formed isError result (a dead stdio proxy is
787
- * unrecoverable for the session).
788
- */
789
- // Declared as `const ... = async function handleRecycle(args) { ... }` (a
790
- // contextually-typed function EXPRESSION), not `async function
791
- // handleRecycle(args: ...) { ... }` (a typed declaration): the latter's
792
- // exact param-list text would drift from vice-proxy.test.mjs's own
793
- // structural oracle (`indexOf("async function handleRecycle(args)")`),
794
- // which is off-limits to edit in this plan. The variable's own type
795
- // annotation gives `args` a real, checked type via TS's ordinary contextual
796
- // typing for a function expression assigned to a typed const -- verified
797
- // live this session against a scratch file (see RE-FINDINGS.md) -- so this
798
- // is real typing, not a suppression: every field read below still narrows
799
- // `args` the same way every other handler in this file does.
800
- const handleRecycle: (args: Record<string, unknown>) => Promise<ToolCallResult> = async function handleRecycle(args) {
801
- const rawReason = args && typeof args.reason === "string" ? args.reason : "";
802
- const reason = rawReason.trim();
803
- if (!reason) {
804
- return isErrorText(
805
- 'vice_recycle requires a non-empty "reason" string naming why this recycle is happening -- it ' +
806
- "becomes the incident record's own explanation, written before anything is killed. No record " +
807
- "and no request were written."
808
- );
809
- }
810
-
811
- const preKillEpoch = readEpoch();
812
-
813
- if (process.env.VICE_MCP_URL) {
814
- return isErrorText(
815
- "vice_recycle: VICE_MCP_URL is set, so this session talks to an explicitly overridden endpoint " +
816
- "with no broker to ask and no supervisor to respawn it. Recycle only applies to a broker-" +
817
- "granted instance. No record and no request were written."
818
- );
819
- }
820
- if (!controlSession) {
821
- return isErrorText(
822
- "vice_recycle: no broker lease is held yet for this session -- recycle only applies to an " +
823
- "instance already granted to this session. Make at least one other forwarded call first. " +
824
- "No record and no request were written."
825
- );
826
- }
827
-
828
- const sessionId = process.env.CLAUDE_CODE_SESSION_ID || null;
829
- const { port } = activeInstance();
830
- const epochBefore = preKillEpoch.present ? preKillEpoch.epoch : null;
831
- const at = new Date().toISOString();
832
-
833
- // Plan 01.3-03 (D-17, extended): gather the FULL criterion-4 evidence set
834
- // -- including the best-effort pre-kill snapshot -- BEFORE the record is
835
- // written. There is no argument, environment variable or branch between
836
- // here and the record write that can reach the request write with any of
837
- // this still ungathered; every step above degrades to unavailable rather
838
- // than aborting, so this line always completes.
839
- const evidence = await gatherWedgeEvidence({ at, port, epoch: epochBefore });
840
- evidence.snapshot = await captureSnapshotAttempt({ at, port, epoch: epochBefore });
841
-
842
- // D-17: the record is written BEFORE the request -- capturing is
843
- // structurally impossible to skip, not a discipline to remember.
844
- const recordPath = writeIncidentRecord({
845
- at,
846
- port,
847
- epoch_before: epochBefore,
848
- reason,
849
- session_id: sessionId,
850
- evidence,
851
- });
852
-
853
- // Plan 01.6.2-07 task 2: the request write + ack poll are replaced by one
854
- // recycle request over the connection this session already holds -- the
855
- // client_pid this session used to send with a recycle request has no
856
- // successor field on the wire, since the connection itself already
857
- // identifies which grant this is (broker-control.mts's own T-01.6.2-31
858
- // discipline: a connection may only recycle the grant it itself holds).
859
- const recycled = await controlSession.recycle(grantId as string);
860
- if (!recycled.ok) {
861
- if (recycled.kind === "broker_gone") {
862
- // D-14 (plan 08): distinct from an acknowledgement carrying a refusal
863
- // (T-01.6.2-46) -- the instance's state is unknown in both cases, but
864
- // the operator's next action differs, a refusal means the target is
865
- // alive and uncooperative, broker_gone means there is no longer
866
- // anyone to ask. Reuses sessionMustRestartMessage() -- the SAME
867
- // fresh-machine vocabulary a forwarded call's own broker-gone path
868
- // (handleGrantedInstanceUnreachable() above) produces -- rather than
869
- // a bare transport error string. Deliberately does NOT attempt to
870
- // open a fresh session and acquire a replacement the way a forwarded
871
- // call does: the instance THIS recycle was trying to kill is now of
872
- // genuinely unknown state (the kill request may or may not have
873
- // reached the broker before the connection dropped), and silently
874
- // handing back a different "replacement" instance under the name of
875
- // a recycle result would claim more certainty about that kill than
876
- // this proxy actually has.
877
- finaliseIncidentRecord(recordPath, { outcome: "broker_gone" });
878
- return isErrorText(
879
- `${sessionMustRestartMessage(recycled)} Incident record: ${recordPath}. This recycle's own kill ` +
880
- `request may or may not have reached the broker before the connection dropped -- the instance's ` +
881
- `state is now unknown.`
882
- );
883
- }
884
- if (recycled.kind === "deadline") {
885
- finaliseIncidentRecord(recordPath, { outcome: "timeout" });
886
- return isErrorText(
887
- `vice_recycle: no ack arrived from the host within the timeout (${recycled.message}). Incident ` +
888
- `record: ${recordPath}. The instance's state is now unknown -- treat it as neither confirmed ` +
889
- `killed nor confirmed alive.`
890
- );
891
- }
892
- // Any other control-plane failure (protocol/unauthorized/bad_request/
893
- // denied/internal) -- an unexpected shape from the broker's own
894
- // response, not exhaustively enumerated here (D-14's full vocabulary is
895
- // plan 08's); still a well-formed, non-throwing result either way.
896
- finaliseIncidentRecord(recordPath, { outcome: "internal" });
897
- return isErrorText(
898
- `vice_recycle: the recycle request failed (${recycled.kind}: ${recycled.message}). Incident record: ${recordPath}.`
899
- );
900
- }
901
-
902
- const ack = recycled.ack;
903
- const killStage: string | null = ack.kill_stage;
904
- const successfulKill = killStage === "already_exited" || killStage === "sigterm" || killStage === "sigkill";
905
-
906
- if (!successfulKill) {
907
- finaliseIncidentRecord(recordPath, { outcome: ack.outcome || "refused", kill_stage: killStage });
908
- return isErrorText(`${recycleAckOutcomeMessage({ ...ack })} Incident record: ${recordPath}.`);
909
- }
910
-
911
- // The kill succeeded -- confirm the machine actually came back. The epoch
912
- // bump and the readiness probe are reported as two SEPARATE facts
913
- // (T-01.3-03): "the epoch moved" is bookkeeping, "the instance answers"
914
- // is evidence, and neither substitutes for the other.
915
- const epochDeadline = Date.now() + RECYCLE_TIMEOUT_MS;
916
- let afterEpoch = readEpoch();
917
- const epochMoved = () =>
918
- afterEpoch.present && (!preKillEpoch.present || (afterEpoch.epoch as number) > (preKillEpoch.epoch as number));
919
- while (Date.now() < epochDeadline && !epochMoved()) {
920
- await new Promise((r) => setTimeout(r, 250));
921
- afterEpoch = readEpoch();
922
- }
923
-
924
- const { url, port: instancePort } = activeInstance();
925
- const probe = await probeInstance({ url, port: instancePort });
926
-
927
- // 2026-08-05 defect fix: the persisted record's own epoch_after must never
928
- // carry a stale value equal to epoch_before -- that pair reads as
929
- // "confirmed unchanged" to a future reader, which is a false claim for a
930
- // kill that just genuinely succeeded (killStage is one of the three
931
- // successful-kill stages here, by construction of the guard above). Only
932
- // the poll loop's own epochMoved() -- not merely afterEpoch.present -- may
933
- // promote the read into the record; anything else stays the honest `null`
934
- // ("not yet known", per renderIncidentRecord()'s existing rendering) so a
935
- // future investigation is never handed a pair that looks complete but
936
- // isn't.
937
- finaliseIncidentRecord(recordPath, {
938
- outcome: "ok",
939
- kill_stage: killStage,
940
- epoch_after: epochMoved() ? afterEpoch.epoch : null,
642
+ // The one place every stock tool call's shared deps object is built -- used
643
+ // by the manifest loop below and by handleRecycle()/handleDiagnose() alike,
644
+ // so there is exactly one definition of "what dispatchStock needs" rather
645
+ // than three copies that could drift apart. Kept as a single-line-callable
646
+ // helper (not inlined at each call site) so every registration reads as one
647
+ // source line -- vice-proxy.test.ts's own registration scanner keys each
648
+ // `tools[...] = ...;` line by its raw captured text and expects one
649
+ // registration per line.
650
+ function dispatchStockFor(name: string, args: Record<string, unknown>): Promise<ToolCallResult> {
651
+ return stockDispatch.dispatchStock(name, args, {
652
+ ensureLease: ensureBrokerLease,
653
+ resolvedBinaryPath: RESOLVED_BINARY.binPath,
654
+ resolvedBinaryPathIsResolved: RESOLVED_BINARY.binPathResolved,
941
655
  });
942
-
943
- // Immediately before returning success -- the deliberate identity change
944
- // this tool exists to cause would otherwise make every subsequent
945
- // forwarded call fail the drift guard.
946
- rebaselineEpochAfterRecycle();
947
-
948
- const snapshotNote =
949
- evidence.snapshot && evidence.snapshot.available
950
- ? `accepted (name: ${(evidence.snapshot.value as { name: string }).name})`
951
- : `unavailable (${evidence.snapshot && evidence.snapshot.reason ? evidence.snapshot.reason : "no reason recorded"})`;
952
-
953
- return {
954
- content: [
955
- {
956
- type: "text",
957
- text:
958
- `vice_recycle: kill stage "${killStage}". Epoch before: ${preKillEpoch.present ? preKillEpoch.epoch : "unknown"}, ` +
959
- `epoch after: ${afterEpoch.present ? afterEpoch.epoch : "unknown"} (${epochMoved() ? "moved" : "did not move within the timeout"}). ` +
960
- `Readiness probe: ${probe.alive ? "the respawned instance answered" : `not yet answering (${probe.reason})`}. ` +
961
- `Snapshot: ${snapshotNote}. ` +
962
- `Incident record: ${recordPath}. This run is VOID -- resume from the last recorded milestone snapshot.`,
963
- },
964
- ],
965
- isError: false,
966
- };
967
656
  }
968
657
 
969
- // ----------------------------------------------------------- vice_diagnose
658
+ // ------------------------------------------------------------ vice_recycle
970
659
  //
971
- // Plan 01.3-02 task 1: the read-mostly half of this phase, up to but not
972
- // including the cycle bracket (task 2 wires that in). Every read below goes
973
- // through the proxy's existing forwarded call() path -- no new host
974
- // capability, no new protocol, no second route.
975
-
976
- // The closed, five-member verdict vocabulary, in the order the checks run.
977
- // Frozen so a future edit cannot quietly widen it -- must_have C1's whole
978
- // point.
979
- const DIAGNOSE_VERDICTS = Object.freeze(["restarted", "checkpoint_trap", "wedged", "stale_read_path", "live"]);
980
-
981
- /** Normalise a checkpoint/register address to a plain number, accepting
982
- * either a JS number or a hex string ("$1103"/"1103"/"0x1103"). An unprefixed
983
- * digit string is read as HEX, matching this project's own address
984
- * convention (every C64 address in this project's docs and RE-FINDINGS.md is
985
- * hex), never decimal. Returns null, never throws, on anything unresolvable
986
- * (T-01.3-06: an untrusted payload degrades to "unknown", never a thrown
987
- * exception). */
988
- function toAddressNumber(value: unknown): number | null {
989
- if (typeof value === "number" && Number.isFinite(value)) return value;
990
- if (typeof value === "string") {
991
- const s = value.trim().replace(/^\$/, "").replace(/^0x/i, "");
992
- const n = parseInt(s, 16);
993
- if (Number.isFinite(n)) return n;
994
- }
995
- return null;
996
- }
997
-
998
- function formatAddress(n: number | null | undefined): string {
999
- return n === null || n === undefined ? "unknown" : `$${n.toString(16).toUpperCase().padStart(4, "0")}`;
1000
- }
1001
-
1002
- function formatByte(n: number | null | undefined): string {
1003
- return n === null || n === undefined ? "unknown" : `$${n.toString(16).toUpperCase().padStart(2, "0")}`;
1004
- }
1005
-
1006
- /** Decode a vice_memory_read result into a plain byte array, accepting
1007
- * either the compact "hex" string encoding (requested below) or the legacy
1008
- * per-byte "bytes" array shape -- an untrusted payload degrades to an empty
1009
- * array, never a thrown exception (T-01.3-06). */
1010
- function bytesFromMemoryReadResult(result: unknown): number[] {
1011
- if (isPlainObject(result) && typeof result.hex === "string") {
1012
- const clean = result.hex.replace(/[^0-9a-fA-F]/g, "");
1013
- const bytes: number[] = [];
1014
- for (let i = 0; i + 1 < clean.length; i += 2) bytes.push(parseInt(clean.slice(i, i + 2), 16));
1015
- return bytes;
1016
- }
1017
- if (isPlainObject(result) && Array.isArray(result.bytes)) {
1018
- return (result.bytes as unknown[])
1019
- .map((b) => (typeof b === "string" ? parseInt(b.replace(/^\$/, ""), 16) : Number(b)))
1020
- .filter((n) => Number.isFinite(n));
1021
- }
1022
- return [];
1023
- }
1024
-
1025
- function wordFromBytes(bytes: number[]): number | null {
1026
- return bytes.length >= 2 ? bytes[0] | (bytes[1] << 8) : null;
1027
- }
1028
-
1029
- // Bit 1 (HIRAM) of the 6510 processor port at $01. SET -- the KERNAL ROM is
1030
- // banked in, and the RAM IRQ vector pair ($0314/$0315) is what the KERNAL's
1031
- // own dispatch actually reads (RE-FINDINGS.md's own vector-table entry).
1032
- // CLEAR -- the KERNAL is replaced by RAM and the CPU reads the hardware
1033
- // IRQ/BRK vector pair ($FFFE/$FFFF) directly, with no ROM indirection.
1034
- const HIRAM_MASK = 0x02;
1035
-
1036
- /** The live-IRQ-handler lookup's own return shape -- shared by
1037
- * gatherCheckpointTrapEvidence() below and by plan 01.3-03's evidence
1038
- * gatherer (gatherWedgeEvidence()). */
1039
- interface IrqHandlerResolution {
1040
- target: number | null;
1041
- pairLabel: string;
1042
- explanation: string;
1043
- }
1044
-
1045
- /**
1046
- * The single definition of the live-IRQ-handler lookup (Key Finding 6):
1047
- * three forwarded reads through the normal call() path -- $01, the RAM
1048
- * vector pair, and (only when $01 says the ROMs are banked out) the hardware
1049
- * vector pair. Consumed by the checkpoint-trap check below and, per this
1050
- * plan's own key_links, by plan 01.3-03's evidence gatherer. Memoises
1051
- * NOTHING: a disk swap, a reset or a different game retargets the handler,
1052
- * so a cached address would silently resolve the wrong pair.
1053
- */
1054
- async function resolveLiveIrqHandler(): Promise<IrqHandlerResolution> {
1055
- const portResult = await call("vice_memory_read", { address: "$01", size: 1, encoding: "hex" });
1056
- const portBytes = bytesFromMemoryReadResult(portResult);
1057
- const port01 = portBytes.length > 0 ? portBytes[0] : null;
1058
- const bankedOut = port01 !== null && (port01 & HIRAM_MASK) === 0;
1059
-
1060
- const ramResult = await call("vice_memory_read", { address: "$0314", size: 2, encoding: "hex" });
1061
- const ramTarget = wordFromBytes(bytesFromMemoryReadResult(ramResult));
1062
-
1063
- if (!bankedOut) {
1064
- return {
1065
- target: ramTarget,
1066
- pairLabel: "the RAM KERNAL IRQ vector pair ($0314/$0315)",
1067
- explanation:
1068
- `$01 read as ${formatByte(port01)} -- the KERNAL ROM is banked in, so the RAM IRQ vector pair ` +
1069
- `($0314/$0315) is the pair this session's IRQ dispatch actually reads; it resolves to ${formatAddress(ramTarget)}.`,
1070
- };
1071
- }
1072
-
1073
- const hwResult = await call("vice_memory_read", { address: "$FFFE", size: 2, encoding: "hex" });
1074
- const hwTarget = wordFromBytes(bytesFromMemoryReadResult(hwResult));
1075
- return {
1076
- target: hwTarget,
1077
- pairLabel: "the hardware IRQ/BRK vector pair ($FFFE/$FFFF)",
1078
- explanation:
1079
- `$01 read as ${formatByte(port01)} -- the KERNAL ROM is banked OUT, so the CPU dispatches ` +
1080
- `directly through the hardware IRQ/BRK vector pair ($FFFE/$FFFF) with no ROM indirection; it ` +
1081
- `resolves to ${formatAddress(hwTarget)}.`,
1082
- };
1083
- }
1084
-
1085
- /**
1086
- * Enumerate armed checkpoints, read the current PC, resolve the live IRQ
1087
- * handler, and decide the checkpoint-trap verdict on two named shapes
1088
- * (D-14): an enabled, stopping, exec checkpoint sitting exactly at the
1089
- * current PC; or one sitting at the resolved handler entry with a hit count
1090
- * of exactly zero (the corroborating tell that it has never actually
1091
- * fired). Makes NO resume and NO stopwatch call -- the whole point of
1092
- * checking this before any cycle bracket (D-14, T-01.3-08).
1093
- */
1094
- /** A single vice_checkpoint_list entry, typed loosely (matching this
1095
- * codebase's own precedent for a host-written record this proxy never
1096
- * asserts a closed shape on) -- every field is read defensively below,
1097
- * never assumed present. */
1098
- interface CheckpointInfo {
1099
- checkpoint_num?: unknown;
1100
- start?: unknown;
1101
- stop?: unknown;
1102
- exec?: unknown;
1103
- enabled?: unknown;
1104
- hit_count?: unknown;
1105
- [key: string]: unknown;
1106
- }
1107
-
1108
- interface CheckpointTrapEvidence {
1109
- isTrap: boolean;
1110
- checkpoints: CheckpointInfo[];
1111
- pc: number | null;
1112
- handler: IrqHandlerResolution;
1113
- trapCheckpoint: CheckpointInfo | null;
1114
- trapReason: "pc" | "handler" | null;
1115
- }
1116
-
1117
- async function gatherCheckpointTrapEvidence(): Promise<CheckpointTrapEvidence> {
1118
- const checkpointsResult = await call("vice_checkpoint_list", {});
1119
- const checkpoints: CheckpointInfo[] =
1120
- isPlainObject(checkpointsResult) && Array.isArray(checkpointsResult.checkpoints)
1121
- ? (checkpointsResult.checkpoints as CheckpointInfo[])
1122
- : [];
1123
-
1124
- const regs = await call("vice_registers_get", {});
1125
- const pc = isPlainObject(regs) && typeof regs.PC === "number" ? regs.PC : null;
1126
-
1127
- const handler = await resolveLiveIrqHandler();
1128
-
1129
- const armedStopping = checkpoints.filter((c) => c && c.enabled !== false && c.stop === true && c.exec === true);
1130
-
1131
- const atPc = pc !== null ? armedStopping.find((c) => toAddressNumber(c.start) === pc) : undefined;
1132
- const atHandler =
1133
- !atPc && handler.target !== null && handler.target !== undefined
1134
- ? armedStopping.find((c) => toAddressNumber(c.start) === handler.target && c.hit_count === 0)
1135
- : undefined;
1136
-
1137
- const trapCheckpoint = atPc || atHandler || null;
1138
- return {
1139
- isTrap: Boolean(trapCheckpoint),
1140
- checkpoints,
1141
- pc,
1142
- handler,
1143
- trapCheckpoint,
1144
- trapReason: atPc ? "pc" : atHandler ? "handler" : null,
1145
- };
1146
- }
1147
-
1148
- // The recorded incident this report's own "not guaranteed" paragraph cites --
1149
- // D-15's own caveat, load-bearing per this plan's planning notes: delete,
1150
- // soft reset, hard reset and an explicit single step ALL left the machine
1151
- // frozen in this recorded case.
1152
- const CHECKPOINT_TRAP_INCIDENT_REF =
1153
- ".planning/todos/pending/2026-08-01-vice-registers-frozen-after-reset-during-01-04-task2.md";
1154
-
1155
- /** Renders the checkpoint_trap verdict's report -- an explanation, never a
1156
- * remedy (D-15): it names the armed checkpoints, the resolved handler, the
1157
- * PC's relation to the trap, states plainly this is self-inflicted and not a
1158
- * wedge, names the agent's own next moves without performing any of them,
1159
- * and closes with the not-guaranteed paragraph. */
1160
- function renderCheckpointTrapReport(evidence: CheckpointTrapEvidence): string {
1161
- const { checkpoints, pc, handler, trapCheckpoint, trapReason } = evidence;
1162
- const checkpointList =
1163
- checkpoints.length === 0
1164
- ? "none armed"
1165
- : checkpoints
1166
- .map((c) => {
1167
- const addr = formatAddress(toAddressNumber(c && c.start));
1168
- const flag = c && c.stop ? "stop" : "continue";
1169
- const enabled = c && c.enabled === false ? "disabled" : "enabled";
1170
- const hitCount = c && typeof c.hit_count === "number" ? c.hit_count : "unknown";
1171
- return `#${c && c.checkpoint_num} ${addr} (${flag}, ${enabled}, hit_count ${hitCount})`;
1172
- })
1173
- .join("; ");
1174
-
1175
- const pcRelation =
1176
- trapReason === "pc"
1177
- ? `exactly at armed checkpoint #${trapCheckpoint!.checkpoint_num} -- that is why the machine is stopped here`
1178
- : trapReason === "handler"
1179
- ? `not at the armed checkpoint's own address, but checkpoint #${trapCheckpoint!.checkpoint_num} sits at ` +
1180
- "the resolved live IRQ handler entry with hit_count 0 -- the corroborating tell that this checkpoint " +
1181
- "has never actually fired, not merely that it fired between reads"
1182
- : "no relation established";
1183
-
1184
- return [
1185
- "vice_diagnose verdict: checkpoint_trap",
1186
- "",
1187
- `Armed checkpoints: ${checkpointList}.`,
1188
- `Resolved live IRQ handler: ${handler.explanation}`,
1189
- `Current PC: ${formatAddress(pc)} -- ${pcRelation}.`,
1190
- "",
1191
- "This is a self-inflicted stop, not a wedge: the machine paused because an armed checkpoint " +
1192
- "fired or sits exactly here, not because it stopped retiring cycles on its own. Recycling now " +
1193
- "would destroy a healthy instance -- no cycle bracket was run to reach this verdict.",
1194
- "",
1195
- "Next moves available to you (this report does not perform any of them): vice_checkpoint_delete " +
1196
- "the offending checkpoint, or vice_checkpoint_toggle it disabled; vice_execution_step past it; " +
1197
- "then re-run vice_diagnose.",
1198
- "",
1199
- "Not guaranteed: deleting the checkpoint is not guaranteed to unfreeze the machine. The recorded " +
1200
- `incident (${CHECKPOINT_TRAP_INCIDENT_REF}) shows checkpoint delete, then a soft reset, then a hard ` +
1201
- "reset, then an explicit single step ALL leaving the machine frozen in sequence -- a checkpoint " +
1202
- "trap may be the onset without being the whole story. If a cycle bracket still measures zero " +
1203
- "after the checkpoint is gone, the verdict becomes wedged and recycle is the fallback after all.",
1204
- ].join("\n");
1205
- }
1206
-
1207
- /** Renders the restarted verdict's report -- reached from a plain epoch-file
1208
- * comparison alone, at zero emulator calls (D-14's ordering: this check
1209
- * costs nothing and runs first). */
1210
- function renderRestartedReport(beforeEpoch: number | null | undefined, afterEpoch: number | null | undefined): string {
1211
- return (
1212
- "vice_diagnose verdict: restarted\n\n" +
1213
- `The host VICE MCP server's epoch changed from ${beforeEpoch} to ${afterEpoch} -- the emulator ` +
1214
- "behind this session restarted. This is answered from a plain epoch comparison alone, at zero " +
1215
- "emulator calls; no checkpoint enumeration was attempted, because a restart is this project's own " +
1216
- "already-handled case (criterion 1) and re-deriving it here would be a second mechanism. Any run " +
1217
- "in flight before this point is void."
1218
- );
1219
- }
1220
-
1221
- // Plan 01.3-02 task 2: the cycle bracket, the definitive liveness test, and
1222
- // the three verdicts that depend on it (wedged, stale_read_path, live).
1223
-
1224
- // Three polls: the bracket needs the machine to be given real forwarded
1225
- // round trips to retire cycles across, and three is enough for the counter
1226
- // to move at any rate worth calling alive.
1227
- const CYCLE_BRACKET_PINGS = 3;
1228
- // Two brackets: criterion 2's minimum for a wedged verdict is two
1229
- // consecutive zeros, and D-04 makes every additional bracket another call to
1230
- // the tool most correlated with host death. Two is the minimum and the
1231
- // maximum.
1232
- const CYCLE_BRACKET_MAX = 2;
1233
-
1234
- // ~991,000 cycles/s is the measured PAL C64 full-speed rate (RE-FINDINGS.md,
1235
- // "the only trustworthy VICE liveness test is a cycle bracket"). Printed
1236
- // only, as an observation beside a measured rate -- D-08 refuses a
1237
- // degradation threshold, and a constant that is only ever printed cannot
1238
- // become one by accident.
1239
- const BASELINE_CYCLES_PER_SECOND = 991000;
1240
-
1241
- function cyclesFromStopwatchResult(result: unknown): number {
1242
- if (isPlainObject(result) && typeof result.cycles === "number") return result.cycles;
1243
- if (isPlainObject(result) && typeof result.previous_cycles === "number") return result.previous_cycles;
1244
- return 0;
1245
- }
1246
-
1247
- interface CycleBracketResult {
1248
- cycles: number;
1249
- elapsedMs: number;
1250
- }
1251
-
1252
- /**
1253
- * The single definition of the cycle bracket criterion 2 requires: reset the
1254
- * stopwatch, resume execution exactly once, poll with ping
1255
- * CYCLE_BRACKET_PINGS times, pause, read the stopwatch back. Pacing comes
1256
- * from the forwarded round trips alone -- there is no timer, no delay and no
1257
- * wall-clock quantity anywhere in it (the standing project rule). Every
1258
- * stopwatch call in this file lives inside this function's body; the
1259
- * structural test enforces it. `elapsedMs` is measured only to print an
1260
- * observational rate afterward -- it decides nothing and paces nothing.
1261
- */
1262
- async function runCycleBracket() {
1263
- await call("vice_cycles_stopwatch", { action: "reset" });
1264
- const startedAt = Date.now();
1265
- await call("vice_execution_run", {});
1266
- for (let i = 0; i < CYCLE_BRACKET_PINGS; i += 1) {
1267
- await call("vice_ping", {}); // the ping EXECUTION field is never inspected here -- it decides nothing (C1, D-07)
1268
- }
1269
- await call("vice_execution_pause", {});
1270
- const elapsedMs = Date.now() - startedAt;
1271
- const readResult = await call("vice_cycles_stopwatch", { action: "read" });
1272
- const cycles = cyclesFromStopwatchResult(readResult);
1273
- return { cycles, elapsedMs };
1274
- }
1275
-
1276
- function registersByteIdentical(a: unknown, b: unknown): boolean {
1277
- try {
1278
- return JSON.stringify(a) === JSON.stringify(b);
1279
- } catch {
1280
- return false;
1281
- }
1282
- }
1283
-
1284
- interface BracketEvidence {
1285
- regsBefore: unknown;
1286
- regsAfter: unknown;
1287
- bracket1: CycleBracketResult;
1288
- bracket2: CycleBracketResult | null;
1289
- finalBracket: CycleBracketResult;
1290
- }
1291
-
1292
- /**
1293
- * Gathers the bracket evidence: a register snapshot at each end, bracket
1294
- * one, and -- only when bracket one retired exactly zero cycles -- bracket
1295
- * two. A non-zero first bracket short-circuits (D-04): the answer is already
1296
- * not wedged, and a second resume buys nothing.
1297
- */
1298
- async function gatherBracketEvidence(): Promise<BracketEvidence> {
1299
- const regsBefore = await call("vice_registers_get", {});
1300
- const bracket1 = await runCycleBracket();
1301
- let bracket2: CycleBracketResult | null = null;
1302
- let finalBracket = bracket1;
1303
- if (bracket1.cycles === 0) {
1304
- bracket2 = await runCycleBracket();
1305
- finalBracket = bracket2;
1306
- }
1307
- const regsAfter = await call("vice_registers_get", {});
1308
- return { regsBefore, regsAfter, bracket1, bracket2, finalBracket };
1309
- }
1310
-
1311
- type LivenessVerdict = "wedged" | "stale_read_path" | "live";
1312
-
1313
- /**
1314
- * Produces the post-bracket verdict (criterion 2/3). Two consecutive zeros
1315
- * is wedged and nothing else is. On any non-zero result (whichever bracket
1316
- * produced it), a byte-identical register snapshot across an advancing
1317
- * bracket is stale_read_path -- one read path is stale while the machine is
1318
- * demonstrably not frozen; anything else is live.
1319
- */
1320
- function classifyLiveness(evidence: BracketEvidence): LivenessVerdict {
1321
- const { bracket1, bracket2, regsBefore, regsAfter } = evidence;
1322
- if (bracket1.cycles === 0 && (!bracket2 || bracket2.cycles === 0)) {
1323
- return "wedged";
1324
- }
1325
- return registersByteIdentical(regsBefore, regsAfter) ? "stale_read_path" : "live";
1326
- }
1327
-
1328
- /**
1329
- * Renders the post-bracket report (wedged/stale_read_path/live). Separates
1330
- * load-bearing evidence (the restart epoch, already checked; the stopwatch
1331
- * delta across the bracket) from corroborating evidence (the program
1332
- * counter, VIC-II state, checkpoint hit counts, a screenshot) explicitly --
1333
- * criterion 3's own requirement. A status of ok with an execution state of
1334
- * running is compatible with every one of these verdicts and is therefore
1335
- * evidence for none of them.
1336
- */
1337
- function renderDiagnoseReport(evidence: BracketEvidence, verdict: LivenessVerdict): string {
1338
- const { bracket1, bracket2, finalBracket } = evidence;
1339
- const bracketsRun = bracket2 ? 2 : 1;
1340
- const ratePerSecond =
1341
- finalBracket.cycles > 0 ? Math.round((finalBracket.cycles / Math.max(finalBracket.elapsedMs, 1)) * 1000) : 0;
1342
-
1343
- const lines = [
1344
- `vice_diagnose verdict: ${verdict}`,
1345
- "",
1346
- "Load-bearing evidence: the restart epoch (already checked, at zero emulator cost) and the " +
1347
- `stopwatch cycle delta across the bracket -- bracket 1 retired ${bracket1.cycles} cycles` +
1348
- (bracket2 ? `, bracket 2 retired ${bracket2.cycles} cycles` : "") +
1349
- ` (${bracketsRun} bracket${bracketsRun > 1 ? "s" : ""} run, ${bracketsRun} resume call${bracketsRun > 1 ? "s" : ""}).`,
1350
- "Corroborating evidence only, never load-bearing on its own: the program counter, VIC-II state, " +
1351
- "checkpoint hit counts, and a screenshot. A status of ok with an execution state of running is " +
1352
- "compatible with every one of these verdicts and is therefore evidence for none of them.",
1353
- ];
1354
-
1355
- if (verdict !== "wedged") {
1356
- lines.push(
1357
- `Measured rate this call: ~${finalBracket.cycles} cycles in ~${finalBracket.elapsedMs}ms ` +
1358
- `(~${ratePerSecond} cycles/s), beside the baseline ~${BASELINE_CYCLES_PER_SECOND} cycles/s ` +
1359
- "(PAL C64 full speed) -- an observation, never a threshold, and never a verdict of its own."
1360
- );
1361
- }
1362
-
1363
- if (verdict === "stale_read_path") {
1364
- lines.push(
1365
- "The register-read path returned a byte-identical snapshot across both ends of an advancing " +
1366
- "bracket -- that read path is stale, but the machine is demonstrably not frozen."
1367
- );
1368
- }
1369
-
1370
- lines.push(
1371
- verdict === "wedged"
1372
- ? "Machine state left: paused, after two zero-cycle brackets. Resuming is your own deliberate next call."
1373
- : "Machine state left: paused, after the bracket that reached this verdict. Resuming is your own deliberate next call."
1374
- );
1375
-
1376
- return lines.join("\n");
1377
- }
1378
-
1379
- /**
1380
- * Handles vice_diagnose. Fixed check order, and the order is the point
1381
- * (D-14): first the epoch comparison (zero emulator calls), then the
1382
- * checkpoint-trap check (no resume at all). Never throws past this point --
1383
- * every branch is a well-formed isError:false or isError:true result.
1384
- */
1385
- async function handleDiagnose(_args: Record<string, unknown>): Promise<ToolCallResult> {
1386
- try {
1387
- const leaseResult = await ensureBrokerLease();
1388
- if (!leaseResult.ok) {
1389
- return isErrorText(leaseResult.message);
1390
- }
1391
- ensureViceSession();
1392
-
1393
- const epochNow = currentEpoch();
1394
- if (epochChanged(epochBaseline, epochNow)) {
1395
- const before = (epochBaseline as EpochResult).epoch;
1396
- epochBaseline = epochNow; // never cache a negative result (criterion 6)
1397
- return { content: [{ type: "text", text: renderRestartedReport(before, epochNow.epoch) }], isError: false };
1398
- }
1399
- if (!(epochBaseline as EpochResult).present && epochNow.present) {
1400
- epochBaseline = epochNow;
1401
- }
1402
-
1403
- const trapEvidence = await gatherCheckpointTrapEvidence();
1404
- if (trapEvidence.isTrap) {
1405
- return { content: [{ type: "text", text: renderCheckpointTrapReport(trapEvidence) }], isError: false };
1406
- }
1407
-
1408
- // Third and last: the cycle bracket, the definitive liveness test, drives
1409
- // the three remaining verdicts (D-14's full order: epoch, trap, bracket).
1410
- const bracketEvidence = await gatherBracketEvidence();
1411
- const verdict = classifyLiveness(bracketEvidence);
1412
- return { content: [{ type: "text", text: renderDiagnoseReport(bracketEvidence, verdict) }], isError: false };
1413
- } catch (e) {
1414
- if (e instanceof MachineRestartedError) {
1415
- const current = currentEpoch();
1416
- epochBaseline = current;
1417
- return { content: [{ type: "text", text: renderRestartedReport(e.baselineEpoch, e.currentEpoch) }], isError: false };
1418
- }
1419
- return isErrorText(
1420
- `vice_diagnose: an unexpected error occurred while gathering evidence: ${e && (e as Error).message ? (e as Error).message : e}`
1421
- );
1422
- }
660
+ // vice_recycle and vice_diagnose (below) are registered as this file's own
661
+ // proxy-local synthetic tools (RECYCLE_TOOL/DIAGNOSE_TOOL above). Before
662
+ // plan 52-04, each ALSO carried its own fork-only implementation here --
663
+ // evidence gathered over call()'s HTTP transport, its own incident-record
664
+ // writes -- reachable only on the (now-deleted) fork backend. The
665
+ // (already-active) stock arm never ran that body at all: it dispatched
666
+ // straight through stockDispatch.dispatchStock() to handleRecycleStock()/
667
+ // handleDiagnoseStock() (stock-recycle.ts/stock-diagnose.ts), which own a
668
+ // complete stock-native evidence gatherer and incident-record write of
669
+ // their own (built for exactly this reason -- see stock-recycle.ts's own
670
+ // header). That fork-only body is deleted, not merely emptied:
671
+ // handleRecycle()/handleDiagnose() SURVIVE as named functions --
672
+ // RECYCLE_TOOL/DIAGNOSE_TOOL's own registration still wires them in by name
673
+ // (a structural oracle in vice-proxy.test.ts asserts handleRecycle's own
674
+ // declaration form) -- but their bodies now do exactly what the stock arm
675
+ // already did, unconditionally, rather than re-deriving a second copy of
676
+ // stock-recycle.ts/stock-diagnose.ts's own logic here.
677
+ const handleRecycle: (args: Record<string, unknown>) => Promise<ToolCallResult> = async function handleRecycle(args) {
678
+ return dispatchStockFor(RECYCLE_TOOL.name, args);
1423
679
  }
1424
680
 
1425
- // -------------------------------------------- vice_recycle: evidence gather
681
+ // ----------------------------------------------------------- vice_diagnose
1426
682
  //
1427
- // Plan 01.3-03 (criterion 4): the destructive path's own evidence set,
1428
- // composed ENTIRELY from reads already forwardable through call() -- no new
1429
- // host capability, no second route. runCycleBracket() and
1430
- // resolveLiveIrqHandler() are plan 01.3-02's own single definitions, reused
1431
- // here rather than re-derived (this plan's own key_links) -- criterion 2's
1432
- // single-bracket-definition guard is a PHASE property, not a plan one.
1433
-
1434
- /**
1435
- * Capture-step deadline (T-01.3-10): a TRANSPORT deadline bounding how long
1436
- * ANY single evidence-gathering step (including the pre-kill snapshot
1437
- * attempt, task 2) may wait for its own forwarded call(s) before this
1438
- * wrapper gives up and records an explicit unavailable-with-reason entry.
1439
- *
1440
- * This is deliberately DIFFERENT from the project's standing prohibition on
1441
- * WALL-CLOCK PACING (never sleep to wait for the emulated machine to reach
1442
- * some state -- synchronise on checkpoint hits and cycle counts instead):
1443
- * that rule governs synchronising INPUT/WAITS against the emulated game's
1444
- * own state. This deadline governs a capture step's patience with the
1445
- * TRANSPORT alone -- exactly the kind of deadline call()'s own
1446
- * AbortSignal.timeout already applies per forwarded call, just bounding the
1447
- * WHOLE step (which may issue several forwarded calls, e.g. the bracket) so
1448
- * one non-answering read can never stall the whole gather, and the
1449
- * snapshot attempt can never stall the recycle itself (D-19). Overridable
1450
- * purely so this file's own test suite can exercise a "never answers"
1451
- * fixture in milliseconds rather than minutes -- production always uses the
1452
- * generous default.
1453
- */
1454
- const CAPTURE_STEP_TIMEOUT_MS = Number(process.env.VICE_RECYCLE_CAPTURE_TIMEOUT_MS || 8000);
1455
-
1456
- /** captureStep()'s own result shape -- structurally an EvidenceItem
1457
- * (incident-record.ts), just narrowed to a discriminated union here so a
1458
- * caller can branch on `available` without an optional-field guess. */
1459
- type CaptureStepResult<T> = { available: true; value: T } | { available: false; reason: string };
1460
-
1461
- /**
1462
- * Runs one evidence-gathering step, turning any rejection, transport
1463
- * failure or capture-step deadline into an explicit `{ available: false,
1464
- * reason }` entry rather than letting it abort the whole gather -- the
1465
- * whole point (D-17, D-19) is that a wedged machine will fail SOME of these
1466
- * and the record must still exist. Never throws.
1467
- */
1468
- async function captureStep<T>(fn: () => Promise<T>): Promise<CaptureStepResult<T>> {
1469
- let timer: NodeJS.Timeout | undefined;
1470
- try {
1471
- const value = await Promise.race([
1472
- fn(),
1473
- new Promise<never>((_, reject) => {
1474
- timer = setTimeout(
1475
- () => reject(new Error(`capture step deadline of ${CAPTURE_STEP_TIMEOUT_MS}ms exceeded`)),
1476
- CAPTURE_STEP_TIMEOUT_MS
1477
- );
1478
- }),
1479
- ]);
1480
- return { available: true, value };
1481
- } catch (e) {
1482
- return { available: false, reason: e && (e as Error).message ? (e as Error).message : String(e) };
1483
- } finally {
1484
- clearTimeout(timer);
1485
- }
1486
- }
1487
-
1488
- /**
1489
- * Assembles criterion-4's evidence set for an incident record: one cycle
1490
- * bracket (runCycleBracket(), plan 01.3-02 -- NEVER a second bracket
1491
- * definition), the program counter and full register snapshot, the full
1492
- * checkpoint enumeration (address, enabled flag, stop-or-continue), the
1493
- * resolved live IRQ handler (resolveLiveIrqHandler(), plan 01.3-02), and a
1494
- * screenshot written to a path in the incidents directory sharing the
1495
- * record's own stem. Every step goes through captureStep() above, so no
1496
- * step can abort the gather.
1497
- *
1498
- * `at`/`port`/`epoch` name the SAME triple the caller passes to
1499
- * writeIncidentRecord(), so the screenshot's path shares that record's stem
1500
- * (best-effort: the very rare case of a same-millisecond/port/epoch
1501
- * collision forcing writeIncidentRecord() to append a numeric suffix onto
1502
- * the actual .md file is not reflected here, since this path is computed
1503
- * BEFORE that write happens).
1504
- */
1505
- async function gatherWedgeEvidence({ at, port, epoch }: IncidentAssetStemOptions): Promise<IncidentEvidence> {
1506
- const bracket = await captureStep(() => runCycleBracket());
1507
- const registers = await captureStep(() => call("vice_registers_get", {}));
1508
- const checkpoints = await captureStep(async () => {
1509
- const result = await call("vice_checkpoint_list", {});
1510
- const list: CheckpointInfo[] =
1511
- isPlainObject(result) && Array.isArray(result.checkpoints) ? (result.checkpoints as CheckpointInfo[]) : [];
1512
- return list.map((c) => ({
1513
- checkpoint_num: c && c.checkpoint_num,
1514
- address: formatAddress(toAddressNumber(c && c.start)),
1515
- enabled: Boolean(c && c.enabled !== false),
1516
- flag: c && c.stop ? "stop" : "continue",
1517
- }));
1518
- });
1519
- const irqHandler = await captureStep(() => resolveLiveIrqHandler());
1520
-
1521
- // The screenshot's path argument must be translated (T-01.3-11's sibling
1522
- // concern): handleToolsCall() applies rewriteArguments() before
1523
- // forwarding, and this proxy-local caller does NOT pass through that seam
1524
- // -- so it is called explicitly here. Skipping this would write the file
1525
- // to a host path that does not exist and return a success the record
1526
- // would then be lying about.
1527
- const screenshotContainerPath = incidentAssetPath({ at, port, epoch, ext: "png" });
1528
- const screenshot = await captureStep(async () => {
1529
- const { args: translated } = rewriteArguments({ path: screenshotContainerPath }, "vice_display_screenshot");
1530
- await call("vice_display_screenshot", translated);
1531
- return relative(repoRoot(), screenshotContainerPath);
1532
- });
1533
-
1534
- return { bracket, registers, checkpoints, irqHandler, screenshot };
1535
- }
1536
-
1537
- /**
1538
- * The best-effort pre-kill snapshot (plan 01.3-03 task 2, D-19): the LAST
1539
- * capture step, run immediately before the incident record is written. It
1540
- * takes a NAME, not a path -- vice_snapshot_save's own contract -- so the
1541
- * file lands in the host emulator's own snapshot directory and nothing
1542
- * container-side can confirm it landed there. The record therefore says
1543
- * the ATTEMPT was accepted, never that a file was verified (T-01.3-11): the
1544
- * wording must not overstate what was established. A rejection, a
1545
- * transport failure or a capture-step deadline records unavailable with
1546
- * the reason verbatim and moves on -- it cannot fail or stall the recycle.
1547
- * The name is built from the SAME timestamp/port/epoch triple the incident
1548
- * record's own stem uses, so the two artifacts are trivially correlated
1549
- * later.
1550
- */
1551
- async function captureSnapshotAttempt({
1552
- at,
1553
- port,
1554
- epoch,
1555
- }: IncidentAssetStemOptions): Promise<CaptureStepResult<{ name: string }>> {
1556
- const name = incidentAssetStem({ at, port, epoch });
1557
- return captureStep(async () => {
1558
- await call("vice_snapshot_save", { name, description: "vice_recycle pre-kill evidence capture" });
1559
- return { name };
1560
- });
683
+ // See vice_recycle's own header comment immediately above: handleDiagnose()
684
+ // SURVIVES as a named function (DIAGNOSE_TOOL's own registration still wires
685
+ // it in by name) but its fork-only evidence-gathering body (the epoch/
686
+ // checkpoint-trap/cycle-bracket walk, all reached over call()'s HTTP
687
+ // transport) is deleted. The stock arm never ran that body -- it already
688
+ // dispatched straight through stockDispatch.dispatchStock() to
689
+ // handleDiagnoseStock() (stock-diagnose.ts), which owns a complete
690
+ // stock-native five-verdict diagnosis of its own. This is that delegation
691
+ // made unconditional, rather than a second copy of stock-diagnose.ts's own
692
+ // logic living here.
693
+ async function handleDiagnose(args: Record<string, unknown>): Promise<ToolCallResult> {
694
+ return dispatchStockFor(DIAGNOSE_TOOL.name, args);
1561
695
  }
1562
696
 
1563
697
  // --------------------------------------------------- unreachable diagnostics
1564
698
  //
1565
- // Plan 01.1-03 task 2 / ROADMAP criterion 7. Blocking on withReconnect()'s
1566
- // ~50s ladder turns a clear diagnosis into an opaque tool timeout, so every
1567
- // forwarded tools/call gets a pre-flight `probeInstance()` check FIRST (one
1568
- // 1500ms-budget round trip, no retry -- see vice-probe.ts's own header for
1569
- // why reusing the resilient ladder here would be wrong). When the probe
1570
- // reports the emulator unreachable, this classifies the failure into exactly
1571
- // one of three states, each with its own message and its own fix, each
1572
- // quoting an absolute host path, each closing off the "just run the
1573
- // transport module from a shell instead" workaround explicitly.
699
+ // ONLY_ROUTE_NOTE and brokerHostPath() below are the shared vocabulary the
700
+ // broker-absent diagnostics family (immediately below) uses to name the one
701
+ // route back to a working emulator. The host-unreachable triple that used
702
+ // to live in this section (never-started/dead-or-hung/alive-but-failed,
703
+ // classifying a failed pre-flight liveness check over the fork's own HTTP
704
+ // transport) is deleted along with the fork-only generic forwarding
705
+ // function and its liveness-probe module: stock has no equivalent
706
+ // probe-then-classify step of its own, and stockDispatch's own
707
+ // session/lease handling reports unreachability through its own vocabulary
708
+ // instead.
1574
709
  //
1575
710
  // This MCP tool surface is the only route to the emulator -- never named
1576
711
  // together with a CLI verb here, since plan 01.1-04 installs a durable gate
@@ -1580,22 +715,13 @@ const ONLY_ROUTE_NOTE =
1580
715
  "the human to start it on the host -- falling back to a direct shell invocation of the underlying " +
1581
716
  "transport is not an available workaround.";
1582
717
 
1583
- // supervisorHostPath() (the per-instance-supervisor host-path helper) is
1584
- // GONE, not merely unused (01.6.2-09, T-01.6.2-54/T-01.6.2-59): its three
1585
- // former consumers below -- neverStartedMessage(), deadOrHungMessage() and
1586
- // aliveButFailedMessage() -- now resolve brokerHostPath() instead, the SAME
1587
- // single helper the broker-absent triple already used. There is exactly one
1588
- // host-path helper left in this file (a structural test in
1589
- // vice-proxy.test.ts asserts that directly: the resolved path's basename
1590
- // equals the surviving launcher's filename).
1591
-
1592
718
  /** The absolute path of the command a human should run on the HOST to
1593
719
  * start/restart access to the emulator -- computed via hostPath() over the
1594
720
  * deployed launcher's container path, degrading to the container path plus
1595
721
  * SET_ENV_HINT exactly as install-resources.ts's hostLaunchInstructions()
1596
722
  * does, so a translation failure still yields something to act on rather
1597
723
  * than an empty message. Recomputed fresh every call -- never cached (see
1598
- * the never-cache-a-negative-result invariant above ensureViceSession()).
724
+ * the never-cache-a-negative-result invariant above, near tools/call).
1599
725
  * Points at resources/vice-launcher.sh's deployed copy -- the one surviving
1600
726
  * host script (01.6.2-09). Every message in this file that used to name
1601
727
  * either the retiring per-instance supervisor (vice-supervisor.sh) or the
@@ -1604,7 +730,13 @@ const ONLY_ROUTE_NOTE =
1604
730
  * the launch/supervise/respawn-with-backoff job the bash supervisor did. */
1605
731
  function brokerHostPath(): string {
1606
732
  const root = repoRoot();
1607
- const target = join(root, "tools", "vice-launcher.sh");
733
+ // Moved 2026-09-08 (D-33): was join(root, "tools", "vice-launcher.sh"),
734
+ // matching installTargetDir()'s pre-consolidation value. Now derived from
735
+ // repo-root.ts's own toolsDir() -- this module is container-side, unlike
736
+ // install-resources.ts, so it CAN import that resolver directly rather
737
+ // than joining the literal a second time -- matching installTargetDir()'s
738
+ // new `<root>/.c64-re-tools/bin` value exactly.
739
+ const target = join(toolsDir(), "bin", "vice-launcher.sh");
1608
740
  try {
1609
741
  return hostPath(target, { workspaceRoot: root });
1610
742
  } catch {
@@ -1615,18 +747,11 @@ function brokerHostPath(): string {
1615
747
  // ------------------------------------------------- broker-absent diagnostics
1616
748
  //
1617
749
  // Plan 01.2-03 task 1 / must_have C10. A missing broker answers exactly one
1618
- // generic message two times out of three sends the reader to the wrong fix
1619
- // -- mirrors the host-unreachable triple above (never-started /
1620
- // dead-or-hung / alive-but-failed), but answers a DIFFERENT question ("is
1621
- // the on-demand broker itself reachable" vs "is the host VICE MCP server
1622
- // reachable"), so both triples stay in place side by side, not one
1623
- // replacing the other. Every message here quotes brokerHostPath() (an
1624
- // absolute HOST path, recomputed fresh -- see that function's own comment)
1625
- // and the single shared ONLY_ROUTE_NOTE definition; no message below writes
1626
- // its own second only-route sentence. As of 01.6.2-09, the host-unreachable
1627
- // triple below quotes the exact same brokerHostPath() helper -- there is
1628
- // only one surviving launcher left to name, so both triples now resolve
1629
- // identically rather than two different paths.
750
+ // generic message two times out of three sends the reader to the wrong fix.
751
+ // Every message here quotes brokerHostPath() (an absolute HOST path,
752
+ // recomputed fresh -- see that function's own comment) and the single
753
+ // shared ONLY_ROUTE_NOTE definition; no message below writes its own second
754
+ // only-route sentence.
1630
755
 
1631
756
  /** State: readBrokerLiveness() found no broker.json at all -- the broker has
1632
757
  * never been started on this host. Nothing on the other side would ever
@@ -1643,7 +768,7 @@ function brokerNeverStartedMessage(): string {
1643
768
  /** State: broker.json exists but its heartbeat is older than the stale
1644
769
  * threshold -- the broker process is dead or hung. Quotes the recorded pid
1645
770
  * (readBrokerLiveness()'s own field), since checking that pid is the first
1646
- * thing a human does on the host, mirroring deadOrHungMessage() above. */
771
+ * thing a human does on the host. */
1647
772
  function brokerDeadOrHungMessage(liveness: BrokerLivenessResult): string {
1648
773
  const pidNote = liveness && liveness.pid != null ? ` (pid ${liveness.pid})` : "";
1649
774
  return (
@@ -1657,12 +782,10 @@ function brokerDeadOrHungMessage(liveness: BrokerLivenessResult): string {
1657
782
  /** State: the broker is alive and a request was polled, but it wrote a
1658
783
  * denial rather than a grant. Relays the denial's own `reason` field
1659
784
  * VERBATIM -- never paraphrased -- and deliberately carries no RESTART
1660
- * instruction, for the same reason aliveButFailedMessage() above carries
1661
- * none: restarting something that is answering correctly is the wrong fix.
1662
- * Still names an absolute path (the running broker's own launcher, purely
1663
- * as a reference, mirroring aliveButFailedMessage()'s `hostRef` note) and
1664
- * the only-route sentence, both required of every broker-absent-adjacent
1665
- * message this proxy emits. */
785
+ * instruction: restarting something that is answering correctly is the
786
+ * wrong fix. Still names an absolute path (the running broker's own
787
+ * launcher, purely as a reference) and the only-route sentence, both
788
+ * required of every broker-absent-adjacent message this proxy emits. */
1666
789
  function brokerLaunchFailedMessage(reason: string): string {
1667
790
  const hostRef = brokerHostPath().split("\n")[0];
1668
791
  return (
@@ -1734,314 +857,44 @@ function brokerControlUnreachableMessage(opened: { kind: ControlFailureKind; mes
1734
857
  // the "orphan request the sweeper must reap" problem this helper solved
1735
858
  // does not exist in this design.
1736
859
 
1737
- // A causeCode-shaped reason string (e.g. "ECONNREFUSED", "ECONNRESET") is
1738
- // exactly what probeInstance() returns for a connection actively refused --
1739
- // see its own fallback `causeCode || e.message`. A timeout, an HTTP error
1740
- // status, or "didn't decode to a recognisable ping" all produce prose
1741
- // instead, never a bare all-caps E-code, which is what keeps this predicate
1742
- // precise rather than a loose substring guess.
1743
- function isConnectionRefusedReason(reason: unknown): boolean {
1744
- return typeof reason === "string" && /^E[A-Z]+$/.test(reason);
1745
- }
1746
-
1747
- function neverStartedMessage(probe: ProbeResult): string {
1748
- return (
1749
- `vice: the host VICE MCP server has never been started at this configured path -- no ` +
1750
- `restart-epoch record exists, and the connection was refused (${probe.reason}). Start it on the host with:\n` +
1751
- ` ${brokerHostPath()}\n` +
1752
- ONLY_ROUTE_NOTE
1753
- );
1754
- }
1755
-
1756
- function deadOrHungMessage(probe: ProbeResult, epoch: EpochResult): string {
1757
- const pidNote =
1758
- epoch && epoch.present && epoch.pid != null
1759
- ? ` (pid ${epoch.pid}${epoch.spawned_at ? `, spawned_at ${epoch.spawned_at}` : ""})`
1760
- : "";
1761
- return (
1762
- `vice: the host VICE MCP server appears to be dead or hung${pidNote} -- ${probe.reason}. ` +
1763
- `Restart it on the host with:\n` +
1764
- ` ${brokerHostPath()}\n` +
1765
- ONLY_ROUTE_NOTE
1766
- );
1767
- }
1768
-
1769
- /** Reached only when the pre-flight probe found the host alive but the
1770
- * forwarded call itself failed (a transport error the retry ladder gave up
1771
- * on, or a genuine RPC error). Relays the host's own message VERBATIM --
1772
- * never paraphrased -- and deliberately carries no restart instruction,
1773
- * since restarting a live, correctly-answering host is the wrong fix for a
1774
- * rejected tool call. Still names an absolute path and the only-route note
1775
- * (both required of every unreachable-adjacent message this proxy emits),
1776
- * worded so as never to suggest the action a restart message would. */
1777
- function aliveButFailedMessage(errMessage: string): string {
1778
- const hostRef = brokerHostPath().split("\n")[0];
1779
- return (
1780
- `vice: the host VICE MCP server (reachable via the host-side launcher at ${hostRef}) rejected ` +
1781
- `this call: ${errMessage} ${ONLY_ROUTE_NOTE}`
1782
- );
1783
- }
1784
-
1785
- // RESOLVED RESIDUAL (originally quick-260801-ccn task 3; re-examined by
1786
- // 01.6.2-09, T-01.6.2-54/T-01.6.2-59): this comment used to record that
1787
- // aliveButFailedMessage() above still named a SEPARATE per-instance
1788
- // supervisor path even under a broker-granted session -- a genuine
1789
- // mismatch, because two different launchers existed. That mismatch is
1790
- // dissolved, not merely reworded: supervisorHostPath() is deleted, and
1791
- // aliveButFailedMessage() now resolves the exact same brokerHostPath()
1792
- // helper every other message in this file uses, so there is only ever one
1793
- // launcher path to name, regardless of session type. What still holds,
1794
- // unchanged, is the REASON this message carries no restart instruction: it
1795
- // answers a different question from both the host-unreachable triple and
1796
- // the broker-granted message below -- an instance that IS reachable and
1797
- // answering rejected ONE call -- where no launcher is the fix and a
1798
- // restart would be the wrong advice on either route (broker-granted or
1799
- // fixed-port).
1800
-
1801
- // ------------------------------------------- broker-granted unreachable diagnostics
1802
- //
1803
- // Quick task 260801-ccn task 3 (D-5) introduced ONE message here, distinct
1804
- // from both the host-unreachable triple above and the broker-ABSENT triple
1805
- // below, for a granted instance that stopped answering: report the fact and
1806
- // tell a human to go investigate on the host.
1807
- //
1808
- // Plan 01.6.2-08 (D-13) turns that report-and-instruct message into a
1809
- // replace-and-report: a granted instance not answering no longer waits for
1810
- // a human -- it costs this session exactly one replacement acquisition,
1811
- // made automatically, and the triggering call still fails LOUDLY (never a
1812
- // silently substituted result) naming the replacement. See
1813
- // handleGrantedInstanceUnreachable() and its own three message builders
860
+ // broker-granted unreachable diagnostics: GONE, not merely unused.
861
+ // handleGrantedInstanceUnreachable() and its three message builders
1814
862
  // (machineReplacedMessage()/replacementFailedMessage()/
1815
- // sessionMustRestartMessage()) further down this file, right after
1816
- // ensureBrokerLease() -- the function this diagnostic superseded is gone,
1817
- // not merely unused: brokerHostPath()'s "go investigate on the host"
1818
- // framing no longer applies once the proxy investigates (replaces) on its
1819
- // own first.
863
+ // sessionMustRestartMessage()) existed to replace-and-report when a granted
864
+ // instance stopped answering the fork-only generic forwarding function's own
865
+ // pre-flight liveness check -- their only caller. Deleted along with that
866
+ // forwarding function; stock has no equivalent probe-then-replace step at
867
+ // this proxy layer, and a dead lease surfaces through stockDispatch's own
868
+ // error handling instead.
1820
869
 
1821
870
  // ------------------------------------------------------------ path rewriting
1822
871
  //
1823
- // Decision D-G (plan 01.1-03 task 3 / criterion 9): container->host path
1824
- // translation moves from every caller's own discipline into this one seam,
1825
- // which sees every forwarded call. The structural rule: any string argument
1826
- // value beginning with "/" is an absolute filesystem path. One that resolves
1827
- // inside the mounted workspace is rewritten to its host form via
1828
- // hostPath() -- the host emulator can only ever be handed a HOST path,
1829
- // since it runs on the host, not in this container. One that resolves
1830
- // outside the workspace is refused outright, before any forwarding, because
1831
- // a container path is never correct on the host: forwarding it untouched
1832
- // can only produce a wrong answer with no error, which is exactly the
1833
- // silent-failure class this criterion exists to eliminate.
1834
- //
1835
- // RELATIVE paths: resolved against the workspace root, but ONLY for the
1836
- // arguments the tools manifest declares to BE paths.
872
+ // isInsideWorkspace() below is the ONE survivor of what used to be a larger
873
+ // container->host path-translation seam here (decision D-G, plan 01.1-03
874
+ // task 3): the per-call argument path-rewriter and its recursive value
875
+ // walker, along with their own refusal classes
876
+ // (PathOutOfWorkspaceError/PathTranslationError), were the fork-only
877
+ // per-forwarded-call rewriter the fork-only generic forwarding function ran
878
+ // before delegating to the fork transport's own dispatch call -- deleted
879
+ // along with it. isInsideWorkspace() itself SURVIVES
880
+ // because it has a second, backend-agnostic consumer: containerizeGrant()
881
+ // (further down this file) re-checks a broker grant's translated
882
+ // epoch_file/supervisor_dir fields against the workspace boundary before
883
+ // trusting them. Stock's OWN emulator-side path translation
884
+ // (stock-paths.ts's withEmulatorSidePath()/STOCK_EMULATOR_SIDE_PATH_TOOLS)
885
+ // is a separate, still-untouched mechanism -- see that file's own header.
1837
886
  //
1838
- // The original rule left every relative string byte-identical, on the
1839
- // reasoning that "a relative-looking string is indistinguishable from a
1840
- // non-path argument (a tool name, a hex address like "$0400", an arbitrary
1841
- // label) without guessing". That reasoning was sound for a walker with no
1842
- // schema, and it pointed callers at a SKILL.md "Paths" section for the
1843
- // absolute-path requirement -- but that SKILL.md was deleted in db9eed3,
1844
- // leaving the requirement stated nowhere. CLAUDE.md's surviving wording
1845
- // ("pass container paths and let the tools handle the boundary") promises
1846
- // the opposite, so callers reasonably passed "disks/foo.d64" and got a bare
1847
- // "Failed to attach disk image" from the host, with nothing anywhere
1848
- // indicating the path was the problem. That cost real session time.
1849
- //
1850
- // The premise is also no longer true. tools-manifest.json -- the same file
1851
- // tools/list is served from -- types every argument, and exactly four
1852
- // declare a path: vice_disk_attach.path, vice_autostart.path,
1853
- // vice_display_screenshot.path and vice_symbols_load.path. Consulting it
1854
- // removes the guessing the residual was protecting against: a relative
1855
- // string in a DECLARED path argument is a path, full stop, and everything
1856
- // else keeps the byte-identical pass-through unchanged.
1857
- //
1858
- // Resolution is against the workspace root, never process.cwd() -- the
1859
- // proxy is one long-lived process serving the whole session, so its cwd is
1860
- // meaningless to the caller. (hostpath.mjs:106 resolves against cwd for its
1861
- // CLI's benefit; that branch is unreachable from here, and deliberately so.)
1862
- //
1863
- // STATED RESIDUAL, narrower than before: a relative string in an argument
1864
- // the manifest does NOT declare as a path is still left byte-identical, and
1865
- // so is a relative string nested inside an object or array. Both remain
1866
- // indistinguishable from non-path data. A worktree caller also resolves
1867
- // against the MAIN workspace root, not its worktree -- correct for the
1868
- // read-only disk images this serves, and an absolute path still overrides.
1869
- const PATH_REWRITE_MAX_DEPTH = 10; // bounded so pathological nesting is left alone rather than looping forever
1870
-
1871
- class PathOutOfWorkspaceError extends Error {}
1872
- class PathTranslationError extends Error {}
1873
-
1874
- // The boundary check MUST run against a normalized path, never the raw
1875
- // string. `startsWith(root)` on an unnormalized value is satisfied by any
1876
- // string that merely begins with the root's characters, so a lexical `..`
1877
- // sequence -- "/workspaces/c64-project/../../../etc/passwd" -- passes a raw
1878
- // prefix test and is then handed to hostPath(), which does NOT refuse it:
1879
- // when relative() normalizes to a leading "..", hostpath.mjs deliberately
1880
- // falls through to generic mount-based translation instead of throwing (its
1881
- // own comment says so, for the CLI's benefit). That makes THIS check the only
1882
- // workspace boundary on the forwarding path, so it has to be the strict one.
1883
- //
1884
- // resolve() collapses "." and ".." segments; callers only reach here after
1885
- // value.startsWith("/") is confirmed, so it is pure normalization and never
1886
- // pulls in process.cwd().
1887
- //
1888
- // STATED RESIDUAL: this is lexical, not physical -- a symlink inside the
1889
- // workspace whose target lives outside it still translates. realpathSync()
1890
- // would catch that but requires the file to already exist, which is wrong for
1891
- // the write-side tools (snapshot_save and friends name a path that does not
1892
- // exist yet). Lexical normalization is the part that can be enforced for both
1893
- // directions without breaking writes.
887
+ // STATED RESIDUAL, unchanged from before this deletion: this check is
888
+ // lexical, not physical -- a symlink inside the workspace whose target lives
889
+ // outside it still translates. realpathSync() would catch that but requires
890
+ // the file to already exist, which is wrong for the write-side tools
891
+ // (snapshot_save and friends name a path that does not exist yet). Lexical
892
+ // normalization is the part that can be enforced for both directions
893
+ // without breaking writes.
1894
894
  function isInsideWorkspace(absPath: string, root: string): boolean {
1895
895
  return absPath === root || absPath.startsWith(root.endsWith("/") ? root : root + "/");
1896
896
  }
1897
897
 
1898
- /**
1899
- * Recursively walk `value`, applying decision D-G's structural rule to
1900
- * every string found. Objects and arrays are walked (bounded by
1901
- * PATH_REWRITE_MAX_DEPTH); numbers, booleans, null, and non-absolute
1902
- * strings are returned byte-identical. `argPath` accumulates a
1903
- * human-readable position (e.g. "arguments.path" or "arguments.files[2]")
1904
- * used in a refusal message so the caller can find exactly which argument
1905
- * was the problem.
1906
- */
1907
- function rewritePathsIn(value: unknown, argPath: string, root: string, depth: number, asWritten?: string): unknown {
1908
- if (depth > PATH_REWRITE_MAX_DEPTH) return value;
1909
- if (typeof value === "string") {
1910
- if (!value.startsWith("/")) return value; // the stated residual: undeclared relative strings untouched
1911
- // Normalize FIRST, then check, then translate the normalized form -- so a
1912
- // path that only looks like it is inside the workspace cannot slip through,
1913
- // and the host is never handed a path still carrying ".." segments.
1914
- const normalized = resolve(value);
1915
- // `asWritten` is set only when rewriteArguments() already resolved a
1916
- // declared-path argument from a relative string. Quoting the resolved
1917
- // form alone would show the caller a path they never typed, so BOTH
1918
- // failure branches below name what they wrote and what it became.
1919
- const escapedRelative = asWritten !== undefined && asWritten !== value;
1920
- if (!isInsideWorkspace(normalized, root)) {
1921
- throw new PathOutOfWorkspaceError(
1922
- (escapedRelative
1923
- ? `vice: ${argPath} is the relative path "${asWritten}", which resolves to ${normalized} -- ` +
1924
- `outside the mounted workspace (${root})`
1925
- : `vice: ${argPath} is an absolute path (${value}) outside the mounted workspace (${root})` +
1926
- (normalized === value ? "" : `; it resolves to ${normalized}`)) +
1927
- `. The host emulator can only be handed paths that live inside the mounted workspace -- move the ` +
1928
- `artifact inside the workspace and call again.`
1929
- );
1930
- }
1931
- try {
1932
- return hostPath(normalized, { workspaceRoot: root });
1933
- } catch (e) {
1934
- // Name what the CALLER wrote first, and the container path it became --
1935
- // never lead with the host path. The caller reasons in container terms
1936
- // and cannot act on a host-side location, so quoting only the resolved
1937
- // form makes a fixable mistake look like an emulator fault.
1938
- throw new PathTranslationError(
1939
- `vice: ${argPath} ` +
1940
- (escapedRelative ? `("${asWritten}", which resolves to ${normalized})` : `(${value})`) +
1941
- ` could not be translated to a host path: ${(e as Error).message}\n ${SET_ENV_HINT}`
1942
- );
1943
- }
1944
- }
1945
- if (Array.isArray(value)) {
1946
- return value.map((v, i) => rewritePathsIn(v, `${argPath}[${i}]`, root, depth + 1));
1947
- }
1948
- if (value && typeof value === "object") {
1949
- const out: Record<string, unknown> = {};
1950
- for (const [k, v] of Object.entries(value)) {
1951
- out[k] = rewritePathsIn(v, `${argPath}.${k}`, root, depth + 1);
1952
- }
1953
- return out;
1954
- }
1955
- return value; // numbers, booleans, null -- byte-identical, never touched
1956
- }
1957
-
1958
- const NO_PATH_ARGS: Set<string> = new Set();
1959
- let PATH_ARGS_BY_TOOL: Map<string, Set<string>> | null = null; // built once per process, from the manifest
1960
-
1961
- /**
1962
- * The set of argument names `toolName` declares to be filesystem paths,
1963
- * read off tools-manifest.json -- the SAME file tools/list is served from,
1964
- * so this can never become a second, drifting copy of "which arguments are
1965
- * paths". An argument qualifies when it is declared `type: "string"` and
1966
- * either is named exactly `path` or opens its description with "Path to" /
1967
- * "File path" (both tests agree on all four current cases; either alone
1968
- * would also suffice, and keeping both means a future manifest entry that
1969
- * satisfies only one is still caught).
1970
- *
1971
- * Deliberately name/description-driven rather than a hardcoded tool list:
1972
- * a manifest refresh that adds a path-taking tool gets the behaviour for
1973
- * free, which a literal list here would silently miss.
1974
- */
1975
- function pathArgsFor(toolName: string): Set<string> {
1976
- if (!PATH_ARGS_BY_TOOL) {
1977
- PATH_ARGS_BY_TOOL = new Map();
1978
- for (const t of readManifestTools()) {
1979
- const props = isPlainObject(t.inputSchema) ? (t.inputSchema.properties as unknown) : undefined;
1980
- if (!props || typeof props !== "object") continue;
1981
- const names = new Set<string>();
1982
- for (const [k, v] of Object.entries(props as Record<string, unknown>)) {
1983
- if (!isPlainObject(v) || v.type !== "string") continue;
1984
- if (k === "path" || /^(path|file path)\b/i.test((v.description as string) || "")) names.add(k);
1985
- }
1986
- if (names.size) PATH_ARGS_BY_TOOL.set(t.name, names);
1987
- }
1988
- }
1989
- return PATH_ARGS_BY_TOOL.get(toolName) || NO_PATH_ARGS;
1990
- }
1991
-
1992
- /** One `arguments.<key>` resolved from a relative, manifest-declared path
1993
- * argument to its absolute container form, before hostPath() translation --
1994
- * the record resolutionNote() below renders for the agent. */
1995
- interface PathResolution {
1996
- arg: string;
1997
- asWritten: string;
1998
- container: string;
1999
- }
2000
-
2001
- /** Rewrite every in-workspace path inside `args` to its host form. A relative
2002
- * string in a manifest-declared path argument is resolved against the
2003
- * workspace root first; everything else keeps the byte-identical
2004
- * pass-through. Throws PathOutOfWorkspaceError / PathTranslationError on the
2005
- * two refusal cases above; the caller (handleToolsCall) converts either into
2006
- * an isError:true result rather than letting it escape. */
2007
- function rewriteArguments(
2008
- args: Record<string, unknown> | undefined,
2009
- toolName: string
2010
- ): { args: Record<string, unknown>; resolutions: PathResolution[] } {
2011
- const root = repoRoot();
2012
- const pathArgs = pathArgsFor(toolName);
2013
- const out: Record<string, unknown> = {};
2014
- const resolutions: PathResolution[] = [];
2015
- for (const [k, v] of Object.entries(args || {})) {
2016
- // Only a top-level, declared-path, non-empty relative string is resolved.
2017
- // Empty stays empty (resolve() would silently turn "" into the workspace
2018
- // root, i.e. a directory, which is never what a caller meant).
2019
- if (pathArgs.has(k) && typeof v === "string" && v !== "" && !v.startsWith("/")) {
2020
- const container = resolve(root, v);
2021
- out[k] = rewritePathsIn(container, `arguments.${k}`, root, 1, v);
2022
- resolutions.push({ arg: k, asWritten: v, container });
2023
- } else {
2024
- out[k] = rewritePathsIn(v, `arguments.${k}`, root, 1);
2025
- }
2026
- }
2027
- return { args: out, resolutions };
2028
- }
2029
-
2030
- /**
2031
- * One line naming, in full, every relative path this call resolved -- so the
2032
- * absolute path actually handed to the emulator is never something the caller
2033
- * has to infer. Returned to the AGENT, not just stderr: the failure this
2034
- * prevents ("Failed to attach disk image", with no indication which file was
2035
- * even attempted) is one the agent has to diagnose, and it cost a real session
2036
- * before the resolution existed at all. Empty string when nothing was resolved,
2037
- * so a call that passed absolute paths reads exactly as it always did.
2038
- */
2039
- function resolutionNote(resolutions: PathResolution[] | undefined): string {
2040
- if (!resolutions || !resolutions.length) return "";
2041
- const parts = resolutions.map((r) => `${r.arg}: "${r.asWritten}" -> ${r.container}`);
2042
- return `vice: resolved relative path${resolutions.length > 1 ? "s" : ""} against the workspace root -- ${parts.join("; ")}`;
2043
- }
2044
-
2045
898
  // ------------------------------------------------------- oversized results
2046
899
  //
2047
900
  // Decision D-E: the `_meta["anthropic/maxResultSizeChars"]` declaration
@@ -2183,6 +1036,14 @@ function handleResultContinue(args: Record<string, unknown>): ToolCallResult {
2183
1036
  // instance down.
2184
1037
  let controlSession: BrokerControlSession | null = null;
2185
1038
  let grantId: string | null = null;
1039
+ // Plan 41-01 (D-15): THIS session's own text-monitor port, stashed by
1040
+ // adoptGrant() beside grantId -- never memoised anywhere else. `null` means
1041
+ // either no grant has been adopted yet, this is a fork instance (which never
1042
+ // carries one), or the observed wire value failed validation (see
1043
+ // adoptGrant()'s own stderr warning for that last case). Read fresh by
1044
+ // buildHeldLease() on every call, exactly like activeInstance() and grantId
1045
+ // above it -- never cached past a replacement acquisition.
1046
+ let grantRemoteMonitorPort: number | null = null;
2186
1047
 
2187
1048
  // ----------------------------------------------------- grant containerization
2188
1049
  //
@@ -2352,11 +1213,23 @@ function buildHeldLease(session: BrokerControlSession): HeldLease {
2352
1213
  // fresh from activeInstance() like every other field here (adoptGrant()
2353
1214
  // put the CONTAINERIZED path there, so it is already in this process's
2354
1215
  // view of the filesystem -- no second translation here).
2355
- // - supervisorDir is the TOP-LEVEL `.vice-supervisor`, where backend.json
1216
+ // - supervisorDir is the TOP-LEVEL `.c64-re-tools/supervisor`, where backend.json
2356
1217
  // lives, resolved through brokerRootDir() -- the SAME resolver
2357
1218
  // broker.json is read from, never a locally re-derived path (the
2358
1219
  // "re-deriving a cross-cutting seam locally" anti-pattern).
2359
- return { host, port, targetId: grantId ?? "", brokerControl: session, epochFile, supervisorDir: brokerRootDir() };
1220
+ return {
1221
+ host,
1222
+ port,
1223
+ targetId: grantId ?? "",
1224
+ brokerControl: session,
1225
+ epochFile,
1226
+ supervisorDir: brokerRootDir(),
1227
+ // Plan 41-01 (D-15): read fresh off the module-level variable
1228
+ // adoptGrant() stashed, exactly like every other field here -- `null`
1229
+ // becomes `undefined` on the lease (HeldLease.remoteMonitorPort is
1230
+ // optional; `null` is not a value that type carries).
1231
+ ...(grantRemoteMonitorPort === null ? {} : { remoteMonitorPort: grantRemoteMonitorPort }),
1232
+ };
2360
1233
  }
2361
1234
 
2362
1235
  async function ensureBrokerLease(): Promise<BrokerLeaseResult> {
@@ -2370,9 +1243,9 @@ async function ensureBrokerLease(): Promise<BrokerLeaseResult> {
2370
1243
  // re-reads broker.json fresh on every call (see its own implementation in
2371
1244
  // vice-broker-client.ts); nothing here memoises the verdict, so this is the
2372
1245
  // broker-path instance of the same never-cache-a-negative-result invariant
2373
- // the comment above ensureViceSession() already states for the host path --
2374
- // the call after a human starts the broker just works, with no session
2375
- // restart required. openBrokerControl() performs this SAME classification
1246
+ // stated near tools/call above -- the call after a human starts the
1247
+ // broker just works, with no session restart required. openBrokerControl()
1248
+ // performs this SAME classification
2376
1249
  // again internally (over its own read of broker.json) before it ever
2377
1250
  // connects -- a second, independent read, not a second answer to trust
2378
1251
  // instead of this one; fetching liveness here first is what gives the
@@ -2410,57 +1283,20 @@ async function ensureBrokerLease(): Promise<BrokerLeaseResult> {
2410
1283
  }
2411
1284
  const session = opened.session;
2412
1285
 
2413
- // WR-04: the FIRST thing done on a freshly opened control session -- before an
2414
- // emulator is allocated -- is to ask the broker which backend IT resolved, and
2415
- // refuse if it disagrees with this process's own verdict.
2416
- //
2417
- // Why this check has to exist: `ACTIVE_BACKEND` is resolved at module scope
2418
- // from resolvedBackend(), against the CONTAINER's filesystem, with no
2419
- // supervisorDir and therefore no cache -- while the emulator it describes is
2420
- // launched by the broker on the HOST, from the broker's own independent
2421
- // resolvedBackend({ supervisorDir }). In the normal devcontainer topology the
2422
- // container has no x64sc at all, so the proxy classifies `unknown` and
2423
- // degrades to `{ backend: "fork", source: "indeterminate" }`. If the host
2424
- // binary is stock, the proxy would otherwise advertise the fork's full
2425
- // manifest and forward HTTP at a binary-monitor port -- and nothing would ever
2426
- // report the disagreement; it would surface as an inexplicable transport
2427
- // failure on the first real tool call. D-01's "one reader" property holds per
2428
- // PROCESS but not across this pair, and this is the seam where the pair first
2429
- // meets.
2430
- //
2431
- // Refusing (rather than adapting) is deliberate: the advertised tool list was
2432
- // already built at startup from ACTIVE_BACKEND and answered to the client, so
2433
- // this process cannot re-decide its own surface here. VICE_BACKEND remains the
2434
- // explicit fix, and it must be set for BOTH processes.
2435
- //
2436
- // Absent evidence is NOT disagreement: a broker that does not report a backend
2437
- // (older build, or an unrecognised value) leaves `backend: null`, and a
2438
- // hostState() call that fails at all is not allowed to block an acquire. Only
2439
- // a definite, named mismatch refuses.
2440
- const brokerState = await session.hostState();
2441
- if (brokerState.ok && brokerState.hostState.backend !== null && brokerState.hostState.backend !== ACTIVE_BACKEND.backend) {
2442
- const brokerBackend = brokerState.hostState.backend;
2443
- await session.release();
2444
- return {
2445
- ok: false,
2446
- message:
2447
- `vice: backend mismatch between this MCP server and the broker that owns the emulator. This process ` +
2448
- `resolved "${ACTIVE_BACKEND.backend}" (source: ${ACTIVE_BACKEND.source}, binary: ${ACTIVE_BACKEND.binPath}) while the ` +
2449
- `broker resolved "${brokerBackend}" (binary: ${brokerState.hostState.vice_bin}) -- and the broker's verdict is the ` +
2450
- `authoritative one, because it is what the emulator was actually launched with. The two backends speak ` +
2451
- `different protocols on that port, so proceeding would send ${ACTIVE_BACKEND.backend === "fork" ? "HTTP at a binary-monitor port" : "binary-monitor frames at an HTTP endpoint"}. ` +
2452
- `This normally means the MCP server runs where the emulator binary is not (a container), so its own detection ` +
2453
- `could not see it. Set VICE_BACKEND=${brokerBackend} for THIS process as well -- it must be set for both -- ` +
2454
- `and restart the MCP server so its advertised tool list matches.`,
2455
- };
2456
- }
2457
- if (!brokerState.ok) {
2458
- console.error(
2459
- `vice-proxy: could not read the broker's own backend verdict (${brokerState.kind}: ${brokerState.message}) -- ` +
2460
- `proceeding with this process's own verdict "${ACTIVE_BACKEND.backend}" (source: ${ACTIVE_BACKEND.source}); a mismatch, if any, will not be detected`,
2461
- );
2462
- }
2463
-
1286
+ // FORKRM-01 (plan 52-06): the broker/proxy backend cross-check that used to
1287
+ // sit here is deleted outright, by recorded decision, with no lighter
1288
+ // replacement. It existed because two processes could resolve `ViceBackend`
1289
+ // differently -- this process's own resolution (against the CONTAINER's
1290
+ // filesystem, which usually has no x64sc at all) and the broker's own
1291
+ // independent resolution (against the HOST's) -- and a disagreement would
1292
+ // otherwise surface only as an inexplicable transport failure on the first
1293
+ // real tool call. With one backend the comparison is a tautology: both
1294
+ // sides can only ever resolve "stock". The residual signal the check also
1295
+ // caught -- one side finding no x64sc binary at all -- still reaches the
1296
+ // operator independently, unaffected by this deletion: `vice_ping`'s
1297
+ // `resolvedBinaryPath` field reports this process's own resolution (via
1298
+ // `RESOLVED_BINARY.binPath`/`binPathResolved` above), and the broker
1299
+ // reports a launch failure by name when it cannot find its own binary.
2464
1300
  const result = await session.acquire();
2465
1301
  if (!result.ok) {
2466
1302
  // No grant is coming for this session -- nothing to hold the connection
@@ -2483,7 +1319,6 @@ async function ensureBrokerLease(): Promise<BrokerLeaseResult> {
2483
1319
  // handleGrantedInstanceUnreachable() below) -- one code path for adopting
2484
1320
  // an instance, never a second one for a replacement.
2485
1321
  adoptGrant({ ...result.grant });
2486
- viceSession = null; // re-baseline: the next ensureViceSession() reads the GRANTED instance's own epoch file
2487
1322
  controlSession = session;
2488
1323
  return { ok: true, lease: buildHeldLease(session) };
2489
1324
  }
@@ -2500,6 +1335,29 @@ async function ensureBrokerLease(): Promise<BrokerLeaseResult> {
2500
1335
  function adoptGrant(grant: Record<string, unknown>): void {
2501
1336
  grantId = typeof grant.id === "string" ? grant.id : null;
2502
1337
  const containerized = containerizeGrant({ ...grant });
1338
+
1339
+ // Plan 41-01 (D-15): validate before stashing. `containerizeGrant()` never
1340
+ // translates this field (a port number needs no host<->container path or
1341
+ // URL rewrite), so `containerized.remote_monitor_port` is exactly the raw
1342
+ // wire value. A value that is not an integer in 1..65535 is rejected,
1343
+ // grantRemoteMonitorPort is left null, and a one-line stderr warning names
1344
+ // the observed value -- never a silent coercion, matching
1345
+ // containerizeGrant()'s own posture for an invalid grant.port.
1346
+ const rawRemoteMonitorPort = containerized.remote_monitor_port;
1347
+ if (rawRemoteMonitorPort === undefined) {
1348
+ grantRemoteMonitorPort = null;
1349
+ } else {
1350
+ const n = Number(rawRemoteMonitorPort);
1351
+ if (Number.isInteger(n) && n >= 1 && n <= 65535) {
1352
+ grantRemoteMonitorPort = n;
1353
+ } else {
1354
+ grantRemoteMonitorPort = null;
1355
+ console.error(
1356
+ `vice-proxy: adoptGrant ${grantId ?? "(no id)"}: remote_monitor_port (${JSON.stringify(rawRemoteMonitorPort)}) is not a valid integer port in 1..65535 -- the text channel will not be dialed for this instance`,
1357
+ );
1358
+ }
1359
+ }
1360
+
2503
1361
  useInstance({
2504
1362
  port: containerized.port as number,
2505
1363
  url: containerized.url as string,
@@ -2510,611 +1368,25 @@ function adoptGrant(grant: Record<string, unknown>): void {
2510
1368
 
2511
1369
  // --------------------------------------- D-13/D-14: replace-and-report
2512
1370
  //
2513
- // Plan 01.6.2-08. A granted instance's pre-flight probe failing used to
2514
- // produce ONE report-and-instruct message (the retired
2515
- // brokerGrantedUnreachableMessage(), see this file's earlier comment naming
2516
- // where it lived) and stop there, leaving a human to go investigate on the
2517
- // host. D-13 changes that into a replace-and-report: the proxy acquires a
2518
- // replacement itself, immediately, over the SAME control session (only the
2519
- // emulator instance is suspected dead here, not necessarily the connection
2520
- // to the broker) -- but still fails the TRIGGERING call LOUDLY, naming the
2521
- // replacement, rather than silently substituting a result read from a
2522
- // machine the caller never asked for. A memory read served quietly against
2523
- // a blank replacement returns zeroed RAM indistinguishable from real data,
2524
- // which is exactly the hazard the epoch guard elsewhere in this file exists
2525
- // to catch -- a notice buried inside an otherwise-successful payload is
2526
- // easy to skim past, so this never returns one.
2527
- //
2528
- // D-14 is what happens when that SAME-session replacement attempt itself
2529
- // discovers the connection is gone (kind "broker_gone"): an ACCEPTED,
2530
- // KNOWING regression, recorded here rather than left to be rediscovered as
2531
- // a defect. Before plan 07's transport swap, the only broker dependency
2532
- // surviving past a grant was a file write (the retiring lease heartbeat)
2533
- // whose failure was a silent no-op -- broker death was survivable by
2534
- // construction, because nothing after the grant still needed the broker at
2535
- // all. Under one held TCP connection, that is no longer true: recycle and
2536
- // (as of this plan) replacement both need a live connection. That safety
2537
- // margin is given up DELIBERATELY, per the tolerance decision recorded in
2538
- // broker-control-plane-over-tcp.md -- the compensation is that a session is
2539
- // told LOUDLY rather than left to quietly keep working against whatever a
2540
- // still-reachable granted instance happens to answer, for as long as it
2541
- // happens to stay reachable.
2542
- //
2543
- // Both D-13 and D-14 reuse the SAME machineReplacedMessage() builder (which
2544
- // itself reuses epochDriftMessage(), the existing voided-run vocabulary the
2545
- // fixed-port epoch-drift guard already carries) -- one vocabulary for a
2546
- // voided run, never a second one paralleling it. Neither outcome is ever
2547
- // cached: controlSession is deliberately left pointing at a known-dead
2548
- // session on every failure branch below, so the NEXT call's own probe
2549
- // failure repeats this exact same from-scratch attempt (a fresh
2550
- // openBrokerControl() reads broker.json fresh every time, never memoised),
2551
- // rather than short-circuiting on a remembered verdict -- see the
2552
- // NEVER-CACHE-A-NEGATIVE-RESULT invariant above ensureViceSession().
2553
-
2554
- /**
2555
- * D-13/D-14's shared report text: a call was refused because the machine
2556
- * behind it was REPLACED out from under it. Reuses epochDriftMessage() --
2557
- * the SAME builder the fixed-port epoch-drift guard already uses -- for the
2558
- * epoch-comparison sentence, rather than inventing a second wording for
2559
- * "this is not the machine you had a moment ago" (D-13's own instruction:
2560
- * no second notion of a voided run). States the three facts an agent needs,
2561
- * literally: the machine was REPLACED, the replacement is FRESH, and prior
2562
- * state on the old instance is GONE.
2563
- *
2564
- * 2026-08-05 defect fix, two parts, both kept INLINE here (not extracted to
2565
- * a helper) so this function's own body still literally contains the
2566
- * `epochDriftMessage(` call the structural test
2567
- * ("the replaced-machine report is built from the existing voided-run
2568
- * vocabulary") pins:
2569
- *
2570
- * 1. Epoch sentence -- three cases, not two. The old code called
2571
- * epochDriftMessage() whenever BOTH epochs were merely present,
2572
- * with no inequality check -- so an unmoved-but-both-present pair
2573
- * (oldEpoch.epoch === newEpoch.epoch) rendered the literally false
2574
- * "epoch changed from 1 to 1" (the exact sighting on file). Now:
2575
- * both present AND different -> epochDriftMessage(), unchanged; both
2576
- * present but EQUAL -> an honest "did not change" sentence (each
2577
- * port's epoch file is an independent counter, so a coincidental
2578
- * match is expected, not evidence of anything -- and a genuinely
2579
- * reused port can still read stale-equal if the host had not yet
2580
- * written its post-respawn bump at the moment this was sampled); not
2581
- * both present -> unchanged from before, "could not both be
2582
- * compared".
2583
- * 2. Port sentence -- when oldPort === newPort (a real, legitimate
2584
- * outcome in this broker's fixed-slot design: a "replacement" can
2585
- * land back on the exact port it replaced), the OLD wording named
2586
- * that single port number as both "the old instance (port X)" and
2587
- * "the replacement instance (port X)" -- two different entities
2588
- * sharing one label, which a reader cannot reconcile ("one port
2589
- * cannot be both"). Now branches on whether the port actually
2590
- * changed: same port says so plainly ("replaced in place"), rather
2591
- * than implying two distinct ports that happen to print the same
2592
- * digits.
2593
- */
2594
- function machineReplacedMessage(opts: {
2595
- where: string;
2596
- reason: string;
2597
- oldPort: number;
2598
- oldEpoch: EpochResult;
2599
- newPort: number;
2600
- newEpoch: EpochResult;
2601
- }): string {
2602
- const { where, reason, oldPort, oldEpoch, newPort, newEpoch } = opts;
2603
- let driftSentence: string;
2604
- if (oldEpoch.present && newEpoch.present) {
2605
- driftSentence =
2606
- oldEpoch.epoch !== newEpoch.epoch
2607
- ? epochDriftMessage(where, oldEpoch, newEpoch)
2608
- : `vice: the epoch recorded ${where} did not change (still ${oldEpoch.epoch}) -- this is NOT evidence ` +
2609
- `the machine stayed the same: each port's epoch counter is independent, so a coincidental match is ` +
2610
- `expected between two unrelated files, and a genuinely reused port can still read stale-equal if the ` +
2611
- `host had not yet recorded its post-respawn bump at the moment this was sampled. Treat every result ` +
2612
- `since the previous call as void and redo that work regardless -- the replacement itself (below) is ` +
2613
- `the operative fact here, not this epoch read.`;
2614
- } else {
2615
- driftSentence =
2616
- `vice: treat every result since the previous call as void and redo that work -- the old instance's ` +
2617
- `epoch and the new instance's epoch could not both be compared ` +
2618
- `(old epoch present: ${oldEpoch.present}, new epoch present: ${newEpoch.present}).`;
2619
- }
2620
- const portSentence =
2621
- oldPort === newPort
2622
- ? `The instance behind port ${oldPort} was REPLACED IN PLACE -- the process is a FRESH emulator (this ` +
2623
- `broker's fixed-slot design can hand the replacement the SAME port back), and all prior state from ` +
2624
- `before the replacement is GONE (${reason}).`
2625
- : `The machine was REPLACED, the replacement is a FRESH emulator, and all prior state on the old ` +
2626
- `instance (port ${oldPort}) is GONE (${reason}).`;
2627
- return (
2628
- `${driftSentence} ${portSentence} Make this call again -- it will run on the replacement instance ` +
2629
- `(port ${newPort}), already acquired and adopted for this session.`
2630
- );
2631
- }
2632
-
2633
- /** D-13: the replacement acquisition itself failed for a reason OTHER than
2634
- * the broker connection being gone (denied, no_free_port, at_capacity, its
2635
- * own deadline, ...). Names both failures -- the original unreachability
2636
- * and the failed replacement -- and does not retry: a retry loop against a
2637
- * broker that cannot currently grant is how one failure becomes a hang. */
2638
- function replacementFailedMessage(probe: ProbeResult, failure: { kind: string; message: string }): string {
2639
- return (
2640
- `vice: retry this call yourself once the underlying problem is fixed -- no further replacement will ` +
2641
- `be attempted automatically. The granted instance stopped answering (${probe.reason}), and a ` +
2642
- `same-session replacement attempt also failed (${failure.kind}: ${failure.message}).`
2643
- );
2644
- }
2645
-
2646
- /** D-14: the broker connection is gone and a fresh one could not be opened
2647
- * either (or could be opened but could not itself acquire) -- there is
2648
- * nothing left this proxy can do on its own. Names the broker, not this
2649
- * proxy, as the cause, and states plainly that no further call in THIS
2650
- * session can succeed until it is running again. */
2651
- function sessionMustRestartMessage(failure: { kind: string; message: string }): string {
2652
- return (
2653
- `vice: this session must be restarted -- no further call in this session can succeed until the ` +
2654
- `broker is running again. The on-demand VICE broker connection is gone and a fresh session could ` +
2655
- `not be opened (${failure.kind}: ${failure.message}). The broker itself is the cause.`
2656
- );
2657
- }
2658
-
2659
- /**
2660
- * D-13/D-14's entry point, reached only when the pre-flight probe found the
2661
- * session's granted instance unreachable AND a control session is held.
2662
- * Exactly one same-session replacement attempt, then (only if THAT attempt
2663
- * discovers the connection itself is gone) exactly one fresh-session
2664
- * attempt -- never a loop, never more than these two acquisitions for one
2665
- * triggering call. Always returns a report string; never a result, even on
2666
- * the success paths -- see this section's own header comment for why.
2667
- */
2668
- async function handleGrantedInstanceUnreachable(probe: ProbeResult, oldEpoch: EpochResult): Promise<string> {
2669
- const { port: oldPort } = activeInstance();
2670
- const session = controlSession as BrokerControlSession;
2671
-
2672
- // Attempt 1: a replacement over the SAME session (D-13). Only the granted
2673
- // EMULATOR is suspected dead here -- the connection to the broker may
2674
- // still be perfectly good, and reusing it is the whole point of "costs
2675
- // one acquisition, not the session."
2676
- //
2677
- // Gap closure (plan 14, WR-03 / T-01.6.2-90): release the grant this
2678
- // session currently holds BEFORE acquiring its replacement -- two
2679
- // independent reasons, both load-bearing, and order matters for both.
2680
- //
2681
- // Reason one: releasing frees the OLD instance's port and capacity slot
2682
- // FIRST, before the acquire below ever asks for one -- a broker already
2683
- // sitting at its instance ceiling can still serve this replacement, where
2684
- // acquiring first could not.
2685
- //
2686
- // Reason two, the actual leak this closes: grantId (this proxy's own
2687
- // module-level grant slot, declared above) is a SINGLE value --
2688
- // adoptGrant() below simply overwrites it. Acquiring a replacement
2689
- // without first releasing what is about to be overwritten is what
2690
- // abandons the prior grant: the broker goes on holding an instance this
2691
- // proxy no longer remembers asking to release, and every further
2692
- // lost-machine event on this same session leaks one more, compounding
2693
- // toward the instance ceiling. No new control-plane message is needed to
2694
- // close this -- the existing release request already releases exactly
2695
- // the grant THIS connection currently holds (no target id on the wire at
2696
- // all), which is precisely the right one, provided it lands before the
2697
- // slot below is overwritten.
2698
- //
2699
- // session.release() performs that release by closing the underlying
2700
- // connection (a synchronous socket.destroy() under the hood -- D-12: the
2701
- // connection IS the lease). The acquire immediately below therefore
2702
- // finds THIS session already gone and answers "broker_gone" -- which is
2703
- // NOT a new failure mode invented for this fix: it is handled by the
2704
- // SAME broker-gone branch a few lines down this function already had,
2705
- // exactly as it already handles any other dead-connection discovery. A
2706
- // failed release introduces no new branch of its own: release() never
2707
- // rejects (closing an already-closed socket is an idempotent no-op), and
2708
- // even if it somehow did, the acquire that follows would classify and
2709
- // report it exactly the same way.
2710
- await session.release();
2711
- grantId = null;
2712
- const result = await session.acquire();
2713
- if (result.ok) {
2714
- adoptGrant({ ...result.grant });
2715
- viceSession = null;
2716
- ensureViceSession(); // re-baseline BEFORE returning -- see the never-cache invariant
2717
- const newInstance = activeInstance();
2718
- return machineReplacedMessage({
2719
- where: "at the pre-flight liveness probe",
2720
- reason: `the granted instance (port ${oldPort}) stopped answering -- ${probe.reason}`,
2721
- oldPort,
2722
- oldEpoch,
2723
- newPort: newInstance.port,
2724
- newEpoch: currentEpoch(),
2725
- });
2726
- }
2727
-
2728
- if (result.kind !== "broker_gone") {
2729
- // Bounded: exactly one replacement attempt, and it failed for a reason
2730
- // that has nothing to do with the connection itself -- report both
2731
- // failures and stop.
2732
- return replacementFailedMessage(probe, result);
2733
- }
2734
-
2735
- // D-14: attempt 1 itself discovered the control connection is gone --
2736
- // now the ORDINARY way this branch is reached, since the release just
2737
- // above always closes it (T-01.6.2-90's own fix, not a regression: the
2738
- // grant that release protects against leaking is already gone by
2739
- // construction before this line ever runs). No release is sent over
2740
- // `session` here, and none is needed: a release cannot be sent over a
2741
- // connection that is already gone, and connection close IS the release
2742
- // in this design, kernel-enforced -- the broker's own close handler has
2743
- // already released the prior grant and killed its instance the moment
2744
- // that close event fired, whichever branch triggered it. If the broker
2745
- // itself died instead, there is nothing left to leak into either.
2746
- // Attempt 2: open a GENUINELY FRESH session -- a brand-new broker.json
2747
- // read (never the stale record the dead session above was opened
2748
- // against), never reusing `session`. controlSession is deliberately left
2749
- // pointing at the dead session on every failure branch below, so a LATER
2750
- // call's own probe failure repeats this exact same from-scratch sequence.
2751
- const opened = await openBrokerControl();
2752
- if (!opened.ok) {
2753
- return sessionMustRestartMessage(opened);
2754
- }
2755
- const freshResult = await opened.session.acquire();
2756
- if (!freshResult.ok) {
2757
- await opened.session.release(); // nothing to hold this connection open for
2758
- return sessionMustRestartMessage(freshResult);
2759
- }
2760
- adoptGrant({ ...freshResult.grant });
2761
- controlSession = opened.session; // the fresh session replaces the dead one, held for the rest of this proxy's life
2762
- viceSession = null;
2763
- ensureViceSession();
2764
- const newInstance = activeInstance();
2765
- return machineReplacedMessage({
2766
- where: "after the broker connection itself was found gone and a fresh session was opened",
2767
- reason: `the broker connection was gone (${result.message})`,
2768
- oldPort,
2769
- oldEpoch,
2770
- newPort: newInstance.port,
2771
- newEpoch: currentEpoch(),
2772
- });
2773
- }
2774
-
2775
- // ------------------------------------------------ D-16 seam hazard annotation
2776
- //
2777
- // Plan 01.3-04. Structurally the OPPOSITE of the deny-list refusal below (the
2778
- // DENY_LIST.includes(name) branch a little further into this same function):
2779
- // the refusal fires BEFORE forwarding and the call never reaches the host;
2780
- // this fires AFTER call() returns a real payload and appends to a
2781
- // SUCCESSFUL result. The call is never refused and the error flag is never
2782
- // set (D-16) -- a stopping checkpoint on an IRQ handler is core reverse-
2783
- // engineering technique that Phase 2's exhaustive trace depends on, so this
2784
- // warns instead of blocking it, the way the deny list blocks vice_disk_list
2785
- // (which has no legitimate use at all).
2786
-
2787
- // The set of capability names whose OWN arguments can express an armed,
2788
- // stopping, exec checkpoint. Today that is vice_checkpoint_add alone.
2789
- // Re-enabling an already-armed stopping checkpoint via vice_checkpoint_toggle
2790
- // or a checkpoint group (vice_checkpoint_group_toggle/_add) can ALSO re-arm
2791
- // one, but neither call's own arguments carry the stop flag -- only the
2792
- // id/group being toggled -- so that re-enable path is NOT detectable from
2793
- // the call alone and is deliberately excluded from this set. That gap is
2794
- // covered by both tools' own descriptions and by vice_diagnose's checkpoint-
2795
- // trap check, and it is stated in the annotation text below rather than left
2796
- // for a reader to discover.
2797
- const CHECKPOINT_ARMING_TOOLS = new Set(["vice_checkpoint_add"]);
2798
-
2799
- // Per-session suppression: an address (as rendered by formatAddress(), or an
2800
- // "unparseable:<raw>" key for an address that could not be parsed) already
2801
- // warned about this session maps to true. Cleared whenever the observed
2802
- // epoch changes -- a new machine has seen none of these. currentEpoch() is a
2803
- // synchronous LOCAL file read (see its own definition above), never a
2804
- // forwarded call, so consulting it here does not violate the "makes no
2805
- // forwarded call of its own" requirement below.
2806
- let seamHazardSeen: Set<string> = new Set();
2807
- let seamHazardEpochKey: number | null = null;
2808
-
2809
- function seamHazardObserveEpoch(): void {
2810
- const epoch = currentEpoch();
2811
- const key = epoch && epoch.present ? epoch.epoch : null;
2812
- if (seamHazardEpochKey !== null && key !== seamHazardEpochKey) {
2813
- seamHazardSeen = new Set(); // a new machine has seen none of these
2814
- }
2815
- seamHazardEpochKey = key;
2816
- }
2817
-
2818
- /** detectCheckpointArmingHazard()'s own return shape -- consumed only by
2819
- * renderCheckpointArmingHazard() below. */
2820
- interface CheckpointArmingHazardDetection {
2821
- addrLabel: string;
2822
- repeat: boolean;
2823
- }
2824
-
2825
- /**
2826
- * D-16's hazard annotation. Returns the annotation text for a successful
2827
- * checkpoint-arming call, or nothing. Returns nothing unless the capability
2828
- * is in CHECKPOINT_ARMING_TOOLS and the arguments express an exec operation
2829
- * with the stop flag set -- callers only reach this after a successful
2830
- * call(), so a rejected arm never reaches here at all (a failed arm has no
2831
- * hazard to warn about). Makes NO forwarded call of its own (T-01.3-13) --
2832
- * the detection is entirely over the arguments the agent already supplied.
2833
- * An unparseable address is still annotated, naming the address as unread
2834
- * rather than silently skipping: an unparseable address is not evidence of
2835
- * safety.
2836
- */
2837
- function detectCheckpointArmingHazard(
2838
- name: string,
2839
- args: Record<string, unknown>
2840
- ): CheckpointArmingHazardDetection | undefined {
2841
- if (!CHECKPOINT_ARMING_TOOLS.has(name)) return undefined;
2842
- // vice_checkpoint_add's own schema: `stop` defaults true, `exec` defaults
2843
- // true -- an ABSENT field is armed, not merely "true when written out".
2844
- const stopArmed = !(args && args.stop === false);
2845
- const execArmed = !(args && args.exec === false);
2846
- if (!stopArmed || !execArmed) return undefined;
2847
-
2848
- seamHazardObserveEpoch();
2849
-
2850
- const addrNum = toAddressNumber(args && args.start);
2851
- const addrLabel =
2852
- addrNum === null ? `an unparseable address (raw value: ${JSON.stringify(args && args.start)})` : formatAddress(addrNum);
2853
- const suppressionKey = addrNum === null ? `unparseable:${JSON.stringify(args && args.start)}` : addrLabel;
2854
-
2855
- const repeat = seamHazardSeen.has(suppressionKey);
2856
- if (!repeat) seamHazardSeen.add(suppressionKey);
2857
- return { addrLabel, repeat };
2858
- }
2859
-
2860
- function renderCheckpointArmingHazard(detection: CheckpointArmingHazardDetection): string {
2861
- const { addrLabel, repeat } = detection;
2862
- if (repeat) {
2863
- return (
2864
- `vice hazard (repeat): a stopping exec checkpoint was armed again at ${addrLabel} -- the full ` +
2865
- "hazard note for this address was already issued earlier this session; see that note."
2866
- );
2867
- }
2868
- return [
2869
- `vice hazard: a stopping exec checkpoint was just armed at ${addrLabel}, and the call was NOT ` +
2870
- "blocked -- it will not be, because this is core reverse-engineering technique.",
2871
- "",
2872
- "This shape -- a stopping exec checkpoint armed, then execution resumed -- is common to every recorded " +
2873
- "freeze on this project. Two variants are on record: a mid-routine stop that froze two independent " +
2874
- "sessions at an identical program counter, and an IRQ-handler-entry stop whose tell was a hit count " +
2875
- "of zero on a screen the machine must have been executing.",
2876
- "",
2877
- "Whether THIS address is the live IRQ handler is a question vice_diagnose answers, by resolving the " +
2878
- "vector pair live -- this warning deliberately does not resolve it here, because doing so on every " +
2879
- "arm would disturb the machine it is protecting.",
2880
- "",
2881
- "Recovery, in order: run vice_diagnose first; reach for vice_recycle only when the bracket says wedge " +
2882
- "with no checkpoint explanation.",
2883
- "",
2884
- "Stated residual: re-enabling this checkpoint later via vice_checkpoint_toggle or a checkpoint group " +
2885
- "carries no stop flag in its own arguments and is therefore NOT annotated by this mechanism -- covered " +
2886
- "by both tools' own descriptions and by vice_diagnose's checkpoint-trap check instead.",
2887
- ].join("\n");
2888
- }
2889
-
2890
- /**
2891
- * Plan 01.3-04 task 2: turns task 1's single hazard into the general
2892
- * mechanism D-06 needs -- a table, so the next confirmed trigger (plan
2893
- * 01.3-05's bounded hunt) is a single entry rather than new plumbing at this
2894
- * seam. Each entry:
2895
- * - id: a short identifier that MUST be named by at least one test in
2896
- * vice-proxy.test.mjs (this file's own structural completeness test
2897
- * enforces it) -- an entry that ships without a matching test fails the
2898
- * suite rather than shipping unproven.
2899
- * - capabilities: the Set of tool names this entry's own detect() can ever
2900
- * match against. Used ONLY by the disjointness structural test below
2901
- * (never for dispatch -- the walk tries every entry against every
2902
- * call). Every capability named here must be ABSENT from DENY_LIST: a
2903
- * capability with no legitimate use is refused before forwarding, and
2904
- * one with a legitimate use is annotated after it, and none is both
2905
- * (D-16).
2906
- * - detect(name, args, payload): returns a truthy detection payload, or
2907
- * nothing. MUST make no forwarded call of its own (T-01.3-13).
2908
- * - render(detection): returns the annotation text for a truthy
2909
- * detection.
2910
- *
2911
- * Plan 01.3-05 is this table's expected next writer, adding the bounded
2912
- * hunt's own confirmed trigger as one more entry here -- not new plumbing.
2913
- */
2914
- // Method-shorthand syntax deliberately (not `detect: (...) => ...`): TS's
2915
- // bivariant method-parameter check is what lets each entry's own narrower
2916
- // detect()/render() pair (e.g. CheckpointArmingHazardDetection, not
2917
- // `unknown`) slot into this shared, heterogeneous table -- exactly the
2918
- // polymorphism the table's own doc comment above describes ("the next
2919
- // confirmed trigger is a single entry"). The production entry below is cast
2920
- // `as SeamHazardEntry` (not the whole SEAM_HAZARDS declaration -- that
2921
- // exact line is vice-proxy.test.mjs's own oracle anchor, `indexOf("const
2922
- // SEAM_HAZARDS = [")`, and must stay byte-identical) so the array's own
2923
- // inferred element type is this interface, which is what lets the
2924
- // TEST-ONLY .push() below (a structurally different detect/render pair)
2925
- // type-check without a second cast at that call site.
2926
- interface SeamHazardEntry {
2927
- id: string;
2928
- capabilities: Set<string>;
2929
- detect(name: string, args: Record<string, unknown>, payload?: unknown): unknown;
2930
- render(detection: unknown): string;
2931
- }
2932
-
2933
- const SEAM_HAZARDS = [
2934
- {
2935
- id: "checkpoint-arming",
2936
- capabilities: CHECKPOINT_ARMING_TOOLS,
2937
- detect: detectCheckpointArmingHazard,
2938
- render: renderCheckpointArmingHazard,
2939
- } as SeamHazardEntry,
2940
- ];
2941
-
2942
- // TEST-ONLY escape hatch (plan 01.3-04 task 2's data-driven proof): proves
2943
- // the walk below is genuinely data-driven, not hand-wired to the one
2944
- // production entry above, by injecting a SECOND entry the same way a real
2945
- // plan 01.3-05 entry would arrive. Matches against vice_ping -- an existing,
2946
- // universally-forwardable tool -- rather than inventing a synthetic
2947
- // capability name that would need its own manifest/deny-list bookkeeping.
2948
- // Never set outside this file's own test suite.
2949
- if (process.env.VICE_SEAM_HAZARDS_TEST_FIXTURE === "1") {
2950
- SEAM_HAZARDS.push({
2951
- id: "test-fixture-synthetic-entry",
2952
- capabilities: new Set(["vice_ping"]),
2953
- detect: (name: string) => (name === "vice_ping" ? { fixture: true } : undefined),
2954
- render: () => "vice-proxy hazard (TEST FIXTURE): synthetic second SEAM_HAZARDS entry, detected and annotated through the same walk.",
2955
- });
2956
- }
2957
-
2958
- /**
2959
- * Walks SEAM_HAZARDS, concatenating every annotation a successful call
2960
- * attracts. Short-circuits per entry on a falsy detection -- a call matching
2961
- * no entry costs one array pass and returns undefined, leaving the payload
2962
- * untouched.
2963
- */
2964
- function renderSeamHazardAnnotations(name: string, args: Record<string, unknown>, payload: unknown): string | undefined {
2965
- const notes: string[] = [];
2966
- for (const entry of SEAM_HAZARDS) {
2967
- const detection = entry.detect(name, args, payload);
2968
- if (detection) {
2969
- notes.push(entry.render(detection));
2970
- }
2971
- }
2972
- return notes.length ? notes.join("\n\n") : undefined;
2973
- }
2974
-
2975
- // forwardToVice() is the retained BODY of what used to be handleToolsCall()
2976
- // -- renamed and trimmed of the name/args extraction, the three synthetic-
2977
- // tool short-circuits, and the deny-list check, all now handled one layer
2978
- // out by the CallToolRequestSchema override and the tool registry
2979
- // construction (both near the bottom of this file, right after the
2980
- // teardown region): each real manifest tool's own buildViceTool() entry
2981
- // wraps this function as its `execute`, so this is reached only for a name
2982
- // already known to be a real, non-deny-listed manifest tool with an
2983
- // already-parsed `args` object. Every function called below is reused
2984
- // completely unchanged from its pre-swap form.
2985
- async function forwardToVice(name: string, args: Record<string, unknown>): Promise<ToolCallResult> {
2986
- const leaseResult = await ensureBrokerLease();
2987
- if (!leaseResult.ok) {
2988
- return isErrorText(leaseResult.message);
2989
- }
2990
- // No touch-on-every-forwarded-call any more (C6's old mechanism, alongside
2991
- // the heartbeat timer, both retired under D-12): the connection itself is
2992
- // the claim, kernel-enforced, with nothing to refresh. Either the socket is
2993
- // still open, or the broker's own "close" handler has already reclaimed
2994
- // the instance -- there is no third, ambiguous state a touch could rescue.
2995
-
2996
- ensureViceSession();
2997
-
2998
- const beforeDrift = checkEpochAndRebaseline("before forwarding");
2999
- if (beforeDrift) {
3000
- // Refused BEFORE any request is serialised -- the whole point of the
3001
- // pre-forward check.
3002
- return isErrorText(beforeDrift);
3003
- }
3004
-
3005
- // Pre-flight liveness probe (task 2 / criterion 7), ordered AFTER the
3006
- // deny-list refusal and the epoch comparison above (a refused tool and a
3007
- // restarted machine both need answering without any network activity at
3008
- // all) and BEFORE delegating to call() -- see vice-probe.ts's header for
3009
- // why this is a single 1500ms-budget round trip with no retry, never
3010
- // wrapped in withReconnect()'s ladder. One call site, not inside a loop.
3011
- const { url, port } = activeInstance();
3012
- const probe = await probeInstance({ url, port });
3013
- if (!probe.alive) {
3014
- const epoch = currentEpoch();
3015
- // D-5 (quick-260801-ccn task 3): the lease check runs FIRST, before the
3016
- // refused-and-no-epoch test below -- under the bug this fixes, BOTH of
3017
- // that test's arms hold true for a fresh broker grant (a just-granted
3018
- // instance's own epoch_file rarely has a baseline recorded yet), so a
3019
- // broker-granted instance was being answered by the RETIRED fixed-port
3020
- // triple instead of naming the broker. That ordering was the whole
3021
- // defect.
3022
- if (controlSession) {
3023
- // D-13/D-14 (plan 08): a granted instance not answering no longer
3024
- // gets a report-and-instruct message -- it gets a replace-and-report.
3025
- // handleGrantedInstanceUnreachable() acquires a replacement over this
3026
- // same session (or, if the session itself turns out to be gone, a
3027
- // genuinely fresh one) and returns an ERROR naming the replacement --
3028
- // never a silently substituted result, even though a working
3029
- // instance is now held for the NEXT call.
3030
- return isErrorText(await handleGrantedInstanceUnreachable(probe, epoch));
3031
- }
3032
- if (isConnectionRefusedReason(probe.reason) && !epoch.present) {
3033
- return isErrorText(neverStartedMessage(probe));
3034
- }
3035
- // Every other unreachable shape -- refused-with-an-epoch-on-record,
3036
- // timed out, or something answered but didn't look like VICE -- is
3037
- // "dead or hung"; probe.reason itself says which, verbatim.
3038
- return isErrorText(deadOrHungMessage(probe, epoch));
3039
- }
3040
-
3041
- // Path translation at the seam (task 3 / decision D-G / criterion 9),
3042
- // ordered after the deny-list refusal, the epoch comparison and the
3043
- // liveness probe above, and before delegating to call(). A refusal here
3044
- // (out-of-workspace absolute path, or a translation failure) is returned
3045
- // exactly like every other tools/call outcome: a well-formed isError:true
3046
- // result, never a throw.
3047
- let translatedArgs: Record<string, unknown>;
3048
- let pathNote = "";
3049
- try {
3050
- const rewritten = rewriteArguments(args, name);
3051
- translatedArgs = rewritten.args;
3052
- pathNote = resolutionNote(rewritten.resolutions);
3053
- } catch (e) {
3054
- if (e instanceof PathOutOfWorkspaceError || e instanceof PathTranslationError) {
3055
- return isErrorText(e.message);
3056
- }
3057
- throw e; // unexpected -- let the never-throw dispatch one layer up handle it
3058
- }
3059
-
3060
- let payload: unknown;
3061
- try {
3062
- payload = await call(name, translatedArgs);
3063
- } catch (e) {
3064
- if (e instanceof MachineRestartedError) {
3065
- // call()'s own post-reconnect fast path detected this first -- convert
3066
- // to the same isError frame shape and re-baseline identically. Two
3067
- // layers, one observable behaviour.
3068
- const current = currentEpoch();
3069
- epochBaseline = current;
3070
- return isErrorText(
3071
- `vice: treat every result since the previous call as void and redo that work -- the emulator was ` +
3072
- `replaced mid-call (epoch changed from ${e.baselineEpoch} to ${e.currentEpoch}). (${e.message})`
3073
- );
3074
- }
3075
- // NEVER rethrow past this point -- a tool-execution failure (transport
3076
- // error, a rejected RPC) is a normal, expected outcome for this method
3077
- // and must come back as a well-formed result, not crash the read loop.
3078
- // The probe above already proved the host alive, so this is the "alive
3079
- // but the operation failed" state -- relay verbatim, no restart advice.
3080
- // The path note rides along on the FAILURE too, and this is the case it
3081
- // was written for: a host-side "Failed to attach disk image" says nothing
3082
- // about which file was attempted, so naming the resolved absolute path
3083
- // here is the difference between a one-line fix and an hour spent
3084
- // suspecting the emulator.
3085
- const failure = aliveButFailedMessage(e && (e as Error).message ? (e as Error).message : String(e));
3086
- return isErrorText(pathNote ? `${failure}\n${pathNote}` : failure);
3087
- }
3088
-
3089
- const afterDrift = checkEpochAndRebaseline("after the call returned");
3090
- if (afterDrift) {
3091
- // A payload read from a machine whose identity changed mid-call is not
3092
- // trustworthy -- return the restart frame INSTEAD OF the call's result.
3093
- return isErrorText(afterDrift);
3094
- }
3095
-
3096
- const rawText = typeof payload === "string" ? payload : JSON.stringify(payload);
3097
- // D-16 seam hazard annotation (plan 01.3-04): computed by walking
3098
- // SEAM_HAZARDS and merged into the TEXT itself, BEFORE wrapPossiblyChunked()
3099
- // runs, so an oversized annotated result still carries the note inside its
3100
- // own chunking (T-01.3-15) -- a warning appended AFTER chunking would be
3101
- // lost off the end. Never routes through isErrorText and never touches the
3102
- // error flag (D-16, T-01.3-12).
3103
- const hazardNote = renderSeamHazardAnnotations(name, args, payload);
3104
- const text = hazardNote ? `${rawText}\n\n${hazardNote}` : rawText;
3105
- const wrapped = wrapPossiblyChunked(text);
3106
- // Append the path note as a trailing content item, never mixed into the
3107
- // payload: wrapPossiblyChunked()'s contract is that the FIRST item is the
3108
- // payload byte-for-byte, so reassembly stays a plain concatenation. Only
3109
- // the unchunked shape is annotated -- a chunked result is already carrying
3110
- // a continuation marker as its second item, and the four tools that can
3111
- // resolve a path (disk_attach, autostart, display_screenshot, symbols_load)
3112
- // never produce output anywhere near the cap.
3113
- if (pathNote && wrapped.content.length === 1) {
3114
- wrapped.content.push({ type: "text", text: pathNote });
3115
- }
3116
- return wrapped;
3117
- }
1371
+ // machineReplacedMessage()/replacementFailedMessage()/sessionMustRestartMessage()
1372
+ // and their entry point handleGrantedInstanceUnreachable() are deleted along
1373
+ // with the fork-only generic forwarding function that used to sit here (a
1374
+ // broker lease, an epoch drift check, a pre-flight liveness probe, a
1375
+ // per-call argument path translation, the fork transport's own HTTP
1376
+ // dispatch call, a D-16 seam-hazard annotation walk over the
1377
+ // checkpoint-arming detector, and result chunking): this whole
1378
+ // replace-and-report mechanism existed to handle a granted instance failing
1379
+ // that forwarding function's own pre-flight liveness check (a fork-only
1380
+ // HTTP round trip) -- its only caller. The D-16 mechanism has no other
1381
+ // caller either, and stock-dispatch.ts's own per-tool handlers have no
1382
+ // equivalent hook today. Every advertised tool now registers straight
1383
+ // through buildViceTool() to stockDispatch.dispatchStock() (see the
1384
+ // registration loop below) -- there is no surviving generic-dispatch
1385
+ // surface for a derived tool to slip behind, matching this plan's own
1386
+ // prohibition against re-opening the nested-argument hazard an outer-name
1387
+ // refusal array used to close. Stock has no equivalent probe-then-replace
1388
+ // step at this proxy layer; a dead lease surfaces through stockDispatch's
1389
+ // own error handling instead.
3118
1390
 
3119
1391
  // -------------------------------------------------------------- teardown
3120
1392
  //
@@ -3189,12 +1461,13 @@ warnOnceAboutOutputLimit(); // D-1.2-H -- one stderr line, at most once per proc
3189
1461
 
3190
1462
  // ------------------------------------------------------- @mastra/mcp seam
3191
1463
  //
3192
- // D-01 (this plan): the wire layer is now MCPServer + startStdio(), with
3193
- // broker leasing, epoch, probe, path rewriting, call() and chunking all
3194
- // reused completely unchanged inside forwardToVice() above -- only the
3195
- // top-level caller changed. See this plan's PLAN.md "Ground truth" section
3196
- // (read directly from @mastra/mcp's compiled source, not its docs) for why
3197
- // tools/call is answered by the CallToolRequestSchema override below rather
1464
+ // D-01: the wire layer is MCPServer + startStdio(), with each registered
1465
+ // tool's own runner (stockDispatch.dispatchStock(), or a proxy-local
1466
+ // handler for the synthetic/anno_* tools, above) doing the actual dispatch
1467
+ // work -- only the top-level caller changed from the original hand-rolled
1468
+ // framing. See this plan's PLAN.md "Ground truth" section (read directly
1469
+ // from @mastra/mcp's compiled source, not its docs) for why tools/call is
1470
+ // answered by the CallToolRequestSchema override below rather
3198
1471
  // than by MCPServer's own dispatch.
3199
1472
 
3200
1473
  /**
@@ -3262,129 +1535,74 @@ function buildViceTool(def: ToolDefinition, run: (args: Record<string, unknown>)
3262
1535
  });
3263
1536
  }
3264
1537
 
3265
- // Plan 01.6.3-02's tracer proved this mechanism on `vice_ping` alone
3266
- // (TRACER_MANIFEST_TOOL_NAMES, since removed). Plan 01.6.3-03 widens the
3267
- // input set to the FULL manifest -- the loop body itself is unchanged from
3268
- // the tracer: no per-tool special case, only the same DENY_LIST filter
3269
- // already proven in Plan 02. The manifest also lists the host's own
3270
- // generic-surface meta-tools (`tools_call`/`tools_list`/`initialize`/
3271
- // `notifications_initialized`) as ordinary tools; 01.6.3-03 registered them
3272
- // like any other manifest entry (a real, disclosed generic-dispatch risk it
3273
- // did not itself widen -- see the CallToolRequestSchema override below for
3274
- // the historical shape of that risk), and 01.4-01 (tasks 1+2) closed it by
3275
- // adding all four to DENY_LIST itself, so this SAME skip now filters them
3276
- // out of `tools` at construction time exactly like vice_disk_list always
3277
- // was.
1538
+ // This loop registers every tool the active manifest advertises. It used
1539
+ // to skip a fixed outer-name refusal array covering the fork HTTP server's
1540
+ // own generic-surface meta-tools (`tools_call`/`tools_list`/`initialize`/
1541
+ // `notifications_initialized`, all of which the fork's manifest advertised
1542
+ // as ordinary forwardable tools) plus `vice_disk_list` (a tool known to
1543
+ // crash that same server). Both the fork manifest and the refusal array are
1544
+ // gone: the manifest this loop reads never advertised any of those names,
1545
+ // so there is nothing left to skip -- every entry registers unconditionally.
1546
+ // tools/list is served entirely by MCPServer's own ListToolsRequestSchema
1547
+ // handler (unmodified, not overridden), reading from this SAME `tools`
1548
+ // object. A manifest hot-reload mid-session is not picked up until the
1549
+ // proxy restarts; the manifest is regenerated by a manual, rare build step,
1550
+ // never mid-session in practice.
3278
1551
  //
3279
- // Construction-time, not read-time -- a deliberate, disclosed narrowing this
3280
- // plan records explicitly: tools/list is now served entirely by MCPServer's
3281
- // own ListToolsRequestSchema handler (unmodified, not overridden), reading
3282
- // from this SAME `tools` object, so vice_disk_list's absence from it is the
3283
- // ONLY enforcement discovery-time needs any more -- no separate filter
3284
- // function runs at read time. A manifest hot-reload mid-session is
3285
- // therefore no longer picked up until the proxy restarts; the manifest is
3286
- // regenerated by a manual, rare build step, never mid-session in practice.
3287
- // Edit 3 (plan 02-10, D-09): the runner each manifest tool's own execute()
3288
- // closes over is chosen by ACTIVE_BACKEND, decided ONCE above, never
3289
- // per-tool or per-call. The first ternary arm below is byte-identical to
3290
- // every prior plan's own forwarding call, unchanged.
3291
- // The second arm (the OTHER backend) passes ensureBrokerLease itself as the
3292
- // injected LeaseProvider (no locally-built acquisition wrapping it -- there
3293
- // is exactly one acquisition function in this file, and this arm calls the
3294
- // SAME one the first arm's own lease check already calls), plus this file's
3295
- // own already-settled binary path so the health-check tool on that path can
3296
- // answer BACK-03 without ever re-detecting anything itself.
3297
- /**
3298
- * The ONE backend-aware registration seam (D-09). CR-07 (code review
3299
- * 2026-08-13) is why it is a function rather than a ternary inlined in the
3300
- * manifest loop: the loop was backend-aware, but the three synthetic tools
3301
- * registered straight after it were NOT, and `tools/list` is served from this
3302
- * same object -- so on the stock backend the advertised surface was `vice_ping`
3303
- * PLUS `vice_result_continue`, `vice_recycle` and `vice_diagnose`, and two of
3304
- * those three ran the fork's HTTP transport against a port speaking the binary
3305
- * monitor. `handleDiagnose()` reaches ensureViceSession() /
3306
- * gatherCheckpointTrapEvidence() / gatherBracketEvidence(); `handleRecycle()`
3307
- * reaches gatherWedgeEvidence(). Both go through call()/forwardToVice(). That
3308
- * is a direct D-09 violation ("the stock path must never fall through to the
3309
- * fork's HTTP forward"), and `vice_diagnose` is the wedge-triage skill's
3310
- * documented opening move -- so its output on stock was HTTP failure text
3311
- * dressed as emulator diagnosis. The pre-existing structural test could not
3312
- * catch it: it only checked that no code LINE pairs the string "stock" with
3313
- * `forwardToVice`, which this arrangement satisfied while still reaching that
3314
- * transport.
3315
- *
3316
- * On the stock backend every tool registered through here is answered by
3317
- * dispatchStock -- which either has a table entry for the name or REFUSES BY
3318
- * NAME. There is no third path and no fall-through, which is D-09's whole
3319
- * point.
3320
- *
3321
- * WHAT NOT TO DO: never register a tool whose runner can reach `call()` /
3322
- * `forwardToVice()` / `ensureViceSession()` without going through this
3323
- * function. The one legitimate exception is a runner that touches no transport
3324
- * at all (`vice_result_continue`, which only reads this proxy's own
3325
- * CONTINUATION_STORE) -- and that exception is asserted, by name, in
3326
- * stock-dispatch.test.ts's structural section rather than left to judgement.
3327
- */
3328
- function buildBackendAwareTool(def: ToolDefinition, forkRun: (args: Record<string, unknown>) => Promise<ToolCallResult>) {
3329
- return ACTIVE_BACKEND.backend === "fork"
3330
- ? buildViceTool(def, forkRun)
3331
- : buildViceTool(def, (args) =>
3332
- stockDispatch.dispatchStock(def.name, args, {
3333
- ensureLease: ensureBrokerLease,
3334
- resolvedBinaryPath: ACTIVE_BACKEND.binPath,
3335
- resolvedBinaryPathIsResolved: ACTIVE_BACKEND.binPathResolved,
3336
- }),
3337
- );
3338
- }
3339
-
1552
+ // The per-backend registration seam this section used to describe
1553
+ // (D-09, CR-07) is deleted: every tool this file
1554
+ // registers now dispatches through stockDispatch.dispatchStock()
1555
+ // unconditionally, which either has a table entry for the name or REFUSES
1556
+ // BY NAME. There is no third path and no fall-through -- D-09's whole
1557
+ // point, true by construction now rather than by a runtime backend check.
3340
1558
  const tools: Record<string, ReturnType<typeof buildViceTool>> = {};
3341
1559
  // Read ONCE and reused below for both the manifest loop and the two
3342
1560
  // synthetic registrations' own resolveAdvertisedToolDefinition() calls --
3343
1561
  // never re-read per registration (WR-07, plan 07-16).
3344
1562
  const manifestTools = readManifestTools();
3345
1563
  for (const def of manifestTools) {
3346
- if (DENY_LIST.includes(def.name)) continue;
3347
- tools[def.name] = buildBackendAwareTool(def, (args) => forwardToVice(def.name, args));
1564
+ tools[def.name] = buildViceTool(def, (args) => dispatchStockFor(def.name, args));
3348
1565
  }
3349
1566
  // Backend-INDEPENDENT by construction: handleResultContinue() is served
3350
1567
  // entirely from this proxy's own CONTINUATION_STORE and opens no socket of any
3351
1568
  // kind, so it is correct on either backend and is deliberately NOT routed
3352
1569
  // through dispatchStock (which would refuse the continuation mechanism itself).
3353
1570
  tools[RESULT_CONTINUE_TOOL.name] = buildViceTool(RESULT_CONTINUE_TOOL, (args) => Promise.resolve(handleResultContinue(args)));
3354
- // Backend-AWARE (CR-07): both of these gather evidence over the fork's HTTP
3355
- // transport, so on stock they are refused by name rather than advertised and
3356
- // then failed at the wire. WR-07 (plan 07-16): the ADVERTISED definition is
3357
- // now also backend-aware -- resolveAdvertisedToolDefinition() picks the
3358
- // corrected stock manifest entry on stock (falling back to the synthetic
3359
- // definition when the manifest has none) and always returns the synthetic
3360
- // definition unchanged on the fork, so RECYCLE_TOOL/DIAGNOSE_TOOL's literal
3361
- // fork-worded text below no longer overwrites the stock manifest's own
3362
- // entry in `tools/list`.
3363
- tools[RECYCLE_TOOL.name] = buildBackendAwareTool(stockDispatch.resolveAdvertisedToolDefinition(RECYCLE_TOOL, ACTIVE_BACKEND.backend, manifestTools), (args) => handleRecycle(args));
3364
- tools[DIAGNOSE_TOOL.name] = buildBackendAwareTool(stockDispatch.resolveAdvertisedToolDefinition(DIAGNOSE_TOOL, ACTIVE_BACKEND.backend, manifestTools), (args) => handleDiagnose(args));
1571
+ // vice_recycle/vice_diagnose keep their own dedicated handlers
1572
+ // (handleRecycle()/handleDiagnose(), declared above) rather than going
1573
+ // through the manifest loop's own inline dispatchStock() call -- both
1574
+ // handlers delegate to dispatchStock() themselves now, so the observable
1575
+ // behaviour is identical either way, but the named handlers stay the
1576
+ // registration point so they remain independently locatable and testable.
1577
+ // WR-07 (plan 07-16): resolveAdvertisedToolDefinition() picks the corrected
1578
+ // stock manifest entry when one exists, falling back to the synthetic
1579
+ // RECYCLE_TOOL/DIAGNOSE_TOOL definition otherwise, so the advertised
1580
+ // tools/list entry stays correct even if the manifest is ever missing or
1581
+ // malformed.
1582
+ tools[RECYCLE_TOOL.name] = buildViceTool(stockDispatch.resolveAdvertisedToolDefinition(RECYCLE_TOOL, manifestTools), (args) => handleRecycle(args));
1583
+ tools[DIAGNOSE_TOOL.name] = buildViceTool(stockDispatch.resolveAdvertisedToolDefinition(DIAGNOSE_TOOL, manifestTools), (args) => handleDiagnose(args));
3365
1584
  // Backend-INDEPENDENT by construction (plan 29-01): the anno_* family never
3366
1585
  // touches VICE at all -- it reaches a PROXY-LOCAL SQLite annotation store
3367
1586
  // this repo owns, opened and closed inside the runner itself, so there is no
3368
- // fork/stock distinction to make and buildBackendAwareTool() would be flatly
3369
- // wrong here (there is nothing for it to dispatch to on either backend). The
3370
- // family is in NEITHER tools-manifest.json NOR tools-manifest.stock.json:
3371
- // both are regenerated by refresh-manifest.ts from a live HOST VICE server's
3372
- // own tools/list, and a local store file is never that host -- a hand-added
3373
- // entry in either manifest would be silently wiped on the next refresh.
3374
- // Registered here via buildViceTool() directly (the SAME exception
3375
- // RESULT_CONTINUE_TOOL above is), so no anno_* runner can ever reach
3376
- // forwardToVice(), call(), or ensureViceSession() -- CLAUDE.md's "derived
3377
- // tools must be intercepted before forwardToVice()" constraint is satisfied
3378
- // by construction for this family, not by an interception, because the
3379
- // runner is never wired to forwardToVice() in the first place.
1587
+ // fork/stock distinction to make. The family is in NEITHER
1588
+ // tools-manifest.json NOR tools-manifest.stock.json: both are regenerated by
1589
+ // refresh-manifest.ts from a live HOST VICE server's own tools/list, and a
1590
+ // local store file is never that host -- a hand-added entry in either
1591
+ // manifest would be silently wiped on the next refresh.
1592
+ // Registered here via buildViceTool() directly (the SAME pattern
1593
+ // RESULT_CONTINUE_TOOL above uses), so no anno_* runner ever reaches
1594
+ // stockDispatch: there is no generic-dispatch surface left anywhere in this
1595
+ // file for a derived tool's runner to slip behind, so this exemption cannot
1596
+ // be violated by omission the way it could when a fork-only forwarding path
1597
+ // still existed.
3380
1598
  // Deliberately NOT named `def` (the manifest loop's own loop variable,
3381
1599
  // above): `stock-dispatch.test.ts`'s `proxyToolRegistrations()` regex-scans
3382
1600
  // this file's own `tools[...] = ...;` lines and keys each one by its raw
3383
1601
  // captured text, so an identically-named loop variable here would make this
3384
1602
  // registration textually indistinguishable from the manifest loop's -- a
3385
- // distinct name (`annoDef`) keeps the anno_* family's own exemption from
3386
- // `buildBackendAwareTool()` from ever being confused with, or accidentally
3387
- // widened to cover, the manifest loop's registration.
1603
+ // distinct name (`annoDef`) keeps the anno_* family's own registration from
1604
+ // ever being confused with, or accidentally merged into, the manifest
1605
+ // loop's.
3388
1606
  for (const annoDef of ANNO_TOOL_DEFINITIONS) {
3389
1607
  tools[annoDef.name] = buildViceTool(annoDef, (args) => runAnnoTool(annoDef.name, args));
3390
1608
  }
@@ -3399,61 +1617,20 @@ await server.startStdio();
3399
1617
  // dispatch always forces isError:false on success and prepends "Error: " on
3400
1618
  // a thrown failure (read directly from @mastra/mcp's compiled source this
3401
1619
  // session, not its docs), which matches neither this file's own
3402
- // {content, isError} contract nor the deny-list's exact refusal wording a
1620
+ // {content, isError} contract nor a capability refusal's exact wording a
3403
1621
  // pre-existing test pins verbatim -- so tools/call is answered entirely by
3404
1622
  // this override, never by MCPServer's own handler. tools/list is NOT
3405
1623
  // overridden -- MCPServer's own ListToolsRequestSchema handler answers it,
3406
1624
  // the one piece of genuine library value this swap adopts.
3407
1625
  server.getServer().setRequestHandler(CallToolRequestSchema, async (request) => {
3408
1626
  const name = request.params.name;
3409
- // Layer 1 (unchanged mechanism, now here instead of the retired
3410
- // handleToolsCall()): call-time deny-list refusal, before any tool lookup
3411
- // and before any network attempt -- independent from `tools`'s own
3412
- // construction-time absence of vice_disk_list (layer 2, the
3413
- // discovery-time enforcement tools/list reads from). Removing either
3414
- // layer leaves the other standing.
3415
- if (DENY_LIST.includes(name)) {
3416
- return {
3417
- content: [{ type: "text", text: denyListRefusalMessage(name) }],
3418
- isError: true,
3419
- };
3420
- }
3421
- // CLOSED BY 01.4-01 (tasks 1+2), closing Phase 01.4 criterion 3's
3422
- // already-recorded open breach concern. This check inspects only the
3423
- // OUTER `name` -- the literal MCP tool being called -- and always has;
3424
- // that outer-name-only shape is unchanged by this fix and is NOT itself
3425
- // the hazard. The hazard was that the manifest also lists the host's own
3426
- // generic-surface meta-tools (`tools_call`/`tools_list`/`initialize`/
3427
- // `notifications_initialized`) as ordinary forwardable tools, and
3428
- // `tools_call` specifically could carry a forbidden name (e.g.
3429
- // `vice_disk_list`) as a NESTED `arguments.name`, bypassing this exact
3430
- // guard by never presenting the forbidden name as the OUTER one. All four
3431
- // meta-tool names are now themselves on DENY_LIST (task 1 added
3432
- // `tools_list`; task 2 added `tools_call`, `initialize` and
3433
- // `notifications_initialized` after confirming, via a repo-wide grep, that
3434
- // none has a sanctioned caller): `tools_call` itself is refused before its
3435
- // own nested argument is ever read, closing the bypass without teaching
3436
- // this guard to parse nested argument shapes -- one array, no new
3437
- // mechanism, exactly 01.4-RESEARCH.md's own Pattern 1 and primary
3438
- // recommendation. The historical bypass-proving test in
3439
- // vice-proxy.test.ts is repointed (not deleted) to assert this closure.
3440
- // Full history in 01.6.3-03-SUMMARY.md and
3441
- // .planning/todos/pending/2026-08-05-generic-surface-deny-list-gap-tools-call-nested-vice-disk-list.md.
3442
1627
  const tool = tools[name];
3443
1628
  if (!tool || !tool.execute) {
3444
- // Layer 3 (BACK-05, plan 08-02): fires ONLY when the ACTIVE backend's
3445
- // trimmed manifest (D-07) never registered this name -- i.e. `tools`
3446
- // has no key for it -- and MUST stay strictly after the DENY_LIST check
3447
- // above: those four meta-tool names are a confused-deputy bypass hazard
3448
- // (01.4-01), not a capability gap, and must never be reachable here.
3449
- // This lookup renders undefined for a genuinely unknown name (or a
3450
- // same-backend miss), so a real typo still falls through to the generic
3451
- // message below unchanged. capability-registry.ts is the ONE place to
3452
- // edit this data -- never hand-add a per-tool special case here.
3453
- const capabilityRefusal = capabilityRefusalMessage(name, ACTIVE_BACKEND.backend);
3454
- if (capabilityRefusal !== undefined) {
3455
- return { content: [{ type: "text", text: capabilityRefusal }], isError: true };
3456
- }
1629
+ // Fires ONLY when the stock manifest (D-07) never registered this name
1630
+ // -- i.e. `tools` has no key for it. There is one backend now, so an
1631
+ // unrecognised name is simply unknown: no per-capability refusal lookup
1632
+ // remains to distinguish "this tool exists on some other backend" from
1633
+ // "this is a typo" -- there is nothing else it could be.
3457
1634
  return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
3458
1635
  }
3459
1636
  try {
@@ -3466,24 +1643,42 @@ server.getServer().setRequestHandler(CallToolRequestSchema, async (request) => {
3466
1643
  isError: true,
3467
1644
  };
3468
1645
  }
1646
+ // Restores wrapPossiblyChunked()'s only call site. buildViceTool() stamps
1647
+ // OUTPUT_CHAR_CAP onto EVERY tool's `_meta` unconditionally, so the
1648
+ // ceiling has to be honoured for every tool -- and this override is the
1649
+ // one place all four registration families (the manifest loop,
1650
+ // vice_recycle/vice_diagnose, the anno_* loop, and vice_result_continue
1651
+ // itself) converge on a single result before it reaches the wire. A
1652
+ // previous edit deleted this function's only caller and left the
1653
+ // function itself in place: for the whole life of one release a
1654
+ // registered continuation tool could only ever refuse an unknown token,
1655
+ // and an oversized result -- MEASURED at 23,290 characters under a
1656
+ // 200-character advertised cap -- crossed the wire whole, unchunked.
1657
+ // Only a single-item text success is a candidate for the split: an
1658
+ // `isError: true` result carries a refusal or a diagnostic, never a
1659
+ // payload, and a result already carrying a marker item (this tool's own
1660
+ // continuation replies, or any future multi-item producer) must never be
1661
+ // wrapped a second time -- this one condition keeps both out without
1662
+ // naming either by name.
1663
+ if (raw.isError === false && raw.content.length === 1 && raw.content[0].type === "text" && typeof raw.content[0].text === "string") {
1664
+ return toolCallResultToWire(wrapPossiblyChunked(raw.content[0].text));
1665
+ }
3469
1666
  return toolCallResultToWire(raw);
3470
1667
  } catch (e) {
3471
1668
  // The never-throw discipline this file already lives by (matching the
3472
1669
  // retired handleToolsCall()'s own "NEVER rethrow past this point"
3473
- // comment) -- forwardToVice() and the synthetic-tool handlers should
3474
- // never actually throw in normal operation, but this override must not
3475
- // depend on that being true.
1670
+ // comment) -- every registered tool's own runner should never actually
1671
+ // throw in normal operation, but this override must not depend on that
1672
+ // being true.
3476
1673
  return { content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }], isError: true };
3477
1674
  }
3478
1675
  });
3479
1676
 
3480
- // Log-line correction (plan 02-10): the fork arm is byte-identical to every
3481
- // prior plan. The stock arm cannot yet name a real instance/port -- no
1677
+ // Log-line (plan 02-10, collapsed to the stock-only message once the fork
1678
+ // arm was deleted): the stock arm cannot yet name a real instance/port -- no
3482
1679
  // acquisition has happened at process startup, only lazily on the first
3483
1680
  // tools/call -- so it names the backend and the binary-monitor target
3484
1681
  // instead of a coordinate pair that does not exist yet.
3485
1682
  console.error(
3486
- ACTIVE_BACKEND.backend === "fork"
3487
- ? `vice-proxy: ready, forwarding to ${activeInstance().url} (port ${activeInstance().port})`
3488
- : `vice-proxy: ready, stock backend active -- dispatching to a broker-claimed binary-monitor instance (resolved binary: ${ACTIVE_BACKEND.binPath})`,
1683
+ `vice-proxy: ready, stock backend active -- dispatching to a broker-claimed binary-monitor instance (resolved binary: ${RESOLVED_BINARY.binPath})`,
3489
1684
  );