/** * Pure embed configuration logic, extracted from the omp extension so it can * be unit-tested without omp's runtime or a live Bun.serve. * * The embedded router runs IN the main omp process and binds a free * OS-assigned port (`port: 0`). Subagents do NOT bind their own router — they * route to the main session's router, whose bound port is published in a * single shared file. This avoids the PID-reuse race: subagents are ephemeral * worker processes whose PIDs get recycled, so keying a port file by PID means * a subagent can read a stale file written by a dead worker that reused its * PID. One shared file, written only by the main session, has exactly one * authoritative writer. */ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { normalizeOrigin } from "../src/context/scope.ts"; /** * The provider id registered into omp. Kept stable so a `models.yml` that * already pins `baseUrl`/`auth` for the same id is overridden by the * extension (extension registration wins at runtime). */ export const EMBED_PROVIDER_ID = "auto-model-router"; /** * A dummy bearer the extension registers so omp treats the provider as * authenticated. The router's `server.apiKey` is unset by default, so it does * not enforce auth; the value only needs to satisfy omp's "has credentials" * gate. */ export const EMBED_DUMMY_API_KEY = "embedded"; /** * Filename (in `$AUTO_MODEL_ROUTER_HOME` / `~/.auto-model-router`) of the shared embed port * file. Written only by the main session's router; read by every subagent and * the toast. A single writer and single file means there is never a stale * per-PID file pointing at a recycled process's dead port. */ export const EMBED_PORT_FILE = "embed.port"; export interface EmbedModelSpec { id: string; name: string; contextWindow: number; maxTokens: number; cost: { input: number; output: number; cacheRead: number; cacheWrite: number }; } export interface EmbedConfig { port: number; host: string; baseUrl: string; models: EmbedModelSpec[]; harnessId?: string; /** * agentdox project scope sent as `X-Agentdox-Scope`. Selects which * project's shared context is injected into every turn, so switching * models never loses the project's memory/docs/brief. */ agentdoxScope?: string; /** * The workspace's repository fingerprint (its git remote `origin`, * normalised) sent as `X-Agentdox-Origin`. A front door with a project * registry uses it to find the project whatever the folder is called; a * router alone ignores it. */ agentdoxOrigin?: string; } /** * Derives an agentdox project slug from the omp workspace directory. * * The workspace basename is the one identifier that is already stable, already * per-project, and requires no configuration — the same convention agentdox's * own `project_ensure` slugs follow. It WINS over `context.defaultScope`, * which is a fallback for workspaces it cannot resolve; see * `buildProviderConfig`. */ export function deriveAgentdoxScope(cwd: string): string { // Both separators: omp reports a Windows cwd with backslashes. const cleaned = cwd.replace(/[\\/]+$/, ""); const base = cleaned.split(/[\\/]/).pop() ?? ""; return base .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); } /** The first `url` under `[remote "origin"]` in a git config, or "" when there is none. */ function originUrlOf(config: string): string { let inOrigin = false; for (const line of config.split(/\r?\n/)) { const t = line.trim(); if (t.startsWith("[")) { inOrigin = /^\[remote\s+"origin"\]$/i.test(t); continue; } if (!inOrigin) continue; const m = /^url\s*=\s*(.+)$/i.exec(t); if (m !== null) return m[1]!.trim().replace(/^"(.*)"$/, "$1"); } return ""; } /** * Derives the workspace's repository fingerprint: the git remote `origin` of * the repository holding `cwd`, normalised by `normalizeOrigin`. * * The scope (`deriveAgentdoxScope`) is the FOLDER's name, which two unrelated * repositories can share and one repository cloned twice does not. The origin * is what a project registry needs to tell them apart, so it travels beside * the scope; the scope itself is untouched by this. * * Walks up from `cwd` to the filesystem root looking for `.git`. A directory * holds `config` directly; a file (a worktree or a submodule) names the real * git dir with `gitdir: `, relative to the folder holding the file, and * a worktree's own dir keeps the shared config one hop further in `commondir`. * Read with the filesystem only — never git itself, which may be absent, slow, * or prompt — and total: any failure, including no repository at all, is "". * `readFile` is injectable so the walk can be tested on hand-built files. */ export function deriveWorkspaceOrigin(cwd: string, readFile: (path: string) => string = (p) => readFileSync(p, "utf8")): string { const read = (path: string): string | null => { try { return readFile(path); } catch { return null; } }; try { if (cwd.trim() === "") return ""; let dir = resolve(cwd); for (;;) { const dotGit = join(dir, ".git"); // A directory: the config sits inside it. (Reading a directory throws.) let config = read(join(dotGit, "config")); if (config === null) { // A file: `gitdir: `; that dir has its own config (a // submodule) or points at the shared one through `commondir`. const pointer = read(dotGit); const m = pointer === null ? null : /^gitdir:\s*(.+?)\s*$/m.exec(pointer); if (m !== null) { const gitDir = resolve(dir, m[1]!); config = read(join(gitDir, "config")); if (config === null) { const common = read(join(gitDir, "commondir")); if (common !== null) config = read(join(resolve(gitDir, common.trim()), "config")); } // A `.git` file that leads nowhere is still the repository's // boundary: nothing above it is this workspace's origin. return config === null ? "" : normalizeOrigin(originUrlOf(config)); } } if (config !== null) return normalizeOrigin(originUrlOf(config)); const parent = dirname(dir); if (parent === dir) return ""; dir = parent; } } catch { return ""; } } /** * Resolves the desired bind port: an explicit `AUTO_MODEL_ROUTER_PORT` when set * and valid, else 0 so the OS assigns a free ephemeral port. * * Ephemeral is the DEFAULT ON PURPOSE: each interactive omp session gets its * OWN router process, so sessions cannot interfere with one another and no * session depends on another staying alive. `configuredPort` is honoured only * when a deployment asks for a fixed port explicitly (env var, or `server.port` * passed in by a caller that wants it), which is the shared/always-on shape * `serve` uses for other harnesses. */ export function resolveEmbedPort(envPort: string | undefined, configuredPort = 0): number { if (envPort !== undefined && envPort !== "") { const port = Number.parseInt(envPort, 10); if (Number.isInteger(port) && port >= 0 && port <= 65_535) return port; } if (Number.isInteger(configuredPort) && configuredPort > 0 && configuredPort <= 65_535) return configuredPort; return 0; } /** * The port omp's `models.yml` currently advertises for our provider, or null * when the block is absent or unparseable. * * This is the port omp resolves `modelRoles.default` against DURING STARTUP, * before this extension loads. If it disagrees with the port we end up serving, * this session's main-model handle points somewhere we are not listening, and * only a restart can rebuild it — there is no API to re-resolve an already * built handle. Detecting the mismatch is what turns a baffling * "Unable to connect" on every real turn into a message that names the cause. */ export function modelsYmlPort(text: string): number | null { const block = /^\s*auto-model-router:\s*$/m.exec(text); if (block === null) return null; const rest = text.slice(block.index); const url = /baseUrl:\s*http:\/\/[^\s:]+:(\d+)/.exec(rest); if (url === null) return null; const port = Number.parseInt(url[1] ?? "", 10); return Number.isInteger(port) ? port : null; } /** * Absolute path of the shared embed port file under a router home directory. */ export function embedPortPath(homeDir: string): string { return join(homeDir, EMBED_PORT_FILE); } /** * Persists the embedded router's actual bound port so subagents and the toast * can follow it. Creates the parent directory when absent. */ export function writeEmbedPort(path: string, port: number): void { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, String(port), "utf8"); } /** * Reads the embedded router's last-known port from the port file, or null when * the file is absent/unreadable/malformed. */ export function readEmbedPort(path: string): number | null { try { const raw = readFileSync(path, "utf8").trim(); const port = Number.parseInt(raw, 10); if (Number.isInteger(port) && port > 0 && port <= 65_535) return port; return null; } catch { return null; } } /** * Checks the shared router actually answers before a subagent registers it. * The port file outlives the process that wrote it, so a stale entry is normal: * registering against a dead port would produce a provider whose every turn * fails with connection refused. A failed health check means "bind your own". */ export async function probeEmbed(port: number, timeoutMs = 1_000): Promise { try { const ctl = new AbortController(); const timer = setTimeout(() => ctl.abort(), timeoutMs); try { const res = await fetch(`http://127.0.0.1:${port}/health`, { signal: ctl.signal }); return res.ok; } finally { clearTimeout(timer); } } catch { return false; } } /** * Builds the provider config for `pi.registerProvider(EMBED_PROVIDER_ID, …)` * given the shared bound port. `models` are the router's own `profiles`, mapped * into omp's provider-model shape (cost is USD per million tokens, same unit * `renderProviderBlock` uses for `config --write`). A wildcard listen address * maps to loopback, since a wildcard is not a connectable target. */ export function buildProviderConfig( port: number, cfg: { server: { host: string; harnessId?: string }; profiles: Array<{ id: string; name: string; contextWindow: number; maxTokens: number }>; ledger: { fallbackBlend: { inputPerMtok: number; outputPerMtok: number } }; context?: { enabled: boolean; defaultScope: string }; }, /** omp's workspace directory, used to derive a scope when none is configured. */ cwd?: string, /** The workspace's repository fingerprint (`deriveWorkspaceOrigin`); "" or absent sends none. */ origin?: string, ): EmbedConfig { const host = cfg.server.host === "0.0.0.0" || cfg.server.host === "::" ? "127.0.0.1" : cfg.server.host; const round = (v: number): number => Math.round(v * 1e4) / 1e4; const input = cfg.ledger.fallbackBlend.inputPerMtok; const output = cfg.ledger.fallbackBlend.outputPerMtok; const cacheRead = input * 0.1; const cacheWrite = input * 1.25; const models = cfg.profiles.map((p) => ({ id: p.id, name: p.name, contextWindow: p.contextWindow, maxTokens: p.maxTokens, cost: { input: round(input), output: round(output), cacheRead: round(cacheRead), cacheWrite: round(cacheWrite) }, })); const out: EmbedConfig = { port, host, baseUrl: `http://${host}:${port}/v1`, models, }; if (cfg.server.harnessId !== undefined && cfg.server.harnessId !== "") { out.harnessId = cfg.server.harnessId; } if (cfg.context?.enabled === true) { // The WORKSPACE wins. `defaultScope` is a scope-agnostic global — one // router install serves every project on the machine — so letting it // override the per-workspace derivation sends one project's slug for all // of them: an ashlands session shipped `X-Agentdox-Scope: omp-router`, // which both injected the wrong project's context and filed its turns // under the wrong scope. The server treats this field as a fallback too // ("the configured default covers harnesses that send none"), so the two // sides now agree: most specific signal first. const derived = deriveAgentdoxScope(cwd ?? ""); const scope = derived !== "" ? derived : cfg.context.defaultScope; if (scope !== "") out.agentdoxScope = scope; // The repository beside the folder: one value for every clone of it, // whatever each is called. Only a registry can use it, so it is sent // only where the scope is. if (origin !== undefined && origin !== "") out.agentdoxOrigin = origin; } return out; }