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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-companion",
3
- "version": "0.1.21",
3
+ "version": "0.1.23",
4
4
  "description": "Relay companion: connects local coding agents to Relay tasks, approvals, and connector tools.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -46,7 +46,10 @@ export async function notifyCodexDesktopThreads({
46
46
  threadIds = [],
47
47
  pinnedThreadIds = null,
48
48
  openThreadId = null,
49
- timeoutMs = Number(process.env.RELAY_CODEX_DESKTOP_TIMEOUT_MS || 4000),
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
- export function buildCodexDesktopRefreshExpression({ threadIds = [], pinnedThreadIds = null, openThreadId = null } = {}) {
82
- const rendererCode = `(${async function relayRefreshCodexRenderer(payload) {
83
- const hostId = "local";
84
- const bridge = window.electronBridge;
85
- if (!bridge?.sendMessageFromView) return { ok: false, reason: "missing-electron-bridge" };
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
- const sent = [];
88
- async function send(message) {
89
- try {
90
- await bridge.sendMessageFromView(message);
91
- sent.push({ type: message.type, ok: true });
92
- } catch (error) {
93
- sent.push({ type: message.type, ok: false, error: String(error?.message || error) });
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
- if (Array.isArray(payload.pinnedThreadIds)) {
98
- await send({ type: "set-pinned-thread-ids-for-host", hostId, threadIds: payload.pinnedThreadIds });
99
- }
100
- if (payload.threadIds.length) {
101
- await send({ type: "hydrate-pinned-threads", hostId, threadIds: payload.threadIds });
102
- }
103
- await send({ type: "refresh-recent-conversations-for-host", hostId });
104
- for (const threadId of payload.threadIds) {
105
- // Codex Desktop (>=0.142) serves thread views from its in-memory core and
106
- // never cold-loads externally created threads — the view spins forever
107
- // until an app restart. Adopt the thread the way the app itself opens one:
108
- // drop any stale cache entry, ask the tail-hydration path to load history
109
- // (feature-gated; harmless when off — dependentConversationIds is REQUIRED
110
- // or the handler throws), then maybe-resume-conversation, which activates
111
- // the thread summary and resumes the conversation from the rollout on disk.
112
- await send({ type: "discard-conversation-from-cache", conversationId: threadId });
113
- await send({ type: "ensure-conversation-history-loaded", conversationId: threadId, dependentConversationIds: [] });
114
- await send({
115
- type: "maybe-resume-conversation",
116
- hostId,
117
- conversationId: threadId,
118
- model: null,
119
- serviceTier: null,
120
- reasoningEffort: null,
121
- workspaceRoots: [],
122
- collaborationMode: null,
123
- });
124
- await send({ type: "broadcast-conversation-snapshot-for-host", hostId, conversationId: threadId });
125
- }
126
- if (payload.openThreadId) {
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
- return { ok: sent.some((item) => item.ok), href: location.href, openThreadId: payload.openThreadId || null, sent };
142
- }.toString()})(${JSON.stringify({
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
- windows.push({
157
- id: win.id,
158
- visible: win.isVisible(),
159
- url: win.webContents.getURL(),
160
- result: await win.webContents.executeJavaScript(code, true),
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
  }
@@ -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
- // A second discard + navigate pass after a short settle has rendered
264
- // deterministically in every observed failure case.
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)}`);