@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.
- package/bin/remodex.js +25 -2
- package/package.json +1 -1
- package/src/bridge.js +46 -61
- package/src/codex-desktop-refresher.js +173 -8
- package/src/codex-tool-wrapper.js +523 -0
- package/src/desktop-ipc-action-follower.js +316 -59
- package/src/desktop-ipc-live-owner.js +148 -6
- package/src/desktop-ipc-owner-transport.js +143 -73
- package/src/desktop-ipc-shared.js +125 -8
- package/src/git-handler.js +189 -108
- package/src/index.js +2 -0
- package/src/macos-launch-agent.js +65 -34
- package/src/rollout-live-mirror.js +142 -13
- package/src/rollout-watch.js +176 -54
- package/src/session-jsonl-history.js +72 -32
- package/src/thread-list-provenance.js +105 -0
- package/src/thread-row-enrichment.js +43 -0
- package/src/thread-runtime-settings-store.js +3 -21
- package/src/worktree-origin.js +192 -0
|
@@ -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
|
-
|
|
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.
|
|
730
|
-
//
|
|
731
|
-
// it
|
|
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(
|
|
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(
|
|
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
|
-
|
|
15
|
-
|
|
14
|
+
buildIpcRequestEnvelope,
|
|
15
|
+
createFrameReader,
|
|
16
16
|
normalizeToken,
|
|
17
17
|
readString,
|
|
18
18
|
requestIdKey,
|
|
19
|
-
|
|
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(
|
|
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",
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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()) {
|