@henols/vice-mcp 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.md +2 -2
  2. package/THIRD-PARTY-NOTICES.md +422 -1
  3. package/anno-bank.ts +171 -0
  4. package/anno-cli.ts +1674 -99
  5. package/anno-enum-gen.ts +416 -30
  6. package/anno-export-asm.ts +1175 -89
  7. package/anno-graphics.ts +338 -0
  8. package/anno-hazard-report.ts +1367 -0
  9. package/anno-import.ts +495 -0
  10. package/anno-join.ts +480 -0
  11. package/anno-provenance-ledger.ts +472 -0
  12. package/anno-register.ts +159 -0
  13. package/anno-store-export.ts +661 -0
  14. package/anno-store.ts +518 -2
  15. package/anno-tools.ts +1169 -16
  16. package/anno-types.ts +275 -2
  17. package/backend-detect.mts +124 -312
  18. package/build.ts +3 -1
  19. package/capture-predicate.ts +597 -0
  20. package/channel-lock.ts +349 -0
  21. package/evid-ingest.ts +217 -0
  22. package/evid-reconcile.ts +316 -0
  23. package/host-tool-client.ts +430 -0
  24. package/incident-record.ts +23 -12
  25. package/install-resources.ts +29 -13
  26. package/memmap-lookup.ts +285 -0
  27. package/package.json +27 -8
  28. package/prg-image.ts +1 -2
  29. package/repo-root.ts +87 -3
  30. package/resources/backend-detect.mjs +98 -236
  31. package/resources/broker-control.mjs +189 -16
  32. package/resources/broker-epoch.mjs +1 -1
  33. package/resources/broker-kill.mjs +8 -2
  34. package/resources/broker-launch.mjs +365 -210
  35. package/resources/broker-state.mjs +64 -18
  36. package/resources/container-guard.mjs +1 -1
  37. package/resources/ghidra-project.mjs +790 -0
  38. package/resources/host-tool.mjs +2561 -0
  39. package/resources/vice-broker.mjs +330 -184
  40. package/resources/vice-launcher.sh +127 -9
  41. package/stock-address.ts +1 -1
  42. package/stock-condition.ts +1 -1
  43. package/stock-connect.ts +9 -5
  44. package/stock-derived.ts +29 -37
  45. package/stock-diagnose.ts +200 -36
  46. package/stock-dispatch.ts +179 -77
  47. package/stock-handler.ts +1 -1
  48. package/stock-paths.ts +18 -14
  49. package/stock-petscii.ts +1 -1
  50. package/stock-protocol.ts +1 -1
  51. package/stock-recycle.ts +83 -2
  52. package/stock-reproducible-run.ts +811 -0
  53. package/stock-run-until.ts +100 -1
  54. package/stock-symbols.ts +4 -4
  55. package/stock-timing.ts +1 -1
  56. package/stop-oracle.ts +167 -0
  57. package/text-capability-probe.ts +660 -0
  58. package/text-connect.ts +157 -0
  59. package/text-protocol.ts +810 -0
  60. package/text-tools.ts +778 -0
  61. package/textmon-backtrace.ts +385 -0
  62. package/textmon-cpuhistory.ts +335 -0
  63. package/textmon-memmap.ts +494 -0
  64. package/textmon-profile.ts +458 -0
  65. package/textmon-registers.ts +748 -0
  66. package/tools-manifest.stock.json +864 -3
  67. package/vice-broker-client.ts +189 -42
  68. package/vice-errors.ts +268 -0
  69. package/vice-proxy.ts +339 -2144
  70. package/vsf-slice.ts +640 -0
  71. package/anno-d64.ts +0 -310
  72. package/capability-registry.ts +0 -390
  73. package/refresh-manifest.ts +0 -124
  74. package/tools-manifest.json +0 -1223
  75. package/vice-probe.ts +0 -278
  76. package/vice-sync.ts +0 -336
  77. package/vice.ts +0 -772
@@ -21,16 +21,25 @@
21
21
  // never-cache-a-negative-result" section.
22
22
  //
23
23
  // MUST NOT import hostpath.ts: the host-path consumer set is closed to
24
- // exactly five production modules (containerpath.ts, install-resources.ts,
25
- // stock-paths.ts, vice-proxy.ts, vice-sync.ts), pinned by
26
- // hostpath-consumers.test.ts, and host-path message text stays in
27
- // vice-proxy.ts, which is already on that list.
24
+ // exactly four production modules (containerpath.ts, install-resources.ts,
25
+ // stock-paths.ts, vice-proxy.ts), pinned by hostpath-consumers.test.ts, and
26
+ // host-path message text stays in vice-proxy.ts, which is already on that
27
+ // list.
28
28
  import { readFileSync } from "node:fs";
29
29
  import { randomUUID } from "node:crypto";
30
30
  import { join, resolve } from "node:path";
31
31
  import { connect, type Socket } from "node:net";
32
32
 
33
33
  import { supervisorDir } from "./repo-root.ts";
34
+ // Phase 33, plan 33-06 (D-15): TYPE-ONLY, and IMPORTED rather than
35
+ // redeclared. broker-launch.mts is the one definition of the profile shape and
36
+ // the module that turns a profile into argv; a second local shape here is how
37
+ // a client would start requesting a knob the host cannot honour. Type-only, so
38
+ // the container-side bundle never resolves the host-bound module at runtime.
39
+ import type { LaunchProfile } from "./broker-launch.mts";
40
+ // backend-detect.mts is ViceBackend's one home (narrowed to a single literal
41
+ // by FORKRM-01, plan 52-06). Type-only, same discipline as the import above.
42
+ import type { ViceBackend } from "./backend-detect.mts";
34
43
  // The module tree's ONE definition of the container-visible host alias
35
44
  // (vice.ts:49), carrying its own VICE_MCP_HOST override -- consumed below by
36
45
  // resolveControlTarget() rather than a fourth `host.docker.internal` literal
@@ -44,7 +53,7 @@ import { supervisorDir } from "./repo-root.ts";
44
53
  // and importing it would pull `hostpath.ts` into this module, which this
45
54
  // file's own header (lines 23-26) forbids and which the host-path
46
55
  // consumer-set assertion polices.
47
- import { mcpHost, ViceError } from "./vice.ts";
56
+ import { mcpHost, ViceError } from "./vice-errors.ts";
48
57
 
49
58
  // -------------------------------------------------------------- request ids
50
59
  //
@@ -301,6 +310,24 @@ export interface AcquireGrant {
301
310
  url: string;
302
311
  epoch_file: string;
303
312
  supervisor_dir: string;
313
+ /** Plan 41-01 (D-15); made mandatory-in-fact by plan 41-05 (D-16): the
314
+ * broker-allocated port stock's `-remotemonitor` text monitor binds.
315
+ * Absent on a fork grant only -- a stock grant that could not bind a
316
+ * text-monitor port no longer reaches the wire at all: the acquire fails
317
+ * outright (`no_free_text_port`) before any grant is produced. */
318
+ remote_monitor_port?: number;
319
+ }
320
+
321
+ /** Plan 41-01: parses the wire's `remote_monitor_port` into a validated
322
+ * integer in 1..65535, or `undefined` when the key is absent OR the observed
323
+ * value is not a valid port -- never a fabricated 0/null standing in for
324
+ * "no port", and never an unvalidated number handed downstream to a dial.
325
+ * Shared by both AcquireGrant construction sites below so the same
326
+ * validation cannot drift between them. */
327
+ function parseOptionalRemoteMonitorPort(value: unknown): number | undefined {
328
+ if (value === undefined) return undefined;
329
+ const n = Number(value);
330
+ return Number.isInteger(n) && n >= 1 && n <= 65535 ? n : undefined;
304
331
  }
305
332
 
306
333
  export interface AcquireOverControlPlaneHandle {
@@ -327,13 +354,61 @@ export interface AcquireOverControlPlaneHandle {
327
354
  * client's generic timeout. */
328
355
  export const CONTROL_ACQUIRE_TIMEOUT_MS: number = Number(process.env.VICE_BROKER_ACQUIRE_TIMEOUT_MS || 120000);
329
356
 
357
+ /** Phase 33, plan 33-06 (REPRO-05, D-15): the request-side launch profile.
358
+ * Optional and absent by default at BOTH acquire write sites in this file.
359
+ *
360
+ * ONE RULE, and it is the whole reason this shape is named rather than
361
+ * inlined: the `profile` key must be OMITTED ENTIRELY when no profile was
362
+ * requested, not written as `profile: undefined`. `JSON.stringify` drops an
363
+ * `undefined` value, so both spellings happen to produce the same bytes today
364
+ * -- but an explicit `null` or `{}` would not, and the property this phase
365
+ * needs is that a profile-less acquire's wire line is BYTE-IDENTICAL to the
366
+ * one this client has always written. The spread idiom below is what makes
367
+ * that structural rather than incidental.
368
+ *
369
+ * The SECOND rule, which cost this plan its own dedicated must-have: the
370
+ * profile has to be written at BOTH sites. This file has two independent
371
+ * acquire writers -- acquireOverControlPlane()'s raw `socket.write` below and
372
+ * openBrokerControl()'s `sendAndAwaitLine` further down. A field added to only
373
+ * one of them silently never arrives for callers on the other path, which is
374
+ * the same defect class as a tool argument that is accepted and dropped.
375
+ *
376
+ * CONSUMER STATUS: SUBSTRATE, NOT YET WIRED (33 review WR-08). The profile
377
+ * threads client -> wire -> narrowing -> eligibility -> argv -> record with
378
+ * tests at every hop, but as of phase 33 NO production call site passes one:
379
+ * `acquireOverControlPlane()` and `BrokerControlSession.acquire()` are only
380
+ * ever invoked without `opts.profile`, so `-warp` and `-console` are
381
+ * unreachable in production. That is deliberate -- phase 33 built the chain
382
+ * ahead of phases 34-38 using it -- and is recorded here rather than left for
383
+ * a reader to discover, because a fully-tested chain reads as a live one.
384
+ *
385
+ * Do NOT close this by inventing a call site. Note also that the profile is
386
+ * refused outright on the fork backend (33 review WR-03, broker-control.mts):
387
+ * it maps to stock-only launch flags, so the first real consumer has to be on
388
+ * stock. */
389
+ export interface AcquireProfileOptions {
390
+ profile?: LaunchProfile;
391
+ }
392
+
393
+ /** Builds the `profile` fragment of an acquire request line -- the ONE place
394
+ * this client decides whether the key appears at all, so the two write sites
395
+ * cannot drift apart on that decision. Returns an empty object (no key) when
396
+ * no profile was requested. */
397
+ function acquireProfileFragment(profile?: LaunchProfile): { profile?: LaunchProfile } {
398
+ return profile === undefined ? {} : { profile };
399
+ }
400
+
330
401
  /** Reads broker.json ONCE for control_host/control_port/control_token,
331
402
  * opens ONE TCP connection, sends a single `acquire` request framed as one
332
403
  * JSON line, and awaits the grant line against
333
404
  * CONTROL_ACQUIRE_TIMEOUT_MS. Rejects (never throws synchronously) on any
334
405
  * failure: broker.json absent/unreadable/missing the control fields, a
335
- * connection error, an `error` response, or a timeout. */
336
- export function acquireOverControlPlane(dir: string = brokerRootDir()): Promise<AcquireOverControlPlaneHandle> {
406
+ * connection error, an `error` response, or a timeout.
407
+ *
408
+ * Phase 33, plan 33-06: takes an optional `profile` (see
409
+ * AcquireProfileOptions above). Omitting it writes the exact wire line this
410
+ * function has always written. */
411
+ export function acquireOverControlPlane(dir: string = brokerRootDir(), opts: AcquireProfileOptions = {}): Promise<AcquireOverControlPlaneHandle> {
337
412
  return new Promise((resolvePromise, reject) => {
338
413
  const broker = readJsonMaybe(brokerJsonPath(dir));
339
414
  if (broker === null) {
@@ -369,7 +444,11 @@ export function acquireOverControlPlane(dir: string = brokerRootDir()): Promise<
369
444
 
370
445
  socket.on("connect", () => {
371
446
  const requestId = newRequestId();
372
- socket.write(`${JSON.stringify({ op: "acquire", id: requestId, token })}\n`);
447
+ // Phase 33, plan 33-06: write site ONE of two (see
448
+ // acquireProfileFragment()'s own comment) -- the key is absent entirely
449
+ // when no profile was requested, so this line stays byte-identical to
450
+ // what it always was for a profile-less acquire.
451
+ socket.write(`${JSON.stringify({ op: "acquire", id: requestId, token, ...acquireProfileFragment(opts.profile) })}\n`);
373
452
  });
374
453
 
375
454
  socket.on("data", (chunk: Buffer) => {
@@ -400,12 +479,14 @@ export function acquireOverControlPlane(dir: string = brokerRootDir()): Promise<
400
479
  if (resp.kind === "grant") {
401
480
  settled = true;
402
481
  clearTimeout(timer);
482
+ const remoteMonitorPort = parseOptionalRemoteMonitorPort(resp.remote_monitor_port);
403
483
  const grant: AcquireGrant = {
404
484
  id: String(resp.id),
405
485
  port: Number(resp.port),
406
486
  url: String(resp.url),
407
487
  epoch_file: String(resp.epoch_file),
408
488
  supervisor_dir: String(resp.supervisor_dir),
489
+ ...(remoteMonitorPort === undefined ? {} : { remote_monitor_port: remoteMonitorPort }),
409
490
  };
410
491
  resolvePromise({
411
492
  grant,
@@ -550,37 +631,56 @@ interface ControlHostStateFields {
550
631
  warm_floor: number;
551
632
  max_instances: number;
552
633
  base_port: number;
553
- /** WR-04: the backend verdict the BROKER resolved -- the authoritative one,
554
- * since it is what decided the emulator's launch argv. `null` when the broker
555
- * predates this field or sent something unrecognised: absent evidence, kept
556
- * strictly distinct from a definite disagreement, so a mismatch check can
557
- * refuse only on the latter. */
558
- backend: "fork" | "stock" | null;
634
+ /** FORKRM-01 (plan 52-06): narrowed from `"fork" | "stock" | null` to
635
+ * `ViceBackend | null` -- `null` when the broker predates this field or
636
+ * sent something unrecognised: absent evidence, kept distinct from a
637
+ * definite value. text-tools.ts's own broker-identity cross-check (out of
638
+ * this plan's scope) still reads this field. */
639
+ backend: ViceBackend | null;
559
640
  }
560
641
 
561
642
  export type ControlHostStateResult =
562
643
  | { ok: true; hostState: ControlHostStateFields }
563
644
  | { ok: false; kind: ControlFailureKind; message: string };
564
645
 
565
- /** Plan 05 (BROK-02/PROTO-08, D-13): the current monitor-socket holder's own
566
- * identity, named in a `monitor_owned` refusal -- field-for-field the same
567
- * shape the broker's own MonitorHolder carries (broker-control.mts), minus
568
- * nothing (pid included, matching GrantRecord's own convention this whole
569
- * mechanism mirrors). */
646
+ // ---------------------------------------------------------------------------
647
+ // MonitorClaimChannel (plan 41-03, D-14): the two-value channel contract,
648
+ // declared HERE as a local literal union rather than imported from
649
+ // broker-state.mts -- that module is host-bound and compiled into
650
+ // resources/*.mjs, and this file is the container-side half. The shared
651
+ // thing between the declarations is the CONTRACT ("binary" | "text"), not
652
+ // the declaration itself -- channel-lock.ts's own MonitorChannel and
653
+ // broker-state.mts's own MonitorChannel each declare it separately for the
654
+ // same reason.
655
+ // ---------------------------------------------------------------------------
656
+ export type MonitorClaimChannel = "binary" | "text";
657
+
658
+ /** Plan 05 (BROK-02/PROTO-08, D-13; gains `channel` in plan 41-03, D-14): the
659
+ * current monitor-socket holder's own identity, named in a `monitor_owned`
660
+ * refusal -- field-for-field the same shape the broker's own MonitorHolder
661
+ * carries (broker-control.mts), minus nothing (pid included, matching
662
+ * GrantRecord's own convention this whole mechanism mirrors). */
570
663
  export interface MonitorClaimHolder {
571
664
  grantId: string;
572
665
  claimedAt: number;
573
666
  pid: number | null;
667
+ channel: MonitorClaimChannel;
574
668
  }
575
669
 
576
670
  export interface ClaimMonitorOptions {
577
671
  targetId: string;
578
672
  timeoutMs?: number;
673
+ /** Plan 41-03 (D-14): which monitor socket to claim. Omitted is
674
+ * byte-identical to `"binary"` -- every pre-41-03 call site (and every
675
+ * broker that predates this field) keeps working unchanged. */
676
+ channel?: MonitorClaimChannel;
579
677
  }
580
678
 
581
679
  export interface ReleaseMonitorOptions {
582
680
  targetId: string;
583
681
  timeoutMs?: number;
682
+ /** Plan 41-03 (D-14): same default-to-binary posture as ClaimMonitorOptions.channel. */
683
+ channel?: MonitorClaimChannel;
584
684
  }
585
685
 
586
686
  /** Discriminated claim outcome (plan 05): `monitor_owned` is kept STRICTLY
@@ -607,6 +707,9 @@ export interface MonitorOwnershipErrorOptions {
607
707
  holderGrantId?: string;
608
708
  holderClaimedAt?: number;
609
709
  port?: number;
710
+ /** Plan 41-03 (D-14): which socket is contended -- so a handshake failure
711
+ * can say which channel was refused without re-parsing the message. */
712
+ channel?: MonitorClaimChannel;
610
713
  }
611
714
 
612
715
  /** Thrown (by a caller that prefers to raise rather than branch on
@@ -628,13 +731,15 @@ export class MonitorOwnershipError extends ViceError {
628
731
  holderGrantId?: string;
629
732
  holderClaimedAt?: number;
630
733
  port?: number;
734
+ channel?: MonitorClaimChannel;
631
735
 
632
- constructor(message: string, { holderGrantId, holderClaimedAt, port }: MonitorOwnershipErrorOptions = {}) {
736
+ constructor(message: string, { holderGrantId, holderClaimedAt, port, channel }: MonitorOwnershipErrorOptions = {}) {
633
737
  super(message);
634
738
  this.name = "MonitorOwnershipError";
635
739
  this.holderGrantId = holderGrantId;
636
740
  this.holderClaimedAt = holderClaimedAt;
637
741
  this.port = port;
742
+ this.channel = channel;
638
743
  }
639
744
  }
640
745
 
@@ -654,7 +759,10 @@ export interface ControlDeadlineOptions {
654
759
  * exactly one request line and resolves against its own deadline; none of
655
760
  * them ever reject. */
656
761
  export interface BrokerControlSession {
657
- acquire(opts?: ControlDeadlineOptions): Promise<ControlAcquireResult>;
762
+ /** Phase 33, plan 33-06 (REPRO-05, D-15): additionally takes an optional
763
+ * `profile` -- see AcquireProfileOptions. Omitting it is byte-identical to
764
+ * every pre-33-06 call. */
765
+ acquire(opts?: ControlDeadlineOptions & AcquireProfileOptions): Promise<ControlAcquireResult>;
658
766
  release(): Promise<ControlReleaseResult>;
659
767
  recycle(targetId: string, opts?: ControlDeadlineOptions): Promise<ControlRecycleResult>;
660
768
  status(opts?: ControlDeadlineOptions): Promise<ControlStatusResult>;
@@ -719,6 +827,21 @@ export interface HeldLease {
719
827
  * backend-detect.mts's own documented degradation for an omitted
720
828
  * supervisorDir. */
721
829
  supervisorDir: string;
830
+ /** Plan 41-01 (D-15); made mandatory-in-fact by plan 41-05 (D-16): THIS
831
+ * instance's own text-monitor port, read by text-connect.ts's
832
+ * textConnect() to dial the `-remotemonitor` channel. MANDATORY on a
833
+ * stock grant, ABSENT on a fork grant -- the fork never launches with
834
+ * `-remotemonitor` and advertises no text tools. Its absence on a stock
835
+ * lease is a real defect, not a tolerated state (mirrors epochFile's own
836
+ * "NOT optional" discipline above): the mechanism that makes this true is
837
+ * broker-launch.mts's acquirePortAndLaunch(), which now FAILS THE WHOLE
838
+ * ACQUIRE when the text-port allocation fails (`no_free_text_port`)
839
+ * rather than degrading to a portless launch -- there is no longer a code
840
+ * path that produces a stock grant, and therefore a HeldLease, without
841
+ * this field. Its optionality here is a transitional TypeScript
842
+ * convenience only (the fork case is real), never a semantic "sometimes
843
+ * missing on stock". */
844
+ remoteMonitorPort?: number;
722
845
  }
723
846
 
724
847
  /** One in-flight request's settlement callback -- pushed onto the session's
@@ -746,12 +869,17 @@ type RawLineOutcome = { ok: true; line: Record<string, unknown> } | { ok: false;
746
869
  /** Never-throw extraction of a `holder` payload from untrusted wire input --
747
870
  * absent or malformed input answers `undefined`, never a partially-filled
748
871
  * object (plan 05's own never-throw-on-untrusted-input posture, matching
749
- * this file's own header comment on broker.json reads). */
750
- function extractHolder(raw: unknown): MonitorClaimHolder | undefined {
872
+ * this file's own header comment on broker.json reads). `channel` (plan
873
+ * 41-03, D-14) defaults to `requestedChannel` -- THE channel this request
874
+ * itself named -- when the wire omits it or sends something unrecognised;
875
+ * never fabricated as a plausible value, in the same register the
876
+ * `grantId: "unknown"` fallback one layer up (claimMonitor()'s own) uses. */
877
+ function extractHolder(raw: unknown, requestedChannel: MonitorClaimChannel): MonitorClaimHolder | undefined {
751
878
  if (typeof raw !== "object" || raw === null) return undefined;
752
879
  const h = raw as Record<string, unknown>;
753
880
  if (typeof h.grantId !== "string" || typeof h.claimedAt !== "number") return undefined;
754
- return { grantId: h.grantId, claimedAt: h.claimedAt, pid: typeof h.pid === "number" ? h.pid : null };
881
+ const channel = h.channel === "text" || h.channel === "binary" ? h.channel : requestedChannel;
882
+ return { grantId: h.grantId, claimedAt: h.claimedAt, pid: typeof h.pid === "number" ? h.pid : null, channel };
755
883
  }
756
884
 
757
885
  /** Builds the session object wrapping an already-CONNECTED socket. Wires the
@@ -835,8 +963,13 @@ function createSession(socket: Socket, token: string): BrokerControlSession {
835
963
  // Plan 05: forward `holder` verbatim ONLY for monitor_owned --
836
964
  // every other error code carries no such field on the wire, and
837
965
  // extractHolder() itself never invents one from absent/malformed
838
- // input.
839
- const holder = code === "monitor_owned" ? extractHolder(line.holder) : undefined;
966
+ // input. Plan 41-03 (D-14): the requested channel comes from
967
+ // THIS payload (the request this response answers), read from
968
+ // the same closure `payload` sendAndAwaitLine() was called
969
+ // with -- an absent/malformed wire `channel` is never fabricated,
970
+ // it defaults to the channel this specific request itself named.
971
+ const requestedChannel: MonitorClaimChannel = payload.channel === "text" ? "text" : "binary";
972
+ const holder = code === "monitor_owned" ? extractHolder(line.holder, requestedChannel) : undefined;
840
973
  resolvePromise({
841
974
  ok: false,
842
975
  kind: code,
@@ -862,20 +995,29 @@ function createSession(socket: Socket, token: string): BrokerControlSession {
862
995
  });
863
996
  }
864
997
 
865
- async function acquire(opts: ControlDeadlineOptions = {}): Promise<ControlAcquireResult> {
998
+ async function acquire(opts: ControlDeadlineOptions & AcquireProfileOptions = {}): Promise<ControlAcquireResult> {
866
999
  const requestId = newRequestId();
867
- const raw = await sendAndAwaitLine({ op: "acquire", id: requestId, token }, opts.timeoutMs ?? ACQUIRE_TIMEOUT_MS);
1000
+ // Phase 33, plan 33-06: write site TWO of two (see
1001
+ // acquireProfileFragment()'s own comment for why both matter) -- same
1002
+ // key-omitted-when-absent discipline as acquireOverControlPlane()'s raw
1003
+ // socket.write above.
1004
+ const raw = await sendAndAwaitLine(
1005
+ { op: "acquire", id: requestId, token, ...acquireProfileFragment(opts.profile) },
1006
+ opts.timeoutMs ?? ACQUIRE_TIMEOUT_MS,
1007
+ );
868
1008
  if (!raw.ok) return raw;
869
1009
  const line = raw.line;
870
1010
  if (line.kind !== "grant") {
871
1011
  return { ok: false, kind: "protocol", message: `openBrokerControl: acquire got unexpected response kind ${String(line.kind)}` };
872
1012
  }
1013
+ const remoteMonitorPort = parseOptionalRemoteMonitorPort(line.remote_monitor_port);
873
1014
  const grant: AcquireGrant = {
874
1015
  id: String(line.id),
875
1016
  port: Number(line.port),
876
1017
  url: String(line.url),
877
1018
  epoch_file: String(line.epoch_file),
878
1019
  supervisor_dir: String(line.supervisor_dir),
1020
+ ...(remoteMonitorPort === undefined ? {} : { remote_monitor_port: remoteMonitorPort }),
879
1021
  };
880
1022
  return { ok: true, grant };
881
1023
  }
@@ -957,16 +1099,17 @@ function createSession(socket: Socket, token: string): BrokerControlSession {
957
1099
  warm_floor: Number(line.warm_floor),
958
1100
  max_instances: Number(line.max_instances),
959
1101
  base_port: Number(line.base_port),
960
- // WR-04: narrowed at the boundary, never cast -- anything other than the
961
- // two known verdicts reads as `null` ("this broker did not tell us"),
962
- // which callers must treat as absent evidence rather than agreement.
963
- backend: line.backend === "fork" || line.backend === "stock" ? line.backend : null,
1102
+ // FORKRM-01 (plan 52-06): narrowed at the boundary, never cast --
1103
+ // anything other than the one known verdict reads as `null` ("this
1104
+ // broker did not tell us"), which callers must treat as absent
1105
+ // evidence rather than agreement.
1106
+ backend: line.backend === "stock" ? line.backend : null,
964
1107
  },
965
1108
  };
966
1109
  }
967
1110
 
968
1111
  /** Claims exclusive ownership of `opts.targetId`'s monitor socket, sending
969
- * `{ op: "monitor_claim", id, target_id, token }` through the SAME
1112
+ * `{ op: "monitor_claim", id, target_id, channel, token }` through the SAME
970
1113
  * `sendAndAwaitLine()` path -- the same session, the same token, the same
971
1114
  * newline-delimited JSON discipline every other op uses; no second
972
1115
  * control connection is ever opened, and this function never dials the
@@ -974,10 +1117,12 @@ function createSession(socket: Socket, token: string): BrokerControlSession {
974
1117
  * -- see MonitorOwnershipError's own header comment for why the claim is
975
1118
  * made BEFORE any binmon connect()). `timeout` is reported distinctly
976
1119
  * from `monitor_owned`: a timeout means the broker did not answer, never
977
- * that someone else owns the socket. */
1120
+ * that someone else owns the socket. `channel` (plan 41-03, D-14) defaults
1121
+ * to `"binary"` when omitted -- byte-identical to every pre-41-03 call. */
978
1122
  async function claimMonitor(opts: ClaimMonitorOptions): Promise<ClaimMonitorOutcome> {
979
1123
  const requestId = newRequestId();
980
- const raw = await sendAndAwaitLine({ op: "monitor_claim", id: requestId, target_id: opts.targetId, token }, opts.timeoutMs ?? ACQUIRE_TIMEOUT_MS);
1124
+ const channel: MonitorClaimChannel = opts.channel ?? "binary";
1125
+ const raw = await sendAndAwaitLine({ op: "monitor_claim", id: requestId, target_id: opts.targetId, channel, token }, opts.timeoutMs ?? ACQUIRE_TIMEOUT_MS);
981
1126
  if (!raw.ok) {
982
1127
  if (raw.kind === "deadline") return { ok: false, reason: "timeout" };
983
1128
  // WR-08: the `monitor_owned` REASON survives even when the wire's own
@@ -989,11 +1134,11 @@ function createSession(socket: Socket, token: string): BrokerControlSession {
989
1134
  // requires (and MonitorOwnershipError exists to preserve) was lost. The
990
1135
  // broker has told us WHICH state this is; not being able to name the
991
1136
  // holder does not make it a different state. Holder fields default to
992
- // "unknown"/0/null so the wording still reads as an ownership conflict
993
- // rather than an emulator fault -- never fabricated as a plausible grant
994
- // id, which would be worse than admitting it is unknown.
1137
+ // "unknown"/0/null/`channel` so the wording still reads as an ownership
1138
+ // conflict rather than an emulator fault -- never fabricated as a
1139
+ // plausible grant id, which would be worse than admitting it is unknown.
995
1140
  if (raw.kind === "monitor_owned") {
996
- return { ok: false, reason: "monitor_owned", holder: raw.holder ?? { grantId: "unknown", claimedAt: 0, pid: null } };
1141
+ return { ok: false, reason: "monitor_owned", holder: raw.holder ?? { grantId: "unknown", claimedAt: 0, pid: null, channel } };
997
1142
  }
998
1143
  if (raw.kind === "unauthorized" || raw.kind === "bad_request" || raw.kind === "denied") return { ok: false, reason: raw.kind };
999
1144
  return { ok: false, reason: "internal" };
@@ -1005,14 +1150,16 @@ function createSession(socket: Socket, token: string): BrokerControlSession {
1005
1150
  }
1006
1151
 
1007
1152
  /** Releases a previously claimed monitor socket, sending
1008
- * `{ op: "monitor_release", id, target_id, token }` over the SAME
1153
+ * `{ op: "monitor_release", id, target_id, channel, token }` over the SAME
1009
1154
  * session. Tolerates a broker that has already cleared the record (the
1010
1155
  * broker's own onMonitorRelease answers `ok: true` for an already-cleared
1011
1156
  * target) -- this function never retries and never opens a second
1012
- * connection. */
1157
+ * connection. `channel` (plan 41-03, D-14) defaults to `"binary"` when
1158
+ * omitted. */
1013
1159
  async function releaseMonitor(opts: ReleaseMonitorOptions): Promise<ReleaseMonitorOutcome> {
1014
1160
  const requestId = newRequestId();
1015
- const raw = await sendAndAwaitLine({ op: "monitor_release", id: requestId, target_id: opts.targetId, token }, opts.timeoutMs ?? ACQUIRE_TIMEOUT_MS);
1161
+ const channel: MonitorClaimChannel = opts.channel ?? "binary";
1162
+ const raw = await sendAndAwaitLine({ op: "monitor_release", id: requestId, target_id: opts.targetId, channel, token }, opts.timeoutMs ?? ACQUIRE_TIMEOUT_MS);
1016
1163
  if (!raw.ok) {
1017
1164
  if (raw.kind === "deadline") return { ok: false, reason: "timeout" };
1018
1165
  if (raw.kind === "unauthorized" || raw.kind === "bad_request" || raw.kind === "denied") return { ok: false, reason: raw.kind };