@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.
- package/CHANGELOG.md +17 -0
- package/README.md +24 -8
- package/package.json +2 -2
- package/scripts/check-claude-bridge.mjs +24 -20
- package/scripts/check.mjs +13 -7
- package/scripts/install-claude-desktop.mjs +1 -0
- package/scripts/install-native-relay.mjs +5 -20
- package/scripts/smoke.mjs +38 -35
- package/scripts/sync-version.mjs +1 -0
- package/src/app-server-client.mjs +271 -65
- package/src/claude-bridge.mjs +6 -6
- package/src/index.mjs +113 -81
- package/src/native-relay-companion.mjs +96 -57
- package/src/native-relay.mjs +168 -32
- package/src/peer-protocol.mjs +53 -5
- package/src/platform.mjs +10 -3
- package/src/thread-delivery.mjs +19 -17
- package/src/turn.mjs +28 -10
package/src/native-relay.mjs
CHANGED
|
@@ -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 = "
|
|
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 {
|
|
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:
|
|
381
|
+
{ reachedCompanion: dispatched },
|
|
245
382
|
),
|
|
246
383
|
),
|
|
247
384
|
timeoutMs,
|
|
248
385
|
);
|
|
249
386
|
|
|
250
|
-
socket.on("connect", () =>
|
|
387
|
+
socket.on("connect", () => {
|
|
388
|
+
dispatched = true;
|
|
389
|
+
socket.write(line);
|
|
390
|
+
});
|
|
251
391
|
socket.on("data", (chunk) => {
|
|
252
|
-
buffer
|
|
253
|
-
if (
|
|
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(
|
|
402
|
+
const index = buffer.indexOf(10);
|
|
263
403
|
if (index < 0) return;
|
|
264
404
|
try {
|
|
265
|
-
finish(resolve, JSON.parse(buffer.
|
|
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(`
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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) {
|
package/src/peer-protocol.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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) =>
|
|
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.`,
|
package/src/thread-delivery.mjs
CHANGED
|
@@ -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"
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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,
|