/** * Operator console for customer Hermes runtimes on Fly. * * The Fly-era successor to `hermes-vps-desktop-setup`, which reaches the * Hostinger VPS over Tailscale. Fly customer machines are a different shape: * one machine per customer, no public service (`services: []`), and nothing * listening on the org private network. So instead of a tailnet hostname, the * console tunnels in and is deliberately *ephemeral*: * * laptop -> fly proxy (WireGuard) -> hallpass :22 -> ssh -L -> 127.0.0.1:9119 * * `hermes serve` starts on the machine's loopback only while an operator is * attached and stops on detach. A loopback bind keeps Hermes' own auth gate off * (`should_require_auth` treats 127.0.0.1 as trusted-operator), which is what * makes static session-token mode available — the only remote auth mode Hermes * Desktop can use without an interactive login provider. Any non-loopback bind * engages the gate, and these images register no auth providers. * * This module is pure: shell, network, and filesystem effects live in * scripts/agent-console.mjs. */ export declare const AGENT_CONSOLE_REMOTE_PORT = 9119; export declare const AGENT_CONSOLE_LOCAL_PORT_BASE = 19119; export declare const AGENT_CONSOLE_SSH_PORT_BASE = 10122; /** * Matches a console process line without matching the literal text of * the command that carries it. The bracket keeps `[h]` from appearing as `h` * in our own `/bin/sh -c` argv, so `pkill -f` cannot kill the ssh session that * issued it — a self-match that silently terminates the connection mid-run. * * Must cover how the process ACTUALLY appears, which is not how it is invoked: * `hermes --profile X dashboard` shows up as * `python3 -m hermes_cli.main -p default dashboard …`. A pattern anchored on * the literal "hermes serve" never matches it, so the stale server survives * every restart, keeps :9119 with its own token, and every new token is * rejected — 401 on REST, 403 on /api/ws. * * Deliberately does NOT match the customer's `… hermes --profile X gateway run`. */ export declare const REMOTE_PROCESS_MATCH_PATTERN = "[h]ermes.*\\b(serve|dashboard)\\b"; export type ConsoleAgent = { appName: string; machineId: string; state: string; displayName?: string; }; export type ConsolePlanEntry = { appName: string; machineId: string; profileName: string; localPort: number; sshPort: number; remoteUrl: string; }; export type ConsolePlan = { entries: ConsolePlanEntry[]; skipped: { appName: string; reason: string; }[]; }; /** True when `pattern` would match the command text that carries it. */ export declare function commandSelfMatchesPattern(command: string, pattern: string): boolean; export type LocalConsoleProcess = { pid: number; command: string; }; export type StaleConsoleReapPlan = { launcherPids: number[]; tunnelPids: number[]; }; export type ConsoleTunnelChildStatus = { label: string; exitCode: number | null; signalCode: string | null; }; /** * Identify stale console launchers and orphaned tunnel processes in a local * process listing. Two launchers cannot share a customer: each restart of the * remote serve mints a new token, so whichever launcher restarted it last * 401s the other's readiness probe forever. Tunnels are matched separately * because a launcher killed without its shutdown handler (SIGKILL, EPIPE * crash) leaves its fly proxy and ssh forward running with no parent. * * Launchers must look like `node … scripts/agent-console.mjs` — a mention of * the script path alone (an editor, a grep) is not a launcher. The caller's * own pid is never targeted. */ export declare function planStaleConsoleReap(input: { selfPid: number; processes: LocalConsoleProcess[]; }): StaleConsoleReapPlan; /** * Report a tunnel process that died before readiness polling begins. * * A bind collision makes `fly proxy` or `ssh -L` exit immediately. Treating * that as a merely-unready backend hides the real local failure inside the * 15-minute remote health window. The launcher checks this after each child * gets its startup grace period and fails while the cause is still obvious. */ export declare function describeConsoleTunnelStartupFailure(children: ConsoleTunnelChildStatus[]): string | null; export declare function agentConsoleProfileName(input: { appName: string; displayName?: string; }): string; /** * Deterministic port per app, stable across runs and independent of discovery * order, so a reshuffle can never point a saved session at another customer. */ export declare function allocateAgentConsolePorts(appNames: string[], base: number): Map; export declare function planAgentConsole(input: { agents: ConsoleAgent[]; }): ConsolePlan; /** Start command for the machine side. Carries no kill pattern by design. */ export declare function buildRemoteServeCommand(input: { token: string; port?: number; profileId?: string; }): string; export declare function buildRemoteStopCommand(): string; /** * Browser-UI variant. `hermes serve` is headless ("web UI disabled — use * `hermes dashboard` for the browser UI"); `hermes dashboard` serves the same * API plus the SPA from the image's own bundle at * /opt/hermes/hermes_cli/web_dist, so no build runs on the machine. * * This is the fallback when Hermes Desktop will not cooperate: the operator * opens the tunnelled URL in a browser and gets the customer's full Hermes, * Slack threads included. */ export declare function buildRemoteDashboardCommand(input: { token: string; port?: number; profileId?: string; }): string; /** Select the exact remote backend for one customer console. */ export declare function buildRemoteConsoleStartCommand(input: { token: string; dashboard: boolean; profileId?: string; port?: number; }): string; /** * Poll a slow customer runtime until its console API is genuinely ready. * * Fly machines can be busy with an existing agent turn or backup. The caller * must not launch Desktop or hand over a browser URL after an arbitrary sleep: * doing so makes Desktop convert a transient reset into an expired-session * screen. The injected clock/sleeper keep this deterministic in tests. */ export declare function waitForConsoleReady(input: { probe: () => Promise; timeoutMs?: number; pollIntervalMs?: number; requiredConsecutiveSuccesses?: number; onProgress?: (elapsedMs: number) => void; now?: () => number; sleep?: (ms: number) => Promise; }): Promise; export type ConsoleBackendRecoveryResult = { ready: boolean; restarted: boolean; }; /** * Recover the console backend after its Fly/SSH transport was rebuilt. * * A Fly Machine replacement kills the ephemeral `hermes serve` process along * with the old VM. Rebuilding only `fly proxy` + SSH leaves a healthy local * listener forwarding to an empty remote port forever. First give a surviving * backend one short grace window (the common laptop-network case); only when * the authenticated status route still cannot answer do we restart the * console-only server. The customer's separate Slack gateway is never part of * this restart sequence. */ export declare function recoverConsoleBackend(input: { probe: () => Promise; restart: () => Promise; waitForReady?: typeof waitForConsoleReady; graceTimeoutMs?: number; restartTimeoutMs?: number; }): Promise; export type ConsoleTunnelHealthDecision = "healthy" | "degraded" | "restart"; /** * Turn noisy tunnel probes into one deterministic recovery decision. * * A single failed fetch is normal while Fly or the laptop changes networks. * Requiring consecutive failures prevents restart thrash, while resetting the * counter after a successful recovery keeps a later outage independently * recoverable. */ export declare function createConsoleTunnelHealthGate(input?: { failureThreshold?: number; }): { observe: (healthy: boolean) => ConsoleTunnelHealthDecision; reset: () => void; consecutiveFailures: () => number; }; /** * Browser URL for a tunnelled dashboard. The token authenticates the SPA. * * Carries `?profile=` explicitly: `--isolated` scopes the server, but the UI's * chat route falls back to `default` on navigation, which shows the machine's * unconfigured profile (no sessions, and the base image's model) instead of the * agent's. */ export declare function buildDashboardUrl(input: { remoteUrl: string; token: string; profileId?: string; }): string; /** * Ordered commands to bring up a clean console. * * The stop MUST run first and as its own remote call. A server left from an * earlier session still holds :9119 with its own session token, so a new * `hermes serve` silently fails to bind and Desktop authenticates against the * old token — a 403 on /api/ws that presents as an endless "CONNECTING" while * REST still answers 200. They cannot be one command: the stop pattern would * match the start command's own argv and kill the ssh session issuing it. */ export declare function buildRemoteRestartSequence(input: { token: string; port?: number; profileId?: string; }): { stop: string; start: string; }; export declare function buildFlyProxyArgs(input: { appName: string; sshPort: number; }): string[]; export declare function buildSshTunnelArgs(input: { keyPath: string; certPath: string; sshPort: number; localPort: number; remotePort?: number; }): string[]; /** * Environment Desktop is launched with to target one customer runtime. * * The only fully scriptable path. Desktop's `connection.json` token field is * decoded through Electron safeStorage, so a plaintext value written there is * silently unusable and Desktop reports "no session token is saved". The env * override is read before any stored config, and leaves the operator's own * gateway configuration (the Hostinger tailnet remote) untouched. */ export declare function buildDesktopLaunchEnv(input: { remoteUrl: string; token: string; }): { HERMES_DESKTOP_REMOTE_URL: string; HERMES_DESKTOP_REMOTE_TOKEN: string; }; /** * Probe the exact authenticated REST path Hermes Desktop waits on at boot. * * A 404 from the lightweight root only proves that Uvicorn has bound the * socket. Desktop immediately calls `/api/status` with the session token and * gives that call 15 seconds; launching after only the root responds can still * strand the UI on its remote-gateway sign-in screen. Keep this timeout below * Desktop's so a successful probe is meaningful, and warm the same route * before the app starts. */ export declare function buildConsoleStatusProbe(input: { remoteUrl: string; token: string; timeoutMs?: number; }): { url: string; headers: { "X-Hermes-Session-Token": string; }; timeoutMs: number; }; export type CustomerSelection = { match: ConsoleAgent | null; choices: string[]; ambiguous?: string[]; }; /** Resolve a human customer name (workspace name) to exactly one agent. */ export declare function selectAgentByCustomer(agents: ConsoleAgent[], query: string): CustomerSelection; /** Read-only listing of the runtime profile directory on a customer machine. */ export declare const REMOTE_PROFILE_DISCOVERY_COMMAND = "ls -d /opt/data/profiles/*/ 2>/dev/null | head -1"; /** * The runtime's own profile id (e.g. `agent-4aaeadf71fe3c8b0975c839c`). * * This is the routing key. Desktop asks the backend for `?profile=`, and * anything else gets "Profile 'x' does not exist" — an empty session list and * no ability to send. */ export declare function parseRemoteProfileId(stdout: string): string | null; export type PerProfileOverride = { entry: ConsolePlanEntry; remoteProfileId: string; token: string; }; export type DesktopConnectionConfig = { mode?: string; remote?: unknown; profiles: Record; [key: string]: unknown; }; /** * Point every customer at its own tunnel in one Desktop, so the profile picker * becomes the customer switcher. * * Two details are load-bearing, both learned the hard way: * * - `mode: "local"`. The picker enumerates the PRIMARY backend's profiles. In * global remote mode that is one host, so overrides for other hosts never * appear. Local mode makes the local Hermes primary, and it lists local * profile directories — one per customer, named for the remote profile id. * - `token: { value }`. Desktop decodes this field through Electron * safeStorage; a bare string comes back empty and Desktop reports "no * session token is saved", which surfaces as a 403 on /api/ws. */ export declare function buildPerProfileConnectionConfig(existing: unknown, overrides: PerProfileOverride[]): { config: DesktopConnectionConfig; restorePoint: { mode?: string; remote?: unknown; }; }; /** * A dangling `~/.hermes/active_profile` breaks every `hermes profile` command * with "Profile 'x' does not exist" — including `profile use`, so the CLI * cannot repair itself. Deleting a profile that was previously made sticky is * enough to cause it. * * Returns the profile to write, or null when the pointer is already valid. */ export declare function repairedActiveProfile(current: string, available: string[]): string | null; /** Workspace names for Fly agent apps, read from the read-only Sellable DB. */ export declare const AGENT_CONSOLE_WORKSPACE_NAME_SQL = "\n SELECT r.\"appName\" AS app_name, w.\"name\" AS workspace_name\n FROM \"AgentFlyRuntime\" r\n JOIN \"Workspace\" w ON w.\"id\" = r.\"workspaceId\"\n";