baychat 0.25.3 → 0.27.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.
@@ -0,0 +1,281 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ChannelFeed = exports.BACKOFF_MS = exports.CLOSE_STOPPED = void 0;
4
+ /** Close code the server sends when a feed was stopped or replaced on purpose. */
5
+ exports.CLOSE_STOPPED = 4000;
6
+ /** Seconds between reconnect attempts: quick at first, then no more than once a minute. */
7
+ exports.BACKOFF_MS = [1_000, 2_000, 5_000, 10_000, 30_000, 60_000];
8
+ const READY_TIMEOUT_MS = 10_000;
9
+ const WATCHDOG_MS = 2 * 60_000;
10
+ /** A timer that fires this much later than asked means the computer slept: reconnect at once. */
11
+ const SLEEP_GAP_MS = 60_000;
12
+ class ChannelFeed {
13
+ deps;
14
+ state = "connecting";
15
+ cursor;
16
+ socket;
17
+ /** Bumped for each socket, so a late callback from an old one cannot touch the new one. */
18
+ generation = 0;
19
+ attempt = 0;
20
+ retryTimer;
21
+ watchdogTimer;
22
+ lastTick = 0;
23
+ now;
24
+ setTimer;
25
+ clearTimer;
26
+ constructor(deps) {
27
+ this.deps = deps;
28
+ this.now = deps.now ?? Date.now;
29
+ this.setTimer = deps.setTimer ?? ((fn, ms) => {
30
+ const t = setTimeout(fn, ms);
31
+ // The stdio transport keeps the process alive; a timer must not on its own.
32
+ t.unref?.();
33
+ return t;
34
+ });
35
+ this.clearTimer = deps.clearTimer ?? ((h) => clearTimeout(h));
36
+ }
37
+ get currentState() {
38
+ return this.state;
39
+ }
40
+ get lastCursor() {
41
+ return this.cursor;
42
+ }
43
+ /**
44
+ * Open the feed and wait for its first `ready`.
45
+ *
46
+ * Resolves `{ ok: true }` once the server has seated the feed — the only moment "connected"
47
+ * is true — or with the reason it could not. A failed start leaves nothing running, so the
48
+ * session can fall back to a Monitor with nothing competing for its feed.
49
+ */
50
+ async start() {
51
+ const minted = await this.deps.mint(undefined);
52
+ if (!minted.ok) {
53
+ this.state = "stopped";
54
+ return { ok: false, detail: minted.detail };
55
+ }
56
+ const ready = await new Promise((resolve) => {
57
+ const timer = this.setTimer(() => {
58
+ this.teardown();
59
+ this.state = "stopped";
60
+ resolve({ ok: false, detail: "The BayChat feed did not answer within 10 seconds." });
61
+ }, this.deps.readyTimeoutMs ?? READY_TIMEOUT_MS);
62
+ this.open(minted.ticket, {
63
+ onReady: () => {
64
+ this.clearTimer(timer);
65
+ resolve({ ok: true });
66
+ },
67
+ onFailBeforeReady: (detail) => {
68
+ this.clearTimer(timer);
69
+ this.teardown();
70
+ this.state = "stopped";
71
+ resolve({ ok: false, detail });
72
+ },
73
+ });
74
+ });
75
+ if (ready.ok)
76
+ this.armWatchdog();
77
+ return ready;
78
+ }
79
+ /** Stop for good: no reconnect, no watchdog. Used on replace and on process exit. */
80
+ stop() {
81
+ this.state = "stopped";
82
+ this.teardown();
83
+ }
84
+ teardown() {
85
+ this.generation++;
86
+ if (this.retryTimer !== undefined)
87
+ this.clearTimer(this.retryTimer);
88
+ if (this.watchdogTimer !== undefined)
89
+ this.clearTimer(this.watchdogTimer);
90
+ this.retryTimer = undefined;
91
+ this.watchdogTimer = undefined;
92
+ const socket = this.socket;
93
+ this.socket = undefined;
94
+ try {
95
+ socket?.close(1000, "BayChat channel closed");
96
+ }
97
+ catch {
98
+ /* Already closed: nothing left to release. */
99
+ }
100
+ }
101
+ open(ticket, first) {
102
+ const generation = ++this.generation;
103
+ const current = () => generation === this.generation && this.state !== "stopped";
104
+ let ready = false;
105
+ this.state = "connecting";
106
+ try {
107
+ this.socket = this.deps.connect(ticket.url, ticket.protocols, {
108
+ onOpen: () => { },
109
+ onMessage: (data) => {
110
+ if (!current())
111
+ return;
112
+ const frame = parseFrame(data);
113
+ if (!frame)
114
+ return;
115
+ if (typeof frame.cursor === "string" && frame.cursor)
116
+ this.cursor = frame.cursor;
117
+ if (frame.type === "ready") {
118
+ ready = true;
119
+ this.state = "open";
120
+ this.attempt = 0;
121
+ first?.onReady();
122
+ return;
123
+ }
124
+ if (frame.type === "messages")
125
+ void this.pushMessages(frame);
126
+ else if (frame.type === "reset")
127
+ void this.pushReset();
128
+ },
129
+ onError: (detail) => this.log(`feed error: ${detail}`),
130
+ onClose: (code, reason) => {
131
+ if (!current())
132
+ return;
133
+ this.socket = undefined;
134
+ if (!ready && first) {
135
+ first.onFailBeforeReady(`The BayChat feed closed before it was ready (${code}${reason ? `: ${reason}` : ""}).`);
136
+ return;
137
+ }
138
+ if (code === exports.CLOSE_STOPPED) {
139
+ // Stopped or replaced on purpose — by stop_listening, end_session, or a newer
140
+ // listener for this session. Reconnecting would fight whichever replaced it.
141
+ this.log(`feed for ${this.deps.session} stopped by the server (${reason || code})`);
142
+ this.stop();
143
+ return;
144
+ }
145
+ this.scheduleReconnect(`closed ${code}${reason ? `: ${reason}` : ""}`);
146
+ },
147
+ });
148
+ }
149
+ catch (err) {
150
+ const detail = err instanceof Error ? err.message : String(err);
151
+ if (first)
152
+ first.onFailBeforeReady(detail);
153
+ else
154
+ this.scheduleReconnect(detail);
155
+ }
156
+ }
157
+ scheduleReconnect(why) {
158
+ if (this.state === "stopped")
159
+ return;
160
+ this.state = "retrying";
161
+ const delay = exports.BACKOFF_MS[Math.min(this.attempt, exports.BACKOFF_MS.length - 1)];
162
+ this.attempt++;
163
+ this.log(`feed for ${this.deps.session} ${why}; reconnecting in ${delay / 1000}s`);
164
+ if (this.retryTimer !== undefined)
165
+ this.clearTimer(this.retryTimer);
166
+ this.retryTimer = this.setTimer(() => {
167
+ this.retryTimer = undefined;
168
+ void this.reconnect();
169
+ }, delay);
170
+ }
171
+ async reconnect() {
172
+ if (this.state === "stopped")
173
+ return;
174
+ const minted = await this.deps.mint(this.cursor);
175
+ // `stop()` may have run while the mint was in flight.
176
+ if (this.stopped())
177
+ return;
178
+ if (minted.ok) {
179
+ this.open(minted.ticket);
180
+ return;
181
+ }
182
+ if (minted.fatal) {
183
+ this.stop();
184
+ await this.deps.push(`BayChat stopped listening for session "${this.deps.session}": ${minted.detail} ` +
185
+ `If the session expired, call join_session again with the same name and room, then call ` +
186
+ `the baychat_channel listen tool again. Until then this session does not hear the Bay.`, this.meta({ event: "stopped" }));
187
+ return;
188
+ }
189
+ this.scheduleReconnect(`could not reconnect (${minted.detail})`);
190
+ }
191
+ /**
192
+ * Every two minutes: ask the server whether it still sees this feed.
193
+ *
194
+ * A socket can die without a close — a laptop lid, a NAT that forgot us — and the server's
195
+ * pings do not reach this side's code to notice. The server's own answer is the check. A
196
+ * timer that fires far later than asked means the computer slept: reconnect at once rather
197
+ * than trust a socket from before the sleep.
198
+ */
199
+ armWatchdog() {
200
+ const every = this.deps.watchdogMs ?? WATCHDOG_MS;
201
+ this.lastTick = this.now();
202
+ this.watchdogTimer = this.setTimer(() => void this.watchdog(every), every);
203
+ }
204
+ async watchdog(every) {
205
+ this.watchdogTimer = undefined;
206
+ if (this.state === "stopped")
207
+ return;
208
+ const late = this.now() - this.lastTick - every > SLEEP_GAP_MS;
209
+ if (this.state === "open") {
210
+ const seen = late ? "disconnected" : await this.deps.status();
211
+ if (this.state === "open" && seen === "disconnected") {
212
+ this.log(`feed for ${this.deps.session} ${late ? "outlived a sleep" : "is not seen by the server"}; reopening`);
213
+ this.generation++;
214
+ const socket = this.socket;
215
+ this.socket = undefined;
216
+ try {
217
+ socket?.close(1000, "Reopening");
218
+ }
219
+ catch {
220
+ /* Already gone. */
221
+ }
222
+ this.attempt = 0;
223
+ this.scheduleReconnect("watchdog");
224
+ }
225
+ }
226
+ else if (late && this.state === "retrying" && this.retryTimer !== undefined) {
227
+ // Woken from sleep mid-backoff: try now instead of after the rest of a long wait.
228
+ this.clearTimer(this.retryTimer);
229
+ this.retryTimer = undefined;
230
+ this.attempt = 0;
231
+ void this.reconnect();
232
+ }
233
+ if (!this.stopped())
234
+ this.armWatchdog();
235
+ }
236
+ async pushMessages(frame) {
237
+ const items = (frame.messages ?? []).filter((m) => typeof m?.conversationId === "string" && typeof m?.messageId === "string");
238
+ if (!items.length)
239
+ return;
240
+ const conversations = [...new Set(items.map((m) => m.conversationId))];
241
+ const count = items.length;
242
+ await this.deps.push(`${count === 1 ? "A new BayChat message" : `${count} new BayChat messages`} for session "${this.deps.session}"` +
243
+ `${this.deps.bayName ? ` in the ${this.deps.bayName} Bay` : ""}. ` +
244
+ `Read ${conversations.length === 1 ? "conversation" : "conversations"} ${conversations.join(", ")} with get_messages ` +
245
+ `from your last watermark, obey shouldRespond, and answer in BayChat. This channel stays open: do not open a Monitor.`, this.meta({
246
+ event: "messages",
247
+ conversation_ids: conversations.join(","),
248
+ message_ids: items.map((m) => m.messageId).join(","),
249
+ }));
250
+ }
251
+ async pushReset() {
252
+ await this.deps.push(`BayChat reset the feed for session "${this.deps.session}". Catch up on your joined conversations with get_messages; ` +
253
+ `a reset never means there were no messages. The channel stays open.`, this.meta({ event: "reset" }));
254
+ }
255
+ meta(extra) {
256
+ return {
257
+ session: this.deps.session,
258
+ ...(this.deps.bayName ? { bay: this.deps.bayName } : {}),
259
+ ...extra,
260
+ };
261
+ }
262
+ /** Read through a method: an `await` in between can change it, which narrowing cannot see. */
263
+ stopped() {
264
+ return this.state === "stopped";
265
+ }
266
+ log(line) {
267
+ this.deps.log?.(line);
268
+ }
269
+ }
270
+ exports.ChannelFeed = ChannelFeed;
271
+ function parseFrame(data) {
272
+ try {
273
+ const parsed = JSON.parse(data);
274
+ if (parsed && typeof parsed === "object" && typeof parsed.type === "string")
275
+ return parsed;
276
+ }
277
+ catch {
278
+ /* Not JSON: the server never sends that, so it is not ours to act on. */
279
+ }
280
+ return undefined;
281
+ }
@@ -0,0 +1,111 @@
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.CHANNEL_LAUNCH_COMMAND = exports.CHANNEL_LAUNCH_ARGS = exports.CHANNEL_MIN_CLAUDE = void 0;
37
+ exports.parseClaudeVersion = parseClaudeVersion;
38
+ exports.claudeSupportsChannel = claudeSupportsChannel;
39
+ exports.claudeChannelAddArgs = claudeChannelAddArgs;
40
+ exports.registerChannelWithClaude = registerChannelWithClaude;
41
+ exports.claudeUserConfigPath = claudeUserConfigPath;
42
+ exports.channelRegistered = channelRegistered;
43
+ /**
44
+ * Getting the channel into Claude Code: registering the server, and starting sessions with it on.
45
+ *
46
+ * Two halves, both needed: Claude Code must know the server (`claude mcp add`, once per computer)
47
+ * AND each session must be started with the channel flag — being in the MCP config is not enough
48
+ * for a channel to deliver anything (docs: "a server also has to be named in --channels").
49
+ */
50
+ const fs = __importStar(require("node:fs"));
51
+ const os = __importStar(require("node:os"));
52
+ const path = __importStar(require("node:path"));
53
+ const detect_1 = require("./detect");
54
+ /**
55
+ * The first Claude Code this is switched on for. Channels exist from earlier releases, but
56
+ * 2.1.234 is where Claude Code started sending a channel only what it registered for the
57
+ * session — the behaviour this design is checked against. On an older one `baychat start`
58
+ * launches without the flag (an unknown flag would stop Claude Code from starting at all), and
59
+ * the session listens through a Monitor exactly as before.
60
+ */
61
+ exports.CHANNEL_MIN_CLAUDE = [2, 1, 234];
62
+ /** `2.1.284 (Claude Code)` → [2, 1, 284]; undefined for anything else. */
63
+ function parseClaudeVersion(text) {
64
+ const m = /(\d+)\.(\d+)\.(\d+)/.exec(text);
65
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : undefined;
66
+ }
67
+ function claudeSupportsChannel(version) {
68
+ if (!version)
69
+ return false;
70
+ for (let i = 0; i < 3; i++) {
71
+ if (version[i] !== exports.CHANNEL_MIN_CLAUDE[i])
72
+ return version[i] > exports.CHANNEL_MIN_CLAUDE[i];
73
+ }
74
+ return true;
75
+ }
76
+ /** The arguments `baychat start` adds to a Claude Code launch. */
77
+ exports.CHANNEL_LAUNCH_ARGS = [detect_1.DEV_CHANNELS_FLAG, detect_1.CHANNEL_FLAG_VALUE];
78
+ /** What to type to start Claude Code with the channel on. */
79
+ exports.CHANNEL_LAUNCH_COMMAND = `claude ${exports.CHANNEL_LAUNCH_ARGS.join(" ")}`;
80
+ /** `claude mcp add` argv for the channel server: this CLI by absolute path, run as `channel`. */
81
+ function claudeChannelAddArgs(launch) {
82
+ return ["mcp", "add", "--scope", "user", detect_1.CHANNEL_MCP_ENTRY, "--", launch.node, launch.entry, "channel"];
83
+ }
84
+ /**
85
+ * Register the channel server with Claude Code. Re-adds on "already exists", so a CLI that moved
86
+ * (a new Node, a reinstall elsewhere) points Claude Code at where it is now.
87
+ */
88
+ function registerChannelWithClaude(launch, run) {
89
+ const args = claudeChannelAddArgs(launch);
90
+ let res = run(args);
91
+ if (res.status !== 0 && /already exists/i.test(res.stderr)) {
92
+ const removed = run(["mcp", "remove", "--scope", "user", detect_1.CHANNEL_MCP_ENTRY]);
93
+ if (removed.status === 0)
94
+ res = run(args);
95
+ }
96
+ return res.status === 0;
97
+ }
98
+ /** Claude Code's user config, where `--scope user` servers live. */
99
+ function claudeUserConfigPath(home = os.homedir()) {
100
+ return path.join(home, ".claude.json");
101
+ }
102
+ /** Is the channel server in Claude Code's user config? Read-only; unreadable counts as no. */
103
+ function channelRegistered(file = claudeUserConfigPath(), read = (p) => fs.readFileSync(p, "utf8")) {
104
+ try {
105
+ const parsed = JSON.parse(read(file));
106
+ return !!parsed?.mcpServers && Object.hasOwn(parsed.mcpServers, detect_1.CHANNEL_MCP_ENTRY);
107
+ }
108
+ catch {
109
+ return false;
110
+ }
111
+ }
@@ -0,0 +1,275 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ChannelService = exports.nodeFeedConnect = exports.CHANNEL_INSTRUCTIONS = void 0;
4
+ exports.realLogin = realLogin;
5
+ exports.realCallTool = realCallTool;
6
+ exports.mintTicket = mintTicket;
7
+ exports.startChannelServer = startChannelServer;
8
+ /**
9
+ * `baychat channel` — the Claude Code channel server (docs/features/CLAUDE_CODE_CHANNEL.md).
10
+ *
11
+ * A local stdio MCP server that Claude Code starts beside the remote `baychat` connection. It
12
+ * declares `claude/channel`, so every `notifications/claude/channel` it sends is queued into the
13
+ * running session as a prompt — which is what wakes an idle session without a Monitor.
14
+ *
15
+ * It does ONE job: hold a session's incoming feed and say when something arrived. Reading,
16
+ * answering and everything else stay on the remote `baychat` tools, so the session's view of
17
+ * the Bay is the same whichever way it was woken.
18
+ *
19
+ * stdout is the JSON-RPC channel: diagnostics go to stderr only.
20
+ */
21
+ const index_js_1 = require("@modelcontextprotocol/sdk/server/index.js");
22
+ const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
23
+ const index_js_2 = require("@modelcontextprotocol/sdk/client/index.js");
24
+ const streamableHttp_js_1 = require("@modelcontextprotocol/sdk/client/streamableHttp.js");
25
+ const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
26
+ const bays_1 = require("../bays");
27
+ const config_1 = require("../config");
28
+ const ws_1 = require("../relay/ws");
29
+ const detect_1 = require("./detect");
30
+ const feed_1 = require("./feed");
31
+ /** Delivered to Claude as context when this server connects. */
32
+ exports.CHANNEL_INSTRUCTIONS = `This is BayChat's wake-up channel. After join_session, call this server's listen tool with the ` +
33
+ `confirmed session name (and bay, when the session joined a Bay other than the default). If it ` +
34
+ `answers active, BayChat messages arrive as <channel source="${detect_1.CHANNEL_MCP_ENTRY}" session="…" ` +
35
+ `event="messages" conversation_ids="…">: read those conversations with the remote BayChat ` +
36
+ `get_messages tool (the same Bay's tools the session joined with), obey shouldRespond and answer ` +
37
+ `in BayChat. Do NOT also open a Monitor or call listen_messages: that would replace this feed. ` +
38
+ `If listen answers inactive or failed, use listen_messages and a native Monitor as before. ` +
39
+ `event="reset" means catch up with get_messages; event="stopped" means the feed ended and says why.`;
40
+ const LISTEN_TOOL = {
41
+ name: "listen",
42
+ description: "Hear this BayChat session's incoming messages through the Claude Code channel instead of a Monitor. " +
43
+ "Call once, right after join_session, with the confirmed session name. Answers active (messages will " +
44
+ "arrive as <channel> events; open no Monitor), inactive (this Claude Code was not started with the " +
45
+ "channel; use listen_messages + Monitor) or failed (use listen_messages + Monitor).",
46
+ inputSchema: {
47
+ type: "object",
48
+ properties: {
49
+ session: { type: "string", description: "The session name join_session confirmed." },
50
+ bay: {
51
+ type: "string",
52
+ description: "The Bay the session joined, when it is not this computer's default Bay (its name or baychat-<bay> entry).",
53
+ },
54
+ },
55
+ required: ["session"],
56
+ },
57
+ };
58
+ /** The real login lookup: the default Bay, or a named one from `credentials.json`. */
59
+ function realLogin(bay) {
60
+ const device = (0, config_1.loadDeviceCredentials)();
61
+ if (!device)
62
+ throw new Error("This computer is not signed in to BayChat. Run `baychat login`.");
63
+ const bays = (0, config_1.loadBayLogins)();
64
+ if (bay === undefined || !bay.trim()) {
65
+ const def = bays.find((b) => b.isDefault);
66
+ return { baseUrl: device.baseUrl, token: device.token, expiresAt: device.expiresAt, ...(def ? { bayName: def.bayName } : {}) };
67
+ }
68
+ const found = (0, bays_1.findBayLogin)(bays, bay);
69
+ return { baseUrl: device.baseUrl, token: found.token, expiresAt: found.expiresAt, bayName: found.bayName };
70
+ }
71
+ /** One remote tool call over the BayChat HTTP MCP endpoint, then disconnect. */
72
+ async function realCallTool(login, name, args) {
73
+ const client = new index_js_2.Client({ name: "baychat-channel", version: "1" });
74
+ try {
75
+ await client.connect(new streamableHttp_js_1.StreamableHTTPClientTransport(new URL("/api/mcp", login.baseUrl), {
76
+ requestInit: {
77
+ headers: { Authorization: `Bearer ${login.token}` },
78
+ signal: AbortSignal.timeout(15_000),
79
+ },
80
+ }));
81
+ return (await client.callTool({ name, arguments: args }));
82
+ }
83
+ finally {
84
+ await client.close().catch(() => {
85
+ /* The call already finished or failed; a failed close has nothing left to release. */
86
+ });
87
+ }
88
+ }
89
+ /** Node's built-in WebSocket with subprotocols (the ticket rides in them, never the URL). */
90
+ const nodeFeedConnect = (url, protocols, handlers) => {
91
+ const ctor = globalThis.WebSocket;
92
+ if (typeof ctor !== "function") {
93
+ throw new ws_1.WebSocketUnavailableError("this Node has no built-in WebSocket (Node 22 or newer is needed for the channel)");
94
+ }
95
+ const ws = new ctor(url, protocols);
96
+ ws.onopen = () => handlers.onOpen();
97
+ ws.onmessage = (ev) => handlers.onMessage(typeof ev?.data === "string" ? ev.data : String(ev?.data ?? ""));
98
+ ws.onerror = (ev) => handlers.onError(typeof ev?.message === "string" && ev.message ? ev.message : "socket error");
99
+ ws.onclose = (ev) => handlers.onClose(typeof ev?.code === "number" ? ev.code : 1006, ev?.reason ?? "");
100
+ return { send: (data) => ws.send(data), close: (code, reason) => ws.close(code, reason) };
101
+ };
102
+ exports.nodeFeedConnect = nodeFeedConnect;
103
+ function resultText(result) {
104
+ return (result.content ?? [])
105
+ .filter((c) => c.type === "text" && typeof c.text === "string")
106
+ .map((c) => c.text)
107
+ .join("\n")
108
+ .trim();
109
+ }
110
+ /** HTTP status of a refused MCP request, when the SDK error carries one. */
111
+ function httpStatus(err) {
112
+ const code = err?.code;
113
+ return typeof code === "number" ? code : undefined;
114
+ }
115
+ /**
116
+ * Mint a listener ticket through `listen_messages`. Fatal only when retrying cannot help: the
117
+ * session is gone, or the login was refused or ran out. Everything else (offline, a restart,
118
+ * the per-minute limit) is retried by the feed.
119
+ */
120
+ async function mintTicket(deps, bay, session, cursor) {
121
+ let login;
122
+ try {
123
+ login = deps.login(bay);
124
+ }
125
+ catch (err) {
126
+ return { ok: false, fatal: true, detail: err instanceof Error ? err.message : String(err) };
127
+ }
128
+ const expires = Date.parse(login.expiresAt);
129
+ if (login.expiresAt && Number.isFinite(expires) && expires <= Date.now()) {
130
+ return { ok: false, fatal: true, detail: "This computer's BayChat login has expired. Run `baychat login`." };
131
+ }
132
+ try {
133
+ const result = await deps.callTool(login, "listen_messages", { session, ...(cursor ? { cursor } : {}) });
134
+ const text = resultText(result);
135
+ if (result.isError) {
136
+ const fatal = /no live session|SESSION_NOT_FOUND|not a member|cannot delegate/i.test(text);
137
+ return { ok: false, fatal, detail: text || "BayChat refused to open the feed." };
138
+ }
139
+ const structured = (result.structuredContent ?? safeJson(text));
140
+ const ws = structured?.monitor?.ws;
141
+ if (typeof ws?.url !== "string" || !Array.isArray(ws.protocols) || !ws.protocols.every((p) => typeof p === "string")) {
142
+ return { ok: false, fatal: false, detail: "BayChat's answer had no feed address." };
143
+ }
144
+ return { ok: true, ticket: { url: ws.url, protocols: ws.protocols } };
145
+ }
146
+ catch (err) {
147
+ const status = httpStatus(err);
148
+ if (status === 401 || status === 403) {
149
+ return { ok: false, fatal: true, detail: "BayChat refused this computer's login. Run `baychat login`." };
150
+ }
151
+ return { ok: false, fatal: false, detail: err instanceof Error ? err.message : String(err) };
152
+ }
153
+ }
154
+ function safeJson(text) {
155
+ try {
156
+ return JSON.parse(text);
157
+ }
158
+ catch {
159
+ return undefined;
160
+ }
161
+ }
162
+ function toolResult(status, text, extra = {}) {
163
+ return { content: [{ type: "text", text }], structuredContent: { status, ...extra } };
164
+ }
165
+ /** The tool-call side of the server, separate from stdio so the tests drive it directly. */
166
+ class ChannelService {
167
+ deps;
168
+ push;
169
+ feeds = new Map();
170
+ constructor(deps, push) {
171
+ this.deps = deps;
172
+ this.push = push;
173
+ }
174
+ async listen(args) {
175
+ const session = typeof args.session === "string" ? args.session.trim() : "";
176
+ if (!session)
177
+ return { content: [{ type: "text", text: "Pass session: the name join_session confirmed." }], isError: true };
178
+ const bay = typeof args.bay === "string" && args.bay.trim() ? args.bay.trim() : undefined;
179
+ const evidence = this.deps.detect();
180
+ if (!evidence.active) {
181
+ return toolResult("inactive", `Inactive: this Claude Code session was not started with the BayChat channel, so its events would be ` +
182
+ `dropped. Use listen_messages and a native Monitor for session "${session}" as usual. To use the channel ` +
183
+ `next time, start Claude Code with \`baychat start\`, or \`claude ${detect_1.DEV_CHANNELS_FLAG} ${detect_1.CHANNEL_FLAG_VALUE}\`.`, { session });
184
+ }
185
+ let bayName;
186
+ try {
187
+ bayName = this.deps.login(bay).bayName;
188
+ }
189
+ catch (err) {
190
+ return toolResult("failed", `Failed: ${err instanceof Error ? err.message : String(err)} Use listen_messages and a native Monitor instead.`, { session });
191
+ }
192
+ const key = `${bayName ?? ""}\u0000${session}`;
193
+ this.feeds.get(key)?.stop();
194
+ const feed = new feed_1.ChannelFeed({
195
+ session,
196
+ ...(bayName ? { bayName } : {}),
197
+ mint: (cursor) => mintTicket(this.deps, bay, session, cursor),
198
+ connect: this.deps.connect,
199
+ status: async () => {
200
+ try {
201
+ const result = await this.deps.callTool(this.deps.login(bay), "get_delivery_status", { session });
202
+ if (result.isError)
203
+ return "unknown";
204
+ const s = result.structuredContent?.status ?? safeJsonStatus(resultText(result));
205
+ return s === "connected" || s === "disconnected" ? s : "unknown";
206
+ }
207
+ catch {
208
+ return "unknown";
209
+ }
210
+ },
211
+ push: this.push,
212
+ log: this.deps.log,
213
+ ...this.deps.feedOverrides,
214
+ });
215
+ this.feeds.set(key, feed);
216
+ const started = await feed.start();
217
+ if (!started.ok) {
218
+ this.feeds.delete(key);
219
+ return toolResult("failed", `Failed: ${started.detail} Use listen_messages and a native Monitor instead.`, { session });
220
+ }
221
+ return toolResult("active", `Active: session "${session}"${bayName ? ` (${bayName})` : ""} now hears the Bay through the channel. New messages ` +
222
+ `arrive here as <channel source="${detect_1.CHANNEL_MCP_ENTRY}"> events, with no deadline and no re-arming. Do NOT open a ` +
223
+ `Monitor or call listen_messages — either would replace this feed. Now read one get_messages batch (limit 10) ` +
224
+ `to catch up on anything from before listening.`, { session, ...(bayName ? { bay: bayName } : {}), how: evidence.how });
225
+ }
226
+ stopAll() {
227
+ for (const feed of this.feeds.values())
228
+ feed.stop();
229
+ this.feeds.clear();
230
+ }
231
+ }
232
+ exports.ChannelService = ChannelService;
233
+ function safeJsonStatus(text) {
234
+ const parsed = safeJson(text);
235
+ return parsed?.status;
236
+ }
237
+ /** Boot on stdio and block on the transport. */
238
+ async function startChannelServer(deps) {
239
+ const log = deps?.log ?? ((line) => console.error(`[baychat channel] ${line}`));
240
+ const mcp = new index_js_1.Server({ name: detect_1.CHANNEL_MCP_ENTRY, version: "1" }, {
241
+ capabilities: { experimental: { "claude/channel": {} }, tools: {} },
242
+ instructions: exports.CHANNEL_INSTRUCTIONS,
243
+ });
244
+ const service = new ChannelService({
245
+ login: deps?.login ?? realLogin,
246
+ detect: deps?.detect ?? (() => (0, detect_1.detectChannel)()),
247
+ callTool: deps?.callTool ?? realCallTool,
248
+ connect: deps?.connect ?? exports.nodeFeedConnect,
249
+ log,
250
+ }, async (content, meta) => {
251
+ try {
252
+ await mcp.notification({ method: "notifications/claude/channel", params: { content, meta } });
253
+ }
254
+ catch (err) {
255
+ log(`could not hand an event to Claude Code: ${err instanceof Error ? err.message : String(err)}`);
256
+ }
257
+ });
258
+ mcp.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({ tools: [LISTEN_TOOL] }));
259
+ mcp.setRequestHandler(types_js_1.CallToolRequestSchema, async (req) => {
260
+ if (req.params.name === LISTEN_TOOL.name)
261
+ return service.listen((req.params.arguments ?? {}));
262
+ return { content: [{ type: "text", text: `Unknown tool: ${req.params.name}` }], isError: true };
263
+ });
264
+ // Claude Code closes our stdin when the session ends. An open feed socket would otherwise
265
+ // keep this process alive with nobody left to tell, holding the session "connected".
266
+ const shutdown = () => {
267
+ service.stopAll();
268
+ process.exit(0);
269
+ };
270
+ mcp.onclose = shutdown;
271
+ process.stdin.once("end", shutdown);
272
+ process.stdin.once("close", shutdown);
273
+ await mcp.connect(new stdio_js_1.StdioServerTransport());
274
+ log("ready on stdio");
275
+ }