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:
2026-10-02 14:19:02 +07:00
parent 5084334d81
commit c507d37baf
55 changed files with 851 additions and 1992 deletions
+13 -17
View File
@@ -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);
}