Compare commits

...

9 Commits

Author SHA1 Message Date
nntrivi2001 8fdda66893 fix: v2.5.9 — Vietnamese in the terminal on macOS, every slash command, the right Claude account
Issue #62: a tmux client whose LC_ALL/LC_CTYPE/LANG names no UTF-8 prints `_`
for every non-ASCII character, and a macOS LaunchAgent starts the dashboard
with no locale at all — Vietnamese text and Claude Code's logo came out as
underscores in every browser terminal.

- shell-path.js: ensureUtf8Locale() defaults LC_CTYPE (en_US.UTF-8 on macOS,
  C.UTF-8 elsewhere) at start, before any tmux call; an explicit LC_ALL and
  Windows are left alone
- RunSetup.tsx: the `/` list now offers Claude Code's bundled skills
  (/code-review, /security-review, /simplify, /loop…) and newer built-ins, and
  every enabled plugin's commands and skills as /<plugin>:<name>
- Claude login decided by the TOKEN's owner (GET /api/oauth/profile, cached
  per token), not by .claude.json: a running session can leave the
  credentials store holding one account's token under another account's
  name. The Profiles modal, its Check quota and the status-bar quota now show
  the account requests are really billed to and warn on the mismatch; a
  switch saves the outgoing token into its OWNER's profile (credentials only)
  instead of overwriting the profile .claude.json names; saving a profile
  while they disagree is refused (EMISMATCH); a saved profile holding another
  account's token reports EWRONGACCOUNT instead of that account's usage
- Running Claude Code sessions follow a switch on their own (they re-read the
  credentials store when it changes), so the "close your sessions first"
  warning is gone
2026-10-07 11:18:10 +07:00
nntrivi2001 7543b8d62a fix: v2.5.8 — the browser terminal attaches on Windows
node-pty on Windows looks the file it starts up on PATH exactly as given and
never adds `.exe`, so `tmux attach-session` started as a bare `tmux` failed
with "File not found" on every attach: the browser's terminal socket opened,
closed, and reconnected every 1.5 s.

- pty-attach.js: attachBinary names tmux by its full file name on Windows
  (ai-tools.which("tmux"), else tmux.exe)
- websocket.js: an attach that throws is logged as `[WS] attach <run> failed`,
  the one place that says why the terminal keeps reconnecting
2026-10-07 00:23:32 +07:00
nntrivi2001 4163f431bb fix: v2.5.7 — panes work on psmux, the Windows tmux
psmux (the WinGet `tmux` on Windows) has no `=name` exact-match targets: it
answered "no server running on session '=agenthub-lane-1'" for a live pane,
so every Windows start reported "the session's command exited as soon as it
started" while the session ran on, and the browser could not attach.

- server/lib/tmux.js: `session`/`pane` targets probe the first live session
  once — `=name` failing where the bare name answers means bare names from
  then on; real tmux keeps `=name` / `=name:` (it matches by prefix otherwise)
- pty-attach.js and cc-update-all-helper.js use the same targets
2026-10-06 23:56:12 +07:00
nntrivi2001 fcf8d8010a fix: v2.5.6 — Windows panes without sh, UI build without node on PATH
- Windows panes: a WinGet tmux runs native programs and there is no `sh`, so
  every pane's `sh -c` wrapper died at once ("Command failed: tmux set-option
  … mouse on"). paneArgv runs the CLI itself on win32, and a Terminal gets
  tmux's own default shell (newSession drops the `--` for an empty argv).
- Plugin UI build: client deps install with --ignore-scripts and tsc/vite run
  by the running node instead of `npm run build`. On Windows esbuild's
  postinstall went through cmd.exe with no `node` on the server's PATH, both
  installs failed, and Update now kept the previous UI. A test keeps the two
  build steps equal to client/package.json's build script.
- Update all: Atomics.wait instead of a `sleep` binary Windows lacks.
2026-10-06 23:17:42 +07:00
nntrivi2001 5fe5d51b1b 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
2026-10-06 22:48:09 +07:00
nntrivi2001 dd9b23106c feat: v2.5.4 — Query Explorer folded into Sessions, Windows picker and paths
- Sessions page tabs: Sessions · Events · Agents. QueryExplorer.tsx became
  AgentsTable.tsx (agents only — sessions and events already had tabs);
  /query redirects to /sessions?view=agents and leaves the sidebar.
  GET /api/query is unchanged and still answers all three entities.
- Windows picker opens in front: -EncodedCommand script spends the hidden
  first ShowWindow that windowsHide gives the process, then brings the TopMost
  owner forward via AttachThreadInput (v2.5.3's owner was itself hidden).
- Session page Resume/Fork/Open pane: owningLane and sessionWorktreeName
  match Windows folders (either separator), with a test.
2026-10-06 21:56:51 +07:00
nntrivi2001 af014d9829 fix: v2.5.3 — Windows workspace folders, picker on top, no console flashes
A workspace could not be created on Windows: "cwd must be an absolute path"
for D:\…, because createLane/canonicalCwd tested for a leading "/".

- lanes.js: path.isAbsolute/path.resolve; canonicalCwd stops at any root (a
  missing drive looped forever); resolveLaneByCwd and /ensure accept "\" too
- native-dialog.js: the Windows picker gets an invisible TopMost owner form,
  shown first — an ownerless dialog from the background server opened behind
  the browser, or hidden
- windows-hide.js: every child process on Windows starts with windowsHide,
  required first by the server and the detached scripts (a console window
  flashed per git/npm call)
- Confirm/update/clear-data modals and the quota panel scroll instead of
  overflowing a short window
2026-10-06 20:22:34 +07:00
nntrivi2001 f8acd5fc29 fix: v2.5.2 — plugin bootstrap exports before it runs (v2.5.1 update crash)
v2.5.1's update died at "bootstrap" on every platform: boot.npmCommand is
not a function. Run as the entry, plugin-bootstrap.js calls bootstrap() →
plugin-open.js, which requires plugin-bootstrap back and got the module.exports
of that moment — {} — because it was assigned at the bottom, below the entry
block. plugin-open.js already exported first for the same reason.

- module.exports moved above the entry block in plugin-bootstrap.js
- A test checks both entry scripts assign module.exports before their entry
  block (it fails on v2.5.1's file); requiring the module, as the other tests
  do, never takes the entry path
2026-10-06 19:17:52 +07:00
nntrivi2001 be75572977 fix: v2.5.1 — plugin npm runs no longer depend on PATH (Windows update failure)
A Windows self-update failed at "bootstrap": 'npm.cmd' is not recognized.
The update chain runs with the dashboard server's environment, whose PATH
need not reach npm.cmd, and the bootstrap looked npm up through cmd.exe
(shell: true, which also printed DEP0190).

- npmCommand() in scripts/plugin-bootstrap.js runs the npm bundled with the
  running node (npm-cli.js beside process.execPath, Windows and Unix
  layouts) with no shell, and puts that node's directory first on PATH (kept
  under the env's own Path/PATH key) so npm scripts find the same node; npm
  on PATH only when that node ships none
- Used by the runtime install, the UI build (plugin-open.js) and the
  doctor's npm row (plugin-doctor.js)
2026-10-06 19:08:38 +07:00
126 changed files with 2932 additions and 1706 deletions
+2 -2
View File
@@ -2296,8 +2296,8 @@ The same shape is used by `GET /status`, `POST /check`, and the `update_status`
| Ahead of the newest release | `{update_available:false, dev_build:true, commits_ahead_of_release:N}` | Sidebar reads `Dev build · N ahead`; modal suppressed (a dev build is not an available update). |
| Shallow clone (incl. the plugin cache) | `{shallow_clone:true, commits_behind_release:null, commits_ahead_of_release:null, dev_build:false}` | No commit badge is rendered at all — the numbers would be graft artefacts. Version compare still decides `update_available`. |
| Newer release, ordinary checkout | `{update_available:true, situation:"release_available", latest_release:"vX.Y.Z", manual_command:"...git fetch origin --tags && git merge --ff-only vX.Y.Z..."}` | Modal opens titled with the release version, links its notes, and shows **Update now**. |
| Newer release, plugin-cache install | `{update_available:true, situation:"plugin_managed", install_kind:"plugin", plugin_marketplace:"sg", plugin_name:"agenthub", manual_command:"claude plugin marketplace update sg && ..."}` | Modal opens WITH **Update now**; `POST /apply` answers `200 {applied:true, mode:"plugin"}` and the detached chain pulls the new version and re-bootstraps. The modal notes that an already-open Claude Code session still needs `/reload-plugins` for new skills/commands. |
| Plugin chain ran but nothing was published | `{last_plugin_update:{ok:true, step:"already_current"}}` | The old server keeps running, untouched; no restart, no error banner. |
| Newer release, plugin-cache install | `{update_available:true, situation:"plugin_managed", install_kind:"plugin", plugin_marketplace:"sg", plugin_name:"agenthub", manual_command:"claude plugin marketplace update sg && ..."}` | Modal opens WITH **Update now**; `POST /apply` answers `200 {applied:true, mode:"plugin"}` and the detached chain pulls the new version and re-bootstraps. The modal notes that an already-open Claude Code session still needs `/reload-plugins` for new skills/commands. It then polls `status` and reloads once the old server went down and answered again, or as soon as a different `local_version` answers; a verdict of this chain (`finished_at` after the click) that is `ok:false` or `already_current` ends the wait and shows it, since the old server then never goes down. Independently, every tab reloads itself onto the new UI build when its WebSocket reconnects to a server whose `/api/health` `version` is not the bundle's (`client/src/lib/newBuildReload.ts`, once per version). |
| Plugin chain ran but nothing was published | `{last_plugin_update:{ok:true, step:"already_current"}}` | The old server keeps running, untouched; no restart, no error banner — a tab waiting on **Update now** stops waiting and says nothing new was pulled. |
| Plugin chain mid-handover | `{last_plugin_update:{ok:true, step:"bootstrap", note:"the new plugin version is installed; the server is being handed over"}}` | Written BEFORE the handover on purpose: that step retires the process tree the helper hangs off, and a helper killed there would otherwise leave `last_plugin_update: null` for an update that succeeded. Overwritten by `done` when the helper survives. |
| Plugin chain failed (no `claude` on PATH, `npm ci` broke, bootstrap died) | `{last_plugin_update:{ok:false, step:"resolve_claude"\|"plugin_update"\|"bootstrap", error:"..."}}` | Danger banner in the modal naming the failed step. A `bootstrap` failure means the old server was already stopped, so the verdict says to run `/agenthub-update` to recover — and it is read back by whichever server comes up next, because it lives outside the cache. |
| Plugin `/apply` with nothing newer published | 409 `{reason:"plugin_managed", release_check:"ok"}` | No chain is spawned. Message names the newest release and points at `/agenthub-update` to force a refresh. |
+6 -5
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+5 -3
View File
@@ -205,7 +205,8 @@ client/
│ │
│ ├── pages/ # Route pages
│ │ ├── Dashboard.tsx # Monitor / Analytics / Health tabs; Analytics' date range (Today/This week from Monday/This month from the 1st/7d/30d/90d/All/Custom, `?range=`, lib/dateRange.ts) narrows its headline, cost and token cards
│ │ ├── QueryExplorer.tsx # /query: filter sessions / events / agents, sort, page, export CSV/JSON (GET /api/query)
│ │ ├── History.tsx # /sessions tab shell (?view=): Sessions · Events (ActivityFeed) · Agents (AgentsTable)
│ │ ├── AgentsTable.tsx # /sessions?view=agents (the old /query redirects here): filter agents, sort, page, export CSV/JSON (GET /api/query?entity=agents)
│ │ ├── Sessions.tsx # Filterable table; shows each session's real name (synced live from the transcript), falls back to the short ID
│ │ ├── SessionDetail.tsx # Agent tree + event timeline + Conversation tab (slash-command pills & output, inline rename markers) + Replay tab (EventReplay) + Snapshot action
│ │ ├── SnapshotView.tsx # Read-only /snapshot/:token page over a frozen session payload
@@ -258,7 +259,7 @@ graph TB
Detail[SessionDetail<br/>agent hierarchy + timeline]
Feed[ActivityFeed<br/>streaming event log]
Workflows[Workflows<br/>Analytics → Workflow sub-tab:<br/>lane pipelines + D3 analytics]
Settings[SettingsHub /settings/:section<br/>General · Appearance · Notifications<br/>Agents: AI tools, Claude Code, Codex, each catalog tool<br/>Routing: AI Router, Pricing · Git: credentials, worktree suggestions]
Settings[SettingsHub /settings/:section<br/>General · Appearance · Notifications<br/>Agents: AI tools, Claude Code, Codex, each catalog tool<br/>Routing: AI Proxy · Cost: Pricing · Git: credentials, worktree suggestions]
App --> Layout
Layout --> Dashboard & Sessions & Detail & Feed & Settings
@@ -504,7 +505,8 @@ function App() {
<Route index element={<Dashboard />} />
<Route path="sessions" element={<History />} />
<Route path="sessions/:id" element={<SessionDetail />} />
<Route path="query" element={<QueryExplorer />} />
{/* legacy path - the Query Explorer is now the Agents tab on /sessions */}
<Route path="query" element={<Navigate to="/sessions?view=agents" replace />} />
{/* legacy path - the activity feed is now a tab on /sessions */}
<Route path="activity" element={<Navigate to="/sessions?view=events" replace />} />
<Route path="workflows" element={<Navigate to="/?tab=analytics&sub=workflow" replace />} />
+6 -11
View File
@@ -71,6 +71,7 @@ import { useWebSocket } from "./hooks/useWebSocket";
import { useNotifications } from "./hooks/useNotifications";
import { eventBus } from "./lib/eventBus";
import { copyOnCtrlShiftC } from "./lib/keys";
import { reloadOnNewBuild } from "./lib/newBuildReload";
import type { WSMessage } from "./lib/types";
// The heavy pages (xterm, the diff viewer, the settings editors) load on first
@@ -85,9 +86,6 @@ const SessionDetail = lazy(() =>
const SnapshotView = lazy(() =>
import("./pages/SnapshotView").then((m) => ({ default: m.SnapshotView }))
);
const QueryExplorer = lazy(() =>
import("./pages/QueryExplorer").then((m) => ({ default: m.QueryExplorer }))
);
function PageFallback() {
const { t } = useTranslation();
@@ -135,6 +133,10 @@ export default function App() {
return () => window.removeEventListener("vite:preloadError", onPreloadError);
}, []);
// The server came back as a newer version (any tab, any way it was updated):
// load its UI build instead of leaving the user to press F5.
useEffect(() => reloadOnNewBuild(), []);
// Ctrl+Shift+C is copy here (Linux terminal habit), not the browser's inspector.
useEffect(() => {
window.addEventListener("keydown", copyOnCtrlShiftC, true);
@@ -172,14 +174,7 @@ export default function App() {
</Page>
}
/>
<Route
path="query"
element={
<Page>
<QueryExplorer />
</Page>
}
/>
<Route path="query" element={<Navigate to="/sessions?view=agents" replace />} />
<Route path="activity" element={<Navigate to="/sessions?view=events" replace />} />
<Route
path="workflows"
+2 -2
View File
@@ -85,14 +85,14 @@ export function ConfirmModal({
>
<div
ref={panelRef}
className="relative w-full max-w-md rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
className="relative flex max-h-full w-full max-w-md flex-col rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={message ? messageId : undefined}
>
<div className="flex items-start gap-3 p-5">
<div className="flex min-h-0 items-start gap-3 overflow-y-auto p-5">
{destructive && (
<div className="w-9 h-9 rounded-lg bg-status-danger/10 border border-status-danger/20 flex items-center justify-center flex-shrink-0">
<AlertTriangle className="w-4.5 h-4.5 text-status-danger" aria-hidden />
-2
View File
@@ -28,8 +28,6 @@ export function DocumentTitle() {
title = t("workspace");
} else if (path.startsWith("/settings")) {
title = t("settings");
} else if (path.startsWith("/query")) {
title = t("query");
} else if (path !== "/") {
title = t("notFound");
}
-2
View File
@@ -69,7 +69,6 @@ import {
ChevronUp,
ChevronDown,
FolderTree,
TableProperties,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { api } from "../lib/api";
@@ -84,7 +83,6 @@ function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
const NAV_KEYS = [
{ to: "/", icon: LayoutDashboard, key: "nav:dashboard" },
{ to: "/sessions", icon: FolderOpen, key: "nav:sessions" },
{ to: "/query", icon: TableProperties, key: "nav:query" },
] as const;
// SmartGift wordmark, replacing the old text subtitle. Remote URLs: the logo is
+2 -1
View File
@@ -121,6 +121,7 @@ function quotaMeta(q: ToolQuota, t: (k: string, o?: Record<string, unknown>) =>
const QUOTA_ERROR_KEY: Record<string, string> = {
ELIVEEXPIRED: "statusBar.quotaExpired",
EPROFILEEXPIRED: "statusBar.quotaProfileExpired",
EWRONGACCOUNT: "statusBar.quotaWrongAccount",
};
/** One account: its name, sign-in line, then a row per window with its reset. */
@@ -208,7 +209,7 @@ function QuotaPanel({
onMouseLeave={onLeave}
onFocus={onEnter}
onBlur={onLeave}
className="fixed bottom-7 right-2 z-[9999] w-[19rem] max-w-[calc(100vw-1rem)] rounded-lg border border-border bg-surface-2 px-3 py-2.5 text-[11px] leading-snug text-fg-primary shadow-lg shadow-black/20"
className="fixed bottom-7 right-2 z-[9999] max-h-[calc(100vh-2.5rem)] w-[19rem] max-w-[calc(100vw-1rem)] overflow-y-auto rounded-lg border border-border bg-surface-2 px-3 py-2.5 text-[11px] leading-snug text-fg-primary shadow-lg shadow-black/20"
>
<div className="mb-2 font-semibold">{t("statusBar.quotaTitle")}</div>
{groups.map(([id, list]) => (
+39 -19
View File
@@ -85,6 +85,7 @@ import { Download, X, Copy, Check, RefreshCw, Zap, ExternalLink } from "lucide-r
import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import { copyText } from "../lib/clipboard";
import { toast } from "../lib/toast";
import { useDialog } from "../hooks/useDialog";
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
@@ -264,22 +265,41 @@ export function UpdateNotifier() {
* stretch, so polling naively would get a 200 from the process we are
* waiting to replace and reload onto the same old bundle.
*/
const pollUntilBack = useCallback((requireDownFirst = false) => {
let sawDown = !requireDownFirst;
const attempt = () => {
api.updates
.status()
.then(() => {
if (sawDown) window.location.reload();
else setTimeout(attempt, 1500);
})
.catch(() => {
sawDown = true;
setTimeout(attempt, 1500);
});
};
setTimeout(attempt, 1500);
}, []);
const pollUntilBack = useCallback(
(requireDownFirst = false, fromVersion: string | null = null) => {
let sawDown = !requireDownFirst;
// Its own verdicts only: an older plugin-update.json is still on disk
// until this chain writes one (5 s slack for a clock a little behind).
const since = Date.now() - 5000;
const attempt = () => {
api.updates
.status()
.then((s) => {
const v = s.last_plugin_update;
const mine = !!v?.finished_at && Date.parse(v.finished_at) >= since;
// A failed step, or nothing to pull, leaves the OLD server up: it
// never goes down, so waiting for that spun "Restarting…" forever.
if (requireDownFirst && v && mine && (v.ok === false || v.step === "already_current")) {
syncFromPayload(s);
setRestarting(false);
if (v.ok) toast.info(t("pluginAlreadyCurrent"));
return;
}
// A new version answering is back too, even if a poll never
// landed in the short gap between the old server and the new one.
const moved = !!fromVersion && !!s.local_version && s.local_version !== fromVersion;
if (sawDown || moved) window.location.reload();
else setTimeout(attempt, 1500);
})
.catch(() => {
sawDown = true;
setTimeout(attempt, 1500);
});
};
setTimeout(attempt, 1500);
},
[syncFromPayload, t]
);
const applyNow = async () => {
if (applying || restarting) return;
@@ -292,7 +312,7 @@ export function UpdateNotifier() {
setRestarting(true);
// mode "plugin": the old server lingers while the chain installs, so
// only treat a reachable server as "back" after it has gone away once.
pollUntilBack(result.mode === "plugin");
pollUntilBack(result.mode === "plugin", status?.local_version ?? null);
return;
}
setError(
@@ -330,7 +350,7 @@ export function UpdateNotifier() {
longest label is a whole sentence ("restarting the dashboard…"). */}
<div
ref={panelRef}
className="w-full max-w-2xl card shadow-2xl animate-slide-up overflow-hidden"
className="w-full max-w-2xl max-h-full flex flex-col card shadow-2xl animate-slide-up overflow-hidden"
>
{/* Header */}
<div className="flex items-start justify-between gap-3 px-5 py-4 border-b border-border">
@@ -370,7 +390,7 @@ export function UpdateNotifier() {
</div>
{/* Body */}
<div className="px-5 py-4 space-y-3">
<div className="min-h-0 overflow-y-auto px-5 py-4 space-y-3">
<p className="text-sm text-fg-secondary leading-relaxed">
{pluginManaged ? t("pluginAutoNote") : release ? t("releaseLead") : t("lead")}
</p>
@@ -222,6 +222,40 @@ describe("UpdateNotifier — Update now", () => {
});
});
describe("UpdateNotifier — plugin chain verdict", () => {
it("stops Restarting when the chain fails and the old server stays up", async () => {
const PLUGIN: UpdateStatusPayload = {
...BASE_STATUS,
situation: "plugin_managed",
install_kind: "plugin",
plugin_marketplace: "sg",
plugin_name: "agenthub",
commits_behind: 0,
local_version: "1.5.0",
release_check: "ok",
latest_release: "v1.6.0",
};
statusMock.mockResolvedValue(PLUGIN);
applyMock.mockResolvedValue({ ...PLUGIN, applied: true, mode: "plugin" });
render(<UpdateNotifier />);
await userEvent.click(await screen.findByText(i18n.t("updates:updateNow")));
await screen.findAllByText(i18n.t("updates:pluginUpdating"));
// The old server answers again, now carrying this chain's failed verdict.
statusMock.mockResolvedValue({
...PLUGIN,
last_plugin_update: {
ok: false,
step: "plugin_update",
error: "boom",
finished_at: new Date().toISOString(),
},
});
expect(await screen.findByText(/plugin_update/, {}, { timeout: 4000 })).toBeInTheDocument();
expect(screen.queryByText(i18n.t("updates:pluginUpdating"))).not.toBeInTheDocument();
});
});
describe("UpdateNotifier — closing vs skipping", () => {
it("Escape only closes for now; Skip this version is the persistent dismiss", async () => {
const { unmount } = render(<UpdateNotifier />);
@@ -88,7 +88,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
list: vi.fn(async () => ({ tools: [], default: "claude" })),
iconUrl: () => "",
},
aiRouters: { list: vi.fn(async () => ({ routers: [] })) },
aiProxies: { list: vi.fn(async () => ({ items: [] })) },
},
};
});
@@ -67,7 +67,7 @@ export function ConfirmDeleteModal<
aria-modal="true"
aria-labelledby="cc-confirm-delete-title"
aria-describedby="cc-confirm-delete-body"
className="relative w-full max-w-lg rounded-xl border border-status-danger/40 bg-surface-1"
className="relative flex max-h-full w-full max-w-lg flex-col rounded-xl border border-status-danger/40 bg-surface-1"
onClick={(e) => e.stopPropagation()}
>
<div className="border-b border-border px-4 py-2.5 flex items-center gap-2">
@@ -76,7 +76,7 @@ export function ConfirmDeleteModal<
{t("edit.confirmDelete")}
</span>
</div>
<div className="p-4 space-y-3">
<div className="min-h-0 overflow-y-auto p-4 space-y-3">
<p id="cc-confirm-delete-body" className="text-xs text-fg-secondary leading-relaxed">
{t("edit.confirmDeleteBody")}
</p>
@@ -26,15 +26,20 @@ import { ConfirmModal } from "../ConfirmModal";
import { startInFreeTabDetailed } from "./startSession";
import { liveRunFor, sessionWorktreeName } from "./WorkspaceSessionList";
/** A path without its trailing separators — `/` or a Windows `\`. */
const strip = (p: string) => p.replace(/[\\/]+$/, "");
/** The workspace of this tool whose folder holds `cwd` most deeply — the
* same path-boundary rule the server binds hook sessions with. */
export function owningLane(cwd: string, tool: string, lanes: Lane[]): Lane | null {
let best: Lane | null = null;
const target = strip(cwd);
for (const l of lanes) {
if (l.tool !== tool) continue;
const root = l.cwd.replace(/\/+$/, "");
if (cwd !== root && !cwd.startsWith(root + "/")) continue;
if (!best || root.length > best.cwd.replace(/\/+$/, "").length) best = l;
const root = strip(l.cwd);
if (target !== root && !target.startsWith(root + "/") && !target.startsWith(root + "\\"))
continue;
if (!best || root.length > strip(best.cwd).length) best = l;
}
return best;
}
@@ -74,7 +79,7 @@ export function SessionResumeButton({
owningLane(session.cwd, tool, lanes));
if (!lane || runs === null) return null;
const run = liveRunFor(lane, runs, session.id);
const same = (a: string, b: string) => a.replace(/\/+$/, "") === b.replace(/\/+$/, "");
const same = (a: string, b: string) => strip(a) === strip(b);
// Its own worktree counts too: the server resumes it there (`claude
// --worktree` sessions), and refuses one whose worktree is gone.
const resumable =
+17 -17
View File
@@ -7,7 +7,7 @@
* `ConsoleTabs.tsx`). Inside
* the box, like Claude Desktop's: AI (the session's own tool, the workspace's
* by default) and one Model chip (`ModelMenu`: the tool's own list or the
* router's models, plus Effort); under it the quieter choices — AI Router
* proxy's models, plus Effort); under it the quieter choices — AI Proxy
* (Native or a profile), Permission (Claude's mode, Codex's sandbox/approval,
* a catalog tool's bypass) and Local / New worktree — the workspace's Recents — the sessions it
* owns and has not archived (`?lane=&archived=0`, with each one's state dot,
@@ -58,7 +58,7 @@ import { useAiTools } from "../../hooks/useAiTools";
import { useProjectTreePrefs } from "../../lib/projectTreePrefs";
import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "../run/RunSetup";
import { AiToolIcon } from "../AiToolIcon";
import { RouterPicker, routerArg, useAiRouters, useSessionRouter } from "../run/RouterPicker";
import { ProxyPicker, proxyArg, useAiProxies, useSessionProxy } from "../run/ProxyPicker";
import { Select } from "../Select";
import { MarkdownPreview } from "../MarkdownPreview";
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu";
@@ -143,25 +143,25 @@ function HomeBody({
}
}, [draftKey, prompt]);
const [images, setImages] = useState<PromptImage[]>([]);
const [router, setRouter] = useSessionRouter();
const routers = useAiRouters();
// Claude and Codex, also before the catalog has loaded (as RouterPicker).
const routerCapable = entry?.routerCapable ?? (claude || codex);
const [proxy, setProxy] = useSessionProxy();
const proxies = useAiProxies();
// Claude and Codex, also before the catalog has loaded (as ProxyPicker).
const proxyCapable = entry?.proxyCapable ?? (claude || codex);
// A profile picked for a tool that can use one; its models replace the tool's own.
const profile = routerCapable
? (routers?.find((r) => r.name === router && !r.builtin) ?? null)
const profile = proxyCapable
? (proxies?.find((r) => r.name === proxy && !r.builtin) ?? null)
: null;
const routerModels: ModelChoice[] | undefined = profile
const proxyModels: ModelChoice[] | undefined = profile
? [
...new Set(
[profile.model, profile.models.opus, profile.models.sonnet, profile.models.haiku].filter(
Boolean
(m): m is string => !!m
)
),
].map((id) => ({ id, label: id }))
: undefined;
// The tool's default model (also when the catalog arrives after mount); on
// a router, the router's own default. A new tool or router drops the pick.
// a proxy, the proxy's own default. A new tool or proxy drops the pick.
const defaultModel = profile ? "" : (entry?.defaultModel ?? "");
const [model, setModel] = useState(defaultModel);
useEffect(() => setModel(defaultModel), [defaultModel, tool, profile?.name]);
@@ -319,7 +319,7 @@ function HomeBody({
// Only a different tool is named: the workspace's own keeps starting
// even after it was disabled in Settings.
...(tool !== lane.tool ? { tool } : {}),
...routerArg(routerCapable, router),
...proxyArg(proxyCapable, proxy),
...(hasModel && model ? { model } : {}),
...(claude ? { permissionMode: mode } : {}),
...(codex ? { sandbox, approval } : {}),
@@ -533,7 +533,7 @@ function HomeBody({
value={model}
onChange={setModel}
tool={tool}
choices={routerModels}
choices={proxyModels}
{...(hasEffort ? { effort, onEffortChange: setEffort } : {})}
/>
)}
@@ -556,12 +556,12 @@ function HomeBody({
data-testid="workspace-home-options"
className="mt-1.5 flex flex-wrap items-center gap-0.5 px-1"
>
<RouterPicker
<ProxyPicker
variant="ghost"
tool={tool}
routers={routers}
value={router}
onChange={setRouter}
proxies={proxies}
value={proxy}
onChange={setProxy}
/>
{claude && (
<Select<PermissionMode>
@@ -66,9 +66,10 @@ export function liveRunFor(lane: Lane, runs: RunHandle[], sessionId: string): Ru
/** The name of the git worktree a session ran in (`claude --worktree` puts it
* at `<repo>/.claude/worktrees/<name>`), or null for one in the folder. */
export function sessionWorktreeName(lane: Lane, s: Session): string | null {
const strip = (p: string) => p.replace(/\/+$/, "");
// Either separator: a Windows cwd is `D:\repo\.claude\worktrees\<name>`.
const strip = (p: string) => p.replace(/[\\/]+$/, "");
if (!s.cwd || strip(s.cwd) === strip(lane.cwd)) return null;
return /\/\.claude\/worktrees\/([^/]+)\/?$/.exec(s.cwd)?.[1] ?? null;
return /[\\/]\.claude[\\/]worktrees[\\/]([^\\/]+)[\\/]?$/.exec(s.cwd)?.[1] ?? null;
}
/** A session row's dot: waiting for the user, failed, held by a live pane, or
@@ -31,7 +31,7 @@ vi.mock("../../../lib/api", () => ({
profileInit: vi.fn(),
mcpSync: vi.fn(),
},
aiRouters: { list: vi.fn() },
aiProxies: { list: vi.fn() },
aiTools: { list: vi.fn(), iconUrl: (id: string) => `/api/ai-tools/${id}/icon` },
run: { start: vi.fn() },
},
@@ -50,7 +50,7 @@ const tool = (id: string, label: string, over: Partial<AiTool> = {}): AiTool =>
prompt: { mode: "argv" },
resume: false,
bypass: false,
routerCapable: false,
proxyCapable: false,
install: null,
update: null,
modelFlag: false,
@@ -62,8 +62,8 @@ const tool = (id: string, label: string, over: Partial<AiTool> = {}): AiTool =>
const TOOLS: AiToolsView = {
default: "claude",
tools: [
tool("claude", "Claude Code", { launcher: "claude", routerCapable: true, prompt: null }),
tool("codex", "Codex", { launcher: "codex", routerCapable: true, prompt: null }),
tool("claude", "Claude Code", { launcher: "claude", proxyCapable: true, prompt: null }),
tool("codex", "Codex", { launcher: "codex", proxyCapable: true, prompt: null }),
tool("gemini", "Gemini"),
tool("aider", "Aider", { installed: false, path: null }),
tool("grok", "Grok", { enabled: false }),
@@ -158,7 +158,7 @@ beforeEach(() => {
.mockReset()
.mockResolvedValue({ scaffolded: false, reason: "no detectable Node.js project" });
vi.mocked(api.lanes.mcpSync).mockReset().mockResolvedValue({ servers: [], profilesSeeded: [] });
vi.mocked(api.aiRouters.list).mockReset();
vi.mocked(api.aiProxies.list).mockReset();
});
describe("AddLaneModal — picking an existing branch (issue #34)", () => {
@@ -639,12 +639,12 @@ describe("AddLaneModal — create/update readme", () => {
});
});
describe("AddLaneModal — no router here", () => {
it("asks for no AI Router: a new session picks its own", async () => {
describe("AddLaneModal — no proxy here", () => {
it("asks for no AI Proxy: a new session picks its own", async () => {
renderModal({});
await screen.findByRole("button", { name: /Claude Code.*Default/ });
expect(screen.queryByRole("radio", { name: "AI Router" })).toBeNull();
expect(api.aiRouters.list).not.toHaveBeenCalled();
expect(screen.queryByRole("radio", { name: "AI Proxy" })).toBeNull();
expect(api.aiProxies.list).not.toHaveBeenCalled();
});
});
@@ -28,7 +28,7 @@ vi.mock("../../../lib/api", () => ({
locks: {
list: vi.fn(),
},
aiRouters: {
aiProxies: {
list: vi.fn(),
},
run: {
@@ -45,10 +45,10 @@ beforeEach(() => {
// stack, so the runtime strip and its button stay absent unless a test opts in.
vi.mocked(api.lanes.runtime).mockReset();
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
// Only the built-in profile by default: a user who never opened the AI Router
// Only the built-in profile by default: a user who never opened the AI Proxy
// tab has nothing to switch between, so the row stays out of every other test.
vi.mocked(api.aiRouters.list).mockReset();
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [] });
vi.mocked(api.aiProxies.list).mockReset();
vi.mocked(api.aiProxies.list).mockResolvedValue({ items: [] });
});
vi.mocked(api.locks.list).mockReset();
vi.mocked(api.locks.list).mockResolvedValue({ locks: [] });
@@ -2,8 +2,8 @@
* @file WorkspaceHome.test.tsx
* @description The workspace home: a new session starts in the first free
* pane slot and opens it, on the AI tool picked for it (the workspace's by
* default), AI and Model in the box and AI Router / Permission / Worktree
* under it (the router's models on a router), all-busy is refused, Recents open a live pane or the
* default), AI and Model in the box and AI Proxy / Permission / Worktree
* under it (the proxy's models on a proxy), all-busy is refused, Recents open a live pane or the
* transcript (never a resume), instructions save, context files upload and
* remove, a folder is added with the repo's untracked worktrees suggested
* (minus hidden sources), an unsupported tool is said so, and memory opens
@@ -117,7 +117,7 @@ vi.mock("../../../lib/api", async (importOriginal) => {
})),
},
aiTools: { list: vi.fn(async () => ({ tools: [], default: "claude" })), iconUrl: () => "" },
aiRouters: { list: vi.fn(async () => ({ items: [] })) },
aiProxies: { list: vi.fn(async () => ({ items: [] })) },
},
};
});
@@ -241,7 +241,7 @@ describe("WorkspaceHome", () => {
await waitFor(() => expect(localStorage.getItem("agenthub.workspace.draft.1")).toBeNull());
});
it("keeps AI and Model (with effort) in the box, Router/Permission/Worktree under it", async () => {
it("keeps AI and Model (with effort) in the box, Proxy/Permission/Worktree under it", async () => {
renderHome();
await screen.findByTestId("workspace-recent-s-live");
const labels = (id: string) =>
@@ -252,7 +252,7 @@ describe("WorkspaceHome", () => {
].map((x) => x.getAttribute("aria-label"));
expect(labels("workspace-home-composer")).toEqual(["AI", "model"]);
expect(labels("workspace-home-options")).toEqual([
"AI Router",
"AI Proxy",
"Permission mode",
expect.stringContaining("own git worktree"),
]);
@@ -263,8 +263,8 @@ describe("WorkspaceHome", () => {
vi.mocked(api.aiTools.list).mockResolvedValue({
default: "claude",
tools: [
{ id: "claude", label: "Claude Code", launcher: "claude", routerCapable: true },
{ id: "codex", label: "Codex", launcher: "codex", routerCapable: true, bypass: true },
{ id: "claude", label: "Claude Code", launcher: "claude", proxyCapable: true },
{ id: "codex", label: "Codex", launcher: "codex", proxyCapable: true, bypass: true },
{ id: "cursor", label: "Cursor CLI", installed: false, enabled: true },
].map((x) => ({ installed: true, enabled: true, ...x })),
} as never);
@@ -289,9 +289,9 @@ describe("WorkspaceHome", () => {
__resetAiTools();
});
it("offers the router's models on a router, and sends the router", async () => {
localStorage.setItem("agenthub.session.router", "glm");
vi.mocked(api.aiRouters.list).mockResolvedValueOnce({
it("offers the proxy's models on a proxy, and sends the proxy", async () => {
localStorage.setItem("agenthub.session.proxy", "glm");
vi.mocked(api.aiProxies.list).mockResolvedValueOnce({
items: [
{ name: "native", label: "Native", builtin: true, models: {} },
{
@@ -316,7 +316,7 @@ describe("WorkspaceHome", () => {
expect(api.lanes.action).toHaveBeenCalledWith(
1,
"start",
expect.objectContaining({ router: "glm" })
expect.objectContaining({ proxy: "glm" })
);
});
@@ -0,0 +1,33 @@
/**
* @file Tests that a session finds its workspace and its `claude --worktree`
* name by folder on Windows paths (`D:\…`) as well as POSIX ones — the
* transcript page's Resume/Fork/Open pane hid on Windows when they matched
* on "/" only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import type { Lane, Session } from "../../../lib/types";
import { owningLane } from "../SessionResumeButton";
import { sessionWorktreeName } from "../WorkspaceSessionList";
const lane = (id: number, cwd: string) => ({ id, cwd, tool: "claude" }) as Lane;
describe("session paths", () => {
it("finds the deepest owning workspace with either separator", () => {
const lanes = [lane(1, "D:\\Project"), lane(2, "D:\\Project\\Hobby\\"), lane(3, "/home/u/app")];
expect(owningLane("D:\\Project\\Hobby\\tri-vi-portfolio", "claude", lanes)?.id).toBe(2);
expect(owningLane("D:\\Project\\Hobby", "claude", lanes)?.id).toBe(2);
expect(owningLane("D:\\ProjectX", "claude", lanes)).toBeNull();
expect(owningLane("/home/u/app/src", "claude", lanes)?.id).toBe(3);
});
it("reads a worktree name from a Windows or POSIX session folder", () => {
const at = (cwd: string) => ({ cwd }) as Session;
expect(
sessionWorktreeName(lane(1, "D:\\repo"), at("D:\\repo\\.claude\\worktrees\\fix-a"))
).toBe("fix-a");
expect(sessionWorktreeName(lane(1, "/r"), at("/r/.claude/worktrees/fix-b/"))).toBe("fix-b");
expect(sessionWorktreeName(lane(1, "D:\\repo"), at("D:\\repo\\"))).toBeNull();
});
});
+2 -8
View File
@@ -14,7 +14,7 @@
* toasts. The `+` menu opens a new tab in the free slot the server picks
* (`slot: "auto"`) — a plain shell (`POST /api/lanes/:id/start {shell: true}`)
* or an installed, enabled AI tool started bare on its default model, no
* prompt (a prompt, router or worktree is still the workspace home's prompt
* prompt (a prompt, proxy or worktree is still the workspace home's prompt
* box) — plus a link to Settings → Agents. Ten sessions is the server's limit
* (`MAX_PANE_SLOT`). The bar is the console's only header: Home (the
* workspace's home) at its left end, `actions` (the single pane's split menus)
@@ -51,13 +51,7 @@ import { AiToolIcon } from "../AiToolIcon";
import { freeSlot, startInFreeTab } from "../lanes/startSession";
import { PANE_DRAG_TYPE, RunIcon, startPaneDrag, type GroupsUpdate } from "./PaneGrid";
import { insertionIndex } from "../../lib/laneOrderStorage";
import {
type PaneGroups,
detachSlot,
groupOf,
leaves,
reorderGroups,
} from "../../lib/paneLayout";
import { type PaneGroups, detachSlot, groupOf, leaves, reorderGroups } from "../../lib/paneLayout";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
type AiToolsState = ReturnType<typeof useAiTools>;
+25 -17
View File
@@ -106,20 +106,27 @@ export function usePaneGroups(laneId: number | null): [PaneGroups, GroupsUpdate]
return [current.groups, update];
}
type Zone = Edge | "center";
export type Zone = Edge | "center";
const pct = (r: Rect) => ({
left: `${r.x * 100}%`,
top: `${r.y * 100}%`,
width: `${r.w * 100}%`,
height: `${r.h * 100}%`,
});
/** The half (or the whole) of a pane a drop zone would fill. */
function zoneRect(r: Rect, zone: Zone): Rect {
if (zone === "left") return { ...r, w: r.w / 2 };
if (zone === "right") return { ...r, x: r.x + r.w / 2, w: r.w / 2 };
if (zone === "top") return { ...r, h: r.h / 2 };
if (zone === "bottom") return { ...r, y: r.y + r.h / 2, h: r.h / 2 };
return r;
/** What dropping `src` on a pane's zone does: the center swaps, an edge
* splits. The drop applies it and the preview draws it, so they agree. */
export function applyDrop(l: PaneLayout, src: number, slot: number, zone: Zone): PaneLayout {
return zone === "center" ? swapInLayout(l, src, slot) : moveInLayout(l, src, slot, zone);
}
/** Where `src` would land, or null for a drop that changes nothing. A pane
* already shown leaves its place first, so its target grows before the
* split — the target's own half is not where it lands. */
export function dropPreview(l: PaneLayout, src: number, slot: number, zone: Zone): Rect | null {
if (src === slot) return null;
const next = applyDrop(l, src, slot, zone);
if (next === l) return null;
return layoutRects(next.root).panes.find((p) => p.slot === src) ?? null;
}
/** A small anchored menu: a trigger button and a list that closes on a pick,
@@ -323,7 +330,7 @@ export function PaneGrid({
}) {
const { t } = useTranslation("lanes");
const gridRef = useRef<HTMLDivElement>(null);
const [drop, setDrop] = useState<{ slot: number; zone: Zone } | null>(null);
const [drop, setDrop] = useState<{ src: number; slot: number; zone: Zone } | null>(null);
const { panes, dividers } = layoutRects(layout.root);
const shown = leaves(layout.root);
const rectOf = new Map(panes.map((p) => [p.slot, p]));
@@ -391,6 +398,8 @@ export function PaneGrid({
return { slot: p.slot, zone: dist < 0.25 ? edge : "center" };
};
const preview = drop && dropPreview(layout, drop.src, drop.slot, drop.zone);
const isPaneDrag = (e: React.DragEvent) =>
dragSlot !== null || !!e.dataTransfer?.types?.includes(PANE_DRAG_TYPE);
@@ -404,7 +413,10 @@ export function PaneGrid({
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
const z = zoneAt(e);
setDrop((d) => (d?.slot === z?.slot && d?.zone === z?.zone ? d : z));
const next = z && dragSlot !== null ? { src: dragSlot, ...z } : null;
setDrop((d) =>
d?.src === next?.src && d?.slot === next?.slot && d?.zone === next?.zone ? d : next
);
}}
onDragLeave={(e) => {
if (!gridRef.current?.contains(e.relatedTarget as Node | null)) setDrop(null);
@@ -417,11 +429,7 @@ export function PaneGrid({
dragSlot = null;
setDrop(null);
if (!z || !Number.isInteger(src) || src === z.slot) return;
onChange((l) =>
z.zone === "center"
? swapInLayout(l, src, z.slot)
: moveInLayout(l, src, z.slot, z.zone as Edge)
);
onChange((l) => applyDrop(l, src, z.slot, z.zone));
}}
>
{mounted.map((slot) => {
@@ -561,11 +569,11 @@ export function PaneGrid({
}
/>
))}
{drop && rectOf.has(drop.slot) && (
{preview && (
<div
data-testid="pane-drop-preview"
className="pointer-events-none absolute z-20 rounded-md border-2 border-accent/60 bg-accent/15 transition-all duration-100"
style={pct(zoneRect(rectOf.get(drop.slot) as Rect, drop.zone))}
style={pct(preview)}
/>
)}
</div>
+104
View File
@@ -0,0 +1,104 @@
/**
* @file ProxyPicker.tsx
* @description The AI Proxy a NEW session starts on — a session's choice, not
* its workspace's (`proxy` on `POST /api/lanes/:id/start`, `server/lib/proxy-apply.js`):
* Native or one of the saved profiles (`useAiProxies`, which the caller also
* reads to list the proxy's models). A tool that cannot use a proxy (only
* Claude and Codex can) shows Native, disabled. The last pick is remembered
* for the next session (`agenthub.session.proxy`; the pre-rename
* `agenthub.session.router` is still read when that is unset).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, type AiProxy } from "../../lib/api";
import { useAiTools } from "../../hooks/useAiTools";
import { Select } from "../Select";
const KEY = "agenthub.session.proxy";
const LEGACY_KEY = "agenthub.session.router";
/** The remembered pick, `"native"` by default. */
export function useSessionProxy(): [string, (v: string) => void] {
const [value, setValue] = useState(() => {
try {
return localStorage.getItem(KEY) || localStorage.getItem(LEGACY_KEY) || "native";
} catch {
return "native";
}
});
const set = (v: string) => {
setValue(v);
try {
localStorage.setItem(KEY, v);
} catch {
/* storage off — the pick still holds for this page */
}
};
return [value, set];
}
/** The saved profiles (Native included), null until they arrive. */
export function useAiProxies(): AiProxy[] | null {
const [proxies, setProxies] = useState<AiProxy[] | null>(null);
useEffect(() => {
let alive = true;
api.aiProxies
.list()
.then((r) => alive && setProxies(r.items))
.catch(() => alive && setProxies([]));
return () => {
alive = false;
};
}, []);
return proxies;
}
/** What to send as `proxy` for `tool`: nothing for native or a tool that has none. */
export function proxyArg(proxyCapable: boolean | undefined, proxy: string): { proxy?: string } {
return proxyCapable && proxy && proxy !== "native" ? { proxy } : {};
}
export function ProxyPicker({
tool,
proxies,
value,
onChange,
variant,
}: {
variant?: "field" | "ghost";
tool: string;
/** From `useAiProxies`. */
proxies: AiProxy[] | null;
value: string;
onChange: (v: string) => void;
}) {
const { t } = useTranslation(["lanes"]);
const aiTools = useAiTools();
const capable = aiTools.byId(tool)?.proxyCapable ?? (tool === "claude" || tool === "codex");
const native = t("proxy.native", { tool: aiTools.label(tool) });
// A remembered profile that has since been deleted falls back to native,
// or the start would be refused for a proxy the user cannot see.
useEffect(() => {
if (proxies && value !== "native" && !proxies.some((r) => r.name === value)) onChange("native");
}, [proxies, value, onChange]);
const options =
capable && proxies?.length
? proxies.map((r) => ({ value: r.name, label: r.builtin ? native : r.label }))
: [{ value: "native", label: native }];
return (
<div data-testid="proxy-picker">
<Select<string>
value={capable ? value : "native"}
onChange={onChange}
options={options}
disabled={!capable}
variant={variant}
label={t("proxy.label")}
/>
</div>
);
}
-102
View File
@@ -1,102 +0,0 @@
/**
* @file RouterPicker.tsx
* @description The AI Router a NEW session starts on — a session's choice, not
* its workspace's (`router` on `POST /api/lanes/:id/start`, `server/lib/router-apply.js`):
* Native or one of the saved profiles (`useAiRouters`, which the caller also
* reads to list the router's models). A tool that cannot use a router (only
* Claude and Codex can) shows Native, disabled. The last pick is remembered
* for the next session (`agenthub.session.router`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, type AiRouter } from "../../lib/api";
import { useAiTools } from "../../hooks/useAiTools";
import { Select } from "../Select";
const KEY = "agenthub.session.router";
/** The remembered pick, `"native"` by default. */
export function useSessionRouter(): [string, (v: string) => void] {
const [value, setValue] = useState(() => {
try {
return localStorage.getItem(KEY) || "native";
} catch {
return "native";
}
});
const set = (v: string) => {
setValue(v);
try {
localStorage.setItem(KEY, v);
} catch {
/* storage off — the pick still holds for this page */
}
};
return [value, set];
}
/** The saved profiles (Native included), null until they arrive. */
export function useAiRouters(): AiRouter[] | null {
const [routers, setRouters] = useState<AiRouter[] | null>(null);
useEffect(() => {
let alive = true;
api.aiRouters
.list()
.then((r) => alive && setRouters(r.items))
.catch(() => alive && setRouters([]));
return () => {
alive = false;
};
}, []);
return routers;
}
/** What to send as `router` for `tool`: nothing for native or a tool that has none. */
export function routerArg(routerCapable: boolean | undefined, router: string): { router?: string } {
return routerCapable && router && router !== "native" ? { router } : {};
}
export function RouterPicker({
tool,
routers,
value,
onChange,
variant,
}: {
variant?: "field" | "ghost";
tool: string;
/** From `useAiRouters`. */
routers: AiRouter[] | null;
value: string;
onChange: (v: string) => void;
}) {
const { t } = useTranslation(["lanes"]);
const aiTools = useAiTools();
const capable = aiTools.byId(tool)?.routerCapable ?? (tool === "claude" || tool === "codex");
const native = t("router.native", { tool: aiTools.label(tool) });
// A remembered profile that has since been deleted falls back to native,
// or the start would be refused for a router the user cannot see.
useEffect(() => {
if (routers && value !== "native" && !routers.some((r) => r.name === value)) onChange("native");
}, [routers, value, onChange]);
const options =
capable && routers?.length
? routers.map((r) => ({ value: r.name, label: r.builtin ? native : r.label }))
: [{ value: "native", label: native }];
return (
<div data-testid="router-picker">
<Select<string>
value={capable ? value : "native"}
onChange={onChange}
options={options}
disabled={!capable}
variant={variant}
label={t("router.label")}
/>
</div>
);
}
+55 -10
View File
@@ -10,7 +10,7 @@
* (`POST /api/run/drop-image`);
* - `CwdAutocomplete` — the folder field the Add-workspace modal uses;
* - `ModelPicker` — a tool's own model list (`GET /api/ai-tools/:id/models`)
* or a fixed one (an AI Router's models), plus Custom…;
* or a fixed one (an AI Proxy's models), plus Custom…;
* - `ModelMenu` — the same list as ONE composer chip, Claude Desktop's
* "Sonnet 5.5 · Medium": the main models with their hints, the rest under
* More models, Custom…, and the Effort slider (Faster → Smarter, a reset
@@ -47,12 +47,15 @@ import { isSubmitEnter } from "../../lib/keys";
export interface SlashCommand {
name: string;
source: "project" | "user" | "builtin";
source: "project" | "user" | "plugin" | "builtin";
description?: string;
}
// Commands the CLI answers itself. They are typed into the pane like any
// other text, so they do run — they are just not files anyone can list.
// Commands the CLI answers itself, and the skills bundled inside it. They are
// typed into the pane like any other text, so they do run — they are just not
// files anyone can list.
// ponytail: a hand-kept list, so it lags new Claude Code releases; no file or
// API lists the built-ins, add names here as they ship.
const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
"help",
"clear",
@@ -62,6 +65,8 @@ const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
"memory",
"hooks",
"cost",
"usage",
"context",
"agents",
"review",
"permissions",
@@ -69,14 +74,36 @@ const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
"init",
"mcp",
"plugin",
"reload-plugins",
"skills",
"output-style",
"resume",
"rewind",
"add-dir",
"doctor",
"export",
"login",
"logout",
"statusline",
"theme",
"fast",
"exit",
"code-review",
"security-review",
"simplify",
"loop",
"schedule",
"fewer-permission-prompts",
"update-config",
"claude-api",
"run",
].map((name) => ({ name, source: "builtin" }));
const SOURCE_ORDER = { project: 0, user: 1, builtin: 2 } as const;
const SOURCE_ORDER = { project: 0, user: 1, plugin: 2, builtin: 3 } as const;
const SOURCE_TONE: Record<SlashCommand["source"], string> = {
project: "text-status-success border-status-success/40",
user: "text-accent border-accent/40",
plugin: "text-accent border-accent/40",
builtin: "text-fg-muted border-border",
};
@@ -185,8 +212,26 @@ export function PromptEditor({
Promise.all([
api.ccConfig.commands(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
api.ccConfig.skills(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
]).then(([c, sk]) => {
if (live) setCommands([...c.items, ...sk.items].map(toCmd).concat(BUILTIN_SLASH_COMMANDS));
api.ccConfig.plugins().catch(() => ({ plugins: [] })),
]).then(([c, sk, pl]) => {
// A plugin's commands and skills are addressed as `/<plugin>:<name>`; one
// installed at both user and project scope is listed once.
const fromPlugins = pl.plugins
.filter((p) => p.enabled !== false && p.contributes)
.flatMap((p) =>
[...p.contributes!.items.commands, ...p.contributes!.items.skills].map(
(it): SlashCommand => ({
name: `${p.name}:${it.name}`,
source: "plugin",
description: it.description || undefined,
})
)
)
.filter((cmd, i, all) => all.findIndex((o) => o.name === cmd.name) === i);
if (live)
setCommands(
[...c.items, ...sk.items].map(toCmd).concat(fromPlugins, BUILTIN_SLASH_COMMANDS)
);
});
return () => {
live = false;
@@ -671,7 +716,7 @@ const MODEL_CUSTOM = "__custom__";
/**
* The models `tool` can start on, the inherit entry (`""`) first: the tool's
* own list (Codex's models cache, Claude's aliases named after the newest
* version, a catalog tool's vendor rows), or `fixed` (a router's models);
* version, a catalog tool's vendor rows), or `fixed` (a proxy's models);
* Claude's static aliases only when the server cannot answer. `loaded` once
* the list is final.
*/
@@ -707,7 +752,7 @@ export function ModelPicker({
value: string;
onChange: (s: string) => void;
tool: LaneTool;
/** A fixed list instead of the tool's own — an AI Router's models. */
/** A fixed list instead of the tool's own — an AI Proxy's models. */
choices?: ModelChoice[];
variant?: "field" | "ghost";
}) {
@@ -781,7 +826,7 @@ export function ModelMenu({
value: string;
onChange: (s: string) => void;
tool: LaneTool;
/** A fixed list instead of the tool's own — an AI Router's models. */
/** A fixed list instead of the tool's own — an AI Proxy's models. */
choices?: ModelChoice[];
/** Omitted for a tool that takes no effort. */
effort?: EffortLevel;
@@ -31,7 +31,7 @@ vi.mock("../../../lib/api", () => ({
action: vi.fn(),
list: vi.fn(),
},
aiRouters: { list: vi.fn().mockResolvedValue({ items: [] }) },
aiProxies: { list: vi.fn().mockResolvedValue({ items: [] }) },
aiTools: {
list: vi.fn().mockResolvedValue({ default: "claude", tools: [] }),
models: vi.fn().mockResolvedValue({ flag: true, models: [], defaultModel: null }),
@@ -0,0 +1,36 @@
/**
* @file PaneGrid.test.ts
* @description The pane drop preview: it draws where the dragged pane will
* actually land (`dropPreview` over `applyDrop`), not half of the target's
* current box, and nothing for a drop that changes nothing.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it } from "vitest";
import { dropPreview } from "../PaneGrid";
import { pane, split, type PaneLayout } from "../../../lib/paneLayout";
const AB: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 0 };
describe("dropPreview", () => {
it("a shown pane leaves its place first: A onto B's top is the whole top half", () => {
expect(dropPreview(AB, 0, 1, "top")).toEqual({ slot: 0, x: 0, y: 0, w: 1, h: 0.5 });
});
it("a pane from another tab splits the target's current box", () => {
expect(dropPreview(AB, 2, 1, "bottom")).toEqual({ slot: 2, x: 0.5, y: 0.5, w: 0.5, h: 0.5 });
});
it("the center swaps into the target's box", () => {
expect(dropPreview(AB, 0, 1, "center")).toEqual({ slot: 0, x: 0.5, y: 0, w: 0.5, h: 1 });
});
it("nothing on its own pane or on a refused move", () => {
expect(dropPreview(AB, 0, 0, "left")).toBeNull();
const full: PaneLayout = {
root: split("row", split("col", pane(0), pane(1)), split("col", pane(2), pane(3))),
focus: 0,
};
expect(dropPreview(full, 5, 0, "left")).toBeNull();
});
});
@@ -1,16 +1,16 @@
/**
* @file RouterPicker.test.tsx
* @description The per-session AI Router picker: Native plus the saved
* profiles, Native alone and disabled on a tool that cannot use a router,
* @file ProxyPicker.test.tsx
* @description The per-session AI Proxy picker: Native plus the saved
* profiles, Native alone and disabled on a tool that cannot use a proxy,
* remembering the pick, and falling back to Native when the remembered
* profile is gone; plus `routerArg`, which never sends native.
* profile is gone; plus `proxyArg`, which never sends native.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { act, render, renderHook, screen, waitFor } from "@testing-library/react";
import type { AiRouter } from "../../../lib/api";
import type { AiProxy } from "../../../lib/api";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { RouterPicker, routerArg, useAiRouters, useSessionRouter } from "../RouterPicker";
import { ProxyPicker, proxyArg, useAiProxies, useSessionProxy } from "../ProxyPicker";
import { api } from "../../../lib/api";
vi.mock("../../../lib/api", async (importOriginal) => {
@@ -22,67 +22,72 @@ vi.mock("../../../lib/api", async (importOriginal) => {
list: vi.fn().mockResolvedValue({
default: "claude",
tools: [
{ id: "claude", label: "Claude Code", routerCapable: true },
{ id: "gemini", label: "Gemini", routerCapable: false },
{ id: "claude", label: "Claude Code", proxyCapable: true },
{ id: "gemini", label: "Gemini", proxyCapable: false },
].map((x) => ({ ...x, installed: true, enabled: true })),
}),
},
aiRouters: { list: vi.fn() },
aiProxies: { list: vi.fn() },
},
};
});
const native = { name: "native", label: "Native", builtin: true } as AiRouter;
const nine = { name: "9router", label: "9Router", builtin: false } as AiRouter;
const native = { name: "native", label: "Native", builtin: true } as AiProxy;
const nine = { name: "9router", label: "9Router", builtin: false } as AiProxy;
beforeEach(() => {
localStorage.clear();
vi.mocked(api.aiRouters.list)
vi.mocked(api.aiProxies.list)
.mockReset()
.mockResolvedValue({ items: [native, nine] } as never);
});
describe("RouterPicker", () => {
describe("ProxyPicker", () => {
it("lists the saved profiles for a tool that can use one", async () => {
render(
<RouterPicker tool="claude" routers={[native, nine]} value="9router" onChange={() => {}} />
<ProxyPicker tool="claude" proxies={[native, nine]} value="9router" onChange={() => {}} />
);
expect(await screen.findByText("9Router")).toBeInTheDocument();
});
it("shows Native, disabled, on a tool with no endpoint to point at a router", () => {
it("shows Native, disabled, on a tool with no endpoint to point at a proxy", () => {
render(
<RouterPicker tool="gemini" routers={[native, nine]} value="9router" onChange={() => {}} />
<ProxyPicker tool="gemini" proxies={[native, nine]} value="9router" onChange={() => {}} />
);
const picker = screen.getByTestId("router-picker");
const picker = screen.getByTestId("proxy-picker");
expect(picker.querySelector("button")).toBeDisabled();
expect(picker).not.toHaveTextContent("9Router");
});
it("falls back to Native when the remembered profile is gone", async () => {
const onChange = vi.fn();
render(<RouterPicker tool="claude" routers={[native]} value="deleted" onChange={onChange} />);
render(<ProxyPicker tool="claude" proxies={[native]} value="deleted" onChange={onChange} />);
await waitFor(() => expect(onChange).toHaveBeenCalledWith("native"));
});
it("useAiRouters reads the saved profiles once", async () => {
const { result } = renderHook(() => useAiRouters());
it("useAiProxies reads the saved profiles once", async () => {
const { result } = renderHook(() => useAiProxies());
await waitFor(() => expect(result.current).toEqual([native, nine]));
expect(api.aiRouters.list).toHaveBeenCalledTimes(1);
expect(api.aiProxies.list).toHaveBeenCalledTimes(1);
});
});
describe("useSessionRouter / routerArg", () => {
describe("useSessionProxy / proxyArg", () => {
it("remembers the last pick for the next session", () => {
const first = renderHook(() => useSessionRouter());
const first = renderHook(() => useSessionProxy());
expect(first.result.current[0]).toBe("native");
act(() => first.result.current[1]("9router"));
expect(renderHook(() => useSessionRouter()).result.current[0]).toBe("9router");
expect(renderHook(() => useSessionProxy()).result.current[0]).toBe("9router");
});
it("sends a router only for a capable tool and never native", () => {
expect(routerArg(true, "9router")).toEqual({ router: "9router" });
expect(routerArg(true, "native")).toEqual({});
expect(routerArg(false, "9router")).toEqual({});
it("still reads the pick remembered before the rename", () => {
localStorage.setItem("agenthub.session.router", "9router");
expect(renderHook(() => useSessionProxy()).result.current[0]).toBe("9router");
});
it("sends a proxy only for a capable tool and never native", () => {
expect(proxyArg(true, "9router")).toEqual({ proxy: "9router" });
expect(proxyArg(true, "native")).toEqual({});
expect(proxyArg(false, "9router")).toEqual({});
});
});
@@ -4,7 +4,7 @@
* `PromptEditor`'s `/` and `@` autocomplete (Enter picks while the list is
* open, `/` only where slash commands apply), Shift/Alt+Enter newlines, a
* pasted image shown as a thumbnail and sent as its saved path, and `ModelPicker` — a tool's own
* list from the server, a fixed list (an AI Router's models) when given one,
* list from the server, a fixed list (an AI Proxy's models) when given one,
* and Custom…, which is a sentinel, never a model id — and `ModelMenu`, the
* same list as one chip: aliases up front, pinned versions under More models,
* plus the effort slider.
@@ -59,6 +59,25 @@ vi.mock("../../../lib/api", async (importOriginal) => {
skills: r({
items: [{ scope: "user", name: "review-pr", frontmatter: {}, preview: "Review a PR" }],
}),
plugins: r({
plugins: [
{
name: "vercel",
enabled: true,
contributes: {
items: {
skills: [{ name: "deploy", description: "Deploy to Vercel" }],
commands: [],
},
},
},
{
name: "off",
enabled: false,
contributes: { items: { skills: [{ name: "deployer" }], commands: [] } },
},
],
}),
},
},
};
@@ -96,6 +115,9 @@ describe("PromptEditor — / and @ suggestions", () => {
expect(options.map((o) => o.textContent)).toEqual([
expect.stringContaining("/review"),
expect.stringContaining("/review-pr"),
// Claude Code's bundled skills, matched on a word of their name.
expect.stringContaining("/code-review"),
expect.stringContaining("/security-review"),
]);
fireEvent.keyDown(box(), { key: "ArrowDown" });
// Read for the directory the session will start in, not the server's own.
@@ -108,6 +130,13 @@ describe("PromptEditor — / and @ suggestions", () => {
expect(spies.onSubmit).not.toHaveBeenCalled();
});
it("offers enabled plugins' skills under their plugin's name", async () => {
renderEditor("");
fireEvent.change(box(), { target: { value: "/deplo" } });
const options = await screen.findAllByRole("option");
expect(options.map((o) => o.textContent)).toEqual([expect.stringContaining("/vercel:deploy")]);
});
it("suggests files under the folder on @", async () => {
vi.mocked(api.run.files).mockResolvedValue({ items: ["src/main.ts"] });
// The prompt is controlled by the caller: put the cursor at the end of it.
@@ -211,7 +240,7 @@ describe("ModelPicker", () => {
expect(await screen.findByText("GPT-6-Sol")).toBeTruthy();
});
it("offers only a fixed list when given one (an AI Router's models)", async () => {
it("offers only a fixed list when given one (an AI Proxy's models)", async () => {
render(
<ModelPicker
value=""
@@ -4,7 +4,7 @@
* computed from — as shown on a tool's Settings tab (Overview for Claude Code
* and Codex, the Antigravity tab itself): Claude
* Code's shows `claude-…` rows, Codex's shows OpenAI rows (`gpt-…`, `o<n>…`,
* `codex-…`), Antigravity's Gemini rows (`gemini-…`). It is ONE table (a router model can serve either tool), so a
* `codex-…`), Antigravity's Gemini rows (`gemini-…`). It is ONE table (a proxy model can serve either tool), so a
* **Show all models** switch lists every row, and a row added outside the
* filter turns it on rather than vanishing. Reset Defaults reseeds Claude's
* rates, so only the Claude tab offers it; the server re-fetches any row the
@@ -49,7 +49,7 @@ const tool = (id: string, label: string, over: Partial<AiTool> = {}): AiTool =>
prompt: { mode: "argv" },
resume: false,
bypass: false,
routerCapable: false,
proxyCapable: false,
install: null,
update: null,
modelFlag: false,
@@ -79,12 +79,7 @@
"rangeFrom": "From",
"rangeTo": "To",
"query": {
"title": "Query Explorer",
"subtitle": "Filter sessions, events and agents, then export what you see.",
"entity": "Data",
"entities": {
"sessions": "Sessions",
"events": "Events",
"agents": "Agents"
},
"search": "Search",
+2 -1
View File
@@ -263,7 +263,8 @@
"title": "Claude Code profiles",
"openButton": "Switch profile",
"subtitle": "A profile is one Anthropic account - its own quota. Switching swaps only the login (credentials + account identity); skills, subagents, memory, plugins, marketplaces, MCP servers and settings all stay exactly as they are.",
"warning": "Close your running Claude Code sessions before switching - a live session rewrites the config on exit and would put the old account back.",
"warning": "Running Claude Code sessions follow a switch on their own: they pick up the new login within about 30 seconds.",
"mismatch": "The login in use belongs to {{owner}}, but Claude Code's config names {{config}}. Requests are billed to {{owner}}. Switch to the account you want to fix this.",
"activeLabel": "Logged in now",
"noActive": "No Claude account is logged in on this machine.",
"unsavedActive": "This account is not saved as a profile yet - save it, otherwise you cannot switch away without losing its credentials.",
+2 -2
View File
@@ -215,8 +215,8 @@
"proof.ticketReport": "Task report",
"renameLabel": "Rename this workspace",
"reorderHint": "Drag to reorder · Alt+←/→",
"router.native": "Native ({{tool}})",
"router.label": "AI Router",
"proxy.native": "Native ({{tool}})",
"proxy.label": "AI Proxy",
"runtime.boot": "▶ up",
"runtime.busy.down": "stopping…",
"runtime.busy.up": "booting…",
+2 -1
View File
@@ -1,6 +1,7 @@
{
"activityFeed": "Activity Feed",
"agentBoard": "Kanban Board",
"agents": "Agents",
"analytics": "Analytics",
"avg": "avg",
"brand": "AgentHub",
@@ -78,6 +79,7 @@
"quotaRefreshing": "Refreshing…",
"quotaExpired": "Token expired: use Claude Code once so it refreshes",
"quotaFailed": "Could not read the quota",
"quotaWrongAccount": "This sign-in belongs to another Claude account: log in as this account and save it again",
"quotaActive": "in use",
"quotaProfileExpired": "Sign-in expired: use Check quota in Claude Code → Profiles to refresh it",
"quotaSignInExpires": "Sign-in expires in {{time}}",
@@ -88,7 +90,6 @@
}
},
"version": "Version",
"query": "Query Explorer",
"openMenu": "Open menu",
"resizeSidebar": "Resize sidebar (double-click to reset)",
"closeMenu": "Close menu",
+13 -13
View File
@@ -577,13 +577,13 @@
"fieldLabelPlaceholder": "Dev box"
},
"cursorPathsNote": "Informational: Cursor sessions count here too — Cursor happens to use the same ~/.claude paths as Claude Code (locally and on synced remotes).",
"aiRouter": {
"title": "AI Router",
"aiProxy": {
"title": "AI Proxy",
"description": "For example 9Router, OpenRouter or a self-hosted gateway. Save one here, then pick it for a session in the workspace prompt box.",
"add": "Add profile",
"edit": "Edit",
"remove": "Remove",
"empty": "No router profile saved yet. Native runs against the vendor's own API.",
"empty": "No proxy profile saved yet. Native runs against the vendor's own API.",
"builtinHint": "Built in — this is the vendor's own API, and it cannot be edited or removed.",
"colLabel": "Profile",
"colBaseUrl": "Endpoint",
@@ -592,16 +592,16 @@
"keySet": "Saved",
"keyMissing": "None",
"fieldLabel": "Name",
"fieldLabelHint": "What you will see in the workspace form. Free text.",
"fieldLabelHint": "What you will see in the session's AI Proxy picker. Free text.",
"fieldBaseUrl": "Base URL",
"fieldBaseUrlHint": "The router's OpenAI/Anthropic-compatible endpoint, e.g. http://localhost:20128/v1",
"fieldBaseUrlHint": "The proxy's root URL, without /v1 — e.g. http://localhost:20128. Claude Code adds /v1/messages and Codex /v1/responses; a /v1 you type is dropped.",
"fieldApiKey": "API key",
"fieldApiKeyHint": "Stored on this machine at 0600 and never sent back to the browser.",
"fieldApiKeyKeep": "Leave blank to keep the saved key.",
"fieldModel": "Model",
"fieldModelHint": "Used for every tier unless you override one below. Many routers accept one symbolic name.",
"fieldModelHint": "Used for every tier unless you override one below. Many proxies accept one symbolic name.",
"fieldBehavesAs": "Behaves as (Claude Code)",
"fieldBehavesAsHint": "Optional. The Claude model id Claude Code should treat this router's model names as (effort defaults, context window), e.g. claude-opus-5-5. Fill it in when Claude Code says a model isn't described by its model catalog, and name the model the router really sends to. While set, it replaces your own modelPicker setting in these sessions.",
"fieldBehavesAsHint": "Optional. The Claude model id Claude Code should treat this proxy's model names as (effort defaults, context window), e.g. claude-opus-5-5. Fill it in when Claude Code says a model isn't described by its model catalog, and name the model the proxy really sends to. While set, it replaces your own modelPicker setting in these sessions.",
"perTier": "Per-tier overrides",
"perTierHint": "Leave a field blank to use the model above.",
"tier": {
@@ -613,12 +613,12 @@
},
"save": "Save",
"cancel": "Cancel",
"removeConfirm": "Remove the profile \"{{label}}\"? Every workspace using it is reset to the vendor's own API right away — on disk and in the dashboard.",
"removeConfirm": "Remove the profile \"{{label}}\"? Its session files go with it, and a session that ran on it resumes on the vendor's own API.",
"loading": "Loading profiles…",
"test": "Test",
"testing": "Testing…",
"testOk": "The endpoint answered — {{model}} is reachable with this key.",
"testFailed": "No answer ({{status}}): {{message}}"
"testOk": "answered — {{model}} is reachable with this key.",
"testFailed": "no answer ({{status}}): {{message}}"
},
"aiTools": {
"title": "AI Tools",
@@ -700,7 +700,7 @@
},
"codexHome": {
"title": "CODEX_HOME",
"description": "Root directory for Codex config and session journals. The dashboard reads it, writes AI Router layers into it, and starts every codex workspace with it.",
"description": "Root directory for Codex config and session journals. The dashboard reads it, writes AI Proxy layers into it, and starts every codex workspace with it.",
"restartNote": "A codex workspace already running keeps the home it started with, and shows as idle, until it is restarted.",
"placeholder": "/path/to/codex-home"
},
@@ -792,8 +792,8 @@
"tool": {
"description": "{{tool}}: path, usage, default model and config files."
},
"aiRouter": {
"title": "AI Router",
"aiProxy": {
"title": "AI Proxy",
"description": "Endpoints that stand in front of Claude Code and Codex."
},
"pricing": {
+1
View File
@@ -25,6 +25,7 @@
"releaseUnavailable": "Could not read the latest release from the repository ({{reason}}); fell back to comparing commits.",
"pluginAutoNote": "This dashboard is installed as a Claude Code plugin. \"Update now\" pulls the new plugin version, reinstalls the runtime, and restarts the server for you — it takes a few minutes. Newly added skills and slash commands only appear in an already-open Claude Code session after you run /reload-plugins there.",
"pluginUpdating": "Pulling the new plugin version and reinstalling the runtime — this takes a few minutes, the page reloads itself when the new server is up...",
"pluginAlreadyCurrent": "No newer plugin version was pulled, so the running dashboard was left as it is.",
"lastPluginUpdateFailed": "The last plugin update failed at step \"{{step}}\": {{error}} — run /agenthub-update in Claude Code to recover.",
"restartingShort": "Restarting…",
"installedTitle": "Version {{version}} is installed but not running",
@@ -79,12 +79,7 @@
"rangeFrom": "Từ ngày",
"rangeTo": "Đến ngày",
"query": {
"title": "Truy vấn dữ liệu",
"subtitle": "Lọc phiên, sự kiện và agent, rồi xuất đúng những gì đang thấy.",
"entity": "Dữ liệu",
"entities": {
"sessions": "Phiên",
"events": "Sự kiện",
"agents": "Agent"
},
"search": "Tìm kiếm",
+2 -1
View File
@@ -258,7 +258,8 @@
"title": "Profile Claude Code",
"openButton": "Đổi profile",
"subtitle": "Mỗi profile là một tài khoản Anthropic - quota riêng. Khi đổi, chỉ phần đăng nhập (credentials + danh tính tài khoản) được hoán đổi; skills, subagent, memory, plugin, marketplace, MCP server và settings vẫn giữ nguyên.",
"warning": "Hãy đóng các phiên Claude Code đang chạy trước khi đổi - phiên đang chạy sẽ ghi lại config khi thoát và đưa tài khoản cũ quay về.",
"warning": "Các phiên Claude Code đang chạy tự đi theo khi đổi: chúng nhận đăng nhập mới trong khoảng 30 giây.",
"mismatch": "Đăng nhập đang dùng thuộc tài khoản {{owner}}, nhưng config của Claude Code lại ghi {{config}}. Mọi request đang tính vào {{owner}}. Bấm Chuyển sang đúng tài khoản bạn muốn để sửa.",
"activeLabel": "Đang đăng nhập",
"noActive": "Chưa có tài khoản Claude nào đăng nhập trên máy này.",
"unsavedActive": "Tài khoản này chưa được lưu thành profile - hãy lưu lại, nếu không sẽ không thể đổi sang profile khác mà không mất credentials của nó.",
+2 -2
View File
@@ -215,8 +215,8 @@
"proof.ticketReport": "Báo cáo nhiệm vụ",
"renameLabel": "Đổi tên workspace này",
"reorderHint": "Kéo để sắp xếp · Alt+←/→",
"router.native": "Native ({{tool}})",
"router.label": "AI Router",
"proxy.native": "Native ({{tool}})",
"proxy.label": "AI Proxy",
"runtime.boot": "▶ chạy",
"runtime.busy.down": "đang dừng…",
"runtime.busy.up": "đang khởi động…",
+2 -1
View File
@@ -1,6 +1,7 @@
{
"activityFeed": "Luồng hoạt động",
"agentBoard": "Bảng Kanban",
"agents": "Agent",
"analytics": "Phân tích",
"avg": "TB",
"brand": "AgentHub",
@@ -78,6 +79,7 @@
"quotaRefreshing": "Đang làm mới…",
"quotaExpired": "Token đã hết hạn: dùng Claude Code một lần để nó tự làm mới",
"quotaFailed": "Không đọc được quota",
"quotaWrongAccount": "Lượt đăng nhập này thuộc tài khoản Claude khác: đăng nhập lại đúng tài khoản này rồi lưu lại",
"quotaActive": "đang dùng",
"quotaProfileExpired": "Đăng nhập đã hết hạn: bấm Kiểm tra quota trong Claude Code → Profiles để làm mới",
"quotaSignInExpires": "Đăng nhập hết hạn sau {{time}}",
@@ -88,7 +90,6 @@
}
},
"version": "Phiên bản",
"query": "Truy vấn",
"openMenu": "Mở menu",
"resizeSidebar": "Kéo để đổi độ rộng sidebar (nhấp đúp để đặt lại)",
"closeMenu": "Đóng menu",
+13 -13
View File
@@ -577,13 +577,13 @@
"fieldLabelPlaceholder": "Máy dev"
},
"cursorPathsNote": "Chỉ mang tính thông tin: phiên Cursor cũng được tính — Cursor tình cờ dùng cùng đường dẫn ~/.claude với Claude Code (cục bộ và trên máy từ xa đã đồng bộ).",
"aiRouter": {
"title": "AI Router",
"aiProxy": {
"title": "AI Proxy",
"description": "Ví dụ 9Router, OpenRouter hoặc gateway tự dựng. Lưu ở đây rồi chọn cho từng session ở ô nhập prompt của workspace.",
"add": "Thêm profile",
"edit": "Sửa",
"remove": "Xoá",
"empty": "Chưa lưu profile router nào. Native là chạy thẳng vào API của hãng.",
"empty": "Chưa lưu profile proxy nào. Native là chạy thẳng vào API của hãng.",
"builtinHint": "Dựng sẵn — đây là API của chính hãng, không sửa và không xoá được.",
"colLabel": "Profile",
"colBaseUrl": "Endpoint",
@@ -592,16 +592,16 @@
"keySet": "Đã lưu",
"keyMissing": "Chưa có",
"fieldLabel": "Tên",
"fieldLabelHint": "Tên bạn sẽ thấy trong form tạo workspace. Gõ tự do.",
"fieldLabelHint": "Tên bạn sẽ thấy trong ô chọn AI Proxy của session. Gõ tự do.",
"fieldBaseUrl": "Base URL",
"fieldBaseUrlHint": "Endpoint tương thích OpenAI/Anthropic của router, ví dụ http://localhost:20128/v1",
"fieldBaseUrlHint": "URL gốc của proxy, không có /v1 — ví dụ http://localhost:20128. Claude Code tự thêm /v1/messages, Codex tự thêm /v1/responses; /v1 bạn gõ vào sẽ được bỏ đi.",
"fieldApiKey": "API key",
"fieldApiKeyHint": "Lưu trên máy này ở quyền 0600, không bao giờ gửi ngược về trình duyệt.",
"fieldApiKeyKeep": "Để trống nếu muốn giữ key đã lưu.",
"fieldModel": "Model",
"fieldModelHint": "Dùng cho mọi bậc trừ khi bạn ghi đè bên dưới. Nhiều router chỉ cần một tên tượng trưng.",
"fieldModelHint": "Dùng cho mọi bậc trừ khi bạn ghi đè bên dưới. Nhiều proxy chỉ cần một tên tượng trưng.",
"fieldBehavesAs": "Hoạt động như (Claude Code)",
"fieldBehavesAsHint": "Không bắt buộc. Model id của Claude mà Claude Code dùng để hiểu các tên model của router này (mặc định effort, context window), ví dụ claude-opus-5-5. Điền khi Claude Code báo một model không có trong model catalog của nó, và chọn đúng model router thật sự chuyển tới. Khi đã đặt, nó thay thiết lập modelPicker của bạn trong các phiên này.",
"fieldBehavesAsHint": "Không bắt buộc. Model id của Claude mà Claude Code dùng để hiểu các tên model của proxy này (mặc định effort, context window), ví dụ claude-opus-5-5. Điền khi Claude Code báo một model không có trong model catalog của nó, và chọn đúng model proxy thật sự chuyển tới. Khi đã đặt, nó thay thiết lập modelPicker của bạn trong các phiên này.",
"perTier": "Ghi đè theo từng bậc",
"perTierHint": "Để trống một ô là dùng model ở trên.",
"tier": {
@@ -613,12 +613,12 @@
},
"save": "Lưu",
"cancel": "Huỷ",
"removeConfirm": "Xoá profile \"{{label}}\"? Mọi workspace đang dùng nó sẽ được đưa về API gốc của hãng ngay lập tức — cả trên đĩa lẫn trong dashboard.",
"removeConfirm": "Xoá profile \"{{label}}\"? Các file session của nó bị xoá theo, và một session từng chạy trên nó sẽ resume bằng API gốc của hãng.",
"loading": "Đang tải danh sách profile…",
"test": "Kiểm tra",
"testing": "Đang kiểm tra…",
"testOk": "Endpoint đã trả lời — {{model}} dùng được với key này.",
"testFailed": "Không trả lời ({{status}}): {{message}}"
"testOk": "đã trả lời — {{model}} dùng được với key này.",
"testFailed": "không trả lời ({{status}}): {{message}}"
},
"aiTools": {
"title": "AI Tools",
@@ -700,7 +700,7 @@
},
"codexHome": {
"title": "CODEX_HOME",
"description": "Thư mục gốc chứa config và nhật ký phiên của Codex. Dashboard đọc từ đây, ghi layer AI Router vào đây, và mở mọi workspace codex với thư mục này.",
"description": "Thư mục gốc chứa config và nhật ký phiên của Codex. Dashboard đọc từ đây, ghi layer AI Proxy vào đây, và mở mọi workspace codex với thư mục này.",
"restartNote": "Workspace codex đang chạy vẫn dùng thư mục cũ, và hiện là idle, cho tới khi được khởi động lại.",
"placeholder": "/đường-dẫn/tới/codex-home"
},
@@ -792,8 +792,8 @@
"tool": {
"description": "{{tool}}: đường dẫn, mức dùng, mô hình mặc định và file cấu hình."
},
"aiRouter": {
"title": "AI Router",
"aiProxy": {
"title": "AI Proxy",
"description": "Các endpoint đứng trước Claude Code và Codex."
},
"pricing": {
+1
View File
@@ -25,6 +25,7 @@
"releaseUnavailable": "Không đọc được bản phát hành mới nhất từ repo ({{reason}}); đã quay về so sánh theo commit.",
"pluginAutoNote": "Dashboard này cài dạng plugin của Claude Code. Bấm \"Cập nhật ngay\" sẽ tự tải bản plugin mới, cài lại runtime và khởi động lại server — mất vài phút. Riêng skill và slash command mới thì session Claude Code đang mở chỉ thấy sau khi bạn chạy /reload-plugins ở đó.",
"pluginUpdating": "Đang tải bản plugin mới và cài lại runtime — mất vài phút, trang sẽ tự reload khi server mới lên...",
"pluginAlreadyCurrent": "Không có phiên bản plugin mới nào được tải về, nên dashboard đang chạy được giữ nguyên.",
"lastPluginUpdateFailed": "Lần cập nhật plugin trước thất bại ở bước \"{{step}}\": {{error}} — chạy /agenthub-update trong Claude Code để khắc phục.",
"restartingShort": "Đang khởi động lại…",
"installedTitle": "Đã cài bản {{version}} nhưng chưa chạy",
@@ -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();
});
});
+60 -45
View File
@@ -1770,7 +1770,13 @@ export const api = {
* logged-in {@link CcAccountIdentity} (null when nobody is logged in).
*/
profiles: () =>
request<{ items: CcProfile[]; active: CcAccountIdentity | null }>("/cc-config/profiles"),
request<{
items: CcProfile[];
active: CcAccountIdentity | null;
/** Set when the live token belongs to a different account than the
* one `.claude.json` names; `active` is then the token's owner. */
mismatch?: { configEmail: string | null } | null;
}>("/cc-config/profiles"),
/**
* POST /api/cc-config/profiles - snapshot the logged-in account as a
* profile. The identifier and initial label are derived from the account;
@@ -2099,13 +2105,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 +2218,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 +3313,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 +3324,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 +3367,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 +3426,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 +3474,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 +4139,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). */
@@ -1,9 +1,11 @@
/**
* @file QueryExplorer.tsx
* @description `/query`: filter sessions, events or agents (free text, AI tool,
* status, local date range, folder subtree, plus the global data scope), sort
* by a column the server allows, page through the rows, and export the whole
* filtered set as CSV or JSON (`GET /api/query`, capped server-side).
* @file AgentsTable.tsx
* @description The Sessions page's **Agents** tab (`/sessions?view=agents`,
* where the old `/query` redirects): filter agents (free text, AI tool, status,
* local date range, folder subtree, plus the global data scope), sort by a
* column the server allows, page through the rows, and export the whole
* filtered set as CSV or JSON (`GET /api/query?entity=agents`, capped
* server-side). Sessions and events have their own tabs beside it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -16,21 +18,15 @@ import { applyRange } from "../lib/dateRange";
import { useDataScope } from "../lib/dataScope";
import { useAiTools } from "../hooks/useAiTools";
import { Select } from "../components/Select";
import type { QueryEntity, QueryPage } from "../lib/types";
import type { QueryPage } from "../lib/types";
const PAGE_SIZE = 50;
const ENTITIES: QueryEntity[] = ["sessions", "events", "agents"];
const STATUSES: Record<QueryEntity, string[]> = {
sessions: ["active", "completed", "error", "abandoned"],
events: [],
agents: ["working", "waiting", "completed", "error"],
};
const STATUSES = ["working", "waiting", "completed", "error"];
export function QueryExplorer() {
export function AgentsTable() {
const { t } = useTranslation("analytics");
const aiTools = useAiTools();
const [scope] = useDataScope();
const [entity, setEntity] = useState<QueryEntity>("sessions");
const [q, setQ] = useState("");
const [cwd, setCwd] = useState("");
const [typed, setTyped] = useState({ q: "", cwd: "" });
@@ -63,7 +59,7 @@ export function QueryExplorer() {
};
const filters = useMemo(() => {
const p = new URLSearchParams({ entity });
const p = new URLSearchParams({ entity: "agents" });
if (typed.q) p.set("q", typed.q);
if (typed.cwd) p.set("cwd", typed.cwd);
if (tool) p.set("tool", tool);
@@ -73,7 +69,7 @@ export function QueryExplorer() {
p.set("dir", dir);
}
return applyRange(p, { from: from || undefined, to: to || undefined });
}, [entity, typed, tool, status, sort, dir, from, to]);
}, [typed, tool, status, sort, dir, from, to]);
useEffect(() => {
let live = true;
@@ -93,14 +89,6 @@ export function QueryExplorer() {
};
}, [filters, offset, scope]);
const switchEntity = (v: QueryEntity) => {
setEntity(v);
setStatus("");
setSort("");
setDir("desc");
setOffset(0);
setPage(null);
};
const reset = () => {
setQ("");
setCwd("");
@@ -122,24 +110,13 @@ export function QueryExplorer() {
const total = page?.total ?? 0;
const cap = page?.export_cap ?? 50_000;
const statuses = STATUSES[entity];
return (
<div className="animate-fade-in space-y-5">
<div>
<h1 className="text-lg font-semibold text-fg-primary">{t("query.title")}</h1>
<p className="text-sm text-fg-muted">{t("query.subtitle")}</p>
</div>
{/* The tab bar names the view. */}
<h1 className="sr-only">{t("query.entities.agents")}</h1>
<div className="card p-4 flex flex-wrap items-end gap-3">
<div className="w-36">
<Select
value={entity}
onChange={switchEntity}
label={t("query.entity")}
options={ENTITIES.map((v) => ({ value: v, label: t(`query.entities.${v}`) }))}
/>
</div>
<input
className="input text-sm w-56"
value={q}
@@ -158,19 +135,17 @@ export function QueryExplorer() {
]}
/>
</div>
{statuses.length > 0 && (
<div className="w-40">
<Select
value={status}
onChange={fresh(setStatus)}
label={t("query.status")}
options={[
{ value: "", label: t("query.allStatuses") },
...statuses.map((s) => ({ value: s, label: t(`common:status.${s}`) })),
]}
/>
</div>
)}
<div className="w-40">
<Select
value={status}
onChange={fresh(setStatus)}
label={t("query.status")}
options={[
{ value: "", label: t("query.allStatuses") },
...STATUSES.map((s) => ({ value: s, label: t(`common:status.${s}`) })),
]}
/>
</div>
<input
type="date"
className="input text-sm"
@@ -276,8 +251,7 @@ export function QueryExplorer() {
<tr key={`${r.id}-${i}`} className="hover:bg-surface-4">
{page.columns.map((c) => {
const v = r[c];
const sid =
(entity === "sessions" && c === "id") || c === "session_id" ? v : null;
const sid = c === "session_id" ? v : null;
return (
<td
key={c}
@@ -1,16 +1,16 @@
/**
* @file AiRouterConfig.tsx
* @description The **AI Router** tab of Settings — CRUD for the named router
* endpoints (9Router, OpenRouter, a self-hosted gateway) a lane can be pointed
* at instead of the vendor's own API.
* @file AiProxyConfig.tsx
* @description The **AI Proxy** page of Settings — CRUD for the named proxy
* endpoints (9Router, OpenRouter, a self-hosted gateway) a session can run on
* instead of the vendor's own API.
*
* A NEW SESSION chooses its router (the workspace home's prompt box, a pane's
* run form), not here. An edit here reaches the sessions already on a profile
* A NEW SESSION chooses its proxy (the workspace home's prompt box), not here. An edit here reaches the sessions already on a profile
* (the server rewrites the file each reads), and a delete removes that file.
*
* The built-in `native` row is rendered read-only: it is the vendor's own API,
* and it exists so that "go back to native" is the same operation as "use a
* router" rather than a special case.
* proxy" rather than a special case. It has no model of its own (the
* session's model picker picks), so its Model cell reads "—".
*
* An API key is write-only here by design — the server never returns one, so
* the form shows whether a key is saved, never the key.
@@ -21,28 +21,35 @@
* A right-click on a profile row opens its Edit / Remove (plus Copy endpoint)
* as a menu; the buttons stay.
*
* **Test** asks the endpoint itself before the profile is saved onto a lane.
* **Test** asks the endpoint itself, once as Claude Code would
* (`/v1/messages`) and once as Codex would (`/v1/responses`), one line each.
* "Saved" and "works" are different claims, and the gap between them is only
* ever discovered inside a lane's session otherwise, where the error surfaces
* as a broken CLI rather than a bad URL.
* ever discovered inside a session otherwise, where the error surfaces as a
* broken CLI rather than a bad URL.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Plus, Pencil, Trash2, Lock } from "lucide-react";
import { api, type AiRouter, type AiRouterInput, type AiRouterModels } from "../lib/api";
import {
api,
type AiProxy,
type AiProxyInput,
type AiProxyModels,
type AiProxyProbe,
} from "../lib/api";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu";
import { confirmDialog } from "../lib/confirm";
import { copyText } from "../lib/clipboard";
import { useDialog } from "../hooks/useDialog";
const TIERS: (keyof AiRouterModels)[] = ["opus", "sonnet", "haiku", "smallFast", "subagent"];
const TIERS: (keyof AiProxyModels)[] = ["opus", "sonnet", "haiku", "smallFast", "subagent"];
type FormState = Omit<AiRouterInput, "apiKey" | "models" | "behavesAs"> & {
type FormState = Omit<AiProxyInput, "apiKey" | "models" | "behavesAs"> & {
apiKey: string;
behavesAs: string;
models: Partial<AiRouterModels>;
models: Partial<AiProxyModels>;
};
const emptyForm = (): FormState => ({
@@ -54,11 +61,11 @@ const emptyForm = (): FormState => ({
behavesAs: "",
});
export function AiRouterConfig() {
export function AiProxyConfig() {
const { t } = useTranslation("settings");
const [items, setItems] = useState<AiRouter[]>([]);
const [items, setItems] = useState<AiProxy[]>([]);
const [error, setError] = useState<string | null>(null);
const [editing, setEditing] = useState<AiRouter | null>(null);
const [editing, setEditing] = useState<AiProxy | null>(null);
const [form, setForm] = useState(emptyForm());
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
@@ -66,14 +73,14 @@ export function AiRouterConfig() {
const [formError, setFormError] = useState<string | null>(null);
const dialogRef = useRef<HTMLDivElement>(null);
useDialog(open, dialogRef, () => setOpen(false));
// The router's own verdict on the form as it currently stands. Cleared on
// every field change: a green "works" left sitting above an edited base URL
// is a claim about a configuration that no longer exists.
const [testResult, setTestResult] = useState<{ ok: boolean; text: string } | null>(null);
// The proxy's own verdict on the form as it currently stands, one line per
// CLI. Cleared on every field change: a green "works" left sitting above an
// edited base URL is a claim about a configuration that no longer exists.
const [testResult, setTestResult] = useState<{ ok: boolean; text: string }[] | null>(null);
const [testing, setTesting] = useState(false);
const ctx = useContextMenu<AiRouter>();
const ctx = useContextMenu<AiProxy>();
// A menu item: close the menu, then act on the row it was opened on.
const pick = (fn: (item: AiRouter) => unknown) => () => {
const pick = (fn: (item: AiProxy) => unknown) => () => {
const item = ctx.menu?.item;
ctx.close();
if (item) fn(item);
@@ -91,7 +98,7 @@ export function AiRouterConfig() {
// vanish. Keeping the message untranslated is the cheaper half of the trade.
const load = useCallback(async () => {
try {
setItems((await api.aiRouters.list()).items);
setItems((await api.aiProxies.list()).items);
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
@@ -114,7 +121,7 @@ export function AiRouterConfig() {
setOpen(true);
};
const startEdit = (item: AiRouter) => {
const startEdit = (item: AiProxy) => {
setEditing(item);
// No apiKey: the server never sent one. Blank means "keep what is stored".
setForm({
@@ -129,7 +136,6 @@ export function AiRouterConfig() {
setOpen(true);
};
/** Report any lane the server could not re-point, or clear a stale report. */
/**
* Ask the endpoint itself. `name` is sent when editing so a profile whose key
* the form cannot show is still testable — the server only honours it while
@@ -139,23 +145,23 @@ export function AiRouterConfig() {
setTesting(true);
setTestResult(null);
try {
const result = await api.aiRouters.test({
const result = await api.aiProxies.test({
baseUrl: form.baseUrl.trim(),
model: form.model.trim(),
...(form.apiKey.trim() ? { apiKey: form.apiKey.trim() } : {}),
...(editing ? { name: editing.name } : {}),
});
setTestResult({
ok: result.ok,
text: result.ok
? t("aiRouter.testOk", { model: result.message })
: t("aiRouter.testFailed", {
status: result.status ?? "—",
message: result.message,
}),
const line = (cli: string, probe: AiProxyProbe) => ({
ok: probe.ok,
text: `${cli}: ${
probe.ok
? t("aiProxy.testOk", { model: probe.message })
: t("aiProxy.testFailed", { status: probe.status ?? "—", message: probe.message })
}`,
});
setTestResult([line("Claude Code", result.claude), line("Codex", result.codex)]);
} catch (e) {
setTestResult({ ok: false, text: e instanceof Error ? e.message : String(e) });
setTestResult([{ ok: false, text: e instanceof Error ? e.message : String(e) }]);
} finally {
setTesting(false);
}
@@ -167,13 +173,13 @@ export function AiRouterConfig() {
try {
// Send only the tiers that differ from the headline model, so the server
// keeps filling the rest from it — that is what makes one symbolic name
// enough for a router that wants the same value everywhere.
const models: Partial<AiRouterModels> = {};
// enough for a proxy that wants the same value everywhere.
const models: Partial<AiProxyModels> = {};
for (const tier of TIERS) {
const value = (form.models[tier] || "").trim();
if (value && value !== form.model.trim()) models[tier] = value;
}
const body: AiRouterInput = {
const body: AiProxyInput = {
label: form.label.trim(),
baseUrl: form.baseUrl.trim(),
model: form.model.trim(),
@@ -182,8 +188,8 @@ export function AiRouterConfig() {
};
if (form.apiKey.trim()) body.apiKey = form.apiKey.trim();
if (editing) await api.aiRouters.update(editing.name, body);
else await api.aiRouters.create(body);
if (editing) await api.aiProxies.update(editing.name, body);
else await api.aiProxies.create(body);
setOpen(false);
await load();
@@ -194,17 +200,17 @@ export function AiRouterConfig() {
}
};
const remove = async (item: AiRouter) => {
const remove = async (item: AiProxy) => {
if (
!(await confirmDialog({
title: t("aiRouter.removeConfirm", { label: item.label }),
confirmLabel: t("aiRouter.remove"),
title: t("aiProxy.removeConfirm", { label: item.label }),
confirmLabel: t("aiProxy.remove"),
}))
)
return;
setError(null);
try {
await api.aiRouters.remove(item.name);
await api.aiProxies.remove(item.name);
await load();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
@@ -215,14 +221,14 @@ export function AiRouterConfig() {
<div className="space-y-4">
<div className="flex items-start justify-between gap-4">
{/* The page header above already names the section. */}
<p className="max-w-2xl text-xs text-fg-muted">{t("aiRouter.description")}</p>
<p className="max-w-2xl text-xs text-fg-muted">{t("aiProxy.description")}</p>
<button
type="button"
onClick={startAdd}
className="inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-accent/40 bg-accent/15 px-3 py-1.5 text-xs font-medium text-accent transition-colors hover:bg-accent/25"
>
<Plus className="h-3.5 w-3.5" />
{t("aiRouter.add")}
{t("aiProxy.add")}
</button>
</div>
@@ -236,10 +242,10 @@ export function AiRouterConfig() {
<table className="w-full text-left text-xs">
<thead className="bg-surface-2 text-[10px] uppercase tracking-wider text-fg-muted">
<tr>
<th className="px-3 py-2 font-medium">{t("aiRouter.colLabel")}</th>
<th className="px-3 py-2 font-medium">{t("aiRouter.colBaseUrl")}</th>
<th className="px-3 py-2 font-medium">{t("aiRouter.colModel")}</th>
<th className="px-3 py-2 font-medium">{t("aiRouter.colKey")}</th>
<th className="px-3 py-2 font-medium">{t("aiProxy.colLabel")}</th>
<th className="px-3 py-2 font-medium">{t("aiProxy.colBaseUrl")}</th>
<th className="px-3 py-2 font-medium">{t("aiProxy.colModel")}</th>
<th className="px-3 py-2 font-medium">{t("aiProxy.colKey")}</th>
<th className="px-3 py-2" />
</tr>
</thead>
@@ -257,27 +263,25 @@ export function AiRouterConfig() {
</span>
{item.builtin && (
<span className="mt-0.5 block text-[10px] text-fg-muted">
{t("aiRouter.builtinHint")}
{t("aiProxy.builtinHint")}
</span>
)}
</td>
<td className="px-3 py-2 font-mono text-[11px] text-fg-secondary">
{item.baseUrl || "—"}
</td>
<td className="px-3 py-2 font-mono text-[11px] text-fg-secondary">{item.model}</td>
<td className="px-3 py-2 font-mono text-[11px] text-fg-secondary">
{item.model || "—"}
</td>
<td className="px-3 py-2 text-fg-secondary">
{item.builtin
? "—"
: item.hasKey
? t("aiRouter.keySet")
: t("aiRouter.keyMissing")}
{item.builtin ? "—" : item.hasKey ? t("aiProxy.keySet") : t("aiProxy.keyMissing")}
</td>
<td className="px-3 py-2 text-right">
{!item.builtin && (
<span className="inline-flex gap-1">
<button
type="button"
aria-label={`${t("aiRouter.edit")} ${item.label}`}
aria-label={`${t("aiProxy.edit")} ${item.label}`}
onClick={() => startEdit(item)}
className="rounded p-1 text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary"
>
@@ -285,7 +289,7 @@ export function AiRouterConfig() {
</button>
<button
type="button"
aria-label={`${t("aiRouter.remove")} ${item.label}`}
aria-label={`${t("aiProxy.remove")} ${item.label}`}
onClick={() => remove(item)}
className="rounded p-1 text-fg-muted transition-colors hover:bg-surface-3 hover:text-status-danger"
>
@@ -299,7 +303,7 @@ export function AiRouterConfig() {
{(loading || items.length <= 1) && (
<tr className="border-t border-border">
<td colSpan={5} className="px-3 py-4 text-center text-fg-muted">
{loading ? t("aiRouter.loading") : t("aiRouter.empty")}
{loading ? t("aiProxy.loading") : t("aiProxy.empty")}
</td>
</tr>
)}
@@ -307,8 +311,8 @@ export function AiRouterConfig() {
</table>
</div>
{ctx.menu && (
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="ai-router-menu">
<MenuItem onSelect={pick(startEdit)}>{t("aiRouter.edit")}</MenuItem>
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="ai-proxy-menu">
<MenuItem onSelect={pick(startEdit)}>{t("aiProxy.edit")}</MenuItem>
{ctx.menu.item.baseUrl && (
<MenuItem onSelect={pick((item) => void copyText(item.baseUrl))}>
{t("rowMenu.copyUrl")}
@@ -316,7 +320,7 @@ export function AiRouterConfig() {
)}
<MenuSeparator />
<MenuItem danger onSelect={pick((item) => void remove(item))}>
{t("aiRouter.remove")}
{t("aiProxy.remove")}
</MenuItem>
</ContextMenu>
)}
@@ -330,36 +334,36 @@ export function AiRouterConfig() {
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="router-form-title"
aria-labelledby="proxy-form-title"
className="max-h-[90vh] w-full max-w-md overflow-y-auto rounded-xl border border-border bg-surface-1 p-5 shadow-xl"
>
<h3 id="router-form-title" className="mb-3 text-sm font-semibold text-fg-primary">
{editing ? t("aiRouter.edit") : t("aiRouter.add")}
<h3 id="proxy-form-title" className="mb-3 text-sm font-semibold text-fg-primary">
{editing ? t("aiProxy.edit") : t("aiProxy.add")}
</h3>
<div className="space-y-3">
<Field
id="router-label"
label={t("aiRouter.fieldLabel")}
hint={t("aiRouter.fieldLabelHint")}
id="proxy-label"
label={t("aiProxy.fieldLabel")}
hint={t("aiProxy.fieldLabelHint")}
value={form.label}
onChange={(v) => setForm((f) => ({ ...f, label: v }))}
/>
<Field
id="router-base-url"
label={t("aiRouter.fieldBaseUrl")}
hint={t("aiRouter.fieldBaseUrlHint")}
id="proxy-base-url"
label={t("aiProxy.fieldBaseUrl")}
hint={t("aiProxy.fieldBaseUrlHint")}
value={form.baseUrl}
mono
onChange={(v) => setForm((f) => ({ ...f, baseUrl: v }))}
/>
<Field
id="router-api-key"
label={t("aiRouter.fieldApiKey")}
id="proxy-api-key"
label={t("aiProxy.fieldApiKey")}
hint={
editing && editing.hasKey
? t("aiRouter.fieldApiKeyKeep")
: t("aiRouter.fieldApiKeyHint")
? t("aiProxy.fieldApiKeyKeep")
: t("aiProxy.fieldApiKeyHint")
}
value={form.apiKey}
type="password"
@@ -367,17 +371,17 @@ export function AiRouterConfig() {
onChange={(v) => setForm((f) => ({ ...f, apiKey: v }))}
/>
<Field
id="router-model"
label={t("aiRouter.fieldModel")}
hint={t("aiRouter.fieldModelHint")}
id="proxy-model"
label={t("aiProxy.fieldModel")}
hint={t("aiProxy.fieldModelHint")}
value={form.model}
mono
onChange={(v) => setForm((f) => ({ ...f, model: v }))}
/>
<Field
id="router-behaves-as"
label={t("aiRouter.fieldBehavesAs")}
hint={t("aiRouter.fieldBehavesAsHint")}
id="proxy-behaves-as"
label={t("aiProxy.fieldBehavesAs")}
hint={t("aiProxy.fieldBehavesAsHint")}
value={form.behavesAs}
mono
placeholder="claude-opus-5-5"
@@ -386,15 +390,15 @@ export function AiRouterConfig() {
<details className="rounded-lg border border-border bg-surface-2 p-2">
<summary className="cursor-pointer text-xs text-fg-secondary">
{t("aiRouter.perTier")}
{t("aiProxy.perTier")}
</summary>
<p className="mt-1 text-[10px] text-fg-muted">{t("aiRouter.perTierHint")}</p>
<p className="mt-1 text-[10px] text-fg-muted">{t("aiProxy.perTierHint")}</p>
<div className="mt-2 space-y-2">
{TIERS.map((tier) => (
<Field
key={tier}
id={`router-model-${tier}`}
label={t(`aiRouter.tier.${tier}`)}
id={`proxy-model-${tier}`}
label={t(`aiProxy.tier.${tier}`)}
value={form.models[tier] || ""}
mono
placeholder={form.model}
@@ -416,35 +420,36 @@ export function AiRouterConfig() {
</p>
)}
{testResult && (
{testResult?.map((r) => (
<p
data-testid="router-test-result"
key={r.text}
data-testid="proxy-test-result"
className={`mt-3 rounded-md border px-3 py-2 text-xs ${
testResult.ok
r.ok
? "border-status-success/40 bg-status-success/10 text-status-success"
: "border-status-danger/40 bg-status-danger/10 text-status-danger"
}`}
>
{testResult.text}
{r.text}
</p>
)}
))}
<div className="mt-4 flex items-center justify-end gap-2">
<button
type="button"
data-testid="router-test"
data-testid="proxy-test"
onClick={test}
disabled={testing || busy || !form.baseUrl.trim() || !form.model.trim()}
className="mr-auto rounded-md border border-border-light px-3 py-1.5 text-xs text-fg-secondary transition-colors hover:text-fg-primary disabled:opacity-50"
>
{testing ? t("aiRouter.testing") : t("aiRouter.test")}
{testing ? t("aiProxy.testing") : t("aiProxy.test")}
</button>
<button
type="button"
onClick={() => setOpen(false)}
className="rounded-md px-3 py-1.5 text-xs text-fg-secondary transition-colors hover:bg-surface-3"
>
{t("aiRouter.cancel")}
{t("aiProxy.cancel")}
</button>
<button
type="button"
@@ -452,7 +457,7 @@ export function AiRouterConfig() {
disabled={busy || !form.label.trim() || !form.baseUrl.trim() || !form.model.trim()}
className="btn-primary px-3 py-1.5 text-xs"
>
{t("aiRouter.save")}
{t("aiProxy.save")}
</button>
</div>
</div>
+15 -4
View File
@@ -4763,6 +4763,8 @@ function ProfilesModal({
const { t } = useTranslation("ccConfig");
const [items, setItems] = useState<CcProfile[] | null>(null);
const [active, setActive] = useState<CcAccountIdentity | null>(null);
/** `.claude.json`'s account, when it is not the one the live token belongs to. */
const [mismatchEmail, setMismatchEmail] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
/** `name` of the profile whose label is being edited inline, plus its draft. */
@@ -4787,6 +4789,7 @@ function ProfilesModal({
const r = await api.ccConfig.profiles();
setItems(r.items);
setActive(r.active);
setMismatchEmail(r.mismatch ? r.mismatch.configEmail || "?" : null);
setError(null);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "unknown");
@@ -4931,10 +4934,18 @@ function ProfilesModal({
<div className="px-4 py-3 border-b border-border space-y-2">
<p className="text-[11px] text-fg-muted leading-relaxed">{t("profiles.subtitle")}</p>
<p className="text-[11px] text-status-warning inline-flex items-start gap-1.5 leading-relaxed">
<ShieldAlert className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
<span>{t("profiles.warning")}</span>
</p>
<p className="text-[11px] text-fg-muted leading-relaxed">{t("profiles.warning")}</p>
{mismatchEmail && (
<p className="text-[11px] text-status-warning inline-flex items-start gap-1.5 leading-relaxed">
<ShieldAlert className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
<span>
{t("profiles.mismatch", {
config: mismatchEmail,
owner: active?.email || active?.accountUuid || "?",
})}
</span>
</p>
)}
</div>
<div className="overflow-auto p-4 space-y-3">
+8 -5
View File
@@ -1,17 +1,19 @@
/**
* @file History.tsx
* @description Tabbed shell for /sessions that hosts the two views over the same
* event pipeline: the per-session list and the raw activity feed. The active tab
* lives in the `?view=` query param, so `/activity` can redirect into it.
* @description Tabbed shell for /sessions that hosts the three views over the
* same data: the per-session list, the raw activity feed and the agents table.
* The active tab lives in the `?view=` query param, so `/activity` and the old
* `/query` Query Explorer can redirect into it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { ComponentType } from "react";
import { useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Activity, FolderOpen } from "lucide-react";
import { Activity, Bot, FolderOpen } from "lucide-react";
import { PageTabs } from "../components/PageTabs";
import { Sessions } from "./Sessions";
import { ActivityFeed } from "./ActivityFeed";
import { AgentsTable } from "./AgentsTable";
interface HistoryView {
id: string;
@@ -21,7 +23,7 @@ interface HistoryView {
}
/**
* Sessions page shell: grouped sessions and the raw event stream as sibling tabs.
* Sessions page shell: sessions, the raw event stream and agents as sibling tabs.
* @returns The tab bar plus the active view.
*/
export function History() {
@@ -31,6 +33,7 @@ export function History() {
const views: HistoryView[] = [
{ id: "sessions", label: t("sessions"), Icon: FolderOpen, Panel: Sessions },
{ id: "events", label: t("activityFeed"), Icon: Activity, Panel: ActivityFeed },
{ id: "agents", label: t("agents"), Icon: Bot, Panel: AgentsTable },
];
const defaultView = views[0]!;
const active = views.find((v) => v.id === params.get("view")) ?? defaultView;
+1 -1
View File
@@ -1229,7 +1229,7 @@ function ClearDataDialog({
aria-modal="true"
aria-labelledby="clear-data-title"
aria-describedby="clear-data-body"
className="card w-full max-w-md space-y-4 border-status-danger/40 p-5"
className="card max-h-full w-full max-w-md space-y-4 overflow-y-auto border-status-danger/40 p-5"
onClick={(e) => e.stopPropagation()}
>
<h2
+11 -6
View File
@@ -46,7 +46,7 @@ import { Settings } from "./Settings";
import { CcConfig } from "./CcConfig";
import { CodexConfig } from "./CodexConfig";
import { AiToolConfig } from "./AiToolConfig";
import { AiRouterConfig } from "./AiRouterConfig";
import { AiProxyConfig } from "./AiProxyConfig";
import { GitCredentialsConfig } from "./GitCredentialsConfig";
import { AppearanceSettings } from "../components/settings/AppearanceSettings";
import { SnapshotSettings } from "../components/settings/SnapshotSettings";
@@ -82,7 +82,7 @@ const LEGACY_TOOL: Record<string, string> = {
dashboard: "appearance",
"claude-code": "agents/claude-code",
codex: "agents/codex",
"ai-router": "ai-router",
"ai-router": "ai-proxy",
"git-credentials": "git-credentials",
};
@@ -117,7 +117,12 @@ function toolNavIcon(id: string, label: string) {
return Icon;
}
const MOVED: Record<string, string> = { general: "appearance", pet: "appearance#pet" };
const MOVED: Record<string, string> = {
general: "appearance",
pet: "appearance#pet",
// The AI Router → AI Proxy rename.
"ai-router": "ai-proxy",
};
/** An unknown section: say so, then show Appearance. */
function UnknownSection({ name }: { name: string }) {
@@ -290,11 +295,11 @@ export function SettingsHub() {
render: () => <AiToolConfig key={x.id} toolId={x.id} />,
})),
{
id: "ai-router",
id: "ai-proxy",
group: "routing",
Icon: Router,
...s("aiRouter"),
render: () => <AiRouterConfig />,
...s("aiProxy"),
render: () => <AiProxyConfig />,
},
{
id: "pricing",
@@ -1,8 +1,8 @@
/**
* @file QueryExplorer.test.tsx
* @description The Query Explorer page: it renders a page of rows with session
* links, sorts by an allowed column on a header click (flipping on a second),
* switches entity, and points both export links at the current filter.
* @file AgentsTable.test.tsx
* @description The Sessions page's Agents tab: it asks only for agents, renders
* a page of rows with session links, sorts by an allowed column on a header
* click (flipping on a second), and points both export links at the filter.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -23,19 +23,8 @@ vi.mock("../../hooks/useAiTools", () => ({
useAiTools: () => ({ tools: [{ id: "codex", label: "Codex" }] }),
}));
import { QueryExplorer } from "../QueryExplorer";
import { AgentsTable } from "../AgentsTable";
const page = (entity: string, rows: Record<string, string>[]) => ({
entity,
columns: Object.keys(rows[0] ?? {}),
sortable: entity === "sessions" ? ["started_at", "name"] : ["created_at"],
statuses: [],
rows,
total: rows.length,
limit: 50,
offset: 0,
export_cap: 50000,
});
const lastParams = () => {
const calls = query.page.mock.calls;
return calls[calls.length - 1]![0] as URLSearchParams;
@@ -44,28 +33,34 @@ const lastParams = () => {
beforeEach(() => {
i18n.changeLanguage("en");
vi.clearAllMocks();
query.page.mockImplementation((p: URLSearchParams) =>
Promise.resolve(
p.get("entity") === "events"
? page("events", [{ id: "1", session_id: "sess-abcdef12", created_at: "2026-10-01" }])
: page("sessions", [{ id: "sess-abcdef12", name: "first", started_at: "2026-10-01" }])
)
);
query.page.mockResolvedValue({
entity: "agents",
columns: ["id", "session_id", "name", "started_at"],
sortable: ["started_at", "name"],
statuses: ["working", "waiting", "completed", "error"],
rows: [{ id: "a1", session_id: "sess-abcdef12", name: "first", started_at: "2026-10-01" }],
total: 1,
limit: 50,
offset: 0,
export_cap: 50000,
});
});
describe("QueryExplorer", () => {
it("shows rows, sorts by an allowed column and exports the filter", async () => {
describe("AgentsTable", () => {
it("lists agents, sorts by an allowed column and exports the filter", async () => {
const user = userEvent.setup();
render(
<MemoryRouter>
<QueryExplorer />
<AgentsTable />
</MemoryRouter>
);
expect(await screen.findByText("first")).toBeInTheDocument();
expect(lastParams().get("entity")).toBe("agents");
expect(screen.getByRole("link", { name: "sess-abc" })).toHaveAttribute(
"href",
"/sessions/sess-abcdef12"
);
expect(screen.getByRole("button", { name: /Status/ })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "name" }));
await waitFor(() => expect(lastParams().get("sort")).toBe("name"));
expect(lastParams().get("dir")).toBe("desc");
@@ -74,22 +69,8 @@ describe("QueryExplorer", () => {
// A column outside `sortable` has no sort button.
expect(screen.queryByRole("button", { name: "id" })).toBeNull();
const csv = screen.getByRole("link", { name: /Export CSV/ });
expect(csv.getAttribute("href")).toContain("entity=agents");
expect(csv.getAttribute("href")).toContain("sort=name");
expect(csv.getAttribute("href")).toContain("format=csv");
});
it("switches entity and drops the status filter for events", async () => {
const user = userEvent.setup();
render(
<MemoryRouter>
<QueryExplorer />
</MemoryRouter>
);
await screen.findByText("first");
expect(screen.getByRole("button", { name: /Status/ })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /^Data/ }));
await user.click(screen.getByRole("option", { name: "Events" }));
await waitFor(() => expect(lastParams().get("entity")).toBe("events"));
expect(screen.queryByRole("button", { name: /Status/ })).toBeNull();
});
});
@@ -0,0 +1,288 @@
/**
* @file Tests for the AI Proxy settings page — listing profiles, creating one,
* and the two rules that keep a key safe: the form never shows one, and an
* edit that leaves the field blank must not send an empty key that would wipe
* the stored one.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AiProxyConfig } from "../AiProxyConfig";
import { api } from "../../lib/api";
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../lib/confirm";
vi.mock("../../lib/api", () => ({
api: {
aiProxies: {
list: vi.fn(),
create: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
test: vi.fn(),
},
},
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) =>
opts && typeof opts.label === "string" ? `${key}:${opts.label}` : key,
}),
}));
const native = {
name: "native",
label: "Native (Claude / Codex)",
builtin: true,
baseUrl: "",
hasKey: false,
model: "",
models: {},
};
const nineRouter = {
...native,
name: "9router",
label: "9Router",
builtin: false,
baseUrl: "http://localhost:20128/v1",
hasKey: true,
model: "Highest",
models: {
opus: "Highest",
sonnet: "Highest",
haiku: "Highest",
smallFast: "Highest",
subagent: "Highest",
},
};
beforeEach(() => {
vi.mocked(api.aiProxies.list)
.mockReset()
.mockResolvedValue({ items: [native] });
vi.mocked(api.aiProxies.create).mockReset().mockResolvedValue(nineRouter);
vi.mocked(api.aiProxies.update).mockReset().mockResolvedValue(nineRouter);
vi.mocked(api.aiProxies.remove).mockReset().mockResolvedValue({ removed: "9router" });
vi.mocked(api.aiProxies.test)
.mockReset()
.mockResolvedValue({
claude: { ok: true, status: 200, message: "Highest" },
codex: { ok: true, status: 200, message: "Highest" },
});
});
describe("AiProxyConfig", () => {
it("lists the built-in native profile with no edit or delete control", async () => {
render(<AiProxyConfig />);
expect(await screen.findByText("Native (Claude / Codex)")).toBeInTheDocument();
expect(screen.queryByLabelText(/aiProxy\.edit Native/)).toBeNull();
expect(screen.queryByLabelText(/aiProxy\.remove Native/)).toBeNull();
});
it("saves a new profile with the typed endpoint, key and model", async () => {
render(<AiProxyConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiProxy\.add/ }));
await user.type(screen.getByLabelText("aiProxy.fieldLabel"), "9Router");
await user.type(screen.getByLabelText("aiProxy.fieldBaseUrl"), "http://localhost:20128/v1");
await user.type(screen.getByLabelText("aiProxy.fieldApiKey"), "sk-typed");
await user.type(screen.getByLabelText("aiProxy.fieldModel"), "Highest");
await user.click(screen.getByRole("button", { name: "aiProxy.save" }));
await waitFor(() =>
expect(api.aiProxies.create).toHaveBeenCalledWith({
label: "9Router",
baseUrl: "http://localhost:20128/v1",
model: "Highest",
// Every tier equals the headline model, so none is sent: the server
// fills them, which is what makes one field enough.
models: {},
behavesAs: "",
apiKey: "sk-typed",
})
);
});
it("sends the typed behavesAs, which maps the proxy's model names in Claude Code (#56)", async () => {
render(<AiProxyConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiProxy\.add/ }));
await user.type(screen.getByLabelText("aiProxy.fieldLabel"), "9Router");
await user.type(screen.getByLabelText("aiProxy.fieldBaseUrl"), "http://localhost:20128/v1");
await user.type(screen.getByLabelText("aiProxy.fieldModel"), "agent");
await user.type(screen.getByLabelText("aiProxy.fieldBehavesAs"), "claude-opus-5-5");
await user.click(screen.getByRole("button", { name: "aiProxy.save" }));
await waitFor(() =>
expect(api.aiProxies.create).toHaveBeenCalledWith(
expect.objectContaining({ model: "agent", behavesAs: "claude-opus-5-5" })
)
);
});
it("sends only the tiers that differ from the headline model", async () => {
render(<AiProxyConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiProxy\.add/ }));
await user.type(screen.getByLabelText("aiProxy.fieldLabel"), "9Router");
await user.type(screen.getByLabelText("aiProxy.fieldBaseUrl"), "http://localhost:20128/v1");
await user.type(screen.getByLabelText("aiProxy.fieldModel"), "Highest");
await user.type(screen.getByLabelText("aiProxy.tier.opus"), "PremiumCombo");
await user.click(screen.getByRole("button", { name: "aiProxy.save" }));
await waitFor(() =>
expect(api.aiProxies.create).toHaveBeenCalledWith(
expect.objectContaining({ models: { opus: "PremiumCombo" } })
)
);
});
it("never puts a stored key in the edit form, and omits it when left blank", async () => {
// The API cannot return a key, so an edit that sends `apiKey: ""` would
// silently erase the one on disk.
vi.mocked(api.aiProxies.list).mockResolvedValue({ items: [native, nineRouter] });
render(<AiProxyConfig />);
const user = userEvent.setup();
await user.click(await screen.findByLabelText("aiProxy.edit 9Router"));
expect(screen.getByLabelText("aiProxy.fieldApiKey")).toHaveValue("");
await user.clear(screen.getByLabelText("aiProxy.fieldLabel"));
await user.type(screen.getByLabelText("aiProxy.fieldLabel"), "9Router nhà");
await user.click(screen.getByRole("button", { name: "aiProxy.save" }));
await waitFor(() => expect(api.aiProxies.update).toHaveBeenCalled());
const call = vi.mocked(api.aiProxies.update).mock.calls[0];
expect(call?.[1]).not.toHaveProperty("apiKey");
});
it("surfaces a server error instead of closing the form", async () => {
vi.mocked(api.aiProxies.create).mockRejectedValue(
new Error("baseUrl must be http:// or https://")
);
render(<AiProxyConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiProxy\.add/ }));
await user.type(screen.getByLabelText("aiProxy.fieldLabel"), "Bad");
await user.type(screen.getByLabelText("aiProxy.fieldBaseUrl"), "file:///etc/passwd");
await user.type(screen.getByLabelText("aiProxy.fieldModel"), "x");
await user.click(screen.getByRole("button", { name: "aiProxy.save" }));
// Inside the dialog, not behind its scrim.
const dialog = screen.getByRole("dialog");
expect(await within(dialog).findByText(/must be http/)).toBeInTheDocument();
});
it("closes the form on Escape", async () => {
render(<AiProxyConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiProxy\.add/ }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
await user.keyboard("{Escape}");
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
});
});
describe("AiProxyConfig — testing an endpoint before saving it", () => {
it("sends the typed endpoint, key and model, and reports success", async () => {
render(<AiProxyConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiProxy\.add/ }));
await user.type(screen.getByLabelText("aiProxy.fieldBaseUrl"), "http://localhost:20128/v1");
await user.type(screen.getByLabelText("aiProxy.fieldApiKey"), "sk-typed");
await user.type(screen.getByLabelText("aiProxy.fieldModel"), "Highest");
await user.click(screen.getByTestId("proxy-test"));
await waitFor(() =>
expect(api.aiProxies.test).toHaveBeenCalledWith({
baseUrl: "http://localhost:20128/v1",
model: "Highest",
apiKey: "sk-typed",
})
);
const lines = await screen.findAllByTestId("proxy-test-result");
expect(lines[0]).toHaveTextContent("Claude Code: aiProxy.testOk");
expect(lines[1]).toHaveTextContent("Codex: aiProxy.testOk");
});
it("names the profile being edited so the server can reuse its stored key", async () => {
vi.mocked(api.aiProxies.list).mockResolvedValue({ items: [native, nineRouter] });
render(<AiProxyConfig />);
const user = userEvent.setup();
await user.click(await screen.findByLabelText(/aiProxy\.edit 9Router/));
await user.click(screen.getByTestId("proxy-test"));
await waitFor(() =>
expect(api.aiProxies.test).toHaveBeenCalledWith({
baseUrl: "http://localhost:20128/v1",
model: "Highest",
name: "9router",
})
);
});
it("shows the proxy's own refusal per CLI rather than claiming it works", async () => {
vi.mocked(api.aiProxies.test).mockResolvedValue({
claude: { ok: false, status: 401, message: "invalid api key" },
codex: { ok: true, status: 200, message: "Highest" },
});
render(<AiProxyConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiProxy\.add/ }));
await user.type(screen.getByLabelText("aiProxy.fieldBaseUrl"), "http://localhost:20128/v1");
await user.type(screen.getByLabelText("aiProxy.fieldModel"), "Highest");
await user.click(screen.getByTestId("proxy-test"));
const [claude, codex] = await screen.findAllByTestId("proxy-test-result");
expect(claude).toHaveTextContent("Claude Code: aiProxy.testFailed");
expect(codex).toHaveTextContent("Codex: aiProxy.testOk");
});
it("drops a stale verdict as soon as a field changes", async () => {
render(<AiProxyConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiProxy\.add/ }));
await user.type(screen.getByLabelText("aiProxy.fieldBaseUrl"), "http://localhost:20128/v1");
await user.type(screen.getByLabelText("aiProxy.fieldModel"), "Highest");
await user.click(screen.getByTestId("proxy-test"));
expect(await screen.findAllByTestId("proxy-test-result")).toHaveLength(2);
await user.type(screen.getByLabelText("aiProxy.fieldBaseUrl"), "2");
await waitFor(() => expect(screen.queryAllByTestId("proxy-test-result")).toHaveLength(0));
});
it("opens a profile's Edit and Remove from a right-click; the built-in row has no menu", async () => {
vi.mocked(api.aiProxies.list).mockResolvedValue({ items: [native, nineRouter] });
let pending: PendingConfirm | null = null;
const off = subscribeConfirm((p) => (pending = p));
const user = userEvent.setup();
render(<AiProxyConfig />);
const row = (await screen.findByText("9Router")).closest("tr")!;
await user.pointer({ keys: "[MouseRight]", target: screen.getByText(native.label) });
expect(screen.queryByRole("menu")).toBeNull();
await user.pointer({ keys: "[MouseRight]", target: row });
await user.click(screen.getByRole("menuitem", { name: "aiProxy.remove" }));
await waitFor(() => expect(pending?.title).toBe("aiProxy.removeConfirm:9Router"));
settleConfirm(false);
await waitFor(() => expect(pending).toBeNull());
expect(api.aiProxies.remove).not.toHaveBeenCalled();
await user.pointer({ keys: "[MouseRight]", target: row });
await user.click(screen.getByRole("menuitem", { name: "aiProxy.edit" }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
off();
});
});
@@ -1,290 +0,0 @@
/**
* @file Tests for the AI Router settings tab — listing profiles, creating one,
* and the two rules that keep a key safe: the form never shows one, and an
* edit that leaves the field blank must not send an empty key that would wipe
* the stored one.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AiRouterConfig } from "../AiRouterConfig";
import { api } from "../../lib/api";
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../lib/confirm";
vi.mock("../../lib/api", () => ({
api: {
aiRouters: {
list: vi.fn(),
create: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
test: vi.fn(),
},
},
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) =>
opts && typeof opts.label === "string" ? `${key}:${opts.label}` : key,
}),
}));
const native = {
name: "native",
label: "Native (Claude / Codex)",
builtin: true,
baseUrl: "",
hasKey: false,
model: "claude-sonnet-5",
models: {
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",
},
};
const nineRouter = {
...native,
name: "9router",
label: "9Router",
builtin: false,
baseUrl: "http://localhost:20128/v1",
hasKey: true,
model: "Highest",
models: {
opus: "Highest",
sonnet: "Highest",
haiku: "Highest",
smallFast: "Highest",
subagent: "Highest",
},
};
beforeEach(() => {
vi.mocked(api.aiRouters.list)
.mockReset()
.mockResolvedValue({ items: [native] });
vi.mocked(api.aiRouters.create).mockReset().mockResolvedValue(nineRouter);
vi.mocked(api.aiRouters.update).mockReset().mockResolvedValue(nineRouter);
vi.mocked(api.aiRouters.remove).mockReset().mockResolvedValue({ removed: "9router" });
vi.mocked(api.aiRouters.test)
.mockReset()
.mockResolvedValue({ ok: true, status: 200, message: "Highest" });
});
describe("AiRouterConfig", () => {
it("lists the built-in native profile with no edit or delete control", async () => {
render(<AiRouterConfig />);
expect(await screen.findByText("Native (Claude / Codex)")).toBeInTheDocument();
expect(screen.queryByLabelText(/aiRouter\.edit Native/)).toBeNull();
expect(screen.queryByLabelText(/aiRouter\.remove Native/)).toBeNull();
});
it("saves a new profile with the typed endpoint, key and model", async () => {
render(<AiRouterConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
await user.type(screen.getByLabelText("aiRouter.fieldLabel"), "9Router");
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "http://localhost:20128/v1");
await user.type(screen.getByLabelText("aiRouter.fieldApiKey"), "sk-typed");
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "Highest");
await user.click(screen.getByRole("button", { name: "aiRouter.save" }));
await waitFor(() =>
expect(api.aiRouters.create).toHaveBeenCalledWith({
label: "9Router",
baseUrl: "http://localhost:20128/v1",
model: "Highest",
// Every tier equals the headline model, so none is sent: the server
// fills them, which is what makes one field enough.
models: {},
behavesAs: "",
apiKey: "sk-typed",
})
);
});
it("sends the typed behavesAs, which maps the router's model names in Claude Code (#56)", async () => {
render(<AiRouterConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
await user.type(screen.getByLabelText("aiRouter.fieldLabel"), "9Router");
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "http://localhost:20128/v1");
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "agent");
await user.type(screen.getByLabelText("aiRouter.fieldBehavesAs"), "claude-opus-5-5");
await user.click(screen.getByRole("button", { name: "aiRouter.save" }));
await waitFor(() =>
expect(api.aiRouters.create).toHaveBeenCalledWith(
expect.objectContaining({ model: "agent", behavesAs: "claude-opus-5-5" })
)
);
});
it("sends only the tiers that differ from the headline model", async () => {
render(<AiRouterConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
await user.type(screen.getByLabelText("aiRouter.fieldLabel"), "9Router");
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "http://localhost:20128/v1");
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "Highest");
await user.type(screen.getByLabelText("aiRouter.tier.opus"), "PremiumCombo");
await user.click(screen.getByRole("button", { name: "aiRouter.save" }));
await waitFor(() =>
expect(api.aiRouters.create).toHaveBeenCalledWith(
expect.objectContaining({ models: { opus: "PremiumCombo" } })
)
);
});
it("never puts a stored key in the edit form, and omits it when left blank", async () => {
// The API cannot return a key, so an edit that sends `apiKey: ""` would
// silently erase the one on disk.
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
render(<AiRouterConfig />);
const user = userEvent.setup();
await user.click(await screen.findByLabelText("aiRouter.edit 9Router"));
expect(screen.getByLabelText("aiRouter.fieldApiKey")).toHaveValue("");
await user.clear(screen.getByLabelText("aiRouter.fieldLabel"));
await user.type(screen.getByLabelText("aiRouter.fieldLabel"), "9Router nhà");
await user.click(screen.getByRole("button", { name: "aiRouter.save" }));
await waitFor(() => expect(api.aiRouters.update).toHaveBeenCalled());
const call = vi.mocked(api.aiRouters.update).mock.calls[0];
expect(call?.[1]).not.toHaveProperty("apiKey");
});
it("surfaces a server error instead of closing the form", async () => {
vi.mocked(api.aiRouters.create).mockRejectedValue(
new Error("baseUrl must be http:// or https://")
);
render(<AiRouterConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
await user.type(screen.getByLabelText("aiRouter.fieldLabel"), "Bad");
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "file:///etc/passwd");
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "x");
await user.click(screen.getByRole("button", { name: "aiRouter.save" }));
// Inside the dialog, not behind its scrim.
const dialog = screen.getByRole("dialog");
expect(await within(dialog).findByText(/must be http/)).toBeInTheDocument();
});
it("closes the form on Escape", async () => {
render(<AiRouterConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
await user.keyboard("{Escape}");
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
});
});
describe("AiRouterConfig — testing an endpoint before saving it", () => {
it("sends the typed endpoint, key and model, and reports success", async () => {
render(<AiRouterConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "http://localhost:20128/v1");
await user.type(screen.getByLabelText("aiRouter.fieldApiKey"), "sk-typed");
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "Highest");
await user.click(screen.getByTestId("router-test"));
await waitFor(() =>
expect(api.aiRouters.test).toHaveBeenCalledWith({
baseUrl: "http://localhost:20128/v1",
model: "Highest",
apiKey: "sk-typed",
})
);
expect(await screen.findByTestId("router-test-result")).toHaveTextContent("aiRouter.testOk");
});
it("names the profile being edited so the server can reuse its stored key", async () => {
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
render(<AiRouterConfig />);
const user = userEvent.setup();
await user.click(await screen.findByLabelText(/aiRouter\.edit 9Router/));
await user.click(screen.getByTestId("router-test"));
await waitFor(() =>
expect(api.aiRouters.test).toHaveBeenCalledWith({
baseUrl: "http://localhost:20128/v1",
model: "Highest",
name: "9router",
})
);
});
it("shows the router's own refusal rather than claiming it works", async () => {
vi.mocked(api.aiRouters.test).mockResolvedValue({
ok: false,
status: 401,
message: "invalid api key",
});
render(<AiRouterConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "http://localhost:20128/v1");
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "Highest");
await user.click(screen.getByTestId("router-test"));
expect(await screen.findByTestId("router-test-result")).toHaveTextContent(
"aiRouter.testFailed"
);
});
it("drops a stale verdict as soon as a field changes", async () => {
render(<AiRouterConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "http://localhost:20128/v1");
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "Highest");
await user.click(screen.getByTestId("router-test"));
expect(await screen.findByTestId("router-test-result")).toBeInTheDocument();
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "2");
await waitFor(() => expect(screen.queryByTestId("router-test-result")).toBeNull());
});
it("opens a profile's Edit and Remove from a right-click; the built-in row has no menu", async () => {
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
let pending: PendingConfirm | null = null;
const off = subscribeConfirm((p) => (pending = p));
const user = userEvent.setup();
render(<AiRouterConfig />);
const row = (await screen.findByText("9Router")).closest("tr")!;
await user.pointer({ keys: "[MouseRight]", target: screen.getByText(native.label) });
expect(screen.queryByRole("menu")).toBeNull();
await user.pointer({ keys: "[MouseRight]", target: row });
await user.click(screen.getByRole("menuitem", { name: "aiRouter.remove" }));
await waitFor(() => expect(pending?.title).toBe("aiRouter.removeConfirm:9Router"));
settleConfirm(false);
await waitFor(() => expect(pending).toBeNull());
expect(api.aiRouters.remove).not.toHaveBeenCalled();
await user.pointer({ keys: "[MouseRight]", target: row });
await user.click(screen.getByRole("menuitem", { name: "aiRouter.edit" }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
off();
});
});
@@ -50,7 +50,7 @@ const GEMINI: AiTool = {
prompt: { mode: "flag" },
resume: false,
bypass: true,
routerCapable: false,
proxyCapable: false,
install: { kind: "npm", package: "@google/gemini-cli" },
update: { kind: "npm" },
modelFlag: true,
@@ -30,7 +30,7 @@ vi.mock("../CodexConfig", () => ({ CodexConfig: () => <div data-testid="codex-co
vi.mock("../AiToolConfig", () => ({
AiToolConfig: ({ toolId }: { toolId: string }) => <div data-testid={`tool-${toolId}`} />,
}));
vi.mock("../AiRouterConfig", () => ({ AiRouterConfig: () => null }));
vi.mock("../AiProxyConfig", () => ({ AiProxyConfig: () => null }));
vi.mock("../GitCredentialsConfig", () => ({ GitCredentialsConfig: () => null }));
vi.mock("../../components/settings/AiToolsSettings", () => ({
AiToolsSettings: () => <div data-testid="ai-tools" />,
@@ -87,7 +87,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
features: { list: r({ features: [] }), show: r({ feature: null }) },
proof: { list: r({ features: [] }), imageUrl: () => "" },
},
aiRouters: { list: r({ items: [] }) },
aiProxies: { list: r({ items: [] }) },
sessions: { list: r({ sessions: [], total: 0, limit: 50, offset: 0 }) },
},
};
@@ -184,11 +184,11 @@ vi.mock("../../lib/api", async (importOriginal) => {
}),
},
},
// The lane card's router picker. Only the built-in profile, which is the
// The lane card's proxy picker. Only the built-in profile, which is the
// shape that renders no picker — nothing here asserts on it.
aiRouters: {
aiProxies: {
list: vi.fn().mockImplementation(async () => {
recordCall("GET", "/api/ai-routers");
recordCall("GET", "/api/ai-proxies");
return { items: [] };
}),
},
@@ -7233,7 +7233,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<p
class="text-[10px] text-status-warning/90 mt-1"
>
UI build v2.5.0 (rebuild client to match)
UI build v2.5.9 (rebuild client to match)
</p>
</div>
<div
@@ -258,7 +258,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
return {
...actual,
api: {
aiRouters: { list: r({ items: [] }) },
aiProxies: { list: r({ items: [] }) },
stats: { get: r(stats), facets: r({ cwds: [] }) },
sessions: {
list: r({ sessions: [], total: 0, limit: 50, offset: 0 }),
+32 -24
View File
@@ -313,7 +313,7 @@ When the session's tool is Codex, three fields control Codex-specific behavior:
- **`approval`** — one of `on-request` or `never`, or omitted/empty for no approval prompt. Passed to `codex -a <value>`.
- **`resumeSessionId`** or **`resumeLast: true`** — resume an earlier run instead of starting new. For Codex workspaces specifically, `resumeLast: true` uses `codex resume --last`; `resumeSessionId` uses `codex resume <sessionId>`. For Claude workspaces, `resumeSessionId` re-attaches a Claude session by id.
- **`forkSession: true`** (with `resumeSessionId`, Claude workspaces only) — resume into a NEW session id (`claude --resume <id> --fork-session`), leaving the original untouched; the run records no session until the fork's first hook. Without `resumeSessionId` it is `400 EBADSESSION`, on any other tool `400 EUNSUPPORTED`.
- **`router: "<name>"`** — the AI Router profile THIS session runs on (omitted = native, or for a resume the router its session last ran on); see § AI Router profiles. `400 EBADROUTER` for an unknown name, `400 EUNSUPPORTED` on a tool other than Claude/Codex.
- **`proxy: "<name>"`** — the AI Proxy profile THIS session runs on (omitted = native, or for a resume the proxy its session last ran on; the pre-rename `router` field is still accepted); see § AI Proxy profiles. `400 EBADPROXY` for an unknown name, `400 EUNSUPPORTED` on a tool other than Claude/Codex.
- **`worktree: true`** (Claude workspaces, no `resumeSessionId`) — a NEW session in a git worktree of its own, Claude Code's way: the pane runs `claude --worktree=ah-<id>-<base36 time>` (the `=` form, since `-w [name]` would take the prompt as the name; the name is made by the server), so Claude itself creates `<repo>/.claude/worktrees/<name>` on branch `worktree-<name>`, copies `.worktreeinclude`, runs the user's `WorktreeCreate` hook and cleans up on exit. `/.claude/worktrees/` is added to the repo root's `info/exclude`; the run is recorded with `worktree: 1` (and the run handle carries `worktree: true`). `400 EUNSUPPORTED` on any other tool. A `resumeSessionId` (or fork) of a session that ran in such a worktree starts the pane IN that worktree; one whose worktree is gone is `400 ENOWORKTREE`.
- **`slot`** — which Workspace session to start, `0`–`9` (ten per workspace) or `"auto"` (default `0`, else `400 EBADSLOT`). `"auto"` lets the server pick the first slot with no live session, under the workspace lock (a client's own pick may come from a stale list); when all ten are busy it answers `409 ERUNLIVE` "every tab of this workspace is busy", while a specific busy slot answers `409 ERUNLIVE` "that tab already has a live session". Slot 0 is the workspace's own `agenthub-lane-<id>` and is what `run_id` records; slots 1–9 are extra sessions on the same workspace, `agenthub-lane-<id>-<slot>`, and never touch `run_id`/`status`. The response adds `run_id`: the session this start opened. `stop`, `reset` and `remove` kill every slot's session, not only `run_id`.
@@ -606,7 +606,7 @@ answer `{available:false}` with HTTP **200** for a cwd git cannot read.
never returned. Capped at 1 MB — a larger file is `truncated: true`, never
`ENOFILE` (a working-tree file is read only up to the cap, never whole).
`400 EBADPATH`, `EBADREV` or `ENOFILE`. `.claude/settings.local.json`
(where a workspace's AI Router key lives) is `403 ESECRET` at EVERY rev and at
(where an older build kept a workspace's AI Proxy key) is `403 ESECRET` at EVERY rev and at
ANY depth (`sub/.claude/settings.local.json`, a subdirectory workspace's key, as
seen from a workspace at the repo root), and so is any path that RESOLVES to it
(a symlinked `.claude` → `conf/` makes it `conf/settings.local.json`). In
@@ -2039,7 +2039,7 @@ curl "http://localhost:4820/api/pricing/cost?tz_offset=-420&from=2026-09-01&to=2
GET /api/pricing/quota[?force=1]
```
How much of each native tool's plan quota is spent: `{ tools: [{ id: "claude"|"codex", active, label?, account?, expiresAt?, windows: [{ label, usedPercent, resetsAt }], error?, asOf? }], checkedAt }` (`server/lib/quota.js`). **Claude** asks Anthropic's `GET /api/oauth/usage` with the access token the CLI holds right now — never refreshed, so an expired token answers `error: "ELIVEEXPIRED"` until Claude Code refreshes it — cached 5 min (`force=1`, the status bar's click, re-asks at most every 30 s); no OAuth login, no entry. That live account is `active: true`; every OTHER saved auth profile follows as `active: false` with its `label`, asked with its OWN stored token only while that token is valid — never refreshed here, since a refresh rotates the saved credentials (only the Profiles modal's **Check quota** does that) — and, once expired, showing the last figure this server read (`asOf`) or `error: "EPROFILEEXPIRED"`. **Codex** reads the newest `rate_limits` from its rollout journals, no network (`asOf` = when the journal recorded it; a window past its reset reads 0). `account` is the signed-in email and `expiresAt` when the stored access token runs out — Claude's `claudeAiOauth.expiresAt`, Codex's `auth.json` access-token `exp` (JWT claims read unverified; no token leaves the server). Labels: `5h`, `wk`, `mo`, else `<n>d`/`<n>h` (the hover spells `wk`/`mo` out as week/month). The status bar shows one "N% used" chip per tool (its live account) at its right end and, on hover, every account grouped per tool with a row per window — bar, percent, reset time.
How much of each native tool's plan quota is spent: `{ tools: [{ id: "claude"|"codex", active, label?, account?, expiresAt?, windows: [{ label, usedPercent, resetsAt }], error?, asOf? }], checkedAt }` (`server/lib/quota.js`). **Claude** asks Anthropic's `GET /api/oauth/usage` with the access token the CLI holds right now — never refreshed, so an expired token answers `error: "ELIVEEXPIRED"` until Claude Code refreshes it — cached 5 min (`force=1`, the status bar's click, re-asks at most every 30 s); no OAuth login, no entry. That live account is `active: true`; every OTHER saved auth profile follows as `active: false` with its `label`, asked with its OWN stored token only while that token is valid — never refreshed here, since a refresh rotates the saved credentials (only the Profiles modal's **Check quota** does that) — and, once expired, showing the last figure this server read (`asOf`) or `error: "EPROFILEEXPIRED"`. Each Claude token's owner is also asked (`GET /api/oauth/profile`): the live row carries the label and email of the account its token belongs to (`.claude.json` can name another), and a saved profile whose token belongs to a different account answers `error: "EWRONGACCOUNT"` with no windows, never that other account's usage. **Codex** reads the newest `rate_limits` from its rollout journals, no network (`asOf` = when the journal recorded it; a window past its reset reads 0). `account` is the signed-in email and `expiresAt` when the stored access token runs out — Claude's `claudeAiOauth.expiresAt`, Codex's `auth.json` access-token `exp` (JWT claims read unverified; no token leaves the server). Labels: `5h`, `wk`, `mo`, else `<n>d`/`<n>h` (the hover spells `wk`/`mo` out as week/month). The status bar shows one "N% used" chip per tool (its live account) at its right end and, on hover, every account grouped per tool with a row per window — bar, percent, reset time.
#### Analytics
```http
@@ -2363,13 +2363,15 @@ Batch `status` is `running`, `done`, `partial` (finished, at least one step fail
**Auth profiles (`/profiles*`).** A *profile* is one Anthropic account — one quota — under a name. Switching swaps exactly two identity artifacts and nothing else: `claudeAiOauth` inside `<CLAUDE_HOME>/.credentials.json`, and `oauthAccount` + `userID` inside the `.claude.json` config file(s) (`<CLAUDE_HOME>.json` and `<CLAUDE_HOME>/.claude.json`, each rewritten only if it already exists, key-merged so `projects`/`numStartups`/everything else survives). Skills, subagents, commands, memory, plugins, marketplaces, MCP servers and settings are never read or written here — that is what makes a switch keep the whole workspace and change only the account. `mcpOAuth` also stays put: those are the machine's MCP server tokens, not account identity. On **macOS** the live `claudeAiOauth` is NOT that file — the `claude` CLI keeps it in the login Keychain (generic password, service `Claude Code-credentials`, account `$USER`), read/written via `/usr/bin/security`; the file is only a fallback (and is kept in step when one already exists). `AGENTHUB_CC_KEYCHAIN=0` pins the file.
Profiles live one JSON file per profile under `<CLAUDE_HOME>/agenthub-profiles/` (dir `0700`, files `0600`) and hold live OAuth tokens, so **no route ever returns token material** — `GET /profiles` answers `{ items, active }` with identity metadata only (`name`, `label`, `savedAt`, `active`, `accountUuid`, `email`, `displayName`, `organization`, `seatTier`, `subscriptionType`, `expiresAt`).
Profiles live one JSON file per profile under `<CLAUDE_HOME>/agenthub-profiles/` (dir `0700`, files `0600`) and hold live OAuth tokens, so **no route ever returns token material** — `GET /profiles` answers `{ items, active, mismatch }` with identity metadata only (`name`, `label`, `savedAt`, `active`, `accountUuid`, `email`, `displayName`, `organization`, `seatTier`, `subscriptionType`, `expiresAt`).
**Who is logged in is decided by the TOKEN, not by `.claude.json`.** The two can disagree — a running Claude Code session can leave the credentials store holding one account's token while `oauthAccount` still names another, and every request is then billed to the token's account. So `GET /profiles`, `POST /profiles`, `POST /profiles/activate` and `POST /profiles/usage` first ask Anthropic who owns the live token (`GET https://api.anthropic.com/api/oauth/profile`, `cc-usage.liveOwner`, cached per token — an access token never changes hands). `active` (and each item's `active`) is that owner; `mismatch` is `{ configEmail, configUuid }` when the config names someone else, else `null`. Offline, or with an expired live token, there is no owner and the config decides, as before.
**`POST /profiles/usage` (how much quota this account has spent).** Answers `{ name, refreshed, fiveHour, sevenDay, checkedAt }`, where each window is `{ utilization, resetsAt }` — a percentage and a rollover time, nothing else. There is no local source for this: nothing under `CLAUDE_HOME` records a utilization, and the credentials file carries only `subscriptionType` and `rateLimitTier`, so the server asks the same endpoint the CLI's own `/usage` reads (`GET https://api.anthropic.com/api/oauth/usage`) with that profile's OAuth token.
It is a **POST**, and behind the same-origin guard, because answering it is a write. A saved profile's access token lasts hours, so every profile that is not the active one is normally expired; reading its usage means refreshing it first, and the refresh **rotates the stored credentials** — Anthropic retires the refresh token it was handed. The new pair is therefore written straight back into the profile file (atomic temp + rename at `0600`, every other field preserved), because a refresh whose result is dropped leaves an account that can never be activated again without a fresh `claude auth login`. A token that is still valid is used as-is and nothing is rewritten; a token that looks valid but is refused (401) is retried once through the refresh path.
**The account that is logged in right now is the exception, and it is read from the CLI.** A refresh token is single use, so every refresh Claude Code performs on its own account retires the copy sitting in that account's profile file — the saved credentials of the LIVE account are the one set guaranteed to be rejected, with `invalid_grant`, however recently the profile was saved. `cc-profiles.liveCredentialsFor` therefore matches the profile's `accountUuid` against `.credentials.json` and, on a hit, uses what the CLI is holding. That token is never refreshed and that file is never written: rotating it would log the user out of their own terminal. An expired live token answers `ELIVEEXPIRED` (409) — using Claude Code once makes the CLI refresh it.
**The account that is logged in right now is the exception, and it is read from the CLI.** A refresh token is single use, so every refresh Claude Code performs on its own account retires the copy sitting in that account's profile file — the saved credentials of the LIVE account are the one set guaranteed to be rejected, with `invalid_grant`, however recently the profile was saved. `cc-profiles.liveCredentialsFor` therefore matches the profile's `accountUuid` against the live token's owner (the config's account only when the owner is unknown) and, on a hit, uses what the CLI is holding. That token is never refreshed and that file is never written: rotating it would log the user out of their own terminal. An expired live token answers `ELIVEEXPIRED` (409) — using Claude Code once makes the CLI refresh it.
**Nothing calls this on a timer**, but opening the Profiles modal does ask for every saved account at once — comparing accounts is the only reason the panel exists, and a button per row made the user do that by hand. What keeps that affordable is the pair of rules above: the live account costs a plain read, and any other profile is refreshed only when its token has actually expired, which leaves it valid for hours of further opens. Each row resolves on its own, so one superseded account still lets its neighbours report. Errors: `ENOTFOUND` (404), `ENOREFRESH`/`ENOAUTH` (409 — the profile has no usable token, re-save it from a login), `ESTALE` (409 — the refresh token was already spent by a newer login or refresh; activate the account once and save the profile again), `ELIVEEXPIRED` (409), `EUNAUTHORIZED` (401), anything upstream 502. See `server/lib/cc-usage.js`.
@@ -2377,7 +2379,7 @@ A profile has two names, and the split is what lets a profile be called `Tài kh
One account is one profile: `POST /profiles` refuses with `EDUPLICATE` when another profile already holds the logged-in account, naming the one that does. Two profiles of a single account would both report `active: true`, leaving nothing to switch between — and, under a blanket active-guard on delete, neither would ever be removable. Re-posting an account into its OWN profile is the token-refresh path, stays allowed, and preserves that profile's label.
The dashboard **cannot log an account in** — only `claude auth login` mints tokens. Adding a profile is therefore always "log in from a terminal, then `POST /profiles` to snapshot what the CLI wrote". `POST /profiles/activate` re-snapshots the outgoing account into its own profile first (reported as `savedOutgoing`), so tokens Claude Code refreshed since the last save are not lost, and returns `{ ok, name, savedOutgoing, written }`. Errors: `EBADNAME` / `EBADLABEL` (400), `ENOTFOUND` (404 — no such profile), `EBADJSON` (409 — a config file on disk is unparseable; nothing is written), `ENOAUTH` (409 — nobody is logged in to snapshot), `EDUPLICATE` (409 — another profile already holds this account), `ELABELTAKEN` (409 — another profile already uses that label), `EUNSAVED` (409 — the live account is not saved as a profile, and switching away would discard the only copy of its credentials), `EACTIVE` (409 — refusing to delete the **last** saved copy of the live account; a redundant copy is deletable, which is what makes an accidental duplicate cleanable). Mutations broadcast `cc_config_changed` with `type: "profile"`. A switch only takes effect for **new** Claude Code sessions, and a session still running will rewrite the config on exit — close them first.
The dashboard **cannot log an account in** — only `claude auth login` mints tokens. Adding a profile is therefore always "log in from a terminal, then `POST /profiles` to snapshot what the CLI wrote". `POST /profiles/activate` re-snapshots the outgoing account into its own profile first (reported as `savedOutgoing`), so tokens Claude Code refreshed since the last save are not lost. Under a `mismatch` that snapshot goes to the profile of the token's OWNER and replaces only its `credentials` (its label and identity stay) — never into the profile the config names, which would hand that profile another account's token — and it is taken even when the owner's profile is the switch target, so the switch writes the newest token that account has. It returns `{ ok, name, savedOutgoing, written }`. Errors: `EBADNAME` / `EBADLABEL` (400), `ENOTFOUND` (404 — no such profile), `EBADJSON` (409 — a config file on disk is unparseable; nothing is written), `ENOAUTH` (409 — nobody is logged in to snapshot), `EDUPLICATE` (409 — another profile already holds this account), `EMISMATCH` (409 — `POST /profiles` while the live token belongs to another account than the config names; switch to that account's profile, or log in again), `ELABELTAKEN` (409 — another profile already uses that label), `EUNSAVED` (409 — the live account is not saved as a profile, and switching away would discard the only copy of its credentials), `EACTIVE` (409 — refusing to delete the **last** saved copy of the live account; a redundant copy is deletable, which is what makes an accidental duplicate cleanable). Mutations broadcast `cc_config_changed` with `type: "profile"`. A running Claude Code session follows a switch by itself: it re-reads the credentials store when the Keychain item's version or `.credentials.json`'s mtime changes (checked about every 30 s in Claude Code 2.1.x), so nobody has to close sessions first. If one still writes its old account back, the token-owner rule above shows the truth and a switch repairs it.
Backup paths look like `<root>/cc-config-backups/<type>/<base>.<ISO>.bak[.dir]` — outside the directories Claude Code scans, so a deleted skill cannot resurface as a backup-named one. The Backups modal in the UI auto-builds `mv` restore commands.
@@ -2466,40 +2468,44 @@ GET /api/ai-tools/versions { [id]: { npmManaged, installed, latest, u
GET /api/ai-tools/:id/icon The tool's favicon, fetched once by the server; 204 → letter avatar
```
Each tool: `{ id, label, command, homepageUrl, installed, path, enabled, source: "builtin"|"custom", launcher, prompt: { mode }, resume: bool, bypass: bool, routerCapable: bool }` — flags only, never the arg arrays. Each tool also carries `modelFlag` (the CLI takes a model: Claude, Codex, or a catalog `model: { flag, vendor? }` field) and `defaultModel` (what a new run starts with, `null` = the CLI's own; a `PUT` with `""`/`null` clears it, an id that is not a plain model name is `400 EBADMODEL`). The config files are only those a built-in or release catalog file lists in `config` (`~/`-relative, no `..`, no credential-looking name); a read or save answers `404 ENOFILE` (no such index) / `ENOENT` (missing — never created), `403 ESECRET` (the name, or a symlink target's, looks like a credential), `413 ETOOBIG` (> 1 MB), `415 EBINARY`, `400 EBADSYNTAX` (JSON/JSONC that does not parse), `409 ESTALE` (the file is no longer at `baseSha`); a save backs the old text up under `<data dir>/ai-tool-config-backups/<id>/` and is same-origin only. `GET /:id/models` reads each tool's own source: Codex's `$CODEX_HOME/models_cache.json` (listed entries by priority — nothing else from that file), Claude's aliases named after the newest priced version plus the priced pinned versions, a catalog tool's vendor-priced ids plus the models its own sessions used; `flag: false` and `[]` for a tool with no model flag, `404 ENOTOOL` for an unknown id. `default` is the effective default: the stored one if installed and enabled, else `claude` if installed and enabled, else the first installed enabled tool, else `null`. `PUT` answers `404 ENOTOOL` for an unknown id, `400 EBADDEFAULT` for a default that is not installed or disabled, `400 EBADFIELD` for a non-boolean `enabled`; disabling the default clears it. Writes are same-origin only. State lives in `<data dir>/ai-tools.json`. Each tool also carries `install` (`{kind:"npm", package}` or `{kind:"script", url}`, else null) and `update` (`{kind:"npm"}`, `{kind:"script"}`, `{kind:"self", args}`, else null), and the body carries `catalog: { source: "builtin"|"remote", tag, fetchedAt }` — `remote` while a copy of the newest release's catalog, newer than this build, is in use (synced daily, off with `AGENTHUB_AI_TOOLS_SYNC=0`).
Each tool: `{ id, label, command, homepageUrl, installed, path, enabled, source: "builtin"|"custom", launcher, prompt: { mode }, resume: bool, bypass: bool, proxyCapable: bool }` — flags only, never the arg arrays. Each tool also carries `modelFlag` (the CLI takes a model: Claude, Codex, or a catalog `model: { flag, vendor? }` field) and `defaultModel` (what a new run starts with, `null` = the CLI's own; a `PUT` with `""`/`null` clears it, an id that is not a plain model name is `400 EBADMODEL`). The config files are only those a built-in or release catalog file lists in `config` (`~/`-relative, no `..`, no credential-looking name); a read or save answers `404 ENOFILE` (no such index) / `ENOENT` (missing — never created), `403 ESECRET` (the name, or a symlink target's, looks like a credential), `413 ETOOBIG` (> 1 MB), `415 EBINARY`, `400 EBADSYNTAX` (JSON/JSONC that does not parse), `409 ESTALE` (the file is no longer at `baseSha`); a save backs the old text up under `<data dir>/ai-tool-config-backups/<id>/` and is same-origin only. `GET /:id/models` reads each tool's own source: Codex's `$CODEX_HOME/models_cache.json` (listed entries by priority — nothing else from that file), Claude's aliases named after the newest priced version plus the priced pinned versions, a catalog tool's vendor-priced ids plus the models its own sessions used; `flag: false` and `[]` for a tool with no model flag, `404 ENOTOOL` for an unknown id. `default` is the effective default: the stored one if installed and enabled, else `claude` if installed and enabled, else the first installed enabled tool, else `null`. `PUT` answers `404 ENOTOOL` for an unknown id, `400 EBADDEFAULT` for a default that is not installed or disabled, `400 EBADFIELD` for a non-boolean `enabled`; disabling the default clears it. Writes are same-origin only. State lives in `<data dir>/ai-tools.json`. Each tool also carries `install` (`{kind:"npm", package}` or `{kind:"script", url}`, else null) and `update` (`{kind:"npm"}`, `{kind:"script"}`, `{kind:"self", args}`, else null), and the body carries `catalog: { source: "builtin"|"remote", tag, fetchedAt }` — `remote` while a copy of the newest release's catalog, newer than this build, is in use (synced daily, off with `AGENTHUB_AI_TOOLS_SYNC=0`).
**Install / update jobs.** Commands come only from built-in or release catalog files, never from the request or `DASHBOARD_AI_TOOLS_DIR`. npm is the login shell's (`npm install -g <package>@latest`); a script is downloaded by the server (https, same site as the tool's homepage, ≤ 1 MB) and run as `bash <file>`; `self` runs `<detected path> ...args`. One job server-wide; the job is `{ id, tool, action, status: "running"|"ok"|"failed", startedAt, endedAt, exitCode, log }` (last 256 KB, 10-minute timeout) and lives in memory. A successful install enables the tool. Errors: `404 ENOTOOL`; `400 ENOINSTALL`/`ENOUPDATE`/`ENOTHING`; `409 EINSTALLED`/`ENOTINSTALLED`/`ENOTNPM` (npm update of a tool whose binary is not under `npm prefix -g`)/`ENONPM`/`EBUSY`. Icons are the root `/favicon.ico`, raster only by magic bytes (never SVG), ≤ 256 KB, cached in `<data dir>/runtime/ai-tool-icons/`, served with `nosniff` and `Content-Security-Policy: sandbox`; an `<img>` passes the dashboard token as `?token=`. The client shows its own shipped mark instead where it has one (`client/public/ai-tools/<id>.svg` — claude, codex, openclaw, and hermes as a `-black`/`-white` pair swapped by the theme; `BUNDLED` in `client/src/components/AiToolIcon.tsx`), so this route is asked only for the other tools.
### AI Router profiles
### AI Proxy profiles
```
GET /api/ai-routers Every saved profile, the built-in `native` one first
POST /api/ai-routers Create — Body: { label, baseUrl, apiKey?, model, models?, behavesAs? }
POST /api/ai-routers/test Probe an endpoint — Body: { baseUrl, model, apiKey?, name? }
PATCH /api/ai-routers/:name Update the same fields, all optional
DELETE /api/ai-routers/:name Delete one saved profile
GET /api/ai-proxies Every saved profile, the built-in `native` one first
POST /api/ai-proxies Create — Body: { label, baseUrl, apiKey?, model, models?, behavesAs? }
POST /api/ai-proxies/test Probe an endpoint — Body: { baseUrl, model, apiKey?, name? }
PATCH /api/ai-proxies/:name Update the same fields, all optional
DELETE /api/ai-proxies/:name Delete one saved profile
```
A **router profile** is an endpoint that stands in front of Claude Code or Codex (9Router, OpenRouter, a self-hosted gateway) plus the model names to use against it. It powers the **AI Router** page of Settings (`/settings/ai-router`), which stores profiles and attaches nothing — the tab's **Test** button (`POST /api/ai-routers/test`, below) is its only outbound call. A profile is chosen per NEW SESSION, not per workspace: the **AI Router** select beside the model picker in the workspace home's prompt box and in a pane's run form (shown only for Claude/Codex and only when a profile beyond Native is saved; the last pick is remembered in `agenthub.session.router`).
`/api/ai-routers` (the name before the AI Router → AI Proxy rename) is the same router mounted a second time, kept for a client older than the rename.
`models` carries the five per-tier names (`opus`, `sonnet`, `haiku`, `smallFast`, `subagent`). Any tier omitted is filled from `model`, so a router that answers to one symbolic name needs one field, not six.
A **proxy profile** is an endpoint that stands in front of Claude Code or Codex (9Router, OpenRouter, a self-hosted gateway) plus the model names to use against it. It powers the **AI Proxy** page of Settings (`/settings/ai-proxy`; the old `/settings/ai-router` redirects there), which stores profiles and attaches nothing — the page's **Test** button (`POST /api/ai-proxies/test`, below) is its only outbound call. A profile is chosen per NEW SESSION, not per workspace: the **AI Proxy** select beside the model picker in the workspace home's prompt box (shown only for Claude/Codex and only when a profile beyond Native is saved; the last pick is remembered in `agenthub.session.proxy`, which falls back to the pre-rename `agenthub.session.router`).
`behavesAs` (optional, `""` clears it, omitted on `PATCH` keeps it) is a Claude model id (`claude-…`, else `400 EBADMODEL`) that Claude Code should treat the router's own model names as. Without it, a name Claude Code's model catalog does not know — a 9Router combo such as `agent` — makes every session print "isn't described by this version's model catalog" and compact at an assumed 200k window (issue #56). The user picks it, never AgentHub: it also sets the context window Claude Code compacts at, and only the user knows which model the router really sends a name to.
`baseUrl` is the proxy's ROOT, without `/v1` — e.g. `http://localhost:20128`. A trailing `/v1` is dropped on save, and on read of a profile an older build stored with one. Claude Code gets the root as `ANTHROPIC_BASE_URL` (it appends `/v1/messages`); the Codex layer gets root + `/v1` (Codex appends `/responses`).
**No response here ever contains key material.** A saved profile reports `hasKey: true|false` and there is deliberately no `GET /:name` — a route returning one profile in full would be the single way an API key could leave the server. Profiles live one JSON file each under `<CLAUDE_HOME>/agenthub-routers/` (dir `0700`, files `0600`). An omitted `apiKey` on `PATCH` keeps the stored one; the edit form cannot display a key, so it has none to send back.
`models` carries the five per-tier names (`opus`, `sonnet`, `haiku`, `smallFast`, `subagent`). Any tier omitted is filled from `model`, so a proxy that answers to one symbolic name needs one field, not six.
**`POST /api/ai-routers/test`** answers the question saving cannot: does this endpoint, with this key, actually serve this model? It sends one Anthropic-style message capped at a single output token to `<baseUrl>/v1/messages` (a 15 s timeout), which is the wire shape a claude workspace's eight env keys point at — a Codex-only router is not covered. A router that answers is a `200` here whatever it replies: `{ ok, status, message }` carries the router's own words (truncated to 300 characters), and `status` is `null` when nothing answered at all. Only bad input is a 4xx.
`behavesAs` (optional, `""` clears it, omitted on `PATCH` keeps it) is a Claude model id (`claude-…`, else `400 EBADMODEL`) that Claude Code should treat the proxy's own model names as. Without it, a name Claude Code's model catalog does not know — a 9Router combo such as `agent` — makes every session print "isn't described by this version's model catalog" and compact at an assumed 200k window (issue #56). The user picks it, never AgentHub: it also sets the context window Claude Code compacts at, and only the user knows which model the proxy really sends a name to.
The stored key is only ever sent to the endpoint it was stored against. A blank `apiKey` falls back to the key of the profile named by `name`, but **only** while the submitted `baseUrl` still equals the saved one — so testing a retyped endpoint needs a retyped key (`400 EBADKEY`). Without that rule this route would be a way to make the server POST a saved API key to any URL a browser names.
**No response here ever contains key material.** A saved profile reports `hasKey: true|false` and there is deliberately no `GET /:name` — a route returning one profile in full would be the single way an API key could leave the server. Profiles live one JSON file each under `<CLAUDE_HOME>/agenthub-proxies/` (dir `0700`, files `0600`); the first read after the rename moves an existing `agenthub-routers/` there and leaves a symlink at the old name, so an older build on the same CLAUDE_HOME still finds them. An omitted `apiKey` on `PATCH` keeps the stored one; the edit form cannot display a key, so it has none to send back.
`native` is a built-in profile, not the absence of one: empty `baseUrl`/`apiKey` and the vendor's own model names, so switching back is the same write path as switching to a router. It answers `409 EBUILTIN` to any update, delete, or attempt to shadow its name. Other errors: `400 EBADLABEL` / `EBADURL` (an endpoint must be `http:` or `https:`) / `EBADMODEL`, `404 ENOTFOUND`.
**`POST /api/ai-proxies/test`** answers the question saving cannot: does this endpoint, with this key, actually serve this model — for each CLI? It sends, in parallel and with a 15 s timeout each, one Anthropic-style message capped at a single output token to `<baseUrl>/v1/messages` (what Claude Code calls) and one OpenAI Responses call (`max_output_tokens: 16`) to `<baseUrl>/v1/responses` (what Codex calls). A proxy that answers is a `200` here whatever it replies: `{ claude: { ok, status, message }, codex: { ok, status, message } }`, each carrying the proxy's own words (truncated to 300 characters), `status` `null` when nothing answered at all. A proxy that serves only one wire shape shows one green line and one red. Only bad input is a 4xx.
**Starting a session on a router.** `POST /api/lanes/:id/start` and `POST /api/run` take `router: "<name>"` (omitted, `""` or `"native"` = the vendor's own API). Only the name comes from the request; the key and endpoint come from the store. An unknown name is `400 EBADROUTER`, a router on a tool other than Claude/Codex `400 EUNSUPPORTED`. A resume (`resumeSessionId`, `resume`) that names no router goes back to the one its session last ran on (`dashboard_runs.router`), or native once that profile is deleted. Each spawn (re)writes the profile's files:
The stored key is only ever sent to the endpoint it was stored against. A blank `apiKey` falls back to the key of the profile named by `name`, but **only** while the submitted `baseUrl` still equals the saved one (both compared without `/v1`) — so testing a retyped endpoint needs a retyped key (`400 EBADKEY`). Without that rule this route would be a way to make the server POST a saved API key to any URL a browser names.
- **Claude** gets `--settings=<data dir>/router-settings/<name>.json` (dir `0700`, file `0600`): an `env` block with the eight keys `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, `ANTHROPIC_DEFAULT_HAIKU_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `CLAUDE_CODE_SUBAGENT_MODEL` (an empty one left out), plus — only when the profile sets `behavesAs` — `modelPicker: { options: [{ model, behavesAs }] }` with one row per distinct model name that is not a bare `claude-*` id. Claude Code reads `modelPicker` from the policy, flag and user layers and takes the FIRST it finds, so while set it replaces a `modelPicker` in the user's own `~/.claude/settings.json` for that session (verified on 2.1.290). The command-line layer outranks the project-local and user settings, so two sessions of one workspace can run on two routers, and only the path is in argv. Native adds nothing, so a router the user set in their own `~/.claude/settings.json` still applies to a native session.
- **Codex** gets `-p <name>` and `$CODEX_HOME/<name>.config.toml`, the layer that flag loads on top of the base config (never touched). The layer sets `wire_api = "responses"` (codex-cli 0.149 refuses `"chat"`); `env_key` is written only when the profile has a key, and the key reaches the pane as `AGENTHUB_ROUTER_KEY` through tmux's own `-e`, never as typed text.
`native` is a built-in profile, not the absence of one: empty `baseUrl`/`apiKey`, and no model of its own (`model: ""`, `models: {}` — the session's model picker picks), so choosing it is the same operation as choosing a proxy. It answers `409 EBUILTIN` to any update, delete, or attempt to shadow its name. Other errors: `400 EBADLABEL` / `EBADURL` (an endpoint must be `http:` or `https:`) / `EBADMODEL`, `404 ENOTFOUND`.
`PATCH` rewrites the profile's files that exist (a live Claude session reads its file again; the next Codex session gets the new layer) and `DELETE` removes both. Workspaces no longer carry a router: `POST /api/lanes/:id/router/apply` is gone, and at every start the server takes the eight keys an older build wrote into a workspace's `.claude/settings.local.json` back out and clears `lanes.router_profile` (a file that is not valid JSON is left alone and retried next start).
**Starting a session on a proxy.** `POST /api/lanes/:id/start` and `POST /api/run` take `proxy: "<name>"` (omitted, `""` or `"native"` = the vendor's own API; the pre-rename `router` field is still accepted). Only the name comes from the request; the key and endpoint come from the store. An unknown name is `400 EBADPROXY`, a proxy on a tool other than Claude/Codex `400 EUNSUPPORTED`. A resume (`resumeSessionId`, `resume`) that names no proxy goes back to the one its session last ran on (`dashboard_runs.proxy`), or native once that profile is deleted. Each spawn (re)writes the profile's files:
- **Claude** gets `--settings=<data dir>/proxy-settings/<name>.json` (dir `0700`, file `0600`): an `env` block with the eight keys `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, `ANTHROPIC_DEFAULT_HAIKU_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `CLAUDE_CODE_SUBAGENT_MODEL` (an empty one left out), plus — only when the profile sets `behavesAs` — `modelPicker: { options: [{ model, behavesAs }] }` with one row per distinct model name that is not a bare `claude-*` id. Claude Code reads `modelPicker` from the policy, flag and user layers and takes the FIRST it finds, so while set it replaces a `modelPicker` in the user's own `~/.claude/settings.json` for that session (verified on 2.1.290). The command-line layer outranks the project-local and user settings, so two sessions of one workspace can run on two proxies, and only the path is in argv. Native adds nothing, so a proxy the user set in their own `~/.claude/settings.json` still applies to a native session.
- **Codex** gets `-p <name>` and `$CODEX_HOME/<name>.config.toml`, the layer that flag loads on top of the base config (never touched). The layer sets `base_url` to the root + `/v1` and `wire_api = "responses"` (codex-cli 0.149 refuses `"chat"`); `env_key` is written only when the profile has a key, and the key reaches the pane as `AGENTHUB_PROXY_KEY` through tmux's own `-e`, never as typed text.
`PATCH` rewrites the profile's files that exist (a live Claude session reads its file again; the next Codex session gets the new layer) and `DELETE` removes them, the pre-rename `<data dir>/router-settings/<name>.json` copy included. Workspaces no longer carry a proxy: `POST /api/lanes/:id/router/apply` is gone, and at every start the server takes the eight keys an older build wrote into a workspace's `.claude/settings.local.json` back out and clears the legacy `lanes.router_profile` column (a file that is not valid JSON is left alone and retried next start).
### Pets (the mascot's custom pets)
@@ -2531,6 +2537,8 @@ A snapshot freezes a session as it is at that moment into one immutable `session
### Query Explorer
The dashboard's Sessions page reads its **Agents** tab (`/sessions?view=agents`; the old `/query` page redirects there) from `entity=agents`; sessions and events have their own tabs over `GET /api/sessions` and `GET /api/events`. The route still answers all three entities for scripts and exports.
```http
GET /api/query?entity=sessions|events|agents&q=&tool=&status=&cwd=&from=&to=&tz_offset=&sources=&sort=&dir=asc|desc&limit=&offset=&format=csv|json
```
+1 -1
View File
@@ -398,7 +398,7 @@ CREATE TABLE notifications (
Per-model pricing rules for cost calculation, keyed by `model_pattern` (a SQL-style glob; `%` matches any characters). Rates are per **million** tokens (USD). The longest matching pattern wins.
**Automatic rates.** `server/lib/pricing-sync.js` runs at server start and every 15 minutes, and fetches LiteLLM's public price list (`model_prices_and_context_window.json` on GitHub) at most once a day. Each pass (1) INSERTS a row, `source = 'sync'`, for every Claude, OpenAI or Gemini model seen in `token_usage` whose family (the id minus a `-YYYYMMDD` / `-YYYY-MM-DD` suffix; OpenAI means `gpt-…`, `o<n>…` or `codex-…`; Gemini means `gemini-…`, taken whole) has no `<family>%` row of its own, AND for every **current** model of the three vendors before anyone uses it — a list key that is a family itself, `mode` `chat`/`responses`, not past its `deprecation_date`, not an audio/search/TTS/realtime/image/embedding variant, and of the current generation (any `claude-<name>-…`; `gpt-` 5 and up; `gemini-` 3 and up — `MIN_GEN`); (2) UPDATES the rates of its own `sync` rows when the list changes; (3) adopts a row from before the `source` column (`NULL`: a seed, an old sync insert or an old edit) as `sync` only while its rates still equal the list exactly. A row edited through `PUT /api/pricing` becomes `source = 'user'` and is never touched again; a deleted row's pattern goes to `model_pricing_dismissed` and is never added back. An entry is taken only from its own vendor (`litellm_provider` `anthropic` / `openai`, and `vertex_ai-language-models` or `gemini` for Gemini); an OpenAI or Gemini row carries no cache-write charge unless the list names one, and no fast rate. Gemini's >200k-token rate is not modelled (the base rate is used). A router's model id (`clinepass/…`) is never priced automatically. `POST /api/settings/reset-pricing` empties `model_pricing_dismissed` for its scope and runs a sync right after reseeding (waiting up to 3 s), so the OpenAI and Gemini rows a reset wiped come straight back; with `{scope: "claude"}` (the Claude Code tab's **Reset Defaults**) it deletes and reseeds only `claude-…` rows, so other vendors' rows and a custom router row survive. A rate outside 0–1000 $/MTok is rejected, and a failed fetch changes nothing. Set `AGENTHUB_PRICING_SYNC=0` (or `false`/`off`) to turn it off.
**Automatic rates.** `server/lib/pricing-sync.js` runs at server start and every 15 minutes, and fetches LiteLLM's public price list (`model_prices_and_context_window.json` on GitHub) at most once a day. Each pass (1) INSERTS a row, `source = 'sync'`, for every Claude, OpenAI or Gemini model seen in `token_usage` whose family (the id minus a `-YYYYMMDD` / `-YYYY-MM-DD` suffix; OpenAI means `gpt-…`, `o<n>…` or `codex-…`; Gemini means `gemini-…`, taken whole) has no `<family>%` row of its own, AND for every **current** model of the three vendors before anyone uses it — a list key that is a family itself, `mode` `chat`/`responses`, not past its `deprecation_date`, not an audio/search/TTS/realtime/image/embedding variant, and of the current generation (any `claude-<name>-…`; `gpt-` 5 and up; `gemini-` 3 and up — `MIN_GEN`); (2) UPDATES the rates of its own `sync` rows when the list changes; (3) adopts a row from before the `source` column (`NULL`: a seed, an old sync insert or an old edit) as `sync` only while its rates still equal the list exactly. A row edited through `PUT /api/pricing` becomes `source = 'user'` and is never touched again; a deleted row's pattern goes to `model_pricing_dismissed` and is never added back. An entry is taken only from its own vendor (`litellm_provider` `anthropic` / `openai`, and `vertex_ai-language-models` or `gemini` for Gemini); an OpenAI or Gemini row carries no cache-write charge unless the list names one, and no fast rate. Gemini's >200k-token rate is not modelled (the base rate is used). A proxy's model id (`clinepass/…`) is never priced automatically. `POST /api/settings/reset-pricing` empties `model_pricing_dismissed` for its scope and runs a sync right after reseeding (waiting up to 3 s), so the OpenAI and Gemini rows a reset wiped come straight back; with `{scope: "claude"}` (the Claude Code tab's **Reset Defaults**) it deletes and reseeds only `claude-…` rows, so other vendors' rows and a custom proxy row survive. A rate outside 0–1000 $/MTok is rejected, and a failed fetch changes nothing. Set `AGENTHUB_PRICING_SYNC=0` (or `false`/`off`) to turn it off.
```sql
CREATE TABLE model_pricing (
+8 -8
View File
@@ -34,7 +34,7 @@ agenthub workspace add --repo /path/to/repo --title "My Feature" --base main --s
Adopting your main repo (`agenthub workspace add --cwd $(pwd)`) is also how you get stage detection working for sessions that work directly in it rather than in a worktree (once a pipeline template is installed) — see "Stage detection" below. Once adopted, that workspace's own card lists every managed-worktree workspace whose `source_repo` matches its `cwd`, each a clickable link to jump to that workspace.
Adding a workspace through the dashboard's "+ Add workspace" flow also auto-runs, best-effort, in parallel: `agenthub workspace profile init` (only if a Node.js project is detected — most repos won't be, and that's a normal outcome, not a failure), `agenthub workspace mcp sync`, and the AI Router pick. None of the three blocks the workspace from being created or from each other — a workspace whose repo has no MCP servers configured, for instance, still gets created and is still usable, just without a synced `.mcp.json`. The modal shows a ✓/✗ summary of the three results and stays open until dismissed (Cancel/X) — it does not auto-close. Run any of the three manually later (from the workspace's own card, or the CLI) if the automatic attempt didn't apply.
Adding a workspace through the dashboard's "+ Add workspace" flow also auto-runs, best-effort, in parallel: `agenthub workspace profile init` (only if a Node.js project is detected — most repos won't be, and that's a normal outcome, not a failure), `agenthub workspace mcp sync`, and the AI Proxy pick. None of the three blocks the workspace from being created or from each other — a workspace whose repo has no MCP servers configured, for instance, still gets created and is still usable, just without a synced `.mcp.json`. The modal shows a ✓/✗ summary of the three results and stays open until dismissed (Cancel/X) — it does not auto-close. Run any of the three manually later (from the workspace's own card, or the CLI) if the automatic attempt didn't apply.
### The "+ Add workspace" modal
@@ -87,13 +87,13 @@ terminal with `agenthub workspace add --pipeline <id>`. *Model endpoint* is the
per-workspace extra the form still asks for, and it sits in the form itself rather
than behind a disclosure — a disclosure around a single field only hid it.
A workspace has no AI Router of its own: each NEW session picks one. The **AI Router** select in the workspace home's prompt box lists the profiles saved in Settings › AI Router (9Router, OpenRouter, a self-hosted gateway) plus **Native** (the vendor's own API); it shows Native alone, disabled, when the session's AI tool cannot use a router (only Claude and Codex can), and it remembers the last pick (`agenthub.session.router`). The start sends `router: "<name>"`; a Claude pane then runs with `--settings=<data dir>/router-settings/<name>.json`, which outranks the workspace's own settings, and a Codex pane with `-p <name>` plus its key through tmux `-e`. So two sessions of one workspace can run on two routers, a worktree session gets its router too, and switching never touches a running session. A resume goes back to the router its session last ran on (`dashboard_runs.router`). Editing a profile in Settings rewrites those files (a rotated key reaches live Claude sessions); deleting it removes them. Older builds wrote a workspace's router into its `.claude/settings.local.json`; every server start takes those eight keys back out. See `docs/API.md` § AI Router profiles.
A workspace has no AI Proxy of its own: each NEW session picks one. The **AI Proxy** select in the workspace home's prompt box lists the profiles saved in Settings › AI Proxy (9Router, OpenRouter, a self-hosted gateway) plus **Native** (the vendor's own API); it shows Native alone, disabled, when the session's AI tool cannot use a proxy (only Claude and Codex can), and it remembers the last pick (`agenthub.session.proxy`, falling back to the pre-rename `agenthub.session.router`). The start sends `proxy: "<name>"` (`router` is still accepted); a Claude pane then runs with `--settings=<data dir>/proxy-settings/<name>.json`, which outranks the workspace's own settings, and a Codex pane with `-p <name>` plus its key through tmux `-e`. So two sessions of one workspace can run on two proxies, a worktree session gets its proxy too, and switching never touches a running session. A resume goes back to the proxy its session last ran on (`dashboard_runs.proxy`). Editing a profile in Settings rewrites those files (a rotated key reaches live Claude sessions); deleting it removes them. Older builds wrote a workspace's proxy into its `.claude/settings.local.json`; every server start takes those eight keys back out. See `docs/API.md` § AI Proxy profiles.
Both modes' path field has a **Browse** button next to it that opens the operating system's own folder picker (`POST /api/lanes/pick-path`). A browser cannot hand a web page an absolute path from its own dialog, so the SERVER opens the dialog on its desktop (zenity/kdialog on Linux, `osascript` on macOS, PowerShell on Windows) — which is your screen only when the browser runs on the same machine. A browser on another machine, or a server with no desktop or no zenity/kdialog, gets the small in-app folder browser instead (`GET /api/lanes/browse?path=<abs>`): click a subfolder to descend, ".." to go up, "Select this folder" to fill the path field; git repos are marked. The same button sits beside the workspace home's **Folder** field, Import history's folder, the project-memory folder, CLAUDE_HOME/CODEX_HOME and a remote source's SSH identity file (a file picker, no in-app fallback).
## Workspace tool: Claude or Codex
A workspace has a default tool: **Claude** (default) or **Codex** (or any AI Tools id), chosen in the "+ Add workspace" modal and immutable once the workspace exists. Each NEW session picks its own in the workspace home's prompt box — the **AI** select at the left of the box (beside it, on the right, ONE model chip holding the model list and Effort; AI Router, Permission and Local / New worktree sit in a row under the box, like Claude Desktop), lists every installed, enabled tool and starts on the workspace's — and `POST /api/lanes/:id/start` takes it as `tool`; a resume keeps the tool its session ran on (`sessions.tool`). The sidebar draws each session's tool icon at the right of its row.
A workspace has a default tool: **Claude** (default) or **Codex** (or any AI Tools id), chosen in the "+ Add workspace" modal and immutable once the workspace exists. Each NEW session picks its own in the workspace home's prompt box — the **AI** select at the left of the box (beside it, on the right, ONE model chip holding the model list and Effort; AI Proxy, Permission and Local / New worktree sit in a row under the box, like Claude Desktop), lists every installed, enabled tool and starts on the workspace's — and `POST /api/lanes/:id/start` takes it as `tool`; a resume keeps the tool its session ran on (`sessions.tool`). The sidebar draws each session's tool icon at the right of its row.
- **`claude` (default)** — `claude` binary is invoked; the session's hooks, messages, and tool calls flow through the dashboard. Claude workspaces render Sessions, Analytics, cost, and stage-detection badges; liveness is tracked by the Claude Code session.
- **`codex` (experimental)** — `codex` binary is invoked instead. The home's prompt box shows Sandbox and Approval in place of Claude's permission mode. Codex workspaces carry **no pipeline**: no stage chip, no time-on-stage, no pipeline or feature picker and no PipelineMap, and nothing infers or advances their stage. They also get no Sessions/Analytics/cost. (The console pre-fills nothing into the prompt box, for either tool.)
@@ -392,9 +392,9 @@ The dashboard web UI merges workspaces and runs into a single **Workspace** page
- **No page header.** The page title and the four counters (`lanes`, `running`, `needs you`, `dead`) are gone: the sidebar's workspace list shows every workspace with its own status dot, and the status bar at the bottom of every page counts running and need-you workspaces. `GET /api/lanes` still returns `counts` for other callers.
- **The sidebar's workspace list picks the workspace** — on every page, below the nav (`client/src/components/WorkspaceTree.tsx`), shaped like Claude Desktop's project list: every workspace on ONE level (no repo grouping, no worktree rows), in your stored order. A row (`WorkspaceRow.tsx`) is a chevron, a status dot (green = active, pulsing while running; amber row = needs you; red = dead or error; grey = idle), the title or folder name and the tool's icon, with `+` (open it for a new session) and `⋯` on hover. Clicking the name opens `/workspace?lane=<id>` and expands the row: the URL is the selection, so a notification link, F5 and the back button all land on the same workspace, and a bare `/workspace` reopens the last one shown (`agenthub.workspace.selected`) or the first. The header has a **tool filter** (shown once workspaces of two tools exist; kept in `agenthub.sidebar.projects` → `tool`, a stored tool no workspace has filters nothing), search and `+` (Add workspace). Search matches workspace names and paths — such a workspace keeps all its sessions — and session names: a workspace whose name does not match still shows when one of its sessions does, with only the matching sessions under it (every list stays mounted, hidden, to load and count). In the collapsed rail the list is a single icon that opens the sidebar. Drag the expanded sidebar's right edge (or focus it and press ←/→) to widen it from 240 px up to 480 px — never more than 40% of the window; the width is kept in `agenthub.sidebar.width` and a double-click on the edge resets it (`client/src/components/Layout.tsx`).
- **A workspace's sessions** — the chevron expands the row to the sessions the workspace OWNS and has not archived (`WorkspaceSessionList.tsx`: `GET /api/sessions?lane=<id>&archived=0`, the newest 20, re-read when the workspace's status or session moves; which rows are expanded is kept in `agenthub.sidebar.projects` → `expanded`). A live AI tab whose session has no row yet — a tab opened bare from `+`, before its first prompt — is listed at the top as **New session** straight from the run (its session id is known from the start), so a new tab shows in its workspace at once; the row becomes the real session when its first hook lands. The sidebar's live panes follow the Workspace page's own run-list poll (`LIVE_RUNS_EVENT`), since a session in slots 1–9 moves no workspace column. A workspace owns a session its pane held — `bindRunSession` stamps `sessions.lane_id` from the pane's run, first writer wins, a nested `claude -p` excluded — and lists nothing else: a `claude` typed into a terminal in the same folder, or history from before the workspace existed, belongs to no workspace and is found on the Sessions page. Sessions older than this ownership column are not guessed: only those `dashboard_runs` still named were backfilled. A session started by a tool with no hooks (Codex without its hooks, any catalog tool) is never stamped. A row's dot is the session's state (`sessionDot`: amber waiting for you, red failed, green a live pane, hollow at rest); double-click renames it (`PATCH {name}`), and its hover button **archives** it (`PATCH {archived: true}` — hidden from the workspace, nothing deleted; the home lists the archived ones folded with **Unarchive**). A filled dot marks a session that still has a live pane (a `GET /api/run` handle carrying its session id — every pane the dashboard starts carries `AGENTHUB_RUN_ID=<its tmux name>`, the hook handler forwards it as `run_id`, and `bindRunSession` in `server/routes/hooks.js` records the hook's session on that run: the first session always, afterwards — for a Claude pane — only a `SessionStart` that is not a fresh `startup` (`/clear`, `/resume`), so a `claude -p` the agent runs inside the pane never takes it over; the rule is Claude-only, and a Codex or Antigravity pane follows every new session): clicking it opens `/workspace?lane=<id>&run=<runId>`, which ATTACHES to that tmux session (`attachRequest` on `LaneConsolePane`) — it starts nothing and resumes nothing. The session opens on its own tab (the tab of its tmux slot): in the pane already showing it (focused), else in the focused pane; **Ctrl/Cmd+click** opens it in a split beside the focused pane (see **Split panes** below). Any other session opens in its workspace the way Claude Desktop opens an old one: `/workspace?lane=<id>&session=<id>` shows its conversation, read-only (`SessionReplay.tsx`, the console hidden — never unmounted — behind it), with a prompt box under it. Opening it resumes nothing; **sending** a message resumes it (`POST /api/lanes/:id/start {slot, resumeSessionId, prompt}` in the first free pane slot, on the tool the session ran on) and opens that pane — a resume re-reads the whole transcript into context, so it waits for that explicit send. The session's analysis page (`/sessions/<id>`: agents, events, cost, transcript) is the **Analyst** button in the session view's right panel, beside Files and Changes (disabled while the pane's run has reported no session yet).
- **Row menu** — right-click a row, or its `⋯` button: Rename, the Model (AI Router) select, New workspace here…, Copy path, Reset worktree… (managed only) and Delete workspace…, each the call the rest of the UI already makes. Delete is record-only exactly as before (see "Removing a workspace").
- **Right-click menus** — every item that has actions opens them on a right-click too, in one shell (`client/src/components/ContextMenu.tsx`: placed at the pointer, kept inside the viewport, closed by Escape, a click outside or an item). The buttons stay; the menu repeats what they do and nothing more. A session row — in the sidebar, the home's Recents and the archived fold (`lanes/SessionMenu.tsx`) — offers Open, Open in split (live), Rename and Archive (sidebar), Unarchive (archived), Session details, Copy session ID and Close tab (live); the sidebar's **New session** row Open, Open in split and Close tab. A console tab offers Open, Open in split, Session details, Copy session ID and Close tab; a pane header (split view) Show <session> here, Split right/down with a new terminal and Close pane. In the Changes panel a changed file (overview row or review header) offers Open, Stage/Unstage, Discard — two clicks, as on its trash button — or the conflict's Keep mine / Take theirs / Mark resolved, and Copy relative path; a Files tree entry Open and Copy relative path; a History commit View commit, Copy commit SHA and Copy commit message. The home's title opens the row menu, and a Context file or extra folder offers Copy path and Remove. Outside the workspace the same shell serves the Sessions table, the Dashboard's agent cards and recent events, the Activity feed and Workflow runs (`components/SessionRowMenu.tsx`: Open session details, Open in its workspace when it has one, Copy session ID, Copy folder path), an attached prompt image (View / Remove), the managed workspace card (Reset), every Settings list (Git credentials, AI Router, Pricing, alert rules, webhooks, remote sources, AI tools) and every Claude Code / Codex config list (skills, agents, commands, output styles and memory files via `FileItemMenu` in `components/config/MdItemList.tsx`; plugins, MCP servers, marketplaces, auth profiles). A delete or remove there takes the button's own confirm — a modal, or two clicks. Text areas (the terminal, a conversation, a diff) keep the browser's own menu. The shell stops the menu's clicks from reaching the card it was opened from.
- **The workspace home** — `/workspace?lane=<id>` opens on the workspace's home (`client/src/components/lanes/WorkspaceHome.tsx`), shaped like a Claude Desktop project page: the name (rename from its `⋯` menu, which is the sidebar row menu) with the repository it belongs to under it (root, the main worktree's branch, the worktree count, from `GET /api/projects` — the facts Settings → Projects used to show), a prompt box — the one place a session starts WITH a prompt (a pane has no run form; the session view's tab bar `+` only starts a bare CLI or a shell) — whose fields run in order **AI** (the session's own tool: every installed, enabled one, the workspace's by default), **AI Router** (Native or a saved profile; Native alone, disabled, for a tool that cannot use one), **Model** (the tool's own list, or on a router that router's models), **Permission** (Claude's permission mode; Codex's sandbox and approval; a catalog tool's per-session bypass switch) and **Effort** (Claude and Codex), whose half-typed text is kept per workspace in `agenthub.workspace.draft.<lane id>` (localStorage, so it survives leaving the page or switching workspace; cleared once sent — attached images are not kept), and whose **Start** opens a NEW session in the first slot with no live run (0–9, `POST /api/lanes/:id/start {slot}`; all ten busy is refused) and then shows it in a tab of its own, and **Recents** — the workspace's own sessions (`?lane=<id>`) with their cost and time, a live one opening its pane and any other its conversation (`&session=`), exactly like the sidebar. The transcript page (`/sessions/<id>`) of a Claude session started in a workspace's own folder carries an explicit **Resume** (`client/src/components/lanes/SessionResumeButton.tsx`, the one place that resumes) and beside it **Fork**, which resumes the history into a NEW session id (`{forkSession: true}` → `--fork-session`, the original untouched, offered even while the original has a live pane): it warns that a resume re-reads the whole transcript into context, re-reads the live panes, then starts it in the workspace's first free pane (`POST /api/lanes/:id/start {slot, resumeSessionId}`) and opens it. A session from a SUBFOLDER gets no Resume — the pane starts in the workspace's folder and Claude finds a session by the folder it ran in — and a session that still has a live pane shows **Open pane** instead. On the right sit the workspace's own context: **Instructions** (appended to every new session's system prompt), **Memory** (read-only: the folder's CLAUDE.md / AGENTS.md family and Claude's auto memory), **Context** (uploaded files, ≤ 10 MB each, stored by AgentHub) and **Folder** (the workspace's own folder plus extra ones, with the repo's untracked worktrees suggested). Only Claude and Codex panes can take them (`--add-dir`, `--append-system-prompt-file=` / `-c developer_instructions=`); for any other tool the page says so and its panes start without them. See `docs/API.md` → Workspace context. The home HIDES the session view with CSS — the console never unmounts behind it.
- **Row menu** — right-click a row, or its `⋯` button: Rename, the Model (AI Proxy) select, New workspace here…, Copy path, Reset worktree… (managed only) and Delete workspace…, each the call the rest of the UI already makes. Delete is record-only exactly as before (see "Removing a workspace").
- **Right-click menus** — every item that has actions opens them on a right-click too, in one shell (`client/src/components/ContextMenu.tsx`: placed at the pointer, kept inside the viewport, closed by Escape, a click outside or an item). The buttons stay; the menu repeats what they do and nothing more. A session row — in the sidebar, the home's Recents and the archived fold (`lanes/SessionMenu.tsx`) — offers Open, Open in split (live), Rename and Archive (sidebar), Unarchive (archived), Session details, Copy session ID and Close tab (live); the sidebar's **New session** row Open, Open in split and Close tab. A console tab offers Open, Open in split, Session details, Copy session ID and Close tab; a pane header (split view) Show <session> here, Split right/down with a new terminal and Close pane. In the Changes panel a changed file (overview row or review header) offers Open, Stage/Unstage, Discard — two clicks, as on its trash button — or the conflict's Keep mine / Take theirs / Mark resolved, and Copy relative path; a Files tree entry Open and Copy relative path; a History commit View commit, Copy commit SHA and Copy commit message. The home's title opens the row menu, and a Context file or extra folder offers Copy path and Remove. Outside the workspace the same shell serves the Sessions table, the Dashboard's agent cards and recent events, the Activity feed and Workflow runs (`components/SessionRowMenu.tsx`: Open session details, Open in its workspace when it has one, Copy session ID, Copy folder path), an attached prompt image (View / Remove), the managed workspace card (Reset), every Settings list (Git credentials, AI Proxy, Pricing, alert rules, webhooks, remote sources, AI tools) and every Claude Code / Codex config list (skills, agents, commands, output styles and memory files via `FileItemMenu` in `components/config/MdItemList.tsx`; plugins, MCP servers, marketplaces, auth profiles). A delete or remove there takes the button's own confirm — a modal, or two clicks. Text areas (the terminal, a conversation, a diff) keep the browser's own menu. The shell stops the menu's clicks from reaching the card it was opened from.
- **The workspace home** — `/workspace?lane=<id>` opens on the workspace's home (`client/src/components/lanes/WorkspaceHome.tsx`), shaped like a Claude Desktop project page: the name (rename from its `⋯` menu, which is the sidebar row menu) with the repository it belongs to under it (root, the main worktree's branch, the worktree count, from `GET /api/projects` — the facts Settings → Projects used to show), a prompt box — the one place a session starts WITH a prompt (a pane has no run form; the session view's tab bar `+` only starts a bare CLI or a shell) — whose fields run in order **AI** (the session's own tool: every installed, enabled one, the workspace's by default), **AI Proxy** (Native or a saved profile; Native alone, disabled, for a tool that cannot use one), **Model** (the tool's own list, or on a router that router's models), **Permission** (Claude's permission mode; Codex's sandbox and approval; a catalog tool's per-session bypass switch) and **Effort** (Claude and Codex), whose half-typed text is kept per workspace in `agenthub.workspace.draft.<lane id>` (localStorage, so it survives leaving the page or switching workspace; cleared once sent — attached images are not kept), and whose **Start** opens a NEW session in the first slot with no live run (0–9, `POST /api/lanes/:id/start {slot}`; all ten busy is refused) and then shows it in a tab of its own, and **Recents** — the workspace's own sessions (`?lane=<id>`) with their cost and time, a live one opening its pane and any other its conversation (`&session=`), exactly like the sidebar. The transcript page (`/sessions/<id>`) of a Claude session started in a workspace's own folder carries an explicit **Resume** (`client/src/components/lanes/SessionResumeButton.tsx`, the one place that resumes) and beside it **Fork**, which resumes the history into a NEW session id (`{forkSession: true}` → `--fork-session`, the original untouched, offered even while the original has a live pane): it warns that a resume re-reads the whole transcript into context, re-reads the live panes, then starts it in the workspace's first free pane (`POST /api/lanes/:id/start {slot, resumeSessionId}`) and opens it. A session from a SUBFOLDER gets no Resume — the pane starts in the workspace's folder and Claude finds a session by the folder it ran in — and a session that still has a live pane shows **Open pane** instead. On the right sit the workspace's own context: **Instructions** (appended to every new session's system prompt), **Memory** (read-only: the folder's CLAUDE.md / AGENTS.md family and Claude's auto memory), **Context** (uploaded files, ≤ 10 MB each, stored by AgentHub) and **Folder** (the workspace's own folder plus extra ones, with the repo's untracked worktrees suggested). Only Claude and Codex panes can take them (`--add-dir`, `--append-system-prompt-file=` / `-c developer_instructions=`); for any other tool the page says so and its panes start without them. See `docs/API.md` → Workspace context. The home HIDES the session view with CSS — the console never unmounts behind it.
- **A session in its own worktree** — Claude Desktop's worktree toggle: the row under the home's prompt box has a **Local / New worktree** select (Claude workspaces in a git repo). Claude Code itself makes a worktree only when started with `--worktree` or when asked mid-session — and a worktree it enters mid-session is invisible to AgentHub, which reads a session's folder once, at its start, and the new session's pane runs `claude --worktree=<name>`, so CLAUDE CODE makes `<repo>/.claude/worktrees/<name>` on branch `worktree-<name>`, copies `.worktreeinclude`, runs your `WorktreeCreate` hook, and on exit removes a worktree with no changes (or asks). AgentHub adds `/.claude/worktrees/` to the repo's `info/exclude` (Claude does not, and the main repo would list it). The session stays this workspace's own — listed with a branch icon and the worktree's name — and the session view's Changes panel works in ITS worktree (every git route with `?session=`, resolved from the session row: owned by the workspace, registered by `git worktree list`; `ENOWORKTREE` otherwise, never the main folder). Resume and Fork start in that worktree. **Archive** of a worktree session with no live pane asks first and removes the worktree like Claude Desktop — `git worktree remove` without force (git refuses unsaved work, and then nothing is archived) and `git branch -d` (unmerged work keeps its branch); a lock held by a live process refuses, a stale one is cleared. The workspace's own folder is never removed.
- **The session view** — `/workspace?lane=<id>&run=<runId>` (a live session picked in the sidebar or Recents, or one just started) shows the console's tabs and the Changes panel, with that run's tab active. The sidebar's workspace row always opens the workspace's home, live tabs or not (a live tab opens from its session row); the **Home** button at the left end of the tab bar opens the home of a workspace whose tabs stay running. Every workspace keeps its own tabs, panes and sessions: another workspace's tab is never shown. A browser notification for a workspace with a live pane opens it here directly.
- **Untracked worktrees** are no longer listed in the sidebar; Settings → Git → **Worktrees** (`showUntracked`, hidden sources `claude` = `<repo>/.claude/worktrees/*` and `agenthub` = `LANES_ROOT`) now decides which of them the home's Add folder suggests.
@@ -1157,7 +1157,7 @@ disables) it lists `$CODEX_HOME/sessions/<YYYY>/<MM>/<DD>/rollout-*.jsonl`
(`$CODEX_HOME` defaults to `~/.codex`; every codex pane is started with the
dashboard's own `CODEX_HOME` through tmux `-e`, so the pane writes where this
reads; it is editable in Settings → Codex → Overview, and a save re-writes each
codex workspace's AI Router layer into the new home — a pane already running keeps
codex workspace's AI Proxy layer into the new home — a pane already running keeps
the old one until it restarts), reads each thread's `cwd` from the
`session_meta` on the file's first line, and calls the owning workspace `running`
while that journal's mtime is under two minutes old — and `idle` once it is
@@ -1749,4 +1749,4 @@ A catalog file:
}
```
`detect` lists bare command names (the first found on PATH is what runs), `requires` other commands that must be present, `args` fixed leading args, `prompt.mode` one of `argv` (optional `separator: "--"`), `flag` (`flag`), or `after-start` (`delayMs`, default 3000 — the prompt is pasted into the pane once the CLI is up). `model.flag` (optional `vendor`: `claude`/`openai`/`google`, whose priced models the picker offers) puts the run form's model before the prompt; `config` (built-in and release files only, like `install`) lists the `~/`-relative files the tool's Settings tab may edit — `format` `json`/`jsonc`/`text`, guessed from the extension, and never a credential-looking name; `resume` enables **Resume last session**; `bypass`/`bypassEnv` enable the run form's per-run **Bypass permissions** switch, which is off every time the form opens. Every non-Claude workspace has no pipeline, no agents install and no AI Router (native only, except Codex); running/idle comes from the exited-pane heal sweep.
`detect` lists bare command names (the first found on PATH is what runs), `requires` other commands that must be present, `args` fixed leading args, `prompt.mode` one of `argv` (optional `separator: "--"`), `flag` (`flag`), or `after-start` (`delayMs`, default 3000 — the prompt is pasted into the pane once the CLI is up). `model.flag` (optional `vendor`: `claude`/`openai`/`google`, whose priced models the picker offers) puts the run form's model before the prompt; `config` (built-in and release files only, like `install`) lists the `~/`-relative files the tool's Settings tab may edit — `format` `json`/`jsonc`/`text`, guessed from the extension, and never a credential-looking name; `resume` enables **Resume last session**; `bypass`/`bypassEnv` enable the run form's per-run **Bypass permissions** switch, which is off every time the form opens. Every non-Claude workspace has no pipeline, no agents install and no AI Proxy (native only, except Codex); running/idle comes from the exited-pane heal sweep.
+3 -3
View File
@@ -126,10 +126,10 @@ waits on an install:
2. **Superseded check** — if Claude Code records a DIFFERENT build of this plugin as installed, this one logs a line and does nothing else (see [A retired build never acts](#a-retired-build-never-acts)).
3. **Node gate** — refuse below 22.5 with one line (see Prerequisites).
4. **Lock** — atomic `mkdir` lock holding the PID; reclaimed when the owner is dead or the lock is older than 10 minutes, so two sessions cannot race the install or spawn two servers.
5. **Install** — `npm install --omit=dev --ignore-scripts` into the runtime dir (never into the plugin cache, which is garbage-collected and replaced on every update). `--ignore-scripts` keeps the root `postinstall` from pulling the whole Vite client toolchain.
5. **Install** — `npm install --omit=dev --ignore-scripts` into the runtime dir, run as the npm bundled with the node running the bootstrap (`npmCommand`: its `npm-cli.js` beside `process.execPath`, no shell, that node's directory first on PATH; `npm` on PATH only when that node ships none) — the update chain runs with the dashboard server's environment, whose PATH need not reach `npm.cmd` on Windows. The UI build below and the doctor's npm row use the same npm (never into the plugin cache, which is garbage-collected and replaced on every update). `--ignore-scripts` keeps the root `postinstall` from pulling the whole Vite client toolchain.
6. **Legacy hook cleanup** — see above.
7. **CLI** — write the `~/.local/bin/agenthub` launcher, never clobbering a `agenthub` the bootstrap did not write. It pins the node that ran the bootstrap and falls back to `node` on `PATH` when that binary is gone (a Homebrew Cellar or nvm path deleted by an upgrade).
8. **UI build** — `client/` is copied into the runtime dir and built there (`npm ci --include=dev`, falling back to `npm install --include=dev`, then `npm run build`), landing in `runtime/client-dist` — the same directory the server serves from. `--include=dev` is required, not tidy: the entire build toolchain (`vite`, `typescript`, `@vitejs/plugin-react`, `tailwindcss`, `@types/*`) is a devDependency, and npm reads an inherited `NODE_ENV=production` as `--omit=dev`. Every bootstrap descended from the running server carries that env — the self-update chain, and any `SessionStart` fired by a `claude` inside a tmux pane the server started — so without the flag the install drops `vite` and the build dies on missing types. Skipped when a bundle for the current version already exists; a failure here does not fail the bootstrap (the API still works, and `/agenthub-open` can retry) but does mean `/run` 404s until it's fixed — and when a bundle from the PREVIOUS version is still on disk the failure is quieter still: the dashboard keeps serving the old UI against the new API, visible only as the Settings page's `UI build vX (rebuild client to match)` line.
8. **UI build** — `client/` is copied into the runtime dir and built there (`npm ci --include=dev --ignore-scripts`, falling back to `npm install` with the same flags, then `tsc -b` and `vite build` run by the bootstrap's own `node` — the client's `build` script without `npm run`), landing in `runtime/client-dist` — the same directory the server serves from. Nothing in it looks `node` up on PATH: on Windows an install script or a `.bin/*.cmd` shim runs through cmd.exe, and the server's PATH may have no `node` (esbuild's postinstall failed there with "'node' is not recognized" and the update kept the old UI); esbuild, the only dependency with an install script, finds its binary without it. `--include=dev` is required, not tidy: the entire build toolchain (`vite`, `typescript`, `@vitejs/plugin-react`, `tailwindcss`, `@types/*`) is a devDependency, and npm reads an inherited `NODE_ENV=production` as `--omit=dev`. Every bootstrap descended from the running server carries that env — the self-update chain, and any `SessionStart` fired by a `claude` inside a tmux pane the server started — so without the flag the install drops `vite` and the build dies on missing types. Skipped when a bundle for the current version already exists; a failure here does not fail the bootstrap (the API still works, and `/agenthub-open` can retry) but does mean `/run` 404s until it's fixed — and when a bundle from the PREVIOUS version is still on disk the failure is quieter still: the dashboard keeps serving the old UI against the new API, visible only as the Settings page's `UI build vX (rebuild client to match)` line.
9. **Server** — when the user has turned **Start AgentHub automatically** on
(Settings → About; OFF by default), install/refresh the
`agenthub.service` unit on Linux — on macOS with a GUI login, the
@@ -149,7 +149,7 @@ waits on an install:
The first run takes a few minutes — most of it is the UI build, which is what
makes client-only routes (`http://localhost:4820/run`, and every other page)
work the moment `claude` starts, with no manual `/agenthub-open` step. Progress
goes to `~/.claude/agenthub/runtime/bootstrap.log`; `npm run build`'s own
goes to `~/.claude/agenthub/runtime/bootstrap.log`; the UI build's own
output goes to `client-build.log` next to it; the server's own output goes to
`server.log`.
+6 -30
View File
@@ -3,7 +3,7 @@
openapi: 3.0.3
info:
title: Agent Dashboard for Claude Code API
version: 2.5.0
version: 2.5.9
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
contact:
name: Nguyễn Ngọc Trí Vĩ
@@ -4177,9 +4177,9 @@ components:
bypass:
type: boolean
description: Offers the per-run Bypass permissions switch
routerCapable:
proxyCapable:
type: boolean
description: Can use an AI Router profile
description: Can use an AI Proxy profile
install:
type: object
nullable: true
@@ -4564,14 +4564,6 @@ components:
env_owned:
type: boolean
description: 'True when the real environment, or the checkout''s own `.env`, set this variable at startup: either wins over the settings file, so a saved value is overridden again at the next start.'
routers:
type: object
properties:
missing:
type: array
items:
type: string
description: '`<lane id> (<title>): <profile>` for every lane naming an AI Router profile the new home''s store (`<CLAUDE_HOME>/agenthub-routers/`) does not have — such a lane starts on its base configuration.'
WorkflowToolRun:
type: object
description: 'A Claude Code Workflow-tool run (issue #167): a fleet of sub-agents spawned by the ''Workflow'' tool (or self-paced /loop). These emit no hooks; the source of truth is the on-disk run journal, ingested into the `workflows` table (see server/lib/workflow-ingest.js). Keyed by `run_id` and parented to the launching session. The JSON-blob columns `phases` and `progress` are parsed into arrays before serialization.'
@@ -11521,7 +11513,7 @@ paths:
'400':
description: EBADPATH (incl. a symlink resolving outside the lane), EBADREV or ENOFILE.
'403':
description: ESECRET — `.claude/settings.local.json` (the AI Router key) is refused at every rev, at any depth, and under any path that resolves to it (a symlinked `.claude`).
description: ESECRET — `.claude/settings.local.json` (where an older build kept the AI Proxy key) is refused at every rev, at any depth, and under any path that resolves to it (a symlinked `.claude`).
'404':
description: Lane not found.
/api/lanes/{id}/git/raw:
@@ -13028,7 +13020,7 @@ paths:
tags:
- Settings
summary: Get the active Codex home directory
description: Returns the Codex home directory (CODEX_HOME, else `<homedir>/.codex`) the dashboard reads Codex config and rollout journals from, writes AI Router layers into, and hands every codex lane pane through tmux `-e`.
description: Returns the Codex home directory (CODEX_HOME, else `<homedir>/.codex`) the dashboard reads Codex config and rollout journals from, writes AI Proxy layers into at each spawn, and hands every codex lane pane through tmux `-e`.
operationId: getCodexHome
responses:
'200':
@@ -13052,7 +13044,7 @@ paths:
tags:
- Settings
summary: Update the Codex home directory
description: Same validation and persistence as PUT /api/settings/claude-home (403 EBADORIGIN from another site, settings file, EBADHOME for a path that is or contains the OS home, INVALID_PATH otherwise). On success it re-writes the AI Router layer (`<name>.config.toml`) of every codex lane on a router into the new home, reported as `routers`, because `codex -p <name>` loads it from there. A codex pane already running keeps its old home until restarted.
description: 'Same validation and persistence as PUT /api/settings/claude-home (403 EBADORIGIN from another site, settings file, EBADHOME for a path that is or contains the OS home, INVALID_PATH otherwise). Nothing is re-written here: a session''s AI Proxy layer (`<name>.config.toml`) is written into the current home at its spawn. A codex pane already running keeps its old home until restarted.'
operationId: updateCodexHome
requestBody:
required: true
@@ -13078,7 +13070,6 @@ paths:
- ok
- codex_home
- env_owned
- routers
properties:
ok:
type: boolean
@@ -13088,25 +13079,10 @@ paths:
type: string
env_owned:
type: boolean
routers:
type: object
required:
- updated
- failed
properties:
updated:
type: integer
failed:
type: array
items:
type: string
example:
ok: true
codex_home: /Users/son/.codex-work
env_owned: false
routers:
updated: 1
failed: []
'400':
description: Invalid path (INVALID_PATH) or too broad (EBADHOME).
content:
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "2.5.0",
"version": "2.5.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "2.5.0",
"version": "2.5.9",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.5.0",
"version": "2.5.9",
"bin": {
"agenthub": "bin/agenthub.js"
},
+10 -4
View File
@@ -22,12 +22,15 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
require("../server/lib/windows-hide").install(); // before child_process is read
const fs = require("fs");
const { rotateIfLarge } = require("./log-rotate");
const path = require("path");
const { execFileSync, spawnSync } = require("child_process");
// What a failed command printed — plugin-bootstrap.js says why on STDOUT.
const { failureText } = require("./plugin-update-helper");
// psmux (the Windows `tmux`) has no `=name:` targets; tmux.js knows which form.
const { pane: paneTarget } = require("../server/lib/tmux");
const [, , runtimeDirArg, planJson] = process.argv;
@@ -169,7 +172,7 @@ function reloadPanes() {
try {
paneCmd = execFileSync(
"tmux",
["display-message", "-p", "-t", `=${name}:`, "#{pane_current_command}"],
["display-message", "-p", "-t", paneTarget(name), "#{pane_current_command}"],
{ encoding: "utf8", timeout: 15_000, stdio: ["ignore", "pipe", "pipe"] }
).trim();
} catch {
@@ -186,10 +189,13 @@ function reloadPanes() {
// `/reload-plugins /reload-skills` — see SETTLE_MS in
// server/lib/cc-reload.js. Blocking is fine here: this worker is
// detached and serves no requests.
if (!first) execFileSync("sleep", ["1"], { timeout: 15_000 });
// Atomics.wait, not `sleep`: Windows has no sleep binary.
if (!first) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1000);
first = false;
execFileSync("tmux", ["send-keys", "-t", `=${name}:`, "-l", command], { timeout: 15_000 });
execFileSync("tmux", ["send-keys", "-t", `=${name}:`, "Enter"], { timeout: 15_000 });
execFileSync("tmux", ["send-keys", "-t", paneTarget(name), "-l", command], {
timeout: 15_000,
});
execFileSync("tmux", ["send-keys", "-t", paneTarget(name), "Enter"], { timeout: 15_000 });
}
reloaded.push(name);
} catch {
+68 -38
View File
@@ -26,6 +26,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
require("../server/lib/windows-hide").install(); // before child_process is read
const fs = require("fs");
const { rotateIfLarge } = require("./log-rotate");
const os = require("os");
@@ -383,6 +384,29 @@ function linkCli(dir = cliDir(), root = PLUGIN_ROOT, nodePath = process.execPath
/* ------------------------------------------------------------- dependencies */
/**
* npm, run by the node executing this file: `{ file, args, env, shell }` for
* `spawnSync`. Its bundled `npm-cli.js` is found from `process.execPath`, so
* nothing depends on PATH — the self-update chain runs with the dashboard
* server's environment, and on Windows that PATH need not hold the directory
* of `npm.cmd` ("'npm.cmd' is not recognized"), even though node itself runs.
* PATH is still prefixed with that node's directory so npm's scripts (the UI
* build's `tsc`/`vite` shims) find the same node. A node with no bundled npm
* (a distro's split nodejs/npm packages) falls back to `npm` on PATH.
*/
function npmCommand(args, { execPath = process.execPath, env = process.env } = {}) {
const dir = path.dirname(execPath);
const pathKey = Object.keys(env).find((k) => k.toUpperCase() === "PATH") || "PATH";
const childEnv = { ...env, [pathKey]: [dir, env[pathKey]].filter(Boolean).join(path.delimiter) };
const cli = [
path.join(dir, "node_modules", "npm", "bin", "npm-cli.js"), // Windows layout
path.join(dir, "..", "lib", "node_modules", "npm", "bin", "npm-cli.js"), // Unix layout
].find((p) => fs.existsSync(p));
if (cli) return { file: execPath, args: [cli, ...args], env: childEnv, shell: false };
const win = process.platform === "win32";
return { file: win ? "npm.cmd" : "npm", args, env: childEnv, shell: win };
}
/**
* Install runtime dependencies into the runtime dir. `--ignore-scripts` keeps
* the root postinstall from pulling the whole Vite client toolchain; the
@@ -395,12 +419,13 @@ function installDeps(rt = runtimeDir(), root = PLUGIN_ROOT) {
const src = path.join(root, f);
if (fs.existsSync(src)) fs.copyFileSync(src, path.join(rt, f));
}
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
const res = spawnSync(
npm,
["install", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund"],
{ cwd: rt, stdio: "inherit", shell: process.platform === "win32" }
);
const npm = npmCommand(["install", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund"]);
const res = spawnSync(npm.file, npm.args, {
cwd: rt,
stdio: "inherit",
env: npm.env,
shell: npm.shell,
});
return res.status === 0;
}
@@ -829,6 +854,43 @@ function main() {
child.unref();
}
// Exported before the entry block below, not after it: `bootstrap()` requires
// plugin-open.js, which requires this module back (`npmCommand`, `runtimeDir`),
// and when this file is the entry that require resolves to whatever is on
// module.exports at that moment — an empty object if it is assigned last.
module.exports = {
MIN_NODE,
LOCK_STALE_MS,
KEEP_RETIRED_ROOTS,
supersededBy,
pruneRetiredPluginRoots,
installedPluginsPath,
pluginVersion,
runtimeDir,
statePath,
readState,
writeState,
depsHash,
fastPathOk,
nodeVersionOk,
lockPath,
lockIsStale,
acquireLock,
releaseLock,
isCheckoutHookEntry,
stripLegacyHooks,
hasLegacyHooks,
linkCli,
npmCommand,
installDeps,
serverIsLive,
liveServers,
livePids,
stopDashboard,
startDashboard,
bootstrap,
};
if (require.main === module) {
const force = process.argv.includes("--force");
if (force || process.argv.includes("--worker")) {
@@ -854,35 +916,3 @@ if (require.main === module) {
}
}
}
module.exports = {
MIN_NODE,
LOCK_STALE_MS,
KEEP_RETIRED_ROOTS,
supersededBy,
pruneRetiredPluginRoots,
installedPluginsPath,
pluginVersion,
runtimeDir,
statePath,
readState,
writeState,
depsHash,
fastPathOk,
nodeVersionOk,
lockPath,
lockIsStale,
acquireLock,
releaseLock,
isCheckoutHookEntry,
stripLegacyHooks,
hasLegacyHooks,
linkCli,
installDeps,
serverIsLive,
liveServers,
livePids,
stopDashboard,
startDashboard,
bootstrap,
};
+3 -4
View File
@@ -125,10 +125,9 @@ function runtimeDepsStatus(rt) {
/** No npm, no dashboard bundle and no runtime install — both go through it. */
function npmStatus() {
const res = spawnSync(process.platform === "win32" ? "npm.cmd" : "npm", ["-v"], {
encoding: "utf8",
shell: process.platform === "win32",
});
// The same npm the bootstrap and the UI build run (`npmCommand`), not PATH's.
const npm = boot.npmCommand(["-v"]);
const res = spawnSync(npm.file, npm.args, { encoding: "utf8", env: npm.env, shell: npm.shell });
if (res.status !== 0) {
return ["fail", "npm", "not runnable — the runtime install and the UI build both need it"];
}
+25 -4
View File
@@ -29,10 +29,15 @@ const boot = require("./plugin-bootstrap");
const { resolveDashboardPort } = require("../server/lib/server-info");
const PLUGIN_ROOT = path.resolve(__dirname, "..");
const NPM = process.platform === "win32" ? "npm.cmd" : "npm";
const NPM = "npm";
const NODE = "node";
/** NPM runs through `boot.npmCommand`, NODE is the running node itself —
* neither is ever a PATH lookup. */
function run(cmd, args, cwd, stdio) {
const res = spawnSync(cmd, args, { cwd, stdio, shell: process.platform === "win32" });
if (cmd === NODE) return spawnSync(process.execPath, args, { cwd, stdio }).status === 0;
const npm = boot.npmCommand(args);
const res = spawnSync(npm.file, npm.args, { cwd, stdio, env: npm.env, shell: npm.shell });
return res.status === 0;
}
@@ -87,14 +92,30 @@ function buildClient({
// `npm run build` failed, and the PREVIOUS bundle stayed in place: the
// dashboard kept serving the old version's UI while the API was already the
// new one. An explicit CLI flag outranks the env, which is the point.
const npmFlags = ["--no-audit", "--no-fund", "--include=dev"];
//
// `--ignore-scripts`, and the build run by node itself below rather than
// `npm run build`: a lifecycle script and a `.bin/*.cmd` shim both go
// through cmd.exe and look `node` up on PATH, and on Windows the server's
// PATH did not have it — esbuild's postinstall died with "'node' is not
// recognized", both installs failed, and the update kept the old UI.
// esbuild is the only dependency with an install script, and it finds its
// platform binary without it.
const npmFlags = ["--no-audit", "--no-fund", "--include=dev", "--ignore-scripts"];
if (
!exec(NPM, ["ci", ...npmFlags], src, stdio) &&
!exec(NPM, ["install", ...npmFlags], src, stdio)
) {
return "failed";
}
if (!exec(NPM, ["run", "build"], src, stdio)) return "failed";
// = client/package.json's "build" script (`tsc -b && vite build`); a test
// keeps the two in step.
const bin = (...p) => path.join(src, "node_modules", ...p);
if (
!exec(NODE, [bin("typescript", "bin", "tsc"), "-b"], src, stdio) ||
!exec(NODE, [bin("vite", "bin", "vite.js"), "build"], src, stdio)
) {
return "failed";
}
fs.rmSync(dist, { recursive: true, force: true });
fs.cpSync(path.join(src, "dist"), dist, { recursive: true });
+1
View File
@@ -31,6 +31,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
require("../server/lib/windows-hide").install(); // before child_process is read
const fs = require("fs");
const { rotateIfLarge } = require("./log-rotate");
const path = require("path");
+1
View File
@@ -7,6 +7,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
require("../server/lib/windows-hide").install(); // before child_process is read
const { spawn } = require("child_process");
const [, , pidArg, entry] = process.argv;
+2 -2
View File
@@ -463,7 +463,7 @@ The OpenAPI spec is generated from `server/openapi.js` (`createOpenApiSpec()`),
| `POST` | `/api/snapshots` | `{session_id, redact?: {file_paths?}}` → freeze the session into an immutable row under a random token (same-origin) |
| `GET` | `/api/snapshots/:token` | `{snapshot, payload}` — the frozen session, agents, events, stats and cost |
| `DELETE`| `/api/snapshots/:id` | Delete a snapshot (same-origin) |
| `GET` | `/api/query` | Query Explorer: sessions / events / agents filtered, sorted from an allowlist, paged; `format=csv\|json` exports the whole filter (≤ 50,000 rows) — `server/routes/query.js` |
| `GET` | `/api/query` | Query Explorer (the Sessions page's Agents tab): sessions / events / agents filtered, sorted from an allowlist, paged; `format=csv\|json` exports the whole filter (≤ 50,000 rows) — `server/routes/query.js` |
| `GET` | `/api/reports` | Scheduled report definitions with their latest run (`server/lib/reports.js`) |
| `POST`/`PUT`/`DELETE` | `/api/reports[/:id]` | Create / update / delete a report definition (same-origin; delete also removes its runs and files) |
| `POST` | `/api/reports/:id/run` | Run a report now (same-origin) |
@@ -701,7 +701,7 @@ Because sync runs non-interactively (`ssh -o BatchMode=yes`), the connection mus
| `GET` | `/api/settings/claude-home` | `{claude_home, env_owned}` — `env_owned` when the real environment or the checkout's `.env` set `CLAUDE_HOME` (a saved value is overridden at the next start) |
| `PUT` | `/api/settings/claude-home` | `{path}` → validate, apply, persist to AgentHub's settings file. 400 `EBADHOME` (is or contains the OS home) / `INVALID_PATH`, 500 `ESAVE`, 403 `EBADORIGIN` (`sameOriginGuard`) |
| `GET` | `/api/settings/codex-home` | `{codex_home, env_owned}` |
| `PUT` | `/api/settings/codex-home` | Same checks as `claude-home`; a session's AI Router layer is written into the current home at its spawn, so nothing is re-written here |
| `PUT` | `/api/settings/codex-home` | Same checks as `claude-home`; a session's AI Proxy layer is written into the current home at its spawn, so nothing is re-written here |
| `GET` | `/api/settings/codex-hooks` | Status of AgentHub's hooks in `$CODEX_HOME/hooks.json` (installed, per-event, Codex's trust as read from `config.toml`) |
| `POST` | `/api/settings/codex-hooks` | Install or reinstall them (`sameOriginGuard`). 400 `EBADJSON` / `EUNSUPPORTED` / `EFOREIGN` (the forwarder path holds a file that is not AgentHub's) |
| `DELETE` | `/api/settings/codex-hooks` | Remove AgentHub's hooks only (`sameOriginGuard`); a foreign forwarder is left alone |
+330
View File
@@ -0,0 +1,330 @@
/**
* @file Tests for the AI Proxy profile store — the CRUD surface, the built-in
* native profile, and the guarantee that a stored API key never appears in a
* response the browser can read.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const os = require("node:os");
const path = require("node:path");
const fs = require("node:fs");
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-ai-proxies-"));
process.env.CLAUDE_HOME = path.join(SUITE_ROOT, ".claude");
const { describe, it, beforeEach, after } = require("node:test");
const assert = require("node:assert/strict");
const proxies = require("../lib/ai-proxies");
const PROXIES_DIR = path.join(process.env.CLAUDE_HOME, "agenthub-proxies");
after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true }));
beforeEach(() => fs.rmSync(PROXIES_DIR, { recursive: true, force: true }));
/** The smallest profile the API accepts. */
function nineRouter(over = {}) {
return {
label: "9Router",
baseUrl: "http://localhost:20128/v1",
apiKey: "sk-secret-value",
model: "Highest",
...over,
};
}
describe("ai-proxies — the built-in native profile", () => {
it("is listed even before anything has been saved", () => {
const { items } = proxies.listProxies();
assert.equal(items.length, 1);
assert.equal(items[0].name, "native");
assert.equal(items[0].builtin, true);
});
it("leaves the two connection variables empty rather than absent", () => {
// The reference native settings.json sets both to "" — an absent key would
// let a user-level proxy base URL leak into a lane meant to run native.
const native = proxies.getProxy("native");
assert.equal(native.baseUrl, "");
assert.equal(native.apiKey, "");
});
it("carries no model ids of its own — the session's model picker picks", () => {
const native = proxies.getProxy("native");
assert.equal(native.model, "");
assert.deepEqual(native.models, {});
});
it("refuses to be edited or removed", () => {
assert.throws(() => proxies.updateProxy("native", { label: "mine" }), { code: "EBUILTIN" });
assert.throws(() => proxies.removeProxy("native"), { code: "EBUILTIN" });
});
});
describe("ai-proxies — create", () => {
it("saves a profile and derives its name from the label", () => {
const created = proxies.createProxy(nineRouter());
assert.equal(created.name, "9Router".toLowerCase());
assert.equal(created.label, "9Router");
assert.ok(fs.existsSync(path.join(PROXIES_DIR, `${created.name}.json`)));
});
it("stores the file 0600 inside a 0700 directory", () => {
const created = proxies.createProxy(nineRouter());
const file = fs.statSync(path.join(PROXIES_DIR, `${created.name}.json`));
const dir = fs.statSync(PROXIES_DIR);
assert.equal(file.mode & 0o777, 0o600);
assert.equal(dir.mode & 0o777, 0o700);
});
it("fills every model slot from `model` when no per-tier value is given", () => {
// The 9Router reference sets all six slots to one symbolic value; typing it
// once must be enough.
proxies.createProxy(nineRouter());
const saved = proxies.getProxy("9router");
assert.equal(saved.models.opus, "Highest");
assert.equal(saved.models.sonnet, "Highest");
assert.equal(saved.models.haiku, "Highest");
assert.equal(saved.models.smallFast, "Highest");
assert.equal(saved.models.subagent, "Highest");
});
it("keeps a per-tier value that was given explicitly", () => {
proxies.createProxy(nineRouter({ models: { opus: "PremiumCombo" } }));
const saved = proxies.getProxy("9router");
assert.equal(saved.models.opus, "PremiumCombo");
assert.equal(saved.models.sonnet, "Highest");
});
it("gives a second profile with the same label a free name", () => {
const a = proxies.createProxy(nineRouter());
const b = proxies.createProxy(nineRouter());
assert.notEqual(a.name, b.name);
});
it("rejects a label that is empty or carries control characters", () => {
assert.throws(() => proxies.createProxy(nineRouter({ label: " " })), { code: "EBADLABEL" });
assert.throws(() => proxies.createProxy(nineRouter({ label: "ab" })), {
code: "EBADLABEL",
});
});
it("rejects a base URL that is not http(s)", () => {
// A `file:` or `javascript:` base URL is never a proxy; it is either a
// mistake or an attempt to point the CLI somewhere it should not go.
assert.throws(() => proxies.createProxy(nineRouter({ baseUrl: "file:///etc/passwd" })), {
code: "EBADURL",
});
assert.throws(() => proxies.createProxy(nineRouter({ baseUrl: "not a url" })), {
code: "EBADURL",
});
});
it("refuses to shadow the built-in native name", () => {
assert.throws(() => proxies.createProxy(nineRouter({ label: "native" })), {
code: "EBUILTIN",
});
});
});
describe("ai-proxies — the key never reaches the browser", () => {
it("is absent from the list, which reports only that one is set", () => {
proxies.createProxy(nineRouter());
const { items } = proxies.listProxies();
const saved = items.find((i) => i.name === "9router");
assert.equal(saved.hasKey, true);
assert.equal(saved.apiKey, undefined);
assert.ok(!JSON.stringify(items).includes("sk-secret-value"));
});
it("is still absent when the profile has no key at all", () => {
proxies.createProxy(nineRouter({ apiKey: "" }));
const { items } = proxies.listProxies();
assert.equal(items.find((i) => i.name === "9router").hasKey, false);
});
});
describe("ai-proxies — update", () => {
it("changes the label without changing the name a lane already references", () => {
// A lane stores the name. Renaming the label must not orphan that lane.
const created = proxies.createProxy(nineRouter());
const updated = proxies.updateProxy(created.name, { label: "9Router (nhà)" });
assert.equal(updated.name, created.name);
assert.equal(updated.label, "9Router (nhà)");
});
it("keeps the stored key when the update omits it", () => {
// The edit form cannot show the key, so it cannot send it back either.
const created = proxies.createProxy(nineRouter());
proxies.updateProxy(created.name, { label: "renamed" });
assert.equal(proxies.getProxy(created.name).apiKey, "sk-secret-value");
});
it("replaces the key when a new one is given", () => {
const created = proxies.createProxy(nineRouter());
proxies.updateProxy(created.name, { apiKey: "sk-rotated" });
assert.equal(proxies.getProxy(created.name).apiKey, "sk-rotated");
});
it("reports a name that does not exist", () => {
assert.throws(() => proxies.updateProxy("ghost", { label: "x" }), { code: "ENOTFOUND" });
});
it("sets, keeps and clears behavesAs, and refuses an id that is not a Claude model's", () => {
const created = proxies.createProxy(nineRouter({ behavesAs: " claude-opus-5-5 " }));
assert.equal(created.behavesAs, "claude-opus-5-5");
assert.equal(
proxies.updateProxy(created.name, { label: "renamed" }).behavesAs,
"claude-opus-5-5"
);
assert.equal(proxies.updateProxy(created.name, { behavesAs: "" }).behavesAs, "");
for (const bad of ["agent", "claude opus", "-claude-x", 5]) {
assert.throws(() => proxies.updateProxy(created.name, { behavesAs: bad }), {
code: "EBADMODEL",
});
}
});
});
describe("ai-proxies — remove", () => {
it("deletes the file", () => {
const created = proxies.createProxy(nineRouter());
proxies.removeProxy(created.name);
assert.equal(fs.existsSync(path.join(PROXIES_DIR, `${created.name}.json`)), false);
assert.equal(proxies.listProxies().items.length, 1); // native only
});
it("reports a name that does not exist", () => {
assert.throws(() => proxies.removeProxy("ghost"), { code: "ENOTFOUND" });
});
});
describe("ai-proxies — a corrupt file is skipped, never fatal", () => {
it("lists the healthy profiles around it", () => {
proxies.createProxy(nineRouter());
fs.writeFileSync(path.join(PROXIES_DIR, "broken.json"), "{not json");
const { items } = proxies.listProxies();
assert.ok(items.some((i) => i.name === "9router"));
assert.ok(!items.some((i) => i.name === "broken"));
});
});
describe("ai-proxies — base URL is the root", () => {
it("drops a trailing /v1 on save", () => {
const created = proxies.createProxy(nineRouter());
assert.equal(created.baseUrl, "http://localhost:20128");
});
it("reads a /v1 an older build stored as the root", () => {
fs.mkdirSync(PROXIES_DIR, { recursive: true });
fs.writeFileSync(
path.join(PROXIES_DIR, "old.json"),
JSON.stringify({ ...nineRouter({ baseUrl: "http://localhost:20128/v1/" }), models: {} })
);
assert.equal(proxies.getProxy("old").baseUrl, "http://localhost:20128");
});
});
describe("ai-proxies — the pre-rename store", () => {
const LEGACY = path.join(process.env.CLAUDE_HOME, "agenthub-routers");
after(() => fs.rmSync(LEGACY, { recursive: true, force: true }));
it("moves agenthub-routers/ here once and leaves a symlink behind", () => {
fs.mkdirSync(LEGACY, { recursive: true });
fs.writeFileSync(path.join(LEGACY, "home.json"), JSON.stringify(nineRouter({ models: {} })));
assert.ok(proxies.listProxies().items.some((i) => i.name === "home"));
assert.ok(fs.existsSync(path.join(PROXIES_DIR, "home.json")));
assert.ok(fs.lstatSync(LEGACY).isSymbolicLink());
// An older build reading the old path still finds the profile.
assert.ok(fs.existsSync(path.join(LEGACY, "home.json")));
});
});
describe("ai-proxies — testProxy", () => {
const realFetch = global.fetch;
after(() => {
global.fetch = realFetch;
});
/** Capture the one request testProxy makes, and answer it however asked. */
function stubFetch(answer) {
const calls = [];
global.fetch = async (url, init) => {
calls.push({ url, init });
if (answer instanceof Error) throw answer;
return answer;
};
return calls;
}
const ok = () => ({ ok: true, status: 200, statusText: "OK", text: async () => "{}" });
it("probes Claude's /v1/messages and Codex's /v1/responses on the root", async () => {
const calls = stubFetch(ok());
const result = await proxies.testProxy(nineRouter());
assert.deepEqual(result.claude, { ok: true, status: 200, message: "Highest" });
assert.deepEqual(result.codex, { ok: true, status: 200, message: "Highest" });
const byUrl = Object.fromEntries(calls.map((c) => [c.url, c.init]));
// The typed `/v1` is dropped: one `/v1`, never two.
const claude = byUrl["http://localhost:20128/v1/messages"];
const codex = byUrl["http://localhost:20128/v1/responses"];
assert.equal(JSON.parse(claude.body).max_tokens, 1);
assert.equal(JSON.parse(claude.body).model, "Highest");
assert.equal(claude.headers["x-api-key"], "sk-secret-value");
assert.equal(JSON.parse(codex.body).model, "Highest");
assert.equal(codex.headers.authorization, "Bearer sk-secret-value");
});
it("reports a proxy that answered with an error rather than throwing", async () => {
stubFetch({ ok: false, status: 401, statusText: "Unauthorized", text: async () => "bad key" });
const result = await proxies.testProxy(nineRouter());
assert.deepEqual(result.claude, { ok: false, status: 401, message: "bad key" });
assert.deepEqual(result.codex, { ok: false, status: 401, message: "bad key" });
});
it("reports an endpoint that never answered", async () => {
// The shape node's own fetch throws: a useless outer message wrapping the
// one thing the user can act on.
stubFetch(
Object.assign(new Error("fetch failed"), {
cause: new Error("connect ECONNREFUSED 127.0.0.1:20199"),
})
);
const { claude } = await proxies.testProxy(nineRouter());
assert.equal(claude.ok, false);
assert.equal(claude.status, null);
assert.match(claude.message, /ECONNREFUSED/);
});
it("falls back to the stored key for the endpoint it was stored against", async () => {
const created = proxies.createProxy(nineRouter());
const calls = stubFetch(ok());
await proxies.testProxy({
name: created.name,
baseUrl: "http://localhost:20128/v1",
model: "Highest",
});
assert.equal(calls[0].init.headers["x-api-key"], "sk-secret-value");
});
it("never sends the stored key to a different endpoint", async () => {
const created = proxies.createProxy(nineRouter());
const calls = stubFetch(ok());
await assert.rejects(
proxies.testProxy({
name: created.name,
baseUrl: "https://attacker.example/v1",
model: "Highest",
}),
{ code: "EBADKEY" }
);
assert.equal(calls.length, 0);
});
it("refuses a base URL that is not http(s)", async () => {
await assert.rejects(proxies.testProxy(nineRouter({ baseUrl: "file:///etc/passwd" })), {
code: "EBADURL",
});
});
});
-288
View File
@@ -1,288 +0,0 @@
/**
* @file Tests for the AI Router profile store — the CRUD surface, the built-in
* native profile, and the guarantee that a stored API key never appears in a
* response the browser can read.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const os = require("node:os");
const path = require("node:path");
const fs = require("node:fs");
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-ai-routers-"));
process.env.CLAUDE_HOME = path.join(SUITE_ROOT, ".claude");
const { describe, it, beforeEach, after } = require("node:test");
const assert = require("node:assert/strict");
const routers = require("../lib/ai-routers");
const ROUTERS_DIR = path.join(process.env.CLAUDE_HOME, "agenthub-routers");
after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true }));
beforeEach(() => fs.rmSync(ROUTERS_DIR, { recursive: true, force: true }));
/** The smallest profile the API accepts. */
function nineRouter(over = {}) {
return {
label: "9Router",
baseUrl: "http://localhost:20128/v1",
apiKey: "sk-secret-value",
model: "Highest",
...over,
};
}
describe("ai-routers — the built-in native profile", () => {
it("is listed even before anything has been saved", () => {
const { items } = routers.listRouters();
assert.equal(items.length, 1);
assert.equal(items[0].name, "native");
assert.equal(items[0].builtin, true);
});
it("leaves the two connection variables empty rather than absent", () => {
// The reference native settings.json sets both to "" — an absent key would
// let a user-level router base URL leak into a lane meant to run native.
const native = routers.getRouter("native");
assert.equal(native.baseUrl, "");
assert.equal(native.apiKey, "");
});
it("refuses to be edited or removed", () => {
assert.throws(() => routers.updateRouter("native", { label: "mine" }), { code: "EBUILTIN" });
assert.throws(() => routers.removeRouter("native"), { code: "EBUILTIN" });
});
});
describe("ai-routers — create", () => {
it("saves a profile and derives its name from the label", () => {
const created = routers.createRouter(nineRouter());
assert.equal(created.name, "9Router".toLowerCase());
assert.equal(created.label, "9Router");
assert.ok(fs.existsSync(path.join(ROUTERS_DIR, `${created.name}.json`)));
});
it("stores the file 0600 inside a 0700 directory", () => {
const created = routers.createRouter(nineRouter());
const file = fs.statSync(path.join(ROUTERS_DIR, `${created.name}.json`));
const dir = fs.statSync(ROUTERS_DIR);
assert.equal(file.mode & 0o777, 0o600);
assert.equal(dir.mode & 0o777, 0o700);
});
it("fills every model slot from `model` when no per-tier value is given", () => {
// The 9Router reference sets all six slots to one symbolic value; typing it
// once must be enough.
routers.createRouter(nineRouter());
const saved = routers.getRouter("9router");
assert.equal(saved.models.opus, "Highest");
assert.equal(saved.models.sonnet, "Highest");
assert.equal(saved.models.haiku, "Highest");
assert.equal(saved.models.smallFast, "Highest");
assert.equal(saved.models.subagent, "Highest");
});
it("keeps a per-tier value that was given explicitly", () => {
routers.createRouter(nineRouter({ models: { opus: "PremiumCombo" } }));
const saved = routers.getRouter("9router");
assert.equal(saved.models.opus, "PremiumCombo");
assert.equal(saved.models.sonnet, "Highest");
});
it("gives a second profile with the same label a free name", () => {
const a = routers.createRouter(nineRouter());
const b = routers.createRouter(nineRouter());
assert.notEqual(a.name, b.name);
});
it("rejects a label that is empty or carries control characters", () => {
assert.throws(() => routers.createRouter(nineRouter({ label: " " })), { code: "EBADLABEL" });
assert.throws(() => routers.createRouter(nineRouter({ label: "ab" })), {
code: "EBADLABEL",
});
});
it("rejects a base URL that is not http(s)", () => {
// A `file:` or `javascript:` base URL is never a router; it is either a
// mistake or an attempt to point the CLI somewhere it should not go.
assert.throws(() => routers.createRouter(nineRouter({ baseUrl: "file:///etc/passwd" })), {
code: "EBADURL",
});
assert.throws(() => routers.createRouter(nineRouter({ baseUrl: "not a url" })), {
code: "EBADURL",
});
});
it("refuses to shadow the built-in native name", () => {
assert.throws(() => routers.createRouter(nineRouter({ label: "native" })), {
code: "EBUILTIN",
});
});
});
describe("ai-routers — the key never reaches the browser", () => {
it("is absent from the list, which reports only that one is set", () => {
routers.createRouter(nineRouter());
const { items } = routers.listRouters();
const saved = items.find((i) => i.name === "9router");
assert.equal(saved.hasKey, true);
assert.equal(saved.apiKey, undefined);
assert.ok(!JSON.stringify(items).includes("sk-secret-value"));
});
it("is still absent when the profile has no key at all", () => {
routers.createRouter(nineRouter({ apiKey: "" }));
const { items } = routers.listRouters();
assert.equal(items.find((i) => i.name === "9router").hasKey, false);
});
});
describe("ai-routers — update", () => {
it("changes the label without changing the name a lane already references", () => {
// A lane stores the name. Renaming the label must not orphan that lane.
const created = routers.createRouter(nineRouter());
const updated = routers.updateRouter(created.name, { label: "9Router (nhà)" });
assert.equal(updated.name, created.name);
assert.equal(updated.label, "9Router (nhà)");
});
it("keeps the stored key when the update omits it", () => {
// The edit form cannot show the key, so it cannot send it back either.
const created = routers.createRouter(nineRouter());
routers.updateRouter(created.name, { label: "renamed" });
assert.equal(routers.getRouter(created.name).apiKey, "sk-secret-value");
});
it("replaces the key when a new one is given", () => {
const created = routers.createRouter(nineRouter());
routers.updateRouter(created.name, { apiKey: "sk-rotated" });
assert.equal(routers.getRouter(created.name).apiKey, "sk-rotated");
});
it("reports a name that does not exist", () => {
assert.throws(() => routers.updateRouter("ghost", { label: "x" }), { code: "ENOTFOUND" });
});
it("sets, keeps and clears behavesAs, and refuses an id that is not a Claude model's", () => {
const created = routers.createRouter(nineRouter({ behavesAs: " claude-opus-5-5 " }));
assert.equal(created.behavesAs, "claude-opus-5-5");
assert.equal(
routers.updateRouter(created.name, { label: "renamed" }).behavesAs,
"claude-opus-5-5"
);
assert.equal(routers.updateRouter(created.name, { behavesAs: "" }).behavesAs, "");
for (const bad of ["agent", "claude opus", "-claude-x", 5]) {
assert.throws(() => routers.updateRouter(created.name, { behavesAs: bad }), {
code: "EBADMODEL",
});
}
});
});
describe("ai-routers — remove", () => {
it("deletes the file", () => {
const created = routers.createRouter(nineRouter());
routers.removeRouter(created.name);
assert.equal(fs.existsSync(path.join(ROUTERS_DIR, `${created.name}.json`)), false);
assert.equal(routers.listRouters().items.length, 1); // native only
});
it("reports a name that does not exist", () => {
assert.throws(() => routers.removeRouter("ghost"), { code: "ENOTFOUND" });
});
});
describe("ai-routers — a corrupt file is skipped, never fatal", () => {
it("lists the healthy profiles around it", () => {
routers.createRouter(nineRouter());
fs.writeFileSync(path.join(ROUTERS_DIR, "broken.json"), "{not json");
const { items } = routers.listRouters();
assert.ok(items.some((i) => i.name === "9router"));
assert.ok(!items.some((i) => i.name === "broken"));
});
});
describe("ai-routers — testRouter", () => {
const realFetch = global.fetch;
after(() => {
global.fetch = realFetch;
});
/** Capture the one request testRouter makes, and answer it however asked. */
function stubFetch(answer) {
const calls = [];
global.fetch = async (url, init) => {
calls.push({ url, init });
if (answer instanceof Error) throw answer;
return answer;
};
return calls;
}
const ok = () => ({ ok: true, status: 200, statusText: "OK", text: async () => "{}" });
it("posts one capped message to the endpoint's /v1/messages", async () => {
const calls = stubFetch(ok());
const result = await routers.testRouter(nineRouter());
assert.equal(result.ok, true);
assert.equal(calls.length, 1);
assert.equal(calls[0].url, "http://localhost:20128/v1/v1/messages");
const body = JSON.parse(calls[0].init.body);
assert.equal(body.max_tokens, 1);
assert.equal(body.model, "Highest");
assert.equal(calls[0].init.headers["x-api-key"], "sk-secret-value");
});
it("reports a router that answered with an error rather than throwing", async () => {
stubFetch({ ok: false, status: 401, statusText: "Unauthorized", text: async () => "bad key" });
const result = await routers.testRouter(nineRouter());
assert.deepEqual(result, { ok: false, status: 401, message: "bad key" });
});
it("reports an endpoint that never answered", async () => {
// The shape node's own fetch throws: a useless outer message wrapping the
// one thing the user can act on.
stubFetch(
Object.assign(new Error("fetch failed"), {
cause: new Error("connect ECONNREFUSED 127.0.0.1:20199"),
})
);
const result = await routers.testRouter(nineRouter());
assert.equal(result.ok, false);
assert.equal(result.status, null);
assert.match(result.message, /ECONNREFUSED/);
});
it("falls back to the stored key for the endpoint it was stored against", async () => {
const created = routers.createRouter(nineRouter());
const calls = stubFetch(ok());
await routers.testRouter({
name: created.name,
baseUrl: "http://localhost:20128/v1",
model: "Highest",
});
assert.equal(calls[0].init.headers["x-api-key"], "sk-secret-value");
});
it("never sends the stored key to a different endpoint", async () => {
const created = routers.createRouter(nineRouter());
const calls = stubFetch(ok());
await assert.rejects(
routers.testRouter({
name: created.name,
baseUrl: "https://attacker.example/v1",
model: "Highest",
}),
{ code: "EBADKEY" }
);
assert.equal(calls.length, 0);
});
it("refuses a base URL that is not http(s)", async () => {
await assert.rejects(routers.testRouter(nineRouter({ baseUrl: "file:///etc/passwd" })), {
code: "EBADURL",
});
});
});
+2 -2
View File
@@ -195,7 +195,7 @@ describe("enable / default", () => {
prompt: { mode: "flag" },
resume: false,
bypass: false,
routerCapable: false,
proxyCapable: false,
modelFlag: false,
defaultModel: null,
modelVendor: null,
@@ -203,7 +203,7 @@ describe("enable / default", () => {
install: null,
update: null,
});
assert.equal(v.tools.find((t) => t.id === "codex").routerCapable, true);
assert.equal(v.tools.find((t) => t.id === "codex").proxyCapable, true);
});
});
+1 -1
View File
@@ -607,7 +607,7 @@ describe("patchSettings — backup and atomicity", () => {
});
it("writes and backs up nothing when the patch changes nothing", () => {
// Re-attaching the same AI Router profile sends the same eight env keys
// Re-attaching the same AI Proxy profile sends the same eight env keys
// every time. With an unconditional backup that left a `.bak` per apply
// piling up inside the user's own project — a dozen untracked files in a
// minute of switching lanes.
+60
View File
@@ -384,3 +384,63 @@ describe("cc-profiles", () => {
assert.throws(() => profiles.listProfiles(), { code: "EBADJSON" });
});
});
// The state a running session can leave behind: `.claude.json` still names one
// account while the credentials store holds ANOTHER account's token. The token
// is what Claude Code actually uses, so its owner (asked of Anthropic by the
// route, passed in here) decides who is logged in.
describe("cc-profiles — identity and token disagree", () => {
/** Thắng and Victor saved; then Victor's token lands under Thắng's identity. */
function mixedLogin() {
login("uuid-t", "t@example.com");
profiles.saveProfile("thang");
login("uuid-v", "v@example.com");
profiles.saveProfile("victor");
login("uuid-t", "t@example.com");
fs.writeFileSync(
CREDS,
JSON.stringify({
claudeAiOauth: { accessToken: "at-v-new", refreshToken: "rt-v-new", expiresAt: 222 },
})
);
return { uuid: "uuid-v", email: "v@example.com" };
}
const stored = (name) => profiles.readProfile(name);
it("marks the token's owner active and reports the disagreement", () => {
const owner = mixedLogin();
const { items, active, mismatch } = profiles.listProfiles({ owner });
assert.deepEqual(
items.filter((p) => p.active).map((p) => p.name),
["victor"]
);
assert.equal(active.email, "v@example.com");
assert.equal(mismatch.configEmail, "t@example.com");
// Without an owner (offline) the config is all there is, as before.
assert.equal(profiles.listProfiles().mismatch, null);
});
it("a switch saves the outgoing token into its OWNER's profile, never the one config names", () => {
const owner = mixedLogin();
profiles.activateProfile("thang", { owner });
assert.equal(stored("victor").credentials.refreshToken, "rt-v-new");
assert.equal(stored("victor").account.emailAddress, "v@example.com");
assert.equal(stored("thang").credentials.refreshToken, "rt-uuid-t");
// And the switch itself landed: Thắng's token and identity, together.
assert.equal(JSON.parse(fs.readFileSync(CREDS, "utf8")).claudeAiOauth.accessToken, "at-uuid-t");
assert.equal(JSON.parse(fs.readFileSync(CONFIG, "utf8")).oauthAccount.accountUuid, "uuid-t");
});
it("switching to the owner's own profile keeps the live (newest) token and fixes the identity", () => {
const owner = mixedLogin();
profiles.activateProfile("victor", { owner });
assert.equal(JSON.parse(fs.readFileSync(CREDS, "utf8")).claudeAiOauth.refreshToken, "rt-v-new");
assert.equal(JSON.parse(fs.readFileSync(CONFIG, "utf8")).oauthAccount.accountUuid, "uuid-v");
assert.equal(stored("thang").credentials.refreshToken, "rt-uuid-t");
});
it("refuses to save a profile while they disagree", () => {
const owner = mixedLogin();
assert.throws(() => profiles.saveProfile(undefined, { owner }), { code: "EMISMATCH" });
});
});
@@ -0,0 +1,64 @@
/**
* @file Verifies db.js renames `dashboard_runs.router` (before the AI Router →
* AI Proxy rename) to `proxy` in place, keeping each run's value, so a resume
* still goes back to the proxy its session last ran on.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("path");
const fs = require("fs");
const os = require("os");
const Database = require("better-sqlite3");
let TEST_DB;
before(() => {
TEST_DB = path.join(os.tmpdir(), `dashboard-runs-router-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
const raw = new Database(TEST_DB);
raw.exec(`
CREATE TABLE dashboard_runs (
id TEXT PRIMARY KEY,
session_id TEXT,
mode TEXT,
cwd TEXT NOT NULL,
model TEXT,
permission_mode TEXT,
effort TEXT,
resume_session_id TEXT,
prompt_preview TEXT,
status TEXT NOT NULL,
exit_code INTEGER,
started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
ended_at TEXT,
router TEXT
);
`);
raw
.prepare(
"INSERT INTO dashboard_runs (id, session_id, cwd, status, router) VALUES (?, ?, ?, ?, ?)"
)
.run("agenthub-lane-1", "sess-1", "/tmp/x", "completed", "9router");
raw.close();
});
after(() => {
for (const suffix of ["", "-wal", "-shm", "-migrate.lock"]) {
fs.rmSync(TEST_DB + suffix, { force: true });
}
});
describe("dashboard_runs.router → proxy", () => {
it("renames the column and keeps the value", () => {
const { db } = require("../db");
const cols = db
.prepare("PRAGMA table_info(dashboard_runs)")
.all()
.map((c) => c.name);
assert.ok(cols.includes("proxy"));
assert.ok(!cols.includes("router"));
assert.equal(require("../lib/dashboard-runs").proxyForSession("sess-1"), "9router");
});
});
@@ -247,7 +247,7 @@ describe("GET /api/lanes/:id/git/raw", () => {
});
describe("GET /api/lanes/:id/git/file maps a secret to 403 ESECRET", () => {
it("answers 403 for the router's settings file", async () => {
it("answers 403 for the old proxy key file (settings.local.json)", async () => {
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "api.db");
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
process.env.DASHBOARD_LIVENESS_PROBE = "0";
+8 -5
View File
@@ -331,19 +331,22 @@ describe("/api/lanes", () => {
ws.close();
});
it("starts a session on the router it names, and refuses one the store lacks", async () => {
it("starts a session on the proxy it names, and refuses one the store lacks", async () => {
const tmux = require("../lib/tmux");
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-router-start-"));
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-proxy-start-"));
const id = (await request("POST", "/api/lanes", { cwd })).body.lane.id;
try {
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") throw new Error("no such session");
return "";
});
const bad = await request("POST", `/api/lanes/${id}/start`, { router: "nope" });
const bad = await request("POST", `/api/lanes/${id}/start`, { proxy: "nope" });
assert.equal(bad.status, 400);
assert.equal(bad.body.error.code, "EBADROUTER");
// The per-workspace route is gone: a router belongs to a session now.
assert.equal(bad.body.error.code, "EBADPROXY");
// The pre-rename field still names a proxy, for a client older than it.
const legacy = await request("POST", `/api/lanes/${id}/start`, { router: "nope" });
assert.equal(legacy.body.error.code, "EBADPROXY");
// The per-workspace route is gone: a proxy belongs to a session now.
const gone = await request("POST", `/api/lanes/${id}/router/apply`, { profile: "native" });
assert.equal(gone.status, 404);
} finally {
+23 -2
View File
@@ -44,6 +44,21 @@ describe("native-dialog", () => {
assert.ok(!args.slice(0, -2).join(" ").includes("shell script"));
});
it("gives the Windows dialog a foreground TopMost owner, so it does not open behind the browser", () => {
for (const kind of ["dir", "file"]) {
const [[cmd, args]] = nd.dialogCommands(kind, "C:\\x", "win32");
assert.equal(cmd, "powershell.exe");
assert.equal(args.at(-2), "-EncodedCommand");
const script = Buffer.from(args.at(-1), "base64").toString("utf16le");
assert.match(
script,
/\$o\.TopMost = \$true;[\s\S]*ShowWindow\(\$o\.Handle, 1\)[\s\S]*\$o\.Show\(\)[\s\S]*Front\(\$o\.Handle\)[\s\S]*\$d\.ShowDialog\(\$o\)/
);
assert.match(script, /AttachThreadInput/);
assert.ok(!script.includes("C:\\x"), "the folder travels in env, never the script");
}
});
it(
"answers the chosen path, null on Cancel, and falls through a missing program",
{
@@ -95,10 +110,16 @@ describe("native-dialog", () => {
it("tells a Cancel from a failure per program", () => {
assert.equal(nd.isCancel("zenity", ""), true);
assert.equal(nd.isCancel("zenity", "Gtk-Message: GtkDialog mapped without a transient parent"), true);
assert.equal(
nd.isCancel("zenity", "Gtk-Message: GtkDialog mapped without a transient parent"),
true
);
assert.equal(nd.isCancel("kdialog", "qt.qpa.xcb: could not connect to display :1"), false);
assert.equal(nd.isCancel("osascript", "execution error: User canceled. (-128)"), true);
assert.equal(nd.isCancel("osascript", "execution error: No user interaction allowed. (-1713)"), false);
assert.equal(
nd.isCancel("osascript", "execution error: No user interaction allowed. (-1713)"),
false
);
assert.equal(nd.isCancel("powershell.exe", ""), true);
assert.equal(nd.isCancel("powershell.exe", "Add-Type : Cannot add type"), false);
});
+93 -11
View File
@@ -455,14 +455,18 @@ describe("buildClient dependency install", () => {
return root;
}
// Records every npm invocation; `fails` names the subcommands that exit non-zero.
// A successful "run build" writes the dist/ the real Vite build would produce.
// Records every invocation — npm's as its args, node's as `node <script> …`;
// `fails` names the npm subcommands that exit non-zero. A successful vite
// build writes the dist/ the real one would produce.
function fakeExec(calls, fails = []) {
return (_cmd, args, cwd) => {
return (cmd, args, cwd) => {
if (cmd === "node") {
calls.push(`node ${path.basename(args[0])} ${args.slice(1).join(" ")}`);
if (args[1] === "build") fs.mkdirSync(path.join(cwd, "dist"), { recursive: true });
return true;
}
calls.push(args.join(" "));
if (fails.includes(args[0])) return false;
if (args[0] === "run") fs.mkdirSync(path.join(cwd, "dist"), { recursive: true });
return true;
return !fails.includes(args[0]);
};
}
@@ -489,10 +493,12 @@ describe("buildClient dependency install", () => {
const result = buildClient({ rt: RT, root: fakeRoot(), exec: fakeExec(calls, ["ci"]) });
assert.equal(result, "built");
assert.deepEqual(
calls.map((c) => c.split(" ")[0]),
["ci", "install", "run"]
);
assert.deepEqual(calls, [
"ci --no-audit --no-fund --include=dev --ignore-scripts",
"install --no-audit --no-fund --include=dev --ignore-scripts",
"node tsc -b",
"node vite.js build",
]);
});
it("installs devDependencies explicitly, because NODE_ENV=production omits them", () => {
@@ -521,11 +527,28 @@ describe("buildClient dependency install", () => {
assert.equal(result, "failed");
assert.equal(
calls.some((c) => c.startsWith("run")),
calls.some((c) => c.startsWith("node")),
false,
"the build must not run on a broken dependency tree"
);
});
it("builds with the running node, no lifecycle script and no PATH lookup (Windows)", () => {
// On Windows a script or a .bin shim runs through cmd.exe and needs `node`
// on PATH; the server's PATH lacked it, so esbuild's postinstall failed and
// every update kept the previous UI. The build here must equal the
// client's own "build" script, run without npm.
assert.equal(
JSON.parse(fs.readFileSync(path.join(__dirname, "..", "..", "client", "package.json")))
.scripts.build,
"tsc -b && vite build"
);
resetRuntime();
const calls = [];
buildClient({ rt: RT, root: fakeRoot(), exec: fakeExec(calls) });
assert.deepEqual(calls.slice(-2), ["node tsc -b", "node vite.js build"]);
assert.ok(calls.every((c) => !c.startsWith("run")));
});
});
describe("supersededBy — an old cache directory must not act", () => {
@@ -651,3 +674,62 @@ describe("pruneRetiredPluginRoots", () => {
assert.ok(fs.existsSync(sibling));
});
});
describe("npmCommand", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-npmcmd-"));
after(() => fs.rmSync(tmp, { recursive: true, force: true }));
const cliAt = (...parts) => {
const p = path.join(tmp, ...parts);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, "");
return p;
};
it("runs the node's own bundled npm (Windows layout), no shell, no PATH lookup", () => {
// The "'npm.cmd' is not recognized" failure: the update chain's PATH had no npm.
const cli = cliAt("win", "node_modules", "npm", "bin", "npm-cli.js");
const execPath = path.join(tmp, "win", "node.exe");
const npm = boot.npmCommand(["install"], { execPath, env: { Path: "C:\\other" } });
assert.equal(npm.file, execPath);
assert.deepEqual(npm.args, [cli, "install"]);
assert.equal(npm.shell, false);
// Windows env keys keep their own case — a second "PATH" key would be ambiguous.
assert.equal(npm.env.Path, [path.dirname(execPath), "C:\\other"].join(path.delimiter));
assert.equal(npm.env.PATH, undefined);
});
it("finds the Unix layout (bin/node + lib/node_modules/npm)", () => {
const cli = cliAt("unix", "lib", "node_modules", "npm", "bin", "npm-cli.js");
const execPath = path.join(tmp, "unix", "bin", "node");
const npm = boot.npmCommand(["-v"], { execPath, env: { PATH: "/usr/bin" } });
assert.deepEqual(npm.args, [
path.join(tmp, "unix", "bin", "..", "lib", "node_modules", "npm", "bin", "npm-cli.js"),
"-v",
]);
assert.ok(fs.existsSync(npm.args[0]) && path.resolve(npm.args[0]) === cli);
assert.equal(npm.env.PATH.split(path.delimiter)[0], path.dirname(execPath));
});
it("falls back to npm on PATH when the node has no bundled npm", () => {
const npm = boot.npmCommand(["-v"], { execPath: path.join(tmp, "bare", "node"), env: {} });
assert.equal(npm.file, process.platform === "win32" ? "npm.cmd" : "npm");
assert.deepEqual(npm.args, ["-v"]);
});
});
describe("entry scripts export before they run", () => {
// plugin-bootstrap.js run as the entry calls bootstrap() → plugin-open.js,
// which requires plugin-bootstrap back and gets whatever module.exports holds
// at that moment. Assigned after the entry block it was still {}, and v2.5.1's
// update died with "boot.npmCommand is not a function". Requiring the module
// (as every other test here does) never takes that path, hence this check.
for (const file of ["plugin-bootstrap.js", "plugin-open.js"]) {
it(file, () => {
const src = fs.readFileSync(path.join(__dirname, "..", "..", "scripts", file), "utf8");
const exportsAt = src.indexOf("\nmodule.exports = {");
const entryAt = src.indexOf("\nif (require.main === module)");
assert.ok(exportsAt > 0 && entryAt > 0, "both markers present");
assert.ok(exportsAt < entryAt, "module.exports must be assigned before the entry block");
});
}
});
@@ -1,8 +1,8 @@
/**
* @file Tests for starting one session on an AI Router profile — Claude Code's
* @file Tests for starting one session on an AI Proxy profile — Claude Code's
* `--settings=<file>` env file and the Codex `<name>.config.toml` layer, an
* edit or delete reaching those files, and the one-time cleanup of the old
* per-workspace router out of a workspace's `settings.local.json`.
* per-workspace proxy out of a workspace's `settings.local.json`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -10,7 +10,7 @@ const os = require("node:os");
const path = require("node:path");
const fs = require("node:fs");
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-router-apply-"));
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-proxy-apply-"));
process.env.CLAUDE_HOME = path.join(SUITE_ROOT, ".claude");
process.env.CODEX_HOME = path.join(SUITE_ROOT, ".codex");
process.env.DASHBOARD_DATA_DIR = path.join(SUITE_ROOT, "data");
@@ -19,27 +19,27 @@ process.env.DASHBOARD_DB_PATH = path.join(SUITE_ROOT, "dashboard.db");
const { describe, it, beforeEach, after } = require("node:test");
const assert = require("node:assert/strict");
const routers = require("../lib/ai-routers");
const proxies = require("../lib/ai-proxies");
const lanesLib = require("../lib/lanes");
const { db } = require("../db");
const {
resolveRouter,
resumeRouter,
routerForSpawn,
resolveProxy,
resumeProxy,
proxyForSpawn,
refreshProfileFiles,
removeProfileFiles,
clearLaneRouters,
} = require("../lib/router-apply");
clearLaneProxies,
} = require("../lib/proxy-apply");
const settingsFile = (name) =>
path.join(process.env.DASHBOARD_DATA_DIR, "router-settings", `${name}.json`);
path.join(process.env.DASHBOARD_DATA_DIR, "proxy-settings", `${name}.json`);
const layerFile = (name) => path.join(process.env.CODEX_HOME, `${name}.config.toml`);
after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true }));
beforeEach(() => {
for (const dir of [
path.join(process.env.CLAUDE_HOME, "agenthub-routers"),
path.join(process.env.CLAUDE_HOME, "agenthub-proxies"),
process.env.CODEX_HOME,
process.env.DASHBOARD_DATA_DIR,
]) {
@@ -48,7 +48,7 @@ beforeEach(() => {
});
function saveNineRouter() {
return routers.createRouter({
return proxies.createProxy({
label: "9Router",
baseUrl: "http://localhost:20128/v1",
apiKey: "sk-secret-value",
@@ -56,32 +56,32 @@ function saveNineRouter() {
});
}
const spawnFor = (tool, name) => routerForSpawn(tool, resolveRouter(tool, name));
const spawnFor = (tool, name) => proxyForSpawn(tool, resolveProxy(tool, name));
describe("resolveRouter", () => {
describe("resolveProxy", () => {
it("reads null, empty and native as the vendor's own API", () => {
for (const name of [null, undefined, "", "native"])
assert.equal(resolveRouter("claude", name), null);
assert.equal(resolveProxy("claude", name), null);
});
it("refuses a profile the store does not have", () => {
assert.throws(
() => resolveRouter("claude", "nope"),
(e) => e.code === "EBADROUTER"
() => resolveProxy("claude", "nope"),
(e) => e.code === "EBADPROXY"
);
});
it("refuses a router on a tool with no endpoint to point at one", () => {
it("refuses a proxy on a tool with no endpoint to point at one", () => {
const { name } = saveNineRouter();
assert.throws(
() => resolveRouter("antigravity", name),
() => resolveProxy("antigravity", name),
(e) => e.code === "EUNSUPPORTED"
);
assert.equal(resolveRouter("antigravity", "native"), null);
assert.equal(resolveProxy("antigravity", "native"), null);
});
});
describe("routerForSpawn — Claude", () => {
describe("proxyForSpawn — Claude", () => {
it("passes a 0600 settings file holding the eight env keys, never the key in argv", () => {
const { name } = saveNineRouter();
const { argv, env } = spawnFor("claude", name);
@@ -89,7 +89,7 @@ describe("routerForSpawn — Claude", () => {
assert.deepEqual(env, {});
assert.ok(!argv.join(" ").includes("sk-secret-value"));
const written = JSON.parse(fs.readFileSync(settingsFile(name), "utf8")).env;
assert.equal(written.ANTHROPIC_BASE_URL, "http://localhost:20128/v1");
assert.equal(written.ANTHROPIC_BASE_URL, "http://localhost:20128"); // Claude Code appends /v1/messages
assert.equal(written.ANTHROPIC_AUTH_TOKEN, "sk-secret-value");
for (const k of [
"ANTHROPIC_MODEL",
@@ -105,7 +105,7 @@ describe("routerForSpawn — Claude", () => {
});
it("leaves a blank key out instead of blanking the user's own setting", () => {
const { name } = routers.createRouter({
const { name } = proxies.createProxy({
label: "Local",
baseUrl: "http://127.0.0.1:20128/v1",
apiKey: "",
@@ -127,8 +127,8 @@ describe("routerForSpawn — Claude", () => {
assert.equal("modelPicker" in written, false);
});
it("maps every distinct router model name with behavesAs (issue #56)", () => {
const { name } = routers.createRouter({
it("maps every distinct proxy model name with behavesAs (issue #56)", () => {
const { name } = proxies.createProxy({
label: "9Router combo",
baseUrl: "http://localhost:20128/v1",
model: "agent",
@@ -148,7 +148,7 @@ describe("routerForSpawn — Claude", () => {
});
});
describe("routerForSpawn — Codex", () => {
describe("proxyForSpawn — Codex", () => {
it("writes a profile layer, never the base config, and hands the key over in env", () => {
const base = path.join(process.env.CODEX_HOME, "config.toml");
fs.mkdirSync(process.env.CODEX_HOME, { recursive: true });
@@ -156,19 +156,19 @@ describe("routerForSpawn — Codex", () => {
const { name } = saveNineRouter();
const { argv, env } = spawnFor("codex", name);
assert.deepEqual(argv, ["-p", name]);
assert.deepEqual(env, { AGENTHUB_ROUTER_KEY: "sk-secret-value" });
assert.deepEqual(env, { AGENTHUB_PROXY_KEY: "sk-secret-value" });
assert.equal(fs.readFileSync(base, "utf8"), 'approval_policy = "never"\n');
const layer = fs.readFileSync(layerFile(name), "utf8");
assert.match(layer, /model_provider = "9router"/);
assert.match(layer, /base_url = "http:\/\/localhost:20128\/v1"/);
assert.match(layer, /env_key = "AGENTHUB_ROUTER_KEY"/);
assert.match(layer, /env_key = "AGENTHUB_PROXY_KEY"/);
// Not "chat": codex-cli 0.149 refuses to load it at all.
assert.match(layer, /wire_api = "responses"/);
assert.ok(!layer.includes("sk-secret-value"));
});
it("names no env_key when the profile has no key", () => {
const { name } = routers.createRouter({
const { name } = proxies.createProxy({
label: "Local gateway",
baseUrl: "http://127.0.0.1:20128/v1",
apiKey: "",
@@ -184,7 +184,7 @@ describe("an edit or a delete reaches the session files", () => {
it("an edit rewrites the files that exist and creates none", () => {
const { name } = saveNineRouter();
spawnFor("claude", name);
routers.updateRouter(name, { apiKey: "sk-rotated" });
proxies.updateProxy(name, { apiKey: "sk-rotated" });
refreshProfileFiles(name);
const env = JSON.parse(fs.readFileSync(settingsFile(name), "utf8")).env;
assert.equal(env.ANTHROPIC_AUTH_TOKEN, "sk-rotated");
@@ -201,25 +201,25 @@ describe("an edit or a delete reaches the session files", () => {
});
});
describe("resumeRouter", () => {
it("goes back to the router the session last ran on, while it exists", () => {
describe("resumeProxy", () => {
it("goes back to the proxy the session last ran on, while it exists", () => {
const { name } = saveNineRouter();
const runs = require("../lib/dashboard-runs");
runs.recordRun({
id: "agenthub-lane-91",
sessionId: "sess-router-1",
sessionId: "sess-proxy-1",
mode: "interactive",
router: name,
proxy: name,
});
assert.equal(resumeRouter("sess-router-1"), name);
routers.removeRouter(name);
assert.equal(resumeRouter("sess-router-1"), null);
assert.equal(resumeRouter("sess-unknown"), null);
assert.equal(resumeProxy("sess-proxy-1"), name);
proxies.removeProxy(name);
assert.equal(resumeProxy("sess-proxy-1"), null);
assert.equal(resumeProxy("sess-unknown"), null);
});
});
describe("clearLaneRouters", () => {
it("takes the old router's keys out of a workspace's settings and clears the column", () => {
describe("clearLaneProxies", () => {
it("takes the old proxy's keys out of a workspace's settings and clears the column", () => {
const cwd = path.join(SUITE_ROOT, "old-lane");
const file = path.join(cwd, ".claude", "settings.local.json");
fs.mkdirSync(path.dirname(file), { recursive: true });
@@ -231,7 +231,7 @@ describe("clearLaneRouters", () => {
})
);
const lane = lanesLib.createLane({ title: "old", cwd, router_profile: "9router" });
clearLaneRouters();
clearLaneProxies();
const data = JSON.parse(fs.readFileSync(file, "utf8"));
assert.deepEqual(data.env, { MY_VAR: "1" });
assert.deepEqual(data.permissions, { allow: ["Bash(ls)"] });
@@ -247,7 +247,7 @@ describe("clearLaneRouters", () => {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, "{ not json");
const lane = lanesLib.createLane({ title: "broken", cwd, router_profile: "9router" });
clearLaneRouters();
clearLaneProxies();
assert.equal(fs.readFileSync(file, "utf8"), "{ not json");
assert.equal(
db.prepare("SELECT router_profile FROM lanes WHERE id = ?").get(lane.id).router_profile,
+14
View File
@@ -169,3 +169,17 @@ describe("pty-attach", () => {
assert.equal(killed, true);
});
});
describe("attachBinary on Windows", () => {
it("names tmux with its extension — node-pty there never adds `.exe`", () => {
// A bare `tmux` made node-pty answer "File not found" on every attach, so
// the browser's terminal opened, closed and reconnected every 1.5 s.
const saved = process.env.PATH;
process.env.PATH = "";
try {
assert.equal(ptyAttach.attachBinary("win32"), "tmux.exe");
} finally {
process.env.PATH = saved;
}
});
});
+22 -13
View File
@@ -213,7 +213,7 @@ describe("pty-run", () => {
});
pty.spawnRun({ laneId: 12, cwd: "/tmp/repo", tool: "codex" });
// Through argv, never as a shell assignment typed into the pane: this is
// also the path an AI Router's API key takes, and a typed line would be
// also the path an AI Proxy's API key takes, and a typed line would be
// rendered to every attached browser and land in the shell history.
const at = respawn.indexOf("-e");
assert.ok(at > -1, "expected a tmux -e environment flag");
@@ -309,7 +309,7 @@ describe("pty-run", () => {
}
});
it("spawnRun starts a session on ITS router: Claude by --settings, Codex by -p", () => {
it("spawnRun starts a session on ITS proxy: Claude by --settings, Codex by -p", () => {
let newSessionArgv = null;
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") {
@@ -320,7 +320,7 @@ describe("pty-run", () => {
if (args[0] === "new-session") newSessionArgv = args;
return "";
});
// The router store and the files a spawn writes live under these homes.
// The proxy store and the files a spawn writes live under these homes.
const saved = ["CLAUDE_HOME", "CODEX_HOME", "DASHBOARD_DATA_DIR"].map((k) => [
k,
process.env[k],
@@ -329,30 +329,30 @@ describe("pty-run", () => {
process.env.CODEX_HOME = path.join(TMP, "codex");
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
try {
const { name } = require("../lib/ai-routers").createRouter({
label: "Router",
const { name } = require("../lib/ai-proxies").createProxy({
label: "Proxy",
baseUrl: "http://localhost:20128/v1",
apiKey: "sk-secret-value",
model: "M",
});
pty.spawnRun({ laneId: 51, cwd: "/tmp/repo", router: name, initialPrompt: "hi" });
pty.spawnRun({ laneId: 51, cwd: "/tmp/repo", proxy: name, initialPrompt: "hi" });
const flag = newSessionArgv.find((a) => a.startsWith("--settings="));
assert.equal(flag, `--settings=${path.join(TMP, "data", "router-settings", `${name}.json`)}`);
assert.equal(flag, `--settings=${path.join(TMP, "data", "proxy-settings", `${name}.json`)}`);
assert.ok(!newSessionArgv.join(" ").includes("sk-secret-value"));
assert.equal(require("../lib/dashboard-runs").getRun("agenthub-lane-51").router, name);
assert.equal(require("../lib/dashboard-runs").getRun("agenthub-lane-51").proxy, name);
pty.spawnRun({ laneId: 52, cwd: "/tmp/repo", tool: "codex", router: name });
pty.spawnRun({ laneId: 52, cwd: "/tmp/repo", tool: "codex", proxy: name });
const at = newSessionArgv.indexOf("-p");
assert.deepEqual(newSessionArgv.slice(at, at + 2), ["-p", name]);
// The key rides tmux's -e, never the command line.
assert.ok(newSessionArgv.includes("AGENTHUB_ROUTER_KEY=sk-secret-value"));
assert.ok(newSessionArgv.includes("AGENTHUB_PROXY_KEY=sk-secret-value"));
pty.spawnRun({ laneId: 53, cwd: "/tmp/repo" });
assert.ok(!newSessionArgv.some((a) => a.startsWith("--settings=")));
assert.equal(require("../lib/dashboard-runs").getRun("agenthub-lane-53").router, null);
assert.equal(require("../lib/dashboard-runs").getRun("agenthub-lane-53").proxy, null);
assert.throws(
() => pty.spawnRun({ laneId: 54, cwd: "/tmp/repo", router: "nope" }),
(e) => e.code === "EBADROUTER"
() => pty.spawnRun({ laneId: 54, cwd: "/tmp/repo", proxy: "nope" }),
(e) => e.code === "EBADPROXY"
);
} finally {
for (const [k, v] of saved) {
@@ -499,6 +499,15 @@ describe("pty-run", () => {
return argv.slice(4);
}
it("runs a Windows pane's command itself — no sh there — and a Terminal as tmux's default shell", () => {
const argv = ["C:\\Users\\u\\.local\\bin\\claude.exe", "--model", "opus"];
assert.deepEqual(pty.paneArgv(argv, "win32"), argv);
assert.deepEqual(pty.paneArgv(null, "win32"), []);
assert.equal(pty.paneArgv(argv, "linux")[0], "sh");
assert.deepEqual(pty.paneArgv(argv, "linux").slice(-3), argv);
assert.match(pty.paneArgv(null, "linux")[2], /exec "\$SHELL" -l$/);
});
it("spawnRun starts the CLI only once the pane has its real size, argv untouched", () => {
// A claude drawn at tmux's birth size duplicated its banner on the first
// resize; the argv rides as positional parameters, never script text.
+106 -1
View File
@@ -3,7 +3,8 @@
* window mapped to the status bar's label/percent/reset, and a window that has
* reset since Codex last wrote reading as empty, and the Codex login's
* account + expiry read from `auth.json`; and a saved Claude profile asked
* with its own token only while it is valid, never refreshed.
* with its own token only while it is valid, never refreshed, and never shown
* with the usage of a token that belongs to a different account.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -83,6 +84,10 @@ test("a saved profile is asked with its own token only while valid, never refres
const asked = [];
process.env.CLAUDE_HOME = home;
global.fetch = async (url, opts) => {
if (url.endsWith("/profile")) {
// The fresh profile's token is its own account's.
return { ok: true, json: async () => ({ account: { uuid: "fresh" } }) };
}
asked.push(`${url} ${opts.headers.Authorization}`);
return {
status: 200,
@@ -107,3 +112,103 @@ test("a saved profile is asked with its own token only while valid, never refres
fs.rmSync(home, { recursive: true, force: true });
}
});
test("a profile holding another account's token shows EWRONGACCOUNT, not that account's usage", async () => {
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { quotas, __resetCache } = require("../lib/quota");
const home = fs.mkdtempSync(path.join(os.tmpdir(), "quota-owner-"));
const dir = path.join(home, "agenthub-profiles");
fs.mkdirSync(dir);
fs.writeFileSync(
path.join(dir, "victor.json"),
JSON.stringify({
label: "Victor",
account: { accountUuid: "victor", emailAddress: "victor@x.y" },
credentials: { accessToken: "tok", refreshToken: "r", expiresAt: Date.now() + 3600_000 },
})
);
const prev = { home: process.env.CLAUDE_HOME, fetch: global.fetch };
process.env.CLAUDE_HOME = home;
global.fetch = async (url) => ({
status: 200,
ok: true,
json: async () =>
url.endsWith("/profile")
? { account: { uuid: "thang" } }
: { five_hour: { utilization: 30, resets_at: null } },
});
try {
__resetCache();
const [victor] = (await quotas()).tools.filter((t) => t.id === "claude");
assert.strictEqual(victor.label, "Victor");
assert.strictEqual(victor.error, "EWRONGACCOUNT");
assert.deepStrictEqual(victor.windows, []);
} finally {
if (prev.home === undefined) delete process.env.CLAUDE_HOME;
else process.env.CLAUDE_HOME = prev.home;
global.fetch = prev.fetch;
__resetCache();
fs.rmSync(home, { recursive: true, force: true });
}
});
test("the live row is the account its token belongs to, not the one .claude.json names", async () => {
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { quotas, __resetCache } = require("../lib/quota");
const root = fs.mkdtempSync(path.join(os.tmpdir(), "quota-live-"));
const home = path.join(root, ".claude");
const dir = path.join(home, "agenthub-profiles");
fs.mkdirSync(dir, { recursive: true });
const save = (name, label, uuid) =>
fs.writeFileSync(
path.join(dir, `${name}.json`),
JSON.stringify({
label,
account: { accountUuid: uuid, emailAddress: `${name}@x.y` },
credentials: { accessToken: `tok-${name}`, refreshToken: "r", expiresAt: 1 },
})
);
save("thang", "Thắng", "uuid-t");
save("victor", "Victor", "uuid-v");
// Thắng's identity, Victor's token.
fs.writeFileSync(
path.join(home, ".credentials.json"),
JSON.stringify({ claudeAiOauth: { accessToken: "live-v", expiresAt: Date.now() + 3600_000 } })
);
fs.writeFileSync(
`${home}.json`,
JSON.stringify({ oauthAccount: { accountUuid: "uuid-t", emailAddress: "thang@x.y" } })
);
const prev = { home: process.env.CLAUDE_HOME, fetch: global.fetch };
process.env.CLAUDE_HOME = home;
global.fetch = async (url) => ({
status: 200,
ok: true,
json: async () =>
url.endsWith("/profile")
? { account: { uuid: "uuid-v", email: "victor@x.y" } }
: { five_hour: { utilization: 82, resets_at: null } },
});
try {
__resetCache();
const claude = (await quotas()).tools.filter((t) => t.id === "claude");
const live = claude.find((t) => t.active);
assert.strictEqual(live.label, "Victor");
assert.strictEqual(live.account, "victor@x.y");
assert.strictEqual(live.ownerUuid, undefined);
assert.deepStrictEqual(
claude.filter((t) => !t.active).map((t) => t.label),
["Thắng"]
);
} finally {
if (prev.home === undefined) delete process.env.CLAUDE_HOME;
else process.env.CLAUDE_HOME = prev.home;
global.fetch = prev.fetch;
__resetCache();
fs.rmSync(root, { recursive: true, force: true });
}
});
+27 -2
View File
@@ -1,13 +1,14 @@
/**
* @file Tests for server/lib/shell-path.js — login-shell PATH hydration with a
* stubbed spawner (noisy rc output, errors, opt-out, dedupe) plus one real
* `/bin/sh` run to prove the argv works end to end.
* `/bin/sh` run to prove the argv works end to end, and the UTF-8 locale
* default that keeps tmux from printing `_` for every non-ASCII character.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, beforeEach, afterEach } = require("node:test");
const assert = require("node:assert/strict");
const { hydrateShellPath, shellPath } = require("../lib/shell-path");
const { hydrateShellPath, shellPath, ensureUtf8Locale } = require("../lib/shell-path");
const M = "__AGENTHUB_PATH__";
const stub =
@@ -69,3 +70,27 @@ describe("hydrateShellPath", () => {
assert.ok(process.env.PATH.startsWith("/usr/bin:/bin"));
});
});
describe("ensureUtf8Locale", () => {
it("sets LC_CTYPE when no locale is set (a macOS LaunchAgent)", () => {
const env = {};
ensureUtf8Locale(env, "darwin");
assert.equal(env.LC_CTYPE, "en_US.UTF-8");
const linux = { LANG: "C" };
ensureUtf8Locale(linux, "linux");
assert.equal(linux.LC_CTYPE, "C.UTF-8");
});
it("leaves a UTF-8 locale, an explicit LC_ALL and Windows alone", () => {
for (const [env, platform] of [
[{ LANG: "vi_VN.UTF-8" }, "darwin"],
[{ LC_CTYPE: "en_US.utf8" }, "linux"],
[{ LC_ALL: "C" }, "darwin"],
[{}, "win32"],
]) {
const before = { ...env };
ensureUtf8Locale(env, platform);
assert.deepEqual(env, before);
}
});
});
+53
View File
@@ -28,6 +28,59 @@ describe("tmux wrapper", () => {
assert.equal(tmux.hasSession("agenthub-lane-1"), false);
});
it("newSession names a command that exited at once instead of tmux's set-option error", () => {
tmux.__setExecImpl((args) => {
if (args[0] === "new-session") return "";
throw Object.assign(new Error("Command failed: tmux " + args.join(" ")), { status: 1 });
});
assert.throws(
() =>
tmux.newSession({ name: "agenthub-lane-4", cwd: "/tmp/repo", argv: ["claude", "-p", "x"] }),
(err) =>
err.code === "EPANEEXITED" && /exited as soon as it started: claude$/.test(err.message)
);
});
it("newSession with no argv leaves tmux its default shell (no trailing --)", () => {
const calls = [];
tmux.__setExecImpl((args) => {
calls.push(args);
return "";
});
tmux.newSession({ name: "agenthub-lane-1", cwd: "C:\\repo", argv: [] });
assert.deepEqual(calls[0], ["new-session", "-d", "-s", "agenthub-lane-1", "-c", "C:\\repo"]);
});
it("names sessions bare on psmux, which has no `=name` targets, and keeps `=` on tmux", () => {
// psmux (the WinGet `tmux` on Windows) answers "no server running on
// session '=agenthub-lane-1'" for a session that is alive, so every
// `=` target read a live pane as gone.
const fake = (supportsExact) => {
const calls = [];
tmux.__setExecImpl(
(args) => {
calls.push(args);
if (args[0] === "list-sessions") return "agenthub-lane-1\n";
const t = args[args.indexOf("-t") + 1];
if (t && t.startsWith("=") && !supportsExact) throw new Error("no server running");
return "";
},
{ exact: null }
);
return calls;
};
let calls = fake(false);
assert.equal(tmux.hasSession("agenthub-lane-1"), true);
tmux.enableMouse("agenthub-lane-1");
assert.ok(calls.some((c) => c[0] === "set-option" && c[2] === "agenthub-lane-1"));
assert.ok(!calls.some((c) => c[0] === "set-option" && c[2].startsWith("=")));
calls = fake(true);
tmux.enableMouse("agenthub-lane-1");
assert.ok(calls.some((c) => c[0] === "set-option" && c[2] === "=agenthub-lane-1:"));
});
it("newSession builds the correct argv", () => {
const calls = [];
tmux.__setExecImpl((args) => {
+50
View File
@@ -0,0 +1,50 @@
/**
* @file Tests for server/lib/windows-hide.js: on win32 every child process,
* async or sync, is started with `windowsHide: true`; elsewhere nothing changes.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { test } = require("node:test");
const assert = require("node:assert/strict");
const { install } = require("../lib/windows-hide");
function fakeCp() {
const seen = [];
class ChildProcess {
spawn(options) {
seen.push(options);
}
}
const rec = (...args) => seen.push(args);
return { seen, cp: { ChildProcess, spawnSync: rec, execFileSync: rec, execSync: rec } };
}
test("win32: async spawns and every sync form get windowsHide", () => {
const { seen, cp } = fakeCp();
install(cp, "win32");
install(cp, "win32"); // idempotent: no double wrap
new cp.ChildProcess().spawn({ file: "git", windowsHide: false });
cp.execFileSync("git", ["status"]);
cp.execFileSync("git", ["status"], { cwd: "x" });
cp.execFileSync("git", { cwd: "x" });
cp.execSync("dir");
cp.spawnSync("git", ["log"], undefined);
assert.deepEqual(seen, [
{ file: "git", windowsHide: true },
["git", ["status"], { windowsHide: true }],
["git", ["status"], { cwd: "x", windowsHide: true }],
["git", { cwd: "x", windowsHide: true }],
["dir", { windowsHide: true }],
["git", ["log"], { windowsHide: true }],
]);
});
test("other platforms are left alone", () => {
const { cp } = fakeCp();
const before = { ...cp, spawn: cp.ChildProcess.prototype.spawn };
install(cp, "linux");
assert.equal(cp.execSync, before.execSync);
assert.equal(cp.ChildProcess.prototype.spawn, before.spawn);
});
+14 -6
View File
@@ -648,7 +648,9 @@ try {
} catch {
db.prepare("ALTER TABLE dashboard_runs ADD COLUMN tool TEXT NOT NULL DEFAULT 'claude'").run();
}
// Which AI Router profile this lane runs against (see server/lib/ai-routers.js).
// Which AI Proxy (then called AI Router) profile this lane ran against. Legacy:
// a proxy is a session's choice now, and proxy-apply.js's clearLaneProxies
// empties this column at every start.
// Nullable, and null means the same thing as 'native': the vendor's own API.
// Set at creation only — like `tool`, it is absent from lanes.js's PATCHABLE,
// because the router is already written into the lane's config on disk and a
@@ -687,13 +689,19 @@ try {
db.prepare("ALTER TABLE dashboard_runs ADD COLUMN worktree INTEGER NOT NULL DEFAULT 0").run();
}
// Migrate: add `router` to dashboard_runs — the AI Router profile the run was
// started on (NULL = native). A router is chosen per session now, and a resume
// that does not name one goes back to the router its session last ran on.
// Migrate: add `proxy` to dashboard_runs — the AI Proxy profile the run was
// started on (NULL = native). A proxy is chosen per session, and a resume that
// does not name one goes back to the proxy its session last ran on. The column
// was `router` before the AI Router → AI Proxy rename; it is renamed in place.
try {
db.prepare("SELECT router FROM dashboard_runs LIMIT 1").get();
db.prepare("SELECT proxy FROM dashboard_runs LIMIT 1").get();
} catch {
db.prepare("ALTER TABLE dashboard_runs ADD COLUMN router TEXT").run();
try {
db.prepare("SELECT router FROM dashboard_runs LIMIT 1").get();
db.prepare("ALTER TABLE dashboard_runs RENAME COLUMN router TO proxy").run();
} catch {
db.prepare("ALTER TABLE dashboard_runs ADD COLUMN proxy TEXT").run();
}
}
// Migrate: add stage-detection columns to lanes. Inference is never evidence —
+13 -5
View File
@@ -3,6 +3,9 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
// First, before any module destructures child_process: no console window per
// git/npm call on Windows (server/lib/windows-hide.js).
require("./lib/windows-hide").install();
if (!process.env.NODE_ENV) process.env.NODE_ENV = "production";
// A server started from inside a Claude Code session (the bootstrap fallback,
@@ -79,7 +82,7 @@ const importRouter = require("./routes/import");
const updatesRouter = require("./routes/updates");
const ccConfigRouter = require("./routes/cc-config");
const codexConfigRouter = require("./routes/codex-config");
const aiRoutersRouter = require("./routes/ai-routers");
const aiProxiesRouter = require("./routes/ai-proxies");
const gitCredentialsRouter = require("./routes/git-credentials");
const aiToolsRouter = require("./routes/ai-tools");
const runRouter = require("./routes/run");
@@ -130,7 +133,8 @@ function createApp() {
app.use("/api/updates", updatesRouter);
app.use("/api/cc-config", ccConfigRouter);
app.use("/api/codex-config", codexConfigRouter);
app.use("/api/ai-routers", aiRoutersRouter);
// `/api/ai-routers` is the pre-rename path, kept for a client older than it.
app.use(["/api/ai-proxies", "/api/ai-routers"], aiProxiesRouter);
app.use("/api/git-credentials", gitCredentialsRouter);
app.use("/api/ai-tools", aiToolsRouter);
app.use("/api/run", runRouter);
@@ -352,6 +356,10 @@ function startBackgroundServices() {
// One-time legacy-session backfill (a no-op once its marker file exists).
autoImportLegacySessions();
// Before any tmux call: without a UTF-8 locale every attach prints `_` for
// non-ASCII text (a macOS LaunchAgent sets none).
require("./lib/shell-path").ensureUtf8Locale();
// The service's PATH is whatever its unit captured at install time; read the
// login shell's so a CLI installed since is detected and reaches lane panes.
require("./lib/shell-path")
@@ -677,12 +685,12 @@ function startBackgroundServices() {
} catch (err) {
console.warn("dashboard-runs reconciliation failed:", err.message);
}
// A router is chosen per session now: take the old per-workspace one out of
// A proxy is chosen per session now: take the old per-workspace one out of
// each workspace's settings.local.json (a no-op once done).
try {
require("./lib/router-apply").clearLaneRouters();
require("./lib/proxy-apply").clearLaneProxies();
} catch (err) {
console.warn("clearing workspace routers failed:", err.message);
console.warn("clearing workspace proxies failed:", err.message);
}
}
@@ -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
+63 -11
View File
@@ -248,12 +248,14 @@ function freeName(base) {
* terminal.
*
* @param stored a profile as returned by `readProfile`
* @param opts.owner the live token's owner (`cc-usage.liveOwner`), when known
* @returns the live `claudeAiOauth` block, or null when this is not that account
*/
function liveCredentialsFor(stored) {
function liveCredentialsFor(stored, { owner = null } = {}) {
const live = readActiveAuth();
if (!live.oauth) return null;
const mine = identityOf(live).accountUuid;
// The token's owner when known: `.claude.json` can name another account.
const mine = owner?.uuid || identityOf(live).accountUuid;
const theirs = identityOf({ oauth: stored.credentials, account: stored.account }).accountUuid;
return mine && mine === theirs ? live.oauth : null;
}
@@ -269,8 +271,25 @@ function readProfile(name) {
* @returns `{ items, active }` — `items[].active` marks the one whose
* `accountUuid` matches the live config. Tokens are never included.
*/
function listProfiles() {
const active = identityOf(readActiveAuth());
function listProfiles({ owner = null } = {}) {
const live = readActiveAuth();
const configured = identityOf(live);
// The token is what Claude Code runs as; `.claude.json` is only a label, and
// a running session can leave it naming a different account.
const mismatch =
owner?.uuid && configured.accountUuid && owner.uuid !== configured.accountUuid
? { configEmail: configured.email, configUuid: configured.accountUuid }
: null;
const active = mismatch
? {
...configured,
accountUuid: owner.uuid,
email: owner.email || null,
displayName: null,
organization: null,
seatTier: null,
}
: configured;
let names = [];
try {
names = fs
@@ -304,7 +323,17 @@ function listProfiles() {
// Label first (that is what the UI shows), name as the tiebreaker so two
// profiles sharing a label still come back in a stable order.
items.sort((a, b) => a.label.localeCompare(b.label) || a.name.localeCompare(b.name));
return { items, active: active.accountUuid || active.email ? active : null };
// The owner's own saved identity reads better than the bare uuid + email.
const mine = mismatch && items.find((p) => p.active);
const shown = mine
? {
...active,
displayName: mine.displayName,
organization: mine.organization,
seatTier: mine.seatTier,
}
: active;
return { items, active: shown.accountUuid || shown.email ? shown : null, mismatch };
}
/**
@@ -321,7 +350,7 @@ function listProfiles() {
* afterwards instead of having to invent a filename up front.
* @returns `{ ok, name, label, ...identity }`
*/
function saveProfile(name) {
function saveProfile(name, { owner = null } = {}) {
const auth = readActiveAuth();
if (!auth.oauth) {
throw err(
@@ -329,6 +358,15 @@ function saveProfile(name) {
"no Claude account is logged in on this machine - run `claude auth login` first"
);
}
const configUuid = auth.account && auth.account.accountUuid;
if (owner?.uuid && configUuid && owner.uuid !== configUuid) {
throw err(
"EMISMATCH",
`the token logged in now belongs to ${owner.email || "another account"}, not ${
auth.account.emailAddress || configUuid
} - switch to that account's profile, or run \`claude auth login\` again`
);
}
const identity = identityOf(auth);
const readable = identity.displayName || (identity.email || "").split("@")[0] || "profile";
// An explicit name is both identifier and initial label; a derived one keeps
@@ -405,22 +443,36 @@ function renameProfile(name, label) {
* @param name Profile to activate.
* @returns `{ ok, name, savedOutgoing, written }`
*/
function activateProfile(name) {
const target = readProfile(name);
if (!target || !target.credentials) throw err("ENOTFOUND", `no profile named "${name}"`);
function activateProfile(name, { owner = null } = {}) {
if (!readProfile(name)?.credentials) throw err("ENOTFOUND", `no profile named "${name}"`);
const current = readActiveAuth();
let savedOutgoing = null;
if (current.oauth) {
const mine = listProfiles().items.find((p) => p.active);
const { items, mismatch } = listProfiles({ owner });
const mine = items.find((p) => p.active);
if (!mine) {
throw err(
"EUNSAVED",
"the account logged in right now is not saved as a profile - save it first, or its credentials are lost on switch"
);
}
if (mine.name !== name) savedOutgoing = saveProfile(mine.name).name;
if (mismatch) {
// The live token belongs to `mine`, but `.claude.json` names someone
// else: keep that profile's own identity and take only the token — the
// newest one that account has (a refresh retired the saved copy). Done
// even when `mine` IS the target, so the switch writes that newest token.
writeProfile(mine.name, {
...readProfile(mine.name),
credentials: current.oauth,
savedAt: Date.now(),
});
savedOutgoing = mine.name;
} else if (mine.name !== name) {
savedOutgoing = saveProfile(mine.name).name;
}
}
const target = readProfile(name);
const written = [];
const { creds: live, keychain } = readCredentials();

Some files were not shown because too many files have changed in this diff Show More