@btatum5/codex-bridge 0.1.0 → 1.3.2

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