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 +209 -23
- package/overlay/perf-counters.cjs +48 -0
- package/overlay/visibility.cjs +5 -2
- package/package.json +1 -1
- package/src/task-daemon.js +14 -2
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 });
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2077
|
-
|
|
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
|
-
|
|
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(() =>
|
|
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(() =>
|
|
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
|
-
|
|
2327
|
-
|
|
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
|
-
//
|
|
2739
|
-
//
|
|
2740
|
-
|
|
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 };
|
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/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,
|