@henols/vice-mcp 0.2.1 → 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 (108) hide show
  1. package/README.md +4 -3
  2. package/THIRD-PARTY-NOTICES.md +423 -25
  3. package/{r2000-acme-ident.ts → anno-acme-ident.ts} +13 -13
  4. package/anno-bank.ts +171 -0
  5. package/anno-cli.ts +3040 -0
  6. package/{r2000-confidence.ts → anno-confidence.ts} +22 -22
  7. package/anno-coverage.ts +2465 -0
  8. package/anno-derive.ts +590 -0
  9. package/anno-details.ts +169 -0
  10. package/anno-enum-gen.ts +919 -0
  11. package/anno-export-asm.ts +2396 -0
  12. package/anno-graphics.ts +338 -0
  13. package/anno-hazard-report.ts +1367 -0
  14. package/anno-import.ts +495 -0
  15. package/anno-index.ts +150 -0
  16. package/anno-join.ts +480 -0
  17. package/{r2000-memmap-render.ts → anno-memmap-render.ts} +236 -95
  18. package/anno-provenance-ledger.ts +472 -0
  19. package/{r2000-regbits-gen.ts → anno-regbits-gen.ts} +20 -15
  20. package/{r2000-regbits.json → anno-regbits.json} +2 -2
  21. package/anno-register.ts +399 -0
  22. package/anno-store-export.ts +661 -0
  23. package/anno-store.ts +4002 -0
  24. package/anno-symbols.ts +266 -0
  25. package/anno-tools.ts +3264 -0
  26. package/anno-types.ts +1909 -0
  27. package/backend-detect.mts +124 -312
  28. package/block-class.ts +201 -0
  29. package/build.ts +4 -2
  30. package/capture-predicate.ts +597 -0
  31. package/channel-lock.ts +349 -0
  32. package/disasm-decoder.ts +14 -14
  33. package/disasm-opcodes.ts +4 -4
  34. package/disasm-renderer.ts +2 -2
  35. package/evid-ingest.ts +217 -0
  36. package/evid-reconcile.ts +316 -0
  37. package/host-tool-client.ts +430 -0
  38. package/hostpath.ts +1 -1
  39. package/incident-record.ts +23 -12
  40. package/install-resources.ts +30 -14
  41. package/memmap-lookup.ts +285 -0
  42. package/package.json +48 -23
  43. package/prg-image.ts +118 -0
  44. package/repo-root.ts +107 -8
  45. package/resources/backend-detect.mjs +98 -236
  46. package/resources/broker-control.mjs +189 -16
  47. package/resources/broker-epoch.mjs +1 -1
  48. package/resources/broker-kill.mjs +8 -2
  49. package/resources/broker-launch.mjs +373 -214
  50. package/resources/broker-state.mjs +64 -18
  51. package/resources/container-guard.mjs +1 -1
  52. package/resources/ghidra-project.mjs +790 -0
  53. package/resources/host-tool.mjs +2561 -0
  54. package/resources/vice-broker.mjs +330 -184
  55. package/resources/vice-launcher.sh +130 -12
  56. package/stock-address.ts +6 -6
  57. package/stock-cia.ts +2 -2
  58. package/stock-condition.ts +8 -8
  59. package/stock-connect.ts +10 -6
  60. package/stock-derived.ts +29 -37
  61. package/stock-diagnose.ts +200 -36
  62. package/stock-dispatch.ts +200 -68
  63. package/stock-execution.ts +5 -3
  64. package/stock-handler.ts +1 -1
  65. package/stock-input.ts +9 -9
  66. package/stock-machine.ts +17 -6
  67. package/stock-paths.ts +18 -14
  68. package/stock-petscii.ts +1 -1
  69. package/stock-protocol.ts +17 -12
  70. package/stock-recycle.ts +83 -2
  71. package/stock-registers.ts +54 -29
  72. package/stock-reproducible-run.ts +811 -0
  73. package/stock-run-until.ts +100 -1
  74. package/stock-sprites.ts +3 -3
  75. package/stock-symbols.ts +13 -13
  76. package/stock-timing.ts +2 -2
  77. package/stock-vicii.ts +1 -1
  78. package/stop-oracle.ts +167 -0
  79. package/text-capability-probe.ts +660 -0
  80. package/text-connect.ts +157 -0
  81. package/text-protocol.ts +810 -0
  82. package/text-tools.ts +778 -0
  83. package/textmon-backtrace.ts +385 -0
  84. package/textmon-cpuhistory.ts +335 -0
  85. package/textmon-memmap.ts +494 -0
  86. package/textmon-profile.ts +458 -0
  87. package/textmon-registers.ts +748 -0
  88. package/tools-manifest.stock.json +864 -3
  89. package/version.ts +1 -1
  90. package/vice-broker-client.ts +189 -42
  91. package/vice-errors.ts +268 -0
  92. package/vice-proxy.ts +392 -2175
  93. package/vsf-slice.ts +640 -0
  94. package/capability-registry.ts +0 -388
  95. package/r2000-cli.ts +0 -1103
  96. package/r2000-d64.ts +0 -310
  97. package/r2000-enum-gen.ts +0 -574
  98. package/r2000-launch.ts +0 -357
  99. package/r2000-mcp-client.ts +0 -596
  100. package/r2000-project.ts +0 -190
  101. package/r2000-symbols.ts +0 -388
  102. package/r2000-tools.ts +0 -914
  103. package/r2000-verify.ts +0 -184
  104. package/refresh-manifest.ts +0 -124
  105. package/tools-manifest.json +0 -1223
  106. package/vice-probe.ts +0 -278
  107. package/vice-sync.ts +0 -336
  108. package/vice.ts +0 -772
package/stock-dispatch.ts CHANGED
@@ -24,10 +24,13 @@
24
24
  // one place a tools/call for the stock backend is routed from.
25
25
  // - Never acquire a broker lease here (Task 2's own ensureStockSession()
26
26
  // header comment explains this prohibition fully).
27
+ // - Never acquire channel-lock.ts's mutex anywhere but inside
28
+ // withChannelLockHeld() (plan 41-02, CHAN-04) -- that is the ONE acquire
29
+ // site on the binary side, and it wraps the delegated HANDLER call only,
30
+ // never the session-acquisition preamble above it.
27
31
  import { resolve, join } from "node:path";
28
32
 
29
- import type { ViceBackend } from "./backend-detect.mts";
30
- import type { ToolInfo } from "./vice.ts";
33
+ import type { ToolInfo } from "./vice-errors.ts";
31
34
  import { type HeldLease } from "./vice-broker-client.ts";
32
35
  import { stockConnect, stockDisconnect, stockReconnect, type StockConnectSession, type StockConnectDeps } from "./stock-connect.ts";
33
36
  import {
@@ -42,6 +45,7 @@ import {
42
45
  } from "./stock-handler.ts";
43
46
  import { attachRunStateTracker } from "./stock-runstate.ts";
44
47
  import { STOCK_DERIVED_TOOLS, type DerivedPureHandler } from "./stock-derived.ts";
48
+ import { acquireChannelLock, ChannelLockTimeoutError } from "./channel-lock.ts";
45
49
 
46
50
  // The six family modules (plans 03-06 through 03-11) -- each exports its
47
51
  // tools as StockSessionHandler-shaped values; this file (D-09) is the ONE
@@ -71,6 +75,7 @@ import { handleCyclesStopwatch, forgetTimingForOtherTargets } from "./stock-timi
71
75
  import { handleRunUntil } from "./stock-run-until.ts";
72
76
  import { handleDiagnoseStock } from "./stock-diagnose.ts";
73
77
  import { handleRecycleStock } from "./stock-recycle.ts";
78
+ import { handleDeviceConsole, handleWarpSet, handleMemmapShow, handleMemmapZap, handleCpuHistory, handleProfileFlat, handleBacktrace, handleIoRegisters } from "./text-tools.ts";
74
79
 
75
80
  // Re-exported so Phase 2's existing import surface (and its 921-line test
76
81
  // file) keeps working unchanged -- these four names used to be DEFINED
@@ -85,21 +90,26 @@ export type { StockToolResult, StockOkResult, StockErrorResult };
85
90
  // ---------------------------------------------------------------------------
86
91
 
87
92
  /**
88
- * Resolves which manifest file backs a given backend's advertised tool
93
+ * Resolves the one manifest file backing the stock backend's advertised tool
89
94
  * surface, following the EXACT override precedence vice-proxy.ts's own
90
95
  * manifestPath() already establishes: an explicit VICE_TOOLS_MANIFEST value
91
96
  * (passed in as `envOverride`, never read from process.env directly here --
92
- * this function stays a pure, injectable seam) wins for either backend,
93
- * unchanged; otherwise the backend picks its own committed default file
94
- * beside `hereDir`. `envOverride` is deliberately a plain parameter, not a
95
- * process.env read, so this function has no hidden global dependency and a
96
- * test can drive every combination without mutating the real environment.
97
+ * this function stays a pure, injectable seam) wins, unchanged; otherwise the
98
+ * committed default file beside `hereDir` is used. `envOverride` is
99
+ * deliberately a plain parameter, not a process.env read, so this function
100
+ * has no hidden global dependency and a test can drive both cases without
101
+ * mutating the real environment.
102
+ *
103
+ * FORKRM-05 (plan 52-07): this used to take a `backend` parameter selecting
104
+ * between two committed manifest files -- there is one manifest now, so the
105
+ * parameter is gone rather than pinned to a literal no caller could ever
106
+ * vary.
97
107
  */
98
- export function manifestPathForBackend(backend: ViceBackend, hereDir: string, envOverride: string | undefined): string {
108
+ export function manifestPathForBackend(hereDir: string, envOverride: string | undefined): string {
99
109
  if (envOverride) {
100
110
  return resolve(envOverride);
101
111
  }
102
- return backend === "stock" ? join(hereDir, "tools-manifest.stock.json") : join(hereDir, "tools-manifest.json");
112
+ return join(hereDir, "tools-manifest.stock.json");
103
113
  }
104
114
 
105
115
  /**
@@ -107,44 +117,38 @@ export function manifestPathForBackend(backend: ViceBackend, hereDir: string, en
107
117
  * is a name-keyed record: whichever assignment to a given key runs LAST
108
118
  * wins, and vice-proxy.ts's own registration order used to assign
109
119
  * RECYCLE_TOOL/DIAGNOSE_TOOL's literal (fork-worded) definitions
110
- * UNCONDITIONALLY, on both backends, straight after the backend-aware
111
- * manifest loop had already populated the same keys correctly. So on the
112
- * stock backend, `tools/list` served the fork's five-verdict vocabulary --
113
- * including `stale_read_path`, which stock cannot produce (D-03) -- and
114
- * omitted `monitor_held_elsewhere`, which stock can, even though the
115
- * corrected stock manifest entry sat right there in
120
+ * UNCONDITIONALLY, straight after the manifest loop had already populated
121
+ * the same keys correctly. `tools/list` served the fork's five-verdict
122
+ * vocabulary -- including `stale_read_path`, which stock cannot produce
123
+ * (D-03) -- and omitted `monitor_held_elsewhere`, which stock can, even
124
+ * though the corrected stock manifest entry sat right there in
116
125
  * tools-manifest.stock.json, unread. This function is the ONE place that
117
126
  * decision is now made, so the manifest loop's own per-tool selection and
118
127
  * the two synthetic tools' registration agree.
119
128
  *
120
- * Behaviour:
121
- * - `backend === "fork"`: always returns `syntheticDef` unchanged, no
122
- * matter what `manifestTools` contains. The fork's advertised surface
123
- * is frozen at v0.1.x and this function must never be able to alter it.
124
- * - `backend === "stock"`: returns the `manifestTools` entry whose `name`
125
- * equals `syntheticDef.name`, if one exists. Falls back to
126
- * `syntheticDef` when no match exists -- `readManifestTools()`'s own
127
- * malformed/unreadable-manifest fallbacks answer `[]`, and in that case
128
- * the proxy must still advertise a WORKING tool rather than none at all
129
- * (T-07-16-02).
130
- * - NEVER merges fields from the two definitions. Picking one whole
131
- * definition keeps `description`, `inputSchema` and `outputSchema`
132
- * internally consistent; a field-by-field merge could pair a fork
133
- * description with a stock `outputSchema`, or the reverse.
129
+ * Behaviour: returns the `manifestTools` entry whose `name` equals
130
+ * `syntheticDef.name`, if one exists. Falls back to `syntheticDef` when no
131
+ * match exists -- `readManifestTools()`'s own malformed/unreadable-manifest
132
+ * fallbacks answer `[]`, and in that case the proxy must still advertise a
133
+ * WORKING tool rather than none at all (T-07-16-02). NEVER merges fields
134
+ * from the two definitions -- picking one whole definition keeps
135
+ * `description`, `inputSchema` and `outputSchema` internally consistent; a
136
+ * field-by-field merge could pair one definition's description with the
137
+ * other's `outputSchema`.
134
138
  *
135
139
  * Declared as a `function`, not a `const` arrow, per this module tree's own
136
140
  * standing rule: stock-dispatch.ts <-> stock-diagnose.ts <-> stock-recycle.ts
137
141
  * form a runtime import cycle, and the phase already reproduced a live
138
142
  * `ReferenceError` from a `const` handler export sitting in that cycle.
143
+ *
144
+ * FORKRM-05 (plan 52-07): this used to take a `backend` parameter and
145
+ * return `syntheticDef` unchanged when it was `"fork"` -- vice-proxy.ts's
146
+ * two call sites always passed the literal `"stock"`, so that branch was
147
+ * dead from the moment plan 52-06 collapsed backend detection. Removed
148
+ * rather than left as an unreachable branch a reader could mistake for live
149
+ * code.
139
150
  */
140
- export function resolveAdvertisedToolDefinition(
141
- syntheticDef: ToolInfo,
142
- backend: ViceBackend,
143
- manifestTools: ToolInfo[],
144
- ): ToolInfo {
145
- if (backend === "fork") {
146
- return syntheticDef;
147
- }
151
+ export function resolveAdvertisedToolDefinition(syntheticDef: ToolInfo, manifestTools: ToolInfo[]): ToolInfo {
148
152
  const manifestEntry = manifestTools.find((t) => t.name === syntheticDef.name);
149
153
  return manifestEntry ?? syntheticDef;
150
154
  }
@@ -185,8 +189,8 @@ export type LeaseProvider = () => Promise<{ ok: true; lease: HeldLease | null }
185
189
  * never implies resolution it did not achieve. It is a plain string handed down from vice-proxy.ts's
186
190
  * OWN single, module-scope call to `resolvedBackend()` (see that file's own
187
191
  * "resolve the active backend once" discipline) -- this module must never
188
- * call `resolvedBackend()`/`probeBackend()` itself, per backend-detect.mts's
189
- * own "do not call this per tool or per call" prohibition. Omitted entirely
192
+ * call `resolvedBackend()` itself, per backend-detect.mts's own "do not
193
+ * call this per tool or per call" prohibition. Omitted entirely
190
194
  * (never expected in production) falls back to an empty string rather than
191
195
  * throwing.
192
196
  */
@@ -201,6 +205,12 @@ export interface StockDispatchDeps {
201
205
  * Omitted defaults to `false` -- the honest answer when nothing said
202
206
  * otherwise. */
203
207
  resolvedBinaryPathIsResolved?: boolean;
208
+ /** Test-only override of channel-lock.ts's acquire bound for THIS call's
209
+ * withChannelLockHeld() wrapping. Production call sites never set this --
210
+ * they always take channel-lock.ts's own CHANNEL_LOCK_ACQUIRE_TIMEOUT_MS
211
+ * default. Exists so a test can observe a ChannelLockTimeoutError (and its
212
+ * refusal text) without waiting out the real ~630-second default. */
213
+ channelLockTimeoutMs?: number;
204
214
  }
205
215
 
206
216
  export type EnsureStockSessionOutcome = { ok: true; session: StockConnectSession } | { ok: false; message: string };
@@ -421,7 +431,7 @@ export async function ensureStockSession(deps: StockDispatchDeps): Promise<Ensur
421
431
  * come from the lease vice-proxy.ts built (see HeldLease's own field comments
422
432
  * for why they are two DIFFERENT directories), and `binPath` is the same
423
433
  * already-settled `resolvedBinaryPath` vice_ping reports -- this module must
424
- * never call resolvedBackend()/probeBackend() itself.
434
+ * never call resolvedBackend() itself.
425
435
  *
426
436
  * An empty string is treated as ABSENT rather than passed through: the two
427
437
  * consumers both branch on truthiness, and passing "" would key a capability
@@ -461,6 +471,53 @@ export { stockDisconnect };
461
471
  * prohibits). */
462
472
  export type StockHandler = (args: Record<string, unknown>, deps: StockDispatchDeps) => Promise<StockToolResult>;
463
473
 
474
+ /**
475
+ * withChannelLockHeld -- acquires channel-lock.ts's mutex for
476
+ * `channel: "binary"` around `fn`, releasing in a `finally` so a throwing
477
+ * `fn` still releases (D-05). This is the ONE acquire site on the binary
478
+ * side; withStockSession() and withDerivedTool()'s `needsSession: true`
479
+ * branch both call it, and neither may acquire the lock any other way.
480
+ *
481
+ * This is what makes the lock's critical section span a whole LOGICAL
482
+ * operation, not a single wire command: `vice_run_until`'s wait
483
+ * (stock-run-until.ts's `waitForCheckpointHit()`) and the reproducible-run
484
+ * path's wait (stock-reproducible-run.ts's `waitForReproducibleStop()`,
485
+ * reached through `runReproducible()`) both run INSIDE the wrapped `fn`, so
486
+ * the lock stays held across resume -> wait -> observe without either wait
487
+ * path being re-cut.
488
+ *
489
+ * FORBIDDEN ALTERNATIVE, named here because it is the obvious-looking wrong
490
+ * design: acquiring and releasing this lock around each individual wire
491
+ * command instead of around the whole handler call. A per-wire-command lock
492
+ * preserves the resume count while destroying what the count protects -- a
493
+ * foreign command (e.g. a text-channel command) can land in the gap between
494
+ * "resume sent" and "checkpoint observed", halting a machine that was
495
+ * supposed to be running toward the checkpoint, so the checkpoint never
496
+ * fires even though no protocol invariant was technically violated per
497
+ * command (41-RESEARCH.md Pitfall 6).
498
+ *
499
+ * A `ChannelLockTimeoutError` is converted into refusal text using the
500
+ * error's OWN message verbatim -- it is already `channelLockRefusalMessage()`'s
501
+ * output -- and NEVER routed through `convertWireError()`, which would
502
+ * re-frame a legitimate ownership statement as a wire fault.
503
+ */
504
+ async function withChannelLockHeld(toolName: string, timeoutMs: number | undefined, fn: () => Promise<StockToolResult>): Promise<StockToolResult> {
505
+ let handle;
506
+ try {
507
+ handle = await acquireChannelLock({ channel: "binary", operation: toolName, timeoutMs });
508
+ } catch (err) {
509
+ if (err instanceof ChannelLockTimeoutError) {
510
+ return isErrorText(err.message);
511
+ }
512
+ throw err;
513
+ }
514
+ try {
515
+ return await fn();
516
+ } finally {
517
+ handle.release();
518
+ }
519
+ }
520
+
464
521
  /**
465
522
  * withStockSession -- THE ONE adapter every STOCK_DISPATCH_TABLE entry goes
466
523
  * through (Task 1, plan 03-12). Before this existed, `viceHandlerPing` was
@@ -488,6 +545,17 @@ export type StockHandler = (args: Record<string, unknown>, deps: StockDispatchDe
488
545
  * Code for the rest of the session (T-3-04) -- a single escaped
489
546
  * exception here would silently end the session's entire tool surface,
490
547
  * not just this one call.
548
+ *
549
+ * Step 3's own try/catch runs INSIDE withChannelLockHeld(toolName, ...)
550
+ * (plan 41-02, CHAN-04): the session-acquisition step above (step 1/2) is
551
+ * NOT covered by the lock -- only the delegated handler call is -- so a
552
+ * broker liveness classification or a handshake failure never queues behind
553
+ * the OTHER channel's halt authority. This is what makes D-05's critical
554
+ * section span a whole logical operation: `vice_run_until`'s and the
555
+ * reproducible-run path's waits run inside their handlers, so the lock is
556
+ * held across resume -> wait -> observe without either wait path being
557
+ * re-cut (see withChannelLockHeld()'s own header for the forbidden
558
+ * per-wire-command alternative).
491
559
  */
492
560
  export function withStockSession(toolName: string, handler: StockSessionHandler): StockHandler {
493
561
  return async (args, deps) => {
@@ -502,11 +570,13 @@ export function withStockSession(toolName: string, handler: StockSessionHandler)
502
570
  return isErrorText(outcome.message);
503
571
  }
504
572
 
505
- try {
506
- return await handler(args, outcome.session, deps);
507
- } catch (err) {
508
- return convertWireError(toolName, err);
509
- }
573
+ return withChannelLockHeld(toolName, deps.channelLockTimeoutMs, async () => {
574
+ try {
575
+ return await handler(args, outcome.session, deps);
576
+ } catch (err) {
577
+ return convertWireError(toolName, err);
578
+ }
579
+ });
510
580
  };
511
581
  }
512
582
 
@@ -530,7 +600,14 @@ export function withStockSession(toolName: string, handler: StockSessionHandler)
530
600
  * calls ensureStockSession() at all -- not a lighter-weight variant of it
531
601
  * (04-RESEARCH.md Pitfall 3) -- and invokes `handler(args, deps)` inside a
532
602
  * single try/catch converting through convertWireError(), so the
533
- * never-throw boundary still holds.
603
+ * never-throw boundary still holds. This branch also NEVER acquires
604
+ * channel-lock.ts's mutex (plan 41-02, CHAN-04): taking halt authority for a
605
+ * pure client-side computation that never touches the wire would block a
606
+ * REAL halting operation for no reason. `vice_diagnose` is also registered
607
+ * `needsSession: false` and therefore also does not acquire here -- its own
608
+ * handler takes the lock with `tryAcquireChannelLock()` (channel-lock.ts)
609
+ * instead, deliberately, so that diagnosing contention never queues behind
610
+ * the holder it is diagnosing (plan 41-04).
534
611
  *
535
612
  * `needsSession: true` runs the EXACT same three-step preamble
536
613
  * withStockSession() runs, reusing the same imported converters -- never a
@@ -538,7 +615,11 @@ export function withStockSession(toolName: string, handler: StockSessionHandler)
538
615
  * inside its own try/catch -> convertHandshakeError(toolName, err); a
539
616
  * `{ ok: false }` outcome returns outcome.message verbatim through
540
617
  * isErrorText(), never re-worded; otherwise handler(args, outcome.session, deps)
541
- * inside a SECOND try/catch -> convertWireError(toolName, err).
618
+ * inside a SECOND try/catch -> convertWireError(toolName, err), with that
619
+ * second try/catch running inside withChannelLockHeld(toolName, ...) --
620
+ * exactly the same wrapping withStockSession() applies, and for the same
621
+ * reason (see that function's own comment on the forbidden per-wire-command
622
+ * alternative).
542
623
  */
543
624
  export function withDerivedTool(toolName: string, opts: { needsSession: true }, handler: StockSessionHandler): StockHandler;
544
625
  export function withDerivedTool(toolName: string, opts: { needsSession: false }, handler: DerivedPureHandler): StockHandler;
@@ -571,11 +652,13 @@ export function withDerivedTool(
571
652
  return isErrorText(outcome.message);
572
653
  }
573
654
 
574
- try {
575
- return await (handler as StockSessionHandler)(args, outcome.session, deps);
576
- } catch (err) {
577
- return convertWireError(toolName, err);
578
- }
655
+ return withChannelLockHeld(toolName, deps.channelLockTimeoutMs, async () => {
656
+ try {
657
+ return await (handler as StockSessionHandler)(args, outcome.session, deps);
658
+ } catch (err) {
659
+ return convertWireError(toolName, err);
660
+ }
661
+ });
579
662
  };
580
663
  }
581
664
 
@@ -591,7 +674,22 @@ export function withDerivedTool(
591
674
  * comment on why). Built through stockAnswer() so the answer now also
592
675
  * carries `runState` (D-06: every stock answer, and `vice_ping` is a stock
593
676
  * answer) alongside every field that was already there.
677
+ *
678
+ * 2026-08-19 finding (closed Phase 15 plan 15-09): `resolvedBinaryPath` is a
679
+ * ONE-TIME, MCP-server-process-startup `$PATH` probe (see vice-proxy.ts's
680
+ * `ACTIVE_BACKEND` comment) -- it is independent of which binary the broker
681
+ * actually leased for THIS request. `resolvedBinaryPathScope` below is an
682
+ * additive, backward-compatible sibling field (the existing field name and
683
+ * shape are unchanged) that carries that qualification into the answer
684
+ * itself, so a caller reading the response -- not just this source comment --
685
+ * learns not to treat the path as this request's authoritative binary
686
+ * identity.
594
687
  */
688
+ const RESOLVED_BINARY_PATH_SCOPE =
689
+ "one-time MCP-server-process-startup PATH probe; NOT the binary the broker leased for this " +
690
+ "request -- for the authoritative per-instance binary, read the broker's own launch record " +
691
+ "(epoch.json's vice_bin field)";
692
+
595
693
  const handlePing: StockSessionHandler = async (_args, session, deps) => {
596
694
  return stockAnswer(session.client, {
597
695
  status: "ok",
@@ -602,6 +700,9 @@ const handlePing: StockSessionHandler = async (_args, session, deps) => {
602
700
  // it, an agent reading `"x64sc"` cannot tell "this is where the binary is"
603
701
  // from "this is what we were told to look for, and we could not find it".
604
702
  resolvedBinaryPathIsResolved: deps.resolvedBinaryPathIsResolved ?? false,
703
+ // 2026-08-19 finding: names what resolvedBinaryPath actually is (a
704
+ // startup-time probe) and where to look instead for a per-request answer.
705
+ resolvedBinaryPathScope: RESOLVED_BINARY_PATH_SCOPE,
605
706
  capabilities: session.capabilities,
606
707
  });
607
708
  };
@@ -611,11 +712,11 @@ const handlePing: StockSessionHandler = async (_args, session, deps) => {
611
712
  * not an oversight (Task 2, plan 03-12):
612
713
  * - `vice_checkpoint_set_ignore_count` (D-15)
613
714
  * - `vice_snapshot_list` (D-16 -- deleted from both manifests)
614
- * - `vice_disk_detach` (D-13 -- Phase 7, via the text monitor)
615
- * - `vice_joystick_tap` (needs a resume plus Phase 7's timing route)
715
+ * - `vice_disk_detach` (CUT from scope 2026-08-17 -- docs/stock-vice-parity.md's dated cut record)
716
+ * - `vice_joystick_tap` (permanently excluded -- stock-input.ts's own header)
616
717
  * - `vice_disk_read_sector` (CUT from scope 2026-08-17 -- no skill calls it; see ROADMAP.md "Cut from scope (v0.2.0, 2026-08-17)" and docs/stock-vice-parity.md item 6)
617
718
  * - `vice_sid_get_state` and the low-level keyboard family (hard losses)
618
- * - `vice_machine_config_get` / `vice_machine_config_set` (Phase 6)
719
+ * - `vice_machine_config_get` / `vice_machine_config_set` (CUT from scope 2026-08-17 along with the whole of Stock-Only Gains -- docs/stock-vice-parity.md's dated cut record)
619
720
  * `dispatchStock()`'s miss branch already refuses any of these by name,
620
721
  * without reading `deps` -- there is nothing else to add for them here.
621
722
  */
@@ -694,9 +795,9 @@ const STOCK_DISPATCH_TABLE: Record<string, StockHandler> = {
694
795
  vice_run_until: withDerivedTool("vice_run_until", { needsSession: true }, handleRunUntil),
695
796
 
696
797
  // derived (TIME-04) -- the two proxy-local synthetic tools (RECYCLE_TOOL/
697
- // DIAGNOSE_TOOL in vice-proxy.ts), backend-routed to dispatchStock() by
698
- // buildBackendAwareTool() rather than served from the fork's HTTP
699
- // transport. Deliberate asymmetry, documented at this call site (see also
798
+ // DIAGNOSE_TOOL in vice-proxy.ts), registered via buildViceTool() and
799
+ // routed to this table's own dispatchStock() entry point, never to the
800
+ // deleted fork transport. Deliberate asymmetry, documented at this call site (see also
700
801
  // DerivedPureHandler's amended doc comment in stock-derived.ts):
701
802
  // vice_diagnose uses needsSession:false because its own handler acquires
702
803
  // the session itself (inside its own try/catch) so it can convert a
@@ -707,6 +808,30 @@ const STOCK_DISPATCH_TABLE: Record<string, StockHandler> = {
707
808
  // evidence and has no verdict vocabulary of its own to preserve.
708
809
  vice_diagnose: withDerivedTool("vice_diagnose", { needsSession: false }, handleDiagnoseStock),
709
810
  vice_recycle: withDerivedTool("vice_recycle", { needsSession: true }, handleRecycleStock),
811
+
812
+ // text-channel remedy tools (plan 41-06, CHAN-03). needsSession:false,
813
+ // deliberately -- NOT withStockSession()/withDerivedTool(needsSession:
814
+ // true): both of those wrap the whole handler call in
815
+ // withChannelLockHeld("binary", ...), and each handler below takes its OWN
816
+ // channel-lock.ts acquire for `channel: "text"` internally
817
+ // (withTextChannelLock(), text-protocol.ts). Registering through either
818
+ // binary-locking adapter would nest a second acquireChannelLock() call
819
+ // inside the first (channel-lock.ts is one single, non-reentrant mutex
820
+ // across both channels) -- a self-deadlock that only resolves by expiring
821
+ // CHANNEL_LOCK_ACQUIRE_TIMEOUT_MS. See text-tools.ts's own header comment
822
+ // (ADAPTER CHOICE) for the full measurement. Neither handler needs a
823
+ // binary session at all -- both resolve the lease via deps.ensureLease()
824
+ // themselves and dial only through textConnect() -- so needsSession:false
825
+ // is not merely the deadlock-avoiding choice, it is also the structurally
826
+ // correct one, matching vice_diagnose's own precedent above.
827
+ vice_device_console: withDerivedTool("vice_device_console", { needsSession: false }, handleDeviceConsole),
828
+ vice_warp_set: withDerivedTool("vice_warp_set", { needsSession: false }, handleWarpSet),
829
+ vice_memmap_show: withDerivedTool("vice_memmap_show", { needsSession: false }, handleMemmapShow),
830
+ vice_memmap_zap: withDerivedTool("vice_memmap_zap", { needsSession: false }, handleMemmapZap),
831
+ vice_cpu_history: withDerivedTool("vice_cpu_history", { needsSession: false }, handleCpuHistory),
832
+ vice_profile_flat: withDerivedTool("vice_profile_flat", { needsSession: false }, handleProfileFlat),
833
+ vice_backtrace: withDerivedTool("vice_backtrace", { needsSession: false }, handleBacktrace),
834
+ vice_io_registers: withDerivedTool("vice_io_registers", { needsSession: false }, handleIoRegisters),
710
835
  };
711
836
 
712
837
  /** Looks up the table entry for `name` -- `undefined` on a miss, never a
@@ -721,20 +846,27 @@ export function stockHandlerFor(name: string): StockHandler | undefined {
721
846
  /**
722
847
  * The ONE dispatch entry point for the stock backend (D-09). On a hit,
723
848
  * delegates to the table entry, unchanged. On a miss, refuses EXPLICITLY --
724
- * naming the tool, stating the stock backend does not implement it, and
725
- * naming the fork as the backend that does -- WITHOUT reading `deps` at all
726
- * (no lease is ever requested for a tool that does not exist on this
727
- * backend). There is no third branch, and in particular NO fall-through to
728
- * the fork's HTTP-forwarding path anywhere in this file or anything it calls
729
- * -- that is D-09's whole point, grep-gated to zero occurrences of that
730
- * function's name in this file's own code lines.
849
+ * naming the tool and stating there is no dispatch entry for it -- WITHOUT
850
+ * reading `deps` at all (no lease is ever requested for a tool that does not
851
+ * exist on this backend). There is no third branch, and in particular NO
852
+ * fall-through to the fork's HTTP-forwarding path anywhere in this file or
853
+ * anything it calls -- that is D-09's whole point, grep-gated to zero
854
+ * occurrences of that function's name in this file's own code lines.
855
+ *
856
+ * FORKRM-05 (plan 52-07): this used to fall back to the deleted per-backend
857
+ * capability registry's refusal renderer first, naming the fork as the
858
+ * backend that provides the tool -- that renderer, and the fork it named,
859
+ * are both gone. A name reaching this branch is advertised on the stock
860
+ * manifest (so it passed vice-proxy.ts's own lookup) but has no dispatch
861
+ * entry: that is always an internal inconsistency now, never a capability
862
+ * gap with a second backend to point a caller at.
731
863
  */
732
864
  export async function dispatchStock(name: string, args: Record<string, unknown>, deps: StockDispatchDeps): Promise<StockToolResult> {
733
865
  const handler = stockHandlerFor(name);
734
866
  if (!handler) {
735
867
  return isErrorText(
736
- `${name} is not implemented by the stock backend -- the fork backend provides this tool. ` +
737
- `Set VICE_BACKEND=fork to use it there, or wait for a later phase to extend the stock dispatch table.`,
868
+ `${name} is advertised on the stock backend's manifest but has no handler in the stock ` +
869
+ `dispatch table -- this is an internal inconsistency, not a capability gap; please file an issue.`,
738
870
  );
739
871
  }
740
872
  return handler(args, deps);
@@ -224,9 +224,11 @@ export const handleExecutionRun: StockSessionHandler = async (args, session) =>
224
224
  * the derived run state is "unknown" (D-07, via refuseIfUnknown()).
225
225
  *
226
226
  * `stepOver: true`'s runtime semantic (skip a JSR's subroutine as one step)
227
- * is [ASSUMED] -- RESEARCH.md Assumptions Log row A2 -- never probed against
228
- * a real JSR. See `.planning/todos/pending/2026-08-14-probe-phase3-assumed-wire-details.md`
229
- * for the outstanding probe debt. This is NOT claimed as verified here.
227
+ * was live-probed against a real JSR on fork VICE 3.10 and CONFIRMED
228
+ * (RESEARCH.md Assumptions Log row A2, closed by 13-PROBE-RESULTS.md §A2):
229
+ * the post-step PC landed at JSR+3, matching a full subroutine skip, and
230
+ * the result was reproduced identically across two independent live
231
+ * sessions.
230
232
  */
231
233
  export const handleExecutionStep: StockSessionHandler = async (args, session) => {
232
234
  const unexpected = refuseUnexpectedArgs(args, ["count", "stepOver"], "vice_execution_step");
package/stock-handler.ts CHANGED
@@ -36,7 +36,7 @@
36
36
  // time, so it creates no runtime cycle even though stock-dispatch.ts
37
37
  // imports this file at runtime.
38
38
  import { MonitorOwnershipError } from "./vice-broker-client.ts";
39
- import { MachineRestartedError } from "./vice.ts";
39
+ import { MachineRestartedError } from "./vice-errors.ts";
40
40
  import { ErrorCode, StockFramingError, StockProtocolError, StockResponseMismatchError, type ViceMonitorClient } from "./stock-protocol.ts";
41
41
  import { runStateFor } from "./stock-runstate.ts";
42
42
  import type { StockConnectSession } from "./stock-connect.ts";
package/stock-input.ts CHANGED
@@ -25,10 +25,10 @@
25
25
  // resumes explicitly, on its own schedule.
26
26
  // - Never add vice_joystick_tap. A tap needs the machine to RUN for a
27
27
  // measured interval -- an unrequested EXIT (forbidden by D-05) plus a
28
- // frame/cycle measurement that does not exist on stock until Phase 7's
29
- // timing route lands (docs/stock-vice-parity.md section A item 7).
30
- // vice_joystick_set (hold/release/centre) satisfies DIRECT-07's
31
- // joystick half in the meantime.
28
+ // frame/cycle measurement stock's wire protocol has no route for at
29
+ // all (docs/stock-vice-parity.md section A item 7). This exclusion is
30
+ // permanent, not pending: vice_joystick_set (hold/release/centre)
31
+ // satisfies DIRECT-07's joystick half instead.
32
32
  // - Never construct an ok-answer outside stockAnswer(). Every successful
33
33
  // result below is built through it, never a bare
34
34
  // `{ content: [...], isError: false }` literal.
@@ -187,11 +187,11 @@ function isValidDirection(value: string): value is JoystickDirection {
187
187
  * the fork's exact names and documented string-or-array shape.
188
188
  *
189
189
  * vice_joystick_tap is deliberately absent from this module (and the whole
190
- * stock manifest) -- a tap needs the machine to run for a measured
191
- * interval, which is an unrequested EXIT (forbidden by D-05) plus a
192
- * frame/cycle measurement stock does not have until Phase 7's timing route
193
- * lands (docs/stock-vice-parity.md section A item 7). Do not approximate it
194
- * with a sleep; do not implement it here.
190
+ * stock manifest), permanently -- a tap needs the machine to run for a
191
+ * measured interval, which is an unrequested EXIT (forbidden by D-05) plus
192
+ * a frame/cycle measurement stock's wire protocol has no route for at all
193
+ * (docs/stock-vice-parity.md section A item 7). Do not approximate it with
194
+ * a sleep; do not implement it here.
195
195
  */
196
196
  export const handleJoystickSet: StockSessionHandler = async (args, session) => {
197
197
  if (!isPlainObject(args)) {
package/stock-machine.ts CHANGED
@@ -12,17 +12,17 @@
12
12
  // WHAT NOT TO DO:
13
13
  // - Never gate or deny vice_machine_reset's hard mode. CLAUDE.md's
14
14
  // power-cycle warning is about RESOURCE_SET (0x52) writes to
15
- // MachineVideoStandard/VICIIModel/MachinePowerFrequency -- Phase 6
16
- // territory, a DIFFERENT opcode entirely. RESET (0xcc) is a distinct
15
+ // MachineVideoStandard/VICIIModel/MachinePowerFrequency -- the CUT
16
+ // vice_machine_config_get/set pair's resources (docs/stock-vice-parity.md), a DIFFERENT opcode entirely. RESET (0xcc) is a distinct
17
17
  // command, and an agent-requested hard reset via RESET is exactly what
18
18
  // DIRECT-06 asks for. It needs no deny-list (RESEARCH.md Pitfall 1).
19
19
  // - Never look for a per-unit disk-attach route mid-implementation.
20
20
  // AUTOSTART (0xdd) has NO drive-unit field on the wire at all -- this is
21
21
  // a protocol gap, not a code bug you can fix by looking harder
22
22
  // (RESEARCH.md Pitfall 2).
23
- // - Never add a disk-detach handler here. D-13 ships that tool in Phase 7
24
- // through the text monitor -- grep-gated to zero occurrences of its name
25
- // in this file's own acceptance criteria.
23
+ // - Never add a disk-detach handler here. D-13's vice_disk_detach was
24
+ // CUT from scope 2026-08-17 (docs/stock-vice-parity.md) -- grep-gated
25
+ // to zero occurrences of its name in this file's own acceptance criteria.
26
26
  // - Never build a host path outside stock-paths.ts. Every filename this
27
27
  // file sends through the wire goes through that same one wrapper --
28
28
  // grep-gated to zero direct hostPath()/hostPathCandidates() calls here.
@@ -150,7 +150,18 @@ export const handleAutostart: StockSessionHandler = async (args, session) => {
150
150
  * at all, so an agent told "attached to unit 9" when the image landed on
151
151
  * unit 8 would debug the wrong drive. See docs/stock-vice-parity.md's D-14
152
152
  * entry.
153
+ *
154
+ * The returned `approximation` string names BOTH real side effects Phase 13
155
+ * plan 13-03's live A5 probe observed against real fork VICE 3.10: a full
156
+ * machine reset AND a program load, not "attach without disturbing machine
157
+ * state." Exported so the pinning test derives its expectation from this
158
+ * constant rather than re-typing the sentence, so the two cannot drift. See
159
+ * `.planning/phases/13-external-verification/13-PROBE-RESULTS.md` § A5.
153
160
  */
161
+ export const DISK_ATTACH_APPROXIMATION =
162
+ "AUTOSTART (D-14): performs a full machine reset and loads a program from the image; " +
163
+ "unlike vice_autostart it does not issue a final run step, as far as observed.";
164
+
154
165
  export const handleDiskAttach: StockSessionHandler = async (args, session) => {
155
166
  const a = isPlainObject(args) ? args : {};
156
167
 
@@ -182,7 +193,7 @@ export const handleDiskAttach: StockSessionHandler = async (args, session) => {
182
193
  unit: 8,
183
194
  path: containerPath,
184
195
  sentPath,
185
- approximation: "AUTOSTART with the run flag clear (D-14)",
196
+ approximation: DISK_ATTACH_APPROXIMATION,
186
197
  });
187
198
  } catch (err) {
188
199
  return convertWireError("vice_disk_attach", err);
package/stock-paths.ts CHANGED
@@ -17,11 +17,14 @@
17
17
  // side finds this comment.
18
18
  //
19
19
  // WHAT NOT TO DO:
20
- // - Never call rewriteArguments() from a stock handler. It lives INSIDE
21
- // forwardToVice() (vice-proxy.ts, around line 2773) -- the one function
22
- // Phase 2's D-09 says the stock path must never touch -- and its own
23
- // comment inverts on stock: what is correct for the fork's derived tools
24
- // is exactly wrong here.
20
+ // - Never merge this file's emulator-side translation with, or replace it
21
+ // by, a general argument-rewriting pass applied before dispatch. Stock
22
+ // tool calls no longer go through any such pass -- vice-proxy.ts's own
23
+ // fork-only per-call path-rewriter (and the generic forwarding function
24
+ // that ran it) is deleted outright -- and reintroducing one would
25
+ // re-create exactly the inversion this file's header names: what a
26
+ // general rewriter does for a client-side-derived path is precisely
27
+ // wrong for the four emulator-side filenames this file translates.
25
28
  // - Never build a host path with a local heuristic (a hand-rolled prefix
26
29
  // swap, a hardcoded mount guess, anything not routed through
27
30
  // hostpath.ts's own hostPathCandidates()/tryHostPaths()). hostpath.ts is
@@ -35,8 +38,8 @@
35
38
  // through withEmulatorSidePath() should stop and re-read this paragraph.
36
39
  import { dirname, join } from "node:path";
37
40
 
38
- import { ViceError, type ViceErrorOptions } from "./vice.ts";
39
- import { repoRoot } from "./repo-root.ts";
41
+ import { ViceError, type ViceErrorOptions } from "./vice-errors.ts";
42
+ import { repoRoot, toolsDir } from "./repo-root.ts";
40
43
  import { isInsideContainer } from "./container-guard.mts";
41
44
  import { tryHostPaths } from "./hostpath.ts";
42
45
  import { ErrorCode, StockProtocolError } from "./stock-protocol.ts";
@@ -167,21 +170,22 @@ export function sanitizeSnapshotName(name: unknown): string {
167
170
 
168
171
  /**
169
172
  * The container path a snapshot named `name` lives at:
170
- * `<repoRoot>/.vice-snapshots/<name>.vsf`. The directory is inside the
171
- * workspace rather than under `~/.config/vice/` (the fork's own location)
172
- * because only a workspace path is inside hostpath.ts's bind-mount mapping
173
- * -- anything outside it cannot be translated for the host at all -- and
174
- * keeping it inside the workspace makes workspace escape structurally
173
+ * `<toolsDir>/snapshots/<name>.vsf` -- a subdirectory of the single
174
+ * tool-written root `repo-root.ts`'s `toolsDir()` owns (D-33). The directory
175
+ * is inside the workspace rather than under `~/.config/vice/` (the fork's own
176
+ * location) because only a workspace path is inside hostpath.ts's bind-mount
177
+ * mapping -- anything outside it cannot be translated for the host at all --
178
+ * and keeping it inside the workspace makes workspace escape structurally
175
179
  * impossible rather than merely checked (T-3-05).
176
180
  */
177
181
  export function snapshotPathFor(name: string): string {
178
- return join(repoRoot(), ".vice-snapshots", `${sanitizeSnapshotName(name)}.vsf`);
182
+ return join(toolsDir(), "snapshots", `${sanitizeSnapshotName(name)}.vsf`);
179
183
  }
180
184
 
181
185
  /** The sidecar metadata path for the same snapshot: same directory, `.json`
182
186
  * extension, same sanitisation. */
183
187
  export function snapshotMetaPathFor(name: string): string {
184
- return join(repoRoot(), ".vice-snapshots", `${sanitizeSnapshotName(name)}.json`);
188
+ return join(toolsDir(), "snapshots", `${sanitizeSnapshotName(name)}.json`);
185
189
  }
186
190
 
187
191
  // Re-exported so a caller building a directory before translating (Task 3's
package/stock-petscii.ts CHANGED
@@ -32,7 +32,7 @@
32
32
  // bytes (0x40/0x41, 0x5a/0x5b, 0x60/0x61, 0x7a/0x7b) and the control-code
33
33
  // regions do not follow that rule uniformly; each range below is
34
34
  // checked explicitly, not derived from a single arithmetic shortcut.
35
- import { ViceError } from "./vice.ts";
35
+ import { ViceError } from "./vice-errors.ts";
36
36
 
37
37
  /** PETSCII's Return code. Both ASCII LF (`\n`) and CR (`\r`) map here -- this
38
38
  * is what the fork's own "Use \n for Return" tool description promises. */