@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.
@@ -0,0 +1,595 @@
1
+ // backend-detect.mts
2
+ //
3
+ // The ONE place that decides which VICE build a binary is, and the ONE
4
+ // reader of VICE_BACKEND anywhere in this tree (D-01). Everything else that
5
+ // needs to know "fork or stock" -- broker-launch.mts's buildViceArgs(),
6
+ // vice-broker.mts's startup wiring, and plan 02-08's later connect handshake
7
+ // -- calls resolvedBackend() below and threads its answer down, exactly like
8
+ // vice.ts's mcpHost() documents for its own container-versus-host question:
9
+ // a SECOND independent reader of the same signal is a bug waiting to
10
+ // happen the moment one copy is updated and the other is not (mcpHost()'s
11
+ // own "three inlined copies" incident is the exact regression this file
12
+ // exists to keep from recurring here).
13
+ //
14
+ // WHAT NOT TO DO:
15
+ // - Do not call resolvedBackend()/probeBackend() per acquire, per launch,
16
+ // or per connect. The broker resolves the backend exactly ONCE, at
17
+ // process startup (vice-broker.mts's run()), and passes the resolved
18
+ // value down through every real launch call site -- see this module's
19
+ // own module-level memo below, which exists as a second line of defence
20
+ // against an accidental extra call, not as the PRIMARY mechanism (that
21
+ // is the caller only ever invoking this once).
22
+ // - Do not call this from inside broker-launch.mts's `inFlight`
23
+ // single-owner launch guard. This is a possibly-blocking child-process
24
+ // spawn (probeBackend()'s --help probe); anything that can block inside
25
+ // that synchronous check-and-set window is the exact failure class the
26
+ // 2026-08-01 triple-launch outage came from (D-03/T-02-25).
27
+ // - Do not add a trial-launch fallback (launch the binary for real and
28
+ // watch what happens) as a second detection mechanism. The `--help`
29
+ // probe below runs entirely outside any launch-guarded critical section
30
+ // BY CONSTRUCTION -- a trial launch would not. It would also depend on
31
+ // RESEARCH.md's assumption A1 (stock's argument parser rejects an
32
+ // unknown flag rather than ignoring it), which is UNVERIFIED against a
33
+ // real stock binary; see docs/phase2-backend-probe-evidence.md.
34
+ //
35
+ // ENVIRONMENT CONSTRAINT (2026-08-13, explicit user scope override): no real
36
+ // stock or fork VICE binary is reachable from the environment this plan
37
+ // executed in, and the user's own ruling for this plan is "we can't do
38
+ // tests with deciding what vice is". Every test in backend-detect.test.ts
39
+ // therefore drives this module's OVERRIDE path, its on-disk CACHE lifecycle,
40
+ // and classifyHelpOutput()'s STRING-PARSING logic against fixture strings
41
+ // authored in the test file -- never a real spawned binary. The `--help`
42
+ // discriminator itself (does a real stock build's --help output actually
43
+ // contain "-binarymonitor" and omit "-mcpserver", the way classifyHelpOutput()
44
+ // below assumes) is recorded as an OPEN, not a VERIFIED, question in
45
+ // docs/phase2-backend-probe-evidence.md section 2 -- that document's verdict
46
+ // is deliberately left standing; nothing in this file's own tests attempts to
47
+ // resolve it, and no fixture string anywhere in this tree should ever be
48
+ // presented as real captured output from either build. See the follow-up
49
+ // todo tracked under .planning/todos/pending/ for what a real-hardware run
50
+ // must still confirm.
51
+ import { spawnSync } from "node:child_process";
52
+ import {
53
+ existsSync,
54
+ readFileSync,
55
+ writeFileSync,
56
+ chmodSync,
57
+ renameSync,
58
+ mkdirSync,
59
+ statSync,
60
+ } from "node:fs";
61
+ import { join, resolve as resolvePath } from "node:path";
62
+
63
+ /** Phase 2 (BROK-01, D-12, D-01): the two shapes this whole tree ever
64
+ * launches or speaks to. Moved here (plan 02-07) from broker-launch.mts's
65
+ * own plan-02-03 definition -- broker-launch.mts now imports/re-exports
66
+ * this one, so the type keeps exactly one home. */
67
+ export type ViceBackend = "fork" | "stock";
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // classifyHelpOutput() -- pure string classification, no I/O at all.
71
+ // ---------------------------------------------------------------------------
72
+
73
+ /** Matches on the literal flag tokens D-02 names as the discriminator: the
74
+ * fork's `-mcpserver` flag versus stock's `-binarymonitor`-only surface.
75
+ * `"fork"` wins when BOTH tokens appear (the fork's own VICE tree is a 3.10
76
+ * checkout and accepts both flags) -- checked FIRST, deliberately, so a
77
+ * build that advertises both is classified by the flag that actually makes
78
+ * it the fork, not merely "also has stock's flag too". `"unknown"` when
79
+ * NEITHER token appears -- a real --help transcript that does not match
80
+ * either shape, a probe that spawned nothing at all (empty text), or
81
+ * anything else this function was never taught to recognise. Never throws;
82
+ * pure function of the text it is given. */
83
+ export function classifyHelpOutput(text: string): "fork" | "stock" | "unknown" {
84
+ const hasFork = text.includes("-mcpserver");
85
+ const hasStock = text.includes("-binarymonitor");
86
+ if (hasFork) return "fork";
87
+ if (hasStock) return "stock";
88
+ return "unknown";
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // probeBackend() -- the --help probe. spawnSync only, argv array, shell:
93
+ // false, never a shell string and never string interpolation of binPath
94
+ // into a command line (T-02-03's mitigation). Bounded by a 5000ms timeout
95
+ // with kill-on-timeout so a hostile or hung binary cannot stall broker
96
+ // startup (T-02-25's second mitigation half).
97
+ // ---------------------------------------------------------------------------
98
+
99
+ const PROBE_TIMEOUT_MS = 5000;
100
+
101
+ /** `--help` first, falling back to `-help` then `-?` ONLY when a run exits
102
+ * non-zero with EMPTY combined output -- a run that exits non-zero but
103
+ * still printed something (some builds write usage to stderr and exit 1) is
104
+ * already usable and is not retried further. */
105
+ const HELP_FLAG_CANDIDATES: readonly string[] = ["--help", "-help", "-?"];
106
+
107
+ export interface SpawnHelpResult {
108
+ text: string;
109
+ exitedZero: boolean;
110
+ }
111
+
112
+ export interface ProbeBackendDeps {
113
+ /** Runs ONE candidate flag against `binPath` and returns its combined
114
+ * stdout+stderr text (VICE writes usage to either, depending on build)
115
+ * plus whether the process exited zero. Injected so no test in this tree
116
+ * ever spawns a real binary -- this environment has none, and per this
117
+ * file's own environment-constraint note above, no test may execute one.
118
+ * Defaults to a real, argv-array, shell:false spawnSync call bounded by
119
+ * PROBE_TIMEOUT_MS with kill-on-timeout; never throws -- a spawn failure
120
+ * (ENOENT, EACCES, a real timeout) collapses to `{ text: "", exitedZero:
121
+ * false }`, which classifyHelpOutput() reads as "unknown" like any other
122
+ * unrecognised output. */
123
+ spawnHelp?: (binPath: string, flag: string) => SpawnHelpResult;
124
+ }
125
+
126
+ function defaultSpawnHelp(binPath: string, flag: string): SpawnHelpResult {
127
+ try {
128
+ const result = spawnSync(binPath, [flag], {
129
+ encoding: "utf8",
130
+ timeout: PROBE_TIMEOUT_MS,
131
+ killSignal: "SIGKILL",
132
+ });
133
+ const text = `${result.stdout ?? ""}${result.stderr ?? ""}`;
134
+ return { text, exitedZero: result.status === 0 };
135
+ } catch {
136
+ return { text: "", exitedZero: false };
137
+ }
138
+ }
139
+
140
+ /** Runs the fallback ladder above against `binPath` and classifies whatever
141
+ * text the LAST attempted flag produced. Never throws -- every failure mode
142
+ * (spawn error, timeout, empty output, an exit code the caller does not
143
+ * recognise) flows through to classifyHelpOutput() as ordinary text, which
144
+ * itself never throws either. */
145
+ export function probeBackend(binPath: string, deps: ProbeBackendDeps = {}): "fork" | "stock" | "unknown" {
146
+ const spawnHelp = deps.spawnHelp ?? defaultSpawnHelp;
147
+ let text = "";
148
+ for (const flag of HELP_FLAG_CANDIDATES) {
149
+ const outcome = spawnHelp(binPath, flag);
150
+ text = outcome.text;
151
+ if (outcome.exitedZero || text.trim() !== "") break;
152
+ }
153
+ return classifyHelpOutput(text);
154
+ }
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // The on-disk cache -- `join(supervisorDir, "backend.json")`. `supervisorDir`
158
+ // is ALWAYS an explicit string this module receives from its caller, never a
159
+ // default this module derives itself: the one true resolver for "where is
160
+ // .vice-supervisor" is repo-root.ts's own supervisorDir() (ARCHITECTURE.md's
161
+ // named "re-deriving a cross-cutting seam locally" anti-pattern -- this file
162
+ // must not become a second, silently-driftable copy of that resolution).
163
+ // A container-side caller passes repo-root.ts's supervisorDir() return value
164
+ // directly; vice-broker.mts's own host-side wiring passes its already-
165
+ // resolved args.stateDir, which IS that same directory (see vice-broker.mts's
166
+ // own parseArgs()). This file cannot import repo-root.ts's VALUE as a static
167
+ // import and still compile as a host-bound artifact: repo-root.ts (and its
168
+ // own dependency install-resources.ts) use `.ts`-extension imports that only
169
+ // resolve under Node's native type-stripping, unbuilt -- exactly the mode a
170
+ // bare host running this module's COMPILED resources/backend-detect.mjs
171
+ // cannot rely on (this project's own standing constraint: "the host side
172
+ // cannot rely on Node's type-stripping the same way"). Passing the resolved
173
+ // string in, rather than importing the resolver, is what keeps this file
174
+ // importable UNBUILT from a container-side .ts (exactly like
175
+ // container-guard.mts's own precedent) AND compilable into resources/ for the
176
+ // host, from the SAME source, with no `#ifdef`-style split.
177
+ //
178
+ // When `supervisorDir` is omitted entirely, every cache read/write below is a
179
+ // no-op (a miss on read, silently skipped on write) -- this module still
180
+ // answers correctly (probe-and-memoise-in-process only), it just never
181
+ // persists an answer across process restarts. This is a graceful
182
+ // degradation, not an error: a caller that has not yet resolved a
183
+ // supervisor directory (or genuinely has none) gets a working, if
184
+ // unpersisted, answer rather than a thrown exception or a guessed path.
185
+ // ---------------------------------------------------------------------------
186
+
187
+ export interface BackendCacheRecord {
188
+ version: 1;
189
+ resolvedPath: string;
190
+ mtimeMs: number;
191
+ sizeBytes: number;
192
+ backend: ViceBackend;
193
+ probedAt: string;
194
+ /** BACK-04: filled in later, by a connect handshake (plan 02-08), never by
195
+ * this file's own probe -- the `--help` probe cannot observe a version
196
+ * quad; only a live VICE_INFO reply over an established connection can. */
197
+ versionQuad?: string;
198
+ cpuHistoryAvailable?: boolean;
199
+ }
200
+
201
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
202
+ return typeof value === "object" && value !== null && !Array.isArray(value);
203
+ }
204
+
205
+ function cachePathFor(supervisorDir: string): string {
206
+ return join(supervisorDir, "backend.json");
207
+ }
208
+
209
+ /** Reads and narrows the cache file at the boundary with isPlainObject()-
210
+ * style checks, never a cast -- absent, unreadable, unparseable, or
211
+ * wrong-shaped (missing/mistyped required fields) all collapse to `null`,
212
+ * treated identically as a cache MISS, never as an error this function
213
+ * surfaces to its caller. */
214
+ function readCacheRecord(supervisorDir: string): BackendCacheRecord | null {
215
+ let raw: string;
216
+ try {
217
+ raw = readFileSync(cachePathFor(supervisorDir), "utf8");
218
+ } catch {
219
+ return null;
220
+ }
221
+ let parsed: unknown;
222
+ try {
223
+ parsed = JSON.parse(raw);
224
+ } catch {
225
+ return null;
226
+ }
227
+ if (!isPlainObject(parsed)) return null;
228
+ if (
229
+ typeof parsed.resolvedPath !== "string" ||
230
+ typeof parsed.mtimeMs !== "number" ||
231
+ typeof parsed.sizeBytes !== "number" ||
232
+ (parsed.backend !== "fork" && parsed.backend !== "stock")
233
+ ) {
234
+ return null;
235
+ }
236
+ const record: BackendCacheRecord = {
237
+ version: 1,
238
+ resolvedPath: parsed.resolvedPath,
239
+ mtimeMs: parsed.mtimeMs,
240
+ sizeBytes: parsed.sizeBytes,
241
+ backend: parsed.backend,
242
+ probedAt: typeof parsed.probedAt === "string" ? parsed.probedAt : "",
243
+ };
244
+ if (typeof parsed.versionQuad === "string") record.versionQuad = parsed.versionQuad;
245
+ if (typeof parsed.cpuHistoryAvailable === "boolean") record.cpuHistoryAvailable = parsed.cpuHistoryAvailable;
246
+ return record;
247
+ }
248
+
249
+ /** Tmp-sibling -> chmod 0600 -> content -> rename, the SAME atomic-write
250
+ * discipline refresh-manifest.ts's writeManifestAtomic() and vice-broker.mts's
251
+ * writeBrokerRecordFile() both already use -- a crash mid-write can only ever
252
+ * leave a stray tmp sibling behind, never a truncated or empty file at the
253
+ * real cache path that a later read would wrongly accept. */
254
+ function writeCacheRecordAtomic(supervisorDir: string, record: BackendCacheRecord): void {
255
+ mkdirSync(supervisorDir, { recursive: true });
256
+ const finalPath = cachePathFor(supervisorDir);
257
+ const tmpPath = `${finalPath}.tmp-${process.pid}-${Date.now()}`;
258
+ writeFileSync(tmpPath, "");
259
+ chmodSync(tmpPath, 0o600);
260
+ writeFileSync(tmpPath, JSON.stringify(record, null, 2) + "\n");
261
+ renameSync(tmpPath, finalPath);
262
+ }
263
+
264
+ // ---------------------------------------------------------------------------
265
+ // Binary identity -- resolve a possibly-bare command name (e.g. "x64sc") to
266
+ // an absolute path (for cache KEYING and stat only -- probeBackend() above
267
+ // still spawns the ORIGINAL, unresolved binPath/viceBin string, letting the
268
+ // OS's own PATH search resolve it exactly like a real invocation would), and
269
+ // stat it for mtimeMs/sizeBytes -- the D-03 planner decision's cache key
270
+ // half: `{ resolvedPath, mtimeMs, sizeBytes }`, which catches a binary
271
+ // replaced in place (an `apt upgrade`, a manual `cp`) without hashing a
272
+ // multi-megabyte file on every broker start.
273
+ // ---------------------------------------------------------------------------
274
+
275
+ export interface BinaryIdentity {
276
+ mtimeMs: number;
277
+ sizeBytes: number;
278
+ }
279
+
280
+ function defaultResolveBinPath(bin: string, env: NodeJS.ProcessEnv): string | null {
281
+ if (bin.includes("/")) {
282
+ const abs = resolvePath(bin);
283
+ return existsSync(abs) ? abs : null;
284
+ }
285
+ const pathEnv = env.PATH ?? "";
286
+ for (const dir of pathEnv.split(":")) {
287
+ if (!dir) continue;
288
+ const candidate = join(dir, bin);
289
+ if (existsSync(candidate)) return candidate;
290
+ }
291
+ return null;
292
+ }
293
+
294
+ function defaultStat(resolvedPath: string): BinaryIdentity | null {
295
+ try {
296
+ const st = statSync(resolvedPath);
297
+ return { mtimeMs: st.mtimeMs, sizeBytes: st.size };
298
+ } catch {
299
+ return null;
300
+ }
301
+ }
302
+
303
+ function defaultLog(line: string): void {
304
+ process.stderr.write(`${line}\n`);
305
+ }
306
+
307
+ // ---------------------------------------------------------------------------
308
+ // resolvedBackend() -- the public entry point.
309
+ // ---------------------------------------------------------------------------
310
+
311
+ export type ResolvedBackendSource = "override" | "cache" | "probe" | "indeterminate";
312
+
313
+ export interface ResolvedBackendResult {
314
+ backend: ViceBackend;
315
+ source: ResolvedBackendSource;
316
+ /**
317
+ * WR-05: the ABSOLUTE path this binary resolved to when it could be resolved,
318
+ * falling back to the configured name otherwise. It used to be `viceBin`
319
+ * unconditionally -- the raw `VICE_BIN`/`"x64sc"` string -- even though this
320
+ * function already computes `resolvedPath` internally for cache keying. Two
321
+ * consumers called that a resolved path in their own doc comments
322
+ * (StockDispatchDeps.resolvedBinaryPath, and BACK-03's `vice_ping` answer),
323
+ * so `vice_ping` on stock reported `"x64sc"` -- a name that, inside a
324
+ * container, resolves to nothing at all.
325
+ *
326
+ * Deliberately NOT what probeBackend() spawns: that still receives the
327
+ * ORIGINAL, unresolved string, so the OS's own PATH search happens exactly
328
+ * as it would for a real invocation. This field is what a HUMAN or an agent
329
+ * is shown, and "which file did you actually mean" is the question it has to
330
+ * answer.
331
+ */
332
+ binPath: string;
333
+ /** WR-05: `true` only when `binPath` above is a real resolved absolute path;
334
+ * `false` when resolution failed and it fell back to the configured name.
335
+ * Carried explicitly so a consumer rendering it to an agent can say which of
336
+ * the two it has, instead of a reader having to guess from whether the string
337
+ * happens to contain a slash. */
338
+ binPathResolved: boolean;
339
+ note?: string;
340
+ }
341
+
342
+ export interface ResolvedBackendDeps {
343
+ env?: NodeJS.ProcessEnv;
344
+ /** Which binary to detect against -- defaults to VICE_BIN or "x64sc",
345
+ * matching broker-launch.mts's own spawnAndRecordInstance() default
346
+ * exactly (one broker, one binary, one verdict -- D-04). */
347
+ viceBin?: string;
348
+ /** See this module's own header comment on the cache section above --
349
+ * NEVER defaulted here. Omitted entirely disables the on-disk cache
350
+ * (probe-and-memoise-in-process only, never persisted). */
351
+ supervisorDir?: string;
352
+ resolveBinPath?: (bin: string, env: NodeJS.ProcessEnv) => string | null;
353
+ stat?: (resolvedPath: string) => BinaryIdentity | null;
354
+ probe?: (binPath: string) => "fork" | "stock" | "unknown";
355
+ now?: () => number;
356
+ log?: (line: string) => void;
357
+ }
358
+
359
+ // Memoised answer for the probe/cache path ONLY -- the override path
360
+ // (VICE_BACKEND set) is always answered fresh, on every call, straight from
361
+ // the environment, and never touches this memo (an explicit override can
362
+ // legitimately differ from call to call within, e.g., a test process driving
363
+ // many scenarios; the detected-backend answer for a fixed binary cannot).
364
+ // This is what makes resolvedBackend() answer "once per long-running
365
+ // process" for the case that actually spawns something, while never
366
+ // requiring a caller to somehow signal "this is a fresh scenario" the way
367
+ // container-guard.mts's isInsideContainer() asks callers to pass explicit
368
+ // deps to bypass ITS OWN memo -- here, the override/no-override distinction
369
+ // already IS that signal.
370
+ let memoisedResult: ResolvedBackendResult | null = null;
371
+
372
+ // D-06: gates the "detected backend X for binary Y" stderr note so a
373
+ // long-running broker (or a test suite driving resolvedBackend() many times)
374
+ // emits it at most once per process -- repo-root.ts's warnedEnvOutsideFrom/
375
+ // warnedNoMarkerFound pattern, reused here verbatim.
376
+ let warnedBackendUnset = false;
377
+
378
+ function emitDetectedNote(result: ResolvedBackendResult, viceBin: string, log: (line: string) => void): void {
379
+ if (warnedBackendUnset) return;
380
+ warnedBackendUnset = true;
381
+ log(
382
+ `vice-broker: detected backend "${result.backend}" for ${viceBin} (source: ${result.source}) -- ` +
383
+ `set VICE_BACKEND=stock or VICE_BACKEND=fork to override this detection explicitly`,
384
+ );
385
+ }
386
+
387
+ /** Test-only escape hatch: clears the in-process memo and the D-06
388
+ * one-time-note gate. Never called by any real production code path --
389
+ * vice-broker.mts calls resolvedBackend() exactly once per real process
390
+ * lifetime and has no reason to ever reset it; this exists solely so
391
+ * backend-detect.test.ts can drive many distinct scenarios (cache hit, cache
392
+ * miss, indeterminate, ...) in one shared test process without one
393
+ * scenario's memoised answer contaminating the next -- mirroring
394
+ * broker-launch.test.ts's own discipline of restoring module-level state
395
+ * between test cases, made explicit here rather than left to careful test
396
+ * ordering, since this module's memo (unlike buildViceArgs()'s one-time
397
+ * note) has no natural "always widens the same way" ordering to exploit. */
398
+ export function resetResolvedBackendForTests(): void {
399
+ memoisedResult = null;
400
+ warnedBackendUnset = false;
401
+ }
402
+
403
+ /** Honours VICE_BACKEND FIRST, returning immediately without spawning
404
+ * anything when it names `stock` or `fork` (BACK-01: one optional config
405
+ * value switches backends, no code edit). Otherwise consults the on-disk
406
+ * cache (when `supervisorDir` is given and the binary's current
407
+ * `{ resolvedPath, mtimeMs, sizeBytes }` all match the stored record); on a
408
+ * miss, probes via probeBackend() and writes the cache. Memoises the
409
+ * probe/cache answer in a module-level variable so a long-running process
410
+ * resolves once (see the memo's own comment above for what "once" means
411
+ * here). Never throws: a probe that classifies "unknown" (including a
412
+ * spawn failure or a timeout, both of which probeBackend() already reduces
413
+ * to "unknown") returns a defined `{ backend: "fork", source:
414
+ * "indeterminate", ... }` outcome instead -- "fork" because that is the
415
+ * pre-Phase-2 behaviour every existing install already has, so an
416
+ * undetectable binary degrades to what already worked rather than to
417
+ * nothing. */
418
+ /** WR-05: the ONE place `binPath`/`binPathResolved` are derived, so the four
419
+ * return paths below cannot disagree about what "the binary" means. A resolved
420
+ * absolute path when there is one; the configured name, flagged as unresolved,
421
+ * when there is not. */
422
+ function binPathFields(resolvedPath: string | null, viceBin: string): { binPath: string; binPathResolved: boolean } {
423
+ return resolvedPath !== null ? { binPath: resolvedPath, binPathResolved: true } : { binPath: viceBin, binPathResolved: false };
424
+ }
425
+
426
+ export function resolvedBackend(deps: ResolvedBackendDeps = {}): ResolvedBackendResult {
427
+ const env = deps.env ?? process.env;
428
+ const viceBin = deps.viceBin ?? env.VICE_BIN ?? "x64sc";
429
+ // A direct read of the real environment on the right of this ternary
430
+ // (rather than the generic `env` local above) is deliberate: this file is
431
+ // grep-gated, tree-wide, as the ONE place that ever names this variable
432
+ // directly against the real environment -- `deps.env` (the test-injection
433
+ // seam) still takes precedence when supplied, exactly like every other
434
+ // field on this options object.
435
+ const override = deps.env ? deps.env.VICE_BACKEND : process.env.VICE_BACKEND;
436
+ const resolveBinPath = deps.resolveBinPath ?? defaultResolveBinPath;
437
+
438
+ if (override === "stock" || override === "fork") {
439
+ // WR-05: an explicit backend override still resolves the PATH, so
440
+ // `vice_ping` reports a real file rather than the bare name. This is a
441
+ // filesystem lookup only -- existsSync per PATH entry -- and NEVER a spawn,
442
+ // so the override path keeps its "answered fresh, straight from the
443
+ // environment, spawns nothing" property.
444
+ return { backend: override, source: "override", ...binPathFields(resolveBinPath(viceBin, env), viceBin) };
445
+ }
446
+
447
+ if (memoisedResult !== null) return memoisedResult;
448
+
449
+ const log = deps.log ?? defaultLog;
450
+ const stat = deps.stat ?? defaultStat;
451
+ const probe = deps.probe ?? ((bin: string) => probeBackend(bin));
452
+ const now = deps.now ?? ((): number => Date.now());
453
+
454
+ const resolvedPath = resolveBinPath(viceBin, env);
455
+ const identity = resolvedPath ? stat(resolvedPath) : null;
456
+ const cacheEligible = resolvedPath !== null && identity !== null && typeof deps.supervisorDir === "string";
457
+
458
+ if (cacheEligible) {
459
+ const cached = readCacheRecord(deps.supervisorDir as string);
460
+ if (
461
+ cached &&
462
+ cached.resolvedPath === resolvedPath &&
463
+ cached.mtimeMs === identity!.mtimeMs &&
464
+ cached.sizeBytes === identity!.sizeBytes
465
+ ) {
466
+ const result: ResolvedBackendResult = { backend: cached.backend, source: "cache", ...binPathFields(resolvedPath, viceBin) };
467
+ memoisedResult = result;
468
+ emitDetectedNote(result, viceBin, log);
469
+ return result;
470
+ }
471
+ }
472
+
473
+ const verdict = probe(viceBin);
474
+
475
+ if (verdict === "unknown") {
476
+ const note =
477
+ `vice-broker: could not determine whether ${viceBin} is the stock or fork VICE build -- ` +
478
+ `its --help output matched neither the -mcpserver nor the -binarymonitor discriminator. ` +
479
+ `Set VICE_BACKEND=stock or VICE_BACKEND=fork explicitly.`;
480
+ log(note);
481
+ const result: ResolvedBackendResult = { backend: "fork", source: "indeterminate", ...binPathFields(resolvedPath, viceBin), note };
482
+ memoisedResult = result;
483
+ return result;
484
+ }
485
+
486
+ if (cacheEligible) {
487
+ writeCacheRecordAtomic(deps.supervisorDir as string, {
488
+ version: 1,
489
+ resolvedPath: resolvedPath as string,
490
+ mtimeMs: identity!.mtimeMs,
491
+ sizeBytes: identity!.sizeBytes,
492
+ backend: verdict,
493
+ probedAt: new Date(now()).toISOString(),
494
+ });
495
+ }
496
+
497
+ const result: ResolvedBackendResult = { backend: verdict, source: "probe", ...binPathFields(resolvedPath, viceBin) };
498
+ memoisedResult = result;
499
+ emitDetectedNote(result, viceBin, log);
500
+ return result;
501
+ }
502
+
503
+ // ---------------------------------------------------------------------------
504
+ // BACK-04: the capability record. Same cache file, same identity match --
505
+ // filled in by plan 02-08's connect handshake, once per binary, never once
506
+ // per connect. This file's own probe never populates these fields (a
507
+ // `--help` transcript cannot carry a version quad); it only ever reads or
508
+ // updates them against a backend verdict this file already wrote.
509
+ // ---------------------------------------------------------------------------
510
+
511
+ export interface CapabilityRecordResult {
512
+ versionQuad?: string;
513
+ cpuHistoryAvailable?: boolean;
514
+ /** True only when `observedVersionQuad` was given AND differs from the
515
+ * stored value -- the caller has just seen a DIFFERENT VICE build than
516
+ * whatever wrote this record (the binary was swapped since the last
517
+ * capability determination), so the stored answer cannot be trusted and
518
+ * must be re-determined rather than reused. */
519
+ stale: boolean;
520
+ }
521
+
522
+ export interface CapabilityDeps {
523
+ env?: NodeJS.ProcessEnv;
524
+ supervisorDir?: string;
525
+ resolveBinPath?: (bin: string, env: NodeJS.ProcessEnv) => string | null;
526
+ stat?: (resolvedPath: string) => BinaryIdentity | null;
527
+ /** A version quad the caller just observed live (over VICE_INFO on an
528
+ * established connection) -- compared against whatever this cache
529
+ * currently has on record for the SAME resolved binary. Omitted entirely
530
+ * skips the staleness comparison outright (the returned `stale` is always
531
+ * `false` when this is omitted). */
532
+ observedVersionQuad?: string;
533
+ }
534
+
535
+ /** Reads whatever capability answers (BACK-04) are on record for `binPath`
536
+ * -- `null` when there is no cache at all, the binary cannot be resolved, the
537
+ * record on file names a DIFFERENT resolved binary, or nothing has been
538
+ * recorded for this binary yet. Never throws. */
539
+ export function readCapabilityRecord(binPath: string, deps: CapabilityDeps = {}): CapabilityRecordResult | null {
540
+ if (typeof deps.supervisorDir !== "string") return null;
541
+ const env = deps.env ?? process.env;
542
+ const resolveBinPath = deps.resolveBinPath ?? defaultResolveBinPath;
543
+ const resolvedPath = resolveBinPath(binPath, env);
544
+ if (!resolvedPath) return null;
545
+
546
+ const existing = readCacheRecord(deps.supervisorDir);
547
+ if (!existing || existing.resolvedPath !== resolvedPath) return null;
548
+ if (existing.versionQuad === undefined && existing.cpuHistoryAvailable === undefined) return null;
549
+
550
+ const stale =
551
+ deps.observedVersionQuad !== undefined &&
552
+ existing.versionQuad !== undefined &&
553
+ existing.versionQuad !== deps.observedVersionQuad;
554
+
555
+ return { versionQuad: existing.versionQuad, cpuHistoryAvailable: existing.cpuHistoryAvailable, stale };
556
+ }
557
+
558
+ /** Attaches `{ versionQuad, cpuHistoryAvailable }` to the EXISTING backend
559
+ * verdict already on record for `binPath`'s resolved identity -- a no-op,
560
+ * never a throw, when there is no such matching record yet (no supervisorDir
561
+ * given, the binary cannot be resolved or stat'd, or the cache names a
562
+ * different binary or has no verdict at all). This function never invents a
563
+ * backend verdict of its own: it can only EXTEND a record resolvedBackend()
564
+ * already wrote, since a `--help` probe has no way to observe a version
565
+ * quad and this function must not silently fabricate the field it did not
566
+ * observe either. */
567
+ export function writeCapabilityRecord(
568
+ binPath: string,
569
+ capability: { versionQuad: string; cpuHistoryAvailable: boolean },
570
+ deps: CapabilityDeps = {},
571
+ ): void {
572
+ if (typeof deps.supervisorDir !== "string") return;
573
+ const env = deps.env ?? process.env;
574
+ const resolveBinPath = deps.resolveBinPath ?? defaultResolveBinPath;
575
+ const stat = deps.stat ?? defaultStat;
576
+
577
+ const resolvedPath = resolveBinPath(binPath, env);
578
+ if (!resolvedPath) return;
579
+ const identity = stat(resolvedPath);
580
+ if (!identity) return;
581
+
582
+ const existing = readCacheRecord(deps.supervisorDir);
583
+ if (!existing || existing.resolvedPath !== resolvedPath) return;
584
+
585
+ writeCacheRecordAtomic(deps.supervisorDir, {
586
+ version: 1,
587
+ resolvedPath,
588
+ mtimeMs: identity.mtimeMs,
589
+ sizeBytes: identity.sizeBytes,
590
+ backend: existing.backend,
591
+ probedAt: existing.probedAt,
592
+ versionQuad: capability.versionQuad,
593
+ cpuHistoryAvailable: capability.cpuHistoryAvailable,
594
+ });
595
+ }
package/build.ts CHANGED
@@ -47,6 +47,7 @@ export const HOST_BOUND_ARTIFACTS: string[] = [
47
47
  "broker-kill.mjs",
48
48
  "broker-epoch.mjs",
49
49
  "broker-control.mjs",
50
+ "backend-detect.mjs",
50
51
  ];
51
52
 
52
53
  /** The generated-file banner (01.6-RESEARCH.md §F), a function of the