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.
- package/.claude-plugin/plugin.json +24 -4
- package/README.md +41 -3
- package/dist/approve-hook.js +93 -3
- package/dist/channel/claude-link.js +145 -0
- package/dist/channel/feed.js +26 -12
- package/dist/channel/launch.js +59 -45
- package/dist/channel/plugin.js +182 -0
- package/dist/channel/relay-link.js +257 -0
- package/dist/channel/server.js +92 -7
- package/dist/cli-help.js +235 -0
- package/dist/commands.js +4 -4
- package/dist/connect-acp.js +3 -2
- package/dist/connect-claude.js +26 -10
- package/dist/device-login.js +2 -2
- package/dist/doctor-command.js +2 -0
- package/dist/doctor.js +97 -3
- package/dist/help-topics.js +4 -3
- package/dist/launch/claude-trust.js +221 -0
- package/dist/launch/command.js +177 -0
- package/dist/launch/policy.js +152 -0
- package/dist/machine-name.js +76 -0
- package/dist/main.js +16 -170
- package/dist/multiplexer-launch.js +271 -0
- package/dist/protocol-content.js +1 -1
- package/dist/relay/acp/agents.js +42 -19
- package/dist/relay/acp/daemon-glue.js +3 -2
- package/dist/relay/acp/runner.js +8 -7
- package/dist/relay/channel-sessions.js +66 -0
- package/dist/relay/commands.js +9 -2
- package/dist/relay/daemon.js +160 -9
- package/dist/relay/launcher/approval-settings.js +140 -0
- package/dist/relay/launcher/argv.js +83 -0
- package/dist/relay/launcher/capabilities.js +76 -0
- package/dist/relay/launcher/dev-channel-screen.fixtures.js +41 -0
- package/dist/relay/launcher/dev-channel-screen.js +47 -0
- package/dist/relay/launcher/handle.js +303 -0
- package/dist/relay/launcher/loop.js +231 -0
- package/dist/relay/launcher/multiplexer.js +168 -0
- package/dist/relay/launcher/started.js +63 -0
- package/dist/relay/profiles.js +1 -1
- package/dist/relay/registry.js +26 -0
- package/dist/relay/terminal-pane.js +31 -2
- package/dist/relay/transport.js +7 -2
- package/dist/relay/ws.js +18 -1
- package/dist/runtime-install.js +19 -3
- package/dist/runtimes.js +3 -0
- package/dist/start-command.js +12 -56
- package/dist/tool-defs.js +23 -36
- 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
|
+
}
|
package/dist/channel/server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 } });
|