baychat 0.27.0 → 0.28.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,12 @@
1
+ {
2
+ "name": "baychat",
3
+ "displayName": "BayChat",
4
+ "description": "Wakes this Claude Code session the moment a BayChat message arrives: no Monitor, no 30-minute deadline. Uses the baychat CLI already on this computer (npm install -g baychat, then baychat login).",
5
+ "author": { "name": "BayChat", "url": "https://baychat.io" },
6
+ "homepage": "https://baychat.io",
7
+ "keywords": ["baychat", "chat", "channel", "mobile", "notifications"],
8
+ "mcpServers": {
9
+ "channel": { "command": "baychat", "args": ["channel"] }
10
+ },
11
+ "channels": [{ "server": "channel", "displayName": "BayChat" }]
12
+ }
package/README.md CHANGED
@@ -112,8 +112,8 @@ the old shell/relay join is available only as an explicitly chosen fallback.
112
112
  Updating npm alone does not refresh a skill already loaded in a running session.
113
113
 
114
114
  **No Monitor deadline with the channel.** `baychat connect claude` also registers
115
- `baychat_channel`, a local Claude Code channel. Start Claude Code with
116
- `baychat start` (or `claude --dangerously-load-development-channels server:baychat_channel`)
115
+ a local Claude Code channel (as the `baychat` plugin, from https://baychat.io/claude/marketplace.json). Start Claude Code with
116
+ `baychat claude` (or `baychat start`, which uses tmux when installed)
117
117
  and the skill calls its `listen` tool instead of opening a Monitor: the channel holds the
118
118
  feed, reconnects by itself, and wakes the session only when a message arrives. Started
119
119
  without the flag, `listen` answers `inactive` and the Monitor path above is used.
@@ -33,7 +33,8 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.DEV_CHANNELS_FLAG = exports.CHANNEL_FLAG_VALUE = exports.CHANNEL_MCP_ENTRY = void 0;
36
+ exports.DEV_CHANNELS_FLAG = exports.PLUGIN_FLAG_VALUE = exports.PLUGIN_ID = exports.CHANNEL_FLAG_VALUE = exports.CHANNEL_MCP_ENTRY = void 0;
37
+ exports.isOurChannelValue = isOurChannelValue;
37
38
  exports.argsEnableChannel = argsEnableChannel;
38
39
  exports.realProcessReader = realProcessReader;
39
40
  exports.detectChannel = detectChannel;
@@ -47,12 +48,19 @@ exports.detectChannel = detectChannel;
47
48
  * replaced. So `listen` answers active only on evidence, and otherwise sends the session back
48
49
  * to `listen_messages` + Monitor.
49
50
  *
50
- * The evidence is the command line Claude Code itself was started with: an ancestor process
51
- * whose arguments carry `--dangerously-load-development-channels` or `--channels` naming
52
- * `server:baychat_channel`. Claude Code spawns this server directly, so it is the parent; a few
53
- * more levels are walked for launchers that wrap it. Where process arguments cannot be read
54
- * (Windows), `BAYCHAT_CHANNEL=1` — set by `baychat start` on the process it launches with the
55
- * flag — is accepted instead.
51
+ * Two kinds of evidence, either enough:
52
+ *
53
+ * 1. `BAYCHAT_CHANNEL=1` in this process's environment. `baychat claude` and `baychat start` set
54
+ * it on the Claude Code they launch WITH the flag, and Claude Code passes its environment to
55
+ * the servers it spawns (checked on 2.1.284). Set per launch, never exported, so a plain
56
+ * `claude` does not inherit it.
57
+ * 2. The flag on an ancestor's command line: `--dangerously-load-development-channels` or
58
+ * `--channels` naming `server:baychat_channel`, for people who type the flag themselves.
59
+ * NOT reliable on its own: Claude Code sets `process.title = "claude"` at start, and where it
60
+ * runs on Node (an npm install, e.g. under nvm on WSL) that overwrites /proc/<pid>/cmdline,
61
+ * so the flags vanish. Found 2026-10-02 on the owner's WSL: launched with the flag, `ps`
62
+ * showed only `claude`, and `listen` wrongly answered inactive. A wrong "inactive" is safe —
63
+ * the session falls back to a Monitor — but it is why (1) exists.
56
64
  */
57
65
  const node_child_process_1 = require("node:child_process");
58
66
  const fs = __importStar(require("node:fs"));
@@ -60,6 +68,13 @@ const fs = __importStar(require("node:fs"));
60
68
  exports.CHANNEL_MCP_ENTRY = "baychat_channel";
61
69
  /** What `--dangerously-load-development-channels` takes for a bare MCP server. */
62
70
  exports.CHANNEL_FLAG_VALUE = `server:${exports.CHANNEL_MCP_ENTRY}`;
71
+ /** The plugin's id and its channel entry, when BayChat is installed as a Claude Code plugin. */
72
+ exports.PLUGIN_ID = "baychat@baychat";
73
+ exports.PLUGIN_FLAG_VALUE = `plugin:${exports.PLUGIN_ID}`;
74
+ /** Does one channel-flag value name BayChat's channel, as a server or as the plugin (any marketplace)? */
75
+ function isOurChannelValue(value) {
76
+ return value === exports.CHANNEL_FLAG_VALUE || /^plugin:baychat@[A-Za-z0-9._-]+$/.test(value);
77
+ }
63
78
  exports.DEV_CHANNELS_FLAG = "--dangerously-load-development-channels";
64
79
  const CHANNEL_FLAGS = [exports.DEV_CHANNELS_FLAG, "--channels"];
65
80
  /** How far up the process tree to look. */
@@ -81,7 +96,7 @@ function argsEnableChannel(args) {
81
96
  for (let j = i + 1; j < args.length && !args[j].startsWith("-"); j++)
82
97
  values.push(...args[j].split(/[\s,]+/));
83
98
  }
84
- if (values.includes(exports.CHANNEL_FLAG_VALUE))
99
+ if (values.some(isOurChannelValue))
85
100
  return true;
86
101
  }
87
102
  return false;
@@ -121,25 +136,18 @@ function realProcessReader(platform = process.platform) {
121
136
  }
122
137
  /** Look for the flag on this process's ancestors, then fall back to the env marker. */
123
138
  function detectChannel(opts = {}) {
124
- const read = opts.read ?? realProcessReader();
125
139
  const env = opts.env ?? process.env;
140
+ if (env.BAYCHAT_CHANNEL === "1")
141
+ return { active: true, how: "env" };
142
+ const read = opts.read ?? realProcessReader();
126
143
  let pid = opts.pid ?? process.ppid;
127
- let readable = false;
128
144
  for (let depth = 0; depth < MAX_DEPTH && pid > 1; depth++) {
129
145
  const info = read(pid);
130
146
  if (!info)
131
147
  break;
132
- if (info.args) {
133
- readable = true;
134
- if (argsEnableChannel(info.args))
135
- return { active: true, how: "process" };
136
- }
148
+ if (info.args && argsEnableChannel(info.args))
149
+ return { active: true, how: "process" };
137
150
  pid = info.ppid;
138
151
  }
139
- // Only where nothing could be read: a readable tree without the flag is a real "no", and an
140
- // inherited marker must not overrule it (a plain `claude` started inside a `baychat start`
141
- // terminal would carry the marker without the flag).
142
- if (!readable && env.BAYCHAT_CHANNEL === "1")
143
- return { active: true, how: "env" };
144
152
  return { active: false, how: "none" };
145
153
  }
@@ -183,7 +183,7 @@ class ChannelFeed {
183
183
  this.stop();
184
184
  await this.deps.push(`BayChat stopped listening for session "${this.deps.session}": ${minted.detail} ` +
185
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" }));
186
+ `this BayChat channel's listen tool again. Until then this session does not hear the Bay.`, this.meta({ event: "stopped" }));
187
187
  return;
188
188
  }
189
189
  this.scheduleReconnect(`could not reconnect (${minted.detail})`);
@@ -33,13 +33,18 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.CHANNEL_LAUNCH_COMMAND = exports.CHANNEL_LAUNCH_ARGS = exports.CHANNEL_MIN_CLAUDE = void 0;
36
+ exports.CHANNEL_ENV = exports.CHANNEL_LAUNCH_COMMAND = exports.PLUGIN_MARKETPLACE_URL = exports.CHANNEL_LAUNCH_ARGS = exports.CHANNEL_MIN_CLAUDE = void 0;
37
37
  exports.parseClaudeVersion = parseClaudeVersion;
38
38
  exports.claudeSupportsChannel = claudeSupportsChannel;
39
+ exports.channelLaunchFor = channelLaunchFor;
40
+ exports.cmdClaude = cmdClaude;
39
41
  exports.claudeChannelAddArgs = claudeChannelAddArgs;
40
42
  exports.registerChannelWithClaude = registerChannelWithClaude;
41
43
  exports.claudeUserConfigPath = claudeUserConfigPath;
42
44
  exports.channelRegistered = channelRegistered;
45
+ exports.claudeUserSettingsPath = claudeUserSettingsPath;
46
+ exports.pluginEnabled = pluginEnabled;
47
+ exports.installPluginWithClaude = installPluginWithClaude;
43
48
  /**
44
49
  * Getting the channel into Claude Code: registering the server, and starting sessions with it on.
45
50
  *
@@ -47,9 +52,11 @@ exports.channelRegistered = channelRegistered;
47
52
  * AND each session must be started with the channel flag — being in the MCP config is not enough
48
53
  * for a channel to deliver anything (docs: "a server also has to be named in --channels").
49
54
  */
55
+ const node_child_process_1 = require("node:child_process");
50
56
  const fs = __importStar(require("node:fs"));
51
57
  const os = __importStar(require("node:os"));
52
58
  const path = __importStar(require("node:path"));
59
+ const runtime_binary_1 = require("../runtime-binary");
53
60
  const detect_1 = require("./detect");
54
61
  /**
55
62
  * The first Claude Code this is switched on for. Channels exist from earlier releases, but
@@ -73,10 +80,62 @@ function claudeSupportsChannel(version) {
73
80
  }
74
81
  return true;
75
82
  }
76
- /** The arguments `baychat start` adds to a Claude Code launch. */
83
+ /** The arguments that switch the hand-registered channel server on. */
77
84
  exports.CHANNEL_LAUNCH_ARGS = [detect_1.DEV_CHANNELS_FLAG, detect_1.CHANNEL_FLAG_VALUE];
85
+ /** Where `claude plugin marketplace add` finds BayChat's plugin catalog (served by the web app). */
86
+ exports.PLUGIN_MARKETPLACE_URL = "https://baychat.io/claude/marketplace.json";
78
87
  /** What to type to start Claude Code with the channel on. */
79
- exports.CHANNEL_LAUNCH_COMMAND = `claude ${exports.CHANNEL_LAUNCH_ARGS.join(" ")}`;
88
+ exports.CHANNEL_LAUNCH_COMMAND = "baychat claude";
89
+ /**
90
+ * The marker the channel trusts (detect.ts). Set on the ONE process launched, never exported:
91
+ * Claude Code can hide its own flags, so the launcher vouches for them instead.
92
+ */
93
+ exports.CHANNEL_ENV = { BAYCHAT_CHANNEL: "1" };
94
+ /**
95
+ * Decide for one Claude Code binary. Plain (and the session uses a Monitor) when the channel
96
+ * server is not registered or this Claude Code is too old for the flag — an unknown flag would
97
+ * stop it from starting at all.
98
+ */
99
+ function channelLaunchFor(claudePath, deps = {}) {
100
+ // Every way BayChat's channel is installed here, each named on the flag. Both can exist after a
101
+ // move to the plugin; Claude Code then keeps the hand-registered server and hides the plugin's
102
+ // duplicate, so naming both is what keeps the channel switched on either way.
103
+ const entries = [
104
+ ...((deps.plugin ?? (() => pluginEnabled()))() ? [detect_1.PLUGIN_FLAG_VALUE] : []),
105
+ ...((deps.registered ?? (() => channelRegistered()))() ? [detect_1.CHANNEL_FLAG_VALUE] : []),
106
+ ];
107
+ if (!entries.length)
108
+ return { on: false, args: [], env: {}, why: "the BayChat channel is not set up here (run `baychat connect claude`)" };
109
+ const text = (deps.version ?? probeVersion)(claudePath);
110
+ if (!claudeSupportsChannel(text ? parseClaudeVersion(text) : undefined)) {
111
+ return { on: false, args: [], env: {}, why: `Claude Code ${exports.CHANNEL_MIN_CLAUDE.join(".")} or newer is needed for the channel (run \`claude update\`)` };
112
+ }
113
+ return { on: true, args: [detect_1.DEV_CHANNELS_FLAG, ...entries], env: { ...exports.CHANNEL_ENV } };
114
+ }
115
+ function probeVersion(claudePath) {
116
+ const probe = (0, node_child_process_1.spawnSync)(claudePath, ["--version"], { encoding: "utf8", timeout: 10_000 });
117
+ return probe.error || probe.status !== 0 ? undefined : String(probe.stdout ?? "");
118
+ }
119
+ /**
120
+ * `baychat claude [args…]` — Claude Code with the BayChat channel switched on, in this terminal.
121
+ * No multiplexer (that is `baychat start`), nothing else changed: every argument is passed on.
122
+ */
123
+ function cmdClaude(args) {
124
+ const binary = (0, runtime_binary_1.resolveRuntimeBinary)("claude", (0, runtime_binary_1.currentBinaryEnv)());
125
+ if (!binary.ok) {
126
+ console.error(`Could not start Claude Code: ${binary.reason}`);
127
+ return 1;
128
+ }
129
+ const launch = channelLaunchFor(binary.path);
130
+ if (!launch.on)
131
+ console.error(`Starting Claude Code without the BayChat channel: ${launch.why}. Messages will use the Monitor.`);
132
+ const result = (0, node_child_process_1.spawnSync)(binary.path, [...launch.args, ...args], { stdio: "inherit", env: { ...process.env, ...launch.env } });
133
+ if (result.error) {
134
+ console.error(`Could not start Claude Code: ${result.error.message}`);
135
+ return 1;
136
+ }
137
+ return result.status ?? 0;
138
+ }
80
139
  /** `claude mcp add` argv for the channel server: this CLI by absolute path, run as `channel`. */
81
140
  function claudeChannelAddArgs(launch) {
82
141
  return ["mcp", "add", "--scope", "user", detect_1.CHANNEL_MCP_ENTRY, "--", launch.node, launch.entry, "channel"];
@@ -109,3 +168,33 @@ function channelRegistered(file = claudeUserConfigPath(), read = (p) => fs.readF
109
168
  return false;
110
169
  }
111
170
  }
171
+ /** Claude Code's user settings, where installed plugins are switched on. */
172
+ function claudeUserSettingsPath(home = os.homedir()) {
173
+ return path.join(home, ".claude", "settings.json");
174
+ }
175
+ /** Is BayChat's plugin installed and switched on for this user? Unreadable counts as no. */
176
+ function pluginEnabled(file = claudeUserSettingsPath(), read = (p) => fs.readFileSync(p, "utf8")) {
177
+ try {
178
+ const parsed = JSON.parse(read(file));
179
+ return parsed?.enabledPlugins?.[detect_1.PLUGIN_ID] === true;
180
+ }
181
+ catch {
182
+ return false;
183
+ }
184
+ }
185
+ /**
186
+ * Install BayChat as a Claude Code plugin: add the catalog from baychat.io, install from it (the
187
+ * plugin itself is fetched from the `baychat` npm package), then remove the hand-registered
188
+ * server, which would otherwise hide the plugin's. Returns false at the first step that fails,
189
+ * leaving whatever worked before untouched — the caller then registers the server by hand.
190
+ */
191
+ function installPluginWithClaude(run) {
192
+ const added = run(["plugin", "marketplace", "add", exports.PLUGIN_MARKETPLACE_URL]);
193
+ if (added.status !== 0 && !/already/i.test(added.stderr))
194
+ return false;
195
+ const installed = run(["plugin", "install", detect_1.PLUGIN_ID]);
196
+ if (installed.status !== 0 && !/already installed/i.test(installed.stderr))
197
+ return false;
198
+ run(["mcp", "remove", "--scope", "user", detect_1.CHANNEL_MCP_ENTRY]); // Absent is fine.
199
+ return true;
200
+ }
@@ -0,0 +1,118 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PermissionRelay = exports.RELAY_OPTIONS = void 0;
4
+ exports.composeRelayQuestion = composeRelayQuestion;
5
+ /** Button labels, and the ONLY index that means yes. Same as `baychat approve-hook`. */
6
+ exports.RELAY_OPTIONS = ["Allow this once", "Deny"];
7
+ const ALLOW_INDEX = 0;
8
+ /** One window of the server's wait; it answers "waiting" when nobody tapped yet. */
9
+ const POLL_WAIT_SECONDS = 25;
10
+ const MAX_CONSECUTIVE_FAILURES = 5;
11
+ const RETRY_DELAY_MS = 3_000;
12
+ const QUESTION_MAX = 2_000;
13
+ const PREVIEW_MAX = 1_200;
14
+ function clip(text, max) {
15
+ return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
16
+ }
17
+ /** What the person on the phone reads. They decide from this text alone. */
18
+ function composeRelayQuestion(req, host, session) {
19
+ const lines = [
20
+ `Claude Code (session "${session}" on ${host}) asks to use ${req.tool_name}: ${req.description}`,
21
+ "",
22
+ req.input_preview ? clip(req.input_preview, PREVIEW_MAX) : "",
23
+ "",
24
+ "The same question is open in the terminal; whichever answer comes first counts. " +
25
+ "Allowing runs it on that machine, now. If you did not expect this, deny.",
26
+ ];
27
+ return clip(lines.join("\n").replace(/\n{3,}/g, "\n\n").trim(), QUESTION_MAX);
28
+ }
29
+ class PermissionRelay {
30
+ deps;
31
+ current;
32
+ constructor(deps) {
33
+ this.deps = deps;
34
+ }
35
+ /** Relay one dialog. Resolves when it is settled, superseded, or given up on. Never throws. */
36
+ async onRequest(req) {
37
+ await this.retire();
38
+ const target = this.deps.target();
39
+ if (!target)
40
+ return; // Nobody is listening through this channel: the terminal alone decides.
41
+ const pending = { claudeId: req.request_id, login: target.login, retired: false };
42
+ this.current = pending;
43
+ try {
44
+ let reply = await this.deps.api(target.login, "POST", "/api/device-api/approvals", {
45
+ session: target.session,
46
+ question: composeRelayQuestion(req, this.deps.host(), target.session),
47
+ options: [...exports.RELAY_OPTIONS],
48
+ // The private chat with the owner: a session is usually in a group too, and a prompt about
49
+ // one person's machine belongs to that person, not to the room.
50
+ direct: true,
51
+ wait: 1,
52
+ });
53
+ pending.cardId = typeof reply.requestId === "string" && reply.requestId ? reply.requestId : undefined;
54
+ if (!pending.cardId) {
55
+ this.deps.log("permission relay: BayChat returned no card id");
56
+ return;
57
+ }
58
+ let failures = 0;
59
+ for (;;) {
60
+ if (pending.retired) {
61
+ await this.withdraw(pending);
62
+ return;
63
+ }
64
+ if (reply.status === "answered") {
65
+ await this.deps.verdict(req.request_id, reply.answerIndex === ALLOW_INDEX ? "allow" : "deny");
66
+ return;
67
+ }
68
+ if (reply.status !== "waiting")
69
+ return; // cancelled, or a status we do not know: no verdict.
70
+ try {
71
+ reply = await this.deps.api(pending.login, "GET", `/api/device-api/approvals/${encodeURIComponent(pending.cardId)}?wait=${POLL_WAIT_SECONDS}`);
72
+ failures = 0;
73
+ }
74
+ catch (err) {
75
+ failures++;
76
+ this.deps.log(`permission relay: waiting failed (${err instanceof Error ? err.message : String(err)}), attempt ${failures}`);
77
+ if (failures >= MAX_CONSECUTIVE_FAILURES) {
78
+ // Give the decision back to the terminal alone, and take the card off the phone.
79
+ await this.withdraw(pending);
80
+ return;
81
+ }
82
+ await (this.deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))))(RETRY_DELAY_MS);
83
+ }
84
+ }
85
+ }
86
+ catch (err) {
87
+ this.deps.log(`permission relay: could not ask BayChat (${err instanceof Error ? err.message : String(err)}); the terminal still decides`);
88
+ }
89
+ finally {
90
+ if (this.current === pending)
91
+ this.current = undefined;
92
+ }
93
+ }
94
+ /** The current dialog is over (a newer one opened, or shutdown): stop and withdraw its card. */
95
+ async retire() {
96
+ const pending = this.current;
97
+ if (!pending)
98
+ return;
99
+ pending.retired = true;
100
+ this.current = undefined;
101
+ await this.withdraw(pending);
102
+ }
103
+ async withdraw(pending) {
104
+ if (!pending.cardId)
105
+ return; // Not created yet: the create path sees `retired` and withdraws.
106
+ const cardId = pending.cardId;
107
+ pending.cardId = undefined;
108
+ try {
109
+ await this.deps.api(pending.login, "POST", `/api/device-api/approvals/${encodeURIComponent(cardId)}/withdraw`, {});
110
+ }
111
+ catch (err) {
112
+ // An older server (no route) or a blip: the card stays up, and a tap on it is dropped by
113
+ // Claude Code as an unknown id. Untidy, never unsafe.
114
+ this.deps.log(`permission relay: could not withdraw card ${cardId} (${err instanceof Error ? err.message : String(err)})`);
115
+ }
116
+ }
117
+ }
118
+ exports.PermissionRelay = PermissionRelay;
@@ -1,4 +1,37 @@
1
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
+ })();
2
35
  Object.defineProperty(exports, "__esModule", { value: true });
3
36
  exports.ChannelService = exports.nodeFeedConnect = exports.CHANNEL_INSTRUCTIONS = void 0;
4
37
  exports.realLogin = realLogin;
@@ -23,20 +56,26 @@ const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
23
56
  const index_js_2 = require("@modelcontextprotocol/sdk/client/index.js");
24
57
  const streamableHttp_js_1 = require("@modelcontextprotocol/sdk/client/streamableHttp.js");
25
58
  const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
59
+ const os = __importStar(require("node:os"));
60
+ const zod_1 = require("zod");
61
+ const api_1 = require("../api");
26
62
  const bays_1 = require("../bays");
27
63
  const config_1 = require("../config");
28
64
  const ws_1 = require("../relay/ws");
29
65
  const detect_1 = require("./detect");
30
66
  const feed_1 = require("./feed");
67
+ const permission_1 = require("./permission");
31
68
  /** Delivered to Claude as context when this server connects. */
32
69
  exports.CHANNEL_INSTRUCTIONS = `This is BayChat's wake-up channel. After join_session, call this server's listen tool with the ` +
33
70
  `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="…" ` +
71
+ `answers active, BayChat messages arrive as <channel source="…" session="…" ` +
35
72
  `event="messages" conversation_ids="…">: read those conversations with the remote BayChat ` +
36
73
  `get_messages tool (the same Bay's tools the session joined with), obey shouldRespond and answer ` +
37
74
  `in BayChat. Do NOT also open a Monitor or call listen_messages: that would replace this feed. ` +
38
75
  `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.`;
76
+ `event="reset" means catch up with get_messages; event="stopped" means the feed ended and says why. ` +
77
+ `Tool-permission prompts are also offered to the session's owner as a BayChat card; answer in the terminal or ` +
78
+ `on the phone, whichever is first.`;
40
79
  const LISTEN_TOOL = {
41
80
  name: "listen",
42
81
  description: "Hear this BayChat session's incoming messages through the Claude Code channel instead of a Monitor. " +
@@ -167,6 +206,8 @@ class ChannelService {
167
206
  deps;
168
207
  push;
169
208
  feeds = new Map();
209
+ /** The most recent session that listened successfully: where permission prompts go. */
210
+ last;
170
211
  constructor(deps, push) {
171
212
  this.deps = deps;
172
213
  this.push = push;
@@ -180,7 +221,8 @@ class ChannelService {
180
221
  if (!evidence.active) {
181
222
  return toolResult("inactive", `Inactive: this Claude Code session was not started with the BayChat channel, so its events would be ` +
182
223
  `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 });
224
+ `next time, start Claude Code with \`baychat claude\` (or \`baychat start\`) — the plain \`claude ${detect_1.DEV_CHANNELS_FLAG} ${detect_1.CHANNEL_FLAG_VALUE}\` ` +
225
+ `works too, except where Claude Code hides its own flags (an npm/Node install).`, { session });
184
226
  }
185
227
  let bayName;
186
228
  try {
@@ -216,17 +258,40 @@ class ChannelService {
216
258
  const started = await feed.start();
217
259
  if (!started.ok) {
218
260
  this.feeds.delete(key);
261
+ if (this.last?.key === key)
262
+ this.last = undefined;
219
263
  return toolResult("failed", `Failed: ${started.detail} Use listen_messages and a native Monitor instead.`, { session });
220
264
  }
265
+ this.last = { key, session, bay };
221
266
  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 ` +
267
+ `arrive here as <channel event="messages"> events, with no deadline and no re-arming. Do NOT open a ` +
223
268
  `Monitor or call listen_messages — either would replace this feed. Now read one get_messages batch (limit 10) ` +
224
269
  `to catch up on anything from before listening.`, { session, ...(bayName ? { bay: bayName } : {}), how: evidence.how });
225
270
  }
271
+ /**
272
+ * Where a permission prompt goes: the session that most recently started listening, while its
273
+ * feed is still up. None → the prompt stays in the terminal only.
274
+ */
275
+ permissionTarget() {
276
+ const last = this.last;
277
+ if (!last)
278
+ return undefined;
279
+ const state = this.feeds.get(last.key)?.currentState;
280
+ if (!state || state === "stopped")
281
+ return undefined;
282
+ try {
283
+ const login = this.deps.login(last.bay);
284
+ return { session: last.session, login: { baseUrl: login.baseUrl, token: login.token } };
285
+ }
286
+ catch {
287
+ return undefined;
288
+ }
289
+ }
226
290
  stopAll() {
227
291
  for (const feed of this.feeds.values())
228
292
  feed.stop();
229
293
  this.feeds.clear();
294
+ this.last = undefined;
230
295
  }
231
296
  }
232
297
  exports.ChannelService = ChannelService;
@@ -234,11 +299,24 @@ function safeJsonStatus(text) {
234
299
  const parsed = safeJson(text);
235
300
  return parsed?.status;
236
301
  }
302
+ /** What Claude Code sends when a tool-approval dialog opens (channels reference). */
303
+ const PermissionRequestSchema = zod_1.z.object({
304
+ method: zod_1.z.literal("notifications/claude/channel/permission_request"),
305
+ params: zod_1.z.object({
306
+ request_id: zod_1.z.string(),
307
+ tool_name: zod_1.z.string(),
308
+ description: zod_1.z.string(),
309
+ input_preview: zod_1.z.string(),
310
+ }),
311
+ });
237
312
  /** Boot on stdio and block on the transport. */
238
313
  async function startChannelServer(deps) {
239
314
  const log = deps?.log ?? ((line) => console.error(`[baychat channel] ${line}`));
240
315
  const mcp = new index_js_1.Server({ name: detect_1.CHANNEL_MCP_ENTRY, version: "1" }, {
241
- capabilities: { experimental: { "claude/channel": {} }, tools: {} },
316
+ // `claude/channel/permission`: Claude Code also offers this channel its tool-approval
317
+ // dialogs (permission.ts). Safe to declare because only the session's owner can answer the
318
+ // card it becomes — the docs' condition for declaring it.
319
+ capabilities: { experimental: { "claude/channel": {}, "claude/channel/permission": {} }, tools: {} },
242
320
  instructions: exports.CHANNEL_INSTRUCTIONS,
243
321
  });
244
322
  const service = new ChannelService({
@@ -255,6 +333,23 @@ async function startChannelServer(deps) {
255
333
  log(`could not hand an event to Claude Code: ${err instanceof Error ? err.message : String(err)}`);
256
334
  }
257
335
  });
336
+ const relay = new permission_1.PermissionRelay({
337
+ target: () => service.permissionTarget(),
338
+ api: (login, method, path, body) => (0, api_1.apiRequest)(login, method, path, body),
339
+ verdict: async (requestId, behavior) => {
340
+ try {
341
+ await mcp.notification({ method: "notifications/claude/channel/permission", params: { request_id: requestId, behavior } });
342
+ }
343
+ catch (err) {
344
+ log(`could not hand a verdict to Claude Code: ${err instanceof Error ? err.message : String(err)}`);
345
+ }
346
+ },
347
+ host: () => os.hostname(),
348
+ log,
349
+ });
350
+ mcp.setNotificationHandler(PermissionRequestSchema, async ({ params }) => {
351
+ void relay.onRequest(params);
352
+ });
258
353
  mcp.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({ tools: [LISTEN_TOOL] }));
259
354
  mcp.setRequestHandler(types_js_1.CallToolRequestSchema, async (req) => {
260
355
  if (req.params.name === LISTEN_TOOL.name)
@@ -265,7 +360,9 @@ async function startChannelServer(deps) {
265
360
  // keep this process alive with nobody left to tell, holding the session "connected".
266
361
  const shutdown = () => {
267
362
  service.stopAll();
268
- process.exit(0);
363
+ // Take any open approval card off the phone before going: its dialog dies with this session.
364
+ // Bounded, so a dead network cannot hold the exit.
365
+ void Promise.race([relay.retire(), new Promise((r) => setTimeout(r, 2_000))]).finally(() => process.exit(0));
269
366
  };
270
367
  mcp.onclose = shutdown;
271
368
  process.stdin.once("end", shutdown);
@@ -5,7 +5,8 @@ exports.BAY_CHOICE = exports.CLAUDE_REACHABILITY = exports.CLAUDE_JOIN_STEPS = v
5
5
  exports.CLAUDE_JOIN_STEPS = `1. Use **remote BayChat MCP + native WebSocket Monitor**. Search deferred tools once
6
6
  if needed, for \`join_session\`, \`listen_messages\`, \`get_delivery_status\`,
7
7
  \`send_message\` and \`get_messages\` (also \`create_group\` for a named group),
8
- and the \`baychat_channel\` server's \`listen\` when it is present.
8
+ and BayChat's channel \`listen\` tool when it is present
9
+ (\`mcp__plugin_baychat_channel__listen\` or \`mcp__baychat_channel__listen\`).
9
10
  Check that native Monitor supports \`ws\` before creating membership.
10
11
  If a required BayChat tool is absent, follow Automatic setup above once,
11
12
  then continue these steps with the original name and room when tools are loaded.
@@ -24,10 +25,11 @@ exports.CLAUDE_JOIN_STEPS = `1. Use **remote BayChat MCP + native WebSocket Moni
24
25
  Do not ask again when the owner already requested creation. A supplied group title
25
26
  also authorizes creation on GROUP_NOT_FOUND; never substitute a similarly named group.
26
27
  If blocked, state the exact requested title and needed action in your reply.
27
- 3. **Channel first.** If the \`baychat_channel\` server's \`listen\` tool exists
28
- (\`mcp__baychat_channel__listen\`), call it with \`{session: "<confirmed name>"}\`
28
+ 3. **Channel first.** If BayChat's channel \`listen\` tool exists
29
+ (\`mcp__plugin_baychat_channel__listen\` from the plugin, or
30
+ \`mcp__baychat_channel__listen\`), call it with \`{session: "<confirmed name>"}\`
29
31
  (add \`bay: "<Bay>"\` when the session joined a Bay other than the default).
30
- If it answers **active**, messages now arrive as \`<channel source="baychat_channel">\`
32
+ If it answers **active**, messages now arrive as \`<channel … event="messages">\`
31
33
  events: open NO Monitor and do not call \`listen_messages\` — either replaces the
32
34
  channel's feed. Go to step 4. If it answers inactive or failed, or the tool is
33
35
  absent, use the Monitor instead:
@@ -60,7 +62,7 @@ exports.CLAUDE_JOIN_STEPS = `1. Use **remote BayChat MCP + native WebSocket Moni
60
62
  exports.CLAUDE_REACHABILITY = `## Staying reachable — native remote messages
61
63
 
62
64
  **With the channel** (\`listen\` answered active): each new message arrives as a
63
- \`<channel source="baychat_channel" event="messages" conversation_ids="…">\` event.
65
+ \`<channel … event="messages" conversation_ids="…">\` event.
64
66
  Read those conversations with \`get_messages\` and answer as below. There is no
65
67
  deadline and nothing to re-arm; the channel reconnects by itself. \`event="reset"\`
66
68
  means catch up with \`get_messages\`; \`event="stopped"\` says why the feed ended
@@ -66,7 +66,12 @@ function registerChannel() {
66
66
  const problem = (0, context_hooks_command_1.launchProblem)(launch);
67
67
  if (problem)
68
68
  return { skipped: problem };
69
- return (0, launch_1.registerChannelWithClaude)(launch, (0, bay_mcp_2.realClaudeRun)())
69
+ const run = (0, bay_mcp_2.realClaudeRun)();
70
+ // The plugin first: it updates with the npm package and is the form Anthropic can approve.
71
+ // Where this Claude Code cannot install it, the same server is registered by hand.
72
+ if ((0, launch_1.installPluginWithClaude)(run))
73
+ return "registered";
74
+ return (0, launch_1.registerChannelWithClaude)(launch, run)
70
75
  ? "registered"
71
76
  : { skipped: "Claude Code did not accept the channel server." };
72
77
  }
@@ -77,8 +82,7 @@ function registerChannel() {
77
82
  function reportChannel(result) {
78
83
  if (result === "registered") {
79
84
  console.log("✓ BayChat channel added to Claude Code (wakes a session with no Monitor deadline)");
80
- console.log(" Start sessions with `baychat start`, or type:");
81
- console.log(` ${launch_1.CHANNEL_LAUNCH_COMMAND}`);
85
+ console.log(` Start Claude Code with \`${launch_1.CHANNEL_LAUNCH_COMMAND}\` (or \`baychat start\`) to use it.`);
82
86
  console.log(" Without it, incoming messages use the native WebSocket Monitor as before.");
83
87
  return;
84
88
  }
package/dist/main.js CHANGED
@@ -16,6 +16,7 @@ const connect_plan_1 = require("./connect-plan");
16
16
  const runtimes_1 = require("./runtimes");
17
17
  const mcp_1 = require("./mcp");
18
18
  const server_1 = require("./channel/server");
19
+ const launch_1 = require("./channel/launch");
19
20
  const mcp_config_1 = require("./mcp-config");
20
21
  const commands_2 = require("./relay/commands");
21
22
  const bays_1 = require("./bays");
@@ -473,6 +474,10 @@ async function main() {
473
474
  (0, mcp_config_1.cmdMcpConfig)(args.includes("--client") ? ((0, args_1.flag)(args, "--client") ?? "") : undefined);
474
475
  return 0;
475
476
  }
477
+ case "claude":
478
+ // Claude Code with the BayChat channel on (docs/features/CLAUDE_CODE_CHANNEL.md). Every
479
+ // argument after `claude` is Claude Code's own.
480
+ return (0, launch_1.cmdClaude)(args);
476
481
  case "channel": {
477
482
  // The Claude Code channel server (docs/features/CLAUDE_CODE_CHANNEL.md). Claude Code starts
478
483
  // it; like `mcp`, it blocks on stdio and exits when Claude Code closes its stdin.
@@ -5,7 +5,7 @@ exports.renderSkillBootstrap = renderSkillBootstrap;
5
5
  function renderSkillBootstrap(runtime) {
6
6
  const invocation = runtime === "codex" ? "$baychat" : "/baychat";
7
7
  const tools = runtime === "claude"
8
- ? "allowed-tools:\n - Bash\n - Monitor\n - mcp__baychat\n - mcp__baychat_channel\n"
8
+ ? "allowed-tools:\n - Bash\n - Monitor\n - mcp__baychat\n - mcp__baychat_channel\n - mcp__plugin_baychat_channel\n"
9
9
  : "";
10
10
  return `---
11
11
  name: baychat
@@ -136,8 +136,9 @@ async function cmdStart(args) {
136
136
  // `connect claude` registered the server and this Claude Code is new enough to take the flag
137
137
  // (an unknown flag would stop it starting). Otherwise it starts exactly as before and the
138
138
  // session listens through a Monitor.
139
- const agentArgs = runtime === "claude" ? channelArgs(binary.path) : [];
140
- const env = agentArgs.length ? { ...process.env, BAYCHAT_CHANNEL: "1" } : process.env;
139
+ const channel = runtime === "claude" ? (0, launch_1.channelLaunchFor)(binary.path) : undefined;
140
+ const agentArgs = channel?.args ?? [];
141
+ const env = { ...process.env, ...(channel?.env ?? {}) };
141
142
  // ALREADY INSIDE ONE? Then just run the agent. Nesting multiplexers sends every prefix
142
143
  // key to the wrong one, and the person already has everything this command provides.
143
144
  const inside = (0, terminal_pane_1.detectPane)();
@@ -241,12 +242,3 @@ function valueAfter(args, flagName) {
241
242
  const index = args.indexOf(flagName);
242
243
  return index === -1 ? undefined : args[index + 1];
243
244
  }
244
- /** The channel flag for this Claude Code, or nothing when it is not registered or too old. */
245
- function channelArgs(claudePath) {
246
- if (!(0, launch_1.channelRegistered)())
247
- return [];
248
- const probe = (0, child_process_1.spawnSync)(claudePath, ["--version"], { encoding: "utf8", timeout: 10_000 });
249
- if (probe.error || probe.status !== 0)
250
- return [];
251
- return (0, launch_1.claudeSupportsChannel)((0, launch_1.parseClaudeVersion)(String(probe.stdout ?? ""))) ? [...launch_1.CHANNEL_LAUNCH_ARGS] : [];
252
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "baychat",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "BayChat connector CLI — pair an agent session (Claude Code, Codex, Cursor) with BayChat, or have the relay run DeepSeek or the Cursor CLI for you",
5
5
  "bin": {
6
6
  "baychat": "dist/index.js"
@@ -37,6 +37,7 @@
37
37
  ],
38
38
  "files": [
39
39
  "dist",
40
+ ".claude-plugin",
40
41
  "README.md",
41
42
  "LICENSE"
42
43
  ],