baychat 0.13.1 → 0.14.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.
@@ -35,26 +35,36 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.cmdRelayStart = cmdRelayStart;
37
37
  exports.ensureRelayInstalled = ensureRelayInstalled;
38
+ exports.tryRelayStatus = tryRelayStatus;
38
39
  exports.cmdRelayStatus = cmdRelayStatus;
39
40
  exports.cmdRelayStop = cmdRelayStop;
41
+ exports.shouldFallBackToMailbox = shouldFallBackToMailbox;
42
+ exports.attachViaMailbox = attachViaMailbox;
43
+ exports.renderSessionLine = renderSessionLine;
40
44
  exports.cmdRelayAttach = cmdRelayAttach;
41
45
  exports.resolveRuntimeBin = resolveRuntimeBin;
42
46
  const child_process_1 = require("child_process");
43
47
  const fs = __importStar(require("fs"));
44
48
  const net = __importStar(require("net"));
45
- const path = __importStar(require("path"));
46
49
  const util_1 = require("util");
47
50
  const adapters_1 = require("./adapters");
48
51
  const autostart_1 = require("./autostart");
52
+ const runtime_binary_1 = require("../runtime-binary");
49
53
  const daemon_1 = require("./daemon");
50
54
  const resume_1 = require("./resume");
55
+ const mailbox_1 = require("./mailbox");
51
56
  const socket_1 = require("./socket");
57
+ const parent_watch_1 = require("./parent-watch");
52
58
  const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
53
59
  /** Connect to a running daemon, or explain that there isn't one. */
54
60
  async function connectOrFail() {
55
61
  const sockPath = (0, socket_1.socketPath)();
56
- if (!(await (0, socket_1.probeSocket)(sockPath))) {
57
- throw new Error("no relay is running — start one with `baychat relay start`");
62
+ const probe = await (0, socket_1.probeSocketDetailed)(sockPath);
63
+ if (!probe.alive) {
64
+ // NOT a flat "no relay is running". See `describeProbeFailure`: a refused
65
+ // pipe and a wedged daemon are both live relays, and telling someone to
66
+ // start another one is the one instruction that makes those worse.
67
+ throw new Error((0, socket_1.describeProbeFailure)(sockPath, probe));
58
68
  }
59
69
  return net.createConnection(sockPath);
60
70
  }
@@ -165,6 +175,26 @@ async function ensureRelayInstalled() {
165
175
  return `Relay: could not start automatically (${err instanceof Error ? err.message : String(err)}). Run \`baychat relay start\` yourself.`;
166
176
  }
167
177
  }
178
+ /**
179
+ * The daemon's status, or null when there is no daemon.
180
+ *
181
+ * `cmdRelayStatus` treats an absent relay as an error to report, which is right
182
+ * for a command whose whole subject is the relay. `doctor` needs the opposite:
183
+ * "not running" is one finding among several and must not abort the rest of the
184
+ * report, so the absence is a value here rather than a thrown error.
185
+ */
186
+ async function tryRelayStatus() {
187
+ try {
188
+ return await request(await connectOrFail(), { type: "status" });
189
+ }
190
+ catch {
191
+ // Every failure means the same thing to a caller that only wants to know
192
+ // whether the relay can answer: no socket, a stale socket, or a daemon too
193
+ // wedged to reply are all "no usable relay". `doctor` reports that state
194
+ // itself; there is nothing here worth logging over it.
195
+ return null;
196
+ }
197
+ }
168
198
  async function cmdRelayStatus() {
169
199
  let status;
170
200
  try {
@@ -184,7 +214,7 @@ async function cmdRelayStatus() {
184
214
  console.log(" none — a session registers itself by running `baychat relay attach`");
185
215
  }
186
216
  for (const s of status.sessions) {
187
- const state = s.attached ? "attached" : s.resumeId ? "detached (headless resume ready)" : "detached (no resume id)";
217
+ const state = sessionState(s);
188
218
  console.log(` ${s.name} [${s.runtime}] ${state}`);
189
219
  console.log(` ${resumeLabel(s)}`);
190
220
  }
@@ -265,6 +295,94 @@ async function cmdRelayStop() {
265
295
  * which then reads the room properly through the BayChat tools. Exit 2 means
266
296
  * the wait lapsed with nothing to report.
267
297
  */
298
+ /**
299
+ * Is this probe failure the sandboxed case, and ONLY that?
300
+ *
301
+ * `denied` means a daemon exists and refused us — precisely a sandbox or token
302
+ * boundary, and precisely what the mailbox is for. Every other reason keeps
303
+ * today's error, deliberately:
304
+ *
305
+ * - `absent` — there is no relay. A mailbox would be created that no daemon
306
+ * is watching, and the agent would block on a FIFO forever believing it was
307
+ * reachable. That is the exact failure this transport exists to end, and
308
+ * the fallback must not reintroduce it.
309
+ * - `timeout` / `error` — a wedged or unexplained daemon. Papering over it
310
+ * with a second channel hides the thing someone needs to fix.
311
+ */
312
+ function shouldFallBackToMailbox(probe) {
313
+ return !probe.alive && probe.reason === "denied";
314
+ }
315
+ /**
316
+ * Arm over the mailbox: register by file, then block on a FIFO read.
317
+ *
318
+ * FOREGROUND, and it must stay foreground. A backgrounded process does not
319
+ * survive the sandbox: measured 2026-08-30, `nohup bash -c 'sleep 25; …' &`
320
+ * inside Codex never wrote its file, because bubblewrap tears down the mount
321
+ * namespace and kills the process group when the command returns. Telling a
322
+ * sandboxed agent to background this would be handing it an instruction that
323
+ * cannot work — which is how one came to report "Armed and joined the group"
324
+ * having armed nothing at all.
325
+ */
326
+ async function attachViaMailbox(opts) {
327
+ // PROVE the root by creating it, rather than resolving one and hoping. A
328
+ // sandbox that permits /tmp may still refuse XDG_RUNTIME_DIR, and only this
329
+ // process can find that out.
330
+ const dir = (0, mailbox_1.pickUsableMailboxDir)(opts.session);
331
+ // Declare the path; the daemon creates the FIFO. Making one requires spawning
332
+ // `mkfifo(1)`, and a sandbox that permits writing files still refuses to spawn
333
+ // a helper binary (measured 2026-08-30: `spawnSync mkfifo EPERM`).
334
+ const fifo = (0, mailbox_1.wakeFifoPath)(dir);
335
+ (0, mailbox_1.writeRegistration)(dir, {
336
+ session: opts.session,
337
+ runtime: opts.runtime,
338
+ resumeId: opts.resume.ok ? opts.resume.resumeId : undefined,
339
+ resumeSource: opts.resume.ok ? opts.resume.source : undefined,
340
+ resumeEvidence: opts.resume.ok ? opts.resume.evidence : undefined,
341
+ resumeCwd: opts.resume.ok ? opts.resume.cwd : undefined,
342
+ 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),
347
+ fifo,
348
+ pid: process.pid,
349
+ registeredAt: new Date().toISOString(),
350
+ });
351
+ await (0, mailbox_1.awaitWakeFifo)(fifo);
352
+ 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");
356
+ const frame = JSON.parse(raw.trim());
357
+ console.log(`WAKE ${frame.messages.length} message(s) in ${frame.conversationId}:`);
358
+ for (const m of frame.messages) {
359
+ const flag = m.shouldRespond ? " [shouldRespond=true]" : "";
360
+ console.log(` (${m.id}) ${m.senderType} ${m.senderId}${flag}: ${m.content}`);
361
+ }
362
+ return 0;
363
+ }
364
+ /** The state half of one `relay status` session line. */
365
+ function sessionState(s) {
366
+ // "registered", not "attached", for the FIFO rung — and the difference is not
367
+ // pedantry. A socket attach IS a held connection, so `attached` is an
368
+ // observation. A mailbox registration is a file on disk; whether the agent is
369
+ // still blocked on its FIFO cannot be checked without ending the wait, since
370
+ // a reader blocked in open() holds no descriptor for /proc to see and opening
371
+ // the write end to look would signal EOF. Delivery is the only honest probe,
372
+ // and it makes it: a wake with no reader is recorded pending, never delivered.
373
+ if (s.attached)
374
+ return s.transport === "fifo" ? "registered (fifo)" : "attached";
375
+ return s.resumeId ? "detached (headless resume ready)" : "detached (no resume id)";
376
+ }
377
+ /**
378
+ * One `relay status` session line.
379
+ *
380
+ * Exported so the rung's wording is testable: a fallback nobody can see in
381
+ * `status` is a silent fallback, which is the thing this transport must not be.
382
+ */
383
+ function renderSessionLine(target) {
384
+ return ` ${target.name} [${target.runtime}] ${sessionState(target)}`;
385
+ }
268
386
  async function cmdRelayAttach(opts) {
269
387
  if (!(0, adapters_1.isKnownRuntime)(opts.runtime)) {
270
388
  console.log(`unknown runtime "${opts.runtime}" — expected one of: ${adapters_1.KNOWN_RUNTIMES.join(", ")}`);
@@ -272,14 +390,18 @@ async function cmdRelayAttach(opts) {
272
390
  }
273
391
  const runtime = opts.runtime;
274
392
  const resume = await resolveAttachResumeId(runtime, opts.resumeId, opts.discovery);
275
- let sock;
276
- try {
277
- sock = await connectOrFail();
393
+ const sockPath = (0, socket_1.socketPath)();
394
+ const probe = await (0, socket_1.probeSocketDetailed)(sockPath);
395
+ if (shouldFallBackToMailbox(probe)) {
396
+ console.log(`Relay socket refused (${probe.alive ? "" : (probe.code ?? "denied")}) — this session is sandboxed.`);
397
+ 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 });
278
399
  }
279
- catch (err) {
280
- console.log(err instanceof Error ? err.message : String(err));
400
+ if (!probe.alive) {
401
+ console.log((0, socket_1.describeProbeFailure)(sockPath, probe));
281
402
  return 1;
282
403
  }
404
+ const sock = net.createConnection(sockPath);
283
405
  return new Promise((resolve) => {
284
406
  const timer = opts.timeoutMs
285
407
  ? setTimeout(() => {
@@ -288,6 +410,19 @@ async function cmdRelayAttach(opts) {
288
410
  resolve(2);
289
411
  }, opts.timeoutMs)
290
412
  : undefined;
413
+ // An attach outlives nothing. When the session that launched it goes away,
414
+ // this process must go with it — otherwise it keeps the socket open, the
415
+ // daemon keeps believing the session is live, and the next wake is written
416
+ // into a corpse and recorded as delivered. See ./parent-watch.ts.
417
+ const parentWatch = (0, parent_watch_1.watchParent)(process.ppid, () => {
418
+ console.log("Session that started this attach has exited — detaching so the relay stops treating it as live.");
419
+ sock.end();
420
+ resolve(3);
421
+ });
422
+ const settle = (code) => {
423
+ parentWatch.stop();
424
+ resolve(code);
425
+ };
291
426
  sock.on("data", (0, socket_1.createFrameReader)((frame) => {
292
427
  if (frame.type === "attached") {
293
428
  console.log(`Attached as "${frame.session}". Waiting for messages…`);
@@ -302,7 +437,7 @@ async function cmdRelayAttach(opts) {
302
437
  console.log(` (${m.id}) ${m.senderType} ${m.senderId}${flag}: ${m.content}`);
303
438
  }
304
439
  sock.end();
305
- resolve(0);
440
+ settle(0);
306
441
  return;
307
442
  }
308
443
  if (frame.type === "error") {
@@ -310,19 +445,19 @@ async function cmdRelayAttach(opts) {
310
445
  clearTimeout(timer);
311
446
  console.log(frame.message);
312
447
  sock.end();
313
- resolve(1);
448
+ settle(1);
314
449
  }
315
450
  }));
316
451
  sock.on("error", (err) => {
317
452
  if (timer)
318
453
  clearTimeout(timer);
319
454
  console.log(err.message);
320
- resolve(1);
455
+ settle(1);
321
456
  });
322
457
  sock.on("close", () => {
323
458
  if (timer)
324
459
  clearTimeout(timer);
325
- resolve(2);
460
+ settle(2);
326
461
  });
327
462
  (0, socket_1.writeFrame)(sock, {
328
463
  type: "attach",
@@ -359,25 +494,36 @@ async function cmdRelayAttach(opts) {
359
494
  * machine where PATH is fine keeps working, and one where it is not now works too.
360
495
  */
361
496
  function resolveRuntimeBin(runtime) {
362
- const dirs = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean);
363
- for (const dir of dirs) {
364
- const candidate = path.join(dir, runtime);
365
- try {
366
- // X_OK, not existsSync: a same-named directory or an unreadable file on PATH
367
- // must not be recorded as the binary, or the recorded path is worse than the
368
- // bare name it replaced.
369
- fs.accessSync(candidate, fs.constants.X_OK);
370
- // Resolve symlinks: ~/.local/bin/claude is typically a link into a versioned
371
- // directory, and recording the link means a later version bump silently
372
- // repoints every wake. The real path is what this session is actually running.
373
- return fs.realpathSync(candidate);
374
- }
375
- catch {
376
- // Not here; keep looking. No logging — a PATH entry that does not hold the
377
- // binary is the normal case, not a problem.
378
- }
497
+ // Delegates to the prover rather than walking PATH itself. The hand-rolled
498
+ // walk this replaces had two Windows faults, and both produced a recorded
499
+ // path the daemon could never spawn:
500
+ //
501
+ // • it joined the BARE name onto each PATH entry, so it never considered
502
+ // `codex.cmd` — and every npm-installed CLI on Windows IS a .cmd shim;
503
+ // • it accepted a candidate on `fs.accessSync(X_OK)`, which Windows has no
504
+ // execute bit to answer, so any readable file passes. The extensionless
505
+ // `codex` — an sh script, unrunnable there — sailed through.
506
+ //
507
+ // Observed 2026-08-30: runtimeBin recorded as
508
+ // `C:\Users\…\npm\codex`, and the wake died with `spawn … ENOENT`.
509
+ //
510
+ // `resolveRuntimeBinary` tries the platform's real extension order and PROVES
511
+ // each candidate by running it, which is the same question this function was
512
+ // always asking — just answered correctly.
513
+ const override = process.env[`BAYCHAT_${runtime.toUpperCase()}_BIN`];
514
+ const resolved = (0, runtime_binary_1.resolveRuntimeBinary)(runtime, (0, runtime_binary_1.currentBinaryEnv)(override));
515
+ if (!resolved.ok)
516
+ return undefined;
517
+ try {
518
+ // Resolve symlinks: ~/.local/bin/claude is typically a link into a versioned
519
+ // directory, and recording the link means a later version bump silently
520
+ // repoints every wake. The real path is what this session is actually running.
521
+ return fs.realpathSync(resolved.path);
522
+ }
523
+ catch {
524
+ // A path we just ran but cannot realpath is still the right answer.
525
+ return resolved.path;
379
526
  }
380
- return undefined;
381
527
  }
382
528
  /**
383
529
  * Decide what resume id this attach registers, and say so out loud.
@@ -36,10 +36,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.RelayDaemon = void 0;
37
37
  const fs = __importStar(require("fs"));
38
38
  const net = __importStar(require("net"));
39
+ const path = __importStar(require("path"));
39
40
  const config_1 = require("../config");
41
+ const runtime_binary_1 = require("../runtime-binary");
40
42
  const adapters_1 = require("./adapters");
41
43
  const queue_1 = require("./queue");
42
44
  const registry_1 = require("./registry");
45
+ const mailbox_1 = require("./mailbox");
46
+ const mailbox_watcher_1 = require("./mailbox-watcher");
43
47
  const socket_1 = require("./socket");
44
48
  const transport_1 = require("./transport");
45
49
  const watermarks_1 = require("./watermarks");
@@ -57,6 +61,9 @@ class RelayDaemon {
57
61
  abort = new AbortController();
58
62
  /** Live attach sockets by session name. */
59
63
  attached = new Map();
64
+ /** Sessions reachable over a mailbox FIFO, by session name. */
65
+ mailboxes = new Map();
66
+ mailboxWatchers;
60
67
  /** Per (session, conversation) delivery positions — the 409 catch-up baseline. */
61
68
  watermarks = new watermarks_1.Watermarks();
62
69
  pending = [];
@@ -75,11 +82,58 @@ class RelayDaemon {
75
82
  discoveryReasons = new Map();
76
83
  discovery;
77
84
  spawnHeadless;
85
+ resolveBinary;
86
+ runTurn;
87
+ /**
88
+ * Resolutions already proven this process, by runtime binary name.
89
+ *
90
+ * Resolution costs a `--version` spawn per candidate, which must not be paid
91
+ * per message. The entry is dropped whenever a headless turn fails, so a user
92
+ * who installs the missing runtime is believed on the next message rather
93
+ * than after a relay restart nothing would have told them to perform.
94
+ */
95
+ binaries = new Map();
78
96
  log;
79
97
  constructor(opts = {}) {
80
98
  this.log = opts.log ?? ((line) => console.log(`[relay] ${line}`));
99
+ // EVERY candidate root, because the daemon cannot know which one the agent's
100
+ // sandbox turned out to permit — it may have fallen back past the one this
101
+ // process would have chosen.
102
+ this.mailboxWatchers = (0, mailbox_1.mailboxRootCandidates)().map((root) => new mailbox_watcher_1.MailboxWatcher({
103
+ root,
104
+ onRegister: (reg) => {
105
+ this.registry.upsert((0, mailbox_1.registrationToTarget)(reg));
106
+ // A session that names itself supersedes anything discovery guessed,
107
+ // exactly as an attach frame does.
108
+ if (reg.resumeId)
109
+ this.discoveryReasons.delete(reg.session);
110
+ // The daemon makes the FIFO, because the agent cannot: creating one
111
+ // needs `mkfifo(1)` spawned, and a sandbox that lets an agent write
112
+ // files still refuses it a helper process. This side has the privilege,
113
+ // so this side does the privileged step.
114
+ try {
115
+ // ENSURE, never recreate: a re-arm must not unlink the FIFO a live
116
+ // reader is already blocked on. See `ensureWakeFifo`.
117
+ (0, mailbox_1.ensureWakeFifo)(path.dirname(reg.fifo));
118
+ }
119
+ catch (err) {
120
+ this.log(`could not create the wake FIFO for ${reg.session}: ${errText(err)}`);
121
+ return;
122
+ }
123
+ this.mailboxes.set(reg.session, reg);
124
+ this.registry.setAttached(reg.session, true);
125
+ this.log(`attached via mailbox: ${reg.session} (${reg.runtime}) at ${reg.fifo}`);
126
+ },
127
+ onDrop: (session, reason) => {
128
+ this.mailboxes.delete(session);
129
+ this.registry.setAttached(session, false);
130
+ this.log(`detached: ${session} — ${reason}`);
131
+ },
132
+ }));
81
133
  this.discovery = opts.discovery ?? {};
82
134
  this.spawnHeadless = opts.spawnHeadless ?? adapters_1.runHeadless;
135
+ this.resolveBinary = opts.resolveBinary ?? ((name) => (0, runtime_binary_1.resolveRuntimeBinary)(name, (0, runtime_binary_1.currentBinaryEnv)()));
136
+ this.runTurn = opts.runTurn;
83
137
  this.queue = new queue_1.SessionQueue((session, batch) => this.deliver(session, batch), (session, err) => {
84
138
  this.lastError = `delivery failed for ${session}: ${errText(err)}`;
85
139
  this.log(this.lastError);
@@ -104,6 +158,10 @@ class RelayDaemon {
104
158
  throw new Error(`a relay is already listening on ${sockPath} — run \`baychat relay status\``);
105
159
  }
106
160
  (0, socket_1.unlinkStaleSocket)(sockPath);
161
+ // Sandboxed agents cannot reach the socket below, so the mailbox has to be
162
+ // watched from the moment the daemon is up — not lazily on first delivery.
163
+ for (const w of this.mailboxWatchers)
164
+ w.start();
107
165
  this.server = net.createServer((sock) => this.onConnection(sock));
108
166
  await new Promise((resolve, reject) => {
109
167
  this.server.once("error", reject);
@@ -189,6 +247,26 @@ class RelayDaemon {
189
247
  async deliver(session, batch) {
190
248
  const target = this.registry.get(session);
191
249
  if (!target) {
250
+ // NOT reported as pending, though an earlier version of this did.
251
+ //
252
+ // `liveSessions` lists every session of the USER, not of this machine. A
253
+ // person with WSL and Windows side by side has BOTH relays receiving
254
+ // events for BOTH machines' sessions, because the server fans out per
255
+ // credential and a session's host is not something it records. Treating
256
+ // "live for this user" as "ours" made each relay report DELIVERY PENDING
257
+ // for sessions the other machine was serving perfectly well — and a false
258
+ // pending is worse than none, because the entire value of that signal is
259
+ // that it means something is genuinely wrong.
260
+ //
261
+ // A session in the registry cannot reach here at all (`target` would be
262
+ // set), so there is no local evidence to appeal to: this name has never
263
+ // been seen on this machine. Another device is very likely handling it,
264
+ // and silence is the honest answer.
265
+ //
266
+ // The genuinely uncovered case — a session that joined a room and never
267
+ // attached ANYWHERE — is per-machine, not per-message, and belongs in
268
+ // `doctor`, which can compare the Bay's live sessions against this
269
+ // machine's registry without ever firing a false alarm.
192
270
  this.record({ kind: "ignored", reason: `unknown session ${session}` }, session, batch);
193
271
  return;
194
272
  }
@@ -202,10 +280,98 @@ class RelayDaemon {
202
280
  return;
203
281
  }
204
282
  const adapter = (0, adapters_1.adapterFor)(target.runtime);
283
+ // ORDERED ABOVE THE FIFO, deliberately.
284
+ //
285
+ // A FIFO hands bytes to a blocked `relay attach`, which prints them and
286
+ // exits. Where the harness re-invokes on that exit (Claude) it is a wake;
287
+ // for an interactive TUI it is only a PRINT — the text appears and nothing
288
+ // makes the agent act on it. Measured 2026-08-31 01:08: delivered via fifo
289
+ // to a live Codex, which then said nothing.
290
+ //
291
+ // `codex queue` puts the message in the session's own turn queue, so the
292
+ // agent ACTS. Prefer the transport that produces an ANSWER over the one
293
+ // that only produces a delivery.
294
+ // RUNG 2: the runtime's OWN inter-session queue, where it has one.
295
+ //
296
+ // Preferred over the headless spawn below because it reaches the LIVE
297
+ // session, where the human already is — so approvals work normally and the
298
+ // relay never acquires a privilege on a chat message's behalf. The headless
299
+ // rung must run `approvalPolicy: "never"`, which blocks Codex's own BayChat
300
+ // write path: observed 2026-08-31, a headlessly-woken Codex read the room
301
+ // and was structurally unable to answer it.
302
+ const resolvedForQueue = await this.resolveResume(target);
303
+ if (adapter.queueMessage && resolvedForQueue.resumeId) {
304
+ // Same rule as the headless spawn: an ABSOLUTE runtimeBin came from the
305
+ // session itself and is never re-resolved; only a bare name is looked up.
306
+ const declared = resolvedForQueue.runtimeBin ?? target.runtime;
307
+ let queueBin = declared;
308
+ if (!path.isAbsolute(declared)) {
309
+ const resolved = this.binaryFor(declared);
310
+ queueBin = resolved.ok ? resolved.path : "";
311
+ }
312
+ if (!queueBin) {
313
+ this.log(`queue unavailable for ${session}: no usable ${target.runtime} binary`);
314
+ }
315
+ else {
316
+ const outcome = await adapter.queueMessage({
317
+ binaryPath: queueBin,
318
+ target: resolvedForQueue,
319
+ message: (0, adapters_1.buildWakePrompt)(session, batch[0].conversationId, batch, undefined),
320
+ });
321
+ if (outcome.kind === "queued") {
322
+ this.record({ kind: "woken", via: "queue", session }, session, batch);
323
+ return;
324
+ }
325
+ // EVERY non-success falls through, including `failed`.
326
+ //
327
+ // The queue is an OPTIMISATION, not a gate: it reaches the live session so
328
+ // approvals work, but the headless rung below is strictly more capable —
329
+ // it carries the Windows `.cmd` spawn plan, the app-server transport, and
330
+ // the binary resolution this path does not replicate. A rung that could
331
+ // not deliver must never shadow one that might; that exact mistake made a
332
+ // stale FIFO swallow three messages earlier tonight, and it would be the
333
+ // same mistake to repeat here for a different reason.
334
+ this.log(`queue did not deliver for ${session} (${outcome.reason}) — falling through to headless`);
335
+ }
336
+ }
205
337
  // A target with no resume id is the unbounded failure this whole path
206
338
  // exists to close: without one, `canResume` says no and the message waits
207
339
  // for a human. Ask the runtime's own on-disk state who this session is
208
340
  // before accepting that answer.
341
+ // RUNG 3: a session whose sandbox refuses the socket, waiting on a FIFO.
342
+ //
343
+ // Ordered after the socket and before headless because it means the SAME
344
+ // thing the socket does — a live session is waiting — while headless means
345
+ // the opposite. `writeWake` never blocks: a FIFO write with no reader would
346
+ // otherwise stall this daemon on one detached agent and stop it serving
347
+ // every other session, which is worse than the bug this rung fixes.
348
+ const mailbox = this.mailboxes.get(session);
349
+ if (mailbox) {
350
+ const payload = `${JSON.stringify({ type: "wake", conversationId: batch[0].conversationId, messages: batch })}\n`;
351
+ const written = (0, mailbox_1.writeWake)(mailbox.fifo, payload);
352
+ if (written.ok) {
353
+ // The one rung where `delivered` is EVIDENCED rather than assumed: a
354
+ // FIFO write completes only when a reader takes the bytes.
355
+ this.record({ kind: "woken", via: "fifo", session }, session, batch);
356
+ return;
357
+ }
358
+ // NOBODY IS WAITING — so FALL THROUGH, do not stop here.
359
+ //
360
+ // A registration is not a listener. The daemon cannot tell a stale
361
+ // registration from a live one on this rung, because a sandboxed agent
362
+ // records a NAMESPACED pid that `processIsAlive` reports alive forever
363
+ // (spec §13). Measured 2026-08-31 00:04: a stale mailbox intercepted three
364
+ // deliveries in a row and recorded each `pending`, while the session had a
365
+ // perfectly good resume id and could have been woken headlessly the whole
366
+ // time. A dead rung was shadowing a working one.
367
+ //
368
+ // ENXIO is the only trustworthy evidence available here, and what it
369
+ // proves is exactly "no live session is waiting" — which is the condition
370
+ // the rung below exists for. So we take it.
371
+ this.log(`mailbox for ${session} has no reader (${written.reason}) — falling through to headless`);
372
+ this.mailboxes.delete(session);
373
+ this.registry.setAttached(session, false);
374
+ }
209
375
  const resolved = await this.resolveResume(target);
210
376
  const check = adapter.canResume(resolved);
211
377
  if (!check.ok) {
@@ -215,13 +381,75 @@ class RelayDaemon {
215
381
  this.record({ kind: "pending", session, reason }, session, batch);
216
382
  return;
217
383
  }
218
- const prompt = (0, adapters_1.buildWakePrompt)(session, batch[0].conversationId, batch);
384
+ // The daemon knows both absolute paths because it IS them — and a session it
385
+ // resumes inherits ITS environment, where neither `node` nor `baychat` is on
386
+ // PATH. Without this the woken session is told to re-arm with a command it
387
+ // cannot run, answers once, and goes quiet.
388
+ const prompt = (0, adapters_1.buildWakePrompt)(session, batch[0].conversationId, batch, {
389
+ node: process.execPath,
390
+ cli: process.argv[1] ?? "",
391
+ runtime: target.runtime,
392
+ // A session that last armed over a mailbox is sandboxed, and a sandboxed
393
+ // session cannot background its re-arm. Telling it to would hand it an
394
+ // instruction that silently does nothing.
395
+ transport: target.transport,
396
+ });
219
397
  const { file, args } = adapter.headlessCommand(resolved, prompt);
398
+ // An ABSOLUTE path came from `SessionTarget.runtimeBin` — recorded by
399
+ // `attach`, inside the session, because the binary a session was launched
400
+ // with is knowable there and nowhere else. Re-resolving it against the
401
+ // daemon's PATH is precisely the guess that rule exists to forbid, and
402
+ // doing so broke a live session: the path ended in `claude.exe`, PATH
403
+ // resolution was asked for a file of that name, and every wake died with
404
+ // "not found on PATH".
405
+ //
406
+ // A BARE NAME has no such provenance, and is where PATH gets it wrong —
407
+ // a Windows shim ahead of the real binary under WSL. That one is resolved.
408
+ let executable = file;
409
+ if (!path.isAbsolute(file)) {
410
+ const binary = this.binaryFor(file);
411
+ if (!binary.ok) {
412
+ this.record({ kind: "pending", session, reason: (0, runtime_binary_1.summarizeResolutionFailure)(binary) }, session, batch);
413
+ return;
414
+ }
415
+ executable = binary.path;
416
+ }
417
+ // A runtime with a richer transport than "spawn a command" gets to use it.
418
+ // Only a transport that could not be used AT ALL falls through to the spawn:
419
+ // a failure that is a real answer about this session — an id that names no
420
+ // thread, a turn the runtime refused — would say exactly the same thing
421
+ // again down the older path, more slowly and less clearly.
422
+ const richTransport = this.runTurn
423
+ ? (input) => this.runTurn({ runtime: target.runtime, ...input })
424
+ : adapter.runTurn?.bind(adapter);
425
+ if (richTransport) {
426
+ const outcome = await richTransport({ binaryPath: executable, target: resolved, prompt });
427
+ if (outcome.kind === "completed") {
428
+ this.record({ kind: "woken", via: "headless", session, exitCode: 0 }, session, batch);
429
+ return;
430
+ }
431
+ if (!outcome.transportUnusable) {
432
+ this.record({ kind: "pending", session, reason: outcome.reason }, session, batch);
433
+ return;
434
+ }
435
+ this.log(`${target.runtime}: falling back to a plain spawn — ${outcome.reason}`);
436
+ }
437
+ // A Windows .cmd needs its interpreter named explicitly, whatever produced
438
+ // the path — resolution or the session's own runtimeBin — so the decision
439
+ // lives here, after both, rather than inside either. Never a shell: these
440
+ // args carry the wake prompt, and therefore other people's message text.
441
+ const plan = (0, runtime_binary_1.spawnPlanFor)(executable, process.platform);
220
442
  // The session's own directory when we know it: `cwd` is only where the
221
443
  // attach process ran, and `codex exec` refuses to start outside a trusted
222
444
  // directory at all.
223
- const { exitCode, stderr } = await this.spawnHeadless(file, args, { cwd: resolved.resumeCwd ?? resolved.cwd });
445
+ const { exitCode, stderr } = await this.spawnHeadless(plan.file, [...plan.prefixArgs, ...args], {
446
+ cwd: resolved.resumeCwd ?? resolved.cwd,
447
+ });
224
448
  if (exitCode !== 0) {
449
+ // The binary ran and the turn still failed, so what we resolved may be
450
+ // stale — a half-finished upgrade, a runtime removed since. Drop it and
451
+ // prove it again next time rather than trusting it for the process's life.
452
+ this.binaries.delete(file);
225
453
  // A non-zero headless turn did not necessarily reply. Recording it as
226
454
  // delivered would claim an answer we cannot evidence.
227
455
  this.record({ kind: "pending", session, reason: `headless ${target.runtime} exited ${exitCode}: ${stderr.slice(0, 200)}` }, session, batch);
@@ -229,6 +457,21 @@ class RelayDaemon {
229
457
  }
230
458
  this.record({ kind: "woken", via: "headless", session, exitCode }, session, batch);
231
459
  }
460
+ /**
461
+ * The proven executable for a runtime, resolved at most once per process
462
+ * until something gives us reason to doubt it.
463
+ */
464
+ binaryFor(name) {
465
+ const known = this.binaries.get(name);
466
+ if (known)
467
+ return known;
468
+ const resolved = this.resolveBinary(name);
469
+ this.binaries.set(name, resolved);
470
+ this.log(resolved.ok
471
+ ? `${name} resolved to ${resolved.path} (${resolved.version})`
472
+ : `${name} unresolved: ${(0, runtime_binary_1.summarizeResolutionFailure)(resolved)}`);
473
+ return resolved;
474
+ }
232
475
  /**
233
476
  * Fill in a missing resume id from the runtime's on-disk state, at most once
234
477
  * every `DISCOVERY_INTERVAL_MS` per session.
@@ -309,6 +552,16 @@ class RelayDaemon {
309
552
  // for it, so drop the throttle and let the next wake use the new id.
310
553
  if (frame.resumeId)
311
554
  this.discoveryReasons.delete(frame.session);
555
+ // EVICT THE PREVIOUS HOLDER. A session name has exactly one listener,
556
+ // and `set` alone would leave the old socket open and its process
557
+ // running — which is how two orphaned attaches came to be stacked on
558
+ // one name on 2026-08-30, each one a place a wake could vanish into.
559
+ // Destroying it makes the displaced attach exit instead of lingering.
560
+ const previous = this.attached.get(frame.session);
561
+ if (previous && previous !== sock) {
562
+ this.log(`replacing attach for ${frame.session}: dropping the previous one`);
563
+ previous.destroy();
564
+ }
312
565
  this.attached.set(frame.session, sock);
313
566
  this.registry.setAttached(frame.session, true);
314
567
  (0, socket_1.writeFrame)(sock, { type: "attached", session: frame.session });
@@ -340,7 +593,8 @@ class RelayDaemon {
340
593
  status() {
341
594
  const sessions = this.registry.all().map((t) => ({
342
595
  ...t,
343
- attached: this.attached.has(t.name),
596
+ attached: this.attached.has(t.name) || this.mailboxes.has(t.name),
597
+ transport: this.attached.has(t.name) ? "socket" : this.mailboxes.has(t.name) ? "fifo" : undefined,
344
598
  }));
345
599
  return {
346
600
  running: true,
@@ -362,6 +616,8 @@ class RelayDaemon {
362
616
  sock.destroy();
363
617
  this.attached.clear();
364
618
  await this.queue.idle();
619
+ for (const w of this.mailboxWatchers)
620
+ w.stop();
365
621
  await new Promise((resolve) => (this.server ? this.server.close(() => resolve()) : resolve()));
366
622
  (0, socket_1.unlinkStaleSocket)((0, socket_1.socketPath)());
367
623
  try {