@makerbi/remodex 1.3.8

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