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
+23 -4
View File
@@ -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 };
-39
View File
@@ -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
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 : {}),
};
+149 -248
View File
@@ -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,
};