import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from "node:fs"; import { homedir, hostname as osHostname } from "node:os"; import { join, dirname } from "node:path"; import { getAllManifests, getManifest } from "agent-relay-providers"; import { DEFAULT_RELAY_URL, type SpawnProvider } from "agent-relay-sdk"; export interface OrchestratorConfig { id: string; hostname: string; relayUrl: string; token?: string; providers: SpawnProvider[]; baseDir: string; env: Record; heartbeatIntervalMs: number; tmuxPrefix: string; apiPort: number; } const DEFAULT_CONFIG_PATH = join(homedir(), ".agent-relay", "orchestrator.json"); const DEFAULT_PROVIDER_IDS = getAllManifests().map((manifest) => manifest.id as SpawnProvider); function envNumberOrDefault(name: string, fallback: number): number { return Number(process.env[name]) || fallback; } function envNonNegativeMax(name: string, fallback: number, min: number): number { return Math.max(min, envNumberOrDefault(name, fallback)); } export function gitShaFromEnv(): string | undefined { return process.env.AGENT_RELAY_GIT_SHA || process.env.GIT_SHA || undefined; } // #1452 — host-side gate for REVIEW-REF PUBLISHING (the origin-mutating half). The host must // decide this itself and NOT trust the relay/caller: a queued, replayed, or hand-crafted // publish-review command must NOT mutate origin unless THIS host has the feature enabled. The // operator sets AGENT_RELAY_REVIEW_PUBLISH=1 on both the relay AND the orchestrator to activate; // unset here ⇒ the host no-ops every publish (fail-safe OFF). UNPUBLISH is deliberately NOT // gated on this — a ref created during a prior ON era must still be cleanable after it flips OFF. export function reviewPublishEnabled(): boolean { return process.env.AGENT_RELAY_REVIEW_PUBLISH === "1"; } // #1452 round-3 (H5) — a hard ceiling on every review-ref git op that touches `origin`. Short // enough that a dead/hung remote fails fast instead of freezing the teardown/land/command loop that // awaits it, generous enough for a real push over a slow link. Clamped to a sane minimum. // // #1452 round-12 — the ceiling is now LOAD-BEARING, not just a fail-fast bound. The push fence // (fencePublishForPush) refreshes the relay's publish-command lease (WORKSPACE_COMMAND_TTL_MS = 40 min) // immediately before the push, and its correctness relies on the git-timeout-bounded push completing // WITHIN that fresh lease so the TTL sweep can never fire between the fence and the push. Without an // UPPER clamp, an operator misconfig (AGENT_RELAY_REVIEW_REF_GIT_TIMEOUT_MS above the lease) would let a // single git op outlast the lease → mid-op timeout → the later push races the reconciler's cleanup // (also LOW-1's over-cleanup of a live push). The 5-min ceiling keeps "the lease comfortably exceeds // every review-ref git op" a STRUCTURAL invariant — even a handful of sequential pre-push ops finish // well inside 40 min. A guard test asserts ceiling << lease so the two constants can't silently drift. export const REVIEW_REF_GIT_TIMEOUT_CEILING_MS = 5 * 60_000; export function reviewRefGitTimeoutMs(): number { return Math.min(REVIEW_REF_GIT_TIMEOUT_CEILING_MS, Math.max(1_000, Number(process.env.AGENT_RELAY_REVIEW_REF_GIT_TIMEOUT_MS) || 15_000)); } export function agentRelayHome(): string { return process.env.AGENT_RELAY_HOME || join(homedir(), ".agent-relay"); } export function artifactCacheDirFromEnv(): string { return process.env.AGENT_RELAY_ARTIFACT_CACHE_DIR || "~/.agent-relay/cache/artifacts"; } export function bunBinFromEnv(): string | undefined { return process.env.AGENT_RELAY_BUN_BIN; } export function providerCommandFromEnv(provider: string): string | undefined { const prefix = getManifest(provider)?.home?.envPrefix ?? provider.toUpperCase().replace(/[^A-Z0-9]+/g, "_"); return process.env[`AGENT_RELAY_${prefix}_COMMAND`]; } export function providerHomeRootFromEnv(): string { return process.env.AGENT_RELAY_PROVIDER_HOME_ROOT || join(homedir(), ".agent-relay", "provider-homes"); } // An ephemeral/test instance advertises AGENT_RELAY_EPHEMERAL=1 (see // src/execution-mode.ts). Mirrored here so the orchestrator package stays // dependency-free of the server package. export function isEphemeralMode(): boolean { return process.env.AGENT_RELAY_EPHEMERAL === "1"; } export function disableSystemdSupervisor(): boolean { // Force process (never systemd) supervision in ephemeral mode so an ephemeral // boot can never create/adopt host `agent-relay-*` systemd units (#1280). return process.env.AGENT_RELAY_DISABLE_SYSTEMD_SUPERVISOR === "1" || isEphemeralMode(); } export function forceSystemdSupervisor(): boolean { return process.env.AGENT_RELAY_FORCE_SYSTEMD_SUPERVISOR === "1"; } export function logDirFromEnv(): string | undefined { return process.env.AGENT_RELAY_LOG_DIR; } export function workspacePushEnabled(): boolean { return process.env.AGENT_RELAY_WORKSPACE_PUSH !== "0"; } export function workspaceDepsMode(): string { return (process.env.AGENT_RELAY_WORKSPACE_DEPS || "symlink").toLowerCase(); } export function tmuxSocketSweepEnabled(): boolean { return process.env.AGENT_RELAY_TMUX_SOCKET_SWEEP !== "0"; } export function tmuxSocketSweepIntervalMs(): number { return envNonNegativeMax("AGENT_RELAY_TMUX_SOCKET_SWEEP_INTERVAL_MS", 10 * 60 * 1000, 60_000); } // #1760 — a HARD per-socket bound on the sweep's `tmux list-sessions` liveness probe. The probe // runs via Bun.spawnSync ON the event loop; a WEDGED tmux server (process alive, socket // unresponsive) makes `list-sessions` block until the server answers — which, unbounded, freezes // the whole orchestrator, the command poll loop included (the wbox stall in #1760: healthy // heartbeat, commands crawling ~11–15 min behind). With the bound a single wedged socket costs at // most this long and its socket is KEPT (a wedged-but-present server must never have its socket // yanked). 2s is generous for a healthy `list-sessions` even under load; clamped so it can never be // set so low that a slow-but-live probe is misread. export function tmuxSocketSweepProbeTimeoutMs(): number { return envNonNegativeMax("AGENT_RELAY_TMUX_SOCKET_SWEEP_PROBE_TIMEOUT_MS", 2_000, 250); } // #1514 — reaper for orphaned, idle managed tmux sessions (e.g. a headless Claude // session wedged at the "Exit anyway / Move to background / Stay" dialog). On by // default; set AGENT_RELAY_WEDGED_SESSION_REAP=0 to disable. export function wedgedSessionReapEnabled(): boolean { return process.env.AGENT_RELAY_WEDGED_SESSION_REAP !== "0"; } export function wedgedSessionReapIntervalMs(): number { return envNonNegativeMax("AGENT_RELAY_WEDGED_SESSION_REAP_INTERVAL_MS", 5 * 60 * 1000, 60_000); } // A session must show no tmux activity for at least this long before an unmanaged // session is reaped. Generous so a brief spawn/registration race (record not yet // written) or a momentary state-file gap can never clip a healthy session; a real // wedge sits idle for hours/days. export function wedgedSessionIdleThresholdMs(): number { return envNonNegativeMax("AGENT_RELAY_WEDGED_SESSION_IDLE_THRESHOLD_MS", 30 * 60 * 1000, 5 * 60 * 1000); } export const TERMINAL_FLUSH_MS = envNonNegativeMax("AGENT_RELAY_TERMINAL_FLUSH_MS", 6, 0); export const TERMINAL_FLUSH_MAX_BYTES = envNonNegativeMax("AGENT_RELAY_TERMINAL_FLUSH_MAX_BYTES", 65536, 4096); export const TERMINAL_BACKPRESSURE_MAX_BYTES = envNonNegativeMax("AGENT_RELAY_TERMINAL_BACKPRESSURE_MAX_BYTES", 8 << 20, 1 << 20); export const TERMINAL_RESIZE_SETTLE_MS = envNonNegativeMax("AGENT_RELAY_TERMINAL_RESIZE_SETTLE_MS", 90, 0); export const TERMINAL_RESYNC_DEBOUNCE_MS = envNonNegativeMax("AGENT_RELAY_TERMINAL_RESYNC_DEBOUNCE_MS", 120, 0); export const TERMINAL_RESYNC_MAX_INTERVAL_MS = envNonNegativeMax("AGENT_RELAY_TERMINAL_RESYNC_MAX_INTERVAL_MS", 350, 0); export const TERMINAL_RESYNC_GROUND_RETRY_MS = envNonNegativeMax("AGENT_RELAY_TERMINAL_RESYNC_GROUND_RETRY_MS", 16, 1); export const TERMINAL_RESYNC_GROUND_DEFER_MAX_MS = envNonNegativeMax("AGENT_RELAY_TERMINAL_RESYNC_GROUND_DEFER_MAX_MS", 500, 0); export const TERMINAL_COMMAND_TIMEOUT_MS = envNonNegativeMax("AGENT_RELAY_TERMINAL_COMMAND_TIMEOUT_MS", 2000, 100); export const TERMINAL_GROUND_WAIT_MAX_MS = envNonNegativeMax("AGENT_RELAY_TERMINAL_GROUND_WAIT_MAX_MS", 500, 0); export const TERMINAL_BACKFILL_SCROLLBACK_LINES = envNonNegativeMax("AGENT_RELAY_TERMINAL_BACKFILL_SCROLLBACK", 1000, 0); export const TERMINAL_DEBUG = process.env.AGENT_RELAY_TERMINAL_DEBUG === "1"; export const SCHEDULER_COMMAND_MAX_CONCURRENT = envNonNegativeMax("AGENT_RELAY_SCHEDULER_COMMAND_MAX_CONCURRENT", 4, 1); export const SCHEDULER_COMMAND_MAX_OUTPUT_BYTES = envNonNegativeMax("AGENT_RELAY_SCHEDULER_COMMAND_MAX_OUTPUT_BYTES", 256 * 1024, 4096); interface RawConfig { id?: string; hostname?: string; relayUrl?: string; token?: string; providers?: string[]; baseDir?: string; env?: Record; heartbeatIntervalMs?: number; tmuxPrefix?: string; apiPort?: number; } function normalizeProviders(values: string[] | undefined): SpawnProvider[] { const requested = values?.map((value) => value.trim()).filter(Boolean) ?? []; const allowed = new Set(DEFAULT_PROVIDER_IDS); const normalized = requested.filter((value): value is SpawnProvider => allowed.has(value as SpawnProvider)); return normalized.length > 0 ? normalized : [...DEFAULT_PROVIDER_IDS]; } export function loadConfig(path?: string): OrchestratorConfig { const configPath = path || process.env.AGENT_RELAY_ORCHESTRATOR_CONFIG || DEFAULT_CONFIG_PATH; let raw: RawConfig = {}; if (existsSync(configPath)) { raw = JSON.parse(readFileSync(configPath, "utf8")); } const id = raw.id || process.env.AGENT_RELAY_ORCHESTRATOR_ID || osHostname().replace(/\./g, "-"); const hostname = raw.hostname || process.env.AGENT_RELAY_ORCHESTRATOR_HOSTNAME || osHostname(); const relayUrl = raw.relayUrl || process.env.AGENT_RELAY_URL || DEFAULT_RELAY_URL; const token = raw.token || process.env.AGENT_RELAY_TOKEN || undefined; const providers = normalizeProviders(raw.providers || process.env.AGENT_RELAY_ORCHESTRATOR_PROVIDERS?.split(",")); const baseDir = raw.baseDir || process.env.AGENT_RELAY_ORCHESTRATOR_BASE_DIR || join(homedir(), "projects"); const env = raw.env || {}; const heartbeatIntervalMs = raw.heartbeatIntervalMs || 30_000; const tmuxPrefix = raw.tmuxPrefix || "ar"; const apiPort = raw.apiPort || Number(process.env.AGENT_RELAY_ORCHESTRATOR_API_PORT) || 4860; return { id, hostname, relayUrl, token, providers, baseDir, env, heartbeatIntervalMs, tmuxPrefix, apiPort }; } export function initConfigFile(config: Partial): string { const configPath = DEFAULT_CONFIG_PATH; mkdirSync(dirname(configPath), { recursive: true }); const defaults: RawConfig = { id: osHostname().replace(/\./g, "-"), hostname: osHostname(), relayUrl: DEFAULT_RELAY_URL, providers: [...DEFAULT_PROVIDER_IDS], baseDir: join(homedir(), "projects"), apiPort: 4860, env: {}, }; const merged = { ...defaults, ...config }; writeFileSync(configPath, JSON.stringify(merged, null, 2) + "\n", { mode: 0o600 }); chmodSync(configPath, 0o600); return configPath; }