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.
@@ -40,7 +40,10 @@ const path = __importStar(require("path"));
40
40
  const config_1 = require("../config");
41
41
  const runtime_binary_1 = require("../runtime-binary");
42
42
  const adapters_1 = require("./adapters");
43
+ const parent_watch_1 = require("./parent-watch");
43
44
  const queue_1 = require("./queue");
45
+ const held_1 = require("./held");
46
+ const provider_env_1 = require("./provider-env");
44
47
  const registry_1 = require("./registry");
45
48
  const mailbox_1 = require("./mailbox");
46
49
  const mailbox_watcher_1 = require("./mailbox-watcher");
@@ -82,6 +85,46 @@ class RelayDaemon {
82
85
  discoveryReasons = new Map();
83
86
  discovery;
84
87
  spawnHeadless;
88
+ isAlive;
89
+ /**
90
+ * Sessions with a headless turn running right now — the one-session lease.
91
+ *
92
+ * `SessionQueue` serialises `deliver()` calls, which is NOT the same thing: a
93
+ * headless turn is awaited inside one of those calls, and a second delivery
94
+ * arriving after it resolves would happily start another. Two headless agents
95
+ * under one name is the same fault as the attach/headless clone, reached by a
96
+ * different door.
97
+ *
98
+ * HONEST LIMIT: this can stop a new headless STARTING. It cannot un-run one
99
+ * already in flight, so it does not make a duplicate impossible — it makes the
100
+ * daemon stop creating them.
101
+ */
102
+ headlessInFlight = new Set();
103
+ /**
104
+ * Batches held for a session that is ALIVE but between wakes.
105
+ *
106
+ * A socket attach exits on every wake, and the turn it triggers runs for
107
+ * MINUTES before the session re-arms. A message arriving in that window is not
108
+ * undeliverable — it is EARLY. Timing out and recording it pending threw away
109
+ * two of the owner's own messages on 2026-08-31 while the agent was busy
110
+ * answering the one before them, which reads to a user as the relay stopping.
111
+ *
112
+ * Held here and flushed the instant the session attaches again.
113
+ *
114
+ * KEYED BY CONVERSATION as well as session. A session is in several rooms, and
115
+ * a wake frame carries ONE conversation id — so merging every held batch under
116
+ * the first one tells the agent that messages from room B came from room A, and
117
+ * sends it to re-read the wrong room. Rooms are held apart and flushed apart.
118
+ *
119
+ * PERSISTED, and that is not a detail. While this was a plain Map a daemon
120
+ * restart lost every held message, and the poll cursor had already moved past
121
+ * them, so the server never sent them again — gone, with `relay status`
122
+ * reporting nothing at all. See `held.ts`.
123
+ */
124
+ held = new held_1.HeldStore();
125
+ /** Beyond this a session is not "busy", it is broken; stop growing and report. */
126
+ static MAX_HELD = 200;
127
+ reattachGraceMs;
85
128
  resolveBinary;
86
129
  runTurn;
87
130
  /**
@@ -132,6 +175,8 @@ class RelayDaemon {
132
175
  }));
133
176
  this.discovery = opts.discovery ?? {};
134
177
  this.spawnHeadless = opts.spawnHeadless ?? adapters_1.runHeadless;
178
+ this.isAlive = opts.isAlive ?? parent_watch_1.processIsAlive;
179
+ this.reattachGraceMs = opts.reattachGraceMs ?? 4000;
135
180
  this.resolveBinary = opts.resolveBinary ?? ((name) => (0, runtime_binary_1.resolveRuntimeBinary)(name, (0, runtime_binary_1.currentBinaryEnv)()));
136
181
  this.runTurn = opts.runTurn;
137
182
  this.queue = new queue_1.SessionQueue((session, batch) => this.deliver(session, batch), (session, err) => {
@@ -153,6 +198,10 @@ class RelayDaemon {
153
198
  }
154
199
  const auth = { baseUrl: device.baseUrl, token: device.token };
155
200
  this.registry.load();
201
+ // Held messages outlive the process that held them. Loaded before the socket
202
+ // opens, so a session that re-arms in the first moments of a new daemon is
203
+ // handed what the previous one was holding rather than a clean slate.
204
+ this.held.load();
156
205
  const sockPath = (0, socket_1.socketPath)();
157
206
  if (await (0, socket_1.probeSocket)(sockPath)) {
158
207
  throw new Error(`a relay is already listening on ${sockPath} — run \`baychat relay status\``);
@@ -239,6 +288,59 @@ class RelayDaemon {
239
288
  // could never have matched anything.
240
289
  this.queue.push(sessionName, { ...message, conversationId });
241
290
  }
291
+ /**
292
+ * Hand a freshly attached session everything held while it was mid-turn.
293
+ *
294
+ * Sent as ONE wake: the messages arrived while the agent was busy and are read
295
+ * together, in order, the way a person returning to a chat reads them. Waking
296
+ * once per held message would spend a turn on each and let the agent answer the
297
+ * first three not knowing the fourth exists.
298
+ */
299
+ async flushHeld(session, sock) {
300
+ const rooms = this.held.roomsFor(session);
301
+ if (rooms.length === 0)
302
+ return;
303
+ for (const room of rooms) {
304
+ try {
305
+ // ACKNOWLEDGED, not fired and forgotten. A hand-over into a socket that
306
+ // has already died would otherwise clear the hold AND record `woken` —
307
+ // destroying the only copy of the messages and filing them as
308
+ // delivered, which is worse than never having held them.
309
+ await (0, socket_1.writeFrameAck)(sock, { type: "wake", conversationId: room.conversationId, messages: room.messages });
310
+ }
311
+ catch (err) {
312
+ // KEEP IT. This store is the only copy: dropping it because the handoff
313
+ // failed turns "held" into "lost", which is the failure it exists to
314
+ // prevent. The next attach gets it instead.
315
+ this.log(`could not hand ${room.messages.length} held message(s) for ${session} over: ${errText(err)}`);
316
+ return;
317
+ }
318
+ // Removed only once it is actually on the wire, and only for THIS room —
319
+ // a failure on the second room must not discard the first's receipt or the
320
+ // third's contents.
321
+ this.held.clearRoom(session, room.conversationId);
322
+ this.record({ kind: "woken", via: "attach", session }, session, room.messages);
323
+ this.log(`delivered ${room.messages.length} held message(s) to ${session} in ${room.conversationId} on re-attach`);
324
+ }
325
+ }
326
+ /**
327
+ * Wait for a session to re-arm its socket, up to `graceMs`.
328
+ *
329
+ * Polled rather than event-driven on purpose: the attach path already evicts
330
+ * a previous holder and re-registers, so the socket map is the single source
331
+ * of truth, and subscribing to it would mean a second one to keep in step.
332
+ */
333
+ async waitForReattach(session, graceMs) {
334
+ const deadline = Date.now() + graceMs;
335
+ for (;;) {
336
+ const sock = this.attached.get(session);
337
+ if (sock && !sock.destroyed)
338
+ return sock;
339
+ if (Date.now() >= deadline)
340
+ return undefined;
341
+ await new Promise((r) => setTimeout(r, 10));
342
+ }
343
+ }
242
344
  /**
243
345
  * Deliver one coalesced batch to one session. Runs under the queue's
244
346
  * per-session lock, so an attach wake and a headless resume can never be in
@@ -272,12 +374,53 @@ class RelayDaemon {
272
374
  }
273
375
  const sock = this.attached.get(session);
274
376
  if (sock && !sock.destroyed) {
275
- // Live session: hand it the batch and let the harness re-invoke it. The
276
- // attach client exits after one wake, which is what makes this a wake
277
- // rather than a stream — and why the socket is dropped here.
278
- (0, socket_1.writeFrame)(sock, { type: "wake", conversationId: batch[0].conversationId, messages: batch });
279
- this.record({ kind: "woken", via: "attach", session }, session, batch);
280
- return;
377
+ // AN OPEN SOCKET IS NOT A LIVE SESSION.
378
+ //
379
+ // `watchParent` used to guarantee that by making an orphaned attach exit
380
+ // with its launcher. Without it — and a `Stop` hook has to detach, so it
381
+ // cannot rely on ppid — an orphan keeps its socket open, the daemon reads
382
+ // "socket open" as "session alive", and the wake is written into a corpse
383
+ // and recorded `woken via attach`. A destroyed message, filed as delivered.
384
+ // Two orphans were measured doing exactly that on 2026-08-30.
385
+ //
386
+ // `ownerPid` answers the same question `watchParent` did, from the other
387
+ // side and without depending on the attach's own process tree: rung 3.5
388
+ // uses it to refuse to clone a LIVING session, and this uses it to refuse
389
+ // to deliver into a DEAD one. Absent (an old registration, or a platform
390
+ // with no readable process table) means unknown, and unknown must not cost
391
+ // a live session its delivery — so only a definite "dead" declines.
392
+ if (target.ownerPid !== undefined && !this.isAlive(target.ownerPid)) {
393
+ this.log(`socket for ${session} is orphaned — owner ${target.ownerPid} is gone; not delivering into it`);
394
+ this.attached.delete(session);
395
+ this.registry.setAttached(session, false);
396
+ sock.destroy();
397
+ // Fall through: the session really is gone, which is what the rungs
398
+ // below are for.
399
+ }
400
+ else {
401
+ // Live session: hand it the batch and let the harness re-invoke it. The
402
+ // attach client exits after one wake, which is what makes this a wake
403
+ // rather than a stream — and why the socket is dropped here.
404
+ //
405
+ // AWAITED. `sock.write()` buffers and returns, so an EPIPE from a peer
406
+ // that died between the liveness check and this line used to arrive
407
+ // after `record` had already filed the batch as `woken` — a lost message
408
+ // reported as delivered. A write that does not complete is not a
409
+ // delivery, and the rungs below exist for exactly this case.
410
+ try {
411
+ await (0, socket_1.writeFrameAck)(sock, { type: "wake", conversationId: batch[0].conversationId, messages: batch });
412
+ this.record({ kind: "woken", via: "attach", session }, session, batch);
413
+ return;
414
+ }
415
+ catch (err) {
416
+ this.log(`attach socket for ${session} took no frame: ${errText(err)} — falling through`);
417
+ this.attached.delete(session);
418
+ this.registry.setAttached(session, false);
419
+ sock.destroy();
420
+ // Fall through: nothing was delivered, so this must not shadow a rung
421
+ // that could still reach the session.
422
+ }
423
+ }
281
424
  }
282
425
  const adapter = (0, adapters_1.adapterFor)(target.runtime);
283
426
  // ORDERED ABOVE THE FIFO, deliberately.
@@ -372,6 +515,58 @@ class RelayDaemon {
372
515
  this.mailboxes.delete(session);
373
516
  this.registry.setAttached(session, false);
374
517
  }
518
+ // RUNG 3.5: NEVER CLONE A LIVING SESSION.
519
+ //
520
+ // A socket attach exits by design on every wake, so a healthy session is
521
+ // briefly unreachable between wakes — and to every rung above, that is
522
+ // indistinguishable from a session that has gone. On 2026-08-31 a message
523
+ // landed in that gap and the ladder spawned a headless twin of a session
524
+ // that was alive and re-arming: two agents on one name, both authorised to
525
+ // answer, both able to act. The twin behaved impeccably and still committed
526
+ // and pushed under an identity that already had an occupant.
527
+ //
528
+ // `ownerPid` is the session itself, so it survives the gap that `pid` and
529
+ // the socket do not. Alive means "wait for it", never "replace it".
530
+ //
531
+ // SKIPPED after a mailbox fell through, and that exception is load-bearing:
532
+ // ENXIO has already PROVEN no reader there, and a sandboxed agent records a
533
+ // namespaced pid that reads alive forever (spec §13) — so applying this to
534
+ // fifo sessions would permanently shadow the one rung that can still reach
535
+ // them, which is the 2026-08-31 00:04 regression in a new costume.
536
+ if (!mailbox && target.ownerPid !== undefined && this.isAlive(target.ownerPid)) {
537
+ const rearmed = await this.waitForReattach(session, this.reattachGraceMs);
538
+ if (rearmed) {
539
+ try {
540
+ await (0, socket_1.writeFrameAck)(rearmed, { type: "wake", conversationId: batch[0].conversationId, messages: batch });
541
+ this.record({ kind: "woken", via: "attach", session }, session, batch);
542
+ return;
543
+ }
544
+ catch (err) {
545
+ // The socket appeared and then went. Do NOT record a delivery, and do
546
+ // not fall to headless either — the session's process is still alive,
547
+ // so this is the held case arriving by a different door.
548
+ this.log(`re-armed socket for ${session} took no frame: ${errText(err)} — holding instead`);
549
+ }
550
+ }
551
+ // HELD, not pending. The session's process is still there — it is mid-turn,
552
+ // which is the normal state of a working agent, not a failure. Recording
553
+ // this pending threw the message away and told the user their relay had
554
+ // stopped; spawning a headless one instead is the clone bug. The third
555
+ // option is the correct one: keep it and hand it over when it re-arms.
556
+ const conversationId = batch[0].conversationId;
557
+ const total = this.held.countFor(session);
558
+ if (total + batch.length > RelayDaemon.MAX_HELD) {
559
+ this.record({
560
+ kind: "pending",
561
+ session,
562
+ reason: `session process ${target.ownerPid} is alive but has not re-attached, and ${total} message(s) are already held — not holding more`,
563
+ }, session, batch);
564
+ return;
565
+ }
566
+ this.held.add(session, conversationId, batch);
567
+ this.log(`holding ${batch.length} message(s) for ${session} in ${conversationId} until it re-attaches (${this.held.countFor(session)} held)`);
568
+ return;
569
+ }
375
570
  const resolved = await this.resolveResume(target);
376
571
  const check = adapter.canResume(resolved);
377
572
  if (!check.ok) {
@@ -381,6 +576,19 @@ class RelayDaemon {
381
576
  this.record({ kind: "pending", session, reason }, session, batch);
382
577
  return;
383
578
  }
579
+ // A SESSION POINTED AT ANOTHER PROVIDER CANNOT BE STARTED BY US.
580
+ //
581
+ // Same shape as the PATH bug and the binary-path bug before it: the session
582
+ // knows something about itself that the daemon's environment does not have.
583
+ // Here the missing piece is a credential we deliberately do not store, so
584
+ // the honest move is to refuse and say exactly what to do — spawning anyway
585
+ // would come up on the DEFAULT provider and either fail or quietly answer
586
+ // from an account the person never meant to spend.
587
+ const providerBlocker = (0, provider_env_1.providerWakeBlocker)(target, process.env);
588
+ if (providerBlocker) {
589
+ this.record({ kind: "pending", session, reason: providerBlocker }, session, batch);
590
+ return;
591
+ }
384
592
  // The daemon knows both absolute paths because it IS them — and a session it
385
593
  // resumes inherits ITS environment, where neither `node` nor `baychat` is on
386
594
  // PATH. Without this the woken session is told to re-arm with a command it
@@ -414,48 +622,110 @@ class RelayDaemon {
414
622
  }
415
623
  executable = binary.path;
416
624
  }
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;
625
+ // THE LEASE — around the WHOLE headless phase, rich transport included.
626
+ //
627
+ // `SessionQueue` already serialises `deliver()`, so this is NOT what stops
628
+ // a second headless turn under normal flow; it is the guard for the paths
629
+ // that do not go through the queue, and the place the invariant is stated
630
+ // rather than assumed. It sits ABOVE `richTransport` because Codex's real
631
+ // headless turn runs there — a lease that wrapped only the plain spawn left
632
+ // the primary path uncovered, which is worse than none: it reads as
633
+ // protection while protecting the branch least likely to be taken.
634
+ //
635
+ // WHAT IT DOES NOT DO, deliberately. An attach arriving mid-turn is still
636
+ // accepted and only logged. Declining it would make a session with a HUMAN
637
+ // sitting at it unreachable in order to protect a spawned proxy, which is a
638
+ // worse failure than the overlap: the person gets silence and no way to see
639
+ // why. So this is mutual exclusion between headless turns, and observability
640
+ // for attach-vs-headless. Claiming more than that is what the review caught.
641
+ if (this.headlessInFlight.has(session)) {
642
+ this.record({ kind: "pending", session, reason: "a headless turn is already running for this session — refusing to start a second" }, session, batch);
643
+ return;
644
+ }
645
+ this.headlessInFlight.add(session);
646
+ // DELIBERATELY NOT AWAITED.
647
+ //
648
+ // `deliver()` runs under `SessionQueue`'s per-session lock, and a headless
649
+ // turn takes MINUTES. Awaiting it here holds that lock for the whole turn,
650
+ // so every later message for this session parks in the queue: not delivered,
651
+ // not recorded pending, not logged. Measured 2026-08-31 — three messages
652
+ // from the room vanished exactly this way while a twin ran, and the only
653
+ // reason anyone noticed was the human asking whether the agent was alive.
654
+ //
655
+ // Released here, the lease still stops a SECOND headless turn starting, and
656
+ // the message that would have started it is now recorded pending with a
657
+ // reason a person can read. Silence becomes a visible refusal.
658
+ void this.runHeadlessTurn({ session, batch, target, adapter, resolved, executable, prompt, args, file })
659
+ .catch((err) => {
660
+ // Nothing awaits this promise any more, so an escaping rejection would
661
+ // be an unhandled one — which can take the whole daemon down and with it
662
+ // every other session. A turn that threw also did not answer anybody, so
663
+ // the honest record is pending.
664
+ const reason = err instanceof Error ? err.message : String(err);
665
+ this.log(`headless turn for ${session} threw: ${reason}`);
666
+ this.record({ kind: "pending", session, reason: `headless turn threw: ${reason}` }, session, batch);
667
+ })
668
+ .finally(() => {
669
+ this.headlessInFlight.delete(session);
670
+ });
671
+ }
672
+ /**
673
+ * The headless turn itself, outside the delivery lock.
674
+ *
675
+ * Split from `deliver()` for one reason: its duration must not decide whether
676
+ * OTHER messages for this session are handled. Everything it records — woken,
677
+ * or pending with a reason — happens whenever the turn actually ends.
678
+ */
679
+ async runHeadlessTurn(ctx) {
680
+ const { session, batch, target, adapter, resolved, executable, prompt, args, file } = ctx;
681
+ try {
682
+ // A runtime with a richer transport than "spawn a command" gets to use it.
683
+ // Only a transport that could not be used AT ALL falls through to the spawn:
684
+ // a failure that is a real answer about this session — an id that names no
685
+ // thread, a turn the runtime refused — would say exactly the same thing
686
+ // again down the older path, more slowly and less clearly.
687
+ const richTransport = this.runTurn
688
+ ? (input) => this.runTurn({ runtime: target.runtime, ...input })
689
+ : adapter.runTurn?.bind(adapter);
690
+ if (richTransport) {
691
+ const outcome = await richTransport({ binaryPath: executable, target: resolved, prompt });
692
+ if (outcome.kind === "completed") {
693
+ this.record({ kind: "woken", via: "headless", session, exitCode: 0 }, session, batch);
694
+ return;
695
+ }
696
+ if (!outcome.transportUnusable) {
697
+ this.record({ kind: "pending", session, reason: outcome.reason }, session, batch);
698
+ return;
699
+ }
700
+ this.log(`${target.runtime}: falling back to a plain spawn — ${outcome.reason}`);
430
701
  }
431
- if (!outcome.transportUnusable) {
432
- this.record({ kind: "pending", session, reason: outcome.reason }, session, batch);
702
+ // A Windows .cmd needs its interpreter named explicitly, whatever produced
703
+ // the path — resolution or the session's own runtimeBin — so the decision
704
+ // lives here, after both, rather than inside either. Never a shell: these
705
+ // args carry the wake prompt, and therefore other people's message text.
706
+ const plan = (0, runtime_binary_1.spawnPlanFor)(executable, process.platform);
707
+ // The session's own directory when we know it: `cwd` is only where the
708
+ // attach process ran, and `codex exec` refuses to start outside a trusted
709
+ // directory at all.
710
+ const { exitCode, stderr } = await this.spawnHeadless(plan.file, [...plan.prefixArgs, ...args], {
711
+ cwd: resolved.resumeCwd ?? resolved.cwd,
712
+ });
713
+ if (exitCode !== 0) {
714
+ // The binary ran and the turn still failed, so what we resolved may be
715
+ // stale — a half-finished upgrade, a runtime removed since. Drop it and
716
+ // prove it again next time rather than trusting it for the process's life.
717
+ this.binaries.delete(file);
718
+ // A non-zero headless turn did not necessarily reply. Recording it as
719
+ // delivered would claim an answer we cannot evidence.
720
+ this.record({ kind: "pending", session, reason: `headless ${target.runtime} exited ${exitCode}: ${stderr.slice(0, 200)}` }, session, batch);
433
721
  return;
434
722
  }
435
- this.log(`${target.runtime}: falling back to a plain spawn — ${outcome.reason}`);
723
+ this.record({ kind: "woken", via: "headless", session, exitCode }, session, batch);
436
724
  }
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);
442
- // The session's own directory when we know it: `cwd` is only where the
443
- // attach process ran, and `codex exec` refuses to start outside a trusted
444
- // directory at all.
445
- const { exitCode, stderr } = await this.spawnHeadless(plan.file, [...plan.prefixArgs, ...args], {
446
- cwd: resolved.resumeCwd ?? resolved.cwd,
447
- });
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);
453
- // A non-zero headless turn did not necessarily reply. Recording it as
454
- // delivered would claim an answer we cannot evidence.
455
- this.record({ kind: "pending", session, reason: `headless ${target.runtime} exited ${exitCode}: ${stderr.slice(0, 200)}` }, session, batch);
456
- return;
725
+ finally {
726
+ // Nothing to release here — the lease is cleared by the caller's
727
+ // `.finally`, which owns it for exactly as long as this turn runs.
457
728
  }
458
- this.record({ kind: "woken", via: "headless", session, exitCode }, session, batch);
459
729
  }
460
730
  /**
461
731
  * The proven executable for a runtime, resolved at most once per process
@@ -512,6 +782,13 @@ class RelayDaemon {
512
782
  record(outcome, session, batch) {
513
783
  if (outcome.kind === "woken") {
514
784
  this.delivered += batch.length;
785
+ // The one witness `relay status` may treat as proof that a session is
786
+ // reachable again. A headless turn only counts when it exited cleanly: a
787
+ // spawn that ran and failed delivered nothing, and recording it would
788
+ // clear the very failure it just repeated.
789
+ if (outcome.via !== "headless" || outcome.exitCode === 0) {
790
+ this.registry.markDelivered(session, new Date().toISOString());
791
+ }
515
792
  this.log(`woke ${session} via ${outcome.via} with ${batch.length} message(s)`);
516
793
  return;
517
794
  }
@@ -547,11 +824,22 @@ class RelayDaemon {
547
824
  resumeCwd: frame.resumeCwd,
548
825
  cwd: frame.cwd,
549
826
  runtimeBin: frame.runtimeBin,
827
+ ownerPid: frame.ownerPid,
828
+ providerUrl: frame.providerUrl,
829
+ providerVar: frame.providerVar,
550
830
  });
551
831
  // A session that names itself supersedes anything discovery guessed
552
832
  // for it, so drop the throttle and let the next wake use the new id.
553
833
  if (frame.resumeId)
554
834
  this.discoveryReasons.delete(frame.session);
835
+ // OVERLAP, AND WE CANNOT PREVENT IT — only report it. A headless turn
836
+ // already running was spawned because this session looked gone; it is
837
+ // mid-turn now, and the daemon will not kill a model that may be part
838
+ // way through writing something. Two agents briefly hold this name.
839
+ // Said out loud because the alternative is that nobody ever knows.
840
+ if (this.headlessInFlight.has(frame.session)) {
841
+ 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`);
842
+ }
555
843
  // EVICT THE PREVIOUS HOLDER. A session name has exactly one listener,
556
844
  // and `set` alone would leave the old socket open and its process
557
845
  // running — which is how two orphaned attaches came to be stacked on
@@ -566,6 +854,7 @@ class RelayDaemon {
566
854
  this.registry.setAttached(frame.session, true);
567
855
  (0, socket_1.writeFrame)(sock, { type: "attached", session: frame.session });
568
856
  this.log(`attached: ${frame.session} (${frame.runtime})`);
857
+ void this.flushHeld(frame.session, sock);
569
858
  return;
570
859
  }
571
860
  if (frame.type === "status") {
@@ -607,6 +896,7 @@ class RelayDaemon {
607
896
  delivered: this.delivered,
608
897
  sessions,
609
898
  pending: this.pending,
899
+ held: this.held.summary(),
610
900
  lastError: this.lastError,
611
901
  };
612
902
  }
@@ -0,0 +1,184 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.HeldStore = void 0;
37
+ const fs = __importStar(require("fs"));
38
+ const path = __importStar(require("path"));
39
+ const config_1 = require("../config");
40
+ const FILE = "relay-held.json";
41
+ function heldPath() {
42
+ return path.join((0, config_1.configDir)(), FILE);
43
+ }
44
+ /**
45
+ * Messages held for a session that is alive but mid-turn, PERSISTED.
46
+ *
47
+ * A socket attach exits on every wake and the turn it triggers runs for minutes
48
+ * before the session re-arms. A message arriving in that window is early, not
49
+ * undeliverable, so the daemon holds it. That much already worked — in memory.
50
+ *
51
+ * In memory was not enough, and the gap was specific: a daemon restart lost
52
+ * every held message, and because the poll cursor had already advanced past
53
+ * them the server never sent them again. They were not pending, not delivered,
54
+ * and not anywhere. `relay status` showed nothing at all, so the only symptom
55
+ * was a person on a phone whose message was never answered — the exact reading
56
+ * of "the relay stopped" that holding was introduced to prevent.
57
+ *
58
+ * So the store is a file, and it is in `relay status`. Held is a THIRD state,
59
+ * neither delivered nor failed, and it has to be visible as itself: printing it
60
+ * as pending would make a working agent look broken, and printing nothing is
61
+ * what we just came from.
62
+ *
63
+ * ⚠️ THIS FILE CONTAINS MESSAGE TEXT IN PLAINTEXT. Everything else the relay
64
+ * persists is metadata — session names, resume ids, positions. This is chat
65
+ * content, sitting on disk on the user's own machine for as long as their agent
66
+ * takes to finish a turn. Mode 0600, in the same private config dir as the
67
+ * device credential, and deliberately emptied the moment a batch is handed
68
+ * over. That is the price of not losing messages, and it is worth naming rather
69
+ * than discovering.
70
+ */
71
+ class HeldStore {
72
+ filePath;
73
+ /** session → conversationId → room. */
74
+ rooms = new Map();
75
+ constructor(filePath = heldPath()) {
76
+ this.filePath = filePath;
77
+ }
78
+ load() {
79
+ let raw;
80
+ try {
81
+ raw = JSON.parse(fs.readFileSync(this.filePath, "utf8"));
82
+ }
83
+ catch {
84
+ // Missing or corrupt. An empty store is the correct reading: it means the
85
+ // daemon holds nothing, which is true, rather than crashing a relay on
86
+ // startup over messages it cannot recover anyway.
87
+ return;
88
+ }
89
+ if (!raw || typeof raw !== "object")
90
+ return;
91
+ for (const [session, bySession] of Object.entries(raw)) {
92
+ if (!bySession || typeof bySession !== "object")
93
+ continue;
94
+ const rooms = new Map();
95
+ for (const [conversationId, value] of Object.entries(bySession)) {
96
+ const room = value;
97
+ if (!room || !Array.isArray(room.messages) || room.messages.length === 0)
98
+ continue;
99
+ rooms.set(conversationId, {
100
+ conversationId,
101
+ heldAt: typeof room.heldAt === "string" ? room.heldAt : new Date().toISOString(),
102
+ messages: room.messages,
103
+ });
104
+ }
105
+ if (rooms.size > 0)
106
+ this.rooms.set(session, rooms);
107
+ }
108
+ }
109
+ save() {
110
+ const out = {};
111
+ for (const [session, rooms] of this.rooms) {
112
+ if (rooms.size === 0)
113
+ continue;
114
+ out[session] = Object.fromEntries(rooms);
115
+ }
116
+ try {
117
+ fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
118
+ if (Object.keys(out).length === 0) {
119
+ // Nothing held: remove the file rather than leaving `{}` behind, so the
120
+ // plaintext does not outlive the hold by a single byte more than it
121
+ // must.
122
+ fs.rmSync(this.filePath, { force: true });
123
+ return;
124
+ }
125
+ fs.writeFileSync(this.filePath, JSON.stringify(out, null, 2), { mode: 0o600 });
126
+ }
127
+ catch {
128
+ // Losing durability is not losing the hold: the in-memory copy still
129
+ // serves this process, which is exactly the behaviour we had before.
130
+ }
131
+ }
132
+ /**
133
+ * Hold a batch for one room.
134
+ *
135
+ * De-duplicated by message id: a cursor re-baseline (409 recovery re-reads
136
+ * from a watermark) can legitimately replay an event, and a room must not
137
+ * show the agent the same line twice when it finally re-arms.
138
+ */
139
+ add(session, conversationId, messages) {
140
+ const rooms = this.rooms.get(session) ?? new Map();
141
+ const room = rooms.get(conversationId) ?? { conversationId, heldAt: new Date().toISOString(), messages: [] };
142
+ for (const m of messages) {
143
+ if (!room.messages.some((held) => held.id === m.id))
144
+ room.messages.push(m);
145
+ }
146
+ rooms.set(conversationId, room);
147
+ this.rooms.set(session, rooms);
148
+ this.save();
149
+ }
150
+ /** Every room held for a session, oldest hold first. */
151
+ roomsFor(session) {
152
+ const rooms = this.rooms.get(session);
153
+ if (!rooms)
154
+ return [];
155
+ return [...rooms.values()].sort((a, b) => Date.parse(a.heldAt) - Date.parse(b.heldAt));
156
+ }
157
+ /** Drop one room's hold — called only once its messages are on the wire. */
158
+ clearRoom(session, conversationId) {
159
+ const rooms = this.rooms.get(session);
160
+ if (!rooms || !rooms.delete(conversationId))
161
+ return;
162
+ if (rooms.size === 0)
163
+ this.rooms.delete(session);
164
+ this.save();
165
+ }
166
+ /** How many messages are held for one session, across all its rooms. */
167
+ countFor(session) {
168
+ let n = 0;
169
+ for (const room of this.rooms.get(session)?.values() ?? [])
170
+ n += room.messages.length;
171
+ return n;
172
+ }
173
+ /** What `relay status` prints. Content is deliberately NOT included. */
174
+ summary() {
175
+ const out = [];
176
+ for (const [session, rooms] of this.rooms) {
177
+ for (const room of rooms.values()) {
178
+ out.push({ session, conversationId: room.conversationId, count: room.messages.length, heldAt: room.heldAt });
179
+ }
180
+ }
181
+ return out.sort((a, b) => Date.parse(a.heldAt) - Date.parse(b.heldAt));
182
+ }
183
+ }
184
+ exports.HeldStore = HeldStore;