@makerbi/remodex 2.3.1 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,6 +11,7 @@ const {
11
11
  createDesktopConversationProjector,
12
12
  desktopTurnsShareLogicalIdentity,
13
13
  matchDesktopTurnIdentityContinuities,
14
+ projectDesktopConversationStateToGoal,
14
15
  projectDesktopConversationStateToThread,
15
16
  } = require("./desktop-ipc-conversation-projector");
16
17
  const {
@@ -18,6 +19,7 @@ const {
18
19
  FRAME_HEADER_BYTES,
19
20
  MAX_FRAME_BYTES,
20
21
  cloneJSON,
22
+ isThreadTurnStateProbeRequest,
21
23
  normalizeToken,
22
24
  readString,
23
25
  requestIdKey,
@@ -35,30 +37,44 @@ const MAX_BASELINE_RECOVERY_ATTEMPTS = 5;
35
37
  const BASELINE_RECOVERY_BASE_DELAY_MS = 1_000;
36
38
  const BASELINE_RECOVERY_MAX_DELAY_MS = 15_000;
37
39
  const MAX_QUEUED_CHANGES_PER_THREAD = 300;
38
- const BACKGROUND_DISCONNECT_GRACE_MS = 30_000;
39
40
  // Phone interest survives per-thread release by design, so cap the set to keep a
40
41
  // marathon single Desktop connection from accumulating every thread id forever.
41
42
  const MAX_ACTIVE_THREAD_IDS = 512;
42
43
  const DESKTOP_IPC_ACTION_SOURCE = "desktop-ipc-action-follower";
43
44
  const REMODEX_LIVE_OWNER_SOURCE = "desktop-ipc-live-owner";
44
- const DESKTOP_STATE_READ_METHODS = new Set(["thread/read", "thread/resume", "thread/turns/list"]);
45
+ const DESKTOP_STATE_READ_METHODS = new Set([
46
+ "thread/read",
47
+ "thread/resume",
48
+ "thread/turns/list",
49
+ "thread/goal/get",
50
+ ]);
45
51
  // Sidebar refreshes should also keep the Litter subscription alive. Without
46
52
  // this, a phone with no selected chat never connects to the Desktop bus and
47
53
  // cannot discover runs that started on the Mac.
48
54
  const DESKTOP_BACKGROUND_DISCOVERY_METHODS = new Set(["thread/list"]);
49
55
  const DESKTOP_TURNS_CURSOR_PREFIX = "remodex-desktop-turns:";
50
- // A cached Desktop state that claims an active turn is only trustworthy while
51
- // Desktop keeps streaming updates for it. Live runs broadcast deltas far more
52
- // often than this window; a silent "active" cache is a stale reconnect echo
53
- // (e.g. Desktop never saw the turn finish) and must not answer phone reads, or
54
- // the phone shows a phantom running indicator until real history loads.
56
+ // Per-thread activity can be quiet during tools and subagents, so the short
57
+ // freshness window alone does not revoke a healthy Desktop owner.
55
58
  const STALE_ACTIVE_READ_MAX_AGE_MS = 20_000;
59
+ // An open Unix socket is not unlimited proof of a functioning publisher. If no
60
+ // frame arrives for this generous lease, yield stale active reads so canonical
61
+ // or rollout recovery can clear a phantom run.
62
+ const CONNECTED_IPC_ACTIVITY_LEASE_MS = 5 * 60_000;
63
+ const MAX_NORMALIZED_REVIEW_FINGERPRINTS_PER_THREAD = 128;
56
64
  const DESKTOP_FOLLOWER_REQUEST_METHODS = new Set([
57
65
  "turn/start",
58
66
  "turn/steer",
59
67
  "turn/interrupt",
60
68
  "thread/compact/start",
61
69
  ]);
70
+ // These mutations have no Codex Desktop follower command. Never leak them to
71
+ // the bridge's separate local app-server owner, which could create a competing
72
+ // owner for the same persisted thread.
73
+ const DESKTOP_OWNER_UNSUPPORTED_MUTATION_ERRORS = new Map([
74
+ ["review/start", "Start this review in Codex Desktop."],
75
+ ["thread/settings/update", "Change these thread settings in Codex Desktop."],
76
+ ["thread/approveGuardianDeniedAction", "Approve this retry in Codex Desktop."],
77
+ ]);
62
78
  const ACTION_METHODS = new Set([
63
79
  "item/commandExecution/requestApproval",
64
80
  "item/fileChange/requestApproval",
@@ -214,7 +230,6 @@ function createDesktopIpcActionFollower({
214
230
  onNormalizedHistoryIndexRebuilt = () => {},
215
231
  requestTimeoutMs = REQUEST_TIMEOUT_MS,
216
232
  ownershipProbeTimeoutMs = OWNERSHIP_PROBE_TIMEOUT_MS,
217
- backgroundDisconnectGraceMs = BACKGROUND_DISCONNECT_GRACE_MS,
218
233
  } = {}) {
219
234
  const ipc = createDesktopIpcClient({
220
235
  socketPath,
@@ -240,6 +255,7 @@ function createDesktopIpcActionFollower({
240
255
  const projectedLiveActiveTurnIdsByThreadId = new Map();
241
256
  const desktopLiveLifecycleByThreadId = new Map();
242
257
  const normalizedLiveIndexesByThreadId = new Map();
258
+ const normalizedReviewFingerprintsByThreadId = new Map();
243
259
  const staleYieldedThreadIds = new Set();
244
260
  const conversationProjector = createDesktopConversationProjector({ now });
245
261
  const pendingRoutesByRequestId = new Map();
@@ -252,7 +268,6 @@ function createDesktopIpcActionFollower({
252
268
  // baseline. Otherwise a disconnect/eviction can erase the only evidence
253
269
  // needed to send the matching completion and leave a phantom running badge.
254
270
  const announcedBackgroundTurnsByThreadId = new Map();
255
- const backgroundDisconnectTimersByThreadId = new Map();
256
271
  // JS Set preserves insertion order; delete-before-add refreshes recency, and
257
272
  // cap eviction skips threads with pending prompts so approvals are not lost.
258
273
  function rememberActiveThread(threadId) {
@@ -301,6 +316,7 @@ function createDesktopIpcActionFollower({
301
316
  projectedLiveActiveTurnIdsByThreadId.delete(threadId);
302
317
  desktopLiveLifecycleByThreadId.delete(threadId);
303
318
  normalizedLiveIndexesByThreadId.delete(threadId);
319
+ normalizedReviewFingerprintsByThreadId.delete(threadId);
304
320
  conversationProjector.remove(threadId);
305
321
  queuedChangesByThreadId.delete(threadId);
306
322
  baselineRecoveryStateByThreadId.delete(threadId);
@@ -347,7 +363,6 @@ function createDesktopIpcActionFollower({
347
363
  } else {
348
364
  settleAnnouncedBackgroundTurn(threadId, "interrupted");
349
365
  }
350
- clearBackgroundDisconnectTimer(threadId);
351
366
  announcedBackgroundTurnsByThreadId.delete(threadId);
352
367
  if (rawState) {
353
368
  const liveState = boundedDesktopLiveStateForThread(threadId, rawState);
@@ -372,6 +387,14 @@ function createDesktopIpcActionFollower({
372
387
  remodexActionSource: DESKTOP_IPC_ACTION_SOURCE,
373
388
  },
374
389
  }));
390
+ // Guardian reviews on normalized-only history turns never appear in
391
+ // canonical thread/read history, so drain their overlays here just
392
+ // like syncProjectedConversationState does; otherwise a thread that
393
+ // stays idle after opening never delivers them.
394
+ emitNormalizedReviewOverlays(
395
+ threadId,
396
+ normalizedLiveIndexesByThreadId.get(threadId)
397
+ );
375
398
  const output = conversationProjector.project(threadId, liveState, {
376
399
  includeAllActiveTurns: true,
377
400
  });
@@ -414,6 +437,27 @@ function createDesktopIpcActionFollower({
414
437
  }
415
438
  }
416
439
 
440
+ if (DESKTOP_OWNER_UNSUPPORTED_MUTATION_ERRORS.has(method)) {
441
+ const threadId = readThreadId(message?.params);
442
+ const mayBelongToDesktop = threadId
443
+ && !liveOwnerThreadIds.has(threadId)
444
+ && !isLocallyOwnedThread(threadId)
445
+ && (isDesktopRoutableThread(threadId)
446
+ || activeThreadIds.has(threadId)
447
+ || ownershipProbeDeadlinesByThreadId.has(threadId)
448
+ || pendingOwnershipProbeTokensByThreadId.has(threadId));
449
+ if (mayBelongToDesktop) {
450
+ sendApplicationResponse(JSON.stringify({
451
+ id: message?.id,
452
+ error: {
453
+ code: -32004,
454
+ message: DESKTOP_OWNER_UNSUPPORTED_MUTATION_ERRORS.get(method),
455
+ },
456
+ }));
457
+ return true;
458
+ }
459
+ }
460
+
417
461
  if (tryServeDesktopOwnedRead(message)) {
418
462
  return true;
419
463
  }
@@ -448,15 +492,12 @@ function createDesktopIpcActionFollower({
448
492
  projectedLiveActiveTurnIdsByThreadId.clear();
449
493
  desktopLiveLifecycleByThreadId.clear();
450
494
  normalizedLiveIndexesByThreadId.clear();
495
+ normalizedReviewFingerprintsByThreadId.clear();
451
496
  conversationProjector.reset();
452
497
  pendingRoutesByRequestId.clear();
453
498
  activeThreadIds.clear();
454
499
  backgroundOnlyThreadIds.clear();
455
500
  announcedBackgroundTurnsByThreadId.clear();
456
- for (const timer of backgroundDisconnectTimersByThreadId.values()) {
457
- clearTimeout(timer);
458
- }
459
- backgroundDisconnectTimersByThreadId.clear();
460
501
  recoveringThreadIds.clear();
461
502
  baselineRecoveryStateByThreadId.clear();
462
503
  queuedChangesByThreadId.clear();
@@ -499,9 +540,6 @@ function createDesktopIpcActionFollower({
499
540
  if (!threadId) {
500
541
  return;
501
542
  }
502
- if (isSnapshotChange(params.change)) {
503
- clearBackgroundDisconnectTimer(threadId);
504
- }
505
543
  const peerOwnershipSnapshot = isPeerOwnershipSnapshot(params);
506
544
  if (peerOwnershipSnapshot && !isLocallyOwnedThread(threadId)) {
507
545
  liveOwnerThreadIds.delete(threadId);
@@ -656,6 +694,7 @@ function createDesktopIpcActionFollower({
656
694
  onNormalizedHistoryIndexRebuilt(threadId);
657
695
  } else {
658
696
  normalizedLiveIndexesByThreadId.delete(threadId);
697
+ normalizedReviewFingerprintsByThreadId.delete(threadId);
659
698
  }
660
699
  }
661
700
 
@@ -690,14 +729,15 @@ function createDesktopIpcActionFollower({
690
729
  projectedLiveActiveTurnIdsByThreadId.clear();
691
730
  desktopLiveLifecycleByThreadId.clear();
692
731
  normalizedLiveIndexesByThreadId.clear();
732
+ normalizedReviewFingerprintsByThreadId.clear();
693
733
  recoveringThreadIds.clear();
694
734
  baselineRecoveryStateByThreadId.clear();
695
735
  queuedChangesByThreadId.clear();
696
736
  pendingOwnershipProbeTokensByThreadId.clear();
697
737
  desktopOwnedByProbeThreadIds.clear();
698
- for (const threadId of announcedBackgroundTurnsByThreadId.keys()) {
699
- scheduleBackgroundDisconnectSettlement(threadId);
700
- }
738
+ // A lost IPC connection is not evidence that Desktop stopped the turn.
739
+ // Keep announced lifecycle state until a reconnect snapshot, archive, or
740
+ // another authoritative state transition supplies a real terminal status.
701
741
  // Keep activeThreadIds: phone interest is phone-scoped, not connection-scoped.
702
742
  // Clearing it here would make reconnect snapshots for a thread the phone is
703
743
  // still viewing fail the activeThreadIds.has() guard until the phone happens
@@ -730,6 +770,7 @@ function createDesktopIpcActionFollower({
730
770
  projectedLiveActiveTurnIdsByThreadId.delete(threadId);
731
771
  desktopLiveLifecycleByThreadId.delete(threadId);
732
772
  normalizedLiveIndexesByThreadId.delete(threadId);
773
+ normalizedReviewFingerprintsByThreadId.delete(threadId);
733
774
  conversationProjector.remove(threadId);
734
775
  queuedChangesByThreadId.delete(threadId);
735
776
  baselineRecoveryStateByThreadId.delete(threadId);
@@ -759,6 +800,7 @@ function createDesktopIpcActionFollower({
759
800
  projectedLiveActiveTurnIdsByThreadId.delete(threadId);
760
801
  desktopLiveLifecycleByThreadId.delete(threadId);
761
802
  normalizedLiveIndexesByThreadId.delete(threadId);
803
+ normalizedReviewFingerprintsByThreadId.delete(threadId);
762
804
  conversationProjector.remove(threadId);
763
805
  queuedChangesByThreadId.delete(threadId);
764
806
  baselineRecoveryStateByThreadId.delete(threadId);
@@ -1015,6 +1057,13 @@ function createDesktopIpcActionFollower({
1015
1057
  }
1016
1058
 
1017
1059
  rememberActiveThread(threadId);
1060
+ if (method === "thread/goal/get") {
1061
+ sendApplicationResponse(JSON.stringify({
1062
+ id: message.id,
1063
+ result: { goal: projectDesktopConversationStateToGoal(threadId, rawState) },
1064
+ }));
1065
+ return true;
1066
+ }
1018
1067
  // Newer Litter snapshots keep materialized history in
1019
1068
  // turnHistory.history.entitiesByKey while leaving the legacy top-level
1020
1069
  // turns array empty or limited to only the current turn. The Desktop
@@ -1028,7 +1077,7 @@ function createDesktopIpcActionFollower({
1028
1077
  canonicalHistoryThreadIds.add(threadId);
1029
1078
  }
1030
1079
  if (canonicalHistoryThreadIds.has(threadId)) {
1031
- if (isDesktopLiveTurnStateSnapshotRequest(message)) {
1080
+ if (isThreadTurnStateProbeRequest(message)) {
1032
1081
  const liveState = boundedDesktopLiveStateForThread(threadId, rawState);
1033
1082
  sendApplicationResponse(JSON.stringify({
1034
1083
  id: message.id,
@@ -1042,11 +1091,13 @@ function createDesktopIpcActionFollower({
1042
1091
  return ownsDesktopCursor ? rejectDesktopTurnsCursor(message) : false;
1043
1092
  }
1044
1093
  const thread = projectDesktopConversationStateToThread(threadId, rawState, { now });
1045
- // A run that Desktop stopped streaming updates for is not a live run: serving
1046
- // it from cache would answer thread-list refreshes with a phantom "running"
1047
- // turn until real history loads. Let the local app-server answer instead.
1094
+ // While IPC remains connected, an explicitly active Desktop snapshot stays
1095
+ // authoritative through quiet tools, reasoning, approvals, and subagents.
1096
+ // Falling through to a second app-server here can replace a genuinely
1097
+ // running turn with stale idle history and make the phone clear Stop.
1048
1098
  if (hasActiveProjectedTurn(thread)
1049
1099
  && isRawStateStaleForActiveRead(threadId)
1100
+ && !hasResponsiveDesktopIpc()
1050
1101
  && !ownsDesktopCursor) {
1051
1102
  staleYieldedThreadIds.add(threadId);
1052
1103
  return false;
@@ -1058,6 +1109,7 @@ function createDesktopIpcActionFollower({
1058
1109
  // echoing the raw Desktop conversationState alongside it doubled
1059
1110
  // heavy threads past the relay frame limit for nothing.
1060
1111
  thread,
1112
+ ...(method === "thread/resume" ? { remodexDesktopIpcMirror: true } : {}),
1061
1113
  };
1062
1114
  if (!result) {
1063
1115
  return ownsDesktopCursor ? rejectDesktopTurnsCursor(message) : false;
@@ -1080,21 +1132,6 @@ function createDesktopIpcActionFollower({
1080
1132
  return true;
1081
1133
  }
1082
1134
 
1083
- function isDesktopLiveTurnStateSnapshotRequest(message) {
1084
- if (readString(message?.method) !== "thread/turns/list"
1085
- || readString(message?.params?.cursor)
1086
- || message?.params?.remodexRequireCanonical === true) {
1087
- return false;
1088
- }
1089
- if (message?.params?.remodexTurnStateOnly === true) {
1090
- return true;
1091
- }
1092
- // Remodex iPhone 2.1 predates the explicit marker. Its running-state probe
1093
- // has this unique request shape; actual history pages use limits 1 and 5.
1094
- return Number(message?.params?.limit) === 8
1095
- && normalizeToken(readString(message?.params?.sortDirection) || "desc") === "desc";
1096
- }
1097
-
1098
1135
  function buildDesktopLiveTurnStateResult(turns) {
1099
1136
  const data = (Array.isArray(turns) ? turns : [])
1100
1137
  .slice()
@@ -1156,6 +1193,10 @@ function createDesktopIpcActionFollower({
1156
1193
  return now() - updatedAt > STALE_ACTIVE_READ_MAX_AGE_MS;
1157
1194
  }
1158
1195
 
1196
+ function hasResponsiveDesktopIpc() {
1197
+ return ipc.hasRecentActivity(CONNECTED_IPC_ACTIVITY_LEASE_MS);
1198
+ }
1199
+
1159
1200
  function boundedDesktopLiveStateForThread(threadId, state) {
1160
1201
  return boundedDesktopLiveState(
1161
1202
  state,
@@ -1183,6 +1224,8 @@ function createDesktopIpcActionFollower({
1183
1224
  const previousById = desktopLiveLifecycleByThreadId.get(threadId) || new Map();
1184
1225
  const nextTurns = activeDesktopTurnDescriptors(liveState);
1185
1226
  const nextById = new Map(nextTurns.map((turn) => [turn.id, turn]));
1227
+ const completedTurnIds = new Set();
1228
+ const startedTurnIds = new Set();
1186
1229
  const {
1187
1230
  previousTurnIds: continuityPreviousTurnIds,
1188
1231
  nextTurnIds: continuityNextTurnIds,
@@ -1207,6 +1250,7 @@ function createDesktopIpcActionFollower({
1207
1250
  threadId,
1208
1251
  { id: previous.id, status: terminalStatus }
1209
1252
  )));
1253
+ completedTurnIds.add(previous.id);
1210
1254
  }
1211
1255
  for (const next of nextTurns) {
1212
1256
  if (previousById.has(next.id)) {
@@ -1222,7 +1266,52 @@ function createDesktopIpcActionFollower({
1222
1266
  ? notificationWithTurnIdentityContinuity(startedNotification)
1223
1267
  : startedNotification
1224
1268
  ));
1269
+ startedTurnIds.add(next.id);
1225
1270
  }
1271
+ reannounceRemainingParallelTurn(
1272
+ threadId,
1273
+ previousById,
1274
+ nextTurns,
1275
+ completedTurnIds,
1276
+ startedTurnIds
1277
+ );
1278
+ }
1279
+
1280
+ // iOS tracks one phone-visible active turn per thread. If parallel turn B was
1281
+ // the most recently announced turn and finishes while older A is still live,
1282
+ // B's completion clears that slot. Re-announce A as continuity so Stop and
1283
+ // running state stay attached to the existing run without advancing its
1284
+ // generation. A newly started replacement already performs this handoff.
1285
+ function reannounceRemainingParallelTurn(
1286
+ threadId,
1287
+ previousById,
1288
+ nextTurns,
1289
+ completedTurnIds,
1290
+ startedTurnIds
1291
+ ) {
1292
+ const restorationTurnId = remainingParallelTurnRestorationId(
1293
+ previousById,
1294
+ nextTurns,
1295
+ completedTurnIds
1296
+ );
1297
+ if (!restorationTurnId || startedTurnIds.has(restorationTurnId)) {
1298
+ return;
1299
+ }
1300
+ const nextVisibleTurn = nextTurns.find((turn) => turn.id === restorationTurnId);
1301
+ sendApplicationResponse(JSON.stringify(notificationWithTurnIdentityContinuity(
1302
+ desktopLiveTurnLifecycleNotification("turn/started", threadId, nextVisibleTurn)
1303
+ )));
1304
+ }
1305
+
1306
+ function remainingParallelTurnRestorationId(previousById, nextTurns, completedTurnIds) {
1307
+ const previousVisibleTurn = [...previousById.values()].at(-1);
1308
+ if (!previousVisibleTurn || !completedTurnIds.has(previousVisibleTurn.id)) {
1309
+ return "";
1310
+ }
1311
+ const nextVisibleTurn = nextTurns.at(-1);
1312
+ return nextVisibleTurn && previousById.has(nextVisibleTurn.id)
1313
+ ? nextVisibleTurn.id
1314
+ : "";
1226
1315
  }
1227
1316
 
1228
1317
  function syncProjectedConversationState(threadId, nextState, { isFullSnapshot = false } = {}) {
@@ -1239,10 +1328,13 @@ function createDesktopIpcActionFollower({
1239
1328
  && !canonicalHistoryReplacementSentThreadIds.has(threadId)) {
1240
1329
  canonicalHistoryReplacementSentThreadIds.add(threadId);
1241
1330
  conversationProjector.remove(threadId);
1242
- // Seed the bounded live tail before asking the phone for canonical
1243
- // history. Subsequent 8-50ms Desktop patches then become small deltas
1244
- // instead of replaying hundreds of current-turn items as a baseline.
1245
- conversationProjector.seed(threadId, liveState);
1331
+ // Project the bounded active tail before asking the phone for canonical
1332
+ // history. On reconnect this snapshot may contain output produced while
1333
+ // IPC was unavailable; silently seeding it would permanently eat that
1334
+ // content when canonical JSONL/rollout history is still behind.
1335
+ const bootstrapOutput = conversationProjector.project(threadId, liveState, {
1336
+ includeAllActiveTurns: true,
1337
+ });
1246
1338
  sendApplicationResponse(JSON.stringify({
1247
1339
  method: "thread/replaced",
1248
1340
  params: {
@@ -1252,7 +1344,16 @@ function createDesktopIpcActionFollower({
1252
1344
  remodexActionSource: DESKTOP_IPC_ACTION_SOURCE,
1253
1345
  },
1254
1346
  }));
1347
+ emitNormalizedReviewOverlays(
1348
+ threadId,
1349
+ normalizedLiveIndexesByThreadId.get(threadId)
1350
+ );
1255
1351
  emitDesktopSnapshotLifecycleTransition(threadId, liveState);
1352
+ for (const notification of bootstrapOutput.notifications || []) {
1353
+ if (readString(notification?.method).startsWith("item/")) {
1354
+ sendApplicationResponse(JSON.stringify(notification));
1355
+ }
1356
+ }
1256
1357
  rememberDesktopLiveProjection(threadId, liveState);
1257
1358
  return;
1258
1359
  }
@@ -1262,6 +1363,10 @@ function createDesktopIpcActionFollower({
1262
1363
  // baselines: preserve only turn lifecycle, then seed. Item diffs resume
1263
1364
  // on subsequent patches and cannot replay hundreds of old rows.
1264
1365
  emitDesktopSnapshotLifecycleTransition(threadId, liveState);
1366
+ emitNormalizedReviewOverlays(
1367
+ threadId,
1368
+ normalizedLiveIndexesByThreadId.get(threadId)
1369
+ );
1265
1370
  conversationProjector.seed(threadId, liveState);
1266
1371
  rememberDesktopLiveProjection(threadId, liveState);
1267
1372
  return;
@@ -1271,6 +1376,10 @@ function createDesktopIpcActionFollower({
1271
1376
  // is a source epoch change. Force a baseline + thread/replaced repair.
1272
1377
  conversationProjector.remove(threadId);
1273
1378
  }
1379
+ emitNormalizedReviewOverlays(
1380
+ threadId,
1381
+ normalizedLiveIndexesByThreadId.get(threadId)
1382
+ );
1274
1383
  const output = conversationProjector.project(threadId, liveState);
1275
1384
  if (resumedAfterStaleYield || output.type === "fullReplace" || output.type === "baseline") {
1276
1385
  // fullReplace: synthesized turn ids just became real, stale rows must go.
@@ -1290,19 +1399,102 @@ function createDesktopIpcActionFollower({
1290
1399
  },
1291
1400
  }));
1292
1401
  }
1293
- for (const notification of output.notifications || []) {
1402
+ const outputNotifications = output.notifications || [];
1403
+ const previousById = desktopLiveLifecycleByThreadId.get(threadId) || new Map();
1404
+ const nextTurns = activeDesktopTurnDescriptors(liveState);
1405
+ const completedTurnIds = new Set(outputNotifications
1406
+ .filter((notification) => notification.method === "turn/completed")
1407
+ .map((notification) => readString(notification.params?.turnId))
1408
+ .filter(Boolean));
1409
+ const startedTurnIds = new Set(outputNotifications
1410
+ .filter((notification) => notification.method === "turn/started")
1411
+ .map((notification) => readString(notification.params?.turnId))
1412
+ .filter(Boolean));
1413
+ const parallelRestorationTurnId = remainingParallelTurnRestorationId(
1414
+ previousById,
1415
+ nextTurns,
1416
+ completedTurnIds
1417
+ );
1418
+ for (const notification of outputNotifications) {
1419
+ const notificationTurnId = readString(notification.params?.turnId);
1294
1420
  const preservesTurnIdentity = notification.method === "turn/started"
1295
- && output.turnIdentityContinuityTurnIds?.includes(
1296
- readString(notification.params?.turnId)
1297
- );
1421
+ && (parallelRestorationTurnId === notificationTurnId
1422
+ || output.turnIdentityContinuityTurnIds?.includes(notificationTurnId));
1298
1423
  const projectedNotification = preservesTurnIdentity
1299
1424
  ? notificationWithTurnIdentityContinuity(notification)
1300
1425
  : notification;
1301
1426
  sendApplicationResponse(JSON.stringify(projectedNotification));
1302
1427
  }
1428
+ reannounceRemainingParallelTurn(
1429
+ threadId,
1430
+ previousById,
1431
+ nextTurns,
1432
+ completedTurnIds,
1433
+ startedTurnIds
1434
+ );
1303
1435
  rememberDesktopLiveProjection(threadId, liveState);
1304
1436
  }
1305
1437
 
1438
+ function emitNormalizedReviewOverlays(threadId, index) {
1439
+ if (!index || !Array.isArray(index.pendingReviewOverlays)) {
1440
+ return;
1441
+ }
1442
+ const overlays = index.pendingReviewOverlays.splice(0);
1443
+ const fingerprints = normalizedReviewFingerprintsByThreadId.get(threadId) || new Map();
1444
+ for (const overlay of overlays) {
1445
+ // Reviews on turns in the projected live tail may also be emitted by
1446
+ // projector output; that duplication is intentional. The projector only
1447
+ // re-emits items for active turns (and not after a silent reseed), so
1448
+ // suppressing the overlay here would permanently drop reviews on
1449
+ // completed tail turns. The phone upserts by reviewId, so duplicates
1450
+ // cost one redundant notification and nothing else.
1451
+ const item = overlay?.item;
1452
+ const review = item?.review && typeof item.review === "object" ? item.review : item;
1453
+ const reviewId = readString(item?.reviewId)
1454
+ || readString(item?.id).replace(/^automatic-approval-review:/, "");
1455
+ const status = readString(review?.status);
1456
+ if (!reviewId || !status || !item?.action) {
1457
+ continue;
1458
+ }
1459
+ const fingerprint = createHash("sha256")
1460
+ .update(JSON.stringify({ status, item }))
1461
+ .digest("hex");
1462
+ if (fingerprints.get(reviewId) === fingerprint) {
1463
+ continue;
1464
+ }
1465
+ // Refresh insertion order so the cache is a bounded per-thread LRU.
1466
+ fingerprints.delete(reviewId);
1467
+ fingerprints.set(reviewId, fingerprint);
1468
+ while (fingerprints.size > MAX_NORMALIZED_REVIEW_FINGERPRINTS_PER_THREAD) {
1469
+ const oldestReviewId = fingerprints.keys().next().value;
1470
+ fingerprints.delete(oldestReviewId);
1471
+ }
1472
+ sendApplicationResponse(JSON.stringify({
1473
+ method: normalizeToken(status) === "inprogress"
1474
+ ? "item/autoApprovalReview/started"
1475
+ : "item/autoApprovalReview/completed",
1476
+ params: {
1477
+ threadId,
1478
+ turnId: overlay.turnId,
1479
+ reviewId,
1480
+ targetItemId: readString(item.targetItemId) || null,
1481
+ startedAtMs: item.startedAtMs ?? null,
1482
+ completedAtMs: item.completedAtMs ?? null,
1483
+ decisionSource: readString(item.decisionSource)
1484
+ || readString(item?.event?.decision_source)
1485
+ || null,
1486
+ review: cloneJSON(review),
1487
+ action: cloneJSON(item.action),
1488
+ remodexDesktopMirror: true,
1489
+ remodexDesktopIpcMirror: true,
1490
+ remodexGuardianRetrySupported: false,
1491
+ remodexActionSource: DESKTOP_IPC_ACTION_SOURCE,
1492
+ },
1493
+ }));
1494
+ }
1495
+ normalizedReviewFingerprintsByThreadId.set(threadId, fingerprints);
1496
+ }
1497
+
1306
1498
  // Unopened chats only need run-state signals for the sidebar. Sending the
1307
1499
  // projector's full bootstrap here would replay every historical item from
1308
1500
  // every running Desktop chat onto the phone during a sidebar refresh.
@@ -1339,14 +1531,12 @@ function createDesktopIpcActionFollower({
1339
1531
  nextActiveTurn
1340
1532
  )));
1341
1533
  announcedBackgroundTurnsByThreadId.set(threadId, nextActiveTurn);
1342
- clearBackgroundDisconnectTimer(threadId);
1343
1534
  }
1344
1535
  }
1345
1536
 
1346
1537
  function settleAnnouncedBackgroundTurn(threadId, status = "interrupted", turn = null) {
1347
1538
  const announcedTurn = announcedBackgroundTurnsByThreadId.get(threadId);
1348
1539
  if (!announcedTurn) {
1349
- clearBackgroundDisconnectTimer(threadId);
1350
1540
  return false;
1351
1541
  }
1352
1542
  const settledTurn = {
@@ -1361,36 +1551,9 @@ function createDesktopIpcActionFollower({
1361
1551
  settledTurn
1362
1552
  )));
1363
1553
  announcedBackgroundTurnsByThreadId.delete(threadId);
1364
- clearBackgroundDisconnectTimer(threadId);
1365
1554
  return true;
1366
1555
  }
1367
1556
 
1368
- function scheduleBackgroundDisconnectSettlement(threadId) {
1369
- if (!announcedBackgroundTurnsByThreadId.has(threadId)
1370
- || backgroundDisconnectTimersByThreadId.has(threadId)) {
1371
- return;
1372
- }
1373
- const expectedTurnId = announcedBackgroundTurnsByThreadId.get(threadId)?.id;
1374
- const timer = setTimeout(() => {
1375
- backgroundDisconnectTimersByThreadId.delete(threadId);
1376
- if (announcedBackgroundTurnsByThreadId.get(threadId)?.id !== expectedTurnId) {
1377
- return;
1378
- }
1379
- settleAnnouncedBackgroundTurn(threadId, "interrupted");
1380
- }, Math.max(0, backgroundDisconnectGraceMs));
1381
- timer.unref?.();
1382
- backgroundDisconnectTimersByThreadId.set(threadId, timer);
1383
- }
1384
-
1385
- function clearBackgroundDisconnectTimer(threadId) {
1386
- const timer = backgroundDisconnectTimersByThreadId.get(threadId);
1387
- if (!timer) {
1388
- return;
1389
- }
1390
- clearTimeout(timer);
1391
- backgroundDisconnectTimersByThreadId.delete(threadId);
1392
- }
1393
-
1394
1557
  function syncThreadArchiveBroadcast(envelope) {
1395
1558
  const params = envelope.params || {};
1396
1559
  const threadId = readString(params.conversationId) || readString(params.conversation_id);
@@ -1410,6 +1573,7 @@ function createDesktopIpcActionFollower({
1410
1573
  projectedLiveActiveTurnIdsByThreadId.delete(threadId);
1411
1574
  desktopLiveLifecycleByThreadId.delete(threadId);
1412
1575
  normalizedLiveIndexesByThreadId.delete(threadId);
1576
+ normalizedReviewFingerprintsByThreadId.delete(threadId);
1413
1577
  conversationProjector.remove(threadId);
1414
1578
  syncProjectedActions(threadId, []);
1415
1579
  }
@@ -1531,7 +1695,14 @@ function createDesktopIpcActionFollower({
1531
1695
  .then(() => resolveFollowerRequestParams(route))
1532
1696
  .then(async (resolvedParams) => {
1533
1697
  if (route.method === "thread-follower-start-turn") {
1534
- await syncDesktopOwnerRuntimeSettings(route.threadId, resolvedParams.turnStartParams);
1698
+ try {
1699
+ await syncDesktopOwnerRuntimeSettings(route.threadId, resolvedParams.turnStartParams);
1700
+ } catch (error) {
1701
+ // The actual turn has not reached Desktop yet. Even if the settings
1702
+ // request timed out after being applied, continuing through the local
1703
+ // app-server is safe because there is no Desktop turn to duplicate.
1704
+ throw markDeliveryFailureError(error);
1705
+ }
1535
1706
  }
1536
1707
  return {
1537
1708
  resolvedParams,
@@ -1775,7 +1946,9 @@ function createDesktopIpcActionFollower({
1775
1946
  return false;
1776
1947
  }
1777
1948
  const thread = projectDesktopConversationStateToThread(normalizedThreadId, liveState, { now });
1778
- if (hasActiveProjectedTurn(thread) && isRawStateStaleForActiveRead(normalizedThreadId)) {
1949
+ if (hasActiveProjectedTurn(thread)
1950
+ && isRawStateStaleForActiveRead(normalizedThreadId)
1951
+ && !hasResponsiveDesktopIpc()) {
1779
1952
  staleYieldedThreadIds.add(normalizedThreadId);
1780
1953
  return false;
1781
1954
  }
@@ -1806,7 +1979,7 @@ function createDesktopIpcActionFollower({
1806
1979
  if (hasActiveProjectedTurn(thread)) {
1807
1980
  const updatedAt = rawStateUpdatedAtByThreadId.get(normalizedThreadId) || 0;
1808
1981
  const hasNewerFallbackActivity = Number(fallbackActivityAt) > updatedAt;
1809
- return ipc.isConnected()
1982
+ return hasResponsiveDesktopIpc()
1810
1983
  && (!isRawStateStaleForActiveRead(normalizedThreadId) || !hasNewerFallbackActivity);
1811
1984
  }
1812
1985
  return !isRawStateStaleForActiveRead(normalizedThreadId);
@@ -1828,6 +2001,7 @@ function createDesktopIpcClient({
1828
2001
  let socket = null;
1829
2002
  let clientId = "";
1830
2003
  let isConnecting = false;
2004
+ let lastActivityAt = 0;
1831
2005
  let readBuffer = Buffer.alloc(0);
1832
2006
  const pendingRequests = new Map();
1833
2007
  const pendingDiscoveries = new Map();
@@ -1939,6 +2113,9 @@ function createDesktopIpcClient({
1939
2113
  }
1940
2114
 
1941
2115
  function handleData(chunk) {
2116
+ if (chunk.length > 0) {
2117
+ lastActivityAt = now();
2118
+ }
1942
2119
  readBuffer = Buffer.concat([readBuffer, chunk]);
1943
2120
  while (readBuffer.length >= FRAME_HEADER_BYTES) {
1944
2121
  const frameLength = readBuffer.readUInt32LE(0);
@@ -2014,6 +2191,7 @@ function createDesktopIpcClient({
2014
2191
  socket = null;
2015
2192
  clientId = "";
2016
2193
  isConnecting = false;
2194
+ lastActivityAt = 0;
2017
2195
  readBuffer = Buffer.alloc(0);
2018
2196
  for (const waiter of pendingRequests.values()) {
2019
2197
  clearTimeout(waiter.timeout);
@@ -2052,6 +2230,11 @@ function createDesktopIpcClient({
2052
2230
  isConnected() {
2053
2231
  return Boolean(socket && !socket.destroyed && clientId);
2054
2232
  },
2233
+ hasRecentActivity(maxAgeMs) {
2234
+ return Boolean(socket && !socket.destroyed && clientId)
2235
+ && lastActivityAt > 0
2236
+ && now() - lastActivityAt <= Math.max(0, Number(maxAgeMs) || 0);
2237
+ },
2055
2238
  sendRequest,
2056
2239
  sendDiscoveryRequest,
2057
2240
  close,
@@ -2414,6 +2597,7 @@ function createNormalizedLiveIndex(state) {
2414
2597
  entryIndexByTurnId,
2415
2598
  turnIdByEntityKey,
2416
2599
  activeTurnIds: new Set(),
2600
+ pendingReviewOverlays: [],
2417
2601
  hasHistoryOutsideRawTurns: Array.from(normalizedTurnIds).some(
2418
2602
  (turnId) => !rawTurnIds.has(turnId)
2419
2603
  ),
@@ -2423,6 +2607,14 @@ function createNormalizedLiveIndex(state) {
2423
2607
  if (turn && isActiveRawTurn(turn)) {
2424
2608
  index.activeTurnIds.add(entry.id);
2425
2609
  }
2610
+ if (turn && entry.rawIndex == null) {
2611
+ for (const item of Array.isArray(turn.items) ? turn.items : []) {
2612
+ if (normalizeToken(item?.type) === "automaticapprovalreview") {
2613
+ const overlay = { turnId: entry.id, item };
2614
+ index.pendingReviewOverlays.push(overlay);
2615
+ }
2616
+ }
2617
+ }
2426
2618
  }
2427
2619
  return index;
2428
2620
  }
@@ -2460,6 +2652,7 @@ function normalizedLiveIndexNeedsRebuild(change) {
2460
2652
 
2461
2653
  function refreshTouchedNormalizedActiveTurns(index, state, change) {
2462
2654
  const touchedTurnIds = new Set();
2655
+ const touchedReviewTurnIds = new Set();
2463
2656
  for (const patch of Array.isArray(change?.patches) ? change.patches : []) {
2464
2657
  const path = Array.isArray(patch?.path) ? patch.path : [];
2465
2658
  if (path[0] === "turns" && Number.isInteger(path[1]) && path[2] === "status") {
@@ -2479,9 +2672,23 @@ function refreshTouchedNormalizedActiveTurns(index, state, change) {
2479
2672
  touchedTurnIds.add(turnId);
2480
2673
  }
2481
2674
  }
2482
- }
2483
- if (touchedTurnIds.size === 0) {
2484
- return;
2675
+ if ((path[0] === "turnHistory" || path[0] === "turn_history")
2676
+ && path[1] === "history"
2677
+ && (path[2] === "entitiesByKey" || path[2] === "entities_by_key")
2678
+ && path[4] === "items") {
2679
+ const turnId = index.turnIdByEntityKey.get(readString(path[3]));
2680
+ const entryIndex = turnId ? index.entryIndexByTurnId.get(turnId) : null;
2681
+ const entry = entryIndex == null ? null : index.entries[entryIndex];
2682
+ const turn = entry ? resolveIndexedTurn(state, entry) : null;
2683
+ const item = Number.isInteger(path[5]) && Array.isArray(turn?.items)
2684
+ ? turn.items[path[5]]
2685
+ : null;
2686
+ const isStructuralItemPatch = path.length <= 6 || path[6] === "type";
2687
+ if (turnId && (isStructuralItemPatch
2688
+ || normalizeToken(item?.type) === "automaticapprovalreview")) {
2689
+ touchedReviewTurnIds.add(turnId);
2690
+ }
2691
+ }
2485
2692
  }
2486
2693
  for (const turnId of touchedTurnIds) {
2487
2694
  const entryIndex = index.entryIndexByTurnId.get(turnId);
@@ -2493,6 +2700,21 @@ function refreshTouchedNormalizedActiveTurns(index, state, change) {
2493
2700
  index.activeTurnIds.delete(turnId);
2494
2701
  }
2495
2702
  }
2703
+ for (const turnId of touchedReviewTurnIds) {
2704
+ const entryIndex = index.entryIndexByTurnId.get(turnId);
2705
+ const entry = entryIndex == null ? null : index.entries[entryIndex];
2706
+ const turn = entry ? resolveIndexedTurn(state, entry) : null;
2707
+ if (!turn || entry?.rawIndex != null) {
2708
+ continue;
2709
+ }
2710
+ for (const item of Array.isArray(turn.items) ? turn.items : []) {
2711
+ if (normalizeToken(item?.type) !== "automaticapprovalreview") {
2712
+ continue;
2713
+ }
2714
+ const overlay = { turnId, item };
2715
+ index.pendingReviewOverlays.push(overlay);
2716
+ }
2717
+ }
2496
2718
  }
2497
2719
 
2498
2720
  function latestActiveRawTurn(state) {
@@ -2539,7 +2761,8 @@ function boundedDesktopLiveTurns(
2539
2761
  if (orderedTurns.length <= 1) {
2540
2762
  return normalizeBoundedTurnsForRuntime(
2541
2763
  orderedTurns.map((turn, index) => withStableProjectedTurnId(turn, index)),
2542
- state
2764
+ state,
2765
+ retainedTurnIds
2543
2766
  );
2544
2767
  }
2545
2768
 
@@ -2566,7 +2789,7 @@ function boundedDesktopLiveTurns(
2566
2789
  const selectedTurns = Array.from(selectedIndexes)
2567
2790
  .sort((left, right) => left - right)
2568
2791
  .map((index) => withStableProjectedTurnId(orderedTurns[index], index));
2569
- return normalizeBoundedTurnsForRuntime(selectedTurns, state);
2792
+ return normalizeBoundedTurnsForRuntime(selectedTurns, state, retainedTurnIds);
2570
2793
  }
2571
2794
 
2572
2795
  function boundedIndexedDesktopLiveTurns(state, index, nowValue, retainedTurnIds) {
@@ -2613,7 +2836,7 @@ function boundedIndexedDesktopLiveTurns(state, index, nowValue, retainedTurnIds)
2613
2836
  return turnIdOf(turn) ? turn : { ...turn, id: entry.id };
2614
2837
  })
2615
2838
  .filter(Boolean);
2616
- return normalizeBoundedTurnsForRuntime(selectedTurns, state);
2839
+ return normalizeBoundedTurnsForRuntime(selectedTurns, state, retainedTurnIds);
2617
2840
  }
2618
2841
 
2619
2842
  function resolveIndexedTurn(state, entry) {
@@ -2690,12 +2913,12 @@ function notificationWithTurnIdentityContinuity(notification) {
2690
2913
  };
2691
2914
  }
2692
2915
 
2693
- function normalizeBoundedTurnsForRuntime(turns, state) {
2916
+ function normalizeBoundedTurnsForRuntime(turns, state, retainedTurnIds = new Set()) {
2694
2917
  if (!isExplicitlyIdleDesktopRuntime(state)) {
2695
2918
  return turns;
2696
2919
  }
2697
2920
  return turns.map((turn) => (
2698
- isActiveRawTurn(turn)
2921
+ isActiveRawTurn(turn) && !retainedTurnIds.has(turnIdOf(turn))
2699
2922
  ? { ...turn, status: "completed" }
2700
2923
  : turn
2701
2924
  ));