baychat 0.27.1 → 0.29.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 (53) hide show
  1. package/.claude-plugin/plugin.json +32 -0
  2. package/README.md +42 -4
  3. package/dist/approve-hook.js +93 -3
  4. package/dist/channel/claude-link.js +145 -0
  5. package/dist/channel/detect.js +10 -2
  6. package/dist/channel/feed.js +27 -13
  7. package/dist/channel/launch.js +67 -12
  8. package/dist/channel/permission.js +118 -0
  9. package/dist/channel/plugin.js +182 -0
  10. package/dist/channel/relay-link.js +257 -0
  11. package/dist/channel/server.js +191 -10
  12. package/dist/claude-onboarding.js +7 -5
  13. package/dist/cli-help.js +235 -0
  14. package/dist/commands.js +4 -4
  15. package/dist/connect-acp.js +3 -2
  16. package/dist/connect-claude.js +31 -10
  17. package/dist/device-login.js +2 -2
  18. package/dist/doctor-command.js +2 -0
  19. package/dist/doctor.js +97 -3
  20. package/dist/help-topics.js +4 -3
  21. package/dist/launch/claude-trust.js +221 -0
  22. package/dist/launch/command.js +177 -0
  23. package/dist/launch/policy.js +152 -0
  24. package/dist/machine-name.js +76 -0
  25. package/dist/main.js +16 -170
  26. package/dist/multiplexer-launch.js +271 -0
  27. package/dist/protocol-content.js +1 -1
  28. package/dist/relay/acp/agents.js +42 -19
  29. package/dist/relay/acp/daemon-glue.js +3 -2
  30. package/dist/relay/acp/runner.js +8 -7
  31. package/dist/relay/channel-sessions.js +66 -0
  32. package/dist/relay/commands.js +9 -2
  33. package/dist/relay/daemon.js +160 -9
  34. package/dist/relay/launcher/approval-settings.js +140 -0
  35. package/dist/relay/launcher/argv.js +83 -0
  36. package/dist/relay/launcher/capabilities.js +76 -0
  37. package/dist/relay/launcher/dev-channel-screen.fixtures.js +41 -0
  38. package/dist/relay/launcher/dev-channel-screen.js +47 -0
  39. package/dist/relay/launcher/handle.js +303 -0
  40. package/dist/relay/launcher/loop.js +231 -0
  41. package/dist/relay/launcher/multiplexer.js +168 -0
  42. package/dist/relay/launcher/started.js +63 -0
  43. package/dist/relay/profiles.js +1 -1
  44. package/dist/relay/registry.js +26 -0
  45. package/dist/relay/terminal-pane.js +31 -2
  46. package/dist/relay/transport.js +7 -2
  47. package/dist/relay/ws.js +18 -1
  48. package/dist/runtime-install.js +19 -3
  49. package/dist/runtimes.js +3 -0
  50. package/dist/skill-bootstrap.js +1 -1
  51. package/dist/start-command.js +12 -56
  52. package/dist/tool-defs.js +23 -36
  53. package/package.json +4 -3
@@ -0,0 +1,66 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CHANNEL_REPLACED = void 0;
4
+ exports.channelRegisterRefusal = channelRegisterRefusal;
5
+ exports.channelGoneReason = channelGoneReason;
6
+ exports.channelExitReply = channelExitReply;
7
+ exports.channelCommandsLine = channelCommandsLine;
8
+ const daemon_glue_1 = require("./acp/daemon-glue");
9
+ /**
10
+ * Claude Code sessions that hear the Bay through the BayChat CHANNEL, as the relay sees them.
11
+ *
12
+ * ── WHAT THE RELAY DOES FOR SUCH A SESSION, AND WHAT IT NEVER DOES ─────────────────────
13
+ *
14
+ * The channel (packages/cli/src/channel/) already pushes every message into the session, so
15
+ * the relay never wakes, resumes, holds or queues one: a second delivery would make the agent
16
+ * answer twice, and a headless resume would clone a session that is alive. What only the relay
17
+ * can do is TYPE: `/clear`, `/new`, `/compact` and the fresh start's clear need the session's
18
+ * terminal, and a channel event is a prompt, which Claude Code never reads as a slash command.
19
+ * So the channel registers the session with its computer's relay for COMMANDS ONLY
20
+ * (`channel-register`, delivery "channel"), and keeps that socket open for the session's life.
21
+ *
22
+ * ── WHY THE SOCKET STAYS OPEN ──────────────────────────────────────────────────────────
23
+ *
24
+ * - Its close is the session's death certificate. Claude Code closes the channel server's
25
+ * stdin when it exits, the server exits, the socket closes: the registration stops being
26
+ * believed at once, and nothing is ever typed into a pane a later shell now holds. (The pane
27
+ * check in terminal-pane.ts still runs before every keystroke, as for any session.)
28
+ * - It carries the fresh start's note request back to the session (`handoff-note`): the
29
+ * channel hands it over as an event, the one way to make a channel session's agent act.
30
+ */
31
+ /** Said to a channel link displaced by a newer registration of the same session name. */
32
+ exports.CHANNEL_REPLACED = "replaced by a newer registration of this session";
33
+ /** Why a `channel-register` frame is refused, or undefined when it is accepted. */
34
+ function channelRegisterRefusal(frame, registry) {
35
+ if (frame.runtime !== "claude")
36
+ return `only a Claude Code session registers through its channel, not ${frame.runtime}`;
37
+ if (!frame.session.trim())
38
+ return "a channel registration needs the session's name";
39
+ return (0, daemon_glue_1.acpAttachRefusal)(registry, frame.session);
40
+ }
41
+ /** Why a command cannot be typed for this channel session right now, or undefined when it can. */
42
+ function channelGoneReason(session) {
43
+ return (`"${session}" hears the Bay through its Claude Code channel, and that channel is not connected to this relay — ` +
44
+ `the session has probably ended. Nothing was typed. If it is still open, it reconnects within a minute; try again then.`);
45
+ }
46
+ /** The reply to `/exit` for a channel session: the relay does not hold its feed, so it cannot detach it. */
47
+ function channelExitReply(session) {
48
+ return (`"${session}" hears the Bay through its Claude Code channel, which this relay does not hold, so /exit cannot detach it. ` +
49
+ `End the session from Settings → Sessions, or close Claude Code in its terminal.`);
50
+ }
51
+ /**
52
+ * One line on whether `/clear`, `/new` and `/compact` reach this channel session — for
53
+ * `relay status` and `/status`. `attached` is the channel's live link to this relay.
54
+ */
55
+ function channelCommandsLine(target) {
56
+ if (!target.attached)
57
+ return "commands: not ready — its Claude Code channel is not connected to this relay (the session ended, or is reconnecting)";
58
+ const pane = target.pane;
59
+ if (!pane)
60
+ return "commands: not ready — not inside tmux or screen, so /clear and /new have nothing to type into (start it with `baychat claude`)";
61
+ if (pane.kind === "tmux")
62
+ return `commands: ready (tmux pane ${pane.pane})`;
63
+ if (pane.kind === "screen")
64
+ return `commands: ready (screen ${pane.session}, window ${pane.window})`;
65
+ return `commands: not ready — ${pane.kind} cannot be typed into yet (tmux and screen can)`;
66
+ }
@@ -68,6 +68,7 @@ const resume_1 = require("./resume");
68
68
  const mailbox_1 = require("./mailbox");
69
69
  const socket_1 = require("./socket");
70
70
  const parent_watch_2 = require("./parent-watch");
71
+ const channel_sessions_1 = require("./channel-sessions");
71
72
  const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
72
73
  /** Connect to a running daemon, or explain that there isn't one. */
73
74
  async function connectOrFail() {
@@ -334,12 +335,14 @@ async function cmdRelayStatus() {
334
335
  }
335
336
  console.log(`\nSessions (${status.sessions.length}):`);
336
337
  if (status.sessions.length === 0) {
337
- console.log(" none — a session registers itself by running `baychat relay attach`");
338
+ console.log(" none — a session registers itself by running `baychat relay attach`, or by listening through its Claude Code channel");
338
339
  }
339
340
  for (const s of status.sessions) {
340
341
  const state = sessionState(s, lastFailureFor.get(s.name));
341
342
  console.log(` ${s.name} [${s.runtime}] ${state}`);
342
- console.log(` ${resumeLabel(s)}`);
343
+ // A channel session is never resumed by the relay, so its second line is whether the
344
+ // commands reach it — the one thing the relay does for it.
345
+ console.log(` ${s.delivery === "channel" ? (0, channel_sessions_1.channelCommandsLine)(s) : resumeLabel(s)}`);
343
346
  }
344
347
  // HELD is a THIRD state and prints as itself.
345
348
  //
@@ -622,6 +625,10 @@ async function readWakeBounded(fifo, deadline, pollMs = 25) {
622
625
  }
623
626
  /** The state half of one `relay status` session line. */
624
627
  function sessionState(s, lastWakeFailure) {
628
+ // The channel delivers this session's messages; the relay holds only its commands link.
629
+ if (s.delivery === "channel") {
630
+ return s.attached ? "Claude Code channel — connected (this relay runs its commands only)" : "Claude Code channel — NOT connected (the session ended, or is reconnecting)";
631
+ }
625
632
  // "registered", not "attached", for the FIFO rung — and the difference is not
626
633
  // pedantry. A socket attach IS a held connection, so `attached` is an
627
634
  // observation. A mailbox registration is a file on disk; whether the agent is
@@ -33,7 +33,9 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.FRESH_START_SETTLE_MS = exports.RelayDaemon = void 0;
36
+ exports.SESSION_COMMAND_MAX_AGE_MS = exports.FRESH_START_SETTLE_MS = exports.RelayDaemon = void 0;
37
+ exports.withoutStaleCommands = withoutStaleCommands;
38
+ exports.commandTypedReply = commandTypedReply;
37
39
  exports.splitSessionCommands = splitSessionCommands;
38
40
  const child_process_1 = require("child_process");
39
41
  const fs = __importStar(require("fs"));
@@ -55,6 +57,7 @@ const daemon_glue_1 = require("./acp/daemon-glue");
55
57
  const runner_1 = require("./acp/runner");
56
58
  const turn_queue_1 = require("./acp/turn-queue");
57
59
  const session_reply_1 = require("./session-reply");
60
+ const channel_sessions_1 = require("./channel-sessions");
58
61
  const runtime_binary_1 = require("../runtime-binary");
59
62
  const adapters_1 = require("./adapters");
60
63
  const parent_watch_1 = require("./parent-watch");
@@ -68,6 +71,8 @@ const mailbox_watcher_1 = require("./mailbox-watcher");
68
71
  const socket_1 = require("./socket");
69
72
  const transport_1 = require("./transport");
70
73
  const watermarks_1 = require("./watermarks");
74
+ const bay_relays_1 = require("./bay-relays");
75
+ const loop_1 = require("./launcher/loop");
71
76
  /**
72
77
  * How long a failed resume-id discovery is believed before it is retried.
73
78
  *
@@ -99,6 +104,11 @@ class RelayDaemon {
99
104
  abort = new AbortController();
100
105
  /** Live attach sockets by session name. */
101
106
  attached = new Map();
107
+ /**
108
+ * Claude Code channels registered for commands only (channel-sessions.ts), by session name.
109
+ * Never a delivery rung: nothing in the wake ladder reads this map.
110
+ */
111
+ channels = new Map();
102
112
  /** Sessions reachable over a mailbox FIFO, by session name. */
103
113
  mailboxes = new Map();
104
114
  mailboxWatchers;
@@ -179,9 +189,13 @@ class RelayDaemon {
179
189
  binaries = new Map();
180
190
  log;
181
191
  onListening;
192
+ startLauncherFn;
193
+ /** Aborted on stop(): ends the launcher's long-poll. */
194
+ launcherAbort = new AbortController();
182
195
  constructor(opts = {}) {
183
196
  this.log = opts.log ?? ((line) => console.log(`[relay] ${line}`));
184
197
  this.onListening = opts.onListening;
198
+ this.startLauncherFn = opts.startLauncher ?? loop_1.startLauncher;
185
199
  // EVERY candidate root, because the daemon cannot know which one the agent's
186
200
  // sandbox turned out to permit — it may have fallen back past the one this
187
201
  // process would have chosen.
@@ -260,6 +274,7 @@ class RelayDaemon {
260
274
  onPending: (session, batch, reason) => this.record({ kind: "pending", session, reason }, session, batch),
261
275
  runTurn: opts.acpRunTurn,
262
276
  caller: opts.acpCaller,
277
+ binaryVersion: opts.acpBinaryVersion,
263
278
  reportContext: (session, event) => this.reportContext(session, event),
264
279
  });
265
280
  }
@@ -323,6 +338,10 @@ class RelayDaemon {
323
338
  // After the socket is ours: a relay refused as a duplicate must not keep sessions alive.
324
339
  this.acpKeepAlive = (0, daemon_glue_1.startAcpUpkeep)(this.acpAlive.run);
325
340
  this.codexContext.start();
341
+ if (!(0, bay_relays_1.isBayRelayChild)()) {
342
+ // Never awaited and never fatal: the launcher retries its own network failures until stop().
343
+ void this.startLauncherFn({ auth, log: (line) => this.log(line), signal: this.launcherAbort.signal }).catch((err) => this.log(`launcher stopped: ${err instanceof Error ? err.message : String(err)}`));
344
+ }
326
345
  this.log(`listening on ${sockPath} (pid ${process.pid}) as device "${device.user.name}"`);
327
346
  this.log(`${this.registry.all().length} known session target(s)`);
328
347
  // The event SOURCE is pluggable (socket preferred, long-poll floor); every
@@ -452,7 +471,11 @@ class RelayDaemon {
452
471
  };
453
472
  const target = this.registry.get(session);
454
473
  if (!target) {
455
- await say(`This relay does not know a session called "${session}" on this machine.`);
474
+ // SILENT. Every relay of a person is handed every live session's events, so a session this
475
+ // relay does not know is normally another computer's — and that computer's relay answers.
476
+ // Saying "I do not know it" from each of the others put one wrong reply per computer in the
477
+ // room (seen 2026-10-05: a Mac and a WSL relay beside the VPS). Same rule as `deliver()`.
478
+ this.log(`session command ${command.trigger} for ${session}: not a session on this machine — left to its own relay`);
456
479
  return;
457
480
  }
458
481
  this.log(`session command ${command.trigger} for ${session} from ${message.senderId}`);
@@ -480,12 +503,21 @@ class RelayDaemon {
480
503
  `Session "${session}"`,
481
504
  ` runtime: ${target.runtime}`,
482
505
  ` terminal: ${where}`,
483
- ` attached: ${target.attached ? `yes (${target.transport ?? "socket"})` : "no"}`,
484
- ` can resume: ${target.resumeId ? `yes (${target.resumeSource ?? "recorded"})` : "no"}`,
506
+ ...(target.delivery === "channel"
507
+ ? [
508
+ " delivery: its Claude Code channel — this relay runs commands only",
509
+ ` ${(0, channel_sessions_1.channelCommandsLine)({ pane: target.pane, attached: this.channelLinked(session) })}`,
510
+ ]
511
+ : [
512
+ ` attached: ${target.attached ? `yes (${target.transport ?? "socket"})` : "no"}`,
513
+ ` can resume: ${target.resumeId ? `yes (${target.resumeSource ?? "recorded"})` : "no"}`,
514
+ ]),
485
515
  ].join("\n"));
486
516
  return;
487
517
  }
488
518
  if (command.id === "exit") {
519
+ if (target.delivery === "channel")
520
+ return say((0, channel_sessions_1.channelExitReply)(session));
489
521
  // DETACH, never kill. Ending somebody's terminal agent from a phone is not a thing
490
522
  // the relay should be able to do by itself, and "stop being reachable" is the part it
491
523
  // genuinely owns and can undo. The reply says which one happened, because "exit" will
@@ -500,6 +532,12 @@ class RelayDaemon {
500
532
  return;
501
533
  }
502
534
  // What is left types into the terminal.
535
+ if (target.delivery === "channel" && !this.channelLinked(session)) {
536
+ // Its channel's link closed: Claude Code has (almost certainly) exited, and its pane may
537
+ // hold a shell or a different session by now.
538
+ await say((0, channel_sessions_1.channelGoneReason)(session));
539
+ return;
540
+ }
503
541
  if (!target.pane) {
504
542
  await say((0, session_commands_1.noTerminalReason)());
505
543
  return;
@@ -532,9 +570,10 @@ class RelayDaemon {
532
570
  // …and whatever was kept to show it (briefs, notes deltas, the notice) described that memory.
533
571
  this.forgetCarried(session);
534
572
  }
535
- await say(command.id === "compact"
536
- ? `Typed ${keys} into "${session}". It is tidying its context now.`
537
- : `Typed ${keys} into "${session}". Its context is cleared.`);
573
+ // HONEST about what typing proves. A runtime in the middle of a reply QUEUES typed input
574
+ // (Claude Code: "Press up to edit queued messages"), so the command runs when that reply
575
+ // ends, not now. Seen on the VPS 2026-10-05, where "Its context is cleared" was untrue.
576
+ await say(commandTypedReply(keys, session, command.id === "compact" ? "tidies its context" : "starts with a cleared context"));
538
577
  }
539
578
  /**
540
579
  * One step of "Fresh start with a handoff" (fresh-start.ts).
@@ -566,8 +605,16 @@ class RelayDaemon {
566
605
  await report({ handoffId: fresh.handoffId, status: "failed", reason: plan.reason });
567
606
  return;
568
607
  }
608
+ if (target.delivery === "channel" && !this.channelLinked(session)) {
609
+ this.log(`fresh start ${fresh.handoffId} for ${session}: its channel is not connected — not started`);
610
+ done();
611
+ await report({ handoffId: fresh.handoffId, status: "failed", reason: "SESSION_GONE" });
612
+ return;
613
+ }
569
614
  if (fresh.phase === "note") {
570
615
  done();
616
+ if (target.delivery === "channel")
617
+ return this.askChannelForNote(session, message, fresh.handoffId, report);
571
618
  chat.push((0, fresh_start_1.noteRequestAsChat)(message, fresh.handoffId));
572
619
  return;
573
620
  }
@@ -713,8 +760,19 @@ class RelayDaemon {
713
760
  // obey something its HARNESS owns — `/clear` is read by the runtime's TUI, and no model
714
761
  // can run it for itself. So the commands are executed here and everything else wakes
715
762
  // the session exactly as it always did.
716
- const { commands, chat, dropped } = splitSessionCommands(batch);
763
+ const split = splitSessionCommands(batch);
764
+ const { chat, dropped } = split;
765
+ // A command REPLAYED long after it was sent (a relay restart resuming its cursor, a long
766
+ // outage) is not run or answered: typing `/clear` minutes later surprises whoever is now at the
767
+ // terminal. Dropped with a log line, never turned into chat.
768
+ const { commands, stale } = withoutStaleCommands(split.commands, Date.now());
769
+ for (const message of stale) {
770
+ this.log(`session command ${message.id} for ${session} dropped: sent ${message.createdAt}, older than ${exports.SESSION_COMMAND_MAX_AGE_MS / 1000}s — replayed, not run`);
771
+ }
772
+ dropped.push(...stale);
717
773
  for (const message of dropped) {
774
+ if (stale.includes(message))
775
+ continue;
718
776
  this.log(`session command ${message.id} for ${session} dropped: its sender may no longer command this session`);
719
777
  }
720
778
  // What rode on a COMMAND — the project brief, the notes delta, BayChat's notice — never reaches
@@ -755,6 +813,12 @@ class RelayDaemon {
755
813
  if (chat.length === 0)
756
814
  return;
757
815
  batch = chat;
816
+ if (this.registry.get(session)?.delivery === "channel") {
817
+ // The channel has already pushed these to the session (channel-sessions.ts). A wake, a
818
+ // resume or a hold here would make it answer twice — or clone it.
819
+ this.record({ kind: "ignored", reason: `${session} hears the Bay through its Claude Code channel` }, session, batch);
820
+ return;
821
+ }
758
822
  // A registration from the server feed may still be corroborating: the roster that
759
823
  // carried it always precedes the session's first event, but checking a thread id reads
760
824
  // the disk. Wait for it rather than drop a message for a session we are registering.
@@ -1317,6 +1381,9 @@ class RelayDaemon {
1317
1381
  providerVar: frame.providerVar,
1318
1382
  serverRegisteredAt: frame.serverRegisteredAt,
1319
1383
  });
1384
+ // A terminal attaching under a name a channel registered takes delivery back from it.
1385
+ if (this.registry.get(frame.session)?.delivery === "channel")
1386
+ this.registry.setDelivery(frame.session, frame.delivery);
1320
1387
  // A session that names itself supersedes anything discovery guessed
1321
1388
  // for it, so drop the throttle and let the next wake use the new id.
1322
1389
  if (frame.resumeId)
@@ -1458,6 +1525,14 @@ class RelayDaemon {
1458
1525
  (0, socket_1.writeFrame)(sock, { type: "status-result", status: this.status() });
1459
1526
  return;
1460
1527
  }
1528
+ if (frame.type === "channel-register") {
1529
+ const refusal = (0, channel_sessions_1.channelRegisterRefusal)(frame, this.registry);
1530
+ if (refusal)
1531
+ return void (0, socket_1.writeFrame)(sock, { type: "error", message: refusal });
1532
+ session = frame.session;
1533
+ this.registerChannel(frame, sock);
1534
+ return;
1535
+ }
1461
1536
  if (frame.type === "acp-register") {
1462
1537
  const result = (0, daemon_glue_1.registerAcpFrame)(frame, { registry: this.registry, log: this.log, resolveBinary: this.resolveBinary });
1463
1538
  (0, socket_1.writeFrame)(sock, result.ok ? { type: "status-result", status: this.status() } : { type: "error", message: result.message });
@@ -1480,14 +1555,61 @@ class RelayDaemon {
1480
1555
  this.registry.setAttached(session, false);
1481
1556
  this.log(`detached: ${session}`);
1482
1557
  }
1558
+ if (session && this.channels.get(session) === sock) {
1559
+ this.channels.delete(session);
1560
+ this.registry.setAttached(session, false);
1561
+ this.log(`channel disconnected: ${session} — its commands are refused until it reconnects`);
1562
+ }
1483
1563
  };
1484
1564
  sock.on("close", drop);
1485
1565
  sock.on("error", drop);
1486
1566
  }
1567
+ /**
1568
+ * A Claude Code channel registering its session for commands only (channel-sessions.ts).
1569
+ * Recorded WHOLE, so a pane from an earlier registration never survives one without it. One
1570
+ * name, one channel: a displaced link is told why, so it stops retrying instead of fighting.
1571
+ */
1572
+ registerChannel(frame, sock) {
1573
+ const previous = this.channels.get(frame.session);
1574
+ if (previous && previous !== sock) {
1575
+ this.log(`replacing channel for ${frame.session}: dropping the previous one`);
1576
+ (0, socket_1.writeFrame)(previous, { type: "error", message: channel_sessions_1.CHANNEL_REPLACED });
1577
+ previous.end();
1578
+ }
1579
+ this.registry.replace({ name: frame.session, runtime: frame.runtime, delivery: "channel", pane: frame.pane, ownerPid: frame.ownerPid });
1580
+ this.channels.set(frame.session, sock);
1581
+ this.registry.setAttached(frame.session, true);
1582
+ (0, socket_1.writeFrame)(sock, { type: "attached", session: frame.session, delivery: "channel" });
1583
+ const where = frame.pane?.kind === "tmux" ? `tmux pane ${frame.pane.pane}` : (frame.pane?.kind ?? "no multiplexer");
1584
+ this.log(`channel registered: ${frame.session} (${frame.runtime}, commands only, ${where})`);
1585
+ }
1586
+ channelLinked(session) {
1587
+ const sock = this.channels.get(session);
1588
+ return !!sock && !sock.destroyed;
1589
+ }
1590
+ /**
1591
+ * The fresh start's note request, for a session its CHANNEL delivers to: the relay drops that
1592
+ * session's chat, and this request exists only on the relay's feed — so it goes back over the
1593
+ * channel's own link, which hands it to the session as an event. Unreachable → SESSION_GONE.
1594
+ */
1595
+ async askChannelForNote(session, message, handoffId, report) {
1596
+ try {
1597
+ const link = this.channels.get(session);
1598
+ if (!link)
1599
+ throw new Error("the channel is not connected");
1600
+ await (0, socket_1.writeFrameAck)(link, { type: "handoff-note", session, conversationId: message.conversationId, handoffId, content: message.content });
1601
+ this.log(`fresh start ${handoffId}: asked ${session} for its note through its channel`);
1602
+ }
1603
+ catch (err) {
1604
+ this.log(`fresh start ${handoffId}: could not reach ${session}'s channel — ${errText(err)}`);
1605
+ await report({ handoffId, status: "failed", reason: "SESSION_GONE" });
1606
+ }
1607
+ }
1487
1608
  status() {
1488
1609
  const sessions = this.registry.all().map((t) => ({
1489
1610
  ...t,
1490
- attached: this.attached.has(t.name) || this.mailboxes.has(t.name),
1611
+ attached: this.attached.has(t.name) || this.mailboxes.has(t.name) || this.channelLinked(t.name),
1612
+ // A channel link is not a delivery transport: `delivery: "channel"` says what it is.
1491
1613
  transport: this.attached.has(t.name)
1492
1614
  ? "socket"
1493
1615
  : this.mailboxes.has(t.name)
@@ -1511,6 +1633,7 @@ class RelayDaemon {
1511
1633
  }
1512
1634
  async stop() {
1513
1635
  clearInterval(this.acpKeepAlive);
1636
+ this.launcherAbort.abort();
1514
1637
  this.codexContext.stop();
1515
1638
  this.context.close();
1516
1639
  this.acp.shutdown();
@@ -1518,6 +1641,9 @@ class RelayDaemon {
1518
1641
  for (const sock of this.attached.values())
1519
1642
  sock.destroy();
1520
1643
  this.attached.clear();
1644
+ for (const sock of this.channels.values())
1645
+ sock.destroy();
1646
+ this.channels.clear();
1521
1647
  await this.queue.idle();
1522
1648
  await this.acp.idle();
1523
1649
  for (const w of this.mailboxWatchers)
@@ -1536,6 +1662,31 @@ class RelayDaemon {
1536
1662
  exports.RelayDaemon = RelayDaemon;
1537
1663
  /** How long a runtime's TUI is given to clear itself before the fresh session's first prompt. */
1538
1664
  exports.FRESH_START_SETTLE_MS = 1_500;
1665
+ /** A session command older than this (server `createdAt` to now) is a replay: dropped, not run. */
1666
+ exports.SESSION_COMMAND_MAX_AGE_MS = 2 * 60_000;
1667
+ /**
1668
+ * Split off the commands that arrive too late to run. Measured from the message's SERVER
1669
+ * timestamp, never local arrival, so a relay restart that resumes its cursor cannot replay a
1670
+ * `/clear` from minutes ago. A fresh-start step is exempt: it is deduplicated on disk by handoff id
1671
+ * and the server bounds it (the clear's `expiresAt`, the note's 15-minute wait). An unreadable
1672
+ * timestamp is treated as fresh: the server always sends one.
1673
+ */
1674
+ function withoutStaleCommands(commands, now) {
1675
+ const live = [];
1676
+ const stale = [];
1677
+ for (const command of commands) {
1678
+ const sent = Date.parse(command.message.createdAt);
1679
+ if (!command.fresh && Number.isFinite(sent) && now - sent > exports.SESSION_COMMAND_MAX_AGE_MS)
1680
+ stale.push(command.message);
1681
+ else
1682
+ live.push(command);
1683
+ }
1684
+ return { commands: live, stale };
1685
+ }
1686
+ /** The room's reply once a command was typed: what typing proves, and no more. */
1687
+ function commandTypedReply(keys, session, effect) {
1688
+ return `Typed ${keys} into "${session}". It ${effect} now — or, if it is in the middle of a reply, as soon as that reply ends.`;
1689
+ }
1539
1690
  function splitSessionCommands(batch) {
1540
1691
  const commands = [];
1541
1692
  const chat = [];
@@ -0,0 +1,140 @@
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.TERMINAL_SETTINGS_FILE = exports.JOIN_ALLOW = void 0;
37
+ exports.approvalPathFor = approvalPathFor;
38
+ exports.launchDir = launchDir;
39
+ exports.approvalSettingsPath = approvalSettingsPath;
40
+ exports.approvalSettings = approvalSettings;
41
+ exports.terminalApprovalSettings = terminalApprovalSettings;
42
+ exports.approvalsDir = approvalsDir;
43
+ exports.writeTerminalApprovalSettings = writeTerminalApprovalSettings;
44
+ exports.currentCliLaunch = currentCliLaunch;
45
+ exports.writeApprovalSettings = writeApprovalSettings;
46
+ exports.removeApprovalSettings = removeApprovalSettings;
47
+ const fs = __importStar(require("fs"));
48
+ const path = __importStar(require("path"));
49
+ const approve_hook_1 = require("../../approve-hook");
50
+ const config_1 = require("../../config");
51
+ /** Always `"hook"` for now: Claude Code's channel permission relay is behind a flag that is off. */
52
+ function approvalPathFor(_sessionName) {
53
+ return "hook";
54
+ }
55
+ function launchDir() {
56
+ return path.join((0, config_1.configDir)(), "launch");
57
+ }
58
+ function approvalSettingsPath(sessionName, dir = launchDir()) {
59
+ return path.join(dir, `${sessionName}.settings.json`);
60
+ }
61
+ /**
62
+ * What a launched session may do WITHOUT a card, so it can join before any card can be answered.
63
+ * `Bash(<prefix>:*)` is Claude Code's prefix rule and `mcp__<server>__*` covers every tool of that
64
+ * one server. Measured on Claude Code 2.1.289 (2026-10-05): a bare `mcp__<server>` did NOT allow
65
+ * the server's tools (the hook was asked), while `mcp__<server>__*` did. The two channel entries are the plugin's and the hand-registered
66
+ * server's names for the same channel (channel/detect.ts); `mcp__baychat__*` is the default Bay's
67
+ * remote connection, the one a launched session joins on.
68
+ */
69
+ exports.JOIN_ALLOW = [
70
+ "Bash(baychat skill:*)",
71
+ "Bash(baychat session-name:*)",
72
+ "mcp__baychat__*",
73
+ "mcp__baychat_channel__*",
74
+ "mcp__plugin_baychat_channel__*",
75
+ ];
76
+ /** The PermissionRequest hook block, running approve-hook by absolute node + entry with `flags`. */
77
+ function permissionHook(cli, flags) {
78
+ return {
79
+ PermissionRequest: [
80
+ {
81
+ matcher: "*",
82
+ hooks: [
83
+ {
84
+ type: "command",
85
+ // Exec form (command + args): each element is one argument, no shell, no quoting.
86
+ // By absolute path: a missing `baychat` on PATH would silently disable the gate.
87
+ command: cli.node,
88
+ args: [cli.entry, "approve-hook", ...flags, "--timeout", String(approve_hook_1.DEFAULT_WAIT_SECONDS)],
89
+ timeout: approve_hook_1.RECOMMENDED_HANDLER_TIMEOUT_SECONDS,
90
+ statusMessage: "Waiting for approval in BayChat…",
91
+ },
92
+ ],
93
+ },
94
+ ],
95
+ };
96
+ }
97
+ /** The settings object for a session started from BayChat. Pure; the test pins its exact content. */
98
+ function approvalSettings(sessionName, cli) {
99
+ return {
100
+ permissions: { allow: [...exports.JOIN_ALLOW] },
101
+ hooks: permissionHook(cli, ["--session", sessionName]),
102
+ };
103
+ }
104
+ /** The settings object for `baychat claude` / `baychat start`: the hook only, no allowlist. */
105
+ function terminalApprovalSettings(cli) {
106
+ return { hooks: permissionHook(cli, ["--session-from-claude"]) };
107
+ }
108
+ /** `~/.baychat/approvals`: where the terminal launches' settings file lives. */
109
+ function approvalsDir() {
110
+ return path.join((0, config_1.configDir)(), "approvals");
111
+ }
112
+ exports.TERMINAL_SETTINGS_FILE = "claude-terminal.settings.json";
113
+ /**
114
+ * Write (or refresh — the CLI may have moved since last time) the terminal launches' settings
115
+ * file, 0600 in a 0700 folder, and return its path.
116
+ */
117
+ function writeTerminalApprovalSettings(cli, dir = approvalsDir()) {
118
+ const file = path.join(dir, exports.TERMINAL_SETTINGS_FILE);
119
+ writePrivateJson(dir, file, terminalApprovalSettings(cli));
120
+ return file;
121
+ }
122
+ function writePrivateJson(dir, file, value) {
123
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
124
+ fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
125
+ fs.chmodSync(file, 0o600);
126
+ }
127
+ /** This CLI by absolute path: the node running it and its entry script. */
128
+ function currentCliLaunch() {
129
+ return { node: fs.realpathSync(process.execPath), entry: fs.realpathSync(process.argv[1] ?? "") };
130
+ }
131
+ /** Write the session's settings file (0600) and return its path. `sessionName` must already be validated. */
132
+ function writeApprovalSettings(sessionName, cli, dir = launchDir()) {
133
+ const file = approvalSettingsPath(sessionName, dir);
134
+ writePrivateJson(dir, file, approvalSettings(sessionName, cli));
135
+ return file;
136
+ }
137
+ /** Delete the session's settings file. Idempotent: an absent file is already deleted. */
138
+ function removeApprovalSettings(sessionName, dir = launchDir()) {
139
+ fs.rmSync(approvalSettingsPath(sessionName, dir), { force: true });
140
+ }
@@ -0,0 +1,83 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.LAUNCH_SESSION_PREFIX = void 0;
4
+ exports.multiplexerSessionName = multiplexerSessionName;
5
+ exports.baychatPrompt = baychatPrompt;
6
+ exports.buildLaunchCommand = buildLaunchCommand;
7
+ exports.launchNameProblem = launchNameProblem;
8
+ exports.roomTitleProblem = roomTitleProblem;
9
+ /**
10
+ * The exact process a launch runs — pure, so the security-relevant shape is testable without
11
+ * ever starting tmux.
12
+ *
13
+ * ── NO SHELL, ANYWHERE ────────────────────────────────────────────────────────────────
14
+ * The result is spawned with `shell: false`. The room title arrives from the network and travels
15
+ * as ONE argv element (inside the `/baychat …` prompt Claude Code receives as its first message);
16
+ * it is never interpolated into a command string, so `"; rm -rf ~` is just a strange title.
17
+ * tmux and screen exec the program and its arguments directly when given more than one word
18
+ * after the session options.
19
+ *
20
+ * ONE LAUNCHER: the multiplexer argv itself comes from multiplexer-launch.ts, the module
21
+ * `baychat start` and `baychat claude` use, as its `detached` kind. So the binary, the
22
+ * `channelLaunchFor` flags, and the `env K=V … <claude>` exec that carries the channel marker and
23
+ * PATH into the pane are the same code on every path. What differs here is only that a launch is
24
+ * DETACHED (`-d` / `-dm`) — nobody is at the terminal — and carries `--settings` for approvals.
25
+ */
26
+ const multiplexer_launch_1 = require("../../multiplexer-launch");
27
+ /** Every launcher-made multiplexer session is named `bc-<session name>`. */
28
+ exports.LAUNCH_SESSION_PREFIX = "bc-";
29
+ function multiplexerSessionName(sessionName) {
30
+ return `${exports.LAUNCH_SESSION_PREFIX}${sessionName}`;
31
+ }
32
+ /** The first message Claude Code receives: the BayChat skill, joining the chosen room. */
33
+ function baychatPrompt(sessionName, join) {
34
+ if (!join)
35
+ return `/baychat ${sessionName}`;
36
+ if ("sessions" in join)
37
+ return `/baychat ${sessionName} --sessions`;
38
+ return `/baychat ${sessionName} --group "${join.group}"`;
39
+ }
40
+ function buildLaunchCommand(input) {
41
+ const name = multiplexerSessionName(input.sessionName);
42
+ const args = (0, multiplexer_launch_1.multiplexerArgv)(input.multiplexer, { kind: "detached", name, cwd: input.folder }, {
43
+ binary: input.claudeBin,
44
+ args: [
45
+ ...input.channelArgs,
46
+ ...(input.settingsFile ? ["--settings", input.settingsFile] : []),
47
+ baychatPrompt(input.sessionName, input.join),
48
+ ],
49
+ env: input.env,
50
+ });
51
+ return { file: input.multiplexer, args, cwd: input.folder, env: input.env };
52
+ }
53
+ const SESSION_NAME_MAX = 60;
54
+ /**
55
+ * The server's rule (apps/api session.service.ts SESSION_NAME_RE: 1-60 chars, no ':') narrowed
56
+ * to what is also safe as a tmux target, a file name (`<name>.settings.json`) and one word of the
57
+ * `/baychat` prompt: letters, digits, `_` and `-`, not starting with `-`.
58
+ */
59
+ const LAUNCH_NAME_RE = /^[A-Za-z0-9_][A-Za-z0-9_-]*$/;
60
+ /** Why `name` cannot be launched, or undefined when it can. */
61
+ function launchNameProblem(name) {
62
+ if (name.length === 0 || name.length > SESSION_NAME_MAX)
63
+ return `A session name is 1-${SESSION_NAME_MAX} characters.`;
64
+ if (!LAUNCH_NAME_RE.test(name))
65
+ return "A session started from BayChat is named with letters, digits, '_' and '-' only.";
66
+ return undefined;
67
+ }
68
+ const ROOM_TITLE_MAX = 80;
69
+ /** C0/C1 controls (line breaks and tabs included), DEL, line/paragraph separators, bidi marks. */
70
+ const UNSAFE_TITLE_CHARS = new RegExp("[\\u0000-\\u001f\\u007f-\\u009f\\u2028\\u2029\\u200e\\u200f\\u202a-\\u202e\\u2066-\\u2069]", "u");
71
+ /**
72
+ * Why a group title cannot be put in the prompt, or undefined when it can. A line break would
73
+ * end the `/baychat` line and start a message of its own; a `"` would end the quoted title early.
74
+ */
75
+ function roomTitleProblem(title) {
76
+ if (title.trim().length === 0 || title.length > ROOM_TITLE_MAX)
77
+ return `A group title is 1-${ROOM_TITLE_MAX} characters.`;
78
+ if (UNSAFE_TITLE_CHARS.test(title))
79
+ return "A group title cannot contain line breaks or control characters.";
80
+ if (title.includes('"'))
81
+ return 'A group title started from BayChat cannot contain a double quote (").';
82
+ return undefined;
83
+ }