@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.
- package/CHANGELOG.md +34 -0
- package/README.md +47 -31
- 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 +12 -27
- 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 +115 -81
- package/src/native-relay-companion.mjs +113 -68
- package/src/native-relay.mjs +181 -37
- package/src/peer-protocol.mjs +129 -27
- package/src/platform.mjs +30 -4
- package/src/thread-delivery.mjs +24 -8
- package/src/turn.mjs +28 -10
package/src/native-relay.mjs
CHANGED
|
@@ -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 = "
|
|
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 {
|
|
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
|
|
297
|
+
reason: `the Codex Desktop native relay is unavailable on ${PLATFORM_LABEL}`,
|
|
154
298
|
};
|
|
155
299
|
}
|
|
156
|
-
if (!
|
|
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:
|
|
381
|
+
{ reachedCompanion: dispatched },
|
|
237
382
|
),
|
|
238
383
|
),
|
|
239
384
|
timeoutMs,
|
|
240
385
|
);
|
|
241
386
|
|
|
242
|
-
socket.on("connect", () =>
|
|
387
|
+
socket.on("connect", () => {
|
|
388
|
+
dispatched = true;
|
|
389
|
+
socket.write(line);
|
|
390
|
+
});
|
|
243
391
|
socket.on("data", (chunk) => {
|
|
244
|
-
buffer
|
|
245
|
-
if (
|
|
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(
|
|
402
|
+
const index = buffer.indexOf(10);
|
|
255
403
|
if (index < 0) return;
|
|
256
404
|
try {
|
|
257
|
-
finish(resolve, JSON.parse(buffer.
|
|
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(`
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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) {
|
package/src/peer-protocol.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
96
|
-
|
|
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) &&
|
|
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
|
-
|
|
210
|
-
|
|
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
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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) =>
|
|
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.`,
|