baychat 0.24.0 → 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.
@@ -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");
@@ -73,6 +81,19 @@ class RelayDaemon {
73
81
  acpQueue = new turn_queue_1.AcpTurnQueue();
74
82
  acp;
75
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;
76
97
  acpKeepAlive;
77
98
  queue;
78
99
  abort = new AbortController();
@@ -206,10 +227,22 @@ class RelayDaemon {
206
227
  opts.resolveBinary ??
207
228
  ((name) => (0, runtime_binary_1.resolveRuntimeBinary)(name, (0, runtime_binary_1.currentBinaryEnv)()));
208
229
  this.runTurn = opts.runTurn;
230
+ this.freshStartOpts = opts.freshStart ?? {};
209
231
  this.queue = new queue_1.SessionQueue((session, batch) => this.deliver(session, batch), (session, err) => {
210
232
  this.lastError = `delivery failed for ${session}: ${errText(err)}`;
211
233
  this.log(this.lastError);
212
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
+ });
213
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 }));
214
247
  this.acp = new runner_1.AcpSessionRunner({
215
248
  registry: this.registry,
@@ -225,6 +258,7 @@ class RelayDaemon {
225
258
  onPending: (session, batch, reason) => this.record({ kind: "pending", session, reason }, session, batch),
226
259
  runTurn: opts.acpRunTurn,
227
260
  caller: opts.acpCaller,
261
+ reportContext: (session, event) => this.reportContext(session, event),
228
262
  });
229
263
  }
230
264
  async start() {
@@ -250,6 +284,14 @@ class RelayDaemon {
250
284
  // handed what the previous one was holding rather than a clean slate.
251
285
  this.held.load();
252
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
+ }
253
295
  const sockPath = (0, socket_1.socketPath)();
254
296
  if (await (0, socket_1.probeSocket)(sockPath)) {
255
297
  throw new Error(`a relay is already listening on ${sockPath} — run \`baychat relay status\``);
@@ -277,6 +319,7 @@ class RelayDaemon {
277
319
  writePidFile();
278
320
  // After the socket is ours: a relay refused as a duplicate must not keep sessions alive.
279
321
  this.acpKeepAlive = (0, daemon_glue_1.startAcpUpkeep)(this.acpAlive.run);
322
+ this.codexContext.start();
280
323
  this.log(`listening on ${sockPath} (pid ${process.pid}) as device "${device.user.name}"`);
281
324
  this.log(`${this.registry.all().length} known session target(s)`);
282
325
  // The event SOURCE is pluggable (socket preferred, long-poll floor); every
@@ -295,8 +338,11 @@ class RelayDaemon {
295
338
  // never had to route an event to — skipping it re-baselines it at "now".
296
339
  this.watermarks.noteLiveSessions(names);
297
340
  const dropped = this.registry.pruneToLive(names);
298
- for (const name of dropped)
341
+ for (const name of dropped) {
299
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
+ }
300
346
  void this.acpAlive.lapsed(this.registry, names);
301
347
  if (registrations)
302
348
  this.applyServerRegistrations(registrations, names);
@@ -388,7 +434,7 @@ class RelayDaemon {
388
434
  * apart from silence. A refusal that names its reason is the whole difference between
389
435
  * "this feature is broken" and "start your agent inside tmux".
390
436
  */
391
- async runSessionCommand(session, message, command) {
437
+ async runSessionCommand(session, message, command, compact = {}) {
392
438
  const say = async (text) => {
393
439
  if (!this.auth)
394
440
  return;
@@ -411,6 +457,13 @@ class RelayDaemon {
411
457
  await say((0, session_commands_1.sessionCommandHelp)());
412
458
  return;
413
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
+ }
414
467
  // `/mode-*`, `/stop`, and what `/status` `/exit` `/clear` `/new` mean with no terminal.
415
468
  if (await (0, commands_1.runAcpSessionCommand)(this.acp, session, target, message, command, say))
416
469
  return;
@@ -448,7 +501,13 @@ class RelayDaemon {
448
501
  await say((0, session_commands_1.noTerminalReason)());
449
502
  return;
450
503
  }
451
- 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
+ }
452
511
  if (!keys) {
453
512
  await say(`${command.trigger} has nothing to type for a ${target.runtime} session.`);
454
513
  return;
@@ -467,8 +526,158 @@ class RelayDaemon {
467
526
  // See `SessionRegistry.clearResume`: the live session's transcript is gone, so the
468
527
  // recorded resume id now points at something no half of this session has.
469
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);
470
531
  }
471
- await say(`Typed ${keys} into "${session}". Its context is cleared.`);
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;
570
+ }
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);
472
681
  }
473
682
  /**
474
683
  * Wait for a session to re-arm its socket, up to `graceMs`.
@@ -501,9 +710,44 @@ class RelayDaemon {
501
710
  // obey something its HARNESS owns — `/clear` is read by the runtime's TUI, and no model
502
711
  // can run it for itself. So the commands are executed here and everything else wakes
503
712
  // the session exactly as it always did.
504
- const { commands, chat } = splitSessionCommands(batch);
505
- for (const { message, command } of commands) {
506
- 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 });
507
751
  }
508
752
  if (chat.length === 0)
509
753
  return;
@@ -1015,6 +1259,9 @@ class RelayDaemon {
1015
1259
  record(outcome, session, batch) {
1016
1260
  if (outcome.kind === "woken") {
1017
1261
  this.delivered += batch.length;
1262
+ const room = batch.at(-1)?.conversationId;
1263
+ if (room)
1264
+ this.lastRoom.set(session, room);
1018
1265
  // The one witness `relay status` may treat as proof that a session is
1019
1266
  // reachable again. A headless turn only counts when it exited cleanly: a
1020
1267
  // spawn that ran and failed delivered nothing, and recording it would
@@ -1261,6 +1508,8 @@ class RelayDaemon {
1261
1508
  }
1262
1509
  async stop() {
1263
1510
  clearInterval(this.acpKeepAlive);
1511
+ this.codexContext.stop();
1512
+ this.context.close();
1264
1513
  this.acp.shutdown();
1265
1514
  this.abort.abort();
1266
1515
  for (const sock of this.attached.values())
@@ -1282,34 +1531,44 @@ class RelayDaemon {
1282
1531
  }
1283
1532
  }
1284
1533
  exports.RelayDaemon = RelayDaemon;
1285
- /**
1286
- * Separate the session commands in a batch from the ordinary chat in it.
1287
- *
1288
- * THREE CONDITIONS, ALL REQUIRED, and each closes a different door:
1289
- *
1290
- * - `shouldRespond` — the message is addressed to THIS session. Without it, a `/clear`
1291
- * typed into a group chat that the session merely watches would clear it.
1292
- * - `commandAuthorized` — the SERVER says this sender may command this session. The relay
1293
- * never decides that itself (see `RelayMessage.commandAuthorized`), and an absent field
1294
- * is a no, so an older server can only ever under-permit.
1295
- * - an exact trigger match — "can you /clear please" is a sentence ABOUT a command and
1296
- * must reach the agent as one.
1297
- *
1298
- * Anything failing any of them is chat, and wakes the session exactly as it does today.
1299
- */
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;
1300
1536
  function splitSessionCommands(batch) {
1301
1537
  const commands = [];
1302
1538
  const chat = [];
1539
+ const dropped = [];
1303
1540
  for (const message of batch) {
1304
- const command = message.shouldRespond === true && message.commandAuthorized === true
1305
- ? (0, session_commands_1.parseSessionCommand)(message.content)
1306
- : undefined;
1307
- 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) {
1308
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);
1309
1564
  else
1310
1565
  chat.push(message);
1311
1566
  }
1312
- 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";
1313
1572
  }
1314
1573
  function writePidFile() {
1315
1574
  try {
@@ -0,0 +1,237 @@
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.HandledHandoffs = void 0;
37
+ exports.samplePane = samplePane;
38
+ exports.waitForIdle = waitForIdle;
39
+ exports.pointerMarker = pointerMarker;
40
+ exports.locatePointer = locatePointer;
41
+ exports.awaitPointer = awaitPointer;
42
+ exports.handledHandoffsPath = handledHandoffsPath;
43
+ const child_process_1 = require("child_process");
44
+ const fs = __importStar(require("fs"));
45
+ const os = __importStar(require("os"));
46
+ const path = __importStar(require("path"));
47
+ const config_1 = require("../config");
48
+ const resume_1 = require("./resume");
49
+ /** What both Claude Code and Codex print in their status line while a turn runs. */
50
+ const BUSY_MARKER = /esc to interrupt/i;
51
+ function execText(file, args) {
52
+ return new Promise((resolve, reject) => {
53
+ (0, child_process_1.execFile)(file, args, { timeout: 5_000, encoding: "utf8", maxBuffer: 1024 * 1024 }, (err, stdout) => err ? reject(err) : resolve(stdout));
54
+ });
55
+ }
56
+ /** Read the bottom of the pane and say whether a turn is running. Never throws. */
57
+ async function samplePane(handle, exec = execText) {
58
+ try {
59
+ if (handle.kind === "tmux") {
60
+ const text = await exec("tmux", ["-S", handle.socket, "capture-pane", "-p", "-t", handle.pane, "-S", "-30"]);
61
+ return BUSY_MARKER.test(text) ? "busy" : "idle";
62
+ }
63
+ if (handle.kind === "screen") {
64
+ const file = path.join(os.tmpdir(), `baychat-pane-${process.pid}-${Date.now()}.txt`);
65
+ try {
66
+ await exec("screen", ["-S", handle.session, "-p", handle.window, "-X", "hardcopy", file]);
67
+ const text = fs.readFileSync(file, "utf8");
68
+ return BUSY_MARKER.test(text.split("\n").slice(-30).join("\n")) ? "busy" : "idle";
69
+ }
70
+ finally {
71
+ fs.rmSync(file, { force: true });
72
+ }
73
+ }
74
+ return "unknown";
75
+ }
76
+ catch {
77
+ return "unknown";
78
+ }
79
+ }
80
+ /**
81
+ * "idle" once two readings in a row say so; "busy" if a turn is still running at the cap;
82
+ * "unknown" when the pane could never be read and no socket vouched for the session.
83
+ */
84
+ async function waitForIdle(opts) {
85
+ const cap = opts.capMs ?? 60_000;
86
+ const step = opts.stepMs ?? 1_000;
87
+ const sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
88
+ const now = opts.now ?? Date.now;
89
+ const deadline = now() + cap;
90
+ let idleRun = 0;
91
+ let sawBusy = false;
92
+ for (;;) {
93
+ const state = await opts.sample();
94
+ if (state === "idle")
95
+ idleRun += 1;
96
+ else
97
+ idleRun = 0;
98
+ if (state === "busy")
99
+ sawBusy = true;
100
+ if (idleRun >= 2)
101
+ return "idle";
102
+ if (state === "unknown" && opts.attached())
103
+ return "idle";
104
+ if (now() >= deadline)
105
+ return sawBusy ? "busy" : state === "unknown" ? "unknown" : "idle";
106
+ await sleep(step);
107
+ }
108
+ }
109
+ /** The words only the pointer line carries (fresh-start.ts `freshStartLine`). */
110
+ function pointerMarker(noteId) {
111
+ return `Your handoff note is note ${noteId}:`;
112
+ }
113
+ async function readTail(file, bytes = 256 * 1024) {
114
+ const handle = await fs.promises.open(file, "r");
115
+ try {
116
+ const { size } = await handle.stat();
117
+ const start = Math.max(0, size - bytes);
118
+ const buf = Buffer.alloc(size - start);
119
+ await handle.read(buf, 0, buf.length, start);
120
+ return buf.toString("utf8");
121
+ }
122
+ finally {
123
+ await handle.close();
124
+ }
125
+ }
126
+ /** Where the pointer line was written, if anywhere yet. Never throws. */
127
+ async function locatePointer(search) {
128
+ const marker = pointerMarker(search.noteId);
129
+ let files;
130
+ try {
131
+ files = await (search.list ?? resume_1.transcriptsWrittenSince)(search.runtime, search.sinceMs, search.discovery);
132
+ }
133
+ catch {
134
+ return undefined;
135
+ }
136
+ for (const { file, id } of files) {
137
+ let text;
138
+ try {
139
+ text = await (search.readTail ?? readTail)(file);
140
+ }
141
+ catch {
142
+ continue;
143
+ }
144
+ if (!text.includes(marker))
145
+ continue;
146
+ return search.oldResumeId !== undefined && id === search.oldResumeId ? { kind: "old" } : { kind: "new", id };
147
+ }
148
+ return undefined;
149
+ }
150
+ /** `locatePointer`, asked again until the window closes. */
151
+ async function awaitPointer(search) {
152
+ const sleep = search.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
153
+ const tries = Math.max(1, Math.ceil((search.timeoutMs ?? 20_000) / (search.stepMs ?? 1_000)));
154
+ for (let i = 0; i < tries; i++) {
155
+ const found = await locatePointer(search);
156
+ if (found)
157
+ return found;
158
+ if (i < tries - 1)
159
+ await sleep(search.stepMs ?? 1_000);
160
+ }
161
+ return undefined;
162
+ }
163
+ // ─── Never twice ──────────────────────────────────────────────────────────────────────
164
+ function handledHandoffsPath() {
165
+ return path.join((0, config_1.configDir)(), "fresh-starts.json");
166
+ }
167
+ /** Kept for two days: far longer than the server's event ring could ever replay a step. */
168
+ const KEEP_MS = 48 * 60 * 60_000;
169
+ /**
170
+ * Fresh-start steps this relay has already handled, by `<handoffId>:<phase>`. ON DISK, 0600, next
171
+ * to the relay's other state: the feed replays after a restart, and a replayed `clear` must not
172
+ * clear a session a second time.
173
+ */
174
+ class HandledHandoffs {
175
+ file;
176
+ now;
177
+ steps;
178
+ constructor(file = handledHandoffsPath(), now = Date.now) {
179
+ this.file = file;
180
+ this.now = now;
181
+ }
182
+ has(handoffId, phase) {
183
+ return this.load().has(`${handoffId}:${phase}`);
184
+ }
185
+ mark(handoffId, phase, session, state) {
186
+ this.load().set(`${handoffId}:${phase}`, { session, state, at: this.now() });
187
+ this.save();
188
+ }
189
+ /** Clears that were being typed when the relay stopped: `{ handoffId, session }`. */
190
+ interrupted() {
191
+ return [...this.load()]
192
+ .filter(([key, step]) => step.state === "started" && key.endsWith(":clear"))
193
+ .map(([key, step]) => ({ handoffId: key.slice(0, -":clear".length), session: step.session }));
194
+ }
195
+ load() {
196
+ if (this.steps)
197
+ return this.steps;
198
+ this.steps = new Map();
199
+ let raw;
200
+ try {
201
+ raw = JSON.parse(fs.readFileSync(this.file, "utf8"));
202
+ }
203
+ catch (err) {
204
+ if (err.code !== "ENOENT") {
205
+ process.stderr.write(`baychat relay: could not read handled fresh starts — ${err instanceof Error ? err.message : String(err)}\n`);
206
+ }
207
+ return this.steps;
208
+ }
209
+ const cutoff = this.now() - KEEP_MS;
210
+ if (raw && typeof raw === "object") {
211
+ for (const [key, value] of Object.entries(raw)) {
212
+ const v = value;
213
+ if (!v || typeof v.session !== "string" || typeof v.at !== "number" || v.at < cutoff)
214
+ continue;
215
+ if (v.state !== "started" && v.state !== "done")
216
+ continue;
217
+ this.steps.set(key, { session: v.session, state: v.state, at: v.at });
218
+ }
219
+ }
220
+ return this.steps;
221
+ }
222
+ save() {
223
+ const cutoff = this.now() - KEEP_MS;
224
+ for (const [key, step] of this.steps ?? [])
225
+ if (step.at < cutoff)
226
+ this.steps.delete(key);
227
+ try {
228
+ fs.mkdirSync(path.dirname(this.file), { recursive: true });
229
+ fs.writeFileSync(this.file, JSON.stringify(Object.fromEntries(this.steps ?? new Map())), { mode: 0o600 });
230
+ fs.chmodSync(this.file, 0o600);
231
+ }
232
+ catch (err) {
233
+ process.stderr.write(`baychat relay: could not persist handled fresh starts — ${err instanceof Error ? err.message : String(err)}\n`);
234
+ }
235
+ }
236
+ }
237
+ exports.HandledHandoffs = HandledHandoffs;