@makerbi/remodex 2.0.1 → 2.3.1

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,9 +142,12 @@ const RELAY_TURNS_LIST_PAGINATION_RESULT_KEYS = [
109
142
  "previousCursor",
110
143
  "previous_cursor",
111
144
  ];
112
- const jsonlArtifactItemsCacheByThread = new Map();
113
- const FORWARDED_REQUEST_METHODS_MAX_SIZE = 500;
114
- const JSONL_ROLLOUT_PATH_CACHE_MAX_SIZE = 200;
145
+ const RELAY_TURNS_LIST_PREVIOUS_PAGINATION_RESULT_KEYS = new Set([
146
+ "prevCursor",
147
+ "prev_cursor",
148
+ "previousCursor",
149
+ "previous_cursor",
150
+ ]);
115
151
 
116
152
  function buildRelayUserAgentHeader({ version = bridgePackageVersion } = {}) {
117
153
  const normalizedVersion = typeof version === "string" && version.trim()
@@ -131,6 +167,11 @@ function buildRelayAccessTokenHeaders(config = {}, env = process.env) {
131
167
  : {};
132
168
  }
133
169
 
170
+ const jsonlArtifactItemsCacheByThread = new Map();
171
+ const jsonlThreadCwdCacheByThread = new Map();
172
+ const FORWARDED_REQUEST_METHODS_MAX_SIZE = 500;
173
+ const JSONL_ROLLOUT_PATH_CACHE_MAX_SIZE = 200;
174
+
134
175
  function evictOldestEntries(map, maxSize) {
135
176
  if (map.size <= maxSize) {
136
177
  return;
@@ -143,6 +184,540 @@ function evictOldestEntries(map, maxSize) {
143
184
  }
144
185
  }
145
186
 
187
+ function createThreadTurnsListFastPageCoordinator({
188
+ waitMs = RELAY_JSONL_FAST_FIRST_PAGE_WAIT_MS,
189
+ handoffTTLms = RELAY_JSONL_CANONICAL_HANDOFF_TTL_MS,
190
+ maxHandoffs = RELAY_JSONL_CANONICAL_HANDOFF_MAX_ENTRIES,
191
+ payloadSoftLimitBytes = RELAY_THREAD_PAYLOAD_SOFT_LIMIT_BYTES,
192
+ sanitizeForRelay = sanitizeThreadHistoryImagesForRelay,
193
+ now = Date.now,
194
+ setTimeoutImpl = setTimeout,
195
+ clearTimeoutImpl = clearTimeout,
196
+ createToken = () => randomBytes(12).toString("hex"),
197
+ } = {}) {
198
+ const handoffsByToken = new Map();
199
+ const latestHandoffTokenByThread = new Map();
200
+ const canonicalFirstPageByKey = new Map();
201
+
202
+ function pruneHandoffs() {
203
+ const cutoff = now() - handoffTTLms;
204
+ for (const [token, entry] of handoffsByToken) {
205
+ if (entry.createdAt >= cutoff) {
206
+ continue;
207
+ }
208
+ handoffsByToken.delete(token);
209
+ if (latestHandoffTokenByThread.get(entry.threadId) === token) {
210
+ latestHandoffTokenByThread.delete(entry.threadId);
211
+ }
212
+ }
213
+ for (const [cacheKey, entry] of canonicalFirstPageByKey) {
214
+ if (entry.createdAt < cutoff) {
215
+ canonicalFirstPageByKey.delete(cacheKey);
216
+ }
217
+ }
218
+ while (handoffsByToken.size > maxHandoffs) {
219
+ const oldestToken = handoffsByToken.keys().next().value;
220
+ const oldest = handoffsByToken.get(oldestToken);
221
+ handoffsByToken.delete(oldestToken);
222
+ if (oldest && latestHandoffTokenByThread.get(oldest.threadId) === oldestToken) {
223
+ latestHandoffTokenByThread.delete(oldest.threadId);
224
+ }
225
+ }
226
+ }
227
+
228
+ function rememberHandoff(threadId, canonicalOutcomePromise, jsonlFallback) {
229
+ pruneHandoffs();
230
+ const token = createToken();
231
+ const entry = {
232
+ token,
233
+ threadId,
234
+ canonicalOutcomePromise,
235
+ hadNonEmptyJsonl: Boolean(jsonlFallback?.response),
236
+ anchorTurnId: firstTurnsListTurnId(jsonlFallback?.response),
237
+ createdAt: now(),
238
+ };
239
+ handoffsByToken.set(token, entry);
240
+ latestHandoffTokenByThread.set(threadId, token);
241
+ pruneHandoffs();
242
+ return token;
243
+ }
244
+
245
+ function consumeHandoff(entry) {
246
+ if (!entry?.token) {
247
+ return;
248
+ }
249
+ handoffsByToken.delete(entry.token);
250
+ if (latestHandoffTokenByThread.get(entry.threadId) === entry.token) {
251
+ latestHandoffTokenByThread.delete(entry.threadId);
252
+ }
253
+ }
254
+
255
+ function canonicalFirstPageOutcome(cacheKey, canonicalRequest, fetchCanonical) {
256
+ pruneHandoffs();
257
+ const existing = canonicalFirstPageByKey.get(cacheKey);
258
+ if (existing) {
259
+ return existing.canonicalOutcomePromise;
260
+ }
261
+ const canonicalOutcomePromise = settleThreadTurnsListCanonicalOutcome(
262
+ fetchCanonical(canonicalRequest)
263
+ );
264
+ canonicalFirstPageByKey.set(cacheKey, {
265
+ canonicalOutcomePromise,
266
+ createdAt: now(),
267
+ });
268
+ canonicalOutcomePromise.then(() => {
269
+ forgetCanonicalFirstPage(cacheKey, canonicalOutcomePromise);
270
+ });
271
+ return canonicalOutcomePromise;
272
+ }
273
+
274
+ function forgetCanonicalFirstPage(cacheKey, canonicalOutcomePromise) {
275
+ const existing = canonicalFirstPageByKey.get(cacheKey);
276
+ if (existing?.canonicalOutcomePromise === canonicalOutcomePromise) {
277
+ canonicalFirstPageByKey.delete(cacheKey);
278
+ }
279
+ }
280
+
281
+ function readHandoffEntry(request) {
282
+ pruneHandoffs();
283
+ const threadId = threadIdFromRequestParams(request?.params);
284
+ const cursor = request?.params?.cursor;
285
+ const token = threadTurnsListHandoffDescriptor(cursor)?.token
286
+ || latestHandoffTokenByThread.get(threadId)
287
+ || "";
288
+ const entry = token ? handoffsByToken.get(token) : null;
289
+ return entry?.threadId === threadId ? entry : null;
290
+ }
291
+
292
+ async function awaitCanonicalOutcome(canonicalOutcomePromise) {
293
+ const outcome = await canonicalOutcomePromise;
294
+ if (!outcome.ok) {
295
+ throw outcome.error;
296
+ }
297
+ return outcome.response;
298
+ }
299
+
300
+ async function extendCanonicalResponseThroughAnchor(
301
+ response,
302
+ canonicalRequest,
303
+ anchorTurnId,
304
+ fetchCanonical,
305
+ maxPages = 12
306
+ ) {
307
+ if (threadTurnsListResponseContainsAnchor(response, anchorTurnId)) {
308
+ return response;
309
+ }
310
+ const firstResult = response?.result;
311
+ const turnsKey = findTurnsListResultKey(firstResult);
312
+ if (!turnsKey) {
313
+ return null;
314
+ }
315
+
316
+ let lastResult = firstResult;
317
+ let combinedTurns = [...firstResult[turnsKey]];
318
+ let cursor = readTurnsListNextCursor(firstResult);
319
+ const seenCursors = new Set();
320
+ for (let pageIndex = 0; pageIndex < maxPages && hasRelayCursor(cursor); pageIndex += 1) {
321
+ const cursorKey = JSON.stringify(cursor);
322
+ if (seenCursors.has(cursorKey)) {
323
+ break;
324
+ }
325
+ seenCursors.add(cursorKey);
326
+ const nextRequest = {
327
+ ...canonicalRequest,
328
+ params: buildAdaptiveTurnsListPageParams(
329
+ canonicalRequest.params,
330
+ RELAY_TURNS_LIST_SAFE_RETRY_LIMIT,
331
+ cursor
332
+ ),
333
+ };
334
+ const nextResponse = await awaitCanonicalOutcome(
335
+ settleThreadTurnsListCanonicalOutcome(fetchCanonical(nextRequest))
336
+ );
337
+ const nextResult = nextResponse?.result;
338
+ const nextTurnsKey = findTurnsListResultKey(nextResult);
339
+ if (!nextTurnsKey) {
340
+ break;
341
+ }
342
+ for (const turn of nextResult[nextTurnsKey]) {
343
+ const turnId = turnListTurnIdentifier(turn);
344
+ if (!turnId || !combinedTurns.some((existing) => turnListTurnIdentifier(existing) === turnId)) {
345
+ combinedTurns.push(turn);
346
+ }
347
+ }
348
+ lastResult = nextResult;
349
+ const combinedResponse = buildSafeTurnsListResponse(
350
+ canonicalRequest.id,
351
+ firstResult,
352
+ lastResult,
353
+ turnsKey,
354
+ combinedTurns
355
+ );
356
+ if (threadTurnsListResponseContainsAnchor(combinedResponse, anchorTurnId)) {
357
+ // The cursor belongs after every turn through the anchor. Keep that
358
+ // complete boundary intact, compacting items if needed; never slice
359
+ // turns and accidentally make the omitted range unreachable.
360
+ return buildCompactedCompleteTurnsListResponse({
361
+ requestId: canonicalRequest.id,
362
+ firstResult,
363
+ lastResult,
364
+ turnsKey,
365
+ turns: combinedTurns,
366
+ sanitizeForRelay,
367
+ sanitizeContext: buildThreadTurnsListRelaySanitizeContext(canonicalRequest),
368
+ payloadSoftLimitBytes,
369
+ });
370
+ }
371
+ const nextCursor = readTurnsListNextCursor(nextResult);
372
+ if (nextResult[nextTurnsKey].length === 0 || !hasRelayCursor(nextCursor)) {
373
+ break;
374
+ }
375
+ cursor = nextCursor;
376
+ }
377
+ return null;
378
+ }
379
+
380
+ async function resolveCanonicalRequest(request, fetchCanonical, existingEntry = null, {
381
+ alignToHandoffAnchor = false,
382
+ validateHandoffAnchor = false,
383
+ handoffAnchorTurnId = "",
384
+ } = {}) {
385
+ const canonicalRequest = canonicalThreadTurnsListRequest(request);
386
+ let entry = existingEntry;
387
+ let response = null;
388
+ const anchorTurnId = entry?.anchorTurnId || handoffAnchorTurnId;
389
+ const canMatchCanonicalAnchor = anchorTurnId
390
+ && !isSyntheticJsonlHistoryTurnId(anchorTurnId);
391
+ if (entry) {
392
+ const observedOutcomePromise = entry.canonicalOutcomePromise;
393
+ const firstOutcome = await observedOutcomePromise;
394
+ const firstResponseIsUsable = firstOutcome.ok
395
+ && !isEmptyTurnsListResponse(firstOutcome.response);
396
+ if (firstResponseIsUsable) {
397
+ response = firstOutcome.response;
398
+ } else {
399
+ if (entry.canonicalOutcomePromise === observedOutcomePromise) {
400
+ entry.canonicalOutcomePromise = settleThreadTurnsListCanonicalOutcome(
401
+ fetchCanonical(canonicalRequest)
402
+ );
403
+ entry.createdAt = now();
404
+ }
405
+ }
406
+ }
407
+
408
+ response = response || await awaitCanonicalOutcome(
409
+ entry?.canonicalOutcomePromise
410
+ || settleThreadTurnsListCanonicalOutcome(fetchCanonical(canonicalRequest))
411
+ );
412
+ if (entry?.hadNonEmptyJsonl && isEmptyTurnsListResponse(response)) {
413
+ throw new Error("Canonical thread history was empty after a non-empty JSONL first page.");
414
+ }
415
+ if (validateHandoffAnchor
416
+ && canMatchCanonicalAnchor
417
+ && !threadTurnsListResponseContainsAnchor(response, anchorTurnId)) {
418
+ response = await extendCanonicalResponseThroughAnchor(
419
+ response,
420
+ canonicalRequest,
421
+ anchorTurnId,
422
+ fetchCanonical
423
+ );
424
+ if (!response) {
425
+ throw new Error("Canonical history does not contain the JSONL handoff anchor yet.");
426
+ }
427
+ }
428
+ const rebound = rebindThreadTurnsListResponseId(response, request.id);
429
+ if (!alignToHandoffAnchor || !canMatchCanonicalAnchor) {
430
+ return rebound;
431
+ }
432
+ const aligned = alignThreadTurnsListResponseToAnchor(rebound, anchorTurnId);
433
+ if (!aligned) {
434
+ throw new Error("Canonical history no longer contains the JSONL handoff anchor.");
435
+ }
436
+ return aligned;
437
+ }
438
+
439
+ async function resolve(request, { fetchCanonical, readJsonl }) {
440
+ const params = request?.params || {};
441
+ const cursor = params.cursor;
442
+ const handoffDescriptor = threadTurnsListHandoffDescriptor(cursor);
443
+ const isHandoffRequest = cursor === JSONL_OLDER_HANDOFF_CURSOR
444
+ || Boolean(handoffDescriptor);
445
+ const requiresCanonical = params.remodexRequireCanonical === true;
446
+ const hasOrdinaryCursor = hasRelayCursor(cursor) && !isHandoffRequest;
447
+
448
+ if (hasOrdinaryCursor) {
449
+ return {
450
+ source: "canonical",
451
+ response: await resolveCanonicalRequest(request, fetchCanonical),
452
+ usesJsonl: false,
453
+ };
454
+ }
455
+
456
+ if (isHandoffRequest || requiresCanonical) {
457
+ const handoffEntry = readHandoffEntry(request);
458
+ const response = await resolveCanonicalRequest(request, fetchCanonical, handoffEntry, {
459
+ alignToHandoffAnchor: isHandoffRequest,
460
+ validateHandoffAnchor: true,
461
+ handoffAnchorTurnId: handoffDescriptor?.anchorTurnId || "",
462
+ });
463
+ consumeHandoff(handoffEntry);
464
+ return {
465
+ source: "canonical",
466
+ response,
467
+ usesJsonl: false,
468
+ };
469
+ }
470
+
471
+ const canonicalRequest = canonicalThreadTurnsListRequest(request);
472
+ const threadId = threadIdFromRequestParams(params);
473
+ const canonicalFirstPageCacheKey = canonicalThreadTurnsListRequestShapeKey(canonicalRequest);
474
+ const canonicalOutcomePromise = canonicalFirstPageOutcome(
475
+ canonicalFirstPageCacheKey,
476
+ canonicalRequest,
477
+ fetchCanonical
478
+ );
479
+ let jsonlFallback = null;
480
+ try {
481
+ jsonlFallback = await readJsonl(request);
482
+ } catch {
483
+ jsonlFallback = null;
484
+ }
485
+ // A rollout tail is a useful emergency baseline only when it contains a
486
+ // whole turn package. Never let a bare tail (for example a file-change or
487
+ // final assistant fragment) win the race with canonical history: iOS would
488
+ // render it as a complete conversation and then merge the real opener in
489
+ // later, which is exactly how orphan cards and duplicate rows appeared.
490
+ if (jsonlFallback?.response && !isCoherentJsonlFirstPageResponse(jsonlFallback.response)) {
491
+ jsonlFallback = null;
492
+ }
493
+ if (!jsonlFallback?.response) {
494
+ const response = await awaitCanonicalOutcome(canonicalOutcomePromise);
495
+ forgetCanonicalFirstPage(canonicalFirstPageCacheKey, canonicalOutcomePromise);
496
+ return {
497
+ source: "canonical",
498
+ response: rebindThreadTurnsListResponseId(response, request.id),
499
+ usesJsonl: false,
500
+ };
501
+ }
502
+
503
+ let timeoutId = null;
504
+ const deadline = new Promise((resolveDeadline) => {
505
+ timeoutId = setTimeoutImpl(() => resolveDeadline({ deadline: true }), waitMs);
506
+ });
507
+ const first = await Promise.race([canonicalOutcomePromise, deadline]);
508
+ if (timeoutId != null) {
509
+ clearTimeoutImpl(timeoutId);
510
+ }
511
+
512
+ if (first?.ok && !isEmptyTurnsListResponse(first.response)) {
513
+ if (shouldPreferJsonlFirstPage(first.response, jsonlFallback.response)) {
514
+ const token = rememberHandoff(threadId, canonicalOutcomePromise, jsonlFallback);
515
+ return {
516
+ source: "jsonl",
517
+ response: buildJsonlCanonicalHandoffResponse(
518
+ jsonlFallback.response,
519
+ request.id,
520
+ token,
521
+ firstTurnsListTurnId(jsonlFallback.response)
522
+ ),
523
+ usesJsonl: true,
524
+ };
525
+ }
526
+ forgetCanonicalFirstPage(canonicalFirstPageCacheKey, canonicalOutcomePromise);
527
+ return {
528
+ source: "canonical",
529
+ response: rebindThreadTurnsListResponseId(first.response, request.id),
530
+ usesJsonl: false,
531
+ jsonlFallback,
532
+ };
533
+ }
534
+
535
+ const token = rememberHandoff(threadId, canonicalOutcomePromise, jsonlFallback);
536
+ return {
537
+ source: "jsonl",
538
+ response: buildJsonlCanonicalHandoffResponse(
539
+ jsonlFallback.response,
540
+ request.id,
541
+ token,
542
+ firstTurnsListTurnId(jsonlFallback.response)
543
+ ),
544
+ usesJsonl: true,
545
+ };
546
+ }
547
+
548
+ return { resolve };
549
+ }
550
+
551
+ function settleThreadTurnsListCanonicalOutcome(promise) {
552
+ return Promise.resolve(promise).then(
553
+ (response) => ({ ok: true, response }),
554
+ (error) => ({ ok: false, error })
555
+ );
556
+ }
557
+
558
+ function threadTurnsListHandoffDescriptor(cursor) {
559
+ if (typeof cursor !== "string" || !cursor.startsWith(JSONL_CANONICAL_HANDOFF_CURSOR_PREFIX)) {
560
+ return null;
561
+ }
562
+ const raw = cursor.slice(JSONL_CANONICAL_HANDOFF_CURSOR_PREFIX.length);
563
+ const separatorIndex = raw.lastIndexOf(":");
564
+ if (separatorIndex < 0) {
565
+ return raw ? { anchorTurnId: "", token: raw } : null;
566
+ }
567
+ const token = raw.slice(separatorIndex + 1);
568
+ if (!token) {
569
+ return null;
570
+ }
571
+ let anchorTurnId = "";
572
+ try {
573
+ anchorTurnId = decodeURIComponent(raw.slice(0, separatorIndex));
574
+ } catch {
575
+ return null;
576
+ }
577
+ return { anchorTurnId, token };
578
+ }
579
+
580
+ function canonicalThreadTurnsListRequest(request) {
581
+ const params = { ...(request?.params || {}) };
582
+ delete params.remodexRequireCanonical;
583
+ delete params.remodexTurnStateOnly;
584
+ if (params.cursor === JSONL_OLDER_HANDOFF_CURSOR || threadTurnsListHandoffDescriptor(params.cursor)) {
585
+ delete params.cursor;
586
+ }
587
+ return { ...request, params };
588
+ }
589
+
590
+ function canonicalThreadTurnsListRequestShapeKey(canonicalRequest) {
591
+ const params = canonicalRequest?.params || {};
592
+ return JSON.stringify(sortJsonValueForCacheKey({
593
+ threadId: threadIdFromRequestParams(params),
594
+ params,
595
+ }));
596
+ }
597
+
598
+ function sortJsonValueForCacheKey(value) {
599
+ if (Array.isArray(value)) {
600
+ return value.map(sortJsonValueForCacheKey);
601
+ }
602
+ if (!value || typeof value !== "object") {
603
+ return value;
604
+ }
605
+ return Object.fromEntries(
606
+ Object.keys(value)
607
+ .sort()
608
+ .map((key) => [key, sortJsonValueForCacheKey(value[key])])
609
+ );
610
+ }
611
+
612
+ function rebindThreadTurnsListResponseId(response, requestId) {
613
+ return response && typeof response === "object"
614
+ ? { ...response, id: requestId }
615
+ : response;
616
+ }
617
+
618
+ function buildJsonlCanonicalHandoffResponse(response, requestId, token, anchorTurnId = "") {
619
+ const result = response?.result;
620
+ if (!result || typeof result !== "object" || Array.isArray(result)) {
621
+ return response;
622
+ }
623
+ return {
624
+ ...response,
625
+ id: requestId,
626
+ result: {
627
+ ...result,
628
+ nextCursor: `${JSONL_CANONICAL_HANDOFF_CURSOR_PREFIX}${encodeURIComponent(anchorTurnId)}:${token}`,
629
+ remodexJsonlFallback: true,
630
+ remodexCanonicalHandoff: true,
631
+ },
632
+ };
633
+ }
634
+
635
+ function shouldPreferJsonlFirstPage(canonicalResponse, jsonlResponse) {
636
+ const canonicalResult = canonicalResponse?.result;
637
+ const jsonlResult = jsonlResponse?.result;
638
+ const canonicalTurnsKey = findTurnsListResultKey(canonicalResult);
639
+ const jsonlTurnsKey = findTurnsListResultKey(jsonlResult);
640
+ if (!canonicalTurnsKey || !jsonlTurnsKey) {
641
+ return false;
642
+ }
643
+ const jsonlTurn = jsonlResult[jsonlTurnsKey]?.[0];
644
+ const jsonlTurnId = turnListTurnIdentifier(jsonlTurn);
645
+ return Boolean(jsonlTurnId)
646
+ && !canonicalResult[canonicalTurnsKey].some((turn) => turnListTurnIdentifier(turn) === jsonlTurnId)
647
+ && shouldMergeLatestJsonlTurn(jsonlTurn);
648
+ }
649
+
650
+ function firstTurnsListTurnId(response) {
651
+ const result = response?.result;
652
+ const turnsKey = findTurnsListResultKey(result);
653
+ return turnsKey ? turnListTurnIdentifier(result[turnsKey]?.[0]) : "";
654
+ }
655
+
656
+ function isCoherentJsonlFirstPageResponse(response) {
657
+ const result = response?.result;
658
+ const turnsKey = findTurnsListResultKey(result);
659
+ const turns = turnsKey ? result[turnsKey] : null;
660
+ if (!Array.isArray(turns) || turns.length === 0) {
661
+ return false;
662
+ }
663
+ // A running turn must contain its materialized user opener. Otherwise an
664
+ // orphan file card or assistant tail can win the fast-page race and later
665
+ // be mistaken for a complete conversation. Explicit terminal turns are
666
+ // allowed without a user item because older compacted/system turns can be
667
+ // legitimately item-only.
668
+ const newestTurn = turns[0];
669
+ const items = Array.isArray(newestTurn?.items) ? newestTurn.items : null;
670
+ if (!items) {
671
+ return false;
672
+ }
673
+ if (items.length === 0) {
674
+ return false;
675
+ }
676
+ const status = String(newestTurn?.status || "").replace(/[_-]/g, "").toLowerCase();
677
+ const isExplicitTerminal = new Set(["completed", "failed", "aborted", "cancelled", "canceled", "interrupted"])
678
+ .has(status);
679
+ if (isExplicitTerminal) {
680
+ return true;
681
+ }
682
+ return items.some((item) => {
683
+ const role = String(item?.role || "").toLowerCase();
684
+ const type = String(item?.type || "").replace(/[_-]/g, "").toLowerCase();
685
+ return role === "user" || type === "usermessage";
686
+ });
687
+ }
688
+
689
+ function threadTurnsListResponseContainsAnchor(response, anchorTurnId) {
690
+ const result = response?.result;
691
+ const turnsKey = findTurnsListResultKey(result);
692
+ return Boolean(turnsKey) && result[turnsKey].some((turn) => (
693
+ turnListTurnIdentifier(turn) === anchorTurnId
694
+ ));
695
+ }
696
+
697
+ function alignThreadTurnsListResponseToAnchor(response, anchorTurnId) {
698
+ const result = response?.result;
699
+ const turnsKey = findTurnsListResultKey(result);
700
+ if (!turnsKey) {
701
+ return null;
702
+ }
703
+ const anchorIndex = result[turnsKey].findIndex((turn) => (
704
+ turnListTurnIdentifier(turn) === anchorTurnId
705
+ ));
706
+ if (anchorIndex < 0) {
707
+ return null;
708
+ }
709
+ if (anchorIndex === 0) {
710
+ return response;
711
+ }
712
+ return {
713
+ ...response,
714
+ result: {
715
+ ...result,
716
+ [turnsKey]: result[turnsKey].slice(anchorIndex),
717
+ },
718
+ };
719
+ }
720
+
146
721
  function startBridge({
147
722
  config: explicitConfig = null,
148
723
  printPairingQr = true,
@@ -181,6 +756,9 @@ function startBridge({
181
756
  const notificationSecret = randomBytes(24).toString("hex");
182
757
  const desktopRefresher = new CodexDesktopRefresher({
183
758
  enabled: config.refreshEnabled,
759
+ // With IPC live sync streaming content, deep-link refreshes are only needed
760
+ // to navigate Desktop onto the phone-driven thread, not to reload content.
761
+ navigationOnly: config.desktopIpcLiveSyncEnabled,
184
762
  debounceMs: config.refreshDebounceMs,
185
763
  refreshCommand: config.refreshCommand,
186
764
  bundleId: config.codexBundleId,
@@ -209,18 +787,17 @@ function startBridge({
209
787
  let relayWatchdogTimer = null;
210
788
  let lastRelayActivityAt = 0;
211
789
  let lastConnectionStatus = null;
212
- let lastConnectionError = "";
213
790
  let codexLaunchState = config.codexEndpoint ? "connected" : "starting";
214
791
  let codexHandshakeState = config.codexEndpoint ? "warm" : "cold";
215
792
  const forwardedInitializeRequestIds = new Set();
216
793
  const bridgeManagedCodexRequestWaiters = new Map();
217
794
  const forwardedRequestMethodsById = new Map();
218
795
  const relaySanitizedResponseMethodsById = new Map();
219
- const relayChannels = [];
220
- const codexResponseRoutesById = new Map();
221
- const extraRelaySessionCount = readExtraRelaySessionCount(process.env);
796
+ const desktopIpcLiveOwnerObservedInboundKeys = new Set();
222
797
  const jsonlTurnsListRolloutCacheByThread = new Map();
223
798
  const jsonlTurnsListRolloutMissCacheByThread = new Map();
799
+ const threadTurnsListFastPageCoordinator = createThreadTurnsListFastPageCoordinator();
800
+ const threadRuntimeSettingsStore = createThreadRuntimeSettingsStore();
224
801
  const trackedForwardedRequestMethods = new Set([
225
802
  "account/login/start",
226
803
  "account/login/cancel",
@@ -257,7 +834,6 @@ function startBridge({
257
834
  }
258
835
  },
259
836
  });
260
- let primaryRelayChannel = null;
261
837
  // Keeps one stable sender identity across reconnects so buffered replay state
262
838
  // reflects what actually made it onto the current relay socket.
263
839
  function sendRelayWireMessage(wireMessage) {
@@ -273,6 +849,14 @@ function startBridge({
273
849
  const rolloutLiveMirror = !config.codexEndpoint
274
850
  ? createRolloutLiveMirrorController({
275
851
  sendApplicationResponse,
852
+ // One live source per thread. The follower keeps fresh/idle Desktop state
853
+ // authoritative, but yields an active cache that stopped broadcasting;
854
+ // a later Desktop snapshot is announced as a new source epoch so the
855
+ // phone performs canonical repair instead of mixing both mirrors.
856
+ shouldSuppressThread: (threadId) => shouldSuppressRolloutMirrorForThread(
857
+ threadId,
858
+ { desktopIpcActionFollower, desktopIpcLiveOwner }
859
+ ),
276
860
  })
277
861
  : null;
278
862
  const desktopIpcActionFollower = !config.codexEndpoint
@@ -281,7 +865,29 @@ function startBridge({
281
865
  readConversationState: async (threadId) => seedConversationStateFromThreadRead(
282
866
  await sendCodexRequest("thread/read", { threadId })
283
867
  ),
868
+ forwardToLocalCodex: (rawMessage) => {
869
+ observeDesktopIpcLiveOwnerInbound(rawMessage);
870
+ forwardInboundRequestToCodex(rawMessage);
871
+ },
872
+ // Threads streamed by the bridge's own app-server must never be held,
873
+ // served from Desktop echoes, or routed over the IPC bus.
874
+ isLocallyOwnedThread: (threadId) => Boolean(desktopIpcLiveOwner?.isThreadOwned(threadId)),
875
+ normalizeTurnStartParams: normalizeTurnStartParamsForCodex,
876
+ runtimeSettingsStore: threadRuntimeSettingsStore,
284
877
  socketPath: config.desktopIpcSocketPath || undefined,
878
+ snapshotDebounceMs: config.desktopIpcSnapshotDebounceMs,
879
+ })
880
+ : null;
881
+ const desktopIpcLiveOwner = !config.codexEndpoint
882
+ ? createDesktopIpcLiveOwner({
883
+ enabled: config.desktopIpcLiveSyncEnabled !== false,
884
+ sendApplicationResponse,
885
+ sendCodexRequest,
886
+ sendRawCodexMessage: (rawMessage) => codex.send(rawMessage),
887
+ normalizeTurnStartParams: normalizeTurnStartParamsForCodex,
888
+ runtimeSettingsStore: threadRuntimeSettingsStore,
889
+ socketPath: config.desktopIpcSocketPath || undefined,
890
+ snapshotDebounceMs: config.desktopIpcSnapshotDebounceMs,
285
891
  })
286
892
  : null;
287
893
  let contextUsageWatcher = null;
@@ -293,6 +899,14 @@ function startBridge({
293
899
  appPath: config.codexAppPath,
294
900
  logPrefix: "[remodex]",
295
901
  });
902
+ const projectRegistry = createProjectRegistry();
903
+ const runtimeProviderRouter = createRuntimeProviderRouter({
904
+ sendApplicationResponse,
905
+ sendCodexRequest,
906
+ sendRuntimeMessage: sendRuntimeApplicationMessage,
907
+ projectRegistry,
908
+ logPrefix: "[remodex]",
909
+ });
296
910
  const voiceHandler = createVoiceHandler({
297
911
  sendCodexRequest,
298
912
  logPrefix: "[remodex]",
@@ -370,9 +984,11 @@ function startBridge({
370
984
  clearReconnectTimer();
371
985
  clearRelayWatchdog();
372
986
  bridgeStatusPublisher.stopHeartbeat();
987
+ runtimeProviderRouter.shutdown();
373
988
  stopContextUsageWatcher();
374
989
  rolloutLiveMirror?.stopAll();
375
990
  desktopIpcActionFollower?.stopAll();
991
+ desktopIpcLiveOwner?.stopAll();
376
992
  }
377
993
 
378
994
  function stopBridge() {
@@ -422,13 +1038,12 @@ function startBridge({
422
1038
  }
423
1039
 
424
1040
  // Keeps npm start output compact by emitting only high-signal connection states.
425
- function logConnectionStatus(status, lastError = "") {
426
- if (lastConnectionStatus === status && lastConnectionError === lastError) {
1041
+ function logConnectionStatus(status) {
1042
+ if (lastConnectionStatus === status) {
427
1043
  return;
428
1044
  }
429
1045
 
430
1046
  lastConnectionStatus = status;
431
- lastConnectionError = lastError;
432
1047
  if (status !== "connected") {
433
1048
  activePhoneSummary = null;
434
1049
  }
@@ -436,16 +1051,13 @@ function startBridge({
436
1051
  state: "running",
437
1052
  connectionStatus: status,
438
1053
  pid: process.pid,
439
- lastError,
1054
+ lastError: "",
440
1055
  });
441
1056
  console.log(`[remodex] ${status}`);
442
- if (lastError) {
443
- console.error(`[remodex] ${lastError}`);
444
- }
445
1057
  }
446
1058
 
447
1059
  // Retries the relay socket while preserving the active Codex process and session id.
448
- function scheduleRelayReconnect(closeCode, closeReason = "") {
1060
+ function scheduleRelayReconnect(closeCode) {
449
1061
  if (isShuttingDown) {
450
1062
  return;
451
1063
  }
@@ -514,7 +1126,7 @@ function startBridge({
514
1126
  }
515
1127
  },
516
1128
  onApplicationMessage(plaintextMessage) {
517
- handleApplicationMessage(plaintextMessage, primaryRelayChannel);
1129
+ handleApplicationMessage(plaintextMessage);
518
1130
  },
519
1131
  })) {
520
1132
  return;
@@ -529,12 +1141,11 @@ function startBridge({
529
1141
  markRelayActivity();
530
1142
  });
531
1143
 
532
- nextSocket.on("close", (code, reason) => {
533
- const closeReason = normalizeWebSocketCloseReason(reason);
1144
+ nextSocket.on("close", (code) => {
534
1145
  if (socket === nextSocket) {
535
1146
  clearRelayWatchdog();
536
1147
  }
537
- logConnectionStatus("disconnected", buildRelayCloseStatusError(code, closeReason));
1148
+ logConnectionStatus("disconnected");
538
1149
  if (socket === nextSocket) {
539
1150
  socket = null;
540
1151
  }
@@ -542,7 +1153,7 @@ function startBridge({
542
1153
  // Relay reconnects are transport-only: keep local live observers running
543
1154
  // so their output can enter secure replay and catch up on the next resume.
544
1155
  desktopRefresher.handleTransportReset();
545
- scheduleRelayReconnect(code, closeReason);
1156
+ scheduleRelayReconnect(code);
546
1157
  });
547
1158
 
548
1159
  nextSocket.on("error", () => {
@@ -553,191 +1164,45 @@ function startBridge({
553
1164
  });
554
1165
  }
555
1166
 
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
1167
  const pairingPayload = secureTransport.createPairingPayload();
699
1168
  const pairingSession = {
700
1169
  pairingPayload,
701
1170
  pairingCode: createShortPairingCode({ length: SHORT_PAIRING_CODE_LENGTH }),
702
1171
  };
703
- primaryRelayChannel = {
704
- label: "primary",
705
- pairingSession,
706
- secureTransport,
707
- sendWireMessage: sendRelayWireMessage,
708
- };
709
- relayChannels.push(primaryRelayChannel);
710
1172
  onPairingSession?.(pairingSession);
711
1173
  if (printPairingQr) {
712
- if (extraRelaySessionCount > 0) {
713
- console.error("[remodex] Pair device 1: scan this QR from your first device.");
714
- }
715
1174
  printQR(pairingSession);
716
1175
  }
717
1176
  pushServiceClient.logUnavailable();
718
1177
  connectRelay();
719
- startExtraRelayChannels();
720
1178
 
721
1179
  codex.onMessage((message) => {
722
- if (handleBridgeManagedCodexResponse(message)) {
1180
+ // Streaming deltas make this the hottest path in the bridge: parse the
1181
+ // envelope once and share the read-only object with every observer.
1182
+ const parsedMessage = parseBridgeMessage(message);
1183
+ if (handleBridgeManagedCodexResponse(message, parsedMessage)) {
723
1184
  return;
724
1185
  }
725
- updatePendingAuthLoginFromCodexMessage(message);
726
- trackCodexHandshakeState(message);
727
- desktopRefresher.handleOutbound(message);
728
- pushNotificationTracker.handleOutbound(message);
729
- rememberThreadFromMessage("codex", message);
730
- sendCodexOutboundToMobile(message);
1186
+ updatePendingAuthLoginFromCodexMessage(message, parsedMessage);
1187
+ trackCodexHandshakeState(message, parsedMessage);
1188
+ desktopRefresher.handleOutbound(message, parsedMessage);
1189
+ desktopIpcLiveOwner?.observeOutbound(message, parsedMessage);
1190
+ pushNotificationTracker.handleOutbound(message, parsedMessage);
1191
+ rememberThreadFromMessage("codex", message, parsedMessage);
1192
+ secureTransport.queueOutboundApplicationMessage(
1193
+ sanitizeRelayBoundCodexMessage(message, parsedMessage),
1194
+ sendRelayWireMessage
1195
+ );
731
1196
  });
732
1197
 
733
1198
  codex.onClose(() => {
734
1199
  const wasShuttingDown = isShuttingDown;
735
1200
  clearRelayWatchdog();
736
1201
  bridgeStatusPublisher.stopHeartbeat();
1202
+ logConnectionStatus("disconnected");
737
1203
  const lastError = wasShuttingDown
738
1204
  ? ""
739
- : (lastConnectionError || "Codex transport closed unexpectedly.");
740
- logConnectionStatus("disconnected", lastError);
1205
+ : "Codex transport closed unexpectedly.";
741
1206
  publishBridgeStatus({
742
1207
  state: wasShuttingDown ? "stopped" : "error",
743
1208
  connectionStatus: "disconnected",
@@ -752,44 +1217,42 @@ function startBridge({
752
1217
  desktopRefresher.handleTransportReset();
753
1218
  failBridgeManagedCodexRequests(new Error("Codex transport closed before the bridge request completed."));
754
1219
  forwardedRequestMethodsById.clear();
755
- codexResponseRoutesById.clear();
756
1220
  if (socket?.readyState === WebSocket.OPEN || socket?.readyState === WebSocket.CONNECTING) {
757
1221
  socket.close();
758
1222
  }
759
- closeExtraRelayChannels();
760
1223
  });
761
1224
 
762
1225
  process.on("SIGINT", () => shutdown(codex, () => socket, prepareBridgeShutdown));
763
1226
  process.on("SIGTERM", () => shutdown(codex, () => socket, prepareBridgeShutdown));
764
1227
 
765
1228
  // 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)) {
1229
+ function handleApplicationMessage(rawMessage) {
1230
+ const parsedMessage = parseBridgeMessage(rawMessage);
1231
+ if (handleBridgeManagedHandshakeMessage(rawMessage, sendApplicationResponse, parsedMessage)) {
769
1232
  return;
770
1233
  }
771
- if (handleBridgeManagedAccountRequest(rawMessage, sendResponse)) {
1234
+ if (handleBridgeManagedAccountRequest(rawMessage, sendApplicationResponse, parsedMessage)) {
772
1235
  return;
773
1236
  }
774
- if (voiceHandler.handleVoiceRequest(rawMessage, sendResponse)) {
1237
+ if (voiceHandler.handleVoiceRequest(rawMessage, sendApplicationResponse, parsedMessage)) {
775
1238
  return;
776
1239
  }
777
- if (handleThreadContextRequest(rawMessage, sendResponse)) {
1240
+ if (handleThreadContextRequest(rawMessage, sendApplicationResponse, parsedMessage)) {
778
1241
  return;
779
1242
  }
780
- if (handleWorkspaceRequest(rawMessage, sendResponse)) {
1243
+ if (handleWorkspaceRequest(rawMessage, sendApplicationResponse)) {
781
1244
  return;
782
1245
  }
783
- if (handleProjectRequest(rawMessage, sendResponse)) {
1246
+ if (handleProjectRequest(rawMessage, sendApplicationResponse, { projectRegistry })) {
784
1247
  return;
785
1248
  }
786
- if (handlePetRequest(rawMessage, sendResponse)) {
1249
+ if (handlePetRequest(rawMessage, sendApplicationResponse)) {
787
1250
  return;
788
1251
  }
789
- if (notificationsHandler.handleNotificationsRequest(rawMessage, sendResponse)) {
1252
+ if (notificationsHandler.handleNotificationsRequest(rawMessage, sendApplicationResponse)) {
790
1253
  return;
791
1254
  }
792
- if (handleDesktopRequest(rawMessage, sendResponse, {
1255
+ if (handleDesktopRequest(rawMessage, sendApplicationResponse, {
793
1256
  bundleId: config.codexBundleId,
794
1257
  appPath: config.codexAppPath,
795
1258
  readBridgePreferences,
@@ -797,200 +1260,102 @@ function startBridge({
797
1260
  updateBridgePackageAndRestart,
798
1261
  })) {
799
1262
  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) {
1263
+ }
1264
+ if (handleGitRequest(rawMessage, sendApplicationResponse, {
1265
+ codexAppPath: config.codexAppPath,
1266
+ onThreadNameSet: sendThreadNameUpdatedNotification,
1267
+ })) {
835
1268
  return;
836
1269
  }
837
-
838
- const sanitizedMessage = sanitizeRelayBoundCodexMessage(rawMessage);
839
- for (const relayChannel of normalizedChannels) {
840
- queueSanitizedApplicationMessageToChannel(sanitizedMessage, relayChannel);
1270
+ desktopRefresher.handleInbound(rawMessage, parsedMessage);
1271
+ rolloutLiveMirror?.observeInbound(rawMessage, parsedMessage);
1272
+ // Track the request method BEFORE follower interception: responses the
1273
+ // follower serves from projected Desktop state must hit the same relay
1274
+ // sanitize/trim budget as app-server responses, or heavy threads ship as
1275
+ // one oversized frame and kill the phone's websocket (EMSGSIZE).
1276
+ rememberForwardedRequestMethod(rawMessage);
1277
+ if (desktopIpcActionFollower?.observeInbound(rawMessage, parsedMessage)) {
1278
+ return;
841
1279
  }
842
- }
843
-
844
- function queueSanitizedApplicationMessageToChannel(sanitizedMessage, relayChannel) {
845
- if (!relayChannel) {
1280
+ if (runtimeProviderRouter.handleApplicationMessage(rawMessage, {
1281
+ sendResponse: sendApplicationResponse,
1282
+ })) {
1283
+ return;
1284
+ }
1285
+ observeDesktopIpcLiveOwnerInbound(rawMessage, parsedMessage);
1286
+ if (handleBridgeManagedThreadTurnsListRequest(rawMessage, sendApplicationResponse)) {
846
1287
  return;
847
1288
  }
1289
+ forwardInboundRequestToCodex(rawMessage);
1290
+ }
848
1291
 
849
- relayChannel.secureTransport.queueOutboundApplicationMessage(
850
- sanitizedMessage,
851
- relayChannel.sendWireMessage
1292
+ function forwardInboundRequestToCodex(rawMessage) {
1293
+ const codexRequest = stripRuntimeProviderFieldsForCodex(
1294
+ normalizeTurnStartForCodex(rawMessage)
852
1295
  );
1296
+ rememberKnownProjectFromRequest("codex-request", codexRequest);
1297
+ rememberForwardedRequestMethod(rawMessage);
1298
+ rememberThreadFromMessage("phone", codexRequest);
1299
+ codex.send(codexRequest);
853
1300
  }
854
1301
 
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));
1302
+ // Held Desktop-ownership probes can later fall back locally, so observe each
1303
+ // phone request at most once in the live owner even if it passes both paths.
1304
+ function observeDesktopIpcLiveOwnerInbound(rawMessage, parsedMessage = null) {
1305
+ if (!desktopIpcLiveOwner) {
1306
+ return;
868
1307
  }
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);
1308
+ const inboundKey = desktopIpcLiveOwnerInboundKey(rawMessage, parsedMessage);
1309
+ if (inboundKey) {
1310
+ if (desktopIpcLiveOwnerObservedInboundKeys.has(inboundKey)) {
894
1311
  return;
895
1312
  }
1313
+ desktopIpcLiveOwnerObservedInboundKeys.add(inboundKey);
1314
+ evictOldestEntries(desktopIpcLiveOwnerObservedInboundKeys, FORWARDED_REQUEST_METHODS_MAX_SIZE);
896
1315
  }
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
- }
1316
+ desktopIpcLiveOwner.observeInbound(rawMessage, parsedMessage);
908
1317
  }
909
1318
 
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;
1319
+ function desktopIpcLiveOwnerInboundKey(rawMessage, parsedMessage = null) {
1320
+ const parsed = parsedMessage ?? safeParseJSON(rawMessage);
1321
+ const method = typeof parsed?.method === "string" ? parsed.method : "";
1322
+ if (!method || parsed?.id == null) {
1323
+ return "";
920
1324
  }
921
-
922
- sendApplicationResponseToChannels(JSON.stringify(mirrorNotification), peerChannels);
1325
+ // extractThreadId only understands turn/thread start and completion params;
1326
+ // archive, steer, interrupt, and compact requests need the generic fields so
1327
+ // same-id requests for different threads never share a dedupe key.
1328
+ const threadId = extractThreadId(method, parsed.params)
1329
+ || readString(parsed?.params?.threadId)
1330
+ || readString(parsed?.params?.thread_id)
1331
+ || readString(parsed?.params?.conversationId)
1332
+ || readString(parsed?.params?.conversation_id)
1333
+ || "";
1334
+ return `${method}:${threadId}:${String(parsed.id)}`;
923
1335
  }
924
1336
 
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) {
1337
+ function parseBridgeMessage(rawMessage) {
1338
+ try {
1339
+ return JSON.parse(rawMessage);
1340
+ } catch {
936
1341
  return null;
937
1342
  }
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
1343
  }
953
1344
 
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);
1345
+ // Encrypts bridge-generated responses instead of letting the relay see plaintext.
1346
+ function sendApplicationResponse(rawMessage) {
1347
+ secureTransport.queueOutboundApplicationMessage(
1348
+ sanitizeRelayBoundCodexMessage(rawMessage),
1349
+ sendRelayWireMessage
1350
+ );
961
1351
  }
962
1352
 
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"));
1353
+ // Provider output keeps the same desktop refresh, push, and secure relay side effects as Codex output.
1354
+ function sendRuntimeApplicationMessage(provider, rawMessage) {
1355
+ desktopRefresher.handleOutbound(rawMessage);
1356
+ pushNotificationTracker.handleOutbound(rawMessage);
1357
+ rememberThreadFromMessage(provider, rawMessage);
1358
+ sendApplicationResponse(rawMessage);
994
1359
  }
995
1360
 
996
1361
  // Mirrors accepted local renames back to the phone using the existing push-event shape.
@@ -1020,25 +1385,41 @@ function startBridge({
1020
1385
 
1021
1386
  rememberThreadFromMessage("phone", rawMessage);
1022
1387
  (async () => {
1388
+ let didRespond = false;
1389
+ const respondOnce = (payload) => {
1390
+ if (didRespond) {
1391
+ return;
1392
+ }
1393
+ didRespond = true;
1394
+ sendResponse(payload);
1395
+ };
1023
1396
  try {
1024
- const response = await fetchAdaptiveThreadTurnsListForRelay(request, {
1025
- fetchPage: (params) => sendCodexRequest("thread/turns/list", params),
1397
+ const selection = await threadTurnsListFastPageCoordinator.resolve(request, {
1398
+ fetchCanonical: (canonicalRequest) => fetchAdaptiveThreadTurnsListForRelay(canonicalRequest, {
1399
+ fetchPage: (params) => sendCodexRequest("thread/turns/list", params),
1400
+ }),
1401
+ readJsonl: (jsonlRequest) => maybeBuildJsonlThreadTurnsListFallback(jsonlRequest, null),
1026
1402
  });
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(),
1403
+ let responsePayload = selection.response;
1404
+ if (selection.source === "canonical" && selection.jsonlFallback?.response?.result) {
1405
+ responsePayload = maybeMergeLatestJsonlTurnIntoTurnsListResponse(
1406
+ request,
1407
+ selection.response,
1408
+ selection.jsonlFallback.response.result
1409
+ ) || selection.response;
1410
+ }
1411
+ sendBridgeManagedThreadTurnsListResponse(request, responsePayload, respondOnce, {
1412
+ skipJsonlArtifactAugmentation: selection.usesJsonl,
1034
1413
  });
1035
- sendResponse(sanitizeThreadHistoryImagesForRelay(
1036
- JSON.stringify(responsePayload),
1037
- "thread/turns/list",
1038
- finalSanitizeContext
1039
- ));
1040
1414
  } catch (error) {
1041
- sendResponse(createJsonRpcErrorResponse(
1415
+ const jsonlFallback = maybeBuildJsonlThreadTurnsListFallback(request, null);
1416
+ if (jsonlFallback?.response && isCoherentJsonlFirstPageResponse(jsonlFallback.response)) {
1417
+ sendBridgeManagedThreadTurnsListResponse(request, jsonlFallback.response, respondOnce, {
1418
+ skipJsonlArtifactAugmentation: true,
1419
+ });
1420
+ return;
1421
+ }
1422
+ respondOnce(createJsonRpcErrorResponse(
1042
1423
  request.id,
1043
1424
  error,
1044
1425
  "thread_turns_list_failed"
@@ -1049,16 +1430,34 @@ function startBridge({
1049
1430
  return true;
1050
1431
  }
1051
1432
 
1433
+ function sendBridgeManagedThreadTurnsListResponse(request, response, sendResponse, {
1434
+ skipJsonlArtifactAugmentation = false,
1435
+ } = {}) {
1436
+ const finalSanitizeContext = buildThreadTurnsListRelaySanitizeContext(request, {
1437
+ skipJsonlArtifactAugmentation,
1438
+ });
1439
+ relaySanitizedResponseMethodsById.set(String(request.id), {
1440
+ method: "thread/turns/list",
1441
+ ...finalSanitizeContext,
1442
+ createdAt: Date.now(),
1443
+ });
1444
+ sendResponse(sanitizeThreadHistoryImagesForRelay(
1445
+ JSON.stringify(response),
1446
+ "thread/turns/list",
1447
+ finalSanitizeContext
1448
+ ));
1449
+ }
1450
+
1052
1451
  function maybeBuildJsonlThreadTurnsListFallback(request, response) {
1053
1452
  const params = request?.params || {};
1054
1453
  const threadId = normalizeNonEmptyString(params.threadId)
1055
1454
  || normalizeNonEmptyString(params.thread_id);
1056
- if (!threadId || hasRelayCursor(params.cursor)) {
1455
+ if (!threadId || hasRelayCursor(params.cursor) || params.remodexRequireCanonical === true) {
1057
1456
  return null;
1058
1457
  }
1059
1458
 
1060
1459
  try {
1061
- const responseIsEmpty = isEmptyTurnsListResponse(response);
1460
+ const responseIsEmpty = response == null || isEmptyTurnsListResponse(response);
1062
1461
  const rolloutPath = resolveJsonlTurnsListRolloutPathForFallback({
1063
1462
  threadId,
1064
1463
  responseIsEmpty,
@@ -1069,10 +1468,20 @@ function startBridge({
1069
1468
  return null;
1070
1469
  }
1071
1470
 
1471
+ // A first page is the local baseline for a newly opened thread. Honor a
1472
+ // caller's larger request, but never manufacture the old one-turn tail:
1473
+ // it has no room to preserve surrounding history while canonical data is
1474
+ // still catching up.
1475
+ const requestedLimit = Number.isInteger(params.limit) && params.limit > 0
1476
+ ? params.limit
1477
+ : RELAY_TURNS_LIST_MAX_INITIAL_LIMIT;
1478
+ const firstPageLimit = params.cursor == null
1479
+ ? Math.max(requestedLimit, RELAY_TURNS_LIST_MAX_INITIAL_LIMIT)
1480
+ : requestedLimit;
1072
1481
  const result = readThreadTurnsListPageFromSessionJsonl(rolloutPath, {
1073
1482
  threadId,
1074
- limit: params.limit,
1075
- maxLimit: 1,
1483
+ limit: firstPageLimit,
1484
+ maxLimit: RELAY_TURNS_LIST_MAX_INITIAL_LIMIT,
1076
1485
  cursor: params.cursor,
1077
1486
  });
1078
1487
  const turnsKey = findTurnsListResultKey(result);
@@ -1081,7 +1490,7 @@ function startBridge({
1081
1490
  }
1082
1491
 
1083
1492
  if (!responseIsEmpty) {
1084
- const mergedResponse = maybeMergeLatestJsonlTurnIntoTurnsListResponse(request, response, result, params);
1493
+ const mergedResponse = maybeMergeLatestJsonlTurnIntoTurnsListResponse(request, response, result);
1085
1494
  return mergedResponse ? { response: mergedResponse, usesJsonl: true } : null;
1086
1495
  }
1087
1496
 
@@ -1147,11 +1556,9 @@ function startBridge({
1147
1556
 
1148
1557
  // Handles the bridge-owned auth status wrappers without exposing tokens to the phone.
1149
1558
  // 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 {
1559
+ function handleBridgeManagedAccountRequest(rawMessage, sendResponse, parsedMessage = null) {
1560
+ const parsed = parsedMessage || parseBridgeMessage(rawMessage);
1561
+ if (!parsed) {
1155
1562
  return false;
1156
1563
  }
1157
1564
 
@@ -1306,16 +1713,30 @@ function startBridge({
1306
1713
  }
1307
1714
 
1308
1715
  // Replaces huge inline desktop-history images with lightweight references before relay encryption.
1309
- function sanitizeRelayBoundCodexMessage(rawMessage) {
1716
+ function sanitizeRelayBoundCodexMessage(rawMessage, parsedMessage = null) {
1310
1717
  pruneExpiredForwardedRequestMethods();
1311
- const normalizedMessage = normalizeRelayBoundJsonRpcMessage(rawMessage, {
1718
+ let normalizedMessage = normalizeRelayBoundJsonRpcMessage(rawMessage, {
1312
1719
  pendingRequestMethodsById: relaySanitizedResponseMethodsById,
1720
+ parsedMessage,
1313
1721
  });
1314
1722
  if (!normalizedMessage) {
1315
1723
  return null;
1316
1724
  }
1317
1725
 
1318
- const parsed = safeParseJSON(normalizedMessage);
1726
+ // Streaming deltas hit this path dozens of times per second; when the
1727
+ // envelope passed through normalization untouched, reuse the parse the
1728
+ // caller already paid for instead of re-parsing the same bytes.
1729
+ let parsed = normalizedMessage === rawMessage && parsedMessage
1730
+ ? parsedMessage
1731
+ : safeParseJSON(normalizedMessage);
1732
+ const sanitizedLiveMessage = sanitizeLiveUserNotification(parsed);
1733
+ if (!sanitizedLiveMessage) {
1734
+ return null;
1735
+ }
1736
+ if (sanitizedLiveMessage !== parsed) {
1737
+ parsed = sanitizedLiveMessage;
1738
+ normalizedMessage = JSON.stringify(parsed);
1739
+ }
1319
1740
  const responseId = parsed?.id;
1320
1741
  if (responseId == null) {
1321
1742
  return sanitizeLiveGeneratedImageMessageForRelay(normalizedMessage);
@@ -1327,12 +1748,19 @@ function startBridge({
1327
1748
  }
1328
1749
  relaySanitizedResponseMethodsById.delete(String(responseId));
1329
1750
 
1751
+ if (trackedRequest.method === "thread/list"
1752
+ || trackedRequest.method === "thread/read"
1753
+ || trackedRequest.method === "thread/resume") {
1754
+ threadRuntimeSettingsStore.enrichResponse(trackedRequest.method, parsed);
1755
+ normalizedMessage = JSON.stringify(parsed);
1756
+ }
1757
+
1330
1758
  return sanitizeThreadHistoryImagesForRelay(normalizedMessage, trackedRequest.method, trackedRequest);
1331
1759
  }
1332
1760
 
1333
- function updatePendingAuthLoginFromCodexMessage(rawMessage) {
1761
+ function updatePendingAuthLoginFromCodexMessage(rawMessage, parsedMessage = null) {
1334
1762
  pruneExpiredForwardedRequestMethods();
1335
- const parsed = safeParseJSON(rawMessage);
1763
+ const parsed = parsedMessage ?? safeParseJSON(rawMessage);
1336
1764
  const responseId = parsed?.id;
1337
1765
  if (responseId != null) {
1338
1766
  const trackedRequest = forwardedRequestMethodsById.get(String(responseId));
@@ -1405,6 +1833,7 @@ function startBridge({
1405
1833
  evictOldestEntries(jsonlArtifactItemsCacheByThread, RELAY_JSONL_ARTIFACT_CACHE_MAX_ENTRIES);
1406
1834
  evictOldestEntries(jsonlTurnsListRolloutCacheByThread, JSONL_ROLLOUT_PATH_CACHE_MAX_SIZE);
1407
1835
  evictOldestEntries(jsonlTurnsListRolloutMissCacheByThread, JSONL_ROLLOUT_PATH_CACHE_MAX_SIZE);
1836
+ evictOldestEntries(jsonlThreadCwdCacheByThread, JSONL_ROLLOUT_PATH_CACHE_MAX_SIZE);
1408
1837
  }
1409
1838
 
1410
1839
  function safeParseJSON(value) {
@@ -1415,8 +1844,8 @@ function startBridge({
1415
1844
  }
1416
1845
  }
1417
1846
 
1418
- function rememberThreadFromMessage(source, rawMessage) {
1419
- const context = extractBridgeMessageContext(rawMessage);
1847
+ function rememberThreadFromMessage(source, rawMessage, parsedMessage = null) {
1848
+ const context = extractBridgeMessageContext(rawMessage, parsedMessage);
1420
1849
  if (!context.threadId) {
1421
1850
  return;
1422
1851
  }
@@ -1427,6 +1856,33 @@ function startBridge({
1427
1856
  }
1428
1857
  }
1429
1858
 
1859
+ // Captures explicit cwd selections before Codex creates the first thread, so
1860
+ // provider-neutral pickers do not depend on a later provider-specific message.
1861
+ function rememberKnownProjectFromRequest(source, rawMessage) {
1862
+ const parsed = safeParseJSON(rawMessage);
1863
+ const method = typeof parsed?.method === "string" ? parsed.method.trim() : "";
1864
+ if (method !== "thread/start" && method !== "turn/start") {
1865
+ return;
1866
+ }
1867
+
1868
+ const params = parsed?.params || {};
1869
+ const cwd = normalizeNonEmptyString(
1870
+ params.cwd || params.current_working_directory || params.working_directory
1871
+ );
1872
+ if (!cwd) {
1873
+ return;
1874
+ }
1875
+
1876
+ try {
1877
+ projectRegistry.rememberProjectPath(cwd, {
1878
+ source,
1879
+ provider: "codex",
1880
+ });
1881
+ } catch {
1882
+ // Registry persistence is best-effort; thread creation must keep flowing.
1883
+ }
1884
+ }
1885
+
1430
1886
  // Mirrors CodexMonitor's persisted token_count fallback so the phone keeps
1431
1887
  // receiving context-window usage even when the runtime omits live thread usage.
1432
1888
  function ensureContextUsageWatcher({ threadId, turnId }) {
@@ -1493,11 +1949,9 @@ function startBridge({
1493
1949
  // The spawned/shared Codex app-server stays warm across phone reconnects.
1494
1950
  // When iPhone reconnects it sends initialize again, but forwarding that to the
1495
1951
  // 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 {
1952
+ function handleBridgeManagedHandshakeMessage(rawMessage, sendResponse = sendApplicationResponse, parsedMessage = null) {
1953
+ const parsed = parsedMessage || parseBridgeMessage(rawMessage);
1954
+ if (!parsed) {
1501
1955
  return false;
1502
1956
  }
1503
1957
 
@@ -1602,11 +2056,9 @@ function startBridge({
1602
2056
  }
1603
2057
 
1604
2058
  // 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 {
2059
+ function trackCodexHandshakeState(rawMessage, parsedMessage = null) {
2060
+ const parsed = parsedMessage ?? safeParseJSON(rawMessage);
2061
+ if (!parsed) {
1610
2062
  return;
1611
2063
  }
1612
2064
 
@@ -1670,11 +2122,9 @@ function startBridge({
1670
2122
 
1671
2123
  // Intercepts responses for bridge-private requests so only user-visible app-server traffic
1672
2124
  // is forwarded back through secure transport.
1673
- function handleBridgeManagedCodexResponse(rawMessage) {
1674
- let parsed = null;
1675
- try {
1676
- parsed = JSON.parse(rawMessage);
1677
- } catch {
2125
+ function handleBridgeManagedCodexResponse(rawMessage, parsedMessage = null) {
2126
+ const parsed = parsedMessage ?? safeParseJSON(rawMessage);
2127
+ if (!parsed) {
1678
2128
  return false;
1679
2129
  }
1680
2130
 
@@ -1734,11 +2184,6 @@ function startBridge({
1734
2184
  function sendRelayRegistrationUpdate(nextDeviceState) {
1735
2185
  deviceState = nextDeviceState;
1736
2186
  if (socket?.readyState !== WebSocket.OPEN) {
1737
- for (const relayChannel of relayChannels) {
1738
- if (relayChannel !== primaryRelayChannel) {
1739
- sendExtraRelayRegistrationUpdate(relayChannel, nextDeviceState);
1740
- }
1741
- }
1742
2187
  return;
1743
2188
  }
1744
2189
 
@@ -1746,11 +2191,6 @@ function startBridge({
1746
2191
  kind: "relayMacRegistration",
1747
2192
  registration: buildMacRegistration(nextDeviceState, pairingSession),
1748
2193
  }));
1749
- for (const relayChannel of relayChannels) {
1750
- if (relayChannel !== primaryRelayChannel) {
1751
- sendExtraRelayRegistrationUpdate(relayChannel, nextDeviceState);
1752
- }
1753
- }
1754
2194
  }
1755
2195
 
1756
2196
  function readBridgePreferences() {
@@ -1832,6 +2272,9 @@ function startBridge({
1832
2272
  stdio: "ignore",
1833
2273
  env: process.env,
1834
2274
  });
2275
+ child.on?.("error", (error) => {
2276
+ console.warn(`[remodex] Failed to schedule the post-update bridge restart: ${error?.message || error}`);
2277
+ });
1835
2278
  child.unref?.();
1836
2279
  }, BRIDGE_RESTART_AFTER_UPDATE_DELAY_MS);
1837
2280
  restartTimer.unref?.();
@@ -1932,7 +2375,7 @@ function createMacOSBridgeWakeAssertion({
1932
2375
  };
1933
2376
  }
1934
2377
 
1935
- // Registers the canonical Mac identity; legacy relay headers can expose one trusted device for auto-resolve.
2378
+ // Registers the canonical Mac identity and the one trusted phone allowed for auto-resolve.
1936
2379
  function buildMacRegistrationHeaders(deviceState, pairingSession) {
1937
2380
  const registration = buildMacRegistration(deviceState, pairingSession);
1938
2381
  const headers = {
@@ -1950,20 +2393,6 @@ function buildMacRegistrationHeaders(deviceState, pairingSession) {
1950
2393
  return headers;
1951
2394
  }
1952
2395
 
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
2396
  function buildMacRegistration(deviceState, pairingSession) {
1968
2397
  const trustedPhoneEntry = Object.entries(deviceState?.trustedPhones || {})[0] || null;
1969
2398
  return {
@@ -2021,7 +2450,7 @@ function shortFingerprint(value) {
2021
2450
  return createHash("sha256").update(normalized).digest("hex").slice(0, 8);
2022
2451
  }
2023
2452
 
2024
- function shutdown(codex, getSocket, beforeExit = () => {}, { exitCode = 0 } = {}) {
2453
+ function shutdown(codex, getSocket, beforeExit = () => {}) {
2025
2454
  beforeExit();
2026
2455
 
2027
2456
  const socket = getSocket();
@@ -2031,41 +2460,7 @@ function shutdown(codex, getSocket, beforeExit = () => {}, { exitCode = 0 } = {}
2031
2460
 
2032
2461
  codex.shutdown();
2033
2462
 
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}).`;
2463
+ setTimeout(() => process.exit(0), 100);
2069
2464
  }
2070
2465
 
2071
2466
  // Forces app-server summary generation off for models whose Responses API calls
@@ -2097,17 +2492,86 @@ function disableUnsupportedReasoningSummaryForTurnStart(rawMessage) {
2097
2492
  });
2098
2493
  }
2099
2494
 
2495
+ function normalizeTurnStartParamsForCodex(params) {
2496
+ const normalizedRawMessage = normalizeTurnStartForCodex(JSON.stringify({
2497
+ method: "turn/start",
2498
+ params,
2499
+ }));
2500
+ const parsed = parseBridgeJSON(normalizedRawMessage);
2501
+ return parsed?.params && typeof parsed.params === "object" && !Array.isArray(parsed.params)
2502
+ ? parsed.params
2503
+ : params;
2504
+ }
2505
+
2506
+ // A turn/start can carry the same runtime choice twice: in the legacy top-level
2507
+ // model/effort fields and in collaborationMode.settings. Codex treats the nested
2508
+ // collaboration settings as authoritative, so a stale Desktop value there can
2509
+ // silently override the model selected on the phone. Keep both representations
2510
+ // aligned before either direct app-server forwarding or Desktop-follower routing.
2511
+ function normalizeTurnStartForCodex(rawMessage) {
2512
+ const parsed = parseBridgeJSON(rawMessage);
2513
+ if (!parsed || parsed.method !== "turn/start") {
2514
+ return rawMessage;
2515
+ }
2516
+
2517
+ const params = parsed.params && typeof parsed.params === "object" && !Array.isArray(parsed.params)
2518
+ ? parsed.params
2519
+ : null;
2520
+ if (!params) {
2521
+ return rawMessage;
2522
+ }
2523
+
2524
+ const model = normalizeNonEmptyString(params.model);
2525
+ const effort = normalizeNonEmptyString(params.effort);
2526
+ let changed = false;
2527
+ let nextParams = params;
2528
+
2529
+ for (const collaborationKey of ["collaborationMode", "collaboration_mode"]) {
2530
+ const collaborationMode = nextParams[collaborationKey];
2531
+ const settings = collaborationMode?.settings;
2532
+ if (!settings || typeof settings !== "object" || Array.isArray(settings)) {
2533
+ continue;
2534
+ }
2535
+
2536
+ const nextSettings = { ...settings };
2537
+ let settingsChanged = false;
2538
+ if (model && normalizeNonEmptyString(settings.model) !== model) {
2539
+ nextSettings.model = model;
2540
+ settingsChanged = true;
2541
+ }
2542
+ if (effort && normalizeNonEmptyString(settings.reasoning_effort) !== effort) {
2543
+ nextSettings.reasoning_effort = effort;
2544
+ settingsChanged = true;
2545
+ }
2546
+ if (!settingsChanged) {
2547
+ continue;
2548
+ }
2549
+
2550
+ nextParams = {
2551
+ ...nextParams,
2552
+ [collaborationKey]: {
2553
+ ...collaborationMode,
2554
+ settings: nextSettings,
2555
+ },
2556
+ };
2557
+ changed = true;
2558
+ }
2559
+
2560
+ const alignedRawMessage = changed
2561
+ ? JSON.stringify({ ...parsed, params: nextParams })
2562
+ : rawMessage;
2563
+ return disableUnsupportedReasoningSummaryForTurnStart(alignedRawMessage);
2564
+ }
2565
+
2100
2566
  function readTurnStartModel(params) {
2101
2567
  return normalizeNonEmptyString(params?.model).toLowerCase()
2102
2568
  || normalizeNonEmptyString(params?.collaborationMode?.settings?.model).toLowerCase()
2103
2569
  || normalizeNonEmptyString(params?.collaboration_mode?.settings?.model).toLowerCase();
2104
2570
  }
2105
2571
 
2106
- function extractBridgeMessageContext(rawMessage) {
2107
- let parsed = null;
2108
- try {
2109
- parsed = JSON.parse(rawMessage);
2110
- } catch {
2572
+ function extractBridgeMessageContext(rawMessage, parsedMessage = null) {
2573
+ const parsed = parsedMessage ?? parseBridgeJSON(rawMessage);
2574
+ if (!parsed) {
2111
2575
  return { method: "", threadId: null, turnId: null };
2112
2576
  }
2113
2577
 
@@ -2269,6 +2733,7 @@ async function fetchAdaptiveThreadTurnsListForRelay(request, {
2269
2733
  const remaining = requestedLimit - combinedTurns.length;
2270
2734
  const pageLimit = selectAdaptiveTurnsListBatchLimit(combinedTurns.length, remaining);
2271
2735
  const pageParams = buildAdaptiveTurnsListPageParams(params, pageLimit, nextCursor);
2736
+ const responseBeforePage = response;
2272
2737
  let page;
2273
2738
 
2274
2739
  try {
@@ -2314,7 +2779,31 @@ async function fetchAdaptiveThreadTurnsListForRelay(request, {
2314
2779
  response = buildSafeTurnsListResponse(request.id, firstResult, lastResult, turnsKey, combinedTurns);
2315
2780
 
2316
2781
  if (measureSanitizedTurnsListResponseBytes(response, sanitizeForRelay, sanitizeContext) >= payloadSoftLimitBytes) {
2317
- response = buildLargestSafeTurnsListResponse({
2782
+ if (responseBeforePage) {
2783
+ // The server cursor belongs after the entire oversized batch. Return
2784
+ // the previous complete cursor boundary instead of slicing turns out
2785
+ // of this batch and making them unreachable.
2786
+ response = responseBeforePage;
2787
+ break;
2788
+ }
2789
+ if (pageTurns.length > pageLimit) {
2790
+ const completeResponse = buildCompactedCompleteTurnsListResponse({
2791
+ requestId: request.id,
2792
+ firstResult,
2793
+ lastResult,
2794
+ turnsKey,
2795
+ turns: pageTurns,
2796
+ sanitizeForRelay,
2797
+ sanitizeContext,
2798
+ payloadSoftLimitBytes,
2799
+ });
2800
+ if (!completeResponse) {
2801
+ throw new Error("thread/turns/list returned an oversized batch without a safe cursor boundary.");
2802
+ }
2803
+ response = completeResponse;
2804
+ break;
2805
+ }
2806
+ const boundedResponse = buildLargestSafeTurnsListResponse({
2318
2807
  requestId: request.id,
2319
2808
  firstResult,
2320
2809
  lastResult,
@@ -2324,7 +2813,11 @@ async function fetchAdaptiveThreadTurnsListForRelay(request, {
2324
2813
  sanitizeForRelay,
2325
2814
  sanitizeContext,
2326
2815
  payloadSoftLimitBytes,
2327
- }) ?? buildEmptyTurnsListResponse(request);
2816
+ });
2817
+ if (!boundedResponse) {
2818
+ throw new Error("The newest chat turn is too large to relay safely.");
2819
+ }
2820
+ response = boundedResponse;
2328
2821
  break;
2329
2822
  }
2330
2823
 
@@ -2347,22 +2840,10 @@ async function fetchAdaptiveThreadTurnsListForRelay(request, {
2347
2840
  }
2348
2841
  }
2349
2842
 
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
- };
2843
+ if (!response) {
2844
+ throw new Error("thread/turns/list completed without a relayable page.");
2845
+ }
2846
+ return response;
2366
2847
  }
2367
2848
 
2368
2849
  function isEmptyTurnsListResponse(response) {
@@ -2391,7 +2872,7 @@ function resolveJsonlTurnsListRolloutPathForFallback({
2391
2872
  : findAndCachePath(threadId);
2392
2873
  }
2393
2874
 
2394
- function maybeMergeLatestJsonlTurnIntoTurnsListResponse(request, response, jsonlResult, params = {}) {
2875
+ function maybeMergeLatestJsonlTurnIntoTurnsListResponse(request, response, jsonlResult) {
2395
2876
  const responseResult = response?.result;
2396
2877
  const responseTurnsKey = findTurnsListResultKey(responseResult);
2397
2878
  const jsonlTurnsKey = findTurnsListResultKey(jsonlResult);
@@ -2410,16 +2891,17 @@ function maybeMergeLatestJsonlTurnIntoTurnsListResponse(request, response, jsonl
2410
2891
  return null;
2411
2892
  }
2412
2893
 
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);
2894
+ // Keep the canonical page intact. Slicing this back to the requested limit
2895
+ // can retain the newer JSONL turn while dropping the canonical cursor anchor,
2896
+ // making that canonical turn permanently unreachable.
2897
+ const mergedTurns = [jsonlTurn, ...responseTurns];
2417
2898
  return {
2418
2899
  id: request.id,
2419
2900
  result: {
2420
2901
  ...responseResult,
2421
2902
  [responseTurnsKey]: mergedTurns,
2422
2903
  remodexJsonlMergedLatest: true,
2904
+ remodexJsonlFallback: true,
2423
2905
  },
2424
2906
  };
2425
2907
  }
@@ -2446,6 +2928,10 @@ function turnListTurnIdentifier(turn) {
2446
2928
  || normalizeNonEmptyString(turn?.turn_id);
2447
2929
  }
2448
2930
 
2931
+ function isSyntheticJsonlHistoryTurnId(turnId) {
2932
+ return normalizeNonEmptyString(turnId).startsWith("turn-line-");
2933
+ }
2934
+
2449
2935
  async function fetchSafeThreadTurnsListFallback(request, {
2450
2936
  fetchPage,
2451
2937
  now,
@@ -2460,35 +2946,29 @@ async function fetchSafeThreadTurnsListFallback(request, {
2460
2946
  const safeLimit = Math.min(requestedLimit, RELAY_TURNS_LIST_SAFE_RETRY_LIMIT);
2461
2947
  const safeParams = buildAdaptiveTurnsListPageParams(params, safeLimit, params?.cursor);
2462
2948
 
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.
2949
+ const page = await fetchMeasuredAdaptiveTurnsListPage(fetchPage, safeParams, now);
2950
+ const pageResult = unwrapAppServerPayloadResult(page.result);
2951
+ const turnsKey = findTurnsListResultKey(pageResult);
2952
+ if (!turnsKey) {
2953
+ throw new Error("thread/turns/list returned no turns array.");
2489
2954
  }
2490
2955
 
2491
- return buildEmptyTurnsListResponse(request);
2956
+ // If the normal pagination path returns a bad first page, retry once with a small page.
2957
+ // The retry response is intentionally minimal so Swift does not decode stale server metadata.
2958
+ const response = buildCompactedCompleteTurnsListResponse({
2959
+ requestId: request.id,
2960
+ firstResult: pageResult,
2961
+ lastResult: pageResult,
2962
+ turnsKey,
2963
+ turns: pageResult[turnsKey],
2964
+ sanitizeForRelay,
2965
+ sanitizeContext,
2966
+ payloadSoftLimitBytes,
2967
+ });
2968
+ if (response) {
2969
+ return response;
2970
+ }
2971
+ throw new Error("thread/turns/list returned a page that is too large to relay safely.");
2492
2972
  }
2493
2973
 
2494
2974
  async function fetchMeasuredAdaptiveTurnsListPage(fetchPage, params, now) {
@@ -2538,6 +3018,48 @@ function buildSafeTurnsListResponse(requestId, firstResult, lastResult, turnsKey
2538
3018
  };
2539
3019
  }
2540
3020
 
3021
+ function buildCompactedCompleteTurnsListResponse({
3022
+ requestId,
3023
+ firstResult,
3024
+ lastResult,
3025
+ turnsKey,
3026
+ turns,
3027
+ sanitizeForRelay,
3028
+ sanitizeContext = {},
3029
+ payloadSoftLimitBytes,
3030
+ }) {
3031
+ const response = buildSafeTurnsListResponse(
3032
+ requestId,
3033
+ firstResult,
3034
+ lastResult,
3035
+ turnsKey,
3036
+ turns
3037
+ );
3038
+ if (measureSanitizedTurnsListResponseBytes(response, sanitizeForRelay, sanitizeContext) < payloadSoftLimitBytes) {
3039
+ return response;
3040
+ }
3041
+
3042
+ for (const maxChars of [
3043
+ RELAY_HISTORY_TEXT_TAIL_LIMIT_CHARS,
3044
+ Math.floor(RELAY_HISTORY_TEXT_TAIL_LIMIT_CHARS / 4),
3045
+ 1_000,
3046
+ 0,
3047
+ ]) {
3048
+ const compacted = buildSafeTurnsListResponse(
3049
+ requestId,
3050
+ firstResult,
3051
+ lastResult,
3052
+ turnsKey,
3053
+ turns.map((turn) => compactTurnsListTurnForRelay(turn, maxChars))
3054
+ );
3055
+ compacted.result.remodexPageCompactedForRelay = true;
3056
+ if (measureSanitizedTurnsListResponseBytes(compacted, sanitizeForRelay, sanitizeContext) < payloadSoftLimitBytes) {
3057
+ return compacted;
3058
+ }
3059
+ }
3060
+ return null;
3061
+ }
3062
+
2541
3063
  // Trims oversized history pages progressively: normal page -> 5 turns -> ... -> 1 turn.
2542
3064
  function buildLargestSafeTurnsListResponse({
2543
3065
  requestId,
@@ -2639,19 +3161,47 @@ function compactEmergencySingleTurnForRelay(turn, maxChars, maxItems) {
2639
3161
  }
2640
3162
 
2641
3163
  const items = Array.isArray(turn.items) ? turn.items : [];
2642
- safeTurn.items = items.slice(-maxItems).map((item) => compactHistoryItemForRelay(item, maxChars));
3164
+ safeTurn.items = selectEmergencyHistoryItemsForRelay(items, maxItems)
3165
+ .map((item) => compactHistoryItemForRelay(item, maxChars));
2643
3166
  safeTurn.remodexEmergencySingleTurnForRelay = true;
2644
3167
  safeTurn.remodexPageCompactedForRelay = true;
2645
3168
  return safeTurn;
2646
3169
  }
2647
3170
 
3171
+ function selectEmergencyHistoryItemsForRelay(items, maxItems) {
3172
+ if (!Array.isArray(items) || items.length <= maxItems) {
3173
+ return Array.isArray(items) ? items : [];
3174
+ }
3175
+
3176
+ const selectedIndices = new Set();
3177
+ const firstUserIndex = items.findIndex((item) => isUserRoleItem(item));
3178
+ if (firstUserIndex >= 0) {
3179
+ selectedIndices.add(firstUserIndex);
3180
+ }
3181
+ for (let index = items.length - 1; index >= 0 && selectedIndices.size < maxItems; index -= 1) {
3182
+ const type = normalizeHistoryItemToken(items[index]?.type);
3183
+ if (type === "plan" || type === "filechange") {
3184
+ selectedIndices.add(index);
3185
+ }
3186
+ }
3187
+ for (let index = items.length - 1; index >= 0 && selectedIndices.size < maxItems; index -= 1) {
3188
+ selectedIndices.add(index);
3189
+ }
3190
+ return [...selectedIndices]
3191
+ .sort((left, right) => left - right)
3192
+ .map((index) => items[index]);
3193
+ }
3194
+
2648
3195
  function buildAdaptiveTurnsListResult(firstResult, lastResult, turnsKey, turns) {
2649
3196
  const result = {};
2650
3197
  result[turnsKey] = turns;
2651
3198
 
2652
3199
  for (const key of RELAY_TURNS_LIST_PAGINATION_RESULT_KEYS) {
2653
- if (Object.prototype.hasOwnProperty.call(lastResult, key)) {
2654
- result[key] = lastResult[key];
3200
+ const sourceResult = RELAY_TURNS_LIST_PREVIOUS_PAGINATION_RESULT_KEYS.has(key)
3201
+ ? firstResult
3202
+ : lastResult;
3203
+ if (Object.prototype.hasOwnProperty.call(sourceResult, key)) {
3204
+ result[key] = sourceResult[key];
2655
3205
  } else {
2656
3206
  delete result[key];
2657
3207
  }
@@ -2698,8 +3248,10 @@ function measureSanitizedTurnsListResponseBytes(response, sanitizeForRelay, requ
2698
3248
  // Keeps app-server responses in the JSON-RPC shape that the App Store iOS client decodes.
2699
3249
  function normalizeRelayBoundJsonRpcMessage(rawMessage, {
2700
3250
  pendingRequestMethodsById = null,
3251
+ // Optional pre-parsed envelope shared by the caller; treated as read-only.
3252
+ parsedMessage = null,
2701
3253
  } = {}) {
2702
- const parsed = parseBridgeJSON(rawMessage);
3254
+ const parsed = parsedMessage ?? parseBridgeJSON(rawMessage);
2703
3255
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
2704
3256
  return null;
2705
3257
  }
@@ -2818,11 +3370,32 @@ function sanitizeThreadHistoryImagesForRelay(rawMessage, requestMethod, requestC
2818
3370
  || normalizeNonEmptyString(thread.id)
2819
3371
  || normalizeNonEmptyString(thread.threadId)
2820
3372
  || 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
3373
 
2825
- if (!didSanitize && !didAugment && !didAugmentThreadMetadata) {
3374
+ // Oversized histories get their turn window trimmed before the per-turn sanitize
3375
+ // and augment passes so full-history work is not spent on turns the payload
3376
+ // budget discards anyway. The byte-budget trim below still enforces the cap.
3377
+ const didPreTrimTurnWindow = Buffer.byteLength(rawMessage, "utf8") > RELAY_THREAD_PAYLOAD_SOFT_LIMIT_BYTES
3378
+ && thread.turns.length > RELAY_HISTORY_RECENT_TURN_TARGET;
3379
+ const workingTurns = didPreTrimTurnWindow
3380
+ ? thread.turns.slice(-RELAY_HISTORY_RECENT_TURN_TARGET)
3381
+ : thread.turns;
3382
+ const workingThread = didPreTrimTurnWindow ? { ...thread, turns: workingTurns } : thread;
3383
+ const trimOptions = didPreTrimTurnWindow
3384
+ ? {
3385
+ preOmittedTurnCount: thread.turns.length - workingTurns.length,
3386
+ compactionIdSource: thread.turns[0],
3387
+ }
3388
+ : {};
3389
+
3390
+ const { turns: sanitizedTurns, didSanitize } = sanitizeRelayHistoryTurns(workingTurns, threadId);
3391
+ const { thread: threadWithJsonlMetadata, didAugment: didAugmentThreadMetadata } = augmentRelayThreadWithJsonlMetadata(workingThread, threadId);
3392
+ const { turns: augmentedTurns, didAugment } = augmentRelayHistoryTurnsWithJsonlArtifacts(
3393
+ sanitizedTurns,
3394
+ threadId,
3395
+ { includeHistoryItems: true }
3396
+ );
3397
+
3398
+ if (!didSanitize && !didAugment && !didAugmentThreadMetadata && !didPreTrimTurnWindow) {
2826
3399
  const trimmedPayload = trimThreadPayloadForRelay(parsed, thread);
2827
3400
  return trimmedPayload == null ? rawMessage : trimmedPayload;
2828
3401
  }
@@ -2838,7 +3411,7 @@ function sanitizeThreadHistoryImagesForRelay(rawMessage, requestMethod, requestC
2838
3411
  },
2839
3412
  });
2840
3413
 
2841
- return trimThreadPayloadForRelay(parseBridgeJSON(sanitizedPayload), null) ?? sanitizedPayload;
3414
+ return trimThreadPayloadForRelay(parseBridgeJSON(sanitizedPayload), null, trimOptions) ?? sanitizedPayload;
2842
3415
  }
2843
3416
 
2844
3417
  function sanitizeThreadTurnsListForRelay(rawMessage, requestContext = {}) {
@@ -2906,27 +3479,104 @@ function readJsonlThreadCwd(threadId) {
2906
3479
  return "";
2907
3480
  }
2908
3481
 
3482
+ const sessionsRoot = resolveSessionsRoot();
3483
+ const cacheKey = buildJsonlThreadCacheKey(sessionsRoot, normalizedThreadId);
3484
+
2909
3485
  try {
2910
- const rolloutPath = findRecentRolloutFileForContextRead(resolveSessionsRoot(), { threadId: normalizedThreadId });
3486
+ const rolloutPath = findRecentRolloutFileForContextRead(sessionsRoot, { threadId: normalizedThreadId });
2911
3487
  if (!rolloutPath) {
2912
3488
  return "";
2913
3489
  }
2914
3490
 
2915
- const metadata = parseSessionJsonlMetadata(fs.readFileSync(rolloutPath, "utf8"));
2916
- const cwd = normalizeNonEmptyString(metadata?.cwd);
2917
- return cwd && path.isAbsolute(cwd) ? cwd : "";
3491
+ const cached = readCachedJsonlThreadCwd(cacheKey, rolloutPath);
3492
+ if (cached) {
3493
+ return cached.cwd;
3494
+ }
3495
+
3496
+ return readAndCacheJsonlThreadCwd(cacheKey, rolloutPath);
2918
3497
  } catch {
2919
3498
  return "";
2920
3499
  }
2921
3500
  }
2922
3501
 
2923
- function augmentRelayHistoryTurnsWithJsonlArtifacts(turns, threadId = "") {
3502
+ function readCachedJsonlThreadCwd(cacheKey, rolloutPath) {
3503
+ const cached = jsonlThreadCwdCacheByThread.get(cacheKey);
3504
+ if (!cached || cached.rolloutPath !== rolloutPath) {
3505
+ return null;
3506
+ }
3507
+
3508
+ const stat = statJsonlRollout(rolloutPath);
3509
+ if (!stat) {
3510
+ jsonlThreadCwdCacheByThread.delete(cacheKey);
3511
+ return null;
3512
+ }
3513
+
3514
+ if (stat.mtimeMs !== cached.mtimeMs || stat.size !== cached.size) {
3515
+ return null;
3516
+ }
3517
+
3518
+ const ttl = cached.cwd ? RELAY_JSONL_THREAD_CWD_CACHE_TTL_MS : RELAY_JSONL_THREAD_EMPTY_CWD_CACHE_TTL_MS;
3519
+ if (Date.now() - cached.checkedAt > ttl) {
3520
+ return null;
3521
+ }
3522
+
3523
+ return { cwd: cached.cwd };
3524
+ }
3525
+
3526
+ function readAndCacheJsonlThreadCwd(cacheKey, rolloutPath, stat = null) {
3527
+ const rolloutStat = stat || statJsonlRollout(rolloutPath);
3528
+ if (!rolloutStat) {
3529
+ jsonlThreadCwdCacheByThread.delete(cacheKey);
3530
+ return "";
3531
+ }
3532
+
3533
+ let cwd = "";
3534
+ try {
3535
+ const metadata = readSessionJsonlMetadataFromFile(rolloutPath);
3536
+ const parsedCwd = normalizeNonEmptyString(metadata?.cwd);
3537
+ cwd = parsedCwd && path.isAbsolute(parsedCwd) ? parsedCwd : "";
3538
+ } catch {
3539
+ cwd = "";
3540
+ }
3541
+
3542
+ rememberJsonlThreadCwdCache(cacheKey, {
3543
+ rolloutPath,
3544
+ cwd,
3545
+ mtimeMs: rolloutStat.mtimeMs,
3546
+ size: rolloutStat.size,
3547
+ checkedAt: Date.now(),
3548
+ });
3549
+ return cwd;
3550
+ }
3551
+
3552
+ function rememberJsonlThreadCwdCache(cacheKey, entry) {
3553
+ jsonlThreadCwdCacheByThread.set(cacheKey, entry);
3554
+ while (jsonlThreadCwdCacheByThread.size > JSONL_ROLLOUT_PATH_CACHE_MAX_SIZE) {
3555
+ const oldestKey = jsonlThreadCwdCacheByThread.keys().next().value;
3556
+ if (oldestKey == null) {
3557
+ break;
3558
+ }
3559
+ jsonlThreadCwdCacheByThread.delete(oldestKey);
3560
+ }
3561
+ }
3562
+
3563
+ function augmentRelayHistoryTurnsWithJsonlArtifacts(turns, threadId = "", {
3564
+ includeHistoryItems = false,
3565
+ } = {}) {
2924
3566
  const normalizedThreadId = normalizeNonEmptyString(threadId);
2925
3567
  if (!normalizedThreadId || !Array.isArray(turns) || turns.length === 0) {
2926
3568
  return { turns, didAugment: false };
2927
3569
  }
2928
3570
 
2929
- const jsonlArtifactsByTurnId = readJsonlArtifactItemsByTurnId(normalizedThreadId);
3571
+ const requestedTurnIds = new Set(turns.map((turn) => (
3572
+ normalizeNonEmptyString(turn?.id)
3573
+ || normalizeNonEmptyString(turn?.turnId)
3574
+ || normalizeNonEmptyString(turn?.turn_id)
3575
+ )).filter(Boolean));
3576
+ const jsonlArtifactsByTurnId = readJsonlArtifactItemsByTurnId(
3577
+ normalizedThreadId,
3578
+ requestedTurnIds
3579
+ );
2930
3580
  if (jsonlArtifactsByTurnId.size === 0) {
2931
3581
  return { turns, didAugment: false };
2932
3582
  }
@@ -2942,22 +3592,17 @@ function augmentRelayHistoryTurnsWithJsonlArtifacts(turns, threadId = "") {
2942
3592
  }
2943
3593
 
2944
3594
  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;
3595
+ const merged = mergeRelayHistoryItemsWithJsonlItems(
3596
+ items,
3597
+ artifacts.timelineItems,
3598
+ normalizedThreadId,
3599
+ {
3600
+ includeJsonlItem: includeHistoryItems
3601
+ ? () => true
3602
+ : isJsonlHistoryArtifactItem,
2953
3603
  }
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
- }
3604
+ );
3605
+ const nextItems = merged.items;
2961
3606
 
2962
3607
  if (nextItems === items) {
2963
3608
  return turn;
@@ -2973,7 +3618,7 @@ function augmentRelayHistoryTurnsWithJsonlArtifacts(turns, threadId = "") {
2973
3618
  return { turns: didAugment ? augmentedTurns : turns, didAugment };
2974
3619
  }
2975
3620
 
2976
- function readJsonlArtifactItemsByTurnId(threadId) {
3621
+ function readJsonlArtifactItemsByTurnId(threadId, requestedTurnIds = new Set()) {
2977
3622
  const emptyArtifactsByTurnId = new Map();
2978
3623
  const normalizedThreadId = normalizeNonEmptyString(threadId);
2979
3624
  if (!normalizedThreadId) {
@@ -2982,7 +3627,11 @@ function readJsonlArtifactItemsByTurnId(threadId) {
2982
3627
 
2983
3628
  const sessionsRoot = resolveSessionsRoot();
2984
3629
  const cacheKey = buildJsonlArtifactItemsCacheKey(sessionsRoot, normalizedThreadId);
2985
- const cachedArtifacts = readCachedJsonlArtifactItems(cacheKey, normalizedThreadId);
3630
+ const cachedArtifacts = readCachedJsonlArtifactItems(
3631
+ cacheKey,
3632
+ normalizedThreadId,
3633
+ requestedTurnIds
3634
+ );
2986
3635
  if (cachedArtifacts) {
2987
3636
  return cachedArtifacts;
2988
3637
  }
@@ -2994,7 +3643,13 @@ function readJsonlArtifactItemsByTurnId(threadId) {
2994
3643
  return emptyArtifactsByTurnId;
2995
3644
  }
2996
3645
 
2997
- return readAndCacheJsonlArtifactItems(cacheKey, rolloutPath, normalizedThreadId);
3646
+ return readAndCacheJsonlArtifactItems(
3647
+ cacheKey,
3648
+ rolloutPath,
3649
+ normalizedThreadId,
3650
+ null,
3651
+ requestedTurnIds
3652
+ );
2998
3653
  } catch (error) {
2999
3654
  jsonlArtifactItemsCacheByThread.delete(cacheKey);
3000
3655
  console.warn(`[remodex] history jsonl artifact augmentation failed for ${normalizedThreadId}: ${error.message}`);
@@ -3004,16 +3659,20 @@ function readJsonlArtifactItemsByTurnId(threadId) {
3004
3659
  }
3005
3660
 
3006
3661
  function buildJsonlArtifactItemsCacheKey(sessionsRoot, threadId) {
3662
+ return buildJsonlThreadCacheKey(sessionsRoot, threadId);
3663
+ }
3664
+
3665
+ function buildJsonlThreadCacheKey(sessionsRoot, threadId) {
3007
3666
  return `${sessionsRoot}\0${threadId}`;
3008
3667
  }
3009
3668
 
3010
- function readCachedJsonlArtifactItems(cacheKey, threadId) {
3669
+ function readCachedJsonlArtifactItems(cacheKey, threadId, requestedTurnIds = new Set()) {
3011
3670
  const cached = jsonlArtifactItemsCacheByThread.get(cacheKey);
3012
3671
  if (!cached) {
3013
3672
  return null;
3014
3673
  }
3015
3674
 
3016
- const stat = statJsonlArtifactRollout(cached.rolloutPath);
3675
+ const stat = statJsonlRollout(cached.rolloutPath);
3017
3676
  if (!stat) {
3018
3677
  jsonlArtifactItemsCacheByThread.delete(cacheKey);
3019
3678
  return null;
@@ -3021,7 +3680,13 @@ function readCachedJsonlArtifactItems(cacheKey, threadId) {
3021
3680
 
3022
3681
  if (stat.mtimeMs !== cached.mtimeMs || stat.size !== cached.size) {
3023
3682
  try {
3024
- return readAndCacheJsonlArtifactItems(cacheKey, cached.rolloutPath, threadId, stat);
3683
+ return readAndCacheJsonlArtifactItems(
3684
+ cacheKey,
3685
+ cached.rolloutPath,
3686
+ threadId,
3687
+ stat,
3688
+ requestedTurnIds
3689
+ );
3025
3690
  } catch (error) {
3026
3691
  jsonlArtifactItemsCacheByThread.delete(cacheKey);
3027
3692
  console.warn(`[remodex] history jsonl artifact cache refresh failed for ${threadId}: ${error.message}`);
@@ -3030,6 +3695,11 @@ function readCachedJsonlArtifactItems(cacheKey, threadId) {
3030
3695
  }
3031
3696
 
3032
3697
  const now = Date.now();
3698
+ const coversRequestedTurns = cached.coversEntireRollout
3699
+ || [...requestedTurnIds].every((turnId) => cached.coveredTurnIds?.has(turnId));
3700
+ if (!coversRequestedTurns) {
3701
+ return null;
3702
+ }
3033
3703
  if (now - cached.checkedAt <= RELAY_JSONL_ARTIFACT_CACHE_TTL_MS) {
3034
3704
  return cached.artifactsByTurnId;
3035
3705
  }
@@ -3038,11 +3708,37 @@ function readCachedJsonlArtifactItems(cacheKey, threadId) {
3038
3708
  return null;
3039
3709
  }
3040
3710
 
3041
- function readAndCacheJsonlArtifactItems(cacheKey, rolloutPath, threadId, stat = null) {
3711
+ function readAndCacheJsonlArtifactItems(
3712
+ cacheKey,
3713
+ rolloutPath,
3714
+ threadId,
3715
+ stat = null,
3716
+ requestedTurnIds = new Set()
3717
+ ) {
3042
3718
  const rolloutStat = stat || fs.statSync(rolloutPath);
3043
3719
  const artifactsByTurnId = new Map();
3720
+ let coveredTurnIds = new Set();
3721
+ let coversEntireRollout = false;
3044
3722
  try {
3045
- const turns = parseSessionJsonlTurns(fs.readFileSync(rolloutPath, "utf8"), { threadId });
3723
+ const recent = readRecentSessionJsonlTurns(rolloutPath, {
3724
+ threadId,
3725
+ limit: RELAY_TURNS_LIST_SAFE_RETRY_LIMIT,
3726
+ });
3727
+ let turns = recent?.turns || [];
3728
+ coversEntireRollout = recent ? !recent.hasOlderTurns : false;
3729
+ coveredTurnIds = new Set(turns.map((turn) => normalizeNonEmptyString(turn?.id)).filter(Boolean));
3730
+ const missesRequestedTurn = [...requestedTurnIds].some((turnId) => !coveredTurnIds.has(turnId));
3731
+
3732
+ // Preserve the old exact artifact behavior for files V8 can safely decode,
3733
+ // but only pay that cost when an older cursor page actually asks for a turn
3734
+ // outside the fast tail. Multi-gigabyte files never enter this path.
3735
+ if (!coversEntireRollout
3736
+ && missesRequestedTurn
3737
+ && rolloutStat.size <= RELAY_JSONL_FULL_ARTIFACT_FALLBACK_MAX_BYTES) {
3738
+ turns = parseSessionJsonlTurns(fs.readFileSync(rolloutPath, "utf8"), { threadId });
3739
+ coversEntireRollout = true;
3740
+ coveredTurnIds = new Set(turns.map((turn) => normalizeNonEmptyString(turn?.id)).filter(Boolean));
3741
+ }
3046
3742
  for (const turn of turns) {
3047
3743
  const turnId = normalizeNonEmptyString(turn?.id);
3048
3744
  const turnItems = Array.isArray(turn?.items) ? turn.items : [];
@@ -3050,47 +3746,9 @@ function readAndCacheJsonlArtifactItems(cacheKey, rolloutPath, threadId, stat =
3050
3746
  continue;
3051
3747
  }
3052
3748
 
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);
3749
+ const timelineItems = buildOrderedJsonlTimelineItems(turnItems, turnId);
3750
+ if (timelineItems.length > 0) {
3751
+ artifactsByTurnId.set(turnId, { timelineItems });
3094
3752
  }
3095
3753
  }
3096
3754
  } catch (error) {
@@ -3104,11 +3762,13 @@ function readAndCacheJsonlArtifactItems(cacheKey, rolloutPath, threadId, stat =
3104
3762
  size: rolloutStat.size,
3105
3763
  checkedAt: Date.now(),
3106
3764
  artifactsByTurnId,
3765
+ coveredTurnIds,
3766
+ coversEntireRollout,
3107
3767
  });
3108
3768
  return artifactsByTurnId;
3109
3769
  }
3110
3770
 
3111
- function statJsonlArtifactRollout(rolloutPath) {
3771
+ function statJsonlRollout(rolloutPath) {
3112
3772
  try {
3113
3773
  return fs.statSync(rolloutPath);
3114
3774
  } catch {
@@ -3127,57 +3787,406 @@ function rememberJsonlArtifactItemsCache(cacheKey, entry) {
3127
3787
  }
3128
3788
  }
3129
3789
 
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;
3790
+ // Keeps JSONL-only rows in rollout order while treating app-server rows as the
3791
+ // authoritative spine. Matching anchors let us place missing rows without ever
3792
+ // moving server-only findings, messages, or richer tool records to the tail.
3793
+ function mergeRelayHistoryItemsWithJsonlItems(existingItems, jsonlItems, threadId = "", {
3794
+ includeJsonlItem = () => true,
3795
+ } = {}) {
3796
+ if (!Array.isArray(existingItems) || !Array.isArray(jsonlItems) || jsonlItems.length === 0) {
3797
+ return { items: existingItems, didMerge: false };
3798
+ }
3799
+
3800
+ const sanitizedJsonlItems = jsonlItems
3801
+ .map((item) => sanitizeJsonlHistoryItemForRelayMerge(item, threadId))
3802
+ .filter(Boolean);
3803
+ if (sanitizedJsonlItems.length === 0) {
3804
+ return { items: existingItems, didMerge: false };
3805
+ }
3806
+
3807
+ if (existingItems.length === 0) {
3808
+ const insertedItems = sanitizedJsonlItems.filter(includeJsonlItem);
3809
+ return insertedItems.length > 0
3810
+ ? { items: insertedItems, didMerge: true }
3811
+ : { items: existingItems, didMerge: false };
3812
+ }
3813
+
3814
+ const usedExistingIndices = new Set();
3815
+ const resolvedExistingItems = existingItems.slice();
3816
+ const insertionsBefore = new Map();
3817
+ const insertionsAfter = new Map();
3818
+ let pendingItems = [];
3819
+ let previousMatchedIndex = null;
3820
+ let matchedAnchorCount = 0;
3821
+ let didReplaceMatchedItem = false;
3822
+
3823
+ // The rollout's turn+text alias is the only identity shared across the
3824
+ // live-owner (item_N) and rollout (msg_...) views of one assistant reply.
3825
+ // Carrying it onto the matched server row lets the phone join this item
3826
+ // with its other-source representations instead of duplicating it.
3827
+ const adoptJsonlSourceAlias = (index, jsonlItem) => {
3828
+ const sourceKey = normalizeNonEmptyString(jsonlItem?.remodexSourceItemKey);
3829
+ const existing = resolvedExistingItems[index];
3830
+ if (!sourceKey || !existing || typeof existing !== "object"
3831
+ || normalizeNonEmptyString(existing.remodexSourceItemKey)) {
3832
+ return;
3136
3833
  }
3137
- if (incomingId && normalizeNonEmptyString(item.id) === incomingId) {
3138
- return true;
3834
+ resolvedExistingItems[index] = { ...existing, remodexSourceItemKey: sourceKey };
3835
+ didReplaceMatchedItem = true;
3836
+ };
3837
+
3838
+ const placePendingItems = () => {
3839
+ if (pendingItems.length === 0) {
3840
+ return;
3139
3841
  }
3140
- if (item.remodexJsonlFileChangeAggregate === true) {
3141
- return true;
3842
+ if (previousMatchedIndex == null) {
3843
+ insertionsBefore.set(0, pendingItems);
3844
+ } else {
3845
+ const existing = insertionsAfter.get(previousMatchedIndex) || [];
3846
+ insertionsAfter.set(previousMatchedIndex, existing.concat(pendingItems));
3142
3847
  }
3848
+ pendingItems = [];
3849
+ };
3143
3850
 
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;
3851
+ for (const jsonlItem of sanitizedJsonlItems) {
3852
+ const unusedMatch = (candidate, index) => !usedExistingIndices.has(index);
3853
+ const eligibleMatch = (candidate, index) => (
3854
+ (previousMatchedIndex == null || index > previousMatchedIndex)
3855
+ && unusedMatch(candidate, index)
3856
+ );
3857
+ let existingIndex = findRelayHistoryExactMatchIndex(existingItems, jsonlItem, eligibleMatch);
3858
+ if (existingIndex === -1) {
3859
+ // Exact identity remains authoritative even when server and rollout order
3860
+ // disagree. Consume that occurrence before considering a later semantic
3861
+ // lookalike, otherwise repeated rows can bind to the wrong server item.
3862
+ const representedExactIndex = findRelayHistoryExactMatchIndex(
3863
+ existingItems,
3864
+ jsonlItem,
3865
+ unusedMatch
3866
+ );
3867
+ if (representedExactIndex !== -1) {
3868
+ usedExistingIndices.add(representedExactIndex);
3869
+ if (isProgressPlanItem(jsonlItem)) {
3870
+ resolvedExistingItems[representedExactIndex] = resolvedProgressPlanHistoryItem(
3871
+ existingItems[representedExactIndex],
3872
+ jsonlItem
3873
+ );
3874
+ didReplaceMatchedItem = true;
3875
+ }
3876
+ adoptJsonlSourceAlias(representedExactIndex, jsonlItem);
3877
+ continue;
3878
+ }
3879
+ existingIndex = findRelayHistorySemanticMatchIndex(existingItems, jsonlItem, eligibleMatch);
3880
+ }
3881
+ if (existingIndex === -1) {
3882
+ // The row can already exist before the monotonic placement frontier when
3883
+ // server and rollout order disagree. Consume each represented occurrence
3884
+ // once; an extra identical JSONL occurrence must remain visible instead
3885
+ // of repeatedly matching the same server row and disappearing.
3886
+ const representedIndex = findRelayHistorySemanticMatchIndex(
3887
+ existingItems,
3888
+ jsonlItem,
3889
+ unusedMatch
3890
+ );
3891
+ if (representedIndex !== -1) {
3892
+ usedExistingIndices.add(representedIndex);
3893
+ if (isProgressPlanItem(jsonlItem)) {
3894
+ resolvedExistingItems[representedIndex] = resolvedProgressPlanHistoryItem(
3895
+ existingItems[representedIndex],
3896
+ jsonlItem
3897
+ );
3898
+ didReplaceMatchedItem = true;
3899
+ }
3900
+ adoptJsonlSourceAlias(representedIndex, jsonlItem);
3901
+ continue;
3151
3902
  }
3903
+ if (includeJsonlItem(jsonlItem)) {
3904
+ pendingItems.push(jsonlItem);
3905
+ }
3906
+ continue;
3152
3907
  }
3153
- return true;
3154
- });
3908
+
3909
+ placePendingItems();
3910
+ usedExistingIndices.add(existingIndex);
3911
+ if (isProgressPlanItem(jsonlItem)) {
3912
+ resolvedExistingItems[existingIndex] = resolvedProgressPlanHistoryItem(
3913
+ existingItems[existingIndex],
3914
+ jsonlItem
3915
+ );
3916
+ didReplaceMatchedItem = true;
3917
+ }
3918
+ adoptJsonlSourceAlias(existingIndex, jsonlItem);
3919
+ previousMatchedIndex = existingIndex;
3920
+ matchedAnchorCount += 1;
3921
+ }
3922
+
3923
+ if (matchedAnchorCount === 0) {
3924
+ const unanchoredArtifacts = sanitizedJsonlItems.filter((item) => (
3925
+ includeJsonlItem(item) && isJsonlHistoryArtifactItem(item)
3926
+ ));
3927
+ if (unanchoredArtifacts.length === 0) {
3928
+ return { items: existingItems, didMerge: false };
3929
+ }
3930
+ const firstAssistantIndex = existingItems.findIndex(isRelayAssistantHistoryItem);
3931
+ const insertionIndex = firstAssistantIndex === -1 ? existingItems.length : firstAssistantIndex;
3932
+ return {
3933
+ items: existingItems.slice(0, insertionIndex)
3934
+ .concat(unanchoredArtifacts, existingItems.slice(insertionIndex)),
3935
+ didMerge: true,
3936
+ };
3937
+ }
3938
+ if (pendingItems.length > 0 && previousMatchedIndex != null) {
3939
+ const existing = insertionsAfter.get(previousMatchedIndex) || [];
3940
+ insertionsAfter.set(previousMatchedIndex, existing.concat(pendingItems));
3941
+ }
3942
+
3943
+ if (insertionsBefore.size === 0 && insertionsAfter.size === 0 && !didReplaceMatchedItem) {
3944
+ return { items: existingItems, didMerge: false };
3945
+ }
3946
+
3947
+ const mergedItems = [];
3948
+ for (const [index, item] of resolvedExistingItems.entries()) {
3949
+ mergedItems.push(...(insertionsBefore.get(index) || []));
3950
+ mergedItems.push(item);
3951
+ mergedItems.push(...(insertionsAfter.get(index) || []));
3952
+ }
3953
+ return { items: mergedItems, didMerge: true };
3155
3954
  }
3156
3955
 
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;
3956
+ function buildOrderedJsonlTimelineItems(turnItems, turnId) {
3957
+ if (!Array.isArray(turnItems) || turnItems.length === 0) {
3958
+ return [];
3959
+ }
3960
+
3961
+ let latestProgressPlanIndex = -1;
3962
+ for (const [index, item] of turnItems.entries()) {
3963
+ if (isProgressPlanItem(item)) {
3964
+ latestProgressPlanIndex = index;
3965
+ }
3966
+ }
3967
+
3968
+ let imageViewIndex = 0;
3969
+ return turnItems.flatMap((item, index) => {
3970
+ if (!shouldIncludeJsonlTimelineItem(item)) {
3971
+ return [];
3972
+ }
3973
+ if (isProgressPlanItem(item) && index !== latestProgressPlanIndex) {
3974
+ return [];
3975
+ }
3976
+
3977
+ const itemType = normalizeHistoryItemToken(item?.type);
3978
+ if (isProgressPlanItem(item)) {
3979
+ return [{
3980
+ ...item,
3981
+ id: normalizeNonEmptyString(item?.id) || `remodex-jsonl-progress-plan-${turnId}`,
3982
+ remodexProgressPlan: true,
3983
+ remodexJsonlProgressPlan: true,
3984
+ }];
3162
3985
  }
3163
- return item.remodexJsonlProgressPlan === true
3164
- || (incomingId && normalizeNonEmptyString(item.id) === incomingId);
3986
+ if (itemType === "imageview") {
3987
+ imageViewIndex += 1;
3988
+ return [{
3989
+ ...item,
3990
+ id: normalizeNonEmptyString(item?.id)
3991
+ || `remodex-jsonl-image-view-${turnId}-${imageViewIndex}`,
3992
+ }];
3993
+ }
3994
+ return [item];
3165
3995
  });
3166
3996
  }
3167
3997
 
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;
3998
+ function shouldIncludeJsonlTimelineItem(item) {
3999
+ const itemType = normalizeHistoryItemToken(item?.type);
4000
+ return Boolean(itemType)
4001
+ && itemType !== "toolcalloutput"
4002
+ && itemType !== "functioncalloutput"
4003
+ && itemType !== "customtoolcalloutput";
4004
+ }
4005
+
4006
+ function isJsonlHistoryArtifactItem(item) {
4007
+ const itemType = normalizeHistoryItemToken(item?.type);
4008
+ return itemType === "filechange"
4009
+ || itemType === "imageview"
4010
+ || isProgressPlanItem(item);
4011
+ }
4012
+
4013
+ function isProgressPlanItem(item) {
4014
+ const itemType = normalizeHistoryItemToken(item?.type);
4015
+ return (itemType === "plan" || itemType === "todolist")
4016
+ && (item?.remodexJsonlProgressPlan === true || item?.remodexProgressPlan === true);
4017
+ }
4018
+
4019
+ function resolvedProgressPlanHistoryItem(existingItem, jsonlItem) {
4020
+ return {
4021
+ ...existingItem,
4022
+ text: jsonlItem.text,
4023
+ explanation: jsonlItem.explanation,
4024
+ plan: jsonlItem.plan,
4025
+ remodexProgressPlan: true,
4026
+ remodexJsonlProgressPlan: true,
4027
+ };
4028
+ }
4029
+
4030
+ function findRelayHistoryExactMatchIndex(items, incomingItem, predicate = () => true) {
4031
+ return items.findIndex((candidate, index) => (
4032
+ predicate(candidate, index) && relayHistoryItemsHaveExactIdentity(candidate, incomingItem)
4033
+ ));
4034
+ }
4035
+
4036
+ function findRelayHistorySemanticMatchIndex(items, incomingItem, predicate = () => true) {
4037
+ return items.findIndex((candidate, index) => (
4038
+ predicate(candidate, index) && areEquivalentRelayHistoryItems(candidate, incomingItem)
4039
+ ));
4040
+ }
4041
+
4042
+ function relayHistoryItemsHaveExactIdentity(first, second) {
4043
+ const firstIdentity = relayHistoryItemIdentity(first);
4044
+ const secondIdentity = relayHistoryItemIdentity(second);
4045
+ if (firstIdentity && secondIdentity && firstIdentity === secondIdentity) {
4046
+ return true;
4047
+ }
4048
+ const firstCallId = relayHistoryItemCallId(first);
4049
+ const secondCallId = relayHistoryItemCallId(second);
4050
+ return Boolean(firstCallId && secondCallId && firstCallId === secondCallId);
4051
+ }
4052
+
4053
+ function isRelayAssistantHistoryItem(item) {
4054
+ const role = normalizeNonEmptyString(item?.role).toLowerCase();
4055
+ const itemType = normalizeHistoryItemToken(item?.type);
4056
+ return role === "assistant"
4057
+ || itemType === "assistantmessage"
4058
+ || itemType === "agentmessage"
4059
+ || (itemType === "message" && role !== "user");
4060
+ }
4061
+
4062
+ function sanitizeJsonlHistoryItemForRelayMerge(item, threadId) {
4063
+ const sanitizedTurn = sanitizeRelayHistoryTurn({ items: [item] }, threadId);
4064
+ return sanitizedTurn?.items?.[0] || item;
4065
+ }
4066
+
4067
+ function areEquivalentRelayHistoryItems(first, second) {
4068
+ const firstIdentity = relayHistoryItemIdentity(first);
4069
+ const secondIdentity = relayHistoryItemIdentity(second);
4070
+ if (firstIdentity && secondIdentity && firstIdentity === secondIdentity) {
4071
+ return true;
4072
+ }
4073
+
4074
+ const firstCallId = relayHistoryItemCallId(first);
4075
+ const secondCallId = relayHistoryItemCallId(second);
4076
+ if (firstCallId && secondCallId && firstCallId === secondCallId) {
4077
+ return true;
4078
+ }
4079
+
4080
+ if (isProgressPlanItem(first) && isProgressPlanItem(second)) {
4081
+ return true;
4082
+ }
4083
+
4084
+ // JSONL line ids are source-local fallbacks, not provider identities. They
4085
+ // may reconcile semantically with a real app-server id; occurrence tracking
4086
+ // in the merge keeps intentional repeated rows distinct. Assistant messages
4087
+ // are exempt from the two-stable-ids refusal: the live-owner state keys them
4088
+ // by app-server event id (item_N) while the rollout records the provider id
4089
+ // (msg_...), so the same reply legitimately carries two stable identities.
4090
+ if (relayHistoryIdentityIsStable(firstIdentity)
4091
+ && relayHistoryIdentityIsStable(secondIdentity)
4092
+ && !(isRelayAssistantHistoryItem(first) && isRelayAssistantHistoryItem(second))) {
4093
+ return false;
4094
+ }
4095
+ if (relayHistoryIdentityIsStable(firstCallId)
4096
+ && relayHistoryIdentityIsStable(secondCallId)) {
4097
+ return false;
4098
+ }
4099
+
4100
+ const firstType = normalizeHistoryItemToken(first?.type);
4101
+ const secondType = normalizeHistoryItemToken(second?.type);
4102
+ if (firstType === "imageview" && secondType === "imageview") {
4103
+ const firstPath = normalizeImageViewPathKey(first);
4104
+ const secondPath = normalizeImageViewPathKey(second);
4105
+ if (firstPath && firstPath === secondPath) {
4106
+ return true;
3174
4107
  }
3175
- const itemId = normalizeNonEmptyString(item.id);
3176
- if (incomingId && itemId === incomingId) {
4108
+ }
4109
+ if (firstType === "filechange" && secondType === "filechange") {
4110
+ const firstPaths = fileChangePathSet(first);
4111
+ const secondPaths = fileChangePathSet(second);
4112
+ if (firstPaths.size > 0
4113
+ && firstPaths.size === secondPaths.size
4114
+ && Array.from(firstPaths).every((pathKey) => secondPaths.has(pathKey))) {
3177
4115
  return true;
3178
4116
  }
3179
- return incomingPath && normalizeImageViewPathKey(item) === incomingPath;
3180
- });
4117
+ }
4118
+
4119
+ const firstText = relayHistoryItemText(first);
4120
+ const secondText = relayHistoryItemText(second);
4121
+ if (!firstText || !secondText || firstText !== secondText) {
4122
+ return false;
4123
+ }
4124
+
4125
+ return relayHistoryItemKindsCompatible(first, second);
4126
+ }
4127
+
4128
+ function relayHistoryItemKindsCompatible(first, second) {
4129
+ const firstType = normalizeHistoryItemToken(first?.type);
4130
+ const secondType = normalizeHistoryItemToken(second?.type);
4131
+ if (firstType && secondType && firstType === secondType) {
4132
+ return true;
4133
+ }
4134
+
4135
+ const firstRole = normalizeNonEmptyString(first?.role).toLowerCase();
4136
+ const secondRole = normalizeNonEmptyString(second?.role).toLowerCase();
4137
+ if (firstRole && secondRole && firstRole === secondRole) {
4138
+ return true;
4139
+ }
4140
+
4141
+ return isRelayMessageLikeHistoryType(firstType) && isRelayMessageLikeHistoryType(secondType);
4142
+ }
4143
+
4144
+ function isRelayMessageLikeHistoryType(itemType) {
4145
+ return itemType === "message"
4146
+ || itemType === "assistantmessage"
4147
+ || itemType === "agentmessage"
4148
+ || itemType === "usermessage";
4149
+ }
4150
+
4151
+ function relayHistoryItemIdentity(item) {
4152
+ return normalizeNonEmptyString(item?.id)
4153
+ || normalizeNonEmptyString(item?.itemId)
4154
+ || normalizeNonEmptyString(item?.item_id);
4155
+ }
4156
+
4157
+ function relayHistoryIdentityIsStable(identity) {
4158
+ const normalizedIdentity = normalizeNonEmptyString(identity);
4159
+ if (!normalizedIdentity) {
4160
+ return false;
4161
+ }
4162
+ return !/^(?:user-message-line|response-item-line|apply-patch-line)-\d+$/.test(normalizedIdentity);
4163
+ }
4164
+
4165
+ function relayHistoryItemCallId(item) {
4166
+ return normalizeNonEmptyString(item?.call_id)
4167
+ || normalizeNonEmptyString(item?.callId);
4168
+ }
4169
+
4170
+ function relayHistoryItemText(item) {
4171
+ if (!item || typeof item !== "object" || Array.isArray(item)) {
4172
+ return "";
4173
+ }
4174
+
4175
+ for (const key of ["text", "message", "summary", "output", "outputText", "output_text", "command"]) {
4176
+ const value = normalizeNonEmptyString(item[key]);
4177
+ if (value) {
4178
+ return value;
4179
+ }
4180
+ }
4181
+
4182
+ if (Array.isArray(item.content)) {
4183
+ return item.content
4184
+ .map(relayHistoryItemText)
4185
+ .filter(Boolean)
4186
+ .join("\n");
4187
+ }
4188
+
4189
+ return "";
3181
4190
  }
3182
4191
 
3183
4192
  function normalizeImageViewPathKey(item) {
@@ -3249,7 +4258,16 @@ function sanitizeRelayHistoryTurn(turn, threadId = "") {
3249
4258
  }
3250
4259
 
3251
4260
  let itemDidChange = false;
3252
- let sanitizedItem = convertApplyPatchHistoryItem(item) || item;
4261
+ let sanitizedItem = sanitizeUserRoleItem(item);
4262
+ if (!sanitizedItem) {
4263
+ turnDidChange = true;
4264
+ return null;
4265
+ }
4266
+ if (sanitizedItem !== item) {
4267
+ itemDidChange = true;
4268
+ }
4269
+
4270
+ sanitizedItem = convertApplyPatchHistoryItem(sanitizedItem) || sanitizedItem;
3253
4271
  if (sanitizedItem !== item) {
3254
4272
  itemDidChange = true;
3255
4273
  }
@@ -3287,7 +4305,7 @@ function sanitizeRelayHistoryTurn(turn, threadId = "") {
3287
4305
  }
3288
4306
 
3289
4307
  return itemDidChange ? sanitizedItem : item;
3290
- });
4308
+ }).filter(Boolean);
3291
4309
 
3292
4310
  return turnDidChange
3293
4311
  ? {
@@ -3297,6 +4315,67 @@ function sanitizeRelayHistoryTurn(turn, threadId = "") {
3297
4315
  : turn;
3298
4316
  }
3299
4317
 
4318
+ // Compatibility predicate for callers that only need a drop/no-drop decision.
4319
+ // The full sanitizer below also rewrites mixed items without losing attachments.
4320
+ const LIVE_ITEM_LIFECYCLE_METHODS = new Set([
4321
+ "item/started",
4322
+ "item/updated",
4323
+ "item/completed",
4324
+ ]);
4325
+
4326
+ function isContextualUserItemNotification(parsed) {
4327
+ const method = typeof parsed?.method === "string" ? parsed.method : "";
4328
+ if (!LIVE_ITEM_LIFECYCLE_METHODS.has(method)) {
4329
+ return false;
4330
+ }
4331
+ const item = parsed?.params?.item;
4332
+ if (!isUserRoleItem(item)) {
4333
+ return false;
4334
+ }
4335
+ return isContextualUserText(readUserItemText(item));
4336
+ }
4337
+
4338
+ // Sanitizes both raw app-server item events and fallback user_message events
4339
+ // before they can become mobile bubbles. Structured attachments stay intact.
4340
+ function sanitizeLiveUserNotification(parsed) {
4341
+ if (!parsed || typeof parsed !== "object") {
4342
+ return parsed;
4343
+ }
4344
+ const method = typeof parsed.method === "string" ? parsed.method : "";
4345
+ if (LIVE_ITEM_LIFECYCLE_METHODS.has(method)) {
4346
+ const item = parsed?.params?.item;
4347
+ if (!isUserRoleItem(item)) {
4348
+ return parsed;
4349
+ }
4350
+ const sanitizedItem = sanitizeUserRoleItem(item);
4351
+ if (!sanitizedItem) {
4352
+ return null;
4353
+ }
4354
+ return sanitizedItem === item ? parsed : {
4355
+ ...parsed,
4356
+ params: { ...parsed.params, item: sanitizedItem },
4357
+ };
4358
+ }
4359
+
4360
+ if (method !== "codex/event/user_message") {
4361
+ return parsed;
4362
+ }
4363
+ const key = typeof parsed?.params?.message === "string"
4364
+ ? "message"
4365
+ : (typeof parsed?.params?.text === "string" ? "text" : "");
4366
+ if (!key) {
4367
+ return parsed;
4368
+ }
4369
+ const visible = visibleUserPromptText(parsed.params[key]);
4370
+ if (!visible) {
4371
+ return null;
4372
+ }
4373
+ return visible === parsed.params[key] ? parsed : {
4374
+ ...parsed,
4375
+ params: { ...parsed.params, [key]: visible },
4376
+ };
4377
+ }
4378
+
3300
4379
  function convertApplyPatchHistoryItem(item) {
3301
4380
  const itemType = normalizeHistoryItemToken(item?.type);
3302
4381
  const toolName = normalizeNonEmptyString(item?.name);
@@ -3608,12 +4687,17 @@ function parseBridgeJSON(value) {
3608
4687
  }
3609
4688
  }
3610
4689
 
3611
- function trimThreadPayloadForRelay(parsed, explicitThread = undefined) {
4690
+ function trimThreadPayloadForRelay(parsed, explicitThread = undefined, options = {}) {
3612
4691
  const thread = explicitThread ?? parsed?.result?.thread;
3613
4692
  if (!parsed || !thread || typeof thread !== "object" || !Array.isArray(thread.turns)) {
3614
4693
  return null;
3615
4694
  }
3616
4695
 
4696
+ // Callers that pre-trimmed the turn window pass the dropped count and the original
4697
+ // first turn here so compaction markers keep reporting whole-thread numbers.
4698
+ const preOmittedTurnCount = Math.max(0, options.preOmittedTurnCount ?? 0);
4699
+ const compactionIdSource = options.compactionIdSource ?? null;
4700
+
3617
4701
  let workingThread = thread;
3618
4702
  let encoded = encodeRelayThreadPayload(parsed, workingThread);
3619
4703
  if (encoded == null) {
@@ -3621,7 +4705,16 @@ function trimThreadPayloadForRelay(parsed, explicitThread = undefined) {
3621
4705
  }
3622
4706
 
3623
4707
  if (Buffer.byteLength(encoded, "utf8") <= RELAY_THREAD_PAYLOAD_SOFT_LIMIT_BYTES) {
3624
- return explicitThread === undefined ? null : encoded;
4708
+ if (preOmittedTurnCount <= 0) {
4709
+ return explicitThread === undefined ? null : encoded;
4710
+ }
4711
+ const compactedThread = buildRelayHistoryCompactedThread(
4712
+ thread,
4713
+ buildRelayCompactedHistoryTurns(thread.turns, thread.turns, preOmittedTurnCount, compactionIdSource),
4714
+ preOmittedTurnCount,
4715
+ thread.turns.length
4716
+ );
4717
+ return encodeRelayThreadPayload(parsed, compactedThread) ?? encoded;
3625
4718
  }
3626
4719
 
3627
4720
  const turns = thread.turns;
@@ -3634,8 +4727,8 @@ function trimThreadPayloadForRelay(parsed, explicitThread = undefined) {
3634
4727
  }
3635
4728
  const candidateThread = buildRelayHistoryCompactedThread(
3636
4729
  thread,
3637
- buildRelayCompactedHistoryTurns(turns, trimmedTurns),
3638
- Math.max(0, turns.length - trimmedTurns.length),
4730
+ buildRelayCompactedHistoryTurns(turns, trimmedTurns, preOmittedTurnCount, compactionIdSource),
4731
+ preOmittedTurnCount + Math.max(0, turns.length - trimmedTurns.length),
3639
4732
  trimmedTurns.length
3640
4733
  );
3641
4734
  encoded = encodeRelayThreadPayload(parsed, candidateThread);
@@ -3655,9 +4748,9 @@ function trimThreadPayloadForRelay(parsed, explicitThread = undefined) {
3655
4748
  while (trimmedItems.length > 1) {
3656
4749
  trimmedItems = trimmedItems.slice(1);
3657
4750
  const compactedTurnPrefix = buildRelayHistoryCompactionTurn(
3658
- Math.max(0, turns.length - 1),
4751
+ preOmittedTurnCount + Math.max(0, turns.length - 1),
3659
4752
  1,
3660
- thread
4753
+ compactionIdSource ?? thread
3661
4754
  );
3662
4755
  const candidateThread = buildRelayHistoryCompactedThread(
3663
4756
  thread,
@@ -3668,7 +4761,7 @@ function trimThreadPayloadForRelay(parsed, explicitThread = undefined) {
3668
4761
  ...newestTurn,
3669
4762
  items: trimmedItems,
3670
4763
  }],
3671
- Math.max(0, turns.length - 1),
4764
+ preOmittedTurnCount + Math.max(0, turns.length - 1),
3672
4765
  1
3673
4766
  );
3674
4767
  encoded = encodeRelayThreadPayload(parsed, candidateThread);
@@ -3690,13 +4783,13 @@ function trimThreadPayloadForRelay(parsed, explicitThread = undefined) {
3690
4783
  let candidateThread = buildRelayHistoryCompactedThread(
3691
4784
  thread,
3692
4785
  [
3693
- ...buildRelayCompactedHistoryTurns(turns, [newestTurn]).slice(0, -1),
4786
+ ...buildRelayCompactedHistoryTurns(turns, [newestTurn], preOmittedTurnCount, compactionIdSource).slice(0, -1),
3694
4787
  {
3695
4788
  ...newestTurn,
3696
4789
  items: [truncatedItem],
3697
4790
  },
3698
4791
  ],
3699
- Math.max(0, turns.length - 1),
4792
+ preOmittedTurnCount + Math.max(0, turns.length - 1),
3700
4793
  1
3701
4794
  );
3702
4795
  encoded = encodeRelayThreadPayload(parsed, candidateThread);
@@ -3707,13 +4800,13 @@ function trimThreadPayloadForRelay(parsed, explicitThread = undefined) {
3707
4800
  candidateThread = buildRelayHistoryCompactedThread(
3708
4801
  thread,
3709
4802
  [
3710
- ...buildRelayCompactedHistoryTurns(turns, [newestTurn]).slice(0, -1),
4803
+ ...buildRelayCompactedHistoryTurns(turns, [newestTurn], preOmittedTurnCount, compactionIdSource).slice(0, -1),
3711
4804
  {
3712
4805
  ...newestTurn,
3713
4806
  items: [compactHistoryItemForRelay(mostRecentItem, RELAY_HISTORY_TEXT_TAIL_LIMIT_CHARS)],
3714
4807
  },
3715
4808
  ],
3716
- Math.max(0, turns.length - 1),
4809
+ preOmittedTurnCount + Math.max(0, turns.length - 1),
3717
4810
  1
3718
4811
  );
3719
4812
  return encodeRelayThreadPayload(parsed, candidateThread);
@@ -3753,6 +4846,32 @@ function trimTurnsListPayloadForRelay(parsed, turnsKey, originalRawMessage = nul
3753
4846
  }
3754
4847
  }
3755
4848
 
4849
+ // A bounded JSONL first page can still describe one exceptionally large
4850
+ // turn with many small items. Keep that provisional response relay-safe while
4851
+ // preserving its handoff flags/cursor; the canonical background page will
4852
+ // replace it with the authoritative history.
4853
+ if (result.remodexJsonlFallback === true) {
4854
+ for (const maxItems of [64, 16, 4, 1]) {
4855
+ for (const maxChars of [1_000, 0]) {
4856
+ const emergencyTurns = turns.map((turn) => (
4857
+ compactEmergencySingleTurnForRelay(turn, maxChars, maxItems)
4858
+ ));
4859
+ const emergencyPayload = JSON.stringify({
4860
+ ...parsed,
4861
+ result: {
4862
+ ...result,
4863
+ [turnsKey]: emergencyTurns,
4864
+ remodexPageCompactedForRelay: true,
4865
+ remodexEmergencyJsonlPageForRelay: true,
4866
+ },
4867
+ });
4868
+ if (Buffer.byteLength(emergencyPayload, "utf8") <= RELAY_THREAD_PAYLOAD_SOFT_LIMIT_BYTES) {
4869
+ return emergencyPayload;
4870
+ }
4871
+ }
4872
+ }
4873
+ }
4874
+
3756
4875
  return fallbackCompactedPayload ?? (originalRawMessage ?? encoded);
3757
4876
  }
3758
4877
 
@@ -3779,12 +4898,12 @@ function buildRelayHistoryCompactedThread(thread, turns, omittedTurnCount, keptT
3779
4898
  };
3780
4899
  }
3781
4900
 
3782
- function buildRelayCompactedHistoryTurns(allTurns, keptTurns) {
3783
- const omittedTurnCount = Math.max(0, allTurns.length - keptTurns.length);
4901
+ function buildRelayCompactedHistoryTurns(allTurns, keptTurns, preOmittedTurnCount = 0, idSourceOverride = null) {
4902
+ const omittedTurnCount = preOmittedTurnCount + Math.max(0, allTurns.length - keptTurns.length);
3784
4903
  const compactionTurn = buildRelayHistoryCompactionTurn(
3785
4904
  omittedTurnCount,
3786
4905
  keptTurns.length,
3787
- allTurns[0]
4906
+ idSourceOverride ?? allTurns[0]
3788
4907
  );
3789
4908
  return compactionTurn ? [compactionTurn, ...keptTurns] : keptTurns;
3790
4909
  }
@@ -3808,6 +4927,9 @@ function buildRelayHistoryCompactionTurn(omittedTurnCount, keptTurnCount, idSour
3808
4927
 
3809
4928
  return {
3810
4929
  id: `remodex-history-compacted-${baseId}`,
4930
+ // A status-less turn reads as interruptible/running to the phone's
4931
+ // turn-state snapshot, flagging idle heavy threads as "thinking".
4932
+ status: "completed",
3811
4933
  remodexSynthetic: true,
3812
4934
  remodexHistoryCompacted: true,
3813
4935
  remodexOmittedTurnCount: omittedTurnCount,
@@ -3970,21 +5092,42 @@ function persistBridgePreferences(
3970
5092
  });
3971
5093
  }
3972
5094
 
5095
+ function shouldSuppressRolloutMirrorForThread(
5096
+ threadId,
5097
+ { desktopIpcActionFollower = null, desktopIpcLiveOwner = null } = {},
5098
+ { fallbackActivityAt = 0 } = {}
5099
+ ) {
5100
+ // Desktop ownership is an expiring live lease, not a permanent boolean. A
5101
+ // stale IPC snapshot used to mute an actively growing rollout forever.
5102
+ const followerIsFresh = typeof desktopIpcActionFollower?.hasFreshLiveThreadState === "function"
5103
+ ? desktopIpcActionFollower.hasFreshLiveThreadState(threadId, { fallbackActivityAt })
5104
+ : desktopIpcActionFollower?.hasLiveThreadState(threadId);
5105
+ const ownerIsFresh = typeof desktopIpcLiveOwner?.isFreshThreadOwned === "function"
5106
+ ? desktopIpcLiveOwner.isFreshThreadOwned(threadId)
5107
+ : false;
5108
+ return Boolean(followerIsFresh) || Boolean(ownerIsFresh);
5109
+ }
5110
+
3973
5111
  module.exports = {
3974
5112
  buildThreadTurnsListRelaySanitizeContext,
3975
5113
  buildHeartbeatBridgeStatus,
3976
- buildRelayCloseStatusError,
3977
5114
  buildRelayAccessTokenHeaders,
3978
5115
  buildRelayUserAgentHeader,
5116
+ canonicalThreadTurnsListRequest,
3979
5117
  createMacOSBridgeWakeAssertion,
5118
+ createThreadTurnsListFastPageCoordinator,
3980
5119
  disableUnsupportedReasoningSummaryForTurnStart,
3981
5120
  fetchAdaptiveThreadTurnsListForRelay,
3982
5121
  hasRelayConnectionGoneStale,
3983
- isTerminalRelayCloseCode,
5122
+ isContextualUserItemNotification,
5123
+ maybeMergeLatestJsonlTurnIntoTurnsListResponse,
5124
+ normalizeTurnStartForCodex,
3984
5125
  normalizeRelayBoundJsonRpcMessage,
3985
5126
  persistBridgePreferences,
3986
5127
  resolveJsonlTurnsListRolloutPathForFallback,
3987
5128
  sanitizeLiveGeneratedImageMessageForRelay,
5129
+ sanitizeLiveUserNotification,
3988
5130
  sanitizeThreadHistoryImagesForRelay,
5131
+ shouldSuppressRolloutMirrorForThread,
3989
5132
  startBridge,
3990
5133
  };