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.
- package/.claude-plugin/plugin.json +32 -0
- package/README.md +42 -4
- package/dist/approve-hook.js +93 -3
- package/dist/channel/claude-link.js +145 -0
- package/dist/channel/detect.js +10 -2
- package/dist/channel/feed.js +27 -13
- package/dist/channel/launch.js +67 -12
- package/dist/channel/permission.js +118 -0
- package/dist/channel/plugin.js +182 -0
- package/dist/channel/relay-link.js +257 -0
- package/dist/channel/server.js +191 -10
- package/dist/claude-onboarding.js +7 -5
- 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 +31 -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/skill-bootstrap.js +1 -1
- package/dist/start-command.js +12 -56
- package/dist/tool-defs.js +23 -36
- package/package.json +4 -3
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.PermissionRelay = exports.RELAY_OPTIONS = void 0;
|
|
4
|
+
exports.composeRelayQuestion = composeRelayQuestion;
|
|
5
|
+
/** Button labels, and the ONLY index that means yes. Same as `baychat approve-hook`. */
|
|
6
|
+
exports.RELAY_OPTIONS = ["Allow this once", "Deny"];
|
|
7
|
+
const ALLOW_INDEX = 0;
|
|
8
|
+
/** One window of the server's wait; it answers "waiting" when nobody tapped yet. */
|
|
9
|
+
const POLL_WAIT_SECONDS = 25;
|
|
10
|
+
const MAX_CONSECUTIVE_FAILURES = 5;
|
|
11
|
+
const RETRY_DELAY_MS = 3_000;
|
|
12
|
+
const QUESTION_MAX = 2_000;
|
|
13
|
+
const PREVIEW_MAX = 1_200;
|
|
14
|
+
function clip(text, max) {
|
|
15
|
+
return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
|
|
16
|
+
}
|
|
17
|
+
/** What the person on the phone reads. They decide from this text alone. */
|
|
18
|
+
function composeRelayQuestion(req, host, session) {
|
|
19
|
+
const lines = [
|
|
20
|
+
`Claude Code (session "${session}" on ${host}) asks to use ${req.tool_name}: ${req.description}`,
|
|
21
|
+
"",
|
|
22
|
+
req.input_preview ? clip(req.input_preview, PREVIEW_MAX) : "",
|
|
23
|
+
"",
|
|
24
|
+
"The same question is open in the terminal; whichever answer comes first counts. " +
|
|
25
|
+
"Allowing runs it on that machine, now. If you did not expect this, deny.",
|
|
26
|
+
];
|
|
27
|
+
return clip(lines.join("\n").replace(/\n{3,}/g, "\n\n").trim(), QUESTION_MAX);
|
|
28
|
+
}
|
|
29
|
+
class PermissionRelay {
|
|
30
|
+
deps;
|
|
31
|
+
current;
|
|
32
|
+
constructor(deps) {
|
|
33
|
+
this.deps = deps;
|
|
34
|
+
}
|
|
35
|
+
/** Relay one dialog. Resolves when it is settled, superseded, or given up on. Never throws. */
|
|
36
|
+
async onRequest(req) {
|
|
37
|
+
await this.retire();
|
|
38
|
+
const target = this.deps.target();
|
|
39
|
+
if (!target)
|
|
40
|
+
return; // Nobody is listening through this channel: the terminal alone decides.
|
|
41
|
+
const pending = { claudeId: req.request_id, login: target.login, retired: false };
|
|
42
|
+
this.current = pending;
|
|
43
|
+
try {
|
|
44
|
+
let reply = await this.deps.api(target.login, "POST", "/api/device-api/approvals", {
|
|
45
|
+
session: target.session,
|
|
46
|
+
question: composeRelayQuestion(req, this.deps.host(), target.session),
|
|
47
|
+
options: [...exports.RELAY_OPTIONS],
|
|
48
|
+
// The private chat with the owner: a session is usually in a group too, and a prompt about
|
|
49
|
+
// one person's machine belongs to that person, not to the room.
|
|
50
|
+
direct: true,
|
|
51
|
+
wait: 1,
|
|
52
|
+
});
|
|
53
|
+
pending.cardId = typeof reply.requestId === "string" && reply.requestId ? reply.requestId : undefined;
|
|
54
|
+
if (!pending.cardId) {
|
|
55
|
+
this.deps.log("permission relay: BayChat returned no card id");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
let failures = 0;
|
|
59
|
+
for (;;) {
|
|
60
|
+
if (pending.retired) {
|
|
61
|
+
await this.withdraw(pending);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (reply.status === "answered") {
|
|
65
|
+
await this.deps.verdict(req.request_id, reply.answerIndex === ALLOW_INDEX ? "allow" : "deny");
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (reply.status !== "waiting")
|
|
69
|
+
return; // cancelled, or a status we do not know: no verdict.
|
|
70
|
+
try {
|
|
71
|
+
reply = await this.deps.api(pending.login, "GET", `/api/device-api/approvals/${encodeURIComponent(pending.cardId)}?wait=${POLL_WAIT_SECONDS}`);
|
|
72
|
+
failures = 0;
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
failures++;
|
|
76
|
+
this.deps.log(`permission relay: waiting failed (${err instanceof Error ? err.message : String(err)}), attempt ${failures}`);
|
|
77
|
+
if (failures >= MAX_CONSECUTIVE_FAILURES) {
|
|
78
|
+
// Give the decision back to the terminal alone, and take the card off the phone.
|
|
79
|
+
await this.withdraw(pending);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
await (this.deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))))(RETRY_DELAY_MS);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
this.deps.log(`permission relay: could not ask BayChat (${err instanceof Error ? err.message : String(err)}); the terminal still decides`);
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
if (this.current === pending)
|
|
91
|
+
this.current = undefined;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** The current dialog is over (a newer one opened, or shutdown): stop and withdraw its card. */
|
|
95
|
+
async retire() {
|
|
96
|
+
const pending = this.current;
|
|
97
|
+
if (!pending)
|
|
98
|
+
return;
|
|
99
|
+
pending.retired = true;
|
|
100
|
+
this.current = undefined;
|
|
101
|
+
await this.withdraw(pending);
|
|
102
|
+
}
|
|
103
|
+
async withdraw(pending) {
|
|
104
|
+
if (!pending.cardId)
|
|
105
|
+
return; // Not created yet: the create path sees `retired` and withdraws.
|
|
106
|
+
const cardId = pending.cardId;
|
|
107
|
+
pending.cardId = undefined;
|
|
108
|
+
try {
|
|
109
|
+
await this.deps.api(pending.login, "POST", `/api/device-api/approvals/${encodeURIComponent(cardId)}/withdraw`, {});
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
// An older server (no route) or a blip: the card stays up, and a tap on it is dropped by
|
|
113
|
+
// Claude Code as an unknown id. Untidy, never unsafe.
|
|
114
|
+
this.deps.log(`permission relay: could not withdraw card ${cardId} (${err instanceof Error ? err.message : String(err)})`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
exports.PermissionRelay = PermissionRelay;
|
|
@@ -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
|
+
}
|