@makerbi/remodex 2.3.2 → 2.5.6

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.
@@ -15,15 +15,19 @@ const {
15
15
  projectDesktopConversationStateToThread,
16
16
  } = require("./desktop-ipc-conversation-projector");
17
17
  const {
18
+ CLIENT_STATUS_CHANGED,
18
19
  DESKTOP_IPC_METHOD_VERSIONS: METHOD_VERSION_BY_NAME,
19
- FRAME_HEADER_BYTES,
20
- MAX_FRAME_BYTES,
20
+ buildIpcRequestEnvelope,
21
21
  cloneJSON,
22
+ createFrameReader,
23
+ isThreadTurnStateProbeRequest,
22
24
  normalizeToken,
23
25
  readString,
24
26
  requestIdKey,
25
27
  resolveDefaultIpcSocketPath,
28
+ resolveIpcSocketPathCandidates,
26
29
  safeParseJSON,
30
+ toSocketPathCandidatesResolver,
27
31
  writeFrame,
28
32
  } = require("./desktop-ipc-shared");
29
33
 
@@ -36,12 +40,13 @@ const MAX_BASELINE_RECOVERY_ATTEMPTS = 5;
36
40
  const BASELINE_RECOVERY_BASE_DELAY_MS = 1_000;
37
41
  const BASELINE_RECOVERY_MAX_DELAY_MS = 15_000;
38
42
  const MAX_QUEUED_CHANGES_PER_THREAD = 300;
39
- const BACKGROUND_DISCONNECT_GRACE_MS = 30_000;
40
43
  // Phone interest survives per-thread release by design, so cap the set to keep a
41
44
  // marathon single Desktop connection from accumulating every thread id forever.
42
45
  const MAX_ACTIVE_THREAD_IDS = 512;
43
46
  const DESKTOP_IPC_ACTION_SOURCE = "desktop-ipc-action-follower";
44
47
  const REMODEX_LIVE_OWNER_SOURCE = "desktop-ipc-live-owner";
48
+ const THREAD_STREAM_FOLLOWING_CHANGED = "thread-stream-following-changed";
49
+ const THREAD_STREAM_FOLLOWING_STATUS_REQUESTED = "thread-stream-following-status-requested";
45
50
  const DESKTOP_STATE_READ_METHODS = new Set([
46
51
  "thread/read",
47
52
  "thread/resume",
@@ -52,13 +57,20 @@ const DESKTOP_STATE_READ_METHODS = new Set([
52
57
  // this, a phone with no selected chat never connects to the Desktop bus and
53
58
  // cannot discover runs that started on the Mac.
54
59
  const DESKTOP_BACKGROUND_DISCOVERY_METHODS = new Set(["thread/list"]);
60
+ // A run is announced to the phone exactly once, and the phone ignores buffered
61
+ // replays of that announcement by design. An app session that starts after the
62
+ // run did would otherwise never learn about it and show no sidebar badge until
63
+ // the chat is opened. Sidebar refreshes re-announce still-active turns instead,
64
+ // throttled per thread so steady polling costs one idempotent notification.
65
+ const BACKGROUND_TURN_REANNOUNCE_MIN_INTERVAL_MS = 30_000;
55
66
  const DESKTOP_TURNS_CURSOR_PREFIX = "remodex-desktop-turns:";
56
- // A cached Desktop state that claims an active turn is only trustworthy while
57
- // Desktop keeps streaming updates for it. Live runs broadcast deltas far more
58
- // often than this window; a silent "active" cache is a stale reconnect echo
59
- // (e.g. Desktop never saw the turn finish) and must not answer phone reads, or
60
- // the phone shows a phantom running indicator until real history loads.
67
+ // Per-thread activity can be quiet during tools and subagents, so the short
68
+ // freshness window alone does not revoke a healthy Desktop owner.
61
69
  const STALE_ACTIVE_READ_MAX_AGE_MS = 20_000;
70
+ // An open Unix socket is not unlimited proof of a functioning publisher. If no
71
+ // frame arrives for this generous lease, yield stale active reads so canonical
72
+ // or rollout recovery can clear a phantom run.
73
+ const CONNECTED_IPC_ACTIVITY_LEASE_MS = 5 * 60_000;
62
74
  const MAX_NORMALIZED_REVIEW_FINGERPRINTS_PER_THREAD = 128;
63
75
  const DESKTOP_FOLLOWER_REQUEST_METHODS = new Set([
64
76
  "turn/start",
@@ -220,16 +232,18 @@ function createDesktopIpcActionFollower({
220
232
  normalizeTurnStartParams = (params) => params,
221
233
  runtimeSettingsStore = null,
222
234
  logPrefix = "[remodex]",
223
- socketPath = resolveDefaultIpcSocketPath(),
235
+ // Resolved per connect: Codex Desktop can start, stop, or move its bus while
236
+ // the bridge stays up.
237
+ socketPath = resolveIpcSocketPathCandidates,
224
238
  netModule = net,
225
239
  now = () => Date.now(),
226
240
  snapshotDebounceMs = 0,
227
241
  setTimeoutFn = setTimeout,
228
242
  clearTimeoutFn = clearTimeout,
229
243
  onNormalizedHistoryIndexRebuilt = () => {},
244
+ onFollowerStateChanged = null,
230
245
  requestTimeoutMs = REQUEST_TIMEOUT_MS,
231
246
  ownershipProbeTimeoutMs = OWNERSHIP_PROBE_TIMEOUT_MS,
232
- backgroundDisconnectGraceMs = BACKGROUND_DISCONNECT_GRACE_MS,
233
247
  } = {}) {
234
248
  const ipc = createDesktopIpcClient({
235
249
  socketPath,
@@ -239,6 +253,7 @@ function createDesktopIpcActionFollower({
239
253
  logPrefix,
240
254
  onEnvelope,
241
255
  onConnected() {
256
+ announceDesktopFollowForActiveThreads();
242
257
  probeHeldFollowerRequests();
243
258
  },
244
259
  onDisconnect,
@@ -260,6 +275,8 @@ function createDesktopIpcActionFollower({
260
275
  const conversationProjector = createDesktopConversationProjector({ now });
261
276
  const pendingRoutesByRequestId = new Map();
262
277
  const activeThreadIds = new Set();
278
+ const desktopFollowThreadIds = new Set();
279
+ const followerClientIdsByThreadId = new Map();
263
280
  // Threads discovered from Litter snapshots before the phone reads them.
264
281
  // Their raw state is retained for lifecycle detection, but their transcript
265
282
  // stays off the relay until the user actually opens the chat.
@@ -268,7 +285,7 @@ function createDesktopIpcActionFollower({
268
285
  // baseline. Otherwise a disconnect/eviction can erase the only evidence
269
286
  // needed to send the matching completion and leave a phantom running badge.
270
287
  const announcedBackgroundTurnsByThreadId = new Map();
271
- const backgroundDisconnectTimersByThreadId = new Map();
288
+ const backgroundTurnReannouncedAtByThreadId = new Map();
272
289
  // JS Set preserves insertion order; delete-before-add refreshes recency, and
273
290
  // cap eviction skips threads with pending prompts so approvals are not lost.
274
291
  function rememberActiveThread(threadId) {
@@ -284,6 +301,35 @@ function createDesktopIpcActionFollower({
284
301
  }
285
302
  }
286
303
 
304
+ function followDesktopThread(threadId, targetClientIds = undefined) {
305
+ if (!threadId || isLocallyOwnedThread(threadId) || liveOwnerThreadIds.has(threadId)) {
306
+ return false;
307
+ }
308
+ desktopFollowThreadIds.add(threadId);
309
+ return ipc.sendBroadcast(THREAD_STREAM_FOLLOWING_CHANGED, {
310
+ hostId: "local",
311
+ conversationId: threadId,
312
+ following: true,
313
+ }, { targetClientIds });
314
+ }
315
+
316
+ function unfollowDesktopThread(threadId) {
317
+ if (!desktopFollowThreadIds.delete(threadId)) {
318
+ return false;
319
+ }
320
+ return ipc.sendBroadcast(THREAD_STREAM_FOLLOWING_CHANGED, {
321
+ hostId: "local",
322
+ conversationId: threadId,
323
+ following: false,
324
+ });
325
+ }
326
+
327
+ function announceDesktopFollowForActiveThreads() {
328
+ for (const threadId of desktopFollowThreadIds) {
329
+ followDesktopThread(threadId);
330
+ }
331
+ }
332
+
287
333
  function oldestEvictableActiveThreadId() {
288
334
  for (const threadId of activeThreadIds) {
289
335
  if (!hasPendingProjectedActions(threadId)
@@ -308,6 +354,7 @@ function createDesktopIpcActionFollower({
308
354
  // without rejecting held requests (removeDesktopThreadState handles real removal).
309
355
  function forgetEvictedThreadState(threadId) {
310
356
  cancelPendingSnapshot(threadId);
357
+ unfollowDesktopThread(threadId);
311
358
  settleAnnouncedBackgroundTurn(threadId, "interrupted");
312
359
  backgroundOnlyThreadIds.delete(threadId);
313
360
  rawStatesByThreadId.delete(threadId);
@@ -347,6 +394,7 @@ function createDesktopIpcActionFollower({
347
394
  const method = readString(message?.method);
348
395
  if (DESKTOP_BACKGROUND_DISCOVERY_METHODS.has(method)) {
349
396
  ipc.ensureConnected();
397
+ reannounceActiveBackgroundTurns();
350
398
  }
351
399
  if (DESKTOP_STATE_READ_METHODS.has(method)) {
352
400
  const threadId = readThreadId(message?.params);
@@ -364,8 +412,8 @@ function createDesktopIpcActionFollower({
364
412
  } else {
365
413
  settleAnnouncedBackgroundTurn(threadId, "interrupted");
366
414
  }
367
- clearBackgroundDisconnectTimer(threadId);
368
415
  announcedBackgroundTurnsByThreadId.delete(threadId);
416
+ backgroundTurnReannouncedAtByThreadId.delete(threadId);
369
417
  if (rawState) {
370
418
  const liveState = boundedDesktopLiveStateForThread(threadId, rawState);
371
419
  const hasCanonicalNormalizedHistory = canonicalHistoryThreadIds.has(threadId)
@@ -480,6 +528,7 @@ function createDesktopIpcActionFollower({
480
528
  ownershipProbeDeadlinesByThreadId.set(threadId, now() + ownershipProbeTimeoutMs);
481
529
  }
482
530
  ipc.ensureConnected();
531
+ followDesktopThread(threadId);
483
532
  return false;
484
533
  }
485
534
 
@@ -497,13 +546,15 @@ function createDesktopIpcActionFollower({
497
546
  normalizedReviewFingerprintsByThreadId.clear();
498
547
  conversationProjector.reset();
499
548
  pendingRoutesByRequestId.clear();
549
+ for (const threadId of desktopFollowThreadIds) {
550
+ unfollowDesktopThread(threadId);
551
+ }
552
+ desktopFollowThreadIds.clear();
500
553
  activeThreadIds.clear();
554
+ followerClientIdsByThreadId.clear();
501
555
  backgroundOnlyThreadIds.clear();
502
556
  announcedBackgroundTurnsByThreadId.clear();
503
- for (const timer of backgroundDisconnectTimersByThreadId.values()) {
504
- clearTimeout(timer);
505
- }
506
- backgroundDisconnectTimersByThreadId.clear();
557
+ backgroundTurnReannouncedAtByThreadId.clear();
507
558
  recoveringThreadIds.clear();
508
559
  baselineRecoveryStateByThreadId.clear();
509
560
  queuedChangesByThreadId.clear();
@@ -522,6 +573,25 @@ function createDesktopIpcActionFollower({
522
573
 
523
574
  // Desktop broadcasts carry the live conversation state Litter projects from.
524
575
  function onEnvelope(envelope) {
576
+ if (envelope?.type === "broadcast" && envelope.method === CLIENT_STATUS_CHANGED) {
577
+ if (normalizeToken(envelope.params?.status) === "disconnected") {
578
+ removeFollowerClient(envelope.params?.clientId || envelope.sourceClientId);
579
+ }
580
+ return;
581
+ }
582
+ if (envelope?.type === "broadcast" && envelope.method === THREAD_STREAM_FOLLOWING_CHANGED) {
583
+ updateFollowerState(envelope);
584
+ return;
585
+ }
586
+ if (envelope?.type === "broadcast"
587
+ && envelope.method === THREAD_STREAM_FOLLOWING_STATUS_REQUESTED) {
588
+ const params = envelope.params || {};
589
+ const threadId = readString(params.conversationId) || readString(params.conversation_id);
590
+ if (threadId && desktopFollowThreadIds.has(threadId)) {
591
+ followDesktopThread(threadId, [envelope.sourceClientId].filter(Boolean));
592
+ }
593
+ return;
594
+ }
525
595
  if (envelope?.type === "broadcast"
526
596
  && (envelope.method === "thread-archived" || envelope.method === "thread-unarchived")) {
527
597
  syncThreadArchiveBroadcast(envelope);
@@ -546,9 +616,6 @@ function createDesktopIpcActionFollower({
546
616
  if (!threadId) {
547
617
  return;
548
618
  }
549
- if (isSnapshotChange(params.change)) {
550
- clearBackgroundDisconnectTimer(threadId);
551
- }
552
619
  const peerOwnershipSnapshot = isPeerOwnershipSnapshot(params);
553
620
  if (peerOwnershipSnapshot && !isLocallyOwnedThread(threadId)) {
554
621
  liveOwnerThreadIds.delete(threadId);
@@ -569,6 +636,14 @@ function createDesktopIpcActionFollower({
569
636
  backgroundOnlyThreadIds.add(threadId);
570
637
  }
571
638
  if (!activeThreadIds.has(threadId)) {
639
+ // After a bridge restart Desktop can re-announce that a renderer follows
640
+ // the thread without replaying its baseline snapshot. A request patch may
641
+ // then be the first state update we see. Preserve that blocking action
642
+ // immediately instead of waiting for a phone read that may never arrive
643
+ // while the already-open screen is parked on the question.
644
+ if (commitSpeculativeActionPatch(threadId, params.change, { backgroundOnly: true })) {
645
+ return;
646
+ }
572
647
  return;
573
648
  }
574
649
 
@@ -590,21 +665,11 @@ function createDesktopIpcActionFollower({
590
665
  const previousState = rawStatesByThreadId.get(threadId) || null;
591
666
  const nextState = applyConversationStateChange(previousState, params.change);
592
667
  if (!nextState) {
593
- if (isPatchChange(params.change)) {
594
- const emptyState = createEmptyConversationState();
595
- const speculativeState = applyConversationStateChange(emptyState, params.change);
596
- const speculativeActions = projectPendingDesktopActions(threadId, speculativeState);
597
- if (speculativeActions.length > 0) {
598
- rawStatesByThreadId.set(threadId, speculativeState);
599
- rawStateUpdatedAtByThreadId.set(threadId, now());
600
- if (!backgroundOnlyThreadIds.has(threadId)) {
601
- conversationProjector.seed(threadId, speculativeState);
602
- }
603
- syncProjectedActions(threadId, speculativeActions);
604
- releaseHeldFollowerRequests(threadId, { toDesktop: true });
605
- return;
606
- }
668
+ if (commitSpeculativeActionPatch(threadId, params.change)) {
669
+ return;
670
+ }
607
671
 
672
+ if (isPatchChange(params.change)) {
608
673
  if (typeof readConversationState !== "function") {
609
674
  return;
610
675
  }
@@ -626,6 +691,31 @@ function createDesktopIpcActionFollower({
626
691
  });
627
692
  }
628
693
 
694
+ function commitSpeculativeActionPatch(threadId, change, { backgroundOnly = false } = {}) {
695
+ if (!isPatchChange(change)) {
696
+ return false;
697
+ }
698
+
699
+ const speculativeState = applyConversationStateChange(createEmptyConversationState(), change);
700
+ const speculativeActions = projectPendingDesktopActions(threadId, speculativeState);
701
+ if (speculativeActions.length === 0) {
702
+ return false;
703
+ }
704
+
705
+ if (backgroundOnly) {
706
+ rememberActiveThread(threadId);
707
+ backgroundOnlyThreadIds.add(threadId);
708
+ }
709
+ rawStatesByThreadId.set(threadId, speculativeState);
710
+ rawStateUpdatedAtByThreadId.set(threadId, now());
711
+ if (!backgroundOnlyThreadIds.has(threadId)) {
712
+ conversationProjector.seed(threadId, speculativeState);
713
+ }
714
+ syncProjectedActions(threadId, speculativeActions);
715
+ releaseHeldFollowerRequests(threadId, { toDesktop: true });
716
+ return true;
717
+ }
718
+
629
719
  function schedulePendingSnapshot(threadId, state) {
630
720
  cancelPendingSnapshot(threadId);
631
721
  const timer = setTimeoutFn(() => {
@@ -744,9 +834,10 @@ function createDesktopIpcActionFollower({
744
834
  queuedChangesByThreadId.clear();
745
835
  pendingOwnershipProbeTokensByThreadId.clear();
746
836
  desktopOwnedByProbeThreadIds.clear();
747
- for (const threadId of announcedBackgroundTurnsByThreadId.keys()) {
748
- scheduleBackgroundDisconnectSettlement(threadId);
749
- }
837
+ clearFollowerState();
838
+ // A lost IPC connection is not evidence that Desktop stopped the turn.
839
+ // Keep announced lifecycle state until a reconnect snapshot, archive, or
840
+ // another authoritative state transition supplies a real terminal status.
750
841
  // Keep activeThreadIds: phone interest is phone-scoped, not connection-scoped.
751
842
  // Clearing it here would make reconnect snapshots for a thread the phone is
752
843
  // still viewing fail the activeThreadIds.has() guard until the phone happens
@@ -759,6 +850,67 @@ function createDesktopIpcActionFollower({
759
850
  // a proven delivery failure falls back to the local app-server.
760
851
  }
761
852
 
853
+ // The same route-follow handshake is used whether Remodex's app-server owns
854
+ // the thread or Codex Desktop owns it. The live-owner component covers the
855
+ // former; this follower covers Desktop-owned threads so the navigation
856
+ // controller can stop retrying as soon as the renderer is actually mounted.
857
+ function updateFollowerState(envelope) {
858
+ const params = envelope?.params || {};
859
+ const threadId = readString(params.conversationId) || readString(params.conversation_id);
860
+ const clientId = readString(envelope?.sourceClientId);
861
+ if (!threadId
862
+ || !clientId
863
+ || clientId === ipc.clientId
864
+ || isLocallyOwnedThread(threadId)
865
+ || liveOwnerThreadIds.has(threadId)) {
866
+ return;
867
+ }
868
+
869
+ const followers = followerClientIdsByThreadId.get(threadId) || new Set();
870
+ if (params.following === true) {
871
+ rememberActiveThread(threadId);
872
+ followDesktopThread(threadId);
873
+ const wasUnfollowed = followers.size === 0;
874
+ followers.add(clientId);
875
+ followerClientIdsByThreadId.set(threadId, followers);
876
+ if (wasUnfollowed) {
877
+ onFollowerStateChanged?.(threadId, true);
878
+ }
879
+ return;
880
+ }
881
+
882
+ if (!followers.delete(clientId)) {
883
+ return;
884
+ }
885
+ if (followers.size === 0) {
886
+ followerClientIdsByThreadId.delete(threadId);
887
+ onFollowerStateChanged?.(threadId, false);
888
+ }
889
+ }
890
+
891
+ function removeFollowerClient(clientId) {
892
+ const normalizedClientId = readString(clientId);
893
+ if (!normalizedClientId) {
894
+ return;
895
+ }
896
+ for (const [threadId, followers] of followerClientIdsByThreadId) {
897
+ if (!followers.delete(normalizedClientId)) {
898
+ continue;
899
+ }
900
+ if (followers.size === 0) {
901
+ followerClientIdsByThreadId.delete(threadId);
902
+ onFollowerStateChanged?.(threadId, false);
903
+ }
904
+ }
905
+ }
906
+
907
+ function clearFollowerState() {
908
+ for (const threadId of followerClientIdsByThreadId.keys()) {
909
+ onFollowerStateChanged?.(threadId, false);
910
+ }
911
+ followerClientIdsByThreadId.clear();
912
+ }
913
+
762
914
  // The bridge's own live owner just claimed this thread's stream, so drop stale
763
915
  // Desktop state instead of hijacking future phone requests into Desktop IPC.
764
916
  function releaseDesktopThreadState(threadId) {
@@ -768,6 +920,7 @@ function createDesktopIpcActionFollower({
768
920
  activeThreadIds.delete(threadId);
769
921
  }
770
922
  liveOwnerThreadIds.add(threadId);
923
+ unfollowDesktopThread(threadId);
771
924
  ownershipProbeDeadlinesByThreadId.delete(threadId);
772
925
  pendingOwnershipProbeTokensByThreadId.delete(threadId);
773
926
  desktopOwnedByProbeThreadIds.delete(threadId);
@@ -798,6 +951,7 @@ function createDesktopIpcActionFollower({
798
951
  activeThreadIds.delete(threadId);
799
952
  }
800
953
  liveOwnerThreadIds.delete(threadId);
954
+ unfollowDesktopThread(threadId);
801
955
  ownershipProbeDeadlinesByThreadId.delete(threadId);
802
956
  pendingOwnershipProbeTokensByThreadId.delete(threadId);
803
957
  desktopOwnedByProbeThreadIds.delete(threadId);
@@ -1012,23 +1166,30 @@ function createDesktopIpcActionFollower({
1012
1166
  }
1013
1167
 
1014
1168
  function syncProjectedActions(threadId, actions) {
1015
- const nextRequestIds = new Set(actions.map((action) => action.id));
1169
+ const nextRequestIds = new Set(actions.map((action) => requestIdKey(action.id)));
1016
1170
  for (const [requestId, route] of Array.from(pendingRoutesByRequestId.entries())) {
1017
1171
  if (route.threadId !== threadId || nextRequestIds.has(requestId)) {
1018
1172
  continue;
1019
1173
  }
1020
1174
 
1021
1175
  pendingRoutesByRequestId.delete(requestId);
1022
- sendApplicationResponse(JSON.stringify(projectedResolvedNotification(threadId, requestId)));
1176
+ sendApplicationResponse(JSON.stringify(
1177
+ projectedResolvedNotification(threadId, route.desktopRequestId)
1178
+ ));
1023
1179
  }
1024
1180
 
1025
1181
  for (const action of actions) {
1026
- if (pendingRoutesByRequestId.has(action.id)) {
1182
+ const requestId = requestIdKey(action.id);
1183
+ if (!requestId || pendingRoutesByRequestId.has(requestId)) {
1027
1184
  continue;
1028
1185
  }
1029
1186
 
1030
- pendingRoutesByRequestId.set(action.id, {
1031
- requestId: action.id,
1187
+ pendingRoutesByRequestId.set(requestId, {
1188
+ requestId,
1189
+ // Preserve the JSON-RPC id's original scalar type. Codex Desktop uses
1190
+ // numeric ids for real requestUserInput calls, and its follower command
1191
+ // must receive that same number rather than the phone-facing map key.
1192
+ desktopRequestId: action.id,
1032
1193
  method: action.method,
1033
1194
  threadId,
1034
1195
  });
@@ -1086,7 +1247,7 @@ function createDesktopIpcActionFollower({
1086
1247
  canonicalHistoryThreadIds.add(threadId);
1087
1248
  }
1088
1249
  if (canonicalHistoryThreadIds.has(threadId)) {
1089
- if (isDesktopLiveTurnStateSnapshotRequest(message)) {
1250
+ if (isThreadTurnStateProbeRequest(message)) {
1090
1251
  const liveState = boundedDesktopLiveStateForThread(threadId, rawState);
1091
1252
  sendApplicationResponse(JSON.stringify({
1092
1253
  id: message.id,
@@ -1100,11 +1261,13 @@ function createDesktopIpcActionFollower({
1100
1261
  return ownsDesktopCursor ? rejectDesktopTurnsCursor(message) : false;
1101
1262
  }
1102
1263
  const thread = projectDesktopConversationStateToThread(threadId, rawState, { now });
1103
- // A run that Desktop stopped streaming updates for is not a live run: serving
1104
- // it from cache would answer thread-list refreshes with a phantom "running"
1105
- // turn until real history loads. Let the local app-server answer instead.
1264
+ // While IPC remains connected, an explicitly active Desktop snapshot stays
1265
+ // authoritative through quiet tools, reasoning, approvals, and subagents.
1266
+ // Falling through to a second app-server here can replace a genuinely
1267
+ // running turn with stale idle history and make the phone clear Stop.
1106
1268
  if (hasActiveProjectedTurn(thread)
1107
1269
  && isRawStateStaleForActiveRead(threadId)
1270
+ && !hasResponsiveDesktopIpc()
1108
1271
  && !ownsDesktopCursor) {
1109
1272
  staleYieldedThreadIds.add(threadId);
1110
1273
  return false;
@@ -1139,21 +1302,6 @@ function createDesktopIpcActionFollower({
1139
1302
  return true;
1140
1303
  }
1141
1304
 
1142
- function isDesktopLiveTurnStateSnapshotRequest(message) {
1143
- if (readString(message?.method) !== "thread/turns/list"
1144
- || readString(message?.params?.cursor)
1145
- || message?.params?.remodexRequireCanonical === true) {
1146
- return false;
1147
- }
1148
- if (message?.params?.remodexTurnStateOnly === true) {
1149
- return true;
1150
- }
1151
- // Remodex iPhone 2.1 predates the explicit marker. Its running-state probe
1152
- // has this unique request shape; actual history pages use limits 1 and 5.
1153
- return Number(message?.params?.limit) === 8
1154
- && normalizeToken(readString(message?.params?.sortDirection) || "desc") === "desc";
1155
- }
1156
-
1157
1305
  function buildDesktopLiveTurnStateResult(turns) {
1158
1306
  const data = (Array.isArray(turns) ? turns : [])
1159
1307
  .slice()
@@ -1215,6 +1363,10 @@ function createDesktopIpcActionFollower({
1215
1363
  return now() - updatedAt > STALE_ACTIVE_READ_MAX_AGE_MS;
1216
1364
  }
1217
1365
 
1366
+ function hasResponsiveDesktopIpc() {
1367
+ return ipc.hasRecentActivity(CONNECTED_IPC_ACTIVITY_LEASE_MS);
1368
+ }
1369
+
1218
1370
  function boundedDesktopLiveStateForThread(threadId, state) {
1219
1371
  return boundedDesktopLiveState(
1220
1372
  state,
@@ -1242,6 +1394,8 @@ function createDesktopIpcActionFollower({
1242
1394
  const previousById = desktopLiveLifecycleByThreadId.get(threadId) || new Map();
1243
1395
  const nextTurns = activeDesktopTurnDescriptors(liveState);
1244
1396
  const nextById = new Map(nextTurns.map((turn) => [turn.id, turn]));
1397
+ const completedTurnIds = new Set();
1398
+ const startedTurnIds = new Set();
1245
1399
  const {
1246
1400
  previousTurnIds: continuityPreviousTurnIds,
1247
1401
  nextTurnIds: continuityNextTurnIds,
@@ -1266,6 +1420,7 @@ function createDesktopIpcActionFollower({
1266
1420
  threadId,
1267
1421
  { id: previous.id, status: terminalStatus }
1268
1422
  )));
1423
+ completedTurnIds.add(previous.id);
1269
1424
  }
1270
1425
  for (const next of nextTurns) {
1271
1426
  if (previousById.has(next.id)) {
@@ -1281,7 +1436,52 @@ function createDesktopIpcActionFollower({
1281
1436
  ? notificationWithTurnIdentityContinuity(startedNotification)
1282
1437
  : startedNotification
1283
1438
  ));
1439
+ startedTurnIds.add(next.id);
1284
1440
  }
1441
+ reannounceRemainingParallelTurn(
1442
+ threadId,
1443
+ previousById,
1444
+ nextTurns,
1445
+ completedTurnIds,
1446
+ startedTurnIds
1447
+ );
1448
+ }
1449
+
1450
+ // iOS tracks one phone-visible active turn per thread. If parallel turn B was
1451
+ // the most recently announced turn and finishes while older A is still live,
1452
+ // B's completion clears that slot. Re-announce A as continuity so Stop and
1453
+ // running state stay attached to the existing run without advancing its
1454
+ // generation. A newly started replacement already performs this handoff.
1455
+ function reannounceRemainingParallelTurn(
1456
+ threadId,
1457
+ previousById,
1458
+ nextTurns,
1459
+ completedTurnIds,
1460
+ startedTurnIds
1461
+ ) {
1462
+ const restorationTurnId = remainingParallelTurnRestorationId(
1463
+ previousById,
1464
+ nextTurns,
1465
+ completedTurnIds
1466
+ );
1467
+ if (!restorationTurnId || startedTurnIds.has(restorationTurnId)) {
1468
+ return;
1469
+ }
1470
+ const nextVisibleTurn = nextTurns.find((turn) => turn.id === restorationTurnId);
1471
+ sendApplicationResponse(JSON.stringify(notificationWithTurnIdentityContinuity(
1472
+ desktopLiveTurnLifecycleNotification("turn/started", threadId, nextVisibleTurn)
1473
+ )));
1474
+ }
1475
+
1476
+ function remainingParallelTurnRestorationId(previousById, nextTurns, completedTurnIds) {
1477
+ const previousVisibleTurn = [...previousById.values()].at(-1);
1478
+ if (!previousVisibleTurn || !completedTurnIds.has(previousVisibleTurn.id)) {
1479
+ return "";
1480
+ }
1481
+ const nextVisibleTurn = nextTurns.at(-1);
1482
+ return nextVisibleTurn && previousById.has(nextVisibleTurn.id)
1483
+ ? nextVisibleTurn.id
1484
+ : "";
1285
1485
  }
1286
1486
 
1287
1487
  function syncProjectedConversationState(threadId, nextState, { isFullSnapshot = false } = {}) {
@@ -1298,10 +1498,13 @@ function createDesktopIpcActionFollower({
1298
1498
  && !canonicalHistoryReplacementSentThreadIds.has(threadId)) {
1299
1499
  canonicalHistoryReplacementSentThreadIds.add(threadId);
1300
1500
  conversationProjector.remove(threadId);
1301
- // Seed the bounded live tail before asking the phone for canonical
1302
- // history. Subsequent 8-50ms Desktop patches then become small deltas
1303
- // instead of replaying hundreds of current-turn items as a baseline.
1304
- conversationProjector.seed(threadId, liveState);
1501
+ // Project the bounded active tail before asking the phone for canonical
1502
+ // history. On reconnect this snapshot may contain output produced while
1503
+ // IPC was unavailable; silently seeding it would permanently eat that
1504
+ // content when canonical JSONL/rollout history is still behind.
1505
+ const bootstrapOutput = conversationProjector.project(threadId, liveState, {
1506
+ includeAllActiveTurns: true,
1507
+ });
1305
1508
  sendApplicationResponse(JSON.stringify({
1306
1509
  method: "thread/replaced",
1307
1510
  params: {
@@ -1316,6 +1519,11 @@ function createDesktopIpcActionFollower({
1316
1519
  normalizedLiveIndexesByThreadId.get(threadId)
1317
1520
  );
1318
1521
  emitDesktopSnapshotLifecycleTransition(threadId, liveState);
1522
+ for (const notification of bootstrapOutput.notifications || []) {
1523
+ if (readString(notification?.method).startsWith("item/")) {
1524
+ sendApplicationResponse(JSON.stringify(notification));
1525
+ }
1526
+ }
1319
1527
  rememberDesktopLiveProjection(threadId, liveState);
1320
1528
  return;
1321
1529
  }
@@ -1361,16 +1569,39 @@ function createDesktopIpcActionFollower({
1361
1569
  },
1362
1570
  }));
1363
1571
  }
1364
- for (const notification of output.notifications || []) {
1572
+ const outputNotifications = output.notifications || [];
1573
+ const previousById = desktopLiveLifecycleByThreadId.get(threadId) || new Map();
1574
+ const nextTurns = activeDesktopTurnDescriptors(liveState);
1575
+ const completedTurnIds = new Set(outputNotifications
1576
+ .filter((notification) => notification.method === "turn/completed")
1577
+ .map((notification) => readString(notification.params?.turnId))
1578
+ .filter(Boolean));
1579
+ const startedTurnIds = new Set(outputNotifications
1580
+ .filter((notification) => notification.method === "turn/started")
1581
+ .map((notification) => readString(notification.params?.turnId))
1582
+ .filter(Boolean));
1583
+ const parallelRestorationTurnId = remainingParallelTurnRestorationId(
1584
+ previousById,
1585
+ nextTurns,
1586
+ completedTurnIds
1587
+ );
1588
+ for (const notification of outputNotifications) {
1589
+ const notificationTurnId = readString(notification.params?.turnId);
1365
1590
  const preservesTurnIdentity = notification.method === "turn/started"
1366
- && output.turnIdentityContinuityTurnIds?.includes(
1367
- readString(notification.params?.turnId)
1368
- );
1591
+ && (parallelRestorationTurnId === notificationTurnId
1592
+ || output.turnIdentityContinuityTurnIds?.includes(notificationTurnId));
1369
1593
  const projectedNotification = preservesTurnIdentity
1370
1594
  ? notificationWithTurnIdentityContinuity(notification)
1371
1595
  : notification;
1372
1596
  sendApplicationResponse(JSON.stringify(projectedNotification));
1373
1597
  }
1598
+ reannounceRemainingParallelTurn(
1599
+ threadId,
1600
+ previousById,
1601
+ nextTurns,
1602
+ completedTurnIds,
1603
+ startedTurnIds
1604
+ );
1374
1605
  rememberDesktopLiveProjection(threadId, liveState);
1375
1606
  }
1376
1607
 
@@ -1470,14 +1701,12 @@ function createDesktopIpcActionFollower({
1470
1701
  nextActiveTurn
1471
1702
  )));
1472
1703
  announcedBackgroundTurnsByThreadId.set(threadId, nextActiveTurn);
1473
- clearBackgroundDisconnectTimer(threadId);
1474
1704
  }
1475
1705
  }
1476
1706
 
1477
1707
  function settleAnnouncedBackgroundTurn(threadId, status = "interrupted", turn = null) {
1478
1708
  const announcedTurn = announcedBackgroundTurnsByThreadId.get(threadId);
1479
1709
  if (!announcedTurn) {
1480
- clearBackgroundDisconnectTimer(threadId);
1481
1710
  return false;
1482
1711
  }
1483
1712
  const settledTurn = {
@@ -1492,34 +1721,34 @@ function createDesktopIpcActionFollower({
1492
1721
  settledTurn
1493
1722
  )));
1494
1723
  announcedBackgroundTurnsByThreadId.delete(threadId);
1495
- clearBackgroundDisconnectTimer(threadId);
1724
+ backgroundTurnReannouncedAtByThreadId.delete(threadId);
1496
1725
  return true;
1497
1726
  }
1498
1727
 
1499
- function scheduleBackgroundDisconnectSettlement(threadId) {
1500
- if (!announcedBackgroundTurnsByThreadId.has(threadId)
1501
- || backgroundDisconnectTimersByThreadId.has(threadId)) {
1728
+ // Re-sends the start of runs that are still in flight, so a phone that missed
1729
+ // the original announcement (fresh app session, reconnect after the run began)
1730
+ // gets its sidebar badge on the next sidebar refresh instead of only when the
1731
+ // chat is opened. The phone treats a repeated start for the same turn as a
1732
+ // no-op, and the identity marker keeps a phone that already saw it from
1733
+ // treating this as a second run. Only worth doing while Desktop IPC is
1734
+ // responsive: the matching completion arrives over the same link, so this
1735
+ // cannot keep a phantom badge alive on its own.
1736
+ function reannounceActiveBackgroundTurns() {
1737
+ if (announcedBackgroundTurnsByThreadId.size === 0 || !hasResponsiveDesktopIpc()) {
1502
1738
  return;
1503
1739
  }
1504
- const expectedTurnId = announcedBackgroundTurnsByThreadId.get(threadId)?.id;
1505
- const timer = setTimeout(() => {
1506
- backgroundDisconnectTimersByThreadId.delete(threadId);
1507
- if (announcedBackgroundTurnsByThreadId.get(threadId)?.id !== expectedTurnId) {
1508
- return;
1509
- }
1510
- settleAnnouncedBackgroundTurn(threadId, "interrupted");
1511
- }, Math.max(0, backgroundDisconnectGraceMs));
1512
- timer.unref?.();
1513
- backgroundDisconnectTimersByThreadId.set(threadId, timer);
1514
- }
1515
1740
 
1516
- function clearBackgroundDisconnectTimer(threadId) {
1517
- const timer = backgroundDisconnectTimersByThreadId.get(threadId);
1518
- if (!timer) {
1519
- return;
1741
+ const reannouncedAt = now();
1742
+ for (const [threadId, announcedTurn] of announcedBackgroundTurnsByThreadId) {
1743
+ const lastReannouncedAt = backgroundTurnReannouncedAtByThreadId.get(threadId) || 0;
1744
+ if (reannouncedAt - lastReannouncedAt < BACKGROUND_TURN_REANNOUNCE_MIN_INTERVAL_MS) {
1745
+ continue;
1746
+ }
1747
+ backgroundTurnReannouncedAtByThreadId.set(threadId, reannouncedAt);
1748
+ sendApplicationResponse(JSON.stringify(notificationWithTurnIdentityContinuity(
1749
+ backgroundTurnLifecycleNotification("turn/started", threadId, announcedTurn)
1750
+ )));
1520
1751
  }
1521
- clearTimeout(timer);
1522
- backgroundDisconnectTimersByThreadId.delete(threadId);
1523
1752
  }
1524
1753
 
1525
1754
  function syncThreadArchiveBroadcast(envelope) {
@@ -1584,7 +1813,7 @@ function createDesktopIpcActionFollower({
1584
1813
  .then(() => {
1585
1814
  pendingRoutesByRequestId.delete(route.requestId);
1586
1815
  sendApplicationResponse(JSON.stringify(
1587
- projectedResolvedNotification(route.threadId, route.requestId)
1816
+ projectedResolvedNotification(route.threadId, route.desktopRequestId)
1588
1817
  ));
1589
1818
  })
1590
1819
  .catch((error) => {
@@ -1914,7 +2143,9 @@ function createDesktopIpcActionFollower({
1914
2143
  return false;
1915
2144
  }
1916
2145
  const thread = projectDesktopConversationStateToThread(normalizedThreadId, liveState, { now });
1917
- if (hasActiveProjectedTurn(thread) && isRawStateStaleForActiveRead(normalizedThreadId)) {
2146
+ if (hasActiveProjectedTurn(thread)
2147
+ && isRawStateStaleForActiveRead(normalizedThreadId)
2148
+ && !hasResponsiveDesktopIpc()) {
1918
2149
  staleYieldedThreadIds.add(normalizedThreadId);
1919
2150
  return false;
1920
2151
  }
@@ -1945,7 +2176,7 @@ function createDesktopIpcActionFollower({
1945
2176
  if (hasActiveProjectedTurn(thread)) {
1946
2177
  const updatedAt = rawStateUpdatedAtByThreadId.get(normalizedThreadId) || 0;
1947
2178
  const hasNewerFallbackActivity = Number(fallbackActivityAt) > updatedAt;
1948
- return ipc.isConnected()
2179
+ return hasResponsiveDesktopIpc()
1949
2180
  && (!isRawStateStaleForActiveRead(normalizedThreadId) || !hasNewerFallbackActivity);
1950
2181
  }
1951
2182
  return !isRawStateStaleForActiveRead(normalizedThreadId);
@@ -1964,20 +2195,37 @@ function createDesktopIpcClient({
1964
2195
  onConnected,
1965
2196
  onDisconnect,
1966
2197
  }) {
2198
+ const resolveSocketPaths = toSocketPathCandidatesResolver(socketPath);
1967
2199
  let socket = null;
1968
2200
  let clientId = "";
1969
2201
  let isConnecting = false;
1970
- let readBuffer = Buffer.alloc(0);
2202
+ let lastActivityAt = 0;
2203
+ let remainingSocketPaths = [];
1971
2204
  const pendingRequests = new Map();
1972
2205
  const pendingDiscoveries = new Map();
2206
+ const frameReader = createFrameReader({
2207
+ onFrame: (envelope) => dispatchEnvelope(envelope),
2208
+ onOverflow: () => close(),
2209
+ });
1973
2210
 
1974
2211
  function ensureConnected() {
1975
2212
  if (socket || isConnecting) {
1976
2213
  return;
1977
2214
  }
1978
2215
 
2216
+ remainingSocketPaths = resolveSocketPaths();
2217
+ connectNextSocket();
2218
+ }
2219
+
2220
+ function connectNextSocket() {
2221
+ const nextSocketPath = remainingSocketPaths.shift();
2222
+ if (!nextSocketPath) {
2223
+ isConnecting = false;
2224
+ return;
2225
+ }
2226
+
1979
2227
  isConnecting = true;
1980
- const nextSocket = netModule.createConnection(socketPath);
2228
+ const nextSocket = netModule.createConnection(nextSocketPath);
1981
2229
  socket = nextSocket;
1982
2230
 
1983
2231
  nextSocket.on("connect", () => {
@@ -1993,14 +2241,29 @@ function createDesktopIpcClient({
1993
2241
  });
1994
2242
  });
1995
2243
  nextSocket.on("data", handleData);
1996
- nextSocket.on("close", handleClose);
2244
+ nextSocket.on("close", () => handleClose(nextSocket));
1997
2245
  nextSocket.on("error", (error) => {
2246
+ if ((error?.code === "ENOENT" || error?.code === "ECONNREFUSED")
2247
+ && remainingSocketPaths.length > 0) {
2248
+ retryNextSocket(nextSocket);
2249
+ return;
2250
+ }
1998
2251
  if (error?.code !== "ENOENT" && error?.code !== "ECONNREFUSED") {
1999
2252
  console.warn(`${logPrefix} desktop IPC connection failed: ${error.message}`);
2000
2253
  }
2001
2254
  });
2002
2255
  }
2003
2256
 
2257
+ function retryNextSocket(failedSocket) {
2258
+ if (socket === failedSocket) {
2259
+ socket = null;
2260
+ }
2261
+ isConnecting = false;
2262
+ frameReader.reset();
2263
+ failedSocket.destroy();
2264
+ connectNextSocket();
2265
+ }
2266
+
2004
2267
  function sendRequest(method, params) {
2005
2268
  ensureConnected();
2006
2269
  if (!socket || socket.destroyed) {
@@ -2008,14 +2271,13 @@ function createDesktopIpcClient({
2008
2271
  }
2009
2272
 
2010
2273
  const requestId = `remodex-${now().toString(36)}-${Math.random().toString(16).slice(2)}`;
2011
- const envelope = {
2012
- type: "request",
2274
+ const envelope = buildIpcRequestEnvelope({
2013
2275
  requestId,
2014
- sourceClientId: method === "initialize" ? "initializing-client" : clientId || "remodex-bridge",
2015
- version: METHOD_VERSION_BY_NAME.get(method) || 1,
2016
2276
  method,
2017
- params: params || {},
2018
- };
2277
+ params,
2278
+ clientId,
2279
+ initializing: method === "initialize",
2280
+ });
2019
2281
 
2020
2282
  return new Promise((resolve, reject) => {
2021
2283
  const timeout = setTimeout(() => {
@@ -2077,25 +2339,30 @@ function createDesktopIpcClient({
2077
2339
  });
2078
2340
  }
2079
2341
 
2080
- function handleData(chunk) {
2081
- readBuffer = Buffer.concat([readBuffer, chunk]);
2082
- while (readBuffer.length >= FRAME_HEADER_BYTES) {
2083
- const frameLength = readBuffer.readUInt32LE(0);
2084
- if (frameLength > MAX_FRAME_BYTES) {
2085
- close();
2086
- return;
2087
- }
2088
- if (readBuffer.length < FRAME_HEADER_BYTES + frameLength) {
2089
- return;
2090
- }
2342
+ function sendBroadcast(method, params, { targetClientIds } = {}) {
2343
+ ensureConnected();
2344
+ if (!socket || socket.destroyed || !clientId) {
2345
+ return false;
2346
+ }
2347
+ const envelope = {
2348
+ type: "broadcast",
2349
+ method,
2350
+ sourceClientId: clientId,
2351
+ params: params || {},
2352
+ version: METHOD_VERSION_BY_NAME.get(method) || 1,
2353
+ };
2354
+ if (Array.isArray(targetClientIds) && targetClientIds.length > 0) {
2355
+ envelope.targetClientIds = targetClientIds;
2356
+ }
2357
+ writeEnvelope(envelope);
2358
+ return true;
2359
+ }
2091
2360
 
2092
- const payload = readBuffer.slice(FRAME_HEADER_BYTES, FRAME_HEADER_BYTES + frameLength).toString("utf8");
2093
- readBuffer = readBuffer.slice(FRAME_HEADER_BYTES + frameLength);
2094
- const envelope = safeParseJSON(payload);
2095
- if (envelope) {
2096
- dispatchEnvelope(envelope);
2097
- }
2361
+ function handleData(chunk) {
2362
+ if (chunk.length > 0) {
2363
+ lastActivityAt = now();
2098
2364
  }
2365
+ frameReader.push(chunk);
2099
2366
  }
2100
2367
 
2101
2368
  function dispatchEnvelope(envelope) {
@@ -2149,11 +2416,15 @@ function createDesktopIpcClient({
2149
2416
  onEnvelope(envelope);
2150
2417
  }
2151
2418
 
2152
- function handleClose() {
2419
+ function handleClose(closedSocket) {
2420
+ if (socket && socket !== closedSocket) {
2421
+ return;
2422
+ }
2153
2423
  socket = null;
2154
2424
  clientId = "";
2155
2425
  isConnecting = false;
2156
- readBuffer = Buffer.alloc(0);
2426
+ lastActivityAt = 0;
2427
+ frameReader.reset();
2157
2428
  for (const waiter of pendingRequests.values()) {
2158
2429
  clearTimeout(waiter.timeout);
2159
2430
  waiter.reject(new Error("Desktop IPC connection closed."));
@@ -2187,12 +2458,21 @@ function createDesktopIpcClient({
2187
2458
  }
2188
2459
 
2189
2460
  return {
2461
+ get clientId() {
2462
+ return clientId;
2463
+ },
2190
2464
  ensureConnected,
2191
2465
  isConnected() {
2192
2466
  return Boolean(socket && !socket.destroyed && clientId);
2193
2467
  },
2468
+ hasRecentActivity(maxAgeMs) {
2469
+ return Boolean(socket && !socket.destroyed && clientId)
2470
+ && lastActivityAt > 0
2471
+ && now() - lastActivityAt <= Math.max(0, Number(maxAgeMs) || 0);
2472
+ },
2194
2473
  sendRequest,
2195
2474
  sendDiscoveryRequest,
2475
+ sendBroadcast,
2196
2476
  close,
2197
2477
  };
2198
2478
  }
@@ -2213,7 +2493,7 @@ function desktopFollowerPayloadForResponse(route, responseMessage) {
2213
2493
  method,
2214
2494
  params: {
2215
2495
  conversationId: route.threadId,
2216
- requestId: route.requestId,
2496
+ requestId: route.desktopRequestId ?? route.requestId,
2217
2497
  response: {
2218
2498
  answers,
2219
2499
  },
@@ -2391,7 +2671,9 @@ function projectPendingDesktopAction(threadId, request) {
2391
2671
  }
2392
2672
 
2393
2673
  return {
2394
- id: requestId,
2674
+ // Keep the original JSON-RPC scalar for the relay request and the Desktop
2675
+ // follower reply. requestIdKey is only an internal map/deduplication key.
2676
+ id: request.id,
2395
2677
  method,
2396
2678
  params: {
2397
2679
  ...params,
@@ -2717,7 +2999,8 @@ function boundedDesktopLiveTurns(
2717
2999
  if (orderedTurns.length <= 1) {
2718
3000
  return normalizeBoundedTurnsForRuntime(
2719
3001
  orderedTurns.map((turn, index) => withStableProjectedTurnId(turn, index)),
2720
- state
3002
+ state,
3003
+ retainedTurnIds
2721
3004
  );
2722
3005
  }
2723
3006
 
@@ -2744,7 +3027,7 @@ function boundedDesktopLiveTurns(
2744
3027
  const selectedTurns = Array.from(selectedIndexes)
2745
3028
  .sort((left, right) => left - right)
2746
3029
  .map((index) => withStableProjectedTurnId(orderedTurns[index], index));
2747
- return normalizeBoundedTurnsForRuntime(selectedTurns, state);
3030
+ return normalizeBoundedTurnsForRuntime(selectedTurns, state, retainedTurnIds);
2748
3031
  }
2749
3032
 
2750
3033
  function boundedIndexedDesktopLiveTurns(state, index, nowValue, retainedTurnIds) {
@@ -2791,7 +3074,7 @@ function boundedIndexedDesktopLiveTurns(state, index, nowValue, retainedTurnIds)
2791
3074
  return turnIdOf(turn) ? turn : { ...turn, id: entry.id };
2792
3075
  })
2793
3076
  .filter(Boolean);
2794
- return normalizeBoundedTurnsForRuntime(selectedTurns, state);
3077
+ return normalizeBoundedTurnsForRuntime(selectedTurns, state, retainedTurnIds);
2795
3078
  }
2796
3079
 
2797
3080
  function resolveIndexedTurn(state, entry) {
@@ -2868,12 +3151,12 @@ function notificationWithTurnIdentityContinuity(notification) {
2868
3151
  };
2869
3152
  }
2870
3153
 
2871
- function normalizeBoundedTurnsForRuntime(turns, state) {
3154
+ function normalizeBoundedTurnsForRuntime(turns, state, retainedTurnIds = new Set()) {
2872
3155
  if (!isExplicitlyIdleDesktopRuntime(state)) {
2873
3156
  return turns;
2874
3157
  }
2875
3158
  return turns.map((turn) => (
2876
- isActiveRawTurn(turn)
3159
+ isActiveRawTurn(turn) && !retainedTurnIds.has(turnIdOf(turn))
2877
3160
  ? { ...turn, status: "completed" }
2878
3161
  : turn
2879
3162
  ));