@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
|
@@ -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
|
-
|
|
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
|
-
|
|
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 (
|
|
585
|
-
|
|
586
|
-
|
|
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(
|
|
1177
|
+
sendApplicationResponse(JSON.stringify(
|
|
1178
|
+
projectedResolvedNotification(threadId, route.desktopRequestId)
|
|
1179
|
+
));
|
|
1014
1180
|
}
|
|
1015
1181
|
|
|
1016
1182
|
for (const action of actions) {
|
|
1017
|
-
|
|
1183
|
+
const requestId = requestIdKey(action.id);
|
|
1184
|
+
if (!requestId || pendingRoutesByRequestId.has(requestId)) {
|
|
1018
1185
|
continue;
|
|
1019
1186
|
}
|
|
1020
1187
|
|
|
1021
|
-
pendingRoutesByRequestId.set(
|
|
1022
|
-
requestId
|
|
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.
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|