@henols/vice-mcp 0.1.11 → 0.2.0

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.
@@ -0,0 +1,400 @@
1
+ #!/usr/bin/env node
2
+ // stock-run-until.ts
3
+ //
4
+ // `vice_run_until` for the stock backend (TIME-02/TIME-03): arms a TEMPORARY,
5
+ // stopping exec checkpoint at the requested address, resumes the machine
6
+ // exactly once, waits event-driven for THAT checkpoint's own CHECKPOINT_INFO,
7
+ // and takes a different, correct cleanup action on each of three paths --
8
+ // hit, timeout, machine-restarted-mid-wait. Implements D-02: an optional,
9
+ // stock-only `timeout_ms` argument defaulting to 30000, the same default
10
+ // VICE_MCP_TIMEOUT_MS already uses, so one number governs both layers.
11
+ //
12
+ // WHY THIS FILE EXISTS: the wedge-triage skill documents `vice_run_until` as
13
+ // having NO working timeout on stock today -- a call against an address that
14
+ // never executes is indistinguishable from a genuine wedge. This module is
15
+ // what bounds that wait and tells the two apart, and is one of the last two
16
+ // skill-called tools missing on the stock backend.
17
+ //
18
+ // WHAT NOT TO DO:
19
+ // - Never wrap the three cleanup paths (hit / timeout / restarted) in one
20
+ // undifferentiated `finally { delete }` -- that is this design space's
21
+ // documented first-draft mistake (Pitfall 4). Each path takes its OWN,
22
+ // distinct action, and only the timeout path ever issues a delete.
23
+ // - Never call registerTraceCheckpoint() here -- that guard exists for
24
+ // `stop:false` trace checkpoints (stock-checkpoints.ts), and the
25
+ // checkpoint this file arms always stops.
26
+ // - Never send a second resume for one wait -- exactly one resume per
27
+ // call, matching vice-sync.ts's own "exactly one resume per wait"
28
+ // invariant, ported here in its stock-native (event-driven, not
29
+ // polling) form.
30
+ // - Never invent a second wire-error converter -- an arming failure goes
31
+ // through convertWireError() directly (the established per-handler
32
+ // convention every sibling family module already follows); a failure
33
+ // surfacing from the resume/wait step is left to propagate uncaught, so
34
+ // the ONE existing converter seam (withStockSession's own
35
+ // convertHandshakeError/convertWireError) produces the answer, not a
36
+ // second one written in this file.
37
+ import {
38
+ CommandType,
39
+ CheckpointOperation,
40
+ checkpointSetBody,
41
+ cpNumBody,
42
+ ErrorCode,
43
+ StockProtocolError,
44
+ type ParsedCheckpointInfoResponse,
45
+ type ResolvedResponse,
46
+ type ViceMonitorClient,
47
+ } from "./stock-protocol.ts";
48
+ import { parseAddress } from "./stock-address.ts";
49
+ import { stockAnswer, isErrorText, convertWireError, type StockSessionHandler } from "./stock-handler.ts";
50
+ import { readProgramCounter } from "./stock-timing.ts";
51
+ import { runStateFor } from "./stock-runstate.ts";
52
+
53
+ /** True iff `value` is a well-formed, generic JSON object -- not null, not
54
+ * an array. Matches this module tree's own isPlainObject() convention
55
+ * (vice.ts:310-316); redeclared privately here, not imported, per the
56
+ * established per-module convention (see stock-checkpoints.ts's own copy). */
57
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
58
+ return typeof value === "object" && value !== null && !Array.isArray(value);
59
+ }
60
+
61
+ function describeError(err: unknown): string {
62
+ return err instanceof Error ? err.message : String(err);
63
+ }
64
+
65
+ /** D-02: the stock-only `timeout_ms` argument's default, in milliseconds.
66
+ * Matches VICE_MCP_TIMEOUT_MS's own 30000 default (vice.ts) so one number
67
+ * governs both the RPC transport layer and this tool's own wait. */
68
+ export const RUN_UNTIL_DEFAULT_TIMEOUT_MS = 30000;
69
+
70
+ /** A present `timeout_ms` above this ceiling is CLAMPED (not refused) to
71
+ * this value, and the answer carries `timeoutClamped: true` -- the caller
72
+ * asked for a deadline this module will not honour past 10 minutes, but the
73
+ * request itself is not malformed the way a non-finite or non-positive
74
+ * value is. */
75
+ export const RUN_UNTIL_MAX_TIMEOUT_MS = 600000;
76
+
77
+ /** Narrows an emitted `event` item to a CHECKPOINT_INFO event -- checked on
78
+ * the parsed item's own `.type` discriminant, never on response type alone
79
+ * (CHECKPOINT_INFO (0x11) shares a response type with a legitimate command
80
+ * reply). The same predicate shape stock-checkpoints.ts's own
81
+ * isCheckpointInfoEvent() uses, copied rather than imported -- each family
82
+ * module keeps its own private copy, matching this tree's established
83
+ * per-module convention. */
84
+ function isCheckpointInfoEvent(item: unknown): item is ParsedCheckpointInfoResponse {
85
+ return isPlainObject(item) && item.type === "checkpoint_info" && isPlainObject(item.checkpoint);
86
+ }
87
+
88
+ type WaitOutcome = { status: "hit"; hitCount: number } | { status: "timeout" };
89
+
90
+ /**
91
+ * Installs ONE `event` listener narrowed on the parsed event's own `.type`
92
+ * discriminant, THEN the specific checkpoint id, sends the resume exactly
93
+ * once, and races that against a single timeout deadline and the client's
94
+ * own `close` signal. The listener is installed BEFORE the resume is sent,
95
+ * so a checkpoint that fires immediately after cannot be missed in the gap
96
+ * between "sent" and "listening".
97
+ *
98
+ * Removes every listener and clears the timer in a `finally` on EVERY path
99
+ * -- resolve, timeout, and rejection -- so a long session never accumulates
100
+ * listeners (T-07-09).
101
+ *
102
+ * A `close` event mid-wait settles the wait as a timeout rather than
103
+ * sitting until the deadline: there is nothing left to wait ON once the
104
+ * socket is gone, and the caller's own timeout-path cleanup attempt will
105
+ * discover the dead connection on its own delete call rather than this
106
+ * function guessing at it.
107
+ *
108
+ * Any rejection from the resume send itself (a MachineRestartedError, or
109
+ * any other error) propagates OUT of this function uncaught -- see
110
+ * handleRunUntil's own comment, below, on why no delete is attempted for
111
+ * that path.
112
+ */
113
+ async function waitForCheckpointHit(client: ViceMonitorClient, checkpointId: number, timeoutMs: number): Promise<WaitOutcome> {
114
+ let timer: ReturnType<typeof setTimeout> | undefined;
115
+ let onEvent: ((item: unknown) => void) | undefined;
116
+ let onClose: (() => void) | undefined;
117
+
118
+ try {
119
+ return await new Promise<WaitOutcome>((resolve, reject) => {
120
+ onEvent = (item: unknown) => {
121
+ if (!isCheckpointInfoEvent(item)) return;
122
+ if (item.checkpoint.id !== checkpointId) return;
123
+ resolve({ status: "hit", hitCount: item.checkpoint.hitCount });
124
+ };
125
+ onClose = () => {
126
+ resolve({ status: "timeout" });
127
+ };
128
+
129
+ client.on("event", onEvent);
130
+ client.on("close", onClose);
131
+ timer = setTimeout(() => resolve({ status: "timeout" }), timeoutMs);
132
+
133
+ // The one resume for this wait -- installed listener above fires
134
+ // BEFORE this send() call so a checkpoint hit racing the reply cannot
135
+ // slip through the gap.
136
+ client.send(CommandType.Exit).catch(reject);
137
+ });
138
+ } finally {
139
+ if (onEvent) client.off("event", onEvent);
140
+ if (onClose) client.off("close", onClose);
141
+ if (timer !== undefined) clearTimeout(timer);
142
+ }
143
+ }
144
+
145
+ export const handleRunUntil: StockSessionHandler = async (args, session, _deps) => {
146
+ if (!isPlainObject(args)) {
147
+ return isErrorText("vice_run_until: arguments must be an object");
148
+ }
149
+
150
+ // WR-18 (07-REVIEW.md), part 1: refuse unexpected keys BY NAME, exactly as
151
+ // the sibling handler added in this same phase does (handleCyclesStopwatch,
152
+ // stock-timing.ts). Accepting them silently means a typo -- `timeoutMs` for
153
+ // `timeout_ms`, `addr` for `address` -- runs with the DEFAULT bound and
154
+ // reports a confident answer, and the caller has no way to tell.
155
+ const RUN_UNTIL_KEYS = ["address", "cycles", "timeout_ms"];
156
+ const unexpectedKeys = Object.keys(args).filter((key) => !RUN_UNTIL_KEYS.includes(key));
157
+ if (unexpectedKeys.length > 0) {
158
+ return isErrorText(
159
+ `vice_run_until: unexpected argument(s): ${unexpectedKeys.join(", ")} -- this tool takes only ${RUN_UNTIL_KEYS.join(", ")}`,
160
+ );
161
+ }
162
+
163
+ // WR-18, part 2: `cycles` is refused WHENEVER it is present, not only when
164
+ // `address` is absent.
165
+ //
166
+ // Before this, the "cycles-only mode not yet implemented" refusal was
167
+ // reachable only on the no-address path, so `{ address: "$c000", cycles: 5000 }`
168
+ // silently DROPPED the cycle bound and answered `reached: true` -- a caller
169
+ // who asked for "run to this address but give up after 5000 cycles" got an
170
+ // unbounded-by-cycles run reported as a success. Refusing is the honest
171
+ // answer: this backend has no cycles-bounded execution at all (TIME-03), and
172
+ // the fork never shipped one either.
173
+ if (args.cycles !== undefined) {
174
+ // The fork's own refusal wording (mcp_tools_debug.c:772), matched
175
+ // verbatim rather than inventing a cycles-bounded execution the fork
176
+ // never shipped -- TIME-03's own requirement. Extended with the
177
+ // address-present case, which the fork's wording does not cover because
178
+ // the fork never silently dropped the bound the way this handler did.
179
+ return isErrorText(
180
+ args.address === undefined
181
+ ? "vice_run_until: cycles-only mode not yet implemented; provide an address"
182
+ : "vice_run_until: cycles-only mode not yet implemented; \"cycles\" is not supported alongside \"address\" either -- it would be " +
183
+ "silently ignored, so it is refused rather than dropped. Remove \"cycles\" and bound the wait with \"timeout_ms\" instead.",
184
+ );
185
+ }
186
+
187
+ if (args.address === undefined) {
188
+ return isErrorText("vice_run_until: address is required");
189
+ }
190
+
191
+ let address: number;
192
+ try {
193
+ address = parseAddress(args.address, { what: "vice_run_until address" });
194
+ } catch (err) {
195
+ return isErrorText(`vice_run_until: ${describeError(err)}`);
196
+ }
197
+
198
+ // D-02: timeout_ms validation. A non-finite, non-numeric, or non-positive
199
+ // value is REFUSED naming the offending value and the valid range -- never
200
+ // silently coerced to 0 (an instant spurious timeout) and never to the
201
+ // default. Fractional values truncate with Math.trunc AFTER the finiteness
202
+ // check, matching clampCpuHistoryCount()'s (stock-connect.ts) own
203
+ // discipline. A value above the ceiling is CLAMPED, not refused, and the
204
+ // answer says so via `timeoutClamped: true`.
205
+ let timeoutMs = RUN_UNTIL_DEFAULT_TIMEOUT_MS;
206
+ let timeoutClamped = false;
207
+ if (args.timeout_ms !== undefined) {
208
+ const raw = args.timeout_ms;
209
+ if (typeof raw !== "number" || !Number.isFinite(raw)) {
210
+ return isErrorText(`vice_run_until: timeout_ms must be a finite number of milliseconds, got ${JSON.stringify(raw)}`);
211
+ }
212
+ const truncated = Math.trunc(raw);
213
+ if (truncated <= 0) {
214
+ return isErrorText(
215
+ `vice_run_until: timeout_ms must be > 0, got ${JSON.stringify(raw)} -- expected an integer in 1..${RUN_UNTIL_MAX_TIMEOUT_MS}`,
216
+ );
217
+ }
218
+ if (truncated > RUN_UNTIL_MAX_TIMEOUT_MS) {
219
+ timeoutMs = RUN_UNTIL_MAX_TIMEOUT_MS;
220
+ timeoutClamped = true;
221
+ } else {
222
+ timeoutMs = truncated;
223
+ }
224
+ }
225
+
226
+ // Arm a temporary, stopping exec checkpoint at `address`. This is this
227
+ // codebase's first caller in this tree to pass the temporary flag as
228
+ // true: VICE itself auto-deletes a temporary checkpoint the instant it
229
+ // fires (mon_breakpoint.c:605-607), unlike every other caller in this
230
+ // tree (handleCheckpointAdd, stock-checkpoints.ts, always passes it
231
+ // false) -- this divergence is deliberate, not an oversight.
232
+ const body = checkpointSetBody({
233
+ start: address,
234
+ end: address,
235
+ stop: true,
236
+ enabled: true,
237
+ operation: CheckpointOperation.Exec,
238
+ temporary: true,
239
+ memspace: 0x00,
240
+ });
241
+
242
+ let response: ResolvedResponse;
243
+ try {
244
+ response = await session.client.send(CommandType.CheckpointSet, body);
245
+ } catch (err) {
246
+ return convertWireError("vice_run_until", err);
247
+ }
248
+ if (response.type !== "checkpoint_info") {
249
+ return isErrorText(`vice_run_until: unexpected reply type "${response.type}" from CHECKPOINT_SET`);
250
+ }
251
+ const checkpointId = response.checkpoint.id;
252
+
253
+ // No try/catch around the wait itself: a MachineRestartedError (or any
254
+ // other failure) surfacing from the resume/wait step propagates straight
255
+ // out of this handler, uncaught. Attempting a delete here would be wrong
256
+ // on every one of those failure causes -- when the machine has restarted,
257
+ // the instance and every checkpoint on it are already gone, so there is
258
+ // nothing to clean up, and the standard restarted wording is produced by
259
+ // the one existing convertHandshakeError()/convertWireError() seam
260
+ // (stock-handler.ts / withStockSession), not a second converter written
261
+ // here.
262
+ const outcome = await waitForCheckpointHit(session.client, checkpointId, timeoutMs);
263
+
264
+ if (outcome.status === "hit") {
265
+ // Hit: VICE already deleted the temporary checkpoint itself
266
+ // (mon_breakpoint.c:605-607) -- issuing CHECKPOINT_DELETE here would
267
+ // target an object that no longer exists.
268
+ //
269
+ // machineHalted (07-14/WR-02): the checkpoint that just fired STOPPED
270
+ // the machine (it was armed with stop:true) -- on stock, any inbound
271
+ // byte halts the machine (CLAUDE.md, monitor_binary.c:281) and nothing
272
+ // in this handler resumes it. Emitted unconditionally, never only when
273
+ // true, so an absent field can never be read as "not halted" (the exact
274
+ // ambiguity WR-02 is about).
275
+ const payload: Record<string, unknown> = {
276
+ requested: "run_until",
277
+ reached: true,
278
+ address,
279
+ checkpointId,
280
+ hitCount: outcome.hitCount,
281
+ timeoutMs,
282
+ machineHalted: true,
283
+ machineHaltedNote:
284
+ "the checkpoint at the requested address stopped the emulated machine when it fired, and nothing here resumed it -- " +
285
+ "this is expected, not a wedge. Call vice_execution_run to resume.",
286
+ };
287
+ if (timeoutClamped) payload.timeoutClamped = true;
288
+ return stockAnswer(session.client, payload);
289
+ }
290
+
291
+ // Timeout: the checkpoint never fired within timeoutMs. Delete it exactly
292
+ // once -- ObjectMissing (the hit landed between the deadline firing and
293
+ // this delete) is tolerated as benign; any other wire error is recorded on
294
+ // the answer, never thrown, so the caller still gets a bounded result.
295
+ let cleanup: "deleted" | "already_gone" | "delete_failed" = "deleted";
296
+ let cleanupError: string | undefined;
297
+ try {
298
+ await session.client.send(CommandType.CheckpointDelete, cpNumBody(checkpointId));
299
+ } catch (err) {
300
+ if (err instanceof StockProtocolError && err.errorCode === ErrorCode.ObjectMissing) {
301
+ cleanup = "already_gone";
302
+ } else {
303
+ cleanup = "delete_failed";
304
+ cleanupError = convertWireError("vice_run_until", err).content[0]!.text;
305
+ }
306
+ }
307
+
308
+ // machineHalted (07-14/WR-02, corrected by 07-REVIEW.md WR-01).
309
+ //
310
+ // 07-14 hardcoded `true` here for all three cleanup branches, reasoning
311
+ // that the CHECKPOINT_DELETE above is itself an inbound byte and so halted
312
+ // the machine. That holds for "deleted" and "already_gone" -- both mean
313
+ // the delete travelled over the wire and was answered -- but NOT for
314
+ // "delete_failed", which is reachable precisely when the socket is already
315
+ // gone: waitForCheckpointHit()'s own `close` handler settles the wait as
316
+ // `{ status: "timeout" }`, the delete then rejects with
317
+ // StockConnectionClosedError, and a hardcoded `true` claims a halted
318
+ // machine over a dead connection while telling the caller to send
319
+ // vice_execution_run down it. stockAnswer() stamps runState into this same
320
+ // object, so that answer could read {"machineHalted": true, "runState":
321
+ // "running"} -- self-contradictory in one JSON body.
322
+ //
323
+ // So derive it, from the same seam stock-diagnose.ts's deriveMachinePaused()
324
+ // uses, and keep the note honest per branch. This is the rule
325
+ // stock-diagnose.ts:642-656 states normatively: a hand-passed state flag
326
+ // drifts from reality the moment a call site changes. Do not reintroduce a
327
+ // literal here.
328
+ const deleteWasAnswered = cleanup !== "delete_failed";
329
+ const machineHalted = deleteWasAnswered && session.client.connected ? true : runStateFor(session.client) === "stopped";
330
+ const machineHaltedNote = machineHalted
331
+ ? "the cleanup CHECKPOINT_DELETE sent after the timeout halted the emulated machine (on stock, any inbound byte does), and " +
332
+ "nothing here resumed it -- this is expected, not a wedge. Call vice_execution_run to resume."
333
+ : "the machine's run state could NOT be established: the cleanup CHECKPOINT_DELETE did not complete (see cleanupError) " +
334
+ "and/or the connection is gone, so nothing here can claim the machine is halted. Call vice_diagnose before acting -- " +
335
+ "in particular do not assume vice_execution_run will reach this instance.";
336
+
337
+ const payload: Record<string, unknown> = {
338
+ requested: "run_until",
339
+ timedOut: true,
340
+ address,
341
+ timeoutMs,
342
+ cleanup,
343
+ machineHalted,
344
+ machineHaltedNote,
345
+ explanation:
346
+ "an address that never executes within the timeout window is, from the caller's side, indistinguishable from " +
347
+ "a genuinely wedged emulator -- see vice-wedge-triage/SKILL.md. This bounded answer means the address itself " +
348
+ "did not execute in time, not that the connection is unresponsive. Whether the machine is now stopped -- and " +
349
+ "therefore whether vice_execution_run is the right next call -- is reported by machineHalted and " +
350
+ "machineHaltedNote; read those rather than assuming either way.",
351
+ };
352
+ if (timeoutClamped) payload.timeoutClamped = true;
353
+ if (cleanupError !== undefined) payload.cleanupError = cleanupError;
354
+
355
+ if (cleanup === "already_gone") {
356
+ // WR-01: an ObjectMissing on this delete means the temporary checkpoint
357
+ // was already gone before the delete arrived -- VICE only does that the
358
+ // instant the checkpoint fires (mon_breakpoint.c:605-607), so the
359
+ // address almost certainly WAS reached, between the deadline expiring
360
+ // and this cleanup delete being sent. Never assert reached:false on
361
+ // this branch; resolve it from the program counter instead, or declare
362
+ // it unresolved -- never fabricate a PC value.
363
+ try {
364
+ const pc = await readProgramCounter(session);
365
+ if (pc === address) {
366
+ payload.reached = true;
367
+ payload.raceResolved = "pc_at_address";
368
+ payload.pcAtCleanup = pc;
369
+ payload.raceNote =
370
+ "the temporary checkpoint fired between the deadline expiring and the cleanup delete being sent -- the program " +
371
+ "counter still at the requested address confirms the address executed.";
372
+ } else {
373
+ payload.reached = false;
374
+ payload.raceResolved = "pc_elsewhere";
375
+ payload.pcAtCleanup = pc;
376
+ payload.raceNote =
377
+ `the temporary checkpoint was already gone before the cleanup delete arrived, but the program counter is at ` +
378
+ `0x${pc.toString(16)}, not the requested 0x${address.toString(16)} -- the race is resolved against a hit.`;
379
+ }
380
+ } catch (err) {
381
+ // The one path where this tool genuinely does not know: never emit
382
+ // `reached` and `reachedUnknown` together, and never emit
383
+ // `reached: false` here -- that would assert a falsehood exactly as
384
+ // confidently as the defect this plan closes.
385
+ payload.reachedUnknown = true;
386
+ payload.raceResolved = "unresolved";
387
+ payload.pcReadError = convertWireError("vice_run_until", err).content[0]!.text;
388
+ payload.raceNote =
389
+ "the temporary checkpoint was already gone before the cleanup delete arrived (it likely fired), but the program " +
390
+ "counter could not be read to confirm it -- read the program counter yourself (vice_registers_get) to settle it.";
391
+ }
392
+ } else {
393
+ // "deleted" and "delete_failed": the checkpoint provably still existed
394
+ // at cleanup time (or its state is reported separately via
395
+ // cleanupError), so no race resolution is warranted.
396
+ payload.reached = false;
397
+ }
398
+
399
+ return stockAnswer(session.client, payload);
400
+ };
package/stock-runstate.ts CHANGED
@@ -41,6 +41,16 @@ export type RunState = "running" | "stopped" | "unknown";
41
41
 
42
42
  export interface RunStateTracker {
43
43
  get(): RunState;
44
+ /** WR-04: true once a JAM (0x61) event has been seen on this client's wire,
45
+ * and never reset -- a jam is a latching fact about this instance, not a
46
+ * transient state. Kept SEPARATE from `get()` on purpose: a jam and a
47
+ * plain STOPPED both leave the run state "stopped", but only one of them
48
+ * means the CPU will never execute another instruction, and only one of
49
+ * them is recovered by a reset rather than a recycle. Collapsing the two
50
+ * (which this file used to do) threw the distinction away at its only
51
+ * consumer, after stock-protocol.ts went to the trouble of parsing JAM's
52
+ * zero-length body without fabricating a PC. */
53
+ jamObserved(): boolean;
44
54
  }
45
55
 
46
56
  let trackers = new WeakMap<ViceMonitorClient, RunStateTracker>();
@@ -68,12 +78,23 @@ export function attachRunStateTracker(client: ViceMonitorClient): RunStateTracke
68
78
  }
69
79
 
70
80
  let state: RunState = "unknown";
81
+ // WR-04: latched, never cleared -- not even by a subsequent RESUMED. A CPU
82
+ // that has jammed stays a machine whose evidence must mention the jam; the
83
+ // recovery is vice_machine_reset, and nothing on this wire can undo the
84
+ // fact that a jam happened on this instance.
85
+ let jamSeen = false;
71
86
 
72
87
  client.on("event", (item: ParsedResponse | StockProtocolError | StockFramingError) => {
73
88
  if (!hasParsedType(item)) {
74
89
  return;
75
90
  }
76
- if (item.type === "stopped" || item.type === "jam") {
91
+ if (item.type === "jam") {
92
+ // A jam halts the CPU, so the run state is "stopped" as before -- but
93
+ // the jam itself is recorded separately rather than being collapsed
94
+ // into it and lost.
95
+ jamSeen = true;
96
+ state = "stopped";
97
+ } else if (item.type === "stopped") {
77
98
  state = "stopped";
78
99
  } else if (item.type === "resumed") {
79
100
  state = "running";
@@ -82,7 +103,7 @@ export function attachRunStateTracker(client: ViceMonitorClient): RunStateTracke
82
103
  // state untouched.
83
104
  });
84
105
 
85
- const tracker: RunStateTracker = { get: () => state };
106
+ const tracker: RunStateTracker = { get: () => state, jamObserved: () => jamSeen };
86
107
  trackers.set(client, tracker);
87
108
  return tracker;
88
109
  }
@@ -96,6 +117,29 @@ export function runStateFor(client: ViceMonitorClient): RunState {
96
117
  return tracker ? tracker.get() : "unknown";
97
118
  }
98
119
 
120
+ /** WR-04: whether a JAM (0x61) has been observed on `client`'s wire -- `false`
121
+ * when nothing is attached, never a throw, same contract as runStateFor().
122
+ *
123
+ * WHY THIS IS SEPARATE FROM runStateFor(): the two jamaction settings produce
124
+ * opposite-looking symptoms from the same underlying dead CPU, and neither is
125
+ * distinguishable from the run state alone.
126
+ * - `-jamaction 2` (Monitor): the machine stops, both liveness brackets
127
+ * read zero advance, and vice_diagnose answers `wedged` -- whose
128
+ * documented response is `vice_recycle`, i.e. destroy the instance, when
129
+ * a vice_machine_reset recovers a jam. Same shape as the
130
+ * `checkpoint_trap` hazard the wedge-triage SKILL already warns about.
131
+ * - default jamaction (continue): the emulator keeps burning cycles
132
+ * refetching the same opcode, so BOTH brackets advance and vice_diagnose
133
+ * answers `live` -- for a machine that will never execute another
134
+ * instruction.
135
+ * The JAM frame that settles it arrived on the wire in both cases. This is
136
+ * how it stops being discarded. It is reported as EVIDENCE on the existing
137
+ * verdicts, never as a sixth verdict (D-03). */
138
+ export function jamObservedFor(client: ViceMonitorClient): boolean {
139
+ const tracker = trackers.get(client);
140
+ return tracker ? tracker.jamObserved() : false;
141
+ }
142
+
99
143
  /** Test-only: replaces the module-level WeakMap with a fresh one, matching
100
144
  * clearHeldStockSession()'s role in stock-dispatch.test.ts's beforeEach()
101
145
  * convention. */