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:
@@ -32,11 +32,11 @@ const insertStmt = db.prepare(`
|
||||
INSERT OR REPLACE INTO dashboard_runs (
|
||||
id, session_id, mode, cwd, model, permission_mode, effort,
|
||||
resume_session_id, prompt_preview, status, exit_code, started_at, ended_at,
|
||||
lane_id, tool, worktree
|
||||
lane_id, tool, worktree, router
|
||||
) VALUES (
|
||||
@id, @session_id, @mode, @cwd, @model, @permission_mode, @effort,
|
||||
@resume_session_id, @prompt_preview, @status, @exit_code, @started_at, @ended_at,
|
||||
@lane_id, @tool, @worktree
|
||||
@lane_id, @tool, @worktree, @router
|
||||
)
|
||||
`);
|
||||
|
||||
@@ -51,7 +51,7 @@ const updateStmt = db.prepare(`
|
||||
|
||||
const RUN_COLUMNS = `id, session_id, mode, cwd, model, permission_mode, effort,
|
||||
resume_session_id, prompt_preview, status, exit_code,
|
||||
started_at, ended_at, lane_id, tool, worktree`;
|
||||
started_at, ended_at, lane_id, tool, worktree, router`;
|
||||
|
||||
const listStmt = db.prepare(`
|
||||
SELECT ${RUN_COLUMNS}
|
||||
@@ -115,6 +115,7 @@ function recordRun(handle) {
|
||||
lane_id: typeof handle.laneId === "number" ? handle.laneId : null,
|
||||
tool: typeof handle.tool === "string" && handle.tool ? handle.tool : "claude",
|
||||
worktree: handle.worktree ? 1 : 0,
|
||||
router: handle.router || null,
|
||||
});
|
||||
} catch (err) {
|
||||
// Best-effort by design (a live run must never fail because sqlite did),
|
||||
@@ -162,6 +163,24 @@ function getRun(id) {
|
||||
}
|
||||
}
|
||||
|
||||
const sessionRouterStmt = db.prepare(`
|
||||
SELECT router FROM dashboard_runs
|
||||
WHERE session_id = @sessionId OR resume_session_id = @sessionId
|
||||
ORDER BY started_at DESC LIMIT 1
|
||||
`);
|
||||
|
||||
/**
|
||||
* The AI Router the newest run holding `sessionId` started on (null = native,
|
||||
* or no run known) — what a resume that names no router goes back to.
|
||||
*/
|
||||
function routerForSession(sessionId) {
|
||||
try {
|
||||
return sessionRouterStmt.get({ sessionId })?.router || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const runningStmt = db.prepare(
|
||||
"SELECT id FROM dashboard_runs WHERE status IN ('running', 'spawning')"
|
||||
);
|
||||
@@ -193,4 +212,4 @@ function reconcileOrphans(isAlive = () => false) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { recordRun, patchRun, listRuns, getRun, reconcileOrphans };
|
||||
module.exports = { recordRun, patchRun, listRuns, getRun, reconcileOrphans, routerForSession };
|
||||
|
||||
@@ -293,43 +293,6 @@ function updateLane(id, patch = {}) {
|
||||
return getLane(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record which AI Router profile a lane runs against. Deliberately its own
|
||||
* function rather than a `PATCHABLE` field: the router is also written into the
|
||||
* lane's config on disk (`server/lib/router-apply.js`), so the column may only
|
||||
* move together with that write — a bare PATCH would leave the row claiming a
|
||||
* router the lane's settings file has never heard of.
|
||||
*
|
||||
* @param {number} id - The lane id.
|
||||
* @param {string|null} profile - A profile name, or null/"native" for the
|
||||
* vendor's own API.
|
||||
*/
|
||||
function setRouterProfile(id, profile) {
|
||||
const value = !profile || profile === "native" ? null : String(profile);
|
||||
db.prepare("UPDATE lanes SET router_profile = ?, updated_at = ? WHERE id = ?").run(
|
||||
value,
|
||||
nowIso(),
|
||||
id
|
||||
);
|
||||
return getLane(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every lane attached to one AI Router profile. Editing or deleting a profile
|
||||
* has to reach the lanes that copied it (`server/lib/router-apply.js`), and this
|
||||
* is how those lanes are found.
|
||||
*
|
||||
* @param {string} profile - A profile name. `native` matches nothing: it is
|
||||
* stored as NULL, and a lane on the vendor's own API has nothing to update.
|
||||
*/
|
||||
function lanesByRouter(profile) {
|
||||
if (!profile || profile === "native") return [];
|
||||
return db
|
||||
.prepare("SELECT * FROM lanes WHERE router_profile = ? ORDER BY id ASC")
|
||||
.all(String(profile))
|
||||
.map(hydrate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write provisioning-time facts that `PATCH /api/lanes/:id` must never reach:
|
||||
* `base_branch`, resolved after `git worktree add` succeeds, and the runtime
|
||||
@@ -928,8 +891,6 @@ module.exports = {
|
||||
deleteLane,
|
||||
setStage,
|
||||
clearCodexPipelines,
|
||||
setRouterProfile,
|
||||
lanesByRouter,
|
||||
recordDetection,
|
||||
clearLane,
|
||||
recoverInterruptedProvisioning,
|
||||
|
||||
+18
-11
@@ -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 : {}),
|
||||
};
|
||||
|
||||
|
||||
+149
-248
@@ -1,30 +1,31 @@
|
||||
/**
|
||||
* @file router-apply.js
|
||||
* @description Attaches an AI Router profile (see `ai-routers.js`) to one lane.
|
||||
* @description Starts ONE session on an AI Router profile (see `ai-routers.js`).
|
||||
* A router is chosen per new session, not per workspace, so nothing here writes
|
||||
* into the user's project.
|
||||
*
|
||||
* The two CLIs are configured in different places, and the difference is
|
||||
* theirs, not a choice made here:
|
||||
*
|
||||
* - **Claude Code** reads an `env` block out of the settings layers, so a
|
||||
* router is eight env keys merged into the lane's own
|
||||
* `.claude/settings.local.json`. `settings.local.json` and not
|
||||
* `settings.json` because the value written includes an API key, and the
|
||||
* `.local` file is the one Claude Code's own convention keeps out of git.
|
||||
* - **Claude Code** gets `--settings=<file>`: a settings file holding the
|
||||
* profile's eight `env` keys, `<data dir>/router-settings/<name>.json`
|
||||
* (0600). The command-line layer outranks the project-local and user
|
||||
* settings (verified on 2.1.286), so two sessions of one workspace can run
|
||||
* on two routers, and the key stays out of argv — only the path is there.
|
||||
*
|
||||
* - **Codex** has no project-level config at all — `codex doctor` reports a
|
||||
* single loaded file, `~/.codex/config.toml`. What it does have is
|
||||
* `-p <name>`, which layers `$CODEX_HOME/<name>.config.toml` on top of that
|
||||
* - **Codex** has no project-level config at all. What it does have is
|
||||
* `-p <name>`, which layers `$CODEX_HOME/<name>.config.toml` on top of the
|
||||
* base. So a profile becomes a layer file, the base is never touched, and
|
||||
* the lane's spawn passes the flag. Codex takes the key from the environment
|
||||
* variable named by `env_key`, so the key stays out of the file entirely and
|
||||
* reaches the pane through tmux's own `-e`, never as typed text.
|
||||
* the key reaches the pane through tmux's own `-e` under the variable the
|
||||
* layer's `env_key` names, never as typed text.
|
||||
*
|
||||
* Target directories come from the lane row, never from a request body — the
|
||||
* same narrowing every other lane write path in this codebase uses.
|
||||
* Both files are (re)written at every spawn, so an edited profile reaches the
|
||||
* next session and a moved CODEX_HOME needs no repair. `refreshProfileFiles`
|
||||
* rewrites them on an edit (a live Claude session reads its file again);
|
||||
* `removeProfileFiles` deletes them with the profile.
|
||||
*
|
||||
* Attaching COPIES a profile, so the store is not the single source of truth
|
||||
* once a lane exists. `reapplyToLanes`/`detachFromLanes` are what keep an edit
|
||||
* or a delete in Settings from being silently inert on the lanes using it.
|
||||
* Workspaces once carried a router of their own in `lanes.router_profile` and
|
||||
* `<cwd>/.claude/settings.local.json`; `clearLaneRouters` takes that out once.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -33,10 +34,9 @@ const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const routers = require("./ai-routers");
|
||||
const { patchSettings } = require("./cc-mutate");
|
||||
const { excludeFromGit } = require("./git-exclude");
|
||||
const { getDataDir } = require("./claude-home");
|
||||
const { getCodexHome } = require("./codex-home");
|
||||
const { lanesByRouter, listLanes, setRouterProfile } = require("./lanes");
|
||||
const { ROUTER_CAPABLE } = require("./ai-tools");
|
||||
|
||||
/**
|
||||
* The env var Codex is told to read the router key from. One fixed name rather
|
||||
@@ -45,7 +45,7 @@ const { lanesByRouter, listLanes, setRouterProfile } = require("./lanes");
|
||||
*/
|
||||
const CODEX_KEY_ENV = "AGENTHUB_ROUTER_KEY";
|
||||
|
||||
/** The eight keys this feature owns in a lane's settings file, and nothing else. */
|
||||
/** The eight env keys a router sets for Claude Code. */
|
||||
function envForProfile(profile) {
|
||||
return {
|
||||
ANTHROPIC_BASE_URL: profile.baseUrl,
|
||||
@@ -59,91 +59,27 @@ function envForProfile(profile) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a profile, or resolve to null when it has since been deleted. Used on
|
||||
* the spawn path, where a missing profile must degrade the lane to its base
|
||||
* configuration rather than refuse to start it.
|
||||
*/
|
||||
function readOrNull(name) {
|
||||
if (!name || name === routers.BUILTIN_NAME) return null;
|
||||
try {
|
||||
return routers.getRouter(name);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
function claudeSettingsPath(name) {
|
||||
return path.join(getDataDir(), "router-settings", `${name}.json`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotently keep `.claude/settings.local.json` out of git, for two reasons
|
||||
* that both matter:
|
||||
*
|
||||
* - `agenthub lanes reset` runs `git clean -fd` (`server/lib/worktree.js`).
|
||||
* Without `-x` that spares IGNORED files only, so an untracked settings file
|
||||
* is deleted and the lane silently loses its router.
|
||||
* - The file holds an API key. Untracked means `git add -A` can commit it.
|
||||
*
|
||||
* The backup directory beside it, for the same reason plus one more: those
|
||||
* copies hold the API key too, and they are what a user sees as a dozen
|
||||
* untracked files appearing in their project while they switch routers.
|
||||
*/
|
||||
function excludeSettingsFromGit(laneDir) {
|
||||
excludeFromGit(laneDir, [".claude/settings.local.json", ".claude/.cc-config-backups/"]);
|
||||
function codexLayerPath(name) {
|
||||
return path.join(getCodexHome(), `${name}.config.toml`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge the eight keys into the lane's settings, leaving every other key —
|
||||
* including every other env var — exactly as it was.
|
||||
*
|
||||
* `patchSettings` does this already and does it better than a local merge
|
||||
* would: per-key deep merge, a size ceiling, `__proto__`-style segments
|
||||
* refused, an atomic write, a timestamped backup, and a file that is not valid
|
||||
* JSON reported (`EBADJSON`) instead of overwritten. It resolves
|
||||
* `project-local` to exactly `<cwd>/.claude/settings.local.json`. The
|
||||
* caller-chosen-cwd rule it is written against is a ROUTE rule; `lane.cwd`
|
||||
* comes from the DB row, which is the shape that rule allows.
|
||||
*
|
||||
* What it does not do is set a mode, because nothing else it writes holds a
|
||||
* secret. This one does, so the file and the backup it just made of the
|
||||
* previous one are both tightened afterwards.
|
||||
*/
|
||||
function applyToClaudeLane(lane, profile) {
|
||||
// Native is not simply "no keys". The two halves go opposite ways:
|
||||
//
|
||||
// - The CONNECTION pair is BLANKED, not removed. An absent key at project
|
||||
// scope means the user-level one applies, so dropping these would let a
|
||||
// user-level router take over a lane that explicitly asked for native.
|
||||
// - The MODEL names are REMOVED. Writing them would pin every lane — including
|
||||
// one created by a user who never opened this tab — to whatever names this
|
||||
// file shipped with, overriding the user-level choice that is the actual
|
||||
// source of truth for native.
|
||||
const env = envForProfile(profile);
|
||||
const set = {};
|
||||
let unset = [];
|
||||
if (profile.builtin) {
|
||||
const connection = ["ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN"];
|
||||
for (const key of connection) set[`env.${key}`] = "";
|
||||
unset = Object.keys(env)
|
||||
.filter((key) => !connection.includes(key))
|
||||
.map((key) => `env.${key}`);
|
||||
} else {
|
||||
for (const [key, value] of Object.entries(env)) set[`env.${key}`] = value;
|
||||
}
|
||||
function writeSecret(file, body) {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(file, body, { mode: 0o600 });
|
||||
fs.chmodSync(file, 0o600); // an existing file keeps its mode on write
|
||||
}
|
||||
|
||||
const result = patchSettings({ scope: "project-local", cwd: lane.cwd, set, unset });
|
||||
// Both branches, not just the router one. A native lane's blanked connection
|
||||
// pair is load-bearing too — `git clean -fd` deleting it would let a
|
||||
// user-level router quietly take over a lane that asked for native.
|
||||
excludeSettingsFromGit(lane.cwd);
|
||||
for (const file of [result.path, result.backupPath]) {
|
||||
if (!file) continue;
|
||||
try {
|
||||
fs.chmodSync(file, 0o600);
|
||||
} catch {
|
||||
// Best effort: a write that landed must not be reported as a failure
|
||||
// because the mode could not be tightened afterwards.
|
||||
}
|
||||
}
|
||||
return { tool: "claude", file: result.path, profile: profile.name };
|
||||
function writeClaudeSettings(profile) {
|
||||
const file = claudeSettingsPath(profile.name);
|
||||
// Empty values are left out: a blank key in the command-line layer would
|
||||
// blank the user's own setting, not leave it alone.
|
||||
const env = Object.fromEntries(Object.entries(envForProfile(profile)).filter(([, v]) => v));
|
||||
writeSecret(file, `${JSON.stringify({ env }, null, 2)}\n`);
|
||||
return file;
|
||||
}
|
||||
|
||||
/** TOML string literal — router names and URLs are validated, quotes are not free. */
|
||||
@@ -155,8 +91,8 @@ function tomlString(value) {
|
||||
* Write `$CODEX_HOME/<name>.config.toml`, the layer `codex -p <name>` loads on
|
||||
* top of the base config. The base file is never read or written here.
|
||||
*/
|
||||
function applyToCodexLane(lane, profile) {
|
||||
const file = path.join(getCodexHome(), `${profile.name}.config.toml`);
|
||||
function writeCodexLayer(profile) {
|
||||
const file = codexLayerPath(profile.name);
|
||||
const body =
|
||||
`# Written by AgentHub — AI Router profile ${profile.label}.\n` +
|
||||
`# Loaded by \`codex -p ${profile.name}\`; the base config.toml is untouched.\n` +
|
||||
@@ -170,168 +106,133 @@ function applyToCodexLane(lane, profile) {
|
||||
// is never set makes Codex refuse to start.
|
||||
(profile.apiKey ? `env_key = ${tomlString(CODEX_KEY_ENV)}\n` : "") +
|
||||
// Not "chat": codex-cli 0.149 rejects it outright — `wire_api = "chat" is
|
||||
// no longer supported`, a hard config error before the first request. The
|
||||
// user's own working 9Router provider uses "responses" too.
|
||||
// no longer supported`, a hard config error before the first request.
|
||||
`wire_api = "responses"\n`;
|
||||
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, body, { mode: 0o600 });
|
||||
return { tool: "codex", file, profile: profile.name };
|
||||
writeSecret(file, body);
|
||||
return file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach `profileName` to `lane`. Called once, when the lane is created.
|
||||
*
|
||||
* @param {{id:number, cwd:string, tool:string}} lane the lane row
|
||||
* @param {string} profileName a saved profile's name, or `"native"`
|
||||
* The profile a new session asked for, checked against its tool. `null`,
|
||||
* `""` and `"native"` mean the vendor's own API and resolve to null.
|
||||
* @throws EBADROUTER for a profile the store does not have, EUNSUPPORTED for a
|
||||
* router on a tool that has no endpoint to point at one.
|
||||
*/
|
||||
const { ROUTER_CAPABLE } = require("./ai-tools");
|
||||
|
||||
function applyRouterToLane(lane, profileName) {
|
||||
const profile = routers.getRouter(profileName); // throws ENOTFOUND
|
||||
// Only Claude and Codex have an endpoint/model override to write; any other
|
||||
// AI tool runs on its vendor's own settings, so native is the one fit.
|
||||
if (!ROUTER_CAPABLE.has(lane.tool || "claude")) {
|
||||
if (profile.builtin) return { tool: lane.tool, file: null, profile: profile.name };
|
||||
throw Object.assign(new Error(`a ${lane.tool} workspace cannot use an AI Router`), {
|
||||
function resolveRouter(tool, name) {
|
||||
if (name == null || name === "" || name === routers.BUILTIN_NAME) return null;
|
||||
if (typeof name !== "string") {
|
||||
throw Object.assign(new Error("router must be a profile name"), { code: "EBADROUTER" });
|
||||
}
|
||||
let profile;
|
||||
try {
|
||||
profile = routers.getRouter(name);
|
||||
} catch (e) {
|
||||
if (e.code !== "ENOTFOUND") throw e;
|
||||
throw Object.assign(new Error(`no AI Router profile named "${name}"`), {
|
||||
code: "EBADROUTER",
|
||||
});
|
||||
}
|
||||
if (profile.builtin) return null;
|
||||
if (!ROUTER_CAPABLE.has(tool)) {
|
||||
throw Object.assign(new Error(`a ${tool} session cannot use an AI Router`), {
|
||||
code: "EUNSUPPORTED",
|
||||
});
|
||||
}
|
||||
if (lane.tool === "codex") {
|
||||
// Native is the base config as it already stands. A layer that overrode
|
||||
// nothing would still shadow whatever model the user set in the base.
|
||||
if (profile.builtin) return { tool: "codex", file: null, profile: profile.name };
|
||||
return applyToCodexLane(lane, profile);
|
||||
}
|
||||
return applyToClaudeLane(lane, profile);
|
||||
return profile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extra argv for a lane's spawn. Codex needs `-p <name>`; a Claude lane needs
|
||||
* nothing, its settings file already carries the router.
|
||||
* @returns {string[]}
|
||||
* The router a resume that names none goes back to: the one its session last
|
||||
* ran on, while that profile still exists — a deleted one resumes on native.
|
||||
*/
|
||||
function routerArgvForSpawn(lane) {
|
||||
if (!lane || lane.tool !== "codex") return [];
|
||||
const profile = readOrNull(lane.router_profile);
|
||||
return profile ? ["-p", profile.name] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extra environment for a lane's spawn — the key Codex's `env_key` names.
|
||||
* @returns {Record<string,string>}
|
||||
*/
|
||||
function routerEnvForSpawn(lane) {
|
||||
if (!lane || lane.tool !== "codex") return {};
|
||||
const profile = readOrNull(lane.router_profile);
|
||||
return profile && profile.apiKey ? { [CODEX_KEY_ENV]: profile.apiKey } : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-run the attach for every lane already on `profileName`.
|
||||
*
|
||||
* Attaching COPIES the profile — a claude lane's key and base URL live in its
|
||||
* own settings file from then on. So an edit that stopped here would be inert
|
||||
* where it matters most: rotating a leaked key in Settings would leave every
|
||||
* claude lane authenticating with the revoked one, forever, with no in-product
|
||||
* way to notice or fix it.
|
||||
*
|
||||
* Per-lane best effort: a lane whose directory is gone, or whose settings file
|
||||
* a human has since broken, must not fail the edit for every other lane.
|
||||
*
|
||||
* @returns {{updated:number, failed:string[]}}
|
||||
*/
|
||||
function reapplyToLanes(profileName) {
|
||||
const failed = [];
|
||||
let updated = 0;
|
||||
for (const lane of lanesByRouter(profileName)) {
|
||||
try {
|
||||
applyRouterToLane(lane, profileName);
|
||||
updated += 1;
|
||||
} catch (e) {
|
||||
failed.push(`${lane.id} (${lane.title || lane.cwd}): ${e.code || e.message}`);
|
||||
}
|
||||
}
|
||||
return { updated, failed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-write the router layer of every codex lane on a router, into the CURRENT
|
||||
* `$CODEX_HOME`. Called after CODEX_HOME moves: the layer `codex -p <name>`
|
||||
* loads lives there, so without this every such lane would fail to start.
|
||||
* Per-lane best effort, like `reapplyToLanes`.
|
||||
*
|
||||
* @returns {{updated:number, failed:string[]}}
|
||||
*/
|
||||
function reapplyCodexLayers() {
|
||||
const failed = [];
|
||||
let updated = 0;
|
||||
for (const lane of listLanes()) {
|
||||
if (lane.tool !== "codex" || !lane.router_profile || lane.router_profile === "native") continue;
|
||||
try {
|
||||
applyRouterToLane(lane, lane.router_profile);
|
||||
updated += 1;
|
||||
} catch (e) {
|
||||
failed.push(`${lane.id} (${lane.title || lane.cwd}): ${e.code || e.message}`);
|
||||
}
|
||||
}
|
||||
return { updated, failed };
|
||||
}
|
||||
|
||||
/**
|
||||
* The lanes naming an AI Router profile the store no longer has — after
|
||||
* CLAUDE_HOME moved (the store is `<CLAUDE_HOME>/agenthub-routers/`), such a
|
||||
* lane starts on its base configuration (`readOrNull`), so the move says so.
|
||||
* @returns {string[]} `"<id> (<title>): <profile>"` per lane.
|
||||
*/
|
||||
function lanesWithMissingRouter() {
|
||||
return listLanes()
|
||||
.filter(
|
||||
(l) => l.router_profile && l.router_profile !== "native" && !readOrNull(l.router_profile)
|
||||
)
|
||||
.map((l) => `${l.id} (${l.title || l.cwd}): ${l.router_profile}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach `profileName` from every lane using it, and delete the Codex layer
|
||||
* file it wrote.
|
||||
*
|
||||
* Deleting a profile has to reach the lanes for the same reason an edit does,
|
||||
* and more urgently: a claude lane keeps the deleted router's key and base URL
|
||||
* in its settings file and would go on routing to a dead endpoint with a key
|
||||
* the user believes they have removed. Each lane is reset to native — which now
|
||||
* UNSETS those keys — and its `router_profile` cleared.
|
||||
*
|
||||
* The layer file matters too: left behind, a later profile that slugifies to
|
||||
* the same name silently inherits this one's `base_url`.
|
||||
*/
|
||||
function detachFromLanes(profileName) {
|
||||
const failed = [];
|
||||
let updated = 0;
|
||||
for (const lane of lanesByRouter(profileName)) {
|
||||
try {
|
||||
applyRouterToLane(lane, routers.BUILTIN_NAME);
|
||||
setRouterProfile(lane.id, null);
|
||||
updated += 1;
|
||||
} catch (e) {
|
||||
failed.push(`${lane.id} (${lane.title || lane.cwd}): ${e.code || e.message}`);
|
||||
}
|
||||
}
|
||||
function resumeRouter(sessionId) {
|
||||
if (!sessionId) return null;
|
||||
const name = require("./dashboard-runs").routerForSession(sessionId);
|
||||
if (!name) return null;
|
||||
try {
|
||||
fs.rmSync(path.join(getCodexHome(), `${profileName}.config.toml`), { force: true });
|
||||
routers.getRouter(name);
|
||||
return name;
|
||||
} catch {
|
||||
/* best effort — an orphan layer is a leak, not a failure to report */
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Argv and env one spawn needs for `profile` (from `resolveRouter`): Claude's
|
||||
* `--settings=<file>`, Codex's `-p <name>` plus the key. Writes the file.
|
||||
* @returns {{argv: string[], env: Record<string,string>}}
|
||||
*/
|
||||
function routerForSpawn(tool, profile) {
|
||||
if (!profile) return { argv: [], env: {} };
|
||||
if (tool === "codex") {
|
||||
writeCodexLayer(profile);
|
||||
return {
|
||||
argv: ["-p", profile.name],
|
||||
env: profile.apiKey ? { [CODEX_KEY_ENV]: profile.apiKey } : {},
|
||||
};
|
||||
}
|
||||
// `=` form: one argv element, never read as an option of its own.
|
||||
return { argv: [`--settings=${writeClaudeSettings(profile)}`], env: {} };
|
||||
}
|
||||
|
||||
/**
|
||||
* After an edit: rewrite whichever of the profile's files exist, so a live
|
||||
* Claude session and the next Codex one see the new key/endpoint. Files that
|
||||
* do not exist yet are written at the next spawn.
|
||||
*/
|
||||
function refreshProfileFiles(name) {
|
||||
const profile = routers.getRouter(name);
|
||||
if (fs.existsSync(claudeSettingsPath(name))) writeClaudeSettings(profile);
|
||||
if (fs.existsSync(codexLayerPath(name))) writeCodexLayer(profile);
|
||||
}
|
||||
|
||||
/**
|
||||
* After a delete: the files hold the deleted router's key and endpoint. Left
|
||||
* behind, a later profile that slugifies to the same name would also inherit
|
||||
* this one's `base_url` in Codex.
|
||||
*/
|
||||
function removeProfileFiles(name) {
|
||||
for (const file of [claudeSettingsPath(name), codexLayerPath(name)]) {
|
||||
try {
|
||||
fs.rmSync(file, { force: true });
|
||||
} catch {
|
||||
/* best effort — an orphan file is a leak, not a failure to report */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Take the old per-workspace router out, once: the eight env keys AgentHub
|
||||
* wrote into a claude workspace's `.claude/settings.local.json` (they would
|
||||
* otherwise route every session there, native ones included) and the
|
||||
* `lanes.router_profile` column. A lane whose file cannot be cleaned (not
|
||||
* JSON, say) keeps its column and is tried again at the next start.
|
||||
*/
|
||||
function clearLaneRouters() {
|
||||
const { db } = require("../db");
|
||||
const { patchSettings } = require("./cc-mutate");
|
||||
const keys = Object.keys(envForProfile({ models: {} })).map((k) => `env.${k}`);
|
||||
const lanes = db
|
||||
.prepare("SELECT id, cwd, tool FROM lanes WHERE router_profile IS NOT NULL")
|
||||
.all();
|
||||
for (const lane of lanes) {
|
||||
try {
|
||||
if (lane.tool === "claude" && fs.existsSync(path.join(lane.cwd, ".claude"))) {
|
||||
patchSettings({ scope: "project-local", cwd: lane.cwd, set: {}, unset: keys });
|
||||
}
|
||||
db.prepare("UPDATE lanes SET router_profile = NULL WHERE id = ?").run(lane.id);
|
||||
} catch (e) {
|
||||
console.warn(`[router] could not clear workspace ${lane.id}'s old router: ${e.message}`);
|
||||
}
|
||||
}
|
||||
return { updated, failed };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
applyRouterToLane,
|
||||
reapplyToLanes,
|
||||
reapplyCodexLayers,
|
||||
detachFromLanes,
|
||||
routerArgvForSpawn,
|
||||
routerEnvForSpawn,
|
||||
lanesWithMissingRouter,
|
||||
resolveRouter,
|
||||
resumeRouter,
|
||||
routerForSpawn,
|
||||
refreshProfileFiles,
|
||||
removeProfileFiles,
|
||||
clearLaneRouters,
|
||||
CODEX_KEY_ENV,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user