From 0a86dfb8c5be13ae4f7d19d6d3bb76abd93abd56 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Mon, 14 Sep 2026 16:04:57 +0700 Subject: [PATCH] feat(settings): add AI Router profiles and attach one per lane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new **AI Router** tab in Settings, next to Codex, stores named router endpoints (9Router, OpenRouter, a self-hosted gateway) as profiles: a base URL, an API key, a default model and an optional per-tier override. The tab only stores them; a lane CHOOSES one in the Add-lane modal, via a Native / AI Router radio, and that choice is written into the lane's own config. The two CLIs are configured differently, and the difference is theirs: - a claude lane gets eight `env` keys merged into its own `.claude/settings.local.json` (`.local` because the value holds a key); - a codex lane gets `$CODEX_HOME/.config.toml` — the layer `codex -p ` loads — because Codex has NO project-level config. Its base `config.toml` is never touched. Native is a built-in profile rather than a code branch, so "go back to native" is the same write path. It blanks the two connection variables instead of removing them (an absent key at project scope lets a USER-level router apply) and removes the model names instead of writing them (writing them would override the user's own global model choice). Key material never leaves the server: profiles live one file each under `/agenthub-routers/` (0700/0600), `GET /api/ai-routers` reports only `hasKey`, and there is deliberately no route returning one profile in full. Three things this had to get right, each found by review and verified against the real tools rather than assumed: - `wire_api = "chat"` is rejected outright by codex-cli 0.149 ("no longer supported"), so every codex lane on a router would have failed to start. The layer writes "responses", and writes `env_key` only when the profile actually has a key — naming an unset variable is itself a startup error. - The key must never be TYPED into a pane. `tmux.sendCommand` used `send-keys` with a `KEY='…' cmd` prefix, which the dashboard streams to every attached browser and bash appends to history; it now takes over the idle pane with `respawn-pane -k -e`, passing env through argv the way `newSession` always has. Verified on tmux 3.4: multi-element argv arrives intact and the pane renders no trace of the value. - Attaching COPIES a profile, so editing or deleting one in Settings has to reach the lanes already using it. An edit re-applies it (a rotated key would otherwise never reach the lane still holding the revoked one); a delete resets those lanes to native and removes the Codex layer file. Both report per-lane failures, which the tab surfaces. `.claude/settings.local.json` is added to the lane's git exclude, for both native and router lanes: `agenthub lanes reset` runs `git clean -fd` without `-x`, so an un-excluded settings file is deleted on reset — and untracked meant the key was committable. DB: `lanes.router_profile`, nullable, additive. Written only by `setRouterProfile` beside the disk write, never through `PATCHABLE`. --- CLAUDE.md | 3 +- client/src/components/lanes/AddLaneModal.tsx | 138 +++++- .../lanes/__tests__/AddLaneModal.test.tsx | 174 ++++++++ client/src/i18n/locales/en/lanes.json | 10 +- client/src/i18n/locales/en/settings.json | 41 +- client/src/i18n/locales/vi/lanes.json | 10 +- client/src/i18n/locales/vi/settings.json | 41 +- client/src/lib/api.ts | 91 ++++ client/src/pages/AiRouterConfig.tsx | 393 ++++++++++++++++++ client/src/pages/SettingsHub.tsx | 6 +- .../pages/__tests__/AiRouterConfig.test.tsx | 178 ++++++++ docs/API.md | 32 ++ docs/LANES.md | 2 + .../2026-09-14-ai-router-profiles-design.md | 106 +++++ server/__tests__/ai-routers.test.js | 190 +++++++++ server/__tests__/pty-run.test.js | 41 +- server/__tests__/router-apply.test.js | 389 +++++++++++++++++ server/db.js | 10 + server/index.js | 2 + server/lib/ai-routers.js | 319 ++++++++++++++ server/lib/lanes.js | 43 +- server/lib/pty-run.js | 20 +- server/lib/router-apply.js | 320 ++++++++++++++ server/lib/tmux.js | 48 ++- server/routes/ai-routers.js | 90 ++++ server/routes/lanes.js | 30 ++ server/routes/run.js | 2 + 27 files changed, 2676 insertions(+), 53 deletions(-) create mode 100644 client/src/pages/AiRouterConfig.tsx create mode 100644 client/src/pages/__tests__/AiRouterConfig.test.tsx create mode 100644 docs/superpowers/specs/2026-09-14-ai-router-profiles-design.md create mode 100644 server/__tests__/ai-routers.test.js create mode 100644 server/__tests__/router-apply.test.js create mode 100644 server/lib/ai-routers.js create mode 100644 server/lib/router-apply.js create mode 100644 server/routes/ai-routers.js diff --git a/CLAUDE.md b/CLAUDE.md index 2bdbf3b2..a794cc39 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Claude Code Working Guide -_Last synced: v1.5.6 (2026-09-11) + the systemd handover rule below (`scripts/systemd-service.js`'s `writeUserService`/`restartUserService` split and `plugin-bootstrap.js`'s deferred `pendingSystemdRestart`) + eb63569 (2026-09-10) + release-based update detection (`server/lib/update-check.js` reads the forge releases API — a checkout via its `origin` remote, a plugin-cache install via the marketplace manifest on disk, since an archive-sourced cache dir has no `.git`; a `plugin_managed` install self-updates via the detached `plugin-update` chain) + the install-wide **Update all** batch and session reload (`server/lib/cc-update-all.js`, `server/lib/cc-reload.js`)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. +_Last synced: v1.6.0 (2026-09-14) + AI Router profiles (`server/lib/ai-routers.js`, `server/lib/router-apply.js`, the Settings tab `/settings?tool=ai-router`, and `lanes.router_profile`) + v1.5.6 (2026-09-11) + the systemd handover rule below (`scripts/systemd-service.js`'s `writeUserService`/`restartUserService` split and `plugin-bootstrap.js`'s deferred `pendingSystemdRestart`) + eb63569 (2026-09-10) + release-based update detection (`server/lib/update-check.js` reads the forge releases API — a checkout via its `origin` remote, a plugin-cache install via the marketplace manifest on disk, since an archive-sourced cache dir has no `.git`; a `plugin_managed` install self-updates via the detached `plugin-update` chain) + the install-wide **Update all** batch and session reload (`server/lib/cc-update-all.js`, `server/lib/cc-reload.js`)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. ## Project mission - Maintain a reliable local-first dashboard for Claude Code session monitoring. @@ -36,6 +36,7 @@ A **lane** is a durable unit of parallel agent work — one working directory, m - Liveness: a silent **watcher** (stage matching `/watch|poll/`) is dead after `LANE_DEAD_SEC` seconds (default 300); a silent **idle** lane is at rest, not dead. - **Never `rm -rf` a lane.** Destructive lane operations (`reset`, `remove`, `purge`) go through `server/lib/worktree.js`/`server/lib/lanes.js`, never a raw filesystem delete — that's what keeps the three-check destroy guard and the DB bookkeeping in lockstep. - **Never build a git command as a shell string.** Lane git operations use `execFile("git", [...args])` with an explicit argument array (see `server/lib/worktree.js`'s `git()` helper), never a concatenated/interpolated string passed to a shell. +- **A lane's AI Router profile is a lane fact too, and it never travels alone.** `lanes.router_profile` (nullable; null means the same as `native`) is written ONLY by `setRouterProfile`, called beside the disk write in `POST /api/lanes/:id/router/apply` — never through `PATCHABLE`. The column and the lane's own config must move together, or the row claims a router the lane's `settings.local.json` has never heard of. What is written comes from the lane row, never the request: a **claude** lane gets eight `env` keys merged into `/.claude/settings.local.json` (`.local` because the value carries an API key; every other key in that file is preserved and a non-JSON file is refused, not rewritten), a **codex** lane gets `$CODEX_HOME/.config.toml` — the layer `codex -p ` loads — because Codex has NO project-level config, and its base `config.toml` is never touched. Key material never leaves the server: profiles live at `/agenthub-routers/` (0700/0600), `GET /api/ai-routers` reports only `hasKey`, and there is deliberately no route that returns one profile in full. **Codex's key reaches the pane through tmux's `-e` argv and is never typed into it** — `tmux.sendCommand` takes over an idle pane with `respawn-pane` for exactly this reason; a `KEY='sk-…' codex …` line typed with `send-keys` would be rendered to every attached browser, kept in the scrollback and appended to the user's shell history. Do not reintroduce a typed command line there. **And attaching COPIES a profile**, so the store stops being the single source of truth the moment a lane exists: `PATCH`/`DELETE /api/ai-routers/:name` must push through to the lanes naming it (`reapplyToLanes`/`detachFromLanes`), or a rotated key silently never reaches the lane still using the revoked one. See `server/lib/ai-routers.js`, `server/lib/router-apply.js`. - **A lane's `tool` is a lane fact, immutable, and never read from a request.** `lanes.tool` (`claude` | `codex`, default `claude`) is set by `createLane` only — it is deliberately absent from `PATCHABLE`, so no PATCH can flip the CLI under a live pane. Every spawn path (`POST /api/lanes/:id/start`, `POST /api/run`) takes the tool from the lane row and ignores whatever the body says; the run panel's Codex-only fields (`sandbox`, `approval`, `resumeLast`) are validated against the fixed allowlists in `server/lib/pty-run.js` before they reach an argv, and the bypass flags (`--dangerously-bypass-approvals-and-sandbox`, `--dangerously-bypass-hook-trust`) are never emitted. `GET /api/run/binary?tool=` resolves the probed binary name from that same allowlist — the query string reaches `which`. - **Adopted lanes are not destroyable.** A lane with `kind === "adopted"` may never have its worktree reset or removed; "removing" one only drops the dashboard's own record of it. This is enforced in code (`assertDestroyable`'s first check) — do not add a path that bypasses it. - **A `cwd` can back at most one lane per tool, not one lane overall.** Uniqueness is the composite `idx_lanes_cwd_tool` (`cwd`, `tool`), not `cwd` alone — Repo/Adopt mode (`POST /api/lanes/ensure`) lets the same directory carry a `claude` lane and a `codex` lane side by side, shown as two separate cards. `resolveLaneByCwd(cwd, tool)` takes an optional `tool` filter for exactly this; hook ingestion (`touchLaneFromHook`, `resolveLaneForWork`) always pins it to `"claude"` because Codex has no hook mechanism in this codebase, so a hook event must never touch a `codex` lane sharing the path. Worktree mode is unaffected — it already allocates a unique directory per lane. diff --git a/client/src/components/lanes/AddLaneModal.tsx b/client/src/components/lanes/AddLaneModal.tsx index ac9a0831..52549bc6 100644 --- a/client/src/components/lanes/AddLaneModal.tsx +++ b/client/src/components/lanes/AddLaneModal.tsx @@ -26,7 +26,7 @@ import { ConfirmModal } from "../ConfirmModal"; import { CwdAutocomplete } from "../run/RunSetup"; import { FolderBrowseModal } from "./FolderBrowseModal"; import { api } from "../../lib/api"; -import type { CwdSuggestion } from "../../lib/api"; +import type { AiRouter, CwdSuggestion } from "../../lib/api"; import type { Lane, LaneTool } from "../../lib/types"; /** Polls the lane until the background `git worktree add` finishes (status @@ -97,6 +97,10 @@ export function AddLaneModal({ const [base, setBase] = useState(""); const [pipeline, setPipeline] = useState("default"); const [writeDocs, setWriteDocs] = useState(false); + // "native" is the built-in profile, not a null state — see server/lib/ai-routers.js. + const [router, setRouter] = useState("native"); + const [routers, setRouters] = useState([]); + const [routersLoaded, setRoutersLoaded] = useState(false); const [pipelines, setPipelines] = useState<{ id: string; name: string; nodes: unknown[] }[]>([]); const [branchesError, setBranchesError] = useState(null); const [tool, setTool] = useState("claude"); @@ -107,6 +111,7 @@ export function AddLaneModal({ profile: "scaffolded" | "skipped" | "failed"; agents: "ok" | "failed"; mcp: "ok" | "failed"; + router: "ok" | "skipped" | "failed"; } | null>(null); const reset = () => { @@ -118,6 +123,8 @@ export function AddLaneModal({ setBase(""); setPipeline("default"); setWriteDocs(false); + setRouter("native"); + setRoutersLoaded(false); setTool("claude"); setBranchesError(null); setError(null); @@ -142,6 +149,26 @@ export function AddLaneModal({ // below, and the lane still gets created. if (!cancelled) setPipelines([]); }); + api.aiRouters + .list() + .then((r) => { + if (cancelled) return; + setRouters(r.items); + // A profile deleted in another tab while this modal sat open would + // otherwise stay selected in state while the select showed no matching + // option — and submit would send a name the server no longer knows. + setRouter((current) => + current === "native" || r.items.some((item) => item.name === current) ? current : "native" + ); + }) + .catch(() => { + // Quiet: with no list the radio simply offers Native, and the lane is + // still created — a router is an optional extra, never a precondition. + if (!cancelled) setRouters([]); + }) + .finally(() => { + if (!cancelled) setRoutersLoaded(true); + }); return () => { cancelled = true; }; @@ -189,6 +216,10 @@ export function AddLaneModal({ const startDocsRun = (laneId: number, cwd: string) => api.run.start({ laneId, cwd, initialPrompt: "/sg-all:sg-md-writing" }); + // Native is the built-in profile, so this is one call either way — there is + // no "no router" branch to keep in step with the real one. + const applyRouter = (laneId: number) => api.lanes.routerApply(laneId, router); + const submit = async () => { const repo = sourceRepo.trim(); const name = title.trim(); @@ -204,8 +235,29 @@ export function AddLaneModal({ pipeline, tool, }); - if (writeDocs && tool === "claude") await startDocsRun(result.lane.id, result.lane.cwd); + // Report the lane the INSTANT it exists server-side. Everything after + // this is post-creation setup that can fail on its own, and letting one + // of those failures skip `onAdded` would leave a real lane invisible in + // the list until some unrelated refetch happened to pick it up. + // Worktree mode below already works this way. onAdded(result.lane); + + // Router first: a session that booted on the vendor's API would keep + // talking to it for its whole life. + const [routerOutcome, docsOutcome] = await Promise.allSettled([ + applyRouter(result.lane.id), + writeDocs && tool === "claude" + ? startDocsRun(result.lane.id, result.lane.cwd) + : Promise.resolve(null), + ]); + const failed = [routerOutcome, docsOutcome].find((o) => o.status === "rejected"); + if (failed && failed.status === "rejected") { + // The lane exists and is already listed — name what did not finish + // instead of closing on a half-set-up lane. + setError(failed.reason instanceof Error ? failed.reason.message : String(failed.reason)); + setBusy(false); + return; + } reset(); onClose(); } catch (err) { @@ -239,10 +291,11 @@ export function AddLaneModal({ return; } - const [profileOutcome, agentsOutcome, mcpOutcome] = await Promise.allSettled([ + const [profileOutcome, agentsOutcome, mcpOutcome, routerOutcome] = await Promise.allSettled([ api.lanes.profileInit(result.lane.id), api.lanes.agentsInstall(result.lane.id), api.lanes.mcpSync(result.lane.id), + applyRouter(result.lane.id), ]); setSetupResult({ profile: @@ -253,6 +306,8 @@ export function AddLaneModal({ : "failed", agents: agentsOutcome.status === "fulfilled" ? "ok" : "failed", mcp: mcpOutcome.status === "fulfilled" ? "ok" : "failed", + router: + routerOutcome.status !== "fulfilled" ? "failed" : router === "native" ? "skipped" : "ok", }); if (writeDocs && tool === "claude") { @@ -265,6 +320,9 @@ export function AddLaneModal({ profileOutcome.status === "fulfilled" ? profileOutcome.value : profileOutcome.reason, agents: agentsOutcome.status === "fulfilled" ? agentsOutcome.value : agentsOutcome.reason, mcp: mcpOutcome.status === "fulfilled" ? mcpOutcome.value : mcpOutcome.reason, + // Never the profile itself: it would put an endpoint and a lane id in + // the browser console. (The key is never sent here at all.) + router: routerOutcome.status, }); } @@ -407,6 +465,75 @@ export function AddLaneModal({

{t("addLanePipelineHint")}

+
+ {t("addLaneRouterLabel")} +
+ {( + [ + { value: "native", label: t("addLaneRouterNative") }, + { value: "router", label: t("addLaneRouterCustom") }, + ] as const + ).map((option) => { + const active = option.value === "native" ? router === "native" : router !== "native"; + return ( + + ); + })} +
+ + {router !== "native" && ( + + )} +

+ {!routersLoaded + ? t("addLaneRouterLoading") + : routers.some((r) => !r.builtin) + ? t("addLaneRouterHint") + : t("addLaneRouterEmptyHint")} +

+
+ {tool === "claude" && (