// Auto-start the ada backend if it isn't reachable. So new users running `ada` for the first time // don't have to also remember "start ada-server in another terminal." If the configured backend URL // is remote (not localhost), we DON'T spawn anything — the user clearly meant to point at a remote. // The spawned child is killed when this process exits. import { spawn } from "node:child_process"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "0.0.0.0", "::1", "[::1]"]); /** True if the backend URL points at this machine — the only case we auto-spawn for. */ export function isLocalBackend(backendUrl: string): boolean { try { return LOCAL_HOSTS.has(new URL(backendUrl).hostname); } catch { return false; } } /** The OpenAI-compatible model list. Every gateway worth pointing at answers this — it is what * "OpenAI-compatible" means — whereas /health is OUR path and nobody else implements it. */ export function modelsUrl(backendUrl: string): string { return `${backendUrl.replace(/\/+$/, "")}/models`; } /** Probe the backend's /health. The URL passed in is `/v1`; /health is at the base, not /v1. */ export function healthUrl(backendUrl: string): string { try { const u = new URL(backendUrl); u.pathname = "/health"; u.search = ""; return u.toString(); } catch { return `${backendUrl.replace(/\/+$/, "").replace(/\/v\d+$/, "")}/health`; } } // Plain node:http with agent:false, NOT fetch: undici's keep-alive socket from a probe lingers into // process teardown and deterministically prints "Assertion failed: !(handle->flags & // UV_HANDLE_CLOSING)" on Windows at exit. agent:false closes the socket with the response. function probe(url: string, timeoutMs = 800): Promise { return new Promise((resolve) => { import("node:http") .then((http) => { const req = http.get(url, { agent: false, timeout: timeoutMs }, (res) => { res.resume(); // drain so the socket can close resolve((res.statusCode ?? 500) < 400); }); req.on("timeout", () => req.destroy()); req.on("error", () => resolve(false)); }) .catch(() => resolve(false)); }); } /** Resolved path to bin/ada-server.mjs (sibling of bin/ada.mjs, packaged in the npm tarball). */ function serverBin(): string { return resolve(fileURLToPath(import.meta.url), "..", "..", "..", "bin", "ada-server.mjs"); } /** * If the backend isn't responding (and the URL is local), spawn `ada-server` as a child process and * wait up to `waitMs` for /health to come up. Returns `"running"` if already alive, `"started"` if * we spawned it, `"remote"` if the URL is remote (skipped), or `"failed"` if it didn't come up in * time. Sets `process.on(...)` handlers so the child dies with us. */ export async function ensureBackend(backendUrl: string, opts?: { quiet?: boolean; waitMs?: number }): Promise<"running" | "started" | "remote" | "failed"> { const probeUrl = healthUrl(backendUrl); if (await probe(probeUrl)) return "running"; // /health is ada-server's OWN path. Asking only that turns "is a usable backend here?" into "is // MY server here?" — so pointing ada at any other local gateway (OmniRoute, LiteLLM, a plain // llama.cpp server) made it try to boot ada-server over the top, fail, and never send the // request at all. If the URL serves an OpenAI-compatible model list, it is a backend; use it. if (await probe(modelsUrl(backendUrl))) return "running"; if (!isLocalBackend(backendUrl)) return "remote"; if (!opts?.quiet) process.stderr.write("\x1b[2mstarting ada-server…\x1b[0m "); const child = spawn(process.execPath, [serverBin()], { stdio: ["ignore", "ignore", "ignore"], detached: false, windowsHide: true }); child.unref(); // don't keep parent alive once parent's own work finishes const killChild = (): void => { try { if (!child.killed) child.kill(); } catch { /* ignore */ } }; process.once("exit", killChild); process.once("SIGINT", () => { killChild(); process.exit(130); }); process.once("SIGTERM", () => { killChild(); process.exit(143); }); child.once("error", () => { /* surfaced via the probe loop's timeout */ }); // 9s (not 5s): a cold first start now loads Better Auth's native better-sqlite3, which on a // fresh install can take a few seconds. Returns the instant /health responds, so a warm start // (≈1s) pays nothing extra — this only buys headroom for the cold case. const deadline = Date.now() + (opts?.waitMs ?? 9000); while (Date.now() < deadline) { await new Promise((r) => setTimeout(r, 150)); if ((await probe(probeUrl, 400)) || (await probe(modelsUrl(backendUrl), 400))) { if (!opts?.quiet) process.stderr.write("\x1b[32mok\x1b[0m\n"); return "started"; } if (child.exitCode != null) break; // child died — no point waiting more } if (!opts?.quiet) process.stderr.write("\x1b[31mfailed\x1b[0m\n"); killChild(); return "failed"; }