relay-companion 0.1.70 → 0.1.72

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
@@ -28,6 +28,7 @@ import { pillStatusPath, waitForPillReady } from "../src/pill-control.js";
28
28
  import { liveToolRequirement, requiredLiveHosts, shouldRequireLiveTools } from "../src/setup-activation.js";
29
29
  import { openRelay, openTask } from "../src/materializer.js";
30
30
  import { runClaudeHook } from "../src/claude-hook.js";
31
+ import { normalizePairingCode, persistPairedAccount } from "../src/account.js";
31
32
  import { resetCompanionStateForAccount } from "../src/notifications.js";
32
33
  import { finishSetupOpenRelay, normalizeSetupHost, setupOpenRelayToken, setupOpenStatus } from "../src/setup-open.js";
33
34
 
@@ -95,8 +96,10 @@ async function cmdPair(flags, { promptForDefaults = true } = {}) {
95
96
  }
96
97
  writeConfig({ apiUrl: url, webUrl: appUrl });
97
98
  const client = new RelayClient({ url });
98
- const res = await client.registerDevice({ pairingCode: code, name, platform: process.platform });
99
- writeConfig({ apiUrl: url, webUrl: appUrl, deviceToken: res.deviceToken, deviceId: res.deviceId, user: res.user });
99
+ const res = await client.registerDevice({ pairingCode: normalizePairingCode(code), name, platform: process.platform });
100
+ // The same persistence the pill's Settings tab uses (src/account.js), so the
101
+ // stored credential shape can never drift between the two pairing surfaces.
102
+ persistPairedAccount({ apiUrl: url, webUrl: appUrl, deviceName: name, registration: res });
100
103
  resetCompanionStateForAccount({ user: res.user, deviceId: res.deviceId });
101
104
  console.log(`Paired as ${res.user.name} <${res.user.email}>. This device is now connected to Relay.`);
102
105
  }
@@ -647,6 +647,62 @@
647
647
  }
648
648
  .sent-chip.read { background:rgba(46,160,67,.14); color:#1a7f37; border:1px solid rgba(46,160,67,.28); }
649
649
  .sent-chip.unread { background:transparent; color:var(--muted-2); border:1px solid rgba(31,26,23,.18); }
650
+
651
+ /* ---- Settings tab ---- */
652
+ /* The account block is flat content on the sheet, like rows: serif identity,
653
+ sans metadata, a tinted avatar disc. Depth only ever comes from tint and
654
+ the reused .open-actions light-built panel — never from borders. */
655
+ .sv-head { display:flex; align-items:center; justify-content:space-between; padding:10px 16px 8px; }
656
+ .sv-title { font-family:var(--serif); font-size:16px; font-weight:500; color:var(--ink); }
657
+ .sv-account { display:flex; align-items:center; gap:12px; padding:4px 16px 0; }
658
+ .sv-avatar {
659
+ flex:0 0 auto; width:38px; height:38px; border-radius:50%;
660
+ background:var(--accent-soft); color:var(--accent);
661
+ font-size:13px; font-weight:600; letter-spacing:.02em;
662
+ display:flex; align-items:center; justify-content:center;
663
+ }
664
+ .sv-id { min-width:0; display:flex; flex-direction:column; }
665
+ .sv-name { font-family:var(--serif); font-size:16px; font-weight:500; color:var(--ink); line-height:1.25; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
666
+ .sv-email { font-size:11.5px; color:var(--muted); margin-top:1px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
667
+ .sv-device { font-size:11px; color:var(--muted-2); margin-top:2px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
668
+ .sv-copy { padding:2px 16px 0; font-size:12px; line-height:1.5; color:var(--muted); }
669
+ .sv-actions { margin:14px 12px 0; }
670
+ /* armed sign-out: the same flat menu row, flipped to the danger ink */
671
+ .oa-item.sv-signout-armed { color:#b4332a; }
672
+ .oa-item.sv-signout-armed:hover { background:rgba(180,51,42,.07); }
673
+ .oa-item.sv-signout-armed:active { background:rgba(180,51,42,.12); }
674
+ /* inline pairing-code entry: mono, tracked, uppercase — the code is the content */
675
+ .sv-pair { display:flex; gap:8px; margin:12px 16px 0; align-items:center; }
676
+ .sv-code {
677
+ flex:1 1 auto; min-width:0; appearance:none;
678
+ border:1px solid rgba(31,26,23,.14); border-radius:9px; padding:8px 11px;
679
+ font-family:var(--mono); font-size:13px; letter-spacing:.14em; text-transform:uppercase;
680
+ color:var(--ink); background:#fff; outline:none;
681
+ transition:border-color .15s var(--settle), box-shadow .15s var(--settle);
682
+ }
683
+ .sv-code::placeholder { font-family:var(--sans); letter-spacing:0; text-transform:none; color:var(--muted-3); }
684
+ .sv-code:focus { border-color:rgba(48,85,102,.42); box-shadow:0 0 0 3px rgba(48,85,102,.08); }
685
+ .sv-connect {
686
+ flex:0 0 auto; appearance:none; border:0; background:var(--accent); color:#fff;
687
+ font-family:var(--sans); font-size:12.5px; font-weight:600; padding:8px 15px; border-radius:9px;
688
+ cursor:pointer; transition:opacity .2s var(--settle);
689
+ }
690
+ .sv-connect:hover { opacity:.9; }
691
+ .sv-connect:disabled { opacity:.4; cursor:default; }
692
+ .sv-hint { padding:7px 16px 0; font-size:11px; line-height:1.45; color:var(--muted-2); }
693
+ .sv-err { margin-top:8px; padding:0 16px; }
694
+ .sv-note { padding:10px 16px 0; font-size:11.5px; color:#1a7f37; line-height:1.4; }
695
+ .sv-version { padding:20px 16px 12px; font-family:var(--mono); font-size:10.5px; color:var(--muted-3); }
696
+ /* signed-out call-to-action on the Relays empty state — quiet filled pill (th-count language) */
697
+ .sv-signin-banner {
698
+ appearance:none; border:0; cursor:pointer; margin-top:2px;
699
+ font-family:var(--sans); font-size:12px; font-weight:600; letter-spacing:.01em;
700
+ color:var(--accent); background:color-mix(in srgb, var(--accent) 9%, transparent);
701
+ box-shadow:inset 0 0 0 0.5px color-mix(in srgb, var(--accent) 14%, transparent);
702
+ border-radius:999px; padding:7px 15px;
703
+ transition:background-color .2s var(--settle);
704
+ }
705
+ .sv-signin-banner:hover { background:color-mix(in srgb, var(--accent) 15%, transparent); }
650
706
  </style>
651
707
  </head>
652
708
  <body>
@@ -684,6 +740,7 @@
684
740
  </button>
685
741
  <button class="tab" type="button" data-view="sent">Sent</button>
686
742
  <button class="tab" type="button" data-view="contacts">Contacts</button>
743
+ <button class="tab" type="button" data-view="settings">Settings</button>
687
744
  <button class="mark-all-read gone" id="markAllRead" type="button">Mark all as read</button>
688
745
  </nav>
689
746
 
@@ -697,6 +754,7 @@
697
754
  </svg>
698
755
  <div class="t1">No relays yet</div>
699
756
  <div class="t2">Questions, results, and notices will land here.</div>
757
+ <button class="sv-signin-banner gone" id="signInBanner" type="button">Sign in to Relay</button>
700
758
  </div>
701
759
  </section>
702
760
 
@@ -767,6 +825,9 @@
767
825
  <div class="cv-list" id="cvList"></div>
768
826
  <div id="contactsEmpty" class="cv-empty gone">No contacts yet.<br>Add someone, or your agent saves them as you relay.</div>
769
827
  </section>
828
+
829
+ <!-- Settings view (account card + sign-out / switch-account; rendered by JS) -->
830
+ <section class="view hidden" id="settingsView"></section>
770
831
  </div>
771
832
  </div>
772
833
 
@@ -811,6 +872,8 @@
811
872
  const thBackEl = document.getElementById("thBack");
812
873
  const thDetailNameEl = document.getElementById("thDetailName");
813
874
  const thHistoryEl = document.getElementById("thHistory");
875
+ const settingsViewEl = document.getElementById("settingsView");
876
+ const signInBannerEl = document.getElementById("signInBanner");
814
877
 
815
878
  const REDUCED = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
816
879
  const EXPANDED = { w: 344, h: 524 };
@@ -1432,6 +1495,7 @@
1432
1495
  }
1433
1496
  function openRelayFromUI(id, source = "relay", mode = "open") {
1434
1497
  if (!id || openingIds.has(id)) return;
1498
+ clearRowNote(id); // a previous open's failure text must not outlive the retry
1435
1499
  openingIds.set(id, performance.now());
1436
1500
  for (const el of openingTargets(id)) el.classList.add("opening");
1437
1501
  setTimeout(() => stopOpening(id), 6000); // safety: never spin forever if openDone is missed
@@ -1634,6 +1698,14 @@
1634
1698
  if (el) el.textContent = message || "";
1635
1699
  }
1636
1700
  function cssId(id) { return String(id).replace(/"/g, '\\"'); }
1701
+ // The .row-err slot carries both inline errors and the green injection note, and one
1702
+ // relay can be rendered into more than one list, so wipe every copy of the row's slot.
1703
+ function clearRowNote(id) {
1704
+ for (const el of document.querySelectorAll(`[data-err="${CSS.escape(id)}"]`)) {
1705
+ el.textContent = "";
1706
+ el.classList.remove("ok");
1707
+ }
1708
+ }
1637
1709
 
1638
1710
  async function runRowAction(id, btn, fn) {
1639
1711
  if (!btn || btn.disabled) return;
@@ -2573,6 +2645,201 @@
2573
2645
  applyView();
2574
2646
  }
2575
2647
 
2648
+ // ---------- Settings view ----------
2649
+ // The account card + sign-out / switch-account lifecycle. Renders from its own
2650
+ // relay:accountInfo load (like Contacts), never from inbox pushes, so a typed
2651
+ // pairing code is never rebuilt away mid-entry. Switch/sign-out succeed by
2652
+ // RESTARTING the pill into the new account, so the success note is brief.
2653
+ let settingsInfo = null; // last relay:accountInfo payload (null while loading)
2654
+ let settingsLoadSeq = 0;
2655
+ let svPairOpen = false; // inline code-entry row revealed
2656
+ let svDraft = ""; // pairing-code text preserved across re-renders
2657
+ let svBusy = false; // pair / sign-out in flight
2658
+ let svNote = ""; // success note while the pill restarts
2659
+ let svError = "";
2660
+ let svSignOutArmed = false;
2661
+ let svSignOutTimer = null;
2662
+ let svFocusCodeOnce = false;
2663
+
2664
+ function resetSignOutArm() {
2665
+ svSignOutArmed = false;
2666
+ if (svSignOutTimer) { clearTimeout(svSignOutTimer); svSignOutTimer = null; }
2667
+ }
2668
+ function svPairRowHtml() {
2669
+ return `
2670
+ <div class="sv-pair" data-stop="1">
2671
+ <input class="sv-code" id="svCode" type="text" inputmode="text" autocomplete="off" spellcheck="false"
2672
+ maxlength="16" placeholder="Pairing code" value="${esc(svDraft)}" ${svBusy ? "disabled" : ""} />
2673
+ <button class="sv-connect" type="button" id="svConnect" ${svBusy ? "disabled" : ""}>${svBusy ? "Connecting…" : "Connect"}</button>
2674
+ </div>
2675
+ <div class="sv-hint">Your browser opened the Relay setup page — paste the pairing code it shows.</div>`;
2676
+ }
2677
+ function renderSettings() {
2678
+ if (activeView !== "settings") return;
2679
+ const info = settingsInfo;
2680
+ if (!info) {
2681
+ settingsViewEl.innerHTML = '<div class="td-quiet">Loading account…</div>';
2682
+ return;
2683
+ }
2684
+ let html = "";
2685
+ if (info.paired) {
2686
+ const name = info.name || info.email || "Relay account";
2687
+ html += `
2688
+ <div class="sv-head"><span class="sv-title">Your account.</span></div>
2689
+ <div class="sv-account">
2690
+ <span class="sv-avatar">${esc(cvInitials(info.name, info.email))}</span>
2691
+ <span class="sv-id">
2692
+ <span class="sv-name">${esc(name)}</span>
2693
+ ${info.email ? `<span class="sv-email">${esc(info.email)}</span>` : ""}
2694
+ <span class="sv-device">This device · ${esc(info.deviceName || "")}</span>
2695
+ </span>
2696
+ </div>
2697
+ <div class="open-actions sv-actions" data-stop="1" role="menu">
2698
+ <button class="oa-item oa-primary" type="button" role="menuitem" id="svSwitch" ${svBusy ? "disabled" : ""}>Switch Account…</button>
2699
+ <div class="oa-sep" role="separator"></div>
2700
+ <button class="oa-item${svSignOutArmed ? " sv-signout-armed" : ""}" type="button" role="menuitem" id="svSignOut" ${svBusy ? "disabled" : ""}>${svSignOutArmed ? "Sign Out — click again to confirm" : "Sign Out"}</button>
2701
+ </div>`;
2702
+ } else {
2703
+ html += `
2704
+ <div class="sv-head"><span class="sv-title">Sign in to Relay.</span></div>
2705
+ <div class="sv-copy">This device isn’t connected to an account yet. Sign in from your browser, then enter the pairing code here.</div>
2706
+ <div class="open-actions sv-actions" data-stop="1" role="menu">
2707
+ <button class="oa-item oa-primary" type="button" role="menuitem" id="svSwitch" ${svBusy ? "disabled" : ""}>Sign In to Relay</button>
2708
+ </div>`;
2709
+ }
2710
+ if (svPairOpen) html += svPairRowHtml();
2711
+ if (svNote) html += `<div class="sv-note">${esc(svNote)}</div>`;
2712
+ html += `<div class="row-err sv-err" id="svErr">${esc(svError)}</div>`;
2713
+ html += `<div class="sv-version">${esc(info.version ? `Relay ${info.version}` : "Relay")}</div>`;
2714
+ settingsViewEl.innerHTML = html;
2715
+ wireSettings();
2716
+ }
2717
+ function wireSettings() {
2718
+ for (const z of settingsViewEl.querySelectorAll('[data-stop="1"]')) {
2719
+ z.addEventListener("click", (e) => e.stopPropagation());
2720
+ }
2721
+ const switchEl = document.getElementById("svSwitch");
2722
+ if (switchEl) switchEl.addEventListener("click", beginPairFlow);
2723
+ const signOutEl = document.getElementById("svSignOut");
2724
+ if (signOutEl) signOutEl.addEventListener("click", onSignOutClick);
2725
+ const connectEl = document.getElementById("svConnect");
2726
+ if (connectEl) connectEl.addEventListener("click", submitPairCode);
2727
+ const codeEl = document.getElementById("svCode");
2728
+ if (codeEl) {
2729
+ // Same focusability contract as the contact form / quick reply: the window
2730
+ // may take keyboard focus only while a text field needs it.
2731
+ codeEl.addEventListener("focus", () => { if (window.relay.setFocusable) window.relay.setFocusable(true); });
2732
+ codeEl.addEventListener("blur", () => { if (activeView !== "contacts" && window.relay.setFocusable) window.relay.setFocusable(false); });
2733
+ codeEl.addEventListener("mousedown", () => setInteractive(true));
2734
+ codeEl.addEventListener("input", () => {
2735
+ svDraft = codeEl.value;
2736
+ svError = "";
2737
+ const errEl = document.getElementById("svErr");
2738
+ if (errEl) errEl.textContent = ""; // clear inline, no rebuild mid-typing
2739
+ });
2740
+ codeEl.addEventListener("keydown", (ev) => {
2741
+ if (ev.key === "Enter") { ev.preventDefault(); submitPairCode(); }
2742
+ });
2743
+ if (svFocusCodeOnce) {
2744
+ svFocusCodeOnce = false;
2745
+ if (window.relay.setFocusable) window.relay.setFocusable(true);
2746
+ setTimeout(() => codeEl.focus(), 30);
2747
+ }
2748
+ }
2749
+ }
2750
+ // Switch account / sign in: open the pairing page in the browser and reveal
2751
+ // the inline code-entry row in the same motion.
2752
+ function beginPairFlow() {
2753
+ if (svBusy) return;
2754
+ resetSignOutArm();
2755
+ svError = "";
2756
+ svNote = "";
2757
+ svPairOpen = true;
2758
+ svFocusCodeOnce = true;
2759
+ if (window.relay.openUrl) window.relay.openUrl((settingsInfo && settingsInfo.setupPath) || "/app/setup");
2760
+ renderSettings();
2761
+ }
2762
+ async function submitPairCode() {
2763
+ if (svBusy) return;
2764
+ const codeEl = document.getElementById("svCode");
2765
+ const code = (codeEl ? codeEl.value : svDraft).trim();
2766
+ if (!code) {
2767
+ svError = "Enter the pairing code from your browser.";
2768
+ renderSettings();
2769
+ return;
2770
+ }
2771
+ svBusy = true;
2772
+ svError = "";
2773
+ renderSettings();
2774
+ let res = null;
2775
+ try {
2776
+ res = await window.relay.pairWithCode({ code });
2777
+ } catch (err) {
2778
+ res = { ok: false, error: (err && err.message) || "Could not connect this device." };
2779
+ }
2780
+ svBusy = false;
2781
+ if (res && res.ok) {
2782
+ svDraft = "";
2783
+ svPairOpen = false;
2784
+ svNote = res.email ? `Connected as ${res.email} — restarting Relay…` : "Connected — restarting Relay…";
2785
+ } else {
2786
+ svError = (res && res.error) || "Could not connect this device.";
2787
+ }
2788
+ renderSettings();
2789
+ }
2790
+ // Sign out is destructive-ish (this device stops receiving relays), so the
2791
+ // button arms on the first click and only a second click within 4s commits.
2792
+ async function onSignOutClick() {
2793
+ if (svBusy) return;
2794
+ if (!svSignOutArmed) {
2795
+ svSignOutArmed = true;
2796
+ if (svSignOutTimer) clearTimeout(svSignOutTimer);
2797
+ svSignOutTimer = setTimeout(() => {
2798
+ svSignOutTimer = null;
2799
+ svSignOutArmed = false;
2800
+ renderSettings();
2801
+ }, 4000);
2802
+ renderSettings();
2803
+ return;
2804
+ }
2805
+ resetSignOutArm();
2806
+ svBusy = true;
2807
+ svError = "";
2808
+ svNote = "";
2809
+ renderSettings();
2810
+ let res = null;
2811
+ try {
2812
+ res = await window.relay.signOut();
2813
+ } catch (err) {
2814
+ res = { ok: false, error: (err && err.message) || "Could not sign out." };
2815
+ }
2816
+ svBusy = false;
2817
+ if (res && res.ok) svNote = "Signed out — restarting Relay…";
2818
+ else svError = (res && res.error) || "Could not sign out.";
2819
+ renderSettings();
2820
+ }
2821
+ async function loadSettings() {
2822
+ const seq = ++settingsLoadSeq;
2823
+ let info = null;
2824
+ try {
2825
+ info = window.relay.accountInfo ? await window.relay.accountInfo() : null;
2826
+ } catch {}
2827
+ if (seq !== settingsLoadSeq) return; // navigated / reloaded while fetching
2828
+ const prevSig = JSON.stringify(settingsInfo);
2829
+ settingsInfo = info && info.ok !== false ? info : { paired: false, name: "", email: "", deviceName: "", version: "" };
2830
+ // Unchanged info: leave the DOM alone so a focused code input (and its
2831
+ // caret) survives the refresh that happens on every visit to the tab.
2832
+ if (JSON.stringify(settingsInfo) !== prevSig) renderSettings();
2833
+ }
2834
+ if (signInBannerEl) signInBannerEl.addEventListener("click", (e) => {
2835
+ e.stopPropagation();
2836
+ activeView = "settings";
2837
+ syncTabs();
2838
+ applyView();
2839
+ renderAll();
2840
+ beginPairFlow(); // the banner IS the sign-in affordance: browser + code row
2841
+ });
2842
+
2576
2843
  // ---------- views / tabs ----------
2577
2844
  function syncTabs() {
2578
2845
  // taskDetail belongs to Sent; the thread-detail view belongs to Relays —
@@ -2586,6 +2853,13 @@
2586
2853
  sentViewEl.classList.toggle("hidden", activeView !== "sent");
2587
2854
  taskDetailViewEl.classList.toggle("hidden", activeView !== "taskDetail");
2588
2855
  contactsViewEl.classList.toggle("hidden", activeView !== "contacts");
2856
+ settingsViewEl.classList.toggle("hidden", activeView !== "settings");
2857
+ if (activeView === "settings") {
2858
+ renderSettings(); // paint immediately from the last known info…
2859
+ loadSettings(); // …then refresh it from config
2860
+ } else {
2861
+ resetSignOutArm(); // leaving the view disarms the confirm state
2862
+ }
2589
2863
  if (activeView === "threads") {
2590
2864
  // Entered only by clicking a conversation row (threadDetailId set there).
2591
2865
  renderThreads();
@@ -2621,6 +2895,10 @@
2621
2895
 
2622
2896
  function renderAll() {
2623
2897
  syncDismissControl();
2898
+ // Signed out: the Relays empty state carries a slim sign-in affordance.
2899
+ if (signInBannerEl) {
2900
+ signInBannerEl.classList.toggle("gone", !payload.account || payload.account.paired !== false);
2901
+ }
2624
2902
  // Relays badge/count reflect only the items that show in the Relays tab
2625
2903
  // (the clean split keeps task_request / share_approval out of this count).
2626
2904
  const unread = (payload.relays || []).filter((r) => r.unread && isRelayListKind(r)).length;
@@ -2711,6 +2989,16 @@
2711
2989
  }, 12000));
2712
2990
  }
2713
2991
  });
2992
+ // An open that failed leaves the relay UNREAD on purpose (it never reached the user),
2993
+ // so the row has to say what happened — otherwise the spinner just stops and the click
2994
+ // looks like it worked. Cleared by the next open of the same row.
2995
+ if (window.relay.onOpenError) window.relay.onOpenError((id, message) => {
2996
+ if (!message) return;
2997
+ for (const el of document.querySelectorAll(`[data-err="${CSS.escape(id)}"]`)) {
2998
+ el.classList.remove("ok");
2999
+ el.innerHTML = esc(message);
3000
+ }
3001
+ });
2714
3002
  window.relay.refresh().then((p) => {
2715
3003
  onPayload(p);
2716
3004
  console.log("[overlay] booted ok, relays=" + (p && p.relays ? p.relays.length : 0) + " sent=" + (p && p.sent ? p.sent.length : 0));
package/overlay/main.cjs CHANGED
@@ -228,6 +228,24 @@ function loadRelayModules() {
228
228
  return relayModulesPromise;
229
229
  }
230
230
 
231
+ // Account lifecycle deps (ESM, lazy like the modules above): src/account.js is
232
+ // the shared pair/sign-out config persistence, src/notifications.js owns the
233
+ // packet-store reset that re-stages the new account's inbox cleanly.
234
+ let accountModulesPromise = null;
235
+ function loadAccountModules() {
236
+ if (!accountModulesPromise) {
237
+ const accountUrl = pathToFileURL(path.join(__dirname, "..", "src", "account.js")).href;
238
+ const notificationsUrl = pathToFileURL(path.join(__dirname, "..", "src", "notifications.js")).href;
239
+ accountModulesPromise = Promise.all([import(accountUrl), import(notificationsUrl)])
240
+ .then(([account, notifications]) => ({ account, notifications }))
241
+ .catch((error) => {
242
+ accountModulesPromise = null;
243
+ throw error;
244
+ });
245
+ }
246
+ return accountModulesPromise;
247
+ }
248
+
231
249
  let sentStagerPromise = null;
232
250
  function loadSentStager() {
233
251
  if (!sentStagerPromise) {
@@ -332,6 +350,118 @@ function account() {
332
350
  };
333
351
  }
334
352
 
353
+ function pillVersion() {
354
+ try {
355
+ return String(require("../package.json").version || "");
356
+ } catch {
357
+ return "";
358
+ }
359
+ }
360
+
361
+ // The Settings account card: who this pill is signed in as, on which device,
362
+ // running which build. deviceName is remembered from pairing when available.
363
+ function accountInfo() {
364
+ const cfg = readConfigFile();
365
+ const user = cfg.user || {};
366
+ return {
367
+ ok: true,
368
+ paired: Boolean(deviceToken()),
369
+ name: user.name || "",
370
+ email: user.email || "",
371
+ deviceName: String(cfg.deviceName || "").trim() || os.hostname(),
372
+ version: pillVersion(),
373
+ setupPath: "/app/setup",
374
+ };
375
+ }
376
+
377
+ // After an account change the daemon must restart into the new credentials, or
378
+ // it keeps polling (and staging) as the OLD account until its next launch.
379
+ function restartCompanionDaemon() {
380
+ try {
381
+ let cmd = null;
382
+ let args = null;
383
+ if (process.platform === "darwin") {
384
+ const uid = typeof process.getuid === "function" ? process.getuid() : 501;
385
+ cmd = "/bin/launchctl";
386
+ args = ["kickstart", "-k", `gui/${uid}/work.relay.companion`];
387
+ } else if (process.platform === "win32") {
388
+ cmd = "cmd";
389
+ args = ["/c", 'schtasks /End /TN "Relay Companion Daemon" & schtasks /Run /TN "Relay Companion Daemon"'];
390
+ }
391
+ if (!cmd) return;
392
+ const child = spawn(cmd, args, { detached: true, stdio: "ignore" });
393
+ child.on("error", (error) => console.error("[overlay] daemon restart spawn failed:", error && error.message));
394
+ child.unref();
395
+ } catch (error) {
396
+ console.error("[overlay] daemon restart failed:", error && error.message);
397
+ }
398
+ }
399
+
400
+ // Relaunch the pill itself so every cache (sent, contacts, attention queue,
401
+ // renderer state) is rebuilt from the new account. launchd / the Scheduled Task
402
+ // respawns it; app.relaunch() covers a bare `relay pill` run. Delayed a beat so
403
+ // the invoking IPC reply reaches the renderer first.
404
+ function relaunchPillSoon() {
405
+ setTimeout(() => {
406
+ try {
407
+ app.relaunch();
408
+ } catch (error) {
409
+ console.error("[overlay] relaunch failed:", error && error.message);
410
+ }
411
+ app.exit(0);
412
+ }, 600);
413
+ }
414
+
415
+ // Switch account: register this device against the pairing code, persist the
416
+ // fresh credentials with the SAME shape `relay pair` writes, wipe the local
417
+ // packet store for the new account, then restart the daemon and the pill.
418
+ async function pairWithCode(input) {
419
+ try {
420
+ const raw = input && typeof input === "object" ? input.code : input;
421
+ const [{ account: accountMod, notifications }, { RelayClient }] = await Promise.all([
422
+ loadAccountModules(),
423
+ loadRelayModules(),
424
+ ]);
425
+ const code = accountMod.normalizePairingCode(raw);
426
+ if (!code) return { ok: false, error: "Enter the pairing code from your browser." };
427
+ const deviceName = accountMod.deviceNameForPairing(readConfigFile());
428
+ const client = new RelayClient();
429
+ const res = await client.registerDevice({ pairingCode: code, name: deviceName, platform: process.platform });
430
+ accountMod.persistPairedAccount({ deviceName, registration: res });
431
+ notifications.resetCompanionStateForAccount(
432
+ { user: res.user, deviceId: res.deviceId, force: true },
433
+ { statePath: STATE_PATH },
434
+ );
435
+ restartCompanionDaemon();
436
+ relaunchPillSoon();
437
+ return { ok: true, email: (res.user && res.user.email) || "" };
438
+ } catch (error) {
439
+ const message = error && error.message ? error.message : String(error);
440
+ console.error("[overlay] pair with code failed:", message);
441
+ return { ok: false, error: message };
442
+ }
443
+ }
444
+
445
+ // Sign out: drop the credentials (keeping URLs + device name), wipe the local
446
+ // packet store, then restart the daemon and the pill into the signed-out state.
447
+ async function signOutAccount() {
448
+ try {
449
+ const { account: accountMod, notifications } = await loadAccountModules();
450
+ accountMod.persistSignedOutAccount();
451
+ notifications.resetCompanionStateForAccount(
452
+ { user: null, deviceId: "", force: true },
453
+ { statePath: STATE_PATH },
454
+ );
455
+ restartCompanionDaemon();
456
+ relaunchPillSoon();
457
+ return { ok: true };
458
+ } catch (error) {
459
+ const message = error && error.message ? error.message : String(error);
460
+ console.error("[overlay] sign out failed:", message);
461
+ return { ok: false, error: message };
462
+ }
463
+ }
464
+
335
465
  // Turn an actionUrl (absolute https, or a relative path) into an absolute URL.
336
466
  function absoluteUrl(pathOrUrl) {
337
467
  const value = String(pathOrUrl || "").trim();
@@ -546,6 +676,7 @@ async function markAllVisibleRelaysRead() {
546
676
  let sentCache = [];
547
677
  let sentLoadedOnce = null;
548
678
  async function refreshSent() {
679
+ if (!deviceToken()) return sentCache; // signed out: nothing to fetch, no 401 log storm
549
680
  try {
550
681
  const client = await relayClient();
551
682
  const res = await client.sent();
@@ -569,6 +700,7 @@ async function refreshTasks() {
569
700
  tasksCache = [];
570
701
  return tasksCache;
571
702
  }
703
+ if (!deviceToken()) return tasksCache; // signed out: skip the poll entirely
572
704
  try {
573
705
  const client = await relayClient();
574
706
  const res = await client.listTasks();
@@ -594,6 +726,7 @@ function ensureTasksLoaded() {
594
726
  let contactsCache = [];
595
727
  let contactsLoadedOnce = null;
596
728
  async function refreshContacts() {
729
+ if (!deviceToken()) return contactsCache; // signed out: skip the poll entirely
597
730
  try {
598
731
  const client = await relayClient();
599
732
  const res = await client.listContacts();
@@ -1375,6 +1508,95 @@ function openClaudeDeepLinkVerified(url, fallbackUrl, label, { freshlyForged = f
1375
1508
  }, 1000);
1376
1509
  }
1377
1510
 
1511
+ // The CLI's `open` prints ONE pretty-printed JSON object on stdout and sends every
1512
+ // log line to stderr, so a plain parse should suffice — but a single stray stdout
1513
+ // line (a Node warning, a dep's console.log) used to turn a SUCCESSFUL open into a
1514
+ // browser fallback, so recover by slicing the outermost {...} instead of giving up.
1515
+ function parseOpenResult(out) {
1516
+ const text = String(out || "").trim();
1517
+ const read = (raw) => {
1518
+ const parsed = JSON.parse(raw);
1519
+ return {
1520
+ url: parsed.url || null,
1521
+ skipExternalOpen: Boolean(parsed.skipExternalOpen),
1522
+ freshlyForged: Boolean(parsed.claudeFreshlyForged),
1523
+ };
1524
+ };
1525
+ try {
1526
+ return read(text);
1527
+ } catch {}
1528
+ const start = text.indexOf("{");
1529
+ const end = text.lastIndexOf("}");
1530
+ if (start >= 0 && end > start) {
1531
+ try {
1532
+ return read(text.slice(start, end + 1));
1533
+ } catch {}
1534
+ }
1535
+ return { url: null, skipExternalOpen: false, freshlyForged: false };
1536
+ }
1537
+
1538
+ // Both stdout and stderr in the failure log: a Windows field report where stdout
1539
+ // was empty is the whole reason the "exit" -> "close" fix below exists.
1540
+ function tailFor(stream) {
1541
+ return String(stream || "").trim().split("\n").slice(-2).join(" | ");
1542
+ }
1543
+
1544
+ // Whether the `relay claude-hook` runtime is registered in ~/.claude/settings.json.
1545
+ // "Open in current chat" stages a file that ONLY that runtime consumes, so without
1546
+ // it the click is a silent no-op. Loaded lazily like the modules above (install.js
1547
+ // is ESM; this overlay is CommonJS).
1548
+ let claudeHooksModulePromise = null;
1549
+ function loadClaudeHooksModule() {
1550
+ if (!claudeHooksModulePromise) {
1551
+ const installUrl = pathToFileURL(path.join(__dirname, "..", "src", "install.js")).href;
1552
+ claudeHooksModulePromise = import(installUrl).catch((error) => {
1553
+ claudeHooksModulePromise = null;
1554
+ throw error;
1555
+ });
1556
+ }
1557
+ return claudeHooksModulePromise;
1558
+ }
1559
+
1560
+ // A missing or unparseable settings file counts as NOT installed: staging into a
1561
+ // machine with no hook runtime is worse than a fresh open, so fail closed.
1562
+ function claudeHooksInstalled(install) {
1563
+ let settings;
1564
+ try {
1565
+ settings = JSON.parse(fs.readFileSync(install.claudeSettingsPath(), "utf8")) || {};
1566
+ } catch {
1567
+ return false;
1568
+ }
1569
+ const hooks = settings.hooks;
1570
+ if (!hooks || typeof hooks !== "object" || Array.isArray(hooks)) return false;
1571
+ for (const entries of Object.values(hooks)) {
1572
+ for (const entry of Array.isArray(entries) ? entries : []) {
1573
+ for (const hook of entry && Array.isArray(entry.hooks) ? entry.hooks : []) {
1574
+ if (install.isRelayClaudeHookCommand(hook && hook.command)) return true;
1575
+ }
1576
+ }
1577
+ }
1578
+ return false;
1579
+ }
1580
+
1581
+ // Register the claude-hook runtime for FUTURE Claude sessions (idempotent; it preserves
1582
+ // every user hook). installClaudeHooks defaults its node to process.execPath, which under
1583
+ // Electron is the OVERLAY binary — a hook command Claude could never run, and one that
1584
+ // would make the presence check above pass forever. So resolve a real node first and skip
1585
+ // the repair entirely when the machine has none.
1586
+ function repairClaudeHooks(install) {
1587
+ execFile(process.platform === "win32" ? "where" : "/usr/bin/which", ["node"], (error, out) => {
1588
+ const node = String(out || "").split("\n")[0].trim();
1589
+ if (error || !node) {
1590
+ console.error("[overlay] cannot register the claude-hook runtime: no node found on PATH");
1591
+ return;
1592
+ }
1593
+ const result = install.installClaudeHooks(undefined, install.stableNodePath(node));
1594
+ if (!result || !result.ok) {
1595
+ console.error("[overlay] claude-hook registration failed:", (result && result.reason) || "unknown");
1596
+ }
1597
+ });
1598
+ }
1599
+
1378
1600
  // Click-to-open a Relay row. A relay materializes a REAL native agent session
1379
1601
  // inside the already-running foregrounded host (Claude Desktop or Codex) via the
1380
1602
  // companion CLI's `open` command, so it appears in that app's recents rail. The
@@ -1404,20 +1626,29 @@ async function openPacket(packetId, { sent = false, fresh = false } = {}) {
1404
1626
  }
1405
1627
  const row = rowById(materializationId);
1406
1628
  if (win && !win.isDestroyed()) win.webContents.send("opening", packetId);
1407
- const finish = () => {
1629
+ // Ack marks the row read AND drops it from the attention queue, so a relay that
1630
+ // never reached the user must NOT be acked — otherwise a failed click silently
1631
+ // retires the notification. The spinner stop, in contrast, owes every path.
1632
+ const finishOpened = () => {
1408
1633
  if (!sent) ackPacket(packetId);
1409
1634
  if (win && !win.isDestroyed()) win.webContents.send("openDone", packetId); // stop the row spinner
1410
1635
  };
1636
+ const finishFailed = (message) => {
1637
+ if (win && !win.isDestroyed()) {
1638
+ if (message) win.webContents.send("openError", packetId, message);
1639
+ win.webContents.send("openDone", packetId);
1640
+ }
1641
+ };
1411
1642
  if (!TASK_FEATURES_ALLOWED && (row?.taskId || isRelayTaskWebTarget(row?.actionUrl))) {
1412
1643
  console.error("[overlay] refusing to open a Relay Task in messages-only mode:", packetId);
1413
- return finish();
1644
+ return finishFailed();
1414
1645
  }
1415
- if (process.env.RELAY_OVERLAY_TEST_NO_HOST_OPEN === "1") return finish();
1646
+ if (process.env.RELAY_OVERLAY_TEST_NO_HOST_OPEN === "1") return finishOpened();
1416
1647
  if (row && row.relayNotificationKind === "connector_reauth") {
1417
1648
  shell
1418
1649
  .openExternal(`${webBase()}/app/connectors`)
1419
1650
  .catch((error) => console.error("[overlay] open failed:", error && error.message));
1420
- return finish();
1651
+ return finishOpened();
1421
1652
  }
1422
1653
  frontmostBundleId((bundle) => {
1423
1654
  const host = resolveClickHost(bundle);
@@ -1446,16 +1677,11 @@ async function openPacket(packetId, { sent = false, fresh = false } = {}) {
1446
1677
  let err = "";
1447
1678
  child.stdout.on("data", (data) => (out += data));
1448
1679
  child.stderr.on("data", (data) => (err += data));
1449
- child.on("exit", (code) => {
1450
- let url = null;
1451
- let skipExternalOpen = false;
1452
- let freshlyForged = false;
1453
- try {
1454
- const parsed = JSON.parse(out.trim());
1455
- url = parsed.url || null;
1456
- skipExternalOpen = Boolean(parsed.skipExternalOpen);
1457
- freshlyForged = Boolean(parsed.claudeFreshlyForged);
1458
- } catch {}
1680
+ // "close", never "exit": on Windows "exit" routinely fires BEFORE the stdout pipe
1681
+ // drains, so `out` was empty, the parse failed, and a perfectly successful open was
1682
+ // routed to the web fallback — the user got a browser instead of Claude.
1683
+ child.on("close", (code) => {
1684
+ const { url, skipExternalOpen, freshlyForged } = parseOpenResult(out);
1459
1685
  if (url || skipExternalOpen) {
1460
1686
  // Raise the host so the relay is visible, whichever open path ran (Claude deep
1461
1687
  // link, Codex bridge, or a plain url). This is the fix for "opened in the right
@@ -1469,6 +1695,7 @@ async function openPacket(packetId, { sent = false, fresh = false } = {}) {
1469
1695
  shell.openExternal(url).catch((error) => console.error("[overlay] openExternal failed:", error && error.message));
1470
1696
  }
1471
1697
  }
1698
+ finishOpened();
1472
1699
  } else {
1473
1700
  console.error(
1474
1701
  "[overlay] open failed for",
@@ -1477,22 +1704,25 @@ async function openPacket(packetId, { sent = false, fresh = false } = {}) {
1477
1704
  host,
1478
1705
  "code",
1479
1706
  code,
1707
+ "stdout:",
1708
+ tailFor(out),
1480
1709
  "stderr:",
1481
- err.trim().split("\n").slice(-2).join(" | "),
1710
+ tailFor(err),
1482
1711
  );
1483
- // Last-resort web fallback so the click is never a dead end.
1712
+ // Last-resort web fallback so the click is never a dead end — but the relay
1713
+ // did not open where it was meant to, so it stays unread and in the queue.
1484
1714
  const target = row && row.taskId
1485
1715
  ? taskWebUrl(row.taskId)
1486
1716
  : row && row.actionUrl
1487
1717
  ? absoluteUrl(row.actionUrl)
1488
1718
  : webBase();
1489
1719
  shell.openExternal(target).catch((e) => console.error("[overlay] web fallback failed:", e && e.message));
1720
+ finishFailed("Couldn't open in Claude — opened on the web instead.");
1490
1721
  }
1491
- finish();
1492
1722
  });
1493
1723
  child.on("error", (error) => {
1494
1724
  console.error("[overlay] open spawn error:", error && error.message);
1495
- finish();
1725
+ finishFailed("Couldn't open — Relay's open helper failed to start.");
1496
1726
  });
1497
1727
  });
1498
1728
  }
@@ -1594,22 +1824,50 @@ async function openPacketInCurrent(packetId) {
1594
1824
  }
1595
1825
  }
1596
1826
  if (!target) return fallbackFresh();
1597
- try {
1598
- claudeInject.stageInjection(RELAY_HOME, target.sessionId, {
1599
- relayId: packetId,
1600
- senderName: row.senderName || "",
1601
- title: row.title || row.displayTitle || "",
1602
- ...threadInfoFor(row),
1603
- });
1604
- } catch (error) {
1605
- console.error("[overlay] claude injection staging failed:", error && error.message);
1606
- return fallbackFresh();
1607
- }
1608
- // Raise Claude Desktop so the user watches the instruction land; a
1609
- // terminal-only session stays where it is (its window is not ours to raise).
1610
- if (target.source === "desktop") activateHost("claude", bundle);
1611
- confirmInjected("claude");
1612
- finish();
1827
+ const stageNow = () => {
1828
+ try {
1829
+ claudeInject.stageInjection(RELAY_HOME, target.sessionId, {
1830
+ relayId: packetId,
1831
+ senderName: row.senderName || "",
1832
+ title: row.title || row.displayTitle || "",
1833
+ ...threadInfoFor(row),
1834
+ });
1835
+ } catch (error) {
1836
+ console.error("[overlay] claude injection staging failed:", error && error.message);
1837
+ return fallbackFresh();
1838
+ }
1839
+ // Raise Claude Desktop so the user watches the instruction land; a
1840
+ // terminal-only session stays where it is (its window is not ours to raise).
1841
+ if (target.source === "desktop") activateHost("claude", bundle);
1842
+ confirmInjected("claude");
1843
+ finish();
1844
+ };
1845
+ // NOTHING but the `relay claude-hook` runtime registered in ~/.claude/settings.json
1846
+ // ever reads the staged file. Where setup never wrote those hooks (Windows installs
1847
+ // where the `claude` CLI isn't on PATH — see src/install.js runSetupInstall) staging
1848
+ // succeeds and the click is a silent, acked no-op. Check before staging. Two-arg
1849
+ // then, not .catch: a throw out of stageNow must not re-enter the failure handler
1850
+ // and finish the click twice.
1851
+ loadClaudeHooksModule().then(
1852
+ (install) => {
1853
+ if (claudeHooksInstalled(install)) return stageNow();
1854
+ // Repair for FUTURE sessions — but a Claude session that is already running
1855
+ // loaded its hooks at startup and will never consume a file staged now, so this
1856
+ // click has to take the fresh-open path, the only one that actually shows the
1857
+ // relay. No ack here: openPacket owns the read state for the fresh open.
1858
+ console.error(
1859
+ "[overlay] claude-hook runtime not registered in",
1860
+ install.claudeSettingsPath(),
1861
+ "— 'Open in current chat' would have been a silent no-op; installing it for future sessions and opening a fresh chat instead (restart Claude to use it)",
1862
+ );
1863
+ repairClaudeHooks(install);
1864
+ fallbackFresh();
1865
+ },
1866
+ (error) => {
1867
+ console.error("[overlay] claude-hook runtime check failed:", error && error.message);
1868
+ fallbackFresh();
1869
+ },
1870
+ );
1613
1871
  });
1614
1872
  }
1615
1873
 
@@ -1645,16 +1903,10 @@ function openTaskDetail(taskId) {
1645
1903
  let err = "";
1646
1904
  child.stdout.on("data", (data) => (out += data));
1647
1905
  child.stderr.on("data", (data) => (err += data));
1648
- child.on("exit", (code) => {
1649
- let url = null;
1650
- let skipExternalOpen = false;
1651
- let freshlyForged = false;
1652
- try {
1653
- const parsed = JSON.parse(out.trim());
1654
- url = parsed.url || null;
1655
- skipExternalOpen = Boolean(parsed.skipExternalOpen);
1656
- freshlyForged = Boolean(parsed.claudeFreshlyForged);
1657
- } catch {}
1906
+ // "close", never "exit" — same Windows stdout-drain race as openPacket: an empty
1907
+ // parse used to send a successful task open to the browser.
1908
+ child.on("close", (code) => {
1909
+ const { url, skipExternalOpen, freshlyForged } = parseOpenResult(out);
1658
1910
  if (url || skipExternalOpen) {
1659
1911
  activateHost(host, bundle); // raise the host so the opened task is visible
1660
1912
  if (url && !skipExternalOpen) {
@@ -1672,8 +1924,10 @@ function openTaskDetail(taskId) {
1672
1924
  host,
1673
1925
  "code",
1674
1926
  code,
1927
+ "stdout:",
1928
+ tailFor(out),
1675
1929
  "stderr:",
1676
- err.trim().split("\n").slice(-2).join(" | "),
1930
+ tailFor(err),
1677
1931
  );
1678
1932
  // Last-resort web fallback so the click is never a dead end.
1679
1933
  shell
@@ -1696,6 +1950,12 @@ function openUrlTarget(url) {
1696
1950
  return;
1697
1951
  }
1698
1952
  const target = absoluteUrl(url);
1953
+ // Sandboxed harness runs must never pop the user's real browser (the same
1954
+ // contract that keeps them from launching Claude/Codex).
1955
+ if (process.env.RELAY_OVERLAY_TEST_NO_HOST_OPEN === "1") {
1956
+ console.error("[overlay] test seam: suppressed external open:", target);
1957
+ return;
1958
+ }
1699
1959
  shell.openExternal(target).catch((error) => console.error("[overlay] open failed:", error && error.message));
1700
1960
  }
1701
1961
 
@@ -1704,7 +1964,21 @@ function openUrlTarget(url) {
1704
1964
  function anchorTopRight() {
1705
1965
  const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()) || screen.getPrimaryDisplay();
1706
1966
  const wa = display.workArea;
1707
- return { x: wa.x + wa.width - WIN.width - MARGIN, y: wa.y + MARGIN, width: WIN.width, height: WIN.height };
1967
+ const anchor = { x: wa.x + wa.width - WIN.width - MARGIN, y: wa.y + MARGIN, width: WIN.width, height: WIN.height };
1968
+ if (process.env.RELAY_OVERLAY_TEST === "1") {
1969
+ // Sandboxed harness runs must never materialize under the user's parked
1970
+ // cursor: a pointer resting on the card holds notifications open BY DESIGN
1971
+ // (macOS banners do the same), which would wedge every dwell-based scenario
1972
+ // when the mouse happens to sit in the top-right corner. Flip the sandbox
1973
+ // window to the opposite edge instead. Production placement is unchanged.
1974
+ try {
1975
+ const p = screen.getCursorScreenPoint();
1976
+ const overlaps =
1977
+ p.x >= anchor.x && p.x < anchor.x + anchor.width && p.y >= anchor.y && p.y < anchor.y + anchor.height;
1978
+ if (overlaps) anchor.x = wa.x + MARGIN;
1979
+ } catch {}
1980
+ }
1981
+ return anchor;
1708
1982
  }
1709
1983
 
1710
1984
  // Show the overlay window. Electron's forwarded-mousemove stream (the thing that
@@ -1834,6 +2108,12 @@ let hitTimer = null;
1834
2108
  let hitIgnoring = true; // mirrors the last setIgnoreMouseEvents call
1835
2109
  let hitHover = false;
1836
2110
  let lastHostProbeAt = 0;
2111
+ // Harness seam: a sandboxed e2e run shares the screen with the user's REAL
2112
+ // mouse, and a pointer that happens to rest on the card rect legitimately
2113
+ // holds notifications open (the macOS-banner rule) — wedging every dwell-based
2114
+ // scenario. The harness drives only synthetic input, so in this mode the hit
2115
+ // test ignores the physical cursor entirely. Never set outside the harness.
2116
+ const HIT_TEST_POINTER_BLIND = process.env.RELAY_OVERLAY_TEST_IGNORE_POINTER === "1";
1837
2117
 
1838
2118
  function applyIgnore(next) {
1839
2119
  if (next === hitIgnoring) return;
@@ -1874,6 +2154,14 @@ function scheduleHit(ms) {
1874
2154
  function hitTick() {
1875
2155
  hitTimer = null;
1876
2156
  if (!win || win.isDestroyed()) return;
2157
+ if (HIT_TEST_POINTER_BLIND) {
2158
+ // Interactive everywhere, hover never asserted: synthetic-input runs must
2159
+ // not have their dwell/fold behavior steered by the user's parked mouse.
2160
+ applyIgnore(false);
2161
+ applyHover(false);
2162
+ scheduleHit(POLL_HIDDEN_MS);
2163
+ return;
2164
+ }
1877
2165
  if (!win.isVisible()) {
1878
2166
  // A hidden window must never hold clicks, and must not cost a cursor read.
1879
2167
  applyIgnore(true);
@@ -1949,7 +2237,10 @@ function createWindow() {
1949
2237
  },
1950
2238
  });
1951
2239
 
1952
- win.setAlwaysOnTop(true, "floating");
2240
+ // Windows strips WS_EX_TOPMOST from a scheduled-task-launched overlay that only asks
2241
+ // politely ("floating"); "screen-saver" is the level that survives. The periodic
2242
+ // re-assert that recovers from later demotions lives in space-presence.cjs.
2243
+ win.setAlwaysOnTop(true, process.platform === "win32" ? "screen-saver" : "floating");
1953
2244
  win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
1954
2245
  // Empty canvas is click-through; startHitTest() below owns this from here on.
1955
2246
  win.setIgnoreMouseEvents(true, { forward: true });
@@ -2291,6 +2582,11 @@ ipcMain.handle("relay:contacts", () => readContacts());
2291
2582
  ipcMain.handle("relay:contactSave", (_e, input) => saveContact(input));
2292
2583
  ipcMain.handle("relay:contactDelete", (_e, input) => deleteContactFromBook(input));
2293
2584
 
2585
+ // Settings tab: account card + the sign-out / switch-account lifecycle.
2586
+ ipcMain.handle("relay:accountInfo", () => accountInfo());
2587
+ ipcMain.handle("relay:pairWithCode", (_e, input) => pairWithCode(input));
2588
+ ipcMain.handle("relay:signOut", () => signOutAccount());
2589
+
2294
2590
  // The overlay is normally focusable:false so it never steals keyboard focus from
2295
2591
  // Claude/Codex. Text fields (the contact form, quick reply) need focus, so the renderer
2296
2592
  // asks us to grant it while a field is open and revoke it the moment the form closes.
@@ -8,6 +8,9 @@ contextBridge.exposeInMainWorld("relay", {
8
8
  // "Open in current chat" staged an injection: the UI confirms it so the
9
9
  // click has visible feedback (delivery is checkpoint-based, not instant).
10
10
  onInjected: (cb) => ipcRenderer.on("injected", (_e, id, info) => cb(id, info || {})),
11
+ // The open failed (CLI error / helper wouldn't spawn). The row stays unread, so
12
+ // say why inline instead of letting the spinner just stop.
13
+ onOpenError: (cb) => ipcRenderer.on("openError", (_e, id, message) => cb(id, message || "")),
11
14
  onOpenFull: (cb) => ipcRenderer.on("openFull", () => cb()), // status-area icon clicked
12
15
  onShown: (cb) => ipcRenderer.on("shown", () => cb()), // window re-shown; reset hover handshake
13
16
 
@@ -43,6 +46,11 @@ contextBridge.exposeInMainWorld("relay", {
43
46
  contactSave: (input) => ipcRenderer.invoke("relay:contactSave", input),
44
47
  contactDelete: (input) => ipcRenderer.invoke("relay:contactDelete", input),
45
48
 
49
+ // settings / account (switch + sign-out relaunch the pill on success)
50
+ accountInfo: () => ipcRenderer.invoke("relay:accountInfo"),
51
+ pairWithCode: (input) => ipcRenderer.invoke("relay:pairWithCode", input),
52
+ signOut: () => ipcRenderer.invoke("relay:signOut"),
53
+
46
54
 
47
55
  // window plumbing
48
56
  setFocusable: (v) => ipcRenderer.send("relay:setFocusable", v),
@@ -18,22 +18,40 @@ function resetWindowZoom(win) {
18
18
  return true;
19
19
  }
20
20
 
21
- function reinforceSpacePresence(win, { moveTop = false } = {}) {
21
+ function reinforceSpacePresence(win, { moveTop = false, platform = process.platform } = {}) {
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.
27
- try {
28
- if (typeof win.isVisibleOnAllWorkspaces !== "function" || !win.isVisibleOnAllWorkspaces()) {
29
- win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
30
- }
31
- } catch {}
32
- try {
33
- if (typeof win.isAlwaysOnTop !== "function" || !win.isAlwaysOnTop()) {
34
- win.setAlwaysOnTop(true, "floating");
35
- }
36
- } catch {}
24
+ const isWindows = platform === "win32";
25
+ // All-Spaces is a macOS Spaces concept. On Windows isVisibleOnAllWorkspaces()
26
+ // always reports false, so the drift check below re-called the setter on every
27
+ // 1.5s poll for nothing.
28
+ if (!isWindows) {
29
+ // Only touch window-server state that actually drifted. Re-asserting
30
+ // setVisibleOnAllWorkspaces / setAlwaysOnTop unconditionally (every 1.5s poll +
31
+ // every Space change) reorders the window each time and reads as flicker.
32
+ try {
33
+ if (typeof win.isVisibleOnAllWorkspaces !== "function" || !win.isVisibleOnAllWorkspaces()) {
34
+ win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
35
+ }
36
+ } catch {}
37
+ }
38
+ if (isWindows) {
39
+ // Re-assert unconditionally: Electron's isAlwaysOnTop() is a cached flag from
40
+ // its own last setAlwaysOnTop call and cannot observe the OS stripping
41
+ // WS_EX_TOPMOST (scheduled-task-launched windows come up without it and sink
42
+ // behind maximized windows). Trusting the getter made the repair a permanent
43
+ // no-op. SetWindowPos-to-topmost on an already-topmost window is not a visible
44
+ // reorder here, so the macOS flicker rationale above does not apply.
45
+ try {
46
+ win.setAlwaysOnTop(true, "screen-saver");
47
+ } catch {}
48
+ } else {
49
+ try {
50
+ if (typeof win.isAlwaysOnTop !== "function" || !win.isAlwaysOnTop()) {
51
+ win.setAlwaysOnTop(true, "floating");
52
+ }
53
+ } catch {}
54
+ }
37
55
  // moveTop is macOS orderWindow:NSWindowAbove — on a HIDDEN window that orders it
38
56
  // back on-screen. That made the dismissed pill flash on every Space switch (shown
39
57
  // here, then hidden again by the visibility sync). Raising only means anything
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-companion",
3
- "version": "0.1.70",
3
+ "version": "0.1.72",
4
4
  "description": "Relay companion for ordinary messages, with dormant coordination features available only by explicit opt-in.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/account.js ADDED
@@ -0,0 +1,65 @@
1
+ import os from "node:os";
2
+ import { readConfig, writeConfigObject } from "./config.js";
3
+
4
+ /**
5
+ * Account lifecycle for the companion: the ONE config-write shape shared by
6
+ * `relay pair` (bin/relay.js cmdPair) and the pill's Settings tab, so switching
7
+ * accounts from either surface persists identical credentials. The pure shapes
8
+ * are exported separately from the fs-touching persist helpers for unit tests.
9
+ */
10
+
11
+ /**
12
+ * Pairing codes are 8 chars from an unambiguous uppercase alphabet
13
+ * (services/devices.ts). The server only trims + uppercases, so typed
14
+ * "abcd-efgh" / "ABCD EFGH" variants are folded here before the request.
15
+ */
16
+ export function normalizePairingCode(raw) {
17
+ return String(raw ?? "")
18
+ .replace(/[\s-]+/g, "")
19
+ .toUpperCase();
20
+ }
21
+
22
+ /** The device name a re-pair should register: the remembered one, else the hostname. */
23
+ export function deviceNameForPairing(config = readConfig()) {
24
+ const stored = String((config && config.deviceName) || "").trim();
25
+ return stored || os.hostname();
26
+ }
27
+
28
+ /**
29
+ * The post-registration config: everything the old config had, plus the fresh
30
+ * credentials. apiUrl/webUrl/deviceName are only written when explicitly given
31
+ * (the pill switches accounts without touching the URLs it was launched with).
32
+ */
33
+ export function pairedAccountConfig(existing, { apiUrl, webUrl, deviceName, registration } = {}) {
34
+ const res = registration || {};
35
+ return {
36
+ ...(existing || {}),
37
+ ...(apiUrl ? { apiUrl } : {}),
38
+ ...(webUrl ? { webUrl } : {}),
39
+ ...(deviceName ? { deviceName } : {}),
40
+ deviceToken: res.deviceToken || "",
41
+ deviceId: res.deviceId || "",
42
+ user: res.user || null,
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Sign-out clears the credential set — user, deviceToken, and the deviceId that
48
+ * belongs to that token — while preserving apiUrl/webUrl, the device name, the
49
+ * companion mode, and any other settings on the file.
50
+ */
51
+ export function signedOutAccountConfig(existing) {
52
+ const next = { ...(existing || {}) };
53
+ delete next.user;
54
+ delete next.deviceToken;
55
+ delete next.deviceId;
56
+ return next;
57
+ }
58
+
59
+ export function persistPairedAccount({ apiUrl, webUrl, deviceName, registration } = {}) {
60
+ return writeConfigObject(pairedAccountConfig(readConfig(), { apiUrl, webUrl, deviceName, registration }));
61
+ }
62
+
63
+ export function persistSignedOutAccount() {
64
+ return writeConfigObject(signedOutAccountConfig(readConfig()));
65
+ }
package/src/config.js CHANGED
@@ -23,7 +23,9 @@ export function configDir() {
23
23
  }
24
24
 
25
25
  export function configPath() {
26
- return path.join(configDir(), "config.json");
26
+ // RELAY_CONFIG (a full file path) matches the overlay's readConfigFile
27
+ // resolution, so a sandboxed pill and these helpers agree on ONE file.
28
+ return process.env.RELAY_CONFIG || path.join(configDir(), "config.json");
27
29
  }
28
30
 
29
31
  export function readConfig() {
@@ -51,19 +53,25 @@ export function readConfig() {
51
53
  }
52
54
  }
53
55
 
54
- export function writeConfig(patch) {
55
- const dir = configDir();
56
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
57
- const next = { ...readConfig(), ...patch };
56
+ /**
57
+ * Replace config.json with exactly `next` (no merge). This is the primitive that
58
+ * lets sign-out REMOVE keys — writeConfig's patch merge can only add or overwrite.
59
+ */
60
+ export function writeConfigObject(next) {
61
+ const file = configPath();
62
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
58
63
  // Atomic write: a crash mid-write must not truncate config.json and wipe the
59
64
  // device token (which would put the daemon into a launchd crash loop).
60
- const file = configPath();
61
65
  const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
62
66
  fs.writeFileSync(tmp, JSON.stringify(next, null, 2), { mode: 0o600 });
63
67
  fs.renameSync(tmp, file);
64
68
  return next;
65
69
  }
66
70
 
71
+ export function writeConfig(patch) {
72
+ return writeConfigObject({ ...readConfig(), ...patch });
73
+ }
74
+
67
75
  export function apiUrl() {
68
76
  return (
69
77
  process.env.RELAY_API_URL ||
package/src/install.js CHANGED
@@ -501,6 +501,21 @@ export function claudeSettingsPath() {
501
501
  );
502
502
  }
503
503
 
504
+ /**
505
+ * True when Claude looks present on this machine, judged ONLY by the directory
506
+ * that holds claudeSettingsPath() (so CLAUDE_SETTINGS / CLAUDE_HOME overrides are
507
+ * respected for tests). Deliberately not a `claude` CLI probe: Windows installs
508
+ * routinely have Claude Code/Desktop with ~/.claude configured by hand and no
509
+ * `claude` on PATH, and those installs still need the hook runtime.
510
+ */
511
+ export function claudeAppearsPresent({ settingsPath = claudeSettingsPath() } = {}) {
512
+ try {
513
+ return fs.existsSync(path.dirname(settingsPath));
514
+ } catch {
515
+ return false;
516
+ }
517
+ }
518
+
504
519
  function shellArg(value) {
505
520
  const clean = String(value);
506
521
  return /\s/.test(clean) ? `"${clean.replaceAll('"', '\\"')}"` : clean;
@@ -1009,14 +1024,19 @@ export async function runSetupInstall({ mode = DEFAULT_COMPANION_MODE } = {}) {
1009
1024
  if (claude.ok) {
1010
1025
  installed.push("Claude Code");
1011
1026
  activations.push(verifyClaudeMcpRegistration({ configPath: claude.configPath }));
1012
- // The Open-in-current-chat hook runtime rides along with the MCP
1013
- // registration (both are what "Relay is installed into Claude" means).
1014
- claudeHooks = installClaudeHooks(bin, node);
1015
1027
  } else if (claude.reason === "claude_code_not_found") {
1016
1028
  missing.push("Claude Code");
1017
1029
  } else {
1018
1030
  missing.push("Claude Code (registration failed)");
1019
1031
  }
1032
+ // The Open-in-current-chat hook runtime rides along with the MCP registration
1033
+ // (both are what "Relay is installed into Claude" means) — but it must NOT
1034
+ // depend on the `claude` CLI being on PATH. On Windows installClaudeCode fails
1035
+ // with claude_code_not_found whenever the CLI is absent even though Claude
1036
+ // Code/Desktop is installed and the MCP was registered by hand; without this
1037
+ // fallback the hook is never written and the pill's "Open in current chat" is a
1038
+ // silent no-op on exactly those machines.
1039
+ if (claude.ok || claudeAppearsPresent()) claudeHooks = installClaudeHooks(bin, node);
1020
1040
  const codex = installCodex(bin, node, { mode: normalizedMode });
1021
1041
  if (codex.ok) {
1022
1042
  installed.push("Codex");