relay-companion
Version:
Install Relay for Claude Code, Cowork, and Codex, then sign in from the Relay pill.
1,359 lines • 67.4 kB
JavaScript
#!/usr/bin/env node
import os from "node:os";
import fsSync from "node:fs";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { fileURLToPath } from "node:url";
import { createRequire } from "node:module";
import { spawnSync, spawn } from "node:child_process";
import readline from "node:readline/promises";
import { stdin, stdout } from "node:process";
import { RelayClient } from "../src/client.js";
import {
writeConfig,
readConfig,
apiUrl,
DEFAULT_API_URL,
DEFAULT_WEB_URL,
DEFAULT_DEV_API_URL,
DEFAULT_STAGING_API_URL,
UPDATE_CHANNEL_STABLE,
UPDATE_CHANNEL_DEV,
UPDATE_CHANNEL_STAGING,
updateChannel,
} from "../src/config.js";
import { runTaskDaemon, pollTaskRuntimeOnce } from "../src/task-daemon.js";
import {
resolveCliTrampoline,
runCliTrampoline,
TRAMPOLINE_ENV,
TRAMPOLINE_SHIM_ROOT_ENV,
TRAMPOLINE_SHIM_VERSION_ENV,
} from "../src/cli-trampoline.js";
import {
hookInstallNotices,
linuxDesktopPaths,
prepareLinuxElectronSandbox,
persistentNodePath,
purgeLocalState,
relayBinPath,
windowsAutostartTaskStatus,
repairDesktopSurfaces,
repairExistingAgentHooks,
repairExistingAgentRegistrations,
accountRestartLines,
desktopExecQuote,
restartRelayServices,
runSetupInstall,
runUninstall,
uninstallResultLines,
startLinuxDesktopServices,
systemdExecQuote,
windowsStartMenuShortcutMissing,
windowsStartMenuShortcutPath,
} from "../src/install.js";
import { readAutostartDaemonRoot } from "../src/autostart-registration.js";
import { companionPackageRoot, readMigrationFailure, readRecoveryFailure, runUpdateOnce } from "../src/auto-update.js";
import { uninstallManagedCompanionPackage } from "../src/uninstall-package.js";
import {
canonicalRuntimeLayout,
readCanonicalRuntimeState,
reconcileCanonicalRuntimeNode,
} from "../src/canonical-runtime.js";
import { listUpdateWorkerJobs } from "../src/canonical-updater.js";
import { pillStatusPath, readPillStatus, waitForPillReady } from "../src/pill-control.js";
import { liveToolRequirement, requiredLiveHosts, shouldRequireLiveTools } from "../src/setup-activation.js";
import { openRelay, openTask } from "../src/materializer.js";
import { runClaudeHook } from "../src/claude-hook.js";
import { runCodexHook } from "../src/codex-hook.js";
import { normalizePairingCode, persistPairedAccount } from "../src/account.js";
import { resetCompanionStateForAccount } from "../src/notifications.js";
import {
finishSetupOpenRelay,
normalizeSetupHost,
setupOpenRelayToken,
setupOpenStatus,
setupPairFlags,
} from "../src/setup-open.js";
import { migratePersistedContentFields } from "../src/content-field-migration.js";
import { accountProductFeatures } from "../src/product-features.js";
import {
importLegacyHistory,
prepareE2eeHistoryImport,
} from "../src/e2ee-history-import.js";
import e2eeIdentity from "../src/e2ee-identity.cjs";
const { createPairingIdentity, persistPairedIdentity } = e2eeIdentity;
function parseFlags(argv) {
const flags = {};
const positional = [];
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a.startsWith("--")) {
const key = a.slice(2);
const next = argv[i + 1];
if (next === undefined || next.startsWith("--")) flags[key] = true;
else {
flags[key] = next;
i++;
}
} else positional.push(a);
}
return { flags, positional };
}
function rejectRemovedCapabilityFlags(command, flags) {
if (!flags.full && !flags["messages-only"]) return;
// One release-boundary exception: an older updater invokes a newly installed
// candidate's internal repair-runtime command with one of these flags. New
// launchers never emit them, and every human-facing command rejects them.
if (command === "repair-runtime" && process.env.RELAY_SKIP_DESKTOP_POSTINSTALL === "1") return;
throw new Error(
"--full and --messages-only were removed. Relay capabilities now require the signed-in account's developer role on dev.",
);
}
async function requireTaskFeatures() {
const client = new RelayClient();
const features = await accountProductFeatures({
client,
env: process.env,
config: readConfig(),
apiUrl: apiUrl(),
timeoutMs: 15_000,
});
if (!features.requests) {
throw new Error("Requests are currently available only to Relay developer accounts on dev.");
}
return features;
}
function companionVersion() {
try {
const require = createRequire(import.meta.url);
const pkg = require("../package.json");
return typeof pkg.version === "string" ? pkg.version : "unknown";
} catch {
return "unknown";
}
}
function companionDistribution() {
try {
return String(createRequire(import.meta.url)("../package.json").relayDistribution || "");
} catch {
return "";
}
}
async function prompt(question, fallback = "") {
const rl = readline.createInterface({ input: stdin, output: stdout });
const answer = (await rl.question(question)).trim();
rl.close();
return answer || fallback;
}
async function cmdPair(flags, { promptForDefaults = true } = {}) {
let url = flags.api || process.env.RELAY_API_URL;
if (!url) {
url = promptForDefaults ? await prompt(`Relay API URL [${apiUrl()}]: `, apiUrl()) : DEFAULT_API_URL;
}
const appUrl = flags.web || process.env.RELAY_WEB_URL || DEFAULT_WEB_URL;
let code = flags.code;
if (!code) code = await prompt("Pairing code (from your Relay app): ");
let name = flags.name;
if (!name) {
name = promptForDefaults ? await prompt(`Device name [${os.hostname()}]: `, os.hostname()) : os.hostname();
}
writeConfig({ apiUrl: url, webUrl: appUrl });
const client = new RelayClient({ url });
const pairingCode = normalizePairingCode(code);
const encryptionIdentity = createPairingIdentity({ pairingCode, name, platform: process.platform });
const res = await client.registerDevice({
pairingCode,
name,
platform: process.platform,
e2eeIdentity: encryptionIdentity.request,
});
// Persist the private key before the bearer token. If the second write fails,
// the key is still recoverable; the reverse ordering could leave a registered
// public identity whose private half was permanently lost.
persistPairedIdentity(encryptionIdentity.state, res);
// The same persistence the pill's Settings tab uses (src/account.js), so the
// stored credential shape can never drift between the two pairing surfaces.
persistPairedAccount({ apiUrl: url, webUrl: appUrl, deviceName: name, registration: res });
await new RelayClient({ url }).ensureE2eeReady();
resetCompanionStateForAccount({ user: res.user, deviceId: res.deviceId });
console.log(`Paired as ${res.user.name} <${res.user.email}>. This device is now connected to Relay.`);
// A re-pair on a machine whose services are already running must reach
// them, exactly as the pill's Switch Account does; until 2026-08-18 this
// path never restarted anything, so a CLI re-pair left the daemon and pill
// on the previous account until the next logon. On a first `relay setup`
// nothing is installed yet and this is a silent no-op.
if (!flags["no-restart"]) {
const restarted = await restartRelayServices({ services: ["daemon", "pill"] });
for (const line of accountRestartLines(restarted)) console.log(line);
}
}
function printActivationStatus(activation) {
if (!activation) return;
if (activation.host === "Codex") {
if (activation.currentSessionReady) {
if (activation.readinessMode === "deferred_tool_search") {
console.log("Relay tools are available in this Codex session through Codex tool search (`relay_send`).");
return;
}
if (activation.readinessMode === "deferred_tool_search_unverified") {
console.log("Relay was added to Codex. Codex can load the Relay tools through tool search (`relay_send`).");
return;
}
console.log("Relay tools are live in this Codex session (`relay_send` verified).");
return;
}
if (activation.serverCatalogHasTool) {
console.log(
"Relay was added to Codex and the MCP server advertises relay_send, but this open Codex session has not exposed relay_send to the agent yet.",
);
return;
}
const detail = activation.reason ? ` (${activation.reason})` : "";
console.log(`Relay was added to Codex, but this open Codex session does not expose relay_send yet${detail}.`);
return;
}
if (activation.host === "Claude Code") {
if (activation.ok) {
console.log("Relay was added to Claude Code for new sessions. Existing Claude Code sessions may need their MCP tools reloaded from inside Claude.");
} else {
console.log("Relay could not verify the Claude Code MCP registration.");
}
}
}
function statusAreaReopenText() {
if (process.platform === "darwin") return "the Relay app in Spotlight or the Relay icon in the menu bar";
// Name the Start Menu first on Windows: the tray icon is usually behind the
// overflow chevron, so it is the harder of the two to find, not the easier.
if (process.platform === "win32") return "Relay in the Start Menu or the Relay icon in the system tray (behind the ^ near the clock)";
return "Relay in your app launcher or taskbar (and the system tray when your desktop provides one)";
}
/** Register the Relay tools into Claude Code + Codex and start the receive daemon. */
async function applyInstall({
requireLiveTools = false,
requiredHosts = requiredLiveHosts(),
claim = false,
reload = true,
} = {}) {
const {
installed,
missing,
daemon,
pill,
activations = [],
binStable = true,
claudeHooks = null,
codexHooks = null,
desktopRestarts = [],
sweptStaleEntries = [],
} = await runSetupInstall({ claim, reload });
const lifecycleFailed = process.platform === "linux" && (!daemon.ok || !pill?.ok);
if (installed.length) console.log(`Added Relay to ${installed.join(" and ")} on this machine.`);
if (!binStable) {
console.log(
"Heads up: Relay is running from a temporary npx cache and couldn't global-install. It may stop working when that cache is cleaned — run `npm install -g relay-companion` to make it permanent.",
);
}
for (const m of missing) {
if (!/registration failed/.test(m)) console.log(`${m} was not found here, so it was skipped.`);
else console.log(`Could not register ${m}.`);
}
for (const activation of activations) printActivationStatus(activation);
for (const notice of hookInstallNotices({ claudeHooks, codexHooks })) console.log(notice);
if (daemon.ok) console.log("Relay is running in the background and will start automatically when you log in.");
else if (daemon.reason === "autostart_unsupported_platform") {
console.log("Run `relay daemon` to receive relays because managed background startup is unavailable on this platform.");
} else {
const failure = [daemon.reason, daemon.detail].filter(Boolean).join(": ");
const detail = failure ? ` (${failure})` : "";
console.log(`Could not install Relay background autostart${detail}. Run \`relay daemon\` to receive relays.`);
}
if (pill?.ok) console.log(`The Relay pill is open and will start automatically when you log in. Reopen it from ${statusAreaReopenText()} after hiding it.`);
else if (pill?.reason === "pill_runtime_missing") {
console.log("Run `relay pill` to open the Relay pill.");
} else if (pill?.reason === "autostart_unsupported_platform") {
console.log(`Run \`relay pill\` to open the Relay pill. Reopen it from ${statusAreaReopenText()} after hiding it.`);
} else if (pill) {
const failure = [pill.reason, pill.detail].filter(Boolean).join(": ");
const detail = failure ? ` (${failure})` : "";
console.log(`Could not install Relay pill autostart${detail}. Run \`relay pill\` to open it.`);
}
// Nothing was installed because no agent lives on this machine. This is the
// single most likely outcome for someone who was sent a relay and followed the
// instructions, and it used to print the skip lines above and exit 0 -- with
// the downloadable .command wrapper then adding "Relay is set up." The person
// ends up with a paired device, a daemon and a pill, and no way to receive a
// relay in an agent, having been told it worked.
if (sweptStaleEntries.length) {
// A stale entry is not harmless: Claude Desktop validates the schema, not the
// filesystem, so it spawns and crashes on every single app launch.
console.log(`Removed a broken Relay entry that pointed at a path that no longer exists (${sweptStaleEntries.join(", ")}).`);
}
if (!installed.length) {
console.log("");
console.log(`Relay could not finish: no ${process.platform === "linux" ? "Claude Code or Codex CLI" : "Claude or Codex app"} was found on this machine.`);
console.log("Relay delivers into an AI assistant, so it needs one of these first:");
if (process.platform !== "linux") {
console.log(" Claude https://claude.com/download (desktop app)");
console.log(" ChatGPT/Codex https://chatgpt.com/download (desktop app)");
}
console.log(" Claude Code https://claude.com/claude-code (terminal)");
if (process.platform === "linux") console.log(" Codex CLI Install the Codex command-line app (terminal)");
console.log("Install one, then run this command again.");
console.log("");
console.log(`In the meantime you can read and reply to your relays on the web: ${absoluteWebTarget("/app/relays")}`);
return { installed, missing, daemon, pill, activations, agentMissing: true, lifecycleFailed };
}
if (desktopRestarts.length) {
console.log("");
// These apps read their config only at process start, so the registration is
// inert until a FULL quit. Closing the window is not enough, and saying so
// vaguely is how people conclude it did not work.
for (const app of desktopRestarts) {
console.log(`Registered with ${app}. Fully quit it (Cmd-Q on macOS, Quit from the tray on Windows) and reopen to use Relay.`);
}
}
const liveRequirement = liveToolRequirement({ activations, requiredHosts });
if (requireLiveTools && !liveRequirement.ok) {
// Registration only takes effect in a NEW agent session, so the session that
// ran this command can never report itself ready -- verifyClaudeMcpRegistration
// returns currentSessionReady:false on every path by construction. Treating
// that as a failure turned a successful install into `exit 1`, and skipped the
// open-relay step that is the entire reason the recipient ran this. Say what
// to do instead of failing.
const hostLabel = liveRequirement.missingHosts.length
? liveRequirement.missingHosts.join(" and ")
: "this agent session";
console.log("");
console.log(`Relay is installed. Restart ${hostLabel} (or open a new session) to use it.`);
}
return { installed, missing, daemon, pill, activations, lifecycleFailed };
}
/**
* First contact installs the verified desktop runtime while signed out. Account
* authorization happens in Relay's own pill/browser surface, where the person can
* see the product and consent. --code remains only for old setup links during the
* migration window; it is never required or prompted for by the new flow.
*/
async function cmdSetup(flags) {
if (flags.restart) {
if (flags.code) throw new Error("Relay setup --restart cannot be combined with the legacy --code path.");
const { createInstallationAuthorizationController } = await import("../src/installation-authorization.js");
await createInstallationAuthorizationController().restart();
console.log("Restarted Relay's one-time account setup. The app will open with a fresh approval.");
console.log("");
}
if (flags.code) {
console.log("Using Relay's legacy pairing-code migration path.");
await cmdPair(setupPairFlags(flags), { promptForDefaults: Boolean(flags.interactive) });
console.log("");
} else {
const hasSavedCredential = Boolean(readConfig().deviceToken);
let savedCredentialRejected = false;
if (hasSavedCredential) {
try {
await new RelayClient().me();
} catch (error) {
savedCredentialRejected = [401, 403].includes(Number(error?.status));
}
}
if (hasSavedCredential && !savedCredentialRejected) {
console.log("Relay has a saved sign-in on this machine. Keeping that account while installing the current app.");
} else if (savedCredentialRejected) {
console.log("Relay’s service no longer accepts this computer’s saved sign-in. Installing the app and opening account recovery.");
} else {
console.log("Installing Relay first. The Relay pill will open signed out so you can review it and sign in there.");
}
console.log("");
}
const install = await applyInstall({
requireLiveTools: Boolean(flags["require-live-tools"]) || shouldRequireLiveTools(),
requiredHosts: requiredLiveHosts(),
// An explicit setup is the migration/ownership handoff. Runtime verification
// in runSetupInstall completes before any platform's autostart is changed.
claim: true,
// The signed thin bootstrap asks setup to write all registrations without
// starting them, then performs the same exact-root lifecycle as the updater.
// Ordinary setup keeps its historical install-and-start behaviour.
reload: !(flags["no-restart"] && process.env.RELAY_BOOTSTRAP_ACTIVATED === "1"),
});
const token = setupOpenRelayToken(flags);
if (token && readConfig().deviceToken) {
console.log("");
const host = normalizeSetupHost(flags.host || flags.in);
const result = await finishSetupOpenRelay({
token,
host,
log: (m) => process.stderr.write(`[relay] ${m}\n`),
});
const openStatus = setupOpenStatus(result.opened, openUrl);
if (openStatus.opened) {
console.log(`Opened relay ${result.relayId} in ${host === "codex" ? "Codex" : "Claude Code"}.`);
} else if (openStatus.url) {
console.log(`Relay ${result.relayId} is ready. Open this URL: ${openStatus.url}`);
} else {
console.log(`Relay ${result.relayId} is staged in the Relay pill.`);
}
} else if (token) {
console.log("");
console.log("Sign in from the Relay pill first; the relay will be available in that account afterward.");
}
// The relay is opened/staged first -- the recipient should still get the thing
// they came for -- but setup did not achieve what it claims, so it must not
// report success. `exitCode` rather than `exit()` so nothing above is truncated.
if (install?.agentMissing || install?.lifecycleFailed) process.exitCode = 1;
}
/** Install the tools + daemon on a device that is already paired. */
async function cmdInstall(flags = {}) {
const install = await applyInstall({
requireLiveTools: shouldRequireLiveTools(),
requiredHosts: requiredLiveHosts(),
claim: Boolean(flags.claim),
});
// `relay setup` already propagates a Linux systemd/pill failure. The shorter
// `relay install` path must keep the same contract: printing recovery advice
// and then exiting zero makes automation (and a person skimming the last line)
// believe a machine is ready when neither background process will survive the
// terminal. Preserve the detailed messages from applyInstall, but fail the
// command so the incomplete lifecycle cannot be mistaken for success.
if (install?.lifecycleFailed) process.exitCode = 1;
}
function cmdRepairDesktop(flags = {}) {
const reload = !flags["no-restart"];
const env = process.platform === "linux"
? { ...process.env, RELAY_ALLOW_SANDBOX_AUTHORIZATION: "1" }
: process.env;
// Repair is deliberately local and non-destructive. It may rewrite Relay's
// launchers and Relay-owned hook registrations, but it never rewrites account
// config, deletes credentials/E2EE state, clears messages/outboxes/preferences,
// or calls a cloud revocation endpoint.
// The updater invokes this command from the verified candidate tree before it
// switches/restarts services. First atomically migrate any existing raw hook
// registrations to Relay's stable bridge; if that fails, do not claim the
// candidate is safe to activate.
const hookRepair = repairExistingAgentHooks();
if (!hookRepair.ok) {
throw new Error(`Could not repair Relay agent hooks (${hookRepair.reason || "migration failed"}).`);
}
const repaired = repairDesktopSurfaces({ reload, claim: Boolean(flags.claim), env });
if (!repaired.ok) {
const failures = [
!repaired.daemon?.ok && `daemon: ${repaired.daemon?.message || repaired.daemon?.reason || "install failed"}`,
!repaired.pill?.ok && `pill: ${repaired.pill?.message || repaired.pill?.reason || "install failed"}`,
].filter(Boolean);
throw new Error(`Could not repair Relay desktop services (${failures.join(", ")}).`);
}
const appText = repaired.pill?.appPath ? ` Relay.app is installed at ${repaired.pill.appPath}.` : "";
const restartText = reload ? "Relay background services were reloaded." : "Relay background service files were refreshed without restarting them.";
console.log(`${restartText}${appText}`);
return repaired;
}
function cmdRepairRuntime(flags = {}) {
writeConfig({});
const reload = !flags["no-restart"];
// Internal target overrides let a verified immutable candidate restore an
// older legacy runtime whose own CLI predates `repair-runtime`.
const targetBin = flags["target-bin"] ? path.resolve(String(flags["target-bin"])) : undefined;
const targetNode = flags["target-node"] ? String(flags["target-node"]) : undefined;
const runtimeBin = targetBin || relayBinPath();
const runtimeNode = persistentNodePath(targetNode || process.execPath);
const target = {
bin: runtimeBin,
node: runtimeNode,
};
const registrations = repairExistingAgentRegistrations(target);
if (!registrations.ok) {
throw new Error(`Could not repair Relay agent registrations (${registrations.reason || "migration failed"}).`);
}
const repaired = repairDesktopSurfaces({ reload, ...target, claim: Boolean(flags.claim) || Boolean(targetBin) });
if (!repaired.ok) {
throw new Error("Could not repair Relay runtime services.");
}
const pointer = reconcileCanonicalRuntimeNode({ node: runtimeNode });
if (!pointer.ok) {
throw new Error(`Could not reconcile Relay's active runtime pointer (${pointer.reason || "verification failed"}).`);
}
const pointerText = pointer.changed
? " The active runtime pointer was updated."
: pointer.reason === "already-current"
? " The active runtime pointer was already current."
: "";
console.log(`Relay runtime repaired with its durable Node.${pointerText}`);
return { ok: true, registrations, pointer, ...repaired };
}
/**
* Remove the Relay tools from every agent and stop the background daemon. The
* pairing survives by default so a reinstall is painless; `--purge` forgets this
* machine entirely — revokes the device server-side, then deletes the pairing
* and all companion state — so the next `relay setup` is a genuinely new device.
*/
async function cmdUninstall(flags = {}) {
const purge = Boolean(flags.purge);
let revocation = null;
if (purge) {
// Revoke first: the token needed to do it lives in the state deleted below.
const cfg = readConfig();
if (cfg.deviceToken) {
try {
await new RelayClient().revokeSelf();
revocation = "revoked";
} catch (err) {
// A token the server no longer recognises is already gone, which is the
// outcome purge wants; anything else is reported, never fatal — the human
// asked to forget this machine and can revoke from Settings > Devices.
revocation = err && err.status === 401 ? "already_revoked" : `failed: ${err && err.message ? err.message : err}`;
}
} else {
revocation = "not_paired";
}
}
const uninstalled = runUninstall();
for (const line of uninstallResultLines(uninstalled)) console.log(line);
if (!uninstalled.ok) process.exitCode = 1;
if (!purge) return;
if (revocation === "revoked") console.log("Revoked this device on your Relay account.");
else if (revocation === "already_revoked") console.log("This device was already revoked on your Relay account.");
else if (revocation === "not_paired") console.log("This device was not paired, so there was nothing to revoke.");
else console.log(`Could not revoke this device (${revocation}). Revoke it from sendrelays.com > Settings > Devices.`);
const purged = purgeLocalState();
for (const target of purged.removed) console.log(`Deleted ${target}`);
for (const f of purged.failed) console.log(`Could not delete ${f.path} (${f.detail}); remove it by hand.`);
console.log("");
// The pairing outliving a purge is the one failure that silently defeats the
// whole command: everything looks cleaned up and the next setup adopts the old
// device. Say so plainly and fail the exit code.
if (purged.pairingRemains) {
console.log("WARNING: the pairing file survived, so this machine is NOT yet forgotten.");
console.log("Delete it by hand, then run this again:");
console.log(` ${path.join(os.homedir(), ".relay", "config.json")}`);
process.exitCode = 1;
return;
}
if (!purged.ok) {
console.log("The pairing is gone, but Relay's local purge is incomplete.");
console.log("Relay kept the npm package so this command can be retried after the reported lock or permission problem is fixed.");
process.exitCode = 1;
return;
}
if (!uninstalled.ok) {
console.log("Relay's local state is gone, but the integration uninstall above is incomplete.");
console.log("Relay kept the npm package so `relay uninstall --purge` can be retried.");
return;
}
if (process.platform === "linux") {
console.log("A root-owned Chromium sandbox helper may remain under /usr/local/lib/relay because it can be shared by other users on this Linux device.");
}
const packageRemoval = uninstallManagedCompanionPackage({
runningRoot: companionPackageRoot(),
shimRoot: process.env[TRAMPOLINE_SHIM_ROOT_ENV] || "",
});
if (!packageRemoval.ok) {
console.log(
`Could not remove the npm package after ${packageRemoval.attempts || 1} ` +
`${packageRemoval.attempts === 1 ? "attempt" : "attempts"} (${packageRemoval.detail || "unknown failure"}).`,
);
console.log(`Relay's data and integrations are gone. Remove the remaining package with: npm uninstall -g relay-companion --prefix "${packageRemoval.prefix}"`);
process.exitCode = 1;
return;
}
if (packageRemoval.removed) console.log("Removed the npm-global Relay package.");
console.log("This machine has forgotten Relay and no installed Relay runtime remains.");
}
async function cmdWhoami() {
const cfg = readConfig();
if (!cfg.deviceToken) {
console.log("Not paired. Run: relay pair");
return;
}
const client = new RelayClient();
const me = await client.me();
console.log(`${me.user.name} <${me.user.email}> (api: ${apiUrl()})`);
}
async function cmdTasksOnce(flags = {}) {
await requireTaskFeatures();
const out = await pollTaskRuntimeOnce({ log: (m) => console.log(`[relay] ${m}`) });
console.log(
`Processed ${out.sessions.length} task session(s), ${out.messages.length} message(s), ${out.notifications.length} Relay companion item(s).`,
);
}
function absoluteWebTarget(pathOrUrl = "/app/tasks") {
if (/^https?:\/\//i.test(pathOrUrl)) return pathOrUrl;
const path = pathOrUrl.startsWith("/") ? pathOrUrl : `/${pathOrUrl}`;
return `${webUrl()}${path}`;
}
function openUrl(url) {
const command = process.platform === "darwin"
? "open"
: process.platform === "win32"
? "cmd"
: "xdg-open";
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
const result = spawnSync(command, args, { stdio: "ignore" });
if (result.status !== 0) {
console.log(url);
return false;
}
return true;
}
// `relay open <id> --host <host>` — materialize the staged Relay row into a real
// native agent session inside the already-running Claude Desktop or Codex, then
// print { url, skipExternalOpen, openedInHost } so the overlay can fall back to
// shell.openExternal(url) when the host wasn't foregrounded directly.
//
// `relay open --task <taskId> --host <host>` — same materialization, but seeded
// from a Relay task (active or completed): the session opens with the task's
// objective + state. Historical coordination records remain readable, while
// their remaining lifecycle stays owned by the Relay UI/backend. Prints the
// identical JSON contract.
async function cmdOpen(positional, flags) {
const log = (m) => process.stderr.write(`[relay] ${m}\n`);
const host = String(flags.host || flags.in || "claude").toLowerCase();
const features = await accountProductFeatures({
client: new RelayClient(),
env: process.env,
config: readConfig(),
apiUrl: apiUrl(),
});
if (flags.cowork) throw new Error("Claude Cowork is temporarily unavailable in Relay");
if (flags.task) {
await requireTaskFeatures();
const result = await openTask({ taskId: String(flags.task), host, log });
console.log(JSON.stringify(result, null, 2));
return;
}
const id = positional[0] || flags.id;
if (!id) throw new Error("Usage: relay open <id> --host claude|codex [--fresh] [--cwd <dir>] [--model <id>] [--effort <level>] (or: relay open --task <taskId> --host claude|codex)");
// --fresh: ignore any remembered native session for this row and forge a new
// one (the pill's "Open in new chat" action).
// --cwd: open in a specific directory, overriding repo routing. Also re-forges,
// since neither host can relocate an existing session.
// --model / --effort: the task runtime picker's choice; a model change also
// re-forges, since neither host can retarget a live session.
const result = await openRelay({
id,
host,
log,
allowTaskRows: features.requests,
forceFresh: Boolean(flags.fresh),
cwd: flags.cwd ? String(flags.cwd) : "",
model: flags.model ? String(flags.model) : "",
effort: flags.effort ? String(flags.effort) : "",
activateDesktop: !flags["quiet-provider"],
cowork: false,
});
console.log(JSON.stringify(result, null, 2));
}
/** Launch (or signal) the desktop Relay companion pill and verify it is visible. */
async function cmdPill(flags = {}, positional = []) {
migratePersistedContentFields({ log: (message) => console.log(`[relay] ${message}`) });
const here = path.dirname(fileURLToPath(import.meta.url));
const overlayMain = path.resolve(here, "../overlay/main.cjs");
// require("electron") returns the path to the electron binary when resolved under node.
let electronPath;
try {
const require = createRequire(import.meta.url);
electronPath = require("electron");
} catch (error) {
throw new Error(
"Electron is not installed. Run `npm install` in packages/companion first. (" +
(error && error.message ? error.message : error) +
")",
);
}
if (typeof electronPath !== "string") {
throw new Error("Could not resolve the Electron binary. Run `npm install` in packages/companion.");
}
const pillEnv = { ...process.env };
if (process.platform === "linux") {
pillEnv.RELAY_ALLOW_SANDBOX_AUTHORIZATION = "1";
const repaired = repairDesktopSurfaces({ reload: false, env: pillEnv });
if (!repaired.ok) {
const failure = repaired.pill?.detail || repaired.pill?.reason || repaired.daemon?.detail || repaired.daemon?.reason;
throw new Error(failure || "Relay could not prepare its Linux desktop services.");
}
const expectedRoot = path.resolve(here, "..");
const beforePidResult = spawnSync("systemctl", ["--user", "show", "--property=MainPID", "--value", "work.relay.companion.pill.service"], {
encoding: "utf8",
timeout: 5_000,
});
const beforePid = Number(String(beforePidResult.stdout || "").trim());
const beforeStatus = readPillStatus();
const exactPillAlreadyOwned = !beforePidResult.error && beforePidResult.status === 0 && beforePid > 0 &&
beforeStatus?.pid === beforePid && path.resolve(String(beforeStatus.packageRoot || "")) === expectedRoot;
const started = startLinuxDesktopServices({ env: pillEnv, restartPill: beforePid > 0 && !exactPillAlreadyOwned });
if (!started.ok) throw new Error(started.detail || `Relay could not start ${started.unit || "its Linux services"}.`);
// The systemd unit must own the singleton before the short-lived second
// Electron instance delivers a reopen/deep-link signal. Otherwise a manual
// app launch can become an unsupervised pill and strand future restarts.
const deadline = Date.now() + 12_000;
let supervised = false;
while (Date.now() <= deadline) {
const mainPid = spawnSync("systemctl", ["--user", "show", "--property=MainPID", "--value", "work.relay.companion.pill.service"], {
encoding: "utf8",
timeout: 5_000,
});
const status = readPillStatus();
const pid = Number(String(mainPid.stdout || "").trim());
if (!mainPid.error && mainPid.status === 0 && pid > 0 && status?.ready === true && status.pid === pid && path.resolve(String(status.packageRoot || "")) === expectedRoot) {
supervised = true;
break;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
if (!supervised) throw new Error("Relay's registered Linux pill service did not become ready. Run `relay doctor` for details.");
}
const reopenNonce = `cli-${process.pid}-${randomUUID()}`;
const deepLinks = positional
.map((value) => String(value || ""))
.filter((value) => value.startsWith("relay://"));
let spawnError = null;
const child = spawn(
electronPath,
[overlayMain, ...deepLinks, "--relay-reopen", reopenNonce],
{
detached: true,
stdio: "ignore",
env: pillEnv,
},
);
child.on("error", (error) => {
spawnError = error;
});
child.unref();
const result = await waitForPillReady(reopenNonce);
if (!result.ok) {
const detail = spawnError ? ` ${spawnError.message || spawnError}` : "";
throw new Error(
`Relay was started but did not confirm that its pill became visible.${detail} Check ${pillStatusPath()} and ~/.relay/pill.log.`,
);
}
// Say when the pill is only up because THIS command asked: with "Show Relay
// automatically" off it goes away again as soon as it is closed, and a flat "it's
// visible" would read as the setting having quietly stopped working.
if (result.status?.pillHidden === true) {
console.log(
`Relay pill is visible. "Show Relay automatically" is off in Settings, so it will hide again once you close it — ` +
`turn that on in Settings to keep it around. Reopen it any time from ${statusAreaReopenText()}.`,
);
return;
}
console.log(`Relay pill is visible. Reopen it from ${statusAreaReopenText()} after hiding it.`);
}
// Diagnose (and optionally heal) swallowed notifications: relays that are
// unread on disk but marked presented with no pending-attention entry can
// never notify again. --requeue-unseen strands them back into the queue;
// --restart-pill makes the running pill reload the healed prefs.
async function cmdDoctor(flags = {}) {
const home = process.env.RELAY_HOME || process.env.RELAY_COMPANION_HOME || path.join(os.homedir(), ".relay-companion");
const statePath = path.join(home, "state.json");
const prefsPath = path.join(home, "overlay-prefs.json");
const readJson = (p) => {
try {
return JSON.parse(fsSync.readFileSync(p, "utf8")) || {};
} catch {
return {};
}
};
const state = readJson(statePath);
const prefs = readJson(prefsPath);
const presented = new Set(Array.isArray(prefs.presentedRelayIds) ? prefs.presentedRelayIds.map(String) : []);
const pending = new Set(Object.keys(prefs.pendingAttention && typeof prefs.pendingAttention === "object" ? prefs.pendingAttention : {}));
const unread = Object.entries(state.packets || {})
.filter(([, row]) => row && row.direction === "inbound" && row.state !== "read")
.map(([id]) => id);
const swallowed = unread.filter((id) => presented.has(id) && !pending.has(id));
console.log(`relay doctor @ ${new Date().toISOString()}`);
console.log(` staged unread: ${unread.length}`);
console.log(` confirmed-seen ledger: ${presented.size}`);
console.log(` pending attention queue: ${pending.size}`);
console.log(` SWALLOWED (unread, marked seen, not queued): ${swallowed.length}`);
for (const id of swallowed.slice(0, 20)) console.log(` - ${id}`);
if (swallowed.length > 20) console.log(` … and ${swallowed.length - 20} more`);
// A pill hidden on purpose never presents, so its queue grows and never drains.
// That is correct behaviour and looks exactly like a fault; say which it is.
if (prefs.pillHidden === true || prefs.soundsMuted === true) {
console.log(
` notifications: ${[
prefs.pillHidden === true ? "automatic display off" : "",
prefs.soundsMuted === true ? "sounds off" : "",
].filter(Boolean).join(", ")} (Settings) — a growing pending queue is expected here, not a fault`,
);
}
// Update health. A machine whose updates cannot land used to be invisible: the
// daemon retried silently forever and the only way to notice was diffing
// package.json against npm by hand (field report, Shane 2026-08-11 — pinned on
// 0.1.77 while 62 versions shipped). Say it here, where people already look.
const updateState = readJson(path.join(home, "update-state.json"));
const failure = updateState.failure && typeof updateState.failure === "object" ? updateState.failure : null;
console.log(` running version: ${companionVersion()}`);
// Say which tree answered, and which tree the human actually typed. The
// trampoline makes a frozen npm shim harmless, but also invisible — this is
// the one place that difference must stay visible (the original field
// report was exactly `relay --version` lying about the machine).
if (trampolineArrival) {
console.log(
` typed \`relay\` shim: ${trampolineArrival.shimVersion || "unknown version"} at ${trampolineArrival.shimRoot || "unknown path"} — handed this run to the canonical runtime`,
);
} else {
console.log(` running from: ${companionPackageRoot()}`);
}
console.log(` update channel: ${updateChannel()}`);
// The update record is written BEFORE each launch (auto-update.js launchPending),
// so a single fresh record is an attempt in flight, not a diagnosis.
const updateLogPath = path.join(os.homedir(), ".relay", "update.log");
const failureInFlight = failure && Number(failure.count) === 1 && Date.now() - Number(failure.lastAt || 0) < 30 * 60 * 1000;
// Canonical migration/recovery keep their own durable attempt records; doctor
// used to read only `failure` and printed "update health: ok" for the entire day
// Sven's machine burned 24GB in canonical install/activation loops.
const updateStatePath = path.join(home, "update-state.json");
const canonicalFailures = [];
try {
const migration = readMigrationFailure(updateStatePath);
if (migration) canonicalFailures.push({ label: "runtime migration", ...migration });
} catch {}
try {
const recovery = readRecoveryFailure(updateStatePath);
if (recovery) canonicalFailures.push({ label: "runtime recovery", ...recovery });
} catch {}
if (failure && Number(failure.count) > 0 && !failureInFlight) {
const since = failure.firstAt ? new Date(Number(failure.firstAt)).toISOString() : "unknown";
console.log(` UPDATES ARE FAILING: ${failure.count} consecutive attempt(s) to install ${failure.target} since ${since}`);
console.log(` cause: see ${updateLogPath}`);
console.log(" if autostart is broken, run: relay repair-installation");
} else if (failureInFlight) {
console.log(` update attempt in flight: ${failure.target} (launched ${new Date(Number(failure.lastAt)).toISOString()})`);
} else if (!canonicalFailures.length) {
console.log(" update health: ok (no failed update attempts recorded)");
}
for (const record of canonicalFailures) {
const since = record.firstAt ? new Date(Number(record.firstAt)).toISOString() : "unknown";
console.log(` CANONICAL ${record.label.toUpperCase()} FAILING: ${record.count} attempt(s) at ${record.target} since ${since}`);
console.log(` cause: see ${updateLogPath}`);
}
// The canonical runtime itself: pointer state, disk footprint, live workers, and
// the last thing the updater actually said. 37 stranded releases / 24GB burned
// for 25 minutes while this command printed nothing at all.
try {
const layout = canonicalRuntimeLayout({});
const pointer = readCanonicalRuntimeState({});
if (pointer) {
console.log(` canonical runtime: ${pointer.version || pointer.candidate?.version || "unknown"} (${pointer.state || (pointer.active ? "active" : "inactive")})`);
}
let releaseNames = [];
try {
releaseNames = fsSync.readdirSync(layout.releasesDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name);
} catch {}
if (releaseNames.length) {
let sizeLabel = "";
if (process.platform !== "win32") {
const du = spawnSync("du", ["-sk", layout.releasesDir], { encoding: "utf8", timeout: 30_000 });
const kb = Number(String(du.stdout || "").trim().split(/\s+/)[0]);
if (Number.isFinite(kb) && kb > 0) sizeLabel = `, ${(kb / 1024 / 1024).toFixed(1)} GB`;
}
console.log(` release trees on disk: ${releaseNames.length}${sizeLabel}`);
if (releaseNames.length > 3) {
console.log(" MORE THAN EXPECTED — failed updates were stranding releases; this build prunes them on the next update attempt");
}
}
if (process.platform === "darwin") {
const workers = listUpdateWorkerJobs({});
const legacy = workers.filter((job) => job.kind === "legacy");
const fixed = workers.find((job) => job.kind === "fixed") || null;
const live = workers.filter((job) => job.pid);
if (workers.length) {
console.log(` update workers in launchd: ${live.length} running, ${legacy.length} legacy label(s)`);
if (legacy.length) console.log(" every legacy label is removed automatically at startup");
if (fixed && !fixed.pid) console.log(" fixed worker label is inactive and ready for the next admitted update");
}
}
try {
const tail = fsSync.readFileSync(updateLogPath, "utf8");
const lines = tail.slice(-65_536).split("\n").filter((line) => /canonical update|canonical activation/.test(line));
if (lines.length) console.log(` last updater event: ${lines.at(-1).replace(/^\[relay-update\]\s*/, "")}`);
} catch {}
} catch {}
if (process.platform === "win32") {
const taskStatus = windowsAutostartTaskStatus();
const missingTasks = taskStatus.missing;
if (missingTasks.length) {
console.log(` MISSING Scheduled Task(s): ${missingTasks.join(", ")}`);
console.log(" fix with: relay repair-installation");
} else if (taskStatus.unavailable.length) {
console.log(" Scheduled Tasks: unable to verify from this process");
console.log(" run relay doctor in a normal terminal to check autostart");
}
// Without this, Start → "relay" finds nothing and the tray icon (parked in the
// Windows 11 overflow chevron) is the only way back to the pill.
if (windowsStartMenuShortcutMissing()) {
console.log(` MISSING Start Menu shortcut: ${windowsStartMenuShortcutPath()}`);
console.log(" fix with: relay repair-installation");
} else {
console.log(" Start Menu shortcut: ok (Start → \"relay\" opens the pill)");
}
}
if (process.platform === "linux") {
const desktopPaths = linuxDesktopPaths();
const activeRoot = path.resolve(companionPackageRoot());
const activeUnitRoot = systemdExecQuote(activeRoot).slice(1, -1);
const activeDesktopRoot = desktopExecQuote(activeRoot).slice(1, -1);
const units = [
{ label: "background service", unit: "work.relay.companion.service", expectedPath: desktopPaths.daemonUnitPath },
{ label: "Relay pill", unit: "work.relay.companion.pill.service", expectedPath: desktopPaths.pillUnitPath },
];
console.log(" Linux services:");
for (const { label, unit, expectedPath } of units) {
const probe = spawnSync(
"systemctl",
["--user", "show", unit, "--property=LoadState,UnitFileState,ActiveState,SubState,MainPID,FragmentPath", "--no-pager"],
{ encoding: "utf8", timeout: 10_000 },
);
const fields = Object.fromEntries(
String(probe.stdout || "")
.split(/\r?\n/)
.map((line) => line.split("="))
.filter(([key, ...value]) => key && value.length)
.map(([key, ...value]) => [key, value.join("=")]),
);
if (probe.error || (!fields.LoadState && probe.status !== 0)) {
const detail = probe.error?.message || String(probe.stderr || "systemd user manager unavailable").trim();
console.log(` ${label}: unable to inspect (${detail})`);
continue;
}
if (fields.LoadState !== "loaded") {
console.log(` ${label}: NOT INSTALLED (${unit})`);
console.log(" fix with: relay repair-installation");
continue;
}
const runtime = [fields.ActiveState, fields.SubState].filter(Boolean).join("/") || "unknown";
const registration = fields.UnitFileState ? `, ${fields.UnitFileState}` : "";
const mainPid = Number.parseInt(String(fields.MainPID || "0"), 10);
console.log(` ${label}: ${runtime}${registration}, pid ${mainPid || "none"} (${unit})`);
let fragmentMatches = false;
try { fragmentMatches = fsSync.realpathSync(fields.FragmentPath) === fsSync.realpathSync(expectedPath); }
catch { fragmentMatches = path.resolve(String(fields.FragmentPath || "")) === path.resolve(expectedPath); }
if (!fragmentMatches) {
console.log(` WRONG UNIT FILE: systemd resolved ${fields.FragmentPath || "nothing"}`);
console.log(` expected: ${expectedPath}`);
console.log(" fix with: relay repair-installation");
}
let unitNamesActiveRoot = false;
try { unitNamesActiveRoot = fsSync.readFileSync(expectedPath, "utf8").includes(activeUnitRoot); } catch {}
if (!unitNamesActiveRoot) {
console.log(` STALE COMMAND: ${expectedPath} does not name the active Relay runtime`);
console.log(" fix with: relay repair-installation");
}
if (fields.ActiveState !== "active" || !Number.isInteger(mainPid) || mainPid <= 0) {
console.log(` inspect with: systemctl --user status ${unit}`);
console.log(" fix with: relay repair-installation");
}
}
const registered = readAutostartDaemonRoot({ platform: "linux" });
let registrationMatches = false;
try { registrationMatches = fsSync.realpathSync(registered?.root) === fsSync.realpathSync(activeRoot); }
catch { registrationMatches = Boolean(registered?.root) && path.resolve(registered.root) === activeRoot; }
if (!registrationMatches) {
console.log(` Linux runtime registration: WRONG (${registered?.root || "unreadable"}; active ${activeRoot})`);
console.log(" fix with: relay repair-installation");
} else {
console.log(` Linux runtime registration: ok (${activeRoot})`);
}
console.log(" Linux desktop integration:");
for (const [label, file] of [
["app launcher", desktopPaths.applicationPath],
["login autostart", desktopPaths.autostartPath],
]) {
const exists = fsSync.existsSync(file);
let stale = false;
if (exists && label === "app launcher") {
try { stale = !fsSync.readFileSync(file, "utf8").includes(activeDesktopRoot); }
catch { stale = true; }
}
console.log(` ${label}: ${!exists ? `MISSING (${file})` : stale ? `STALE (${file})` : "ok"}`);
if (!exists || stale) console.log(" fix with: relay repair-installation");
}
const mime = spawnSync("xdg-mime", ["query", "default", "x-scheme-handler/relay"], {
encoding: "utf8",
timeout: 10_000,
});
const mimeHandler = String(mime.stdout || "").trim();
if (!mime.error && mime.status === 0 && mimeHandler === "work.relay.companion.desktop") {
console.log(" relay: links: ok");
} else {
console.log(` relay: links: WRONG (${mimeHandler || mime.error?.message || "no handler"})`);
console.log(" fix with: relay repair-installation");
}
try {
const electronPath = createRequire(import.meta.url)("electron");
const sandboxEnv = {};
const sandbox = prepareLinuxElectronSandbox({
platform: "linux",
electronPath,
env: sandboxEnv,
allowAuthorization: false,
});
if (sandbox.ok) {
console.log(` Electron sandbox: ${sandbox.discovered ? `verified (${sandbox.destination})` : "kernel user namespace available"}`);
} else {
console.log(` Electron sandbox: NEEDS ATTENTION (${sandbox.detail || sandbox.reason})`);
console.log(" fix with: relay update");
}
} catch (error) {
console.log(` Electron sandbox: unable to inspect (${error?.message || error})`);
console.log(" fix with: relay repair-installation");
}
const relayLogDir = path.join(os.homedir(), ".relay");
console.log(" Linux logs:");
console.log(` background service: ${path.join(relayLogDir, "daemon.log")}`);
console.log(` Relay pill: ${path.join(relayLogDir, "pill.log")}`);
console.log(" service history: journalctl --user -u work.relay.companion.service -u work.relay.companion.pill.service");
}
if (flags["requeue-unseen"] && prefs.pillHidden === true) {
console.log("Refusing --requeue-unseen while \"Show Relay automatically\" is off: nothing can present, so it would only grow the queue.");
console.log("Turn that setting on in Relay's Settings tab first.");
} else if (flags["requeue-unseen"] && swallowed.length) {
prefs.presentedRelayIds = [...presented].filter((id) => !swallowed.includes(id));
fsSync.writeFileSync(prefsPath, `${JSON.stringify(prefs, null, 2)}\n`);
console.log(`Requeued ${swallowed.length} relay(s): removed from the confirmed-seen ledger.`);
if (flags["restart-pill"] && process.platform === "darwin") {
const uid = typeof process.getuid === "function" ? process.getuid() : 501;
spawnSync("launchctl", ["kickstart", "-k", `gui/${uid}/work.relay.companion.pill`], { stdio: "ignore" });
console.log("Pill restarted; the queue will replay them sequentially when you are present.");
} else if (flags["restart-pill"] && process.platform === "linux") {
const restarted = await restartRelayServices({ services: ["pill"] });
if (restarted.pill === "restarted") {
console.log("Pill restarted; the queue will replay them sequentially when you are present.");
} else if (restarted.pill === "not_installed") {
console.log("The Relay pill service is not installed; run `relay repair-installation`.");
} else {
const detail = restarted.detail?.pill ? ` (${restarted.detail.pill})` : "";
console.log(`The Relay pill did not restart${detail}; run \`relay repair-installation\` or restart it with systemctl.`);
}
} else if (process.platform === "linux") {
console.log("Restart the pill to pick this up: systemctl --user restart work.relay.companion.pill.service");
} else {
console.log("Restart the pill to pick this up: launchctl kickstart -k gui/$UID/work.relay.companion.pill");
}
}
}
// View or switch the release channel this machine follows. Stable tracks npm's
// `latest` dist-tag; dev and staging track their matching deliberately promoted
// tags.
// The daemon re-reads the channel on every poll, so a switch to dev takes
// effect within about a minute with no restart. Switching back to stable never
// downgrades: the machine keeps its current build until `latest` catches up at
// the next promotion (the updater only ever moves forward).
// Flip this machine between environments as ONE switch. Dev enables the dev
// API/channel; staging selects a production-like candidate with no developer
// capabilities; prod restores the production API/stable channel. All three
// share one account and inbox. Going back to prod does not downgrade the
// installed build (the updater only moves forward).
function cmdEnv(positional = [], flags = {}) {
const target = String(positional[0] || "").trim().toLowerCase();
const cfg = readConfig();
const configuredApiUrl = cfg.apiUrl || DEFAULT_API_URL;
const current = cfg.stagingApiUrl && configuredApiUrl === cfg.stagingApiUrl
? "staging"
: configuredApiUrl === DEFAULT_API_URL
? "prod"
: "dev";
if (!target) {
console.log(`[relay] env: ${current} (api: ${cfg.apiUrl || DEFAULT_API_URL}, update channel: ${updateChannel()})`);
console.log("[relay] switch with: relay env dev | relay env staging | relay env prod");
return;
}
if (target === "prod" || target === "stable") {
writeConfig({ apiUrl: DEFAULT_API_URL, webUrl: DEFAULT_WEB_URL, updateChannel: UPDATE_CHANNEL_STABLE });
console.log(`[relay] env set to prod (api: ${DEFAULT_API_URL}, web: ${DEFAULT_WEB_URL}, channel: stable)`);
console.log("[relay] note: this does not downgrade the installed build; run `npm i -g relay-companion@latest` to match users exactly");
return;
}
if (target !== "dev" && target !== "staging") {
console.error(`[relay] unknown env "${target}" — use "dev", "staging", or "prod"`);
process.exitCode = 1;
return;
}
const isStaging = target === "staging";
const savedApiUrl = isStaging ? cfg.stagingApiUrl : cfg.devApiUrl;
const defaultApiUrl = isStaging ? DEFAULT_STAGING_API_URL : DEFAULT_DEV_API_URL;
const selectedApiUrl = String(flags.api || savedApiUrl || defaultApiUrl || "").trim().replace(/\/+$/, "");
if (!selectedApiUrl) {
console.error(`[relay] the ${target} API URL is not known yet — pass it once: relay env ${target} --api https://<relay-api-${target}-url>`);
process.exitCode = 1;
return;
}
const selectedWebUrl = String(flags.web || DEFAULT_WEB_URL).trim().replace(/\/+$/, "");
if (!selectedWebUrl) {
console.error(`[relay] the account web URL is not known — pass it explicitly with --web`);
process.exitCode = 1;
return;
}
const selectedChannel = isStaging ? UPDATE_CHANNEL_STAGING : UPDATE_CHANNEL_DEV;
const savedApiKey = isStaging ? "stagingApiUrl" : "devApiUrl";
writeConfig({
apiUrl: selectedApiUrl,
[savedApiKey]: selectedApiUrl,
webUrl: selectedWebUrl,
updateChannel: selectedChannel,
});
const featureNote = isStaging ? "production feature set" : "developer feature set";
console.log(`[relay] env set to ${target} (api: ${selectedApiUrl}, web: ${selectedWebUrl}, channel: ${selectedChannel}) — same account, ${featureNote}`);
}
function cmdUpdateChannel(positional = []) {
const requested = String(positional[0] || "").trim().toLowerCase();
if (!requested) {
const source = process.env.RELAY_UPDATE_CHANNEL ? " (from RELAY_UPDATE_CHANNEL)" : "";
console.log(`[relay] update channel: ${updateChannel()}${source}`);
console.log(`[relay] switch with: relay update-channel ${UPDATE_CHANNEL_DEV} | relay update-channel ${UPDATE_CHANNEL_STAGING} | relay update-channel ${UPDATE_CHANNEL_STABLE}`);
return;
}
if (![UPDATE_CHANNEL_DEV, UPDATE_CHANNEL_STAGING, UPDATE_CHANNEL_STABLE].includes(requested)) {
console.error(`[relay] unknown channel "${requested}" — use "${UPDATE_CHANNEL_DEV}", "${UPDATE_CHANNEL_STAGING}", or "${UPDATE_CHANNEL_STABLE}"`);
process.exitCode = 1;
return;
}
// Release code and its server contract are one environment. Keeping these as
// independent switches allowed a dev Companion to keep calling production,
// where unreleased routes (notably Slack) correctly do not exist. Preserve
// the old command as an alias, but make the resulting state coherent.
if (requested === UPDATE_CHANNEL_DEV) return cmdEnv(["dev"]);
if (requested === UPDATE_CHANNEL_STAGING) return cmdEnv(["staging"]);
if (requested === UPDATE_CHANNEL_STABLE) return cmdEnv(["prod"]);
}
async function cmdE2eeHistoryImport(flags) {
const client = new RelayClient();
const prepared = await prepareE2eeHistoryImport(client);
const { me, items, summary } = prepared;
console.log(`[relay] encrypted history import plan for ${me.user.name} <${me.user.email}>`);
console.log(`[relay] ${summary.messages} authored Relays: ${summary.direct} direct, ${summary.groups} group, ${summary.requests} Requests`);
console.log(`[relay] ${summary.attachments} attachments; ${summary.edited} edited; ${summary.deleted} deleted`);
console.log("[relay] destination ids and dates will be fresh; no legacy mapping is stored by Relay");
if (!flags.execute) {
console.log(`[relay] dry run only — execute with: relay e2ee-history-import --execute --confirm ${me.user.email}`);
return;
}
const confirmation = String(flags.confirm || "").trim().toLowerCase();
if (confirmation !== String(me.user.email || "").trim().toLowerCase()) {
throw new Error(`Refusing to write history: --confirm must exactly match ${me.user.email}.`);
}
let maxItems = Number.POSITIVE_INFINITY;
if (flags.max !== undefined) {
maxItems = Number(flags.max);
if (!(maxItems > 0) || !Number.isInteger(maxItems)) {
throw new Error("--max must be a positive whole number when supplied.");
}
}
const result = await importLegacyHistory(client, items, {
accountId: me.user.id,
maxItems,
onProgress: ({ index, total }) => console.log(`[relay] imported ${index}/${total}`),
});
console.log(`[relay] import complete: ${result.imported} new, ${result.alreadyImported} already checkpointed, ${result.remaining} remaining`);
console.log(`[relay] local retry checkpoint: ${result.checkpointPath}`);
}
// Set when this process is the trampoline's hop target (see main()).
let trampolineArrival = null;
async function main() {
const [command, ...rest] = process.argv.slice(2);
// The one-time full migration bridge still publishes bin/relay.js so older
// fleets can reach it. A human's first `setup`, however, must exercise the
// same signed canonical bootstrap and release canary as the later thin
// installer. Candidate activation sets this marker before calling setup back.
if (
command === "setup" &&
companionDistribution() === "bridge-runtime" &&
process.env.RELAY_BOOTSTRAP_ACTIVATED !== "1"
) {
const bootstrap = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../bootstrap/relay-setup.cjs");
const result = spawnSync(process.execPath, [bootstrap, "setup", ...rest], {
stdio: "inherit",
windowsHide: true,
env: process.env,
});
if (result.error) throw result.error;
process.exitCode = Number.isInteger(result.status) ? result.status : 1;
return;
}
// The typed `relay` command is the npm global shim and would otherwise stay
// at install-time version forever while the canonical runtime moves on
// underneath it. Hand human-facing commands to the active release; the
// services and updater-internal commands are exempt (see cli-trampoline.js).
const hop = resolveCliTrampoline({ command, argv: rest });
if (hop) {
process.exit(runCliTrampoline(hop));
}
// If we ARE the hop target, remember where the human's typed `relay` lives
// (doctor reports it), then strip the markers: they are a per-invocation
// loop guard, and a long-lived child (daemon, pill, an agent session) that
// inherited them would silently never trampoline again.
if (process.env[TRAMPOLINE_ENV]) {
trampolineArrival = {
shimVersion: process.env[TRAMPOLINE_SHIM_VERSION_ENV] || null,
shimRoot: process.env[TRAMPOLINE_SHIM_ROOT_ENV] || null,
};
delete process.env[TRAMPOLINE_ENV];
delete process.env[TRAMPOLINE_SHIM_VERSION_ENV];
delete process.env[TRAMPOLINE_SHIM_ROOT_ENV];
}
const { flags, positional } = parseFlags(rest);
rejectRemovedCapabilityFlags(command, flags);
switch (command) {
case "version":
case "--version":
case "-v":
console.log(companionVersion());
return;
case "setup":
return cmdSetup(flags);
case "pair":
return cmdPair(flags);
case "install":
return cmdInstall(flags);
case "repair-installation":
case "repair-desktop":
return cmdRepairDesktop(flags);
case "repair-runtime":
return cmdRepairRuntime(flags);
case "uninstall":
return cmdUninstall(flags);
case "whoami":
return cmdWhoami();
case "tasks":
return cmdTasksOnce(flags);
case "open":
return cmdOpen(positional, flags);
case "claude-hook":
// Internal: Claude Code hook runtime (installed by `relay install` into
// ~/.claude/settings.json). Reads the hook event JSON on stdin and must
// never fail the hosting Claude session.
return runClaudeHook();
case "codex-hook":
// Internal: Codex hook runtime. It offers private Relay context without
// changing human read state or starting a turn.
return runCodexHook();
case "daemon":
return runTaskDaemon({ intervalMs: Number(flags.interval) || 4000 });
case "pill":
return cmdPill(flags, positional);
case "mcp":
return Promise.all([
import("../src/mcp-broker-state.js"),
import("../src/mcp-bridge.js"),
]).then(([state, bridge]) => {
state.ensureMcpBrokerProvisioned();
return bridge.runMcpBridge();
});
case "wake-on":
// Let Claude Desktop sessions be woken by an incoming relay (see
// src/desktop-wake.js). Verified before it is trusted; reversible.
return import("../src/desktop-wake.js").then(({ syncDesktopWake }) => {
for (const r of syncDesktopWake({ log: (m) => console.log(`[relay] ${m}`) })) {
console.log(`[relay] CLI ${r.version}: ${r.ok ? r.reason : `FAILED (${r.reason})`}`);
}
});
case "wake-off":
return import("../src/desktop-wake.js").then(({ uninstallAllDesktopWake }) => {
for (const r of uninstallAllDesktopWake({ log: (m) => console.log(`[relay] ${m}`) })) {
console.log(`[relay] CLI ${r.version}: ${r.reason}`);
}
});
case "wake-status":
return import("../src/desktop-wake.js").then(({ wakeStatus }) => {
for (const r of wakeStatus()) console.log(`[relay] CLI ${r.version}: ${r.installed ? "wake enabled" : "not enabled"}`);
});
case "channel":
// EXPERIMENTAL: Claude Code channel server (see src/channel-server.js).
// Register in .mcp.json and start the session with
// claude --dangerously-load-development-channels server:relay
return import("../src/channel-server.js").then(({ runChannelServer }) => runChannelServer());
case "update":
case "self-update":
return runUpdateOnce().then(() => {});
case "update-channel":
return cmdUpdateChannel(positional);
case "env":
return cmdEnv(positional, flags);
case "e2ee-history-import":
return cmdE2eeHistoryImport(flags);
case "doctor":
return cmdDoctor(flags);
default:
console.log(
[
"Relay companion",
"",
"Usage:",
" relay setup Install and open Relay; sign in from the Relay pill",
" relay setup --restart Replace a stuck or expired one-time setup approval",
" relay version Print the installed Relay companion version",
" relay setup --code CODE --open-relay TOKEN --host codex|claude",
" relay setup --code CODE Legacy migration only: pair by code, then install",
" relay install Add Relay to your agents",
" relay uninstall Remove Relay from your agents and stop the daemon (keeps the pairing)",
" relay uninstall --purge Revoke this device and completely remove Relay from this machine",
" relay repair-installation [--no-restart] Non-destructively repair Relay.app and services; preserves account, encryption, messages, outbox, and preferences",
" relay repair-desktop [--no-restart] Compatibility alias for repair-installation",
" relay repair-runtime [--no-restart] Internal: refresh existing Relay registrations and runtime services",
" relay pair [--api URL] [--web URL] [--code CODE] [--no-restart] Pair this machine only (no agent install); restarts running Relay services onto the new account",
" relay tasks Pull and process the developer Request runtime once",
" relay open <id> --host claude|codex [--fresh] Materialize a staged Relay row into a native agent session (--fresh forces a new one)",
" relay open --task <taskId> --host claude|codex Materialize a developer Request into a native agent session",
" relay daemon [--interval MS] Run Relay delivery; developer accounts also receive Requests",
" relay pill Launch the Relay pill",
" relay mcp Run tools allowed by the signed-in account",
" relay update Update Relay",
" relay update-channel [dev|staging|stable] Show or switch the release channel",
" relay <command> --no-trampoline Run the invoked install itself instead of handing off to the canonical runtime",
" relay <command> --claim Let this tree take over the machine's autostart even when it is not the canonical runtime",
" relay env [dev|staging|prod] [--api URL] Switch API + release channel together",
" relay e2ee-history-import Dry-run the signed-in account's one-off encrypted history copy",
" relay e2ee-history-import --execute --confirm EMAIL Encrypt and copy that account's authored legacy history",
" relay whoami Show the paired account",
].join("\n"),
);
}
}
main().catch((err) => {
console.error(err.message || err);
process.exit(1);
});