@henols/vice-mcp 0.1.9 → 0.1.11

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.
@@ -29,8 +29,17 @@ import { join, basename, resolve as resolvePath } from "node:path";
29
29
  import { fileURLToPath } from "node:url";
30
30
  import { spawn as nodeSpawn } from "node:child_process";
31
31
  import { containerGuardReport, containerGuardEnforce } from "./container-guard.mjs";
32
- import { createBrokerState, nextFreePort, countReady, countTotal, countLaunching, atCapacity, resolveBasePort, } from "./broker-state.mjs";
33
- import { acquirePortAndLaunch, maintainWarmFloor, probeReady, runBrokerPass, withCrashSupervision, } from "./broker-launch.mjs";
32
+ import { createBrokerState, nextFreePort, countReady, countTotal, countLaunching, atCapacity, resolveBasePort, clearMonitorClient, } from "./broker-state.mjs";
33
+ import { acquirePortAndLaunch, deleteInstanceRecord, maintainWarmFloor, probeReady, runBrokerPass, withCrashSupervision, } from "./broker-launch.mjs";
34
+ // Plan 02-07: resolvedBackend() is now the ONE reader of VICE_BACKEND in
35
+ // this tree -- ViceBackend's own definition moved to backend-detect.mts too,
36
+ // so broker-launch.mjs's own (type-only) re-import of it and this file's
37
+ // VALUE import both name the same one home. A real value import is safe
38
+ // here (unlike inside broker-launch.mts) because vice-broker.mts is ALWAYS
39
+ // run from its own compiled resources/ form -- both modules are compiled
40
+ // together in the same build.ts pass, so "./backend-detect.mjs" always
41
+ // exists as a real sibling file by the time this import resolves.
42
+ import { resolvedBackend } from "./backend-detect.mjs";
34
43
  import { verifiedKill, registerShutdownHandlers, startupBanner, reapOrphanedInstances } from "./broker-kill.mjs";
35
44
  import { writeEpochRecord, epochPathFor, nextEpochFor, instanceLogDirFor } from "./broker-epoch.mjs";
36
45
  import { startControlListener, newControlToken, drainPendingAcquires, resolveControlPort, } from "./broker-control.mjs";
@@ -226,15 +235,45 @@ function writeEpochForLaunch(record, logRelPath) {
226
235
  * epoch record naming the wrong one. Leaving it unset means a respawn's
227
236
  * output lands in the supervision module's own log file under the same
228
237
  * per-instance logs directory D-23 requires, and the epoch record names
229
- * the file that actually received the output. */
230
- function superviseDepsFor(stateDir, state) {
238
+ * the file that actually received the output.
239
+ *
240
+ * CR-01 (03-REVIEW.md): `backend` is a REQUIRED positional parameter, not an
241
+ * optional field a call site may quietly omit. Before this, both real call
242
+ * sites built their deps here WITHOUT it, so `spawnAndRecordInstance()`'s own
243
+ * `deps.backend ?? "fork"` default silently took over the moment crash
244
+ * supervision replaced an instance -- a stock instance's crash-respawn or
245
+ * `vice_recycle` relaunched it with the FORK's `-mcpserver` argv, which stock
246
+ * upstream VICE does not understand at all, leaving a pool member that can
247
+ * never be reached over the binary monitor again while still counting toward
248
+ * countReady()/countTotal(). Making it positional and required is what makes
249
+ * that omission a compile error rather than a silent backend swap: the FIRST
250
+ * launch and every REPLACEMENT of it now build their argv from the SAME
251
+ * resolved verdict. `binmonHost` is threaded for the same reason, one step
252
+ * ahead of need -- no broker call site configures a stock bind override today
253
+ * (acquirePortAndLaunch()'s own `binmonHost` is likewise unset), so it is
254
+ * always `undefined` in production right now; the parameter exists so that
255
+ * adding one later cannot reintroduce exactly this divergence between a
256
+ * launch's argv and its respawn's argv. */
257
+ function superviseDepsFor(stateDir, state, backend, binmonHost) {
231
258
  return {
232
259
  state,
233
260
  stateDir,
234
261
  epoch: { epochPathFor, instanceLogDirFor, nextEpochFor, writeEpochRecord },
235
262
  log: (line) => process.stderr.write(`${line}\n`),
263
+ backend,
264
+ binmonHost,
236
265
  };
237
266
  }
267
+ /** Exported ONLY so a test can install withCrashSupervision() through the
268
+ * REAL deps object this module actually uses in production, rather than a
269
+ * hand-built SuperviseChildDeps that can (and did) diverge from it -- the
270
+ * exact blind spot CR-01 (03-REVIEW.md) lived in: broker-launch.test.ts's own
271
+ * respawn/recycle tests each construct their deps inline and therefore pass
272
+ * `backend: "stock"` directly, so the production builder's missing field was
273
+ * invisible to the whole suite. Same discipline as broker-kill.mts's
274
+ * `_HANDLED_SIGNALS`: an underscore-prefixed alias, never called by any
275
+ * production code path in this module. */
276
+ export const _superviseDepsFor = superviseDepsFor;
238
277
  /** Sets the deliberate-death marker and its respawn-after-kill answer
239
278
  * TOGETHER -- the single place in this module that ever writes either
240
279
  * field, so a call site can never set one and forget the other, which is
@@ -326,7 +365,11 @@ async function selectWarmInstance(state, deps) {
326
365
  // WR-02 only changes what happens to the kill's own PROMISE next, never
327
366
  // this ordering.
328
367
  markDeliberateDeath(record, false);
329
- state.instances.delete(record.port);
368
+ // CR-02 (03-REVIEW.md): dropping a record is also where its second
369
+ // (`-remotemonitor`) port stops being spoken for -- deleteInstanceRecord()
370
+ // is the ONE place both mutations happen together, so a drop can never
371
+ // leak a port out of the fixed allocation band.
372
+ deleteInstanceRecord(state, record.port);
330
373
  // Distinct wording from shutdown()'s own "shutdown complete" line
331
374
  // (broker-kill.mts) and from handleRecycleForRealBroker's own log-free
332
375
  // path -- D-07's standing constraint that a lifecycle decision must be
@@ -383,7 +426,11 @@ async function selectWarmInstance(state, deps) {
383
426
  * control.mts's own attemptAcquire()/enqueueAcquire() queue the request and
384
427
  * retry it later rather than refusing it. */
385
428
  export async function handleAcquire(requestId, stateDir, state, deps = {}) {
386
- const probe = deps.probe ?? ((port) => probeReady(port));
429
+ // WR-01: the readiness probe is backend-aware, from the SAME threaded-down
430
+ // verdict handleAcquire already uses for buildViceArgs() -- on stock the port
431
+ // speaks the binary monitor, so an HTTP POST there can never succeed.
432
+ const backend = deps.backend ?? "fork";
433
+ const probe = deps.probe ?? ((port) => probeReady(port, { backend }));
387
434
  // Textually a verifiedKill( call site, not merely a reference -- reused
388
435
  // UNCHANGED from broker-kill.mts (Phase 01.6.2 criterion 6), never
389
436
  // re-derived, and never replaced by a bare process.kill().
@@ -412,12 +459,18 @@ export async function handleAcquire(requestId, stateDir, state, deps = {}) {
412
459
  state,
413
460
  stateDir,
414
461
  allocatePort: nextFreePort,
462
+ // CR-01 (03-REVIEW.md): the SAME local `backend` const resolved at the
463
+ // top of this function feeds BOTH the initial argv (here) and the
464
+ // supervision deps below, so a crash-respawn of this instance can never
465
+ // build a different backend's argv than the launch it replaces.
466
+ backend,
467
+ allocateRemoteMonitorPort: deps.allocateRemoteMonitorPort,
415
468
  spawnFactory: deps.buildColdSpawnFactory ??
416
469
  ((port) => {
417
470
  const supervisorDir = join(stateDir, String(port));
418
471
  const { spawn, logRelPath } = makeLoggingSpawn(join(supervisorDir, "logs"));
419
472
  lastLogRelPath = logRelPath;
420
- return withCrashSupervision("acquire", port, spawn, superviseDepsFor(stateDir, state));
473
+ return withCrashSupervision("acquire", port, spawn, superviseDepsFor(stateDir, state, backend));
421
474
  }),
422
475
  });
423
476
  if (!result.ok) {
@@ -431,7 +484,10 @@ export async function handleAcquire(requestId, stateDir, state, deps = {}) {
431
484
  // toward countTotal()/atCapacity() until crash supervision's own
432
485
  // delayed respawn/give-up machinery eventually noticed and freed it,
433
486
  // even though the caller was already told "internal" right now.
434
- state.instances.delete(result.record.port);
487
+ // CR-02: deleteInstanceRecord(), not a bare map delete -- a stock launch
488
+ // that failed this way already had its second port allocated and
489
+ // blocked by acquirePortAndLaunch().
490
+ deleteInstanceRecord(state, result.record.port);
435
491
  return { ok: false, reason: "internal" };
436
492
  }
437
493
  record = result.record;
@@ -465,8 +521,66 @@ function handleStatus(state) {
465
521
  state: r.state,
466
522
  reason: r.reason,
467
523
  epoch: typeof r.epoch === "number" ? r.epoch : null,
524
+ hasMonitorClient: r.monitorClient !== undefined,
468
525
  }));
469
526
  }
527
+ /** Resolves a monitor_claim/monitor_release target the SAME way
528
+ * handleRelease() and handleRecycleForRealBroker() already resolve theirs:
529
+ * `targetId` is a grant id, looked up in state.grants for its port, then
530
+ * the instance at that port. Returns `null` for an unknown target_id/port
531
+ * so callers answer `bad_request`, never `internal` (plan 05's own
532
+ * acceptance criterion). */
533
+ function resolveInstanceForMonitorTarget(targetId, state) {
534
+ const grant = state.grants.get(targetId);
535
+ if (!grant)
536
+ return null;
537
+ return state.instances.get(grant.port) ?? null;
538
+ }
539
+ /** Answers `monitor_claim` (plan 05, BROK-02/PROTO-08, D-13): exclusive
540
+ * monitor-socket ownership enforced HERE, broker-side, so a conflicting
541
+ * claim is refused by name before any second `connect()` is ever attempted
542
+ * -- the one state stock VICE cannot report and no client-side heuristic
543
+ * can diagnose. `targetId` doubles as both "which instance" (resolved via
544
+ * the SAME grant lookup handleRelease()/handleRecycleForRealBroker() already
545
+ * use) and "the requesting grant's own identity" -- the claim IS the grant,
546
+ * so there is no separate identity to carry. A repeated claim from the SAME
547
+ * grant is idempotent (`ok: true`, no second holder created); a claim from
548
+ * a DIFFERENT grant while the instance already has a holder is refused,
549
+ * naming the current holder (T-02-18) -- never the emulator's own fault. */
550
+ export function handleMonitorClaim(requestId, targetId, state) {
551
+ void requestId; // correlation only -- the claim's own identity is targetId itself
552
+ const instance = resolveInstanceForMonitorTarget(targetId, state);
553
+ if (!instance)
554
+ return { ok: false, code: "bad_request" };
555
+ const existing = instance.monitorClient;
556
+ if (!existing) {
557
+ instance.monitorClient = { grantId: targetId, claimedAt: Date.now(), pid: instance.pid };
558
+ return { ok: true };
559
+ }
560
+ if (existing.grantId === targetId) {
561
+ return { ok: true }; // idempotent repeat from the SAME grant -- no second holder
562
+ }
563
+ return { ok: false, code: "monitor_owned", holder: { grantId: existing.grantId, claimedAt: existing.claimedAt, pid: existing.pid } };
564
+ }
565
+ /** Answers `monitor_release` (plan 05, T-02-01): clears `monitorClient` ONLY
566
+ * when `targetId` names the CURRENT holder -- a non-holder is refused, not
567
+ * silently accepted (spoofing a release is exactly T-02-01's own
568
+ * disposition). An instance with no current holder at all tolerates the
569
+ * release as a success, matching the container-side client's own documented
570
+ * tolerance for releasing a socket the broker already cleared. */
571
+ export function handleMonitorRelease(requestId, targetId, state) {
572
+ void requestId; // correlation only, matching handleMonitorClaim()'s own posture
573
+ const instance = resolveInstanceForMonitorTarget(targetId, state);
574
+ if (!instance)
575
+ return { ok: false, code: "bad_request" };
576
+ if (!instance.monitorClient)
577
+ return { ok: true }; // already cleared -- tolerated, not an error
578
+ if (instance.monitorClient.grantId !== targetId) {
579
+ return { ok: false, code: "denied" };
580
+ }
581
+ clearMonitorClient(instance);
582
+ return { ok: true };
583
+ }
470
584
  /** Resolves a recycle target's emulator child pid from THIS broker's own
471
585
  * in-memory instance record -- record.pid is, by construction, exactly the
472
586
  * same value broker-epoch.mts's writer puts in epoch.json's own `pid` field
@@ -530,6 +644,13 @@ async function handleRecycleForRealBroker(targetId, state) {
530
644
  }
531
645
  const epochBefore = typeof instance.epoch === "number" ? instance.epoch : null;
532
646
  markDeliberateDeath(instance, true);
647
+ // Plan 05: a recycle clears monitor-client ownership as a side effect --
648
+ // the respawned record the exit handler creates is a BRAND NEW
649
+ // InstanceRecord object (broker-launch.mts's spawnAndRecordInstance())
650
+ // that never carries this field forward regardless, but clearing it here
651
+ // too keeps the CURRENT (pre-kill) record's own state honest for the
652
+ // window between this call and that respawn.
653
+ clearMonitorClient(instance);
533
654
  const killStage = await verifiedKill({ pid: instance.pid, expectedIdentity: instance.expectedIdentity });
534
655
  const outcome = killStage === "identity_refused" ? "identity_refused" : "ok";
535
656
  const reason = killStage === "identity_refused" ? "process identity did not match the recorded emulator binary -- the target was NOT signalled and is still running" : "";
@@ -554,11 +675,12 @@ async function handleRecycleForRealBroker(targetId, state) {
554
675
  * because of invariants -- at most one launch per call, never invoked
555
676
  * concurrently with itself -- enforced elsewhere and never checked at the
556
677
  * point the variable used to be declared). */
557
- function maintainWarmFloorForRealBroker(stateDir, state) {
678
+ function maintainWarmFloorForRealBroker(stateDir, state, backend) {
558
679
  let lastWarmLaunchLogRelPath = "";
559
680
  return maintainWarmFloor({
560
681
  state,
561
682
  stateDir,
683
+ backend,
562
684
  spawnFactory: (port) => {
563
685
  const supervisorDir = join(stateDir, String(port));
564
686
  const { spawn, logRelPath } = makeLoggingSpawn(join(supervisorDir, "logs"));
@@ -574,10 +696,21 @@ function maintainWarmFloorForRealBroker(stateDir, state) {
574
696
  lastWarmLaunchLogRelPath = logRelPath;
575
697
  return child;
576
698
  };
577
- return withCrashSupervision("spare", port, stashingSpawn, superviseDepsFor(stateDir, state));
699
+ // CR-01 (03-REVIEW.md): the SAME resolved `backend` this function
700
+ // already receives for the launch argv is threaded into the supervision
701
+ // deps, so a warm instance's own crash-respawn stays on its backend.
702
+ return withCrashSupervision("spare", port, stashingSpawn, superviseDepsFor(stateDir, state, backend));
578
703
  },
579
- probe: (port) => probeReady(port),
704
+ // WR-01: same backend-aware probe route as handleAcquire's, from the SAME
705
+ // resolved verdict this function already receives for the launch argv.
706
+ probe: (port) => probeReady(port, { backend }),
580
707
  allocatePort: nextFreePort,
708
+ // Plan 03-04 (DIRECT-06, D-13): same wiring as handleAcquire()'s own
709
+ // cold-launch arm -- acquirePortAndLaunch() (reached via
710
+ // maintainWarmFloor() below) gates the second allocation on
711
+ // `backend === "stock"` itself, so this function need not check the
712
+ // backend before passing it.
713
+ allocateRemoteMonitorPort: (s, exclude) => nextFreePort(s, { exclude }),
581
714
  countReady,
582
715
  countTotal,
583
716
  countLaunching,
@@ -631,8 +764,17 @@ export function handleRelease(requestId, state) {
631
764
  const instance = state.instances.get(grant.port);
632
765
  if (instance && instance.pid === grant.pid) {
633
766
  markDeliberateDeath(instance, false);
767
+ // Plan 05: releasing clears monitor-client ownership as a side effect
768
+ // -- redundant with the instance-map deletion two lines below (the
769
+ // WHOLE record, monitorClient included, is going away), but explicit
770
+ // for the same reason GrantRecord's own clearing is explicit here: the
771
+ // instance-map deletion is a Task-2-era invariant this task must not
772
+ // depend on silently continuing to hold.
773
+ clearMonitorClient(instance);
634
774
  state.grants.delete(requestId);
635
- state.instances.delete(grant.port);
775
+ // CR-02: kill-never-recycle means this instance is gone for good, so its
776
+ // second (`-remotemonitor`) port must go back to the allocator with it.
777
+ deleteInstanceRecord(state, grant.port);
636
778
  verifiedKill({ pid: instance.pid, expectedIdentity: instance.expectedIdentity }).catch(() => {
637
779
  // best-effort; nothing further to report on this path this task
638
780
  });
@@ -692,6 +834,23 @@ async function run(args) {
692
834
  nextEpochFor,
693
835
  writeEpochRecord,
694
836
  });
837
+ // Plan 02-07 (D-01, D-03): resolved ONCE here, after the unconditional
838
+ // startup reap and BEFORE the control listener binds -- never re-read per
839
+ // launch, and never called from inside broker-launch.mts's `inFlight`
840
+ // single-owner guard (this call sits entirely outside it; no launch is
841
+ // even possible yet at this point in run()). `supervisorDir: args.stateDir`
842
+ // is passed explicitly -- args.stateDir IS `.vice-supervisor` under this
843
+ // broker's own repo root (see parseArgs() above), so this is the SAME
844
+ // directory repo-root.ts's supervisorDir() would resolve to, without this
845
+ // host-bound module ever importing that container-side resolver directly
846
+ // (backend-detect.mts's own header comment explains why it cannot). An
847
+ // `indeterminate` outcome does not prevent the broker from starting: it
848
+ // logs its own note (backend-detect.mts) and this line proceeds with the
849
+ // "fork" answer resolvedBackend() already returns for that case -- the
850
+ // pre-Phase-2 behaviour every existing install already has.
851
+ const backendResult = resolvedBackend({ supervisorDir: args.stateDir });
852
+ const backend = backendResult.backend;
853
+ process.stderr.write(`vice-broker: backend "${backend}" (source: ${backendResult.source}, binary: ${backendResult.binPath})\n`);
695
854
  // D-18: the singleton guarantee holds only while the control port keeps its default -- two brokers deliberately configured onto different ports are two brokers, and no code prevents that.
696
855
  let listener;
697
856
  try {
@@ -699,10 +858,18 @@ async function run(args) {
699
858
  host: controlHost,
700
859
  port: controlPort,
701
860
  token,
702
- onAcquire: (requestId) => handleAcquire(requestId, args.stateDir, state),
861
+ onAcquire: (requestId) => handleAcquire(requestId, args.stateDir, state, {
862
+ backend,
863
+ // Plan 03-04 (DIRECT-06, D-13): threaded down to
864
+ // acquirePortAndLaunch()'s own gate (backend === "stock"); this
865
+ // callback does NOT re-read VICE_BACKEND itself.
866
+ allocateRemoteMonitorPort: (s, exclude) => nextFreePort(s, { exclude }),
867
+ }),
703
868
  onRelease: (requestId) => handleRelease(requestId, state),
704
869
  onRecycle: (targetId) => handleRecycleForRealBroker(targetId, state),
705
870
  onStatus: () => handleStatus(state),
871
+ onMonitorClaim: (requestId, targetId) => handleMonitorClaim(requestId, targetId, state),
872
+ onMonitorRelease: (requestId, targetId) => handleMonitorRelease(requestId, targetId, state),
706
873
  onHostState: () => ({
707
874
  pid: process.pid,
708
875
  startedAt,
@@ -711,6 +878,10 @@ async function run(args) {
711
878
  warmFloor: resolveWarmFloorForRecord(),
712
879
  maxInstances: resolveCeilingForRecord(),
713
880
  basePort: resolveBasePort(),
881
+ // WR-04: the verdict THIS process resolved once, at startup, above --
882
+ // the same one every launch argv is built from. Never a second
883
+ // resolvedBackend() call (backend-detect.mts's own prohibition).
884
+ backend,
714
885
  }),
715
886
  });
716
887
  }
@@ -811,7 +982,7 @@ async function run(args) {
811
982
  passInFlight = true;
812
983
  runBrokerPass({
813
984
  serveAcquires: () => drainPendingAcquires(listener.pendingAcquires),
814
- maintainWarmFloor: () => maintainWarmFloorForRealBroker(args.stateDir, state),
985
+ maintainWarmFloor: () => maintainWarmFloorForRealBroker(args.stateDir, state, backend),
815
986
  })
816
987
  .catch((e) => {
817
988
  process.stderr.write(`vice-broker: evaluation pass failed: ${e.message}\n`);
@@ -0,0 +1,219 @@
1
+ #!/usr/bin/env node
2
+ // stock-address.ts
3
+ //
4
+ // THE ONE address parser for the stock backend (D-04). Every stock handler
5
+ // that accepts an address or a byte count parses it through parseAddress()/
6
+ // parseByteCount() here -- never a second, re-derived regex or range check
7
+ // in a family module.
8
+ //
9
+ // WHY THIS FILE EXISTS: re-deriving an address parser per tool family is
10
+ // this codebase's own named anti-pattern ("re-deriving a cross-cutting seam
11
+ // locally") -- the memory and checkpoint families (Phase 3) both need
12
+ // identical decimal/$hex/0x parsing and identical 0..0xffff range
13
+ // enforcement, and D-04 requires a single pluggable symbol-resolution hook
14
+ // so Phase 5's symbol store (DERIV-04) can fill it later without every call
15
+ // site changing.
16
+ //
17
+ // WHAT NOT TO DO:
18
+ // - Never re-derive an address regex in a family module -- import
19
+ // parseAddress()/parseByteCount() from here instead.
20
+ // - Never treat a bare decimal string as hex here. This is the MCP
21
+ // argument surface an agent (or a caller) types a value into, not
22
+ // VICE's own condition lexer -- CLAUDE.md's "bare integer literals are
23
+ // hex by default" rule belongs to the checkpoint-condition emitter, not
24
+ // this parser. Do not conflate the two.
25
+ // - Never implement symbol resolution in Phase 3. setSymbolResolver() is
26
+ // a deliberately empty extension point until Phase 5's DERIV-04 symbol
27
+ // store installs a real one; the default here stays `null`.
28
+ // - Never add a second resolver holder. `nameFor` (address -> name,
29
+ // DISASM-06's first consumer, Phase 4) and `resolve` (name -> address,
30
+ // Phase 3) live on the SAME `SymbolResolver` object, read from the SAME
31
+ // module-level `symbolResolver` holder above. Phase 5's DERIV-04 store
32
+ // installs one object implementing both; a second holder or a
33
+ // re-derived address->name map in a family module would force it to
34
+ // install itself twice.
35
+ import { ViceError, type ViceErrorOptions } from "./vice.ts";
36
+
37
+ export interface SymbolResolver {
38
+ resolve(name: string): number | undefined;
39
+ /** The inverse direction (DISASM-06, Phase 4's first consumer of it) --
40
+ * optional so the Phase 3 default (`null`, no resolver installed at all)
41
+ * and every existing test fake stay valid without implementing it. */
42
+ nameFor?(address: number): string | undefined;
43
+ }
44
+
45
+ // The ONE module-level holder for the installed resolver. `null` in Phase 3
46
+ // -- no symbol resolution happens until a later phase installs one.
47
+ let symbolResolver: SymbolResolver | null = null;
48
+
49
+ /** The deliberately-empty extension point Phase 5's DERIV-04 symbol store
50
+ * fills. Passing `null` (the Phase 3 default) restores the "no symbol table
51
+ * loaded" refusal. */
52
+ export function setSymbolResolver(resolver: SymbolResolver | null): void {
53
+ symbolResolver = resolver;
54
+ }
55
+
56
+ /** DISASM-06's reverse lookup: address -> name, read from the SAME holder
57
+ * `parseAddress()` reads. Returns `undefined` -- never throws -- when no
58
+ * resolver is installed, or when the installed resolver has no `nameFor`
59
+ * (e.g. a Phase 3-era fake that only implements `resolve`). This is a
60
+ * client-side convenience read, not an MCP argument parse, so there is no
61
+ * "no symbol table is loaded" refusal here -- that wording belongs to
62
+ * `parseAddress()`'s own symbolic-name path; a caller wanting an explanatory
63
+ * note for its own answer uses `hasSymbolStore()` to decide that itself. */
64
+ export function symbolNameFor(address: number): string | undefined {
65
+ if (!symbolResolver || typeof symbolResolver.nameFor !== "function") {
66
+ return undefined;
67
+ }
68
+ return symbolResolver.nameFor(address);
69
+ }
70
+
71
+ /** True iff a resolver is installed AND it implements the reverse (`nameFor`)
72
+ * direction -- what D-14 reads to decide whether `show_symbols` is a
73
+ * no-op that SAYS SO on the answer, rather than the handler guessing from an
74
+ * empty/undefined result (which could equally mean "store installed, but no
75
+ * name for this particular address"). */
76
+ export function hasSymbolStore(): boolean {
77
+ return symbolResolver !== null && typeof symbolResolver.nameFor === "function";
78
+ }
79
+
80
+ /** The one address/byte-count error type this module ever throws -- never a
81
+ * bare Error, matching vice.ts's established ViceError hierarchy. */
82
+ export class StockAddressError extends ViceError {
83
+ constructor(message: string, options: ViceErrorOptions = {}) {
84
+ super(message, options);
85
+ this.name = "StockAddressError";
86
+ }
87
+ }
88
+
89
+ /** A bare word that COULD be a symbol name -- checked only after every
90
+ * numeric form below has already failed to match, so a malformed numeric
91
+ * string (e.g. "0xzz") is refused as malformed, never misread as a
92
+ * candidate symbol. */
93
+ const SYMBOL_NAME_RE = /^[A-Za-z_][A-Za-z0-9_.]*$/;
94
+
95
+ function inAddressRange(value: number): boolean {
96
+ return Number.isInteger(value) && value >= 0 && value <= 0xffff;
97
+ }
98
+
99
+ /**
100
+ * Parses `input` into a 0..0xffff address (D-04). Accepted forms: a JS
101
+ * `number` in range; a `"$hex"` string (leading `$`, hex digits,
102
+ * case-insensitive); a `"0x"`/`"0X"` string; or a bare decimal string
103
+ * (`"4096"`) -- decimal here, deliberately, since this is the MCP argument
104
+ * surface, not VICE's own condition lexer where bare literals are hex.
105
+ * Surrounding whitespace is trimmed. A symbolic name (matching
106
+ * `/^[A-Za-z_][A-Za-z0-9_.]*$/`) refuses with "no symbol table is loaded"
107
+ * when no resolver is installed, or "not a known symbol" when a resolver IS
108
+ * installed but returns `undefined` -- never a parse/syntax error either
109
+ * way (D-04's explicit requirement).
110
+ */
111
+ export function parseAddress(input: unknown, opts: { what?: string } = {}): number {
112
+ const what = opts.what ?? "address";
113
+
114
+ if (typeof input === "number") {
115
+ if (!inAddressRange(input)) {
116
+ throw new StockAddressError(`${what}: ${input} is out of range -- expected an integer 0..65535 ($0000-$ffff)`);
117
+ }
118
+ return input;
119
+ }
120
+
121
+ if (typeof input !== "string") {
122
+ throw new StockAddressError(`${what}: expected a number, a decimal string, a "$hex" string, or a "0x" string, got ${typeof input}`);
123
+ }
124
+
125
+ const trimmed = input.trim();
126
+
127
+ if (trimmed === "") {
128
+ throw new StockAddressError(`${what}: empty string is not a valid address`);
129
+ }
130
+
131
+ if (trimmed.startsWith("$")) {
132
+ const hexPart = trimmed.slice(1);
133
+ if (hexPart === "" || !/^[0-9a-fA-F]+$/.test(hexPart)) {
134
+ throw new StockAddressError(`${what}: "${trimmed}" is not a valid "$hex" address -- expected "$" followed by hex digits, e.g. "$D019"`);
135
+ }
136
+ const value = parseInt(hexPart, 16);
137
+ if (!inAddressRange(value)) {
138
+ throw new StockAddressError(`${what}: "${trimmed}" (0x${value.toString(16)}) is out of range -- expected 0..65535 ($0000-$ffff)`);
139
+ }
140
+ return value;
141
+ }
142
+
143
+ if (/^0[xX]/.test(trimmed)) {
144
+ const hexPart = trimmed.slice(2);
145
+ if (hexPart === "" || !/^[0-9a-fA-F]+$/.test(hexPart)) {
146
+ throw new StockAddressError(`${what}: "${trimmed}" is not a valid "0x" address -- expected "0x" followed by hex digits, e.g. "0xD019"`);
147
+ }
148
+ const value = parseInt(hexPart, 16);
149
+ if (!inAddressRange(value)) {
150
+ throw new StockAddressError(`${what}: "${trimmed}" (0x${value.toString(16)}) is out of range -- expected 0..65535 ($0000-$ffff)`);
151
+ }
152
+ return value;
153
+ }
154
+
155
+ if (/^[0-9]+$/.test(trimmed)) {
156
+ const value = parseInt(trimmed, 10);
157
+ if (!inAddressRange(value)) {
158
+ throw new StockAddressError(`${what}: "${trimmed}" is out of range -- expected a decimal integer 0..65535`);
159
+ }
160
+ return value;
161
+ }
162
+
163
+ if (SYMBOL_NAME_RE.test(trimmed)) {
164
+ if (!symbolResolver) {
165
+ throw new StockAddressError(
166
+ `${what}: "${trimmed}" looks like a symbol name, but no symbol table is loaded -- use a numeric address ` +
167
+ `(decimal, "$hex", or "0x...") instead`,
168
+ );
169
+ }
170
+ const resolved = symbolResolver.resolve(trimmed);
171
+ if (resolved === undefined) {
172
+ throw new StockAddressError(`${what}: "${trimmed}" is not a known symbol`);
173
+ }
174
+ if (!inAddressRange(resolved)) {
175
+ throw new StockAddressError(`${what}: symbol "${trimmed}" resolved to ${resolved}, which is out of range 0..65535`);
176
+ }
177
+ return resolved;
178
+ }
179
+
180
+ throw new StockAddressError(`${what}: "${trimmed}" is not a valid address -- expected a decimal number, "$hex", "0x...", or a known symbol name`);
181
+ }
182
+
183
+ /**
184
+ * Parses a byte count through the same numeric forms as parseAddress() --
185
+ * decimal, "$hex", "0x..." -- never through the symbol path (a byte count
186
+ * is never symbolic). Refuses `0`, negatives, non-integers, and anything
187
+ * above `max` (default `0xffff`), so the `size`-style range check is not
188
+ * re-derived per family either.
189
+ */
190
+ export function parseByteCount(input: unknown, opts: { max?: number; what?: string } = {}): number {
191
+ const max = opts.max ?? 0xffff;
192
+ const what = opts.what ?? "byte count";
193
+
194
+ let value: number;
195
+ if (typeof input === "number") {
196
+ if (!Number.isInteger(input)) {
197
+ throw new StockAddressError(`${what}: ${input} is not an integer`);
198
+ }
199
+ value = input;
200
+ } else if (typeof input === "string") {
201
+ const trimmed = input.trim();
202
+ if (/^\$[0-9a-fA-F]+$/.test(trimmed)) {
203
+ value = parseInt(trimmed.slice(1), 16);
204
+ } else if (/^0[xX][0-9a-fA-F]+$/.test(trimmed)) {
205
+ value = parseInt(trimmed.slice(2), 16);
206
+ } else if (/^[0-9]+$/.test(trimmed)) {
207
+ value = parseInt(trimmed, 10);
208
+ } else {
209
+ throw new StockAddressError(`${what}: "${trimmed}" is not a valid byte count -- expected a decimal number, "$hex", or "0x..."`);
210
+ }
211
+ } else {
212
+ throw new StockAddressError(`${what}: expected a number or a numeric string, got ${typeof input}`);
213
+ }
214
+
215
+ if (value <= 0 || value > max) {
216
+ throw new StockAddressError(`${what}: ${value} is out of range -- expected 1..${max}`);
217
+ }
218
+ return value;
219
+ }