@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
package/bin/remodex.js
CHANGED
|
@@ -16,6 +16,7 @@ const {
|
|
|
16
16
|
startBridge,
|
|
17
17
|
startMacOSBridgeService,
|
|
18
18
|
stopMacOSBridgeService,
|
|
19
|
+
uninstallMacOSBridgeService,
|
|
19
20
|
resetBridgePairing,
|
|
20
21
|
openLastActiveThread,
|
|
21
22
|
watchThreadRollout,
|
|
@@ -33,6 +34,7 @@ const defaultDeps = {
|
|
|
33
34
|
startBridge,
|
|
34
35
|
startMacOSBridgeService,
|
|
35
36
|
stopMacOSBridgeService,
|
|
37
|
+
uninstallMacOSBridgeService,
|
|
36
38
|
resetBridgePairing,
|
|
37
39
|
openLastActiveThread,
|
|
38
40
|
watchThreadRollout,
|
|
@@ -195,6 +197,27 @@ async function main({
|
|
|
195
197
|
return;
|
|
196
198
|
}
|
|
197
199
|
|
|
200
|
+
if (command === "uninstall-service") {
|
|
201
|
+
assertMacOSCommand(command, {
|
|
202
|
+
platform,
|
|
203
|
+
consoleImpl,
|
|
204
|
+
exitImpl,
|
|
205
|
+
});
|
|
206
|
+
const result = deps.uninstallMacOSBridgeService();
|
|
207
|
+
emitResult({
|
|
208
|
+
payload: {
|
|
209
|
+
ok: true,
|
|
210
|
+
currentVersion: version,
|
|
211
|
+
plistPath: result?.plistPath,
|
|
212
|
+
removed: result?.removed,
|
|
213
|
+
},
|
|
214
|
+
message: "[remodex] Removed the macOS bridge service. You can now run `npm uninstall -g @makerbi/remodex`.",
|
|
215
|
+
jsonOutput,
|
|
216
|
+
consoleImpl,
|
|
217
|
+
});
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
198
221
|
if (command === "status") {
|
|
199
222
|
assertMacOSCommand(command, {
|
|
200
223
|
platform,
|
|
@@ -280,9 +303,9 @@ async function main({
|
|
|
280
303
|
consoleImpl.error(`Unknown command: ${command}`);
|
|
281
304
|
consoleImpl.error(
|
|
282
305
|
"Usage: remodex up | remodex run [--extra-device|--extra-devices=N] | remodex start | remodex restart | "
|
|
283
|
-
+ "remodex qr | remodex pair | remodex stop | remodex status | "
|
|
306
|
+
+ "remodex qr | remodex pair | remodex stop | remodex uninstall-service | remodex status | "
|
|
284
307
|
+ "remodex reset-pairing | remodex resume | remodex watch [threadId] | remodex --version | "
|
|
285
|
-
+ "append --json to start/restart/qr/pair/stop/status/reset-pairing/resume for machine-readable output"
|
|
308
|
+
+ "append --json to start/restart/qr/pair/stop/uninstall-service/status/reset-pairing/resume for machine-readable output"
|
|
286
309
|
);
|
|
287
310
|
exitImpl(1);
|
|
288
311
|
}
|
package/package.json
CHANGED
package/src/bridge.js
CHANGED
|
@@ -54,7 +54,9 @@ const {
|
|
|
54
54
|
const { createBridgeSecureTransport } = require("./secure-transport");
|
|
55
55
|
const { createRolloutLiveMirrorController } = require("./rollout-live-mirror");
|
|
56
56
|
const {
|
|
57
|
+
buildCompleteThreadReadParams,
|
|
57
58
|
isContextualUserText,
|
|
59
|
+
isThreadTurnStateProbeRequest,
|
|
58
60
|
isUserRoleItem,
|
|
59
61
|
readUserItemText,
|
|
60
62
|
sanitizeUserRoleItem,
|
|
@@ -66,6 +68,9 @@ const {
|
|
|
66
68
|
} = require("./desktop-ipc-action-follower");
|
|
67
69
|
const { createDesktopIpcLiveOwner } = require("./desktop-ipc-live-owner");
|
|
68
70
|
const { createThreadRuntimeSettingsStore } = require("./thread-runtime-settings-store");
|
|
71
|
+
const { createThreadListProvenanceEnricher } = require("./thread-list-provenance");
|
|
72
|
+
const { createWorktreeOriginEnricher } = require("./worktree-origin");
|
|
73
|
+
const { forEachThreadRowInResponse } = require("./thread-row-enrichment");
|
|
69
74
|
const { version: bridgePackageVersion = "" } = require("../package.json");
|
|
70
75
|
const {
|
|
71
76
|
MINIMUM_SUPPORTED_IOS_APP_VERSION,
|
|
@@ -120,7 +125,7 @@ const RELAY_JSONL_FULL_ARTIFACT_FALLBACK_MAX_BYTES = Math.max(
|
|
|
120
125
|
0,
|
|
121
126
|
bufferConstants.MAX_STRING_LENGTH - (8 * 1024 * 1024)
|
|
122
127
|
);
|
|
123
|
-
const BRIDGE_PACKAGE_UPDATE_COMMAND = "npm install -g remodex@latest";
|
|
128
|
+
const BRIDGE_PACKAGE_UPDATE_COMMAND = "npm install -g @makerbi/remodex@latest";
|
|
124
129
|
const BRIDGE_PACKAGE_UPDATE_TIMEOUT_MS = 180_000;
|
|
125
130
|
const BRIDGE_RESTART_AFTER_UPDATE_DELAY_MS = 750;
|
|
126
131
|
const MODELS_WITHOUT_REASONING_SUMMARY = new Set([
|
|
@@ -476,6 +481,27 @@ function createThreadTurnsListFastPageCoordinator({
|
|
|
476
481
|
canonicalRequest,
|
|
477
482
|
fetchCanonical
|
|
478
483
|
);
|
|
484
|
+
let timeoutId = null;
|
|
485
|
+
const deadline = new Promise((resolveDeadline) => {
|
|
486
|
+
timeoutId = setTimeoutImpl(() => resolveDeadline({ deadline: true }), waitMs);
|
|
487
|
+
});
|
|
488
|
+
const first = await Promise.race([canonicalOutcomePromise, deadline]);
|
|
489
|
+
if (timeoutId != null) {
|
|
490
|
+
clearTimeoutImpl(timeoutId);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (first?.ok && !isEmptyTurnsListResponse(first.response)) {
|
|
494
|
+
forgetCanonicalFirstPage(canonicalFirstPageCacheKey, canonicalOutcomePromise);
|
|
495
|
+
return {
|
|
496
|
+
source: "canonical",
|
|
497
|
+
response: rebindThreadTurnsListResponseId(first.response, request.id),
|
|
498
|
+
usesJsonl: false,
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Deadline hit, canonical error, or an empty canonical page: only now pay
|
|
503
|
+
// for the synchronous rollout read, so a fast canonical response never
|
|
504
|
+
// blocks behind it and never delays itself.
|
|
479
505
|
let jsonlFallback = null;
|
|
480
506
|
try {
|
|
481
507
|
jsonlFallback = await readJsonl(request);
|
|
@@ -500,38 +526,6 @@ function createThreadTurnsListFastPageCoordinator({
|
|
|
500
526
|
};
|
|
501
527
|
}
|
|
502
528
|
|
|
503
|
-
let timeoutId = null;
|
|
504
|
-
const deadline = new Promise((resolveDeadline) => {
|
|
505
|
-
timeoutId = setTimeoutImpl(() => resolveDeadline({ deadline: true }), waitMs);
|
|
506
|
-
});
|
|
507
|
-
const first = await Promise.race([canonicalOutcomePromise, deadline]);
|
|
508
|
-
if (timeoutId != null) {
|
|
509
|
-
clearTimeoutImpl(timeoutId);
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
if (first?.ok && !isEmptyTurnsListResponse(first.response)) {
|
|
513
|
-
if (shouldPreferJsonlFirstPage(first.response, jsonlFallback.response)) {
|
|
514
|
-
const token = rememberHandoff(threadId, canonicalOutcomePromise, jsonlFallback);
|
|
515
|
-
return {
|
|
516
|
-
source: "jsonl",
|
|
517
|
-
response: buildJsonlCanonicalHandoffResponse(
|
|
518
|
-
jsonlFallback.response,
|
|
519
|
-
request.id,
|
|
520
|
-
token,
|
|
521
|
-
firstTurnsListTurnId(jsonlFallback.response)
|
|
522
|
-
),
|
|
523
|
-
usesJsonl: true,
|
|
524
|
-
};
|
|
525
|
-
}
|
|
526
|
-
forgetCanonicalFirstPage(canonicalFirstPageCacheKey, canonicalOutcomePromise);
|
|
527
|
-
return {
|
|
528
|
-
source: "canonical",
|
|
529
|
-
response: rebindThreadTurnsListResponseId(first.response, request.id),
|
|
530
|
-
usesJsonl: false,
|
|
531
|
-
jsonlFallback,
|
|
532
|
-
};
|
|
533
|
-
}
|
|
534
|
-
|
|
535
529
|
const token = rememberHandoff(threadId, canonicalOutcomePromise, jsonlFallback);
|
|
536
530
|
return {
|
|
537
531
|
source: "jsonl",
|
|
@@ -577,6 +571,33 @@ function threadTurnsListHandoffDescriptor(cursor) {
|
|
|
577
571
|
return { anchorTurnId, token };
|
|
578
572
|
}
|
|
579
573
|
|
|
574
|
+
// The bounded canonical page can read a busy mirrored run as closed for a
|
|
575
|
+
// beat, which used to flap the phone's running state. When the rollout mirror
|
|
576
|
+
// is actively tailing a real turn, ride its id along on the turn-state probe
|
|
577
|
+
// as an advisory field; history pages stay untouched.
|
|
578
|
+
function annotateTurnStateProbeWithMirrorActiveTurn(request, response, getMirrorActiveTurnId) {
|
|
579
|
+
if (!isThreadTurnStateProbeRequest(request)) {
|
|
580
|
+
return response;
|
|
581
|
+
}
|
|
582
|
+
const params = request?.params || {};
|
|
583
|
+
const threadId = normalizeNonEmptyString(params.threadId)
|
|
584
|
+
|| normalizeNonEmptyString(params.thread_id);
|
|
585
|
+
const mirrorActiveTurnId = threadId ? getMirrorActiveTurnId?.(threadId) : null;
|
|
586
|
+
const result = response?.result;
|
|
587
|
+
if (!mirrorActiveTurnId || !result || typeof result !== "object" || Array.isArray(result)) {
|
|
588
|
+
return response;
|
|
589
|
+
}
|
|
590
|
+
// Page responses can come from the fast-page cache: never mutate a shared
|
|
591
|
+
// object, or the annotation would outlive the mirror on later replays.
|
|
592
|
+
return {
|
|
593
|
+
...response,
|
|
594
|
+
result: {
|
|
595
|
+
...result,
|
|
596
|
+
remodexMirrorActiveTurnId: mirrorActiveTurnId,
|
|
597
|
+
},
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
|
|
580
601
|
function canonicalThreadTurnsListRequest(request) {
|
|
581
602
|
const params = { ...(request?.params || {}) };
|
|
582
603
|
delete params.remodexRequireCanonical;
|
|
@@ -632,21 +653,6 @@ function buildJsonlCanonicalHandoffResponse(response, requestId, token, anchorTu
|
|
|
632
653
|
};
|
|
633
654
|
}
|
|
634
655
|
|
|
635
|
-
function shouldPreferJsonlFirstPage(canonicalResponse, jsonlResponse) {
|
|
636
|
-
const canonicalResult = canonicalResponse?.result;
|
|
637
|
-
const jsonlResult = jsonlResponse?.result;
|
|
638
|
-
const canonicalTurnsKey = findTurnsListResultKey(canonicalResult);
|
|
639
|
-
const jsonlTurnsKey = findTurnsListResultKey(jsonlResult);
|
|
640
|
-
if (!canonicalTurnsKey || !jsonlTurnsKey) {
|
|
641
|
-
return false;
|
|
642
|
-
}
|
|
643
|
-
const jsonlTurn = jsonlResult[jsonlTurnsKey]?.[0];
|
|
644
|
-
const jsonlTurnId = turnListTurnIdentifier(jsonlTurn);
|
|
645
|
-
return Boolean(jsonlTurnId)
|
|
646
|
-
&& !canonicalResult[canonicalTurnsKey].some((turn) => turnListTurnIdentifier(turn) === jsonlTurnId)
|
|
647
|
-
&& shouldMergeLatestJsonlTurn(jsonlTurn);
|
|
648
|
-
}
|
|
649
|
-
|
|
650
656
|
function firstTurnsListTurnId(response) {
|
|
651
657
|
const result = response?.result;
|
|
652
658
|
const turnsKey = findTurnsListResultKey(result);
|
|
@@ -755,7 +761,10 @@ function startBridge({
|
|
|
755
761
|
const relaySessionUrl = `${relayBaseUrl}/${sessionId}`;
|
|
756
762
|
const notificationSecret = randomBytes(24).toString("hex");
|
|
757
763
|
const desktopRefresher = new CodexDesktopRefresher({
|
|
758
|
-
|
|
764
|
+
// IPC snapshots are accepted only after Codex mounts the route and
|
|
765
|
+
// announces itself as a follower. Auto-follow performs that one-time
|
|
766
|
+
// activation; refreshEnabled still controls the legacy reload workaround.
|
|
767
|
+
enabled: config.refreshEnabled || config.desktopAutoFollowEnabled === true,
|
|
759
768
|
// With IPC live sync streaming content, deep-link refreshes are only needed
|
|
760
769
|
// to navigate Desktop onto the phone-driven thread, not to reload content.
|
|
761
770
|
navigationOnly: config.desktopIpcLiveSyncEnabled,
|
|
@@ -798,6 +807,8 @@ function startBridge({
|
|
|
798
807
|
const jsonlTurnsListRolloutMissCacheByThread = new Map();
|
|
799
808
|
const threadTurnsListFastPageCoordinator = createThreadTurnsListFastPageCoordinator();
|
|
800
809
|
const threadRuntimeSettingsStore = createThreadRuntimeSettingsStore();
|
|
810
|
+
const threadListProvenanceEnricher = createThreadListProvenanceEnricher();
|
|
811
|
+
const worktreeOriginEnricher = createWorktreeOriginEnricher();
|
|
801
812
|
const trackedForwardedRequestMethods = new Set([
|
|
802
813
|
"account/login/start",
|
|
803
814
|
"account/login/cancel",
|
|
@@ -863,7 +874,7 @@ function startBridge({
|
|
|
863
874
|
? createDesktopIpcActionFollower({
|
|
864
875
|
sendApplicationResponse,
|
|
865
876
|
readConversationState: async (threadId) => seedConversationStateFromThreadRead(
|
|
866
|
-
await sendCodexRequest("thread/read",
|
|
877
|
+
await sendCodexRequest("thread/read", buildCompleteThreadReadParams(threadId))
|
|
867
878
|
),
|
|
868
879
|
forwardToLocalCodex: (rawMessage) => {
|
|
869
880
|
observeDesktopIpcLiveOwnerInbound(rawMessage);
|
|
@@ -876,6 +887,9 @@ function startBridge({
|
|
|
876
887
|
runtimeSettingsStore: threadRuntimeSettingsStore,
|
|
877
888
|
socketPath: config.desktopIpcSocketPath || undefined,
|
|
878
889
|
snapshotDebounceMs: config.desktopIpcSnapshotDebounceMs,
|
|
890
|
+
onFollowerStateChanged(threadId, following) {
|
|
891
|
+
desktopRefresher.handleFollowerStateChanged(threadId, following);
|
|
892
|
+
},
|
|
879
893
|
})
|
|
880
894
|
: null;
|
|
881
895
|
const desktopIpcLiveOwner = !config.codexEndpoint
|
|
@@ -888,6 +902,9 @@ function startBridge({
|
|
|
888
902
|
runtimeSettingsStore: threadRuntimeSettingsStore,
|
|
889
903
|
socketPath: config.desktopIpcSocketPath || undefined,
|
|
890
904
|
snapshotDebounceMs: config.desktopIpcSnapshotDebounceMs,
|
|
905
|
+
onFollowerStateChanged(threadId, following) {
|
|
906
|
+
desktopRefresher.handleFollowerStateChanged(threadId, following);
|
|
907
|
+
},
|
|
891
908
|
})
|
|
892
909
|
: null;
|
|
893
910
|
let contextUsageWatcher = null;
|
|
@@ -1152,7 +1169,6 @@ function startBridge({
|
|
|
1152
1169
|
stopContextUsageWatcher();
|
|
1153
1170
|
// Relay reconnects are transport-only: keep local live observers running
|
|
1154
1171
|
// so their output can enter secure replay and catch up on the next resume.
|
|
1155
|
-
desktopRefresher.handleTransportReset();
|
|
1156
1172
|
scheduleRelayReconnect(code);
|
|
1157
1173
|
});
|
|
1158
1174
|
|
|
@@ -1400,15 +1416,7 @@ function startBridge({
|
|
|
1400
1416
|
}),
|
|
1401
1417
|
readJsonl: (jsonlRequest) => maybeBuildJsonlThreadTurnsListFallback(jsonlRequest, null),
|
|
1402
1418
|
});
|
|
1403
|
-
|
|
1404
|
-
if (selection.source === "canonical" && selection.jsonlFallback?.response?.result) {
|
|
1405
|
-
responsePayload = maybeMergeLatestJsonlTurnIntoTurnsListResponse(
|
|
1406
|
-
request,
|
|
1407
|
-
selection.response,
|
|
1408
|
-
selection.jsonlFallback.response.result
|
|
1409
|
-
) || selection.response;
|
|
1410
|
-
}
|
|
1411
|
-
sendBridgeManagedThreadTurnsListResponse(request, responsePayload, respondOnce, {
|
|
1419
|
+
sendBridgeManagedThreadTurnsListResponse(request, selection.response, respondOnce, {
|
|
1412
1420
|
skipJsonlArtifactAugmentation: selection.usesJsonl,
|
|
1413
1421
|
});
|
|
1414
1422
|
} catch (error) {
|
|
@@ -1433,6 +1441,11 @@ function startBridge({
|
|
|
1433
1441
|
function sendBridgeManagedThreadTurnsListResponse(request, response, sendResponse, {
|
|
1434
1442
|
skipJsonlArtifactAugmentation = false,
|
|
1435
1443
|
} = {}) {
|
|
1444
|
+
response = annotateTurnStateProbeWithMirrorActiveTurn(
|
|
1445
|
+
request,
|
|
1446
|
+
response,
|
|
1447
|
+
(threadId) => rolloutLiveMirror?.getActiveTurnId(threadId) || null
|
|
1448
|
+
);
|
|
1436
1449
|
const finalSanitizeContext = buildThreadTurnsListRelaySanitizeContext(request, {
|
|
1437
1450
|
skipJsonlArtifactAugmentation,
|
|
1438
1451
|
});
|
|
@@ -1751,7 +1764,12 @@ function startBridge({
|
|
|
1751
1764
|
if (trackedRequest.method === "thread/list"
|
|
1752
1765
|
|| trackedRequest.method === "thread/read"
|
|
1753
1766
|
|| trackedRequest.method === "thread/resume") {
|
|
1754
|
-
|
|
1767
|
+
// One walk over the rows for both enrichers instead of one traversal each.
|
|
1768
|
+
forEachThreadRowInResponse(trackedRequest.method, parsed, (thread) => {
|
|
1769
|
+
threadRuntimeSettingsStore.attachToThread(thread);
|
|
1770
|
+
threadListProvenanceEnricher.attachToThread(thread);
|
|
1771
|
+
worktreeOriginEnricher.attachToThread(thread);
|
|
1772
|
+
});
|
|
1755
1773
|
normalizedMessage = JSON.stringify(parsed);
|
|
1756
1774
|
}
|
|
1757
1775
|
|
|
@@ -5109,6 +5127,7 @@ function shouldSuppressRolloutMirrorForThread(
|
|
|
5109
5127
|
}
|
|
5110
5128
|
|
|
5111
5129
|
module.exports = {
|
|
5130
|
+
annotateTurnStateProbeWithMirrorActiveTurn,
|
|
5112
5131
|
buildThreadTurnsListRelaySanitizeContext,
|
|
5113
5132
|
buildHeartbeatBridgeStatus,
|
|
5114
5133
|
buildRelayAccessTokenHeaders,
|
|
@@ -18,6 +18,8 @@ const DEFAULT_MID_RUN_REFRESH_THROTTLE_MS = 3_000;
|
|
|
18
18
|
const DEFAULT_ROLLOUT_LOOKUP_TIMEOUT_MS = 5_000;
|
|
19
19
|
const DEFAULT_ROLLOUT_IDLE_TIMEOUT_MS = 10_000;
|
|
20
20
|
const DEFAULT_CUSTOM_REFRESH_FAILURE_THRESHOLD = 3;
|
|
21
|
+
const DEFAULT_FOLLOW_CONFIRM_TIMEOUT_MS = 1_500;
|
|
22
|
+
const DEFAULT_FOLLOW_MAX_ATTEMPTS = 3;
|
|
21
23
|
const REFRESH_SCRIPT_PATH = path.join(__dirname, "scripts", "codex-refresh.applescript");
|
|
22
24
|
const NEW_THREAD_DEEP_LINK = "codex://threads/new";
|
|
23
25
|
|
|
@@ -44,6 +46,8 @@ class CodexDesktopRefresher {
|
|
|
44
46
|
watchThreadRolloutFactory = createThreadRolloutActivityWatcher,
|
|
45
47
|
refreshBackend = null,
|
|
46
48
|
customRefreshFailureThreshold = DEFAULT_CUSTOM_REFRESH_FAILURE_THRESHOLD,
|
|
49
|
+
followConfirmTimeoutMs = DEFAULT_FOLLOW_CONFIRM_TIMEOUT_MS,
|
|
50
|
+
followMaxAttempts = DEFAULT_FOLLOW_MAX_ATTEMPTS,
|
|
47
51
|
} = {}) {
|
|
48
52
|
this.enabled = enabled;
|
|
49
53
|
this.navigationOnly = navigationOnly;
|
|
@@ -62,6 +66,8 @@ class CodexDesktopRefresher {
|
|
|
62
66
|
this.refreshBackend = refreshBackend
|
|
63
67
|
|| (this.refreshCommand ? "command" : (this.refreshExecutor ? "command" : "applescript"));
|
|
64
68
|
this.customRefreshFailureThreshold = customRefreshFailureThreshold;
|
|
69
|
+
this.followConfirmTimeoutMs = followConfirmTimeoutMs;
|
|
70
|
+
this.followMaxAttempts = followMaxAttempts;
|
|
65
71
|
|
|
66
72
|
this.mode = "idle";
|
|
67
73
|
this.pendingNewThread = false;
|
|
@@ -84,6 +90,11 @@ class CodexDesktopRefresher {
|
|
|
84
90
|
this.watchStartAt = 0;
|
|
85
91
|
this.lastRolloutSize = null;
|
|
86
92
|
this.stopWatcherAfterRefreshThreadId = null;
|
|
93
|
+
this.materializationPendingThreadIds = new Set();
|
|
94
|
+
this.followedThreadIds = new Set();
|
|
95
|
+
this.followAttemptsByThreadId = new Map();
|
|
96
|
+
this.followConfirmationTimersByThreadId = new Map();
|
|
97
|
+
this.followActivationSerial = 0;
|
|
87
98
|
this.runtimeRefreshAvailable = enabled;
|
|
88
99
|
this.consecutiveRefreshFailures = 0;
|
|
89
100
|
this.unavailableLogged = false;
|
|
@@ -117,8 +128,19 @@ class CodexDesktopRefresher {
|
|
|
117
128
|
return;
|
|
118
129
|
}
|
|
119
130
|
|
|
131
|
+
if (this.navigationOnly && target.threadId) {
|
|
132
|
+
if (this.followedThreadIds.has(target.threadId)) {
|
|
133
|
+
this.log(`desktop follow already active thread=${target.threadId}`);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (this.materializationPendingThreadIds.has(target.threadId)) {
|
|
137
|
+
this.ensureWatcher(target.threadId);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
120
142
|
this.queueRefresh("phone", target, `phone ${method}`);
|
|
121
|
-
if (target.threadId) {
|
|
143
|
+
if (target.threadId && !this.navigationOnly) {
|
|
122
144
|
this.ensureWatcher(target.threadId);
|
|
123
145
|
}
|
|
124
146
|
}
|
|
@@ -149,16 +171,51 @@ class CodexDesktopRefresher {
|
|
|
149
171
|
|
|
150
172
|
if (method === "thread/started") {
|
|
151
173
|
const target = resolveOutboundTarget(method, parsed);
|
|
174
|
+
const shouldWaitForMaterialization = Boolean(
|
|
175
|
+
this.navigationOnly
|
|
176
|
+
&& this.pendingNewThread
|
|
177
|
+
&& target?.threadId
|
|
178
|
+
);
|
|
152
179
|
this.pendingNewThread = false;
|
|
153
180
|
this.clearFallbackTimer();
|
|
181
|
+
if (shouldWaitForMaterialization) {
|
|
182
|
+
this.materializationPendingThreadIds.add(target.threadId);
|
|
183
|
+
this.mode = "waiting_for_materialization";
|
|
184
|
+
this.ensureWatcher(target.threadId);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
154
187
|
this.queueRefresh("phone", target, `codex ${method}`);
|
|
155
|
-
if (target?.threadId) {
|
|
188
|
+
if (target?.threadId && !this.navigationOnly) {
|
|
156
189
|
this.mode = "watching_thread";
|
|
157
190
|
this.ensureWatcher(target.threadId);
|
|
158
191
|
}
|
|
159
192
|
}
|
|
160
193
|
}
|
|
161
194
|
|
|
195
|
+
// Codex emits this only after the thread route mounts and its renderer calls
|
|
196
|
+
// set-active-conversation. That is the authoritative proof that Desktop will
|
|
197
|
+
// accept the owner's snapshots instead of dropping them as unfollowed.
|
|
198
|
+
handleFollowerStateChanged(threadId, following) {
|
|
199
|
+
const normalizedThreadId = readString(threadId);
|
|
200
|
+
if (!normalizedThreadId) {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (!following) {
|
|
204
|
+
this.followedThreadIds.delete(normalizedThreadId);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
this.followedThreadIds.add(normalizedThreadId);
|
|
209
|
+
this.materializationPendingThreadIds.delete(normalizedThreadId);
|
|
210
|
+
this.followAttemptsByThreadId.delete(normalizedThreadId);
|
|
211
|
+
this.clearFollowConfirmationTimer(normalizedThreadId);
|
|
212
|
+
this.log(`desktop follow confirmed thread=${normalizedThreadId}`);
|
|
213
|
+
if (this.activeWatchedThreadId === normalizedThreadId) {
|
|
214
|
+
this.stopWatcher();
|
|
215
|
+
this.mode = "idle";
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
162
219
|
// Stops volatile watcher/fallback state when transport drops or bridge exits.
|
|
163
220
|
handleTransportReset() {
|
|
164
221
|
this.clearRefreshTimer();
|
|
@@ -168,6 +225,9 @@ class CodexDesktopRefresher {
|
|
|
168
225
|
this.mode = "idle";
|
|
169
226
|
this.clearFallbackTimer();
|
|
170
227
|
this.stopWatcher();
|
|
228
|
+
this.materializationPendingThreadIds.clear();
|
|
229
|
+
this.followedThreadIds.clear();
|
|
230
|
+
this.clearAllFollowConfirmationTimers();
|
|
171
231
|
}
|
|
172
232
|
|
|
173
233
|
queueRefresh(kind, target, reason) {
|
|
@@ -283,12 +343,29 @@ class CodexDesktopRefresher {
|
|
|
283
343
|
&& this.now() - this.lastRefreshAt < this.debounceMs
|
|
284
344
|
) {
|
|
285
345
|
this.log(`refresh skipped (duplicate target): ${refreshSignature}`);
|
|
346
|
+
} else if (
|
|
347
|
+
this.navigationOnly
|
|
348
|
+
&& targetThreadId
|
|
349
|
+
&& this.followedThreadIds.has(targetThreadId)
|
|
350
|
+
) {
|
|
351
|
+
this.log(`desktop follow confirmed before navigation thread=${targetThreadId}`);
|
|
286
352
|
} else {
|
|
287
|
-
|
|
353
|
+
const refreshTargetUrl = this.followActivationTargetUrl(
|
|
354
|
+
targetUrl,
|
|
355
|
+
targetThreadId
|
|
356
|
+
);
|
|
357
|
+
await this.executeRefresh(refreshTargetUrl);
|
|
288
358
|
this.lastRefreshAt = this.now();
|
|
289
359
|
this.lastRefreshSignature = refreshSignature;
|
|
290
360
|
this.consecutiveRefreshFailures = 0;
|
|
291
361
|
didRefresh = true;
|
|
362
|
+
if (
|
|
363
|
+
this.navigationOnly
|
|
364
|
+
&& targetThreadId
|
|
365
|
+
&& !this.followedThreadIds.has(targetThreadId)
|
|
366
|
+
) {
|
|
367
|
+
this.scheduleFollowConfirmation(targetThreadId, targetUrl);
|
|
368
|
+
}
|
|
292
369
|
}
|
|
293
370
|
if (completionTurnId && didRefresh) {
|
|
294
371
|
this.lastTurnIdRefreshed = completionTurnId;
|
|
@@ -355,7 +432,7 @@ class CodexDesktopRefresher {
|
|
|
355
432
|
|
|
356
433
|
// Schedules a single low-cost fallback when a brand new thread id is still unknown.
|
|
357
434
|
scheduleNewThreadFallback() {
|
|
358
|
-
if (!this.canRefresh()) {
|
|
435
|
+
if (!this.canRefresh() || this.navigationOnly) {
|
|
359
436
|
return;
|
|
360
437
|
}
|
|
361
438
|
|
|
@@ -386,7 +463,7 @@ class CodexDesktopRefresher {
|
|
|
386
463
|
|
|
387
464
|
// Keeps one lightweight rollout watcher alive for the current Remodex-controlled thread.
|
|
388
465
|
ensureWatcher(threadId) {
|
|
389
|
-
if (
|
|
466
|
+
if (!this.canRefresh() || !threadId) {
|
|
390
467
|
return;
|
|
391
468
|
}
|
|
392
469
|
|
|
@@ -406,16 +483,19 @@ class CodexDesktopRefresher {
|
|
|
406
483
|
onEvent: (event) => this.handleWatcherEvent(event),
|
|
407
484
|
onIdle: () => {
|
|
408
485
|
this.log(`rollout watcher idle thread=${threadId}`);
|
|
486
|
+
this.activateAfterMaterializationWait(threadId, "rollout watcher idle");
|
|
409
487
|
this.stopWatcher();
|
|
410
488
|
this.mode = this.pendingNewThread ? "pending_new_thread" : "idle";
|
|
411
489
|
},
|
|
412
490
|
onTimeout: () => {
|
|
413
491
|
this.log(`rollout watcher timeout thread=${threadId}`);
|
|
492
|
+
this.activateAfterMaterializationWait(threadId, "rollout watcher timeout");
|
|
414
493
|
this.stopWatcher();
|
|
415
494
|
this.mode = this.pendingNewThread ? "pending_new_thread" : "idle";
|
|
416
495
|
},
|
|
417
496
|
onError: (error) => {
|
|
418
497
|
this.log(`rollout watcher failed thread=${threadId}: ${error.message}`);
|
|
498
|
+
this.activateAfterMaterializationWait(threadId, "rollout watcher error");
|
|
419
499
|
this.stopWatcher();
|
|
420
500
|
this.mode = this.pendingNewThread ? "pending_new_thread" : "idle";
|
|
421
501
|
},
|
|
@@ -451,10 +531,19 @@ class CodexDesktopRefresher {
|
|
|
451
531
|
});
|
|
452
532
|
|
|
453
533
|
if (event.reason === "materialized") {
|
|
534
|
+
this.materializationPendingThreadIds.delete(event.threadId);
|
|
454
535
|
this.queueRefresh("rollout_materialized", {
|
|
455
536
|
threadId: event.threadId,
|
|
456
537
|
url: buildThreadDeepLink(event.threadId),
|
|
457
538
|
}, `rollout ${event.reason}`);
|
|
539
|
+
if (this.navigationOnly) {
|
|
540
|
+
this.stopWatcher();
|
|
541
|
+
this.mode = "idle";
|
|
542
|
+
}
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (this.navigationOnly) {
|
|
458
547
|
return;
|
|
459
548
|
}
|
|
460
549
|
|
|
@@ -513,6 +602,9 @@ class CodexDesktopRefresher {
|
|
|
513
602
|
this.clearFallbackTimer();
|
|
514
603
|
this.stopWatcher();
|
|
515
604
|
this.clearPendingState();
|
|
605
|
+
this.materializationPendingThreadIds.clear();
|
|
606
|
+
this.followedThreadIds.clear();
|
|
607
|
+
this.clearAllFollowConfirmationTimers();
|
|
516
608
|
this.mode = "idle";
|
|
517
609
|
|
|
518
610
|
if (!this.unavailableLogged) {
|
|
@@ -529,6 +621,69 @@ class CodexDesktopRefresher {
|
|
|
529
621
|
hasPendingRefreshWork() {
|
|
530
622
|
return this.pendingCompletionRefresh || this.pendingRefreshKinds.size > 0;
|
|
531
623
|
}
|
|
624
|
+
|
|
625
|
+
activateAfterMaterializationWait(threadId, reason) {
|
|
626
|
+
if (!this.navigationOnly || !this.materializationPendingThreadIds.delete(threadId)) {
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
this.queueRefresh("materialization_fallback", {
|
|
630
|
+
threadId,
|
|
631
|
+
url: buildThreadDeepLink(threadId),
|
|
632
|
+
}, reason);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Opening an already-active deep link is a no-op in Codex, so the route's
|
|
636
|
+
// set-active-conversation effect never re-announces following after an IPC
|
|
637
|
+
// reconnect. Every activation gets a unique query value: waiting for a failed
|
|
638
|
+
// plain attempt first leaves the first 1.5s of a phone turn invisible.
|
|
639
|
+
followActivationTargetUrl(targetUrl, threadId) {
|
|
640
|
+
if (!this.navigationOnly || !threadId) {
|
|
641
|
+
return targetUrl;
|
|
642
|
+
}
|
|
643
|
+
const resolvedTargetUrl = targetUrl || buildThreadDeepLink(threadId);
|
|
644
|
+
const separator = resolvedTargetUrl.includes("?") ? "&" : "?";
|
|
645
|
+
this.followActivationSerial += 1;
|
|
646
|
+
return `${resolvedTargetUrl}${separator}remodex-follow=${this.now()}-${this.followActivationSerial}`;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
scheduleFollowConfirmation(threadId, targetUrl) {
|
|
650
|
+
this.clearFollowConfirmationTimer(threadId);
|
|
651
|
+
const attempts = (this.followAttemptsByThreadId.get(threadId) || 0) + 1;
|
|
652
|
+
this.followAttemptsByThreadId.set(threadId, attempts);
|
|
653
|
+
if (attempts >= this.followMaxAttempts) {
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const timer = setTimeout(() => {
|
|
658
|
+
this.followConfirmationTimersByThreadId.delete(threadId);
|
|
659
|
+
if (this.followedThreadIds.has(threadId) || !this.canRefresh()) {
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
this.queueRefresh("follow_retry", {
|
|
663
|
+
threadId,
|
|
664
|
+
url: targetUrl || buildThreadDeepLink(threadId),
|
|
665
|
+
}, `desktop follow not confirmed (attempt ${attempts + 1})`);
|
|
666
|
+
}, Math.max(0, this.followConfirmTimeoutMs));
|
|
667
|
+
timer.unref?.();
|
|
668
|
+
this.followConfirmationTimersByThreadId.set(threadId, timer);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
clearFollowConfirmationTimer(threadId) {
|
|
672
|
+
const timer = this.followConfirmationTimersByThreadId.get(threadId);
|
|
673
|
+
if (!timer) {
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
clearTimeout(timer);
|
|
677
|
+
this.followConfirmationTimersByThreadId.delete(threadId);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
clearAllFollowConfirmationTimers() {
|
|
681
|
+
for (const timer of this.followConfirmationTimersByThreadId.values()) {
|
|
682
|
+
clearTimeout(timer);
|
|
683
|
+
}
|
|
684
|
+
this.followConfirmationTimersByThreadId.clear();
|
|
685
|
+
this.followAttemptsByThreadId.clear();
|
|
686
|
+
}
|
|
532
687
|
}
|
|
533
688
|
|
|
534
689
|
function readBridgeConfig({
|
|
@@ -573,6 +728,7 @@ function readBridgeConfig({
|
|
|
573
728
|
);
|
|
574
729
|
const explicitRefreshEnabled = readOptionalBooleanEnv(["REMODEX_REFRESH_ENABLED"], env);
|
|
575
730
|
const explicitDesktopIpcLiveSyncEnabled = readOptionalBooleanEnv(["REMODEX_DESKTOP_IPC_LIVE_SYNC"], env);
|
|
731
|
+
const explicitDesktopAutoFollowEnabled = readOptionalBooleanEnv(["REMODEX_DESKTOP_AUTO_FOLLOW"], env);
|
|
576
732
|
const explicitKeepMacAwakeEnabled = readOptionalBooleanEnv(["REMODEX_KEEP_MAC_AWAKE"], env);
|
|
577
733
|
const persistedKeepMacAwakeEnabled = typeof daemonConfig.keepMacAwakeEnabled === "boolean"
|
|
578
734
|
? daemonConfig.keepMacAwakeEnabled
|
|
@@ -585,6 +741,14 @@ function readBridgeConfig({
|
|
|
585
741
|
? daemonConfig.refreshEnabled
|
|
586
742
|
: null;
|
|
587
743
|
const defaultRefreshEnabled = persistedRefreshEnabled == null ? false : persistedRefreshEnabled;
|
|
744
|
+
const desktopIpcLiveSyncEnabled = explicitDesktopIpcLiveSyncEnabled == null
|
|
745
|
+
? true
|
|
746
|
+
: explicitDesktopIpcLiveSyncEnabled;
|
|
747
|
+
const defaultDesktopAutoFollowEnabled = (
|
|
748
|
+
platform === "darwin"
|
|
749
|
+
&& !codexEndpoint
|
|
750
|
+
&& desktopIpcLiveSyncEnabled
|
|
751
|
+
);
|
|
588
752
|
return {
|
|
589
753
|
relayUrl,
|
|
590
754
|
relayAccessToken,
|
|
@@ -609,9 +773,10 @@ function readBridgeConfig({
|
|
|
609
773
|
: explicitKeepMacAwakeEnabled,
|
|
610
774
|
codexEndpoint,
|
|
611
775
|
desktopIpcSocketPath: readFirstDefinedEnv(["REMODEX_DESKTOP_IPC_SOCKET"], "", env),
|
|
612
|
-
desktopIpcLiveSyncEnabled
|
|
613
|
-
|
|
614
|
-
|
|
776
|
+
desktopIpcLiveSyncEnabled,
|
|
777
|
+
desktopAutoFollowEnabled: explicitDesktopAutoFollowEnabled == null
|
|
778
|
+
? defaultDesktopAutoFollowEnabled
|
|
779
|
+
: explicitDesktopAutoFollowEnabled,
|
|
615
780
|
desktopIpcSnapshotDebounceMs: parseIntegerEnv(
|
|
616
781
|
readFirstDefinedEnv(["REMODEX_DESKTOP_IPC_SNAPSHOT_DEBOUNCE_MS"], "75", env),
|
|
617
782
|
75
|