@minhspark/codex-mcp-bridge 1.12.1 → 1.12.2

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.
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import net from "node:net";
3
3
  import path from "node:path";
4
+ import { randomUUID } from "node:crypto";
4
5
 
5
6
  import { IS_MACOS, IS_WINDOWS, PLATFORM_LABEL, homeDir } from "./platform.mjs";
6
7
 
@@ -29,7 +30,7 @@ import { IS_MACOS, IS_WINDOWS, PLATFORM_LABEL, homeDir } from "./platform.mjs";
29
30
  * below are the two places to fix, and `CODEX_NATIVE_RELAY_METHOD` overrides
30
31
  * the name without a release.
31
32
  */
32
- export const NATIVE_DISPATCH_METHOD = "codex_app.send_message_to_thread";
33
+ export const NATIVE_DISPATCH_METHOD = "tools/call";
33
34
 
34
35
  export const RELAY_PROTOCOL_VERSION = 1;
35
36
 
@@ -47,13 +48,6 @@ const WINDOWS_RELAY_SOCKET = "\\\\.\\pipe\\LOCAL\\codex-native-relay";
47
48
  const RELAY_CONFIG_NAME = "native-relay.json";
48
49
 
49
50
  export class NativeRelayError extends Error {
50
- /**
51
- * `reachedCompanion` is what decides whether falling back to the app-server
52
- * path is worth doing. A companion that never answered says nothing about
53
- * the target thread, so the older path deserves its turn; a companion that
54
- * answered with a refusal has already asked Codex, and asking again through
55
- * a second app-server only adds a writer-lock failure on top.
56
- */
57
51
  constructor(message, code, { reachedCompanion = false } = {}) {
58
52
  super(message);
59
53
  this.name = "NativeRelayError";
@@ -135,7 +129,149 @@ export function resolveRelayThreadId(env = process.env) {
135
129
  * middle of a request.
136
130
  */
137
131
  export function nativeDispatchParams({ executorThreadId, targetThreadId, message }) {
138
- return { executorThreadId, threadId: targetThreadId, message };
132
+ return {
133
+ arguments: { threadId: targetThreadId, prompt: message },
134
+ callId: `codex-native-relay-${randomUUID()}`,
135
+ namespace: "codex_app",
136
+ threadId: executorThreadId,
137
+ tool: "send_message_to_thread",
138
+ turnId: `codex-native-relay-turn-${randomUUID()}`,
139
+ };
140
+ }
141
+
142
+ export class NativeToolsClient {
143
+ constructor({ env = process.env, socketPath = env.CODEX_APP_TOOLS_PIPE_PATH, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
144
+ this.env = env;
145
+ this.socketPath = socketPath;
146
+ this.timeoutMs = timeoutMs;
147
+ this.socket = null;
148
+ this.connectingSocket = null;
149
+ this.connecting = null;
150
+ this.pending = new Map();
151
+ this.nextId = 1;
152
+ }
153
+
154
+ async connect() {
155
+ if (this.connecting) return this.connecting;
156
+ if (this.socket && !this.socket.destroyed) return;
157
+ if (!this.socketPath) {
158
+ throw new NativeRelayError("CODEX_APP_TOOLS_PIPE_PATH is missing; launch the companion from Codex Desktop", "NATIVE_PIPE_UNAVAILABLE");
159
+ }
160
+ this.connecting = new Promise((resolve, reject) => {
161
+ const socket = net.connect({ path: this.socketPath });
162
+ this.connectingSocket = socket;
163
+ let buffer = Buffer.alloc(0);
164
+ let connected = false;
165
+ const timer = globalThis.setTimeout(() => {
166
+ reject(new NativeRelayError("Timed out connecting to the Codex Desktop native tools pipe", "NATIVE_PIPE_UNAVAILABLE"));
167
+ socket.destroy();
168
+ }, this.timeoutMs);
169
+ const fail = (err) => {
170
+ globalThis.clearTimeout(timer);
171
+ if (!connected) reject(err);
172
+ if (this.socket === socket) this.socket = null;
173
+ for (const pending of this.pending.values()) {
174
+ if (pending.socket === socket) pending.reject(err);
175
+ }
176
+ socket.destroy();
177
+ };
178
+ socket.on("connect", () => {
179
+ connected = true;
180
+ globalThis.clearTimeout(timer);
181
+ this.socket = socket;
182
+ resolve();
183
+ });
184
+ socket.on("data", (chunk) => {
185
+ buffer = Buffer.concat([buffer, chunk]);
186
+ while (buffer.length >= 4) {
187
+ const length = buffer.readUInt32LE(0);
188
+ if (!length || length > MAX_FRAME_BYTES) {
189
+ fail(new NativeRelayError("Invalid Codex Desktop native frame length", "NATIVE_BAD_RESPONSE"));
190
+ return;
191
+ }
192
+ if (buffer.length < length + 4) return;
193
+ let response;
194
+ try {
195
+ response = JSON.parse(buffer.subarray(4, length + 4).toString("utf8"));
196
+ } catch {
197
+ fail(new NativeRelayError("Malformed Codex Desktop native response", "NATIVE_BAD_RESPONSE"));
198
+ return;
199
+ }
200
+ buffer = buffer.subarray(length + 4);
201
+ if (!response || typeof response !== "object" || response.jsonrpc !== "2.0") {
202
+ fail(new NativeRelayError("Invalid Codex Desktop JSON-RPC response", "NATIVE_BAD_RESPONSE"));
203
+ return;
204
+ }
205
+ const pending = this.pending.get(response.id);
206
+ if (!pending) continue;
207
+ if (response.error) {
208
+ pending.reject(new NativeRelayError(response.error.message ?? "Codex Desktop rejected the native dispatch", "NATIVE_DISPATCH_FAILED"));
209
+ } else if (Object.hasOwn(response, "result")) {
210
+ pending.resolve(response.result);
211
+ } else {
212
+ pending.reject(new NativeRelayError("Codex Desktop native response has no result", "NATIVE_BAD_RESPONSE"));
213
+ }
214
+ }
215
+ });
216
+ socket.on("error", (err) => fail(new NativeRelayError(`Codex Desktop native pipe failed: ${err.message}`, connected ? "NATIVE_DELIVERY_UNCONFIRMED" : "NATIVE_PIPE_UNAVAILABLE")));
217
+ socket.on("close", () => fail(new NativeRelayError("Codex Desktop native tools pipe closed before confirming delivery", connected ? "NATIVE_DELIVERY_UNCONFIRMED" : "NATIVE_PIPE_UNAVAILABLE")));
218
+ });
219
+ try {
220
+ await this.connecting;
221
+ } finally {
222
+ this.connecting = null;
223
+ this.connectingSocket = null;
224
+ }
225
+ }
226
+
227
+ async dispatch(args) {
228
+ const id = this.nextId++;
229
+ const payload = Buffer.from(JSON.stringify({
230
+ jsonrpc: "2.0",
231
+ id,
232
+ method: this.env.CODEX_NATIVE_RELAY_METHOD ?? NATIVE_DISPATCH_METHOD,
233
+ params: nativeDispatchParams(args),
234
+ }));
235
+ if (payload.length > MAX_FRAME_BYTES) {
236
+ throw new NativeRelayError("Native dispatch exceeds the frame limit", "RELAY_MESSAGE_TOO_LARGE");
237
+ }
238
+ await this.connect();
239
+ const header = Buffer.alloc(4);
240
+ header.writeUInt32LE(payload.length);
241
+ return new Promise((resolve, reject) => {
242
+ const finish = (fn, value) => {
243
+ if (!this.pending.delete(id)) return;
244
+ globalThis.clearTimeout(timer);
245
+ fn(value);
246
+ };
247
+ const timer = globalThis.setTimeout(() => {
248
+ finish(reject, new NativeRelayError("Codex Desktop native dispatch timed out; delivery may have occurred", "NATIVE_DELIVERY_UNCONFIRMED"));
249
+ }, this.timeoutMs);
250
+ this.pending.set(id, {
251
+ socket: this.socket,
252
+ resolve: (value) => finish(resolve, value),
253
+ reject: (err) => finish(reject, err),
254
+ });
255
+ try {
256
+ if (!this.socket || this.socket.destroyed) throw new Error("native tools pipe is closed");
257
+ this.socket.write(Buffer.concat([header, payload]), (err) => {
258
+ if (err) finish(reject, new NativeRelayError(`Native dispatch write failed: ${err.message}`, "NATIVE_DELIVERY_UNCONFIRMED"));
259
+ });
260
+ } catch (err) {
261
+ finish(reject, new NativeRelayError(`Native dispatch write failed: ${err.message}`, "NATIVE_DELIVERY_UNCONFIRMED"));
262
+ }
263
+ });
264
+ }
265
+
266
+ close() {
267
+ const socket = this.socket;
268
+ this.socket = null;
269
+ for (const pending of this.pending.values()) {
270
+ pending.reject(new NativeRelayError("Native tools client closed before confirming delivery", "NATIVE_DELIVERY_UNCONFIRMED"));
271
+ }
272
+ socket?.destroy();
273
+ this.connectingSocket?.destroy();
274
+ }
139
275
  }
140
276
 
141
277
  /**
@@ -211,7 +347,7 @@ export class NativeDesktopRelay {
211
347
  }
212
348
 
213
349
  const response = await this.#roundTrip(line, timeoutMs);
214
- if (response?.ok) return response;
350
+ if (response?.ok === true && response.v === RELAY_PROTOCOL_VERSION) return response;
215
351
  throw new NativeRelayError(
216
352
  response?.error?.message ?? "the Codex Desktop relay refused the message",
217
353
  response?.error?.code ?? "NATIVE_DISPATCH_FAILED",
@@ -222,8 +358,9 @@ export class NativeDesktopRelay {
222
358
  #roundTrip(line, timeoutMs) {
223
359
  const socketPath = this.socketPath;
224
360
  return new Promise((resolve, reject) => {
225
- let buffer = "";
361
+ let buffer = Buffer.alloc(0);
226
362
  let settled = false;
363
+ let dispatched = false;
227
364
  const socket = net.connect({ path: socketPath });
228
365
 
229
366
  const finish = (fn, value) => {
@@ -241,16 +378,19 @@ export class NativeDesktopRelay {
241
378
  new NativeRelayError(
242
379
  `The Codex Desktop relay did not answer within ${timeoutMs}ms`,
243
380
  "RELAY_TIMEOUT",
244
- { reachedCompanion: true },
381
+ { reachedCompanion: dispatched },
245
382
  ),
246
383
  ),
247
384
  timeoutMs,
248
385
  );
249
386
 
250
- socket.on("connect", () => socket.write(line));
387
+ socket.on("connect", () => {
388
+ dispatched = true;
389
+ socket.write(line);
390
+ });
251
391
  socket.on("data", (chunk) => {
252
- buffer += chunk.toString("utf8");
253
- if (Buffer.byteLength(buffer, "utf8") > MAX_FRAME_BYTES) {
392
+ buffer = Buffer.concat([buffer, chunk]);
393
+ if (buffer.length > MAX_FRAME_BYTES) {
254
394
  finish(
255
395
  reject,
256
396
  new NativeRelayError("The Codex Desktop relay answered with an oversized frame", "RELAY_BAD_RESPONSE", {
@@ -259,10 +399,10 @@ export class NativeDesktopRelay {
259
399
  );
260
400
  return;
261
401
  }
262
- const index = buffer.indexOf("\n");
402
+ const index = buffer.indexOf(10);
263
403
  if (index < 0) return;
264
404
  try {
265
- finish(resolve, JSON.parse(buffer.slice(0, index)));
405
+ finish(resolve, JSON.parse(buffer.subarray(0, index).toString("utf8")));
266
406
  } catch (err) {
267
407
  finish(
268
408
  reject,
@@ -277,7 +417,7 @@ export class NativeDesktopRelay {
277
417
  socket.on("error", (err) =>
278
418
  finish(
279
419
  reject,
280
- new NativeRelayError(`Cannot reach the Codex Desktop relay at ${socketPath}: ${err.message}`, "RELAY_UNREACHABLE"),
420
+ new NativeRelayError(`Codex Desktop relay connection failed at ${socketPath}: ${err.message}`, dispatched ? "RELAY_DELIVERY_UNCONFIRMED" : "RELAY_UNREACHABLE", { reachedCompanion: dispatched }),
281
421
  ),
282
422
  );
283
423
  socket.on("close", () =>
@@ -285,7 +425,8 @@ export class NativeDesktopRelay {
285
425
  reject,
286
426
  new NativeRelayError(
287
427
  `The Codex Desktop relay at ${socketPath} closed before answering`,
288
- "RELAY_UNREACHABLE",
428
+ dispatched ? "RELAY_DELIVERY_UNCONFIRMED" : "RELAY_UNREACHABLE",
429
+ { reachedCompanion: dispatched },
289
430
  ),
290
431
  ),
291
432
  );
@@ -293,13 +434,6 @@ export class NativeDesktopRelay {
293
434
  }
294
435
  }
295
436
 
296
- /**
297
- * Creates the dedicated executor thread once and remembers it, using the
298
- * ordinary app-server path - which is allowed to take a writer lock here
299
- * precisely because this thread belongs to nobody else. The caller stops the
300
- * app-server afterwards, so the lock is released and Codex Desktop is left
301
- * with the state to itself.
302
- */
303
437
  export async function bootstrapRelayThread(client, { cwd = homeDir(), env = process.env, name = "Native Relay" } = {}) {
304
438
  const res = await client.call("thread/start", {
305
439
  cwd,
@@ -309,14 +443,16 @@ export async function bootstrapRelayThread(client, { cwd = homeDir(), env = proc
309
443
  const threadId = res?.thread?.id;
310
444
  if (!threadId) throw new NativeRelayError("Codex app-server created no relay thread id", "RELAY_BOOTSTRAP_FAILED");
311
445
 
446
+ let release;
312
447
  try {
313
- await client.call("thread/name/set", { threadId, name });
314
- } catch {
315
- // A thread without a title still works as an executor context.
448
+ try {
449
+ await client.call("thread/name/set", { threadId, name });
450
+ } catch {}
451
+ writeRelayConfig({ relayThreadId: threadId, createdAt: new Date().toISOString() }, env);
452
+ } finally {
453
+ release = await client.releaseThread(threadId);
316
454
  }
317
-
318
- writeRelayConfig({ relayThreadId: threadId, createdAt: new Date().toISOString() }, env);
319
- return { threadId, configPath: relayConfigPath(env) };
455
+ return { threadId, configPath: relayConfigPath(env), release };
320
456
  }
321
457
 
322
458
  export function writeRelayConfig(config, env = process.env) {
@@ -212,6 +212,9 @@ export class PeerEndpoint {
212
212
  this.server = null;
213
213
  this.inbox = [];
214
214
  this.listeners = new Set();
215
+ this.messageSequence = 0;
216
+ this.requestQueues = new Map();
217
+ this.unconfirmedReplies = new Map();
215
218
  this.started = false;
216
219
  }
217
220
 
@@ -292,8 +295,9 @@ export class PeerEndpoint {
292
295
 
293
296
  #handleConnection(socket) {
294
297
  let buffer = "";
298
+ socket.setEncoding("utf8");
295
299
  socket.on("data", (chunk) => {
296
- buffer += chunk.toString("utf8");
300
+ buffer += chunk;
297
301
  let index;
298
302
  while ((index = buffer.indexOf("\n")) >= 0) {
299
303
  const line = buffer.slice(0, index).trim();
@@ -306,8 +310,9 @@ export class PeerEndpoint {
306
310
  this.log(`ignored malformed peer frame (${line.slice(0, 80)})`);
307
311
  continue;
308
312
  }
309
- const record = { ...message, receivedAt: Date.now() };
313
+ const record = { ...message, receivedAt: Date.now(), sequence: ++this.messageSequence };
310
314
  this.inbox.push(record);
315
+ this.#removePendingReply(record.fromSocket);
311
316
  this.log(`inbox <- ${record.fromSocket ?? "?"}: ${record.text.slice(0, 120)}`);
312
317
  for (const listener of [...this.listeners]) {
313
318
  try {
@@ -394,12 +399,55 @@ export class PeerEndpoint {
394
399
  return frame.msg_id;
395
400
  }
396
401
 
402
+ async sendAndWait(targetSocket, text, { timeoutMs = 120000, priority = "next" } = {}) {
403
+ const previous = this.requestQueues.get(targetSocket) ?? Promise.resolve();
404
+ const pending = previous.catch(() => {}).then(async () => {
405
+ const unconfirmed = this.unconfirmedReplies.get(targetSocket) ?? 0;
406
+ if (timeoutMs > 0 && unconfirmed > 0) {
407
+ const error = new Error(
408
+ `${unconfirmed} earlier message(s) to ${targetSocket} still await a reply; this message was not sent. `
409
+ + "Wait for Claude's outstanding replies and check read_claude_inbox, or set waitSec to 0 to send without matching a reply.",
410
+ );
411
+ error.code = "PEER_REPLY_PENDING";
412
+ throw error;
413
+ }
414
+ const since = Date.now();
415
+ const afterSequence = this.messageSequence;
416
+ this.unconfirmedReplies.set(targetSocket, unconfirmed + 1);
417
+ let msgId;
418
+ try {
419
+ msgId = await this.send(targetSocket, text, { priority });
420
+ } catch (err) {
421
+ this.#removePendingReply(targetSocket);
422
+ throw err;
423
+ }
424
+ const reply = timeoutMs > 0
425
+ ? await this.waitForReply(targetSocket, { timeoutMs, since, afterSequence })
426
+ : null;
427
+ return { msgId, reply };
428
+ });
429
+ this.requestQueues.set(targetSocket, pending);
430
+ try {
431
+ return await pending;
432
+ } finally {
433
+ if (this.requestQueues.get(targetSocket) === pending) this.requestQueues.delete(targetSocket);
434
+ }
435
+ }
436
+
437
+ #removePendingReply(fromSocket) {
438
+ const pending = this.unconfirmedReplies.get(fromSocket) ?? 0;
439
+ if (pending > 1) this.unconfirmedReplies.set(fromSocket, pending - 1);
440
+ else this.unconfirmedReplies.delete(fromSocket);
441
+ }
442
+
397
443
  /**
398
444
  * Claude answers with a fresh msg_id rather than an in-reply-to field, so a
399
445
  * reply is matched by origin socket and arrival time.
400
446
  */
401
- waitForReply(fromSocket, { timeoutMs = 120000, since = Date.now() } = {}) {
402
- const existing = this.inbox.find((m) => m.fromSocket === fromSocket && m.receivedAt >= since);
447
+ waitForReply(fromSocket, { timeoutMs = 120000, since = Date.now(), afterSequence = null } = {}) {
448
+ const matches = (record) => record.fromSocket === fromSocket
449
+ && (afterSequence === null ? record.receivedAt >= since : record.sequence > afterSequence);
450
+ const existing = this.inbox.find(matches);
403
451
  if (existing) return Promise.resolve(existing);
404
452
  return new Promise((resolve) => {
405
453
  const timer = globalThis.setTimeout(() => {
@@ -407,7 +455,7 @@ export class PeerEndpoint {
407
455
  resolve(null);
408
456
  }, timeoutMs);
409
457
  const unsubscribe = this.onMessage((record) => {
410
- if (record.fromSocket !== fromSocket) return;
458
+ if (!matches(record)) return;
411
459
  globalThis.clearTimeout(timer);
412
460
  unsubscribe();
413
461
  resolve(record);
package/src/platform.mjs CHANGED
@@ -188,6 +188,7 @@ export function resolveCodexBin(explicit) {
188
188
  */
189
189
  export function isWritableDir(target) {
190
190
  try {
191
+ if (!statSync(target).isDirectory()) return false;
191
192
  accessSync(target, constants.W_OK);
192
193
  return true;
193
194
  } catch {
@@ -293,7 +294,7 @@ export function resolveWorkspacePath(input) {
293
294
  const writable = ordered.find((candidate) => existsSync(candidate) && isWritableDir(candidate));
294
295
  if (writable) {
295
296
  return {
296
- path: writable,
297
+ path: path.resolve(writable),
297
298
  remapped: writable !== original,
298
299
  writable: true,
299
300
  note:
@@ -305,10 +306,16 @@ export function resolveWorkspacePath(input) {
305
306
  };
306
307
  }
307
308
 
308
- const existing = ordered.find((candidate) => existsSync(candidate));
309
+ const existing = ordered.find((candidate) => {
310
+ try {
311
+ return statSync(candidate).isDirectory();
312
+ } catch {
313
+ return false;
314
+ }
315
+ });
309
316
  if (existing) {
310
317
  return {
311
- path: existing,
318
+ path: path.resolve(existing),
312
319
  remapped: existing !== original,
313
320
  writable: false,
314
321
  note: `cwd ${existing} exists but is not writable on ${PLATFORM_LABEL}; Codex will fail on any file edit.`,
@@ -17,7 +17,7 @@ import { runTurn } from "./turn.mjs";
17
17
  */
18
18
  export const NATIVE_BACKEND = "codex-desktop-native";
19
19
  export const APP_SERVER_BACKEND = "app-server";
20
- const RELEASE_STATUSES = new Set(["completed", "interrupted", "failed", "disconnected"]);
20
+ const RELEASE_STATUSES = new Set(["completed", "interrupted", "failed"]);
21
21
 
22
22
  export function createThreadDelivery({
23
23
  codex,
@@ -47,7 +47,7 @@ export function createThreadDelivery({
47
47
  reportedUnavailable = null;
48
48
  return { backend: NATIVE_BACKEND, threadId, ack };
49
49
  } catch (err) {
50
- if (err.reachedCompanion) throw err;
50
+ if (err.reachedCompanion || err.code !== "RELAY_UNREACHABLE") throw err;
51
51
  log(`native relay unreachable (${err.message}); falling back to the app-server path`);
52
52
  }
53
53
  } else if (status.reason !== reportedUnavailable) {
@@ -56,22 +56,24 @@ export function createThreadDelivery({
56
56
  }
57
57
 
58
58
  if (!codex) throw new Error("No Codex app-server client is configured to deliver this message");
59
- await codex.ensureThreadAttached(threadId);
60
- const turn = await runTurn(codex, {
61
- threadId,
62
- input: [{ type: "text", text }],
63
- timeoutMs,
64
- });
65
- if (releaseAfterTurn && RELEASE_STATUSES.has(turn.status) && typeof codex.stopServer === "function") {
66
- try {
67
- const released = await codex.stopServer();
68
- if (released?.stillListening) log("app-server release requested but it is still listening");
69
- if (released?.stopped === false) log("app-server release skipped: " + (released.reason ?? "unknown reason"));
70
- } catch (err) {
71
- log("app-server release failed: " + err.message);
59
+ const send = async () => {
60
+ await codex.ensureThreadAttached(threadId);
61
+ const turn = await runTurn(codex, {
62
+ threadId,
63
+ input: [{ type: "text", text }],
64
+ timeoutMs,
65
+ });
66
+ if (releaseAfterTurn && RELEASE_STATUSES.has(turn.status) && typeof codex.releaseThread === "function") {
67
+ try {
68
+ const released = await codex.releaseThread(threadId);
69
+ if (!released?.released) log("thread release pending: " + (released.reason ?? released.status ?? "awaiting unload"));
70
+ } catch (err) {
71
+ log("thread release failed: " + err.message);
72
+ }
72
73
  }
73
- }
74
- return { backend: APP_SERVER_BACKEND, threadId, turn };
74
+ return { backend: APP_SERVER_BACKEND, threadId, turn };
75
+ };
76
+ return codex.withThread ? codex.withThread(threadId, send) : send();
75
77
  }
76
78
 
77
79
  function describe() {
package/src/turn.mjs CHANGED
@@ -54,10 +54,16 @@ export async function runTurn(client, { threadId, input, timeoutMs = 240000, tur
54
54
  });
55
55
 
56
56
  const process = (msg) => {
57
+ if (settled) return;
57
58
  const params = msg.params ?? {};
58
59
  if (turnId && params.turnId && params.turnId !== turnId) return;
59
60
 
60
61
  switch (msg.method) {
62
+ case "thread/closed": {
63
+ settled = true;
64
+ resolveDone({ status: "disconnected", error: { message: "The thread closed before its turn completed" } });
65
+ return;
66
+ }
61
67
  case "item/completed": {
62
68
  const summary = summarizeItem(params.item);
63
69
  if (!summary) return;
@@ -90,7 +96,7 @@ export async function runTurn(client, { threadId, input, timeoutMs = 240000, tur
90
96
  };
91
97
 
92
98
  const unsubscribe = client.subscribe(threadId, (msg) => {
93
- if (!turnId) {
99
+ if (!turnId && msg.method !== "thread/closed") {
94
100
  buffered.push(msg);
95
101
  return;
96
102
  }
@@ -113,15 +119,27 @@ export async function runTurn(client, { threadId, input, timeoutMs = 240000, tur
113
119
  });
114
120
 
115
121
  try {
116
- const started = await client.request(
117
- "turn/start",
118
- { threadId, input, ...turnOverrides },
119
- { timeoutMs: Math.min(timeoutMs, 60000) },
120
- );
121
- turnId = started?.turn?.id ?? null;
122
- for (const msg of buffered.splice(0)) process(msg);
123
-
124
- const outcome = await done;
122
+ const start = await Promise.race([
123
+ client.request(
124
+ "turn/start",
125
+ { ...turnOverrides, threadId, input },
126
+ { timeoutMs: Math.min(timeoutMs, 60000) },
127
+ ).then((started) => ({ started }), (error) => ({ error })),
128
+ done.then((outcome) => ({ outcome })),
129
+ ]);
130
+ if (start.error) throw start.error;
131
+ let outcome = start.outcome;
132
+ if (!outcome) {
133
+ turnId = start.started?.turn?.id ?? null;
134
+ if (typeof turnId !== "string" || !turnId.trim()) {
135
+ throw new Error("Codex app-server did not return a turn id for turn/start");
136
+ }
137
+ for (const msg of buffered.splice(0)) process(msg);
138
+ if (TERMINAL_STATUSES.has(start.started?.turn?.status)) {
139
+ process({ method: "turn/completed", params: { turn: start.started.turn } });
140
+ }
141
+ outcome = await done;
142
+ }
125
143
  return {
126
144
  threadId,
127
145
  turnId,