relay-companion 0.1.71 → 0.1.73

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
@@ -62,6 +62,7 @@ const {
62
62
  companionModeFromRuntime,
63
63
  taskFeaturesAllowed,
64
64
  } = require("./mode-policy.cjs");
65
+ const perf = require("./perf-counters.cjs");
65
66
 
66
67
  const RELAY_HOME = process.env.RELAY_HOME || process.env.RELAY_COMPANION_HOME || path.join(os.homedir(), ".relay-companion");
67
68
  const STATE_PATH = path.join(RELAY_HOME, "state.json");
@@ -132,17 +133,40 @@ let pendingReopenNonce = "";
132
133
  let lastReopenNonce = "";
133
134
  let lastPillStatusSig = "";
134
135
  const PRESENTED_RELAY_CAP = 500;
136
+ // Dirty gate: the guaranteed-attention machinery calls writeOverlayPrefs on every
137
+ // safety tick while the queue is non-empty, which used to SYNC-write an identical
138
+ // 9KB file every 2.5s for hours (observed live on 2026-08-05: a fresh mtime on
139
+ // every 5s sample). Serialize first and skip the disk entirely when the content
140
+ // is byte-identical to the last successful write. Every real state change still
141
+ // persists immediately — including the in-flight marker BEFORE renderer delivery
142
+ // (beginShow mutates the queue, so that serialization always differs). The write
143
+ // itself is now atomic (tmp+rename): a crash mid-write must never corrupt the
144
+ // durable attention queue it exists to protect.
145
+ let lastPrefsSerialized = "";
135
146
  function writeOverlayPrefs() {
147
+ let tmp = "";
136
148
  try {
137
- fs.mkdirSync(RELAY_HOME, { recursive: true });
138
149
  const prefs = attention.saveQueue(attentionQueue, {
139
150
  dismissed,
140
151
  attentionLatched,
141
152
  presentedRelayIds: [...presentedRelayIds],
142
153
  activeAttentionIds: [...activeAttentionIds],
143
154
  });
144
- fs.writeFileSync(OVERLAY_PREFS_PATH, `${JSON.stringify(prefs, null, 2)}\n`);
155
+ const serialized = `${JSON.stringify(prefs, null, 2)}\n`;
156
+ if (serialized === lastPrefsSerialized) {
157
+ perf.inc("prefsWriteSkips");
158
+ return;
159
+ }
160
+ fs.mkdirSync(RELAY_HOME, { recursive: true });
161
+ tmp = `${OVERLAY_PREFS_PATH}.${process.pid}.${Date.now()}.tmp`;
162
+ fs.writeFileSync(tmp, serialized);
163
+ fs.renameSync(tmp, OVERLAY_PREFS_PATH);
164
+ lastPrefsSerialized = serialized;
165
+ perf.inc("prefsWrites");
145
166
  } catch (error) {
167
+ try {
168
+ if (tmp) fs.rmSync(tmp, { force: true });
169
+ } catch {}
146
170
  console.error(`[overlay] ${new Date().toISOString()} prefs write failed:`, error && error.message);
147
171
  }
148
172
  }
@@ -177,6 +201,7 @@ function writePillStatus(reopenNonce = "") {
177
201
  fs.writeFileSync(tmp, `${JSON.stringify(status, null, 2)}\n`);
178
202
  fs.renameSync(tmp, PILL_STATUS_PATH);
179
203
  lastPillStatusSig = sig;
204
+ perf.inc("statusWrites");
180
205
  } catch (error) {
181
206
  try {
182
207
  if (tmp) fs.rmSync(tmp, { force: true });
@@ -228,6 +253,24 @@ function loadRelayModules() {
228
253
  return relayModulesPromise;
229
254
  }
230
255
 
256
+ // Account lifecycle deps (ESM, lazy like the modules above): src/account.js is
257
+ // the shared pair/sign-out config persistence, src/notifications.js owns the
258
+ // packet-store reset that re-stages the new account's inbox cleanly.
259
+ let accountModulesPromise = null;
260
+ function loadAccountModules() {
261
+ if (!accountModulesPromise) {
262
+ const accountUrl = pathToFileURL(path.join(__dirname, "..", "src", "account.js")).href;
263
+ const notificationsUrl = pathToFileURL(path.join(__dirname, "..", "src", "notifications.js")).href;
264
+ accountModulesPromise = Promise.all([import(accountUrl), import(notificationsUrl)])
265
+ .then(([account, notifications]) => ({ account, notifications }))
266
+ .catch((error) => {
267
+ accountModulesPromise = null;
268
+ throw error;
269
+ });
270
+ }
271
+ return accountModulesPromise;
272
+ }
273
+
231
274
  let sentStagerPromise = null;
232
275
  function loadSentStager() {
233
276
  if (!sentStagerPromise) {
@@ -332,6 +375,118 @@ function account() {
332
375
  };
333
376
  }
334
377
 
378
+ function pillVersion() {
379
+ try {
380
+ return String(require("../package.json").version || "");
381
+ } catch {
382
+ return "";
383
+ }
384
+ }
385
+
386
+ // The Settings account card: who this pill is signed in as, on which device,
387
+ // running which build. deviceName is remembered from pairing when available.
388
+ function accountInfo() {
389
+ const cfg = readConfigFile();
390
+ const user = cfg.user || {};
391
+ return {
392
+ ok: true,
393
+ paired: Boolean(deviceToken()),
394
+ name: user.name || "",
395
+ email: user.email || "",
396
+ deviceName: String(cfg.deviceName || "").trim() || os.hostname(),
397
+ version: pillVersion(),
398
+ setupPath: "/app/setup",
399
+ };
400
+ }
401
+
402
+ // After an account change the daemon must restart into the new credentials, or
403
+ // it keeps polling (and staging) as the OLD account until its next launch.
404
+ function restartCompanionDaemon() {
405
+ try {
406
+ let cmd = null;
407
+ let args = null;
408
+ if (process.platform === "darwin") {
409
+ const uid = typeof process.getuid === "function" ? process.getuid() : 501;
410
+ cmd = "/bin/launchctl";
411
+ args = ["kickstart", "-k", `gui/${uid}/work.relay.companion`];
412
+ } else if (process.platform === "win32") {
413
+ cmd = "cmd";
414
+ args = ["/c", 'schtasks /End /TN "Relay Companion Daemon" & schtasks /Run /TN "Relay Companion Daemon"'];
415
+ }
416
+ if (!cmd) return;
417
+ const child = spawn(cmd, args, { detached: true, stdio: "ignore" });
418
+ child.on("error", (error) => console.error("[overlay] daemon restart spawn failed:", error && error.message));
419
+ child.unref();
420
+ } catch (error) {
421
+ console.error("[overlay] daemon restart failed:", error && error.message);
422
+ }
423
+ }
424
+
425
+ // Relaunch the pill itself so every cache (sent, contacts, attention queue,
426
+ // renderer state) is rebuilt from the new account. launchd / the Scheduled Task
427
+ // respawns it; app.relaunch() covers a bare `relay pill` run. Delayed a beat so
428
+ // the invoking IPC reply reaches the renderer first.
429
+ function relaunchPillSoon() {
430
+ setTimeout(() => {
431
+ try {
432
+ app.relaunch();
433
+ } catch (error) {
434
+ console.error("[overlay] relaunch failed:", error && error.message);
435
+ }
436
+ app.exit(0);
437
+ }, 600);
438
+ }
439
+
440
+ // Switch account: register this device against the pairing code, persist the
441
+ // fresh credentials with the SAME shape `relay pair` writes, wipe the local
442
+ // packet store for the new account, then restart the daemon and the pill.
443
+ async function pairWithCode(input) {
444
+ try {
445
+ const raw = input && typeof input === "object" ? input.code : input;
446
+ const [{ account: accountMod, notifications }, { RelayClient }] = await Promise.all([
447
+ loadAccountModules(),
448
+ loadRelayModules(),
449
+ ]);
450
+ const code = accountMod.normalizePairingCode(raw);
451
+ if (!code) return { ok: false, error: "Enter the pairing code from your browser." };
452
+ const deviceName = accountMod.deviceNameForPairing(readConfigFile());
453
+ const client = new RelayClient();
454
+ const res = await client.registerDevice({ pairingCode: code, name: deviceName, platform: process.platform });
455
+ accountMod.persistPairedAccount({ deviceName, registration: res });
456
+ notifications.resetCompanionStateForAccount(
457
+ { user: res.user, deviceId: res.deviceId, force: true },
458
+ { statePath: STATE_PATH },
459
+ );
460
+ restartCompanionDaemon();
461
+ relaunchPillSoon();
462
+ return { ok: true, email: (res.user && res.user.email) || "" };
463
+ } catch (error) {
464
+ const message = error && error.message ? error.message : String(error);
465
+ console.error("[overlay] pair with code failed:", message);
466
+ return { ok: false, error: message };
467
+ }
468
+ }
469
+
470
+ // Sign out: drop the credentials (keeping URLs + device name), wipe the local
471
+ // packet store, then restart the daemon and the pill into the signed-out state.
472
+ async function signOutAccount() {
473
+ try {
474
+ const { account: accountMod, notifications } = await loadAccountModules();
475
+ accountMod.persistSignedOutAccount();
476
+ notifications.resetCompanionStateForAccount(
477
+ { user: null, deviceId: "", force: true },
478
+ { statePath: STATE_PATH },
479
+ );
480
+ restartCompanionDaemon();
481
+ relaunchPillSoon();
482
+ return { ok: true };
483
+ } catch (error) {
484
+ const message = error && error.message ? error.message : String(error);
485
+ console.error("[overlay] sign out failed:", message);
486
+ return { ok: false, error: message };
487
+ }
488
+ }
489
+
335
490
  // Turn an actionUrl (absolute https, or a relative path) into an absolute URL.
336
491
  function absoluteUrl(pathOrUrl) {
337
492
  const value = String(pathOrUrl || "").trim();
@@ -545,11 +700,29 @@ async function markAllVisibleRelaysRead() {
545
700
 
546
701
  let sentCache = [];
547
702
  let sentLoadedOnce = null;
703
+ // Fingerprint over exactly the fields the inbox signature (and therefore the
704
+ // renderer) can observe, so "did anything change?" costs a tiny stringify
705
+ // instead of a full payload rebuild per refresh.
706
+ let sentFingerprint = "";
707
+ function sentFingerprintOf(items) {
708
+ return JSON.stringify(
709
+ (items || []).map((r) => [
710
+ r.relayId,
711
+ r.state,
712
+ r.updatedAt,
713
+ r.delivery && r.delivery.state,
714
+ r.delivery && r.delivery.channel,
715
+ r.hasAttachments,
716
+ ]),
717
+ );
718
+ }
548
719
  async function refreshSent() {
720
+ if (!deviceToken()) return sentCache; // signed out: nothing to fetch, no 401 log storm
549
721
  try {
550
722
  const client = await relayClient();
551
723
  const res = await client.sent();
552
724
  sentCache = Array.isArray(res && res.items) ? res.items : [];
725
+ sentFingerprint = sentFingerprintOf(sentCache);
553
726
  } catch (error) {
554
727
  console.error("[overlay] listSent failed:", error && error.message);
555
728
  }
@@ -569,6 +742,7 @@ async function refreshTasks() {
569
742
  tasksCache = [];
570
743
  return tasksCache;
571
744
  }
745
+ if (!deviceToken()) return tasksCache; // signed out: skip the poll entirely
572
746
  try {
573
747
  const client = await relayClient();
574
748
  const res = await client.listTasks();
@@ -593,7 +767,13 @@ function ensureTasksLoaded() {
593
767
 
594
768
  let contactsCache = [];
595
769
  let contactsLoadedOnce = null;
770
+ let contactsFingerprint = "";
771
+ function contactsFingerprintOf(list) {
772
+ // Same triple the inbox signature hashes for contacts.
773
+ return JSON.stringify((list || []).map((c) => [c.id, c.name, c.email]));
774
+ }
596
775
  async function refreshContacts() {
776
+ if (!deviceToken()) return contactsCache; // signed out: skip the poll entirely
597
777
  try {
598
778
  const client = await relayClient();
599
779
  const res = await client.listContacts();
@@ -611,6 +791,7 @@ async function refreshContacts() {
611
791
  };
612
792
  })
613
793
  .sort((a, b) => String(a.name).localeCompare(String(b.name)));
794
+ contactsFingerprint = contactsFingerprintOf(contactsCache);
614
795
  } catch (error) {
615
796
  // Keep the last good cache; a transient network failure must not blank the UI.
616
797
  console.error("[overlay] listContacts failed:", error && error.message);
@@ -658,6 +839,7 @@ async function deleteContactFromBook(input) {
658
839
  // pushInbox when it lands. This is what makes the pill appear immediately even on a
659
840
  // black-holed network (the client fetch timeout is 15s — far too long to block paint).
660
841
  function buildPayload() {
842
+ perf.inc("payloadBuilds");
661
843
  // Kick the first loads without awaiting; each calls pushInbox(false) on completion.
662
844
  if (!sentLoadedOnce) ensureSentLoaded().then(() => pushInbox(false)).catch(() => {});
663
845
  if (!contactsLoadedOnce) ensureContactsLoaded().then(() => pushInbox(false)).catch(() => {});
@@ -678,6 +860,21 @@ function buildPayload() {
678
860
  // Pushes are serialized: overlapping timers (fs.watch + safety poll + sent refresh)
679
861
  // must not interleave sends, or the renderer can paint an older payload last.
680
862
  let pushChain = Promise.resolve();
863
+ // state.json generation gate for the 2.5s safety poll: reading + parsing a
864
+ // ~500KB store and re-deriving a 150-row payload every tick is what kept the
865
+ // pill hot all day. The safety tick now costs ONE stat() unless the file
866
+ // actually changed since the last full push (fs.watch/watchFile still fire the
867
+ // real pushes on change; this closes their races). Content changes always move
868
+ // mtimeMs/size because every writer uses temp+rename or a direct rewrite.
869
+ let lastStateStatSig = "";
870
+ function stateFileStatSig() {
871
+ try {
872
+ const st = fs.statSync(STATE_PATH);
873
+ return `${st.mtimeMs}:${st.size}`;
874
+ } catch {
875
+ return "missing";
876
+ }
877
+ }
681
878
  const USER_IDLE_THRESHOLD_SECONDS = 15;
682
879
  let systemSuspended = false;
683
880
  let screenLocked = false;
@@ -690,6 +887,7 @@ function userIsAway() {
690
887
  if (process.env.RELAY_OVERLAY_TEST_FORCE_ACTIVE === "1") return false;
691
888
  if (systemSuspended || screenLocked || !loginSessionActive) return true;
692
889
  try {
890
+ perf.inc("idleQueries");
693
891
  const state = powerMonitor.getSystemIdleState(USER_IDLE_THRESHOLD_SECONDS);
694
892
  return state === "idle" || state === "locked";
695
893
  } catch {
@@ -713,6 +911,7 @@ const dwellMs = () => Number(process.env.RELAY_OVERLAY_NOTIFICATION_MS) || 7000;
713
911
 
714
912
  function idleSecondsSafe() {
715
913
  try {
914
+ perf.inc("idleQueries");
716
915
  return powerMonitor.getSystemIdleTime();
717
916
  } catch {
718
917
  return 0;
@@ -744,21 +943,35 @@ function abortCurrentShow(reason) {
744
943
  // card was visible). A wake resets the idle counter, so the sampler alone —
745
944
  // not a single end-of-dwell reading — is what makes wake-to-black-screen
746
945
  // dwells fail closed and stay queued.
747
- function beginShowSampling(entryIds, digest) {
946
+ function beginShowSampling(entryIds, digest, { sticky = false } = {}) {
748
947
  const idleAtStart = idleSecondsSafe();
749
948
  const startedAt = Date.now();
750
949
  const show = { ids: entryIds, digest: Boolean(digest), startedAt, idleAtStart, inputSeen: false, sampler: null };
950
+ // Sticky cards latch open indefinitely and only ever confirm via a renderer
951
+ // interaction (interacted=true), which needs no idle evidence — so don't run
952
+ // a 1Hz idle query for the whole time one sits on screen.
953
+ if (sticky) return show;
954
+ // Cap the sampler at a few dwells past the fold deadline: the renderer's
955
+ // attentionDone lands within one dwell, and evidence gathered after ~30s
956
+ // could never belong to this card's visible interval anyway.
957
+ const samplerCapMs = Math.max(dwellMs() * 4, 30000);
751
958
  show.sampler = setInterval(() => {
752
959
  const elapsed = (Date.now() - startedAt) / 1000;
753
960
  const expected = show.idleAtStart + elapsed;
754
961
  if (idleSecondsSafe() < expected - 1) show.inputSeen = true;
962
+ if (Date.now() - startedAt > samplerCapMs && show.sampler) {
963
+ clearInterval(show.sampler);
964
+ show.sampler = null;
965
+ }
755
966
  }, 1000);
756
967
  return show;
757
968
  }
758
969
 
759
970
  // One card (or one digest) at a time. Every exit from the queue is either a
760
971
  // confirmed dwell/interaction or an explicit per-relay user act elsewhere.
761
- function pumpAttention() {
972
+ // prebuiltPayload lets pushInboxNow hand over the payload it just derived, so
973
+ // the hot pump path never parses state.json a second time per tick.
974
+ function pumpAttention(prebuiltPayload = null) {
762
975
  if (!win || win.isDestroyed() || !pillReady || !rendererListening) return false;
763
976
  if (currentShow || attention.hasShowing(attentionQueue)) return false;
764
977
  if (userIsAway()) {
@@ -775,7 +988,7 @@ function pumpAttention() {
775
988
  if (!hasFresh) return false;
776
989
  }
777
990
 
778
- const payload = buildPayload();
991
+ const payload = prebuiltPayload || buildPayload();
779
992
  const unreadRows = new Map(
780
993
  visibleRelayRows(payload.relays).filter((r) => r.unread).map((r) => [r.id, r]),
781
994
  );
@@ -800,7 +1013,7 @@ function pumpAttention() {
800
1013
  if (!row) {
801
1014
  attention.drop(attentionQueue, entry.id);
802
1015
  writeOverlayPrefs();
803
- return pumpAttention();
1016
+ return pumpAttention(payload); // same store generation: reuse the build
804
1017
  }
805
1018
  sticky = entry.sticky === true;
806
1019
  attention.beginShow(attentionQueue, entry.id);
@@ -818,7 +1031,7 @@ function pumpAttention() {
818
1031
  deferredAttention = false;
819
1032
  maybeShow({ force: true });
820
1033
  activeAttentionIds = new Set(ids);
821
- currentShow = beginShowSampling(ids, digestMode);
1034
+ currentShow = beginShowSampling(ids, digestMode, { sticky });
822
1035
  setThrottlingForShow(true);
823
1036
  lastEngagedAt = Date.now(); // a live card warrants tight sent/host cadence briefly
824
1037
  currentShow.sticky = sticky;
@@ -840,6 +1053,14 @@ function pumpAttention() {
840
1053
  // The return pump replaces the old fixed [0,1200,4500]ms retries: while relays
841
1054
  // still owe a notification it keeps trying every 2s — across slow wakes, slow
842
1055
  // Wi-Fi reassociation and the daemon's next poll — until the queue drains.
1056
+ //
1057
+ // It is a RETRY loop, not a maintenance loop (2026-08-05 freeze audit): the old
1058
+ // per-tick refreshOverlayForActiveSpace({force:true}) spawned `ps` and forced a
1059
+ // window-server re-assertion every 2s for as long as anything was queued — with
1060
+ // a sticky card latched on stage, that was a permanent hot loop. Space presence
1061
+ // is owned by events (Space changes, show edges, return-from-away, display
1062
+ // changes); pumpAttention's own maybeShow({force:true}) still raises the window
1063
+ // whenever a card actually fires.
843
1064
  let returnPumpTimer = null;
844
1065
  function startReturnPump() {
845
1066
  if (returnPumpTimer) return;
@@ -849,12 +1070,21 @@ function startReturnPump() {
849
1070
  returnPumpTimer = null;
850
1071
  return;
851
1072
  }
852
- if (userIsAway()) return;
1073
+ // A card is on stage: its confirm/abort exit re-pumps (or restarts this
1074
+ // pump). Ticking during the dwell was pure churn.
1075
+ if (currentShow || attention.hasShowing(attentionQueue)) return;
1076
+ if (userIsAway()) {
1077
+ // Park entirely while away: the 1s deferred-attention poll owns the
1078
+ // return edge and restarts the pump via reconcileAttentionAfterReturn.
1079
+ deferredAttention = true;
1080
+ clearInterval(returnPumpTimer);
1081
+ returnPumpTimer = null;
1082
+ return;
1083
+ }
853
1084
  // Returning from away cuts through a snooze: the user left, so what they
854
1085
  // dismissed is stale context and unseen relays must surface again.
855
1086
  dismissSnoozedIds = new Set();
856
1087
  burstShown = 0;
857
- refreshOverlayForActiveSpace({ force: true });
858
1088
  pumpAttention();
859
1089
  }, 2000);
860
1090
  }
@@ -883,6 +1113,10 @@ function pushInbox(force) {
883
1113
  }
884
1114
  async function pushInboxNow(force) {
885
1115
  if (!win || win.isDestroyed()) return;
1116
+ // Record the state.json generation BEFORE reading it: a write that lands
1117
+ // mid-read leaves the stat differing on the next safety tick, so the racing
1118
+ // change is re-pushed rather than silently skipped.
1119
+ lastStateStatSig = stateFileStatSig();
886
1120
  const payload = buildPayload();
887
1121
  const rows = payload.relays;
888
1122
  const notifiableRows = visibleRelayRows(rows);
@@ -936,7 +1170,7 @@ async function pushInboxNow(force) {
936
1170
  lastSig = sig;
937
1171
  if (win && !win.isDestroyed()) win.webContents.send("inbox", payload);
938
1172
  }
939
- pumpAttention();
1173
+ pumpAttention(payload); // reuse this build; pumping must not re-read the store
940
1174
  }
941
1175
 
942
1176
  // Refresh state-derived rows only (fast path used by fs.watch + the safety poll).
@@ -1130,9 +1364,11 @@ function taskVersion(taskId) {
1130
1364
  // Best-effort frontmost-app bundle id (no permission prompt; uses lsappinfo).
1131
1365
  function frontmostBundleId(cb) {
1132
1366
  if (process.platform !== "darwin") return cb(null);
1367
+ perf.inc("spawns");
1133
1368
  execFile("/usr/bin/lsappinfo", ["front"], (e1, asn) => {
1134
1369
  const a = String(asn || "").trim();
1135
1370
  if (e1 || !a) return cb(null);
1371
+ perf.inc("spawns");
1136
1372
  execFile("/usr/bin/lsappinfo", ["info", "-only", "bundleid", a], (e2, out) => {
1137
1373
  const m = String(out || "").match(/"CFBundleIdentifier"\s*=\s*"([^"]+)"/);
1138
1374
  cb(e2 ? null : m ? m[1] : null);
@@ -1162,6 +1398,7 @@ function activateHost(host, observedBundle = null) {
1162
1398
  if (lastError) console.error("[overlay] activateHost failed:", host, lastError && lastError.message);
1163
1399
  return;
1164
1400
  }
1401
+ perf.inc("spawns");
1165
1402
  execFile("/usr/bin/open", ["-b", bundle], (error) => {
1166
1403
  if (error) tryBundle(index + 1, error);
1167
1404
  });
@@ -1530,6 +1767,7 @@ async function openPacket(packetId, { sent = false, fresh = false } = {}) {
1530
1767
  // then keep the row spinner alive while it does post-import title repair.
1531
1768
  env.RELAY_IMPORT_CLAUDE_DESKTOP = "0";
1532
1769
  }
1770
+ perf.inc("spawns");
1533
1771
  const child = spawn(
1534
1772
  process.execPath,
1535
1773
  // --fresh ("Open in new chat"): the materializer ignores the remembered
@@ -1758,6 +1996,7 @@ function openTaskDetail(taskId) {
1758
1996
  // Let the overlay own the actual deep-link launch (see openPacket).
1759
1997
  env.RELAY_IMPORT_CLAUDE_DESKTOP = "0";
1760
1998
  }
1999
+ perf.inc("spawns");
1761
2000
  const child = spawn(
1762
2001
  process.execPath,
1763
2002
  [RELAY_CLI, "open", "--task", taskId, "--host", host, COMPANION_MODE_CLI_ARG],
@@ -1817,6 +2056,12 @@ function openUrlTarget(url) {
1817
2056
  return;
1818
2057
  }
1819
2058
  const target = absoluteUrl(url);
2059
+ // Sandboxed harness runs must never pop the user's real browser (the same
2060
+ // contract that keeps them from launching Claude/Codex).
2061
+ if (process.env.RELAY_OVERLAY_TEST_NO_HOST_OPEN === "1") {
2062
+ console.error("[overlay] test seam: suppressed external open:", target);
2063
+ return;
2064
+ }
1820
2065
  shell.openExternal(target).catch((error) => console.error("[overlay] open failed:", error && error.message));
1821
2066
  }
1822
2067
 
@@ -1825,7 +2070,21 @@ function openUrlTarget(url) {
1825
2070
  function anchorTopRight() {
1826
2071
  const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()) || screen.getPrimaryDisplay();
1827
2072
  const wa = display.workArea;
1828
- return { x: wa.x + wa.width - WIN.width - MARGIN, y: wa.y + MARGIN, width: WIN.width, height: WIN.height };
2073
+ const anchor = { x: wa.x + wa.width - WIN.width - MARGIN, y: wa.y + MARGIN, width: WIN.width, height: WIN.height };
2074
+ if (process.env.RELAY_OVERLAY_TEST === "1") {
2075
+ // Sandboxed harness runs must never materialize under the user's parked
2076
+ // cursor: a pointer resting on the card holds notifications open BY DESIGN
2077
+ // (macOS banners do the same), which would wedge every dwell-based scenario
2078
+ // when the mouse happens to sit in the top-right corner. Flip the sandbox
2079
+ // window to the opposite edge instead. Production placement is unchanged.
2080
+ try {
2081
+ const p = screen.getCursorScreenPoint();
2082
+ const overlaps =
2083
+ p.x >= anchor.x && p.x < anchor.x + anchor.width && p.y >= anchor.y && p.y < anchor.y + anchor.height;
2084
+ if (overlaps) anchor.x = wa.x + MARGIN;
2085
+ } catch {}
2086
+ }
2087
+ return anchor;
1829
2088
  }
1830
2089
 
1831
2090
  // Show the overlay window. Electron's forwarded-mousemove stream (the thing that
@@ -1837,6 +2096,7 @@ function showOverlayWindow({ force = false, reposition = true } = {}) {
1837
2096
  if (!win || win.isDestroyed()) return;
1838
2097
  const visible = win.isVisible();
1839
2098
  if (visible && !force) {
2099
+ perf.inc("spaceAsserts");
1840
2100
  reinforceSpacePresence(win);
1841
2101
  return;
1842
2102
  }
@@ -1850,6 +2110,7 @@ function showOverlayWindow({ force = false, reposition = true } = {}) {
1850
2110
  // No reinforceSpacePresence before this call: showInactiveOnAllSpaces must observe
1851
2111
  // whether the collection behavior actually drifted to decide between a real
1852
2112
  // re-attach and a no-op — repairing it first would force the re-show every time.
2113
+ perf.inc("spaceAsserts");
1853
2114
  const shown = showInactiveOnAllSpaces(win, { force });
1854
2115
  if (shown) {
1855
2116
  // hidden -> shown only: re-assert click-through and reset the renderer's
@@ -1858,6 +2119,11 @@ function showOverlayWindow({ force = false, reposition = true } = {}) {
1858
2119
  // the pointer handshake is live — a dead card until the pointer re-enters.
1859
2120
  win.setIgnoreMouseEvents(true, { forward: true });
1860
2121
  win.webContents.send("shown");
2122
+ // Becoming visible is when host-running freshness starts mattering for
2123
+ // click routing again; the hidden poll cadence is slow, so take one
2124
+ // reading at the show edge (process list only — the frontmost probe
2125
+ // belongs to hover/click time).
2126
+ pollHosts({ probeFrontmost: false });
1861
2127
  }
1862
2128
  }
1863
2129
 
@@ -1867,7 +2133,23 @@ function maybeShow({ force = false, reposition = true } = {}) {
1867
2133
  // trayForcedVisible honors an explicit status-area click even when no host runs.
1868
2134
  const wanted = overlayWanted({ hostRunning, trayForcedVisible, trayAvailable, dismissed, ghostActive, attentionLatched });
1869
2135
  if (wanted) {
1870
- showOverlayWindow({ force, reposition });
2136
+ // macOS steady state (already visible, nothing forcing): leave the
2137
+ // window-server state alone. The old unconditional showOverlayWindow here
2138
+ // re-asserted Space presence from every periodic caller (host poll, return
2139
+ // pump), which is timer-driven window-server load. Presence repair now runs
2140
+ // only on the events that can actually break it: Space changes, show edges,
2141
+ // wake/unlock reconciles, display changes and explicit forces.
2142
+ //
2143
+ // Windows is EXCLUDED from the fast path on purpose: the OS strips
2144
+ // WS_EX_TOPMOST in ways Electron's cached isAlwaysOnTop() cannot observe
2145
+ // (see space-presence.cjs), so the periodic reinforce IS the topmost
2146
+ // self-heal there — and SetWindowPos on an already-topmost window is not a
2147
+ // visible reorder on Windows.
2148
+ if (!force && win.isVisible() && process.platform === "darwin") {
2149
+ // no-op on the window
2150
+ } else {
2151
+ showOverlayWindow({ force, reposition });
2152
+ }
1871
2153
  } else if (win.isVisible()) {
1872
2154
  win.hide();
1873
2155
  }
@@ -1889,6 +2171,7 @@ function refreshOverlayForActiveSpace({ force = false } = {}) {
1889
2171
  }
1890
2172
 
1891
2173
  function readHostProcesses(cb) {
2174
+ perf.inc("spawns");
1892
2175
  if (process.platform === "win32") {
1893
2176
  execFile("tasklist", ["/FO", "CSV", "/NH"], cb);
1894
2177
  return;
@@ -1911,7 +2194,7 @@ function updateHostRunningFromProcesses(text) {
1911
2194
  hostRunning = hostTracker.update(claudeRunning, codexRunning);
1912
2195
  }
1913
2196
 
1914
- function pollHosts() {
2197
+ function pollHosts({ probeFrontmost = true } = {}) {
1915
2198
  // Show whenever EITHER host app is running; track the foregrounded one for click routing.
1916
2199
  readHostProcesses((err, stdout) => {
1917
2200
  // On a process-list error, leave the last-known running state untouched rather than
@@ -1920,9 +2203,15 @@ function pollHosts() {
1920
2203
  if (hostRunning) trayForcedVisible = false; // normal host-based visibility takes back over
1921
2204
  maybeShow();
1922
2205
  });
1923
- frontmostBundleId((bundle) => {
1924
- rememberForegroundHost(hostFromBundle(bundle));
1925
- });
2206
+ // The frontmost probe is two more spawns and only feeds lastHost, whose job is
2207
+ // click routing. Clicks always take a fresh frontmost reading, and the hover
2208
+ // approach probe captures the just-before-click state — so the periodic loop
2209
+ // only pays for it while the user is plausibly about to click (engaged).
2210
+ if (probeFrontmost) {
2211
+ frontmostBundleId((bundle) => {
2212
+ rememberForegroundHost(hostFromBundle(bundle));
2213
+ });
2214
+ }
1926
2215
  }
1927
2216
 
1928
2217
  // ---- pointer hit test (main-process authority) -----------------------------
@@ -1955,6 +2244,12 @@ let hitTimer = null;
1955
2244
  let hitIgnoring = true; // mirrors the last setIgnoreMouseEvents call
1956
2245
  let hitHover = false;
1957
2246
  let lastHostProbeAt = 0;
2247
+ // Harness seam: a sandboxed e2e run shares the screen with the user's REAL
2248
+ // mouse, and a pointer that happens to rest on the card rect legitimately
2249
+ // holds notifications open (the macOS-banner rule) — wedging every dwell-based
2250
+ // scenario. The harness drives only synthetic input, so in this mode the hit
2251
+ // test ignores the physical cursor entirely. Never set outside the harness.
2252
+ const HIT_TEST_POINTER_BLIND = process.env.RELAY_OVERLAY_TEST_IGNORE_POINTER === "1";
1958
2253
 
1959
2254
  function applyIgnore(next) {
1960
2255
  if (next === hitIgnoring) return;
@@ -1995,6 +2290,14 @@ function scheduleHit(ms) {
1995
2290
  function hitTick() {
1996
2291
  hitTimer = null;
1997
2292
  if (!win || win.isDestroyed()) return;
2293
+ if (HIT_TEST_POINTER_BLIND) {
2294
+ // Interactive everywhere, hover never asserted: synthetic-input runs must
2295
+ // not have their dwell/fold behavior steered by the user's parked mouse.
2296
+ applyIgnore(false);
2297
+ applyHover(false);
2298
+ scheduleHit(POLL_HIDDEN_MS);
2299
+ return;
2300
+ }
1998
2301
  if (!win.isVisible()) {
1999
2302
  // A hidden window must never hold clicks, and must not cost a cursor read.
2000
2303
  applyIgnore(true);
@@ -2005,6 +2308,7 @@ function hitTick() {
2005
2308
  let bounds;
2006
2309
  let point;
2007
2310
  try {
2311
+ perf.inc("cursorReads");
2008
2312
  bounds = win.getContentBounds(); // frameless: content == frame; follows setPos for free
2009
2313
  point = screen.getCursorScreenPoint();
2010
2314
  } catch {
@@ -2137,13 +2441,29 @@ function createWindow() {
2137
2441
  if (!file || String(file).startsWith("state.json")) pushInboxQuiet();
2138
2442
  });
2139
2443
  } catch {}
2140
- setInterval(() => pushInboxQuiet(), 2500); // safety net for state.json
2444
+ // Safety net for state.json: ONE stat() per tick unless the file generation
2445
+ // actually moved since the last full push. The full 500KB parse + payload +
2446
+ // signature rebuild every 2.5s regardless of change was a top contributor to
2447
+ // the pill's always-on CPU (2026-08-05 freeze audit).
2448
+ setInterval(() => {
2449
+ if (stateFileStatSig() === lastStateStatSig) {
2450
+ perf.inc("statePollSkips");
2451
+ return;
2452
+ }
2453
+ pushInboxQuiet();
2454
+ }, 2500);
2141
2455
  // Adaptive cadences (visibility.cjs): tight loops only while the user is
2142
2456
  // engaged; idle machines get slow heartbeats instead of spawn/fetch storms.
2143
2457
  const testMode = process.env.RELAY_OVERLAY_TEST === "1";
2144
2458
  const sentLoop = () => {
2459
+ const fingerprintBefore = sentFingerprint;
2145
2460
  refreshSent()
2146
- .then(() => pushInbox(false))
2461
+ .then(() => {
2462
+ // Only rebuild + repush when the sig-relevant fields moved; an idle
2463
+ // machine's unchanged Sent list should cost the fetch and nothing more.
2464
+ if (sentFingerprint !== fingerprintBefore) return pushInbox(false);
2465
+ perf.inc("sentPushSkips");
2466
+ })
2147
2467
  .catch(() => {})
2148
2468
  .finally(() =>
2149
2469
  setTimeout(sentLoop, sentRefreshDelayMs({ testMode, engaged: isEngaged(), showActive: Boolean(currentShow) })),
@@ -2151,13 +2471,26 @@ function createWindow() {
2151
2471
  };
2152
2472
  setTimeout(sentLoop, 5000);
2153
2473
  setInterval(() => {
2474
+ const fingerprintBefore = contactsFingerprint;
2154
2475
  refreshContacts()
2155
- .then(() => pushInbox(false))
2476
+ .then(() => {
2477
+ if (contactsFingerprint !== fingerprintBefore) return pushInbox(false);
2478
+ perf.inc("contactsPushSkips");
2479
+ })
2156
2480
  .catch(() => {});
2157
2481
  }, 60000); // slow contact refresh; the contacts view also refreshes on open
2158
2482
  const hostLoop = () => {
2159
- pollHosts();
2160
- setTimeout(hostLoop, hostPollDelayMs({ testMode, engaged: isEngaged() }));
2483
+ // Engaged: fresh frontmost capture for imminent clicks. Otherwise the
2484
+ // process-list read alone keeps hostRunning/click-routing state warm.
2485
+ pollHosts({ probeFrontmost: isEngaged() });
2486
+ setTimeout(
2487
+ hostLoop,
2488
+ hostPollDelayMs({
2489
+ testMode,
2490
+ engaged: isEngaged(),
2491
+ visible: Boolean(win && !win.isDestroyed() && win.isVisible()),
2492
+ }),
2493
+ );
2161
2494
  };
2162
2495
  setTimeout(hostLoop, hostPollDelayMs({ testMode, engaged: true }));
2163
2496
  }
@@ -2415,6 +2748,11 @@ ipcMain.handle("relay:contacts", () => readContacts());
2415
2748
  ipcMain.handle("relay:contactSave", (_e, input) => saveContact(input));
2416
2749
  ipcMain.handle("relay:contactDelete", (_e, input) => deleteContactFromBook(input));
2417
2750
 
2751
+ // Settings tab: account card + the sign-out / switch-account lifecycle.
2752
+ ipcMain.handle("relay:accountInfo", () => accountInfo());
2753
+ ipcMain.handle("relay:pairWithCode", (_e, input) => pairWithCode(input));
2754
+ ipcMain.handle("relay:signOut", () => signOutAccount());
2755
+
2418
2756
  // The overlay is normally focusable:false so it never steals keyboard focus from
2419
2757
  // Claude/Codex. Text fields (the contact form, quick reply) need focus, so the renderer
2420
2758
  // asks us to grant it while a field is open and revoke it the moment the form closes.
@@ -2434,6 +2772,7 @@ ipcMain.on("relay:cardSize", (_e, w, h) => {
2434
2772
  ipcMain.on("relay:setPos", (_e, x, y) => {
2435
2773
  if (win && !win.isDestroyed() && Number.isFinite(x) && Number.isFinite(y)) {
2436
2774
  win.setPosition(Math.round(x), Math.round(y));
2775
+ perf.inc("spaceAsserts");
2437
2776
  reinforceSpacePresence(win);
2438
2777
  }
2439
2778
  });
@@ -2563,9 +2902,26 @@ if (!gotSingleInstanceLock) {
2563
2902
  createTray();
2564
2903
  installActiveSpaceWatcher();
2565
2904
  installPowerAttentionLifecycle();
2566
- // Test seam: the e2e harness (test/e2e-overlay.mjs) drives the real state machine
2567
- // over the main-process inspector. Never set outside the harness.
2568
- if (process.env.RELAY_OVERLAY_TEST === "1") {
2905
+ // Display topology changes are the remaining event that can strand the
2906
+ // overlay (stale bounds, dropped always-on-top after a monitor swap).
2907
+ // Event-driven repair replaces the old every-poll re-assertion.
2908
+ try {
2909
+ screen.on("display-added", () => maybeShow({ force: true }));
2910
+ screen.on("display-removed", () => maybeShow({ force: true }));
2911
+ screen.on("display-metrics-changed", () => maybeShow({ force: true }));
2912
+ } catch (error) {
2913
+ console.error("[overlay] display watcher failed:", error && error.message);
2914
+ }
2915
+ // Perf-counter log line for live diagnosis (opt-in, stderr → pill.log).
2916
+ if (process.env.RELAY_OVERLAY_PERF_LOG === "1") {
2917
+ perf.startPerfLog({ log: (line) => console.error(line) });
2918
+ }
2919
+ // Test seam: the e2e harness (test/e2e-overlay.mjs) and the perf harness
2920
+ // (test/perf-overlay.mjs) drive the real state machine over the main-process
2921
+ // inspector. RELAY_OVERLAY_PERF=1 exposes the seam WITHOUT flipping the
2922
+ // RELAY_OVERLAY_TEST cadences, so measurements see production timing.
2923
+ // Never set outside the harnesses.
2924
+ if (process.env.RELAY_OVERLAY_TEST === "1" || process.env.RELAY_OVERLAY_PERF === "1") {
2569
2925
  global.__relayTest = {
2570
2926
  showFromTray,
2571
2927
  requestExternalReopen,
@@ -2582,6 +2938,7 @@ if (!gotSingleInstanceLock) {
2582
2938
  },
2583
2939
  setSentCache: (items) => {
2584
2940
  sentCache = Array.isArray(items) ? items : [];
2941
+ sentFingerprint = sentFingerprintOf(sentCache);
2585
2942
  return pushInbox(true);
2586
2943
  },
2587
2944
  getWin: () => win,
@@ -2594,6 +2951,7 @@ if (!gotSingleInstanceLock) {
2594
2951
  return ids;
2595
2952
  },
2596
2953
  pumpAttention,
2954
+ perf: () => perf.snapshot(),
2597
2955
  state: () => ({
2598
2956
  dismissed,
2599
2957
  attentionLatched,
@@ -0,0 +1,48 @@
1
+ // Always-on, near-zero-cost perf counters for the overlay main process.
2
+ //
3
+ // Motivation (2026-08-05 whole-Mac stutter investigation): the pill's background
4
+ // cost is invisible until it is measured. Every recurring expense — process
5
+ // spawns, window-server re-assertions, cursor/idle queries, full payload builds,
6
+ // prefs writes — increments a named counter here, so a live overlay (or the
7
+ // sandboxed e2e/perf harness) can report exact per-minute rates instead of
8
+ // guesses. Incrementing a property on a plain object is nanoseconds; the module
9
+ // never allocates on the hot path.
10
+ //
11
+ // Reading:
12
+ // - test seam: global.__relayTest.perf() returns snapshot()
13
+ // - log line: RELAY_OVERLAY_PERF_LOG=1 prints per-minute deltas to stderr
14
+
15
+ "use strict";
16
+
17
+ const counters = Object.create(null);
18
+ const startedAt = Date.now();
19
+
20
+ function inc(name, by = 1) {
21
+ counters[name] = (counters[name] || 0) + by;
22
+ }
23
+
24
+ function snapshot() {
25
+ return { ...counters, uptimeMs: Date.now() - startedAt };
26
+ }
27
+
28
+ // Per-minute delta logger. Off unless explicitly enabled; unref'd so it never
29
+ // keeps the process alive.
30
+ function startPerfLog({ log = () => {}, intervalMs = 60000, now = Date.now } = {}) {
31
+ let last = { ...counters };
32
+ let lastAt = now();
33
+ const timer = setInterval(() => {
34
+ const at = now();
35
+ const minutes = Math.max((at - lastAt) / 60000, 1e-6);
36
+ const deltas = {};
37
+ for (const key of Object.keys(counters)) {
38
+ deltas[key] = Math.round(((counters[key] || 0) - (last[key] || 0)) / minutes);
39
+ }
40
+ last = { ...counters };
41
+ lastAt = at;
42
+ log(`[overlay] perf/min ${JSON.stringify(deltas)}`);
43
+ }, intervalMs);
44
+ if (timer && typeof timer.unref === "function") timer.unref();
45
+ return timer;
46
+ }
47
+
48
+ module.exports = { inc, snapshot, startPerfLog };
@@ -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),
@@ -103,11 +103,14 @@ function recoverInterruptedAttentionPrefs(input = {}) {
103
103
  // ---- adaptive poll cadences (the anti-spawn-storm rules) -------------------
104
104
  // Host detection spawns real processes (lsappinfo/ps on macOS, tasklist on
105
105
  // Windows — expensive there and AV-scanned). Poll fast only while the user is
106
- // plausibly about to click the pill; idle machines get a slow heartbeat.
106
+ // plausibly about to click the pill; idle machines get a slow heartbeat, and a
107
+ // HIDDEN pill (dismissed or not on screen) backs off further still — host
108
+ // freshness only matters again at the show edge, which takes its own reading.
107
109
  // Windows idles slower still because its per-spawn cost dwarfs macOS's.
108
- function hostPollDelayMs({ testMode = false, engaged = false, platform = process.platform } = {}) {
110
+ function hostPollDelayMs({ testMode = false, engaged = false, visible = true, platform = process.platform } = {}) {
109
111
  if (testMode) return 1500;
110
112
  if (engaged) return platform === "win32" ? 3000 : 1500;
113
+ if (!visible) return platform === "win32" ? 45000 : 30000;
111
114
  return platform === "win32" ? 20000 : 10000;
112
115
  }
113
116
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-companion",
3
- "version": "0.1.71",
3
+ "version": "0.1.73",
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 ||
@@ -123,6 +123,13 @@ async function pollVisibleTaskEvents({ client, ledger, tasks, log }) {
123
123
  return visibleEvents;
124
124
  }
125
125
 
126
+ // Content signature for the ledger write-skip below. updatedAt is stamped fresh
127
+ // on every write by design, so it is excluded — with it included no two polls
128
+ // could ever match and the skip would be dead code.
129
+ export function ledgerContentSignature(ledger) {
130
+ return JSON.stringify({ ...ledger, updatedAt: null });
131
+ }
132
+
126
133
  // The ledger's dedupe maps grow forever (processedMessages, taskEvents, plainRelays,
127
134
  // notifications, sessions). Left unbounded, the daemon JSON.parse+stringify+fsyncs a
128
135
  // multi-megabyte file every 4s poll. Keep the newest N entries by processedAt so the
@@ -241,9 +248,13 @@ export async function pollOrdinaryRelayOnce({
241
248
  stagePlainRelay = defaultStagePlainRelayItem,
242
249
  } = {}) {
243
250
  const ledger = readTaskLedger();
251
+ // Write-skip (2026-08-05 always-on-cost audit): an idle account rewrote an
252
+ // identical ~150KB ledger every 4s poll, forever. Only touch the disk when a
253
+ // poll actually changed the dedupe state.
254
+ const ledgerBaseline = ledgerContentSignature(ledger);
244
255
  const ordinaryRelays = await pollPlainInbox({ client, ledger, stagePlainRelay, log });
245
256
  pruneLedger(ledger);
246
- writeTaskLedger(ledger);
257
+ if (ledgerContentSignature(ledger) !== ledgerBaseline) writeTaskLedger(ledger);
247
258
  return { ordinaryRelays };
248
259
  }
249
260
 
@@ -255,6 +266,7 @@ export async function pollTaskRuntimeOnce({
255
266
  adapters,
256
267
  } = {}) {
257
268
  const ledger = readTaskLedger();
269
+ const ledgerBaseline = ledgerContentSignature(ledger); // see pollOrdinaryRelayOnce
258
270
  let inbox = { messages: [], sessions: [] };
259
271
  try {
260
272
  inbox = await client.agentInbox();
@@ -349,7 +361,7 @@ export async function pollTaskRuntimeOnce({
349
361
 
350
362
  const humanPolling = await pollHumanNotifications({ client, ledger, stageCompanionItem, stagePlainRelay, log });
351
363
  pruneLedger(ledger);
352
- writeTaskLedger(ledger);
364
+ if (ledgerContentSignature(ledger) !== ledgerBaseline) writeTaskLedger(ledger);
353
365
  return {
354
366
  sessions: touched,
355
367
  messages: processedMessages,