baychat 0.13.1 → 0.15.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.
@@ -36,10 +36,16 @@ 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");
43
+ const parent_watch_1 = require("./parent-watch");
41
44
  const queue_1 = require("./queue");
45
+ const held_1 = require("./held");
42
46
  const registry_1 = require("./registry");
47
+ const mailbox_1 = require("./mailbox");
48
+ const mailbox_watcher_1 = require("./mailbox-watcher");
43
49
  const socket_1 = require("./socket");
44
50
  const transport_1 = require("./transport");
45
51
  const watermarks_1 = require("./watermarks");
@@ -57,6 +63,9 @@ class RelayDaemon {
57
63
  abort = new AbortController();
58
64
  /** Live attach sockets by session name. */
59
65
  attached = new Map();
66
+ /** Sessions reachable over a mailbox FIFO, by session name. */
67
+ mailboxes = new Map();
68
+ mailboxWatchers;
60
69
  /** Per (session, conversation) delivery positions — the 409 catch-up baseline. */
61
70
  watermarks = new watermarks_1.Watermarks();
62
71
  pending = [];
@@ -75,11 +84,100 @@ class RelayDaemon {
75
84
  discoveryReasons = new Map();
76
85
  discovery;
77
86
  spawnHeadless;
87
+ isAlive;
88
+ /**
89
+ * Sessions with a headless turn running right now — the one-session lease.
90
+ *
91
+ * `SessionQueue` serialises `deliver()` calls, which is NOT the same thing: a
92
+ * headless turn is awaited inside one of those calls, and a second delivery
93
+ * arriving after it resolves would happily start another. Two headless agents
94
+ * under one name is the same fault as the attach/headless clone, reached by a
95
+ * different door.
96
+ *
97
+ * HONEST LIMIT: this can stop a new headless STARTING. It cannot un-run one
98
+ * already in flight, so it does not make a duplicate impossible — it makes the
99
+ * daemon stop creating them.
100
+ */
101
+ headlessInFlight = new Set();
102
+ /**
103
+ * Batches held for a session that is ALIVE but between wakes.
104
+ *
105
+ * A socket attach exits on every wake, and the turn it triggers runs for
106
+ * MINUTES before the session re-arms. A message arriving in that window is not
107
+ * undeliverable — it is EARLY. Timing out and recording it pending threw away
108
+ * two of the owner's own messages on 2026-08-31 while the agent was busy
109
+ * answering the one before them, which reads to a user as the relay stopping.
110
+ *
111
+ * Held here and flushed the instant the session attaches again.
112
+ *
113
+ * KEYED BY CONVERSATION as well as session. A session is in several rooms, and
114
+ * a wake frame carries ONE conversation id — so merging every held batch under
115
+ * the first one tells the agent that messages from room B came from room A, and
116
+ * sends it to re-read the wrong room. Rooms are held apart and flushed apart.
117
+ *
118
+ * PERSISTED, and that is not a detail. While this was a plain Map a daemon
119
+ * restart lost every held message, and the poll cursor had already moved past
120
+ * them, so the server never sent them again — gone, with `relay status`
121
+ * reporting nothing at all. See `held.ts`.
122
+ */
123
+ held = new held_1.HeldStore();
124
+ /** Beyond this a session is not "busy", it is broken; stop growing and report. */
125
+ static MAX_HELD = 200;
126
+ reattachGraceMs;
127
+ resolveBinary;
128
+ runTurn;
129
+ /**
130
+ * Resolutions already proven this process, by runtime binary name.
131
+ *
132
+ * Resolution costs a `--version` spawn per candidate, which must not be paid
133
+ * per message. The entry is dropped whenever a headless turn fails, so a user
134
+ * who installs the missing runtime is believed on the next message rather
135
+ * than after a relay restart nothing would have told them to perform.
136
+ */
137
+ binaries = new Map();
78
138
  log;
79
139
  constructor(opts = {}) {
80
140
  this.log = opts.log ?? ((line) => console.log(`[relay] ${line}`));
141
+ // EVERY candidate root, because the daemon cannot know which one the agent's
142
+ // sandbox turned out to permit — it may have fallen back past the one this
143
+ // process would have chosen.
144
+ this.mailboxWatchers = (0, mailbox_1.mailboxRootCandidates)().map((root) => new mailbox_watcher_1.MailboxWatcher({
145
+ root,
146
+ onRegister: (reg) => {
147
+ this.registry.upsert((0, mailbox_1.registrationToTarget)(reg));
148
+ // A session that names itself supersedes anything discovery guessed,
149
+ // exactly as an attach frame does.
150
+ if (reg.resumeId)
151
+ this.discoveryReasons.delete(reg.session);
152
+ // The daemon makes the FIFO, because the agent cannot: creating one
153
+ // needs `mkfifo(1)` spawned, and a sandbox that lets an agent write
154
+ // files still refuses it a helper process. This side has the privilege,
155
+ // so this side does the privileged step.
156
+ try {
157
+ // ENSURE, never recreate: a re-arm must not unlink the FIFO a live
158
+ // reader is already blocked on. See `ensureWakeFifo`.
159
+ (0, mailbox_1.ensureWakeFifo)(path.dirname(reg.fifo));
160
+ }
161
+ catch (err) {
162
+ this.log(`could not create the wake FIFO for ${reg.session}: ${errText(err)}`);
163
+ return;
164
+ }
165
+ this.mailboxes.set(reg.session, reg);
166
+ this.registry.setAttached(reg.session, true);
167
+ this.log(`attached via mailbox: ${reg.session} (${reg.runtime}) at ${reg.fifo}`);
168
+ },
169
+ onDrop: (session, reason) => {
170
+ this.mailboxes.delete(session);
171
+ this.registry.setAttached(session, false);
172
+ this.log(`detached: ${session} — ${reason}`);
173
+ },
174
+ }));
81
175
  this.discovery = opts.discovery ?? {};
82
176
  this.spawnHeadless = opts.spawnHeadless ?? adapters_1.runHeadless;
177
+ this.isAlive = opts.isAlive ?? parent_watch_1.processIsAlive;
178
+ this.reattachGraceMs = opts.reattachGraceMs ?? 4000;
179
+ this.resolveBinary = opts.resolveBinary ?? ((name) => (0, runtime_binary_1.resolveRuntimeBinary)(name, (0, runtime_binary_1.currentBinaryEnv)()));
180
+ this.runTurn = opts.runTurn;
83
181
  this.queue = new queue_1.SessionQueue((session, batch) => this.deliver(session, batch), (session, err) => {
84
182
  this.lastError = `delivery failed for ${session}: ${errText(err)}`;
85
183
  this.log(this.lastError);
@@ -99,11 +197,19 @@ class RelayDaemon {
99
197
  }
100
198
  const auth = { baseUrl: device.baseUrl, token: device.token };
101
199
  this.registry.load();
200
+ // Held messages outlive the process that held them. Loaded before the socket
201
+ // opens, so a session that re-arms in the first moments of a new daemon is
202
+ // handed what the previous one was holding rather than a clean slate.
203
+ this.held.load();
102
204
  const sockPath = (0, socket_1.socketPath)();
103
205
  if (await (0, socket_1.probeSocket)(sockPath)) {
104
206
  throw new Error(`a relay is already listening on ${sockPath} — run \`baychat relay status\``);
105
207
  }
106
208
  (0, socket_1.unlinkStaleSocket)(sockPath);
209
+ // Sandboxed agents cannot reach the socket below, so the mailbox has to be
210
+ // watched from the moment the daemon is up — not lazily on first delivery.
211
+ for (const w of this.mailboxWatchers)
212
+ w.start();
107
213
  this.server = net.createServer((sock) => this.onConnection(sock));
108
214
  await new Promise((resolve, reject) => {
109
215
  this.server.once("error", reject);
@@ -181,6 +287,59 @@ class RelayDaemon {
181
287
  // could never have matched anything.
182
288
  this.queue.push(sessionName, { ...message, conversationId });
183
289
  }
290
+ /**
291
+ * Hand a freshly attached session everything held while it was mid-turn.
292
+ *
293
+ * Sent as ONE wake: the messages arrived while the agent was busy and are read
294
+ * together, in order, the way a person returning to a chat reads them. Waking
295
+ * once per held message would spend a turn on each and let the agent answer the
296
+ * first three not knowing the fourth exists.
297
+ */
298
+ async flushHeld(session, sock) {
299
+ const rooms = this.held.roomsFor(session);
300
+ if (rooms.length === 0)
301
+ return;
302
+ for (const room of rooms) {
303
+ try {
304
+ // ACKNOWLEDGED, not fired and forgotten. A hand-over into a socket that
305
+ // has already died would otherwise clear the hold AND record `woken` —
306
+ // destroying the only copy of the messages and filing them as
307
+ // delivered, which is worse than never having held them.
308
+ await (0, socket_1.writeFrameAck)(sock, { type: "wake", conversationId: room.conversationId, messages: room.messages });
309
+ }
310
+ catch (err) {
311
+ // KEEP IT. This store is the only copy: dropping it because the handoff
312
+ // failed turns "held" into "lost", which is the failure it exists to
313
+ // prevent. The next attach gets it instead.
314
+ this.log(`could not hand ${room.messages.length} held message(s) for ${session} over: ${errText(err)}`);
315
+ return;
316
+ }
317
+ // Removed only once it is actually on the wire, and only for THIS room —
318
+ // a failure on the second room must not discard the first's receipt or the
319
+ // third's contents.
320
+ this.held.clearRoom(session, room.conversationId);
321
+ this.record({ kind: "woken", via: "attach", session }, session, room.messages);
322
+ this.log(`delivered ${room.messages.length} held message(s) to ${session} in ${room.conversationId} on re-attach`);
323
+ }
324
+ }
325
+ /**
326
+ * Wait for a session to re-arm its socket, up to `graceMs`.
327
+ *
328
+ * Polled rather than event-driven on purpose: the attach path already evicts
329
+ * a previous holder and re-registers, so the socket map is the single source
330
+ * of truth, and subscribing to it would mean a second one to keep in step.
331
+ */
332
+ async waitForReattach(session, graceMs) {
333
+ const deadline = Date.now() + graceMs;
334
+ for (;;) {
335
+ const sock = this.attached.get(session);
336
+ if (sock && !sock.destroyed)
337
+ return sock;
338
+ if (Date.now() >= deadline)
339
+ return undefined;
340
+ await new Promise((r) => setTimeout(r, 10));
341
+ }
342
+ }
184
343
  /**
185
344
  * Deliver one coalesced batch to one session. Runs under the queue's
186
345
  * per-session lock, so an attach wake and a headless resume can never be in
@@ -189,23 +348,224 @@ class RelayDaemon {
189
348
  async deliver(session, batch) {
190
349
  const target = this.registry.get(session);
191
350
  if (!target) {
351
+ // NOT reported as pending, though an earlier version of this did.
352
+ //
353
+ // `liveSessions` lists every session of the USER, not of this machine. A
354
+ // person with WSL and Windows side by side has BOTH relays receiving
355
+ // events for BOTH machines' sessions, because the server fans out per
356
+ // credential and a session's host is not something it records. Treating
357
+ // "live for this user" as "ours" made each relay report DELIVERY PENDING
358
+ // for sessions the other machine was serving perfectly well — and a false
359
+ // pending is worse than none, because the entire value of that signal is
360
+ // that it means something is genuinely wrong.
361
+ //
362
+ // A session in the registry cannot reach here at all (`target` would be
363
+ // set), so there is no local evidence to appeal to: this name has never
364
+ // been seen on this machine. Another device is very likely handling it,
365
+ // and silence is the honest answer.
366
+ //
367
+ // The genuinely uncovered case — a session that joined a room and never
368
+ // attached ANYWHERE — is per-machine, not per-message, and belongs in
369
+ // `doctor`, which can compare the Bay's live sessions against this
370
+ // machine's registry without ever firing a false alarm.
192
371
  this.record({ kind: "ignored", reason: `unknown session ${session}` }, session, batch);
193
372
  return;
194
373
  }
195
374
  const sock = this.attached.get(session);
196
375
  if (sock && !sock.destroyed) {
197
- // Live session: hand it the batch and let the harness re-invoke it. The
198
- // attach client exits after one wake, which is what makes this a wake
199
- // rather than a stream — and why the socket is dropped here.
200
- (0, socket_1.writeFrame)(sock, { type: "wake", conversationId: batch[0].conversationId, messages: batch });
201
- this.record({ kind: "woken", via: "attach", session }, session, batch);
202
- return;
376
+ // AN OPEN SOCKET IS NOT A LIVE SESSION.
377
+ //
378
+ // `watchParent` used to guarantee that by making an orphaned attach exit
379
+ // with its launcher. Without it — and a `Stop` hook has to detach, so it
380
+ // cannot rely on ppid — an orphan keeps its socket open, the daemon reads
381
+ // "socket open" as "session alive", and the wake is written into a corpse
382
+ // and recorded `woken via attach`. A destroyed message, filed as delivered.
383
+ // Two orphans were measured doing exactly that on 2026-08-30.
384
+ //
385
+ // `ownerPid` answers the same question `watchParent` did, from the other
386
+ // side and without depending on the attach's own process tree: rung 3.5
387
+ // uses it to refuse to clone a LIVING session, and this uses it to refuse
388
+ // to deliver into a DEAD one. Absent (an old registration, or a platform
389
+ // with no readable process table) means unknown, and unknown must not cost
390
+ // a live session its delivery — so only a definite "dead" declines.
391
+ if (target.ownerPid !== undefined && !this.isAlive(target.ownerPid)) {
392
+ this.log(`socket for ${session} is orphaned — owner ${target.ownerPid} is gone; not delivering into it`);
393
+ this.attached.delete(session);
394
+ this.registry.setAttached(session, false);
395
+ sock.destroy();
396
+ // Fall through: the session really is gone, which is what the rungs
397
+ // below are for.
398
+ }
399
+ else {
400
+ // Live session: hand it the batch and let the harness re-invoke it. The
401
+ // attach client exits after one wake, which is what makes this a wake
402
+ // rather than a stream — and why the socket is dropped here.
403
+ //
404
+ // AWAITED. `sock.write()` buffers and returns, so an EPIPE from a peer
405
+ // that died between the liveness check and this line used to arrive
406
+ // after `record` had already filed the batch as `woken` — a lost message
407
+ // reported as delivered. A write that does not complete is not a
408
+ // delivery, and the rungs below exist for exactly this case.
409
+ try {
410
+ await (0, socket_1.writeFrameAck)(sock, { type: "wake", conversationId: batch[0].conversationId, messages: batch });
411
+ this.record({ kind: "woken", via: "attach", session }, session, batch);
412
+ return;
413
+ }
414
+ catch (err) {
415
+ this.log(`attach socket for ${session} took no frame: ${errText(err)} — falling through`);
416
+ this.attached.delete(session);
417
+ this.registry.setAttached(session, false);
418
+ sock.destroy();
419
+ // Fall through: nothing was delivered, so this must not shadow a rung
420
+ // that could still reach the session.
421
+ }
422
+ }
203
423
  }
204
424
  const adapter = (0, adapters_1.adapterFor)(target.runtime);
425
+ // ORDERED ABOVE THE FIFO, deliberately.
426
+ //
427
+ // A FIFO hands bytes to a blocked `relay attach`, which prints them and
428
+ // exits. Where the harness re-invokes on that exit (Claude) it is a wake;
429
+ // for an interactive TUI it is only a PRINT — the text appears and nothing
430
+ // makes the agent act on it. Measured 2026-08-31 01:08: delivered via fifo
431
+ // to a live Codex, which then said nothing.
432
+ //
433
+ // `codex queue` puts the message in the session's own turn queue, so the
434
+ // agent ACTS. Prefer the transport that produces an ANSWER over the one
435
+ // that only produces a delivery.
436
+ // RUNG 2: the runtime's OWN inter-session queue, where it has one.
437
+ //
438
+ // Preferred over the headless spawn below because it reaches the LIVE
439
+ // session, where the human already is — so approvals work normally and the
440
+ // relay never acquires a privilege on a chat message's behalf. The headless
441
+ // rung must run `approvalPolicy: "never"`, which blocks Codex's own BayChat
442
+ // write path: observed 2026-08-31, a headlessly-woken Codex read the room
443
+ // and was structurally unable to answer it.
444
+ const resolvedForQueue = await this.resolveResume(target);
445
+ if (adapter.queueMessage && resolvedForQueue.resumeId) {
446
+ // Same rule as the headless spawn: an ABSOLUTE runtimeBin came from the
447
+ // session itself and is never re-resolved; only a bare name is looked up.
448
+ const declared = resolvedForQueue.runtimeBin ?? target.runtime;
449
+ let queueBin = declared;
450
+ if (!path.isAbsolute(declared)) {
451
+ const resolved = this.binaryFor(declared);
452
+ queueBin = resolved.ok ? resolved.path : "";
453
+ }
454
+ if (!queueBin) {
455
+ this.log(`queue unavailable for ${session}: no usable ${target.runtime} binary`);
456
+ }
457
+ else {
458
+ const outcome = await adapter.queueMessage({
459
+ binaryPath: queueBin,
460
+ target: resolvedForQueue,
461
+ message: (0, adapters_1.buildWakePrompt)(session, batch[0].conversationId, batch, undefined),
462
+ });
463
+ if (outcome.kind === "queued") {
464
+ this.record({ kind: "woken", via: "queue", session }, session, batch);
465
+ return;
466
+ }
467
+ // EVERY non-success falls through, including `failed`.
468
+ //
469
+ // The queue is an OPTIMISATION, not a gate: it reaches the live session so
470
+ // approvals work, but the headless rung below is strictly more capable —
471
+ // it carries the Windows `.cmd` spawn plan, the app-server transport, and
472
+ // the binary resolution this path does not replicate. A rung that could
473
+ // not deliver must never shadow one that might; that exact mistake made a
474
+ // stale FIFO swallow three messages earlier tonight, and it would be the
475
+ // same mistake to repeat here for a different reason.
476
+ this.log(`queue did not deliver for ${session} (${outcome.reason}) — falling through to headless`);
477
+ }
478
+ }
205
479
  // A target with no resume id is the unbounded failure this whole path
206
480
  // exists to close: without one, `canResume` says no and the message waits
207
481
  // for a human. Ask the runtime's own on-disk state who this session is
208
482
  // before accepting that answer.
483
+ // RUNG 3: a session whose sandbox refuses the socket, waiting on a FIFO.
484
+ //
485
+ // Ordered after the socket and before headless because it means the SAME
486
+ // thing the socket does — a live session is waiting — while headless means
487
+ // the opposite. `writeWake` never blocks: a FIFO write with no reader would
488
+ // otherwise stall this daemon on one detached agent and stop it serving
489
+ // every other session, which is worse than the bug this rung fixes.
490
+ const mailbox = this.mailboxes.get(session);
491
+ if (mailbox) {
492
+ const payload = `${JSON.stringify({ type: "wake", conversationId: batch[0].conversationId, messages: batch })}\n`;
493
+ const written = (0, mailbox_1.writeWake)(mailbox.fifo, payload);
494
+ if (written.ok) {
495
+ // The one rung where `delivered` is EVIDENCED rather than assumed: a
496
+ // FIFO write completes only when a reader takes the bytes.
497
+ this.record({ kind: "woken", via: "fifo", session }, session, batch);
498
+ return;
499
+ }
500
+ // NOBODY IS WAITING — so FALL THROUGH, do not stop here.
501
+ //
502
+ // A registration is not a listener. The daemon cannot tell a stale
503
+ // registration from a live one on this rung, because a sandboxed agent
504
+ // records a NAMESPACED pid that `processIsAlive` reports alive forever
505
+ // (spec §13). Measured 2026-08-31 00:04: a stale mailbox intercepted three
506
+ // deliveries in a row and recorded each `pending`, while the session had a
507
+ // perfectly good resume id and could have been woken headlessly the whole
508
+ // time. A dead rung was shadowing a working one.
509
+ //
510
+ // ENXIO is the only trustworthy evidence available here, and what it
511
+ // proves is exactly "no live session is waiting" — which is the condition
512
+ // the rung below exists for. So we take it.
513
+ this.log(`mailbox for ${session} has no reader (${written.reason}) — falling through to headless`);
514
+ this.mailboxes.delete(session);
515
+ this.registry.setAttached(session, false);
516
+ }
517
+ // RUNG 3.5: NEVER CLONE A LIVING SESSION.
518
+ //
519
+ // A socket attach exits by design on every wake, so a healthy session is
520
+ // briefly unreachable between wakes — and to every rung above, that is
521
+ // indistinguishable from a session that has gone. On 2026-08-31 a message
522
+ // landed in that gap and the ladder spawned a headless twin of a session
523
+ // that was alive and re-arming: two agents on one name, both authorised to
524
+ // answer, both able to act. The twin behaved impeccably and still committed
525
+ // and pushed under an identity that already had an occupant.
526
+ //
527
+ // `ownerPid` is the session itself, so it survives the gap that `pid` and
528
+ // the socket do not. Alive means "wait for it", never "replace it".
529
+ //
530
+ // SKIPPED after a mailbox fell through, and that exception is load-bearing:
531
+ // ENXIO has already PROVEN no reader there, and a sandboxed agent records a
532
+ // namespaced pid that reads alive forever (spec §13) — so applying this to
533
+ // fifo sessions would permanently shadow the one rung that can still reach
534
+ // them, which is the 2026-08-31 00:04 regression in a new costume.
535
+ if (!mailbox && target.ownerPid !== undefined && this.isAlive(target.ownerPid)) {
536
+ const rearmed = await this.waitForReattach(session, this.reattachGraceMs);
537
+ if (rearmed) {
538
+ try {
539
+ await (0, socket_1.writeFrameAck)(rearmed, { type: "wake", conversationId: batch[0].conversationId, messages: batch });
540
+ this.record({ kind: "woken", via: "attach", session }, session, batch);
541
+ return;
542
+ }
543
+ catch (err) {
544
+ // The socket appeared and then went. Do NOT record a delivery, and do
545
+ // not fall to headless either — the session's process is still alive,
546
+ // so this is the held case arriving by a different door.
547
+ this.log(`re-armed socket for ${session} took no frame: ${errText(err)} — holding instead`);
548
+ }
549
+ }
550
+ // HELD, not pending. The session's process is still there — it is mid-turn,
551
+ // which is the normal state of a working agent, not a failure. Recording
552
+ // this pending threw the message away and told the user their relay had
553
+ // stopped; spawning a headless one instead is the clone bug. The third
554
+ // option is the correct one: keep it and hand it over when it re-arms.
555
+ const conversationId = batch[0].conversationId;
556
+ const total = this.held.countFor(session);
557
+ if (total + batch.length > RelayDaemon.MAX_HELD) {
558
+ this.record({
559
+ kind: "pending",
560
+ session,
561
+ reason: `session process ${target.ownerPid} is alive but has not re-attached, and ${total} message(s) are already held — not holding more`,
562
+ }, session, batch);
563
+ return;
564
+ }
565
+ this.held.add(session, conversationId, batch);
566
+ this.log(`holding ${batch.length} message(s) for ${session} in ${conversationId} until it re-attaches (${this.held.countFor(session)} held)`);
567
+ return;
568
+ }
209
569
  const resolved = await this.resolveResume(target);
210
570
  const check = adapter.canResume(resolved);
211
571
  if (!check.ok) {
@@ -215,19 +575,158 @@ class RelayDaemon {
215
575
  this.record({ kind: "pending", session, reason }, session, batch);
216
576
  return;
217
577
  }
218
- const prompt = (0, adapters_1.buildWakePrompt)(session, batch[0].conversationId, batch);
578
+ // The daemon knows both absolute paths because it IS them — and a session it
579
+ // resumes inherits ITS environment, where neither `node` nor `baychat` is on
580
+ // PATH. Without this the woken session is told to re-arm with a command it
581
+ // cannot run, answers once, and goes quiet.
582
+ const prompt = (0, adapters_1.buildWakePrompt)(session, batch[0].conversationId, batch, {
583
+ node: process.execPath,
584
+ cli: process.argv[1] ?? "",
585
+ runtime: target.runtime,
586
+ // A session that last armed over a mailbox is sandboxed, and a sandboxed
587
+ // session cannot background its re-arm. Telling it to would hand it an
588
+ // instruction that silently does nothing.
589
+ transport: target.transport,
590
+ });
219
591
  const { file, args } = adapter.headlessCommand(resolved, prompt);
220
- // The session's own directory when we know it: `cwd` is only where the
221
- // attach process ran, and `codex exec` refuses to start outside a trusted
222
- // directory at all.
223
- const { exitCode, stderr } = await this.spawnHeadless(file, args, { cwd: resolved.resumeCwd ?? resolved.cwd });
224
- if (exitCode !== 0) {
225
- // A non-zero headless turn did not necessarily reply. Recording it as
226
- // delivered would claim an answer we cannot evidence.
227
- this.record({ kind: "pending", session, reason: `headless ${target.runtime} exited ${exitCode}: ${stderr.slice(0, 200)}` }, session, batch);
592
+ // An ABSOLUTE path came from `SessionTarget.runtimeBin` — recorded by
593
+ // `attach`, inside the session, because the binary a session was launched
594
+ // with is knowable there and nowhere else. Re-resolving it against the
595
+ // daemon's PATH is precisely the guess that rule exists to forbid, and
596
+ // doing so broke a live session: the path ended in `claude.exe`, PATH
597
+ // resolution was asked for a file of that name, and every wake died with
598
+ // "not found on PATH".
599
+ //
600
+ // A BARE NAME has no such provenance, and is where PATH gets it wrong —
601
+ // a Windows shim ahead of the real binary under WSL. That one is resolved.
602
+ let executable = file;
603
+ if (!path.isAbsolute(file)) {
604
+ const binary = this.binaryFor(file);
605
+ if (!binary.ok) {
606
+ this.record({ kind: "pending", session, reason: (0, runtime_binary_1.summarizeResolutionFailure)(binary) }, session, batch);
607
+ return;
608
+ }
609
+ executable = binary.path;
610
+ }
611
+ // THE LEASE — around the WHOLE headless phase, rich transport included.
612
+ //
613
+ // `SessionQueue` already serialises `deliver()`, so this is NOT what stops
614
+ // a second headless turn under normal flow; it is the guard for the paths
615
+ // that do not go through the queue, and the place the invariant is stated
616
+ // rather than assumed. It sits ABOVE `richTransport` because Codex's real
617
+ // headless turn runs there — a lease that wrapped only the plain spawn left
618
+ // the primary path uncovered, which is worse than none: it reads as
619
+ // protection while protecting the branch least likely to be taken.
620
+ //
621
+ // WHAT IT DOES NOT DO, deliberately. An attach arriving mid-turn is still
622
+ // accepted and only logged. Declining it would make a session with a HUMAN
623
+ // sitting at it unreachable in order to protect a spawned proxy, which is a
624
+ // worse failure than the overlap: the person gets silence and no way to see
625
+ // why. So this is mutual exclusion between headless turns, and observability
626
+ // for attach-vs-headless. Claiming more than that is what the review caught.
627
+ if (this.headlessInFlight.has(session)) {
628
+ this.record({ kind: "pending", session, reason: "a headless turn is already running for this session — refusing to start a second" }, session, batch);
228
629
  return;
229
630
  }
230
- this.record({ kind: "woken", via: "headless", session, exitCode }, session, batch);
631
+ this.headlessInFlight.add(session);
632
+ // DELIBERATELY NOT AWAITED.
633
+ //
634
+ // `deliver()` runs under `SessionQueue`'s per-session lock, and a headless
635
+ // turn takes MINUTES. Awaiting it here holds that lock for the whole turn,
636
+ // so every later message for this session parks in the queue: not delivered,
637
+ // not recorded pending, not logged. Measured 2026-08-31 — three messages
638
+ // from the room vanished exactly this way while a twin ran, and the only
639
+ // reason anyone noticed was the human asking whether the agent was alive.
640
+ //
641
+ // Released here, the lease still stops a SECOND headless turn starting, and
642
+ // the message that would have started it is now recorded pending with a
643
+ // reason a person can read. Silence becomes a visible refusal.
644
+ void this.runHeadlessTurn({ session, batch, target, adapter, resolved, executable, prompt, args, file })
645
+ .catch((err) => {
646
+ // Nothing awaits this promise any more, so an escaping rejection would
647
+ // be an unhandled one — which can take the whole daemon down and with it
648
+ // every other session. A turn that threw also did not answer anybody, so
649
+ // the honest record is pending.
650
+ const reason = err instanceof Error ? err.message : String(err);
651
+ this.log(`headless turn for ${session} threw: ${reason}`);
652
+ this.record({ kind: "pending", session, reason: `headless turn threw: ${reason}` }, session, batch);
653
+ })
654
+ .finally(() => {
655
+ this.headlessInFlight.delete(session);
656
+ });
657
+ }
658
+ /**
659
+ * The headless turn itself, outside the delivery lock.
660
+ *
661
+ * Split from `deliver()` for one reason: its duration must not decide whether
662
+ * OTHER messages for this session are handled. Everything it records — woken,
663
+ * or pending with a reason — happens whenever the turn actually ends.
664
+ */
665
+ async runHeadlessTurn(ctx) {
666
+ const { session, batch, target, adapter, resolved, executable, prompt, args, file } = ctx;
667
+ try {
668
+ // A runtime with a richer transport than "spawn a command" gets to use it.
669
+ // Only a transport that could not be used AT ALL falls through to the spawn:
670
+ // a failure that is a real answer about this session — an id that names no
671
+ // thread, a turn the runtime refused — would say exactly the same thing
672
+ // again down the older path, more slowly and less clearly.
673
+ const richTransport = this.runTurn
674
+ ? (input) => this.runTurn({ runtime: target.runtime, ...input })
675
+ : adapter.runTurn?.bind(adapter);
676
+ if (richTransport) {
677
+ const outcome = await richTransport({ binaryPath: executable, target: resolved, prompt });
678
+ if (outcome.kind === "completed") {
679
+ this.record({ kind: "woken", via: "headless", session, exitCode: 0 }, session, batch);
680
+ return;
681
+ }
682
+ if (!outcome.transportUnusable) {
683
+ this.record({ kind: "pending", session, reason: outcome.reason }, session, batch);
684
+ return;
685
+ }
686
+ this.log(`${target.runtime}: falling back to a plain spawn — ${outcome.reason}`);
687
+ }
688
+ // A Windows .cmd needs its interpreter named explicitly, whatever produced
689
+ // the path — resolution or the session's own runtimeBin — so the decision
690
+ // lives here, after both, rather than inside either. Never a shell: these
691
+ // args carry the wake prompt, and therefore other people's message text.
692
+ const plan = (0, runtime_binary_1.spawnPlanFor)(executable, process.platform);
693
+ // The session's own directory when we know it: `cwd` is only where the
694
+ // attach process ran, and `codex exec` refuses to start outside a trusted
695
+ // directory at all.
696
+ const { exitCode, stderr } = await this.spawnHeadless(plan.file, [...plan.prefixArgs, ...args], {
697
+ cwd: resolved.resumeCwd ?? resolved.cwd,
698
+ });
699
+ if (exitCode !== 0) {
700
+ // The binary ran and the turn still failed, so what we resolved may be
701
+ // stale — a half-finished upgrade, a runtime removed since. Drop it and
702
+ // prove it again next time rather than trusting it for the process's life.
703
+ this.binaries.delete(file);
704
+ // A non-zero headless turn did not necessarily reply. Recording it as
705
+ // delivered would claim an answer we cannot evidence.
706
+ this.record({ kind: "pending", session, reason: `headless ${target.runtime} exited ${exitCode}: ${stderr.slice(0, 200)}` }, session, batch);
707
+ return;
708
+ }
709
+ this.record({ kind: "woken", via: "headless", session, exitCode }, session, batch);
710
+ }
711
+ finally {
712
+ // Nothing to release here — the lease is cleared by the caller's
713
+ // `.finally`, which owns it for exactly as long as this turn runs.
714
+ }
715
+ }
716
+ /**
717
+ * The proven executable for a runtime, resolved at most once per process
718
+ * until something gives us reason to doubt it.
719
+ */
720
+ binaryFor(name) {
721
+ const known = this.binaries.get(name);
722
+ if (known)
723
+ return known;
724
+ const resolved = this.resolveBinary(name);
725
+ this.binaries.set(name, resolved);
726
+ this.log(resolved.ok
727
+ ? `${name} resolved to ${resolved.path} (${resolved.version})`
728
+ : `${name} unresolved: ${(0, runtime_binary_1.summarizeResolutionFailure)(resolved)}`);
729
+ return resolved;
231
730
  }
232
731
  /**
233
732
  * Fill in a missing resume id from the runtime's on-disk state, at most once
@@ -269,6 +768,13 @@ class RelayDaemon {
269
768
  record(outcome, session, batch) {
270
769
  if (outcome.kind === "woken") {
271
770
  this.delivered += batch.length;
771
+ // The one witness `relay status` may treat as proof that a session is
772
+ // reachable again. A headless turn only counts when it exited cleanly: a
773
+ // spawn that ran and failed delivered nothing, and recording it would
774
+ // clear the very failure it just repeated.
775
+ if (outcome.via !== "headless" || outcome.exitCode === 0) {
776
+ this.registry.markDelivered(session, new Date().toISOString());
777
+ }
272
778
  this.log(`woke ${session} via ${outcome.via} with ${batch.length} message(s)`);
273
779
  return;
274
780
  }
@@ -304,15 +810,35 @@ class RelayDaemon {
304
810
  resumeCwd: frame.resumeCwd,
305
811
  cwd: frame.cwd,
306
812
  runtimeBin: frame.runtimeBin,
813
+ ownerPid: frame.ownerPid,
307
814
  });
308
815
  // A session that names itself supersedes anything discovery guessed
309
816
  // for it, so drop the throttle and let the next wake use the new id.
310
817
  if (frame.resumeId)
311
818
  this.discoveryReasons.delete(frame.session);
819
+ // OVERLAP, AND WE CANNOT PREVENT IT — only report it. A headless turn
820
+ // already running was spawned because this session looked gone; it is
821
+ // mid-turn now, and the daemon will not kill a model that may be part
822
+ // way through writing something. Two agents briefly hold this name.
823
+ // Said out loud because the alternative is that nobody ever knows.
824
+ if (this.headlessInFlight.has(frame.session)) {
825
+ this.log(`WARNING: ${frame.session} attached while a headless turn for it is still running — two agents hold this session name until that turn ends`);
826
+ }
827
+ // EVICT THE PREVIOUS HOLDER. A session name has exactly one listener,
828
+ // and `set` alone would leave the old socket open and its process
829
+ // running — which is how two orphaned attaches came to be stacked on
830
+ // one name on 2026-08-30, each one a place a wake could vanish into.
831
+ // Destroying it makes the displaced attach exit instead of lingering.
832
+ const previous = this.attached.get(frame.session);
833
+ if (previous && previous !== sock) {
834
+ this.log(`replacing attach for ${frame.session}: dropping the previous one`);
835
+ previous.destroy();
836
+ }
312
837
  this.attached.set(frame.session, sock);
313
838
  this.registry.setAttached(frame.session, true);
314
839
  (0, socket_1.writeFrame)(sock, { type: "attached", session: frame.session });
315
840
  this.log(`attached: ${frame.session} (${frame.runtime})`);
841
+ void this.flushHeld(frame.session, sock);
316
842
  return;
317
843
  }
318
844
  if (frame.type === "status") {
@@ -340,7 +866,8 @@ class RelayDaemon {
340
866
  status() {
341
867
  const sessions = this.registry.all().map((t) => ({
342
868
  ...t,
343
- attached: this.attached.has(t.name),
869
+ attached: this.attached.has(t.name) || this.mailboxes.has(t.name),
870
+ transport: this.attached.has(t.name) ? "socket" : this.mailboxes.has(t.name) ? "fifo" : undefined,
344
871
  }));
345
872
  return {
346
873
  running: true,
@@ -353,6 +880,7 @@ class RelayDaemon {
353
880
  delivered: this.delivered,
354
881
  sessions,
355
882
  pending: this.pending,
883
+ held: this.held.summary(),
356
884
  lastError: this.lastError,
357
885
  };
358
886
  }
@@ -362,6 +890,8 @@ class RelayDaemon {
362
890
  sock.destroy();
363
891
  this.attached.clear();
364
892
  await this.queue.idle();
893
+ for (const w of this.mailboxWatchers)
894
+ w.stop();
365
895
  await new Promise((resolve) => (this.server ? this.server.close(() => resolve()) : resolve()));
366
896
  (0, socket_1.unlinkStaleSocket)((0, socket_1.socketPath)());
367
897
  try {