baychat 0.14.0 → 0.16.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/args.js +15 -0
- package/dist/doctor.js +21 -2
- package/dist/index.js +14 -1
- package/dist/relay/adapters.js +110 -10
- package/dist/relay/commands.js +322 -29
- package/dist/relay/daemon.js +333 -43
- package/dist/relay/held.js +184 -0
- package/dist/relay/mailbox.js +1 -0
- package/dist/relay/owner-pid.js +169 -0
- package/dist/relay/profiles.js +210 -0
- package/dist/relay/provider-env.js +180 -0
- package/dist/relay/registry.js +11 -0
- package/dist/relay/socket.js +48 -0
- package/dist/runtime-binary.js +14 -0
- package/dist/runtimes.js +8 -0
- package/package.json +1 -1
package/dist/relay/mailbox.js
CHANGED
|
@@ -185,6 +185,7 @@ function registrationToTarget(reg) {
|
|
|
185
185
|
resumeCwd: reg.resumeCwd,
|
|
186
186
|
cwd: reg.cwd,
|
|
187
187
|
runtimeBin: reg.runtimeBin,
|
|
188
|
+
ownerPid: reg.ownerPid,
|
|
188
189
|
// Persisted, so a LATER headless resume still knows this session is
|
|
189
190
|
// sandboxed and must be told to re-arm in the foreground. `status()`
|
|
190
191
|
// recomputes the live rung for display; this is the remembered one.
|
|
@@ -0,0 +1,169 @@
|
|
|
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.procTable = void 0;
|
|
37
|
+
exports.ownerPidFor = ownerPidFor;
|
|
38
|
+
exports.isRuntimeCommand = isRuntimeCommand;
|
|
39
|
+
exports.currentOwnerPid = currentOwnerPid;
|
|
40
|
+
exports.detectRuntime = detectRuntime;
|
|
41
|
+
const fs = __importStar(require("fs"));
|
|
42
|
+
const path = __importStar(require("path"));
|
|
43
|
+
/** How far to walk before giving up. Deep enough for shell wrappers, bounded so a cycle cannot hang an attach. */
|
|
44
|
+
const MAX_HOPS = 12;
|
|
45
|
+
/**
|
|
46
|
+
* The nearest ancestor that IS this runtime, or undefined when none is found.
|
|
47
|
+
*
|
|
48
|
+
* Undefined rather than a fallback to `ppid` on purpose: a witness that lies is
|
|
49
|
+
* worse than no witness. With none, the daemon keeps its previous behaviour and
|
|
50
|
+
* may spawn headlessly — the old bug. With a wrong one it spawns headlessly
|
|
51
|
+
* while the session is alive — the worse bug, and a silent one.
|
|
52
|
+
*/
|
|
53
|
+
function ownerPidFor(runtime, startPid, table) {
|
|
54
|
+
let pid = startPid;
|
|
55
|
+
const seen = new Set();
|
|
56
|
+
for (let hop = 0; hop < MAX_HOPS; hop++) {
|
|
57
|
+
if (pid <= 1 || seen.has(pid))
|
|
58
|
+
return undefined;
|
|
59
|
+
seen.add(pid);
|
|
60
|
+
if (isRuntimeCommand(table.commandOf(pid), runtime))
|
|
61
|
+
return pid;
|
|
62
|
+
const parent = table.parentOf(pid);
|
|
63
|
+
if (parent === undefined)
|
|
64
|
+
return undefined;
|
|
65
|
+
pid = parent;
|
|
66
|
+
}
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Whether a command line IS the runtime, rather than merely mentioning it.
|
|
71
|
+
*
|
|
72
|
+
* Only the first two tokens are considered — the executable, and the script when
|
|
73
|
+
* that executable is an interpreter. The attach process itself runs
|
|
74
|
+
* `node …/baychat relay attach --runtime claude`, which CONTAINS "claude" as an
|
|
75
|
+
* argument; matching anywhere on the line would make every attach its own owner
|
|
76
|
+
* and defeat the check entirely.
|
|
77
|
+
*/
|
|
78
|
+
function isRuntimeCommand(command, runtime) {
|
|
79
|
+
if (!command)
|
|
80
|
+
return false;
|
|
81
|
+
const tokens = command.trim().split(/\s+/).filter(Boolean);
|
|
82
|
+
if (tokens.length === 0)
|
|
83
|
+
return false;
|
|
84
|
+
if (namesRuntime(tokens[0], runtime))
|
|
85
|
+
return true;
|
|
86
|
+
// An interpreter's SCRIPT is the real executable: `node …/codex/bin/codex.js`
|
|
87
|
+
// is Codex. Only consulted behind an interpreter, and only for something that
|
|
88
|
+
// looks like a path — otherwise `grep claude syslog` would match its own
|
|
89
|
+
// search term, and every attach would match its own `--runtime claude`.
|
|
90
|
+
if (!INTERPRETERS.has(path.basename(tokens[0]).toLowerCase()))
|
|
91
|
+
return false;
|
|
92
|
+
const script = tokens[1];
|
|
93
|
+
if (!script || !/[\\/]/.test(script))
|
|
94
|
+
return false;
|
|
95
|
+
return namesRuntime(script, runtime);
|
|
96
|
+
}
|
|
97
|
+
const INTERPRETERS = new Set(["node", "node.exe", "bun", "deno", "python", "python3"]);
|
|
98
|
+
/** `claude`, `claude.exe`, `codex.js` — the runtime's binary, however spelled. Not `claudette`. */
|
|
99
|
+
function namesRuntime(token, runtime) {
|
|
100
|
+
const base = path.basename(token).toLowerCase();
|
|
101
|
+
return base === runtime || base.startsWith(`${runtime}.`);
|
|
102
|
+
}
|
|
103
|
+
/** Reads the live process tree from /proc. Returns nothing where /proc is absent (macOS, Windows). */
|
|
104
|
+
exports.procTable = {
|
|
105
|
+
parentOf(pid) {
|
|
106
|
+
try {
|
|
107
|
+
// Field 4 of /proc/<pid>/stat is ppid. Read past the comm field, which is
|
|
108
|
+
// parenthesised and may itself contain spaces.
|
|
109
|
+
const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
110
|
+
const after = stat.slice(stat.lastIndexOf(")") + 1).trim().split(/\s+/);
|
|
111
|
+
const ppid = Number(after[1]);
|
|
112
|
+
return Number.isFinite(ppid) ? ppid : undefined;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
commandOf(pid) {
|
|
119
|
+
try {
|
|
120
|
+
return fs.readFileSync(`/proc/${pid}/cmdline`, "utf8").replace(/\0/g, " ").trim() || undefined;
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
/** The owning runtime process for THIS attach, read from the live tree. */
|
|
128
|
+
function currentOwnerPid(runtime) {
|
|
129
|
+
return ownerPidFor(runtime, process.ppid, exports.procTable);
|
|
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/relay/registry.js
CHANGED
|
@@ -121,6 +121,17 @@ class SessionRegistry {
|
|
|
121
121
|
if (t)
|
|
122
122
|
t.attached = attached;
|
|
123
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* Record that a delivery to this session actually landed.
|
|
126
|
+
*
|
|
127
|
+
* Separate from `setAttached` because it means something stronger: attached is
|
|
128
|
+
* a state that can be claimed, this is an event that was observed.
|
|
129
|
+
*/
|
|
130
|
+
markDelivered(name, at) {
|
|
131
|
+
const t = this.sessions.get(name);
|
|
132
|
+
if (t)
|
|
133
|
+
t.lastDeliveredAt = at;
|
|
134
|
+
}
|
|
124
135
|
/**
|
|
125
136
|
* Drop targets whose session is no longer live server-side.
|
|
126
137
|
*
|
package/dist/relay/socket.js
CHANGED
|
@@ -39,6 +39,7 @@ exports.isNamedPipe = isNamedPipe;
|
|
|
39
39
|
exports.pidFilePath = pidFilePath;
|
|
40
40
|
exports.createFrameReader = createFrameReader;
|
|
41
41
|
exports.writeFrame = writeFrame;
|
|
42
|
+
exports.writeFrameAck = writeFrameAck;
|
|
42
43
|
exports.probeSocketDetailed = probeSocketDetailed;
|
|
43
44
|
exports.probeSocket = probeSocket;
|
|
44
45
|
exports.describeProbeFailure = describeProbeFailure;
|
|
@@ -156,6 +157,53 @@ function createFrameReader(onFrame, onBad) {
|
|
|
156
157
|
function writeFrame(sock, frame) {
|
|
157
158
|
sock.write(JSON.stringify(frame) + "\n");
|
|
158
159
|
}
|
|
160
|
+
/**
|
|
161
|
+
* Write a frame and WAIT for the socket to take it, surfacing the failure if it
|
|
162
|
+
* does not.
|
|
163
|
+
*
|
|
164
|
+
* `writeFrame` is fire-and-forget. `sock.write()` buffers and returns, so a peer
|
|
165
|
+
* that has already gone produces an EPIPE on the socket's `error` event LATER —
|
|
166
|
+
* by which time the caller has recorded `woken`. That is a lost message filed as
|
|
167
|
+
* delivered, which is the single outcome `relay status` exists to make
|
|
168
|
+
* impossible: every other failure is at least visible as pending.
|
|
169
|
+
*
|
|
170
|
+
* Three ways this settles, and all three are needed. The write callback covers
|
|
171
|
+
* the ordinary case; the `error` event covers an EPIPE that arrives without one;
|
|
172
|
+
* `close` covers a peer that vanishes while the bytes are still buffered, which
|
|
173
|
+
* reports no error at all.
|
|
174
|
+
*
|
|
175
|
+
* HONEST LIMIT — this is a FLUSH confirmation, not a receiver acknowledgement.
|
|
176
|
+
* It proves the bytes left this process, not that the agent read them. A true
|
|
177
|
+
* end-to-end ack needs an `ack` frame back from the attach client, and every
|
|
178
|
+
* already-installed client would have to be taught to send one before the
|
|
179
|
+
* daemon could require it. What this closes is the failure that was actually
|
|
180
|
+
* observed — a dead peer recorded as woken — not the whole class.
|
|
181
|
+
*/
|
|
182
|
+
function writeFrameAck(sock, frame) {
|
|
183
|
+
return new Promise((resolve, reject) => {
|
|
184
|
+
if (sock.destroyed || sock.writableEnded) {
|
|
185
|
+
reject(new Error("the attach socket is already closed"));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
let settled = false;
|
|
189
|
+
const settle = (err) => {
|
|
190
|
+
if (settled)
|
|
191
|
+
return;
|
|
192
|
+
settled = true;
|
|
193
|
+
sock.off("error", onError);
|
|
194
|
+
sock.off("close", onClose);
|
|
195
|
+
if (err)
|
|
196
|
+
reject(err);
|
|
197
|
+
else
|
|
198
|
+
resolve();
|
|
199
|
+
};
|
|
200
|
+
const onError = (err) => settle(err);
|
|
201
|
+
const onClose = () => settle(new Error("the attach socket closed before the frame was flushed"));
|
|
202
|
+
sock.once("error", onError);
|
|
203
|
+
sock.once("close", onClose);
|
|
204
|
+
sock.write(JSON.stringify(frame) + "\n", (err) => settle(err ?? undefined));
|
|
205
|
+
});
|
|
206
|
+
}
|
|
159
207
|
/**
|
|
160
208
|
* Probe the attach endpoint, KEEPING THE REASON IT FAILED.
|
|
161
209
|
*
|
package/dist/runtime-binary.js
CHANGED
|
@@ -173,6 +173,20 @@ function joinPath(dir, file, platform) {
|
|
|
173
173
|
* @returns file + prefix args to spawn it with, always `shell: false`
|
|
174
174
|
*/
|
|
175
175
|
function spawnPlanFor(candidate, platform) {
|
|
176
|
+
// A SCRIPT NEEDS ITS INTERPRETER, on every platform.
|
|
177
|
+
//
|
|
178
|
+
// The managed Codex fallback records `<CODEX_MANAGED_PACKAGE_ROOT>/bin/codex.js`
|
|
179
|
+
// — the file the npm install is actually running — and a `.js` cannot be
|
|
180
|
+
// spawned directly. On Unix that needs a shebang AND the execute bit, neither
|
|
181
|
+
// of which an npm package guarantees; on Windows there is no execute bit at
|
|
182
|
+
// all, and only `.cmd`/`.bat` were wrapped. So the recorded path was one the
|
|
183
|
+
// daemon could not run, which is the 2026-08-30 ENOENT failure in a new place.
|
|
184
|
+
//
|
|
185
|
+
// `process.execPath` is the Node running this CLI. Deliberately NOT a bare
|
|
186
|
+
// "node": the daemon's PATH may not have one — that is the whole reason
|
|
187
|
+
// `runtimeBin` is recorded by the session in the first place.
|
|
188
|
+
if (/\.(mjs|cjs|js)$/i.test(candidate))
|
|
189
|
+
return { file: process.execPath, prefixArgs: [candidate] };
|
|
176
190
|
const needsInterpreter = platform === "win32" && /\.(cmd|bat)$/i.test(candidate);
|
|
177
191
|
if (!needsInterpreter)
|
|
178
192
|
return { file: candidate, prefixArgs: [] };
|