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.
Files changed (53) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +13 -2
  3. package/dist/api.js +30 -0
  4. package/dist/bays-command.js +6 -0
  5. package/dist/channel/feed.js +48 -4
  6. package/dist/channel/log-file.js +37 -0
  7. package/dist/channel/server.js +34 -4
  8. package/dist/claude-context-flush.js +31 -8
  9. package/dist/claude-context.js +19 -5
  10. package/dist/claude-onboarding.js +21 -8
  11. package/dist/cli-help.js +3 -0
  12. package/dist/client-paths.js +6 -1
  13. package/dist/client-scope.js +34 -0
  14. package/dist/commands.js +16 -8
  15. package/dist/connect-link.js +205 -0
  16. package/dist/connect-plan.js +2 -2
  17. package/dist/connect.js +8 -0
  18. package/dist/device-login.js +19 -0
  19. package/dist/launch/command.js +6 -3
  20. package/dist/main.js +18 -2
  21. package/dist/mcp-files.js +2 -0
  22. package/dist/mcp-register.js +2 -0
  23. package/dist/protocol-content.js +1 -1
  24. package/dist/refresh-clients.js +7 -0
  25. package/dist/relay/acp/approval.js +99 -30
  26. package/dist/relay/acp/daemon-glue.js +2 -1
  27. package/dist/relay/adapters.js +5 -1
  28. package/dist/relay/bay-relays.js +18 -5
  29. package/dist/relay/codex-allowances.js +113 -0
  30. package/dist/relay/codex-app-server.js +637 -157
  31. package/dist/relay/codex-approvals.js +390 -0
  32. package/dist/relay/codex-commands.js +61 -0
  33. package/dist/relay/codex-log.js +260 -0
  34. package/dist/relay/codex-runner.js +505 -0
  35. package/dist/relay/codex-version.js +50 -0
  36. package/dist/relay/daemon.js +190 -6
  37. package/dist/relay/fresh-start.js +2 -1
  38. package/dist/relay/launcher/capabilities.js +24 -9
  39. package/dist/relay/launcher/codex-launch.js +9 -2
  40. package/dist/relay/launcher/codex-relay-launch.js +80 -0
  41. package/dist/relay/launcher/handle.js +101 -30
  42. package/dist/relay/launcher/loop.js +5 -1
  43. package/dist/relay/launcher/stop-registered.js +124 -9
  44. package/dist/relay/registry.js +30 -0
  45. package/dist/relay/runtime-limits.js +167 -0
  46. package/dist/relay/spawn-env.js +40 -3
  47. package/dist/relay/terminal-pane.js +1 -0
  48. package/dist/sea-names.js +34 -0
  49. package/dist/session-command.js +3 -1
  50. package/dist/session-name.js +25 -2
  51. package/dist/skill-bootstrap.js +2 -0
  52. package/dist/tool-defs.js +30 -0
  53. 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
- // `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.
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
- * Run one turn against an existing Codex thread and resolve with its outcome.
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 nextId = 0;
79
- const pending = new Map();
80
- let stdoutBuffer = "";
81
- let stderrTail = "";
82
- /** Resolve once, and always leave no process behind. */
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(timer);
88
- child.kill("SIGTERM");
89
- // A child that ignores the polite signal would otherwise outlive the relay
90
- // and keep answering a room nobody is watching.
91
- setTimeout(() => child.kill("SIGKILL"), 5_000).unref();
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
- let timer = setTimeout(() => finish({
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
- const request = (method, params) => new Promise((res) => {
100
- const id = nextId++;
101
- pending.set(id, res);
102
- child.stdin?.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`);
103
- });
104
- const notify = (method, params) => {
105
- child.stdin?.write(`${JSON.stringify({ jsonrpc: "2.0", method, params })}\n`);
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
- child.stdout?.on("data", (chunk) => {
108
- stdoutBuffer += chunk.toString();
109
- // Newline-delimited JSON. A partial line is kept for the next chunk;
110
- // parsing one would be the classic framing bug.
111
- let newline = stdoutBuffer.indexOf("\n");
112
- while (newline >= 0) {
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
- if (message.method === "turn/completed")
137
- finish({ kind: "completed" });
432
+ onTurnCompleted(params);
138
433
  }
139
- });
140
- child.stderr?.on("data", (chunk) => {
141
- // Bounded, and kept only to explain a failure. Codex logs freely here even
142
- // on a healthy run, so it is never treated as a failure signal by itself.
143
- const text = chunk.toString();
144
- stderrTail = `${stderrTail}${text}`.slice(-2_000);
145
- });
146
- child.on("error", (err) => {
147
- finish({
148
- kind: "failed",
149
- transportUnusable: true,
150
- reason: `could not start codex app-server: ${err.message}`,
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
- child.on("close", (code) => {
154
- // Only meaningful if we have not already completed: an expected exit
155
- // follows our own SIGTERM.
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
- transportUnusable: true,
161
- reason: `codex app-server exited ${code ?? "on a signal"} before the turn completed${firstLine(stderrTail)}`,
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 initialized = await request("initialize", {
166
- clientInfo: {
167
- name: "baychat-relay",
168
- title: "BayChat relay",
169
- version: CLIENT_VERSION,
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
- notify("initialized", {});
181
- const resumed = await request("thread/resume", {
182
- threadId: req.threadId,
183
- cwd: req.cwd,
184
- // See the file header: no human is present, so nothing may be approved.
185
- approvalPolicy: "never",
186
- });
187
- if (resumed.error) {
188
- // "already has an active writer" is not a fault — it is the interactive
189
- // session being OPEN. A thread being written by a live Codex TUI cannot
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
- // The distinct failure `exec resume` could never report: the id does not
208
- // name a thread on this machine. Worth saying plainly, because the usual
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(timer);
220
- timer = setTimeout(() => finish({
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
- }), turnTimeout);
225
- const started = await request("turn/start", {
226
- threadId: req.threadId,
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
- if (message.id === 0) {
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
  /**