@makerbi/remodex 2.0.1 → 2.3.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,7 +5,8 @@
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 { createHash, randomBytes, randomUUID } = require("crypto");
8
+ const { constants: bufferConstants } = require("buffer");
9
+ const { createHash, randomBytes } = require("crypto");
9
10
  const { execFile, spawn } = require("child_process");
10
11
  const fs = require("fs");
11
12
  const path = require("path");
@@ -52,10 +53,19 @@ const {
52
53
  } = require("./secure-device-state");
53
54
  const { createBridgeSecureTransport } = require("./secure-transport");
54
55
  const { createRolloutLiveMirrorController } = require("./rollout-live-mirror");
56
+ const {
57
+ isContextualUserText,
58
+ isUserRoleItem,
59
+ readUserItemText,
60
+ sanitizeUserRoleItem,
61
+ visibleUserPromptText,
62
+ } = require("./desktop-ipc-shared");
55
63
  const {
56
64
  createDesktopIpcActionFollower,
57
65
  seedConversationStateFromThreadRead,
58
66
  } = require("./desktop-ipc-action-follower");
67
+ const { createDesktopIpcLiveOwner } = require("./desktop-ipc-live-owner");
68
+ const { createThreadRuntimeSettingsStore } = require("./thread-runtime-settings-store");
59
69
  const { version: bridgePackageVersion = "" } = require("../package.json");
60
70
  const {
61
71
  MINIMUM_SUPPORTED_IOS_APP_VERSION,
@@ -65,21 +75,29 @@ const {
65
75
  } = require("./ios-app-compatibility");
66
76
  const { createShortPairingCode, SHORT_PAIRING_CODE_LENGTH } = require("./qr");
67
77
  const {
68
- parseSessionJsonlMetadata,
78
+ JSONL_OLDER_HANDOFF_CURSOR,
69
79
  parseSessionJsonlTurns,
80
+ readRecentSessionJsonlTurns,
81
+ readSessionJsonlMetadataFromFile,
70
82
  readThreadTurnsListPageFromSessionJsonl,
71
83
  } = require("./session-jsonl-history");
72
84
  const { buildApplyPatchFileChangeItem } = require("./apply-patch-changes");
85
+ const {
86
+ createRuntimeProviderRouter,
87
+ stripRuntimeProviderFieldsForCodex,
88
+ } = require("./runtime-provider-router");
89
+ const { createProjectRegistry } = require("./project-registry");
73
90
 
74
91
  const execFileAsync = promisify(execFile);
75
92
  const RELAY_WATCHDOG_PING_INTERVAL_MS = 10_000;
76
- const CLOSE_CODE_INVALID_RELAY_REQUEST = 4000;
77
- const CLOSE_CODE_REPLACED_BY_NEW_MAC = 4001;
78
- const CLOSE_CODE_MAC_UNAUTHORIZED = 4005;
79
93
  const RELAY_HISTORY_IMAGE_REFERENCE_URL = "remodex://history-image-elided";
80
94
  const RELAY_THREAD_PAYLOAD_SOFT_LIMIT_BYTES = 4 * 1024 * 1024;
81
95
  const RELAY_HISTORY_TEXT_TAIL_LIMIT_CHARS = 24_000;
82
- const RELAY_HISTORY_RECENT_TURN_TARGET = 40;
96
+ // Recent-turn window used only when a thread/read payload already exceeds the
97
+ // relay soft budget: heavy threads first paint with this many newest turns and
98
+ // older history arrives via thread/turns/list pagination. Normal threads are
99
+ // never trimmed.
100
+ const RELAY_HISTORY_RECENT_TURN_TARGET = 16;
83
101
  const RELAY_TURNS_LIST_TARGET_BUDGET_MS = 5_500;
84
102
  const RELAY_TURNS_LIST_BUDGET_RESERVE_MS = 1_000;
85
103
  const RELAY_TURNS_LIST_MAX_INITIAL_LIMIT = 5;
@@ -87,7 +105,22 @@ const RELAY_TURNS_LIST_SAFE_RETRY_LIMIT = 5;
87
105
  const RELAY_JSONL_TURNS_LIST_CACHE_TTL_MS = 30_000;
88
106
  const RELAY_JSONL_ARTIFACT_CACHE_TTL_MS = 2_000;
89
107
  const RELAY_JSONL_ARTIFACT_CACHE_MAX_ENTRIES = 128;
90
- const BRIDGE_PACKAGE_UPDATE_COMMAND = "npm install -g @makerbi/remodex@latest";
108
+ // Session cwd is stable for a rollout file, but the same thread can later get a
109
+ // newer rollout with a different cwd; cache entries are validated against file identity.
110
+ const RELAY_JSONL_THREAD_CWD_CACHE_TTL_MS = 5 * 60_000;
111
+ const RELAY_JSONL_THREAD_EMPTY_CWD_CACHE_TTL_MS = 30_000;
112
+ const RELAY_JSONL_FAST_FIRST_PAGE_WAIT_MS = 1_500;
113
+ // The phone may be backgrounded between the provisional JSONL page and its
114
+ // canonical reconciliation. Keep the handoff long enough that a normal
115
+ // foreground/reconnect does not turn a coherent first page into a dead cursor.
116
+ const RELAY_JSONL_CANONICAL_HANDOFF_TTL_MS = 10 * 60_000;
117
+ const RELAY_JSONL_CANONICAL_HANDOFF_MAX_ENTRIES = 32;
118
+ const JSONL_CANONICAL_HANDOFF_CURSOR_PREFIX = "remodex-jsonl-handoff-v1:";
119
+ const RELAY_JSONL_FULL_ARTIFACT_FALLBACK_MAX_BYTES = Math.max(
120
+ 0,
121
+ bufferConstants.MAX_STRING_LENGTH - (8 * 1024 * 1024)
122
+ );
123
+ const BRIDGE_PACKAGE_UPDATE_COMMAND = "npm install -g remodex@latest";
91
124
  const BRIDGE_PACKAGE_UPDATE_TIMEOUT_MS = 180_000;
92
125
  const BRIDGE_RESTART_AFTER_UPDATE_DELAY_MS = 750;
93
126
  const MODELS_WITHOUT_REASONING_SUMMARY = new Set([
@@ -109,28 +142,17 @@ const RELAY_TURNS_LIST_PAGINATION_RESULT_KEYS = [
109
142
  "previousCursor",
110
143
  "previous_cursor",
111
144
  ];
145
+ const RELAY_TURNS_LIST_PREVIOUS_PAGINATION_RESULT_KEYS = new Set([
146
+ "prevCursor",
147
+ "prev_cursor",
148
+ "previousCursor",
149
+ "previous_cursor",
150
+ ]);
112
151
  const jsonlArtifactItemsCacheByThread = new Map();
152
+ const jsonlThreadCwdCacheByThread = new Map();
113
153
  const FORWARDED_REQUEST_METHODS_MAX_SIZE = 500;
114
154
  const JSONL_ROLLOUT_PATH_CACHE_MAX_SIZE = 200;
115
155
 
116
- function buildRelayUserAgentHeader({ version = bridgePackageVersion } = {}) {
117
- const normalizedVersion = typeof version === "string" && version.trim()
118
- ? version.trim().replace(/\s+/g, "-")
119
- : "dev";
120
- return `RemodexBridge/${normalizedVersion}`;
121
- }
122
-
123
- function buildRelayAccessTokenHeaders(config = {}, env = process.env) {
124
- const token = normalizeNonEmptyString(
125
- config.relayAccessToken
126
- || env.REMODEX_RELAY_ACCESS_TOKEN
127
- || env.PHODEX_RELAY_ACCESS_TOKEN
128
- );
129
- return token
130
- ? { "x-remodex-relay-token": token }
131
- : {};
132
- }
133
-
134
156
  function evictOldestEntries(map, maxSize) {
135
157
  if (map.size <= maxSize) {
136
158
  return;
@@ -143,6 +165,540 @@ function evictOldestEntries(map, maxSize) {
143
165
  }
144
166
  }
145
167
 
168
+ function createThreadTurnsListFastPageCoordinator({
169
+ waitMs = RELAY_JSONL_FAST_FIRST_PAGE_WAIT_MS,
170
+ handoffTTLms = RELAY_JSONL_CANONICAL_HANDOFF_TTL_MS,
171
+ maxHandoffs = RELAY_JSONL_CANONICAL_HANDOFF_MAX_ENTRIES,
172
+ payloadSoftLimitBytes = RELAY_THREAD_PAYLOAD_SOFT_LIMIT_BYTES,
173
+ sanitizeForRelay = sanitizeThreadHistoryImagesForRelay,
174
+ now = Date.now,
175
+ setTimeoutImpl = setTimeout,
176
+ clearTimeoutImpl = clearTimeout,
177
+ createToken = () => randomBytes(12).toString("hex"),
178
+ } = {}) {
179
+ const handoffsByToken = new Map();
180
+ const latestHandoffTokenByThread = new Map();
181
+ const canonicalFirstPageByKey = new Map();
182
+
183
+ function pruneHandoffs() {
184
+ const cutoff = now() - handoffTTLms;
185
+ for (const [token, entry] of handoffsByToken) {
186
+ if (entry.createdAt >= cutoff) {
187
+ continue;
188
+ }
189
+ handoffsByToken.delete(token);
190
+ if (latestHandoffTokenByThread.get(entry.threadId) === token) {
191
+ latestHandoffTokenByThread.delete(entry.threadId);
192
+ }
193
+ }
194
+ for (const [cacheKey, entry] of canonicalFirstPageByKey) {
195
+ if (entry.createdAt < cutoff) {
196
+ canonicalFirstPageByKey.delete(cacheKey);
197
+ }
198
+ }
199
+ while (handoffsByToken.size > maxHandoffs) {
200
+ const oldestToken = handoffsByToken.keys().next().value;
201
+ const oldest = handoffsByToken.get(oldestToken);
202
+ handoffsByToken.delete(oldestToken);
203
+ if (oldest && latestHandoffTokenByThread.get(oldest.threadId) === oldestToken) {
204
+ latestHandoffTokenByThread.delete(oldest.threadId);
205
+ }
206
+ }
207
+ }
208
+
209
+ function rememberHandoff(threadId, canonicalOutcomePromise, jsonlFallback) {
210
+ pruneHandoffs();
211
+ const token = createToken();
212
+ const entry = {
213
+ token,
214
+ threadId,
215
+ canonicalOutcomePromise,
216
+ hadNonEmptyJsonl: Boolean(jsonlFallback?.response),
217
+ anchorTurnId: firstTurnsListTurnId(jsonlFallback?.response),
218
+ createdAt: now(),
219
+ };
220
+ handoffsByToken.set(token, entry);
221
+ latestHandoffTokenByThread.set(threadId, token);
222
+ pruneHandoffs();
223
+ return token;
224
+ }
225
+
226
+ function consumeHandoff(entry) {
227
+ if (!entry?.token) {
228
+ return;
229
+ }
230
+ handoffsByToken.delete(entry.token);
231
+ if (latestHandoffTokenByThread.get(entry.threadId) === entry.token) {
232
+ latestHandoffTokenByThread.delete(entry.threadId);
233
+ }
234
+ }
235
+
236
+ function canonicalFirstPageOutcome(cacheKey, canonicalRequest, fetchCanonical) {
237
+ pruneHandoffs();
238
+ const existing = canonicalFirstPageByKey.get(cacheKey);
239
+ if (existing) {
240
+ return existing.canonicalOutcomePromise;
241
+ }
242
+ const canonicalOutcomePromise = settleThreadTurnsListCanonicalOutcome(
243
+ fetchCanonical(canonicalRequest)
244
+ );
245
+ canonicalFirstPageByKey.set(cacheKey, {
246
+ canonicalOutcomePromise,
247
+ createdAt: now(),
248
+ });
249
+ canonicalOutcomePromise.then(() => {
250
+ forgetCanonicalFirstPage(cacheKey, canonicalOutcomePromise);
251
+ });
252
+ return canonicalOutcomePromise;
253
+ }
254
+
255
+ function forgetCanonicalFirstPage(cacheKey, canonicalOutcomePromise) {
256
+ const existing = canonicalFirstPageByKey.get(cacheKey);
257
+ if (existing?.canonicalOutcomePromise === canonicalOutcomePromise) {
258
+ canonicalFirstPageByKey.delete(cacheKey);
259
+ }
260
+ }
261
+
262
+ function readHandoffEntry(request) {
263
+ pruneHandoffs();
264
+ const threadId = threadIdFromRequestParams(request?.params);
265
+ const cursor = request?.params?.cursor;
266
+ const token = threadTurnsListHandoffDescriptor(cursor)?.token
267
+ || latestHandoffTokenByThread.get(threadId)
268
+ || "";
269
+ const entry = token ? handoffsByToken.get(token) : null;
270
+ return entry?.threadId === threadId ? entry : null;
271
+ }
272
+
273
+ async function awaitCanonicalOutcome(canonicalOutcomePromise) {
274
+ const outcome = await canonicalOutcomePromise;
275
+ if (!outcome.ok) {
276
+ throw outcome.error;
277
+ }
278
+ return outcome.response;
279
+ }
280
+
281
+ async function extendCanonicalResponseThroughAnchor(
282
+ response,
283
+ canonicalRequest,
284
+ anchorTurnId,
285
+ fetchCanonical,
286
+ maxPages = 12
287
+ ) {
288
+ if (threadTurnsListResponseContainsAnchor(response, anchorTurnId)) {
289
+ return response;
290
+ }
291
+ const firstResult = response?.result;
292
+ const turnsKey = findTurnsListResultKey(firstResult);
293
+ if (!turnsKey) {
294
+ return null;
295
+ }
296
+
297
+ let lastResult = firstResult;
298
+ let combinedTurns = [...firstResult[turnsKey]];
299
+ let cursor = readTurnsListNextCursor(firstResult);
300
+ const seenCursors = new Set();
301
+ for (let pageIndex = 0; pageIndex < maxPages && hasRelayCursor(cursor); pageIndex += 1) {
302
+ const cursorKey = JSON.stringify(cursor);
303
+ if (seenCursors.has(cursorKey)) {
304
+ break;
305
+ }
306
+ seenCursors.add(cursorKey);
307
+ const nextRequest = {
308
+ ...canonicalRequest,
309
+ params: buildAdaptiveTurnsListPageParams(
310
+ canonicalRequest.params,
311
+ RELAY_TURNS_LIST_SAFE_RETRY_LIMIT,
312
+ cursor
313
+ ),
314
+ };
315
+ const nextResponse = await awaitCanonicalOutcome(
316
+ settleThreadTurnsListCanonicalOutcome(fetchCanonical(nextRequest))
317
+ );
318
+ const nextResult = nextResponse?.result;
319
+ const nextTurnsKey = findTurnsListResultKey(nextResult);
320
+ if (!nextTurnsKey) {
321
+ break;
322
+ }
323
+ for (const turn of nextResult[nextTurnsKey]) {
324
+ const turnId = turnListTurnIdentifier(turn);
325
+ if (!turnId || !combinedTurns.some((existing) => turnListTurnIdentifier(existing) === turnId)) {
326
+ combinedTurns.push(turn);
327
+ }
328
+ }
329
+ lastResult = nextResult;
330
+ const combinedResponse = buildSafeTurnsListResponse(
331
+ canonicalRequest.id,
332
+ firstResult,
333
+ lastResult,
334
+ turnsKey,
335
+ combinedTurns
336
+ );
337
+ if (threadTurnsListResponseContainsAnchor(combinedResponse, anchorTurnId)) {
338
+ // The cursor belongs after every turn through the anchor. Keep that
339
+ // complete boundary intact, compacting items if needed; never slice
340
+ // turns and accidentally make the omitted range unreachable.
341
+ return buildCompactedCompleteTurnsListResponse({
342
+ requestId: canonicalRequest.id,
343
+ firstResult,
344
+ lastResult,
345
+ turnsKey,
346
+ turns: combinedTurns,
347
+ sanitizeForRelay,
348
+ sanitizeContext: buildThreadTurnsListRelaySanitizeContext(canonicalRequest),
349
+ payloadSoftLimitBytes,
350
+ });
351
+ }
352
+ const nextCursor = readTurnsListNextCursor(nextResult);
353
+ if (nextResult[nextTurnsKey].length === 0 || !hasRelayCursor(nextCursor)) {
354
+ break;
355
+ }
356
+ cursor = nextCursor;
357
+ }
358
+ return null;
359
+ }
360
+
361
+ async function resolveCanonicalRequest(request, fetchCanonical, existingEntry = null, {
362
+ alignToHandoffAnchor = false,
363
+ validateHandoffAnchor = false,
364
+ handoffAnchorTurnId = "",
365
+ } = {}) {
366
+ const canonicalRequest = canonicalThreadTurnsListRequest(request);
367
+ let entry = existingEntry;
368
+ let response = null;
369
+ const anchorTurnId = entry?.anchorTurnId || handoffAnchorTurnId;
370
+ const canMatchCanonicalAnchor = anchorTurnId
371
+ && !isSyntheticJsonlHistoryTurnId(anchorTurnId);
372
+ if (entry) {
373
+ const observedOutcomePromise = entry.canonicalOutcomePromise;
374
+ const firstOutcome = await observedOutcomePromise;
375
+ const firstResponseIsUsable = firstOutcome.ok
376
+ && !isEmptyTurnsListResponse(firstOutcome.response);
377
+ if (firstResponseIsUsable) {
378
+ response = firstOutcome.response;
379
+ } else {
380
+ if (entry.canonicalOutcomePromise === observedOutcomePromise) {
381
+ entry.canonicalOutcomePromise = settleThreadTurnsListCanonicalOutcome(
382
+ fetchCanonical(canonicalRequest)
383
+ );
384
+ entry.createdAt = now();
385
+ }
386
+ }
387
+ }
388
+
389
+ response = response || await awaitCanonicalOutcome(
390
+ entry?.canonicalOutcomePromise
391
+ || settleThreadTurnsListCanonicalOutcome(fetchCanonical(canonicalRequest))
392
+ );
393
+ if (entry?.hadNonEmptyJsonl && isEmptyTurnsListResponse(response)) {
394
+ throw new Error("Canonical thread history was empty after a non-empty JSONL first page.");
395
+ }
396
+ if (validateHandoffAnchor
397
+ && canMatchCanonicalAnchor
398
+ && !threadTurnsListResponseContainsAnchor(response, anchorTurnId)) {
399
+ response = await extendCanonicalResponseThroughAnchor(
400
+ response,
401
+ canonicalRequest,
402
+ anchorTurnId,
403
+ fetchCanonical
404
+ );
405
+ if (!response) {
406
+ throw new Error("Canonical history does not contain the JSONL handoff anchor yet.");
407
+ }
408
+ }
409
+ const rebound = rebindThreadTurnsListResponseId(response, request.id);
410
+ if (!alignToHandoffAnchor || !canMatchCanonicalAnchor) {
411
+ return rebound;
412
+ }
413
+ const aligned = alignThreadTurnsListResponseToAnchor(rebound, anchorTurnId);
414
+ if (!aligned) {
415
+ throw new Error("Canonical history no longer contains the JSONL handoff anchor.");
416
+ }
417
+ return aligned;
418
+ }
419
+
420
+ async function resolve(request, { fetchCanonical, readJsonl }) {
421
+ const params = request?.params || {};
422
+ const cursor = params.cursor;
423
+ const handoffDescriptor = threadTurnsListHandoffDescriptor(cursor);
424
+ const isHandoffRequest = cursor === JSONL_OLDER_HANDOFF_CURSOR
425
+ || Boolean(handoffDescriptor);
426
+ const requiresCanonical = params.remodexRequireCanonical === true;
427
+ const hasOrdinaryCursor = hasRelayCursor(cursor) && !isHandoffRequest;
428
+
429
+ if (hasOrdinaryCursor) {
430
+ return {
431
+ source: "canonical",
432
+ response: await resolveCanonicalRequest(request, fetchCanonical),
433
+ usesJsonl: false,
434
+ };
435
+ }
436
+
437
+ if (isHandoffRequest || requiresCanonical) {
438
+ const handoffEntry = readHandoffEntry(request);
439
+ const response = await resolveCanonicalRequest(request, fetchCanonical, handoffEntry, {
440
+ alignToHandoffAnchor: isHandoffRequest,
441
+ validateHandoffAnchor: true,
442
+ handoffAnchorTurnId: handoffDescriptor?.anchorTurnId || "",
443
+ });
444
+ consumeHandoff(handoffEntry);
445
+ return {
446
+ source: "canonical",
447
+ response,
448
+ usesJsonl: false,
449
+ };
450
+ }
451
+
452
+ const canonicalRequest = canonicalThreadTurnsListRequest(request);
453
+ const threadId = threadIdFromRequestParams(params);
454
+ const canonicalFirstPageCacheKey = canonicalThreadTurnsListRequestShapeKey(canonicalRequest);
455
+ const canonicalOutcomePromise = canonicalFirstPageOutcome(
456
+ canonicalFirstPageCacheKey,
457
+ canonicalRequest,
458
+ fetchCanonical
459
+ );
460
+ let jsonlFallback = null;
461
+ try {
462
+ jsonlFallback = await readJsonl(request);
463
+ } catch {
464
+ jsonlFallback = null;
465
+ }
466
+ // A rollout tail is a useful emergency baseline only when it contains a
467
+ // whole turn package. Never let a bare tail (for example a file-change or
468
+ // final assistant fragment) win the race with canonical history: iOS would
469
+ // render it as a complete conversation and then merge the real opener in
470
+ // later, which is exactly how orphan cards and duplicate rows appeared.
471
+ if (jsonlFallback?.response && !isCoherentJsonlFirstPageResponse(jsonlFallback.response)) {
472
+ jsonlFallback = null;
473
+ }
474
+ if (!jsonlFallback?.response) {
475
+ const response = await awaitCanonicalOutcome(canonicalOutcomePromise);
476
+ forgetCanonicalFirstPage(canonicalFirstPageCacheKey, canonicalOutcomePromise);
477
+ return {
478
+ source: "canonical",
479
+ response: rebindThreadTurnsListResponseId(response, request.id),
480
+ usesJsonl: false,
481
+ };
482
+ }
483
+
484
+ let timeoutId = null;
485
+ const deadline = new Promise((resolveDeadline) => {
486
+ timeoutId = setTimeoutImpl(() => resolveDeadline({ deadline: true }), waitMs);
487
+ });
488
+ const first = await Promise.race([canonicalOutcomePromise, deadline]);
489
+ if (timeoutId != null) {
490
+ clearTimeoutImpl(timeoutId);
491
+ }
492
+
493
+ if (first?.ok && !isEmptyTurnsListResponse(first.response)) {
494
+ if (shouldPreferJsonlFirstPage(first.response, jsonlFallback.response)) {
495
+ const token = rememberHandoff(threadId, canonicalOutcomePromise, jsonlFallback);
496
+ return {
497
+ source: "jsonl",
498
+ response: buildJsonlCanonicalHandoffResponse(
499
+ jsonlFallback.response,
500
+ request.id,
501
+ token,
502
+ firstTurnsListTurnId(jsonlFallback.response)
503
+ ),
504
+ usesJsonl: true,
505
+ };
506
+ }
507
+ forgetCanonicalFirstPage(canonicalFirstPageCacheKey, canonicalOutcomePromise);
508
+ return {
509
+ source: "canonical",
510
+ response: rebindThreadTurnsListResponseId(first.response, request.id),
511
+ usesJsonl: false,
512
+ jsonlFallback,
513
+ };
514
+ }
515
+
516
+ const token = rememberHandoff(threadId, canonicalOutcomePromise, jsonlFallback);
517
+ return {
518
+ source: "jsonl",
519
+ response: buildJsonlCanonicalHandoffResponse(
520
+ jsonlFallback.response,
521
+ request.id,
522
+ token,
523
+ firstTurnsListTurnId(jsonlFallback.response)
524
+ ),
525
+ usesJsonl: true,
526
+ };
527
+ }
528
+
529
+ return { resolve };
530
+ }
531
+
532
+ function settleThreadTurnsListCanonicalOutcome(promise) {
533
+ return Promise.resolve(promise).then(
534
+ (response) => ({ ok: true, response }),
535
+ (error) => ({ ok: false, error })
536
+ );
537
+ }
538
+
539
+ function threadTurnsListHandoffDescriptor(cursor) {
540
+ if (typeof cursor !== "string" || !cursor.startsWith(JSONL_CANONICAL_HANDOFF_CURSOR_PREFIX)) {
541
+ return null;
542
+ }
543
+ const raw = cursor.slice(JSONL_CANONICAL_HANDOFF_CURSOR_PREFIX.length);
544
+ const separatorIndex = raw.lastIndexOf(":");
545
+ if (separatorIndex < 0) {
546
+ return raw ? { anchorTurnId: "", token: raw } : null;
547
+ }
548
+ const token = raw.slice(separatorIndex + 1);
549
+ if (!token) {
550
+ return null;
551
+ }
552
+ let anchorTurnId = "";
553
+ try {
554
+ anchorTurnId = decodeURIComponent(raw.slice(0, separatorIndex));
555
+ } catch {
556
+ return null;
557
+ }
558
+ return { anchorTurnId, token };
559
+ }
560
+
561
+ function canonicalThreadTurnsListRequest(request) {
562
+ const params = { ...(request?.params || {}) };
563
+ delete params.remodexRequireCanonical;
564
+ delete params.remodexTurnStateOnly;
565
+ if (params.cursor === JSONL_OLDER_HANDOFF_CURSOR || threadTurnsListHandoffDescriptor(params.cursor)) {
566
+ delete params.cursor;
567
+ }
568
+ return { ...request, params };
569
+ }
570
+
571
+ function canonicalThreadTurnsListRequestShapeKey(canonicalRequest) {
572
+ const params = canonicalRequest?.params || {};
573
+ return JSON.stringify(sortJsonValueForCacheKey({
574
+ threadId: threadIdFromRequestParams(params),
575
+ params,
576
+ }));
577
+ }
578
+
579
+ function sortJsonValueForCacheKey(value) {
580
+ if (Array.isArray(value)) {
581
+ return value.map(sortJsonValueForCacheKey);
582
+ }
583
+ if (!value || typeof value !== "object") {
584
+ return value;
585
+ }
586
+ return Object.fromEntries(
587
+ Object.keys(value)
588
+ .sort()
589
+ .map((key) => [key, sortJsonValueForCacheKey(value[key])])
590
+ );
591
+ }
592
+
593
+ function rebindThreadTurnsListResponseId(response, requestId) {
594
+ return response && typeof response === "object"
595
+ ? { ...response, id: requestId }
596
+ : response;
597
+ }
598
+
599
+ function buildJsonlCanonicalHandoffResponse(response, requestId, token, anchorTurnId = "") {
600
+ const result = response?.result;
601
+ if (!result || typeof result !== "object" || Array.isArray(result)) {
602
+ return response;
603
+ }
604
+ return {
605
+ ...response,
606
+ id: requestId,
607
+ result: {
608
+ ...result,
609
+ nextCursor: `${JSONL_CANONICAL_HANDOFF_CURSOR_PREFIX}${encodeURIComponent(anchorTurnId)}:${token}`,
610
+ remodexJsonlFallback: true,
611
+ remodexCanonicalHandoff: true,
612
+ },
613
+ };
614
+ }
615
+
616
+ function shouldPreferJsonlFirstPage(canonicalResponse, jsonlResponse) {
617
+ const canonicalResult = canonicalResponse?.result;
618
+ const jsonlResult = jsonlResponse?.result;
619
+ const canonicalTurnsKey = findTurnsListResultKey(canonicalResult);
620
+ const jsonlTurnsKey = findTurnsListResultKey(jsonlResult);
621
+ if (!canonicalTurnsKey || !jsonlTurnsKey) {
622
+ return false;
623
+ }
624
+ const jsonlTurn = jsonlResult[jsonlTurnsKey]?.[0];
625
+ const jsonlTurnId = turnListTurnIdentifier(jsonlTurn);
626
+ return Boolean(jsonlTurnId)
627
+ && !canonicalResult[canonicalTurnsKey].some((turn) => turnListTurnIdentifier(turn) === jsonlTurnId)
628
+ && shouldMergeLatestJsonlTurn(jsonlTurn);
629
+ }
630
+
631
+ function firstTurnsListTurnId(response) {
632
+ const result = response?.result;
633
+ const turnsKey = findTurnsListResultKey(result);
634
+ return turnsKey ? turnListTurnIdentifier(result[turnsKey]?.[0]) : "";
635
+ }
636
+
637
+ function isCoherentJsonlFirstPageResponse(response) {
638
+ const result = response?.result;
639
+ const turnsKey = findTurnsListResultKey(result);
640
+ const turns = turnsKey ? result[turnsKey] : null;
641
+ if (!Array.isArray(turns) || turns.length === 0) {
642
+ return false;
643
+ }
644
+ // A running turn must contain its materialized user opener. Otherwise an
645
+ // orphan file card or assistant tail can win the fast-page race and later
646
+ // be mistaken for a complete conversation. Explicit terminal turns are
647
+ // allowed without a user item because older compacted/system turns can be
648
+ // legitimately item-only.
649
+ const newestTurn = turns[0];
650
+ const items = Array.isArray(newestTurn?.items) ? newestTurn.items : null;
651
+ if (!items) {
652
+ return false;
653
+ }
654
+ if (items.length === 0) {
655
+ return false;
656
+ }
657
+ const status = String(newestTurn?.status || "").replace(/[_-]/g, "").toLowerCase();
658
+ const isExplicitTerminal = new Set(["completed", "failed", "aborted", "cancelled", "canceled", "interrupted"])
659
+ .has(status);
660
+ if (isExplicitTerminal) {
661
+ return true;
662
+ }
663
+ return items.some((item) => {
664
+ const role = String(item?.role || "").toLowerCase();
665
+ const type = String(item?.type || "").replace(/[_-]/g, "").toLowerCase();
666
+ return role === "user" || type === "usermessage";
667
+ });
668
+ }
669
+
670
+ function threadTurnsListResponseContainsAnchor(response, anchorTurnId) {
671
+ const result = response?.result;
672
+ const turnsKey = findTurnsListResultKey(result);
673
+ return Boolean(turnsKey) && result[turnsKey].some((turn) => (
674
+ turnListTurnIdentifier(turn) === anchorTurnId
675
+ ));
676
+ }
677
+
678
+ function alignThreadTurnsListResponseToAnchor(response, anchorTurnId) {
679
+ const result = response?.result;
680
+ const turnsKey = findTurnsListResultKey(result);
681
+ if (!turnsKey) {
682
+ return null;
683
+ }
684
+ const anchorIndex = result[turnsKey].findIndex((turn) => (
685
+ turnListTurnIdentifier(turn) === anchorTurnId
686
+ ));
687
+ if (anchorIndex < 0) {
688
+ return null;
689
+ }
690
+ if (anchorIndex === 0) {
691
+ return response;
692
+ }
693
+ return {
694
+ ...response,
695
+ result: {
696
+ ...result,
697
+ [turnsKey]: result[turnsKey].slice(anchorIndex),
698
+ },
699
+ };
700
+ }
701
+
146
702
  function startBridge({
147
703
  config: explicitConfig = null,
148
704
  printPairingQr = true,
@@ -181,6 +737,9 @@ function startBridge({
181
737
  const notificationSecret = randomBytes(24).toString("hex");
182
738
  const desktopRefresher = new CodexDesktopRefresher({
183
739
  enabled: config.refreshEnabled,
740
+ // With IPC live sync streaming content, deep-link refreshes are only needed
741
+ // to navigate Desktop onto the phone-driven thread, not to reload content.
742
+ navigationOnly: config.desktopIpcLiveSyncEnabled,
184
743
  debounceMs: config.refreshDebounceMs,
185
744
  refreshCommand: config.refreshCommand,
186
745
  bundleId: config.codexBundleId,
@@ -209,18 +768,17 @@ function startBridge({
209
768
  let relayWatchdogTimer = null;
210
769
  let lastRelayActivityAt = 0;
211
770
  let lastConnectionStatus = null;
212
- let lastConnectionError = "";
213
771
  let codexLaunchState = config.codexEndpoint ? "connected" : "starting";
214
772
  let codexHandshakeState = config.codexEndpoint ? "warm" : "cold";
215
773
  const forwardedInitializeRequestIds = new Set();
216
774
  const bridgeManagedCodexRequestWaiters = new Map();
217
775
  const forwardedRequestMethodsById = new Map();
218
776
  const relaySanitizedResponseMethodsById = new Map();
219
- const relayChannels = [];
220
- const codexResponseRoutesById = new Map();
221
- const extraRelaySessionCount = readExtraRelaySessionCount(process.env);
777
+ const desktopIpcLiveOwnerObservedInboundKeys = new Set();
222
778
  const jsonlTurnsListRolloutCacheByThread = new Map();
223
779
  const jsonlTurnsListRolloutMissCacheByThread = new Map();
780
+ const threadTurnsListFastPageCoordinator = createThreadTurnsListFastPageCoordinator();
781
+ const threadRuntimeSettingsStore = createThreadRuntimeSettingsStore();
224
782
  const trackedForwardedRequestMethods = new Set([
225
783
  "account/login/start",
226
784
  "account/login/cancel",
@@ -257,7 +815,6 @@ function startBridge({
257
815
  }
258
816
  },
259
817
  });
260
- let primaryRelayChannel = null;
261
818
  // Keeps one stable sender identity across reconnects so buffered replay state
262
819
  // reflects what actually made it onto the current relay socket.
263
820
  function sendRelayWireMessage(wireMessage) {
@@ -273,6 +830,14 @@ function startBridge({
273
830
  const rolloutLiveMirror = !config.codexEndpoint
274
831
  ? createRolloutLiveMirrorController({
275
832
  sendApplicationResponse,
833
+ // One live source per thread. The follower keeps fresh/idle Desktop state
834
+ // authoritative, but yields an active cache that stopped broadcasting;
835
+ // a later Desktop snapshot is announced as a new source epoch so the
836
+ // phone performs canonical repair instead of mixing both mirrors.
837
+ shouldSuppressThread: (threadId) => shouldSuppressRolloutMirrorForThread(
838
+ threadId,
839
+ { desktopIpcActionFollower, desktopIpcLiveOwner }
840
+ ),
276
841
  })
277
842
  : null;
278
843
  const desktopIpcActionFollower = !config.codexEndpoint
@@ -281,7 +846,29 @@ function startBridge({
281
846
  readConversationState: async (threadId) => seedConversationStateFromThreadRead(
282
847
  await sendCodexRequest("thread/read", { threadId })
283
848
  ),
849
+ forwardToLocalCodex: (rawMessage) => {
850
+ observeDesktopIpcLiveOwnerInbound(rawMessage);
851
+ forwardInboundRequestToCodex(rawMessage);
852
+ },
853
+ // Threads streamed by the bridge's own app-server must never be held,
854
+ // served from Desktop echoes, or routed over the IPC bus.
855
+ isLocallyOwnedThread: (threadId) => Boolean(desktopIpcLiveOwner?.isThreadOwned(threadId)),
856
+ normalizeTurnStartParams: normalizeTurnStartParamsForCodex,
857
+ runtimeSettingsStore: threadRuntimeSettingsStore,
284
858
  socketPath: config.desktopIpcSocketPath || undefined,
859
+ snapshotDebounceMs: config.desktopIpcSnapshotDebounceMs,
860
+ })
861
+ : null;
862
+ const desktopIpcLiveOwner = !config.codexEndpoint
863
+ ? createDesktopIpcLiveOwner({
864
+ enabled: config.desktopIpcLiveSyncEnabled !== false,
865
+ sendApplicationResponse,
866
+ sendCodexRequest,
867
+ sendRawCodexMessage: (rawMessage) => codex.send(rawMessage),
868
+ normalizeTurnStartParams: normalizeTurnStartParamsForCodex,
869
+ runtimeSettingsStore: threadRuntimeSettingsStore,
870
+ socketPath: config.desktopIpcSocketPath || undefined,
871
+ snapshotDebounceMs: config.desktopIpcSnapshotDebounceMs,
285
872
  })
286
873
  : null;
287
874
  let contextUsageWatcher = null;
@@ -293,6 +880,14 @@ function startBridge({
293
880
  appPath: config.codexAppPath,
294
881
  logPrefix: "[remodex]",
295
882
  });
883
+ const projectRegistry = createProjectRegistry();
884
+ const runtimeProviderRouter = createRuntimeProviderRouter({
885
+ sendApplicationResponse,
886
+ sendCodexRequest,
887
+ sendRuntimeMessage: sendRuntimeApplicationMessage,
888
+ projectRegistry,
889
+ logPrefix: "[remodex]",
890
+ });
296
891
  const voiceHandler = createVoiceHandler({
297
892
  sendCodexRequest,
298
893
  logPrefix: "[remodex]",
@@ -370,9 +965,11 @@ function startBridge({
370
965
  clearReconnectTimer();
371
966
  clearRelayWatchdog();
372
967
  bridgeStatusPublisher.stopHeartbeat();
968
+ runtimeProviderRouter.shutdown();
373
969
  stopContextUsageWatcher();
374
970
  rolloutLiveMirror?.stopAll();
375
971
  desktopIpcActionFollower?.stopAll();
972
+ desktopIpcLiveOwner?.stopAll();
376
973
  }
377
974
 
378
975
  function stopBridge() {
@@ -422,13 +1019,12 @@ function startBridge({
422
1019
  }
423
1020
 
424
1021
  // Keeps npm start output compact by emitting only high-signal connection states.
425
- function logConnectionStatus(status, lastError = "") {
426
- if (lastConnectionStatus === status && lastConnectionError === lastError) {
1022
+ function logConnectionStatus(status) {
1023
+ if (lastConnectionStatus === status) {
427
1024
  return;
428
1025
  }
429
1026
 
430
1027
  lastConnectionStatus = status;
431
- lastConnectionError = lastError;
432
1028
  if (status !== "connected") {
433
1029
  activePhoneSummary = null;
434
1030
  }
@@ -436,16 +1032,13 @@ function startBridge({
436
1032
  state: "running",
437
1033
  connectionStatus: status,
438
1034
  pid: process.pid,
439
- lastError,
1035
+ lastError: "",
440
1036
  });
441
1037
  console.log(`[remodex] ${status}`);
442
- if (lastError) {
443
- console.error(`[remodex] ${lastError}`);
444
- }
445
1038
  }
446
1039
 
447
1040
  // Retries the relay socket while preserving the active Codex process and session id.
448
- function scheduleRelayReconnect(closeCode, closeReason = "") {
1041
+ function scheduleRelayReconnect(closeCode) {
449
1042
  if (isShuttingDown) {
450
1043
  return;
451
1044
  }
@@ -485,10 +1078,8 @@ function startBridge({
485
1078
  },
486
1079
  // The relay uses this per-session secret to authenticate the first push registration.
487
1080
  headers: {
488
- "User-Agent": buildRelayUserAgentHeader(),
489
1081
  "x-role": "mac",
490
1082
  "x-notification-secret": notificationSecret,
491
- ...buildRelayAccessTokenHeaders(config),
492
1083
  ...buildMacRegistrationHeaders(deviceState, pairingSession),
493
1084
  },
494
1085
  });
@@ -514,7 +1105,7 @@ function startBridge({
514
1105
  }
515
1106
  },
516
1107
  onApplicationMessage(plaintextMessage) {
517
- handleApplicationMessage(plaintextMessage, primaryRelayChannel);
1108
+ handleApplicationMessage(plaintextMessage);
518
1109
  },
519
1110
  })) {
520
1111
  return;
@@ -529,12 +1120,11 @@ function startBridge({
529
1120
  markRelayActivity();
530
1121
  });
531
1122
 
532
- nextSocket.on("close", (code, reason) => {
533
- const closeReason = normalizeWebSocketCloseReason(reason);
1123
+ nextSocket.on("close", (code) => {
534
1124
  if (socket === nextSocket) {
535
1125
  clearRelayWatchdog();
536
1126
  }
537
- logConnectionStatus("disconnected", buildRelayCloseStatusError(code, closeReason));
1127
+ logConnectionStatus("disconnected");
538
1128
  if (socket === nextSocket) {
539
1129
  socket = null;
540
1130
  }
@@ -542,7 +1132,7 @@ function startBridge({
542
1132
  // Relay reconnects are transport-only: keep local live observers running
543
1133
  // so their output can enter secure replay and catch up on the next resume.
544
1134
  desktopRefresher.handleTransportReset();
545
- scheduleRelayReconnect(code, closeReason);
1135
+ scheduleRelayReconnect(code);
546
1136
  });
547
1137
 
548
1138
  nextSocket.on("error", () => {
@@ -553,191 +1143,45 @@ function startBridge({
553
1143
  });
554
1144
  }
555
1145
 
556
- // Optional draft path: one bridge process can expose extra one-mobile relay sessions without changing relay behavior.
557
- function startExtraRelayChannels() {
558
- for (let index = 1; index <= extraRelaySessionCount; index += 1) {
559
- startExtraRelayChannel(index);
560
- }
561
- }
562
-
563
- function startExtraRelayChannel(index) {
564
- const extraSessionId = randomUUID();
565
- const extraRelaySessionUrl = `${relayBaseUrl}/${extraSessionId}`;
566
- let reconnectTimerForChannel = null;
567
- let reconnectAttemptForChannel = 0;
568
- const extraSecureTransport = createBridgeSecureTransport({
569
- sessionId: extraSessionId,
570
- relayUrl: relayBaseUrl,
571
- deviceState,
572
- onTrustedPhoneUpdate(nextDeviceState) {
573
- deviceState = nextDeviceState;
574
- sendRelayRegistrationUpdate(nextDeviceState);
575
- sendExtraRelayRegistrationUpdate(extraRelayChannel, nextDeviceState);
576
- },
577
- });
578
- const extraPairingSession = {
579
- pairingPayload: extraSecureTransport.createPairingPayload(),
580
- pairingCode: createShortPairingCode({ length: SHORT_PAIRING_CODE_LENGTH }),
581
- };
582
- const extraRelayChannel = {
583
- label: `extra-${index}`,
584
- pairingSession: extraPairingSession,
585
- secureTransport: extraSecureTransport,
586
- socket: null,
587
- closing: false,
588
- sendWireMessage(wireMessage) {
589
- if (extraRelayChannel.socket?.readyState !== WebSocket.OPEN) {
590
- return false;
591
- }
592
-
593
- extraRelayChannel.socket.send(wireMessage);
594
- return true;
595
- },
596
- close() {
597
- extraRelayChannel.closing = true;
598
- if (reconnectTimerForChannel) {
599
- clearTimeout(reconnectTimerForChannel);
600
- reconnectTimerForChannel = null;
601
- }
602
- if (
603
- extraRelayChannel.socket?.readyState === WebSocket.OPEN
604
- || extraRelayChannel.socket?.readyState === WebSocket.CONNECTING
605
- ) {
606
- extraRelayChannel.socket.close();
607
- }
608
- },
609
- };
610
- relayChannels.push(extraRelayChannel);
611
-
612
- if (printPairingQr) {
613
- console.error(`[remodex] Pair device ${index + 1}: scan this QR from your other device.`);
614
- printQR(extraPairingSession);
615
- }
616
-
617
- connectExtraRelay();
618
-
619
- function scheduleExtraReconnect() {
620
- if (isShuttingDown || extraRelayChannel.closing || reconnectTimerForChannel) {
621
- return;
622
- }
623
-
624
- reconnectAttemptForChannel += 1;
625
- const delayMs = Math.min(1_000 * reconnectAttemptForChannel, 5_000);
626
- reconnectTimerForChannel = setTimeout(() => {
627
- reconnectTimerForChannel = null;
628
- connectExtraRelay();
629
- }, delayMs);
630
- }
631
-
632
- function connectExtraRelay() {
633
- if (isShuttingDown || extraRelayChannel.closing) {
634
- return;
635
- }
636
-
637
- const nextSocket = new WebSocket(extraRelaySessionUrl, {
638
- headers: {
639
- "User-Agent": buildRelayUserAgentHeader(),
640
- "x-role": "mac",
641
- "x-notification-secret": notificationSecret,
642
- ...buildRelayAccessTokenHeaders(config),
643
- ...buildMacRegistrationHeaders(deviceState, extraPairingSession),
644
- },
645
- });
646
- extraRelayChannel.socket = nextSocket;
647
-
648
- nextSocket.on("open", () => {
649
- reconnectAttemptForChannel = 0;
650
- extraSecureTransport.bindLiveSendWireMessage(extraRelayChannel.sendWireMessage);
651
- sendExtraRelayRegistrationUpdate(extraRelayChannel, deviceState);
652
- });
653
-
654
- nextSocket.on("message", (data) => {
655
- const message = typeof data === "string" ? data : data.toString("utf8");
656
- extraSecureTransport.handleIncomingWireMessage(message, {
657
- sendControlMessage(controlMessage) {
658
- if (nextSocket.readyState === WebSocket.OPEN) {
659
- nextSocket.send(JSON.stringify(controlMessage));
660
- }
661
- },
662
- onApplicationMessage(plaintextMessage) {
663
- handleApplicationMessage(plaintextMessage, extraRelayChannel);
664
- },
665
- });
666
- });
667
-
668
- nextSocket.on("close", () => {
669
- if (extraRelayChannel.socket === nextSocket) {
670
- extraRelayChannel.socket = null;
671
- }
672
- scheduleExtraReconnect();
673
- });
674
-
675
- nextSocket.on("error", () => {});
676
- }
677
- }
678
-
679
- function closeExtraRelayChannels() {
680
- for (const relayChannel of relayChannels) {
681
- if (relayChannel !== primaryRelayChannel && typeof relayChannel.close === "function") {
682
- relayChannel.close();
683
- }
684
- }
685
- }
686
-
687
- function sendExtraRelayRegistrationUpdate(relayChannel, nextDeviceState) {
688
- if (!relayChannel?.socket || relayChannel.socket.readyState !== WebSocket.OPEN) {
689
- return;
690
- }
691
-
692
- relayChannel.socket.send(JSON.stringify({
693
- kind: "relayMacRegistration",
694
- registration: buildMacRegistration(nextDeviceState, relayChannel.pairingSession),
695
- }));
696
- }
697
-
698
1146
  const pairingPayload = secureTransport.createPairingPayload();
699
1147
  const pairingSession = {
700
1148
  pairingPayload,
701
1149
  pairingCode: createShortPairingCode({ length: SHORT_PAIRING_CODE_LENGTH }),
702
1150
  };
703
- primaryRelayChannel = {
704
- label: "primary",
705
- pairingSession,
706
- secureTransport,
707
- sendWireMessage: sendRelayWireMessage,
708
- };
709
- relayChannels.push(primaryRelayChannel);
710
1151
  onPairingSession?.(pairingSession);
711
1152
  if (printPairingQr) {
712
- if (extraRelaySessionCount > 0) {
713
- console.error("[remodex] Pair device 1: scan this QR from your first device.");
714
- }
715
1153
  printQR(pairingSession);
716
1154
  }
717
1155
  pushServiceClient.logUnavailable();
718
1156
  connectRelay();
719
- startExtraRelayChannels();
720
1157
 
721
1158
  codex.onMessage((message) => {
722
- if (handleBridgeManagedCodexResponse(message)) {
1159
+ // Streaming deltas make this the hottest path in the bridge: parse the
1160
+ // envelope once and share the read-only object with every observer.
1161
+ const parsedMessage = parseBridgeMessage(message);
1162
+ if (handleBridgeManagedCodexResponse(message, parsedMessage)) {
723
1163
  return;
724
1164
  }
725
- updatePendingAuthLoginFromCodexMessage(message);
726
- trackCodexHandshakeState(message);
727
- desktopRefresher.handleOutbound(message);
728
- pushNotificationTracker.handleOutbound(message);
729
- rememberThreadFromMessage("codex", message);
730
- sendCodexOutboundToMobile(message);
1165
+ updatePendingAuthLoginFromCodexMessage(message, parsedMessage);
1166
+ trackCodexHandshakeState(message, parsedMessage);
1167
+ desktopRefresher.handleOutbound(message, parsedMessage);
1168
+ desktopIpcLiveOwner?.observeOutbound(message, parsedMessage);
1169
+ pushNotificationTracker.handleOutbound(message, parsedMessage);
1170
+ rememberThreadFromMessage("codex", message, parsedMessage);
1171
+ secureTransport.queueOutboundApplicationMessage(
1172
+ sanitizeRelayBoundCodexMessage(message, parsedMessage),
1173
+ sendRelayWireMessage
1174
+ );
731
1175
  });
732
1176
 
733
1177
  codex.onClose(() => {
734
1178
  const wasShuttingDown = isShuttingDown;
735
1179
  clearRelayWatchdog();
736
1180
  bridgeStatusPublisher.stopHeartbeat();
1181
+ logConnectionStatus("disconnected");
737
1182
  const lastError = wasShuttingDown
738
1183
  ? ""
739
- : (lastConnectionError || "Codex transport closed unexpectedly.");
740
- logConnectionStatus("disconnected", lastError);
1184
+ : "Codex transport closed unexpectedly.";
741
1185
  publishBridgeStatus({
742
1186
  state: wasShuttingDown ? "stopped" : "error",
743
1187
  connectionStatus: "disconnected",
@@ -752,44 +1196,42 @@ function startBridge({
752
1196
  desktopRefresher.handleTransportReset();
753
1197
  failBridgeManagedCodexRequests(new Error("Codex transport closed before the bridge request completed."));
754
1198
  forwardedRequestMethodsById.clear();
755
- codexResponseRoutesById.clear();
756
1199
  if (socket?.readyState === WebSocket.OPEN || socket?.readyState === WebSocket.CONNECTING) {
757
1200
  socket.close();
758
1201
  }
759
- closeExtraRelayChannels();
760
1202
  });
761
1203
 
762
1204
  process.on("SIGINT", () => shutdown(codex, () => socket, prepareBridgeShutdown));
763
1205
  process.on("SIGTERM", () => shutdown(codex, () => socket, prepareBridgeShutdown));
764
1206
 
765
1207
  // Routes decrypted app payloads through the same bridge handlers as before.
766
- function handleApplicationMessage(rawMessage, relayChannel = primaryRelayChannel) {
767
- const sendResponse = (responseMessage) => sendApplicationResponseToChannel(responseMessage, relayChannel);
768
- if (handleBridgeManagedHandshakeMessage(rawMessage, sendResponse)) {
1208
+ function handleApplicationMessage(rawMessage) {
1209
+ const parsedMessage = parseBridgeMessage(rawMessage);
1210
+ if (handleBridgeManagedHandshakeMessage(rawMessage, sendApplicationResponse, parsedMessage)) {
769
1211
  return;
770
1212
  }
771
- if (handleBridgeManagedAccountRequest(rawMessage, sendResponse)) {
1213
+ if (handleBridgeManagedAccountRequest(rawMessage, sendApplicationResponse, parsedMessage)) {
772
1214
  return;
773
1215
  }
774
- if (voiceHandler.handleVoiceRequest(rawMessage, sendResponse)) {
1216
+ if (voiceHandler.handleVoiceRequest(rawMessage, sendApplicationResponse, parsedMessage)) {
775
1217
  return;
776
1218
  }
777
- if (handleThreadContextRequest(rawMessage, sendResponse)) {
1219
+ if (handleThreadContextRequest(rawMessage, sendApplicationResponse, parsedMessage)) {
778
1220
  return;
779
1221
  }
780
- if (handleWorkspaceRequest(rawMessage, sendResponse)) {
1222
+ if (handleWorkspaceRequest(rawMessage, sendApplicationResponse)) {
781
1223
  return;
782
1224
  }
783
- if (handleProjectRequest(rawMessage, sendResponse)) {
1225
+ if (handleProjectRequest(rawMessage, sendApplicationResponse, { projectRegistry })) {
784
1226
  return;
785
1227
  }
786
- if (handlePetRequest(rawMessage, sendResponse)) {
1228
+ if (handlePetRequest(rawMessage, sendApplicationResponse)) {
787
1229
  return;
788
1230
  }
789
- if (notificationsHandler.handleNotificationsRequest(rawMessage, sendResponse)) {
1231
+ if (notificationsHandler.handleNotificationsRequest(rawMessage, sendApplicationResponse)) {
790
1232
  return;
791
1233
  }
792
- if (handleDesktopRequest(rawMessage, sendResponse, {
1234
+ if (handleDesktopRequest(rawMessage, sendApplicationResponse, {
793
1235
  bundleId: config.codexBundleId,
794
1236
  appPath: config.codexAppPath,
795
1237
  readBridgePreferences,
@@ -797,200 +1239,102 @@ function startBridge({
797
1239
  updateBridgePackageAndRestart,
798
1240
  })) {
799
1241
  return;
800
- }
801
- if (handleGitRequest(rawMessage, sendResponse, {
802
- codexAppPath: config.codexAppPath,
803
- onThreadNameSet: sendThreadNameUpdatedNotification,
804
- })) {
805
- return;
806
- }
807
- desktopRefresher.handleInbound(rawMessage);
808
- rolloutLiveMirror?.observeInbound(rawMessage);
809
- if (desktopIpcActionFollower?.observeInbound(rawMessage)) {
810
- return;
811
- }
812
- if (handleBridgeManagedThreadTurnsListRequest(rawMessage, sendResponse)) {
813
- return;
814
- }
815
- const codexRequest = disableUnsupportedReasoningSummaryForTurnStart(rawMessage);
816
- const codexMessage = prepareCodexForwardMessage(codexRequest, relayChannel);
817
- rememberForwardedRequestMethod(codexMessage);
818
- rememberThreadFromMessage("phone", codexMessage);
819
- mirrorUserMessageToPeerDevices(rawMessage, relayChannel);
820
- codex.send(codexMessage);
821
- }
822
-
823
- // Encrypts bridge-generated responses instead of letting the relay see plaintext.
824
- function sendApplicationResponse(rawMessage) {
825
- sendApplicationResponseToChannels(rawMessage, relayChannels);
826
- }
827
-
828
- function sendApplicationResponseToChannel(rawMessage, relayChannel = primaryRelayChannel) {
829
- sendApplicationResponseToChannels(rawMessage, [relayChannel]);
830
- }
831
-
832
- function sendApplicationResponseToChannels(rawMessage, channels) {
833
- const normalizedChannels = channels.filter(Boolean);
834
- if (normalizedChannels.length === 0) {
1242
+ }
1243
+ if (handleGitRequest(rawMessage, sendApplicationResponse, {
1244
+ codexAppPath: config.codexAppPath,
1245
+ onThreadNameSet: sendThreadNameUpdatedNotification,
1246
+ })) {
835
1247
  return;
836
1248
  }
837
-
838
- const sanitizedMessage = sanitizeRelayBoundCodexMessage(rawMessage);
839
- for (const relayChannel of normalizedChannels) {
840
- queueSanitizedApplicationMessageToChannel(sanitizedMessage, relayChannel);
1249
+ desktopRefresher.handleInbound(rawMessage, parsedMessage);
1250
+ rolloutLiveMirror?.observeInbound(rawMessage, parsedMessage);
1251
+ // Track the request method BEFORE follower interception: responses the
1252
+ // follower serves from projected Desktop state must hit the same relay
1253
+ // sanitize/trim budget as app-server responses, or heavy threads ship as
1254
+ // one oversized frame and kill the phone's websocket (EMSGSIZE).
1255
+ rememberForwardedRequestMethod(rawMessage);
1256
+ if (desktopIpcActionFollower?.observeInbound(rawMessage, parsedMessage)) {
1257
+ return;
841
1258
  }
842
- }
843
-
844
- function queueSanitizedApplicationMessageToChannel(sanitizedMessage, relayChannel) {
845
- if (!relayChannel) {
1259
+ if (runtimeProviderRouter.handleApplicationMessage(rawMessage, {
1260
+ sendResponse: sendApplicationResponse,
1261
+ })) {
1262
+ return;
1263
+ }
1264
+ observeDesktopIpcLiveOwnerInbound(rawMessage, parsedMessage);
1265
+ if (handleBridgeManagedThreadTurnsListRequest(rawMessage, sendApplicationResponse)) {
846
1266
  return;
847
1267
  }
1268
+ forwardInboundRequestToCodex(rawMessage);
1269
+ }
848
1270
 
849
- relayChannel.secureTransport.queueOutboundApplicationMessage(
850
- sanitizedMessage,
851
- relayChannel.sendWireMessage
1271
+ function forwardInboundRequestToCodex(rawMessage) {
1272
+ const codexRequest = stripRuntimeProviderFieldsForCodex(
1273
+ normalizeTurnStartForCodex(rawMessage)
852
1274
  );
1275
+ rememberKnownProjectFromRequest("codex-request", codexRequest);
1276
+ rememberForwardedRequestMethod(rawMessage);
1277
+ rememberThreadFromMessage("phone", codexRequest);
1278
+ codex.send(codexRequest);
853
1279
  }
854
1280
 
855
- // Rewrites mobile request ids per relay channel so iPhone/iPad can use overlapping JSON-RPC ids safely.
856
- function prepareCodexForwardMessage(rawMessage, relayChannel = primaryRelayChannel) {
857
- const parsed = safeParseJSON(rawMessage);
858
- if (!parsed || parsed.id == null || !relayChannel?.label) {
859
- return rawMessage;
860
- }
861
-
862
- pruneExpiredCodexResponseRoutes();
863
- const originalId = parsed.id;
864
- const forwardedId = `mobile:${relayChannel.label}:${randomBytes(8).toString("hex")}`;
865
- if (parsed.method === "initialize") {
866
- forwardedInitializeRequestIds.delete(String(originalId));
867
- forwardedInitializeRequestIds.add(String(forwardedId));
1281
+ // Held Desktop-ownership probes can later fall back locally, so observe each
1282
+ // phone request at most once in the live owner even if it passes both paths.
1283
+ function observeDesktopIpcLiveOwnerInbound(rawMessage, parsedMessage = null) {
1284
+ if (!desktopIpcLiveOwner) {
1285
+ return;
868
1286
  }
869
- parsed.id = forwardedId;
870
- codexResponseRoutesById.set(String(forwardedId), {
871
- relayChannel,
872
- originalId,
873
- createdAt: Date.now(),
874
- });
875
- return JSON.stringify(parsed);
876
- }
877
-
878
- function sendCodexOutboundToMobile(rawMessage) {
879
- pruneExpiredCodexResponseRoutes();
880
- const parsed = safeParseJSON(rawMessage);
881
- const responseId = parsed?.id;
882
- if (responseId != null) {
883
- const route = codexResponseRoutesById.get(String(responseId));
884
- if (route) {
885
- codexResponseRoutesById.delete(String(responseId));
886
- const sanitizedMessage = sanitizeRelayBoundCodexMessage(rawMessage);
887
- const sanitizedParsed = safeParseJSON(sanitizedMessage);
888
- if (sanitizedParsed && typeof sanitizedParsed === "object") {
889
- sanitizedParsed.id = route.originalId;
890
- queueSanitizedApplicationMessageToChannel(JSON.stringify(sanitizedParsed), route.relayChannel);
891
- return;
892
- }
893
- queueSanitizedApplicationMessageToChannel(sanitizedMessage, route.relayChannel);
1287
+ const inboundKey = desktopIpcLiveOwnerInboundKey(rawMessage, parsedMessage);
1288
+ if (inboundKey) {
1289
+ if (desktopIpcLiveOwnerObservedInboundKeys.has(inboundKey)) {
894
1290
  return;
895
1291
  }
1292
+ desktopIpcLiveOwnerObservedInboundKeys.add(inboundKey);
1293
+ evictOldestEntries(desktopIpcLiveOwnerObservedInboundKeys, FORWARDED_REQUEST_METHODS_MAX_SIZE);
896
1294
  }
897
-
898
- sendApplicationResponse(rawMessage);
899
- }
900
-
901
- function pruneExpiredCodexResponseRoutes() {
902
- const cutoff = Date.now() - forwardedRequestMethodTTLms;
903
- for (const [requestId, route] of codexResponseRoutesById.entries()) {
904
- if (!route || route.createdAt < cutoff) {
905
- codexResponseRoutesById.delete(requestId);
906
- }
907
- }
1295
+ desktopIpcLiveOwner.observeInbound(rawMessage, parsedMessage);
908
1296
  }
909
1297
 
910
- // Keeps secondary devices' timelines ordered by echoing the user's prompt before assistant deltas arrive.
911
- function mirrorUserMessageToPeerDevices(rawMessage, originRelayChannel) {
912
- const mirrorNotification = buildPeerUserMessageNotification(rawMessage);
913
- if (!mirrorNotification) {
914
- return;
915
- }
916
-
917
- const peerChannels = relayChannels.filter((relayChannel) => relayChannel !== originRelayChannel);
918
- if (peerChannels.length === 0) {
919
- return;
1298
+ function desktopIpcLiveOwnerInboundKey(rawMessage, parsedMessage = null) {
1299
+ const parsed = parsedMessage ?? safeParseJSON(rawMessage);
1300
+ const method = typeof parsed?.method === "string" ? parsed.method : "";
1301
+ if (!method || parsed?.id == null) {
1302
+ return "";
920
1303
  }
921
-
922
- sendApplicationResponseToChannels(JSON.stringify(mirrorNotification), peerChannels);
1304
+ // extractThreadId only understands turn/thread start and completion params;
1305
+ // archive, steer, interrupt, and compact requests need the generic fields so
1306
+ // same-id requests for different threads never share a dedupe key.
1307
+ const threadId = extractThreadId(method, parsed.params)
1308
+ || readString(parsed?.params?.threadId)
1309
+ || readString(parsed?.params?.thread_id)
1310
+ || readString(parsed?.params?.conversationId)
1311
+ || readString(parsed?.params?.conversation_id)
1312
+ || "";
1313
+ return `${method}:${threadId}:${String(parsed.id)}`;
923
1314
  }
924
1315
 
925
- function buildPeerUserMessageNotification(rawMessage) {
926
- const parsed = safeParseJSON(rawMessage);
927
- const method = typeof parsed?.method === "string" ? parsed.method.trim() : "";
928
- if (method !== "turn/start" && method !== "turn/steer") {
929
- return null;
930
- }
931
-
932
- const params = parsed?.params && typeof parsed.params === "object" ? parsed.params : null;
933
- const threadId = readString(params?.threadId || params?.thread_id);
934
- const text = extractTextFromTurnPayload(params);
935
- if (!threadId || !text) {
1316
+ function parseBridgeMessage(rawMessage) {
1317
+ try {
1318
+ return JSON.parse(rawMessage);
1319
+ } catch {
936
1320
  return null;
937
1321
  }
938
-
939
- const turnId = readString(params?.turnId || params?.turn_id || params?.expectedTurnId || params?.expected_turn_id);
940
- return {
941
- method: "codex/event/user_message",
942
- params: {
943
- threadId,
944
- thread_id: threadId,
945
- turnId: turnId || undefined,
946
- turn_id: turnId || undefined,
947
- message: text,
948
- text,
949
- source: "peer-mobile",
950
- },
951
- };
952
1322
  }
953
1323
 
954
- function extractTextFromTurnPayload(params) {
955
- const directText = readString(params?.message || params?.text || params?.prompt);
956
- if (directText) {
957
- return directText;
958
- }
959
-
960
- return extractTextFromTurnInput(params?.input);
1324
+ // Encrypts bridge-generated responses instead of letting the relay see plaintext.
1325
+ function sendApplicationResponse(rawMessage) {
1326
+ secureTransport.queueOutboundApplicationMessage(
1327
+ sanitizeRelayBoundCodexMessage(rawMessage),
1328
+ sendRelayWireMessage
1329
+ );
961
1330
  }
962
1331
 
963
- function extractTextFromTurnInput(input) {
964
- if (typeof input === "string") {
965
- return readString(input);
966
- }
967
-
968
- if (input && typeof input === "object" && !Array.isArray(input)) {
969
- const directText = readString(input.text || input.message || input.content);
970
- if (directText) {
971
- return directText;
972
- }
973
- }
974
-
975
- const inputItems = Array.isArray(input)
976
- ? input
977
- : Array.isArray(input?.items)
978
- ? input.items
979
- : [];
980
- const textParts = [];
981
- for (const item of inputItems) {
982
- if (!item || typeof item !== "object") {
983
- continue;
984
- }
985
-
986
- const itemType = readString(item.type).toLowerCase();
987
- const itemText = readString(item.text || item.message || item.content);
988
- if ((itemType === "text" || itemType === "input_text" || itemType === "message") && itemText) {
989
- textParts.push(itemText);
990
- }
991
- }
992
-
993
- return readString(textParts.join("\n\n"));
1332
+ // Provider output keeps the same desktop refresh, push, and secure relay side effects as Codex output.
1333
+ function sendRuntimeApplicationMessage(provider, rawMessage) {
1334
+ desktopRefresher.handleOutbound(rawMessage);
1335
+ pushNotificationTracker.handleOutbound(rawMessage);
1336
+ rememberThreadFromMessage(provider, rawMessage);
1337
+ sendApplicationResponse(rawMessage);
994
1338
  }
995
1339
 
996
1340
  // Mirrors accepted local renames back to the phone using the existing push-event shape.
@@ -1020,25 +1364,41 @@ function startBridge({
1020
1364
 
1021
1365
  rememberThreadFromMessage("phone", rawMessage);
1022
1366
  (async () => {
1367
+ let didRespond = false;
1368
+ const respondOnce = (payload) => {
1369
+ if (didRespond) {
1370
+ return;
1371
+ }
1372
+ didRespond = true;
1373
+ sendResponse(payload);
1374
+ };
1023
1375
  try {
1024
- const response = await fetchAdaptiveThreadTurnsListForRelay(request, {
1025
- fetchPage: (params) => sendCodexRequest("thread/turns/list", params),
1376
+ const selection = await threadTurnsListFastPageCoordinator.resolve(request, {
1377
+ fetchCanonical: (canonicalRequest) => fetchAdaptiveThreadTurnsListForRelay(canonicalRequest, {
1378
+ fetchPage: (params) => sendCodexRequest("thread/turns/list", params),
1379
+ }),
1380
+ readJsonl: (jsonlRequest) => maybeBuildJsonlThreadTurnsListFallback(jsonlRequest, null),
1026
1381
  });
1027
- const jsonlFallback = maybeBuildJsonlThreadTurnsListFallback(request, response);
1028
- const responsePayload = jsonlFallback?.response ?? response;
1029
- const finalSanitizeContext = buildThreadTurnsListRelaySanitizeContext(request);
1030
- relaySanitizedResponseMethodsById.set(String(request.id), {
1031
- method: "thread/turns/list",
1032
- ...finalSanitizeContext,
1033
- createdAt: Date.now(),
1382
+ let responsePayload = selection.response;
1383
+ if (selection.source === "canonical" && selection.jsonlFallback?.response?.result) {
1384
+ responsePayload = maybeMergeLatestJsonlTurnIntoTurnsListResponse(
1385
+ request,
1386
+ selection.response,
1387
+ selection.jsonlFallback.response.result
1388
+ ) || selection.response;
1389
+ }
1390
+ sendBridgeManagedThreadTurnsListResponse(request, responsePayload, respondOnce, {
1391
+ skipJsonlArtifactAugmentation: selection.usesJsonl,
1034
1392
  });
1035
- sendResponse(sanitizeThreadHistoryImagesForRelay(
1036
- JSON.stringify(responsePayload),
1037
- "thread/turns/list",
1038
- finalSanitizeContext
1039
- ));
1040
1393
  } catch (error) {
1041
- sendResponse(createJsonRpcErrorResponse(
1394
+ const jsonlFallback = maybeBuildJsonlThreadTurnsListFallback(request, null);
1395
+ if (jsonlFallback?.response && isCoherentJsonlFirstPageResponse(jsonlFallback.response)) {
1396
+ sendBridgeManagedThreadTurnsListResponse(request, jsonlFallback.response, respondOnce, {
1397
+ skipJsonlArtifactAugmentation: true,
1398
+ });
1399
+ return;
1400
+ }
1401
+ respondOnce(createJsonRpcErrorResponse(
1042
1402
  request.id,
1043
1403
  error,
1044
1404
  "thread_turns_list_failed"
@@ -1049,16 +1409,34 @@ function startBridge({
1049
1409
  return true;
1050
1410
  }
1051
1411
 
1412
+ function sendBridgeManagedThreadTurnsListResponse(request, response, sendResponse, {
1413
+ skipJsonlArtifactAugmentation = false,
1414
+ } = {}) {
1415
+ const finalSanitizeContext = buildThreadTurnsListRelaySanitizeContext(request, {
1416
+ skipJsonlArtifactAugmentation,
1417
+ });
1418
+ relaySanitizedResponseMethodsById.set(String(request.id), {
1419
+ method: "thread/turns/list",
1420
+ ...finalSanitizeContext,
1421
+ createdAt: Date.now(),
1422
+ });
1423
+ sendResponse(sanitizeThreadHistoryImagesForRelay(
1424
+ JSON.stringify(response),
1425
+ "thread/turns/list",
1426
+ finalSanitizeContext
1427
+ ));
1428
+ }
1429
+
1052
1430
  function maybeBuildJsonlThreadTurnsListFallback(request, response) {
1053
1431
  const params = request?.params || {};
1054
1432
  const threadId = normalizeNonEmptyString(params.threadId)
1055
1433
  || normalizeNonEmptyString(params.thread_id);
1056
- if (!threadId || hasRelayCursor(params.cursor)) {
1434
+ if (!threadId || hasRelayCursor(params.cursor) || params.remodexRequireCanonical === true) {
1057
1435
  return null;
1058
1436
  }
1059
1437
 
1060
1438
  try {
1061
- const responseIsEmpty = isEmptyTurnsListResponse(response);
1439
+ const responseIsEmpty = response == null || isEmptyTurnsListResponse(response);
1062
1440
  const rolloutPath = resolveJsonlTurnsListRolloutPathForFallback({
1063
1441
  threadId,
1064
1442
  responseIsEmpty,
@@ -1069,10 +1447,20 @@ function startBridge({
1069
1447
  return null;
1070
1448
  }
1071
1449
 
1450
+ // A first page is the local baseline for a newly opened thread. Honor a
1451
+ // caller's larger request, but never manufacture the old one-turn tail:
1452
+ // it has no room to preserve surrounding history while canonical data is
1453
+ // still catching up.
1454
+ const requestedLimit = Number.isInteger(params.limit) && params.limit > 0
1455
+ ? params.limit
1456
+ : RELAY_TURNS_LIST_MAX_INITIAL_LIMIT;
1457
+ const firstPageLimit = params.cursor == null
1458
+ ? Math.max(requestedLimit, RELAY_TURNS_LIST_MAX_INITIAL_LIMIT)
1459
+ : requestedLimit;
1072
1460
  const result = readThreadTurnsListPageFromSessionJsonl(rolloutPath, {
1073
1461
  threadId,
1074
- limit: params.limit,
1075
- maxLimit: 1,
1462
+ limit: firstPageLimit,
1463
+ maxLimit: RELAY_TURNS_LIST_MAX_INITIAL_LIMIT,
1076
1464
  cursor: params.cursor,
1077
1465
  });
1078
1466
  const turnsKey = findTurnsListResultKey(result);
@@ -1081,7 +1469,7 @@ function startBridge({
1081
1469
  }
1082
1470
 
1083
1471
  if (!responseIsEmpty) {
1084
- const mergedResponse = maybeMergeLatestJsonlTurnIntoTurnsListResponse(request, response, result, params);
1472
+ const mergedResponse = maybeMergeLatestJsonlTurnIntoTurnsListResponse(request, response, result);
1085
1473
  return mergedResponse ? { response: mergedResponse, usesJsonl: true } : null;
1086
1474
  }
1087
1475
 
@@ -1147,11 +1535,9 @@ function startBridge({
1147
1535
 
1148
1536
  // Handles the bridge-owned auth status wrappers without exposing tokens to the phone.
1149
1537
  // This dispatcher stays synchronous so non-account messages can continue down the normal routing chain.
1150
- function handleBridgeManagedAccountRequest(rawMessage, sendResponse) {
1151
- let parsed = null;
1152
- try {
1153
- parsed = JSON.parse(rawMessage);
1154
- } catch {
1538
+ function handleBridgeManagedAccountRequest(rawMessage, sendResponse, parsedMessage = null) {
1539
+ const parsed = parsedMessage || parseBridgeMessage(rawMessage);
1540
+ if (!parsed) {
1155
1541
  return false;
1156
1542
  }
1157
1543
 
@@ -1306,16 +1692,30 @@ function startBridge({
1306
1692
  }
1307
1693
 
1308
1694
  // Replaces huge inline desktop-history images with lightweight references before relay encryption.
1309
- function sanitizeRelayBoundCodexMessage(rawMessage) {
1695
+ function sanitizeRelayBoundCodexMessage(rawMessage, parsedMessage = null) {
1310
1696
  pruneExpiredForwardedRequestMethods();
1311
- const normalizedMessage = normalizeRelayBoundJsonRpcMessage(rawMessage, {
1697
+ let normalizedMessage = normalizeRelayBoundJsonRpcMessage(rawMessage, {
1312
1698
  pendingRequestMethodsById: relaySanitizedResponseMethodsById,
1699
+ parsedMessage,
1313
1700
  });
1314
1701
  if (!normalizedMessage) {
1315
1702
  return null;
1316
1703
  }
1317
1704
 
1318
- const parsed = safeParseJSON(normalizedMessage);
1705
+ // Streaming deltas hit this path dozens of times per second; when the
1706
+ // envelope passed through normalization untouched, reuse the parse the
1707
+ // caller already paid for instead of re-parsing the same bytes.
1708
+ let parsed = normalizedMessage === rawMessage && parsedMessage
1709
+ ? parsedMessage
1710
+ : safeParseJSON(normalizedMessage);
1711
+ const sanitizedLiveMessage = sanitizeLiveUserNotification(parsed);
1712
+ if (!sanitizedLiveMessage) {
1713
+ return null;
1714
+ }
1715
+ if (sanitizedLiveMessage !== parsed) {
1716
+ parsed = sanitizedLiveMessage;
1717
+ normalizedMessage = JSON.stringify(parsed);
1718
+ }
1319
1719
  const responseId = parsed?.id;
1320
1720
  if (responseId == null) {
1321
1721
  return sanitizeLiveGeneratedImageMessageForRelay(normalizedMessage);
@@ -1327,12 +1727,19 @@ function startBridge({
1327
1727
  }
1328
1728
  relaySanitizedResponseMethodsById.delete(String(responseId));
1329
1729
 
1730
+ if (trackedRequest.method === "thread/list"
1731
+ || trackedRequest.method === "thread/read"
1732
+ || trackedRequest.method === "thread/resume") {
1733
+ threadRuntimeSettingsStore.enrichResponse(trackedRequest.method, parsed);
1734
+ normalizedMessage = JSON.stringify(parsed);
1735
+ }
1736
+
1330
1737
  return sanitizeThreadHistoryImagesForRelay(normalizedMessage, trackedRequest.method, trackedRequest);
1331
1738
  }
1332
1739
 
1333
- function updatePendingAuthLoginFromCodexMessage(rawMessage) {
1740
+ function updatePendingAuthLoginFromCodexMessage(rawMessage, parsedMessage = null) {
1334
1741
  pruneExpiredForwardedRequestMethods();
1335
- const parsed = safeParseJSON(rawMessage);
1742
+ const parsed = parsedMessage ?? safeParseJSON(rawMessage);
1336
1743
  const responseId = parsed?.id;
1337
1744
  if (responseId != null) {
1338
1745
  const trackedRequest = forwardedRequestMethodsById.get(String(responseId));
@@ -1405,6 +1812,7 @@ function startBridge({
1405
1812
  evictOldestEntries(jsonlArtifactItemsCacheByThread, RELAY_JSONL_ARTIFACT_CACHE_MAX_ENTRIES);
1406
1813
  evictOldestEntries(jsonlTurnsListRolloutCacheByThread, JSONL_ROLLOUT_PATH_CACHE_MAX_SIZE);
1407
1814
  evictOldestEntries(jsonlTurnsListRolloutMissCacheByThread, JSONL_ROLLOUT_PATH_CACHE_MAX_SIZE);
1815
+ evictOldestEntries(jsonlThreadCwdCacheByThread, JSONL_ROLLOUT_PATH_CACHE_MAX_SIZE);
1408
1816
  }
1409
1817
 
1410
1818
  function safeParseJSON(value) {
@@ -1415,8 +1823,8 @@ function startBridge({
1415
1823
  }
1416
1824
  }
1417
1825
 
1418
- function rememberThreadFromMessage(source, rawMessage) {
1419
- const context = extractBridgeMessageContext(rawMessage);
1826
+ function rememberThreadFromMessage(source, rawMessage, parsedMessage = null) {
1827
+ const context = extractBridgeMessageContext(rawMessage, parsedMessage);
1420
1828
  if (!context.threadId) {
1421
1829
  return;
1422
1830
  }
@@ -1427,6 +1835,33 @@ function startBridge({
1427
1835
  }
1428
1836
  }
1429
1837
 
1838
+ // Captures explicit cwd selections before Codex creates the first thread, so
1839
+ // provider-neutral pickers do not depend on a later provider-specific message.
1840
+ function rememberKnownProjectFromRequest(source, rawMessage) {
1841
+ const parsed = safeParseJSON(rawMessage);
1842
+ const method = typeof parsed?.method === "string" ? parsed.method.trim() : "";
1843
+ if (method !== "thread/start" && method !== "turn/start") {
1844
+ return;
1845
+ }
1846
+
1847
+ const params = parsed?.params || {};
1848
+ const cwd = normalizeNonEmptyString(
1849
+ params.cwd || params.current_working_directory || params.working_directory
1850
+ );
1851
+ if (!cwd) {
1852
+ return;
1853
+ }
1854
+
1855
+ try {
1856
+ projectRegistry.rememberProjectPath(cwd, {
1857
+ source,
1858
+ provider: "codex",
1859
+ });
1860
+ } catch {
1861
+ // Registry persistence is best-effort; thread creation must keep flowing.
1862
+ }
1863
+ }
1864
+
1430
1865
  // Mirrors CodexMonitor's persisted token_count fallback so the phone keeps
1431
1866
  // receiving context-window usage even when the runtime omits live thread usage.
1432
1867
  function ensureContextUsageWatcher({ threadId, turnId }) {
@@ -1493,11 +1928,9 @@ function startBridge({
1493
1928
  // The spawned/shared Codex app-server stays warm across phone reconnects.
1494
1929
  // When iPhone reconnects it sends initialize again, but forwarding that to the
1495
1930
  // already-initialized Codex transport only produces "Already initialized".
1496
- function handleBridgeManagedHandshakeMessage(rawMessage, sendResponse = sendApplicationResponse) {
1497
- let parsed = null;
1498
- try {
1499
- parsed = JSON.parse(rawMessage);
1500
- } catch {
1931
+ function handleBridgeManagedHandshakeMessage(rawMessage, sendResponse = sendApplicationResponse, parsedMessage = null) {
1932
+ const parsed = parsedMessage || parseBridgeMessage(rawMessage);
1933
+ if (!parsed) {
1501
1934
  return false;
1502
1935
  }
1503
1936
 
@@ -1602,11 +2035,9 @@ function startBridge({
1602
2035
  }
1603
2036
 
1604
2037
  // Learns whether the underlying Codex transport has already completed its own MCP handshake.
1605
- function trackCodexHandshakeState(rawMessage) {
1606
- let parsed = null;
1607
- try {
1608
- parsed = JSON.parse(rawMessage);
1609
- } catch {
2038
+ function trackCodexHandshakeState(rawMessage, parsedMessage = null) {
2039
+ const parsed = parsedMessage ?? safeParseJSON(rawMessage);
2040
+ if (!parsed) {
1610
2041
  return;
1611
2042
  }
1612
2043
 
@@ -1670,11 +2101,9 @@ function startBridge({
1670
2101
 
1671
2102
  // Intercepts responses for bridge-private requests so only user-visible app-server traffic
1672
2103
  // is forwarded back through secure transport.
1673
- function handleBridgeManagedCodexResponse(rawMessage) {
1674
- let parsed = null;
1675
- try {
1676
- parsed = JSON.parse(rawMessage);
1677
- } catch {
2104
+ function handleBridgeManagedCodexResponse(rawMessage, parsedMessage = null) {
2105
+ const parsed = parsedMessage ?? safeParseJSON(rawMessage);
2106
+ if (!parsed) {
1678
2107
  return false;
1679
2108
  }
1680
2109
 
@@ -1734,11 +2163,6 @@ function startBridge({
1734
2163
  function sendRelayRegistrationUpdate(nextDeviceState) {
1735
2164
  deviceState = nextDeviceState;
1736
2165
  if (socket?.readyState !== WebSocket.OPEN) {
1737
- for (const relayChannel of relayChannels) {
1738
- if (relayChannel !== primaryRelayChannel) {
1739
- sendExtraRelayRegistrationUpdate(relayChannel, nextDeviceState);
1740
- }
1741
- }
1742
2166
  return;
1743
2167
  }
1744
2168
 
@@ -1746,11 +2170,6 @@ function startBridge({
1746
2170
  kind: "relayMacRegistration",
1747
2171
  registration: buildMacRegistration(nextDeviceState, pairingSession),
1748
2172
  }));
1749
- for (const relayChannel of relayChannels) {
1750
- if (relayChannel !== primaryRelayChannel) {
1751
- sendExtraRelayRegistrationUpdate(relayChannel, nextDeviceState);
1752
- }
1753
- }
1754
2173
  }
1755
2174
 
1756
2175
  function readBridgePreferences() {
@@ -1832,6 +2251,9 @@ function startBridge({
1832
2251
  stdio: "ignore",
1833
2252
  env: process.env,
1834
2253
  });
2254
+ child.on?.("error", (error) => {
2255
+ console.warn(`[remodex] Failed to schedule the post-update bridge restart: ${error?.message || error}`);
2256
+ });
1835
2257
  child.unref?.();
1836
2258
  }, BRIDGE_RESTART_AFTER_UPDATE_DELAY_MS);
1837
2259
  restartTimer.unref?.();
@@ -1932,7 +2354,7 @@ function createMacOSBridgeWakeAssertion({
1932
2354
  };
1933
2355
  }
1934
2356
 
1935
- // Registers the canonical Mac identity; legacy relay headers can expose one trusted device for auto-resolve.
2357
+ // Registers the canonical Mac identity and the one trusted phone allowed for auto-resolve.
1936
2358
  function buildMacRegistrationHeaders(deviceState, pairingSession) {
1937
2359
  const registration = buildMacRegistration(deviceState, pairingSession);
1938
2360
  const headers = {
@@ -1950,20 +2372,6 @@ function buildMacRegistrationHeaders(deviceState, pairingSession) {
1950
2372
  return headers;
1951
2373
  }
1952
2374
 
1953
- function readExtraRelaySessionCount(env = process.env) {
1954
- const rawValue = readString(env.REMODEX_EXTRA_RELAY_SESSIONS || env.PHODEX_EXTRA_RELAY_SESSIONS);
1955
- if (!rawValue) {
1956
- return 0;
1957
- }
1958
-
1959
- const parsed = Number.parseInt(rawValue, 10);
1960
- if (!Number.isFinite(parsed) || parsed <= 0) {
1961
- return 0;
1962
- }
1963
-
1964
- return Math.min(parsed, 3);
1965
- }
1966
-
1967
2375
  function buildMacRegistration(deviceState, pairingSession) {
1968
2376
  const trustedPhoneEntry = Object.entries(deviceState?.trustedPhones || {})[0] || null;
1969
2377
  return {
@@ -2021,7 +2429,7 @@ function shortFingerprint(value) {
2021
2429
  return createHash("sha256").update(normalized).digest("hex").slice(0, 8);
2022
2430
  }
2023
2431
 
2024
- function shutdown(codex, getSocket, beforeExit = () => {}, { exitCode = 0 } = {}) {
2432
+ function shutdown(codex, getSocket, beforeExit = () => {}) {
2025
2433
  beforeExit();
2026
2434
 
2027
2435
  const socket = getSocket();
@@ -2031,41 +2439,7 @@ function shutdown(codex, getSocket, beforeExit = () => {}, { exitCode = 0 } = {}
2031
2439
 
2032
2440
  codex.shutdown();
2033
2441
 
2034
- setTimeout(() => process.exit(exitCode), 100);
2035
- }
2036
-
2037
- function isTerminalRelayCloseCode(closeCode) {
2038
- return closeCode === CLOSE_CODE_INVALID_RELAY_REQUEST
2039
- || closeCode === CLOSE_CODE_REPLACED_BY_NEW_MAC
2040
- || closeCode === CLOSE_CODE_MAC_UNAUTHORIZED;
2041
- }
2042
-
2043
- function normalizeWebSocketCloseReason(reason) {
2044
- if (typeof reason === "string") {
2045
- return reason.trim();
2046
- }
2047
-
2048
- if (Buffer.isBuffer(reason)) {
2049
- return reason.toString("utf8").trim();
2050
- }
2051
-
2052
- return "";
2053
- }
2054
-
2055
- function buildRelayCloseStatusError(closeCode, closeReason = "") {
2056
- if (!Number.isInteger(closeCode) || closeCode === 1000 || closeCode === 1005) {
2057
- return "";
2058
- }
2059
-
2060
- const normalizedReason = normalizeNonEmptyString(closeReason);
2061
- if (closeCode === CLOSE_CODE_MAC_UNAUTHORIZED) {
2062
- return normalizedReason
2063
- || "Relay authorization failed. Set REMODEX_RELAY_ACCESS_TOKEN or use a relay that does not require a Mac access token.";
2064
- }
2065
-
2066
- return normalizedReason
2067
- ? `Relay closed the connection (${closeCode}): ${normalizedReason}`
2068
- : `Relay closed the connection (${closeCode}).`;
2442
+ setTimeout(() => process.exit(0), 100);
2069
2443
  }
2070
2444
 
2071
2445
  // Forces app-server summary generation off for models whose Responses API calls
@@ -2097,17 +2471,86 @@ function disableUnsupportedReasoningSummaryForTurnStart(rawMessage) {
2097
2471
  });
2098
2472
  }
2099
2473
 
2474
+ function normalizeTurnStartParamsForCodex(params) {
2475
+ const normalizedRawMessage = normalizeTurnStartForCodex(JSON.stringify({
2476
+ method: "turn/start",
2477
+ params,
2478
+ }));
2479
+ const parsed = parseBridgeJSON(normalizedRawMessage);
2480
+ return parsed?.params && typeof parsed.params === "object" && !Array.isArray(parsed.params)
2481
+ ? parsed.params
2482
+ : params;
2483
+ }
2484
+
2485
+ // A turn/start can carry the same runtime choice twice: in the legacy top-level
2486
+ // model/effort fields and in collaborationMode.settings. Codex treats the nested
2487
+ // collaboration settings as authoritative, so a stale Desktop value there can
2488
+ // silently override the model selected on the phone. Keep both representations
2489
+ // aligned before either direct app-server forwarding or Desktop-follower routing.
2490
+ function normalizeTurnStartForCodex(rawMessage) {
2491
+ const parsed = parseBridgeJSON(rawMessage);
2492
+ if (!parsed || parsed.method !== "turn/start") {
2493
+ return rawMessage;
2494
+ }
2495
+
2496
+ const params = parsed.params && typeof parsed.params === "object" && !Array.isArray(parsed.params)
2497
+ ? parsed.params
2498
+ : null;
2499
+ if (!params) {
2500
+ return rawMessage;
2501
+ }
2502
+
2503
+ const model = normalizeNonEmptyString(params.model);
2504
+ const effort = normalizeNonEmptyString(params.effort);
2505
+ let changed = false;
2506
+ let nextParams = params;
2507
+
2508
+ for (const collaborationKey of ["collaborationMode", "collaboration_mode"]) {
2509
+ const collaborationMode = nextParams[collaborationKey];
2510
+ const settings = collaborationMode?.settings;
2511
+ if (!settings || typeof settings !== "object" || Array.isArray(settings)) {
2512
+ continue;
2513
+ }
2514
+
2515
+ const nextSettings = { ...settings };
2516
+ let settingsChanged = false;
2517
+ if (model && normalizeNonEmptyString(settings.model) !== model) {
2518
+ nextSettings.model = model;
2519
+ settingsChanged = true;
2520
+ }
2521
+ if (effort && normalizeNonEmptyString(settings.reasoning_effort) !== effort) {
2522
+ nextSettings.reasoning_effort = effort;
2523
+ settingsChanged = true;
2524
+ }
2525
+ if (!settingsChanged) {
2526
+ continue;
2527
+ }
2528
+
2529
+ nextParams = {
2530
+ ...nextParams,
2531
+ [collaborationKey]: {
2532
+ ...collaborationMode,
2533
+ settings: nextSettings,
2534
+ },
2535
+ };
2536
+ changed = true;
2537
+ }
2538
+
2539
+ const alignedRawMessage = changed
2540
+ ? JSON.stringify({ ...parsed, params: nextParams })
2541
+ : rawMessage;
2542
+ return disableUnsupportedReasoningSummaryForTurnStart(alignedRawMessage);
2543
+ }
2544
+
2100
2545
  function readTurnStartModel(params) {
2101
2546
  return normalizeNonEmptyString(params?.model).toLowerCase()
2102
2547
  || normalizeNonEmptyString(params?.collaborationMode?.settings?.model).toLowerCase()
2103
2548
  || normalizeNonEmptyString(params?.collaboration_mode?.settings?.model).toLowerCase();
2104
2549
  }
2105
2550
 
2106
- function extractBridgeMessageContext(rawMessage) {
2107
- let parsed = null;
2108
- try {
2109
- parsed = JSON.parse(rawMessage);
2110
- } catch {
2551
+ function extractBridgeMessageContext(rawMessage, parsedMessage = null) {
2552
+ const parsed = parsedMessage ?? parseBridgeJSON(rawMessage);
2553
+ if (!parsed) {
2111
2554
  return { method: "", threadId: null, turnId: null };
2112
2555
  }
2113
2556
 
@@ -2269,6 +2712,7 @@ async function fetchAdaptiveThreadTurnsListForRelay(request, {
2269
2712
  const remaining = requestedLimit - combinedTurns.length;
2270
2713
  const pageLimit = selectAdaptiveTurnsListBatchLimit(combinedTurns.length, remaining);
2271
2714
  const pageParams = buildAdaptiveTurnsListPageParams(params, pageLimit, nextCursor);
2715
+ const responseBeforePage = response;
2272
2716
  let page;
2273
2717
 
2274
2718
  try {
@@ -2314,7 +2758,31 @@ async function fetchAdaptiveThreadTurnsListForRelay(request, {
2314
2758
  response = buildSafeTurnsListResponse(request.id, firstResult, lastResult, turnsKey, combinedTurns);
2315
2759
 
2316
2760
  if (measureSanitizedTurnsListResponseBytes(response, sanitizeForRelay, sanitizeContext) >= payloadSoftLimitBytes) {
2317
- response = buildLargestSafeTurnsListResponse({
2761
+ if (responseBeforePage) {
2762
+ // The server cursor belongs after the entire oversized batch. Return
2763
+ // the previous complete cursor boundary instead of slicing turns out
2764
+ // of this batch and making them unreachable.
2765
+ response = responseBeforePage;
2766
+ break;
2767
+ }
2768
+ if (pageTurns.length > pageLimit) {
2769
+ const completeResponse = buildCompactedCompleteTurnsListResponse({
2770
+ requestId: request.id,
2771
+ firstResult,
2772
+ lastResult,
2773
+ turnsKey,
2774
+ turns: pageTurns,
2775
+ sanitizeForRelay,
2776
+ sanitizeContext,
2777
+ payloadSoftLimitBytes,
2778
+ });
2779
+ if (!completeResponse) {
2780
+ throw new Error("thread/turns/list returned an oversized batch without a safe cursor boundary.");
2781
+ }
2782
+ response = completeResponse;
2783
+ break;
2784
+ }
2785
+ const boundedResponse = buildLargestSafeTurnsListResponse({
2318
2786
  requestId: request.id,
2319
2787
  firstResult,
2320
2788
  lastResult,
@@ -2324,7 +2792,11 @@ async function fetchAdaptiveThreadTurnsListForRelay(request, {
2324
2792
  sanitizeForRelay,
2325
2793
  sanitizeContext,
2326
2794
  payloadSoftLimitBytes,
2327
- }) ?? buildEmptyTurnsListResponse(request);
2795
+ });
2796
+ if (!boundedResponse) {
2797
+ throw new Error("The newest chat turn is too large to relay safely.");
2798
+ }
2799
+ response = boundedResponse;
2328
2800
  break;
2329
2801
  }
2330
2802
 
@@ -2347,22 +2819,10 @@ async function fetchAdaptiveThreadTurnsListForRelay(request, {
2347
2819
  }
2348
2820
  }
2349
2821
 
2350
- return response ?? {
2351
- id: request.id,
2352
- result: {
2353
- data: [],
2354
- },
2355
- };
2356
- }
2357
-
2358
- function buildEmptyTurnsListResponse(request) {
2359
- return {
2360
- id: request.id,
2361
- result: {
2362
- data: [],
2363
- nextCursor: null,
2364
- },
2365
- };
2822
+ if (!response) {
2823
+ throw new Error("thread/turns/list completed without a relayable page.");
2824
+ }
2825
+ return response;
2366
2826
  }
2367
2827
 
2368
2828
  function isEmptyTurnsListResponse(response) {
@@ -2391,7 +2851,7 @@ function resolveJsonlTurnsListRolloutPathForFallback({
2391
2851
  : findAndCachePath(threadId);
2392
2852
  }
2393
2853
 
2394
- function maybeMergeLatestJsonlTurnIntoTurnsListResponse(request, response, jsonlResult, params = {}) {
2854
+ function maybeMergeLatestJsonlTurnIntoTurnsListResponse(request, response, jsonlResult) {
2395
2855
  const responseResult = response?.result;
2396
2856
  const responseTurnsKey = findTurnsListResultKey(responseResult);
2397
2857
  const jsonlTurnsKey = findTurnsListResultKey(jsonlResult);
@@ -2410,16 +2870,17 @@ function maybeMergeLatestJsonlTurnIntoTurnsListResponse(request, response, jsonl
2410
2870
  return null;
2411
2871
  }
2412
2872
 
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);
2873
+ // Keep the canonical page intact. Slicing this back to the requested limit
2874
+ // can retain the newer JSONL turn while dropping the canonical cursor anchor,
2875
+ // making that canonical turn permanently unreachable.
2876
+ const mergedTurns = [jsonlTurn, ...responseTurns];
2417
2877
  return {
2418
2878
  id: request.id,
2419
2879
  result: {
2420
2880
  ...responseResult,
2421
2881
  [responseTurnsKey]: mergedTurns,
2422
2882
  remodexJsonlMergedLatest: true,
2883
+ remodexJsonlFallback: true,
2423
2884
  },
2424
2885
  };
2425
2886
  }
@@ -2446,6 +2907,10 @@ function turnListTurnIdentifier(turn) {
2446
2907
  || normalizeNonEmptyString(turn?.turn_id);
2447
2908
  }
2448
2909
 
2910
+ function isSyntheticJsonlHistoryTurnId(turnId) {
2911
+ return normalizeNonEmptyString(turnId).startsWith("turn-line-");
2912
+ }
2913
+
2449
2914
  async function fetchSafeThreadTurnsListFallback(request, {
2450
2915
  fetchPage,
2451
2916
  now,
@@ -2460,35 +2925,29 @@ async function fetchSafeThreadTurnsListFallback(request, {
2460
2925
  const safeLimit = Math.min(requestedLimit, RELAY_TURNS_LIST_SAFE_RETRY_LIMIT);
2461
2926
  const safeParams = buildAdaptiveTurnsListPageParams(params, safeLimit, params?.cursor);
2462
2927
 
2463
- try {
2464
- const page = await fetchMeasuredAdaptiveTurnsListPage(fetchPage, safeParams, now);
2465
- const pageResult = unwrapAppServerPayloadResult(page.result);
2466
- const turnsKey = findTurnsListResultKey(pageResult);
2467
- if (!turnsKey) {
2468
- return buildEmptyTurnsListResponse(request);
2469
- }
2470
-
2471
- // If the normal pagination path returns a bad first page, retry once with a small page.
2472
- // The retry response is intentionally minimal so Swift does not decode stale server metadata.
2473
- const response = buildLargestSafeTurnsListResponse({
2474
- requestId: request.id,
2475
- firstResult: pageResult,
2476
- lastResult: pageResult,
2477
- turnsKey,
2478
- turns: pageResult[turnsKey],
2479
- maxTurns: safeLimit,
2480
- sanitizeForRelay,
2481
- sanitizeContext,
2482
- payloadSoftLimitBytes,
2483
- });
2484
- if (response) {
2485
- return response;
2486
- }
2487
- } catch {
2488
- // Fall through to a valid empty page: the phone can keep the thread open instead of crashing.
2928
+ const page = await fetchMeasuredAdaptiveTurnsListPage(fetchPage, safeParams, now);
2929
+ const pageResult = unwrapAppServerPayloadResult(page.result);
2930
+ const turnsKey = findTurnsListResultKey(pageResult);
2931
+ if (!turnsKey) {
2932
+ throw new Error("thread/turns/list returned no turns array.");
2489
2933
  }
2490
2934
 
2491
- return buildEmptyTurnsListResponse(request);
2935
+ // If the normal pagination path returns a bad first page, retry once with a small page.
2936
+ // The retry response is intentionally minimal so Swift does not decode stale server metadata.
2937
+ const response = buildCompactedCompleteTurnsListResponse({
2938
+ requestId: request.id,
2939
+ firstResult: pageResult,
2940
+ lastResult: pageResult,
2941
+ turnsKey,
2942
+ turns: pageResult[turnsKey],
2943
+ sanitizeForRelay,
2944
+ sanitizeContext,
2945
+ payloadSoftLimitBytes,
2946
+ });
2947
+ if (response) {
2948
+ return response;
2949
+ }
2950
+ throw new Error("thread/turns/list returned a page that is too large to relay safely.");
2492
2951
  }
2493
2952
 
2494
2953
  async function fetchMeasuredAdaptiveTurnsListPage(fetchPage, params, now) {
@@ -2538,6 +2997,48 @@ function buildSafeTurnsListResponse(requestId, firstResult, lastResult, turnsKey
2538
2997
  };
2539
2998
  }
2540
2999
 
3000
+ function buildCompactedCompleteTurnsListResponse({
3001
+ requestId,
3002
+ firstResult,
3003
+ lastResult,
3004
+ turnsKey,
3005
+ turns,
3006
+ sanitizeForRelay,
3007
+ sanitizeContext = {},
3008
+ payloadSoftLimitBytes,
3009
+ }) {
3010
+ const response = buildSafeTurnsListResponse(
3011
+ requestId,
3012
+ firstResult,
3013
+ lastResult,
3014
+ turnsKey,
3015
+ turns
3016
+ );
3017
+ if (measureSanitizedTurnsListResponseBytes(response, sanitizeForRelay, sanitizeContext) < payloadSoftLimitBytes) {
3018
+ return response;
3019
+ }
3020
+
3021
+ for (const maxChars of [
3022
+ RELAY_HISTORY_TEXT_TAIL_LIMIT_CHARS,
3023
+ Math.floor(RELAY_HISTORY_TEXT_TAIL_LIMIT_CHARS / 4),
3024
+ 1_000,
3025
+ 0,
3026
+ ]) {
3027
+ const compacted = buildSafeTurnsListResponse(
3028
+ requestId,
3029
+ firstResult,
3030
+ lastResult,
3031
+ turnsKey,
3032
+ turns.map((turn) => compactTurnsListTurnForRelay(turn, maxChars))
3033
+ );
3034
+ compacted.result.remodexPageCompactedForRelay = true;
3035
+ if (measureSanitizedTurnsListResponseBytes(compacted, sanitizeForRelay, sanitizeContext) < payloadSoftLimitBytes) {
3036
+ return compacted;
3037
+ }
3038
+ }
3039
+ return null;
3040
+ }
3041
+
2541
3042
  // Trims oversized history pages progressively: normal page -> 5 turns -> ... -> 1 turn.
2542
3043
  function buildLargestSafeTurnsListResponse({
2543
3044
  requestId,
@@ -2639,19 +3140,47 @@ function compactEmergencySingleTurnForRelay(turn, maxChars, maxItems) {
2639
3140
  }
2640
3141
 
2641
3142
  const items = Array.isArray(turn.items) ? turn.items : [];
2642
- safeTurn.items = items.slice(-maxItems).map((item) => compactHistoryItemForRelay(item, maxChars));
3143
+ safeTurn.items = selectEmergencyHistoryItemsForRelay(items, maxItems)
3144
+ .map((item) => compactHistoryItemForRelay(item, maxChars));
2643
3145
  safeTurn.remodexEmergencySingleTurnForRelay = true;
2644
3146
  safeTurn.remodexPageCompactedForRelay = true;
2645
3147
  return safeTurn;
2646
3148
  }
2647
3149
 
3150
+ function selectEmergencyHistoryItemsForRelay(items, maxItems) {
3151
+ if (!Array.isArray(items) || items.length <= maxItems) {
3152
+ return Array.isArray(items) ? items : [];
3153
+ }
3154
+
3155
+ const selectedIndices = new Set();
3156
+ const firstUserIndex = items.findIndex((item) => isUserRoleItem(item));
3157
+ if (firstUserIndex >= 0) {
3158
+ selectedIndices.add(firstUserIndex);
3159
+ }
3160
+ for (let index = items.length - 1; index >= 0 && selectedIndices.size < maxItems; index -= 1) {
3161
+ const type = normalizeHistoryItemToken(items[index]?.type);
3162
+ if (type === "plan" || type === "filechange") {
3163
+ selectedIndices.add(index);
3164
+ }
3165
+ }
3166
+ for (let index = items.length - 1; index >= 0 && selectedIndices.size < maxItems; index -= 1) {
3167
+ selectedIndices.add(index);
3168
+ }
3169
+ return [...selectedIndices]
3170
+ .sort((left, right) => left - right)
3171
+ .map((index) => items[index]);
3172
+ }
3173
+
2648
3174
  function buildAdaptiveTurnsListResult(firstResult, lastResult, turnsKey, turns) {
2649
3175
  const result = {};
2650
3176
  result[turnsKey] = turns;
2651
3177
 
2652
3178
  for (const key of RELAY_TURNS_LIST_PAGINATION_RESULT_KEYS) {
2653
- if (Object.prototype.hasOwnProperty.call(lastResult, key)) {
2654
- result[key] = lastResult[key];
3179
+ const sourceResult = RELAY_TURNS_LIST_PREVIOUS_PAGINATION_RESULT_KEYS.has(key)
3180
+ ? firstResult
3181
+ : lastResult;
3182
+ if (Object.prototype.hasOwnProperty.call(sourceResult, key)) {
3183
+ result[key] = sourceResult[key];
2655
3184
  } else {
2656
3185
  delete result[key];
2657
3186
  }
@@ -2698,8 +3227,10 @@ function measureSanitizedTurnsListResponseBytes(response, sanitizeForRelay, requ
2698
3227
  // Keeps app-server responses in the JSON-RPC shape that the App Store iOS client decodes.
2699
3228
  function normalizeRelayBoundJsonRpcMessage(rawMessage, {
2700
3229
  pendingRequestMethodsById = null,
3230
+ // Optional pre-parsed envelope shared by the caller; treated as read-only.
3231
+ parsedMessage = null,
2701
3232
  } = {}) {
2702
- const parsed = parseBridgeJSON(rawMessage);
3233
+ const parsed = parsedMessage ?? parseBridgeJSON(rawMessage);
2703
3234
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
2704
3235
  return null;
2705
3236
  }
@@ -2818,11 +3349,32 @@ function sanitizeThreadHistoryImagesForRelay(rawMessage, requestMethod, requestC
2818
3349
  || normalizeNonEmptyString(thread.id)
2819
3350
  || normalizeNonEmptyString(thread.threadId)
2820
3351
  || normalizeNonEmptyString(thread.thread_id);
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);
2824
3352
 
2825
- if (!didSanitize && !didAugment && !didAugmentThreadMetadata) {
3353
+ // Oversized histories get their turn window trimmed before the per-turn sanitize
3354
+ // and augment passes so full-history work is not spent on turns the payload
3355
+ // budget discards anyway. The byte-budget trim below still enforces the cap.
3356
+ const didPreTrimTurnWindow = Buffer.byteLength(rawMessage, "utf8") > RELAY_THREAD_PAYLOAD_SOFT_LIMIT_BYTES
3357
+ && thread.turns.length > RELAY_HISTORY_RECENT_TURN_TARGET;
3358
+ const workingTurns = didPreTrimTurnWindow
3359
+ ? thread.turns.slice(-RELAY_HISTORY_RECENT_TURN_TARGET)
3360
+ : thread.turns;
3361
+ const workingThread = didPreTrimTurnWindow ? { ...thread, turns: workingTurns } : thread;
3362
+ const trimOptions = didPreTrimTurnWindow
3363
+ ? {
3364
+ preOmittedTurnCount: thread.turns.length - workingTurns.length,
3365
+ compactionIdSource: thread.turns[0],
3366
+ }
3367
+ : {};
3368
+
3369
+ const { turns: sanitizedTurns, didSanitize } = sanitizeRelayHistoryTurns(workingTurns, threadId);
3370
+ const { thread: threadWithJsonlMetadata, didAugment: didAugmentThreadMetadata } = augmentRelayThreadWithJsonlMetadata(workingThread, threadId);
3371
+ const { turns: augmentedTurns, didAugment } = augmentRelayHistoryTurnsWithJsonlArtifacts(
3372
+ sanitizedTurns,
3373
+ threadId,
3374
+ { includeHistoryItems: true }
3375
+ );
3376
+
3377
+ if (!didSanitize && !didAugment && !didAugmentThreadMetadata && !didPreTrimTurnWindow) {
2826
3378
  const trimmedPayload = trimThreadPayloadForRelay(parsed, thread);
2827
3379
  return trimmedPayload == null ? rawMessage : trimmedPayload;
2828
3380
  }
@@ -2838,7 +3390,7 @@ function sanitizeThreadHistoryImagesForRelay(rawMessage, requestMethod, requestC
2838
3390
  },
2839
3391
  });
2840
3392
 
2841
- return trimThreadPayloadForRelay(parseBridgeJSON(sanitizedPayload), null) ?? sanitizedPayload;
3393
+ return trimThreadPayloadForRelay(parseBridgeJSON(sanitizedPayload), null, trimOptions) ?? sanitizedPayload;
2842
3394
  }
2843
3395
 
2844
3396
  function sanitizeThreadTurnsListForRelay(rawMessage, requestContext = {}) {
@@ -2906,27 +3458,104 @@ function readJsonlThreadCwd(threadId) {
2906
3458
  return "";
2907
3459
  }
2908
3460
 
3461
+ const sessionsRoot = resolveSessionsRoot();
3462
+ const cacheKey = buildJsonlThreadCacheKey(sessionsRoot, normalizedThreadId);
3463
+
2909
3464
  try {
2910
- const rolloutPath = findRecentRolloutFileForContextRead(resolveSessionsRoot(), { threadId: normalizedThreadId });
3465
+ const rolloutPath = findRecentRolloutFileForContextRead(sessionsRoot, { threadId: normalizedThreadId });
2911
3466
  if (!rolloutPath) {
2912
3467
  return "";
2913
3468
  }
2914
3469
 
2915
- const metadata = parseSessionJsonlMetadata(fs.readFileSync(rolloutPath, "utf8"));
2916
- const cwd = normalizeNonEmptyString(metadata?.cwd);
2917
- return cwd && path.isAbsolute(cwd) ? cwd : "";
3470
+ const cached = readCachedJsonlThreadCwd(cacheKey, rolloutPath);
3471
+ if (cached) {
3472
+ return cached.cwd;
3473
+ }
3474
+
3475
+ return readAndCacheJsonlThreadCwd(cacheKey, rolloutPath);
2918
3476
  } catch {
2919
3477
  return "";
2920
3478
  }
2921
3479
  }
2922
3480
 
2923
- function augmentRelayHistoryTurnsWithJsonlArtifacts(turns, threadId = "") {
3481
+ function readCachedJsonlThreadCwd(cacheKey, rolloutPath) {
3482
+ const cached = jsonlThreadCwdCacheByThread.get(cacheKey);
3483
+ if (!cached || cached.rolloutPath !== rolloutPath) {
3484
+ return null;
3485
+ }
3486
+
3487
+ const stat = statJsonlRollout(rolloutPath);
3488
+ if (!stat) {
3489
+ jsonlThreadCwdCacheByThread.delete(cacheKey);
3490
+ return null;
3491
+ }
3492
+
3493
+ if (stat.mtimeMs !== cached.mtimeMs || stat.size !== cached.size) {
3494
+ return null;
3495
+ }
3496
+
3497
+ const ttl = cached.cwd ? RELAY_JSONL_THREAD_CWD_CACHE_TTL_MS : RELAY_JSONL_THREAD_EMPTY_CWD_CACHE_TTL_MS;
3498
+ if (Date.now() - cached.checkedAt > ttl) {
3499
+ return null;
3500
+ }
3501
+
3502
+ return { cwd: cached.cwd };
3503
+ }
3504
+
3505
+ function readAndCacheJsonlThreadCwd(cacheKey, rolloutPath, stat = null) {
3506
+ const rolloutStat = stat || statJsonlRollout(rolloutPath);
3507
+ if (!rolloutStat) {
3508
+ jsonlThreadCwdCacheByThread.delete(cacheKey);
3509
+ return "";
3510
+ }
3511
+
3512
+ let cwd = "";
3513
+ try {
3514
+ const metadata = readSessionJsonlMetadataFromFile(rolloutPath);
3515
+ const parsedCwd = normalizeNonEmptyString(metadata?.cwd);
3516
+ cwd = parsedCwd && path.isAbsolute(parsedCwd) ? parsedCwd : "";
3517
+ } catch {
3518
+ cwd = "";
3519
+ }
3520
+
3521
+ rememberJsonlThreadCwdCache(cacheKey, {
3522
+ rolloutPath,
3523
+ cwd,
3524
+ mtimeMs: rolloutStat.mtimeMs,
3525
+ size: rolloutStat.size,
3526
+ checkedAt: Date.now(),
3527
+ });
3528
+ return cwd;
3529
+ }
3530
+
3531
+ function rememberJsonlThreadCwdCache(cacheKey, entry) {
3532
+ jsonlThreadCwdCacheByThread.set(cacheKey, entry);
3533
+ while (jsonlThreadCwdCacheByThread.size > JSONL_ROLLOUT_PATH_CACHE_MAX_SIZE) {
3534
+ const oldestKey = jsonlThreadCwdCacheByThread.keys().next().value;
3535
+ if (oldestKey == null) {
3536
+ break;
3537
+ }
3538
+ jsonlThreadCwdCacheByThread.delete(oldestKey);
3539
+ }
3540
+ }
3541
+
3542
+ function augmentRelayHistoryTurnsWithJsonlArtifacts(turns, threadId = "", {
3543
+ includeHistoryItems = false,
3544
+ } = {}) {
2924
3545
  const normalizedThreadId = normalizeNonEmptyString(threadId);
2925
3546
  if (!normalizedThreadId || !Array.isArray(turns) || turns.length === 0) {
2926
3547
  return { turns, didAugment: false };
2927
3548
  }
2928
3549
 
2929
- const jsonlArtifactsByTurnId = readJsonlArtifactItemsByTurnId(normalizedThreadId);
3550
+ const requestedTurnIds = new Set(turns.map((turn) => (
3551
+ normalizeNonEmptyString(turn?.id)
3552
+ || normalizeNonEmptyString(turn?.turnId)
3553
+ || normalizeNonEmptyString(turn?.turn_id)
3554
+ )).filter(Boolean));
3555
+ const jsonlArtifactsByTurnId = readJsonlArtifactItemsByTurnId(
3556
+ normalizedThreadId,
3557
+ requestedTurnIds
3558
+ );
2930
3559
  if (jsonlArtifactsByTurnId.size === 0) {
2931
3560
  return { turns, didAugment: false };
2932
3561
  }
@@ -2942,22 +3571,17 @@ function augmentRelayHistoryTurnsWithJsonlArtifacts(turns, threadId = "") {
2942
3571
  }
2943
3572
 
2944
3573
  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;
3574
+ const merged = mergeRelayHistoryItemsWithJsonlItems(
3575
+ items,
3576
+ artifacts.timelineItems,
3577
+ normalizedThreadId,
3578
+ {
3579
+ includeJsonlItem: includeHistoryItems
3580
+ ? () => true
3581
+ : isJsonlHistoryArtifactItem,
2953
3582
  }
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
- }
3583
+ );
3584
+ const nextItems = merged.items;
2961
3585
 
2962
3586
  if (nextItems === items) {
2963
3587
  return turn;
@@ -2973,7 +3597,7 @@ function augmentRelayHistoryTurnsWithJsonlArtifacts(turns, threadId = "") {
2973
3597
  return { turns: didAugment ? augmentedTurns : turns, didAugment };
2974
3598
  }
2975
3599
 
2976
- function readJsonlArtifactItemsByTurnId(threadId) {
3600
+ function readJsonlArtifactItemsByTurnId(threadId, requestedTurnIds = new Set()) {
2977
3601
  const emptyArtifactsByTurnId = new Map();
2978
3602
  const normalizedThreadId = normalizeNonEmptyString(threadId);
2979
3603
  if (!normalizedThreadId) {
@@ -2982,7 +3606,11 @@ function readJsonlArtifactItemsByTurnId(threadId) {
2982
3606
 
2983
3607
  const sessionsRoot = resolveSessionsRoot();
2984
3608
  const cacheKey = buildJsonlArtifactItemsCacheKey(sessionsRoot, normalizedThreadId);
2985
- const cachedArtifacts = readCachedJsonlArtifactItems(cacheKey, normalizedThreadId);
3609
+ const cachedArtifacts = readCachedJsonlArtifactItems(
3610
+ cacheKey,
3611
+ normalizedThreadId,
3612
+ requestedTurnIds
3613
+ );
2986
3614
  if (cachedArtifacts) {
2987
3615
  return cachedArtifacts;
2988
3616
  }
@@ -2994,7 +3622,13 @@ function readJsonlArtifactItemsByTurnId(threadId) {
2994
3622
  return emptyArtifactsByTurnId;
2995
3623
  }
2996
3624
 
2997
- return readAndCacheJsonlArtifactItems(cacheKey, rolloutPath, normalizedThreadId);
3625
+ return readAndCacheJsonlArtifactItems(
3626
+ cacheKey,
3627
+ rolloutPath,
3628
+ normalizedThreadId,
3629
+ null,
3630
+ requestedTurnIds
3631
+ );
2998
3632
  } catch (error) {
2999
3633
  jsonlArtifactItemsCacheByThread.delete(cacheKey);
3000
3634
  console.warn(`[remodex] history jsonl artifact augmentation failed for ${normalizedThreadId}: ${error.message}`);
@@ -3004,16 +3638,20 @@ function readJsonlArtifactItemsByTurnId(threadId) {
3004
3638
  }
3005
3639
 
3006
3640
  function buildJsonlArtifactItemsCacheKey(sessionsRoot, threadId) {
3641
+ return buildJsonlThreadCacheKey(sessionsRoot, threadId);
3642
+ }
3643
+
3644
+ function buildJsonlThreadCacheKey(sessionsRoot, threadId) {
3007
3645
  return `${sessionsRoot}\0${threadId}`;
3008
3646
  }
3009
3647
 
3010
- function readCachedJsonlArtifactItems(cacheKey, threadId) {
3648
+ function readCachedJsonlArtifactItems(cacheKey, threadId, requestedTurnIds = new Set()) {
3011
3649
  const cached = jsonlArtifactItemsCacheByThread.get(cacheKey);
3012
3650
  if (!cached) {
3013
3651
  return null;
3014
3652
  }
3015
3653
 
3016
- const stat = statJsonlArtifactRollout(cached.rolloutPath);
3654
+ const stat = statJsonlRollout(cached.rolloutPath);
3017
3655
  if (!stat) {
3018
3656
  jsonlArtifactItemsCacheByThread.delete(cacheKey);
3019
3657
  return null;
@@ -3021,7 +3659,13 @@ function readCachedJsonlArtifactItems(cacheKey, threadId) {
3021
3659
 
3022
3660
  if (stat.mtimeMs !== cached.mtimeMs || stat.size !== cached.size) {
3023
3661
  try {
3024
- return readAndCacheJsonlArtifactItems(cacheKey, cached.rolloutPath, threadId, stat);
3662
+ return readAndCacheJsonlArtifactItems(
3663
+ cacheKey,
3664
+ cached.rolloutPath,
3665
+ threadId,
3666
+ stat,
3667
+ requestedTurnIds
3668
+ );
3025
3669
  } catch (error) {
3026
3670
  jsonlArtifactItemsCacheByThread.delete(cacheKey);
3027
3671
  console.warn(`[remodex] history jsonl artifact cache refresh failed for ${threadId}: ${error.message}`);
@@ -3030,6 +3674,11 @@ function readCachedJsonlArtifactItems(cacheKey, threadId) {
3030
3674
  }
3031
3675
 
3032
3676
  const now = Date.now();
3677
+ const coversRequestedTurns = cached.coversEntireRollout
3678
+ || [...requestedTurnIds].every((turnId) => cached.coveredTurnIds?.has(turnId));
3679
+ if (!coversRequestedTurns) {
3680
+ return null;
3681
+ }
3033
3682
  if (now - cached.checkedAt <= RELAY_JSONL_ARTIFACT_CACHE_TTL_MS) {
3034
3683
  return cached.artifactsByTurnId;
3035
3684
  }
@@ -3038,11 +3687,37 @@ function readCachedJsonlArtifactItems(cacheKey, threadId) {
3038
3687
  return null;
3039
3688
  }
3040
3689
 
3041
- function readAndCacheJsonlArtifactItems(cacheKey, rolloutPath, threadId, stat = null) {
3690
+ function readAndCacheJsonlArtifactItems(
3691
+ cacheKey,
3692
+ rolloutPath,
3693
+ threadId,
3694
+ stat = null,
3695
+ requestedTurnIds = new Set()
3696
+ ) {
3042
3697
  const rolloutStat = stat || fs.statSync(rolloutPath);
3043
3698
  const artifactsByTurnId = new Map();
3699
+ let coveredTurnIds = new Set();
3700
+ let coversEntireRollout = false;
3044
3701
  try {
3045
- const turns = parseSessionJsonlTurns(fs.readFileSync(rolloutPath, "utf8"), { threadId });
3702
+ const recent = readRecentSessionJsonlTurns(rolloutPath, {
3703
+ threadId,
3704
+ limit: RELAY_TURNS_LIST_SAFE_RETRY_LIMIT,
3705
+ });
3706
+ let turns = recent?.turns || [];
3707
+ coversEntireRollout = recent ? !recent.hasOlderTurns : false;
3708
+ coveredTurnIds = new Set(turns.map((turn) => normalizeNonEmptyString(turn?.id)).filter(Boolean));
3709
+ const missesRequestedTurn = [...requestedTurnIds].some((turnId) => !coveredTurnIds.has(turnId));
3710
+
3711
+ // Preserve the old exact artifact behavior for files V8 can safely decode,
3712
+ // but only pay that cost when an older cursor page actually asks for a turn
3713
+ // outside the fast tail. Multi-gigabyte files never enter this path.
3714
+ if (!coversEntireRollout
3715
+ && missesRequestedTurn
3716
+ && rolloutStat.size <= RELAY_JSONL_FULL_ARTIFACT_FALLBACK_MAX_BYTES) {
3717
+ turns = parseSessionJsonlTurns(fs.readFileSync(rolloutPath, "utf8"), { threadId });
3718
+ coversEntireRollout = true;
3719
+ coveredTurnIds = new Set(turns.map((turn) => normalizeNonEmptyString(turn?.id)).filter(Boolean));
3720
+ }
3046
3721
  for (const turn of turns) {
3047
3722
  const turnId = normalizeNonEmptyString(turn?.id);
3048
3723
  const turnItems = Array.isArray(turn?.items) ? turn.items : [];
@@ -3050,47 +3725,9 @@ function readAndCacheJsonlArtifactItems(cacheKey, rolloutPath, threadId, stat =
3050
3725
  continue;
3051
3726
  }
3052
3727
 
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);
3728
+ const timelineItems = buildOrderedJsonlTimelineItems(turnItems, turnId);
3729
+ if (timelineItems.length > 0) {
3730
+ artifactsByTurnId.set(turnId, { timelineItems });
3094
3731
  }
3095
3732
  }
3096
3733
  } catch (error) {
@@ -3104,11 +3741,13 @@ function readAndCacheJsonlArtifactItems(cacheKey, rolloutPath, threadId, stat =
3104
3741
  size: rolloutStat.size,
3105
3742
  checkedAt: Date.now(),
3106
3743
  artifactsByTurnId,
3744
+ coveredTurnIds,
3745
+ coversEntireRollout,
3107
3746
  });
3108
3747
  return artifactsByTurnId;
3109
3748
  }
3110
3749
 
3111
- function statJsonlArtifactRollout(rolloutPath) {
3750
+ function statJsonlRollout(rolloutPath) {
3112
3751
  try {
3113
3752
  return fs.statSync(rolloutPath);
3114
3753
  } catch {
@@ -3127,57 +3766,406 @@ function rememberJsonlArtifactItemsCache(cacheKey, entry) {
3127
3766
  }
3128
3767
  }
3129
3768
 
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;
3769
+ // Keeps JSONL-only rows in rollout order while treating app-server rows as the
3770
+ // authoritative spine. Matching anchors let us place missing rows without ever
3771
+ // moving server-only findings, messages, or richer tool records to the tail.
3772
+ function mergeRelayHistoryItemsWithJsonlItems(existingItems, jsonlItems, threadId = "", {
3773
+ includeJsonlItem = () => true,
3774
+ } = {}) {
3775
+ if (!Array.isArray(existingItems) || !Array.isArray(jsonlItems) || jsonlItems.length === 0) {
3776
+ return { items: existingItems, didMerge: false };
3777
+ }
3778
+
3779
+ const sanitizedJsonlItems = jsonlItems
3780
+ .map((item) => sanitizeJsonlHistoryItemForRelayMerge(item, threadId))
3781
+ .filter(Boolean);
3782
+ if (sanitizedJsonlItems.length === 0) {
3783
+ return { items: existingItems, didMerge: false };
3784
+ }
3785
+
3786
+ if (existingItems.length === 0) {
3787
+ const insertedItems = sanitizedJsonlItems.filter(includeJsonlItem);
3788
+ return insertedItems.length > 0
3789
+ ? { items: insertedItems, didMerge: true }
3790
+ : { items: existingItems, didMerge: false };
3791
+ }
3792
+
3793
+ const usedExistingIndices = new Set();
3794
+ const resolvedExistingItems = existingItems.slice();
3795
+ const insertionsBefore = new Map();
3796
+ const insertionsAfter = new Map();
3797
+ let pendingItems = [];
3798
+ let previousMatchedIndex = null;
3799
+ let matchedAnchorCount = 0;
3800
+ let didReplaceMatchedItem = false;
3801
+
3802
+ // The rollout's turn+text alias is the only identity shared across the
3803
+ // live-owner (item_N) and rollout (msg_...) views of one assistant reply.
3804
+ // Carrying it onto the matched server row lets the phone join this item
3805
+ // with its other-source representations instead of duplicating it.
3806
+ const adoptJsonlSourceAlias = (index, jsonlItem) => {
3807
+ const sourceKey = normalizeNonEmptyString(jsonlItem?.remodexSourceItemKey);
3808
+ const existing = resolvedExistingItems[index];
3809
+ if (!sourceKey || !existing || typeof existing !== "object"
3810
+ || normalizeNonEmptyString(existing.remodexSourceItemKey)) {
3811
+ return;
3136
3812
  }
3137
- if (incomingId && normalizeNonEmptyString(item.id) === incomingId) {
3138
- return true;
3813
+ resolvedExistingItems[index] = { ...existing, remodexSourceItemKey: sourceKey };
3814
+ didReplaceMatchedItem = true;
3815
+ };
3816
+
3817
+ const placePendingItems = () => {
3818
+ if (pendingItems.length === 0) {
3819
+ return;
3139
3820
  }
3140
- if (item.remodexJsonlFileChangeAggregate === true) {
3141
- return true;
3821
+ if (previousMatchedIndex == null) {
3822
+ insertionsBefore.set(0, pendingItems);
3823
+ } else {
3824
+ const existing = insertionsAfter.get(previousMatchedIndex) || [];
3825
+ insertionsAfter.set(previousMatchedIndex, existing.concat(pendingItems));
3142
3826
  }
3827
+ pendingItems = [];
3828
+ };
3143
3829
 
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;
3830
+ for (const jsonlItem of sanitizedJsonlItems) {
3831
+ const unusedMatch = (candidate, index) => !usedExistingIndices.has(index);
3832
+ const eligibleMatch = (candidate, index) => (
3833
+ (previousMatchedIndex == null || index > previousMatchedIndex)
3834
+ && unusedMatch(candidate, index)
3835
+ );
3836
+ let existingIndex = findRelayHistoryExactMatchIndex(existingItems, jsonlItem, eligibleMatch);
3837
+ if (existingIndex === -1) {
3838
+ // Exact identity remains authoritative even when server and rollout order
3839
+ // disagree. Consume that occurrence before considering a later semantic
3840
+ // lookalike, otherwise repeated rows can bind to the wrong server item.
3841
+ const representedExactIndex = findRelayHistoryExactMatchIndex(
3842
+ existingItems,
3843
+ jsonlItem,
3844
+ unusedMatch
3845
+ );
3846
+ if (representedExactIndex !== -1) {
3847
+ usedExistingIndices.add(representedExactIndex);
3848
+ if (isProgressPlanItem(jsonlItem)) {
3849
+ resolvedExistingItems[representedExactIndex] = resolvedProgressPlanHistoryItem(
3850
+ existingItems[representedExactIndex],
3851
+ jsonlItem
3852
+ );
3853
+ didReplaceMatchedItem = true;
3854
+ }
3855
+ adoptJsonlSourceAlias(representedExactIndex, jsonlItem);
3856
+ continue;
3857
+ }
3858
+ existingIndex = findRelayHistorySemanticMatchIndex(existingItems, jsonlItem, eligibleMatch);
3859
+ }
3860
+ if (existingIndex === -1) {
3861
+ // The row can already exist before the monotonic placement frontier when
3862
+ // server and rollout order disagree. Consume each represented occurrence
3863
+ // once; an extra identical JSONL occurrence must remain visible instead
3864
+ // of repeatedly matching the same server row and disappearing.
3865
+ const representedIndex = findRelayHistorySemanticMatchIndex(
3866
+ existingItems,
3867
+ jsonlItem,
3868
+ unusedMatch
3869
+ );
3870
+ if (representedIndex !== -1) {
3871
+ usedExistingIndices.add(representedIndex);
3872
+ if (isProgressPlanItem(jsonlItem)) {
3873
+ resolvedExistingItems[representedIndex] = resolvedProgressPlanHistoryItem(
3874
+ existingItems[representedIndex],
3875
+ jsonlItem
3876
+ );
3877
+ didReplaceMatchedItem = true;
3878
+ }
3879
+ adoptJsonlSourceAlias(representedIndex, jsonlItem);
3880
+ continue;
3151
3881
  }
3882
+ if (includeJsonlItem(jsonlItem)) {
3883
+ pendingItems.push(jsonlItem);
3884
+ }
3885
+ continue;
3152
3886
  }
3153
- return true;
3154
- });
3887
+
3888
+ placePendingItems();
3889
+ usedExistingIndices.add(existingIndex);
3890
+ if (isProgressPlanItem(jsonlItem)) {
3891
+ resolvedExistingItems[existingIndex] = resolvedProgressPlanHistoryItem(
3892
+ existingItems[existingIndex],
3893
+ jsonlItem
3894
+ );
3895
+ didReplaceMatchedItem = true;
3896
+ }
3897
+ adoptJsonlSourceAlias(existingIndex, jsonlItem);
3898
+ previousMatchedIndex = existingIndex;
3899
+ matchedAnchorCount += 1;
3900
+ }
3901
+
3902
+ if (matchedAnchorCount === 0) {
3903
+ const unanchoredArtifacts = sanitizedJsonlItems.filter((item) => (
3904
+ includeJsonlItem(item) && isJsonlHistoryArtifactItem(item)
3905
+ ));
3906
+ if (unanchoredArtifacts.length === 0) {
3907
+ return { items: existingItems, didMerge: false };
3908
+ }
3909
+ const firstAssistantIndex = existingItems.findIndex(isRelayAssistantHistoryItem);
3910
+ const insertionIndex = firstAssistantIndex === -1 ? existingItems.length : firstAssistantIndex;
3911
+ return {
3912
+ items: existingItems.slice(0, insertionIndex)
3913
+ .concat(unanchoredArtifacts, existingItems.slice(insertionIndex)),
3914
+ didMerge: true,
3915
+ };
3916
+ }
3917
+ if (pendingItems.length > 0 && previousMatchedIndex != null) {
3918
+ const existing = insertionsAfter.get(previousMatchedIndex) || [];
3919
+ insertionsAfter.set(previousMatchedIndex, existing.concat(pendingItems));
3920
+ }
3921
+
3922
+ if (insertionsBefore.size === 0 && insertionsAfter.size === 0 && !didReplaceMatchedItem) {
3923
+ return { items: existingItems, didMerge: false };
3924
+ }
3925
+
3926
+ const mergedItems = [];
3927
+ for (const [index, item] of resolvedExistingItems.entries()) {
3928
+ mergedItems.push(...(insertionsBefore.get(index) || []));
3929
+ mergedItems.push(item);
3930
+ mergedItems.push(...(insertionsAfter.get(index) || []));
3931
+ }
3932
+ return { items: mergedItems, didMerge: true };
3155
3933
  }
3156
3934
 
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;
3935
+ function buildOrderedJsonlTimelineItems(turnItems, turnId) {
3936
+ if (!Array.isArray(turnItems) || turnItems.length === 0) {
3937
+ return [];
3938
+ }
3939
+
3940
+ let latestProgressPlanIndex = -1;
3941
+ for (const [index, item] of turnItems.entries()) {
3942
+ if (isProgressPlanItem(item)) {
3943
+ latestProgressPlanIndex = index;
3944
+ }
3945
+ }
3946
+
3947
+ let imageViewIndex = 0;
3948
+ return turnItems.flatMap((item, index) => {
3949
+ if (!shouldIncludeJsonlTimelineItem(item)) {
3950
+ return [];
3951
+ }
3952
+ if (isProgressPlanItem(item) && index !== latestProgressPlanIndex) {
3953
+ return [];
3954
+ }
3955
+
3956
+ const itemType = normalizeHistoryItemToken(item?.type);
3957
+ if (isProgressPlanItem(item)) {
3958
+ return [{
3959
+ ...item,
3960
+ id: normalizeNonEmptyString(item?.id) || `remodex-jsonl-progress-plan-${turnId}`,
3961
+ remodexProgressPlan: true,
3962
+ remodexJsonlProgressPlan: true,
3963
+ }];
3162
3964
  }
3163
- return item.remodexJsonlProgressPlan === true
3164
- || (incomingId && normalizeNonEmptyString(item.id) === incomingId);
3965
+ if (itemType === "imageview") {
3966
+ imageViewIndex += 1;
3967
+ return [{
3968
+ ...item,
3969
+ id: normalizeNonEmptyString(item?.id)
3970
+ || `remodex-jsonl-image-view-${turnId}-${imageViewIndex}`,
3971
+ }];
3972
+ }
3973
+ return [item];
3165
3974
  });
3166
3975
  }
3167
3976
 
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;
3977
+ function shouldIncludeJsonlTimelineItem(item) {
3978
+ const itemType = normalizeHistoryItemToken(item?.type);
3979
+ return Boolean(itemType)
3980
+ && itemType !== "toolcalloutput"
3981
+ && itemType !== "functioncalloutput"
3982
+ && itemType !== "customtoolcalloutput";
3983
+ }
3984
+
3985
+ function isJsonlHistoryArtifactItem(item) {
3986
+ const itemType = normalizeHistoryItemToken(item?.type);
3987
+ return itemType === "filechange"
3988
+ || itemType === "imageview"
3989
+ || isProgressPlanItem(item);
3990
+ }
3991
+
3992
+ function isProgressPlanItem(item) {
3993
+ const itemType = normalizeHistoryItemToken(item?.type);
3994
+ return (itemType === "plan" || itemType === "todolist")
3995
+ && (item?.remodexJsonlProgressPlan === true || item?.remodexProgressPlan === true);
3996
+ }
3997
+
3998
+ function resolvedProgressPlanHistoryItem(existingItem, jsonlItem) {
3999
+ return {
4000
+ ...existingItem,
4001
+ text: jsonlItem.text,
4002
+ explanation: jsonlItem.explanation,
4003
+ plan: jsonlItem.plan,
4004
+ remodexProgressPlan: true,
4005
+ remodexJsonlProgressPlan: true,
4006
+ };
4007
+ }
4008
+
4009
+ function findRelayHistoryExactMatchIndex(items, incomingItem, predicate = () => true) {
4010
+ return items.findIndex((candidate, index) => (
4011
+ predicate(candidate, index) && relayHistoryItemsHaveExactIdentity(candidate, incomingItem)
4012
+ ));
4013
+ }
4014
+
4015
+ function findRelayHistorySemanticMatchIndex(items, incomingItem, predicate = () => true) {
4016
+ return items.findIndex((candidate, index) => (
4017
+ predicate(candidate, index) && areEquivalentRelayHistoryItems(candidate, incomingItem)
4018
+ ));
4019
+ }
4020
+
4021
+ function relayHistoryItemsHaveExactIdentity(first, second) {
4022
+ const firstIdentity = relayHistoryItemIdentity(first);
4023
+ const secondIdentity = relayHistoryItemIdentity(second);
4024
+ if (firstIdentity && secondIdentity && firstIdentity === secondIdentity) {
4025
+ return true;
4026
+ }
4027
+ const firstCallId = relayHistoryItemCallId(first);
4028
+ const secondCallId = relayHistoryItemCallId(second);
4029
+ return Boolean(firstCallId && secondCallId && firstCallId === secondCallId);
4030
+ }
4031
+
4032
+ function isRelayAssistantHistoryItem(item) {
4033
+ const role = normalizeNonEmptyString(item?.role).toLowerCase();
4034
+ const itemType = normalizeHistoryItemToken(item?.type);
4035
+ return role === "assistant"
4036
+ || itemType === "assistantmessage"
4037
+ || itemType === "agentmessage"
4038
+ || (itemType === "message" && role !== "user");
4039
+ }
4040
+
4041
+ function sanitizeJsonlHistoryItemForRelayMerge(item, threadId) {
4042
+ const sanitizedTurn = sanitizeRelayHistoryTurn({ items: [item] }, threadId);
4043
+ return sanitizedTurn?.items?.[0] || item;
4044
+ }
4045
+
4046
+ function areEquivalentRelayHistoryItems(first, second) {
4047
+ const firstIdentity = relayHistoryItemIdentity(first);
4048
+ const secondIdentity = relayHistoryItemIdentity(second);
4049
+ if (firstIdentity && secondIdentity && firstIdentity === secondIdentity) {
4050
+ return true;
4051
+ }
4052
+
4053
+ const firstCallId = relayHistoryItemCallId(first);
4054
+ const secondCallId = relayHistoryItemCallId(second);
4055
+ if (firstCallId && secondCallId && firstCallId === secondCallId) {
4056
+ return true;
4057
+ }
4058
+
4059
+ if (isProgressPlanItem(first) && isProgressPlanItem(second)) {
4060
+ return true;
4061
+ }
4062
+
4063
+ // JSONL line ids are source-local fallbacks, not provider identities. They
4064
+ // may reconcile semantically with a real app-server id; occurrence tracking
4065
+ // in the merge keeps intentional repeated rows distinct. Assistant messages
4066
+ // are exempt from the two-stable-ids refusal: the live-owner state keys them
4067
+ // by app-server event id (item_N) while the rollout records the provider id
4068
+ // (msg_...), so the same reply legitimately carries two stable identities.
4069
+ if (relayHistoryIdentityIsStable(firstIdentity)
4070
+ && relayHistoryIdentityIsStable(secondIdentity)
4071
+ && !(isRelayAssistantHistoryItem(first) && isRelayAssistantHistoryItem(second))) {
4072
+ return false;
4073
+ }
4074
+ if (relayHistoryIdentityIsStable(firstCallId)
4075
+ && relayHistoryIdentityIsStable(secondCallId)) {
4076
+ return false;
4077
+ }
4078
+
4079
+ const firstType = normalizeHistoryItemToken(first?.type);
4080
+ const secondType = normalizeHistoryItemToken(second?.type);
4081
+ if (firstType === "imageview" && secondType === "imageview") {
4082
+ const firstPath = normalizeImageViewPathKey(first);
4083
+ const secondPath = normalizeImageViewPathKey(second);
4084
+ if (firstPath && firstPath === secondPath) {
4085
+ return true;
3174
4086
  }
3175
- const itemId = normalizeNonEmptyString(item.id);
3176
- if (incomingId && itemId === incomingId) {
4087
+ }
4088
+ if (firstType === "filechange" && secondType === "filechange") {
4089
+ const firstPaths = fileChangePathSet(first);
4090
+ const secondPaths = fileChangePathSet(second);
4091
+ if (firstPaths.size > 0
4092
+ && firstPaths.size === secondPaths.size
4093
+ && Array.from(firstPaths).every((pathKey) => secondPaths.has(pathKey))) {
3177
4094
  return true;
3178
4095
  }
3179
- return incomingPath && normalizeImageViewPathKey(item) === incomingPath;
3180
- });
4096
+ }
4097
+
4098
+ const firstText = relayHistoryItemText(first);
4099
+ const secondText = relayHistoryItemText(second);
4100
+ if (!firstText || !secondText || firstText !== secondText) {
4101
+ return false;
4102
+ }
4103
+
4104
+ return relayHistoryItemKindsCompatible(first, second);
4105
+ }
4106
+
4107
+ function relayHistoryItemKindsCompatible(first, second) {
4108
+ const firstType = normalizeHistoryItemToken(first?.type);
4109
+ const secondType = normalizeHistoryItemToken(second?.type);
4110
+ if (firstType && secondType && firstType === secondType) {
4111
+ return true;
4112
+ }
4113
+
4114
+ const firstRole = normalizeNonEmptyString(first?.role).toLowerCase();
4115
+ const secondRole = normalizeNonEmptyString(second?.role).toLowerCase();
4116
+ if (firstRole && secondRole && firstRole === secondRole) {
4117
+ return true;
4118
+ }
4119
+
4120
+ return isRelayMessageLikeHistoryType(firstType) && isRelayMessageLikeHistoryType(secondType);
4121
+ }
4122
+
4123
+ function isRelayMessageLikeHistoryType(itemType) {
4124
+ return itemType === "message"
4125
+ || itemType === "assistantmessage"
4126
+ || itemType === "agentmessage"
4127
+ || itemType === "usermessage";
4128
+ }
4129
+
4130
+ function relayHistoryItemIdentity(item) {
4131
+ return normalizeNonEmptyString(item?.id)
4132
+ || normalizeNonEmptyString(item?.itemId)
4133
+ || normalizeNonEmptyString(item?.item_id);
4134
+ }
4135
+
4136
+ function relayHistoryIdentityIsStable(identity) {
4137
+ const normalizedIdentity = normalizeNonEmptyString(identity);
4138
+ if (!normalizedIdentity) {
4139
+ return false;
4140
+ }
4141
+ return !/^(?:user-message-line|response-item-line|apply-patch-line)-\d+$/.test(normalizedIdentity);
4142
+ }
4143
+
4144
+ function relayHistoryItemCallId(item) {
4145
+ return normalizeNonEmptyString(item?.call_id)
4146
+ || normalizeNonEmptyString(item?.callId);
4147
+ }
4148
+
4149
+ function relayHistoryItemText(item) {
4150
+ if (!item || typeof item !== "object" || Array.isArray(item)) {
4151
+ return "";
4152
+ }
4153
+
4154
+ for (const key of ["text", "message", "summary", "output", "outputText", "output_text", "command"]) {
4155
+ const value = normalizeNonEmptyString(item[key]);
4156
+ if (value) {
4157
+ return value;
4158
+ }
4159
+ }
4160
+
4161
+ if (Array.isArray(item.content)) {
4162
+ return item.content
4163
+ .map(relayHistoryItemText)
4164
+ .filter(Boolean)
4165
+ .join("\n");
4166
+ }
4167
+
4168
+ return "";
3181
4169
  }
3182
4170
 
3183
4171
  function normalizeImageViewPathKey(item) {
@@ -3249,7 +4237,16 @@ function sanitizeRelayHistoryTurn(turn, threadId = "") {
3249
4237
  }
3250
4238
 
3251
4239
  let itemDidChange = false;
3252
- let sanitizedItem = convertApplyPatchHistoryItem(item) || item;
4240
+ let sanitizedItem = sanitizeUserRoleItem(item);
4241
+ if (!sanitizedItem) {
4242
+ turnDidChange = true;
4243
+ return null;
4244
+ }
4245
+ if (sanitizedItem !== item) {
4246
+ itemDidChange = true;
4247
+ }
4248
+
4249
+ sanitizedItem = convertApplyPatchHistoryItem(sanitizedItem) || sanitizedItem;
3253
4250
  if (sanitizedItem !== item) {
3254
4251
  itemDidChange = true;
3255
4252
  }
@@ -3287,7 +4284,7 @@ function sanitizeRelayHistoryTurn(turn, threadId = "") {
3287
4284
  }
3288
4285
 
3289
4286
  return itemDidChange ? sanitizedItem : item;
3290
- });
4287
+ }).filter(Boolean);
3291
4288
 
3292
4289
  return turnDidChange
3293
4290
  ? {
@@ -3297,6 +4294,67 @@ function sanitizeRelayHistoryTurn(turn, threadId = "") {
3297
4294
  : turn;
3298
4295
  }
3299
4296
 
4297
+ // Compatibility predicate for callers that only need a drop/no-drop decision.
4298
+ // The full sanitizer below also rewrites mixed items without losing attachments.
4299
+ const LIVE_ITEM_LIFECYCLE_METHODS = new Set([
4300
+ "item/started",
4301
+ "item/updated",
4302
+ "item/completed",
4303
+ ]);
4304
+
4305
+ function isContextualUserItemNotification(parsed) {
4306
+ const method = typeof parsed?.method === "string" ? parsed.method : "";
4307
+ if (!LIVE_ITEM_LIFECYCLE_METHODS.has(method)) {
4308
+ return false;
4309
+ }
4310
+ const item = parsed?.params?.item;
4311
+ if (!isUserRoleItem(item)) {
4312
+ return false;
4313
+ }
4314
+ return isContextualUserText(readUserItemText(item));
4315
+ }
4316
+
4317
+ // Sanitizes both raw app-server item events and fallback user_message events
4318
+ // before they can become mobile bubbles. Structured attachments stay intact.
4319
+ function sanitizeLiveUserNotification(parsed) {
4320
+ if (!parsed || typeof parsed !== "object") {
4321
+ return parsed;
4322
+ }
4323
+ const method = typeof parsed.method === "string" ? parsed.method : "";
4324
+ if (LIVE_ITEM_LIFECYCLE_METHODS.has(method)) {
4325
+ const item = parsed?.params?.item;
4326
+ if (!isUserRoleItem(item)) {
4327
+ return parsed;
4328
+ }
4329
+ const sanitizedItem = sanitizeUserRoleItem(item);
4330
+ if (!sanitizedItem) {
4331
+ return null;
4332
+ }
4333
+ return sanitizedItem === item ? parsed : {
4334
+ ...parsed,
4335
+ params: { ...parsed.params, item: sanitizedItem },
4336
+ };
4337
+ }
4338
+
4339
+ if (method !== "codex/event/user_message") {
4340
+ return parsed;
4341
+ }
4342
+ const key = typeof parsed?.params?.message === "string"
4343
+ ? "message"
4344
+ : (typeof parsed?.params?.text === "string" ? "text" : "");
4345
+ if (!key) {
4346
+ return parsed;
4347
+ }
4348
+ const visible = visibleUserPromptText(parsed.params[key]);
4349
+ if (!visible) {
4350
+ return null;
4351
+ }
4352
+ return visible === parsed.params[key] ? parsed : {
4353
+ ...parsed,
4354
+ params: { ...parsed.params, [key]: visible },
4355
+ };
4356
+ }
4357
+
3300
4358
  function convertApplyPatchHistoryItem(item) {
3301
4359
  const itemType = normalizeHistoryItemToken(item?.type);
3302
4360
  const toolName = normalizeNonEmptyString(item?.name);
@@ -3608,12 +4666,17 @@ function parseBridgeJSON(value) {
3608
4666
  }
3609
4667
  }
3610
4668
 
3611
- function trimThreadPayloadForRelay(parsed, explicitThread = undefined) {
4669
+ function trimThreadPayloadForRelay(parsed, explicitThread = undefined, options = {}) {
3612
4670
  const thread = explicitThread ?? parsed?.result?.thread;
3613
4671
  if (!parsed || !thread || typeof thread !== "object" || !Array.isArray(thread.turns)) {
3614
4672
  return null;
3615
4673
  }
3616
4674
 
4675
+ // Callers that pre-trimmed the turn window pass the dropped count and the original
4676
+ // first turn here so compaction markers keep reporting whole-thread numbers.
4677
+ const preOmittedTurnCount = Math.max(0, options.preOmittedTurnCount ?? 0);
4678
+ const compactionIdSource = options.compactionIdSource ?? null;
4679
+
3617
4680
  let workingThread = thread;
3618
4681
  let encoded = encodeRelayThreadPayload(parsed, workingThread);
3619
4682
  if (encoded == null) {
@@ -3621,7 +4684,16 @@ function trimThreadPayloadForRelay(parsed, explicitThread = undefined) {
3621
4684
  }
3622
4685
 
3623
4686
  if (Buffer.byteLength(encoded, "utf8") <= RELAY_THREAD_PAYLOAD_SOFT_LIMIT_BYTES) {
3624
- return explicitThread === undefined ? null : encoded;
4687
+ if (preOmittedTurnCount <= 0) {
4688
+ return explicitThread === undefined ? null : encoded;
4689
+ }
4690
+ const compactedThread = buildRelayHistoryCompactedThread(
4691
+ thread,
4692
+ buildRelayCompactedHistoryTurns(thread.turns, thread.turns, preOmittedTurnCount, compactionIdSource),
4693
+ preOmittedTurnCount,
4694
+ thread.turns.length
4695
+ );
4696
+ return encodeRelayThreadPayload(parsed, compactedThread) ?? encoded;
3625
4697
  }
3626
4698
 
3627
4699
  const turns = thread.turns;
@@ -3634,8 +4706,8 @@ function trimThreadPayloadForRelay(parsed, explicitThread = undefined) {
3634
4706
  }
3635
4707
  const candidateThread = buildRelayHistoryCompactedThread(
3636
4708
  thread,
3637
- buildRelayCompactedHistoryTurns(turns, trimmedTurns),
3638
- Math.max(0, turns.length - trimmedTurns.length),
4709
+ buildRelayCompactedHistoryTurns(turns, trimmedTurns, preOmittedTurnCount, compactionIdSource),
4710
+ preOmittedTurnCount + Math.max(0, turns.length - trimmedTurns.length),
3639
4711
  trimmedTurns.length
3640
4712
  );
3641
4713
  encoded = encodeRelayThreadPayload(parsed, candidateThread);
@@ -3655,9 +4727,9 @@ function trimThreadPayloadForRelay(parsed, explicitThread = undefined) {
3655
4727
  while (trimmedItems.length > 1) {
3656
4728
  trimmedItems = trimmedItems.slice(1);
3657
4729
  const compactedTurnPrefix = buildRelayHistoryCompactionTurn(
3658
- Math.max(0, turns.length - 1),
4730
+ preOmittedTurnCount + Math.max(0, turns.length - 1),
3659
4731
  1,
3660
- thread
4732
+ compactionIdSource ?? thread
3661
4733
  );
3662
4734
  const candidateThread = buildRelayHistoryCompactedThread(
3663
4735
  thread,
@@ -3668,7 +4740,7 @@ function trimThreadPayloadForRelay(parsed, explicitThread = undefined) {
3668
4740
  ...newestTurn,
3669
4741
  items: trimmedItems,
3670
4742
  }],
3671
- Math.max(0, turns.length - 1),
4743
+ preOmittedTurnCount + Math.max(0, turns.length - 1),
3672
4744
  1
3673
4745
  );
3674
4746
  encoded = encodeRelayThreadPayload(parsed, candidateThread);
@@ -3690,13 +4762,13 @@ function trimThreadPayloadForRelay(parsed, explicitThread = undefined) {
3690
4762
  let candidateThread = buildRelayHistoryCompactedThread(
3691
4763
  thread,
3692
4764
  [
3693
- ...buildRelayCompactedHistoryTurns(turns, [newestTurn]).slice(0, -1),
4765
+ ...buildRelayCompactedHistoryTurns(turns, [newestTurn], preOmittedTurnCount, compactionIdSource).slice(0, -1),
3694
4766
  {
3695
4767
  ...newestTurn,
3696
4768
  items: [truncatedItem],
3697
4769
  },
3698
4770
  ],
3699
- Math.max(0, turns.length - 1),
4771
+ preOmittedTurnCount + Math.max(0, turns.length - 1),
3700
4772
  1
3701
4773
  );
3702
4774
  encoded = encodeRelayThreadPayload(parsed, candidateThread);
@@ -3707,13 +4779,13 @@ function trimThreadPayloadForRelay(parsed, explicitThread = undefined) {
3707
4779
  candidateThread = buildRelayHistoryCompactedThread(
3708
4780
  thread,
3709
4781
  [
3710
- ...buildRelayCompactedHistoryTurns(turns, [newestTurn]).slice(0, -1),
4782
+ ...buildRelayCompactedHistoryTurns(turns, [newestTurn], preOmittedTurnCount, compactionIdSource).slice(0, -1),
3711
4783
  {
3712
4784
  ...newestTurn,
3713
4785
  items: [compactHistoryItemForRelay(mostRecentItem, RELAY_HISTORY_TEXT_TAIL_LIMIT_CHARS)],
3714
4786
  },
3715
4787
  ],
3716
- Math.max(0, turns.length - 1),
4788
+ preOmittedTurnCount + Math.max(0, turns.length - 1),
3717
4789
  1
3718
4790
  );
3719
4791
  return encodeRelayThreadPayload(parsed, candidateThread);
@@ -3753,6 +4825,32 @@ function trimTurnsListPayloadForRelay(parsed, turnsKey, originalRawMessage = nul
3753
4825
  }
3754
4826
  }
3755
4827
 
4828
+ // A bounded JSONL first page can still describe one exceptionally large
4829
+ // turn with many small items. Keep that provisional response relay-safe while
4830
+ // preserving its handoff flags/cursor; the canonical background page will
4831
+ // replace it with the authoritative history.
4832
+ if (result.remodexJsonlFallback === true) {
4833
+ for (const maxItems of [64, 16, 4, 1]) {
4834
+ for (const maxChars of [1_000, 0]) {
4835
+ const emergencyTurns = turns.map((turn) => (
4836
+ compactEmergencySingleTurnForRelay(turn, maxChars, maxItems)
4837
+ ));
4838
+ const emergencyPayload = JSON.stringify({
4839
+ ...parsed,
4840
+ result: {
4841
+ ...result,
4842
+ [turnsKey]: emergencyTurns,
4843
+ remodexPageCompactedForRelay: true,
4844
+ remodexEmergencyJsonlPageForRelay: true,
4845
+ },
4846
+ });
4847
+ if (Buffer.byteLength(emergencyPayload, "utf8") <= RELAY_THREAD_PAYLOAD_SOFT_LIMIT_BYTES) {
4848
+ return emergencyPayload;
4849
+ }
4850
+ }
4851
+ }
4852
+ }
4853
+
3756
4854
  return fallbackCompactedPayload ?? (originalRawMessage ?? encoded);
3757
4855
  }
3758
4856
 
@@ -3779,12 +4877,12 @@ function buildRelayHistoryCompactedThread(thread, turns, omittedTurnCount, keptT
3779
4877
  };
3780
4878
  }
3781
4879
 
3782
- function buildRelayCompactedHistoryTurns(allTurns, keptTurns) {
3783
- const omittedTurnCount = Math.max(0, allTurns.length - keptTurns.length);
4880
+ function buildRelayCompactedHistoryTurns(allTurns, keptTurns, preOmittedTurnCount = 0, idSourceOverride = null) {
4881
+ const omittedTurnCount = preOmittedTurnCount + Math.max(0, allTurns.length - keptTurns.length);
3784
4882
  const compactionTurn = buildRelayHistoryCompactionTurn(
3785
4883
  omittedTurnCount,
3786
4884
  keptTurns.length,
3787
- allTurns[0]
4885
+ idSourceOverride ?? allTurns[0]
3788
4886
  );
3789
4887
  return compactionTurn ? [compactionTurn, ...keptTurns] : keptTurns;
3790
4888
  }
@@ -3808,6 +4906,9 @@ function buildRelayHistoryCompactionTurn(omittedTurnCount, keptTurnCount, idSour
3808
4906
 
3809
4907
  return {
3810
4908
  id: `remodex-history-compacted-${baseId}`,
4909
+ // A status-less turn reads as interruptible/running to the phone's
4910
+ // turn-state snapshot, flagging idle heavy threads as "thinking".
4911
+ status: "completed",
3811
4912
  remodexSynthetic: true,
3812
4913
  remodexHistoryCompacted: true,
3813
4914
  remodexOmittedTurnCount: omittedTurnCount,
@@ -3970,21 +5071,40 @@ function persistBridgePreferences(
3970
5071
  });
3971
5072
  }
3972
5073
 
5074
+ function shouldSuppressRolloutMirrorForThread(
5075
+ threadId,
5076
+ { desktopIpcActionFollower = null, desktopIpcLiveOwner = null } = {},
5077
+ { fallbackActivityAt = 0 } = {}
5078
+ ) {
5079
+ // Desktop ownership is an expiring live lease, not a permanent boolean. A
5080
+ // stale IPC snapshot used to mute an actively growing rollout forever.
5081
+ const followerIsFresh = typeof desktopIpcActionFollower?.hasFreshLiveThreadState === "function"
5082
+ ? desktopIpcActionFollower.hasFreshLiveThreadState(threadId, { fallbackActivityAt })
5083
+ : desktopIpcActionFollower?.hasLiveThreadState(threadId);
5084
+ const ownerIsFresh = typeof desktopIpcLiveOwner?.isFreshThreadOwned === "function"
5085
+ ? desktopIpcLiveOwner.isFreshThreadOwned(threadId)
5086
+ : false;
5087
+ return Boolean(followerIsFresh) || Boolean(ownerIsFresh);
5088
+ }
5089
+
3973
5090
  module.exports = {
3974
5091
  buildThreadTurnsListRelaySanitizeContext,
3975
5092
  buildHeartbeatBridgeStatus,
3976
- buildRelayCloseStatusError,
3977
- buildRelayAccessTokenHeaders,
3978
- buildRelayUserAgentHeader,
5093
+ canonicalThreadTurnsListRequest,
3979
5094
  createMacOSBridgeWakeAssertion,
5095
+ createThreadTurnsListFastPageCoordinator,
3980
5096
  disableUnsupportedReasoningSummaryForTurnStart,
3981
5097
  fetchAdaptiveThreadTurnsListForRelay,
3982
5098
  hasRelayConnectionGoneStale,
3983
- isTerminalRelayCloseCode,
5099
+ isContextualUserItemNotification,
5100
+ maybeMergeLatestJsonlTurnIntoTurnsListResponse,
5101
+ normalizeTurnStartForCodex,
3984
5102
  normalizeRelayBoundJsonRpcMessage,
3985
5103
  persistBridgePreferences,
3986
5104
  resolveJsonlTurnsListRolloutPathForFallback,
3987
5105
  sanitizeLiveGeneratedImageMessageForRelay,
5106
+ sanitizeLiveUserNotification,
3988
5107
  sanitizeThreadHistoryImagesForRelay,
5108
+ shouldSuppressRolloutMirrorForThread,
3989
5109
  startBridge,
3990
5110
  };