@deepdream314/remodex 1.3.8

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/src/bridge.js ADDED
@@ -0,0 +1,1259 @@
1
+ // FILE: bridge.js
2
+ // Purpose: Runs Codex locally, bridges relay traffic, and coordinates desktop refreshes for Codex.app.
3
+ // Layer: CLI service
4
+ // Exports: startBridge
5
+ // Depends on: ws, crypto, os, ./qr, ./codex-desktop-refresher, ./codex-transport, ./rollout-watch, ./voice-handler
6
+
7
+ const WebSocket = require("ws");
8
+ const { randomBytes } = require("crypto");
9
+ const { execFile } = require("child_process");
10
+ const os = require("os");
11
+ const { promisify } = require("util");
12
+ const {
13
+ CodexDesktopRefresher,
14
+ readBridgeConfig,
15
+ } = require("./codex-desktop-refresher");
16
+ const { createCodexTransport } = require("./codex-transport");
17
+ const { createThreadRolloutActivityWatcher } = require("./rollout-watch");
18
+ const { printQR } = require("./qr");
19
+ const { rememberActiveThread } = require("./session-state");
20
+ const { handleDesktopRequest } = require("./desktop-handler");
21
+ const { handleGitRequest } = require("./git-handler");
22
+ const { handleThreadContextRequest } = require("./thread-context-handler");
23
+ const { handleWorkspaceRequest } = require("./workspace-handler");
24
+ const { createNotificationsHandler } = require("./notifications-handler");
25
+ const { createVoiceHandler, resolveVoiceAuth } = require("./voice-handler");
26
+ const {
27
+ composeSanitizedAuthStatusFromSettledResults,
28
+ } = require("./account-status");
29
+ const { createBridgePackageVersionStatusReader } = require("./package-version-status");
30
+ const { createPushNotificationServiceClient } = require("./push-notification-service-client");
31
+ const { createPushNotificationTracker } = require("./push-notification-tracker");
32
+ const {
33
+ loadOrCreateBridgeDeviceState,
34
+ resolveBridgeRelaySession,
35
+ } = require("./secure-device-state");
36
+ const { createBridgeSecureTransport } = require("./secure-transport");
37
+ const { createRolloutLiveMirrorController } = require("./rollout-live-mirror");
38
+
39
+ const execFileAsync = promisify(execFile);
40
+ const RELAY_WATCHDOG_PING_INTERVAL_MS = 10_000;
41
+ const RELAY_WATCHDOG_STALE_AFTER_MS = 25_000;
42
+ const BRIDGE_STATUS_HEARTBEAT_INTERVAL_MS = 5_000;
43
+ const STALE_RELAY_STATUS_MESSAGE = "Relay heartbeat stalled; reconnect pending.";
44
+ const RELAY_HISTORY_IMAGE_REFERENCE_URL = "remodex://history-image-elided";
45
+
46
+ function startBridge({
47
+ config: explicitConfig = null,
48
+ printPairingQr = true,
49
+ onPairingPayload = null,
50
+ onBridgeStatus = null,
51
+ } = {}) {
52
+ const config = explicitConfig || readBridgeConfig();
53
+ const relayBaseUrl = config.relayUrl.replace(/\/+$/, "");
54
+ if (!relayBaseUrl) {
55
+ console.error("[remodex] No relay URL configured.");
56
+ console.error("[remodex] In a source checkout, run ./run-local-remodex.sh or set REMODEX_RELAY.");
57
+ process.exit(1);
58
+ }
59
+
60
+ let deviceState;
61
+ try {
62
+ deviceState = loadOrCreateBridgeDeviceState();
63
+ } catch (error) {
64
+ console.error(`[remodex] ${(error && error.message) || "Failed to load the saved bridge pairing state."}`);
65
+ process.exit(1);
66
+ }
67
+ const relaySession = resolveBridgeRelaySession(deviceState);
68
+ deviceState = relaySession.deviceState;
69
+ const sessionId = relaySession.sessionId;
70
+ const relaySessionUrl = `${relayBaseUrl}/${sessionId}`;
71
+ const notificationSecret = randomBytes(24).toString("hex");
72
+ const desktopRefresher = new CodexDesktopRefresher({
73
+ enabled: config.refreshEnabled,
74
+ debounceMs: config.refreshDebounceMs,
75
+ refreshCommand: config.refreshCommand,
76
+ bundleId: config.codexBundleId,
77
+ appPath: config.codexAppPath,
78
+ });
79
+ const pushServiceClient = createPushNotificationServiceClient({
80
+ baseUrl: config.pushServiceUrl,
81
+ sessionId,
82
+ notificationSecret,
83
+ });
84
+ const notificationsHandler = createNotificationsHandler({
85
+ pushServiceClient,
86
+ });
87
+ const pushNotificationTracker = createPushNotificationTracker({
88
+ sessionId,
89
+ pushServiceClient,
90
+ previewMaxChars: config.pushPreviewMaxChars,
91
+ });
92
+ const readBridgePackageVersionStatus = createBridgePackageVersionStatusReader();
93
+
94
+ // Keep the local Codex runtime alive across transient relay disconnects.
95
+ let socket = null;
96
+ let isShuttingDown = false;
97
+ let reconnectAttempt = 0;
98
+ let reconnectTimer = null;
99
+ let relayWatchdogTimer = null;
100
+ let statusHeartbeatTimer = null;
101
+ let lastRelayActivityAt = 0;
102
+ let lastPublishedBridgeStatus = null;
103
+ let lastConnectionStatus = null;
104
+ let codexHandshakeState = config.codexEndpoint ? "warm" : "cold";
105
+ const forwardedInitializeRequestIds = new Set();
106
+ const bridgeManagedCodexRequestWaiters = new Map();
107
+ const forwardedRequestMethodsById = new Map();
108
+ const relaySanitizedResponseMethodsById = new Map();
109
+ const trackedForwardedRequestMethods = new Set([
110
+ "account/login/start",
111
+ "account/login/cancel",
112
+ "account/logout",
113
+ ]);
114
+ const relaySanitizedRequestMethods = new Set([
115
+ "thread/read",
116
+ "thread/resume",
117
+ ]);
118
+ const forwardedRequestMethodTTLms = 2 * 60_000;
119
+ const pendingAuthLogin = {
120
+ loginId: null,
121
+ authUrl: null,
122
+ requestId: null,
123
+ startedAt: 0,
124
+ };
125
+ const secureTransport = createBridgeSecureTransport({
126
+ sessionId,
127
+ relayUrl: relayBaseUrl,
128
+ deviceState,
129
+ onTrustedPhoneUpdate(nextDeviceState) {
130
+ deviceState = nextDeviceState;
131
+ sendRelayRegistrationUpdate(nextDeviceState);
132
+ },
133
+ });
134
+ // Keeps one stable sender identity across reconnects so buffered replay state
135
+ // reflects what actually made it onto the current relay socket.
136
+ function sendRelayWireMessage(wireMessage) {
137
+ if (socket?.readyState !== WebSocket.OPEN) {
138
+ return false;
139
+ }
140
+
141
+ socket.send(wireMessage);
142
+ return true;
143
+ }
144
+ // Only the spawned local runtime needs rollout mirroring; a real endpoint
145
+ // already provides the authoritative live stream for resumed threads.
146
+ const rolloutLiveMirror = !config.codexEndpoint
147
+ ? createRolloutLiveMirrorController({
148
+ sendApplicationResponse,
149
+ })
150
+ : null;
151
+ let contextUsageWatcher = null;
152
+ let watchedContextUsageKey = null;
153
+
154
+ const codex = createCodexTransport({
155
+ endpoint: config.codexEndpoint,
156
+ env: process.env,
157
+ logPrefix: "[remodex]",
158
+ });
159
+ const voiceHandler = createVoiceHandler({
160
+ sendCodexRequest,
161
+ logPrefix: "[remodex]",
162
+ });
163
+ startBridgeStatusHeartbeat();
164
+ publishBridgeStatus({
165
+ state: "starting",
166
+ connectionStatus: "starting",
167
+ pid: process.pid,
168
+ lastError: "",
169
+ });
170
+
171
+ codex.onError((error) => {
172
+ publishBridgeStatus({
173
+ state: "error",
174
+ connectionStatus: "error",
175
+ pid: process.pid,
176
+ lastError: error.message,
177
+ });
178
+ if (config.codexEndpoint) {
179
+ console.error(`[remodex] Failed to connect to Codex endpoint: ${config.codexEndpoint}`);
180
+ } else {
181
+ console.error("[remodex] Failed to start `codex app-server`.");
182
+ console.error(`[remodex] Launch command: ${codex.describe()}`);
183
+ console.error("[remodex] Make sure the Codex CLI is installed and that the launcher works on this OS.");
184
+ }
185
+ console.error(error.message);
186
+ process.exit(1);
187
+ });
188
+
189
+ function clearReconnectTimer() {
190
+ if (!reconnectTimer) {
191
+ return;
192
+ }
193
+
194
+ clearTimeout(reconnectTimer);
195
+ reconnectTimer = null;
196
+ }
197
+
198
+ // Periodically rewrites the latest bridge snapshot so CLI status does not stay frozen.
199
+ function startBridgeStatusHeartbeat() {
200
+ if (statusHeartbeatTimer) {
201
+ return;
202
+ }
203
+
204
+ statusHeartbeatTimer = setInterval(() => {
205
+ if (!lastPublishedBridgeStatus || isShuttingDown) {
206
+ return;
207
+ }
208
+
209
+ onBridgeStatus?.(buildHeartbeatBridgeStatus(lastPublishedBridgeStatus, lastRelayActivityAt));
210
+ }, BRIDGE_STATUS_HEARTBEAT_INTERVAL_MS);
211
+ statusHeartbeatTimer.unref?.();
212
+ }
213
+
214
+ function clearBridgeStatusHeartbeat() {
215
+ if (!statusHeartbeatTimer) {
216
+ return;
217
+ }
218
+
219
+ clearInterval(statusHeartbeatTimer);
220
+ statusHeartbeatTimer = null;
221
+ }
222
+
223
+ // Tracks relay liveness locally so sleep/wake zombie sockets can be force-reconnected.
224
+ function markRelayActivity() {
225
+ lastRelayActivityAt = Date.now();
226
+ }
227
+
228
+ function clearRelayWatchdog() {
229
+ if (!relayWatchdogTimer) {
230
+ return;
231
+ }
232
+
233
+ clearInterval(relayWatchdogTimer);
234
+ relayWatchdogTimer = null;
235
+ }
236
+
237
+ function startRelayWatchdog(trackedSocket) {
238
+ clearRelayWatchdog();
239
+ markRelayActivity();
240
+
241
+ relayWatchdogTimer = setInterval(() => {
242
+ if (isShuttingDown || socket !== trackedSocket) {
243
+ clearRelayWatchdog();
244
+ return;
245
+ }
246
+
247
+ if (trackedSocket.readyState !== WebSocket.OPEN) {
248
+ return;
249
+ }
250
+
251
+ if (hasRelayConnectionGoneStale(lastRelayActivityAt)) {
252
+ console.warn("[remodex] relay heartbeat stalled; forcing reconnect");
253
+ logConnectionStatus("disconnected");
254
+ trackedSocket.terminate();
255
+ return;
256
+ }
257
+
258
+ try {
259
+ trackedSocket.ping();
260
+ } catch {
261
+ trackedSocket.terminate();
262
+ }
263
+ }, RELAY_WATCHDOG_PING_INTERVAL_MS);
264
+ relayWatchdogTimer.unref?.();
265
+ }
266
+
267
+ // Keeps npm start output compact by emitting only high-signal connection states.
268
+ function logConnectionStatus(status) {
269
+ if (lastConnectionStatus === status) {
270
+ return;
271
+ }
272
+
273
+ lastConnectionStatus = status;
274
+ publishBridgeStatus({
275
+ state: "running",
276
+ connectionStatus: status,
277
+ pid: process.pid,
278
+ lastError: "",
279
+ });
280
+ console.log(`[remodex] ${status}`);
281
+ }
282
+
283
+ // Retries the relay socket while preserving the active Codex process and session id.
284
+ function scheduleRelayReconnect(closeCode) {
285
+ if (isShuttingDown) {
286
+ return;
287
+ }
288
+
289
+ if (closeCode === 4000 || closeCode === 4001) {
290
+ logConnectionStatus("disconnected");
291
+ shutdown(codex, () => socket, () => {
292
+ isShuttingDown = true;
293
+ clearReconnectTimer();
294
+ clearRelayWatchdog();
295
+ clearBridgeStatusHeartbeat();
296
+ });
297
+ return;
298
+ }
299
+
300
+ if (reconnectTimer) {
301
+ return;
302
+ }
303
+
304
+ reconnectAttempt += 1;
305
+ const delayMs = Math.min(1_000 * reconnectAttempt, 5_000);
306
+ logConnectionStatus("connecting");
307
+ reconnectTimer = setTimeout(() => {
308
+ reconnectTimer = null;
309
+ connectRelay();
310
+ }, delayMs);
311
+ }
312
+
313
+ function connectRelay() {
314
+ if (isShuttingDown) {
315
+ return;
316
+ }
317
+
318
+ logConnectionStatus("connecting");
319
+ const nextSocket = new WebSocket(relaySessionUrl, {
320
+ // The relay uses this per-session secret to authenticate the first push registration.
321
+ headers: {
322
+ "x-role": "mac",
323
+ "x-notification-secret": notificationSecret,
324
+ ...buildMacRegistrationHeaders(deviceState),
325
+ },
326
+ });
327
+ socket = nextSocket;
328
+
329
+ nextSocket.on("open", () => {
330
+ markRelayActivity();
331
+ clearReconnectTimer();
332
+ reconnectAttempt = 0;
333
+ startRelayWatchdog(nextSocket);
334
+ logConnectionStatus("connected");
335
+ secureTransport.bindLiveSendWireMessage(sendRelayWireMessage);
336
+ sendRelayRegistrationUpdate(deviceState);
337
+ });
338
+
339
+ nextSocket.on("message", (data) => {
340
+ markRelayActivity();
341
+ const message = typeof data === "string" ? data : data.toString("utf8");
342
+ if (secureTransport.handleIncomingWireMessage(message, {
343
+ sendControlMessage(controlMessage) {
344
+ if (nextSocket.readyState === WebSocket.OPEN) {
345
+ nextSocket.send(JSON.stringify(controlMessage));
346
+ }
347
+ },
348
+ onApplicationMessage(plaintextMessage) {
349
+ handleApplicationMessage(plaintextMessage);
350
+ },
351
+ })) {
352
+ return;
353
+ }
354
+ });
355
+
356
+ nextSocket.on("ping", () => {
357
+ markRelayActivity();
358
+ });
359
+
360
+ nextSocket.on("pong", () => {
361
+ markRelayActivity();
362
+ });
363
+
364
+ nextSocket.on("close", (code) => {
365
+ if (socket === nextSocket) {
366
+ clearRelayWatchdog();
367
+ }
368
+ logConnectionStatus("disconnected");
369
+ if (socket === nextSocket) {
370
+ socket = null;
371
+ }
372
+ stopContextUsageWatcher();
373
+ rolloutLiveMirror?.stopAll();
374
+ desktopRefresher.handleTransportReset();
375
+ scheduleRelayReconnect(code);
376
+ });
377
+
378
+ nextSocket.on("error", () => {
379
+ if (socket === nextSocket) {
380
+ clearRelayWatchdog();
381
+ }
382
+ logConnectionStatus("disconnected");
383
+ });
384
+ }
385
+
386
+ const pairingPayload = secureTransport.createPairingPayload();
387
+ onPairingPayload?.(pairingPayload);
388
+ if (printPairingQr) {
389
+ printQR(pairingPayload);
390
+ }
391
+ pushServiceClient.logUnavailable();
392
+ connectRelay();
393
+
394
+ codex.onMessage((message) => {
395
+ if (handleBridgeManagedCodexResponse(message)) {
396
+ return;
397
+ }
398
+ updatePendingAuthLoginFromCodexMessage(message);
399
+ trackCodexHandshakeState(message);
400
+ desktopRefresher.handleOutbound(message);
401
+ pushNotificationTracker.handleOutbound(message);
402
+ rememberThreadFromMessage("codex", message);
403
+ secureTransport.queueOutboundApplicationMessage(
404
+ sanitizeRelayBoundCodexMessage(message),
405
+ sendRelayWireMessage
406
+ );
407
+ });
408
+
409
+ codex.onClose(() => {
410
+ clearRelayWatchdog();
411
+ clearBridgeStatusHeartbeat();
412
+ logConnectionStatus("disconnected");
413
+ publishBridgeStatus({
414
+ state: "stopped",
415
+ connectionStatus: "disconnected",
416
+ pid: process.pid,
417
+ lastError: "",
418
+ });
419
+ isShuttingDown = true;
420
+ clearReconnectTimer();
421
+ stopContextUsageWatcher();
422
+ rolloutLiveMirror?.stopAll();
423
+ desktopRefresher.handleTransportReset();
424
+ failBridgeManagedCodexRequests(new Error("Codex transport closed before the bridge request completed."));
425
+ forwardedRequestMethodsById.clear();
426
+ if (socket?.readyState === WebSocket.OPEN || socket?.readyState === WebSocket.CONNECTING) {
427
+ socket.close();
428
+ }
429
+ });
430
+
431
+ process.on("SIGINT", () => shutdown(codex, () => socket, () => {
432
+ isShuttingDown = true;
433
+ clearReconnectTimer();
434
+ clearRelayWatchdog();
435
+ clearBridgeStatusHeartbeat();
436
+ }));
437
+ process.on("SIGTERM", () => shutdown(codex, () => socket, () => {
438
+ isShuttingDown = true;
439
+ clearReconnectTimer();
440
+ clearRelayWatchdog();
441
+ clearBridgeStatusHeartbeat();
442
+ }));
443
+
444
+ // Routes decrypted app payloads through the same bridge handlers as before.
445
+ function handleApplicationMessage(rawMessage) {
446
+ if (handleBridgeManagedHandshakeMessage(rawMessage)) {
447
+ return;
448
+ }
449
+ if (handleBridgeManagedAccountRequest(rawMessage, sendApplicationResponse)) {
450
+ return;
451
+ }
452
+ if (voiceHandler.handleVoiceRequest(rawMessage, sendApplicationResponse)) {
453
+ return;
454
+ }
455
+ if (handleThreadContextRequest(rawMessage, sendApplicationResponse)) {
456
+ return;
457
+ }
458
+ if (handleWorkspaceRequest(rawMessage, sendApplicationResponse)) {
459
+ return;
460
+ }
461
+ if (notificationsHandler.handleNotificationsRequest(rawMessage, sendApplicationResponse)) {
462
+ return;
463
+ }
464
+ if (handleDesktopRequest(rawMessage, sendApplicationResponse, {
465
+ bundleId: config.codexBundleId,
466
+ appPath: config.codexAppPath,
467
+ })) {
468
+ return;
469
+ }
470
+ if (handleGitRequest(rawMessage, sendApplicationResponse)) {
471
+ return;
472
+ }
473
+ desktopRefresher.handleInbound(rawMessage);
474
+ rolloutLiveMirror?.observeInbound(rawMessage);
475
+ rememberForwardedRequestMethod(rawMessage);
476
+ rememberThreadFromMessage("phone", rawMessage);
477
+ codex.send(rawMessage);
478
+ }
479
+
480
+ // Encrypts bridge-generated responses instead of letting the relay see plaintext.
481
+ function sendApplicationResponse(rawMessage) {
482
+ secureTransport.queueOutboundApplicationMessage(rawMessage, sendRelayWireMessage);
483
+ }
484
+
485
+ // ─── Bridge-owned auth snapshot ─────────────────────────────
486
+
487
+ // Handles the bridge-owned auth status wrappers without exposing tokens to the phone.
488
+ // This dispatcher stays synchronous so non-account messages can continue down the normal routing chain.
489
+ function handleBridgeManagedAccountRequest(rawMessage, sendResponse) {
490
+ let parsed = null;
491
+ try {
492
+ parsed = JSON.parse(rawMessage);
493
+ } catch {
494
+ return false;
495
+ }
496
+
497
+ const method = typeof parsed?.method === "string" ? parsed.method.trim() : "";
498
+ if (method !== "account/status/read"
499
+ && method !== "getAuthStatus"
500
+ && method !== "account/login/openOnMac"
501
+ && method !== "voice/resolveAuth") {
502
+ return false;
503
+ }
504
+
505
+ const requestId = parsed.id;
506
+ const shouldRespond = requestId != null;
507
+ readBridgeManagedAccountResult(method, parsed.params || {})
508
+ .then((result) => {
509
+ if (shouldRespond) {
510
+ sendResponse(JSON.stringify({ id: requestId, result }));
511
+ }
512
+ })
513
+ .catch((error) => {
514
+ if (shouldRespond) {
515
+ sendResponse(createJsonRpcErrorResponse(requestId, error, "auth_status_failed"));
516
+ }
517
+ });
518
+
519
+ return true;
520
+ }
521
+
522
+ // Resolves bridge-owned account helpers like status reads and Mac-side browser opening.
523
+ async function readBridgeManagedAccountResult(method, params) {
524
+ switch (method) {
525
+ case "account/status/read":
526
+ case "getAuthStatus":
527
+ return readSanitizedAuthStatus();
528
+ case "account/login/openOnMac":
529
+ return openPendingAuthLoginOnMac(params);
530
+ case "voice/resolveAuth":
531
+ return resolveVoiceAuth(sendCodexRequest);
532
+ default:
533
+ throw new Error(`Unsupported bridge-managed account method: ${method}`);
534
+ }
535
+ }
536
+
537
+ // Combines account/read + getAuthStatus into one safe snapshot for the phone UI.
538
+ // The two RPCs are settled independently so one transient failure does not hide the other.
539
+ async function readSanitizedAuthStatus() {
540
+ const [accountReadResult, authStatusResult, bridgeVersionInfoResult] = await Promise.allSettled([
541
+ sendCodexRequest("account/read", {
542
+ refreshToken: false,
543
+ }),
544
+ sendCodexRequest("getAuthStatus", {
545
+ includeToken: true,
546
+ refreshToken: true,
547
+ }),
548
+ readBridgePackageVersionStatus(),
549
+ ]);
550
+
551
+ return composeSanitizedAuthStatusFromSettledResults({
552
+ accountReadResult: accountReadResult.status === "fulfilled"
553
+ ? {
554
+ status: "fulfilled",
555
+ value: normalizeAccountRead(accountReadResult.value),
556
+ }
557
+ : accountReadResult,
558
+ authStatusResult,
559
+ loginInFlight: Boolean(pendingAuthLogin.loginId),
560
+ bridgeVersionInfo: bridgeVersionInfoResult.status === "fulfilled"
561
+ ? bridgeVersionInfoResult.value
562
+ : null,
563
+ transportMode: codex.mode,
564
+ });
565
+ }
566
+
567
+ // Opens the ChatGPT sign-in URL in the default browser on the bridge Mac.
568
+ async function openPendingAuthLoginOnMac(params) {
569
+ if (process.platform !== "darwin") {
570
+ const error = new Error("Opening ChatGPT sign-in on the bridge is only supported on macOS.");
571
+ error.errorCode = "unsupported_platform";
572
+ throw error;
573
+ }
574
+
575
+ const authUrl = readString(params?.authUrl) || pendingAuthLogin.authUrl;
576
+ if (!authUrl) {
577
+ const error = new Error("No pending ChatGPT sign-in URL is available on this bridge.");
578
+ error.errorCode = "missing_auth_url";
579
+ throw error;
580
+ }
581
+
582
+ await execFileAsync("open", [authUrl], { timeout: 15_000 });
583
+ return {
584
+ success: true,
585
+ openedOnMac: true,
586
+ };
587
+ }
588
+
589
+ function normalizeAccountRead(payload) {
590
+ if (!payload || typeof payload !== "object") {
591
+ return {
592
+ account: null,
593
+ requiresOpenaiAuth: true,
594
+ };
595
+ }
596
+
597
+ return {
598
+ account: payload.account && typeof payload.account === "object" ? payload.account : null,
599
+ requiresOpenaiAuth: Boolean(payload.requiresOpenaiAuth),
600
+ };
601
+ }
602
+
603
+ function createJsonRpcErrorResponse(requestId, error, defaultErrorCode) {
604
+ return JSON.stringify({
605
+ id: requestId,
606
+ error: {
607
+ code: -32000,
608
+ message: error?.userMessage || error?.message || "Bridge request failed.",
609
+ data: {
610
+ errorCode: error?.errorCode || defaultErrorCode,
611
+ },
612
+ },
613
+ });
614
+ }
615
+
616
+ function rememberForwardedRequestMethod(rawMessage) {
617
+ const parsed = safeParseJSON(rawMessage);
618
+ const method = typeof parsed?.method === "string" ? parsed.method.trim() : "";
619
+ const requestId = parsed?.id;
620
+ if (!method || requestId == null) {
621
+ return;
622
+ }
623
+
624
+ pruneExpiredForwardedRequestMethods();
625
+ if (trackedForwardedRequestMethods.has(method)) {
626
+ forwardedRequestMethodsById.set(String(requestId), {
627
+ method,
628
+ createdAt: Date.now(),
629
+ });
630
+ }
631
+ if (relaySanitizedRequestMethods.has(method)) {
632
+ relaySanitizedResponseMethodsById.set(String(requestId), {
633
+ method,
634
+ createdAt: Date.now(),
635
+ });
636
+ }
637
+ }
638
+
639
+ // Replaces huge inline desktop-history images with lightweight references before relay encryption.
640
+ function sanitizeRelayBoundCodexMessage(rawMessage) {
641
+ pruneExpiredForwardedRequestMethods();
642
+ const parsed = safeParseJSON(rawMessage);
643
+ const responseId = parsed?.id;
644
+ if (responseId == null) {
645
+ return rawMessage;
646
+ }
647
+
648
+ const trackedRequest = relaySanitizedResponseMethodsById.get(String(responseId));
649
+ if (!trackedRequest) {
650
+ return rawMessage;
651
+ }
652
+ relaySanitizedResponseMethodsById.delete(String(responseId));
653
+
654
+ return sanitizeThreadHistoryImagesForRelay(rawMessage, trackedRequest.method);
655
+ }
656
+
657
+ function updatePendingAuthLoginFromCodexMessage(rawMessage) {
658
+ pruneExpiredForwardedRequestMethods();
659
+ const parsed = safeParseJSON(rawMessage);
660
+ const responseId = parsed?.id;
661
+ if (responseId != null) {
662
+ const trackedRequest = forwardedRequestMethodsById.get(String(responseId));
663
+ if (trackedRequest) {
664
+ forwardedRequestMethodsById.delete(String(responseId));
665
+ const requestMethod = trackedRequest.method;
666
+
667
+ if (requestMethod === "account/login/start") {
668
+ const loginId = readString(parsed?.result?.loginId);
669
+ const authUrl = readString(parsed?.result?.authUrl);
670
+ if (!loginId || !authUrl) {
671
+ clearPendingAuthLogin();
672
+ return;
673
+ }
674
+ pendingAuthLogin.loginId = loginId || null;
675
+ pendingAuthLogin.authUrl = authUrl || null;
676
+ pendingAuthLogin.requestId = String(responseId);
677
+ pendingAuthLogin.startedAt = Date.now();
678
+ return;
679
+ }
680
+
681
+ if (requestMethod === "account/login/cancel" || requestMethod === "account/logout") {
682
+ clearPendingAuthLogin();
683
+ return;
684
+ }
685
+ }
686
+ }
687
+
688
+ const method = typeof parsed?.method === "string" ? parsed.method.trim() : "";
689
+ if (method === "account/login/completed") {
690
+ clearPendingAuthLogin();
691
+ return;
692
+ }
693
+
694
+ if (method === "account/updated") {
695
+ clearPendingAuthLogin();
696
+ }
697
+ }
698
+
699
+ function clearPendingAuthLogin() {
700
+ pendingAuthLogin.loginId = null;
701
+ pendingAuthLogin.authUrl = null;
702
+ pendingAuthLogin.requestId = null;
703
+ pendingAuthLogin.startedAt = 0;
704
+ }
705
+
706
+ function pruneExpiredForwardedRequestMethods(now = Date.now()) {
707
+ for (const [requestId, trackedRequest] of forwardedRequestMethodsById.entries()) {
708
+ if (!trackedRequest || (now - trackedRequest.createdAt) >= forwardedRequestMethodTTLms) {
709
+ forwardedRequestMethodsById.delete(requestId);
710
+ }
711
+ }
712
+ for (const [requestId, trackedRequest] of relaySanitizedResponseMethodsById.entries()) {
713
+ if (!trackedRequest || (now - trackedRequest.createdAt) >= forwardedRequestMethodTTLms) {
714
+ relaySanitizedResponseMethodsById.delete(requestId);
715
+ }
716
+ }
717
+ }
718
+
719
+ function safeParseJSON(value) {
720
+ try {
721
+ return JSON.parse(value);
722
+ } catch {
723
+ return null;
724
+ }
725
+ }
726
+
727
+ function rememberThreadFromMessage(source, rawMessage) {
728
+ const context = extractBridgeMessageContext(rawMessage);
729
+ if (!context.threadId) {
730
+ return;
731
+ }
732
+
733
+ rememberActiveThread(context.threadId, source);
734
+ if (shouldStartContextUsageWatcher(context)) {
735
+ ensureContextUsageWatcher(context);
736
+ }
737
+ }
738
+
739
+ // Mirrors CodexMonitor's persisted token_count fallback so the phone keeps
740
+ // receiving context-window usage even when the runtime omits live thread usage.
741
+ function ensureContextUsageWatcher({ threadId, turnId }) {
742
+ const normalizedThreadId = readString(threadId);
743
+ const normalizedTurnId = readString(turnId);
744
+ if (!normalizedThreadId) {
745
+ return;
746
+ }
747
+
748
+ const nextWatcherKey = `${normalizedThreadId}|${normalizedTurnId || "pending-turn"}`;
749
+ if (watchedContextUsageKey === nextWatcherKey && contextUsageWatcher) {
750
+ return;
751
+ }
752
+
753
+ stopContextUsageWatcher();
754
+ watchedContextUsageKey = nextWatcherKey;
755
+ contextUsageWatcher = createThreadRolloutActivityWatcher({
756
+ threadId: normalizedThreadId,
757
+ turnId: normalizedTurnId,
758
+ onUsage: ({ threadId: usageThreadId, usage }) => {
759
+ sendContextUsageNotification(usageThreadId, usage);
760
+ },
761
+ onIdle: () => {
762
+ if (watchedContextUsageKey === nextWatcherKey) {
763
+ stopContextUsageWatcher();
764
+ }
765
+ },
766
+ onTimeout: () => {
767
+ if (watchedContextUsageKey === nextWatcherKey) {
768
+ stopContextUsageWatcher();
769
+ }
770
+ },
771
+ onError: () => {
772
+ if (watchedContextUsageKey === nextWatcherKey) {
773
+ stopContextUsageWatcher();
774
+ }
775
+ },
776
+ });
777
+ }
778
+
779
+ function stopContextUsageWatcher() {
780
+ if (contextUsageWatcher) {
781
+ contextUsageWatcher.stop();
782
+ }
783
+
784
+ contextUsageWatcher = null;
785
+ watchedContextUsageKey = null;
786
+ }
787
+
788
+ function sendContextUsageNotification(threadId, usage) {
789
+ if (!threadId || !usage) {
790
+ return;
791
+ }
792
+
793
+ sendApplicationResponse(JSON.stringify({
794
+ method: "thread/tokenUsage/updated",
795
+ params: {
796
+ threadId,
797
+ usage,
798
+ },
799
+ }));
800
+ }
801
+
802
+ // The spawned/shared Codex app-server stays warm across phone reconnects.
803
+ // When iPhone reconnects it sends initialize again, but forwarding that to the
804
+ // already-initialized Codex transport only produces "Already initialized".
805
+ function handleBridgeManagedHandshakeMessage(rawMessage) {
806
+ let parsed = null;
807
+ try {
808
+ parsed = JSON.parse(rawMessage);
809
+ } catch {
810
+ return false;
811
+ }
812
+
813
+ const method = typeof parsed?.method === "string" ? parsed.method.trim() : "";
814
+ if (!method) {
815
+ return false;
816
+ }
817
+
818
+ if (method === "initialize" && parsed.id != null) {
819
+ if (codexHandshakeState !== "warm") {
820
+ forwardedInitializeRequestIds.add(String(parsed.id));
821
+ return false;
822
+ }
823
+
824
+ sendApplicationResponse(JSON.stringify({
825
+ id: parsed.id,
826
+ result: {
827
+ bridgeManaged: true,
828
+ },
829
+ }));
830
+ return true;
831
+ }
832
+
833
+ if (method === "initialized") {
834
+ return codexHandshakeState === "warm";
835
+ }
836
+
837
+ return false;
838
+ }
839
+
840
+ // Learns whether the underlying Codex transport has already completed its own MCP handshake.
841
+ function trackCodexHandshakeState(rawMessage) {
842
+ let parsed = null;
843
+ try {
844
+ parsed = JSON.parse(rawMessage);
845
+ } catch {
846
+ return;
847
+ }
848
+
849
+ const responseId = parsed?.id;
850
+ if (responseId == null) {
851
+ return;
852
+ }
853
+
854
+ const responseKey = String(responseId);
855
+ if (!forwardedInitializeRequestIds.has(responseKey)) {
856
+ return;
857
+ }
858
+
859
+ forwardedInitializeRequestIds.delete(responseKey);
860
+
861
+ if (parsed?.result != null) {
862
+ codexHandshakeState = "warm";
863
+ return;
864
+ }
865
+
866
+ const errorMessage = typeof parsed?.error?.message === "string"
867
+ ? parsed.error.message.toLowerCase()
868
+ : "";
869
+ if (errorMessage.includes("already initialized")) {
870
+ codexHandshakeState = "warm";
871
+ }
872
+ }
873
+
874
+ // Runs bridge-private JSON-RPC calls against the local app-server so token-bearing responses
875
+ // can power bridge features like transcription without ever reaching the phone.
876
+ function sendCodexRequest(method, params) {
877
+ const requestId = `bridge-managed-${randomBytes(12).toString("hex")}`;
878
+ const payload = JSON.stringify({
879
+ id: requestId,
880
+ method,
881
+ params,
882
+ });
883
+
884
+ return new Promise((resolve, reject) => {
885
+ const timeout = setTimeout(() => {
886
+ bridgeManagedCodexRequestWaiters.delete(requestId);
887
+ reject(new Error(`Codex request timed out: ${method}`));
888
+ }, 20_000);
889
+
890
+ bridgeManagedCodexRequestWaiters.set(requestId, {
891
+ method,
892
+ resolve,
893
+ reject,
894
+ timeout,
895
+ });
896
+
897
+ try {
898
+ codex.send(payload);
899
+ } catch (error) {
900
+ clearTimeout(timeout);
901
+ bridgeManagedCodexRequestWaiters.delete(requestId);
902
+ reject(error);
903
+ }
904
+ });
905
+ }
906
+
907
+ // Intercepts responses for bridge-private requests so only user-visible app-server traffic
908
+ // is forwarded back through secure transport.
909
+ function handleBridgeManagedCodexResponse(rawMessage) {
910
+ let parsed = null;
911
+ try {
912
+ parsed = JSON.parse(rawMessage);
913
+ } catch {
914
+ return false;
915
+ }
916
+
917
+ const responseId = typeof parsed?.id === "string" ? parsed.id : null;
918
+ if (!responseId) {
919
+ return false;
920
+ }
921
+
922
+ const waiter = bridgeManagedCodexRequestWaiters.get(responseId);
923
+ if (!waiter) {
924
+ return false;
925
+ }
926
+
927
+ bridgeManagedCodexRequestWaiters.delete(responseId);
928
+ clearTimeout(waiter.timeout);
929
+
930
+ if (parsed.error) {
931
+ const error = new Error(parsed.error.message || `Codex request failed: ${waiter.method}`);
932
+ error.code = parsed.error.code;
933
+ error.data = parsed.error.data;
934
+ waiter.reject(error);
935
+ return true;
936
+ }
937
+
938
+ waiter.resolve(parsed.result ?? null);
939
+ return true;
940
+ }
941
+
942
+ function failBridgeManagedCodexRequests(error) {
943
+ for (const waiter of bridgeManagedCodexRequestWaiters.values()) {
944
+ clearTimeout(waiter.timeout);
945
+ waiter.reject(error);
946
+ }
947
+ bridgeManagedCodexRequestWaiters.clear();
948
+ }
949
+
950
+ function publishBridgeStatus(status) {
951
+ lastPublishedBridgeStatus = status;
952
+ onBridgeStatus?.(status);
953
+ }
954
+
955
+ // Refreshes the relay's trusted-mac index after the QR bootstrap locks in a phone identity.
956
+ function sendRelayRegistrationUpdate(nextDeviceState) {
957
+ deviceState = nextDeviceState;
958
+ if (socket?.readyState !== WebSocket.OPEN) {
959
+ return;
960
+ }
961
+
962
+ socket.send(JSON.stringify({
963
+ kind: "relayMacRegistration",
964
+ registration: buildMacRegistration(nextDeviceState),
965
+ }));
966
+ }
967
+ }
968
+
969
+ // Registers the canonical Mac identity and the one trusted iPhone allowed for auto-resolve.
970
+ function buildMacRegistrationHeaders(deviceState) {
971
+ const registration = buildMacRegistration(deviceState);
972
+ const headers = {
973
+ "x-mac-device-id": registration.macDeviceId,
974
+ "x-mac-identity-public-key": registration.macIdentityPublicKey,
975
+ "x-machine-name": registration.displayName,
976
+ };
977
+ if (registration.trustedPhoneDeviceId && registration.trustedPhonePublicKey) {
978
+ headers["x-trusted-phone-device-id"] = registration.trustedPhoneDeviceId;
979
+ headers["x-trusted-phone-public-key"] = registration.trustedPhonePublicKey;
980
+ }
981
+ return headers;
982
+ }
983
+
984
+ function buildMacRegistration(deviceState) {
985
+ const trustedPhoneEntry = Object.entries(deviceState?.trustedPhones || {})[0] || null;
986
+ return {
987
+ macDeviceId: normalizeNonEmptyString(deviceState?.macDeviceId),
988
+ macIdentityPublicKey: normalizeNonEmptyString(deviceState?.macIdentityPublicKey),
989
+ displayName: normalizeNonEmptyString(os.hostname()),
990
+ trustedPhoneDeviceId: normalizeNonEmptyString(trustedPhoneEntry?.[0]),
991
+ trustedPhonePublicKey: normalizeNonEmptyString(trustedPhoneEntry?.[1]),
992
+ };
993
+ }
994
+
995
+ function shutdown(codex, getSocket, beforeExit = () => {}) {
996
+ beforeExit();
997
+
998
+ const socket = getSocket();
999
+ if (socket?.readyState === WebSocket.OPEN || socket?.readyState === WebSocket.CONNECTING) {
1000
+ socket.close();
1001
+ }
1002
+
1003
+ codex.shutdown();
1004
+
1005
+ setTimeout(() => process.exit(0), 100);
1006
+ }
1007
+
1008
+ function extractBridgeMessageContext(rawMessage) {
1009
+ let parsed = null;
1010
+ try {
1011
+ parsed = JSON.parse(rawMessage);
1012
+ } catch {
1013
+ return { method: "", threadId: null, turnId: null };
1014
+ }
1015
+
1016
+ const method = parsed?.method;
1017
+ const params = parsed?.params;
1018
+ const threadId = extractThreadId(method, params);
1019
+ const turnId = extractTurnId(method, params);
1020
+
1021
+ return {
1022
+ method: typeof method === "string" ? method : "",
1023
+ threadId,
1024
+ turnId,
1025
+ };
1026
+ }
1027
+
1028
+ function shouldStartContextUsageWatcher(context) {
1029
+ if (!context?.threadId) {
1030
+ return false;
1031
+ }
1032
+
1033
+ return context.method === "turn/start"
1034
+ || context.method === "turn/started";
1035
+ }
1036
+
1037
+ function extractThreadId(method, params) {
1038
+ if (method === "turn/start" || method === "turn/started") {
1039
+ return (
1040
+ readString(params?.threadId)
1041
+ || readString(params?.thread_id)
1042
+ || readString(params?.turn?.threadId)
1043
+ || readString(params?.turn?.thread_id)
1044
+ );
1045
+ }
1046
+
1047
+ if (method === "thread/start" || method === "thread/started") {
1048
+ return (
1049
+ readString(params?.threadId)
1050
+ || readString(params?.thread_id)
1051
+ || readString(params?.thread?.id)
1052
+ || readString(params?.thread?.threadId)
1053
+ || readString(params?.thread?.thread_id)
1054
+ );
1055
+ }
1056
+
1057
+ if (method === "turn/completed") {
1058
+ return (
1059
+ readString(params?.threadId)
1060
+ || readString(params?.thread_id)
1061
+ || readString(params?.turn?.threadId)
1062
+ || readString(params?.turn?.thread_id)
1063
+ );
1064
+ }
1065
+
1066
+ return null;
1067
+ }
1068
+
1069
+ function extractTurnId(method, params) {
1070
+ if (method === "turn/started" || method === "turn/completed") {
1071
+ return (
1072
+ readString(params?.turnId)
1073
+ || readString(params?.turn_id)
1074
+ || readString(params?.id)
1075
+ || readString(params?.turn?.id)
1076
+ || readString(params?.turn?.turnId)
1077
+ || readString(params?.turn?.turn_id)
1078
+ );
1079
+ }
1080
+
1081
+ return null;
1082
+ }
1083
+
1084
+ function readString(value) {
1085
+ return typeof value === "string" && value ? value : null;
1086
+ }
1087
+
1088
+ function normalizeNonEmptyString(value) {
1089
+ return typeof value === "string" && value.trim() ? value.trim() : "";
1090
+ }
1091
+
1092
+ // Shrinks `thread/read` and `thread/resume` snapshots by eliding inline image blobs.
1093
+ function sanitizeThreadHistoryImagesForRelay(rawMessage, requestMethod) {
1094
+ if (requestMethod !== "thread/read" && requestMethod !== "thread/resume") {
1095
+ return rawMessage;
1096
+ }
1097
+
1098
+ const parsed = parseBridgeJSON(rawMessage);
1099
+ const thread = parsed?.result?.thread;
1100
+ if (!thread || typeof thread !== "object" || !Array.isArray(thread.turns)) {
1101
+ return rawMessage;
1102
+ }
1103
+
1104
+ let didSanitize = false;
1105
+ const sanitizedTurns = thread.turns.map((turn) => {
1106
+ if (!turn || typeof turn !== "object" || !Array.isArray(turn.items)) {
1107
+ return turn;
1108
+ }
1109
+
1110
+ let turnDidChange = false;
1111
+ const sanitizedItems = turn.items.map((item) => {
1112
+ if (!item || typeof item !== "object" || !Array.isArray(item.content)) {
1113
+ return item;
1114
+ }
1115
+
1116
+ let itemDidChange = false;
1117
+ const sanitizedContent = item.content.map((contentItem) => {
1118
+ const sanitizedEntry = sanitizeInlineHistoryImageContentItem(contentItem);
1119
+ if (sanitizedEntry !== contentItem) {
1120
+ itemDidChange = true;
1121
+ }
1122
+ return sanitizedEntry;
1123
+ });
1124
+
1125
+ if (!itemDidChange) {
1126
+ return item;
1127
+ }
1128
+
1129
+ turnDidChange = true;
1130
+ return {
1131
+ ...item,
1132
+ content: sanitizedContent,
1133
+ };
1134
+ });
1135
+
1136
+ if (!turnDidChange) {
1137
+ return turn;
1138
+ }
1139
+
1140
+ didSanitize = true;
1141
+ return {
1142
+ ...turn,
1143
+ items: sanitizedItems,
1144
+ };
1145
+ });
1146
+
1147
+ if (!didSanitize) {
1148
+ return rawMessage;
1149
+ }
1150
+
1151
+ return JSON.stringify({
1152
+ ...parsed,
1153
+ result: {
1154
+ ...parsed.result,
1155
+ thread: {
1156
+ ...thread,
1157
+ turns: sanitizedTurns,
1158
+ },
1159
+ },
1160
+ });
1161
+ }
1162
+
1163
+ // Converts `data:image/...` history content into a tiny placeholder the iPhone can render safely.
1164
+ function sanitizeInlineHistoryImageContentItem(contentItem) {
1165
+ if (!contentItem || typeof contentItem !== "object") {
1166
+ return contentItem;
1167
+ }
1168
+
1169
+ const normalizedType = normalizeRelayHistoryContentType(contentItem.type);
1170
+ if (normalizedType !== "image" && normalizedType !== "localimage") {
1171
+ return contentItem;
1172
+ }
1173
+
1174
+ const hasInlineUrl = isInlineHistoryImageDataURL(contentItem.url)
1175
+ || isInlineHistoryImageDataURL(contentItem.image_url)
1176
+ || isInlineHistoryImageDataURL(contentItem.path);
1177
+ if (!hasInlineUrl) {
1178
+ return contentItem;
1179
+ }
1180
+
1181
+ const {
1182
+ url: _url,
1183
+ image_url: _imageUrl,
1184
+ path: _path,
1185
+ ...rest
1186
+ } = contentItem;
1187
+
1188
+ return {
1189
+ ...rest,
1190
+ url: RELAY_HISTORY_IMAGE_REFERENCE_URL,
1191
+ };
1192
+ }
1193
+
1194
+ function normalizeRelayHistoryContentType(value) {
1195
+ return typeof value === "string"
1196
+ ? value.toLowerCase().replace(/[\s_-]+/g, "")
1197
+ : "";
1198
+ }
1199
+
1200
+ function isInlineHistoryImageDataURL(value) {
1201
+ return typeof value === "string" && value.toLowerCase().startsWith("data:image");
1202
+ }
1203
+
1204
+ function parseBridgeJSON(value) {
1205
+ try {
1206
+ return JSON.parse(value);
1207
+ } catch {
1208
+ return null;
1209
+ }
1210
+ }
1211
+
1212
+ // Treats silent relay sockets as stale so the daemon can self-heal after sleep/wake.
1213
+ function hasRelayConnectionGoneStale(
1214
+ lastActivityAt,
1215
+ {
1216
+ now = Date.now(),
1217
+ staleAfterMs = RELAY_WATCHDOG_STALE_AFTER_MS,
1218
+ } = {}
1219
+ ) {
1220
+ return Number.isFinite(lastActivityAt)
1221
+ && Number.isFinite(now)
1222
+ && now - lastActivityAt >= staleAfterMs;
1223
+ }
1224
+
1225
+ // Keeps persisted daemon status honest by downgrading stale "connected" snapshots.
1226
+ function buildHeartbeatBridgeStatus(
1227
+ status,
1228
+ lastActivityAt,
1229
+ {
1230
+ now = Date.now(),
1231
+ staleAfterMs = RELAY_WATCHDOG_STALE_AFTER_MS,
1232
+ staleMessage = STALE_RELAY_STATUS_MESSAGE,
1233
+ } = {}
1234
+ ) {
1235
+ if (!status || typeof status !== "object") {
1236
+ return status;
1237
+ }
1238
+
1239
+ if (status.connectionStatus !== "connected") {
1240
+ return status;
1241
+ }
1242
+
1243
+ if (!hasRelayConnectionGoneStale(lastActivityAt, { now, staleAfterMs })) {
1244
+ return status;
1245
+ }
1246
+
1247
+ return {
1248
+ ...status,
1249
+ connectionStatus: "disconnected",
1250
+ lastError: staleMessage,
1251
+ };
1252
+ }
1253
+
1254
+ module.exports = {
1255
+ buildHeartbeatBridgeStatus,
1256
+ hasRelayConnectionGoneStale,
1257
+ sanitizeThreadHistoryImagesForRelay,
1258
+ startBridge,
1259
+ };