@makerbi/remodex 1.5.4 → 2.0.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/src/bridge.js CHANGED
@@ -5,8 +5,9 @@
5
5
  // Depends on: ws, crypto, os, ./bridge-status, ./codex-desktop-refresher, ./codex-transport, ./rollout-watch, ./voice-handler
6
6
 
7
7
  const WebSocket = require("ws");
8
- const { randomBytes, randomUUID } = require("crypto");
8
+ const { createHash, randomBytes, randomUUID } = require("crypto");
9
9
  const { execFile, spawn } = require("child_process");
10
+ const fs = require("fs");
10
11
  const path = require("path");
11
12
  const os = require("os");
12
13
  const { promisify } = require("util");
@@ -45,6 +46,7 @@ const { createPushNotificationTracker } = require("./push-notification-tracker")
45
46
  const { resolveCodexGeneratedImagesRoot } = require("./codex-home");
46
47
  const {
47
48
  loadOrCreateBridgeDeviceState,
49
+ rememberLastSeenClientDeviceKind,
48
50
  rememberLastSeenPhoneAppVersion,
49
51
  resolveBridgeRelaySession,
50
52
  } = require("./secure-device-state");
@@ -52,6 +54,7 @@ const { createBridgeSecureTransport } = require("./secure-transport");
52
54
  const { createRolloutLiveMirrorController } = require("./rollout-live-mirror");
53
55
  const {
54
56
  createDesktopIpcActionFollower,
57
+ seedConversationStateFromThreadRead,
55
58
  } = require("./desktop-ipc-action-follower");
56
59
  const { version: bridgePackageVersion = "" } = require("../package.json");
57
60
  const {
@@ -62,8 +65,11 @@ const {
62
65
  } = require("./ios-app-compatibility");
63
66
  const { createShortPairingCode, SHORT_PAIRING_CODE_LENGTH } = require("./qr");
64
67
  const {
68
+ parseSessionJsonlMetadata,
69
+ parseSessionJsonlTurns,
65
70
  readThreadTurnsListPageFromSessionJsonl,
66
71
  } = require("./session-jsonl-history");
72
+ const { buildApplyPatchFileChangeItem } = require("./apply-patch-changes");
67
73
 
68
74
  const execFileAsync = promisify(execFile);
69
75
  const RELAY_WATCHDOG_PING_INTERVAL_MS = 10_000;
@@ -78,6 +84,12 @@ const RELAY_TURNS_LIST_TARGET_BUDGET_MS = 5_500;
78
84
  const RELAY_TURNS_LIST_BUDGET_RESERVE_MS = 1_000;
79
85
  const RELAY_TURNS_LIST_MAX_INITIAL_LIMIT = 5;
80
86
  const RELAY_TURNS_LIST_SAFE_RETRY_LIMIT = 5;
87
+ const RELAY_JSONL_TURNS_LIST_CACHE_TTL_MS = 30_000;
88
+ const RELAY_JSONL_ARTIFACT_CACHE_TTL_MS = 2_000;
89
+ const RELAY_JSONL_ARTIFACT_CACHE_MAX_ENTRIES = 128;
90
+ const BRIDGE_PACKAGE_UPDATE_COMMAND = "npm install -g @makerbi/remodex@latest";
91
+ const BRIDGE_PACKAGE_UPDATE_TIMEOUT_MS = 180_000;
92
+ const BRIDGE_RESTART_AFTER_UPDATE_DELAY_MS = 750;
81
93
  const MODELS_WITHOUT_REASONING_SUMMARY = new Set([
82
94
  "gpt-5.3-codex-spark",
83
95
  ]);
@@ -97,6 +109,9 @@ const RELAY_TURNS_LIST_PAGINATION_RESULT_KEYS = [
97
109
  "previousCursor",
98
110
  "previous_cursor",
99
111
  ];
112
+ const jsonlArtifactItemsCacheByThread = new Map();
113
+ const FORWARDED_REQUEST_METHODS_MAX_SIZE = 500;
114
+ const JSONL_ROLLOUT_PATH_CACHE_MAX_SIZE = 200;
100
115
 
101
116
  function buildRelayUserAgentHeader({ version = bridgePackageVersion } = {}) {
102
117
  const normalizedVersion = typeof version === "string" && version.trim()
@@ -116,6 +131,18 @@ function buildRelayAccessTokenHeaders(config = {}, env = process.env) {
116
131
  : {};
117
132
  }
118
133
 
134
+ function evictOldestEntries(map, maxSize) {
135
+ if (map.size <= maxSize) {
136
+ return;
137
+ }
138
+ const excess = map.size - maxSize;
139
+ const iterator = map.keys();
140
+ for (let i = 0; i < excess; i += 1) {
141
+ const key = iterator.next().value;
142
+ map.delete(key);
143
+ }
144
+ }
145
+
119
146
  function startBridge({
120
147
  config: explicitConfig = null,
121
148
  printPairingQr = true,
@@ -192,6 +219,8 @@ function startBridge({
192
219
  const relayChannels = [];
193
220
  const codexResponseRoutesById = new Map();
194
221
  const extraRelaySessionCount = readExtraRelaySessionCount(process.env);
222
+ const jsonlTurnsListRolloutCacheByThread = new Map();
223
+ const jsonlTurnsListRolloutMissCacheByThread = new Map();
195
224
  const trackedForwardedRequestMethods = new Set([
196
225
  "account/login/start",
197
226
  "account/login/cancel",
@@ -210,14 +239,23 @@ function startBridge({
210
239
  requestId: null,
211
240
  startedAt: 0,
212
241
  };
242
+ let activePhoneSummary = null;
213
243
  const secureTransport = createBridgeSecureTransport({
214
244
  sessionId,
215
245
  relayUrl: relayBaseUrl,
216
246
  deviceState,
247
+ displayName: os.hostname(),
217
248
  onTrustedPhoneUpdate(nextDeviceState) {
218
249
  deviceState = nextDeviceState;
219
250
  sendRelayRegistrationUpdate(nextDeviceState);
220
251
  },
252
+ onSecureSessionReady(session) {
253
+ activePhoneSummary = buildActivePhoneSummary(session, deviceState);
254
+ const lastPublishedBridgeStatus = bridgeStatusPublisher.latest();
255
+ if (lastPublishedBridgeStatus) {
256
+ publishBridgeStatus(lastPublishedBridgeStatus);
257
+ }
258
+ },
221
259
  });
222
260
  let primaryRelayChannel = null;
223
261
  // Keeps one stable sender identity across reconnects so buffered replay state
@@ -240,6 +278,9 @@ function startBridge({
240
278
  const desktopIpcActionFollower = !config.codexEndpoint
241
279
  ? createDesktopIpcActionFollower({
242
280
  sendApplicationResponse,
281
+ readConversationState: async (threadId) => seedConversationStateFromThreadRead(
282
+ await sendCodexRequest("thread/read", { threadId })
283
+ ),
243
284
  socketPath: config.desktopIpcSocketPath || undefined,
244
285
  })
245
286
  : null;
@@ -323,6 +364,33 @@ function startBridge({
323
364
  relayWatchdogTimer = null;
324
365
  }
325
366
 
367
+ function prepareBridgeShutdown() {
368
+ isShuttingDown = true;
369
+ bridgeWakeAssertion.stop();
370
+ clearReconnectTimer();
371
+ clearRelayWatchdog();
372
+ bridgeStatusPublisher.stopHeartbeat();
373
+ stopContextUsageWatcher();
374
+ rolloutLiveMirror?.stopAll();
375
+ desktopIpcActionFollower?.stopAll();
376
+ }
377
+
378
+ function stopBridge() {
379
+ if (isShuttingDown) {
380
+ return;
381
+ }
382
+
383
+ prepareBridgeShutdown();
384
+ desktopRefresher.handleTransportReset();
385
+ failBridgeManagedCodexRequests(new Error("Bridge stopped before the request completed."));
386
+ forwardedRequestMethodsById.clear();
387
+
388
+ if (socket?.readyState === WebSocket.OPEN || socket?.readyState === WebSocket.CONNECTING) {
389
+ socket.close();
390
+ }
391
+ codex.shutdown();
392
+ }
393
+
326
394
  function startRelayWatchdog(trackedSocket) {
327
395
  clearRelayWatchdog();
328
396
  markRelayActivity();
@@ -361,6 +429,9 @@ function startBridge({
361
429
 
362
430
  lastConnectionStatus = status;
363
431
  lastConnectionError = lastError;
432
+ if (status !== "connected") {
433
+ activePhoneSummary = null;
434
+ }
364
435
  publishBridgeStatus({
365
436
  state: "running",
366
437
  connectionStatus: status,
@@ -379,19 +450,9 @@ function startBridge({
379
450
  return;
380
451
  }
381
452
 
382
- if (isTerminalRelayCloseCode(closeCode)) {
383
- const lastError = buildRelayCloseStatusError(closeCode, closeReason);
384
- logConnectionStatus("disconnected", lastError);
385
- shutdown(codex, () => socket, () => {
386
- isShuttingDown = true;
387
- bridgeWakeAssertion.stop();
388
- clearReconnectTimer();
389
- clearRelayWatchdog();
390
- bridgeStatusPublisher.stopHeartbeat();
391
- closeExtraRelayChannels();
392
- }, {
393
- exitCode: closeCode === CLOSE_CODE_MAC_UNAUTHORIZED ? 1 : 0,
394
- });
453
+ if (closeCode === 4000 || closeCode === 4001) {
454
+ logConnectionStatus("disconnected");
455
+ shutdown(codex, () => socket, prepareBridgeShutdown);
395
456
  return;
396
457
  }
397
458
 
@@ -400,7 +461,9 @@ function startBridge({
400
461
  }
401
462
 
402
463
  reconnectAttempt += 1;
403
- const delayMs = Math.min(1_000 * reconnectAttempt, 5_000);
464
+ const baseDelayMs = Math.min(1_000 * reconnectAttempt, 5_000);
465
+ const jitterMs = Math.floor(Math.random() * Math.min(baseDelayMs, 2_000));
466
+ const delayMs = baseDelayMs + jitterMs;
404
467
  logConnectionStatus("connecting");
405
468
  reconnectTimer = setTimeout(() => {
406
469
  reconnectTimer = null;
@@ -415,6 +478,11 @@ function startBridge({
415
478
 
416
479
  logConnectionStatus("connecting");
417
480
  const nextSocket = new WebSocket(relaySessionUrl, {
481
+ perMessageDeflate: {
482
+ zlibDeflateOptions: { level: 6 },
483
+ threshold: 256,
484
+ concurrencyLimit: 4,
485
+ },
418
486
  // The relay uses this per-session secret to authenticate the first push registration.
419
487
  headers: {
420
488
  "User-Agent": buildRelayUserAgentHeader(),
@@ -471,8 +539,8 @@ function startBridge({
471
539
  socket = null;
472
540
  }
473
541
  stopContextUsageWatcher();
474
- rolloutLiveMirror?.stopAll();
475
- desktopIpcActionFollower?.stopAll();
542
+ // Relay reconnects are transport-only: keep local live observers running
543
+ // so their output can enter secure replay and catch up on the next resume.
476
544
  desktopRefresher.handleTransportReset();
477
545
  scheduleRelayReconnect(code, closeReason);
478
546
  });
@@ -680,12 +748,7 @@ function startBridge({
680
748
  console.error(`[remodex] ${lastError}`);
681
749
  process.exitCode = 1;
682
750
  }
683
- isShuttingDown = true;
684
- bridgeWakeAssertion.stop();
685
- clearReconnectTimer();
686
- stopContextUsageWatcher();
687
- rolloutLiveMirror?.stopAll();
688
- desktopIpcActionFollower?.stopAll();
751
+ prepareBridgeShutdown();
689
752
  desktopRefresher.handleTransportReset();
690
753
  failBridgeManagedCodexRequests(new Error("Codex transport closed before the bridge request completed."));
691
754
  forwardedRequestMethodsById.clear();
@@ -696,22 +759,8 @@ function startBridge({
696
759
  closeExtraRelayChannels();
697
760
  });
698
761
 
699
- process.on("SIGINT", () => shutdown(codex, () => socket, () => {
700
- isShuttingDown = true;
701
- bridgeWakeAssertion.stop();
702
- clearReconnectTimer();
703
- clearRelayWatchdog();
704
- bridgeStatusPublisher.stopHeartbeat();
705
- closeExtraRelayChannels();
706
- }));
707
- process.on("SIGTERM", () => shutdown(codex, () => socket, () => {
708
- isShuttingDown = true;
709
- bridgeWakeAssertion.stop();
710
- clearReconnectTimer();
711
- clearRelayWatchdog();
712
- bridgeStatusPublisher.stopHeartbeat();
713
- closeExtraRelayChannels();
714
- }));
762
+ process.on("SIGINT", () => shutdown(codex, () => socket, prepareBridgeShutdown));
763
+ process.on("SIGTERM", () => shutdown(codex, () => socket, prepareBridgeShutdown));
715
764
 
716
765
  // Routes decrypted app payloads through the same bridge handlers as before.
717
766
  function handleApplicationMessage(rawMessage, relayChannel = primaryRelayChannel) {
@@ -745,6 +794,7 @@ function startBridge({
745
794
  appPath: config.codexAppPath,
746
795
  readBridgePreferences,
747
796
  updateBridgePreferences,
797
+ updateBridgePackageAndRestart,
748
798
  })) {
749
799
  return;
750
800
  }
@@ -974,12 +1024,19 @@ function startBridge({
974
1024
  const response = await fetchAdaptiveThreadTurnsListForRelay(request, {
975
1025
  fetchPage: (params) => sendCodexRequest("thread/turns/list", params),
976
1026
  });
977
- const fallbackResponse = maybeBuildJsonlThreadTurnsListFallback(request, response);
1027
+ const jsonlFallback = maybeBuildJsonlThreadTurnsListFallback(request, response);
1028
+ const responsePayload = jsonlFallback?.response ?? response;
1029
+ const finalSanitizeContext = buildThreadTurnsListRelaySanitizeContext(request);
978
1030
  relaySanitizedResponseMethodsById.set(String(request.id), {
979
1031
  method: "thread/turns/list",
1032
+ ...finalSanitizeContext,
980
1033
  createdAt: Date.now(),
981
1034
  });
982
- sendResponse(JSON.stringify(fallbackResponse ?? response));
1035
+ sendResponse(sanitizeThreadHistoryImagesForRelay(
1036
+ JSON.stringify(responsePayload),
1037
+ "thread/turns/list",
1038
+ finalSanitizeContext
1039
+ ));
983
1040
  } catch (error) {
984
1041
  sendResponse(createJsonRpcErrorResponse(
985
1042
  request.id,
@@ -993,10 +1050,6 @@ function startBridge({
993
1050
  }
994
1051
 
995
1052
  function maybeBuildJsonlThreadTurnsListFallback(request, response) {
996
- if (!isEmptyTurnsListResponse(response)) {
997
- return null;
998
- }
999
-
1000
1053
  const params = request?.params || {};
1001
1054
  const threadId = normalizeNonEmptyString(params.threadId)
1002
1055
  || normalizeNonEmptyString(params.thread_id);
@@ -1005,10 +1058,17 @@ function startBridge({
1005
1058
  }
1006
1059
 
1007
1060
  try {
1008
- const rolloutPath = findRecentRolloutFileForContextRead(resolveSessionsRoot(), { threadId });
1061
+ const responseIsEmpty = isEmptyTurnsListResponse(response);
1062
+ const rolloutPath = resolveJsonlTurnsListRolloutPathForFallback({
1063
+ threadId,
1064
+ responseIsEmpty,
1065
+ readCachedPath: readCachedJsonlTurnsListRolloutPath,
1066
+ findAndCachePath: findAndCacheJsonlTurnsListRolloutPath,
1067
+ });
1009
1068
  if (!rolloutPath) {
1010
1069
  return null;
1011
1070
  }
1071
+
1012
1072
  const result = readThreadTurnsListPageFromSessionJsonl(rolloutPath, {
1013
1073
  threadId,
1014
1074
  limit: params.limit,
@@ -1020,16 +1080,69 @@ function startBridge({
1020
1080
  return null;
1021
1081
  }
1022
1082
 
1083
+ if (!responseIsEmpty) {
1084
+ const mergedResponse = maybeMergeLatestJsonlTurnIntoTurnsListResponse(request, response, result, params);
1085
+ return mergedResponse ? { response: mergedResponse, usesJsonl: true } : null;
1086
+ }
1087
+
1023
1088
  return {
1024
- id: request.id,
1025
- result,
1089
+ response: {
1090
+ id: request.id,
1091
+ result,
1092
+ },
1093
+ usesJsonl: true,
1026
1094
  };
1027
1095
  } catch (error) {
1096
+ jsonlTurnsListRolloutCacheByThread.delete(threadId);
1028
1097
  console.warn(`[remodex] thread/turns/list jsonl fallback failed: ${error.message}`);
1029
1098
  return null;
1030
1099
  }
1031
1100
  }
1032
1101
 
1102
+ function findAndCacheJsonlTurnsListRolloutPath(threadId) {
1103
+ if (hasFreshJsonlTurnsListRolloutMiss(threadId)) {
1104
+ return "";
1105
+ }
1106
+
1107
+ const rolloutPath = findRecentRolloutFileForContextRead(resolveSessionsRoot(), { threadId });
1108
+ if (rolloutPath) {
1109
+ jsonlTurnsListRolloutMissCacheByThread.delete(threadId);
1110
+ jsonlTurnsListRolloutCacheByThread.set(threadId, {
1111
+ rolloutPath,
1112
+ cachedAt: Date.now(),
1113
+ });
1114
+ } else {
1115
+ jsonlTurnsListRolloutMissCacheByThread.set(threadId, Date.now());
1116
+ }
1117
+ return rolloutPath;
1118
+ }
1119
+
1120
+ function readCachedJsonlTurnsListRolloutPath(threadId) {
1121
+ const cached = jsonlTurnsListRolloutCacheByThread.get(threadId);
1122
+ if (!cached) {
1123
+ return "";
1124
+ }
1125
+ if (Date.now() - cached.cachedAt > RELAY_JSONL_TURNS_LIST_CACHE_TTL_MS) {
1126
+ jsonlTurnsListRolloutCacheByThread.delete(threadId);
1127
+ return "";
1128
+ }
1129
+ // Non-empty app-server pages only consult this positive cache to avoid
1130
+ // walking the sessions tree during ordinary pagination.
1131
+ return cached.rolloutPath;
1132
+ }
1133
+
1134
+ function hasFreshJsonlTurnsListRolloutMiss(threadId) {
1135
+ const missedAt = jsonlTurnsListRolloutMissCacheByThread.get(threadId);
1136
+ if (!missedAt) {
1137
+ return false;
1138
+ }
1139
+ if (Date.now() - missedAt <= RELAY_JSONL_TURNS_LIST_CACHE_TTL_MS) {
1140
+ return true;
1141
+ }
1142
+ jsonlTurnsListRolloutMissCacheByThread.delete(threadId);
1143
+ return false;
1144
+ }
1145
+
1033
1146
  // ─── Bridge-owned auth snapshot ─────────────────────────────
1034
1147
 
1035
1148
  // Handles the bridge-owned auth status wrappers without exposing tokens to the phone.
@@ -1178,10 +1291,17 @@ function startBridge({
1178
1291
  });
1179
1292
  }
1180
1293
  if (relaySanitizedRequestMethods.has(method)) {
1181
- relaySanitizedResponseMethodsById.set(String(requestId), {
1294
+ const trackedRequest = {
1182
1295
  method,
1296
+ threadId: method === "thread/turns/list" || method === "thread/read" || method === "thread/resume"
1297
+ ? threadIdFromRequestParams(parsed.params)
1298
+ : "",
1183
1299
  createdAt: Date.now(),
1184
- });
1300
+ };
1301
+ if (method === "thread/turns/list") {
1302
+ trackedRequest.skipJsonlArtifactAugmentation = false;
1303
+ }
1304
+ relaySanitizedResponseMethodsById.set(String(requestId), trackedRequest);
1185
1305
  }
1186
1306
  }
1187
1307
 
@@ -1207,7 +1327,7 @@ function startBridge({
1207
1327
  }
1208
1328
  relaySanitizedResponseMethodsById.delete(String(responseId));
1209
1329
 
1210
- return sanitizeThreadHistoryImagesForRelay(normalizedMessage, trackedRequest.method);
1330
+ return sanitizeThreadHistoryImagesForRelay(normalizedMessage, trackedRequest.method, trackedRequest);
1211
1331
  }
1212
1332
 
1213
1333
  function updatePendingAuthLoginFromCodexMessage(rawMessage) {
@@ -1260,16 +1380,31 @@ function startBridge({
1260
1380
  }
1261
1381
 
1262
1382
  function pruneExpiredForwardedRequestMethods(now = Date.now()) {
1383
+ const expiredForwarded = [];
1263
1384
  for (const [requestId, trackedRequest] of forwardedRequestMethodsById.entries()) {
1264
1385
  if (!trackedRequest || (now - trackedRequest.createdAt) >= forwardedRequestMethodTTLms) {
1265
- forwardedRequestMethodsById.delete(requestId);
1386
+ expiredForwarded.push(requestId);
1266
1387
  }
1267
1388
  }
1389
+ for (const id of expiredForwarded) {
1390
+ forwardedRequestMethodsById.delete(id);
1391
+ }
1392
+
1393
+ const expiredSanitized = [];
1268
1394
  for (const [requestId, trackedRequest] of relaySanitizedResponseMethodsById.entries()) {
1269
1395
  if (!trackedRequest || (now - trackedRequest.createdAt) >= forwardedRequestMethodTTLms) {
1270
- relaySanitizedResponseMethodsById.delete(requestId);
1396
+ expiredSanitized.push(requestId);
1271
1397
  }
1272
1398
  }
1399
+ for (const id of expiredSanitized) {
1400
+ relaySanitizedResponseMethodsById.delete(id);
1401
+ }
1402
+
1403
+ evictOldestEntries(forwardedRequestMethodsById, FORWARDED_REQUEST_METHODS_MAX_SIZE);
1404
+ evictOldestEntries(relaySanitizedResponseMethodsById, FORWARDED_REQUEST_METHODS_MAX_SIZE);
1405
+ evictOldestEntries(jsonlArtifactItemsCacheByThread, RELAY_JSONL_ARTIFACT_CACHE_MAX_ENTRIES);
1406
+ evictOldestEntries(jsonlTurnsListRolloutCacheByThread, JSONL_ROLLOUT_PATH_CACHE_MAX_SIZE);
1407
+ evictOldestEntries(jsonlTurnsListRolloutMissCacheByThread, JSONL_ROLLOUT_PATH_CACHE_MAX_SIZE);
1273
1408
  }
1274
1409
 
1275
1410
  function safeParseJSON(value) {
@@ -1406,6 +1541,20 @@ function startBridge({
1406
1541
  function bridgeManagedInitializeCompatibilityError(params) {
1407
1542
  const clientInfo = params && typeof params === "object" ? params.clientInfo : null;
1408
1543
  const clientName = normalizeNonEmptyString(clientInfo?.name);
1544
+ const clientDeviceKind = classifyClientDeviceKind(clientName);
1545
+ if (clientDeviceKind) {
1546
+ deviceState = rememberLastSeenClientDeviceKind(deviceState, clientDeviceKind);
1547
+ if (activePhoneSummary?.connected) {
1548
+ activePhoneSummary = {
1549
+ ...activePhoneSummary,
1550
+ deviceKind: clientDeviceKind,
1551
+ };
1552
+ const lastPublishedBridgeStatus = bridgeStatusPublisher.latest();
1553
+ if (lastPublishedBridgeStatus) {
1554
+ publishBridgeStatus(lastPublishedBridgeStatus);
1555
+ }
1556
+ }
1557
+ }
1409
1558
  if (clientName !== "codexmobile_ios") {
1410
1559
  return null;
1411
1560
  }
@@ -1574,7 +1723,11 @@ function startBridge({
1574
1723
  }
1575
1724
 
1576
1725
  function publishBridgeStatus(status) {
1577
- bridgeStatusPublisher.publish(status);
1726
+ bridgeStatusPublisher.publish({
1727
+ ...status,
1728
+ activeDevice: activePhoneSummary,
1729
+ activePhone: activePhoneSummary,
1730
+ });
1578
1731
  }
1579
1732
 
1580
1733
  // Refreshes the relay's trusted-mac index after the QR bootstrap locks in a phone identity.
@@ -1629,6 +1782,64 @@ function startBridge({
1629
1782
 
1630
1783
  return readBridgePreferences();
1631
1784
  }
1785
+
1786
+ async function updateBridgePackageAndRestart() {
1787
+ if (process.platform !== "darwin") {
1788
+ const error = new Error("Bridge self-update is available only for the macOS bridge service.");
1789
+ error.errorCode = "unsupported_platform";
1790
+ error.userMessage = error.message;
1791
+ throw error;
1792
+ }
1793
+
1794
+ try {
1795
+ await execFileAsync("/bin/zsh", [
1796
+ "-lc",
1797
+ [
1798
+ "export TERM=dumb",
1799
+ "source ~/.zshrc >/dev/null 2>/dev/null || true",
1800
+ BRIDGE_PACKAGE_UPDATE_COMMAND,
1801
+ ].join("; "),
1802
+ ], {
1803
+ timeout: BRIDGE_PACKAGE_UPDATE_TIMEOUT_MS,
1804
+ maxBuffer: 2 * 1024 * 1024,
1805
+ });
1806
+ } catch (error) {
1807
+ const nextError = new Error(
1808
+ truncateCommandOutput(error?.stderr || error?.stdout || error?.message)
1809
+ || "Could not update the Remodex bridge package on this Mac."
1810
+ );
1811
+ nextError.errorCode = "bridge_update_failed";
1812
+ nextError.userMessage = nextError.message;
1813
+ nextError.cause = error;
1814
+ throw nextError;
1815
+ }
1816
+
1817
+ scheduleBridgeServiceRestartAfterUpdate();
1818
+ return {
1819
+ success: true,
1820
+ command: BRIDGE_PACKAGE_UPDATE_COMMAND,
1821
+ restartScheduled: true,
1822
+ restartDelayMs: BRIDGE_RESTART_AFTER_UPDATE_DELAY_MS,
1823
+ };
1824
+ }
1825
+
1826
+ // Restarts after the RPC response has crossed the encrypted phone channel.
1827
+ function scheduleBridgeServiceRestartAfterUpdate() {
1828
+ const restartTimer = setTimeout(() => {
1829
+ const cliPath = path.join(__dirname, "..", "bin", "remodex.js");
1830
+ const child = spawn(process.execPath, [cliPath, "restart"], {
1831
+ detached: true,
1832
+ stdio: "ignore",
1833
+ env: process.env,
1834
+ });
1835
+ child.unref?.();
1836
+ }, BRIDGE_RESTART_AFTER_UPDATE_DELAY_MS);
1837
+ restartTimer.unref?.();
1838
+ }
1839
+
1840
+ return {
1841
+ stop: stopBridge,
1842
+ };
1632
1843
  }
1633
1844
 
1634
1845
  // Holds a single macOS idle-sleep assertion for as long as the bridge process stays alive.
@@ -1769,6 +1980,47 @@ function buildMacRegistration(deviceState, pairingSession) {
1769
1980
  };
1770
1981
  }
1771
1982
 
1983
+ function buildActivePhoneSummary(session, deviceState = null) {
1984
+ const phoneFingerprint = shortFingerprint(session?.phoneDeviceId);
1985
+ if (!phoneFingerprint) {
1986
+ return null;
1987
+ }
1988
+
1989
+ return {
1990
+ connected: true,
1991
+ phoneFingerprint,
1992
+ deviceKind: normalizeNonEmptyString(deviceState?.lastSeenDeviceKind) || null,
1993
+ handshakeMode: normalizeNonEmptyString(session?.handshakeMode) || null,
1994
+ keyEpoch: Number.isFinite(session?.keyEpoch) ? session.keyEpoch : null,
1995
+ updatedAt: new Date().toISOString(),
1996
+ };
1997
+ }
1998
+
1999
+ function classifyClientDeviceKind(clientName) {
2000
+ const normalized = normalizeNonEmptyString(clientName).toLowerCase();
2001
+ if (!normalized) {
2002
+ return null;
2003
+ }
2004
+ if (normalized.includes("android")) {
2005
+ return "android";
2006
+ }
2007
+ if (normalized.includes("ios") || normalized.includes("iphone")) {
2008
+ return "iphone";
2009
+ }
2010
+ if (normalized.includes("macos") || normalized.includes("mac")) {
2011
+ return "mac";
2012
+ }
2013
+ return null;
2014
+ }
2015
+
2016
+ function shortFingerprint(value) {
2017
+ const normalized = normalizeNonEmptyString(value);
2018
+ if (!normalized) {
2019
+ return null;
2020
+ }
2021
+ return createHash("sha256").update(normalized).digest("hex").slice(0, 8);
2022
+ }
2023
+
1772
2024
  function shutdown(codex, getSocket, beforeExit = () => {}, { exitCode = 0 } = {}) {
1773
2025
  beforeExit();
1774
2026
 
@@ -1935,6 +2187,14 @@ function normalizeNonEmptyString(value) {
1935
2187
  return typeof value === "string" && value.trim() ? value.trim() : "";
1936
2188
  }
1937
2189
 
2190
+ function truncateCommandOutput(value, maxChars = 1_200) {
2191
+ const normalized = normalizeNonEmptyString(value);
2192
+ if (!normalized || normalized.length <= maxChars) {
2193
+ return normalized;
2194
+ }
2195
+ return `...${normalized.slice(-maxChars)}`;
2196
+ }
2197
+
1938
2198
  function parseAdaptiveThreadTurnsListRequest(rawMessage) {
1939
2199
  const parsed = parseBridgeJSON(rawMessage);
1940
2200
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
@@ -1961,6 +2221,22 @@ function parseAdaptiveThreadTurnsListRequest(rawMessage) {
1961
2221
  return parsed;
1962
2222
  }
1963
2223
 
2224
+ function threadIdFromRequestParams(params) {
2225
+ return normalizeNonEmptyString(params?.threadId)
2226
+ || normalizeNonEmptyString(params?.thread_id)
2227
+ || normalizeNonEmptyString(params?.id)
2228
+ || "";
2229
+ }
2230
+
2231
+ function buildThreadTurnsListRelaySanitizeContext(request, {
2232
+ skipJsonlArtifactAugmentation = false,
2233
+ } = {}) {
2234
+ return {
2235
+ threadId: threadIdFromRequestParams(request?.params || {}),
2236
+ skipJsonlArtifactAugmentation,
2237
+ };
2238
+ }
2239
+
1964
2240
  async function fetchAdaptiveThreadTurnsListForRelay(request, {
1965
2241
  fetchPage,
1966
2242
  now = Date.now,
@@ -1978,6 +2254,9 @@ async function fetchAdaptiveThreadTurnsListForRelay(request, {
1978
2254
  const requestedLimit = Number.isInteger(params?.limit) && params.limit > 0
1979
2255
  ? Math.min(params.limit, RELAY_TURNS_LIST_MAX_INITIAL_LIMIT)
1980
2256
  : 1;
2257
+ const sanitizeContext = buildThreadTurnsListRelaySanitizeContext(request, {
2258
+ skipJsonlArtifactAugmentation: true,
2259
+ });
1981
2260
  const startedAt = now();
1982
2261
  let nextCursor = params?.cursor;
1983
2262
  let turnsKey = null;
@@ -2002,6 +2281,7 @@ async function fetchAdaptiveThreadTurnsListForRelay(request, {
2002
2281
  fetchPage,
2003
2282
  now,
2004
2283
  sanitizeForRelay,
2284
+ sanitizeContext,
2005
2285
  payloadSoftLimitBytes,
2006
2286
  });
2007
2287
  }
@@ -2014,6 +2294,7 @@ async function fetchAdaptiveThreadTurnsListForRelay(request, {
2014
2294
  fetchPage,
2015
2295
  now,
2016
2296
  sanitizeForRelay,
2297
+ sanitizeContext,
2017
2298
  payloadSoftLimitBytes,
2018
2299
  });
2019
2300
  }
@@ -2032,7 +2313,7 @@ async function fetchAdaptiveThreadTurnsListForRelay(request, {
2032
2313
  combinedTurns = combinedTurns.concat(pageTurns);
2033
2314
  response = buildSafeTurnsListResponse(request.id, firstResult, lastResult, turnsKey, combinedTurns);
2034
2315
 
2035
- if (measureSanitizedTurnsListResponseBytes(response, sanitizeForRelay) >= payloadSoftLimitBytes) {
2316
+ if (measureSanitizedTurnsListResponseBytes(response, sanitizeForRelay, sanitizeContext) >= payloadSoftLimitBytes) {
2036
2317
  response = buildLargestSafeTurnsListResponse({
2037
2318
  requestId: request.id,
2038
2319
  firstResult,
@@ -2041,6 +2322,7 @@ async function fetchAdaptiveThreadTurnsListForRelay(request, {
2041
2322
  turns: combinedTurns,
2042
2323
  maxTurns: RELAY_TURNS_LIST_SAFE_RETRY_LIMIT,
2043
2324
  sanitizeForRelay,
2325
+ sanitizeContext,
2044
2326
  payloadSoftLimitBytes,
2045
2327
  }) ?? buildEmptyTurnsListResponse(request);
2046
2328
  break;
@@ -2052,7 +2334,7 @@ async function fetchAdaptiveThreadTurnsListForRelay(request, {
2052
2334
  }
2053
2335
 
2054
2336
  const rawPageBytes = jsonByteLength(pageResult);
2055
- const sanitizedResponseBytes = measureSanitizedTurnsListResponseBytes(response, sanitizeForRelay);
2337
+ const sanitizedResponseBytes = measureSanitizedTurnsListResponseBytes(response, sanitizeForRelay, sanitizeContext);
2056
2338
  const elapsedMs = Math.max(0, now() - startedAt);
2057
2339
  const remainingBudgetMs = Math.max(0, targetBudgetMs - elapsedMs);
2058
2340
  if (
@@ -2088,10 +2370,87 @@ function isEmptyTurnsListResponse(response) {
2088
2370
  return Boolean(turnsKey) && response.result[turnsKey].length === 0;
2089
2371
  }
2090
2372
 
2373
+ // Non-empty app-server pages can be stale for Mac-started runs, so the first page
2374
+ // still gets one JSONL lookup when the positive rollout cache is cold.
2375
+ function resolveJsonlTurnsListRolloutPathForFallback({
2376
+ threadId,
2377
+ responseIsEmpty,
2378
+ readCachedPath,
2379
+ findAndCachePath,
2380
+ }) {
2381
+ if (!threadId || typeof findAndCachePath !== "function") {
2382
+ return "";
2383
+ }
2384
+
2385
+ if (responseIsEmpty) {
2386
+ return findAndCachePath(threadId);
2387
+ }
2388
+
2389
+ return typeof readCachedPath === "function"
2390
+ ? readCachedPath(threadId) || findAndCachePath(threadId)
2391
+ : findAndCachePath(threadId);
2392
+ }
2393
+
2394
+ function maybeMergeLatestJsonlTurnIntoTurnsListResponse(request, response, jsonlResult, params = {}) {
2395
+ const responseResult = response?.result;
2396
+ const responseTurnsKey = findTurnsListResultKey(responseResult);
2397
+ const jsonlTurnsKey = findTurnsListResultKey(jsonlResult);
2398
+ if (!responseTurnsKey || !jsonlTurnsKey) {
2399
+ return null;
2400
+ }
2401
+
2402
+ const responseTurns = responseResult[responseTurnsKey];
2403
+ const jsonlTurn = jsonlResult[jsonlTurnsKey]?.[0];
2404
+ const jsonlTurnId = turnListTurnIdentifier(jsonlTurn);
2405
+ if (!jsonlTurnId || responseTurns.some((turn) => turnListTurnIdentifier(turn) === jsonlTurnId)) {
2406
+ return null;
2407
+ }
2408
+
2409
+ if (!shouldMergeLatestJsonlTurn(jsonlTurn)) {
2410
+ return null;
2411
+ }
2412
+
2413
+ const requestedLimit = Number.isInteger(params?.limit) && params.limit > 0
2414
+ ? params.limit
2415
+ : responseTurns.length + 1;
2416
+ const mergedTurns = [jsonlTurn, ...responseTurns].slice(0, requestedLimit);
2417
+ return {
2418
+ id: request.id,
2419
+ result: {
2420
+ ...responseResult,
2421
+ [responseTurnsKey]: mergedTurns,
2422
+ remodexJsonlMergedLatest: true,
2423
+ },
2424
+ };
2425
+ }
2426
+
2427
+ function shouldMergeLatestJsonlTurn(turn) {
2428
+ if (!turn || typeof turn !== "object") {
2429
+ return false;
2430
+ }
2431
+
2432
+ const status = normalizeHistoryItemToken(turn.status);
2433
+ if (status === "running" || status === "inprogress" || status === "active") {
2434
+ return true;
2435
+ }
2436
+
2437
+ return Array.isArray(turn.items) && turn.items.some((item) => {
2438
+ const type = normalizeHistoryItemToken(item?.type);
2439
+ return type === "plan" || type === "filechange";
2440
+ });
2441
+ }
2442
+
2443
+ function turnListTurnIdentifier(turn) {
2444
+ return normalizeNonEmptyString(turn?.id)
2445
+ || normalizeNonEmptyString(turn?.turnId)
2446
+ || normalizeNonEmptyString(turn?.turn_id);
2447
+ }
2448
+
2091
2449
  async function fetchSafeThreadTurnsListFallback(request, {
2092
2450
  fetchPage,
2093
2451
  now,
2094
2452
  sanitizeForRelay,
2453
+ sanitizeContext = {},
2095
2454
  payloadSoftLimitBytes,
2096
2455
  }) {
2097
2456
  const params = request?.params;
@@ -2119,6 +2478,7 @@ async function fetchSafeThreadTurnsListFallback(request, {
2119
2478
  turns: pageResult[turnsKey],
2120
2479
  maxTurns: safeLimit,
2121
2480
  sanitizeForRelay,
2481
+ sanitizeContext,
2122
2482
  payloadSoftLimitBytes,
2123
2483
  });
2124
2484
  if (response) {
@@ -2187,6 +2547,7 @@ function buildLargestSafeTurnsListResponse({
2187
2547
  turns,
2188
2548
  maxTurns,
2189
2549
  sanitizeForRelay,
2550
+ sanitizeContext = {},
2190
2551
  payloadSoftLimitBytes,
2191
2552
  }) {
2192
2553
  const sliceLimit = Math.min(turns.length, maxTurns);
@@ -2198,7 +2559,7 @@ function buildLargestSafeTurnsListResponse({
2198
2559
  turnsKey,
2199
2560
  turns.slice(0, count)
2200
2561
  );
2201
- if (measureSanitizedTurnsListResponseBytes(response, sanitizeForRelay) < payloadSoftLimitBytes) {
2562
+ if (measureSanitizedTurnsListResponseBytes(response, sanitizeForRelay, sanitizeContext) < payloadSoftLimitBytes) {
2202
2563
  return response;
2203
2564
  }
2204
2565
  }
@@ -2208,6 +2569,7 @@ function buildLargestSafeTurnsListResponse({
2208
2569
  turnsKey,
2209
2570
  turn: turns[0],
2210
2571
  sanitizeForRelay,
2572
+ sanitizeContext,
2211
2573
  payloadSoftLimitBytes,
2212
2574
  });
2213
2575
  }
@@ -2218,6 +2580,7 @@ function buildEmergencySingleTurnResponse({
2218
2580
  turnsKey,
2219
2581
  turn,
2220
2582
  sanitizeForRelay,
2583
+ sanitizeContext = {},
2221
2584
  payloadSoftLimitBytes,
2222
2585
  }) {
2223
2586
  if (!turn || typeof turn !== "object" || Array.isArray(turn)) {
@@ -2240,7 +2603,7 @@ function buildEmergencySingleTurnResponse({
2240
2603
  remodexEmergencySingleTurnForRelay: true,
2241
2604
  },
2242
2605
  };
2243
- if (measureSanitizedTurnsListResponseBytes(response, sanitizeForRelay) < payloadSoftLimitBytes) {
2606
+ if (measureSanitizedTurnsListResponseBytes(response, sanitizeForRelay, sanitizeContext) < payloadSoftLimitBytes) {
2244
2607
  return response;
2245
2608
  }
2246
2609
  }
@@ -2261,6 +2624,10 @@ function compactEmergencySingleTurnForRelay(turn, maxChars, maxItems) {
2261
2624
  "created_at",
2262
2625
  "completedAt",
2263
2626
  "completed_at",
2627
+ "timeZoneIdentifier",
2628
+ "timeZone",
2629
+ "timezone",
2630
+ "time_zone",
2264
2631
  "status",
2265
2632
  "role",
2266
2633
  "kind",
@@ -2318,10 +2685,10 @@ function jsonByteLength(value) {
2318
2685
  }
2319
2686
  }
2320
2687
 
2321
- function measureSanitizedTurnsListResponseBytes(response, sanitizeForRelay) {
2688
+ function measureSanitizedTurnsListResponseBytes(response, sanitizeForRelay, requestContext = {}) {
2322
2689
  try {
2323
2690
  const rawResponse = JSON.stringify(response);
2324
- const sanitizedResponse = sanitizeForRelay(rawResponse, "thread/turns/list");
2691
+ const sanitizedResponse = sanitizeForRelay(rawResponse, "thread/turns/list", requestContext);
2325
2692
  return Buffer.byteLength(sanitizedResponse, "utf8");
2326
2693
  } catch {
2327
2694
  return Number.POSITIVE_INFINITY;
@@ -2432,9 +2799,9 @@ function isRelayBoundServerRequestMethod(method) {
2432
2799
 
2433
2800
  // Shrinks thread history snapshots/pages for mobile relay delivery.
2434
2801
  // This elides bulky blobs and replaces oversized older history with a compact marker.
2435
- function sanitizeThreadHistoryImagesForRelay(rawMessage, requestMethod) {
2802
+ function sanitizeThreadHistoryImagesForRelay(rawMessage, requestMethod, requestContext = {}) {
2436
2803
  if (requestMethod === "thread/turns/list") {
2437
- return sanitizeThreadTurnsListForRelay(rawMessage);
2804
+ return sanitizeThreadTurnsListForRelay(rawMessage, requestContext);
2438
2805
  }
2439
2806
 
2440
2807
  if (requestMethod !== "thread/read" && requestMethod !== "thread/resume") {
@@ -2447,12 +2814,15 @@ function sanitizeThreadHistoryImagesForRelay(rawMessage, requestMethod) {
2447
2814
  return rawMessage;
2448
2815
  }
2449
2816
 
2450
- const threadId = normalizeNonEmptyString(thread.id)
2817
+ const threadId = normalizeNonEmptyString(requestContext?.threadId)
2818
+ || normalizeNonEmptyString(thread.id)
2451
2819
  || normalizeNonEmptyString(thread.threadId)
2452
2820
  || normalizeNonEmptyString(thread.thread_id);
2453
2821
  const { turns: sanitizedTurns, didSanitize } = sanitizeRelayHistoryTurns(thread.turns, threadId);
2822
+ const { thread: threadWithJsonlMetadata, didAugment: didAugmentThreadMetadata } = augmentRelayThreadWithJsonlMetadata(thread, threadId);
2823
+ const { turns: augmentedTurns, didAugment } = augmentRelayHistoryTurnsWithJsonlArtifacts(sanitizedTurns, threadId);
2454
2824
 
2455
- if (!didSanitize) {
2825
+ if (!didSanitize && !didAugment && !didAugmentThreadMetadata) {
2456
2826
  const trimmedPayload = trimThreadPayloadForRelay(parsed, thread);
2457
2827
  return trimmedPayload == null ? rawMessage : trimmedPayload;
2458
2828
  }
@@ -2462,8 +2832,8 @@ function sanitizeThreadHistoryImagesForRelay(rawMessage, requestMethod) {
2462
2832
  result: {
2463
2833
  ...parsed.result,
2464
2834
  thread: {
2465
- ...thread,
2466
- turns: sanitizedTurns,
2835
+ ...threadWithJsonlMetadata,
2836
+ turns: augmentedTurns,
2467
2837
  },
2468
2838
  },
2469
2839
  });
@@ -2471,7 +2841,7 @@ function sanitizeThreadHistoryImagesForRelay(rawMessage, requestMethod) {
2471
2841
  return trimThreadPayloadForRelay(parseBridgeJSON(sanitizedPayload), null) ?? sanitizedPayload;
2472
2842
  }
2473
2843
 
2474
- function sanitizeThreadTurnsListForRelay(rawMessage) {
2844
+ function sanitizeThreadTurnsListForRelay(rawMessage, requestContext = {}) {
2475
2845
  const parsed = parseBridgeJSON(rawMessage);
2476
2846
  const result = parsed?.result;
2477
2847
  if (!result || typeof result !== "object" || Array.isArray(result)) {
@@ -2483,23 +2853,372 @@ function sanitizeThreadTurnsListForRelay(rawMessage) {
2483
2853
  return rawMessage;
2484
2854
  }
2485
2855
 
2486
- const threadId = normalizeNonEmptyString(result.threadId)
2856
+ const threadId = normalizeNonEmptyString(requestContext?.threadId)
2857
+ || normalizeNonEmptyString(result.threadId)
2487
2858
  || normalizeNonEmptyString(result.thread_id)
2488
2859
  || normalizeNonEmptyString(result.thread?.id)
2489
2860
  || normalizeNonEmptyString(result.thread?.threadId)
2490
- || normalizeNonEmptyString(result.thread?.thread_id);
2861
+ || normalizeNonEmptyString(result.thread?.thread_id)
2862
+ || inferThreadIdFromTurns(result[turnsKey]);
2491
2863
  const { turns: sanitizedTurns, didSanitize } = sanitizeRelayHistoryTurns(result[turnsKey], threadId);
2492
- const sanitizedParsed = didSanitize
2864
+ const shouldAugmentJsonlArtifacts = requestContext?.skipJsonlArtifactAugmentation !== true;
2865
+ const { turns: augmentedTurns, didAugment } = shouldAugmentJsonlArtifacts
2866
+ ? augmentRelayHistoryTurnsWithJsonlArtifacts(sanitizedTurns, threadId)
2867
+ : { turns: sanitizedTurns, didAugment: false };
2868
+ const didChange = didSanitize || didAugment;
2869
+ const sanitizedParsed = didChange
2493
2870
  ? {
2494
2871
  ...parsed,
2495
2872
  result: {
2496
2873
  ...result,
2497
- [turnsKey]: sanitizedTurns,
2874
+ [turnsKey]: augmentedTurns,
2498
2875
  },
2499
2876
  }
2500
2877
  : parsed;
2501
2878
 
2502
- return trimTurnsListPayloadForRelay(sanitizedParsed, turnsKey, didSanitize ? null : rawMessage);
2879
+ return trimTurnsListPayloadForRelay(sanitizedParsed, turnsKey, didChange ? null : rawMessage);
2880
+ }
2881
+
2882
+ function augmentRelayThreadWithJsonlMetadata(thread, threadId = "") {
2883
+ const cwd = readJsonlThreadCwd(threadId);
2884
+ if (!cwd || !thread || typeof thread !== "object") {
2885
+ return { thread, didAugment: false };
2886
+ }
2887
+
2888
+ if (normalizeNonEmptyString(thread.cwd) === cwd
2889
+ && normalizeNonEmptyString(thread.current_working_directory) === cwd) {
2890
+ return { thread, didAugment: false };
2891
+ }
2892
+
2893
+ return {
2894
+ thread: {
2895
+ ...thread,
2896
+ cwd,
2897
+ current_working_directory: cwd,
2898
+ },
2899
+ didAugment: true,
2900
+ };
2901
+ }
2902
+
2903
+ function readJsonlThreadCwd(threadId) {
2904
+ const normalizedThreadId = normalizeNonEmptyString(threadId);
2905
+ if (!normalizedThreadId) {
2906
+ return "";
2907
+ }
2908
+
2909
+ try {
2910
+ const rolloutPath = findRecentRolloutFileForContextRead(resolveSessionsRoot(), { threadId: normalizedThreadId });
2911
+ if (!rolloutPath) {
2912
+ return "";
2913
+ }
2914
+
2915
+ const metadata = parseSessionJsonlMetadata(fs.readFileSync(rolloutPath, "utf8"));
2916
+ const cwd = normalizeNonEmptyString(metadata?.cwd);
2917
+ return cwd && path.isAbsolute(cwd) ? cwd : "";
2918
+ } catch {
2919
+ return "";
2920
+ }
2921
+ }
2922
+
2923
+ function augmentRelayHistoryTurnsWithJsonlArtifacts(turns, threadId = "") {
2924
+ const normalizedThreadId = normalizeNonEmptyString(threadId);
2925
+ if (!normalizedThreadId || !Array.isArray(turns) || turns.length === 0) {
2926
+ return { turns, didAugment: false };
2927
+ }
2928
+
2929
+ const jsonlArtifactsByTurnId = readJsonlArtifactItemsByTurnId(normalizedThreadId);
2930
+ if (jsonlArtifactsByTurnId.size === 0) {
2931
+ return { turns, didAugment: false };
2932
+ }
2933
+
2934
+ let didAugment = false;
2935
+ const augmentedTurns = turns.map((turn) => {
2936
+ const turnId = normalizeNonEmptyString(turn?.id)
2937
+ || normalizeNonEmptyString(turn?.turnId)
2938
+ || normalizeNonEmptyString(turn?.turn_id);
2939
+ const artifacts = turnId ? jsonlArtifactsByTurnId.get(turnId) : null;
2940
+ if (!artifacts || !turn || typeof turn !== "object") {
2941
+ return turn;
2942
+ }
2943
+
2944
+ const items = Array.isArray(turn.items) ? turn.items : [];
2945
+ let nextItems = items;
2946
+ if (artifacts.fileChangeItem && !hasEquivalentFileChangeItem(nextItems, artifacts.fileChangeItem)) {
2947
+ nextItems = nextItems === items ? [...items] : nextItems;
2948
+ nextItems.push(artifacts.fileChangeItem);
2949
+ }
2950
+ for (const imageViewItem of artifacts.imageViewItems || []) {
2951
+ if (hasEquivalentImageViewItem(nextItems, imageViewItem)) {
2952
+ continue;
2953
+ }
2954
+ nextItems = nextItems === items ? [...items] : nextItems;
2955
+ nextItems.push(imageViewItem);
2956
+ }
2957
+ if (artifacts.progressPlanItem && !hasEquivalentProgressPlanItem(nextItems, artifacts.progressPlanItem)) {
2958
+ nextItems = nextItems === items ? [...items] : nextItems;
2959
+ nextItems.push(artifacts.progressPlanItem);
2960
+ }
2961
+
2962
+ if (nextItems === items) {
2963
+ return turn;
2964
+ }
2965
+
2966
+ didAugment = true;
2967
+ return {
2968
+ ...turn,
2969
+ items: nextItems,
2970
+ };
2971
+ });
2972
+
2973
+ return { turns: didAugment ? augmentedTurns : turns, didAugment };
2974
+ }
2975
+
2976
+ function readJsonlArtifactItemsByTurnId(threadId) {
2977
+ const emptyArtifactsByTurnId = new Map();
2978
+ const normalizedThreadId = normalizeNonEmptyString(threadId);
2979
+ if (!normalizedThreadId) {
2980
+ return emptyArtifactsByTurnId;
2981
+ }
2982
+
2983
+ const sessionsRoot = resolveSessionsRoot();
2984
+ const cacheKey = buildJsonlArtifactItemsCacheKey(sessionsRoot, normalizedThreadId);
2985
+ const cachedArtifacts = readCachedJsonlArtifactItems(cacheKey, normalizedThreadId);
2986
+ if (cachedArtifacts) {
2987
+ return cachedArtifacts;
2988
+ }
2989
+
2990
+ try {
2991
+ const rolloutPath = findRecentRolloutFileForContextRead(sessionsRoot, { threadId: normalizedThreadId });
2992
+ if (!rolloutPath) {
2993
+ jsonlArtifactItemsCacheByThread.delete(cacheKey);
2994
+ return emptyArtifactsByTurnId;
2995
+ }
2996
+
2997
+ return readAndCacheJsonlArtifactItems(cacheKey, rolloutPath, normalizedThreadId);
2998
+ } catch (error) {
2999
+ jsonlArtifactItemsCacheByThread.delete(cacheKey);
3000
+ console.warn(`[remodex] history jsonl artifact augmentation failed for ${normalizedThreadId}: ${error.message}`);
3001
+ }
3002
+
3003
+ return emptyArtifactsByTurnId;
3004
+ }
3005
+
3006
+ function buildJsonlArtifactItemsCacheKey(sessionsRoot, threadId) {
3007
+ return `${sessionsRoot}\0${threadId}`;
3008
+ }
3009
+
3010
+ function readCachedJsonlArtifactItems(cacheKey, threadId) {
3011
+ const cached = jsonlArtifactItemsCacheByThread.get(cacheKey);
3012
+ if (!cached) {
3013
+ return null;
3014
+ }
3015
+
3016
+ const stat = statJsonlArtifactRollout(cached.rolloutPath);
3017
+ if (!stat) {
3018
+ jsonlArtifactItemsCacheByThread.delete(cacheKey);
3019
+ return null;
3020
+ }
3021
+
3022
+ if (stat.mtimeMs !== cached.mtimeMs || stat.size !== cached.size) {
3023
+ try {
3024
+ return readAndCacheJsonlArtifactItems(cacheKey, cached.rolloutPath, threadId, stat);
3025
+ } catch (error) {
3026
+ jsonlArtifactItemsCacheByThread.delete(cacheKey);
3027
+ console.warn(`[remodex] history jsonl artifact cache refresh failed for ${threadId}: ${error.message}`);
3028
+ return null;
3029
+ }
3030
+ }
3031
+
3032
+ const now = Date.now();
3033
+ if (now - cached.checkedAt <= RELAY_JSONL_ARTIFACT_CACHE_TTL_MS) {
3034
+ return cached.artifactsByTurnId;
3035
+ }
3036
+
3037
+ cached.checkedAt = now;
3038
+ return null;
3039
+ }
3040
+
3041
+ function readAndCacheJsonlArtifactItems(cacheKey, rolloutPath, threadId, stat = null) {
3042
+ const rolloutStat = stat || fs.statSync(rolloutPath);
3043
+ const artifactsByTurnId = new Map();
3044
+ try {
3045
+ const turns = parseSessionJsonlTurns(fs.readFileSync(rolloutPath, "utf8"), { threadId });
3046
+ for (const turn of turns) {
3047
+ const turnId = normalizeNonEmptyString(turn?.id);
3048
+ const turnItems = Array.isArray(turn?.items) ? turn.items : [];
3049
+ if (!turnId || turnItems.length === 0) {
3050
+ continue;
3051
+ }
3052
+
3053
+ const fileChanges = turnItems.filter((item) => normalizeHistoryItemToken(item?.type) === "filechange");
3054
+ const progressPlan = turnItems.find((item) => (
3055
+ normalizeHistoryItemToken(item?.type) === "plan"
3056
+ && item?.remodexJsonlProgressPlan === true
3057
+ ));
3058
+ const artifacts = {
3059
+ fileChangeItem: null,
3060
+ imageViewItems: [],
3061
+ progressPlanItem: null,
3062
+ };
3063
+
3064
+ const changes = [];
3065
+ for (const item of fileChanges) {
3066
+ if (Array.isArray(item.changes)) {
3067
+ changes.push(...item.changes);
3068
+ }
3069
+ }
3070
+ if (changes.length > 0) {
3071
+ artifacts.fileChangeItem = {
3072
+ id: `remodex-jsonl-file-change-${turnId}`,
3073
+ type: "fileChange",
3074
+ status: "completed",
3075
+ changes,
3076
+ remodexJsonlFileChangeAggregate: true,
3077
+ };
3078
+ }
3079
+ if (progressPlan) {
3080
+ artifacts.progressPlanItem = {
3081
+ ...progressPlan,
3082
+ id: normalizeNonEmptyString(progressPlan.id) || `remodex-jsonl-progress-plan-${turnId}`,
3083
+ };
3084
+ }
3085
+ artifacts.imageViewItems = turnItems
3086
+ .filter((item) => normalizeHistoryItemToken(item?.type) === "imageview")
3087
+ .map((item, index) => ({
3088
+ ...item,
3089
+ id: normalizeNonEmptyString(item.id) || `remodex-jsonl-image-view-${turnId}-${index + 1}`,
3090
+ }));
3091
+
3092
+ if (artifacts.fileChangeItem || artifacts.progressPlanItem || artifacts.imageViewItems.length > 0) {
3093
+ artifactsByTurnId.set(turnId, artifacts);
3094
+ }
3095
+ }
3096
+ } catch (error) {
3097
+ jsonlArtifactItemsCacheByThread.delete(cacheKey);
3098
+ throw error;
3099
+ }
3100
+
3101
+ rememberJsonlArtifactItemsCache(cacheKey, {
3102
+ rolloutPath,
3103
+ mtimeMs: rolloutStat.mtimeMs,
3104
+ size: rolloutStat.size,
3105
+ checkedAt: Date.now(),
3106
+ artifactsByTurnId,
3107
+ });
3108
+ return artifactsByTurnId;
3109
+ }
3110
+
3111
+ function statJsonlArtifactRollout(rolloutPath) {
3112
+ try {
3113
+ return fs.statSync(rolloutPath);
3114
+ } catch {
3115
+ return null;
3116
+ }
3117
+ }
3118
+
3119
+ function rememberJsonlArtifactItemsCache(cacheKey, entry) {
3120
+ jsonlArtifactItemsCacheByThread.set(cacheKey, entry);
3121
+ while (jsonlArtifactItemsCacheByThread.size > RELAY_JSONL_ARTIFACT_CACHE_MAX_ENTRIES) {
3122
+ const oldestKey = jsonlArtifactItemsCacheByThread.keys().next().value;
3123
+ if (oldestKey == null) {
3124
+ break;
3125
+ }
3126
+ jsonlArtifactItemsCacheByThread.delete(oldestKey);
3127
+ }
3128
+ }
3129
+
3130
+ function hasEquivalentFileChangeItem(items, incomingItem) {
3131
+ const incomingId = normalizeNonEmptyString(incomingItem?.id);
3132
+ const incomingPaths = fileChangePathSet(incomingItem);
3133
+ return items.some((item) => {
3134
+ if (normalizeHistoryItemToken(item?.type) !== "filechange") {
3135
+ return false;
3136
+ }
3137
+ if (incomingId && normalizeNonEmptyString(item.id) === incomingId) {
3138
+ return true;
3139
+ }
3140
+ if (item.remodexJsonlFileChangeAggregate === true) {
3141
+ return true;
3142
+ }
3143
+
3144
+ const existingPaths = fileChangePathSet(item);
3145
+ if (incomingPaths.size === 0 || existingPaths.size === 0) {
3146
+ return false;
3147
+ }
3148
+ for (const pathKey of incomingPaths) {
3149
+ if (!existingPaths.has(pathKey)) {
3150
+ return false;
3151
+ }
3152
+ }
3153
+ return true;
3154
+ });
3155
+ }
3156
+
3157
+ function hasEquivalentProgressPlanItem(items, incomingItem) {
3158
+ const incomingId = normalizeNonEmptyString(incomingItem?.id);
3159
+ return items.some((item) => {
3160
+ if (normalizeHistoryItemToken(item?.type) !== "plan") {
3161
+ return false;
3162
+ }
3163
+ return item.remodexJsonlProgressPlan === true
3164
+ || (incomingId && normalizeNonEmptyString(item.id) === incomingId);
3165
+ });
3166
+ }
3167
+
3168
+ function hasEquivalentImageViewItem(items, incomingItem) {
3169
+ const incomingId = normalizeNonEmptyString(incomingItem?.id);
3170
+ const incomingPath = normalizeImageViewPathKey(incomingItem);
3171
+ return items.some((item) => {
3172
+ if (normalizeHistoryItemToken(item?.type) !== "imageview") {
3173
+ return false;
3174
+ }
3175
+ const itemId = normalizeNonEmptyString(item.id);
3176
+ if (incomingId && itemId === incomingId) {
3177
+ return true;
3178
+ }
3179
+ return incomingPath && normalizeImageViewPathKey(item) === incomingPath;
3180
+ });
3181
+ }
3182
+
3183
+ function normalizeImageViewPathKey(item) {
3184
+ return normalizeNonEmptyString(item?.path)
3185
+ || normalizeNonEmptyString(item?.saved_path)
3186
+ || normalizeNonEmptyString(item?.savedPath)
3187
+ || normalizeNonEmptyString(item?.file_path)
3188
+ || normalizeNonEmptyString(item?.filePath);
3189
+ }
3190
+
3191
+ function fileChangePathSet(item) {
3192
+ const paths = new Set();
3193
+ const changes = Array.isArray(item?.changes) ? item.changes : [];
3194
+ for (const change of changes) {
3195
+ const pathKey = normalizeFileChangePathKey(change?.path || change?.file || change?.filePath || change?.file_path);
3196
+ if (pathKey) {
3197
+ paths.add(pathKey);
3198
+ }
3199
+ }
3200
+ return paths;
3201
+ }
3202
+
3203
+ function normalizeFileChangePathKey(value) {
3204
+ return normalizeNonEmptyString(value).replace(/\\/g, "/").replace(/^\/+/, "").toLowerCase();
3205
+ }
3206
+
3207
+ function inferThreadIdFromTurns(turns) {
3208
+ if (!Array.isArray(turns)) {
3209
+ return "";
3210
+ }
3211
+ for (const turn of turns) {
3212
+ const threadId = normalizeNonEmptyString(turn?.threadId)
3213
+ || normalizeNonEmptyString(turn?.thread_id)
3214
+ || normalizeNonEmptyString(turn?.thread?.id)
3215
+ || normalizeNonEmptyString(turn?.thread?.threadId)
3216
+ || normalizeNonEmptyString(turn?.thread?.thread_id);
3217
+ if (threadId) {
3218
+ return threadId;
3219
+ }
3220
+ }
3221
+ return "";
2503
3222
  }
2504
3223
 
2505
3224
  function sanitizeRelayHistoryTurns(turns, threadId = "") {
@@ -2530,7 +3249,12 @@ function sanitizeRelayHistoryTurn(turn, threadId = "") {
2530
3249
  }
2531
3250
 
2532
3251
  let itemDidChange = false;
2533
- let sanitizedItem = annotateImageGenerationHistoryItem(item, turnThreadId);
3252
+ let sanitizedItem = convertApplyPatchHistoryItem(item) || item;
3253
+ if (sanitizedItem !== item) {
3254
+ itemDidChange = true;
3255
+ }
3256
+
3257
+ sanitizedItem = annotateImageGenerationHistoryItem(sanitizedItem, turnThreadId);
2534
3258
  if (sanitizedItem !== item) {
2535
3259
  itemDidChange = true;
2536
3260
  }
@@ -2573,6 +3297,26 @@ function sanitizeRelayHistoryTurn(turn, threadId = "") {
2573
3297
  : turn;
2574
3298
  }
2575
3299
 
3300
+ function convertApplyPatchHistoryItem(item) {
3301
+ const itemType = normalizeHistoryItemToken(item?.type);
3302
+ const toolName = normalizeNonEmptyString(item?.name);
3303
+ if (toolName !== "apply_patch" || itemType !== "customtoolcall") {
3304
+ return null;
3305
+ }
3306
+
3307
+ const fileChangeItem = buildApplyPatchFileChangeItem({
3308
+ callId: normalizeNonEmptyString(item.call_id) || normalizeNonEmptyString(item.callId) || normalizeNonEmptyString(item.id),
3309
+ patch: normalizeNonEmptyString(item.input),
3310
+ status: normalizeNonEmptyString(item.status) || "completed",
3311
+ idFallback: normalizeNonEmptyString(item.id) || "history-apply-patch-file-change",
3312
+ });
3313
+ return fileChangeItem ? { ...item, ...fileChangeItem } : null;
3314
+ }
3315
+
3316
+ function normalizeHistoryItemToken(value) {
3317
+ return normalizeNonEmptyString(value).toLowerCase().replace(/[\s_-]+/g, "");
3318
+ }
3319
+
2576
3320
  // Annotates live image-generation notifications so the phone can render a local-file
2577
3321
  // preview and does not receive the bulky inline base64 result over the relay.
2578
3322
  function sanitizeLiveGeneratedImageMessageForRelay(rawMessage) {
@@ -3149,6 +3893,20 @@ function compactHistoryItemForRelay(item, maxChars) {
3149
3893
  type: typeof item?.type === "string" ? item.type : "relay_truncated_item",
3150
3894
  role: typeof item?.role === "string" ? item.role : undefined,
3151
3895
  itemId: typeof item?.itemId === "string" ? item.itemId : undefined,
3896
+ turnId: typeof item?.turnId === "string" ? item.turnId : undefined,
3897
+ turn_id: typeof item?.turn_id === "string" ? item.turn_id : undefined,
3898
+ createdAt: relayScalarHistoryMetadata(item?.createdAt),
3899
+ created_at: relayScalarHistoryMetadata(item?.created_at),
3900
+ startedAt: relayScalarHistoryMetadata(item?.startedAt),
3901
+ started_at: relayScalarHistoryMetadata(item?.started_at),
3902
+ completedAt: relayScalarHistoryMetadata(item?.completedAt),
3903
+ completed_at: relayScalarHistoryMetadata(item?.completed_at),
3904
+ timestamp: relayScalarHistoryMetadata(item?.timestamp),
3905
+ time: relayScalarHistoryMetadata(item?.time),
3906
+ timeZoneIdentifier: relayScalarHistoryMetadata(item?.timeZoneIdentifier),
3907
+ timeZone: relayScalarHistoryMetadata(item?.timeZone),
3908
+ timezone: relayScalarHistoryMetadata(item?.timezone),
3909
+ time_zone: relayScalarHistoryMetadata(item?.time_zone),
3152
3910
  relayPayloadTruncated: true,
3153
3911
  };
3154
3912
  const tailText = maxChars > 0 ? firstRelayTextTail(item, maxChars) : "";
@@ -3161,6 +3919,10 @@ function compactHistoryItemForRelay(item, maxChars) {
3161
3919
  );
3162
3920
  }
3163
3921
 
3922
+ function relayScalarHistoryMetadata(value) {
3923
+ return typeof value === "string" || typeof value === "number" ? value : undefined;
3924
+ }
3925
+
3164
3926
  function firstRelayTextTail(value, maxChars) {
3165
3927
  if (!value || typeof value !== "object" || Array.isArray(value)) {
3166
3928
  return "";
@@ -3209,6 +3971,7 @@ function persistBridgePreferences(
3209
3971
  }
3210
3972
 
3211
3973
  module.exports = {
3974
+ buildThreadTurnsListRelaySanitizeContext,
3212
3975
  buildHeartbeatBridgeStatus,
3213
3976
  buildRelayCloseStatusError,
3214
3977
  buildRelayAccessTokenHeaders,
@@ -3220,6 +3983,7 @@ module.exports = {
3220
3983
  isTerminalRelayCloseCode,
3221
3984
  normalizeRelayBoundJsonRpcMessage,
3222
3985
  persistBridgePreferences,
3986
+ resolveJsonlTurnsListRolloutPathForFallback,
3223
3987
  sanitizeLiveGeneratedImageMessageForRelay,
3224
3988
  sanitizeThreadHistoryImagesForRelay,
3225
3989
  startBridge,