relay-companion 0.1.21 → 0.1.23
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/package.json +1 -1
- package/src/codex-desktop.js +113 -62
- package/src/materializer.js +11 -4
package/package.json
CHANGED
package/src/codex-desktop.js
CHANGED
|
@@ -46,7 +46,10 @@ export async function notifyCodexDesktopThreads({
|
|
|
46
46
|
threadIds = [],
|
|
47
47
|
pinnedThreadIds = null,
|
|
48
48
|
openThreadId = null,
|
|
49
|
-
|
|
49
|
+
primeOpen = true,
|
|
50
|
+
// The open path navigates hotkey -> (prime wait) -> /local inside the renderer,
|
|
51
|
+
// so the inspector eval must outlast RELAY_CODEX_OPEN_PRIME_MS; default higher.
|
|
52
|
+
timeoutMs = Number(process.env.RELAY_CODEX_DESKTOP_TIMEOUT_MS || 8000),
|
|
50
53
|
} = {}) {
|
|
51
54
|
if (process.env.RELAY_CODEX_DESKTOP_REFRESH === "0") return { attempted: false, reason: "disabled" };
|
|
52
55
|
if (process.platform !== "darwin") return { attempted: false, reason: "not-darwin" };
|
|
@@ -59,7 +62,7 @@ export async function notifyCodexDesktopThreads({
|
|
|
59
62
|
const pids = await findCodexMainPids();
|
|
60
63
|
if (!pids.length) return { attempted: true, ok: false, reason: "codex-not-running", results: [] };
|
|
61
64
|
|
|
62
|
-
const expression = buildCodexDesktopRefreshExpression({ threadIds: ids, pinnedThreadIds: pinnedIds, openThreadId: openId });
|
|
65
|
+
const expression = buildCodexDesktopRefreshExpression({ threadIds: ids, pinnedThreadIds: pinnedIds, openThreadId: openId, primeOpen });
|
|
63
66
|
const results = [];
|
|
64
67
|
for (const pid of pids) {
|
|
65
68
|
try {
|
|
@@ -78,87 +81,135 @@ export async function notifyCodexDesktopThreads({
|
|
|
78
81
|
return { attempted: true, ok: results.some((result) => result.ok), results };
|
|
79
82
|
}
|
|
80
83
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
84
|
+
// Runs INSIDE each Codex renderer: it is serialized to a string via toString()
|
|
85
|
+
// and evaluated in the window's page context, so it must be self-contained — no
|
|
86
|
+
// imports, only renderer globals (window, location). Exported at module scope so
|
|
87
|
+
// the thread-routing decision can be unit-tested directly.
|
|
88
|
+
export async function relayRefreshCodexRenderer(payload) {
|
|
89
|
+
const hostId = "local";
|
|
90
|
+
const bridge = window.electronBridge;
|
|
91
|
+
if (!bridge?.sendMessageFromView) return { ok: false, reason: "missing-electron-bridge" };
|
|
86
92
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
93
|
+
const sent = [];
|
|
94
|
+
// Bound every bridge send. Some host messages (notably maybe-resume-conversation)
|
|
95
|
+
// can hang indefinitely; the original serial awaits then block the navigation
|
|
96
|
+
// that follows, leaving the thread unopened. Cap each send so the open proceeds.
|
|
97
|
+
async function send(message, timeoutMs = 2000) {
|
|
98
|
+
try {
|
|
99
|
+
await Promise.race([
|
|
100
|
+
bridge.sendMessageFromView(message),
|
|
101
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("send-timeout")), timeoutMs)),
|
|
102
|
+
]);
|
|
103
|
+
sent.push({ type: message.type, ok: true });
|
|
104
|
+
} catch (error) {
|
|
105
|
+
sent.push({ type: message.type, ok: false, error: String(error?.message || error) });
|
|
95
106
|
}
|
|
107
|
+
}
|
|
96
108
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
109
|
+
if (Array.isArray(payload.pinnedThreadIds)) {
|
|
110
|
+
await send({ type: "set-pinned-thread-ids-for-host", hostId, threadIds: payload.pinnedThreadIds });
|
|
111
|
+
}
|
|
112
|
+
if (payload.threadIds.length) {
|
|
113
|
+
await send({ type: "hydrate-pinned-threads", hostId, threadIds: payload.threadIds });
|
|
114
|
+
}
|
|
115
|
+
await send({ type: "refresh-recent-conversations-for-host", hostId });
|
|
116
|
+
for (const threadId of payload.threadIds) {
|
|
117
|
+
// Codex Desktop (>=0.142) serves thread views from its in-memory core and
|
|
118
|
+
// never cold-loads externally created threads — the view spins forever
|
|
119
|
+
// until an app restart. Adopt the thread the way the app itself opens one:
|
|
120
|
+
// drop any stale cache entry, ask the tail-hydration path to load history
|
|
121
|
+
// (feature-gated; harmless when off — dependentConversationIds is REQUIRED
|
|
122
|
+
// or the handler throws), then maybe-resume-conversation, which activates
|
|
123
|
+
// the thread summary and resumes the conversation from the rollout on disk.
|
|
124
|
+
await send({ type: "discard-conversation-from-cache", conversationId: threadId });
|
|
125
|
+
await send({ type: "ensure-conversation-history-loaded", conversationId: threadId, dependentConversationIds: [] });
|
|
126
|
+
await send({
|
|
127
|
+
type: "maybe-resume-conversation",
|
|
128
|
+
hostId,
|
|
129
|
+
conversationId: threadId,
|
|
130
|
+
model: null,
|
|
131
|
+
serviceTier: null,
|
|
132
|
+
reasoningEffort: null,
|
|
133
|
+
workspaceRoots: [],
|
|
134
|
+
collaborationMode: null,
|
|
135
|
+
});
|
|
136
|
+
await send({ type: "broadcast-conversation-snapshot-for-host", hostId, conversationId: threadId });
|
|
137
|
+
}
|
|
138
|
+
if (payload.openThreadId) {
|
|
139
|
+
const encoded = encodeURIComponent(String(payload.openThreadId));
|
|
140
|
+
const navTo = (path) => {
|
|
127
141
|
try {
|
|
128
|
-
window.postMessage(
|
|
129
|
-
|
|
130
|
-
type: "navigate-to-route",
|
|
131
|
-
path: `/hotkey-window/thread/${encodeURIComponent(String(payload.openThreadId))}`,
|
|
132
|
-
},
|
|
133
|
-
location.origin,
|
|
134
|
-
);
|
|
135
|
-
sent.push({ type: "navigate-to-route", ok: true, threadId: payload.openThreadId });
|
|
142
|
+
window.postMessage({ type: "navigate-to-route", path }, location.origin);
|
|
143
|
+
sent.push({ type: "navigate-to-route", ok: true, path });
|
|
136
144
|
} catch (error) {
|
|
137
|
-
sent.push({ type: "navigate-to-route", ok: false, error: String(error?.message || error) });
|
|
145
|
+
sent.push({ type: "navigate-to-route", ok: false, path, error: String(error?.message || error) });
|
|
138
146
|
}
|
|
147
|
+
};
|
|
148
|
+
// Codex renders a thread through two router surfaces: the main window
|
|
149
|
+
// (sidebar + recents rail) at /local/<id>, and the borderless hotkey
|
|
150
|
+
// "quick chat" view at /hotkey-window/thread/<id>. Two facts drive the logic:
|
|
151
|
+
// 1. The app uses an in-memory router, so location.pathname is ALWAYS
|
|
152
|
+
// /index.html — it never reveals the current route or window kind.
|
|
153
|
+
// 2. The /local/<id> route CANNOT cold-load an externally-created relay
|
|
154
|
+
// thread: it renders the empty "New chat" state and never recovers.
|
|
155
|
+
// Only /hotkey-window/thread/<id> loads such a thread from disk into the
|
|
156
|
+
// renderer store. Once loaded, /local/<id> renders it fine.
|
|
157
|
+
// So to land the user in the main window WITH content, prime the load via the
|
|
158
|
+
// hotkey route, then switch to /local/<id> once it is warm. The hotkey view
|
|
159
|
+
// shows the thread during the prime, so the transition is content -> content,
|
|
160
|
+
// never a blank flash. The second materializer pass runs with primeOpen=false
|
|
161
|
+
// and just re-asserts /local (the thread is warm by then).
|
|
162
|
+
const inHotkeyWindow = /hotkey-window/.test(location.href) || /hotkey-window/.test(location.search || "");
|
|
163
|
+
const primeMs = Number.isFinite(payload.primeMs) ? payload.primeMs : 1200;
|
|
164
|
+
if (inHotkeyWindow) {
|
|
165
|
+
navTo(`/hotkey-window/thread/${encoded}`);
|
|
166
|
+
} else if (payload.primeOpen === false) {
|
|
167
|
+
navTo(`/local/${encoded}`);
|
|
168
|
+
} else {
|
|
169
|
+
navTo(`/hotkey-window/thread/${encoded}`);
|
|
170
|
+
await new Promise((resolve) => setTimeout(resolve, primeMs));
|
|
171
|
+
navTo(`/local/${encoded}`);
|
|
139
172
|
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return { ok: sent.some((item) => item.ok), href: location.href, openThreadId: payload.openThreadId || null, sent };
|
|
176
|
+
}
|
|
140
177
|
|
|
141
|
-
|
|
142
|
-
|
|
178
|
+
export function buildCodexDesktopRefreshExpression({ threadIds = [], pinnedThreadIds = null, openThreadId = null, primeOpen = true } = {}) {
|
|
179
|
+
const rendererCode = `(${relayRefreshCodexRenderer.toString()})(${JSON.stringify({
|
|
143
180
|
threadIds: uniqueStrings(threadIds),
|
|
144
181
|
pinnedThreadIds,
|
|
145
182
|
openThreadId: String(openThreadId || "").trim() || null,
|
|
183
|
+
primeOpen: primeOpen !== false,
|
|
184
|
+
primeMs: Number(process.env.RELAY_CODEX_OPEN_PRIME_MS) || 1200,
|
|
146
185
|
})})`;
|
|
186
|
+
// Only bring a window forward when we're actually opening a thread.
|
|
187
|
+
const wantsFocus = Boolean(String(openThreadId || "").trim());
|
|
147
188
|
|
|
148
189
|
return `(async () => {
|
|
149
190
|
const req = process.mainModule?.require || process.getBuiltinModule("module").createRequire(process.cwd() + "/");
|
|
150
191
|
const { BrowserWindow } = req("electron");
|
|
151
192
|
const code = ${JSON.stringify(rendererCode)};
|
|
193
|
+
const wantsFocus = ${JSON.stringify(wantsFocus)};
|
|
152
194
|
const windows = [];
|
|
153
195
|
for (const win of BrowserWindow.getAllWindows()) {
|
|
154
196
|
if (win.isDestroyed()) continue;
|
|
155
197
|
try {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
198
|
+
const url = win.webContents.getURL();
|
|
199
|
+
const result = await win.webContents.executeJavaScript(code, true);
|
|
200
|
+
// Bring the main window forward after navigating it to the thread, the way
|
|
201
|
+
// Codex's own restoreShowAndFocusWindow does. The hotkey overlay manages
|
|
202
|
+
// its own visibility, so leave it alone and only surface the main window.
|
|
203
|
+
let focused = false;
|
|
204
|
+
if (wantsFocus && !String(url || "").includes("/hotkey-window")) {
|
|
205
|
+
try {
|
|
206
|
+
if (win.isMinimized()) win.restore();
|
|
207
|
+
win.show();
|
|
208
|
+
win.focus();
|
|
209
|
+
focused = true;
|
|
210
|
+
} catch {}
|
|
211
|
+
}
|
|
212
|
+
windows.push({ id: win.id, visible: win.isVisible(), url, focused, result });
|
|
162
213
|
} catch (error) {
|
|
163
214
|
windows.push({ id: win.id, ok: false, error: String(error?.message || error) });
|
|
164
215
|
}
|
package/src/materializer.js
CHANGED
|
@@ -257,13 +257,19 @@ async function materializeRowInHost({ id, row, rowState = {}, host = "claude", l
|
|
|
257
257
|
// works. The refresh therefore discards the stale cache entry and navigates
|
|
258
258
|
// through the bridge, which forces the view to reload the now-complete file.
|
|
259
259
|
// The codex:// deep link is only the fallback when the bridge is unreachable.
|
|
260
|
+
// First pass PRIMES the open: it navigates to /hotkey-window/thread/<id>
|
|
261
|
+
// (the only route that cold-loads an externally-created thread from disk),
|
|
262
|
+
// waits, then switches to /local/<id> so the thread shows in the main window
|
|
263
|
+
// with the sidebar. The /local route alone renders an empty "New chat" pane
|
|
264
|
+
// for a fresh relay thread and never recovers.
|
|
260
265
|
const desktopOpenResult = await refreshCodexDesktopForThreads([threadId], { force: true, openThreadId: threadId });
|
|
261
266
|
// The desktop occasionally re-caches the conversation between our discard
|
|
262
267
|
// and the view's load, so a single refresh still races on some first opens.
|
|
263
|
-
//
|
|
264
|
-
//
|
|
268
|
+
// The second pass runs with primeOpen=false — the thread is warm from the
|
|
269
|
+
// first pass, so a plain /local navigate re-asserts it deterministically
|
|
270
|
+
// without a second hotkey->local visual transition.
|
|
265
271
|
await sleep(Number(process.env.RELAY_CODEX_OPEN_SECOND_PASS_MS || 1200));
|
|
266
|
-
const secondPassResult = await refreshCodexDesktopForThreads([threadId], { force: true, openThreadId: threadId });
|
|
272
|
+
const secondPassResult = await refreshCodexDesktopForThreads([threadId], { force: true, openThreadId: threadId, primeOpen: false });
|
|
267
273
|
openedInHost = Boolean(desktopOpenResult?.ok || secondPassResult?.ok);
|
|
268
274
|
skipExternalOpen = openedInHost;
|
|
269
275
|
url = `codex://threads/${encodeURIComponent(threadId)}`;
|
|
@@ -393,7 +399,7 @@ function ensureRelayCodexIndexMarker({ threadId, sessionPath, packetId }) {
|
|
|
393
399
|
}
|
|
394
400
|
}
|
|
395
401
|
|
|
396
|
-
async function refreshCodexDesktopForThreads(threadIds, { force = false, openThreadId = null } = {}) {
|
|
402
|
+
async function refreshCodexDesktopForThreads(threadIds, { force = false, openThreadId = null, primeOpen = true } = {}) {
|
|
397
403
|
const uniqueThreadIds = Array.from(new Set(threadIds.filter(Boolean)));
|
|
398
404
|
if (!uniqueThreadIds.length && !force) return null;
|
|
399
405
|
try {
|
|
@@ -401,6 +407,7 @@ async function refreshCodexDesktopForThreads(threadIds, { force = false, openThr
|
|
|
401
407
|
threadIds: uniqueThreadIds,
|
|
402
408
|
pinnedThreadIds: readPinnedThreadIds(),
|
|
403
409
|
openThreadId,
|
|
410
|
+
primeOpen,
|
|
404
411
|
});
|
|
405
412
|
if (process.env.RELAY_DEBUG && result.attempted && !result.ok) {
|
|
406
413
|
console.error(`Relay Codex desktop refresh did not reach a running window: ${JSON.stringify(result)}`);
|