@makerbi/remodex 1.5.4 → 1.5.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/bridge.js CHANGED
@@ -7,6 +7,7 @@
7
7
  const WebSocket = require("ws");
8
8
  const { 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");
@@ -52,6 +53,7 @@ const { createBridgeSecureTransport } = require("./secure-transport");
52
53
  const { createRolloutLiveMirrorController } = require("./rollout-live-mirror");
53
54
  const {
54
55
  createDesktopIpcActionFollower,
56
+ seedConversationStateFromThreadRead,
55
57
  } = require("./desktop-ipc-action-follower");
56
58
  const { version: bridgePackageVersion = "" } = require("../package.json");
57
59
  const {
@@ -62,8 +64,11 @@ const {
62
64
  } = require("./ios-app-compatibility");
63
65
  const { createShortPairingCode, SHORT_PAIRING_CODE_LENGTH } = require("./qr");
64
66
  const {
67
+ parseSessionJsonlMetadata,
68
+ parseSessionJsonlTurns,
65
69
  readThreadTurnsListPageFromSessionJsonl,
66
70
  } = require("./session-jsonl-history");
71
+ const { buildApplyPatchFileChangeItem } = require("./apply-patch-changes");
67
72
 
68
73
  const execFileAsync = promisify(execFile);
69
74
  const RELAY_WATCHDOG_PING_INTERVAL_MS = 10_000;
@@ -78,6 +83,12 @@ const RELAY_TURNS_LIST_TARGET_BUDGET_MS = 5_500;
78
83
  const RELAY_TURNS_LIST_BUDGET_RESERVE_MS = 1_000;
79
84
  const RELAY_TURNS_LIST_MAX_INITIAL_LIMIT = 5;
80
85
  const RELAY_TURNS_LIST_SAFE_RETRY_LIMIT = 5;
86
+ const RELAY_JSONL_TURNS_LIST_CACHE_TTL_MS = 30_000;
87
+ const RELAY_JSONL_ARTIFACT_CACHE_TTL_MS = 2_000;
88
+ const RELAY_JSONL_ARTIFACT_CACHE_MAX_ENTRIES = 128;
89
+ const BRIDGE_PACKAGE_UPDATE_COMMAND = "npm install -g @makerbi/remodex@latest";
90
+ const BRIDGE_PACKAGE_UPDATE_TIMEOUT_MS = 180_000;
91
+ const BRIDGE_RESTART_AFTER_UPDATE_DELAY_MS = 750;
81
92
  const MODELS_WITHOUT_REASONING_SUMMARY = new Set([
82
93
  "gpt-5.3-codex-spark",
83
94
  ]);
@@ -97,6 +108,9 @@ const RELAY_TURNS_LIST_PAGINATION_RESULT_KEYS = [
97
108
  "previousCursor",
98
109
  "previous_cursor",
99
110
  ];
111
+ const jsonlArtifactItemsCacheByThread = new Map();
112
+ const FORWARDED_REQUEST_METHODS_MAX_SIZE = 500;
113
+ const JSONL_ROLLOUT_PATH_CACHE_MAX_SIZE = 200;
100
114
 
101
115
  function buildRelayUserAgentHeader({ version = bridgePackageVersion } = {}) {
102
116
  const normalizedVersion = typeof version === "string" && version.trim()
@@ -116,6 +130,18 @@ function buildRelayAccessTokenHeaders(config = {}, env = process.env) {
116
130
  : {};
117
131
  }
118
132
 
133
+ function evictOldestEntries(map, maxSize) {
134
+ if (map.size <= maxSize) {
135
+ return;
136
+ }
137
+ const excess = map.size - maxSize;
138
+ const iterator = map.keys();
139
+ for (let i = 0; i < excess; i += 1) {
140
+ const key = iterator.next().value;
141
+ map.delete(key);
142
+ }
143
+ }
144
+
119
145
  function startBridge({
120
146
  config: explicitConfig = null,
121
147
  printPairingQr = true,
@@ -192,6 +218,8 @@ function startBridge({
192
218
  const relayChannels = [];
193
219
  const codexResponseRoutesById = new Map();
194
220
  const extraRelaySessionCount = readExtraRelaySessionCount(process.env);
221
+ const jsonlTurnsListRolloutCacheByThread = new Map();
222
+ const jsonlTurnsListRolloutMissCacheByThread = new Map();
195
223
  const trackedForwardedRequestMethods = new Set([
196
224
  "account/login/start",
197
225
  "account/login/cancel",
@@ -214,6 +242,7 @@ function startBridge({
214
242
  sessionId,
215
243
  relayUrl: relayBaseUrl,
216
244
  deviceState,
245
+ displayName: os.hostname(),
217
246
  onTrustedPhoneUpdate(nextDeviceState) {
218
247
  deviceState = nextDeviceState;
219
248
  sendRelayRegistrationUpdate(nextDeviceState);
@@ -240,6 +269,9 @@ function startBridge({
240
269
  const desktopIpcActionFollower = !config.codexEndpoint
241
270
  ? createDesktopIpcActionFollower({
242
271
  sendApplicationResponse,
272
+ readConversationState: async (threadId) => seedConversationStateFromThreadRead(
273
+ await sendCodexRequest("thread/read", { threadId })
274
+ ),
243
275
  socketPath: config.desktopIpcSocketPath || undefined,
244
276
  })
245
277
  : null;
@@ -323,6 +355,33 @@ function startBridge({
323
355
  relayWatchdogTimer = null;
324
356
  }
325
357
 
358
+ function prepareBridgeShutdown() {
359
+ isShuttingDown = true;
360
+ bridgeWakeAssertion.stop();
361
+ clearReconnectTimer();
362
+ clearRelayWatchdog();
363
+ bridgeStatusPublisher.stopHeartbeat();
364
+ stopContextUsageWatcher();
365
+ rolloutLiveMirror?.stopAll();
366
+ desktopIpcActionFollower?.stopAll();
367
+ }
368
+
369
+ function stopBridge() {
370
+ if (isShuttingDown) {
371
+ return;
372
+ }
373
+
374
+ prepareBridgeShutdown();
375
+ desktopRefresher.handleTransportReset();
376
+ failBridgeManagedCodexRequests(new Error("Bridge stopped before the request completed."));
377
+ forwardedRequestMethodsById.clear();
378
+
379
+ if (socket?.readyState === WebSocket.OPEN || socket?.readyState === WebSocket.CONNECTING) {
380
+ socket.close();
381
+ }
382
+ codex.shutdown();
383
+ }
384
+
326
385
  function startRelayWatchdog(trackedSocket) {
327
386
  clearRelayWatchdog();
328
387
  markRelayActivity();
@@ -379,19 +438,9 @@ function startBridge({
379
438
  return;
380
439
  }
381
440
 
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
- });
441
+ if (closeCode === 4000 || closeCode === 4001) {
442
+ logConnectionStatus("disconnected");
443
+ shutdown(codex, () => socket, prepareBridgeShutdown);
395
444
  return;
396
445
  }
397
446
 
@@ -400,7 +449,9 @@ function startBridge({
400
449
  }
401
450
 
402
451
  reconnectAttempt += 1;
403
- const delayMs = Math.min(1_000 * reconnectAttempt, 5_000);
452
+ const baseDelayMs = Math.min(1_000 * reconnectAttempt, 5_000);
453
+ const jitterMs = Math.floor(Math.random() * Math.min(baseDelayMs, 2_000));
454
+ const delayMs = baseDelayMs + jitterMs;
404
455
  logConnectionStatus("connecting");
405
456
  reconnectTimer = setTimeout(() => {
406
457
  reconnectTimer = null;
@@ -415,6 +466,11 @@ function startBridge({
415
466
 
416
467
  logConnectionStatus("connecting");
417
468
  const nextSocket = new WebSocket(relaySessionUrl, {
469
+ perMessageDeflate: {
470
+ zlibDeflateOptions: { level: 6 },
471
+ threshold: 256,
472
+ concurrencyLimit: 4,
473
+ },
418
474
  // The relay uses this per-session secret to authenticate the first push registration.
419
475
  headers: {
420
476
  "User-Agent": buildRelayUserAgentHeader(),
@@ -471,8 +527,8 @@ function startBridge({
471
527
  socket = null;
472
528
  }
473
529
  stopContextUsageWatcher();
474
- rolloutLiveMirror?.stopAll();
475
- desktopIpcActionFollower?.stopAll();
530
+ // Relay reconnects are transport-only: keep local live observers running
531
+ // so their output can enter secure replay and catch up on the next resume.
476
532
  desktopRefresher.handleTransportReset();
477
533
  scheduleRelayReconnect(code, closeReason);
478
534
  });
@@ -680,12 +736,7 @@ function startBridge({
680
736
  console.error(`[remodex] ${lastError}`);
681
737
  process.exitCode = 1;
682
738
  }
683
- isShuttingDown = true;
684
- bridgeWakeAssertion.stop();
685
- clearReconnectTimer();
686
- stopContextUsageWatcher();
687
- rolloutLiveMirror?.stopAll();
688
- desktopIpcActionFollower?.stopAll();
739
+ prepareBridgeShutdown();
689
740
  desktopRefresher.handleTransportReset();
690
741
  failBridgeManagedCodexRequests(new Error("Codex transport closed before the bridge request completed."));
691
742
  forwardedRequestMethodsById.clear();
@@ -696,22 +747,8 @@ function startBridge({
696
747
  closeExtraRelayChannels();
697
748
  });
698
749
 
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
- }));
750
+ process.on("SIGINT", () => shutdown(codex, () => socket, prepareBridgeShutdown));
751
+ process.on("SIGTERM", () => shutdown(codex, () => socket, prepareBridgeShutdown));
715
752
 
716
753
  // Routes decrypted app payloads through the same bridge handlers as before.
717
754
  function handleApplicationMessage(rawMessage, relayChannel = primaryRelayChannel) {
@@ -745,6 +782,7 @@ function startBridge({
745
782
  appPath: config.codexAppPath,
746
783
  readBridgePreferences,
747
784
  updateBridgePreferences,
785
+ updateBridgePackageAndRestart,
748
786
  })) {
749
787
  return;
750
788
  }
@@ -974,12 +1012,19 @@ function startBridge({
974
1012
  const response = await fetchAdaptiveThreadTurnsListForRelay(request, {
975
1013
  fetchPage: (params) => sendCodexRequest("thread/turns/list", params),
976
1014
  });
977
- const fallbackResponse = maybeBuildJsonlThreadTurnsListFallback(request, response);
1015
+ const jsonlFallback = maybeBuildJsonlThreadTurnsListFallback(request, response);
1016
+ const responsePayload = jsonlFallback?.response ?? response;
1017
+ const finalSanitizeContext = buildThreadTurnsListRelaySanitizeContext(request);
978
1018
  relaySanitizedResponseMethodsById.set(String(request.id), {
979
1019
  method: "thread/turns/list",
1020
+ ...finalSanitizeContext,
980
1021
  createdAt: Date.now(),
981
1022
  });
982
- sendResponse(JSON.stringify(fallbackResponse ?? response));
1023
+ sendResponse(sanitizeThreadHistoryImagesForRelay(
1024
+ JSON.stringify(responsePayload),
1025
+ "thread/turns/list",
1026
+ finalSanitizeContext
1027
+ ));
983
1028
  } catch (error) {
984
1029
  sendResponse(createJsonRpcErrorResponse(
985
1030
  request.id,
@@ -993,10 +1038,6 @@ function startBridge({
993
1038
  }
994
1039
 
995
1040
  function maybeBuildJsonlThreadTurnsListFallback(request, response) {
996
- if (!isEmptyTurnsListResponse(response)) {
997
- return null;
998
- }
999
-
1000
1041
  const params = request?.params || {};
1001
1042
  const threadId = normalizeNonEmptyString(params.threadId)
1002
1043
  || normalizeNonEmptyString(params.thread_id);
@@ -1005,10 +1046,17 @@ function startBridge({
1005
1046
  }
1006
1047
 
1007
1048
  try {
1008
- const rolloutPath = findRecentRolloutFileForContextRead(resolveSessionsRoot(), { threadId });
1049
+ const responseIsEmpty = isEmptyTurnsListResponse(response);
1050
+ const rolloutPath = resolveJsonlTurnsListRolloutPathForFallback({
1051
+ threadId,
1052
+ responseIsEmpty,
1053
+ readCachedPath: readCachedJsonlTurnsListRolloutPath,
1054
+ findAndCachePath: findAndCacheJsonlTurnsListRolloutPath,
1055
+ });
1009
1056
  if (!rolloutPath) {
1010
1057
  return null;
1011
1058
  }
1059
+
1012
1060
  const result = readThreadTurnsListPageFromSessionJsonl(rolloutPath, {
1013
1061
  threadId,
1014
1062
  limit: params.limit,
@@ -1020,16 +1068,69 @@ function startBridge({
1020
1068
  return null;
1021
1069
  }
1022
1070
 
1071
+ if (!responseIsEmpty) {
1072
+ const mergedResponse = maybeMergeLatestJsonlTurnIntoTurnsListResponse(request, response, result, params);
1073
+ return mergedResponse ? { response: mergedResponse, usesJsonl: true } : null;
1074
+ }
1075
+
1023
1076
  return {
1024
- id: request.id,
1025
- result,
1077
+ response: {
1078
+ id: request.id,
1079
+ result,
1080
+ },
1081
+ usesJsonl: true,
1026
1082
  };
1027
1083
  } catch (error) {
1084
+ jsonlTurnsListRolloutCacheByThread.delete(threadId);
1028
1085
  console.warn(`[remodex] thread/turns/list jsonl fallback failed: ${error.message}`);
1029
1086
  return null;
1030
1087
  }
1031
1088
  }
1032
1089
 
1090
+ function findAndCacheJsonlTurnsListRolloutPath(threadId) {
1091
+ if (hasFreshJsonlTurnsListRolloutMiss(threadId)) {
1092
+ return "";
1093
+ }
1094
+
1095
+ const rolloutPath = findRecentRolloutFileForContextRead(resolveSessionsRoot(), { threadId });
1096
+ if (rolloutPath) {
1097
+ jsonlTurnsListRolloutMissCacheByThread.delete(threadId);
1098
+ jsonlTurnsListRolloutCacheByThread.set(threadId, {
1099
+ rolloutPath,
1100
+ cachedAt: Date.now(),
1101
+ });
1102
+ } else {
1103
+ jsonlTurnsListRolloutMissCacheByThread.set(threadId, Date.now());
1104
+ }
1105
+ return rolloutPath;
1106
+ }
1107
+
1108
+ function readCachedJsonlTurnsListRolloutPath(threadId) {
1109
+ const cached = jsonlTurnsListRolloutCacheByThread.get(threadId);
1110
+ if (!cached) {
1111
+ return "";
1112
+ }
1113
+ if (Date.now() - cached.cachedAt > RELAY_JSONL_TURNS_LIST_CACHE_TTL_MS) {
1114
+ jsonlTurnsListRolloutCacheByThread.delete(threadId);
1115
+ return "";
1116
+ }
1117
+ // Non-empty app-server pages only consult this positive cache to avoid
1118
+ // walking the sessions tree during ordinary pagination.
1119
+ return cached.rolloutPath;
1120
+ }
1121
+
1122
+ function hasFreshJsonlTurnsListRolloutMiss(threadId) {
1123
+ const missedAt = jsonlTurnsListRolloutMissCacheByThread.get(threadId);
1124
+ if (!missedAt) {
1125
+ return false;
1126
+ }
1127
+ if (Date.now() - missedAt <= RELAY_JSONL_TURNS_LIST_CACHE_TTL_MS) {
1128
+ return true;
1129
+ }
1130
+ jsonlTurnsListRolloutMissCacheByThread.delete(threadId);
1131
+ return false;
1132
+ }
1133
+
1033
1134
  // ─── Bridge-owned auth snapshot ─────────────────────────────
1034
1135
 
1035
1136
  // Handles the bridge-owned auth status wrappers without exposing tokens to the phone.
@@ -1178,10 +1279,17 @@ function startBridge({
1178
1279
  });
1179
1280
  }
1180
1281
  if (relaySanitizedRequestMethods.has(method)) {
1181
- relaySanitizedResponseMethodsById.set(String(requestId), {
1282
+ const trackedRequest = {
1182
1283
  method,
1284
+ threadId: method === "thread/turns/list" || method === "thread/read" || method === "thread/resume"
1285
+ ? threadIdFromRequestParams(parsed.params)
1286
+ : "",
1183
1287
  createdAt: Date.now(),
1184
- });
1288
+ };
1289
+ if (method === "thread/turns/list") {
1290
+ trackedRequest.skipJsonlArtifactAugmentation = false;
1291
+ }
1292
+ relaySanitizedResponseMethodsById.set(String(requestId), trackedRequest);
1185
1293
  }
1186
1294
  }
1187
1295
 
@@ -1207,7 +1315,7 @@ function startBridge({
1207
1315
  }
1208
1316
  relaySanitizedResponseMethodsById.delete(String(responseId));
1209
1317
 
1210
- return sanitizeThreadHistoryImagesForRelay(normalizedMessage, trackedRequest.method);
1318
+ return sanitizeThreadHistoryImagesForRelay(normalizedMessage, trackedRequest.method, trackedRequest);
1211
1319
  }
1212
1320
 
1213
1321
  function updatePendingAuthLoginFromCodexMessage(rawMessage) {
@@ -1260,16 +1368,31 @@ function startBridge({
1260
1368
  }
1261
1369
 
1262
1370
  function pruneExpiredForwardedRequestMethods(now = Date.now()) {
1371
+ const expiredForwarded = [];
1263
1372
  for (const [requestId, trackedRequest] of forwardedRequestMethodsById.entries()) {
1264
1373
  if (!trackedRequest || (now - trackedRequest.createdAt) >= forwardedRequestMethodTTLms) {
1265
- forwardedRequestMethodsById.delete(requestId);
1374
+ expiredForwarded.push(requestId);
1266
1375
  }
1267
1376
  }
1377
+ for (const id of expiredForwarded) {
1378
+ forwardedRequestMethodsById.delete(id);
1379
+ }
1380
+
1381
+ const expiredSanitized = [];
1268
1382
  for (const [requestId, trackedRequest] of relaySanitizedResponseMethodsById.entries()) {
1269
1383
  if (!trackedRequest || (now - trackedRequest.createdAt) >= forwardedRequestMethodTTLms) {
1270
- relaySanitizedResponseMethodsById.delete(requestId);
1384
+ expiredSanitized.push(requestId);
1271
1385
  }
1272
1386
  }
1387
+ for (const id of expiredSanitized) {
1388
+ relaySanitizedResponseMethodsById.delete(id);
1389
+ }
1390
+
1391
+ evictOldestEntries(forwardedRequestMethodsById, FORWARDED_REQUEST_METHODS_MAX_SIZE);
1392
+ evictOldestEntries(relaySanitizedResponseMethodsById, FORWARDED_REQUEST_METHODS_MAX_SIZE);
1393
+ evictOldestEntries(jsonlArtifactItemsCacheByThread, RELAY_JSONL_ARTIFACT_CACHE_MAX_ENTRIES);
1394
+ evictOldestEntries(jsonlTurnsListRolloutCacheByThread, JSONL_ROLLOUT_PATH_CACHE_MAX_SIZE);
1395
+ evictOldestEntries(jsonlTurnsListRolloutMissCacheByThread, JSONL_ROLLOUT_PATH_CACHE_MAX_SIZE);
1273
1396
  }
1274
1397
 
1275
1398
  function safeParseJSON(value) {
@@ -1629,6 +1752,64 @@ function startBridge({
1629
1752
 
1630
1753
  return readBridgePreferences();
1631
1754
  }
1755
+
1756
+ async function updateBridgePackageAndRestart() {
1757
+ if (process.platform !== "darwin") {
1758
+ const error = new Error("Bridge self-update is available only for the macOS bridge service.");
1759
+ error.errorCode = "unsupported_platform";
1760
+ error.userMessage = error.message;
1761
+ throw error;
1762
+ }
1763
+
1764
+ try {
1765
+ await execFileAsync("/bin/zsh", [
1766
+ "-lc",
1767
+ [
1768
+ "export TERM=dumb",
1769
+ "source ~/.zshrc >/dev/null 2>/dev/null || true",
1770
+ BRIDGE_PACKAGE_UPDATE_COMMAND,
1771
+ ].join("; "),
1772
+ ], {
1773
+ timeout: BRIDGE_PACKAGE_UPDATE_TIMEOUT_MS,
1774
+ maxBuffer: 2 * 1024 * 1024,
1775
+ });
1776
+ } catch (error) {
1777
+ const nextError = new Error(
1778
+ truncateCommandOutput(error?.stderr || error?.stdout || error?.message)
1779
+ || "Could not update the Remodex bridge package on this Mac."
1780
+ );
1781
+ nextError.errorCode = "bridge_update_failed";
1782
+ nextError.userMessage = nextError.message;
1783
+ nextError.cause = error;
1784
+ throw nextError;
1785
+ }
1786
+
1787
+ scheduleBridgeServiceRestartAfterUpdate();
1788
+ return {
1789
+ success: true,
1790
+ command: BRIDGE_PACKAGE_UPDATE_COMMAND,
1791
+ restartScheduled: true,
1792
+ restartDelayMs: BRIDGE_RESTART_AFTER_UPDATE_DELAY_MS,
1793
+ };
1794
+ }
1795
+
1796
+ // Restarts after the RPC response has crossed the encrypted phone channel.
1797
+ function scheduleBridgeServiceRestartAfterUpdate() {
1798
+ const restartTimer = setTimeout(() => {
1799
+ const cliPath = path.join(__dirname, "..", "bin", "remodex.js");
1800
+ const child = spawn(process.execPath, [cliPath, "restart"], {
1801
+ detached: true,
1802
+ stdio: "ignore",
1803
+ env: process.env,
1804
+ });
1805
+ child.unref?.();
1806
+ }, BRIDGE_RESTART_AFTER_UPDATE_DELAY_MS);
1807
+ restartTimer.unref?.();
1808
+ }
1809
+
1810
+ return {
1811
+ stop: stopBridge,
1812
+ };
1632
1813
  }
1633
1814
 
1634
1815
  // Holds a single macOS idle-sleep assertion for as long as the bridge process stays alive.
@@ -1935,6 +2116,14 @@ function normalizeNonEmptyString(value) {
1935
2116
  return typeof value === "string" && value.trim() ? value.trim() : "";
1936
2117
  }
1937
2118
 
2119
+ function truncateCommandOutput(value, maxChars = 1_200) {
2120
+ const normalized = normalizeNonEmptyString(value);
2121
+ if (!normalized || normalized.length <= maxChars) {
2122
+ return normalized;
2123
+ }
2124
+ return `...${normalized.slice(-maxChars)}`;
2125
+ }
2126
+
1938
2127
  function parseAdaptiveThreadTurnsListRequest(rawMessage) {
1939
2128
  const parsed = parseBridgeJSON(rawMessage);
1940
2129
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
@@ -1961,6 +2150,22 @@ function parseAdaptiveThreadTurnsListRequest(rawMessage) {
1961
2150
  return parsed;
1962
2151
  }
1963
2152
 
2153
+ function threadIdFromRequestParams(params) {
2154
+ return normalizeNonEmptyString(params?.threadId)
2155
+ || normalizeNonEmptyString(params?.thread_id)
2156
+ || normalizeNonEmptyString(params?.id)
2157
+ || "";
2158
+ }
2159
+
2160
+ function buildThreadTurnsListRelaySanitizeContext(request, {
2161
+ skipJsonlArtifactAugmentation = false,
2162
+ } = {}) {
2163
+ return {
2164
+ threadId: threadIdFromRequestParams(request?.params || {}),
2165
+ skipJsonlArtifactAugmentation,
2166
+ };
2167
+ }
2168
+
1964
2169
  async function fetchAdaptiveThreadTurnsListForRelay(request, {
1965
2170
  fetchPage,
1966
2171
  now = Date.now,
@@ -1978,6 +2183,9 @@ async function fetchAdaptiveThreadTurnsListForRelay(request, {
1978
2183
  const requestedLimit = Number.isInteger(params?.limit) && params.limit > 0
1979
2184
  ? Math.min(params.limit, RELAY_TURNS_LIST_MAX_INITIAL_LIMIT)
1980
2185
  : 1;
2186
+ const sanitizeContext = buildThreadTurnsListRelaySanitizeContext(request, {
2187
+ skipJsonlArtifactAugmentation: true,
2188
+ });
1981
2189
  const startedAt = now();
1982
2190
  let nextCursor = params?.cursor;
1983
2191
  let turnsKey = null;
@@ -2002,6 +2210,7 @@ async function fetchAdaptiveThreadTurnsListForRelay(request, {
2002
2210
  fetchPage,
2003
2211
  now,
2004
2212
  sanitizeForRelay,
2213
+ sanitizeContext,
2005
2214
  payloadSoftLimitBytes,
2006
2215
  });
2007
2216
  }
@@ -2014,6 +2223,7 @@ async function fetchAdaptiveThreadTurnsListForRelay(request, {
2014
2223
  fetchPage,
2015
2224
  now,
2016
2225
  sanitizeForRelay,
2226
+ sanitizeContext,
2017
2227
  payloadSoftLimitBytes,
2018
2228
  });
2019
2229
  }
@@ -2032,7 +2242,7 @@ async function fetchAdaptiveThreadTurnsListForRelay(request, {
2032
2242
  combinedTurns = combinedTurns.concat(pageTurns);
2033
2243
  response = buildSafeTurnsListResponse(request.id, firstResult, lastResult, turnsKey, combinedTurns);
2034
2244
 
2035
- if (measureSanitizedTurnsListResponseBytes(response, sanitizeForRelay) >= payloadSoftLimitBytes) {
2245
+ if (measureSanitizedTurnsListResponseBytes(response, sanitizeForRelay, sanitizeContext) >= payloadSoftLimitBytes) {
2036
2246
  response = buildLargestSafeTurnsListResponse({
2037
2247
  requestId: request.id,
2038
2248
  firstResult,
@@ -2041,6 +2251,7 @@ async function fetchAdaptiveThreadTurnsListForRelay(request, {
2041
2251
  turns: combinedTurns,
2042
2252
  maxTurns: RELAY_TURNS_LIST_SAFE_RETRY_LIMIT,
2043
2253
  sanitizeForRelay,
2254
+ sanitizeContext,
2044
2255
  payloadSoftLimitBytes,
2045
2256
  }) ?? buildEmptyTurnsListResponse(request);
2046
2257
  break;
@@ -2052,7 +2263,7 @@ async function fetchAdaptiveThreadTurnsListForRelay(request, {
2052
2263
  }
2053
2264
 
2054
2265
  const rawPageBytes = jsonByteLength(pageResult);
2055
- const sanitizedResponseBytes = measureSanitizedTurnsListResponseBytes(response, sanitizeForRelay);
2266
+ const sanitizedResponseBytes = measureSanitizedTurnsListResponseBytes(response, sanitizeForRelay, sanitizeContext);
2056
2267
  const elapsedMs = Math.max(0, now() - startedAt);
2057
2268
  const remainingBudgetMs = Math.max(0, targetBudgetMs - elapsedMs);
2058
2269
  if (
@@ -2088,10 +2299,87 @@ function isEmptyTurnsListResponse(response) {
2088
2299
  return Boolean(turnsKey) && response.result[turnsKey].length === 0;
2089
2300
  }
2090
2301
 
2302
+ // Non-empty app-server pages can be stale for Mac-started runs, so the first page
2303
+ // still gets one JSONL lookup when the positive rollout cache is cold.
2304
+ function resolveJsonlTurnsListRolloutPathForFallback({
2305
+ threadId,
2306
+ responseIsEmpty,
2307
+ readCachedPath,
2308
+ findAndCachePath,
2309
+ }) {
2310
+ if (!threadId || typeof findAndCachePath !== "function") {
2311
+ return "";
2312
+ }
2313
+
2314
+ if (responseIsEmpty) {
2315
+ return findAndCachePath(threadId);
2316
+ }
2317
+
2318
+ return typeof readCachedPath === "function"
2319
+ ? readCachedPath(threadId) || findAndCachePath(threadId)
2320
+ : findAndCachePath(threadId);
2321
+ }
2322
+
2323
+ function maybeMergeLatestJsonlTurnIntoTurnsListResponse(request, response, jsonlResult, params = {}) {
2324
+ const responseResult = response?.result;
2325
+ const responseTurnsKey = findTurnsListResultKey(responseResult);
2326
+ const jsonlTurnsKey = findTurnsListResultKey(jsonlResult);
2327
+ if (!responseTurnsKey || !jsonlTurnsKey) {
2328
+ return null;
2329
+ }
2330
+
2331
+ const responseTurns = responseResult[responseTurnsKey];
2332
+ const jsonlTurn = jsonlResult[jsonlTurnsKey]?.[0];
2333
+ const jsonlTurnId = turnListTurnIdentifier(jsonlTurn);
2334
+ if (!jsonlTurnId || responseTurns.some((turn) => turnListTurnIdentifier(turn) === jsonlTurnId)) {
2335
+ return null;
2336
+ }
2337
+
2338
+ if (!shouldMergeLatestJsonlTurn(jsonlTurn)) {
2339
+ return null;
2340
+ }
2341
+
2342
+ const requestedLimit = Number.isInteger(params?.limit) && params.limit > 0
2343
+ ? params.limit
2344
+ : responseTurns.length + 1;
2345
+ const mergedTurns = [jsonlTurn, ...responseTurns].slice(0, requestedLimit);
2346
+ return {
2347
+ id: request.id,
2348
+ result: {
2349
+ ...responseResult,
2350
+ [responseTurnsKey]: mergedTurns,
2351
+ remodexJsonlMergedLatest: true,
2352
+ },
2353
+ };
2354
+ }
2355
+
2356
+ function shouldMergeLatestJsonlTurn(turn) {
2357
+ if (!turn || typeof turn !== "object") {
2358
+ return false;
2359
+ }
2360
+
2361
+ const status = normalizeHistoryItemToken(turn.status);
2362
+ if (status === "running" || status === "inprogress" || status === "active") {
2363
+ return true;
2364
+ }
2365
+
2366
+ return Array.isArray(turn.items) && turn.items.some((item) => {
2367
+ const type = normalizeHistoryItemToken(item?.type);
2368
+ return type === "plan" || type === "filechange";
2369
+ });
2370
+ }
2371
+
2372
+ function turnListTurnIdentifier(turn) {
2373
+ return normalizeNonEmptyString(turn?.id)
2374
+ || normalizeNonEmptyString(turn?.turnId)
2375
+ || normalizeNonEmptyString(turn?.turn_id);
2376
+ }
2377
+
2091
2378
  async function fetchSafeThreadTurnsListFallback(request, {
2092
2379
  fetchPage,
2093
2380
  now,
2094
2381
  sanitizeForRelay,
2382
+ sanitizeContext = {},
2095
2383
  payloadSoftLimitBytes,
2096
2384
  }) {
2097
2385
  const params = request?.params;
@@ -2119,6 +2407,7 @@ async function fetchSafeThreadTurnsListFallback(request, {
2119
2407
  turns: pageResult[turnsKey],
2120
2408
  maxTurns: safeLimit,
2121
2409
  sanitizeForRelay,
2410
+ sanitizeContext,
2122
2411
  payloadSoftLimitBytes,
2123
2412
  });
2124
2413
  if (response) {
@@ -2187,6 +2476,7 @@ function buildLargestSafeTurnsListResponse({
2187
2476
  turns,
2188
2477
  maxTurns,
2189
2478
  sanitizeForRelay,
2479
+ sanitizeContext = {},
2190
2480
  payloadSoftLimitBytes,
2191
2481
  }) {
2192
2482
  const sliceLimit = Math.min(turns.length, maxTurns);
@@ -2198,7 +2488,7 @@ function buildLargestSafeTurnsListResponse({
2198
2488
  turnsKey,
2199
2489
  turns.slice(0, count)
2200
2490
  );
2201
- if (measureSanitizedTurnsListResponseBytes(response, sanitizeForRelay) < payloadSoftLimitBytes) {
2491
+ if (measureSanitizedTurnsListResponseBytes(response, sanitizeForRelay, sanitizeContext) < payloadSoftLimitBytes) {
2202
2492
  return response;
2203
2493
  }
2204
2494
  }
@@ -2208,6 +2498,7 @@ function buildLargestSafeTurnsListResponse({
2208
2498
  turnsKey,
2209
2499
  turn: turns[0],
2210
2500
  sanitizeForRelay,
2501
+ sanitizeContext,
2211
2502
  payloadSoftLimitBytes,
2212
2503
  });
2213
2504
  }
@@ -2218,6 +2509,7 @@ function buildEmergencySingleTurnResponse({
2218
2509
  turnsKey,
2219
2510
  turn,
2220
2511
  sanitizeForRelay,
2512
+ sanitizeContext = {},
2221
2513
  payloadSoftLimitBytes,
2222
2514
  }) {
2223
2515
  if (!turn || typeof turn !== "object" || Array.isArray(turn)) {
@@ -2240,7 +2532,7 @@ function buildEmergencySingleTurnResponse({
2240
2532
  remodexEmergencySingleTurnForRelay: true,
2241
2533
  },
2242
2534
  };
2243
- if (measureSanitizedTurnsListResponseBytes(response, sanitizeForRelay) < payloadSoftLimitBytes) {
2535
+ if (measureSanitizedTurnsListResponseBytes(response, sanitizeForRelay, sanitizeContext) < payloadSoftLimitBytes) {
2244
2536
  return response;
2245
2537
  }
2246
2538
  }
@@ -2318,10 +2610,10 @@ function jsonByteLength(value) {
2318
2610
  }
2319
2611
  }
2320
2612
 
2321
- function measureSanitizedTurnsListResponseBytes(response, sanitizeForRelay) {
2613
+ function measureSanitizedTurnsListResponseBytes(response, sanitizeForRelay, requestContext = {}) {
2322
2614
  try {
2323
2615
  const rawResponse = JSON.stringify(response);
2324
- const sanitizedResponse = sanitizeForRelay(rawResponse, "thread/turns/list");
2616
+ const sanitizedResponse = sanitizeForRelay(rawResponse, "thread/turns/list", requestContext);
2325
2617
  return Buffer.byteLength(sanitizedResponse, "utf8");
2326
2618
  } catch {
2327
2619
  return Number.POSITIVE_INFINITY;
@@ -2432,9 +2724,9 @@ function isRelayBoundServerRequestMethod(method) {
2432
2724
 
2433
2725
  // Shrinks thread history snapshots/pages for mobile relay delivery.
2434
2726
  // This elides bulky blobs and replaces oversized older history with a compact marker.
2435
- function sanitizeThreadHistoryImagesForRelay(rawMessage, requestMethod) {
2727
+ function sanitizeThreadHistoryImagesForRelay(rawMessage, requestMethod, requestContext = {}) {
2436
2728
  if (requestMethod === "thread/turns/list") {
2437
- return sanitizeThreadTurnsListForRelay(rawMessage);
2729
+ return sanitizeThreadTurnsListForRelay(rawMessage, requestContext);
2438
2730
  }
2439
2731
 
2440
2732
  if (requestMethod !== "thread/read" && requestMethod !== "thread/resume") {
@@ -2447,12 +2739,15 @@ function sanitizeThreadHistoryImagesForRelay(rawMessage, requestMethod) {
2447
2739
  return rawMessage;
2448
2740
  }
2449
2741
 
2450
- const threadId = normalizeNonEmptyString(thread.id)
2742
+ const threadId = normalizeNonEmptyString(requestContext?.threadId)
2743
+ || normalizeNonEmptyString(thread.id)
2451
2744
  || normalizeNonEmptyString(thread.threadId)
2452
2745
  || normalizeNonEmptyString(thread.thread_id);
2453
2746
  const { turns: sanitizedTurns, didSanitize } = sanitizeRelayHistoryTurns(thread.turns, threadId);
2747
+ const { thread: threadWithJsonlMetadata, didAugment: didAugmentThreadMetadata } = augmentRelayThreadWithJsonlMetadata(thread, threadId);
2748
+ const { turns: augmentedTurns, didAugment } = augmentRelayHistoryTurnsWithJsonlArtifacts(sanitizedTurns, threadId);
2454
2749
 
2455
- if (!didSanitize) {
2750
+ if (!didSanitize && !didAugment && !didAugmentThreadMetadata) {
2456
2751
  const trimmedPayload = trimThreadPayloadForRelay(parsed, thread);
2457
2752
  return trimmedPayload == null ? rawMessage : trimmedPayload;
2458
2753
  }
@@ -2462,8 +2757,8 @@ function sanitizeThreadHistoryImagesForRelay(rawMessage, requestMethod) {
2462
2757
  result: {
2463
2758
  ...parsed.result,
2464
2759
  thread: {
2465
- ...thread,
2466
- turns: sanitizedTurns,
2760
+ ...threadWithJsonlMetadata,
2761
+ turns: augmentedTurns,
2467
2762
  },
2468
2763
  },
2469
2764
  });
@@ -2471,7 +2766,7 @@ function sanitizeThreadHistoryImagesForRelay(rawMessage, requestMethod) {
2471
2766
  return trimThreadPayloadForRelay(parseBridgeJSON(sanitizedPayload), null) ?? sanitizedPayload;
2472
2767
  }
2473
2768
 
2474
- function sanitizeThreadTurnsListForRelay(rawMessage) {
2769
+ function sanitizeThreadTurnsListForRelay(rawMessage, requestContext = {}) {
2475
2770
  const parsed = parseBridgeJSON(rawMessage);
2476
2771
  const result = parsed?.result;
2477
2772
  if (!result || typeof result !== "object" || Array.isArray(result)) {
@@ -2483,23 +2778,372 @@ function sanitizeThreadTurnsListForRelay(rawMessage) {
2483
2778
  return rawMessage;
2484
2779
  }
2485
2780
 
2486
- const threadId = normalizeNonEmptyString(result.threadId)
2781
+ const threadId = normalizeNonEmptyString(requestContext?.threadId)
2782
+ || normalizeNonEmptyString(result.threadId)
2487
2783
  || normalizeNonEmptyString(result.thread_id)
2488
2784
  || normalizeNonEmptyString(result.thread?.id)
2489
2785
  || normalizeNonEmptyString(result.thread?.threadId)
2490
- || normalizeNonEmptyString(result.thread?.thread_id);
2786
+ || normalizeNonEmptyString(result.thread?.thread_id)
2787
+ || inferThreadIdFromTurns(result[turnsKey]);
2491
2788
  const { turns: sanitizedTurns, didSanitize } = sanitizeRelayHistoryTurns(result[turnsKey], threadId);
2492
- const sanitizedParsed = didSanitize
2789
+ const shouldAugmentJsonlArtifacts = requestContext?.skipJsonlArtifactAugmentation !== true;
2790
+ const { turns: augmentedTurns, didAugment } = shouldAugmentJsonlArtifacts
2791
+ ? augmentRelayHistoryTurnsWithJsonlArtifacts(sanitizedTurns, threadId)
2792
+ : { turns: sanitizedTurns, didAugment: false };
2793
+ const didChange = didSanitize || didAugment;
2794
+ const sanitizedParsed = didChange
2493
2795
  ? {
2494
2796
  ...parsed,
2495
2797
  result: {
2496
2798
  ...result,
2497
- [turnsKey]: sanitizedTurns,
2799
+ [turnsKey]: augmentedTurns,
2498
2800
  },
2499
2801
  }
2500
2802
  : parsed;
2501
2803
 
2502
- return trimTurnsListPayloadForRelay(sanitizedParsed, turnsKey, didSanitize ? null : rawMessage);
2804
+ return trimTurnsListPayloadForRelay(sanitizedParsed, turnsKey, didChange ? null : rawMessage);
2805
+ }
2806
+
2807
+ function augmentRelayThreadWithJsonlMetadata(thread, threadId = "") {
2808
+ const cwd = readJsonlThreadCwd(threadId);
2809
+ if (!cwd || !thread || typeof thread !== "object") {
2810
+ return { thread, didAugment: false };
2811
+ }
2812
+
2813
+ if (normalizeNonEmptyString(thread.cwd) === cwd
2814
+ && normalizeNonEmptyString(thread.current_working_directory) === cwd) {
2815
+ return { thread, didAugment: false };
2816
+ }
2817
+
2818
+ return {
2819
+ thread: {
2820
+ ...thread,
2821
+ cwd,
2822
+ current_working_directory: cwd,
2823
+ },
2824
+ didAugment: true,
2825
+ };
2826
+ }
2827
+
2828
+ function readJsonlThreadCwd(threadId) {
2829
+ const normalizedThreadId = normalizeNonEmptyString(threadId);
2830
+ if (!normalizedThreadId) {
2831
+ return "";
2832
+ }
2833
+
2834
+ try {
2835
+ const rolloutPath = findRecentRolloutFileForContextRead(resolveSessionsRoot(), { threadId: normalizedThreadId });
2836
+ if (!rolloutPath) {
2837
+ return "";
2838
+ }
2839
+
2840
+ const metadata = parseSessionJsonlMetadata(fs.readFileSync(rolloutPath, "utf8"));
2841
+ const cwd = normalizeNonEmptyString(metadata?.cwd);
2842
+ return cwd && path.isAbsolute(cwd) ? cwd : "";
2843
+ } catch {
2844
+ return "";
2845
+ }
2846
+ }
2847
+
2848
+ function augmentRelayHistoryTurnsWithJsonlArtifacts(turns, threadId = "") {
2849
+ const normalizedThreadId = normalizeNonEmptyString(threadId);
2850
+ if (!normalizedThreadId || !Array.isArray(turns) || turns.length === 0) {
2851
+ return { turns, didAugment: false };
2852
+ }
2853
+
2854
+ const jsonlArtifactsByTurnId = readJsonlArtifactItemsByTurnId(normalizedThreadId);
2855
+ if (jsonlArtifactsByTurnId.size === 0) {
2856
+ return { turns, didAugment: false };
2857
+ }
2858
+
2859
+ let didAugment = false;
2860
+ const augmentedTurns = turns.map((turn) => {
2861
+ const turnId = normalizeNonEmptyString(turn?.id)
2862
+ || normalizeNonEmptyString(turn?.turnId)
2863
+ || normalizeNonEmptyString(turn?.turn_id);
2864
+ const artifacts = turnId ? jsonlArtifactsByTurnId.get(turnId) : null;
2865
+ if (!artifacts || !turn || typeof turn !== "object") {
2866
+ return turn;
2867
+ }
2868
+
2869
+ const items = Array.isArray(turn.items) ? turn.items : [];
2870
+ let nextItems = items;
2871
+ if (artifacts.fileChangeItem && !hasEquivalentFileChangeItem(nextItems, artifacts.fileChangeItem)) {
2872
+ nextItems = nextItems === items ? [...items] : nextItems;
2873
+ nextItems.push(artifacts.fileChangeItem);
2874
+ }
2875
+ for (const imageViewItem of artifacts.imageViewItems || []) {
2876
+ if (hasEquivalentImageViewItem(nextItems, imageViewItem)) {
2877
+ continue;
2878
+ }
2879
+ nextItems = nextItems === items ? [...items] : nextItems;
2880
+ nextItems.push(imageViewItem);
2881
+ }
2882
+ if (artifacts.progressPlanItem && !hasEquivalentProgressPlanItem(nextItems, artifacts.progressPlanItem)) {
2883
+ nextItems = nextItems === items ? [...items] : nextItems;
2884
+ nextItems.push(artifacts.progressPlanItem);
2885
+ }
2886
+
2887
+ if (nextItems === items) {
2888
+ return turn;
2889
+ }
2890
+
2891
+ didAugment = true;
2892
+ return {
2893
+ ...turn,
2894
+ items: nextItems,
2895
+ };
2896
+ });
2897
+
2898
+ return { turns: didAugment ? augmentedTurns : turns, didAugment };
2899
+ }
2900
+
2901
+ function readJsonlArtifactItemsByTurnId(threadId) {
2902
+ const emptyArtifactsByTurnId = new Map();
2903
+ const normalizedThreadId = normalizeNonEmptyString(threadId);
2904
+ if (!normalizedThreadId) {
2905
+ return emptyArtifactsByTurnId;
2906
+ }
2907
+
2908
+ const sessionsRoot = resolveSessionsRoot();
2909
+ const cacheKey = buildJsonlArtifactItemsCacheKey(sessionsRoot, normalizedThreadId);
2910
+ const cachedArtifacts = readCachedJsonlArtifactItems(cacheKey, normalizedThreadId);
2911
+ if (cachedArtifacts) {
2912
+ return cachedArtifacts;
2913
+ }
2914
+
2915
+ try {
2916
+ const rolloutPath = findRecentRolloutFileForContextRead(sessionsRoot, { threadId: normalizedThreadId });
2917
+ if (!rolloutPath) {
2918
+ jsonlArtifactItemsCacheByThread.delete(cacheKey);
2919
+ return emptyArtifactsByTurnId;
2920
+ }
2921
+
2922
+ return readAndCacheJsonlArtifactItems(cacheKey, rolloutPath, normalizedThreadId);
2923
+ } catch (error) {
2924
+ jsonlArtifactItemsCacheByThread.delete(cacheKey);
2925
+ console.warn(`[remodex] history jsonl artifact augmentation failed for ${normalizedThreadId}: ${error.message}`);
2926
+ }
2927
+
2928
+ return emptyArtifactsByTurnId;
2929
+ }
2930
+
2931
+ function buildJsonlArtifactItemsCacheKey(sessionsRoot, threadId) {
2932
+ return `${sessionsRoot}\0${threadId}`;
2933
+ }
2934
+
2935
+ function readCachedJsonlArtifactItems(cacheKey, threadId) {
2936
+ const cached = jsonlArtifactItemsCacheByThread.get(cacheKey);
2937
+ if (!cached) {
2938
+ return null;
2939
+ }
2940
+
2941
+ const stat = statJsonlArtifactRollout(cached.rolloutPath);
2942
+ if (!stat) {
2943
+ jsonlArtifactItemsCacheByThread.delete(cacheKey);
2944
+ return null;
2945
+ }
2946
+
2947
+ if (stat.mtimeMs !== cached.mtimeMs || stat.size !== cached.size) {
2948
+ try {
2949
+ return readAndCacheJsonlArtifactItems(cacheKey, cached.rolloutPath, threadId, stat);
2950
+ } catch (error) {
2951
+ jsonlArtifactItemsCacheByThread.delete(cacheKey);
2952
+ console.warn(`[remodex] history jsonl artifact cache refresh failed for ${threadId}: ${error.message}`);
2953
+ return null;
2954
+ }
2955
+ }
2956
+
2957
+ const now = Date.now();
2958
+ if (now - cached.checkedAt <= RELAY_JSONL_ARTIFACT_CACHE_TTL_MS) {
2959
+ return cached.artifactsByTurnId;
2960
+ }
2961
+
2962
+ cached.checkedAt = now;
2963
+ return null;
2964
+ }
2965
+
2966
+ function readAndCacheJsonlArtifactItems(cacheKey, rolloutPath, threadId, stat = null) {
2967
+ const rolloutStat = stat || fs.statSync(rolloutPath);
2968
+ const artifactsByTurnId = new Map();
2969
+ try {
2970
+ const turns = parseSessionJsonlTurns(fs.readFileSync(rolloutPath, "utf8"), { threadId });
2971
+ for (const turn of turns) {
2972
+ const turnId = normalizeNonEmptyString(turn?.id);
2973
+ const turnItems = Array.isArray(turn?.items) ? turn.items : [];
2974
+ if (!turnId || turnItems.length === 0) {
2975
+ continue;
2976
+ }
2977
+
2978
+ const fileChanges = turnItems.filter((item) => normalizeHistoryItemToken(item?.type) === "filechange");
2979
+ const progressPlan = turnItems.find((item) => (
2980
+ normalizeHistoryItemToken(item?.type) === "plan"
2981
+ && item?.remodexJsonlProgressPlan === true
2982
+ ));
2983
+ const artifacts = {
2984
+ fileChangeItem: null,
2985
+ imageViewItems: [],
2986
+ progressPlanItem: null,
2987
+ };
2988
+
2989
+ const changes = [];
2990
+ for (const item of fileChanges) {
2991
+ if (Array.isArray(item.changes)) {
2992
+ changes.push(...item.changes);
2993
+ }
2994
+ }
2995
+ if (changes.length > 0) {
2996
+ artifacts.fileChangeItem = {
2997
+ id: `remodex-jsonl-file-change-${turnId}`,
2998
+ type: "fileChange",
2999
+ status: "completed",
3000
+ changes,
3001
+ remodexJsonlFileChangeAggregate: true,
3002
+ };
3003
+ }
3004
+ if (progressPlan) {
3005
+ artifacts.progressPlanItem = {
3006
+ ...progressPlan,
3007
+ id: normalizeNonEmptyString(progressPlan.id) || `remodex-jsonl-progress-plan-${turnId}`,
3008
+ };
3009
+ }
3010
+ artifacts.imageViewItems = turnItems
3011
+ .filter((item) => normalizeHistoryItemToken(item?.type) === "imageview")
3012
+ .map((item, index) => ({
3013
+ ...item,
3014
+ id: normalizeNonEmptyString(item.id) || `remodex-jsonl-image-view-${turnId}-${index + 1}`,
3015
+ }));
3016
+
3017
+ if (artifacts.fileChangeItem || artifacts.progressPlanItem || artifacts.imageViewItems.length > 0) {
3018
+ artifactsByTurnId.set(turnId, artifacts);
3019
+ }
3020
+ }
3021
+ } catch (error) {
3022
+ jsonlArtifactItemsCacheByThread.delete(cacheKey);
3023
+ throw error;
3024
+ }
3025
+
3026
+ rememberJsonlArtifactItemsCache(cacheKey, {
3027
+ rolloutPath,
3028
+ mtimeMs: rolloutStat.mtimeMs,
3029
+ size: rolloutStat.size,
3030
+ checkedAt: Date.now(),
3031
+ artifactsByTurnId,
3032
+ });
3033
+ return artifactsByTurnId;
3034
+ }
3035
+
3036
+ function statJsonlArtifactRollout(rolloutPath) {
3037
+ try {
3038
+ return fs.statSync(rolloutPath);
3039
+ } catch {
3040
+ return null;
3041
+ }
3042
+ }
3043
+
3044
+ function rememberJsonlArtifactItemsCache(cacheKey, entry) {
3045
+ jsonlArtifactItemsCacheByThread.set(cacheKey, entry);
3046
+ while (jsonlArtifactItemsCacheByThread.size > RELAY_JSONL_ARTIFACT_CACHE_MAX_ENTRIES) {
3047
+ const oldestKey = jsonlArtifactItemsCacheByThread.keys().next().value;
3048
+ if (oldestKey == null) {
3049
+ break;
3050
+ }
3051
+ jsonlArtifactItemsCacheByThread.delete(oldestKey);
3052
+ }
3053
+ }
3054
+
3055
+ function hasEquivalentFileChangeItem(items, incomingItem) {
3056
+ const incomingId = normalizeNonEmptyString(incomingItem?.id);
3057
+ const incomingPaths = fileChangePathSet(incomingItem);
3058
+ return items.some((item) => {
3059
+ if (normalizeHistoryItemToken(item?.type) !== "filechange") {
3060
+ return false;
3061
+ }
3062
+ if (incomingId && normalizeNonEmptyString(item.id) === incomingId) {
3063
+ return true;
3064
+ }
3065
+ if (item.remodexJsonlFileChangeAggregate === true) {
3066
+ return true;
3067
+ }
3068
+
3069
+ const existingPaths = fileChangePathSet(item);
3070
+ if (incomingPaths.size === 0 || existingPaths.size === 0) {
3071
+ return false;
3072
+ }
3073
+ for (const pathKey of incomingPaths) {
3074
+ if (!existingPaths.has(pathKey)) {
3075
+ return false;
3076
+ }
3077
+ }
3078
+ return true;
3079
+ });
3080
+ }
3081
+
3082
+ function hasEquivalentProgressPlanItem(items, incomingItem) {
3083
+ const incomingId = normalizeNonEmptyString(incomingItem?.id);
3084
+ return items.some((item) => {
3085
+ if (normalizeHistoryItemToken(item?.type) !== "plan") {
3086
+ return false;
3087
+ }
3088
+ return item.remodexJsonlProgressPlan === true
3089
+ || (incomingId && normalizeNonEmptyString(item.id) === incomingId);
3090
+ });
3091
+ }
3092
+
3093
+ function hasEquivalentImageViewItem(items, incomingItem) {
3094
+ const incomingId = normalizeNonEmptyString(incomingItem?.id);
3095
+ const incomingPath = normalizeImageViewPathKey(incomingItem);
3096
+ return items.some((item) => {
3097
+ if (normalizeHistoryItemToken(item?.type) !== "imageview") {
3098
+ return false;
3099
+ }
3100
+ const itemId = normalizeNonEmptyString(item.id);
3101
+ if (incomingId && itemId === incomingId) {
3102
+ return true;
3103
+ }
3104
+ return incomingPath && normalizeImageViewPathKey(item) === incomingPath;
3105
+ });
3106
+ }
3107
+
3108
+ function normalizeImageViewPathKey(item) {
3109
+ return normalizeNonEmptyString(item?.path)
3110
+ || normalizeNonEmptyString(item?.saved_path)
3111
+ || normalizeNonEmptyString(item?.savedPath)
3112
+ || normalizeNonEmptyString(item?.file_path)
3113
+ || normalizeNonEmptyString(item?.filePath);
3114
+ }
3115
+
3116
+ function fileChangePathSet(item) {
3117
+ const paths = new Set();
3118
+ const changes = Array.isArray(item?.changes) ? item.changes : [];
3119
+ for (const change of changes) {
3120
+ const pathKey = normalizeFileChangePathKey(change?.path || change?.file || change?.filePath || change?.file_path);
3121
+ if (pathKey) {
3122
+ paths.add(pathKey);
3123
+ }
3124
+ }
3125
+ return paths;
3126
+ }
3127
+
3128
+ function normalizeFileChangePathKey(value) {
3129
+ return normalizeNonEmptyString(value).replace(/\\/g, "/").replace(/^\/+/, "").toLowerCase();
3130
+ }
3131
+
3132
+ function inferThreadIdFromTurns(turns) {
3133
+ if (!Array.isArray(turns)) {
3134
+ return "";
3135
+ }
3136
+ for (const turn of turns) {
3137
+ const threadId = normalizeNonEmptyString(turn?.threadId)
3138
+ || normalizeNonEmptyString(turn?.thread_id)
3139
+ || normalizeNonEmptyString(turn?.thread?.id)
3140
+ || normalizeNonEmptyString(turn?.thread?.threadId)
3141
+ || normalizeNonEmptyString(turn?.thread?.thread_id);
3142
+ if (threadId) {
3143
+ return threadId;
3144
+ }
3145
+ }
3146
+ return "";
2503
3147
  }
2504
3148
 
2505
3149
  function sanitizeRelayHistoryTurns(turns, threadId = "") {
@@ -2530,7 +3174,12 @@ function sanitizeRelayHistoryTurn(turn, threadId = "") {
2530
3174
  }
2531
3175
 
2532
3176
  let itemDidChange = false;
2533
- let sanitizedItem = annotateImageGenerationHistoryItem(item, turnThreadId);
3177
+ let sanitizedItem = convertApplyPatchHistoryItem(item) || item;
3178
+ if (sanitizedItem !== item) {
3179
+ itemDidChange = true;
3180
+ }
3181
+
3182
+ sanitizedItem = annotateImageGenerationHistoryItem(sanitizedItem, turnThreadId);
2534
3183
  if (sanitizedItem !== item) {
2535
3184
  itemDidChange = true;
2536
3185
  }
@@ -2573,6 +3222,26 @@ function sanitizeRelayHistoryTurn(turn, threadId = "") {
2573
3222
  : turn;
2574
3223
  }
2575
3224
 
3225
+ function convertApplyPatchHistoryItem(item) {
3226
+ const itemType = normalizeHistoryItemToken(item?.type);
3227
+ const toolName = normalizeNonEmptyString(item?.name);
3228
+ if (toolName !== "apply_patch" || itemType !== "customtoolcall") {
3229
+ return null;
3230
+ }
3231
+
3232
+ const fileChangeItem = buildApplyPatchFileChangeItem({
3233
+ callId: normalizeNonEmptyString(item.call_id) || normalizeNonEmptyString(item.callId) || normalizeNonEmptyString(item.id),
3234
+ patch: normalizeNonEmptyString(item.input),
3235
+ status: normalizeNonEmptyString(item.status) || "completed",
3236
+ idFallback: normalizeNonEmptyString(item.id) || "history-apply-patch-file-change",
3237
+ });
3238
+ return fileChangeItem ? { ...item, ...fileChangeItem } : null;
3239
+ }
3240
+
3241
+ function normalizeHistoryItemToken(value) {
3242
+ return normalizeNonEmptyString(value).toLowerCase().replace(/[\s_-]+/g, "");
3243
+ }
3244
+
2576
3245
  // Annotates live image-generation notifications so the phone can render a local-file
2577
3246
  // preview and does not receive the bulky inline base64 result over the relay.
2578
3247
  function sanitizeLiveGeneratedImageMessageForRelay(rawMessage) {
@@ -3149,6 +3818,12 @@ function compactHistoryItemForRelay(item, maxChars) {
3149
3818
  type: typeof item?.type === "string" ? item.type : "relay_truncated_item",
3150
3819
  role: typeof item?.role === "string" ? item.role : undefined,
3151
3820
  itemId: typeof item?.itemId === "string" ? item.itemId : undefined,
3821
+ turnId: typeof item?.turnId === "string" ? item.turnId : undefined,
3822
+ turn_id: typeof item?.turn_id === "string" ? item.turn_id : undefined,
3823
+ createdAt: relayScalarHistoryMetadata(item?.createdAt),
3824
+ created_at: relayScalarHistoryMetadata(item?.created_at),
3825
+ timestamp: relayScalarHistoryMetadata(item?.timestamp),
3826
+ time: relayScalarHistoryMetadata(item?.time),
3152
3827
  relayPayloadTruncated: true,
3153
3828
  };
3154
3829
  const tailText = maxChars > 0 ? firstRelayTextTail(item, maxChars) : "";
@@ -3161,6 +3836,10 @@ function compactHistoryItemForRelay(item, maxChars) {
3161
3836
  );
3162
3837
  }
3163
3838
 
3839
+ function relayScalarHistoryMetadata(value) {
3840
+ return typeof value === "string" || typeof value === "number" ? value : undefined;
3841
+ }
3842
+
3164
3843
  function firstRelayTextTail(value, maxChars) {
3165
3844
  if (!value || typeof value !== "object" || Array.isArray(value)) {
3166
3845
  return "";
@@ -3209,6 +3888,7 @@ function persistBridgePreferences(
3209
3888
  }
3210
3889
 
3211
3890
  module.exports = {
3891
+ buildThreadTurnsListRelaySanitizeContext,
3212
3892
  buildHeartbeatBridgeStatus,
3213
3893
  buildRelayCloseStatusError,
3214
3894
  buildRelayAccessTokenHeaders,
@@ -3220,6 +3900,7 @@ module.exports = {
3220
3900
  isTerminalRelayCloseCode,
3221
3901
  normalizeRelayBoundJsonRpcMessage,
3222
3902
  persistBridgePreferences,
3903
+ resolveJsonlTurnsListRolloutPathForFallback,
3223
3904
  sanitizeLiveGeneratedImageMessageForRelay,
3224
3905
  sanitizeThreadHistoryImagesForRelay,
3225
3906
  startBridge,