/** * Hub daemon — machine-level singleton for remote access. * * The hub is the ONLY public entry point (the port a tunnel maps). It does * token auth, instance discovery, and byte-level WebSocket proxying from a * remote client to one bridge's loopback ACP endpoint (ADR-0002), plus two * plain-HTTP conveniences that spare clients a full ACP round-trip (ADR-0005): * a proxied per-instance /status, and an account-level /api/quota queried * directly (quota belongs to the machine's credentials, not to any instance). * POST /api/upgrade lets a client TRIGGER a self-decided restart: the hub * re-checks whether the on-disk build is newer than the running process and, * only if so, re-spawns itself onto it — the decision is never the client's. * It holds no session state and understands no ACP — a proxied connection * stays bound to one instance for its whole lifetime. * * Bridges register via POST /api/register every 10s (the registration doubles * as the heartbeat; entries older than the heartbeat TTL are pruned). A client * that needs an immediately-honest list (e.g. a phone app's pull-to-refresh) * passes ?probe=1 to /api/instances: the hub TCP-probes each registered * loopback port and prunes unreachable bridges before answering — no periodic * probing, the cost is paid only when someone refreshes. When no instance is * registered and no proxy is active for `idleExitMs`, the hub re-reads the * user config LIVE: remote still enabled → stays resident (phone-driven * create/resume must work with zero local bridges); disabled → exits, and the * next bridge re-spawns it on demand. */ import { type ChildProcess } from "node:child_process"; import type { TerminalPrefs } from "../config/user-config.js"; export interface HubOptions { port: number; host: string; token: string; /** Registration TTL before an instance is pruned (default 30s). */ heartbeatTimeoutMs?: number; /** * How long an instance must stay probe-unreachable before ?probe=1 prunes it * (default 8s). A single failed probe only marks it unhealthy — a busy * bridge's event loop can stall past the connect timeout while fully alive. */ probeGraceMs?: number; /** Idle time with zero instances and zero proxies before exit (default 10min). */ idleExitMs?: number; /** WebSocket keepalive ping interval (default 30s; tunnels drop idle links). */ pingIntervalMs?: number; /** * How long a visible-terminal incubation (session-create/-resume) waits for * the spawned bridge's registration (default TUI_REGISTER_TIMEOUT_MS; tests * shrink it). */ tuiRegisterTimeoutMs?: number; /** * Fires when the hub decided it should restart onto newer on-disk code * (a newer bridge registered, or POST /api/upgrade found the dist newer). * The standalone daemon re-spawns a replacement before exiting (see * bin/hub.ts); falls back to onIdleExit when unset. */ onRestart?: () => void; /** * Override the on-disk locations /api/upgrade checks against (tests point * these at fixtures). Defaults: this package's package.json and the dist * directory this module runs from. */ codePaths?: { packageJson: string; distDir: string; }; /** * Override the frozen content fingerprint this hub compares bridges and * /api/upgrade against (tests inject fixtures). Default: read from the * code-fingerprint.json next to this module's dist root; null in dev/src. */ hubFingerprint?: string | null; /** * Override where the remote session-create endpoints read the known-project * whitelist from (tests point this at a fixture sqlite). Default: the App's * tasks-index.sqlite (see listKnownWorkspaces). */ projectsDbPath?: string; /** * Override the idle-exit stay-alive check (tests pin it). Default: live * re-read of the user config (remoteEnabledLive) — remote still enabled * means the hub stays resident so a phone can create/resume at any time; * only an explicit disable retires the daemon. */ stayAliveCheck?: () => boolean; /** * Override how the remote session-create / session-resume endpoints spawn * a bridge (tests inject a fake). Default: this node + this package's * dist/cli.js — an interactive TUI in a visible terminal for * session-create and session-resume ("tui"; resume carries the requested * session in ZCODE_ACP_RESUME_SESSION), a detached headless serve bridge * for background queries ("serve"). A "tui" attempt returns null when the * launch fails; the caller then walks down the terminal preference list * (terminalLaunches) and only spawns "serve" once every entry failed. */ spawnServe?: (opts: { cwd: string; env: NodeJS.ProcessEnv; /** "tui" = visible terminal (session-create/-resume); "serve" = detached headless. */ kind: "tui" | "serve"; /** The resolved launch for a "tui" attempt (one list entry). */ launch?: TerminalLaunch; }) => ChildProcess | null | Promise; /** * Override the ordered terminal preference list the create/resume * incubation walks (tests inject fixed entries). Default: resolved LIVE * per incubation from remoteTerminalPrefs (config file > env), so editing * the file takes effect without a hub restart. */ terminalLaunches?: TerminalLaunch[]; /** * How long after startup the hub ignores newer-bridge stale votes (tests * shrink to 0). Default STALE_VOTE_COOLDOWN_MS — the loop breaker so a * same-age respawn can never be voted into a restart churn. */ staleVoteCooldownMs?: number; } export interface HubHandle { port: number; close(): Promise; } /** How the hub hands the .command script to a terminal (ADR-0016). */ export type TerminalLaunch = /** ZCODE_ACP_HUB_TERMINAL_COMMAND: a shell command; `{script}` (if present) * is replaced by the quoted script path, else the path is appended. */ { kind: "shell"; command: string; } /** `.command`-executing apps (Terminal, iTerm): `open -a