@makerbi/remodex 2.4.0 → 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,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)
@@ -478,6 +528,7 @@ function createDesktopIpcActionFollower({
478
528
  ownershipProbeDeadlinesByThreadId.set(threadId, now() + ownershipProbeTimeoutMs);
479
529
  }
480
530
  ipc.ensureConnected();
531
+ followDesktopThread(threadId);
481
532
  return false;
482
533
  }
483
534
 
@@ -495,9 +546,15 @@ function createDesktopIpcActionFollower({
495
546
  normalizedReviewFingerprintsByThreadId.clear();
496
547
  conversationProjector.reset();
497
548
  pendingRoutesByRequestId.clear();
549
+ for (const threadId of desktopFollowThreadIds) {
550
+ unfollowDesktopThread(threadId);
551
+ }
552
+ desktopFollowThreadIds.clear();
498
553
  activeThreadIds.clear();
554
+ followerClientIdsByThreadId.clear();
499
555
  backgroundOnlyThreadIds.clear();
500
556
  announcedBackgroundTurnsByThreadId.clear();
557
+ backgroundTurnReannouncedAtByThreadId.clear();
501
558
  recoveringThreadIds.clear();
502
559
  baselineRecoveryStateByThreadId.clear();
503
560
  queuedChangesByThreadId.clear();
@@ -516,6 +573,25 @@ function createDesktopIpcActionFollower({
516
573
 
517
574
  // Desktop broadcasts carry the live conversation state Litter projects from.
518
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
+ }
519
595
  if (envelope?.type === "broadcast"
520
596
  && (envelope.method === "thread-archived" || envelope.method === "thread-unarchived")) {
521
597
  syncThreadArchiveBroadcast(envelope);
@@ -560,6 +636,14 @@ function createDesktopIpcActionFollower({
560
636
  backgroundOnlyThreadIds.add(threadId);
561
637
  }
562
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
+ }
563
647
  return;
564
648
  }
565
649
 
@@ -581,21 +665,11 @@ function createDesktopIpcActionFollower({
581
665
  const previousState = rawStatesByThreadId.get(threadId) || null;
582
666
  const nextState = applyConversationStateChange(previousState, params.change);
583
667
  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
- }
668
+ if (commitSpeculativeActionPatch(threadId, params.change)) {
669
+ return;
670
+ }
598
671
 
672
+ if (isPatchChange(params.change)) {
599
673
  if (typeof readConversationState !== "function") {
600
674
  return;
601
675
  }
@@ -617,6 +691,31 @@ function createDesktopIpcActionFollower({
617
691
  });
618
692
  }
619
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
+
620
719
  function schedulePendingSnapshot(threadId, state) {
621
720
  cancelPendingSnapshot(threadId);
622
721
  const timer = setTimeoutFn(() => {
@@ -735,6 +834,7 @@ function createDesktopIpcActionFollower({
735
834
  queuedChangesByThreadId.clear();
736
835
  pendingOwnershipProbeTokensByThreadId.clear();
737
836
  desktopOwnedByProbeThreadIds.clear();
837
+ clearFollowerState();
738
838
  // A lost IPC connection is not evidence that Desktop stopped the turn.
739
839
  // Keep announced lifecycle state until a reconnect snapshot, archive, or
740
840
  // another authoritative state transition supplies a real terminal status.
@@ -750,6 +850,67 @@ function createDesktopIpcActionFollower({
750
850
  // a proven delivery failure falls back to the local app-server.
751
851
  }
752
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
+
753
914
  // The bridge's own live owner just claimed this thread's stream, so drop stale
754
915
  // Desktop state instead of hijacking future phone requests into Desktop IPC.
755
916
  function releaseDesktopThreadState(threadId) {
@@ -759,6 +920,7 @@ function createDesktopIpcActionFollower({
759
920
  activeThreadIds.delete(threadId);
760
921
  }
761
922
  liveOwnerThreadIds.add(threadId);
923
+ unfollowDesktopThread(threadId);
762
924
  ownershipProbeDeadlinesByThreadId.delete(threadId);
763
925
  pendingOwnershipProbeTokensByThreadId.delete(threadId);
764
926
  desktopOwnedByProbeThreadIds.delete(threadId);
@@ -789,6 +951,7 @@ function createDesktopIpcActionFollower({
789
951
  activeThreadIds.delete(threadId);
790
952
  }
791
953
  liveOwnerThreadIds.delete(threadId);
954
+ unfollowDesktopThread(threadId);
792
955
  ownershipProbeDeadlinesByThreadId.delete(threadId);
793
956
  pendingOwnershipProbeTokensByThreadId.delete(threadId);
794
957
  desktopOwnedByProbeThreadIds.delete(threadId);
@@ -1003,23 +1166,30 @@ function createDesktopIpcActionFollower({
1003
1166
  }
1004
1167
 
1005
1168
  function syncProjectedActions(threadId, actions) {
1006
- const nextRequestIds = new Set(actions.map((action) => action.id));
1169
+ const nextRequestIds = new Set(actions.map((action) => requestIdKey(action.id)));
1007
1170
  for (const [requestId, route] of Array.from(pendingRoutesByRequestId.entries())) {
1008
1171
  if (route.threadId !== threadId || nextRequestIds.has(requestId)) {
1009
1172
  continue;
1010
1173
  }
1011
1174
 
1012
1175
  pendingRoutesByRequestId.delete(requestId);
1013
- sendApplicationResponse(JSON.stringify(projectedResolvedNotification(threadId, requestId)));
1176
+ sendApplicationResponse(JSON.stringify(
1177
+ projectedResolvedNotification(threadId, route.desktopRequestId)
1178
+ ));
1014
1179
  }
1015
1180
 
1016
1181
  for (const action of actions) {
1017
- if (pendingRoutesByRequestId.has(action.id)) {
1182
+ const requestId = requestIdKey(action.id);
1183
+ if (!requestId || pendingRoutesByRequestId.has(requestId)) {
1018
1184
  continue;
1019
1185
  }
1020
1186
 
1021
- pendingRoutesByRequestId.set(action.id, {
1022
- 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,
1023
1193
  method: action.method,
1024
1194
  threadId,
1025
1195
  });
@@ -1551,9 +1721,36 @@ function createDesktopIpcActionFollower({
1551
1721
  settledTurn
1552
1722
  )));
1553
1723
  announcedBackgroundTurnsByThreadId.delete(threadId);
1724
+ backgroundTurnReannouncedAtByThreadId.delete(threadId);
1554
1725
  return true;
1555
1726
  }
1556
1727
 
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()) {
1738
+ return;
1739
+ }
1740
+
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
+ )));
1751
+ }
1752
+ }
1753
+
1557
1754
  function syncThreadArchiveBroadcast(envelope) {
1558
1755
  const params = envelope.params || {};
1559
1756
  const threadId = readString(params.conversationId) || readString(params.conversation_id);
@@ -1616,7 +1813,7 @@ function createDesktopIpcActionFollower({
1616
1813
  .then(() => {
1617
1814
  pendingRoutesByRequestId.delete(route.requestId);
1618
1815
  sendApplicationResponse(JSON.stringify(
1619
- projectedResolvedNotification(route.threadId, route.requestId)
1816
+ projectedResolvedNotification(route.threadId, route.desktopRequestId)
1620
1817
  ));
1621
1818
  })
1622
1819
  .catch((error) => {
@@ -1998,21 +2195,37 @@ function createDesktopIpcClient({
1998
2195
  onConnected,
1999
2196
  onDisconnect,
2000
2197
  }) {
2198
+ const resolveSocketPaths = toSocketPathCandidatesResolver(socketPath);
2001
2199
  let socket = null;
2002
2200
  let clientId = "";
2003
2201
  let isConnecting = false;
2004
2202
  let lastActivityAt = 0;
2005
- let readBuffer = Buffer.alloc(0);
2203
+ let remainingSocketPaths = [];
2006
2204
  const pendingRequests = new Map();
2007
2205
  const pendingDiscoveries = new Map();
2206
+ const frameReader = createFrameReader({
2207
+ onFrame: (envelope) => dispatchEnvelope(envelope),
2208
+ onOverflow: () => close(),
2209
+ });
2008
2210
 
2009
2211
  function ensureConnected() {
2010
2212
  if (socket || isConnecting) {
2011
2213
  return;
2012
2214
  }
2013
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
+
2014
2227
  isConnecting = true;
2015
- const nextSocket = netModule.createConnection(socketPath);
2228
+ const nextSocket = netModule.createConnection(nextSocketPath);
2016
2229
  socket = nextSocket;
2017
2230
 
2018
2231
  nextSocket.on("connect", () => {
@@ -2028,14 +2241,29 @@ function createDesktopIpcClient({
2028
2241
  });
2029
2242
  });
2030
2243
  nextSocket.on("data", handleData);
2031
- nextSocket.on("close", handleClose);
2244
+ nextSocket.on("close", () => handleClose(nextSocket));
2032
2245
  nextSocket.on("error", (error) => {
2246
+ if ((error?.code === "ENOENT" || error?.code === "ECONNREFUSED")
2247
+ && remainingSocketPaths.length > 0) {
2248
+ retryNextSocket(nextSocket);
2249
+ return;
2250
+ }
2033
2251
  if (error?.code !== "ENOENT" && error?.code !== "ECONNREFUSED") {
2034
2252
  console.warn(`${logPrefix} desktop IPC connection failed: ${error.message}`);
2035
2253
  }
2036
2254
  });
2037
2255
  }
2038
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
+
2039
2267
  function sendRequest(method, params) {
2040
2268
  ensureConnected();
2041
2269
  if (!socket || socket.destroyed) {
@@ -2043,14 +2271,13 @@ function createDesktopIpcClient({
2043
2271
  }
2044
2272
 
2045
2273
  const requestId = `remodex-${now().toString(36)}-${Math.random().toString(16).slice(2)}`;
2046
- const envelope = {
2047
- type: "request",
2274
+ const envelope = buildIpcRequestEnvelope({
2048
2275
  requestId,
2049
- sourceClientId: method === "initialize" ? "initializing-client" : clientId || "remodex-bridge",
2050
- version: METHOD_VERSION_BY_NAME.get(method) || 1,
2051
2276
  method,
2052
- params: params || {},
2053
- };
2277
+ params,
2278
+ clientId,
2279
+ initializing: method === "initialize",
2280
+ });
2054
2281
 
2055
2282
  return new Promise((resolve, reject) => {
2056
2283
  const timeout = setTimeout(() => {
@@ -2112,28 +2339,30 @@ function createDesktopIpcClient({
2112
2339
  });
2113
2340
  }
2114
2341
 
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
+ }
2360
+
2115
2361
  function handleData(chunk) {
2116
2362
  if (chunk.length > 0) {
2117
2363
  lastActivityAt = now();
2118
2364
  }
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
- }
2365
+ frameReader.push(chunk);
2137
2366
  }
2138
2367
 
2139
2368
  function dispatchEnvelope(envelope) {
@@ -2187,12 +2416,15 @@ function createDesktopIpcClient({
2187
2416
  onEnvelope(envelope);
2188
2417
  }
2189
2418
 
2190
- function handleClose() {
2419
+ function handleClose(closedSocket) {
2420
+ if (socket && socket !== closedSocket) {
2421
+ return;
2422
+ }
2191
2423
  socket = null;
2192
2424
  clientId = "";
2193
2425
  isConnecting = false;
2194
2426
  lastActivityAt = 0;
2195
- readBuffer = Buffer.alloc(0);
2427
+ frameReader.reset();
2196
2428
  for (const waiter of pendingRequests.values()) {
2197
2429
  clearTimeout(waiter.timeout);
2198
2430
  waiter.reject(new Error("Desktop IPC connection closed."));
@@ -2226,6 +2458,9 @@ function createDesktopIpcClient({
2226
2458
  }
2227
2459
 
2228
2460
  return {
2461
+ get clientId() {
2462
+ return clientId;
2463
+ },
2229
2464
  ensureConnected,
2230
2465
  isConnected() {
2231
2466
  return Boolean(socket && !socket.destroyed && clientId);
@@ -2237,6 +2472,7 @@ function createDesktopIpcClient({
2237
2472
  },
2238
2473
  sendRequest,
2239
2474
  sendDiscoveryRequest,
2475
+ sendBroadcast,
2240
2476
  close,
2241
2477
  };
2242
2478
  }
@@ -2257,7 +2493,7 @@ function desktopFollowerPayloadForResponse(route, responseMessage) {
2257
2493
  method,
2258
2494
  params: {
2259
2495
  conversationId: route.threadId,
2260
- requestId: route.requestId,
2496
+ requestId: route.desktopRequestId ?? route.requestId,
2261
2497
  response: {
2262
2498
  answers,
2263
2499
  },
@@ -2435,7 +2671,9 @@ function projectPendingDesktopAction(threadId, request) {
2435
2671
  }
2436
2672
 
2437
2673
  return {
2438
- 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,
2439
2677
  method,
2440
2678
  params: {
2441
2679
  ...params,