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 +227 -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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2077
|
-
|
|
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
|
-
|
|
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(() =>
|
|
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(() =>
|
|
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
|
-
|
|
2327
|
-
|
|
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
|
-
//
|
|
2739
|
-
//
|
|
2740
|
-
|
|
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 };
|
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,
|