@makerbi/remodex 2.3.2 → 2.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,6 +9,7 @@ const net = require("net");
9
9
  const {
10
10
  CLIENT_STATUS_CHANGED,
11
11
  DESKTOP_IPC_METHOD_VERSIONS: METHOD_VERSION_BY_NAME,
12
+ buildCompleteThreadReadParams,
12
13
  cloneJSON,
13
14
  conversationSnapshotShowsActiveTurn,
14
15
  isPlainJSONObject,
@@ -16,6 +17,7 @@ const {
16
17
  readString,
17
18
  requestIdKey,
18
19
  resolveDefaultIpcSocketPath,
20
+ resolveIpcSocketPathCandidates,
19
21
  safeParseJSON,
20
22
  visibleUserPromptFromInputEntries,
21
23
  } = require("./desktop-ipc-shared");
@@ -51,6 +53,8 @@ const DEFAULT_LIVE_OWNERSHIP_FRESHNESS_MS = 20_000;
51
53
  // can actually see the thread.
52
54
  const DEFAULT_SIDEBAR_REFRESH_DELAY_MS = 1_200;
53
55
  const THREAD_STREAM_STATE_CHANGED = "thread-stream-state-changed";
56
+ const THREAD_STREAM_FOLLOWING_CHANGED = "thread-stream-following-changed";
57
+ const THREAD_STREAM_FOLLOWING_STATUS_REQUESTED = "thread-stream-following-status-requested";
54
58
  // Cached thread/read responses are only a hydration convenience; owned threads
55
59
  // are never evicted, so a small cap keeps long browsing sessions bounded.
56
60
  const MAX_CACHED_THREADS = 30;
@@ -114,7 +118,9 @@ function createDesktopIpcLiveOwner({
114
118
  sendRawCodexMessage,
115
119
  normalizeTurnStartParams = (params) => params,
116
120
  runtimeSettingsStore = null,
117
- socketPath = resolveDefaultIpcSocketPath(),
121
+ // Resolved per connect: Codex Desktop can start, stop, or move its bus while
122
+ // the bridge stays up.
123
+ socketPath = resolveIpcSocketPathCandidates,
118
124
  sidebarRefreshDelayMs = DEFAULT_SIDEBAR_REFRESH_DELAY_MS,
119
125
  snapshotDebounceMs = DEFAULT_SNAPSHOT_DEBOUNCE_MS,
120
126
  maxPatchCount = DEFAULT_MAX_PATCH_COUNT,
@@ -124,6 +130,7 @@ function createDesktopIpcLiveOwner({
124
130
  initialHistoryRetryMs = DEFAULT_INITIAL_HISTORY_RETRY_MS,
125
131
  initialHistoryMaxAttempts = DEFAULT_INITIAL_HISTORY_MAX_ATTEMPTS,
126
132
  liveOwnershipFreshnessMs = DEFAULT_LIVE_OWNERSHIP_FRESHNESS_MS,
133
+ onFollowerStateChanged = null,
127
134
  netModule = net,
128
135
  now = () => Date.now(),
129
136
  logPrefix = "[remodex]",
@@ -155,8 +162,14 @@ function createDesktopIpcLiveOwner({
155
162
  // baseRevision matches their last-seen revision, and load-complete-history
156
163
  // waits for the snapshot carrying the returned revision.
157
164
  const streamRevisionsByThreadId = new Map();
165
+ const followerClientIdsByThreadId = new Map();
158
166
  const announcedSidebarThreadIds = new Set();
159
167
  const sidebarRefreshTimersByThreadId = new Map();
168
+ // A new rollout can exist as a thread id before Desktop's separate app-server
169
+ // can return it from thread/list. Keep a bounded materialization replay alive
170
+ // until the first user item and turn completion prove the rollout was written.
171
+ const pendingSidebarMaterializationThreadIds = new Set();
172
+ const replayedSidebarMaterializationThreadIds = new Set();
160
173
  const pendingTurnStartParamsByThreadId = new Map();
161
174
  const pendingTurnStartEntriesByRequestId = new Map();
162
175
  const followerRuntimeOverridesByThreadId = new Map();
@@ -179,6 +192,7 @@ function createDesktopIpcLiveOwner({
179
192
  logPrefix,
180
193
  onConnected() {
181
194
  flushPendingThreadArchiveMetadataBroadcasts();
195
+ requestFollowerStatusForAllOwnedThreads();
182
196
  broadcastAllOwnedSnapshots();
183
197
  },
184
198
  onBroadcast(envelope) {
@@ -322,6 +336,7 @@ function createDesktopIpcLiveOwner({
322
336
  }
323
337
  refreshOptimisticFallbackForThread(update.threadId);
324
338
  scheduleSnapshot(update.threadId);
339
+ replaySidebarAnnouncementAfterMaterialization(message, update.threadId);
325
340
  }
326
341
 
327
342
  if (readString(message.method) === "turn/completed") {
@@ -360,11 +375,14 @@ function createDesktopIpcLiveOwner({
360
375
  lastBroadcastStatesByThreadId.clear();
361
376
  fallbackTurnIdsByThreadId.clear();
362
377
  streamRevisionsByThreadId.clear();
378
+ followerClientIdsByThreadId.clear();
363
379
  for (const timer of sidebarRefreshTimersByThreadId.values()) {
364
380
  clearTimeout(timer);
365
381
  }
366
382
  sidebarRefreshTimersByThreadId.clear();
367
383
  announcedSidebarThreadIds.clear();
384
+ pendingSidebarMaterializationThreadIds.clear();
385
+ replayedSidebarMaterializationThreadIds.clear();
368
386
  pendingTurnStartParamsByThreadId.clear();
369
387
  pendingTurnStartEntriesByRequestId.clear();
370
388
  followerRuntimeOverridesByThreadId.clear();
@@ -616,8 +634,12 @@ function createDesktopIpcLiveOwner({
616
634
  if (!normalizedThreadId) {
617
635
  return;
618
636
  }
637
+ const isNewOwner = !ownedThreadIds.has(normalizedThreadId);
619
638
  ownedThreadIds.add(normalizedThreadId);
620
639
  ipc.ensureConnected();
640
+ if (isNewOwner) {
641
+ requestFollowerStatus(normalizedThreadId);
642
+ }
621
643
  }
622
644
 
623
645
  // Notification-only thread/start paths do not echo a request id, so consume the
@@ -662,6 +684,9 @@ function createDesktopIpcLiveOwner({
662
684
  lastBroadcastStatesByThreadId.delete(normalizedThreadId);
663
685
  fallbackTurnIdsByThreadId.delete(normalizedThreadId);
664
686
  streamRevisionsByThreadId.delete(normalizedThreadId);
687
+ if (followerClientIdsByThreadId.delete(normalizedThreadId)) {
688
+ onFollowerStateChanged?.(normalizedThreadId, false);
689
+ }
665
690
  // An active-peer takeover can still drop a non-empty queue (hasActiveLocalTurn
666
691
  // only shields idle yields); announce the emptied queue instead of letting
667
692
  // clients keep rendering drafts the bridge will never run.
@@ -674,6 +699,8 @@ function createDesktopIpcLiveOwner({
674
699
  announcedReadStateThreadIds.delete(normalizedThreadId);
675
700
  cancelSidebarAnnouncement(normalizedThreadId);
676
701
  announcedSidebarThreadIds.delete(normalizedThreadId);
702
+ pendingSidebarMaterializationThreadIds.delete(normalizedThreadId);
703
+ replayedSidebarMaterializationThreadIds.delete(normalizedThreadId);
677
704
  pendingTurnStartParamsByThreadId.delete(normalizedThreadId);
678
705
  followerRuntimeOverridesByThreadId.delete(normalizedThreadId);
679
706
  for (const [requestId, pending] of Array.from(pendingTurnStartEntriesByRequestId.entries())) {
@@ -726,9 +753,9 @@ function createDesktopIpcLiveOwner({
726
753
  }
727
754
 
728
755
  // Desktop has no watcher on the shared session store, but its webview reacts
729
- // to thread-unarchived broadcasts by re-running thread/list. Announcing each
730
- // phone-driven thread once (after the rollout has had time to persist) makes
731
- // it appear in Desktop's sidebar without the disruptive deep-link bounce.
756
+ // to thread-unarchived broadcasts by re-running thread/list. The first timed
757
+ // announcement keeps the common path responsive; materialization events below
758
+ // replay it because a new rollout id can precede Desktop's thread/list entry.
732
759
  function scheduleSidebarAnnouncement(threadId) {
733
760
  const normalizedThreadId = readString(threadId);
734
761
  if (!normalizedThreadId
@@ -736,6 +763,7 @@ function createDesktopIpcLiveOwner({
736
763
  || sidebarRefreshTimersByThreadId.has(normalizedThreadId)) {
737
764
  return;
738
765
  }
766
+ pendingSidebarMaterializationThreadIds.add(normalizedThreadId);
739
767
  const timer = setTimeout(() => {
740
768
  sidebarRefreshTimersByThreadId.delete(normalizedThreadId);
741
769
  if (!ownedThreadIds.has(normalizedThreadId)) {
@@ -748,6 +776,42 @@ function createDesktopIpcLiveOwner({
748
776
  sidebarRefreshTimersByThreadId.set(normalizedThreadId, timer);
749
777
  }
750
778
 
779
+ function replaySidebarAnnouncementAfterMaterialization(message, threadId) {
780
+ const normalizedThreadId = readString(threadId);
781
+ if (!normalizedThreadId
782
+ || !ownedThreadIds.has(normalizedThreadId)
783
+ || !pendingSidebarMaterializationThreadIds.has(normalizedThreadId)) {
784
+ return;
785
+ }
786
+
787
+ const method = readString(message?.method);
788
+ const itemType = readString(message?.params?.item?.type);
789
+ const turnItems = Array.isArray(message?.params?.turn?.items)
790
+ ? message.params.turn.items
791
+ : [];
792
+ const includesPersistedUserMessage = (
793
+ (method === "item/started" || method === "item/completed")
794
+ && itemType === "userMessage"
795
+ ) || turnItems.some((item) => readString(item?.type) === "userMessage");
796
+ const turnCompleted = method === "turn/completed";
797
+ if (!includesPersistedUserMessage && !turnCompleted) {
798
+ return;
799
+ }
800
+
801
+ cancelSidebarAnnouncement(normalizedThreadId);
802
+ announcedSidebarThreadIds.add(normalizedThreadId);
803
+ if (!replayedSidebarMaterializationThreadIds.has(normalizedThreadId) || turnCompleted) {
804
+ broadcastThreadUnarchived(normalizedThreadId);
805
+ }
806
+
807
+ if (turnCompleted) {
808
+ pendingSidebarMaterializationThreadIds.delete(normalizedThreadId);
809
+ replayedSidebarMaterializationThreadIds.delete(normalizedThreadId);
810
+ return;
811
+ }
812
+ replayedSidebarMaterializationThreadIds.add(normalizedThreadId);
813
+ }
814
+
751
815
  function cancelSidebarAnnouncement(threadId) {
752
816
  const timer = sidebarRefreshTimersByThreadId.get(threadId);
753
817
  if (timer) {
@@ -993,7 +1057,10 @@ function createDesktopIpcLiveOwner({
993
1057
  return;
994
1058
  }
995
1059
  const hydration = Promise.resolve()
996
- .then(() => sendCodexRequest("thread/read", { threadId: normalizedThreadId }))
1060
+ .then(() => sendCodexRequest(
1061
+ "thread/read",
1062
+ buildCompleteThreadReadParams(normalizedThreadId)
1063
+ ))
997
1064
  .then((result) => {
998
1065
  const thread = readThreadFromPayload(result);
999
1066
  if (!thread?.id) {
@@ -1097,7 +1164,19 @@ function createDesktopIpcLiveOwner({
1097
1164
 
1098
1165
  function handlePeerBroadcast(envelope) {
1099
1166
  if (envelope?.method === CLIENT_STATUS_CHANGED) {
1167
+ // The fallback router can accept the owner's connection before any
1168
+ // Desktop client exists. Metadata broadcasts remain queued in that state;
1169
+ // retry them whenever peer membership changes so a newly connected
1170
+ // Desktop immediately refreshes its sidebar.
1171
+ flushPendingThreadArchiveMetadataBroadcasts();
1100
1172
  broadcastAllOwnedSnapshots();
1173
+ if (normalizeToken(envelope.params?.status) === "disconnected") {
1174
+ removeFollowerClient(envelope.params?.clientId || envelope.sourceClientId);
1175
+ }
1176
+ return;
1177
+ }
1178
+ if (envelope?.method === THREAD_STREAM_FOLLOWING_CHANGED) {
1179
+ updateFollowerState(envelope);
1101
1180
  return;
1102
1181
  }
1103
1182
  if (maybeYieldOwnedThreadForPeerArchive(envelope)) {
@@ -1131,6 +1210,66 @@ function createDesktopIpcLiveOwner({
1131
1210
  removeOwnedThread(threadId);
1132
1211
  }
1133
1212
 
1213
+ function updateFollowerState(envelope) {
1214
+ const params = envelope?.params || {};
1215
+ const threadId = readString(params.conversationId) || readString(params.conversation_id);
1216
+ const clientId = readString(envelope?.sourceClientId);
1217
+ if (!threadId || !clientId || !ownedThreadIds.has(threadId)) {
1218
+ return;
1219
+ }
1220
+
1221
+ const followers = followerClientIdsByThreadId.get(threadId) || new Set();
1222
+ if (params.following === true) {
1223
+ const wasUnfollowed = followers.size === 0;
1224
+ followers.add(clientId);
1225
+ followerClientIdsByThreadId.set(threadId, followers);
1226
+ if (wasUnfollowed) {
1227
+ onFollowerStateChanged?.(threadId, true);
1228
+ }
1229
+ // Match Codex's owner behavior: a newly mounted follower receives an
1230
+ // immediate full baseline instead of waiting for the next model delta.
1231
+ broadcastConversationState(threadId, { forceSnapshot: true });
1232
+ return;
1233
+ }
1234
+
1235
+ if (!followers.delete(clientId)) {
1236
+ return;
1237
+ }
1238
+ if (followers.size === 0) {
1239
+ followerClientIdsByThreadId.delete(threadId);
1240
+ onFollowerStateChanged?.(threadId, false);
1241
+ }
1242
+ }
1243
+
1244
+ function removeFollowerClient(clientId) {
1245
+ const normalizedClientId = readString(clientId);
1246
+ if (!normalizedClientId) {
1247
+ return;
1248
+ }
1249
+ for (const [threadId, followers] of followerClientIdsByThreadId) {
1250
+ if (!followers.delete(normalizedClientId)) {
1251
+ continue;
1252
+ }
1253
+ if (followers.size === 0) {
1254
+ followerClientIdsByThreadId.delete(threadId);
1255
+ onFollowerStateChanged?.(threadId, false);
1256
+ }
1257
+ }
1258
+ }
1259
+
1260
+ function requestFollowerStatusForAllOwnedThreads() {
1261
+ for (const threadId of ownedThreadIds) {
1262
+ requestFollowerStatus(threadId);
1263
+ }
1264
+ }
1265
+
1266
+ function requestFollowerStatus(threadId) {
1267
+ ipc.sendBroadcast(THREAD_STREAM_FOLLOWING_STATUS_REQUESTED, {
1268
+ hostId,
1269
+ conversationId: threadId,
1270
+ });
1271
+ }
1272
+
1134
1273
  function isPeerOwnershipBroadcast(params) {
1135
1274
  if (readString(params?.remodexOwnerSource) === REMODEX_LIVE_OWNER_SOURCE) {
1136
1275
  return false;
@@ -1203,7 +1342,10 @@ function createDesktopIpcLiveOwner({
1203
1342
  // history is complete, then force-broadcast a fresh snapshot.
1204
1343
  async function handleFollowerLoadCompleteHistory(conversationId) {
1205
1344
  try {
1206
- const result = await sendCodexRequest("thread/read", { threadId: conversationId });
1345
+ const result = await sendCodexRequest(
1346
+ "thread/read",
1347
+ buildCompleteThreadReadParams(conversationId)
1348
+ );
1207
1349
  const thread = readThreadFromPayload(result);
1208
1350
  if (thread?.id) {
1209
1351
  rememberCachedThread(thread.id, thread);
@@ -11,16 +11,18 @@ const path = require("path");
11
11
  const {
12
12
  CLIENT_STATUS_CHANGED,
13
13
  DESKTOP_IPC_METHOD_VERSIONS: METHOD_VERSION_BY_NAME,
14
- FRAME_HEADER_BYTES,
15
- MAX_FRAME_BYTES,
14
+ buildIpcRequestEnvelope,
15
+ createFrameReader,
16
16
  normalizeToken,
17
17
  readString,
18
18
  requestIdKey,
19
- safeParseJSON,
19
+ toSocketPathCandidatesResolver,
20
+ toSocketPathResolver,
20
21
  writeFrame,
21
22
  } = require("./desktop-ipc-shared");
22
23
 
23
24
  const DEFAULT_DISCOVERY_TIMEOUT_MS = 1_000;
25
+ const SOCKET_PROBE_TIMEOUT_MS = 500;
24
26
 
25
27
  function createDesktopOwnerIpcClient({
26
28
  socketPath,
@@ -35,16 +37,22 @@ function createDesktopOwnerIpcClient({
35
37
  canHandleRequest,
36
38
  handleRequest,
37
39
  }) {
40
+ const resolveSocketPaths = toSocketPathCandidatesResolver(socketPath);
38
41
  let socket = null;
39
42
  let isConnecting = false;
40
43
  let isInitialized = false;
41
44
  let clientId = "";
42
- let readBuffer = Buffer.alloc(0);
43
45
  let reconnectTimer = null;
44
46
  let shouldReconnect = false;
47
+ let remainingSocketPaths = [];
48
+ let preferredSocketFailure = null;
49
+ const frameReader = createFrameReader({
50
+ onFrame: (envelope) => dispatchEnvelope(envelope),
51
+ onOverflow: () => closeSocket(),
52
+ });
45
53
  const localRouter = startRouterWhenMissing
46
54
  ? createDesktopIpcRouterServer({
47
- socketPath,
55
+ socketPath: () => resolveSocketPaths()[0],
48
56
  netModule,
49
57
  now,
50
58
  requestTimeoutMs,
@@ -60,8 +68,20 @@ function createDesktopOwnerIpcClient({
60
68
  return;
61
69
  }
62
70
  clearReconnectTimer();
71
+ remainingSocketPaths = resolveSocketPaths();
72
+ preferredSocketFailure = null;
73
+ connectNextSocket();
74
+ }
75
+
76
+ function connectNextSocket() {
77
+ const nextSocketPath = remainingSocketPaths.shift();
78
+ if (!nextSocketPath) {
79
+ isConnecting = false;
80
+ return;
81
+ }
82
+
63
83
  isConnecting = true;
64
- const nextSocket = netModule.createConnection(socketPath);
84
+ const nextSocket = netModule.createConnection(nextSocketPath);
65
85
  socket = nextSocket;
66
86
 
67
87
  nextSocket.on("connect", () => {
@@ -77,11 +97,19 @@ function createDesktopOwnerIpcClient({
77
97
  closeSocket();
78
98
  });
79
99
  });
80
- nextSocket.on("data", handleData);
100
+ nextSocket.on("data", (chunk) => frameReader.push(chunk));
81
101
  nextSocket.on("close", () => handleClose(nextSocket));
82
102
  nextSocket.on("error", (error) => {
83
103
  if (error?.code === "ENOENT" || error?.code === "ECONNREFUSED") {
84
- startLocalRouterAfterMissingSocket(error.code);
104
+ preferredSocketFailure ||= { code: error.code, socketPath: nextSocketPath };
105
+ if (remainingSocketPaths.length > 0) {
106
+ retryNextSocket(nextSocket);
107
+ return;
108
+ }
109
+ startLocalRouterAfterMissingSocket(
110
+ preferredSocketFailure.code,
111
+ preferredSocketFailure.socketPath
112
+ );
85
113
  return;
86
114
  }
87
115
  if (error?.code !== "ENOENT" && error?.code !== "ECONNREFUSED") {
@@ -90,11 +118,46 @@ function createDesktopOwnerIpcClient({
90
118
  });
91
119
  }
92
120
 
93
- function startLocalRouterAfterMissingSocket(reasonCode) {
121
+ function retryNextSocket(failedSocket) {
122
+ if (socket === failedSocket) {
123
+ socket = null;
124
+ }
125
+ isConnecting = false;
126
+ frameReader.reset();
127
+ failedSocket.destroy();
128
+ connectNextSocket();
129
+ }
130
+
131
+ function startLocalRouterAfterMissingSocket(reasonCode, failedSocketPath) {
94
132
  if (!localRouter || localRouter.isStarted) {
95
133
  return;
96
134
  }
97
- localRouter.start({ removeStaleSocket: reasonCode === "ECONNREFUSED" })
135
+ // ECONNREFUSED means the socket file is there with nobody behind it, so the
136
+ // fallback router replaces it. Codex can bind its own bus in the gap between
137
+ // that refusal and the replacement, and unlinking a live socket would strand
138
+ // the desktop on a path no other client reaches: probe once more first, and
139
+ // let the ordinary reconnect take over when the bus is back.
140
+ const mayReplaceStaleSocket = reasonCode === "ECONNREFUSED";
141
+ const removeStaleSocket = mayReplaceStaleSocket
142
+ ? probeSocketIsListening(failedSocketPath, netModule).then((isListening) => !isListening)
143
+ : Promise.resolve(false);
144
+
145
+ removeStaleSocket
146
+ .then((shouldRemoveStaleSocket) => {
147
+ if (mayReplaceStaleSocket && !shouldRemoveStaleSocket) {
148
+ return null;
149
+ }
150
+ return startLocalRouter({ removeStaleSocket: shouldRemoveStaleSocket });
151
+ })
152
+ .catch((error) => {
153
+ if (error?.code !== "EADDRINUSE") {
154
+ console.warn(`${logPrefix} desktop IPC router fallback failed: ${error.message}`);
155
+ }
156
+ });
157
+ }
158
+
159
+ function startLocalRouter({ removeStaleSocket }) {
160
+ return localRouter.start({ removeStaleSocket })
98
161
  .then(() => {
99
162
  if (!shouldReconnect) {
100
163
  return;
@@ -103,11 +166,6 @@ function createDesktopOwnerIpcClient({
103
166
  isConnecting = false;
104
167
  clearReconnectTimer();
105
168
  ensureConnected();
106
- })
107
- .catch((error) => {
108
- if (error?.code !== "EADDRINUSE") {
109
- console.warn(`${logPrefix} desktop IPC router fallback failed: ${error.message}`);
110
- }
111
169
  });
112
170
  }
113
171
 
@@ -116,6 +174,12 @@ function createDesktopOwnerIpcClient({
116
174
  if (!socket || socket.destroyed || !isInitialized) {
117
175
  return false;
118
176
  }
177
+ // A write to the bridge-owned fallback router is not a delivery when no
178
+ // Desktop/VSCode peer is attached. Report that state as pending so callers
179
+ // can replay metadata and snapshots when the router announces a real peer.
180
+ if (localRouter && !localRouter.hasBroadcastRecipientFor(clientId)) {
181
+ return false;
182
+ }
119
183
  const envelope = {
120
184
  type: "broadcast",
121
185
  method,
@@ -132,14 +196,7 @@ function createDesktopOwnerIpcClient({
132
196
  return Promise.reject(new Error("Desktop IPC is not connected."));
133
197
  }
134
198
  const requestId = `remodex-owner-${now().toString(36)}-${randomUUID()}`;
135
- const envelope = {
136
- type: "request",
137
- requestId,
138
- sourceClientId: initializing ? "initializing-client" : clientId || "remodex-bridge",
139
- version: METHOD_VERSION_BY_NAME.get(method) || 1,
140
- method,
141
- params: params || {},
142
- };
199
+ const envelope = buildIpcRequestEnvelope({ requestId, method, params, clientId, initializing });
143
200
  return new Promise((resolve, reject) => {
144
201
  const timeout = setTimeout(() => {
145
202
  pendingResponses.delete(requestId);
@@ -160,27 +217,6 @@ function createDesktopOwnerIpcClient({
160
217
  });
161
218
  }
162
219
 
163
- function handleData(chunk) {
164
- readBuffer = Buffer.concat([readBuffer, chunk]);
165
- while (readBuffer.length >= FRAME_HEADER_BYTES) {
166
- const frameLength = readBuffer.readUInt32LE(0);
167
- if (frameLength > MAX_FRAME_BYTES) {
168
- closeSocket();
169
- return;
170
- }
171
- if (readBuffer.length < FRAME_HEADER_BYTES + frameLength) {
172
- return;
173
- }
174
-
175
- const payload = readBuffer.slice(FRAME_HEADER_BYTES, FRAME_HEADER_BYTES + frameLength).toString("utf8");
176
- readBuffer = readBuffer.slice(FRAME_HEADER_BYTES + frameLength);
177
- const envelope = safeParseJSON(payload);
178
- if (envelope) {
179
- dispatchEnvelope(envelope);
180
- }
181
- }
182
- }
183
-
184
220
  function dispatchEnvelope(envelope) {
185
221
  if (envelope.type === "response") {
186
222
  handleResponse(envelope);
@@ -252,7 +288,7 @@ function createDesktopOwnerIpcClient({
252
288
  isConnecting = false;
253
289
  isInitialized = false;
254
290
  clientId = "";
255
- readBuffer = Buffer.alloc(0);
291
+ frameReader.reset();
256
292
  for (const waiter of pendingResponses.values()) {
257
293
  clearTimeout(waiter.timeout);
258
294
  waiter.reject(new Error("Desktop IPC connection closed."));
@@ -332,10 +368,14 @@ function createDesktopIpcRouterServer({
332
368
  discoveryTimeoutMs,
333
369
  logPrefix,
334
370
  }) {
371
+ const resolveSocketPath = toSocketPathResolver(socketPath);
335
372
  let server = null;
336
373
  let started = false;
337
374
  let starting = null;
338
375
  let closed = false;
376
+ // Closing must unlink exactly what this router listened on, not whatever the
377
+ // resolver would pick later.
378
+ let listeningSocketPath = "";
339
379
  let nextClientSeq = 1;
340
380
  const clientsById = new Map();
341
381
  const pendingDiscoveryResponses = new Map();
@@ -350,8 +390,9 @@ function createDesktopIpcRouterServer({
350
390
  }
351
391
  closed = false;
352
392
  starting = new Promise((resolve, reject) => {
393
+ const nextSocketPath = resolveSocketPath();
353
394
  try {
354
- prepareSocketPathForListen(socketPath, { removeStaleSocket });
395
+ prepareSocketPathForListen(nextSocketPath, { removeStaleSocket });
355
396
  } catch (error) {
356
397
  starting = null;
357
398
  reject(error);
@@ -365,9 +406,10 @@ function createDesktopIpcRouterServer({
365
406
  server = null;
366
407
  reject(error);
367
408
  });
368
- nextServer.listen(socketPath, () => {
409
+ nextServer.listen(nextSocketPath, () => {
369
410
  started = true;
370
411
  starting = null;
412
+ listeningSocketPath = nextSocketPath;
371
413
  nextServer.removeAllListeners("error");
372
414
  nextServer.on("error", (error) => {
373
415
  console.warn(`${logPrefix} desktop IPC router fallback error: ${error.message}`);
@@ -384,35 +426,17 @@ function createDesktopIpcRouterServer({
384
426
  id: "",
385
427
  type: "",
386
428
  socket,
387
- buffer: Buffer.alloc(0),
388
429
  initialized: false,
389
430
  };
390
- socket.on("data", (chunk) => handleClientData(client, chunk));
431
+ const frameReader = createFrameReader({
432
+ onFrame: (envelope) => dispatchClientEnvelope(client, envelope),
433
+ onOverflow: () => client.socket.destroy(),
434
+ });
435
+ socket.on("data", (chunk) => frameReader.push(chunk));
391
436
  socket.on("close", () => removeClient(client));
392
437
  socket.on("error", () => removeClient(client));
393
438
  }
394
439
 
395
- function handleClientData(client, chunk) {
396
- client.buffer = Buffer.concat([client.buffer, chunk]);
397
- while (client.buffer.length >= FRAME_HEADER_BYTES) {
398
- const frameLength = client.buffer.readUInt32LE(0);
399
- if (frameLength > MAX_FRAME_BYTES) {
400
- client.socket.destroy();
401
- return;
402
- }
403
- if (client.buffer.length < FRAME_HEADER_BYTES + frameLength) {
404
- return;
405
- }
406
-
407
- const payload = client.buffer.slice(FRAME_HEADER_BYTES, FRAME_HEADER_BYTES + frameLength).toString("utf8");
408
- client.buffer = client.buffer.slice(FRAME_HEADER_BYTES + frameLength);
409
- const envelope = safeParseJSON(payload);
410
- if (envelope) {
411
- dispatchClientEnvelope(client, envelope);
412
- }
413
- }
414
- }
415
-
416
440
  function dispatchClientEnvelope(client, envelope) {
417
441
  if (envelope.type === "request" && envelope.method === "initialize") {
418
442
  initializeClient(client, envelope);
@@ -685,9 +709,10 @@ function createDesktopIpcRouterServer({
685
709
  server.close();
686
710
  server = null;
687
711
  }
688
- if (shouldRemoveSocketPath) {
689
- removeSocketPathAfterClose(socketPath);
712
+ if (shouldRemoveSocketPath && listeningSocketPath) {
713
+ removeSocketPathAfterClose(listeningSocketPath);
690
714
  }
715
+ listeningSocketPath = "";
691
716
  }
692
717
 
693
718
  function writeEnvelopeToClient(client, envelope) {
@@ -703,9 +728,25 @@ function createDesktopIpcRouterServer({
703
728
  }
704
729
  }
705
730
 
731
+ function hasBroadcastRecipientFor(senderClientId) {
732
+ const sender = clientsById.get(senderClientId);
733
+ if (!sender) {
734
+ // The owner is connected to an external Codex bus rather than this
735
+ // fallback router; that bus itself is the broadcast recipient.
736
+ return true;
737
+ }
738
+ return Array.from(clientsById.values()).some((client) => (
739
+ client !== sender
740
+ && client.initialized
741
+ && !client.socket.destroyed
742
+ && normalizeToken(client.type) !== "remodexbridge"
743
+ ));
744
+ }
745
+
706
746
  return {
707
747
  start,
708
748
  close,
749
+ hasBroadcastRecipientFor,
709
750
  get isStarted() {
710
751
  return started && !closed;
711
752
  },
@@ -716,11 +757,40 @@ function routedResponseKey(clientId, requestId) {
716
757
  return `${clientId}:${requestId}`;
717
758
  }
718
759
 
760
+ // Answers "is anyone actually serving this path right now", which is the only
761
+ // safe basis for replacing a socket file the bridge does not own.
762
+ function probeSocketIsListening(socketPath, netModule) {
763
+ return new Promise((resolve) => {
764
+ let probe = null;
765
+ const finish = (isListening) => {
766
+ clearTimeout(timeout);
767
+ if (probe) {
768
+ probe.removeAllListeners();
769
+ probe.destroy();
770
+ probe = null;
771
+ }
772
+ resolve(isListening);
773
+ };
774
+ const timeout = setTimeout(() => finish(false), SOCKET_PROBE_TIMEOUT_MS);
775
+ timeout.unref?.();
776
+
777
+ try {
778
+ probe = netModule.createConnection(socketPath);
779
+ } catch {
780
+ finish(false);
781
+ return;
782
+ }
783
+ probe.once("connect", () => finish(true));
784
+ probe.once("error", () => finish(false));
785
+ });
786
+ }
787
+
719
788
  function prepareSocketPathForListen(socketPath, { removeStaleSocket = false } = {}) {
720
789
  if (process.platform === "win32") {
721
790
  return;
722
791
  }
723
- fs.mkdirSync(path.dirname(socketPath), { recursive: true });
792
+ // Matches how Codex creates its own IPC directory: local user only.
793
+ fs.mkdirSync(path.dirname(socketPath), { recursive: true, mode: 0o700 });
724
794
  if (removeStaleSocket && fs.existsSync(socketPath)) {
725
795
  const socketStat = fs.lstatSync(socketPath);
726
796
  if (!socketStat.isSocket()) {