@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.
@@ -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
+ }
@@ -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(() => reject(new Error("The Desktop response deadline elapsed. A previous operation may still be running; inspect the existing task before sending anything again.")), Math.max(1, deadline - this.now()));
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);