@makerbi/remodex 2.4.0 → 3.1.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.
@@ -15,16 +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,
22
23
  isThreadTurnStateProbeRequest,
23
24
  normalizeToken,
24
25
  readString,
25
26
  requestIdKey,
26
27
  resolveDefaultIpcSocketPath,
28
+ resolveIpcSocketPathCandidates,
27
29
  safeParseJSON,
30
+ toSocketPathCandidatesResolver,
28
31
  writeFrame,
29
32
  } = require("./desktop-ipc-shared");
30
33
 
@@ -42,6 +45,8 @@ const MAX_QUEUED_CHANGES_PER_THREAD = 300;
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,6 +57,12 @@ 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
67
  // Per-thread activity can be quiet during tools and subagents, so the short
57
68
  // freshness window alone does not revoke a healthy Desktop owner.
@@ -221,13 +232,16 @@ function createDesktopIpcActionFollower({
221
232
  normalizeTurnStartParams = (params) => params,
222
233
  runtimeSettingsStore = null,
223
234
  logPrefix = "[remodex]",
224
- socketPath = resolveDefaultIpcSocketPath(),
235
+ // Resolved per connect: Codex Desktop can start, stop, or move its bus while
236
+ // the bridge stays up.
237
+ socketPath = resolveIpcSocketPathCandidates,
225
238
  netModule = net,
226
239
  now = () => Date.now(),
227
240
  snapshotDebounceMs = 0,
228
241
  setTimeoutFn = setTimeout,
229
242
  clearTimeoutFn = clearTimeout,
230
243
  onNormalizedHistoryIndexRebuilt = () => {},
244
+ onFollowerStateChanged = null,
231
245
  requestTimeoutMs = REQUEST_TIMEOUT_MS,
232
246
  ownershipProbeTimeoutMs = OWNERSHIP_PROBE_TIMEOUT_MS,
233
247
  } = {}) {
@@ -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,6 +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();
288
+ const backgroundTurnReannouncedAtByThreadId = new Map();
271
289
  // JS Set preserves insertion order; delete-before-add refreshes recency, and
272
290
  // cap eviction skips threads with pending prompts so approvals are not lost.
273
291
  function rememberActiveThread(threadId) {
@@ -283,6 +301,35 @@ function createDesktopIpcActionFollower({
283
301
  }
284
302
  }
285
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
+
286
333
  function oldestEvictableActiveThreadId() {
287
334
  for (const threadId of activeThreadIds) {
288
335
  if (!hasPendingProjectedActions(threadId)
@@ -307,6 +354,7 @@ function createDesktopIpcActionFollower({
307
354
  // without rejecting held requests (removeDesktopThreadState handles real removal).
308
355
  function forgetEvictedThreadState(threadId) {
309
356
  cancelPendingSnapshot(threadId);
357
+ unfollowDesktopThread(threadId);
310
358
  settleAnnouncedBackgroundTurn(threadId, "interrupted");
311
359
  backgroundOnlyThreadIds.delete(threadId);
312
360
  rawStatesByThreadId.delete(threadId);
@@ -346,6 +394,7 @@ function createDesktopIpcActionFollower({
346
394
  const method = readString(message?.method);
347
395
  if (DESKTOP_BACKGROUND_DISCOVERY_METHODS.has(method)) {
348
396
  ipc.ensureConnected();
397
+ reannounceActiveBackgroundTurns();
349
398
  }
350
399
  if (DESKTOP_STATE_READ_METHODS.has(method)) {
351
400
  const threadId = readThreadId(message?.params);
@@ -364,6 +413,7 @@ function createDesktopIpcActionFollower({
364
413
  settleAnnouncedBackgroundTurn(threadId, "interrupted");
365
414
  }
366
415
  announcedBackgroundTurnsByThreadId.delete(threadId);
416
+ backgroundTurnReannouncedAtByThreadId.delete(threadId);
367
417
  if (rawState) {
368
418
  const liveState = boundedDesktopLiveStateForThread(threadId, rawState);
369
419
  const hasCanonicalNormalizedHistory = canonicalHistoryThreadIds.has(threadId)
@@ -393,7 +443,8 @@ function createDesktopIpcActionFollower({
393
443
  // stays idle after opening never delivers them.
394
444
  emitNormalizedReviewOverlays(
395
445
  threadId,
396
- normalizedLiveIndexesByThreadId.get(threadId)
446
+ normalizedLiveIndexesByThreadId.get(threadId),
447
+ liveState
397
448
  );
398
449
  const output = conversationProjector.project(threadId, liveState, {
399
450
  includeAllActiveTurns: true,
@@ -478,6 +529,7 @@ function createDesktopIpcActionFollower({
478
529
  ownershipProbeDeadlinesByThreadId.set(threadId, now() + ownershipProbeTimeoutMs);
479
530
  }
480
531
  ipc.ensureConnected();
532
+ followDesktopThread(threadId);
481
533
  return false;
482
534
  }
483
535
 
@@ -495,9 +547,15 @@ function createDesktopIpcActionFollower({
495
547
  normalizedReviewFingerprintsByThreadId.clear();
496
548
  conversationProjector.reset();
497
549
  pendingRoutesByRequestId.clear();
550
+ for (const threadId of desktopFollowThreadIds) {
551
+ unfollowDesktopThread(threadId);
552
+ }
553
+ desktopFollowThreadIds.clear();
498
554
  activeThreadIds.clear();
555
+ followerClientIdsByThreadId.clear();
499
556
  backgroundOnlyThreadIds.clear();
500
557
  announcedBackgroundTurnsByThreadId.clear();
558
+ backgroundTurnReannouncedAtByThreadId.clear();
501
559
  recoveringThreadIds.clear();
502
560
  baselineRecoveryStateByThreadId.clear();
503
561
  queuedChangesByThreadId.clear();
@@ -516,6 +574,25 @@ function createDesktopIpcActionFollower({
516
574
 
517
575
  // Desktop broadcasts carry the live conversation state Litter projects from.
518
576
  function onEnvelope(envelope) {
577
+ if (envelope?.type === "broadcast" && envelope.method === CLIENT_STATUS_CHANGED) {
578
+ if (normalizeToken(envelope.params?.status) === "disconnected") {
579
+ removeFollowerClient(envelope.params?.clientId || envelope.sourceClientId);
580
+ }
581
+ return;
582
+ }
583
+ if (envelope?.type === "broadcast" && envelope.method === THREAD_STREAM_FOLLOWING_CHANGED) {
584
+ updateFollowerState(envelope);
585
+ return;
586
+ }
587
+ if (envelope?.type === "broadcast"
588
+ && envelope.method === THREAD_STREAM_FOLLOWING_STATUS_REQUESTED) {
589
+ const params = envelope.params || {};
590
+ const threadId = readString(params.conversationId) || readString(params.conversation_id);
591
+ if (threadId && desktopFollowThreadIds.has(threadId)) {
592
+ followDesktopThread(threadId, [envelope.sourceClientId].filter(Boolean));
593
+ }
594
+ return;
595
+ }
519
596
  if (envelope?.type === "broadcast"
520
597
  && (envelope.method === "thread-archived" || envelope.method === "thread-unarchived")) {
521
598
  syncThreadArchiveBroadcast(envelope);
@@ -560,6 +637,14 @@ function createDesktopIpcActionFollower({
560
637
  backgroundOnlyThreadIds.add(threadId);
561
638
  }
562
639
  if (!activeThreadIds.has(threadId)) {
640
+ // After a bridge restart Desktop can re-announce that a renderer follows
641
+ // the thread without replaying its baseline snapshot. A request patch may
642
+ // then be the first state update we see. Preserve that blocking action
643
+ // immediately instead of waiting for a phone read that may never arrive
644
+ // while the already-open screen is parked on the question.
645
+ if (commitSpeculativeActionPatch(threadId, params.change, { backgroundOnly: true })) {
646
+ return;
647
+ }
563
648
  return;
564
649
  }
565
650
 
@@ -581,21 +666,11 @@ function createDesktopIpcActionFollower({
581
666
  const previousState = rawStatesByThreadId.get(threadId) || null;
582
667
  const nextState = applyConversationStateChange(previousState, params.change);
583
668
  if (!nextState) {
584
- if (isPatchChange(params.change)) {
585
- const emptyState = createEmptyConversationState();
586
- const speculativeState = applyConversationStateChange(emptyState, params.change);
587
- const speculativeActions = projectPendingDesktopActions(threadId, speculativeState);
588
- if (speculativeActions.length > 0) {
589
- rawStatesByThreadId.set(threadId, speculativeState);
590
- rawStateUpdatedAtByThreadId.set(threadId, now());
591
- if (!backgroundOnlyThreadIds.has(threadId)) {
592
- conversationProjector.seed(threadId, speculativeState);
593
- }
594
- syncProjectedActions(threadId, speculativeActions);
595
- releaseHeldFollowerRequests(threadId, { toDesktop: true });
596
- return;
597
- }
669
+ if (commitSpeculativeActionPatch(threadId, params.change)) {
670
+ return;
671
+ }
598
672
 
673
+ if (isPatchChange(params.change)) {
599
674
  if (typeof readConversationState !== "function") {
600
675
  return;
601
676
  }
@@ -617,6 +692,31 @@ function createDesktopIpcActionFollower({
617
692
  });
618
693
  }
619
694
 
695
+ function commitSpeculativeActionPatch(threadId, change, { backgroundOnly = false } = {}) {
696
+ if (!isPatchChange(change)) {
697
+ return false;
698
+ }
699
+
700
+ const speculativeState = applyConversationStateChange(createEmptyConversationState(), change);
701
+ const speculativeActions = projectPendingDesktopActions(threadId, speculativeState);
702
+ if (speculativeActions.length === 0) {
703
+ return false;
704
+ }
705
+
706
+ if (backgroundOnly) {
707
+ rememberActiveThread(threadId);
708
+ backgroundOnlyThreadIds.add(threadId);
709
+ }
710
+ rawStatesByThreadId.set(threadId, speculativeState);
711
+ rawStateUpdatedAtByThreadId.set(threadId, now());
712
+ if (!backgroundOnlyThreadIds.has(threadId)) {
713
+ conversationProjector.seed(threadId, speculativeState);
714
+ }
715
+ syncProjectedActions(threadId, speculativeActions);
716
+ releaseHeldFollowerRequests(threadId, { toDesktop: true });
717
+ return true;
718
+ }
719
+
620
720
  function schedulePendingSnapshot(threadId, state) {
621
721
  cancelPendingSnapshot(threadId);
622
722
  const timer = setTimeoutFn(() => {
@@ -735,6 +835,7 @@ function createDesktopIpcActionFollower({
735
835
  queuedChangesByThreadId.clear();
736
836
  pendingOwnershipProbeTokensByThreadId.clear();
737
837
  desktopOwnedByProbeThreadIds.clear();
838
+ clearFollowerState();
738
839
  // A lost IPC connection is not evidence that Desktop stopped the turn.
739
840
  // Keep announced lifecycle state until a reconnect snapshot, archive, or
740
841
  // another authoritative state transition supplies a real terminal status.
@@ -750,6 +851,67 @@ function createDesktopIpcActionFollower({
750
851
  // a proven delivery failure falls back to the local app-server.
751
852
  }
752
853
 
854
+ // The same route-follow handshake is used whether Remodex's app-server owns
855
+ // the thread or Codex Desktop owns it. The live-owner component covers the
856
+ // former; this follower covers Desktop-owned threads so the navigation
857
+ // controller can stop retrying as soon as the renderer is actually mounted.
858
+ function updateFollowerState(envelope) {
859
+ const params = envelope?.params || {};
860
+ const threadId = readString(params.conversationId) || readString(params.conversation_id);
861
+ const clientId = readString(envelope?.sourceClientId);
862
+ if (!threadId
863
+ || !clientId
864
+ || clientId === ipc.clientId
865
+ || isLocallyOwnedThread(threadId)
866
+ || liveOwnerThreadIds.has(threadId)) {
867
+ return;
868
+ }
869
+
870
+ const followers = followerClientIdsByThreadId.get(threadId) || new Set();
871
+ if (params.following === true) {
872
+ rememberActiveThread(threadId);
873
+ followDesktopThread(threadId);
874
+ const wasUnfollowed = followers.size === 0;
875
+ followers.add(clientId);
876
+ followerClientIdsByThreadId.set(threadId, followers);
877
+ if (wasUnfollowed) {
878
+ onFollowerStateChanged?.(threadId, true);
879
+ }
880
+ return;
881
+ }
882
+
883
+ if (!followers.delete(clientId)) {
884
+ return;
885
+ }
886
+ if (followers.size === 0) {
887
+ followerClientIdsByThreadId.delete(threadId);
888
+ onFollowerStateChanged?.(threadId, false);
889
+ }
890
+ }
891
+
892
+ function removeFollowerClient(clientId) {
893
+ const normalizedClientId = readString(clientId);
894
+ if (!normalizedClientId) {
895
+ return;
896
+ }
897
+ for (const [threadId, followers] of followerClientIdsByThreadId) {
898
+ if (!followers.delete(normalizedClientId)) {
899
+ continue;
900
+ }
901
+ if (followers.size === 0) {
902
+ followerClientIdsByThreadId.delete(threadId);
903
+ onFollowerStateChanged?.(threadId, false);
904
+ }
905
+ }
906
+ }
907
+
908
+ function clearFollowerState() {
909
+ for (const threadId of followerClientIdsByThreadId.keys()) {
910
+ onFollowerStateChanged?.(threadId, false);
911
+ }
912
+ followerClientIdsByThreadId.clear();
913
+ }
914
+
753
915
  // The bridge's own live owner just claimed this thread's stream, so drop stale
754
916
  // Desktop state instead of hijacking future phone requests into Desktop IPC.
755
917
  function releaseDesktopThreadState(threadId) {
@@ -759,6 +921,7 @@ function createDesktopIpcActionFollower({
759
921
  activeThreadIds.delete(threadId);
760
922
  }
761
923
  liveOwnerThreadIds.add(threadId);
924
+ unfollowDesktopThread(threadId);
762
925
  ownershipProbeDeadlinesByThreadId.delete(threadId);
763
926
  pendingOwnershipProbeTokensByThreadId.delete(threadId);
764
927
  desktopOwnedByProbeThreadIds.delete(threadId);
@@ -789,6 +952,7 @@ function createDesktopIpcActionFollower({
789
952
  activeThreadIds.delete(threadId);
790
953
  }
791
954
  liveOwnerThreadIds.delete(threadId);
955
+ unfollowDesktopThread(threadId);
792
956
  ownershipProbeDeadlinesByThreadId.delete(threadId);
793
957
  pendingOwnershipProbeTokensByThreadId.delete(threadId);
794
958
  desktopOwnedByProbeThreadIds.delete(threadId);
@@ -1003,23 +1167,30 @@ function createDesktopIpcActionFollower({
1003
1167
  }
1004
1168
 
1005
1169
  function syncProjectedActions(threadId, actions) {
1006
- const nextRequestIds = new Set(actions.map((action) => action.id));
1170
+ const nextRequestIds = new Set(actions.map((action) => requestIdKey(action.id)));
1007
1171
  for (const [requestId, route] of Array.from(pendingRoutesByRequestId.entries())) {
1008
1172
  if (route.threadId !== threadId || nextRequestIds.has(requestId)) {
1009
1173
  continue;
1010
1174
  }
1011
1175
 
1012
1176
  pendingRoutesByRequestId.delete(requestId);
1013
- sendApplicationResponse(JSON.stringify(projectedResolvedNotification(threadId, requestId)));
1177
+ sendApplicationResponse(JSON.stringify(
1178
+ projectedResolvedNotification(threadId, route.desktopRequestId)
1179
+ ));
1014
1180
  }
1015
1181
 
1016
1182
  for (const action of actions) {
1017
- if (pendingRoutesByRequestId.has(action.id)) {
1183
+ const requestId = requestIdKey(action.id);
1184
+ if (!requestId || pendingRoutesByRequestId.has(requestId)) {
1018
1185
  continue;
1019
1186
  }
1020
1187
 
1021
- pendingRoutesByRequestId.set(action.id, {
1022
- requestId: action.id,
1188
+ pendingRoutesByRequestId.set(requestId, {
1189
+ requestId,
1190
+ // Preserve the JSON-RPC id's original scalar type. Codex Desktop uses
1191
+ // numeric ids for real requestUserInput calls, and its follower command
1192
+ // must receive that same number rather than the phone-facing map key.
1193
+ desktopRequestId: action.id,
1023
1194
  method: action.method,
1024
1195
  threadId,
1025
1196
  });
@@ -1346,7 +1517,8 @@ function createDesktopIpcActionFollower({
1346
1517
  }));
1347
1518
  emitNormalizedReviewOverlays(
1348
1519
  threadId,
1349
- normalizedLiveIndexesByThreadId.get(threadId)
1520
+ normalizedLiveIndexesByThreadId.get(threadId),
1521
+ liveState
1350
1522
  );
1351
1523
  emitDesktopSnapshotLifecycleTransition(threadId, liveState);
1352
1524
  for (const notification of bootstrapOutput.notifications || []) {
@@ -1365,7 +1537,8 @@ function createDesktopIpcActionFollower({
1365
1537
  emitDesktopSnapshotLifecycleTransition(threadId, liveState);
1366
1538
  emitNormalizedReviewOverlays(
1367
1539
  threadId,
1368
- normalizedLiveIndexesByThreadId.get(threadId)
1540
+ normalizedLiveIndexesByThreadId.get(threadId),
1541
+ liveState
1369
1542
  );
1370
1543
  conversationProjector.seed(threadId, liveState);
1371
1544
  rememberDesktopLiveProjection(threadId, liveState);
@@ -1378,7 +1551,8 @@ function createDesktopIpcActionFollower({
1378
1551
  }
1379
1552
  emitNormalizedReviewOverlays(
1380
1553
  threadId,
1381
- normalizedLiveIndexesByThreadId.get(threadId)
1554
+ normalizedLiveIndexesByThreadId.get(threadId),
1555
+ liveState
1382
1556
  );
1383
1557
  const output = conversationProjector.project(threadId, liveState);
1384
1558
  if (resumedAfterStaleYield || output.type === "fullReplace" || output.type === "baseline") {
@@ -1435,11 +1609,16 @@ function createDesktopIpcActionFollower({
1435
1609
  rememberDesktopLiveProjection(threadId, liveState);
1436
1610
  }
1437
1611
 
1438
- function emitNormalizedReviewOverlays(threadId, index) {
1612
+ function emitNormalizedReviewOverlays(threadId, index, projectedState) {
1439
1613
  if (!index || !Array.isArray(index.pendingReviewOverlays)) {
1440
1614
  return;
1441
1615
  }
1442
1616
  const overlays = index.pendingReviewOverlays.splice(0);
1617
+ const projectedTurnIds = new Set(
1618
+ (Array.isArray(projectedState?.turns) ? projectedState.turns : [])
1619
+ .map(turnIdOf)
1620
+ .filter(Boolean)
1621
+ );
1443
1622
  const fingerprints = normalizedReviewFingerprintsByThreadId.get(threadId) || new Map();
1444
1623
  for (const overlay of overlays) {
1445
1624
  // Reviews on turns in the projected live tail may also be emitted by
@@ -1456,6 +1635,16 @@ function createDesktopIpcActionFollower({
1456
1635
  if (!reviewId || !status || !item?.action) {
1457
1636
  continue;
1458
1637
  }
1638
+ // Completed approved reviews are ordinary tool history. Replaying every
1639
+ // normalized-history review after a bounded canonical read appends old
1640
+ // rows at the phone's live tail, detached from their original disclosure.
1641
+ // The projected tail still emits its approvals so they reconcile in the
1642
+ // owning turn; non-approved outcomes remain global because they may need
1643
+ // the user's attention.
1644
+ if (normalizeToken(status) === "approved"
1645
+ && !projectedTurnIds.has(readString(overlay.turnId))) {
1646
+ continue;
1647
+ }
1459
1648
  const fingerprint = createHash("sha256")
1460
1649
  .update(JSON.stringify({ status, item }))
1461
1650
  .digest("hex");
@@ -1551,9 +1740,36 @@ function createDesktopIpcActionFollower({
1551
1740
  settledTurn
1552
1741
  )));
1553
1742
  announcedBackgroundTurnsByThreadId.delete(threadId);
1743
+ backgroundTurnReannouncedAtByThreadId.delete(threadId);
1554
1744
  return true;
1555
1745
  }
1556
1746
 
1747
+ // Re-sends the start of runs that are still in flight, so a phone that missed
1748
+ // the original announcement (fresh app session, reconnect after the run began)
1749
+ // gets its sidebar badge on the next sidebar refresh instead of only when the
1750
+ // chat is opened. The phone treats a repeated start for the same turn as a
1751
+ // no-op, and the identity marker keeps a phone that already saw it from
1752
+ // treating this as a second run. Only worth doing while Desktop IPC is
1753
+ // responsive: the matching completion arrives over the same link, so this
1754
+ // cannot keep a phantom badge alive on its own.
1755
+ function reannounceActiveBackgroundTurns() {
1756
+ if (announcedBackgroundTurnsByThreadId.size === 0 || !hasResponsiveDesktopIpc()) {
1757
+ return;
1758
+ }
1759
+
1760
+ const reannouncedAt = now();
1761
+ for (const [threadId, announcedTurn] of announcedBackgroundTurnsByThreadId) {
1762
+ const lastReannouncedAt = backgroundTurnReannouncedAtByThreadId.get(threadId) || 0;
1763
+ if (reannouncedAt - lastReannouncedAt < BACKGROUND_TURN_REANNOUNCE_MIN_INTERVAL_MS) {
1764
+ continue;
1765
+ }
1766
+ backgroundTurnReannouncedAtByThreadId.set(threadId, reannouncedAt);
1767
+ sendApplicationResponse(JSON.stringify(notificationWithTurnIdentityContinuity(
1768
+ backgroundTurnLifecycleNotification("turn/started", threadId, announcedTurn)
1769
+ )));
1770
+ }
1771
+ }
1772
+
1557
1773
  function syncThreadArchiveBroadcast(envelope) {
1558
1774
  const params = envelope.params || {};
1559
1775
  const threadId = readString(params.conversationId) || readString(params.conversation_id);
@@ -1616,7 +1832,7 @@ function createDesktopIpcActionFollower({
1616
1832
  .then(() => {
1617
1833
  pendingRoutesByRequestId.delete(route.requestId);
1618
1834
  sendApplicationResponse(JSON.stringify(
1619
- projectedResolvedNotification(route.threadId, route.requestId)
1835
+ projectedResolvedNotification(route.threadId, route.desktopRequestId)
1620
1836
  ));
1621
1837
  })
1622
1838
  .catch((error) => {
@@ -1998,21 +2214,37 @@ function createDesktopIpcClient({
1998
2214
  onConnected,
1999
2215
  onDisconnect,
2000
2216
  }) {
2217
+ const resolveSocketPaths = toSocketPathCandidatesResolver(socketPath);
2001
2218
  let socket = null;
2002
2219
  let clientId = "";
2003
2220
  let isConnecting = false;
2004
2221
  let lastActivityAt = 0;
2005
- let readBuffer = Buffer.alloc(0);
2222
+ let remainingSocketPaths = [];
2006
2223
  const pendingRequests = new Map();
2007
2224
  const pendingDiscoveries = new Map();
2225
+ const frameReader = createFrameReader({
2226
+ onFrame: (envelope) => dispatchEnvelope(envelope),
2227
+ onOverflow: () => close(),
2228
+ });
2008
2229
 
2009
2230
  function ensureConnected() {
2010
2231
  if (socket || isConnecting) {
2011
2232
  return;
2012
2233
  }
2013
2234
 
2235
+ remainingSocketPaths = resolveSocketPaths();
2236
+ connectNextSocket();
2237
+ }
2238
+
2239
+ function connectNextSocket() {
2240
+ const nextSocketPath = remainingSocketPaths.shift();
2241
+ if (!nextSocketPath) {
2242
+ isConnecting = false;
2243
+ return;
2244
+ }
2245
+
2014
2246
  isConnecting = true;
2015
- const nextSocket = netModule.createConnection(socketPath);
2247
+ const nextSocket = netModule.createConnection(nextSocketPath);
2016
2248
  socket = nextSocket;
2017
2249
 
2018
2250
  nextSocket.on("connect", () => {
@@ -2028,14 +2260,29 @@ function createDesktopIpcClient({
2028
2260
  });
2029
2261
  });
2030
2262
  nextSocket.on("data", handleData);
2031
- nextSocket.on("close", handleClose);
2263
+ nextSocket.on("close", () => handleClose(nextSocket));
2032
2264
  nextSocket.on("error", (error) => {
2265
+ if ((error?.code === "ENOENT" || error?.code === "ECONNREFUSED")
2266
+ && remainingSocketPaths.length > 0) {
2267
+ retryNextSocket(nextSocket);
2268
+ return;
2269
+ }
2033
2270
  if (error?.code !== "ENOENT" && error?.code !== "ECONNREFUSED") {
2034
2271
  console.warn(`${logPrefix} desktop IPC connection failed: ${error.message}`);
2035
2272
  }
2036
2273
  });
2037
2274
  }
2038
2275
 
2276
+ function retryNextSocket(failedSocket) {
2277
+ if (socket === failedSocket) {
2278
+ socket = null;
2279
+ }
2280
+ isConnecting = false;
2281
+ frameReader.reset();
2282
+ failedSocket.destroy();
2283
+ connectNextSocket();
2284
+ }
2285
+
2039
2286
  function sendRequest(method, params) {
2040
2287
  ensureConnected();
2041
2288
  if (!socket || socket.destroyed) {
@@ -2043,14 +2290,13 @@ function createDesktopIpcClient({
2043
2290
  }
2044
2291
 
2045
2292
  const requestId = `remodex-${now().toString(36)}-${Math.random().toString(16).slice(2)}`;
2046
- const envelope = {
2047
- type: "request",
2293
+ const envelope = buildIpcRequestEnvelope({
2048
2294
  requestId,
2049
- sourceClientId: method === "initialize" ? "initializing-client" : clientId || "remodex-bridge",
2050
- version: METHOD_VERSION_BY_NAME.get(method) || 1,
2051
2295
  method,
2052
- params: params || {},
2053
- };
2296
+ params,
2297
+ clientId,
2298
+ initializing: method === "initialize",
2299
+ });
2054
2300
 
2055
2301
  return new Promise((resolve, reject) => {
2056
2302
  const timeout = setTimeout(() => {
@@ -2112,28 +2358,30 @@ function createDesktopIpcClient({
2112
2358
  });
2113
2359
  }
2114
2360
 
2361
+ function sendBroadcast(method, params, { targetClientIds } = {}) {
2362
+ ensureConnected();
2363
+ if (!socket || socket.destroyed || !clientId) {
2364
+ return false;
2365
+ }
2366
+ const envelope = {
2367
+ type: "broadcast",
2368
+ method,
2369
+ sourceClientId: clientId,
2370
+ params: params || {},
2371
+ version: METHOD_VERSION_BY_NAME.get(method) || 1,
2372
+ };
2373
+ if (Array.isArray(targetClientIds) && targetClientIds.length > 0) {
2374
+ envelope.targetClientIds = targetClientIds;
2375
+ }
2376
+ writeEnvelope(envelope);
2377
+ return true;
2378
+ }
2379
+
2115
2380
  function handleData(chunk) {
2116
2381
  if (chunk.length > 0) {
2117
2382
  lastActivityAt = now();
2118
2383
  }
2119
- readBuffer = Buffer.concat([readBuffer, chunk]);
2120
- while (readBuffer.length >= FRAME_HEADER_BYTES) {
2121
- const frameLength = readBuffer.readUInt32LE(0);
2122
- if (frameLength > MAX_FRAME_BYTES) {
2123
- close();
2124
- return;
2125
- }
2126
- if (readBuffer.length < FRAME_HEADER_BYTES + frameLength) {
2127
- return;
2128
- }
2129
-
2130
- const payload = readBuffer.slice(FRAME_HEADER_BYTES, FRAME_HEADER_BYTES + frameLength).toString("utf8");
2131
- readBuffer = readBuffer.slice(FRAME_HEADER_BYTES + frameLength);
2132
- const envelope = safeParseJSON(payload);
2133
- if (envelope) {
2134
- dispatchEnvelope(envelope);
2135
- }
2136
- }
2384
+ frameReader.push(chunk);
2137
2385
  }
2138
2386
 
2139
2387
  function dispatchEnvelope(envelope) {
@@ -2187,12 +2435,15 @@ function createDesktopIpcClient({
2187
2435
  onEnvelope(envelope);
2188
2436
  }
2189
2437
 
2190
- function handleClose() {
2438
+ function handleClose(closedSocket) {
2439
+ if (socket && socket !== closedSocket) {
2440
+ return;
2441
+ }
2191
2442
  socket = null;
2192
2443
  clientId = "";
2193
2444
  isConnecting = false;
2194
2445
  lastActivityAt = 0;
2195
- readBuffer = Buffer.alloc(0);
2446
+ frameReader.reset();
2196
2447
  for (const waiter of pendingRequests.values()) {
2197
2448
  clearTimeout(waiter.timeout);
2198
2449
  waiter.reject(new Error("Desktop IPC connection closed."));
@@ -2226,6 +2477,9 @@ function createDesktopIpcClient({
2226
2477
  }
2227
2478
 
2228
2479
  return {
2480
+ get clientId() {
2481
+ return clientId;
2482
+ },
2229
2483
  ensureConnected,
2230
2484
  isConnected() {
2231
2485
  return Boolean(socket && !socket.destroyed && clientId);
@@ -2237,6 +2491,7 @@ function createDesktopIpcClient({
2237
2491
  },
2238
2492
  sendRequest,
2239
2493
  sendDiscoveryRequest,
2494
+ sendBroadcast,
2240
2495
  close,
2241
2496
  };
2242
2497
  }
@@ -2257,7 +2512,7 @@ function desktopFollowerPayloadForResponse(route, responseMessage) {
2257
2512
  method,
2258
2513
  params: {
2259
2514
  conversationId: route.threadId,
2260
- requestId: route.requestId,
2515
+ requestId: route.desktopRequestId ?? route.requestId,
2261
2516
  response: {
2262
2517
  answers,
2263
2518
  },
@@ -2435,7 +2690,9 @@ function projectPendingDesktopAction(threadId, request) {
2435
2690
  }
2436
2691
 
2437
2692
  return {
2438
- id: requestId,
2693
+ // Keep the original JSON-RPC scalar for the relay request and the Desktop
2694
+ // follower reply. requestIdKey is only an internal map/deduplication key.
2695
+ id: request.id,
2439
2696
  method,
2440
2697
  params: {
2441
2698
  ...params,