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
+7 -46
View File
@@ -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,