@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
@@ -2,34 +2,31 @@
2
2
  // Compiled by `tsc` from broker-control.mts. Edit the TypeScript source and rebuild;
3
3
  // changes made directly to this file are silently overwritten by the next build, and are never
4
4
  // deployed to the host on their own -- install-resources.mjs copies THIS file's on-disk contents
5
- // verbatim to tools/, so an edit made only here reaches the host but is lost on the very next
5
+ // verbatim to .c64-re-tools/bin/, so an edit made only here reaches the host but is lost on the very next
6
6
  // rebuild.
7
7
  // broker-control.mts
8
8
  //
9
- // N / D-01 (plan 01, tracer): the framing, the token gate, and acquire/
10
- // release. Plan 05 (task 1) completed the message set: recycle, status,
9
+ // The framing, the token gate, acquire/release, recycle, status,
11
10
  // host_state, the arrival-ordered pending-acquire structure, and the
12
- // kernel-enforced singleton guard's low-level bind primitive. THIS PLAN's
13
- // task 2 adds a SEVENTH and EIGHTH op, `monitor_claim`/`monitor_release`
14
- // (BROK-02/PROTO-08, D-13): exclusive ownership of an instance's raw binmon
15
- // socket, enforced here rather than left to a client-side heuristic --
16
- // stock VICE services exactly one binmon client, and a second connect()
17
- // produces no reply and no EOF, so the refusal must happen BEFORE any
18
- // second dial is ever attempted. The subsystem's FIRST network listener: a
19
- // TCP control plane replacing the bash broker's requests/grants/denials/
20
- // leases directory tree entirely. One JSON object per line; the connection
21
- // open IS the claim, connection close IS the release (T-01.6.2-01 through
22
- // -09).
11
+ // kernel-enforced singleton guard's low-level bind primitive. Also adds
12
+ // `monitor_claim`/`monitor_release`: exclusive ownership of an instance's
13
+ // raw binmon socket, enforced here rather than left to a client-side
14
+ // heuristic -- stock VICE services exactly one binmon client, and a second
15
+ // connect() produces no reply and no EOF, so the refusal must happen
16
+ // BEFORE any second dial is ever attempted. The subsystem's FIRST network
17
+ // listener: a TCP control plane replacing the bash broker's
18
+ // requests/grants/denials/leases directory tree entirely. One JSON object
19
+ // per line; the connection open IS the claim, connection close IS the
20
+ // release (T-01.6.2-01 through -09).
23
21
  //
24
- // Wire format confirmed at plan 01's blocking checkpoint:decision
25
- // (2026-08-03, `as-specified`, no amendments -- see .planning/RE-FINDINGS.md
26
- // for the full record, including the two accepted residual risks and the
27
- // unix-domain-socket dead end). Auth: per-boot capability token compared
28
- // constant-time, checked BEFORE any state read or write. Bind: 0.0.0.0
29
- // explicitly, never 127.0.0.1 -- host.docker.internal is the bridge
30
- // address, not loopback, so a loopback-only listener is structurally
31
- // unreachable from the container. Port: 19510 default via
32
- // VICE_BROKER_CONTROL_PORT.
22
+ // Wire format confirmed at a blocking checkpoint decision (2026-08-03,
23
+ // `as-specified`, no amendments), which accepted some residual risk and
24
+ // considered and rejected a unix-domain-socket alternative. Auth: per-boot
25
+ // capability token compared constant-time, checked BEFORE any state read
26
+ // or write. Bind: 0.0.0.0 explicitly, never 127.0.0.1 --
27
+ // host.docker.internal is the bridge address, not loopback, so a
28
+ // loopback-only listener is structurally unreachable from the container.
29
+ // Port: 19510 default via VICE_BROKER_CONTROL_PORT.
33
30
  import { createServer } from "node:net";
34
31
  import { timingSafeEqual, randomBytes } from "node:crypto";
35
32
  /** 32 cryptographically random bytes rendered as hex -- the per-boot
@@ -40,14 +37,98 @@ export function newControlToken() {
40
37
  return randomBytes(32).toString("hex");
41
38
  }
42
39
  const MAX_LINE_BYTES = 65536;
43
- /** CR-03: the one refusal wording for a target-naming op whose `target_id` is
40
+ /** The one refusal wording for a target-naming op whose `target_id` is
44
41
  * not the grant the asking connection itself holds. Deliberately worded as an
45
42
  * authorisation refusal and NOT as an ownership conflict between two
46
43
  * legitimate holders (`monitor_owned`, which names a holder) and never as an
47
44
  * emulator fault -- see attachControlProtocol()'s own ownsTarget() comment,
48
- * and T-02-18's prohibition on wedge/hang vocabulary in this file's
45
+ * and this file's own prohibition on wedge/hang vocabulary in its
49
46
  * monitor-op refusals. */
50
47
  const MONITOR_OWNERSHIP_DENIAL = "monitor_claim/monitor_release may only target the grant this connection itself holds";
48
+ /** Resolves the `channel` field on a `monitor_claim`/`monitor_release`
49
+ * request line: an ABSENT field means `binary` deliberately -- a broker
50
+ * restarted mid-upgrade against a client that predates this field keeps
51
+ * working (backward compatibility). An unrecognised NON-EMPTY value is
52
+ * `bad_request`, never a silent fallback and never cast -- the caller
53
+ * below names both accepted values in the refusal message. */
54
+ function resolveMonitorChannel(raw) {
55
+ if (raw === undefined)
56
+ return "binary";
57
+ if (raw === "binary" || raw === "text")
58
+ return raw;
59
+ return "bad_request";
60
+ }
61
+ // ---------------------------------------------------------------------------
62
+ // The launch-profile narrowing site.
63
+ //
64
+ // THIS IS THE ONE PLACE `profile` IS NARROWED. Do not re-derive this check
65
+ // anywhere else -- not in vice-broker.mts, not in broker-launch.mts, not in
66
+ // the container-side client. A second copy is how one of them ends up
67
+ // accepting a shape the other refuses.
68
+ //
69
+ // WHY IT HAS TO EXIST AT ALL: `ControlRequest` above carries an index
70
+ // signature, so *anything* a container writes on the wire parses into it. The
71
+ // profile then feeds buildViceArgs(), i.e. an `execve(x64sc, argv)` on the
72
+ // HOST. An unvalidated `profile` is therefore an argv-construction surface
73
+ // across a trust boundary, not merely a typing inconvenience.
74
+ //
75
+ // WHY UNKNOWN KEYS ARE REFUSED BY NAME rather than dropped: a silently
76
+ // accepted typo means a caller asked for warp, got an unwarped instance, and
77
+ // received a confident success -- the same undetectable-lie failure a
78
+ // mismatched grant-and-request eligibility check exists to prevent one
79
+ // layer down, and it is why the message below names the offending key --
80
+ // the by-name unexpected-argument discipline the tool handlers already use
81
+ // (RUN_UNTIL_KEYS' own convention).
82
+ //
83
+ // WHAT MUST NEVER BE ADDED HERE: a passthrough string, an `extraArgs`, or any
84
+ // key whose VALUE reaches argv. `profile` maps to exactly two literal flag
85
+ // tokens (`-console`, `-warp`) and to nothing else (T-33-04). `VICE_ARGS`
86
+ // stays the single, deliberate operator-only whole-argv override.
87
+ // ---------------------------------------------------------------------------
88
+ /** The complete accepted key set -- the ONE binding list this narrowing
89
+ * checks against, so adding a knob to LaunchProfile without adding it here
90
+ * refuses the knob rather than silently widening the boundary. */
91
+ const LAUNCH_PROFILE_KEYS = Object.freeze(["warp", "headless"]);
92
+ const LAUNCH_PROFILE_SHAPE = `an object with optional boolean keys ${LAUNCH_PROFILE_KEYS.join("/")}, or absent`;
93
+ /** Narrows an untrusted `profile` field off the wire. Never throws; answers a
94
+ * discriminated result so the caller writes the existing `bad_request` error
95
+ * shape rather than needing a try/catch at the protocol boundary.
96
+ *
97
+ * Rules, in the order they are applied:
98
+ * - `undefined` (key absent) and `null` -> `ok` with `undefined`. Both mean
99
+ * profile-less, which is byte-identically today's behaviour.
100
+ * - a PLAIN object (arrays and every other non-plain value refused) whose
101
+ * keys are a subset of LAUNCH_PROFILE_KEYS and whose PRESENT values are
102
+ * booleans -> `ok` with that object.
103
+ * - anything else -> `ok: false`, with a message naming the offending value
104
+ * (or key) and the accepted shape. Never coerced, never silently dropped:
105
+ * `"yes"`, `1` and `"warp"` are refusals, not truthy warp requests. */
106
+ export function normaliseLaunchProfile(raw) {
107
+ if (raw === undefined || raw === null)
108
+ return { ok: true, profile: undefined };
109
+ if (typeof raw !== "object" || Array.isArray(raw)) {
110
+ // Arrays are specifically excluded: `typeof [] === "object"` in JS, so
111
+ // without the Array.isArray() arm a JSON array would reach the key walk
112
+ // below and pass it vacuously (an empty array has no own keys).
113
+ return { ok: false, message: `profile must be ${LAUNCH_PROFILE_SHAPE}; got ${JSON.stringify(raw) ?? String(raw)}` };
114
+ }
115
+ const entries = Object.entries(raw);
116
+ const unknownKeys = entries.filter(([key]) => !LAUNCH_PROFILE_KEYS.includes(key)).map(([key]) => key);
117
+ if (unknownKeys.length > 0) {
118
+ return { ok: false, message: `profile has unknown key(s) ${unknownKeys.join(", ")}; accepted shape is ${LAUNCH_PROFILE_SHAPE}` };
119
+ }
120
+ const profile = {};
121
+ for (const [key, value] of entries) {
122
+ if (typeof value !== "boolean") {
123
+ return { ok: false, message: `profile.${key} must be a boolean; got ${JSON.stringify(value) ?? String(value)}` };
124
+ }
125
+ if (key === "warp")
126
+ profile.warp = value;
127
+ if (key === "headless")
128
+ profile.headless = value;
129
+ }
130
+ return { ok: true, profile };
131
+ }
51
132
  export function resolveControlPort(override) {
52
133
  if (typeof override === "number")
53
134
  return override;
@@ -74,6 +155,20 @@ function writeLine(socket, obj) {
74
155
  socket.write(`${JSON.stringify(obj)}\n`);
75
156
  }
76
157
  }
158
+ /** Writes a `host_tool` SUCCESS response line -- the object host-tool.mts's
159
+ * runHostTool() produced, whatever shape that is (`{ ok: true, ... }` or
160
+ * its own `{ ok: false, message }` refusal). This is deliberately NOT
161
+ * `writeLine()`/`ControlResponse`: the host-tool response shape is
162
+ * host-tool.mts's own contract, not one more `ControlResponse` variant this
163
+ * module would otherwise have to keep in sync with a sibling module's
164
+ * allowlist. A REJECTED onHostTool() promise never reaches this function --
165
+ * it is answered through the ordinary `writeLine()`/`error` path instead,
166
+ * so every protocol-level failure still goes through one shape. */
167
+ function writeHostToolLine(socket, obj) {
168
+ if (socket.writable) {
169
+ socket.write(`${JSON.stringify(obj)}\n`);
170
+ }
171
+ }
77
172
  function defaultRequestId(prefix) {
78
173
  return `${prefix}-${process.pid}-${Date.now()}`;
79
174
  }
@@ -93,9 +188,10 @@ export function enqueueAcquire(queue, entry) {
93
188
  * to stay correct; a genuinely adversarial retry pattern could still starve
94
189
  * an entry across MULTIPLE passes, which is exactly the direct fairness
95
190
  * proof this module deliberately does not author -- injecting N acquires
96
- * and asserting grants return in that order is Phase 01.6.2.1's D-08
97
- * deliverable. The original defect this queue replaces (a lexical iteration
98
- * over `req-<pid>-<ms>-<hex>` filenames) cannot exist here regardless: there
191
+ * and asserting grants return in that order is left as a property for a
192
+ * future test to prove, not this module's own deliverable. The original
193
+ * defect this queue replaces (a lexical iteration over
194
+ * `req-<pid>-<ms>-<hex>` filenames) cannot exist here regardless: there
99
195
  * is no file, and no re-ordering call of any kind anywhere in this region. */
100
196
  export async function drainPendingAcquires(queue) {
101
197
  const snapshot = queue.splice(0, queue.length);
@@ -162,9 +258,9 @@ function attachControlProtocol(server, opts, pendingAcquires) {
162
258
  // every other connection and from the server itself (T-01.6.2-06).
163
259
  });
164
260
  /**
165
- * CR-03 (code review 2026-08-13). THE per-connection ownership predicate
166
- * every target-naming op is gated on -- the same rule `recycle` has
167
- * enforced since T-01.6.2-31, now shared rather than copied.
261
+ * THE per-connection ownership predicate every target-naming op is
262
+ * gated on -- the same rule `recycle` has enforced since this
263
+ * protocol's earliest version, now shared rather than copied.
168
264
  *
169
265
  * Before this existed, `monitor_claim`/`monitor_release` took `target_id`
170
266
  * from the request and passed it straight through, so any connection
@@ -176,8 +272,8 @@ function attachControlProtocol(server, opts, pendingAcquires) {
176
272
  * session B could lock session A out of its own monitor socket, or
177
273
  * RELEASE A's live claim, after which a third client was free to dial the
178
274
  * same single-client binmon socket. That is precisely the unserviced-
179
- * backlog state D-13 exists to prevent and that CLAUDE.md says must never
180
- * be reachable.
275
+ * backlog state this ownership check exists to prevent and that
276
+ * CLAUDE.md says must never be reachable.
181
277
  *
182
278
  * WHAT NOT TO DO: never add another op that acts on a caller-supplied
183
279
  * `target_id` without gating it here first. The grant a connection holds
@@ -194,10 +290,9 @@ function attachControlProtocol(server, opts, pendingAcquires) {
194
290
  * `drainPendingAcquires()` drives, so the two paths can never answer
195
291
  * differently for the same requestId.
196
292
  *
197
- * Gap closure (plan 14, WR-03/T-01.6.2-87/-88): two destroyed-socket
198
- * checks guard a grant against outliving the connection that owns it,
199
- * and they bound TWO DIFFERENT failures -- do not conflate them into one
200
- * claim.
293
+ * Two destroyed-socket checks guard a grant against outliving the
294
+ * connection that owns it, and they bound TWO DIFFERENT failures -- do
295
+ * not conflate them into one claim.
201
296
  *
202
297
  * Half one -- the pre-check immediately below, BEFORE onAcquire() is
203
298
  * ever called -- closes the ALWAYS-REACHABLE leak: a client that
@@ -217,7 +312,7 @@ function attachControlProtocol(server, opts, pendingAcquires) {
217
312
  * callback with the same request id instead of silently dropping the
218
313
  * grant it produced.
219
314
  */
220
- function attemptAcquire(requestId) {
315
+ function attemptAcquire(requestId, profile) {
221
316
  // Half one: a queued entry whose owning socket is already gone is
222
317
  // settled immediately, WITHOUT ever calling onAcquire() -- this is
223
318
  // what keeps a retried drain pass from performing a real, ownerless
@@ -225,7 +320,12 @@ function attachControlProtocol(server, opts, pendingAcquires) {
225
320
  if (socket.destroyed)
226
321
  return Promise.resolve(true);
227
322
  return opts
228
- .onAcquire(requestId)
323
+ // The profile is threaded through THIS shared helper, which both
324
+ // the immediate first attempt and every later
325
+ // drainPendingAcquires() retry go through -- so a request that
326
+ // queued behind an in-flight launch is retried later with the
327
+ // profile it was MADE with, never with a profile-less one.
328
+ .onAcquire(requestId, profile)
229
329
  .then((outcome) => {
230
330
  if (outcome.ok) {
231
331
  // Half two: the pre-check above ran before this call; a
@@ -245,6 +345,14 @@ function attachControlProtocol(server, opts, pendingAcquires) {
245
345
  url: outcome.grant.url,
246
346
  epoch_file: outcome.grant.epochFile,
247
347
  supervisor_dir: outcome.grant.supervisorDir,
348
+ // Key omitted entirely when absent -- the fork case only now.
349
+ // A stock grant whose second (text-monitor) port allocation
350
+ // failed never reaches this line at all:
351
+ // acquirePortAndLaunch() fails the WHOLE acquire
352
+ // (`no_free_text_port`) before any grant is produced, so
353
+ // "absent" no longer needs to cover that case. Never a
354
+ // fabricated 0 or null standing in for "no port".
355
+ ...(outcome.grant.remoteMonitorPort === undefined ? {} : { remote_monitor_port: outcome.grant.remoteMonitorPort }),
248
356
  });
249
357
  return true;
250
358
  }
@@ -289,11 +397,51 @@ function attachControlProtocol(server, opts, pendingAcquires) {
289
397
  socket.destroy();
290
398
  return;
291
399
  }
292
- if (req.op === "acquire") {
400
+ // Dispatched FIRST in the chain, before "acquire" -- so the ordering
401
+ // reads clearly. Dispatch here is on EXACT STRING EQUALITY, never
402
+ // fallthrough, so branch order does not itself change which requests
403
+ // reach attemptAcquire() -- what actually makes this branch unable to
404
+ // touch lease state is that opts.onHostTool is its OWN callback (see
405
+ // StartControlListenerOptions' own comment), never composed from
406
+ // onAcquire/onRelease/onRecycle/onStatus/onHostState/onMonitorClaim/
407
+ // onMonitorRelease.
408
+ if (req.op === "host_tool") {
409
+ opts
410
+ .onHostTool(req)
411
+ .then((result) => {
412
+ if (!socket.destroyed)
413
+ writeHostToolLine(socket, result);
414
+ })
415
+ .catch(() => {
416
+ if (!socket.destroyed) {
417
+ writeLine(socket, { kind: "error", code: "internal", message: "host_tool threw" });
418
+ }
419
+ });
420
+ }
421
+ else if (req.op === "acquire") {
293
422
  const requestId = typeof req.id === "string" && req.id !== "" ? req.id : defaultRequestId("req");
294
- void attemptAcquire(requestId).then((settled) => {
423
+ // Narrow BEFORE attemptAcquire, so a malformed profile never
424
+ // reaches onAcquire and therefore never reaches the port allocator,
425
+ // a spawn, or argv construction. A refusal also does NOT enqueue --
426
+ // the request is answered and dropped, never retried on a later
427
+ // drain pass with the same bad shape.
428
+ const normalised = normaliseLaunchProfile(req.profile);
429
+ if (!normalised.ok) {
430
+ writeLine(socket, { kind: "error", code: "bad_request", message: normalised.message });
431
+ return;
432
+ }
433
+ const profile = normalised.profile;
434
+ // A profile-is-stock-only refusal used to live here: it refused
435
+ // `profile.warp`/`profile.headless` when this broker's resolved
436
+ // backend had no `-warp`/`-console` route at all, so a caller
437
+ // learned a knob would be silently ignored rather than getting a
438
+ // confident grant with no effect. Now that the fork backend is
439
+ // gone, there is one backend and it always has that route, so the
440
+ // condition this refused can no longer occur -- deleted rather than
441
+ // left as a check against a value that can never disagree.
442
+ void attemptAcquire(requestId, profile).then((settled) => {
295
443
  if (!settled) {
296
- enqueueAcquire(pendingAcquires, { requestId, attempt: () => attemptAcquire(requestId) });
444
+ enqueueAcquire(pendingAcquires, { requestId, attempt: () => attemptAcquire(requestId, profile) });
297
445
  }
298
446
  });
299
447
  }
@@ -313,7 +461,7 @@ function attachControlProtocol(server, opts, pendingAcquires) {
313
461
  // called, so a mismatched target never reaches the kill discipline
314
462
  // and never signals anything -- an injected signal recorder stays
315
463
  // empty for this case. Now expressed through the SAME ownsTarget()
316
- // predicate monitor_claim/monitor_release use (CR-03), so the three
464
+ // predicate monitor_claim/monitor_release use, so the three
317
465
  // target-naming ops cannot drift apart.
318
466
  if (!ownsTarget(targetId)) {
319
467
  writeLine(socket, {
@@ -354,7 +502,6 @@ function attachControlProtocol(server, opts, pendingAcquires) {
354
502
  started_at: hs.startedAt,
355
503
  node_version: hs.nodeVersion,
356
504
  vice_bin: hs.viceBin,
357
- warm_floor: hs.warmFloor,
358
505
  max_instances: hs.maxInstances,
359
506
  base_port: hs.basePort,
360
507
  backend: hs.backend,
@@ -370,29 +517,39 @@ function attachControlProtocol(server, opts, pendingAcquires) {
370
517
  writeLine(socket, { kind: "error", code: "denied", message: MONITOR_OWNERSHIP_DENIAL });
371
518
  return;
372
519
  }
520
+ const channel = resolveMonitorChannel(req.channel);
521
+ if (channel === "bad_request") {
522
+ writeLine(socket, {
523
+ kind: "error",
524
+ code: "bad_request",
525
+ message: `monitor_claim: unrecognised channel ${JSON.stringify(req.channel)} -- accepted values are "binary" and "text"`,
526
+ });
527
+ return;
528
+ }
373
529
  const requestId = typeof req.id === "string" && req.id !== "" ? req.id : defaultRequestId("claim");
374
- const outcome = opts.onMonitorClaim(requestId, targetId);
530
+ const outcome = opts.onMonitorClaim(requestId, targetId, channel);
375
531
  if (outcome.ok) {
376
532
  writeLine(socket, { kind: "monitor_claimed" });
377
533
  }
378
534
  else if (outcome.code === "monitor_owned") {
379
- // Ownership conflict, named by holder -- deliberately worded to
380
- // never suggest the emulator itself has stopped answering
381
- // (T-02-18; the plan's own grep gate polices this).
535
+ // Ownership conflict, named by holder AND channel -- deliberately
536
+ // worded to never suggest the emulator itself has stopped
537
+ // answering.
382
538
  //
383
- // WR-08 (broker side): `holder` is REQUIRED by MonitorClaimOutcome for
539
+ // `holder` is REQUIRED by MonitorClaimOutcome for
384
540
  // this code, but this handler runs inside socket.on("data") with no
385
541
  // try/catch above it, so a producer that ever omitted it would throw a
386
542
  // TypeError out of the control listener and take the broker process
387
543
  // with it -- a type contract is not a runtime guarantee at a wire
388
544
  // boundary. The fallback names the holder as unknown rather than
389
- // fabricating one, matching what the container-side client now does
390
- // with a malformed holder payload.
391
- const holder = outcome.holder ?? { grantId: "unknown", claimedAt: 0, pid: null };
545
+ // fabricating one (matching what the container-side client now does
546
+ // with a malformed holder payload), and defaults `channel` to the
547
+ // channel THIS request asked for -- never a fabricated third value.
548
+ const holder = outcome.holder ?? { grantId: "unknown", claimedAt: 0, pid: null, channel };
392
549
  writeLine(socket, {
393
550
  kind: "error",
394
551
  code: "monitor_owned",
395
- message: `instance already has a monitor client (grant ${holder.grantId}, claimed at ${holder.claimedAt}) -- this is an ownership conflict, not an emulator failure`,
552
+ message: `instance already has a monitor client on the ${holder.channel} channel (grant ${holder.grantId}, claimed at ${holder.claimedAt}) -- this is an ownership conflict, not an emulator failure`,
396
553
  holder,
397
554
  });
398
555
  }
@@ -410,8 +567,17 @@ function attachControlProtocol(server, opts, pendingAcquires) {
410
567
  writeLine(socket, { kind: "error", code: "denied", message: MONITOR_OWNERSHIP_DENIAL });
411
568
  return;
412
569
  }
570
+ const channel = resolveMonitorChannel(req.channel);
571
+ if (channel === "bad_request") {
572
+ writeLine(socket, {
573
+ kind: "error",
574
+ code: "bad_request",
575
+ message: `monitor_release: unrecognised channel ${JSON.stringify(req.channel)} -- accepted values are "binary" and "text"`,
576
+ });
577
+ return;
578
+ }
413
579
  const requestId = typeof req.id === "string" && req.id !== "" ? req.id : defaultRequestId("release-monitor");
414
- const outcome = opts.onMonitorRelease(requestId, targetId);
580
+ const outcome = opts.onMonitorRelease(requestId, targetId, channel);
415
581
  if (outcome.ok) {
416
582
  writeLine(socket, { kind: "monitor_released" });
417
583
  }
@@ -2,17 +2,16 @@
2
2
  // Compiled by `tsc` from broker-epoch.mts. Edit the TypeScript source and rebuild;
3
3
  // changes made directly to this file are silently overwritten by the next build, and are never
4
4
  // deployed to the host on their own -- install-resources.mjs copies THIS file's on-disk contents
5
- // verbatim to tools/, so an edit made only here reaches the host but is lost on the very next
5
+ // verbatim to .c64-re-tools/bin/, so an edit made only here reaches the host but is lost on the very next
6
6
  // rebuild.
7
7
  // broker-epoch.mts
8
8
  //
9
- // B / D-04: the per-instance epoch.json writer, held to the frozen
10
- // eight-field contract captured in fixtures/ (task 1, before the bash
11
- // writer that produced them is deleted later in this phase). Ports
12
- // write_epoch()'s exact field shape and its atomic tmp-sibling-then-rename
13
- // discipline -- the tmp file is created empty, mode tightened to
14
- // owner-read-write BEFORE any content reaches it, content written, then
15
- // renamed -- matching writeBrokerRecord()'s own choke point in
9
+ // The per-instance epoch.json writer, held to the frozen eight-field
10
+ // contract captured in fixtures/ before the bash writer that produced them
11
+ // was deleted. Ports write_epoch()'s exact field shape and its atomic
12
+ // tmp-sibling-then-rename discipline -- the tmp file is created empty, mode
13
+ // tightened to owner-read-write BEFORE any content reaches it, content
14
+ // written, then renamed -- matching writeBrokerRecord()'s own choke point in
16
15
  // vice-broker.mts exactly.
17
16
  //
18
17
  // Plan 03, Task 1 completes this module: the path derivations
@@ -2,11 +2,11 @@
2
2
  // Compiled by `tsc` from broker-kill.mts. Edit the TypeScript source and rebuild;
3
3
  // changes made directly to this file are silently overwritten by the next build, and are never
4
4
  // deployed to the host on their own -- install-resources.mjs copies THIS file's on-disk contents
5
- // verbatim to tools/, so an edit made only here reaches the host but is lost on the very next
5
+ // verbatim to .c64-re-tools/bin/, so an edit made only here reaches the host but is lost on the very next
6
6
  // rebuild.
7
7
  // broker-kill.mts
8
8
  //
9
- // D (complete, this plan -- 01.6.2-04): the identity-verified kill discipline,
9
+ // The identity-verified kill discipline,
10
10
  // ported from resources/vice-broker.sh's signal_recorded_pid()/
11
11
  // signal_vice_child_pid(): zero-signal liveness check, identity check against
12
12
  // the process's own argument string, SIGTERM, poll-then-SIGKILL. The
@@ -30,13 +30,13 @@
30
30
  // unconditionally (kill-never-recycle). The uncatchable signals (SIGKILL,
31
31
  // SIGSTOP) are deliberately unhandled -- see registerShutdownHandlers()'s
32
32
  // own comment.
33
- // - reapOrphanedInstances(): the unconditional startup reap (criterion I,
34
- // D-15) that reaches instances this broker process has no in-memory
33
+ // - reapOrphanedInstances(): the unconditional startup reap that reaches
34
+ // instances this broker process has no in-memory
35
35
  // record of, derived from the emulator port band plus this broker's OWN
36
36
  // on-disk allocation record (epoch.json) -- never a host process
37
- // listing or a scan of another process's argv (02-03-PLAN.md/D-14/D-15;
37
+ // listing or a scan of another process's argv --
38
38
  // see reapOrphanedInstances()'s own header comment for the incident
39
- // this revision closes).
39
+ // this revision closes.
40
40
  import { execFileSync } from "node:child_process";
41
41
  import { readFileSync, readdirSync } from "node:fs";
42
42
  import { join } from "node:path";
@@ -80,7 +80,7 @@ function defaultLog(line) {
80
80
  * signal_vice_child_pid() do. An empty/null/non-positive pid, or a pid
81
81
  * already gone, returns "already_exited" without ever signalling -- "the
82
82
  * machine being gone is the goal", per the bash version's own comment. An
83
- * EMPTY expectedIdentity is REFUSED outright (CR-04 -- see the guard's own
83
+ * EMPTY expectedIdentity is REFUSED outright (see the guard's own
84
84
  * comment below), as is a live pid whose OWN argument string does not contain
85
85
  * expectedIdentity: both return "identity_refused" without ever signalling,
86
86
  * the one outcome a caller must be able to tell apart from every other stage
@@ -99,7 +99,7 @@ export async function verifiedKill({ pid, expectedIdentity, deps = {} }) {
99
99
  if (!isAlive(pid)) {
100
100
  return "already_exited";
101
101
  }
102
- // CR-04 (code review 2026-08-13): an EMPTY expectedIdentity REFUSES, it
102
+ // An EMPTY expectedIdentity REFUSES, it
103
103
  // never permits. `"".includes` is vacuously satisfied by every process's
104
104
  // argv, so the guard below was unconditionally true for the empty string --
105
105
  // which disabled it entirely and let the caller SIGTERM (then SIGKILL)
@@ -294,18 +294,19 @@ export function registerShutdownHandlers(deps) {
294
294
  }
295
295
  };
296
296
  }
297
- /** D-25's mandatory start-time banner: printed unconditionally, before the
297
+ /** The mandatory start-time banner: printed unconditionally, before the
298
298
  * control listener begins accepting, naming exactly what a keyboard
299
299
  * interrupt or a closed terminal destroys. On 2026-08-02 a `^C` produced
300
300
  * "reap saw 4 recorded instance(s), terminated 4" and killed a live
301
301
  * session -- the incident was not caused by missing machinery, it was
302
302
  * caused by nobody being told. Detaching stays the operator's own
303
- * nohup/setsid/systemd choice (D-25) -- this banner names that choice
303
+ * nohup/setsid/systemd choice -- this banner names that choice
304
304
  * rather than offering a flag; the launcher stays thin.
305
305
  *
306
- * D-25/P-13 (01.6.2.1-05-PLAN.md): the one place naming the retired
307
- * warm-floor environment variable does not weaken D-10/D-11's clean break --
308
- * the line added below reports the variable's mere PRESENCE, never its
306
+ * The one place naming the retired
307
+ * warm-floor environment variable does not weaken the clean break made when
308
+ * the warm floor was retired -- the line added below reports the variable's
309
+ * mere PRESENCE, never its
309
310
  * value, and no reader anywhere in this broker still consults it (the
310
311
  * structural gate in broker-kill.test.ts proves that). Without it, an
311
312
  * operator with the retired variable set in a shell profile would silently
@@ -324,27 +325,30 @@ export function startupBanner() {
324
325
  "vice-broker: to run this broker outside the current terminal session, use your own",
325
326
  "vice-broker: nohup/setsid/systemd -- this launcher does not offer a --detach flag.",
326
327
  ];
327
- if (process.env.VICE_BROKER_SPARES !== undefined) { // banner-only presence check (D-25/P-13) -- never reads the value
328
- lines.push("vice-broker: NOTE -- the VICE_BROKER_SPARES environment variable is set and is IGNORED; it was retired with no alias or fallback. Use VICE_BROKER_WARM_FLOOR instead.");
328
+ if (process.env.VICE_BROKER_SPARES !== undefined) { // banner-only presence check -- never reads the value
329
+ lines.push(
330
+ // This note's own former "use the warm-floor
331
+ // knob instead" replacement is ITSELF retired along with the warm
332
+ // floor -- pointing an operator at a second dead knob would be worse
333
+ // than pointing at none. VICE now launches strictly on demand, on the
334
+ // first request; there is no warming knob left to name.
335
+ "vice-broker: NOTE -- the VICE_BROKER_SPARES environment variable is set and is IGNORED; it was retired with no alias or fallback. There is no replacement -- VICE now launches strictly on demand, on the first request.");
329
336
  }
330
337
  return lines.join("\n");
331
338
  }
332
339
  // ============================================================================
333
- // Startup reap: unconditional, file-free... but no longer PROCESS-TABLE-free
334
- // (criterion I, D-15, as revised by 02-03-PLAN.md/D-14/D-15).
340
+ // Startup reap: unconditional, file-free... but no longer PROCESS-TABLE-free.
335
341
  //
336
- // 02-03-PLAN.md (BROK-03) retires this section's entire former identity
337
- // mechanism -- the two functions it lived in are gone from this tree
342
+ // This section's entire former identity
343
+ // mechanism is retired -- the two functions it lived in are gone from this tree
338
344
  // outright, not merely unused -- which used to select kill targets by
339
345
  // scanning EVERY host process's own argument string for a plain substring
340
346
  // match on the configured emulator binary path, gated only by "some bare
341
- // integer token >= basePort appears somewhere in that same string" -- folded
342
- // todo
343
- // `.planning/todos/pending/2026-08-12-broker-orphan-reap-substring-identity-match.md`,
347
+ // integer token >= basePort appears somewhere in that same string". This was
344
348
  // observed killing two unrelated orchestrator shell processes on a
345
349
  // developer's host (a long scratchpad path supplied the qualifying integer;
346
- // a short VICE_BIN like `/bin/sleep` supplied the substring). D-15 replaces
347
- // that heuristic with the broker's OWN allocation record: this reap now
350
+ // a short VICE_BIN like `/bin/sleep` supplied the substring). The replacement
351
+ // uses the broker's OWN allocation record: this reap now
348
352
  // enumerates the instance directories under `stateDir` (which THIS broker,
349
353
  // or a same-machine predecessor, created) and kills only the pid each
350
354
  // directory's own epoch.json actually recorded launching. A host process
@@ -424,8 +428,9 @@ function bumpEpochForInstanceDir(deps, stateDir, port) {
424
428
  };
425
429
  deps.writeEpochRecord({ supervisorDir, record });
426
430
  }
427
- /** The unconditional startup reap (criterion I, D-15, kill-target identity
428
- * revised by 02-03-PLAN.md/D-14/D-15). Runs on every broker start, before the
431
+ /** The unconditional startup reap (kill-target identity
432
+ * revised to use the broker's own allocation record instead of a process-table
433
+ * scan). Runs on every broker start, before the
429
434
  * control listener accepts and before anything is launched -- unconditional
430
435
  * because a broker killed with SIGKILL never runs a shutdown path, so "was
431
436
  * the last shutdown clean" is unanswerable, and a marker file recording that
@@ -435,14 +440,14 @@ function bumpEpochForInstanceDir(deps, stateDir, port) {
435
440
  * Enumerates the on-disk instance directories under `stateDir` in the
436
441
  * allocation band (`port >= basePort` -- the 6510-6599 range below it stays
437
442
  * reserved by convention for an emulator a human launched for their own
438
- * work, D-18), and for each one reads its OWN `epoch.json` -- never a host
443
+ * work), and for each one reads its OWN `epoch.json` -- never a host
439
444
  * process listing, never an argv scan. A directory whose record is absent,
440
445
  * unparseable, or carries no finite positive `pid` contributes nothing to
441
446
  * `found`/`killed` and is skipped by the kill half entirely, but the epoch
442
447
  * bump below still runs for it: a registry-free restart must still void
443
448
  * every in-band instance directory it finds, including one it has no usable
444
- * pid for, which is the exact case this seed
445
- * (.planning/seeds/broker-restart-reaps-and-voids.md) flags -- the void has
449
+ * pid for, which is the exact case a registry-free restart must handle --
450
+ * the void has
446
451
  * to reach instances a registry-free restart never heard of. A record that
447
452
  * DOES carry a usable pid AND a non-empty `vice_bin` is killed via
448
453
  * verifiedKill() with `expectedIdentity` set to THAT record's own `vice_bin`
@@ -450,7 +455,7 @@ function bumpEpochForInstanceDir(deps, stateDir, port) {
450
455
  * does not match what THIS broker itself recorded launching there is refused
451
456
  * (`identity_refused`), exactly like every other verifiedKill() call site in
452
457
  * this module. A record carrying a usable pid but NO `vice_bin` is NOT a kill
453
- * candidate at all (CR-04): it contributes nothing to `found`/`killed`, the
458
+ * candidate at all: it contributes nothing to `found`/`killed`, the
454
459
  * kill dep is never invoked, and only the epoch bump runs -- an unidentifiable
455
460
  * pid is refused, never killed on the strength of the pid alone.
456
461
  *
@@ -478,7 +483,7 @@ export async function reapOrphanedInstances(options) {
478
483
  if (typeof pid === "number" && Number.isFinite(pid) && pid > 0) {
479
484
  const expectedIdentity = typeof epochFields?.vice_bin === "string" ? epochFields.vice_bin : "";
480
485
  if (expectedIdentity === "") {
481
- // CR-04: a record with a usable pid but NO recorded identity is not a
486
+ // A record with a usable pid but NO recorded identity is not a
482
487
  // kill candidate at all -- it is not counted in `found` and the kill
483
488
  // dep is never invoked. verifiedKill() refuses an empty identity too
484
489
  // (second layer, deliberately: removing either leaves the other