baychat 0.14.0 → 0.16.0

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.
@@ -43,18 +43,24 @@ exports.attachViaMailbox = attachViaMailbox;
43
43
  exports.renderSessionLine = renderSessionLine;
44
44
  exports.cmdRelayAttach = cmdRelayAttach;
45
45
  exports.resolveRuntimeBin = resolveRuntimeBin;
46
+ exports.decideRuntimeBin = decideRuntimeBin;
46
47
  const child_process_1 = require("child_process");
47
48
  const fs = __importStar(require("fs"));
48
49
  const net = __importStar(require("net"));
50
+ const path = __importStar(require("path"));
49
51
  const util_1 = require("util");
50
52
  const adapters_1 = require("./adapters");
53
+ const profiles_1 = require("./profiles");
54
+ const provider_env_1 = require("./provider-env");
51
55
  const autostart_1 = require("./autostart");
52
56
  const runtime_binary_1 = require("../runtime-binary");
57
+ const owner_pid_1 = require("./owner-pid");
58
+ const parent_watch_1 = require("./parent-watch");
53
59
  const daemon_1 = require("./daemon");
54
60
  const resume_1 = require("./resume");
55
61
  const mailbox_1 = require("./mailbox");
56
62
  const socket_1 = require("./socket");
57
- const parent_watch_1 = require("./parent-watch");
63
+ const parent_watch_2 = require("./parent-watch");
58
64
  const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
59
65
  /** Connect to a running daemon, or explain that there isn't one. */
60
66
  async function connectOrFail() {
@@ -209,24 +215,98 @@ async function cmdRelayStatus() {
209
215
  console.log(` polls: ${status.polls} delivered: ${status.delivered} cursor: ${status.cursor ?? "—"}`);
210
216
  if (status.lastError)
211
217
  console.log(` last error: ${status.lastError}`);
218
+ // Computed before the session list so a line can report its own last failure.
219
+ const byName = new Map(status.sessions.map((s) => [s.name, s]));
220
+ /**
221
+ * Why this failure no longer stands, or undefined if it still does.
222
+ *
223
+ * Returns the REASON rather than a boolean so the line can say what cleared
224
+ * it. "(stale)" with no cause is the same species of unexamined claim this
225
+ * command exists to stop printing.
226
+ */
227
+ const staleReason = (p) => {
228
+ const session = byName.get(p.session);
229
+ // No session at all means nothing has been repaired — that is live, not stale.
230
+ if (!session)
231
+ return undefined;
232
+ // TWO WITNESSES, AND ONLY TWO. Everything weaker was tried and was wrong.
233
+ //
234
+ // A live SOCKET is demonstrably reachable — the attach is holding it now, so
235
+ // whatever this entry records is no longer true. `attached` alone is not
236
+ // that: the daemon sets it for a mailbox REGISTRATION too, and the fifo rung
237
+ // itself refuses to trust one, proving a reader with ENXIO on every write.
238
+ if (session.attached && session.transport !== "fifo")
239
+ return "an attach is holding the socket now";
240
+ // A delivery that actually LANDED after the failure. `registeredAt` was used
241
+ // for this and should not have been: it proves `relay attach` was rerun, and
242
+ // the npm/snap outage was made entirely of attaches that registered
243
+ // perfectly and then failed every single delivery.
244
+ const delivered = session.lastDeliveredAt;
245
+ if (delivered !== undefined && Date.parse(delivered) > Date.parse(p.at)) {
246
+ return `a later delivery landed at ${delivered}`;
247
+ }
248
+ return undefined;
249
+ };
250
+ const lastFailureFor = new Map();
251
+ for (const p of status.pending) {
252
+ if (!staleReason(p))
253
+ lastFailureFor.set(p.session, p.reason);
254
+ }
212
255
  console.log(`\nSessions (${status.sessions.length}):`);
213
256
  if (status.sessions.length === 0) {
214
257
  console.log(" none — a session registers itself by running `baychat relay attach`");
215
258
  }
216
259
  for (const s of status.sessions) {
217
- const state = sessionState(s);
260
+ const state = sessionState(s, lastFailureFor.get(s.name));
218
261
  console.log(` ${s.name} [${s.runtime}] ${state}`);
219
262
  console.log(` ${resumeLabel(s)}`);
220
263
  }
264
+ // HELD is a THIRD state and prints as itself.
265
+ //
266
+ // These messages reached this box, were not delivered, and are not a failure:
267
+ // the agent is mid-turn and they go over the moment it re-arms. Folding them
268
+ // into pending would paint a working relay red; leaving them out is what we
269
+ // came from — before this they lived only in the daemon's heap, so a restart
270
+ // lost them silently and `relay status` never mentioned they had existed.
271
+ //
272
+ // Deliberately does NOT affect the exit code. A monitor should page on
273
+ // pending, never on an agent that is simply busy.
274
+ const held = status.held ?? [];
275
+ if (held.length > 0) {
276
+ const total = held.reduce((n, h) => n + h.count, 0);
277
+ console.log(`\nHELD (${total}) — waiting for a busy session to re-arm, not lost:`);
278
+ for (const h of held) {
279
+ console.log(` ${h.heldAt} ${h.session} ${h.count} message(s) in ${h.conversationId}`);
280
+ }
281
+ }
221
282
  // Pending is the point of the whole command: these are messages that reached
222
283
  // this machine and that nobody answered.
284
+ //
285
+ // But a failure the operator has already FIXED must stop shouting. Pending
286
+ // records are never removed, so before this a single bad afternoon left the
287
+ // command exiting 2 forever — and a status that is permanently red is a status
288
+ // nobody reads. An entry that predates its session's current registration
289
+ // describes a session that has since been repaired: still worth printing as
290
+ // history, no longer worth alerting on.
223
291
  if (status.pending.length > 0) {
224
- console.log(`\nDELIVERY PENDING (${status.pending.length}) reached this box, not answered:`);
292
+ const live = status.pending.filter((p) => !staleReason(p));
293
+ const stale = status.pending.length - live.length;
294
+ const heading = live.length > 0
295
+ ? `DELIVERY PENDING (${live.length}) — reached this box, not answered:`
296
+ : `DELIVERY PENDING (0 live, ${stale} historical) — every one has been superseded by a later success:`;
297
+ console.log(`\n${heading}`);
225
298
  for (const p of status.pending.slice(-10)) {
226
- console.log(` ${p.at} ${p.session} msg ${p.messageId}`);
299
+ const why = staleReason(p);
300
+ console.log(` ${p.at} ${p.session} msg ${p.messageId}${why ? ` (stale — ${why})` : ""}`);
227
301
  console.log(` ${p.reason}`);
228
302
  }
229
- return 2; // distinct exit code so a monitor can alert on it
303
+ if (live.length > 0 && stale > 0) {
304
+ console.log(` (${stale} older entr${stale === 1 ? "y" : "ies"} marked stale — superseded by a later success.)`);
305
+ }
306
+ // Distinct exit code so a monitor can alert on it — but only for failures
307
+ // that still stand.
308
+ if (live.length > 0)
309
+ return 2;
230
310
  }
231
311
  return 0;
232
312
  }
@@ -340,19 +420,51 @@ async function attachViaMailbox(opts) {
340
420
  resumeEvidence: opts.resume.ok ? opts.resume.evidence : undefined,
341
421
  resumeCwd: opts.resume.ok ? opts.resume.cwd : undefined,
342
422
  cwd: process.cwd(),
343
- // Resolved HERE, inside the session, for the same reason the socket rung
344
- // does it: the binary a session was launched with is knowable here and
345
- // nowhere else.
346
- runtimeBin: resolveRuntimeBin(opts.runtime),
423
+ // Resolved inside the session, for the same reason the socket rung does it:
424
+ // the binary a session was launched with is knowable here and nowhere else.
425
+ // Passed in rather than re-probed — see `decideRuntimeBin`.
426
+ runtimeBin: opts.runtimeBin,
347
427
  fifo,
348
428
  pid: process.pid,
429
+ // The SESSION's pid, not this attach's and not its shell's. `pid` above dies
430
+ // with every wake, and `process.ppid` is a throwaway wrapper that exits in
431
+ // seconds — recording either told the daemon a live session had gone, and it
432
+ // spawned a headless duplicate. See `currentOwnerPid`.
433
+ ownerPid: opts.ownerPid ?? (0, owner_pid_1.currentOwnerPid)(opts.runtime),
349
434
  registeredAt: new Date().toISOString(),
350
435
  });
351
- await (0, mailbox_1.awaitWakeFifo)(fifo);
436
+ const timeoutMs = opts.timeoutMs;
437
+ // NON-BLOCKING, because the obvious version cannot be cancelled.
438
+ //
439
+ // `readFile(fifo)` blocks in `open(2)` inside the libuv threadpool. No signal
440
+ // reaches it, and — measured — `process.exit()` does not end the process
441
+ // either: Node waits for that worker, so a "timeout" printed there is a lie the
442
+ // caller then hangs behind. An earlier fix here claimed a bound and shipped
443
+ // exactly that; the test missed it by never reaching the read.
444
+ //
445
+ // `O_RDONLY | O_NONBLOCK` opens a FIFO with no writer IMMEDIATELY, and reads
446
+ // then return nothing until the daemon writes. Polling that is genuinely
447
+ // cancellable: the deadline is a plain loop condition, the process can exit on
448
+ // its own, and there is nothing left blocked behind it.
449
+ const deadline = timeoutMs === undefined ? Number.POSITIVE_INFINITY : Date.now() + timeoutMs;
450
+ try {
451
+ await (0, mailbox_1.awaitWakeFifo)(fifo, Math.min(timeoutMs ?? 30_000, 30_000));
452
+ }
453
+ catch (err) {
454
+ // The relay never made the FIFO. Bounded arm-once treats that as its timeout
455
+ // rather than an error: the registration above still happened, which is the
456
+ // point of arming once.
457
+ if (timeoutMs === undefined)
458
+ throw err;
459
+ console.log(err instanceof Error ? err.message : String(err));
460
+ return 2;
461
+ }
352
462
  console.log(`Attached as "${opts.session}" over a mailbox FIFO (${fifo}). Waiting for messages…`);
353
- // Opening for read blocks until the daemon opens for write. That block IS the
354
- // wait, and it costs nothing: no model is running while it holds.
355
- const raw = await fs.promises.readFile(fifo, "utf8");
463
+ const raw = await readWakeBounded(fifo, deadline);
464
+ if (raw === "timeout") {
465
+ console.log("No new messages before timeout. Registered and reachable — the relay will wake this session.");
466
+ return 2;
467
+ }
356
468
  const frame = JSON.parse(raw.trim());
357
469
  console.log(`WAKE ${frame.messages.length} message(s) in ${frame.conversationId}:`);
358
470
  for (const m of frame.messages) {
@@ -361,8 +473,49 @@ async function attachViaMailbox(opts) {
361
473
  }
362
474
  return 0;
363
475
  }
476
+ /**
477
+ * Wait for one wake frame on a FIFO, without ever blocking uninterruptibly.
478
+ *
479
+ * Returns "timeout" instead of throwing: for `arm-once` a timeout is the normal
480
+ * ending, not a failure — registration already happened, and the relay owns
481
+ * every later wake.
482
+ */
483
+ async function readWakeBounded(fifo, deadline, pollMs = 25) {
484
+ const handle = await fs.promises.open(fifo, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK);
485
+ try {
486
+ const buf = Buffer.alloc(64 * 1024);
487
+ let acc = "";
488
+ for (;;) {
489
+ let bytes = 0;
490
+ try {
491
+ ({ bytesRead: bytes } = await handle.read(buf, 0, buf.length, null));
492
+ }
493
+ catch (err) {
494
+ // EAGAIN is "no writer has written yet" on a non-blocking FIFO, which is
495
+ // the normal state of this wait. Anything else is a real fault.
496
+ if (err.code !== "EAGAIN")
497
+ throw err;
498
+ }
499
+ // A read of 0 is NOT end-of-input here. With no writer attached a
500
+ // non-blocking FIFO reports EOF, and the writer we are waiting for has not
501
+ // arrived yet — treating it as the end would turn every wait into an
502
+ // instant empty answer.
503
+ if (bytes > 0) {
504
+ acc += buf.subarray(0, bytes).toString("utf8");
505
+ if (acc.includes("\n"))
506
+ return acc;
507
+ }
508
+ if (Date.now() >= deadline)
509
+ return "timeout";
510
+ await new Promise((r) => setTimeout(r, pollMs));
511
+ }
512
+ }
513
+ finally {
514
+ await handle.close();
515
+ }
516
+ }
364
517
  /** The state half of one `relay status` session line. */
365
- function sessionState(s) {
518
+ function sessionState(s, lastWakeFailure) {
366
519
  // "registered", not "attached", for the FIFO rung — and the difference is not
367
520
  // pedantry. A socket attach IS a held connection, so `attached` is an
368
521
  // observation. A mailbox registration is a file on disk; whether the agent is
@@ -370,8 +523,50 @@ function sessionState(s) {
370
523
  // a reader blocked in open() holds no descriptor for /proc to see and opening
371
524
  // the write end to look would signal EOF. Delivery is the only honest probe,
372
525
  // and it makes it: a wake with no reader is recorded pending, never delivered.
526
+ // A live SOCKET outranks a past failure: the attach is holding it right now.
527
+ // A FIFO REGISTRATION does not, and must not short-circuit the check below —
528
+ // that is the same "a registration is not a reader" rule `staleReason` applies
529
+ // to the pending list, and leaving it out here meant a session whose last wake
530
+ // died still printed a clean `registered (fifo)` on the one line a human reads.
531
+ if (s.attached && s.transport !== "fifo")
532
+ return "attached";
533
+ if (s.attached && !lastWakeFailure)
534
+ return "registered (fifo)";
373
535
  if (s.attached)
374
- return s.transport === "fifo" ? "registered (fifo)" : "attached";
536
+ return `registered (fifo) — LAST WAKE FAILED, not reachable: ${lastWakeFailure}`;
537
+ // "headless resume ready" is a claim about a PRECONDITION — that we hold an id
538
+ // — and for a long time it was printed even while every wake using that id had
539
+ // failed. A non-engineer reads "ready" as "fine", so the one line that answers
540
+ // "is anything listening?" was the line most likely to mislead. If a wake has
541
+ // actually been tried and failed since this session registered, say THAT: an
542
+ // outcome outranks a precondition.
543
+ if (lastWakeFailure)
544
+ return `detached — LAST WAKE FAILED, not reachable: ${lastWakeFailure}`;
545
+ // INTERRUPTED, NOT GONE — and this line used to read "headless resume ready",
546
+ // which sounds fine and is now actively false.
547
+ //
548
+ // Stopping a task in the driving harness kills its background `relay attach`
549
+ // from OUTSIDE the process: no self-detach line is printed, nothing fails, the
550
+ // registration simply stops having a listener. And interrupting your own agent
551
+ // is an ORDINARY thing to do — every other fault in this release needed an
552
+ // unusual machine; this one needs Ctrl+C.
553
+ //
554
+ // "Headless resume ready" is wrong here for a specific reason: the daemon will
555
+ // NOT resume a session whose owning process is still alive — that is the
556
+ // no-clone guard — so a wake is held as pending instead. Reachable is exactly
557
+ // what this session is not.
558
+ //
559
+ // SCOPED TO RUNTIMES WITH NO LIVE-SESSION QUEUE. Codex is deliberately
560
+ // `arm-once`: its bounded attach exits and the session is SUPPOSED to sit
561
+ // detached with a live owner, because rung 2 hands the message to
562
+ // `codex queue` — which reaches the live session, and is tried BEFORE the
563
+ // no-clone guard is ever consulted. Warning there would alarm every healthy
564
+ // Codex on the machine, and a warning that fires when nothing is wrong is how
565
+ // a real one stops being read.
566
+ const hasLiveQueue = (0, adapters_1.adapterFor)(s.runtime).queueMessage !== undefined;
567
+ if (!hasLiveQueue && s.ownerPid !== undefined && (0, parent_watch_1.processIsAlive)(s.ownerPid)) {
568
+ return "detached — NOTHING IS LISTENING, re-arm: the session is alive but its attach is gone (interrupted?), so a wake will be held, not resumed";
569
+ }
375
570
  return s.resumeId ? "detached (headless resume ready)" : "detached (no resume id)";
376
571
  }
377
572
  /**
@@ -380,22 +575,73 @@ function sessionState(s) {
380
575
  * Exported so the rung's wording is testable: a fallback nobody can see in
381
576
  * `status` is a silent fallback, which is the thing this transport must not be.
382
577
  */
383
- function renderSessionLine(target) {
384
- return ` ${target.name} [${target.runtime}] ${sessionState(target)}`;
578
+ function renderSessionLine(target, lastWakeFailure) {
579
+ return ` ${target.name} [${target.runtime}] ${sessionState(target, lastWakeFailure)}`;
385
580
  }
386
581
  async function cmdRelayAttach(opts) {
387
- if (!(0, adapters_1.isKnownRuntime)(opts.runtime)) {
388
- console.log(`unknown runtime "${opts.runtime}" — expected one of: ${adapters_1.KNOWN_RUNTIMES.join(", ")}`);
582
+ // NO LONGER A GATE.
583
+ //
584
+ // This used to exit 1 on any name outside a list of four, which refused
585
+ // agents that needed nothing from us: being handed a message over an attach
586
+ // socket asks nothing of a runtime beyond running a command and waiting.
587
+ // `unknown runtime "kimi"` was us declining to serve something we could serve
588
+ // perfectly well. An unrecognised name now attaches and works at Level 1, and
589
+ // says plainly what it cannot do.
590
+ const runtime = opts.runtime;
591
+ const profile = (0, profiles_1.profileFor)(runtime);
592
+ if (!(0, adapters_1.isKnownRuntime)(runtime)) {
593
+ console.log(`No built-in profile for "${runtime}" — attaching anyway.`);
594
+ console.log(" It will be woken while this attach is running. When nothing is listening, a");
595
+ console.log(" message waits here instead and `relay status` says why.");
596
+ console.log(" If it can continue a session without a UI, tell us and we will ship support:");
597
+ console.log(" https://github.com/SeaQuestdev/BayChat/issues");
598
+ }
599
+ else if (profile && !profile.headless) {
600
+ // Said at ATTACH time, not discovered weeks later from a pending entry. A
601
+ // limitation nobody was told about reads as a broken relay.
602
+ console.log(`${profile.label}: reachable while this attach is running.`);
603
+ console.log(` Not resumable when nothing is listening — ${profile.noHeadlessReason}.`);
604
+ console.log(` (checked ${profile.checked}; if that has changed, please tell us)`);
605
+ }
606
+ // An explicit BAYCHAT_<RUNTIME>_BIN is a user ASSERTION, not a hint: they have
607
+ // told us which binary this session runs under. If it cannot be proven, the
608
+ // only honest move is to stop. Recording `runtimeBin: undefined` and attaching
609
+ // anyway looks like success and hands the daemon back exactly the guess this
610
+ // setting exists to prevent — which is how a confined snap `codex` came to be
611
+ // spawned for an npm session and every wake died "no rollout found".
612
+ // Probed ONCE, here, and carried to whichever transport wins below.
613
+ const runtimeBin = decideRuntimeBin(runtime);
614
+ if (!runtimeBin.ok) {
615
+ console.log(`BAYCHAT_${runtime.toUpperCase()}_BIN is set but unusable: ${runtimeBin.reason}`);
616
+ console.log("Not attaching. Fix it or unset it — nothing was registered, so the relay will not report this session as reachable.");
389
617
  return 1;
390
618
  }
391
- const runtime = opts.runtime;
619
+ // Read from THIS session's environment, which is the only place it exists —
620
+ // the daemon's own environment is a different thing entirely, and that gap is
621
+ // the whole reason this is recorded here rather than looked up there.
622
+ const provider = (0, provider_env_1.providerOverrideFrom)(process.env);
623
+ if (provider) {
624
+ console.log(`Provider override: ${provider.url} (${provider.variable}).`);
625
+ console.log(" Carried over as the URL only — your key is deliberately NOT stored by BayChat.");
626
+ console.log(" While this attach is running you are reached without being restarted, so it does not matter.");
627
+ console.log(" For wakes when nothing is listening, put the key in the relay service once; `relay status` says how.");
628
+ }
392
629
  const resume = await resolveAttachResumeId(runtime, opts.resumeId, opts.discovery);
393
630
  const sockPath = (0, socket_1.socketPath)();
394
631
  const probe = await (0, socket_1.probeSocketDetailed)(sockPath);
395
632
  if (shouldFallBackToMailbox(probe)) {
396
633
  console.log(`Relay socket refused (${probe.alive ? "" : (probe.code ?? "denied")}) — this session is sandboxed.`);
397
634
  console.log("Falling back to a mailbox FIFO, which a sandbox permits. `relay status` will show this session as attached (fifo).");
398
- return attachViaMailbox({ session: opts.session, runtime, resume });
635
+ return attachViaMailbox({
636
+ session: opts.session,
637
+ runtime,
638
+ resume,
639
+ runtimeBin: runtimeBin.bin,
640
+ ownerPid: opts.ownerPid,
641
+ // Honoured on BOTH transports now. It was socket-only, which made
642
+ // `--timeout 30` a no-op precisely where a sandboxed session needs it.
643
+ timeoutMs: opts.timeoutMs,
644
+ });
399
645
  }
400
646
  if (!probe.alive) {
401
647
  console.log((0, socket_1.describeProbeFailure)(sockPath, probe));
@@ -414,7 +660,7 @@ async function cmdRelayAttach(opts) {
414
660
  // this process must go with it — otherwise it keeps the socket open, the
415
661
  // daemon keeps believing the session is live, and the next wake is written
416
662
  // into a corpse and recorded as delivered. See ./parent-watch.ts.
417
- const parentWatch = (0, parent_watch_1.watchParent)(process.ppid, () => {
663
+ const parentWatch = (0, parent_watch_2.watchParent)(process.ppid, () => {
418
664
  console.log("Session that started this attach has exited — detaching so the relay stops treating it as live.");
419
665
  sock.end();
420
666
  resolve(3);
@@ -468,9 +714,16 @@ async function cmdRelayAttach(opts) {
468
714
  resumeEvidence: resume.ok ? resume.evidence : undefined,
469
715
  resumeCwd: resume.ok ? resume.cwd : undefined,
470
716
  cwd: process.cwd(),
471
- // Resolved HERE, inside the session, because this is the only process that
472
- // can answer it correctly — see SessionTarget.runtimeBin.
473
- runtimeBin: resolveRuntimeBin(runtime),
717
+ // Decided once above, not re-probed here see `decideRuntimeBin`.
718
+ runtimeBin: runtimeBin.bin,
719
+ // See SessionTarget.ownerPid: the runtime process that owns this session —
720
+ // found by walking past the launching shell, which does not outlive the
721
+ // wake and so cannot witness anything.
722
+ ownerPid: opts.ownerPid ?? (0, owner_pid_1.currentOwnerPid)(runtime),
723
+ // The URL only. `provider-env.ts` is the module that guarantees no
724
+ // credential travels with it.
725
+ providerUrl: provider?.url,
726
+ providerVar: provider?.variable,
474
727
  });
475
728
  });
476
729
  }
@@ -510,19 +763,59 @@ function resolveRuntimeBin(runtime) {
510
763
  // `resolveRuntimeBinary` tries the platform's real extension order and PROVES
511
764
  // each candidate by running it, which is the same question this function was
512
765
  // always asking — just answered correctly.
766
+ const decided = decideRuntimeBin(runtime);
767
+ return decided.ok ? decided.bin : undefined;
768
+ }
769
+ /**
770
+ * What to record for this runtime, or why the attach must not proceed.
771
+ *
772
+ * ONE probe, and the caller carries the answer. `resolveRuntimeBinary` SPAWNS
773
+ * each candidate to prove it, so asking twice is not merely wasteful: the second
774
+ * answer can differ from the first. An attach that passed the override guard and
775
+ * then re-probed could still register `runtimeBin: undefined` — the exact state
776
+ * the guard exists to prevent, reached by way of the guard.
777
+ */
778
+ function decideRuntimeBin(runtime) {
513
779
  const override = process.env[`BAYCHAT_${runtime.toUpperCase()}_BIN`];
514
780
  const resolved = (0, runtime_binary_1.resolveRuntimeBinary)(runtime, (0, runtime_binary_1.currentBinaryEnv)(override));
515
- if (!resolved.ok)
516
- return undefined;
781
+ if (!resolved.ok) {
782
+ // An explicit override is a user ASSERTION. If it cannot be proven, stop —
783
+ // see the guard in `cmdRelayAttach`. Absence of one is not an assertion, so
784
+ // a plain failed probe keeps the best-effort fallback.
785
+ if (override)
786
+ return { ok: false, reason: (0, runtime_binary_1.summarizeResolutionFailure)(resolved) };
787
+ return { ok: true, bin: managedRuntimeBin(runtime) };
788
+ }
517
789
  try {
518
790
  // Resolve symlinks: ~/.local/bin/claude is typically a link into a versioned
519
791
  // directory, and recording the link means a later version bump silently
520
792
  // repoints every wake. The real path is what this session is actually running.
521
- return fs.realpathSync(resolved.path);
793
+ return { ok: true, bin: fs.realpathSync(resolved.path) };
522
794
  }
523
795
  catch {
524
796
  // A path we just ran but cannot realpath is still the right answer.
525
- return resolved.path;
797
+ return { ok: true, bin: resolved.path };
798
+ }
799
+ }
800
+ function managedRuntimeBin(runtime) {
801
+ if (runtime !== "codex")
802
+ return undefined;
803
+ const root = process.env.CODEX_MANAGED_PACKAGE_ROOT?.trim();
804
+ if (!root)
805
+ return undefined;
806
+ const candidate = path.join(root, "bin", "codex.js");
807
+ // PROVEN, not assumed. `statSync().isFile()` was the whole check here, so any
808
+ // file at that path counted — including one that cannot be spawned. Recording
809
+ // an unrunnable path is the failure this fallback exists to prevent, so it is
810
+ // put through the same prover an override gets: spawn it, ask its version.
811
+ const proved = (0, runtime_binary_1.resolveRuntimeBinary)(runtime, (0, runtime_binary_1.currentBinaryEnv)(candidate));
812
+ if (!proved.ok)
813
+ return undefined;
814
+ try {
815
+ return fs.realpathSync(proved.path);
816
+ }
817
+ catch {
818
+ return proved.path;
526
819
  }
527
820
  }
528
821
  /**