baychat 0.13.1 → 0.14.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 +146 -1
- package/dist/args.js +57 -0
- package/dist/client-paths.js +69 -0
- package/dist/commands.js +108 -0
- package/dist/connect.js +46 -14
- package/dist/credential-refresh.js +97 -0
- package/dist/doctor-command.js +154 -0
- package/dist/doctor.js +502 -0
- package/dist/index.js +40 -26
- package/dist/relay/adapters.js +82 -1
- package/dist/relay/codex-app-server.js +217 -0
- package/dist/relay/codex-queue.js +68 -0
- package/dist/relay/commands.js +177 -31
- package/dist/relay/daemon.js +259 -3
- package/dist/relay/mailbox-watcher.js +118 -0
- package/dist/relay/mailbox.js +319 -0
- package/dist/relay/parent-watch.js +68 -0
- package/dist/relay/resume.js +39 -11
- package/dist/relay/socket.js +98 -14
- package/dist/relay/spawn-env.js +69 -0
- package/dist/runtime-binary.js +269 -0
- package/dist/runtimes.js +118 -46
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
4
|
const commands_1 = require("./commands");
|
|
5
5
|
const approve_hook_1 = require("./approve-hook");
|
|
6
|
+
const doctor_command_1 = require("./doctor-command");
|
|
6
7
|
const connect_1 = require("./connect");
|
|
7
8
|
const mcp_1 = require("./mcp");
|
|
8
9
|
const mcp_config_1 = require("./mcp-config");
|
|
9
10
|
const commands_2 = require("./relay/commands");
|
|
10
11
|
const help_topics_1 = require("./help-topics");
|
|
12
|
+
const args_1 = require("./args");
|
|
11
13
|
const HELP = `baychat — BayChat connector CLI for agent sessions (Claude Code, Codex)
|
|
12
14
|
|
|
13
15
|
Usage:
|
|
@@ -83,6 +85,14 @@ Usage:
|
|
|
83
85
|
came from, so "we can wake this headlessly"
|
|
84
86
|
is a claim you can check
|
|
85
87
|
baychat relay stop Stop the relay and disable it at boot
|
|
88
|
+
baychat doctor [--json] Check every link between this machine and
|
|
89
|
+
BayChat — credential, relay, and per runtime
|
|
90
|
+
its MCP registration, skill, executable and
|
|
91
|
+
live session — and print exactly what to type
|
|
92
|
+
for each thing that is wrong. Exit 0 clear,
|
|
93
|
+
1 broken, 2 messages nothing answered.
|
|
94
|
+
--json emits the same report as data, for
|
|
95
|
+
pasting into a support thread
|
|
86
96
|
baychat approve-hook [--session <name>] [--timeout <sec>]
|
|
87
97
|
Claude Code PermissionRequest hook: send the
|
|
88
98
|
permission prompt to BayChat as a decision card
|
|
@@ -108,46 +118,45 @@ Usage:
|
|
|
108
118
|
Connect flow: in BayChat, open the agent -> Connect -> copy the pairing code,
|
|
109
119
|
then run \`baychat pair <code>\`. Pairing rotates the agent token; use a
|
|
110
120
|
dedicated agent per session.`;
|
|
111
|
-
function flag(args, name) {
|
|
112
|
-
const i = args.indexOf(name);
|
|
113
|
-
return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined;
|
|
114
|
-
}
|
|
115
|
-
/** The first positional (non `--flag`) argument, so a command's id isn't shadowed
|
|
116
|
-
* by a leading boolean flag like `--catch-up`/`--refresh`. */
|
|
117
|
-
function positional(args) {
|
|
118
|
-
return args.find((a) => !a.startsWith("--"));
|
|
119
|
-
}
|
|
120
121
|
/** Drop a `--name <value>` pair, so a multi-word positional (a search query)
|
|
121
|
-
* doesn't swallow the flag's value as part of itself.
|
|
122
|
+
* doesn't swallow the flag's value as part of itself.
|
|
123
|
+
*
|
|
124
|
+
* Only the flag is dropped when its value is missing — the next token is
|
|
125
|
+
* another option, or there is none. Consuming two entries there would eat an
|
|
126
|
+
* unrelated argument, the same mistake `flag` refuses to make. */
|
|
122
127
|
function withoutFlag(args, name) {
|
|
123
128
|
const i = args.indexOf(name);
|
|
124
|
-
|
|
129
|
+
if (i < 0)
|
|
130
|
+
return args;
|
|
131
|
+
const next = args[i + 1];
|
|
132
|
+
const consumed = next !== undefined && !next.startsWith("--") ? 2 : 1;
|
|
133
|
+
return [...args.slice(0, i), ...args.slice(i + consumed)];
|
|
125
134
|
}
|
|
126
135
|
/** A flag's value as a number, or undefined when absent. A non-numeric value
|
|
127
136
|
* becomes NaN and is rejected by the tool's own bounds check with a message
|
|
128
137
|
* that names the field. */
|
|
129
138
|
function numberFlag(args, name) {
|
|
130
|
-
const raw = flag(args, name);
|
|
139
|
+
const raw = (0, args_1.flag)(args, name);
|
|
131
140
|
return raw === undefined ? undefined : Number(raw);
|
|
132
141
|
}
|
|
133
142
|
async function main() {
|
|
134
143
|
const [command, ...args] = process.argv.slice(2);
|
|
135
144
|
switch (command) {
|
|
136
145
|
case "onboard":
|
|
137
|
-
await (0, commands_1.cmdOnboard)(positional(args), { catchUp: args.includes("--catch-up") });
|
|
146
|
+
await (0, commands_1.cmdOnboard)((0, args_1.positional)(args), { catchUp: args.includes("--catch-up") });
|
|
138
147
|
return 0;
|
|
139
148
|
case "login": {
|
|
140
|
-
const loggedIn = await (0, commands_1.cmdLogin)({ base: flag(args, "--base"), token: flag(args, "--token") });
|
|
149
|
+
const loggedIn = await (0, commands_1.cmdLogin)({ base: (0, args_1.flag)(args, "--base"), token: (0, args_1.flag)(args, "--token") });
|
|
141
150
|
return loggedIn ? 0 : 2; // 2 = the link request expired without approval
|
|
142
151
|
}
|
|
143
152
|
case "pair": {
|
|
144
153
|
if (!args[0])
|
|
145
154
|
throw new Error("Usage: baychat pair <code>");
|
|
146
|
-
await (0, commands_1.cmdPair)(args[0], flag(args, "--base"));
|
|
155
|
+
await (0, commands_1.cmdPair)(args[0], (0, args_1.flag)(args, "--base"));
|
|
147
156
|
return 0;
|
|
148
157
|
}
|
|
149
158
|
case "link": {
|
|
150
|
-
const linked = await (0, commands_1.cmdLink)({ name: flag(args, "--name"), base: flag(args, "--base") });
|
|
159
|
+
const linked = await (0, commands_1.cmdLink)({ name: (0, args_1.flag)(args, "--name"), base: (0, args_1.flag)(args, "--base") });
|
|
151
160
|
return linked ? 0 : 2;
|
|
152
161
|
}
|
|
153
162
|
case "qr":
|
|
@@ -209,7 +218,7 @@ async function main() {
|
|
|
209
218
|
return 0;
|
|
210
219
|
}
|
|
211
220
|
case "summary": {
|
|
212
|
-
const conversationId = positional(args);
|
|
221
|
+
const conversationId = (0, args_1.positional)(args);
|
|
213
222
|
if (!conversationId)
|
|
214
223
|
throw new Error("Usage: baychat summary <conversationId> [--refresh]");
|
|
215
224
|
await (0, commands_1.cmdSummary)(conversationId, { refresh: args.includes("--refresh") });
|
|
@@ -223,7 +232,7 @@ async function main() {
|
|
|
223
232
|
return 0;
|
|
224
233
|
}
|
|
225
234
|
case "fetch": {
|
|
226
|
-
const url = positional(withoutFlag(args, "--max-chars"));
|
|
235
|
+
const url = (0, args_1.positional)(withoutFlag(args, "--max-chars"));
|
|
227
236
|
if (!url)
|
|
228
237
|
throw new Error("Usage: baychat fetch <url> [--max-chars <n>]");
|
|
229
238
|
await (0, commands_1.cmdFetch)(url, { maxChars: numberFlag(args, "--max-chars") });
|
|
@@ -232,8 +241,8 @@ async function main() {
|
|
|
232
241
|
case "watch": {
|
|
233
242
|
if (!args[0])
|
|
234
243
|
throw new Error("Usage: baychat watch <conversationId>");
|
|
235
|
-
const intervalSec = Number(flag(args, "--interval") ?? "5");
|
|
236
|
-
const timeoutSec = Number(flag(args, "--timeout") ?? "300");
|
|
244
|
+
const intervalSec = Number((0, args_1.flag)(args, "--interval") ?? "5");
|
|
245
|
+
const timeoutSec = Number((0, args_1.flag)(args, "--timeout") ?? "300");
|
|
237
246
|
const got = await (0, commands_1.cmdWatch)(args[0], {
|
|
238
247
|
intervalMs: intervalSec * 1000,
|
|
239
248
|
timeoutMs: timeoutSec * 1000,
|
|
@@ -241,7 +250,7 @@ async function main() {
|
|
|
241
250
|
return got ? 0 : 2;
|
|
242
251
|
}
|
|
243
252
|
case "relay": {
|
|
244
|
-
const sub = positional(args) ?? "";
|
|
253
|
+
const sub = (0, args_1.positional)(args) ?? "";
|
|
245
254
|
const rest = args.filter((a) => a !== sub);
|
|
246
255
|
switch (sub) {
|
|
247
256
|
case "start":
|
|
@@ -252,15 +261,15 @@ async function main() {
|
|
|
252
261
|
case "stop":
|
|
253
262
|
return await (0, commands_2.cmdRelayStop)();
|
|
254
263
|
case "attach": {
|
|
255
|
-
const session = flag(rest, "--session");
|
|
264
|
+
const session = (0, args_1.flag)(rest, "--session");
|
|
256
265
|
if (!session) {
|
|
257
266
|
throw new Error("Usage: baychat relay attach --session <name> [--runtime claude|codex|hermes] [--resume-id <id>] [--timeout <sec>]");
|
|
258
267
|
}
|
|
259
268
|
const timeoutSec = numberFlag(rest, "--timeout");
|
|
260
269
|
return await (0, commands_2.cmdRelayAttach)({
|
|
261
270
|
session,
|
|
262
|
-
runtime: flag(rest, "--runtime") ?? "claude",
|
|
263
|
-
resumeId: flag(rest, "--resume-id"),
|
|
271
|
+
runtime: (0, args_1.flag)(rest, "--runtime") ?? "claude",
|
|
272
|
+
resumeId: (0, args_1.flag)(rest, "--resume-id"),
|
|
264
273
|
timeoutMs: timeoutSec ? timeoutSec * 1000 : undefined,
|
|
265
274
|
});
|
|
266
275
|
}
|
|
@@ -274,15 +283,20 @@ async function main() {
|
|
|
274
283
|
// exit-1 hook with no JSON on stdout is a NON-BLOCKING error: the tool call proceeds. The
|
|
275
284
|
// guard in the catch below covers that anyway.
|
|
276
285
|
return await (0, approve_hook_1.cmdApproveHook)(args);
|
|
286
|
+
case "doctor":
|
|
287
|
+
// Deliberately placed with the setup commands: it is the thing you run
|
|
288
|
+
// when one of them did not take, and the one command that inspects all
|
|
289
|
+
// of them at once.
|
|
290
|
+
return await (0, doctor_command_1.cmdDoctor)(args);
|
|
277
291
|
case "connect": {
|
|
278
292
|
// A bare `connect` prints the client menu; positional() skips a leading flag
|
|
279
293
|
// so `connect --base x codex` still finds the client.
|
|
280
|
-
return await (0, connect_1.cmdConnect)(positional(args), { base: flag(args, "--base") });
|
|
294
|
+
return await (0, connect_1.cmdConnect)((0, args_1.positional)(args), { base: (0, args_1.flag)(args, "--base") });
|
|
281
295
|
}
|
|
282
296
|
case "mcp-config": {
|
|
283
297
|
// `--client` with no value is a typo, not a request for the menu: pass the
|
|
284
298
|
// empty string so it is rejected by name rather than silently listing.
|
|
285
|
-
(0, mcp_config_1.cmdMcpConfig)(args.includes("--client") ? (flag(args, "--client") ?? "") : undefined);
|
|
299
|
+
(0, mcp_config_1.cmdMcpConfig)(args.includes("--client") ? ((0, args_1.flag)(args, "--client") ?? "") : undefined);
|
|
286
300
|
return 0;
|
|
287
301
|
}
|
|
288
302
|
case "mcp": {
|
package/dist/relay/adapters.js
CHANGED
|
@@ -7,7 +7,10 @@ exports.isKnownRuntime = isKnownRuntime;
|
|
|
7
7
|
exports.runHeadless = runHeadless;
|
|
8
8
|
const child_process_1 = require("child_process");
|
|
9
9
|
const attachments_1 = require("../attachments");
|
|
10
|
+
const codex_app_server_1 = require("./codex-app-server");
|
|
11
|
+
const codex_queue_1 = require("./codex-queue");
|
|
10
12
|
const resume_1 = require("./resume");
|
|
13
|
+
const spawn_env_1 = require("./spawn-env");
|
|
11
14
|
/** How long a headless turn may run before the relay gives up on it. */
|
|
12
15
|
const HEADLESS_TIMEOUT_MS = 10 * 60_000;
|
|
13
16
|
/**
|
|
@@ -18,7 +21,7 @@ const HEADLESS_TIMEOUT_MS = 10 * 60_000;
|
|
|
18
21
|
* relay carries the message and nothing more. Telling a resumed turn to "answer
|
|
19
22
|
* this" would route around the room's reply policy from outside the room.
|
|
20
23
|
*/
|
|
21
|
-
function buildWakePrompt(session, conversationId, batch) {
|
|
24
|
+
function buildWakePrompt(session, conversationId, batch, reArm) {
|
|
22
25
|
const lines = [];
|
|
23
26
|
for (const m of batch) {
|
|
24
27
|
const flag = m.shouldRespond ? " [shouldRespond=true]" : "";
|
|
@@ -40,8 +43,47 @@ function buildWakePrompt(session, conversationId, batch) {
|
|
|
40
43
|
...lines,
|
|
41
44
|
"",
|
|
42
45
|
`Re-read the room with the BayChat tools (session="${session}") before acting. Reply ONLY if the server marks shouldRespond for you; otherwise stay silent and end the turn.`,
|
|
46
|
+
...reArmLines(session, reArm),
|
|
43
47
|
].join("\n");
|
|
44
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* How this session gets woken a SECOND time.
|
|
51
|
+
*
|
|
52
|
+
* A headless resume is spawned by the daemon, so it inherits the daemon's
|
|
53
|
+
* environment — and under systemd that PATH contains neither `node` nor
|
|
54
|
+
* `baychat` nor the runtime itself. The installed skill tells a woken session to
|
|
55
|
+
* re-arm with a bare `baychat relay attach`, which a resumed session cannot run:
|
|
56
|
+
* command not found. It answers once and then goes quiet forever, which is
|
|
57
|
+
* precisely the failure re-arming exists to prevent — and it looks like the
|
|
58
|
+
* model forgetting rather than like us handing it an impossible instruction.
|
|
59
|
+
*
|
|
60
|
+
* So the daemon, which knows both absolute paths because it IS them, spells the
|
|
61
|
+
* command out. Omitted entirely when the caller has no paths to offer, rather
|
|
62
|
+
* than printed with holes in it.
|
|
63
|
+
*/
|
|
64
|
+
function reArmLines(session, reArm) {
|
|
65
|
+
if (!reArm)
|
|
66
|
+
return [];
|
|
67
|
+
const command = ` ${reArm.node} ${reArm.cli} relay attach --session "${session}" --runtime ${reArm.runtime}`;
|
|
68
|
+
if (reArm.transport === "fifo") {
|
|
69
|
+
return [
|
|
70
|
+
"",
|
|
71
|
+
"To stay reachable, run this IN THE FOREGROUND before ending your turn. It",
|
|
72
|
+
"blocks until a message arrives, prints it, and exits — that exit is the wake.",
|
|
73
|
+
"Do NOT put it in the background: your sandbox kills backgrounded processes",
|
|
74
|
+
"when the command returns, so a backgrounded attach listens to nothing while",
|
|
75
|
+
"looking like it worked.",
|
|
76
|
+
command,
|
|
77
|
+
];
|
|
78
|
+
}
|
|
79
|
+
return [
|
|
80
|
+
"",
|
|
81
|
+
"To stay reachable, run this in the BACKGROUND before ending your turn. It is",
|
|
82
|
+
"written with absolute paths because a resumed session's PATH may not contain",
|
|
83
|
+
"node or baychat at all:",
|
|
84
|
+
command,
|
|
85
|
+
];
|
|
86
|
+
}
|
|
45
87
|
/**
|
|
46
88
|
* One attachment line for the wake prompt: what it is, then how to fetch it.
|
|
47
89
|
*
|
|
@@ -91,6 +133,19 @@ const claudeAdapter = {
|
|
|
91
133
|
};
|
|
92
134
|
const codexAdapter = {
|
|
93
135
|
runtime: "codex",
|
|
136
|
+
queueMessage({ binaryPath, target, message }) {
|
|
137
|
+
// `codex queue` landed in 0.149.0. Delivering here rather than through the
|
|
138
|
+
// headless spawn is what lets a woken Codex actually REPLY: the message goes
|
|
139
|
+
// to the live session, where the human is present to approve, instead of a
|
|
140
|
+
// separate turn that must run `approvalPolicy: "never"` and is therefore
|
|
141
|
+
// blocked from calling BayChat's write tools at all.
|
|
142
|
+
return (0, codex_queue_1.queueToThread)({
|
|
143
|
+
binaryPath,
|
|
144
|
+
threadId: target.resumeId,
|
|
145
|
+
message,
|
|
146
|
+
cwd: target.resumeCwd ?? target.cwd,
|
|
147
|
+
});
|
|
148
|
+
},
|
|
94
149
|
canResume(target) {
|
|
95
150
|
if (!target.resumeId) {
|
|
96
151
|
return {
|
|
@@ -109,6 +164,29 @@ const codexAdapter = {
|
|
|
109
164
|
args: ["exec", "resume", target.resumeId, prompt],
|
|
110
165
|
};
|
|
111
166
|
},
|
|
167
|
+
/**
|
|
168
|
+
* Prefer `codex app-server`, which can say what `codex exec` never could.
|
|
169
|
+
*
|
|
170
|
+
* `exec resume` reports only an exit code, so "the thread id names nothing on
|
|
171
|
+
* this machine", "the turn stopped at an approval" and "the model was
|
|
172
|
+
* unavailable" all arrive as `exited 1`. app-server distinguishes them, and
|
|
173
|
+
* the reason is what a user needs to act.
|
|
174
|
+
*
|
|
175
|
+
* Opting out with BAYCHAT_CODEX_TRANSPORT=exec is deliberate: the app-server
|
|
176
|
+
* interface is marked `[experimental]` by OpenAI and its shape is not frozen,
|
|
177
|
+
* so there has to be a way back that does not require a new release.
|
|
178
|
+
*/
|
|
179
|
+
async runTurn({ binaryPath, target, prompt }) {
|
|
180
|
+
if (process.env.BAYCHAT_CODEX_TRANSPORT === "exec") {
|
|
181
|
+
return { kind: "failed", transportUnusable: true, reason: "BAYCHAT_CODEX_TRANSPORT=exec" };
|
|
182
|
+
}
|
|
183
|
+
return (0, codex_app_server_1.runCodexTurn)({
|
|
184
|
+
binaryPath,
|
|
185
|
+
threadId: target.resumeId,
|
|
186
|
+
prompt,
|
|
187
|
+
cwd: target.resumeCwd ?? target.cwd,
|
|
188
|
+
}, { spawn: child_process_1.spawn });
|
|
189
|
+
},
|
|
112
190
|
};
|
|
113
191
|
/**
|
|
114
192
|
* Cursor can be woken, and cannot be resumed. Both halves matter.
|
|
@@ -200,6 +278,9 @@ function runHeadless(file, args, opts = {}) {
|
|
|
200
278
|
return new Promise((resolve) => {
|
|
201
279
|
const child = (0, child_process_1.spawn)(file, args, {
|
|
202
280
|
cwd: opts.cwd,
|
|
281
|
+
// An npm-installed runtime is a script with a `#!/usr/bin/env node`
|
|
282
|
+
// shebang, and the daemon's systemd PATH has no node. See ./spawn-env.ts.
|
|
283
|
+
env: (0, spawn_env_1.headlessSpawnEnv)(),
|
|
203
284
|
shell: false,
|
|
204
285
|
stdio: ["ignore", "ignore", "pipe"],
|
|
205
286
|
detached: false,
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Waking a Codex thread through `codex app-server` instead of `codex exec resume`.
|
|
3
|
+
//
|
|
4
|
+
// WHY. `codex exec resume <id> <prompt>` is a fire-and-forget process whose only
|
|
5
|
+
// report is an exit code. It cannot say whether the turn answered, whether it
|
|
6
|
+
// stopped at an approval, or whether the id even named a real thread — every one
|
|
7
|
+
// of those is "exit non-zero", and the relay could only record DELIVERY PENDING
|
|
8
|
+
// with a number in it.
|
|
9
|
+
//
|
|
10
|
+
// `codex app-server` is the JSON-RPC 2.0 interface behind OpenAI's own VS Code
|
|
11
|
+
// and JetBrains plugins. It answers `thread/resume` for a specific id — with a
|
|
12
|
+
// distinct error when that id is not a real thread — and reports the turn's end
|
|
13
|
+
// as `turn/completed` rather than as a process exit. Verified against codex-cli
|
|
14
|
+
// 0.114.0 on 2026-08-30: initialize → initialized → thread/resume → turn/start,
|
|
15
|
+
// with the resumed thread's own MCP servers (BayChat included) started for it.
|
|
16
|
+
//
|
|
17
|
+
// ONE PROCESS PER WAKE, NOT A HELD CONNECTION. The design note for this work
|
|
18
|
+
// proposed keeping one app-server alive for the daemon's life. That buys a little
|
|
19
|
+
// latency and costs a supervision problem — restarts, health, a wedged child
|
|
20
|
+
// holding every session's queue — for a path that runs at human speed anyway.
|
|
21
|
+
// A process per wake keeps the failure modes identical to the ones `runHeadless`
|
|
22
|
+
// already has, and every win that mattered (an id we address explicitly, a
|
|
23
|
+
// structured turn result, no shell) survives.
|
|
24
|
+
//
|
|
25
|
+
// `approvalPolicy: "never"` IS THE SECURITY BOUNDARY. A headless turn has no
|
|
26
|
+
// human to ask, so the only safe policy is one that never prompts and never
|
|
27
|
+
// escalates: a command needing approval fails inside the sandbox instead of
|
|
28
|
+
// running because a chat message asked for it. The relay carries messages; it
|
|
29
|
+
// does not acquire privileges on their behalf.
|
|
30
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
31
|
+
exports.runCodexTurn = runCodexTurn;
|
|
32
|
+
const runtime_binary_1 = require("../runtime-binary");
|
|
33
|
+
const spawn_env_1 = require("./spawn-env");
|
|
34
|
+
/** How long a whole wake may take — resume, turn, and completion. */
|
|
35
|
+
const TURN_TIMEOUT_MS = 10 * 60_000;
|
|
36
|
+
/** How long the handshake alone may take before we call the binary unusable. */
|
|
37
|
+
const HANDSHAKE_TIMEOUT_MS = 30_000;
|
|
38
|
+
/**
|
|
39
|
+
* Run one turn against an existing Codex thread and resolve with its outcome.
|
|
40
|
+
*
|
|
41
|
+
* Never throws and never rejects: every failure — a binary that will not start,
|
|
42
|
+
* a thread id that names nothing, a protocol error, a timeout — is a `failed`
|
|
43
|
+
* outcome carrying its reason. The caller's job is to record that, and a thrown
|
|
44
|
+
* exception would only turn a describable state into a stack trace.
|
|
45
|
+
*/
|
|
46
|
+
function runCodexTurn(req, deps) {
|
|
47
|
+
const spawn = deps.spawn;
|
|
48
|
+
const turnTimeout = deps.turnTimeoutMs ?? TURN_TIMEOUT_MS;
|
|
49
|
+
const handshakeTimeout = deps.handshakeTimeoutMs ?? HANDSHAKE_TIMEOUT_MS;
|
|
50
|
+
return new Promise((resolve) => {
|
|
51
|
+
// A Windows .cmd needs its interpreter named, exactly as the plain-spawn
|
|
52
|
+
// path does. This was missed when that fix landed, and the result was
|
|
53
|
+
// `delivery failed for CodexTest: spawn EINVAL` on every wake — the
|
|
54
|
+
// transport failing before the handshake, on a machine where Codex was
|
|
55
|
+
// installed and working.
|
|
56
|
+
const plan = (0, runtime_binary_1.spawnPlanFor)(req.binaryPath, process.platform);
|
|
57
|
+
const child = spawn(plan.file, [...plan.prefixArgs, "app-server"], {
|
|
58
|
+
cwd: req.cwd,
|
|
59
|
+
// Same reason as `runHeadless`: an npm-installed codex is a script with a
|
|
60
|
+
// `#!/usr/bin/env node` shebang and the daemon's PATH has no node. Both
|
|
61
|
+
// spawn sites need this — fixing only one leaves the other dying with
|
|
62
|
+
// exit 127 the moment it becomes the reachable path.
|
|
63
|
+
env: (0, spawn_env_1.headlessSpawnEnv)(),
|
|
64
|
+
// No shell, ever: the prompt carries message text written by other people
|
|
65
|
+
// in the room, and a shell string would make `$(…)` in a chat message run
|
|
66
|
+
// on this box. Nothing here is concatenated into a command line.
|
|
67
|
+
shell: false,
|
|
68
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
69
|
+
});
|
|
70
|
+
let settled = false;
|
|
71
|
+
let nextId = 0;
|
|
72
|
+
const pending = new Map();
|
|
73
|
+
let stdoutBuffer = "";
|
|
74
|
+
let stderrTail = "";
|
|
75
|
+
/** Resolve once, and always leave no process behind. */
|
|
76
|
+
const finish = (outcome) => {
|
|
77
|
+
if (settled)
|
|
78
|
+
return;
|
|
79
|
+
settled = true;
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
child.kill("SIGTERM");
|
|
82
|
+
// A child that ignores the polite signal would otherwise outlive the relay
|
|
83
|
+
// and keep answering a room nobody is watching.
|
|
84
|
+
setTimeout(() => child.kill("SIGKILL"), 5_000).unref();
|
|
85
|
+
resolve(outcome);
|
|
86
|
+
};
|
|
87
|
+
let timer = setTimeout(() => finish({ kind: "failed", transportUnusable: true, reason: `codex app-server did not complete the handshake within ${Math.round(handshakeTimeout / 1000)}s` }), handshakeTimeout);
|
|
88
|
+
const request = (method, params) => new Promise((res) => {
|
|
89
|
+
const id = nextId++;
|
|
90
|
+
pending.set(id, res);
|
|
91
|
+
child.stdin?.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`);
|
|
92
|
+
});
|
|
93
|
+
const notify = (method, params) => {
|
|
94
|
+
child.stdin?.write(`${JSON.stringify({ jsonrpc: "2.0", method, params })}\n`);
|
|
95
|
+
};
|
|
96
|
+
child.stdout?.on("data", (chunk) => {
|
|
97
|
+
stdoutBuffer += chunk.toString();
|
|
98
|
+
// Newline-delimited JSON. A partial line is kept for the next chunk;
|
|
99
|
+
// parsing one would be the classic framing bug.
|
|
100
|
+
let newline = stdoutBuffer.indexOf("\n");
|
|
101
|
+
while (newline >= 0) {
|
|
102
|
+
const line = stdoutBuffer.slice(0, newline).trim();
|
|
103
|
+
stdoutBuffer = stdoutBuffer.slice(newline + 1);
|
|
104
|
+
newline = stdoutBuffer.indexOf("\n");
|
|
105
|
+
if (line === "")
|
|
106
|
+
continue;
|
|
107
|
+
let message;
|
|
108
|
+
try {
|
|
109
|
+
message = JSON.parse(line);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
// Not our protocol. Ignored rather than fatal: the binary is entitled
|
|
113
|
+
// to print things, and a stray line must not lose a turn that is
|
|
114
|
+
// otherwise proceeding normally.
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (typeof message.id === "number" && pending.has(message.id)) {
|
|
118
|
+
const waiting = pending.get(message.id);
|
|
119
|
+
pending.delete(message.id);
|
|
120
|
+
waiting?.(message);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
// `turn/completed` is the end of the wake — the whole reason this
|
|
124
|
+
// transport is better than an exit code.
|
|
125
|
+
if (message.method === "turn/completed")
|
|
126
|
+
finish({ kind: "completed" });
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
child.stderr?.on("data", (chunk) => {
|
|
130
|
+
// Bounded, and kept only to explain a failure. Codex logs freely here even
|
|
131
|
+
// on a healthy run, so it is never treated as a failure signal by itself.
|
|
132
|
+
const text = chunk.toString();
|
|
133
|
+
stderrTail = `${stderrTail}${text}`.slice(-2_000);
|
|
134
|
+
});
|
|
135
|
+
child.on("error", (err) => {
|
|
136
|
+
finish({ kind: "failed", transportUnusable: true, reason: `could not start codex app-server: ${err.message}` });
|
|
137
|
+
});
|
|
138
|
+
child.on("close", (code) => {
|
|
139
|
+
// Only meaningful if we have not already completed: an expected exit
|
|
140
|
+
// follows our own SIGTERM.
|
|
141
|
+
finish({
|
|
142
|
+
kind: "failed",
|
|
143
|
+
// Dying before the turn completed means the transport did not work,
|
|
144
|
+
// whatever the cause — worth one attempt down the older path.
|
|
145
|
+
transportUnusable: true,
|
|
146
|
+
reason: `codex app-server exited ${code ?? "on a signal"} before the turn completed${firstLine(stderrTail)}`,
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
void (async () => {
|
|
150
|
+
const initialized = await request("initialize", {
|
|
151
|
+
clientInfo: { name: "baychat-relay", title: "BayChat relay", version: CLIENT_VERSION },
|
|
152
|
+
});
|
|
153
|
+
if (initialized.error) {
|
|
154
|
+
finish({ kind: "failed", transportUnusable: true, reason: `codex app-server refused the handshake: ${initialized.error.message}` });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
notify("initialized", {});
|
|
158
|
+
const resumed = await request("thread/resume", {
|
|
159
|
+
threadId: req.threadId,
|
|
160
|
+
cwd: req.cwd,
|
|
161
|
+
// See the file header: no human is present, so nothing may be approved.
|
|
162
|
+
approvalPolicy: "never",
|
|
163
|
+
});
|
|
164
|
+
if (resumed.error) {
|
|
165
|
+
// "already has an active writer" is not a fault — it is the interactive
|
|
166
|
+
// session being OPEN. A thread being written by a live Codex TUI cannot
|
|
167
|
+
// also be resumed from outside it, and that is the correct behaviour:
|
|
168
|
+
// two writers on one thread is the failure mode, not the refusal.
|
|
169
|
+
//
|
|
170
|
+
// It also names the remedy exactly. A live session is reachable through
|
|
171
|
+
// its `relay attach`, which is what attach is FOR; headless resume is
|
|
172
|
+
// for a session whose terminal has gone. Saying "could not resume" and
|
|
173
|
+
// stopping there sends someone hunting for a break that is not there.
|
|
174
|
+
if (/active writer/i.test(resumed.error.message)) {
|
|
175
|
+
finish({
|
|
176
|
+
kind: "failed",
|
|
177
|
+
transportUnusable: false,
|
|
178
|
+
reason: `Codex session "${req.threadId}" is open in a terminal, so it cannot be resumed from ` +
|
|
179
|
+
`outside it — a live session is woken through \`baychat relay attach\`, which must be ` +
|
|
180
|
+
`running in that session. Close the terminal to make it headlessly resumable instead.`,
|
|
181
|
+
});
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
// The distinct failure `exec resume` could never report: the id does not
|
|
185
|
+
// name a thread on this machine. Worth saying plainly, because the usual
|
|
186
|
+
// cause is a Codex whose sessions live somewhere else — a snap install
|
|
187
|
+
// keeps them under ~/snap/codex/current/sessions.
|
|
188
|
+
finish({ kind: "failed", transportUnusable: false, reason: `codex could not resume thread ${req.threadId}: ${resumed.error.message}` });
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
// The handshake is done; the clock is now the turn's, which is far longer.
|
|
192
|
+
clearTimeout(timer);
|
|
193
|
+
timer = setTimeout(() => finish({ kind: "failed", transportUnusable: false, reason: `codex turn did not complete within ${Math.round(turnTimeout / 60_000)} minutes` }), turnTimeout);
|
|
194
|
+
const started = await request("turn/start", {
|
|
195
|
+
threadId: req.threadId,
|
|
196
|
+
input: [{ type: "text", text: req.prompt }],
|
|
197
|
+
approvalPolicy: "never",
|
|
198
|
+
});
|
|
199
|
+
if (started.error) {
|
|
200
|
+
finish({ kind: "failed", transportUnusable: false, reason: `codex refused the turn: ${started.error.message}` });
|
|
201
|
+
}
|
|
202
|
+
// Success is NOT the response to turn/start — that only says the turn was
|
|
203
|
+
// accepted. The wake ends at the `turn/completed` notification, handled above.
|
|
204
|
+
})();
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
/** Reported to Codex as the client version. Kept in step with the package. */
|
|
208
|
+
const CLIENT_VERSION = "0.12.0";
|
|
209
|
+
/** One line of captured stderr, for appending to a failure reason. */
|
|
210
|
+
function firstLine(text) {
|
|
211
|
+
for (const line of text.split("\n")) {
|
|
212
|
+
const trimmed = line.trim();
|
|
213
|
+
if (trimmed !== "")
|
|
214
|
+
return `: ${trimmed.slice(0, 200)}`;
|
|
215
|
+
}
|
|
216
|
+
return "";
|
|
217
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.queueToThread = queueToThread;
|
|
4
|
+
const child_process_1 = require("child_process");
|
|
5
|
+
const runtime_binary_1 = require("../runtime-binary");
|
|
6
|
+
const spawn_env_1 = require("./spawn-env");
|
|
7
|
+
/** How long `codex queue` may take before we treat it as unusable. */
|
|
8
|
+
const QUEUE_TIMEOUT_MS = 30_000;
|
|
9
|
+
/**
|
|
10
|
+
* Deliver a message to a Codex session through Codex's own queue.
|
|
11
|
+
*
|
|
12
|
+
* WHY THIS IS THE RIGHT PATH, and better than every rung it sits above.
|
|
13
|
+
*
|
|
14
|
+
* The relay's other options all fight the runtime. A socket attach is refused
|
|
15
|
+
* inside a sandbox; a FIFO reaches a process but nothing re-invokes the agent;
|
|
16
|
+
* a headless resume starts a SEPARATE turn with no human, which is why it must
|
|
17
|
+
* run `approvalPolicy: "never"` — and that policy blocks Codex's own BayChat
|
|
18
|
+
* write path, so a headlessly-woken Codex can read the room and never answer it.
|
|
19
|
+
* Observed 2026-08-31 00:09 in Codex's own words: "BayChat's write path is
|
|
20
|
+
* unavailable under the enforced no-approval policy."
|
|
21
|
+
*
|
|
22
|
+
* `codex queue` sidesteps all of it. The message goes to the LIVE session, where
|
|
23
|
+
* the human already is, so approvals work normally and the relay never has to
|
|
24
|
+
* acquire a privilege on a chat message's behalf. It is first-party, needs no
|
|
25
|
+
* pty, injects nothing into a terminal, and queues when the session is between
|
|
26
|
+
* turns rather than being lost.
|
|
27
|
+
*
|
|
28
|
+
* Requires Codex >= 0.149.0, which is where `queue` landed. An older build is
|
|
29
|
+
* reported `unsupported` so the caller drops to the spawn, exactly as `runTurn`
|
|
30
|
+
* does with `transportUnusable`.
|
|
31
|
+
*
|
|
32
|
+
* argv array, never a shell string: the message is text written by other people
|
|
33
|
+
* in the room, and a shell would make `$(…)` in a chat message run on this box.
|
|
34
|
+
*/
|
|
35
|
+
function queueToThread(input) {
|
|
36
|
+
const runner = input.run ?? child_process_1.execFile;
|
|
37
|
+
// Node cannot spawn a `.cmd` directly — it fails EINVAL — so on Windows the
|
|
38
|
+
// interpreter is named explicitly. NEVER `shell: true`: the message is text
|
|
39
|
+
// written by other people in the room, and a shell string would make `$(…)`
|
|
40
|
+
// in a chat message run on this box.
|
|
41
|
+
const plan = (0, runtime_binary_1.spawnPlanFor)(input.binaryPath, input.platform ?? process.platform);
|
|
42
|
+
return new Promise((resolve) => {
|
|
43
|
+
runner(plan.file, [...plan.prefixArgs, "queue", "--thread", input.threadId, "--message", input.message], {
|
|
44
|
+
cwd: input.cwd,
|
|
45
|
+
env: (0, spawn_env_1.headlessSpawnEnv)(),
|
|
46
|
+
timeout: input.timeoutMs ?? QUEUE_TIMEOUT_MS,
|
|
47
|
+
maxBuffer: 1024 * 1024,
|
|
48
|
+
}, (err, stdout, stderr) => {
|
|
49
|
+
const out = `${stdout ?? ""}${stderr ?? ""}`;
|
|
50
|
+
if (!err) {
|
|
51
|
+
// "Queued message <uuid> for thread <uuid>." — kept as evidence, the
|
|
52
|
+
// same way a resume id carries the evidence it was learned with.
|
|
53
|
+
const id = /Queued message ([0-9a-fA-F-]{8,})/.exec(out)?.[1];
|
|
54
|
+
return resolve({ kind: "queued", messageId: id });
|
|
55
|
+
}
|
|
56
|
+
// An older Codex has no `queue` subcommand. clap says so on stderr, and
|
|
57
|
+
// it is a statement about the BUILD, not about this session — so the
|
|
58
|
+
// caller may still try the rung below.
|
|
59
|
+
if (/unrecognized subcommand|unexpected argument|error: unknown/i.test(out)) {
|
|
60
|
+
return resolve({ kind: "unsupported", reason: `this codex build has no \`queue\` subcommand: ${firstLine(out)}` });
|
|
61
|
+
}
|
|
62
|
+
resolve({ kind: "failed", reason: firstLine(out) || err.message });
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
function firstLine(text) {
|
|
67
|
+
return text.split("\n").map((l) => l.trim()).filter(Boolean)[0] ?? "";
|
|
68
|
+
}
|