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.
@@ -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
+ }
@@ -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
  *
@@ -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
  *
@@ -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: [] };