UNPKG

relay-companion

Version:

Install Relay for Claude Code, Cowork, and Codex, then sign in from the Relay pill.

4,095 lines • 172 kB
import { spawn, spawnSync } from "node:child_process";
import {
  claudeDesktopConfigDirs,
  claudeDesktopConfigPathIn,
  claudeDesktopEntry,
  codexAppPresent,
  mergeClaudeDesktopConfig,
  resolveStableNode,
} from "./desktop-hosts.js";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { createRequire } from "node:module";
import { createHash, randomBytes } from "node:crypto";
import { fileURLToPath } from "node:url";
import { activateCodexMcp, verifyClaudeMcpRegistration } from "./setup-activation.js";
import { ensureStableMcpLauncher, mcpLaunchCommand, removeStableMcpLauncher } from "./mcp-launcher.js";
import { brokerIdentity, removeMcpBrokerProvisioning } from "./mcp-broker-state.js";
import { canonicalOwnershipGuard, verifyCanonicalCandidate } from "./canonical-runtime.js";
import { ensureStableHookLauncher, removeStableHookLauncher } from "./hook-launcher.js";
import { writeConfig } from "./config.js";
import { deleteInstallationAuthorizationCredentials } from "./installation-authorization.js";
import {
  ACTIVE_UPDATE_WORKER_ENV,
  cleanupMacUpdateAgents,
  updateAgentCleanupDetail,
} from "./update-agent-cleanup.js";

// Persistent install of the Relay companion into the user's local agents.
//
// After pairing, this registers the Relay MCP server (`relay mcp`) into Claude
// Code and Codex at user scope, so EVERY interactive session gets the Relay
// tools. The MCP server resolves the signed-in account's capabilities from the
// Relay API when it starts.
// It also installs a launchd agent that keeps the receive daemon running.

const DAEMON_LAUNCH_LABEL = "work.relay.companion";
const PILL_LAUNCH_LABEL = "work.relay.companion.pill";
const WINDOWS_DAEMON_TASK_NAME = "Relay Companion Daemon";
const WINDOWS_PILL_TASK_NAME = "Relay Companion Pill";
const LINUX_DAEMON_UNIT = `${DAEMON_LAUNCH_LABEL}.service`;
const LINUX_PILL_UNIT = `${PILL_LAUNCH_LABEL}.service`;
const LINUX_DESKTOP_FILE = "work.relay.companion.desktop";
const LINUX_LEGACY_DESKTOP_FILE = "relay.desktop";
const LINUX_AUTOSTART_FILE = "work.relay.companion.pill.desktop";
const DEFAULT_NPM_INSTALL_TIMEOUT_MS = 10 * 60 * 1000;
const RELAY_MAC_APP_NAME = "Relay.app";
const RELAY_MAC_APP_FALLBACK_NAME = "Relay Companion.app";
const RELAY_MAC_BUNDLE_IDENTIFIER = "work.relay.companion.launcher";
const { deleteDeviceToken } = createRequire(import.meta.url)("./credential-store.cjs");
const {
  canonicalCliLauncherSource,
  canonicalCliPaths,
  durableNodePath,
  isCanonicalCliShimSource,
} = createRequire(import.meta.url)("../bootstrap/relay-setup.cjs");
const { isTemporaryNodePath, relayOwnedNodePath } = createRequire(import.meta.url)("../bootstrap/owned-node-runtime.cjs");

export const PACKAGE_NAME = "relay-companion";

/** Absolute path to this companion's CLI entrypoint (deps resolve from here). */
export function relayBinPath() {
  return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../bin/relay.js");
}

function packageRootForBin(bin = relayBinPath(), platform = process.platform) {
  // Cross-platform tests may exercise the Darwin writer against a real Windows
  // temp tree. Preserve that host path while still parsing injected /Users/...
  // registrations with POSIX semantics.
  const api = platform === "win32" || /^[A-Za-z]:[\\/]/.test(String(bin)) ? path.win32 : path.posix;
  return api.resolve(api.dirname(bin), "..");
}

// A STABLE node path to bake into launchd plists and MCP registrations. process.execPath
// is often a version-managed binary (/opt/homebrew/Cellar/node/X/bin/node, ~/.nvm/…,
// ~/.n/…, fnm, volta) whose exact path disappears on a routine `brew upgrade node` or
// `nvm uninstall` — which would permanently break the daemon plist and every MCP entry.
// Prefer a well-known public symlink that currently resolves to the SAME runtime (it
// follows future upgrades); fall back to execPath when none matches.
const VERSION_MANAGED_NODE_RE = /[\\/](Cellar[\\/]node|\.nvm[\\/]versions|\.n[\\/]versions|\.fnm|fnm[\\/]|\.volta|\.hermes[\\/]node)[\\/]/i;
export function relayNodeVersionSupported(version) {
  const match = /^(\d+)\.(\d+)\.(\d+)/.exec(String(version || "").trim());
  if (!match) return false;
  const [, major, minor] = match.map(Number);
  return major > 22 || (major === 22 && minor >= 12);
}

export function compatibleNodeRuntime(executable, { runCommand = spawnSync } = {}) {
  try {
    const result = runCommand(executable, ["-p", "process.versions.node"], {
      encoding: "utf8",
      timeout: 5000,
      windowsHide: true,
    });
    const ok = result?.ok === true || (!result?.error && result?.status === 0);
    return ok && relayNodeVersionSupported(result?.stdout ?? result?.out);
  } catch {
    return false;
  }
}

export function stableNodePath(execPath = process.execPath, {
  platform = process.platform,
  realpath = (p) => fs.realpathSync(p),
  existsSync = fs.existsSync,
  runCommand = spawnSync,
  env = process.env,
} = {}) {
  if (!execPath) return execPath;
  let realExec;
  try {
    realExec = realpath(execPath);
  } catch {
    realExec = execPath;
  }
  // Already a stable, non-version-managed path — keep it.
  const volatile = VERSION_MANAGED_NODE_RE.test(execPath)
    || VERSION_MANAGED_NODE_RE.test(realExec)
    || isTemporaryNodePath(execPath, { platform, realpathSync: realpath });
  if (!volatile) return execPath;
  const candidates = ["/opt/homebrew/bin/node", "/usr/local/bin/node", "/usr/bin/node"];
  const delimiter = String(execPath).includes("/") ? ":" : path.delimiter;
  const pathApi = String(execPath).includes("/") ? path.posix : path;
  for (const directory of String(env?.PATH || "").split(delimiter).filter(Boolean)) {
    candidates.push(pathApi.join(directory, String(execPath).includes("/") ? "node" : "node.exe"));
  }
  // Prefer a public path whose realpath matches the running runtime exactly.
  for (const candidate of candidates) {
    try {
      if (
        existsSync(candidate)
        && !isTemporaryNodePath(candidate, { platform, realpathSync: realpath })
        && realpath(candidate) === realExec
      ) return candidate;
    } catch {}
  }
  // Otherwise prefer an existing public, non-version-managed Node only when it
  // can actually run this Relay release. A durable Node 20 path is not an
  // upgrade from a volatile but compatible Node 22/24 path.
  for (const candidate of candidates) {
    try {
      if (
        existsSync(candidate) &&
        !VERSION_MANAGED_NODE_RE.test(candidate) &&
        !isTemporaryNodePath(candidate, { platform, realpathSync: realpath }) &&
        compatibleNodeRuntime(candidate, { runCommand })
      ) return candidate;
    } catch {}
  }
  return execPath;
}

export function persistentNodePath(execPath = process.execPath, {
  platform = process.platform,
  homeDir = os.homedir(),
  runtimeRoot = path.join(homeDir, ".relay", "runtime"),
  realpath = (p) => fs.realpathSync(p),
  existsSync = fs.existsSync,
  runCommand = spawnSync,
  env = process.env,
  fsImpl = fs,
} = {}) {
  const stable = stableNodePath(execPath, { platform, realpath, existsSync, runCommand, env });
  const durable = platform === "linux"
    ? durableNodePath(stable, { platform, runtimeRoot, fsImpl })
    : stable;
  return relayOwnedNodePath(durable, {
    platform,
    runtimeRoot,
    realpathSync: realpath,
    runCommand,
    fsImpl,
  });
}

function plistEscape(value) {
  return String(value)
    .replaceAll("&", "&")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;");
}

function shellSingleQuote(value) {
  return `'${String(value).replaceAll("'", "'\\''")}'`;
}

export function systemdExecQuote(value) {
  const text = String(value ?? "");
  if (/[\0\r\n]/.test(text)) throw new Error("systemd arguments cannot contain control characters");
  return `"${text
    .replaceAll("\\", "\\\\")
    .replaceAll('"', '\\"')
    .replaceAll("$", () => "$$")
    .replaceAll("%", () => "%%")}"`;
}

export function systemdUnitQuote(value) {
  const text = String(value ?? "");
  if (/[\0\r\n]/.test(text)) throw new Error("systemd values cannot contain control characters");
  return `"${text
    .replaceAll("\\", "\\\\")
    .replaceAll('"', '\\"')
    .replaceAll("%", () => "%%")}"`;
}

export function desktopExecQuote(value) {
  const text = String(value ?? "");
  if (/[\0\r\n]/.test(text)) throw new Error("desktop entry arguments cannot contain control characters");
  return `"${text
    // Desktop Entry Exec parsing has two escaping layers. Four file-level
    // backslashes represent one literal backslash; a literal dollar needs two.
    .replaceAll("\\", "\\\\\\\\")
    .replaceAll('"', '\\"')
    .replaceAll("`", "\\`")
    .replaceAll("$", "\\\\$")
    .replaceAll("%", () => "%%")}"`;
}

export function linuxDesktopPaths({ homeDir = os.homedir(), env = process.env } = {}) {
  const configHome = env.XDG_CONFIG_HOME || path.posix.join(homeDir, ".config");
  const dataHome = env.XDG_DATA_HOME || path.posix.join(homeDir, ".local", "share");
  return {
    daemonUnitPath: path.posix.join(configHome, "systemd", "user", LINUX_DAEMON_UNIT),
    pillUnitPath: path.posix.join(configHome, "systemd", "user", LINUX_PILL_UNIT),
    applicationPath: path.posix.join(dataHome, "applications", LINUX_DESKTOP_FILE),
    autostartPath: path.posix.join(configHome, "autostart", LINUX_AUTOSTART_FILE),
    pillStarterPath: path.posix.join(homeDir, ".relay", "bin", "relay-pill-start"),
    daemonLogPath: path.posix.join(homeDir, ".relay", "daemon.log"),
    pillLogPath: path.posix.join(homeDir, ".relay", "pill.log"),
  };
}

function linuxPathEnvironment(env = process.env) {
  return env.PATH || "/usr/local/bin:/usr/bin:/bin";
}

function linuxServiceEnvironment({ homeDir, env = process.env } = {}) {
  const values = {
    HOME: homeDir,
    PATH: linuxPathEnvironment(env),
    XDG_CONFIG_HOME: env.XDG_CONFIG_HOME,
    XDG_DATA_HOME: env.XDG_DATA_HOME,
    RELAY_CONFIG: env.RELAY_CONFIG,
    RELAY_CONFIG_DIR: env.RELAY_CONFIG_DIR,
    RELAY_HOME: env.RELAY_HOME,
    RELAY_COMPANION_HOME: env.RELAY_COMPANION_HOME,
    CLAUDE_HOME: env.CLAUDE_HOME,
    CODEX_HOME: env.CODEX_HOME,
    CLAUDE_CLI_PATH: env.CLAUDE_CLI_PATH,
    CODEX_CLI_PATH: env.CODEX_CLI_PATH,
    RELAY_TERMINAL: env.RELAY_TERMINAL,
    TERMINAL: env.TERMINAL,
    CHROME_DEVEL_SANDBOX: env.CHROME_DEVEL_SANDBOX,
    LANG: env.LANG,
    LANGUAGE: env.LANGUAGE,
    LC_ALL: env.LC_ALL,
    LC_CTYPE: env.LC_CTYPE,
    SSH_AUTH_SOCK: env.SSH_AUTH_SOCK,
    SSL_CERT_FILE: env.SSL_CERT_FILE,
    SSL_CERT_DIR: env.SSL_CERT_DIR,
    NODE_EXTRA_CA_CERTS: env.NODE_EXTRA_CA_CERTS,
  };
  return Object.entries(values)
    .filter(([, value]) => value)
    .map(([name, value]) => `Environment=${systemdUnitQuote(`${name}=${value}`)}`)
    .join("\n");
}

export function linuxDaemonUnitText({ bin, node, homeDir = os.homedir(), env = process.env } = {}) {
  const paths = linuxDesktopPaths({ homeDir, env });
  const relayBinIdentity = Buffer.from(String(bin), "utf8").toString("base64url");
  return `[Unit]
Description=Relay Companion background service
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
# RelayBinBase64=${relayBinIdentity}
ExecStart=${[node, "--max-old-space-size=128", bin, "daemon"].map(systemdExecQuote).join(" ")}
Restart=always
RestartSec=3
${linuxServiceEnvironment({ homeDir, env })}
StandardOutput=${systemdUnitQuote(`append:${paths.daemonLogPath}`)}
StandardError=${systemdUnitQuote(`append:${paths.daemonLogPath}`)}

[Install]
WantedBy=default.target
`;
}

export function linuxPillUnitText({ electronPath, overlayMain, homeDir = os.homedir(), env = process.env } = {}) {
  const paths = linuxDesktopPaths({ homeDir, env });
  return `[Unit]
Description=Relay desktop pill
After=graphical-session.target
PartOf=graphical-session.target

[Service]
Type=simple
ExecStart=${[electronPath, overlayMain].map(systemdExecQuote).join(" ")}
Restart=on-failure
RestartSec=3
${linuxServiceEnvironment({ homeDir, env })}
StandardOutput=${systemdUnitQuote(`append:${paths.pillLogPath}`)}
StandardError=${systemdUnitQuote(`append:${paths.pillLogPath}`)}
`;
}

export function linuxPillStarterText() {
  return `#!/bin/sh
systemctl --user import-environment DISPLAY WAYLAND_DISPLAY XAUTHORITY DBUS_SESSION_BUS_ADDRESS XDG_CURRENT_DESKTOP XDG_SESSION_TYPE XDG_RUNTIME_DIR SSH_AUTH_SOCK SSL_CERT_FILE SSL_CERT_DIR NODE_EXTRA_CA_CERTS LANG LANGUAGE LC_ALL LC_CTYPE >/dev/null 2>&1 || true
systemctl --user restart ${LINUX_DAEMON_UNIT} >/dev/null 2>&1 || true
exec systemctl --user restart ${LINUX_PILL_UNIT}
`;
}

export function linuxApplicationDesktopText({ node, bin, iconPath } = {}) {
  return `[Desktop Entry]
Type=Application
Version=1.0
Name=Relay
Comment=Open the Relay desktop companion
Exec=${desktopExecQuote(node)} ${desktopExecQuote(bin)} pill %u
Icon=${iconPath}
Terminal=false
StartupNotify=true
StartupWMClass=work.relay.companion
Categories=Utility;Network;
MimeType=x-scheme-handler/relay;
`;
}

export function linuxAutostartDesktopText({ pillStarterPath } = {}) {
  return `[Desktop Entry]
Type=Application
Version=1.0
Name=Relay
Comment=Start the Relay desktop companion
Exec=${desktopExecQuote(pillStarterPath)}
Terminal=false
StartupNotify=false
X-GNOME-Autostart-enabled=true
`;
}

function writeLinuxDesktopFile(file, contents, mode = 0o600) {
  writeTextAtomic(file, contents);
  try { fs.chmodSync(file, mode); } catch {}
}

function importLinuxGraphicalEnvironment(runCommand, env = process.env) {
  const names = [
    "DISPLAY",
    "WAYLAND_DISPLAY",
    "XAUTHORITY",
    "DBUS_SESSION_BUS_ADDRESS",
    "XDG_CURRENT_DESKTOP",
    "XDG_SESSION_TYPE",
    "XDG_RUNTIME_DIR",
    "SSH_AUTH_SOCK",
    "SSL_CERT_FILE",
    "SSL_CERT_DIR",
    "NODE_EXTRA_CA_CERTS",
    "LANG",
    "LANGUAGE",
    "LC_ALL",
    "LC_CTYPE",
  ].filter((name) => env[name]);
  if (!names.length) return { ok: true, skipped: true };
  return runCommand("systemctl", ["--user", "import-environment", ...names]);
}

/** Start the registered Linux services without creating an unsupervised pill. */
export function startLinuxDesktopServices({ runCommand = run, env = process.env, restartPill = false } = {}) {
  const imported = importLinuxGraphicalEnvironment(runCommand, env);
  if (!imported.ok) {
    return { ok: false, reason: "graphical_environment_import_failed", detail: imported.out };
  }
  for (const unit of [LINUX_DAEMON_UNIT, LINUX_PILL_UNIT]) {
    const verb = unit === LINUX_PILL_UNIT && restartPill ? "restart" : "start";
    const started = runCommand("systemctl", ["--user", verb, unit]);
    if (!started.ok) return { ok: false, reason: "linux_service_start_failed", unit, detail: started.out };
  }
  return { ok: true };
}

/**
 * Make a user unit resolvable even when Relay's XDG_CONFIG_HOME differs from
 * the environment captured by the long-lived systemd user manager. `link`
 * is idempotent in effect; an existing standard-path unit may make that call
 * nonzero, so the authoritative check is FragmentPath after daemon-reload.
 */
export function linkLinuxUserUnit(unitPath, unit, { runCommand = run, realpathSync = fs.realpathSync } = {}) {
  runCommand("systemctl", ["--user", "link", path.posix.resolve(unitPath)]);
  const reloaded = runCommand("systemctl", ["--user", "daemon-reload"]);
  if (!reloaded.ok) {
    return { ok: false, reason: "systemd_user_unavailable", detail: reloaded.out, unitPath };
  }
  const shown = runCommand("systemctl", ["--user", "show", "--property=FragmentPath", "--value", unit]);
  const fragmentPath = String(shown.out || "").trim();
  let fragmentMatches = false;
  if (shown.ok && fragmentPath) {
    try { fragmentMatches = realpathSync(fragmentPath) === realpathSync(unitPath); }
    catch { fragmentMatches = path.posix.resolve(fragmentPath) === path.posix.resolve(unitPath); }
  }
  if (!fragmentMatches) {
    return {
      ok: false,
      reason: "systemd_unit_resolution_failed",
      detail: shown.out || `systemd resolved ${unit} to ${fragmentPath || "nothing"}, expected ${unitPath}`,
      unitPath,
      fragmentPath,
    };
  }
  const cat = runCommand("systemctl", ["--user", "cat", unit]);
  if (!cat.ok) {
    return { ok: false, reason: "systemd_unit_resolution_failed", detail: cat.out, unitPath, fragmentPath };
  }
  return { ok: true, unitPath, fragmentPath };
}

export function registerLinuxProtocolHandler(applicationPath, { runCommand = run } = {}) {
  // Cache refresh is optional on minimal desktops; xdg-mime itself is the
  // portable contract and must round-trip before setup says links can open.
  runCommand("update-desktop-database", [path.dirname(applicationPath)]);
  const registered = runCommand("xdg-mime", ["default", LINUX_DESKTOP_FILE, "x-scheme-handler/relay"]);
  if (!registered.ok) {
    return { ok: false, reason: "protocol_handler_registration_failed", detail: registered.out, applicationPath };
  }
  const queried = runCommand("xdg-mime", ["query", "default", "x-scheme-handler/relay"]);
  const handler = String(queried.out || "").trim();
  if (!queried.ok || handler !== LINUX_DESKTOP_FILE) {
    return {
      ok: false,
      reason: "protocol_handler_verification_failed",
      detail: queried.out || `Linux selected ${handler || "no application"} for relay: links.`,
      applicationPath,
      handler,
    };
  }
  return { ok: true, applicationPath, handler };
}

export function removeLinuxRelayMimeDefaults({
  homeDir = os.homedir(),
  env = process.env,
  readFileSync = fs.readFileSync,
  existsSync = fs.existsSync,
  writeFile = writeTextAtomic,
} = {}) {
  const paths = linuxDesktopPaths({ homeDir, env });
  const candidates = [
    path.posix.join(env.XDG_CONFIG_HOME || path.posix.join(homeDir, ".config"), "mimeapps.list"),
    path.posix.join(path.dirname(paths.applicationPath), "mimeapps.list"),
  ];
  const changed = [];
  for (const file of [...new Set(candidates)]) {
    if (!existsSync(file)) continue;
    let text;
    try { text = String(readFileSync(file, "utf8")); } catch { continue; }
    let section = "";
    let touched = false;
    const next = text.split(/(?<=\n)/).map((line) => {
      const sectionMatch = line.match(/^\s*\[([^\]]+)\]\s*(?:\r?\n)?$/);
      if (sectionMatch) {
        section = sectionMatch[1];
        return line;
      }
      if (!["Default Applications", "Added Associations"].includes(section)) return line;
      const match = line.match(/^(\s*x-scheme-handler\/relay\s*=\s*)([^\r\n]*)(\r?\n)?$/);
      if (!match) return line;
      const values = match[2].split(";").map((value) => value.trim()).filter(Boolean);
      const kept = values.filter((value) => ![LINUX_DESKTOP_FILE, LINUX_LEGACY_DESKTOP_FILE].includes(value));
      if (kept.length === values.length) return line;
      touched = true;
      return kept.length ? `${match[1]}${kept.join(";")};${match[3] || ""}` : "";
    }).join("");
    if (touched) {
      writeFile(file, next);
      changed.push(file);
    }
  }
  return { ok: true, changed };
}

/** Refuse Linux setup before host registrations when its lifecycle contract is unavailable. */
export function linuxDesktopPreflight({
  platform = process.platform,
  arch = process.arch,
  env = process.env,
  glibcVersion = process.report?.getReport?.()?.header?.glibcVersionRuntime || "",
  runCommand = run,
} = {}) {
  if (platform !== "linux") return { ok: true, skipped: true };
  if (!["x64", "arm64"].includes(arch)) {
    return { ok: false, reason: "linux_arch_unsupported", detail: `Relay supports Linux x64 and arm64, not ${arch}.` };
  }
  if (!glibcVersion) {
    return { ok: false, reason: "linux_libc_unsupported", detail: "Relay requires a glibc-based Linux distribution." };
  }
  if (!env.DISPLAY && !env.WAYLAND_DISPLAY) {
    return {
      ok: false,
      reason: "linux_graphical_session_missing",
      detail: "Relay setup must run inside a graphical X11 or Wayland login session.",
    };
  }
  if (env.WAYLAND_DISPLAY && !env.DISPLAY) {
    return {
      ok: false,
      reason: "linux_xwayland_unavailable",
      detail: "Relay currently needs XWayland in a Wayland login because its desktop pill must move and resize. Install or enable XWayland, then run setup again.",
    };
  }
  const systemd = runCommand("systemctl", ["--user", "show-environment"]);
  if (!systemd.ok) {
    return {
      ok: false,
      reason: "systemd_user_unavailable",
      detail: systemd.out || "Relay requires an available systemd user manager.",
    };
  }
  for (const dependency of [
    { command: "systemd-run", args: ["--version"], packageName: "systemd", purpose: "safe background updates" },
    { command: "xdg-mime", args: ["--version"], packageName: "xdg-utils", purpose: "Relay link registration" },
  ]) {
    const checked = runCommand(dependency.command, dependency.args);
    if (!checked.ok) {
      return {
        ok: false,
        reason: "linux_desktop_dependency_missing",
        detail: `Relay needs ${dependency.command} for ${dependency.purpose}. Install your distribution's ${dependency.packageName} package, then run setup again.`,
        command: dependency.command,
        packageName: dependency.packageName,
      };
    }
  }
  return { ok: true, glibcVersion, session: env.WAYLAND_DISPLAY ? "wayland-xwayland" : "x11" };
}

function linuxUserNamespacesRestricted({ readFileSync = fs.readFileSync } = {}) {
  const restrictedWhenOne = ["/proc/sys/kernel/apparmor_restrict_unprivileged_userns"];
  const restrictedWhenZero = [
    "/proc/sys/kernel/unprivileged_userns_clone",
    "/proc/sys/user/max_user_namespaces",
  ];
  for (const pathname of restrictedWhenOne) {
    try {
      if (String(readFileSync(pathname, "utf8")).trim() === "1") return true;
    } catch {}
  }
  for (const pathname of restrictedWhenZero) {
    try {
      if (String(readFileSync(pathname, "utf8")).trim() === "0") return true;
    } catch {}
  }
  return false;
}

function sha256File(file, { readFileSync = fs.readFileSync } = {}) {
  return createHash("sha256").update(readFileSync(file)).digest("hex");
}

function trustedLinuxSandbox(pathname, expectedSha256, {
  lstatSync = fs.lstatSync,
  realpathSync = fs.realpathSync,
  readFileSync = fs.readFileSync,
} = {}) {
  try {
    const root = "/usr/local/lib/relay/chromium-sandboxes";
    const resolved = path.posix.resolve(pathname);
    const relative = path.posix.relative(root, resolved);
    if (!/^[a-f0-9]{64}\/chrome-sandbox$/.test(relative)) return false;
    for (const directory of ["/usr/local", "/usr/local/lib", "/usr/local/lib/relay", root, path.posix.dirname(resolved)]) {
      const directoryStat = lstatSync(directory);
      if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink() || directoryStat.uid !== 0 || (directoryStat.mode & 0o022) !== 0) {
        return false;
      }
      if (path.posix.resolve(realpathSync(directory)) !== directory) return false;
    }
    const stat = lstatSync(resolved);
    return stat.isFile() && !stat.isSymbolicLink() && path.posix.resolve(realpathSync(resolved)) === resolved &&
      stat.uid === 0 && (stat.mode & 0o4777) === 0o4755 &&
      sha256File(pathname, { readFileSync }) === expectedSha256;
  } catch {
    return false;
  }
}

export function linkLinuxElectronSandbox({
  source,
  destination,
  expectedSha256,
  lstatSync = fs.lstatSync,
  realpathSync = fs.realpathSync,
  readFileSync = fs.readFileSync,
  symlinkSync = fs.symlinkSync,
  renameSync = fs.renameSync,
  rmSync = fs.rmSync,
  random = randomBytes,
} = {}) {
  if (!trustedLinuxSandbox(destination, expectedSha256, { lstatSync, realpathSync, readFileSync })) {
    throw new Error("Cannot activate an untrusted Electron sandbox helper");
  }
  const sourceStat = lstatSync(source);
  if (sourceStat.isSymbolicLink() && path.posix.resolve(realpathSync(source)) === path.posix.resolve(destination)) {
    return { ok: true, linked: true, source, destination };
  }
  if (!sourceStat.isFile() || sourceStat.isSymbolicLink() || sha256File(source, { readFileSync }) !== expectedSha256) {
    throw new Error(`Refusing to replace an unexpected Electron sandbox helper: ${source}`);
  }
  const temporary = `${source}.relay-link-${process.pid}-${random(8).toString("hex")}`;
  try {
    symlinkSync(destination, temporary);
    const temporaryStat = lstatSync(temporary);
    if (!temporaryStat.isSymbolicLink() || path.posix.resolve(realpathSync(temporary)) !== path.posix.resolve(destination)) {
      throw new Error("Temporary Electron sandbox link failed validation");
    }
    renameSync(temporary, source);
  } finally {
    try { rmSync(temporary, { force: true }); } catch {}
  }
  const linkedStat = lstatSync(source);
  if (!linkedStat.isSymbolicLink() || path.posix.resolve(realpathSync(source)) !== path.posix.resolve(destination)) {
    throw new Error("Electron sandbox helper link failed activation");
  }
  return { ok: true, linked: true, source, destination };
}

function linuxAuthorizationEnvironment(env = process.env) {
  const allowed = [
    "HOME", "USER", "LOGNAME", "LANG", "LANGUAGE", "TERM",
    "DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "DBUS_SESSION_BUS_ADDRESS",
    "XDG_RUNTIME_DIR", "XDG_CURRENT_DESKTOP", "XDG_SESSION_TYPE",
  ];
  const next = { PATH: "/usr/sbin:/usr/bin:/sbin:/bin" };
  for (const name of allowed) {
    const value = env?.[name];
    if (value != null && value !== "" && !/\0|\r|\n/.test(String(value))) next[name] = String(value);
  }
  for (const [name, value] of Object.entries(env || {})) {
    if (/^LC_[A-Z0-9_]+$/i.test(name) && value != null && value !== "" && !/\0|\r|\n/.test(String(value))) {
      next[name] = String(value);
    }
  }
  return next;
}

/** Install Electron's exact helper into a root-owned, user-nonwritable location. */
export function ensureLinuxElectronSandbox({
  electronPath,
  env = process.env,
  allowAuthorization = true,
  onAuthorization = () => {},
  runCommand = run,
  readFileSync = fs.readFileSync,
  lstatSync = fs.lstatSync,
  realpathSync = fs.realpathSync,
  geteuid = () => (typeof process.geteuid === "function" ? process.geteuid() : -1),
  linkSandbox = linkLinuxElectronSandbox,
} = {}) {
  const source = path.posix.join(path.posix.dirname(String(electronPath || "")), "chrome-sandbox");
  let expectedSha256;
  try { expectedSha256 = sha256File(source, { readFileSync }); }
  catch {
    return { ok: false, reason: "linux_sandbox_helper_missing", detail: "Relay's exact Chromium sandbox helper is missing." };
  }
  const destination = `/usr/local/lib/relay/chromium-sandboxes/${expectedSha256}/chrome-sandbox`;
  if (!trustedLinuxSandbox(destination, expectedSha256, { lstatSync, realpathSync, readFileSync })) {
    if (!allowAuthorization) {
      return {
        ok: false,
        reason: "linux_sandbox_update_authorization_required",
        detail: "This Electron update needs a refreshed Linux sandbox helper. Run `relay update` in a desktop terminal to approve it.",
        source,
        destination,
      };
    }
    onAuthorization({ source, destination });
    // Copy once into a root-only temporary file, hash that immutable copy, and
    // only then grant setuid. The source lives in a user tree, so hashing before
    // the privileged copy would leave a replacement race and a privilege bug.
    const script = [
      "set -eu",
      "src=$1", "dst=$2", "expected=$3",
      "parent=$(/usr/bin/dirname -- \"$dst\")",
      "d=/usr/local; [ ! -L \"$d\" ]; [ \"$(/usr/bin/readlink -f -- \"$d\")\" = \"$d\" ]; [ \"$(/usr/bin/stat -c %u -- \"$d\")\" = 0 ]; [ -z \"$(/usr/bin/find \"$d\" -maxdepth 0 -perm /022 -print -quit)\" ]",
      "d=/usr/local/lib; [ ! -L \"$d\" ]; /usr/bin/install -d -o root -g root -m 0755 -- \"$d\"; [ \"$(/usr/bin/readlink -f -- \"$d\")\" = \"$d\" ]; [ \"$(/usr/bin/stat -c %u -- \"$d\")\" = 0 ]; [ -z \"$(/usr/bin/find \"$d\" -maxdepth 0 -perm /022 -print -quit)\" ]",
      "for d in /usr/local/lib/relay /usr/local/lib/relay/chromium-sandboxes \"$parent\"; do [ ! -L \"$d\" ]; /usr/bin/install -d -o root -g root -m 0755 -- \"$d\"; [ \"$(/usr/bin/readlink -f -- \"$d\")\" = \"$d\" ]; done",
      "tmp=\"$parent/.chrome-sandbox.$$\"",
      "trap '/usr/bin/rm -f -- \"$tmp\"' EXIT HUP INT TERM",
      "/usr/bin/install -o root -g root -m 0755 -- \"$src\" \"$tmp\"",
      "actual=$(/usr/bin/sha256sum \"$tmp\")", "actual=${actual%% *}",
      "[ \"$actual\" = \"$expected\" ]",
      "/usr/bin/chmod 4755 \"$tmp\"",
      "/usr/bin/mv -fT -- \"$tmp\" \"$dst\"",
      "trap - EXIT HUP INT TERM",
    ].join("; ");
    const command = geteuid() === 0 ? "/bin/sh" : "pkexec";
    const args = geteuid() === 0
      ? ["-c", script, "relay-sandbox-install", source, destination, expectedSha256]
      : ["/bin/sh", "-c", script, "relay-sandbox-install", source, destination, expectedSha256];
    const installed = runCommand(command, args, {
      timeoutMs: 2 * 60_000,
      env: linuxAuthorizationEnvironment(env),
    });
    if (!installed.ok || !trustedLinuxSandbox(destination, expectedSha256, { lstatSync, realpathSync, readFileSync })) {
      return {
        ok: false,
        reason: "linux_sandbox_authorization_failed",
        detail: installed.missing && command === "pkexec"
          ? "Relay could not find pkexec. Install your distribution's polkit/pkexec package, then run `relay update` again."
          : installed.out || "Relay needs one administrator approval to install its exact Chromium sandbox helper.",
        source,
        destination,
      };
    }
  }
  try {
    linkSandbox({ source, destination, expectedSha256, lstatSync, realpathSync, readFileSync });
  } catch (error) {
    return {
      ok: false,
      reason: "linux_sandbox_activation_failed",
      detail: error instanceof Error ? error.message : "Relay could not activate Electron's trusted sandbox helper.",
      source,
      destination,
    };
  }
  env.CHROME_DEVEL_SANDBOX = destination;
  return { ok: true, source, destination, sha256: expectedSha256, linked: true };
}

/** Prepare direct Electron launches on hosts that cannot use user namespaces. */
export function prepareLinuxElectronSandbox({
  platform = process.platform,
  electronPath,
  env = process.env,
  readFileSync = fs.readFileSync,
  ...options
} = {}) {
  if (platform !== "linux") {
    return { ok: true, skipped: true };
  }
  const source = path.posix.join(path.posix.dirname(String(electronPath || "")), "chrome-sandbox");
  try {
    const expectedSha256 = sha256File(source, { readFileSync });
    const destination = `/usr/local/lib/relay/chromium-sandboxes/${expectedSha256}/chrome-sandbox`;
    if (trustedLinuxSandbox(destination, expectedSha256, { ...options, readFileSync })) {
      const linkSandbox = options.linkSandbox || linkLinuxElectronSandbox;
      try {
        linkSandbox({
          source,
          destination,
          expectedSha256,
          readFileSync,
          lstatSync: options.lstatSync,
          realpathSync: options.realpathSync,
        });
      } catch (error) {
        return {
          ok: false,
          reason: "linux_sandbox_activation_failed",
          detail: error instanceof Error ? error.message : "Relay could not activate Electron's trusted sandbox helper.",
          source,
          destination,
        };
      }
      env.CHROME_DEVEL_SANDBOX = destination;
      return { ok: true, source, destination, sha256: expectedSha256, discovered: true, linked: true };
    }
  } catch {}
  if (!linuxUserNamespacesRestricted({ readFileSync })) return { ok: true, skipped: true };
  return ensureLinuxElectronSandbox({ electronPath, env, readFileSync, ...options });
}

/** Prove the exact Electron binary can render securely before setup mutates host state. */
export function linuxElectronRuntimePreflight({
  platform = process.platform,
  electronPath,
  probePath,
  env = process.env,
  runCommand = run,
  readFileSync = fs.readFileSync,
  onAuthorization = () => {},
} = {}) {
  if (platform !== "linux") return { ok: true, skipped: true };
  if (!electronPath || !probePath) {
    return { ok: false, reason: "linux_electron_runtime_missing", detail: "Relay's Linux desktop runtime is incomplete." };
  }

  const linked = runCommand("ldd", [electronPath], { timeoutMs: 20_000 });
  const missing = String(linked.out || "")
    .split(/\r?\n/)
    .filter((line) => /=>\s*not found\s*$/i.test(line))
    .map((line) => line.trim().split(/\s+/)[0])
    .filter(Boolean);
  if (missing.length) {
    return {
      ok: false,
      reason: "linux_desktop_libraries_missing",
      detail: `Relay needs Linux desktop libraries that are not installed: ${[...new Set(missing)].join(", ")}.`,
      missing,
    };
  }
  if (!linked.ok) {
    return {
      ok: false,
      reason: "linux_desktop_library_check_failed",
      detail: linked.out || "Relay could not inspect the Electron runtime's shared libraries.",
    };
  }

  let launched = runCommand(electronPath, [probePath], { timeoutMs: 30_000, env });
  const restricted = linuxUserNamespacesRestricted({ readFileSync });
  const launchFailed = !launched.ok || !String(launched.out || "").includes("relay-electron-ready");
  const sandboxFailure = restricted || /(?:sandbox|user namespace|move to new namespace)/i.test(String(launched.out || ""));
  if (launchFailed && sandboxFailure) {
    const sandbox = ensureLinuxElectronSandbox({ electronPath, env, runCommand, readFileSync, onAuthorization });
    if (!sandbox.ok) return sandbox;
    launched = runCommand(electronPath, [probePath], { timeoutMs: 30_000, env });
  }
  if (!launched.ok || !String(launched.out || "").includes("relay-electron-ready")) {
    return {
      ok: false,
      reason: sandboxFailure ? "linux_electron_sandbox_launch_failed" : "linux_electron_launch_failed",
      detail: launched.out || "Relay's desktop window could not start in this graphical session.",
    };
  }
  return { ok: true };
}

function installLinuxDaemonAutostart({ bin, node, runCommand, reload, homeDir, env = process.env }) {
  const paths = linuxDesktopPaths({ homeDir, env });
  writeLinuxDesktopFile(paths.daemonUnitPath, linuxDaemonUnitText({ bin, node, homeDir, env }));
  const linked = linkLinuxUserUnit(paths.daemonUnitPath, LINUX_DAEMON_UNIT, { runCommand });
  if (!linked.ok) return { ...linked, logPath: paths.daemonLogPath, started: false };
  const imported = importLinuxGraphicalEnvironment(runCommand, env);
  if (!imported.ok) {
    return {
      ok: false,
      reason: "linux_session_environment_import_failed",
      detail: imported.out,
      unitPath: paths.daemonUnitPath,
      logPath: paths.daemonLogPath,
      started: false,
    };
  }
  if (!reload) {
    const enabled = runCommand("systemctl", ["--user", "enable", LINUX_DAEMON_UNIT]);
    return {
      ok: enabled.ok,
      ...(enabled.ok ? {} : { reason: "daemon_service_enable_failed", detail: enabled.out }),
      unitPath: paths.daemonUnitPath,
      logPath: paths.daemonLogPath,
      started: false,
    };
  }
  const enabled = runCommand("systemctl", ["--user", "enable", "--now", LINUX_DAEMON_UNIT]);
  return {
    ok: enabled.ok,
    ...(enabled.ok ? {} : { reason: "daemon_service_start_failed", detail: enabled.out }),
    unitPath: paths.daemonUnitPath,
    logPath: paths.daemonLogPath,
    started: enabled.ok,
  };
}

function installLinuxPillAutostart({ bin, node, electronPath, overlayMain, runCommand, reload, homeDir, env = process.env }) {
  const paths = linuxDesktopPaths({ homeDir, env });
  const iconPath = path.posix.join(packageRootForBin(bin, "linux"), "overlay", "relayAppIcon.svg");
  writeLinuxDesktopFile(paths.pillUnitPath, linuxPillUnitText({ electronPath, overlayMain, homeDir, env }));
  writeLinuxDesktopFile(paths.pillStarterPath, linuxPillStarterText(), 0o700);
  writeLinuxDesktopFile(paths.applicationPath, linuxApplicationDesktopText({ node, bin, iconPath }));
  writeLinuxDesktopFile(paths.autostartPath, linuxAutostartDesktopText({ pillStarterPath: paths.pillStarterPath }));
  const linked = linkLinuxUserUnit(paths.pillUnitPath, LINUX_PILL_UNIT, { runCommand });
  if (!linked.ok) return { ...linked, ...paths, started: false };
  const protocol = registerLinuxProtocolHandler(paths.applicationPath, { runCommand });
  if (!protocol.ok) return { ...protocol, ...paths, started: false };
  if (!reload) {
    return {
      ok: true,
      unitPath: paths.pillUnitPath,
      applicationPath: paths.applicationPath,
      autostartPath: paths.autostartPath,
      logPath: paths.pillLogPath,
      started: false,
    };
  }
  importLinuxGraphicalEnvironment(runCommand, env);
  const started = runCommand("systemctl", ["--user", "restart", LINUX_PILL_UNIT]);
  return {
    ok: started.ok,
    ...(started.ok ? {} : { reason: "pill_service_start_failed", detail: started.out }),
    unitPath: paths.pillUnitPath,
    applicationPath: paths.applicationPath,
    autostartPath: paths.autostartPath,
    logPath: paths.pillLogPath,
    electronPath,
    overlayMain,
    started: started.ok,
  };
}

function packageVersion(packageRoot) {
  try {
    const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8"));
    return typeof pkg.version === "string" && pkg.version ? pkg.version : "unknown";
  } catch {
    return "unknown";
  }
}

export function exactCompanionSpec(version) {
  const normalized = String(version || "").trim();
  if (!/^\d+\.\d+\.\d+$/.test(normalized)) {
    throw new Error(`Relay requires an exact published version, received: ${normalized || "missing"}`);
  }
  return `${PACKAGE_NAME}@${normalized}`;
}

export function npmInstallArgs({ version, prefix = null, global = false } = {}) {
  const args = ["install", "--ignore-scripts", "--no-audit", "--no-fund"];
  if (global) args.push("-g");
  if (prefix) args.push("--prefix", prefix);
  args.push(exactCompanionSpec(version));
  return args;
}

function npmInvocation(args, {
  env = process.env,
  execPath = process.execPath,
  platform = process.platform,
  existsSync = fs.existsSync,
} = {}) {
  const candidates = [
    env.npm_execpath,
    path.resolve(path.dirname(execPath), "../lib/node_modules/npm/bin/npm-cli.js"),
    path.resolve(path.dirname(execPath), "node_modules/npm/bin/npm-cli.js"),
  ].filter(Boolean);
  const npmCli = candidates.find((candidate) => {
    try { return existsSync(candidate); } catch { return false; }
  });
  if (npmCli) return { command: execPath, args: [npmCli, ...args] };
  return { command: platform === "win32" ? "npm.cmd" : "npm", args };
}

function runNpm(args, options = {}) {
  const invocation = npmInvocation(args);
  return run(invocation.command, invocation.args, options);
}

function isRelayOwnedMacApp(appPath) {
  try {
    const info = fs.readFileSync(path.join(appPath, "Contents", "Info.plist"), "utf8");
    return new RegExp(
      `<key>\\s*CFBundleIdentifier\\s*</key>\\s*<string>\\s*${RELAY_MAC_BUNDLE_IDENTIFIER.replaceAll(".", "\\.")}\\s*</string>`,
    ).test(info);
  } catch {
    return false;
  }
}

function chooseRelayMacAppPath(applicationsDir) {
  const candidates = [RELAY_MAC_APP_NAME, RELAY_MAC_APP_FALLBACK_NAME].map((name) => path.join(applicationsDir, name));
  // Keep using an existing Relay-owned fallback bundle even if the primary name
  // later becomes free. Otherwise a repair would create a second Spotlight result
  // and strand the still-valid fallback app.
  return (
    candidates.find((candidate) => fs.existsSync(candidate) && isRelayOwnedMacApp(candidate)) ||
    candidates.find((candidate) => !fs.existsSync(candidate)) ||
    null
  );
}

function buildRelayMacIcon(packageRoot, resourcesDir, runCommand) {
  const sourceSvg = path.join(packageRoot, "overlay", "relayAppIcon.svg");
  const fallbackPng = path.join(packageRoot, "overlay", "relayTrayTemplate@2x.png");
  const iconsetDir = path.join(resourcesDir, "RelayIcon.iconset");
  const masterPng = path.join(resourcesDir, "RelayIcon-1024.png");
  const icnsPath = path.join(resourcesDir, "RelayIcon.icns");
  if (fs.existsSync(sourceSvg)) {
    fs.mkdirSync(iconsetDir, { recursive: true });
    const converted = runCommand("/usr/bin/sips", ["-s", "format", "png", sourceSvg, "--out", masterPng]);
    if (converted.ok && fs.existsSync(masterPng)) {
      const renditions = [
        ["icon_16x16.png", 16],
        ["icon_16x16@2x.png", 32],
        ["icon_32x32.png", 32],
        ["icon_32x32@2x.png", 64],
        ["icon_128x128.png", 128],
        ["icon_128x128@2x.png", 256],
        ["icon_256x256.png", 256],
        ["icon_256x256@2x.png", 512],
        ["icon_512x512.png", 512],
        ["icon_512x512@2x.png", 1024],
      ];
      let rendered = true;
      for (const [name, size] of renditions) {
        const result = runCommand("/usr/bin/sips", [
          "-z",
          String(size),
          String(size),
          masterPng,
          "--out",
          path.join(iconsetDir, name),
        ]);
        if (!result.ok) rendered = false;
      }
      if (rendered) {
        const packed = runCommand("/usr/bin/iconutil", ["-c", "icns", iconsetDir, "-o", icnsPath]);
        if (packed.ok && fs.existsSync(icnsPath)) {
          fs.rmSync(iconsetDir, { recursive: true, force: true });
          fs.rmSync(masterPng, { force: true });
          return "RelayIcon.icns";
        }
      }
    }
  }
  fs.rmSync(iconsetDir, { recursive: true, force: true });
  fs.rmSync(masterPng, { force: true });
  if (fs.existsSync(fallbackPng)) {
    fs.copyFileSync(fallbackPng, path.join(resourcesDir, "RelayIcon.png"));
    return "RelayIcon.png";
  }
  return null;
}

/**
 * Build the small, real macOS application users can find in Spotlight and open to
 * bring Relay back. The launcher deliberately exits after starting Electron in the
 * background: every LaunchServices open then becomes a fresh single-instance signal
 * to the long-running pill, instead of an activation event getting lost in a shell
 * wrapper process.
 */
export function installRelayMacApp({
  bin = relayBinPath(),
  electronPath,
  overlayMain,
  homeDir = os.homedir(),
  runCommand = run,
} = {}) {
  if (!electronPath || !overlayMain || !fs.existsSync(electronPath) || !fs.existsSync(overlayMain)) {
    return { ok: false, reason: "pill_runtime_missing", electronPath, overlayMain };
  }
  const packageRoot = packageRootForBin(bin);
  const applicationsDir = path.join(homeDir, "Applications");
  const appPath = chooseRelayMacAppPath(applicationsDir);
  if (!appPath) {
    return {
      ok: false,
      reason: "relay_app_name_conflict",
      appPaths: [RELAY_MAC_APP_NAME, RELAY_MAC_APP_FALLBACK_NAME].map((name) => path.join(applicationsDir, name)),
    };
  }
  // Keep the staging bundle's final extension as .app. osacompile decides whether
  // to produce an application bundle from the output extension; a generic .tmp
  // path would produce a flat compiled script instead. Nest it under a hidden,
  // same-volume directory so Spotlight cannot transiently expose a duplicate app.
  const stagingDir = path.join(applicationsDir, `.relay-app-staging-${process.pid}-${Date.now()}`);
  const tmpPath = path.join(stagingDir, RELAY_MAC_APP_NAME);
  const contentsDir = path.join(tmpPath, "Contents");
  const macosDir = path.join(contentsDir, "MacOS");
  const resourcesDir = path.join(contentsDir, "Resources");
  const appletExecutablePath = path.join(macosDir, "applet");
  const launcherPath = path.join(resourcesDir, "relay-launcher.sh");
  const logPath = path.join(homeDir, ".relay", "pill.log");
  const pillStatusPath = path.join(homeDir, ".relay-companion", "pill-status.json");
  const pillPlistPath = path.join(homeDir, "Library", "LaunchAgents", `${PILL_LAUNCH_LABEL}.plist`);
  const version = packageVersion(packageRoot);

  try {
    fs.rmSync(stagingDir, { recursive: true, force: true });
    fs.mkdirSync(applicationsDir, { recursive: true });
    fs.mkdirSync(stagingDir, { recursive: true });
    fs.mkdirSync(path.dirname(logPath), { recursive: true });

    // LaunchServices rejects a shell script used directly as CFBundleExecutable
    // (NSOSStatusErrorDomain -10669 on current macOS), even when it has a valid
    // shebang and executable mode. Generate the system AppleScript applet wrapper
    // so the bundle has a native universal Mach-O executable. The applet only runs
    // our resource shell script; it does not automate or inspect another app.
    const appletSource = [
      "on run",
      '  set launcherPath to POSIX path of (path to resource "relay-launcher.sh")',
      '  do shell script "/bin/sh " & quoted form of launcherPath',
      "end run",
    ].join("\n");
    const compiled = runCommand("/usr/bin/osacompile", ["-e", appletSource, "-o", tmpPath]);
    if (!compiled.ok || !fs.existsSync(appletExecutablePath)) {
      throw new Error(`could not compile native Relay app launcher${compiled.out ? `: ${compiled.out}` : ""}`);
    }
    fs.mkdirSync(resourcesDir, { recursive: true });

    const launcher = [
      "#!/bin/sh",
      'nonce="app-$$-$(date +%s)"',
      'domain="gui/$(id -u)"',
      `service="$domain/${PILL_LAUNCH_LABEL}"`,
      `plist=${shellSingleQuote(pillPlistPath)}`,
      `status=${shellSingleQuote(pillStatusPath)}`,
      // If an agent unloaded Relay rather than merely killing it, restore the
      // launchd-owned process first. Otherwise a standalone Electron process would
      // work temporarily but later fight KeepAlive when repair/bootstrap runs.
      'if ! /bin/launchctl print "$service" >/dev/null 2>&1 && [ -f "$plist" ]; then',
      '  /bin/launchctl bootstrap "$domain" "$plist" >/dev/null 2>&1 || true',
      "fi",
      '/bin/launchctl kickstart "$service" >/dev/null 2>&1 || true',
      // launchd's `state = running` only means exec started; it can be reported
      // before Electron acquires the single-instance lock. Wait for the owner's
      // atomic ready acknowledgement instead, and verify its pid is still alive.
      // The nonce-bearing launch below then signals a real owner rather than
      // racing it and accidentally becoming an orphan outside launchd.
      "i=0",
      'while [ "$i" -lt 80 ]; do',
      '  owner_pid=$(/usr/bin/plutil -extract pid raw -o - "$status" 2>/dev/null || true)',
      '  owner_ready=$(/usr/bin/plutil -extract ready raw -o - "$status" 2>/dev/null || true)',
      '  if [ "$owner_ready" = "true" ] && [ -n "$owner_pid" ] && /bin/kill -0 "$owner_pid" 2>/dev/null; then',
      "    break",
      "  fi",
      "  /bin/sleep 0.1",
      '  i=$((i + 1))',
      "done",
      `nohup ${shellSingleQuote(electronPath)} ${shellSingleQuote(overlayMain)} --relay-reopen "$nonce" >> ${shellSingleQuote(logPath)} 2>&1 &`,
      "exit 0",
      "",
    ].join("\n");
    fs.writeFileSync(launcherPath, launcher, { mode: 0o755 });
    fs.chmodSync(launcherPath, 0o755);

    const iconName = buildRelayMacIcon(packageRoot, resourcesDir, runCommand);
    const iconEntry = iconName
      ? `  <key>CFBundleIconFile</key><string>${iconName}</string>\n`
      : "";
    const info = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>CFBundleName</key><string>Relay</string>
  <key>CFBundleDisplayName</key><string>Relay</string>
  <key>CFBundleExecutable</key><string>applet</string>
  <key>CFBundleIdentifier</key><string>${RELAY_MAC_BUNDLE_IDENTIFIER}</string>
  <key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
  <key>CFBundlePackageType</key><string>APPL</string>
  <key>CFBundleSignature</key><string>aplt</string>
  <key>CFBundleShortVersionString</key><string>${plistEscape(version)}</string>
  <key>CFBundleVersion</key><string>${plistEscape(version)}</string>
${iconEntry}  <key>LSUIElement</key><true/>
  <key>NSHighResolutionCapable</key><true/>
  <key>OSAAppletShowStartupScreen</key><false/>
</dict>
</plist>
`;
    fs.writeFileSync(path.join(contentsDir, "Info.plist"), info);
    fs.writeFileSync(path.join(contentsDir, "PkgInfo"), "APPL????");

    // osacompile signs the initial applet, so adding our launcher, icon, and final
    // Info.plist invalidates that temporary signature. Re-sign the completed bundle
    // and verify it before exposing it to LaunchServices; otherwise a bundle can be
    // indexed by Spotlight yet still fail when the user clicks it.
    const signed = runCommand("/usr/bin/codesign", ["--force", "--deep", "--sign", "-", tmpPath]);
    if (!signed.ok) {
      throw new Error(`could not sign native Relay app launcher${signed.out ? `: ${signed.out}` : ""}`);
    }
    const verified = runCommand("/usr/bin/codesign", ["--verify", "--deep", "--strict", tmpPath]);
    if (!verified.ok) {
      throw new Error(`could not verify native Relay app launcher${verified.out ? `: ${verified.out}` : ""}`);
    }

    // Replace the Relay-owned bundle atomically enough that Spotlight never indexes
    // a half-written app. LaunchServices/Spotlight refreshes are best-effort; merely
    // placing a valid .app under ~/Applications is sufficient on a normal macOS host.
    // Re-check ownership at the destructive boundary in case another process placed
    // or replaced an unrelated app at the chosen path while the bundle was compiling.
    if (fs.existsSync(appPath) && !isRelayOwnedMacApp(appPath)) {
      throw new Error("Relay app target changed during installation");
    }
    fs.rmSync(appPath, { recursive: true, force: true });
    fs.renameSync(tmpPath, appPath);
    fs.rmSync(stagingDir, { recursive: true, force: true });
    runCommand("/usr/bin/touch", [appPath]);
    runCommand(
      "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister",
      ["-f", appPath]
    );
    runCommand("/usr/bin/mdimport", [appPath]);
    return {
      ok: true,
      appPath,
      launcherPath: path.join(appPath, "Contents", "Resources", "relay-launcher.sh"),
      appletExecutablePath: path.join(appPath, "Contents", "MacOS", "applet"),
    };
  } catch (error) {
    try {
      fs.rmSync(stagingDir, { recursive: true, force: true });
    } catch {}
    return {
      ok: false,
      reason: "relay_app_install_failed",
      appPath,
      detail: error && error.message ? error.message : String(error),
    };
  }
}

/**
 * A STABLE path to the companion CLI that the agents and the daemon can keep
 * launching. When `setup` runs from npx (an ephemeral cache that gets cleaned),
 * we global-install the package and point at that instead, so the registration
 * doesn't rot. From a dev checkout or an existing global install the running
 * path is already stable, so we use it directly.
 */
export function resolveStableBin() {
  const here = relayBinPath();
  const version = packageVersion(packageRootForBin(here));
  const spec = exactCompanionSpec(version);
  const ephemeral = /[\\/](_npx|\.npm[\\/]_npx|npm-cache[\\/]_npx)[\\/]/.test(here);
  if (!ephemeral) return { bin: here, stable: true, version, spec };
  // Prefer a real global install.
  const install = runNpm(npmInstallArgs({ version, global: true }), {
    timeoutMs: npmInstallTimeoutMs(),
  });
  if (install.ok) {
    const rootResult = runNpm(["root", "-g"]);
    const root = rootResult.ok ? rootResult.out.split(/\r?\n/).at(-1)?.trim() : "";
    const globalBin = path.join(root, ...PACKAGE_NAME.split("/"), "bin", "relay.js");
    if (root && fs.existsSync(globalBin)) return { bin: globalBin, stable: true, version, spec };
  }
  // Global install failed (permissions, no prefix). Install into a relay-owned prefix
  // under ~/.relay/lib — no sudo, survives npx-cache pruning — rather than pointing the
  // daemon/MCP registrations at the ephemeral _npx path (which vanishes when the cache
  // is cleaned, silently killing the daemon later).
  const prefix = path.join(os.homedir(), ".relay", "lib");
  try {
    fs.mkdirSync(prefix, { recursive: true });
  } catch {}
  const localInstall = runNpm(npmInstallArgs({ version, prefix }), {
    timeoutMs: npmInstallTimeoutMs(),
  });
  const localBin = path.join(prefix, "node_modules", ...PACKAGE_NAME.split("/"), "bin", "relay.js");
  if (localInstall.ok && fs.existsSync(localBin)) return { bin: localBin, stable: true, version, spec };
  // Last resort: the ephemeral path. Flag it so callers can warn the user that the
  // registration may rot when the npx cache is pruned.
  return { bin: here, stable: false, version, spec };
}

function npmInstallTimeoutMs(env = process.env) {
  const parsed = Number(env.RELAY_NPM_INSTALL_TIMEOUT_MS);
  return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_NPM_INSTALL_TIMEOUT_MS;
}

function run(cmd, args, { timeoutMs = 20_000, env = process.env } = {}) {
  // windowsHide keeps `schtasks`/`npm` helper spawns from flashing a console window
  // when this runs from a windowless parent (the daemon's repair path).
  const res = spawnSync(cmd, args, { encoding: "utf8", timeout: timeoutMs, windowsHide: true, env });
  return {
    ok: !res.error && res.status === 0,
    status: res.status,
    out: `${res.stdout || ""}${res.stderr || ""}`.trim(),
    missing: Boolean(res.error && res.error.code === "ENOENT"),
  };
}

/**
 * run(), without blocking the event loop: the same {ok, status, out, missing}
 * shape, resolved when the process exits. For callers that live inside an
 * event loop someone is looking at (the pill's Electron main process) and
 * need to wait on a helper that may legitimately take several seconds.
 */
function runAsync(cmd, args, { timeoutMs = 20_000 } = {}) {
  return new Promise((resolve) => {
    let out = "";
    let settled = false;
    const finish = (value) => {
      if (settled) return;
      settled = true;
      resolve(value);
    };
    let child;
    try {
      child = spawn(cmd, args, { windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
    } catch (error) {
      finish({ ok: false, status: null, out: String(error && error.message), missing: error && error.code === "ENOENT" });
      return;
    }
    const timer = setTimeout(() => {
      try { child.kill(); } catch {}
      finish({ ok: false, status: null, out: `${out}\n(timed out after ${timeoutMs}ms)`.trim(), missing: false });
    }, timeoutMs);
    child.stdout.on("data", (d) => { out += String(d); });
    child.stderr.on("data", (d) => { out += String(d); });
    child.on("error", (error) => {
      clearTimeout(timer);
      finish({ ok: false, status: null, out: String(error && error.message), missing: error && error.code === "ENOENT" });
    });
    child.on("close", (status) => {
      clearTimeout(timer);
      finish({ ok: status === 0, status, out: out.trim(), missing: false });
    });
  });
}

export function claudeCodeConfigPath() {
  return process.env.CLAUDE_CODE_CONFIG || path.join(os.homedir(), ".claude.json");
}

export function codexConfigPath() {
  if (process.env.CODEX_CONFIG) return process.env.CODEX_CONFIG;
  const home = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
  return path.join(home, "config.toml");
}

export function codexHooksPath() {
  if (process.env.CODEX_HOOKS) return process.env.CODEX_HOOKS;
  const home = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
  return path.join(home, "hooks.json");
}

function readJsonObject(file) {
  const text = fs.readFileSync(file, "utf8");
  const parsed = JSON.parse(text);
  return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
}

function privateConfigMode(file) {
  try {
    // Preserve an owner read-only file, but never carry group/other access into
    // the replacement. These host configs can contain unrelated MCP secrets.
    return (fs.statSync(file).mode & 0o600) || 0o600;
  } catch {
    return 0o600;
  }
}

function writeJsonAtomic(file, value) {
  fs.mkdirSync(path.dirname(file), { recursive: true });
  const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
  try {
    const mode = privateConfigMode(file);
    fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode });
    try { fs.chmodSync(tmp, mode); } catch {}
    fs.renameSync(tmp, file);
  } finally {
    try { fs.rmSync(tmp, { force: true }); } catch {}
  }
}

function writeTextAtomic(file, value) {
  fs.mkdirSync(path.dirname(file), { recursive: true });
  const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
  try {
    const mode = privateConfigMode(file);
    fs.writeFileSync(tmp, value, { mode });
    try { fs.chmodSync(tmp, mode); } catch {}
    fs.renameSync(tmp, file);
  } finally {
    try { fs.rmSync(tmp, { force: true }); } catch {}
  }
}

export function writeClaudeCodeMcpConfig(
  bin = relayBinPath(),
  node = stableNodePath(),
  configPath = claudeCodeConfigPath(),
) {
  if (!fs.existsSync(configPath)) return { ok: false, reason: "claude_config_missing", configPath };
  try {
    const cfg = readJsonObject(configPath);
    const existingServers =
      cfg.mcpServers && typeof cfg.mcpServers === "object" && !Array.isArray(cfg.mcpServers)
        ? cfg.mcpServers
        : {};
    const launch = mcpLaunchCommand({ mcpBin: bin, node });
    cfg.mcpServers = {
      ...existingServers,
      relay: {
        type: "stdio",
        command: launch.command,
        args: launch.args,
        env: launch.env,
        alwaysLoad: true,
      },
    };
    writeJsonAtomic(configPath, cfg);
    return { ok: true, method: "config", configPath };
  } catch (error) {
    return {
      ok: false,
      reason: "claude_config_write_failed",
      configPath,
      detail: error && error.message ? error.message : String(error),
    };
  }
}

export function removeClaudeCodeMcpConfig(configPath = claudeCodeConfigPath()) {
  if (!fs.existsSync(configPath)) return { ok: true, configPath };
  try {
    const cfg = readJsonObject(configPath);
    if (cfg.mcpServers && typeof cfg.mcpServers === "object" && !Array.isArray(cfg.mcpServers)) {
      delete cfg.mcpServers.relay;
      writeJsonAtomic(configPath, cfg);
    }
    return { ok: true, configPath };
  } catch (error) {
    return {
      ok: false,
      reason: "claude_config_write_failed",
      configPath,
      detail: error && error.message ? error.message : String(error),
    };
  }
}

// ---- Claude Code hooks (Open-in-current-chat runtime) ----------------------
//
// `relay claude-hook` (src/claude-hook.js) must run on PostToolUse / Stop /
// UserPromptSubmit / SessionStart so a pill "Open in current chat" click can be
// delivered into the user's LIVE Claude session mid-turn, at turn end, or on
// the next prompt. Claude Code hot-reloads settings hooks on change, so the
// registration takes effect without restarting Claude.

const CLAUDE_HOOK_EVENTS = ["PostToolUse", "Stop", "UserPromptSubmit", "SessionStart"];
// These are Relay's own bounded AI-session capabilities. Claude otherwise pauses
// a background/idle agent for an interactive MCP approval that no foreground UI
// exists to answer, which makes agent-to-agent delivery look completed while the
// target is actually stuck. Install both current names and the two legacy aliases
// so chats created before a companion upgrade can still finish cleanly.
const RELAY_CLAUDE_ALLOWED_TOOLS = [
  "mcp__relay__relay_ai_sessions",
  "mcp__relay__relay_ai_session",
  "mcp__relay__relay_sessions",
  "mcp__relay__relay_session",
];
// Ours is identifiable by the command containing "relay.js claude-hook"
// (quoting-tolerant): install replaces exactly these, uninstall removes ONLY these.
const RELAY_CLAUDE_HOOK_COMMAND_RE = /relay\.js["']?\s+claude-hook(?:\s|$)/;

export function claudeSettingsPath() {
  return (
    process.env.CLAUDE_SETTINGS ||
    path.join(process.env.CLAUDE_HOME || path.join(os.homedir(), ".claude"), "settings.json")
  );
}

/**
 * True when Claude looks present on this machine, judged ONLY by the directory
 * that holds claudeSettingsPath() (so CLAUDE_SETTINGS / CLAUDE_HOME overrides are
 * respected for tests). Deliberately not a `claude` CLI probe: Windows installs
 * routinely have Claude Code/Desktop with ~/.claude configured by hand and no
 * `claude` on PATH, and those installs still need the hook runtime.
 */
export function claudeAppearsPresent({ settingsPath = claudeSettingsPath() } = {}) {
  try {
    return fs.existsSync(path.dirname(settingsPath));
  } catch {
    return false;
  }
}

function shellArg(value) {
  const clean = String(value);
  return /\s/.test(clean) ? `"${clean.replaceAll('"', '\\"')}"` : clean;
}

function stableHookCommand(hookInvocation, hookName) {
  const parts = [hookInvocation.command, ...hookInvocation.argsPrefix];
  // Claude runs command hooks through a POSIX-compatible shell on Windows as
  // well. Bare Windows paths lose their backslashes there (C:\\Windows becomes
  // C:Windows), so quote every launcher argument on every platform.
  const quoted = parts.map(shellSingleQuote);
  // Keep the final hook name unquoted for Relay 0.1.57's ownership regex.
  return [...quoted, hookName].join(" ");
}

export function claudeHookCommand(bin = relayBinPath(), node = stableNodePath()) {
  return `${shellArg(node)} ${shellArg(bin)} claude-hook`;
}

export function claudeHookHandler(bin = relayBinPath(), node = stableNodePath(), hookInvocation = null) {
  if (hookInvocation) {
    return {
      type: "command",
      // Keep the complete `relay.js claude-hook` signature in command, not
      // only in exec-form args. Relay 0.1.57-era downgrade/uninstall code knew
      // only how to identify the shell form; retaining it prevents duplicate
      // handlers when a fleet rolls back.
      command: stableHookCommand(hookInvocation, "claude-hook"),
      timeout: 5,
    };
  }
  return {
    type: "command",
    command: String(node),
    args: [String(bin), "claude-hook"],
    timeout: 5,
  };
}

export function isRelayClaudeHookCommand(commandOrHook) {
  if (commandOrHook && typeof commandOrHook === "object") {
    const args = Array.isArray(commandOrHook.args) ? commandOrHook.args.map(String) : [];
    const script = String(args.at(-2) || "").replaceAll("\\", "/");
    if (args.at(-1) === "claude-hook" && script.endsWith("/relay.js")) return true;
    return RELAY_CLAUDE_HOOK_COMMAND_RE.test(String(commandOrHook.command || ""));
  }
  return RELAY_CLAUDE_HOOK_COMMAND_RE.test(String(commandOrHook || ""));
}

// Strip our hook from a settings hook-entry list, preserving everything the
// user put there. An entry that mixes our hook with user hooks keeps the user
// hooks; an entry that only carried ours is dropped.
function withoutRelayClaudeHooks(entries) {
  const kept = [];
  for (const entry of Array.isArray(entries) ? entries : []) {
    if (!entry || typeof entry !== "object" || !Array.isArray(entry.hooks)) {
      kept.push(entry);
      continue;
    }
    const keptHooks = entry.hooks.filter((hook) => !isRelayClaudeHookCommand(hook));
    if (keptHooks.length === entry.hooks.length) kept.push(entry);
    else if (keptHooks.length > 0) kept.push({ ...entry, hooks: keptHooks });
  }
  return kept;
}

/**
 * Merge the Relay claude-hook into ~/.claude/settings.json (path overridable via
 * CLAUDE_SETTINGS / CLAUDE_HOME for tests). Idempotent — reinstalling never
 * duplicates — and preserves all user content; creates the file if missing.
 */
export function installClaudeHooks(
  bin = relayBinPath(),
  node = stableNodePath(),
  { settingsPath = claudeSettingsPath(), hookInvocation = null } = {},
) {
  const handler = claudeHookHandler(bin, node, hookInvocation);
  let settings = {};
  if (fs.existsSync(settingsPath)) {
    try {
      settings = readJsonObject(settingsPath);
    } catch (error) {
      // Never clobber a settings file we cannot parse — the user's own hooks
      // and permissions live in it.
      return {
        ok: false,
        reason: "claude_settings_unreadable",
        settingsPath,
        detail: error && error.message ? error.message : String(error),
      };
    }
  }
  try {
    const permissions =
      settings.permissions && typeof settings.permissions === "object" && !Array.isArray(settings.permissions)
        ? settings.permissions
        : {};
    const allowed = Array.isArray(permissions.allow) ? permissions.allow : [];
    permissions.allow = [...allowed];
    for (const tool of RELAY_CLAUDE_ALLOWED_TOOLS) {
      if (!permissions.allow.includes(tool)) permissions.allow.push(tool);
    }
    settings.permissions = permissions;
    const hooks =
      settings.hooks && typeof settings.hooks === "object" && !Array.isArray(settings.hooks) ? settings.hooks : {};
    for (const event of CLAUDE_HOOK_EVENTS) {
      const cleaned = withoutRelayClaudeHooks(hooks[event]);
      const installedHandler = Array.isArray(handler.args) ? { ...handler, args: [...handler.args] } : { ...handler };
      cleaned.push({ matcher: "*", hooks: [installedHandler] });
      hooks[event] = cleaned;
    }
    settings.hooks = hooks;
    writeJsonAtomic(settingsPath, settings);
    return {
      ok: true,
      settingsPath,
      command: handler.command,
      ...(Array.isArray(handler.args) ? { args: [...handler.args] } : {}),
      events: [...CLAUDE_HOOK_EVENTS],
    };
  } catch (error) {
    return {
      ok: false,
      reason: "claude_settings_write_failed",
      settingsPath,
      detail: error && error.message ? error.message : String(error),
    };
  }
}

/** Install Claude hooks through the upgrade-surviving bridge. */
export function installClaudeHooksWithStableLauncher(
  bin = relayBinPath(),
  node = stableNodePath(),
  { homeDir = os.homedir(), platform = process.platform, ...options } = {},
) {
  try {
    const hookInvocation = ensureStableHookLauncher({ targetBin: bin, node, homeDir, platform });
    return installClaudeHooks(bin, node, { ...options, hookInvocation });
  } catch (error) {
    return { ok: false, reason: "hook_launcher_write_failed", detail: error?.message || String(error) };
  }
}

/** Remove ONLY the Relay claude-hook entries; every user hook survives. */
export function uninstallClaudeHooks({ settingsPath = claudeSettingsPath() } = {}) {
  if (!fs.existsSync(settingsPath)) return { ok: true, settingsPath };
  let settings;
  try {
    settings = readJsonObject(settingsPath);
  } catch (error) {
    return {
      ok: false,
      reason: "claude_settings_unreadable",
      settingsPath,
      detail: error && error.message ? error.message : String(error),
    };
  }
  const hooks = settings.hooks;
  let changed = false;
  const permissions = settings.permissions;
  if (permissions && typeof permissions === "object" && !Array.isArray(permissions) && Array.isArray(permissions.allow)) {
    const kept = permissions.allow.filter((tool) => !RELAY_CLAUDE_ALLOWED_TOOLS.includes(tool));
    if (kept.length !== permissions.allow.length) {
      changed = true;
      if (kept.length) permissions.allow = kept;
      else delete permissions.allow;
    }
  }
  if (!hooks || typeof hooks !== "object" || Array.isArray(hooks)) {
    if (changed) writeJsonAtomic(settingsPath, settings);
    return { ok: true, settingsPath };
  }
  // Sweep every event key (not just the four we install today) so entries from
  // older/newer Relay versions are removed too.
  for (const event of Object.keys(hooks)) {
    if (!Array.isArray(hooks[event])) continue;
    const before = hooks[event];
    const after = withoutRelayClaudeHooks(before);
    if (after.length === before.length && after.every((entry, index) => entry === before[index])) continue;
    changed = true;
    if (after.length === 0) delete hooks[event];
    else hooks[event] = after;
  }
  if (!changed) return { ok: true, settingsPath };
  if (Object.keys(hooks).length === 0) delete settings.hooks;
  try {
    writeJsonAtomic(settingsPath, settings);
    return { ok: true, settingsPath, removed: true };
  } catch (error) {
    return {
      ok: false,
      reason: "claude_settings_write_failed",
      settingsPath,
      detail: error && error.message ? error.message : String(error),
    };
  }
}

// ---- Codex hooks (private recent Relay context) ----------------------------

const CODEX_RELAY_CONTEXT_EVENTS = ["UserPromptSubmit", "PostToolUse", "Stop"];
const RELAY_CODEX_HOOK_COMMAND_RE = /relay\.js["']?\s+codex-hook(?:\s|$)/;

export function codexHookCommand(bin = relayBinPath(), node = stableNodePath(), hookInvocation = null) {
  if (hookInvocation) {
    return stableHookCommand(hookInvocation, "codex-hook");
  }
  return `${shellArg(node)} ${shellArg(bin)} codex-hook`;
}

export function isRelayCodexHookCommand(command) {
  return RELAY_CODEX_HOOK_COMMAND_RE.test(String(command || ""));
}

function withoutRelayCodexHooks(entries) {
  const kept = [];
  for (const entry of Array.isArray(entries) ? entries : []) {
    if (!entry || typeof entry !== "object" || !Array.isArray(entry.hooks)) {
      kept.push(entry);
      continue;
    }
    const keptHooks = entry.hooks.filter((hook) => !isRelayCodexHookCommand(hook && hook.command));
    if (keptHooks.length === entry.hooks.length) kept.push(entry);
    else if (keptHooks.length) kept.push({ ...entry, hooks: keptHooks });
  }
  return kept;
}

/** Merge Relay's hooks into ~/.codex/hooks.json without replacing user hooks. */
export function installCodexHooks(
  bin = relayBinPath(),
  node = stableNodePath(),
  { hooksPath = codexHooksPath(), hookInvocation = null } = {},
) {
  const command = codexHookCommand(bin, node, hookInvocation);
  let config = {};
  if (fs.existsSync(hooksPath)) {
    try {
      config = readJsonObject(hooksPath);
    } catch (error) {
      return {
        ok: false,
        reason: "codex_hooks_unreadable",
        hooksPath,
        detail: error && error.message ? error.message : String(error),
      };
    }
  }
  try {
    const before = JSON.stringify(config);
    const hooks = config.hooks && typeof config.hooks === "object" && !Array.isArray(config.hooks)
      ? config.hooks
      : {};
    for (const event of CODEX_RELAY_CONTEXT_EVENTS) {
      const cleaned = withoutRelayCodexHooks(hooks[event]);
      cleaned.push({ matcher: "*", hooks: [{ type: "command", command, timeout: 5 }] });
      hooks[event] = cleaned;
    }
    config.hooks = hooks;
    const changed = JSON.stringify(config) !== before;
    if (changed) writeJsonAtomic(hooksPath, config);
    return {
      ok: true,
      hooksPath,
      command,
      events: [...CODEX_RELAY_CONTEXT_EVENTS],
      changed,
      requiresTrustReview: changed,
    };
  } catch (error) {
    return {
      ok: false,
      reason: "codex_hooks_write_failed",
      hooksPath,
      detail: error && error.message ? error.message : String(error),
    };
  }
}

/** Install Codex hooks through the upgrade-surviving bridge. */
export function installCodexHooksWithStableLauncher(
  bin = relayBinPath(),
  node = stableNodePath(),
  { homeDir = os.homedir(), platform = process.platform, ...options } = {},
) {
  try {
    const hookInvocation = ensureStableHookLauncher({ targetBin: bin, node, homeDir, platform });
    return installCodexHooks(bin, node, { ...options, hookInvocation });
  } catch (error) {
    return { ok: false, reason: "hook_launcher_write_failed", detail: error?.message || String(error) };
  }
}

function jsonFileHasRelayHook(filePath, predicate) {
  if (!fs.existsSync(filePath)) return false;
  const config = readJsonObject(filePath);
  const hooks = config.hooks;
  if (!hooks || typeof hooks !== "object" || Array.isArray(hooks)) return false;
  for (const entries of Object.values(hooks)) {
    for (const entry of Array.isArray(entries) ? entries : []) {
      for (const hook of entry && Array.isArray(entry.hooks) ? entry.hooks : []) {
        if (predicate(hook)) return true;
      }
    }
  }
  return false;
}

function unreadableFileLooksRelayOwned(filePath, hookName) {
  try {
    const source = fs.readFileSync(filePath, "utf8");
    return new RegExp(`relay\\.js[\\s\\S]{0,512}${hookName}`).test(source);
  } catch {
    return false;
  }
}

/**
 * Move only already-installed Relay hooks onto the stable bridge. This is the
 * updater/repair migration path: it never creates a host config merely because
 * Relay happens to be updating on the machine.
 */
export function repairExistingAgentHooks({
  bin = relayBinPath(),
  node = stableNodePath(),
  homeDir = os.homedir(),
  platform = process.platform,
  claudeSettingsFile = process.env.CLAUDE_SETTINGS
    || path.join(process.env.CLAUDE_HOME || path.join(homeDir, ".claude"), "settings.json"),
  codexHooksFile = process.env.CODEX_HOOKS
    || path.join(process.env.CODEX_HOME || path.join(homeDir, ".codex"), "hooks.json"),
} = {}) {
  try {
    node = persistentNodePath(node, { platform, homeDir });
  } catch (error) {
    return { ok: false, reason: "node_runtime_preservation_failed", detail: error?.message || String(error) };
  }
  let claudeInstalled = false;
  let codexInstalled = false;
  try {
    claudeInstalled = jsonFileHasRelayHook(claudeSettingsFile, isRelayClaudeHookCommand);
  } catch (error) {
    if (unreadableFileLooksRelayOwned(claudeSettingsFile, "claude-hook")) {
      return { ok: false, attempted: false, reason: "claude_settings_unreadable", detail: error?.message || String(error) };
    }
  }
  try {
    codexInstalled = jsonFileHasRelayHook(
      codexHooksFile,
      (hook) => isRelayCodexHookCommand(hook && hook.command),
    );
  } catch (error) {
    if (unreadableFileLooksRelayOwned(codexHooksFile, "codex-hook")) {
      return { ok: false, attempted: false, reason: "codex_hooks_unreadable", detail: error?.message || String(error) };
    }
  }
  if (!claudeInstalled && !codexInstalled) return { ok: true, attempted: false };

  let hookInvocation;
  try {
    hookInvocation = ensureStableHookLauncher({ targetBin: bin, node, homeDir, platform });
  } catch (error) {
    return { ok: false, attempted: true, reason: "hook_launcher_write_failed", detail: error?.message || String(error) };
  }
  const claudeHooks = claudeInstalled
    ? installClaudeHooks(bin, node, { settingsPath: claudeSettingsFile, hookInvocation })
    : null;
  const codexHooks = codexInstalled
    ? installCodexHooks(bin, node, { hooksPath: codexHooksFile, hookInvocation })
    : null;
  return {
    ok: Boolean((!claudeHooks || claudeHooks.ok) && (!codexHooks || codexHooks.ok)),
    attempted: true,
    hookInvocation,
    claudeHooks,
    codexHooks,
  };
}

/** Remove only Relay command handlers from Codex hooks.json. */
export function uninstallCodexHooks({ hooksPath = codexHooksPath() } = {}) {
  if (!fs.existsSync(hooksPath)) return { ok: true, hooksPath };
  let config;
  try {
    config = readJsonObject(hooksPath);
  } catch (error) {
    return {
      ok: false,
      reason: "codex_hooks_unreadable",
      hooksPath,
      detail: error && error.message ? error.message : String(error),
    };
  }
  const hooks = config.hooks;
  if (!hooks || typeof hooks !== "object" || Array.isArray(hooks)) return { ok: true, hooksPath };
  let changed = false;
  for (const event of Object.keys(hooks)) {
    if (!Array.isArray(hooks[event])) continue;
    const before = hooks[event];
    const after = withoutRelayCodexHooks(before);
    if (after.length === before.length && after.every((entry, index) => entry === before[index])) continue;
    changed = true;
    if (after.length) hooks[event] = after;
    else delete hooks[event];
  }
  if (!changed) return { ok: true, hooksPath };
  if (Object.keys(hooks).length === 0) delete config.hooks;
  try {
    writeJsonAtomic(hooksPath, config);
    return { ok: true, hooksPath, removed: true };
  } catch (error) {
    return {
      ok: false,
      reason: "codex_hooks_write_failed",
      hooksPath,
      detail: error && error.message ? error.message : String(error),
    };
  }
}

export function hookInstallNotices({ claudeHooks = null, codexHooks = null } = {}) {
  const notices = [];
  for (const [host, result] of [["Claude Code", claudeHooks], ["Codex", codexHooks]]) {
    if (!result || result.ok) continue;
    const detail = [result.reason, result.detail].filter(Boolean).join(": ");
    notices.push(`Could not install Relay hooks for ${host}${detail ? ` (${detail})` : ""}.`);
  }
  if (codexHooks?.ok && codexHooks.requiresTrustReview) {
    notices.push("Codex requires one final step: open `/hooks` in Codex and trust the Relay hook.");
  }
  return notices;
}

function tomlQuote(value) {
  return JSON.stringify(String(value));
}

export const RELAY_CODEX_DIRECT_NAMESPACE = "mcp__relay";

function tomlTableBounds(text, tableName) {
  const lines = String(text || "").split(/\r?\n/);
  const header = `[${tableName}]`;
  const startLine = lines.findIndex((line) => line.trim() === header);
  if (startLine === -1) return null;

  let endLine = startLine + 1;
  while (endLine < lines.length && !/^\s*\[[^\]]+\]\s*$/.test(lines[endLine])) endLine += 1;
  return {
    before: lines.slice(0, startLine).join("\n"),
    table: lines.slice(startLine, endLine).join("\n"),
    after: lines.slice(endLine).join("\n"),
  };
}

function parseTomlStringArray(value) {
  const source = String(value || "");
  let index = 0;
  while (/\s/.test(source[index] || "")) index += 1;
  if (source[index] !== "[") throw new Error("expected a TOML string array");
  index += 1;

  const values = [];
  while (index < source.length) {
    while (index < source.length) {
      while (/\s|,/.test(source[index] || "")) index += 1;
      if (source[index] !== "#") break;
      while (index < source.length && source[index] !== "\n") index += 1;
    }
    if (source[index] === "]") return { values, consumed: index + 1 };
    const quote = source[index];
    if (quote !== '"' && quote !== "'") throw new Error("expected quoted TOML array values");

    const start = index;
    index += 1;
    let escaped = false;
    while (index < source.length) {
      const char = source[index];
      if (quote === '"' && char === "\\" && !escaped) {
        escaped = true;
        index += 1;
        continue;
      }
      if (char === quote && !escaped) break;
      escaped = false;
      index += 1;
    }
    if (source[index] !== quote) throw new Error("unterminated TOML string array value");
    const token = source.slice(start, index + 1);
    values.push(quote === '"' ? JSON.parse(token) : token.slice(1, -1));
    index += 1;
  }
  throw new Error("unterminated TOML string array");
}

function formatTomlStringArray(values) {
  return `[${values.map(tomlQuote).join(", ")}]`;
}

/** Add or remove one string in a TOML array without rewriting unrelated user config. */
export function updateTomlStringArray(text, tableName, key, value, { remove = false } = {}) {
  const source = String(text || "");
  const bounds = tomlTableBounds(source, tableName);
  if (!bounds) {
    if (remove) return source;
    const prefix = source.trimEnd();
    const table = `[${tableName}]\n${key} = ${formatTomlStringArray([value])}`;
    return `${prefix}${prefix ? "\n\n" : ""}${table}\n`;
  }

  const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  const assignment = new RegExp(`^(\\s*)${escapedKey}\\s*=`, "m").exec(bounds.table);
  if (!assignment) {
    if (remove) return source;
    const table = `${bounds.table.trimEnd()}\n${key} = ${formatTomlStringArray([value])}`;
    return [bounds.before, table, bounds.after].filter(Boolean).join("\n").replace(/\n*$/, "\n");
  }

  const valueStart = assignment.index + assignment[0].length;
  const parsed = parseTomlStringArray(bounds.table.slice(valueStart));
  const current = [...new Set(parsed.values.map(String))];
  const next = remove
    ? current.filter((candidate) => candidate !== value)
    : current.includes(value) ? current : [...current, value];
  const valueEnd = valueStart + parsed.consumed;
  let table;
  if (remove && next.length === 0) {
    const lineStart = bounds.table.lastIndexOf("\n", assignment.index - 1) + 1;
    const followingNewline = bounds.table.indexOf("\n", valueEnd);
    const lineEnd = followingNewline === -1 ? bounds.table.length : followingNewline + 1;
    table = `${bounds.table.slice(0, lineStart)}${bounds.table.slice(lineEnd)}`.trimEnd();
  } else {
    table = `${bounds.table.slice(0, valueStart)} ${formatTomlStringArray(next)}${bounds.table.slice(valueEnd)}`;
  }
  return [bounds.before, table, bounds.after].filter(Boolean).join("\n").replace(/\n*$/, "\n");
}

export function codexRelayMcpTomlSection(
  bin = relayBinPath(),
  node = stableNodePath(),
) {
  const launch = mcpLaunchCommand({ mcpBin: bin, node });
  return [
    "[mcp_servers.relay]",
    `command = ${tomlQuote(launch.command)}`,
    `args = [${launch.args.map(tomlQuote).join(", ")}]`,
    "startup_timeout_sec = 30",
    "tool_timeout_sec = 300",
    "",
  ].join("\n");
}

export function replaceTomlTable(text, tableName, replacement) {
  const header = `[${tableName}]`;
  const lines = String(text || "").split(/\r?\n/);
  const start = lines.findIndex((line) => line.trim() === header);
  const cleanReplacement = String(replacement || "").trimEnd();
  if (start === -1) {
    const prefix = String(text || "").trimEnd();
    return `${prefix}${prefix ? "\n\n" : ""}${cleanReplacement}\n`;
  }

  let end = start + 1;
  while (end < lines.length && !/^\s*\[[^\]]+\]\s*$/.test(lines[end])) end += 1;
  const before = lines.slice(0, start).join("\n").trimEnd();
  const after = lines.slice(end).join("\n").trimStart();
  return `${before ? `${before}\n\n` : ""}${cleanReplacement}${after ? `\n\n${after}` : ""}\n`;
}

export function removeTomlTable(text, tableName) {
  const header = `[${tableName}]`;
  const lines = String(text || "").split(/\r?\n/);
  const start = lines.findIndex((line) => line.trim() === header);
  if (start === -1) return String(text || "");

  let end = start + 1;
  while (end < lines.length && !/^\s*\[[^\]]+\]\s*$/.test(lines[end])) end += 1;
  const before = lines.slice(0, start).join("\n").trimEnd();
  const after = lines.slice(end).join("\n").trimStart();
  return `${before}${before && after ? "\n\n" : ""}${after}${before || after ? "\n" : ""}`;
}

export function writeCodexMcpConfig(
  bin = relayBinPath(),
  node = stableNodePath(),
  configPath = codexConfigPath(),
) {
  const configDir = path.dirname(configPath);
  if (!fs.existsSync(configPath) && !fs.existsSync(configDir)) {
    // The Codex desktop experience ships inside the ChatGPT app and reads this
    // same file, but ~/.codex only appears once Codex has actually been run. A
    // freshly-installed ChatGPT app is a real host, so create the directory
    // rather than reporting "codex not found" to someone who has it open.
    if (!codexAppPresent()) return { ok: false, reason: "codex_config_missing", configPath };
    try {
      fs.mkdirSync(configDir, { recursive: true });
    } catch (error) {
      return {
        ok: false,
        reason: "codex_config_write_failed",
        configPath,
        detail: error && error.message ? error.message : String(error),
      };
    }
  }
  try {
    const existing = fs.existsSync(configPath) ? fs.readFileSync(configPath, "utf8") : "";
    const withDirectRelay = updateTomlStringArray(
      existing,
      "features.code_mode",
      "direct_only_tool_namespaces",
      RELAY_CODEX_DIRECT_NAMESPACE,
    );
    writeTextAtomic(
      configPath,
      replaceTomlTable(withDirectRelay, "mcp_servers.relay", codexRelayMcpTomlSection(bin, node)),
    );
    return { ok: true, method: "config", configPath };
  } catch (error) {
    return {
      ok: false,
      reason: "codex_config_write_failed",
      configPath,
      detail: error && error.message ? error.message : String(error),
    };
  }
}

export function removeCodexMcpConfig(configPath = codexConfigPath()) {
  if (!fs.existsSync(configPath)) return { ok: true, configPath };
  try {
    const withoutRelayServer = removeTomlTable(fs.readFileSync(configPath, "utf8"), "mcp_servers.relay");
    writeTextAtomic(
      configPath,
      updateTomlStringArray(
        withoutRelayServer,
        "features.code_mode",
        "direct_only_tool_namespaces",
        RELAY_CODEX_DIRECT_NAMESPACE,
        { remove: true },
      ),
    );
    return { ok: true, configPath };
  } catch (error) {
    return {
      ok: false,
      reason: "codex_config_write_failed",
      configPath,
      detail: error && error.message ? error.message : String(error),
    };
  }
}

function electronPathForPackageRoot(packageRoot) {
  try {
    const require = createRequire(path.join(packageRoot, "package.json"));
    const electronPath = require("electron");
    return typeof electronPath === "string" && electronPath ? electronPath : null;
  } catch {
    return null;
  }
}

function electronPackageRootForPackageRoot(packageRoot) {
  try {
    const require = createRequire(path.join(packageRoot, "package.json"));
    // Resolve without executing Electron's index.js. When npm skipped Electron's
    // download, requiring the module throws because path.txt is absent, but its
    // install.js is still available. This also follows npm's hoisted sibling layout.
    return path.dirname(require.resolve("electron"));
  } catch {
    return null;
  }
}

export function ensureElectronRuntime(packageRoot, { runCommand = run } = {}) {
  let electronPath = electronPathForPackageRoot(packageRoot);
  if (electronPath && fs.existsSync(electronPath)) return { ok: true, electronPath, repaired: false };

  const electronPackageRoot = electronPackageRootForPackageRoot(packageRoot);
  const installScript = electronPackageRoot ? path.join(electronPackageRoot, "install.js") : null;
  if (!installScript || !fs.existsSync(installScript)) {
    return { ok: false, reason: "electron_install_script_missing", electronPath, installScript };
  }

  const res = runCommand(process.execPath, [installScript], { timeoutMs: npmInstallTimeoutMs() });
  electronPath = electronPathForPackageRoot(packageRoot);
  if (res.ok && electronPath && fs.existsSync(electronPath)) {
    return { ok: true, electronPath, repaired: true };
  }

  return {
    ok: false,
    reason: "electron_runtime_missing",
    electronPath,
    installScript,
    detail: res.out,
  };
}

/** Whether an agent CLI is on PATH. */
export function commandExists(cmd) {
  const res = spawnSync(cmd, ["--version"], { encoding: "utf8", timeout: 8000 });
  return !res.error && (res.status === 0 || Boolean((res.stdout || "").trim()));
}

/** Register the Relay MCP into Claude Code at user scope (loads in every session). Idempotent. */
export function installClaudeCode(
  bin = relayBinPath(),
  node = stableNodePath(),
  { command = "claude" } = {},
) {
  let cliResult = null;
  if (commandExists(command)) {
    const launch = mcpLaunchCommand({ mcpBin: bin, node });
    run(command, ["mcp", "remove", "-s", "user", "relay"]); // ignore if absent
    cliResult = run(command, ["mcp", "add", "-s", "user", "relay", "--", launch.command, ...launch.args]);
    if (cliResult.ok || /already exists/i.test(cliResult.out)) {
      return { ok: true, method: "cli" };
    }
  }

  const configResult = writeClaudeCodeMcpConfig(bin, node, claudeCodeConfigPath());
  if (configResult.ok) return configResult;
  if (cliResult) return { ok: false, reason: "registration_failed", detail: cliResult.out, configPath: configResult.configPath };
  return { ok: false, reason: "claude_code_not_found", configPath: configResult.configPath };
}

/** Register the Relay MCP into Codex (loads in every session). Idempotent. */
export function installCodex(
  bin = relayBinPath(),
  node = stableNodePath(),
  { command = "codex" } = {},
) {
  let cliResult = null;
  if (commandExists(command)) {
    const launch = mcpLaunchCommand({ mcpBin: bin, node });
    run(command, ["mcp", "remove", "relay"]); // ignore if absent
    cliResult = run(command, ["mcp", "add", "relay", "--", launch.command, ...launch.args]);
    if (cliResult.ok || /already exists/i.test(cliResult.out)) {
      return { ok: true, method: "cli" };
    }
  }

  const configResult = writeCodexMcpConfig(bin, node, codexConfigPath());
  if (configResult.ok) return configResult;
  if (cliResult) return { ok: false, reason: "registration_failed", detail: cliResult.out, configPath: configResult.configPath };
  return { ok: false, reason: "codex_not_found", configPath: configResult.configPath };
}

/**
 * Refuse to point the machine's autostart at a tree the canonical runtime does
 * not currently own.
 *
 * Registering is a machine-global act: whoever writes the plist decides what
 * launchd restarts from then on, and the daemon cannot survive a registration
 * that disagrees with the pointer. The guard itself is not new — it existed and
 * was wired only into `postinstall`, which is precisely why `install`,
 * `repair-desktop` and the startup migration could each still re-pin the whole
 * machine on their own. Guarding the two WRITERS instead of their callers
 * covers every present caller and every future one.
 *
 * Returns a refusal result, or null to proceed. Anything unreadable proceeds:
 * a first install has no pointer yet, and during activation the pointer reads
 * as null by design, so neither may be blocked.
 */
function autostartClaimRefusal(bin, { claim, homeDir, platform, ownershipGuard }) {
  if (claim) return null;
  let guard = null;
  try {
    guard = ownershipGuard(packageRootForBin(bin, platform), { homeDir, platform });
  } catch {
    return null;
  }
  if (!guard || guard.mayClaim) return null;
  const wouldRegister = packageRootForBin(bin, platform);
  const current = guard.current?.packageRoot ?? null;
  return {
    ok: false,
    reason: "not-canonical-runtime",
    guard: guard.reason,
    wouldRegister,
    current,
    message:
      `refusing to point this machine's autostart at ${wouldRegister}` +
      (current ? `, because the canonical runtime is ${current}` : "") +
      ". Registering a non-current tree strands the daemon in a restart loop it cannot detect. " +
      "Re-run with --claim if this tree really should own the machine.",
  };
}

/** Install + load a launchd agent that keeps `relay daemon` running (macOS). Idempotent. */
export function installDaemonAutostart(
  bin = relayBinPath(),
  node = stableNodePath(),
  {
    platform = process.platform,
    runCommand = run,
    reload = true,
    homeDir = os.homedir(),
    ensureLauncher = ensureWindowsHiddenLauncher,
    claim = false,
    ownershipGuard = canonicalOwnershipGuard,
    env = process.env,
  } = {},
) {
  const refusal = autostartClaimRefusal(bin, { claim, homeDir, platform, ownershipGuard });
  if (refusal) return refusal;
  // The daemon runs 24/7 and only polls, so cap its V8 heap — this is the
  // heaviest always-on Relay process, and an uncapped heap lets a transient
  // large response set a high-water mark it then holds for the machine's uptime.
  const daemonArgs = ["--max-old-space-size=128", bin, "daemon"];
  if (platform === "win32") {
    const logPath = path.join(homeDir, ".relay", "daemon.log");
    fs.mkdirSync(path.dirname(logPath), { recursive: true });
    const res = installWindowsLogonTask({
      taskName: WINDOWS_DAEMON_TASK_NAME,
      executable: node,
      args: daemonArgs,
      logPath,
      runCommand,
      homeDir,
      ensureLauncher,
      reload,
    });
    return { ...res, logPath };
  }
  if (platform === "linux") {
    return installLinuxDaemonAutostart({ bin, node, runCommand, reload, homeDir, env });
  }
  if (platform !== "darwin") return { ok: false, reason: "autostart_unsupported_platform" };
  const home = homeDir;
  const plistPath = path.join(home, "Library", "LaunchAgents", `${DAEMON_LAUNCH_LABEL}.plist`);
  const logPath = path.join(home, ".relay", "daemon.log");
  fs.mkdirSync(path.dirname(plistPath), { recursive: true });
  fs.mkdirSync(path.dirname(logPath), { recursive: true });
  const pathEnv = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin";
  const plist = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key><string>${DAEMON_LAUNCH_LABEL}</string>
  <key>ProgramArguments</key>
  <array>
    <string>${plistEscape(node)}</string>
${daemonArgs.map((argument) => `    <string>${plistEscape(argument)}</string>`).join("\n")}
  </array>
  <key>RunAtLoad</key><true/>
  <key>KeepAlive</key><true/>
  <key>StandardOutPath</key><string>${plistEscape(logPath)}</string>
  <key>StandardErrorPath</key><string>${plistEscape(logPath)}</string>
  <key>EnvironmentVariables</key>
  <dict>
    <key>PATH</key><string>${plistEscape(pathEnv)}</string>
  </dict>
</dict>
</plist>
`;
  fs.writeFileSync(plistPath, plist);
  if (!reload) return { ok: true, plistPath, logPath, started: false };
  runCommand("launchctl", ["unload", plistPath]); // ignore if not loaded
  const res = runCommand("launchctl", ["load", plistPath]);
  return { ok: res.ok, plistPath, logPath, started: res.ok };
}

/** Install + load a launchd agent that starts the visible Relay pill (macOS). */
export function installPillAutostart(
  bin = relayBinPath(),
  {
    platform = process.platform,
    runCommand = run,
    reload = true,
    homeDir = os.homedir(),
    node = stableNodePath(),
    ensureLauncher = ensureWindowsHiddenLauncher,
    claim = false,
    ownershipGuard = canonicalOwnershipGuard,
    env = process.env,
  } = {},
) {
  if (!["darwin", "win32", "linux"].includes(platform)) return { ok: false, reason: "autostart_unsupported_platform" };
  const refusal = autostartClaimRefusal(bin, { claim, homeDir, platform, ownershipGuard });
  if (refusal) return refusal;
  const packageRoot = packageRootForBin(bin, platform);
  const electron = ensureElectronRuntime(packageRoot, { runCommand });
  const electronPath = electron.electronPath;
  const overlayMain = path.join(packageRoot, "overlay", "main.cjs");
  const pillArgs = [overlayMain];
  if (!electron.ok || !electronPath || !fs.existsSync(overlayMain)) {
    return { ok: false, reason: "pill_runtime_missing", electronPath, overlayMain, electron };
  }
  if (platform === "linux") {
    const sandbox = prepareLinuxElectronSandbox({
      platform,
      electronPath,
      env,
      runCommand,
      allowAuthorization: env.RELAY_ALLOW_SANDBOX_AUTHORIZATION === "1",
    });
    if (!sandbox.ok) return { ...sandbox, electronPath, overlayMain };
    return installLinuxPillAutostart({
      bin,
      node,
      electronPath,
      overlayMain,
      runCommand,
      reload,
      homeDir,
      env,
    });
  }
  if (platform === "win32") {
    const logPath = path.join(homeDir, ".relay", "pill.log");
    fs.mkdirSync(path.dirname(logPath), { recursive: true });
    const res = installWindowsLogonTask({
      taskName: WINDOWS_PILL_TASK_NAME,
      executable: electronPath,
      args: pillArgs,
      logPath,
      runCommand,
      homeDir,
      ensureLauncher,
      reload,
    });
    // The Start Menu entry is the Windows counterpart to macOS's Relay.app, and it
    // is installed from the same place so the two platforms can never drift. Unlike
    // macOS it does NOT gate `ok`: autostart is what keeps Relay running, a shortcut
    // only makes it launchable, and failing the former for the latter would be a
    // worse machine than one with no Start Menu entry.
    const shortcut = installWindowsStartMenuShortcut({ bin, platform, homeDir, runCommand, ensureLauncher });
    return { ...res, electronPath, overlayMain, logPath, shortcut };
  }

  const home = homeDir;
  const plistPath = path.join(home, "Library", "LaunchAgents", `${PILL_LAUNCH_LABEL}.plist`);
  const logPath = path.join(home, ".relay", "pill.log");
  fs.mkdirSync(path.dirname(plistPath), { recursive: true });
  fs.mkdirSync(path.dirname(logPath), { recursive: true });
  const relayApp = installRelayMacApp({ bin, electronPath, overlayMain, homeDir, runCommand });
  if (!relayApp.ok) return { ...relayApp, electronPath, overlayMain, logPath };
  const pathEnv = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin";
  const plist = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key><string>${PILL_LAUNCH_LABEL}</string>
  <key>ProgramArguments</key>
  <array>
    <string>${plistEscape(electronPath)}</string>
${pillArgs.map((argument) => `    <string>${plistEscape(argument)}</string>`).join("\n")}
  </array>
  <key>RunAtLoad</key><true/>
  <!-- Restart a crashed pill, but not a clean second instance that exits after
       losing Electron's single-instance lock. KeepAlive=true turns that normal
       exit into an endless launch/reopen/sound loop whenever a standalone owner
       wins the startup race. RunAtLoad still starts the pill at login. -->
  <key>KeepAlive</key>
  <dict>
    <key>SuccessfulExit</key><false/>
  </dict>
  <!-- The pill draws a window the user clicks, so it must be scheduled like an
       app, not like batch work. Without ProcessType, launchd treats an agent as
       ordinary background work: under load macOS starves it, and the field
       symptom is a visible pill whose clicks and animation freeze for seconds
       while it burns no CPU and runs no JavaScript. Interactive opts out of that
       throttling; LowPriorityIO off keeps its disk reads from being deferred. -->
  <key>ProcessType</key><string>Interactive</string>
  <key>LowPriorityIO</key><false/>
  <key>StandardOutPath</key><string>${plistEscape(logPath)}</string>
  <key>StandardErrorPath</key><string>${plistEscape(logPath)}</string>
  <key>EnvironmentVariables</key>
  <dict>
    <key>PATH</key><string>${plistEscape(pathEnv)}</string>
  </dict>
</dict>
</plist>
`;
  fs.writeFileSync(plistPath, plist);
  if (!reload) {
    return {
      ok: true,
      plistPath,
      logPath,
      electronPath,
      overlayMain,
      appPath: relayApp.appPath,
      started: false,
    };
  }
  runCommand("launchctl", ["unload", plistPath]); // ignore if not loaded
  const res = runCommand("launchctl", ["load", plistPath]);
  return {
    ok: res.ok,
    plistPath,
    logPath,
    electronPath,
    overlayMain,
    appPath: relayApp.appPath,
    started: res.ok,
  };
}

function quoteWindowsCmdArg(value) {
  return `"${String(value).replaceAll('"', '\\"')}"`;
}

export function windowsTaskAction(executable, args = [], logPath = null, comspec = process.env.ComSpec || "cmd.exe") {
  const command = [quoteWindowsCmdArg(executable), ...args.map(quoteWindowsCmdArg)].join(" ");
  const redirect = logPath ? ` >> ${quoteWindowsCmdArg(logPath)} 2>&1` : "";
  return `${quoteWindowsCmdArg(comspec)} /d /s /c "${command}${redirect}"`;
}

/**
 * A GUI-subsystem shim that starts the real (console) task action hidden.
 *
 * Task Scheduler launches an `<Exec>` action with the default SW_SHOWNORMAL, so a
 * console `Command` — cmd.exe, and therefore node.exe and electron.exe under it —
 * gets a console window that stays on screen for the life of the service. On
 * Windows 11 the default-terminal handoff turns that into a blank Windows Terminal
 * window, and every updater restart of the two tasks pops a fresh pair (measured on
 * Windows 11 26200: a cmd.exe action produced a visible `C:\\Windows\\system32\\cmd.exe`
 * terminal window every run; this shim produced none, with the child confirmed running).
 *
 * The task's `<Hidden>` setting does NOT do this — it only hides the task from the
 * Task Scheduler UI. `powershell -WindowStyle Hidden` does not do it either: it is
 * applied after the window exists and the Terminal handoff ignores it.
 *
 * wscript.exe is a GUI subsystem binary, so Task Scheduler allocates it no console
 * at all, and WshShell.Run's `0` window style creates the child with SW_HIDE.
 *
 * The command is BAKED INTO the script, not passed as an argument. Handing it to the
 * task's `<Arguments>` puts Task Scheduler, CommandLineToArgvW and cmd all in charge
 * of the same quotes: measured, that three-deep nesting registered and parsed clean
 * but never started the service. A VBScript string literal has exactly one escaping
 * rule (a doubled quote), and the file is UTF-16, so no console codepage can corrupt
 * a non-ASCII install path (C:\Users\José) the way a .cmd or .bat would.
 */
export function windowsHiddenLauncherVbs(commandLine) {
  // VBScript literal: "" is one quote. Nothing else needs escaping here.
  const literal = `"${String(commandLine).replaceAll('"', '""')}"`;
  return [
    "' Relay Companion: starts a service with no console window.",
    "' Written by 'relay install' / 'relay repair-desktop'; safe to delete (it is recreated).",
    "Option Explicit",
    "Dim shell",
    'Set shell = CreateObject("WScript.Shell")',
    `shell.Run ${literal}, 0, False`,
    "",
  ].join("\r\n");
}

/** Stable per-task shim, e.g. `~/.relay/relay-companion-daemon.vbs`. */
export function windowsHiddenLauncherPath(homeDir = os.homedir(), taskName = "Relay Service") {
  const slug = String(taskName).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "relay-service";
  return path.join(homeDir, ".relay", `${slug}.vbs`);
}

/**
 * Write the shim and confirm the pieces it needs are actually there. Returns null
 * when anything is missing — hardened boxes disable Windows Script Host entirely —
 * and the caller then registers the plain console action: a visible window is
 * annoying, a service that never starts is not.
 */
export function ensureWindowsHiddenLauncher({
  homeDir = os.homedir(),
  taskName = "Relay Service",
  commandLine = "",
  systemRoot = process.env.SystemRoot || process.env.windir || "C:\\Windows",
  runCommand = run,
  writeFile = fs.writeFileSync,
  makeDir = fs.mkdirSync,
  exists = fs.existsSync,
} = {}) {
  const wscript = path.win32.join(systemRoot, "System32", "wscript.exe");
  if (!exists(wscript)) return null;
  // Policy can turn WSH off machine- or user-wide; an absent value means enabled.
  for (const hive of ["HKLM", "HKCU"]) {
    const query = runCommand("reg", ["query", `${hive}\\Software\\Microsoft\\Windows Script Host\\Settings`, "/v", "Enabled"]);
    if (query.ok && /\bEnabled\b\s+REG_DWORD\s+0x0\b/i.test(query.out)) return null;
  }
  const vbsPath = windowsHiddenLauncherPath(homeDir, taskName);
  try {
    makeDir(path.dirname(vbsPath), { recursive: true });
    // UTF-16 with a BOM. The script host reads a BOM-less .vbs as the ANSI codepage,
    // which mangles any non-ASCII character in an install path; the BOM removes the
    // guess. Same reason the updater writes its .ps1 this way.
    writeFile(vbsPath, `\ufeff${windowsHiddenLauncherVbs(commandLine)}`, "utf16le");
  } catch {
    return null;
  }
  return { wscript, vbsPath };
}

/** Task `<Arguments>` that run the shim. //B keeps a script error from popping a dialog. */
export function windowsHiddenLauncherArgs(vbsPath) {
  return `//B //Nologo ${quoteWindowsCmdArg(vbsPath)}`;
}

/**
 * Relay as a thing you can LAUNCH on Windows.
 *
 * macOS gets ~/Applications/Relay.app (installRelayMacApp), so Spotlight → "Relay"
 * reopens the pill. Windows had no equivalent at all: `skipTaskbar: true` means no
 * taskbar button and no Alt-Tab entry, the tray is created without a GUID so
 * Windows 11 parks the icon in the overflow chevron, and Start → "relay" found
 * nothing. The only way back was a terminal (field report, Shane 2026-08-17).
 *
 * A per-user .lnk in the Start Menu's Programs folder is the whole fix: Start
 * search indexes that folder by shortcut name, and writing there needs no admin
 * rights and no installer.
 */
const RELAY_WINDOWS_SHORTCUT_NAME = "Relay.lnk";
const RELAY_WINDOWS_LAUNCHER_TASK = "Relay Pill Launcher";
const RELAY_WINDOWS_ICON_NAME = "relay.ico";

export function windowsStartMenuDir(env = process.env, homeDir = os.homedir()) {
  const appData = env.APPDATA || path.join(homeDir, "AppData", "Roaming");
  return path.join(appData, "Microsoft", "Windows", "Start Menu", "Programs");
}

export function windowsStartMenuShortcutPath(env = process.env, homeDir = os.homedir()) {
  return path.join(windowsStartMenuDir(env, homeDir), RELAY_WINDOWS_SHORTCUT_NAME);
}

// PowerShell literal: '' is one quote. Nothing else is interpreted inside a
// single-quoted string, so a username containing $, ` or " cannot break the script.
function powerShellSingleQuote(value) {
  return `'${String(value).replaceAll("'", "''")}'`;
}

/**
 * Build the PowerShell that writes the shell link. Node cannot author a .lnk
 * natively; WScript.Shell's CreateShortcut is the one dependency-free way, and the
 * repo already shells out to reg/schtasks/powershell elsewhere.
 *
 * It writes to `stagingPath` so a failure never leaves a half-written shortcut in
 * the user's Start Menu; the caller renames it into place.
 */
export function windowsShortcutScript({ stagingPath, target, args, iconPath = "", description = "" }) {
  return [
    "$ErrorActionPreference='Stop'",
    `$s=(New-Object -ComObject WScript.Shell).CreateShortcut(${powerShellSingleQuote(stagingPath)})`,
    `$s.TargetPath=${powerShellSingleQuote(target)}`,
    `$s.Arguments=${powerShellSingleQuote(args)}`,
    ...(description ? [`$s.Description=${powerShellSingleQuote(description)}`] : []),
    ...(iconPath ? [`$s.IconLocation=${powerShellSingleQuote(iconPath)}`] : []),
    "$s.Save()",
  ].join("; ");
}

export function installWindowsStartMenuShortcut({
  bin = relayBinPath(),
  node = stableNodePath(),
  platform = process.platform,
  homeDir = os.homedir(),
  env = process.env,
  runCommand = run,
  ensureLauncher = ensureWindowsHiddenLauncher,
  exists = fs.existsSync,
  makeDir = fs.mkdirSync,
  rename = fs.renameSync,
  remove = fs.rmSync,
} = {}) {
  if (platform !== "win32") return { ok: false, reason: "unsupported_platform" };
  const lnkPath = windowsStartMenuShortcutPath(env, homeDir);
  const stagingPath = `${lnkPath}.${process.pid}.tmp.lnk`;
  const packageRoot = packageRootForBin(bin);
  const iconPath = path.join(packageRoot, "overlay", RELAY_WINDOWS_ICON_NAME);

  // `relay pill` already resolves Electron, spawns it with a --relay-reopen nonce and
  // waits for the single-instance owner to confirm the card is on screen. That is the
  // same contract the macOS applet's launcher script implements, so an ALREADY-RUNNING
  // pill is raised rather than duplicated. Reusing the CLI keeps one open path.
  const commandLine = `${quoteWindowsCmdArg(node)} ${quoteWindowsCmdArg(bin)} pill`;
  const launcher = ensureLauncher({ homeDir, taskName: RELAY_WINDOWS_LAUNCHER_TASK, commandLine, runCommand });
  // Windows Script Host can be disabled by policy, in which case ensureLauncher
  // returns null. Target node directly and accept a console flash: a Start Menu
  // entry that blinks beats one that does not exist — the same trade-off
  // installWindowsLogonTask already makes for the background services.
  const target = launcher ? launcher.wscript : node;
  const args = launcher ? windowsHiddenLauncherArgs(launcher.vbsPath) : `${quoteWindowsCmdArg(bin)} pill`;

  try {
    makeDir(path.dirname(lnkPath), { recursive: true });
  } catch (error) {
    return { ok: false, reason: "start_menu_unwritable", detail: error?.message || String(error), lnkPath };
  }
  const script = windowsShortcutScript({
    stagingPath,
    target,
    args,
    iconPath: exists(iconPath) ? iconPath : "",
    description: "Show the Relay pill",
  });
  const res = runCommand("powershell", ["-NoProfile", "-NonInteractive", "-Command", script]);
  if (!res.ok) {
    try {
      remove(stagingPath, { force: true });
    } catch {}
    return { ok: false, reason: res.missing ? "powershell_missing" : "shortcut_write_failed", detail: res.out, lnkPath };
  }
  try {
    rename(stagingPath, lnkPath);
  } catch (error) {
    try {
      remove(stagingPath, { force: true });
    } catch {}
    return { ok: false, reason: "shortcut_publish_failed", detail: error?.message || String(error), lnkPath };
  }
  return { ok: true, lnkPath, target, args, hidden: Boolean(launcher) };
}

/** True when win32 has no Start Menu entry — drives `relay doctor` and the repair path. */
export function windowsStartMenuShortcutMissing({
  platform = process.platform,
  env = process.env,
  homeDir = os.homedir(),
  exists = fs.existsSync,
} = {}) {
  if (platform !== "win32") return false;
  return !exists(windowsStartMenuShortcutPath(env, homeDir));
}

function windowsTaskXmlEscape(value) {
  return String(value)
    .replaceAll("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;")
    .replaceAll("'", "&apos;");
}

/**
 * Build a Scheduled Task definition without putting the executable action in
 * schtasks.exe's `/TR` argument. `/TR` has both a small length ceiling and its
 * own quote parser; Relay's Electron path crosses both boundaries on normal
 * Windows installs. XML keeps the command and arguments as data instead.
 */
export function windowsTaskXml(
  executable,
  args = [],
  logPath = null,
  comspec = process.env.ComSpec || "cmd.exe",
  userId = process.env.USERNAME
    ? `${process.env.USERDOMAIN ? `${process.env.USERDOMAIN}\\` : ""}${process.env.USERNAME}`
    : "",
  launcher = null,
) {
  const command = [quoteWindowsCmdArg(executable), ...args.map(quoteWindowsCmdArg)].join(" ");
  const redirect = logPath ? ` >> ${quoteWindowsCmdArg(logPath)} 2>&1` : "";
  // With the shim, the action is a windowless wscript that starts this same command
  // hidden. Without it, cmd runs as the action directly and Windows shows its console
  // for as long as the service lives.
  const taskCommand = launcher ? launcher.wscript : comspec;
  const commandArgs = launcher ? windowsHiddenLauncherArgs(launcher.vbsPath) : `/d /s /c "${command}${redirect}"`;
  return `<?xml version="1.0" encoding="UTF-16"?>
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
  <Triggers>
    <LogonTrigger><Enabled>true</Enabled>${userId ? `<UserId>${windowsTaskXmlEscape(userId)}</UserId>` : ""}</LogonTrigger>
  </Triggers>
  <Principals>
    <Principal id="RelayUser">
      ${userId ? `<UserId>${windowsTaskXmlEscape(userId)}</UserId>` : ""}
      ${userId ? "<LogonType>InteractiveToken</LogonType>" : ""}
      <RunLevel>LeastPrivilege</RunLevel>
    </Principal>
  </Principals>
  <Settings>
    <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
    <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
    <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
    <AllowHardTerminate>true</AllowHardTerminate>
    <StartWhenAvailable>true</StartWhenAvailable>
    <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
    <IdleSettings><StopOnIdleEnd>false</StopOnIdleEnd><RestartOnIdle>false</RestartOnIdle></IdleSettings>
    <AllowStartOnDemand>true</AllowStartOnDemand>
    <Enabled>true</Enabled>
    <Hidden>false</Hidden>
    <RunOnlyIfIdle>false</RunOnlyIfIdle>
    <WakeToRun>false</WakeToRun>
    <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
  </Settings>
  <Actions Context="RelayUser">
    <Exec>
      <Command>${windowsTaskXmlEscape(taskCommand)}</Command>
      <Arguments>${windowsTaskXmlEscape(commandArgs)}</Arguments>
    </Exec>
  </Actions>
</Task>
`;
}

function installWindowsLogonTask({
  taskName,
  executable,
  args = [],
  logPath = null,
  runCommand = run,
  homeDir = os.homedir(),
  ensureLauncher = ensureWindowsHiddenLauncher,
  reload = true,
}) {
  const action = windowsTaskAction(executable, args, logPath);
  // Null when Windows Script Host is unavailable: the task still registers, it just
  // shows its console window the way it did before.
  const launcher = ensureLauncher({ homeDir, taskName, commandLine: action });
  const xml = windowsTaskXml(executable, args, logPath, process.env.ComSpec || "cmd.exe", undefined, launcher);
  const xmlPath = path.join(os.tmpdir(), `relay-task-${process.pid}-${Math.random().toString(16).slice(2)}.xml`);
  // schtasks reads task XML as UTF-16 on every supported Windows version. The
  // BOM matters on Windows PowerShell 5-era systems.
  fs.writeFileSync(xmlPath, `\uFEFF${xml}`, "utf16le");
  if (reload) runCommand("schtasks", ["/End", "/TN", taskName]); // ignore if absent or not running
  let create;
  try {
    create = runCommand("schtasks", ["/Create", "/TN", taskName, "/XML", xmlPath, "/F"]);
  } finally {
    try {
      fs.unlinkSync(xmlPath);
    } catch {}
  }
  if (!create.ok) {
    return { ok: false, reason: "scheduled_task_create_failed", taskName, action, detail: create.out };
  }
  if (!reload) {
    return { ok: true, taskName, action, hidden: Boolean(launcher), started: false };
  }
  const start = runCommand("schtasks", ["/Run", "/TN", taskName]);
  return {
    ok: true,
    taskName,
    action,
    hidden: Boolean(launcher),
    started: start.ok,
    startDetail: start.ok ? "" : start.out,
  };
}

/**
 * Detect Claude Code + Codex, register the Relay MCP into each present, and
 * start the receive daemon. Returns a summary for the CLI to print.
 */
export async function runSetupInstall({ claim = false, reload = true } = {}) {
  const { bin, stable: binStable, version } = resolveStableBin();
  const packageRoot = packageRootForBin(bin);
  // First contact deliberately installs with --ignore-scripts. Prepare and verify
  // the complete desktop runtime here, before any MCP registration, daemon, task,
  // shortcut, or launch agent is allowed to point at it.
  const electron = ensureElectronRuntime(packageRoot);
  if (!electron.ok) {
    throw new Error(`Relay runtime preparation failed (${electron.reason || "electron unavailable"}).`);
  }
  const verified = verifyCanonicalCandidate(packageRoot, version);
  if (!verified.ok) {
    throw new Error(`Relay refused to activate an unverified runtime (${verified.reason}${verified.detail ? `: ${verified.detail}` : ""}).`);
  }
  const platformPreflight = linuxDesktopPreflight();
  if (!platformPreflight.ok) {
    throw new Error(`Relay cannot install on this Linux session (${platformPreflight.reason}): ${platformPreflight.detail}`);
  }
  const linuxRuntimePreflight = linuxElectronRuntimePreflight({
    electronPath: electron.electronPath,
    probePath: path.join(packageRoot, "overlay", "linux-runtime-probe.cjs"),
    onAuthorization: () => {
      console.log("Relay needs one administrator approval to install Electron's exact Linux sandbox helper. Relay will not disable the sandbox.");
    },
  });
  if (!linuxRuntimePreflight.ok) {
    throw new Error(`Relay cannot install its Linux desktop runtime (${linuxRuntimePreflight.reason}): ${linuxRuntimePreflight.detail}`);
  }
  const updateAgents = cleanupMacUpdateAgents();
  if (!updateAgents.ok) {
    throw new Error(`Relay could not remove an older update agent (${updateAgentCleanupDetail(updateAgents) || "launchd cleanup failed"}).`);
  }
  // Only after the complete target site is verified may setup touch host state.
  // This migration preserves account values while removing retired capability
  // switches; putting it here also keeps malformed native helpers from changing
  // config, MCP registrations, launchers, or services before setup fails closed.
  writeConfig({});
  // Bake the upgrade-surviving node path into the MCP registrations too, not just the
  // daemon plist — otherwise a `brew upgrade node` / `nvm uninstall` deletes the
  // version-managed node the Claude/Codex MCP `command` points at, and the agents can
  // no longer spawn the Relay MCP server.
  const node = persistentNodePath();
  const mcpBin = ensureStableMcpLauncher({ targetBin: bin, node });
  const installed = [];
  const missing = [];
  const activations = [];
  // Desktop apps read their config at process start, so a fresh registration is
  // inert until the user fully quits and reopens them. Collected here so the CLI
  // can say which ones, by name, instead of implying setup failed.
  const desktopRestarts = [];
  const sweptStaleEntries = [];
  const configuredClaudeCommand = process.env.CLAUDE_CLI_PATH;
  const configuredCodexCommand = process.env.CODEX_CLI_PATH;
  const claudeCommand = configuredClaudeCommand && /[\\/]/.test(configuredClaudeCommand)
    ? path.resolve(configuredClaudeCommand)
    : configuredClaudeCommand || "claude";
  const codexCommand = configuredCodexCommand && /[\\/]/.test(configuredCodexCommand)
    ? path.resolve(configuredCodexCommand)
    : configuredCodexCommand || "codex";
  const serviceEnv = {
    ...process.env,
    ...(configuredClaudeCommand ? { CLAUDE_CLI_PATH: claudeCommand } : {}),
    ...(configuredCodexCommand ? { CODEX_CLI_PATH: codexCommand } : {}),
    ...(process.platform === "linux" ? { RELAY_ALLOW_SANDBOX_AUTHORIZATION: "1" } : {}),
  };
  const claude = installClaudeCode(mcpBin, node, { command: claudeCommand });
  let claudeHooks = null;
  if (claude.ok) {
    installed.push("Claude Code");
    activations.push(verifyClaudeMcpRegistration({ configPath: claude.configPath }));
  } else if (claude.reason === "claude_code_not_found") {
    missing.push("Claude Code");
  } else {
    missing.push("Claude Code (registration failed)");
  }

  // Claude DESKTOP is a separate product with its own config file, and it is what
  // most people who get sent a relay actually have. Registering it is independent
  // of the CLI: a desktop-only machine used to report "Claude Code was not found
  // here, so it was skipped" and register nothing anywhere.
  const claudeDesktop = process.platform === "linux"
    ? { ok: false, reason: "unsupported_platform" }
    : installClaudeDesktop(mcpBin, node);
  if (process.platform !== "linux") {
    if (claudeDesktop.ok) {
      installed.push("Claude Desktop");
      desktopRestarts.push("Claude Desktop");
      if (claudeDesktop.swept?.length) sweptStaleEntries.push(...claudeDesktop.swept);
    } else if (claudeDesktop.reason === "claude_desktop_not_found") {
      missing.push("Claude Desktop");
    } else {
      missing.push("Claude Desktop (registration failed)");
    }
  }
  // The Open-in-current-chat hook runtime rides along with the MCP registration
  // (both are what "Relay is installed into Claude" means) — but it must NOT
  // depend on the `claude` CLI being on PATH. On Windows installClaudeCode fails
  // with claude_code_not_found whenever the CLI is absent even though Claude
  // Code/Desktop is installed and the MCP was registered by hand; without this
  // fallback the hook is never written and the pill's "Open in current chat" is a
  // silent no-op on exactly those machines.
  if (claude.ok || claudeAppearsPresent()) {
    claudeHooks = installClaudeHooksWithStableLauncher(bin, node);
  }
  const codex = installCodex(mcpBin, node, { command: codexCommand });
  let codexHooks = null;
  if (codex.ok) {
    installed.push("Codex");
    activations.push(await activateCodexMcp({ command: codexCommand }));
    codexHooks = installCodexHooksWithStableLauncher(bin, node);
  } else if (codex.reason === "codex_not_found") {
    missing.push("Codex");
  } else {
    missing.push("Codex (registration failed)");
  }
  const daemon = installDaemonAutostart(bin, node, { claim, reload, env: serviceEnv });
  const pill = installPillAutostart(bin, { claim, reload, env: serviceEnv });
  return {
    installed,
    missing,
    daemon,
    pill,
    activations,
    binStable,
    claudeHooks,
    codexHooks,
    desktopRestarts,
    sweptStaleEntries,
  };
}

/**
 * Refresh agent MCP registrations and Relay-owned hooks after an auto-update.
 * The daemon calls this from the newly installed tree, so future host sessions
 * point at the current launcher and existing fleets receive new hook events
 * without having to rerun setup.
 */
export function repairAgentMcpRegistrations({
  bin = relayBinPath(),
  node = stableNodePath(),
  homeDir = os.homedir(),
  claudeConfigFile = process.env.CLAUDE_CODE_CONFIG || path.join(homeDir, ".claude.json"),
  codexConfigFile = process.env.CODEX_CONFIG || path.join(process.env.CODEX_HOME || path.join(homeDir, ".codex"), "config.toml"),
  claudeSettingsFile = process.env.CLAUDE_SETTINGS
    || path.join(process.env.CLAUDE_HOME || path.join(homeDir, ".claude"), "settings.json"),
  codexHooksFile = process.env.CODEX_HOOKS
    || path.join(process.env.CODEX_HOME || path.join(homeDir, ".codex"), "hooks.json"),
} = {}) {
  const mcpBin = ensureStableMcpLauncher({ targetBin: bin, node, homeDir });
  const claude = writeClaudeCodeMcpConfig(mcpBin, node, claudeConfigFile);
  const codex = writeCodexMcpConfig(mcpBin, node, codexConfigFile);
  const claudeDesktop = installClaudeDesktop(mcpBin, node, { env: { ...process.env, HOME: homeDir } });
  // Updates migrate hooks only when Relay already owns a handler in the host
  // config. Do not manufacture settings for agents that are not installed.
  const hookRepair = repairExistingAgentHooks({
    bin,
    node,
    homeDir,
    claudeSettingsFile,
    codexHooksFile,
  });
  const claudeHooks = hookRepair.claudeHooks || null;
  const codexHooks = hookRepair.codexHooks || null;
  return { mcpBin, claude, codex, claudeDesktop, hookRepair, claudeHooks, codexHooks };
}

function claudeConfigHasRelay(configPath) {
  if (!fs.existsSync(configPath)) return false;
  try {
    const config = readJsonObject(configPath);
    return Boolean(config.mcpServers && typeof config.mcpServers === "object" && config.mcpServers.relay);
  } catch (error) {
    // An unrelated malformed Claude config is not ours to block an update over.
    // If it appears to contain Relay, fail closed rather than pretending we
    // refreshed a registration we could not safely parse.
    if (/"relay"\s*:/.test(fs.readFileSync(configPath, "utf8"))) throw error;
    return false;
  }
}

function codexConfigHasRelay(configPath) {
  if (!fs.existsSync(configPath)) return false;
  return String(fs.readFileSync(configPath, "utf8"))
    .split(/\r?\n/)
    .some((line) => line.trim() === "[mcp_servers.relay]");
}

function existingClaudeDesktopRelayConfigs({ homeDir, platform, env }) {
  const configs = [];
  for (const dir of claudeDesktopConfigDirs({ env: { ...env, HOME: homeDir }, platform })) {
    const configPath = claudeDesktopConfigPathIn(dir);
    if (!fs.existsSync(configPath)) continue;
    try {
      const config = readJsonObject(configPath);
      if (config.mcpServers && typeof config.mcpServers === "object" && config.mcpServers.relay) {
        configs.push(configPath);
      }
    } catch (error) {
      const raw = fs.readFileSync(configPath, "utf8");
      if (/"relay"\s*:/.test(raw)) throw error;
    }
  }
  return configs;
}

/**
 * Candidate activation repair: refresh only registrations Relay already owns.
 * Unlike setup/repairAgentMcpRegistrations, this never opts a new host into MCP
 * or creates an absent host config merely because an update landed.
 */
export function repairExistingAgentRegistrations({
  bin = relayBinPath(),
  node = stableNodePath(),
  homeDir = os.homedir(),
  platform = process.platform,
  env = process.env,
  claudeConfigFile = process.env.CLAUDE_CODE_CONFIG || path.join(homeDir, ".claude.json"),
  codexConfigFile = process.env.CODEX_CONFIG || path.join(process.env.CODEX_HOME || path.join(homeDir, ".codex"), "config.toml"),
  claudeSettingsFile = process.env.CLAUDE_SETTINGS
    || path.join(process.env.CLAUDE_HOME || path.join(homeDir, ".claude"), "settings.json"),
  codexHooksFile = process.env.CODEX_HOOKS
    || path.join(process.env.CODEX_HOME || path.join(homeDir, ".codex"), "hooks.json"),
} = {}) {
  try {
    node = persistentNodePath(node, { platform, homeDir });
  } catch (error) {
    return { ok: false, reason: "node_runtime_preservation_failed", detail: error?.message || String(error) };
  }
  let claudeInstalled = false;
  let codexInstalled = false;
  let claudeDesktopConfigs = [];
  try {
    claudeInstalled = claudeConfigHasRelay(claudeConfigFile);
    codexInstalled = codexConfigHasRelay(codexConfigFile);
    claudeDesktopConfigs = existingClaudeDesktopRelayConfigs({ homeDir, platform, env });
  } catch (error) {
    return { ok: false, reason: "agent_config_unreadable", detail: error?.message || String(error) };
  }

  let mcpBin = null;
  let claude = null;
  let codex = null;
  let claudeDesktop = null;
  if (claudeInstalled || codexInstalled || claudeDesktopConfigs.length) {
    try {
      mcpBin = ensureStableMcpLauncher({ targetBin: bin, node, homeDir });
    } catch (error) {
      return { ok: false, reason: "mcp_launcher_write_failed", detail: error?.message || String(error) };
    }
    if (claudeInstalled) {
      claude = writeClaudeCodeMcpConfig(mcpBin, node, claudeConfigFile);
    }
    if (codexInstalled) {
      codex = writeCodexMcpConfig(mcpBin, node, codexConfigFile);
    }
    if (claudeDesktopConfigs.length) {
      const launch = mcpLaunchCommand({ mcpBin, node: resolveStableNode({ execPath: node }) });
      const entry = claudeDesktopEntry({
        command: launch.command,
        args: launch.args,
        relayHome: env.RELAY_HOME || undefined,
      });
      const written = [];
      try {
        for (const configPath of claudeDesktopConfigs) {
          const { text } = mergeClaudeDesktopConfig(fs.readFileSync(configPath, "utf8"), entry);
          writeTextAtomic(configPath, text);
          written.push(configPath);
        }
        claudeDesktop = { ok: true, configPaths: written };
      } catch (error) {
        claudeDesktop = { ok: false, reason: "claude_desktop_write_failed", detail: error?.message || String(error) };
      }
    }
  }
  const hookRepair = repairExistingAgentHooks({
    bin,
    node,
    homeDir,
    platform,
    claudeSettingsFile,
    codexHooksFile,
  });
  return {
    ok: Boolean((!claude || claude.ok) && (!codex || codex.ok) && (!claudeDesktop || claudeDesktop.ok) && hookRepair.ok),
    mcpBin,
    claude,
    codex,
    claudeDesktop,
    hookRepair,
  };
}

/**
 * Repair only Relay-owned desktop/background surfaces. This intentionally does not
 * inspect or mutate MCP registration, account state, or the network, so package
 * postinstall and the detached updater can safely call it after replacing the tree.
 */
/**
 * Are both Windows autostart tasks registered under the names Relay actually
 * drives (`WINDOWS_*_TASK_NAME`)?
 *
 * This is the root cause behind a whole class of silently-pinned Windows boxes.
 * Early adopters who set autostart up by hand — before the companion shipped
 * win32 autostart — named their tasks whatever they liked (field report, Shane
 * 2026-08-11: `RelayCompanionDaemon` / `RelayCompanionPill`, no spaces). Relay's
 * restart path drives the spaced names, so every `schtasks /Run` failed and the
 * updater rolled back a perfectly good install, forever. Tasks also go missing
 * for less exotic reasons: cleanup tooling, group policy, an OS migration or
 * reinstall.
 *
 * The updater no longer *depends* on these tasks (it falls back to a direct
 * launch), but a machine with no logon task has no autostart at all, so the
 * daemon repairs them the moment it notices.
 */
function scheduledTaskDefinitelyMissing(result) {
  if (result?.ok) return false;
  const output = String(result?.out || "");
  return /cannot find the file specified|cannot find (?:the )?task|task.+does not exist/i.test(output);
}

export function windowsAutostartTaskStatus({ platform = process.platform, runCommand = run } = {}) {
  if (platform !== "win32") return { missing: [], unavailable: [] };
  const missing = [];
  const unavailable = [];
  for (const taskName of [WINDOWS_DAEMON_TASK_NAME, WINDOWS_PILL_TASK_NAME]) {
    const result = runCommand("schtasks", ["/Query", "/TN", taskName]);
    if (result?.ok) continue;
    if (scheduledTaskDefinitelyMissing(result)) missing.push(taskName);
    else unavailable.push({ taskName, detail: result?.out || result?.error?.message || "query failed" });
  }
  return { missing, unavailable };
}

export function missingWindowsAutostartTasks(options = {}) {
  return windowsAutostartTaskStatus(options).missing;
}

/**
 * Re-register any Windows autostart task that has gone missing. Safe to call on
 * every daemon boot: it queries first and does nothing in the overwhelmingly
 * common case where both tasks are present.
 */
export function ensureWindowsAutostartTasks({
  platform = process.platform,
  runCommand = run,
  homeDir = os.homedir(),
  env = process.env,
  log = () => {},
} = {}) {
  if (platform !== "win32") return { attempted: false, missing: [] };
  let missing = [];
  let shortcutMissing = false;
  try {
    const taskStatus = windowsAutostartTaskStatus({ platform, runCommand });
    if (taskStatus.unavailable.length) {
      return {
        attempted: false,
        missing: [],
        reason: "query-failed",
        detail: taskStatus.unavailable.map((entry) => `${entry.taskName}: ${entry.detail}`).join("; "),
      };
    }
    missing = taskStatus.missing;
    // Auto-update replaces the install tree, and an interrupted update can leave the
    // Start Menu entry behind or stale. Repairing it on the same schedule as the
    // tasks is what keeps Relay launchable across updates.
    shortcutMissing = windowsStartMenuShortcutMissing({ platform, env, homeDir });
  } catch (error) {
    return { attempted: false, missing: [], reason: "query-failed", detail: error?.message || String(error) };
  }
  if (!missing.length && !shortcutMissing) return { attempted: false, missing: [] };
  log(
    `autostart repair: ${[
      missing.length ? `Scheduled Task(s) missing (${missing.join(", ")})` : "",
      shortcutMissing ? "Start Menu shortcut missing" : "",
    ].filter(Boolean).join("; ")}; re-registering`,
  );
  // repairDesktopSurfaces re-registers pill first, daemon last — the same ordering
  // contract the updater uses, and it recreates BOTH tasks idempotently with /F.
  const result = repairDesktopSurfaces({ platform, runCommand, homeDir });
  log(
    result.ok
      ? "autostart repair: Scheduled Tasks re-registered"
      : `autostart repair FAILED (${[
          [result.daemon?.reason, result.daemon?.detail].filter(Boolean).join(": "),
          [result.pill?.reason, result.pill?.detail].filter(Boolean).join(": "),
        ].filter(Boolean).join("; ")}); run \`relay setup\` to fix autostart`,
  );
  return { attempted: true, missing, shortcutMissing, ok: Boolean(result.ok), result };
}

export function repairDesktopSurfaces({
  bin = relayBinPath(),
  node = stableNodePath(),
  platform = process.platform,
  runCommand = run,
  reload = true,
  homeDir = os.homedir(),
  claim = false,
  env = process.env,
} = {}) {
  try {
    node = persistentNodePath(node, { platform, homeDir });
  } catch (error) {
    const failure = {
      ok: false,
      reason: "node_runtime_preservation_failed",
      detail: error?.message || String(error),
    };
    return { ok: false, daemon: failure, pill: failure };
  }
  const updateAgents = cleanupMacUpdateAgents({
    platform,
    runCommand,
    homeDir,
    preserveCurrentWorker: process.env[ACTIVE_UPDATE_WORKER_ENV] === "1",
  });
  if (!updateAgents.ok) {
    const failure = {
      ok: false,
      reason: "update_agent_cleanup_failed",
      detail: updateAgentCleanupDetail(updateAgents) || "launchd cleanup failed",
    };
    return { ok: false, daemon: failure, pill: failure, updateAgents };
  }
  const pill = installPillAutostart(bin, { platform, runCommand, reload, homeDir, claim, node, env });
  // Reload the daemon last. A repair may be invoked by the updater's detached child;
  // replacing the pill first avoids killing the update-owning daemon before all other
  // desktop surfaces are ready.
  const daemon = installDaemonAutostart(bin, node, { platform, runCommand, reload, homeDir, claim, env });
  return { ok: Boolean(daemon.ok && pill.ok), daemon, pill };
}

/** Remove the Relay MCP from both agents and stop/clear the background daemon. */
/**
 * The PowerShell that finds every Relay service process by command-line
 * identity — the daemon (`relay.js … daemon`) and the pill (`overlay/main.cjs`,
 * whose Electron children die with it) — and terminates them. Exported as a
 * string so tests can pin what it matches without spawning PowerShell.
 *
 * Why this exists at all: the tasks launch through a short-lived
 * wscript/WshShell.Run wrapper, so `schtasks /End` returns SUCCESS having
 * killed the wrapper while the actual node/Electron grandchildren survive.
 * The canonical updater already learned this and sweeps by identity; uninstall
 * inherited only the `/End` and so left the daemon alive — observed on
 * 2026-08-18, where the surviving daemon then re-registered the scheduled
 * tasks, the MCP entries, and the Start Menu shortcut it had just watched
 * uninstall remove. A live daemon actively reverses an uninstall.
 */
export const WINDOWS_STOP_RELAY_SERVICES_PS = [
  "$find={ @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object {",
  "  $_.ProcessId -ne $PID -and $_.CommandLine -and ($_.CommandLine -match '[\\\\/]relay-companion[\\\\/]') -and (($_.CommandLine -match '[\\\\/]relay\\.js.*\\bdaemon\\b') -or ($_.CommandLine -match '[\\\\/]overlay[\\\\/]main\\.cjs') -or ($_.CommandLine -match '[\\\\/]mcp-broker-entry\\.js') -or ($_.CommandLine -match '[\\\\/]relay\\.js.*(?:\\s|\")mcp(?:\\s|\"|$)'))",
  "}) }; $p=&$find; for($i=0;$i -lt 3 -and $p.Count;$i++){ foreach($x in $p){ try { Invoke-CimMethod -InputObject $x -MethodName Terminate -ErrorAction Stop | Out-Null } catch {} }; Start-Sleep -Milliseconds 150; $p=&$find }; if($p.Count){ Write-Error ('Relay processes did not stop: '+(($p | ForEach-Object ProcessId) -join ', ')); exit 1 }",
].join(" ");

/**
 * Stop the Relay services on Windows: end the tasks, then terminate whatever
 * the task end left behind. Best-effort and idempotent; returns what it did
 * so callers can report rather than assume.
 */
export function stopWindowsRelayServices({ runCommand = run } = {}) {
  for (const task of [WINDOWS_DAEMON_TASK_NAME, WINDOWS_PILL_TASK_NAME]) runCommand("schtasks", ["/End", "/TN", task]);
  const swept = runCommand("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", WINDOWS_STOP_RELAY_SERVICES_PS]);
  return {
    ok: Boolean(swept && swept.ok),
    swept: Boolean(swept && swept.ok),
    detail: swept && swept.ok ? "" : (swept?.out || "Could not stop Relay processes."),
  };
}

export function stopPosixMcpBrokers({
  runCommand = run,
  kill = process.kill,
  userId = typeof process.getuid === "function" ? process.getuid() : null,
  processId = process.pid,
  configScopeId = brokerIdentity({ packageRoot: runningPackageRoot() }).configScopeId,
} = {}) {
  const queried = runCommand("/bin/ps", ["-axo", "uid=,pid=,command="]);
  if (!queried.ok) return { ok: false, stopped: [], detail: queried.out || "Could not enumerate Relay MCP brokers." };
  const stopped = [];
  for (const line of String(queried.out || "").split(/\r?\n/)) {
    const match = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line);
    if (!match) continue;
    const uid = Number(match[1]);
    const pid = Number(match[2]);
    const command = match[3].replaceAll("\\", "/");
    if (userId !== null && uid !== userId) continue;
    if (!Number.isInteger(pid) || pid <= 1 || pid === processId) continue;
    const relayBroker = /(?:node_modules\/relay-companion|packages\/companion)\/src\/mcp-broker-entry\.js(?:\s|$)/i.test(command);
    if (!relayBroker || !command.includes(`--config-scope=${configScopeId}`)) continue;
    try {
      kill(pid, "SIGTERM");
      stopped.push(pid);
    } catch {}
  }
  return { ok: true, stopped };
}

function installedPosixRelayProcessRows({
  runCommand = run,
  processId = process.pid,
  userId = typeof process.getuid === "function" ? process.getuid() : 0,
} = {}) {
  const queried = runCommand("/bin/ps", ["-axo", "uid=,pid=,command="]);
  if (!queried.ok) return { ok: false, rows: [], detail: queried.out || "Could not enumerate Relay processes." };
  const rows = [];
  for (const line of String(queried.out || "").split(/\r?\n/)) {
    const match = line.match(/^\s*(\d+)\s+(\d+)\s+(.*)$/);
    if (!match) continue;
    const uid = Number(match[1]);
    const pid = Number(match[2]);
    const command = match[3];
    if (!Number.isInteger(uid) || uid !== userId) continue;
    if (!Number.isInteger(pid) || pid <= 0 || pid === processId) continue;
    const normalized = command.replaceAll("\\", "/");
    if (!/node_modules\/relay-companion\//i.test(normalized)) continue;
    const daemon = /\/relay\.js(?:["'\s]|$).*\bdaemon\b/i.test(normalized);
    const pill = /\/overlay\/main\.cjs(?:["'\s]|$)/i.test(normalized);
    const mcp = /\/relay\.js["']?\s+mcp(?:\s|$)/i.test(normalized);
    if (!daemon && !pill && !mcp) continue;
    rows.push({ pid, command });
  }
  return { ok: true, rows };
}

function blockingPause(ms) {
  const signal = new Int32Array(new SharedArrayBuffer(4));
  Atomics.wait(signal, 0, 0, Math.max(1, ms));
}

function stopPosixRelayProcesses({
  runCommand = run,
  sleep = blockingPause,
  processId = process.pid,
  userId = typeof process.getuid === "function" ? process.getuid() : 0,
} = {}) {
  const read = () => installedPosixRelayProcessRows({ runCommand, processId, userId });
  const first = read();
  if (!first.ok) return first;
  for (const row of first.rows) runCommand("/bin/kill", ["-TERM", String(row.pid)]);
  let live = first;
  for (let attempt = 0; attempt < 20 && live.rows.length; attempt += 1) {
    sleep(100);
    live = read();
    if (!live.ok) return live;
  }
  for (const row of live.rows) runCommand("/bin/kill", ["-KILL", String(row.pid)]);
  if (live.rows.length) sleep(200);
  const final = read();
  if (!final.ok) return final;
  if (final.rows.length) {
    return { ok: false, rows: final.rows, detail: `Relay processes did not stop: ${final.rows.map(({ pid }) => pid).join(", ")}` };
  }
  return { ok: true, stopped: first.rows.map(({ pid }) => pid) };
}

/** Stop live Relay-owned processes on macOS, including MCP children of open agents. */
export function stopMacRelayProcesses(options = {}) {
  return stopPosixRelayProcesses(options);
}

/** Stop supervised and standalone Linux processes before removing registrations. */
export function stopLinuxRelayServices({
  runCommand = run,
  sleep = blockingPause,
  processId = process.pid,
  userId = typeof process.getuid === "function" ? process.getuid() : 0,
} = {}) {
  for (const unit of [LINUX_PILL_UNIT, LINUX_DAEMON_UNIT]) {
    runCommand("systemctl", ["--user", "disable", "--now", unit]);
  }
  return stopPosixRelayProcesses({ runCommand, sleep, processId, userId });
}

/**
 * The two background services an account change has to reach, by the names
 * each platform's supervisor knows them under and by the command-line shape
 * their live process has (the ONLY reliable handle on Windows — see
 * WINDOWS_STOP_RELAY_SERVICES_PS).
 */
const RELAY_SERVICES = {
  daemon: {
    label: DAEMON_LAUNCH_LABEL,
    task: WINDOWS_DAEMON_TASK_NAME,
    // node.exe running relay.js daemon. Anchored on node so the cmd wrapper
    // that pipes its output cannot stand in for a node that never came up.
    processPattern: "node\\.exe.*[\\\\/]relay\\.js.*\\bdaemon\\b",
  },
  pill: {
    label: PILL_LAUNCH_LABEL,
    task: WINDOWS_PILL_TASK_NAME,
    processPattern: "electron\\.exe.*[\\\\/]overlay[\\\\/]main\\.cjs",
  },
};

/**
 * PowerShell for one Windows service restart, verified end to end. Exported
 * as a string builder so tests can pin what it does without running it.
 *
 * Why not `schtasks /End` + `/Run`, which the pill's Switch Account used until
 * 2026-08-18: the tasks launch through a wscript wrapper, so /End "succeeds"
 * having killed the wrapper while node lives on with the old token, and /Run
 * reports success the moment the ACTION starts, before anyone knows whether
 * the program inside it survived. Both lessons were already learned by the
 * updater (auto-update.js Restart-RelayService); this is the same discipline
 * for the account paths: stop by identity, start via the task, then wait for
 * the real process. Prints exactly one status word:
 *   restarted      the new process is observably alive
 *   not_installed  no scheduled task by this name (the service is left alone —
 *                  a daemon that is running follows the account on its own)
 *   failed         the task exists but its process never appeared
 */
export function windowsRestartServiceScript(service, { waitSeconds = 15 } = {}) {
  const spec = RELAY_SERVICES[service];
  if (!spec) throw new Error(`unknown Relay service: ${service}`);
  return [
    `$Task = '${spec.task}'`,
    `$Pattern = '${spec.processPattern}'`,
    "function Get-Live { try { @(Get-CimInstance Win32_Process -ErrorAction Stop | Where-Object { $_.ProcessId -ne $PID -and $_.CommandLine -and $_.CommandLine -match $Pattern }) } catch { @() } }",
    "$null = schtasks /Query /TN $Task 2>$null; if ($LASTEXITCODE -ne 0) { Write-Output 'not_installed'; exit 0 }",
    "$null = schtasks /End /TN $Task 2>$null",
    "$Old = Get-Live",
    "foreach ($P in $Old) { try { Stop-Process -Id $P.ProcessId -Force -ErrorAction Stop } catch {} }",
    // Let the OS release the log handle before the replacement's wrapper opens it.
    "if ($Old.Count -gt 0) { Start-Sleep -Milliseconds 800 }",
    "$null = schtasks /Run /TN $Task 2>$null; if ($LASTEXITCODE -ne 0) { Write-Output 'failed'; exit 0 }",
    `$Deadline = (Get-Date).AddSeconds(${Math.max(1, Math.floor(waitSeconds))})`,
    "while ((Get-Date) -lt $Deadline) { if ((Get-Live).Count -gt 0) { Write-Output 'restarted'; exit 0 }; Start-Sleep -Milliseconds 500 }",
    "Write-Output 'failed'",
  ].join("; ");
}

/**
 * Restart Relay's background services so they run against whatever
 * config.json holds now. This is the ONE restart the account paths use —
 * `relay pair`, the pill's Switch Account, and Sign Out — so a lesson learned
 * once about how a platform's supervisor actually behaves lands in all three.
 *
 * Returns { daemon, pill } with one of restarted | not_installed | failed |
 * skipped per service, plus `detail` for logs. Never throws: the account is
 * already persisted by the time this runs, and the daemon rebinds itself on
 * its next poll even if this fails (task-daemon.js followAccountDrift), so a
 * restart failure is something to REPORT, not something to roll back.
 *
 * `services` defaults to the daemon alone: the pill restarts itself
 * (relaunchPillSoon) because from inside the pill a kickstart is suicide
 * before the IPC reply reaches the renderer.
 *
 * Async and non-blocking by default (the Windows path can legitimately take
 * several seconds while it waits for the new process) so the pill's main
 * process keeps painting; a sync runner may be injected for tests.
 */
export async function restartRelayServices({
  services = ["daemon"],
  runCommand = runAsync,
  waitSeconds = 15,
  platform = process.platform,
  pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
} = {}) {
  const result = { daemon: "skipped", pill: "skipped", detail: {} };
  for (const service of services) {
    const spec = RELAY_SERVICES[service];
    if (!spec) continue;
    try {
      if (platform === "darwin") {
        const uid = typeof process.getuid === "function" ? process.getuid() : 501;
        const target = `gui/${uid}/${spec.label}`;
        const loaded = await runCommand("/bin/launchctl", ["print", target]);
        if (!loaded.ok) {
          result[service] = "not_installed";
          continue;
        }
        // kickstart -k: kill the running instance and start a fresh one.
        const kicked = await runCommand("/bin/launchctl", ["kickstart", "-k", target]);
        result[service] = kicked.ok ? "restarted" : "failed";
        if (!kicked.ok) result.detail[service] = kicked.out;
      } else if (platform === "win32") {
        const res = await runCommand(
          "powershell.exe",
          ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", windowsRestartServiceScript(service, { waitSeconds })],
          { timeoutMs: (waitSeconds + 10) * 1000 },
        );
        const tokens = String((res && res.out) || "").trim().split(/\s+/);
        const word = tokens.find((t) => ["restarted", "not_installed", "failed"].includes(t)) || "failed";
        result[service] = word;
        if (result[service] === "failed") result.detail[service] = (res && res.out) || "no output";
      } else if (platform === "linux") {
        const unit = service === "daemon" ? LINUX_DAEMON_UNIT : LINUX_PILL_UNIT;
        const loaded = await runCommand("systemctl", ["--user", "show", "--property=LoadState", "--value", unit]);
        if (!loaded.ok || String(loaded.out || "").trim() !== "loaded") {
          result[service] = "not_installed";
          continue;
        }
        const restarted = await runCommand("systemctl", ["--user", "restart", unit]);
        if (!restarted.ok) {
          result[service] = "failed";
          result.detail[service] = restarted.out;
          continue;
        }
        const attempts = Math.max(1, Math.ceil(Number(waitSeconds || 0) * 4));
        let lastDetail = "service did not become active with a live main process";
        for (let attempt = 0; attempt < attempts; attempt += 1) {
          const active = await runCommand("systemctl", ["--user", "show", "--property=ActiveState", "--value", unit]);
          const mainPid = await runCommand("systemctl", ["--user", "show", "--property=MainPID", "--value", unit]);
          const pid = Number.parseInt(String(mainPid.out || "").trim(), 10);
          if (active.ok && String(active.out || "").trim() === "active" && mainPid.ok && Number.isInteger(pid) && pid > 0) {
            result[service] = "restarted";
            break;
          }
          lastDetail = [active.out, mainPid.out].map((value) => String(value || "").trim()).filter(Boolean).join("; ") || lastDetail;
          if (attempt + 1 < attempts) await pause(250);
        }
        if (result[service] !== "restarted") {
          result[service] = "failed";
          result.detail[service] = lastDetail;
        }
      } else {
        result[service] = "not_installed";
      }
    } catch (error) {
      result[service] = "failed";
      result.detail[service] = error && error.message ? error.message : String(error);
    }
  }
  return result;
}

/**
 * Human-readable outcome of restartRelayServices after an account change: only
 * the services that exist are mentioned, a failure says what to run, and the
 * one thing Relay cannot do — restart an agent session it did not start — is
 * always said, because every open session's Relay tools stay bound to the
 * previous account until that session is restarted (its next tool call refuses
 * with the same instruction; see mcp.js accountDriftRefusal).
 */
export function accountRestartLines(result) {
  const lines = [];
  const restarted = ["daemon", "pill"].filter((s) => result[s] === "restarted");
  const failed = ["daemon", "pill"].filter((s) => result[s] === "failed");
  const label = { daemon: "background service", pill: "Relay app" };
  if (restarted.length) lines.push(`Restarted the ${restarted.map((s) => label[s]).join(" and ")} on the new account.`);
  if (failed.length) {
    const detail = result.detail && (result.detail.daemon || result.detail.pill);
    lines.push(
      `The ${failed.map((s) => label[s]).join(" and ")} did not restart${detail ? ` (${detail})` : ""}; ` +
        "run `relay repair-installation` or restart this computer.",
    );
  }
  if (restarted.length || failed.length) {
    lines.push("Restart any open Claude Code or Codex sessions — their Relay tools are still on the previous account.");
  }
  return lines;
}

/**
 * Legacy one-shot task from the retired Windows updater patch era. Current
 * code never creates it, but machines that lived through that era still carry
 * it, and an uninstall that leaves a Relay task behind is not an uninstall.
 */
const WINDOWS_LEGACY_UPDATER_TASK_NAME = "Relay Companion Updater";

export const UNINSTALL_RETRY_ATTEMPTS = 3;

function uninstallFailureDetail(result) {
  if (!result || typeof result !== "object") return "The removal step returned no result.";
  if (result.detail) return String(result.detail);
  if (Array.isArray(result.failures) && result.failures.length) {
    return result.failures
      .map((failure) => `${failure.configPath || failure.path || "unknown location"}: ${failure.detail || "could not remove"}`)
      .join("; ");
  }
  if (result.out) return String(result.out);
  if (result.reason) return String(result.reason);
  if (result.status != null) return `command exited ${result.status}`;
  return "The removal step did not complete.";
}

/** Run one idempotent uninstall operation with bounded retries and retain exact evidence. */
export function retryUninstallStep(id, label, operation, {
  attempts = UNINSTALL_RETRY_ATTEMPTS,
  sleep = blockingPause,
  retryDelayMs = 150,
} = {}) {
  const limit = Math.max(1, Number(attempts) || 1);
  const history = [];
  for (let attempt = 1; attempt <= limit; attempt += 1) {
    let result;
    try {
      result = operation();
    } catch (error) {
      result = { ok: false, detail: error?.message || String(error) };
    }
    if (!result || typeof result !== "object" || typeof result.ok !== "boolean") {
      result = { ok: false, detail: "The removal step returned an invalid result." };
    }
    history.push(result);
    if (result.ok) {
      return { ...result, id, label, ok: true, attempts: attempt, history };
    }
    if (attempt < limit) sleep(retryDelayMs * attempt);
  }
  const last = history.at(-1);
  return {
    ...last,
    id,
    label,
    ok: false,
    attempts: history.length,
    history,
    detail: uninstallFailureDetail(last),
  };
}

export function mcpCliRemovalResult(result) {
  if (result?.ok) return result;
  const out = String(result?.out || "");
  if (result?.missing) return { ...result, ok: true, skipped: true, detail: "Host CLI is not installed." };
  if (
    /\bno\b.*\bmcp\b.*(?:\bfound\b.*\brelay\b|\brelay\b.*\bfound\b)|\brelay\b.*\b(?:does not exist|not found|isn't configured|is not configured)\b/i.test(out)
  ) {
    return { ...result, ok: true, alreadyAbsent: true };
  }
  return { ...result, ok: false, detail: out || "The host CLI could not remove Relay." };
}

function removeOwnedPath(target, { recursive = false } = {}) {
  if (!fs.existsSync(target)) return { ok: true, path: target, alreadyAbsent: true };
  try {
    if (recursive) fs.rmSync(target, { recursive: true, force: true });
    else fs.unlinkSync(target);
  } catch (error) {
    return { ok: false, path: target, detail: error?.message || String(error) };
  }
  return fs.existsSync(target)
    ? { ok: false, path: target, detail: "The path still exists after removal." }
    : { ok: true, path: target, removed: true };
}

export function runUninstall({
  platform = process.platform,
  homeDir = os.homedir(),
  env = process.env,
  runCommand = run,
  attempts = UNINSTALL_RETRY_ATTEMPTS,
  sleep = blockingPause,
} = {}) {
  const steps = [];
  const record = (id, label, operation) => {
    const step = retryUninstallStep(id, label, operation, { attempts, sleep });
    steps.push(step);
    return step;
  };

  const updateAgents = record("update_agents", "older Relay update agents", () => {
    const result = cleanupMacUpdateAgents({ platform, runCommand, homeDir });
    return result.ok ? result : { ...result, detail: updateAgentCleanupDetail(result) || "launchd cleanup failed" };
  });

  // Services FIRST, on every platform. A daemon that outlives the steps below
  // re-registers them within one poll cycle (ensureWindowsAutostartTasks,
  // repair-runtime), silently undoing the uninstall.
  const services = record("live_processes", "live Relay services and MCP processes", () => {
    if (platform === "win32") return stopWindowsRelayServices({ runCommand });
    const brokerStop = stopPosixMcpBrokers({
      runCommand,
      configScopeId: brokerIdentity({ env, packageRoot: runningPackageRoot(), platform, homeDir }).configScopeId,
    });
    if (!brokerStop.ok) return brokerStop;
    if (platform === "linux") {
      const stopped = stopLinuxRelayServices({ runCommand, sleep });
      return stopped.ok ? { ...stopped, brokerStopped: brokerStop.stopped } : stopped;
    }
    if (platform === "darwin") {
      for (const label of [PILL_LAUNCH_LABEL, DAEMON_LAUNCH_LABEL]) {
        runCommand("launchctl", ["unload", path.join(homeDir, "Library", "LaunchAgents", `${label}.plist`)]);
      }
      const stopped = stopMacRelayProcesses({ runCommand, sleep });
      return stopped.ok ? { ...stopped, brokerStopped: brokerStop.stopped } : stopped;
    }
    return { ...brokerStop, skipped: true };
  });

  const claudeCliRemoval = record("claude_cli_mcp", "Claude Code's user MCP registration", () =>
    mcpCliRemovalResult(runCommand("claude", ["mcp", "remove", "-s", "user", "relay"])),
  );
  const codexCliRemoval = record("codex_cli_mcp", "Codex's user MCP registration", () =>
    mcpCliRemovalResult(runCommand("codex", ["mcp", "remove", "relay"])),
  );
  const claudeConfigRemoval = record("claude_config_mcp", "Claude Code's Relay MCP config", () => removeClaudeCodeMcpConfig());
  const codexConfigRemoval = record("codex_config_mcp", "Codex's Relay MCP config", () => removeCodexMcpConfig());
  const claudeDesktopRemoval = record("claude_desktop_mcp", "Claude Desktop's Relay MCP config", () =>
    removeClaudeDesktopMcpConfig({ env }),
  );
  const claudeHookRemoval = record("claude_hooks", "Claude Code's Relay hooks", () => uninstallClaudeHooks());
  const codexHookRemoval = record("codex_hooks", "Codex's Relay hooks", () => uninstallCodexHooks());
  // If a malformed host config prevented handler removal, leave the tiny bridge
  // in place. Deleting it would strand a visible MODULE_NOT_FOUND Stop hook.
  if (claudeHookRemoval.ok && codexHookRemoval.ok) {
    record("hook_launcher", "Relay's hook launcher", () => {
      try {
        removeStableHookLauncher({ homeDir });
        return { ok: true };
      } catch (error) {
        return { ok: false, detail: error?.message || String(error) };
      }
    });
  }
  record("mcp_launcher", "Relay's stable MCP launcher", () => {
    try {
      const launcherPath = removeStableMcpLauncher(homeDir);
      return removeOwnedPath(launcherPath);
    } catch (error) {
      return { ok: false, detail: error?.message || String(error) };
    }
  });

  if (platform === "darwin") {
    for (const label of [DAEMON_LAUNCH_LABEL, PILL_LAUNCH_LABEL]) {
      const plistPath = path.join(homeDir, "Library", "LaunchAgents", `${label}.plist`);
      record(`mac_plist_${label}`, `macOS launch agent ${label}`, () => removeOwnedPath(plistPath));
    }
    for (const name of [RELAY_MAC_APP_NAME, RELAY_MAC_APP_FALLBACK_NAME]) {
      const appPath = path.join(homeDir, "Applications", name);
      if (isRelayOwnedMacApp(appPath)) record(`mac_app_${name}`, name, () => removeOwnedPath(appPath, { recursive: true }));
    }
  } else if (platform === "win32") {
    // Processes were stopped at the top; now the tasks that would relaunch
    // them at next logon.
    for (const task of [WINDOWS_DAEMON_TASK_NAME, WINDOWS_PILL_TASK_NAME, WINDOWS_LEGACY_UPDATER_TASK_NAME]) {
      record(`windows_task_${task}`, `Windows task ${task}`, () => {
        const deleted = runCommand("schtasks", ["/Delete", "/TN", task, "/F"]);
        const query = runCommand("schtasks", ["/Query", "/TN", task]);
        if (!query.ok) return { ok: true, removed: deleted.ok, alreadyAbsent: !deleted.ok };
        return { ok: false, detail: deleted.out || `The scheduled task ${task} still exists.` };
      });
    }
    for (const taskName of [WINDOWS_DAEMON_TASK_NAME, WINDOWS_PILL_TASK_NAME, RELAY_WINDOWS_LAUNCHER_TASK]) {
      const launcherPath = windowsHiddenLauncherPath(homeDir, taskName);
      record(`windows_launcher_${taskName}`, `Windows launcher ${taskName}`, () => removeOwnedPath(launcherPath));
    }
    // The Start Menu entry is the Windows counterpart to Relay.app above.
    record("windows_shortcut", "Relay's Start Menu shortcut", () => removeOwnedPath(windowsStartMenuShortcutPath(env, homeDir)));
  } else if (platform === "linux") {
    const paths = linuxDesktopPaths({ homeDir, env });
    record("linux_mime", "Relay's Linux protocol registration", () => removeLinuxRelayMimeDefaults({ homeDir, env }));
    for (const file of [
      paths.daemonUnitPath,
      paths.pillUnitPath,
      paths.applicationPath,
      path.join(path.dirname(paths.applicationPath), LINUX_LEGACY_DESKTOP_FILE),
      paths.autostartPath,
      paths.pillStarterPath,
    ]) {
      record(`linux_file_${file}`, file, () => removeOwnedPath(file));
    }
    record("linux_daemon_reload", "Linux service registry reload", () => runCommand("systemctl", ["--user", "daemon-reload"]));
    record("linux_desktop_reload", "Linux desktop registry reload", () => {
      const result = runCommand("update-desktop-database", [path.dirname(paths.applicationPath)]);
      return result.missing ? { ...result, ok: true, skipped: true } : result;
    });
  }
  record("mcp_broker_provisioning", "Relay's shared MCP broker state", () => {
    try {
      const removedBroker = removeMcpBrokerProvisioning({
        env,
        packageRoot: runningPackageRoot(),
        platform,
      });
      return removeOwnedPath(removedBroker.files.dir, { recursive: true });
    } catch (error) {
      return { ok: false, detail: error?.message || String(error) };
    }
  });

  const failures = steps.filter((step) => !step.ok);
  const removedDesktopPaths = Array.from(new Set(
    claudeDesktopRemoval.history.flatMap((result) => result.removedFrom || []),
  ));
  return {
    ok: failures.length === 0,
    steps,
    failures,
    services,
    updateAgents,
    claudeCode: { ok: claudeCliRemoval.ok && claudeConfigRemoval.ok && claudeHookRemoval.ok },
    codex: { ok: codexCliRemoval.ok && codexConfigRemoval.ok && codexHookRemoval.ok },
    claudeDesktop: { ...claudeDesktopRemoval, removedFrom: removedDesktopPaths },
    openSessionsNeedRestart: true,
  };
}

/** Human- and agent-readable uninstall result. Never claim success over a failed step. */
export function uninstallResultLines(result) {
  const lines = [];
  if (result?.ok) {
    lines.push("Removed Relay's MCP registrations and hooks from Claude Code, Codex, and Claude Desktop.");
    lines.push("Stopped Relay's background services and disconnected live Relay MCP processes.");
  } else {
    lines.push("Relay uninstall is incomplete.");
    for (const failure of result?.failures || []) {
      const attempts = Number(failure.attempts) || 1;
      lines.push(
        `Could not remove ${failure.label || failure.id || "a Relay component"} after ${attempts} ` +
        `${attempts === 1 ? "attempt" : "attempts"} (${uninstallFailureDetail(failure)}).`,
      );
    }
    lines.push("Relay has not reported a successful uninstall. Fix the items above, then run `relay uninstall` again.");
  }
  lines.push(
    "Restart any Claude or Codex sessions that were open during uninstall. A host cannot retract Relay instructions " +
    "already copied into a conversation's context; disconnecting the MCP process makes those instructions inert.",
  );
  return lines;
}

/**
 * The directories that hold everything Relay knows about this machine: the
 * pairing (`~/.relay/config.json` with the device token, plus logs and hidden
 * launchers) and the companion's working state (`~/.relay-companion`: ledger,
 * prefs, pill status, cached attachments). Honours the same env overrides the
 * readers use, so a test or a relocated install purges what it actually used.
 */
export function localStateDirs({ homeDir = os.homedir(), env = process.env } = {}) {
  const dirs = [
    env.RELAY_CONFIG_DIR || path.join(homeDir, ".relay"),
    env.RELAY_HOME || env.RELAY_COMPANION_HOME || path.join(homeDir, ".relay-companion"),
  ];
  return Array.from(new Set(dirs.map((dir) => path.resolve(dir))));
}

/** Electron's profile is outside Relay's ordinary state dirs on every desktop OS. */
export function electronProfileDirs({
  platform = process.platform,
  homeDir = os.homedir(),
  env = process.env,
} = {}) {
  if (platform === "win32") {
    return [path.resolve(env.APPDATA || path.join(homeDir, "AppData", "Roaming"), "Relay")];
  }
  if (platform === "darwin") {
    return [path.resolve(homeDir, "Library", "Application Support", "Relay")];
  }
  if (platform === "linux") {
    return [path.resolve(env.XDG_CONFIG_HOME || path.join(homeDir, ".config"), "Relay")];
  }
  return [];
}

function isLegacyBootstrapCliShimSource(source, { tempDir = os.tmpdir() } = {}) {
  if (typeof source !== "string" || !source.startsWith("#!/bin/sh\nexec ")) return false;
  const match = source.match(/^#!\/bin\/sh\nexec\s+'[^'\r\n]+'\s+'([^'\r\n]+[\\/]relay-cli\.cjs)'\s+"\$@"\s*$/);
  if (!match) return false;
  const launcher = path.resolve(match[1]);
  if (!pathContains(path.resolve(tempDir), launcher)) return false;
  return path.basename(path.dirname(launcher)).startsWith("relay-bootstrap-");
}

/** The companion package this process is executing from. */
export function runningPackageRoot() {
  return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
}

/** Whether `child` is `parent` or lives underneath it. Case-insensitive on Windows. */
export function pathContains(parent, child, platform = process.platform) {
  const norm = (p) => {
    const resolved = path.resolve(p);
    return platform === "win32" ? resolved.toLowerCase() : resolved;
  };
  const rel = path.relative(norm(parent), norm(child));
  if (rel === "") return true;
  return rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel);
}

/** The one file whose survival would leave this machine still paired. */
function pairingCredentialPath({ homeDir = os.homedir(), env = process.env } = {}) {
  return env.RELAY_CONFIG || path.join(env.RELAY_CONFIG_DIR || path.join(homeDir, ".relay"), "config.json");
}

/**
 * Forget this machine: delete the pairing and every byte of companion state, so
 * the next `relay setup` is a genuinely fresh device. Deliberately NOT part of
 * runUninstall — removing the integrations while keeping the pairing is what
 * makes a reinstall painless, and that stays the default.
 *
 * Callers revoke the device server-side BEFORE this runs (the token lives in
 * what is about to be deleted).
 *
 * Deletion ORDER is the whole design here, because since the canonical runtime
 * (~/.relay/runtime/releases/<id>) the tree being deleted can be the tree this
 * process is executing from:
 *
 *   1. the pairing credential first, alone. Every other file here is cleanup;
 *      this is the only one whose survival produces the one outcome a purge must
 *      never produce — a machine that reports success but is still paired.
 *   2. the remaining entries one at a time rather than the directory in one
 *      call, so a single locked file (an Electron binary a dying pill still
 *      holds) cannot spare everything beside it.
 *   3. the branch we are running from last, giving it the best chance of being
 *      the only casualty.
 *
 * Windows may hold a log or binary open for a moment after the daemon task ends,
 * hence the retries; whatever still survives is reported, never hidden.
 */
export function purgeLocalState({
  homeDir = os.homedir(),
  env = process.env,
  runningFrom = runningPackageRoot(),
  platform = process.platform,
  deleteCredential = deleteDeviceToken,
  deleteInstallationCredentials = deleteInstallationAuthorizationCredentials,
} = {}) {
  const removed = [];
  const failed = [];
  const remove = (target) => {
    if (!fs.existsSync(target)) return;
    try {
      fs.rmSync(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
      removed.push(target);
    } catch (error) {
      failed.push({ path: target, detail: error && error.message ? error.message : String(error) });
    }
  };

  const customConfig = Boolean(env.RELAY_CONFIG || env.RELAY_CONFIG_DIR);
  const sameNativeHome = (() => {
    const current = path.resolve(os.homedir());
    const requested = path.resolve(homeDir);
    return platform === "win32" ? current.toLowerCase() === requested.toLowerCase() : current === requested;
  })();
  const explicitNativeCredentialScope = env.RELAY_NATIVE_CREDENTIALS_WITH_CUSTOM_CONFIG === "1";
  const useNativeCredentials = platform === process.platform
    && (sameNativeHome || explicitNativeCredentialScope)
    && (!customConfig || explicitNativeCredentialScope);
  let credentialAccount = "device-token";
  try {
    const config = JSON.parse(fs.readFileSync(pairingCredentialPath({ homeDir, env }), "utf8"));
    credentialAccount = String(config.credentialAccount || credentialAccount);
  } catch {}
  const credential = useNativeCredentials
    ? deleteCredential({ platform, env, account: credentialAccount })
    : { ok: true };
  if (!credential.ok && (platform === "darwin" || platform === "win32" || platform === "linux")) {
    failed.push({ path: "native credential store", detail: credential.detail || "credential deletion failed" });
  }
  const installationCredentials = useNativeCredentials
    ? deleteInstallationCredentials({ platform, env })
    : { ok: true };
  if (!installationCredentials.ok && (platform === "darwin" || platform === "win32" || platform === "linux")) {
    failed.push({
      path: "native installation credential store",
      detail: installationCredentials.detail || "installation credential deletion failed",
    });
  }
  remove(pairingCredentialPath({ homeDir, env }));

  // The durable Linux command and one retired bootstrap handoff are the only
  // purge targets outside Relay's own state directories. Both have strict
  // generated identities, so an unrelated ~/.local/bin/relay is never touched.
  const { pointerPath, launcherPath, shimPath } = canonicalCliPaths({ homeDir });
  try {
    const shimSource = fs.readFileSync(shimPath, "utf8");
    if (
      (platform === "linux" && isCanonicalCliShimSource(shimSource, launcherPath))
      || isLegacyBootstrapCliShimSource(shimSource, { tempDir: env.TEMP || env.TMPDIR || os.tmpdir() })
    ) remove(shimPath);
  } catch {}
  if (platform === "linux") {
    try {
      const launcherSource = fs.readFileSync(launcherPath, "utf8");
      if (launcherSource === canonicalCliLauncherSource(pointerPath)) remove(launcherPath);
    } catch {}
  }

  for (const dir of localStateDirs({ homeDir, env })) {
    if (!fs.existsSync(dir)) continue;
    // ALWAYS entry by entry, never one rmSync on the directory — wherever we
    // are running from. Observed 2026-08-18 running from the global shim: one
    // locked daemon.log made the single rmSync throw, and every other file in
    // ~/.relay survived beside it. The credential was already gone (step 1),
    // so the machine was forgotten, but "forgotten" and "clean" should be the
    // same thing whenever the OS allows.
    let entries;
    try {
      entries = fs.readdirSync(dir).map((name) => path.join(dir, name));
    } catch (error) {
      failed.push({ path: dir, detail: error && error.message ? error.message : String(error) });
      continue;
    }
    for (const entry of entries.filter((e) => !pathContains(e, runningFrom, platform))) remove(entry);
    for (const entry of entries.filter((e) => pathContains(e, runningFrom, platform))) remove(entry);
    // Only now can the directory itself go. A surviving child makes this fail,
    // which is exactly what should be reported rather than swallowed.
    remove(dir);
  }

  for (const dir of electronProfileDirs({ platform, homeDir, env })) remove(dir);

  const pairingRemains = fs.existsSync(pairingCredentialPath({ homeDir, env }));
  return { ok: failed.length === 0, removed, failed, pairingRemains };
}

/**
 * Register the Relay MCP into Claude DESKTOP.
 *
 * Separate from `installClaudeCode` because they are different products with
 * different config files: the CLI reads ~/.claude.json, the desktop app reads
 * claude_desktop_config.json and never looks at the other. Writing that file is
 * the only unattended path — Claude Desktop has no CLI and no install deep link,
 * and a .mcpb extension cannot install without a human clicking a dialog.
 *
 * Writes every candidate directory that exists, because on Windows the app reads
 * a virtualised MSIX path while its own "Edit Config" button opens %APPDATA%.
 */
export function installClaudeDesktop(bin = relayBinPath(), node = stableNodePath(), { env = process.env } = {}) {
  const dirs = claudeDesktopConfigDirs({ env });
  if (!dirs.length) return { ok: false, reason: "claude_desktop_not_found" };

  const launch = mcpLaunchCommand({ mcpBin: bin, node: resolveStableNode({ execPath: node }) });
  const entry = claudeDesktopEntry({
    command: launch.command,
    args: launch.args,
    relayHome: env.RELAY_HOME || undefined,
  });

  const written = [];
  const swept = [];
  let lastError = null;
  for (const dir of dirs) {
    const configPath = claudeDesktopConfigPathIn(dir);
    try {
      const existing = fs.existsSync(configPath) ? fs.readFileSync(configPath, "utf8") : "";
      const { text, removed } = mergeClaudeDesktopConfig(existing, entry);
      // Atomic: the app watches this file and may be running.
      const tmp = `${configPath}.${process.pid}.${Date.now()}.tmp`;
      fs.mkdirSync(dir, { recursive: true });
      fs.writeFileSync(tmp, text, { mode: 0o600 });
      fs.renameSync(tmp, configPath);
      written.push(configPath);
      swept.push(...removed);
    } catch (error) {
      lastError = error && error.message ? error.message : String(error);
    }
  }

  if (!written.length) return { ok: false, reason: "claude_desktop_write_failed", detail: lastError };
  return { ok: true, method: "desktop_config", configPaths: written, swept };
}

/**
 * The uninstall twin of installClaudeDesktop. Without it every uninstalled
 * machine kept an orphaned `relay` stdio server that Claude Desktop tried, and
 * failed, to start on every launch — forever.
 *
 * Same discipline as the writer: every candidate directory, atomic rename
 * because the app watches the file, and a malformed file is left alone rather
 * than guessed at (deleting our key is not worth destroying their preferences).
 */
export function removeClaudeDesktopMcpConfig({ env = process.env, name = "relay" } = {}) {
  const dirs = claudeDesktopConfigDirs({ env });
  const removedFrom = [];
  const failures = [];
  for (const dir of dirs) {
    const configPath = claudeDesktopConfigPathIn(dir);
    if (!fs.existsSync(configPath)) continue;
    try {
      const cfg = readJsonObject(configPath);
      const servers = cfg.mcpServers;
      if (!servers || typeof servers !== "object" || Array.isArray(servers) || !(name in servers)) continue;
      delete servers[name];
      const tmp = `${configPath}.${process.pid}.${Date.now()}.tmp`;
      fs.writeFileSync(tmp, `${JSON.stringify(cfg, null, 2)}\n`, { mode: 0o600 });
      fs.renameSync(tmp, configPath);
      removedFrom.push(configPath);
    } catch (error) {
      failures.push({ configPath, detail: error && error.message ? error.message : String(error) });
    }
  }
  return { ok: failures.length === 0, removedFrom, failures };
}

/**
 * Claude Desktop can be centrally disallowed from running user-added stdio
 * servers. Writing a config that will never load, and calling it success, is the
 * failure mode this whole change exists to remove — so check first and say so.
 */
export function claudeDesktopLocalMcpDisabled({ platform = process.platform } = {}) {
  if (platform !== "darwin") return false;
  const res = run("defaults", ["read", "com.anthropic.claudefordesktop", "isLocalDevMcpEnabled"]);
  if (!res.ok) return false; // Unset: the default is enabled.
  return /^\s*(0|false|no)\s*$/i.test(String(res.out || ""));
}