relay-companion 0.1.35 → 0.1.36

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}.`);
@@ -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) {
@@ -712,7 +772,7 @@ function claudeLastFocusedAt(metaPath) {
712
772
  return 0;
713
773
  }
714
774
  }
715
- function openClaudeDeepLinkVerified(url, fallbackUrl, label) {
775
+ function openClaudeDeepLinkVerified(url, fallbackUrl, label, { freshlyForged = false } = {}) {
716
776
  const fire = () =>
717
777
  shell.openExternal(url).catch((error) => console.error("[overlay] openExternal failed:", error && error.message));
718
778
  if (process.platform !== "darwin") return fire();
@@ -723,13 +783,20 @@ function openClaudeDeepLinkVerified(url, fallbackUrl, label) {
723
783
  const logOffset = claudeLogSize();
724
784
  fire();
725
785
  if (!metaPath && logOffset < 0) return; // nothing to verify against — legacy behavior
786
+ // On a FIRST open the companion CLI just forged the session and stamped
787
+ // lastFocusedAt=now — so the "focused moments ago" recency check would be vacuously
788
+ // true and confirm a resume that a wedged Claude actually swallowed (no nudge, no web
789
+ // fallback). Suppress recency ONLY on a fresh forge (signalled by the CLI). On a
790
+ // resume of an already-imported session the CLI does NOT stamp the timestamp, so a
791
+ // recent lastFocusedAt is a GENUINE Claude focus — keep trusting it, or a real resume
792
+ // the user was just looking at would falsely fall back to the web view.
726
793
  const confirmed = () => {
727
794
  if (metaPath) {
728
795
  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.
796
+ // A genuine Claude focus advances the timestamp past our pre-fire baseline.
731
797
  if (focusedAt > focusBaseline) return true;
732
- if (focusedAt && Date.now() - focusedAt < 15000) return true;
798
+ // Recency: trust it unless the CLI's own bootstrap write made it vacuously fresh.
799
+ if (!freshlyForged && focusedAt && Date.now() - focusedAt < 15000) return true;
733
800
  }
734
801
  return claudeLogSawResume(sessionId, logOffset);
735
802
  };
@@ -803,16 +870,18 @@ function openPacket(packetId) {
803
870
  child.on("exit", (code) => {
804
871
  let url = null;
805
872
  let skipExternalOpen = false;
873
+ let freshlyForged = false;
806
874
  try {
807
875
  const parsed = JSON.parse(out.trim());
808
876
  url = parsed.url || null;
809
877
  skipExternalOpen = Boolean(parsed.skipExternalOpen);
878
+ freshlyForged = Boolean(parsed.claudeFreshlyForged);
810
879
  } catch {}
811
880
  if (url || skipExternalOpen) {
812
881
  if (url && !skipExternalOpen) {
813
882
  const webFallback = row && row.taskId ? taskWebUrl(row.taskId) : row && row.actionUrl ? absoluteUrl(row.actionUrl) : webBase();
814
883
  if (claudeSessionIdFromUrl(url)) {
815
- openClaudeDeepLinkVerified(url, webFallback, packetId);
884
+ openClaudeDeepLinkVerified(url, webFallback, packetId, { freshlyForged });
816
885
  } else {
817
886
  shell.openExternal(url).catch((error) => console.error("[overlay] openExternal failed:", error && error.message));
818
887
  }
@@ -875,15 +944,17 @@ function openTaskDetail(taskId) {
875
944
  child.on("exit", (code) => {
876
945
  let url = null;
877
946
  let skipExternalOpen = false;
947
+ let freshlyForged = false;
878
948
  try {
879
949
  const parsed = JSON.parse(out.trim());
880
950
  url = parsed.url || null;
881
951
  skipExternalOpen = Boolean(parsed.skipExternalOpen);
952
+ freshlyForged = Boolean(parsed.claudeFreshlyForged);
882
953
  } catch {}
883
954
  if (url || skipExternalOpen) {
884
955
  if (url && !skipExternalOpen) {
885
956
  if (claudeSessionIdFromUrl(url)) {
886
- openClaudeDeepLinkVerified(url, taskWebUrl(taskId), taskId);
957
+ openClaudeDeepLinkVerified(url, taskWebUrl(taskId), taskId, { freshlyForged });
887
958
  } else {
888
959
  shell.openExternal(url).catch((error) => console.error("[overlay] openExternal failed:", error && error.message));
889
960
  }
@@ -953,16 +1024,21 @@ function showOverlayWindow({ force = false, reposition = true } = {}) {
953
1024
  }
954
1025
  placeOverlayWindow({ reposition });
955
1026
  const shown = showInactiveOnAllSpaces(win, { force });
956
- win.setIgnoreMouseEvents(true, { forward: true });
957
- if (shown) win.webContents.send("shown");
1027
+ if (shown) {
1028
+ // hidden -> shown only: re-assert click-through and reset the renderer's
1029
+ // interactivity handshake. Touching setIgnoreMouseEvents on an already-visible
1030
+ // window would silently flip it click-through while the renderer still thinks
1031
+ // the pointer handshake is live — a dead card until the pointer re-enters.
1032
+ win.setIgnoreMouseEvents(true, { forward: true });
1033
+ win.webContents.send("shown");
1034
+ }
958
1035
  }
959
1036
 
960
1037
  function maybeShow({ force = false, reposition = true } = {}) {
961
1038
  if (!win || win.isDestroyed()) return;
962
1039
  // Dismissed keeps the window hidden except while a ghost notification is up.
963
1040
  // trayForcedVisible honors an explicit status-area click even when no host runs.
964
- const dismissedEffective = trayAvailable && dismissed;
965
- const wanted = (hostRunning || trayForcedVisible) && (!dismissedEffective || ghostActive);
1041
+ const wanted = overlayWanted({ hostRunning, trayForcedVisible, trayAvailable, dismissed, ghostActive });
966
1042
  if (wanted) {
967
1043
  showOverlayWindow({ force, reposition });
968
1044
  } else if (win.isVisible()) {
@@ -988,6 +1064,10 @@ function readHostProcesses(cb) {
988
1064
  execFile("/bin/ps", ["-Ao", "comm"], cb);
989
1065
  }
990
1066
 
1067
+ // Debounced OFF transition (~3 misses = ~4.5s at the 1.5s poll): a host relaunching
1068
+ // during its own auto-update, or one truncated process-list read, must not blink the
1069
+ // pill off and back on.
1070
+ const hostTracker = createHostRunningTracker({ missThreshold: 3 });
991
1071
  function updateHostRunningFromProcesses(text) {
992
1072
  const s = String(text || "");
993
1073
  if (process.platform === "darwin") {
@@ -1003,7 +1083,7 @@ function updateHostRunningFromProcesses(text) {
1003
1083
  claudeRunning = /(^|\n)Claude(\n|$)/.test(s) || /(^|\n)claude(\n|$)/.test(s);
1004
1084
  codexRunning = /(^|\n)Codex(\n|$)/.test(s) || /(^|\n)codex(\n|$)/.test(s);
1005
1085
  }
1006
- hostRunning = claudeRunning || codexRunning;
1086
+ hostRunning = hostTracker.update(claudeRunning, codexRunning);
1007
1087
  }
1008
1088
 
1009
1089
  function pollHosts() {
@@ -1057,7 +1137,21 @@ function createWindow() {
1057
1137
  console.error("[renderer]", d);
1058
1138
  });
1059
1139
  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)));
1140
+ // A dead renderer leaves an invisible, unrecoverable window (the tray click would
1141
+ // "do nothing"). Reload it — with a short backoff so a hard crash can't hot-loop.
1142
+ let renderGoneAt = 0;
1143
+ win.webContents.on("render-process-gone", (_e, det) => {
1144
+ console.error("[render-gone]", JSON.stringify(det));
1145
+ if (!win || win.isDestroyed()) return;
1146
+ const delay = Date.now() - renderGoneAt < 30000 ? 5000 : 250;
1147
+ renderGoneAt = Date.now();
1148
+ setTimeout(() => {
1149
+ if (!win || win.isDestroyed()) return;
1150
+ lastSig = ""; // force a full repaint after reload
1151
+ knownIds = null;
1152
+ win.webContents.reload();
1153
+ }, delay);
1154
+ });
1061
1155
  win.webContents.on("did-finish-load", () => resetWindowZoom(win));
1062
1156
  win.webContents.on("zoom-changed", (event) => {
1063
1157
  if (event && typeof event.preventDefault === "function") event.preventDefault();
@@ -1071,7 +1165,8 @@ function createWindow() {
1071
1165
  }
1072
1166
  });
1073
1167
  win.once("ready-to-show", () => {
1074
- // buildPayload() awaits the first sent-relay load, so a single push paints all tabs.
1168
+ // First paint is instant: relays come from local state.json; the sent + contacts
1169
+ // background loads kicked off here each re-push when they land (buildPayload).
1075
1170
  pushInbox(true).catch((error) => console.error("[overlay] initial push failed:", error && error.message));
1076
1171
  pollHosts();
1077
1172
  });
@@ -1090,6 +1185,11 @@ function createWindow() {
1090
1185
  .then(() => pushInbox(false))
1091
1186
  .catch(() => {});
1092
1187
  }, 5000); // live sent-relay refresh
1188
+ setInterval(() => {
1189
+ refreshContacts()
1190
+ .then(() => pushInbox(false))
1191
+ .catch(() => {});
1192
+ }, 60000); // slow contact refresh; the contacts view also refreshes on open
1093
1193
  setInterval(pollHosts, 1500);
1094
1194
  }
1095
1195
 
@@ -1135,7 +1235,9 @@ function createTrayIcon() {
1135
1235
  return icon && !icon.isEmpty() ? icon : templateIcon && !templateIcon.isEmpty() ? templateIcon : null;
1136
1236
  }
1137
1237
 
1238
+ let lastTrayShowAt = 0;
1138
1239
  function showFromTray() {
1240
+ lastTrayShowAt = Date.now();
1139
1241
  setDismissed(false);
1140
1242
  ghostActive = false;
1141
1243
  trayForcedVisible = true; // an explicit click always gets the card
@@ -1229,6 +1331,10 @@ ipcMain.on("relay:setPos", (_e, x, y) => {
1229
1331
  // ✕ on the card: hide the overlay entirely until the status-area Relay mark is clicked.
1230
1332
  // The renderer plays its exit animation first, then sends this.
1231
1333
  ipcMain.on("relay:dismiss", () => {
1334
+ // Ignore only a dismiss whose IPC was already in flight when a tray "Show Relay"
1335
+ // just landed (narrow window). A genuine ✕ click is always honored.
1336
+ if (shouldIgnoreDismiss({ now: Date.now(), lastTrayShowAt })) return;
1337
+ lastTrayShowAt = 0; // honored: clear the guard so it can't linger
1232
1338
  if (!trayAvailable) {
1233
1339
  setDismissed(false);
1234
1340
  ghostActive = false;
@@ -1284,6 +1390,18 @@ if (!gotSingleInstanceLock) {
1284
1390
  createWindow();
1285
1391
  createTray();
1286
1392
  installActiveSpaceWatcher();
1393
+ // Test seam: the e2e harness (test/e2e-overlay.mjs) drives the real state machine
1394
+ // over the main-process inspector. Never set outside the harness.
1395
+ if (process.env.RELAY_OVERLAY_TEST === "1") {
1396
+ global.__relayTest = {
1397
+ showFromTray,
1398
+ maybeShow,
1399
+ refreshOverlayForActiveSpace,
1400
+ pollHosts,
1401
+ getWin: () => win,
1402
+ state: () => ({ dismissed, ghostActive, trayForcedVisible, hostRunning, visible: win && !win.isDestroyed() ? win.isVisible() : null }),
1403
+ };
1404
+ }
1287
1405
  });
1288
1406
 
1289
1407
  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 = () => {} } = {}) {
@@ -0,0 +1,54 @@
1
+ // Pure overlay-visibility logic, extracted from main.cjs so the state machine that
2
+ // decides when the pill window is on screen is unit-testable without Electron.
3
+ //
4
+ // The window is wanted when a host app is running (or the user explicitly clicked the
5
+ // status-area icon), unless the user dismissed the pill — with a ghost notification
6
+ // temporarily overriding a dismissal.
7
+
8
+ function overlayWanted({
9
+ hostRunning = false,
10
+ trayForcedVisible = false,
11
+ trayAvailable = false,
12
+ dismissed = false,
13
+ ghostActive = false,
14
+ } = {}) {
15
+ const dismissedEffective = trayAvailable && dismissed;
16
+ return (hostRunning || trayForcedVisible) && (!dismissedEffective || ghostActive);
17
+ }
18
+
19
+ // Debounced "a host is running" tracker. The OFF transition requires `missThreshold`
20
+ // consecutive polls with neither host present: a host relaunching during its own
21
+ // auto-update (or one truncated process-list read) must not blink the pill off.
22
+ function createHostRunningTracker({ missThreshold = 3 } = {}) {
23
+ let running = false;
24
+ let missStreak = 0;
25
+ return {
26
+ update(claudeRunning, codexRunning) {
27
+ if (claudeRunning || codexRunning) {
28
+ missStreak = 0;
29
+ running = true;
30
+ } else {
31
+ missStreak += 1;
32
+ if (missStreak >= missThreshold) running = false;
33
+ }
34
+ return running;
35
+ },
36
+ get running() {
37
+ return running;
38
+ },
39
+ };
40
+ }
41
+
42
+ // A dismiss whose IPC was ALREADY in flight when a tray "Show Relay" landed would hide
43
+ // the pill the user just reopened. The renderer's cancelBye() prevents the exit timer
44
+ // from firing after a reopen, so this guard only needs to cover the narrow window where
45
+ // the dismiss IPC had already been SENT (~IPC latency). Keep it short — a wide window
46
+ // would swallow a GENUINE ✕ click made shortly after Show Relay. lastTrayShowAt must be
47
+ // reset once a dismiss is honored so the guard never lingers across interactions.
48
+ function shouldIgnoreDismiss({ now, lastTrayShowAt, windowMs = 250 } = {}) {
49
+ if (!lastTrayShowAt) return false;
50
+ const delta = Number(now) - Number(lastTrayShowAt);
51
+ return delta >= 0 && delta < windowMs;
52
+ }
53
+
54
+ module.exports = { overlayWanted, createHostRunningTracker, shouldIgnoreDismiss };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-companion",
3
- "version": "0.1.35",
3
+ "version": "0.1.36",
4
4
  "description": "Relay companion: connects local coding agents to Relay tasks, approvals, and connector tools.",
5
5
  "type": "module",
6
6
  "bin": {