baychat 0.28.0 → 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 (49) hide show
  1. package/.claude-plugin/plugin.json +24 -4
  2. package/README.md +41 -3
  3. package/dist/approve-hook.js +93 -3
  4. package/dist/channel/claude-link.js +145 -0
  5. package/dist/channel/feed.js +26 -12
  6. package/dist/channel/launch.js +59 -45
  7. package/dist/channel/plugin.js +182 -0
  8. package/dist/channel/relay-link.js +257 -0
  9. package/dist/channel/server.js +92 -7
  10. package/dist/cli-help.js +235 -0
  11. package/dist/commands.js +4 -4
  12. package/dist/connect-acp.js +3 -2
  13. package/dist/connect-claude.js +26 -10
  14. package/dist/device-login.js +2 -2
  15. package/dist/doctor-command.js +2 -0
  16. package/dist/doctor.js +97 -3
  17. package/dist/help-topics.js +4 -3
  18. package/dist/launch/claude-trust.js +221 -0
  19. package/dist/launch/command.js +177 -0
  20. package/dist/launch/policy.js +152 -0
  21. package/dist/machine-name.js +76 -0
  22. package/dist/main.js +16 -170
  23. package/dist/multiplexer-launch.js +271 -0
  24. package/dist/protocol-content.js +1 -1
  25. package/dist/relay/acp/agents.js +42 -19
  26. package/dist/relay/acp/daemon-glue.js +3 -2
  27. package/dist/relay/acp/runner.js +8 -7
  28. package/dist/relay/channel-sessions.js +66 -0
  29. package/dist/relay/commands.js +9 -2
  30. package/dist/relay/daemon.js +160 -9
  31. package/dist/relay/launcher/approval-settings.js +140 -0
  32. package/dist/relay/launcher/argv.js +83 -0
  33. package/dist/relay/launcher/capabilities.js +76 -0
  34. package/dist/relay/launcher/dev-channel-screen.fixtures.js +41 -0
  35. package/dist/relay/launcher/dev-channel-screen.js +47 -0
  36. package/dist/relay/launcher/handle.js +303 -0
  37. package/dist/relay/launcher/loop.js +231 -0
  38. package/dist/relay/launcher/multiplexer.js +168 -0
  39. package/dist/relay/launcher/started.js +63 -0
  40. package/dist/relay/profiles.js +1 -1
  41. package/dist/relay/registry.js +26 -0
  42. package/dist/relay/terminal-pane.js +31 -2
  43. package/dist/relay/transport.js +7 -2
  44. package/dist/relay/ws.js +18 -1
  45. package/dist/runtime-install.js +19 -3
  46. package/dist/runtimes.js +3 -0
  47. package/dist/start-command.js +12 -56
  48. package/dist/tool-defs.js +23 -36
  49. package/package.json +3 -3
@@ -0,0 +1,182 @@
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.PLUGIN_MARKETPLACE_NAME = exports.PLUGIN_MARKETPLACE_URL = void 0;
37
+ exports.realPluginFiles = realPluginFiles;
38
+ exports.claudeUserSettingsPath = claudeUserSettingsPath;
39
+ exports.pluginEnabled = pluginEnabled;
40
+ exports.baychatMarketplace = baychatMarketplace;
41
+ exports.installedPlugin = installedPlugin;
42
+ exports.pluginChannelReady = pluginChannelReady;
43
+ exports.installPluginWithClaude = installPluginWithClaude;
44
+ /**
45
+ * BayChat as a Claude Code plugin: installing it, and reading what Claude Code actually installed.
46
+ *
47
+ * The plugin is the `baychat` npm package itself (`.claude-plugin/plugin.json` declares the
48
+ * channel), listed in a catalog served at baychat.io. `launch.ts` decides how a session is started
49
+ * with whichever form of the channel is installed; this file owns the plugin form.
50
+ *
51
+ * SUCCESS IS READ FROM WHAT IS INSTALLED, NEVER FROM EXIT CODES. `claude plugin marketplace add`
52
+ * and `claude plugin install` both answer "already" for ANY marketplace or plugin of that name.
53
+ * Computers set up with BayChat before the channel existed carry a marketplace that is ALSO named
54
+ * `baychat` — GitHub `SeaQuestdev/baychat-plugins`, whose `baychat` plugin is skill-only (no
55
+ * `mcpServers`, no `channels`). On those both steps said "already", connect removed the
56
+ * hand-registered `baychat_channel`, and the channel existed nowhere while setup reported success
57
+ * (2026-10-03, a laptop upgraded from 0.24.0 to 0.28.0). So the installed plugin's own manifest is
58
+ * the evidence, here and in `doctor`.
59
+ *
60
+ * Every reader takes `PluginFiles`, so the decisions are testable against any machine's files.
61
+ * Claude Code's file layout is not a published contract: anything unreadable or unexpected reads
62
+ * as "not installed", which sends the installer to its safe fallback (the hand-registered server).
63
+ */
64
+ const fs = __importStar(require("node:fs"));
65
+ const os = __importStar(require("node:os"));
66
+ const path = __importStar(require("node:path"));
67
+ const detect_1 = require("./detect");
68
+ /** Where `claude plugin marketplace add` finds BayChat's plugin catalog (served by the web app). */
69
+ exports.PLUGIN_MARKETPLACE_URL = "https://baychat.io/claude/marketplace.json";
70
+ /** The catalog's name, the part of `PLUGIN_ID` after the `@`. */
71
+ exports.PLUGIN_MARKETPLACE_NAME = detect_1.PLUGIN_ID.slice(detect_1.PLUGIN_ID.indexOf("@") + 1);
72
+ function realPluginFiles(home = os.homedir()) {
73
+ return {
74
+ home,
75
+ read: (file) => {
76
+ try {
77
+ return fs.readFileSync(file, "utf8");
78
+ }
79
+ catch {
80
+ return null; // Absent or unreadable: the readers below turn that into "not installed".
81
+ }
82
+ },
83
+ };
84
+ }
85
+ /** Claude Code's user settings, where installed plugins are switched on. */
86
+ function claudeUserSettingsPath(home = os.homedir()) {
87
+ return path.join(home, ".claude", "settings.json");
88
+ }
89
+ /** Is BayChat's plugin installed and switched on for this user? Unreadable counts as no. */
90
+ function pluginEnabled(files = realPluginFiles()) {
91
+ const settings = readJson(files, claudeUserSettingsPath(files.home));
92
+ return settings?.enabledPlugins?.[detect_1.PLUGIN_ID] === true;
93
+ }
94
+ /** Read from `~/.claude/plugins/known_marketplaces.json`. Unreadable counts as absent. */
95
+ function baychatMarketplace(files) {
96
+ const known = readJson(files, path.join(pluginsDir(files.home), "known_marketplaces.json"));
97
+ const source = known?.[exports.PLUGIN_MARKETPLACE_NAME]?.source;
98
+ if (!source || typeof source !== "object")
99
+ return { kind: "absent" };
100
+ if (source.source === "url" && source.url === exports.PLUGIN_MARKETPLACE_URL)
101
+ return { kind: "ours" };
102
+ const where = [source.repo, source.url, source.path].find((v) => typeof v === "string");
103
+ return { kind: "foreign", source: [source.source, where].filter((v) => typeof v === "string").join(" ") || "unknown source" };
104
+ }
105
+ /**
106
+ * Read `installed_plugins.json` (the user-scope entry; `connect` installs at user scope), then that
107
+ * install's `.claude-plugin/plugin.json`. Only a manifest with a non-empty `channels` counts.
108
+ */
109
+ function installedPlugin(files) {
110
+ const record = readJson(files, path.join(pluginsDir(files.home), "installed_plugins.json"));
111
+ const entries = record?.plugins?.[detect_1.PLUGIN_ID];
112
+ // Version 2 of the file keeps a list per plugin (one per scope); version 1 kept one object.
113
+ const list = (Array.isArray(entries) ? entries : entries ? [entries] : []);
114
+ const entry = list.find((e) => e?.scope === "user") ?? list.find((e) => e?.scope === undefined);
115
+ if (!entry || typeof entry.installPath !== "string")
116
+ return { state: "absent" };
117
+ const version = typeof entry.version === "string" ? entry.version : "unknown";
118
+ const manifestPath = path.join(entry.installPath, ".claude-plugin", "plugin.json");
119
+ const manifest = readJson(files, manifestPath);
120
+ if (!manifest)
121
+ return { state: "no-channel", version, why: `its manifest (${manifestPath}) could not be read` };
122
+ if (!Array.isArray(manifest.channels) || manifest.channels.length === 0) {
123
+ return { state: "no-channel", version, why: "its manifest declares no channel" };
124
+ }
125
+ return { state: "channel", version };
126
+ }
127
+ /** The one condition under which the plugin delivers the channel: switched on, and declaring it. */
128
+ function pluginChannelReady(files) {
129
+ return pluginEnabled(files) && installedPlugin(files).state === "channel";
130
+ }
131
+ /**
132
+ * Install BayChat as a Claude Code plugin, and say whether the channel is now there.
133
+ *
134
+ * 1. A `baychat` marketplace from anywhere but baychat.io is removed first — otherwise "add"
135
+ * answers "already" and the old catalog stays.
136
+ * 2. Add the catalog from baychat.io and install from it (the plugin is fetched from the `baychat`
137
+ * npm package).
138
+ * 3. Read what is installed. "Already installed" can be a plugin from before — the old skill-only
139
+ * one, or one switched off — so a plugin without the channel is reinstalled once.
140
+ * 4. Only then remove the hand-registered server, which would otherwise hide the plugin's.
141
+ *
142
+ * True ONLY when the installed plugin is switched on and declares the channel. False at the first
143
+ * step that fails, with the hand-registered server untouched — the caller then registers it by
144
+ * hand, and a session started with both named keeps working either way.
145
+ */
146
+ function installPluginWithClaude(run, files = realPluginFiles()) {
147
+ if (baychatMarketplace(files).kind === "foreign") {
148
+ if (run(["plugin", "marketplace", "remove", exports.PLUGIN_MARKETPLACE_NAME]).status !== 0)
149
+ return false;
150
+ }
151
+ const added = run(["plugin", "marketplace", "add", exports.PLUGIN_MARKETPLACE_URL]);
152
+ if (added.status !== 0 && !/already/i.test(added.stderr))
153
+ return false;
154
+ if (!installFromCatalog(run))
155
+ return false;
156
+ if (!pluginChannelReady(files)) {
157
+ run(["plugin", "uninstall", detect_1.PLUGIN_ID]); // Absent is fine: the install below decides.
158
+ if (!installFromCatalog(run) || !pluginChannelReady(files))
159
+ return false;
160
+ }
161
+ run(["mcp", "remove", "--scope", "user", detect_1.CHANNEL_MCP_ENTRY]); // Absent is fine.
162
+ return true;
163
+ }
164
+ function installFromCatalog(run) {
165
+ const installed = run(["plugin", "install", detect_1.PLUGIN_ID]);
166
+ return installed.status === 0 || /already installed/i.test(installed.stderr);
167
+ }
168
+ function pluginsDir(home) {
169
+ return path.join(home, ".claude", "plugins");
170
+ }
171
+ /** Parsed JSON, or undefined when the file is absent, unreadable or not JSON. */
172
+ function readJson(files, file) {
173
+ const text = files.read(file);
174
+ if (text === null)
175
+ return undefined;
176
+ try {
177
+ return JSON.parse(text);
178
+ }
179
+ catch {
180
+ return undefined;
181
+ }
182
+ }
@@ -0,0 +1,257 @@
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.RelayLink = void 0;
37
+ exports.describeCommandReach = describeCommandReach;
38
+ /**
39
+ * The channel's link to this computer's relay: how a channel session's `/clear` gets typed.
40
+ *
41
+ * ── WHY A CHANNEL SESSION NEEDS THE RELAY AT ALL ──────────────────────────────────────
42
+ *
43
+ * The channel delivers messages, but a `<channel>` event is a PROMPT: Claude Code never reads a
44
+ * slash command out of one. `/clear`, `/new`, `/compact` and the fresh start's clear work only
45
+ * when something TYPES them into the session's terminal, and the relay is what does that
46
+ * (relay/terminal-pane.ts). So after `listen` goes active, the channel server registers its
47
+ * session with the relay — for commands only (relay/channel-sessions.ts): the relay never wakes
48
+ * it, and drops its chat, because the channel already delivered it.
49
+ *
50
+ * ── WHAT IT SENDS, AND WHY IT IS READ HERE ────────────────────────────────────────────
51
+ *
52
+ * The pane (`$TMUX`/`$TMUX_PANE`, or screen's `$STY`/`$WINDOW`) and the Claude Code process.
53
+ * Both are read in THIS process, which Claude Code started and whose environment it inherited —
54
+ * the same rule `relay attach` follows: the process inside the session answers, the relay never
55
+ * guesses where to send keystrokes.
56
+ *
57
+ * ── WHY THE SOCKET STAYS OPEN ─────────────────────────────────────────────────────────
58
+ *
59
+ * Its close tells the relay the session is gone (Claude Code exiting closes this process's
60
+ * stdin, and the process exits), so a pane a later shell occupies is never typed into on this
61
+ * registration's word. And it carries the fresh start's note request back (`handoff-note`). A
62
+ * relay that is not running, or restarts, is retried with backoff for as long as the channel
63
+ * lives; `onChange` says when the relay starts or stops running this session's commands.
64
+ */
65
+ const net = __importStar(require("node:net"));
66
+ const socket_1 = require("../relay/socket");
67
+ const channel_sessions_1 = require("../relay/channel-sessions");
68
+ const feed_1 = require("./feed");
69
+ /** How long `start` waits for the first answer before reporting "still connecting". */
70
+ const FIRST_ANSWER_MS = 3_000;
71
+ /** A note request is BayChat's own short instruction; anything longer is not one. */
72
+ const MAX_NOTE_CHARS = 4_000;
73
+ class RelayLink {
74
+ opts;
75
+ current = { kind: "connecting" };
76
+ socket;
77
+ attempt = 0;
78
+ retryTimer;
79
+ settleFirst;
80
+ setTimer;
81
+ clearTimer;
82
+ constructor(opts) {
83
+ this.opts = opts;
84
+ this.setTimer =
85
+ opts.setTimer ??
86
+ ((fn, ms) => {
87
+ const t = setTimeout(fn, ms);
88
+ // The stdio transport keeps the process alive; a retry timer must not on its own.
89
+ t.unref?.();
90
+ return t;
91
+ });
92
+ this.clearTimer = opts.clearTimer ?? ((h) => clearTimeout(h));
93
+ }
94
+ get state() {
95
+ return this.current;
96
+ }
97
+ get registered() {
98
+ return this.current.kind === "registered";
99
+ }
100
+ /** The `listen` answer's line on whether commands from the Bay reach this session. */
101
+ describeReach() {
102
+ return describeCommandReach(this.current, this.opts.pane);
103
+ }
104
+ /**
105
+ * Connect and register, resolving with the first answer (or "connecting" after a short wait,
106
+ * so a wedged relay never holds up `listen`). Never throws: no relay is an ordinary state.
107
+ */
108
+ start(firstAnswerMs = FIRST_ANSWER_MS) {
109
+ return new Promise((resolve) => {
110
+ const timer = this.setTimer(() => this.settle(), firstAnswerMs);
111
+ this.settleFirst = () => {
112
+ this.settleFirst = undefined;
113
+ this.clearTimer(timer);
114
+ resolve(this.current);
115
+ };
116
+ this.connect();
117
+ });
118
+ }
119
+ /** Stop for good: the relay sees the socket close and stops running this session's commands. */
120
+ close() {
121
+ const was = this.registered;
122
+ this.current = { kind: "closed" };
123
+ if (this.retryTimer !== undefined)
124
+ this.clearTimer(this.retryTimer);
125
+ this.retryTimer = undefined;
126
+ const socket = this.socket;
127
+ this.socket = undefined;
128
+ socket?.destroy();
129
+ this.settle();
130
+ if (was)
131
+ this.opts.onChange?.(false);
132
+ }
133
+ settle() {
134
+ this.settleFirst?.();
135
+ }
136
+ connect() {
137
+ if (this.finished())
138
+ return;
139
+ let socket;
140
+ try {
141
+ socket = (this.opts.connect ?? ((p) => net.createConnection(p)))(this.opts.socketPath);
142
+ }
143
+ catch (err) {
144
+ this.lost(errText(err));
145
+ return;
146
+ }
147
+ this.socket = socket;
148
+ const mine = () => this.socket === socket && !this.finished();
149
+ socket.on("connect", () => {
150
+ if (!mine())
151
+ return;
152
+ const frame = {
153
+ type: "channel-register",
154
+ session: this.opts.session,
155
+ runtime: "claude",
156
+ ...(this.opts.pane ? { pane: this.opts.pane } : {}),
157
+ ...(this.opts.ownerPid !== undefined ? { ownerPid: this.opts.ownerPid } : {}),
158
+ };
159
+ (0, socket_1.writeFrame)(socket, frame);
160
+ });
161
+ socket.on("data", (0, socket_1.createFrameReader)((frame) => {
162
+ if (!mine())
163
+ return;
164
+ this.onFrame(frame);
165
+ }));
166
+ // `error` is followed by `close`; the reason is kept for the state the close reports.
167
+ let failure;
168
+ socket.on("error", (err) => {
169
+ failure = describeConnectError(err);
170
+ });
171
+ socket.on("close", () => {
172
+ if (!mine())
173
+ return;
174
+ this.socket = undefined;
175
+ this.lost(failure ?? "the relay closed the connection");
176
+ });
177
+ }
178
+ onFrame(frame) {
179
+ if (frame.type === "attached" && frame.delivery === "channel") {
180
+ this.attempt = 0;
181
+ this.current = { kind: "registered" };
182
+ this.opts.log?.(`relay runs the commands of session "${this.opts.session}"`);
183
+ this.settle();
184
+ this.opts.onChange?.(true);
185
+ return;
186
+ }
187
+ if (frame.type === "handoff-note") {
188
+ if (typeof frame.handoffId !== "string" || typeof frame.content !== "string" || frame.content.length > MAX_NOTE_CHARS)
189
+ return;
190
+ this.opts.onHandoffNote({ conversationId: frame.conversationId, handoffId: frame.handoffId, content: frame.content });
191
+ return;
192
+ }
193
+ if (frame.type === "error") {
194
+ // A refusal is the relay's considered answer (a relay-run session of that name, or a
195
+ // newer channel for it): retrying would only fight it.
196
+ const was = this.registered;
197
+ this.current = { kind: "refused", reason: frame.message === channel_sessions_1.CHANNEL_REPLACED ? channel_sessions_1.CHANNEL_REPLACED : `the relay refused it: ${frame.message}` };
198
+ const socket = this.socket;
199
+ this.socket = undefined;
200
+ socket?.destroy();
201
+ this.opts.log?.(`relay link for "${this.opts.session}" ended: ${this.current.reason}`);
202
+ this.settle();
203
+ if (was)
204
+ this.opts.onChange?.(false);
205
+ }
206
+ }
207
+ /** The connection failed or dropped: say so, and try again later. */
208
+ lost(reason) {
209
+ if (this.finished())
210
+ return;
211
+ const was = this.registered;
212
+ this.current = { kind: "waiting", reason };
213
+ this.settle();
214
+ if (was)
215
+ this.opts.onChange?.(false);
216
+ const delay = feed_1.BACKOFF_MS[Math.min(this.attempt, feed_1.BACKOFF_MS.length - 1)];
217
+ this.attempt++;
218
+ if (this.retryTimer !== undefined)
219
+ this.clearTimer(this.retryTimer);
220
+ this.retryTimer = this.setTimer(() => {
221
+ this.retryTimer = undefined;
222
+ this.connect();
223
+ }, delay);
224
+ }
225
+ finished() {
226
+ return this.current.kind === "closed" || this.current.kind === "refused";
227
+ }
228
+ }
229
+ exports.RelayLink = RelayLink;
230
+ /**
231
+ * One line for the session (the `listen` answer) on whether its commands from the Bay work, and
232
+ * what to do when they do not. `pane` is what this process inherited.
233
+ */
234
+ function describeCommandReach(state, pane) {
235
+ if (state.kind === "registered") {
236
+ if (!pane) {
237
+ return "Commands from the Bay: /status and /help answer, but /clear, /new and /compact cannot reach this session — it is not inside tmux or screen (start it with `baychat claude` next time).";
238
+ }
239
+ return "Commands from the Bay (/clear, /new, /compact, Fresh start): ready — this computer's relay types them into this session's terminal.";
240
+ }
241
+ if (state.kind === "connecting")
242
+ return "Commands from the Bay: still connecting to this computer's relay.";
243
+ if (state.kind === "closed")
244
+ return "Commands from the Bay: off.";
245
+ return `Commands from the Bay (/clear, /new, /compact): not available — ${state.reason}.${state.kind === "waiting" ? " Retrying in the background." : ""}`;
246
+ }
247
+ function describeConnectError(err) {
248
+ const code = err.code;
249
+ if (code === "ENOENT" || code === "ECONNREFUSED")
250
+ return "no BayChat relay is running on this computer (start it with `baychat relay start`)";
251
+ if (code === "EACCES" || code === "EPERM")
252
+ return "this process may not open the relay's socket";
253
+ return `could not reach the relay (${code ?? err.message})`;
254
+ }
255
+ function errText(err) {
256
+ return err instanceof Error ? err.message : String(err);
257
+ }
@@ -34,6 +34,8 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.ChannelService = exports.nodeFeedConnect = exports.CHANNEL_INSTRUCTIONS = void 0;
37
+ exports.realCommandLink = realCommandLink;
38
+ exports.relaySocketFor = relaySocketFor;
37
39
  exports.realLogin = realLogin;
38
40
  exports.realCallTool = realCallTool;
39
41
  exports.mintTicket = mintTicket;
@@ -61,10 +63,16 @@ const zod_1 = require("zod");
61
63
  const api_1 = require("../api");
62
64
  const bays_1 = require("../bays");
63
65
  const config_1 = require("../config");
66
+ const bay_relays_1 = require("../relay/bay-relays");
67
+ const owner_pid_1 = require("../relay/owner-pid");
68
+ const socket_1 = require("../relay/socket");
69
+ const terminal_pane_1 = require("../relay/terminal-pane");
64
70
  const ws_1 = require("../relay/ws");
71
+ const claude_link_1 = require("./claude-link");
65
72
  const detect_1 = require("./detect");
66
73
  const feed_1 = require("./feed");
67
74
  const permission_1 = require("./permission");
75
+ const relay_link_1 = require("./relay-link");
68
76
  /** Delivered to Claude as context when this server connects. */
69
77
  exports.CHANNEL_INSTRUCTIONS = `This is BayChat's wake-up channel. After join_session, call this server's listen tool with the ` +
70
78
  `confirmed session name (and bay, when the session joined a Bay other than the default). If it ` +
@@ -73,9 +81,11 @@ exports.CHANNEL_INSTRUCTIONS = `This is BayChat's wake-up channel. After join_se
73
81
  `get_messages tool (the same Bay's tools the session joined with), obey shouldRespond and answer ` +
74
82
  `in BayChat. Do NOT also open a Monitor or call listen_messages: that would replace this feed. ` +
75
83
  `If listen answers inactive or failed, use listen_messages and a native Monitor as before. ` +
76
- `event="reset" means catch up with get_messages; event="stopped" means the feed ended and says why. ` +
84
+ `event="reset" means catch up with get_messages; event="stopped" means the feed ended and says why; ` +
85
+ `event="handoff" is BayChat's own request for a handoff note before a fresh start: do what it says. ` +
77
86
  `Tool-permission prompts are also offered to the session's owner as a BayChat card; answer in the terminal or ` +
78
- `on the phone, whichever is first.`;
87
+ `on the phone, whichever is first. Answer in BayChat only; in the terminal write at most one short line, ` +
88
+ `never the answer again (both are paid output).`;
79
89
  const LISTEN_TOOL = {
80
90
  name: "listen",
81
91
  description: "Hear this BayChat session's incoming messages through the Claude Code channel instead of a Monitor. " +
@@ -94,6 +104,21 @@ const LISTEN_TOOL = {
94
104
  required: ["session"],
95
105
  },
96
106
  };
107
+ /**
108
+ * The real command link: the relay socket of the session's Bay, and the pane and Claude Code
109
+ * process THIS process inherited — Claude Code starts the channel server, so its `$TMUX` and its
110
+ * parent are the session's (owner-pid.ts walks the live process tree to prove the latter).
111
+ */
112
+ function realCommandLink(log) {
113
+ return (session, bay, hooks) => new relay_link_1.RelayLink({ socketPath: relaySocketFor(bay), session, pane: (0, terminal_pane_1.detectPane)(), ownerPid: (0, owner_pid_1.currentOwnerPid)("claude"), ...hooks, log });
114
+ }
115
+ /** The relay that serves a Bay's sessions: the default relay, or that Bay's own (relay/bay-relays.ts). */
116
+ function relaySocketFor(bay) {
117
+ if (bay === undefined || !bay.trim())
118
+ return (0, socket_1.socketPath)();
119
+ const login = (0, bays_1.findBayLogin)((0, config_1.loadBayLogins)(), bay);
120
+ return login.isDefault ? (0, socket_1.socketPath)() : (0, bay_relays_1.bayRelayScope)(login.bayId, (0, config_1.configDir)(), (0, socket_1.socketPath)()).socket;
121
+ }
97
122
  /** The real login lookup: the default Bay, or a named one from `credentials.json`. */
98
123
  function realLogin(bay) {
99
124
  const device = (0, config_1.loadDeviceCredentials)();
@@ -156,7 +181,7 @@ function httpStatus(err) {
156
181
  * session is gone, or the login was refused or ran out. Everything else (offline, a restart,
157
182
  * the per-minute limit) is retried by the feed.
158
183
  */
159
- async function mintTicket(deps, bay, session, cursor) {
184
+ async function mintTicket(deps, { bay, session, cursor, commands }) {
160
185
  let login;
161
186
  try {
162
187
  login = deps.login(bay);
@@ -169,7 +194,7 @@ async function mintTicket(deps, bay, session, cursor) {
169
194
  return { ok: false, fatal: true, detail: "This computer's BayChat login has expired. Run `baychat login`." };
170
195
  }
171
196
  try {
172
- const result = await deps.callTool(login, "listen_messages", { session, ...(cursor ? { cursor } : {}) });
197
+ const result = await deps.callTool(login, "listen_messages", { session, ...(cursor ? { cursor } : {}), ...(commands ? { commands } : {}) });
173
198
  const text = resultText(result);
174
199
  if (result.isError) {
175
200
  const fatal = /no live session|SESSION_NOT_FOUND|not a member|cannot delegate/i.test(text);
@@ -206,6 +231,7 @@ class ChannelService {
206
231
  deps;
207
232
  push;
208
233
  feeds = new Map();
234
+ links = new Map();
209
235
  /** The most recent session that listened successfully: where permission prompts go. */
210
236
  last;
211
237
  constructor(deps, push) {
@@ -233,10 +259,23 @@ class ChannelService {
233
259
  }
234
260
  const key = `${bayName ?? ""}\u0000${session}`;
235
261
  this.feeds.get(key)?.stop();
236
- const feed = new feed_1.ChannelFeed({
262
+ // COMMANDS FIRST, so the first ticket already says whether this computer's relay runs them.
263
+ // When that changes (the relay starts, stops or restarts), the feed is reopened so the
264
+ // server's view follows (realtime/remote-listeners.ts). Never a reason to fail `listen`.
265
+ let feed;
266
+ const link = this.openCommandLink(key, session, bay, bayName, (registered) => feed?.reopen(registered ? "now has this computer's relay running its commands" : "lost this computer's relay"));
267
+ if (link)
268
+ await link.start();
269
+ feed = new feed_1.ChannelFeed({
237
270
  session,
238
271
  ...(bayName ? { bayName } : {}),
239
- mint: (cursor) => mintTicket(this.deps, bay, session, cursor),
272
+ mint: (cursor) => mintTicket(this.deps, { bay, session, cursor, ...(link?.registered ? { commands: "relay" } : {}) }),
273
+ onStop: () => {
274
+ this.closeCommandLink(key, link);
275
+ // A stopped feed: the hook must not keep sending cards in a name that may be gone.
276
+ if (bay === undefined)
277
+ this.deps.claudeLink?.remove(session);
278
+ },
240
279
  connect: this.deps.connect,
241
280
  status: async () => {
242
281
  try {
@@ -258,15 +297,55 @@ class ChannelService {
258
297
  const started = await feed.start();
259
298
  if (!started.ok) {
260
299
  this.feeds.delete(key);
300
+ this.closeCommandLink(key, link);
261
301
  if (this.last?.key === key)
262
302
  this.last = undefined;
263
303
  return toolResult("failed", `Failed: ${started.detail} Use listen_messages and a native Monitor instead.`, { session });
264
304
  }
265
305
  this.last = { key, session, bay };
306
+ // Approvals for `baychat claude` / `baychat start` (approve-hook --session-from-claude): this
307
+ // Claude Code is now this session. Only for the default Bay, the one the hook's device login
308
+ // asks; a session on another Bay keeps its prompts in the terminal (and clears an older link,
309
+ // so a card never goes out in a name this Claude Code has moved on from).
310
+ if (bay === undefined)
311
+ this.deps.claudeLink?.write(session);
312
+ else
313
+ this.deps.claudeLink?.remove(undefined);
266
314
  return toolResult("active", `Active: session "${session}"${bayName ? ` (${bayName})` : ""} now hears the Bay through the channel. New messages ` +
267
315
  `arrive here as <channel event="messages"> events, with no deadline and no re-arming. Do NOT open a ` +
268
316
  `Monitor or call listen_messages — either would replace this feed. Now read one get_messages batch (limit 10) ` +
269
- `to catch up on anything from before listening.`, { session, ...(bayName ? { bay: bayName } : {}), how: evidence.how });
317
+ `to catch up on anything from before listening.` +
318
+ (link ? ` ${link.describeReach()}` : ""), { session, ...(bayName ? { bay: bayName } : {}), how: evidence.how, commands: link?.state.kind ?? "off" });
319
+ }
320
+ /**
321
+ * Register this session's commands with the relay (relay-link.ts). A fresh start's note
322
+ * request comes back over it and is handed to the session as an event — the relay drops a
323
+ * channel session's chat, so this is the one way to ask its agent for the note.
324
+ */
325
+ openCommandLink(key, session, bay, bayName, onChange) {
326
+ this.links.get(key)?.close();
327
+ this.links.delete(key);
328
+ if (!this.deps.commandLink)
329
+ return undefined;
330
+ let link;
331
+ try {
332
+ link = this.deps.commandLink(session, bay, {
333
+ onHandoffNote: (note) => void this.push(note.content, { session, ...(bayName ? { bay: bayName } : {}), event: "handoff", handoff_id: note.handoffId }),
334
+ onChange,
335
+ });
336
+ }
337
+ catch (err) {
338
+ // The feed matters more than the commands: a session that cannot be commanded still hears.
339
+ this.deps.log(`commands from the Bay are off for ${session}: ${err instanceof Error ? err.message : String(err)}`);
340
+ return undefined;
341
+ }
342
+ this.links.set(key, link);
343
+ return link;
344
+ }
345
+ closeCommandLink(key, link) {
346
+ link?.close();
347
+ if (link && this.links.get(key) === link)
348
+ this.links.delete(key);
270
349
  }
271
350
  /**
272
351
  * Where a permission prompt goes: the session that most recently started listening, while its
@@ -291,7 +370,11 @@ class ChannelService {
291
370
  for (const feed of this.feeds.values())
292
371
  feed.stop();
293
372
  this.feeds.clear();
373
+ for (const link of this.links.values())
374
+ link.close();
375
+ this.links.clear();
294
376
  this.last = undefined;
377
+ this.deps.claudeLink?.remove(undefined);
295
378
  }
296
379
  }
297
380
  exports.ChannelService = ChannelService;
@@ -325,6 +408,8 @@ async function startChannelServer(deps) {
325
408
  callTool: deps?.callTool ?? realCallTool,
326
409
  connect: deps?.connect ?? exports.nodeFeedConnect,
327
410
  log,
411
+ commandLink: deps?.commandLink ?? realCommandLink(log),
412
+ claudeLink: deps?.claudeLink ?? (0, claude_link_1.realClaudeLink)(log),
328
413
  }, async (content, meta) => {
329
414
  try {
330
415
  await mcp.notification({ method: "notifications/claude/channel", params: { content, meta } });