baychat 0.32.0 → 0.32.2
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/.claude-plugin/plugin.json +1 -1
- package/dist/bays.js +23 -1
- package/dist/channel/feed.js +22 -0
- package/dist/channel/server.js +10 -5
- package/dist/protocol-content.js +1 -1
- package/dist/relay/bay-relays.js +2 -1
- package/dist/relay/commands.js +2 -1
- package/dist/relay/daemon.js +61 -1
- package/dist/relay/launcher/approval-settings.js +63 -6
- package/dist/relay/launcher/argv.js +19 -5
- package/dist/relay/launcher/handle.js +85 -22
- package/dist/relay/launcher/loop.js +8 -3
- package/dist/relay/launcher/multiplexer.js +32 -1
- package/dist/relay/launcher/relay-identity.js +41 -0
- package/dist/relay/log-line.js +11 -0
- package/dist/relay/registry.js +7 -0
- package/dist/relay/terminal-pane.js +10 -1
- package/package.json +1 -1
package/dist/relay/daemon.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
(
|
|
249
|
-
|
|
273
|
+
forgetStarted(entry.name, deps.launchDir);
|
|
274
|
+
deps.forgetSession?.(entry.name);
|
|
250
275
|
};
|
|
251
|
-
if (!(0, multiplexer_1.
|
|
276
|
+
if (!(0, multiplexer_1.launchSessionExists)(entry.multiplexer, entry.name, deps.run)) {
|
|
252
277
|
forget();
|
|
253
278
|
return "not-running";
|
|
254
279
|
}
|
|
255
|
-
|
|
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.
|
|
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).
|
|
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
|
-
/**
|
|
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
|
+
}
|
package/dist/relay/registry.js
CHANGED
|
@@ -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
|
*
|