baychat 0.25.3 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -50,6 +50,24 @@ setup runs inside the session; device approval or a client reload may still need
50
50
  you. For a first installation without the skill, `baychat connect codex` or
51
51
  `baychat connect claude` installs it once. Updating npm does not deploy remote MCP.
52
52
 
53
+ ### Several Bays on one computer
54
+
55
+ If you own an Enterprise Bay with Team Bays, one approval of `baychat login` signs this
56
+ computer in to **every** one of them: your Personal Bay and each Team Bay you are in.
57
+ (Free and Pro, and people invited into a Team Bay, get the one Bay they approved in — as
58
+ before.) Each Bay is its own connection, and a session uses exactly one:
59
+
60
+ ```text
61
+ /baychat Scout "Coding" # the default Bay (your Personal Bay unless you chose another)
62
+ /baychat Scout "Coding" --bay Harbor # the Team Bay "Harbor" — its own MCP entry, baychat-harbor
63
+ baychat bays # list this computer's Bay logins
64
+ baychat bays default Harbor # make Harbor the default instead
65
+ ```
66
+
67
+ Claude Code and every connected client get `baychat` (the default Bay) and one
68
+ `baychat-<bay>` entry per other Bay. With one Bay nothing changes: only `baychat`.
69
+ The relay runs one connection per Bay, so the same session name in two Bays never collides.
70
+
53
71
  ## What the package and MCP each do
54
72
 
55
73
  The npm package contains the CLI, installed runtime commands, local relay,
@@ -273,6 +291,7 @@ one where remembering to re-arm falls to the agent.
273
291
  | Command | Description |
274
292
  |---------|-------------|
275
293
  | `baychat login [--start \| --finish \| --token <PAT>] [--base <url>]` | Log this computer in. Agents use `--start` to show an approval link immediately and `--finish` once approved; interactive login displays a QR and waits |
294
+ | `baychat bays [default <name>]` | List this computer's Bay logins (one approval signs an Enterprise owner in to the Personal Bay and its Team Bays), or choose the default Bay — the one a session joins when it names none |
276
295
  | `baychat onboard [<conv>]` | **Run first.** Print the agent protocol + your live identity, conversations, and (a) room's context |
277
296
  | `baychat pair <code> [--base <url>]` | Redeem a pairing code and store credentials |
278
297
  | `baychat hermes init [--home <dir>] [--enable]` | Install the BayChat platform plugin into a Hermes agent (`~/.hermes`) and put your paired token in the `.env` its adapter reads. `--enable` also runs the two `hermes` commands that switch it on |
@@ -0,0 +1,177 @@
1
+ "use strict";
2
+ // Writing the per-Bay MCP entries (docs/features/MULTI_BAY_DEVICE_LOGIN.md).
3
+ //
4
+ // The default Bay stays the `baychat` server every client already has — written exactly as
5
+ // before by the code that always wrote it. THIS file adds the OTHER Bays as `baychat-<bay>`
6
+ // beside it, and removes ones it wrote earlier that are no longer wanted (a Bay that went
7
+ // away, or the Bay that just became the default).
8
+ //
9
+ // Two invariants:
10
+ // 1. A one-Bay computer is untouched. `extraBayEntries` is empty below two Bays, and with
11
+ // nothing stale this writes nothing and spawns nothing.
12
+ // 2. REFRESH IS NOT CONNECT (credential-refresh.ts): a client config file is only edited
13
+ // when it already names BayChat. Writing into a client the person never connected would
14
+ // configure software they did not ask us to touch.
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.renameServerInConfig = renameServerInConfig;
17
+ exports.syncBayClientConfigs = syncBayClientConfigs;
18
+ exports.claudeBayAddArgs = claudeBayAddArgs;
19
+ exports.realClaudeRun = realClaudeRun;
20
+ exports.syncBayEntriesWithClaude = syncBayEntriesWithClaude;
21
+ exports.applyBayEntries = applyBayEntries;
22
+ const node_child_process_1 = require("node:child_process");
23
+ const client_config_writer_1 = require("./client-config-writer");
24
+ const client_paths_1 = require("./client-paths");
25
+ const mcp_dialects_1 = require("./mcp-dialects");
26
+ const bays_1 = require("./bays");
27
+ /**
28
+ * The same client config, under another server name. `buildClientConfig` (shared verbatim
29
+ * with the mobile app, so not edited here) always names the server `baychat`; a per-Bay entry
30
+ * is that config with the name swapped and nothing else changed.
31
+ */
32
+ function renameServerInConfig(config, name) {
33
+ if (config.format === "toml") {
34
+ const body = config.body.replace(new RegExp(`^(\\s*\\[\\s*mcp_servers\\.)${bays_1.DEFAULT_MCP_ENTRY}(?=\\s*[\\].])`, "gm"), `$1${name}`);
35
+ return { ...config, body };
36
+ }
37
+ const parsed = JSON.parse(config.body);
38
+ const entry = parsed.mcpServers[bays_1.DEFAULT_MCP_ENTRY];
39
+ return { ...config, body: JSON.stringify({ ...parsed, mcpServers: { [name]: entry } }, null, 2) };
40
+ }
41
+ /**
42
+ * Bring every ALREADY-CONNECTED client config file onto the wanted per-Bay entries.
43
+ *
44
+ * @returns the paths actually changed, for the caller to report.
45
+ */
46
+ function syncBayClientConfigs(url, extras, stale, io, env) {
47
+ if (extras.length === 0 && stale.length === 0)
48
+ return [];
49
+ const changed = [];
50
+ for (const client of mcp_dialects_1.MCP_CLIENTS) {
51
+ for (const path of (0, client_paths_1.inspectableConfigPaths)(client, env)) {
52
+ const existing = io.readFile(path);
53
+ if (existing === null || !existing.includes(bays_1.DEFAULT_MCP_ENTRY))
54
+ continue;
55
+ try {
56
+ const next = syncOneFile(client, existing, url, extras, stale);
57
+ if (next === existing)
58
+ continue;
59
+ io.copyFile(path, (0, client_config_writer_1.backupPathFor)(path));
60
+ io.writeFile(path, next);
61
+ changed.push(path);
62
+ }
63
+ catch {
64
+ // One unreadable config must not stop the others; `doctor` names it. Same rule as the
65
+ // default entry's refresh in credential-refresh.ts.
66
+ }
67
+ }
68
+ }
69
+ return changed;
70
+ }
71
+ function syncOneFile(client, existing, url, extras, stale) {
72
+ let content = existing;
73
+ for (const name of stale) {
74
+ const removed = client === "codex" ? (0, client_config_writer_1.removeTomlServer)(content, name) : (0, client_config_writer_1.removeJsonServer)(content, name);
75
+ if (removed !== null)
76
+ content = removed;
77
+ }
78
+ for (const extra of extras) {
79
+ const config = renameServerInConfig((0, mcp_dialects_1.buildClientConfig)(client, { url, token: extra.bay.token }), extra.name);
80
+ content = (0, client_config_writer_1.mergeClientConfig)(config, content, extra.name).content;
81
+ }
82
+ return content;
83
+ }
84
+ /** `claude mcp …` argv for one named server. The token rides argv exactly as for `baychat`. */
85
+ function claudeBayAddArgs(name, baseUrl, token) {
86
+ return [
87
+ "mcp",
88
+ "add",
89
+ "--transport",
90
+ "http",
91
+ "--scope",
92
+ "user",
93
+ name,
94
+ `${baseUrl}/api/mcp`,
95
+ "--header",
96
+ `Authorization: Bearer ${token}`,
97
+ ];
98
+ }
99
+ /** Characters cmd.exe quoting cannot hold (commands.ts `WINDOWS_UNSAFE`). */
100
+ const WINDOWS_UNSAFE = /["%\u0000-\u001f\u007f]/;
101
+ /** Run `claude <args>` the way `registerWithClaude` does, or null when it cannot run safely. */
102
+ function realClaudeRun(platform = process.platform) {
103
+ return (args) => {
104
+ const shell = platform === "win32";
105
+ if (shell && args.some((a) => WINDOWS_UNSAFE.test(a))) {
106
+ return { status: null, stderr: "", error: new Error("unsafe argument for cmd.exe") };
107
+ }
108
+ const res = (0, node_child_process_1.spawnSync)("claude", shell ? args.map((a) => `"${a}"`) : args, {
109
+ encoding: "utf8",
110
+ stdio: ["ignore", "ignore", "pipe"],
111
+ timeout: 15_000,
112
+ killSignal: "SIGKILL",
113
+ shell,
114
+ });
115
+ return { status: res.status, stderr: String(res.stderr ?? ""), ...(res.error ? { error: res.error } : {}) };
116
+ };
117
+ }
118
+ /**
119
+ * Register each extra Bay with Claude Code as `baychat-<bay>`, and remove stale ones.
120
+ *
121
+ * A renewal re-adds: Claude refuses `add` for an existing name, so "already exists" is
122
+ * answered by remove + add — the same rule `registerWithClaude` follows for `baychat`.
123
+ *
124
+ * @returns names added and names that could not be — never throws (the login is saved).
125
+ */
126
+ function syncBayEntriesWithClaude(baseUrl, extras, stale, run = realClaudeRun()) {
127
+ const out = { added: [], removed: [], failed: [] };
128
+ for (const name of stale) {
129
+ const res = run(["mcp", "remove", name]);
130
+ if (res.status === 0)
131
+ out.removed.push(name);
132
+ // Absent is the common case and not a failure: there was nothing to remove.
133
+ }
134
+ for (const extra of extras) {
135
+ const args = claudeBayAddArgs(extra.name, baseUrl, extra.bay.token);
136
+ let res = run(args);
137
+ if (res.status !== 0 && /already exists/i.test(res.stderr)) {
138
+ const removed = run(["mcp", "remove", extra.name]);
139
+ if (removed.status === 0)
140
+ res = run(args);
141
+ }
142
+ if (res.status === 0)
143
+ out.added.push(extra.name);
144
+ else
145
+ out.failed.push(extra.name);
146
+ }
147
+ return out;
148
+ }
149
+ /**
150
+ * After a login or a default switch: write the per-Bay entries everywhere this computer is
151
+ * already connected, and say what changed. Best-effort and always reported — the login itself
152
+ * is already saved, and a person told their login failed would redo it into the same place.
153
+ */
154
+ function applyBayEntries(baseUrl, previous, current, opts) {
155
+ const log = opts.log ?? ((line) => console.log(line));
156
+ const extras = (0, bays_1.extraBayEntries)(current);
157
+ const stale = (0, bays_1.staleBayEntryNames)(previous, current);
158
+ if (extras.length === 0 && stale.length === 0)
159
+ return;
160
+ try {
161
+ for (const path of syncBayClientConfigs(`${baseUrl}/api/mcp`, extras, stale, opts.io, opts.env)) {
162
+ log(`✓ Updated ${path} with your other Bays`);
163
+ }
164
+ }
165
+ catch (err) {
166
+ log(` Could not update client configs for your other Bays: ${err instanceof Error ? err.message : String(err)}`);
167
+ }
168
+ if (!opts.claude)
169
+ return;
170
+ const result = syncBayEntriesWithClaude(baseUrl, extras, stale, opts.run);
171
+ for (const name of result.added)
172
+ log(`✓ BayChat ${name} added to Claude Code`);
173
+ for (const name of result.failed) {
174
+ log(` Could not add ${name} to Claude Code — run \`baychat login\` again, or add it by hand:`);
175
+ log(` claude mcp add --transport http --scope user ${name} ${baseUrl}/api/mcp --header "Authorization: Bearer <token>"`);
176
+ }
177
+ }
@@ -0,0 +1,60 @@
1
+ "use strict";
2
+ // `baychat bays` — the Bay logins on this computer, and which one is the default
3
+ // (docs/features/MULTI_BAY_DEVICE_LOGIN.md).
4
+ Object.defineProperty(exports, "__esModule", { value: true });
5
+ exports.BAYS_USAGE = void 0;
6
+ exports.cmdBays = cmdBays;
7
+ const config_1 = require("./config");
8
+ const bays_1 = require("./bays");
9
+ const bay_mcp_1 = require("./bay-mcp");
10
+ const commands_1 = require("./commands");
11
+ const client_paths_1 = require("./client-paths");
12
+ exports.BAYS_USAGE = `Usage: baychat bays List the Bay logins on this computer
13
+ baychat bays default <name> Make <name> the default Bay (a session that names
14
+ no Bay joins it; older clients use it)`;
15
+ /**
16
+ * `baychat bays [default <name>]`.
17
+ *
18
+ * Switching the default is LOCAL: no server call, no new credential. It moves which stored
19
+ * token `device` holds, rewrites the `baychat` MCP entry to that Bay and the `baychat-<bay>`
20
+ * entries to the others. Every token still opens exactly its own Bay.
21
+ */
22
+ function cmdBays(args) {
23
+ const [sub, ...rest] = args;
24
+ if (sub === undefined) {
25
+ for (const line of (0, bays_1.renderBayList)((0, config_1.loadBayLogins)(), (0, config_1.loadDeviceCredentials)()))
26
+ console.log(line);
27
+ return 0;
28
+ }
29
+ if (sub !== "default")
30
+ throw new Error(exports.BAYS_USAGE);
31
+ const name = rest.join(" ").trim();
32
+ if (!name)
33
+ throw new Error(exports.BAYS_USAGE);
34
+ const device = (0, config_1.loadDeviceCredentials)();
35
+ const bays = (0, config_1.loadBayLogins)();
36
+ if (!device)
37
+ throw new Error("This computer is not logged in to BayChat — run `baychat login`.");
38
+ if (bays.length < 2) {
39
+ throw new Error("This computer has one Bay login, so it is already the default. An Enterprise owner with Team Bays gets every Bay at `baychat login`.");
40
+ }
41
+ const target = (0, bays_1.findBayLogin)(bays, name);
42
+ if (target.isDefault) {
43
+ console.log(`${target.bayName} is already this computer's default Bay.`);
44
+ return 0;
45
+ }
46
+ const next = (0, bays_1.withDefaultBay)(bays, device, target);
47
+ (0, config_1.saveDeviceCredentials)(next.device, next.bays);
48
+ console.log(`✓ ${target.bayName} is now this computer's default Bay.`);
49
+ // The default is the `baychat` entry: every connected client gets the new token there, and
50
+ // the Bay that WAS the default moves to its own `baychat-<bay>` entry.
51
+ (0, commands_1.refreshConnectedClients)(next.device.baseUrl, next.device.token);
52
+ (0, commands_1.registerWithClaude)(next.device.baseUrl, next.device.token);
53
+ (0, bay_mcp_1.applyBayEntries)(next.device.baseUrl, bays, next.bays, {
54
+ claude: true,
55
+ io: commands_1.realConfigIo,
56
+ env: (0, client_paths_1.currentPathEnv)(),
57
+ });
58
+ console.log(" Restart your agent clients (and `baychat relay stop && baychat relay start` if the relay runs) to pick it up.");
59
+ return 0;
60
+ }
package/dist/bays.js ADDED
@@ -0,0 +1,145 @@
1
+ "use strict";
2
+ // One computer, one login per Bay (docs/features/MULTI_BAY_DEVICE_LOGIN.md) — the CLI's half.
3
+ //
4
+ // One approval on the phone can sign this computer in to several Bays. The server mints one
5
+ // ORDINARY single-Bay credential per Bay; nothing about a token changed. What changes here is
6
+ // only how this computer keeps them and offers them:
7
+ //
8
+ // - credentials.json keeps `device` = the DEFAULT Bay (every older reader is unchanged) and
9
+ // the whole set under `bays` (config.ts).
10
+ // - Each MCP client gets the default Bay as the `baychat` server it always had, and every
11
+ // OTHER Bay as `baychat-<bay>` — written ONLY when there is more than one Bay, so a one-Bay
12
+ // user's client config is byte-identical to before.
13
+ // - A session picks its Bay at start: `--bay <name>`, or the default.
14
+ //
15
+ // This file is the PURE part — names, lookups, the default switch. No I/O.
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.DEFAULT_MCP_ENTRY = void 0;
18
+ exports.baySlug = baySlug;
19
+ exports.bayEntryName = bayEntryName;
20
+ exports.extraBayEntries = extraBayEntries;
21
+ exports.staleBayEntryNames = staleBayEntryNames;
22
+ exports.findBayLogin = findBayLogin;
23
+ exports.withDefaultBay = withDefaultBay;
24
+ exports.renderBayList = renderBayList;
25
+ /** The MCP server name every client has always had: the default Bay. */
26
+ exports.DEFAULT_MCP_ENTRY = "baychat";
27
+ /** How a Bay name becomes part of an MCP server name: `[a-z0-9-]`, no edge dashes, short. */
28
+ function baySlug(name) {
29
+ const slug = name
30
+ .normalize("NFKD")
31
+ .replace(/[̀-ͯ]/g, "")
32
+ .toLowerCase()
33
+ .replace(/[^a-z0-9]+/g, "-")
34
+ .replace(/^-+|-+$/g, "")
35
+ .slice(0, 32)
36
+ .replace(/-+$/g, "");
37
+ return slug || "bay";
38
+ }
39
+ /** A Bay id, reduced to what an MCP server name may carry. */
40
+ function idSuffix(bayId) {
41
+ return bayId.toLowerCase().replace(/[^a-z0-9]/g, "").slice(-6) || "x";
42
+ }
43
+ /**
44
+ * The MCP server name for one Bay among the computer's Bays: `baychat-<slug>`.
45
+ *
46
+ * Stable for a given set of Bay names; when two of this person's Bays slug to the same text
47
+ * (two Bays called "Work"), BOTH get a short id suffix, so neither name depends on which one
48
+ * happened to be listed first. Claude Code exposes the tools as `mcp__baychat-<slug>__…`.
49
+ */
50
+ function bayEntryName(bay, all) {
51
+ const slug = baySlug(bay.bayName);
52
+ const clash = all.some((other) => other.bayId !== bay.bayId && baySlug(other.bayName) === slug);
53
+ return `${exports.DEFAULT_MCP_ENTRY}-${clash ? `${slug}-${idSuffix(bay.bayId)}` : slug}`;
54
+ }
55
+ /**
56
+ * The `baychat-<bay>` entries for the NON-default Bays — and none at all with fewer than two
57
+ * Bays, which is what keeps a one-Bay computer's client configs exactly as they were.
58
+ */
59
+ function extraBayEntries(bays) {
60
+ if (bays.length < 2)
61
+ return [];
62
+ return bays.filter((b) => !b.isDefault).map((bay) => ({ name: bayEntryName(bay, bays), bay }));
63
+ }
64
+ /**
65
+ * Entry names THIS CLI wrote for the previous set of Bays and must now remove: the previous
66
+ * extras minus the ones still wanted (a Bay that went away, or the Bay that just became the
67
+ * default and is `baychat` now). Only ever names this CLI itself derived and wrote — never a
68
+ * server it did not write, never the default `baychat` entry — and none at all for a computer
69
+ * that had one Bay before, so a one-Bay login never even asks a client to remove anything.
70
+ */
71
+ function staleBayEntryNames(previous, current) {
72
+ const wanted = new Set(extraBayEntries(current).map((e) => e.name));
73
+ return extraBayEntries(previous)
74
+ .map((e) => e.name)
75
+ .filter((n) => !wanted.has(n) && n !== exports.DEFAULT_MCP_ENTRY)
76
+ .sort();
77
+ }
78
+ /**
79
+ * Find a Bay login by what a person would type: its name (any case), its MCP entry name or
80
+ * slug, or its id. Ambiguity is an error, never a guess — the wrong Bay is the wrong room.
81
+ *
82
+ * @throws naming the Bays that exist, when nothing (or more than one) matches.
83
+ */
84
+ function findBayLogin(bays, query) {
85
+ const q = query.trim();
86
+ const lower = q.toLowerCase();
87
+ const tiers = [
88
+ (b) => b.bayId === q,
89
+ (b) => b.bayName.trim().toLowerCase() === lower,
90
+ (b) => bayEntryName(b, bays) === lower || bayEntryName(b, bays) === `${exports.DEFAULT_MCP_ENTRY}-${lower}`,
91
+ (b) => baySlug(b.bayName) === baySlug(q),
92
+ ];
93
+ for (const match of tiers) {
94
+ const found = bays.filter(match);
95
+ if (found.length === 1)
96
+ return found[0];
97
+ if (found.length > 1) {
98
+ throw new Error(`"${q}" matches more than one Bay (${found.map((b) => `"${b.bayName}"`).join(", ")}) — use the entry name from \`baychat bays\`.`);
99
+ }
100
+ }
101
+ const known = bays.map((b) => `"${b.bayName}"`).join(", ");
102
+ throw new Error(bays.length
103
+ ? `No Bay login named "${q}" on this computer. Bays here: ${known}. Run \`baychat bays\`.`
104
+ : `This computer has one BayChat login and no other Bays — run \`baychat login\` to pick up more.`);
105
+ }
106
+ /**
107
+ * Make `target` the default Bay: the set with `isDefault` moved, and the `device` record now
108
+ * holding the target's token. Pure — the caller saves both and rewrites the client configs.
109
+ */
110
+ function withDefaultBay(bays, device, target) {
111
+ return {
112
+ bays: bays.map((b) => ({ ...b, isDefault: b.bayId === target.bayId })),
113
+ device: { ...device, token: target.token, expiresAt: target.expiresAt },
114
+ };
115
+ }
116
+ /** `baychat bays` — one line per Bay login, the default marked. Pure, for testing. */
117
+ function renderBayList(bays, device, now = Date.now()) {
118
+ if (bays.length === 0) {
119
+ if (!device)
120
+ return ["This computer is not logged in to BayChat — run `baychat login`."];
121
+ return [
122
+ `One login on this computer (as ${device.user.name}), in the Bay it was approved in.`,
123
+ "An Enterprise owner with Team Bays gets every Bay here — run `baychat login` again after updating.",
124
+ ];
125
+ }
126
+ const lines = ["Bay logins on this computer:"];
127
+ const width = Math.max(...bays.map((b) => b.bayName.length));
128
+ for (const bay of bays) {
129
+ const entry = bay.isDefault || bays.length < 2 ? exports.DEFAULT_MCP_ENTRY : bayEntryName(bay, bays);
130
+ lines.push(` ${bay.isDefault ? "*" : " "} ${bay.bayName.padEnd(width)} ${expiryText(bay.expiresAt, now)} MCP: ${entry}`);
131
+ }
132
+ lines.push("* = default Bay (a session that names no Bay joins it).");
133
+ if (bays.length > 1)
134
+ lines.push("Change it: baychat bays default <name>. Join another: /baychat <name> --bay <Bay>.");
135
+ return lines;
136
+ }
137
+ function expiryText(expiresAt, now) {
138
+ const ms = Date.parse(expiresAt) - now;
139
+ if (Number.isNaN(ms))
140
+ return "expiry unknown";
141
+ if (ms <= 0)
142
+ return "EXPIRED — run baychat login";
143
+ const days = Math.floor(ms / 86_400_000);
144
+ return days >= 1 ? `expires in ${days} day(s)` : `expires in ${Math.max(1, Math.round(ms / 3_600_000))}h`;
145
+ }
@@ -1,6 +1,6 @@
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\`,
@@ -25,9 +25,10 @@ exports.CLAUDE_JOIN_STEPS = `1. Use **remote BayChat MCP + native WebSocket Moni
25
25
  If blocked, state the exact requested title and needed action in your reply.
26
26
  3. Call \`listen_messages({session: "<confirmed name>"})\`. Pass its returned
27
27
  \`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.
28
+ \`ws: {url, protocols}\` and \`timeout_ms\`. If your Monitor offers a
29
+ \`persistent\` option, also set \`persistent: true\`. Keep its ticket private.
30
+ This is a native WebSocket source, not a shell command. No terminal, relay or
31
+ attach loop is needed; when its deadline ends it, reopen it (below).
31
32
  4. Call \`get_delivery_status({session: "<confirmed name>"})\`. Only \`connected\`
32
33
  confirms incoming delivery. \`awaiting_connection\` means a ticket was created
33
34
  but Monitor has not connected; show that exact state, not "ready".
@@ -56,11 +57,17 @@ obey \`shouldRespond\`, acknowledge slow work with 👀, and answer in BayChat.
56
57
  Use \`contact_agent\` for directed replies to peers. A delivery event is not a
57
58
  model acknowledgement; never claim a message is read just because it arrived.
58
59
 
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.
60
+ A \`reset\` event means catch up through history. Close code **4000** means
61
+ intentionally stopped or replaced: **do not reconnect**.
62
+
63
+ **The Monitor has a deadline.** Claude Code ends every Monitor after at most 30
64
+ minutes ("Monitor timed out" / "Monitor expired"), whether or not anything
65
+ arrived. That is the runtime's limit, not a stop: **every time it happens, and on
66
+ any other close**, call \`listen_messages\` with this session and the last cursor
67
+ (from \`ready\` or \`messages\`), open one replacement native Monitor, and confirm
68
+ \`get_delivery_status\` again. The reopened feed replays what it missed, so no
69
+ \`get_messages\` catch-up is needed unless a \`reset\` arrives. Skipping this
70
+ leaves the session deaf to the Bay with its terminal still open.
64
71
  Keep the last cursor in this conversation; do not save tickets in files.
65
72
 
66
73
  \`stop_listening\` stops incoming delivery without ending membership.
@@ -68,3 +75,29 @@ Keep the last cursor in this conversation; do not save tickets in files.
68
75
  that has been closed cannot answer until resumed. Do not promise instant model
69
76
  replies: show connected delivery separately from actual reading and answering.
70
77
  `;
78
+ /**
79
+ * Picking the Bay (docs/features/MULTI_BAY_DEVICE_LOGIN.md). One approval can sign a computer
80
+ * in to several Bays, and each is its own MCP connection — `baychat` for the default Bay,
81
+ * `baychat-<bay>` for each other. A session belongs to exactly one, for every call: tools of
82
+ * two Bays mixed in one session would post in a different Bay than the one it joined.
83
+ */
84
+ exports.BAY_CHOICE = `## Which Bay
85
+
86
+ This computer may be logged in to more than one Bay (one approval gives an
87
+ Enterprise owner their Personal Bay and its Team Bays). Each Bay is its own
88
+ BayChat MCP connection: \`baychat\` is the default Bay, and every other Bay is
89
+ \`baychat-<bay>\` (for example \`baychat-harbor\`). \`baychat bays\` lists them.
90
+
91
+ - If the user named a Bay (\`--bay <Bay>\`, or "join Harbor"), use THAT Bay's
92
+ connection for EVERY BayChat call in this session — in Claude Code its tools are
93
+ \`mcp__baychat-<bay>__…\` (e.g. \`mcp__baychat-harbor__join_session\`), not the
94
+ plain \`mcp__baychat__…\` ones. Never mix two Bays' tools in one session: a call
95
+ through another Bay's connection reaches a different Bay.
96
+ - If no Bay was named and more than one \`baychat…\` connection is available, ask the
97
+ user ONCE which Bay before joining. With only \`baychat\`, use it.
98
+ - \`join_session\` reports the Bay it joined (\`bay.name\`). Say it in your one-line
99
+ confirmation; if it is not the Bay that was asked for, stop and say so.
100
+ - Shell commands take the same choice: \`baychat join … --bay "<Bay>"\`.
101
+ - A Bay named that has no connection here: say so and suggest \`baychat bays\`
102
+ (or \`baychat login\` to pick up new Bays). Never fall back to another Bay.
103
+ `;
@@ -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.