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
+18 -11
View File
@@ -30,7 +30,7 @@
*/
const tmux = require("./tmux");
const { routerArgvForSpawn, routerEnvForSpawn } = require("./router-apply");
const { resolveRouter, routerForSpawn } = require("./router-apply");
const { desktopEnv } = require("./desktop-env");
const { getCodexHome } = require("./codex-home");
const aiTools = require("./ai-tools");
@@ -111,6 +111,7 @@ function buildClaudeArgv({
initialPrompt,
addDirs = [],
systemPromptFile = null,
routerArgv = [],
}) {
const argv = ["claude"];
// `--add-dir` is VARIADIC (`<directories...>`): it swallows every following
@@ -118,6 +119,8 @@ function buildClaudeArgv({
// always ends it — after the prompt it would eat the prompt as a directory.
if (addDirs.length) argv.push("--add-dir", ...addDirs);
argv.push("--permission-mode", permissionMode || "auto");
// The session's AI Router: `--settings=<file>` (router-apply.js).
argv.push(...routerArgv);
// Claude Code makes the worktree itself (`.claude/worktrees/<name>`, branch
// `worktree-<name>`, `.worktreeinclude`, the user's WorktreeCreate hook).
// `=` form: `-w [name]` takes an OPTIONAL value and would otherwise swallow
@@ -158,7 +161,7 @@ function buildCodexArgv({
resumeLast,
bypass,
initialPrompt,
routerProfile,
routerArgv = [],
addDirs = [],
systemPrompt = null,
systemPromptFile = null,
@@ -167,7 +170,7 @@ function buildCodexArgv({
// Before the subcommand and before the prompt: `-p` layers
// `$CODEX_HOME/<name>.config.toml` over the base config, and an option that
// lands after the positional prompt is not an option any more.
argv.push(...routerArgvForSpawn({ tool: "codex", router_profile: routerProfile }));
argv.push(...routerArgv);
if (resumeSessionId) argv.push("resume", resumeSessionId);
else if (resumeLast) argv.push("resume", "--last");
if (model) argv.push("-m", model);
@@ -242,8 +245,9 @@ function buildArgv(args, entry) {
* @param {boolean} [args.bypass] the user ticked "bypass permissions" for THIS
* run; appends the catalog's bypass args/env. Claude has no catalog bypass.
* @param {string} [args.initialPrompt]
* @param {string} [args.routerProfile] AI Router profile the LANE runs against
* (`lanes.router_profile`), never a request field.
* @param {string} [args.router] AI Router profile THIS session runs on — a
* name only, its key and endpoint come from the store; null/"native" = the
* vendor's own API. Claude and Codex only (`EUNSUPPORTED`).
*/
function spawnRun(args) {
const {
@@ -263,7 +267,7 @@ function spawnRun(args) {
resumeLast,
bypass,
initialPrompt,
routerProfile,
router,
} = args || {};
if (typeof laneId !== "number" || !Number.isInteger(laneId)) {
throw makeErr("EBADLANE", "laneId must be an integer");
@@ -315,6 +319,7 @@ function spawnRun(args) {
if (worktree === true && entry.launcher !== "claude") {
throw makeErr("EUNSUPPORTED", `${tool} cannot start a session in its own worktree`);
}
const routerProfile = resolveRouter(tool, router); // ENOTFOUND, EUNSUPPORTED
if (forkSession === true) {
if (!resumeSessionId) throw makeErr("EBADSESSION", "a fork needs resumeSessionId");
if (entry.launcher !== "claude") {
@@ -351,6 +356,7 @@ function spawnRun(args) {
laneId,
tool
);
const routerSpawn = routerForSpawn(tool, routerProfile);
const argv = buildArgv(
{
addDirs,
@@ -368,7 +374,7 @@ function spawnRun(args) {
resumeLast,
bypass: bypass === true,
initialPrompt,
routerProfile,
routerArgv: routerSpawn.argv,
},
entry
);
@@ -394,6 +400,7 @@ function spawnRun(args) {
effort: effort || null,
resumeSessionId: resumeSessionId || null,
worktree: worktree === true || inWorktree === true,
router: routerProfile ? routerProfile.name : null,
prompt: initialPrompt || "",
status: "running",
startedAt,
@@ -407,9 +414,9 @@ function spawnRun(args) {
// from inside it then resolves the lane exactly, instead of matching on cwd —
// which is ambiguous now that one directory can back both a `claude` lane and
// a `codex` lane (see bin/agenthub.js's resolveLaneArg).
// A codex lane on an AI Router also needs the key its layer's `env_key`
// names. A claude lane needs nothing here — its settings file already carries
// the router — and a lane on the vendor's own API needs nothing either.
// A codex session on an AI Router also needs the key its layer's `env_key`
// names. A claude one needs nothing here — its `--settings` file carries the
// router — and a session on the vendor's own API needs nothing either.
// The graphical session's own DISPLAY/WAYLAND_DISPLAY goes in too: the
// server normally runs as a systemd user service with neither, so a pane it
// starts has no clipboard at all — pasting an image into `claude` there
@@ -430,7 +437,7 @@ function spawnRun(args) {
// journals and the router layer `-p <name>` loads live there — and the
// tmux server's own env may predate, or never have had, CODEX_HOME.
...(tool === "codex" ? { CODEX_HOME: getCodexHome() } : {}),
...routerEnvForSpawn({ tool, router_profile: routerProfile || null }),
...routerSpawn.env,
...(bypass === true && entry.bypassEnv ? entry.bypassEnv : {}),
};