relay-companion 0.1.72 → 0.1.74

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
@@ -21,6 +21,24 @@
21
21
  // auth. ack/mark-read writes state.json directly (atomic temp+rename).
22
22
 
23
23
  const { app, BrowserWindow, Menu, Tray, ipcMain, nativeImage, powerMonitor, shell, screen, systemPreferences } = require("electron");
24
+
25
+ // A closed stdout/stderr pipe must never kill the pill. When whatever launched
26
+ // us goes away (launchd log rotation, a terminal that spawned `relay pill`, a
27
+ // parent harness exiting), the next console write throws EPIPE — and an
28
+ // unhandled throw in the main process shows Electron's "A JavaScript error
29
+ // occurred" dialog and takes the overlay down. Logging is diagnostic; it is
30
+ // never worth a crash, so swallow write errors on both streams and treat a
31
+ // stray EPIPE as a no-op.
32
+ for (const stream of [process.stdout, process.stderr]) {
33
+ try {
34
+ stream.on("error", () => {});
35
+ } catch {}
36
+ }
37
+ process.on("uncaughtException", (error) => {
38
+ if (error && (error.code === "EPIPE" || error.code === "ERR_STREAM_DESTROYED")) return;
39
+ throw error;
40
+ });
41
+
24
42
  const fs = require("node:fs");
25
43
  const os = require("node:os");
26
44
  const path = require("node:path");
@@ -62,6 +80,7 @@ const {
62
80
  companionModeFromRuntime,
63
81
  taskFeaturesAllowed,
64
82
  } = require("./mode-policy.cjs");
83
+ const perf = require("./perf-counters.cjs");
65
84
 
66
85
  const RELAY_HOME = process.env.RELAY_HOME || process.env.RELAY_COMPANION_HOME || path.join(os.homedir(), ".relay-companion");
67
86
  const STATE_PATH = path.join(RELAY_HOME, "state.json");
@@ -132,17 +151,40 @@ let pendingReopenNonce = "";
132
151
  let lastReopenNonce = "";
133
152
  let lastPillStatusSig = "";
134
153
  const PRESENTED_RELAY_CAP = 500;
154
+ // Dirty gate: the guaranteed-attention machinery calls writeOverlayPrefs on every
155
+ // safety tick while the queue is non-empty, which used to SYNC-write an identical
156
+ // 9KB file every 2.5s for hours (observed live on 2026-08-05: a fresh mtime on
157
+ // every 5s sample). Serialize first and skip the disk entirely when the content
158
+ // is byte-identical to the last successful write. Every real state change still
159
+ // persists immediately — including the in-flight marker BEFORE renderer delivery
160
+ // (beginShow mutates the queue, so that serialization always differs). The write
161
+ // itself is now atomic (tmp+rename): a crash mid-write must never corrupt the
162
+ // durable attention queue it exists to protect.
163
+ let lastPrefsSerialized = "";
135
164
  function writeOverlayPrefs() {
165
+ let tmp = "";
136
166
  try {
137
- fs.mkdirSync(RELAY_HOME, { recursive: true });
138
167
  const prefs = attention.saveQueue(attentionQueue, {
139
168
  dismissed,
140
169
  attentionLatched,
141
170
  presentedRelayIds: [...presentedRelayIds],
142
171
  activeAttentionIds: [...activeAttentionIds],
143
172
  });
144
- fs.writeFileSync(OVERLAY_PREFS_PATH, `${JSON.stringify(prefs, null, 2)}\n`);
173
+ const serialized = `${JSON.stringify(prefs, null, 2)}\n`;
174
+ if (serialized === lastPrefsSerialized) {
175
+ perf.inc("prefsWriteSkips");
176
+ return;
177
+ }
178
+ fs.mkdirSync(RELAY_HOME, { recursive: true });
179
+ tmp = `${OVERLAY_PREFS_PATH}.${process.pid}.${Date.now()}.tmp`;
180
+ fs.writeFileSync(tmp, serialized);
181
+ fs.renameSync(tmp, OVERLAY_PREFS_PATH);
182
+ lastPrefsSerialized = serialized;
183
+ perf.inc("prefsWrites");
145
184
  } catch (error) {
185
+ try {
186
+ if (tmp) fs.rmSync(tmp, { force: true });
187
+ } catch {}
146
188
  console.error(`[overlay] ${new Date().toISOString()} prefs write failed:`, error && error.message);
147
189
  }
148
190
  }
@@ -177,6 +219,7 @@ function writePillStatus(reopenNonce = "") {
177
219
  fs.writeFileSync(tmp, `${JSON.stringify(status, null, 2)}\n`);
178
220
  fs.renameSync(tmp, PILL_STATUS_PATH);
179
221
  lastPillStatusSig = sig;
222
+ perf.inc("statusWrites");
180
223
  } catch (error) {
181
224
  try {
182
225
  if (tmp) fs.rmSync(tmp, { force: true });
@@ -675,12 +718,29 @@ async function markAllVisibleRelaysRead() {
675
718
 
676
719
  let sentCache = [];
677
720
  let sentLoadedOnce = null;
721
+ // Fingerprint over exactly the fields the inbox signature (and therefore the
722
+ // renderer) can observe, so "did anything change?" costs a tiny stringify
723
+ // instead of a full payload rebuild per refresh.
724
+ let sentFingerprint = "";
725
+ function sentFingerprintOf(items) {
726
+ return JSON.stringify(
727
+ (items || []).map((r) => [
728
+ r.relayId,
729
+ r.state,
730
+ r.updatedAt,
731
+ r.delivery && r.delivery.state,
732
+ r.delivery && r.delivery.channel,
733
+ r.hasAttachments,
734
+ ]),
735
+ );
736
+ }
678
737
  async function refreshSent() {
679
738
  if (!deviceToken()) return sentCache; // signed out: nothing to fetch, no 401 log storm
680
739
  try {
681
740
  const client = await relayClient();
682
741
  const res = await client.sent();
683
742
  sentCache = Array.isArray(res && res.items) ? res.items : [];
743
+ sentFingerprint = sentFingerprintOf(sentCache);
684
744
  } catch (error) {
685
745
  console.error("[overlay] listSent failed:", error && error.message);
686
746
  }
@@ -725,6 +785,11 @@ function ensureTasksLoaded() {
725
785
 
726
786
  let contactsCache = [];
727
787
  let contactsLoadedOnce = null;
788
+ let contactsFingerprint = "";
789
+ function contactsFingerprintOf(list) {
790
+ // Same triple the inbox signature hashes for contacts.
791
+ return JSON.stringify((list || []).map((c) => [c.id, c.name, c.email]));
792
+ }
728
793
  async function refreshContacts() {
729
794
  if (!deviceToken()) return contactsCache; // signed out: skip the poll entirely
730
795
  try {
@@ -744,6 +809,7 @@ async function refreshContacts() {
744
809
  };
745
810
  })
746
811
  .sort((a, b) => String(a.name).localeCompare(String(b.name)));
812
+ contactsFingerprint = contactsFingerprintOf(contactsCache);
747
813
  } catch (error) {
748
814
  // Keep the last good cache; a transient network failure must not blank the UI.
749
815
  console.error("[overlay] listContacts failed:", error && error.message);
@@ -791,6 +857,7 @@ async function deleteContactFromBook(input) {
791
857
  // pushInbox when it lands. This is what makes the pill appear immediately even on a
792
858
  // black-holed network (the client fetch timeout is 15s — far too long to block paint).
793
859
  function buildPayload() {
860
+ perf.inc("payloadBuilds");
794
861
  // Kick the first loads without awaiting; each calls pushInbox(false) on completion.
795
862
  if (!sentLoadedOnce) ensureSentLoaded().then(() => pushInbox(false)).catch(() => {});
796
863
  if (!contactsLoadedOnce) ensureContactsLoaded().then(() => pushInbox(false)).catch(() => {});
@@ -811,6 +878,21 @@ function buildPayload() {
811
878
  // Pushes are serialized: overlapping timers (fs.watch + safety poll + sent refresh)
812
879
  // must not interleave sends, or the renderer can paint an older payload last.
813
880
  let pushChain = Promise.resolve();
881
+ // state.json generation gate for the 2.5s safety poll: reading + parsing a
882
+ // ~500KB store and re-deriving a 150-row payload every tick is what kept the
883
+ // pill hot all day. The safety tick now costs ONE stat() unless the file
884
+ // actually changed since the last full push (fs.watch/watchFile still fire the
885
+ // real pushes on change; this closes their races). Content changes always move
886
+ // mtimeMs/size because every writer uses temp+rename or a direct rewrite.
887
+ let lastStateStatSig = "";
888
+ function stateFileStatSig() {
889
+ try {
890
+ const st = fs.statSync(STATE_PATH);
891
+ return `${st.mtimeMs}:${st.size}`;
892
+ } catch {
893
+ return "missing";
894
+ }
895
+ }
814
896
  const USER_IDLE_THRESHOLD_SECONDS = 15;
815
897
  let systemSuspended = false;
816
898
  let screenLocked = false;
@@ -823,6 +905,7 @@ function userIsAway() {
823
905
  if (process.env.RELAY_OVERLAY_TEST_FORCE_ACTIVE === "1") return false;
824
906
  if (systemSuspended || screenLocked || !loginSessionActive) return true;
825
907
  try {
908
+ perf.inc("idleQueries");
826
909
  const state = powerMonitor.getSystemIdleState(USER_IDLE_THRESHOLD_SECONDS);
827
910
  return state === "idle" || state === "locked";
828
911
  } catch {
@@ -846,6 +929,7 @@ const dwellMs = () => Number(process.env.RELAY_OVERLAY_NOTIFICATION_MS) || 7000;
846
929
 
847
930
  function idleSecondsSafe() {
848
931
  try {
932
+ perf.inc("idleQueries");
849
933
  return powerMonitor.getSystemIdleTime();
850
934
  } catch {
851
935
  return 0;
@@ -877,21 +961,35 @@ function abortCurrentShow(reason) {
877
961
  // card was visible). A wake resets the idle counter, so the sampler alone —
878
962
  // not a single end-of-dwell reading — is what makes wake-to-black-screen
879
963
  // dwells fail closed and stay queued.
880
- function beginShowSampling(entryIds, digest) {
964
+ function beginShowSampling(entryIds, digest, { sticky = false } = {}) {
881
965
  const idleAtStart = idleSecondsSafe();
882
966
  const startedAt = Date.now();
883
967
  const show = { ids: entryIds, digest: Boolean(digest), startedAt, idleAtStart, inputSeen: false, sampler: null };
968
+ // Sticky cards latch open indefinitely and only ever confirm via a renderer
969
+ // interaction (interacted=true), which needs no idle evidence — so don't run
970
+ // a 1Hz idle query for the whole time one sits on screen.
971
+ if (sticky) return show;
972
+ // Cap the sampler at a few dwells past the fold deadline: the renderer's
973
+ // attentionDone lands within one dwell, and evidence gathered after ~30s
974
+ // could never belong to this card's visible interval anyway.
975
+ const samplerCapMs = Math.max(dwellMs() * 4, 30000);
884
976
  show.sampler = setInterval(() => {
885
977
  const elapsed = (Date.now() - startedAt) / 1000;
886
978
  const expected = show.idleAtStart + elapsed;
887
979
  if (idleSecondsSafe() < expected - 1) show.inputSeen = true;
980
+ if (Date.now() - startedAt > samplerCapMs && show.sampler) {
981
+ clearInterval(show.sampler);
982
+ show.sampler = null;
983
+ }
888
984
  }, 1000);
889
985
  return show;
890
986
  }
891
987
 
892
988
  // One card (or one digest) at a time. Every exit from the queue is either a
893
989
  // confirmed dwell/interaction or an explicit per-relay user act elsewhere.
894
- function pumpAttention() {
990
+ // prebuiltPayload lets pushInboxNow hand over the payload it just derived, so
991
+ // the hot pump path never parses state.json a second time per tick.
992
+ function pumpAttention(prebuiltPayload = null) {
895
993
  if (!win || win.isDestroyed() || !pillReady || !rendererListening) return false;
896
994
  if (currentShow || attention.hasShowing(attentionQueue)) return false;
897
995
  if (userIsAway()) {
@@ -908,7 +1006,7 @@ function pumpAttention() {
908
1006
  if (!hasFresh) return false;
909
1007
  }
910
1008
 
911
- const payload = buildPayload();
1009
+ const payload = prebuiltPayload || buildPayload();
912
1010
  const unreadRows = new Map(
913
1011
  visibleRelayRows(payload.relays).filter((r) => r.unread).map((r) => [r.id, r]),
914
1012
  );
@@ -933,7 +1031,7 @@ function pumpAttention() {
933
1031
  if (!row) {
934
1032
  attention.drop(attentionQueue, entry.id);
935
1033
  writeOverlayPrefs();
936
- return pumpAttention();
1034
+ return pumpAttention(payload); // same store generation: reuse the build
937
1035
  }
938
1036
  sticky = entry.sticky === true;
939
1037
  attention.beginShow(attentionQueue, entry.id);
@@ -951,7 +1049,7 @@ function pumpAttention() {
951
1049
  deferredAttention = false;
952
1050
  maybeShow({ force: true });
953
1051
  activeAttentionIds = new Set(ids);
954
- currentShow = beginShowSampling(ids, digestMode);
1052
+ currentShow = beginShowSampling(ids, digestMode, { sticky });
955
1053
  setThrottlingForShow(true);
956
1054
  lastEngagedAt = Date.now(); // a live card warrants tight sent/host cadence briefly
957
1055
  currentShow.sticky = sticky;
@@ -973,6 +1071,14 @@ function pumpAttention() {
973
1071
  // The return pump replaces the old fixed [0,1200,4500]ms retries: while relays
974
1072
  // still owe a notification it keeps trying every 2s — across slow wakes, slow
975
1073
  // Wi-Fi reassociation and the daemon's next poll — until the queue drains.
1074
+ //
1075
+ // It is a RETRY loop, not a maintenance loop (2026-08-05 freeze audit): the old
1076
+ // per-tick refreshOverlayForActiveSpace({force:true}) spawned `ps` and forced a
1077
+ // window-server re-assertion every 2s for as long as anything was queued — with
1078
+ // a sticky card latched on stage, that was a permanent hot loop. Space presence
1079
+ // is owned by events (Space changes, show edges, return-from-away, display
1080
+ // changes); pumpAttention's own maybeShow({force:true}) still raises the window
1081
+ // whenever a card actually fires.
976
1082
  let returnPumpTimer = null;
977
1083
  function startReturnPump() {
978
1084
  if (returnPumpTimer) return;
@@ -982,12 +1088,21 @@ function startReturnPump() {
982
1088
  returnPumpTimer = null;
983
1089
  return;
984
1090
  }
985
- if (userIsAway()) return;
1091
+ // A card is on stage: its confirm/abort exit re-pumps (or restarts this
1092
+ // pump). Ticking during the dwell was pure churn.
1093
+ if (currentShow || attention.hasShowing(attentionQueue)) return;
1094
+ if (userIsAway()) {
1095
+ // Park entirely while away: the 1s deferred-attention poll owns the
1096
+ // return edge and restarts the pump via reconcileAttentionAfterReturn.
1097
+ deferredAttention = true;
1098
+ clearInterval(returnPumpTimer);
1099
+ returnPumpTimer = null;
1100
+ return;
1101
+ }
986
1102
  // Returning from away cuts through a snooze: the user left, so what they
987
1103
  // dismissed is stale context and unseen relays must surface again.
988
1104
  dismissSnoozedIds = new Set();
989
1105
  burstShown = 0;
990
- refreshOverlayForActiveSpace({ force: true });
991
1106
  pumpAttention();
992
1107
  }, 2000);
993
1108
  }
@@ -1016,6 +1131,10 @@ function pushInbox(force) {
1016
1131
  }
1017
1132
  async function pushInboxNow(force) {
1018
1133
  if (!win || win.isDestroyed()) return;
1134
+ // Record the state.json generation BEFORE reading it: a write that lands
1135
+ // mid-read leaves the stat differing on the next safety tick, so the racing
1136
+ // change is re-pushed rather than silently skipped.
1137
+ lastStateStatSig = stateFileStatSig();
1019
1138
  const payload = buildPayload();
1020
1139
  const rows = payload.relays;
1021
1140
  const notifiableRows = visibleRelayRows(rows);
@@ -1069,7 +1188,7 @@ async function pushInboxNow(force) {
1069
1188
  lastSig = sig;
1070
1189
  if (win && !win.isDestroyed()) win.webContents.send("inbox", payload);
1071
1190
  }
1072
- pumpAttention();
1191
+ pumpAttention(payload); // reuse this build; pumping must not re-read the store
1073
1192
  }
1074
1193
 
1075
1194
  // Refresh state-derived rows only (fast path used by fs.watch + the safety poll).
@@ -1263,9 +1382,11 @@ function taskVersion(taskId) {
1263
1382
  // Best-effort frontmost-app bundle id (no permission prompt; uses lsappinfo).
1264
1383
  function frontmostBundleId(cb) {
1265
1384
  if (process.platform !== "darwin") return cb(null);
1385
+ perf.inc("spawns");
1266
1386
  execFile("/usr/bin/lsappinfo", ["front"], (e1, asn) => {
1267
1387
  const a = String(asn || "").trim();
1268
1388
  if (e1 || !a) return cb(null);
1389
+ perf.inc("spawns");
1269
1390
  execFile("/usr/bin/lsappinfo", ["info", "-only", "bundleid", a], (e2, out) => {
1270
1391
  const m = String(out || "").match(/"CFBundleIdentifier"\s*=\s*"([^"]+)"/);
1271
1392
  cb(e2 ? null : m ? m[1] : null);
@@ -1295,6 +1416,7 @@ function activateHost(host, observedBundle = null) {
1295
1416
  if (lastError) console.error("[overlay] activateHost failed:", host, lastError && lastError.message);
1296
1417
  return;
1297
1418
  }
1419
+ perf.inc("spawns");
1298
1420
  execFile("/usr/bin/open", ["-b", bundle], (error) => {
1299
1421
  if (error) tryBundle(index + 1, error);
1300
1422
  });
@@ -1663,6 +1785,7 @@ async function openPacket(packetId, { sent = false, fresh = false } = {}) {
1663
1785
  // then keep the row spinner alive while it does post-import title repair.
1664
1786
  env.RELAY_IMPORT_CLAUDE_DESKTOP = "0";
1665
1787
  }
1788
+ perf.inc("spawns");
1666
1789
  const child = spawn(
1667
1790
  process.execPath,
1668
1791
  // --fresh ("Open in new chat"): the materializer ignores the remembered
@@ -1891,6 +2014,7 @@ function openTaskDetail(taskId) {
1891
2014
  // Let the overlay own the actual deep-link launch (see openPacket).
1892
2015
  env.RELAY_IMPORT_CLAUDE_DESKTOP = "0";
1893
2016
  }
2017
+ perf.inc("spawns");
1894
2018
  const child = spawn(
1895
2019
  process.execPath,
1896
2020
  [RELAY_CLI, "open", "--task", taskId, "--host", host, COMPANION_MODE_CLI_ARG],
@@ -1990,6 +2114,7 @@ function showOverlayWindow({ force = false, reposition = true } = {}) {
1990
2114
  if (!win || win.isDestroyed()) return;
1991
2115
  const visible = win.isVisible();
1992
2116
  if (visible && !force) {
2117
+ perf.inc("spaceAsserts");
1993
2118
  reinforceSpacePresence(win);
1994
2119
  return;
1995
2120
  }
@@ -2003,6 +2128,7 @@ function showOverlayWindow({ force = false, reposition = true } = {}) {
2003
2128
  // No reinforceSpacePresence before this call: showInactiveOnAllSpaces must observe
2004
2129
  // whether the collection behavior actually drifted to decide between a real
2005
2130
  // re-attach and a no-op — repairing it first would force the re-show every time.
2131
+ perf.inc("spaceAsserts");
2006
2132
  const shown = showInactiveOnAllSpaces(win, { force });
2007
2133
  if (shown) {
2008
2134
  // hidden -> shown only: re-assert click-through and reset the renderer's
@@ -2011,6 +2137,11 @@ function showOverlayWindow({ force = false, reposition = true } = {}) {
2011
2137
  // the pointer handshake is live — a dead card until the pointer re-enters.
2012
2138
  win.setIgnoreMouseEvents(true, { forward: true });
2013
2139
  win.webContents.send("shown");
2140
+ // Becoming visible is when host-running freshness starts mattering for
2141
+ // click routing again; the hidden poll cadence is slow, so take one
2142
+ // reading at the show edge (process list only — the frontmost probe
2143
+ // belongs to hover/click time).
2144
+ pollHosts({ probeFrontmost: false });
2014
2145
  }
2015
2146
  }
2016
2147
 
@@ -2020,7 +2151,23 @@ function maybeShow({ force = false, reposition = true } = {}) {
2020
2151
  // trayForcedVisible honors an explicit status-area click even when no host runs.
2021
2152
  const wanted = overlayWanted({ hostRunning, trayForcedVisible, trayAvailable, dismissed, ghostActive, attentionLatched });
2022
2153
  if (wanted) {
2023
- showOverlayWindow({ force, reposition });
2154
+ // macOS steady state (already visible, nothing forcing): leave the
2155
+ // window-server state alone. The old unconditional showOverlayWindow here
2156
+ // re-asserted Space presence from every periodic caller (host poll, return
2157
+ // pump), which is timer-driven window-server load. Presence repair now runs
2158
+ // only on the events that can actually break it: Space changes, show edges,
2159
+ // wake/unlock reconciles, display changes and explicit forces.
2160
+ //
2161
+ // Windows is EXCLUDED from the fast path on purpose: the OS strips
2162
+ // WS_EX_TOPMOST in ways Electron's cached isAlwaysOnTop() cannot observe
2163
+ // (see space-presence.cjs), so the periodic reinforce IS the topmost
2164
+ // self-heal there — and SetWindowPos on an already-topmost window is not a
2165
+ // visible reorder on Windows.
2166
+ if (!force && win.isVisible() && process.platform === "darwin") {
2167
+ // no-op on the window
2168
+ } else {
2169
+ showOverlayWindow({ force, reposition });
2170
+ }
2024
2171
  } else if (win.isVisible()) {
2025
2172
  win.hide();
2026
2173
  }
@@ -2042,6 +2189,7 @@ function refreshOverlayForActiveSpace({ force = false } = {}) {
2042
2189
  }
2043
2190
 
2044
2191
  function readHostProcesses(cb) {
2192
+ perf.inc("spawns");
2045
2193
  if (process.platform === "win32") {
2046
2194
  execFile("tasklist", ["/FO", "CSV", "/NH"], cb);
2047
2195
  return;
@@ -2064,7 +2212,7 @@ function updateHostRunningFromProcesses(text) {
2064
2212
  hostRunning = hostTracker.update(claudeRunning, codexRunning);
2065
2213
  }
2066
2214
 
2067
- function pollHosts() {
2215
+ function pollHosts({ probeFrontmost = true } = {}) {
2068
2216
  // Show whenever EITHER host app is running; track the foregrounded one for click routing.
2069
2217
  readHostProcesses((err, stdout) => {
2070
2218
  // On a process-list error, leave the last-known running state untouched rather than
@@ -2073,9 +2221,15 @@ function pollHosts() {
2073
2221
  if (hostRunning) trayForcedVisible = false; // normal host-based visibility takes back over
2074
2222
  maybeShow();
2075
2223
  });
2076
- frontmostBundleId((bundle) => {
2077
- rememberForegroundHost(hostFromBundle(bundle));
2078
- });
2224
+ // The frontmost probe is two more spawns and only feeds lastHost, whose job is
2225
+ // click routing. Clicks always take a fresh frontmost reading, and the hover
2226
+ // approach probe captures the just-before-click state — so the periodic loop
2227
+ // only pays for it while the user is plausibly about to click (engaged).
2228
+ if (probeFrontmost) {
2229
+ frontmostBundleId((bundle) => {
2230
+ rememberForegroundHost(hostFromBundle(bundle));
2231
+ });
2232
+ }
2079
2233
  }
2080
2234
 
2081
2235
  // ---- pointer hit test (main-process authority) -----------------------------
@@ -2172,6 +2326,7 @@ function hitTick() {
2172
2326
  let bounds;
2173
2327
  let point;
2174
2328
  try {
2329
+ perf.inc("cursorReads");
2175
2330
  bounds = win.getContentBounds(); // frameless: content == frame; follows setPos for free
2176
2331
  point = screen.getCursorScreenPoint();
2177
2332
  } catch {
@@ -2304,13 +2459,29 @@ function createWindow() {
2304
2459
  if (!file || String(file).startsWith("state.json")) pushInboxQuiet();
2305
2460
  });
2306
2461
  } catch {}
2307
- setInterval(() => pushInboxQuiet(), 2500); // safety net for state.json
2462
+ // Safety net for state.json: ONE stat() per tick unless the file generation
2463
+ // actually moved since the last full push. The full 500KB parse + payload +
2464
+ // signature rebuild every 2.5s regardless of change was a top contributor to
2465
+ // the pill's always-on CPU (2026-08-05 freeze audit).
2466
+ setInterval(() => {
2467
+ if (stateFileStatSig() === lastStateStatSig) {
2468
+ perf.inc("statePollSkips");
2469
+ return;
2470
+ }
2471
+ pushInboxQuiet();
2472
+ }, 2500);
2308
2473
  // Adaptive cadences (visibility.cjs): tight loops only while the user is
2309
2474
  // engaged; idle machines get slow heartbeats instead of spawn/fetch storms.
2310
2475
  const testMode = process.env.RELAY_OVERLAY_TEST === "1";
2311
2476
  const sentLoop = () => {
2477
+ const fingerprintBefore = sentFingerprint;
2312
2478
  refreshSent()
2313
- .then(() => pushInbox(false))
2479
+ .then(() => {
2480
+ // Only rebuild + repush when the sig-relevant fields moved; an idle
2481
+ // machine's unchanged Sent list should cost the fetch and nothing more.
2482
+ if (sentFingerprint !== fingerprintBefore) return pushInbox(false);
2483
+ perf.inc("sentPushSkips");
2484
+ })
2314
2485
  .catch(() => {})
2315
2486
  .finally(() =>
2316
2487
  setTimeout(sentLoop, sentRefreshDelayMs({ testMode, engaged: isEngaged(), showActive: Boolean(currentShow) })),
@@ -2318,13 +2489,26 @@ function createWindow() {
2318
2489
  };
2319
2490
  setTimeout(sentLoop, 5000);
2320
2491
  setInterval(() => {
2492
+ const fingerprintBefore = contactsFingerprint;
2321
2493
  refreshContacts()
2322
- .then(() => pushInbox(false))
2494
+ .then(() => {
2495
+ if (contactsFingerprint !== fingerprintBefore) return pushInbox(false);
2496
+ perf.inc("contactsPushSkips");
2497
+ })
2323
2498
  .catch(() => {});
2324
2499
  }, 60000); // slow contact refresh; the contacts view also refreshes on open
2325
2500
  const hostLoop = () => {
2326
- pollHosts();
2327
- setTimeout(hostLoop, hostPollDelayMs({ testMode, engaged: isEngaged() }));
2501
+ // Engaged: fresh frontmost capture for imminent clicks. Otherwise the
2502
+ // process-list read alone keeps hostRunning/click-routing state warm.
2503
+ pollHosts({ probeFrontmost: isEngaged() });
2504
+ setTimeout(
2505
+ hostLoop,
2506
+ hostPollDelayMs({
2507
+ testMode,
2508
+ engaged: isEngaged(),
2509
+ visible: Boolean(win && !win.isDestroyed() && win.isVisible()),
2510
+ }),
2511
+ );
2328
2512
  };
2329
2513
  setTimeout(hostLoop, hostPollDelayMs({ testMode, engaged: true }));
2330
2514
  }
@@ -2606,6 +2790,7 @@ ipcMain.on("relay:cardSize", (_e, w, h) => {
2606
2790
  ipcMain.on("relay:setPos", (_e, x, y) => {
2607
2791
  if (win && !win.isDestroyed() && Number.isFinite(x) && Number.isFinite(y)) {
2608
2792
  win.setPosition(Math.round(x), Math.round(y));
2793
+ perf.inc("spaceAsserts");
2609
2794
  reinforceSpacePresence(win);
2610
2795
  }
2611
2796
  });
@@ -2735,9 +2920,26 @@ if (!gotSingleInstanceLock) {
2735
2920
  createTray();
2736
2921
  installActiveSpaceWatcher();
2737
2922
  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") {
2923
+ // Display topology changes are the remaining event that can strand the
2924
+ // overlay (stale bounds, dropped always-on-top after a monitor swap).
2925
+ // Event-driven repair replaces the old every-poll re-assertion.
2926
+ try {
2927
+ screen.on("display-added", () => maybeShow({ force: true }));
2928
+ screen.on("display-removed", () => maybeShow({ force: true }));
2929
+ screen.on("display-metrics-changed", () => maybeShow({ force: true }));
2930
+ } catch (error) {
2931
+ console.error("[overlay] display watcher failed:", error && error.message);
2932
+ }
2933
+ // Perf-counter log line for live diagnosis (opt-in, stderr → pill.log).
2934
+ if (process.env.RELAY_OVERLAY_PERF_LOG === "1") {
2935
+ perf.startPerfLog({ log: (line) => console.error(line) });
2936
+ }
2937
+ // Test seam: the e2e harness (test/e2e-overlay.mjs) and the perf harness
2938
+ // (test/perf-overlay.mjs) drive the real state machine over the main-process
2939
+ // inspector. RELAY_OVERLAY_PERF=1 exposes the seam WITHOUT flipping the
2940
+ // RELAY_OVERLAY_TEST cadences, so measurements see production timing.
2941
+ // Never set outside the harnesses.
2942
+ if (process.env.RELAY_OVERLAY_TEST === "1" || process.env.RELAY_OVERLAY_PERF === "1") {
2741
2943
  global.__relayTest = {
2742
2944
  showFromTray,
2743
2945
  requestExternalReopen,
@@ -2754,6 +2956,7 @@ if (!gotSingleInstanceLock) {
2754
2956
  },
2755
2957
  setSentCache: (items) => {
2756
2958
  sentCache = Array.isArray(items) ? items : [];
2959
+ sentFingerprint = sentFingerprintOf(sentCache);
2757
2960
  return pushInbox(true);
2758
2961
  },
2759
2962
  getWin: () => win,
@@ -2766,6 +2969,7 @@ if (!gotSingleInstanceLock) {
2766
2969
  return ids;
2767
2970
  },
2768
2971
  pumpAttention,
2972
+ perf: () => perf.snapshot(),
2769
2973
  state: () => ({
2770
2974
  dismissed,
2771
2975
  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.74",
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,