@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
@@ -2,7 +2,7 @@
2
2
  // Compiled by `tsc` from broker-launch.mts. Edit the TypeScript source and rebuild;
3
3
  // changes made directly to this file are silently overwritten by the next build, and are never
4
4
  // deployed to the host on their own -- install-resources.mjs copies THIS file's on-disk contents
5
- // verbatim to tools/, so an edit made only here reaches the host but is lost on the very next
5
+ // verbatim to .c64-re-tools/bin/, so an edit made only here reaches the host but is lost on the very next
6
6
  // rebuild.
7
7
  // broker-launch.mts
8
8
  //
@@ -14,8 +14,12 @@
14
14
  // in-process mechanism (collapsed from a three-way branch by Phase
15
15
  // 01.6.2.1's own plan 02 -- D-05 as amended by P-05/P-06/P-07; see
16
16
  // probeReady()'s own header comment below for the amendment's record),
17
- // serialised warm-floor maintenance (one launch per pass, never more), and
18
- // the fixed-order evaluation pass both surviving concerns run through.
17
+ // the launching -> ready promotion sweep (promoteLaunchingInstances() --
18
+ // plan 41-05, folded todo: this used to be step 1 inside a warm-floor
19
+ // maintenance function that speculatively pre-launched spare instances;
20
+ // that floor is RETIRED and VICE now launches strictly on demand, but the
21
+ // promotion sweep outlived it), and the fixed-order evaluation pass both
22
+ // surviving concerns run through.
19
23
  //
20
24
  // Plan 03, Task 2 grows this module into a real per-child supervisor
21
25
  // (C2/D-23), absorbing resources/vice-supervisor.sh wholesale: superviseChild()
@@ -71,6 +75,37 @@ let warnedBinmonBindWidened = false;
71
75
  // caller could widen one host override and not the other, though in
72
76
  // practice both resolve from the same `binmonHost` value below).
73
77
  let warnedRemoteMonitorBindWidened = false;
78
+ /** Phase 33, plan 33-05 (`REPRO-01`): the random seed the stock determinism
79
+ * block pins, and the exact value the reproduction was measured with on this
80
+ * host -- exported so a capture record's reproducibility key can cite ONE
81
+ * definition rather than re-deriving a literal that could silently drift away
82
+ * from the launches it claims to describe. MEASURED 2026-09-02 against genuine
83
+ * stock 3.9 over `-binarymonitor` (33-RESEARCH.md M3): two cold boots WITHOUT
84
+ * the block differ at 59 of the 4080 addresses in the untouched `$C000-$CFEF`
85
+ * window; WITH it, at 0 of 4080. */
86
+ export const STOCK_DETERMINISM_SEED = 4242;
87
+ /** Phase 33, plan 33-05 (`REPRO-01`): the determinism block, in ONE fixed
88
+ * order, emitted UNCONDITIONALLY by the stock branch below (never gated on
89
+ * `profile` -- see buildViceArgs()'s own comment above `args`). Exported and
90
+ * frozen so tests and evidence scripts assert against this single definition
91
+ * instead of a second hand-copied array, and so no caller can mutate the
92
+ * shared value into a launch that no longer matches the recorded seed.
93
+ *
94
+ * The order is fixed and load-bearing beyond readability: `REPRO-04` keys
95
+ * captures on an argv digest, so a block whose element order varied between
96
+ * two launches on the same port would produce two digests for one launch
97
+ * intent. */
98
+ export const STOCK_DETERMINISM_FLAGS = Object.freeze([
99
+ "-seed",
100
+ String(STOCK_DETERMINISM_SEED),
101
+ "-raminitstartrandom",
102
+ "0",
103
+ "-raminitrepeatrandom",
104
+ "0",
105
+ "-raminitrandomchance",
106
+ "0",
107
+ "+autostart-delay-random",
108
+ ]);
74
109
  /** Resolves the emulator's own argument vector for the given `backend`. The
75
110
  * `VICE_ARGS` full-override short-circuit (matching
76
111
  * resources/vice-supervisor.sh's own VICE_ARGS convention exactly) is
@@ -80,15 +115,12 @@ let warnedRemoteMonitorBindWidened = false;
80
115
  * understands neither `-mcpserver` nor `-binarymonitor` flags, and that need
81
116
  * does not depend on which backend is configured.
82
117
  *
83
- * `backend: "fork"` returns exactly the pre-Phase-2 shape, byte-identical:
84
- * the MCP server flag, the MCP server host from `mcpHost` or
85
- * VICE_BROKER_MCP_HOST (default `0.0.0.0`), and the MCP server port.
86
- *
87
- * `backend: "stock"` returns `-binarymonitor -binarymonitoraddress
118
+ * `backend: "stock"` (FORKRM-01, plan 52-06: the only value `ViceBackend` has
119
+ * left) returns `-binarymonitor -binarymonitoraddress
88
120
  * ip4://<host>:<port>` (docs/phase1-probe-results.md's confirmed real-world
89
121
  * command line). The host resolves from `binmonHost` or
90
122
  * VICE_BROKER_BINMON_HOST, defaulting to `127.0.0.1` -- deliberately
91
- * narrower than the fork path's `0.0.0.0` default, because VICE's binary
123
+ * narrow, because VICE's binary
92
124
  * monitor is unauthenticated by design and grants full read/write over the
93
125
  * emulated machine plus process control to anything that can reach it
94
126
  * (planner decision, `02-03-PLAN.md`). Widening the bind away from loopback
@@ -111,11 +143,29 @@ let warnedRemoteMonitorBindWidened = false;
111
143
  * than implied stock-3.9 coverage. Widening THIS bind away from loopback emits
112
144
  * its own one-time stderr note (`warnedRemoteMonitorBindWidened`), naming
113
145
  * the resolved address and stating that VICE's TEXT monitor accepts
114
- * arbitrary monitor commands and is unauthenticated -- Phase 3 dials
115
- * nothing on this port; only the launch flag lands now (see D-13's own
116
- * rationale: adding the flag later would require relaunching a live
117
- * instance, destroying all emulation state). */
118
- export function buildViceArgs(port, { backend, mcpHost, binmonHost, viceArgsEnv, remoteMonitorPort, }) {
146
+ * arbitrary monitor commands and is unauthenticated. Phase 41 dials this
147
+ * port (text-connect.ts's textConnect()) and Phase 41-05 (D-16) made it
148
+ * MANDATORY on every stock launch -- a stock launch that cannot bind it now
149
+ * fails the whole acquire rather than launching without it (see D-13's own
150
+ * rationale for why the flag itself is set at launch time and not added
151
+ * later: doing so would require relaunching a live instance, destroying all
152
+ * emulation state).
153
+ *
154
+ * Phase 33, plan 33-05 (`D-15`, `REPRO-01`, `REPRO-05`): the stock branch now
155
+ * also emits STOCK_DETERMINISM_FLAGS unconditionally, and takes an optional
156
+ * `profile` for the two additive launch knobs. Deliberately in the same
157
+ * register as tryLaunchOne's own widened `spawn` field below, and for the same
158
+ * reason: `profile` is optional, so every pre-existing caller and every
159
+ * pre-existing test stub keeps compiling and behaving identically, and an
160
+ * ABSENT profile produces exactly the same argv as an empty one or one whose
161
+ * knobs are both `false`. What optionality could NOT save (33-RESEARCH.md P7,
162
+ * and the `D-15` amendment rider dated 2026-09-02): the determinism block is
163
+ * unconditional on stock, so all FIVE stock whole-argv assertions in
164
+ * broker-launch.test.ts move even with `profile` absent -- it is the block and
165
+ * not the profile that moves them. `D-15`'s byte-identity claim therefore
166
+ * survives in full only for the `profile` half: an absent profile adds no
167
+ * flag. */
168
+ export function buildViceArgs(port, { backend, mcpHost, binmonHost, viceArgsEnv, remoteMonitorPort, profile, }) {
119
169
  const rawViceArgs = viceArgsEnv ?? process.env.VICE_ARGS;
120
170
  if (typeof rawViceArgs === "string" && rawViceArgs.trim() !== "") {
121
171
  return rawViceArgs.trim().split(/\s+/);
@@ -138,11 +188,31 @@ export function buildViceArgs(port, { backend, mcpHost, binmonHost, viceArgsEnv,
138
188
  // first element: it is VICE's reset-to-compiled-in-defaults instruction,
139
189
  // not an inert "these are the baselines" no-op, so any flag emitted
140
190
  // before it (including -drive8type) is silently clobbered back to its
141
- // compiled-in value. `-drive8type 1541` therefore has to come
142
- // immediately after `-default`, and -- per CLAUDE.md's documented
143
- // constraint -- `-default` also has to come before `-binarymonitor` or
144
- // the monitor never binds and the subsequent connect hangs in the
145
- // backlog looking exactly like a wedge. Confirmed sufficient live in
191
+ // compiled-in value. `-drive8type 1541` therefore has to come AFTER
192
+ // `-default` -- not necessarily IMMEDIATELY after -- and -- per
193
+ // CLAUDE.md's documented constraint -- `-default` also has to come before
194
+ // `-binarymonitor` or the monitor never binds and the subsequent connect
195
+ // hangs in the backlog looking exactly like a wedge.
196
+ //
197
+ // WORDING CORRECTED (33 review WR-09). This paragraph used to say
198
+ // "immediately after `-default`", which the `-console` block below now
199
+ // violates by construction whenever `profile.headless` is set -- leaving
200
+ // the next editor to find code contradicting the comment and having to
201
+ // re-derive which one is authoritative. What is load-bearing is the
202
+ // RELATIVE ORDER (`-default` precedes everything it resets), not
203
+ // adjacency.
204
+ //
205
+ // The `-console` block's own citation was `alive=yes bound=1`, which does
206
+ // NOT cover this paragraph's property: I-2's failure mode is Drive8Type
207
+ // silently reverting to 0 (NONE) WHILE THE MONITOR STILL BINDS FINE, so
208
+ // liveness and boundness cannot tell the good case from the failure being
209
+ // guarded against. Re-verified against the resource itself
210
+ // [VERIFIED: live probe 2026-09-03, genuine unpatched stock
211
+ // /usr/bin/x64sc (VICE 3.9), DISPLAY and WAYLAND_DISPLAY both unset]:
212
+ // [-default -console -drive8type 1541 <determinism> -binarymonitor]
213
+ // alive=yes bound=1 Drive8Type=1541 Drive8TrueEmulation=1
214
+ // read over `RESOURCE_GET` (0x51) with `-console` interposed. So the
215
+ // citation now covers the RESOURCE and not only liveness. Confirmed sufficient live in
146
216
  // Phase 8.1's standalone probe (08.1-WALKTHROUGH-EVIDENCE.md §4):
147
217
  // `resourceget "Drive8Type"` moved 0 -> 1541 and a `load` over the text
148
218
  // monitor succeeded immediately. Deliberately NOT setting
@@ -153,7 +223,81 @@ export function buildViceArgs(port, { backend, mcpHost, binmonHost, viceArgsEnv,
153
223
  // might default Drive8TrueEmulation to 0) is read and deliberately not
154
224
  // pre-emptively defended against here; plan 03's live test is what would
155
225
  // surface it if that assumption is ever wrong on a different build.
156
- const args = ["-default", "-drive8type", "1541", "-binarymonitor", "-binarymonitoraddress", `ip4://${host}:${port}`];
226
+ //
227
+ // Phase 33, plan 33-05, `-console` at index 1 (`REPRO-05`, `D-15`): the
228
+ // headless route is `-console`, and its POSITION is as load-bearing as
229
+ // `-default`'s. `-console` is handled in the SAME `main.c` pre-scan as
230
+ // `-default` -- that loop `break`s at the first option it does not
231
+ // recognise and then strips the prefix it handled from argv
232
+ // [CITED: vice-3.8/src/main.c:184-192, 232-238] -- and `console_mode`
233
+ // gates GTK initialisation at two call sites (`ui_init_with_args`,
234
+ // `ui_init`) that BOTH run before the late command-line parser
235
+ // `initcmdline_check_args()` [CITED: vice-3.8/src/main.c:296-345]. A
236
+ // `-console` seen only by the late parser therefore arrives after GTK has
237
+ // already tried and failed. MEASURED 2026-09-02 with `DISPLAY` and
238
+ // `WAYLAND_DISPLAY` both unset (33-RESEARCH.md P5):
239
+ // [-default -console -binarymonitor] alive=yes bound=1
240
+ // [-default -drive8type 1541 -console -binarymonitor] alive=no bound=0 Gtk-WARNING: cannot open display:
241
+ // [-default -console -drive8type 1541 -binarymonitor] alive=yes bound=1
242
+ // So `-console` goes immediately after `-default` and BEFORE
243
+ // `-drive8type` -- which is compatible with the I-2 paragraph above as
244
+ // corrected (33 review WR-09): that constraint is `-drive8type` AFTER
245
+ // `-default`, not adjacent to it, and the interposition was re-verified
246
+ // over `RESOURCE_GET` to leave Drive8Type=1541 rather than only to leave
247
+ // the monitor bound. It is pinned by an ordering assertion rather than by
248
+ // this comment -- a bare flag push with no reason is exactly what let the
249
+ // `-default` ordering constraint be rediscovered by a red CI run last
250
+ // time.
251
+ //
252
+ // Phase 33, plan 33-05, the determinism block (`REPRO-01`): emitted
253
+ // UNCONDITIONALLY on stock, never gated on `profile`. Read over
254
+ // `RESOURCE_GET` (0x51) on this build under `-default -drive8type 1541`
255
+ // [VERIFIED: live probe 2026-09-02, 33-RESEARCH.md M2],
256
+ // `-raminitrandomchance 0` is the LOAD-BEARING one: the factory value is
257
+ // **10**, i.e. 0.1% of all RAM bits randomly flipped at power-up, and it is
258
+ // the dominant term in the divergence this milestone removes. The
259
+ // `-raminitstartrandom 0` / `-raminitrepeatrandom 0` pair already reads 0
260
+ // at factory and is DEFENSIVE against an operator `vicerc` -- belt and
261
+ // braces alongside the scratch `XDG_CONFIG_HOME` the I-1 rider threads in
262
+ // spawnAndRecordInstance() below.
263
+ //
264
+ // `+autostart-delay-random` is a FIFTH flag beyond `REPRO-01`'s text
265
+ // (which names only `-seed` plus the three `raminit*`), recorded here as a
266
+ // deliberate ADDITION rather than smuggled in. `AutostartDelayRandom`
267
+ // ships at **1** on this build [VERIFIED: same probe], it draws an
268
+ // additional random delay of up to 10 frames
269
+ // [CITED: vice-3.8/src/autostart.c:1432-1436], and -- the effect that is
270
+ // easy to miss -- it also SELECTS WHICH keyboard-buffer feed injects `RUN`
271
+ // (`kbdbuf_feed_runcmd` when set, `kbdbuf_feed` when clear)
272
+ // [CITED: vice-3.8/src/autostart.c:882-886]. Disabling it is therefore a
273
+ // behavioural change and not only a timing one. Pin it once; never toggle
274
+ // it between the two runs of a capture pair.
275
+ //
276
+ // Phase 33, plan 33-05, `-warp` (`D-15`): position-free (MEASURED
277
+ // 2026-09-02), placed here only so the argv reads in the order a human
278
+ // would describe it. Launch-time is the ONLY route on stock: there is no
279
+ // runtime `WarpMode` resource at all (`RESOURCE_GET` replies `err=0x01`
280
+ // OBJECT_MISSING on 3.9), so no runtime setter can exist here. Worth
281
+ // roughly **1.97x** on this host and this launch profile -- 5.23 emulated
282
+ // seconds against 2.65 over the same 5 s wall clock [MEASURED:
283
+ // evidence/33-wallclock-control.md, Control B instance 1] -- and NOT the
284
+ // order of magnitude a reader may assume; `AUTOSTART` additionally turns
285
+ // warp on by itself during the load whatever argv says, so both loads are
286
+ // warped either way. It is behaviour-neutral under a frame-anchored
287
+ // protocol (identical registers and one identical 64K sha256 across a
288
+ // warped and an unwarped run, same source), which is the precondition
289
+ // `33-06` needs before `profile.warp` ships; it is NOT neutral for a
290
+ // wall-clock bracket, which it invalidates by 1.76x.
291
+ const args = ["-default"];
292
+ if (profile?.headless) {
293
+ args.push("-console");
294
+ }
295
+ args.push("-drive8type", "1541");
296
+ args.push(...STOCK_DETERMINISM_FLAGS);
297
+ if (profile?.warp) {
298
+ args.push("-warp");
299
+ }
300
+ args.push("-binarymonitor", "-binarymonitoraddress", `ip4://${host}:${port}`);
157
301
  if (typeof remoteMonitorPort === "number") {
158
302
  if (host !== "127.0.0.1" && !warnedRemoteMonitorBindWidened) {
159
303
  warnedRemoteMonitorBindWidened = true;
@@ -186,12 +330,30 @@ function spawnAndRecordInstance(reason, port, deps) {
186
330
  const spawnFn = deps.spawn ?? ((cmd, args, opts) => nodeSpawn(cmd, args, opts));
187
331
  const now = deps.now ?? (() => Date.now());
188
332
  const viceBin = deps.viceBin ?? process.env.VICE_BIN ?? "x64sc";
189
- const backend = deps.backend ?? "fork";
333
+ const backend = deps.backend ?? "stock";
334
+ // Plan 41-05 (D-16): the ONE construction site for a fresh InstanceRecord
335
+ // asserts the invariant every downstream consumer (HeldLease,
336
+ // textConnect(), etc.) was written against -- a stock record NEVER lacks a
337
+ // text-monitor port. acquirePortAndLaunch() above already fails the whole
338
+ // acquire before ever reaching this function when the second allocation
339
+ // fails, so a caller that lands here with `backend: "stock"` and no
340
+ // `remoteMonitorPort` is a defect in THIS module (a call site that bypassed
341
+ // that guarantee), not a state a stock record may legitimately carry --
342
+ // throw by name rather than silently writing a record that violates it.
343
+ // The fork case is real and unaffected: this check is stock-only.
344
+ if (backend === "stock" && deps.remoteMonitorPort === undefined) {
345
+ throw new Error("spawnAndRecordInstance: backend \"stock\" requires remoteMonitorPort (D-16) -- a stock launch that cannot bind a text-monitor port must fail the acquire before reaching this construction site, never write a portless stock record");
346
+ }
190
347
  const viceArgs = buildViceArgs(port, {
191
348
  backend,
192
349
  mcpHost: deps.mcpHost,
193
350
  binmonHost: deps.binmonHost,
194
351
  remoteMonitorPort: deps.remoteMonitorPort,
352
+ // Phase 33, plan 33-06: the ONE place a launch's profile becomes argv.
353
+ // The record built below mirrors the SAME value, so an instance's
354
+ // recorded profile and its actual argv are written in one step and
355
+ // cannot disagree.
356
+ profile: deps.profile,
195
357
  });
196
358
  const log = deps.log ?? defaultLog;
197
359
  // I-1 rider (audit §4.4, 08.2-02-PLAN.md Task 2): production stock
@@ -204,24 +366,23 @@ function spawnAndRecordInstance(reason, port, deps) {
204
366
  // unreachable) and pass it as a third options argument carrying `env`
205
367
  // only. Never `shell: true`: the existing array-form spawn(viceBin,
206
368
  // viceArgs) call avoids shell interpretation entirely and that property
207
- // must survive this widening. For backend === "fork", spawnFn is called
208
- // with NO third argument at all, so the fork path's observable behaviour
209
- // stays bit-for-bit what it was (BACK-02 is a standing gate and the fork
210
- // backend has been the sole production backend across all of v0.1.x).
369
+ // must survive this widening.
211
370
  //
212
371
  // Scope boundary (do not remove this note): the production broker daemon
213
372
  // always supplies its own deps.spawn / deps.spawnFactory, so the widened
214
373
  // default wrapper above is dead code on the real launch paths. This
215
374
  // function's job is only to COMPUTE the value at the one seam that should
216
- // own it; the forwarding to nodeSpawn() happens at four further hops --
217
- // makeLoggingSpawn() and maintainWarmFloorForRealBroker's inner
218
- // stashingSpawn in vice-broker.mts, and withCrashSupervision()'s wrapper
219
- // body and launchSupervised()'s defaultRealSpawn in this file. All four
220
- // now forward the options argument (plan 08.2-06 closed them in this same
221
- // phase, with a handleAcquire() composition test that omits
222
- // buildColdSpawnFactory so an injected stub cannot fake the proof). If you
223
- // add a fifth spawn hop, it must forward options too, or production stock
224
- // launches silently lose their config isolation again.
375
+ // own it; the forwarding to nodeSpawn() happens at three further hops --
376
+ // makeLoggingSpawn() in vice-broker.mts, and withCrashSupervision()'s
377
+ // wrapper body and launchSupervised()'s defaultRealSpawn in this file
378
+ // (plan 41-05, folded todo: a FOURTH hop, the retired warm floor's own
379
+ // inner stashingSpawn closure in vice-broker.mts, is REMOVED along with
380
+ // the function that held it). All three now forward the options argument
381
+ // (plan 08.2-06 closed them in this same phase, with a handleAcquire()
382
+ // composition test that omits buildColdSpawnFactory so an injected stub
383
+ // cannot fake the proof). If you add another spawn hop, it must forward
384
+ // options too, or production stock launches silently lose their config
385
+ // isolation again.
225
386
  //
226
387
  // Scratch-dir lifetime: this function deliberately does NOT clean the
227
388
  // directory up -- the spawned emulator process outlives this function's
@@ -254,7 +415,26 @@ function spawnAndRecordInstance(reason, port, deps) {
254
415
  viceBin,
255
416
  viceArgs,
256
417
  dryRun: false,
418
+ // Plan 41-03 (D-14): non-optional, defaulted to an empty map -- "no
419
+ // claim on any channel" is an empty map, never an absent field. The
420
+ // ONE place a fresh InstanceRecord is constructed, so this is the ONE
421
+ // place this default is set.
422
+ monitorClients: {},
423
+ // Plan 41-05 (D-16): key omitted only on the FORK path now -- the guard
424
+ // above already throws before this point for any stock call with no
425
+ // remoteMonitorPort, so a stock record reaching this line always
426
+ // supplies the key. "Absent" means fork, never "stock allocation
427
+ // failed" (that state no longer exists).
257
428
  ...(deps.remoteMonitorPort === undefined ? {} : { remoteMonitorPort: deps.remoteMonitorPort }),
429
+ // Phase 33, plan 33-06: same key-omitted-when-undefined idiom as
430
+ // remoteMonitorPort directly above. An absent request must produce a
431
+ // record with NO `profile` key at all -- not `profile: undefined` --
432
+ // because "absent means profile-less" is the property a broker restarted
433
+ // mid-phase relies on when it reads records written before this field
434
+ // existed. A copy, not the caller's own object: the record outlives this
435
+ // call and a caller mutating its profile afterwards must not silently
436
+ // change what this instance claims it was launched with.
437
+ ...(deps.profile === undefined ? {} : { profile: { ...deps.profile } }),
258
438
  };
259
439
  deps.state.instances.set(port, record);
260
440
  return record;
@@ -271,10 +451,10 @@ function spawnAndRecordInstance(reason, port, deps) {
271
451
  *
272
452
  * This is the RIGHT primitive when the port is already decided and fixed
273
453
  * (most tests; any future caller with its own allocation scheme). It is
274
- * deliberately NOT what handleAcquire or maintainWarmFloor call for a
275
- * FRESH port, because nextFreePort() itself is asynchronous (a real
276
- * port-in-use probe requires it) -- see acquirePortAndLaunch()'s own
277
- * header comment for the race that creates and how it is closed. */
454
+ * deliberately NOT what handleAcquire calls for a FRESH port, because
455
+ * nextFreePort() itself is asynchronous (a real port-in-use probe requires
456
+ * it) -- see acquirePortAndLaunch()'s own header comment for the race that
457
+ * creates and how it is closed. */
278
458
  export function tryLaunchOne(reason, port, deps) {
279
459
  if (inFlight)
280
460
  return null;
@@ -291,26 +471,37 @@ export function tryLaunchOne(reason, port, deps) {
291
471
  * instant tryLaunchOne() alone guards. This closes a genuine race window
292
472
  * tryLaunchOne() cannot: nextFreePort()'s own port-in-use probe is
293
473
  * asynchronous (plan 02, C4 -- a real bind-and-release check), so two
294
- * overlapping callers (a cold acquire arriving over the TCP control
295
- * listener at any moment, and a warm-floor pass on its own poll timer)
296
- * could otherwise BOTH be told the SAME candidate port is free before
297
- * either commits it to state.instances -- a double-launch on one port,
298
- * silently overwriting the earlier record. The guard is checked and set
299
- * SYNCHRONOUSLY before the first `await`, exactly like tryLaunchOne()'s
300
- * own discipline, so a second concurrent call is refused immediately
301
- * (`launch_in_flight`) rather than racing on the allocation.
474
+ * overlapping callers could otherwise BOTH be told the SAME candidate port
475
+ * is free before either commits it to state.instances -- a double-launch on
476
+ * one port, silently overwriting the earlier record. The guard is checked
477
+ * and set SYNCHRONOUSLY before the first `await`, exactly like
478
+ * tryLaunchOne()'s own discipline, so a second concurrent call is refused
479
+ * immediately (`launch_in_flight`) rather than racing on the allocation.
302
480
  *
303
- * This is also the function that restores vice-broker.sh's own
304
- * process_requests() throttle (its `in_flight` local, checked before a
305
- * COLD launch, not only before a warm one): a cold acquire and a
306
- * warm-floor pass can never launch simultaneously, matching the bash
481
+ * Plan 41-05 (folded todo): this guard's own reasoning OUTLIVED the warm
482
+ * floor it was originally written alongside -- it exists because of the
483
+ * 2026-08-01 triple-launch outage (three simultaneous x64sc launches: one
484
+ * SEGV, one exit 1, one exit 0 at the identical spawn second) and is
485
+ * regression-tested (CLAUDE.md), and that history has nothing to do with
486
+ * whether a warm floor exists. Today the only caller of this function is the
487
+ * cold-acquire arm (vice-broker.mts's handleAcquire(), via `serveAcquires()`
488
+ * in runBrokerPass()); the overlap this guard closes is now TWO OR MORE
489
+ * concurrent acquires -- e.g. two requests arriving over the TCP control
490
+ * listener at nearly the same moment, or one arriving while an EARLIER
491
+ * acquire's own launch is still resolving -- never a warming pass, which no
492
+ * longer exists. This is also the function that restores vice-broker.sh's
493
+ * own process_requests() throttle (its `in_flight` local): whatever launches
494
+ * this broker ever attempts, they never overlap, matching the bash
307
495
  * original's declined-to-change behaviour (RESEARCH.md §A1/§C). D-07
308
496
  * (01.6.2.1-03-PLAN.md) layers non-preemptive PRIORITY on top of this same
309
- * "one at a time" guard, never replacing it: this function still only ever
310
- * refuses a second concurrent caller (`launch_in_flight`), and never kills
311
- * or preempts whichever caller already holds the slot -- which reason wins
312
- * this slot NEXT, once it frees, falls out of runBrokerPass()'s own fixed
313
- * evaluation order (that function's own invariant comment), not from
497
+ * "one at a time" guard, never replacing it, and the anti-pattern it names --
498
+ * killing or relaunching preemptively to serve a newer request -- is likewise
499
+ * unaffected by the floor's removal: this function still only ever refuses a
500
+ * second concurrent caller (`launch_in_flight`), and never kills or preempts
501
+ * whichever caller already holds the slot. Among multiple QUEUED acquires,
502
+ * which one wins this slot NEXT, once it frees, falls out of the
503
+ * arrival-ordered pending-acquire structure (broker-control.mts's D-08
504
+ * mechanism) that requeues a refused acquire for the next pass -- not from
314
505
  * anything in this function. The refusal below logs which reason currently
315
506
  * holds the slot and which reason is waiting, so the decision is
316
507
  * reconstructable from the log after an incident. */
@@ -351,16 +542,27 @@ export async function acquirePortAndLaunch(reason, deps) {
351
542
  // does. `state.blockedPorts` is a plain Set the type import already
352
543
  // describes, so mutating it directly needs no value import at all --
353
544
  // exactly the same discipline handleExit()'s own
354
- // `record.monitorClient = undefined` uses in place of
545
+ // `record.monitorClients = {}` uses in place of
355
546
  // clearMonitorClient().
356
547
  deps.state.blockedPorts.add(remoteResult.port);
357
548
  remoteMonitorPort = remoteResult.port;
358
549
  }
359
550
  else {
360
- // Degrade, never fail: a port nothing dials yet (Phase 3 builds no
361
- // text-monitor client) must never make the backend unavailable.
551
+ // Plan 41-05 (D-16): FAIL, never degrade. Owner direction, verbatim:
552
+ // "it should not be possible, vice must be started witht the text
553
+ // channel." A stock launch that cannot bind a text-monitor port
554
+ // fails the whole acquire -- no process is spawned. The PRIMARY port
555
+ // allocated moments earlier is not yet in `state.instances` and was
556
+ // never added to `state.blockedPorts` by this function (only
557
+ // `nextFreePort()`'s own in-use probe blocks a candidate, and that
558
+ // never ran against the winning candidate) -- so it is already
559
+ // allocatable again on the very next call with no further release
560
+ // step; a discriminating-power test proves this rather than assuming
561
+ // it. This failure arm must NEVER call spawnAndRecordInstance() or
562
+ // otherwise leave a port "spoken for" on the caller's behalf.
362
563
  log(`vice-broker: second (-remotemonitor) port allocation failed (${remoteResult.reason}) -- ` +
363
- `launching WITHOUT -remotemonitor; nothing in Phase 3 dials the text-monitor port anyway`);
564
+ `abandoning the stock launch; the text-monitor port is mandatory on every stock launch (D-16) and the acquire fails`);
565
+ return { ok: false, reason: "no_free_text_port" };
364
566
  }
365
567
  }
366
568
  const record = spawnAndRecordInstance(reason, port, {
@@ -374,6 +576,7 @@ export async function acquirePortAndLaunch(reason, deps) {
374
576
  backend: deps.backend,
375
577
  binmonHost: deps.binmonHost,
376
578
  remoteMonitorPort,
579
+ profile: deps.profile,
377
580
  });
378
581
  return { ok: true, record };
379
582
  }
@@ -393,8 +596,9 @@ export async function acquirePortAndLaunch(reason, deps) {
393
596
  * instance permanently consumed one more port out of the fixed
394
597
  * PORT_SCAN_CEILING window even though the OS port was free again the instant
395
598
  * the owning process exited -- a long-running broker (the explicit design goal
396
- * of an on-demand pool with crash supervision and a warm floor) eventually
397
- * exhausts its band and answers `no_free_port` to ordinary launches purely
599
+ * of an on-demand pool with crash supervision, per plan 41-05 launched
600
+ * strictly on demand rather than kept warm) eventually exhausts its band and
601
+ * answers `no_free_port` to ordinary launches purely
398
602
  * from routine churn, with no operator recourse short of a broker restart.
399
603
  *
400
604
  * A RESPAWN is deliberately NOT a call site: the replacement instance keeps
@@ -645,8 +849,8 @@ async function defaultBinmonProbe(port, timeoutMs) {
645
849
  * fully honoured by this collapse, not reversed by it.
646
850
  *
647
851
  * No retry loop, deliberately: a still-booting instance simply fails THIS
648
- * pass and is re-probed on the next one (maintainWarmFloor()'s own per-pass
649
- * cadence, or a later grant-time re-probe) -- this is what makes the
852
+ * pass and is re-probed on the next one (promoteLaunchingInstances()'s own
853
+ * per-pass cadence, or a later grant-time re-probe) -- this is what makes the
650
854
  * shortened ~1s default below safe rather than reckless: a slow host is
651
855
  * re-probed, never starved, and the seconds-valued timeout knob
652
856
  * (VICE_BROKER_PROBE_TIMEOUT_S) still lets an operator on a slow host raise
@@ -665,61 +869,22 @@ export async function probeReady(port, deps = {}) {
665
869
  const httpProbe = deps.httpProbe ?? defaultHttpProbe;
666
870
  return httpProbe(port, timeoutMs);
667
871
  }
668
- // ---------------------------------------------------------------------------
669
- // Serialised warm-floor maintenance
670
- // ---------------------------------------------------------------------------
671
- function resolveWarmFloor(override) {
672
- if (typeof override === "number")
673
- return override;
674
- const raw = process.env.VICE_BROKER_WARM_FLOOR;
675
- if (raw === undefined || raw === "")
676
- return 1;
677
- const n = Number(raw);
678
- return Number.isFinite(n) ? n : 1;
679
- }
680
- function resolveCeiling(override) {
681
- if (typeof override === "number")
682
- return override;
683
- const raw = process.env.VICE_BROKER_MAX;
684
- if (raw === undefined || raw === "")
685
- return 16;
686
- const n = Number(raw);
687
- return Number.isFinite(n) ? n : 16;
688
- }
689
- /** Promotes launching instances via probe, then -- unless a launch is
690
- * already in flight -- launches AT MOST ONE instance toward the warm floor
691
- * and returns. Never loops to reach the floor in one call: reaching
692
- * VICE_BROKER_WARM_FLOOR this way costs one
693
- * additional CALL per warm instance instead of one call total, which is the exact
694
- * trade the 2026-08-01 outage made non-negotiable (three simultaneous
695
- * x64sc launches: one SEGV, one exit 1, one exit 0 at the identical spawn
696
- * second). `async`/`await` makes launching everything needed in one go
697
- * look free and idiomatic; it is actively dangerous here. DO NOT gather
698
- * several pending launches into a single concurrent await, and do not
699
- * "helpfully" loop this function internally until the floor is met.
700
- *
701
- * D-05's probe-live floor evaluation (count_ready() trusting probe-live
702
- * instances rather than a recorded `ready` state) is explicitly Phase
703
- * 01.6.2.1's criterion L, NOT this plan's -- countReady() here still
704
- * counts by RECORDED state, exactly like the bash original's count_ready()
705
- * before Decision 5.2. A reviewer must not mistake this for an oversight:
706
- * it is the declined-for-this-phase choice RESEARCH.md §A1 recommends, and
707
- * grant_from_spare()'s own live re-probe at GRANT time (broker-kill.mts /
708
- * plan 04's territory) is a separate, already-correct mechanism this plan
709
- * does not touch. */
710
- export async function maintainWarmFloor(deps) {
872
+ /** Promotes every `launching` instance whose readiness probe now succeeds to
873
+ * `ready`, recording its readiness timestamp and logging the elapsed boot
874
+ * time. Runs regardless of whether a launch is in flight -- promotion and a
875
+ * NEW launch starting are independent concerns; an already-launched instance
876
+ * becomes usable the moment it answers, whether or not this same pass goes on
877
+ * to start anything further. No retry loop: a still-booting instance simply
878
+ * fails THIS pass and is re-probed on the next one (runBrokerPass()'s own
879
+ * per-tick cadence). */
880
+ export async function promoteLaunchingInstances(deps) {
711
881
  const log = deps.log ?? defaultLog;
712
882
  const now = deps.now ?? (() => Date.now());
713
883
  // WR-01: the DEFAULT probe follows this call's own backend, so a caller that
714
- // threads `backend` for the launch argv and omits `probe` gets a matching
715
- // readiness route rather than an HTTP POST at a binary-monitor port. An
716
- // explicitly injected `probe` still wins, unchanged.
717
- const probe = deps.probe ?? ((port) => probeReady(port, { backend: deps.backend ?? "fork" }));
718
- // Step 1: promote every "launching" instance whose probe now succeeds.
719
- // Runs regardless of whether a launch is in flight -- promotion and
720
- // speculative warming are independent concerns; an already-launched
721
- // instance becomes usable the moment it answers, whether or not this
722
- // pass goes on to warm anything further.
884
+ // threads `backend` and omits `probe` gets a matching readiness route
885
+ // rather than an HTTP POST at a binary-monitor port. An explicitly
886
+ // injected `probe` still wins, unchanged.
887
+ const probe = deps.probe ?? ((port) => probeReady(port, { backend: deps.backend ?? "stock" }));
723
888
  for (const record of deps.state.instances.values()) {
724
889
  if (record.state !== "launching")
725
890
  continue;
@@ -732,65 +897,6 @@ export async function maintainWarmFloor(deps) {
732
897
  log(`vice-broker: port ${record.port} launching -> ready (${elapsedMs}ms)`);
733
898
  }
734
899
  }
735
- // Step 2 (P-06: the warm-zero "no readiness mechanism" branch that used
736
- // to sit here is GONE -- the surviving probe mechanism is in-process and
737
- // always available, so there is no "no mechanism" state left to warm
738
- // zero against). No new boot starts while one is already under way --
739
- // THE single in-flight counter (countLaunching) both this function and a
740
- // cold acquire (vice-broker.mts's handleAcquire) consult.
741
- if (deps.countLaunching(deps.state) > 0) {
742
- // D-07's launch-slot decision log line, this decision point's own half:
743
- // name WHICH reason currently holds the slot (the launching record's
744
- // own `reason`, whichever call produced it -- cold acquire or an
745
- // earlier warming pass), not merely that warming is waiting.
746
- const inFlightRecord = Array.from(deps.state.instances.values()).find((r) => r.state === "launching");
747
- const winningReason = inFlightRecord?.reason ?? "unknown";
748
- log(`vice-broker: launch-slot decision -- ${winningReason} holds the slot; spare waits (D-07)`);
749
- return;
750
- }
751
- const ready = deps.countReady(deps.state);
752
- const total = deps.countTotal(deps.state);
753
- const warmFloor = resolveWarmFloor(deps.warmFloor);
754
- const ceiling = resolveCeiling(deps.ceiling);
755
- if (!(ready < warmFloor && total < ceiling)) {
756
- return;
757
- }
758
- // acquirePortAndLaunch() holds the SAME single in_flight owner across
759
- // its own async port allocation -- not merely tryLaunchOne()'s
760
- // synchronous spawn instant. This is what actually closes the race
761
- // between this warm-floor launch and a cold acquire (vice-broker.mts's
762
- // handleAcquire) arriving over the TCP control listener at any moment:
763
- // the countLaunching() check just above is a cheap PRE-check (bails
764
- // early when a launch is already recorded), but nextFreePort() is
765
- // itself asynchronous, so without the guard held across the allocation
766
- // too, two overlapping callers could still both be told the same
767
- // candidate port is free before either commits it.
768
- const result = await acquirePortAndLaunch("spare", {
769
- state: deps.state,
770
- stateDir: deps.stateDir,
771
- allocatePort: deps.allocatePort,
772
- allocateRemoteMonitorPort: deps.allocateRemoteMonitorPort,
773
- spawn: deps.spawn,
774
- spawnFactory: deps.spawnFactory,
775
- now: deps.now,
776
- viceBin: deps.viceBin,
777
- mcpHost: deps.mcpHost,
778
- backend: deps.backend,
779
- binmonHost: deps.binmonHost,
780
- });
781
- if (result.ok) {
782
- log(`vice-broker: warmed 1 warm instance this pass -- ${ready + 1} of ${warmFloor} ready, remainder warmed on later passes`);
783
- deps.onLaunched?.(result.record);
784
- }
785
- else if (result.reason === "no_free_port") {
786
- log(`vice-broker: no free port available -- warming no further warm instances; ${ready} of ${warmFloor} ready`);
787
- }
788
- else {
789
- // A launch started (cold or warm) between this function's own
790
- // countLaunching() check above and this call -- a narrow window
791
- // closed by the guard rather than assumed impossible.
792
- log("vice-broker: a warm-floor launch was attempted but a launch was already in flight -- deferring to a later pass");
793
- }
794
900
  }
795
901
  /** The fixed pass order (mirrors vice-broker.sh's own broker_once(), whose
796
902
  * comment names the ordering as load-bearing: "the spare invariant is
@@ -804,21 +910,26 @@ export async function maintainWarmFloor(deps) {
804
910
  * instrumented no-op functions and assert call ORDER without needing a
805
911
  * real broker, a real port or a real launch.
806
912
  *
807
- * D-07 (01.6.2.1-03-PLAN.md): THIS is where launch priority actually lives
808
- * -- serving acquires before maintaining the warm floor is what lets a
809
- * request-driven launch win a freed slot before a warming launch, within
810
- * one pass, on top of the single in-flight owner (acquirePortAndLaunch()'s
811
- * own invariant comment) that this order never weakens. Inverting this
812
- * order lets a warming launch take the slot first and go untested against
813
- * a concurrently arriving acquire, which is exactly the regression
814
- * broker-launch.test.ts's own D-07 priority test is written to catch (its
815
- * own discriminating-power demonstration inverts this exact order and
816
- * observes the test go red). Priority decides only which reason wins the
817
- * NEXT freed slot -- it is never a substitute for the lock, and it never
818
- * kills or abandons whichever boot is already in flight. */
913
+ * Plan 41-05 (folded todo): the warm floor that D-07 (01.6.2.1-03-PLAN.md)
914
+ * originally reasoned about here is GONE -- `promoteLaunching` never calls
915
+ * acquirePortAndLaunch() and so never competes for the single in-flight
916
+ * launch slot the way a warm-floor spare launch used to. `serveAcquires()`
917
+ * (via its own drainPendingAcquires()) is now the ONLY caller in this pass
918
+ * that ever launches anything, so D-07's original "which reason wins a
919
+ * freed slot" question has nothing left to decide BETWEEN these two steps --
920
+ * that reasoning still applies WITHIN the acquire arm itself (two overlapping
921
+ * acquires still resolve through the single in-flight owner
922
+ * (acquirePortAndLaunch()'s own invariant comment), which this reordering
923
+ * never weakens). What the fixed order still buys: promoting AFTER serving
924
+ * means an instance that becomes probe-ready DURING this exact tick is not
925
+ * available to any acquire THIS SAME pass -- selectWarmInstance() sees it on
926
+ * the NEXT pass instead, a bound of one poll interval (VICE_BROKER_POLL_MS),
927
+ * never a correctness gap, since a cold acquire finding no ready candidate
928
+ * falls straight through to its own dedicated cold launch rather than
929
+ * waiting on one. */
819
930
  export async function runBrokerPass(deps) {
820
931
  await deps.serveAcquires();
821
- await deps.maintainWarmFloor();
932
+ await deps.promoteLaunching();
822
933
  }
823
934
  // ===========================================================================
824
935
  // Per-child supervision (Plan 03, Task 2 -- C2/D-23): absorbs
@@ -857,9 +968,11 @@ function resolveCount(envVar, defaultValue, override) {
857
968
  * UNEXPLAINED exit, not this one. The pre-kill crash history and backoff
858
969
  * are carried forward UNCHANGED, and a pre-kill "granted" state is
859
970
  * restored on the fresh record -- the relaunch primitive always creates a
860
- * new record in the "launching" state, and leaving it there would let the
861
- * warm floor's own ready-count numerator mistake a recycled session's own
862
- * machine for an available warm instance.
971
+ * new record in the "launching" state, and leaving it there (once
972
+ * promoted to "ready" by the next probe pass) would let a LATER,
973
+ * UNRELATED acquire's own selectWarmInstance() walk (vice-broker.mts)
974
+ * mistake a recycled session's own machine for an available candidate to
975
+ * grant out from under the session that already owns it.
863
976
  * - deliberateKill set WITHOUT respawnAfterKill -> "deliberate_teardown":
864
977
  * drop the instance, no respawn. This is T-01.6.2-21's whole point --
865
978
  * without reading this flag, every deliberate teardown would respawn
@@ -883,22 +996,25 @@ async function handleExit(reason, port, deps) {
883
996
  return;
884
997
  }
885
998
  const log = deps.log ?? defaultLog;
886
- // Plan 05 (BROK-02/PROTO-08): the process behind this instance's monitor
887
- // socket has just exited, by every path this function can take (crash,
888
- // recycle, or a deliberate teardown) -- clear the ownership record HERE,
999
+ // Plan 05 (BROK-02/PROTO-08), promoted to a per-channel map by plan 41-03
1000
+ // (D-14): the process behind this instance's monitor sockets has just
1001
+ // exited, by every path this function can take (crash, recycle, or a
1002
+ // deliberate teardown) -- clear EVERY channel's ownership record HERE,
889
1003
  // once, before any of those paths branch, so a client that died without
890
- // releasing can never hold this lock forever. Redundant with the
891
- // respawn/delete paths below (a fresh InstanceRecord never carries this
892
- // field forward; a deleted one has no field to carry), but explicit for
893
- // the same reason broker-state.mts's own header comment names this as one
894
- // of the three required clearing sites. Assigned directly (not via
895
- // broker-state.mjs's clearMonitorClient()) -- this module's own
1004
+ // releasing can never hold this lock forever on any channel. Redundant
1005
+ // with the respawn/delete paths below (a fresh InstanceRecord never
1006
+ // carries this forward; a deleted one has no field to carry), but
1007
+ // explicit for the same reason broker-state.mts's own header comment
1008
+ // names this as one of the required clearing sites. Assigned directly
1009
+ // (not via broker-state.mjs's clearMonitorClient()) -- this module's own
896
1010
  // type-only import of that sibling (see this file's own header comment a
897
1011
  // few lines above) is load-bearing: a VALUE import would turn "./broker-
898
1012
  // state.mjs" into a real runtime resolution this file cannot satisfy when
899
1013
  // loaded directly (as broker-launch.test.ts does), rather than the
900
- // compiled resources/ sibling this specifier is actually shaped for.
901
- record.monitorClient = undefined;
1014
+ // compiled resources/ sibling this specifier is actually shaped for. `{}`
1015
+ // (not `undefined`) since `monitorClients` is non-optional -- "no claim
1016
+ // on any channel" is an empty map.
1017
+ record.monitorClients = {};
902
1018
  if (record.deliberateKill) {
903
1019
  if (record.respawnAfterKill) {
904
1020
  // Recycle. Capture the pre-kill state, crash history and backoff
@@ -914,7 +1030,18 @@ async function handleExit(reason, port, deps) {
914
1030
  // with a brand new record, for the same reason the three values above
915
1031
  // are.
916
1032
  const preKillRemoteMonitorPort = record.remoteMonitorPort;
917
- const respawned = launchSupervised(reason, port, deps, preKillCrashTimes, preKillBackoffMs, preKillRemoteMonitorPort);
1033
+ // Phase 33, plan 33-06 (D-16, T-33-24): the launch PROFILE is carried
1034
+ // forward for exactly the reason CR-02 carries the remote-monitor port
1035
+ // forward, and the failure it prevents is sharper. Without this, a
1036
+ // recycled `{warp:true}` instance would come back UNWARPED while its
1037
+ // fresh record still claimed `profile:{warp:true}` -- after which
1038
+ // profileEligible() (vice-broker.mts) would happily hand that instance
1039
+ // to the next warp request. That is precisely the undetectable lie
1040
+ // D-16 exists to structurally exclude, reintroduced one respawn later.
1041
+ // Captured BEFORE launchSupervised() overwrites this port's map entry
1042
+ // with a brand new record, same as the four values above.
1043
+ const preKillProfile = record.profile;
1044
+ const respawned = launchSupervised(reason, port, deps, preKillCrashTimes, preKillBackoffMs, preKillRemoteMonitorPort, preKillProfile);
918
1045
  if (respawned && preKillState === "granted") {
919
1046
  respawned.state = "granted";
920
1047
  }
@@ -963,7 +1090,10 @@ async function handleExit(reason, port, deps) {
963
1090
  // silently strip `-remotemonitor` (and its InstanceRecord field) off the
964
1091
  // replacement, which is what made D-13's "the instance record carries it"
965
1092
  // stop being true the first time an instance was replaced.
966
- const respawned = launchSupervised(reason, port, deps, crashTimes, nextBackoffMs, record.remoteMonitorPort);
1093
+ // Phase 33, plan 33-06: same carry-forward as the recycle branch above --
1094
+ // an unexplained crash must not silently strip `-warp`/`-console` off the
1095
+ // replacement while leaving the record claiming them (D-16, T-33-24).
1096
+ const respawned = launchSupervised(reason, port, deps, crashTimes, nextBackoffMs, record.remoteMonitorPort, record.profile);
967
1097
  deps.onOutcome?.(respawned ? "respawned" : "given_up", port);
968
1098
  }
969
1099
  /** The single exit-listener installation point in the whole module tree.
@@ -990,8 +1120,9 @@ async function handleExit(reason, port, deps) {
990
1120
  export function withCrashSupervision(reason, port, baseSpawn, deps) {
991
1121
  // I-1 rider (08.2-06-PLAN.md, Task 1): forwards a third options argument
992
1122
  // in the BODY, not just the type -- this is the hop that matters most,
993
- // because it wraps every real launch path (cold acquire, warm floor, and
994
- // every respawn). A type-only widening would still silently drop a
1123
+ // because it wraps every real launch path (cold acquire and every
1124
+ // respawn -- plan 41-05 retires the warm floor, the third path this
1125
+ // comment used to name). A type-only widening would still silently drop a
995
1126
  // caller's options at this call site.
996
1127
  return (cmd, args, options) => {
997
1128
  const child = baseSpawn(cmd, args, options);
@@ -1024,8 +1155,20 @@ export function withCrashSupervision(reason, port, baseSpawn, deps) {
1024
1155
  * meanwhile), exactly as it reuses the primary `port` argument; this function
1025
1156
  * stays fully synchronous and never allocates. `undefined` is the correct
1026
1157
  * value for a FIRST launch through superviseChild() and for every fork launch,
1027
- * which is why the parameter is optional. */
1028
- function launchSupervised(reason, port, deps, crashTimes, backoffMs, remoteMonitorPort) {
1158
+ * which is why the parameter is optional.
1159
+ *
1160
+ * Phase 33, plan 33-06 (D-16): `profile` is threaded the SAME way and for a
1161
+ * sharper version of the same reason -- it belongs to the instance, not to a
1162
+ * single spawn of it, and warp is fixed at spawn (there is no runtime
1163
+ * `WarpMode` resource on stock at all). A replacement that dropped it would
1164
+ * come back unwarped while its record still claimed warp, which is exactly the
1165
+ * mismatch-between-grant-and-request that D-16's eligibility rule exists to
1166
+ * make impossible. `undefined` is correct for a FIRST launch through
1167
+ * superviseChild() -- production has no profile-less first-launch call site
1168
+ * of its own left after plan 41-05 retires the warm floor, but this
1169
+ * module's own unit tests still drive one directly -- and for every fork
1170
+ * launch, which is why this parameter is optional too. */
1171
+ function launchSupervised(reason, port, deps, crashTimes, backoffMs, remoteMonitorPort, profile) {
1029
1172
  const supervisorDir = join(deps.stateDir, String(port));
1030
1173
  const epochFile = deps.epoch.epochPathFor(deps.stateDir, port);
1031
1174
  const logDir = deps.epoch.instanceLogDirFor(deps.stateDir, port);
@@ -1067,6 +1210,7 @@ function launchSupervised(reason, port, deps, crashTimes, backoffMs, remoteMonit
1067
1210
  backend: deps.backend,
1068
1211
  binmonHost: deps.binmonHost,
1069
1212
  remoteMonitorPort,
1213
+ profile,
1070
1214
  log: deps.log,
1071
1215
  });
1072
1216
  if (!record)
@@ -1103,8 +1247,19 @@ function launchSupervised(reason, port, deps, crashTimes, backoffMs, remoteMonit
1103
1247
  * (crash respawn with backoff, crash-loop give-up, kill-never-recycle via
1104
1248
  * the deliberate-kill marker, and the per-instance boot/crash log), exactly
1105
1249
  * mirroring resources/vice-supervisor.sh's own respawn loop but expressed
1106
- * as an event-loop exit handler instead of a `while true` poll. */
1107
- export function superviseChild(reason, port, deps) {
1250
+ * as an event-loop exit handler instead of a `while true` poll.
1251
+ *
1252
+ * Plan 41-05 (D-16): `remoteMonitorPort` is an OPTIONAL fourth parameter,
1253
+ * threaded straight through to launchSupervised() exactly like every other
1254
+ * optional trailing parameter in this file. A
1255
+ * `backend: "stock"` caller MUST supply it: spawnAndRecordInstance()'s own
1256
+ * construction-site assertion (D-16) throws otherwise, since this function
1257
+ * is a genuine first-launch call site, not merely a respawn. This is not a
1258
+ * production stock first-launch path today (only acquirePortAndLaunch() is)
1259
+ * -- it exists for this module's own unit tests to drive a supervised first
1260
+ * launch directly, and the parameter exists so a stock test case can do so
1261
+ * without violating the same guarantee production code enforces. */
1262
+ export function superviseChild(reason, port, deps, remoteMonitorPort) {
1108
1263
  const initialBackoffMs = resolveMs("VICE_RESTART_BACKOFF_S", 3, deps.initialBackoffMs);
1109
- return launchSupervised(reason, port, deps, [], initialBackoffMs);
1264
+ return launchSupervised(reason, port, deps, [], initialBackoffMs, remoteMonitorPort);
1110
1265
  }