@minhspark/codex-mcp-bridge 1.13.8 → 1.15.0
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 +40 -1
- package/README.md +37 -18
- package/package.json +6 -6
- package/scripts/install-claude-desktop.mjs +12 -3
- package/scripts/install-codex-mcp.mjs +8 -5
- package/scripts/install-native-relay.mjs +15 -9
- package/src/bridge-account-context.mjs +77 -0
- package/src/claude-bridge-launcher.mjs +3 -0
- package/src/claude-bridge.mjs +90 -28
- package/src/claude-desktop-context.mjs +11 -6
- package/src/claude-sender-context.mjs +206 -0
- package/src/claude-session-router.mjs +92 -0
- package/src/codex-account-context.mjs +116 -0
- package/src/codex-bridge-launcher.mjs +3 -0
- package/src/codex-mcp-registration.mjs +14 -9
- package/src/desktop-account-context.mjs +140 -0
- package/src/desktop-task-receipts.mjs +3 -1
- package/src/index.mjs +86 -8
- package/src/mcp-supervisor.mjs +314 -0
- package/src/native-relay-companion.mjs +169 -34
- package/src/native-relay-launcher.mjs +3 -0
- package/src/native-relay.mjs +119 -47
- package/src/peer-protocol.mjs +124 -21
- package/src/platform.mjs +4 -1
- package/src/release-snapshot.mjs +95 -0
- package/src/reload-control.mjs +96 -0
- package/src/reply-forwarder.mjs +43 -0
- package/src/thread-delivery.mjs +27 -11
package/src/native-relay.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { execFile } from "node:child_process";
|
|
|
6
6
|
import { promisify } from "node:util";
|
|
7
7
|
|
|
8
8
|
import { IS_MACOS, IS_WINDOWS, PLATFORM_LABEL, homeDir } from "./platform.mjs";
|
|
9
|
+
import { assertAccountIdentity } from "./bridge-account-context.mjs";
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* The Codex Desktop app owns the per-thread writer lock of every thread it has
|
|
@@ -35,6 +36,7 @@ import { IS_MACOS, IS_WINDOWS, PLATFORM_LABEL, homeDir } from "./platform.mjs";
|
|
|
35
36
|
export const NATIVE_DISPATCH_METHOD = "tools/call";
|
|
36
37
|
|
|
37
38
|
export const RELAY_PROTOCOL_VERSION = 1;
|
|
39
|
+
export const ACCOUNT_RELAY_PROTOCOL_VERSION = 2;
|
|
38
40
|
|
|
39
41
|
/**
|
|
40
42
|
* A relay frame carries one chat message, so a megabyte-scale line is either a
|
|
@@ -70,6 +72,28 @@ export function desktopTaskSocketPath(env = process.env) {
|
|
|
70
72
|
return env.CODEX_NATIVE_RELAY_SOCKET ?? `${relaySocketPath(env)}-desktop-tasks`;
|
|
71
73
|
}
|
|
72
74
|
|
|
75
|
+
export function accountRelaySocketPath(env = process.env) {
|
|
76
|
+
return `${relaySocketPath(env)}-accounts-v2`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function relayAccountContext(value) {
|
|
80
|
+
if (value === undefined) return undefined;
|
|
81
|
+
if (!exactObject(value, ["claude", "codex"]) || Object.keys(value).length !== 2 || [value.claude, value.codex].some((item) => typeof item !== "string" || !/^[a-f0-9]{64}$/.test(item))) {
|
|
82
|
+
throw beforeWriteError(new NativeRelayError("A bound relay request requires exact Claude and Codex account fingerprints", "RELAY_BAD_REQUEST"));
|
|
83
|
+
}
|
|
84
|
+
return Object.freeze({ claude: value.claude, codex: value.codex });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function beforeWriteError(error) {
|
|
88
|
+
const failure = new NativeRelayError(error?.message ?? "Relay preflight failed before dispatch", typeof error?.code === "string" ? error.code : "RELAY_PREFLIGHT_FAILED", { reachedCompanion: false });
|
|
89
|
+
failure.sent = false;
|
|
90
|
+
return failure;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function validateBeforeSend(beforeSend) {
|
|
94
|
+
if (beforeSend !== undefined && typeof beforeSend !== "function") throw beforeWriteError(new NativeRelayError("Relay beforeSend must be a function", "RELAY_BAD_REQUEST"));
|
|
95
|
+
}
|
|
96
|
+
|
|
73
97
|
export function desktopTasksConfigured(env = process.env) {
|
|
74
98
|
return env.CODEX_BRIDGE_DESKTOP_TASKS !== undefined
|
|
75
99
|
? env.CODEX_BRIDGE_DESKTOP_TASKS === "1"
|
|
@@ -409,11 +433,14 @@ export async function resolveNativeToolsPipePath({
|
|
|
409
433
|
}
|
|
410
434
|
|
|
411
435
|
export class NativeToolsClient {
|
|
412
|
-
constructor({ env = process.env, socketPath = env.CODEX_APP_TOOLS_PIPE_PATH, timeoutMs = DEFAULT_TIMEOUT_MS, resolveSocketPath = () => resolveNativeToolsPipePath({ env }) } = {}) {
|
|
436
|
+
constructor({ env = process.env, socketPath = env.CODEX_APP_TOOLS_PIPE_PATH, timeoutMs = DEFAULT_TIMEOUT_MS, resolveSocketPath = () => resolveNativeToolsPipePath({ env }), assertAccount = assertAccountIdentity } = {}) {
|
|
413
437
|
this.env = env;
|
|
414
438
|
this.socketPath = socketPath;
|
|
439
|
+
this.explicitSocketPath = socketPath || null;
|
|
440
|
+
this.hasDiscoveredSocket = false;
|
|
415
441
|
this.timeoutMs = timeoutMs;
|
|
416
442
|
this.resolveSocketPath = resolveSocketPath;
|
|
443
|
+
this.assertAccount = assertAccount;
|
|
417
444
|
this.socket = null;
|
|
418
445
|
this.connectingSocket = null;
|
|
419
446
|
this.connecting = null;
|
|
@@ -432,6 +459,7 @@ export class NativeToolsClient {
|
|
|
432
459
|
throw new NativeRelayError("Native tools client closed while discovering the Desktop pipe", "NATIVE_PIPE_UNAVAILABLE");
|
|
433
460
|
}
|
|
434
461
|
this.socketPath = socketPath;
|
|
462
|
+
if (socketPath && !this.explicitSocketPath) this.hasDiscoveredSocket = true;
|
|
435
463
|
if (!this.socketPath) {
|
|
436
464
|
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");
|
|
437
465
|
}
|
|
@@ -440,14 +468,17 @@ export class NativeToolsClient {
|
|
|
440
468
|
this.connectingSocket = socket;
|
|
441
469
|
let buffer = Buffer.alloc(0);
|
|
442
470
|
let connected = false;
|
|
471
|
+
let failed = false;
|
|
443
472
|
const timer = globalThis.setTimeout(() => {
|
|
444
|
-
|
|
445
|
-
socket.destroy();
|
|
473
|
+
fail(new NativeRelayError("Timed out connecting to the Codex Desktop native tools pipe", "NATIVE_PIPE_UNAVAILABLE"));
|
|
446
474
|
}, this.timeoutMs);
|
|
447
475
|
const fail = (err) => {
|
|
476
|
+
if (failed) return;
|
|
477
|
+
failed = true;
|
|
448
478
|
globalThis.clearTimeout(timer);
|
|
449
479
|
if (!connected) reject(err);
|
|
450
480
|
if (this.socket === socket) this.socket = null;
|
|
481
|
+
if (!this.explicitSocketPath && this.connectionGeneration === generation) this.socketPath = null;
|
|
451
482
|
for (const pending of this.pending.values()) {
|
|
452
483
|
if (pending.socket === socket) pending.reject(err);
|
|
453
484
|
}
|
|
@@ -503,15 +534,17 @@ export class NativeToolsClient {
|
|
|
503
534
|
}
|
|
504
535
|
}
|
|
505
536
|
|
|
506
|
-
async dispatch(args) {
|
|
507
|
-
return this.#request(nativeDispatchParams(args));
|
|
537
|
+
async dispatch(args, options) {
|
|
538
|
+
return this.#request(nativeDispatchParams(args), options);
|
|
508
539
|
}
|
|
509
540
|
|
|
510
|
-
async dispatchDesktop(args) {
|
|
511
|
-
return this.#request(nativeDesktopOperationParams(args));
|
|
541
|
+
async dispatchDesktop(args, options) {
|
|
542
|
+
return this.#request(nativeDesktopOperationParams(args), options);
|
|
512
543
|
}
|
|
513
544
|
|
|
514
|
-
async #request(params) {
|
|
545
|
+
async #request(params, { beforeSend, accountContext } = {}) {
|
|
546
|
+
validateBeforeSend(beforeSend);
|
|
547
|
+
const accounts = relayAccountContext(accountContext);
|
|
515
548
|
const id = this.nextId++;
|
|
516
549
|
const payload = Buffer.from(JSON.stringify({
|
|
517
550
|
jsonrpc: "2.0",
|
|
@@ -522,31 +555,50 @@ export class NativeToolsClient {
|
|
|
522
555
|
if (payload.length > MAX_FRAME_BYTES) {
|
|
523
556
|
throw new NativeRelayError("Native dispatch exceeds the frame limit", "RELAY_MESSAGE_TOO_LARGE");
|
|
524
557
|
}
|
|
525
|
-
|
|
558
|
+
try {
|
|
559
|
+
await this.connect();
|
|
560
|
+
} catch (error) {
|
|
561
|
+
throw beforeWriteError(error);
|
|
562
|
+
}
|
|
526
563
|
const header = Buffer.alloc(4);
|
|
527
564
|
header.writeUInt32LE(payload.length);
|
|
528
565
|
return new Promise((resolve, reject) => {
|
|
566
|
+
const socket = this.socket;
|
|
567
|
+
let dispatched = false;
|
|
529
568
|
const finish = (fn, value) => {
|
|
530
569
|
if (!this.pending.delete(id)) return;
|
|
531
570
|
globalThis.clearTimeout(timer);
|
|
532
571
|
fn(value);
|
|
533
572
|
};
|
|
534
573
|
const timer = globalThis.setTimeout(() => {
|
|
535
|
-
finish(reject,
|
|
574
|
+
finish(reject, dispatched
|
|
575
|
+
? new NativeRelayError("Codex Desktop native dispatch timed out; delivery may have occurred", "NATIVE_DELIVERY_UNCONFIRMED")
|
|
576
|
+
: beforeWriteError(new NativeRelayError("Codex Desktop native send deadline elapsed before dispatch", "NATIVE_PIPE_UNAVAILABLE")));
|
|
536
577
|
}, this.timeoutMs);
|
|
537
578
|
this.pending.set(id, {
|
|
538
|
-
socket
|
|
579
|
+
socket,
|
|
539
580
|
resolve: (value) => finish(resolve, value),
|
|
540
|
-
reject: (err) => finish(reject, err),
|
|
581
|
+
reject: (err) => finish(reject, dispatched ? err : beforeWriteError(err)),
|
|
541
582
|
});
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
if (
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
583
|
+
void (async () => {
|
|
584
|
+
try {
|
|
585
|
+
const checked = beforeSend?.();
|
|
586
|
+
if (checked && typeof checked.then === "function") await checked;
|
|
587
|
+
if (!this.pending.has(id)) return;
|
|
588
|
+
if (accounts) {
|
|
589
|
+
const verified = this.assertAccount(accounts);
|
|
590
|
+
if (verified && typeof verified.then === "function") await verified;
|
|
591
|
+
}
|
|
592
|
+
if (!this.pending.has(id)) return;
|
|
593
|
+
if (!socket || socket.destroyed || this.socket !== socket) throw new NativeRelayError("Native tools pipe closed before dispatch", "NATIVE_PIPE_UNAVAILABLE");
|
|
594
|
+
dispatched = true;
|
|
595
|
+
socket.write(Buffer.concat([header, payload]), (err) => {
|
|
596
|
+
if (err) finish(reject, new NativeRelayError(`Native dispatch write failed: ${err.message}`, "NATIVE_DELIVERY_UNCONFIRMED"));
|
|
597
|
+
});
|
|
598
|
+
} catch (err) {
|
|
599
|
+
finish(reject, dispatched ? new NativeRelayError(`Native dispatch write failed: ${err.message}`, "NATIVE_DELIVERY_UNCONFIRMED") : beforeWriteError(err));
|
|
600
|
+
}
|
|
601
|
+
})();
|
|
550
602
|
});
|
|
551
603
|
}
|
|
552
604
|
|
|
@@ -554,6 +606,7 @@ export class NativeToolsClient {
|
|
|
554
606
|
this.connectionGeneration++;
|
|
555
607
|
const socket = this.socket;
|
|
556
608
|
this.socket = null;
|
|
609
|
+
if (!this.explicitSocketPath) this.socketPath = null;
|
|
557
610
|
for (const pending of this.pending.values()) {
|
|
558
611
|
pending.reject(new NativeRelayError("Native tools client closed before confirming delivery", "NATIVE_DELIVERY_UNCONFIRMED"));
|
|
559
612
|
}
|
|
@@ -603,9 +656,10 @@ export function nativeRelayStatus(env = process.env) {
|
|
|
603
656
|
* socket cannot go stale while Codex Desktop restarts underneath it.
|
|
604
657
|
*/
|
|
605
658
|
export class NativeDesktopRelay {
|
|
606
|
-
constructor({ env = process.env, socketPath = null, timeoutMs = DEFAULT_TIMEOUT_MS, log = () => {} } = {}) {
|
|
659
|
+
constructor({ env = process.env, socketPath = null, accountSocketPath = null, timeoutMs = DEFAULT_TIMEOUT_MS, log = () => {} } = {}) {
|
|
607
660
|
this.env = env;
|
|
608
661
|
this.explicitSocketPath = socketPath;
|
|
662
|
+
this.accountSocketPath = accountSocketPath ?? socketPath ?? accountRelaySocketPath(env);
|
|
609
663
|
this.timeoutMs = timeoutMs;
|
|
610
664
|
this.log = log;
|
|
611
665
|
}
|
|
@@ -614,27 +668,31 @@ export class NativeDesktopRelay {
|
|
|
614
668
|
return this.explicitSocketPath ?? relaySocketPath(this.env);
|
|
615
669
|
}
|
|
616
670
|
|
|
617
|
-
status() {
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
671
|
+
status({ accountContext } = {}) {
|
|
672
|
+
const socketPath = accountContext ? this.accountSocketPath : this.explicitSocketPath;
|
|
673
|
+
if (!socketPath) return nativeRelayStatus(this.env);
|
|
674
|
+
const status = nativeRelayStatus({ ...this.env, CODEX_NATIVE_RELAY_SOCKET: socketPath });
|
|
675
|
+
return { ...status, socketPath };
|
|
621
676
|
}
|
|
622
677
|
|
|
623
678
|
get available() {
|
|
624
679
|
return this.status().enabled;
|
|
625
680
|
}
|
|
626
681
|
|
|
627
|
-
async sendMessage(targetThreadId, message, { timeoutMs = this.timeoutMs } = {}) {
|
|
628
|
-
const
|
|
629
|
-
|
|
682
|
+
async sendMessage(targetThreadId, message, { timeoutMs = this.timeoutMs, beforeSend, accountContext } = {}) {
|
|
683
|
+
const accounts = relayAccountContext(accountContext);
|
|
684
|
+
const request = { v: accounts ? ACCOUNT_RELAY_PROTOCOL_VERSION : RELAY_PROTOCOL_VERSION, targetThreadId, message, ...(accounts ? { accountContext: accounts } : {}) };
|
|
685
|
+
return this.#request(request, timeoutMs, beforeSend);
|
|
630
686
|
}
|
|
631
687
|
|
|
632
|
-
async requestDesktop(operation, args, { timeoutMs = this.timeoutMs } = {}) {
|
|
688
|
+
async requestDesktop(operation, args, { timeoutMs = this.timeoutMs, beforeSend, accountContext } = {}) {
|
|
633
689
|
validateDesktopOperation(operation, args);
|
|
634
|
-
|
|
690
|
+
const accounts = relayAccountContext(accountContext);
|
|
691
|
+
return this.#request({ v: accounts ? ACCOUNT_RELAY_PROTOCOL_VERSION : RELAY_PROTOCOL_VERSION, operation, arguments: args, ...(accounts ? { accountContext: accounts } : {}) }, timeoutMs, beforeSend);
|
|
635
692
|
}
|
|
636
693
|
|
|
637
|
-
async #request(request, timeoutMs) {
|
|
694
|
+
async #request(request, timeoutMs, beforeSend) {
|
|
695
|
+
validateBeforeSend(beforeSend);
|
|
638
696
|
const line = `${JSON.stringify(request)}\n`;
|
|
639
697
|
if (Buffer.byteLength(line, "utf8") > MAX_FRAME_BYTES) {
|
|
640
698
|
throw new NativeRelayError(
|
|
@@ -643,20 +701,21 @@ export class NativeDesktopRelay {
|
|
|
643
701
|
);
|
|
644
702
|
}
|
|
645
703
|
|
|
646
|
-
const response = await this.#roundTrip(line, timeoutMs);
|
|
647
|
-
if (response?.ok === true && response.v ===
|
|
704
|
+
const response = await this.#roundTrip(line, timeoutMs, beforeSend, request.accountContext ? this.accountSocketPath : this.socketPath);
|
|
705
|
+
if (response?.ok === true && response.v === request.v) {
|
|
648
706
|
if (request.operation && response.operation !== request.operation) throw new NativeRelayError("The relay answered a different Desktop operation; do not retry this request", "RELAY_BAD_RESPONSE", { reachedCompanion: true });
|
|
649
707
|
return response;
|
|
650
708
|
}
|
|
651
|
-
|
|
709
|
+
const error = new NativeRelayError(
|
|
652
710
|
response?.error?.message ?? "the Codex Desktop relay refused the message",
|
|
653
711
|
response?.error?.code ?? "NATIVE_DISPATCH_FAILED",
|
|
654
|
-
{ reachedCompanion:
|
|
712
|
+
{ reachedCompanion: response?.error?.sent !== false },
|
|
655
713
|
);
|
|
714
|
+
if (response?.error?.sent === false) error.sent = false;
|
|
715
|
+
throw error;
|
|
656
716
|
}
|
|
657
717
|
|
|
658
|
-
#roundTrip(line, timeoutMs) {
|
|
659
|
-
const socketPath = this.socketPath;
|
|
718
|
+
#roundTrip(line, timeoutMs, beforeSend, socketPath) {
|
|
660
719
|
return new Promise((resolve, reject) => {
|
|
661
720
|
let buffer = Buffer.alloc(0);
|
|
662
721
|
let settled = false;
|
|
@@ -675,18 +734,29 @@ export class NativeDesktopRelay {
|
|
|
675
734
|
() =>
|
|
676
735
|
finish(
|
|
677
736
|
reject,
|
|
678
|
-
new NativeRelayError(
|
|
737
|
+
dispatched ? new NativeRelayError(
|
|
679
738
|
`The Codex Desktop relay did not answer within ${timeoutMs}ms`,
|
|
680
739
|
"RELAY_TIMEOUT",
|
|
681
|
-
{ reachedCompanion:
|
|
682
|
-
),
|
|
740
|
+
{ reachedCompanion: true },
|
|
741
|
+
) : beforeWriteError(new NativeRelayError("The Codex Desktop relay send deadline elapsed before dispatch", "RELAY_UNREACHABLE")),
|
|
683
742
|
),
|
|
684
743
|
timeoutMs,
|
|
685
744
|
);
|
|
686
745
|
|
|
687
746
|
socket.on("connect", () => {
|
|
688
|
-
|
|
689
|
-
|
|
747
|
+
if (settled) return;
|
|
748
|
+
void (async () => {
|
|
749
|
+
try {
|
|
750
|
+
const checked = beforeSend?.();
|
|
751
|
+
if (checked && typeof checked.then === "function") await checked;
|
|
752
|
+
if (settled) return;
|
|
753
|
+
if (socket.destroyed) throw new NativeRelayError("The relay socket closed before dispatch", "RELAY_UNREACHABLE");
|
|
754
|
+
dispatched = true;
|
|
755
|
+
socket.write(line);
|
|
756
|
+
} catch (error) {
|
|
757
|
+
finish(reject, dispatched ? new NativeRelayError(`Relay write failed: ${error.message}`, "RELAY_DELIVERY_UNCONFIRMED", { reachedCompanion: true }) : beforeWriteError(error));
|
|
758
|
+
}
|
|
759
|
+
})();
|
|
690
760
|
});
|
|
691
761
|
socket.on("data", (chunk) => {
|
|
692
762
|
buffer = Buffer.concat([buffer, chunk]);
|
|
@@ -717,17 +787,19 @@ export class NativeDesktopRelay {
|
|
|
717
787
|
socket.on("error", (err) =>
|
|
718
788
|
finish(
|
|
719
789
|
reject,
|
|
720
|
-
|
|
790
|
+
dispatched
|
|
791
|
+
? new NativeRelayError(`Codex Desktop relay connection failed at ${socketPath}: ${err.message}`, "RELAY_DELIVERY_UNCONFIRMED", { reachedCompanion: true })
|
|
792
|
+
: beforeWriteError(new NativeRelayError(`Codex Desktop relay connection failed at ${socketPath}: ${err.message}`, "RELAY_UNREACHABLE")),
|
|
721
793
|
),
|
|
722
794
|
);
|
|
723
795
|
socket.on("close", () =>
|
|
724
796
|
finish(
|
|
725
797
|
reject,
|
|
726
|
-
new NativeRelayError(
|
|
798
|
+
dispatched ? new NativeRelayError(
|
|
727
799
|
`The Codex Desktop relay at ${socketPath} closed before answering`,
|
|
728
|
-
|
|
729
|
-
{ reachedCompanion:
|
|
730
|
-
),
|
|
800
|
+
"RELAY_DELIVERY_UNCONFIRMED",
|
|
801
|
+
{ reachedCompanion: true },
|
|
802
|
+
) : beforeWriteError(new NativeRelayError(`The Codex Desktop relay at ${socketPath} closed before dispatch`, "RELAY_UNREACHABLE")),
|
|
731
803
|
),
|
|
732
804
|
);
|
|
733
805
|
});
|
package/src/peer-protocol.mjs
CHANGED
|
@@ -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
|
|
|
@@ -289,8 +290,9 @@ export function readTranscript(sessionId, cwd, limit = 10) {
|
|
|
289
290
|
* recipient starts a new turn with a user entry whose uuid is the message id
|
|
290
291
|
* (origin.msg_id names it as well). A busy recipient absorbs the message into
|
|
291
292
|
* the running turn instead: the entry is a queued_command attachment under a
|
|
292
|
-
* fresh uuid
|
|
293
|
-
*
|
|
293
|
+
* fresh uuid, and the reply is that turn's closing text. The peer origin's
|
|
294
|
+
* msg_id identifies the message; source_uuid is unrelated on 2.1.229-2.1.237
|
|
295
|
+
* and equals the message id on the measured 2.1.260-2.1.263 builds.
|
|
294
296
|
*/
|
|
295
297
|
function injectedMessageRoot(entry, msgId) {
|
|
296
298
|
if (typeof entry.uuid !== "string" || !entry.uuid.trim()) return null;
|
|
@@ -300,8 +302,7 @@ function injectedMessageRoot(entry, msgId) {
|
|
|
300
302
|
if (entry.uuid === msgId || matchesOrigin(entry.origin)) return "idle";
|
|
301
303
|
}
|
|
302
304
|
const attachment = entry.type === "attachment" ? entry.attachment : null;
|
|
303
|
-
if (attachment?.type === "queued_command" && matchesOrigin(attachment.origin)
|
|
304
|
-
&& (attachment.source_uuid === undefined || attachment.source_uuid === msgId)) return "absorbed";
|
|
305
|
+
if (attachment?.type === "queued_command" && matchesOrigin(attachment.origin)) return "absorbed";
|
|
305
306
|
return null;
|
|
306
307
|
}
|
|
307
308
|
|
|
@@ -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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
767
|
-
|
|
768
|
-
|
|
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
|
|
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
|
|