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.
- package/.claude-plugin/plugin.json +32 -0
- package/README.md +42 -4
- package/dist/approve-hook.js +93 -3
- package/dist/channel/claude-link.js +145 -0
- package/dist/channel/detect.js +10 -2
- package/dist/channel/feed.js +27 -13
- package/dist/channel/launch.js +67 -12
- package/dist/channel/permission.js +118 -0
- package/dist/channel/plugin.js +182 -0
- package/dist/channel/relay-link.js +257 -0
- package/dist/channel/server.js +191 -10
- package/dist/claude-onboarding.js +7 -5
- package/dist/cli-help.js +235 -0
- package/dist/commands.js +4 -4
- package/dist/connect-acp.js +3 -2
- package/dist/connect-claude.js +31 -10
- package/dist/device-login.js +2 -2
- package/dist/doctor-command.js +2 -0
- package/dist/doctor.js +97 -3
- package/dist/help-topics.js +4 -3
- package/dist/launch/claude-trust.js +221 -0
- package/dist/launch/command.js +177 -0
- package/dist/launch/policy.js +152 -0
- package/dist/machine-name.js +76 -0
- package/dist/main.js +16 -170
- package/dist/multiplexer-launch.js +271 -0
- package/dist/protocol-content.js +1 -1
- package/dist/relay/acp/agents.js +42 -19
- package/dist/relay/acp/daemon-glue.js +3 -2
- package/dist/relay/acp/runner.js +8 -7
- package/dist/relay/channel-sessions.js +66 -0
- package/dist/relay/commands.js +9 -2
- package/dist/relay/daemon.js +160 -9
- package/dist/relay/launcher/approval-settings.js +140 -0
- package/dist/relay/launcher/argv.js +83 -0
- package/dist/relay/launcher/capabilities.js +76 -0
- package/dist/relay/launcher/dev-channel-screen.fixtures.js +41 -0
- package/dist/relay/launcher/dev-channel-screen.js +47 -0
- package/dist/relay/launcher/handle.js +303 -0
- package/dist/relay/launcher/loop.js +231 -0
- package/dist/relay/launcher/multiplexer.js +168 -0
- package/dist/relay/launcher/started.js +63 -0
- package/dist/relay/profiles.js +1 -1
- package/dist/relay/registry.js +26 -0
- package/dist/relay/terminal-pane.js +31 -2
- package/dist/relay/transport.js +7 -2
- package/dist/relay/ws.js +18 -1
- package/dist/runtime-install.js +19 -3
- package/dist/runtimes.js +3 -0
- package/dist/skill-bootstrap.js +1 -1
- package/dist/start-command.js +12 -56
- package/dist/tool-defs.js +23 -36
- 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
|
+
}
|
package/dist/relay/commands.js
CHANGED
|
@@ -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
|
-
|
|
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
|
package/dist/relay/daemon.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
484
|
-
|
|
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
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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
|
|
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
|
+
}
|