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