@minhspark/codex-mcp-bridge 1.13.3 → 1.13.5
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 +34 -16
- package/package.json +1 -1
- package/scripts/check-claude-bridge.mjs +2 -1
- package/scripts/install-codex-mcp.mjs +12 -15
- package/scripts/install-native-relay.mjs +35 -3
- package/src/claude-bridge.mjs +167 -57
- package/src/claude-desktop-context.mjs +160 -0
- package/src/codex-mcp-registration.mjs +25 -0
- package/src/codex-sender-context.mjs +134 -0
- package/src/index.mjs +33 -11
- package/src/native-relay-companion.mjs +1 -1
- package/src/peer-protocol.mjs +126 -28
- package/src/platform.mjs +55 -1
- package/src/reply-forwarder.mjs +172 -0
- package/src/runtime-state.mjs +34 -0
- package/src/thread-delivery.mjs +8 -4
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
const KNOWN_UNSENT_CODES = new Set([
|
|
2
|
+
"RELAY_UNREACHABLE",
|
|
3
|
+
"RELAY_MESSAGE_TOO_LARGE",
|
|
4
|
+
"RELAY_THREAD_UNCONFIGURED",
|
|
5
|
+
"RELAY_BAD_REQUEST",
|
|
6
|
+
]);
|
|
7
|
+
|
|
8
|
+
function validIdentity(value) {
|
|
9
|
+
return typeof value === "string" && value.length > 0 && !/\s/.test(value);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function identityError(message) {
|
|
13
|
+
return Object.assign(new Error(message), { code: "INVALID_REPLY_IDENTITY" });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function failureState(error) {
|
|
17
|
+
const code = typeof error?.code === "string" ? error.code : "FORWARD_OUTCOME_UNKNOWN";
|
|
18
|
+
const uncertain = error?.reachedCompanion === true || error?.deliveryUncertain === true
|
|
19
|
+
|| /TIMEOUT|TIMEDOUT/.test(code) || error?.name === "TimeoutError";
|
|
20
|
+
const unsent = error?.sent === false || error?.dispatched === false
|
|
21
|
+
|| error?.preflight?.sent === false || KNOWN_UNSENT_CODES.has(code);
|
|
22
|
+
return {
|
|
23
|
+
status: !uncertain && unsent ? "failed" : "unknown",
|
|
24
|
+
reasonCode: code,
|
|
25
|
+
reason: error?.message ?? String(error),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class ReplyForwarder {
|
|
30
|
+
constructor({
|
|
31
|
+
deliver,
|
|
32
|
+
minIntervalMs = 5000,
|
|
33
|
+
maxPerSession = 50,
|
|
34
|
+
now = Date.now,
|
|
35
|
+
schedule = (callback, delay) => setTimeout(callback, delay),
|
|
36
|
+
cancel = clearTimeout,
|
|
37
|
+
beforeForward,
|
|
38
|
+
} = {}) {
|
|
39
|
+
if (typeof deliver !== "function") throw new TypeError("Reply forwarding requires a delivery function");
|
|
40
|
+
if (!Number.isFinite(minIntervalMs) || minIntervalMs < 0) throw new TypeError("Invalid reply forwarding interval");
|
|
41
|
+
if (!Number.isSafeInteger(maxPerSession) || maxPerSession < 1) throw new TypeError("Invalid reply forwarding limit");
|
|
42
|
+
if (![now, schedule, cancel].every((value) => typeof value === "function")
|
|
43
|
+
|| (beforeForward !== undefined && typeof beforeForward !== "function")) {
|
|
44
|
+
throw new TypeError("Invalid reply forwarding callbacks");
|
|
45
|
+
}
|
|
46
|
+
this.deliver = deliver;
|
|
47
|
+
this.minIntervalMs = minIntervalMs;
|
|
48
|
+
this.maxPerSession = maxPerSession;
|
|
49
|
+
this.now = now;
|
|
50
|
+
this.schedule = schedule;
|
|
51
|
+
this.cancel = cancel;
|
|
52
|
+
this.beforeForward = beforeForward;
|
|
53
|
+
this.records = new Map();
|
|
54
|
+
this.queue = [];
|
|
55
|
+
this.timer = null;
|
|
56
|
+
this.active = null;
|
|
57
|
+
this.lastAt = null;
|
|
58
|
+
this.attempts = 0;
|
|
59
|
+
this.closed = false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
enqueue(record, threadId) {
|
|
63
|
+
const msgId = record?.inReplyTo ?? record?.msgId;
|
|
64
|
+
if (!validIdentity(msgId) || !validIdentity(threadId) || typeof record?.text !== "string" || !record.text.trim()) {
|
|
65
|
+
throw identityError("Reply forwarding requires an original message ID, exact destination task ID, and nonempty text");
|
|
66
|
+
}
|
|
67
|
+
if (record.replyThreadId != null && record.replyThreadId !== threadId) {
|
|
68
|
+
throw identityError("The reply destination differs from the original sending task");
|
|
69
|
+
}
|
|
70
|
+
const existing = this.records.get(msgId);
|
|
71
|
+
if (existing) {
|
|
72
|
+
if (existing.receipt.threadId !== threadId) throw identityError("An existing reply cannot be redirected to another task");
|
|
73
|
+
return this.read(msgId);
|
|
74
|
+
}
|
|
75
|
+
const entry = {
|
|
76
|
+
record: Object.freeze({ ...record, text: record.text, replyThreadId: threadId }),
|
|
77
|
+
receipt: { msgId, threadId, status: "queued", reasonCode: null, reason: null, queuedAt: this.now() },
|
|
78
|
+
};
|
|
79
|
+
this.records.set(msgId, entry);
|
|
80
|
+
if (this.closed) this.#block(entry, "FORWARDER_CLOSED", "Reply forwarding is closed; this reply was not dispatched");
|
|
81
|
+
else if (this.attempts >= this.maxPerSession) this.#block(entry, "SESSION_LIMIT_REACHED", "The per-session reply forwarding limit was reached; this reply was not dispatched");
|
|
82
|
+
else {
|
|
83
|
+
this.queue.push(entry);
|
|
84
|
+
this.#scheduleNext();
|
|
85
|
+
}
|
|
86
|
+
return this.read(msgId);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
read(msgId) {
|
|
90
|
+
const entry = this.records.get(msgId);
|
|
91
|
+
return entry ? { ...entry.receipt } : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
status() {
|
|
95
|
+
const counts = { total: this.records.size, queued: 0, sending: 0, forwarded: 0, failed: 0, unknown: 0, blocked: 0 };
|
|
96
|
+
for (const { receipt } of this.records.values()) counts[receipt.status] += 1;
|
|
97
|
+
return { ...counts, attempts: this.attempts, maxPerSession: this.maxPerSession, closed: this.closed };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
close() {
|
|
101
|
+
this.closed = true;
|
|
102
|
+
if (this.timer !== null) this.cancel(this.timer);
|
|
103
|
+
this.timer = null;
|
|
104
|
+
for (const entry of this.queue.splice(0)) {
|
|
105
|
+
this.#block(entry, "FORWARDER_CLOSED", "Reply forwarding closed before this reply was dispatched");
|
|
106
|
+
}
|
|
107
|
+
if (this.active?.receipt.status === "queued") {
|
|
108
|
+
this.#block(this.active, "FORWARDER_CLOSED", "Reply forwarding closed before this reply was dispatched");
|
|
109
|
+
}
|
|
110
|
+
return this.status();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
#block(entry, reasonCode, reason) {
|
|
114
|
+
Object.assign(entry.receipt, { status: "blocked", reasonCode, reason, completedAt: this.now() });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
#scheduleNext() {
|
|
118
|
+
if (this.closed || this.active || this.timer !== null || !this.queue.length) return;
|
|
119
|
+
if (this.attempts >= this.maxPerSession) {
|
|
120
|
+
for (const entry of this.queue.splice(0)) {
|
|
121
|
+
this.#block(entry, "SESSION_LIMIT_REACHED", "The per-session reply forwarding limit was reached; this reply was not dispatched");
|
|
122
|
+
}
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const delay = this.lastAt === null ? 0 : Math.max(0, this.lastAt + this.minIntervalMs - this.now());
|
|
126
|
+
this.timer = this.schedule(() => {
|
|
127
|
+
this.timer = null;
|
|
128
|
+
void this.#forwardNext();
|
|
129
|
+
}, delay);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async #forwardNext() {
|
|
133
|
+
if (this.closed || this.active || !this.queue.length) return;
|
|
134
|
+
const entry = this.queue.shift();
|
|
135
|
+
this.active = entry;
|
|
136
|
+
try {
|
|
137
|
+
try {
|
|
138
|
+
await this.beforeForward?.(entry.record, entry.receipt.threadId);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (entry.receipt.status !== "blocked") {
|
|
141
|
+
Object.assign(entry.receipt, {
|
|
142
|
+
status: "failed",
|
|
143
|
+
reasonCode: typeof error?.code === "string" ? error.code : "FORWARD_PREFLIGHT_FAILED",
|
|
144
|
+
reason: error?.message ?? String(error),
|
|
145
|
+
completedAt: this.now(),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (this.closed) {
|
|
151
|
+
this.#block(entry, "FORWARDER_CLOSED", "Reply forwarding closed before this reply was dispatched");
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
this.lastAt = this.now();
|
|
155
|
+
this.attempts += 1;
|
|
156
|
+
Object.assign(entry.receipt, { status: "sending", attemptedAt: this.lastAt });
|
|
157
|
+
try {
|
|
158
|
+
const result = await this.deliver(entry.receipt.threadId, entry.record);
|
|
159
|
+
Object.assign(entry.receipt, {
|
|
160
|
+
status: "forwarded",
|
|
161
|
+
completedAt: this.now(),
|
|
162
|
+
...(typeof result?.backend === "string" ? { backend: result.backend } : {}),
|
|
163
|
+
});
|
|
164
|
+
} catch (error) {
|
|
165
|
+
Object.assign(entry.receipt, failureState(error), { completedAt: this.now() });
|
|
166
|
+
}
|
|
167
|
+
} finally {
|
|
168
|
+
this.active = null;
|
|
169
|
+
this.#scheduleNext();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
export function createRuntimeState({ directory = path.dirname(fileURLToPath(import.meta.url)), configuration = () => null } = {}) {
|
|
7
|
+
const fingerprint = () => {
|
|
8
|
+
const hash = createHash("sha256");
|
|
9
|
+
for (const file of ["../package.json", ...readdirSync(directory).filter((file) => file.endsWith(".mjs")).sort()]) {
|
|
10
|
+
hash.update(file).update("\0").update(readFileSync(path.join(directory, file))).update("\0");
|
|
11
|
+
}
|
|
12
|
+
return hash.digest("hex");
|
|
13
|
+
};
|
|
14
|
+
const revision = fingerprint();
|
|
15
|
+
const configured = JSON.stringify(configuration());
|
|
16
|
+
const startedAt = new Date().toISOString();
|
|
17
|
+
const status = () => {
|
|
18
|
+
let diskRevision = null;
|
|
19
|
+
let reason = null;
|
|
20
|
+
try {
|
|
21
|
+
diskRevision = fingerprint();
|
|
22
|
+
if (diskRevision !== revision) reason = "Bridge source changed after this MCP process started";
|
|
23
|
+
else if (JSON.stringify(configuration()) !== configured) reason = "Bridge routing configuration changed after this MCP process started";
|
|
24
|
+
} catch {
|
|
25
|
+
reason = "Bridge source or routing configuration can no longer be read";
|
|
26
|
+
}
|
|
27
|
+
return { pid: process.pid, startedAt, revision, diskRevision, current: reason === null, reason };
|
|
28
|
+
};
|
|
29
|
+
const assertCurrent = () => {
|
|
30
|
+
const state = status();
|
|
31
|
+
if (!state.current) throw new Error(`${state.reason}. Reconnect this MCP server in the existing Desktop task before sending. No message was sent; do not create a replacement task or use an external app-server.`);
|
|
32
|
+
};
|
|
33
|
+
return { status, assertCurrent };
|
|
34
|
+
}
|
package/src/thread-delivery.mjs
CHANGED
|
@@ -64,8 +64,9 @@ export class DesktopTaskDelivery {
|
|
|
64
64
|
|
|
65
65
|
async withThread(threadId, operation, { deadline } = {}) {
|
|
66
66
|
const previous = this.threadOperations.get(threadId) ?? Promise.resolve();
|
|
67
|
+
let expired = false;
|
|
67
68
|
const current = previous.catch(() => {}).then(() => {
|
|
68
|
-
if (deadline !== undefined && this.now() >= deadline) throw new Error("The response deadline elapsed while another operation held this thread. No new prompt was sent.");
|
|
69
|
+
if (expired || (deadline !== undefined && this.now() >= deadline)) throw new Error("The response deadline elapsed while another operation held this thread. No new prompt was sent.");
|
|
69
70
|
return operation();
|
|
70
71
|
});
|
|
71
72
|
this.threadOperations.set(threadId, current);
|
|
@@ -75,7 +76,10 @@ export class DesktopTaskDelivery {
|
|
|
75
76
|
let timer;
|
|
76
77
|
try {
|
|
77
78
|
return await Promise.race([current, new Promise((_, reject) => {
|
|
78
|
-
timer = setTimeout(() =>
|
|
79
|
+
timer = setTimeout(() => {
|
|
80
|
+
expired = true;
|
|
81
|
+
reject(new Error("The Desktop response deadline elapsed. A previous operation may still be running; inspect the existing task before sending anything again."));
|
|
82
|
+
}, Math.max(1, deadline - this.now()));
|
|
79
83
|
})]);
|
|
80
84
|
} finally {
|
|
81
85
|
clearTimeout(timer);
|
|
@@ -314,7 +318,7 @@ export function createThreadDelivery({
|
|
|
314
318
|
return { backend: NATIVE_BACKEND, threadId, ack };
|
|
315
319
|
} catch (err) {
|
|
316
320
|
if (err.reachedCompanion || err.code !== "RELAY_UNREACHABLE") throw err;
|
|
317
|
-
if (desktopOnly) throw new Error(`Codex Desktop relay is unavailable: ${err.message}. Desktop-only mode will not start or use an external app-server.`);
|
|
321
|
+
if (desktopOnly) throw Object.assign(new Error(`Codex Desktop relay is unavailable: ${err.message}. Desktop-only mode will not start or use an external app-server.`), { code: "RELAY_UNREACHABLE", sent: false });
|
|
318
322
|
log(`native relay unreachable (${err.message}); falling back to the app-server path`);
|
|
319
323
|
}
|
|
320
324
|
} else if (status.reason !== reportedUnavailable) {
|
|
@@ -322,7 +326,7 @@ export function createThreadDelivery({
|
|
|
322
326
|
log(`native relay not in use: ${status.reason}`);
|
|
323
327
|
}
|
|
324
328
|
|
|
325
|
-
if (desktopOnly) throw new Error(`Codex Desktop relay is unavailable: ${status.reason ?? "no native acknowledgement"}. Desktop-only mode will not start or use an external app-server.`);
|
|
329
|
+
if (desktopOnly) throw Object.assign(new Error(`Codex Desktop relay is unavailable: ${status.reason ?? "no native acknowledgement"}. Desktop-only mode will not start or use an external app-server.`), { code: "RELAY_UNREACHABLE", sent: false });
|
|
326
330
|
if (!codex) throw new Error("No Codex app-server client is configured to deliver this message");
|
|
327
331
|
const send = async () => {
|
|
328
332
|
await codex.ensureThreadAttached(threadId);
|