@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 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.3.2",
3
+ "version": "2.5.6",
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,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
- 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,
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", { threadId })
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
- let responsePayload = selection.response;
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
- 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
+ });
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
- 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