baychat 0.27.1 → 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,7 +112,7 @@ 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
115
+ a local Claude Code channel (as the `baychat` plugin, from https://baychat.io/claude/marketplace.json). Start Claude Code with
116
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
@@ -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;
@@ -67,6 +68,13 @@ const fs = __importStar(require("node:fs"));
67
68
  exports.CHANNEL_MCP_ENTRY = "baychat_channel";
68
69
  /** What `--dangerously-load-development-channels` takes for a bare MCP server. */
69
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
+ }
70
78
  exports.DEV_CHANNELS_FLAG = "--dangerously-load-development-channels";
71
79
  const CHANNEL_FLAGS = [exports.DEV_CHANNELS_FLAG, "--channels"];
72
80
  /** How far up the process tree to look. */
@@ -88,7 +96,7 @@ function argsEnableChannel(args) {
88
96
  for (let j = i + 1; j < args.length && !args[j].startsWith("-"); j++)
89
97
  values.push(...args[j].split(/[\s,]+/));
90
98
  }
91
- if (values.includes(exports.CHANNEL_FLAG_VALUE))
99
+ if (values.some(isOurChannelValue))
92
100
  return true;
93
101
  }
94
102
  return false;
@@ -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,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.CHANNEL_ENV = 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
39
  exports.channelLaunchFor = channelLaunchFor;
@@ -42,6 +42,9 @@ exports.claudeChannelAddArgs = claudeChannelAddArgs;
42
42
  exports.registerChannelWithClaude = registerChannelWithClaude;
43
43
  exports.claudeUserConfigPath = claudeUserConfigPath;
44
44
  exports.channelRegistered = channelRegistered;
45
+ exports.claudeUserSettingsPath = claudeUserSettingsPath;
46
+ exports.pluginEnabled = pluginEnabled;
47
+ exports.installPluginWithClaude = installPluginWithClaude;
45
48
  /**
46
49
  * Getting the channel into Claude Code: registering the server, and starting sessions with it on.
47
50
  *
@@ -77,8 +80,10 @@ function claudeSupportsChannel(version) {
77
80
  }
78
81
  return true;
79
82
  }
80
- /** The arguments `baychat start` adds to a Claude Code launch. */
83
+ /** The arguments that switch the hand-registered channel server on. */
81
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";
82
87
  /** What to type to start Claude Code with the channel on. */
83
88
  exports.CHANNEL_LAUNCH_COMMAND = "baychat claude";
84
89
  /**
@@ -92,14 +97,20 @@ exports.CHANNEL_ENV = { BAYCHAT_CHANNEL: "1" };
92
97
  * stop it from starting at all.
93
98
  */
94
99
  function channelLaunchFor(claudePath, deps = {}) {
95
- const registered = deps.registered ?? (() => channelRegistered());
96
- if (!registered())
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)
97
108
  return { on: false, args: [], env: {}, why: "the BayChat channel is not set up here (run `baychat connect claude`)" };
98
109
  const text = (deps.version ?? probeVersion)(claudePath);
99
110
  if (!claudeSupportsChannel(text ? parseClaudeVersion(text) : undefined)) {
100
111
  return { on: false, args: [], env: {}, why: `Claude Code ${exports.CHANNEL_MIN_CLAUDE.join(".")} or newer is needed for the channel (run \`claude update\`)` };
101
112
  }
102
- return { on: true, args: [...exports.CHANNEL_LAUNCH_ARGS], env: { ...exports.CHANNEL_ENV } };
113
+ return { on: true, args: [detect_1.DEV_CHANNELS_FLAG, ...entries], env: { ...exports.CHANNEL_ENV } };
103
114
  }
104
115
  function probeVersion(claudePath) {
105
116
  const probe = (0, node_child_process_1.spawnSync)(claudePath, ["--version"], { encoding: "utf8", timeout: 10_000 });
@@ -157,3 +168,33 @@ function channelRegistered(file = claudeUserConfigPath(), read = (p) => fs.readF
157
168
  return false;
158
169
  }
159
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;
@@ -217,17 +258,40 @@ class ChannelService {
217
258
  const started = await feed.start();
218
259
  if (!started.ok) {
219
260
  this.feeds.delete(key);
261
+ if (this.last?.key === key)
262
+ this.last = undefined;
220
263
  return toolResult("failed", `Failed: ${started.detail} Use listen_messages and a native Monitor instead.`, { session });
221
264
  }
265
+ this.last = { key, session, bay };
222
266
  return toolResult("active", `Active: session "${session}"${bayName ? ` (${bayName})` : ""} now hears the Bay through the channel. New messages ` +
223
- `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 ` +
224
268
  `Monitor or call listen_messages — either would replace this feed. Now read one get_messages batch (limit 10) ` +
225
269
  `to catch up on anything from before listening.`, { session, ...(bayName ? { bay: bayName } : {}), how: evidence.how });
226
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
+ }
227
290
  stopAll() {
228
291
  for (const feed of this.feeds.values())
229
292
  feed.stop();
230
293
  this.feeds.clear();
294
+ this.last = undefined;
231
295
  }
232
296
  }
233
297
  exports.ChannelService = ChannelService;
@@ -235,11 +299,24 @@ function safeJsonStatus(text) {
235
299
  const parsed = safeJson(text);
236
300
  return parsed?.status;
237
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
+ });
238
312
  /** Boot on stdio and block on the transport. */
239
313
  async function startChannelServer(deps) {
240
314
  const log = deps?.log ?? ((line) => console.error(`[baychat channel] ${line}`));
241
315
  const mcp = new index_js_1.Server({ name: detect_1.CHANNEL_MCP_ENTRY, version: "1" }, {
242
- 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: {} },
243
320
  instructions: exports.CHANNEL_INSTRUCTIONS,
244
321
  });
245
322
  const service = new ChannelService({
@@ -256,6 +333,23 @@ async function startChannelServer(deps) {
256
333
  log(`could not hand an event to Claude Code: ${err instanceof Error ? err.message : String(err)}`);
257
334
  }
258
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
+ });
259
353
  mcp.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({ tools: [LISTEN_TOOL] }));
260
354
  mcp.setRequestHandler(types_js_1.CallToolRequestSchema, async (req) => {
261
355
  if (req.params.name === LISTEN_TOOL.name)
@@ -266,7 +360,9 @@ async function startChannelServer(deps) {
266
360
  // keep this process alive with nobody left to tell, holding the session "connected".
267
361
  const shutdown = () => {
268
362
  service.stopAll();
269
- 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));
270
366
  };
271
367
  mcp.onclose = shutdown;
272
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
  }
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "baychat",
3
- "version": "0.27.1",
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
  ],