relay-companion 0.1.35 → 0.1.37

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/bin/relay.js CHANGED
@@ -104,8 +104,13 @@ function statusAreaReopenText() {
104
104
 
105
105
  /** Register the Relay tools into Claude Code + Codex and start the receive daemon. */
106
106
  async function applyInstall({ requireLiveTools = false, requiredHosts = requiredLiveHosts() } = {}) {
107
- const { installed, missing, daemon, pill, activations = [] } = await runSetupInstall();
107
+ const { installed, missing, daemon, pill, activations = [], binStable = true } = await runSetupInstall();
108
108
  if (installed.length) console.log(`Added Relay to ${installed.join(" and ")} on this machine.`);
109
+ if (!binStable) {
110
+ console.log(
111
+ "Heads up: Relay is running from a temporary npx cache and couldn't global-install. It may stop working when that cache is cleaned — run `npm install -g relay-companion` to make it permanent.",
112
+ );
113
+ }
109
114
  for (const m of missing) {
110
115
  if (!/registration failed/.test(m)) console.log(`${m} was not found here, so it was skipped.`);
111
116
  else console.log(`Could not register ${m}.`);
@@ -22,28 +22,31 @@ function isRelayBundle(bundle) {
22
22
  return clean.includes("relay") || clean.includes("electron");
23
23
  }
24
24
 
25
- // Cold-start choice when NEITHER agent is running: pick by what's installed.
26
- // macOS preserves the original preference (Codex, then Claude). Windows is
27
- // Claude-only for now: the Codex materializer still depends on macOS Codex
28
- // Desktop paths/app-server behavior, while Claude's claude:// resume handler is
29
- // registered by Claude Desktop on Windows.
25
+ // Cold-start choice when NEITHER agent is foregrounded/running: pick by what's
26
+ // installed. When BOTH are available and neither is foregrounded, prefer Claude
27
+ // (per the product rule: "if I have claude and codex and neither is foregrounded,
28
+ // foreground claude"). Windows is Claude-only for now: the Codex materializer still
29
+ // depends on macOS Codex Desktop paths/app-server behavior, while Claude's claude://
30
+ // resume handler is registered by Claude Desktop on Windows.
30
31
  function defaultClickHost({ codexInstalled = false, claudeInstalled = false, platform = process.platform } = {}) {
31
32
  if (platform === "win32") return "claude";
32
- if (codexInstalled) return "codex";
33
33
  if (claudeInstalled) return "claude";
34
- return "codex";
34
+ if (codexInstalled) return "codex";
35
+ return "claude";
35
36
  }
36
37
 
37
38
  // Decide which host a pill click should open into. Precedence, strongest signal first:
38
- // 1. The frontmost app IS a host — the user is literally looking at it.
39
+ // 1. The frontmost app IS a host — the user is literally looking at it, so open there.
40
+ // (Codex frontmost -> Codex; Claude frontmost -> Claude.)
39
41
  // 2. The click momentarily activated the accessory overlay (or no frontmost app could
40
- // be read) AND a host was foregrounded moments ago — use that captured host.
42
+ // be read) AND a host was foregrounded moments ago — use that just-foregrounded host.
41
43
  // 3. Exactly one host PROCESS is running — route to it. This is the off-Space fix:
42
44
  // `ps` sees a host on any Space, so a running-but-not-frontmost Claude wins over a
43
- // closed Codex instead of cold-starting Codex.
44
- // 4. Both hosts are running and none is frontmost — fall back to the most-recently
45
- // foregrounded host if we ever saw one.
46
- // 5. Nothing better — installed-preference default (true cold start).
45
+ // closed Codex instead of cold-starting Codex. (Only Codex up -> Codex; only Claude
46
+ // up -> Claude.)
47
+ // 4. Both hosts are running but NEITHER is foregrounded — prefer Claude (product rule).
48
+ // 5. Nothing running — installed-preference default (true cold start; prefers Claude
49
+ // when both are installed).
47
50
  function chooseClickHost({
48
51
  frontHost = null,
49
52
  bundle = null,
@@ -67,7 +70,8 @@ function chooseClickHost({
67
70
 
68
71
  if (claudeRunning && !effectiveCodexRunning) return "claude";
69
72
  if (effectiveCodexRunning && !claudeRunning) return "codex";
70
- if (claudeRunning && effectiveCodexRunning && lastHost) return lastHost;
73
+ // Both running, neither foregrounded (and none foregrounded moments ago): prefer Claude.
74
+ if (claudeRunning && effectiveCodexRunning) return "claude";
71
75
 
72
76
  return defaultClickHost({ codexInstalled: effectiveCodexInstalled, claudeInstalled, platform });
73
77
  }
@@ -758,6 +758,15 @@
758
758
  }
759
759
  // ✕ — the card leaves gracefully, then the main process hides the window and keeps
760
760
  // it hidden (persisted) until the status-area Relay mark is clicked.
761
+ // The exit is cancellable: if the user reopens (tray click / new ghost banner) while
762
+ // the bye animation is in flight, the pending timeout must NOT fire — it would
763
+ // dismiss the pill they just reopened and park the card offstage under a visible
764
+ // window (a click that "does nothing").
765
+ let byeTimer = null;
766
+ function cancelBye() {
767
+ if (byeTimer) { clearTimeout(byeTimer); byeTimer = null; }
768
+ cardEl.classList.remove("bye");
769
+ }
761
770
  function dismissOverlay() {
762
771
  if (cardEl.classList.contains("bye")) return;
763
772
  if (ghostTimer) { clearTimeout(ghostTimer); ghostTimer = null; }
@@ -765,7 +774,8 @@
765
774
  const wasGhost = ghost;
766
775
  cardEl.classList.add("bye");
767
776
  setInteractive(false);
768
- setTimeout(() => {
777
+ byeTimer = setTimeout(() => {
778
+ byeTimer = null;
769
779
  if (wasGhost) window.relay.notifDone(); // already dismissed; just re-hide the window
770
780
  else window.relay.dismiss();
771
781
  resetOffstage();
@@ -776,6 +786,7 @@
776
786
  // when done it fades away instead of folding back into anything.
777
787
  function ghostArrival(row) {
778
788
  if (ghostTimer) { clearTimeout(ghostTimer); ghostTimer = null; }
789
+ cancelBye(); // a banner arriving mid-exit must not be torn down by the old exit
779
790
  clearPeek();
780
791
  collapsed = false;
781
792
  cardEl.classList.remove("collapsed", "offstage", "bye");
@@ -800,6 +811,7 @@
800
811
  // Status-area Relay mark clicked: the card comes back FULLY OPEN (never the pill).
801
812
  function trayOpen() {
802
813
  const wasOffstage = cardEl.classList.contains("offstage") || cardEl.classList.contains("bye");
814
+ cancelBye(); // a reopen mid-exit must survive: the pending dismiss would hide us again
803
815
  if (ghostTimer) { clearTimeout(ghostTimer); ghostTimer = null; }
804
816
  ghost = false;
805
817
  clearPeek();
@@ -843,8 +855,14 @@
843
855
  scrollEl.scrollTop = 0;
844
856
  springTo(PEEK.w, peekHeight());
845
857
  playTink();
846
- peekTimer = setTimeout(() => {
847
- peekTimer = null; peeking = false;
858
+ peekTimer = setTimeout(function tick() {
859
+ peekTimer = null;
860
+ // Never fold the banner away while the pointer is on it or a reply is being
861
+ // typed — that rug-pulls the user mid-interaction and can trap keyboard focus
862
+ // in a now-invisible textarea. Check again shortly instead (macOS banners
863
+ // hold while hovered too). Mirrors the ghost-notification guard.
864
+ if (interactive || quickReplyIsActive()) { peekTimer = setTimeout(tick, 4000); return; }
865
+ peeking = false;
848
866
  cardEl.classList.remove("peek");
849
867
  collapsed = true; cardEl.classList.add("collapsed");
850
868
  springTo(PILL.w, PILL.h);
@@ -2126,6 +2144,15 @@
2126
2144
  onPayload(p);
2127
2145
  console.log("[overlay] booted ok, relays=" + (p && p.relays ? p.relays.length : 0) + " sent=" + (p && p.sent ? p.sent.length : 0));
2128
2146
  }).catch((e) => console.log("[overlay] refresh failed", e && e.message ? e.message : e));
2147
+
2148
+ // Keep the relative "3m / 2h" times honest now that identical payloads are no longer
2149
+ // re-pushed. Skip while the pointer is on the card (a rebuild under the pointer wipes
2150
+ // hover state) and while dragging; the next tick catches up.
2151
+ setInterval(() => {
2152
+ if (dragging || interactive) return;
2153
+ if (activeView === "relays") renderRelays();
2154
+ else if (activeView === "sent") renderSent();
2155
+ }, 60000);
2129
2156
  </script>
2130
2157
  </body>
2131
2158
  </html>
package/overlay/main.cjs CHANGED
@@ -30,6 +30,8 @@ const { execFile, execFileSync, spawn, pathToFileURL } = (() => {
30
30
  return { execFile: cp.execFile, execFileSync: cp.execFileSync, spawn: cp.spawn, pathToFileURL: url.pathToFileURL };
31
31
  })();
32
32
  const { hostFromBundle, chooseClickHost } = require("./host-select.cjs");
33
+ const { overlayWanted, createHostRunningTracker, shouldIgnoreDismiss } = require("./visibility.cjs");
34
+ const { withJsonLock } = require("../src/state-lock.cjs");
33
35
  const {
34
36
  reinforceSpacePresence,
35
37
  resetWindowZoom,
@@ -113,6 +115,15 @@ async function relayClient() {
113
115
  }
114
116
 
115
117
  app.setName("Relay");
118
+ // Test seam: an isolated userData dir gives the e2e harness its own single-instance
119
+ // lock, so a sandboxed overlay can run beside the real pill without fighting it.
120
+ if (process.env.RELAY_OVERLAY_USER_DATA) {
121
+ try {
122
+ app.setPath("userData", process.env.RELAY_OVERLAY_USER_DATA);
123
+ } catch (error) {
124
+ console.error("[overlay] userData override failed:", error && error.message);
125
+ }
126
+ }
116
127
  const gotSingleInstanceLock = app.requestSingleInstanceLock();
117
128
 
118
129
  // ---- config / account ----------------------------------------------------
@@ -298,14 +309,19 @@ function ensureTasksLoaded() {
298
309
  return tasksLoadedOnce;
299
310
  }
300
311
 
301
- // ---- contacts (live) -----------------------------------------------------
312
+ // ---- contacts (cached; refreshed on a slow interval + on demand) ----------
313
+ // Contacts must NEVER be fetched per state push: a slow or failing network would
314
+ // stall every inbox paint and blank the list (observed live: "listContacts failed:
315
+ // fetch failed" every 2.5s while offline). Keep the last good list; refresh lazily.
302
316
 
303
- async function readContacts() {
317
+ let contactsCache = [];
318
+ let contactsLoadedOnce = null;
319
+ async function refreshContacts() {
304
320
  try {
305
321
  const client = await relayClient();
306
322
  const res = await client.listContacts();
307
323
  const contacts = Array.isArray(res && res.contacts) ? res.contacts : [];
308
- return contacts
324
+ contactsCache = contacts
309
325
  .map((c) => {
310
326
  const emails = Array.isArray(c.emails) ? c.emails.filter(Boolean) : c.email ? [c.email] : [];
311
327
  return {
@@ -319,9 +335,18 @@ async function readContacts() {
319
335
  })
320
336
  .sort((a, b) => String(a.name).localeCompare(String(b.name)));
321
337
  } catch (error) {
338
+ // Keep the last good cache; a transient network failure must not blank the UI.
322
339
  console.error("[overlay] listContacts failed:", error && error.message);
323
- return [];
324
340
  }
341
+ return contactsCache;
342
+ }
343
+ function ensureContactsLoaded() {
344
+ if (!contactsLoadedOnce) contactsLoadedOnce = refreshContacts().catch(() => contactsCache);
345
+ return contactsLoadedOnce;
346
+ }
347
+ async function readContacts() {
348
+ await refreshContacts();
349
+ return contactsCache;
325
350
  }
326
351
 
327
352
  async function saveContact(input) {
@@ -350,26 +375,41 @@ async function deleteContactFromBook(input) {
350
375
 
351
376
  // ---- payload assembly + push to renderer ---------------------------------
352
377
 
353
- async function buildPayload() {
354
- await ensureSentLoaded();
355
- const [relays, contacts] = [readRelays(), await readContacts()];
378
+ // buildPayload is synchronous over CACHES so the first paint never waits on the
379
+ // network. Relays come straight from local state.json (instant, works offline); sent
380
+ // + contacts are served from cache and their first background load triggers its own
381
+ // pushInbox when it lands. This is what makes the pill appear immediately even on a
382
+ // black-holed network (the client fetch timeout is 15s — far too long to block paint).
383
+ function buildPayload() {
384
+ // Kick the first loads without awaiting; each calls pushInbox(false) on completion.
385
+ if (!sentLoadedOnce) ensureSentLoaded().then(() => pushInbox(false)).catch(() => {});
386
+ if (!contactsLoadedOnce) ensureContactsLoaded().then(() => pushInbox(false)).catch(() => {});
356
387
  return {
357
388
  account: account(),
358
389
  ui: {
359
390
  canDismiss: trayAvailable,
360
391
  reopenSurface: reopenSurfaceName(),
361
392
  },
362
- relays,
393
+ relays: readRelays(),
363
394
  sent: sentCache,
364
395
  tasks: [],
365
- contacts,
396
+ contacts: contactsCache,
366
397
  };
367
398
  }
368
399
 
369
400
  let knownIds = null; // null until first load, so we never "notify" for rows already present
370
- async function pushInbox(force) {
401
+ // Pushes are serialized: overlapping timers (fs.watch + safety poll + sent refresh)
402
+ // must not interleave sends, or the renderer can paint an older payload last.
403
+ let pushChain = Promise.resolve();
404
+ function pushInbox(force) {
405
+ pushChain = pushChain.then(() => pushInboxNow(force)).catch((error) => {
406
+ console.error("[overlay] pushInbox failed:", error && error.message);
407
+ });
408
+ return pushChain;
409
+ }
410
+ async function pushInboxNow(force) {
371
411
  if (!win || win.isDestroyed()) return;
372
- const payload = await buildPayload();
412
+ const payload = buildPayload();
373
413
  const rows = payload.relays;
374
414
  const notifiableRows = visibleRelayRows(rows);
375
415
  if (knownIds === null) {
@@ -393,7 +433,19 @@ async function pushInbox(force) {
393
433
  }
394
434
  }
395
435
  const sig = JSON.stringify({
396
- relays: rows.map((r) => [r.id, r.relayNotificationKind, r.unread, r.title, r.senderName, r.urgency]),
436
+ // Include the answered-state fields: a question answered on the web / in a host
437
+ // session flips quickReply/humanAnswered without changing unread/title, and
438
+ // omitting them left a live reply box in the pill for an already-answered question.
439
+ relays: rows.map((r) => [
440
+ r.id,
441
+ r.relayNotificationKind,
442
+ r.unread,
443
+ r.title,
444
+ r.senderName,
445
+ r.urgency,
446
+ r.quickReply,
447
+ r.humanAnswered,
448
+ ]),
397
449
  sent: payload.sent.map((r) => [
398
450
  r.relayId,
399
451
  r.state,
@@ -405,31 +457,37 @@ async function pushInbox(force) {
405
457
  contacts: payload.contacts.map((c) => [c.id, c.name, c.email]),
406
458
  account: [payload.account.paired, payload.account.email],
407
459
  });
408
- if (!force && sig === lastSig) {
409
- if (!win || win.isDestroyed()) return;
410
- }
460
+ // Unchanged data: skip the send entirely. Re-sending identical payloads made the
461
+ // renderer rebuild its DOM every few seconds (hover flicker, restarted transitions).
462
+ if (!force && sig === lastSig) return;
411
463
  lastSig = sig;
412
464
  if (win && !win.isDestroyed()) win.webContents.send("inbox", payload);
413
465
  }
414
466
 
415
467
  // Refresh state-derived rows only (fast path used by fs.watch + the safety poll).
416
468
  function pushInboxQuiet() {
417
- pushInbox(false).catch((error) => console.error("[overlay] pushInbox failed:", error && error.message));
469
+ pushInbox(false);
418
470
  }
419
471
 
420
472
  // ---- mark-read (write state.json directly) -------------------------------
421
473
 
422
474
  function ackPacket(packetId) {
423
475
  if (!packetId) return;
424
- const store = readStore();
425
- if (!store.packets || !store.packets[packetId]) return;
426
- const row = store.packets[packetId];
427
- store.packets[packetId] = {
428
- ...row,
429
- state: "read",
430
- updatedAt: new Date().toISOString(),
431
- };
432
- writeStateAtomic(store);
476
+ // Locked read-modify-write: the daemon stages rows into the same file, and an
477
+ // unsynchronized ack racing a stage would silently drop the new relay.
478
+ const row = withJsonLock(STATE_PATH, () => {
479
+ const store = readStore();
480
+ if (!store.packets || !store.packets[packetId]) return null;
481
+ const existing = store.packets[packetId];
482
+ store.packets[packetId] = {
483
+ ...existing,
484
+ state: "read",
485
+ updatedAt: new Date().toISOString(),
486
+ };
487
+ writeStateAtomic(store);
488
+ return existing;
489
+ });
490
+ if (!row) return;
433
491
  if (row.relayNotificationKind === "plain_relay") {
434
492
  relayClient()
435
493
  .then((client) => client.markRead(packetId, { idempotencyKey: idempotencyKey("read") }))
@@ -531,18 +589,20 @@ async function answerQuestion(taskId, messageId, text, packetId) {
531
589
  }
532
590
 
533
591
  function markQuestionAnswered(packetId, answerMarkdown) {
534
- const store = readStore();
535
- const row = store.packets && store.packets[packetId];
536
- if (!row) return;
537
- store.packets[packetId] = {
538
- ...row,
539
- state: "read",
540
- quickReply: false,
541
- humanAnswered: true,
542
- humanAnswerMarkdown: answerMarkdown,
543
- updatedAt: new Date().toISOString(),
544
- };
545
- writeStateAtomic(store);
592
+ withJsonLock(STATE_PATH, () => {
593
+ const store = readStore();
594
+ const row = store.packets && store.packets[packetId];
595
+ if (!row) return;
596
+ store.packets[packetId] = {
597
+ ...row,
598
+ state: "read",
599
+ quickReply: false,
600
+ humanAnswered: true,
601
+ humanAnswerMarkdown: answerMarkdown,
602
+ updatedAt: new Date().toISOString(),
603
+ };
604
+ writeStateAtomic(store);
605
+ });
546
606
  }
547
607
 
548
608
  function taskVersion(taskId) {
@@ -571,6 +631,23 @@ function rememberForegroundHost(host) {
571
631
  lastHostSeenAt = Date.now();
572
632
  }
573
633
 
634
+ // Bring the host app to the FOREGROUND so the just-opened relay is actually visible.
635
+ // shell.openExternal(claude://…) / the Codex bridge route the content to the host but
636
+ // do NOT reliably raise it: the overlay is a non-activating accessory window, and a
637
+ // host sitting on another macOS Space stays put (observed live: "it opened it in claude
638
+ // (good) but didnt foreground claude"). `open -b <bundle>` activates the app — switching
639
+ // to its Space and launching it if it wasn't running. `-g` is intentionally NOT passed,
640
+ // because raising the app is exactly the point.
641
+ const HOST_BUNDLE_ID = { claude: "com.anthropic.claudefordesktop", codex: "com.openai.codex" };
642
+ function activateHost(host) {
643
+ if (process.platform !== "darwin") return; // Windows foregrounding is handled by its own open path
644
+ const bundle = HOST_BUNDLE_ID[host];
645
+ if (!bundle) return;
646
+ execFile("/usr/bin/open", ["-b", bundle], (error) => {
647
+ if (error) console.error("[overlay] activateHost failed:", host, error && error.message);
648
+ });
649
+ }
650
+
574
651
  function appInstalled(appName) {
575
652
  const name = String(appName || "").replace(/[^A-Za-z0-9 ._-]/g, "");
576
653
  if (!name) return false;
@@ -712,7 +789,7 @@ function claudeLastFocusedAt(metaPath) {
712
789
  return 0;
713
790
  }
714
791
  }
715
- function openClaudeDeepLinkVerified(url, fallbackUrl, label) {
792
+ function openClaudeDeepLinkVerified(url, fallbackUrl, label, { freshlyForged = false } = {}) {
716
793
  const fire = () =>
717
794
  shell.openExternal(url).catch((error) => console.error("[overlay] openExternal failed:", error && error.message));
718
795
  if (process.platform !== "darwin") return fire();
@@ -723,13 +800,20 @@ function openClaudeDeepLinkVerified(url, fallbackUrl, label) {
723
800
  const logOffset = claudeLogSize();
724
801
  fire();
725
802
  if (!metaPath && logOffset < 0) return; // nothing to verify against — legacy behavior
803
+ // On a FIRST open the companion CLI just forged the session and stamped
804
+ // lastFocusedAt=now — so the "focused moments ago" recency check would be vacuously
805
+ // true and confirm a resume that a wedged Claude actually swallowed (no nudge, no web
806
+ // fallback). Suppress recency ONLY on a fresh forge (signalled by the CLI). On a
807
+ // resume of an already-imported session the CLI does NOT stamp the timestamp, so a
808
+ // recent lastFocusedAt is a GENUINE Claude focus — keep trusting it, or a real resume
809
+ // the user was just looking at would falsely fall back to the web view.
726
810
  const confirmed = () => {
727
811
  if (metaPath) {
728
812
  const focusedAt = claudeLastFocusedAt(metaPath);
729
- // Advanced past the pre-fire baseline, or focused moments ago (covers a
730
- // focus write racing our baseline snapshot) — either way Claude has it.
813
+ // A genuine Claude focus advances the timestamp past our pre-fire baseline.
731
814
  if (focusedAt > focusBaseline) return true;
732
- if (focusedAt && Date.now() - focusedAt < 15000) return true;
815
+ // Recency: trust it unless the CLI's own bootstrap write made it vacuously fresh.
816
+ if (!freshlyForged && focusedAt && Date.now() - focusedAt < 15000) return true;
733
817
  }
734
818
  return claudeLogSawResume(sessionId, logOffset);
735
819
  };
@@ -803,16 +887,22 @@ function openPacket(packetId) {
803
887
  child.on("exit", (code) => {
804
888
  let url = null;
805
889
  let skipExternalOpen = false;
890
+ let freshlyForged = false;
806
891
  try {
807
892
  const parsed = JSON.parse(out.trim());
808
893
  url = parsed.url || null;
809
894
  skipExternalOpen = Boolean(parsed.skipExternalOpen);
895
+ freshlyForged = Boolean(parsed.claudeFreshlyForged);
810
896
  } catch {}
811
897
  if (url || skipExternalOpen) {
898
+ // Raise the host so the relay is visible, whichever open path ran (Claude deep
899
+ // link, Codex bridge, or a plain url). This is the fix for "opened in the right
900
+ // host but didn't foreground it".
901
+ activateHost(host);
812
902
  if (url && !skipExternalOpen) {
813
903
  const webFallback = row && row.taskId ? taskWebUrl(row.taskId) : row && row.actionUrl ? absoluteUrl(row.actionUrl) : webBase();
814
904
  if (claudeSessionIdFromUrl(url)) {
815
- openClaudeDeepLinkVerified(url, webFallback, packetId);
905
+ openClaudeDeepLinkVerified(url, webFallback, packetId, { freshlyForged });
816
906
  } else {
817
907
  shell.openExternal(url).catch((error) => console.error("[overlay] openExternal failed:", error && error.message));
818
908
  }
@@ -875,15 +965,18 @@ function openTaskDetail(taskId) {
875
965
  child.on("exit", (code) => {
876
966
  let url = null;
877
967
  let skipExternalOpen = false;
968
+ let freshlyForged = false;
878
969
  try {
879
970
  const parsed = JSON.parse(out.trim());
880
971
  url = parsed.url || null;
881
972
  skipExternalOpen = Boolean(parsed.skipExternalOpen);
973
+ freshlyForged = Boolean(parsed.claudeFreshlyForged);
882
974
  } catch {}
883
975
  if (url || skipExternalOpen) {
976
+ activateHost(host); // raise the host so the opened task is visible
884
977
  if (url && !skipExternalOpen) {
885
978
  if (claudeSessionIdFromUrl(url)) {
886
- openClaudeDeepLinkVerified(url, taskWebUrl(taskId), taskId);
979
+ openClaudeDeepLinkVerified(url, taskWebUrl(taskId), taskId, { freshlyForged });
887
980
  } else {
888
981
  shell.openExternal(url).catch((error) => console.error("[overlay] openExternal failed:", error && error.message));
889
982
  }
@@ -953,16 +1046,21 @@ function showOverlayWindow({ force = false, reposition = true } = {}) {
953
1046
  }
954
1047
  placeOverlayWindow({ reposition });
955
1048
  const shown = showInactiveOnAllSpaces(win, { force });
956
- win.setIgnoreMouseEvents(true, { forward: true });
957
- if (shown) win.webContents.send("shown");
1049
+ if (shown) {
1050
+ // hidden -> shown only: re-assert click-through and reset the renderer's
1051
+ // interactivity handshake. Touching setIgnoreMouseEvents on an already-visible
1052
+ // window would silently flip it click-through while the renderer still thinks
1053
+ // the pointer handshake is live — a dead card until the pointer re-enters.
1054
+ win.setIgnoreMouseEvents(true, { forward: true });
1055
+ win.webContents.send("shown");
1056
+ }
958
1057
  }
959
1058
 
960
1059
  function maybeShow({ force = false, reposition = true } = {}) {
961
1060
  if (!win || win.isDestroyed()) return;
962
1061
  // Dismissed keeps the window hidden except while a ghost notification is up.
963
1062
  // trayForcedVisible honors an explicit status-area click even when no host runs.
964
- const dismissedEffective = trayAvailable && dismissed;
965
- const wanted = (hostRunning || trayForcedVisible) && (!dismissedEffective || ghostActive);
1063
+ const wanted = overlayWanted({ hostRunning, trayForcedVisible, trayAvailable, dismissed, ghostActive });
966
1064
  if (wanted) {
967
1065
  showOverlayWindow({ force, reposition });
968
1066
  } else if (win.isVisible()) {
@@ -988,6 +1086,10 @@ function readHostProcesses(cb) {
988
1086
  execFile("/bin/ps", ["-Ao", "comm"], cb);
989
1087
  }
990
1088
 
1089
+ // Debounced OFF transition (~3 misses = ~4.5s at the 1.5s poll): a host relaunching
1090
+ // during its own auto-update, or one truncated process-list read, must not blink the
1091
+ // pill off and back on.
1092
+ const hostTracker = createHostRunningTracker({ missThreshold: 3 });
991
1093
  function updateHostRunningFromProcesses(text) {
992
1094
  const s = String(text || "");
993
1095
  if (process.platform === "darwin") {
@@ -1003,7 +1105,7 @@ function updateHostRunningFromProcesses(text) {
1003
1105
  claudeRunning = /(^|\n)Claude(\n|$)/.test(s) || /(^|\n)claude(\n|$)/.test(s);
1004
1106
  codexRunning = /(^|\n)Codex(\n|$)/.test(s) || /(^|\n)codex(\n|$)/.test(s);
1005
1107
  }
1006
- hostRunning = claudeRunning || codexRunning;
1108
+ hostRunning = hostTracker.update(claudeRunning, codexRunning);
1007
1109
  }
1008
1110
 
1009
1111
  function pollHosts() {
@@ -1057,7 +1159,21 @@ function createWindow() {
1057
1159
  console.error("[renderer]", d);
1058
1160
  });
1059
1161
  win.webContents.on("preload-error", (_e, p, err) => console.error("[preload-error]", p, err && err.message));
1060
- win.webContents.on("render-process-gone", (_e, det) => console.error("[render-gone]", JSON.stringify(det)));
1162
+ // A dead renderer leaves an invisible, unrecoverable window (the tray click would
1163
+ // "do nothing"). Reload it — with a short backoff so a hard crash can't hot-loop.
1164
+ let renderGoneAt = 0;
1165
+ win.webContents.on("render-process-gone", (_e, det) => {
1166
+ console.error("[render-gone]", JSON.stringify(det));
1167
+ if (!win || win.isDestroyed()) return;
1168
+ const delay = Date.now() - renderGoneAt < 30000 ? 5000 : 250;
1169
+ renderGoneAt = Date.now();
1170
+ setTimeout(() => {
1171
+ if (!win || win.isDestroyed()) return;
1172
+ lastSig = ""; // force a full repaint after reload
1173
+ knownIds = null;
1174
+ win.webContents.reload();
1175
+ }, delay);
1176
+ });
1061
1177
  win.webContents.on("did-finish-load", () => resetWindowZoom(win));
1062
1178
  win.webContents.on("zoom-changed", (event) => {
1063
1179
  if (event && typeof event.preventDefault === "function") event.preventDefault();
@@ -1071,7 +1187,8 @@ function createWindow() {
1071
1187
  }
1072
1188
  });
1073
1189
  win.once("ready-to-show", () => {
1074
- // buildPayload() awaits the first sent-relay load, so a single push paints all tabs.
1190
+ // First paint is instant: relays come from local state.json; the sent + contacts
1191
+ // background loads kicked off here each re-push when they land (buildPayload).
1075
1192
  pushInbox(true).catch((error) => console.error("[overlay] initial push failed:", error && error.message));
1076
1193
  pollHosts();
1077
1194
  });
@@ -1090,6 +1207,11 @@ function createWindow() {
1090
1207
  .then(() => pushInbox(false))
1091
1208
  .catch(() => {});
1092
1209
  }, 5000); // live sent-relay refresh
1210
+ setInterval(() => {
1211
+ refreshContacts()
1212
+ .then(() => pushInbox(false))
1213
+ .catch(() => {});
1214
+ }, 60000); // slow contact refresh; the contacts view also refreshes on open
1093
1215
  setInterval(pollHosts, 1500);
1094
1216
  }
1095
1217
 
@@ -1135,7 +1257,9 @@ function createTrayIcon() {
1135
1257
  return icon && !icon.isEmpty() ? icon : templateIcon && !templateIcon.isEmpty() ? templateIcon : null;
1136
1258
  }
1137
1259
 
1260
+ let lastTrayShowAt = 0;
1138
1261
  function showFromTray() {
1262
+ lastTrayShowAt = Date.now();
1139
1263
  setDismissed(false);
1140
1264
  ghostActive = false;
1141
1265
  trayForcedVisible = true; // an explicit click always gets the card
@@ -1229,6 +1353,10 @@ ipcMain.on("relay:setPos", (_e, x, y) => {
1229
1353
  // ✕ on the card: hide the overlay entirely until the status-area Relay mark is clicked.
1230
1354
  // The renderer plays its exit animation first, then sends this.
1231
1355
  ipcMain.on("relay:dismiss", () => {
1356
+ // Ignore only a dismiss whose IPC was already in flight when a tray "Show Relay"
1357
+ // just landed (narrow window). A genuine ✕ click is always honored.
1358
+ if (shouldIgnoreDismiss({ now: Date.now(), lastTrayShowAt })) return;
1359
+ lastTrayShowAt = 0; // honored: clear the guard so it can't linger
1232
1360
  if (!trayAvailable) {
1233
1361
  setDismissed(false);
1234
1362
  ghostActive = false;
@@ -1284,6 +1412,18 @@ if (!gotSingleInstanceLock) {
1284
1412
  createWindow();
1285
1413
  createTray();
1286
1414
  installActiveSpaceWatcher();
1415
+ // Test seam: the e2e harness (test/e2e-overlay.mjs) drives the real state machine
1416
+ // over the main-process inspector. Never set outside the harness.
1417
+ if (process.env.RELAY_OVERLAY_TEST === "1") {
1418
+ global.__relayTest = {
1419
+ showFromTray,
1420
+ maybeShow,
1421
+ refreshOverlayForActiveSpace,
1422
+ pollHosts,
1423
+ getWin: () => win,
1424
+ state: () => ({ dismissed, ghostActive, trayForcedVisible, hostRunning, visible: win && !win.isDestroyed() ? win.isVisible() : null }),
1425
+ };
1426
+ }
1287
1427
  });
1288
1428
 
1289
1429
  app.on("window-all-closed", () => {
@@ -21,11 +21,18 @@ function resetWindowZoom(win) {
21
21
  function reinforceSpacePresence(win, { moveTop = false } = {}) {
22
22
  if (!isUsableWindow(win)) return false;
23
23
  resetWindowZoom(win);
24
+ // Only touch window-server state that actually drifted. Re-asserting
25
+ // setVisibleOnAllWorkspaces / setAlwaysOnTop unconditionally (every 1.5s poll +
26
+ // every Space change) reorders the window each time and reads as flicker.
24
27
  try {
25
- win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
28
+ if (typeof win.isVisibleOnAllWorkspaces !== "function" || !win.isVisibleOnAllWorkspaces()) {
29
+ win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
30
+ }
26
31
  } catch {}
27
32
  try {
28
- win.setAlwaysOnTop(true, "floating");
33
+ if (typeof win.isAlwaysOnTop !== "function" || !win.isAlwaysOnTop()) {
34
+ win.setAlwaysOnTop(true, "floating");
35
+ }
29
36
  } catch {}
30
37
  if (moveTop && typeof win.moveTop === "function") {
31
38
  try {
@@ -43,11 +50,14 @@ function showInactiveOnAllSpaces(win, { force = false } = {}) {
43
50
  return false;
44
51
  }
45
52
 
53
+ // NEVER hide() an already-visible window here: the hide/show cycle was a visible
54
+ // blink on every forced re-show (twice per Space change). showInactive() on a
55
+ // visible window is enough to re-attach it to the active Space once the
56
+ // all-workspaces collection behavior has been re-asserted above.
46
57
  reinforceSpacePresence(win);
47
- if (visible && force && typeof win.hide === "function") win.hide();
48
58
  if (typeof win.showInactive === "function") win.showInactive();
49
59
  reinforceSpacePresence(win, { moveTop: true });
50
- return true;
60
+ return !visible;
51
61
  }
52
62
 
53
63
  function subscribeActiveSpaceChanges(systemPreferences, callback, { log = () => {} } = {}) {