@minhspark/codex-mcp-bridge 1.12.0 → 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,8 +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";
4
5
 
5
- import { IS_MACOS, PLATFORM_LABEL, homeDir } from "./platform.mjs";
6
+ import { IS_MACOS, IS_WINDOWS, PLATFORM_LABEL, homeDir } from "./platform.mjs";
6
7
 
7
8
  /**
8
9
  * The Codex Desktop app owns the per-thread writer lock of every thread it has
@@ -29,7 +30,7 @@ import { IS_MACOS, 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
 
@@ -43,16 +44,10 @@ export const MAX_FRAME_BYTES = 128 * 1024;
43
44
 
44
45
  const DEFAULT_TIMEOUT_MS = 30000;
45
46
  const RELAY_SOCKET_NAME = "native-relay.sock";
47
+ const WINDOWS_RELAY_SOCKET = "\\\\.\\pipe\\LOCAL\\codex-native-relay";
46
48
  const RELAY_CONFIG_NAME = "native-relay.json";
47
49
 
48
50
  export class NativeRelayError extends Error {
49
- /**
50
- * `reachedCompanion` is what decides whether falling back to the app-server
51
- * path is worth doing. A companion that never answered says nothing about
52
- * the target thread, so the older path deserves its turn; a companion that
53
- * answered with a refusal has already asked Codex, and asking again through
54
- * a second app-server only adds a writer-lock failure on top.
55
- */
56
51
  constructor(message, code, { reachedCompanion = false } = {}) {
57
52
  super(message);
58
53
  this.name = "NativeRelayError";
@@ -66,7 +61,7 @@ export function codexHome(env = process.env) {
66
61
  }
67
62
 
68
63
  export function relaySocketPath(env = process.env) {
69
- return env.CODEX_NATIVE_RELAY_SOCKET ?? path.join(codexHome(env), RELAY_SOCKET_NAME);
64
+ return env.CODEX_NATIVE_RELAY_SOCKET ?? (IS_WINDOWS ? WINDOWS_RELAY_SOCKET : path.join(codexHome(env), RELAY_SOCKET_NAME));
70
65
  }
71
66
 
72
67
  export function relayConfigPath(env = process.env) {
@@ -81,6 +76,13 @@ function isSocketFile(target) {
81
76
  }
82
77
  }
83
78
 
79
+ function isRelayEndpoint(target) {
80
+ if (IS_WINDOWS) {
81
+ return typeof target === "string" && target.toLowerCase().startsWith("\\\\.\\pipe\\") && fs.existsSync(target);
82
+ }
83
+ return isSocketFile(target);
84
+ }
85
+
84
86
  export function readRelayConfig(env = process.env) {
85
87
  const file = relayConfigPath(env);
86
88
  try {
@@ -127,7 +129,149 @@ export function resolveRelayThreadId(env = process.env) {
127
129
  * middle of a request.
128
130
  */
129
131
  export function nativeDispatchParams({ executorThreadId, targetThreadId, message }) {
130
- 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
+ }
131
275
  }
132
276
 
133
277
  /**
@@ -145,15 +289,15 @@ export function nativeRelayStatus(env = process.env) {
145
289
  return { enabled: false, mode, socketPath, reason: "disabled by CODEX_BRIDGE_NATIVE_RELAY=0" };
146
290
  }
147
291
  const forced = mode === "1" || mode === "on";
148
- if (!IS_MACOS && !forced) {
292
+ if (!IS_MACOS && !IS_WINDOWS && !forced) {
149
293
  return {
150
294
  enabled: false,
151
295
  mode,
152
296
  socketPath,
153
- reason: `the Codex Desktop native relay is macOS-only (this is ${PLATFORM_LABEL})`,
297
+ reason: `the Codex Desktop native relay is unavailable on ${PLATFORM_LABEL}`,
154
298
  };
155
299
  }
156
- if (!isSocketFile(socketPath)) {
300
+ if (!isRelayEndpoint(socketPath)) {
157
301
  return {
158
302
  enabled: false,
159
303
  mode,
@@ -203,7 +347,7 @@ export class NativeDesktopRelay {
203
347
  }
204
348
 
205
349
  const response = await this.#roundTrip(line, timeoutMs);
206
- if (response?.ok) return response;
350
+ if (response?.ok === true && response.v === RELAY_PROTOCOL_VERSION) return response;
207
351
  throw new NativeRelayError(
208
352
  response?.error?.message ?? "the Codex Desktop relay refused the message",
209
353
  response?.error?.code ?? "NATIVE_DISPATCH_FAILED",
@@ -214,8 +358,9 @@ export class NativeDesktopRelay {
214
358
  #roundTrip(line, timeoutMs) {
215
359
  const socketPath = this.socketPath;
216
360
  return new Promise((resolve, reject) => {
217
- let buffer = "";
361
+ let buffer = Buffer.alloc(0);
218
362
  let settled = false;
363
+ let dispatched = false;
219
364
  const socket = net.connect({ path: socketPath });
220
365
 
221
366
  const finish = (fn, value) => {
@@ -233,16 +378,19 @@ export class NativeDesktopRelay {
233
378
  new NativeRelayError(
234
379
  `The Codex Desktop relay did not answer within ${timeoutMs}ms`,
235
380
  "RELAY_TIMEOUT",
236
- { reachedCompanion: true },
381
+ { reachedCompanion: dispatched },
237
382
  ),
238
383
  ),
239
384
  timeoutMs,
240
385
  );
241
386
 
242
- socket.on("connect", () => socket.write(line));
387
+ socket.on("connect", () => {
388
+ dispatched = true;
389
+ socket.write(line);
390
+ });
243
391
  socket.on("data", (chunk) => {
244
- buffer += chunk.toString("utf8");
245
- if (Buffer.byteLength(buffer, "utf8") > MAX_FRAME_BYTES) {
392
+ buffer = Buffer.concat([buffer, chunk]);
393
+ if (buffer.length > MAX_FRAME_BYTES) {
246
394
  finish(
247
395
  reject,
248
396
  new NativeRelayError("The Codex Desktop relay answered with an oversized frame", "RELAY_BAD_RESPONSE", {
@@ -251,10 +399,10 @@ export class NativeDesktopRelay {
251
399
  );
252
400
  return;
253
401
  }
254
- const index = buffer.indexOf("\n");
402
+ const index = buffer.indexOf(10);
255
403
  if (index < 0) return;
256
404
  try {
257
- finish(resolve, JSON.parse(buffer.slice(0, index)));
405
+ finish(resolve, JSON.parse(buffer.subarray(0, index).toString("utf8")));
258
406
  } catch (err) {
259
407
  finish(
260
408
  reject,
@@ -269,7 +417,7 @@ export class NativeDesktopRelay {
269
417
  socket.on("error", (err) =>
270
418
  finish(
271
419
  reject,
272
- 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 }),
273
421
  ),
274
422
  );
275
423
  socket.on("close", () =>
@@ -277,7 +425,8 @@ export class NativeDesktopRelay {
277
425
  reject,
278
426
  new NativeRelayError(
279
427
  `The Codex Desktop relay at ${socketPath} closed before answering`,
280
- "RELAY_UNREACHABLE",
428
+ dispatched ? "RELAY_DELIVERY_UNCONFIRMED" : "RELAY_UNREACHABLE",
429
+ { reachedCompanion: dispatched },
281
430
  ),
282
431
  ),
283
432
  );
@@ -285,13 +434,6 @@ export class NativeDesktopRelay {
285
434
  }
286
435
  }
287
436
 
288
- /**
289
- * Creates the dedicated executor thread once and remembers it, using the
290
- * ordinary app-server path - which is allowed to take a writer lock here
291
- * precisely because this thread belongs to nobody else. The caller stops the
292
- * app-server afterwards, so the lock is released and Codex Desktop is left
293
- * with the state to itself.
294
- */
295
437
  export async function bootstrapRelayThread(client, { cwd = homeDir(), env = process.env, name = "Native Relay" } = {}) {
296
438
  const res = await client.call("thread/start", {
297
439
  cwd,
@@ -301,14 +443,16 @@ export async function bootstrapRelayThread(client, { cwd = homeDir(), env = proc
301
443
  const threadId = res?.thread?.id;
302
444
  if (!threadId) throw new NativeRelayError("Codex app-server created no relay thread id", "RELAY_BOOTSTRAP_FAILED");
303
445
 
446
+ let release;
304
447
  try {
305
- await client.call("thread/name/set", { threadId, name });
306
- } catch {
307
- // 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);
308
454
  }
309
-
310
- writeRelayConfig({ relayThreadId: threadId, createdAt: new Date().toISOString() }, env);
311
- return { threadId, configPath: relayConfigPath(env) };
455
+ return { threadId, configPath: relayConfigPath(env), release };
312
456
  }
313
457
 
314
458
  export function writeRelayConfig(config, env = process.env) {
@@ -32,7 +32,7 @@ const projectsDir = () => path.join(homeDir(), ".claude", "projects");
32
32
 
33
33
  /**
34
34
  * A Claude Code session advertises itself in ~/.claude/sessions/<pid>.json and
35
- * listens for peer messages on a unix socket. Messages are newline-delimited
35
+ * listens for peer messages on a local socket or Windows named pipe. Messages are newline-delimited
36
36
  * JSON; the wrapper element is what Claude renders in its chat surface.
37
37
  */
38
38
  export function buildFrame({ text, fromSocket, priority = "next" }) {
@@ -84,6 +84,30 @@ function isProcessAlive(pid) {
84
84
  }
85
85
  }
86
86
 
87
+ const SESSION_READ_ATTEMPTS = 3;
88
+ const PEER_SEND_ATTEMPTS = 3;
89
+ const PEER_CONNECT_TIMEOUT_MS = 2000;
90
+ const PEER_RETRY_DELAY_MS = 75;
91
+ const RETRYABLE_PEER_ERRORS = new Set(["ECONNREFUSED", "ECONNRESET", "ENOENT", "EPIPE", "ENOTFOUND", "ETIMEDOUT"]);
92
+
93
+ function readSessionEntry(file) {
94
+ for (let attempt = 0; attempt < SESSION_READ_ATTEMPTS; attempt += 1) {
95
+ try {
96
+ const first = fs.readFileSync(file, "utf8");
97
+ const second = fs.readFileSync(file, "utf8");
98
+ if (first !== second) continue;
99
+ return JSON.parse(second);
100
+ } catch {}
101
+ }
102
+ return null;
103
+ }
104
+
105
+ function hasMessagingEndpoint(entry) {
106
+ const socket = entry?.messagingSocketPath;
107
+ if (IS_WINDOWS && typeof socket === "string" && socket.toLowerCase().startsWith("\\\\.\\pipe\\")) return true;
108
+ return Boolean(socket) && fs.existsSync(socket);
109
+ }
110
+
87
111
  export const BRIDGE_ENTRYPOINT = "codex-bridge";
88
112
 
89
113
  export function listClaudeSessions({ includeDead = false, includeBridges = false } = {}) {
@@ -92,15 +116,11 @@ export function listClaudeSessions({ includeDead = false, includeBridges = false
92
116
  const rows = [];
93
117
  for (const file of fs.readdirSync(dir)) {
94
118
  if (!file.endsWith(".json")) continue;
95
- let entry;
96
- try {
97
- entry = JSON.parse(fs.readFileSync(path.join(dir, file), "utf8"));
98
- } catch {
99
- continue;
100
- }
119
+ const entry = readSessionEntry(path.join(dir, file));
120
+ if (!entry) continue;
101
121
  if (!entry?.pid || !entry?.messagingSocketPath) continue;
102
122
  if (entry.entrypoint === BRIDGE_ENTRYPOINT && !includeBridges) continue;
103
- const alive = isProcessAlive(entry.pid) && fs.existsSync(entry.messagingSocketPath);
123
+ const alive = isProcessAlive(entry.pid) && hasMessagingEndpoint(entry);
104
124
  if (!alive && !includeDead) continue;
105
125
  rows.push({
106
126
  pid: entry.pid,
@@ -192,6 +212,9 @@ export class PeerEndpoint {
192
212
  this.server = null;
193
213
  this.inbox = [];
194
214
  this.listeners = new Set();
215
+ this.messageSequence = 0;
216
+ this.requestQueues = new Map();
217
+ this.unconfirmedReplies = new Map();
195
218
  this.started = false;
196
219
  }
197
220
 
@@ -206,12 +229,8 @@ export class PeerEndpoint {
206
229
  for (const file of fs.readdirSync(dir)) {
207
230
  if (!file.endsWith(".json")) continue;
208
231
  const registry = path.join(dir, file);
209
- let entry;
210
- try {
211
- entry = JSON.parse(fs.readFileSync(registry, "utf8"));
212
- } catch {
213
- continue;
214
- }
232
+ const entry = readSessionEntry(registry);
233
+ if (!entry) continue;
215
234
  if (entry?.entrypoint !== BRIDGE_ENTRYPOINT) continue;
216
235
  if (!entry.pid || entry.pid === this.pid || isProcessAlive(entry.pid)) continue;
217
236
  for (const stale of [registry, entry.messagingSocketPath, ...fs.readdirSync(dir)
@@ -276,8 +295,9 @@ export class PeerEndpoint {
276
295
 
277
296
  #handleConnection(socket) {
278
297
  let buffer = "";
298
+ socket.setEncoding("utf8");
279
299
  socket.on("data", (chunk) => {
280
- buffer += chunk.toString("utf8");
300
+ buffer += chunk;
281
301
  let index;
282
302
  while ((index = buffer.indexOf("\n")) >= 0) {
283
303
  const line = buffer.slice(0, index).trim();
@@ -290,8 +310,9 @@ export class PeerEndpoint {
290
310
  this.log(`ignored malformed peer frame (${line.slice(0, 80)})`);
291
311
  continue;
292
312
  }
293
- const record = { ...message, receivedAt: Date.now() };
313
+ const record = { ...message, receivedAt: Date.now(), sequence: ++this.messageSequence };
294
314
  this.inbox.push(record);
315
+ this.#removePendingReply(record.fromSocket);
295
316
  this.log(`inbox <- ${record.fromSocket ?? "?"}: ${record.text.slice(0, 120)}`);
296
317
  for (const listener of [...this.listeners]) {
297
318
  try {
@@ -328,24 +349,105 @@ export class PeerEndpoint {
328
349
 
329
350
  async send(targetSocket, text, { priority = "next" } = {}) {
330
351
  const frame = buildFrame({ text, fromSocket: this.socketPath, priority });
331
- await new Promise((resolve, reject) => {
332
- const client = net.connect({ path: targetSocket }, () => {
333
- client.write(`${JSON.stringify(frame)}\n`, () => {
334
- client.end();
335
- resolve();
352
+ const line = JSON.stringify(frame) + "\n";
353
+ for (let attempt = 1; attempt <= PEER_SEND_ATTEMPTS; attempt += 1) {
354
+ try {
355
+ await new Promise((resolve, reject) => {
356
+ const client = net.connect({ path: targetSocket });
357
+ let connected = false;
358
+ let writeStarted = false;
359
+ let settled = false;
360
+ const timer = globalThis.setTimeout(() => {
361
+ const error = new Error("timed out connecting to " + targetSocket);
362
+ error.code = "ETIMEDOUT";
363
+ finish(error);
364
+ client.destroy();
365
+ }, PEER_CONNECT_TIMEOUT_MS);
366
+ const finish = (error) => {
367
+ if (settled) return;
368
+ settled = true;
369
+ globalThis.clearTimeout(timer);
370
+ if (error) {
371
+ reject({ error, retryable: !connected && !writeStarted });
372
+ } else {
373
+ resolve();
374
+ }
375
+ };
376
+ client.once("connect", () => {
377
+ connected = true;
378
+ writeStarted = true;
379
+ client.write(line, (error) => {
380
+ if (error) {
381
+ finish(error);
382
+ return;
383
+ }
384
+ client.end();
385
+ finish();
386
+ });
387
+ });
388
+ client.once("error", finish);
336
389
  });
337
- });
338
- client.on("error", reject);
339
- });
390
+ return frame.msg_id;
391
+ } catch (failure) {
392
+ const error = failure?.error ?? failure;
393
+ if (!failure?.retryable || attempt === PEER_SEND_ATTEMPTS || !RETRYABLE_PEER_ERRORS.has(error?.code)) {
394
+ throw error;
395
+ }
396
+ await new Promise((resolve) => globalThis.setTimeout(resolve, PEER_RETRY_DELAY_MS));
397
+ }
398
+ }
340
399
  return frame.msg_id;
341
400
  }
342
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
+
343
443
  /**
344
444
  * Claude answers with a fresh msg_id rather than an in-reply-to field, so a
345
445
  * reply is matched by origin socket and arrival time.
346
446
  */
347
- waitForReply(fromSocket, { timeoutMs = 120000, since = Date.now() } = {}) {
348
- 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);
349
451
  if (existing) return Promise.resolve(existing);
350
452
  return new Promise((resolve) => {
351
453
  const timer = globalThis.setTimeout(() => {
@@ -353,7 +455,7 @@ export class PeerEndpoint {
353
455
  resolve(null);
354
456
  }, timeoutMs);
355
457
  const unsubscribe = this.onMessage((record) => {
356
- if (record.fromSocket !== fromSocket) return;
458
+ if (!matches(record)) return;
357
459
  globalThis.clearTimeout(timer);
358
460
  unsubscribe();
359
461
  resolve(record);
package/src/platform.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { execFile, execFileSync } from "node:child_process";
2
- import { accessSync, constants, existsSync } from "node:fs";
2
+ import { accessSync, constants, existsSync, readdirSync, statSync } from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
@@ -94,7 +94,26 @@ function isRunnable(candidate) {
94
94
  function windowsCodexCandidates() {
95
95
  const roaming = process.env.APPDATA;
96
96
  const local = process.env.LOCALAPPDATA;
97
+ const versionedRoot = local && path.join(local, "OpenAI", "Codex", "bin");
98
+ let versioned = [];
99
+ if (versionedRoot) {
100
+ try {
101
+ versioned = readdirSync(versionedRoot, { withFileTypes: true })
102
+ .filter((entry) => entry.isDirectory())
103
+ .map((entry) => path.join(versionedRoot, entry.name, "codex.exe"))
104
+ .filter((candidate) => isRunnable(candidate))
105
+ .sort((left, right) => {
106
+ try {
107
+ return statSync(path.dirname(right)).mtimeMs - statSync(path.dirname(left)).mtimeMs;
108
+ } catch {
109
+ return 0;
110
+ }
111
+ });
112
+ } catch {}
113
+ }
97
114
  return [
115
+ process.env.CODEX_CLI_PATH,
116
+ ...versioned,
98
117
  local && path.join(local, "Programs", "OpenAI", "Codex", "bin", "codex.exe"),
99
118
  roaming && path.join(roaming, "npm", "codex.cmd"),
100
119
  process.env.ProgramFiles && path.join(process.env.ProgramFiles, "nodejs", "codex.cmd"),
@@ -169,6 +188,7 @@ export function resolveCodexBin(explicit) {
169
188
  */
170
189
  export function isWritableDir(target) {
171
190
  try {
191
+ if (!statSync(target).isDirectory()) return false;
172
192
  accessSync(target, constants.W_OK);
173
193
  return true;
174
194
  } catch {
@@ -274,7 +294,7 @@ export function resolveWorkspacePath(input) {
274
294
  const writable = ordered.find((candidate) => existsSync(candidate) && isWritableDir(candidate));
275
295
  if (writable) {
276
296
  return {
277
- path: writable,
297
+ path: path.resolve(writable),
278
298
  remapped: writable !== original,
279
299
  writable: true,
280
300
  note:
@@ -286,10 +306,16 @@ export function resolveWorkspacePath(input) {
286
306
  };
287
307
  }
288
308
 
289
- 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
+ });
290
316
  if (existing) {
291
317
  return {
292
- path: existing,
318
+ path: path.resolve(existing),
293
319
  remapped: existing !== original,
294
320
  writable: false,
295
321
  note: `cwd ${existing} exists but is not writable on ${PLATFORM_LABEL}; Codex will fail on any file edit.`,