baychat 0.14.0 → 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.
@@ -40,7 +40,9 @@ 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");
44
46
  const registry_1 = require("./registry");
45
47
  const mailbox_1 = require("./mailbox");
46
48
  const mailbox_watcher_1 = require("./mailbox-watcher");
@@ -82,6 +84,46 @@ class RelayDaemon {
82
84
  discoveryReasons = new Map();
83
85
  discovery;
84
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;
85
127
  resolveBinary;
86
128
  runTurn;
87
129
  /**
@@ -132,6 +174,8 @@ class RelayDaemon {
132
174
  }));
133
175
  this.discovery = opts.discovery ?? {};
134
176
  this.spawnHeadless = opts.spawnHeadless ?? adapters_1.runHeadless;
177
+ this.isAlive = opts.isAlive ?? parent_watch_1.processIsAlive;
178
+ this.reattachGraceMs = opts.reattachGraceMs ?? 4000;
135
179
  this.resolveBinary = opts.resolveBinary ?? ((name) => (0, runtime_binary_1.resolveRuntimeBinary)(name, (0, runtime_binary_1.currentBinaryEnv)()));
136
180
  this.runTurn = opts.runTurn;
137
181
  this.queue = new queue_1.SessionQueue((session, batch) => this.deliver(session, batch), (session, err) => {
@@ -153,6 +197,10 @@ class RelayDaemon {
153
197
  }
154
198
  const auth = { baseUrl: device.baseUrl, token: device.token };
155
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();
156
204
  const sockPath = (0, socket_1.socketPath)();
157
205
  if (await (0, socket_1.probeSocket)(sockPath)) {
158
206
  throw new Error(`a relay is already listening on ${sockPath} — run \`baychat relay status\``);
@@ -239,6 +287,59 @@ class RelayDaemon {
239
287
  // could never have matched anything.
240
288
  this.queue.push(sessionName, { ...message, conversationId });
241
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
+ }
242
343
  /**
243
344
  * Deliver one coalesced batch to one session. Runs under the queue's
244
345
  * per-session lock, so an attach wake and a headless resume can never be in
@@ -272,12 +373,53 @@ class RelayDaemon {
272
373
  }
273
374
  const sock = this.attached.get(session);
274
375
  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;
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
+ }
281
423
  }
282
424
  const adapter = (0, adapters_1.adapterFor)(target.runtime);
283
425
  // ORDERED ABOVE THE FIFO, deliberately.
@@ -372,6 +514,58 @@ class RelayDaemon {
372
514
  this.mailboxes.delete(session);
373
515
  this.registry.setAttached(session, false);
374
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
+ }
375
569
  const resolved = await this.resolveResume(target);
376
570
  const check = adapter.canResume(resolved);
377
571
  if (!check.ok) {
@@ -414,48 +608,110 @@ class RelayDaemon {
414
608
  }
415
609
  executable = binary.path;
416
610
  }
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;
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);
629
+ return;
630
+ }
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}`);
430
687
  }
431
- if (!outcome.transportUnusable) {
432
- this.record({ kind: "pending", session, reason: outcome.reason }, session, batch);
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);
433
707
  return;
434
708
  }
435
- this.log(`${target.runtime}: falling back to a plain spawn ${outcome.reason}`);
709
+ this.record({ kind: "woken", via: "headless", session, exitCode }, session, batch);
436
710
  }
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;
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.
457
714
  }
458
- this.record({ kind: "woken", via: "headless", session, exitCode }, session, batch);
459
715
  }
460
716
  /**
461
717
  * The proven executable for a runtime, resolved at most once per process
@@ -512,6 +768,13 @@ class RelayDaemon {
512
768
  record(outcome, session, batch) {
513
769
  if (outcome.kind === "woken") {
514
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
+ }
515
778
  this.log(`woke ${session} via ${outcome.via} with ${batch.length} message(s)`);
516
779
  return;
517
780
  }
@@ -547,11 +810,20 @@ class RelayDaemon {
547
810
  resumeCwd: frame.resumeCwd,
548
811
  cwd: frame.cwd,
549
812
  runtimeBin: frame.runtimeBin,
813
+ ownerPid: frame.ownerPid,
550
814
  });
551
815
  // A session that names itself supersedes anything discovery guessed
552
816
  // for it, so drop the throttle and let the next wake use the new id.
553
817
  if (frame.resumeId)
554
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
+ }
555
827
  // EVICT THE PREVIOUS HOLDER. A session name has exactly one listener,
556
828
  // and `set` alone would leave the old socket open and its process
557
829
  // running — which is how two orphaned attaches came to be stacked on
@@ -566,6 +838,7 @@ class RelayDaemon {
566
838
  this.registry.setAttached(frame.session, true);
567
839
  (0, socket_1.writeFrame)(sock, { type: "attached", session: frame.session });
568
840
  this.log(`attached: ${frame.session} (${frame.runtime})`);
841
+ void this.flushHeld(frame.session, sock);
569
842
  return;
570
843
  }
571
844
  if (frame.type === "status") {
@@ -607,6 +880,7 @@ class RelayDaemon {
607
880
  delivered: this.delivered,
608
881
  sessions,
609
882
  pending: this.pending,
883
+ held: this.held.summary(),
610
884
  lastError: this.lastError,
611
885
  };
612
886
  }
@@ -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;
@@ -185,6 +185,7 @@ function registrationToTarget(reg) {
185
185
  resumeCwd: reg.resumeCwd,
186
186
  cwd: reg.cwd,
187
187
  runtimeBin: reg.runtimeBin,
188
+ ownerPid: reg.ownerPid,
188
189
  // Persisted, so a LATER headless resume still knows this session is
189
190
  // sandboxed and must be told to re-arm in the foreground. `status()`
190
191
  // recomputes the live rung for display; this is the remembered one.