@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.
- package/bin/remodex.js +25 -2
- package/package.json +1 -1
- package/src/bridge.js +80 -61
- package/src/codex-desktop-refresher.js +173 -8
- package/src/codex-tool-wrapper.js +523 -0
- package/src/desktop-ipc-action-follower.js +412 -129
- package/src/desktop-ipc-live-owner.js +148 -6
- package/src/desktop-ipc-owner-transport.js +143 -73
- package/src/desktop-ipc-shared.js +273 -46
- 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 +282 -22
- package/src/rollout-watch.js +176 -54
- package/src/session-jsonl-history.js +77 -33
- 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,15 +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,
|
|
23
|
+
isThreadTurnStateProbeRequest,
|
|
22
24
|
normalizeToken,
|
|
23
25
|
readString,
|
|
24
26
|
requestIdKey,
|
|
25
27
|
resolveDefaultIpcSocketPath,
|
|
28
|
+
resolveIpcSocketPathCandidates,
|
|
26
29
|
safeParseJSON,
|
|
30
|
+
toSocketPathCandidatesResolver,
|
|
27
31
|
writeFrame,
|
|
28
32
|
} = require("./desktop-ipc-shared");
|
|
29
33
|
|
|
@@ -36,12 +40,13 @@ const MAX_BASELINE_RECOVERY_ATTEMPTS = 5;
|
|
|
36
40
|
const BASELINE_RECOVERY_BASE_DELAY_MS = 1_000;
|
|
37
41
|
const BASELINE_RECOVERY_MAX_DELAY_MS = 15_000;
|
|
38
42
|
const MAX_QUEUED_CHANGES_PER_THREAD = 300;
|
|
39
|
-
const BACKGROUND_DISCONNECT_GRACE_MS = 30_000;
|
|
40
43
|
// Phone interest survives per-thread release by design, so cap the set to keep a
|
|
41
44
|
// marathon single Desktop connection from accumulating every thread id forever.
|
|
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,13 +57,20 @@ 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
|
-
//
|
|
57
|
-
//
|
|
58
|
-
// often than this window; a silent "active" cache is a stale reconnect echo
|
|
59
|
-
// (e.g. Desktop never saw the turn finish) and must not answer phone reads, or
|
|
60
|
-
// the phone shows a phantom running indicator until real history loads.
|
|
67
|
+
// Per-thread activity can be quiet during tools and subagents, so the short
|
|
68
|
+
// freshness window alone does not revoke a healthy Desktop owner.
|
|
61
69
|
const STALE_ACTIVE_READ_MAX_AGE_MS = 20_000;
|
|
70
|
+
// An open Unix socket is not unlimited proof of a functioning publisher. If no
|
|
71
|
+
// frame arrives for this generous lease, yield stale active reads so canonical
|
|
72
|
+
// or rollout recovery can clear a phantom run.
|
|
73
|
+
const CONNECTED_IPC_ACTIVITY_LEASE_MS = 5 * 60_000;
|
|
62
74
|
const MAX_NORMALIZED_REVIEW_FINGERPRINTS_PER_THREAD = 128;
|
|
63
75
|
const DESKTOP_FOLLOWER_REQUEST_METHODS = new Set([
|
|
64
76
|
"turn/start",
|
|
@@ -220,16 +232,18 @@ function createDesktopIpcActionFollower({
|
|
|
220
232
|
normalizeTurnStartParams = (params) => params,
|
|
221
233
|
runtimeSettingsStore = null,
|
|
222
234
|
logPrefix = "[remodex]",
|
|
223
|
-
|
|
235
|
+
// Resolved per connect: Codex Desktop can start, stop, or move its bus while
|
|
236
|
+
// the bridge stays up.
|
|
237
|
+
socketPath = resolveIpcSocketPathCandidates,
|
|
224
238
|
netModule = net,
|
|
225
239
|
now = () => Date.now(),
|
|
226
240
|
snapshotDebounceMs = 0,
|
|
227
241
|
setTimeoutFn = setTimeout,
|
|
228
242
|
clearTimeoutFn = clearTimeout,
|
|
229
243
|
onNormalizedHistoryIndexRebuilt = () => {},
|
|
244
|
+
onFollowerStateChanged = null,
|
|
230
245
|
requestTimeoutMs = REQUEST_TIMEOUT_MS,
|
|
231
246
|
ownershipProbeTimeoutMs = OWNERSHIP_PROBE_TIMEOUT_MS,
|
|
232
|
-
backgroundDisconnectGraceMs = BACKGROUND_DISCONNECT_GRACE_MS,
|
|
233
247
|
} = {}) {
|
|
234
248
|
const ipc = createDesktopIpcClient({
|
|
235
249
|
socketPath,
|
|
@@ -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,7 +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();
|
|
271
|
-
const
|
|
288
|
+
const backgroundTurnReannouncedAtByThreadId = new Map();
|
|
272
289
|
// JS Set preserves insertion order; delete-before-add refreshes recency, and
|
|
273
290
|
// cap eviction skips threads with pending prompts so approvals are not lost.
|
|
274
291
|
function rememberActiveThread(threadId) {
|
|
@@ -284,6 +301,35 @@ function createDesktopIpcActionFollower({
|
|
|
284
301
|
}
|
|
285
302
|
}
|
|
286
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
|
+
|
|
287
333
|
function oldestEvictableActiveThreadId() {
|
|
288
334
|
for (const threadId of activeThreadIds) {
|
|
289
335
|
if (!hasPendingProjectedActions(threadId)
|
|
@@ -308,6 +354,7 @@ function createDesktopIpcActionFollower({
|
|
|
308
354
|
// without rejecting held requests (removeDesktopThreadState handles real removal).
|
|
309
355
|
function forgetEvictedThreadState(threadId) {
|
|
310
356
|
cancelPendingSnapshot(threadId);
|
|
357
|
+
unfollowDesktopThread(threadId);
|
|
311
358
|
settleAnnouncedBackgroundTurn(threadId, "interrupted");
|
|
312
359
|
backgroundOnlyThreadIds.delete(threadId);
|
|
313
360
|
rawStatesByThreadId.delete(threadId);
|
|
@@ -347,6 +394,7 @@ function createDesktopIpcActionFollower({
|
|
|
347
394
|
const method = readString(message?.method);
|
|
348
395
|
if (DESKTOP_BACKGROUND_DISCOVERY_METHODS.has(method)) {
|
|
349
396
|
ipc.ensureConnected();
|
|
397
|
+
reannounceActiveBackgroundTurns();
|
|
350
398
|
}
|
|
351
399
|
if (DESKTOP_STATE_READ_METHODS.has(method)) {
|
|
352
400
|
const threadId = readThreadId(message?.params);
|
|
@@ -364,8 +412,8 @@ function createDesktopIpcActionFollower({
|
|
|
364
412
|
} else {
|
|
365
413
|
settleAnnouncedBackgroundTurn(threadId, "interrupted");
|
|
366
414
|
}
|
|
367
|
-
clearBackgroundDisconnectTimer(threadId);
|
|
368
415
|
announcedBackgroundTurnsByThreadId.delete(threadId);
|
|
416
|
+
backgroundTurnReannouncedAtByThreadId.delete(threadId);
|
|
369
417
|
if (rawState) {
|
|
370
418
|
const liveState = boundedDesktopLiveStateForThread(threadId, rawState);
|
|
371
419
|
const hasCanonicalNormalizedHistory = canonicalHistoryThreadIds.has(threadId)
|
|
@@ -480,6 +528,7 @@ function createDesktopIpcActionFollower({
|
|
|
480
528
|
ownershipProbeDeadlinesByThreadId.set(threadId, now() + ownershipProbeTimeoutMs);
|
|
481
529
|
}
|
|
482
530
|
ipc.ensureConnected();
|
|
531
|
+
followDesktopThread(threadId);
|
|
483
532
|
return false;
|
|
484
533
|
}
|
|
485
534
|
|
|
@@ -497,13 +546,15 @@ function createDesktopIpcActionFollower({
|
|
|
497
546
|
normalizedReviewFingerprintsByThreadId.clear();
|
|
498
547
|
conversationProjector.reset();
|
|
499
548
|
pendingRoutesByRequestId.clear();
|
|
549
|
+
for (const threadId of desktopFollowThreadIds) {
|
|
550
|
+
unfollowDesktopThread(threadId);
|
|
551
|
+
}
|
|
552
|
+
desktopFollowThreadIds.clear();
|
|
500
553
|
activeThreadIds.clear();
|
|
554
|
+
followerClientIdsByThreadId.clear();
|
|
501
555
|
backgroundOnlyThreadIds.clear();
|
|
502
556
|
announcedBackgroundTurnsByThreadId.clear();
|
|
503
|
-
|
|
504
|
-
clearTimeout(timer);
|
|
505
|
-
}
|
|
506
|
-
backgroundDisconnectTimersByThreadId.clear();
|
|
557
|
+
backgroundTurnReannouncedAtByThreadId.clear();
|
|
507
558
|
recoveringThreadIds.clear();
|
|
508
559
|
baselineRecoveryStateByThreadId.clear();
|
|
509
560
|
queuedChangesByThreadId.clear();
|
|
@@ -522,6 +573,25 @@ function createDesktopIpcActionFollower({
|
|
|
522
573
|
|
|
523
574
|
// Desktop broadcasts carry the live conversation state Litter projects from.
|
|
524
575
|
function onEnvelope(envelope) {
|
|
576
|
+
if (envelope?.type === "broadcast" && envelope.method === CLIENT_STATUS_CHANGED) {
|
|
577
|
+
if (normalizeToken(envelope.params?.status) === "disconnected") {
|
|
578
|
+
removeFollowerClient(envelope.params?.clientId || envelope.sourceClientId);
|
|
579
|
+
}
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
if (envelope?.type === "broadcast" && envelope.method === THREAD_STREAM_FOLLOWING_CHANGED) {
|
|
583
|
+
updateFollowerState(envelope);
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
if (envelope?.type === "broadcast"
|
|
587
|
+
&& envelope.method === THREAD_STREAM_FOLLOWING_STATUS_REQUESTED) {
|
|
588
|
+
const params = envelope.params || {};
|
|
589
|
+
const threadId = readString(params.conversationId) || readString(params.conversation_id);
|
|
590
|
+
if (threadId && desktopFollowThreadIds.has(threadId)) {
|
|
591
|
+
followDesktopThread(threadId, [envelope.sourceClientId].filter(Boolean));
|
|
592
|
+
}
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
525
595
|
if (envelope?.type === "broadcast"
|
|
526
596
|
&& (envelope.method === "thread-archived" || envelope.method === "thread-unarchived")) {
|
|
527
597
|
syncThreadArchiveBroadcast(envelope);
|
|
@@ -546,9 +616,6 @@ function createDesktopIpcActionFollower({
|
|
|
546
616
|
if (!threadId) {
|
|
547
617
|
return;
|
|
548
618
|
}
|
|
549
|
-
if (isSnapshotChange(params.change)) {
|
|
550
|
-
clearBackgroundDisconnectTimer(threadId);
|
|
551
|
-
}
|
|
552
619
|
const peerOwnershipSnapshot = isPeerOwnershipSnapshot(params);
|
|
553
620
|
if (peerOwnershipSnapshot && !isLocallyOwnedThread(threadId)) {
|
|
554
621
|
liveOwnerThreadIds.delete(threadId);
|
|
@@ -569,6 +636,14 @@ function createDesktopIpcActionFollower({
|
|
|
569
636
|
backgroundOnlyThreadIds.add(threadId);
|
|
570
637
|
}
|
|
571
638
|
if (!activeThreadIds.has(threadId)) {
|
|
639
|
+
// After a bridge restart Desktop can re-announce that a renderer follows
|
|
640
|
+
// the thread without replaying its baseline snapshot. A request patch may
|
|
641
|
+
// then be the first state update we see. Preserve that blocking action
|
|
642
|
+
// immediately instead of waiting for a phone read that may never arrive
|
|
643
|
+
// while the already-open screen is parked on the question.
|
|
644
|
+
if (commitSpeculativeActionPatch(threadId, params.change, { backgroundOnly: true })) {
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
572
647
|
return;
|
|
573
648
|
}
|
|
574
649
|
|
|
@@ -590,21 +665,11 @@ function createDesktopIpcActionFollower({
|
|
|
590
665
|
const previousState = rawStatesByThreadId.get(threadId) || null;
|
|
591
666
|
const nextState = applyConversationStateChange(previousState, params.change);
|
|
592
667
|
if (!nextState) {
|
|
593
|
-
if (
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
const speculativeActions = projectPendingDesktopActions(threadId, speculativeState);
|
|
597
|
-
if (speculativeActions.length > 0) {
|
|
598
|
-
rawStatesByThreadId.set(threadId, speculativeState);
|
|
599
|
-
rawStateUpdatedAtByThreadId.set(threadId, now());
|
|
600
|
-
if (!backgroundOnlyThreadIds.has(threadId)) {
|
|
601
|
-
conversationProjector.seed(threadId, speculativeState);
|
|
602
|
-
}
|
|
603
|
-
syncProjectedActions(threadId, speculativeActions);
|
|
604
|
-
releaseHeldFollowerRequests(threadId, { toDesktop: true });
|
|
605
|
-
return;
|
|
606
|
-
}
|
|
668
|
+
if (commitSpeculativeActionPatch(threadId, params.change)) {
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
607
671
|
|
|
672
|
+
if (isPatchChange(params.change)) {
|
|
608
673
|
if (typeof readConversationState !== "function") {
|
|
609
674
|
return;
|
|
610
675
|
}
|
|
@@ -626,6 +691,31 @@ function createDesktopIpcActionFollower({
|
|
|
626
691
|
});
|
|
627
692
|
}
|
|
628
693
|
|
|
694
|
+
function commitSpeculativeActionPatch(threadId, change, { backgroundOnly = false } = {}) {
|
|
695
|
+
if (!isPatchChange(change)) {
|
|
696
|
+
return false;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
const speculativeState = applyConversationStateChange(createEmptyConversationState(), change);
|
|
700
|
+
const speculativeActions = projectPendingDesktopActions(threadId, speculativeState);
|
|
701
|
+
if (speculativeActions.length === 0) {
|
|
702
|
+
return false;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
if (backgroundOnly) {
|
|
706
|
+
rememberActiveThread(threadId);
|
|
707
|
+
backgroundOnlyThreadIds.add(threadId);
|
|
708
|
+
}
|
|
709
|
+
rawStatesByThreadId.set(threadId, speculativeState);
|
|
710
|
+
rawStateUpdatedAtByThreadId.set(threadId, now());
|
|
711
|
+
if (!backgroundOnlyThreadIds.has(threadId)) {
|
|
712
|
+
conversationProjector.seed(threadId, speculativeState);
|
|
713
|
+
}
|
|
714
|
+
syncProjectedActions(threadId, speculativeActions);
|
|
715
|
+
releaseHeldFollowerRequests(threadId, { toDesktop: true });
|
|
716
|
+
return true;
|
|
717
|
+
}
|
|
718
|
+
|
|
629
719
|
function schedulePendingSnapshot(threadId, state) {
|
|
630
720
|
cancelPendingSnapshot(threadId);
|
|
631
721
|
const timer = setTimeoutFn(() => {
|
|
@@ -744,9 +834,10 @@ function createDesktopIpcActionFollower({
|
|
|
744
834
|
queuedChangesByThreadId.clear();
|
|
745
835
|
pendingOwnershipProbeTokensByThreadId.clear();
|
|
746
836
|
desktopOwnedByProbeThreadIds.clear();
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
837
|
+
clearFollowerState();
|
|
838
|
+
// A lost IPC connection is not evidence that Desktop stopped the turn.
|
|
839
|
+
// Keep announced lifecycle state until a reconnect snapshot, archive, or
|
|
840
|
+
// another authoritative state transition supplies a real terminal status.
|
|
750
841
|
// Keep activeThreadIds: phone interest is phone-scoped, not connection-scoped.
|
|
751
842
|
// Clearing it here would make reconnect snapshots for a thread the phone is
|
|
752
843
|
// still viewing fail the activeThreadIds.has() guard until the phone happens
|
|
@@ -759,6 +850,67 @@ function createDesktopIpcActionFollower({
|
|
|
759
850
|
// a proven delivery failure falls back to the local app-server.
|
|
760
851
|
}
|
|
761
852
|
|
|
853
|
+
// The same route-follow handshake is used whether Remodex's app-server owns
|
|
854
|
+
// the thread or Codex Desktop owns it. The live-owner component covers the
|
|
855
|
+
// former; this follower covers Desktop-owned threads so the navigation
|
|
856
|
+
// controller can stop retrying as soon as the renderer is actually mounted.
|
|
857
|
+
function updateFollowerState(envelope) {
|
|
858
|
+
const params = envelope?.params || {};
|
|
859
|
+
const threadId = readString(params.conversationId) || readString(params.conversation_id);
|
|
860
|
+
const clientId = readString(envelope?.sourceClientId);
|
|
861
|
+
if (!threadId
|
|
862
|
+
|| !clientId
|
|
863
|
+
|| clientId === ipc.clientId
|
|
864
|
+
|| isLocallyOwnedThread(threadId)
|
|
865
|
+
|| liveOwnerThreadIds.has(threadId)) {
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
const followers = followerClientIdsByThreadId.get(threadId) || new Set();
|
|
870
|
+
if (params.following === true) {
|
|
871
|
+
rememberActiveThread(threadId);
|
|
872
|
+
followDesktopThread(threadId);
|
|
873
|
+
const wasUnfollowed = followers.size === 0;
|
|
874
|
+
followers.add(clientId);
|
|
875
|
+
followerClientIdsByThreadId.set(threadId, followers);
|
|
876
|
+
if (wasUnfollowed) {
|
|
877
|
+
onFollowerStateChanged?.(threadId, true);
|
|
878
|
+
}
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
if (!followers.delete(clientId)) {
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
if (followers.size === 0) {
|
|
886
|
+
followerClientIdsByThreadId.delete(threadId);
|
|
887
|
+
onFollowerStateChanged?.(threadId, false);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
function removeFollowerClient(clientId) {
|
|
892
|
+
const normalizedClientId = readString(clientId);
|
|
893
|
+
if (!normalizedClientId) {
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
for (const [threadId, followers] of followerClientIdsByThreadId) {
|
|
897
|
+
if (!followers.delete(normalizedClientId)) {
|
|
898
|
+
continue;
|
|
899
|
+
}
|
|
900
|
+
if (followers.size === 0) {
|
|
901
|
+
followerClientIdsByThreadId.delete(threadId);
|
|
902
|
+
onFollowerStateChanged?.(threadId, false);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function clearFollowerState() {
|
|
908
|
+
for (const threadId of followerClientIdsByThreadId.keys()) {
|
|
909
|
+
onFollowerStateChanged?.(threadId, false);
|
|
910
|
+
}
|
|
911
|
+
followerClientIdsByThreadId.clear();
|
|
912
|
+
}
|
|
913
|
+
|
|
762
914
|
// The bridge's own live owner just claimed this thread's stream, so drop stale
|
|
763
915
|
// Desktop state instead of hijacking future phone requests into Desktop IPC.
|
|
764
916
|
function releaseDesktopThreadState(threadId) {
|
|
@@ -768,6 +920,7 @@ function createDesktopIpcActionFollower({
|
|
|
768
920
|
activeThreadIds.delete(threadId);
|
|
769
921
|
}
|
|
770
922
|
liveOwnerThreadIds.add(threadId);
|
|
923
|
+
unfollowDesktopThread(threadId);
|
|
771
924
|
ownershipProbeDeadlinesByThreadId.delete(threadId);
|
|
772
925
|
pendingOwnershipProbeTokensByThreadId.delete(threadId);
|
|
773
926
|
desktopOwnedByProbeThreadIds.delete(threadId);
|
|
@@ -798,6 +951,7 @@ function createDesktopIpcActionFollower({
|
|
|
798
951
|
activeThreadIds.delete(threadId);
|
|
799
952
|
}
|
|
800
953
|
liveOwnerThreadIds.delete(threadId);
|
|
954
|
+
unfollowDesktopThread(threadId);
|
|
801
955
|
ownershipProbeDeadlinesByThreadId.delete(threadId);
|
|
802
956
|
pendingOwnershipProbeTokensByThreadId.delete(threadId);
|
|
803
957
|
desktopOwnedByProbeThreadIds.delete(threadId);
|
|
@@ -1012,23 +1166,30 @@ function createDesktopIpcActionFollower({
|
|
|
1012
1166
|
}
|
|
1013
1167
|
|
|
1014
1168
|
function syncProjectedActions(threadId, actions) {
|
|
1015
|
-
const nextRequestIds = new Set(actions.map((action) => action.id));
|
|
1169
|
+
const nextRequestIds = new Set(actions.map((action) => requestIdKey(action.id)));
|
|
1016
1170
|
for (const [requestId, route] of Array.from(pendingRoutesByRequestId.entries())) {
|
|
1017
1171
|
if (route.threadId !== threadId || nextRequestIds.has(requestId)) {
|
|
1018
1172
|
continue;
|
|
1019
1173
|
}
|
|
1020
1174
|
|
|
1021
1175
|
pendingRoutesByRequestId.delete(requestId);
|
|
1022
|
-
sendApplicationResponse(JSON.stringify(
|
|
1176
|
+
sendApplicationResponse(JSON.stringify(
|
|
1177
|
+
projectedResolvedNotification(threadId, route.desktopRequestId)
|
|
1178
|
+
));
|
|
1023
1179
|
}
|
|
1024
1180
|
|
|
1025
1181
|
for (const action of actions) {
|
|
1026
|
-
|
|
1182
|
+
const requestId = requestIdKey(action.id);
|
|
1183
|
+
if (!requestId || pendingRoutesByRequestId.has(requestId)) {
|
|
1027
1184
|
continue;
|
|
1028
1185
|
}
|
|
1029
1186
|
|
|
1030
|
-
pendingRoutesByRequestId.set(
|
|
1031
|
-
requestId
|
|
1187
|
+
pendingRoutesByRequestId.set(requestId, {
|
|
1188
|
+
requestId,
|
|
1189
|
+
// Preserve the JSON-RPC id's original scalar type. Codex Desktop uses
|
|
1190
|
+
// numeric ids for real requestUserInput calls, and its follower command
|
|
1191
|
+
// must receive that same number rather than the phone-facing map key.
|
|
1192
|
+
desktopRequestId: action.id,
|
|
1032
1193
|
method: action.method,
|
|
1033
1194
|
threadId,
|
|
1034
1195
|
});
|
|
@@ -1086,7 +1247,7 @@ function createDesktopIpcActionFollower({
|
|
|
1086
1247
|
canonicalHistoryThreadIds.add(threadId);
|
|
1087
1248
|
}
|
|
1088
1249
|
if (canonicalHistoryThreadIds.has(threadId)) {
|
|
1089
|
-
if (
|
|
1250
|
+
if (isThreadTurnStateProbeRequest(message)) {
|
|
1090
1251
|
const liveState = boundedDesktopLiveStateForThread(threadId, rawState);
|
|
1091
1252
|
sendApplicationResponse(JSON.stringify({
|
|
1092
1253
|
id: message.id,
|
|
@@ -1100,11 +1261,13 @@ function createDesktopIpcActionFollower({
|
|
|
1100
1261
|
return ownsDesktopCursor ? rejectDesktopTurnsCursor(message) : false;
|
|
1101
1262
|
}
|
|
1102
1263
|
const thread = projectDesktopConversationStateToThread(threadId, rawState, { now });
|
|
1103
|
-
//
|
|
1104
|
-
//
|
|
1105
|
-
//
|
|
1264
|
+
// While IPC remains connected, an explicitly active Desktop snapshot stays
|
|
1265
|
+
// authoritative through quiet tools, reasoning, approvals, and subagents.
|
|
1266
|
+
// Falling through to a second app-server here can replace a genuinely
|
|
1267
|
+
// running turn with stale idle history and make the phone clear Stop.
|
|
1106
1268
|
if (hasActiveProjectedTurn(thread)
|
|
1107
1269
|
&& isRawStateStaleForActiveRead(threadId)
|
|
1270
|
+
&& !hasResponsiveDesktopIpc()
|
|
1108
1271
|
&& !ownsDesktopCursor) {
|
|
1109
1272
|
staleYieldedThreadIds.add(threadId);
|
|
1110
1273
|
return false;
|
|
@@ -1139,21 +1302,6 @@ function createDesktopIpcActionFollower({
|
|
|
1139
1302
|
return true;
|
|
1140
1303
|
}
|
|
1141
1304
|
|
|
1142
|
-
function isDesktopLiveTurnStateSnapshotRequest(message) {
|
|
1143
|
-
if (readString(message?.method) !== "thread/turns/list"
|
|
1144
|
-
|| readString(message?.params?.cursor)
|
|
1145
|
-
|| message?.params?.remodexRequireCanonical === true) {
|
|
1146
|
-
return false;
|
|
1147
|
-
}
|
|
1148
|
-
if (message?.params?.remodexTurnStateOnly === true) {
|
|
1149
|
-
return true;
|
|
1150
|
-
}
|
|
1151
|
-
// Remodex iPhone 2.1 predates the explicit marker. Its running-state probe
|
|
1152
|
-
// has this unique request shape; actual history pages use limits 1 and 5.
|
|
1153
|
-
return Number(message?.params?.limit) === 8
|
|
1154
|
-
&& normalizeToken(readString(message?.params?.sortDirection) || "desc") === "desc";
|
|
1155
|
-
}
|
|
1156
|
-
|
|
1157
1305
|
function buildDesktopLiveTurnStateResult(turns) {
|
|
1158
1306
|
const data = (Array.isArray(turns) ? turns : [])
|
|
1159
1307
|
.slice()
|
|
@@ -1215,6 +1363,10 @@ function createDesktopIpcActionFollower({
|
|
|
1215
1363
|
return now() - updatedAt > STALE_ACTIVE_READ_MAX_AGE_MS;
|
|
1216
1364
|
}
|
|
1217
1365
|
|
|
1366
|
+
function hasResponsiveDesktopIpc() {
|
|
1367
|
+
return ipc.hasRecentActivity(CONNECTED_IPC_ACTIVITY_LEASE_MS);
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1218
1370
|
function boundedDesktopLiveStateForThread(threadId, state) {
|
|
1219
1371
|
return boundedDesktopLiveState(
|
|
1220
1372
|
state,
|
|
@@ -1242,6 +1394,8 @@ function createDesktopIpcActionFollower({
|
|
|
1242
1394
|
const previousById = desktopLiveLifecycleByThreadId.get(threadId) || new Map();
|
|
1243
1395
|
const nextTurns = activeDesktopTurnDescriptors(liveState);
|
|
1244
1396
|
const nextById = new Map(nextTurns.map((turn) => [turn.id, turn]));
|
|
1397
|
+
const completedTurnIds = new Set();
|
|
1398
|
+
const startedTurnIds = new Set();
|
|
1245
1399
|
const {
|
|
1246
1400
|
previousTurnIds: continuityPreviousTurnIds,
|
|
1247
1401
|
nextTurnIds: continuityNextTurnIds,
|
|
@@ -1266,6 +1420,7 @@ function createDesktopIpcActionFollower({
|
|
|
1266
1420
|
threadId,
|
|
1267
1421
|
{ id: previous.id, status: terminalStatus }
|
|
1268
1422
|
)));
|
|
1423
|
+
completedTurnIds.add(previous.id);
|
|
1269
1424
|
}
|
|
1270
1425
|
for (const next of nextTurns) {
|
|
1271
1426
|
if (previousById.has(next.id)) {
|
|
@@ -1281,7 +1436,52 @@ function createDesktopIpcActionFollower({
|
|
|
1281
1436
|
? notificationWithTurnIdentityContinuity(startedNotification)
|
|
1282
1437
|
: startedNotification
|
|
1283
1438
|
));
|
|
1439
|
+
startedTurnIds.add(next.id);
|
|
1284
1440
|
}
|
|
1441
|
+
reannounceRemainingParallelTurn(
|
|
1442
|
+
threadId,
|
|
1443
|
+
previousById,
|
|
1444
|
+
nextTurns,
|
|
1445
|
+
completedTurnIds,
|
|
1446
|
+
startedTurnIds
|
|
1447
|
+
);
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
// iOS tracks one phone-visible active turn per thread. If parallel turn B was
|
|
1451
|
+
// the most recently announced turn and finishes while older A is still live,
|
|
1452
|
+
// B's completion clears that slot. Re-announce A as continuity so Stop and
|
|
1453
|
+
// running state stay attached to the existing run without advancing its
|
|
1454
|
+
// generation. A newly started replacement already performs this handoff.
|
|
1455
|
+
function reannounceRemainingParallelTurn(
|
|
1456
|
+
threadId,
|
|
1457
|
+
previousById,
|
|
1458
|
+
nextTurns,
|
|
1459
|
+
completedTurnIds,
|
|
1460
|
+
startedTurnIds
|
|
1461
|
+
) {
|
|
1462
|
+
const restorationTurnId = remainingParallelTurnRestorationId(
|
|
1463
|
+
previousById,
|
|
1464
|
+
nextTurns,
|
|
1465
|
+
completedTurnIds
|
|
1466
|
+
);
|
|
1467
|
+
if (!restorationTurnId || startedTurnIds.has(restorationTurnId)) {
|
|
1468
|
+
return;
|
|
1469
|
+
}
|
|
1470
|
+
const nextVisibleTurn = nextTurns.find((turn) => turn.id === restorationTurnId);
|
|
1471
|
+
sendApplicationResponse(JSON.stringify(notificationWithTurnIdentityContinuity(
|
|
1472
|
+
desktopLiveTurnLifecycleNotification("turn/started", threadId, nextVisibleTurn)
|
|
1473
|
+
)));
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
function remainingParallelTurnRestorationId(previousById, nextTurns, completedTurnIds) {
|
|
1477
|
+
const previousVisibleTurn = [...previousById.values()].at(-1);
|
|
1478
|
+
if (!previousVisibleTurn || !completedTurnIds.has(previousVisibleTurn.id)) {
|
|
1479
|
+
return "";
|
|
1480
|
+
}
|
|
1481
|
+
const nextVisibleTurn = nextTurns.at(-1);
|
|
1482
|
+
return nextVisibleTurn && previousById.has(nextVisibleTurn.id)
|
|
1483
|
+
? nextVisibleTurn.id
|
|
1484
|
+
: "";
|
|
1285
1485
|
}
|
|
1286
1486
|
|
|
1287
1487
|
function syncProjectedConversationState(threadId, nextState, { isFullSnapshot = false } = {}) {
|
|
@@ -1298,10 +1498,13 @@ function createDesktopIpcActionFollower({
|
|
|
1298
1498
|
&& !canonicalHistoryReplacementSentThreadIds.has(threadId)) {
|
|
1299
1499
|
canonicalHistoryReplacementSentThreadIds.add(threadId);
|
|
1300
1500
|
conversationProjector.remove(threadId);
|
|
1301
|
-
//
|
|
1302
|
-
// history.
|
|
1303
|
-
//
|
|
1304
|
-
|
|
1501
|
+
// Project the bounded active tail before asking the phone for canonical
|
|
1502
|
+
// history. On reconnect this snapshot may contain output produced while
|
|
1503
|
+
// IPC was unavailable; silently seeding it would permanently eat that
|
|
1504
|
+
// content when canonical JSONL/rollout history is still behind.
|
|
1505
|
+
const bootstrapOutput = conversationProjector.project(threadId, liveState, {
|
|
1506
|
+
includeAllActiveTurns: true,
|
|
1507
|
+
});
|
|
1305
1508
|
sendApplicationResponse(JSON.stringify({
|
|
1306
1509
|
method: "thread/replaced",
|
|
1307
1510
|
params: {
|
|
@@ -1316,6 +1519,11 @@ function createDesktopIpcActionFollower({
|
|
|
1316
1519
|
normalizedLiveIndexesByThreadId.get(threadId)
|
|
1317
1520
|
);
|
|
1318
1521
|
emitDesktopSnapshotLifecycleTransition(threadId, liveState);
|
|
1522
|
+
for (const notification of bootstrapOutput.notifications || []) {
|
|
1523
|
+
if (readString(notification?.method).startsWith("item/")) {
|
|
1524
|
+
sendApplicationResponse(JSON.stringify(notification));
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1319
1527
|
rememberDesktopLiveProjection(threadId, liveState);
|
|
1320
1528
|
return;
|
|
1321
1529
|
}
|
|
@@ -1361,16 +1569,39 @@ function createDesktopIpcActionFollower({
|
|
|
1361
1569
|
},
|
|
1362
1570
|
}));
|
|
1363
1571
|
}
|
|
1364
|
-
|
|
1572
|
+
const outputNotifications = output.notifications || [];
|
|
1573
|
+
const previousById = desktopLiveLifecycleByThreadId.get(threadId) || new Map();
|
|
1574
|
+
const nextTurns = activeDesktopTurnDescriptors(liveState);
|
|
1575
|
+
const completedTurnIds = new Set(outputNotifications
|
|
1576
|
+
.filter((notification) => notification.method === "turn/completed")
|
|
1577
|
+
.map((notification) => readString(notification.params?.turnId))
|
|
1578
|
+
.filter(Boolean));
|
|
1579
|
+
const startedTurnIds = new Set(outputNotifications
|
|
1580
|
+
.filter((notification) => notification.method === "turn/started")
|
|
1581
|
+
.map((notification) => readString(notification.params?.turnId))
|
|
1582
|
+
.filter(Boolean));
|
|
1583
|
+
const parallelRestorationTurnId = remainingParallelTurnRestorationId(
|
|
1584
|
+
previousById,
|
|
1585
|
+
nextTurns,
|
|
1586
|
+
completedTurnIds
|
|
1587
|
+
);
|
|
1588
|
+
for (const notification of outputNotifications) {
|
|
1589
|
+
const notificationTurnId = readString(notification.params?.turnId);
|
|
1365
1590
|
const preservesTurnIdentity = notification.method === "turn/started"
|
|
1366
|
-
&&
|
|
1367
|
-
|
|
1368
|
-
);
|
|
1591
|
+
&& (parallelRestorationTurnId === notificationTurnId
|
|
1592
|
+
|| output.turnIdentityContinuityTurnIds?.includes(notificationTurnId));
|
|
1369
1593
|
const projectedNotification = preservesTurnIdentity
|
|
1370
1594
|
? notificationWithTurnIdentityContinuity(notification)
|
|
1371
1595
|
: notification;
|
|
1372
1596
|
sendApplicationResponse(JSON.stringify(projectedNotification));
|
|
1373
1597
|
}
|
|
1598
|
+
reannounceRemainingParallelTurn(
|
|
1599
|
+
threadId,
|
|
1600
|
+
previousById,
|
|
1601
|
+
nextTurns,
|
|
1602
|
+
completedTurnIds,
|
|
1603
|
+
startedTurnIds
|
|
1604
|
+
);
|
|
1374
1605
|
rememberDesktopLiveProjection(threadId, liveState);
|
|
1375
1606
|
}
|
|
1376
1607
|
|
|
@@ -1470,14 +1701,12 @@ function createDesktopIpcActionFollower({
|
|
|
1470
1701
|
nextActiveTurn
|
|
1471
1702
|
)));
|
|
1472
1703
|
announcedBackgroundTurnsByThreadId.set(threadId, nextActiveTurn);
|
|
1473
|
-
clearBackgroundDisconnectTimer(threadId);
|
|
1474
1704
|
}
|
|
1475
1705
|
}
|
|
1476
1706
|
|
|
1477
1707
|
function settleAnnouncedBackgroundTurn(threadId, status = "interrupted", turn = null) {
|
|
1478
1708
|
const announcedTurn = announcedBackgroundTurnsByThreadId.get(threadId);
|
|
1479
1709
|
if (!announcedTurn) {
|
|
1480
|
-
clearBackgroundDisconnectTimer(threadId);
|
|
1481
1710
|
return false;
|
|
1482
1711
|
}
|
|
1483
1712
|
const settledTurn = {
|
|
@@ -1492,34 +1721,34 @@ function createDesktopIpcActionFollower({
|
|
|
1492
1721
|
settledTurn
|
|
1493
1722
|
)));
|
|
1494
1723
|
announcedBackgroundTurnsByThreadId.delete(threadId);
|
|
1495
|
-
|
|
1724
|
+
backgroundTurnReannouncedAtByThreadId.delete(threadId);
|
|
1496
1725
|
return true;
|
|
1497
1726
|
}
|
|
1498
1727
|
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1728
|
+
// Re-sends the start of runs that are still in flight, so a phone that missed
|
|
1729
|
+
// the original announcement (fresh app session, reconnect after the run began)
|
|
1730
|
+
// gets its sidebar badge on the next sidebar refresh instead of only when the
|
|
1731
|
+
// chat is opened. The phone treats a repeated start for the same turn as a
|
|
1732
|
+
// no-op, and the identity marker keeps a phone that already saw it from
|
|
1733
|
+
// treating this as a second run. Only worth doing while Desktop IPC is
|
|
1734
|
+
// responsive: the matching completion arrives over the same link, so this
|
|
1735
|
+
// cannot keep a phantom badge alive on its own.
|
|
1736
|
+
function reannounceActiveBackgroundTurns() {
|
|
1737
|
+
if (announcedBackgroundTurnsByThreadId.size === 0 || !hasResponsiveDesktopIpc()) {
|
|
1502
1738
|
return;
|
|
1503
1739
|
}
|
|
1504
|
-
const expectedTurnId = announcedBackgroundTurnsByThreadId.get(threadId)?.id;
|
|
1505
|
-
const timer = setTimeout(() => {
|
|
1506
|
-
backgroundDisconnectTimersByThreadId.delete(threadId);
|
|
1507
|
-
if (announcedBackgroundTurnsByThreadId.get(threadId)?.id !== expectedTurnId) {
|
|
1508
|
-
return;
|
|
1509
|
-
}
|
|
1510
|
-
settleAnnouncedBackgroundTurn(threadId, "interrupted");
|
|
1511
|
-
}, Math.max(0, backgroundDisconnectGraceMs));
|
|
1512
|
-
timer.unref?.();
|
|
1513
|
-
backgroundDisconnectTimersByThreadId.set(threadId, timer);
|
|
1514
|
-
}
|
|
1515
1740
|
|
|
1516
|
-
|
|
1517
|
-
const
|
|
1518
|
-
|
|
1519
|
-
|
|
1741
|
+
const reannouncedAt = now();
|
|
1742
|
+
for (const [threadId, announcedTurn] of announcedBackgroundTurnsByThreadId) {
|
|
1743
|
+
const lastReannouncedAt = backgroundTurnReannouncedAtByThreadId.get(threadId) || 0;
|
|
1744
|
+
if (reannouncedAt - lastReannouncedAt < BACKGROUND_TURN_REANNOUNCE_MIN_INTERVAL_MS) {
|
|
1745
|
+
continue;
|
|
1746
|
+
}
|
|
1747
|
+
backgroundTurnReannouncedAtByThreadId.set(threadId, reannouncedAt);
|
|
1748
|
+
sendApplicationResponse(JSON.stringify(notificationWithTurnIdentityContinuity(
|
|
1749
|
+
backgroundTurnLifecycleNotification("turn/started", threadId, announcedTurn)
|
|
1750
|
+
)));
|
|
1520
1751
|
}
|
|
1521
|
-
clearTimeout(timer);
|
|
1522
|
-
backgroundDisconnectTimersByThreadId.delete(threadId);
|
|
1523
1752
|
}
|
|
1524
1753
|
|
|
1525
1754
|
function syncThreadArchiveBroadcast(envelope) {
|
|
@@ -1584,7 +1813,7 @@ function createDesktopIpcActionFollower({
|
|
|
1584
1813
|
.then(() => {
|
|
1585
1814
|
pendingRoutesByRequestId.delete(route.requestId);
|
|
1586
1815
|
sendApplicationResponse(JSON.stringify(
|
|
1587
|
-
projectedResolvedNotification(route.threadId, route.
|
|
1816
|
+
projectedResolvedNotification(route.threadId, route.desktopRequestId)
|
|
1588
1817
|
));
|
|
1589
1818
|
})
|
|
1590
1819
|
.catch((error) => {
|
|
@@ -1914,7 +2143,9 @@ function createDesktopIpcActionFollower({
|
|
|
1914
2143
|
return false;
|
|
1915
2144
|
}
|
|
1916
2145
|
const thread = projectDesktopConversationStateToThread(normalizedThreadId, liveState, { now });
|
|
1917
|
-
if (hasActiveProjectedTurn(thread)
|
|
2146
|
+
if (hasActiveProjectedTurn(thread)
|
|
2147
|
+
&& isRawStateStaleForActiveRead(normalizedThreadId)
|
|
2148
|
+
&& !hasResponsiveDesktopIpc()) {
|
|
1918
2149
|
staleYieldedThreadIds.add(normalizedThreadId);
|
|
1919
2150
|
return false;
|
|
1920
2151
|
}
|
|
@@ -1945,7 +2176,7 @@ function createDesktopIpcActionFollower({
|
|
|
1945
2176
|
if (hasActiveProjectedTurn(thread)) {
|
|
1946
2177
|
const updatedAt = rawStateUpdatedAtByThreadId.get(normalizedThreadId) || 0;
|
|
1947
2178
|
const hasNewerFallbackActivity = Number(fallbackActivityAt) > updatedAt;
|
|
1948
|
-
return
|
|
2179
|
+
return hasResponsiveDesktopIpc()
|
|
1949
2180
|
&& (!isRawStateStaleForActiveRead(normalizedThreadId) || !hasNewerFallbackActivity);
|
|
1950
2181
|
}
|
|
1951
2182
|
return !isRawStateStaleForActiveRead(normalizedThreadId);
|
|
@@ -1964,20 +2195,37 @@ function createDesktopIpcClient({
|
|
|
1964
2195
|
onConnected,
|
|
1965
2196
|
onDisconnect,
|
|
1966
2197
|
}) {
|
|
2198
|
+
const resolveSocketPaths = toSocketPathCandidatesResolver(socketPath);
|
|
1967
2199
|
let socket = null;
|
|
1968
2200
|
let clientId = "";
|
|
1969
2201
|
let isConnecting = false;
|
|
1970
|
-
let
|
|
2202
|
+
let lastActivityAt = 0;
|
|
2203
|
+
let remainingSocketPaths = [];
|
|
1971
2204
|
const pendingRequests = new Map();
|
|
1972
2205
|
const pendingDiscoveries = new Map();
|
|
2206
|
+
const frameReader = createFrameReader({
|
|
2207
|
+
onFrame: (envelope) => dispatchEnvelope(envelope),
|
|
2208
|
+
onOverflow: () => close(),
|
|
2209
|
+
});
|
|
1973
2210
|
|
|
1974
2211
|
function ensureConnected() {
|
|
1975
2212
|
if (socket || isConnecting) {
|
|
1976
2213
|
return;
|
|
1977
2214
|
}
|
|
1978
2215
|
|
|
2216
|
+
remainingSocketPaths = resolveSocketPaths();
|
|
2217
|
+
connectNextSocket();
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
function connectNextSocket() {
|
|
2221
|
+
const nextSocketPath = remainingSocketPaths.shift();
|
|
2222
|
+
if (!nextSocketPath) {
|
|
2223
|
+
isConnecting = false;
|
|
2224
|
+
return;
|
|
2225
|
+
}
|
|
2226
|
+
|
|
1979
2227
|
isConnecting = true;
|
|
1980
|
-
const nextSocket = netModule.createConnection(
|
|
2228
|
+
const nextSocket = netModule.createConnection(nextSocketPath);
|
|
1981
2229
|
socket = nextSocket;
|
|
1982
2230
|
|
|
1983
2231
|
nextSocket.on("connect", () => {
|
|
@@ -1993,14 +2241,29 @@ function createDesktopIpcClient({
|
|
|
1993
2241
|
});
|
|
1994
2242
|
});
|
|
1995
2243
|
nextSocket.on("data", handleData);
|
|
1996
|
-
nextSocket.on("close", handleClose);
|
|
2244
|
+
nextSocket.on("close", () => handleClose(nextSocket));
|
|
1997
2245
|
nextSocket.on("error", (error) => {
|
|
2246
|
+
if ((error?.code === "ENOENT" || error?.code === "ECONNREFUSED")
|
|
2247
|
+
&& remainingSocketPaths.length > 0) {
|
|
2248
|
+
retryNextSocket(nextSocket);
|
|
2249
|
+
return;
|
|
2250
|
+
}
|
|
1998
2251
|
if (error?.code !== "ENOENT" && error?.code !== "ECONNREFUSED") {
|
|
1999
2252
|
console.warn(`${logPrefix} desktop IPC connection failed: ${error.message}`);
|
|
2000
2253
|
}
|
|
2001
2254
|
});
|
|
2002
2255
|
}
|
|
2003
2256
|
|
|
2257
|
+
function retryNextSocket(failedSocket) {
|
|
2258
|
+
if (socket === failedSocket) {
|
|
2259
|
+
socket = null;
|
|
2260
|
+
}
|
|
2261
|
+
isConnecting = false;
|
|
2262
|
+
frameReader.reset();
|
|
2263
|
+
failedSocket.destroy();
|
|
2264
|
+
connectNextSocket();
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2004
2267
|
function sendRequest(method, params) {
|
|
2005
2268
|
ensureConnected();
|
|
2006
2269
|
if (!socket || socket.destroyed) {
|
|
@@ -2008,14 +2271,13 @@ function createDesktopIpcClient({
|
|
|
2008
2271
|
}
|
|
2009
2272
|
|
|
2010
2273
|
const requestId = `remodex-${now().toString(36)}-${Math.random().toString(16).slice(2)}`;
|
|
2011
|
-
const envelope = {
|
|
2012
|
-
type: "request",
|
|
2274
|
+
const envelope = buildIpcRequestEnvelope({
|
|
2013
2275
|
requestId,
|
|
2014
|
-
sourceClientId: method === "initialize" ? "initializing-client" : clientId || "remodex-bridge",
|
|
2015
|
-
version: METHOD_VERSION_BY_NAME.get(method) || 1,
|
|
2016
2276
|
method,
|
|
2017
|
-
params
|
|
2018
|
-
|
|
2277
|
+
params,
|
|
2278
|
+
clientId,
|
|
2279
|
+
initializing: method === "initialize",
|
|
2280
|
+
});
|
|
2019
2281
|
|
|
2020
2282
|
return new Promise((resolve, reject) => {
|
|
2021
2283
|
const timeout = setTimeout(() => {
|
|
@@ -2077,25 +2339,30 @@ function createDesktopIpcClient({
|
|
|
2077
2339
|
});
|
|
2078
2340
|
}
|
|
2079
2341
|
|
|
2080
|
-
function
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2342
|
+
function sendBroadcast(method, params, { targetClientIds } = {}) {
|
|
2343
|
+
ensureConnected();
|
|
2344
|
+
if (!socket || socket.destroyed || !clientId) {
|
|
2345
|
+
return false;
|
|
2346
|
+
}
|
|
2347
|
+
const envelope = {
|
|
2348
|
+
type: "broadcast",
|
|
2349
|
+
method,
|
|
2350
|
+
sourceClientId: clientId,
|
|
2351
|
+
params: params || {},
|
|
2352
|
+
version: METHOD_VERSION_BY_NAME.get(method) || 1,
|
|
2353
|
+
};
|
|
2354
|
+
if (Array.isArray(targetClientIds) && targetClientIds.length > 0) {
|
|
2355
|
+
envelope.targetClientIds = targetClientIds;
|
|
2356
|
+
}
|
|
2357
|
+
writeEnvelope(envelope);
|
|
2358
|
+
return true;
|
|
2359
|
+
}
|
|
2091
2360
|
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
if (envelope) {
|
|
2096
|
-
dispatchEnvelope(envelope);
|
|
2097
|
-
}
|
|
2361
|
+
function handleData(chunk) {
|
|
2362
|
+
if (chunk.length > 0) {
|
|
2363
|
+
lastActivityAt = now();
|
|
2098
2364
|
}
|
|
2365
|
+
frameReader.push(chunk);
|
|
2099
2366
|
}
|
|
2100
2367
|
|
|
2101
2368
|
function dispatchEnvelope(envelope) {
|
|
@@ -2149,11 +2416,15 @@ function createDesktopIpcClient({
|
|
|
2149
2416
|
onEnvelope(envelope);
|
|
2150
2417
|
}
|
|
2151
2418
|
|
|
2152
|
-
function handleClose() {
|
|
2419
|
+
function handleClose(closedSocket) {
|
|
2420
|
+
if (socket && socket !== closedSocket) {
|
|
2421
|
+
return;
|
|
2422
|
+
}
|
|
2153
2423
|
socket = null;
|
|
2154
2424
|
clientId = "";
|
|
2155
2425
|
isConnecting = false;
|
|
2156
|
-
|
|
2426
|
+
lastActivityAt = 0;
|
|
2427
|
+
frameReader.reset();
|
|
2157
2428
|
for (const waiter of pendingRequests.values()) {
|
|
2158
2429
|
clearTimeout(waiter.timeout);
|
|
2159
2430
|
waiter.reject(new Error("Desktop IPC connection closed."));
|
|
@@ -2187,12 +2458,21 @@ function createDesktopIpcClient({
|
|
|
2187
2458
|
}
|
|
2188
2459
|
|
|
2189
2460
|
return {
|
|
2461
|
+
get clientId() {
|
|
2462
|
+
return clientId;
|
|
2463
|
+
},
|
|
2190
2464
|
ensureConnected,
|
|
2191
2465
|
isConnected() {
|
|
2192
2466
|
return Boolean(socket && !socket.destroyed && clientId);
|
|
2193
2467
|
},
|
|
2468
|
+
hasRecentActivity(maxAgeMs) {
|
|
2469
|
+
return Boolean(socket && !socket.destroyed && clientId)
|
|
2470
|
+
&& lastActivityAt > 0
|
|
2471
|
+
&& now() - lastActivityAt <= Math.max(0, Number(maxAgeMs) || 0);
|
|
2472
|
+
},
|
|
2194
2473
|
sendRequest,
|
|
2195
2474
|
sendDiscoveryRequest,
|
|
2475
|
+
sendBroadcast,
|
|
2196
2476
|
close,
|
|
2197
2477
|
};
|
|
2198
2478
|
}
|
|
@@ -2213,7 +2493,7 @@ function desktopFollowerPayloadForResponse(route, responseMessage) {
|
|
|
2213
2493
|
method,
|
|
2214
2494
|
params: {
|
|
2215
2495
|
conversationId: route.threadId,
|
|
2216
|
-
requestId: route.requestId,
|
|
2496
|
+
requestId: route.desktopRequestId ?? route.requestId,
|
|
2217
2497
|
response: {
|
|
2218
2498
|
answers,
|
|
2219
2499
|
},
|
|
@@ -2391,7 +2671,9 @@ function projectPendingDesktopAction(threadId, request) {
|
|
|
2391
2671
|
}
|
|
2392
2672
|
|
|
2393
2673
|
return {
|
|
2394
|
-
|
|
2674
|
+
// Keep the original JSON-RPC scalar for the relay request and the Desktop
|
|
2675
|
+
// follower reply. requestIdKey is only an internal map/deduplication key.
|
|
2676
|
+
id: request.id,
|
|
2395
2677
|
method,
|
|
2396
2678
|
params: {
|
|
2397
2679
|
...params,
|
|
@@ -2717,7 +2999,8 @@ function boundedDesktopLiveTurns(
|
|
|
2717
2999
|
if (orderedTurns.length <= 1) {
|
|
2718
3000
|
return normalizeBoundedTurnsForRuntime(
|
|
2719
3001
|
orderedTurns.map((turn, index) => withStableProjectedTurnId(turn, index)),
|
|
2720
|
-
state
|
|
3002
|
+
state,
|
|
3003
|
+
retainedTurnIds
|
|
2721
3004
|
);
|
|
2722
3005
|
}
|
|
2723
3006
|
|
|
@@ -2744,7 +3027,7 @@ function boundedDesktopLiveTurns(
|
|
|
2744
3027
|
const selectedTurns = Array.from(selectedIndexes)
|
|
2745
3028
|
.sort((left, right) => left - right)
|
|
2746
3029
|
.map((index) => withStableProjectedTurnId(orderedTurns[index], index));
|
|
2747
|
-
return normalizeBoundedTurnsForRuntime(selectedTurns, state);
|
|
3030
|
+
return normalizeBoundedTurnsForRuntime(selectedTurns, state, retainedTurnIds);
|
|
2748
3031
|
}
|
|
2749
3032
|
|
|
2750
3033
|
function boundedIndexedDesktopLiveTurns(state, index, nowValue, retainedTurnIds) {
|
|
@@ -2791,7 +3074,7 @@ function boundedIndexedDesktopLiveTurns(state, index, nowValue, retainedTurnIds)
|
|
|
2791
3074
|
return turnIdOf(turn) ? turn : { ...turn, id: entry.id };
|
|
2792
3075
|
})
|
|
2793
3076
|
.filter(Boolean);
|
|
2794
|
-
return normalizeBoundedTurnsForRuntime(selectedTurns, state);
|
|
3077
|
+
return normalizeBoundedTurnsForRuntime(selectedTurns, state, retainedTurnIds);
|
|
2795
3078
|
}
|
|
2796
3079
|
|
|
2797
3080
|
function resolveIndexedTurn(state, entry) {
|
|
@@ -2868,12 +3151,12 @@ function notificationWithTurnIdentityContinuity(notification) {
|
|
|
2868
3151
|
};
|
|
2869
3152
|
}
|
|
2870
3153
|
|
|
2871
|
-
function normalizeBoundedTurnsForRuntime(turns, state) {
|
|
3154
|
+
function normalizeBoundedTurnsForRuntime(turns, state, retainedTurnIds = new Set()) {
|
|
2872
3155
|
if (!isExplicitlyIdleDesktopRuntime(state)) {
|
|
2873
3156
|
return turns;
|
|
2874
3157
|
}
|
|
2875
3158
|
return turns.map((turn) => (
|
|
2876
|
-
isActiveRawTurn(turn)
|
|
3159
|
+
isActiveRawTurn(turn) && !retainedTurnIds.has(turnIdOf(turn))
|
|
2877
3160
|
? { ...turn, status: "completed" }
|
|
2878
3161
|
: turn
|
|
2879
3162
|
));
|