relay-companion 0.1.34 → 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}.`);
@@ -452,6 +452,14 @@
452
452
  .cv-input::placeholder { color:var(--muted-3); }
453
453
  .cv-input:focus { border-color:rgba(48,85,102,.42); box-shadow:0 0 0 3px rgba(48,85,102,.08); }
454
454
  .cv-form-actions { display:flex; align-items:center; justify-content:flex-end; gap:14px; margin-top:1px; }
455
+ .cv-delete {
456
+ appearance:none; border:0; background:transparent; color:#b4332a; font-family:var(--sans);
457
+ font-size:12.5px; font-weight:600; cursor:pointer; transition:opacity .15s var(--settle), color .15s var(--settle);
458
+ margin-right:auto; padding:0;
459
+ }
460
+ .cv-delete:hover { opacity:.72; }
461
+ .cv-delete.armed { color:#8f261f; }
462
+ .cv-delete:disabled { opacity:.4; cursor:default; }
455
463
  .cv-cancel { appearance:none; border:0; background:transparent; color:var(--muted); font-family:var(--sans); font-size:12.5px; font-weight:500; cursor:pointer; transition:color .15s var(--settle); }
456
464
  .cv-cancel:hover { color:var(--ink-2); }
457
465
  .cv-save {
@@ -601,6 +609,7 @@
601
609
  <div class="cv-hint" id="cvHint"></div>
602
610
  <div class="cv-error" id="cvError"></div>
603
611
  <div class="cv-form-actions">
612
+ <button class="cv-delete gone" type="button" id="cvDelete">Delete</button>
604
613
  <button class="cv-cancel" type="button" id="cvCancel">Cancel</button>
605
614
  <button class="cv-save" type="submit" id="cvSave">Save</button>
606
615
  </div>
@@ -643,6 +652,7 @@
643
652
  const cvErrorEl = document.getElementById("cvError");
644
653
  const cvSaveEl = document.getElementById("cvSave");
645
654
  const cvCancelEl = document.getElementById("cvCancel");
655
+ const cvDeleteEl = document.getElementById("cvDelete");
646
656
 
647
657
  const REDUCED = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
648
658
  const EXPANDED = { w: 344, h: 524 };
@@ -748,6 +758,15 @@
748
758
  }
749
759
  // ✕ — the card leaves gracefully, then the main process hides the window and keeps
750
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
+ }
751
770
  function dismissOverlay() {
752
771
  if (cardEl.classList.contains("bye")) return;
753
772
  if (ghostTimer) { clearTimeout(ghostTimer); ghostTimer = null; }
@@ -755,7 +774,8 @@
755
774
  const wasGhost = ghost;
756
775
  cardEl.classList.add("bye");
757
776
  setInteractive(false);
758
- setTimeout(() => {
777
+ byeTimer = setTimeout(() => {
778
+ byeTimer = null;
759
779
  if (wasGhost) window.relay.notifDone(); // already dismissed; just re-hide the window
760
780
  else window.relay.dismiss();
761
781
  resetOffstage();
@@ -766,6 +786,7 @@
766
786
  // when done it fades away instead of folding back into anything.
767
787
  function ghostArrival(row) {
768
788
  if (ghostTimer) { clearTimeout(ghostTimer); ghostTimer = null; }
789
+ cancelBye(); // a banner arriving mid-exit must not be torn down by the old exit
769
790
  clearPeek();
770
791
  collapsed = false;
771
792
  cardEl.classList.remove("collapsed", "offstage", "bye");
@@ -790,6 +811,7 @@
790
811
  // Status-area Relay mark clicked: the card comes back FULLY OPEN (never the pill).
791
812
  function trayOpen() {
792
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
793
815
  if (ghostTimer) { clearTimeout(ghostTimer); ghostTimer = null; }
794
816
  ghost = false;
795
817
  clearPeek();
@@ -833,8 +855,14 @@
833
855
  scrollEl.scrollTop = 0;
834
856
  springTo(PEEK.w, peekHeight());
835
857
  playTink();
836
- peekTimer = setTimeout(() => {
837
- 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;
838
866
  cardEl.classList.remove("peek");
839
867
  collapsed = true; cardEl.classList.add("collapsed");
840
868
  springTo(PILL.w, PILL.h);
@@ -1848,6 +1876,8 @@
1848
1876
  const COMMON_DOMAINS = ["gmail.com","outlook.com","hotmail.com","yahoo.com","icloud.com","me.com","proton.me","protonmail.com","live.com","aol.com"];
1849
1877
  let cvEmailsList = []; // committed, lowercased, deduped addresses (the chips)
1850
1878
  let cvSuggIdx = 0; // highlighted suggestion row
1879
+ let cvDeleteArmed = false;
1880
+ let cvDeleteTimer = null;
1851
1881
  function isValidEmail(s) { return EMAIL_RE.test(String(s).trim()); }
1852
1882
  function cvSuggestions() {
1853
1883
  const draft = cvEmailEl.value;
@@ -1907,6 +1937,21 @@
1907
1937
  cvEmailsList = cvEmailsList.filter((x) => x !== e);
1908
1938
  renderEmailChips();
1909
1939
  }
1940
+ function editingContactId() {
1941
+ return cvEditing && (cvEditing.id || cvEditing.contactId) ? String(cvEditing.id || cvEditing.contactId) : "";
1942
+ }
1943
+ function resetDeleteButton() {
1944
+ cvDeleteArmed = false;
1945
+ if (cvDeleteTimer) { clearTimeout(cvDeleteTimer); cvDeleteTimer = null; }
1946
+ cvDeleteEl.textContent = "Delete";
1947
+ cvDeleteEl.classList.remove("armed");
1948
+ cvDeleteEl.disabled = false;
1949
+ }
1950
+ function syncDeleteButton() {
1951
+ resetDeleteButton();
1952
+ const canDelete = Boolean(editingContactId());
1953
+ cvDeleteEl.classList.toggle("gone", !canDelete);
1954
+ }
1910
1955
  cvEmailsEl.addEventListener("mousedown", (e) => {
1911
1956
  if (e.target === cvEmailsEl) { e.preventDefault(); cvEmailEl.focus(); }
1912
1957
  });
@@ -1944,6 +1989,7 @@
1944
1989
  cvErrorEl.textContent = "";
1945
1990
  cvSuggestEl.classList.add("hidden"); cvSuggestEl.innerHTML = "";
1946
1991
  renderEmailChips();
1992
+ syncDeleteButton();
1947
1993
  cvFormEl.classList.remove("hidden");
1948
1994
  if (window.relay.setFocusable) window.relay.setFocusable(true);
1949
1995
  setInteractive(true);
@@ -1957,10 +2003,37 @@
1957
2003
  cvErrorEl.textContent = "";
1958
2004
  cvHintEl.textContent = "";
1959
2005
  cvSuggestEl.classList.add("hidden"); cvSuggestEl.innerHTML = "";
2006
+ cvDeleteEl.classList.add("gone");
2007
+ resetDeleteButton();
1960
2008
  if (window.relay.setFocusable) window.relay.setFocusable(false);
1961
2009
  }
1962
2010
  cvAddEl.addEventListener("click", () => openContactForm(null));
1963
2011
  cvCancelEl.addEventListener("click", closeContactForm);
2012
+ cvDeleteEl.addEventListener("click", async () => {
2013
+ const contactId = editingContactId();
2014
+ if (!contactId || cvDeleteEl.disabled) return;
2015
+ if (!cvDeleteArmed) {
2016
+ cvDeleteArmed = true;
2017
+ cvDeleteEl.textContent = "Delete?";
2018
+ cvDeleteEl.classList.add("armed");
2019
+ cvDeleteTimer = setTimeout(resetDeleteButton, 3500);
2020
+ return;
2021
+ }
2022
+ cvDeleteEl.disabled = true;
2023
+ cvSaveEl.disabled = true;
2024
+ cvErrorEl.textContent = "";
2025
+ try {
2026
+ const next = await window.relay.contactDelete({ contactId });
2027
+ if (Array.isArray(next)) contactsList = next;
2028
+ closeContactForm();
2029
+ renderContacts();
2030
+ } catch (err) {
2031
+ cvErrorEl.textContent = (err && err.message) ? err.message : "Could not delete.";
2032
+ cvDeleteEl.disabled = false;
2033
+ cvSaveEl.disabled = false;
2034
+ resetDeleteButton();
2035
+ }
2036
+ });
1964
2037
  cvFormEl.addEventListener("submit", async (e) => {
1965
2038
  e.preventDefault();
1966
2039
  const name = [cvFirstEl.value, cvLastEl.value].map((s) => s.trim()).filter(Boolean).join(" ");
@@ -1973,7 +2046,8 @@
1973
2046
  cvErrorEl.textContent = "";
1974
2047
  try {
1975
2048
  // Match the website's ContactWrite shape: emails[] is the identity, email = primary.
1976
- const next = await window.relay.contactSave({ name, emails, email: emails[0] });
2049
+ const contactId = editingContactId();
2050
+ const next = await window.relay.contactSave({ contactId, name, emails, email: emails[0] });
1977
2051
  if (Array.isArray(next)) contactsList = next;
1978
2052
  closeContactForm();
1979
2053
  renderContacts();
@@ -2070,6 +2144,15 @@
2070
2144
  onPayload(p);
2071
2145
  console.log("[overlay] booted ok, relays=" + (p && p.relays ? p.relays.length : 0) + " sent=" + (p && p.sent ? p.sent.length : 0));
2072
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);
2073
2156
  </script>
2074
2157
  </body>
2075
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,32 +309,48 @@ 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 {
312
- id: c.id || "",
328
+ id: c.id || c.contactId || "",
313
329
  name: c.name || emails[0] || "",
314
330
  email: emails[0] || c.email || "",
315
331
  emails,
332
+ onRelay: Boolean(c.onRelay),
316
333
  updatedAt: c.updatedAt || null,
317
334
  };
318
335
  })
319
336
  .sort((a, b) => String(a.name).localeCompare(String(b.name)));
320
337
  } catch (error) {
338
+ // Keep the last good cache; a transient network failure must not blank the UI.
321
339
  console.error("[overlay] listContacts failed:", error && error.message);
322
- return [];
323
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;
324
350
  }
325
351
 
326
352
  async function saveContact(input) {
353
+ const contactId = String((input && input.contactId) || "").trim();
327
354
  const name = String((input && input.name) || "").trim();
328
355
  const emails = Array.isArray(input && input.emails)
329
356
  ? input.emails.map((e) => String(e || "").trim()).filter(Boolean)
@@ -333,32 +360,56 @@ async function saveContact(input) {
333
360
  if (!name) throw new Error("A name is required");
334
361
  for (const e of emails) if (!e.includes("@")) throw new Error("That email looks off.");
335
362
  const client = await relayClient();
336
- await client.upsertContact({ name, emails, email: emails[0] || "" });
363
+ if (contactId) await client.updateContact(contactId, { name, emails, email: emails[0] || "" });
364
+ else await client.upsertContact({ name, emails, email: emails[0] || "" });
365
+ return readContacts();
366
+ }
367
+
368
+ async function deleteContactFromBook(input) {
369
+ const contactId = String((input && input.contactId) || input || "").trim();
370
+ if (!contactId) throw new Error("Missing contact id.");
371
+ const client = await relayClient();
372
+ await client.deleteContact(contactId);
337
373
  return readContacts();
338
374
  }
339
375
 
340
376
  // ---- payload assembly + push to renderer ---------------------------------
341
377
 
342
- async function buildPayload() {
343
- await ensureSentLoaded();
344
- 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(() => {});
345
387
  return {
346
388
  account: account(),
347
389
  ui: {
348
390
  canDismiss: trayAvailable,
349
391
  reopenSurface: reopenSurfaceName(),
350
392
  },
351
- relays,
393
+ relays: readRelays(),
352
394
  sent: sentCache,
353
395
  tasks: [],
354
- contacts,
396
+ contacts: contactsCache,
355
397
  };
356
398
  }
357
399
 
358
400
  let knownIds = null; // null until first load, so we never "notify" for rows already present
359
- 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) {
360
411
  if (!win || win.isDestroyed()) return;
361
- const payload = await buildPayload();
412
+ const payload = buildPayload();
362
413
  const rows = payload.relays;
363
414
  const notifiableRows = visibleRelayRows(rows);
364
415
  if (knownIds === null) {
@@ -382,7 +433,19 @@ async function pushInbox(force) {
382
433
  }
383
434
  }
384
435
  const sig = JSON.stringify({
385
- 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
+ ]),
386
449
  sent: payload.sent.map((r) => [
387
450
  r.relayId,
388
451
  r.state,
@@ -394,31 +457,37 @@ async function pushInbox(force) {
394
457
  contacts: payload.contacts.map((c) => [c.id, c.name, c.email]),
395
458
  account: [payload.account.paired, payload.account.email],
396
459
  });
397
- if (!force && sig === lastSig) {
398
- if (!win || win.isDestroyed()) return;
399
- }
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;
400
463
  lastSig = sig;
401
464
  if (win && !win.isDestroyed()) win.webContents.send("inbox", payload);
402
465
  }
403
466
 
404
467
  // Refresh state-derived rows only (fast path used by fs.watch + the safety poll).
405
468
  function pushInboxQuiet() {
406
- pushInbox(false).catch((error) => console.error("[overlay] pushInbox failed:", error && error.message));
469
+ pushInbox(false);
407
470
  }
408
471
 
409
472
  // ---- mark-read (write state.json directly) -------------------------------
410
473
 
411
474
  function ackPacket(packetId) {
412
475
  if (!packetId) return;
413
- const store = readStore();
414
- if (!store.packets || !store.packets[packetId]) return;
415
- const row = store.packets[packetId];
416
- store.packets[packetId] = {
417
- ...row,
418
- state: "read",
419
- updatedAt: new Date().toISOString(),
420
- };
421
- 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;
422
491
  if (row.relayNotificationKind === "plain_relay") {
423
492
  relayClient()
424
493
  .then((client) => client.markRead(packetId, { idempotencyKey: idempotencyKey("read") }))
@@ -520,18 +589,20 @@ async function answerQuestion(taskId, messageId, text, packetId) {
520
589
  }
521
590
 
522
591
  function markQuestionAnswered(packetId, answerMarkdown) {
523
- const store = readStore();
524
- const row = store.packets && store.packets[packetId];
525
- if (!row) return;
526
- store.packets[packetId] = {
527
- ...row,
528
- state: "read",
529
- quickReply: false,
530
- humanAnswered: true,
531
- humanAnswerMarkdown: answerMarkdown,
532
- updatedAt: new Date().toISOString(),
533
- };
534
- 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
+ });
535
606
  }
536
607
 
537
608
  function taskVersion(taskId) {
@@ -701,7 +772,7 @@ function claudeLastFocusedAt(metaPath) {
701
772
  return 0;
702
773
  }
703
774
  }
704
- function openClaudeDeepLinkVerified(url, fallbackUrl, label) {
775
+ function openClaudeDeepLinkVerified(url, fallbackUrl, label, { freshlyForged = false } = {}) {
705
776
  const fire = () =>
706
777
  shell.openExternal(url).catch((error) => console.error("[overlay] openExternal failed:", error && error.message));
707
778
  if (process.platform !== "darwin") return fire();
@@ -712,13 +783,20 @@ function openClaudeDeepLinkVerified(url, fallbackUrl, label) {
712
783
  const logOffset = claudeLogSize();
713
784
  fire();
714
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.
715
793
  const confirmed = () => {
716
794
  if (metaPath) {
717
795
  const focusedAt = claudeLastFocusedAt(metaPath);
718
- // Advanced past the pre-fire baseline, or focused moments ago (covers a
719
- // focus write racing our baseline snapshot) — either way Claude has it.
796
+ // A genuine Claude focus advances the timestamp past our pre-fire baseline.
720
797
  if (focusedAt > focusBaseline) return true;
721
- 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;
722
800
  }
723
801
  return claudeLogSawResume(sessionId, logOffset);
724
802
  };
@@ -792,16 +870,18 @@ function openPacket(packetId) {
792
870
  child.on("exit", (code) => {
793
871
  let url = null;
794
872
  let skipExternalOpen = false;
873
+ let freshlyForged = false;
795
874
  try {
796
875
  const parsed = JSON.parse(out.trim());
797
876
  url = parsed.url || null;
798
877
  skipExternalOpen = Boolean(parsed.skipExternalOpen);
878
+ freshlyForged = Boolean(parsed.claudeFreshlyForged);
799
879
  } catch {}
800
880
  if (url || skipExternalOpen) {
801
881
  if (url && !skipExternalOpen) {
802
882
  const webFallback = row && row.taskId ? taskWebUrl(row.taskId) : row && row.actionUrl ? absoluteUrl(row.actionUrl) : webBase();
803
883
  if (claudeSessionIdFromUrl(url)) {
804
- openClaudeDeepLinkVerified(url, webFallback, packetId);
884
+ openClaudeDeepLinkVerified(url, webFallback, packetId, { freshlyForged });
805
885
  } else {
806
886
  shell.openExternal(url).catch((error) => console.error("[overlay] openExternal failed:", error && error.message));
807
887
  }
@@ -864,15 +944,17 @@ function openTaskDetail(taskId) {
864
944
  child.on("exit", (code) => {
865
945
  let url = null;
866
946
  let skipExternalOpen = false;
947
+ let freshlyForged = false;
867
948
  try {
868
949
  const parsed = JSON.parse(out.trim());
869
950
  url = parsed.url || null;
870
951
  skipExternalOpen = Boolean(parsed.skipExternalOpen);
952
+ freshlyForged = Boolean(parsed.claudeFreshlyForged);
871
953
  } catch {}
872
954
  if (url || skipExternalOpen) {
873
955
  if (url && !skipExternalOpen) {
874
956
  if (claudeSessionIdFromUrl(url)) {
875
- openClaudeDeepLinkVerified(url, taskWebUrl(taskId), taskId);
957
+ openClaudeDeepLinkVerified(url, taskWebUrl(taskId), taskId, { freshlyForged });
876
958
  } else {
877
959
  shell.openExternal(url).catch((error) => console.error("[overlay] openExternal failed:", error && error.message));
878
960
  }
@@ -942,16 +1024,21 @@ function showOverlayWindow({ force = false, reposition = true } = {}) {
942
1024
  }
943
1025
  placeOverlayWindow({ reposition });
944
1026
  const shown = showInactiveOnAllSpaces(win, { force });
945
- win.setIgnoreMouseEvents(true, { forward: true });
946
- 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
+ }
947
1035
  }
948
1036
 
949
1037
  function maybeShow({ force = false, reposition = true } = {}) {
950
1038
  if (!win || win.isDestroyed()) return;
951
1039
  // Dismissed keeps the window hidden except while a ghost notification is up.
952
1040
  // trayForcedVisible honors an explicit status-area click even when no host runs.
953
- const dismissedEffective = trayAvailable && dismissed;
954
- const wanted = (hostRunning || trayForcedVisible) && (!dismissedEffective || ghostActive);
1041
+ const wanted = overlayWanted({ hostRunning, trayForcedVisible, trayAvailable, dismissed, ghostActive });
955
1042
  if (wanted) {
956
1043
  showOverlayWindow({ force, reposition });
957
1044
  } else if (win.isVisible()) {
@@ -977,6 +1064,10 @@ function readHostProcesses(cb) {
977
1064
  execFile("/bin/ps", ["-Ao", "comm"], cb);
978
1065
  }
979
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 });
980
1071
  function updateHostRunningFromProcesses(text) {
981
1072
  const s = String(text || "");
982
1073
  if (process.platform === "darwin") {
@@ -992,7 +1083,7 @@ function updateHostRunningFromProcesses(text) {
992
1083
  claudeRunning = /(^|\n)Claude(\n|$)/.test(s) || /(^|\n)claude(\n|$)/.test(s);
993
1084
  codexRunning = /(^|\n)Codex(\n|$)/.test(s) || /(^|\n)codex(\n|$)/.test(s);
994
1085
  }
995
- hostRunning = claudeRunning || codexRunning;
1086
+ hostRunning = hostTracker.update(claudeRunning, codexRunning);
996
1087
  }
997
1088
 
998
1089
  function pollHosts() {
@@ -1046,7 +1137,21 @@ function createWindow() {
1046
1137
  console.error("[renderer]", d);
1047
1138
  });
1048
1139
  win.webContents.on("preload-error", (_e, p, err) => console.error("[preload-error]", p, err && err.message));
1049
- 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
+ });
1050
1155
  win.webContents.on("did-finish-load", () => resetWindowZoom(win));
1051
1156
  win.webContents.on("zoom-changed", (event) => {
1052
1157
  if (event && typeof event.preventDefault === "function") event.preventDefault();
@@ -1060,7 +1165,8 @@ function createWindow() {
1060
1165
  }
1061
1166
  });
1062
1167
  win.once("ready-to-show", () => {
1063
- // 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).
1064
1170
  pushInbox(true).catch((error) => console.error("[overlay] initial push failed:", error && error.message));
1065
1171
  pollHosts();
1066
1172
  });
@@ -1079,6 +1185,11 @@ function createWindow() {
1079
1185
  .then(() => pushInbox(false))
1080
1186
  .catch(() => {});
1081
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
1082
1193
  setInterval(pollHosts, 1500);
1083
1194
  }
1084
1195
 
@@ -1124,7 +1235,9 @@ function createTrayIcon() {
1124
1235
  return icon && !icon.isEmpty() ? icon : templateIcon && !templateIcon.isEmpty() ? templateIcon : null;
1125
1236
  }
1126
1237
 
1238
+ let lastTrayShowAt = 0;
1127
1239
  function showFromTray() {
1240
+ lastTrayShowAt = Date.now();
1128
1241
  setDismissed(false);
1129
1242
  ghostActive = false;
1130
1243
  trayForcedVisible = true; // an explicit click always gets the card
@@ -1188,6 +1301,7 @@ ipcMain.handle("relay:decline", (_e, taskId, approvalId) => declineShare(taskId,
1188
1301
  ipcMain.handle("relay:answer", (_e, taskId, messageId, text, packetId) => answerQuestion(taskId, messageId, text, packetId));
1189
1302
  ipcMain.handle("relay:contacts", () => readContacts());
1190
1303
  ipcMain.handle("relay:contactSave", (_e, input) => saveContact(input));
1304
+ ipcMain.handle("relay:contactDelete", (_e, input) => deleteContactFromBook(input));
1191
1305
 
1192
1306
  // The overlay is normally focusable:false so it never steals keyboard focus from
1193
1307
  // Claude/Codex. Text fields (the contact form, quick reply) need focus, so the renderer
@@ -1217,6 +1331,10 @@ ipcMain.on("relay:setPos", (_e, x, y) => {
1217
1331
  // ✕ on the card: hide the overlay entirely until the status-area Relay mark is clicked.
1218
1332
  // The renderer plays its exit animation first, then sends this.
1219
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
1220
1338
  if (!trayAvailable) {
1221
1339
  setDismissed(false);
1222
1340
  ghostActive = false;
@@ -1272,6 +1390,18 @@ if (!gotSingleInstanceLock) {
1272
1390
  createWindow();
1273
1391
  createTray();
1274
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
+ }
1275
1405
  });
1276
1406
 
1277
1407
  app.on("window-all-closed", () => {
@@ -32,6 +32,7 @@ contextBridge.exposeInMainWorld("relay", {
32
32
  // contacts
33
33
  contacts: () => ipcRenderer.invoke("relay:contacts"),
34
34
  contactSave: (input) => ipcRenderer.invoke("relay:contactSave", input),
35
+ contactDelete: (input) => ipcRenderer.invoke("relay:contactDelete", input),
35
36
 
36
37
  // window plumbing
37
38
  setFocusable: (v) => ipcRenderer.send("relay:setFocusable", v),