@btatum5/codex-bridge 0.1.0 → 1.3.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.
- package/README.md +473 -23
- package/bin/codex-bridge.js +136 -100
- package/bin/phodex.js +8 -0
- package/bin/remodex.js +8 -0
- package/package.json +38 -24
- package/src/bridge.js +622 -0
- package/src/codex-desktop-refresher.js +776 -0
- package/src/codex-transport.js +238 -0
- package/src/daemon-state.js +170 -0
- package/src/desktop-handler.js +407 -0
- package/src/git-handler.js +1267 -0
- package/src/index.js +35 -0
- package/src/macos-launch-agent.js +457 -0
- package/src/notifications-handler.js +95 -0
- package/src/push-notification-completion-dedupe.js +147 -0
- package/src/push-notification-service-client.js +151 -0
- package/src/push-notification-tracker.js +688 -0
- package/src/qr.js +19 -0
- package/src/rollout-live-mirror.js +730 -0
- package/src/rollout-watch.js +853 -0
- package/src/scripts/codex-handoff.applescript +100 -0
- package/src/scripts/codex-refresh.applescript +51 -0
- package/src/secure-device-state.js +430 -0
- package/src/secure-transport.js +738 -0
- package/src/session-state.js +62 -0
- package/src/thread-context-handler.js +80 -0
- package/src/workspace-handler.js +464 -0
- package/server.mjs +0 -290
|
@@ -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
|
+
};
|