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 +5 -2
- package/overlay/inbox.html +269 -0
- package/overlay/main.cjs +382 -24
- package/overlay/perf-counters.cjs +48 -0
- package/overlay/preload.cjs +5 -0
- package/overlay/visibility.cjs +5 -2
- package/package.json +1 -1
- package/src/account.js +65 -0
- package/src/config.js +14 -6
- package/src/task-daemon.js +14 -2
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
|
-
|
|
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
|
}
|
package/overlay/inbox.html
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1924
|
-
|
|
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
|
-
|
|
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(() =>
|
|
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(() =>
|
|
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
|
-
|
|
2160
|
-
|
|
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
|
-
//
|
|
2567
|
-
//
|
|
2568
|
-
|
|
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 };
|
package/overlay/preload.cjs
CHANGED
|
@@ -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/overlay/visibility.cjs
CHANGED
|
@@ -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
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
/**
|
|
57
|
+
* Replace config.json with exactly `next` (no merge). This is the primitive that
|
|
58
|
+
* lets sign-out REMOVE keys — writeConfig's patch merge can only add or overwrite.
|
|
59
|
+
*/
|
|
60
|
+
export function writeConfigObject(next) {
|
|
61
|
+
const file = configPath();
|
|
62
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
58
63
|
// Atomic write: a crash mid-write must not truncate config.json and wipe the
|
|
59
64
|
// device token (which would put the daemon into a launchd crash loop).
|
|
60
|
-
const file = configPath();
|
|
61
65
|
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
62
66
|
fs.writeFileSync(tmp, JSON.stringify(next, null, 2), { mode: 0o600 });
|
|
63
67
|
fs.renameSync(tmp, file);
|
|
64
68
|
return next;
|
|
65
69
|
}
|
|
66
70
|
|
|
71
|
+
export function writeConfig(patch) {
|
|
72
|
+
return writeConfigObject({ ...readConfig(), ...patch });
|
|
73
|
+
}
|
|
74
|
+
|
|
67
75
|
export function apiUrl() {
|
|
68
76
|
return (
|
|
69
77
|
process.env.RELAY_API_URL ||
|
package/src/task-daemon.js
CHANGED
|
@@ -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,
|