androdex 1.1.5 → 1.1.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/README.md CHANGED
@@ -102,12 +102,32 @@ npm install
102
102
  npm start
103
103
  ```
104
104
 
105
+ ## Release
106
+
107
+ Publish the npm package from this directory, not from the repository root:
108
+
109
+ ```sh
110
+ cd androdex-bridge
111
+ npm test
112
+ npm pack --dry-run
113
+ npm version patch --no-git-tag-version
114
+ npm publish --access public
115
+ ```
116
+
117
+ Verify the published version after the release:
118
+
119
+ ```sh
120
+ npm view androdex version
121
+ ```
122
+
123
+ If your npm account requires write-time 2FA, rerun the publish command with `--otp=<code>`.
124
+
105
125
  ## Manual Smoke Checklist
106
126
 
107
127
  1. Run `androdex up` and confirm the Android app can pair successfully.
108
128
  2. Run `androdex up` inside a workspace and confirm the host keeps Codex bound to that local project.
109
129
  3. From Android, open an existing thread and create a new one to confirm the remote client flow still works end to end.
110
- 4. If desktop refresh is enabled, verify phone-authored thread activity updates the host Codex desktop as expected without a Settings bounce.
130
+ 4. If desktop refresh is enabled, verify phone-authored thread activity updates the host Codex desktop via the Settings-bounce remount workaround.
111
131
  5. Restart the launchd service or reconnect the phone and confirm the saved pairing and active workspace recover without losing host-local state.
112
132
 
113
133
  ## Project status
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "androdex",
3
- "version": "1.1.5",
3
+ "version": "1.1.6",
4
4
  "description": "macOS host bridge between Codex and the Androdex Android app. Run `androdex up` to start.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/bridge.js CHANGED
@@ -11,7 +11,6 @@ const {
11
11
  CodexDesktopRefresher,
12
12
  readBridgeConfig,
13
13
  } = require("./codex-desktop-refresher");
14
- const { createDesktopThreadReadRefresher } = require("./codex-desktop-thread-sync");
15
14
  const { createCodexRpcClient } = require("./codex-rpc-client");
16
15
  const { createThreadRolloutActivityWatcher } = require("./rollout-watch");
17
16
  const { printQR } = require("./qr");
@@ -132,18 +131,12 @@ function startBridge({
132
131
  },
133
132
  requestIdPrefix: `androdex-bridge-${sessionId}`,
134
133
  });
135
- const refreshDesktopThreadState = createDesktopThreadReadRefresher({
136
- sendCodexRequest(method, params) {
137
- return codexRpcClient.sendRequest(method, params);
138
- },
139
- });
140
134
  const desktopRefresher = new CodexDesktopRefresher({
141
135
  enabled: config.refreshEnabled,
142
136
  debounceMs: config.refreshDebounceMs,
143
137
  refreshCommand: config.refreshCommand,
144
138
  bundleId: config.codexBundleId,
145
139
  appPath: config.codexAppPath,
146
- refreshThreadState: refreshDesktopThreadState,
147
140
  });
148
141
  const workspaceRuntime = createWorkspaceRuntime({
149
142
  config,
@@ -273,6 +266,7 @@ function startBridge({
273
266
  }
274
267
 
275
268
  logConnectionStatus("connecting");
269
+ let hasSeenInboundRelayTraffic = false;
276
270
  const nextSocket = new WebSocket(relaySessionUrl, {
277
271
  headers: {
278
272
  "x-role": "mac",
@@ -308,6 +302,7 @@ function startBridge({
308
302
  });
309
303
 
310
304
  nextSocket.on("message", (data) => {
305
+ hasSeenInboundRelayTraffic = true;
311
306
  markRelayActivity();
312
307
  const message = typeof data === "string" ? data : data.toString("utf8");
313
308
  if (secureTransport.handleIncomingWireMessage(message, {
@@ -324,7 +319,14 @@ function startBridge({
324
319
  }
325
320
  });
326
321
 
327
- nextSocket.on("pong", markRelayActivity);
322
+ nextSocket.on("pong", () => {
323
+ hasSeenInboundRelayTraffic = true;
324
+ markRelayActivity();
325
+ });
326
+ nextSocket.on("ping", () => {
327
+ hasSeenInboundRelayTraffic = true;
328
+ markRelayActivity();
329
+ });
328
330
  nextSocket.on("close", (code) => {
329
331
  clearRelayWatchdog();
330
332
  logConnectionStatus("disconnected");
@@ -629,8 +631,7 @@ function startBridge({
629
631
  }
630
632
 
631
633
  if (codexHandshakeState !== "warm") {
632
- forwardedInitializeRequestIds.add(String(parsed.id));
633
- return false;
634
+ primeCodexHandshake();
634
635
  }
635
636
 
636
637
  sendApplicationResponse(JSON.stringify({
@@ -645,7 +646,10 @@ function startBridge({
645
646
 
646
647
  if (method === "initialized") {
647
648
  cachedInitializedNotification = true;
648
- return codexHandshakeState === "warm" || !workspaceRuntime.hasActiveWorkspace();
649
+ if (workspaceRuntime.hasActiveWorkspace() && codexHandshakeState !== "warm") {
650
+ primeCodexHandshake();
651
+ }
652
+ return true;
649
653
  }
650
654
 
651
655
  return false;
@@ -704,7 +708,12 @@ function startBridge({
704
708
  }
705
709
 
706
710
  function primeCodexHandshake() {
707
- if (!workspaceRuntime.hasActiveWorkspace() || codexHandshakeState === "warm" || !cachedInitializeParams) {
711
+ if (
712
+ !workspaceRuntime.hasActiveWorkspace()
713
+ || codexHandshakeState === "warm"
714
+ || !cachedInitializeParams
715
+ || syntheticInitializeRequest
716
+ ) {
708
717
  return;
709
718
  }
710
719
  sendSyntheticInitialize(cachedInitializeParams, false);
@@ -797,6 +806,10 @@ function startBridge({
797
806
  return;
798
807
  }
799
808
 
809
+ if (!hasSeenInboundRelayTraffic) {
810
+ return;
811
+ }
812
+
800
813
  if (hasRelayConnectionGoneStale(lastRelayActivityAt)) {
801
814
  console.warn("[androdex] relay heartbeat stalled; forcing reconnect");
802
815
  publishBridgeStatus({
@@ -2,10 +2,9 @@
2
2
  // Purpose: Debounced Mac desktop refresh controller for Codex.app after phone-authored conversation changes.
3
3
  // Layer: CLI helper
4
4
  // Exports: CodexDesktopRefresher, readBridgeConfig
5
- // Depends on: child_process, fs, path, ./rollout-watch
5
+ // Depends on: child_process, path, ./rollout-watch
6
6
 
7
7
  const { execFile } = require("child_process");
8
- const fs = require("fs");
9
8
  const path = require("path");
10
9
  const { createThreadRolloutActivityWatcher } = require("./rollout-watch");
11
10
 
@@ -36,7 +35,6 @@ class CodexDesktopRefresher {
36
35
  now = () => Date.now(),
37
36
  refreshExecutor = null,
38
37
  watchThreadRolloutFactory = createThreadRolloutActivityWatcher,
39
- refreshThreadState = null,
40
38
  refreshBackend = null,
41
39
  customRefreshFailureThreshold = DEFAULT_CUSTOM_REFRESH_FAILURE_THRESHOLD,
42
40
  } = {}) {
@@ -53,7 +51,6 @@ class CodexDesktopRefresher {
53
51
  this.now = now;
54
52
  this.refreshExecutor = refreshExecutor;
55
53
  this.watchThreadRolloutFactory = watchThreadRolloutFactory;
56
- this.refreshThreadState = refreshThreadState;
57
54
  this.refreshBackend = refreshBackend
58
55
  || (this.refreshCommand ? "command" : (this.refreshExecutor ? "command" : "applescript"));
59
56
  this.customRefreshFailureThreshold = customRefreshFailureThreshold;
@@ -211,9 +208,23 @@ class CodexDesktopRefresher {
211
208
  }
212
209
 
213
210
  scheduleNewThreadFallback() {
214
- this.clearFallbackTimer();
211
+ if (!this.canRefresh()) {
212
+ return;
213
+ }
214
+
215
+ if (this.fallbackTimer) {
216
+ return;
217
+ }
218
+
215
219
  this.fallbackTimer = setTimeout(() => {
216
- this.queueRefresh("phone", { threadId: null, url: NEW_THREAD_DEEP_LINK }, "new-thread fallback");
220
+ this.fallbackTimer = null;
221
+ if (!this.pendingNewThread || this.pendingTargetThreadId) {
222
+ return;
223
+ }
224
+
225
+ this.noteRefreshTarget({ threadId: null, url: NEW_THREAD_DEEP_LINK });
226
+ this.pendingRefreshKinds.add("phone");
227
+ this.scheduleRefresh("fallback thread/start");
217
228
  }, this.fallbackNewThreadMs);
218
229
  }
219
230
 
@@ -227,7 +238,11 @@ class CodexDesktopRefresher {
227
238
  }
228
239
 
229
240
  ensureWatcher(threadId) {
230
- if (!threadId || this.activeWatchedThreadId === threadId) {
241
+ if (!this.canRefresh() || !threadId) {
242
+ return;
243
+ }
244
+
245
+ if (this.activeWatchedThreadId === threadId && this.activeWatcher) {
231
246
  return;
232
247
  }
233
248
 
@@ -235,40 +250,43 @@ class CodexDesktopRefresher {
235
250
  this.activeWatchedThreadId = threadId;
236
251
  this.watchStartAt = this.now();
237
252
  this.lastRolloutSize = null;
253
+ this.mode = "watching_thread";
238
254
  this.activeWatcher = this.watchThreadRolloutFactory({
239
255
  threadId,
240
- timeoutMs: this.rolloutLookupTimeoutMs,
256
+ lookupTimeoutMs: this.rolloutLookupTimeoutMs,
241
257
  idleTimeoutMs: this.rolloutIdleTimeoutMs,
242
- onActivity: ({ size }) => {
243
- if (size === this.lastRolloutSize) {
244
- return;
245
- }
246
-
247
- this.lastRolloutSize = size;
248
- const target = { threadId, url: buildThreadDeepLink(threadId) };
249
- this.queueRefresh("phone", target, "rollout activity");
250
- },
258
+ onEvent: (event) => this.handleWatcherEvent(event),
251
259
  onIdle: () => {
252
- if (this.stopWatcherAfterRefreshThreadId === threadId) {
253
- this.stopWatcher();
254
- }
260
+ this.log(`rollout watcher idle thread=${threadId}`);
261
+ this.stopWatcher();
262
+ this.mode = this.pendingNewThread ? "pending_new_thread" : "idle";
255
263
  },
256
264
  onTimeout: () => {
265
+ this.log(`rollout watcher timeout thread=${threadId}`);
257
266
  this.stopWatcher();
267
+ this.mode = this.pendingNewThread ? "pending_new_thread" : "idle";
258
268
  },
259
- onError: () => {
269
+ onError: (error) => {
270
+ this.log(`rollout watcher failed thread=${threadId}: ${error.message}`);
260
271
  this.stopWatcher();
272
+ this.mode = this.pendingNewThread ? "pending_new_thread" : "idle";
261
273
  },
262
274
  });
263
275
  }
264
276
 
265
277
  stopWatcher() {
266
- this.activeWatcher?.stop?.();
278
+ if (!this.activeWatcher) {
279
+ this.activeWatchedThreadId = null;
280
+ this.watchStartAt = 0;
281
+ this.lastRolloutSize = null;
282
+ return;
283
+ }
284
+
285
+ this.activeWatcher.stop();
267
286
  this.activeWatcher = null;
268
287
  this.activeWatchedThreadId = null;
269
288
  this.watchStartAt = 0;
270
289
  this.lastRolloutSize = null;
271
- this.stopWatcherAfterRefreshThreadId = null;
272
290
  }
273
291
 
274
292
  scheduleRefresh(reason) {
@@ -276,11 +294,18 @@ class CodexDesktopRefresher {
276
294
  return;
277
295
  }
278
296
 
279
- this.clearRefreshTimer();
297
+ if (this.refreshTimer) {
298
+ this.log(`refresh already pending: ${reason}`);
299
+ return;
300
+ }
301
+
302
+ const elapsedSinceLastRefresh = this.now() - this.lastRefreshAt;
303
+ const waitMs = Math.max(0, this.debounceMs - elapsedSinceLastRefresh);
304
+ this.log(`refresh scheduled: ${reason}`);
280
305
  this.refreshTimer = setTimeout(() => {
281
306
  this.refreshTimer = null;
282
- void this.executeRefresh(reason);
283
- }, this.debounceMs);
307
+ void this.runPendingRefresh();
308
+ }, waitMs);
284
309
  }
285
310
 
286
311
  clearRefreshTimer() {
@@ -292,116 +317,180 @@ class CodexDesktopRefresher {
292
317
  this.refreshTimer = null;
293
318
  }
294
319
 
295
- async executeRefresh(reason) {
296
- if (this.refreshRunning || !this.canRefresh() || !this.hasPendingRefreshWork()) {
320
+ async runPendingRefresh() {
321
+ if (!this.canRefresh()) {
322
+ this.clearPendingState();
297
323
  return;
298
324
  }
299
325
 
300
- this.refreshRunning = true;
301
- try {
302
- const completionTargetUrl = this.pendingCompletionTargetUrl;
303
- const completionTurnId = this.pendingCompletionTurnId;
304
- const completionThreadId = this.pendingCompletionTargetThreadId;
305
- const watcherThreadIdToStop = this.stopWatcherAfterRefreshThreadId;
306
- const targetUrl = this.pendingTargetUrl;
307
- const targetThreadId = this.pendingTargetThreadId;
308
- const refreshKinds = new Set(this.pendingRefreshKinds);
326
+ if (!this.hasPendingRefreshWork()) {
327
+ return;
328
+ }
309
329
 
310
- this.clearPendingState();
330
+ if (this.refreshRunning) {
331
+ this.log("refresh skipped (debounced): another refresh is already running");
332
+ return;
333
+ }
311
334
 
312
- if (completionTargetUrl) {
313
- await this.syncThreadStateBeforeRefresh(completionThreadId);
314
- await this.runRefresh(completionTargetUrl, {
315
- reason,
316
- refreshKinds,
317
- isCompletionRun: true,
318
- });
319
- this.lastTurnIdRefreshed = completionTurnId || null;
320
- if (completionThreadId && watcherThreadIdToStop === completionThreadId) {
321
- this.stopWatcher();
322
- }
323
- return;
324
- }
335
+ const isCompletionRun = this.pendingCompletionRefresh;
336
+ const pendingRefreshKinds = isCompletionRun
337
+ ? new Set(["completion"])
338
+ : new Set(this.pendingRefreshKinds);
339
+ const completionTurnId = this.pendingCompletionTurnId;
340
+ const targetUrl = isCompletionRun ? this.pendingCompletionTargetUrl : this.pendingTargetUrl;
341
+ const targetThreadId = isCompletionRun
342
+ ? this.pendingCompletionTargetThreadId
343
+ : this.pendingTargetThreadId;
344
+ const stopWatcherAfterRefreshThreadId = isCompletionRun
345
+ ? this.stopWatcherAfterRefreshThreadId
346
+ : null;
347
+ const shouldForceCompletionRefresh = isCompletionRun;
348
+
349
+ if (isCompletionRun) {
350
+ this.pendingCompletionRefresh = false;
351
+ this.pendingCompletionTurnId = null;
352
+ this.clearPendingCompletionTarget();
353
+ this.stopWatcherAfterRefreshThreadId = null;
354
+ } else {
355
+ this.pendingRefreshKinds.clear();
356
+ this.clearPendingTarget();
357
+ }
358
+
359
+ this.refreshRunning = true;
360
+ this.log(
361
+ `refresh running: ${Array.from(pendingRefreshKinds).join("+")}${targetThreadId ? ` thread=${targetThreadId}` : ""}`
362
+ );
325
363
 
326
- if (targetUrl) {
327
- await this.syncThreadStateBeforeRefresh(targetThreadId || null);
328
- await this.runRefresh(targetUrl, {
329
- reason,
330
- refreshKinds,
331
- isCompletionRun: false,
332
- });
364
+ let didRefresh = false;
365
+ try {
366
+ const refreshSignature = `${targetUrl || "app"}|${targetThreadId || "no-thread"}`;
367
+ if (
368
+ !shouldForceCompletionRefresh
369
+ && refreshSignature === this.lastRefreshSignature
370
+ && this.now() - this.lastRefreshAt < this.debounceMs
371
+ ) {
372
+ this.log(`refresh skipped (duplicate target): ${refreshSignature}`);
373
+ } else {
374
+ await this.executeRefresh(targetUrl);
375
+ this.lastRefreshAt = this.now();
376
+ this.lastRefreshSignature = refreshSignature;
377
+ this.consecutiveRefreshFailures = 0;
378
+ didRefresh = true;
333
379
  }
334
- } catch (error) {
335
- this.consecutiveRefreshFailures += 1;
336
- const message = extractErrorMessage(error);
337
- this.log(`refresh failed (${reason}): ${message}`);
338
- if (this.consecutiveRefreshFailures >= this.customRefreshFailureThreshold || isDesktopUnavailableError(message)) {
339
- this.disableRuntimeRefresh(message);
380
+ if (completionTurnId && didRefresh) {
381
+ this.lastTurnIdRefreshed = completionTurnId;
340
382
  }
383
+ } catch (error) {
384
+ this.handleRefreshFailure(error);
341
385
  } finally {
342
386
  this.refreshRunning = false;
387
+ if (
388
+ didRefresh
389
+ && stopWatcherAfterRefreshThreadId
390
+ && stopWatcherAfterRefreshThreadId === this.activeWatchedThreadId
391
+ ) {
392
+ this.stopWatcher();
393
+ this.mode = this.pendingNewThread ? "pending_new_thread" : "idle";
394
+ }
395
+ // A completion refresh can queue while another refresh is still running,
396
+ // so retry whenever either queue still has work.
343
397
  if (this.hasPendingRefreshWork()) {
344
- this.scheduleRefresh("follow-up refresh");
398
+ this.scheduleRefresh("pending follow-up refresh");
345
399
  }
346
400
  }
347
401
  }
348
402
 
349
- async syncThreadStateBeforeRefresh(threadId) {
350
- if (!threadId || typeof this.refreshThreadState !== "function") {
351
- return;
403
+ executeRefresh(targetUrl) {
404
+ if (typeof this.refreshExecutor === "function") {
405
+ return this.refreshExecutor({
406
+ targetUrl,
407
+ bundleId: this.bundleId,
408
+ appPath: this.appPath,
409
+ });
352
410
  }
353
411
 
354
- try {
355
- await this.refreshThreadState({ threadId });
356
- } catch (error) {
357
- this.log(`thread state refresh failed for ${threadId}: ${extractErrorMessage(error)}`);
412
+ if (this.refreshBackend === "command") {
413
+ return execFilePromise("sh", ["-lc", this.refreshCommand]);
358
414
  }
415
+
416
+ return execFilePromise("osascript", [
417
+ REFRESH_SCRIPT_PATH,
418
+ this.bundleId,
419
+ this.appPath,
420
+ targetUrl || "",
421
+ ]);
422
+ }
423
+
424
+ clearPendingCompletionTarget() {
425
+ this.pendingCompletionTargetUrl = "";
426
+ this.pendingCompletionTargetThreadId = "";
359
427
  }
360
428
 
361
- async runRefresh(targetUrl, { reason, refreshKinds = new Set(), isCompletionRun = false } = {}) {
362
- const signature = `${targetUrl}|${isCompletionRun ? "completion" : "activity"}`;
363
- if (
364
- !isCompletionRun
365
- && signature === this.lastRefreshSignature
366
- && (this.now() - this.lastRefreshAt) < this.midRunRefreshThrottleMs
367
- ) {
429
+ handleWatcherEvent(event) {
430
+ if (!event?.threadId || event.threadId !== this.activeWatchedThreadId) {
368
431
  return;
369
432
  }
370
433
 
371
- this.lastRefreshSignature = signature;
372
- this.lastRefreshAt = this.now();
373
- this.consecutiveRefreshFailures = 0;
374
- this.log(`refreshing desktop (${reason}) -> ${targetUrl}`);
434
+ const previousSize = this.lastRolloutSize;
435
+ this.lastRolloutSize = event.size;
436
+ this.noteRefreshTarget({
437
+ threadId: event.threadId,
438
+ url: buildThreadDeepLink(event.threadId),
439
+ });
375
440
 
376
- if (typeof this.refreshExecutor === "function") {
377
- await this.refreshExecutor({
378
- targetUrl,
379
- bundleId: this.bundleId,
380
- appPath: this.appPath,
381
- });
441
+ if (event.reason === "materialized") {
442
+ this.queueRefresh("rollout_materialized", {
443
+ threadId: event.threadId,
444
+ url: buildThreadDeepLink(event.threadId),
445
+ }, `rollout ${event.reason}`);
382
446
  return;
383
447
  }
384
448
 
385
- if (this.refreshBackend === "command") {
386
- await execFilePromise("sh", ["-lc", this.refreshCommand]);
449
+ if (event.reason !== "growth") {
387
450
  return;
388
451
  }
389
452
 
390
- if (this.refreshBackend === "applescript") {
391
- await execFilePromise("osascript", [
392
- REFRESH_SCRIPT_PATH,
393
- this.bundleId,
394
- this.appPath,
395
- targetUrl,
396
- ]);
453
+ if (previousSize == null) {
454
+ this.queueRefresh("rollout_growth", {
455
+ threadId: event.threadId,
456
+ url: buildThreadDeepLink(event.threadId),
457
+ }, "rollout first-growth");
458
+ this.lastMidRunRefreshAt = this.now();
397
459
  return;
398
460
  }
461
+
462
+ if (this.now() - this.lastMidRunRefreshAt < this.midRunRefreshThrottleMs) {
463
+ return;
464
+ }
465
+
466
+ this.lastMidRunRefreshAt = this.now();
467
+ this.queueRefresh("rollout_growth", {
468
+ threadId: event.threadId,
469
+ url: buildThreadDeepLink(event.threadId),
470
+ }, "rollout mid-run");
399
471
  }
400
472
 
401
473
  log(message) {
402
474
  console.log(`${this.logPrefix} ${message}`);
403
475
  }
404
476
 
477
+ handleRefreshFailure(error) {
478
+ const message = extractErrorMessage(error);
479
+ console.error(`${this.logPrefix} refresh failed: ${message}`);
480
+
481
+ if (this.refreshBackend === "applescript" && isDesktopUnavailableError(message)) {
482
+ this.disableRuntimeRefresh("desktop refresh unavailable on this Mac");
483
+ return;
484
+ }
485
+
486
+ if (this.refreshBackend === "command") {
487
+ this.consecutiveRefreshFailures += 1;
488
+ if (this.consecutiveRefreshFailures >= this.customRefreshFailureThreshold) {
489
+ this.disableRuntimeRefresh("custom refresh command kept failing");
490
+ }
491
+ }
492
+ }
493
+
405
494
  disableRuntimeRefresh(reason) {
406
495
  if (!this.runtimeRefreshAvailable) {
407
496
  return;
@@ -99,6 +99,7 @@ async function startMacOSBridgeService({
99
99
  osImpl,
100
100
  nodePath,
101
101
  cliPath,
102
+ refreshEnabled: Boolean(nextConfig.refreshEnabled),
102
103
  });
103
104
  restartLaunchAgent({
104
105
  env,
@@ -172,6 +173,7 @@ function getMacOSBridgeServiceStatus({
172
173
  const pairingFreshness = classifyPairingPayloadFreshness(pairingSession);
173
174
  const duplicateBridgeProcesses = listDuplicateBridgeProcesses({ execFileSyncImpl, launchdPid: launchd.pid });
174
175
  const deviceState = runCatchingLoadBridgeDeviceState(loadBridgeDeviceStateImpl);
176
+ const daemonConfig = readDaemonConfig({ env, fsImpl });
175
177
  return {
176
178
  label: SERVICE_LABEL,
177
179
  platform: "darwin",
@@ -182,6 +184,7 @@ function getMacOSBridgeServiceStatus({
182
184
  pairingSession,
183
185
  pairingFreshness,
184
186
  hasTrustedPhone: hasTrustedPhones(deviceState),
187
+ refreshEnabled: Boolean(daemonConfig?.refreshEnabled),
185
188
  duplicateBridgeProcesses,
186
189
  stdoutLogPath: resolveBridgeStdoutLogPath({ env }),
187
190
  stderrLogPath: resolveBridgeStderrLogPath({ env }),
@@ -200,6 +203,7 @@ function printMacOSBridgeServiceStatus(options = {}) {
200
203
  console.log(`[androdex] Bridge state: ${bridgeState}`);
201
204
  console.log(`[androdex] Connection: ${connectionStatus}`);
202
205
  console.log(`[androdex] Trusted phone: ${status.hasTrustedPhone ? "yes" : "no"}`);
206
+ console.log(`[androdex] Desktop refresh: ${status.refreshEnabled ? "enabled" : "disabled"}`);
203
207
  console.log(`[androdex] Pairing payload: ${pairingCreatedAt} (${status.pairingFreshness})`);
204
208
  if (status.duplicateBridgeProcesses.length > 0) {
205
209
  console.log(`[androdex] Duplicate bridge processes: ${status.duplicateBridgeProcesses.length}`);
@@ -227,6 +231,7 @@ function writeLaunchAgentPlist({
227
231
  osImpl = os,
228
232
  nodePath = process.execPath,
229
233
  cliPath = path.resolve(__dirname, "..", "bin", "androdex.js"),
234
+ refreshEnabled = false,
230
235
  } = {}) {
231
236
  const plistPath = resolveLaunchAgentPlistPath({ env, osImpl });
232
237
  const stateDir = resolveAndrodexStateDir({ env, osImpl });
@@ -241,6 +246,7 @@ function writeLaunchAgentPlist({
241
246
  stderrLogPath,
242
247
  nodePath,
243
248
  cliPath,
249
+ refreshEnabled,
244
250
  });
245
251
 
246
252
  fsImpl.mkdirSync(path.dirname(plistPath), { recursive: true });
@@ -256,6 +262,7 @@ function buildLaunchAgentPlist({
256
262
  stderrLogPath,
257
263
  nodePath,
258
264
  cliPath,
265
+ refreshEnabled = false,
259
266
  }) {
260
267
  return `<?xml version="1.0" encoding="UTF-8"?>
261
268
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -286,6 +293,8 @@ function buildLaunchAgentPlist({
286
293
  <string>${escapeXml(pathEnv)}</string>
287
294
  <key>ANDRODEX_DEVICE_STATE_DIR</key>
288
295
  <string>${escapeXml(stateDir)}</string>
296
+ <key>ANDRODEX_REFRESH_ENABLED</key>
297
+ <string>${escapeXml(refreshEnabled ? "true" : "false")}</string>
289
298
  </dict>
290
299
  <key>StandardOutPath</key>
291
300
  <string>${escapeXml(stdoutLogPath)}</string>
@@ -1,5 +1,5 @@
1
1
  -- FILE: codex-refresh.applescript
2
- -- Purpose: Reopens Codex on the requested route without detouring through Settings.
2
+ -- Purpose: Forces a non-destructive route bounce inside Codex so the target thread remounts without killing runs.
3
3
  -- Layer: UI automation helper
4
4
  -- Args: bundle id, app path fallback, optional target deep link
5
5
 
@@ -7,6 +7,7 @@ on run argv
7
7
  set bundleId to item 1 of argv
8
8
  set appPath to item 2 of argv
9
9
  set targetUrl to ""
10
+ set bounceUrl to "codex://settings"
10
11
 
11
12
  if (count of argv) is greater than or equal to 3 then
12
13
  set targetUrl to item 3 of argv
@@ -18,6 +19,9 @@ on run argv
18
19
 
19
20
  delay 0.12
20
21
 
22
+ my openCodexUrl(bundleId, appPath, bounceUrl)
23
+ delay 0.18
24
+
21
25
  if targetUrl is not "" then
22
26
  my openCodexUrl(bundleId, appPath, targetUrl)
23
27
  else
@@ -33,7 +37,7 @@ end run
33
37
  on openCodexUrl(bundleId, appPath, targetUrl)
34
38
  try
35
39
  if targetUrl is not "" then
36
- open location targetUrl
40
+ do shell script "open -b " & quoted form of bundleId & " " & quoted form of targetUrl
37
41
  else
38
42
  do shell script "open -b " & quoted form of bundleId
39
43
  end if
@@ -1,34 +0,0 @@
1
- // FILE: codex-desktop-thread-sync.js
2
- // Purpose: Refreshes app-server thread state from disk before nudging the desktop UI.
3
- // Layer: CLI helper
4
- // Exports: createDesktopThreadReadRefresher
5
- // Depends on: none
6
-
7
- function createDesktopThreadReadRefresher({
8
- sendCodexRequest,
9
- includeTurns = true,
10
- } = {}) {
11
- if (typeof sendCodexRequest !== "function") {
12
- throw new Error("createDesktopThreadReadRefresher requires a sendCodexRequest function.");
13
- }
14
-
15
- return function refreshDesktopThreadState({ threadId }) {
16
- const normalizedThreadId = readString(threadId);
17
- if (!normalizedThreadId) {
18
- return Promise.resolve(null);
19
- }
20
-
21
- return sendCodexRequest("thread/read", {
22
- threadId: normalizedThreadId,
23
- includeTurns,
24
- });
25
- };
26
- }
27
-
28
- function readString(value) {
29
- return typeof value === "string" && value.trim() ? value.trim() : "";
30
- }
31
-
32
- module.exports = {
33
- createDesktopThreadReadRefresher,
34
- };