@henols/vice-mcp 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) 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 +1736 -163
  5. package/anno-confidence.ts +2 -2
  6. package/anno-derive.ts +6 -6
  7. package/anno-details.ts +4 -4
  8. package/anno-enum-gen.ts +416 -30
  9. package/anno-export-asm.ts +1211 -126
  10. package/anno-graphics.ts +338 -0
  11. package/anno-hazard-report.ts +1367 -0
  12. package/anno-import.ts +495 -0
  13. package/anno-index.ts +8 -8
  14. package/anno-join.ts +480 -0
  15. package/anno-memmap-render.ts +22 -21
  16. package/anno-provenance-ledger.ts +472 -0
  17. package/anno-regbits-gen.ts +13 -13
  18. package/anno-register.ts +159 -0
  19. package/anno-store-export.ts +661 -0
  20. package/anno-store.ts +635 -124
  21. package/anno-symbols.ts +7 -7
  22. package/anno-tools.ts +1169 -16
  23. package/anno-types.ts +313 -40
  24. package/backend-detect.mts +124 -312
  25. package/build.ts +3 -1
  26. package/capture-predicate.ts +597 -0
  27. package/channel-lock.ts +349 -0
  28. package/evid-ingest.ts +217 -0
  29. package/evid-reconcile.ts +316 -0
  30. package/host-tool-client.ts +430 -0
  31. package/incident-record.ts +23 -12
  32. package/install-resources.ts +29 -13
  33. package/memmap-lookup.ts +285 -0
  34. package/package.json +27 -8
  35. package/prg-image.ts +1 -2
  36. package/repo-root.ts +87 -3
  37. package/resources/backend-detect.mjs +98 -236
  38. package/resources/broker-control.mjs +220 -54
  39. package/resources/broker-epoch.mjs +7 -8
  40. package/resources/broker-kill.mjs +36 -31
  41. package/resources/broker-launch.mjs +511 -374
  42. package/resources/broker-state.mjs +69 -24
  43. package/resources/container-guard.mjs +1 -1
  44. package/resources/ghidra-project.mjs +790 -0
  45. package/resources/host-tool.mjs +2533 -0
  46. package/resources/vice-broker.mjs +434 -290
  47. package/resources/vice-launcher.sh +127 -9
  48. package/stock-address.ts +1 -1
  49. package/stock-condition.ts +1 -1
  50. package/stock-connect.ts +9 -5
  51. package/stock-derived.ts +29 -37
  52. package/stock-diagnose.ts +200 -36
  53. package/stock-dispatch.ts +179 -77
  54. package/stock-handler.ts +1 -1
  55. package/stock-paths.ts +18 -14
  56. package/stock-petscii.ts +1 -1
  57. package/stock-protocol.ts +1 -1
  58. package/stock-recycle.ts +83 -2
  59. package/stock-reproducible-run.ts +811 -0
  60. package/stock-run-until.ts +100 -1
  61. package/stock-symbols.ts +4 -4
  62. package/stock-timing.ts +1 -1
  63. package/stop-oracle.ts +167 -0
  64. package/text-capability-probe.ts +660 -0
  65. package/text-connect.ts +157 -0
  66. package/text-protocol.ts +810 -0
  67. package/text-tools.ts +778 -0
  68. package/textmon-backtrace.ts +385 -0
  69. package/textmon-cpuhistory.ts +335 -0
  70. package/textmon-memmap.ts +494 -0
  71. package/textmon-profile.ts +458 -0
  72. package/textmon-registers.ts +748 -0
  73. package/tools-manifest.stock.json +864 -3
  74. package/vice-broker-client.ts +253 -108
  75. package/vice-errors.ts +268 -0
  76. package/vice-proxy.ts +339 -2144
  77. package/vsf-slice.ts +640 -0
  78. package/anno-d64.ts +0 -310
  79. package/capability-registry.ts +0 -390
  80. package/refresh-manifest.ts +0 -124
  81. package/tools-manifest.json +0 -1223
  82. package/vice-probe.ts +0 -278
  83. package/vice-sync.ts +0 -336
  84. package/vice.ts +0 -772
@@ -15,7 +15,10 @@
15
15
  # --check-container is now forwarded through to the Node entry point, which
16
16
  # answers it, preserving the exact same exit-code contract this launcher
17
17
  # always had: 2 when the guard refuses, 3 for the report path, 0 for
18
- # --print-paths.
18
+ # --print-paths, and (new below) 4 when no interpreter this launcher is
19
+ # willing to exec into could be resolved -- distinct from 2/3 so a triage
20
+ # reader can tell "no usable interpreter" from "container guard refused"
21
+ # at a glance.
19
22
  #
20
23
  # Copies vice-broker.sh's own opening shape: SELF_PATH/SELF_DIR resolution,
21
24
  # resolve_repo_root(). Plan 11 INLINES resolve_repo_root() here (it used to
@@ -131,12 +134,105 @@ REPO_ROOT="$(resolve_repo_root "$SELF_DIR")"
131
134
  # deployed tools/ copy that may be stale or hand-edited.
132
135
  BROKER_ARTIFACT="$SELF_DIR/vice-broker.mjs"
133
136
 
137
+ # ---------------------------------------------------------------- interpreter resolution
138
+ #
139
+ # WHY THIS EXISTS: this launcher used to `exec node ...`, trusting whatever
140
+ # `node` a shell's PATH resolved first. On a development host with several
141
+ # Node majors installed side by side that is an accident waiting to happen,
142
+ # and a service environment (a systemd unit's own PATH, carrying no
143
+ # interactive-shell version-manager entry) resolves a DIFFERENT one than an
144
+ # interactive shell -- the same script started two different ways running
145
+ # two different interpreters, with no record of which. Nothing spawns this
146
+ # script (the broker never spawns itself; the only thing that ever prints
147
+ # this path is a message telling a HUMAN to run it), so there is no parent
148
+ # process whose own interpreter this launcher could inherit or receive as an
149
+ # argument -- the ladder below, plus a refusal, is the whole of it.
150
+ #
151
+ # NODE_FLOOR_MAJOR mirrors src/mcp/vice/package.json's `engines.node` --
152
+ # pinned to that single number by a test (host-scripts.test.ts) precisely so
153
+ # this floor cannot silently drift into a second, disagreeing number, which
154
+ # is the same failure class as the bare-interpreter exec this section
155
+ # replaces.
156
+ NODE_FLOOR_MAJOR=24
157
+
158
+ # probe_node_version <candidate-path>
159
+ #
160
+ # On success, sets NODE_RESOLVED_VERSION (the raw "vX.Y.Z" string) and
161
+ # NODE_RESOLVED_MAJOR (just the leading number) and returns 0. On any
162
+ # failure -- not executable, crashes, or prints something this launcher
163
+ # cannot parse as a version -- returns 1 and sets neither. Called only from
164
+ # inside an `if`, so a failure here becomes a named refusal downstream
165
+ # rather than aborting the whole script under `set -e`.
166
+ probe_node_version() {
167
+ local candidate="$1" raw major
168
+ if ! raw="$("$candidate" --version 2>/dev/null)"; then
169
+ return 1
170
+ fi
171
+ case "$raw" in
172
+ v[0-9]*) : ;;
173
+ *) return 1 ;;
174
+ esac
175
+ major="${raw#v}"
176
+ major="${major%%.*}"
177
+ case "$major" in
178
+ ''|*[!0-9]*) return 1 ;;
179
+ esac
180
+ NODE_RESOLVED_VERSION="$raw"
181
+ NODE_RESOLVED_MAJOR="$major"
182
+ return 0
183
+ }
184
+
185
+ # Resolution ladder -- exactly two rungs, then a refusal. There is no third
186
+ # rung to invent: see the WHY note above for why the broker's own
187
+ # interpreter can never be threaded in as a candidate.
188
+ #
189
+ # 1. VICE_BROKER_NODE, an absolute-path override, accepted only when it
190
+ # names an executable file -- this is the rung that fixes a service
191
+ # unit whose PATH carries no usable node at all: point it at one
192
+ # directly instead of depending on PATH.
193
+ # 2. `node` as found on PATH.
194
+ NODE_BIN=""
195
+ NODE_VERSION=""
196
+ NODE_MAJOR=""
197
+ NODE_RESOLUTION_ERROR=""
198
+
199
+ if [ -n "${VICE_BROKER_NODE:-}" ]; then
200
+ if [ -f "${VICE_BROKER_NODE}" ] && [ -x "${VICE_BROKER_NODE}" ]; then
201
+ if probe_node_version "$VICE_BROKER_NODE"; then
202
+ NODE_BIN="$VICE_BROKER_NODE"
203
+ NODE_VERSION="$NODE_RESOLVED_VERSION"
204
+ NODE_MAJOR="$NODE_RESOLVED_MAJOR"
205
+ else
206
+ NODE_RESOLUTION_ERROR="VICE_BROKER_NODE is set to '$VICE_BROKER_NODE', but running it with --version failed or produced output this launcher could not parse. Set VICE_BROKER_NODE to an absolute path to a working node executable, or unset it to fall back to node on PATH."
207
+ fi
208
+ else
209
+ NODE_RESOLUTION_ERROR="VICE_BROKER_NODE is set to '$VICE_BROKER_NODE', which does not resolve to an executable file. Set VICE_BROKER_NODE to an absolute path to an executable node interpreter, or unset it to fall back to node on PATH."
210
+ fi
211
+ else
212
+ NODE_CANDIDATE="$(command -v node 2>/dev/null || true)"
213
+ if [ -n "$NODE_CANDIDATE" ]; then
214
+ if probe_node_version "$NODE_CANDIDATE"; then
215
+ NODE_BIN="$NODE_CANDIDATE"
216
+ NODE_VERSION="$NODE_RESOLVED_VERSION"
217
+ NODE_MAJOR="$NODE_RESOLVED_MAJOR"
218
+ else
219
+ NODE_RESOLUTION_ERROR="Resolved 'node' on PATH at '$NODE_CANDIDATE', but running it with --version failed or produced output this launcher could not parse. Install a working Node >= v${NODE_FLOOR_MAJOR} and put it on PATH, or set VICE_BROKER_NODE to an absolute path to one."
220
+ fi
221
+ else
222
+ NODE_RESOLUTION_ERROR="No 'node' executable was found on PATH and VICE_BROKER_NODE is not set. Install Node >= v${NODE_FLOOR_MAJOR} and put it on PATH, or set VICE_BROKER_NODE to an absolute path to one."
223
+ fi
224
+ fi
225
+
134
226
  # ---------------------------------------------------------------- --print-paths
135
227
  #
136
- # Prints already-resolved variables only -- writes no state, spawns nothing,
137
- # so (like vice-broker.sh's own --print-paths) it needs no guard enforcement
138
- # to report what this launcher would use. Checked BEFORE --check-container is
139
- # forwarded, since --print-paths needs no guard verdict at all.
228
+ # Prints already-resolved variables only -- writes no state and spawns
229
+ # nothing beyond the version probe above, so (like vice-broker.sh's own
230
+ # --print-paths) it needs no guard enforcement to report what this launcher
231
+ # would use. Checked BEFORE --check-container is forwarded, since
232
+ # --print-paths needs no guard verdict at all, and BEFORE the floor is
233
+ # enforced below: this diagnostic reports what it found, or reports it
234
+ # found nothing, but never refuses -- a diagnostic that dies exactly when
235
+ # the thing it diagnoses is broken is worthless.
140
236
  PRINT_PATHS=0
141
237
  for arg in "$@"; do
142
238
  case "$arg" in
@@ -150,9 +246,29 @@ if [ "$PRINT_PATHS" -eq 1 ]; then
150
246
  echo "repo_root=$REPO_ROOT"
151
247
  echo "self_dir=$SELF_DIR"
152
248
  echo "broker_artifact=$BROKER_ARTIFACT"
249
+ echo "node_bin=$NODE_BIN"
250
+ echo "node_version=$NODE_VERSION"
153
251
  exit 0
154
252
  fi
155
253
 
254
+ # ---------------------------------------------------------------- interpreter gate
255
+ #
256
+ # Refuses BEFORE exec, by name, with a remedy -- a below-floor or
257
+ # unresolvable interpreter must never reach the broker artifact, because
258
+ # once it does, whatever fails next presents as a wedge with no obvious
259
+ # cause. This project detects and refuses by name; it never installs
260
+ # anything and never shells out to a package manager.
261
+ if [ -z "$NODE_BIN" ]; then
262
+ printf 'vice-launcher: refusing to start -- %s\n' "$NODE_RESOLUTION_ERROR" >&2
263
+ exit 4
264
+ fi
265
+
266
+ if [ "$NODE_MAJOR" -lt "$NODE_FLOOR_MAJOR" ]; then
267
+ printf 'vice-launcher: refusing to start -- resolved node interpreter %s reports %s, which is below the required floor v%s.x. Install a Node >= v%s and put it on PATH, or set VICE_BROKER_NODE to an absolute path to one that satisfies the floor.\n' \
268
+ "$NODE_BIN" "$NODE_VERSION" "$NODE_FLOOR_MAJOR" "$NODE_FLOOR_MAJOR" >&2
269
+ exit 4
270
+ fi
271
+
156
272
  # ---------------------------------------------------------------- exec
157
273
  #
158
274
  # The guard now runs INSIDE the Node entry point, at its own process
@@ -161,9 +277,11 @@ fi
161
277
  # enforcement path (exit 2, refusal) are the broker's own job now. This
162
278
  # launcher forwards every argument, including --repo-root, unchanged, and
163
279
  # no longer inspects --check-container itself; the exit-code contract this
164
- # launcher always exposed (2/3/0) is preserved because the guard functions
165
- # ported into container-guard.mts return the SAME codes
280
+ # launcher always exposed (2/3/0, now also 4) is preserved because the guard
281
+ # functions ported into container-guard.mts return the SAME codes
166
282
  # container_guard_enforce()/container_guard_report() always did. Signal
167
283
  # delivery still passes straight through to the broker process with no bash
168
- # trap in between.
169
- exec node "$BROKER_ARTIFACT" --repo-root "$REPO_ROOT" "$@"
284
+ # trap in between -- exec replaces the process image with the RESOLVED
285
+ # interpreter, never a bare command name, so the interpreter this launcher
286
+ # actually gated is the one that actually runs.
287
+ exec "$NODE_BIN" "$BROKER_ARTIFACT" --repo-root "$REPO_ROOT" "$@"
package/stock-address.ts CHANGED
@@ -32,7 +32,7 @@
32
32
  // installs one object implementing both; a second holder or a
33
33
  // re-derived address->name map in a family module would force it to
34
34
  // install itself twice.
35
- import { ViceError, type ViceErrorOptions } from "./vice.ts";
35
+ import { ViceError, type ViceErrorOptions } from "./vice-errors.ts";
36
36
 
37
37
  export interface SymbolResolver {
38
38
  resolve(name: string): number | undefined;
@@ -46,7 +46,7 @@
46
46
  // session, never touches a socket, never imports anything from the
47
47
  // session/session-handler layer.
48
48
 
49
- import { ViceError } from "./vice.ts";
49
+ import { ViceError } from "./vice-errors.ts";
50
50
 
51
51
  // ---------------------------------------------------------------------------
52
52
  // Errors
package/stock-connect.ts CHANGED
@@ -42,7 +42,7 @@ import {
42
42
  StockRequestTimeoutError,
43
43
  } from "./stock-protocol.ts";
44
44
  import { readCapabilityRecord, writeCapabilityRecord, type CapabilityDeps } from "./backend-detect.mts";
45
- import { MachineRestartedError, ViceError, readEpoch, type EpochResult } from "./vice.ts";
45
+ import { MachineRestartedError, ViceError, readEpoch, type EpochResult } from "./vice-errors.ts";
46
46
  import {
47
47
  MonitorOwnershipError,
48
48
  type ClaimMonitorOptions,
@@ -402,12 +402,14 @@ async function safeResume(client: ViceMonitorClient): Promise<void> {
402
402
  * leave a frozen C64 behind either.
403
403
  */
404
404
  export async function stockConnect({ host, port, targetId, brokerControl, deps = {} }: StockConnectOptions): Promise<StockConnectSession> {
405
- const claimOutcome = await brokerControl.claimMonitor({ targetId });
405
+ // Plan 41-03 (D-14): explicit, not relying on claimMonitor()'s own default
406
+ // -- the binary path names its own channel at the call site.
407
+ const claimOutcome = await brokerControl.claimMonitor({ targetId, channel: "binary" });
406
408
  if (!claimOutcome.ok) {
407
409
  if (claimOutcome.reason === "monitor_owned") {
408
410
  throw new MonitorOwnershipError(
409
411
  `stockConnect: monitor for target ${targetId} on port ${port} is already claimed by grant ${claimOutcome.holder.grantId}`,
410
- { holderGrantId: claimOutcome.holder.grantId, holderClaimedAt: claimOutcome.holder.claimedAt, port },
412
+ { holderGrantId: claimOutcome.holder.grantId, holderClaimedAt: claimOutcome.holder.claimedAt, port, channel: claimOutcome.holder.channel },
411
413
  );
412
414
  }
413
415
  // "timeout" (the broker did not answer) is kept strictly distinct from
@@ -474,7 +476,9 @@ export async function stockConnect({ host, port, targetId, brokerControl, deps =
474
476
  // reason. Both outcomes are now reported on stderr and neither can
475
477
  // displace `err`.
476
478
  try {
477
- const released = await brokerControl.releaseMonitor({ targetId });
479
+ // Plan 41-03 (D-14): explicit "binary" -- never releases the "text"
480
+ // claim as a side effect of a failed binary handshake.
481
+ const released = await brokerControl.releaseMonitor({ targetId, channel: "binary" });
478
482
  if (!released.ok) {
479
483
  console.error(
480
484
  `stockConnect: monitor release for target ${targetId} after a failed handshake was refused (${released.reason}) -- the instance may still be claimed`,
@@ -493,7 +497,7 @@ export async function stockConnect({ host, port, targetId, brokerControl, deps =
493
497
  * stockConnect()'s own failure-path release above. */
494
498
  export async function stockDisconnect(session: StockConnectSession): Promise<void> {
495
499
  await safeDisconnect(session.client);
496
- await session.brokerControl.releaseMonitor({ targetId: session.targetId });
500
+ await session.brokerControl.releaseMonitor({ targetId: session.targetId, channel: "binary" });
497
501
  }
498
502
 
499
503
  // ---------------------------------------------------------------------------
package/stock-derived.ts CHANGED
@@ -18,48 +18,32 @@
18
18
  // hazard. There, NOT translating an emulator-side path is the bug -- four
19
19
  // tools carry a filename stock VICE opens on the HOST, and stock-paths.ts's
20
20
  // whole job is making sure that translation happens. HERE, translating a
21
- // CLIENT-SIDE-DERIVED path is the bug: `forwardToVice()` calls
22
- // `rewriteArguments(args, name)` itself, before it ever delegates to
23
- // `call()` -- so a derived tool sitting behind `call()` would receive
24
- // HOST-translated paths and act on them INSIDE THE CONTAINER (ROADMAP Phase 4
25
- // Notes, CLAUDE.md). The derived-tool seam this file anchors exists so a
26
- // derived tool's handler is reached BEFORE `forwardToVice()` runs
27
- // `rewriteArguments()` at all.
21
+ // CLIENT-SIDE-DERIVED path is the bug: a derived tool answers from data this
22
+ // proxy already holds, so handing its arguments through a HOST path
23
+ // translator meant for a forwarded call would act on a translated path
24
+ // INSIDE THE CONTAINER instead of the untranslated one the derived handler
25
+ // actually needs.
28
26
  //
29
- // SECOND CONSUMER: `gatherWedgeEvidence()` in vice-proxy.ts calls
30
- // `rewriteArguments()` itself, for `vice_display_screenshot`. On the stock
31
- // backend, PERFORMING that translation becomes the bug -- its own comment
32
- // inverts. Phase 5 criterion 5 owns that fix; it is deliberately NOT
33
- // repointed here.
34
- //
35
- // CITATION STYLE, deliberate (07-REVIEW.md WR-12): the two call sites above
36
- // are named by SYMBOL (`forwardToVice()`'s own `rewriteArguments(args, name)`
37
- // call; `gatherWedgeEvidence()`'s own call), never by line number. This header
38
- // used to cite `vice-proxy.ts:2773`, `:1343` and `:1367`; by the time WR-12 was
39
- // filed the real lines were 2889, 1344 and 1368 -- 07-16 edited vice-proxy.ts
40
- // without re-verifying them, and this header is the in-tree statement of
41
- // CLAUDE.md's derived-tool constraint, so its citations are load-bearing. Line
42
- // numbers in this file drift every phase; grep for the symbol instead.
43
- //
44
- // WHY THE HAZARD IS UNREACHABLE ON STOCK TODAY -- restated in terms of what
45
- // actually enforces it (WR-12 again; the previous reason had gone stale):
46
- // `buildBackendAwareTool()` routes EVERY stock tool call to
47
- // `dispatchStock()`, so `forwardToVice()`, `handleRecycle()` and
48
- // `gatherWedgeEvidence()` are reachable only on the fork arm. That is a
49
- // structural property of the registration, not of any tool's name. The reason
50
- // this header used to give -- "handleRecycle() is backend-aware and refused by
51
- // name after CR-07, and vice_display_screenshot does not exist on stock until
52
- // Phase 5" -- is now WRONG on its first clause: Phase 7 implemented
53
- // `vice_recycle` on stock (`handleRecycleStock`) and registered it in
54
- // STOCK_DERIVED_TOOLS below. The conclusion held; the stated reason did not,
55
- // which is worse than no reason for a constraint a future reader will
56
- // re-derive.
27
+ // WHY THE HAZARD IS UNREACHABLE ON STOCK TODAY: it no longer has anywhere to
28
+ // come FROM. The fork-only generic forwarding function and its own
29
+ // per-call argument path-rewriter, and the fork-only wedge-evidence
30
+ // gatherer that also ran that rewriter for `vice_display_screenshot`, are
31
+ // both deleted outright -- there is no remaining call site anywhere in this
32
+ // package that translates a host path before dispatch. Every stock tool
33
+ // call, derived or direct, reaches `dispatchStock()` in stock-dispatch.ts
34
+ // through exactly one registration seam in vice-proxy.ts, with no
35
+ // per-backend branch left to route around it. The hazard this file was
36
+ // built to close is now closed by construction, not merely by a runtime
37
+ // backend check: there is no path-translating function left for a derived
38
+ // tool's runner to be reached behind.
57
39
  //
58
40
  // WHAT NOT TO DO:
59
41
  // - Never `import` hostpath.ts from this file, or from any module listed
60
42
  // in STOCK_DERIVED_TOOLS' implementations -- hostpath-consumers.test.ts
61
43
  // fails the build if you do.
62
- // - Never `import` vice-proxy.ts, and never call rewriteArguments().
44
+ // - Never `import` vice-proxy.ts, and never re-introduce a per-call
45
+ // argument path-translator that a derived tool's runner could be
46
+ // reached behind.
63
47
  // - Never add a client-side-derived tool to stock-paths.ts's
64
48
  // STOCK_EMULATOR_SIDE_PATH_TOOLS -- that file's own header already warns
65
49
  // against exactly this.
@@ -76,7 +60,7 @@
76
60
  // withDerivedTool() refuses on this set, and a declared-but-unshipped
77
61
  // module fails at module load in the published tarball rather than at
78
62
  // dispatch.
79
- import { ViceError, type ViceErrorOptions } from "./vice.ts";
63
+ import { ViceError, type ViceErrorOptions } from "./vice-errors.ts";
80
64
  import type { StockToolResult } from "./stock-handler.ts";
81
65
  import type { StockDispatchDeps } from "./stock-dispatch.ts";
82
66
 
@@ -114,6 +98,14 @@ export const STOCK_DERIVED_TOOLS: ReadonlySet<string> = new Set([
114
98
  "vice_run_until", // Phase 7, TIME-02
115
99
  "vice_diagnose", // Phase 7, TIME-04
116
100
  "vice_recycle", // Phase 7, TIME-04
101
+ "vice_device_console", // Plan 41-06, CHAN-03 -- text-channel remedy tool, needsSession:false (text-tools.ts)
102
+ "vice_warp_set", // Plan 41-06, CHAN-03 -- text-channel remedy tool, needsSession:false (text-tools.ts)
103
+ "vice_memmap_show", // Plan 42-01, PARSE-01 -- text-channel access-map tool, needsSession:false (text-tools.ts)
104
+ "vice_memmap_zap", // Plan 43-03, EVID-05 -- text-channel access-map bracket-reset tool, needsSession:false (text-tools.ts)
105
+ "vice_cpu_history", // Plan 42-07, PARSE-02 -- text-channel CPU-history tool, needsSession:false (text-tools.ts)
106
+ "vice_profile_flat", // Plan 42-07, PARSE-02 -- text-channel flat-profile tool, needsSession:false (text-tools.ts)
107
+ "vice_backtrace", // Plan 42-07, PARSE-02/D-42-4 -- text-channel backtrace tool, shares the fork's own name, needsSession:false (text-tools.ts)
108
+ "vice_io_registers", // Plan 42-07, PARSE-02 -- text-channel register-decode tool, needsSession:false (text-tools.ts)
117
109
  ]);
118
110
 
119
111
  /**
package/stock-diagnose.ts CHANGED
@@ -48,8 +48,9 @@ import { ensureStockSession, type StockDispatchDeps, type EnsureStockSessionOutc
48
48
  import type { DerivedPureHandler } from "./stock-derived.ts";
49
49
  import type { StockConnectSession } from "./stock-connect.ts";
50
50
  import { runStateFor, jamObservedFor } from "./stock-runstate.ts";
51
- import { MachineRestartedError, readEpoch, type EpochResult } from "./vice.ts";
51
+ import { MachineRestartedError, readEpoch, type EpochResult } from "./vice-errors.ts";
52
52
  import { MonitorOwnershipError } from "./vice-broker-client.ts";
53
+ import { tryAcquireChannelLock, currentChannelLockHolder, type ChannelLockHandle, type ChannelLockHolder, type MonitorChannel } from "./channel-lock.ts";
53
54
 
54
55
  /** True iff `value` is a well-formed, generic JSON object -- not null, not
55
56
  * an array. Matches this module tree's own isPlainObject() convention,
@@ -649,6 +650,29 @@ function renderStockWedgedReport(bracket1: StockLivenessBracketResult, bracket2:
649
650
  );
650
651
  }
651
652
 
653
+ /** D-10/D-11: rendered when step 4's guard finds a FOREIGN hold already live
654
+ * -- `tryAcquireChannelLock()` returned `null`, so no bracket was ever run.
655
+ * `holder` is a snapshot taken at the moment the guard observed it; the
656
+ * authoritative `evidence.channelContention` is derived independently and
657
+ * slightly later, inside diagnoseVerdictResult() -- a few milliseconds'
658
+ * difference in `heldMs` between the two is expected and honest, not a bug.
659
+ * Deliberately self-contained (repeats the channel/operation/duration the
660
+ * universal channelContentionNote() will also append), so this report reads
661
+ * completely even if the note above it is somehow stripped. Must never
662
+ * contain wedge, wedged, hang, hung, frozen, stuck or unresponsive. */
663
+ function renderStockContendedReport(holder: ChannelLockHolder): string {
664
+ const heldMs = Math.max(0, Date.now() - holder.heldSince);
665
+ const grantId = holder.grantId ?? "unknown";
666
+ return (
667
+ "vice_diagnose verdict: live\n\n" +
668
+ `The ${holder.channel} channel currently holds this instance's halt authority (operation "${holder.operation}", ` +
669
+ `grant ${grantId}, held for ${heldMs}ms). No liveness bracket was run: this call was never granted halt ` +
670
+ "authority, so the machine was never resumed and liveness was not measured here -- the contention evidence is " +
671
+ "what explains the zero bracket count, not a bracket that read zero. The instance is healthy and answering: " +
672
+ "this is never a reason to recycle. Wait for the other channel's operation to finish, or find its holder."
673
+ );
674
+ }
675
+
652
676
  /** The `detail` half of the `liveness_unmeasurable` refusal (07-REVIEW.md
653
677
  * WR-02). Deliberately carries NO "vice_diagnose:" prefix of its own --
654
678
  * diagnoseUnavailableResult() owns the documented, machine-parseable prefix,
@@ -751,24 +775,118 @@ const JAM_OBSERVED_NOTE =
751
775
  "a wedge. Recover with vice_machine_reset -- do NOT vice_recycle, which destroys an instance a reset " +
752
776
  "would have fixed.";
753
777
 
778
+ // ---------------------------------------------------------------------------
779
+ // D-09/D-10/D-11 (plan 41-04, CHAN-05): channelContention -- always-present
780
+ // cross-cutting evidence on the jamObserved model, plus the guard (in
781
+ // handleDiagnoseStock()'s step 4, below) that makes `wedged` structurally
782
+ // unreachable while contended.
783
+ //
784
+ // DELIBERATE ASYMMETRY WITH jamObserved, RECORDED HERE SO A LATER READER DOES
785
+ // NOT "FIX" IT: jamObserved guards only in vice-wedge-triage/SKILL.md's
786
+ // prose and still lets `wedged` return -- the caller has to read the
787
+ // evidence and choose not to recycle. channelContention guards in CODE, in
788
+ // the handler itself, and makes `wedged` structurally unreachable while a
789
+ // foreign hold is live. The reason is the cost of being wrong: a contended
790
+ // instance is not merely misdiagnosed, it would be DESTROYED by the
791
+ // documented `wedged` remedy (T-41-15). Do not collapse this asymmetry by
792
+ // demoting the code guard to a prose-only warning to match jamObserved's
793
+ // shape -- they are deliberately different because what they are protecting
794
+ // against is not the same severity.
795
+ // ---------------------------------------------------------------------------
796
+
797
+ /** Always-present evidence on every `vice_diagnose` verdict (D-09), naming
798
+ * whether the OTHER monitor channel currently holds this instance's halt
799
+ * authority. `channel`/`operation`/`grantId`/`heldMs` are `null` exactly when
800
+ * `held` is `false` -- never omitted, so "not held" and "held for under a
801
+ * millisecond" stay distinguishable (`heldMs: null` vs `heldMs: 0`). */
802
+ export interface StockChannelContention {
803
+ held: boolean;
804
+ channel: MonitorChannel | null;
805
+ operation: string | null;
806
+ grantId: string | null;
807
+ heldMs: number | null;
808
+ }
809
+
810
+ /**
811
+ * Derives `StockChannelContention` from channel-lock.ts's holder record.
812
+ *
813
+ * `self` is the handle THIS diagnose call itself currently holds, if any --
814
+ * passed only from inside handleDiagnoseStock()'s own step-4 guard, while it
815
+ * holds the lock across its own liveness bracket(s). When `self` is
816
+ * non-null, this call IS the current holder by construction (only one
817
+ * holder can exist at a time), so reporting that as "contention" would be
818
+ * dishonest -- a diagnose call's own halt authority over its own bracket is
819
+ * not a FOREIGN hold. Every other caller (the four verdicts reached before
820
+ * the guard ever runs) passes no `self`, and a genuine foreign hold observed
821
+ * at that moment is reported exactly as measured.
822
+ *
823
+ * `grantId` reads the literal `unknown` when the holder recorded none --
824
+ * never fabricated, matching claimMonitor()'s own posture. `heldMs` is
825
+ * `Math.max(0, Math.trunc(nowMs - holder.heldSince))`, a non-negative whole
826
+ * millisecond integer.
827
+ */
828
+ export function channelContentionFor(nowMs: number, self: ChannelLockHandle | null): StockChannelContention {
829
+ if (self !== null) {
830
+ return { held: false, channel: null, operation: null, grantId: null, heldMs: null };
831
+ }
832
+ const holder = currentChannelLockHolder();
833
+ if (holder === null) {
834
+ return { held: false, channel: null, operation: null, grantId: null, heldMs: null };
835
+ }
836
+ return {
837
+ held: true,
838
+ channel: holder.channel,
839
+ operation: holder.operation,
840
+ grantId: holder.grantId ?? "unknown",
841
+ heldMs: Math.max(0, Math.trunc(nowMs - holder.heldSince)),
842
+ };
843
+ }
844
+
845
+ /** Appended to a verdict's report in the same register as JAM_OBSERVED_NOTE
846
+ * above, whenever `channelContention.held` is true -- deliberately UNIVERSAL:
847
+ * contention is evidence cutting across all five verdicts (D-09), not
848
+ * exclusive to the live-with-bracketsRun:0 path the guard itself reaches. A
849
+ * concurrent foreign hold can just as easily be observed while THIS call is
850
+ * answering restarted/checkpoint_trap/monitor_held_elsewhere. Must never
851
+ * contain wedge, wedged, hang, hung, frozen, stuck or unresponsive -- the
852
+ * same register channelLockRefusalMessage() (channel-lock.ts) already
853
+ * establishes, and the instance is healthy and answering regardless of which
854
+ * verdict carries this note. */
855
+ function channelContentionNote(contention: StockChannelContention): string {
856
+ return (
857
+ "\n\nCHANNEL CONTENTION: the " +
858
+ `${contention.channel} channel currently holds this instance's halt authority (operation ` +
859
+ `"${contention.operation}", grant ${contention.grantId}, held for ${contention.heldMs}ms). The instance is ` +
860
+ "healthy and answering -- this is never a reason to recycle."
861
+ );
862
+ }
863
+
754
864
  /** WR-04: `jamObserved` is derived HERE, from the same seam as
755
865
  * `machinePaused`, and stamped into EVERY verdict's evidence -- not added
756
866
  * per-call-site, for the same reason WR-03 removed the hand-passed
757
867
  * `machinePaused`. It is evidence on the existing five verdicts, never a
758
868
  * sixth verdict (D-03). Always present (never omitted when false), so an
759
- * absent field can never be read as "no jam". */
869
+ * absent field can never be read as "no jam". D-09: `channelContention` joins
870
+ * it here, on the identical always-present, derived-once, spread-unconditionally
871
+ * shape -- see `self`'s doc comment on channelContentionFor() above for why
872
+ * this function must be told which handle (if any) THIS call itself holds. */
760
873
  function diagnoseVerdictResult(
761
874
  session: StockConnectSession | null,
762
875
  verdict: StockDiagnoseVerdict,
763
876
  evidence: Record<string, unknown>,
764
877
  report: string,
878
+ self: ChannelLockHandle | null = null,
765
879
  ): StockToolResult {
766
880
  const { machinePaused, machinePausedSource } = deriveMachinePaused(session);
767
881
  const jamObserved = session === null ? false : jamObservedFor(session.client);
882
+ const channelContention = channelContentionFor(Date.now(), self);
883
+ let finalReport = report;
884
+ if (jamObserved) finalReport += JAM_OBSERVED_NOTE;
885
+ if (channelContention.held) finalReport += channelContentionNote(channelContention);
768
886
  const payload: Record<string, unknown> = {
769
887
  verdict,
770
- evidence: { ...evidence, jamObserved },
771
- report: jamObserved ? report + JAM_OBSERVED_NOTE : report,
888
+ evidence: { ...evidence, jamObserved, channelContention },
889
+ report: finalReport,
772
890
  machinePaused,
773
891
  machinePausedSource,
774
892
  };
@@ -936,47 +1054,93 @@ export async function handleDiagnoseStock(_args: Record<string, unknown>, deps:
936
1054
  }
937
1055
 
938
1056
  // Step 4: the liveness bracket -- the only step that resumes.
939
- let bracket1: StockLivenessBracketResult;
940
- try {
941
- bracket1 = await runStockLivenessBracket(session);
942
- } catch (err) {
943
- return diagnoseUnavailableResult("evidence_gathering_failed", `the liveness bracket failed (${describeStockError(err)}).`);
1057
+ //
1058
+ // D-10 guard, placed immediately BEFORE the first bracket can run --
1059
+ // that is, before the only step that resumes the machine, and therefore
1060
+ // before any path that can reach the `wedged` return below.
1061
+ // tryAcquireChannelLock() is the SYNCHRONOUS, non-blocking entry point,
1062
+ // chosen deliberately over acquireChannelLock(): a diagnostic that
1063
+ // queued behind a foreign hold would block behind the exact holder it
1064
+ // exists to report, and one that merely peeked at the holder record
1065
+ // without taking the lock could still have a foreign hold begin during
1066
+ // its own bracket. On `null` a foreign hold is live RIGHT NOW --
1067
+ // short-circuit to `live` with contention evidence and `bracketsRun: 0`
1068
+ // (the honest count: the machine was never resumed, so liveness was not
1069
+ // measured), with NO bracket run at all -- this is D-11's verdict choice
1070
+ // (the instance is healthy and responsive, not wedged). On a handle,
1071
+ // hold it across BOTH brackets and release in a `finally` covering every
1072
+ // return from here on, so a thrown bracket still releases. This is what
1073
+ // makes `wedged` structurally unreachable while contended: the only
1074
+ // route to it is through a bracket, and a bracket only runs when this
1075
+ // lock was actually taken by THIS call.
1076
+ const channelHandle = tryAcquireChannelLock({ channel: "binary", operation: "vice_diagnose" });
1077
+ if (channelHandle === null) {
1078
+ const holder = currentChannelLockHolder();
1079
+ // Invariant: tryAcquireChannelLock() returning null means SOME holder
1080
+ // exists (that is precisely why it refused) -- currentChannelLockHolder()
1081
+ // reading null here would be a lock-module invariant violation, not a
1082
+ // reachable case this handler needs to degrade gracefully for.
1083
+ if (holder === null) {
1084
+ return diagnoseUnavailableResult(
1085
+ "unknown",
1086
+ "channel-lock: tryAcquireChannelLock() refused but currentChannelLockHolder() reported no holder -- internal invariant violation.",
1087
+ );
1088
+ }
1089
+ return diagnoseVerdictResult(session, "live", { bracketsRun: 0 }, renderStockContendedReport(holder));
944
1090
  }
1091
+ try {
1092
+ let bracket1: StockLivenessBracketResult;
1093
+ try {
1094
+ bracket1 = await runStockLivenessBracket(session);
1095
+ } catch (err) {
1096
+ return diagnoseUnavailableResult("evidence_gathering_failed", `the liveness bracket failed (${describeStockError(err)}).`);
1097
+ }
945
1098
 
946
- if (bracket1.advanced === null) {
947
- return diagnoseUnavailableResult("liveness_unmeasurable", inconclusiveBracketText(bracket1));
948
- }
949
- if (bracket1.advanced) {
950
- return diagnoseVerdictResult(session, "live", { bracketsRun: 1, bracket: serializeBracket(bracket1) }, renderStockLiveReport(bracket1));
951
- }
1099
+ if (bracket1.advanced === null) {
1100
+ return diagnoseUnavailableResult("liveness_unmeasurable", inconclusiveBracketText(bracket1));
1101
+ }
1102
+ if (bracket1.advanced) {
1103
+ return diagnoseVerdictResult(
1104
+ session,
1105
+ "live",
1106
+ { bracketsRun: 1, bracket: serializeBracket(bracket1) },
1107
+ renderStockLiveReport(bracket1),
1108
+ channelHandle,
1109
+ );
1110
+ }
952
1111
 
953
- // Run a second bracket only when the first shows no advance -- mirroring
954
- // the fork's own short-circuit.
955
- let bracket2: StockLivenessBracketResult;
956
- try {
957
- bracket2 = await runStockLivenessBracket(session);
958
- } catch (err) {
959
- return diagnoseUnavailableResult("evidence_gathering_failed", `the second liveness bracket failed (${describeStockError(err)}).`);
960
- }
1112
+ // Run a second bracket only when the first shows no advance -- mirroring
1113
+ // the fork's own short-circuit.
1114
+ let bracket2: StockLivenessBracketResult;
1115
+ try {
1116
+ bracket2 = await runStockLivenessBracket(session);
1117
+ } catch (err) {
1118
+ return diagnoseUnavailableResult("evidence_gathering_failed", `the second liveness bracket failed (${describeStockError(err)}).`);
1119
+ }
1120
+
1121
+ if (bracket2.advanced === null) {
1122
+ return diagnoseUnavailableResult("liveness_unmeasurable", inconclusiveBracketText(bracket2));
1123
+ }
1124
+ if (bracket2.advanced) {
1125
+ return diagnoseVerdictResult(
1126
+ session,
1127
+ "live",
1128
+ { bracketsRun: 2, bracket1: serializeBracket(bracket1), bracket2: serializeBracket(bracket2) },
1129
+ renderStockLiveReport(bracket2),
1130
+ channelHandle,
1131
+ );
1132
+ }
961
1133
 
962
- if (bracket2.advanced === null) {
963
- return diagnoseUnavailableResult("liveness_unmeasurable", inconclusiveBracketText(bracket2));
964
- }
965
- if (bracket2.advanced) {
966
1134
  return diagnoseVerdictResult(
967
1135
  session,
968
- "live",
1136
+ "wedged",
969
1137
  { bracketsRun: 2, bracket1: serializeBracket(bracket1), bracket2: serializeBracket(bracket2) },
970
- renderStockLiveReport(bracket2),
1138
+ renderStockWedgedReport(bracket1, bracket2),
1139
+ channelHandle,
971
1140
  );
1141
+ } finally {
1142
+ channelHandle.release();
972
1143
  }
973
-
974
- return diagnoseVerdictResult(
975
- session,
976
- "wedged",
977
- { bracketsRun: 2, bracket1: serializeBracket(bracket1), bracket2: serializeBracket(bracket2) },
978
- renderStockWedgedReport(bracket1, bracket2),
979
- );
980
1144
  } catch (err) {
981
1145
  // WR-02: the outer catch-all goes through the classifier too, so there is
982
1146
  // no isError answer this handler can produce that lacks the documented