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
+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