baychat 0.15.0 → 0.17.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/README.md +25 -0
- package/dist/index.js +7 -1
- package/dist/relay/adapters.js +89 -7
- package/dist/relay/commands.js +39 -4
- package/dist/relay/daemon.js +16 -0
- package/dist/relay/owner-pid.js +40 -0
- package/dist/relay/profiles.js +210 -0
- package/dist/relay/provider-env.js +180 -0
- package/dist/runtime-binary.js +21 -2
- package/dist/runtimes.js +52 -3
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -131,6 +131,31 @@ history, which the relay searches in both places.
|
|
|
131
131
|
If you hit something else, `baychat doctor --json` plus what you expected is
|
|
132
132
|
everything we need.
|
|
133
133
|
|
|
134
|
+
## Which agents can use this?
|
|
135
|
+
|
|
136
|
+
**Probably yours.** BayChat reaches an agent on two levels, and the first one asks almost nothing:
|
|
137
|
+
|
|
138
|
+
- **Level 1 — while it is listening.** The agent runs one command and waits; a message is handed
|
|
139
|
+
straight over. Any agent that can run a shell command and wait qualifies.
|
|
140
|
+
- **Level 2 — when it is *not* listening.** BayChat restarts the agent and drops it back into the
|
|
141
|
+
right conversation. This needs the agent to be able to say which session it is, and to have a
|
|
142
|
+
way to resume that session without a UI (`--resume <id>` and friends).
|
|
143
|
+
|
|
144
|
+
Adapters ship for **Claude Code**, **Codex**, **Cursor** and **Hermes**. Many others — Gemini CLI,
|
|
145
|
+
Copilot CLI, Goose, OpenCode/Crush, Qwen Code, Kimi Code CLI, CodeBuddy, iFlow, Trae, Aider — look
|
|
146
|
+
compatible on paper, with per-agent detail, exact flags, known bugs and a **date on every row** in
|
|
147
|
+
[`RUNTIME_COMPATIBILITY.md`](https://github.com/SeaQuestdev/BayChat/blob/main/docs/features/RUNTIME_COMPATIBILITY.md).
|
|
148
|
+
|
|
149
|
+
**Using a different model inside one of those agents changes nothing.** GLM, DeepSeek, MiniMax,
|
|
150
|
+
Kimi and others ship Anthropic-compatible endpoints, and people run them inside Claude Code. The
|
|
151
|
+
relay wakes a *program* — it has no concept of which model is behind it. Point your agent at your
|
|
152
|
+
provider, then join a Bay normally.
|
|
153
|
+
|
|
154
|
+
> That compatibility table is **read from vendor documentation**, not run by us, for everything
|
|
155
|
+
> except the four with adapters — and these projects move fast. If a row is wrong,
|
|
156
|
+
> [tell us](https://github.com/SeaQuestdev/BayChat/issues) rather than assuming it cannot work.
|
|
157
|
+
> Treat any row older than about three months as unverified.
|
|
158
|
+
|
|
134
159
|
## Relay
|
|
135
160
|
|
|
136
161
|
A Claude Code or Codex session has **no background listener**. It runs when a
|
package/dist/index.js
CHANGED
|
@@ -10,6 +10,8 @@ const mcp_config_1 = require("./mcp-config");
|
|
|
10
10
|
const commands_2 = require("./relay/commands");
|
|
11
11
|
const help_topics_1 = require("./help-topics");
|
|
12
12
|
const args_1 = require("./args");
|
|
13
|
+
const owner_pid_1 = require("./relay/owner-pid");
|
|
14
|
+
const profiles_1 = require("./relay/profiles");
|
|
13
15
|
const HELP = `baychat — BayChat connector CLI for agent sessions (Claude Code, Codex)
|
|
14
16
|
|
|
15
17
|
Usage:
|
|
@@ -272,7 +274,11 @@ async function main() {
|
|
|
272
274
|
const timeoutSec = numberFlag(rest, "--timeout");
|
|
273
275
|
return await (0, commands_2.cmdRelayAttach)({
|
|
274
276
|
session,
|
|
275
|
-
|
|
277
|
+
// Detected from the process tree when not given, so a person
|
|
278
|
+
// connecting Gemini or Kimi does not have to know the flag exists.
|
|
279
|
+
// Defaulting to "claude" for everyone was fine when four runtimes
|
|
280
|
+
// were all we served; it is a wrong answer now.
|
|
281
|
+
runtime: (0, args_1.flag)(rest, "--runtime") ?? (0, owner_pid_1.detectRuntime)(profiles_1.RUNTIME_PROFILES) ?? "claude",
|
|
276
282
|
resumeId: (0, args_1.flag)(rest, "--resume-id"),
|
|
277
283
|
timeoutMs: timeoutSec ? timeoutSec * 1000 : undefined,
|
|
278
284
|
// Passed by `relay rearm`, which reads it where the runtime is still
|
package/dist/relay/adapters.js
CHANGED
|
@@ -10,6 +10,7 @@ const attachments_1 = require("../attachments");
|
|
|
10
10
|
const codex_app_server_1 = require("./codex-app-server");
|
|
11
11
|
const codex_queue_1 = require("./codex-queue");
|
|
12
12
|
const resume_1 = require("./resume");
|
|
13
|
+
const profiles_1 = require("./profiles");
|
|
13
14
|
const spawn_env_1 = require("./spawn-env");
|
|
14
15
|
/** How long a headless turn may run before the relay gives up on it. */
|
|
15
16
|
const HEADLESS_TIMEOUT_MS = 10 * 60_000;
|
|
@@ -271,18 +272,99 @@ const ADAPTERS = {
|
|
|
271
272
|
cursor: cursorAdapter,
|
|
272
273
|
hermes: hermesAdapter,
|
|
273
274
|
};
|
|
275
|
+
/**
|
|
276
|
+
* An agent we ship no knowledge of at all.
|
|
277
|
+
*
|
|
278
|
+
* Level 1 and honest about it. This exists so that an unrecognised runtime is
|
|
279
|
+
* SERVED rather than refused: while its attach is listening it is reached over
|
|
280
|
+
* the socket like anything else, and when it is not, the message is recorded
|
|
281
|
+
* pending with a reason a human can act on — instead of `relay attach` exiting
|
|
282
|
+
* 1 and the agent never joining at all.
|
|
283
|
+
*/
|
|
284
|
+
function unknownAdapter(runtime) {
|
|
285
|
+
return {
|
|
286
|
+
runtime,
|
|
287
|
+
canResume: () => ({
|
|
288
|
+
ok: false,
|
|
289
|
+
reason: `no resume is known for "${runtime}" — it is reachable while \`baychat relay attach\` is running, and a message arriving otherwise waits here. If it has a way to continue a session without a UI, tell us and we will ship it: https://github.com/SeaQuestdev/BayChat/issues`,
|
|
290
|
+
}),
|
|
291
|
+
discoverResume: async () => ({
|
|
292
|
+
ok: false,
|
|
293
|
+
reason: `no on-disk layout is known for "${runtime}", so its sessions cannot be identified from here`,
|
|
294
|
+
}),
|
|
295
|
+
headlessCommand() {
|
|
296
|
+
throw new Error(`${runtime} has no headless command`);
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* An adapter built from a shipped PROFILE rather than hand-written code.
|
|
302
|
+
*
|
|
303
|
+
* The research behind `profiles.ts` found that the shape is universal and only
|
|
304
|
+
* the spelling differs — so for most runtimes an adapter is an argv template,
|
|
305
|
+
* and this turns one into the interface the daemon already speaks.
|
|
306
|
+
*/
|
|
307
|
+
function profileAdapter(profile) {
|
|
308
|
+
return {
|
|
309
|
+
runtime: profile.id,
|
|
310
|
+
canResume(target) {
|
|
311
|
+
if (!profile.headless) {
|
|
312
|
+
return {
|
|
313
|
+
ok: false,
|
|
314
|
+
reason: `${profile.label} cannot be resumed without a UI: ${profile.noHeadlessReason} (checked ${profile.checked}, from ${profile.source} — if this has changed, tell us)`,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
// `session-name` runtimes are resumed by the name the human chose, which
|
|
318
|
+
// IS the BayChat session name — so there is nothing to discover and
|
|
319
|
+
// nothing that could name someone else's session.
|
|
320
|
+
const id = profile.sessionId.kind === "session-name" ? target.name : target.resumeId;
|
|
321
|
+
if (!id) {
|
|
322
|
+
return {
|
|
323
|
+
ok: false,
|
|
324
|
+
reason: `no ${profile.label} session id recorded — ${profile.sessionId.kind === "flag-only" ? profile.sessionId.how : "the session did not report one"}. Pass it with \`relay attach --resume-id\`.`,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
return { ok: true };
|
|
328
|
+
},
|
|
329
|
+
// Deliberately never guesses. A profile describes how to USE an id, not how
|
|
330
|
+
// to find one on disk, and "the newest session in this folder" is not an
|
|
331
|
+
// identification — it is how an agent ends up answering a room it has no
|
|
332
|
+
// memory of.
|
|
333
|
+
discoverResume: async () => ({
|
|
334
|
+
ok: false,
|
|
335
|
+
reason: `${profile.label} sessions cannot be identified from disk by this CLI — pass \`--resume-id\` at attach time`,
|
|
336
|
+
}),
|
|
337
|
+
headlessCommand(target, prompt) {
|
|
338
|
+
if (!profile.headless)
|
|
339
|
+
throw new Error(`${profile.id} has no headless command`);
|
|
340
|
+
const id = profile.sessionId.kind === "session-name" ? target.name : target.resumeId;
|
|
341
|
+
return {
|
|
342
|
+
file: target.runtimeBin ?? profile.bin,
|
|
343
|
+
args: (0, profiles_1.fillTemplate)(profile.headless.args, { id, prompt }),
|
|
344
|
+
};
|
|
345
|
+
},
|
|
346
|
+
};
|
|
347
|
+
}
|
|
274
348
|
function adapterFor(runtime) {
|
|
275
|
-
|
|
349
|
+
const builtin = ADAPTERS[runtime];
|
|
350
|
+
if (builtin)
|
|
351
|
+
return builtin;
|
|
352
|
+
const profile = (0, profiles_1.profileFor)(runtime);
|
|
353
|
+
return profile ? profileAdapter(profile) : unknownAdapter(runtime);
|
|
276
354
|
}
|
|
277
|
-
/**
|
|
278
|
-
exports.KNOWN_RUNTIMES = Object.keys(ADAPTERS);
|
|
355
|
+
/** The runtimes we ship knowledge of, for help text. NOT a list of what is accepted. */
|
|
356
|
+
exports.KNOWN_RUNTIMES = [...Object.keys(ADAPTERS), ...profiles_1.RUNTIME_PROFILES.map((p) => p.id)];
|
|
279
357
|
/**
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
358
|
+
* Do we ship knowledge of this runtime?
|
|
359
|
+
*
|
|
360
|
+
* ⚠️ This is NO LONGER a gate on attaching — `relay attach` accepts any name and
|
|
361
|
+
* serves an unknown one at Level 1. It answers "will we do better than the
|
|
362
|
+
* floor for this?", which is a different question and must not be used to
|
|
363
|
+
* refuse. Refusing on a name is what kept agents out that needed nothing from
|
|
364
|
+
* us, and the Cursor outage was the same mistake from the other side.
|
|
283
365
|
*/
|
|
284
366
|
function isKnownRuntime(value) {
|
|
285
|
-
return Object.prototype.hasOwnProperty.call(ADAPTERS, value);
|
|
367
|
+
return Object.prototype.hasOwnProperty.call(ADAPTERS, value) || (0, profiles_1.profileFor)(value) !== undefined;
|
|
286
368
|
}
|
|
287
369
|
/**
|
|
288
370
|
* Run a headless turn to completion.
|
package/dist/relay/commands.js
CHANGED
|
@@ -50,6 +50,8 @@ const net = __importStar(require("net"));
|
|
|
50
50
|
const path = __importStar(require("path"));
|
|
51
51
|
const util_1 = require("util");
|
|
52
52
|
const adapters_1 = require("./adapters");
|
|
53
|
+
const profiles_1 = require("./profiles");
|
|
54
|
+
const provider_env_1 = require("./provider-env");
|
|
53
55
|
const autostart_1 = require("./autostart");
|
|
54
56
|
const runtime_binary_1 = require("../runtime-binary");
|
|
55
57
|
const owner_pid_1 = require("./owner-pid");
|
|
@@ -577,11 +579,30 @@ function renderSessionLine(target, lastWakeFailure) {
|
|
|
577
579
|
return ` ${target.name} [${target.runtime}] ${sessionState(target, lastWakeFailure)}`;
|
|
578
580
|
}
|
|
579
581
|
async function cmdRelayAttach(opts) {
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
582
|
+
// NO LONGER A GATE.
|
|
583
|
+
//
|
|
584
|
+
// This used to exit 1 on any name outside a list of four, which refused
|
|
585
|
+
// agents that needed nothing from us: being handed a message over an attach
|
|
586
|
+
// socket asks nothing of a runtime beyond running a command and waiting.
|
|
587
|
+
// `unknown runtime "kimi"` was us declining to serve something we could serve
|
|
588
|
+
// perfectly well. An unrecognised name now attaches and works at Level 1, and
|
|
589
|
+
// says plainly what it cannot do.
|
|
584
590
|
const runtime = opts.runtime;
|
|
591
|
+
const profile = (0, profiles_1.profileFor)(runtime);
|
|
592
|
+
if (!(0, adapters_1.isKnownRuntime)(runtime)) {
|
|
593
|
+
console.log(`No built-in profile for "${runtime}" — attaching anyway.`);
|
|
594
|
+
console.log(" It will be woken while this attach is running. When nothing is listening, a");
|
|
595
|
+
console.log(" message waits here instead and `relay status` says why.");
|
|
596
|
+
console.log(" If it can continue a session without a UI, tell us and we will ship support:");
|
|
597
|
+
console.log(" https://github.com/SeaQuestdev/BayChat/issues");
|
|
598
|
+
}
|
|
599
|
+
else if (profile && !profile.headless) {
|
|
600
|
+
// Said at ATTACH time, not discovered weeks later from a pending entry. A
|
|
601
|
+
// limitation nobody was told about reads as a broken relay.
|
|
602
|
+
console.log(`${profile.label}: reachable while this attach is running.`);
|
|
603
|
+
console.log(` Not resumable when nothing is listening — ${profile.noHeadlessReason}.`);
|
|
604
|
+
console.log(` (checked ${profile.checked}; if that has changed, please tell us)`);
|
|
605
|
+
}
|
|
585
606
|
// An explicit BAYCHAT_<RUNTIME>_BIN is a user ASSERTION, not a hint: they have
|
|
586
607
|
// told us which binary this session runs under. If it cannot be proven, the
|
|
587
608
|
// only honest move is to stop. Recording `runtimeBin: undefined` and attaching
|
|
@@ -595,6 +616,16 @@ async function cmdRelayAttach(opts) {
|
|
|
595
616
|
console.log("Not attaching. Fix it or unset it — nothing was registered, so the relay will not report this session as reachable.");
|
|
596
617
|
return 1;
|
|
597
618
|
}
|
|
619
|
+
// Read from THIS session's environment, which is the only place it exists —
|
|
620
|
+
// the daemon's own environment is a different thing entirely, and that gap is
|
|
621
|
+
// the whole reason this is recorded here rather than looked up there.
|
|
622
|
+
const provider = (0, provider_env_1.providerOverrideFrom)(process.env);
|
|
623
|
+
if (provider) {
|
|
624
|
+
console.log(`Provider override: ${provider.url} (${provider.variable}).`);
|
|
625
|
+
console.log(" Carried over as the URL only — your key is deliberately NOT stored by BayChat.");
|
|
626
|
+
console.log(" While this attach is running you are reached without being restarted, so it does not matter.");
|
|
627
|
+
console.log(" For wakes when nothing is listening, put the key in the relay service once; `relay status` says how.");
|
|
628
|
+
}
|
|
598
629
|
const resume = await resolveAttachResumeId(runtime, opts.resumeId, opts.discovery);
|
|
599
630
|
const sockPath = (0, socket_1.socketPath)();
|
|
600
631
|
const probe = await (0, socket_1.probeSocketDetailed)(sockPath);
|
|
@@ -689,6 +720,10 @@ async function cmdRelayAttach(opts) {
|
|
|
689
720
|
// found by walking past the launching shell, which does not outlive the
|
|
690
721
|
// wake and so cannot witness anything.
|
|
691
722
|
ownerPid: opts.ownerPid ?? (0, owner_pid_1.currentOwnerPid)(runtime),
|
|
723
|
+
// The URL only. `provider-env.ts` is the module that guarantees no
|
|
724
|
+
// credential travels with it.
|
|
725
|
+
providerUrl: provider?.url,
|
|
726
|
+
providerVar: provider?.variable,
|
|
692
727
|
});
|
|
693
728
|
});
|
|
694
729
|
}
|
package/dist/relay/daemon.js
CHANGED
|
@@ -43,6 +43,7 @@ const adapters_1 = require("./adapters");
|
|
|
43
43
|
const parent_watch_1 = require("./parent-watch");
|
|
44
44
|
const queue_1 = require("./queue");
|
|
45
45
|
const held_1 = require("./held");
|
|
46
|
+
const provider_env_1 = require("./provider-env");
|
|
46
47
|
const registry_1 = require("./registry");
|
|
47
48
|
const mailbox_1 = require("./mailbox");
|
|
48
49
|
const mailbox_watcher_1 = require("./mailbox-watcher");
|
|
@@ -575,6 +576,19 @@ class RelayDaemon {
|
|
|
575
576
|
this.record({ kind: "pending", session, reason }, session, batch);
|
|
576
577
|
return;
|
|
577
578
|
}
|
|
579
|
+
// A SESSION POINTED AT ANOTHER PROVIDER CANNOT BE STARTED BY US.
|
|
580
|
+
//
|
|
581
|
+
// Same shape as the PATH bug and the binary-path bug before it: the session
|
|
582
|
+
// knows something about itself that the daemon's environment does not have.
|
|
583
|
+
// Here the missing piece is a credential we deliberately do not store, so
|
|
584
|
+
// the honest move is to refuse and say exactly what to do — spawning anyway
|
|
585
|
+
// would come up on the DEFAULT provider and either fail or quietly answer
|
|
586
|
+
// from an account the person never meant to spend.
|
|
587
|
+
const providerBlocker = (0, provider_env_1.providerWakeBlocker)(target, process.env);
|
|
588
|
+
if (providerBlocker) {
|
|
589
|
+
this.record({ kind: "pending", session, reason: providerBlocker }, session, batch);
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
578
592
|
// The daemon knows both absolute paths because it IS them — and a session it
|
|
579
593
|
// resumes inherits ITS environment, where neither `node` nor `baychat` is on
|
|
580
594
|
// PATH. Without this the woken session is told to re-arm with a command it
|
|
@@ -811,6 +825,8 @@ class RelayDaemon {
|
|
|
811
825
|
cwd: frame.cwd,
|
|
812
826
|
runtimeBin: frame.runtimeBin,
|
|
813
827
|
ownerPid: frame.ownerPid,
|
|
828
|
+
providerUrl: frame.providerUrl,
|
|
829
|
+
providerVar: frame.providerVar,
|
|
814
830
|
});
|
|
815
831
|
// A session that names itself supersedes anything discovery guessed
|
|
816
832
|
// for it, so drop the throttle and let the next wake use the new id.
|
package/dist/relay/owner-pid.js
CHANGED
|
@@ -37,6 +37,7 @@ exports.procTable = void 0;
|
|
|
37
37
|
exports.ownerPidFor = ownerPidFor;
|
|
38
38
|
exports.isRuntimeCommand = isRuntimeCommand;
|
|
39
39
|
exports.currentOwnerPid = currentOwnerPid;
|
|
40
|
+
exports.detectRuntime = detectRuntime;
|
|
40
41
|
const fs = __importStar(require("fs"));
|
|
41
42
|
const path = __importStar(require("path"));
|
|
42
43
|
/** How far to walk before giving up. Deep enough for shell wrappers, bounded so a cycle cannot hang an attach. */
|
|
@@ -127,3 +128,42 @@ exports.procTable = {
|
|
|
127
128
|
function currentOwnerPid(runtime) {
|
|
128
129
|
return ownerPidFor(runtime, process.ppid, exports.procTable);
|
|
129
130
|
}
|
|
131
|
+
/**
|
|
132
|
+
* Which agent are we running INSIDE?
|
|
133
|
+
*
|
|
134
|
+
* Asked of the process tree rather than the environment, and that choice is the
|
|
135
|
+
* whole point. Guessing environment-variable names for a dozen agents would put
|
|
136
|
+
* a claim in the code for every one of them, and a variable that does not expand
|
|
137
|
+
* produces a confident wrong answer — the failure this codebase keeps meeting.
|
|
138
|
+
* An ancestor process named `gemini` is not a guess; it is the agent, observed.
|
|
139
|
+
*
|
|
140
|
+
* Reuses the same walk and the same "IS the runtime, not merely mentions it"
|
|
141
|
+
* test as `ownerPidFor`, so a `--runtime kimi` argument on our own command line
|
|
142
|
+
* cannot make us detect ourselves.
|
|
143
|
+
*
|
|
144
|
+
* Returns undefined freely. Undetected is a fine outcome — the user can say
|
|
145
|
+
* `--runtime <name>`, and an unnamed runtime is still served at Level 1. A wrong
|
|
146
|
+
* detection would be far worse: it would attach the session to another agent's
|
|
147
|
+
* resume rules.
|
|
148
|
+
*/
|
|
149
|
+
function detectRuntime(candidates, startPid = process.ppid, table = exports.procTable) {
|
|
150
|
+
let pid = startPid;
|
|
151
|
+
const seen = new Set();
|
|
152
|
+
for (let hop = 0; hop < MAX_HOPS; hop++) {
|
|
153
|
+
if (pid <= 1 || seen.has(pid))
|
|
154
|
+
return undefined;
|
|
155
|
+
seen.add(pid);
|
|
156
|
+
const command = table.commandOf(pid);
|
|
157
|
+
// NEAREST ancestor wins. An agent launched from inside another agent should
|
|
158
|
+
// resolve to the one actually running this session, not the outer shell's.
|
|
159
|
+
for (const candidate of candidates) {
|
|
160
|
+
if (isRuntimeCommand(command, candidate.bin))
|
|
161
|
+
return candidate.id;
|
|
162
|
+
}
|
|
163
|
+
const parent = table.parentOf(pid);
|
|
164
|
+
if (parent === undefined)
|
|
165
|
+
return undefined;
|
|
166
|
+
pid = parent;
|
|
167
|
+
}
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Built-in knowledge of agent runtimes we did not write an adapter for.
|
|
4
|
+
*
|
|
5
|
+
* The point of this file is that **a person connecting a new agent should not
|
|
6
|
+
* have to write anything.** They cannot be expected to know what a resume flag
|
|
7
|
+
* is, and asking them to describe their own agent in a config file is the same
|
|
8
|
+
* demand wearing a friendlier hat. So the knowledge lives here, shipped, and
|
|
9
|
+
* the CLI recognises the agent it is running inside.
|
|
10
|
+
*
|
|
11
|
+
* What the research found (2026-08-31, sources on each entry): the SHAPE is
|
|
12
|
+
* universal and only the spelling differs. Almost every serious agent CLI has a
|
|
13
|
+
* run-without-a-UI flag and a resume-a-session flag. So a runtime is DATA — a
|
|
14
|
+
* binary name and an argv template — not a program. That is why adding one here
|
|
15
|
+
* is a table row rather than an adapter.
|
|
16
|
+
*
|
|
17
|
+
* ## Two levels, and most agents only reach the first
|
|
18
|
+
*
|
|
19
|
+
* - **Level 1** — reachable while its `relay attach` is running. Needs nothing
|
|
20
|
+
* from the agent but the ability to run a command and wait. Every profile here
|
|
21
|
+
* gets this, including ones with no entry at all.
|
|
22
|
+
* - **Level 2** — reachable when nothing is listening, by resuming the session.
|
|
23
|
+
* Needs BOTH a `headless` template AND a way to know the session's id.
|
|
24
|
+
*
|
|
25
|
+
* ## The rule this file must not break
|
|
26
|
+
*
|
|
27
|
+
* **Never advertise something we have not confirmed.** A profile claiming an
|
|
28
|
+
* environment variable that does not expand produces an attach that records
|
|
29
|
+
* nothing and looks exactly like success — the failure mode this codebase has
|
|
30
|
+
* been bitten by more than once. So `sessionId` is `flag-only` unless the
|
|
31
|
+
* variable is known to exist, and `headless` is omitted entirely where the
|
|
32
|
+
* vendor's resume path is unconfirmed or known broken. An entry that offers
|
|
33
|
+
* less than the agent can do costs a little reach; one that offers more costs
|
|
34
|
+
* silence, which is far worse.
|
|
35
|
+
*
|
|
36
|
+
* `confidence` says which of those two we are on. Only `claude` and `codex` are
|
|
37
|
+
* `run`; everything else is `docs` — read from the vendor, never executed here.
|
|
38
|
+
* See `docs/features/RUNTIME_COMPATIBILITY.md`, which is generated from the same
|
|
39
|
+
* research and carries the same dates.
|
|
40
|
+
*/
|
|
41
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
42
|
+
exports.RUNTIME_PROFILES = exports.BUILTIN_RUNTIMES = void 0;
|
|
43
|
+
exports.profileFor = profileFor;
|
|
44
|
+
exports.reachesLevel2 = reachesLevel2;
|
|
45
|
+
exports.fillTemplate = fillTemplate;
|
|
46
|
+
/**
|
|
47
|
+
* Runtimes with a hand-written adapter in `adapters.ts`. Not in this table —
|
|
48
|
+
* they do more than a template can express (Codex's app-server and queue,
|
|
49
|
+
* Claude's transcript discovery).
|
|
50
|
+
*/
|
|
51
|
+
exports.BUILTIN_RUNTIMES = ["claude", "codex", "cursor", "hermes"];
|
|
52
|
+
exports.RUNTIME_PROFILES = [
|
|
53
|
+
{
|
|
54
|
+
id: "gemini",
|
|
55
|
+
label: "Gemini CLI",
|
|
56
|
+
bin: "gemini",
|
|
57
|
+
sessionId: {
|
|
58
|
+
kind: "flag-only",
|
|
59
|
+
how: "`gemini --list-sessions` prints them; sessions live under ~/.gemini/tmp/<project-hash>/chats/. Exposing the id to a running session is an open request (google-gemini/gemini-cli#14435).",
|
|
60
|
+
},
|
|
61
|
+
headless: { args: ["-r", "{id}", "{prompt}"] },
|
|
62
|
+
confidence: "docs",
|
|
63
|
+
checked: "2026-08-31",
|
|
64
|
+
source: "https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/session-management.md",
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
id: "copilot",
|
|
68
|
+
label: "GitHub Copilot CLI",
|
|
69
|
+
bin: "copilot",
|
|
70
|
+
sessionId: {
|
|
71
|
+
kind: "flag-only",
|
|
72
|
+
how: "The id is printed after a non-interactive run, and `/session` shows it interactively. Exposing it to the session itself is an open request (github/copilot-cli#895, #807).",
|
|
73
|
+
},
|
|
74
|
+
// NOTE: deliberately WITHOUT `--allow-all-tools`. That flag hands an agent
|
|
75
|
+
// every tool unattended, and a chat message must never be the thing that
|
|
76
|
+
// grants a privilege. If a user wants it, that is their decision to make in
|
|
77
|
+
// their own configuration, not one this table makes silently on their behalf.
|
|
78
|
+
headless: { args: ["-p", "{prompt}", "--resume", "{id}"] },
|
|
79
|
+
confidence: "docs",
|
|
80
|
+
checked: "2026-08-31",
|
|
81
|
+
source: "https://docs.github.com/en/copilot/how-tos/copilot-sdk/features/session-persistence",
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
id: "goose",
|
|
85
|
+
label: "Goose",
|
|
86
|
+
bin: "goose",
|
|
87
|
+
// The whole reason Goose is the best Level 2 candidate here: the handle is
|
|
88
|
+
// a name the human picked, and BayChat already asked for one.
|
|
89
|
+
sessionId: { kind: "session-name" },
|
|
90
|
+
headless: { args: ["run", "--resume", "--name", "{id}", "-t", "{prompt}"] },
|
|
91
|
+
confidence: "docs",
|
|
92
|
+
checked: "2026-08-31",
|
|
93
|
+
source: "https://goose-docs.ai/docs/guides/goose-cli-commands/",
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
id: "kimi",
|
|
97
|
+
label: "Kimi Code CLI",
|
|
98
|
+
bin: "kimi",
|
|
99
|
+
sessionId: {
|
|
100
|
+
kind: "flag-only",
|
|
101
|
+
how: "Ids exist — `kimi export <session_id>` takes one, and the CLI prints a resume hint when a session exits — but whether a running session can read its own is not documented.",
|
|
102
|
+
},
|
|
103
|
+
headless: { args: ["-p", "{prompt}", "--resume", "{id}"] },
|
|
104
|
+
confidence: "docs",
|
|
105
|
+
checked: "2026-08-31",
|
|
106
|
+
source: "https://moonshotai.github.io/kimi-cli/en/reference/kimi-command.html",
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
id: "opencode",
|
|
110
|
+
label: "OpenCode",
|
|
111
|
+
bin: "opencode",
|
|
112
|
+
sessionId: { kind: "flag-only", how: "Ids are accepted by `--session`; how a session reads its own is not documented." },
|
|
113
|
+
headless: { args: ["--session", "{id}", "--prompt", "{prompt}"] },
|
|
114
|
+
confidence: "docs",
|
|
115
|
+
checked: "2026-08-31",
|
|
116
|
+
source: "https://open-code.ai/en/docs/cli",
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
id: "qwen",
|
|
120
|
+
label: "Qwen Code",
|
|
121
|
+
bin: "qwen",
|
|
122
|
+
// Gate 1 solved unusually well — by asking the CLI rather than the session.
|
|
123
|
+
// Gate 2 is the missing half: `/resume` is documented as an IN-SESSION slash
|
|
124
|
+
// command, and a slash command cannot be typed by a daemon.
|
|
125
|
+
sessionId: { kind: "flag-only", how: "`qwen sessions list --json` and `qwen sessions ps --json` expose `sessionId`." },
|
|
126
|
+
noHeadlessReason: "resume is documented as the in-session `/resume` command, not a startup flag — so there is no confirmed way to continue a specific session non-interactively",
|
|
127
|
+
confidence: "docs",
|
|
128
|
+
checked: "2026-08-31",
|
|
129
|
+
source: "https://qwenlm.github.io/qwen-code-docs/en/users/features/commands/",
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
id: "codebuddy",
|
|
133
|
+
label: "CodeBuddy (Tencent Cloud)",
|
|
134
|
+
bin: "codebuddy",
|
|
135
|
+
sessionId: { kind: "flag-only", how: "`codebuddy -r <session-id>` accepts one; how a session reads its own is not documented." },
|
|
136
|
+
noHeadlessReason: "`-r <session-id>` resumes interactively; no non-interactive prompt flag is documented alongside it, so a wake would open a UI nobody is watching",
|
|
137
|
+
confidence: "docs",
|
|
138
|
+
checked: "2026-08-31",
|
|
139
|
+
source: "https://www.codebuddy.ai/docs/cli/reference",
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
id: "iflow",
|
|
143
|
+
label: "iFlow CLI",
|
|
144
|
+
bin: "iflow",
|
|
145
|
+
sessionId: { kind: "flag-only", how: "`iflow --resume` browses them interactively." },
|
|
146
|
+
// Documented, and broken in exactly the mode we would use. This entry is
|
|
147
|
+
// why the compatibility table carries dates: without one it would read as
|
|
148
|
+
// supported.
|
|
149
|
+
noHeadlessReason: "`-r <session_id>` HANGS in headless mode — it drops into interactive and waits for input forever, even with `-y` (iflow-ai/iflow-cli#196). `-c` resumes only the previous session, which is not an identification.",
|
|
150
|
+
confidence: "docs",
|
|
151
|
+
checked: "2026-08-31",
|
|
152
|
+
source: "https://github.com/iflow-ai/iflow-cli/issues/196",
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
id: "trae",
|
|
156
|
+
label: "Trae Agent (ByteDance)",
|
|
157
|
+
bin: "trae-cli",
|
|
158
|
+
sessionId: { kind: "flag-only", how: "Trajectories are recorded per run; a resumable session id is not documented." },
|
|
159
|
+
noHeadlessReason: "a headless interface is on the project's roadmap and has not shipped",
|
|
160
|
+
confidence: "docs",
|
|
161
|
+
checked: "2026-08-31",
|
|
162
|
+
source: "https://github.com/bytedance/trae-agent/blob/main/docs/roadmap.md",
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
id: "aider",
|
|
166
|
+
label: "Aider",
|
|
167
|
+
bin: "aider",
|
|
168
|
+
sessionId: { kind: "flag-only", how: "Aider has no session ids at all." },
|
|
169
|
+
noHeadlessReason: "`--restore-chat-history` restores THE history for a repository, not a chosen session — so there is nothing to identify and nothing to resume",
|
|
170
|
+
confidence: "docs",
|
|
171
|
+
checked: "2026-08-31",
|
|
172
|
+
source: "https://aider.chat/docs/config/options.html",
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
id: "pi",
|
|
176
|
+
label: "Pi",
|
|
177
|
+
bin: "pi",
|
|
178
|
+
// Pi documents PI_SESSION_ID as injected into the bash tool it calls, which
|
|
179
|
+
// is precisely the self-identification shape. Still `docs`, not `run`.
|
|
180
|
+
sessionId: { kind: "env", variable: "PI_SESSION_ID" },
|
|
181
|
+
noHeadlessReason: "Pi exposes its session id, but a non-interactive resume command is not documented — half of Level 2, honestly reported",
|
|
182
|
+
confidence: "docs",
|
|
183
|
+
checked: "2026-08-31",
|
|
184
|
+
source: "https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/environment-variables.md",
|
|
185
|
+
},
|
|
186
|
+
];
|
|
187
|
+
function profileFor(id) {
|
|
188
|
+
return exports.RUNTIME_PROFILES.find((p) => p.id === id);
|
|
189
|
+
}
|
|
190
|
+
/** Can this profile be woken when nothing is listening? */
|
|
191
|
+
function reachesLevel2(profile) {
|
|
192
|
+
return profile.headless !== undefined;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Fill a headless template.
|
|
196
|
+
*
|
|
197
|
+
* Substitutes whole argv entries rather than doing string interpolation: an
|
|
198
|
+
* argument is either exactly `{prompt}` and becomes the prompt, or it is passed
|
|
199
|
+
* through untouched. A prompt containing quotes, newlines or `$(…)` is
|
|
200
|
+
* therefore inert — it is one argv element, never part of a command line.
|
|
201
|
+
*/
|
|
202
|
+
function fillTemplate(args, values) {
|
|
203
|
+
return args.map((arg) => {
|
|
204
|
+
if (arg === "{id}")
|
|
205
|
+
return values.id;
|
|
206
|
+
if (arg === "{prompt}")
|
|
207
|
+
return values.prompt;
|
|
208
|
+
return arg;
|
|
209
|
+
});
|
|
210
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* A session pointed at somebody else's model provider, and what the daemon can
|
|
4
|
+
* honestly do about it.
|
|
5
|
+
*
|
|
6
|
+
* ## The problem
|
|
7
|
+
*
|
|
8
|
+
* People run Claude Code against GLM, Kimi, DeepSeek and others by setting two
|
|
9
|
+
* variables in their own shell: a base URL and a credential. Those live in
|
|
10
|
+
* THAT terminal. The relay daemon is a background service with a different
|
|
11
|
+
* environment entirely — the same fact that made a headless wake die on
|
|
12
|
+
* `spawn claude ENOENT` in August, arriving again by a new door.
|
|
13
|
+
*
|
|
14
|
+
* While the session is attached this never shows: the message is handed to a
|
|
15
|
+
* process that already has its own settings. It only bites on a headless wake,
|
|
16
|
+
* where the daemon STARTS the agent and it comes up pointed at the default
|
|
17
|
+
* provider — failing, or worse, quietly answering from an account the person
|
|
18
|
+
* did not intend to spend.
|
|
19
|
+
*
|
|
20
|
+
* ## Why we do not fix it by carrying the settings over
|
|
21
|
+
*
|
|
22
|
+
* The obvious fix is to record them at attach time, the way `runtimeBin` and
|
|
23
|
+
* the resume id already are. One of the two is an **API key**, and Karmen's
|
|
24
|
+
* call on 2026-09-01 was that writing somebody's key into a file is a security
|
|
25
|
+
* cost not worth the convenience — a judgement this module exists to enforce
|
|
26
|
+
* rather than re-argue.
|
|
27
|
+
*
|
|
28
|
+
* So we record the BASE URL, which is a public endpoint and not a secret, and
|
|
29
|
+
* we never record a credential. What that buys is not automation but HONESTY:
|
|
30
|
+
* the daemon can see that a session had an override, notice that it has no
|
|
31
|
+
* credential of its own to start that agent with, and refuse with an
|
|
32
|
+
* instruction — instead of spawning something destined to fail and filing it as
|
|
33
|
+
* a delivery.
|
|
34
|
+
*
|
|
35
|
+
* ⚠️ Nothing here may ever return, log, or persist a value read from a
|
|
36
|
+
* credential variable. `provider-env.test.ts` pins that.
|
|
37
|
+
*/
|
|
38
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
39
|
+
if (k2 === undefined) k2 = k;
|
|
40
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
41
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
42
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
43
|
+
}
|
|
44
|
+
Object.defineProperty(o, k2, desc);
|
|
45
|
+
}) : (function(o, m, k, k2) {
|
|
46
|
+
if (k2 === undefined) k2 = k;
|
|
47
|
+
o[k2] = m[k];
|
|
48
|
+
}));
|
|
49
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
50
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
51
|
+
}) : function(o, v) {
|
|
52
|
+
o["default"] = v;
|
|
53
|
+
});
|
|
54
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
55
|
+
var ownKeys = function(o) {
|
|
56
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
57
|
+
var ar = [];
|
|
58
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
59
|
+
return ar;
|
|
60
|
+
};
|
|
61
|
+
return ownKeys(o);
|
|
62
|
+
};
|
|
63
|
+
return function (mod) {
|
|
64
|
+
if (mod && mod.__esModule) return mod;
|
|
65
|
+
var result = {};
|
|
66
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
67
|
+
__setModuleDefault(result, mod);
|
|
68
|
+
return result;
|
|
69
|
+
};
|
|
70
|
+
})();
|
|
71
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
72
|
+
exports.PROVIDER_KEY_VARS = exports.PROVIDER_URL_VARS = void 0;
|
|
73
|
+
exports.providerOverrideFrom = providerOverrideFrom;
|
|
74
|
+
exports.daemonHasProviderCredential = daemonHasProviderCredential;
|
|
75
|
+
exports.providerConfiguredOnDisk = providerConfiguredOnDisk;
|
|
76
|
+
exports.providerWakeBlocker = providerWakeBlocker;
|
|
77
|
+
const fs = __importStar(require("fs"));
|
|
78
|
+
const os = __importStar(require("os"));
|
|
79
|
+
const path = __importStar(require("path"));
|
|
80
|
+
/** Base-URL variables. Public endpoints — safe to record and to print. */
|
|
81
|
+
exports.PROVIDER_URL_VARS = [
|
|
82
|
+
"ANTHROPIC_BASE_URL",
|
|
83
|
+
"ANTHROPIC_API_URL",
|
|
84
|
+
"OPENAI_BASE_URL",
|
|
85
|
+
"OPENAI_API_BASE",
|
|
86
|
+
];
|
|
87
|
+
/**
|
|
88
|
+
* Credential variables. Their NAMES are used; their VALUES are never read.
|
|
89
|
+
*
|
|
90
|
+
* Kept as a list rather than a pattern so that adding a provider is a
|
|
91
|
+
* deliberate edit — a regex like /KEY|TOKEN/ would silently start matching
|
|
92
|
+
* variables nobody has thought about.
|
|
93
|
+
*/
|
|
94
|
+
exports.PROVIDER_KEY_VARS = [
|
|
95
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
96
|
+
"ANTHROPIC_API_KEY",
|
|
97
|
+
"OPENAI_API_KEY",
|
|
98
|
+
];
|
|
99
|
+
/**
|
|
100
|
+
* Is this session pointed somewhere other than its runtime's default?
|
|
101
|
+
*
|
|
102
|
+
* Reads only URL variables. A session with a key set but no base URL is using
|
|
103
|
+
* its runtime's normal provider with its own account, which is not an override
|
|
104
|
+
* and needs nothing from us.
|
|
105
|
+
*/
|
|
106
|
+
function providerOverrideFrom(env) {
|
|
107
|
+
for (const variable of exports.PROVIDER_URL_VARS) {
|
|
108
|
+
const url = env[variable];
|
|
109
|
+
if (url && url.trim())
|
|
110
|
+
return { variable, url: url.trim() };
|
|
111
|
+
}
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
/** Does the DAEMON hold a credential it could start such an agent with? */
|
|
115
|
+
function daemonHasProviderCredential(env) {
|
|
116
|
+
return exports.PROVIDER_KEY_VARS.some((v) => Boolean(env[v]?.trim()));
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Has the RUNTIME been configured on disk, where a spawned copy will find it?
|
|
120
|
+
*
|
|
121
|
+
* This is the question that makes ordinary Claude Code work at all: its login
|
|
122
|
+
* lives in `~/.claude/.credentials.json`, so a `claude` the daemon starts reads
|
|
123
|
+
* the same file and needs nothing handed to it. Environment variables are the
|
|
124
|
+
* opposite — they exist only in the shell they were typed into.
|
|
125
|
+
*
|
|
126
|
+
* Claude Code reads an `env` block from `~/.claude/settings.json` too, so a
|
|
127
|
+
* person can configure another provider THERE instead of in their shell. That
|
|
128
|
+
* configuration survives a spawn exactly like the credential does, and refusing
|
|
129
|
+
* such a wake would decline one that was going to work.
|
|
130
|
+
*
|
|
131
|
+
* Which is the rule this function exists to honour: **unknown must not cost a
|
|
132
|
+
* live session its delivery — only a definite "no" declines.** Where we cannot
|
|
133
|
+
* read a runtime's configuration we return `undefined`, and the caller must
|
|
134
|
+
* treat that as "let it try", never as "no".
|
|
135
|
+
*/
|
|
136
|
+
function providerConfiguredOnDisk(runtime, variable, readFile = (p) => fs.readFileSync(p, "utf8"), home = os.homedir()) {
|
|
137
|
+
// Only Claude Code's settings file is documented and stable enough to read.
|
|
138
|
+
// Every other runtime is genuinely unknown, and says so.
|
|
139
|
+
if (runtime !== "claude")
|
|
140
|
+
return undefined;
|
|
141
|
+
try {
|
|
142
|
+
const parsed = JSON.parse(readFile(path.join(home, ".claude", "settings.json")));
|
|
143
|
+
const env = parsed?.env;
|
|
144
|
+
if (!env || typeof env !== "object")
|
|
145
|
+
return false;
|
|
146
|
+
return Boolean(env[variable]?.trim());
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
// No file, or unreadable. "No file" is a definite no for THIS mechanism, but
|
|
150
|
+
// a parse failure is not — and distinguishing them costs more than it buys,
|
|
151
|
+
// so both come back as unknown and the wake is attempted.
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Why a headless wake would fail for this session, and exactly what to do — or
|
|
157
|
+
* undefined when it would be fine.
|
|
158
|
+
*
|
|
159
|
+
* Said as a `pending` reason rather than discovered from a failed turn, because
|
|
160
|
+
* a wake that dies inside a spawned process leaves a person with nothing to
|
|
161
|
+
* read. The whole point of refusing here is that the refusal can carry the fix.
|
|
162
|
+
*/
|
|
163
|
+
function providerWakeBlocker(target, daemonEnv, onDisk = providerConfiguredOnDisk) {
|
|
164
|
+
if (!target.providerVar)
|
|
165
|
+
return undefined;
|
|
166
|
+
if (daemonHasProviderCredential(daemonEnv))
|
|
167
|
+
return undefined;
|
|
168
|
+
// The runtime may already be configured where a spawned copy will find it —
|
|
169
|
+
// which is exactly how ordinary Claude Code works without anyone handing it
|
|
170
|
+
// anything. `undefined` means we could not tell, and NOT being able to tell
|
|
171
|
+
// must not cost a delivery.
|
|
172
|
+
if (target.runtime && onDisk(target.runtime, target.providerVar) !== false)
|
|
173
|
+
return undefined;
|
|
174
|
+
return (`this session runs against ${target.providerUrl ?? "a custom provider"} (${target.providerVar}), ` +
|
|
175
|
+
`and the relay has no credential of its own to start it with — so a headless wake would come up ` +
|
|
176
|
+
`on the DEFAULT provider instead of yours. Your key is deliberately not stored by BayChat. ` +
|
|
177
|
+
`Add it to the relay service once (systemd: \`systemctl --user edit baychat-relay\`, then ` +
|
|
178
|
+
`\`[Service]\` / \`Environment=${exports.PROVIDER_KEY_VARS[0]}=...\` and \`Environment=${target.providerVar}=${target.providerUrl ?? "..."}\`), ` +
|
|
179
|
+
`or keep this session attached, where it is reached without being restarted.`);
|
|
180
|
+
}
|
package/dist/runtime-binary.js
CHANGED
|
@@ -41,6 +41,7 @@ exports.currentBinaryEnv = currentBinaryEnv;
|
|
|
41
41
|
exports.summarizeResolutionFailure = summarizeResolutionFailure;
|
|
42
42
|
const child_process_1 = require("child_process");
|
|
43
43
|
const fs = __importStar(require("fs"));
|
|
44
|
+
const spawn_env_1 = require("./relay/spawn-env");
|
|
44
45
|
/**
|
|
45
46
|
* Resolve a runtime's executable, proving each candidate before accepting it.
|
|
46
47
|
*
|
|
@@ -231,12 +232,30 @@ function firstMeaningfulLine(text) {
|
|
|
231
232
|
*
|
|
232
233
|
* `shell: false` throughout: nothing here is ever concatenated into a command
|
|
233
234
|
* line, and a PATH entry is attacker-adjacent data on a shared machine.
|
|
235
|
+
*
|
|
236
|
+
* THE PATH SEARCHED IS THE PATH THE SPAWN WILL USE, not this process's raw one.
|
|
237
|
+
* The daemon resolves a runtime and then starts it, and those two steps must
|
|
238
|
+
* agree about where binaries are. `headlessSpawnEnv` prepends the daemon's own
|
|
239
|
+
* node directory — required because an npm runtime is a `#!/usr/bin/env node`
|
|
240
|
+
* script and systemd's PATH has no node — and that directory is also where npm
|
|
241
|
+
* puts the runtime's own symlink.
|
|
242
|
+
*
|
|
243
|
+
* Resolving against the raw PATH instead picked a DIFFERENT binary than the one
|
|
244
|
+
* about to be launched. Measured 2026-09-01, daemon pid 41259: systemd's PATH
|
|
245
|
+
* carried no nvm and did carry `/snap/bin`, so a bare `codex` resolved to the
|
|
246
|
+
* confined snap build, which cannot read `~/.codex/sessions`. Every wake below
|
|
247
|
+
* the socket rung failed with `no rollout found for thread id`, naming a
|
|
248
|
+
* rollout file that was on disk the whole time. The queue rung and the headless
|
|
249
|
+
* rung share this lookup, so one wrong PATH took out both.
|
|
250
|
+
*
|
|
251
|
+
* Only BARE names reach here — an absolute `SessionTarget.runtimeBin` is never
|
|
252
|
+
* re-resolved, and that rule is unchanged.
|
|
234
253
|
*/
|
|
235
|
-
function currentBinaryEnv(override) {
|
|
254
|
+
function currentBinaryEnv(override, env = process.env, execPath = process.execPath) {
|
|
236
255
|
const delimiter = process.platform === "win32" ? ";" : ":";
|
|
237
256
|
return {
|
|
238
257
|
platform: process.platform,
|
|
239
|
-
pathEntries: (
|
|
258
|
+
pathEntries: ((0, spawn_env_1.headlessSpawnEnv)(env, execPath).PATH ?? "").split(delimiter),
|
|
240
259
|
override,
|
|
241
260
|
isExecutable(candidate) {
|
|
242
261
|
try {
|
package/dist/runtimes.js
CHANGED
|
@@ -108,6 +108,55 @@ failure. Arm once, then get on with your work.
|
|
|
108
108
|
If you are ever unsure whether the relay knows you, run \`baychat relay status\`:
|
|
109
109
|
you are reachable when your session is listed with a resume id, whether or not
|
|
110
110
|
anything is attached.`;
|
|
111
|
+
}
|
|
112
|
+
if (reArm === "supervised-loop") {
|
|
113
|
+
// WHO runs attach again is the whole fix — attach itself is unchanged.
|
|
114
|
+
//
|
|
115
|
+
// `attach` is one-shot on purpose: it exits on a wake, and that exit is what
|
|
116
|
+
// re-invokes the session. Until 2026-09-01 the thing that ran it again was
|
|
117
|
+
// the MODEL, instructed here to remember after every turn. One interrupted
|
|
118
|
+
// turn and the session is deaf, silently and for good.
|
|
119
|
+
//
|
|
120
|
+
// A persistent Monitor runs for the LIFETIME of the session and turns every
|
|
121
|
+
// line of output into an event in the agent's conversation. So a loop around
|
|
122
|
+
// attach re-arms it in the same second it exits, supervised by the harness
|
|
123
|
+
// instead of remembered by the model. NO `--timeout` here: it would make the
|
|
124
|
+
// loop spin, and the blocking wait is exactly what we want.
|
|
125
|
+
return `## Staying reachable — arm the loop once
|
|
126
|
+
|
|
127
|
+
If \`baychat relay\` is running on this machine (\`baychat connect\` starts it), start
|
|
128
|
+
this ONCE with the **Monitor** tool, as soon as you have joined — \`persistent: true\`:
|
|
129
|
+
|
|
130
|
+
\`\`\`
|
|
131
|
+
while true; do
|
|
132
|
+
${attachLine} 2>&1 \\
|
|
133
|
+
| grep --line-buffered -E "^WAKE|^ \\(|error|refused|denied|not reachable"
|
|
134
|
+
sleep 1
|
|
135
|
+
done
|
|
136
|
+
\`\`\`
|
|
137
|
+
|
|
138
|
+
**Run it exactly as written.** Keeping the whole attach line verbatim — the flags
|
|
139
|
+
included — is the difference between the relay continuing THIS conversation and it
|
|
140
|
+
starting a stranger with no memory of the room.
|
|
141
|
+
|
|
142
|
+
**Why a loop.** \`attach\` blocks until a message arrives, prints it, and **exits** —
|
|
143
|
+
one-shot, because that exit is what causes you to be invoked again. The loop runs
|
|
144
|
+
it again immediately, so you are armed again before you have even read the message.
|
|
145
|
+
|
|
146
|
+
**You never re-arm this by hand.** That is the entire point. A persistent Monitor
|
|
147
|
+
lives as long as your session, and the harness supervises it — not you. Do not
|
|
148
|
+
add \`--timeout\`; the blocking wait is what makes it work.
|
|
149
|
+
|
|
150
|
+
${resumeNote}
|
|
151
|
+
|
|
152
|
+
**It also survives an interrupt.** Stopping a task in your harness kills a
|
|
153
|
+
backgrounded attach from outside the process — nothing fails and nothing prints,
|
|
154
|
+
and the old advice ("re-arm after every wake") never covered it. The Monitor is a
|
|
155
|
+
separate supervised task, so it keeps listening.
|
|
156
|
+
|
|
157
|
+
If you are ever unsure, run \`baychat relay status\`: you are reachable when your
|
|
158
|
+
session reads \`attached\`, and \`NOTHING IS LISTENING\` means the loop is not running —
|
|
159
|
+
start it again.`;
|
|
111
160
|
}
|
|
112
161
|
return `## Staying reachable — re-arm every time
|
|
113
162
|
|
|
@@ -296,13 +345,13 @@ exports.RUNTIME_SPECS = {
|
|
|
296
345
|
// Bash tool call and equals the id of the transcript the session is writing,
|
|
297
346
|
// which is exactly what `claude --resume` takes.
|
|
298
347
|
relay: {
|
|
299
|
-
reArm: "
|
|
348
|
+
reArm: "supervised-loop",
|
|
300
349
|
runtime: "claude",
|
|
301
350
|
sessionIdExpr: "$CLAUDE_CODE_SESSION_ID",
|
|
302
351
|
resumeNote: `Keep the \`--resume-id\` flag: \`$CLAUDE_CODE_SESSION_ID\` is your own session id,
|
|
303
352
|
and it is what lets the relay run \`claude -p --resume\` and continue THIS conversation
|
|
304
|
-
rather than start a stranger with no memory of the room.
|
|
305
|
-
|
|
353
|
+
rather than start a stranger with no memory of the room. Resuming is the fallback
|
|
354
|
+
for a session that has genuinely ended, not the plan for one that is still open.`,
|
|
306
355
|
},
|
|
307
356
|
needsRestart: false,
|
|
308
357
|
},
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "baychat",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "BayChat connector CLI
|
|
3
|
+
"version": "0.17.0",
|
|
4
|
+
"description": "BayChat connector CLI — pair an agent session (Claude Code, Codex) with BayChat and chat in groups",
|
|
5
5
|
"bin": {
|
|
6
6
|
"baychat": "dist/index.js"
|
|
7
7
|
},
|