@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
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,6 +54,7 @@ const {
|
|
|
54
54
|
const { createBridgeSecureTransport } = require("./secure-transport");
|
|
55
55
|
const { createRolloutLiveMirrorController } = require("./rollout-live-mirror");
|
|
56
56
|
const {
|
|
57
|
+
buildCompleteThreadReadParams,
|
|
57
58
|
isContextualUserText,
|
|
58
59
|
isThreadTurnStateProbeRequest,
|
|
59
60
|
isUserRoleItem,
|
|
@@ -67,6 +68,9 @@ const {
|
|
|
67
68
|
} = require("./desktop-ipc-action-follower");
|
|
68
69
|
const { createDesktopIpcLiveOwner } = require("./desktop-ipc-live-owner");
|
|
69
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");
|
|
70
74
|
const { version: bridgePackageVersion = "" } = require("../package.json");
|
|
71
75
|
const {
|
|
72
76
|
MINIMUM_SUPPORTED_IOS_APP_VERSION,
|
|
@@ -121,7 +125,7 @@ const RELAY_JSONL_FULL_ARTIFACT_FALLBACK_MAX_BYTES = Math.max(
|
|
|
121
125
|
0,
|
|
122
126
|
bufferConstants.MAX_STRING_LENGTH - (8 * 1024 * 1024)
|
|
123
127
|
);
|
|
124
|
-
const BRIDGE_PACKAGE_UPDATE_COMMAND = "npm install -g remodex@latest";
|
|
128
|
+
const BRIDGE_PACKAGE_UPDATE_COMMAND = "npm install -g @makerbi/remodex@latest";
|
|
125
129
|
const BRIDGE_PACKAGE_UPDATE_TIMEOUT_MS = 180_000;
|
|
126
130
|
const BRIDGE_RESTART_AFTER_UPDATE_DELAY_MS = 750;
|
|
127
131
|
const MODELS_WITHOUT_REASONING_SUMMARY = new Set([
|
|
@@ -477,6 +481,27 @@ function createThreadTurnsListFastPageCoordinator({
|
|
|
477
481
|
canonicalRequest,
|
|
478
482
|
fetchCanonical
|
|
479
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.
|
|
480
505
|
let jsonlFallback = null;
|
|
481
506
|
try {
|
|
482
507
|
jsonlFallback = await readJsonl(request);
|
|
@@ -501,38 +526,6 @@ function createThreadTurnsListFastPageCoordinator({
|
|
|
501
526
|
};
|
|
502
527
|
}
|
|
503
528
|
|
|
504
|
-
let timeoutId = null;
|
|
505
|
-
const deadline = new Promise((resolveDeadline) => {
|
|
506
|
-
timeoutId = setTimeoutImpl(() => resolveDeadline({ deadline: true }), waitMs);
|
|
507
|
-
});
|
|
508
|
-
const first = await Promise.race([canonicalOutcomePromise, deadline]);
|
|
509
|
-
if (timeoutId != null) {
|
|
510
|
-
clearTimeoutImpl(timeoutId);
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
if (first?.ok && !isEmptyTurnsListResponse(first.response)) {
|
|
514
|
-
if (shouldPreferJsonlFirstPage(first.response, jsonlFallback.response)) {
|
|
515
|
-
const token = rememberHandoff(threadId, canonicalOutcomePromise, jsonlFallback);
|
|
516
|
-
return {
|
|
517
|
-
source: "jsonl",
|
|
518
|
-
response: buildJsonlCanonicalHandoffResponse(
|
|
519
|
-
jsonlFallback.response,
|
|
520
|
-
request.id,
|
|
521
|
-
token,
|
|
522
|
-
firstTurnsListTurnId(jsonlFallback.response)
|
|
523
|
-
),
|
|
524
|
-
usesJsonl: true,
|
|
525
|
-
};
|
|
526
|
-
}
|
|
527
|
-
forgetCanonicalFirstPage(canonicalFirstPageCacheKey, canonicalOutcomePromise);
|
|
528
|
-
return {
|
|
529
|
-
source: "canonical",
|
|
530
|
-
response: rebindThreadTurnsListResponseId(first.response, request.id),
|
|
531
|
-
usesJsonl: false,
|
|
532
|
-
jsonlFallback,
|
|
533
|
-
};
|
|
534
|
-
}
|
|
535
|
-
|
|
536
529
|
const token = rememberHandoff(threadId, canonicalOutcomePromise, jsonlFallback);
|
|
537
530
|
return {
|
|
538
531
|
source: "jsonl",
|
|
@@ -660,21 +653,6 @@ function buildJsonlCanonicalHandoffResponse(response, requestId, token, anchorTu
|
|
|
660
653
|
};
|
|
661
654
|
}
|
|
662
655
|
|
|
663
|
-
function shouldPreferJsonlFirstPage(canonicalResponse, jsonlResponse) {
|
|
664
|
-
const canonicalResult = canonicalResponse?.result;
|
|
665
|
-
const jsonlResult = jsonlResponse?.result;
|
|
666
|
-
const canonicalTurnsKey = findTurnsListResultKey(canonicalResult);
|
|
667
|
-
const jsonlTurnsKey = findTurnsListResultKey(jsonlResult);
|
|
668
|
-
if (!canonicalTurnsKey || !jsonlTurnsKey) {
|
|
669
|
-
return false;
|
|
670
|
-
}
|
|
671
|
-
const jsonlTurn = jsonlResult[jsonlTurnsKey]?.[0];
|
|
672
|
-
const jsonlTurnId = turnListTurnIdentifier(jsonlTurn);
|
|
673
|
-
return Boolean(jsonlTurnId)
|
|
674
|
-
&& !canonicalResult[canonicalTurnsKey].some((turn) => turnListTurnIdentifier(turn) === jsonlTurnId)
|
|
675
|
-
&& shouldMergeLatestJsonlTurn(jsonlTurn);
|
|
676
|
-
}
|
|
677
|
-
|
|
678
656
|
function firstTurnsListTurnId(response) {
|
|
679
657
|
const result = response?.result;
|
|
680
658
|
const turnsKey = findTurnsListResultKey(result);
|
|
@@ -783,7 +761,10 @@ function startBridge({
|
|
|
783
761
|
const relaySessionUrl = `${relayBaseUrl}/${sessionId}`;
|
|
784
762
|
const notificationSecret = randomBytes(24).toString("hex");
|
|
785
763
|
const desktopRefresher = new CodexDesktopRefresher({
|
|
786
|
-
|
|
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,
|
|
787
768
|
// With IPC live sync streaming content, deep-link refreshes are only needed
|
|
788
769
|
// to navigate Desktop onto the phone-driven thread, not to reload content.
|
|
789
770
|
navigationOnly: config.desktopIpcLiveSyncEnabled,
|
|
@@ -826,6 +807,8 @@ function startBridge({
|
|
|
826
807
|
const jsonlTurnsListRolloutMissCacheByThread = new Map();
|
|
827
808
|
const threadTurnsListFastPageCoordinator = createThreadTurnsListFastPageCoordinator();
|
|
828
809
|
const threadRuntimeSettingsStore = createThreadRuntimeSettingsStore();
|
|
810
|
+
const threadListProvenanceEnricher = createThreadListProvenanceEnricher();
|
|
811
|
+
const worktreeOriginEnricher = createWorktreeOriginEnricher();
|
|
829
812
|
const trackedForwardedRequestMethods = new Set([
|
|
830
813
|
"account/login/start",
|
|
831
814
|
"account/login/cancel",
|
|
@@ -891,7 +874,7 @@ function startBridge({
|
|
|
891
874
|
? createDesktopIpcActionFollower({
|
|
892
875
|
sendApplicationResponse,
|
|
893
876
|
readConversationState: async (threadId) => seedConversationStateFromThreadRead(
|
|
894
|
-
await sendCodexRequest("thread/read",
|
|
877
|
+
await sendCodexRequest("thread/read", buildCompleteThreadReadParams(threadId))
|
|
895
878
|
),
|
|
896
879
|
forwardToLocalCodex: (rawMessage) => {
|
|
897
880
|
observeDesktopIpcLiveOwnerInbound(rawMessage);
|
|
@@ -904,6 +887,9 @@ function startBridge({
|
|
|
904
887
|
runtimeSettingsStore: threadRuntimeSettingsStore,
|
|
905
888
|
socketPath: config.desktopIpcSocketPath || undefined,
|
|
906
889
|
snapshotDebounceMs: config.desktopIpcSnapshotDebounceMs,
|
|
890
|
+
onFollowerStateChanged(threadId, following) {
|
|
891
|
+
desktopRefresher.handleFollowerStateChanged(threadId, following);
|
|
892
|
+
},
|
|
907
893
|
})
|
|
908
894
|
: null;
|
|
909
895
|
const desktopIpcLiveOwner = !config.codexEndpoint
|
|
@@ -916,6 +902,9 @@ function startBridge({
|
|
|
916
902
|
runtimeSettingsStore: threadRuntimeSettingsStore,
|
|
917
903
|
socketPath: config.desktopIpcSocketPath || undefined,
|
|
918
904
|
snapshotDebounceMs: config.desktopIpcSnapshotDebounceMs,
|
|
905
|
+
onFollowerStateChanged(threadId, following) {
|
|
906
|
+
desktopRefresher.handleFollowerStateChanged(threadId, following);
|
|
907
|
+
},
|
|
919
908
|
})
|
|
920
909
|
: null;
|
|
921
910
|
let contextUsageWatcher = null;
|
|
@@ -1180,7 +1169,6 @@ function startBridge({
|
|
|
1180
1169
|
stopContextUsageWatcher();
|
|
1181
1170
|
// Relay reconnects are transport-only: keep local live observers running
|
|
1182
1171
|
// so their output can enter secure replay and catch up on the next resume.
|
|
1183
|
-
desktopRefresher.handleTransportReset();
|
|
1184
1172
|
scheduleRelayReconnect(code);
|
|
1185
1173
|
});
|
|
1186
1174
|
|
|
@@ -1428,15 +1416,7 @@ function startBridge({
|
|
|
1428
1416
|
}),
|
|
1429
1417
|
readJsonl: (jsonlRequest) => maybeBuildJsonlThreadTurnsListFallback(jsonlRequest, null),
|
|
1430
1418
|
});
|
|
1431
|
-
|
|
1432
|
-
if (selection.source === "canonical" && selection.jsonlFallback?.response?.result) {
|
|
1433
|
-
responsePayload = maybeMergeLatestJsonlTurnIntoTurnsListResponse(
|
|
1434
|
-
request,
|
|
1435
|
-
selection.response,
|
|
1436
|
-
selection.jsonlFallback.response.result
|
|
1437
|
-
) || selection.response;
|
|
1438
|
-
}
|
|
1439
|
-
sendBridgeManagedThreadTurnsListResponse(request, responsePayload, respondOnce, {
|
|
1419
|
+
sendBridgeManagedThreadTurnsListResponse(request, selection.response, respondOnce, {
|
|
1440
1420
|
skipJsonlArtifactAugmentation: selection.usesJsonl,
|
|
1441
1421
|
});
|
|
1442
1422
|
} catch (error) {
|
|
@@ -1784,7 +1764,12 @@ function startBridge({
|
|
|
1784
1764
|
if (trackedRequest.method === "thread/list"
|
|
1785
1765
|
|| trackedRequest.method === "thread/read"
|
|
1786
1766
|
|| trackedRequest.method === "thread/resume") {
|
|
1787
|
-
|
|
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
|
+
});
|
|
1788
1773
|
normalizedMessage = JSON.stringify(parsed);
|
|
1789
1774
|
}
|
|
1790
1775
|
|
|
@@ -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
|