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/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
- return i < 0 ? args : [...args.slice(0, i), ...args.slice(i + 2)];
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": {
@@ -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
+ }