baychat 0.34.1 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +13 -2
- package/dist/api.js +30 -0
- package/dist/bays-command.js +6 -0
- package/dist/channel/feed.js +48 -4
- package/dist/channel/log-file.js +37 -0
- package/dist/channel/server.js +34 -4
- package/dist/claude-context-flush.js +31 -8
- package/dist/claude-context.js +19 -5
- package/dist/claude-onboarding.js +21 -8
- package/dist/cli-help.js +3 -0
- package/dist/client-paths.js +6 -1
- package/dist/client-scope.js +34 -0
- package/dist/commands.js +16 -8
- package/dist/connect-link.js +205 -0
- package/dist/connect-plan.js +2 -2
- package/dist/connect.js +8 -0
- package/dist/device-login.js +19 -0
- package/dist/launch/command.js +6 -3
- package/dist/main.js +18 -2
- package/dist/mcp-files.js +2 -0
- package/dist/mcp-register.js +2 -0
- package/dist/protocol-content.js +1 -1
- package/dist/refresh-clients.js +7 -0
- package/dist/relay/acp/approval.js +99 -30
- package/dist/relay/acp/daemon-glue.js +2 -1
- package/dist/relay/adapters.js +5 -1
- package/dist/relay/bay-relays.js +18 -5
- package/dist/relay/codex-allowances.js +113 -0
- package/dist/relay/codex-app-server.js +637 -157
- package/dist/relay/codex-approvals.js +390 -0
- package/dist/relay/codex-commands.js +61 -0
- package/dist/relay/codex-log.js +260 -0
- package/dist/relay/codex-runner.js +505 -0
- package/dist/relay/codex-version.js +50 -0
- package/dist/relay/daemon.js +190 -6
- package/dist/relay/fresh-start.js +2 -1
- package/dist/relay/launcher/capabilities.js +24 -9
- package/dist/relay/launcher/codex-launch.js +9 -2
- package/dist/relay/launcher/codex-relay-launch.js +80 -0
- package/dist/relay/launcher/handle.js +101 -30
- package/dist/relay/launcher/loop.js +5 -1
- package/dist/relay/launcher/stop-registered.js +124 -9
- package/dist/relay/registry.js +30 -0
- package/dist/relay/runtime-limits.js +167 -0
- package/dist/relay/spawn-env.js +40 -3
- package/dist/relay/terminal-pane.js +1 -0
- package/dist/sea-names.js +34 -0
- package/dist/session-command.js +3 -1
- package/dist/session-name.js +25 -2
- package/dist/skill-bootstrap.js +2 -0
- package/dist/tool-defs.js +30 -0
- package/package.json +1 -1
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
// as `turn/completed` rather than as a process exit. Verified against codex-cli
|
|
14
14
|
// 0.114.0 on 2026-08-30: initialize → initialized → thread/resume → turn/start,
|
|
15
15
|
// with the resumed thread's own MCP servers (BayChat included) started for it.
|
|
16
|
+
// Approvals, `turn.status`, `turn/interrupt` and `thread/compact/start` follow the
|
|
17
|
+
// official 0.160 schema (`openai/codex@rust-v0.160.0`, app-server-protocol).
|
|
16
18
|
//
|
|
17
19
|
// ONE PROCESS PER WAKE, NOT A HELD CONNECTION. The design note for this work
|
|
18
20
|
// proposed keeping one app-server alive for the daemon's life. That buys a little
|
|
@@ -22,225 +24,700 @@
|
|
|
22
24
|
// already has, and every win that mattered (an id we address explicitly, a
|
|
23
25
|
// structured turn result, no shell) survives.
|
|
24
26
|
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
27
|
+
// THE SECURITY BOUNDARY IS THE POLICY, AND A HUMAN. With no `policy` a turn runs
|
|
28
|
+
// `approvalPolicy: "never"` exactly as it always has: nobody to ask, so a command
|
|
29
|
+
// needing approval fails inside the sandbox instead of running because a chat
|
|
30
|
+
// message asked for it. With a policy of `"on-request"` (2026-10-23, codex-runner.ts)
|
|
31
|
+
// Codex ASKS — and every question goes through `onApproval`, which puts a card on
|
|
32
|
+
// the owner's phone. Nothing is ever approved here by default: a request this
|
|
33
|
+
// client cannot route to a person is answered with the refusal.
|
|
30
34
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
35
|
+
exports.PausableTimer = void 0;
|
|
36
|
+
exports.askingPolicy = askingPolicy;
|
|
37
|
+
exports.readOnlyPolicy = readOnlyPolicy;
|
|
31
38
|
exports.runCodexTurn = runCodexTurn;
|
|
39
|
+
exports.runCodexCompact = runCodexCompact;
|
|
40
|
+
exports.classifyMessage = classifyMessage;
|
|
41
|
+
exports.refuseServerRequest = refuseServerRequest;
|
|
32
42
|
exports.readCodexRateLimits = readCodexRateLimits;
|
|
33
43
|
exports.parseCodexRateLimits = parseCodexRateLimits;
|
|
34
44
|
const runtime_binary_1 = require("../runtime-binary");
|
|
45
|
+
const codex_approvals_1 = require("./codex-approvals");
|
|
35
46
|
const spawn_env_1 = require("./spawn-env");
|
|
36
|
-
/** How long a whole wake may take — resume, turn, and completion. */
|
|
47
|
+
/** How long a whole wake may take — resume, turn, and completion — not counting time a card is up. */
|
|
37
48
|
const TURN_TIMEOUT_MS = 10 * 60_000;
|
|
38
49
|
/** How long the handshake alone may take before we call the binary unusable. */
|
|
39
50
|
const HANDSHAKE_TIMEOUT_MS = 30_000;
|
|
51
|
+
/** After a card is answered, the turn always gets at least this long to use the answer. */
|
|
52
|
+
const AFTER_CARD_MIN_MS = 2 * 60_000;
|
|
53
|
+
/** How long an interrupted turn has to say `turn/completed` before its process is killed. */
|
|
54
|
+
const INTERRUPT_GRACE_MS = 10_000;
|
|
40
55
|
/**
|
|
41
|
-
*
|
|
56
|
+
* Features a relay turn always runs with, whatever the user's config says. `exec_permission_approvals`
|
|
57
|
+
* (under development in 0.160, off by default) would add an approval path this relay has never
|
|
58
|
+
* seen and so cannot show on a card: kept off explicitly.
|
|
59
|
+
*/
|
|
60
|
+
const RELAY_FEATURES = { "features.exec_permission_approvals": false };
|
|
61
|
+
/**
|
|
62
|
+
* Everyone who woke the turn may command the session: Codex asks before anything outside its
|
|
63
|
+
* sandbox, the sandbox writes only the workspace, and there is NO network unless a card allows it
|
|
64
|
+
* (a command's `networkApprovalContext`, or a permissions request).
|
|
65
|
+
*/
|
|
66
|
+
function askingPolicy() {
|
|
67
|
+
return {
|
|
68
|
+
approvalPolicy: "on-request",
|
|
69
|
+
sandbox: "workspace-write",
|
|
70
|
+
sandboxPolicy: { type: "workspaceWrite", networkAccess: false, writableRoots: [], excludeTmpdirEnvVar: false, excludeSlashTmp: false },
|
|
71
|
+
approvalsReviewer: "user",
|
|
72
|
+
config: { ...RELAY_FEATURES },
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Somebody without command access woke the turn: it may read and answer, nothing else, and it
|
|
77
|
+
* never asks (no cards). Under `"never"` Codex 0.160 DENIES an MCP tool call that needs approval
|
|
78
|
+
* (core/src/mcp_tool_call.rs), which would include BayChat's own `send_message` — so BayChat's
|
|
79
|
+
* entry is set to approve its own tools for this wake, or the turn could not answer at all.
|
|
80
|
+
*/
|
|
81
|
+
function readOnlyPolicy(mcpEntry) {
|
|
82
|
+
return {
|
|
83
|
+
approvalPolicy: "never",
|
|
84
|
+
sandbox: "read-only",
|
|
85
|
+
sandboxPolicy: { type: "readOnly", networkAccess: false },
|
|
86
|
+
config: { ...RELAY_FEATURES, ...(mcpEntry ? { [`mcp_servers.${mcpEntry}.default_tools_approval_mode`]: "approve" } : {}) },
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* A timer that can be paused and resumed, remembering how long it had left.
|
|
91
|
+
*
|
|
92
|
+
* The turn's 10 minutes are Codex's, not the person's: a card can sit on a phone for its own
|
|
93
|
+
* 10 minutes, and a turn that timed out while its owner was reading the question would throw the
|
|
94
|
+
* answer away. So the clock stops while a card is up, and on resume the turn always has at least
|
|
95
|
+
* `minMs` to act on the answer.
|
|
96
|
+
*/
|
|
97
|
+
class PausableTimer {
|
|
98
|
+
fire;
|
|
99
|
+
timer;
|
|
100
|
+
remaining;
|
|
101
|
+
startedAt = 0;
|
|
102
|
+
done = false;
|
|
103
|
+
constructor(ms, fire) {
|
|
104
|
+
this.fire = fire;
|
|
105
|
+
this.remaining = ms;
|
|
106
|
+
this.arm();
|
|
107
|
+
}
|
|
108
|
+
get msLeft() {
|
|
109
|
+
return this.timer ? Math.max(0, this.remaining - (Date.now() - this.startedAt)) : this.remaining;
|
|
110
|
+
}
|
|
111
|
+
pause() {
|
|
112
|
+
if (!this.timer || this.done)
|
|
113
|
+
return;
|
|
114
|
+
this.remaining = this.msLeft;
|
|
115
|
+
clearTimeout(this.timer);
|
|
116
|
+
this.timer = undefined;
|
|
117
|
+
}
|
|
118
|
+
resume(minMs = 0) {
|
|
119
|
+
if (this.timer || this.done)
|
|
120
|
+
return;
|
|
121
|
+
this.remaining = Math.max(this.remaining, minMs);
|
|
122
|
+
this.arm();
|
|
123
|
+
}
|
|
124
|
+
clear() {
|
|
125
|
+
this.done = true;
|
|
126
|
+
clearTimeout(this.timer);
|
|
127
|
+
this.timer = undefined;
|
|
128
|
+
}
|
|
129
|
+
arm() {
|
|
130
|
+
this.startedAt = Date.now();
|
|
131
|
+
this.timer = setTimeout(() => {
|
|
132
|
+
this.timer = undefined;
|
|
133
|
+
this.done = true;
|
|
134
|
+
this.fire();
|
|
135
|
+
}, this.remaining);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
exports.PausableTimer = PausableTimer;
|
|
139
|
+
/**
|
|
140
|
+
* Start `codex app-server` and frame its JSON-RPC: newline-delimited, our ids `bc-N`, server
|
|
141
|
+
* requests told apart by their method (`classifyMessage`). Returns the reason when there is no
|
|
142
|
+
* argv to run.
|
|
143
|
+
*/
|
|
144
|
+
function openAppServer(binaryPath, cwd, spawn, handlers) {
|
|
145
|
+
// A Windows .cmd needs its interpreter named, exactly as the plain-spawn
|
|
146
|
+
// path does. This was missed when that fix landed, and the result was
|
|
147
|
+
// `delivery failed for CodexTest: spawn EINVAL` on every wake — the
|
|
148
|
+
// transport failing before the handshake, on a machine where Codex was
|
|
149
|
+
// installed and working.
|
|
150
|
+
// `app-server` is a fixed word, so a plan always yields argv — `!ok` is unreachable here.
|
|
151
|
+
const run = (0, runtime_binary_1.planArgv)((0, runtime_binary_1.spawnPlanFor)(binaryPath, process.platform), ["app-server"]);
|
|
152
|
+
if (!run.ok)
|
|
153
|
+
return { error: run.reason };
|
|
154
|
+
const child = spawn(run.file, run.args, {
|
|
155
|
+
windowsHide: true,
|
|
156
|
+
windowsVerbatimArguments: run.verbatim,
|
|
157
|
+
cwd,
|
|
158
|
+
// Same reason as `runHeadless`: an npm-installed codex is a script with a
|
|
159
|
+
// `#!/usr/bin/env node` shebang and the daemon's PATH has no node. Both
|
|
160
|
+
// spawn sites need this — fixing only one leaves the other dying with
|
|
161
|
+
// exit 127 the moment it becomes the reachable path.
|
|
162
|
+
env: (0, spawn_env_1.headlessSpawnEnv)(),
|
|
163
|
+
// No shell, ever: the prompt carries message text written by other people
|
|
164
|
+
// in the room, and a shell string would make `$(…)` in a chat message run
|
|
165
|
+
// on this box. Nothing here is concatenated into a command line.
|
|
166
|
+
shell: false,
|
|
167
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
168
|
+
});
|
|
169
|
+
let nextId = 0;
|
|
170
|
+
// OUR request ids are strings ("bc-0", "bc-1", …) and the server's are its own: a server
|
|
171
|
+
// REQUEST is told apart by its `method`, never by its id (see `classifyMessage`).
|
|
172
|
+
const pending = new Map();
|
|
173
|
+
let stdoutBuffer = "";
|
|
174
|
+
let stderrTail = "";
|
|
175
|
+
let ended = false;
|
|
176
|
+
const end = (info) => {
|
|
177
|
+
if (ended)
|
|
178
|
+
return;
|
|
179
|
+
ended = true;
|
|
180
|
+
handlers.onEnd(info);
|
|
181
|
+
};
|
|
182
|
+
const write = (obj) => {
|
|
183
|
+
if (ended)
|
|
184
|
+
return;
|
|
185
|
+
child.stdin?.write(`${JSON.stringify({ jsonrpc: "2.0", ...obj })}\n`);
|
|
186
|
+
};
|
|
187
|
+
child.stdout?.on("data", (chunk) => {
|
|
188
|
+
stdoutBuffer += chunk.toString();
|
|
189
|
+
// Newline-delimited JSON. A partial line is kept for the next chunk;
|
|
190
|
+
// parsing one would be the classic framing bug.
|
|
191
|
+
let newline = stdoutBuffer.indexOf("\n");
|
|
192
|
+
while (newline >= 0) {
|
|
193
|
+
const line = stdoutBuffer.slice(0, newline).trim();
|
|
194
|
+
stdoutBuffer = stdoutBuffer.slice(newline + 1);
|
|
195
|
+
newline = stdoutBuffer.indexOf("\n");
|
|
196
|
+
if (line === "")
|
|
197
|
+
continue;
|
|
198
|
+
let message;
|
|
199
|
+
try {
|
|
200
|
+
message = JSON.parse(line);
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
// Not our protocol. Ignored rather than fatal: the binary is entitled
|
|
204
|
+
// to print things, and a stray line must not lose a turn that is
|
|
205
|
+
// otherwise proceeding normally.
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
const kind = classifyMessage(message);
|
|
209
|
+
if (kind === "response") {
|
|
210
|
+
const waiting = pending.get(String(message.id));
|
|
211
|
+
if (!waiting)
|
|
212
|
+
continue; // not ours, or answered already: never guessed at
|
|
213
|
+
pending.delete(String(message.id));
|
|
214
|
+
waiting(message);
|
|
215
|
+
}
|
|
216
|
+
else if (kind === "request") {
|
|
217
|
+
handlers.onServerRequest(message);
|
|
218
|
+
}
|
|
219
|
+
else if (kind === "notification") {
|
|
220
|
+
handlers.onNotification(message);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
child.stderr?.on("data", (chunk) => {
|
|
225
|
+
// Bounded, and kept only to explain a failure. Codex logs freely here even
|
|
226
|
+
// on a healthy run, so it is never treated as a failure signal by itself.
|
|
227
|
+
stderrTail = `${stderrTail}${chunk.toString()}`.slice(-2_000);
|
|
228
|
+
});
|
|
229
|
+
// A child that died before reading: its stdin errors (EPIPE), which must not crash the relay.
|
|
230
|
+
// `close` follows and reports it.
|
|
231
|
+
child.stdin?.on("error", () => { });
|
|
232
|
+
child.on("error", (err) => end({ startError: err.message }));
|
|
233
|
+
child.on("close", (code) => end({ code }));
|
|
234
|
+
return {
|
|
235
|
+
request: (method, params) => new Promise((res) => {
|
|
236
|
+
const id = `bc-${nextId++}`;
|
|
237
|
+
pending.set(id, res);
|
|
238
|
+
write({ id, method, params });
|
|
239
|
+
}),
|
|
240
|
+
notify: (method, params) => write({ method, params }),
|
|
241
|
+
/** Answer a server request: its id VERBATIM (a number stays a number), and a result or an error. */
|
|
242
|
+
respond: (id, answer) => write({ id, ...answer }),
|
|
243
|
+
kill: () => {
|
|
244
|
+
ended = true;
|
|
245
|
+
child.kill("SIGTERM");
|
|
246
|
+
// A child that ignores the polite signal would otherwise outlive the relay
|
|
247
|
+
// and keep answering a room nobody is watching.
|
|
248
|
+
setTimeout(() => child.kill("SIGKILL"), 5_000).unref();
|
|
249
|
+
},
|
|
250
|
+
stderrTail: () => stderrTail,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
/** The handshake every wake starts with. Resolves an error message, or undefined. */
|
|
254
|
+
async function handshake(conn) {
|
|
255
|
+
const initialized = await conn.request("initialize", {
|
|
256
|
+
clientInfo: { name: "baychat-relay", title: "BayChat relay", version: CLIENT_VERSION },
|
|
257
|
+
});
|
|
258
|
+
if (initialized.error)
|
|
259
|
+
return initialized.error.message;
|
|
260
|
+
conn.notify("initialized", {});
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
263
|
+
/** `thread/resume` or `thread/start` params for a policy — undefined fields left out. */
|
|
264
|
+
function threadParams(policy, cwd) {
|
|
265
|
+
// See the file header: with no policy, nothing may be approved.
|
|
266
|
+
if (!policy)
|
|
267
|
+
return { cwd, approvalPolicy: "never" };
|
|
268
|
+
return {
|
|
269
|
+
cwd,
|
|
270
|
+
approvalPolicy: policy.approvalPolicy,
|
|
271
|
+
sandbox: policy.sandbox,
|
|
272
|
+
...(policy.approvalsReviewer ? { approvalsReviewer: policy.approvalsReviewer } : {}),
|
|
273
|
+
...(policy.config ? { config: policy.config } : {}),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
/** A `thread/resume` refusal, said so a person can act on it. */
|
|
277
|
+
function resumeRefusal(threadId, message) {
|
|
278
|
+
// "already has an active writer" is not a fault — it is the interactive
|
|
279
|
+
// session being OPEN. A thread being written by a live Codex TUI cannot
|
|
280
|
+
// also be resumed from outside it, and that is the correct behaviour:
|
|
281
|
+
// two writers on one thread is the failure mode, not the refusal.
|
|
282
|
+
//
|
|
283
|
+
// It also names the remedy exactly. A live session is reachable through
|
|
284
|
+
// its `relay attach`, which is what attach is FOR; headless resume is
|
|
285
|
+
// for a session whose terminal has gone. Saying "could not resume" and
|
|
286
|
+
// stopping there sends someone hunting for a break that is not there.
|
|
287
|
+
if (/active writer/i.test(message)) {
|
|
288
|
+
return {
|
|
289
|
+
kind: "failed",
|
|
290
|
+
transportUnusable: false,
|
|
291
|
+
reason: `Codex session "${threadId}" is open in a terminal, so it cannot be resumed from ` +
|
|
292
|
+
`outside it — a live session is woken through \`baychat relay attach\`, which must be ` +
|
|
293
|
+
`running in that session. Close the terminal to make it headlessly resumable instead.`,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
// The distinct failure `exec resume` could never report: the id does not
|
|
297
|
+
// name a thread on this machine. Worth saying plainly, because the usual
|
|
298
|
+
// cause is a Codex whose sessions live somewhere else — a snap install
|
|
299
|
+
// keeps them under ~/snap/codex/current/sessions.
|
|
300
|
+
return {
|
|
301
|
+
kind: "failed",
|
|
302
|
+
transportUnusable: false,
|
|
303
|
+
threadMissing: /no rollout|not found|unknown thread|does not exist/i.test(message),
|
|
304
|
+
reason: `codex could not resume thread ${threadId}: ${message}`,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Run one turn against a Codex thread and resolve with its outcome.
|
|
42
309
|
*
|
|
43
310
|
* Never throws and never rejects: every failure — a binary that will not start,
|
|
44
311
|
* a thread id that names nothing, a protocol error, a timeout — is a `failed`
|
|
45
312
|
* outcome carrying its reason. The caller's job is to record that, and a thrown
|
|
46
313
|
* exception would only turn a describable state into a stack trace.
|
|
314
|
+
*
|
|
315
|
+
* Approval requests are answered ONE AT A TIME, in the order they arrived (a person reads one
|
|
316
|
+
* card at a time, and Codex's later requests usually depend on the earlier answer). While any is
|
|
317
|
+
* waiting, the turn's clock is paused. One Codex settled itself (`serverRequest/resolved`) is
|
|
318
|
+
* withdrawn and never answered. Only `turn.status: "completed"` counts as an answer:
|
|
319
|
+
* `interrupted` and `failed` are failures with their reason.
|
|
47
320
|
*/
|
|
48
321
|
function runCodexTurn(req, deps) {
|
|
49
|
-
const spawn = deps.spawn;
|
|
50
322
|
const turnTimeout = deps.turnTimeoutMs ?? TURN_TIMEOUT_MS;
|
|
51
323
|
const handshakeTimeout = deps.handshakeTimeoutMs ?? HANDSHAKE_TIMEOUT_MS;
|
|
324
|
+
const graceMs = deps.interruptGraceMs ?? INTERRUPT_GRACE_MS;
|
|
325
|
+
const afterCardMs = deps.afterCardMinMs ?? AFTER_CARD_MIN_MS;
|
|
326
|
+
const log = req.log ?? (() => { });
|
|
52
327
|
return new Promise((resolve) => {
|
|
53
|
-
// A Windows .cmd needs its interpreter named, exactly as the plain-spawn
|
|
54
|
-
// path does. This was missed when that fix landed, and the result was
|
|
55
|
-
// `delivery failed for CodexTest: spawn EINVAL` on every wake — the
|
|
56
|
-
// transport failing before the handshake, on a machine where Codex was
|
|
57
|
-
// installed and working.
|
|
58
|
-
// `app-server` is a fixed word, so a plan always yields argv — `!ok` is unreachable here.
|
|
59
|
-
const run = (0, runtime_binary_1.planArgv)((0, runtime_binary_1.spawnPlanFor)(req.binaryPath, process.platform), ["app-server"]);
|
|
60
|
-
if (!run.ok)
|
|
61
|
-
return resolve({ kind: "failed", transportUnusable: true, reason: run.reason });
|
|
62
|
-
const child = spawn(run.file, run.args, {
|
|
63
|
-
windowsHide: true,
|
|
64
|
-
windowsVerbatimArguments: run.verbatim,
|
|
65
|
-
cwd: req.cwd,
|
|
66
|
-
// Same reason as `runHeadless`: an npm-installed codex is a script with a
|
|
67
|
-
// `#!/usr/bin/env node` shebang and the daemon's PATH has no node. Both
|
|
68
|
-
// spawn sites need this — fixing only one leaves the other dying with
|
|
69
|
-
// exit 127 the moment it becomes the reachable path.
|
|
70
|
-
env: (0, spawn_env_1.headlessSpawnEnv)(),
|
|
71
|
-
// No shell, ever: the prompt carries message text written by other people
|
|
72
|
-
// in the room, and a shell string would make `$(…)` in a chat message run
|
|
73
|
-
// on this box. Nothing here is concatenated into a command line.
|
|
74
|
-
shell: false,
|
|
75
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
76
|
-
});
|
|
77
328
|
let settled = false;
|
|
78
|
-
let
|
|
79
|
-
|
|
80
|
-
let
|
|
81
|
-
let
|
|
82
|
-
|
|
329
|
+
let threadId = req.threadId;
|
|
330
|
+
let startedThread = false;
|
|
331
|
+
let turnId;
|
|
332
|
+
let stopping = false;
|
|
333
|
+
let turnTimer;
|
|
334
|
+
let graceTimer;
|
|
335
|
+
/** Items an approval may refer to, by item id (a file change's diff is only here). */
|
|
336
|
+
const items = new Map();
|
|
337
|
+
/** Server requests Codex settled itself; key = String(id). */
|
|
338
|
+
const resolvedByServer = new Set();
|
|
339
|
+
/** The answer each in-flight request is waiting for, so it can be called off. */
|
|
340
|
+
const inFlight = new Map();
|
|
341
|
+
let approvals = Promise.resolve();
|
|
342
|
+
let asking = 0;
|
|
343
|
+
/** Resolve once, and always leave no process behind and no card up. */
|
|
83
344
|
const finish = (outcome) => {
|
|
84
345
|
if (settled)
|
|
85
346
|
return;
|
|
86
347
|
settled = true;
|
|
87
|
-
clearTimeout(
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
348
|
+
clearTimeout(handshakeTimer);
|
|
349
|
+
clearTimeout(graceTimer);
|
|
350
|
+
turnTimer?.clear();
|
|
351
|
+
for (const ac of inFlight.values())
|
|
352
|
+
ac.abort();
|
|
353
|
+
req.signal?.removeEventListener("abort", onAbort);
|
|
354
|
+
conn.kill();
|
|
92
355
|
resolve(outcome);
|
|
93
356
|
};
|
|
94
|
-
|
|
357
|
+
const handshakeTimer = setTimeout(() => finish({
|
|
95
358
|
kind: "failed",
|
|
96
359
|
transportUnusable: true,
|
|
97
360
|
reason: `codex app-server did not complete the handshake within ${Math.round(handshakeTimeout / 1000)}s`,
|
|
98
361
|
}), handshakeTimeout);
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
362
|
+
/** Turn ends that arrived before we knew our own turn's id (the answer to turn/start is a
|
|
363
|
+
* response; the notification can overtake it). Settled once the id is known. */
|
|
364
|
+
const early = new Map();
|
|
365
|
+
const learnTurnId = (id) => {
|
|
366
|
+
if (turnId)
|
|
367
|
+
return;
|
|
368
|
+
turnId = id;
|
|
369
|
+
const waiting = early.get(id);
|
|
370
|
+
early.clear();
|
|
371
|
+
if (waiting)
|
|
372
|
+
onTurnCompleted(waiting);
|
|
106
373
|
};
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
const line = stdoutBuffer.slice(0, newline).trim();
|
|
114
|
-
stdoutBuffer = stdoutBuffer.slice(newline + 1);
|
|
115
|
-
newline = stdoutBuffer.indexOf("\n");
|
|
116
|
-
if (line === "")
|
|
117
|
-
continue;
|
|
118
|
-
let message;
|
|
119
|
-
try {
|
|
120
|
-
message = JSON.parse(line);
|
|
121
|
-
}
|
|
122
|
-
catch {
|
|
123
|
-
// Not our protocol. Ignored rather than fatal: the binary is entitled
|
|
124
|
-
// to print things, and a stray line must not lose a turn that is
|
|
125
|
-
// otherwise proceeding normally.
|
|
126
|
-
continue;
|
|
127
|
-
}
|
|
128
|
-
if (typeof message.id === "number" && pending.has(message.id)) {
|
|
129
|
-
const waiting = pending.get(message.id);
|
|
130
|
-
pending.delete(message.id);
|
|
131
|
-
waiting?.(message);
|
|
132
|
-
continue;
|
|
374
|
+
const onTurnCompleted = (params) => {
|
|
375
|
+
const turn = (params.turn ?? null);
|
|
376
|
+
if (turn && typeof turn.id === "string") {
|
|
377
|
+
if (!turnId) {
|
|
378
|
+
early.set(turn.id, params);
|
|
379
|
+
return;
|
|
133
380
|
}
|
|
381
|
+
// Another turn's end (a compaction it ran first) is not ours.
|
|
382
|
+
if (turn.id !== turnId)
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
// A Codex older than `turn.status` (0.114 sent `{threadId}` alone) only ever said this for
|
|
386
|
+
// a finished turn, so a missing status reads as completed. A present one is believed.
|
|
387
|
+
const status = turn && typeof turn.status === "string" ? turn.status : "completed";
|
|
388
|
+
if (status === "completed") {
|
|
389
|
+
finish(startedThread && threadId ? { kind: "completed", threadId } : { kind: "completed" });
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
const error = (turn?.error ?? null);
|
|
393
|
+
const detail = error && typeof error.message === "string" ? `: ${error.message}` : "";
|
|
394
|
+
if (status === "interrupted") {
|
|
395
|
+
finish({ kind: "failed", transportUnusable: false, stopped: stopping, reason: stopping ? "the turn was stopped from BayChat" : `Codex interrupted the turn${detail}` });
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
finish({ kind: "failed", transportUnusable: false, reason: `the Codex turn ${status === "failed" ? "failed" : `ended as "${status}"`}${detail}` });
|
|
399
|
+
};
|
|
400
|
+
const onNotification = (message) => {
|
|
401
|
+
const method = message.method;
|
|
402
|
+
const params = (message.params ?? {});
|
|
403
|
+
try {
|
|
404
|
+
req.onNotification?.(method, params);
|
|
405
|
+
}
|
|
406
|
+
catch {
|
|
407
|
+
// The log is never worth a turn.
|
|
408
|
+
}
|
|
409
|
+
if (method === "item/started" || method === "item/completed") {
|
|
410
|
+
const item = (0, codex_approvals_1.cachedItemFrom)(params.item);
|
|
411
|
+
if (item)
|
|
412
|
+
items.set(item.id, item);
|
|
413
|
+
}
|
|
414
|
+
else if (method === "item/fileChange/patchUpdated") {
|
|
415
|
+
const id = typeof params.itemId === "string" ? params.itemId : "";
|
|
416
|
+
if (id)
|
|
417
|
+
items.set(id, { type: "fileChange", id, changes: (0, codex_approvals_1.fileChangesFrom)(params.changes) });
|
|
418
|
+
}
|
|
419
|
+
else if (method === "turn/started") {
|
|
420
|
+
const turn = (params.turn ?? {});
|
|
421
|
+
if (typeof turn.id === "string")
|
|
422
|
+
learnTurnId(turn.id);
|
|
423
|
+
}
|
|
424
|
+
else if (method === "serverRequest/resolved") {
|
|
425
|
+
const key = String(params.requestId);
|
|
426
|
+
resolvedByServer.add(key);
|
|
427
|
+
inFlight.get(key)?.abort();
|
|
428
|
+
}
|
|
429
|
+
else if (method === "turn/completed") {
|
|
134
430
|
// `turn/completed` is the end of the wake — the whole reason this
|
|
135
431
|
// transport is better than an exit code.
|
|
136
|
-
|
|
137
|
-
finish({ kind: "completed" });
|
|
432
|
+
onTurnCompleted(params);
|
|
138
433
|
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
434
|
+
};
|
|
435
|
+
const onServerRequest = (message) => {
|
|
436
|
+
const id = message.id;
|
|
437
|
+
const method = message.method;
|
|
438
|
+
const params = (message.params ?? {});
|
|
439
|
+
if (stopping) {
|
|
440
|
+
// The turn is being stopped: nothing more is asked of anyone, and nothing is allowed from
|
|
441
|
+
// memory either — every question in the grace period is refused.
|
|
442
|
+
log(`codex asked ${method} while stopping — refused`);
|
|
443
|
+
conn.respond(id, refuseServerRequest(method));
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
if (!(0, codex_approvals_1.isCodexApprovalMethod)(method) || !req.onApproval) {
|
|
447
|
+
// Codex is asking US something we do not route to a person. Every such question must
|
|
448
|
+
// be answered, or the turn waits on it until the timeout; the answer is the refusal.
|
|
449
|
+
log(`codex asked ${method} — refused (${req.onApproval ? "not something this relay asks a person" : "nobody to ask in this turn"})`);
|
|
450
|
+
conn.respond(id, refuseServerRequest(method));
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
const key = String(id);
|
|
454
|
+
const itemId = typeof params.itemId === "string" ? params.itemId : "";
|
|
455
|
+
const ac = new AbortController();
|
|
456
|
+
inFlight.set(key, ac);
|
|
457
|
+
// Serialized: one card at a time, in the order Codex asked.
|
|
458
|
+
approvals = approvals.then(async () => {
|
|
459
|
+
let answer;
|
|
460
|
+
let asked = false;
|
|
461
|
+
const personAsked = () => {
|
|
462
|
+
if (asked)
|
|
463
|
+
return;
|
|
464
|
+
asked = true;
|
|
465
|
+
asking += 1;
|
|
466
|
+
turnTimer?.pause();
|
|
467
|
+
};
|
|
468
|
+
try {
|
|
469
|
+
if (settled || resolvedByServer.has(key))
|
|
470
|
+
return;
|
|
471
|
+
// A stop that came while this one waited its turn: refused, never asked.
|
|
472
|
+
answer = stopping
|
|
473
|
+
? refuseServerRequest(method).result
|
|
474
|
+
: await req.onApproval({ method, params, ...(items.get(itemId) ? { item: items.get(itemId) } : {}) }, ac.signal, { personAsked });
|
|
475
|
+
}
|
|
476
|
+
catch (err) {
|
|
477
|
+
log(`approval for ${method} failed (${err instanceof Error ? err.message : String(err)}) — refused`);
|
|
478
|
+
answer = refuseServerRequest(method).result;
|
|
479
|
+
}
|
|
480
|
+
finally {
|
|
481
|
+
inFlight.delete(key);
|
|
482
|
+
if (asked) {
|
|
483
|
+
asking -= 1;
|
|
484
|
+
if (asking === 0)
|
|
485
|
+
turnTimer?.resume(afterCardMs);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
// Codex settled it while the card was up (or the turn is over): answering now would be
|
|
489
|
+
// an answer to nothing — or worse, to a request id it has since reused.
|
|
490
|
+
if (settled || resolvedByServer.has(key))
|
|
491
|
+
return;
|
|
492
|
+
conn.respond(id, { result: answer });
|
|
151
493
|
});
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
finish({
|
|
494
|
+
};
|
|
495
|
+
const opened = openAppServer(req.binaryPath, req.cwd, deps.spawn, {
|
|
496
|
+
onNotification,
|
|
497
|
+
onServerRequest,
|
|
498
|
+
onEnd: ({ startError, code }) => finish({
|
|
157
499
|
kind: "failed",
|
|
158
500
|
// Dying before the turn completed means the transport did not work,
|
|
159
|
-
// whatever the cause — worth one attempt down the older path.
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
501
|
+
// whatever the cause — worth one attempt down the older path. Unless
|
|
502
|
+
// WE stopped it: then there is nothing to retry.
|
|
503
|
+
transportUnusable: !stopping,
|
|
504
|
+
...(stopping ? { stopped: true } : {}),
|
|
505
|
+
reason: startError !== undefined
|
|
506
|
+
? `could not start codex app-server: ${startError}`
|
|
507
|
+
: `codex app-server exited ${code ?? "on a signal"} before the turn completed${firstLine(conn.stderrTail())}`,
|
|
508
|
+
}),
|
|
163
509
|
});
|
|
510
|
+
if ("error" in opened) {
|
|
511
|
+
clearTimeout(handshakeTimer);
|
|
512
|
+
settled = true;
|
|
513
|
+
resolve({ kind: "failed", transportUnusable: true, reason: opened.error });
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
const conn = opened;
|
|
517
|
+
/** `/stop`, End: ask Codex to stop the turn, and kill the process if it does not. */
|
|
518
|
+
const onAbort = () => {
|
|
519
|
+
if (settled || stopping)
|
|
520
|
+
return;
|
|
521
|
+
stopping = true;
|
|
522
|
+
for (const ac of inFlight.values())
|
|
523
|
+
ac.abort();
|
|
524
|
+
if (!turnId || !threadId) {
|
|
525
|
+
// No turn to interrupt yet: ending the process is the whole stop.
|
|
526
|
+
finish({ kind: "failed", transportUnusable: false, stopped: true, reason: "the turn was stopped from BayChat" });
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
void conn.request("turn/interrupt", { threadId, turnId });
|
|
530
|
+
graceTimer = setTimeout(() => finish({ kind: "failed", transportUnusable: false, stopped: true, reason: "the turn was stopped from BayChat" }), graceMs);
|
|
531
|
+
};
|
|
532
|
+
if (req.signal?.aborted) {
|
|
533
|
+
clearTimeout(handshakeTimer);
|
|
534
|
+
settled = true;
|
|
535
|
+
resolve({ kind: "failed", transportUnusable: false, stopped: true, reason: "the turn was stopped from BayChat" });
|
|
536
|
+
conn.kill();
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
req.signal?.addEventListener("abort", onAbort, { once: true });
|
|
164
540
|
void (async () => {
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
},
|
|
171
|
-
});
|
|
172
|
-
if (initialized.error) {
|
|
173
|
-
finish({
|
|
174
|
-
kind: "failed",
|
|
175
|
-
transportUnusable: true,
|
|
176
|
-
reason: `codex app-server refused the handshake: ${initialized.error.message}`,
|
|
177
|
-
});
|
|
541
|
+
const refused = await handshake(conn);
|
|
542
|
+
if (settled)
|
|
543
|
+
return;
|
|
544
|
+
if (refused !== undefined) {
|
|
545
|
+
finish({ kind: "failed", transportUnusable: true, reason: `codex app-server refused the handshake: ${refused}` });
|
|
178
546
|
return;
|
|
179
547
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
// also be resumed from outside it, and that is the correct behaviour:
|
|
191
|
-
// two writers on one thread is the failure mode, not the refusal.
|
|
192
|
-
//
|
|
193
|
-
// It also names the remedy exactly. A live session is reachable through
|
|
194
|
-
// its `relay attach`, which is what attach is FOR; headless resume is
|
|
195
|
-
// for a session whose terminal has gone. Saying "could not resume" and
|
|
196
|
-
// stopping there sends someone hunting for a break that is not there.
|
|
197
|
-
if (/active writer/i.test(resumed.error.message)) {
|
|
198
|
-
finish({
|
|
199
|
-
kind: "failed",
|
|
200
|
-
transportUnusable: false,
|
|
201
|
-
reason: `Codex session "${req.threadId}" is open in a terminal, so it cannot be resumed from ` +
|
|
202
|
-
`outside it — a live session is woken through \`baychat relay attach\`, which must be ` +
|
|
203
|
-
`running in that session. Close the terminal to make it headlessly resumable instead.`,
|
|
204
|
-
});
|
|
548
|
+
if (threadId) {
|
|
549
|
+
const resumed = await conn.request("thread/resume", { threadId, ...threadParams(req.policy, req.cwd) });
|
|
550
|
+
if (settled)
|
|
551
|
+
return;
|
|
552
|
+
if (resumed.error)
|
|
553
|
+
return finish(resumeRefusal(threadId, resumed.error.message));
|
|
554
|
+
}
|
|
555
|
+
else {
|
|
556
|
+
const started = await conn.request("thread/start", threadParams(req.policy, req.cwd));
|
|
557
|
+
if (settled)
|
|
205
558
|
return;
|
|
559
|
+
const id = (started.result ?? {}).thread?.id;
|
|
560
|
+
if (started.error || typeof id !== "string") {
|
|
561
|
+
return finish({ kind: "failed", transportUnusable: false, reason: `codex could not start a new thread: ${started.error?.message ?? "no thread id in its answer"}` });
|
|
206
562
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
// cause is a Codex whose sessions live somewhere else — a snap install
|
|
210
|
-
// keeps them under ~/snap/codex/current/sessions.
|
|
211
|
-
finish({
|
|
212
|
-
kind: "failed",
|
|
213
|
-
transportUnusable: false,
|
|
214
|
-
reason: `codex could not resume thread ${req.threadId}: ${resumed.error.message}`,
|
|
215
|
-
});
|
|
216
|
-
return;
|
|
563
|
+
threadId = id;
|
|
564
|
+
startedThread = true;
|
|
217
565
|
}
|
|
218
566
|
// The handshake is done; the clock is now the turn's, which is far longer.
|
|
219
|
-
clearTimeout(
|
|
220
|
-
|
|
567
|
+
clearTimeout(handshakeTimer);
|
|
568
|
+
turnTimer = new PausableTimer(turnTimeout, () => finish({
|
|
221
569
|
kind: "failed",
|
|
222
570
|
transportUnusable: false,
|
|
223
571
|
reason: `codex turn did not complete within ${Math.round(turnTimeout / 60_000)} minutes`,
|
|
224
|
-
})
|
|
225
|
-
|
|
226
|
-
|
|
572
|
+
}));
|
|
573
|
+
if (asking > 0)
|
|
574
|
+
turnTimer.pause();
|
|
575
|
+
const started = await conn.request("turn/start", {
|
|
576
|
+
threadId,
|
|
227
577
|
input: [{ type: "text", text: req.prompt }],
|
|
228
|
-
approvalPolicy: "never",
|
|
578
|
+
approvalPolicy: req.policy?.approvalPolicy ?? "never",
|
|
579
|
+
...(req.policy ? { sandboxPolicy: req.policy.sandboxPolicy } : {}),
|
|
580
|
+
...(req.policy?.approvalsReviewer ? { approvalsReviewer: req.policy.approvalsReviewer } : {}),
|
|
229
581
|
});
|
|
582
|
+
if (settled)
|
|
583
|
+
return;
|
|
230
584
|
if (started.error) {
|
|
231
585
|
finish({
|
|
232
586
|
kind: "failed",
|
|
233
587
|
transportUnusable: false,
|
|
234
588
|
reason: `codex refused the turn: ${started.error.message}`,
|
|
235
589
|
});
|
|
590
|
+
return;
|
|
236
591
|
}
|
|
592
|
+
const id = (started.result ?? {}).turn?.id;
|
|
593
|
+
if (typeof id === "string")
|
|
594
|
+
learnTurnId(id);
|
|
595
|
+
else if (!turnId && early.size > 0)
|
|
596
|
+
onTurnCompleted([...early.values()][0]); // an answer with no id: the only turn there is
|
|
237
597
|
// Success is NOT the response to turn/start — that only says the turn was
|
|
238
598
|
// accepted. The wake ends at the `turn/completed` notification, handled above.
|
|
239
599
|
})();
|
|
240
600
|
});
|
|
241
601
|
}
|
|
602
|
+
/** How long a compaction may take before the relay gives up on it. */
|
|
603
|
+
const COMPACT_TIMEOUT_MS = 5 * 60_000;
|
|
604
|
+
/**
|
|
605
|
+
* A short exchange with one `codex app-server` process: handshake, `body`, kill. Every server
|
|
606
|
+
* request on the way is refused (nobody is asked during setup or a compaction). Resolves what
|
|
607
|
+
* `body` settles with, or a failure — never throws.
|
|
608
|
+
*/
|
|
609
|
+
function shortExchange(binaryPath, cwd, deps, timeoutMs, what, fail, body) {
|
|
610
|
+
return new Promise((resolve) => {
|
|
611
|
+
let settled = false;
|
|
612
|
+
let handler;
|
|
613
|
+
const done = (value) => {
|
|
614
|
+
if (settled)
|
|
615
|
+
return;
|
|
616
|
+
settled = true;
|
|
617
|
+
clearTimeout(timer);
|
|
618
|
+
conn.kill();
|
|
619
|
+
resolve(value);
|
|
620
|
+
};
|
|
621
|
+
const opened = openAppServer(binaryPath, cwd, deps.spawn, {
|
|
622
|
+
onNotification: (m) => handler?.(m.method, (m.params ?? {})),
|
|
623
|
+
onServerRequest: (m) => conn.respond(m.id, refuseServerRequest(m.method)),
|
|
624
|
+
onEnd: ({ startError, code }) => done(fail(startError !== undefined ? `could not start codex app-server: ${startError}` : `codex app-server exited ${code ?? "on a signal"} before ${what} finished${firstLine(conn.stderrTail())}`, true)),
|
|
625
|
+
});
|
|
626
|
+
if ("error" in opened)
|
|
627
|
+
return resolve(fail(opened.error, true));
|
|
628
|
+
const conn = opened;
|
|
629
|
+
const timer = setTimeout(() => done(fail(`${what} did not finish within ${Math.round(timeoutMs / 1000)}s`, false)), timeoutMs);
|
|
630
|
+
void (async () => {
|
|
631
|
+
const refused = await handshake(conn);
|
|
632
|
+
if (settled)
|
|
633
|
+
return;
|
|
634
|
+
if (refused !== undefined)
|
|
635
|
+
return done(fail(`codex app-server refused the handshake: ${refused}`, true));
|
|
636
|
+
await body(conn, done, { on: (h) => (handler = h) });
|
|
637
|
+
})();
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* `/compact` for a relay-run Codex: resume the thread, ask it to compact (`thread/compact/start`,
|
|
642
|
+
* which answers `{}` at once), and wait for the compaction to end — `thread/compacted`, or the
|
|
643
|
+
* compaction turn's `turn/completed`. At most five minutes. Never throws.
|
|
644
|
+
*/
|
|
645
|
+
function runCodexCompact(req, deps) {
|
|
646
|
+
const fail = (reason, transportUnusable) => ({ kind: "failed", reason, transportUnusable });
|
|
647
|
+
if (req.signal?.aborted)
|
|
648
|
+
return Promise.resolve({ kind: "failed", reason: "the compaction was stopped", transportUnusable: false, stopped: true });
|
|
649
|
+
return shortExchange(req.binaryPath, req.cwd, deps, deps.compactTimeoutMs ?? COMPACT_TIMEOUT_MS, "the compaction", fail, async (conn, done, notifications) => {
|
|
650
|
+
req.signal?.addEventListener("abort", () => done({ kind: "failed", reason: "the compaction was stopped", transportUnusable: false, stopped: true }), { once: true });
|
|
651
|
+
notifications.on((method, params) => {
|
|
652
|
+
try {
|
|
653
|
+
req.onNotification?.(method, params);
|
|
654
|
+
}
|
|
655
|
+
catch {
|
|
656
|
+
// The log is never worth a compaction.
|
|
657
|
+
}
|
|
658
|
+
if (method === "thread/compacted")
|
|
659
|
+
return done({ kind: "completed" });
|
|
660
|
+
if (method === "turn/completed") {
|
|
661
|
+
const turn = (params.turn ?? null);
|
|
662
|
+
const status = turn && typeof turn.status === "string" ? turn.status : "completed";
|
|
663
|
+
const error = (turn?.error ?? null);
|
|
664
|
+
done(status === "completed" ? { kind: "completed" } : fail(`the compaction ended as "${status}"${error && typeof error.message === "string" ? `: ${error.message}` : ""}`, false));
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
const resumed = await conn.request("thread/resume", { threadId: req.threadId, ...threadParams(req.policy, req.cwd) });
|
|
668
|
+
if (resumed.error)
|
|
669
|
+
return done(resumeRefusal(req.threadId, resumed.error.message));
|
|
670
|
+
const compact = await conn.request("thread/compact/start", { threadId: req.threadId });
|
|
671
|
+
if (compact.error)
|
|
672
|
+
done(fail(`codex refused to compact: ${compact.error.message}`, false));
|
|
673
|
+
});
|
|
674
|
+
}
|
|
242
675
|
/** Reported to Codex as the client version. Kept in step with the package. */
|
|
243
676
|
const CLIENT_VERSION = "0.12.0";
|
|
677
|
+
/**
|
|
678
|
+
* What one line from `codex app-server` is.
|
|
679
|
+
*
|
|
680
|
+
* A line with a `method` AND an `id` is a REQUEST from the server — an approval it wants
|
|
681
|
+
* answered, an elicitation, a tool call. A line with an `id` and no `method` is a RESPONSE to one
|
|
682
|
+
* of ours. Before 2026-10-23 this client only looked at the id, so a server request whose id
|
|
683
|
+
* happened to equal one of our own numeric ids (both sides counted from 0) was taken as the
|
|
684
|
+
* answer to OUR request — `initialize` "answered" by an approval request — and the real answer
|
|
685
|
+
* was then dropped as unknown. The method decides, and our ids are strings besides.
|
|
686
|
+
*/
|
|
687
|
+
function classifyMessage(message) {
|
|
688
|
+
const hasId = message.id !== undefined && message.id !== null;
|
|
689
|
+
if (typeof message.method === "string")
|
|
690
|
+
return hasId ? "request" : "notification";
|
|
691
|
+
return hasId ? "response" : "other";
|
|
692
|
+
}
|
|
693
|
+
/** JSON-RPC "method not found": what a request this client does not handle is answered with. */
|
|
694
|
+
const NOT_HANDLED = -32601;
|
|
695
|
+
/**
|
|
696
|
+
* The answer to a server request when there is nobody to ask — every one a refusal.
|
|
697
|
+
*
|
|
698
|
+
* Shapes from the official 0.160 schema (`codex-rs/app-server-protocol/schema/json`): the v2
|
|
699
|
+
* approvals take `{decision}` with "decline" (the agent carries on without it); permissions are
|
|
700
|
+
* granted as an EMPTY profile; an MCP elicitation is declined; the legacy v1 approvals take
|
|
701
|
+
* `{decision: "denied"}`. Anything else — a request for user input, a dynamic tool call, an auth
|
|
702
|
+
* refresh — is not something this client does, and says so as a JSON-RPC error rather than
|
|
703
|
+
* leaving the turn waiting for an answer that never comes.
|
|
704
|
+
*/
|
|
705
|
+
function refuseServerRequest(method) {
|
|
706
|
+
switch (method) {
|
|
707
|
+
case "item/commandExecution/requestApproval":
|
|
708
|
+
case "item/fileChange/requestApproval":
|
|
709
|
+
return { result: { decision: "decline" } };
|
|
710
|
+
case "item/permissions/requestApproval":
|
|
711
|
+
return { result: { permissions: {}, scope: "turn" } };
|
|
712
|
+
case "mcpServer/elicitation/request":
|
|
713
|
+
return { result: { action: "decline" } };
|
|
714
|
+
case "execCommandApproval":
|
|
715
|
+
case "applyPatchApproval":
|
|
716
|
+
return { result: { decision: "denied" } };
|
|
717
|
+
default:
|
|
718
|
+
return { error: { code: NOT_HANDLED, message: `baychat-relay does not handle ${method}` } };
|
|
719
|
+
}
|
|
720
|
+
}
|
|
244
721
|
/** One line of captured stderr, for appending to a failure reason. */
|
|
245
722
|
function firstLine(text) {
|
|
246
723
|
for (const line of text.split("\n")) {
|
|
@@ -300,13 +777,16 @@ function readCodexRateLimits(binaryPath, deps) {
|
|
|
300
777
|
catch {
|
|
301
778
|
continue;
|
|
302
779
|
}
|
|
303
|
-
|
|
780
|
+
// Only a RESPONSE can answer ours; a server request with a colliding id is not one.
|
|
781
|
+
if (classifyMessage(message) !== "response")
|
|
782
|
+
continue;
|
|
783
|
+
if (message.id === "bc-0") {
|
|
304
784
|
if (message.error)
|
|
305
785
|
return finish(null);
|
|
306
786
|
send({ method: "initialized", params: {} });
|
|
307
|
-
send({ id: 1, method: "account/rateLimits/read", params: {} });
|
|
787
|
+
send({ id: "bc-1", method: "account/rateLimits/read", params: {} });
|
|
308
788
|
}
|
|
309
|
-
else if (message.id === 1) {
|
|
789
|
+
else if (message.id === "bc-1") {
|
|
310
790
|
return finish(message.error ? null : parseCodexRateLimits(message.result));
|
|
311
791
|
}
|
|
312
792
|
}
|
|
@@ -317,7 +797,7 @@ function readCodexRateLimits(binaryPath, deps) {
|
|
|
317
797
|
child.stderr?.on("data", () => { });
|
|
318
798
|
child.on("error", () => finish(null));
|
|
319
799
|
child.on("close", () => finish(null));
|
|
320
|
-
send({ id: 0, method: "initialize", params: { clientInfo: { name: "baychat-relay", title: "BayChat relay", version: CLIENT_VERSION } } });
|
|
800
|
+
send({ id: "bc-0", method: "initialize", params: { clientInfo: { name: "baychat-relay", title: "BayChat relay", version: CLIENT_VERSION } } });
|
|
321
801
|
});
|
|
322
802
|
}
|
|
323
803
|
/**
|