feat: v2.5.5 — AI Router renamed AI Proxy, reload onto new builds, update no longer spins

AI Proxy (was AI Router):
- server/lib/ai-proxies.js, proxy-apply.js, routes/ai-proxies.js mounted at
  /api/ai-proxies and the old /api/ai-routers; `router` still accepted on the
  start routes; profiles move to <CLAUDE_HOME>/agenthub-proxies/
- dashboard_runs.router renamed in place to proxy: an older build on the same
  DB fails on that column, so dev and prod sharing one DB update together
- a proxy's baseUrl stored as its root (a typed /v1 made /v1/v1/messages);
  Test probes Claude /v1/messages and Codex /v1/responses
- Settings → AI Proxy (/settings/ai-router redirects), ProxyPicker

Updates:
- every tab reloads onto the new UI build when its WebSocket reconnects to a
  server of another version (lib/newBuildReload.ts, once per version)
- Update now stops waiting on a plugin chain that failed or pulled nothing
  (the old server never goes down), and reloads as soon as a new version answers

Also:
- a pane whose command exits at once says so (EPANEEXITED) instead of
  "Command failed: tmux set-option … mouse on"
- the pane drag preview draws where the pane actually lands
This commit is contained in:
2026-10-06 22:48:09 +07:00
parent dd9b23106c
commit 5fe5d51b1b
82 changed files with 1792 additions and 1412 deletions
@@ -1,30 +1,35 @@
/**
* @file ai-routers.js
* @description Named AI Router profiles — an endpoint (9Router, OpenRouter, a
* @file ai-proxies.js
* @description Named AI Proxy profiles — an endpoint (9Router, OpenRouter, a
* self-hosted gateway) plus the model names to use against it, saved once and
* attached to a lane when that lane is created.
* chosen by a new session (`proxy-apply.js`).
*
* A profile is exactly what the two CLIs need to talk to a router and nothing
* A profile is exactly what the two CLIs need to talk to a proxy and nothing
* more: `baseUrl`, `apiKey`, a default `model`, and one slot per tier mirroring
* the env keys Claude Code reads. Per-tier slots because a router may expose a
* the env keys Claude Code reads. Per-tier slots because a proxy may expose a
* different id per tier — and the `model` field fills every one of them,
* because the common case is a single symbolic name for everything.
*
* Profiles are stored one JSON file per profile under
* `<CLAUDE_HOME>/agenthub-routers/` (dir 0700, files 0600), the same shape and
* the same reasoning as `cc-profiles.js`: they hold a live API key, so **no
* route ever returns key material** — `listRouters` reports `hasKey` and
* nothing else about it.
* `baseUrl` is the proxy's ROOT, without `/v1`: Claude Code appends
* `/v1/messages` to it, and the Codex layer appends `/v1` itself. A `/v1`
* typed (or stored by an older build) is dropped by `normalizeBaseUrl`.
*
* `native` is a built-in profile rather than a branch in the caller: it carries
* empty connection fields and the vendor's own model names, so "switch back to
* native" is the same single write path as "switch to a router". It cannot be
* Profiles are stored one JSON file per profile under
* `<CLAUDE_HOME>/agenthub-proxies/` (dir 0700, files 0600; the pre-rename
* `agenthub-routers/` is moved there on first read, see `proxiesDir`), the same
* shape and the same reasoning as `cc-profiles.js`: they hold a live API key,
* so **no route ever returns key material** — `listProxies` reports `hasKey`
* and nothing else about it.
*
* `native` is a built-in profile rather than a branch in the caller: it has no
* endpoint and no model of its own (the session's model picker decides), so
* "use the vendor's own API" is a choice like any profile. It cannot be
* edited, removed, or shadowed by a saved profile.
*
* `testRouter` is the one thing here that talks to the network: the smallest
* real request a claude lane would make, so "saved" and "actually works" stop
* being the same claim. It is deliberately NOT a generic proxy — see the note
* on its stored-key fallback.
* `testProxy` is the one thing here that talks to the network: the smallest
* real request each CLI would make, so "saved" and "actually works" stop
* being the same claim. It is deliberately NOT a generic forwarder — see the
* note on its stored-key fallback.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -50,10 +55,8 @@ const MODEL_SLOTS = ["opus", "sonnet", "haiku", "smallFast", "subagent"];
const BUILTIN_NAME = "native";
/**
* The vendor's own defaults, used when a lane is set back to native. Empty
* connection fields rather than absent ones: the native reference settings.json
* sets both to "", and an absent key would let a user-level router base URL
* leak into a lane that asked for native.
* The vendor's own API. Never written anywhere (`resolveProxy` answers null for
* it), so it carries no model ids — the session's model picker picks.
*/
const NATIVE = Object.freeze({
name: BUILTIN_NAME,
@@ -61,14 +64,8 @@ const NATIVE = Object.freeze({
builtin: true,
baseUrl: "",
apiKey: "",
model: "claude-sonnet-5",
models: Object.freeze({
opus: "claude-opus-5",
sonnet: "claude-sonnet-5",
haiku: "claude-haiku-4-5-20251001",
smallFast: "claude-haiku-4-5-20251001",
subagent: "claude-haiku-4-5-20251001",
}),
model: "",
models: Object.freeze({}),
});
function err(code, message) {
@@ -77,15 +74,34 @@ function err(code, message) {
return e;
}
function routersDir() {
return path.join(getClaudeHome(), "agenthub-routers");
/**
* The profile store. The first read after the rename moves the old
* `agenthub-routers/` here and leaves a symlink at the old name, so an older
* build on the same CLAUDE_HOME still finds its profiles.
*/
function proxiesDir() {
const dir = path.join(getClaudeHome(), "agenthub-proxies");
if (fs.existsSync(dir)) return dir;
const legacy = path.join(getClaudeHome(), "agenthub-routers");
try {
if (fs.lstatSync(legacy).isSymbolicLink()) return dir;
fs.renameSync(legacy, dir);
} catch {
return dir; // no legacy store (or it could not be moved — read it next time)
}
try {
fs.symlinkSync("agenthub-proxies", legacy, "dir");
} catch {
/* best effort — the profiles themselves are already in place */
}
return dir;
}
function routerFile(name) {
function proxyFile(name) {
if (typeof name !== "string" || !NAME_RE.test(name)) {
throw err("EBADNAME", "name must be 1-40 chars of letters, digits, dot, dash or underscore");
}
return path.join(routersDir(), `${name}.json`);
return path.join(proxiesDir(), `${name}.json`);
}
function readJson(file) {
@@ -119,7 +135,7 @@ function slugify(text) {
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 32);
return NAME_RE.test(base) ? base : "router";
return NAME_RE.test(base) ? base : "proxy";
}
/** `base`, or `base-2`, `base-3`… — the first that is neither taken nor built in. */
@@ -127,7 +143,7 @@ function freeName(base) {
let name = base;
for (
let n = 2;
name === BUILTIN_NAME || fs.existsSync(path.join(routersDir(), `${name}.json`));
name === BUILTIN_NAME || fs.existsSync(path.join(proxiesDir(), `${name}.json`));
) {
name = `${base}-${n}`;
n += 1;
@@ -147,9 +163,10 @@ function normalizeLabel(label) {
}
/**
* A router endpoint is an http(s) URL and nothing else. A `file:` or
* `javascript:` base URL is never a router — it is a mistake, or an attempt to
* point a CLI somewhere it should not go.
* A proxy endpoint is an http(s) URL and nothing else. A `file:` or
* `javascript:` base URL is never a proxy — it is a mistake, or an attempt to
* point a CLI somewhere it should not go. A trailing `/v1` is dropped: the
* root is stored, and each CLI's path is added where it is used.
*/
function normalizeBaseUrl(baseUrl) {
const raw = typeof baseUrl === "string" ? baseUrl.trim() : "";
@@ -163,10 +180,10 @@ function normalizeBaseUrl(baseUrl) {
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw err("EBADURL", "baseUrl must be http:// or https://");
}
return raw.replace(/\/+$/, "");
return raw.replace(/\/+$/, "").replace(/\/v1$/, "");
}
/** A model name is free text — a router names its own models — but not blank. */
/** A model name is free text — a proxy names its own models — but not blank. */
function normalizeModel(model) {
const clean = typeof model === "string" ? model.trim() : "";
if (!clean || clean.length > 200 || /[\u0000-\u001f\u007f]/.test(clean)) {
@@ -190,10 +207,10 @@ function normalizeModels(model, models) {
}
/**
* A known Claude model id that Claude Code should treat the router's own model
* A known Claude model id that Claude Code should treat the proxy's own model
* names as (its `modelPicker` `behavesAs`, issue #56). Optional; `""` clears it.
* The user picks it, never AgentHub: it also sets the context window Claude
* Code compacts at, and a router may route a name to a non-Claude model.
* Code compacts at, and a proxy may route a name to a non-Claude model.
*/
function normalizeBehavesAs(value) {
const clean = typeof value === "string" ? value.trim() : null;
@@ -210,10 +227,17 @@ function assertNotBuiltin(name) {
}
}
/** Read one stored profile (key included), or null when it does not exist. */
function readRouter(name) {
const stored = readJson(routerFile(name));
return stored && typeof stored === "object" ? stored : null;
/**
* Read one stored profile (key included), or null when it does not exist. A
* base URL an older build stored with `/v1` comes back as the root.
*/
function readProxy(name) {
const stored = readJson(proxyFile(name));
if (!stored || typeof stored !== "object") return null;
if (typeof stored.baseUrl === "string") {
stored.baseUrl = stored.baseUrl.replace(/\/+$/, "").replace(/\/v1$/, "");
}
return stored;
}
/** The browser-safe view: everything except the key, plus whether one is set. */
@@ -226,11 +250,11 @@ function publicView(profile) {
* Every saved profile, with the built-in native one first.
* @returns `{ items }` — key material is never included.
*/
function listRouters() {
function listProxies() {
let names = [];
try {
names = fs
.readdirSync(routersDir())
.readdirSync(proxiesDir())
.filter((f) => f.endsWith(".json"))
.map((f) => f.slice(0, -5));
} catch {
@@ -242,7 +266,7 @@ function listRouters() {
if (!NAME_RE.test(name) || name === BUILTIN_NAME) continue;
let stored;
try {
stored = readRouter(name);
stored = readProxy(name);
} catch {
continue; // a corrupt profile file is skipped, not fatal for the list
}
@@ -254,12 +278,12 @@ function listRouters() {
/**
* One profile INCLUDING its key — for the server's own writers only. No route
* may return this shape; `listRouters` is what the browser sees.
* may return this shape; `listProxies` is what the browser sees.
*/
function getRouter(name) {
function getProxy(name) {
if (name === BUILTIN_NAME) return { ...NATIVE, models: { ...NATIVE.models } };
const stored = readRouter(name);
if (!stored) throw err("ENOTFOUND", `no router profile named '${name}'`);
const stored = readProxy(name);
if (!stored) throw err("ENOTFOUND", `no proxy profile named '${name}'`);
return { ...stored, name, builtin: false };
}
@@ -268,7 +292,7 @@ function getRouter(name) {
* models?:Record<string,string>, behavesAs?:string}} input
* @returns the browser-safe view of what was saved
*/
function createRouter(input = {}) {
function createProxy(input = {}) {
const label = normalizeLabel(input.label);
const slug = slugify(label);
assertNotBuiltin(slug);
@@ -285,19 +309,20 @@ function createRouter(input = {}) {
};
const name = freeName(slug);
writeJson(routerFile(name), profile);
writeJson(proxyFile(name), profile);
return publicView({ ...profile, name, builtin: false });
}
/**
* Merge changes into a saved profile. The `name` never changes — a lane stores
* it, and renaming would orphan that lane. An omitted `apiKey` keeps the stored
* one: the edit form cannot show a key, so it cannot send one back either.
* Merge changes into a saved profile. The `name` never changes — a session's
* run row stores it for a resume, and renaming would orphan that. An omitted
* `apiKey` keeps the stored one: the edit form cannot show a key, so it cannot
* send one back either.
*/
function updateRouter(name, changes = {}) {
function updateProxy(name, changes = {}) {
assertNotBuiltin(name);
const stored = readRouter(name);
if (!stored) throw err("ENOTFOUND", `no router profile named '${name}'`);
const stored = readProxy(name);
if (!stored) throw err("ENOTFOUND", `no proxy profile named '${name}'`);
const model = changes.model === undefined ? stored.model : normalizeModel(changes.model);
const next = {
@@ -320,14 +345,14 @@ function updateRouter(name, changes = {}) {
savedAt: new Date().toISOString(),
};
writeJson(routerFile(name), next);
writeJson(proxyFile(name), next);
return publicView({ ...next, name, builtin: false });
}
function removeRouter(name) {
function removeProxy(name) {
assertNotBuiltin(name);
const file = routerFile(name);
if (!fs.existsSync(file)) throw err("ENOTFOUND", `no router profile named '${name}'`);
const file = proxyFile(name);
if (!fs.existsSync(file)) throw err("ENOTFOUND", `no proxy profile named '${name}'`);
fs.rmSync(file);
return { removed: name };
}
@@ -335,59 +360,21 @@ function removeRouter(name) {
/** How long a connection test waits before calling the endpoint dead. */
const TEST_TIMEOUT_MS = 15_000;
/** Enough of a failing router's own words to be actionable, not enough to flood. */
/** Enough of a failing proxy's own words to be actionable, not enough to flood. */
const TEST_BODY_MAX = 300;
/**
* Probe a router endpoint with the smallest real request a claude lane makes:
* one Anthropic-style message capped at a single output token.
*
* Deliberately the CLAUDE wire shape and not Codex's `responses` one. A profile
* is not tool-specific, so a test has to pick; `/v1/messages` is what a claude
* lane's eight env keys point at, and it is the path a router has to serve for
* this feature to work at all.
*
* **The stored key is only ever sent to the endpoint it was stored against.**
* A blank `apiKey` falls back to the saved profile named by `name` — but only
* when the submitted `baseUrl` still matches the saved one. Without that check
* this route would be a way to make the server POST a saved API key to any URL
* a browser names, which is exactly the leak `listRouters` exists to prevent.
*
* A router that answers is a success even when it answers with an error: the
* outcome is reported (`ok`, `status`, `message`), never thrown. Only bad INPUT
* throws.
*
* @param {{name?:string, baseUrl:string, apiKey?:string, model:string}} input
* @returns {Promise<{ok:boolean, status:number|null, message:string}>}
*/
async function testRouter(input = {}) {
const baseUrl = normalizeBaseUrl(input.baseUrl);
const model = normalizeModel(input.model);
let apiKey = typeof input.apiKey === "string" ? input.apiKey.trim() : "";
if (!apiKey && input.name) {
const stored = readRouter(input.name);
if (stored && normalizeBaseUrl(stored.baseUrl || "") === baseUrl) apiKey = stored.apiKey || "";
}
if (!apiKey) throw err("EBADKEY", "enter the API key to test this endpoint");
/** POST `body` to `url`; the outcome is a result, never a throw. */
async function probe(url, apiKey, headers, body, model) {
let res;
try {
res = await fetch(`${baseUrl}/v1/messages`, {
res = await fetch(url, {
method: "POST",
headers: {
"content-type": "application/json",
// Both, because routers disagree on which one they read, and it is the
// same key going to the same endpoint either way.
"x-api-key": apiKey,
authorization: `Bearer ${apiKey}`,
"anthropic-version": "2023-06-01",
...headers,
},
body: JSON.stringify({
model,
max_tokens: 1,
messages: [{ role: "user", content: "ping" }],
}),
body: JSON.stringify(body),
signal: AbortSignal.timeout(TEST_TIMEOUT_MS),
});
} catch (e) {
@@ -403,22 +390,74 @@ async function testRouter(input = {}) {
message: e.name === "TimeoutError" ? "timed out" : (e.cause && e.cause.message) || e.message,
};
}
const body = await res.text().catch(() => "");
const text = await res.text().catch(() => "");
return {
ok: res.ok,
status: res.status,
message: res.ok ? model : body.slice(0, TEST_BODY_MAX) || res.statusText,
message: res.ok ? model : text.slice(0, TEST_BODY_MAX) || res.statusText,
};
}
/**
* Probe a proxy endpoint with the smallest real request each CLI makes, in
* parallel: Claude Code's Anthropic message (`/v1/messages`, one output token)
* and Codex's OpenAI Responses call (`/v1/responses`, the 16-token minimum). A
* profile is not tool-specific, so a proxy that serves only one wire shape
* shows up as one green line and one red.
*
* **The stored key is only ever sent to the endpoint it was stored against.**
* A blank `apiKey` falls back to the saved profile named by `name` — but only
* when the submitted `baseUrl` still matches the saved one. Without that check
* this route would be a way to make the server POST a saved API key to any URL
* a browser names, which is exactly the leak `listProxies` exists to prevent.
*
* A proxy that answers is a success even when it answers with an error: each
* outcome is reported (`ok`, `status`, `message`), never thrown. Only bad
* INPUT throws.
*
* @param {{name?:string, baseUrl:string, apiKey?:string, model:string}} input
* @returns {Promise<{claude: TestResult, codex: TestResult}>} where TestResult
* is `{ok:boolean, status:number|null, message:string}`
*/
async function testProxy(input = {}) {
const baseUrl = normalizeBaseUrl(input.baseUrl);
const model = normalizeModel(input.model);
let apiKey = typeof input.apiKey === "string" ? input.apiKey.trim() : "";
if (!apiKey && input.name) {
const stored = readProxy(input.name);
if (stored && normalizeBaseUrl(stored.baseUrl || "") === baseUrl) apiKey = stored.apiKey || "";
}
if (!apiKey) throw err("EBADKEY", "enter the API key to test this endpoint");
const [claude, codex] = await Promise.all([
probe(
`${baseUrl}/v1/messages`,
apiKey,
// `x-api-key` beside the Bearer header, because proxies disagree on which
// one they read, and it is the same key going to the same endpoint.
{ "x-api-key": apiKey, "anthropic-version": "2023-06-01" },
{ model, max_tokens: 1, messages: [{ role: "user", content: "ping" }] },
model
),
probe(
`${baseUrl}/v1/responses`,
apiKey,
{},
{ model, input: "ping", max_output_tokens: 16 },
model
),
]);
return { claude, codex };
}
module.exports = {
listRouters,
getRouter,
createRouter,
updateRouter,
removeRouter,
testRouter,
listProxies,
getProxy,
createProxy,
updateProxy,
removeProxy,
testProxy,
MODEL_SLOTS,
BUILTIN_NAME,
NATIVE,
+3 -3
View File
@@ -21,7 +21,7 @@ const CMD_RE = /^[A-Za-z0-9._+-]+$/;
const ENV_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
const LAUNCHERS = new Set(["claude", "codex"]);
const PROMPT_MODES = new Set(["argv", "flag", "after-start"]);
const ROUTER_CAPABLE = new Set(["claude", "codex"]);
const PROXY_CAPABLE = new Set(["claude", "codex"]);
const PKG_RE = /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
// Files shipped in a release (built-in, or synced from the newest release) are
// trusted to name a launcher and an install/update; a custom file is not.
@@ -417,7 +417,7 @@ function view() {
prompt: t.prompt ? { mode: t.prompt.mode } : null,
resume: !!t.resume,
bypass: !!(t.bypass || t.bypassEnv),
routerCapable: ROUTER_CAPABLE.has(t.id),
proxyCapable: PROXY_CAPABLE.has(t.id),
// claude/codex take a model through their own argv builders.
modelFlag: Boolean(t.model || t.launcher),
defaultModel: state.models[t.id] || null,
@@ -458,7 +458,7 @@ module.exports = {
which,
spawnTarget,
siteOf,
ROUTER_CAPABLE,
PROXY_CAPABLE,
CONFIG_SECRET_RE,
__setCatalogDirs,
};
+1 -1
View File
@@ -572,7 +572,7 @@ function patchSettings(args) {
}
// A patch that changes nothing writes nothing — and therefore backs up
// nothing. Re-applying the same AI Router profile to a lane is the common
// nothing. Re-applying the same AI Proxy profile to a lane is the common
// case (every re-attach sends the same eight env keys), and an unconditional
// backup turned that into a `.bak` per apply piling up inside the user's
// project. `changed` still lists the keys the caller asked for: they ARE the
+11 -11
View File
@@ -32,11 +32,11 @@ const insertStmt = db.prepare(`
INSERT OR REPLACE INTO dashboard_runs (
id, session_id, mode, cwd, model, permission_mode, effort,
resume_session_id, prompt_preview, status, exit_code, started_at, ended_at,
lane_id, tool, worktree, router
lane_id, tool, worktree, proxy
) VALUES (
@id, @session_id, @mode, @cwd, @model, @permission_mode, @effort,
@resume_session_id, @prompt_preview, @status, @exit_code, @started_at, @ended_at,
@lane_id, @tool, @worktree, @router
@lane_id, @tool, @worktree, @proxy
)
`);
@@ -51,7 +51,7 @@ const updateStmt = db.prepare(`
const RUN_COLUMNS = `id, session_id, mode, cwd, model, permission_mode, effort,
resume_session_id, prompt_preview, status, exit_code,
started_at, ended_at, lane_id, tool, worktree, router`;
started_at, ended_at, lane_id, tool, worktree, proxy`;
const listStmt = db.prepare(`
SELECT ${RUN_COLUMNS}
@@ -123,7 +123,7 @@ function recordRun(handle) {
lane_id: typeof handle.laneId === "number" ? handle.laneId : null,
tool: typeof handle.tool === "string" && handle.tool ? handle.tool : "claude",
worktree: handle.worktree ? 1 : 0,
router: handle.router || null,
proxy: handle.proxy || null,
});
} catch (err) {
// Best-effort by design (a live run must never fail because sqlite did),
@@ -181,19 +181,19 @@ function getRun(id) {
}
}
const sessionRouterStmt = db.prepare(`
SELECT router FROM dashboard_runs
const sessionProxyStmt = db.prepare(`
SELECT proxy FROM dashboard_runs
WHERE session_id = @sessionId OR resume_session_id = @sessionId
ORDER BY started_at DESC LIMIT 1
`);
/**
* The AI Router the newest run holding `sessionId` started on (null = native,
* or no run known) — what a resume that names no router goes back to.
* The AI Proxy the newest run holding `sessionId` started on (null = native,
* or no run known) — what a resume that names no proxy goes back to.
*/
function routerForSession(sessionId) {
function proxyForSession(sessionId) {
try {
return sessionRouterStmt.get({ sessionId })?.router || null;
return sessionProxyStmt.get({ sessionId })?.proxy || null;
} catch {
return null;
}
@@ -308,7 +308,7 @@ module.exports = {
getRun,
forgetShellRun,
reconcileOrphans,
routerForSession,
proxyForSession,
laneForSession,
claimToolSessions,
};
+4 -1
View File
@@ -233,7 +233,10 @@ function importExportBundle(db, bundle) {
// Session-independent, config-like tables: restore by natural PK, never
// overwriting a row the target machine already has.
for (const r of Array.isArray(bundle.dashboard_runs) ? bundle.dashboard_runs : []) {
if (r && r.id && insert.dashboard_runs(r).changes > 0) counters.dashboard_runs++;
// `router` is the pre-rename name of `proxy`, in a bundle an older build exported.
const row =
r && r.router !== undefined && r.proxy === undefined ? { ...r, proxy: r.router } : r;
if (row && row.id && insert.dashboard_runs(row).changes > 0) counters.dashboard_runs++;
}
for (const r of Array.isArray(bundle.alert_rules) ? bundle.alert_rules : []) {
if (r && r.id && insert.alert_rules(r).changes > 0) counters.alert_rules++;
+1 -1
View File
@@ -6,7 +6,7 @@
*
* One JSON file under `<CLAUDE_HOME>/agenthub-git-credentials/` (dir 0700,
* file 0600, written through a temp file), one record per protocol + host,
* shared by everyone using this AgentHub. Like the AI Router store, **no route
* shared by everyone using this AgentHub. Like the AI Proxy store, **no route
* ever returns a password** — `list` reports `hasToken`.
*
* `gitConfigFor` is how a credential reaches git: `-c` args that make OUR
+2 -2
View File
@@ -77,8 +77,8 @@ const VALID_RANGES = new Set(["work", "branch", "commit"]);
* pathspec. `git` is called with an argv array, so this is belt-and-braces. */
const SAFE_REV = /^[A-Za-z0-9][A-Za-z0-9._\/-]{0,200}$/;
/** The file whose CONTENT this module never returns, at any rev and at ANY
* depth: `settings.local.json` is where router-apply.js writes a lane's AI
* Router API key, and key material never leaves the server. Any depth,
* depth: `settings.local.json` is where AgentHub once wrote a lane's AI
* Proxy API key, and key material never leaves the server. Any depth,
* because a lane at the repo root sees a subdirectory lane's key as
* `sub/.claude/settings.local.json`. Lower-cased: a case-insensitive
* filesystem (macOS) opens it under any casing. */
+1 -1
View File
@@ -7,7 +7,7 @@
* The guards are the READ path's own (git-diff.js), so the editor can never
* write where the viewer could not read: a lexical `lanePath`, a realpath
* confined to the lane (no `..`, no symlink out), and no `settings.local.json`
* — the lane router's key file — at any depth or through any symlink. On top:
* — where a lane's proxy key once lived — at any depth or through any symlink. On top:
* only an EXISTING, textual file within the viewer's size cap, and only over
* the exact bytes the editor loaded (`baseSha`, from `readFileAt`), so a save
* never silently overwrites what the agent wrote in the meantime (ESTALE).
@@ -1,7 +1,7 @@
/**
* @file router-apply.js
* @description Starts ONE session on an AI Router profile (see `ai-routers.js`).
* A router is chosen per new session, not per workspace, so nothing here writes
* @file proxy-apply.js
* @description Starts ONE session on an AI Proxy profile (see `ai-proxies.js`).
* A proxy is chosen per new session, not per workspace, so nothing here writes
* into the user's project.
*
* The two CLIs are configured in different places, and the difference is
@@ -9,24 +9,27 @@
*
* - **Claude Code** gets `--settings=<file>`: a settings file holding the
* profile's eight `env` keys (plus `modelPicker` rows when the profile
* sets `behavesAs`), `<data dir>/router-settings/<name>.json`
* sets `behavesAs`), `<data dir>/proxy-settings/<name>.json`
* (0600). The command-line layer outranks the project-local and user
* settings (verified on 2.1.286), so two sessions of one workspace can run
* on two routers, and the key stays out of argv — only the path is there.
* on two proxies, and the key stays out of argv — only the path is there.
*
* - **Codex** has no project-level config at all. What it does have is
* `-p <name>`, which layers `$CODEX_HOME/<name>.config.toml` on top of the
* base. So a profile becomes a layer file, the base is never touched, and
* the key reaches the pane through tmux's own `-e` under the variable the
* layer's `env_key` names, never as typed text.
* layer's `env_key` names, never as typed text. Its `base_url` is the
* profile's root plus `/v1` (Codex appends `/responses`); Claude Code's
* `ANTHROPIC_BASE_URL` is the bare root (it appends `/v1/messages`).
*
* Both files are (re)written at every spawn, so an edited profile reaches the
* next session and a moved CODEX_HOME needs no repair. `refreshProfileFiles`
* rewrites them on an edit (a live Claude session reads its file again);
* `removeProfileFiles` deletes them with the profile.
*
* Workspaces once carried a router of their own in `lanes.router_profile` and
* `<cwd>/.claude/settings.local.json`; `clearLaneRouters` takes that out once.
* Workspaces once carried a proxy (then called a router) of their own in
* `lanes.router_profile` and `<cwd>/.claude/settings.local.json`;
* `clearLaneProxies` takes that out once.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -34,19 +37,19 @@
const fs = require("node:fs");
const path = require("node:path");
const routers = require("./ai-routers");
const proxies = require("./ai-proxies");
const { getDataDir } = require("./claude-home");
const { getCodexHome } = require("./codex-home");
const { ROUTER_CAPABLE } = require("./ai-tools");
const { PROXY_CAPABLE } = require("./ai-tools");
/**
* The env var Codex is told to read the router key from. One fixed name rather
* The env var Codex is told to read the proxy key from. One fixed name rather
* than one per profile: a Codex pane runs exactly one profile, so a second name
* would only create the chance of the wrong one being set.
*/
const CODEX_KEY_ENV = "AGENTHUB_ROUTER_KEY";
const CODEX_KEY_ENV = "AGENTHUB_PROXY_KEY";
/** The eight env keys a router sets for Claude Code. */
/** The eight env keys a proxy sets for Claude Code. */
function envForProfile(profile) {
return {
ANTHROPIC_BASE_URL: profile.baseUrl,
@@ -61,7 +64,7 @@ function envForProfile(profile) {
}
function claudeSettingsPath(name) {
return path.join(getDataDir(), "router-settings", `${name}.json`);
return path.join(getDataDir(), "proxy-settings", `${name}.json`);
}
function codexLayerPath(name) {
@@ -75,7 +78,7 @@ function writeSecret(file, body) {
}
/**
* `modelPicker` rows telling Claude Code which known model each of the router's
* `modelPicker` rows telling Claude Code which known model each of the proxy's
* own names behaves as — without one, a name like 9Router's `agent` draws an
* "isn't described by this version's model catalog" warning and a 200k
* compact window (issue #56). Only when the profile sets `behavesAs`: this
@@ -100,7 +103,7 @@ function writeClaudeSettings(profile) {
return file;
}
/** TOML string literal — router names and URLs are validated, quotes are not free. */
/** TOML string literal — proxy names and URLs are validated, quotes are not free. */
function tomlString(value) {
return `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
}
@@ -112,14 +115,14 @@ function tomlString(value) {
function writeCodexLayer(profile) {
const file = codexLayerPath(profile.name);
const body =
`# Written by AgentHub — AI Router profile ${profile.label}.\n` +
`# Written by AgentHub — AI Proxy profile ${profile.label}.\n` +
`# Loaded by \`codex -p ${profile.name}\`; the base config.toml is untouched.\n` +
`model = ${tomlString(profile.model)}\n` +
`model_provider = ${tomlString(profile.name)}\n\n` +
`[model_providers.${profile.name}]\n` +
`name = ${tomlString(profile.label)}\n` +
`base_url = ${tomlString(profile.baseUrl)}\n` +
// `env_key` only when there is a key to name. A local router (the 9Router
`base_url = ${tomlString(`${profile.baseUrl}/v1`)}\n` +
// `env_key` only when there is a key to name. A local proxy (the 9Router
// reference config is exactly this) needs none, and naming a variable that
// is never set makes Codex refuse to start.
(profile.apiKey ? `env_key = ${tomlString(CODEX_KEY_ENV)}\n` : "") +
@@ -133,26 +136,26 @@ function writeCodexLayer(profile) {
/**
* The profile a new session asked for, checked against its tool. `null`,
* `""` and `"native"` mean the vendor's own API and resolve to null.
* @throws EBADROUTER for a profile the store does not have, EUNSUPPORTED for a
* router on a tool that has no endpoint to point at one.
* @throws EBADPROXY for a profile the store does not have, EUNSUPPORTED for a
* proxy on a tool that has no endpoint to point at one.
*/
function resolveRouter(tool, name) {
if (name == null || name === "" || name === routers.BUILTIN_NAME) return null;
function resolveProxy(tool, name) {
if (name == null || name === "" || name === proxies.BUILTIN_NAME) return null;
if (typeof name !== "string") {
throw Object.assign(new Error("router must be a profile name"), { code: "EBADROUTER" });
throw Object.assign(new Error("proxy must be a profile name"), { code: "EBADPROXY" });
}
let profile;
try {
profile = routers.getRouter(name);
profile = proxies.getProxy(name);
} catch (e) {
if (e.code !== "ENOTFOUND") throw e;
throw Object.assign(new Error(`no AI Router profile named "${name}"`), {
code: "EBADROUTER",
throw Object.assign(new Error(`no AI Proxy profile named "${name}"`), {
code: "EBADPROXY",
});
}
if (profile.builtin) return null;
if (!ROUTER_CAPABLE.has(tool)) {
throw Object.assign(new Error(`a ${tool} session cannot use an AI Router`), {
if (!PROXY_CAPABLE.has(tool)) {
throw Object.assign(new Error(`a ${tool} session cannot use an AI Proxy`), {
code: "EUNSUPPORTED",
});
}
@@ -160,15 +163,15 @@ function resolveRouter(tool, name) {
}
/**
* The router a resume that names none goes back to: the one its session last
* The proxy a resume that names none goes back to: the one its session last
* ran on, while that profile still exists — a deleted one resumes on native.
*/
function resumeRouter(sessionId) {
function resumeProxy(sessionId) {
if (!sessionId) return null;
const name = require("./dashboard-runs").routerForSession(sessionId);
const name = require("./dashboard-runs").proxyForSession(sessionId);
if (!name) return null;
try {
routers.getRouter(name);
proxies.getProxy(name);
return name;
} catch {
return null;
@@ -176,11 +179,11 @@ function resumeRouter(sessionId) {
}
/**
* Argv and env one spawn needs for `profile` (from `resolveRouter`): Claude's
* Argv and env one spawn needs for `profile` (from `resolveProxy`): Claude's
* `--settings=<file>`, Codex's `-p <name>` plus the key. Writes the file.
* @returns {{argv: string[], env: Record<string,string>}}
*/
function routerForSpawn(tool, profile) {
function proxyForSpawn(tool, profile) {
if (!profile) return { argv: [], env: {} };
if (tool === "codex") {
writeCodexLayer(profile);
@@ -199,18 +202,19 @@ function routerForSpawn(tool, profile) {
* do not exist yet are written at the next spawn.
*/
function refreshProfileFiles(name) {
const profile = routers.getRouter(name);
const profile = proxies.getProxy(name);
if (fs.existsSync(claudeSettingsPath(name))) writeClaudeSettings(profile);
if (fs.existsSync(codexLayerPath(name))) writeCodexLayer(profile);
}
/**
* After a delete: the files hold the deleted router's key and endpoint. Left
* After a delete: the files hold the deleted proxy's key and endpoint. Left
* behind, a later profile that slugifies to the same name would also inherit
* this one's `base_url` in Codex.
* this one's `base_url` in Codex. The pre-rename `router-settings/` copy goes too.
*/
function removeProfileFiles(name) {
for (const file of [claudeSettingsPath(name), codexLayerPath(name)]) {
const legacy = path.join(getDataDir(), "router-settings", `${name}.json`);
for (const file of [claudeSettingsPath(name), codexLayerPath(name), legacy]) {
try {
fs.rmSync(file, { force: true });
} catch {
@@ -220,13 +224,13 @@ function removeProfileFiles(name) {
}
/**
* Take the old per-workspace router out, once: the eight env keys AgentHub
* Take the old per-workspace proxy out, once: the eight env keys AgentHub
* wrote into a claude workspace's `.claude/settings.local.json` (they would
* otherwise route every session there, native ones included) and the
* `lanes.router_profile` column. A lane whose file cannot be cleaned (not
* JSON, say) keeps its column and is tried again at the next start.
*/
function clearLaneRouters() {
function clearLaneProxies() {
const { db } = require("../db");
const { patchSettings } = require("./cc-mutate");
const keys = Object.keys(envForProfile({ models: {} })).map((k) => `env.${k}`);
@@ -240,17 +244,17 @@ function clearLaneRouters() {
}
db.prepare("UPDATE lanes SET router_profile = NULL WHERE id = ?").run(lane.id);
} catch (e) {
console.warn(`[router] could not clear workspace ${lane.id}'s old router: ${e.message}`);
console.warn(`[proxy] could not clear workspace ${lane.id}'s old proxy: ${e.message}`);
}
}
}
module.exports = {
resolveRouter,
resumeRouter,
routerForSpawn,
resolveProxy,
resumeProxy,
proxyForSpawn,
refreshProfileFiles,
removeProfileFiles,
clearLaneRouters,
clearLaneProxies,
CODEX_KEY_ENV,
};
+17 -17
View File
@@ -31,7 +31,7 @@
*/
const tmux = require("./tmux");
const { resolveRouter, routerForSpawn } = require("./router-apply");
const { resolveProxy, proxyForSpawn } = require("./proxy-apply");
const { desktopEnv } = require("./desktop-env");
const { getCodexHome } = require("./codex-home");
const aiTools = require("./ai-tools");
@@ -131,7 +131,7 @@ function buildClaudeArgv({
initialPrompt,
addDirs = [],
systemPromptFile = null,
routerArgv = [],
proxyArgv = [],
}) {
const argv = ["claude"];
// `--add-dir` is VARIADIC (`<directories...>`): it swallows every following
@@ -139,8 +139,8 @@ function buildClaudeArgv({
// always ends it — after the prompt it would eat the prompt as a directory.
if (addDirs.length) argv.push("--add-dir", ...addDirs);
argv.push("--permission-mode", permissionMode || "auto");
// The session's AI Router: `--settings=<file>` (router-apply.js).
argv.push(...routerArgv);
// The session's AI Proxy: `--settings=<file>` (proxy-apply.js).
argv.push(...proxyArgv);
// Claude Code makes the worktree itself (`.claude/worktrees/<name>`, branch
// `worktree-<name>`, `.worktreeinclude`, the user's WorktreeCreate hook).
// `=` form: `-w [name]` takes an OPTIONAL value and would otherwise swallow
@@ -182,7 +182,7 @@ function buildCodexArgv({
resumeLast,
bypass,
initialPrompt,
routerArgv = [],
proxyArgv = [],
addDirs = [],
systemPrompt = null,
systemPromptFile = null,
@@ -191,7 +191,7 @@ function buildCodexArgv({
// Before the subcommand and before the prompt: `-p` layers
// `$CODEX_HOME/<name>.config.toml` over the base config, and an option that
// lands after the positional prompt is not an option any more.
argv.push(...routerArgv);
argv.push(...proxyArgv);
if (resumeSessionId) argv.push("resume", resumeSessionId);
else if (resumeLast) argv.push("resume", "--last");
if (model) argv.push("-m", model);
@@ -266,7 +266,7 @@ function buildArgv(args, entry) {
* @param {boolean} [args.bypass] the user ticked "bypass permissions" for THIS
* run; appends the catalog's bypass args/env. Claude has no catalog bypass.
* @param {string} [args.initialPrompt]
* @param {string} [args.router] AI Router profile THIS session runs on — a
* @param {string} [args.proxy] AI Proxy profile THIS session runs on — a
* name only, its key and endpoint come from the store; null/"native" = the
* vendor's own API. Claude and Codex only (`EUNSUPPORTED`).
*/
@@ -288,7 +288,7 @@ function spawnRun(args) {
resumeLast,
bypass,
initialPrompt,
router,
proxy,
} = args || {};
if (typeof laneId !== "number" || !Number.isInteger(laneId)) {
throw makeErr("EBADLANE", "laneId must be an integer");
@@ -345,7 +345,7 @@ function spawnRun(args) {
if (worktree === true && entry.launcher !== "claude") {
throw makeErr("EUNSUPPORTED", `${tool} cannot start a session in its own worktree`);
}
const routerProfile = resolveRouter(tool, router); // ENOTFOUND, EUNSUPPORTED
const proxyProfile = resolveProxy(tool, proxy); // ENOTFOUND, EUNSUPPORTED
if (forkSession === true) {
if (!resumeSessionId) throw makeErr("EBADSESSION", "a fork needs resumeSessionId");
if (entry.launcher !== "claude") {
@@ -382,7 +382,7 @@ function spawnRun(args) {
laneId,
tool
);
const routerSpawn = routerForSpawn(tool, routerProfile);
const proxySpawn = proxyForSpawn(tool, proxyProfile);
// A new Claude session gets its id from us, so the run knows the session it
// holds from the start — a hook that lost AGENTHUB_RUN_ID (an older hook
// handler) still finds its pane by it (routes/hooks.js → bindRunSession).
@@ -406,7 +406,7 @@ function spawnRun(args) {
resumeLast,
bypass: bypass === true,
initialPrompt,
routerArgv: routerSpawn.argv,
proxyArgv: proxySpawn.argv,
},
entry
);
@@ -432,7 +432,7 @@ function spawnRun(args) {
effort: effort || null,
resumeSessionId: resumeSessionId || null,
worktree: worktree === true || inWorktree === true,
router: routerProfile ? routerProfile.name : null,
proxy: proxyProfile ? proxyProfile.name : null,
prompt: initialPrompt || "",
status: "running",
startedAt,
@@ -446,9 +446,9 @@ function spawnRun(args) {
// from inside it then resolves the lane exactly, instead of matching on cwd —
// which is ambiguous now that one directory can back both a `claude` lane and
// a `codex` lane (see bin/agenthub.js's resolveLaneArg).
// A codex session on an AI Router also needs the key its layer's `env_key`
// A codex session on an AI Proxy also needs the key its layer's `env_key`
// names. A claude one needs nothing here — its `--settings` file carries the
// router — and a session on the vendor's own API needs nothing either.
// proxy — and a session on the vendor's own API needs nothing either.
// The graphical session's own DISPLAY/WAYLAND_DISPLAY goes in too: the
// server normally runs as a systemd user service with neither, so a pane it
// starts has no clipboard at all — pasting an image into `claude` there
@@ -466,14 +466,14 @@ function spawnRun(args) {
...(process.env.PATH ? { PATH: process.env.PATH } : {}),
...desktopEnv(),
// A codex pane must read the same home the dashboard reads — its rollout
// journals and the router layer `-p <name>` loads live there — and the
// journals and the proxy layer `-p <name>` loads live there — and the
// tmux server's own env may predate, or never have had, CODEX_HOME.
...(tool === "codex" ? { CODEX_HOME: getCodexHome() } : {}),
// No CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN: an inline claude duplicated its
// output whenever the pane narrowed (tmux re-wraps the full-width rules and
// pushes lines into history its redraw cannot clear). The user's own
// `"tui"` setting decides; under fullscreen the pane scrolls in Claude.
...routerSpawn.env,
...proxySpawn.env,
...(bypass === true && entry.bypassEnv ? entry.bypassEnv : {}),
};
@@ -571,7 +571,7 @@ function spawnShell({ laneId, slot = 0, cwd }) {
effort: null,
resumeSessionId: null,
worktree: false,
router: null,
proxy: null,
prompt: "",
status: "running",
startedAt: Date.now(),
+12 -1
View File
@@ -77,7 +77,18 @@ function envFlags(env) {
*/
function newSession({ name, cwd, argv, env }) {
execImpl(["new-session", "-d", "-s", name, "-c", cwd, ...envFlags(env), "--", ...argv]);
enableMouse(name);
try {
enableMouse(name);
} catch (err) {
// The pane's command exited at once (a CLI that refuses the terminal tmux
// gives it, say) and tmux closed the session with it: say that, not the
// "Command failed: tmux set-option …" that hid the real problem.
if (hasSession(name)) throw err;
throw Object.assign(
new Error(`the session's command exited as soon as it started: ${argv[0]}`),
{ code: "EPANEEXITED" }
);
}
}
// Without this the pane has no scrollbar/drag scroll at all — wheel events