@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/stock-dispatch.ts CHANGED
@@ -24,11 +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";
31
- import { capabilityRefusalMessage } from "./capability-registry.ts";
33
+ import type { ToolInfo } from "./vice-errors.ts";
32
34
  import { type HeldLease } from "./vice-broker-client.ts";
33
35
  import { stockConnect, stockDisconnect, stockReconnect, type StockConnectSession, type StockConnectDeps } from "./stock-connect.ts";
34
36
  import {
@@ -43,6 +45,7 @@ import {
43
45
  } from "./stock-handler.ts";
44
46
  import { attachRunStateTracker } from "./stock-runstate.ts";
45
47
  import { STOCK_DERIVED_TOOLS, type DerivedPureHandler } from "./stock-derived.ts";
48
+ import { acquireChannelLock, ChannelLockTimeoutError } from "./channel-lock.ts";
46
49
 
47
50
  // The six family modules (plans 03-06 through 03-11) -- each exports its
48
51
  // tools as StockSessionHandler-shaped values; this file (D-09) is the ONE
@@ -72,6 +75,7 @@ import { handleCyclesStopwatch, forgetTimingForOtherTargets } from "./stock-timi
72
75
  import { handleRunUntil } from "./stock-run-until.ts";
73
76
  import { handleDiagnoseStock } from "./stock-diagnose.ts";
74
77
  import { handleRecycleStock } from "./stock-recycle.ts";
78
+ import { handleDeviceConsole, handleWarpSet, handleMemmapShow, handleMemmapZap, handleCpuHistory, handleProfileFlat, handleBacktrace, handleIoRegisters } from "./text-tools.ts";
75
79
 
76
80
  // Re-exported so Phase 2's existing import surface (and its 921-line test
77
81
  // file) keeps working unchanged -- these four names used to be DEFINED
@@ -86,21 +90,26 @@ export type { StockToolResult, StockOkResult, StockErrorResult };
86
90
  // ---------------------------------------------------------------------------
87
91
 
88
92
  /**
89
- * Resolves which manifest file backs a given backend's advertised tool
93
+ * Resolves the one manifest file backing the stock backend's advertised tool
90
94
  * surface, following the EXACT override precedence vice-proxy.ts's own
91
95
  * manifestPath() already establishes: an explicit VICE_TOOLS_MANIFEST value
92
96
  * (passed in as `envOverride`, never read from process.env directly here --
93
- * this function stays a pure, injectable seam) wins for either backend,
94
- * unchanged; otherwise the backend picks its own committed default file
95
- * beside `hereDir`. `envOverride` is deliberately a plain parameter, not a
96
- * process.env read, so this function has no hidden global dependency and a
97
- * 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.
98
107
  */
99
- export function manifestPathForBackend(backend: ViceBackend, hereDir: string, envOverride: string | undefined): string {
108
+ export function manifestPathForBackend(hereDir: string, envOverride: string | undefined): string {
100
109
  if (envOverride) {
101
110
  return resolve(envOverride);
102
111
  }
103
- return backend === "stock" ? join(hereDir, "tools-manifest.stock.json") : join(hereDir, "tools-manifest.json");
112
+ return join(hereDir, "tools-manifest.stock.json");
104
113
  }
105
114
 
106
115
  /**
@@ -108,44 +117,38 @@ export function manifestPathForBackend(backend: ViceBackend, hereDir: string, en
108
117
  * is a name-keyed record: whichever assignment to a given key runs LAST
109
118
  * wins, and vice-proxy.ts's own registration order used to assign
110
119
  * RECYCLE_TOOL/DIAGNOSE_TOOL's literal (fork-worded) definitions
111
- * UNCONDITIONALLY, on both backends, straight after the backend-aware
112
- * manifest loop had already populated the same keys correctly. So on the
113
- * stock backend, `tools/list` served the fork's five-verdict vocabulary --
114
- * including `stale_read_path`, which stock cannot produce (D-03) -- and
115
- * omitted `monitor_held_elsewhere`, which stock can, even though the
116
- * 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
117
125
  * tools-manifest.stock.json, unread. This function is the ONE place that
118
126
  * decision is now made, so the manifest loop's own per-tool selection and
119
127
  * the two synthetic tools' registration agree.
120
128
  *
121
- * Behaviour:
122
- * - `backend === "fork"`: always returns `syntheticDef` unchanged, no
123
- * matter what `manifestTools` contains. The fork's advertised surface
124
- * is frozen at v0.1.x and this function must never be able to alter it.
125
- * - `backend === "stock"`: returns the `manifestTools` entry whose `name`
126
- * equals `syntheticDef.name`, if one exists. Falls back to
127
- * `syntheticDef` when no match exists -- `readManifestTools()`'s own
128
- * malformed/unreadable-manifest fallbacks answer `[]`, and in that case
129
- * the proxy must still advertise a WORKING tool rather than none at all
130
- * (T-07-16-02).
131
- * - NEVER merges fields from the two definitions. Picking one whole
132
- * definition keeps `description`, `inputSchema` and `outputSchema`
133
- * internally consistent; a field-by-field merge could pair a fork
134
- * 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`.
135
138
  *
136
139
  * Declared as a `function`, not a `const` arrow, per this module tree's own
137
140
  * standing rule: stock-dispatch.ts <-> stock-diagnose.ts <-> stock-recycle.ts
138
141
  * form a runtime import cycle, and the phase already reproduced a live
139
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.
140
150
  */
141
- export function resolveAdvertisedToolDefinition(
142
- syntheticDef: ToolInfo,
143
- backend: ViceBackend,
144
- manifestTools: ToolInfo[],
145
- ): ToolInfo {
146
- if (backend === "fork") {
147
- return syntheticDef;
148
- }
151
+ export function resolveAdvertisedToolDefinition(syntheticDef: ToolInfo, manifestTools: ToolInfo[]): ToolInfo {
149
152
  const manifestEntry = manifestTools.find((t) => t.name === syntheticDef.name);
150
153
  return manifestEntry ?? syntheticDef;
151
154
  }
@@ -186,8 +189,8 @@ export type LeaseProvider = () => Promise<{ ok: true; lease: HeldLease | null }
186
189
  * never implies resolution it did not achieve. It is a plain string handed down from vice-proxy.ts's
187
190
  * OWN single, module-scope call to `resolvedBackend()` (see that file's own
188
191
  * "resolve the active backend once" discipline) -- this module must never
189
- * call `resolvedBackend()`/`probeBackend()` itself, per backend-detect.mts's
190
- * 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
191
194
  * (never expected in production) falls back to an empty string rather than
192
195
  * throwing.
193
196
  */
@@ -202,6 +205,12 @@ export interface StockDispatchDeps {
202
205
  * Omitted defaults to `false` -- the honest answer when nothing said
203
206
  * otherwise. */
204
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;
205
214
  }
206
215
 
207
216
  export type EnsureStockSessionOutcome = { ok: true; session: StockConnectSession } | { ok: false; message: string };
@@ -422,7 +431,7 @@ export async function ensureStockSession(deps: StockDispatchDeps): Promise<Ensur
422
431
  * come from the lease vice-proxy.ts built (see HeldLease's own field comments
423
432
  * for why they are two DIFFERENT directories), and `binPath` is the same
424
433
  * already-settled `resolvedBinaryPath` vice_ping reports -- this module must
425
- * never call resolvedBackend()/probeBackend() itself.
434
+ * never call resolvedBackend() itself.
426
435
  *
427
436
  * An empty string is treated as ABSENT rather than passed through: the two
428
437
  * consumers both branch on truthiness, and passing "" would key a capability
@@ -462,6 +471,53 @@ export { stockDisconnect };
462
471
  * prohibits). */
463
472
  export type StockHandler = (args: Record<string, unknown>, deps: StockDispatchDeps) => Promise<StockToolResult>;
464
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
+
465
521
  /**
466
522
  * withStockSession -- THE ONE adapter every STOCK_DISPATCH_TABLE entry goes
467
523
  * through (Task 1, plan 03-12). Before this existed, `viceHandlerPing` was
@@ -489,6 +545,17 @@ export type StockHandler = (args: Record<string, unknown>, deps: StockDispatchDe
489
545
  * Code for the rest of the session (T-3-04) -- a single escaped
490
546
  * exception here would silently end the session's entire tool surface,
491
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).
492
559
  */
493
560
  export function withStockSession(toolName: string, handler: StockSessionHandler): StockHandler {
494
561
  return async (args, deps) => {
@@ -503,11 +570,13 @@ export function withStockSession(toolName: string, handler: StockSessionHandler)
503
570
  return isErrorText(outcome.message);
504
571
  }
505
572
 
506
- try {
507
- return await handler(args, outcome.session, deps);
508
- } catch (err) {
509
- return convertWireError(toolName, err);
510
- }
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
+ });
511
580
  };
512
581
  }
513
582
 
@@ -531,7 +600,14 @@ export function withStockSession(toolName: string, handler: StockSessionHandler)
531
600
  * calls ensureStockSession() at all -- not a lighter-weight variant of it
532
601
  * (04-RESEARCH.md Pitfall 3) -- and invokes `handler(args, deps)` inside a
533
602
  * single try/catch converting through convertWireError(), so the
534
- * 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).
535
611
  *
536
612
  * `needsSession: true` runs the EXACT same three-step preamble
537
613
  * withStockSession() runs, reusing the same imported converters -- never a
@@ -539,7 +615,11 @@ export function withStockSession(toolName: string, handler: StockSessionHandler)
539
615
  * inside its own try/catch -> convertHandshakeError(toolName, err); a
540
616
  * `{ ok: false }` outcome returns outcome.message verbatim through
541
617
  * isErrorText(), never re-worded; otherwise handler(args, outcome.session, deps)
542
- * 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).
543
623
  */
544
624
  export function withDerivedTool(toolName: string, opts: { needsSession: true }, handler: StockSessionHandler): StockHandler;
545
625
  export function withDerivedTool(toolName: string, opts: { needsSession: false }, handler: DerivedPureHandler): StockHandler;
@@ -572,11 +652,13 @@ export function withDerivedTool(
572
652
  return isErrorText(outcome.message);
573
653
  }
574
654
 
575
- try {
576
- return await (handler as StockSessionHandler)(args, outcome.session, deps);
577
- } catch (err) {
578
- return convertWireError(toolName, err);
579
- }
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
+ });
580
662
  };
581
663
  }
582
664
 
@@ -713,9 +795,9 @@ const STOCK_DISPATCH_TABLE: Record<string, StockHandler> = {
713
795
  vice_run_until: withDerivedTool("vice_run_until", { needsSession: true }, handleRunUntil),
714
796
 
715
797
  // derived (TIME-04) -- the two proxy-local synthetic tools (RECYCLE_TOOL/
716
- // DIAGNOSE_TOOL in vice-proxy.ts), backend-routed to dispatchStock() by
717
- // buildBackendAwareTool() rather than served from the fork's HTTP
718
- // 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
719
801
  // DerivedPureHandler's amended doc comment in stock-derived.ts):
720
802
  // vice_diagnose uses needsSession:false because its own handler acquires
721
803
  // the session itself (inside its own try/catch) so it can convert a
@@ -726,6 +808,30 @@ const STOCK_DISPATCH_TABLE: Record<string, StockHandler> = {
726
808
  // evidence and has no verdict vocabulary of its own to preserve.
727
809
  vice_diagnose: withDerivedTool("vice_diagnose", { needsSession: false }, handleDiagnoseStock),
728
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),
729
835
  };
730
836
 
731
837
  /** Looks up the table entry for `name` -- `undefined` on a miss, never a
@@ -740,31 +846,27 @@ export function stockHandlerFor(name: string): StockHandler | undefined {
740
846
  /**
741
847
  * The ONE dispatch entry point for the stock backend (D-09). On a hit,
742
848
  * delegates to the table entry, unchanged. On a miss, refuses EXPLICITLY --
743
- * naming the tool, stating the stock backend does not implement it, and
744
- * naming the fork as the backend that does -- WITHOUT reading `deps` at all
745
- * (no lease is ever requested for a tool that does not exist on this
746
- * backend). There is no third branch, and in particular NO fall-through to
747
- * the fork's HTTP-forwarding path anywhere in this file or anything it calls
748
- * -- that is D-09's whole point, grep-gated to zero occurrences of that
749
- * 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.
750
863
  */
751
864
  export async function dispatchStock(name: string, args: Record<string, unknown>, deps: StockDispatchDeps): Promise<StockToolResult> {
752
865
  const handler = stockHandlerFor(name);
753
866
  if (!handler) {
754
- // WR-13: route through capability-registry.ts's ONE authoritative
755
- // refusal renderer rather than a second, locally-composed wording --
756
- // that renderer knows which backend ACTUALLY provides each name
757
- // (avoiding the false "the fork backend provides this tool" claim for a
758
- // stock-only-gain name) and never uses "wait for a later phase" framing
759
- // for a hardware loss. Fall back to an internal-inconsistency message
760
- // ONLY when the registry has no entry at all for `name` -- meaning the
761
- // tool is advertised on the stock manifest (so it reached this branch)
762
- // but stockHandlerFor() has no dispatch entry AND capability-registry.ts
763
- // has no divergence entry either: a bug to report, not a capability gap.
764
867
  return isErrorText(
765
- capabilityRefusalMessage(name, "stock") ??
766
- `${name} is advertised on the stock backend's manifest but has no handler in the stock ` +
767
- `dispatch table -- this is an internal inconsistency, not a capability gap; please file an issue.`,
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.`,
768
870
  );
769
871
  }
770
872
  return handler(args, deps);
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-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. */
package/stock-protocol.ts CHANGED
@@ -41,7 +41,7 @@
41
41
  import { EventEmitter } from "node:events";
42
42
  import net from "node:net";
43
43
 
44
- import { ViceError } from "./vice.ts";
44
+ import { ViceError } from "./vice-errors.ts";
45
45
 
46
46
  // ---------------------------------------------------------------------------
47
47
  // Wire constants (hand-copied, not imported -- see header comment above)
package/stock-recycle.ts CHANGED
@@ -64,7 +64,7 @@ import { handleRegistersGet } from "./stock-registers.ts";
64
64
  import { stockAnswer, isErrorText, type StockSessionHandler, type StockToolResult } from "./stock-handler.ts";
65
65
  import { stockDisconnect, type StockConnectSession } from "./stock-connect.ts";
66
66
  import type { StockDispatchDeps } from "./stock-dispatch.ts";
67
- import { readEpoch } from "./vice.ts";
67
+ import { readEpoch } from "./vice-errors.ts";
68
68
 
69
69
  function describeError(err: unknown): string {
70
70
  return err instanceof Error ? err.message : String(err);
@@ -307,6 +307,81 @@ export async function gatherStockWedgeEvidence(session: StockConnectSession, dep
307
307
  * per-outcome vocabulary is the same one. Redeclared locally rather than
308
308
  * imported: importing it would mean importing vice-proxy.ts, which this
309
309
  * module must never do. */
310
+ const DEFAULT_RECYCLE_EPOCH_POLL_TIMEOUT_MS = 3000;
311
+ const RECYCLE_EPOCH_POLL_INTERVAL_MS = 50;
312
+
313
+ /** Read fresh on EVERY call -- same load-time-vs-call-time reasoning as
314
+ * stockCaptureStepTimeoutMs() above: a static `import` is hoisted ahead of
315
+ * any top-level statement in the importing file, so a module-level constant
316
+ * computed once at load time could never be retuned by a test that sets
317
+ * `process.env` afterwards. Deliberately its OWN environment variable,
318
+ * distinct from `VICE_RECYCLE_CAPTURE_TIMEOUT_MS` -- that knob bounds one
319
+ * evidence-gathering step before the kill; this one bounds the epoch poll
320
+ * after it, and a single shared knob would let retuning one silently retune
321
+ * the other. Exported so the test file can assert the default directly. */
322
+ export function stockRecycleEpochPollTimeoutMs(): number {
323
+ const raw = process.env.VICE_RECYCLE_EPOCH_POLL_TIMEOUT_MS;
324
+ if (raw === undefined || raw === "") return DEFAULT_RECYCLE_EPOCH_POLL_TIMEOUT_MS;
325
+ const parsed = Number(raw);
326
+ // A non-positive deadline would make the poll finish before it ever reads,
327
+ // so every confirmed kill would record a null epoch_after -- and the field
328
+ // would look present (the producer ran) while carrying no information at
329
+ // all, which is worse than the missing producer this poll exists to fix.
330
+ if (Number.isFinite(parsed) && parsed > 0) return parsed;
331
+ console.error(
332
+ `VICE_RECYCLE_EPOCH_POLL_TIMEOUT_MS=${JSON.stringify(raw)} is not a positive number of milliseconds -- ignoring it and using the ` +
333
+ `default ${DEFAULT_RECYCLE_EPOCH_POLL_TIMEOUT_MS}ms. A value of 0 would end the post-kill epoch poll before its first read, so a ` +
334
+ "confirmed kill would always record a null epoch_after, indistinguishable from a genuine stall.",
335
+ );
336
+ return DEFAULT_RECYCLE_EPOCH_POLL_TIMEOUT_MS;
337
+ }
338
+
339
+ function sleep(ms: number): Promise<void> {
340
+ return new Promise((resolve) => setTimeout(resolve, ms));
341
+ }
342
+
343
+ /** The ONE predicate that may promote a post-kill read into the record. A
344
+ * present-but-unchanged value must stay out: a pair of equal before/after
345
+ * numbers reads to a future investigator as a confirmed no-turnover, which is
346
+ * a false claim for a kill the guard above this call already established
347
+ * succeeded. Absence of a pre-kill epoch (a lease that never had one) makes
348
+ * the first present read count as an advance -- there is nothing higher than
349
+ * "nothing" to compare against. */
350
+ function epochAdvanced(before: number | null, after: ReturnType<typeof readEpoch>): boolean {
351
+ if (!after.present || after.epoch === null) return false;
352
+ return before === null || after.epoch > before;
353
+ }
354
+
355
+ /**
356
+ * Bounded post-kill epoch poll. Reuses the SAME dependency-injected reader
357
+ * and file path the pre-kill read above already used, so the before and
358
+ * after values come from one source. Loops until either the epoch has
359
+ * advanced (epochAdvanced() above) or its own wall-clock deadline passes,
360
+ * clamping the final sleep so the loop cannot overshoot that deadline. A
361
+ * lease with no epoch file polls nothing and resolves null immediately --
362
+ * there is nothing to read. A throw from the reader is treated as a read
363
+ * that did not advance, never as a fatal error on a path that runs after a
364
+ * destructive action has already happened.
365
+ */
366
+ async function pollEpochAfter(readEpochFn: typeof readEpoch, epochFile: string, epochBefore: number | null): Promise<number | null> {
367
+ if (!epochFile) return null;
368
+ const deadline = Date.now() + stockRecycleEpochPollTimeoutMs();
369
+ for (;;) {
370
+ let result: ReturnType<typeof readEpoch> | null;
371
+ try {
372
+ result = readEpochFn(epochFile);
373
+ } catch {
374
+ result = null;
375
+ }
376
+ if (result && epochAdvanced(epochBefore, result)) {
377
+ return result.epoch;
378
+ }
379
+ const remaining = deadline - Date.now();
380
+ if (remaining <= 0) return null;
381
+ await sleep(Math.min(RECYCLE_EPOCH_POLL_INTERVAL_MS, remaining));
382
+ }
383
+ }
384
+
310
385
  function recycleAckOutcomeMessage(ack: { outcome: string; kill_stage: string; reason: string }): string {
311
386
  const stage = ack.kill_stage || "unknown";
312
387
  const reasonSuffix = ack.reason ? ` (${ack.reason})` : "";
@@ -472,7 +547,13 @@ export async function handleRecycleStock(args: Record<string, unknown>, session:
472
547
  return isErrorText(`vice_recycle: ${recycleAckOutcomeMessage(ack)} Incident record: ${recordPath}.`);
473
548
  }
474
549
 
475
- finaliseIncidentRecord(recordPath, { outcome: "ok", kill_stage: killStage });
550
+ // Post-kill epoch poll -- the record's own `epoch_after` producer. Only
551
+ // reached on a confirmed kill: a refusal, a timeout or a broker-gone
552
+ // outcome each leave the machine's state unknown, and polling for an
553
+ // epoch advance on any of those would invent a fact this handler has no
554
+ // basis for.
555
+ const epochAfter = await pollEpochAfter(readEpochFn, lease.epochFile, epochBefore);
556
+ finaliseIncidentRecord(recordPath, { outcome: "ok", kill_stage: killStage, epoch_after: epochAfter });
476
557
 
477
558
  // stockAnswer() stamps runState from session.client -- read BEFORE the
478
559
  // teardown below disconnects it, so the answer reports the machine's