relay-companion 0.1.22 → 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.22",
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 {
@@ -88,9 +91,15 @@ export async function relayRefreshCodexRenderer(payload) {
88
91
  if (!bridge?.sendMessageFromView) return { ok: false, reason: "missing-electron-bridge" };
89
92
 
90
93
  const sent = [];
91
- async function send(message) {
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) {
92
98
  try {
93
- await bridge.sendMessageFromView(message);
99
+ await Promise.race([
100
+ bridge.sendMessageFromView(message),
101
+ new Promise((_, reject) => setTimeout(() => reject(new Error("send-timeout")), timeoutMs)),
102
+ ]);
94
103
  sent.push({ type: message.type, ok: true });
95
104
  } catch (error) {
96
105
  sent.push({ type: message.type, ok: false, error: String(error?.message || error) });
@@ -127,33 +136,52 @@ export async function relayRefreshCodexRenderer(payload) {
127
136
  await send({ type: "broadcast-conversation-snapshot-for-host", hostId, conversationId: threadId });
128
137
  }
129
138
  if (payload.openThreadId) {
130
- try {
131
- const encoded = encodeURIComponent(String(payload.openThreadId));
132
- // Codex renders a thread through two separate router surfaces: the main
133
- // window (sidebar + recents rail) at /local/<id>, and the borderless hotkey
134
- // "quick chat" overlay at /hotkey-window/thread/<id>. The app's own
135
- // navigation chooses between them by whether the current window is the
136
- // hotkey one (its QH() keys off location.pathname). Mirror that here so the
137
- // thread opens in whichever surface the user is actually in — otherwise
138
- // every open is forced into the overlay even when the main window is up.
139
- const path = location.pathname.startsWith("/hotkey-window")
140
- ? `/hotkey-window/thread/${encoded}`
141
- : `/local/${encoded}`;
142
- window.postMessage({ type: "navigate-to-route", path }, location.origin);
143
- sent.push({ type: "navigate-to-route", ok: true, threadId: payload.openThreadId, path });
144
- } catch (error) {
145
- sent.push({ type: "navigate-to-route", ok: false, error: String(error?.message || error) });
139
+ const encoded = encodeURIComponent(String(payload.openThreadId));
140
+ const navTo = (path) => {
141
+ try {
142
+ window.postMessage({ type: "navigate-to-route", path }, location.origin);
143
+ sent.push({ type: "navigate-to-route", ok: true, path });
144
+ } catch (error) {
145
+ sent.push({ type: "navigate-to-route", ok: false, path, error: String(error?.message || error) });
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}`);
146
172
  }
147
173
  }
148
174
 
149
175
  return { ok: sent.some((item) => item.ok), href: location.href, openThreadId: payload.openThreadId || null, sent };
150
176
  }
151
177
 
152
- export function buildCodexDesktopRefreshExpression({ threadIds = [], pinnedThreadIds = null, openThreadId = null } = {}) {
178
+ export function buildCodexDesktopRefreshExpression({ threadIds = [], pinnedThreadIds = null, openThreadId = null, primeOpen = true } = {}) {
153
179
  const rendererCode = `(${relayRefreshCodexRenderer.toString()})(${JSON.stringify({
154
180
  threadIds: uniqueStrings(threadIds),
155
181
  pinnedThreadIds,
156
182
  openThreadId: String(openThreadId || "").trim() || null,
183
+ primeOpen: primeOpen !== false,
184
+ primeMs: Number(process.env.RELAY_CODEX_OPEN_PRIME_MS) || 1200,
157
185
  })})`;
158
186
  // Only bring a window forward when we're actually opening a thread.
159
187
  const wantsFocus = Boolean(String(openThreadId || "").trim());
@@ -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)}`);