@minhspark/codex-mcp-bridge 1.12.1 → 1.12.3

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,9 @@
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";
5
+ import { execFile } from "node:child_process";
6
+ import { promisify } from "node:util";
4
7
 
5
8
  import { IS_MACOS, IS_WINDOWS, PLATFORM_LABEL, homeDir } from "./platform.mjs";
6
9
 
@@ -29,7 +32,7 @@ import { IS_MACOS, IS_WINDOWS, PLATFORM_LABEL, homeDir } from "./platform.mjs";
29
32
  * below are the two places to fix, and `CODEX_NATIVE_RELAY_METHOD` overrides
30
33
  * the name without a release.
31
34
  */
32
- export const NATIVE_DISPATCH_METHOD = "codex_app.send_message_to_thread";
35
+ export const NATIVE_DISPATCH_METHOD = "tools/call";
33
36
 
34
37
  export const RELAY_PROTOCOL_VERSION = 1;
35
38
 
@@ -47,13 +50,6 @@ const WINDOWS_RELAY_SOCKET = "\\\\.\\pipe\\LOCAL\\codex-native-relay";
47
50
  const RELAY_CONFIG_NAME = "native-relay.json";
48
51
 
49
52
  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
53
  constructor(message, code, { reachedCompanion = false } = {}) {
58
54
  super(message);
59
55
  this.name = "NativeRelayError";
@@ -135,7 +131,312 @@ export function resolveRelayThreadId(env = process.env) {
135
131
  * middle of a request.
136
132
  */
137
133
  export function nativeDispatchParams({ executorThreadId, targetThreadId, message }) {
138
- return { executorThreadId, threadId: targetThreadId, message };
134
+ return {
135
+ arguments: { threadId: targetThreadId, prompt: message },
136
+ callId: `codex-native-relay-${randomUUID()}`,
137
+ namespace: "codex_app",
138
+ threadId: executorThreadId,
139
+ tool: "send_message_to_thread",
140
+ turnId: `codex-native-relay-turn-${randomUUID()}`,
141
+ };
142
+ }
143
+
144
+ const execFileAsync = promisify(execFile);
145
+
146
+ function splitDesktopCommandLine(commandLine, platform) {
147
+ const args = [];
148
+ let value = "";
149
+ let quote = null;
150
+ let depth = 0;
151
+ for (let index = 0; index < commandLine.length; index++) {
152
+ const char = commandLine[index];
153
+ if (platform === "win32" && char === "\\") {
154
+ let end = index;
155
+ while (commandLine[end] === "\\") end++;
156
+ const count = end - index;
157
+ if (commandLine[end] === '"') {
158
+ value += "\\".repeat(Math.floor(count / 2));
159
+ if (count % 2) value += '"';
160
+ else quote = quote ? null : '"';
161
+ index = end;
162
+ } else {
163
+ value += "\\".repeat(count);
164
+ index = end - 1;
165
+ }
166
+ continue;
167
+ }
168
+ if (platform !== "win32" && depth > 0) {
169
+ value += char;
170
+ if (quote) {
171
+ if (char === "\\" && quote === '"') value += commandLine[++index] ?? "";
172
+ else if (char === quote) quote = null;
173
+ } else if (char === '"' || char === "'") quote = char;
174
+ else if (char === "{" || char === "[") depth++;
175
+ else if (char === "}" || char === "]") depth--;
176
+ continue;
177
+ }
178
+ if (char === '"' || (platform !== "win32" && char === "'")) {
179
+ if (!quote) quote = char;
180
+ else if (quote === char) quote = null;
181
+ else value += char;
182
+ } else if (!quote && /\s/.test(char)) {
183
+ if (value) args.push(value);
184
+ value = "";
185
+ } else {
186
+ value += char;
187
+ if (platform !== "win32" && !quote && char === "{") depth++;
188
+ }
189
+ }
190
+ if (quote || depth) return [];
191
+ if (value) args.push(value);
192
+ return args;
193
+ }
194
+
195
+ function inlineTableValues(table) {
196
+ const text = table.trim();
197
+ if (!text.startsWith("{") || !text.endsWith("}")) return null;
198
+ const entries = [];
199
+ let start = 1;
200
+ let quote = null;
201
+ let depth = 0;
202
+ for (let index = 1; index < text.length - 1; index++) {
203
+ const char = text[index];
204
+ if (quote) {
205
+ if (char === "\\" && quote === '"') index++;
206
+ else if (char === quote) quote = null;
207
+ } else if (char === '"' || char === "'") quote = char;
208
+ else if (char === "{" || char === "[") depth++;
209
+ else if (char === "}" || char === "]") {
210
+ if (--depth < 0) return null;
211
+ } else if (char === "," && depth === 0) {
212
+ entries.push(text.slice(start, index));
213
+ start = index + 1;
214
+ }
215
+ }
216
+ if (quote || depth) return null;
217
+ entries.push(text.slice(start, -1));
218
+ const values = new Map();
219
+ for (const entry of entries) {
220
+ if (!entry.trim()) continue;
221
+ const match = entry.match(/^\s*(?:"([A-Za-z_][A-Za-z0-9_-]*)"|'([A-Za-z_][A-Za-z0-9_-]*)'|([A-Za-z_][A-Za-z0-9_-]*))\s*=\s*([\s\S]+)$/);
222
+ if (!match) return null;
223
+ const key = match[1] ?? match[2] ?? match[3];
224
+ if (values.has(key)) return null;
225
+ values.set(key, match[4].trim());
226
+ }
227
+ return values;
228
+ }
229
+
230
+ export function nativeToolsPipeFromCommandLine(commandLine, { platform = process.platform } = {}) {
231
+ if (typeof commandLine !== "string" || /[\r\n\0]/.test(commandLine)) return null;
232
+ const args = splitDesktopCommandLine(commandLine, platform);
233
+ const paths = platform === "win32" ? path.win32 : path.posix;
234
+ if (!/^codex(?:\.exe)?$/i.test(paths.basename(args[0] ?? ""))) return null;
235
+ const overrides = [];
236
+ let appServer = false;
237
+ for (let index = 1; index < args.length; index++) {
238
+ const arg = args[index];
239
+ if (arg === "-c" || arg === "--config") {
240
+ overrides.push(args[++index] ?? "");
241
+ } else if (arg.startsWith("--config=")) overrides.push(arg.slice(9));
242
+ else if (arg === "app-server") appServer = true;
243
+ else if (!arg.startsWith("-") && !appServer) return null;
244
+ }
245
+ if (!appServer) return null;
246
+ const candidates = [];
247
+ for (const override of overrides) {
248
+ const match = override.match(/^mcp_servers\.codex_app\s*=\s*([\s\S]+)$/);
249
+ if (!match) continue;
250
+ const config = inlineTableValues(match[1]);
251
+ if (!config) return null;
252
+ const env = inlineTableValues(config.get("env") ?? "{}");
253
+ if (!env) return null;
254
+ const raw = env.get("CODEX_APP_TOOLS_PIPE_PATH");
255
+ if (raw === undefined) continue;
256
+ let candidate;
257
+ try {
258
+ candidate = raw.startsWith('"') ? JSON.parse(raw) : /^'[^']*'$/.test(raw) ? raw.slice(1, -1) : null;
259
+ } catch {
260
+ return null;
261
+ }
262
+ if (typeof candidate !== "string" || /[\r\n\0]/.test(candidate)) return null;
263
+ if (platform === "win32" ? !/^\\\\\.\\pipe\\[^\\]/i.test(candidate) : !path.posix.isAbsolute(candidate)) return null;
264
+ candidates.push(candidate);
265
+ }
266
+ return candidates.length && new Set(candidates).size === 1 ? candidates[0] : null;
267
+ }
268
+
269
+ async function readParentCommandLine(parentPid, platform) {
270
+ if (!Number.isSafeInteger(parentPid) || parentPid <= 0) return null;
271
+ const options = { timeout: 5000, maxBuffer: 128 * 1024, windowsHide: true };
272
+ if (platform === "win32") {
273
+ const powershell = path.win32.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
274
+ const script = `$p=Get-CimInstance Win32_Process -Filter 'ProcessId=${parentPid}'; if ($p.Name -eq 'codex.exe') { $p.CommandLine | ConvertTo-Json -Compress }`;
275
+ const { stdout } = await execFileAsync(powershell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script], options);
276
+ return stdout.trim() ? JSON.parse(stdout.trim()) : null;
277
+ }
278
+ const { stdout } = await execFileAsync("/bin/ps", ["-ww", "-p", String(parentPid), "-o", "args="], options);
279
+ return stdout.trim();
280
+ }
281
+
282
+ export async function resolveNativeToolsPipePath({
283
+ env = process.env,
284
+ parentPid = process.ppid,
285
+ platform = process.platform,
286
+ readParentCommandLine: readParent = readParentCommandLine,
287
+ } = {}) {
288
+ if (env.CODEX_APP_TOOLS_PIPE_PATH) return env.CODEX_APP_TOOLS_PIPE_PATH;
289
+ try {
290
+ return nativeToolsPipeFromCommandLine(await readParent(parentPid, platform), { platform });
291
+ } catch {
292
+ return null;
293
+ }
294
+ }
295
+
296
+ export class NativeToolsClient {
297
+ constructor({ env = process.env, socketPath = env.CODEX_APP_TOOLS_PIPE_PATH, timeoutMs = DEFAULT_TIMEOUT_MS, resolveSocketPath = () => resolveNativeToolsPipePath({ env }) } = {}) {
298
+ this.env = env;
299
+ this.socketPath = socketPath;
300
+ this.timeoutMs = timeoutMs;
301
+ this.resolveSocketPath = resolveSocketPath;
302
+ this.socket = null;
303
+ this.connectingSocket = null;
304
+ this.connecting = null;
305
+ this.connectionGeneration = 0;
306
+ this.pending = new Map();
307
+ this.nextId = 1;
308
+ }
309
+
310
+ async connect() {
311
+ if (this.connecting) return this.connecting;
312
+ if (this.socket && !this.socket.destroyed) return;
313
+ const generation = this.connectionGeneration;
314
+ this.connecting = (async () => {
315
+ const socketPath = this.socketPath || await this.resolveSocketPath();
316
+ if (generation !== this.connectionGeneration) {
317
+ throw new NativeRelayError("Native tools client closed while discovering the Desktop pipe", "NATIVE_PIPE_UNAVAILABLE");
318
+ }
319
+ this.socketPath = socketPath;
320
+ if (!this.socketPath) {
321
+ throw new NativeRelayError("CODEX_APP_TOOLS_PIPE_PATH is missing from the environment and parent Desktop app-server configuration; launch the companion from Codex Desktop", "NATIVE_PIPE_UNAVAILABLE");
322
+ }
323
+ return new Promise((resolve, reject) => {
324
+ const socket = net.connect({ path: this.socketPath });
325
+ this.connectingSocket = socket;
326
+ let buffer = Buffer.alloc(0);
327
+ let connected = false;
328
+ const timer = globalThis.setTimeout(() => {
329
+ reject(new NativeRelayError("Timed out connecting to the Codex Desktop native tools pipe", "NATIVE_PIPE_UNAVAILABLE"));
330
+ socket.destroy();
331
+ }, this.timeoutMs);
332
+ const fail = (err) => {
333
+ globalThis.clearTimeout(timer);
334
+ if (!connected) reject(err);
335
+ if (this.socket === socket) this.socket = null;
336
+ for (const pending of this.pending.values()) {
337
+ if (pending.socket === socket) pending.reject(err);
338
+ }
339
+ socket.destroy();
340
+ };
341
+ socket.on("connect", () => {
342
+ connected = true;
343
+ globalThis.clearTimeout(timer);
344
+ this.socket = socket;
345
+ resolve();
346
+ });
347
+ socket.on("data", (chunk) => {
348
+ buffer = Buffer.concat([buffer, chunk]);
349
+ while (buffer.length >= 4) {
350
+ const length = buffer.readUInt32LE(0);
351
+ if (!length || length > MAX_FRAME_BYTES) {
352
+ fail(new NativeRelayError("Invalid Codex Desktop native frame length", "NATIVE_BAD_RESPONSE"));
353
+ return;
354
+ }
355
+ if (buffer.length < length + 4) return;
356
+ let response;
357
+ try {
358
+ response = JSON.parse(buffer.subarray(4, length + 4).toString("utf8"));
359
+ } catch {
360
+ fail(new NativeRelayError("Malformed Codex Desktop native response", "NATIVE_BAD_RESPONSE"));
361
+ return;
362
+ }
363
+ buffer = buffer.subarray(length + 4);
364
+ if (!response || typeof response !== "object" || response.jsonrpc !== "2.0") {
365
+ fail(new NativeRelayError("Invalid Codex Desktop JSON-RPC response", "NATIVE_BAD_RESPONSE"));
366
+ return;
367
+ }
368
+ const pending = this.pending.get(response.id);
369
+ if (!pending) continue;
370
+ if (response.error) {
371
+ pending.reject(new NativeRelayError(response.error.message ?? "Codex Desktop rejected the native dispatch", "NATIVE_DISPATCH_FAILED"));
372
+ } else if (Object.hasOwn(response, "result")) {
373
+ pending.resolve(response.result);
374
+ } else {
375
+ pending.reject(new NativeRelayError("Codex Desktop native response has no result", "NATIVE_BAD_RESPONSE"));
376
+ }
377
+ }
378
+ });
379
+ socket.on("error", (err) => fail(new NativeRelayError(`Codex Desktop native pipe failed: ${err.message}`, connected ? "NATIVE_DELIVERY_UNCONFIRMED" : "NATIVE_PIPE_UNAVAILABLE")));
380
+ socket.on("close", () => fail(new NativeRelayError("Codex Desktop native tools pipe closed before confirming delivery", connected ? "NATIVE_DELIVERY_UNCONFIRMED" : "NATIVE_PIPE_UNAVAILABLE")));
381
+ });
382
+ })();
383
+ try {
384
+ await this.connecting;
385
+ } finally {
386
+ this.connecting = null;
387
+ this.connectingSocket = null;
388
+ }
389
+ }
390
+
391
+ async dispatch(args) {
392
+ const id = this.nextId++;
393
+ const payload = Buffer.from(JSON.stringify({
394
+ jsonrpc: "2.0",
395
+ id,
396
+ method: this.env.CODEX_NATIVE_RELAY_METHOD ?? NATIVE_DISPATCH_METHOD,
397
+ params: nativeDispatchParams(args),
398
+ }));
399
+ if (payload.length > MAX_FRAME_BYTES) {
400
+ throw new NativeRelayError("Native dispatch exceeds the frame limit", "RELAY_MESSAGE_TOO_LARGE");
401
+ }
402
+ await this.connect();
403
+ const header = Buffer.alloc(4);
404
+ header.writeUInt32LE(payload.length);
405
+ return new Promise((resolve, reject) => {
406
+ const finish = (fn, value) => {
407
+ if (!this.pending.delete(id)) return;
408
+ globalThis.clearTimeout(timer);
409
+ fn(value);
410
+ };
411
+ const timer = globalThis.setTimeout(() => {
412
+ finish(reject, new NativeRelayError("Codex Desktop native dispatch timed out; delivery may have occurred", "NATIVE_DELIVERY_UNCONFIRMED"));
413
+ }, this.timeoutMs);
414
+ this.pending.set(id, {
415
+ socket: this.socket,
416
+ resolve: (value) => finish(resolve, value),
417
+ reject: (err) => finish(reject, err),
418
+ });
419
+ try {
420
+ if (!this.socket || this.socket.destroyed) throw new Error("native tools pipe is closed");
421
+ this.socket.write(Buffer.concat([header, payload]), (err) => {
422
+ if (err) finish(reject, new NativeRelayError(`Native dispatch write failed: ${err.message}`, "NATIVE_DELIVERY_UNCONFIRMED"));
423
+ });
424
+ } catch (err) {
425
+ finish(reject, new NativeRelayError(`Native dispatch write failed: ${err.message}`, "NATIVE_DELIVERY_UNCONFIRMED"));
426
+ }
427
+ });
428
+ }
429
+
430
+ close() {
431
+ this.connectionGeneration++;
432
+ const socket = this.socket;
433
+ this.socket = null;
434
+ for (const pending of this.pending.values()) {
435
+ pending.reject(new NativeRelayError("Native tools client closed before confirming delivery", "NATIVE_DELIVERY_UNCONFIRMED"));
436
+ }
437
+ socket?.destroy();
438
+ this.connectingSocket?.destroy();
439
+ }
139
440
  }
140
441
 
141
442
  /**
@@ -211,7 +512,7 @@ export class NativeDesktopRelay {
211
512
  }
212
513
 
213
514
  const response = await this.#roundTrip(line, timeoutMs);
214
- if (response?.ok) return response;
515
+ if (response?.ok === true && response.v === RELAY_PROTOCOL_VERSION) return response;
215
516
  throw new NativeRelayError(
216
517
  response?.error?.message ?? "the Codex Desktop relay refused the message",
217
518
  response?.error?.code ?? "NATIVE_DISPATCH_FAILED",
@@ -222,8 +523,9 @@ export class NativeDesktopRelay {
222
523
  #roundTrip(line, timeoutMs) {
223
524
  const socketPath = this.socketPath;
224
525
  return new Promise((resolve, reject) => {
225
- let buffer = "";
526
+ let buffer = Buffer.alloc(0);
226
527
  let settled = false;
528
+ let dispatched = false;
227
529
  const socket = net.connect({ path: socketPath });
228
530
 
229
531
  const finish = (fn, value) => {
@@ -241,16 +543,19 @@ export class NativeDesktopRelay {
241
543
  new NativeRelayError(
242
544
  `The Codex Desktop relay did not answer within ${timeoutMs}ms`,
243
545
  "RELAY_TIMEOUT",
244
- { reachedCompanion: true },
546
+ { reachedCompanion: dispatched },
245
547
  ),
246
548
  ),
247
549
  timeoutMs,
248
550
  );
249
551
 
250
- socket.on("connect", () => socket.write(line));
552
+ socket.on("connect", () => {
553
+ dispatched = true;
554
+ socket.write(line);
555
+ });
251
556
  socket.on("data", (chunk) => {
252
- buffer += chunk.toString("utf8");
253
- if (Buffer.byteLength(buffer, "utf8") > MAX_FRAME_BYTES) {
557
+ buffer = Buffer.concat([buffer, chunk]);
558
+ if (buffer.length > MAX_FRAME_BYTES) {
254
559
  finish(
255
560
  reject,
256
561
  new NativeRelayError("The Codex Desktop relay answered with an oversized frame", "RELAY_BAD_RESPONSE", {
@@ -259,10 +564,10 @@ export class NativeDesktopRelay {
259
564
  );
260
565
  return;
261
566
  }
262
- const index = buffer.indexOf("\n");
567
+ const index = buffer.indexOf(10);
263
568
  if (index < 0) return;
264
569
  try {
265
- finish(resolve, JSON.parse(buffer.slice(0, index)));
570
+ finish(resolve, JSON.parse(buffer.subarray(0, index).toString("utf8")));
266
571
  } catch (err) {
267
572
  finish(
268
573
  reject,
@@ -277,7 +582,7 @@ export class NativeDesktopRelay {
277
582
  socket.on("error", (err) =>
278
583
  finish(
279
584
  reject,
280
- new NativeRelayError(`Cannot reach the Codex Desktop relay at ${socketPath}: ${err.message}`, "RELAY_UNREACHABLE"),
585
+ new NativeRelayError(`Codex Desktop relay connection failed at ${socketPath}: ${err.message}`, dispatched ? "RELAY_DELIVERY_UNCONFIRMED" : "RELAY_UNREACHABLE", { reachedCompanion: dispatched }),
281
586
  ),
282
587
  );
283
588
  socket.on("close", () =>
@@ -285,7 +590,8 @@ export class NativeDesktopRelay {
285
590
  reject,
286
591
  new NativeRelayError(
287
592
  `The Codex Desktop relay at ${socketPath} closed before answering`,
288
- "RELAY_UNREACHABLE",
593
+ dispatched ? "RELAY_DELIVERY_UNCONFIRMED" : "RELAY_UNREACHABLE",
594
+ { reachedCompanion: dispatched },
289
595
  ),
290
596
  ),
291
597
  );
@@ -293,13 +599,6 @@ export class NativeDesktopRelay {
293
599
  }
294
600
  }
295
601
 
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
602
  export async function bootstrapRelayThread(client, { cwd = homeDir(), env = process.env, name = "Native Relay" } = {}) {
304
603
  const res = await client.call("thread/start", {
305
604
  cwd,
@@ -309,14 +608,16 @@ export async function bootstrapRelayThread(client, { cwd = homeDir(), env = proc
309
608
  const threadId = res?.thread?.id;
310
609
  if (!threadId) throw new NativeRelayError("Codex app-server created no relay thread id", "RELAY_BOOTSTRAP_FAILED");
311
610
 
611
+ let release;
312
612
  try {
313
- await client.call("thread/name/set", { threadId, name });
314
- } catch {
315
- // A thread without a title still works as an executor context.
613
+ try {
614
+ await client.call("thread/name/set", { threadId, name });
615
+ } catch {}
616
+ writeRelayConfig({ relayThreadId: threadId, createdAt: new Date().toISOString() }, env);
617
+ } finally {
618
+ release = await client.releaseThread(threadId);
316
619
  }
317
-
318
- writeRelayConfig({ relayThreadId: threadId, createdAt: new Date().toISOString() }, env);
319
- return { threadId, configPath: relayConfigPath(env) };
620
+ return { threadId, configPath: relayConfigPath(env), release };
320
621
  }
321
622
 
322
623
  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() {