relay-companion 0.1.71 → 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 };
@@ -2582,6 +2645,201 @@
2582
2645
  applyView();
2583
2646
  }
2584
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
+
2585
2843
  // ---------- views / tabs ----------
2586
2844
  function syncTabs() {
2587
2845
  // taskDetail belongs to Sent; the thread-detail view belongs to Relays —
@@ -2595,6 +2853,13 @@
2595
2853
  sentViewEl.classList.toggle("hidden", activeView !== "sent");
2596
2854
  taskDetailViewEl.classList.toggle("hidden", activeView !== "taskDetail");
2597
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
+ }
2598
2863
  if (activeView === "threads") {
2599
2864
  // Entered only by clicking a conversation row (threadDetailId set there).
2600
2865
  renderThreads();
@@ -2630,6 +2895,10 @@
2630
2895
 
2631
2896
  function renderAll() {
2632
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
+ }
2633
2902
  // Relays badge/count reflect only the items that show in the Relays tab
2634
2903
  // (the clean split keeps task_request / share_approval out of this count).
2635
2904
  const unread = (payload.relays || []).filter((r) => r.unread && isRelayListKind(r)).length;
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();
@@ -1817,6 +1950,12 @@ function openUrlTarget(url) {
1817
1950
  return;
1818
1951
  }
1819
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
+ }
1820
1959
  shell.openExternal(target).catch((error) => console.error("[overlay] open failed:", error && error.message));
1821
1960
  }
1822
1961
 
@@ -1825,7 +1964,21 @@ function openUrlTarget(url) {
1825
1964
  function anchorTopRight() {
1826
1965
  const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()) || screen.getPrimaryDisplay();
1827
1966
  const wa = display.workArea;
1828
- 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;
1829
1982
  }
1830
1983
 
1831
1984
  // Show the overlay window. Electron's forwarded-mousemove stream (the thing that
@@ -1955,6 +2108,12 @@ let hitTimer = null;
1955
2108
  let hitIgnoring = true; // mirrors the last setIgnoreMouseEvents call
1956
2109
  let hitHover = false;
1957
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";
1958
2117
 
1959
2118
  function applyIgnore(next) {
1960
2119
  if (next === hitIgnoring) return;
@@ -1995,6 +2154,14 @@ function scheduleHit(ms) {
1995
2154
  function hitTick() {
1996
2155
  hitTimer = null;
1997
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
+ }
1998
2165
  if (!win.isVisible()) {
1999
2166
  // A hidden window must never hold clicks, and must not cost a cursor read.
2000
2167
  applyIgnore(true);
@@ -2415,6 +2582,11 @@ ipcMain.handle("relay:contacts", () => readContacts());
2415
2582
  ipcMain.handle("relay:contactSave", (_e, input) => saveContact(input));
2416
2583
  ipcMain.handle("relay:contactDelete", (_e, input) => deleteContactFromBook(input));
2417
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
+
2418
2590
  // The overlay is normally focusable:false so it never steals keyboard focus from
2419
2591
  // Claude/Codex. Text fields (the contact form, quick reply) need focus, so the renderer
2420
2592
  // asks us to grant it while a field is open and revoke it the moment the form closes.
@@ -46,6 +46,11 @@ contextBridge.exposeInMainWorld("relay", {
46
46
  contactSave: (input) => ipcRenderer.invoke("relay:contactSave", input),
47
47
  contactDelete: (input) => ipcRenderer.invoke("relay:contactDelete", input),
48
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
+
49
54
 
50
55
  // window plumbing
51
56
  setFocusable: (v) => ipcRenderer.send("relay:setFocusable", v),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-companion",
3
- "version": "0.1.71",
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 ||