androdex 1.1.3

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.
@@ -0,0 +1,723 @@
1
+ // FILE: codex-desktop-refresher.js
2
+ // Purpose: Debounced Mac desktop refresh controller for Codex.app after phone-authored conversation changes.
3
+ // Layer: CLI helper
4
+ // Exports: CodexDesktopRefresher, readBridgeConfig
5
+ // Depends on: child_process, path, ./codex-desktop-launcher, ./rollout-watch
6
+
7
+ const { execFile } = require("child_process");
8
+ const path = require("path");
9
+ const { openCodexDesktopTarget } = require("./codex-desktop-launcher");
10
+ const { createThreadRolloutActivityWatcher } = require("./rollout-watch");
11
+
12
+ const DEFAULT_BUNDLE_ID = "com.openai.codex";
13
+ const DEFAULT_APP_PATH = "/Applications/Codex.app";
14
+ const DEFAULT_DEBOUNCE_MS = 1200;
15
+ const DEFAULT_FALLBACK_NEW_THREAD_MS = 2_000;
16
+ const DEFAULT_MID_RUN_REFRESH_THROTTLE_MS = 3_000;
17
+ const DEFAULT_ROLLOUT_LOOKUP_TIMEOUT_MS = 5_000;
18
+ const DEFAULT_ROLLOUT_IDLE_TIMEOUT_MS = 10_000;
19
+ const DEFAULT_CUSTOM_REFRESH_FAILURE_THRESHOLD = 3;
20
+ const REFRESH_SCRIPT_PATH = path.join(__dirname, "scripts", "codex-refresh.applescript");
21
+ const NEW_THREAD_DEEP_LINK = "codex://threads/new";
22
+
23
+ class CodexDesktopRefresher {
24
+ constructor({
25
+ enabled = true,
26
+ debounceMs = DEFAULT_DEBOUNCE_MS,
27
+ refreshCommand = "",
28
+ bundleId = DEFAULT_BUNDLE_ID,
29
+ appPath = DEFAULT_APP_PATH,
30
+ platform = process.platform,
31
+ logPrefix = "[androdex]",
32
+ fallbackNewThreadMs = DEFAULT_FALLBACK_NEW_THREAD_MS,
33
+ midRunRefreshThrottleMs = DEFAULT_MID_RUN_REFRESH_THROTTLE_MS,
34
+ rolloutLookupTimeoutMs = DEFAULT_ROLLOUT_LOOKUP_TIMEOUT_MS,
35
+ rolloutIdleTimeoutMs = DEFAULT_ROLLOUT_IDLE_TIMEOUT_MS,
36
+ now = () => Date.now(),
37
+ refreshExecutor = null,
38
+ watchThreadRolloutFactory = createThreadRolloutActivityWatcher,
39
+ refreshBackend = null,
40
+ customRefreshFailureThreshold = DEFAULT_CUSTOM_REFRESH_FAILURE_THRESHOLD,
41
+ } = {}) {
42
+ this.enabled = enabled;
43
+ this.debounceMs = debounceMs;
44
+ this.refreshCommand = refreshCommand;
45
+ this.bundleId = bundleId;
46
+ this.appPath = appPath;
47
+ this.platform = platform;
48
+ this.logPrefix = logPrefix;
49
+ this.fallbackNewThreadMs = fallbackNewThreadMs;
50
+ this.midRunRefreshThrottleMs = midRunRefreshThrottleMs;
51
+ this.rolloutLookupTimeoutMs = rolloutLookupTimeoutMs;
52
+ this.rolloutIdleTimeoutMs = rolloutIdleTimeoutMs;
53
+ this.now = now;
54
+ this.refreshExecutor = refreshExecutor;
55
+ this.watchThreadRolloutFactory = watchThreadRolloutFactory;
56
+ this.refreshBackend = refreshBackend
57
+ || (this.refreshCommand
58
+ ? "command"
59
+ : (this.refreshExecutor
60
+ ? "command"
61
+ : (this.platform === "darwin" ? "applescript" : "protocol")));
62
+ this.customRefreshFailureThreshold = customRefreshFailureThreshold;
63
+
64
+ this.mode = "idle";
65
+ this.pendingNewThread = false;
66
+ this.pendingRefreshKinds = new Set();
67
+ this.pendingCompletionRefresh = false;
68
+ this.pendingCompletionTurnId = null;
69
+ this.pendingCompletionTargetUrl = "";
70
+ this.pendingCompletionTargetThreadId = "";
71
+ this.pendingTargetUrl = "";
72
+ this.pendingTargetThreadId = "";
73
+ this.lastRefreshAt = 0;
74
+ this.lastRefreshSignature = "";
75
+ this.lastTurnIdRefreshed = null;
76
+ this.lastMidRunRefreshAt = 0;
77
+ this.refreshTimer = null;
78
+ this.refreshRunning = false;
79
+ this.fallbackTimer = null;
80
+ this.activeWatcher = null;
81
+ this.activeWatchedThreadId = null;
82
+ this.watchStartAt = 0;
83
+ this.lastRolloutSize = null;
84
+ this.stopWatcherAfterRefreshThreadId = null;
85
+ this.runtimeRefreshAvailable = enabled;
86
+ this.consecutiveRefreshFailures = 0;
87
+ this.unavailableLogged = false;
88
+ }
89
+
90
+ handleInbound(rawMessage) {
91
+ const parsed = safeParseJSON(rawMessage);
92
+ if (!parsed) {
93
+ return;
94
+ }
95
+
96
+ const method = parsed.method;
97
+ if (method === "thread/start") {
98
+ const target = resolveInboundTarget(method, parsed);
99
+ if (target?.threadId) {
100
+ this.queueRefresh("phone", target, `phone ${method}`);
101
+ this.ensureWatcher(target.threadId);
102
+ return;
103
+ }
104
+
105
+ this.pendingNewThread = true;
106
+ this.mode = "pending_new_thread";
107
+ this.clearPendingTarget();
108
+ this.scheduleNewThreadFallback();
109
+ return;
110
+ }
111
+
112
+ if (method === "turn/start") {
113
+ const target = resolveInboundTarget(method, parsed);
114
+ if (!target) {
115
+ return;
116
+ }
117
+
118
+ this.queueRefresh("phone", target, `phone ${method}`);
119
+ if (target.threadId) {
120
+ this.ensureWatcher(target.threadId);
121
+ }
122
+ }
123
+ }
124
+
125
+ handleOutbound(rawMessage) {
126
+ const parsed = safeParseJSON(rawMessage);
127
+ if (!parsed) {
128
+ return;
129
+ }
130
+
131
+ const method = parsed.method;
132
+ if (method === "turn/completed") {
133
+ this.clearFallbackTimer();
134
+ const turnId = extractTurnId(parsed);
135
+ if (turnId && turnId === this.lastTurnIdRefreshed) {
136
+ this.log(`refresh skipped (debounced): completion already refreshed for ${turnId}`);
137
+ return;
138
+ }
139
+
140
+ const target = resolveOutboundTarget(method, parsed);
141
+ this.queueCompletionRefresh(target, turnId, `codex ${method}`);
142
+ return;
143
+ }
144
+
145
+ if (method === "thread/started") {
146
+ const target = resolveOutboundTarget(method, parsed);
147
+ this.pendingNewThread = false;
148
+ this.clearFallbackTimer();
149
+ this.queueRefresh("phone", target, `codex ${method}`);
150
+ if (target?.threadId) {
151
+ this.mode = "watching_thread";
152
+ this.ensureWatcher(target.threadId);
153
+ }
154
+ }
155
+ }
156
+
157
+ // Stops volatile watcher/fallback state when transport drops or bridge exits.
158
+ handleTransportReset() {
159
+ this.clearRefreshTimer();
160
+ this.clearPendingState();
161
+ this.lastRefreshAt = 0;
162
+ this.lastRefreshSignature = "";
163
+ this.mode = "idle";
164
+ this.clearFallbackTimer();
165
+ this.stopWatcher();
166
+ }
167
+
168
+ queueRefresh(kind, target, reason) {
169
+ this.noteRefreshTarget(target);
170
+ this.pendingRefreshKinds.add(kind);
171
+ this.scheduleRefresh(reason);
172
+ }
173
+
174
+ queueCompletionRefresh(target, turnId, reason) {
175
+ this.noteCompletionTarget(target);
176
+ this.pendingCompletionRefresh = true;
177
+ this.pendingCompletionTurnId = turnId;
178
+ this.stopWatcherAfterRefreshThreadId = target?.threadId || null;
179
+ this.scheduleRefresh(reason);
180
+ }
181
+
182
+ noteRefreshTarget(target) {
183
+ if (!target?.url) {
184
+ return;
185
+ }
186
+
187
+ this.pendingTargetUrl = target.url;
188
+ this.pendingTargetThreadId = target.threadId || "";
189
+ }
190
+
191
+ clearPendingTarget() {
192
+ this.pendingTargetUrl = "";
193
+ this.pendingTargetThreadId = "";
194
+ }
195
+
196
+ noteCompletionTarget(target) {
197
+ if (!target?.url) {
198
+ return;
199
+ }
200
+
201
+ this.pendingCompletionTargetUrl = target.url;
202
+ this.pendingCompletionTargetThreadId = target.threadId || "";
203
+ }
204
+
205
+ clearPendingCompletionTarget() {
206
+ this.pendingCompletionTargetUrl = "";
207
+ this.pendingCompletionTargetThreadId = "";
208
+ }
209
+
210
+ scheduleRefresh(reason) {
211
+ if (!this.canRefresh()) {
212
+ return;
213
+ }
214
+
215
+ if (this.refreshTimer) {
216
+ this.log(`refresh already pending: ${reason}`);
217
+ return;
218
+ }
219
+
220
+ const elapsedSinceLastRefresh = this.now() - this.lastRefreshAt;
221
+ const waitMs = Math.max(0, this.debounceMs - elapsedSinceLastRefresh);
222
+ this.log(`refresh scheduled: ${reason}`);
223
+ this.refreshTimer = setTimeout(() => {
224
+ this.refreshTimer = null;
225
+ void this.runPendingRefresh();
226
+ }, waitMs);
227
+ }
228
+
229
+ async runPendingRefresh() {
230
+ if (!this.canRefresh()) {
231
+ this.clearPendingState();
232
+ return;
233
+ }
234
+
235
+ if (!this.hasPendingRefreshWork()) {
236
+ return;
237
+ }
238
+
239
+ if (this.refreshRunning) {
240
+ this.log("refresh skipped (debounced): another refresh is already running");
241
+ return;
242
+ }
243
+
244
+ const isCompletionRun = this.pendingCompletionRefresh;
245
+ const pendingRefreshKinds = isCompletionRun
246
+ ? new Set(["completion"])
247
+ : new Set(this.pendingRefreshKinds);
248
+ const completionTurnId = this.pendingCompletionTurnId;
249
+ const targetUrl = isCompletionRun ? this.pendingCompletionTargetUrl : this.pendingTargetUrl;
250
+ const targetThreadId = isCompletionRun
251
+ ? this.pendingCompletionTargetThreadId
252
+ : this.pendingTargetThreadId;
253
+ const stopWatcherAfterRefreshThreadId = isCompletionRun
254
+ ? this.stopWatcherAfterRefreshThreadId
255
+ : null;
256
+ const shouldForceCompletionRefresh = isCompletionRun;
257
+
258
+ if (isCompletionRun) {
259
+ this.pendingCompletionRefresh = false;
260
+ this.pendingCompletionTurnId = null;
261
+ this.clearPendingCompletionTarget();
262
+ this.stopWatcherAfterRefreshThreadId = null;
263
+ } else {
264
+ this.pendingRefreshKinds.clear();
265
+ this.clearPendingTarget();
266
+ }
267
+ this.refreshRunning = true;
268
+ this.log(
269
+ `refresh running: ${Array.from(pendingRefreshKinds).join("+")}${targetThreadId ? ` thread=${targetThreadId}` : ""}`
270
+ );
271
+
272
+ let didRefresh = false;
273
+ try {
274
+ const refreshSignature = `${targetUrl || "app"}|${targetThreadId || "no-thread"}`;
275
+ if (
276
+ !shouldForceCompletionRefresh
277
+ && refreshSignature === this.lastRefreshSignature
278
+ && this.now() - this.lastRefreshAt < this.debounceMs
279
+ ) {
280
+ this.log(`refresh skipped (duplicate target): ${refreshSignature}`);
281
+ } else {
282
+ await this.executeRefresh(targetUrl);
283
+ this.lastRefreshAt = this.now();
284
+ this.lastRefreshSignature = refreshSignature;
285
+ this.consecutiveRefreshFailures = 0;
286
+ didRefresh = true;
287
+ }
288
+ if (completionTurnId && didRefresh) {
289
+ this.lastTurnIdRefreshed = completionTurnId;
290
+ }
291
+ } catch (error) {
292
+ this.handleRefreshFailure(error);
293
+ } finally {
294
+ this.refreshRunning = false;
295
+ if (
296
+ didRefresh
297
+ && stopWatcherAfterRefreshThreadId
298
+ && stopWatcherAfterRefreshThreadId === this.activeWatchedThreadId
299
+ ) {
300
+ this.stopWatcher();
301
+ this.mode = this.pendingNewThread ? "pending_new_thread" : "idle";
302
+ }
303
+ // A completion refresh can queue while another refresh is still running,
304
+ // so retry whenever either queue still has work.
305
+ if (this.hasPendingRefreshWork()) {
306
+ this.scheduleRefresh("pending follow-up refresh");
307
+ }
308
+ }
309
+ }
310
+
311
+ executeRefresh(targetUrl) {
312
+ if (this.refreshExecutor) {
313
+ return this.refreshExecutor(targetUrl || "");
314
+ }
315
+
316
+ if (this.refreshCommand) {
317
+ if (this.platform === "win32") {
318
+ return execFilePromise("cmd.exe", ["/d", "/c", this.refreshCommand], {
319
+ windowsHide: true,
320
+ });
321
+ }
322
+
323
+ return execFilePromise("/bin/sh", ["-lc", this.refreshCommand]);
324
+ }
325
+
326
+ if (this.refreshBackend === "applescript") {
327
+ return execFilePromise("osascript", [
328
+ REFRESH_SCRIPT_PATH,
329
+ this.bundleId,
330
+ this.appPath,
331
+ targetUrl || "",
332
+ ]);
333
+ }
334
+
335
+ return openCodexDesktopTarget({
336
+ targetUrl,
337
+ bundleId: this.bundleId,
338
+ appPath: this.appPath,
339
+ platform: this.platform,
340
+ });
341
+ }
342
+
343
+ clearPendingState() {
344
+ this.pendingNewThread = false;
345
+ this.pendingRefreshKinds.clear();
346
+ this.pendingCompletionRefresh = false;
347
+ this.pendingCompletionTurnId = null;
348
+ this.clearPendingCompletionTarget();
349
+ this.clearPendingTarget();
350
+ this.stopWatcherAfterRefreshThreadId = null;
351
+ }
352
+
353
+ clearRefreshTimer() {
354
+ if (!this.refreshTimer) {
355
+ return;
356
+ }
357
+
358
+ clearTimeout(this.refreshTimer);
359
+ this.refreshTimer = null;
360
+ }
361
+
362
+ // Schedules a single low-cost fallback when a brand new thread id is still unknown.
363
+ scheduleNewThreadFallback() {
364
+ if (!this.canRefresh()) {
365
+ return;
366
+ }
367
+
368
+ if (this.fallbackTimer) {
369
+ return;
370
+ }
371
+
372
+ this.fallbackTimer = setTimeout(() => {
373
+ this.fallbackTimer = null;
374
+ if (!this.pendingNewThread || this.pendingTargetThreadId) {
375
+ return;
376
+ }
377
+
378
+ this.noteRefreshTarget({ threadId: null, url: NEW_THREAD_DEEP_LINK });
379
+ this.pendingRefreshKinds.add("phone");
380
+ this.scheduleRefresh("fallback thread/start");
381
+ }, this.fallbackNewThreadMs);
382
+ }
383
+
384
+ clearFallbackTimer() {
385
+ if (!this.fallbackTimer) {
386
+ return;
387
+ }
388
+
389
+ clearTimeout(this.fallbackTimer);
390
+ this.fallbackTimer = null;
391
+ }
392
+
393
+ // Keeps one lightweight rollout watcher alive for the current Androdex-controlled thread.
394
+ ensureWatcher(threadId) {
395
+ if (!this.canRefresh() || !threadId) {
396
+ return;
397
+ }
398
+
399
+ if (this.activeWatchedThreadId === threadId && this.activeWatcher) {
400
+ return;
401
+ }
402
+
403
+ this.stopWatcher();
404
+ this.activeWatchedThreadId = threadId;
405
+ this.watchStartAt = this.now();
406
+ this.lastRolloutSize = null;
407
+ this.mode = "watching_thread";
408
+ this.activeWatcher = this.watchThreadRolloutFactory({
409
+ threadId,
410
+ lookupTimeoutMs: this.rolloutLookupTimeoutMs,
411
+ idleTimeoutMs: this.rolloutIdleTimeoutMs,
412
+ onEvent: (event) => this.handleWatcherEvent(event),
413
+ onIdle: () => {
414
+ this.log(`rollout watcher idle thread=${threadId}`);
415
+ this.stopWatcher();
416
+ this.mode = this.pendingNewThread ? "pending_new_thread" : "idle";
417
+ },
418
+ onTimeout: () => {
419
+ this.log(`rollout watcher timeout thread=${threadId}`);
420
+ this.stopWatcher();
421
+ this.mode = this.pendingNewThread ? "pending_new_thread" : "idle";
422
+ },
423
+ onError: (error) => {
424
+ this.log(`rollout watcher failed thread=${threadId}: ${error.message}`);
425
+ this.stopWatcher();
426
+ this.mode = this.pendingNewThread ? "pending_new_thread" : "idle";
427
+ },
428
+ });
429
+ }
430
+
431
+ stopWatcher() {
432
+ if (!this.activeWatcher) {
433
+ this.activeWatchedThreadId = null;
434
+ this.watchStartAt = 0;
435
+ this.lastRolloutSize = null;
436
+ return;
437
+ }
438
+
439
+ this.activeWatcher.stop();
440
+ this.activeWatcher = null;
441
+ this.activeWatchedThreadId = null;
442
+ this.watchStartAt = 0;
443
+ this.lastRolloutSize = null;
444
+ }
445
+
446
+ // Converts rollout growth into occasional refreshes without spamming the desktop.
447
+ handleWatcherEvent(event) {
448
+ if (!event?.threadId || event.threadId !== this.activeWatchedThreadId) {
449
+ return;
450
+ }
451
+
452
+ const previousSize = this.lastRolloutSize;
453
+ this.lastRolloutSize = event.size;
454
+ this.noteRefreshTarget({
455
+ threadId: event.threadId,
456
+ url: buildThreadDeepLink(event.threadId),
457
+ });
458
+
459
+ if (event.reason === "materialized") {
460
+ this.queueRefresh("rollout_materialized", {
461
+ threadId: event.threadId,
462
+ url: buildThreadDeepLink(event.threadId),
463
+ }, `rollout ${event.reason}`);
464
+ return;
465
+ }
466
+
467
+ if (event.reason !== "growth") {
468
+ return;
469
+ }
470
+
471
+ if (previousSize == null) {
472
+ this.queueRefresh("rollout_growth", {
473
+ threadId: event.threadId,
474
+ url: buildThreadDeepLink(event.threadId),
475
+ }, "rollout first-growth");
476
+ this.lastMidRunRefreshAt = this.now();
477
+ return;
478
+ }
479
+
480
+ if (this.now() - this.lastMidRunRefreshAt < this.midRunRefreshThrottleMs) {
481
+ return;
482
+ }
483
+
484
+ this.lastMidRunRefreshAt = this.now();
485
+ this.queueRefresh("rollout_growth", {
486
+ threadId: event.threadId,
487
+ url: buildThreadDeepLink(event.threadId),
488
+ }, "rollout mid-run");
489
+ }
490
+
491
+ log(message) {
492
+ console.log(`${this.logPrefix} ${message}`);
493
+ }
494
+
495
+ handleRefreshFailure(error) {
496
+ const message = extractErrorMessage(error);
497
+ console.error(`${this.logPrefix} refresh failed: ${message}`);
498
+
499
+ if (this.refreshBackend === "applescript" && isDesktopUnavailableError(message)) {
500
+ this.disableRuntimeRefresh("desktop refresh unavailable on this Mac");
501
+ return;
502
+ }
503
+
504
+ if (this.refreshBackend === "command") {
505
+ this.consecutiveRefreshFailures += 1;
506
+ if (this.consecutiveRefreshFailures >= this.customRefreshFailureThreshold) {
507
+ this.disableRuntimeRefresh("custom refresh command kept failing");
508
+ }
509
+ return;
510
+ }
511
+
512
+ if (this.refreshBackend === "protocol") {
513
+ this.disableRuntimeRefresh("desktop protocol refresh is unavailable on this host");
514
+ }
515
+ }
516
+
517
+ disableRuntimeRefresh(reason) {
518
+ if (!this.runtimeRefreshAvailable) {
519
+ return;
520
+ }
521
+
522
+ this.runtimeRefreshAvailable = false;
523
+ this.clearRefreshTimer();
524
+ this.clearFallbackTimer();
525
+ this.stopWatcher();
526
+ this.clearPendingState();
527
+ this.mode = "idle";
528
+
529
+ if (!this.unavailableLogged) {
530
+ console.error(`${this.logPrefix} desktop refresh disabled until restart: ${reason}`);
531
+ this.unavailableLogged = true;
532
+ }
533
+ }
534
+
535
+ canRefresh() {
536
+ return this.enabled && this.runtimeRefreshAvailable;
537
+ }
538
+
539
+ // Tells the debounce loop whether any phone/completion refresh is still waiting to run.
540
+ hasPendingRefreshWork() {
541
+ return this.pendingCompletionRefresh || this.pendingRefreshKinds.size > 0;
542
+ }
543
+ }
544
+
545
+ function readBridgeConfig({ env = process.env, platform = process.platform } = {}) {
546
+ const codexEndpoint = readFirstDefinedEnv(["ANDRODEX_CODEX_ENDPOINT"], "", env);
547
+ const refreshCommand = readFirstDefinedEnv(["ANDRODEX_REFRESH_COMMAND"], "", env);
548
+ const explicitRefreshEnabled = readOptionalBooleanEnv(["ANDRODEX_REFRESH_ENABLED"], env);
549
+ // Windows uses protocol deep links to keep the desktop client aligned with
550
+ // phone-authored activity. macOS keeps the more conservative opt-in default
551
+ // because it still depends on the AppleScript workaround.
552
+ const defaultRefreshEnabled = platform === "win32";
553
+ return {
554
+ relayUrl: readFirstDefinedEnv(["ANDRODEX_RELAY"], "", env),
555
+ refreshEnabled: explicitRefreshEnabled == null
556
+ ? defaultRefreshEnabled
557
+ : explicitRefreshEnabled,
558
+ refreshDebounceMs: parseIntegerEnv(
559
+ readFirstDefinedEnv(["ANDRODEX_REFRESH_DEBOUNCE_MS"], String(DEFAULT_DEBOUNCE_MS), env),
560
+ DEFAULT_DEBOUNCE_MS
561
+ ),
562
+ codexEndpoint,
563
+ refreshCommand,
564
+ codexBundleId: readFirstDefinedEnv(
565
+ ["ANDRODEX_CODEX_BUNDLE_ID"],
566
+ DEFAULT_BUNDLE_ID,
567
+ env
568
+ ),
569
+ codexAppPath: DEFAULT_APP_PATH,
570
+ };
571
+ }
572
+
573
+ function execFilePromise(command, args) {
574
+ return new Promise((resolve, reject) => {
575
+ execFile(command, args, (error, stdout, stderr) => {
576
+ if (error) {
577
+ error.stdout = stdout;
578
+ error.stderr = stderr;
579
+ reject(error);
580
+ return;
581
+ }
582
+ resolve({ stdout, stderr });
583
+ });
584
+ });
585
+ }
586
+
587
+ function safeParseJSON(value) {
588
+ try {
589
+ return JSON.parse(value);
590
+ } catch {
591
+ return null;
592
+ }
593
+ }
594
+
595
+ function extractTurnId(message) {
596
+ const params = message?.params;
597
+ if (!params || typeof params !== "object") {
598
+ return null;
599
+ }
600
+
601
+ if (typeof params.turnId === "string" && params.turnId) {
602
+ return params.turnId;
603
+ }
604
+
605
+ if (params.turn && typeof params.turn === "object" && typeof params.turn.id === "string") {
606
+ return params.turn.id;
607
+ }
608
+
609
+ return null;
610
+ }
611
+
612
+ function extractThreadId(message) {
613
+ const params = message?.params;
614
+ if (!params || typeof params !== "object") {
615
+ return null;
616
+ }
617
+
618
+ const candidates = [
619
+ params.threadId,
620
+ params.conversationId,
621
+ params.thread?.id,
622
+ params.thread?.threadId,
623
+ params.turn?.threadId,
624
+ params.turn?.conversationId,
625
+ ];
626
+
627
+ for (const candidate of candidates) {
628
+ if (typeof candidate === "string" && candidate) {
629
+ return candidate;
630
+ }
631
+ }
632
+
633
+ return null;
634
+ }
635
+
636
+ function resolveInboundTarget(method, message) {
637
+ const threadId = extractThreadId(message);
638
+ if (threadId) {
639
+ return { threadId, url: buildThreadDeepLink(threadId) };
640
+ }
641
+
642
+ if (method === "thread/start" || method === "turn/start") {
643
+ return { threadId: null, url: NEW_THREAD_DEEP_LINK };
644
+ }
645
+
646
+ return null;
647
+ }
648
+
649
+ function resolveOutboundTarget(method, message) {
650
+ const threadId = extractThreadId(message);
651
+ if (threadId) {
652
+ return { threadId, url: buildThreadDeepLink(threadId) };
653
+ }
654
+
655
+ if (method === "thread/started") {
656
+ return { threadId: null, url: NEW_THREAD_DEEP_LINK };
657
+ }
658
+
659
+ return null;
660
+ }
661
+
662
+ function buildThreadDeepLink(threadId) {
663
+ return `codex://threads/${threadId}`;
664
+ }
665
+
666
+ function readOptionalBooleanEnv(keys, env = process.env) {
667
+ for (const key of keys) {
668
+ const value = env[key];
669
+ if (typeof value === "string" && value.trim() !== "") {
670
+ return parseBooleanEnv(value.trim());
671
+ }
672
+ }
673
+ return null;
674
+ }
675
+
676
+ function readFirstDefinedEnv(keys, fallback, env = process.env) {
677
+ for (const key of keys) {
678
+ const value = env[key];
679
+ if (typeof value === "string" && value.trim() !== "") {
680
+ return value.trim();
681
+ }
682
+ }
683
+ return fallback;
684
+ }
685
+
686
+ function parseBooleanEnv(value) {
687
+ const normalized = String(value).trim().toLowerCase();
688
+ return normalized !== "false" && normalized !== "0" && normalized !== "no";
689
+ }
690
+
691
+ function parseIntegerEnv(value, fallback) {
692
+ const parsed = Number.parseInt(String(value), 10);
693
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
694
+ }
695
+
696
+ function extractErrorMessage(error) {
697
+ return (
698
+ error?.stderr?.toString("utf8")
699
+ || error?.stdout?.toString("utf8")
700
+ || error?.message
701
+ || "unknown refresh error"
702
+ ).trim();
703
+ }
704
+
705
+ function isDesktopUnavailableError(message) {
706
+ const normalized = String(message).toLowerCase();
707
+ return [
708
+ "unable to find application named",
709
+ "application isn’t running",
710
+ "application isn't running",
711
+ "can’t get application id",
712
+ "can't get application id",
713
+ "does not exist",
714
+ "no application knows how to open",
715
+ "cannot find app",
716
+ "could not find application",
717
+ ].some((snippet) => normalized.includes(snippet));
718
+ }
719
+
720
+ module.exports = {
721
+ CodexDesktopRefresher,
722
+ readBridgeConfig,
723
+ };