@minhspark/codex-mcp-bridge 1.13.9 → 1.15.1

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.
@@ -6,6 +6,7 @@ import os from "node:os";
6
6
  import path from "node:path";
7
7
 
8
8
  import { homeDir, IS_WINDOWS } from "./platform.mjs";
9
+ import { cloneReloadState } from "./reload-control.mjs";
9
10
 
10
11
  const SOCKET_DIR = "/tmp/cc-socks";
11
12
 
@@ -366,6 +367,10 @@ export class PeerEndpoint {
366
367
  this.sentMessages = new Map();
367
368
  this.pendingMessages = new Map();
368
369
  this.responsePoll = null;
370
+ this.connections = new Set();
371
+ this.activityRevision = 0;
372
+ this.reloadPaused = false;
373
+ this.processHandlersRegistered = false;
369
374
  }
370
375
 
371
376
  /**
@@ -396,6 +401,7 @@ export class PeerEndpoint {
396
401
  }
397
402
 
398
403
  async start() {
404
+ if (this.reloadPaused) throw new Error("The peer endpoint is quiesced for reload");
399
405
  if (this.started) return;
400
406
  this.#sweepDeadBridges();
401
407
  const procStart = readProcessStart(this.pid);
@@ -432,24 +438,33 @@ export class PeerEndpoint {
432
438
  fs.writeFileSync(this.registryPath, JSON.stringify(this.registry), { mode: 0o600 });
433
439
  fs.writeFileSync(this.keyPath, JSON.stringify({ peerToken, ...processIdentity }), { mode: 0o600 });
434
440
 
435
- for (const signal of ["SIGINT", "SIGTERM"]) {
436
- process.on(signal, () => {
437
- this.stop();
438
- process.exit(0);
439
- });
441
+ if (!this.processHandlersRegistered) {
442
+ for (const signal of ["SIGINT", "SIGTERM"]) {
443
+ process.on(signal, () => {
444
+ this.stop();
445
+ process.exit(0);
446
+ });
447
+ }
448
+ process.on("exit", () => this.stop());
449
+ this.processHandlersRegistered = true;
440
450
  }
441
- process.on("exit", () => this.stop());
442
451
 
443
452
  this.started = true;
444
453
  this.log(`peer "${this.name}" listening on ${this.socketPath}`);
445
454
  }
446
455
 
447
456
  #handleConnection(socket) {
457
+ this.activityRevision += 1;
458
+ this.connections.add(socket);
459
+ socket.once("close", () => this.connections.delete(socket));
460
+ if (this.reloadPaused) { socket.destroy(); return; }
448
461
  let buffer = "";
449
462
  let authenticated = false;
450
463
  let firstLine = true;
451
464
  socket.setEncoding("utf8");
452
465
  socket.on("data", (chunk) => {
466
+ this.activityRevision += 1;
467
+ if (this.reloadPaused) { socket.destroy(); return; }
453
468
  buffer += chunk;
454
469
  if (buffer.length > 1048576) { socket.destroy(); return; }
455
470
  let index;
@@ -502,6 +517,7 @@ export class PeerEndpoint {
502
517
  }
503
518
 
504
519
  #receiveMessage(message) {
520
+ this.activityRevision += 1;
505
521
  const record = { ...message, receivedAt: Date.now(), sequence: ++this.messageSequence };
506
522
  this.inbox.push(record);
507
523
  const key = record.inReplyTo ?? [...this.pendingMessages].find(([, entry]) => entry.targetSocket === record.fromSocket)?.[0];
@@ -512,11 +528,12 @@ export class PeerEndpoint {
512
528
  if (sent) {
513
529
  record.inReplyTo = key;
514
530
  record.replyThreadId = sent.replyThreadId ?? null;
531
+ record.accountContext = sent.accountContext ?? null;
515
532
  sent.reply = record;
516
533
  }
517
534
  this.#removePendingReply(record.fromSocket, key);
518
535
  }
519
- this.log(`inbox <- ${record.fromSocket ?? "?"}: ${record.text.slice(0, 120)}`);
536
+ this.log(`inbox <- ${record.fromSocket ?? "?"}: ${record.text.length} characters`);
520
537
  for (const listener of [...this.listeners]) {
521
538
  try { listener(record); }
522
539
  catch (err) { this.log(`peer listener error: ${err.message}`); }
@@ -564,6 +581,7 @@ export class PeerEndpoint {
564
581
  }
565
582
 
566
583
  async send(targetSocket, text, { priority = "next", msgId, permissionMode = this.permissionMode, beforeSend } = {}) {
584
+ if (this.reloadPaused) throw new Error("The peer endpoint is quiesced; no message was sent");
567
585
  const frame = buildFrame({ text, fromSocket: this.socketPath, priority, permissionMode });
568
586
  if (msgId) frame.uuid = frame.msg_id = msgId;
569
587
  const token = readPeerToken(targetSocket);
@@ -573,6 +591,8 @@ export class PeerEndpoint {
573
591
  try {
574
592
  await new Promise((resolve, reject) => {
575
593
  const client = net.connect({ path: targetSocket });
594
+ this.connections.add(client);
595
+ client.once("close", () => this.connections.delete(client));
576
596
  let connected = false;
577
597
  let writeStarted = false;
578
598
  let settled = false;
@@ -626,7 +646,7 @@ export class PeerEndpoint {
626
646
  return frame.msg_id;
627
647
  }
628
648
 
629
- async sendAndWait(targetSocket, text, { timeoutMs = 120000, priority = "next", transcriptSession, beforeSend, permissionMode = this.permissionMode, replyThreadId, senderReview, senderApprovalPolicy, recipient } = {}) {
649
+ async sendAndWait(targetSocket, text, { timeoutMs = 120000, priority = "next", transcriptSession, beforeSend, permissionMode = this.permissionMode, replyThreadId, senderReview, senderApprovalPolicy, recipient, accountContext } = {}) {
630
650
  const previous = this.requestQueues.get(targetSocket) ?? Promise.resolve();
631
651
  const pending = previous.catch(() => {}).then(async () => {
632
652
  await beforeSend?.();
@@ -644,7 +664,7 @@ export class PeerEndpoint {
644
664
  this.unconfirmedReplies.set(targetSocket, unconfirmed + 1);
645
665
  let msgId = crypto.randomUUID();
646
666
  this.pendingMessages.set(msgId, { targetSocket, transcriptSession });
647
- this.sentMessages.set(msgId, { targetSocket, transcriptSession, sentAt: since, replyThreadId, permissionMode, senderApprovalPolicy, ...(senderReview ? { senderReview: { ...senderReview } } : {}), ...(recipient ? { recipient: { ...recipient } } : {}) });
667
+ this.sentMessages.set(msgId, { targetSocket, transcriptSession, sentAt: since, replyThreadId, permissionMode, senderApprovalPolicy, ...(senderReview ? { senderReview: { ...senderReview } } : {}), ...(recipient ? { recipient: { ...recipient } } : {}), ...(accountContext ? { accountContext: Object.freeze({ ...accountContext }) } : {}) });
648
668
  if (transcriptSession && !this.responsePoll) {
649
669
  this.responsePoll = globalThis.setInterval(() => this.#refreshTranscriptReplies(), 250);
650
670
  this.responsePoll.unref();
@@ -726,9 +746,15 @@ export class PeerEndpoint {
726
746
  });
727
747
  }
728
748
 
729
- drainInbox(limit = 20) {
749
+ drainInbox(limit = 20, accepts = () => true) {
730
750
  if (!Number.isSafeInteger(limit) || limit < 1) throw new Error("Inbox limit must be a positive integer");
731
- return this.inbox.splice(0, limit);
751
+ const selected = [];
752
+ this.inbox = this.inbox.filter((record) => {
753
+ if (selected.length >= limit || !accepts(record)) return true;
754
+ selected.push(record);
755
+ return false;
756
+ });
757
+ return selected;
732
758
  }
733
759
 
734
760
  readDelivery(msgId) {
@@ -751,11 +777,91 @@ export class PeerEndpoint {
751
777
  ...(sent.senderReview ? { senderReview: { ...sent.senderReview } } : {}),
752
778
  ...(sent.recipient ? { recipientPermissionMode: sent.recipient.permissionMode ?? null, recipientPermissionClass: sent.recipient.permissionClass ?? null, recipientInboundPolicy: sent.recipient.inboundPolicy ?? null } : {}),
753
779
  replyThreadId: sent.replyThreadId ?? null,
780
+ ...(sent.accountContext ? { accountContext: { ...sent.accountContext } } : {}),
754
781
  pending: this.pendingMessages.has(msgId),
755
782
  ...(sent.reply ? { reply: sent.reply.text, source: sent.reply.source ?? "peer", ...(sent.reply.absorbed ? { replyAbsorbed: true } : {}), ...(sent.reply.forwardingError ? { forwardingError: { ...sent.reply.forwardingError } } : {}) } : {}),
756
783
  };
757
784
  }
758
785
 
786
+ reloadReason() {
787
+ if (this.connections.size) return "Peer socket connections are still open";
788
+ if (this.requestQueues.size || this.pendingMessages.size || this.unconfirmedReplies.size) return "Claude messages still have pending or unconfirmed delivery";
789
+ for (const [id, sent] of this.sentMessages) {
790
+ if (!sent.reply && !sent.failed && !["refused", "denied", "expired", "dropped"].includes(this.deliveryReceipts.get(id)?.status)) {
791
+ return "A Claude delivery outcome remains unconfirmed";
792
+ }
793
+ }
794
+ return null;
795
+ }
796
+
797
+ async quiesce() {
798
+ const reason = this.reloadReason();
799
+ if (reason) throw new Error(reason);
800
+ const revision = this.activityRevision;
801
+ this.reloadPaused = true;
802
+ globalThis.clearInterval(this.responsePoll);
803
+ this.responsePoll = null;
804
+ if (this.server?.listening) {
805
+ await new Promise((resolve, reject) => this.server.close((error) => error ? reject(error) : resolve()));
806
+ }
807
+ this.started = false;
808
+ this.#removeRegistration();
809
+ if (revision !== this.activityRevision || this.reloadReason()) throw new Error("Peer activity raced with reload; the endpoint remains quiesced");
810
+ }
811
+
812
+ async resume() {
813
+ this.reloadPaused = false;
814
+ await this.start();
815
+ }
816
+
817
+ exportReloadState() {
818
+ const reason = this.reloadReason();
819
+ if (reason || this.started && !this.reloadPaused) throw new Error(reason ?? "Quiesce the peer endpoint before exporting state");
820
+ return cloneReloadState({ name: this.name, messageSequence: this.messageSequence, inbox: this.inbox,
821
+ sentMessages: [...this.sentMessages], deliveryReceipts: [...this.deliveryReceipts] });
822
+ }
823
+
824
+ restoreReloadState(input) {
825
+ if (this.started || this.connections.size || this.sentMessages.size || this.inbox.length) throw new Error("Peer reload state requires an empty dormant endpoint");
826
+ const state = cloneReloadState(input);
827
+ if (!state || typeof state.name !== "string" || !state.name || !Number.isSafeInteger(state.messageSequence) || state.messageSequence < 0
828
+ || !Array.isArray(state.inbox) || !Array.isArray(state.sentMessages) || !Array.isArray(state.deliveryReceipts)) throw new Error("Invalid peer reload state");
829
+ const restoreMap = (entries) => {
830
+ const restored = new Map();
831
+ for (const pair of entries) {
832
+ if (!Array.isArray(pair) || pair.length !== 2 || typeof pair[0] !== "string" || !pair[0] || restored.has(pair[0])
833
+ || !pair[1] || typeof pair[1] !== "object" || Array.isArray(pair[1])) throw new Error("Invalid peer reload record");
834
+ restored.set(pair[0], pair[1]);
835
+ }
836
+ return restored;
837
+ };
838
+ const sentMessages = restoreMap(state.sentMessages);
839
+ const deliveryReceipts = restoreMap(state.deliveryReceipts);
840
+ for (const record of state.inbox) {
841
+ if (!record || typeof record.text !== "string" || !Number.isSafeInteger(record.sequence) || record.sequence < 1
842
+ || record.sequence > state.messageSequence || !Number.isFinite(record.receivedAt)) throw new Error("Invalid inbox reload record");
843
+ }
844
+ for (const [id, sent] of sentMessages) {
845
+ if (typeof sent.targetSocket !== "string" || !sent.targetSocket || !Number.isFinite(sent.sentAt)
846
+ || sent.reply && (typeof sent.reply.text !== "string" || sent.reply.inReplyTo !== id)
847
+ || !sent.reply && !sent.failed && !["refused", "denied", "expired", "dropped"].includes(deliveryReceipts.get(id)?.status)) {
848
+ throw new Error("Invalid or unconfirmed Claude delivery reload record");
849
+ }
850
+ }
851
+ this.name = state.name;
852
+ this.messageSequence = state.messageSequence;
853
+ this.inbox = state.inbox;
854
+ this.sentMessages = sentMessages;
855
+ this.deliveryReceipts = deliveryReceipts;
856
+ }
857
+
858
+ #removeRegistration() {
859
+ for (const file of [this.socketPath, this.registryPath, this.keyPath]) {
860
+ if (!file) continue;
861
+ try { fs.rmSync(file, { force: true }); } catch {}
862
+ }
863
+ }
864
+
759
865
  stop() {
760
866
  globalThis.clearInterval(this.responsePoll);
761
867
  this.responsePoll = null;
@@ -763,12 +869,9 @@ export class PeerEndpoint {
763
869
  try {
764
870
  this.server?.close();
765
871
  } catch {}
766
- for (const file of [this.socketPath, this.registryPath, this.keyPath]) {
767
- if (!file) continue;
768
- try {
769
- fs.rmSync(file, { force: true });
770
- } catch {}
771
- }
872
+ for (const socket of this.connections) socket.destroy();
873
+ this.connections.clear();
874
+ this.#removeRegistration();
772
875
  this.started = false;
773
876
  }
774
877
  }
package/src/platform.mjs CHANGED
@@ -61,7 +61,10 @@ const FOREIGN_ROOT_PATTERNS = [
61
61
  * directory layout out of the source.
62
62
  */
63
63
  function bridgeParentDir() {
64
- const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
64
+ const sourceRoot = process.env.CODEX_BRIDGE_WORKER === "1" && process.env.CODEX_BRIDGE_SOURCE_ROOT;
65
+ const repoRoot = sourceRoot && path.isAbsolute(sourceRoot)
66
+ ? sourceRoot
67
+ : path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
65
68
  return path.dirname(repoRoot);
66
69
  }
67
70
 
@@ -0,0 +1,198 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ function digestFiles(root, files) {
7
+ const hash = createHash("sha256");
8
+ for (const file of files) hash.update(file).update("\0").update(fs.readFileSync(path.join(root, file))).update("\0");
9
+ return hash.digest("hex");
10
+ }
11
+
12
+ const DIGEST_FIELDS = ["dev", "ino", "size", "mtimeNs", "ctimeNs"];
13
+ const DIGEST_PATTERN = /^[a-f0-9]{64}$/;
14
+
15
+ function safeRelativePath(file) {
16
+ return typeof file === "string" && file.length > 0 && !file.includes("\0")
17
+ && !path.posix.isAbsolute(file) && !path.win32.parse(file).root
18
+ && path.normalize(file) === file && !file.split(/[\\/]/).some((part) => !part || part === "." || part === "..");
19
+ }
20
+
21
+ function digestMetadata(cache) {
22
+ const directories = new Map();
23
+ const dirty = new Set();
24
+ const metadataPath = (directory) => path.join(cache, `.digests-${createHash("sha256").update(directory).digest("hex")}.json`);
25
+ const signature = (stat) => Object.fromEntries(DIGEST_FIELDS.map((field) => [field, String(stat[field])]));
26
+ const matches = (left, right) => left && DIGEST_FIELDS.every((field) => left[field] === right[field]);
27
+ const load = (directory) => {
28
+ dirty.add(directory);
29
+ let descriptor;
30
+ try {
31
+ const file = metadataPath(directory);
32
+ const info = fs.lstatSync(file, { bigint: true });
33
+ if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1n || info.size > 32n * 1024n * 1024n) return new Map();
34
+ descriptor = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
35
+ const opened = fs.fstatSync(descriptor, { bigint: true });
36
+ if (!opened.isFile() || opened.nlink !== 1n || !matches(signature(info), signature(opened))) return new Map();
37
+ const metadata = JSON.parse(fs.readFileSync(descriptor, "utf8"));
38
+ if (metadata?.schema !== 1 || metadata.directory !== directory || !metadata.files || typeof metadata.files !== "object" || Array.isArray(metadata.files)) return new Map();
39
+ const entries = Object.entries(metadata.files);
40
+ if (entries.some(([file, record]) => !safeRelativePath(file) || !record || typeof record !== "object" || Array.isArray(record)
41
+ || typeof record.hash !== "string" || !DIGEST_PATTERN.test(record.hash)
42
+ || DIGEST_FIELDS.some((field) => typeof record[field] !== "string" || !/^-?\d+$/.test(record[field])))) return new Map();
43
+ dirty.delete(directory);
44
+ return new Map(entries);
45
+ } catch {
46
+ return new Map();
47
+ } finally {
48
+ if (descriptor !== undefined) fs.closeSync(descriptor);
49
+ }
50
+ };
51
+ return {
52
+ digest(directory, files) {
53
+ const previous = directories.get(directory) ?? load(directory);
54
+ const records = new Map();
55
+ const hash = createHash("sha256");
56
+ if (previous.size !== files.length) dirty.add(directory);
57
+ for (const file of files) {
58
+ if (!safeRelativePath(file)) throw new Error(`Unsafe release file: ${file}`);
59
+ const fullPath = path.join(directory, file);
60
+ const before = fs.statSync(fullPath, { bigint: true });
61
+ if (!before.isFile()) throw new Error(`Unsupported release file: ${file}`);
62
+ const current = signature(before);
63
+ let record = previous.get(file);
64
+ if (!matches(record, current)) {
65
+ dirty.add(directory);
66
+ const contentHash = createHash("sha256").update(fs.readFileSync(fullPath)).digest("hex");
67
+ const after = fs.statSync(fullPath, { bigint: true });
68
+ if (!after.isFile() || !matches(current, signature(after))) throw new Error("A release file changed during integrity verification");
69
+ record = { ...current, hash: contentHash };
70
+ }
71
+ records.set(file, record);
72
+ hash.update(file).update("\0").update(record.hash).update("\0");
73
+ }
74
+ directories.set(directory, records);
75
+ return hash.digest("hex");
76
+ },
77
+ relocate(from, to) {
78
+ if (directories.has(from)) {
79
+ directories.set(to, directories.get(from));
80
+ dirty.add(to);
81
+ }
82
+ directories.delete(from);
83
+ dirty.delete(from);
84
+ },
85
+ forget(directory) { directories.delete(directory); dirty.delete(directory); },
86
+ flush() {
87
+ for (const [directory, records] of directories) {
88
+ if (!dirty.has(directory)) continue;
89
+ const file = metadataPath(directory);
90
+ const temporary = path.join(cache, `.digests-${process.pid}-${randomUUID()}.tmp`);
91
+ try {
92
+ try {
93
+ const info = fs.lstatSync(file);
94
+ if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1) continue;
95
+ } catch (error) { if (error.code !== "ENOENT") continue; }
96
+ fs.writeFileSync(temporary, JSON.stringify({ schema: 1, directory, files: Object.fromEntries(records) }), { flag: "wx", mode: 0o600 });
97
+ fs.renameSync(temporary, file);
98
+ dirty.delete(directory);
99
+ } catch {
100
+ } finally {
101
+ try { fs.rmSync(temporary, { force: true }); } catch {}
102
+ }
103
+ }
104
+ },
105
+ };
106
+ }
107
+
108
+ function inventory(root, prefix = "") {
109
+ const files = [];
110
+ for (const entry of fs.readdirSync(path.join(root, prefix), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name, "en"))) {
111
+ if (entry.name === ".bin") continue;
112
+ const relative = path.join(prefix, entry.name);
113
+ const stat = entry.isSymbolicLink() ? fs.statSync(path.join(root, relative)) : entry;
114
+ if (stat.isDirectory()) files.push(...inventory(root, relative));
115
+ else if (stat.isFile()) files.push(relative);
116
+ else throw new Error(`Unsupported release file: ${relative}`);
117
+ }
118
+ return files;
119
+ }
120
+
121
+ export function sourceRevision(root) {
122
+ const files = ["package.json", ...inventory(root, "src")];
123
+ for (const file of ["package-lock.json", "node_modules/.package-lock.json"]) {
124
+ if (fs.existsSync(path.join(root, file))) files.push(file);
125
+ }
126
+ return digestFiles(root, files);
127
+ }
128
+
129
+ export function snapshotRoot(env = process.env) {
130
+ return path.resolve(env.CODEX_BRIDGE_RUNTIME_CACHE ?? path.join(env.CODEX_HOME ?? path.join(os.homedir(), ".codex"), "bridge-runtimes"));
131
+ }
132
+
133
+ function removeOwned(directory, cache) {
134
+ const relative = path.relative(cache, directory);
135
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) throw new Error("Refusing to remove an unowned runtime directory");
136
+ if (fs.existsSync(directory) && fs.lstatSync(directory).isSymbolicLink()) throw new Error("Refusing to remove a linked runtime directory");
137
+ fs.rmSync(directory, { recursive: true, force: true });
138
+ }
139
+
140
+ export function createReleaseSnapshot(root, { cache = snapshotRoot(), expectedRevision = sourceRevision(root) } = {}) {
141
+ root = fs.realpathSync.native(root);
142
+ if (fs.existsSync(cache) && fs.lstatSync(cache).isSymbolicLink()) throw new Error("The runtime cache cannot be a symbolic link");
143
+ const missing = [];
144
+ let ancestor = path.resolve(cache);
145
+ while (!fs.existsSync(ancestor)) {
146
+ missing.unshift(path.basename(ancestor));
147
+ const parent = path.dirname(ancestor);
148
+ if (parent === ancestor) throw new Error("The runtime cache has no accessible filesystem root");
149
+ ancestor = parent;
150
+ }
151
+ cache = path.join(fs.realpathSync.native(ancestor), ...missing);
152
+ fs.mkdirSync(cache, { recursive: true, mode: 0o700 });
153
+ if (fs.lstatSync(cache).isSymbolicLink()) throw new Error("The runtime cache cannot be a symbolic link");
154
+ cache = fs.realpathSync.native(cache);
155
+ const metadata = digestMetadata(cache);
156
+ const dependencies = inventory(root, "node_modules");
157
+ const dependencyRevision = metadata.digest(root, dependencies);
158
+ const key = createHash("sha256").update(expectedRevision).update(dependencyRevision).digest("hex");
159
+ const target = path.join(cache, key);
160
+ const validate = (directory) => {
161
+ if (fs.lstatSync(directory).isSymbolicLink()) throw new Error("The release directory cannot be a symbolic link");
162
+ return sourceRevision(directory) === expectedRevision && metadata.digest(directory, inventory(directory, "node_modules")) === dependencyRevision;
163
+ };
164
+ if (fs.existsSync(target)) {
165
+ if (!validate(target)) throw new Error("An existing immutable runtime failed integrity verification");
166
+ if (sourceRevision(root) !== expectedRevision) throw new Error("The installation changed during snapshot verification");
167
+ metadata.flush();
168
+ return { directory: target, revision: expectedRevision, key };
169
+ }
170
+ const temporary = path.join(cache, `.preparing-${process.pid}-${randomUUID()}`);
171
+ try {
172
+ fs.mkdirSync(temporary, { mode: 0o700 });
173
+ const files = ["package.json", ...inventory(root, "src"), ...dependencies];
174
+ if (fs.existsSync(path.join(root, "package-lock.json"))) files.push("package-lock.json");
175
+ const directories = new Set(["src", "node_modules", ...files.map((file) => path.dirname(file))]);
176
+ for (const directory of directories) fs.mkdirSync(path.join(temporary, directory), { recursive: true, mode: 0o700 });
177
+ for (const file of files) {
178
+ fs.copyFileSync(path.join(root, file), path.join(temporary, file), fs.constants.COPYFILE_FICLONE);
179
+ }
180
+ if (sourceRevision(root) !== expectedRevision || metadata.digest(root, inventory(root, "node_modules")) !== dependencyRevision || !validate(temporary)) {
181
+ throw new Error("The installation changed while its immutable runtime was being prepared");
182
+ }
183
+ try {
184
+ fs.renameSync(temporary, target);
185
+ metadata.relocate(temporary, target);
186
+ }
187
+ catch (error) {
188
+ if (!fs.existsSync(target) || !validate(target)) throw error;
189
+ removeOwned(temporary, cache);
190
+ metadata.forget(temporary);
191
+ }
192
+ metadata.flush();
193
+ return { directory: target, revision: expectedRevision, key };
194
+ } catch (error) {
195
+ removeOwned(temporary, cache);
196
+ throw error;
197
+ }
198
+ }
@@ -0,0 +1,102 @@
1
+ export function assertRoutingReload(previous, next) {
2
+ if (typeof previous !== "boolean" || typeof next !== "boolean" || previous && !next) {
3
+ throw new Error("Invalid routing reload or Desktop-to-app-server downgrade; reconnect explicitly to change to legacy mode");
4
+ }
5
+ }
6
+
7
+ export function cloneReloadState(value) {
8
+ const encoded = JSON.stringify(value);
9
+ if (typeof encoded !== "string" || Buffer.byteLength(encoded) > 16 * 1024 * 1024) {
10
+ throw new Error("Reload state is missing or exceeds its transfer limit");
11
+ }
12
+ return JSON.parse(encoded);
13
+ }
14
+
15
+ export function clientReloadReason(client) {
16
+ if (!client) return null;
17
+ for (const field of ["pending", "threadOperations", "attachingThreads", "activeTurns", "threadListeners"]) {
18
+ if (client[field]?.size) return `The app-server still has ${field}`;
19
+ }
20
+ if (client.connecting) return "The app-server connection is still being established";
21
+ return null;
22
+ }
23
+
24
+ export function createReloadControl({ entry, inspect = () => null, quiesce = () => {}, exportState = () => ({}), restore = () => {}, activate = () => {}, resume = activate, env = process.env, channel = process } = {}) {
25
+ if (!["index.mjs", "claude-bridge.mjs", "native-relay-companion.mjs"].includes(entry)) throw new Error("Invalid reload entry point");
26
+ const enabled = env.CODEX_BRIDGE_WORKER === "1" && typeof channel.send === "function";
27
+ let phase = enabled && env.CODEX_BRIDGE_STAGED === "1" ? "staged" : "active";
28
+ let active = 0;
29
+ let deferred = null;
30
+ let listening = false;
31
+ let commands = Promise.resolve();
32
+
33
+ const status = () => {
34
+ const reason = active ? "MCP tool calls are still active" : deferred ?? inspect();
35
+ return { reloadable: phase === "active" && !reason, reason: reason ?? (phase === "active" ? null : `The worker is ${phase}`), schema: 1, phase };
36
+ };
37
+
38
+ const control = async (action, data) => {
39
+ if (action === "inspect") return status();
40
+ if (action === "quiesce") {
41
+ const current = status();
42
+ if (!current.reloadable) throw new Error(current.reason);
43
+ phase = "quiescing";
44
+ try {
45
+ await quiesce();
46
+ const reason = deferred ?? inspect();
47
+ if (active || reason) throw new Error(reason ?? "A tool call raced with quiescence");
48
+ const state = cloneReloadState({ schema: 1, entry, payload: exportState() });
49
+ phase = "quiesced";
50
+ return { state };
51
+ } catch (error) {
52
+ phase = "quiesced";
53
+ throw error;
54
+ }
55
+ }
56
+ if (action === "restore") {
57
+ if (phase !== "staged") throw new Error("Reload state can only be restored into a staged worker");
58
+ const state = cloneReloadState(data);
59
+ if (!state || state.schema !== 1 || state.entry !== entry || !state.payload || typeof state.payload !== "object" || Array.isArray(state.payload)) {
60
+ throw new Error("Reload state schema or entry point does not match this worker");
61
+ }
62
+ await restore(state.payload);
63
+ return { restored: true, schema: 1 };
64
+ }
65
+ if (action === "activate" || action === "resume") {
66
+ if (phase === "active") return { active: true, schema: 1 };
67
+ if (action === "activate" && phase !== "staged" || action === "resume" && phase !== "quiesced") {
68
+ throw new Error(`Cannot ${action} a ${phase} worker`);
69
+ }
70
+ await (action === "activate" ? activate() : resume());
71
+ phase = "active";
72
+ return { active: true, schema: 1 };
73
+ }
74
+ throw new Error("Unknown bridge control action");
75
+ };
76
+
77
+ return {
78
+ get staged() { return phase === "staged"; },
79
+ inspect: status,
80
+ defer(reason) { deferred ??= String(reason); },
81
+ async run(handler) {
82
+ if (phase !== "active") throw new Error(`Bridge worker is ${phase}; no tool operation was started`);
83
+ active += 1;
84
+ try { return await handler(); }
85
+ finally { active -= 1; }
86
+ },
87
+ control,
88
+ listen() {
89
+ if (!enabled || listening) return;
90
+ listening = true;
91
+ channel.on("message", (message) => {
92
+ if (message?.type !== "bridge:control" || !["string", "number"].includes(typeof message.id)) return;
93
+ commands = commands.catch(() => {}).then(async () => {
94
+ let response;
95
+ try { response = { result: await control(message.action, message.data) }; }
96
+ catch (error) { response = { error: error?.message ?? String(error) }; }
97
+ if (channel.connected !== false) channel.send({ type: "bridge:control-result", id: message.id, ...response });
98
+ });
99
+ });
100
+ },
101
+ };
102
+ }
@@ -1,3 +1,5 @@
1
+ import { cloneReloadState } from "./reload-control.mjs";
2
+
1
3
  const KNOWN_UNSENT_CODES = new Set([
2
4
  "RELAY_UNREACHABLE",
3
5
  "RELAY_MESSAGE_TOO_LARGE",
@@ -97,6 +99,47 @@ export class ReplyForwarder {
97
99
  return { ...counts, attempts: this.attempts, maxPerSession: this.maxPerSession, closed: this.closed };
98
100
  }
99
101
 
102
+ reloadReason() {
103
+ if (this.active || this.queue.length || this.timer !== null) return "Reply forwarding is still active or queued";
104
+ if ([...this.records.values()].some(({ receipt }) => ["queued", "sending", "unknown"].includes(receipt.status))) {
105
+ return "A reply forwarding outcome remains unconfirmed";
106
+ }
107
+ return null;
108
+ }
109
+
110
+ exportReloadState() {
111
+ const reason = this.reloadReason();
112
+ if (reason) throw new Error(reason);
113
+ return cloneReloadState({ records: [...this.records], attempts: this.attempts, lastAt: this.lastAt, closed: this.closed,
114
+ maxPerSession: this.maxPerSession, minIntervalMs: this.minIntervalMs });
115
+ }
116
+
117
+ restoreReloadState(input) {
118
+ if (this.records.size || this.active || this.queue.length || this.timer !== null) throw new Error("Reply forwarding state is not empty");
119
+ const state = cloneReloadState(input);
120
+ if (!state || !Array.isArray(state.records) || !Number.isSafeInteger(state.attempts) || state.attempts < 0
121
+ || !Number.isSafeInteger(state.maxPerSession) || state.maxPerSession < 1 || state.attempts > state.maxPerSession
122
+ || !Number.isFinite(state.minIntervalMs) || state.minIntervalMs < 0 || typeof state.closed !== "boolean"
123
+ || state.lastAt !== null && (!Number.isFinite(state.lastAt) || state.lastAt < 0)) throw new Error("Invalid reply forwarding reload state");
124
+ const records = new Map();
125
+ for (const pair of state.records) {
126
+ if (!Array.isArray(pair) || pair.length !== 2) throw new Error("Invalid reply forwarding record");
127
+ const [id, entry] = pair;
128
+ if (!validIdentity(id) || records.has(id) || !entry?.record || !entry.receipt
129
+ || entry.receipt.msgId !== id || !validIdentity(entry.receipt.threadId)
130
+ || entry.record.replyThreadId !== entry.receipt.threadId || (entry.record.inReplyTo ?? entry.record.msgId) !== id
131
+ || typeof entry.record.text !== "string" || !entry.record.text.trim()
132
+ || !["forwarded", "failed", "blocked"].includes(entry.receipt.status)) throw new Error("Invalid or unconfirmed reply forwarding record");
133
+ records.set(id, { record: Object.freeze(entry.record), receipt: entry.receipt });
134
+ }
135
+ this.records = records;
136
+ this.attempts = state.attempts;
137
+ this.lastAt = state.lastAt;
138
+ this.closed = state.closed;
139
+ this.maxPerSession = Math.min(this.maxPerSession, state.maxPerSession);
140
+ this.minIntervalMs = Math.max(this.minIntervalMs, state.minIntervalMs);
141
+ }
142
+
100
143
  close() {
101
144
  this.closed = true;
102
145
  if (this.timer !== null) this.cancel(this.timer);