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
@@ -0,0 +1,58 @@
/**
* @file Tests for newBuildReload: after a reconnect the tab reloads once when
* the server's version is not this bundle's, never on the first connect,
* never for the same version, and not twice for one version.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { eventBus } from "../eventBus";
import { reloadOnNewBuild } from "../newBuildReload";
const flush = () => new Promise((r) => setTimeout(r, 0));
function setup(serverVersion: string) {
const reload = vi.fn();
const store = new Map<string, string>();
const storage = {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
};
const off = reloadOnNewBuild({
bundle: "2.5.4",
health: () => Promise.resolve({ version: serverVersion }),
reload,
storage,
});
return { reload, off };
}
let off: (() => void) | undefined;
afterEach(() => off?.());
describe("reloadOnNewBuild", () => {
it("reloads once after a reconnect onto a new version", async () => {
const s = setup("2.5.5");
off = s.off;
eventBus.setConnected(true); // first connect: nothing to compare against yet
await flush();
expect(s.reload).not.toHaveBeenCalled();
eventBus.setConnected(false);
eventBus.setConnected(true);
await flush();
expect(s.reload).toHaveBeenCalledTimes(1);
eventBus.setConnected(false); // same version again: a failed UI build must not loop
eventBus.setConnected(true);
await flush();
expect(s.reload).toHaveBeenCalledTimes(1);
});
it("stays put when the server is this bundle's version", async () => {
const s = setup("2.5.4");
off = s.off;
eventBus.setConnected(false);
eventBus.setConnected(true);
await flush();
expect(s.reload).not.toHaveBeenCalled();
});
});
+53 -44
View File
@@ -2099,13 +2099,6 @@ export const api = {
}),
},
// ──────────────────────────────── AI Router API ─────────────────────────────
/** Named AI Router endpoints (9Router, OpenRouter, a self-hosted gateway) a
* lane can be pointed at instead of the vendor's own API. Managed in the
* **AI Router** tab of Settings; attached to a lane when the lane is created.
*
* No response here ever carries an API key — a saved profile reports only
* `hasKey`. */
/** AgentHub's own git credentials (issue #45), shared by every user of this
* install. No response ever carries a password — only `hasToken`. */
gitCredentials: {
@@ -2219,37 +2212,44 @@ export const api = {
return `${BASE}/ai-tools/${encodeURIComponent(id)}/icon${q}`;
},
},
aiRouters: {
/** GET /api/ai-routers - every profile, the built-in `native` one first. */
list: () => request<{ items: AiRouter[] }>("/ai-routers"),
/** POST /api/ai-routers - save a new profile. */
create: (body: AiRouterInput) =>
request<AiRouter>("/ai-routers", { method: "POST", body: JSON.stringify(body) }),
// ──────────────────────────────── AI Proxy API ──────────────────────────────
/** Named AI Proxy endpoints (9Router, OpenRouter, a self-hosted gateway) a
* new session can run on instead of the vendor's own API. Managed in the
* **AI Proxy** page of Settings; chosen per session (`proxy` on a start).
*
* No response here ever carries an API key — a saved profile reports only
* `hasKey`. */
aiProxies: {
/** GET /api/ai-proxies - every profile, the built-in `native` one first. */
list: () => request<{ items: AiProxy[] }>("/ai-proxies"),
/** POST /api/ai-proxies - save a new profile. */
create: (body: AiProxyInput) =>
request<AiProxy>("/ai-proxies", { method: "POST", body: JSON.stringify(body) }),
/**
* PATCH /api/ai-routers/:name - change a saved profile. Omitting `apiKey`
* PATCH /api/ai-proxies/:name - change a saved profile. Omitting `apiKey`
* keeps the stored one, which is what the edit form always does: it cannot
* display a key, so it has none to send back.
*/
update: (name: string, body: Partial<AiRouterInput>) =>
request<AiRouter>(`/ai-routers/${encodeURIComponent(name)}`, {
update: (name: string, body: Partial<AiProxyInput>) =>
request<AiProxy>(`/ai-proxies/${encodeURIComponent(name)}`, {
method: "PATCH",
body: JSON.stringify(body),
}),
/**
* POST /api/ai-routers/test - ask the endpoint itself whether it works,
* before a profile that cannot reach anything is saved onto a lane.
* POST /api/ai-proxies/test - ask the endpoint itself whether it works
* for Claude Code and for Codex, before a session finds out it does not.
*/
test: (body: AiRouterTestInput) =>
request<AiRouterTestResult>("/ai-routers/test", {
test: (body: AiProxyTestInput) =>
request<AiProxyTestResult>("/ai-proxies/test", {
method: "POST",
body: JSON.stringify(body),
}),
/**
* DELETE /api/ai-routers/:name - also removes the session files that
* DELETE /api/ai-proxies/:name - also removes the session files that
* copy its key.
*/
remove: (name: string) =>
request<{ removed: string }>(`/ai-routers/${encodeURIComponent(name)}`, {
request<{ removed: string }>(`/ai-proxies/${encodeURIComponent(name)}`, {
method: "DELETE",
}),
},
@@ -3307,10 +3307,10 @@ export interface CcProfileUsage {
* user + project scope in one response. */
export type CcScope = "user" | "project" | "all";
/** The per-tier model names an AI Router profile carries. Each maps to one env
* var Claude Code reads; a router that names its models once fills them all
/** The per-tier model names an AI Proxy profile carries. Each maps to one env
* var Claude Code reads; a proxy that names its models once fills them all
* from the profile's `model`. */
export interface AiRouterModels {
export interface AiProxyModels {
opus: string;
sonnet: string;
haiku: string;
@@ -3318,7 +3318,6 @@ export interface AiRouterModels {
subagent: string;
}
/** One saved AI Router profile, as the API returns it — **never** with a key. */
/** A saved git login, as the server reports it: never the password. */
export interface GitCredential {
protocol: "http" | "https";
@@ -3362,8 +3361,8 @@ export interface AiTool {
resume: boolean;
/** Offers the per-run "Bypass permissions" switch. */
bypass: boolean;
/** Can use an AI Router profile (Claude, Codex); others are native only. */
routerCapable: boolean;
/** Can use an AI Proxy profile (Claude, Codex); others are native only. */
proxyCapable: boolean;
/** What Install runs; null when the catalog names no install. */
install: { kind: "npm"; package: string } | { kind: "script"; url: string } | null;
/** What Update runs; `self` is the tool's own updater with these args. */
@@ -3421,37 +3420,47 @@ export interface AiToolJob {
log: string;
}
export interface AiRouter {
/** Identifier, derived from the label. Stable: a lane stores this. */
/** One saved AI Proxy profile, as the API returns it — **never** with a key. */
export interface AiProxy {
/** Identifier, derived from the label. Stable: a session's run row stores it. */
name: string;
label: string;
/** True only for the read-only `native` profile (the vendor's own API). */
builtin: boolean;
/** Empty string for `native`, an http(s) URL otherwise. */
/** Empty string for `native`, else the proxy's http(s) ROOT, without `/v1`. */
baseUrl: string;
/** Whether a key is stored. The key itself never leaves the server. */
hasKey: boolean;
/** "" for `native`, which has no model of its own. */
model: string;
models: AiRouterModels;
/** Claude model id the router's own model names behave as; "" when unset. */
/** Empty for `native`. */
models: Partial<AiProxyModels>;
/** Claude model id the proxy's own model names behave as; "" when unset. */
behavesAs?: string;
savedAt?: string;
}
/** Outcome of POST /api/ai-routers/test. A router that answered with an error
* is still a successful HTTP call: `ok` is the ROUTER's verdict, `status` is
* its HTTP code (null when nothing answered at all), and `message` is its own
* words — truncated server-side, never the key. */
export interface AiRouterTestResult {
/** One probe of POST /api/ai-proxies/test. A proxy that answered with an
* error is still a successful HTTP call: `ok` is the PROXY's verdict, `status`
* is its HTTP code (null when nothing answered at all), and `message` is its
* own words — truncated server-side, never the key. */
export interface AiProxyProbe {
ok: boolean;
status: number | null;
message: string;
}
/** Body for POST /api/ai-routers/test. `name` lets the server reuse the stored
/** Outcome of POST /api/ai-proxies/test: Claude Code's `/v1/messages` and
* Codex's `/v1/responses`, probed side by side. */
export interface AiProxyTestResult {
claude: AiProxyProbe;
codex: AiProxyProbe;
}
/** Body for POST /api/ai-proxies/test. `name` lets the server reuse the stored
* key for a profile being edited — but only while `baseUrl` still matches the
* saved one, so a typed-in endpoint always needs a typed-in key. */
export interface AiRouterTestInput {
export interface AiProxyTestInput {
baseUrl: string;
model: string;
apiKey?: string;
@@ -3459,14 +3468,14 @@ export interface AiRouterTestInput {
}
/** Body accepted when creating or updating a profile. */
export interface AiRouterInput {
export interface AiProxyInput {
label: string;
baseUrl: string;
/** Omit on update to keep the stored key. */
apiKey?: string;
model: string;
/** Any tier left out is filled from `model`. */
models?: Partial<AiRouterModels>;
models?: Partial<AiProxyModels>;
/** A Claude model id; "" clears it. Omit on update to keep the stored one. */
behavesAs?: string;
}
@@ -4124,8 +4133,8 @@ export interface RunStartArgs {
resumeLast?: boolean;
/** Turn off the tool's permission prompts for THIS run (its catalog bypass args). */
bypass?: boolean;
/** AI Router profile THIS session runs on; omitted = native, or a resume's own. */
router?: string;
/** AI Proxy profile THIS session runs on; omitted = native, or a resume's own. */
proxy?: string;
}
/** A lane's tmux-backed terminal run — one per lane, id is the tmux session
+46
View File
@@ -0,0 +1,46 @@
/**
* @file newBuildReload.ts
* @description Reloads the tab onto the new UI build after the server was
* updated. Every update — the dashboard's own Update button in any tab,
* `/agenthub-update`, a restart onto a newer plugin — drops the WebSocket; when
* it comes back, `GET /api/health` names the server's version, and a version
* other than this bundle's `__APP_VERSION__` means the page is stale. At most
* one reload per version (sessionStorage), so a server whose UI build failed
* cannot make the tab reload on every reconnect.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { eventBus } from "./eventBus";
const KEY = "agenthub.reloadedFor";
export function reloadOnNewBuild({
bundle = __APP_VERSION__,
health = () => fetch("/api/health").then((r) => r.json() as Promise<{ version?: string }>),
reload = () => window.location.reload(),
storage = window.sessionStorage,
}: {
bundle?: string;
health?: () => Promise<{ version?: string }>;
reload?: () => void;
storage?: Pick<Storage, "getItem" | "setItem">;
} = {}): () => void {
let wasDown = false;
return eventBus.onConnection((up) => {
if (!up) {
wasDown = true;
return;
}
if (!wasDown) return;
wasDown = false;
health()
.then(({ version }) => {
if (!version || version === bundle || storage.getItem(KEY) === version) return;
storage.setItem(KEY, version);
reload();
})
.catch(() => {
/* the next reconnect asks again */
});
});
}
+1 -1
View File
@@ -259,7 +259,7 @@ export const SETTINGS_INDEX: {
{ section: "agents/codex", titleKey: "codexConfig:tabs.plugins", to: "?tab=plugins" },
{ section: "agents/antigravity", titleKey: "antigravityHooks.title", keywords: ["agy", "hooks"] },
{ section: "agents/*", titleKey: "toolTab.defaultModel", keywords: ["model", "config"] },
{ section: "ai-router", titleKey: "aiRouter.title", keywords: ["proxy", "endpoint", "key"] },
{ section: "ai-proxy", titleKey: "aiProxy.title", keywords: ["router", "endpoint", "key"] },
{ section: "pricing", titleKey: "pricing.title", keywords: ["cost", "price", "tokens"] },
{ section: "git-credentials", titleKey: "gitCreds.title", keywords: ["login", "token"] },
{ section: "worktrees", titleKey: "worktrees.showUntracked", keywords: ["hide", "suggestions"] },
+3 -4
View File
@@ -2652,10 +2652,9 @@ export interface Lane {
kind: "managed" | "adopted";
/** The CLI this lane's runs spawn. Immutable once the lane exists. */
tool: LaneTool;
/** Name of the AI Router profile this lane runs against, or null for the
* vendor's own API. Unlike `tool` this IS changeable after creation, but
* only through `POST /api/lanes/:id/router/apply` — the column and the
* lane's own config on disk must move together. */
/** Legacy: the per-workspace AI Proxy (then "AI Router") profile. A proxy
* is a session's choice now and the server empties this at every start, so
* it is always null. */
router_profile: string | null;
/** For a managed worktree lane, the repo it was provisioned from. Null for
* an adopted lane (it IS a source repo, not a worktree of one). */