relay-companion
Version:
Install Relay for Claude Code, Cowork, and Codex, then sign in from the Relay pill.
180 lines (169 loc) • 7.88 kB
JavaScript
// Pure overlay-visibility logic, extracted from main.cjs so the state machine that
// decides when the pill window is on screen is unit-testable without Electron.
//
// Relay is an independently installed desktop surface, not a child of Claude/Codex.
// Keep it available whenever its background process runs; only an explicit dismissal
// hides it. Host-running remains an input for click routing and backwards-compatible
// callers, but closing an agent terminal/app must never make Relay disappear.
function overlayWanted({
persistent = true,
hostRunning = false,
trayForcedVisible = false,
trayAvailable = false,
dismissed = false,
ghostActive = false,
attentionLatched = false,
permanentlyHidden = false,
explicitlyOpened = false,
} = {}) {
// `permanentlyHidden` is the Settings choice ("Keep Relay hidden"), and it is a
// PREFERENCE, not a snooze: unlike `dismissed`, nothing in the product may revoke
// it — not a running host, not a new relay's attention latch, not a ghost banner.
// Only the user can, and `explicitlyOpened` is how they say so: it is set solely
// by a deliberate open (tray click, Relay.lnk / Relay.app, `relay pill`), which is
// also the only route back to the Settings toggle that turns this off.
//
// NOT `trayForcedVisible`: that flag is a host-visibility handoff, cleared by both
// host pollers whenever Claude/Codex is running, so gating on it would hide the
// pill again within one poll of the user opening it.
//
// Gated on trayAvailable for exactly the reason dismissal is (see below): with no
// status-area icon there is no way back, so hiding has to be inert.
const hiddenEffective = trayAvailable && permanentlyHidden;
if (hiddenEffective && !explicitlyOpened) return false;
const dismissedEffective = trayAvailable && dismissed;
return (persistent || hostRunning || trayForcedVisible || attentionLatched) && (!dismissedEffective || ghostActive);
}
// Debounced "a host is running" tracker. The OFF transition requires `missThreshold`
// consecutive polls with neither host present: a host relaunching during its own
// auto-update (or one truncated process-list read) must not blink the pill off.
function createHostRunningTracker({ missThreshold = 3 } = {}) {
let running = false;
let missStreak = 0;
return {
update(claudeRunning, codexRunning) {
if (claudeRunning || codexRunning) {
missStreak = 0;
running = true;
} else {
missStreak += 1;
if (missStreak >= missThreshold) running = false;
}
return running;
},
get running() {
return running;
},
};
}
// A dismiss whose IPC was ALREADY in flight when a tray "Show Relay" landed would hide
// the pill the user just reopened. The renderer's cancelBye() prevents the exit timer
// from firing after a reopen, so this guard only needs to cover the narrow window where
// the dismiss IPC had already been SENT (~IPC latency). Keep it short — a wide window
// would swallow a GENUINE ✕ click made shortly after Show Relay. lastTrayShowAt must be
// reset once a dismiss is honored so the guard never lingers across interactions.
function shouldIgnoreDismiss({ now, lastTrayShowAt, windowMs = 250 } = {}) {
if (!lastTrayShowAt) return false;
const delta = Number(now) - Number(lastTrayShowAt);
return delta >= 0 && delta < windowMs;
}
// Keep presentation history bounded without ever evicting a relay that is still
// unread. Evicting an unread id would make the next safety poll treat it as new and
// could create an endless re-notification loop on a large inbox.
function boundedPresentedRelayIds(currentIds, newlyPresentedIds, retainedUnreadIds, cap = 500) {
const ids = new Set(Array.isArray(currentIds) ? currentIds.map(String) : []);
for (const value of newlyPresentedIds || []) {
const id = String(value || "");
if (!id) continue;
ids.delete(id);
ids.add(id);
}
const retained = new Set(Array.from(retainedUnreadIds || [], String));
while (ids.size > cap) {
const evictable = [...ids].find((id) => !retained.has(id));
if (!evictable) break;
ids.delete(evictable);
}
return [...ids];
}
// An attention stack is recorded as presented before its seven-second animation
// starts. If the process dies during that interval, the renderer never gets to send
// `attentionDone`, so those ids must be put back into the unpresented pool on the next
// launch. Clear the in-flight marker at the same time: if the new process also dies
// before it can present the recovered stack, the ids are already absent from history
// and remain eligible on the following launch.
function recoverInterruptedAttentionPrefs(input = {}) {
const prefs = input && typeof input === "object" && !Array.isArray(input) ? input : {};
const activeAttentionIds = [...new Set(
(Array.isArray(prefs.activeAttentionIds) ? prefs.activeAttentionIds : [])
.map((value) => String(value || ""))
.filter(Boolean),
)];
const active = new Set(activeAttentionIds);
const presentedRelayIds = [...new Set(
(Array.isArray(prefs.presentedRelayIds) ? prefs.presentedRelayIds : [])
.map((value) => String(value || ""))
.filter(Boolean),
)].filter((id) => !active.has(id));
return {
prefs: { ...prefs, presentedRelayIds, activeAttentionIds: [] },
interruptedAttentionIds: activeAttentionIds,
};
}
// ---- adaptive poll cadences (the anti-spawn-storm rules) -------------------
// Host detection spawns real processes (lsappinfo/ps on macOS, tasklist on
// Windows — expensive there and AV-scanned). Poll fast only while the user is
// plausibly about to click the pill; idle machines get a slow heartbeat, and a
// HIDDEN pill (dismissed or not on screen) backs off further still — host
// freshness only matters again at the show edge, which takes its own reading.
// Windows idles slower still because its per-spawn cost dwarfs macOS's.
function hostPollDelayMs({ testMode = false, engaged = false, visible = true, platform = process.platform } = {}) {
if (testMode) return 1500;
if (engaged) return platform === "win32" ? 3000 : 1500;
if (!visible) return platform === "win32" ? 45000 : 30000;
return platform === "win32" ? 20000 : 10000;
}
// The Sent list re-fetch pulls full bodies for up to 200 relays; at the old
// fixed 5s that is ~17k requests/day per idle machine. Fast only while the
// user is engaged with the pill or a notification is on stage.
function sentRefreshDelayMs({ testMode = false, engaged = false, showActive = false } = {}) {
if (testMode) return 5000;
return engaged || showActive ? 5000 : 30000;
}
// The tray's "Quit Relay": one detached shell that stops BOTH background
// services. Order matters — the daemon first, the pill's own service LAST,
// because the second bootout kills the process that spawned the shell.
// launchctl bootout (not kickstart/stop) is the verb that beats KeepAlive;
// the jobs return at next login (RunAtLoad) or via `relay pill`.
function quitRelayCommand({ platform = process.platform, uid = 501 } = {}) {
if (platform === "win32") {
return [
"cmd",
["/c", 'schtasks /End /TN "Relay Companion Daemon" & schtasks /End /TN "Relay Companion Pill"'],
];
}
if (platform === "linux") {
return [
"/bin/sh",
["-c", "systemctl --user stop work.relay.companion.service work.relay.companion.pill.service"],
];
}
return [
"/bin/sh",
[
"-c",
`/bin/launchctl bootout gui/${uid}/work.relay.companion 2>/dev/null; ` +
`/bin/launchctl bootout gui/${uid}/work.relay.companion.pill 2>/dev/null`,
],
];
}
module.exports = {
quitRelayCommand,
hostPollDelayMs,
sentRefreshDelayMs,
overlayWanted,
createHostRunningTracker,
shouldIgnoreDismiss,
boundedPresentedRelayIds,
recoverInterruptedAttentionPrefs,
};