@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.
- package/CHANGELOG.md +29 -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 +102 -58
- package/src/native-relay.mjs +333 -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,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 = "
|
|
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 {
|
|
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:
|
|
546
|
+
{ reachedCompanion: dispatched },
|
|
245
547
|
),
|
|
246
548
|
),
|
|
247
549
|
timeoutMs,
|
|
248
550
|
);
|
|
249
551
|
|
|
250
|
-
socket.on("connect", () =>
|
|
552
|
+
socket.on("connect", () => {
|
|
553
|
+
dispatched = true;
|
|
554
|
+
socket.write(line);
|
|
555
|
+
});
|
|
251
556
|
socket.on("data", (chunk) => {
|
|
252
|
-
buffer
|
|
253
|
-
if (
|
|
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(
|
|
567
|
+
const index = buffer.indexOf(10);
|
|
263
568
|
if (index < 0) return;
|
|
264
569
|
try {
|
|
265
|
-
finish(resolve, JSON.parse(buffer.
|
|
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(`
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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) {
|
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() {
|