baychat 0.31.0 → 0.32.1

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.
@@ -74,6 +74,10 @@ const transport_1 = require("./transport");
74
74
  const watermarks_1 = require("./watermarks");
75
75
  const bay_relays_1 = require("./bay-relays");
76
76
  const loop_1 = require("./launcher/loop");
77
+ const log_line_1 = require("./log-line");
78
+ const handle_1 = require("./launcher/handle");
79
+ const multiplexer_1 = require("./launcher/multiplexer");
80
+ const approval_settings_1 = require("./launcher/approval-settings");
77
81
  /**
78
82
  * How long a failed resume-id discovery is believed before it is retried.
79
83
  *
@@ -82,6 +86,8 @@ const loop_1 = require("./launcher/loop");
82
86
  * unanswerable delivery expensive as well as pending.
83
87
  */
84
88
  const DISCOVERY_INTERVAL_MS = 10 * 60_000;
89
+ /** The launch records are checked against the multiplexer at most this often (`dropEndedLaunches`). */
90
+ const LAUNCH_CHECK_MIN_MS = 15_000;
85
91
  class RelayDaemon {
86
92
  registry = new registry_1.SessionRegistry();
87
93
  acpQueue = new turn_queue_1.AcpTurnQueue();
@@ -134,6 +140,10 @@ class RelayDaemon {
134
140
  discovery;
135
141
  spawnHeadless;
136
142
  isAlive;
143
+ forgetEndedLaunchFn;
144
+ forgetEndedLaunchesFn;
145
+ /** When the launch records were last checked against the multiplexer (`dropEndedLaunches`). */
146
+ launchesCheckedAt = 0;
137
147
  /**
138
148
  * Sessions with a headless turn running right now — the one-session lease.
139
149
  *
@@ -199,7 +209,9 @@ class RelayDaemon {
199
209
  /** A launch from BayChat is being carried out (the launcher's `onLaunch`). */
200
210
  launching = false;
201
211
  constructor(opts = {}) {
202
- this.log = opts.log ?? ((line) => console.log(`[relay] ${line}`));
212
+ // Every line carries when it happened (ISO, UTC): relay.log is read after the fact, against the
213
+ // server's own UTC log lines (2026-10-16: a STOP could not be placed in time).
214
+ this.log = opts.log ?? ((line) => console.log((0, log_line_1.relayLogLine)(line)));
203
215
  this.onListening = opts.onListening;
204
216
  this.startLauncherFn = opts.startLauncher ?? loop_1.startLauncher;
205
217
  this.autoUpdateStatus = opts.autoUpdateStatus;
@@ -245,6 +257,8 @@ class RelayDaemon {
245
257
  this.discovery = opts.discovery ?? {};
246
258
  this.spawnHeadless = opts.spawnHeadless ?? adapters_1.runHeadless;
247
259
  this.isAlive = opts.isAlive ?? parent_watch_1.processIsAlive;
260
+ this.forgetEndedLaunchFn = opts.forgetEndedLaunch ?? ((name) => (0, handle_1.forgetEndedLaunch)(name, { run: multiplexer_1.realRun, launchDir: (0, approval_settings_1.launchDir)() }));
261
+ this.forgetEndedLaunchesFn = opts.forgetEndedLaunches ?? (() => (0, handle_1.forgetEndedLaunches)({ run: multiplexer_1.realRun, launchDir: (0, approval_settings_1.launchDir)() }));
248
262
  this.reattachGraceMs = opts.reattachGraceMs ?? 4000;
249
263
  this.resolveBinary =
250
264
  opts.resolveBinary ??
@@ -354,6 +368,11 @@ class RelayDaemon {
354
368
  onLaunch: (busy) => {
355
369
  this.launching = busy;
356
370
  },
371
+ // A session the launcher stopped is forgotten here too, not kept as a dead registration.
372
+ forgetSession: (name) => {
373
+ if (this.registry.remove(name))
374
+ this.log(`session ${name} stopped by its launcher: forgotten`);
375
+ },
357
376
  }).catch((err) => this.log(`launcher stopped: ${err instanceof Error ? err.message : String(err)}`));
358
377
  }
359
378
  this.log(`listening on ${sockPath} (pid ${process.pid}) as device "${device.user.name}"`);
@@ -374,6 +393,8 @@ class RelayDaemon {
374
393
  // never had to route an event to — skipping it re-baselines it at "now".
375
394
  this.watermarks.noteLiveSessions(names);
376
395
  const dropped = this.registry.pruneToLive(names);
396
+ this.dropDeadChannelSessions();
397
+ this.dropEndedLaunches();
377
398
  for (const name of dropped) {
378
399
  this.log(`session ended server-side, dropped: ${name}`);
379
400
  // A name reused later is a new session: nothing kept for the old one may reach it.
@@ -1580,6 +1601,7 @@ class RelayDaemon {
1580
1601
  this.channels.delete(session);
1581
1602
  this.registry.setAttached(session, false);
1582
1603
  this.log(`channel disconnected: ${session} — its commands are refused until it reconnects`);
1604
+ this.dropDeadChannelSessions();
1583
1605
  }
1584
1606
  };
1585
1607
  sock.on("close", drop);
@@ -1604,6 +1626,44 @@ class RelayDaemon {
1604
1626
  const where = frame.pane?.kind === "tmux" ? `tmux pane ${frame.pane.pane}` : (frame.pane?.kind ?? "no multiplexer");
1605
1627
  this.log(`channel registered: ${frame.session} (${frame.runtime}, commands only, ${where})`);
1606
1628
  }
1629
+ /**
1630
+ * Forget every channel session that is DEAD: its channel link is closed and its Claude Code
1631
+ * process (`ownerPid`) is gone — from relay-sessions.json, and from launch/started.json when its
1632
+ * launcher session has ended too. Run when a channel link closes and on every session poll, so a
1633
+ * session that died without End is not kept as a registration the relay still believes in
1634
+ * (2026-10-16: mac_motion stayed in both files with no tmux and no claude left).
1635
+ *
1636
+ * A link that closed while its process lives is a reconnect in progress, and is kept. A
1637
+ * registration with no pid is judged by its link alone once the server no longer lists it.
1638
+ */
1639
+ dropDeadChannelSessions() {
1640
+ for (const target of this.registry.all()) {
1641
+ if (target.delivery !== "channel" || this.channelLinked(target.name))
1642
+ continue;
1643
+ if (target.ownerPid === undefined || this.isAlive(target.ownerPid))
1644
+ continue;
1645
+ this.registry.remove(target.name);
1646
+ const launch = this.forgetEndedLaunchFn(target.name) ? " and its launch record" : "";
1647
+ this.log(`channel session ${target.name} is gone (process ${target.ownerPid} not running): dropped its registration${launch}`);
1648
+ }
1649
+ }
1650
+ /**
1651
+ * Forget every launch whose launcher session has ended on its own (launch/started.json), and
1652
+ * say so the way the channel sweep above does. That sweep only sees a session whose channel
1653
+ * registered with THIS relay; a launch whose channel never did (2026-10-16, the Mac: `qa_kill`'s
1654
+ * claude killed by hand, `bc-qa_kill` gone, still listed minutes later, nothing in relay.log)
1655
+ * is caught here. Session polls can come with every event batch, so this asks the multiplexer
1656
+ * at most every LAUNCH_CHECK_MIN_MS.
1657
+ */
1658
+ dropEndedLaunches() {
1659
+ const now = Date.now();
1660
+ if (now - this.launchesCheckedAt < LAUNCH_CHECK_MIN_MS)
1661
+ return;
1662
+ this.launchesCheckedAt = now;
1663
+ for (const gone of this.forgetEndedLaunchesFn()) {
1664
+ this.log(`launched session ${gone.name} is gone (its ${gone.multiplexer} session is not running): dropped its launch record`);
1665
+ }
1666
+ }
1607
1667
  channelLinked(session) {
1608
1668
  const sock = this.channels.get(session);
1609
1669
  return !!sock && !sock.destroyed;
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.TERMINAL_SETTINGS_FILE = exports.UNATTENDED_SETTINGS = exports.JOIN_ALLOW = void 0;
36
+ exports.LAUNCH_MCP_ENTRY = exports.TERMINAL_SETTINGS_FILE = exports.UNATTENDED_SETTINGS = exports.OWN_CONNECTION_ALLOW = exports.JOIN_ALLOW = void 0;
37
37
  exports.approvalPathFor = approvalPathFor;
38
38
  exports.launchDir = launchDir;
39
39
  exports.approvalSettingsPath = approvalSettingsPath;
@@ -45,11 +45,18 @@ exports.approvalsDir = approvalsDir;
45
45
  exports.writeTerminalApprovalSettings = writeTerminalApprovalSettings;
46
46
  exports.currentCliLaunch = currentCliLaunch;
47
47
  exports.writeApprovalSettings = writeApprovalSettings;
48
+ exports.launchMcpConfigPath = launchMcpConfigPath;
49
+ exports.launchMcpConfig = launchMcpConfig;
50
+ exports.writeLaunchMcpConfig = writeLaunchMcpConfig;
51
+ exports.removeLaunchFiles = removeLaunchFiles;
48
52
  exports.removeApprovalSettings = removeApprovalSettings;
49
53
  const fs = __importStar(require("fs"));
50
54
  const path = __importStar(require("path"));
51
55
  const approve_hook_1 = require("../../approve-hook");
52
56
  const config_1 = require("../../config");
57
+ const bays_1 = require("../../bays");
58
+ Object.defineProperty(exports, "LAUNCH_MCP_ENTRY", { enumerable: true, get: function () { return bays_1.LAUNCH_MCP_ENTRY; } });
59
+ const detect_1 = require("../../channel/detect");
53
60
  const opt_in_1 = require("../../denied/opt-in");
54
61
  /** Always `"hook"` for now: Claude Code's channel permission relay is behind a flag that is off. */
55
62
  function approvalPathFor(_sessionName) {
@@ -76,6 +83,18 @@ exports.JOIN_ALLOW = [
76
83
  "mcp__baychat_channel__*",
77
84
  "mcp__plugin_baychat_channel__*",
78
85
  ];
86
+ /**
87
+ * The allowlist of a session launched by a relay on a setup of its own (relay-identity.ts,
88
+ * `--strict-mcp-config`): its own `baychat-relay` connection and the channel server in its own
89
+ * MCP file — and NOT `mcp__baychat__*`, the computer's user-wide production login (Windows QA
90
+ * 2026-10-16), nor the plugin's channel, which strict mode does not load.
91
+ */
92
+ exports.OWN_CONNECTION_ALLOW = [
93
+ "Bash(baychat skill:*)",
94
+ "Bash(baychat session-name:*)",
95
+ `mcp__${bays_1.LAUNCH_MCP_ENTRY}__*`,
96
+ "mcp__baychat_channel__*",
97
+ ];
79
98
  /** The PermissionRequest hook block, running approve-hook by absolute node + entry with `flags`. */
80
99
  function permissionHook(cli, flags) {
81
100
  return {
@@ -159,13 +178,14 @@ exports.UNATTENDED_SETTINGS = { skillOverrides: { "auto-mode-setup": "off" } };
159
178
  /**
160
179
  * The settings object for a session started from BayChat. Pure; the test pins its exact content.
161
180
  * `bayEntry`: the MCP connection the session joins on, when it is not the default `baychat`
162
- * (a computer logged in to several Bays) — its tools are join tools too.
181
+ * (a computer logged in to several Bays) — its tools are join tools too. `ownConnection`: a relay
182
+ * on a setup of its own (`OWN_CONNECTION_ALLOW`).
163
183
  */
164
- function approvalSettings(sessionName, cli, bayEntry) {
184
+ function approvalSettings(sessionName, cli, bayEntry, ownConnection = false) {
165
185
  const extra = bayEntry && bayEntry !== "baychat" ? [`mcp__${bayEntry}__*`] : [];
166
186
  return {
167
187
  ...exports.UNATTENDED_SETTINGS,
168
- permissions: { allow: [...exports.JOIN_ALLOW, ...extra] },
188
+ permissions: { allow: ownConnection ? [...exports.OWN_CONNECTION_ALLOW] : [...exports.JOIN_ALLOW, ...extra] },
169
189
  hooks: permissionHook(cli, ["--session", sessionName]),
170
190
  };
171
191
  }
@@ -197,11 +217,48 @@ function currentCliLaunch() {
197
217
  return { node: fs.realpathSync(process.execPath), entry: fs.realpathSync(process.argv[1] ?? "") };
198
218
  }
199
219
  /** Write the session's settings file (0600) and return its path. `sessionName` must already be validated. */
200
- function writeApprovalSettings(sessionName, cli, dir = launchDir(), bayEntry) {
220
+ function writeApprovalSettings(sessionName, cli, dir = launchDir(), bayEntry, ownConnection = false) {
201
221
  const file = approvalSettingsPath(sessionName, dir);
202
- writePrivateJson(dir, file, withOptIn(approvalSettings(sessionName, cli, bayEntry), cli, ["--session", sessionName]));
222
+ writePrivateJson(dir, file, withOptIn(approvalSettings(sessionName, cli, bayEntry, ownConnection), cli, ["--session", sessionName]));
223
+ return file;
224
+ }
225
+ function launchMcpConfigPath(sessionName, dir = launchDir()) {
226
+ return path.join(dir, `${sessionName}.mcp.json`);
227
+ }
228
+ /**
229
+ * The `--mcp-config` content. Pure. The session runs with `--strict-mcp-config`, so this is the
230
+ * WHOLE of its MCP: the remote BayChat server (as `claude mcp add --transport http` writes it)
231
+ * and, for a channel launch, the channel server itself — the user-wide `baychat_channel` entry
232
+ * (or the plugin's) no longer loads, and `--dangerously-load-development-channels
233
+ * server:baychat_channel` must name a server that does. Same command `baychat connect claude`
234
+ * registers (`node <cli> channel`), with the relay's location variables (no secrets).
235
+ */
236
+ function launchMcpConfig(server, channel) {
237
+ return {
238
+ mcpServers: {
239
+ [bays_1.LAUNCH_MCP_ENTRY]: {
240
+ type: "http",
241
+ url: `${server.baseUrl.replace(/\/+$/, "")}/api/mcp`,
242
+ headers: { Authorization: `Bearer ${server.token}` },
243
+ },
244
+ ...(channel ? { [detect_1.CHANNEL_MCP_ENTRY]: { type: "stdio", command: channel.cli.node, args: [channel.cli.entry, "channel"], env: channel.env } } : {}),
245
+ },
246
+ };
247
+ }
248
+ /**
249
+ * Write the session's own MCP config (0600 in the 0700 launch folder: it holds the Bay's token,
250
+ * like credentials.json) and return its path. Deleted with the settings file (`removeLaunchFiles`).
251
+ */
252
+ function writeLaunchMcpConfig(sessionName, server, dir = launchDir(), channel) {
253
+ const file = launchMcpConfigPath(sessionName, dir);
254
+ writePrivateJson(dir, file, launchMcpConfig(server, channel));
203
255
  return file;
204
256
  }
257
+ /** Delete every file a launch wrote for its session (settings, MCP config). Idempotent. */
258
+ function removeLaunchFiles(sessionName, dir = launchDir()) {
259
+ removeApprovalSettings(sessionName, dir);
260
+ fs.rmSync(launchMcpConfigPath(sessionName, dir), { force: true });
261
+ }
205
262
  /** Delete the session's settings file. Idempotent: an absent file is already deleted. */
206
263
  function removeApprovalSettings(sessionName, dir = launchDir()) {
207
264
  fs.rmSync(approvalSettingsPath(sessionName, dir), { force: true });
@@ -23,6 +23,7 @@ exports.roomTitleProblem = roomTitleProblem;
23
23
  * PATH into the pane are the same code on every path. What differs here is only that a launch is
24
24
  * DETACHED (`-d` / `-dm`) — nobody is at the terminal — and carries `--settings` for approvals.
25
25
  */
26
+ const detect_1 = require("../../channel/detect");
26
27
  const multiplexer_launch_1 = require("../../multiplexer-launch");
27
28
  /** Every launcher-made multiplexer session is named `bc-<session name>`. */
28
29
  exports.LAUNCH_SESSION_PREFIX = "bc-";
@@ -38,12 +39,19 @@ function multiplexerSessionName(sessionName) {
38
39
  exports.UNATTENDED_NOTE = "(Started from the BayChat app: nobody is at this keyboard. Never ask anything before you have joined; if you cannot join, say why in one line and stop.)";
39
40
  /**
40
41
  * The first message Claude Code receives: the BayChat skill, joining the chosen room — on the
41
- * named Bay connection when the computer has several, so the skill never asks which.
42
+ * named Bay connection when the computer has several, so the skill never asks which. With
43
+ * `ownConnection` (a relay on a setup of its own) the connection is named but no `--bay` is given:
44
+ * that session has one login, so there is no Bay to choose.
42
45
  */
43
- function baychatPrompt(sessionName, join, bayEntry) {
46
+ function baychatPrompt(sessionName, join, bayEntry, ownConnection = false, channel = false) {
44
47
  const room = !join ? "" : "sessions" in join ? " --sessions" : "direct" in join ? " --private" : ` --group "${join.group}"`;
45
- const bay = bayEntry ? ` --bay ${bayEntry}` : "";
46
- const connection = bayEntry ? ` Use only the ${bayEntry} connection (its mcp__${bayEntry}__ tools).` : "";
48
+ // An own connection (`baychat-relay`) is not a Bay: as `--bay` the session passed it on to the
49
+ // channel's listen, which failed (Mac QA 2026-10-16). It is only named as the connection to use.
50
+ const bay = bayEntry && !ownConnection ? ` --bay ${bayEntry}` : "";
51
+ // "Use only …" alone read as "not the channel either": the session went straight to
52
+ // listen_messages + Monitor (Mac QA 2026-10-16). Its channel is named as part of it.
53
+ const ownChannel = ownConnection && channel ? ` and its channel (call mcp__${detect_1.CHANNEL_MCP_ENTRY}__listen first, with session "${sessionName}")` : "";
54
+ const connection = bayEntry ? ` Use only the ${bayEntry} connection (its mcp__${bayEntry}__ tools)${ownChannel}.` : "";
47
55
  return `/baychat ${sessionName}${room}${bay} ${exports.UNATTENDED_NOTE}${connection}`;
48
56
  }
49
57
  function buildLaunchCommand(input) {
@@ -51,9 +59,15 @@ function buildLaunchCommand(input) {
51
59
  const args = (0, multiplexer_launch_1.multiplexerArgv)(input.multiplexer, { kind: "detached", name, cwd: input.folder }, {
52
60
  binary: input.claudeBin,
53
61
  args: [
62
+ // FIRST, and closed by a flag. Claude Code's `--mcp-config <configs...>` is VARIADIC: placed
63
+ // just before the prompt it took the `/baychat …` line as a second config path and claude
64
+ // exited at once (ENAMETOOLONG, Mac QA 2026-10-16). `--strict-mcp-config` both ends the list
65
+ // and keeps the computer's user-wide servers (the default login's `baychat`) out of the
66
+ // session: an own connection is only ever written for a relay on a setup of its own.
67
+ ...(input.mcpConfigFile ? ["--mcp-config", input.mcpConfigFile, "--strict-mcp-config"] : []),
54
68
  ...input.channelArgs,
55
69
  ...(input.settingsFile ? ["--settings", input.settingsFile] : []),
56
- baychatPrompt(input.sessionName, input.join, input.bayEntry),
70
+ baychatPrompt(input.sessionName, input.join, input.bayEntry, input.mcpConfigFile !== undefined, input.channelArgs.length > 0),
57
71
  ],
58
72
  env: input.env,
59
73
  });
@@ -2,9 +2,12 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.CONFIRM_DEADLINE_MS = exports.WARNING_POLL_MS = exports.WARNING_WATCH_MS = void 0;
4
4
  exports.handleLaunch = handleLaunch;
5
+ exports.forgetEndedLaunch = forgetEndedLaunch;
6
+ exports.forgetEndedLaunches = forgetEndedLaunches;
5
7
  exports.parseLaunch = parseLaunch;
6
8
  exports.launchBayEntry = launchBayEntry;
7
9
  const bays_1 = require("../../bays");
10
+ const detect_1 = require("../../channel/detect");
8
11
  const policy_1 = require("../../launch/policy");
9
12
  const argv_1 = require("./argv");
10
13
  const approval_settings_1 = require("./approval-settings");
@@ -75,32 +78,51 @@ function startSession(launch, deps) {
75
78
  const live = (0, multiplexer_1.liveLaunchSessions)(multiplexer, deps.run);
76
79
  const recordFile = (0, started_1.startedRecordPath)(deps.launchDir);
77
80
  const started = pruneEnded((0, started_1.loadStarted)(recordFile), live, deps);
78
- if (live.includes(launch.sessionName))
79
- return refused("NAME_TAKEN");
80
- if (live.length >= policy_1.MAX_SESSIONS_PER_COMPUTER)
81
- return refused("CAP_REACHED");
82
- if (started.filter((s) => s.spotId === spot.id).length >= spot.maxSessions)
81
+ if (live.includes(launch.sessionName)) {
82
+ // Said in relay.log (Windows QA 2026-10-16: refused with nothing logged). The app answers
83
+ // NAME_TAKEN with "Replace it", whose STOP reaches only a session this launcher started.
84
+ const own = started.some((s) => s.name === launch.sessionName);
85
+ deps.log(`launch ${launch.id}: refused, "${launch.sessionName}" is still running here (${multiplexer} session ${(0, argv_1.multiplexerSessionName)(launch.sessionName)}${own ? ", started by BayChat: Replace it can stop it" : ", not started by BayChat: stop it at this computer"})`);
86
+ // Not ours: Replace it could not stop it (its STOP is refused NOT_LAUNCHED), so say so with a
87
+ // code of its own and the app offers no Replace it (Mac QA 2026-10-16: a loop otherwise).
88
+ return refused(own ? "NAME_TAKEN" : "NAME_TAKEN_FOREIGN");
89
+ }
90
+ if (live.length >= policy_1.MAX_SESSIONS_PER_COMPUTER || started.filter((s) => s.spotId === spot.id).length >= spot.maxSessions) {
91
+ deps.log(`launch ${launch.id}: refused, too many sessions running (${live.length} here, the folder "${spot.label}" allows ${spot.maxSessions})`);
83
92
  return refused("CAP_REACHED");
93
+ }
84
94
  const binary = deps.resolveBinary(capabilities_1.RUNTIME_BINARY.claude);
85
95
  if (!binary.ok)
86
96
  return refused("RUNTIME_MISSING");
87
97
  // Monitor: no channel flag and no channel marker — the session listens with listen_messages + Monitor.
88
98
  const channel = launch.listen === "monitor" ? { args: [], env: {} } : deps.channelLaunch(binary.path);
89
- const settingsFile = (0, approval_settings_1.approvalPathFor)(launch.sessionName) === "hook" ? (0, approval_settings_1.writeApprovalSettings)(launch.sessionName, deps.cli, deps.launchDir, bay.entry) : undefined;
99
+ // A relay on a setup of its own gives the session its own connection to the relay's server,
100
+ // with the launch's Bay login, and names it in the /baychat line (relay-identity.ts).
101
+ // It runs with --strict-mcp-config, so a channel launch carries its channel server in that file
102
+ // and names exactly that one on the channel flag (approval-settings.ts `launchMcpConfig`).
103
+ const own = deps.ownServer ? { baseUrl: deps.ownServer.baseUrl, token: bay.token ?? deps.ownServer.token } : null;
104
+ const ownChannel = own && channel.args.length > 0;
105
+ const mcpConfigFile = own
106
+ ? (0, approval_settings_1.writeLaunchMcpConfig)(launch.sessionName, own, deps.launchDir, ownChannel ? { cli: deps.cli, env: deps.relayEnv ?? {} } : undefined)
107
+ : undefined;
108
+ const channelArgs = ownChannel ? [detect_1.DEV_CHANNELS_FLAG, detect_1.CHANNEL_FLAG_VALUE] : channel.args;
109
+ const bayEntry = own ? approval_settings_1.LAUNCH_MCP_ENTRY : bay.entry;
110
+ const settingsFile = (0, approval_settings_1.approvalPathFor)(launch.sessionName) === "hook" ? (0, approval_settings_1.writeApprovalSettings)(launch.sessionName, deps.cli, deps.launchDir, bayEntry, own !== null) : undefined;
90
111
  const command = (0, argv_1.buildLaunchCommand)({
91
112
  multiplexer,
92
113
  sessionName: launch.sessionName,
93
114
  folder: folder.path,
94
115
  claudeBin: binary.path,
95
- channelArgs: channel.args,
96
- env: { ...channel.env, PATH: deps.pathEnv },
116
+ channelArgs,
117
+ env: { ...(deps.relayEnv ?? {}), ...channel.env, PATH: deps.pathEnv },
97
118
  settingsFile,
119
+ mcpConfigFile,
98
120
  join: launch.join,
99
- bayEntry: bay.entry,
121
+ bayEntry,
100
122
  });
101
123
  const res = deps.run(command.file, command.args, { cwd: command.cwd, env: command.env });
102
124
  if (res.error || res.status !== 0) {
103
- (0, approval_settings_1.removeApprovalSettings)(launch.sessionName, deps.launchDir);
125
+ (0, approval_settings_1.removeLaunchFiles)(launch.sessionName, deps.launchDir);
104
126
  deps.log(`launch ${launch.id}: ${command.file} failed: ${res.error?.message ?? (res.stderr.trim() || `exit ${res.status}`)}`);
105
127
  return { status: "FAILED", reason: "SPAWN_FAILED" };
106
128
  }
@@ -230,11 +252,15 @@ function stopSession(launch, deps) {
230
252
  if (!entry)
231
253
  return refused("NOT_LAUNCHED");
232
254
  const outcome = stopRecorded(entry, deps);
233
- if (outcome === "not-running")
255
+ if (outcome === "not-running") {
256
+ deps.log(`launch ${launch.id}: "${entry.name}" was not running any more; its records are cleared`);
234
257
  return refused("NOT_RUNNING");
235
- if (outcome === "failed")
258
+ }
259
+ if (outcome === "failed") {
260
+ deps.log(`launch ${launch.id}: could not stop "${entry.name}": its ${entry.multiplexer} session is still there`);
236
261
  return { status: "FAILED", reason: "STOP_FAILED" };
237
- deps.log(`launch ${launch.id}: stopped "${entry.name}" for ${launch.requestedBy.name}`);
262
+ }
263
+ deps.log(`launch ${launch.id}: stopped "${entry.name}" for ${launch.requestedBy.name} — its ${entry.multiplexer} session is gone (checked)`);
238
264
  return { status: "STARTED" };
239
265
  }
240
266
  /**
@@ -243,27 +269,63 @@ function stopSession(launch, deps) {
243
269
  * the record, so a later STOP can try again.
244
270
  */
245
271
  function stopRecorded(entry, deps) {
246
- const recordFile = (0, started_1.startedRecordPath)(deps.launchDir);
247
272
  const forget = () => {
248
- (0, started_1.saveStarted)((0, started_1.loadStarted)(recordFile).filter((s) => s.name !== entry.name), recordFile);
249
- (0, approval_settings_1.removeApprovalSettings)(entry.name, deps.launchDir);
273
+ forgetStarted(entry.name, deps.launchDir);
274
+ deps.forgetSession?.(entry.name);
250
275
  };
251
- if (!(0, multiplexer_1.liveLaunchSessions)(entry.multiplexer, deps.run).includes(entry.name)) {
276
+ if (!(0, multiplexer_1.launchSessionExists)(entry.multiplexer, entry.name, deps.run)) {
252
277
  forget();
253
278
  return "not-running";
254
279
  }
255
- if (!(0, multiplexer_1.killLaunchSession)(entry.multiplexer, entry.name, deps.run))
280
+ // The kill's own answer is not the proof: the session must be GONE (2026-10-16, mac_motion — a
281
+ // STOP reported "closed" and the owner still found it). A session still there is STOP_FAILED and
282
+ // stays recorded, so a later STOP can try again.
283
+ (0, multiplexer_1.killLaunchSession)(entry.multiplexer, entry.name, deps.run);
284
+ if ((0, multiplexer_1.launchSessionExists)(entry.multiplexer, entry.name, deps.run))
256
285
  return "failed";
257
286
  forget();
258
287
  return "stopped";
259
288
  }
289
+ /** Drop one name from started.json and delete its files (settings, MCP config). */
290
+ function forgetStarted(name, dir) {
291
+ const recordFile = (0, started_1.startedRecordPath)(dir);
292
+ (0, started_1.saveStarted)((0, started_1.loadStarted)(recordFile).filter((s) => s.name !== name), recordFile);
293
+ (0, approval_settings_1.removeLaunchFiles)(name, dir);
294
+ }
295
+ /**
296
+ * Forget a launch whose launcher session has ENDED on its own (the terminal closed, claude exited):
297
+ * drop it from started.json. One still running is never touched. True when one was forgotten.
298
+ * The relay calls this when a channel session it knew is found dead.
299
+ */
300
+ function forgetEndedLaunch(name, deps) {
301
+ const entry = (0, started_1.loadStarted)((0, started_1.startedRecordPath)(deps.launchDir)).find((s) => s.name === name);
302
+ if (!entry || (0, multiplexer_1.launchSessionExists)(entry.multiplexer, entry.name, deps.run))
303
+ return false;
304
+ forgetStarted(name, deps.launchDir);
305
+ return true;
306
+ }
307
+ /**
308
+ * Forget EVERY launch whose launcher session has ended on its own — a claude killed by hand, a
309
+ * terminal closed — and return what was forgotten. The relay runs this on its session polls, so a
310
+ * dead launch does not sit in started.json until the next START happens to prune it (2026-10-16,
311
+ * the Mac: `qa_kill`'s tmux session ended, its channel had never registered with that relay, so
312
+ * the channel sweep never saw it, and it stayed listed for minutes). A session whose state cannot
313
+ * be read is kept: forgetting a live one would let its name be started twice.
314
+ */
315
+ function forgetEndedLaunches(deps) {
316
+ const started = (0, started_1.loadStarted)((0, started_1.startedRecordPath)(deps.launchDir));
317
+ const ended = started.filter((s) => (0, multiplexer_1.launchSessionState)(s.multiplexer, s.name, deps.run) === "ended");
318
+ for (const gone of ended)
319
+ forgetStarted(gone.name, deps.launchDir);
320
+ return ended;
321
+ }
260
322
  /** Forget sessions that ended on their own (and delete their settings files); return the rest. */
261
323
  function pruneEnded(started, live, deps) {
262
324
  const alive = started.filter((s) => live.includes(s.name));
263
325
  if (alive.length === started.length)
264
326
  return started;
265
327
  for (const gone of started.filter((s) => !alive.includes(s)))
266
- (0, approval_settings_1.removeApprovalSettings)(gone.name, deps.launchDir);
328
+ (0, approval_settings_1.removeLaunchFiles)(gone.name, deps.launchDir);
267
329
  (0, started_1.saveStarted)(alive, (0, started_1.startedRecordPath)(deps.launchDir));
268
330
  return alive;
269
331
  }
@@ -302,7 +364,8 @@ function parseLaunch(raw) {
302
364
  /**
303
365
  * Which of this computer's Bay connections a launch joins on. `entry` is the MCP connection to
304
366
  * name in the `/baychat` line — undefined when there is only one, so there is nothing to choose
305
- * (a one-Bay computer's line stays exactly as before). Null: the launch's Bay has no connection
367
+ * (a one-Bay computer's line stays exactly as before). `token` is that Bay's login, for a relay
368
+ * that gives the session a connection of its own (relay-identity.ts); absent with no saved set. Null: the launch's Bay has no connection
306
369
  * here, and the session must not be started into some other Bay.
307
370
  *
308
371
  * The launcher runs in the DEFAULT Bay's relay, so a launch normally names that Bay; an older
@@ -316,8 +379,8 @@ function launchBayEntry(bay, bays) {
316
379
  if (!found)
317
380
  return null;
318
381
  if (bays.length < 2)
319
- return { entry: undefined };
320
- return { entry: found.isDefault ? bays_1.DEFAULT_MCP_ENTRY : (0, bays_1.bayEntryName)(found, bays) };
382
+ return { entry: undefined, token: found.token };
383
+ return { entry: found.isDefault ? bays_1.DEFAULT_MCP_ENTRY : (0, bays_1.bayEntryName)(found, bays), token: found.token };
321
384
  }
322
385
  /** `null` is "join nothing"; undefined is a join that is present but malformed. */
323
386
  function parseJoin(raw) {
@@ -45,6 +45,7 @@ const runtime_binary_1 = require("../../runtime-binary");
45
45
  const spawn_env_1 = require("../spawn-env");
46
46
  const approval_settings_1 = require("./approval-settings");
47
47
  const capabilities_1 = require("./capabilities");
48
+ const relay_identity_1 = require("./relay-identity");
48
49
  const handle_1 = require("./handle");
49
50
  const multiplexer_1 = require("./multiplexer");
50
51
  /**
@@ -66,8 +67,8 @@ const BACKOFF_MAX_MS = 60_000;
66
67
  const CAPABILITIES_REFRESH_MS = 10 * 60_000;
67
68
  /** A long-poll holds for WAIT_SECONDS; give the request this much beyond it before calling it dead. */
68
69
  const REQUEST_GRACE_MS = 15_000;
69
- async function startLauncher({ auth, log, signal, onLaunch, deps: overrides }) {
70
- const deps = { ...realLoopDeps(log, signal), ...overrides };
70
+ async function startLauncher({ auth, log, signal, onLaunch, forgetSession, deps: overrides }) {
71
+ const deps = { ...realLoopDeps(auth, log, signal, forgetSession), ...overrides };
71
72
  let advertised;
72
73
  let unreported;
73
74
  let backoff = BACKOFF_MIN_MS;
@@ -146,7 +147,7 @@ function idOf(raw) {
146
147
  function describe(err) {
147
148
  return err instanceof Error ? err.message : String(err);
148
149
  }
149
- function realLoopDeps(log, signal) {
150
+ function realLoopDeps(auth, log, signal, forgetSession) {
150
151
  const binaryEnv = () => (0, runtime_binary_1.currentBinaryEnv)();
151
152
  const handleDeps = () => ({
152
153
  loadPolicy: () => (0, policy_1.loadLaunchPolicy)(),
@@ -166,8 +167,12 @@ function realLoopDeps(log, signal) {
166
167
  cli: (0, approval_settings_1.currentCliLaunch)(),
167
168
  launchDir: (0, approval_settings_1.launchDir)(),
168
169
  pathEnv: (0, spawn_env_1.headlessSpawnEnv)().PATH ?? "",
170
+ // The session talks to the same server and account as this relay (relay-identity.ts).
171
+ relayEnv: (0, relay_identity_1.relayIdentityEnv)(),
172
+ ownServer: (0, relay_identity_1.relayHasOwnSetup)() ? { baseUrl: auth.baseUrl, token: auth.token } : null,
169
173
  now: () => new Date(),
170
174
  log,
175
+ forgetSession,
171
176
  });
172
177
  return {
173
178
  request: exports.deviceRequest,
@@ -37,6 +37,8 @@ exports.realRun = exports.WITHHELD_FROM_AGENTS = void 0;
37
37
  exports.agentSafeEnv = agentSafeEnv;
38
38
  exports.detectMultiplexer = detectMultiplexer;
39
39
  exports.liveLaunchSessions = liveLaunchSessions;
40
+ exports.launchSessionExists = launchSessionExists;
41
+ exports.launchSessionState = launchSessionState;
40
42
  exports.killLaunchSession = killLaunchSession;
41
43
  exports.tmuxPaneTarget = tmuxPaneTarget;
42
44
  exports.capturePane = capturePane;
@@ -105,7 +107,36 @@ function liveLaunchSessions(multiplexer, run) {
105
107
  const names = res.stdout.split("\n").map((l) => /^\s*\d+\.(\S+)\s/.exec(l)?.[1] ?? "");
106
108
  return withoutPrefix(names);
107
109
  }
108
- /** Kill the launcher session for `sessionName`. True when the multiplexer said it did. */
110
+ /**
111
+ * Is the launcher session for `sessionName` still there? tmux: `has-session -t =bc-<name>` (exact
112
+ * name), the one question that cannot be answered by a stale listing. screen: its listing.
113
+ */
114
+ function launchSessionExists(multiplexer, sessionName, run) {
115
+ return launchSessionState(multiplexer, sessionName, run) === "running";
116
+ }
117
+ /**
118
+ * `launchSessionExists`, for a caller that must not act on a guess: "unknown" when the
119
+ * multiplexer could not be asked at all (not on PATH, would not spawn), so a sweep never
120
+ * forgets a live session because tmux was briefly unreachable. tmux answers non-zero both for
121
+ * "no such session" and "no server running" — either way there is no `bc-<name>`.
122
+ */
123
+ function launchSessionState(multiplexer, sessionName, run) {
124
+ if (multiplexer === "tmux") {
125
+ const res = run("tmux", ["has-session", "-t", `=${(0, argv_1.multiplexerSessionName)(sessionName)}`]);
126
+ if (res.error)
127
+ return "unknown";
128
+ return res.status === 0 ? "running" : "ended";
129
+ }
130
+ // `screen -ls` exits 1 even when listing, so only a failure to run it is "unknown".
131
+ if (run("screen", ["-ls"]).error)
132
+ return "unknown";
133
+ return liveLaunchSessions(multiplexer, run).includes(sessionName) ? "running" : "ended";
134
+ }
135
+ /**
136
+ * Kill the launcher session for `sessionName` — the WHOLE `bc-<name>` session, every window and
137
+ * pane in it, never just the pane or the claude process. True when the multiplexer said it did;
138
+ * the caller confirms it is gone (`launchSessionExists`).
139
+ */
109
140
  function killLaunchSession(multiplexer, sessionName, run) {
110
141
  const target = (0, argv_1.multiplexerSessionName)(sessionName);
111
142
  const res = multiplexer === "tmux"
@@ -0,0 +1,41 @@
1
+ "use strict";
2
+ /**
3
+ * WHICH BayChat a launched session talks to: the same one as the relay that launched it.
4
+ *
5
+ * A relay can run on a setup of its own — `BAYCHAT_CONFIG_DIR` (another login, another account)
6
+ * and/or `BAYCHAT_API_URL` (another server). The Claude Code it launches used to know none of
7
+ * it: the `env K=V … claude` exec carried only the channel marker and PATH, and Claude Code's
8
+ * BayChat connection is the computer's user-wide `baychat` MCP server (`claude mcp add --scope
9
+ * user`, the DEFAULT login). So a session launched by a test relay joined the production Bay
10
+ * (2026-10-16). Two halves fix it:
11
+ *
12
+ * 1. `relayIdentityEnv` — the relay's own location variables ride into the pane, so every
13
+ * `baychat` the session runs (the skill, `session-name`, the approve and denied hooks, the
14
+ * channel server) reads the relay's config, server and socket. NEVER a token: the `env
15
+ * K=V` exec puts these in the multiplexer's argv, which any process on the machine can read.
16
+ * 2. `relayHasOwnSetup` — when the relay is on a setup of its own, the session also gets a
17
+ * connection of its own (`--mcp-config`, approval-settings.ts `writeLaunchMcpConfig`) and
18
+ * is told to join on it, never on the user-wide `baychat` that belongs to another login.
19
+ *
20
+ * A relay on the computer's ordinary setup (neither variable set — how `baychat relay` and its
21
+ * autostart run) launches exactly as before.
22
+ */
23
+ Object.defineProperty(exports, "__esModule", { value: true });
24
+ exports.relayIdentityEnv = relayIdentityEnv;
25
+ exports.relayHasOwnSetup = relayHasOwnSetup;
26
+ /** The variables that say where a relay's config, server, socket and mailbox are. No secrets. */
27
+ const RELAY_LOCATION_VARS = ["BAYCHAT_CONFIG_DIR", "BAYCHAT_API_URL", "BAYCHAT_RELAY_SOCKET", "BAYCHAT_MAILBOX_DIR"];
28
+ /** The relay's location variables that are set, to hand to a launched pane. */
29
+ function relayIdentityEnv(env = process.env) {
30
+ const out = {};
31
+ for (const name of RELAY_LOCATION_VARS) {
32
+ const value = env[name];
33
+ if (value)
34
+ out[name] = value;
35
+ }
36
+ return out;
37
+ }
38
+ /** True when the relay runs on another login or server than the computer's default one. */
39
+ function relayHasOwnSetup(env = process.env) {
40
+ return Boolean(env.BAYCHAT_CONFIG_DIR || env.BAYCHAT_API_URL);
41
+ }
@@ -0,0 +1,11 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.relayLogLine = relayLogLine;
4
+ /**
5
+ * One relay.log line: an ISO timestamp (UTC), the `[relay]` tag, the message. relay.log is read
6
+ * after the fact, against the server's own UTC log lines; without a time on each line a STOP or a
7
+ * registration could not be placed (2026-10-16, mac_motion).
8
+ */
9
+ function relayLogLine(line, now = new Date()) {
10
+ return `${now.toISOString()} [relay] ${line}`;
11
+ }
@@ -139,6 +139,13 @@ class SessionRegistry {
139
139
  get(name) {
140
140
  return this.sessions.get(name);
141
141
  }
142
+ /** Forget one session outright — it was stopped, or found dead. True when it was known. */
143
+ remove(name) {
144
+ const known = this.sessions.delete(name);
145
+ if (known)
146
+ this.save();
147
+ return known;
148
+ }
142
149
  /**
143
150
  * Forget how to resume this session — what `/clear` means to the headless path.
144
151
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "baychat",
3
- "version": "0.31.0",
3
+ "version": "0.32.1",
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"