@makerbi/remodex 1.5.1 → 1.5.2

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 CHANGED
@@ -5,7 +5,7 @@
5
5
  // Depends on: ws, crypto, os, ./codex-home, ./qr, ./codex-desktop-refresher, ./codex-transport, ./rollout-watch, ./voice-handler, ./ios-app-compatibility
6
6
 
7
7
  const WebSocket = require("ws");
8
- const { randomBytes } = require("crypto");
8
+ const { randomBytes, randomUUID } = require("crypto");
9
9
  const { execFile, spawn } = require("child_process");
10
10
  const path = require("path");
11
11
  const os = require("os");
@@ -15,7 +15,11 @@ const {
15
15
  readBridgeConfig,
16
16
  } = require("./codex-desktop-refresher");
17
17
  const { createCodexTransport } = require("./codex-transport");
18
- const { createThreadRolloutActivityWatcher } = require("./rollout-watch");
18
+ const {
19
+ createThreadRolloutActivityWatcher,
20
+ findRecentRolloutFileForContextRead,
21
+ resolveSessionsRoot,
22
+ } = require("./rollout-watch");
19
23
  const { printQR } = require("./qr");
20
24
  const { rememberActiveThread } = require("./session-state");
21
25
  const { handleDesktopRequest } = require("./desktop-handler");
@@ -52,6 +56,9 @@ const {
52
56
  normalizeVersionString,
53
57
  } = require("./ios-app-compatibility");
54
58
  const { createShortPairingCode, SHORT_PAIRING_CODE_LENGTH } = require("./qr");
59
+ const {
60
+ readThreadTurnsListPageFromSessionJsonl,
61
+ } = require("./session-jsonl-history");
55
62
 
56
63
  const execFileAsync = promisify(execFile);
57
64
  const RELAY_WATCHDOG_PING_INTERVAL_MS = 10_000;
@@ -68,6 +75,8 @@ const RELAY_HISTORY_TEXT_TAIL_LIMIT_CHARS = 24_000;
68
75
  const RELAY_HISTORY_RECENT_TURN_TARGET = 40;
69
76
  const RELAY_TURNS_LIST_TARGET_BUDGET_MS = 5_500;
70
77
  const RELAY_TURNS_LIST_BUDGET_RESERVE_MS = 1_000;
78
+ const RELAY_TURNS_LIST_MAX_INITIAL_LIMIT = 5;
79
+ const RELAY_TURNS_LIST_SAFE_RETRY_LIMIT = 5;
71
80
  const RELAY_TURNS_LIST_RESULT_KEYS = ["data", "items", "turns"];
72
81
  const RELAY_TURNS_LIST_PAGINATION_RESULT_KEYS = [
73
82
  "nextCursor",
@@ -178,12 +187,16 @@ function startBridge({
178
187
  const bridgeManagedCodexRequestWaiters = new Map();
179
188
  const forwardedRequestMethodsById = new Map();
180
189
  const relaySanitizedResponseMethodsById = new Map();
190
+ const relayChannels = [];
191
+ const codexResponseRoutesById = new Map();
192
+ const extraRelaySessionCount = readExtraRelaySessionCount(process.env);
181
193
  const trackedForwardedRequestMethods = new Set([
182
194
  "account/login/start",
183
195
  "account/login/cancel",
184
196
  "account/logout",
185
197
  ]);
186
198
  const relaySanitizedRequestMethods = new Set([
199
+ "thread/list",
187
200
  "thread/read",
188
201
  "thread/resume",
189
202
  "thread/turns/list",
@@ -204,6 +217,7 @@ function startBridge({
204
217
  sendRelayRegistrationUpdate(nextDeviceState);
205
218
  },
206
219
  });
220
+ let primaryRelayChannel = null;
207
221
  // Keeps one stable sender identity across reconnects so buffered replay state
208
222
  // reflects what actually made it onto the current relay socket.
209
223
  function sendRelayWireMessage(wireMessage) {
@@ -446,7 +460,7 @@ function startBridge({
446
460
  }
447
461
  },
448
462
  onApplicationMessage(plaintextMessage) {
449
- handleApplicationMessage(plaintextMessage);
463
+ handleApplicationMessage(plaintextMessage, primaryRelayChannel);
450
464
  },
451
465
  })) {
452
466
  return;
@@ -485,17 +499,168 @@ function startBridge({
485
499
  });
486
500
  }
487
501
 
502
+ // Optional draft path: one bridge process can expose extra one-mobile relay sessions without changing relay behavior.
503
+ function startExtraRelayChannels() {
504
+ for (let index = 1; index <= extraRelaySessionCount; index += 1) {
505
+ startExtraRelayChannel(index);
506
+ }
507
+ }
508
+
509
+ function startExtraRelayChannel(index) {
510
+ const extraSessionId = randomUUID();
511
+ const extraRelaySessionUrl = `${relayBaseUrl}/${extraSessionId}`;
512
+ let reconnectTimerForChannel = null;
513
+ let reconnectAttemptForChannel = 0;
514
+ const extraSecureTransport = createBridgeSecureTransport({
515
+ sessionId: extraSessionId,
516
+ relayUrl: relayBaseUrl,
517
+ deviceState,
518
+ onTrustedPhoneUpdate(nextDeviceState) {
519
+ deviceState = nextDeviceState;
520
+ sendRelayRegistrationUpdate(nextDeviceState);
521
+ sendExtraRelayRegistrationUpdate(extraRelayChannel, nextDeviceState);
522
+ },
523
+ });
524
+ const extraPairingSession = {
525
+ pairingPayload: extraSecureTransport.createPairingPayload(),
526
+ pairingCode: createShortPairingCode({ length: SHORT_PAIRING_CODE_LENGTH }),
527
+ };
528
+ const extraRelayChannel = {
529
+ label: `extra-${index}`,
530
+ pairingSession: extraPairingSession,
531
+ secureTransport: extraSecureTransport,
532
+ socket: null,
533
+ closing: false,
534
+ sendWireMessage(wireMessage) {
535
+ if (extraRelayChannel.socket?.readyState !== WebSocket.OPEN) {
536
+ return false;
537
+ }
538
+
539
+ extraRelayChannel.socket.send(wireMessage);
540
+ return true;
541
+ },
542
+ close() {
543
+ extraRelayChannel.closing = true;
544
+ if (reconnectTimerForChannel) {
545
+ clearTimeout(reconnectTimerForChannel);
546
+ reconnectTimerForChannel = null;
547
+ }
548
+ if (
549
+ extraRelayChannel.socket?.readyState === WebSocket.OPEN
550
+ || extraRelayChannel.socket?.readyState === WebSocket.CONNECTING
551
+ ) {
552
+ extraRelayChannel.socket.close();
553
+ }
554
+ },
555
+ };
556
+ relayChannels.push(extraRelayChannel);
557
+
558
+ if (printPairingQr) {
559
+ console.error(`[remodex] Pair device ${index + 1}: scan this QR from your other device.`);
560
+ printQR(extraPairingSession);
561
+ }
562
+
563
+ connectExtraRelay();
564
+
565
+ function scheduleExtraReconnect() {
566
+ if (isShuttingDown || extraRelayChannel.closing || reconnectTimerForChannel) {
567
+ return;
568
+ }
569
+
570
+ reconnectAttemptForChannel += 1;
571
+ const delayMs = Math.min(1_000 * reconnectAttemptForChannel, 5_000);
572
+ reconnectTimerForChannel = setTimeout(() => {
573
+ reconnectTimerForChannel = null;
574
+ connectExtraRelay();
575
+ }, delayMs);
576
+ }
577
+
578
+ function connectExtraRelay() {
579
+ if (isShuttingDown || extraRelayChannel.closing) {
580
+ return;
581
+ }
582
+
583
+ const nextSocket = new WebSocket(extraRelaySessionUrl, {
584
+ headers: {
585
+ "x-role": "mac",
586
+ "x-notification-secret": notificationSecret,
587
+ ...buildMacRegistrationHeaders(deviceState, extraPairingSession),
588
+ },
589
+ });
590
+ extraRelayChannel.socket = nextSocket;
591
+
592
+ nextSocket.on("open", () => {
593
+ reconnectAttemptForChannel = 0;
594
+ extraSecureTransport.bindLiveSendWireMessage(extraRelayChannel.sendWireMessage);
595
+ sendExtraRelayRegistrationUpdate(extraRelayChannel, deviceState);
596
+ });
597
+
598
+ nextSocket.on("message", (data) => {
599
+ const message = typeof data === "string" ? data : data.toString("utf8");
600
+ extraSecureTransport.handleIncomingWireMessage(message, {
601
+ sendControlMessage(controlMessage) {
602
+ if (nextSocket.readyState === WebSocket.OPEN) {
603
+ nextSocket.send(JSON.stringify(controlMessage));
604
+ }
605
+ },
606
+ onApplicationMessage(plaintextMessage) {
607
+ handleApplicationMessage(plaintextMessage, extraRelayChannel);
608
+ },
609
+ });
610
+ });
611
+
612
+ nextSocket.on("close", () => {
613
+ if (extraRelayChannel.socket === nextSocket) {
614
+ extraRelayChannel.socket = null;
615
+ }
616
+ scheduleExtraReconnect();
617
+ });
618
+
619
+ nextSocket.on("error", () => {});
620
+ }
621
+ }
622
+
623
+ function closeExtraRelayChannels() {
624
+ for (const relayChannel of relayChannels) {
625
+ if (relayChannel !== primaryRelayChannel && typeof relayChannel.close === "function") {
626
+ relayChannel.close();
627
+ }
628
+ }
629
+ }
630
+
631
+ function sendExtraRelayRegistrationUpdate(relayChannel, nextDeviceState) {
632
+ if (!relayChannel?.socket || relayChannel.socket.readyState !== WebSocket.OPEN) {
633
+ return;
634
+ }
635
+
636
+ relayChannel.socket.send(JSON.stringify({
637
+ kind: "relayMacRegistration",
638
+ registration: buildMacRegistration(nextDeviceState, relayChannel.pairingSession),
639
+ }));
640
+ }
641
+
488
642
  const pairingPayload = secureTransport.createPairingPayload();
489
643
  const pairingSession = {
490
644
  pairingPayload,
491
645
  pairingCode: createShortPairingCode({ length: SHORT_PAIRING_CODE_LENGTH }),
492
646
  };
647
+ primaryRelayChannel = {
648
+ label: "primary",
649
+ pairingSession,
650
+ secureTransport,
651
+ sendWireMessage: sendRelayWireMessage,
652
+ };
653
+ relayChannels.push(primaryRelayChannel);
493
654
  onPairingSession?.(pairingSession);
494
655
  if (printPairingQr) {
656
+ if (extraRelaySessionCount > 0) {
657
+ console.error("[remodex] Pair device 1: scan this QR from your first device.");
658
+ }
495
659
  printQR(pairingSession);
496
660
  }
497
661
  pushServiceClient.logUnavailable();
498
662
  connectRelay();
663
+ startExtraRelayChannels();
499
664
 
500
665
  codex.onMessage((message) => {
501
666
  if (handleBridgeManagedCodexResponse(message)) {
@@ -506,10 +671,7 @@ function startBridge({
506
671
  desktopRefresher.handleOutbound(message);
507
672
  pushNotificationTracker.handleOutbound(message);
508
673
  rememberThreadFromMessage("codex", message);
509
- secureTransport.queueOutboundApplicationMessage(
510
- sanitizeRelayBoundCodexMessage(message),
511
- sendRelayWireMessage
512
- );
674
+ sendCodexOutboundToMobile(message);
513
675
  });
514
676
 
515
677
  codex.onClose(() => {
@@ -532,9 +694,11 @@ function startBridge({
532
694
  desktopRefresher.handleTransportReset();
533
695
  failBridgeManagedCodexRequests(new Error("Codex transport closed before the bridge request completed."));
534
696
  forwardedRequestMethodsById.clear();
697
+ codexResponseRoutesById.clear();
535
698
  if (socket?.readyState === WebSocket.OPEN || socket?.readyState === WebSocket.CONNECTING) {
536
699
  socket.close();
537
700
  }
701
+ closeExtraRelayChannels();
538
702
  });
539
703
 
540
704
  process.on("SIGINT", () => shutdown(codex, () => socket, () => {
@@ -543,6 +707,7 @@ function startBridge({
543
707
  clearReconnectTimer();
544
708
  clearRelayWatchdog();
545
709
  clearBridgeStatusHeartbeat();
710
+ closeExtraRelayChannels();
546
711
  }));
547
712
  process.on("SIGTERM", () => shutdown(codex, () => socket, () => {
548
713
  isShuttingDown = true;
@@ -550,35 +715,37 @@ function startBridge({
550
715
  clearReconnectTimer();
551
716
  clearRelayWatchdog();
552
717
  clearBridgeStatusHeartbeat();
718
+ closeExtraRelayChannels();
553
719
  }));
554
720
 
555
721
  // Routes decrypted app payloads through the same bridge handlers as before.
556
- function handleApplicationMessage(rawMessage) {
557
- if (handleBridgeManagedHandshakeMessage(rawMessage)) {
722
+ function handleApplicationMessage(rawMessage, relayChannel = primaryRelayChannel) {
723
+ const sendResponse = (responseMessage) => sendApplicationResponseToChannel(responseMessage, relayChannel);
724
+ if (handleBridgeManagedHandshakeMessage(rawMessage, sendResponse)) {
558
725
  return;
559
726
  }
560
- if (handleBridgeManagedAccountRequest(rawMessage, sendApplicationResponse)) {
727
+ if (handleBridgeManagedAccountRequest(rawMessage, sendResponse)) {
561
728
  return;
562
729
  }
563
- if (voiceHandler.handleVoiceRequest(rawMessage, sendApplicationResponse)) {
730
+ if (voiceHandler.handleVoiceRequest(rawMessage, sendResponse)) {
564
731
  return;
565
732
  }
566
- if (handleThreadContextRequest(rawMessage, sendApplicationResponse)) {
733
+ if (handleThreadContextRequest(rawMessage, sendResponse)) {
567
734
  return;
568
735
  }
569
- if (handleWorkspaceRequest(rawMessage, sendApplicationResponse)) {
736
+ if (handleWorkspaceRequest(rawMessage, sendResponse)) {
570
737
  return;
571
738
  }
572
- if (handleProjectRequest(rawMessage, sendApplicationResponse)) {
739
+ if (handleProjectRequest(rawMessage, sendResponse)) {
573
740
  return;
574
741
  }
575
- if (handlePetRequest(rawMessage, sendApplicationResponse)) {
742
+ if (handlePetRequest(rawMessage, sendResponse)) {
576
743
  return;
577
744
  }
578
- if (notificationsHandler.handleNotificationsRequest(rawMessage, sendApplicationResponse)) {
745
+ if (notificationsHandler.handleNotificationsRequest(rawMessage, sendResponse)) {
579
746
  return;
580
747
  }
581
- if (handleDesktopRequest(rawMessage, sendApplicationResponse, {
748
+ if (handleDesktopRequest(rawMessage, sendResponse, {
582
749
  bundleId: config.codexBundleId,
583
750
  appPath: config.codexAppPath,
584
751
  readBridgePreferences,
@@ -586,7 +753,7 @@ function startBridge({
586
753
  })) {
587
754
  return;
588
755
  }
589
- if (handleGitRequest(rawMessage, sendApplicationResponse, {
756
+ if (handleGitRequest(rawMessage, sendResponse, {
590
757
  codexAppPath: config.codexAppPath,
591
758
  onThreadNameSet: sendThreadNameUpdatedNotification,
592
759
  })) {
@@ -597,22 +764,189 @@ function startBridge({
597
764
  if (desktopIpcActionFollower?.observeInbound(rawMessage)) {
598
765
  return;
599
766
  }
600
- if (handleBridgeManagedThreadTurnsListRequest(rawMessage)) {
767
+ if (handleBridgeManagedThreadTurnsListRequest(rawMessage, sendResponse)) {
601
768
  return;
602
769
  }
603
- rememberForwardedRequestMethod(rawMessage);
770
+ const codexMessage = prepareCodexForwardMessage(rawMessage, relayChannel);
771
+ rememberForwardedRequestMethod(codexMessage);
604
772
  rememberThreadFromMessage("phone", rawMessage);
605
- codex.send(rawMessage);
773
+ mirrorUserMessageToPeerDevices(rawMessage, relayChannel);
774
+ codex.send(codexMessage);
606
775
  }
607
776
 
608
777
  // Encrypts bridge-generated responses instead of letting the relay see plaintext.
609
778
  function sendApplicationResponse(rawMessage) {
610
- secureTransport.queueOutboundApplicationMessage(
611
- sanitizeRelayBoundCodexMessage(rawMessage),
612
- sendRelayWireMessage
779
+ sendApplicationResponseToChannels(rawMessage, relayChannels);
780
+ }
781
+
782
+ function sendApplicationResponseToChannel(rawMessage, relayChannel = primaryRelayChannel) {
783
+ sendApplicationResponseToChannels(rawMessage, [relayChannel]);
784
+ }
785
+
786
+ function sendApplicationResponseToChannels(rawMessage, channels) {
787
+ const normalizedChannels = channels.filter(Boolean);
788
+ if (normalizedChannels.length === 0) {
789
+ return;
790
+ }
791
+
792
+ const sanitizedMessage = sanitizeRelayBoundCodexMessage(rawMessage);
793
+ for (const relayChannel of normalizedChannels) {
794
+ queueSanitizedApplicationMessageToChannel(sanitizedMessage, relayChannel);
795
+ }
796
+ }
797
+
798
+ function queueSanitizedApplicationMessageToChannel(sanitizedMessage, relayChannel) {
799
+ if (!relayChannel) {
800
+ return;
801
+ }
802
+
803
+ relayChannel.secureTransport.queueOutboundApplicationMessage(
804
+ sanitizedMessage,
805
+ relayChannel.sendWireMessage
613
806
  );
614
807
  }
615
808
 
809
+ // Rewrites mobile request ids per relay channel so iPhone/iPad can use overlapping JSON-RPC ids safely.
810
+ function prepareCodexForwardMessage(rawMessage, relayChannel = primaryRelayChannel) {
811
+ const parsed = safeParseJSON(rawMessage);
812
+ if (!parsed || parsed.id == null || !relayChannel?.label) {
813
+ return rawMessage;
814
+ }
815
+
816
+ pruneExpiredCodexResponseRoutes();
817
+ const originalId = parsed.id;
818
+ const forwardedId = `mobile:${relayChannel.label}:${randomBytes(8).toString("hex")}`;
819
+ if (parsed.method === "initialize") {
820
+ forwardedInitializeRequestIds.delete(String(originalId));
821
+ forwardedInitializeRequestIds.add(String(forwardedId));
822
+ }
823
+ parsed.id = forwardedId;
824
+ codexResponseRoutesById.set(String(forwardedId), {
825
+ relayChannel,
826
+ originalId,
827
+ createdAt: Date.now(),
828
+ });
829
+ return JSON.stringify(parsed);
830
+ }
831
+
832
+ function sendCodexOutboundToMobile(rawMessage) {
833
+ pruneExpiredCodexResponseRoutes();
834
+ const parsed = safeParseJSON(rawMessage);
835
+ const responseId = parsed?.id;
836
+ if (responseId != null) {
837
+ const route = codexResponseRoutesById.get(String(responseId));
838
+ if (route) {
839
+ codexResponseRoutesById.delete(String(responseId));
840
+ const sanitizedMessage = sanitizeRelayBoundCodexMessage(rawMessage);
841
+ const sanitizedParsed = safeParseJSON(sanitizedMessage);
842
+ if (sanitizedParsed && typeof sanitizedParsed === "object") {
843
+ sanitizedParsed.id = route.originalId;
844
+ queueSanitizedApplicationMessageToChannel(JSON.stringify(sanitizedParsed), route.relayChannel);
845
+ return;
846
+ }
847
+ queueSanitizedApplicationMessageToChannel(sanitizedMessage, route.relayChannel);
848
+ return;
849
+ }
850
+ }
851
+
852
+ sendApplicationResponse(rawMessage);
853
+ }
854
+
855
+ function pruneExpiredCodexResponseRoutes() {
856
+ const cutoff = Date.now() - forwardedRequestMethodTTLms;
857
+ for (const [requestId, route] of codexResponseRoutesById.entries()) {
858
+ if (!route || route.createdAt < cutoff) {
859
+ codexResponseRoutesById.delete(requestId);
860
+ }
861
+ }
862
+ }
863
+
864
+ // Keeps secondary devices' timelines ordered by echoing the user's prompt before assistant deltas arrive.
865
+ function mirrorUserMessageToPeerDevices(rawMessage, originRelayChannel) {
866
+ const mirrorNotification = buildPeerUserMessageNotification(rawMessage);
867
+ if (!mirrorNotification) {
868
+ return;
869
+ }
870
+
871
+ const peerChannels = relayChannels.filter((relayChannel) => relayChannel !== originRelayChannel);
872
+ if (peerChannels.length === 0) {
873
+ return;
874
+ }
875
+
876
+ sendApplicationResponseToChannels(JSON.stringify(mirrorNotification), peerChannels);
877
+ }
878
+
879
+ function buildPeerUserMessageNotification(rawMessage) {
880
+ const parsed = safeParseJSON(rawMessage);
881
+ const method = typeof parsed?.method === "string" ? parsed.method.trim() : "";
882
+ if (method !== "turn/start" && method !== "turn/steer") {
883
+ return null;
884
+ }
885
+
886
+ const params = parsed?.params && typeof parsed.params === "object" ? parsed.params : null;
887
+ const threadId = readString(params?.threadId || params?.thread_id);
888
+ const text = extractTextFromTurnPayload(params);
889
+ if (!threadId || !text) {
890
+ return null;
891
+ }
892
+
893
+ const turnId = readString(params?.turnId || params?.turn_id || params?.expectedTurnId || params?.expected_turn_id);
894
+ return {
895
+ method: "codex/event/user_message",
896
+ params: {
897
+ threadId,
898
+ thread_id: threadId,
899
+ turnId: turnId || undefined,
900
+ turn_id: turnId || undefined,
901
+ message: text,
902
+ text,
903
+ source: "peer-mobile",
904
+ },
905
+ };
906
+ }
907
+
908
+ function extractTextFromTurnPayload(params) {
909
+ const directText = readString(params?.message || params?.text || params?.prompt);
910
+ if (directText) {
911
+ return directText;
912
+ }
913
+
914
+ return extractTextFromTurnInput(params?.input);
915
+ }
916
+
917
+ function extractTextFromTurnInput(input) {
918
+ if (typeof input === "string") {
919
+ return readString(input);
920
+ }
921
+
922
+ if (input && typeof input === "object" && !Array.isArray(input)) {
923
+ const directText = readString(input.text || input.message || input.content);
924
+ if (directText) {
925
+ return directText;
926
+ }
927
+ }
928
+
929
+ const inputItems = Array.isArray(input)
930
+ ? input
931
+ : Array.isArray(input?.items)
932
+ ? input.items
933
+ : [];
934
+ const textParts = [];
935
+ for (const item of inputItems) {
936
+ if (!item || typeof item !== "object") {
937
+ continue;
938
+ }
939
+
940
+ const itemType = readString(item.type).toLowerCase();
941
+ const itemText = readString(item.text || item.message || item.content);
942
+ if ((itemType === "text" || itemType === "input_text" || itemType === "message") && itemText) {
943
+ textParts.push(itemText);
944
+ }
945
+ }
946
+
947
+ return readString(textParts.join("\n\n"));
948
+ }
949
+
616
950
  // Mirrors accepted local renames back to the phone using the existing push-event shape.
617
951
  function sendThreadNameUpdatedNotification(result) {
618
952
  const threadId = readString(result?.threadId || result?.thread_id);
@@ -632,7 +966,7 @@ function startBridge({
632
966
  }));
633
967
  }
634
968
 
635
- function handleBridgeManagedThreadTurnsListRequest(rawMessage) {
969
+ function handleBridgeManagedThreadTurnsListRequest(rawMessage, sendResponse = sendApplicationResponse) {
636
970
  const request = parseAdaptiveThreadTurnsListRequest(rawMessage);
637
971
  if (!request) {
638
972
  return false;
@@ -644,13 +978,14 @@ function startBridge({
644
978
  const response = await fetchAdaptiveThreadTurnsListForRelay(request, {
645
979
  fetchPage: (params) => sendCodexRequest("thread/turns/list", params),
646
980
  });
981
+ const fallbackResponse = maybeBuildJsonlThreadTurnsListFallback(request, response);
647
982
  relaySanitizedResponseMethodsById.set(String(request.id), {
648
983
  method: "thread/turns/list",
649
984
  createdAt: Date.now(),
650
985
  });
651
- sendApplicationResponse(JSON.stringify(response));
986
+ sendResponse(JSON.stringify(fallbackResponse ?? response));
652
987
  } catch (error) {
653
- sendApplicationResponse(createJsonRpcErrorResponse(
988
+ sendResponse(createJsonRpcErrorResponse(
654
989
  request.id,
655
990
  error,
656
991
  "thread_turns_list_failed"
@@ -661,6 +996,44 @@ function startBridge({
661
996
  return true;
662
997
  }
663
998
 
999
+ function maybeBuildJsonlThreadTurnsListFallback(request, response) {
1000
+ if (!isEmptyTurnsListResponse(response)) {
1001
+ return null;
1002
+ }
1003
+
1004
+ const params = request?.params || {};
1005
+ const threadId = normalizeNonEmptyString(params.threadId)
1006
+ || normalizeNonEmptyString(params.thread_id);
1007
+ if (!threadId || hasRelayCursor(params.cursor)) {
1008
+ return null;
1009
+ }
1010
+
1011
+ try {
1012
+ const rolloutPath = findRecentRolloutFileForContextRead(resolveSessionsRoot(), { threadId });
1013
+ if (!rolloutPath) {
1014
+ return null;
1015
+ }
1016
+ const result = readThreadTurnsListPageFromSessionJsonl(rolloutPath, {
1017
+ threadId,
1018
+ limit: params.limit,
1019
+ maxLimit: 1,
1020
+ cursor: params.cursor,
1021
+ });
1022
+ const turnsKey = findTurnsListResultKey(result);
1023
+ if (!turnsKey || result[turnsKey].length === 0) {
1024
+ return null;
1025
+ }
1026
+
1027
+ return {
1028
+ id: request.id,
1029
+ result,
1030
+ };
1031
+ } catch (error) {
1032
+ console.warn(`[remodex] thread/turns/list jsonl fallback failed: ${error.message}`);
1033
+ return null;
1034
+ }
1035
+ }
1036
+
664
1037
  // ─── Bridge-owned auth snapshot ─────────────────────────────
665
1038
 
666
1039
  // Handles the bridge-owned auth status wrappers without exposing tokens to the phone.
@@ -819,19 +1192,26 @@ function startBridge({
819
1192
  // Replaces huge inline desktop-history images with lightweight references before relay encryption.
820
1193
  function sanitizeRelayBoundCodexMessage(rawMessage) {
821
1194
  pruneExpiredForwardedRequestMethods();
822
- const parsed = safeParseJSON(rawMessage);
1195
+ const normalizedMessage = normalizeRelayBoundJsonRpcMessage(rawMessage, {
1196
+ pendingRequestMethodsById: relaySanitizedResponseMethodsById,
1197
+ });
1198
+ if (!normalizedMessage) {
1199
+ return null;
1200
+ }
1201
+
1202
+ const parsed = safeParseJSON(normalizedMessage);
823
1203
  const responseId = parsed?.id;
824
1204
  if (responseId == null) {
825
- return sanitizeLiveGeneratedImageMessageForRelay(rawMessage);
1205
+ return sanitizeLiveGeneratedImageMessageForRelay(normalizedMessage);
826
1206
  }
827
1207
 
828
1208
  const trackedRequest = relaySanitizedResponseMethodsById.get(String(responseId));
829
1209
  if (!trackedRequest) {
830
- return rawMessage;
1210
+ return normalizedMessage;
831
1211
  }
832
1212
  relaySanitizedResponseMethodsById.delete(String(responseId));
833
1213
 
834
- return sanitizeThreadHistoryImagesForRelay(rawMessage, trackedRequest.method);
1214
+ return sanitizeThreadHistoryImagesForRelay(normalizedMessage, trackedRequest.method);
835
1215
  }
836
1216
 
837
1217
  function updatePendingAuthLoginFromCodexMessage(rawMessage) {
@@ -982,7 +1362,7 @@ function startBridge({
982
1362
  // The spawned/shared Codex app-server stays warm across phone reconnects.
983
1363
  // When iPhone reconnects it sends initialize again, but forwarding that to the
984
1364
  // already-initialized Codex transport only produces "Already initialized".
985
- function handleBridgeManagedHandshakeMessage(rawMessage) {
1365
+ function handleBridgeManagedHandshakeMessage(rawMessage, sendResponse = sendApplicationResponse) {
986
1366
  let parsed = null;
987
1367
  try {
988
1368
  parsed = JSON.parse(rawMessage);
@@ -998,7 +1378,7 @@ function startBridge({
998
1378
  if (method === "initialize" && parsed.id != null) {
999
1379
  const compatibilityError = bridgeManagedInitializeCompatibilityError(parsed.params || {});
1000
1380
  if (compatibilityError) {
1001
- sendApplicationResponse(JSON.stringify({
1381
+ sendResponse(JSON.stringify({
1002
1382
  id: parsed.id,
1003
1383
  error: compatibilityError,
1004
1384
  }));
@@ -1010,7 +1390,7 @@ function startBridge({
1010
1390
  return false;
1011
1391
  }
1012
1392
 
1013
- sendApplicationResponse(JSON.stringify({
1393
+ sendResponse(JSON.stringify({
1014
1394
  id: parsed.id,
1015
1395
  result: {
1016
1396
  bridgeManaged: true,
@@ -1174,10 +1554,21 @@ function startBridge({
1174
1554
  return true;
1175
1555
  }
1176
1556
 
1177
- waiter.resolve(parsed.result ?? null);
1557
+ waiter.resolve(readBridgeManagedSuccessPayload(parsed));
1178
1558
  return true;
1179
1559
  }
1180
1560
 
1561
+ // Normalizes private app-server responses before the bridge re-wraps them for iOS.
1562
+ function readBridgeManagedSuccessPayload(parsed) {
1563
+ if (Object.prototype.hasOwnProperty.call(parsed, "result")) {
1564
+ return parsed.result ?? null;
1565
+ }
1566
+ if (Object.prototype.hasOwnProperty.call(parsed, "payload")) {
1567
+ return parsed.payload ?? null;
1568
+ }
1569
+ return null;
1570
+ }
1571
+
1181
1572
  function failBridgeManagedCodexRequests(error) {
1182
1573
  for (const waiter of bridgeManagedCodexRequestWaiters.values()) {
1183
1574
  clearTimeout(waiter.timeout);
@@ -1199,6 +1590,11 @@ function startBridge({
1199
1590
  function sendRelayRegistrationUpdate(nextDeviceState) {
1200
1591
  deviceState = nextDeviceState;
1201
1592
  if (socket?.readyState !== WebSocket.OPEN) {
1593
+ for (const relayChannel of relayChannels) {
1594
+ if (relayChannel !== primaryRelayChannel) {
1595
+ sendExtraRelayRegistrationUpdate(relayChannel, nextDeviceState);
1596
+ }
1597
+ }
1202
1598
  return;
1203
1599
  }
1204
1600
 
@@ -1206,6 +1602,11 @@ function startBridge({
1206
1602
  kind: "relayMacRegistration",
1207
1603
  registration: buildMacRegistration(nextDeviceState, pairingSession),
1208
1604
  }));
1605
+ for (const relayChannel of relayChannels) {
1606
+ if (relayChannel !== primaryRelayChannel) {
1607
+ sendExtraRelayRegistrationUpdate(relayChannel, nextDeviceState);
1608
+ }
1609
+ }
1209
1610
  }
1210
1611
 
1211
1612
  function readBridgePreferences() {
@@ -1329,7 +1730,7 @@ function createMacOSBridgeWakeAssertion({
1329
1730
  };
1330
1731
  }
1331
1732
 
1332
- // Registers the canonical Mac identity and the one trusted iPhone allowed for auto-resolve.
1733
+ // Registers the canonical Mac identity; legacy relay headers can expose one trusted device for auto-resolve.
1333
1734
  function buildMacRegistrationHeaders(deviceState, pairingSession) {
1334
1735
  const registration = buildMacRegistration(deviceState, pairingSession);
1335
1736
  const headers = {
@@ -1347,6 +1748,20 @@ function buildMacRegistrationHeaders(deviceState, pairingSession) {
1347
1748
  return headers;
1348
1749
  }
1349
1750
 
1751
+ function readExtraRelaySessionCount(env = process.env) {
1752
+ const rawValue = readString(env.REMODEX_EXTRA_RELAY_SESSIONS || env.PHODEX_EXTRA_RELAY_SESSIONS);
1753
+ if (!rawValue) {
1754
+ return 0;
1755
+ }
1756
+
1757
+ const parsed = Number.parseInt(rawValue, 10);
1758
+ if (!Number.isFinite(parsed) || parsed <= 0) {
1759
+ return 0;
1760
+ }
1761
+
1762
+ return Math.min(parsed, 3);
1763
+ }
1764
+
1350
1765
  function buildMacRegistration(deviceState, pairingSession) {
1351
1766
  const trustedPhoneEntry = Object.entries(deviceState?.trustedPhones || {})[0] || null;
1352
1767
  return {
@@ -1535,7 +1950,7 @@ async function fetchAdaptiveThreadTurnsListForRelay(request, {
1535
1950
 
1536
1951
  const params = request?.params;
1537
1952
  const requestedLimit = Number.isInteger(params?.limit) && params.limit > 0
1538
- ? params.limit
1953
+ ? Math.min(params.limit, RELAY_TURNS_LIST_MAX_INITIAL_LIMIT)
1539
1954
  : 1;
1540
1955
  const startedAt = now();
1541
1956
  let nextCursor = params?.cursor;
@@ -1557,17 +1972,24 @@ async function fetchAdaptiveThreadTurnsListForRelay(request, {
1557
1972
  if (response) {
1558
1973
  return response;
1559
1974
  }
1560
- throw error;
1975
+ return await fetchSafeThreadTurnsListFallback(request, {
1976
+ fetchPage,
1977
+ now,
1978
+ sanitizeForRelay,
1979
+ payloadSoftLimitBytes,
1980
+ });
1561
1981
  }
1562
1982
 
1563
- const pageResult = page.result;
1983
+ const pageResult = unwrapAppServerPayloadResult(page.result);
1564
1984
  const pageTurnsKey = findTurnsListResultKey(pageResult);
1565
1985
  if (!pageTurnsKey) {
1566
1986
  if (!response) {
1567
- return {
1568
- id: request.id,
1569
- result: pageResult ?? null,
1570
- };
1987
+ return await fetchSafeThreadTurnsListFallback(request, {
1988
+ fetchPage,
1989
+ now,
1990
+ sanitizeForRelay,
1991
+ payloadSoftLimitBytes,
1992
+ });
1571
1993
  }
1572
1994
  return response;
1573
1995
  }
@@ -1582,10 +2004,21 @@ async function fetchAdaptiveThreadTurnsListForRelay(request, {
1582
2004
 
1583
2005
  const pageTurns = pageResult[pageTurnsKey];
1584
2006
  combinedTurns = combinedTurns.concat(pageTurns);
1585
- response = {
1586
- id: request.id,
1587
- result: buildAdaptiveTurnsListResult(firstResult, lastResult, turnsKey, combinedTurns),
1588
- };
2007
+ response = buildSafeTurnsListResponse(request.id, firstResult, lastResult, turnsKey, combinedTurns);
2008
+
2009
+ if (measureSanitizedTurnsListResponseBytes(response, sanitizeForRelay) >= payloadSoftLimitBytes) {
2010
+ response = buildLargestSafeTurnsListResponse({
2011
+ requestId: request.id,
2012
+ firstResult,
2013
+ lastResult,
2014
+ turnsKey,
2015
+ turns: combinedTurns,
2016
+ maxTurns: RELAY_TURNS_LIST_SAFE_RETRY_LIMIT,
2017
+ sanitizeForRelay,
2018
+ payloadSoftLimitBytes,
2019
+ }) ?? buildEmptyTurnsListResponse(request);
2020
+ break;
2021
+ }
1589
2022
 
1590
2023
  nextCursor = readTurnsListNextCursor(pageResult);
1591
2024
  if (combinedTurns.length >= requestedLimit || !hasRelayCursor(nextCursor) || pageTurns.length === 0) {
@@ -1614,6 +2047,64 @@ async function fetchAdaptiveThreadTurnsListForRelay(request, {
1614
2047
  };
1615
2048
  }
1616
2049
 
2050
+ function buildEmptyTurnsListResponse(request) {
2051
+ return {
2052
+ id: request.id,
2053
+ result: {
2054
+ data: [],
2055
+ nextCursor: null,
2056
+ },
2057
+ };
2058
+ }
2059
+
2060
+ function isEmptyTurnsListResponse(response) {
2061
+ const turnsKey = findTurnsListResultKey(response?.result);
2062
+ return Boolean(turnsKey) && response.result[turnsKey].length === 0;
2063
+ }
2064
+
2065
+ async function fetchSafeThreadTurnsListFallback(request, {
2066
+ fetchPage,
2067
+ now,
2068
+ sanitizeForRelay,
2069
+ payloadSoftLimitBytes,
2070
+ }) {
2071
+ const params = request?.params;
2072
+ const requestedLimit = Number.isInteger(params?.limit) && params.limit > 0
2073
+ ? params.limit
2074
+ : RELAY_TURNS_LIST_SAFE_RETRY_LIMIT;
2075
+ const safeLimit = Math.min(requestedLimit, RELAY_TURNS_LIST_SAFE_RETRY_LIMIT);
2076
+ const safeParams = buildAdaptiveTurnsListPageParams(params, safeLimit, params?.cursor);
2077
+
2078
+ try {
2079
+ const page = await fetchMeasuredAdaptiveTurnsListPage(fetchPage, safeParams, now);
2080
+ const pageResult = unwrapAppServerPayloadResult(page.result);
2081
+ const turnsKey = findTurnsListResultKey(pageResult);
2082
+ if (!turnsKey) {
2083
+ return buildEmptyTurnsListResponse(request);
2084
+ }
2085
+
2086
+ // If the normal pagination path returns a bad first page, retry once with a small page.
2087
+ // The retry response is intentionally minimal so Swift does not decode stale server metadata.
2088
+ const response = buildLargestSafeTurnsListResponse({
2089
+ requestId: request.id,
2090
+ firstResult: pageResult,
2091
+ lastResult: pageResult,
2092
+ turnsKey,
2093
+ turns: pageResult[turnsKey],
2094
+ maxTurns: safeLimit,
2095
+ sanitizeForRelay,
2096
+ payloadSoftLimitBytes,
2097
+ });
2098
+ if (response) {
2099
+ return response;
2100
+ }
2101
+ } catch {
2102
+ // Fall through to a valid empty page: the phone can keep the thread open instead of crashing.
2103
+ }
2104
+
2105
+ return buildEmptyTurnsListResponse(request);
2106
+ }
2107
+
1617
2108
  async function fetchMeasuredAdaptiveTurnsListPage(fetchPage, params, now) {
1618
2109
  const startedAt = now();
1619
2110
  const result = await fetchPage(params);
@@ -1654,13 +2145,115 @@ function findTurnsListResultKey(result) {
1654
2145
  return RELAY_TURNS_LIST_RESULT_KEYS.find((key) => Array.isArray(result[key])) || null;
1655
2146
  }
1656
2147
 
1657
- function buildAdaptiveTurnsListResult(firstResult, lastResult, turnsKey, turns) {
1658
- const result = {
1659
- ...firstResult,
2148
+ function buildSafeTurnsListResponse(requestId, firstResult, lastResult, turnsKey, turns) {
2149
+ return {
2150
+ id: requestId,
2151
+ result: buildAdaptiveTurnsListResult(firstResult, lastResult, turnsKey, turns),
1660
2152
  };
1661
- for (const key of RELAY_TURNS_LIST_RESULT_KEYS) {
1662
- delete result[key];
2153
+ }
2154
+
2155
+ // Trims oversized history pages progressively: normal page -> 5 turns -> ... -> 1 turn.
2156
+ function buildLargestSafeTurnsListResponse({
2157
+ requestId,
2158
+ firstResult,
2159
+ lastResult,
2160
+ turnsKey,
2161
+ turns,
2162
+ maxTurns,
2163
+ sanitizeForRelay,
2164
+ payloadSoftLimitBytes,
2165
+ }) {
2166
+ const sliceLimit = Math.min(turns.length, maxTurns);
2167
+ for (let count = sliceLimit; count > 0; count -= 1) {
2168
+ const response = buildSafeTurnsListResponse(
2169
+ requestId,
2170
+ firstResult,
2171
+ lastResult,
2172
+ turnsKey,
2173
+ turns.slice(0, count)
2174
+ );
2175
+ if (measureSanitizedTurnsListResponseBytes(response, sanitizeForRelay) < payloadSoftLimitBytes) {
2176
+ return response;
2177
+ }
1663
2178
  }
2179
+ return buildEmergencySingleTurnResponse({
2180
+ requestId,
2181
+ lastResult,
2182
+ turnsKey,
2183
+ turn: turns[0],
2184
+ sanitizeForRelay,
2185
+ payloadSoftLimitBytes,
2186
+ });
2187
+ }
2188
+
2189
+ function buildEmergencySingleTurnResponse({
2190
+ requestId,
2191
+ lastResult,
2192
+ turnsKey,
2193
+ turn,
2194
+ sanitizeForRelay,
2195
+ payloadSoftLimitBytes,
2196
+ }) {
2197
+ if (!turn || typeof turn !== "object" || Array.isArray(turn)) {
2198
+ return null;
2199
+ }
2200
+
2201
+ for (const maxItems of [16, 4, 1]) {
2202
+ for (const maxChars of [
2203
+ RELAY_HISTORY_TEXT_TAIL_LIMIT_CHARS,
2204
+ Math.floor(RELAY_HISTORY_TEXT_TAIL_LIMIT_CHARS / 4),
2205
+ 1_000,
2206
+ 0,
2207
+ ]) {
2208
+ const response = {
2209
+ id: requestId,
2210
+ result: {
2211
+ ...buildAdaptiveTurnsListResult({}, lastResult, turnsKey, [
2212
+ compactEmergencySingleTurnForRelay(turn, maxChars, maxItems),
2213
+ ]),
2214
+ remodexEmergencySingleTurnForRelay: true,
2215
+ },
2216
+ };
2217
+ if (measureSanitizedTurnsListResponseBytes(response, sanitizeForRelay) < payloadSoftLimitBytes) {
2218
+ return response;
2219
+ }
2220
+ }
2221
+ }
2222
+
2223
+ return null;
2224
+ }
2225
+
2226
+ function compactEmergencySingleTurnForRelay(turn, maxChars, maxItems) {
2227
+ const safeTurn = {};
2228
+ for (const key of [
2229
+ "id",
2230
+ "turnId",
2231
+ "turn_id",
2232
+ "threadId",
2233
+ "thread_id",
2234
+ "createdAt",
2235
+ "created_at",
2236
+ "completedAt",
2237
+ "completed_at",
2238
+ "status",
2239
+ "role",
2240
+ "kind",
2241
+ ]) {
2242
+ const value = turn[key];
2243
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
2244
+ safeTurn[key] = value;
2245
+ }
2246
+ }
2247
+
2248
+ const items = Array.isArray(turn.items) ? turn.items : [];
2249
+ safeTurn.items = items.slice(-maxItems).map((item) => compactHistoryItemForRelay(item, maxChars));
2250
+ safeTurn.remodexEmergencySingleTurnForRelay = true;
2251
+ safeTurn.remodexPageCompactedForRelay = true;
2252
+ return safeTurn;
2253
+ }
2254
+
2255
+ function buildAdaptiveTurnsListResult(firstResult, lastResult, turnsKey, turns) {
2256
+ const result = {};
1664
2257
  result[turnsKey] = turns;
1665
2258
 
1666
2259
  for (const key of RELAY_TURNS_LIST_PAGINATION_RESULT_KEYS) {
@@ -1709,6 +2302,108 @@ function measureSanitizedTurnsListResponseBytes(response, sanitizeForRelay) {
1709
2302
  }
1710
2303
  }
1711
2304
 
2305
+ // Keeps app-server responses in the JSON-RPC shape that the App Store iOS client decodes.
2306
+ function normalizeRelayBoundJsonRpcMessage(rawMessage, {
2307
+ pendingRequestMethodsById = null,
2308
+ } = {}) {
2309
+ const parsed = parseBridgeJSON(rawMessage);
2310
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
2311
+ return null;
2312
+ }
2313
+
2314
+ const hasMethod = typeof parsed.method === "string" && parsed.method.length > 0;
2315
+ const hasResponseId = parsed.id !== undefined && parsed.id !== null;
2316
+ const hasResult = Object.prototype.hasOwnProperty.call(parsed, "result");
2317
+ const hasError = Object.prototype.hasOwnProperty.call(parsed, "error");
2318
+ const hasPayload = Object.prototype.hasOwnProperty.call(parsed, "payload");
2319
+ if (hasResponseId && !hasMethod && !hasResult && !hasError && hasPayload) {
2320
+ const { payload, ...rest } = parsed;
2321
+ return JSON.stringify({
2322
+ ...rest,
2323
+ result: payload ?? null,
2324
+ });
2325
+ }
2326
+
2327
+ if (hasResponseId && !hasMethod && hasResult && !hasError) {
2328
+ const unwrappedResult = unwrapAppServerPayloadResult(parsed.result);
2329
+ if (unwrappedResult !== parsed.result) {
2330
+ return JSON.stringify({
2331
+ ...parsed,
2332
+ result: unwrappedResult,
2333
+ });
2334
+ }
2335
+ }
2336
+
2337
+ if (hasMethod && hasResponseId && !isRelayBoundServerRequestMethod(parsed.method)) {
2338
+ const trackedRequest = pendingRequestMethodsById?.get(String(parsed.id));
2339
+ const isTrackedResponse = trackedRequest?.method === parsed.method
2340
+ && (hasResult || hasError || hasPayload);
2341
+ if (isTrackedResponse) {
2342
+ const { method, payload, ...rest } = parsed;
2343
+ if (!hasResult && !hasError && hasPayload) {
2344
+ return JSON.stringify({
2345
+ ...rest,
2346
+ result: payload ?? null,
2347
+ });
2348
+ }
2349
+ if (hasResult && !hasError) {
2350
+ return JSON.stringify({
2351
+ ...rest,
2352
+ result: unwrapAppServerPayloadResult(rest.result),
2353
+ });
2354
+ }
2355
+ return JSON.stringify(rest);
2356
+ }
2357
+
2358
+ return null;
2359
+ }
2360
+
2361
+ if (!hasMethod && !hasResponseId) {
2362
+ return null;
2363
+ }
2364
+
2365
+ return rawMessage;
2366
+ }
2367
+
2368
+ function unwrapAppServerPayloadResult(value) {
2369
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
2370
+ return value;
2371
+ }
2372
+ if (!Object.prototype.hasOwnProperty.call(value, "payload")) {
2373
+ return value;
2374
+ }
2375
+
2376
+ const payload = value.payload;
2377
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
2378
+ return value;
2379
+ }
2380
+
2381
+ const directPayloadKeys = [
2382
+ "data",
2383
+ "items",
2384
+ "threads",
2385
+ "turns",
2386
+ "thread",
2387
+ ];
2388
+ const hasDirectResultPayload = directPayloadKeys.some((key) => (
2389
+ Object.prototype.hasOwnProperty.call(payload, key)
2390
+ ));
2391
+ if (!hasDirectResultPayload) {
2392
+ return value;
2393
+ }
2394
+
2395
+ return {
2396
+ ...value,
2397
+ ...payload,
2398
+ };
2399
+ }
2400
+
2401
+ function isRelayBoundServerRequestMethod(method) {
2402
+ return method === "item/tool/requestUserInput"
2403
+ || method === "tool/requestUserInput"
2404
+ || method.endsWith("requestApproval");
2405
+ }
2406
+
1712
2407
  // Shrinks thread history snapshots/pages for mobile relay delivery.
1713
2408
  // This elides bulky blobs and replaces oversized older history with a compact marker.
1714
2409
  function sanitizeThreadHistoryImagesForRelay(rawMessage, requestMethod) {
@@ -2538,6 +3233,7 @@ module.exports = {
2538
3233
  fetchAdaptiveThreadTurnsListForRelay,
2539
3234
  hasRelayConnectionGoneStale,
2540
3235
  isTerminalRelayCloseCode,
3236
+ normalizeRelayBoundJsonRpcMessage,
2541
3237
  persistBridgePreferences,
2542
3238
  sanitizeLiveGeneratedImageMessageForRelay,
2543
3239
  sanitizeThreadHistoryImagesForRelay,