feat: AI Router per session; Settings at the sidebar's foot; shipped tool icons
AI Router is a session's choice, not a workspace's: - an AI Router select beside the model picker in the workspace home and a pane's run form (RouterPicker.tsx; hidden off Claude/Codex or with only Native; last pick remembered in agenthub.session.router) - `router` on POST /api/lanes/:id/start and POST /api/run; Claude gets --settings=<data dir>/router-settings/<name>.json (0600, outranks project and user settings, key never in argv), Codex -p <name> + key via tmux -e - dashboard_runs.router; a resume naming none goes back to it - profile PATCH rewrites the session files, DELETE removes them - gone: POST /api/lanes/:id/router/apply, LaneRouterRow, the Add-workspace router radio, the claude-/codex-home `routers` answers; clearLaneRouters takes the old keys out of each workspace's settings.local.json at start Sidebar: Settings pinned to the foot, the collapse toggle beside it (stacked on the rail) instead of in the brand's corner. Icons: shipped SVG marks for claude, codex, openclaw and hermes (a black/white pair swapped by theme) in client/public/ai-tools/, used by AiToolIcon before the server's favicon.
This commit is contained in:
+13
-17
@@ -1,14 +1,12 @@
|
||||
/**
|
||||
* @file ai-routers.js (routes)
|
||||
* @description CRUD for AI Router profiles — the endpoints behind the
|
||||
* **AI Router** tab of Settings. CHOOSING a profile for a lane is not here: it
|
||||
* happens once, when the lane is created (`POST /api/lanes/:id/router/apply`),
|
||||
* because the write target is the lane's own directory.
|
||||
* **AI Router** tab of Settings. CHOOSING a profile is not here: a new session
|
||||
* names one (`router` on `POST /api/lanes/:id/start` and `POST /api/run`).
|
||||
*
|
||||
* An edit or a delete here does reach the lanes that already chose it, though.
|
||||
* Attaching COPIES the profile into the lane's own config, so a store-only
|
||||
* write would be inert exactly where it matters most — a rotated key would
|
||||
* never reach the lanes still authenticating with the revoked one.
|
||||
* An edit or a delete here does reach the sessions using it: each reads the
|
||||
* profile through a file written at its spawn (`server/lib/router-apply.js`),
|
||||
* which an edit rewrites and a delete removes.
|
||||
*
|
||||
* No response on this router carries key material. `GET /` returns the store's
|
||||
* public view (`hasKey: true|false`), and there is deliberately no
|
||||
@@ -21,7 +19,7 @@
|
||||
const express = require("express");
|
||||
|
||||
const routers = require("../lib/ai-routers");
|
||||
const { reapplyToLanes, detachFromLanes } = require("../lib/router-apply");
|
||||
const { refreshProfileFiles, removeProfileFiles } = require("../lib/router-apply");
|
||||
const { sameOriginGuard } = require("../lib/same-origin");
|
||||
|
||||
const router = express.Router();
|
||||
@@ -79,26 +77,24 @@ router.post("/test", sameOriginGuard, async (req, res) => {
|
||||
// PATCH /api/ai-routers/:name — same fields, all optional. An omitted `apiKey`
|
||||
// keeps the stored one; the edit form cannot show a key, so it cannot echo one.
|
||||
//
|
||||
// Attaching COPIES a profile into the lane's own config, so an edit that only
|
||||
// wrote the store here would be inert: rotating a leaked key would leave every
|
||||
// claude lane on the revoked one. `reapplyToLanes` pushes the change out, and
|
||||
// reports per-lane failures instead of failing the edit.
|
||||
// A session reads the profile through a file written at its spawn, so the
|
||||
// edit rewrites those files too — a rotated key reaches live Claude sessions.
|
||||
router.patch("/:name", sameOriginGuard, (req, res) => {
|
||||
try {
|
||||
const saved = routers.updateRouter(req.params.name, req.body || {});
|
||||
res.json({ ...saved, lanes: reapplyToLanes(saved.name) });
|
||||
refreshProfileFiles(saved.name);
|
||||
res.json(saved);
|
||||
} catch (e) {
|
||||
return fail(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/ai-routers/:name — every lane using it is reset to native, in its
|
||||
// config on disk as well as in the DB. Leaving the lanes alone would keep the
|
||||
// deleted router's key and base URL live in each claude lane's settings file.
|
||||
// DELETE /api/ai-routers/:name — and the session files that copy its key.
|
||||
router.delete("/:name", sameOriginGuard, (req, res) => {
|
||||
try {
|
||||
const removed = routers.removeRouter(req.params.name);
|
||||
res.json({ ...removed, lanes: detachFromLanes(req.params.name) });
|
||||
removeProfileFiles(req.params.name);
|
||||
res.json(removed);
|
||||
} catch (e) {
|
||||
return fail(res, e);
|
||||
}
|
||||
|
||||
+7
-46
@@ -27,7 +27,7 @@ const laneFeatures = require("../lib/lane-features");
|
||||
const proofLib = require("../lib/proof");
|
||||
const { broadcast } = require("../websocket");
|
||||
const runs = require("../lib/pty-run");
|
||||
const { applyRouterToLane } = require("../lib/router-apply");
|
||||
const { resumeRouter } = require("../lib/router-apply");
|
||||
const { sameOriginGuard } = require("./run");
|
||||
const { writeLaneFile } = require("../lib/lane-file-write");
|
||||
const { preflight } = require("../lib/lane-preflight");
|
||||
@@ -735,48 +735,6 @@ router.post("/:id/mcp/sync", sameOriginGuard, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /:id/router/apply — point this lane at one AI Router profile (or back at
|
||||
* `native`). Body: `{ profile: "<name>" }`.
|
||||
*
|
||||
* The profile name is the only thing taken from the request: what gets written,
|
||||
* and where, is derived from the lane row (`server/lib/router-apply.js`). The
|
||||
* lane's `router_profile` column is set in the same call so a later spawn knows
|
||||
* which `-p` layer a codex pane needs.
|
||||
*/
|
||||
router.post("/:id/router/apply", sameOriginGuard, (req, res) => {
|
||||
const lane = lanesLib.getLane(req.params.id);
|
||||
if (!lane)
|
||||
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
|
||||
const profile = (req.body || {}).profile;
|
||||
if (typeof profile !== "string" || !profile) {
|
||||
return res.status(400).json({ error: { code: "EBADREQ", message: "profile is required" } });
|
||||
}
|
||||
try {
|
||||
const result = applyRouterToLane(lane, profile);
|
||||
lanesLib.setRouterProfile(lane.id, profile);
|
||||
// A pane that is already running does NOT fully adopt the switch. Claude
|
||||
// Code re-applies an `env` block to a live session when the settings file
|
||||
// changes, but the session's model was resolved at startup — so the OLD
|
||||
// router's model name keeps being sent to the NEW endpoint, which answers
|
||||
// "model not found". (Switching to `native` is the same story: the router's
|
||||
// model name reaches api.anthropic.com.) Nothing here can fix that from the
|
||||
// outside; the pane has to be restarted. Report it instead of letting the
|
||||
// switch look clean and then fail on the user's next prompt.
|
||||
const runId = runs.runIdForLane(lane.id);
|
||||
const live = runs.getRun(runId)?.status === "running";
|
||||
// Every other mutating lane route broadcasts; without it the client's
|
||||
// cached row keeps the OLD router_profile, and the next lane switch
|
||||
// re-reads that stale row and flips the picker back.
|
||||
broadcastLane(lane.id);
|
||||
res.json({ ...result, restart_required: live, run_id: live ? runId : null });
|
||||
} catch (err) {
|
||||
const status =
|
||||
{ ENOTFOUND: 404, EBADJSON: 409, EBADNAME: 400, EUNSUPPORTED: 400 }[err.code] || 500;
|
||||
res.status(status).json({ error: { code: err.code || "ERUNTIME", message: err.message } });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Detect a Node.js project at this lane's OWN directory and scaffold
|
||||
* .agenthub/profile/ if one is found — the HTTP equivalent of
|
||||
@@ -1880,9 +1838,12 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
// asked for the other CLI would otherwise get a pane that doesn't
|
||||
// match the lane's badge, its history, or its binary probe.
|
||||
tool: current.tool || "claude",
|
||||
// Same reasoning as `tool`: the router is the lane's fact, written
|
||||
// into its config when the lane was created.
|
||||
routerProfile: current.router_profile || null,
|
||||
// The SESSION's AI Router, by name; a resume that names none goes
|
||||
// back to the one its session last ran on.
|
||||
router:
|
||||
body.router !== undefined
|
||||
? body.router
|
||||
: resumeRouter(body.resumeSessionId || (body.resume ? current.session_id : null)),
|
||||
model: body.model,
|
||||
permissionMode: body.permissionMode,
|
||||
effort: body.effort,
|
||||
|
||||
@@ -30,6 +30,7 @@ const tmux = require("../lib/tmux");
|
||||
const lanesLib = require("../lib/lanes");
|
||||
const { sameOriginGuard } = require("../lib/same-origin");
|
||||
const aiTools = require("../lib/ai-tools");
|
||||
const { resumeRouter } = require("../lib/router-apply");
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -321,8 +322,12 @@ router.post("/", (req, res) => {
|
||||
laneId,
|
||||
cwd,
|
||||
tool: lane?.tool || "claude",
|
||||
// The lane's own router, exactly like its tool — never a body field.
|
||||
routerProfile: lane?.router_profile || null,
|
||||
// The session's AI Router, by name (key and endpoint from the store); a
|
||||
// resume that names none goes back to the one its session last ran on.
|
||||
router:
|
||||
body.router !== undefined
|
||||
? body.router
|
||||
: resumeRouter(typeof body.resumeSessionId === "string" ? body.resumeSessionId : null),
|
||||
model: typeof body.model === "string" && body.model ? body.model : null,
|
||||
permissionMode:
|
||||
typeof body.permissionMode === "string" && ALLOWED_PERMISSION_MODES.has(body.permissionMode)
|
||||
|
||||
@@ -25,7 +25,6 @@ const APP_VERSION = (() => {
|
||||
const { getSettingsPath, getClaudeHome, setClaudeHome } = require("../lib/claude-home");
|
||||
const { getCodexHome, setCodexHome } = require("../lib/codex-home");
|
||||
const { envOwned } = require("../lib/settings-env");
|
||||
const { reapplyCodexLayers, lanesWithMissingRouter } = require("../lib/router-apply");
|
||||
const pricingSync = require("../lib/pricing-sync");
|
||||
|
||||
function getDbSize() {
|
||||
@@ -285,7 +284,7 @@ router.get("/export", (_req, res) => {
|
||||
// overridden again at the next start), PUT validates, applies and persists
|
||||
// (server/lib/settings-env.js). EBADHOME = too broad, INVALID_PATH = any other
|
||||
// bad path, 500 ESAVE = the settings file could not be written.
|
||||
function homeRoutes(route, key, field, get, set, after = () => ({})) {
|
||||
function homeRoutes(route, key, field, get, set) {
|
||||
router.get(route, (_req, res) => {
|
||||
res.json({ [field]: get(), env_owned: envOwned.has(key) });
|
||||
});
|
||||
@@ -308,20 +307,15 @@ function homeRoutes(route, key, field, get, set, after = () => ({})) {
|
||||
error: { code: bad ? err.code : "ESAVE", message: err.message },
|
||||
});
|
||||
}
|
||||
res.json({ ok: true, [field]: resolved, env_owned: envOwned.has(key), ...after() });
|
||||
res.json({ ok: true, [field]: resolved, env_owned: envOwned.has(key) });
|
||||
});
|
||||
}
|
||||
|
||||
// GET/PUT /api/settings/claude-home — a PUT names the lanes whose AI Router
|
||||
// profile the new home's store does not have (they would start on native).
|
||||
homeRoutes("/claude-home", "CLAUDE_HOME", "claude_home", getClaudeHome, setClaudeHome, () => ({
|
||||
routers: { missing: lanesWithMissingRouter() },
|
||||
}));
|
||||
// GET/PUT /api/settings/codex-home — a PUT re-writes every codex lane's AI
|
||||
// Router layer into the new home, or `codex -p <name>` fails at the next start.
|
||||
homeRoutes("/codex-home", "CODEX_HOME", "codex_home", getCodexHome, setCodexHome, () => ({
|
||||
routers: reapplyCodexLayers(),
|
||||
}));
|
||||
// GET/PUT /api/settings/claude-home
|
||||
homeRoutes("/claude-home", "CLAUDE_HOME", "claude_home", getClaudeHome, setClaudeHome);
|
||||
// GET/PUT /api/settings/codex-home — a session's AI Router layer is written
|
||||
// into the current home at its spawn, so a move needs no repair here.
|
||||
homeRoutes("/codex-home", "CODEX_HOME", "codex_home", getCodexHome, setCodexHome);
|
||||
|
||||
// POST /api/settings/cleanup — abandon stale sessions, purge old data
|
||||
router.post("/cleanup", (req, res) => {
|
||||
|
||||
Reference in New Issue
Block a user