relay-companion
Version:
Install Relay for Claude Code, Cowork, and Codex, then sign in from the Relay pill.
983 lines (947 loc) • 42.4 kB
JavaScript
import { randomUUID } from "node:crypto";
import { readFileSync } from "node:fs";
import path from "node:path";
import { RelayClient } from "./client.js";
import {
ensureRuntimeSession,
freshMessages,
hasActiveTurns,
markMessagesProcessed,
orderTaskMessages,
readTaskLedger,
writeTaskLedger,
} from "./runtime.js";
import {
buildHumanNotifications,
defaultStageRelayCompanionItem,
freshNotifications,
markNotificationsProcessed,
markCompanionItemsReadThrough,
removeSuppressedCompanionItems,
stagePlainRelayItem as defaultStagePlainRelayItem,
updateStagedRelayAttachments,
} from "./notifications.js";
import { companionPackageRoot, createAutoUpdater, currentCompanionVersion } from "./auto-update.js";
import { startLogRotation } from "./log-rotate.js";
import { startDesktopStartupMigration } from "./desktop-migration.js";
import { ensureWindowsAutostartTasks, repairAgentMcpRegistrations } from "./install.js";
import { apiUrl, readConfig } from "./config.js";
import { activeSessionOperationCount, runSessionDirectoryOnce } from "./session-controller.js";
import { createE2eeClaudeRuntimeController } from "./e2ee-claude-runtime.js";
import { productFeatures } from "./product-features.js";
import { migratePersistedContentFields } from "./content-field-migration.js";
import agentRelayContext from "./agent-relay-context.cjs";
import { storeDir } from "./host-paths.js";
import { readCanonicalRuntime } from "./canonical-runtime.js";
import { autostartWillReplace } from "./autostart-registration.js";
import { startRelayCodexProjectRepairLoop } from "./codex-project-repair.js";
const { recordAgentRelayIndex } = agentRelayContext;
function idempotencyKey(prefix) {
return `${prefix}_${randomUUID()}`;
}
export function messagesForSession(session, messages) {
// A human_message is scoped to sender + recipients, so it also lands in the
// sender's own agent inbox — don't echo their own words back into their run.
return messages.filter(
(message) =>
message.taskId === session.taskId &&
!(message.kind === "human_message" && message.senderUserId && message.senderUserId === session.ownerUserId),
);
}
function markProcessedOnce(ledger, processed, messages) {
const unseen = [];
for (const message of messages) {
if (processed.has(message.id)) continue;
processed.add(message.id);
unseen.push(message);
}
if (unseen.length) markMessagesProcessed(ledger, unseen);
return unseen;
}
function freshTaskEvents(ledger, events) {
ledger.taskEvents ||= {};
return events.filter((event) => {
const seen = ledger.taskEvents[event.id];
return !seen || seen.sequence !== event.sequence;
});
}
function markTaskEventsProcessed(ledger, events) {
ledger.taskEvents ||= {};
const processedAt = new Date().toISOString();
for (const event of events) {
ledger.taskEvents[event.id] = {
taskId: event.taskId,
sequence: event.sequence,
type: event.type,
processedAt,
};
}
}
function freshPlainRelays(ledger, items) {
ledger.plainRelays ||= {};
return items.filter((item) => {
const seen = ledger.plainRelays[item.relayId];
return (
!seen ||
seen.updatedAt !== item.updatedAt ||
seen.state !== item.state ||
(seen.taskStartedAt || "") !== (item.taskStartedAt || "") ||
(seen.taskCompletedAt || "") !== (item.taskCompletedAt || "") ||
(seen.taskRunOwner || "null") !== JSON.stringify(item.taskRunOwner || null) ||
(seen.taskClaim || "null") !== JSON.stringify(item.taskClaim || null) ||
(seen.restoredAt || "") !== (item.restoredAt || "")
);
});
}
function markPlainRelaysProcessed(ledger, items) {
ledger.plainRelays ||= {};
const processedAt = new Date().toISOString();
for (const item of items) {
ledger.plainRelays[item.relayId] = {
state: item.state,
updatedAt: item.updatedAt || item.createdAt || "",
// Task lifecycle receipts are projected independently of the Relay row.
// Starting or completing a Task deliberately does not edit the original
// Relay's updatedAt, so these fields must participate in the daemon's
// generation key or the pill keeps rendering its initially staged
// "Waiting on you" copy forever.
taskStartedAt: item.taskStartedAt || "",
taskCompletedAt: item.taskCompletedAt || "",
taskRunOwner: JSON.stringify(item.taskRunOwner || null),
taskClaim: JSON.stringify(item.taskClaim || null),
restoredAt: item.restoredAt || "",
processedAt,
};
}
}
// Terminal tasks never grow new events, so polling them is pure N+1 overhead —
// on long-lived accounts this was most of the daemon's request volume every cycle.
const LIVE_TASK_STATES = new Set(["draft", "resolving", "inviting", "active", "completing"]);
async function pollVisibleTaskEvents({ client, ledger, tasks, log }) {
if (typeof client.taskEvents !== "function") return [];
const visibleEvents = [];
const liveTasks = (tasks.tasks || []).filter((task) => LIVE_TASK_STATES.has(task.state));
for (const task of liveTasks) {
try {
const result = await client.taskEvents(task.id);
// The daemon posts its own daemon.heartbeat events; observing + logging +
// ledgering them back is pure self-noise. Ignore them entirely.
const relevant = (result.events || []).filter((event) => event.type !== "daemon.heartbeat");
const fresh = freshTaskEvents(ledger, relevant);
if (fresh.length) {
markTaskEventsProcessed(ledger, fresh);
for (const event of fresh) {
log(`observed task event ${event.id} (${event.type}) for task ${task.id}`);
}
visibleEvents.push(...fresh);
}
} catch (err) {
log(`task event polling failed for ${task.id}: ${err.message}`);
}
}
return visibleEvents;
}
// Content signature for the ledger write-skip below. updatedAt is stamped fresh
// on every write by design, so it is excluded — with it included no two polls
// could ever match and the skip would be dead code.
export function ledgerContentSignature(ledger) {
return JSON.stringify({ ...ledger, updatedAt: null });
}
// The ledger's dedupe maps grow forever (processedMessages, taskEvents, plainRelays,
// notifications, sessions). Left unbounded, the daemon JSON.parse+stringify+fsyncs a
// multi-megabyte file every 4s poll. Keep the newest N entries by processedAt so the
// dedupe windows stay correct for anything the API still returns, while capping size.
const LEDGER_MAP_CAP = 500;
function pruneLedgerMap(map, cap = LEDGER_MAP_CAP) {
if (!map || typeof map !== "object") return;
const entries = Object.entries(map);
if (entries.length <= cap) return;
entries.sort((a, b) => {
const ta = Date.parse(a[1]?.processedAt || a[1]?.lastDaemonEventAt || 0) || 0;
const tb = Date.parse(b[1]?.processedAt || b[1]?.lastDaemonEventAt || 0) || 0;
return tb - ta; // newest first
});
for (const [key] of entries.slice(cap)) delete map[key];
}
function pruneLedger(ledger) {
pruneLedgerMap(ledger.processedMessages);
pruneLedgerMap(ledger.taskEvents);
pruneLedgerMap(ledger.plainRelays);
pruneLedgerMap(ledger.notifications);
}
/**
* How many packets to pull per batch request. The server caps this at 100; ask
* for the same so a full backlog drains in ceil(N/100) round trips.
*/
const PACKET_BATCH_SIZE = 100;
/**
* Fetch packets for `items` in as few round trips as the server allows, falling
* back to the one-at-a-time route against a server too old to know the batch
* endpoint (a companion always outruns the API deploy on someone's machine).
* Returns a Map of relayId -> {packet, attachmentUrls}; ids the server withheld
* are simply absent, and the caller skips them.
*/
async function fetchPacketsForItems({ client, items, log }) {
const byId = new Map();
if (!items.length) return byId;
if (typeof client.fetchRelayPackets === "function") {
let batched = true;
for (let i = 0; i < items.length; i += PACKET_BATCH_SIZE) {
const slice = items.slice(i, i + PACKET_BATCH_SIZE);
try {
const res = await client.fetchRelayPackets(slice.map((item) => item.relayId));
for (const [id, value] of Object.entries((res && res.packets) || {})) byId.set(id, value);
} catch (err) {
// 404 means this API predates the batch route; anything else is a real
// failure but the per-id path is still worth trying before giving up.
log(`batch packet fetch failed (${err.message}); falling back to per-relay fetch`);
batched = false;
break;
}
}
if (batched) return byId;
byId.clear();
}
for (const item of items) {
try {
byId.set(item.relayId, await client.fetchRelay(item.relayId));
} catch (err) {
log(`ordinary Relay fetch failed for ${item.relayId}: ${err.message}`);
}
}
return byId;
}
/**
* Download a freshly staged relay's attachments NOW, while the packet's signed
* URLs are seconds old, and patch the acquired localPaths onto the staged row.
* Ingest is the one moment the URLs are guaranteed live — a lazy download on a
* click days later starts from an expired signature. Failures only log: the
* open path re-runs the same idempotent download (with a URL refresh) anyway.
*/
async function prefetchStagedRelayAttachments({ item, packet, attachmentUrls, log }) {
const attachments = Array.isArray(packet?.attachments) ? packet.attachments : [];
if (!attachments.length) return;
try {
const { materializeAttachmentFiles } = await import("./materializer.js");
const materialized = await materializeAttachmentFiles(
{ id: item.relayId, attachments, attachmentUrls: attachmentUrls || {} },
{ log },
);
const withLocal = (materialized.attachments || []).filter((a) => a && a.localPath).length;
if (withLocal && updateStagedRelayAttachments(item.relayId, materialized.attachments)) {
log(`prefetched ${withLocal}/${attachments.length} attachment(s) for ${item.relayId}`);
}
} catch (err) {
log(`attachment prefetch failed for ${item.relayId}: ${err && err.message ? err.message : err}`);
}
}
async function pollPlainInbox({
client,
ledger,
stagePlainRelay,
log,
persistLedger = () => {},
agentContextHome = storeDir(),
agentContextScope = client?.token || "",
}) {
if (typeof client.inbox !== "function" || typeof client.fetchRelay !== "function") return [];
try {
// Summary projection: identity and change-detection fields only. The bodies
// arrive with the packets below, for the few relays that are actually new.
const inbox = await client.inbox({ summary: true });
// Reconcile cross-surface changes for ordinary accounts, which never call
// the task-scoped endpoints that used to be the only inboxState source. A relay
// deleted on the web (or read-all'd from another device) must leave this pill
// too, not linger as a permanent unread ghost.
if (inbox.inboxState) {
const removed = removeSuppressedCompanionItems(inbox.inboxState.suppressedItemIds || []);
if (removed) log(`removed ${removed} Relay item(s) moved to Recently Deleted elsewhere`);
const markedRead = markCompanionItemsReadThrough(inbox.inboxState.readAllAt);
if (markedRead) log(`marked ${markedRead} Relay item(s) read from another surface`);
}
const fresh = freshPlainRelays(ledger, inbox.items || []);
const packets = await fetchPacketsForItems({ client, items: fresh, log });
const staged = [];
for (const item of fresh) {
const relay = packets.get(item.relayId);
// Withheld or failed: leave it out of the ledger so the next poll retries.
if (!relay || !relay.packet) continue;
try {
const seen = ledger.plainRelays[item.relayId];
stagePlainRelay(
{ item, packet: relay.packet, attachmentUrls: relay.attachmentUrls || {} },
{ forceUnread: Boolean(item.restoredAt && seen?.restoredAt !== item.restoredAt) },
);
staged.push(item);
log(`staged ordinary Relay from ${item.sender?.name || "someone"}: ${item.title || item.relayId}`);
// Commit progress as it happens. The ledger used to be written only
// after the whole pass, so a restart mid-pass (auto-update restarts are
// routine) threw away every relay staged so far and the next pass began
// again from zero — the re-stage storm that made new relays wait behind
// a backlog they had already been through.
markPlainRelaysProcessed(ledger, [item]);
persistLedger();
} catch (err) {
log(`ordinary Relay staging failed for ${item.relayId}: ${err.message}`);
}
}
// Reuse the summary response already fetched for human delivery. The hook
// index contains metadata plus, for untitled typed texts, the text itself
// (an untitled relay has no other content); it is account-scoped, and its
// writer skips byte-identical snapshots, so this adds no network/DB poll
// and no four-second idle disk write.
try {
recordAgentRelayIndex(agentContextHome, agentContextScope, inbox);
} catch (err) {
log(`recent Relay context persistence failed: ${err.message}`);
}
// Prefetch attachments only after every stage + ledger commit has landed,
// so a slow download can never delay the relays queued behind it.
for (const item of staged) {
const relay = packets.get(item.relayId);
if (!relay || !relay.packet) continue;
await prefetchStagedRelayAttachments({
item,
packet: relay.packet,
attachmentUrls: relay.attachmentUrls || {},
log,
});
}
staged.inboxOk = true;
return staged;
} catch (err) {
log(`ordinary Relay inbox polling failed: ${err.message}`);
const failed = [];
// Carried on the array so both callers keep their existing shape. A wedged
// client (see SELF_HEAL_FAILURE_STREAK) can only be recognised by whether
// the inbox call itself succeeded, not by how much it staged.
failed.inboxOk = false;
return failed;
}
}
async function pollHumanNotifications({
client,
ledger,
stageCompanionItem,
stagePlainRelay,
log,
persistLedger = () => {},
}) {
const ordinaryRelays = await pollPlainInbox({ client, ledger, stagePlainRelay, log, persistLedger });
try {
const [me, tasks, relays, connectors] = await Promise.all([
client.me(),
client.listTasks(),
client.listRelays(),
client.listConnectors(),
]);
const events = await pollVisibleTaskEvents({ client, ledger, tasks, log });
const removed = removeSuppressedCompanionItems(relays.inboxState?.suppressedItemIds || []);
if (removed) log(`removed ${removed} Relay item(s) moved to Recently Deleted elsewhere`);
const markedRead = markCompanionItemsReadThrough(relays.inboxState?.readAllAt);
if (markedRead) log(`marked ${markedRead} Relay item(s) read from another surface`);
const notifications = buildHumanNotifications({
user: me.user,
tasks: tasks.tasks || [],
relays: relays.relays || [],
connectors: connectors.connectors || [],
inboxState: relays.inboxState || {},
});
const fresh = freshNotifications(ledger, notifications);
const staged = [];
for (const notification of fresh) {
try {
stageCompanionItem(notification);
staged.push(notification);
log(`staged Relay companion item ${notification.kind}: ${notification.title}`);
} catch (err) {
log(`Relay companion staging failed for ${notification.kind}: ${err.message}`);
}
}
if (staged.length) markNotificationsProcessed(ledger, staged);
return { notifications: staged, ordinaryRelays, events };
} catch (err) {
log(`Relay companion attention polling failed: ${err.message}`);
return { notifications: [], ordinaryRelays, events: [] };
}
}
/**
* Poll only the ordinary Relay inbox. This is the complete receive loop used by
* ordinary accounts: it deliberately has no task, task-session,
* connector, or task-notification client calls.
*/
export async function pollOrdinaryRelayOnce({
client = new RelayClient(),
log = () => {},
stagePlainRelay = defaultStagePlainRelayItem,
agentContextHome = storeDir(),
agentContextScope = client?.token || "",
} = {}) {
const ledger = readTaskLedger();
// Write-skip (2026-08-05 always-on-cost audit): an idle account rewrote an
// identical ~150KB ledger every 4s poll, forever. Only touch the disk when a
// poll actually changed the dedupe state.
const ledgerBaseline = ledgerContentSignature(ledger);
// Staging commits each relay as it lands (see pollPlainInbox). An idle poll
// stages nothing and so still writes nothing.
const persistLedger = () => {
pruneLedger(ledger);
writeTaskLedger(ledger);
};
const ordinaryRelays = await pollPlainInbox({
client,
ledger,
stagePlainRelay,
log,
persistLedger,
agentContextHome,
agentContextScope,
});
pruneLedger(ledger);
if (ledgerContentSignature(ledger) !== ledgerBaseline) writeTaskLedger(ledger);
return { ordinaryRelays, inboxOk: ordinaryRelays.inboxOk !== false };
}
export async function pollTaskRuntimeOnce({
client = new RelayClient(),
log = () => {},
stageCompanionItem = defaultStageRelayCompanionItem,
stagePlainRelay = defaultStagePlainRelayItem,
adapters,
} = {}) {
const ledger = readTaskLedger();
const ledgerBaseline = ledgerContentSignature(ledger); // see pollOrdinaryRelayOnce
let inbox = { messages: [], sessions: [] };
try {
inbox = await client.agentInbox();
} catch (err) {
log(`task agent inbox polling failed: ${err.message}`);
}
const fresh = freshMessages(ledger, inbox.messages || []);
const processedMessageIds = new Set();
const processedMessages = [];
const touched = [];
for (const session of inbox.sessions || []) {
let claimedSession = session;
try {
const claimState = session.state === "queued" || session.state === "idle" || session.state === "stale"
? "starting"
: session.state;
const claimed = await client.heartbeatSession(session.id, {
state: claimState,
sessionRef: session.sessionRef || {},
lastError: null,
idempotencyKey: idempotencyKey("claim"),
});
claimedSession = claimed.session || session;
} catch (err) {
if (err.status === 409 || err.body?.error === "version_conflict") {
log(`session ${session.id} is leased by another companion; skipping`);
continue;
}
log(`claim failed for ${session.id}: ${err.message}`);
continue;
}
const sessionMessages = orderTaskMessages(messagesForSession(claimedSession, fresh));
let runtime;
try {
runtime = await ensureRuntimeSession({ session: claimedSession, messages: sessionMessages, ledger, adapters });
} catch (err) {
log(`runtime failed for ${session.id}: ${err.message}`);
continue;
}
// Only mark messages processed once they were actually delivered into a live
// host turn. A degraded fallback (no host installed → state "stale", or a
// fallback sessionRef) did NOT deliver them; leaving them fresh means they
// redeliver the moment the host is available, instead of the agent waiting
// forever for an answer the human already gave.
const delivered =
runtime.state !== "stale" && runtime.sessionRef?.mode !== "degraded_fallback";
if (delivered) {
processedMessages.push(...markProcessedOnce(ledger, processedMessageIds, sessionMessages));
} else if (sessionMessages.length) {
log(`host unavailable for ${session.id}; ${sessionMessages.length} message(s) left for redelivery`);
}
touched.push(runtime);
try {
await client.heartbeatSession(session.id, {
state: runtime.state,
sessionRef: runtime.sessionRef,
lastError: runtime.state === "stale" ? runtime.sessionRef?.reason || "host_unavailable" : null,
idempotencyKey: idempotencyKey("heartbeat"),
});
// Only emit a task_events row on a real state change (or every ~5 min as a
// liveness ping). Posting one per session per 4s poll flooded the DB, the
// ledger, and the unrotated daemon log, and crowded real events out of the
// API's 200-row window. The heartbeatSession call above still renews the
// lease every poll — a lease renewal needs no event row.
ledger.sessions[session.id] ||= {};
const bookkeeping = ledger.sessions[session.id];
const nowMs = Date.now();
const stateChanged = bookkeeping.lastPostedState !== runtime.state;
const stale = !bookkeeping.lastDaemonEventAt || nowMs - bookkeeping.lastDaemonEventAt > 5 * 60 * 1000;
if (stateChanged || stale) {
await client.postDaemonEvent(session.taskId, {
sessionId: session.id,
type: "daemon.heartbeat",
body: `Relay companion heartbeat for ${runtime.host}.`,
payload: { runtimeState: runtime.state, sessionRef: runtime.sessionRef },
idempotencyKey: idempotencyKey("daemon_event"),
});
bookkeeping.lastPostedState = runtime.state;
bookkeeping.lastDaemonEventAt = nowMs;
}
} catch (err) {
log(`heartbeat failed for ${session.id}: ${err.message}`);
}
}
if (processedMessages.length) {
for (const message of processedMessages) {
log(`received task message ${message.id} (${message.kind}) for task ${message.taskId}`);
}
}
const humanPolling = await pollHumanNotifications({
client,
ledger,
stageCompanionItem,
stagePlainRelay,
log,
persistLedger: () => {
pruneLedger(ledger);
writeTaskLedger(ledger);
},
});
pruneLedger(ledger);
if (ledgerContentSignature(ledger) !== ledgerBaseline) writeTaskLedger(ledger);
return {
sessions: touched,
messages: processedMessages,
notifications: humanPolling.notifications,
ordinaryRelays: humanPolling.ordinaryRelays,
events: humanPolling.events,
inboxOk: humanPolling.ordinaryRelays.inboxOk !== false,
};
}
// Run updater maintenance independently from API authentication and task polling.
// This matters at login/offline recovery: client.me() or a Task fetch can be slow or
// repeatedly fail, but a newly published companion may contain the very fix needed to
// recover. createAutoUpdater handles the real one-minute registry rate limit; this
// lightweight loop only lets pending/busy/retry state react within a few seconds.
/**
* Once an update has been launched, THIS process is the one being replaced and must
* not outlive its replacement. On Windows the updater cannot reliably kill it: the
* script restarts services with `schtasks /End`, which only ends processes in the
* task's own tree, and a daemon started through the WMI escape hatch is not in that
* tree. Field report (Shane, 2026-08-17): the old daemon survived as an orphan
* holding daemon.log open, so the /Run'd replacement's `>> daemon.log` redirect
* failed and its cmd wrapper exited 1 before node started (update.log still said
* "daemon restarted via task"); the orphan then re-fired the same update from its
* stale in-memory version and two updaters raced the tree into an unbootable state.
*
* Exit as soon as the replacement is observably committed. A hard ceiling ends
* the observation window but MUST NOT exit without that evidence: Windows logon
* tasks are not KeepAlive supervisors, so an install failure followed by a blind
* exit leaves the machine with a pill but no polling daemon until the next login.
* Returns the timer so callers/tests can cancel it; `exitImpl` is injectable.
*/
export const SELF_UPDATE_EXIT_POLL_MS = 2000;
export const SELF_UPDATE_EXIT_CEILING_MS = 5 * 60 * 1000;
export const CANONICAL_UPDATE_EXIT_CEILING_MS = 22 * 60 * 1000;
export function scheduleSelfUpdateExit({
runningVersion,
packageRoot,
log = () => {},
exitImpl = (code) => process.exit(code),
readVersion = defaultReadPackageVersion,
pollMs = SELF_UPDATE_EXIT_POLL_MS,
ceilingMs = SELF_UPDATE_EXIT_CEILING_MS,
setIntervalImpl = setInterval,
clearIntervalImpl = clearInterval,
now = () => Date.now(),
replacementReady = null,
onCeiling = null,
} = {}) {
const startedAt = now();
let done = false;
const finish = (reason) => {
if (done) return;
done = true;
clearIntervalImpl(timer);
log(`self-update: ${reason}; exiting so the replacement daemon can start`);
exitImpl(0);
};
const expire = () => {
if (done) return;
done = true;
clearIntervalImpl(timer);
log(
`self-update: updater grace period of ${Math.round(ceilingMs / 1000)}s elapsed ` +
"without a verified replacement; keeping the current daemon alive and resuming checks",
);
try { onCeiling?.(); } catch (error) {
log(`self-update: could not resume checks after updater timeout: ${error?.message || error}`);
}
};
const timer = setIntervalImpl(() => {
if (typeof replacementReady === "function" && replacementReady()) {
finish("canonical runtime pointer selected a replacement tree");
return;
}
const onDisk = readVersion(packageRoot);
if (onDisk && runningVersion && onDisk !== runningVersion) {
finish(`new tree ${onDisk} is on disk (was running ${runningVersion})`);
} else if (now() - startedAt >= ceilingMs) {
expire();
}
}, pollMs);
if (timer && typeof timer.unref === "function") timer.unref();
return { timer, cancel: () => { done = true; clearIntervalImpl(timer); } };
}
function defaultReadPackageVersion(packageRoot) {
try {
return JSON.parse(readFileSync(path.join(packageRoot, "package.json"), "utf8")).version || null;
} catch {
return null;
}
}
export function startAutoUpdateLoop({
autoUpdater,
intervalMs = 5000,
log = () => {},
setIntervalImpl = setInterval,
clearIntervalImpl = clearInterval,
onUpdateLaunched = null,
} = {}) {
if (!autoUpdater || typeof autoUpdater.tick !== "function") throw new Error("autoUpdater.tick is required");
let exitScheduled = false;
let replacing = false;
const resumeChecks = () => {
replacing = false;
exitScheduled = false;
};
const run = () =>
replacing
? Promise.resolve({ status: "replacement-pending" })
: Promise.resolve()
.then(() => autoUpdater.tick())
.then((update) => {
if (["updating", "stale-process", "migrating-runtime", "recovering-runtime", "rescuing-runtime", "repairing-runtime"].includes(update.status)) {
// The external updater owns the attempt from this point. Quiesce THIS
// process immediately so it cannot re-fire from stale version/channel
// state while scheduleSelfUpdateExit gives the installer time to finish.
replacing = true;
if (update.status === "updating") {
log(`self-update launched (${update.current} -> ${update.latest}); restarting shortly`);
} else if (update.status === "migrating-runtime") {
log(`canonical runtime migration launched for ${update.current}`);
} else if (update.status === "recovering-runtime") {
log(`canonical runtime recovery launched for ${update.current}`);
} else if (update.status === "rescuing-runtime") {
log(`canonical runtime rescue launched for ${update.current}`);
} else if (update.status === "repairing-runtime") {
log(`canonical runtime repair launched for ${update.current}`);
} else {
log(`self-update: this daemon runs ${update.current} but disk is ${update.onDisk}; stepping aside`);
}
// Arm the self-exit exactly once per launch; a re-tick while the updater is
// still installing must not re-arm it (or worse, exit early on the ceiling).
if (!exitScheduled && typeof onUpdateLaunched === "function") {
exitScheduled = true;
try {
onUpdateLaunched(update, { resumeChecks });
} catch (err) {
resumeChecks();
log(`self-update: could not arm self-exit: ${err && err.message ? err.message : err}`);
}
}
} else if (update.status === "launch-failed") {
log(`self-update launch failed for ${update.current} -> ${update.latest}`);
}
return update;
})
.catch((err) => {
// eslint-disable-next-line no-console
console.error(`[relay] auto-update check error: ${err && err.message ? err.message : err}`);
return { status: "loop-error", error: err };
});
void run(); // check immediately, including while startup authentication is offline
const timer = setIntervalImpl(() => void run(), intervalMs);
if (timer && typeof timer.unref === "function") timer.unref();
return { run, timer, stop: () => clearIntervalImpl(timer) };
}
/**
* Consecutive failed inbox polls before the receiver rebuilds itself. Each cycle
* is the poll interval plus up to the client's 15s request timeout, so 60 is
* roughly 5-20 minutes of continuous failure — long enough that ordinary
* sleep/suspend and transient network loss never trip it.
*/
export const SELF_HEAL_FAILURE_STREAK = 60;
// The session controller — publish this machine's session directory, then
// claim and execute remote session operations — is the substrate under
// relay_ai_sessions / relay_ai_session. On the product row it shares with
// Tasks (prod v1: send · receive · open — Sven, 2026-08-17) it is off
// for ordinary accounts: nothing is uploaded, nothing is listened for, the
// receiver loop is all that runs. A tick never throws either way: session
// directory failure must never stop ordinary Relay delivery or a developer's
// Task runtime while a new API release rolls through.
export async function sessionControllerTick({ client, log, features, run = runSessionDirectoryOnce } = {}) {
if (features && features.aiSessions === false) return { ran: false };
try {
await run({ client, log });
return { ran: true };
} catch (err) {
log(`session directory unavailable: ${err.message}`);
return { ran: true, error: err };
}
}
// The server-issued account role controls whether this loop asks for Task
// work. Ordinary delivery remains active for every account.
export async function daemonDeliveryTick({
client,
log = () => {},
features,
taskPoll = pollTaskRuntimeOnce,
ordinaryPoll = pollOrdinaryRelayOnce,
} = {}) {
if (features && features.requests === false) {
const result = await ordinaryPoll({ client, log });
return {
ordinaryOnly: true,
sessions: [],
messages: [],
notifications: [],
events: [],
...result,
};
}
return { ordinaryOnly: false, ...(await taskPoll({ client, log })) };
}
function daemonProductFeatures(log, user) {
const features = productFeatures({ env: process.env, config: readConfig(), apiUrl: apiUrl(), user });
if (!features.aiSessions) log("developer session features off for this account: no session directory upload, no remote session operations");
if (!features.requests) log("Task runtime off for this account: ordinary Relay delivery only");
return features;
}
const ACCOUNT_FEATURE_REFRESH_MS = 5 * 60 * 1000;
async function refreshDaemonProductFeatures(client, current, log) {
try {
const me = await client.me();
return daemonProductFeatures(log, me.user);
} catch (error) {
log(`developer profile refresh failed: ${error?.message || error}`);
return current;
}
}
// Boot resilience: the daemon starts at login, often BEFORE the network is up.
// Crashing on the first me() put launchd into a restart loop; retry instead.
// Each retry also re-reads the account: a daemon that booted unpaired, or was
// signed out underneath, must pick up the credential a later pairing writes
// instead of 401-ing forever on the token it started with.
async function resolveMe(client) {
for (let attempt = 1; ; attempt += 1) {
try {
return await client.me();
} catch (err) {
const wait = Math.min(60_000, 2_000 * attempt);
// eslint-disable-next-line no-console
console.error(`[relay] startup me() failed (${err && err.message}); retrying in ${Math.round(wait / 1000)}s`);
await new Promise((resolve) => setTimeout(resolve, wait));
if (client.accountDrift().status !== "same") client.rebindToCurrentAccount();
}
}
}
/**
* Follow config.json's account, in place, on every poll. Pairing, the pill's
* Switch Account, and Sign Out all rewrite the file while this process runs;
* each of them also asks the OS to restart the daemon, and on Windows that
* request has failed in the field (`schtasks /End` kills the launcher wrapper,
* not node), leaving a daemon that polls with the previous token and stages
* the previous person's Relays into the store the pill just wiped for the new
* one. Rebinding here is the repair that does not depend on any supervisor —
* and it is what a restart would have achieved. Exiting instead is not an
* option: the Windows task has no restart-on-failure and the process is
* detached from it, so an exited daemon stays exited until next logon.
*
* A sign-out parks in resolveMe's retry loop exactly as an unpaired boot does,
* until a credential appears.
*/
async function followAccountDrift({ client, log, role }) {
const drift = client.accountDrift();
if (drift.status === "same") return null;
const was = drift.bound.email || drift.bound.userId || "(unpaired)";
const now = drift.current.email || drift.current.userId || "(signed out)";
log(`account ${drift.status} on this computer (${was} -> ${now}); rebinding the ${role}`);
client.rebindToCurrentAccount();
const next = await resolveMe(client);
log(`${role} for ${next.user.email} (rebound without a restart)`);
return next;
}
export async function runTaskDaemon({ intervalMs = 4000 } = {}) {
// Last-resort safety net: the daemon is always-on and launchd-restarted, but a
// restart re-delivers in-flight work and can duplicate agent turns. Keep it alive
// through any stray async error rather than crash-looping.
process.on("uncaughtException", (err) => {
// eslint-disable-next-line no-console
console.error(`[relay] uncaught exception (continuing): ${err && err.stack ? err.stack : err}`);
});
process.on("unhandledRejection", (reason) => {
// eslint-disable-next-line no-console
console.error(`[relay] unhandled rejection (continuing): ${reason && reason.stack ? reason.stack : reason}`);
});
// Start update maintenance BEFORE any network/authentication dependency. It has its
// own registry retry/backoff and remains independent from the task-poll loop.
const log = (m) => console.log(`[relay] ${new Date().toISOString()} ${m}`);
migratePersistedContentFields({ log });
// Repair Relay.app + launchd surfaces independently of npm postinstall. This is a
// bounded, per-version migration; it reloads only the pill and never unloads this
// currently running daemon.
try { repairAgentMcpRegistrations(); } catch (error) {
log(`MCP launcher repair failed: ${error?.message || error}`);
}
startDesktopStartupMigration({ log });
const autoUpdater = createAutoUpdater({
log,
hasActiveWork: () => hasActiveTurns() || activeSessionOperationCount() > 0,
});
// Exit once the replacement tree is on disk.
const bootPackageRoot = companionPackageRoot();
const bootVersion = currentCompanionVersion(bootPackageRoot);
startAutoUpdateLoop({
autoUpdater,
log,
onUpdateLaunched: (_update, { resumeChecks }) => {
let warnedNoReplacement = false;
return scheduleSelfUpdateExit({
runningVersion: bootVersion,
packageRoot: bootPackageRoot,
log,
ceilingMs: CANONICAL_UPDATE_EXIT_CEILING_MS,
replacementReady: () => {
const current = readCanonicalRuntime();
// "The pointer names a tree that is not mine" is the SAME fact that
// condemned this daemon — auto-update derives `stale-process` from
// exactly it — so on its own it cannot also be the evidence that a
// replacement will start; the check would be verifying its own
// precondition and the exit would be unconditional. The autostart
// registration is the independent witness: it, not the pointer,
// decides which tree the supervisor actually restarts.
if (!current || path.resolve(current.packageRoot) === path.resolve(bootPackageRoot)) return false;
const verdict = autostartWillReplace(bootPackageRoot);
if (!verdict.willReplace && !warnedNoReplacement) {
warnedNoReplacement = true;
log(
`self-update: staying alive — the canonical pointer names ${current.version || "another tree"} but ` +
`${verdict.reason}${verdict.registration ? ` (${verdict.registration.source})` : ""}; ` +
"exiting now would only restart this same tree",
);
}
return verdict.willReplace;
},
onCeiling: resumeChecks,
});
},
});
// Keep the always-on logs bounded. The daemon is the single long-lived owner on
// the machine, so it is the natural place to cap files that nothing else prunes.
startLogRotation({ log });
// A previous Companion release gave unanchored Relay tasks the correct
// ~/Relay cwd but never registered/assigned the matching Codex sidebar
// project. Repair every remembered Codex task in the background, retrying
// until Codex has a window available. This never launches or focuses Codex;
// future opens also perform the assignment directly.
startRelayCodexProjectRepairLoop({ log });
// Age out downloaded attachment copies; ingest prefetch would otherwise grow
// ~/.relay-companion/attachments with the inbox forever.
try {
const { sweepStaleAttachmentFiles } = await import("./materializer.js");
sweepStaleAttachmentFiles({ log });
} catch (err) {
log(`attachment sweep failed: ${err && err.message ? err.message : err}`);
}
// Re-register Windows autostart tasks that have gone missing. Without them the
// machine has no logon start at all, and (before the updater stopped depending on
// them) every self-update rolled itself back. Cheap query, no-ops off win32.
try {
ensureWindowsAutostartTasks({ log });
} catch (err) {
log(`autostart repair check failed: ${err && err.message ? err.message : err}`);
}
let client = new RelayClient();
const me = await resolveMe(client);
// eslint-disable-next-line no-console
console.log(`[relay] receiver for ${me.user.email}; polling every ${intervalMs}ms`);
let features = daemonProductFeatures(log, me.user);
let claudeRuntime = createE2eeClaudeRuntimeController({ client, logger: log });
void claudeRuntime.tick();
let featureRefreshAt = Date.now() + ACCOUNT_FEATURE_REFRESH_MS;
let consecutiveFailures = 0;
for (;;) {
const rebound = await followAccountDrift({ client, log, role: "receiver" });
if (rebound) {
await claudeRuntime.stop();
claudeRuntime = createE2eeClaudeRuntimeController({ client, logger: log });
features = daemonProductFeatures(log, rebound.user);
featureRefreshAt = Date.now() + ACCOUNT_FEATURE_REFRESH_MS;
} else if (Date.now() >= featureRefreshAt) {
features = await refreshDaemonProductFeatures(client, features, log);
featureRefreshAt = Date.now() + ACCOUNT_FEATURE_REFRESH_MS;
}
void claudeRuntime.tick();
await sessionControllerTick({ client, log, features });
try {
const result = await daemonDeliveryTick({ client, log, features });
if (result.ordinaryOnly) {
if (result.ordinaryRelays.length) log(`processed ${result.ordinaryRelays.length} ordinary relay(s)`);
} else if (
result.sessions.length ||
result.messages.length ||
result.notifications.length ||
result.ordinaryRelays.length ||
result.events.length
) {
log(
`processed ${result.sessions.length} session(s), ${result.messages.length} message(s), ${result.notifications.length} task attention item(s), ${result.ordinaryRelays.length} ordinary relay(s), ${result.events.length} event(s)`,
);
}
if (result.inboxOk) {
if (consecutiveFailures >= SELF_HEAL_FAILURE_STREAK / 2) {
log(`inbox polling recovered after ${consecutiveFailures} consecutive failure(s)`);
}
consecutiveFailures = 0;
} else {
consecutiveFailures += 1;
}
} catch (err) {
consecutiveFailures += 1;
// eslint-disable-next-line no-console
console.error(`[relay] task daemon error: ${err.message}`);
}
if (consecutiveFailures >= SELF_HEAL_FAILURE_STREAK) {
// A long-lived process can keep rendering stale local state while every
// API call fails. Rebuild its client after a bounded streak. launchd can
// restart on macOS; Windows Scheduled Tasks cannot, so Windows repairs in
// place and waits for connectivity without giving up its logon process.
if (process.platform === "win32") {
log(`inbox polling has failed ${consecutiveFailures} times; rebuilding the Windows receiver client`);
client = new RelayClient();
const recovered = await resolveMe(client);
await claudeRuntime.stop();
claudeRuntime = createE2eeClaudeRuntimeController({ client, logger: log });
void claudeRuntime.tick();
features = daemonProductFeatures(log, recovered.user);
featureRefreshAt = Date.now() + ACCOUNT_FEATURE_REFRESH_MS;
consecutiveFailures = 0;
} else {
log(`inbox polling has failed ${consecutiveFailures} times; restarting the receiver`);
process.exit(1);
}
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
runTaskDaemon().catch((err) => {
// eslint-disable-next-line no-console
console.error(err);
process.exit(1);
});
}