relay-companion 0.1.72 → 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/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 });
@@ -675,12 +700,29 @@ async function markAllVisibleRelaysRead() {
675
700
 
676
701
  let sentCache = [];
677
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
+ }
678
719
  async function refreshSent() {
679
720
  if (!deviceToken()) return sentCache; // signed out: nothing to fetch, no 401 log storm
680
721
  try {
681
722
  const client = await relayClient();
682
723
  const res = await client.sent();
683
724
  sentCache = Array.isArray(res && res.items) ? res.items : [];
725
+ sentFingerprint = sentFingerprintOf(sentCache);
684
726
  } catch (error) {
685
727
  console.error("[overlay] listSent failed:", error && error.message);
686
728
  }
@@ -725,6 +767,11 @@ function ensureTasksLoaded() {
725
767
 
726
768
  let contactsCache = [];
727
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
+ }
728
775
  async function refreshContacts() {
729
776
  if (!deviceToken()) return contactsCache; // signed out: skip the poll entirely
730
777
  try {
@@ -744,6 +791,7 @@ async function refreshContacts() {
744
791
  };
745
792
  })
746
793
  .sort((a, b) => String(a.name).localeCompare(String(b.name)));
794
+ contactsFingerprint = contactsFingerprintOf(contactsCache);
747
795
  } catch (error) {
748
796
  // Keep the last good cache; a transient network failure must not blank the UI.
749
797
  console.error("[overlay] listContacts failed:", error && error.message);
@@ -791,6 +839,7 @@ async function deleteContactFromBook(input) {
791
839
  // pushInbox when it lands. This is what makes the pill appear immediately even on a
792
840
  // black-holed network (the client fetch timeout is 15s — far too long to block paint).
793
841
  function buildPayload() {
842
+ perf.inc("payloadBuilds");
794
843
  // Kick the first loads without awaiting; each calls pushInbox(false) on completion.
795
844
  if (!sentLoadedOnce) ensureSentLoaded().then(() => pushInbox(false)).catch(() => {});
796
845
  if (!contactsLoadedOnce) ensureContactsLoaded().then(() => pushInbox(false)).catch(() => {});
@@ -811,6 +860,21 @@ function buildPayload() {
811
860
  // Pushes are serialized: overlapping timers (fs.watch + safety poll + sent refresh)
812
861
  // must not interleave sends, or the renderer can paint an older payload last.
813
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
+ }
814
878
  const USER_IDLE_THRESHOLD_SECONDS = 15;
815
879
  let systemSuspended = false;
816
880
  let screenLocked = false;
@@ -823,6 +887,7 @@ function userIsAway() {
823
887
  if (process.env.RELAY_OVERLAY_TEST_FORCE_ACTIVE === "1") return false;
824
888
  if (systemSuspended || screenLocked || !loginSessionActive) return true;
825
889
  try {
890
+ perf.inc("idleQueries");
826
891
  const state = powerMonitor.getSystemIdleState(USER_IDLE_THRESHOLD_SECONDS);
827
892
  return state === "idle" || state === "locked";
828
893
  } catch {
@@ -846,6 +911,7 @@ const dwellMs = () => Number(process.env.RELAY_OVERLAY_NOTIFICATION_MS) || 7000;
846
911
 
847
912
  function idleSecondsSafe() {
848
913
  try {
914
+ perf.inc("idleQueries");
849
915
  return powerMonitor.getSystemIdleTime();
850
916
  } catch {
851
917
  return 0;
@@ -877,21 +943,35 @@ function abortCurrentShow(reason) {
877
943
  // card was visible). A wake resets the idle counter, so the sampler alone —
878
944
  // not a single end-of-dwell reading — is what makes wake-to-black-screen
879
945
  // dwells fail closed and stay queued.
880
- function beginShowSampling(entryIds, digest) {
946
+ function beginShowSampling(entryIds, digest, { sticky = false } = {}) {
881
947
  const idleAtStart = idleSecondsSafe();
882
948
  const startedAt = Date.now();
883
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);
884
958
  show.sampler = setInterval(() => {
885
959
  const elapsed = (Date.now() - startedAt) / 1000;
886
960
  const expected = show.idleAtStart + elapsed;
887
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
+ }
888
966
  }, 1000);
889
967
  return show;
890
968
  }
891
969
 
892
970
  // One card (or one digest) at a time. Every exit from the queue is either a
893
971
  // confirmed dwell/interaction or an explicit per-relay user act elsewhere.
894
- 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) {
895
975
  if (!win || win.isDestroyed() || !pillReady || !rendererListening) return false;
896
976
  if (currentShow || attention.hasShowing(attentionQueue)) return false;
897
977
  if (userIsAway()) {
@@ -908,7 +988,7 @@ function pumpAttention() {
908
988
  if (!hasFresh) return false;
909
989
  }
910
990
 
911
- const payload = buildPayload();
991
+ const payload = prebuiltPayload || buildPayload();
912
992
  const unreadRows = new Map(
913
993
  visibleRelayRows(payload.relays).filter((r) => r.unread).map((r) => [r.id, r]),
914
994
  );
@@ -933,7 +1013,7 @@ function pumpAttention() {
933
1013
  if (!row) {
934
1014
  attention.drop(attentionQueue, entry.id);
935
1015
  writeOverlayPrefs();
936
- return pumpAttention();
1016
+ return pumpAttention(payload); // same store generation: reuse the build
937
1017
  }
938
1018
  sticky = entry.sticky === true;
939
1019
  attention.beginShow(attentionQueue, entry.id);
@@ -951,7 +1031,7 @@ function pumpAttention() {
951
1031
  deferredAttention = false;
952
1032
  maybeShow({ force: true });
953
1033
  activeAttentionIds = new Set(ids);
954
- currentShow = beginShowSampling(ids, digestMode);
1034
+ currentShow = beginShowSampling(ids, digestMode, { sticky });
955
1035
  setThrottlingForShow(true);
956
1036
  lastEngagedAt = Date.now(); // a live card warrants tight sent/host cadence briefly
957
1037
  currentShow.sticky = sticky;
@@ -973,6 +1053,14 @@ function pumpAttention() {
973
1053
  // The return pump replaces the old fixed [0,1200,4500]ms retries: while relays
974
1054
  // still owe a notification it keeps trying every 2s — across slow wakes, slow
975
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.
976
1064
  let returnPumpTimer = null;
977
1065
  function startReturnPump() {
978
1066
  if (returnPumpTimer) return;
@@ -982,12 +1070,21 @@ function startReturnPump() {
982
1070
  returnPumpTimer = null;
983
1071
  return;
984
1072
  }
985
- 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
+ }
986
1084
  // Returning from away cuts through a snooze: the user left, so what they
987
1085
  // dismissed is stale context and unseen relays must surface again.
988
1086
  dismissSnoozedIds = new Set();
989
1087
  burstShown = 0;
990
- refreshOverlayForActiveSpace({ force: true });
991
1088
  pumpAttention();
992
1089
  }, 2000);
993
1090
  }
@@ -1016,6 +1113,10 @@ function pushInbox(force) {
1016
1113
  }
1017
1114
  async function pushInboxNow(force) {
1018
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();
1019
1120
  const payload = buildPayload();
1020
1121
  const rows = payload.relays;
1021
1122
  const notifiableRows = visibleRelayRows(rows);
@@ -1069,7 +1170,7 @@ async function pushInboxNow(force) {
1069
1170
  lastSig = sig;
1070
1171
  if (win && !win.isDestroyed()) win.webContents.send("inbox", payload);
1071
1172
  }
1072
- pumpAttention();
1173
+ pumpAttention(payload); // reuse this build; pumping must not re-read the store
1073
1174
  }
1074
1175
 
1075
1176
  // Refresh state-derived rows only (fast path used by fs.watch + the safety poll).
@@ -1263,9 +1364,11 @@ function taskVersion(taskId) {
1263
1364
  // Best-effort frontmost-app bundle id (no permission prompt; uses lsappinfo).
1264
1365
  function frontmostBundleId(cb) {
1265
1366
  if (process.platform !== "darwin") return cb(null);
1367
+ perf.inc("spawns");
1266
1368
  execFile("/usr/bin/lsappinfo", ["front"], (e1, asn) => {
1267
1369
  const a = String(asn || "").trim();
1268
1370
  if (e1 || !a) return cb(null);
1371
+ perf.inc("spawns");
1269
1372
  execFile("/usr/bin/lsappinfo", ["info", "-only", "bundleid", a], (e2, out) => {
1270
1373
  const m = String(out || "").match(/"CFBundleIdentifier"\s*=\s*"([^"]+)"/);
1271
1374
  cb(e2 ? null : m ? m[1] : null);
@@ -1295,6 +1398,7 @@ function activateHost(host, observedBundle = null) {
1295
1398
  if (lastError) console.error("[overlay] activateHost failed:", host, lastError && lastError.message);
1296
1399
  return;
1297
1400
  }
1401
+ perf.inc("spawns");
1298
1402
  execFile("/usr/bin/open", ["-b", bundle], (error) => {
1299
1403
  if (error) tryBundle(index + 1, error);
1300
1404
  });
@@ -1663,6 +1767,7 @@ async function openPacket(packetId, { sent = false, fresh = false } = {}) {
1663
1767
  // then keep the row spinner alive while it does post-import title repair.
1664
1768
  env.RELAY_IMPORT_CLAUDE_DESKTOP = "0";
1665
1769
  }
1770
+ perf.inc("spawns");
1666
1771
  const child = spawn(
1667
1772
  process.execPath,
1668
1773
  // --fresh ("Open in new chat"): the materializer ignores the remembered
@@ -1891,6 +1996,7 @@ function openTaskDetail(taskId) {
1891
1996
  // Let the overlay own the actual deep-link launch (see openPacket).
1892
1997
  env.RELAY_IMPORT_CLAUDE_DESKTOP = "0";
1893
1998
  }
1999
+ perf.inc("spawns");
1894
2000
  const child = spawn(
1895
2001
  process.execPath,
1896
2002
  [RELAY_CLI, "open", "--task", taskId, "--host", host, COMPANION_MODE_CLI_ARG],
@@ -1990,6 +2096,7 @@ function showOverlayWindow({ force = false, reposition = true } = {}) {
1990
2096
  if (!win || win.isDestroyed()) return;
1991
2097
  const visible = win.isVisible();
1992
2098
  if (visible && !force) {
2099
+ perf.inc("spaceAsserts");
1993
2100
  reinforceSpacePresence(win);
1994
2101
  return;
1995
2102
  }
@@ -2003,6 +2110,7 @@ function showOverlayWindow({ force = false, reposition = true } = {}) {
2003
2110
  // No reinforceSpacePresence before this call: showInactiveOnAllSpaces must observe
2004
2111
  // whether the collection behavior actually drifted to decide between a real
2005
2112
  // re-attach and a no-op — repairing it first would force the re-show every time.
2113
+ perf.inc("spaceAsserts");
2006
2114
  const shown = showInactiveOnAllSpaces(win, { force });
2007
2115
  if (shown) {
2008
2116
  // hidden -> shown only: re-assert click-through and reset the renderer's
@@ -2011,6 +2119,11 @@ function showOverlayWindow({ force = false, reposition = true } = {}) {
2011
2119
  // the pointer handshake is live — a dead card until the pointer re-enters.
2012
2120
  win.setIgnoreMouseEvents(true, { forward: true });
2013
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 });
2014
2127
  }
2015
2128
  }
2016
2129
 
@@ -2020,7 +2133,23 @@ function maybeShow({ force = false, reposition = true } = {}) {
2020
2133
  // trayForcedVisible honors an explicit status-area click even when no host runs.
2021
2134
  const wanted = overlayWanted({ hostRunning, trayForcedVisible, trayAvailable, dismissed, ghostActive, attentionLatched });
2022
2135
  if (wanted) {
2023
- 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
+ }
2024
2153
  } else if (win.isVisible()) {
2025
2154
  win.hide();
2026
2155
  }
@@ -2042,6 +2171,7 @@ function refreshOverlayForActiveSpace({ force = false } = {}) {
2042
2171
  }
2043
2172
 
2044
2173
  function readHostProcesses(cb) {
2174
+ perf.inc("spawns");
2045
2175
  if (process.platform === "win32") {
2046
2176
  execFile("tasklist", ["/FO", "CSV", "/NH"], cb);
2047
2177
  return;
@@ -2064,7 +2194,7 @@ function updateHostRunningFromProcesses(text) {
2064
2194
  hostRunning = hostTracker.update(claudeRunning, codexRunning);
2065
2195
  }
2066
2196
 
2067
- function pollHosts() {
2197
+ function pollHosts({ probeFrontmost = true } = {}) {
2068
2198
  // Show whenever EITHER host app is running; track the foregrounded one for click routing.
2069
2199
  readHostProcesses((err, stdout) => {
2070
2200
  // On a process-list error, leave the last-known running state untouched rather than
@@ -2073,9 +2203,15 @@ function pollHosts() {
2073
2203
  if (hostRunning) trayForcedVisible = false; // normal host-based visibility takes back over
2074
2204
  maybeShow();
2075
2205
  });
2076
- frontmostBundleId((bundle) => {
2077
- rememberForegroundHost(hostFromBundle(bundle));
2078
- });
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
+ }
2079
2215
  }
2080
2216
 
2081
2217
  // ---- pointer hit test (main-process authority) -----------------------------
@@ -2172,6 +2308,7 @@ function hitTick() {
2172
2308
  let bounds;
2173
2309
  let point;
2174
2310
  try {
2311
+ perf.inc("cursorReads");
2175
2312
  bounds = win.getContentBounds(); // frameless: content == frame; follows setPos for free
2176
2313
  point = screen.getCursorScreenPoint();
2177
2314
  } catch {
@@ -2304,13 +2441,29 @@ function createWindow() {
2304
2441
  if (!file || String(file).startsWith("state.json")) pushInboxQuiet();
2305
2442
  });
2306
2443
  } catch {}
2307
- 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);
2308
2455
  // Adaptive cadences (visibility.cjs): tight loops only while the user is
2309
2456
  // engaged; idle machines get slow heartbeats instead of spawn/fetch storms.
2310
2457
  const testMode = process.env.RELAY_OVERLAY_TEST === "1";
2311
2458
  const sentLoop = () => {
2459
+ const fingerprintBefore = sentFingerprint;
2312
2460
  refreshSent()
2313
- .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
+ })
2314
2467
  .catch(() => {})
2315
2468
  .finally(() =>
2316
2469
  setTimeout(sentLoop, sentRefreshDelayMs({ testMode, engaged: isEngaged(), showActive: Boolean(currentShow) })),
@@ -2318,13 +2471,26 @@ function createWindow() {
2318
2471
  };
2319
2472
  setTimeout(sentLoop, 5000);
2320
2473
  setInterval(() => {
2474
+ const fingerprintBefore = contactsFingerprint;
2321
2475
  refreshContacts()
2322
- .then(() => pushInbox(false))
2476
+ .then(() => {
2477
+ if (contactsFingerprint !== fingerprintBefore) return pushInbox(false);
2478
+ perf.inc("contactsPushSkips");
2479
+ })
2323
2480
  .catch(() => {});
2324
2481
  }, 60000); // slow contact refresh; the contacts view also refreshes on open
2325
2482
  const hostLoop = () => {
2326
- pollHosts();
2327
- 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
+ );
2328
2494
  };
2329
2495
  setTimeout(hostLoop, hostPollDelayMs({ testMode, engaged: true }));
2330
2496
  }
@@ -2606,6 +2772,7 @@ ipcMain.on("relay:cardSize", (_e, w, h) => {
2606
2772
  ipcMain.on("relay:setPos", (_e, x, y) => {
2607
2773
  if (win && !win.isDestroyed() && Number.isFinite(x) && Number.isFinite(y)) {
2608
2774
  win.setPosition(Math.round(x), Math.round(y));
2775
+ perf.inc("spaceAsserts");
2609
2776
  reinforceSpacePresence(win);
2610
2777
  }
2611
2778
  });
@@ -2735,9 +2902,26 @@ if (!gotSingleInstanceLock) {
2735
2902
  createTray();
2736
2903
  installActiveSpaceWatcher();
2737
2904
  installPowerAttentionLifecycle();
2738
- // Test seam: the e2e harness (test/e2e-overlay.mjs) drives the real state machine
2739
- // over the main-process inspector. Never set outside the harness.
2740
- 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") {
2741
2925
  global.__relayTest = {
2742
2926
  showFromTray,
2743
2927
  requestExternalReopen,
@@ -2754,6 +2938,7 @@ if (!gotSingleInstanceLock) {
2754
2938
  },
2755
2939
  setSentCache: (items) => {
2756
2940
  sentCache = Array.isArray(items) ? items : [];
2941
+ sentFingerprint = sentFingerprintOf(sentCache);
2757
2942
  return pushInbox(true);
2758
2943
  },
2759
2944
  getWin: () => win,
@@ -2766,6 +2951,7 @@ if (!gotSingleInstanceLock) {
2766
2951
  return ids;
2767
2952
  },
2768
2953
  pumpAttention,
2954
+ perf: () => perf.snapshot(),
2769
2955
  state: () => ({
2770
2956
  dismissed,
2771
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 };
@@ -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.72",
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": {
@@ -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,