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.
@@ -1,10 +1,11 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.CLAUDE_REACHABILITY = exports.CLAUDE_JOIN_STEPS = void 0;
3
+ exports.BAY_CHOICE = exports.CLAUDE_REACHABILITY = exports.CLAUDE_JOIN_STEPS = void 0;
4
4
  /** Claude's native entry point. An expired local relay login says nothing about a separately configured MCP connection. */
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
- \`send_message\` and \`get_messages\` (also \`create_group\` for a named group).
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
9
  Check that native Monitor supports \`ws\` before creating membership.
9
10
  If a required BayChat tool is absent, follow Automatic setup above once,
10
11
  then continue these steps with the original name and room when tools are loaded.
@@ -23,11 +24,19 @@ exports.CLAUDE_JOIN_STEPS = `1. Use **remote BayChat MCP + native WebSocket Moni
23
24
  Do not ask again when the owner already requested creation. A supplied group title
24
25
  also authorizes creation on GROUP_NOT_FOUND; never substitute a similarly named group.
25
26
  If blocked, state the exact requested title and needed action in your reply.
26
- 3. Call \`listen_messages({session: "<confirmed name>"})\`. Pass its returned
27
+ 3. **Channel first.** If the \`baychat_channel\` server's \`listen\` tool exists
28
+ (\`mcp__baychat_channel__listen\`), call it with \`{session: "<confirmed name>"}\`
29
+ (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">\`
31
+ events: open NO Monitor and do not call \`listen_messages\` — either replaces the
32
+ channel's feed. Go to step 4. If it answers inactive or failed, or the tool is
33
+ absent, use the Monitor instead:
34
+ call \`listen_messages({session: "<confirmed name>"})\`. Pass its returned
27
35
  \`monitor\` object directly to the native **Monitor** tool: it contains
28
- \`ws: {url, protocols}\` and \`persistent: true\`. Keep its ticket private.
29
- This is a native WebSocket source, not a shell command. No terminal, relay,
30
- attach loop or manual re-arming is needed while this listener remains open.
36
+ \`ws: {url, protocols}\` and \`timeout_ms\`. If your Monitor offers a
37
+ \`persistent\` option, also set \`persistent: true\`. Keep its ticket private.
38
+ This is a native WebSocket source, not a shell command. No terminal, relay or
39
+ attach loop is needed; when its deadline ends it, reopen it (below).
31
40
  4. Call \`get_delivery_status({session: "<confirmed name>"})\`. Only \`connected\`
32
41
  confirms incoming delivery. \`awaiting_connection\` means a ticket was created
33
42
  but Monitor has not connected; show that exact state, not "ready".
@@ -50,17 +59,31 @@ exports.CLAUDE_JOIN_STEPS = `1. Use **remote BayChat MCP + native WebSocket Moni
50
59
  /** Native socket lifecycle, including the intentional-stop boundary. */
51
60
  exports.CLAUDE_REACHABILITY = `## Staying reachable — native remote messages
52
61
 
53
- Keep the native WebSocket Monitor open between messages. On a \`messages\` event,
62
+ **With the channel** (\`listen\` answered active): each new message arrives as a
63
+ \`<channel source="baychat_channel" event="messages" conversation_ids="…">\` event.
64
+ Read those conversations with \`get_messages\` and answer as below. There is no
65
+ deadline and nothing to re-arm; the channel reconnects by itself. \`event="reset"\`
66
+ means catch up with \`get_messages\`; \`event="stopped"\` says why the feed ended
67
+ (for an expired session: \`join_session\` again with the same name and room, then
68
+ \`listen\` again). Never open a Monitor beside an active channel.
69
+
70
+ **With a Monitor:** keep the native WebSocket Monitor open between messages. On a \`messages\` event,
54
71
  read the indicated conversations with \`get_messages\`, deduplicate message IDs,
55
72
  obey \`shouldRespond\`, acknowledge slow work with 👀, and answer in BayChat.
56
73
  Use \`contact_agent\` for directed replies to peers. A delivery event is not a
57
74
  model acknowledgement; never claim a message is read just because it arrived.
58
75
 
59
- A \`reset\` event means catch up through history. If the socket closes, Monitor
60
- has ended: \`persistent: true\` does not reconnect a closed socket by itself.
61
- Close code **4000** means intentionally stopped or replaced: **do not reconnect**.
62
- For other closes, call \`listen_messages\` with this session and the last cursor,
63
- open one replacement native Monitor, and confirm \`get_delivery_status\` again.
76
+ A \`reset\` event means catch up through history. Close code **4000** means
77
+ intentionally stopped or replaced: **do not reconnect**.
78
+
79
+ **The Monitor has a deadline.** Claude Code ends every Monitor after at most 30
80
+ minutes ("Monitor timed out" / "Monitor expired"), whether or not anything
81
+ arrived. That is the runtime's limit, not a stop: **every time it happens, and on
82
+ any other close**, call \`listen_messages\` with this session and the last cursor
83
+ (from \`ready\` or \`messages\`), open one replacement native Monitor, and confirm
84
+ \`get_delivery_status\` again. The reopened feed replays what it missed, so no
85
+ \`get_messages\` catch-up is needed unless a \`reset\` arrives. Skipping this
86
+ leaves the session deaf to the Bay with its terminal still open.
64
87
  Keep the last cursor in this conversation; do not save tickets in files.
65
88
 
66
89
  \`stop_listening\` stops incoming delivery without ending membership.
@@ -68,3 +91,29 @@ Keep the last cursor in this conversation; do not save tickets in files.
68
91
  that has been closed cannot answer until resumed. Do not promise instant model
69
92
  replies: show connected delivery separately from actual reading and answering.
70
93
  `;
94
+ /**
95
+ * Picking the Bay (docs/features/MULTI_BAY_DEVICE_LOGIN.md). One approval can sign a computer
96
+ * in to several Bays, and each is its own MCP connection — `baychat` for the default Bay,
97
+ * `baychat-<bay>` for each other. A session belongs to exactly one, for every call: tools of
98
+ * two Bays mixed in one session would post in a different Bay than the one it joined.
99
+ */
100
+ exports.BAY_CHOICE = `## Which Bay
101
+
102
+ This computer may be logged in to more than one Bay (one approval gives an
103
+ Enterprise owner their Personal Bay and its Team Bays). Each Bay is its own
104
+ BayChat MCP connection: \`baychat\` is the default Bay, and every other Bay is
105
+ \`baychat-<bay>\` (for example \`baychat-harbor\`). \`baychat bays\` lists them.
106
+
107
+ - If the user named a Bay (\`--bay <Bay>\`, or "join Harbor"), use THAT Bay's
108
+ connection for EVERY BayChat call in this session — in Claude Code its tools are
109
+ \`mcp__baychat-<bay>__…\` (e.g. \`mcp__baychat-harbor__join_session\`), not the
110
+ plain \`mcp__baychat__…\` ones. Never mix two Bays' tools in one session: a call
111
+ through another Bay's connection reaches a different Bay.
112
+ - If no Bay was named and more than one \`baychat…\` connection is available, ask the
113
+ user ONCE which Bay before joining. With only \`baychat\`, use it.
114
+ - \`join_session\` reports the Bay it joined (\`bay.name\`). Say it in your one-line
115
+ confirmation; if it is not the Bay that was asked for, stop and say so.
116
+ - Shell commands take the same choice: \`baychat join … --bay "<Bay>"\`.
117
+ - A Bay named that has no connection here: say so and suggest \`baychat bays\`
118
+ (or \`baychat login\` to pick up new Bays). Never fall back to another Bay.
119
+ `;
@@ -23,7 +23,9 @@
23
23
  // - Callers back up first (`backupPathFor`), so there is always a way back.
24
24
  Object.defineProperty(exports, "__esModule", { value: true });
25
25
  exports.mergeTomlConfig = mergeTomlConfig;
26
+ exports.removeTomlServer = removeTomlServer;
26
27
  exports.mergeJsonConfig = mergeJsonConfig;
28
+ exports.removeJsonServer = removeJsonServer;
27
29
  exports.mergeClientConfig = mergeClientConfig;
28
30
  exports.backupPathFor = backupPathFor;
29
31
  exports.expandHome = expandHome;
@@ -31,9 +33,12 @@ exports.expandHome = expandHome;
31
33
  * The TOML table header this owns, and the only span it may replace.
32
34
  *
33
35
  * Sub-tables (`[mcp_servers.baychat.env]`) belong to the same server and are
34
- * replaced with it; a header for any OTHER server ends the span.
36
+ * replaced with it; a header for any OTHER server ends the span — including a
37
+ * per-Bay `[mcp_servers.baychat-harbor]` (MULTI_BAY_DEVICE_LOGIN.md), which is
38
+ * its own server with its own name and never part of `baychat`'s span.
35
39
  */
36
40
  const TOML_TABLE = "mcp_servers.baychat";
41
+ const tomlTable = (serverName) => `mcp_servers.${serverName}`;
37
42
  /** Matches a top-level TOML table header line, capturing its dotted key. */
38
43
  const TOML_HEADER = /^\s*\[\s*([^\]]+?)\s*\]\s*$/;
39
44
  /**
@@ -42,12 +47,26 @@ const TOML_HEADER = /^\s*\[\s*([^\]]+?)\s*\]\s*$/;
42
47
  * @param existing current file content, or null when the file does not exist yet
43
48
  * @param body the `[mcp_servers.baychat]` block to install
44
49
  */
45
- function mergeTomlConfig(existing, body) {
50
+ function mergeTomlConfig(existing, body, serverName = "baychat") {
46
51
  const block = body.trimEnd();
47
52
  if (existing === null || existing.trim() === "") {
48
53
  return { content: `${block}\n`, action: "created" };
49
54
  }
50
55
  const lines = existing.split("\n");
56
+ const { start, end } = tomlSpan(lines, serverName === "baychat" ? TOML_TABLE : tomlTable(serverName));
57
+ if (start === -1) {
58
+ // Not present — append, keeping exactly one blank line as a separator so the
59
+ // result is readable whether or not the file ended with a newline.
60
+ const prefix = existing.endsWith("\n") ? existing.replace(/\n+$/, "\n") : `${existing}\n`;
61
+ return { content: `${prefix}\n${block}\n`, action: "appended" };
62
+ }
63
+ const before = lines.slice(0, start);
64
+ const after = lines.slice(end);
65
+ const merged = [...before, ...block.split("\n"), ...after].join("\n");
66
+ return { content: merged.endsWith("\n") ? merged : `${merged}\n`, action: "updated" };
67
+ }
68
+ /** The line span `[start, end)` of one server's table (and its sub-tables); start -1 = absent. */
69
+ function tomlSpan(lines, table) {
51
70
  let start = -1;
52
71
  let end = lines.length;
53
72
  for (let i = 0; i < lines.length; i += 1) {
@@ -55,7 +74,7 @@ function mergeTomlConfig(existing, body) {
55
74
  if (!match)
56
75
  continue;
57
76
  const key = (match[1] ?? "").replace(/\s+/g, "");
58
- const ours = key === TOML_TABLE || key.startsWith(`${TOML_TABLE}.`);
77
+ const ours = key === table || key.startsWith(`${table}.`);
59
78
  if (start === -1) {
60
79
  if (ours)
61
80
  start = i;
@@ -67,16 +86,24 @@ function mergeTomlConfig(existing, body) {
67
86
  break;
68
87
  }
69
88
  }
70
- if (start === -1) {
71
- // Not present — append, keeping exactly one blank line as a separator so the
72
- // result is readable whether or not the file ended with a newline.
73
- const prefix = existing.endsWith("\n") ? existing.replace(/\n+$/, "\n") : `${existing}\n`;
74
- return { content: `${prefix}\n${block}\n`, action: "appended" };
75
- }
89
+ return { start, end };
90
+ }
91
+ /**
92
+ * Remove one server's table (and sub-tables) from a TOML config, or `null` when it is not
93
+ * there. Used only for the `baychat-<bay>` entries this CLI wrote itself.
94
+ */
95
+ function removeTomlServer(existing, serverName) {
96
+ const lines = existing.split("\n");
97
+ const { start, end } = tomlSpan(lines, tomlTable(serverName));
98
+ if (start === -1)
99
+ return null;
76
100
  const before = lines.slice(0, start);
77
101
  const after = lines.slice(end);
78
- const merged = [...before, ...block.split("\n"), ...after].join("\n");
79
- return { content: merged.endsWith("\n") ? merged : `${merged}\n`, action: "updated" };
102
+ // Drop the blank separator the append left above it, so add+remove round-trips cleanly.
103
+ while (before.length > 0 && before[before.length - 1].trim() === "")
104
+ before.pop();
105
+ const merged = [...before, ...(after.length && before.length ? [""] : []), ...after].join("\n").replace(/\n+$/, "");
106
+ return merged === "" ? "" : `${merged}\n`;
80
107
  }
81
108
  /**
82
109
  * Merge the BayChat server entry into a JSON MCP config (Cursor, Claude Desktop).
@@ -86,13 +113,13 @@ function mergeTomlConfig(existing, body) {
86
113
  * including a file that is merely mid-edit. Refusing lets the CLI say which file
87
114
  * to look at.
88
115
  */
89
- function mergeJsonConfig(existing, body) {
116
+ function mergeJsonConfig(existing, body, serverName = "baychat") {
90
117
  // `body` is ours, produced by buildClientConfig — a parse failure here is a bug
91
118
  // in this package, not user input, so it is allowed to throw plainly.
92
119
  const incoming = JSON.parse(body);
93
- const entry = incoming.mcpServers?.baychat;
120
+ const entry = incoming.mcpServers?.[serverName];
94
121
  if (entry === undefined) {
95
- throw new Error("Generated config has no mcpServers.baychat entry — this is a bug.");
122
+ throw new Error(`Generated config has no mcpServers.${serverName} entry — this is a bug.`);
96
123
  }
97
124
  if (existing === null || existing.trim() === "") {
98
125
  return { content: `${JSON.stringify(incoming, null, 2)}\n`, action: "created" };
@@ -109,19 +136,40 @@ function mergeJsonConfig(existing, body) {
109
136
  }
110
137
  const current = parsed;
111
138
  const servers = { ...(current.mcpServers ?? {}) };
112
- const had = Object.prototype.hasOwnProperty.call(servers, "baychat");
113
- servers.baychat = entry;
139
+ const had = Object.prototype.hasOwnProperty.call(servers, serverName);
140
+ servers[serverName] = entry;
114
141
  const merged = { ...current, mcpServers: servers };
115
142
  return {
116
143
  content: `${JSON.stringify(merged, null, 2)}\n`,
117
144
  action: had ? "updated" : "appended",
118
145
  };
119
146
  }
147
+ /**
148
+ * Remove one server entry from a JSON MCP config, or `null` when it is not there (or the file
149
+ * is not a JSON object — which is left alone, exactly as the merge leaves it alone).
150
+ */
151
+ function removeJsonServer(existing, serverName) {
152
+ let parsed;
153
+ try {
154
+ parsed = JSON.parse(existing);
155
+ }
156
+ catch {
157
+ return null;
158
+ }
159
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
160
+ return null;
161
+ const current = parsed;
162
+ if (!current.mcpServers || !Object.prototype.hasOwnProperty.call(current.mcpServers, serverName))
163
+ return null;
164
+ const servers = { ...current.mcpServers };
165
+ delete servers[serverName];
166
+ return `${JSON.stringify({ ...current, mcpServers: servers }, null, 2)}\n`;
167
+ }
120
168
  /** Merge by dialect. Keeps the format decision in one place. */
121
- function mergeClientConfig(config, existing) {
169
+ function mergeClientConfig(config, existing, serverName = "baychat") {
122
170
  return config.format === "toml"
123
- ? mergeTomlConfig(existing, config.body)
124
- : mergeJsonConfig(existing, config.body);
171
+ ? mergeTomlConfig(existing, config.body, serverName)
172
+ : mergeJsonConfig(existing, config.body, serverName);
125
173
  }
126
174
  /**
127
175
  * Where to copy a file before rewriting it.
package/dist/commands.js CHANGED
@@ -20,6 +20,7 @@ exports.cmdWatch = cmdWatch;
20
20
  exports.cmdLink = cmdLink;
21
21
  exports.quoteForCmd = quoteForCmd;
22
22
  exports.claudeMcpAddSpawn = claudeMcpAddSpawn;
23
+ exports.refreshConnectedClients = refreshConnectedClients;
23
24
  exports.registerWithClaude = registerWithClaude;
24
25
  exports.cmdLogin = cmdLogin;
25
26
  exports.deviceExpiryWarning = deviceExpiryWarning;
@@ -35,6 +36,7 @@ const api_1 = require("./api");
35
36
  const protocol_1 = require("./protocol");
36
37
  const connection_qr_1 = require("./connection-qr");
37
38
  const config_1 = require("./config");
39
+ const bay_mcp_1 = require("./bay-mcp");
38
40
  const context_1 = require("./context");
39
41
  const runtime_install_1 = require("./runtime-install");
40
42
  const credential_refresh_1 = require("./credential-refresh");
@@ -818,6 +820,9 @@ async function cmdLogin(opts = {}) {
818
820
  console.log(`Approve this device in BayChat, then run baychat login --finish on this same computer. Link expires ${request.expiresAt}.`);
819
821
  return true;
820
822
  }
823
+ // Read BEFORE the new login is saved: the per-Bay MCP entries the previous login wrote are
824
+ // removed when they are no longer wanted (MULTI_BAY_DEVICE_LOGIN.md).
825
+ const previousBays = (0, config_1.loadBayLogins)();
821
826
  if (opts.mode === "finish") {
822
827
  const result = await (0, device_login_1.finishDeviceLogin)(opts.base || process.env.BAYCHAT_API_URL);
823
828
  if (result.status === "pending") {
@@ -830,6 +835,8 @@ async function cmdLogin(opts = {}) {
830
835
  completeDeviceLogin(result.device, {
831
836
  ...opts,
832
837
  message: `✓ Logged in as ${result.device.user.name}`,
838
+ bays: result.bays,
839
+ previousBays,
833
840
  });
834
841
  console.log("Reconnect BayChat in your client to load the refreshed login, then retry the same session name.");
835
842
  return true;
@@ -844,6 +851,7 @@ async function cmdLogin(opts = {}) {
844
851
  }, {
845
852
  ...opts,
846
853
  message: `✓ Logged in as ${me.user.name} (${me.tenant.name})`,
854
+ previousBays,
847
855
  });
848
856
  return true;
849
857
  }
@@ -894,22 +902,47 @@ async function cmdLogin(opts = {}) {
894
902
  token: status.token,
895
903
  user: status.user,
896
904
  expiresAt: status.expiresAt,
897
- }, { ...opts, message: `✓ Logged in as ${status.user.name}` });
905
+ }, {
906
+ ...opts,
907
+ message: `✓ Logged in as ${status.user.name}`,
908
+ bays: (0, device_login_1.parseBayLogins)(status.bays, status.token),
909
+ previousBays,
910
+ });
898
911
  return true;
899
912
  }
900
913
  }
901
914
  console.log("Login request expired — run baychat login again.");
902
915
  return false;
903
916
  }
904
- /** Persist an approved login before refreshing the clients that use it. */
917
+ /**
918
+ * Persist an approved login before refreshing the clients that use it.
919
+ *
920
+ * `bays` is the per-Bay set a multi-Bay approval delivered (MULTI_BAY_DEVICE_LOGIN.md). The
921
+ * default Bay is `device` and is written to every client exactly as before; the OTHER Bays are
922
+ * added beside it as `baychat-<bay>` — only when there are two or more, so a one-Bay login
923
+ * changes nothing it did not change before.
924
+ */
905
925
  function completeDeviceLogin(device, options) {
906
- (0, config_1.saveDeviceCredentials)(device);
926
+ (0, config_1.saveDeviceCredentials)(device, options.bays);
907
927
  console.log(options.message);
928
+ const bays = options.bays ?? [];
929
+ if (bays.length > 1) {
930
+ const names = bays.map((b) => (b.isDefault ? `${b.bayName} (default)` : b.bayName)).join(", ");
931
+ console.log(` This computer can join: ${names}`);
932
+ }
908
933
  refreshConnectedClients(device.baseUrl, device.token);
909
934
  if (options.registerClaude !== false)
910
935
  registerWithClaude(device.baseUrl, device.token);
911
- if (options.hint !== false)
936
+ (0, bay_mcp_1.applyBayEntries)(device.baseUrl, options.previousBays ?? [], bays, {
937
+ claude: options.registerClaude !== false,
938
+ io: exports.realConfigIo,
939
+ env: (0, client_paths_1.currentPathEnv)(),
940
+ });
941
+ if (options.hint !== false) {
912
942
  console.log("\n Run /baychat <name> in any session.");
943
+ if (bays.length > 1)
944
+ console.log(" In another Bay: /baychat <name> --bay <Bay>. See them all: baychat bays.");
945
+ }
913
946
  }
914
947
  /**
915
948
  * The renewal nudge for a device credential, or null when none is due. Device
package/dist/config.js CHANGED
@@ -38,6 +38,7 @@ exports.configDir = configDir;
38
38
  exports.saveCredentials = saveCredentials;
39
39
  exports.loadCredentials = loadCredentials;
40
40
  exports.saveDeviceCredentials = saveDeviceCredentials;
41
+ exports.loadBayLogins = loadBayLogins;
41
42
  exports.loadDeviceCredentials = loadDeviceCredentials;
42
43
  exports.loadCursor = loadCursor;
43
44
  exports.saveCursor = saveCursor;
@@ -141,8 +142,36 @@ function loadCredentials() {
141
142
  return null;
142
143
  return { baseUrl: String(file.baseUrl ?? exports.DEFAULT_API_URL), token: file.token, agent };
143
144
  }
144
- function saveDeviceCredentials(device) {
145
- writeMerged({ device });
145
+ /**
146
+ * Save a device login. `bays` is the per-Bay set a multi-Bay approval delivered; a login that
147
+ * delivered none (an older server, `--token`) CLEARS any previous set, so `device` and `bays`
148
+ * can never describe two different logins. (`undefined` drops the key: JSON.stringify omits it.)
149
+ */
150
+ function saveDeviceCredentials(device, bays) {
151
+ writeMerged({ device, bays: bays && bays.length > 0 ? bays : undefined });
152
+ }
153
+ /**
154
+ * Every Bay login on this computer, or `[]` when the login predates multi-Bay approval, when
155
+ * the device token comes from `BAYCHAT_DEVICE_TOKEN`, or when the stored set is unusable.
156
+ * `[]` means exactly today's behaviour: one login, `device`.
157
+ */
158
+ function loadBayLogins() {
159
+ if (process.env.BAYCHAT_DEVICE_TOKEN)
160
+ return [];
161
+ const raw = loadFile().bays;
162
+ if (!Array.isArray(raw))
163
+ return [];
164
+ const bays = raw.filter((b) => !!b &&
165
+ typeof b === "object" &&
166
+ typeof b.bayId === "string" &&
167
+ typeof b.bayName === "string" &&
168
+ typeof b.token === "string" &&
169
+ b.token.length > 0 &&
170
+ typeof b.expiresAt === "string");
171
+ // A set with no single default is not one this CLI wrote; trust `device` instead.
172
+ if (bays.length === 0 || bays.filter((b) => b.isDefault === true).length !== 1)
173
+ return [];
174
+ return bays.map((b) => ({ ...b, isDefault: b.isDefault === true }));
146
175
  }
147
176
  function loadDeviceCredentials() {
148
177
  // Same env-override shape as BAYCHAT_TOKEN: a headless/CI box can supply a
@@ -3,7 +3,12 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.cmdConnectClaude = cmdConnectClaude;
4
4
  const commands_1 = require("./commands");
5
5
  const config_1 = require("./config");
6
+ const bay_mcp_1 = require("./bay-mcp");
7
+ const client_paths_1 = require("./client-paths");
6
8
  const runtime_install_1 = require("./runtime-install");
9
+ const bay_mcp_2 = require("./bay-mcp");
10
+ const context_hooks_command_1 = require("./context-hooks-command");
11
+ const launch_1 = require("./channel/launch");
7
12
  /** One-time Claude setup reuses a valid login, registers MCP, and refreshes its skill. */
8
13
  async function cmdConnectClaude(opts = {}) {
9
14
  try {
@@ -32,11 +37,16 @@ async function cmdConnectClaude(opts = {}) {
32
37
  if (!device)
33
38
  throw new Error("No device login was saved. Retry baychat connect claude.");
34
39
  const registered = (0, commands_1.registerWithClaude)(device.baseUrl, device.token);
40
+ // The other Bays this login covers, as `baychat-<bay>` (none for a one-Bay login). Passing
41
+ // the current set as "previous" too removes only entries that no longer belong — e.g. the
42
+ // Bay that is now the default.
43
+ const bays = (0, config_1.loadBayLogins)();
44
+ (0, bay_mcp_1.applyBayEntries)(device.baseUrl, bays, bays, { claude: true, io: commands_1.realConfigIo, env: (0, client_paths_1.currentPathEnv)() });
35
45
  for (const line of (0, runtime_install_1.describeInstall)((0, runtime_install_1.installRuntimeCommand)("claude")))
36
46
  console.log(" " + line);
37
47
  if (!registered)
38
48
  return 1;
39
- console.log(" Incoming messages use native WebSocket Monitor; the session will verify its connection after joining.");
49
+ reportChannel(registerChannel());
40
50
  console.log(' Restart Claude Code once, then use /baychat <name> "<group>".');
41
51
  return 0;
42
52
  }
@@ -46,3 +56,32 @@ async function cmdConnectClaude(opts = {}) {
46
56
  return 1;
47
57
  }
48
58
  }
59
+ /**
60
+ * Register the wake-up channel (docs/features/CLAUDE_CODE_CHANNEL.md). Best-effort: without it
61
+ * a session still listens through a Monitor, so a failure here is reported, never fatal.
62
+ */
63
+ function registerChannel() {
64
+ try {
65
+ const launch = (0, context_hooks_command_1.currentLaunch)();
66
+ const problem = (0, context_hooks_command_1.launchProblem)(launch);
67
+ if (problem)
68
+ return { skipped: problem };
69
+ return (0, launch_1.registerChannelWithClaude)(launch, (0, bay_mcp_2.realClaudeRun)())
70
+ ? "registered"
71
+ : { skipped: "Claude Code did not accept the channel server." };
72
+ }
73
+ catch (err) {
74
+ return { skipped: err instanceof Error ? err.message : String(err) };
75
+ }
76
+ }
77
+ function reportChannel(result) {
78
+ if (result === "registered") {
79
+ 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}`);
82
+ console.log(" Without it, incoming messages use the native WebSocket Monitor as before.");
83
+ return;
84
+ }
85
+ console.log(` BayChat channel not added: ${result.skipped}`);
86
+ console.log(" Incoming messages use native WebSocket Monitor; the session will verify its connection after joining.");
87
+ }
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.parseBayLogins = parseBayLogins;
36
37
  exports.startDeviceLogin = startDeviceLogin;
37
38
  exports.finishDeviceLogin = finishDeviceLogin;
38
39
  const fs = __importStar(require("node:fs"));
@@ -42,12 +43,46 @@ const node_crypto_1 = require("node:crypto");
42
43
  const zod_1 = require("zod");
43
44
  const api_1 = require("./api");
44
45
  const config_1 = require("./config");
46
+ const bayLoginSchema = zod_1.z.object({
47
+ bayId: zod_1.z.string().min(1),
48
+ bayName: zod_1.z.string(),
49
+ token: zod_1.z.string().min(1),
50
+ expiresAt: zod_1.z.string().datetime(),
51
+ isDefault: zod_1.z.boolean(),
52
+ });
45
53
  const deviceSchema = zod_1.z.object({
46
54
  baseUrl: zod_1.z.string().url(),
47
55
  token: zod_1.z.string().min(1),
48
56
  user: zod_1.z.object({ id: zod_1.z.string().min(1), name: zod_1.z.string() }),
49
57
  expiresAt: zod_1.z.string().datetime(),
58
+ /** Every Bay's login (MULTI_BAY_DEVICE_LOGIN.md); absent from an older server. */
59
+ bays: zod_1.z.array(bayLoginSchema).optional(),
50
60
  });
61
+ /**
62
+ * The per-Bay logins in an approval, or `undefined` to behave exactly as before (one login).
63
+ *
64
+ * The set is used only when it is coherent with the top-level token every older reader uses:
65
+ * exactly one default, and the default's token IS the top-level token. Anything else is
66
+ * ignored rather than half-trusted — `device` alone still works, and a mismatched set would
67
+ * route sessions to a Bay the approval did not make the default.
68
+ */
69
+ function parseBayLogins(raw, topLevelToken) {
70
+ const parsed = zod_1.z.array(bayLoginSchema.passthrough()).safeParse(raw);
71
+ if (!parsed.success || parsed.data.length === 0)
72
+ return undefined;
73
+ const defaults = parsed.data.filter((b) => b.isDefault);
74
+ if (defaults.length !== 1 || defaults[0].token !== topLevelToken)
75
+ return undefined;
76
+ if (new Set(parsed.data.map((b) => b.bayId)).size !== parsed.data.length)
77
+ return undefined;
78
+ return parsed.data.map(({ bayId, bayName, token, expiresAt, isDefault }) => ({
79
+ bayId,
80
+ bayName,
81
+ token,
82
+ expiresAt,
83
+ isDefault,
84
+ }));
85
+ }
51
86
  const pendingSchema = zod_1.z.object({
52
87
  baseUrl: zod_1.z.string().url(),
53
88
  id: zod_1.z.string().min(1),
@@ -105,6 +140,7 @@ async function finishDeviceLogin(baseUrl) {
105
140
  throw new Error("BayChat returned an invalid login status. Run baychat login --start again.");
106
141
  const parsed = deviceSchema.safeParse({
107
142
  ...status,
143
+ bays: parseBayLogins(status.bays, status.token),
108
144
  baseUrl: pending.baseUrl,
109
145
  });
110
146
  if (!parsed.success)
@@ -114,9 +150,10 @@ async function finishDeviceLogin(baseUrl) {
114
150
  // existing credentials fails; the next finish must not consume it again.
115
151
  writePending({ ...pending, approved: device });
116
152
  }
117
- (0, config_1.saveDeviceCredentials)(device);
153
+ const { bays, ...single } = device;
154
+ (0, config_1.saveDeviceCredentials)(single, bays);
118
155
  fs.unlinkSync(pendingPath());
119
- return { status: "approved", device };
156
+ return { status: "approved", device: single, ...(bays ? { bays } : {}) };
120
157
  });
121
158
  }
122
159
  function pendingPath() {
@@ -48,6 +48,8 @@ const os = __importStar(require("os"));
48
48
  const config_1 = require("./config");
49
49
  const doctor_1 = require("./doctor");
50
50
  const commands_1 = require("./relay/commands");
51
+ const bay_relays_1 = require("./relay/bay-relays");
52
+ const socket_1 = require("./relay/socket");
51
53
  const registry_1 = require("./relay/registry");
52
54
  const credential_refresh_1 = require("./credential-refresh");
53
55
  const client_paths_1 = require("./client-paths");
@@ -75,7 +77,19 @@ async function currentDoctorEnv() {
75
77
  // reached for in the first place.
76
78
  const registry = new registry_1.SessionRegistry();
77
79
  registry.load();
80
+ // One line per Bay login; a non-default Bay's relay is its own process (relay/bay-relays.ts).
81
+ const bays = (0, config_1.loadBayLogins)();
82
+ const bayRelayAlive = {};
83
+ if (bays.length > 1) {
84
+ for (const bay of bays) {
85
+ if (bay.isDefault)
86
+ continue;
87
+ bayRelayAlive[bay.bayId] = await (0, socket_1.probeSocket)((0, bay_relays_1.bayRelayScope)(bay.bayId, (0, config_1.configDir)(), (0, socket_1.socketPath)()).socket);
88
+ }
89
+ }
78
90
  return {
91
+ bays,
92
+ bayRelayAlive,
79
93
  home: os.homedir(),
80
94
  platform: process.platform,
81
95
  now: Date.now(),
package/dist/doctor.js CHANGED
@@ -60,6 +60,7 @@ exports.mailboxCheck = mailboxCheck;
60
60
  exports.acpChecks = acpChecks;
61
61
  exports.exitCodeFor = exitCodeFor;
62
62
  exports.renderReport = renderReport;
63
+ exports.bayChecks = bayChecks;
63
64
  const fs = __importStar(require("fs"));
64
65
  const os = __importStar(require("os"));
65
66
  const path = __importStar(require("path"));
@@ -106,6 +107,7 @@ const EXPIRY_WARNING_MS = 3 * 86_400_000;
106
107
  function buildReport(env) {
107
108
  return {
108
109
  credentials: credentialsCheck(env),
110
+ bays: bayChecks(env),
109
111
  relay: relayCheck(env),
110
112
  mailbox: mailboxCheck(),
111
113
  staleCredentials: staleCredentialCheck(env),
@@ -269,6 +271,8 @@ function exitCodeFor(report) {
269
271
  function renderReport(report) {
270
272
  const lines = [];
271
273
  lines.push(...renderSummaryCheck(report.credentials));
274
+ for (const check of report.bays)
275
+ lines.push(...renderSummaryCheck(check));
272
276
  lines.push(...renderSummaryCheck(report.staleCredentials));
273
277
  lines.push(...renderSummaryCheck(report.relay));
274
278
  lines.push(...renderSummaryCheck(report.mailbox));
@@ -286,6 +290,39 @@ function renderReport(report) {
286
290
  return lines;
287
291
  }
288
292
  // ─── Individual checks ──────────────────────────────────────────────────────
293
+ /**
294
+ * One line per Bay login. An expired Bay login fails exactly as an expired device login does
295
+ * (`baychat login` renews every Bay at once); a non-default Bay whose relay does not answer
296
+ * warns, because only that Bay's terminal sessions lose their wake-ups.
297
+ */
298
+ function bayChecks(env) {
299
+ const bays = env.bays ?? [];
300
+ if (bays.length < 2)
301
+ return [];
302
+ return bays.map((bay) => {
303
+ const name = "bay";
304
+ const label = `"${bay.bayName}"${bay.isDefault ? " (default)" : ""}`;
305
+ const expiresAt = Date.parse(bay.expiresAt);
306
+ if (Number.isNaN(expiresAt) || expiresAt <= env.now) {
307
+ return { name, status: "fail", detail: `${label} — login expired`, remedy: "baychat login" };
308
+ }
309
+ const left = remaining(expiresAt - env.now);
310
+ const alive = bay.isDefault ? undefined : env.bayRelayAlive?.[bay.bayId];
311
+ const relay = alive === undefined ? "" : alive ? ", relay running" : ", relay NOT running";
312
+ if (alive === false) {
313
+ return {
314
+ name,
315
+ status: "warn",
316
+ detail: `${label} — ${left} left${relay}`,
317
+ remedy: "baychat relay stop && baychat relay start",
318
+ };
319
+ }
320
+ const warn = expiresAt - env.now <= EXPIRY_WARNING_MS;
321
+ return warn
322
+ ? { name, status: "warn", detail: `${label} — ${left} left${relay}`, remedy: "baychat login" }
323
+ : { name, status: "pass", detail: `${label} — ${left} left${relay}` };
324
+ });
325
+ }
289
326
  function credentialsCheck(env) {
290
327
  if (!env.device) {
291
328
  return {
@@ -599,6 +636,7 @@ function withRemedy(line, check) {
599
636
  function allChecks(report) {
600
637
  return [
601
638
  report.credentials,
639
+ ...report.bays,
602
640
  report.staleCredentials,
603
641
  report.relay,
604
642
  report.mailbox,