baychat 0.27.1 → 0.29.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.
Files changed (53) hide show
  1. package/.claude-plugin/plugin.json +32 -0
  2. package/README.md +42 -4
  3. package/dist/approve-hook.js +93 -3
  4. package/dist/channel/claude-link.js +145 -0
  5. package/dist/channel/detect.js +10 -2
  6. package/dist/channel/feed.js +27 -13
  7. package/dist/channel/launch.js +67 -12
  8. package/dist/channel/permission.js +118 -0
  9. package/dist/channel/plugin.js +182 -0
  10. package/dist/channel/relay-link.js +257 -0
  11. package/dist/channel/server.js +191 -10
  12. package/dist/claude-onboarding.js +7 -5
  13. package/dist/cli-help.js +235 -0
  14. package/dist/commands.js +4 -4
  15. package/dist/connect-acp.js +3 -2
  16. package/dist/connect-claude.js +31 -10
  17. package/dist/device-login.js +2 -2
  18. package/dist/doctor-command.js +2 -0
  19. package/dist/doctor.js +97 -3
  20. package/dist/help-topics.js +4 -3
  21. package/dist/launch/claude-trust.js +221 -0
  22. package/dist/launch/command.js +177 -0
  23. package/dist/launch/policy.js +152 -0
  24. package/dist/machine-name.js +76 -0
  25. package/dist/main.js +16 -170
  26. package/dist/multiplexer-launch.js +271 -0
  27. package/dist/protocol-content.js +1 -1
  28. package/dist/relay/acp/agents.js +42 -19
  29. package/dist/relay/acp/daemon-glue.js +3 -2
  30. package/dist/relay/acp/runner.js +8 -7
  31. package/dist/relay/channel-sessions.js +66 -0
  32. package/dist/relay/commands.js +9 -2
  33. package/dist/relay/daemon.js +160 -9
  34. package/dist/relay/launcher/approval-settings.js +140 -0
  35. package/dist/relay/launcher/argv.js +83 -0
  36. package/dist/relay/launcher/capabilities.js +76 -0
  37. package/dist/relay/launcher/dev-channel-screen.fixtures.js +41 -0
  38. package/dist/relay/launcher/dev-channel-screen.js +47 -0
  39. package/dist/relay/launcher/handle.js +303 -0
  40. package/dist/relay/launcher/loop.js +231 -0
  41. package/dist/relay/launcher/multiplexer.js +168 -0
  42. package/dist/relay/launcher/started.js +63 -0
  43. package/dist/relay/profiles.js +1 -1
  44. package/dist/relay/registry.js +26 -0
  45. package/dist/relay/terminal-pane.js +31 -2
  46. package/dist/relay/transport.js +7 -2
  47. package/dist/relay/ws.js +18 -1
  48. package/dist/runtime-install.js +19 -3
  49. package/dist/runtimes.js +3 -0
  50. package/dist/skill-bootstrap.js +1 -1
  51. package/dist/start-command.js +12 -56
  52. package/dist/tool-defs.js +23 -36
  53. package/package.json +4 -3
@@ -0,0 +1,231 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.deviceRequest = void 0;
37
+ exports.startLauncher = startLauncher;
38
+ const fs = __importStar(require("fs"));
39
+ const api_1 = require("../../api");
40
+ const launch_1 = require("../../channel/launch");
41
+ const claude_trust_1 = require("../../launch/claude-trust");
42
+ const policy_1 = require("../../launch/policy");
43
+ const runtime_binary_1 = require("../../runtime-binary");
44
+ const spawn_env_1 = require("../spawn-env");
45
+ const approval_settings_1 = require("./approval-settings");
46
+ const capabilities_1 = require("./capabilities");
47
+ const handle_1 = require("./handle");
48
+ const multiplexer_1 = require("./multiplexer");
49
+ /**
50
+ * The launcher: this computer's side of "Start a session from BayChat".
51
+ *
52
+ * It tells BayChat what it can start (`PUT /launch-capabilities`, again whenever the policy file
53
+ * changes and every few minutes in case a runtime was installed), then holds
54
+ * `GET /launches/next?wait=25` — which doubles as the "this computer is online" heartbeat — and
55
+ * carries out each launch, ONE AT A TIME: the next poll is not made until the previous launch has
56
+ * been handled and its report delivered, so two launches can never race past the caps.
57
+ *
58
+ * Network trouble is never fatal: every failure is logged and retried with a growing pause, and
59
+ * only `signal` ends the loop. Self-contained so the daemon wires it with one call.
60
+ */
61
+ const WAIT_SECONDS = 25;
62
+ const BACKOFF_MIN_MS = 1_000;
63
+ const BACKOFF_MAX_MS = 60_000;
64
+ /** Re-advertise this often even when the policy has not changed (a runtime may have been installed). */
65
+ const CAPABILITIES_REFRESH_MS = 10 * 60_000;
66
+ /** A long-poll holds for WAIT_SECONDS; give the request this much beyond it before calling it dead. */
67
+ const REQUEST_GRACE_MS = 15_000;
68
+ async function startLauncher({ auth, log, signal, deps: overrides }) {
69
+ const deps = { ...realLoopDeps(log, signal), ...overrides };
70
+ let advertised;
71
+ let unreported;
72
+ let backoff = BACKOFF_MIN_MS;
73
+ while (!signal.aborted) {
74
+ try {
75
+ if (unreported) {
76
+ await deps.request(auth, "POST", `/api/device-api/launches/${encodeURIComponent(unreported.id)}/report`, unreported.report, signal);
77
+ unreported = undefined;
78
+ }
79
+ const stamp = deps.policyStamp();
80
+ if (!advertised || advertised.stamp !== stamp || deps.now() - advertised.at >= CAPABILITIES_REFRESH_MS) {
81
+ await deps.request(auth, "PUT", "/api/device-api/launch-capabilities", deps.capabilities(), signal);
82
+ advertised = { stamp, at: deps.now() };
83
+ }
84
+ const answer = (await deps.request(auth, "GET", `/api/device-api/launches/next?wait=${WAIT_SECONDS}`, undefined, signal));
85
+ const raw = answer?.launch;
86
+ if (raw) {
87
+ const launch = (0, handle_1.parseLaunch)(raw);
88
+ const id = launch?.id ?? idOf(raw);
89
+ if (!id) {
90
+ log("launcher: ignored a launch with no id");
91
+ }
92
+ else {
93
+ // Kept until delivered: a report lost to the network is sent before the next poll.
94
+ const io = launchIo(deps, auth, id, signal);
95
+ unreported = { id, report: launch ? await safeHandle(deps, launch, io, log) : { status: "REFUSED", reason: "BAD_REQUEST" } };
96
+ await deps.request(auth, "POST", `/api/device-api/launches/${encodeURIComponent(id)}/report`, unreported.report, signal);
97
+ unreported = undefined;
98
+ }
99
+ }
100
+ backoff = BACKOFF_MIN_MS;
101
+ }
102
+ catch (err) {
103
+ if (signal.aborted)
104
+ return;
105
+ log(`launcher: ${describe(err)}; retrying in ${Math.round(backoff / 1000)}s`);
106
+ await deps.sleep(backoff, signal);
107
+ backoff = Math.min(backoff * 2, BACKOFF_MAX_MS);
108
+ }
109
+ }
110
+ }
111
+ /** A bug in handling one launch must report FAILED, not spin the loop on the same launch. */
112
+ async function safeHandle(deps, launch, io, log) {
113
+ try {
114
+ return await deps.handle(launch, io);
115
+ }
116
+ catch (err) {
117
+ log(`launch ${launch.id}: ${describe(err)}`);
118
+ return { status: "FAILED", reason: "LAUNCHER_ERROR" };
119
+ }
120
+ }
121
+ /** The interim report and the confirm long-poll for launch `id`, over the device API. */
122
+ function launchIo(deps, auth, id, signal) {
123
+ const base = `/api/device-api/launches/${encodeURIComponent(id)}`;
124
+ return {
125
+ report: async (report) => {
126
+ await deps.request(auth, "POST", `${base}/report`, report, signal);
127
+ },
128
+ pollDecision: async () => parseConfirm(await deps.request(auth, "GET", `${base}/confirm?wait=${WAIT_SECONDS}`, undefined, signal)),
129
+ signal,
130
+ };
131
+ }
132
+ /** Only an explicit "allow" allows; anything odd reads as "no decision yet". */
133
+ function parseConfirm(raw) {
134
+ const answer = (raw ?? {});
135
+ const decision = answer.decision === "allow" || answer.decision === "deny" ? answer.decision : null;
136
+ return { decision, reason: typeof answer.reason === "string" ? answer.reason : null };
137
+ }
138
+ function idOf(raw) {
139
+ const id = raw.id;
140
+ return typeof id === "string" ? id : undefined;
141
+ }
142
+ function describe(err) {
143
+ return err instanceof Error ? err.message : String(err);
144
+ }
145
+ function realLoopDeps(log, signal) {
146
+ const binaryEnv = () => (0, runtime_binary_1.currentBinaryEnv)();
147
+ const handleDeps = () => ({
148
+ loadPolicy: () => (0, policy_1.loadLaunchPolicy)(),
149
+ resolveFolder: policy_1.resolveSpotFolder,
150
+ isFolderTrusted: (folder) => (0, claude_trust_1.isFolderTrusted)(folder),
151
+ resolveBinary: (name) => (0, runtime_binary_1.resolveRuntimeBinary)(name, binaryEnv()),
152
+ channelLaunch: (claudePath) => {
153
+ const launch = (0, launch_1.channelLaunchFor)(claudePath);
154
+ return { args: launch.args, env: launch.env };
155
+ },
156
+ detectMultiplexer: () => (0, multiplexer_1.detectMultiplexer)(multiplexer_1.realRun),
157
+ run: multiplexer_1.realRun,
158
+ capture: (multiplexer, name) => (0, multiplexer_1.capturePane)(multiplexer, name, multiplexer_1.realRun, { dir: (0, approval_settings_1.launchDir)() }),
159
+ pressEnter: (multiplexer, name) => (0, multiplexer_1.pressEnter)(multiplexer, name, multiplexer_1.realRun),
160
+ sleep: (ms) => sleepUnlessAborted(ms, signal),
161
+ cli: (0, approval_settings_1.currentCliLaunch)(),
162
+ launchDir: (0, approval_settings_1.launchDir)(),
163
+ pathEnv: (0, spawn_env_1.headlessSpawnEnv)().PATH ?? "",
164
+ now: () => new Date(),
165
+ log,
166
+ });
167
+ return {
168
+ request: exports.deviceRequest,
169
+ policyStamp: () => {
170
+ try {
171
+ const st = fs.statSync((0, policy_1.launchPolicyPath)());
172
+ return `${st.mtimeMs}:${st.size}`;
173
+ }
174
+ catch {
175
+ return "absent";
176
+ }
177
+ },
178
+ capabilities: () => (0, capabilities_1.buildCapabilities)((0, policy_1.loadLaunchPolicy)(), {
179
+ resolveBinary: (name) => (0, runtime_binary_1.resolveRuntimeBinary)(name, binaryEnv()),
180
+ multiplexer: (0, multiplexer_1.detectMultiplexer)(multiplexer_1.realRun),
181
+ }),
182
+ handle: (launch, io) => (0, handle_1.handleLaunch)(launch, handleDeps(), io),
183
+ sleep: sleepUnlessAborted,
184
+ now: () => Date.now(),
185
+ };
186
+ }
187
+ /**
188
+ * The device API, with an abort signal and a deadline. Unlike `apiRequest` it accepts the empty
189
+ * `204` the capabilities PUT and the report answer with.
190
+ */
191
+ const deviceRequest = async (auth, method, apiPath, body, signal) => {
192
+ const res = await fetch(`${auth.baseUrl}${apiPath}`, {
193
+ method,
194
+ headers: { Authorization: `Bearer ${auth.token}`, "Content-Type": "application/json" },
195
+ body: body === undefined ? undefined : JSON.stringify(body),
196
+ signal: AbortSignal.any([signal, AbortSignal.timeout(WAIT_SECONDS * 1000 + REQUEST_GRACE_MS)]),
197
+ });
198
+ const text = await res.text();
199
+ if (!res.ok) {
200
+ let code;
201
+ try {
202
+ code = JSON.parse(text).code;
203
+ }
204
+ catch {
205
+ // Not JSON: the status alone says enough, and a response body is never echoed into logs.
206
+ }
207
+ throw new api_1.ApiError(res.status, `${method} ${apiPath.split("?")[0]} answered HTTP ${res.status}${code ? ` ${code}` : ""}`, code);
208
+ }
209
+ if (!text)
210
+ return undefined;
211
+ try {
212
+ return JSON.parse(text);
213
+ }
214
+ catch {
215
+ throw new api_1.ApiError(res.status, `${method} ${apiPath.split("?")[0]} answered with something that is not JSON`);
216
+ }
217
+ };
218
+ exports.deviceRequest = deviceRequest;
219
+ function sleepUnlessAborted(ms, signal) {
220
+ return new Promise((resolve) => {
221
+ if (signal.aborted)
222
+ return resolve();
223
+ const timer = setTimeout(done, ms);
224
+ function done() {
225
+ clearTimeout(timer);
226
+ signal.removeEventListener("abort", done);
227
+ resolve();
228
+ }
229
+ signal.addEventListener("abort", done, { once: true });
230
+ });
231
+ }
@@ -0,0 +1,168 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.realRun = exports.WITHHELD_FROM_AGENTS = void 0;
37
+ exports.agentSafeEnv = agentSafeEnv;
38
+ exports.detectMultiplexer = detectMultiplexer;
39
+ exports.liveLaunchSessions = liveLaunchSessions;
40
+ exports.killLaunchSession = killLaunchSession;
41
+ exports.tmuxPaneTarget = tmuxPaneTarget;
42
+ exports.capturePane = capturePane;
43
+ exports.pressEnter = pressEnter;
44
+ const child_process_1 = require("child_process");
45
+ const fs = __importStar(require("fs"));
46
+ const path = __importStar(require("path"));
47
+ const argv_1 = require("./argv");
48
+ const RUN_TIMEOUT_MS = 10_000;
49
+ /**
50
+ * Variables the relay may hold that must never reach a launched agent. A tmux or screen server the
51
+ * launcher starts inherits this process's environment, and every pane in it inherits the server's:
52
+ * the device token is this computer's login, and an agent (or anything it runs) must not be able to
53
+ * read it from its own environment. Found in the 2026-10-05 live run, where a test relay's
54
+ * BAYCHAT_DEVICE_TOKEN showed up in the launched session's channel process.
55
+ */
56
+ exports.WITHHELD_FROM_AGENTS = ["BAYCHAT_DEVICE_TOKEN", "BAYCHAT_TOKEN"];
57
+ function agentSafeEnv(env) {
58
+ const out = { ...env };
59
+ for (const key of exports.WITHHELD_FROM_AGENTS)
60
+ delete out[key];
61
+ return out;
62
+ }
63
+ const realRun = (file, args, opts) => {
64
+ const res = (0, child_process_1.spawnSync)(file, args, {
65
+ shell: false,
66
+ encoding: "utf8",
67
+ timeout: RUN_TIMEOUT_MS,
68
+ stdio: ["ignore", "pipe", "pipe"],
69
+ cwd: opts?.cwd,
70
+ env: agentSafeEnv(opts?.env ? { ...process.env, ...opts.env } : process.env),
71
+ });
72
+ return { status: res.status, stdout: String(res.stdout ?? ""), stderr: String(res.stderr ?? ""), error: res.error };
73
+ };
74
+ exports.realRun = realRun;
75
+ /**
76
+ * The multiplexer a launch would use: tmux, else screen, else null.
77
+ *
78
+ * Probed by RUNNING it, as `baychat start` does. screen is matched on its banner, not its exit
79
+ * status: GNU screen 4.00 prints "Screen version …" for `-v` and exits 1 (measured on macOS).
80
+ */
81
+ function detectMultiplexer(run) {
82
+ const tmux = run("tmux", ["-V"]);
83
+ if (!tmux.error && tmux.status === 0)
84
+ return "tmux";
85
+ const screen = run("screen", ["-v"]);
86
+ if (!screen.error && /screen version/i.test(`${screen.stdout}${screen.stderr}`))
87
+ return "screen";
88
+ return null;
89
+ }
90
+ /**
91
+ * The session names (without `bc-`) of every launcher-made session alive in `multiplexer`.
92
+ * No server running means none.
93
+ */
94
+ function liveLaunchSessions(multiplexer, run) {
95
+ if (multiplexer === "tmux") {
96
+ const res = run("tmux", ["ls", "-F", "#{session_name}"]);
97
+ if (res.error || res.status !== 0)
98
+ return [];
99
+ return withoutPrefix(res.stdout.split("\n").map((l) => l.trim()));
100
+ }
101
+ // `screen -ls` lines look like "\t12345.bc-name\t(Detached)"; it exits 1 even when listing.
102
+ const res = run("screen", ["-ls"]);
103
+ if (res.error)
104
+ return [];
105
+ const names = res.stdout.split("\n").map((l) => /^\s*\d+\.(\S+)\s/.exec(l)?.[1] ?? "");
106
+ return withoutPrefix(names);
107
+ }
108
+ /** Kill the launcher session for `sessionName`. True when the multiplexer said it did. */
109
+ function killLaunchSession(multiplexer, sessionName, run) {
110
+ const target = (0, argv_1.multiplexerSessionName)(sessionName);
111
+ const res = multiplexer === "tmux"
112
+ ? run("tmux", ["kill-session", "-t", `=${target}`]) // `=`: exact name, never a prefix match
113
+ : run("screen", ["-S", target, "-X", "quit"]);
114
+ return !res.error && res.status === 0;
115
+ }
116
+ /**
117
+ * The tmux target for the launcher's pane of `sessionName`. Pane commands (capture-pane,
118
+ * send-keys) need the trailing colon: tmux 3.7c answers "can't find pane: =bc-x" for the bare
119
+ * form, while `=bc-x:` resolves that session's active pane, and `=` refuses a prefix match
120
+ * (`=bc-x:` never reaches `bc-x2`). Verified on macOS with a throwaway session, 2026-10-05.
121
+ */
122
+ function tmuxPaneTarget(sessionName) {
123
+ return `=${(0, argv_1.multiplexerSessionName)(sessionName)}:`;
124
+ }
125
+ const realHardcopyFs = {
126
+ readFile: (file) => fs.readFileSync(file, "utf8"),
127
+ remove: (file) => fs.rmSync(file, { force: true }),
128
+ };
129
+ /**
130
+ * What the launcher's pane shows right now, as text; null when it cannot be read (the session is
131
+ * gone, the multiplexer failed). Read-only.
132
+ */
133
+ function capturePane(multiplexer, sessionName, run, opts) {
134
+ if (multiplexer === "tmux") {
135
+ // -J joins wrapped lines, so a phrase is never split across two lines by a narrow pane.
136
+ const res = run("tmux", ["capture-pane", "-p", "-J", "-t", tmuxPaneTarget(sessionName)]);
137
+ return res.error || res.status !== 0 ? null : res.stdout;
138
+ }
139
+ const files = opts.fs ?? realHardcopyFs;
140
+ const file = path.join(opts.dir, `${sessionName}.hardcopy.txt`);
141
+ const res = run("screen", ["-S", (0, argv_1.multiplexerSessionName)(sessionName), "-p", "0", "-X", "hardcopy", file]);
142
+ if (res.error || res.status !== 0)
143
+ return null;
144
+ try {
145
+ return files.readFile(file);
146
+ }
147
+ catch {
148
+ return null;
149
+ }
150
+ finally {
151
+ try {
152
+ files.remove(file);
153
+ }
154
+ catch {
155
+ // A leftover hardcopy holds only what the pane showed; it is overwritten next time.
156
+ }
157
+ }
158
+ }
159
+ /** Send ONE Enter to the launcher's pane. True when the multiplexer accepted it. */
160
+ function pressEnter(multiplexer, sessionName, run) {
161
+ const res = multiplexer === "tmux"
162
+ ? run("tmux", ["send-keys", "-t", tmuxPaneTarget(sessionName), "Enter"])
163
+ : run("screen", ["-S", (0, argv_1.multiplexerSessionName)(sessionName), "-X", "stuff", "\r"]);
164
+ return !res.error && res.status === 0;
165
+ }
166
+ function withoutPrefix(names) {
167
+ return names.filter((n) => n.startsWith(argv_1.LAUNCH_SESSION_PREFIX) && n.length > argv_1.LAUNCH_SESSION_PREFIX.length).map((n) => n.slice(argv_1.LAUNCH_SESSION_PREFIX.length));
168
+ }
@@ -0,0 +1,63 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.startedRecordPath = startedRecordPath;
37
+ exports.loadStarted = loadStarted;
38
+ exports.saveStarted = saveStarted;
39
+ const fs = __importStar(require("fs"));
40
+ const path = __importStar(require("path"));
41
+ const approval_settings_1 = require("./approval-settings");
42
+ function startedRecordPath(dir = (0, approval_settings_1.launchDir)()) {
43
+ return path.join(dir, "started.json");
44
+ }
45
+ /** The recorded sessions; [] when the file is missing or unreadable (nothing may be stopped). */
46
+ function loadStarted(file = startedRecordPath()) {
47
+ let raw;
48
+ try {
49
+ raw = JSON.parse(fs.readFileSync(file, "utf8"));
50
+ }
51
+ catch {
52
+ return [];
53
+ }
54
+ const sessions = raw?.sessions;
55
+ if (!Array.isArray(sessions))
56
+ return [];
57
+ return sessions.filter((s) => !!s && typeof s === "object" && typeof s.name === "string" && typeof s.spotId === "string" && (s.multiplexer === "tmux" || s.multiplexer === "screen"));
58
+ }
59
+ function saveStarted(sessions, file = startedRecordPath()) {
60
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
61
+ fs.writeFileSync(file, `${JSON.stringify({ sessions }, null, 2)}\n`, { mode: 0o600 });
62
+ fs.chmodSync(file, 0o600);
63
+ }
@@ -191,7 +191,7 @@ exports.RUNTIME_PROFILES = [
191
191
  noHeadlessReason: "DeepSeek Harness is run by the relay over ACP (`baychat connect dsh`), which enforces modes; a terminal dsh session is not woken headlessly.",
192
192
  confidence: "docs",
193
193
  checked: "2026-09-19",
194
- source: "https://www.npmjs.com/package/@deepseek-ai/dsh (0.1.5-rc.2, dsh-shell-env + dsh-acp README)",
194
+ source: "https://www.npmjs.com/package/@deepseek-ai/dsh (0.1.5-rc.2, dsh-shell-env + dsh-acp README; MCP entry and skills folder unchanged in 0.2.0-rc.2 source, 2026-10-01)",
195
195
  },
196
196
  {
197
197
  id: "pi",
@@ -110,6 +110,32 @@ class SessionRegistry {
110
110
  this.save();
111
111
  return merged;
112
112
  }
113
+ /**
114
+ * Record a target WHOLE, replacing what was known — for a registration that describes the
115
+ * session completely (a Claude Code channel's). `upsert` would keep an old `pane` the new
116
+ * registration left out, and that pane may now hold a shell.
117
+ */
118
+ replace(target) {
119
+ const fresh = { ...stripUndefined(target), name: target.name, runtime: target.runtime, registeredAt: new Date().toISOString() };
120
+ this.sessions.set(target.name, fresh);
121
+ this.save();
122
+ return fresh;
123
+ }
124
+ /**
125
+ * Hand delivery back to the relay's wake ladder (or to another owner): an attach taking over
126
+ * a name a channel registered must not inherit "the channel delivers, drop the chat". A
127
+ * separate method because `upsert` strips undefined, as `clearResume` explains.
128
+ */
129
+ setDelivery(name, delivery) {
130
+ const existing = this.sessions.get(name);
131
+ if (!existing || existing.delivery === delivery)
132
+ return;
133
+ if (delivery === undefined)
134
+ delete existing.delivery;
135
+ else
136
+ existing.delivery = delivery;
137
+ this.save();
138
+ }
113
139
  get(name) {
114
140
  return this.sessions.get(name);
115
141
  }
@@ -81,7 +81,7 @@ function verifyPane(handle, runtime, run = (file, args) => (0, child_process_1.e
81
81
  "-p",
82
82
  "-t",
83
83
  handle.pane,
84
- "#{pane_dead} #{pane_current_command}",
84
+ "#{pane_dead} #{pane_current_command} #{pane_tty}",
85
85
  ]).trim();
86
86
  }
87
87
  catch (err) {
@@ -90,10 +90,17 @@ function verifyPane(handle, runtime, run = (file, args) => (0, child_process_1.e
90
90
  reason: `tmux could not find pane ${handle.pane} any more (${errText(err)}).`,
91
91
  };
92
92
  }
93
- const [dead, current] = out.split(/\s+/);
93
+ const [dead, current, paneTty] = out.split(/\s+/);
94
94
  if (dead !== "0")
95
95
  return { ok: false, reason: `pane ${handle.pane} has exited.` };
96
96
  const want = expectedCommand(runtime);
97
+ // A launcher that stayed the pane's foreground program: `baychat claude` or `baychat start`
98
+ // run INSIDE tmux keeps `node` as the parent of the agent (Node cannot exec), so tmux names
99
+ // `node`. Typing is still safe when the session's OWN recorded process is alive, is the
100
+ // runtime, and sits in that pane's foreground process group — what a keystroke reaches.
101
+ // When the agent has exited (a shell left on the pane) that process is gone: still refused.
102
+ if (current !== want && ownerPid !== undefined && ownerHoldsPane(ownerPid, runtime, paneTty, run))
103
+ return { ok: true };
97
104
  if (current !== want) {
98
105
  return {
99
106
  ok: false,
@@ -102,6 +109,28 @@ function verifyPane(handle, runtime, run = (file, args) => (0, child_process_1.e
102
109
  }
103
110
  return { ok: true };
104
111
  }
112
+ /**
113
+ * Is the session's recorded runtime process the FOREGROUND of the pane's terminal?
114
+ *
115
+ * Three facts, all from the OS: the process is alive and is the runtime (`runtimeStillRunning`);
116
+ * its controlling terminal is the pane's (`pane_tty`); and its process group is that terminal's
117
+ * foreground group (`pgid == tpgid`), so keystrokes reach it and not a shell or a subprocess. A
118
+ * reused pid on another terminal fails the second; an agent in the background fails the third.
119
+ */
120
+ function ownerHoldsPane(pid, runtime, paneTty, run) {
121
+ if (!paneTty || !runtimeStillRunning(pid, runtime, run).ok)
122
+ return false;
123
+ let out;
124
+ try {
125
+ out = run("ps", ["-o", "tpgid=,pgid=,tty=", "-p", String(pid)]).trim();
126
+ }
127
+ catch {
128
+ return false;
129
+ }
130
+ const [tpgid, pgid, tty] = out.split(/\s+/);
131
+ const bare = (t) => t.replace(/^\/dev\//, "");
132
+ return !!tty && tty !== "?" && bare(tty) === bare(paneTty) && !!pgid && Number(pgid) > 0 && tpgid === pgid;
133
+ }
105
134
  /**
106
135
  * Is `pid` still running, and still the runtime we recorded?
107
136
  *
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.SeenDeliveries = void 0;
3
+ exports.SeenDeliveries = exports.NO_WEBSOCKET_DETAIL = void 0;
4
4
  exports.runRelayFeed = runRelayFeed;
5
5
  /**
6
6
  * The relay's event SOURCE, and the only place that chooses between the two.
@@ -44,6 +44,11 @@ const HEALTHY_AFTER_MS = 60_000;
44
44
  const WS_RETRY_LADDER_MS = [3_000, 15_000, 60_000, 300_000, 600_000];
45
45
  /** How often the live-session list is re-read while the socket is up. See `refreshSessions`. */
46
46
  const SESSION_REFRESH_MS = 60_000;
47
+ /**
48
+ * `relay status`'s transport detail when this Node has no WebSocket at all. A wire value across the
49
+ * process boundary: `doctor` matches it to say the relay's Node is too old, not the server.
50
+ */
51
+ exports.NO_WEBSOCKET_DETAIL = "fallback — no WebSocket in this runtime";
47
52
  /** Deliveries (session + message id) remembered for de-duplication across a transport switch. */
48
53
  const SEEN_DELIVERIES_LIMIT = 2_000;
49
54
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -150,7 +155,7 @@ async function runRelayFeed(opts) {
150
155
  // straight out of here, exactly as it did before.
151
156
  const detail = socketsPossible
152
157
  ? `fallback — retrying the socket in ${Math.max(0, Math.round((retryAfter - Date.now()) / 1000))}s`
153
- : "fallback — no WebSocket in this runtime";
158
+ : exports.NO_WEBSOCKET_DETAIL;
154
159
  announce("long-poll", detail);
155
160
  const stint = new AbortController();
156
161
  const stopStint = () => stint.abort();
package/dist/relay/ws.js CHANGED
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.nodeWebSocketConnect = exports.WebSocketUnavailableError = exports.AGENT_WS_PATH = void 0;
3
+ exports.nodeWebSocketConnect = exports.WebSocketUnavailableError = exports.NODE_UPGRADE_HINT = exports.WEBSOCKET_MIN_NODE = exports.AGENT_WS_PATH = void 0;
4
+ exports.hasBuiltInWebSocket = hasBuiltInWebSocket;
4
5
  exports.runWebSocketFeed = runWebSocketFeed;
5
6
  exports.wsUrlFor = wsUrlFor;
6
7
  exports.parseFrame = parseFrame;
@@ -31,6 +32,22 @@ exports.AGENT_WS_PATH = "/api/agent-api/ws";
31
32
  const READY_TIMEOUT_MS = 10_000;
32
33
  /** Close code the gateway uses when the credential stopped verifying. */
33
34
  const WS_CLOSE_CREDENTIAL = 4001;
35
+ /** The first Node with a built-in `WebSocket`: what the relay's socket and the Claude Code channel need. */
36
+ exports.WEBSOCKET_MIN_NODE = 22;
37
+ /**
38
+ * Does this Node have the built-in `WebSocket`? Node 20 has none, and `engines` still allows it —
39
+ * there the relay long-polls (and the server throttles it) and `baychat channel` cannot start.
40
+ */
41
+ function hasBuiltInWebSocket() {
42
+ return typeof globalThis.WebSocket === "function";
43
+ }
44
+ /**
45
+ * What to do on a Node without it, as `doctor` and `connect claude` say it. Reinstalling under the
46
+ * new Node matters: npm keeps one global `baychat` per Node, and `baychat update` rewrites the
47
+ * relay's service with the Node it runs on.
48
+ */
49
+ exports.NODE_UPGRADE_HINT = `run baychat on Node ${exports.WEBSOCKET_MIN_NODE} or newer: install it, \`npm install -g baychat\` with it, then \`baychat update\`` +
50
+ " (docs/features/CLAUDE_CODE_CHANNEL.md has a recipe that leaves the system Node alone)";
34
51
  /** Thrown by `nodeWebSocketConnect` on a runtime with no global `WebSocket`. */
35
52
  class WebSocketUnavailableError extends Error {
36
53
  constructor(message) {
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.installRuntimeCommand = installRuntimeCommand;
37
+ exports.skillFileBody = skillFileBody;
37
38
  exports.describeInstall = describeInstall;
38
39
  const fs = __importStar(require("fs"));
39
40
  const os = __importStar(require("os"));
@@ -70,9 +71,7 @@ function installRuntimeCommand(runtime, home = os.homedir()) {
70
71
  }
71
72
  const dir = path.join(home, spec.command.dir);
72
73
  const file = path.join(dir, spec.command.file);
73
- const body = runtime === "codex" || runtime === "claude"
74
- ? (0, skill_bootstrap_1.renderSkillBootstrap)(runtime)
75
- : spec.command.render((0, runtimes_1.commandContextFor)(runtime));
74
+ const body = skillFileBody(runtime);
76
75
  fs.mkdirSync(dir, { recursive: true });
77
76
  // Back up a hand-edited skill rather than silently overwriting it — the user
78
77
  // may have tuned the room rules for their own setup.
@@ -82,6 +81,23 @@ function installRuntimeCommand(runtime, home = os.homedir()) {
82
81
  fs.writeFileSync(file, body, { mode: 0o644 });
83
82
  return { ...base, written: file };
84
83
  }
84
+ /**
85
+ * Exactly what `installRuntimeCommand` writes for a runtime, or null when it writes nothing.
86
+ *
87
+ * ONE function, because `doctor` compares the installed file against it. Claude Code and Codex
88
+ * get a small, stable bootstrap that loads the current workflow from the package at run time
89
+ * (`baychat skill --runtime …`); the others get the whole workflow. When doctor compared against
90
+ * the whole workflow instead, every freshly updated Claude Code / Codex machine was reported
91
+ * "out of date" — a check that fails on a correct install (2026-10-03).
92
+ */
93
+ function skillFileBody(runtime) {
94
+ const spec = (0, runtimes_1.runtimeSpec)(runtime);
95
+ if (!spec.command)
96
+ return null;
97
+ return runtime === "codex" || runtime === "claude"
98
+ ? (0, skill_bootstrap_1.renderSkillBootstrap)(runtime)
99
+ : spec.command.render((0, runtimes_1.commandContextFor)(runtime));
100
+ }
85
101
  /** Human-readable summary lines for the end of `connect`. */
86
102
  function describeInstall(report) {
87
103
  const spec = (0, runtimes_1.runtimeSpec)(report.runtime);