baychat 0.23.1 → 0.25.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.
Files changed (39) hide show
  1. package/README.md +120 -15
  2. package/dist/api.js +19 -2
  3. package/dist/claude-context-flush.js +219 -0
  4. package/dist/claude-context-state.js +191 -0
  5. package/dist/claude-context.js +311 -0
  6. package/dist/claude-settings.js +503 -0
  7. package/dist/codex-onboarding.js +33 -23
  8. package/dist/context-hooks-command.js +165 -0
  9. package/dist/context-report.js +307 -0
  10. package/dist/index.js +26 -497
  11. package/dist/main.js +511 -0
  12. package/dist/mcp-notes.js +110 -0
  13. package/dist/mcp.js +84 -5
  14. package/dist/protocol-content.js +1 -1
  15. package/dist/relay/acp/client.js +18 -0
  16. package/dist/relay/acp/prompt.js +54 -3
  17. package/dist/relay/acp/runner.js +100 -2
  18. package/dist/relay/acp/turn-queue.js +99 -3
  19. package/dist/relay/codex-context.js +242 -0
  20. package/dist/relay/compact-command.js +63 -0
  21. package/dist/relay/compact-focus.attacks.js +36 -0
  22. package/dist/relay/compact-focus.js +64 -0
  23. package/dist/relay/context-notice.js +144 -0
  24. package/dist/relay/daemon.js +429 -73
  25. package/dist/relay/fresh-start-verify.js +237 -0
  26. package/dist/relay/fresh-start.js +141 -0
  27. package/dist/relay/message-format.js +34 -15
  28. package/dist/relay/project-brief.js +195 -0
  29. package/dist/relay/project-notes.js +300 -0
  30. package/dist/relay/resume.js +25 -0
  31. package/dist/relay/server-registration.js +40 -0
  32. package/dist/relay/session-commands.js +2 -0
  33. package/dist/relay/terminal-text.js +13 -0
  34. package/dist/relay/transport.js +2 -1
  35. package/dist/relay/updates.js +46 -8
  36. package/dist/relay/ws.js +49 -6
  37. package/dist/runtimes.js +24 -6
  38. package/dist/tool-defs.js +93 -0
  39. package/package.json +1 -1
@@ -33,15 +33,23 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.RelayDaemon = void 0;
36
+ exports.FRESH_START_SETTLE_MS = exports.RelayDaemon = void 0;
37
37
  exports.splitSessionCommands = splitSessionCommands;
38
38
  const child_process_1 = require("child_process");
39
39
  const fs = __importStar(require("fs"));
40
40
  const net = __importStar(require("net"));
41
41
  const path = __importStar(require("path"));
42
42
  const config_1 = require("../config");
43
+ const context_report_1 = require("../context-report");
44
+ const codex_context_1 = require("./codex-context");
43
45
  const session_commands_1 = require("./session-commands");
44
46
  const terminal_pane_1 = require("./terminal-pane");
47
+ const compact_command_1 = require("./compact-command");
48
+ const fresh_start_1 = require("./fresh-start");
49
+ const project_brief_1 = require("./project-brief");
50
+ const project_notes_1 = require("./project-notes");
51
+ const context_notice_1 = require("./context-notice");
52
+ const fresh_start_verify_1 = require("./fresh-start-verify");
45
53
  const commands_1 = require("./acp/commands");
46
54
  const daemon_glue_1 = require("./acp/daemon-glue");
47
55
  const runner_1 = require("./acp/runner");
@@ -54,6 +62,7 @@ const queue_1 = require("./queue");
54
62
  const held_1 = require("./held");
55
63
  const provider_env_1 = require("./provider-env");
56
64
  const registry_1 = require("./registry");
65
+ const resume_1 = require("./resume");
57
66
  const mailbox_1 = require("./mailbox");
58
67
  const mailbox_watcher_1 = require("./mailbox-watcher");
59
68
  const socket_1 = require("./socket");
@@ -72,6 +81,19 @@ class RelayDaemon {
72
81
  acpQueue = new turn_queue_1.AcpTurnQueue();
73
82
  acp;
74
83
  acpAlive;
84
+ /** How full each session's context is, as its runtime reports it (contract §7). */
85
+ context;
86
+ /** Reads each Codex session's own rollout for its context numbers. */
87
+ codexContext;
88
+ /** The room each session was last woken in — where its context numbers belong. */
89
+ lastRoom = new Map();
90
+ /** Project briefs whose own message was a session command, waiting for the next chat message. */
91
+ carriedBriefs = new project_brief_1.CarriedBriefs();
92
+ carriedNotes = new project_notes_1.CarriedNotes();
93
+ carriedNotices = new context_notice_1.CarriedNotices();
94
+ /** Fresh-start steps already handled, on disk: a replayed step never runs twice. */
95
+ handledHandoffs = new fresh_start_verify_1.HandledHandoffs();
96
+ freshStartOpts;
75
97
  acpKeepAlive;
76
98
  queue;
77
99
  abort = new AbortController();
@@ -137,6 +159,10 @@ class RelayDaemon {
137
159
  * reporting nothing at all. See `held.ts`.
138
160
  */
139
161
  held = new held_1.HeldStore();
162
+ /** Server-fed registrations being applied right now, by session. See `deliver`. */
163
+ serverRegistrations = new Map();
164
+ /** Registrations whose thread id did not corroborate, by session + registeredAt → when. */
165
+ refusedRegistrations = new Map();
140
166
  /** Beyond this a session is not "busy", it is broken; stop growing and report. */
141
167
  static MAX_HELD = 200;
142
168
  reattachGraceMs;
@@ -201,10 +227,22 @@ class RelayDaemon {
201
227
  opts.resolveBinary ??
202
228
  ((name) => (0, runtime_binary_1.resolveRuntimeBinary)(name, (0, runtime_binary_1.currentBinaryEnv)()));
203
229
  this.runTurn = opts.runTurn;
230
+ this.freshStartOpts = opts.freshStart ?? {};
204
231
  this.queue = new queue_1.SessionQueue((session, batch) => this.deliver(session, batch), (session, err) => {
205
232
  this.lastError = `delivery failed for ${session}: ${errText(err)}`;
206
233
  this.log(this.lastError);
207
234
  });
235
+ this.context = new context_report_1.ContextReporter({ auth: () => this.auth, log: (line) => this.log(line) });
236
+ this.codexContext = new codex_context_1.CodexContextTailer({
237
+ // Codex sessions this relay can name a thread for, and that the server still lists as live.
238
+ targets: () => this.registry
239
+ .all()
240
+ .filter((t) => t.runtime === "codex" && t.resumeId && (t.attached || this.liveSessions.includes(t.name)))
241
+ .map((t) => ({ session: t.name, threadId: t.resumeId })),
242
+ report: (session, event) => this.reportContext(session, event),
243
+ conversationFor: (session) => this.lastRoom.get(session),
244
+ log: (line) => this.log(line),
245
+ });
208
246
  this.acpAlive = (0, daemon_glue_1.acpKeepAliveScheduler)(() => (0, daemon_glue_1.keepAcpSessionsAlive)({ registry: this.registry, auth: this.auth, caller: opts.acpCaller, log: this.log }));
209
247
  this.acp = new runner_1.AcpSessionRunner({
210
248
  registry: this.registry,
@@ -220,6 +258,7 @@ class RelayDaemon {
220
258
  onPending: (session, batch, reason) => this.record({ kind: "pending", session, reason }, session, batch),
221
259
  runTurn: opts.acpRunTurn,
222
260
  caller: opts.acpCaller,
261
+ reportContext: (session, event) => this.reportContext(session, event),
223
262
  });
224
263
  }
225
264
  async start() {
@@ -245,6 +284,14 @@ class RelayDaemon {
245
284
  // handed what the previous one was holding rather than a clean slate.
246
285
  this.held.load();
247
286
  this.acpQueue.load();
287
+ // A clear that was being typed when the previous relay stopped: the session may well have been
288
+ // cleared, so it is reported cleared-but-not-pointed (the server re-arms it), never left to
289
+ // time out as "not cleared".
290
+ for (const { handoffId, session } of this.handledHandoffs.interrupted()) {
291
+ this.log(`fresh start ${handoffId} for ${session} was interrupted mid-clear — reporting it cleared`);
292
+ this.handledHandoffs.mark(handoffId, "clear", session, "done");
293
+ void (0, fresh_start_1.postHandoffReport)(auth, session, { handoffId, status: "fresh", reason: "FIRST_PROMPT_FAILED" }, (line) => this.log(line));
294
+ }
248
295
  const sockPath = (0, socket_1.socketPath)();
249
296
  if (await (0, socket_1.probeSocket)(sockPath)) {
250
297
  throw new Error(`a relay is already listening on ${sockPath} — run \`baychat relay status\``);
@@ -272,6 +319,7 @@ class RelayDaemon {
272
319
  writePidFile();
273
320
  // After the socket is ours: a relay refused as a duplicate must not keep sessions alive.
274
321
  this.acpKeepAlive = (0, daemon_glue_1.startAcpUpkeep)(this.acpAlive.run);
322
+ this.codexContext.start();
275
323
  this.log(`listening on ${sockPath} (pid ${process.pid}) as device "${device.user.name}"`);
276
324
  this.log(`${this.registry.all().length} known session target(s)`);
277
325
  // The event SOURCE is pluggable (socket preferred, long-poll floor); every
@@ -284,15 +332,20 @@ class RelayDaemon {
284
332
  for (const e of events)
285
333
  this.onEvent(e);
286
334
  },
287
- onSessions: (names) => {
335
+ onSessions: (names, registrations) => {
288
336
  this.liveSessions = names;
289
337
  // A recovery must cover every live session, including one this relay has
290
338
  // never had to route an event to — skipping it re-baselines it at "now".
291
339
  this.watermarks.noteLiveSessions(names);
292
340
  const dropped = this.registry.pruneToLive(names);
293
- for (const name of dropped)
341
+ for (const name of dropped) {
294
342
  this.log(`session ended server-side, dropped: ${name}`);
343
+ // A name reused later is a new session: nothing kept for the old one may reach it.
344
+ this.forgetCarried(name);
345
+ }
295
346
  void this.acpAlive.lapsed(this.registry, names);
347
+ if (registrations)
348
+ this.applyServerRegistrations(registrations, names);
296
349
  },
297
350
  onPoll: (cursor) => {
298
351
  this.cursor = cursor;
@@ -381,7 +434,7 @@ class RelayDaemon {
381
434
  * apart from silence. A refusal that names its reason is the whole difference between
382
435
  * "this feature is broken" and "start your agent inside tmux".
383
436
  */
384
- async runSessionCommand(session, message, command) {
437
+ async runSessionCommand(session, message, command, compact = {}) {
385
438
  const say = async (text) => {
386
439
  if (!this.auth)
387
440
  return;
@@ -404,6 +457,13 @@ class RelayDaemon {
404
457
  await say((0, session_commands_1.sessionCommandHelp)());
405
458
  return;
406
459
  }
460
+ if (command.id === "compact") {
461
+ if (compact.refusal)
462
+ return say(compact.refusal);
463
+ if (target.acp) {
464
+ return say(`"${session}" is run by the relay and tidies its own memory; /compact cannot reach it. /new starts it fresh.`);
465
+ }
466
+ }
407
467
  // `/mode-*`, `/stop`, and what `/status` `/exit` `/clear` `/new` mean with no terminal.
408
468
  if (await (0, commands_1.runAcpSessionCommand)(this.acp, session, target, message, command, say))
409
469
  return;
@@ -441,7 +501,13 @@ class RelayDaemon {
441
501
  await say((0, session_commands_1.noTerminalReason)());
442
502
  return;
443
503
  }
444
- const keys = command.keys?.(target.runtime);
504
+ let keys = command.keys?.(target.runtime);
505
+ if (command.id === "compact") {
506
+ const typed = (0, compact_command_1.compactKeys)(target.runtime, compact.focus);
507
+ if (!typed.ok)
508
+ return say(typed.reason);
509
+ keys = typed.keys;
510
+ }
445
511
  if (!keys) {
446
512
  await say(`${command.trigger} has nothing to type for a ${target.runtime} session.`);
447
513
  return;
@@ -460,8 +526,158 @@ class RelayDaemon {
460
526
  // See `SessionRegistry.clearResume`: the live session's transcript is gone, so the
461
527
  // recorded resume id now points at something no half of this session has.
462
528
  this.registry.clearResume(session);
529
+ // …and whatever was kept to show it (briefs, notes deltas, the notice) described that memory.
530
+ this.forgetCarried(session);
531
+ }
532
+ await say(command.id === "compact"
533
+ ? `Typed ${keys} into "${session}". It is tidying its context now.`
534
+ : `Typed ${keys} into "${session}". Its context is cleared.`);
535
+ }
536
+ /**
537
+ * One step of "Fresh start with a handoff" (fresh-start.ts).
538
+ *
539
+ * `note`: if this relay could clear the session afterwards, the request joins `chat` and wakes
540
+ * the session like any message; if it could not, the server hears why at once and the session
541
+ * is never asked. `clear` (sent only once the note is saved): wait until the session is idle,
542
+ * clear it the way `/clear` and `/new` do, point it at its note, see where that first prompt
543
+ * landed, and report (fresh-start-verify.ts). A session this machine does not know is left to
544
+ * the relay that does; a step already handled — a re-served event, a replay after a restart —
545
+ * is never run twice; a clear past its `expiresAt` is not started.
546
+ */
547
+ async runFreshStart(session, message, fresh, chat) {
548
+ const target = this.registry.get(session);
549
+ if (!target) {
550
+ this.log(`fresh start ${fresh.handoffId} for ${session}: not a session on this machine — left to its own relay`);
551
+ return;
552
+ }
553
+ if (this.handledHandoffs.has(fresh.handoffId, fresh.phase)) {
554
+ this.log(`fresh start ${fresh.handoffId} for ${session}: the ${fresh.phase} step was already handled — ignored`);
555
+ return;
556
+ }
557
+ const done = () => this.handledHandoffs.mark(fresh.handoffId, fresh.phase, session, "done");
558
+ const report = (outcome) => this.auth ? (0, fresh_start_1.postHandoffReport)(this.auth, session, outcome, (line) => this.log(line)) : Promise.resolve();
559
+ const plan = (0, fresh_start_1.freshStartPlan)(target);
560
+ if (plan.kind === "refuse") {
561
+ this.log(`fresh start ${fresh.handoffId} for ${session} refused: ${plan.reason}`);
562
+ done();
563
+ await report({ handoffId: fresh.handoffId, status: "failed", reason: plan.reason });
564
+ return;
565
+ }
566
+ if (fresh.phase === "note") {
567
+ done();
568
+ chat.push((0, fresh_start_1.noteRequestAsChat)(message, fresh.handoffId));
569
+ return;
463
570
  }
464
- await say(`Typed ${keys} into "${session}". Its context is cleared.`);
571
+ if (fresh.expiresAt !== undefined && Date.now() > fresh.expiresAt) {
572
+ // The server has already given up on this step (CLEAR_TIMEOUT); clearing now would clear a
573
+ // session nobody is watching any more. Nothing is typed.
574
+ this.log(`fresh start ${fresh.handoffId} for ${session}: the clear step expired before it could start — not cleared`);
575
+ done();
576
+ return;
577
+ }
578
+ if (plan.kind === "acp") {
579
+ // No terminal: forget the ACP session and make the note its next prompt, alone; a turn
580
+ // running now is stopped and its messages wait behind the note (acp/runner.ts).
581
+ done();
582
+ this.forgetCarried(session);
583
+ this.acp.freshStart(session, (0, fresh_start_1.freshStartMessage)(message, fresh), fresh.handoffId);
584
+ this.log(`fresh start ${fresh.handoffId}: ${session} will start a new ACP session from its note`);
585
+ await report({ handoffId: fresh.handoffId, status: "fresh" });
586
+ return;
587
+ }
588
+ const pane = target.pane;
589
+ const opts = this.freshStartOpts;
590
+ // 1. Not in the middle of a turn: typed then, the clear is queued or refused and the pointer
591
+ // lands in the old conversation.
592
+ const idle = await (0, fresh_start_verify_1.waitForIdle)({
593
+ sample: () => (opts.samplePane ?? fresh_start_verify_1.samplePane)(pane),
594
+ attached: () => {
595
+ const sock = this.attached.get(session);
596
+ return !!sock && !sock.destroyed;
597
+ },
598
+ capMs: opts.idleCapMs,
599
+ stepMs: opts.idleStepMs,
600
+ });
601
+ if (idle === "busy") {
602
+ this.log(`fresh start ${fresh.handoffId}: ${session} stayed busy — nothing typed; its note is kept`);
603
+ done();
604
+ await report({ handoffId: fresh.handoffId, status: "failed", reason: "CLEAR_FAILED" });
605
+ return;
606
+ }
607
+ // 2. Clear. Recorded as started first: a relay that dies from here on reports it on restart.
608
+ this.handledHandoffs.mark(fresh.handoffId, "clear", session, "started");
609
+ const typedAt = Date.now();
610
+ const cleared = await (0, terminal_pane_1.typeIntoPane)(pane, target.runtime, plan.clearKeys, { ownerPid: target.ownerPid });
611
+ if (!cleared.ok) {
612
+ this.log(`fresh start ${fresh.handoffId}: could not clear ${session} — ${cleared.reason}`);
613
+ done();
614
+ await report({ handoffId: fresh.handoffId, status: "failed", reason: "CLEAR_FAILED" });
615
+ return;
616
+ }
617
+ const oldResumeId = target.resumeId;
618
+ // Same as `/clear`: the recorded resume id names a transcript this session no longer has, and
619
+ // anything kept to show it described the old memory.
620
+ this.registry.clearResume(session);
621
+ this.forgetCarried(session);
622
+ // Let the runtime's TUI finish clearing before the first prompt is typed into it.
623
+ await new Promise((resolve) => setTimeout(resolve, opts.settleMs ?? exports.FRESH_START_SETTLE_MS));
624
+ const typed = await (0, terminal_pane_1.typeIntoPane)(pane, target.runtime, (0, fresh_start_1.freshStartLine)(fresh.note.id, session), { ownerPid: target.ownerPid });
625
+ if (!typed.ok) {
626
+ // Cleared, but not pointed at its note: still fresh (the server must re-arm it), said so.
627
+ this.log(`fresh start ${fresh.handoffId}: ${session} was cleared but its first prompt was not typed — ${typed.reason}`);
628
+ done();
629
+ await report({ handoffId: fresh.handoffId, status: "fresh", reason: "FIRST_PROMPT_FAILED" });
630
+ return;
631
+ }
632
+ // 3. Where did the first prompt land? Only the pointer line names this note.
633
+ const runtime = target.runtime === "codex" ? "codex" : "claude";
634
+ const landing = await (opts.awaitPointer ?? fresh_start_verify_1.awaitPointer)({
635
+ runtime,
636
+ noteId: fresh.note.id,
637
+ sinceMs: typedAt - 1_000,
638
+ ...(oldResumeId ? { oldResumeId } : {}),
639
+ discovery: this.discovery,
640
+ timeoutMs: opts.pointerTimeoutMs,
641
+ });
642
+ done();
643
+ if (landing?.kind === "old") {
644
+ this.log(`fresh start ${fresh.handoffId}: ${plan.clearKeys} did not take in ${session} — its first prompt went to the old conversation`);
645
+ if (oldResumeId)
646
+ this.registry.upsert({ name: session, runtime: target.runtime, resumeId: oldResumeId });
647
+ await report({ handoffId: fresh.handoffId, status: "failed", reason: "CLEAR_FAILED" });
648
+ return;
649
+ }
650
+ if (landing?.kind === "new") {
651
+ this.registry.upsert({
652
+ name: session,
653
+ runtime: target.runtime,
654
+ resumeId: landing.id,
655
+ resumeSource: "transcript",
656
+ resumeEvidence: `the fresh session BayChat pointed at note ${fresh.note.id}`,
657
+ });
658
+ this.log(`fresh start ${fresh.handoffId}: ${session} started fresh (${landing.id}) and was pointed at its note`);
659
+ await report({ handoffId: fresh.handoffId, status: "fresh" });
660
+ return;
661
+ }
662
+ this.log(`fresh start ${fresh.handoffId}: typed ${plan.clearKeys} into ${session}, but could not see the fresh session start`);
663
+ await report({ handoffId: fresh.handoffId, status: "fresh", reason: "UNVERIFIED" });
664
+ }
665
+ /** The session's own context report — and what a new context means for what the relay keeps. */
666
+ reportContext(session, event) {
667
+ if (event.event === "session_start")
668
+ this.forgetCarried(session);
669
+ this.context.report(session, event);
670
+ }
671
+ /**
672
+ * Forget the briefs, notes deltas and notice kept for this session: it started fresh (a fresh
673
+ * start, `/clear`, `/new`, a new thread) or ended, so they describe a memory it no longer has —
674
+ * and a reused name must not inherit them. The server re-sends the whole set after a re-arm.
675
+ */
676
+ forgetCarried(session) {
677
+ this.carriedBriefs.forget(session);
678
+ this.carriedNotes.forget(session);
679
+ this.carriedNotices.forget(session);
680
+ this.acpQueue.forgetCarried(session);
465
681
  }
466
682
  /**
467
683
  * Wait for a session to re-arm its socket, up to `graceMs`.
@@ -494,13 +710,52 @@ class RelayDaemon {
494
710
  // obey something its HARNESS owns — `/clear` is read by the runtime's TUI, and no model
495
711
  // can run it for itself. So the commands are executed here and everything else wakes
496
712
  // the session exactly as it always did.
497
- const { commands, chat } = splitSessionCommands(batch);
498
- for (const { message, command } of commands) {
499
- await this.runSessionCommand(session, message, command);
713
+ const { commands, chat, dropped } = splitSessionCommands(batch);
714
+ for (const message of dropped) {
715
+ this.log(`session command ${message.id} for ${session} dropped: its sender may no longer command this session`);
716
+ }
717
+ // What rode on a COMMAND — the project brief, the notes delta, BayChat's notice — never reaches
718
+ // the session with it (the command is typed, not read), and the server will not send it again;
719
+ // a WITHDRAWN command's too (the server recorded it as handed whether or not the message is
720
+ // shown). It moves FORWARD to the next chat message for that room — in this batch, or on disk
721
+ // until the next one (project-brief.ts, project-notes.ts, context-notice.ts). What was kept
722
+ // from earlier batches is applied AFTER, underneath: it is older than anything here.
723
+ const homeless = [];
724
+ for (const message of [...commands.map((c) => c.message), ...dropped]) {
725
+ const lost = { message };
726
+ if (message.projectBrief && !(0, project_brief_1.carryBrief)(message.projectBrief, message.conversationId, chat))
727
+ lost.brief = message.projectBrief;
728
+ if (message.projectNotes && !(0, project_notes_1.carryNotes)(message.projectNotes, message, chat))
729
+ lost.notes = message.projectNotes;
730
+ if (message.notice && !(0, context_notice_1.carryNotice)(message.notice, chat))
731
+ lost.notice = message.notice;
732
+ if (lost.brief || lost.notes || lost.notice)
733
+ homeless.push(lost);
734
+ }
735
+ this.carriedBriefs.apply(session, chat);
736
+ this.carriedNotes.apply(session, chat);
737
+ this.carriedNotices.apply(session, chat);
738
+ for (const { message, brief, notes, notice } of homeless) {
739
+ if (brief)
740
+ this.carriedBriefs.keep(session, message.conversationId, brief);
741
+ if (notes)
742
+ this.carriedNotes.keep(session, message.conversationId, notes);
743
+ if (notice)
744
+ this.carriedNotices.keep(session, notice);
745
+ }
746
+ for (const { message, command, focus, refusal, fresh } of commands) {
747
+ if (fresh)
748
+ await this.runFreshStart(session, message, fresh, chat);
749
+ else
750
+ await this.runSessionCommand(session, message, command, { focus, refusal });
500
751
  }
501
752
  if (chat.length === 0)
502
753
  return;
503
754
  batch = chat;
755
+ // A registration from the server feed may still be corroborating: the roster that
756
+ // carried it always precedes the session's first event, but checking a thread id reads
757
+ // the disk. Wait for it rather than drop a message for a session we are registering.
758
+ await this.serverRegistrations.get(session);
504
759
  const target = this.registry.get(session);
505
760
  if (!target) {
506
761
  // NOT reported as pending, though an earlier version of this did.
@@ -1004,6 +1259,9 @@ class RelayDaemon {
1004
1259
  record(outcome, session, batch) {
1005
1260
  if (outcome.kind === "woken") {
1006
1261
  this.delivered += batch.length;
1262
+ const room = batch.at(-1)?.conversationId;
1263
+ if (room)
1264
+ this.lastRoom.set(session, room);
1007
1265
  // The one witness `relay status` may treat as proof that a session is
1008
1266
  // reachable again. A headless turn only counts when it exited cleanly: a
1009
1267
  // spawn that ran and failed delivered nothing, and recording it would
@@ -1032,6 +1290,134 @@ class RelayDaemon {
1032
1290
  }
1033
1291
  this.log(`ignored ${batch.length} message(s): ${outcome.reason}`);
1034
1292
  }
1293
+ /**
1294
+ * Record what an attach taught us about a session — the part every attach shares, whether
1295
+ * it arrived as a local `attach` frame or as a registration from the server feed.
1296
+ *
1297
+ * `sock` is the socket the attach arrived on; a server-fed registration has none, and so
1298
+ * displaces any socket holding the name, exactly as a newer local attach would.
1299
+ */
1300
+ recordAttach(frame, sock) {
1301
+ this.registry.upsert({
1302
+ name: frame.session,
1303
+ runtime: frame.runtime,
1304
+ delivery: frame.delivery,
1305
+ resumeId: frame.resumeId,
1306
+ resumeSource: frame.resumeSource,
1307
+ resumeEvidence: frame.resumeEvidence,
1308
+ resumeCwd: frame.resumeCwd,
1309
+ cwd: frame.cwd,
1310
+ runtimeBin: frame.runtimeBin,
1311
+ ownerPid: frame.ownerPid,
1312
+ pane: frame.pane,
1313
+ providerUrl: frame.providerUrl,
1314
+ providerVar: frame.providerVar,
1315
+ serverRegisteredAt: frame.serverRegisteredAt,
1316
+ });
1317
+ // A session that names itself supersedes anything discovery guessed
1318
+ // for it, so drop the throttle and let the next wake use the new id.
1319
+ if (frame.resumeId)
1320
+ this.discoveryReasons.delete(frame.session);
1321
+ // OVERLAP, AND WE CANNOT PREVENT IT — only report it. A headless turn
1322
+ // already running was spawned because this session looked gone; it is
1323
+ // mid-turn now, and the daemon will not kill a model that may be part
1324
+ // way through writing something. Two agents briefly hold this name.
1325
+ // Said out loud because the alternative is that nobody ever knows.
1326
+ if (this.headlessInFlight.has(frame.session)) {
1327
+ 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`);
1328
+ }
1329
+ // EVICT THE PREVIOUS HOLDER. A session name has exactly one listener,
1330
+ // and `set` alone would leave the old socket open and its process
1331
+ // running — which is how two orphaned attaches came to be stacked on
1332
+ // one name on 2026-08-30, each one a place a wake could vanish into.
1333
+ // Destroying it makes the displaced attach exit instead of lingering.
1334
+ const previous = this.attached.get(frame.session);
1335
+ if (previous && previous !== sock) {
1336
+ this.log(`replacing attach for ${frame.session}: dropping the previous one`);
1337
+ previous.destroy();
1338
+ }
1339
+ }
1340
+ /**
1341
+ * Native delivery (`codex queue`) owns this session from now on: no socket is waited on,
1342
+ * and anything held for it goes straight to the queue. The tail of a local
1343
+ * `attach { delivery: "queue" }` AND of a server-fed registration — one path, not two.
1344
+ */
1345
+ armNativeQueue(session, runtime, via = "") {
1346
+ this.attached.delete(session);
1347
+ this.registry.setAttached(session, false);
1348
+ this.log(`registered native delivery: ${session} (${runtime})${via}`);
1349
+ for (const room of this.held.roomsFor(session)) {
1350
+ for (const message of room.messages)
1351
+ this.queue.push(session, message);
1352
+ }
1353
+ }
1354
+ /**
1355
+ * Apply the server's registrations for this device, once each.
1356
+ *
1357
+ * Called with every roster. A registration is repeated on every one, so it is applied
1358
+ * only when its `registeredAt` differs from the one the local target was built from — a
1359
+ * new MCP join — and never for a session the same roster does not list as live.
1360
+ */
1361
+ applyServerRegistrations(regs, live) {
1362
+ const liveNames = new Set(live);
1363
+ for (const reg of regs) {
1364
+ if (!liveNames.has(reg.session))
1365
+ continue;
1366
+ if (this.registry.get(reg.session)?.serverRegisteredAt === reg.registeredAt)
1367
+ continue;
1368
+ if (this.serverRegistrations.has(reg.session))
1369
+ continue;
1370
+ const refusedAt = this.refusedRegistrations.get(`${reg.session}\u0000${reg.registeredAt}`);
1371
+ if (refusedAt !== undefined && Date.now() - refusedAt < DISCOVERY_INTERVAL_MS)
1372
+ continue;
1373
+ const applying = this.applyServerRegistration(reg)
1374
+ .catch((err) => this.log(`server registration for ${reg.session} failed: ${errText(err)}`))
1375
+ .finally(() => this.serverRegistrations.delete(reg.session));
1376
+ this.serverRegistrations.set(reg.session, applying);
1377
+ }
1378
+ }
1379
+ /**
1380
+ * Register one session exactly as a local `attach { delivery: "queue" }` frame would.
1381
+ *
1382
+ * SECURITY. The registration reached us only over this relay's OWN authenticated feed —
1383
+ * the server sends a device only the registrations its own credential made. Even so the
1384
+ * thread id is not taken on the server's word: it is corroborated against THIS machine's
1385
+ * Codex rollouts, the same check `$CODEX_THREAD_ID` gets at a local attach. So a thread id
1386
+ * can only ever become the target of `codex queue` / `thread/resume` for a LOCAL,
1387
+ * top-level Codex thread — never a subagent, never a thread on another machine, never
1388
+ * anything that is not a Codex thread at all.
1389
+ */
1390
+ async applyServerRegistration(reg) {
1391
+ const relayRun = (0, daemon_glue_1.acpAttachRefusal)(this.registry, reg.session);
1392
+ if (relayRun)
1393
+ return this.log(`server registration ignored: ${relayRun}`);
1394
+ const resume = await (0, resume_1.resumeIdFromSessionEnv)("codex", {
1395
+ ...this.discovery,
1396
+ env: { [resume_1.SESSION_ID_ENV.codex]: reg.threadId },
1397
+ });
1398
+ if (!resume.ok) {
1399
+ this.refusedRegistrations.set(`${reg.session}\u0000${reg.registeredAt}`, Date.now());
1400
+ this.log(`server registration for ${reg.session} refused: ${resume.reason}`);
1401
+ return;
1402
+ }
1403
+ const codex = this.binaryFor("codex");
1404
+ if (!codex.ok) {
1405
+ // Registered anyway: delivery reports its own `pending` reason per message, and a
1406
+ // user who installs Codex is believed on the next message.
1407
+ this.log(`server registration for ${reg.session}: no usable codex binary yet (${(0, runtime_binary_1.summarizeResolutionFailure)(codex)})`);
1408
+ }
1409
+ this.recordAttach({
1410
+ session: reg.session,
1411
+ runtime: "codex",
1412
+ delivery: "queue",
1413
+ resumeId: resume.resumeId,
1414
+ resumeSource: resume.source,
1415
+ resumeEvidence: `registered through MCP join_session; ${resume.evidence}`,
1416
+ resumeCwd: resume.cwd,
1417
+ serverRegisteredAt: reg.registeredAt,
1418
+ });
1419
+ this.armNativeQueue(reg.session, "codex", " via the server feed");
1420
+ }
1035
1421
  onConnection(sock) {
1036
1422
  let session;
1037
1423
  const read = (0, socket_1.createFrameReader)((frame) => {
@@ -1048,56 +1434,14 @@ class RelayDaemon {
1048
1434
  return;
1049
1435
  }
1050
1436
  session = frame.session;
1051
- this.registry.upsert({
1052
- name: frame.session,
1053
- runtime: frame.runtime,
1054
- delivery: frame.delivery,
1055
- resumeId: frame.resumeId,
1056
- resumeSource: frame.resumeSource,
1057
- resumeEvidence: frame.resumeEvidence,
1058
- resumeCwd: frame.resumeCwd,
1059
- cwd: frame.cwd,
1060
- runtimeBin: frame.runtimeBin,
1061
- ownerPid: frame.ownerPid,
1062
- pane: frame.pane,
1063
- providerUrl: frame.providerUrl,
1064
- providerVar: frame.providerVar,
1065
- });
1066
- // A session that names itself supersedes anything discovery guessed
1067
- // for it, so drop the throttle and let the next wake use the new id.
1068
- if (frame.resumeId)
1069
- this.discoveryReasons.delete(frame.session);
1070
- // OVERLAP, AND WE CANNOT PREVENT IT — only report it. A headless turn
1071
- // already running was spawned because this session looked gone; it is
1072
- // mid-turn now, and the daemon will not kill a model that may be part
1073
- // way through writing something. Two agents briefly hold this name.
1074
- // Said out loud because the alternative is that nobody ever knows.
1075
- if (this.headlessInFlight.has(frame.session)) {
1076
- 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`);
1077
- }
1078
- // EVICT THE PREVIOUS HOLDER. A session name has exactly one listener,
1079
- // and `set` alone would leave the old socket open and its process
1080
- // running — which is how two orphaned attaches came to be stacked on
1081
- // one name on 2026-08-30, each one a place a wake could vanish into.
1082
- // Destroying it makes the displaced attach exit instead of lingering.
1083
- const previous = this.attached.get(frame.session);
1084
- if (previous && previous !== sock) {
1085
- this.log(`replacing attach for ${frame.session}: dropping the previous one`);
1086
- previous.destroy();
1087
- }
1437
+ this.recordAttach(frame, sock);
1088
1438
  if (frame.delivery === "queue") {
1089
- this.attached.delete(frame.session);
1090
- this.registry.setAttached(frame.session, false);
1439
+ this.armNativeQueue(frame.session, frame.runtime);
1091
1440
  (0, socket_1.writeFrame)(sock, {
1092
1441
  type: "attached",
1093
1442
  session: frame.session,
1094
1443
  delivery: "queue",
1095
1444
  });
1096
- this.log(`registered native delivery: ${frame.session} (${frame.runtime})`);
1097
- for (const room of this.held.roomsFor(frame.session)) {
1098
- for (const message of room.messages)
1099
- this.queue.push(frame.session, message);
1100
- }
1101
1445
  return;
1102
1446
  }
1103
1447
  this.attached.set(frame.session, sock);
@@ -1164,6 +1508,8 @@ class RelayDaemon {
1164
1508
  }
1165
1509
  async stop() {
1166
1510
  clearInterval(this.acpKeepAlive);
1511
+ this.codexContext.stop();
1512
+ this.context.close();
1167
1513
  this.acp.shutdown();
1168
1514
  this.abort.abort();
1169
1515
  for (const sock of this.attached.values())
@@ -1185,34 +1531,44 @@ class RelayDaemon {
1185
1531
  }
1186
1532
  }
1187
1533
  exports.RelayDaemon = RelayDaemon;
1188
- /**
1189
- * Separate the session commands in a batch from the ordinary chat in it.
1190
- *
1191
- * THREE CONDITIONS, ALL REQUIRED, and each closes a different door:
1192
- *
1193
- * - `shouldRespond` — the message is addressed to THIS session. Without it, a `/clear`
1194
- * typed into a group chat that the session merely watches would clear it.
1195
- * - `commandAuthorized` — the SERVER says this sender may command this session. The relay
1196
- * never decides that itself (see `RelayMessage.commandAuthorized`), and an absent field
1197
- * is a no, so an older server can only ever under-permit.
1198
- * - an exact trigger match — "can you /clear please" is a sentence ABOUT a command and
1199
- * must reach the agent as one.
1200
- *
1201
- * Anything failing any of them is chat, and wakes the session exactly as it does today.
1202
- */
1534
+ /** How long a runtime's TUI is given to clear itself before the fresh session's first prompt. */
1535
+ exports.FRESH_START_SETTLE_MS = 1_500;
1203
1536
  function splitSessionCommands(batch) {
1204
1537
  const commands = [];
1205
1538
  const chat = [];
1539
+ const dropped = [];
1206
1540
  for (const message of batch) {
1207
- const command = message.shouldRespond === true && message.commandAuthorized === true
1208
- ? (0, session_commands_1.parseSessionCommand)(message.content)
1209
- : undefined;
1210
- if (command)
1541
+ const allowed = message.shouldRespond === true && message.commandAuthorized === true;
1542
+ const command = allowed ? (0, session_commands_1.parseSessionCommand)(message.content) : undefined;
1543
+ if (command) {
1211
1544
  commands.push({ message, command });
1545
+ continue;
1546
+ }
1547
+ // A REFUSED /compact is still a command, not chat: a focus carrying a newline or an escape
1548
+ // sequence must not reach the agent as a message it might act on either.
1549
+ const compact = allowed ? (0, compact_command_1.parseCompactCommand)(message.content) : undefined;
1550
+ if (compact?.kind === "compact")
1551
+ commands.push({ message, command: compact_command_1.COMPACT_COMMAND, ...(compact.focus ? { focus: compact.focus } : {}) });
1552
+ else if (compact?.kind === "refused")
1553
+ commands.push({ message, command: compact_command_1.COMPACT_COMMAND, refusal: compact.reason });
1554
+ // The app's "Fresh start": a directed command whose text is never typed (fresh-start.ts).
1555
+ else if (allowed && (0, fresh_start_1.parseFreshStart)(message)) {
1556
+ commands.push({ message, command: fresh_start_1.FRESH_START_COMMAND, fresh: (0, fresh_start_1.parseFreshStart)(message) });
1557
+ }
1558
+ // A DIRECTED command (the app's "Tidy up now": an event that exists on this session's feed
1559
+ // only, `metadata.type: "session.command"`) is never chat. If its authority was withdrawn
1560
+ // between the request and the hand-over, the server says `commandAuthorized: false` — and
1561
+ // the text must then reach the agent neither as keystrokes nor as a message to act on.
1562
+ else if (isDirectedCommand(message))
1563
+ dropped.push(message);
1212
1564
  else
1213
1565
  chat.push(message);
1214
1566
  }
1215
- return { commands, chat };
1567
+ return { commands, chat, dropped };
1568
+ }
1569
+ function isDirectedCommand(message) {
1570
+ const metadata = message.metadata;
1571
+ return !!metadata && typeof metadata === "object" && metadata.type === "session.command";
1216
1572
  }
1217
1573
  function writePidFile() {
1218
1574
  try {