@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@makerbi/remodex",
3
- "version": "2.4.0",
3
+ "version": "3.1.0",
4
4
  "description": "Local bridge between Codex and the Remodex mobile app. Run `remodex up` to start.",
5
5
  "repository": {
6
6
  "type": "git",
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
- enabled: config.refreshEnabled,
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", { threadId })
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
- let responsePayload = selection.response;
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
- threadRuntimeSettingsStore.enrichResponse(trackedRequest.method, parsed);
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
- await this.executeRefresh(targetUrl);
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 (this.navigationOnly || !this.canRefresh() || !threadId) {
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: explicitDesktopIpcLiveSyncEnabled == null
613
- ? true
614
- : explicitDesktopIpcLiveSyncEnabled,
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