Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5fe5d51b1b | |||
| dd9b23106c | |||
| af014d9829 | |||
| f8acd5fc29 | |||
| be75572977 | |||
| 03ecc9e29a |
+2
-2
@@ -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. |
|
||||
|
||||
+4
-3
@@ -117,9 +117,10 @@ echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc # where the plugin put
|
||||
`node:sqlite`); a checkout still runs on Node 20. The `agenthub` launcher
|
||||
pins the node that installed it and falls back to `node` on `PATH` when a
|
||||
`brew upgrade` or `nvm` switch deletes that binary.
|
||||
- **Starts at login by itself (LaunchAgent).** The plugin bootstrap installs
|
||||
`~/Library/LaunchAgents/io.smartgift.agenthub.plist` (a checkout: `agenthub
|
||||
service install`), so the dashboard comes back after every login with no
|
||||
- **Starts at login once you turn it on (LaunchAgent).** Off by default; with
|
||||
Settings → About → **Start AgentHub automatically** on, the plugin bootstrap
|
||||
installs `~/Library/LaunchAgents/io.smartgift.agenthub.plist` (a checkout:
|
||||
the same switch, or `agenthub service install`), so the dashboard comes back after every login with no
|
||||
Claude Code session, and launchd restarts it if it dies. Lane panes survive
|
||||
a restart of the server. Its output goes to `~/Library/Logs/agenthub.log`.
|
||||
A LaunchAgent needs a login at the Mac itself; over SSH only, or when
|
||||
|
||||
@@ -203,7 +203,10 @@ agenthub service uninstall # disable and remove it
|
||||
On Linux, `agenthub service install` also tries `loginctl enable-linger` so the service
|
||||
starts on a cold boot, before anyone logs in; if that needs elevated rights on
|
||||
your machine it prints the `sudo loginctl enable-linger <user>` command to run
|
||||
once yourself. Plugin installs get the same persistence automatically — see
|
||||
once yourself. The same is a switch in the dashboard — Settings → About →
|
||||
**Start AgentHub automatically** (off by default; it enables or disables the
|
||||
unit and never stops the running server). Plugin installs get it only once
|
||||
that switch is on — see
|
||||
[Auto-start on boot](docs/PLUGINS.md#auto-start-on-boot) in `docs/PLUGINS.md`.
|
||||
|
||||
## The CLI
|
||||
|
||||
+4
-1
@@ -176,7 +176,10 @@ agenthub service uninstall # disable và gỡ bỏ
|
||||
`agenthub service install` cũng thử chạy `loginctl enable-linger` để service khởi
|
||||
động được cả khi cold boot (chưa ai login); nếu máy cần quyền cao hơn, lệnh
|
||||
này sẽ in ra `sudo loginctl enable-linger <user>` để bạn tự chạy một lần.
|
||||
Bản cài qua plugin cũng được tự khởi động tương tự — xem
|
||||
Trong dashboard cũng có công tắc tương ứng — Settings → About → **Tự động khởi
|
||||
động AgentHub** (mặc định tắt; chỉ enable/disable unit, không bao giờ dừng
|
||||
server đang chạy). Bản cài qua plugin chỉ được tự khởi động khi công tắc này
|
||||
bật — xem
|
||||
[Auto-start on boot](docs/PLUGINS.md#auto-start-on-boot) trong
|
||||
`docs/PLUGINS.md`.
|
||||
|
||||
|
||||
+11
-11
File diff suppressed because one or more lines are too long
+5
-3
@@ -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
@@ -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"
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -208,7 +208,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]) => (
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
@@ -671,7 +671,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 +707,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 +781,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();
|
||||
});
|
||||
});
|
||||
+33
-28
@@ -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.
|
||||
@@ -211,7 +211,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=""
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* @file AutostartSettings — Settings → About's "Start automatically" switch:
|
||||
* whether the dashboard starts at boot/login through the per-user service
|
||||
* (systemd `--user` unit, or a LaunchAgent on macOS), over
|
||||
* `GET/PUT /api/settings/autostart`. Off by default; a change never starts or
|
||||
* stops the running server.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type AutostartStatus } from "../../lib/api";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
|
||||
|
||||
export function AutostartSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [status, setStatus] = useState<AutostartStatus | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.settings.autostart
|
||||
.get()
|
||||
.then(setStatus)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const change = async (enabled: boolean) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
setStatus(await api.settings.autostart.set(enabled));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const note = !status
|
||||
? undefined
|
||||
: !status.available
|
||||
? t("autostart.unavailable")
|
||||
: status.foreign
|
||||
? t("autostart.foreign")
|
||||
: t(`autostart.desc_${status.kind}`);
|
||||
|
||||
return (
|
||||
<SettingsSection id="autostart" title={t("autostart.title")}>
|
||||
<SettingsSwitchRow
|
||||
label={t("autostart.label")}
|
||||
description={note}
|
||||
checked={!!status?.enabled}
|
||||
disabled={!status?.available || status.foreign || saving}
|
||||
onChange={(v) => void change(v)}
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"activityFeed": "Activity Feed",
|
||||
"agentBoard": "Kanban Board",
|
||||
"agents": "Agents",
|
||||
"analytics": "Analytics",
|
||||
"avg": "avg",
|
||||
"brand": "AgentHub",
|
||||
@@ -88,7 +89,6 @@
|
||||
}
|
||||
},
|
||||
"version": "Version",
|
||||
"query": "Query Explorer",
|
||||
"openMenu": "Open menu",
|
||||
"resizeSidebar": "Resize sidebar (double-click to reset)",
|
||||
"closeMenu": "Close menu",
|
||||
|
||||
@@ -303,6 +303,14 @@
|
||||
"typeToConfirm": "Type “{{phrase}}” to confirm:",
|
||||
"phrase": "clear all data"
|
||||
},
|
||||
"autostart": {
|
||||
"title": "Startup",
|
||||
"label": "Start AgentHub automatically",
|
||||
"desc_systemd": "Run the dashboard as a systemd --user service, so it starts at boot without Claude Code being opened. Turning it off keeps the running server up.",
|
||||
"desc_launchd": "Run the dashboard as a LaunchAgent, so it starts at login without Claude Code being opened. Turning it off keeps the running server up.",
|
||||
"unavailable": "Not available here: needs systemd --user on Linux, or a GUI login on macOS.",
|
||||
"foreign": "A service named agenthub that AgentHub did not write already exists — AgentHub leaves it alone."
|
||||
},
|
||||
"about": {
|
||||
"title": "About",
|
||||
"description": "Server runtime information.",
|
||||
@@ -569,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",
|
||||
@@ -584,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": {
|
||||
@@ -605,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",
|
||||
@@ -692,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"
|
||||
},
|
||||
@@ -784,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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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",
|
||||
@@ -88,7 +89,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",
|
||||
|
||||
@@ -303,6 +303,14 @@
|
||||
"typeToConfirm": "Gõ “{{phrase}}” để xác nhận:",
|
||||
"phrase": "xóa toàn bộ dữ liệu"
|
||||
},
|
||||
"autostart": {
|
||||
"title": "Khởi động",
|
||||
"label": "Tự động khởi động AgentHub",
|
||||
"desc_systemd": "Chạy dashboard bằng systemd --user service để nó tự bật khi khởi động máy, không cần mở Claude Code. Tắt đi thì server đang chạy vẫn giữ nguyên.",
|
||||
"desc_launchd": "Chạy dashboard bằng LaunchAgent để nó tự bật khi đăng nhập, không cần mở Claude Code. Tắt đi thì server đang chạy vẫn giữ nguyên.",
|
||||
"unavailable": "Không khả dụng ở đây: cần systemd --user trên Linux, hoặc phiên đăng nhập GUI trên macOS.",
|
||||
"foreign": "Đã có một service tên agenthub không do AgentHub tạo — AgentHub không đụng vào."
|
||||
},
|
||||
"about": {
|
||||
"title": "Giới thiệu",
|
||||
"description": "Thông tin runtime của máy chủ.",
|
||||
@@ -569,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",
|
||||
@@ -584,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": {
|
||||
@@ -605,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",
|
||||
@@ -692,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"
|
||||
},
|
||||
@@ -784,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": {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
+75
-44
@@ -1114,6 +1114,18 @@ export const api = {
|
||||
/** DELETE /api/settings/codex-hooks — AgentHub's own hooks only */
|
||||
remove: () => request<CodexHooksStatus>("/settings/codex-hooks", { method: "DELETE" }),
|
||||
},
|
||||
/** Start the dashboard at boot/login (systemd `--user` / LaunchAgent) — off
|
||||
* by default; a change never starts or stops the running server. */
|
||||
autostart: {
|
||||
/** GET /api/settings/autostart */
|
||||
get: () => request<AutostartStatus>("/settings/autostart"),
|
||||
/** PUT /api/settings/autostart */
|
||||
set: (enabled: boolean) =>
|
||||
request<AutostartStatus>("/settings/autostart", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ enabled }),
|
||||
}),
|
||||
},
|
||||
/** Opt-in secret redaction of stored payloads and the folders whose hooks
|
||||
* are dropped (`server/lib/privacy.js`). */
|
||||
privacy: {
|
||||
@@ -2087,13 +2099,6 @@ export const api = {
|
||||
}),
|
||||
},
|
||||
|
||||
// ──────────────────────────────── AI Router API ─────────────────────────────
|
||||
/** Named AI Router endpoints (9Router, OpenRouter, a self-hosted gateway) a
|
||||
* lane can be pointed at instead of the vendor's own API. Managed in the
|
||||
* **AI Router** tab of Settings; attached to a lane when the lane is created.
|
||||
*
|
||||
* No response here ever carries an API key — a saved profile reports only
|
||||
* `hasKey`. */
|
||||
/** AgentHub's own git credentials (issue #45), shared by every user of this
|
||||
* install. No response ever carries a password — only `hasToken`. */
|
||||
gitCredentials: {
|
||||
@@ -2207,37 +2212,44 @@ export const api = {
|
||||
return `${BASE}/ai-tools/${encodeURIComponent(id)}/icon${q}`;
|
||||
},
|
||||
},
|
||||
aiRouters: {
|
||||
/** GET /api/ai-routers - every profile, the built-in `native` one first. */
|
||||
list: () => request<{ items: AiRouter[] }>("/ai-routers"),
|
||||
/** POST /api/ai-routers - save a new profile. */
|
||||
create: (body: AiRouterInput) =>
|
||||
request<AiRouter>("/ai-routers", { method: "POST", body: JSON.stringify(body) }),
|
||||
// ──────────────────────────────── AI Proxy API ──────────────────────────────
|
||||
/** Named AI Proxy endpoints (9Router, OpenRouter, a self-hosted gateway) a
|
||||
* new session can run on instead of the vendor's own API. Managed in the
|
||||
* **AI Proxy** page of Settings; chosen per session (`proxy` on a start).
|
||||
*
|
||||
* No response here ever carries an API key — a saved profile reports only
|
||||
* `hasKey`. */
|
||||
aiProxies: {
|
||||
/** GET /api/ai-proxies - every profile, the built-in `native` one first. */
|
||||
list: () => request<{ items: AiProxy[] }>("/ai-proxies"),
|
||||
/** POST /api/ai-proxies - save a new profile. */
|
||||
create: (body: AiProxyInput) =>
|
||||
request<AiProxy>("/ai-proxies", { method: "POST", body: JSON.stringify(body) }),
|
||||
/**
|
||||
* PATCH /api/ai-routers/:name - change a saved profile. Omitting `apiKey`
|
||||
* PATCH /api/ai-proxies/:name - change a saved profile. Omitting `apiKey`
|
||||
* keeps the stored one, which is what the edit form always does: it cannot
|
||||
* display a key, so it has none to send back.
|
||||
*/
|
||||
update: (name: string, body: Partial<AiRouterInput>) =>
|
||||
request<AiRouter>(`/ai-routers/${encodeURIComponent(name)}`, {
|
||||
update: (name: string, body: Partial<AiProxyInput>) =>
|
||||
request<AiProxy>(`/ai-proxies/${encodeURIComponent(name)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
/**
|
||||
* POST /api/ai-routers/test - ask the endpoint itself whether it works,
|
||||
* before a profile that cannot reach anything is saved onto a lane.
|
||||
* POST /api/ai-proxies/test - ask the endpoint itself whether it works
|
||||
* for Claude Code and for Codex, before a session finds out it does not.
|
||||
*/
|
||||
test: (body: AiRouterTestInput) =>
|
||||
request<AiRouterTestResult>("/ai-routers/test", {
|
||||
test: (body: AiProxyTestInput) =>
|
||||
request<AiProxyTestResult>("/ai-proxies/test", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
/**
|
||||
* DELETE /api/ai-routers/:name - also removes the session files that
|
||||
* DELETE /api/ai-proxies/:name - also removes the session files that
|
||||
* copy its key.
|
||||
*/
|
||||
remove: (name: string) =>
|
||||
request<{ removed: string }>(`/ai-routers/${encodeURIComponent(name)}`, {
|
||||
request<{ removed: string }>(`/ai-proxies/${encodeURIComponent(name)}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
},
|
||||
@@ -3295,10 +3307,10 @@ export interface CcProfileUsage {
|
||||
* user + project scope in one response. */
|
||||
export type CcScope = "user" | "project" | "all";
|
||||
|
||||
/** The per-tier model names an AI Router profile carries. Each maps to one env
|
||||
* var Claude Code reads; a router that names its models once fills them all
|
||||
/** The per-tier model names an AI Proxy profile carries. Each maps to one env
|
||||
* var Claude Code reads; a proxy that names its models once fills them all
|
||||
* from the profile's `model`. */
|
||||
export interface AiRouterModels {
|
||||
export interface AiProxyModels {
|
||||
opus: string;
|
||||
sonnet: string;
|
||||
haiku: string;
|
||||
@@ -3306,7 +3318,6 @@ export interface AiRouterModels {
|
||||
subagent: string;
|
||||
}
|
||||
|
||||
/** One saved AI Router profile, as the API returns it — **never** with a key. */
|
||||
/** A saved git login, as the server reports it: never the password. */
|
||||
export interface GitCredential {
|
||||
protocol: "http" | "https";
|
||||
@@ -3350,8 +3361,8 @@ export interface AiTool {
|
||||
resume: boolean;
|
||||
/** Offers the per-run "Bypass permissions" switch. */
|
||||
bypass: boolean;
|
||||
/** Can use an AI Router profile (Claude, Codex); others are native only. */
|
||||
routerCapable: boolean;
|
||||
/** Can use an AI Proxy profile (Claude, Codex); others are native only. */
|
||||
proxyCapable: boolean;
|
||||
/** What Install runs; null when the catalog names no install. */
|
||||
install: { kind: "npm"; package: string } | { kind: "script"; url: string } | null;
|
||||
/** What Update runs; `self` is the tool's own updater with these args. */
|
||||
@@ -3409,37 +3420,47 @@ export interface AiToolJob {
|
||||
log: string;
|
||||
}
|
||||
|
||||
export interface AiRouter {
|
||||
/** Identifier, derived from the label. Stable: a lane stores this. */
|
||||
/** One saved AI Proxy profile, as the API returns it — **never** with a key. */
|
||||
export interface AiProxy {
|
||||
/** Identifier, derived from the label. Stable: a session's run row stores it. */
|
||||
name: string;
|
||||
label: string;
|
||||
/** True only for the read-only `native` profile (the vendor's own API). */
|
||||
builtin: boolean;
|
||||
/** Empty string for `native`, an http(s) URL otherwise. */
|
||||
/** Empty string for `native`, else the proxy's http(s) ROOT, without `/v1`. */
|
||||
baseUrl: string;
|
||||
/** Whether a key is stored. The key itself never leaves the server. */
|
||||
hasKey: boolean;
|
||||
/** "" for `native`, which has no model of its own. */
|
||||
model: string;
|
||||
models: AiRouterModels;
|
||||
/** Claude model id the router's own model names behave as; "" when unset. */
|
||||
/** Empty for `native`. */
|
||||
models: Partial<AiProxyModels>;
|
||||
/** Claude model id the proxy's own model names behave as; "" when unset. */
|
||||
behavesAs?: string;
|
||||
savedAt?: string;
|
||||
}
|
||||
|
||||
/** Outcome of POST /api/ai-routers/test. A router that answered with an error
|
||||
* is still a successful HTTP call: `ok` is the ROUTER's verdict, `status` is
|
||||
* its HTTP code (null when nothing answered at all), and `message` is its own
|
||||
* words — truncated server-side, never the key. */
|
||||
export interface AiRouterTestResult {
|
||||
/** One probe of POST /api/ai-proxies/test. A proxy that answered with an
|
||||
* error is still a successful HTTP call: `ok` is the PROXY's verdict, `status`
|
||||
* is its HTTP code (null when nothing answered at all), and `message` is its
|
||||
* own words — truncated server-side, never the key. */
|
||||
export interface AiProxyProbe {
|
||||
ok: boolean;
|
||||
status: number | null;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Body for POST /api/ai-routers/test. `name` lets the server reuse the stored
|
||||
/** Outcome of POST /api/ai-proxies/test: Claude Code's `/v1/messages` and
|
||||
* Codex's `/v1/responses`, probed side by side. */
|
||||
export interface AiProxyTestResult {
|
||||
claude: AiProxyProbe;
|
||||
codex: AiProxyProbe;
|
||||
}
|
||||
|
||||
/** Body for POST /api/ai-proxies/test. `name` lets the server reuse the stored
|
||||
* key for a profile being edited — but only while `baseUrl` still matches the
|
||||
* saved one, so a typed-in endpoint always needs a typed-in key. */
|
||||
export interface AiRouterTestInput {
|
||||
export interface AiProxyTestInput {
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
apiKey?: string;
|
||||
@@ -3447,14 +3468,14 @@ export interface AiRouterTestInput {
|
||||
}
|
||||
|
||||
/** Body accepted when creating or updating a profile. */
|
||||
export interface AiRouterInput {
|
||||
export interface AiProxyInput {
|
||||
label: string;
|
||||
baseUrl: string;
|
||||
/** Omit on update to keep the stored key. */
|
||||
apiKey?: string;
|
||||
model: string;
|
||||
/** Any tier left out is filled from `model`. */
|
||||
models?: Partial<AiRouterModels>;
|
||||
models?: Partial<AiProxyModels>;
|
||||
/** A Claude model id; "" clears it. Omit on update to keep the stored one. */
|
||||
behavesAs?: string;
|
||||
}
|
||||
@@ -4112,8 +4133,8 @@ export interface RunStartArgs {
|
||||
resumeLast?: boolean;
|
||||
/** Turn off the tool's permission prompts for THIS run (its catalog bypass args). */
|
||||
bypass?: boolean;
|
||||
/** AI Router profile THIS session runs on; omitted = native, or a resume's own. */
|
||||
router?: string;
|
||||
/** AI Proxy profile THIS session runs on; omitted = native, or a resume's own. */
|
||||
proxy?: string;
|
||||
}
|
||||
|
||||
/** A lane's tmux-backed terminal run — one per lane, id is the tmux session
|
||||
@@ -4334,6 +4355,16 @@ export interface RemoteSourceInput {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/** GET /api/settings/autostart. `available` false: no systemd `--user` /
|
||||
* launchd GUI session here; `foreign`: a unit named agenthub AgentHub did not
|
||||
* write, which it never touches. */
|
||||
export interface AutostartStatus {
|
||||
available: boolean;
|
||||
kind: "systemd" | "launchd";
|
||||
enabled: boolean;
|
||||
foreign: boolean;
|
||||
}
|
||||
|
||||
/** GET /api/settings/privacy. A field the environment set is `env_owned`
|
||||
* and cannot be saved from the dashboard. */
|
||||
export interface PrivacySettings {
|
||||
|
||||
@@ -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 */
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -104,6 +104,12 @@ export const SETTINGS_INDEX: {
|
||||
titleKey: "about.title",
|
||||
keywords: ["version", "server", "database", "uptime"],
|
||||
},
|
||||
{
|
||||
section: "about",
|
||||
titleKey: "autostart.label",
|
||||
keywords: ["autostart", "boot", "login", "systemd", "launchd", "service"],
|
||||
to: "#autostart",
|
||||
},
|
||||
{
|
||||
section: "appearance",
|
||||
titleKey: "sagi.title",
|
||||
@@ -253,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"] },
|
||||
|
||||
@@ -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>
|
||||
@@ -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,6 +1,6 @@
|
||||
/**
|
||||
* @file Settings.tsx
|
||||
* @description The install-wide settings, as three pages of the Settings nav (`SettingsHub.tsx`): `section="data"` — total cost, database overview, workspace housekeeping (dry run first, confirm before a real run), import, remote sources, push ingest (`components/settings/PushIngestSettings.tsx`), privacy (`components/settings/PrivacySettings.tsx`), export, this browser's preferences backup, session cleanup (whole days ≥ 1, a dry-run count in its confirm) and the danger zone (typed confirmation) —, `section="about"` — system information — and `section="notifications"` — browser notifications (per-browser, with a reset) and alerts (server-wide). Server info (`/api/settings/info`) is read only by Data and About, refreshed on activity at most every 10 s. Language, theme and the pet live on the Appearance page (`components/settings/AppearanceSettings.tsx`); per-tool settings live in that tool's page under `/settings/agents/<id>`.
|
||||
* @description The install-wide settings, as three pages of the Settings nav (`SettingsHub.tsx`): `section="data"` — total cost, database overview, workspace housekeeping (dry run first, confirm before a real run), import, remote sources, push ingest (`components/settings/PushIngestSettings.tsx`), privacy (`components/settings/PrivacySettings.tsx`), export, this browser's preferences backup, session cleanup (whole days ≥ 1, a dry-run count in its confirm) and the danger zone (typed confirmation) —, `section="about"` — the start-at-login switch (`AutostartSettings`) and system information — and `section="notifications"` — browser notifications (per-browser, with a reset) and alerts (server-wide). Server info (`/api/settings/info`) is read only by Data and About, refreshed on activity at most every 10 s. Language, theme and the pet live on the Appearance page (`components/settings/AppearanceSettings.tsx`); per-tool settings live in that tool's page under `/settings/agents/<id>`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -107,6 +107,7 @@ import { ImportHistory } from "../components/ImportHistory";
|
||||
import { RemoteSources } from "../components/RemoteSources";
|
||||
import { PushIngestSettings } from "../components/settings/PushIngestSettings";
|
||||
import { PrivacySettings } from "../components/settings/PrivacySettings";
|
||||
import { AutostartSettings } from "../components/settings/AutostartSettings";
|
||||
import { Skeleton } from "../components/Skeleton";
|
||||
import { AlertsNotifications } from "../components/AlertsNotifications";
|
||||
import {
|
||||
@@ -1121,6 +1122,9 @@ export function Settings({
|
||||
|
||||
{section === "about" && (
|
||||
<section id="about" className="scroll-mt-24">
|
||||
<div className="mb-4">
|
||||
<AutostartSettings />
|
||||
</div>
|
||||
<p className="text-xs text-fg-muted mb-4">{t("about.description")}</p>
|
||||
{sysInfo ? (
|
||||
<div className="card p-5">
|
||||
@@ -1225,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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -925,10 +925,11 @@ export function Workspace() {
|
||||
<div
|
||||
className={
|
||||
sessionView
|
||||
? // Cancels Layout's page padding; the 1.5rem left is the status bar's.
|
||||
"-m-5 flex h-[calc(100vh-1.5rem)] flex-col lg:-m-6"
|
||||
? // Cancels Layout's page padding; the 1.5rem left is the status bar's,
|
||||
// plus the drawer's 2.75rem menu bar below `md` (issue #61).
|
||||
"-m-5 flex h-[calc(100vh-1.5rem)] flex-col max-md:h-[calc(100vh-4.25rem)] lg:-m-6"
|
||||
: viewportLocked
|
||||
? "h-[calc(100vh-4rem)] lg:h-[calc(100vh-4.5rem)] flex flex-col gap-5"
|
||||
? "h-[calc(100vh-4rem)] max-md:h-[calc(100vh-6.75rem)] lg:h-[calc(100vh-4.5rem)] flex flex-col gap-5"
|
||||
: "space-y-5"
|
||||
}
|
||||
>
|
||||
|
||||
+22
-41
@@ -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: [] };
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -7109,6 +7109,58 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
class="scroll-mt-24"
|
||||
id="about"
|
||||
>
|
||||
<div
|
||||
class="mb-4"
|
||||
>
|
||||
<section
|
||||
class="card p-5 scroll-mt-24"
|
||||
id="autostart"
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
Startup
|
||||
</h3>
|
||||
<div
|
||||
class="mt-2"
|
||||
>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
>
|
||||
<p
|
||||
class="text-sm text-fg-secondary"
|
||||
>
|
||||
Start AgentHub automatically
|
||||
</p>
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
>
|
||||
Run the dashboard as a systemd --user service, so it starts at boot without Claude Code being opened. Turning it off keeps the running server up.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="shrink-0"
|
||||
>
|
||||
<button
|
||||
aria-checked="false"
|
||||
aria-label="Start AgentHub automatically"
|
||||
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50 bg-border-light ring-1 ring-inset ring-fg-muted/80"
|
||||
role="switch"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="inline-block h-4 w-4 rounded-full bg-white shadow transition-transform translate-x-0.5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<p
|
||||
class="text-xs text-fg-muted mb-4"
|
||||
>
|
||||
@@ -7181,7 +7233,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<p
|
||||
class="text-[10px] text-status-warning/90 mt-1"
|
||||
>
|
||||
UI build v2.4.0 (rebuild client to match)
|
||||
UI build v2.5.5 (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 }),
|
||||
@@ -334,6 +334,10 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
rotate: r({ token: "t", enabled: true, env_owned: false }),
|
||||
revoke: r({ enabled: false, env_owned: false }),
|
||||
},
|
||||
autostart: {
|
||||
get: r({ available: true, kind: "systemd", enabled: false, foreign: false }),
|
||||
set: r({ available: true, kind: "systemd", enabled: true, foreign: false }),
|
||||
},
|
||||
privacy: {
|
||||
get: r({
|
||||
redact_secrets: false,
|
||||
|
||||
+35
-20
@@ -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
|
||||
@@ -1735,6 +1735,15 @@ POST /api/settings/cleanup
|
||||
|
||||
Body `{ abandon_hours?, purge_days?, dry_run? }`. `abandon_hours` marks active sessions silent that long as `abandoned` (and completes their working agents); `purge_days` deletes ended sessions (with their events and agents) that started more than that many days ago. `purge_days` must be a whole number ≥ 1, else `400 INVALID_PURGE_DAYS` — a fraction would purge almost everything. `dry_run: true` answers the same counts (`abandoned`, `purged_sessions`, `purged_events`, `purged_agents`, plus `dry_run: true`) and changes nothing.
|
||||
|
||||
#### Start at boot/login
|
||||
|
||||
```http
|
||||
GET /api/settings/autostart
|
||||
PUT /api/settings/autostart
|
||||
```
|
||||
|
||||
Settings → About's **Start AgentHub automatically** switch. `GET` answers `{ available, kind, enabled, foreign }`: `kind` is `systemd` (a `--user` unit) or `launchd` (a LaunchAgent), `available` is false with no systemd `--user` / no macOS GUI login, `foreign` is true when a unit named `agenthub` exists that AgentHub did not write. `PUT { enabled: boolean }` (same-origin only) enables or disables the unit — writing one for the running server first when none exists — and answers the same shape. It never starts or stops the server. Off by default: a plugin install gets no unit until this is turned on. `400 EBADVALUE` for a non-boolean, `400 EUNAVAILABLE` with no service manager, `409 EFOREIGN` for a foreign unit.
|
||||
|
||||
---
|
||||
|
||||
### Tools
|
||||
@@ -2457,40 +2466,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)
|
||||
|
||||
@@ -2522,6 +2535,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
@@ -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
@@ -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.
|
||||
|
||||
+14
-4
@@ -126,12 +126,13 @@ 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.
|
||||
9. **Server** — on Linux with `systemd --user` available, install/refresh a
|
||||
`agenthub.service` unit — on macOS with a GUI login, the
|
||||
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
|
||||
`io.smartgift.agenthub` LaunchAgent (see [Auto-start on boot](#auto-start-on-boot)) — instead
|
||||
of a bare spawn, so the server is supervised and comes back after a reboot
|
||||
with no Claude Code session involved. Everywhere else (Windows, no
|
||||
@@ -242,7 +243,16 @@ throughout: a directory that will not delete is left alone, never an error.
|
||||
|
||||
### Auto-start on boot
|
||||
|
||||
On Linux, every plugin bootstrap (not just the first) installs/refreshes a
|
||||
Opt-in, and off by default: Settings → About → **Start AgentHub automatically**
|
||||
(`GET/PUT /api/settings/autostart`, `server/lib/autostart.js`). The switch IS
|
||||
the unit's enabled state — on writes a unit for the running server when none
|
||||
exists and enables it, off disables it; neither starts or stops the server. A
|
||||
bootstrap only installs or starts a unit that is enabled, never changes that
|
||||
state (`writeUserService({ enable })` keeps it on a rewrite), and with it off
|
||||
the server is the detached spawn below. An install that already had the unit
|
||||
enabled before this switch existed keeps it on.
|
||||
|
||||
While it is on, every plugin bootstrap (not just the first) installs/refreshes a
|
||||
systemd `--user` service (`~/.config/systemd/user/agenthub.service`, managed via
|
||||
`scripts/systemd-service.js` — shared with the checkout path's
|
||||
`agenthub service install`, see the README). Its `ExecStart`/`WorkingDirectory`
|
||||
|
||||
+88
-30
@@ -3,7 +3,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Agent Dashboard for Claude Code API
|
||||
version: 2.4.0
|
||||
version: 2.5.5
|
||||
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:
|
||||
@@ -12585,6 +12577,88 @@ paths:
|
||||
results:
|
||||
- id: src_a
|
||||
ok: true
|
||||
/api/settings/autostart:
|
||||
get:
|
||||
tags:
|
||||
- Settings
|
||||
summary: Whether the dashboard starts at boot/login
|
||||
description: The per-user service's enabled state (a systemd `--user` unit, or a LaunchAgent on macOS). Off by default.
|
||||
operationId: getAutostart
|
||||
responses:
|
||||
'200':
|
||||
description: Start-at-login state
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- available
|
||||
- kind
|
||||
- enabled
|
||||
- foreign
|
||||
properties:
|
||||
available:
|
||||
type: boolean
|
||||
description: False with no systemd --user (Linux) or GUI login (macOS)
|
||||
kind:
|
||||
type: string
|
||||
enum:
|
||||
- systemd
|
||||
- launchd
|
||||
enabled:
|
||||
type: boolean
|
||||
foreign:
|
||||
type: boolean
|
||||
description: A unit named agenthub exists that AgentHub did not write
|
||||
put:
|
||||
tags:
|
||||
- Settings
|
||||
summary: Turn start-at-login on or off
|
||||
description: Enables or disables the unit, writing one for the running server first when none exists. Never starts or stops the server. Same-origin only.
|
||||
operationId: setAutostart
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- enabled
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
responses:
|
||||
'200':
|
||||
description: The new state
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- available
|
||||
- kind
|
||||
- enabled
|
||||
- foreign
|
||||
properties:
|
||||
available:
|
||||
type: boolean
|
||||
description: False with no systemd --user (Linux) or GUI login (macOS)
|
||||
kind:
|
||||
type: string
|
||||
enum:
|
||||
- systemd
|
||||
- launchd
|
||||
enabled:
|
||||
type: boolean
|
||||
foreign:
|
||||
type: boolean
|
||||
description: A unit named agenthub exists that AgentHub did not write
|
||||
'400':
|
||||
description: 'EBADVALUE: `enabled` is not a boolean; EUNAVAILABLE: no systemd --user / launchd GUI session'
|
||||
'403':
|
||||
description: 'EBADORIGIN: another site asked'
|
||||
'409':
|
||||
description: 'EFOREIGN: a unit named agenthub that AgentHub did not write'
|
||||
/api/settings/codex-hooks:
|
||||
get:
|
||||
tags:
|
||||
@@ -12946,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':
|
||||
@@ -12970,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
|
||||
@@ -12996,7 +13070,6 @@ paths:
|
||||
- ok
|
||||
- codex_home
|
||||
- env_owned
|
||||
- routers
|
||||
properties:
|
||||
ok:
|
||||
type: boolean
|
||||
@@ -13006,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:
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.4.0",
|
||||
"version": "2.5.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "2.4.0",
|
||||
"version": "2.5.5",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.4.0",
|
||||
"version": "2.5.5",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
@@ -22,6 +22,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");
|
||||
|
||||
@@ -105,6 +105,26 @@ function unitIsActive(name) {
|
||||
return res.status === 0 && /^\s*state = running$/m.test(String(res.stdout || ""));
|
||||
}
|
||||
|
||||
/** Is our plist set to load at login — the Settings "start automatically"
|
||||
* switch. A `launchctl disable` override is what turns it off. */
|
||||
function unitIsEnabled(name) {
|
||||
if (!systemctlAvailable() || !unitIsOurs(name)) return false;
|
||||
const res = launchctl(["print-disabled", domain()]);
|
||||
const line = String(res.stdout || "")
|
||||
.split("\n")
|
||||
.find((l) => l.includes(`"${jobLabel(name)}"`));
|
||||
return !line || !/=>\s*(disabled|true)/.test(line);
|
||||
}
|
||||
|
||||
/** Enable or disable loading at login — never loads or unloads the job, so
|
||||
* the server answering the switch stays up either way. */
|
||||
function setUnitEnabled(name, on) {
|
||||
if (!systemctlAvailable()) return "unavailable";
|
||||
if (!unitIsOurs(name)) return "foreign";
|
||||
const res = launchctl([on ? "enable" : "disable", `${domain()}/${jobLabel(name)}`]);
|
||||
return res.status === 0 ? "ok" : "failed";
|
||||
}
|
||||
|
||||
/**
|
||||
* `argv` is the program and its arguments as separate strings (the systemd
|
||||
* twin's `execStart` is one line; a path with a space would split here).
|
||||
@@ -153,14 +173,15 @@ ${envLines}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the plist and clears any "disabled" override — and starts nothing.
|
||||
* Writes the plist and clears any "disabled" override (unless `enable:
|
||||
* false`, which leaves it as it is) — and starts nothing.
|
||||
* A loaded job keeps running its OLD definition until `restartUserService`;
|
||||
* an unloaded one is picked up at the next login. Same split, same reason, as
|
||||
* the systemd twin.
|
||||
*
|
||||
* @returns {"written"|"unchanged"|"foreign"|"unavailable"|"failed"}
|
||||
*/
|
||||
function writeUserService({ name, workingDir, argv, env = {} }) {
|
||||
function writeUserService({ name, workingDir, argv, env = {}, enable = true }) {
|
||||
if (!systemctlAvailable()) return "unavailable";
|
||||
const body = unitContent({ name, workingDir, argv, env });
|
||||
const target = unitPath(name);
|
||||
@@ -173,7 +194,7 @@ function writeUserService({ name, workingDir, argv, env = {} }) {
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(logPath()), { recursive: true });
|
||||
fs.writeFileSync(target, body, "utf8");
|
||||
launchctl(["enable", `${domain()}/${jobLabel(name)}`]);
|
||||
if (enable) launchctl(["enable", `${domain()}/${jobLabel(name)}`]);
|
||||
return "written";
|
||||
} catch {
|
||||
return "failed";
|
||||
@@ -229,6 +250,8 @@ module.exports = {
|
||||
unitIsStale,
|
||||
unitRoot,
|
||||
unitIsActive,
|
||||
unitIsEnabled,
|
||||
setUnitEnabled,
|
||||
writeUserService,
|
||||
restartUserService,
|
||||
installUserService,
|
||||
|
||||
+83
-49
@@ -8,11 +8,11 @@
|
||||
*
|
||||
* The worker installs runtime dependencies, removes hook entries left behind by
|
||||
* an older `npm run install-hooks` (they would double-count every event), puts
|
||||
* the `agenthub` CLI on PATH, and starts the dashboard server — as a systemd
|
||||
* `--user` service or a macOS LaunchAgent when available (see
|
||||
* `service-manager.js`), so it survives
|
||||
* reboots without Claude Code being opened first; otherwise a detached spawn
|
||||
* scoped to this session, same as before.
|
||||
* the `agenthub` CLI on PATH, and starts the dashboard server — through the
|
||||
* systemd `--user` service or macOS LaunchAgent (see `service-manager.js`)
|
||||
* only while the user has turned start-at-login on (Settings → About, OFF by
|
||||
* default), so it survives reboots without Claude Code being opened first;
|
||||
* otherwise a detached spawn scoped to this session.
|
||||
*
|
||||
* Everything writable lives under `~/.claude/agenthub/runtime/` — NOT in
|
||||
* the plugin cache, which Claude Code replaces wholesale on every plugin
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -738,13 +763,17 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
|
||||
// the restart is deferred to `pendingSystemdRestart` below, because
|
||||
// starting it now would kill this process mid-bootstrap. Otherwise
|
||||
// there is no cgroup to be reaped by, and install-and-start is fine.
|
||||
const result = systemdOwned
|
||||
? svc.writeUserService(unit)
|
||||
: svc.systemctlAvailable()
|
||||
? svc.installUserService(unit)
|
||||
: "unavailable";
|
||||
// Start-at-login is OPT-IN (Settings → About, `/api/settings/autostart`):
|
||||
// the unit is installed or started only while the user has it enabled,
|
||||
// and its enabled state is never changed here.
|
||||
const autostart = svc.unitIsEnabled("agenthub");
|
||||
let result = "off";
|
||||
if (systemdOwned) result = svc.writeUserService({ ...unit, enable: autostart });
|
||||
else if (autostart)
|
||||
result = needsServer ? svc.installUserService(unit) : svc.writeUserService(unit);
|
||||
else if (unitStale) svc.writeUserService({ ...unit, enable: false });
|
||||
if (result === "written" || result === "unchanged") {
|
||||
if (result === "written") svc.enableLinger();
|
||||
if (result === "written" && autostart) svc.enableLinger();
|
||||
// Only when the RUNNING process has to change. A stale unit beside a
|
||||
// healthy server is now corrected on disk and left to take effect at
|
||||
// the next start — restarting a working dashboard to fix a file that
|
||||
@@ -825,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")) {
|
||||
@@ -850,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,
|
||||
};
|
||||
|
||||
@@ -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"];
|
||||
}
|
||||
|
||||
@@ -29,10 +29,12 @@ 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";
|
||||
|
||||
function run(cmd, args, cwd, stdio) {
|
||||
const res = spawnSync(cmd, args, { cwd, stdio, shell: process.platform === "win32" });
|
||||
/** `cmd` is always NPM: run through `boot.npmCommand`, never a PATH lookup. */
|
||||
function run(_cmd, args, cwd, stdio) {
|
||||
const npm = boot.npmCommand(args);
|
||||
const res = spawnSync(npm.file, npm.args, { cwd, stdio, env: npm.env, shell: npm.shell });
|
||||
return res.status === 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -103,6 +103,22 @@ function unitRoot(name) {
|
||||
return match ? match[1].trim() : null;
|
||||
}
|
||||
|
||||
/** Is our unit enabled to start at boot/login — the Settings "start
|
||||
* automatically" switch. Costs a `systemctl` call. */
|
||||
function unitIsEnabled(name) {
|
||||
if (!systemctlAvailable() || !unitIsOurs(name)) return false;
|
||||
const res = execImpl(["is-enabled", `${name}.service`], { encoding: "utf8", stdio: "pipe" });
|
||||
return String(res.stdout || "").trim() === "enabled";
|
||||
}
|
||||
|
||||
/** Enable or disable our unit for boot — never starts or stops it, so the
|
||||
* server answering the switch stays up either way. */
|
||||
function setUnitEnabled(name, on) {
|
||||
if (!systemctlAvailable()) return "unavailable";
|
||||
if (!unitIsOurs(name)) return "foreign";
|
||||
return systemctl([on ? "enable" : "disable", `${name}.service`]).status === 0 ? "ok" : "failed";
|
||||
}
|
||||
|
||||
/** Is the unit currently running? Costs a `systemctl` call — never call it
|
||||
* from the fast path. */
|
||||
function unitIsActive(name) {
|
||||
@@ -138,14 +154,15 @@ function systemctl(args) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes (or refreshes) the unit file and enables it for boot — but starts
|
||||
* Writes (or refreshes) the unit file and enables it for boot (unless
|
||||
* `enable: false`, which leaves the enabled state as it is) — but starts
|
||||
* nothing. Split out of `installUserService` for the plugin self-update, which
|
||||
* must be able to point the unit at the new cache directory *before* anything
|
||||
* stops the running server, and defer the restart to its very last act.
|
||||
*
|
||||
* @returns {"written"|"unchanged"|"foreign"|"unavailable"|"failed"}
|
||||
*/
|
||||
function writeUserService({ name, description, workingDir, execStart, env = {} }) {
|
||||
function writeUserService({ name, description, workingDir, execStart, env = {}, enable = true }) {
|
||||
if (!systemctlAvailable()) return "unavailable";
|
||||
const body = unitContent({ description, workingDir, execStart, env });
|
||||
const target = unitPath(name);
|
||||
@@ -158,7 +175,7 @@ function writeUserService({ name, description, workingDir, execStart, env = {} }
|
||||
fs.mkdirSync(unitDir(), { recursive: true });
|
||||
fs.writeFileSync(target, body, "utf8");
|
||||
systemctl(["daemon-reload"]);
|
||||
systemctl(["enable", `${name}.service`]);
|
||||
if (enable) systemctl(["enable", `${name}.service`]);
|
||||
return "written";
|
||||
} catch {
|
||||
return "failed";
|
||||
@@ -231,6 +248,8 @@ module.exports = {
|
||||
unitIsStale,
|
||||
unitRoot,
|
||||
unitIsActive,
|
||||
unitIsEnabled,
|
||||
setUnitEnabled,
|
||||
writeUserService,
|
||||
restartUserService,
|
||||
installUserService,
|
||||
|
||||
+2
-2
@@ -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 |
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* @file Tests server/lib/autostart.js (Settings → About's "Start AgentHub
|
||||
* automatically"): off by default, on writes + enables a unit for this server
|
||||
* without starting it, off disables without stopping, and a foreign unit is
|
||||
* never touched. Also the bootstrap's half: `writeUserService({enable:false})`
|
||||
* leaves the enabled state alone. systemctl is faked; HOME is a temp dir.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, beforeEach, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-autostart-"));
|
||||
const prevHome = process.env.HOME;
|
||||
process.env.HOME = TMP_HOME;
|
||||
|
||||
const svc = require("../../scripts/systemd-service");
|
||||
const autostart = require("../lib/autostart");
|
||||
svc.enableLinger = () => true; // the real one runs loginctl, outside the seam
|
||||
|
||||
let calls = [];
|
||||
let enabled = false;
|
||||
|
||||
beforeEach(() => {
|
||||
calls = [];
|
||||
enabled = false;
|
||||
fs.rmSync(svc.unitPath("agenthub"), { force: true });
|
||||
svc.__setExecImpl((args) => {
|
||||
if (args[0] === "--version") return { status: 0 };
|
||||
calls.push(args.join(" "));
|
||||
if (args[0] === "is-enabled")
|
||||
return { status: 0, stdout: enabled ? "enabled\n" : "disabled\n" };
|
||||
if (args[0] === "enable") enabled = true;
|
||||
if (args[0] === "disable") enabled = false;
|
||||
return { status: 0 };
|
||||
});
|
||||
});
|
||||
|
||||
after(() => {
|
||||
svc.__reset();
|
||||
if (prevHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = prevHome;
|
||||
fs.rmSync(TMP_HOME, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const linuxOnly = { skip: process.platform !== "linux" ? "systemd is Linux-only" : false };
|
||||
const startsOrStops = (c) => /^(start|stop|restart)\b|--now/.test(c);
|
||||
|
||||
describe("autostart", linuxOnly, () => {
|
||||
it("is off with no unit, and turning it off writes nothing", () => {
|
||||
assert.equal(autostart.status().enabled, false);
|
||||
autostart.setEnabled(false);
|
||||
assert.equal(fs.existsSync(svc.unitPath("agenthub")), false);
|
||||
});
|
||||
|
||||
it("on writes a unit for this server and enables it, starting nothing", () => {
|
||||
const res = autostart.setEnabled(true);
|
||||
assert.equal(res.enabled, true);
|
||||
const body = fs.readFileSync(svc.unitPath("agenthub"), "utf8");
|
||||
assert.match(
|
||||
body,
|
||||
new RegExp(`^WorkingDirectory=${path.resolve(__dirname, "..", "..")}$`, "m")
|
||||
);
|
||||
assert.ok(calls.includes("enable agenthub.service"));
|
||||
assert.ok(!calls.some(startsOrStops), JSON.stringify(calls));
|
||||
});
|
||||
|
||||
it("off disables our unit and keeps it, stopping nothing", () => {
|
||||
autostart.setEnabled(true);
|
||||
calls = [];
|
||||
const res = autostart.setEnabled(false);
|
||||
assert.equal(res.enabled, false);
|
||||
assert.ok(calls.includes("disable agenthub.service"));
|
||||
assert.ok(!calls.some(startsOrStops), JSON.stringify(calls));
|
||||
assert.ok(fs.existsSync(svc.unitPath("agenthub")));
|
||||
});
|
||||
|
||||
it("refuses a unit it did not write", () => {
|
||||
fs.mkdirSync(path.dirname(svc.unitPath("agenthub")), { recursive: true });
|
||||
fs.writeFileSync(svc.unitPath("agenthub"), "[Service]\nExecStart=/opt/mine\n", "utf8");
|
||||
assert.equal(autostart.status().foreign, true);
|
||||
assert.throws(() => autostart.setEnabled(true), { code: "EFOREIGN" });
|
||||
assert.equal(
|
||||
fs.readFileSync(svc.unitPath("agenthub"), "utf8"),
|
||||
"[Service]\nExecStart=/opt/mine\n"
|
||||
);
|
||||
});
|
||||
|
||||
it("writeUserService({enable:false}) repoints without enabling — the bootstrap's off path", () => {
|
||||
svc.writeUserService({
|
||||
name: "agenthub",
|
||||
description: "x",
|
||||
workingDir: "/plugin/root",
|
||||
execStart: "/usr/bin/node /plugin/root/server/index.js",
|
||||
enable: false,
|
||||
});
|
||||
assert.ok(!calls.some((c) => c.startsWith("enable")), JSON.stringify(calls));
|
||||
assert.equal(svc.unitIsEnabled("agenthub"), false);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -651,3 +651,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,
|
||||
@@ -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) {
|
||||
|
||||
@@ -28,6 +28,19 @@ 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 builds the correct argv", () => {
|
||||
const calls = [];
|
||||
tmux.__setExecImpl((args) => {
|
||||
|
||||
@@ -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
@@ -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 —
|
||||
|
||||
+9
-5
@@ -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);
|
||||
@@ -677,12 +681,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,
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* @file autostart.js
|
||||
* @description Settings → About's "Start automatically" switch: whether the
|
||||
* dashboard starts at boot/login through the per-user service
|
||||
* (`scripts/service-manager.js` — a systemd `--user` unit, or a LaunchAgent on
|
||||
* macOS). The switch IS the unit's enabled state, nothing stored beside it.
|
||||
* Turning it on writes a unit for THIS server when none exists, and enables
|
||||
* it; turning it off disables it. Neither ever starts or stops anything — the
|
||||
* server answering the click is the one that would die. Off by default: the
|
||||
* plugin bootstrap installs no unit until the user opts in.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const svc = require("../../scripts/service-manager");
|
||||
|
||||
const NAME = "agenthub";
|
||||
const ROOT = path.resolve(__dirname, "..", "..");
|
||||
|
||||
/** A unit by our name that AgentHub did not write — never ours to touch. */
|
||||
function isForeign() {
|
||||
return fs.existsSync(svc.unitPath(NAME)) && !svc.unitIsOurs(NAME);
|
||||
}
|
||||
|
||||
function status() {
|
||||
const available = svc.systemctlAvailable();
|
||||
return {
|
||||
available,
|
||||
kind: svc.kind,
|
||||
enabled: available && svc.unitIsEnabled(NAME),
|
||||
foreign: isForeign(),
|
||||
};
|
||||
}
|
||||
|
||||
/** The unit for the running server — same shape the bootstrap writes. */
|
||||
function unitForThisServer() {
|
||||
const entry = path.join(ROOT, "server", "index.js");
|
||||
const env = { NODE_ENV: "production", PATH: process.env.PATH || "" };
|
||||
for (const key of ["NODE_PATH", "DASHBOARD_CLIENT_DIST", "DASHBOARD_PORT"]) {
|
||||
if (process.env[key]) env[key] = process.env[key];
|
||||
}
|
||||
return {
|
||||
name: NAME,
|
||||
description: "AgentHub dashboard server",
|
||||
workingDir: ROOT,
|
||||
execStart: `${process.execPath} ${entry}`,
|
||||
argv: [process.execPath, entry],
|
||||
env,
|
||||
};
|
||||
}
|
||||
|
||||
function fail(code, message) {
|
||||
return Object.assign(new Error(message), { code });
|
||||
}
|
||||
|
||||
function setEnabled(on) {
|
||||
if (!svc.systemctlAvailable()) {
|
||||
throw fail("EUNAVAILABLE", `no ${svc.label} manager is available on this machine`);
|
||||
}
|
||||
let result;
|
||||
if (svc.unitIsOurs(NAME)) result = svc.setUnitEnabled(NAME, on);
|
||||
else if (isForeign()) result = "foreign";
|
||||
else if (!on)
|
||||
result = "ok"; // nothing installed: already off
|
||||
else {
|
||||
result = svc.writeUserService(unitForThisServer()); // writes + enables, starts nothing
|
||||
if (result === "written") svc.enableLinger();
|
||||
}
|
||||
if (result === "foreign") {
|
||||
throw fail("EFOREIGN", `${svc.unitPath(NAME)} was not written by AgentHub — not touching it`);
|
||||
}
|
||||
if (result === "failed" || result === "unavailable") {
|
||||
throw fail("ESERVICE", `could not change the ${svc.label}`);
|
||||
}
|
||||
return status();
|
||||
}
|
||||
|
||||
module.exports = { status, setEnabled };
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user