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
+1 -1
View File
File diff suppressed because one or more lines are too long
+3 -3
View File
File diff suppressed because one or more lines are too long
+3
View File
@@ -0,0 +1,3 @@
<svg width="248" height="248" viewBox="0 0 248 248" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M52.4285 162.873L98.7844 136.879L99.5485 134.602L98.7844 133.334H96.4921L88.7237 132.862L62.2346 132.153L39.3113 131.207L17.0249 130.026L11.4214 128.844L6.2 121.873L6.7094 118.447L11.4214 115.257L18.171 115.847L33.0711 116.911L55.485 118.447L71.6586 119.392L95.728 121.873H99.5485L100.058 120.337L98.7844 119.392L97.7656 118.447L74.5877 102.732L49.4995 86.1905L36.3823 76.62L29.3779 71.7757L25.8121 67.2858L24.2839 57.3608L30.6515 50.2716L39.3113 50.8623L41.4763 51.4531L50.2636 58.1879L68.9842 72.7209L93.4357 90.6804L97.0015 93.6343L98.4374 92.6652L98.6571 91.9801L97.0015 89.2625L83.757 65.2772L69.621 40.8192L63.2534 30.6579L61.5978 24.632C60.9565 22.1032 60.579 20.0111 60.579 17.4246L67.8381 7.49965L71.9133 6.19995L81.7193 7.49965L85.7946 11.0443L91.9074 24.9865L101.714 46.8451L116.996 76.62L121.453 85.4816L123.873 93.6343L124.764 96.1155H126.292V94.6976L127.566 77.9197L129.858 57.3608L132.15 30.8942L132.915 23.4505L136.608 14.4708L143.994 9.62643L149.725 12.344L154.437 19.0788L153.8 23.4505L150.998 41.6463L145.522 70.1215L141.957 89.2625H143.994L146.414 86.7813L156.093 74.0206L172.266 53.698L179.398 45.6635L187.803 36.802L193.152 32.5484H203.34L210.726 43.6549L207.415 55.1159L196.972 68.3492L188.312 79.5739L175.896 96.2095L168.191 109.585L168.882 110.689L170.738 110.53L198.755 104.504L213.91 101.787L231.994 98.7149L240.144 102.496L241.036 106.395L237.852 114.311L218.495 119.037L195.826 123.645L162.07 131.592L161.696 131.893L162.137 132.547L177.36 133.925L183.855 134.279H199.774L229.447 136.524L237.215 141.605L241.8 147.867L241.036 152.711L229.065 158.737L213.019 154.956L175.45 145.977L162.587 142.787H160.805V143.85L171.502 154.366L191.242 172.089L215.82 195.011L217.094 200.682L213.91 205.172L210.599 204.699L188.949 188.394L180.544 181.069L161.696 165.118H160.422V166.772L164.752 173.152L187.803 207.771L188.949 218.405L187.294 221.832L181.308 223.959L174.813 222.777L161.187 203.754L147.305 182.486L136.098 163.345L134.745 164.2L128.075 235.42L125.019 239.082L117.887 241.8L111.902 237.31L108.718 229.984L111.902 215.452L115.722 196.547L118.779 181.541L121.58 162.873L123.291 156.636L123.14 156.219L121.773 156.449L107.699 175.752L86.304 204.699L69.3663 222.777L65.291 224.431L58.2867 220.768L58.9235 214.27L62.8713 208.48L86.304 178.705L100.44 160.155L109.551 149.507L109.462 147.967L108.959 147.924L46.6977 188.512L35.6182 189.93L30.7788 185.44L31.4156 178.115L33.7079 175.752L52.4285 162.873Z" fill="#D97757"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg version="1.2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 250 250" width="250" height="250"><defs><linearGradient id="P" gradientUnits="userSpaceOnUse"/><linearGradient id="g1" x2="1" href="#P" gradientTransform="matrix(0,249.335,-249.128,0,125,.332)"><stop stop-color="#b1a7ff"/><stop offset=".5" stop-color="#7a9dff"/><stop offset="1" stop-color="#3941ff"/></linearGradient></defs><style>.a{fill:url(#g1)}</style><path class="a" d="m84.3 5.1q3.7-1.5 7.7-2.6 3.9-1 7.9-1.6 4-0.5 8.1-0.6 4 0 8 0.5 20.7 2.4 37.1 17.7 0.1 0.1 0.4 0.3 0.1 0 0.2 0 0 0 0.2 0 0 0 0.1 0 0 0 0.1 0 5.2-1.4 10.7-1.9 5.4-0.4 10.7 0.1 5.5 0.4 10.7 1.9 5.2 1.3 10.1 3.6l0.6 0.4 1.6 0.8q5.2 2.5 9.7 6.1 4.7 3.4 8.6 7.7 3.8 4.3 6.9 9.2 3 4.8 5.2 10.2 4.3 10.5 4.3 22.1 0.2 2.1 0 4.2-0.1 2.2-0.2 4.3-0.3 2.1-0.7 4.3-0.4 2.1-0.9 4.1 0 0.2 0 0.4 0 0.2 0 0.5 0 0.1 0.1 0.4 0.1 0.1 0.3 0.3 12.3 12.6 16.3 30 6 29.7-12.2 53.5l-1.9 2.2q-3 3.5-6.5 6.4-3.4 3.1-7.3 5.5-3.8 2.4-8.1 4.2-4.1 1.9-8.5 3.2-0.3 0-0.4 0.2-0.3 0-0.4 0.1-0.1 0.1-0.3 0.4 0 0.1-0.1 0.3c-2.7 7.7-5.3 14.2-10.2 20.7-12.5 16.5-30.8 25.5-51.5 25.5q-24.6-0.1-43.6-18.1-0.2-0.1-0.4-0.2-0.2-0.1-0.4-0.1-0.2 0-0.3 0-0.3 0-0.4 0c-5.4 1.7-10.9 1.9-16.7 1.9q-3.5 0-7-0.5-3.4-0.4-6.9-1.2-3.3-0.8-6.6-2-3.3-1.2-6.4-2.8-3.3-1.6-6.4-3.6-3-2-5.8-4.3-3-2.3-5.5-5-2.5-2.6-4.6-5.6c-2.2-2.7-4.3-5.4-5.8-8.5q-0.8-1.6-1.6-3.2-0.6-1.7-1.3-3.3-0.7-1.7-1.2-3.4-0.5-1.6-1-3.4-1.1-4-1.6-7.9-0.6-4-0.6-8 0-4 0.6-8 0.4-4 1.4-8 0 0 0-0.1 0-0.1 0-0.1 0.2-0.2 0.2-0.3 0-0.1-0.2-0.1 0-0.2 0-0.3 0-0.1-0.1-0.1 0-0.2 0-0.2-0.1-0.1-0.1-0.1-2.4-2.5-4.6-5.2-2.1-2.7-4-5.4-1.7-3-3.2-6-1.5-3.1-2.6-6.3-0.8-2-1.3-4.1-0.7-2-1.1-4-0.4-2.1-0.7-4.2-0.2-2.2-0.4-4.3-0.2-2.8-0.1-5.6 0-2.8 0.3-5.4 0.1-2.8 0.6-5.6 0.4-2.8 1.1-5.5 7-23.1 26.9-36.3 4.3-2.9 8.2-4.5 4.5-1.9 9-3.2 0.2 0 0.3-0.1 0.1-0.2 0.3-0.3 0.1 0 0.1-0.3 0.1-0.1 0.1-0.2 1-3.1 2.2-6 1-2.9 2.5-5.7 1.5-3 3.2-5.6 1.7-2.7 3.7-5.1 2.5-3.2 5.3-5.9 3-2.8 6.1-5.4 3.2-2.4 6.8-4.4 3.5-2 7.2-3.5zm48.3 146.4c-2.3 0.1-4.4 1-6 2.8-1.5 1.6-2.4 3.7-2.4 5.9 0 2.3 0.9 4.4 2.4 6.2 1.6 1.6 3.7 2.5 6 2.6h50.4c2.4 0.1 4.8-0.6 6.5-2.4 1.7-1.6 2.8-4 2.8-6.4 0-2.4-1.1-4.7-2.8-6.3-1.7-1.8-4.1-2.6-6.5-2.4zm-56.7-64.9c-1.2-1.9-3-3.4-5.3-3.9-2.2-0.5-4.5-0.3-6.5 0.9-2 1.1-3.5 3-4.1 5.2-0.7 2.2-0.4 4.6 0.6 6.5l17.7 30.9-17.5 29.5c-1.2 2-1.6 4.5-1.1 6.8 0.7 2.3 2.1 4.1 4.1 5.3 2 1.2 4.4 1.6 6.7 0.9 2.2-0.5 4.2-1.9 5.4-3.9l20.1-34.1q0.7-0.9 0.9-2.1 0.3-1.1 0.3-2.3 0-1.2-0.3-2.2-0.2-1.2-0.8-2.2z"/></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 29 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 29 KiB

+1
View File
@@ -0,0 +1 @@
<svg version="1.2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 250 250" width="250" height="250"><defs><linearGradient id="P" gradientUnits="userSpaceOnUse"/><linearGradient id="g1" x2="1" href="#P" gradientTransform="matrix(288.354,233.302,-248.835,307.552,-6.865,4.771)"><stop stop-color="#ff4d4d"/><stop offset="1" stop-color="#991b1b"/></linearGradient><linearGradient id="g2" x2="1" href="#P" gradientTransform="matrix(44.906,54.969,-62.556,51.105,0,100.75)"><stop stop-color="#ff4d4d"/><stop offset="1" stop-color="#991b1b"/></linearGradient><linearGradient id="g3" x2="1" href="#P" gradientTransform="matrix(52.229,46.969,-41.209,45.824,201.927,103.677)"><stop stop-color="#ff4d4d"/><stop offset="1" stop-color="#991b1b"/></linearGradient></defs><style>.a{fill:url(#g1)}.b{fill:url(#g2)}.c{fill:url(#g3)}.d{fill:#ff4d4d}.e{fill:#050810}.f{fill:#00e5cc}</style><path class="a" d="m125 26.8c-65.9-0.1-98.9 54.9-98.9 98.9 0 43.9 33 87.9 65.9 98.9v21.9h22v-21.9c0 0 11 4.3 22 0v21.9h22v-21.9c32.9-11 65.9-55 65.9-98.9 0-44-33-99-98.9-99z"/><path class="b" d="m37.1 103.7c-33-11-44 11-33 32.9 11 22 33 11 44-10.9 6.6-15.4 0-22-11-22z"/><path class="c" d="m212.9 103.7c33-11 44 11 33 32.9-11 22-33 11-44-10.9-6.6-15.4 0-22 11-22z"/><path class="d" d="m57.4 19.5c4.9-2.9 10.8-2.4 16.8 0.4 6 2.8 12.7 8.1 20.2 15.5 0.6 0.6 0.9 1.5 0.9 2.3 0 0.9-0.3 1.8-0.9 2.4-0.7 0.6-1.5 0.9-2.4 0.9-0.8 0-1.7-0.3-2.3-0.9-7.2-7.2-13.3-11.9-18.3-14.2-4.9-2.3-8.3-2.1-10.6-0.7-0.8 0.4-1.6 0.4-2.4 0.2-0.9-0.2-1.5-0.7-2-1.5-0.4-0.7-0.5-1.5-0.4-2.3 0.2-0.9 0.7-1.6 1.4-2.1zm118.4 0.4c6-2.8 11.9-3.3 16.8-0.4 0.7 0.5 1.2 1.2 1.4 2.1 0.1 0.8 0 1.6-0.4 2.3-0.5 0.8-1.1 1.3-2 1.5-0.8 0.2-1.6 0.2-2.4-0.2-2.3-1.4-5.7-1.6-10.6 0.7-5 2.3-11.1 7-18.3 14.2-0.6 0.6-1.5 0.9-2.3 0.9-0.9 0-1.7-0.3-2.4-0.9-0.6-0.6-0.9-1.5-0.9-2.4 0-0.8 0.3-1.7 0.9-2.3 7.5-7.4 14.2-12.7 20.2-15.5z"/><path class="e" d="m92 94.9c3.5 0 6.9-1.4 9.4-3.9 2.4-2.5 3.8-5.8 3.8-9.3 0-3.5-1.4-6.9-3.8-9.3-2.5-2.5-5.9-3.9-9.4-3.9-3.5 0-6.8 1.4-9.3 3.9-2.5 2.4-3.9 5.8-3.9 9.3 0 3.5 1.4 6.8 3.9 9.3 2.5 2.5 5.8 3.9 9.3 3.9zm66 0c3.5 0 6.8-1.4 9.3-3.9 2.5-2.5 3.9-5.8 3.9-9.3 0-3.5-1.4-6.9-3.9-9.3-2.5-2.5-5.8-3.9-9.3-3.9-3.5 0-6.9 1.4-9.4 3.9-2.4 2.4-3.8 5.8-3.8 9.3 0 3.5 1.4 6.8 3.8 9.3 2.5 2.5 5.9 3.9 9.4 3.9z"/><path class="f" d="m94.2 85c1.5 0 2.9-0.6 3.9-1.6 1-1 1.6-2.4 1.6-3.9 0-1.5-0.6-2.9-1.6-3.9-1-1-2.4-1.6-3.9-1.6-1.4 0-2.8 0.6-3.9 1.6-1 1-1.6 2.4-1.6 3.9 0 1.5 0.6 2.8 1.6 3.9 1.1 1 2.5 1.6 3.9 1.6zm66 0c1.4 0 2.8-0.6 3.8-1.6 1.1-1 1.7-2.4 1.7-3.9 0-1.5-0.6-2.8-1.7-3.9-1-1-2.4-1.6-3.8-1.6-1.5 0-2.9 0.6-3.9 1.6-1 1-1.6 2.4-1.6 3.9 0 1.5 0.6 2.9 1.6 3.9 1 1 2.4 1.6 3.9 1.6z"/></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

+24 -4
View File
@@ -1,14 +1,23 @@
/** /**
* @file AiToolIcon.tsx * @file AiToolIcon.tsx
* @description An AI tool's icon: the server's cached favicon * @description An AI tool's icon: the mark shipped in `client/public/ai-tools/`
* (GET /api/ai-tools/:id/icon), or the label's first letter when there is * when there is one (a light/dark pair where a mark needs one per theme), else
* none. Shared by the AI Tools modal and the Add-lane tool picker. * the server's cached favicon (GET /api/ai-tools/:id/icon), else the label's
* first letter. Every tool icon in the app renders through here.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { useState } from "react"; import { useState } from "react";
import { api, type AiTool } from "../lib/api"; import { api, type AiTool } from "../lib/api";
/** Shipped marks by tool id; `[light, dark]` swaps with the theme (CSS only). */
const BUNDLED: Record<string, string | [string, string]> = {
claude: "/ai-tools/claude.svg",
codex: "/ai-tools/codex.svg",
openclaw: "/ai-tools/openclaw.svg",
hermes: ["/ai-tools/hermes-black.svg", "/ai-tools/hermes-white.svg"],
};
/** /**
* @param props.tool - The catalog entry. * @param props.tool - The catalog entry.
* @param props.className - Size classes (default `h-8 w-8`). * @param props.className - Size classes (default `h-8 w-8`).
@@ -22,13 +31,24 @@ export function AiToolIcon({
}) { }) {
// Keyed by id: one instance showing another tool must retry its icon. // Keyed by id: one instance showing another tool must retry its icon.
const [failedId, setFailedId] = useState<string | null>(null); const [failedId, setFailedId] = useState<string | null>(null);
const box = `${className} shrink-0 rounded-md bg-surface-4 object-contain p-1`;
const bundled = BUNDLED[tool.id];
if (typeof bundled === "string") return <img src={bundled} alt="" className={box} />;
if (bundled) {
return (
<>
<img src={bundled[0]} alt="" className={`${box} dark:hidden`} />
<img src={bundled[1]} alt="" className={`${box} hidden dark:block`} />
</>
);
}
if (failedId !== tool.id) { if (failedId !== tool.id) {
return ( return (
<img <img
src={api.aiTools.iconUrl(tool.id)} src={api.aiTools.iconUrl(tool.id)}
alt="" alt=""
onError={() => setFailedId(tool.id)} onError={() => setFailedId(tool.id)}
className={`${className} shrink-0 rounded-md bg-surface-4 object-contain p-1`} className={box}
/> />
); );
} }
+47 -32
View File
@@ -1,6 +1,6 @@
/** /**
* @file Sidebar.tsx * @file Sidebar.tsx
* @description The app's left sidebar: the brand with a small collapse/expand button in its corner, the navigation links and the workspace list. It also owns the connection details modal (opened from the status bar), which carries the version and the update check — the sidebar itself has no footer, language or theme controls (those live in Settings → General / Appearance). The collapsed state is kept in localStorage. * @description The app's left sidebar: the brand, the navigation links and the workspace list, with Settings and the collapse/expand button pinned to its foot. It also owns the connection details modal (opened from the status bar), which carries the version and the update check — the sidebar itself has no footer, language or theme controls (those live in Settings → General / Appearance). The collapsed state is kept in localStorage.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
/* ============================================================================= /* =============================================================================
@@ -82,7 +82,6 @@ function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
const NAV_KEYS = [ const NAV_KEYS = [
{ to: "/", icon: LayoutDashboard, key: "nav:dashboard" }, { to: "/", icon: LayoutDashboard, key: "nav:dashboard" },
{ to: "/sessions", icon: FolderOpen, key: "nav:sessions" }, { to: "/sessions", icon: FolderOpen, key: "nav:sessions" },
{ to: "/settings", icon: Settings, key: "nav:settings" },
] as const; ] as const;
// SmartGift wordmark, replacing the old text subtitle. Remote URLs: the logo is // SmartGift wordmark, replacing the old text subtitle. Remote URLs: the logo is
@@ -384,32 +383,23 @@ export function Sidebar({
onUpdateAvailableChange?.(updateAvailable); onUpdateAvailableChange?.(updateAvailable);
}, [updateAvailable, onUpdateAvailableChange]); }, [updateAvailable, onUpdateAvailableChange]);
const navClass = ({ isActive }: { isActive: boolean }) =>
`flex items-center gap-3 rounded-lg text-sm font-medium transition-colors duration-150 ${
collapsed ? "justify-center px-2 py-2.5" : "px-3 py-2.5"
} ${
isActive
? "bg-accent/10 text-accent border border-accent/20"
: "text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
}`;
return ( return (
<aside <aside
className={`fixed left-0 top-0 bottom-0 bg-surface-1 border-r border-border flex flex-col z-30 overflow-hidden transition-[width] duration-200 ${ className={`fixed left-0 top-0 bottom-0 bg-surface-1 border-r border-border flex flex-col z-30 overflow-hidden transition-[width] duration-200 ${
collapsed ? "w-[4.25rem]" : "w-60" collapsed ? "w-[4.25rem]" : "w-60"
}`} }`}
> >
{/* Brand, with the collapse toggle as a small button in its corner (or <div className="px-3 py-4 border-b border-border flex-shrink-0">
under the mark on the rail) rather than a full-width row. */} <div className="flex flex-col items-center justify-center">
<div className="relative px-3 py-4 border-b border-border flex-shrink-0">
<button
type="button"
onClick={onToggle}
data-testid="sidebar-toggle"
title={collapsed ? t("nav:expand") : t("nav:collapse")}
aria-label={collapsed ? t("nav:expand") : t("nav:collapse")}
className={`z-10 inline-flex h-6 w-6 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary ${
collapsed ? "absolute bottom-1 left-1/2 -translate-x-1/2" : "absolute right-1.5 top-1.5"
}`}
>
{collapsed ? (
<PanelLeftOpen className="h-3.5 w-3.5" />
) : (
<PanelLeftClose className="h-3.5 w-3.5" />
)}
</button>
<div className={`flex flex-col items-center justify-center ${collapsed ? "pb-4" : ""}`}>
{collapsed ? ( {collapsed ? (
<img src={BRAND_MARK} alt={t("nav:brandSub")} className="w-8 h-8 flex-shrink-0" /> <img src={BRAND_MARK} alt={t("nav:brandSub")} className="w-8 h-8 flex-shrink-0" />
) : ( ) : (
@@ -431,7 +421,7 @@ export function Sidebar({
</div> </div>
{/* Nav - only this section scrolls when its items overflow; the rest of {/* Nav - only this section scrolls when its items overflow; the rest of
the sidebar (brand, language, collapse toggle, footer) stays pinned. the sidebar (brand, and the Settings + collapse footer) stays pinned.
Chevron buttons appear at the edges when content is clipped, so the Chevron buttons appear at the edges when content is clipped, so the
user knows there's more to reach without inspecting the scrollbar. */} user knows there's more to reach without inspecting the scrollbar. */}
<div className="flex-1 min-h-0 relative flex"> <div className="flex-1 min-h-0 relative flex">
@@ -444,15 +434,7 @@ export function Sidebar({
to={to} to={to}
end={to === "/"} end={to === "/"}
title={collapsed ? label : undefined} title={collapsed ? label : undefined}
className={({ isActive }) => className={navClass}
`flex items-center gap-3 rounded-lg text-sm font-medium transition-colors duration-150 ${
collapsed ? "justify-center px-2 py-2.5" : "px-3 py-2.5"
} ${
isActive
? "bg-accent/10 text-accent border border-accent/20"
: "text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
}`
}
> >
<Icon className="w-4 h-4 flex-shrink-0" /> <Icon className="w-4 h-4 flex-shrink-0" />
{!collapsed && <span>{label}</span>} {!collapsed && <span>{label}</span>}
@@ -499,6 +481,39 @@ export function Sidebar({
)} )}
</div> </div>
{/* Settings and the collapse toggle stick to the foot, out of the
scrolling nav. On the rail they stack. */}
<div
className={`flex flex-shrink-0 gap-1 border-t border-border px-2 py-2 ${
collapsed ? "flex-col" : "items-center"
}`}
>
<NavLink
to="/settings"
title={collapsed ? t("nav:settings") : undefined}
className={(p) => `${navClass(p)} ${collapsed ? "" : "flex-1"}`}
>
<Settings className="w-4 h-4 flex-shrink-0" />
{!collapsed && <span>{t("nav:settings")}</span>}
</NavLink>
<button
type="button"
onClick={onToggle}
data-testid="sidebar-toggle"
title={collapsed ? t("nav:expand") : t("nav:collapse")}
aria-label={collapsed ? t("nav:expand") : t("nav:collapse")}
className={`inline-flex items-center justify-center rounded-lg text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary ${
collapsed ? "py-2.5" : "h-9 w-9 flex-shrink-0"
}`}
>
{collapsed ? (
<PanelLeftOpen className="h-4 w-4" />
) : (
<PanelLeftClose className="h-4 w-4" />
)}
</button>
</div>
<ConnectionStatusModal <ConnectionStatusModal
open={connectionOpen} open={connectionOpen}
onClose={() => onConnectionOpenChange(false)} onClose={() => onConnectionOpenChange(false)}
+2 -167
View File
@@ -24,7 +24,7 @@ import { ConfirmModal } from "../ConfirmModal";
import { CwdAutocomplete } from "../run/RunSetup"; import { CwdAutocomplete } from "../run/RunSetup";
import { FolderBrowseModal } from "./FolderBrowseModal"; import { FolderBrowseModal } from "./FolderBrowseModal";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import type { AiRouter, CwdSuggestion } from "../../lib/api"; import type { CwdSuggestion } from "../../lib/api";
import type { Lane, LaneWorktree } from "../../lib/types"; import type { Lane, LaneWorktree } from "../../lib/types";
import { useAiTools } from "../../hooks/useAiTools"; import { useAiTools } from "../../hooks/useAiTools";
import { AiToolIcon } from "../AiToolIcon"; import { AiToolIcon } from "../AiToolIcon";
@@ -106,10 +106,6 @@ export function AddLaneModal({
// repo's worktrees, hence the busy markers below. // repo's worktrees, hence the busy markers below.
const [branchMode, setBranchMode] = useState<"new" | "existing">("new"); const [branchMode, setBranchMode] = useState<"new" | "existing">("new");
const [base, setBase] = useState(""); const [base, setBase] = useState("");
// "native" is the built-in profile, not a null state — see server/lib/ai-routers.js.
const [router, setRouter] = useState("native");
const [routers, setRouters] = useState<AiRouter[]>([]);
const [routersLoaded, setRoutersLoaded] = useState(false);
const [branchesError, setBranchesError] = useState<string | null>(null); const [branchesError, setBranchesError] = useState<string | null>(null);
// null = the default AI tool (Settings → AI Tools), resolved at submit time. // null = the default AI tool (Settings → AI Tools), resolved at submit time.
const [pickedTool, setPickedTool] = useState<string | null>(null); const [pickedTool, setPickedTool] = useState<string | null>(null);
@@ -123,20 +119,15 @@ export function AddLaneModal({
const [toolListOpen, setToolListOpen] = useState(false); const [toolListOpen, setToolListOpen] = useState(false);
const pickTool = (id: string) => { const pickTool = (id: string) => {
setPickedTool(id === defaultTool ? null : id); setPickedTool(id === defaultTool ? null : id);
// A tool with no endpoint to rewrite runs native only (see router-apply.js).
if (!aiTools.byId(id)?.routerCapable) setRouter("native");
setToolListOpen(false); setToolListOpen(false);
}; };
const noTools = aiTools.loaded && aiTools.defaultId === null; const noTools = aiTools.loaded && aiTools.defaultId === null;
const toolRouterCapable =
aiTools.byId(tool)?.routerCapable ?? (tool === "claude" || tool === "codex");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [browseOpen, setBrowseOpen] = useState(false); const [browseOpen, setBrowseOpen] = useState(false);
const [setupResult, setSetupResult] = useState<{ const [setupResult, setSetupResult] = useState<{
profile: "scaffolded" | "skipped" | "failed"; profile: "scaffolded" | "skipped" | "failed";
mcp: "ok" | "failed"; mcp: "ok" | "failed";
router: "ok" | "skipped" | "failed";
} | null>(null); } | null>(null);
const reset = () => { const reset = () => {
@@ -148,8 +139,6 @@ export function AddLaneModal({
setWorktrees([]); setWorktrees([]);
setBranchMode("new"); setBranchMode("new");
setBase(""); setBase("");
setRouter("native");
setRoutersLoaded(false);
setPickedTool(null); setPickedTool(null);
setBranchesError(null); setBranchesError(null);
setError(null); setError(null);
@@ -169,36 +158,6 @@ export function AddLaneModal({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]); }, [open]);
// A session that boots on the vendor's API keeps talking to it for its whole
// life, so the router has to be picked here, before the lane exists.
useEffect(() => {
if (!open) return;
let cancelled = false;
api.aiRouters
.list()
.then((r) => {
if (cancelled) return;
setRouters(r.items);
// A profile deleted in another tab while this modal sat open would
// otherwise stay selected in state while the select showed no matching
// option — and submit would send a name the server no longer knows.
setRouter((current) =>
current === "native" || r.items.some((item) => item.name === current) ? current : "native"
);
})
.catch(() => {
// Quiet: with no list the radio simply offers Native, and the lane is
// still created — a router is an optional extra, never a precondition.
if (!cancelled) setRouters([]);
})
.finally(() => {
if (!cancelled) setRoutersLoaded(true);
});
return () => {
cancelled = true;
};
}, [open]);
// Look up the repo's branches once the path settles - debounced so every // Look up the repo's branches once the path settles - debounced so every
// keystroke while typing a path doesn't fire a request against a path that // keystroke while typing a path doesn't fire a request against a path that
// isn't finished yet. Worktree mode only: "Repo" mode adopts as-is and // isn't finished yet. Worktree mode only: "Repo" mode adopts as-is and
@@ -236,10 +195,6 @@ export function AddLaneModal({
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [mode, sourceRepo, t]); }, [mode, sourceRepo, t]);
// Native is the built-in profile, so this is one call either way — there is
// no "no router" branch to keep in step with the real one.
const applyRouter = (laneId: number) => api.lanes.routerApply(laneId, router);
const submit = async () => { const submit = async () => {
const repo = sourceRepo.trim(); const repo = sourceRepo.trim();
const name = title.trim(); const name = title.trim();
@@ -274,18 +229,6 @@ export function AddLaneModal({
setBusy(false); setBusy(false);
return; return;
} }
// A session that booted on the vendor's API would keep talking to it
// for its whole life, so the router is applied before anything runs.
try {
await applyRouter(result.lane.id);
} catch (routerErr) {
// The lane exists and is already listed — name what did not finish
// instead of closing on a half-set-up lane.
setError(routerErr instanceof Error ? routerErr.message : String(routerErr));
setBusy(false);
return;
}
reset(); reset();
onClose(); onClose();
} catch (err) { } catch (err) {
@@ -322,10 +265,9 @@ export function AddLaneModal({
return; return;
} }
const [profileOutcome, mcpOutcome, routerOutcome] = await Promise.allSettled([ const [profileOutcome, mcpOutcome] = await Promise.allSettled([
api.lanes.profileInit(result.lane.id), api.lanes.profileInit(result.lane.id),
api.lanes.mcpSync(result.lane.id), api.lanes.mcpSync(result.lane.id),
applyRouter(result.lane.id),
]); ]);
const outcome = { const outcome = {
profile: profile:
@@ -335,8 +277,6 @@ export function AddLaneModal({
: "skipped" : "skipped"
: "failed", : "failed",
mcp: mcpOutcome.status === "fulfilled" ? "ok" : "failed", mcp: mcpOutcome.status === "fulfilled" ? "ok" : "failed",
router:
routerOutcome.status !== "fulfilled" ? "failed" : router === "native" ? "skipped" : "ok",
} as const; } as const;
const anyFailed = Object.values(outcome).includes("failed"); const anyFailed = Object.values(outcome).includes("failed");
setSetupResult(outcome); setSetupResult(outcome);
@@ -346,9 +286,6 @@ export function AddLaneModal({
profile: profile:
profileOutcome.status === "fulfilled" ? profileOutcome.value : profileOutcome.reason, profileOutcome.status === "fulfilled" ? profileOutcome.value : profileOutcome.reason,
mcp: mcpOutcome.status === "fulfilled" ? mcpOutcome.value : mcpOutcome.reason, mcp: mcpOutcome.status === "fulfilled" ? mcpOutcome.value : mcpOutcome.reason,
// Never the profile itself: it would put an endpoint and a lane id in
// the browser console. (The key is never sent here at all.)
router: routerOutcome.status,
}); });
} }
@@ -690,103 +627,6 @@ export function AddLaneModal({
</div> </div>
)} )}
{/* The router is the one per-lane extra this form still asks for, so it
sits in the form itself — a disclosure around a single field only
hid it. The pipeline picker is gone entirely: every lane is created
on `default` (the server's own default) and the template is changed
from the lane's own header afterwards. */}
<div className="rounded-md border border-border-light bg-surface-0 p-2">
<div>
<fieldset>
<legend className="mb-1 text-xs text-fg-secondary">{t("addLaneRouterLabel")}</legend>
<div className="flex gap-2">
{(
[
{
value: "native",
label: t("addLaneRouterNativeTool", { tool: aiTools.label(tool) }),
},
{ value: "router", label: t("addLaneRouterCustom") },
] as const
).map((option) => {
const active =
option.value === "native" ? router === "native" : router !== "native";
// Clicking "AI Router" before the list lands would resolve back to
// Native and read as a dead control.
const disabled =
option.value === "router" && (!routersLoaded || !toolRouterCapable);
return (
<label
key={option.value}
title={
option.value === "router" && !toolRouterCapable
? t("router.nativeOnly", { tool: aiTools.label(tool) })
: undefined
}
// No radio dot: the card itself lights up when selected. That
// leaves the DISABLED state with nothing to show it, since the
// greyed-out dot was carrying it — hence the explicit branch.
// The input only goes visually hidden, so keyboard and screen
// readers still get a real radio group, and the card carries
// its focus ring.
className={`block flex-1 rounded-lg border p-2.5 text-center text-xs shadow-sm transition-colors focus-within:ring-2 focus-within:ring-accent/50 ${
disabled
? "cursor-not-allowed border-border bg-surface-2 text-fg-muted opacity-60"
: active
? "cursor-pointer border-accent bg-accent/10 text-accent"
: "cursor-pointer border-border bg-surface-2 text-fg-secondary hover:border-border-light hover:bg-surface-3"
}`}
>
<input
type="radio"
name="add-lane-router"
className="sr-only"
disabled={disabled}
checked={active}
onChange={() =>
// Switching to "AI Router" lands on the first saved
// profile; with none saved the radio stays on native and
// the select below explains why.
setRouter(
option.value === "native"
? "native"
: routers.find((r) => !r.builtin)?.name || "native"
)
}
/>
{option.label}
</label>
);
})}
</div>
{router !== "native" && (
<select
aria-label={t("addLaneRouterProfileLabel")}
value={router}
onChange={(e) => setRouter(e.target.value)}
className="mt-2 w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-accent focus:outline-none"
>
{routers
.filter((r) => !r.builtin)
.map((r) => (
<option key={r.name} value={r.name}>
{r.label}
</option>
))}
</select>
)}
<p className="mt-1 text-[10px] text-fg-muted">
{!routersLoaded
? t("addLaneRouterLoading")
: routers.some((r) => !r.builtin)
? t("addLaneRouterHint")
: t("addLaneRouterEmptyHint")}
</p>
</fieldset>
</div>
</div>
{setupResult && ( {setupResult && (
<div className="rounded-md border border-border-light bg-surface-0 p-2 space-y-1"> <div className="rounded-md border border-border-light bg-surface-0 p-2 space-y-1">
<p className="text-[10px] font-medium text-fg-secondary">{t("addLaneSetupTitle")}</p> <p className="text-[10px] font-medium text-fg-secondary">{t("addLaneSetupTitle")}</p>
@@ -798,11 +638,6 @@ export function AddLaneModal({
skipped: setupResult.profile === "skipped", skipped: setupResult.profile === "skipped",
}, },
{ label: t("addLaneSetupMcp"), ok: setupResult.mcp === "ok" }, { label: t("addLaneSetupMcp"), ok: setupResult.mcp === "ok" },
{
label: t("addLaneSetupRouter"),
ok: setupResult.router !== "failed",
skipped: setupResult.router === "skipped",
},
] as const ] as const
).map((row) => ( ).map((row) => (
<p key={row.label} className="flex items-center gap-1.5 text-[11px] text-fg-primary"> <p key={row.label} className="flex items-center gap-1.5 text-[11px] text-fg-primary">
+1 -171
View File
@@ -10,8 +10,6 @@
* progress: the pipeline map directly below this card draws the same number * progress: the pipeline map directly below this card draws the same number
* as named, coloured nodes, so a bar and a percent beside it were a worse * as named, coloured nodes, so a bar and a percent beside it were a worse
* second telling of it. The repo * second telling of it. The repo
* card also carries the lane's AI Router picker — the Add-lane modal is the
* only other place one is chosen, and a lane outlives its creation. The repo
* tooling dropdown (`LaneToolsMenu`, exported from this file — agents * tooling dropdown (`LaneToolsMenu`, exported from this file — agents
* install / MCP sync / dev-sync check / create-update readme) and the * install / MCP sync / dev-sync check / create-update readme) and the
* remove-lane button live in Workspace's page header / pipeline-picker row * remove-lane button live in Workspace's page header / pipeline-picker row
@@ -22,15 +20,13 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { useEffect, useRef, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { DestructiveLaneModal } from "./DestructiveLaneModal"; import { DestructiveLaneModal } from "./DestructiveLaneModal";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import { eventBus } from "../../lib/eventBus"; import { eventBus } from "../../lib/eventBus";
import { keepIfSame } from "../../lib/keepIfSame"; import { keepIfSame } from "../../lib/keepIfSame";
import type { AiRouter } from "../../lib/api";
import type { Lane, LaneRuntime, NamedLock } from "../../lib/types"; import type { Lane, LaneRuntime, NamedLock } from "../../lib/types";
import { useAiTools } from "../../hooks/useAiTools";
/** How often a mounted card re-probes its stack. Faster than the Changes panel's /** How often a mounted card re-probes its stack. Faster than the Changes panel's
* git refresh because a stack dying is exactly what the user needs to see, and slower than * git refresh because a stack dying is exactly what the user needs to see, and slower than
@@ -106,172 +102,6 @@ function useLaneLocks(laneSlot: number | null): NamedLock[] {
return locks; return locks;
} }
/**
* Which AI Router this lane runs against, and the one place it can be changed
* after the lane exists — the Add-lane modal is the only other one, and a lane
* outlives the moment it was created.
*
* Applying writes the lane's own config on disk as well as the DB column, so it
* goes through `POST /lanes/:id/router/apply` and never a PATCH. A failure puts
* the select back: the row must not claim a router the lane did not get.
*
* Rendered only when there is something to choose. A user who has never opened
* the AI Router tab has exactly the built-in `native` profile, and a select
* with one option is a control that cannot do anything.
*/
export function LaneRouterRow({ lane }: { lane: Lane }) {
const { t } = useTranslation(["lanes"]);
const aiTools = useAiTools();
const toolLabel = aiTools.label(lane.tool || "claude");
// Only Claude and Codex have an endpoint/model override to write; every
// other tool is offered native alone (the server refuses the rest anyway).
const routerCapable =
aiTools.byId(lane.tool || "claude")?.routerCapable ??
(lane.tool === "claude" || lane.tool === "codex" || !lane.tool);
const [routers, setRouters] = useState<AiRouter[] | null>(null);
const [current, setCurrent] = useState(lane.router_profile || "native");
const [busy, setBusy] = useState(false);
const [note, setNote] = useState<{ ok: boolean; text: string; runId?: string } | null>(null);
// The polled lane list is the source of truth; a switch elsewhere (the lane
// was reset to native by a profile delete, say) has to land here too.
useEffect(() => {
setCurrent(lane.router_profile || "native");
setNote(null);
}, [lane.id, lane.router_profile]);
// One instance of this row serves every lane — it lives in the Workspace's
// lane-detail header, and switching lanes swaps its `lane` prop rather than
// remounting it. So an apply that is still in flight when the user moves on
// would land its rollback and its note on whatever lane is showing by then:
// lane B's failed switch putting lane B's old selection into lane A's picker.
const shownLane = useRef(lane.id);
useEffect(() => {
shownLane.current = lane.id;
}, [lane.id]);
useEffect(() => {
let alive = true;
api.aiRouters
.list()
.then((r) => {
if (alive) setRouters(r.items);
})
.catch(() => {
// Silent, same contract as the runtime poller above: a card that
// cannot list routers simply shows no router row.
if (alive) setRouters([]);
});
return () => {
alive = false;
};
}, []);
if (!routers || routers.length <= 1) return null;
const change = async (name: string) => {
const laneId = lane.id;
const previous = current;
setCurrent(name);
setBusy(true);
setNote(null);
try {
const res = await api.lanes.routerApply(laneId, name);
if (shownLane.current !== laneId) return;
// A live pane adopted the new base URL/key but kept the model names it
// resolved at startup, so it would send the OLD router's model to the NEW
// endpoint and fail with "model not found". Only that case gets the
// warning and the restart button.
setNote(
res.restart_required && res.run_id
? { ok: false, text: t("router.appliedRestart"), runId: res.run_id }
: { ok: true, text: t("router.applied") }
);
} catch (err) {
if (shownLane.current !== laneId) return;
setCurrent(previous);
setNote({ ok: false, text: err instanceof Error ? err.message : String(err) });
} finally {
// `busy` belongs to the control on screen, not to the request, so it is
// cleared either way — a lane switch must never leave the picker locked.
setBusy(false);
}
};
/**
* Kill the pane and open a fresh one for this lane. Started with the lane id
* alone on purpose: the previous run's `--model` flag names a model of the
* router the user just left, so replaying it would re-create the exact
* mismatch this button exists to clear.
*/
const restart = async (runId: string) => {
const laneId = lane.id;
setBusy(true);
try {
await api.run.kill(runId);
await api.run.start({ laneId });
if (shownLane.current !== laneId) return;
setNote({ ok: true, text: t("router.restarted") });
} catch (err) {
if (shownLane.current !== laneId) return;
setNote({ ok: false, text: err instanceof Error ? err.message : String(err) });
} finally {
setBusy(false);
}
};
return (
<label
className="flex items-center gap-1.5 text-xs text-fg-muted"
data-testid={`lane-router-${lane.id}`}
>
{t("router.label")}
<select
value={current}
disabled={busy}
onChange={(e) => void change(e.target.value)}
className="max-w-40 truncate rounded border border-border bg-surface-1 px-2 py-0.5 text-fg-secondary disabled:opacity-50"
>
{routers.map((r) => (
<option
key={r.name}
value={r.name}
disabled={!r.builtin && !routerCapable}
title={
!r.builtin && !routerCapable ? t("router.nativeOnly", { tool: toolLabel }) : undefined
}
>
{r.builtin ? t("router.native", { tool: toolLabel }) : r.label}
</option>
))}
</select>
{/* The applied-note is a tooltip, not a line of its own: it fires on a
click the user just made, and a wrapped sentence under the control
would push every row below it down for the life of the selection.
A failure still gets visible colour, because that one is not
self-evident from the select. */}
{note && (
<span className={note.ok ? "text-fg-muted" : "text-status-danger"} title={note.text}>
{note.ok ? "✓" : "⚠"}
</span>
)}
{/* The one case with an action attached: restarting the pane is what
actually makes the switch take effect, so it is a button and not
another sentence the user has to act on somewhere else. */}
{note?.runId && (
<button
type="button"
disabled={busy}
onClick={() => void restart(note.runId as string)}
className="rounded border border-border px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 disabled:opacity-50"
>
{t("router.restart")}
</button>
)}
</label>
);
}
export const LIVENESS_DOT: Record<Lane["liveness"], string> = { export const LIVENESS_DOT: Record<Lane["liveness"], string> = {
active: "bg-status-success", active: "bg-status-success",
idle: "bg-surface-4", idle: "bg-surface-4",
@@ -39,6 +39,7 @@ import { fmtCost, timeAgo } from "../../lib/format";
import { useAiTools } from "../../hooks/useAiTools"; import { useAiTools } from "../../hooks/useAiTools";
import { useProjectTreePrefs } from "../../lib/projectTreePrefs"; import { useProjectTreePrefs } from "../../lib/projectTreePrefs";
import { ModelPicker, PromptEditor } from "../run/RunSetup"; import { ModelPicker, PromptEditor } from "../run/RunSetup";
import { RouterPicker, routerArg, useSessionRouter } from "../run/RouterPicker";
import { Select } from "../Select"; import { Select } from "../Select";
import { MarkdownPreview } from "../MarkdownPreview"; import { MarkdownPreview } from "../MarkdownPreview";
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu"; import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu";
@@ -107,6 +108,7 @@ function HomeBody({
// Claude Desktop's worktree toggle: the new session gets a git worktree of // Claude Desktop's worktree toggle: the new session gets a git worktree of
// its own (`claude --worktree`), made and cleaned up by Claude Code. // its own (`claude --worktree`), made and cleaned up by Claude Code.
const [inWorktree, setInWorktree] = useState(false); const [inWorktree, setInWorktree] = useState(false);
const [router, setRouter] = useSessionRouter();
const [starting, setStarting] = useState(false); const [starting, setStarting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [liveRuns, setLiveRuns] = useState<RunHandle[]>([]); const [liveRuns, setLiveRuns] = useState<RunHandle[]>([]);
@@ -231,6 +233,7 @@ function HomeBody({
...(model ? { model } : {}), ...(model ? { model } : {}),
...(claude ? { permissionMode: mode } : {}), ...(claude ? { permissionMode: mode } : {}),
...(claude && inWorktree && project?.root ? { worktree: true } : {}), ...(claude && inWorktree && project?.root ? { worktree: true } : {}),
...routerArg(aiTools.byId(lane.tool)?.routerCapable, router),
}); });
setPrompt(""); setPrompt("");
const runId = r.run_id ?? paneRunId(lane.id, slot); const runId = r.run_id ?? paneRunId(lane.id, slot);
@@ -341,6 +344,7 @@ function HomeBody({
<div className="w-56"> <div className="w-56">
<ModelPicker value={model} onChange={setModel} tool={lane.tool} /> <ModelPicker value={model} onChange={setModel} tool={lane.tool} />
</div> </div>
<RouterPicker tool={lane.tool} value={router} onChange={setRouter} />
{claude && ( {claude && (
<div className="w-44"> <div className="w-44">
<Select<PermissionMode> <Select<PermissionMode>
@@ -2,8 +2,7 @@
* @file WorkspaceRowMenu — the sidebar workspace row's context menu, opened at * @file WorkspaceRowMenu — the sidebar workspace row's context menu, opened at
* the pointer (right-click) or under the row's `⋯` button. Every item is an * the pointer (right-click) or under the row's `⋯` button. Every item is an
* existing call: Start/Stop (`POST /api/lanes/:id/start` slot 0 / `stop`), * existing call: Start/Stop (`POST /api/lanes/:id/start` slot 0 / `stop`),
* Rename (the row's inline field), the AI Router select (`LaneRouterRow`, * Rename (the row's inline field), Create/update readme (claude only, the same run
* router-capable tools only), Create/update readme (claude only, the same run
* the lane Tools menu starts), New workspace here (the Add modal prefilled), * the lane Tools menu starts), New workspace here (the Add modal prefilled),
* Copy path, and Reset/Remove through `DestructiveLaneModal` — Reset only on a * Copy path, and Reset/Remove through `DestructiveLaneModal` — Reset only on a
* managed lane. The server's guards are the real ones; the menu only hides * managed lane. The server's guards are the real ones; the menu only hides
@@ -16,7 +15,6 @@ import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import type { Lane } from "../../lib/types"; import type { Lane } from "../../lib/types";
import { LaneRouterRow } from "./LaneCard";
export type WorkspaceMenuAction = "rename" | "new-here" | "reset" | "remove" | "started"; export type WorkspaceMenuAction = "rename" | "new-here" | "reset" | "remove" | "started";
@@ -93,9 +91,6 @@ export function WorkspaceRowMenu({
<button type="button" role="menuitem" className={item} onClick={() => onAction("rename")}> <button type="button" role="menuitem" className={item} onClick={() => onAction("rename")}>
{t("menu.rename")} {t("menu.rename")}
</button> </button>
<div className="px-2 py-1">
<LaneRouterRow lane={lane} />
</div>
{lane.tool === "claude" && ( {lane.tool === "claude" && (
<button <button
type="button" type="button"
@@ -29,7 +29,6 @@ vi.mock("../../../lib/api", () => ({
get: vi.fn(), get: vi.fn(),
profileInit: vi.fn(), profileInit: vi.fn(),
mcpSync: vi.fn(), mcpSync: vi.fn(),
routerApply: vi.fn(),
}, },
aiRouters: { list: vi.fn() }, aiRouters: { list: vi.fn() },
aiTools: { list: vi.fn(), iconUrl: (id: string) => `/api/ai-tools/${id}/icon` }, aiTools: { list: vi.fn(), iconUrl: (id: string) => `/api/ai-tools/${id}/icon` },
@@ -157,53 +156,9 @@ beforeEach(() => {
.mockReset() .mockReset()
.mockResolvedValue({ scaffolded: false, reason: "no detectable Node.js project" }); .mockResolvedValue({ scaffolded: false, reason: "no detectable Node.js project" });
vi.mocked(api.lanes.mcpSync).mockReset().mockResolvedValue({ servers: [], profilesSeeded: [] }); vi.mocked(api.lanes.mcpSync).mockReset().mockResolvedValue({ servers: [], profilesSeeded: [] });
vi.mocked(api.lanes.routerApply).mockReset().mockResolvedValue({ vi.mocked(api.aiRouters.list).mockReset();
tool: "claude",
file: null,
restart_required: false,
run_id: null,
profile: "native",
});
// Only the built-in profile by default, so the router radio stays on Native
// and no test has to opt out of it.
vi.mocked(api.aiRouters.list)
.mockReset()
.mockResolvedValue({ items: [nativeRouter()] });
}); });
/** The built-in profile, exactly as `GET /api/ai-routers` reports it. */
function nativeRouter() {
return {
name: "native",
label: "Native",
builtin: true,
baseUrl: "",
hasKey: false,
model: "claude-sonnet-5",
models: {
opus: "claude-opus-5",
sonnet: "claude-sonnet-5",
haiku: "claude-haiku-4-5-20251001",
smallFast: "claude-haiku-4-5-20251001",
subagent: "claude-haiku-4-5-20251001",
},
};
}
/** A saved router profile, for the tests that need the radio to have a target. */
function savedRouter(over: Record<string, unknown> = {}) {
return {
...nativeRouter(),
name: "9router",
label: "9Router",
builtin: false,
hasKey: true,
baseUrl: "http://localhost:20128/v1",
model: "Highest",
...over,
};
}
describe("AddLaneModal — picking an existing branch (issue #34)", () => { describe("AddLaneModal — picking an existing branch (issue #34)", () => {
const withWorktrees = { const withWorktrees = {
branches: ["main", "feat/other", "feat/held"], branches: ["main", "feat/other", "feat/held"],
@@ -644,134 +599,12 @@ describe("AddLaneModal — create/update readme", () => {
}); });
}); });
describe("AddLaneModal — AI Router", () => { describe("AddLaneModal — no router here", () => {
it("applies native without asking, so a lane always has a declared endpoint", async () => { it("asks for no AI Router: a new session picks its own", async () => {
vi.mocked(api.lanes.ensure).mockResolvedValue({ renderModal({});
lane: laneFixture({ status: "idle", cwd: "/Users/tester/projects/repo" }), await screen.findByRole("button", { name: /Claude Code.*Default/ });
created: true, expect(screen.queryByRole("radio", { name: "AI Router" })).toBeNull();
}); expect(api.aiRouters.list).not.toHaveBeenCalled();
renderModal();
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Repo" }));
const repoField = screen.getByLabelText("Directory");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await user.click(screen.getByRole("button", { name: "Add workspace" }));
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(9, "native"));
});
it("sends the picked profile once AI Router is chosen", async () => {
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [nativeRouter(), savedRouter()] });
vi.mocked(api.lanes.ensure).mockResolvedValue({
lane: laneFixture({ status: "idle", cwd: "/Users/tester/projects/repo" }),
created: true,
});
renderModal();
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Repo" }));
const repoField = screen.getByLabelText("Directory");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await user.click(await screen.findByRole("radio", { name: "AI Router" }));
await user.click(screen.getByRole("button", { name: "Add workspace" }));
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(9, "9router"));
});
it("keeps the radio on Native when no profile is saved, and says why", async () => {
// Choosing "AI Router" with nothing to point at would create a lane whose
// declared endpoint does not exist.
renderModal();
const user = userEvent.setup();
await user.click(await screen.findByRole("radio", { name: "AI Router" }));
expect(screen.getByRole("radio", { name: /^Native/ })).toBeChecked();
expect(screen.getByText(/No router profile saved yet/)).toBeInTheDocument();
});
it("creates the lane anyway when the profile list cannot be fetched", async () => {
vi.mocked(api.aiRouters.list).mockRejectedValue(new Error("offline"));
vi.mocked(api.lanes.ensure).mockResolvedValue({
lane: laneFixture({ status: "idle", cwd: "/Users/tester/projects/repo" }),
created: true,
});
renderModal();
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Repo" }));
const repoField = screen.getByLabelText("Directory");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await user.click(screen.getByRole("button", { name: "Add workspace" }));
await waitFor(() => expect(api.lanes.ensure).toHaveBeenCalled());
});
});
describe("AddLaneModal — a created lane is never lost to a setup failure", () => {
it("reports the adopted lane even when applying the router fails", async () => {
// `ensure` already created it server-side; skipping onAdded would hide a
// real lane from the list until some unrelated refetch found it.
vi.mocked(api.lanes.routerApply).mockRejectedValue(new Error("no router profile named 'gone'"));
vi.mocked(api.lanes.ensure).mockResolvedValue({
lane: laneFixture({ status: "idle", cwd: "/Users/tester/projects/repo" }),
created: true,
});
const onAdded = vi.fn();
renderModal({ onAdded });
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Repo" }));
const repoField = screen.getByLabelText("Directory");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await user.click(screen.getByRole("button", { name: "Add workspace" }));
await waitFor(() => expect(onAdded).toHaveBeenCalled());
// …and the failure is still surfaced, on a modal left open.
expect(await screen.findByText(/no router profile named/)).toBeInTheDocument();
});
});
describe("AddLaneModal — a router profile that disappeared", () => {
it("falls back to Native when the saved profile is gone by the time the list loads", async () => {
// Deleted in another tab while this modal sat open: the select would show
// no matching option while state still held the dead name.
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [nativeRouter(), savedRouter()] });
vi.mocked(api.lanes.ensure).mockResolvedValue({
lane: laneFixture({ status: "idle", cwd: "/Users/tester/projects/repo" }),
created: true,
});
const { rerender } = renderModal();
const user = userEvent.setup();
await user.click(await screen.findByRole("radio", { name: "AI Router" }));
expect(screen.getByRole("radio", { name: "AI Router" })).toBeChecked();
// Re-open with the profile gone from the server's answer.
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [nativeRouter()] });
rerender(<AddLaneModal open={false} onClose={vi.fn()} onAdded={vi.fn()} cwdSuggestions={[]} />);
rerender(<AddLaneModal open onClose={vi.fn()} onAdded={vi.fn()} cwdSuggestions={[]} />);
await waitFor(() => expect(screen.getByRole("radio", { name: /^Native/ })).toBeChecked());
});
it("does not offer AI Router until the profile list has arrived", async () => {
// Clicking it early resolved back to Native with no feedback, which reads
// as a dead control.
let resolveList: (v: { items: ReturnType<typeof nativeRouter>[] }) => void = () => {};
vi.mocked(api.aiRouters.list).mockReturnValue(
new Promise((resolve) => {
resolveList = resolve;
}) as never
);
renderModal();
expect(screen.getByRole("radio", { name: "AI Router" })).toBeDisabled();
resolveList({ items: [nativeRouter(), savedRouter()] });
await waitFor(() => expect(screen.getByRole("radio", { name: "AI Router" })).toBeEnabled());
}); });
}); });
@@ -811,15 +644,6 @@ describe("AddLaneModal — tool selection", () => {
); );
}); });
it("offers only Native for a tool that cannot use an AI Router", async () => {
renderModal({});
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /Claude Code.*Default/ }));
await user.click(screen.getByRole("button", { name: "Gemini" }));
expect(screen.getByRole("radio", { name: "Native (Gemini)" })).toBeChecked();
expect(screen.getByRole("radio", { name: "AI Router" })).toBeDisabled();
});
it("says so, with a link to Settings, when no tool is installed and enabled", async () => { it("says so, with a link to Settings, when no tool is installed and enabled", async () => {
vi.mocked(api.aiTools.list).mockResolvedValue({ default: null, tools: [] }); vi.mocked(api.aiTools.list).mockResolvedValue({ default: null, tools: [] });
renderModal({}); renderModal({});
@@ -9,9 +9,9 @@
*/ */
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, act } from "@testing-library/react"; import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import LaneCard, { LaneToolsMenu, LaneRouterRow } from "../LaneCard"; import LaneCard, { LaneToolsMenu } from "../LaneCard";
import type { Lane, LaneRuntime } from "../../../lib/types"; import type { Lane, LaneRuntime } from "../../../lib/types";
import { api } from "../../../lib/api"; import { api } from "../../../lib/api";
@@ -25,7 +25,6 @@ vi.mock("../../../lib/api", () => ({
preflight: vi.fn().mockResolvedValue({ blocked: [], warnings: [] }), preflight: vi.fn().mockResolvedValue({ blocked: [], warnings: [] }),
mcpSync: vi.fn(), mcpSync: vi.fn(),
syncBaseCheck: vi.fn(), syncBaseCheck: vi.fn(),
routerApply: vi.fn(),
}, },
locks: { locks: {
list: vi.fn(), list: vi.fn(),
@@ -374,168 +373,3 @@ describe("LaneToolsMenu", () => {
expect(container).toBeEmptyDOMElement(); expect(container).toBeEmptyDOMElement();
}); });
}); });
describe("LaneRouterRow — the Model picker", () => {
const native = {
name: "native",
label: "Native",
builtin: true,
baseUrl: "",
hasKey: false,
model: "claude-sonnet-5",
models: {
opus: "claude-opus-5",
sonnet: "claude-sonnet-5",
haiku: "claude-haiku-4-5-20251001",
smallFast: "claude-haiku-4-5-20251001",
subagent: "claude-haiku-4-5-20251001",
},
};
const nineRouter = { ...native, name: "9router", label: "9Router", builtin: false };
it("stays out of the card when native is the only profile", async () => {
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native] });
render(<LaneRouterRow lane={makeLane()} />);
await waitFor(() => expect(api.aiRouters.list).toHaveBeenCalled());
expect(screen.queryByTestId("lane-router-1")).toBeNull();
});
it("shows the lane's current profile selected", async () => {
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
render(<LaneRouterRow lane={makeLane({ router_profile: "9router" })} />);
expect(await screen.findByDisplayValue("9Router")).toBeInTheDocument();
});
it("offers only native to a tool that cannot use an AI Router", async () => {
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
render(<LaneRouterRow lane={makeLane({ tool: "gemini" })} />);
const select = await screen.findByDisplayValue("Native (gemini)");
const router = Array.from((select as HTMLSelectElement).options).find(
(o) => o.value === "9router"
);
expect(router?.disabled).toBe(true);
});
it("applies a switch through the router route, not a lane PATCH", async () => {
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
vi.mocked(api.lanes.routerApply).mockResolvedValue({
tool: "claude",
file: "/work/demo/.claude/settings.local.json",
profile: "9router",
restart_required: false,
run_id: null,
});
render(<LaneRouterRow lane={makeLane()} />);
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude)"), ["9router"]);
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(1, "9router"));
expect(await screen.findByDisplayValue("9Router")).toBeInTheDocument();
});
it("offers a restart when a live pane still holds the old router's models", async () => {
// Claude Code re-applies `env` to a running session but keeps the model it
// resolved at startup, so the switch is only half-applied until the pane is
// restarted — the server says so and the row must act on it.
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
vi.mocked(api.lanes.routerApply).mockResolvedValue({
tool: "claude",
file: "/work/demo/.claude/settings.local.json",
profile: "9router",
restart_required: true,
run_id: "agenthub-lane-1",
});
vi.mocked(api.run.kill).mockResolvedValue({ ok: true });
vi.mocked(api.run.start).mockResolvedValue({} as never);
render(<LaneRouterRow lane={makeLane()} />);
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude)"), ["9router"]);
const restart = await screen.findByRole("button", { name: "Restart" });
await userEvent.click(restart);
await waitFor(() => expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1"));
// Started by lane id alone: replaying the old run's --model would name a
// model of the router the user just left.
expect(api.run.start).toHaveBeenCalledWith({ laneId: 1 });
});
it("leaves the restart button out when no pane is running", async () => {
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
vi.mocked(api.lanes.routerApply).mockResolvedValue({
tool: "claude",
file: "/work/demo/.claude/settings.local.json",
profile: "9router",
restart_required: false,
run_id: null,
});
render(<LaneRouterRow lane={makeLane()} />);
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude)"), ["9router"]);
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalled());
expect(screen.queryByRole("button", { name: "Restart" })).toBeNull();
});
it("does not carry a pick made on one lane back to the lane selected before it", async () => {
// One LaneRouterRow instance serves every lane (it lives in the Workspace's
// lane-detail header), so its selected value is component state that
// outlives a lane switch unless the sync effect puts the new lane's own
// profile back.
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
vi.mocked(api.lanes.routerApply).mockResolvedValue({
tool: "claude",
file: "/work/b/.claude/settings.local.json",
profile: "9router",
restart_required: false,
run_id: null,
});
const laneA = makeLane({ id: 1, router_profile: null });
const laneB = makeLane({ id: 2, router_profile: null });
const { rerender } = render(<LaneRouterRow lane={laneA} />);
await screen.findByDisplayValue("Native (Claude)");
rerender(<LaneRouterRow lane={laneB} />);
await userEvent.selectOptions(screen.getByDisplayValue("Native (Claude)"), ["9router"]);
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(2, "9router"));
rerender(<LaneRouterRow lane={laneA} />);
expect(screen.getByDisplayValue("Native (Claude)")).toBeInTheDocument();
});
it("does not roll a failed apply back onto a DIFFERENT lane the user has since switched to", async () => {
// The row is one instance shared by every lane, so an apply still in flight
// when the user moves on would paint its rollback and its error on whoever
// is on screen by then — lane B's failure changing lane A's picker.
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
let reject: (e: Error) => void = () => {};
vi.mocked(api.lanes.routerApply).mockImplementation(
() => new Promise((_, r) => (reject = r)) as ReturnType<typeof api.lanes.routerApply>
);
const laneA = makeLane({ id: 1, router_profile: null });
const laneB = makeLane({ id: 2, router_profile: null });
const { rerender } = render(<LaneRouterRow lane={laneB} />);
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude)"), ["9router"]);
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(2, "9router"));
rerender(<LaneRouterRow lane={laneA} />);
await act(async () => {
reject(new Error("EBADJSON"));
await Promise.resolve();
});
expect(screen.getByDisplayValue("Native (Claude)")).toBeInTheDocument();
expect(screen.queryByTitle("EBADJSON")).toBeNull();
// And the picker must not be left locked by a request for another lane.
expect(screen.getByRole("combobox")).not.toBeDisabled();
});
it("puts the selection back when the apply fails", async () => {
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
vi.mocked(api.lanes.routerApply).mockRejectedValue(new Error("EBADJSON"));
render(<LaneRouterRow lane={makeLane()} />);
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude)"), ["9router"]);
await waitFor(() => expect(screen.getByTitle("EBADJSON")).toBeInTheDocument());
expect(screen.getByDisplayValue("Native (Claude)")).toBeInTheDocument();
});
});
@@ -353,6 +353,7 @@ export function LaneConsolePane({
approval: args.approval, approval: args.approval,
resumeLast: args.resumeLast, resumeLast: args.resumeLast,
bypass: args.bypass, bypass: args.bypass,
router: args.router,
}); });
} catch (laneErr: unknown) { } catch (laneErr: unknown) {
// A code-only check: the server's message is human prose ("lane // A code-only check: the server's message is human prose ("lane
@@ -0,0 +1,91 @@
/**
* @file RouterPicker.tsx
* @description The AI Router a NEW session starts on — a session's choice, not
* its workspace's (`router` on `POST /api/lanes/:id/start`, `server/lib/router-apply.js`).
* Shown only for a tool that can use one (Claude, Codex) and only when a
* profile beyond Native exists. The last pick is remembered for the next
* session (`agenthub.session.router`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { api, type AiRouter } from "../../lib/api";
import { useAiTools } from "../../hooks/useAiTools";
import { Select } from "../Select";
const KEY = "agenthub.session.router";
/** The remembered pick, `"native"` by default. */
export function useSessionRouter(): [string, (v: string) => void] {
const [value, setValue] = useState(() => {
try {
return localStorage.getItem(KEY) || "native";
} catch {
return "native";
}
});
const set = (v: string) => {
setValue(v);
try {
localStorage.setItem(KEY, v);
} catch {
/* storage off — the pick still holds for this page */
}
};
return [value, set];
}
/** What to send as `router` for `tool`: nothing for native or a tool that has none. */
export function routerArg(routerCapable: boolean | undefined, router: string): { router?: string } {
return routerCapable && router && router !== "native" ? { router } : {};
}
export function RouterPicker({
tool,
value,
onChange,
wrap = (el) => el,
}: {
tool: string;
value: string;
onChange: (v: string) => void;
/** Puts the select in the caller's labelled field; not called while hidden. */
wrap?: (select: ReactNode) => ReactNode;
}) {
const { t } = useTranslation(["lanes"]);
const aiTools = useAiTools();
const capable = aiTools.byId(tool)?.routerCapable ?? (tool === "claude" || tool === "codex");
const [routers, setRouters] = useState<AiRouter[] | null>(null);
useEffect(() => {
let alive = true;
api.aiRouters
.list()
.then((r) => alive && setRouters(r.items))
.catch(() => alive && setRouters([]));
return () => {
alive = false;
};
}, []);
// A remembered profile that has since been deleted falls back to native,
// or the start would be refused for a router the user cannot see.
useEffect(() => {
if (routers && value !== "native" && !routers.some((r) => r.name === value)) onChange("native");
}, [routers, value, onChange]);
if (!capable || !routers || routers.length <= 1) return null;
return wrap(
<div className="w-44" data-testid="router-picker" title={t("router.label")}>
<Select<string>
value={value}
onChange={onChange}
options={routers.map((r) => ({
value: r.name,
label: r.builtin ? t("router.native", { tool: aiTools.label(tool) }) : r.label,
}))}
/>
</div>
);
}
+33 -4
View File
@@ -59,6 +59,7 @@ import type {
import type { Session, LaneTool } from "../../lib/types"; import type { Session, LaneTool } from "../../lib/types";
import { useAiTools } from "../../hooks/useAiTools"; import { useAiTools } from "../../hooks/useAiTools";
import { Select } from "../Select"; import { Select } from "../Select";
import { RouterPicker, routerArg, useSessionRouter } from "./RouterPicker";
export interface SlashCommand { export interface SlashCommand {
name: string; name: string;
@@ -409,6 +410,8 @@ export function RunSetup(props: RunSetupProps) {
const canResume = !catalogTool || !!entry?.resume; const canResume = !catalogTool || !!entry?.resume;
// Per run, never remembered: the form starts unticked every time it mounts. // Per run, never remembered: the form starts unticked every time it mounts.
const [bypass, setBypass] = useState(false); const [bypass, setBypass] = useState(false);
const [router, setRouter] = useSessionRouter();
const routerSent = routerArg(entry?.routerCapable, router).router;
const canBypass = props.tool !== "claude" && !!entry?.bypass; const canBypass = props.tool !== "claude" && !!entry?.bypass;
// Claude and Codex always take a model; a catalog tool only when its file names the flag. // Claude and Codex always take a model; a catalog tool only when its file names the flag.
const hasModel = !catalogTool || !!entry?.modelFlag; const hasModel = !catalogTool || !!entry?.modelFlag;
@@ -450,6 +453,15 @@ export function RunSetup(props: RunSetupProps) {
<ModelPicker value={props.model} onChange={props.onModelChange} tool={props.tool} /> <ModelPicker value={props.model} onChange={props.onModelChange} tool={props.tool} />
</Field> </Field>
)} )}
{/* A resume goes back to the router its session ran on (server-side). */}
{!props.resumeSession && (
<RouterPicker
tool={props.tool}
value={router}
onChange={setRouter}
wrap={(select) => <Field label={t("lanes:router.label")}>{select}</Field>}
/>
)}
{catalogTool ? null : props.tool === "codex" ? ( {catalogTool ? null : props.tool === "codex" ? (
<> <>
<Field label={t("fields.sandbox")}> <Field label={t("fields.sandbox")}>
@@ -536,7 +548,7 @@ export function RunSetup(props: RunSetupProps) {
// update has not landed yet on this tick, so reading // update has not landed yet on this tick, so reading
// props.resumeSession here would resume nothing. // props.resumeSession here would resume nothing.
if (s && !props.busy) if (s && !props.busy)
handleStart(props, s, bypass, hasModel, entry?.defaultModel); handleStart(props, s, bypass, hasModel, entry?.defaultModel, routerSent);
}} }}
// Fall back to the typed cwd when no lane is selected — otherwise // Fall back to the typed cwd when no lane is selected — otherwise
// an unfiltered picker lists every session from every repo. // an unfiltered picker lists every session from every repo.
@@ -564,7 +576,14 @@ export function RunSetup(props: RunSetupProps) {
value={props.prompt} value={props.prompt}
onChange={props.onPromptChange} onChange={props.onPromptChange}
onSubmit={() => onSubmit={() =>
handleStart(props, undefined, canBypass && bypass, hasModel, entry?.defaultModel) handleStart(
props,
undefined,
canBypass && bypass,
hasModel,
entry?.defaultModel,
routerSent
)
} }
placeholder={t("fields.promptPlaceholderTerminal", { tool: aiTools.label(props.tool) })} placeholder={t("fields.promptPlaceholderTerminal", { tool: aiTools.label(props.tool) })}
rows={5} rows={5}
@@ -620,7 +639,14 @@ export function RunSetup(props: RunSetupProps) {
</div> </div>
<button <button
onClick={() => onClick={() =>
handleStart(props, undefined, canBypass && bypass, hasModel, entry?.defaultModel) handleStart(
props,
undefined,
canBypass && bypass,
hasModel,
entry?.defaultModel,
routerSent
)
} }
disabled={ disabled={
!props.binaryFound || !props.binaryFound ||
@@ -655,7 +681,8 @@ function handleStart(
session?: Session, session?: Session,
bypass?: boolean, bypass?: boolean,
hasModel?: boolean, hasModel?: boolean,
defaultModel?: string | null defaultModel?: string | null,
router?: string
) { ) {
const resume = session ?? props.resumeSession; const resume = session ?? props.resumeSession;
const codex = props.tool === "codex"; const codex = props.tool === "codex";
@@ -682,6 +709,8 @@ function handleStart(
// asked for, so a resume carries no prompt at all. // asked for, so a resume carries no prompt at all.
initialPrompt: resume ? undefined : props.prompt || undefined, initialPrompt: resume ? undefined : props.prompt || undefined,
bypass: bypass || undefined, bypass: bypass || undefined,
// A resume names none: the server goes back to its session's own router.
router: resume ? undefined : router,
}); });
} }
@@ -29,6 +29,7 @@ vi.mock("../../../lib/api", () => ({
action: vi.fn(), action: vi.fn(),
list: vi.fn(), list: vi.fn(),
}, },
aiRouters: { list: vi.fn().mockResolvedValue({ items: [] }) },
aiTools: { aiTools: {
list: vi.fn().mockResolvedValue({ default: "claude", tools: [] }), list: vi.fn().mockResolvedValue({ default: "claude", tools: [] }),
models: vi.fn().mockResolvedValue({ flag: true, models: [], defaultModel: null }), models: vi.fn().mockResolvedValue({ flag: true, models: [], defaultModel: null }),
@@ -0,0 +1,83 @@
/**
* @file RouterPicker.test.tsx
* @description The per-session AI Router picker: hidden with only Native or on
* a tool that cannot use a router, listing the saved profiles otherwise,
* remembering the pick, and falling back to Native when the remembered
* profile is gone; plus `routerArg`, which never sends native.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { act, render, renderHook, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { RouterPicker, routerArg, useSessionRouter } from "../RouterPicker";
import { api } from "../../../lib/api";
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
aiTools: {
list: vi.fn().mockResolvedValue({
default: "claude",
tools: [
{ id: "claude", label: "Claude Code", routerCapable: true },
{ id: "gemini", label: "Gemini", routerCapable: false },
].map((x) => ({ ...x, installed: true, enabled: true })),
}),
},
aiRouters: { list: vi.fn() },
},
};
});
const native = { name: "native", label: "Native", builtin: true };
const nine = { name: "9router", label: "9Router", builtin: false };
beforeEach(() => {
localStorage.clear();
vi.mocked(api.aiRouters.list)
.mockReset()
.mockResolvedValue({ items: [native, nine] } as never);
});
describe("RouterPicker", () => {
it("lists the saved profiles for a tool that can use one", async () => {
render(<RouterPicker tool="claude" value="9router" onChange={() => {}} />);
expect(await screen.findByText("9Router")).toBeInTheDocument();
});
it("is hidden with only Native saved", async () => {
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native] } as never);
render(<RouterPicker tool="claude" value="native" onChange={() => {}} />);
await waitFor(() => expect(api.aiRouters.list).toHaveBeenCalled());
expect(screen.queryByTestId("router-picker")).toBeNull();
});
it("is hidden on a tool with no endpoint to point at a router", async () => {
render(<RouterPicker tool="gemini" value="native" onChange={() => {}} />);
await waitFor(() => expect(api.aiRouters.list).toHaveBeenCalled());
expect(screen.queryByTestId("router-picker")).toBeNull();
});
it("falls back to Native when the remembered profile is gone", async () => {
const onChange = vi.fn();
render(<RouterPicker tool="claude" value="deleted" onChange={onChange} />);
await waitFor(() => expect(onChange).toHaveBeenCalledWith("native"));
});
});
describe("useSessionRouter / routerArg", () => {
it("remembers the last pick for the next session", () => {
const first = renderHook(() => useSessionRouter());
expect(first.result.current[0]).toBe("native");
act(() => first.result.current[1]("9router"));
expect(renderHook(() => useSessionRouter()).result.current[0]).toBe("9router");
});
it("sends a router only for a capable tool and never native", () => {
expect(routerArg(true, "9router")).toEqual({ router: "9router" });
expect(routerArg(true, "native")).toEqual({});
expect(routerArg(false, "9router")).toEqual({});
});
});
@@ -25,6 +25,7 @@ vi.mock("../../../lib/api", async (importOriginal) => {
return { return {
...actual, ...actual,
api: { api: {
aiRouters: { list: r({ items: [] }) },
aiTools: { aiTools: {
list: r({ list: r({
default: "claude", default: "claude",
@@ -139,14 +139,7 @@ export function ClaudeAgentHubSettings({ onHomeChanged }: { onHomeChanged?: () =
api.settings.claudeHome.set(p).then((r) => { api.settings.claudeHome.set(p).then((r) => {
void load(); void load();
onHomeChanged?.(); onHomeChanged?.();
const missing = r.routers?.missing ?? []; return { value: r.claude_home, envOwned: !!r.env_owned };
return {
value: r.claude_home,
envOwned: !!r.env_owned,
warning: missing.length
? t("claudeHome.routersMissing", { lanes: missing.join(", ") })
: null,
};
}) })
} }
/> />
@@ -244,10 +244,23 @@ describe("AiToolsSettings — install and update", () => {
describe("AiToolIcon", () => { describe("AiToolIcon", () => {
it("retries the icon when the same instance shows another tool", async () => { it("retries the icon when the same instance shows another tool", async () => {
const { AiToolIcon } = await import("../../AiToolIcon"); const { AiToolIcon } = await import("../../AiToolIcon");
const { container, rerender } = render(<AiToolIcon tool={{ id: "claude", label: "Claude" }} />); const { container, rerender } = render(<AiToolIcon tool={{ id: "gemini", label: "Gemini" }} />);
fireEvent.error(container.querySelector("img")!); fireEvent.error(container.querySelector("img")!);
expect(screen.getByText("C")).toBeInTheDocument(); expect(screen.getByText("G")).toBeInTheDocument();
rerender(<AiToolIcon tool={{ id: "codex", label: "Codex" }} />); rerender(<AiToolIcon tool={{ id: "cursor", label: "Cursor CLI" }} />);
expect(container.querySelector('img[src="/api/ai-tools/codex/icon"]')).toBeTruthy(); expect(container.querySelector('img[src="/api/ai-tools/cursor/icon"]')).toBeTruthy();
});
it("uses the shipped mark, and a light/dark pair for Hermes", async () => {
const { AiToolIcon } = await import("../../AiToolIcon");
const { container, rerender } = render(<AiToolIcon tool={{ id: "claude", label: "Claude" }} />);
expect(container.querySelector('img[src="/ai-tools/claude.svg"]')).toBeTruthy();
rerender(<AiToolIcon tool={{ id: "hermes", label: "Hermes" }} />);
expect(container.querySelector('img[src="/ai-tools/hermes-black.svg"]')?.className).toMatch(
/dark:hidden/
);
expect(container.querySelector('img[src="/ai-tools/hermes-white.svg"]')?.className).toMatch(
/hidden dark:block/
);
}); });
}); });
@@ -6,7 +6,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { render, screen, within } from "@testing-library/react"; import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next"; import i18n from "i18next";
@@ -62,7 +62,7 @@ beforeEach(() => {
.mockResolvedValue({ claude_home: "/home/u/.claude" }); .mockResolvedValue({ claude_home: "/home/u/.claude" });
vi.mocked(api.settings.claudeHome.set) vi.mocked(api.settings.claudeHome.set)
.mockReset() .mockReset()
.mockResolvedValue({ ok: true, claude_home: "/srv/claude", routers: { missing: [] } }); .mockResolvedValue({ ok: true, claude_home: "/srv/claude" });
vi.mocked(api.settings.resetPricing).mockReset(); vi.mocked(api.settings.resetPricing).mockReset();
vi.mocked(api.settings.reinstallHooks) vi.mocked(api.settings.reinstallHooks)
.mockReset() .mockReset()
@@ -97,12 +97,7 @@ describe("ClaudeAgentHubSettings", () => {
expect(api.settings.reinstallHooks).toHaveBeenCalledTimes(1); expect(api.settings.reinstallHooks).toHaveBeenCalledTimes(1);
}); });
it("re-reads hook status and tells the page after a CLAUDE_HOME save, naming lanes left without their router", async () => { it("re-reads hook status and tells the page after a CLAUDE_HOME save", async () => {
vi.mocked(api.settings.claudeHome.set).mockResolvedValue({
ok: true,
claude_home: "/srv/claude",
routers: { missing: ["7 (api): my-router"] },
});
const onHomeChanged = vi.fn(); const onHomeChanged = vi.fn();
const user = userEvent.setup(); const user = userEvent.setup();
const { container } = render(<ClaudeAgentHubSettings onHomeChanged={onHomeChanged} />); const { container } = render(<ClaudeAgentHubSettings onHomeChanged={onHomeChanged} />);
@@ -112,8 +107,7 @@ describe("ClaudeAgentHubSettings", () => {
const home = container.querySelector("section#claude-home") as HTMLElement; const home = container.querySelector("section#claude-home") as HTMLElement;
const reads = vi.mocked(api.settings.info).mock.calls.length; const reads = vi.mocked(api.settings.info).mock.calls.length;
await user.click(within(home).getByRole("button")); await user.click(within(home).getByRole("button"));
expect(await within(home).findByText(/7 \(api\): my-router/)).toBeInTheDocument(); await waitFor(() => expect(onHomeChanged).toHaveBeenCalledTimes(1));
expect(onHomeChanged).toHaveBeenCalledTimes(1);
expect(vi.mocked(api.settings.info).mock.calls.length).toBeGreaterThan(reads); expect(vi.mocked(api.settings.info).mock.calls.length).toBeGreaterThan(reads);
}); });
+1 -14
View File
@@ -40,23 +40,15 @@
"addLaneRepoHintAdopt": "An existing directory. The dashboard tracks it as-is — no worktree, no new branch.", "addLaneRepoHintAdopt": "An existing directory. The dashboard tracks it as-is — no worktree, no new branch.",
"addLaneRepoLabel": "Source repository", "addLaneRepoLabel": "Source repository",
"addLaneRepoLabelAdopt": "Directory", "addLaneRepoLabelAdopt": "Directory",
"addLaneRouterCustom": "AI Router",
"addLaneRouterEmptyHint": "No router profile saved yet — add one under Settings › AI Router.",
"addLaneRouterHint": "Native talks to the vendor's own API. An AI Router profile is written into this workspace's own config when it is created.",
"addLaneRouterLabel": "Model endpoint",
"addLaneRouterLoading": "Loading router profiles…",
"addLaneRouterProfileLabel": "Router profile",
"addLaneSetupClose": "Close", "addLaneSetupClose": "Close",
"addLaneSetupMcp": "MCP servers", "addLaneSetupMcp": "MCP servers",
"addLaneSetupProfile": "Profile", "addLaneSetupProfile": "Profile",
"addLaneSetupRouter": "Model endpoint",
"addLaneSetupTitle": "Setup", "addLaneSetupTitle": "Setup",
"addLaneTitleLabel": "Title", "addLaneTitleLabel": "Title",
"addLaneTitlePlaceholder": "Optional", "addLaneTitlePlaceholder": "Optional",
"addLaneToolDefaultBadge": "Default", "addLaneToolDefaultBadge": "Default",
"addLaneNoTools": "No AI tool is installed and enabled.", "addLaneNoTools": "No AI tool is installed and enabled.",
"addLaneNoToolsLink": "Open Settings → AI Tools", "addLaneNoToolsLink": "Open Settings → AI Tools",
"addLaneRouterNativeTool": "Native ({{tool}})",
"addLaneToolHint": "Which CLI drives this workspace. Cannot be changed later.", "addLaneToolHint": "Which CLI drives this workspace. Cannot be changed later.",
"addLaneWorktreeDetached": "(detached)", "addLaneWorktreeDetached": "(detached)",
"addLaneWorktreesMore": "+{{count}} more", "addLaneWorktreesMore": "+{{count}} more",
@@ -222,13 +214,8 @@
"proof.ticketReport": "Task report", "proof.ticketReport": "Task report",
"renameLabel": "Rename this workspace", "renameLabel": "Rename this workspace",
"reorderHint": "Drag to reorder · Alt+←/→", "reorderHint": "Drag to reorder · Alt+←/→",
"router.applied": "Applied.",
"router.native": "Native ({{tool}})", "router.native": "Native ({{tool}})",
"router.nativeOnly": "{{tool}} runs on its own settings — AI Router profiles work with Claude Code and Codex only.", "router.label": "AI Router",
"router.appliedRestart": "Applied, but the session running in this workspace still uses the old models — restart it to take effect.",
"router.label": "Model",
"router.restart": "Restart",
"router.restarted": "Session restarted with the new router.",
"runtime.boot": "▶ up", "runtime.boot": "▶ up",
"runtime.busy.down": "stopping…", "runtime.busy.down": "stopping…",
"runtime.busy.up": "booting…", "runtime.busy.up": "booting…",
+2 -5
View File
@@ -67,8 +67,7 @@
"claudeHome": { "claudeHome": {
"title": "CLAUDE_HOME", "title": "CLAUDE_HOME",
"description": "Root directory for Claude Code session data. Changing this updates the scan path for transcripts and imports immediately.", "description": "Root directory for Claude Code session data. Changing this updates the scan path for transcripts and imports immediately.",
"placeholder": "/path/to/claude-home", "placeholder": "/path/to/claude-home"
"routersMissing": "Saved. These workspaces name an AI Router profile the new home does not have, and start on Native until it is added there or they are re-pointed: {{lanes}}"
}, },
"hooks": { "hooks": {
"title": "Hook Configuration", "title": "Hook Configuration",
@@ -510,7 +509,6 @@
"cancel": "Cancel", "cancel": "Cancel",
"removeConfirm": "Remove the profile \"{{label}}\"? Every workspace using it is reset to the vendor's own API right away — on disk and in the dashboard.", "removeConfirm": "Remove the profile \"{{label}}\"? Every workspace using it is reset to the vendor's own API right away — on disk and in the dashboard.",
"loading": "Loading profiles…", "loading": "Loading profiles…",
"lanesFailed": "Saved, but {{count}} workspace could not be updated and still carries the old endpoint: {{lanes}}",
"test": "Test", "test": "Test",
"testing": "Testing…", "testing": "Testing…",
"testOk": "The endpoint answered — {{model}} is reachable with this key.", "testOk": "The endpoint answered — {{model}} is reachable with this key.",
@@ -589,8 +587,7 @@
"title": "CODEX_HOME", "title": "CODEX_HOME",
"description": "Root directory for Codex config and session journals. The dashboard reads it, writes AI Router layers into it, and starts every codex workspace with it.", "description": "Root directory for Codex config and session journals. The dashboard reads it, writes AI Router layers into it, and starts every codex workspace with it.",
"restartNote": "A codex workspace already running keeps the home it started with, and shows as idle, until it is restarted.", "restartNote": "A codex workspace already running keeps the home it started with, and shows as idle, until it is restarted.",
"placeholder": "/path/to/codex-home", "placeholder": "/path/to/codex-home"
"routersFailed": "Saved, but the AI Router layer could not be re-written for: {{lanes}}"
}, },
"codexHooks": { "codexHooks": {
"title": "Codex hooks", "title": "Codex hooks",
+1 -14
View File
@@ -40,23 +40,15 @@
"addLaneRepoHintAdopt": "Một thư mục có sẵn. Dashboard theo dõi nguyên trạng — không tạo worktree, không tạo nhánh mới.", "addLaneRepoHintAdopt": "Một thư mục có sẵn. Dashboard theo dõi nguyên trạng — không tạo worktree, không tạo nhánh mới.",
"addLaneRepoLabel": "Repo nguồn", "addLaneRepoLabel": "Repo nguồn",
"addLaneRepoLabelAdopt": "Thư mục", "addLaneRepoLabelAdopt": "Thư mục",
"addLaneRouterCustom": "AI Router",
"addLaneRouterEmptyHint": "Chưa lưu profile router nào — thêm ở Settings › AI Router.",
"addLaneRouterHint": "Native là gọi thẳng API của hãng. Chọn profile AI Router thì cấu hình được ghi vào config riêng của workspace này lúc tạo.",
"addLaneRouterLabel": "Endpoint model",
"addLaneRouterLoading": "Đang tải danh sách router…",
"addLaneRouterProfileLabel": "Profile router",
"addLaneSetupClose": "Đóng", "addLaneSetupClose": "Đóng",
"addLaneSetupMcp": "MCP server", "addLaneSetupMcp": "MCP server",
"addLaneSetupProfile": "Profile", "addLaneSetupProfile": "Profile",
"addLaneSetupRouter": "Endpoint model",
"addLaneSetupTitle": "Thiết lập", "addLaneSetupTitle": "Thiết lập",
"addLaneTitleLabel": "Tiêu đề", "addLaneTitleLabel": "Tiêu đề",
"addLaneTitlePlaceholder": "Không bắt buộc", "addLaneTitlePlaceholder": "Không bắt buộc",
"addLaneToolDefaultBadge": "Mặc định", "addLaneToolDefaultBadge": "Mặc định",
"addLaneNoTools": "Chưa có AI tool nào được cài và bật.", "addLaneNoTools": "Chưa có AI tool nào được cài và bật.",
"addLaneNoToolsLink": "Mở Cài đặt → AI Tools", "addLaneNoToolsLink": "Mở Cài đặt → AI Tools",
"addLaneRouterNativeTool": "Native ({{tool}})",
"addLaneToolHint": "CLI nào điều khiển workspace này. Không thể thay đổi sau này.", "addLaneToolHint": "CLI nào điều khiển workspace này. Không thể thay đổi sau này.",
"addLaneWorktreeDetached": "(detached)", "addLaneWorktreeDetached": "(detached)",
"addLaneWorktreesMore": "+{{count}} nữa", "addLaneWorktreesMore": "+{{count}} nữa",
@@ -222,13 +214,8 @@
"proof.ticketReport": "Báo cáo nhiệm vụ", "proof.ticketReport": "Báo cáo nhiệm vụ",
"renameLabel": "Đổi tên workspace này", "renameLabel": "Đổi tên workspace này",
"reorderHint": "Kéo để sắp xếp · Alt+←/→", "reorderHint": "Kéo để sắp xếp · Alt+←/→",
"router.applied": "Đã áp dụng.",
"router.native": "Native ({{tool}})", "router.native": "Native ({{tool}})",
"router.nativeOnly": "{{tool}} chạy bằng cấu hình riêng — hồ sơ AI Router chỉ dùng được với Claude Code và Codex.", "router.label": "AI Router",
"router.appliedRestart": "Đã áp dụng, nhưng phiên đang chạy trong workspace này vẫn dùng model cũ — khởi động lại phiên để có hiệu lực.",
"router.label": "Model",
"router.restart": "Khởi động lại",
"router.restarted": "Đã khởi động lại phiên với router mới.",
"runtime.boot": "▶ chạy", "runtime.boot": "▶ chạy",
"runtime.busy.down": "đang dừng…", "runtime.busy.down": "đang dừng…",
"runtime.busy.up": "đang khởi động…", "runtime.busy.up": "đang khởi động…",
+2 -5
View File
@@ -67,8 +67,7 @@
"claudeHome": { "claudeHome": {
"title": "CLAUDE_HOME", "title": "CLAUDE_HOME",
"description": "Thư mục gốc chứa dữ liệu phiên Claude Code. Thay đổi sẽ cập nhật ngay đường dẫn quét cho transcript và import.", "description": "Thư mục gốc chứa dữ liệu phiên Claude Code. Thay đổi sẽ cập nhật ngay đường dẫn quét cho transcript và import.",
"placeholder": "/đường-dẫn/tới/claude-home", "placeholder": "/đường-dẫn/tới/claude-home"
"routersMissing": "Đã lưu. Các workspace này dùng một hồ sơ AI Router mà thư mục mới không có, nên sẽ chạy Native cho đến khi bạn thêm hồ sơ đó ở đây hoặc chọn lại router cho workspace: {{lanes}}"
}, },
"hooks": { "hooks": {
"title": "Cấu hình hook", "title": "Cấu hình hook",
@@ -510,7 +509,6 @@
"cancel": "Huỷ", "cancel": "Huỷ",
"removeConfirm": "Xoá profile \"{{label}}\"? Mọi workspace đang dùng nó sẽ được đưa về API gốc của hãng ngay lập tức — cả trên đĩa lẫn trong dashboard.", "removeConfirm": "Xoá profile \"{{label}}\"? Mọi workspace đang dùng nó sẽ được đưa về API gốc của hãng ngay lập tức — cả trên đĩa lẫn trong dashboard.",
"loading": "Đang tải danh sách profile…", "loading": "Đang tải danh sách profile…",
"lanesFailed": "Đã lưu, nhưng {{count}} workspace không cập nhật được và vẫn đang giữ endpoint cũ: {{lanes}}",
"test": "Kiểm tra", "test": "Kiểm tra",
"testing": "Đang kiểm tra…", "testing": "Đang kiểm tra…",
"testOk": "Endpoint đã trả lời — {{model}} dùng được với key này.", "testOk": "Endpoint đã trả lời — {{model}} dùng được với key này.",
@@ -589,8 +587,7 @@
"title": "CODEX_HOME", "title": "CODEX_HOME",
"description": "Thư mục gốc chứa config và nhật ký phiên của Codex. Dashboard đọc từ đây, ghi layer AI Router vào đây, và mở mọi workspace codex với thư mục này.", "description": "Thư mục gốc chứa config và nhật ký phiên của Codex. Dashboard đọc từ đây, ghi layer AI Router vào đây, và mở mọi workspace codex với thư mục này.",
"restartNote": "Workspace codex đang chạy vẫn dùng thư mục cũ, và hiện là idle, cho tới khi được khởi động lại.", "restartNote": "Workspace codex đang chạy vẫn dùng thư mục cũ, và hiện là idle, cho tới khi được khởi động lại.",
"placeholder": "/đường-dẫn/tới/codex-home", "placeholder": "/đường-dẫn/tới/codex-home"
"routersFailed": "Đã lưu, nhưng không ghi lại được layer AI Router cho: {{lanes}}"
}, },
"codexHooks": { "codexHooks": {
"title": "Hook của Codex", "title": "Hook của Codex",
+10 -39
View File
@@ -1042,19 +1042,16 @@ export const api = {
/** /**
* PUT /api/settings/claude-home - repoint the server at a new Claude home. * PUT /api/settings/claude-home - repoint the server at a new Claude home.
* @param path New absolute `~/.claude` root the server should read from. * @param path New absolute `~/.claude` root the server should read from.
* @returns `{ ok, claude_home, routers }` — the accepted path, and the * @returns `{ ok, claude_home }` — the accepted path.
* lanes whose AI Router profile the new home's store does not have.
*/ */
set: (path: string) => set: (path: string) =>
request<{ request<{
ok: boolean; ok: boolean;
claude_home: string; claude_home: string;
env_owned?: boolean; env_owned?: boolean;
routers: { missing: string[] };
}>("/settings/claude-home", { method: "PUT", body: JSON.stringify({ path }) }), }>("/settings/claude-home", { method: "PUT", body: JSON.stringify({ path }) }),
}, },
/** The Codex home: same shape as `claudeHome`, plus the AI Router layers /** The Codex home: same shape as `claudeHome`. */
* a PUT re-wrote into the new home for codex lanes. */
codexHome: { codexHome: {
/** GET /api/settings/codex-home */ /** GET /api/settings/codex-home */
get: () => request<{ codex_home: string; env_owned: boolean }>("/settings/codex-home"), get: () => request<{ codex_home: string; env_owned: boolean }>("/settings/codex-home"),
@@ -1064,7 +1061,6 @@ export const api = {
ok: boolean; ok: boolean;
codex_home: string; codex_home: string;
env_owned: boolean; env_owned: boolean;
routers: { updated: number; failed: string[] };
}>("/settings/codex-home", { method: "PUT", body: JSON.stringify({ path }) }), }>("/settings/codex-home", { method: "PUT", body: JSON.stringify({ path }) }),
}, },
/** AgentHub's hooks in Codex's `$CODEX_HOME/hooks.json` — changed only on a /** AgentHub's hooks in Codex's `$CODEX_HOME/hooks.json` — changed only on a
@@ -2084,7 +2080,7 @@ export const api = {
* display a key, so it has none to send back. * display a key, so it has none to send back.
*/ */
update: (name: string, body: Partial<AiRouterInput>) => update: (name: string, body: Partial<AiRouterInput>) =>
request<AiRouter & { lanes: AiRouterLaneSync }>(`/ai-routers/${encodeURIComponent(name)}`, { request<AiRouter>(`/ai-routers/${encodeURIComponent(name)}`, {
method: "PATCH", method: "PATCH",
body: JSON.stringify(body), body: JSON.stringify(body),
}), }),
@@ -2098,14 +2094,13 @@ export const api = {
body: JSON.stringify(body), body: JSON.stringify(body),
}), }),
/** /**
* DELETE /api/ai-routers/:name - also resets every lane using it back to * DELETE /api/ai-routers/:name - also removes the session files that
* native, on disk as well as in the DB. * copy its key.
*/ */
remove: (name: string) => remove: (name: string) =>
request<{ removed: string; lanes: AiRouterLaneSync }>( request<{ removed: string }>(`/ai-routers/${encodeURIComponent(name)}`, {
`/ai-routers/${encodeURIComponent(name)}`, method: "DELETE",
{ method: "DELETE" } }),
),
}, },
// ────────────────────────────────── Run API ───────────────────────────────── // ────────────────────────────────── Run API ─────────────────────────────────
@@ -2895,23 +2890,6 @@ export const api = {
body: JSON.stringify(body), body: JSON.stringify(body),
}), }),
}, },
/**
* POST /lanes/:id/router/apply - point this lane at an AI Router profile,
* or back at `native`. Writes the lane's own config on disk; only the
* profile NAME comes from here.
*/
routerApply: (id: number, profile: string) =>
request<{
tool: string;
file: string | null;
profile: string;
/** A pane is live and keeps the old models until it restarts. */
restart_required: boolean;
run_id: string | null;
}>(`/lanes/${id}/router/apply`, {
method: "POST",
body: JSON.stringify({ profile }),
}),
mcpSync: (id: number) => mcpSync: (id: number) =>
request<{ servers: string[]; profilesSeeded: string[] }>(`/lanes/${id}/mcp/sync`, { request<{ servers: string[]; profilesSeeded: string[] }>(`/lanes/${id}/mcp/sync`, {
method: "POST", method: "POST",
@@ -3253,15 +3231,6 @@ export interface AiRouterTestInput {
name?: string; name?: string;
} }
/** How an edit or a delete landed on the lanes already using that profile.
* Attaching copies a profile, so the store alone is not the whole truth: a
* lane listed in `failed` still carries the OLD key and endpoint. */
export interface AiRouterLaneSync {
updated: number;
/** One `"<id> (<title>): <code>"` line per lane that could not be rewritten. */
failed: string[];
}
/** Body accepted when creating or updating a profile. */ /** Body accepted when creating or updating a profile. */
export interface AiRouterInput { export interface AiRouterInput {
label: string; label: string;
@@ -3926,6 +3895,8 @@ export interface RunStartArgs {
resumeLast?: boolean; resumeLast?: boolean;
/** Turn off the tool's permission prompts for THIS run (its catalog bypass args). */ /** Turn off the tool's permission prompts for THIS run (its catalog bypass args). */
bypass?: boolean; bypass?: boolean;
/** AI Router profile THIS session runs on; omitted = native, or a resume's own. */
router?: string;
} }
/** A lane's tmux-backed terminal run — one per lane, id is the tmux session /** A lane's tmux-backed terminal run — one per lane, id is the tmux session
+5 -27
View File
@@ -4,12 +4,9 @@
* endpoints (9Router, OpenRouter, a self-hosted gateway) a lane can be pointed * endpoints (9Router, OpenRouter, a self-hosted gateway) a lane can be pointed
* at instead of the vendor's own API. * at instead of the vendor's own API.
* *
* A lane CHOOSES its router in the Add-lane modal, not here. But choosing one * A NEW SESSION chooses its router (the workspace home's prompt box, a pane's
* copies it into that lane's own config, so editing or deleting a profile here * run form), not here. An edit here reaches the sessions already on a profile
* does reach every lane already using it — the server re-applies it, or resets * (the server rewrites the file each reads), and a delete removes that file.
* those lanes to native, and reports any lane it could not touch. That report
* is surfaced: a rotated key that silently missed two lanes would leave the
* user believing a revoked key is out of use when it is not.
* *
* The built-in `native` row is rendered read-only: it is the vendor's own API, * The built-in `native` row is rendered read-only: it is the vendor's own API,
* and it exists so that "go back to native" is the same operation as "use a * and it exists so that "go back to native" is the same operation as "use a
@@ -49,9 +46,6 @@ export function AiRouterConfig() {
const { t } = useTranslation("settings"); const { t } = useTranslation("settings");
const [items, setItems] = useState<AiRouter[]>([]); const [items, setItems] = useState<AiRouter[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Not an error — the profile itself saved. It is the lanes that did not all
// follow, which the user must be told about rather than left to assume.
const [laneWarning, setLaneWarning] = useState<string | null>(null);
const [editing, setEditing] = useState<AiRouter | null>(null); const [editing, setEditing] = useState<AiRouter | null>(null);
const [form, setForm] = useState(emptyForm()); const [form, setForm] = useState(emptyForm());
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -110,14 +104,6 @@ export function AiRouterConfig() {
}; };
/** Report any lane the server could not re-point, or clear a stale report. */ /** Report any lane the server could not re-point, or clear a stale report. */
const reportLanes = (lanes?: { updated: number; failed: string[] }) => {
setLaneWarning(
lanes && lanes.failed.length
? t("aiRouter.lanesFailed", { count: lanes.failed.length, lanes: lanes.failed.join(", ") })
: null
);
};
/** /**
* Ask the endpoint itself. `name` is sent when editing so a profile whose key * Ask the endpoint itself. `name` is sent when editing so a profile whose key
* the form cannot show is still testable — the server only honours it while * the form cannot show is still testable — the server only honours it while
@@ -152,7 +138,6 @@ export function AiRouterConfig() {
const submit = async () => { const submit = async () => {
setBusy(true); setBusy(true);
setError(null); setError(null);
setLaneWarning(null);
try { try {
// Send only the tiers that differ from the headline model, so the server // Send only the tiers that differ from the headline model, so the server
// keeps filling the rest from it — that is what makes one symbolic name // keeps filling the rest from it — that is what makes one symbolic name
@@ -170,7 +155,7 @@ export function AiRouterConfig() {
}; };
if (form.apiKey.trim()) body.apiKey = form.apiKey.trim(); if (form.apiKey.trim()) body.apiKey = form.apiKey.trim();
if (editing) reportLanes((await api.aiRouters.update(editing.name, body)).lanes); if (editing) await api.aiRouters.update(editing.name, body);
else await api.aiRouters.create(body); else await api.aiRouters.create(body);
setOpen(false); setOpen(false);
@@ -185,9 +170,8 @@ export function AiRouterConfig() {
const remove = async (item: AiRouter) => { const remove = async (item: AiRouter) => {
if (!window.confirm(t("aiRouter.removeConfirm", { label: item.label }))) return; if (!window.confirm(t("aiRouter.removeConfirm", { label: item.label }))) return;
setError(null); setError(null);
setLaneWarning(null);
try { try {
reportLanes((await api.aiRouters.remove(item.name)).lanes); await api.aiRouters.remove(item.name);
await load(); await load();
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : String(e)); setError(e instanceof Error ? e.message : String(e));
@@ -220,12 +204,6 @@ export function AiRouterConfig() {
</p> </p>
)} )}
{laneWarning && (
<p className="rounded-md border border-status-warning/40 bg-status-warning/10 px-3 py-2 text-xs text-status-warning">
{laneWarning}
</p>
)}
<div className="overflow-hidden rounded-lg border border-border"> <div className="overflow-hidden rounded-lg border border-border">
<table className="w-full text-left text-xs"> <table className="w-full text-left text-xs">
<thead className="bg-surface-2 text-[10px] uppercase tracking-wider text-fg-muted"> <thead className="bg-surface-2 text-[10px] uppercase tracking-wider text-fg-muted">
-3
View File
@@ -473,9 +473,6 @@ export function CodexConfig() {
return { return {
value: r.codex_home, value: r.codex_home,
envOwned: r.env_owned, envOwned: r.env_owned,
warning: r.routers.failed.length
? tSettings("codexHome.routersFailed", { lanes: r.routers.failed.join(", ") })
: null,
}; };
}) })
} }
+1 -8
View File
@@ -70,12 +70,7 @@ import { useAiTools } from "../hooks/useAiTools";
import { AiToolIcon } from "../components/AiToolIcon"; import { AiToolIcon } from "../components/AiToolIcon";
import { LaneConsolePane } from "../components/run/LaneConsolePane"; import { LaneConsolePane } from "../components/run/LaneConsolePane";
import PipelineMap from "../components/lanes/PipelineMap"; import PipelineMap from "../components/lanes/PipelineMap";
import LaneCard, { import LaneCard, { since, LIVENESS_DOT, LaneToolsMenu } from "../components/lanes/LaneCard";
since,
LIVENESS_DOT,
LaneToolsMenu,
LaneRouterRow,
} from "../components/lanes/LaneCard";
import { AddLaneModal } from "../components/lanes/AddLaneModal"; import { AddLaneModal } from "../components/lanes/AddLaneModal";
import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel"; import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel";
import { WorkspaceHome } from "../components/lanes/WorkspaceHome"; import { WorkspaceHome } from "../components/lanes/WorkspaceHome";
@@ -883,8 +878,6 @@ export function Workspace() {
))} ))}
</select> </select>
)} )}
{/* `agy` has no endpoint to point at a router. */}
<LaneRouterRow lane={currentLane} />
{/* No stage chip and no "auto: <stage>" chip here: the {/* No stage chip and no "auto: <stage>" chip here: the
PipelineMap right below this header already draws both PipelineMap right below this header already draws both
— the declared node as the current ring, the detected — the declared node as the current ring, the detected
@@ -69,12 +69,8 @@ beforeEach(() => {
.mockReset() .mockReset()
.mockResolvedValue({ items: [native] }); .mockResolvedValue({ items: [native] });
vi.mocked(api.aiRouters.create).mockReset().mockResolvedValue(nineRouter); vi.mocked(api.aiRouters.create).mockReset().mockResolvedValue(nineRouter);
vi.mocked(api.aiRouters.update) vi.mocked(api.aiRouters.update).mockReset().mockResolvedValue(nineRouter);
.mockReset() vi.mocked(api.aiRouters.remove).mockReset().mockResolvedValue({ removed: "9router" });
.mockResolvedValue({ ...nineRouter, lanes: { updated: 0, failed: [] } });
vi.mocked(api.aiRouters.remove)
.mockReset()
.mockResolvedValue({ removed: "9router", lanes: { updated: 0, failed: [] } });
vi.mocked(api.aiRouters.test) vi.mocked(api.aiRouters.test)
.mockReset() .mockReset()
.mockResolvedValue({ ok: true, status: 200, message: "Highest" }); .mockResolvedValue({ ok: true, status: 200, message: "Highest" });
@@ -149,25 +145,6 @@ describe("AiRouterConfig", () => {
expect(call?.[1]).not.toHaveProperty("apiKey"); expect(call?.[1]).not.toHaveProperty("apiKey");
}); });
it("warns when a rotated key did not reach every lane using the profile", async () => {
// The whole point of pushing an edit out to the lanes is that a revoked key
// stops being used. A lane that silently kept the old one must be named,
// or the user believes a rotation is complete when it is not.
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
vi.mocked(api.aiRouters.update).mockResolvedValue({
...nineRouter,
lanes: { updated: 1, failed: ["7 (checkout): EBADJSON"] },
});
render(<AiRouterConfig />);
const user = userEvent.setup();
await user.click(await screen.findByLabelText("aiRouter.edit 9Router"));
await user.type(screen.getByLabelText("aiRouter.fieldApiKey"), "sk-rotated");
await user.click(screen.getByRole("button", { name: "aiRouter.save" }));
expect(await screen.findByText("aiRouter.lanesFailed")).toBeInTheDocument();
});
it("surfaces a server error instead of closing the form", async () => { it("surfaces a server error instead of closing the form", async () => {
vi.mocked(api.aiRouters.create).mockRejectedValue( vi.mocked(api.aiRouters.create).mockRejectedValue(
new Error("baseUrl must be http:// or https://") new Error("baseUrl must be http:// or https://")
@@ -123,14 +123,13 @@ afterEach(() => {
}); });
describe("CodexConfig", () => { describe("CodexConfig", () => {
it("edits CODEX_HOME on the Overview and reports lanes whose router layer failed", async () => { it("edits CODEX_HOME on the Overview", async () => {
stubCodexApi({}); stubCodexApi({});
codexHomeMock.get.mockResolvedValue({ codex_home: "/home/.codex", env_owned: false }); codexHomeMock.get.mockResolvedValue({ codex_home: "/home/.codex", env_owned: false });
codexHomeMock.set.mockResolvedValue({ codexHomeMock.set.mockResolvedValue({
ok: true, ok: true,
codex_home: "/work/.codex", codex_home: "/work/.codex",
env_owned: false, env_owned: false,
routers: { updated: 1, failed: ["4 (api): EDIR"] },
}); });
render(<CodexConfig />); render(<CodexConfig />);
await settle(); await settle();
@@ -146,7 +145,6 @@ describe("CodexConfig", () => {
await userEvent.click(screen.getByRole("button", { name: "Save" })); await userEvent.click(screen.getByRole("button", { name: "Save" }));
expect(codexHomeMock.set).toHaveBeenCalledWith("/work/.codex"); expect(codexHomeMock.set).toHaveBeenCalledWith("/work/.codex");
expect(await screen.findByText(/4 \(api\): EDIR/)).toBeInTheDocument();
// Every list lives under CODEX_HOME, so a new one re-reads them. // Every list lives under CODEX_HOME, so a new one re-reads them.
expect(codexConfigMock.overview.mock.calls.length).toBeGreaterThan(overviewCalls); expect(codexConfigMock.overview.mock.calls.length).toBeGreaterThan(overviewCalls);
}); });
@@ -30,6 +30,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
return { return {
...actual, ...actual,
api: { api: {
aiRouters: { list: r({ items: [] }) },
run: { run: {
list: r({ runs: [], items: [] }), list: r({ runs: [], items: [] }),
history: r({ items: [] }), history: r({ items: [] }),
@@ -1,243 +0,0 @@
/**
* @file Workspace.laneRouter.test.tsx
* @description The Model (AI Router) picker in the Workspace lane-detail
* header, exercised through the page rather than through `LaneRouterRow` on its
* own — one row instance serves every lane, so the bug this pins is a lane
* switch carrying the previous lane's selection with it: pick a router on one
* lane, come back to a lane that is on native, and see the router still
* selected there.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, act, screen, waitFor } from "@testing-library/react";
import { MemoryRouter, useSearchParams } from "react-router-dom";
import userEvent from "@testing-library/user-event";
type LaneFixture = {
id: number;
cwd: string;
title: string;
pipeline: string;
pipeline_name: string;
pipeline_nodes: never[];
detected_signal: string | null;
run_id: string | null;
router_profile: string | null;
};
const lane = (id: number, title: string): LaneFixture => ({
id,
cwd: `/workspace/${id}`,
title,
pipeline: "default",
pipeline_name: "default",
pipeline_nodes: [],
detected_signal: null,
run_id: null,
router_profile: null,
});
const h = vi.hoisted(() => {
const nativeProfile = {
name: "native",
label: "Native",
builtin: true,
baseUrl: "",
hasKey: false,
model: "claude-sonnet-5",
models: {
opus: "claude-opus-5",
sonnet: "claude-sonnet-5",
haiku: "claude-haiku-4-5-20251001",
smallFast: "claude-haiku-4-5-20251001",
subagent: "claude-haiku-4-5-20251001",
},
};
return {
lanes: [] as { id: number; router_profile: string | null }[],
routerApply: vi.fn(),
profiles: [
nativeProfile,
{ ...nativeProfile, name: "9router", label: "9Router", builtin: false },
],
};
});
const routerApply = h.routerApply;
let lanesToReturn: LaneFixture[] = [];
// The workspace's home (its own suite) fetches sources and memory; these
// tests are about the session view it hides, so it is a stub here.
vi.mock("../../components/lanes/WorkspaceHome", () => ({
WorkspaceHome: () => <div data-testid="workspace-home" />,
paneRunId: (id: number, slot: number) =>
slot ? `agenthub-lane-${id}-${slot}` : `agenthub-lane-${id}`,
}));
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
lanes: {
list: vi.fn().mockImplementation(async () => ({
lanes: h.lanes,
counts: { total: h.lanes.length, running: 0, needs_you: 0, dead: 0 },
})),
ensure: vi.fn(),
action: vi.fn(),
git: vi.fn().mockResolvedValue({ available: false }),
runtime: vi.fn().mockResolvedValue({ available: false }),
stage: vi.fn(),
update: vi.fn(),
pipelines: vi
.fn()
.mockResolvedValue({ pipelines: [{ id: "default", name: "default", nodes: [] }] }),
// The route the picker must use — never a lane PATCH.
routerApply: vi.fn().mockImplementation(async (id: number, profile: string) => {
h.routerApply(id, profile);
// The row moving with the call is the server's broadcast, not a test
// convenience: the apply route broadcasts the lane so the cached row
// stops disagreeing with what was just written. Drop the broadcast
// server-side and the next lane switch re-reads a stale profile.
const row = h.lanes.find((l) => l.id === id);
if (row) row.router_profile = profile === "native" ? null : profile;
return { tool: "claude", file: `/workspace/${id}/.claude/settings.local.json`, profile };
}),
features: { list: vi.fn().mockResolvedValue({ features: [] }), show: vi.fn() },
proof: {
list: vi.fn().mockResolvedValue({ features: [] }),
imageUrl: vi.fn(),
delete: vi.fn(),
},
},
aiRouters: {
list: vi.fn().mockImplementation(async () => ({ items: h.profiles })),
},
run: {
list: vi.fn().mockResolvedValue({ runs: [], items: [] }),
history: vi.fn().mockResolvedValue({ items: [] }),
binary: vi.fn().mockResolvedValue({ found: true, path: "/usr/bin/claude" }),
cwds: vi.fn().mockResolvedValue({ items: [] }),
files: vi.fn().mockResolvedValue({ items: [] }),
start: vi.fn(),
get: vi.fn(),
send: vi.fn(),
kill: vi.fn(),
},
ccConfig: {
commands: vi.fn().mockResolvedValue({ items: [] }),
plugins: vi.fn().mockResolvedValue({ plugins: [] }),
file: vi.fn().mockResolvedValue({ text: "", content: "" }),
},
sessions: {
list: vi.fn().mockResolvedValue({ sessions: [], total: 0, limit: 50, offset: 0 }),
transcript: vi.fn().mockResolvedValue({ messages: [] }),
},
},
};
});
vi.mock("../../lib/eventBus", () => ({
eventBus: {
subscribe: () => () => {},
publish: () => {},
onConnection: () => () => {},
connected: true,
setConnected: () => {},
},
}));
vi.mock("../../components/run/TerminalView", () => ({
TerminalView: ({ runId }: { runId: string }) => (
<div data-testid="terminal-view" data-run-id={runId} />
),
}));
import { Workspace } from "../Workspace";
class ObserverStub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver =
globalThis.ResizeObserver || (ObserverStub as unknown as typeof ResizeObserver);
for (const fn of ["scrollIntoView", "scrollBy", "scrollTo"] as const) {
if (!(Element.prototype as unknown as Record<string, unknown>)[fn]) {
(Element.prototype as unknown as Record<string, unknown>)[fn] = function () {};
}
}
async function settle() {
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
});
}
beforeEach(() => {
routerApply.mockClear();
lanesToReturn = [lane(1, "Lane A"), lane(2, "Lane B")];
h.lanes = lanesToReturn as unknown as typeof h.lanes;
});
/** Stands in for the sidebar's project tree: picking a workspace sets `?lane=`. */
function Picker() {
const [, setParams] = useSearchParams();
return (
<>
{lanesToReturn.map((l) => (
<button key={l.id} onClick={() => setParams({ lane: String(l.id) })}>
{`pick ${l.title}`}
</button>
))}
</>
);
}
async function renderWorkspace() {
const utils = render(
<MemoryRouter initialEntries={["/run"]}>
<Picker />
<Workspace />
</MemoryRouter>
);
await settle();
await settle();
await settle();
return utils;
}
/** Pick a lane the way the sidebar does: by naming it in the URL. */
async function selectLane(user: ReturnType<typeof userEvent.setup>, title: string) {
await user.click(screen.getByRole("button", { name: `pick ${title}` }));
await settle();
}
describe("Workspace — the lane Model picker across a lane switch", () => {
it("leaves a lane on native after another lane was pointed at a router", async () => {
await renderWorkspace();
const user = userEvent.setup();
await selectLane(user, "Lane A");
expect(await screen.findByDisplayValue("Native (Claude)")).toBeInTheDocument();
await selectLane(user, "Lane B");
await user.selectOptions(screen.getByDisplayValue("Native (Claude)"), ["9router"]);
await waitFor(() => expect(routerApply).toHaveBeenCalledWith(2, "9router"));
await settle();
expect(screen.getByDisplayValue("9Router")).toBeInTheDocument();
// Back to the lane that was never switched. It is still native server-side,
// so the picker must say so — the row is one instance shared by every lane.
await selectLane(user, "Lane A");
expect(screen.getByDisplayValue("Native (Claude)")).toBeInTheDocument();
// And nothing may have been written to lane A on the way.
expect(routerApply).not.toHaveBeenCalledWith(1, expect.anything());
});
});
@@ -258,6 +258,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
return { return {
...actual, ...actual,
api: { api: {
aiRouters: { list: r({ items: [] }) },
stats: { get: r(stats), facets: r({ cwds: [] }) }, stats: { get: r(stats), facets: r({ cwds: [] }) },
sessions: { sessions: {
list: r({ sessions: [], total: 0, limit: 50, offset: 0 }), list: r({ sessions: [], total: 0, limit: 50, offset: 0 }),
+7 -12
View File
@@ -272,6 +272,7 @@ All fields are optional. The tool (CLI invoked) comes from the workspace itself,
- **`approval`** — one of `on-request` or `never`, or omitted/empty for no approval prompt. Passed to `codex -a <value>`. - **`approval`** — one of `on-request` or `never`, or omitted/empty for no approval prompt. Passed to `codex -a <value>`.
- **`resumeSessionId`** or **`resumeLast: true`** — resume an earlier run instead of starting new. For Codex workspaces specifically, `resumeLast: true` uses `codex resume --last`; `resumeSessionId` uses `codex resume <sessionId>`. For Claude workspaces, `resumeSessionId` re-attaches a Claude session by id. - **`resumeSessionId`** or **`resumeLast: true`** — resume an earlier run instead of starting new. For Codex workspaces specifically, `resumeLast: true` uses `codex resume --last`; `resumeSessionId` uses `codex resume <sessionId>`. For Claude workspaces, `resumeSessionId` re-attaches a Claude session by id.
- **`forkSession: true`** (with `resumeSessionId`, Claude workspaces only) — resume into a NEW session id (`claude --resume <id> --fork-session`), leaving the original untouched; the run records no session until the fork's first hook. Without `resumeSessionId` it is `400 EBADSESSION`, on any other tool `400 EUNSUPPORTED`. - **`forkSession: true`** (with `resumeSessionId`, Claude workspaces only) — resume into a NEW session id (`claude --resume <id> --fork-session`), leaving the original untouched; the run records no session until the fork's first hook. Without `resumeSessionId` it is `400 EBADSESSION`, on any other tool `400 EUNSUPPORTED`.
- **`router: "<name>"`** — the AI Router profile THIS session runs on (omitted = native, or for a resume the router its session last ran on); see § AI Router profiles. `400 EBADROUTER` for an unknown name, `400 EUNSUPPORTED` on a tool other than Claude/Codex.
- **`worktree: true`** (Claude workspaces, no `resumeSessionId`) — a NEW session in a git worktree of its own, Claude Code's way: the pane runs `claude --worktree=ah-<id>-<base36 time>` (the `=` form, since `-w [name]` would take the prompt as the name; the name is made by the server), so Claude itself creates `<repo>/.claude/worktrees/<name>` on branch `worktree-<name>`, copies `.worktreeinclude`, runs the user's `WorktreeCreate` hook and cleans up on exit. `/.claude/worktrees/` is added to the repo root's `info/exclude`; the run is recorded with `worktree: 1` (and the run handle carries `worktree: true`). `400 EUNSUPPORTED` on any other tool. A `resumeSessionId` (or fork) of a session that ran in such a worktree starts the pane IN that worktree; one whose worktree is gone is `400 ENOWORKTREE`. - **`worktree: true`** (Claude workspaces, no `resumeSessionId`) — a NEW session in a git worktree of its own, Claude Code's way: the pane runs `claude --worktree=ah-<id>-<base36 time>` (the `=` form, since `-w [name]` would take the prompt as the name; the name is made by the server), so Claude itself creates `<repo>/.claude/worktrees/<name>` on branch `worktree-<name>`, copies `.worktreeinclude`, runs the user's `WorktreeCreate` hook and cleans up on exit. `/.claude/worktrees/` is added to the repo root's `info/exclude`; the run is recorded with `worktree: 1` (and the run handle carries `worktree: true`). `400 EUNSUPPORTED` on any other tool. A `resumeSessionId` (or fork) of a session that ran in such a worktree starts the pane IN that worktree; one whose worktree is gone is `400 ENOWORKTREE`.
- **`slot`** — which Workspace split pane's session to start, `0`–`3` (default `0`, else `400 EBADSLOT`). Slot 0 is the workspace's own `agenthub-lane-<id>` and is what `run_id` records; slots 1–3 are extra sessions on the same workspace, `agenthub-lane-<id>-<slot>`, and never touch `run_id`/`status`. The response adds `run_id`: the session this start opened. `stop`, `reset` and `remove` kill every slot's session, not only `run_id`. - **`slot`** — which Workspace split pane's session to start, `0`–`3` (default `0`, else `400 EBADSLOT`). Slot 0 is the workspace's own `agenthub-lane-<id>` and is what `run_id` records; slots 1–3 are extra sessions on the same workspace, `agenthub-lane-<id>-<slot>`, and never touch `run_id`/`status`. The response adds `run_id`: the session this start opened. `stop`, `reset` and `remove` kill every slot's session, not only `run_id`.
@@ -2230,7 +2231,7 @@ GET /api/ai-tools/:id/icon The tool's favicon, fetched once by the se
Each tool: `{ id, label, command, homepageUrl, installed, path, enabled, source: "builtin"|"custom", launcher, prompt: { mode }, resume: bool, bypass: bool, routerCapable: bool }` — flags only, never the arg arrays. Each tool also carries `modelFlag` (the CLI takes a model: Claude, Codex, or a catalog `model: { flag, vendor? }` field) and `defaultModel` (what a new run starts with, `null` = the CLI's own; a `PUT` with `""`/`null` clears it, an id that is not a plain model name is `400 EBADMODEL`). The config files are only those a built-in or release catalog file lists in `config` (`~/`-relative, no `..`, no credential-looking name); a read or save answers `404 ENOFILE` (no such index) / `ENOENT` (missing — never created), `403 ESECRET` (the name, or a symlink target's, looks like a credential), `413 ETOOBIG` (> 1 MB), `415 EBINARY`, `400 EBADSYNTAX` (JSON/JSONC that does not parse), `409 ESTALE` (the file is no longer at `baseSha`); a save backs the old text up under `<data dir>/ai-tool-config-backups/<id>/` and is same-origin only. `GET /:id/models` reads each tool's own source: Codex's `$CODEX_HOME/models_cache.json` (listed entries by priority — nothing else from that file), Claude's aliases named after the newest priced version plus the priced pinned versions, a catalog tool's vendor-priced ids plus the models its own sessions used; `flag: false` and `[]` for a tool with no model flag, `404 ENOTOOL` for an unknown id. `default` is the effective default: the stored one if installed and enabled, else `claude` if installed and enabled, else the first installed enabled tool, else `null`. `PUT` answers `404 ENOTOOL` for an unknown id, `400 EBADDEFAULT` for a default that is not installed or disabled, `400 EBADFIELD` for a non-boolean `enabled`; disabling the default clears it. Writes are same-origin only. State lives in `<data dir>/ai-tools.json`. Each tool also carries `install` (`{kind:"npm", package}` or `{kind:"script", url}`, else null) and `update` (`{kind:"npm"}`, `{kind:"script"}`, `{kind:"self", args}`, else null), and the body carries `catalog: { source: "builtin"|"remote", tag, fetchedAt }` — `remote` while a copy of the newest release's catalog, newer than this build, is in use (synced daily, off with `AGENTHUB_AI_TOOLS_SYNC=0`). Each tool: `{ id, label, command, homepageUrl, installed, path, enabled, source: "builtin"|"custom", launcher, prompt: { mode }, resume: bool, bypass: bool, routerCapable: bool }` — flags only, never the arg arrays. Each tool also carries `modelFlag` (the CLI takes a model: Claude, Codex, or a catalog `model: { flag, vendor? }` field) and `defaultModel` (what a new run starts with, `null` = the CLI's own; a `PUT` with `""`/`null` clears it, an id that is not a plain model name is `400 EBADMODEL`). The config files are only those a built-in or release catalog file lists in `config` (`~/`-relative, no `..`, no credential-looking name); a read or save answers `404 ENOFILE` (no such index) / `ENOENT` (missing — never created), `403 ESECRET` (the name, or a symlink target's, looks like a credential), `413 ETOOBIG` (> 1 MB), `415 EBINARY`, `400 EBADSYNTAX` (JSON/JSONC that does not parse), `409 ESTALE` (the file is no longer at `baseSha`); a save backs the old text up under `<data dir>/ai-tool-config-backups/<id>/` and is same-origin only. `GET /:id/models` reads each tool's own source: Codex's `$CODEX_HOME/models_cache.json` (listed entries by priority — nothing else from that file), Claude's aliases named after the newest priced version plus the priced pinned versions, a catalog tool's vendor-priced ids plus the models its own sessions used; `flag: false` and `[]` for a tool with no model flag, `404 ENOTOOL` for an unknown id. `default` is the effective default: the stored one if installed and enabled, else `claude` if installed and enabled, else the first installed enabled tool, else `null`. `PUT` answers `404 ENOTOOL` for an unknown id, `400 EBADDEFAULT` for a default that is not installed or disabled, `400 EBADFIELD` for a non-boolean `enabled`; disabling the default clears it. Writes are same-origin only. State lives in `<data dir>/ai-tools.json`. Each tool also carries `install` (`{kind:"npm", package}` or `{kind:"script", url}`, else null) and `update` (`{kind:"npm"}`, `{kind:"script"}`, `{kind:"self", args}`, else null), and the body carries `catalog: { source: "builtin"|"remote", tag, fetchedAt }` — `remote` while a copy of the newest release's catalog, newer than this build, is in use (synced daily, off with `AGENTHUB_AI_TOOLS_SYNC=0`).
**Install / update jobs.** Commands come only from built-in or release catalog files, never from the request or `DASHBOARD_AI_TOOLS_DIR`. npm is the login shell's (`npm install -g <package>@latest`); a script is downloaded by the server (https, same site as the tool's homepage, ≤ 1 MB) and run as `bash <file>`; `self` runs `<detected path> ...args`. One job server-wide; the job is `{ id, tool, action, status: "running"|"ok"|"failed", startedAt, endedAt, exitCode, log }` (last 256 KB, 10-minute timeout) and lives in memory. A successful install enables the tool. Errors: `404 ENOTOOL`; `400 ENOINSTALL`/`ENOUPDATE`/`ENOTHING`; `409 EINSTALLED`/`ENOTINSTALLED`/`ENOTNPM` (npm update of a tool whose binary is not under `npm prefix -g`)/`ENONPM`/`EBUSY`. Icons are the root `/favicon.ico`, raster only by magic bytes (never SVG), ≤ 256 KB, cached in `<data dir>/runtime/ai-tool-icons/`, served with `nosniff` and `Content-Security-Policy: sandbox`; an `<img>` passes the dashboard token as `?token=`. **Install / update jobs.** Commands come only from built-in or release catalog files, never from the request or `DASHBOARD_AI_TOOLS_DIR`. npm is the login shell's (`npm install -g <package>@latest`); a script is downloaded by the server (https, same site as the tool's homepage, ≤ 1 MB) and run as `bash <file>`; `self` runs `<detected path> ...args`. One job server-wide; the job is `{ id, tool, action, status: "running"|"ok"|"failed", startedAt, endedAt, exitCode, log }` (last 256 KB, 10-minute timeout) and lives in memory. A successful install enables the tool. Errors: `404 ENOTOOL`; `400 ENOINSTALL`/`ENOUPDATE`/`ENOTHING`; `409 EINSTALLED`/`ENOTINSTALLED`/`ENOTNPM` (npm update of a tool whose binary is not under `npm prefix -g`)/`ENONPM`/`EBUSY`. Icons are the root `/favicon.ico`, raster only by magic bytes (never SVG), ≤ 256 KB, cached in `<data dir>/runtime/ai-tool-icons/`, served with `nosniff` and `Content-Security-Policy: sandbox`; an `<img>` passes the dashboard token as `?token=`. The client shows its own shipped mark instead where it has one (`client/public/ai-tools/<id>.svg` — claude, codex, openclaw, and hermes as a `-black`/`-white` pair swapped by the theme; `BUNDLED` in `client/src/components/AiToolIcon.tsx`), so this route is asked only for the other tools.
### AI Router profiles ### AI Router profiles
@@ -2242,9 +2243,7 @@ PATCH /api/ai-routers/:name Update the same fields, all optional
DELETE /api/ai-routers/:name Delete one saved profile DELETE /api/ai-routers/:name Delete one saved profile
``` ```
`PATCH` and `DELETE` also answer with `lanes: { updated, failed[] }` — see "an edit reaches the workspaces" below. A **router profile** is an endpoint that stands in front of Claude Code or Codex (9Router, OpenRouter, a self-hosted gateway) plus the model names to use against it. It powers the **AI Router** page of Settings (`/settings/ai-router`), which stores profiles and attaches nothing — the tab's **Test** button (`POST /api/ai-routers/test`, below) is its only outbound call. A profile is chosen per NEW SESSION, not per workspace: the **AI Router** select beside the model picker in the workspace home's prompt box and in a pane's run form (shown only for Claude/Codex and only when a profile beyond Native is saved; the last pick is remembered in `agenthub.session.router`).
A **router profile** is an endpoint that stands in front of Claude Code or Codex (9Router, OpenRouter, a self-hosted gateway) plus the model names to use against it. It powers the **AI Router** page of Settings (`/settings/ai-router`), which stores profiles and attaches nothing — the tab's **Test** button (`POST /api/ai-routers/test`, below) is its only outbound call. Attaching a profile to a workspace happens in the "+ Add workspace" modal, or later from the workspace's card in Workspace.
`models` carries the five per-tier names (`opus`, `sonnet`, `haiku`, `smallFast`, `subagent`). Any tier omitted is filled from `model`, so a router that answers to one symbolic name needs one field, not six. `models` carries the five per-tier names (`opus`, `sonnet`, `haiku`, `smallFast`, `subagent`). Any tier omitted is filled from `model`, so a router that answers to one symbolic name needs one field, not six.
@@ -2256,16 +2255,12 @@ The stored key is only ever sent to the endpoint it was stored against. A blank
`native` is a built-in profile, not the absence of one: empty `baseUrl`/`apiKey` and the vendor's own model names, so switching back is the same write path as switching to a router. It answers `409 EBUILTIN` to any update, delete, or attempt to shadow its name. Other errors: `400 EBADLABEL` / `EBADURL` (an endpoint must be `http:` or `https:`) / `EBADMODEL`, `404 ENOTFOUND`. `native` is a built-in profile, not the absence of one: empty `baseUrl`/`apiKey` and the vendor's own model names, so switching back is the same write path as switching to a router. It answers `409 EBUILTIN` to any update, delete, or attempt to shadow its name. Other errors: `400 EBADLABEL` / `EBADURL` (an endpoint must be `http:` or `https:`) / `EBADMODEL`, `404 ENOTFOUND`.
**`POST /api/lanes/:id/router/apply`** — Body: `{ profile: "<name>" }` — is where a profile reaches disk. The response carries `restart_required` and `run_id`: a pane already running adopts the new connection but keeps the model it resolved at startup, so it must be restarted before the switch is real. Only the name comes from the request; what is written, and where, is derived from the workspace row: **Starting a session on a router.** `POST /api/lanes/:id/start` and `POST /api/run` take `router: "<name>"` (omitted, `""` or `"native"` = the vendor's own API). Only the name comes from the request; the key and endpoint come from the store. An unknown name is `400 EBADROUTER`, a router on a tool other than Claude/Codex `400 EUNSUPPORTED`. A resume (`resumeSessionId`, `resume`) that names no router goes back to the one its session last ran on (`dashboard_runs.router`), or native once that profile is deleted. Each spawn (re)writes the profile's files:
- A **claude** workspace gets eight env keys merged into `<lane.cwd>/.claude/settings.local.json` (`ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, `ANTHROPIC_DEFAULT_HAIKU_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `CLAUDE_CODE_SUBAGENT_MODEL`). Every other key in that file, including every other env var, is preserved; a file that is not valid JSON is reported as `409 EBADJSON` and left untouched. `.local.json` rather than `settings.json` because the written value includes an API key. - **Claude** gets `--settings=<data dir>/router-settings/<name>.json` (dir `0700`, file `0600`): an `env` block with the eight keys `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, `ANTHROPIC_DEFAULT_HAIKU_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `CLAUDE_CODE_SUBAGENT_MODEL` (an empty one left out). The command-line layer outranks the project-local and user settings, so two sessions of one workspace can run on two routers, and only the path is in argv. Native adds nothing, so a router the user set in their own `~/.claude/settings.json` still applies to a native session.
- A **codex** workspace gets `$CODEX_HOME/<name>.config.toml`, the layer `codex -p <name>` loads on top of the base config — Codex has no project-level config, so the base `config.toml` is never touched. The layer sets `wire_api = "responses"`; `"chat"` is not an option, codex-cli 0.149 refuses to load a config containing it. `env_key` is written only when the profile has a key — naming a variable that is never set is itself a startup error — and the key stays out of the file: the workspace's spawn hands `AGENTHUB_ROUTER_KEY` to the pane through tmux's own `-e`, never as typed text. - **Codex** gets `-p <name>` and `$CODEX_HOME/<name>.config.toml`, the layer that flag loads on top of the base config (never touched). The layer sets `wire_api = "responses"` (codex-cli 0.149 refuses `"chat"`); `env_key` is written only when the profile has a key, and the key reaches the pane as `AGENTHUB_ROUTER_KEY` through tmux's own `-e`, never as typed text.
Attaching to a **native** workspace is not a no-op for claude: `ANTHROPIC_BASE_URL` and `ANTHROPIC_AUTH_TOKEN` are written as empty strings (removing them would let a *user-level* router apply to a workspace that asked for native), while the six model names are removed (writing them would override the user's own global model choice). `PATCH` rewrites the profile's files that exist (a live Claude session reads its file again; the next Codex session gets the new layer) and `DELETE` removes both. Workspaces no longer carry a router: `POST /api/lanes/:id/router/apply` is gone, and at every start the server takes the eight keys an older build wrote into a workspace's `.claude/settings.local.json` back out and clears `lanes.router_profile` (a file that is not valid JSON is left alone and retried next start).
The workspace's `router_profile` column is set in the same call. Like `tool`, it is absent from the workspace `PATCH` allowlist: the row and the config on disk may only move together. The updated workspace is then broadcast as `lane_update`, same as every other mutating workspace route — without it a client's cached row keeps the old `router_profile`, and re-selecting the workspace re-reads that stale row and flips the picker back to the previous profile.
**An edit reaches the workspaces that already use the profile.** Attaching *copies* a profile — a claude workspace carries the key and base URL in its own settings file from then on — so a store-only write would be inert exactly where it matters: rotating a leaked key in Settings would leave every claude workspace authenticating with the revoked one. `PATCH` therefore re-applies the profile to each workspace naming it, and `DELETE` resets those workspaces to native (on disk and in the DB) and removes the Codex layer file. Both report `lanes: { updated: <n>, failed: ["<id> (<title>): <code>", …] }`; a workspace whose directory is gone or whose settings file a human has broken is reported there, never fatal to the edit.
--- ---
+1 -1
View File
@@ -87,7 +87,7 @@ terminal with `agenthub lanes add --pipeline <id>`. *Model endpoint* is the one
per-workspace extra the form still asks for, and it sits in the form itself rather per-workspace extra the form still asks for, and it sits in the form itself rather
than behind a disclosure — a disclosure around a single field only hid it. than behind a disclosure — a disclosure around a single field only hid it.
The **Model endpoint** radio chooses between **Native** (the vendor's own API) and **AI Router** — a profile saved in Settings › AI Router (9Router, OpenRouter, a self-hosted gateway). Choosing one writes it into the new workspace's own config as the workspace is created: a claude workspace gets the `env` block of its `.claude/settings.local.json`, a codex workspace gets a `$CODEX_HOME/<name>.config.toml` layer plus a `-p <name>` on every spawn. With no profile saved the radio stays on Native and says so. Unlike the workspace's tool, this one is not immutable: an existing workspace's detail header in Workspace carries a **Model** select (beside the pipeline picker) showing its current profile, and picking another calls `POST /api/lanes/:id/router/apply`, rewriting the workspace's config and its `router_profile` column together. (The select is hidden when `native` is the only profile — there would be nothing to choose.) A failed apply puts the select back rather than leaving the row claiming a router the workspace did not get, and marks itself `⚠` with the error in its tooltip. A session already running in the pane is only HALF switched: Claude Code re-applies an `env` block to a live session when the file changes, but the session's model was resolved at startup, so the previous router's model name keeps being sent to the new endpoint and the next prompt fails with a model-not-found error (switching back to Native included). The route therefore answers `restart_required: true` plus the `run_id` whenever a pane is live, and the select renders a **Restart** button that kills that pane and opens a fresh one — started from the workspace id alone, since replaying the old run's `--model` would re-create the same mismatch. Editing or deleting the profile itself in Settings also reaches every workspace using it: an edit re-applies it (so a rotated key actually lands), a delete resets those workspaces to Native. See `docs/API.md` § AI Router profiles. A workspace has no AI Router of its own: each NEW session picks one. The **AI Router** select beside the model picker — in the workspace home's prompt box and in a pane's run form — lists the profiles saved in Settings › AI Router (9Router, OpenRouter, a self-hosted gateway) plus **Native** (the vendor's own API); it is hidden when only Native exists or the workspace's tool cannot use a router (only Claude and Codex can), and it remembers the last pick (`agenthub.session.router`). The start sends `router: "<name>"`; a Claude pane then runs with `--settings=<data dir>/router-settings/<name>.json`, which outranks the workspace's own settings, and a Codex pane with `-p <name>` plus its key through tmux `-e`. So two sessions of one workspace can run on two routers, a worktree session gets its router too, and switching never touches a running session. A resume goes back to the router its session last ran on (`dashboard_runs.router`). Editing a profile in Settings rewrites those files (a rotated key reaches live Claude sessions); deleting it removes them. Older builds wrote a workspace's router into its `.claude/settings.local.json`; every server start takes those eight keys back out. See `docs/API.md` § AI Router profiles.
Both modes' path field has a **Browse** button next to it, opening a small folder browser (`GET /api/lanes/browse?path=<abs>`) instead of a native OS picker — a browser cannot hand a web page an absolute filesystem path from a native dialog, so this dashboard (local-first, server and browser on the same machine) lists directories server-side instead: click a subfolder to descend, ".." to go up, "Select this folder" to fill the path field. Git repos are marked in the listing. Both modes' path field has a **Browse** button next to it, opening a small folder browser (`GET /api/lanes/browse?path=<abs>`) instead of a native OS picker — a browser cannot hand a web page an absolute filesystem path from a native dialog, so this dashboard (local-first, server and browser on the same machine) lists directories server-side instead: click a subfolder to descend, ".." to go up, "Select this folder" to fill the path field. Git repos are marked in the listing.
+1 -1
View File
@@ -50,7 +50,7 @@ archived, and give a session — not a project — its own worktree.
| 3 | Drop the activity bar's **Sessions** tab (third copy of the same list) | small | | 3 | Drop the activity bar's **Sessions** tab (third copy of the same list) | small |
| 4 | **Fork** a Claude session into a free pane (`--resume <id> --fork-session`) | small | | 4 | **Fork** a Claude session into a free pane (`--resume <id> --fork-session`) | small |
| 5 | Ctrl/Cmd+click a live session in the sidebar opens it in a second pane | small | | 5 | Ctrl/Cmd+click a live session in the sidebar opens it in a second pane | small |
| 6 | Worktree per session = child workspace (D4) — **ask first** | large | | 6 | Worktree per session, Claude's way (D4) | large |
| 7 | Comment on a diff line → text pasted into the session's prompt | medium, optional | | 7 | Comment on a diff line → text pasted into the session's prompt | medium, optional |
### Phase 1 — session ownership ### Phase 1 — session ownership
+2 -2
View File
@@ -682,9 +682,9 @@ Because sync runs non-interactively (`ssh -o BatchMode=yes`), the connection mus
| `POST` | `/api/settings/reinstall-hooks`| Reinstall Claude Code hooks | | `POST` | `/api/settings/reinstall-hooks`| Reinstall Claude Code hooks |
| `POST` | `/api/settings/reset-pricing` | Reset pricing table to defaults, then run the automatic pricing sync at once (waiting up to 3 s). Body `{scope: "claude"}` (the Claude Code tab's **Reset Defaults**) deletes and reseeds only `claude-…` rows; no body resets every row | | `POST` | `/api/settings/reset-pricing` | Reset pricing table to defaults, then run the automatic pricing sync at once (waiting up to 3 s). Body `{scope: "claude"}` (the Claude Code tab's **Reset Defaults**) deletes and reseeds only `claude-…` rows; no body resets every row |
| `GET` | `/api/settings/claude-home` | `{claude_home, env_owned}` — `env_owned` when the real environment or the checkout's `.env` set `CLAUDE_HOME` (a saved value is overridden at the next start) | | `GET` | `/api/settings/claude-home` | `{claude_home, env_owned}` — `env_owned` when the real environment or the checkout's `.env` set `CLAUDE_HOME` (a saved value is overridden at the next start) |
| `PUT` | `/api/settings/claude-home` | `{path}` → validate, apply, persist to AgentHub's settings file. 400 `EBADHOME` (is or contains the OS home) / `INVALID_PATH`, 500 `ESAVE`, 403 `EBADORIGIN` (`sameOriginGuard`). Answers `routers: {missing: [...]}` — workspaces naming an AI Router profile the new `<CLAUDE_HOME>/agenthub-routers/` store lacks (they start on native) | | `PUT` | `/api/settings/claude-home` | `{path}` → validate, apply, persist to AgentHub's settings file. 400 `EBADHOME` (is or contains the OS home) / `INVALID_PATH`, 500 `ESAVE`, 403 `EBADORIGIN` (`sameOriginGuard`) |
| `GET` | `/api/settings/codex-home` | `{codex_home, env_owned}` | | `GET` | `/api/settings/codex-home` | `{codex_home, env_owned}` |
| `PUT` | `/api/settings/codex-home` | Same checks as `claude-home`; answers `routers: {updated, failed}` after re-writing every codex workspace's AI Router layer into the new home (`reapplyCodexLayers`) | | `PUT` | `/api/settings/codex-home` | Same checks as `claude-home`; a session's AI Router layer is written into the current home at its spawn, so nothing is re-written here |
| `GET` | `/api/settings/codex-hooks` | Status of AgentHub's hooks in `$CODEX_HOME/hooks.json` (installed, per-event, Codex's trust as read from `config.toml`) | | `GET` | `/api/settings/codex-hooks` | Status of AgentHub's hooks in `$CODEX_HOME/hooks.json` (installed, per-event, Codex's trust as read from `config.toml`) |
| `POST` | `/api/settings/codex-hooks` | Install or reinstall them (`sameOriginGuard`). 400 `EBADJSON` / `EUNSUPPORTED` / `EFOREIGN` (the forwarder path holds a file that is not AgentHub's) | | `POST` | `/api/settings/codex-hooks` | Install or reinstall them (`sameOriginGuard`). 400 `EBADJSON` / `EUNSUPPORTED` / `EFOREIGN` (the forwarder path holds a file that is not AgentHub's) |
| `DELETE` | `/api/settings/codex-hooks` | Remove AgentHub's hooks only (`sameOriginGuard`); a foreign forwarder is left alone | | `DELETE` | `/api/settings/codex-hooks` | Remove AgentHub's hooks only (`sameOriginGuard`); a foreign forwarder is left alone |
+12 -56
View File
@@ -278,65 +278,21 @@ describe("/api/lanes", () => {
ws.close(); ws.close();
}); });
it("broadcasts lane_update on router apply", async () => { it("starts a session on the router it names, and refuses one the store lacks", async () => {
// Without this the client's cached row keeps the OLD router_profile: the
// picker looks right until the user switches lanes and back, at which
// point the effect re-reads the stale row and flips the selection.
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-router-bcast-"));
const created = await request("POST", "/api/lanes", { cwd, title: "Router Lane" });
const id = created.body.lane.id;
// Start somewhere other than native, so the broadcast has to carry a
// CHANGED value rather than the one the row already held.
require("../lib/lanes").setRouterProfile(id, "some-router");
const ws = new WebSocket(`ws://127.0.0.1:${server.address().port}/ws`);
const messages = [];
await new Promise((resolve, reject) => {
ws.on("open", resolve);
ws.on("error", reject);
});
ws.on("message", (msg) => {
try {
messages.push(JSON.parse(msg));
} catch {
/* parse error */
}
});
try {
const applied = await request("POST", `/api/lanes/${id}/router/apply`, { profile: "native" });
assert.equal(applied.status, 200);
await new Promise((resolve) => setTimeout(resolve, 100));
const msg = messages.find((m) => m.type === "lane_update" && m.data.lane?.id === id);
assert.ok(msg, "router apply should broadcast lane_update");
// `native` is stored as null — what matters is that the pushed row is the
// post-apply one, not the "some-router" the client still has cached.
assert.equal(msg.data.lane.router_profile, null);
} finally {
// A socket left open by a failed assertion keeps the whole run alive.
ws.close();
}
await request("DELETE", `/api/lanes/${id}`);
fs.rmSync(cwd, { recursive: true, force: true });
});
it("router apply asks for a restart only while the lane's pane is live", async () => {
const tmux = require("../lib/tmux"); const tmux = require("../lib/tmux");
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-router-live-")); const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-router-start-"));
const id = (await request("POST", "/api/lanes", { cwd })).body.lane.id; const id = (await request("POST", "/api/lanes", { cwd })).body.lane.id;
try { try {
for (const live of [false, true]) { tmux.__setExecImpl((args) => {
tmux.__setExecImpl((args) => { if (args[0] === "has-session") throw new Error("no such session");
if (args[0] === "has-session" && !live) throw new Error("no such session"); return "";
return ""; });
}); const bad = await request("POST", `/api/lanes/${id}/start`, { router: "nope" });
const r = await request("POST", `/api/lanes/${id}/router/apply`, { profile: "native" }); assert.equal(bad.status, 400);
assert.equal(r.status, 200); assert.equal(bad.body.error.code, "EBADROUTER");
assert.equal(r.body.restart_required, live); // The per-workspace route is gone: a router belongs to a session now.
assert.equal(r.body.run_id, live ? `agenthub-lane-${id}` : null); const gone = await request("POST", `/api/lanes/${id}/router/apply`, { profile: "native" });
} assert.equal(gone.status, 404);
} finally { } finally {
tmux.__reset(); tmux.__reset();
fs.rmSync(cwd, { recursive: true, force: true }); fs.rmSync(cwd, { recursive: true, force: true });
+53
View File
@@ -267,6 +267,59 @@ describe("pty-run", () => {
} }
}); });
it("spawnRun starts a session on ITS router: Claude by --settings, Codex by -p", () => {
let newSessionArgv = null;
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") {
const e = new Error("gone");
e.status = 1;
throw e;
}
if (args[0] === "new-session") newSessionArgv = args;
return "";
});
// The router store and the files a spawn writes live under these homes.
const saved = ["CLAUDE_HOME", "CODEX_HOME", "DASHBOARD_DATA_DIR"].map((k) => [
k,
process.env[k],
]);
process.env.CLAUDE_HOME = path.join(TMP, "claude");
process.env.CODEX_HOME = path.join(TMP, "codex");
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
try {
const { name } = require("../lib/ai-routers").createRouter({
label: "Router",
baseUrl: "http://localhost:20128/v1",
apiKey: "sk-secret-value",
model: "M",
});
pty.spawnRun({ laneId: 51, cwd: "/tmp/repo", router: name, initialPrompt: "hi" });
const flag = newSessionArgv.find((a) => a.startsWith("--settings="));
assert.equal(flag, `--settings=${path.join(TMP, "data", "router-settings", `${name}.json`)}`);
assert.ok(!newSessionArgv.join(" ").includes("sk-secret-value"));
assert.equal(require("../lib/dashboard-runs").getRun("agenthub-lane-51").router, name);
pty.spawnRun({ laneId: 52, cwd: "/tmp/repo", tool: "codex", router: name });
const at = newSessionArgv.indexOf("-p");
assert.deepEqual(newSessionArgv.slice(at, at + 2), ["-p", name]);
// The key rides tmux's -e, never the command line.
assert.ok(newSessionArgv.includes("AGENTHUB_ROUTER_KEY=sk-secret-value"));
pty.spawnRun({ laneId: 53, cwd: "/tmp/repo" });
assert.ok(!newSessionArgv.some((a) => a.startsWith("--settings=")));
assert.equal(require("../lib/dashboard-runs").getRun("agenthub-lane-53").router, null);
assert.throws(
() => pty.spawnRun({ laneId: 54, cwd: "/tmp/repo", router: "nope" }),
(e) => e.code === "EBADROUTER"
);
} finally {
for (const [k, v] of saved) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
}
});
it("spawnRun offers bypass in the pane's shift+tab cycle without starting in it", () => { it("spawnRun offers bypass in the pane's shift+tab cycle without starting in it", () => {
let newSessionArgv = null; let newSessionArgv = null;
tmux.__setExecImpl((args) => { tmux.__setExecImpl((args) => {
+149 -361
View File
@@ -1,18 +1,19 @@
/** /**
* @file Tests for attaching an AI Router profile to a lane — the Claude Code * @file Tests for starting one session on an AI Router profile — Claude Code's
* `settings.local.json` env merge and the Codex `<name>.config.toml` layer, * `--settings=<file>` env file and the Codex `<name>.config.toml` layer, an
* including the guarantees that neither write destroys anything already there. * edit or delete reaching those files, and the one-time cleanup of the old
* per-workspace router out of a workspace's `settings.local.json`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
const os = require("node:os"); const os = require("node:os");
const path = require("node:path"); const path = require("node:path");
const fs = require("node:fs"); const fs = require("node:fs");
const { execFileSync } = require("node:child_process");
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-router-apply-")); const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-router-apply-"));
process.env.CLAUDE_HOME = path.join(SUITE_ROOT, ".claude"); process.env.CLAUDE_HOME = path.join(SUITE_ROOT, ".claude");
process.env.CODEX_HOME = path.join(SUITE_ROOT, ".codex"); process.env.CODEX_HOME = path.join(SUITE_ROOT, ".codex");
process.env.DASHBOARD_DATA_DIR = path.join(SUITE_ROOT, "data");
process.env.DASHBOARD_DB_PATH = path.join(SUITE_ROOT, "dashboard.db"); process.env.DASHBOARD_DB_PATH = path.join(SUITE_ROOT, "dashboard.db");
const { describe, it, beforeEach, after } = require("node:test"); const { describe, it, beforeEach, after } = require("node:test");
@@ -20,30 +21,30 @@ const assert = require("node:assert/strict");
const routers = require("../lib/ai-routers"); const routers = require("../lib/ai-routers");
const lanesLib = require("../lib/lanes"); const lanesLib = require("../lib/lanes");
const { db } = require("../db");
const { const {
applyRouterToLane, resolveRouter,
reapplyToLanes, resumeRouter,
detachFromLanes, routerForSpawn,
routerEnvForSpawn, refreshProfileFiles,
routerArgvForSpawn, removeProfileFiles,
clearLaneRouters,
} = require("../lib/router-apply"); } = require("../lib/router-apply");
const LANE_CWD = path.join(SUITE_ROOT, "project"); const settingsFile = (name) =>
const SETTINGS = path.join(LANE_CWD, ".claude", "settings.local.json"); path.join(process.env.DASHBOARD_DATA_DIR, "router-settings", `${name}.json`);
const layerFile = (name) => path.join(process.env.CODEX_HOME, `${name}.config.toml`);
after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true })); after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true }));
beforeEach(() => { beforeEach(() => {
fs.rmSync(path.join(process.env.CLAUDE_HOME, "agenthub-routers"), { for (const dir of [
recursive: true, path.join(process.env.CLAUDE_HOME, "agenthub-routers"),
force: true, process.env.CODEX_HOME,
}); process.env.DASHBOARD_DATA_DIR,
fs.rmSync(LANE_CWD, { recursive: true, force: true }); ]) {
fs.rmSync(process.env.CODEX_HOME, { recursive: true, force: true }); fs.rmSync(dir, { recursive: true, force: true });
fs.mkdirSync(LANE_CWD, { recursive: true }); }
// The DB outlives each test; a profile name is reused, so lanes left behind
// would be picked up by the next test's `reapplyToLanes` and inflate its count.
for (const lane of lanesLib.listLanes()) lanesLib.deleteLane(lane.id);
}); });
function saveNineRouter() { function saveNineRouter() {
@@ -55,388 +56,175 @@ function saveNineRouter() {
}); });
} }
const claudeLane = () => ({ id: 7, cwd: LANE_CWD, tool: "claude" }); const spawnFor = (tool, name) => routerForSpawn(tool, resolveRouter(tool, name));
const codexLane = () => ({ id: 8, cwd: LANE_CWD, tool: "codex" });
describe("router-apply — Claude lane", () => { describe("resolveRouter", () => {
it("writes the eight env keys the reference settings file swaps", () => { it("reads null, empty and native as the vendor's own API", () => {
const { name } = saveNineRouter(); for (const name of [null, undefined, "", "native"])
applyRouterToLane(claudeLane(), name); assert.equal(resolveRouter("claude", name), null);
const env = JSON.parse(fs.readFileSync(SETTINGS, "utf8")).env;
assert.equal(env.ANTHROPIC_BASE_URL, "http://localhost:20128/v1");
assert.equal(env.ANTHROPIC_AUTH_TOKEN, "sk-secret-value");
assert.equal(env.ANTHROPIC_MODEL, "Highest");
assert.equal(env.ANTHROPIC_DEFAULT_OPUS_MODEL, "Highest");
assert.equal(env.ANTHROPIC_DEFAULT_SONNET_MODEL, "Highest");
assert.equal(env.ANTHROPIC_DEFAULT_HAIKU_MODEL, "Highest");
assert.equal(env.ANTHROPIC_SMALL_FAST_MODEL, "Highest");
assert.equal(env.CLAUDE_CODE_SUBAGENT_MODEL, "Highest");
}); });
it("keeps every unrelated key in the file it merges into", () => { it("refuses a profile the store does not have", () => {
// A lane's settings.local.json is the user's file; this feature owns eight assert.throws(
// keys in it and nothing else. () => resolveRouter("claude", "nope"),
fs.mkdirSync(path.dirname(SETTINGS), { recursive: true }); (e) => e.code === "EBADROUTER"
fs.writeFileSync(
SETTINGS,
JSON.stringify({
permissions: { defaultMode: "bypassPermissions" },
env: { MY_OWN_VAR: "keep me", ANTHROPIC_MODEL: "to be replaced" },
})
);
const { name } = saveNineRouter();
applyRouterToLane(claudeLane(), name);
const saved = JSON.parse(fs.readFileSync(SETTINGS, "utf8"));
assert.equal(saved.permissions.defaultMode, "bypassPermissions");
assert.equal(saved.env.MY_OWN_VAR, "keep me");
assert.equal(saved.env.ANTHROPIC_MODEL, "Highest");
});
it("writes the file 0600, because it now holds an API key", () => {
const { name } = saveNineRouter();
applyRouterToLane(claudeLane(), name);
assert.equal(fs.statSync(SETTINGS).mode & 0o777, 0o600);
});
it("blanks the connection variables for the native profile instead of dropping them", () => {
// Absent keys would let a user-level router base URL apply to a lane that
// explicitly asked for native.
const { name } = saveNineRouter();
applyRouterToLane(claudeLane(), name);
applyRouterToLane(claudeLane(), "native");
const env = JSON.parse(fs.readFileSync(SETTINGS, "utf8")).env;
assert.equal(env.ANTHROPIC_BASE_URL, "");
assert.equal(env.ANTHROPIC_AUTH_TOKEN, "");
});
it("removes the model names for the native profile rather than pinning them", () => {
// The opposite of the connection pair above, and for the opposite reason:
// a project-scope model name overrides the user's own global choice, so a
// lane on native must leave that choice alone instead of writing whatever
// `claude-*` names this codebase happened to ship with.
fs.mkdirSync(path.dirname(SETTINGS), { recursive: true });
fs.writeFileSync(SETTINGS, JSON.stringify({ env: { MY_OWN_VAR: "keep me" } }));
const { name } = saveNineRouter();
applyRouterToLane(claudeLane(), name);
applyRouterToLane(claudeLane(), "native");
const env = JSON.parse(fs.readFileSync(SETTINGS, "utf8")).env;
assert.equal(env.ANTHROPIC_MODEL, undefined);
assert.equal(env.ANTHROPIC_DEFAULT_OPUS_MODEL, undefined);
assert.equal(env.CLAUDE_CODE_SUBAGENT_MODEL, undefined);
assert.equal(env.MY_OWN_VAR, "keep me"); // clearing ours touches nothing else
});
it("refuses a profile that does not exist rather than writing a broken file", () => {
assert.throws(() => applyRouterToLane(claudeLane(), "ghost"), { code: "ENOTFOUND" });
assert.equal(fs.existsSync(SETTINGS), false);
});
it("leaves a settings file that is not valid JSON alone", () => {
fs.mkdirSync(path.dirname(SETTINGS), { recursive: true });
fs.writeFileSync(SETTINGS, "{not json");
const { name } = saveNineRouter();
assert.throws(() => applyRouterToLane(claudeLane(), name), { code: "EBADJSON" });
assert.equal(fs.readFileSync(SETTINGS, "utf8"), "{not json");
});
});
describe("router-apply — the written file must survive a lane reset", () => {
// Real repos, asserted through `git status`: a fake `.git` dir let a test pass
// while git itself never read the exclude file it checked.
const REPO = path.join(SUITE_ROOT, "repo");
// A pre-commit hook running this suite exports GIT_INDEX_FILE & co., which
// would point these fixture calls at the outer repo.
const git = (cwd, ...args) => {
const env = { ...process.env };
for (const name of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR"]) {
delete env[name];
}
return execFileSync("git", args, { cwd, env, encoding: "utf8" });
};
const untracked = (cwd) =>
git(cwd, "status", "--porcelain", "--untracked-files=all", "--ignored=no")
.split("\n")
.filter(Boolean);
// A machine-wide ignore (Claude Code adds one for settings.local.json) would
// hide the very file these tests look for.
beforeEach(() => {
process.env.GIT_CONFIG_GLOBAL = "/dev/null";
process.env.GIT_CONFIG_NOSYSTEM = "1";
fs.rmSync(REPO, { recursive: true, force: true });
fs.mkdirSync(REPO, { recursive: true });
git(REPO, "init", "-q");
git(
REPO,
"-c",
"user.name=t",
"-c",
"user.email=t@t",
"commit",
"-q",
"--allow-empty",
"-m",
"init"
); );
}); });
const laneAt = (cwd) => ({ id: 7, cwd, tool: "claude" }); it("refuses a router on a tool with no endpoint to point at one", () => {
it("hides settings.local.json and its backups, so `git clean -fd` cannot delete them", () => {
// `agenthub lanes reset` runs `git clean -fd` (server/lib/worktree.js).
// Without -x that spares IGNORED files only — an untracked settings file
// would be deleted, silently detaching the lane from its router. The same
// entry also stops an API key from being committable.
const { name } = saveNineRouter();
applyRouterToLane(laneAt(REPO), name);
applyRouterToLane(laneAt(REPO), "native"); // a second write leaves a backup
assert.deepEqual(untracked(REPO), []);
});
it("hides them in a worktree lane, whose exclude file is the COMMON dir's", () => {
// git reads info/exclude from the common git dir only; a copy under
// .git/worktrees/<name>/info/ is never consulted.
const wt = path.join(SUITE_ROOT, "wt");
fs.rmSync(wt, { recursive: true, force: true });
git(REPO, "worktree", "add", "-q", "-b", "lane1", wt);
const { name } = saveNineRouter();
applyRouterToLane(laneAt(wt), name);
applyRouterToLane(laneAt(wt), "native");
assert.deepEqual(untracked(wt), []);
});
it("hides them in a lane that is a subdirectory of its repo", () => {
const sub = path.join(REPO, "packages", "app");
fs.mkdirSync(sub, { recursive: true });
const { name } = saveNineRouter();
applyRouterToLane(laneAt(sub), name);
applyRouterToLane(laneAt(sub), "native");
assert.deepEqual(untracked(REPO), []);
});
it("adds the entry once, however many times a router is applied", () => {
const { name } = saveNineRouter();
applyRouterToLane(laneAt(REPO), name);
applyRouterToLane(laneAt(REPO), "native");
const exclude = fs.readFileSync(path.join(REPO, ".git", "info", "exclude"), "utf8");
assert.equal(exclude.split("\n").filter((l) => l.includes("settings.local.json")).length, 1);
});
it("still writes the settings file when the lane is not a git repo at all", () => {
const { name } = saveNineRouter();
applyRouterToLane(claudeLane(), name);
assert.ok(fs.existsSync(SETTINGS));
});
});
describe("router-apply — Antigravity lane", () => {
const agyLane = () => ({ id: 9, cwd: LANE_CWD, tool: "antigravity" });
it("accepts native and writes nothing", () => {
assert.equal(applyRouterToLane(agyLane(), "native").file, null);
assert.equal(fs.existsSync(SETTINGS), false);
});
it("refuses a real router instead of writing Claude's settings file", () => {
const { name } = saveNineRouter(); const { name } = saveNineRouter();
assert.throws( assert.throws(
() => applyRouterToLane(agyLane(), name), () => resolveRouter("antigravity", name),
(err) => err.code === "EUNSUPPORTED" (e) => e.code === "EUNSUPPORTED"
); );
assert.equal(fs.existsSync(SETTINGS), false); assert.equal(resolveRouter("antigravity", "native"), null);
}); });
}); });
describe("router-apply — Codex lane", () => { describe("routerForSpawn — Claude", () => {
it("writes a profile layer file, never the base config", () => { it("passes a 0600 settings file holding the eight env keys, never the key in argv", () => {
const { name } = saveNineRouter();
const { argv, env } = spawnFor("claude", name);
assert.deepEqual(argv, [`--settings=${settingsFile(name)}`]);
assert.deepEqual(env, {});
assert.ok(!argv.join(" ").includes("sk-secret-value"));
const written = JSON.parse(fs.readFileSync(settingsFile(name), "utf8")).env;
assert.equal(written.ANTHROPIC_BASE_URL, "http://localhost:20128/v1");
assert.equal(written.ANTHROPIC_AUTH_TOKEN, "sk-secret-value");
for (const k of [
"ANTHROPIC_MODEL",
"ANTHROPIC_DEFAULT_OPUS_MODEL",
"ANTHROPIC_DEFAULT_SONNET_MODEL",
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
"ANTHROPIC_SMALL_FAST_MODEL",
"CLAUDE_CODE_SUBAGENT_MODEL",
]) {
assert.equal(written[k], "Highest", k);
}
assert.equal(fs.statSync(settingsFile(name)).mode & 0o777, 0o600);
});
it("leaves a blank key out instead of blanking the user's own setting", () => {
const { name } = routers.createRouter({
label: "Local",
baseUrl: "http://127.0.0.1:20128/v1",
apiKey: "",
model: "Highest",
});
spawnFor("claude", name);
const written = JSON.parse(fs.readFileSync(settingsFile(name), "utf8")).env;
assert.equal("ANTHROPIC_AUTH_TOKEN" in written, false);
});
it("adds nothing for native", () => {
assert.deepEqual(spawnFor("claude", "native"), { argv: [], env: {} });
});
});
describe("routerForSpawn — Codex", () => {
it("writes a profile layer, never the base config, and hands the key over in env", () => {
const base = path.join(process.env.CODEX_HOME, "config.toml"); const base = path.join(process.env.CODEX_HOME, "config.toml");
fs.mkdirSync(process.env.CODEX_HOME, { recursive: true }); fs.mkdirSync(process.env.CODEX_HOME, { recursive: true });
fs.writeFileSync(base, 'approval_policy = "never"\n'); fs.writeFileSync(base, 'approval_policy = "never"\n');
const { name } = saveNineRouter(); const { name } = saveNineRouter();
applyRouterToLane(codexLane(), name); const { argv, env } = spawnFor("codex", name);
assert.deepEqual(argv, ["-p", name]);
assert.deepEqual(env, { AGENTHUB_ROUTER_KEY: "sk-secret-value" });
assert.equal(fs.readFileSync(base, "utf8"), 'approval_policy = "never"\n'); assert.equal(fs.readFileSync(base, "utf8"), 'approval_policy = "never"\n');
const layer = fs.readFileSync(path.join(process.env.CODEX_HOME, `${name}.config.toml`), "utf8"); const layer = fs.readFileSync(layerFile(name), "utf8");
assert.match(layer, /model_provider = "9router"/); assert.match(layer, /model_provider = "9router"/);
assert.match(layer, /base_url = "http:\/\/localhost:20128\/v1"/); assert.match(layer, /base_url = "http:\/\/localhost:20128\/v1"/);
assert.match(layer, /env_key = "AGENTHUB_ROUTER_KEY"/); assert.match(layer, /env_key = "AGENTHUB_ROUTER_KEY"/);
}); // Not "chat": codex-cli 0.149 refuses to load it at all.
assert.match(layer, /wire_api = "responses"/);
it("keeps the key out of the config file, naming an env var instead", () => {
// Codex reads the key from the environment; writing it into config.toml
// would put a secret in a file with no reason to hold one.
const { name } = saveNineRouter();
applyRouterToLane(codexLane(), name);
const layer = fs.readFileSync(path.join(process.env.CODEX_HOME, `${name}.config.toml`), "utf8");
assert.ok(!layer.includes("sk-secret-value")); assert.ok(!layer.includes("sk-secret-value"));
}); });
it("writes no layer file for the native profile", () => { it("names no env_key when the profile has no key", () => {
// Native means "the base config as it already is" — a layer that overrode
// nothing would still shadow a model the user set in the base.
applyRouterToLane(codexLane(), "native");
assert.equal(fs.existsSync(path.join(process.env.CODEX_HOME, "native.config.toml")), false);
});
it("does not touch the lane's .claude directory", () => {
const { name } = saveNineRouter();
applyRouterToLane(codexLane(), name);
assert.equal(fs.existsSync(SETTINGS), false);
});
it('never writes wire_api = "chat", which codex refuses to load at all', () => {
// Verified against the installed codex-cli 0.149.0: `wire_api = "chat" is
// no longer supported` is a hard config error, so a layer carrying it makes
// the lane fail to start rather than fall back to anything.
const { name } = saveNineRouter();
applyRouterToLane(codexLane(), name);
const layer = fs.readFileSync(path.join(process.env.CODEX_HOME, `${name}.config.toml`), "utf8");
assert.match(layer, /wire_api = "responses"/);
assert.ok(!layer.includes('wire_api = "chat"'));
});
it("names no env_key when the profile has no key to put in it", () => {
// A local router needs no key — the reference 9Router provider has none.
// Naming a variable that is never set makes codex refuse to start.
const { name } = routers.createRouter({ const { name } = routers.createRouter({
label: "Local gateway", label: "Local gateway",
baseUrl: "http://127.0.0.1:20128/v1", baseUrl: "http://127.0.0.1:20128/v1",
apiKey: "", apiKey: "",
model: "Highest", model: "Highest",
}); });
applyRouterToLane(codexLane(), name); const { env } = spawnFor("codex", name);
const layer = fs.readFileSync(path.join(process.env.CODEX_HOME, `${name}.config.toml`), "utf8"); assert.deepEqual(env, {});
assert.ok(!layer.includes("env_key"), layer); assert.ok(!fs.readFileSync(layerFile(name), "utf8").includes("env_key"));
}); });
}); });
describe("router-apply — an edit or a delete reaches the lanes already using it", () => { describe("an edit or a delete reaches the session files", () => {
// Attaching COPIES the profile into the lane's own config, so a store-only it("an edit rewrites the files that exist and creates none", () => {
// write is inert exactly where it matters: the key a lane authenticates with.
/** A real lane row in a directory of its own, already attached to `profile`. */
function laneOn(profile, dirName) {
const cwd = path.join(SUITE_ROOT, dirName);
fs.mkdirSync(cwd, { recursive: true });
const lane = lanesLib.createLane({ title: dirName, cwd, router_profile: profile });
applyRouterToLane(lane, profile);
return { lane, settings: path.join(cwd, ".claude", "settings.local.json") };
}
const envOf = (file) => JSON.parse(fs.readFileSync(file, "utf8")).env;
it("pushes a rotated key out to every claude lane on that profile", () => {
const { name } = saveNineRouter(); const { name } = saveNineRouter();
const a = laneOn(name, "rotate-a"); spawnFor("claude", name);
const b = laneOn(name, "rotate-b");
assert.equal(envOf(a.settings).ANTHROPIC_AUTH_TOKEN, "sk-secret-value");
routers.updateRouter(name, { apiKey: "sk-rotated" }); routers.updateRouter(name, { apiKey: "sk-rotated" });
const result = reapplyToLanes(name); refreshProfileFiles(name);
const env = JSON.parse(fs.readFileSync(settingsFile(name), "utf8")).env;
assert.equal(result.updated, 2); assert.equal(env.ANTHROPIC_AUTH_TOKEN, "sk-rotated");
assert.deepEqual(result.failed, []); assert.equal(fs.existsSync(layerFile(name)), false);
assert.equal(envOf(a.settings).ANTHROPIC_AUTH_TOKEN, "sk-rotated");
assert.equal(envOf(b.settings).ANTHROPIC_AUTH_TOKEN, "sk-rotated");
}); });
it("leaves lanes on a different profile alone", () => { it("a delete removes both files", () => {
const { name } = saveNineRouter(); const { name } = saveNineRouter();
const other = routers.createRouter({ spawnFor("claude", name);
label: "OpenRouter", spawnFor("codex", name);
baseUrl: "https://openrouter.ai/api/v1", removeProfileFiles(name);
apiKey: "sk-other", assert.equal(fs.existsSync(settingsFile(name)), false);
model: "auto", assert.equal(fs.existsSync(layerFile(name)), false);
});
const mine = laneOn(name, "mixed-mine");
const theirs = laneOn(other.name, "mixed-theirs");
routers.updateRouter(name, { apiKey: "sk-rotated" });
reapplyToLanes(name);
assert.equal(envOf(mine.settings).ANTHROPIC_AUTH_TOKEN, "sk-rotated");
assert.equal(envOf(theirs.settings).ANTHROPIC_AUTH_TOKEN, "sk-other");
});
it("clears the deleted router out of the lane's settings and its DB row", () => {
// Without this the lane keeps routing to an endpoint the user believes they
// removed, with a key they believe they revoked.
const { name } = saveNineRouter();
const { lane, settings } = laneOn(name, "deleted-router");
routers.removeRouter(name);
const result = detachFromLanes(name);
assert.equal(result.updated, 1);
assert.equal(envOf(settings).ANTHROPIC_BASE_URL, "");
assert.equal(envOf(settings).ANTHROPIC_AUTH_TOKEN, "");
assert.equal(envOf(settings).ANTHROPIC_MODEL, undefined);
assert.equal(lanesLib.getLane(lane.id).router_profile, null);
});
it("deletes the codex layer file, so a later same-named profile cannot inherit it", () => {
const { name } = saveNineRouter();
const layer = path.join(process.env.CODEX_HOME, `${name}.config.toml`);
applyRouterToLane(codexLane(), name);
assert.ok(fs.existsSync(layer));
routers.removeRouter(name);
detachFromLanes(name);
assert.equal(fs.existsSync(layer), false);
});
it("reports a lane it could not update instead of failing the whole edit", () => {
const { name } = saveNineRouter();
const ok = laneOn(name, "partial-ok");
const broken = laneOn(name, "partial-broken");
fs.writeFileSync(broken.settings, "{ not json");
const result = reapplyToLanes(name);
assert.equal(result.updated, 1);
assert.equal(result.failed.length, 1);
assert.match(result.failed[0], /partial-broken/);
assert.equal(envOf(ok.settings).ANTHROPIC_BASE_URL, "http://localhost:20128/v1");
});
it("has nothing to do for native, which is stored as no router at all", () => {
laneOn("native", "native-lane");
assert.deepEqual(reapplyToLanes("native"), { updated: 0, failed: [] });
}); });
}); });
describe("router-apply — what a spawn adds", () => { describe("resumeRouter", () => {
it("gives a codex lane the profile flag and the key in its environment", () => { it("goes back to the router the session last ran on, while it exists", () => {
const { name } = saveNineRouter(); const { name } = saveNineRouter();
assert.deepEqual(routerArgvForSpawn({ ...codexLane(), router_profile: name }), ["-p", name]); const runs = require("../lib/dashboard-runs");
assert.deepEqual(routerEnvForSpawn({ ...codexLane(), router_profile: name }), { runs.recordRun({
AGENTHUB_ROUTER_KEY: "sk-secret-value", id: "agenthub-lane-91",
sessionId: "sess-router-1",
mode: "interactive",
router: name,
}); });
}); assert.equal(resumeRouter("sess-router-1"), name);
it("adds nothing for a claude lane, whose settings file already carries it", () => {
const { name } = saveNineRouter();
assert.deepEqual(routerArgvForSpawn({ ...claudeLane(), router_profile: name }), []);
assert.deepEqual(routerEnvForSpawn({ ...claudeLane(), router_profile: name }), {});
});
it("adds nothing for a lane with no router, or one set to native", () => {
assert.deepEqual(routerArgvForSpawn({ ...codexLane(), router_profile: null }), []);
assert.deepEqual(routerArgvForSpawn({ ...codexLane(), router_profile: "native" }), []);
assert.deepEqual(routerEnvForSpawn({ ...codexLane(), router_profile: "native" }), {});
});
it("adds nothing when the profile a lane names has since been deleted", () => {
// Deleting a profile must degrade the lane to its base config, never crash
// the spawn that was about to start.
const { name } = saveNineRouter();
routers.removeRouter(name); routers.removeRouter(name);
assert.deepEqual(routerArgvForSpawn({ ...codexLane(), router_profile: name }), []); assert.equal(resumeRouter("sess-router-1"), null);
assert.deepEqual(routerEnvForSpawn({ ...codexLane(), router_profile: name }), {}); assert.equal(resumeRouter("sess-unknown"), null);
});
});
describe("clearLaneRouters", () => {
it("takes the old router's keys out of a workspace's settings and clears the column", () => {
const cwd = path.join(SUITE_ROOT, "old-lane");
const file = path.join(cwd, ".claude", "settings.local.json");
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(
file,
JSON.stringify({
permissions: { allow: ["Bash(ls)"] },
env: { ANTHROPIC_BASE_URL: "http://x", ANTHROPIC_AUTH_TOKEN: "sk", MY_VAR: "1" },
})
);
const lane = lanesLib.createLane({ title: "old", cwd, router_profile: "9router" });
clearLaneRouters();
const data = JSON.parse(fs.readFileSync(file, "utf8"));
assert.deepEqual(data.env, { MY_VAR: "1" });
assert.deepEqual(data.permissions, { allow: ["Bash(ls)"] });
assert.equal(
db.prepare("SELECT router_profile FROM lanes WHERE id = ?").get(lane.id).router_profile,
null
);
});
it("keeps the column of a workspace whose file it could not clean, to retry later", () => {
const cwd = path.join(SUITE_ROOT, "broken-lane");
const file = path.join(cwd, ".claude", "settings.local.json");
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, "{ not json");
const lane = lanesLib.createLane({ title: "broken", cwd, router_profile: "9router" });
clearLaneRouters();
assert.equal(fs.readFileSync(file, "utf8"), "{ not json");
assert.equal(
db.prepare("SELECT router_profile FROM lanes WHERE id = ?").get(lane.id).router_profile,
"9router"
);
}); });
}); });
+2 -32
View File
@@ -1,11 +1,7 @@
/** /**
* @file Tests GET/PUT /api/settings/codex-home and setCodexHome: the shared * @file Tests GET/PUT /api/settings/codex-home and setCodexHome: the shared
* home-dir guard, persistence to the settings file, the `env_owned` flag, and * home-dir guard, persistence to the settings file, the `env_owned` flag, and
* the AI Router layer being re-written into the NEW home for every codex lane * a home change from another site refused. Everything lives in a temp dir.
* on a router (a `codex -p <name>` against a home without `<name>.config.toml`
* would fail at start); a home change from another site refused, and a
* CLAUDE_HOME change naming the lanes whose router profile the new home's
* store lacks. Everything lives in a temp dir.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -25,9 +21,6 @@ const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict"); const assert = require("node:assert/strict");
const express = require("express"); const express = require("express");
const routers = require("../lib/ai-routers");
const lanesLib = require("../lib/lanes");
const { applyRouterToLane } = require("../lib/router-apply");
const { envOwned } = require("../lib/settings-env"); const { envOwned } = require("../lib/settings-env");
const settingsRouter = require("../routes/settings"); const settingsRouter = require("../routes/settings");
@@ -97,27 +90,13 @@ describe("/api/settings/codex-home", () => {
} }
}); });
it("switches, persists, and re-writes every codex lane's router layer into the new home", async () => { it("switches and persists", async () => {
const cwd = path.join(ROOT, "lane");
fs.mkdirSync(cwd, { recursive: true });
const { name } = routers.createRouter({
label: "9Router",
baseUrl: "http://localhost:20128/v1",
apiKey: "sk-secret-value",
model: "Highest",
});
const lane = lanesLib.createLane({ title: "c", cwd, tool: "codex", router_profile: name });
applyRouterToLane(lane, name);
assert.ok(fs.existsSync(path.join(process.env.CODEX_HOME, `${name}.config.toml`)));
const next = path.join(ROOT, "codex-b"); const next = path.join(ROOT, "codex-b");
fs.mkdirSync(next); fs.mkdirSync(next);
const res = await request("PUT", "/api/settings/codex-home", { path: next }); const res = await request("PUT", "/api/settings/codex-home", { path: next });
assert.equal(res.status, 200); assert.equal(res.status, 200);
assert.equal(res.body.codex_home, next); assert.equal(res.body.codex_home, next);
assert.deepEqual(res.body.routers, { updated: 1, failed: [] });
assert.equal(process.env.CODEX_HOME, next); assert.equal(process.env.CODEX_HOME, next);
assert.ok(fs.existsSync(path.join(next, `${name}.config.toml`)));
assert.match( assert.match(
fs.readFileSync(process.env.AGENTHUB_SETTINGS_FILE, "utf8"), fs.readFileSync(process.env.AGENTHUB_SETTINGS_FILE, "utf8"),
new RegExp(`^CODEX_HOME=${next}$`, "m") new RegExp(`^CODEX_HOME=${next}$`, "m")
@@ -136,13 +115,4 @@ describe("/api/settings/codex-home", () => {
assert.equal(res.status, 403); assert.equal(res.status, 403);
assert.notEqual(process.env.CODEX_HOME, next); assert.notEqual(process.env.CODEX_HOME, next);
}); });
it("a CLAUDE_HOME change names the lanes whose router the new store lacks", async () => {
const next = path.join(ROOT, "claude-b");
fs.mkdirSync(next);
const res = await request("PUT", "/api/settings/claude-home", { path: next });
assert.equal(res.status, 200);
assert.equal(res.body.routers.missing.length, 1);
assert.match(res.body.routers.missing[0], /\(c\): 9router/i);
});
}); });
+9
View File
@@ -584,6 +584,15 @@ try {
db.prepare("ALTER TABLE dashboard_runs ADD COLUMN worktree INTEGER NOT NULL DEFAULT 0").run(); db.prepare("ALTER TABLE dashboard_runs ADD COLUMN worktree INTEGER NOT NULL DEFAULT 0").run();
} }
// Migrate: add `router` to dashboard_runs — the AI Router profile the run was
// started on (NULL = native). A router is chosen per session now, and a resume
// that does not name one goes back to the router its session last ran on.
try {
db.prepare("SELECT router FROM dashboard_runs LIMIT 1").get();
} catch {
db.prepare("ALTER TABLE dashboard_runs ADD COLUMN router TEXT").run();
}
// Migrate: add stage-detection columns to lanes. Inference is never evidence — // Migrate: add stage-detection columns to lanes. Inference is never evidence —
// these are additive columns separate from `stage` (the declared stage), so // these are additive columns separate from `stage` (the declared stage), so
// `stage`'s meaning is untouched and turning the feature off loses nothing. // `stage`'s meaning is untouched and turning the feature off loses nothing.
+7
View File
@@ -605,6 +605,13 @@ function startBackgroundServices() {
} catch (err) { } catch (err) {
console.warn("dashboard-runs reconciliation failed:", err.message); console.warn("dashboard-runs reconciliation failed:", err.message);
} }
// A router is chosen per session now: take the old per-workspace one out of
// each workspace's settings.local.json (a no-op once done).
try {
require("./lib/router-apply").clearLaneRouters();
} catch (err) {
console.warn("clearing workspace routers failed:", err.message);
}
} }
/** /**
+23 -4
View File
@@ -32,11 +32,11 @@ const insertStmt = db.prepare(`
INSERT OR REPLACE INTO dashboard_runs ( INSERT OR REPLACE INTO dashboard_runs (
id, session_id, mode, cwd, model, permission_mode, effort, id, session_id, mode, cwd, model, permission_mode, effort,
resume_session_id, prompt_preview, status, exit_code, started_at, ended_at, resume_session_id, prompt_preview, status, exit_code, started_at, ended_at,
lane_id, tool, worktree lane_id, tool, worktree, router
) VALUES ( ) VALUES (
@id, @session_id, @mode, @cwd, @model, @permission_mode, @effort, @id, @session_id, @mode, @cwd, @model, @permission_mode, @effort,
@resume_session_id, @prompt_preview, @status, @exit_code, @started_at, @ended_at, @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, const RUN_COLUMNS = `id, session_id, mode, cwd, model, permission_mode, effort,
resume_session_id, prompt_preview, status, exit_code, 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(` const listStmt = db.prepare(`
SELECT ${RUN_COLUMNS} SELECT ${RUN_COLUMNS}
@@ -115,6 +115,7 @@ function recordRun(handle) {
lane_id: typeof handle.laneId === "number" ? handle.laneId : null, lane_id: typeof handle.laneId === "number" ? handle.laneId : null,
tool: typeof handle.tool === "string" && handle.tool ? handle.tool : "claude", tool: typeof handle.tool === "string" && handle.tool ? handle.tool : "claude",
worktree: handle.worktree ? 1 : 0, worktree: handle.worktree ? 1 : 0,
router: handle.router || null,
}); });
} catch (err) { } catch (err) {
// Best-effort by design (a live run must never fail because sqlite did), // 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( const runningStmt = db.prepare(
"SELECT id FROM dashboard_runs WHERE status IN ('running', 'spawning')" "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); 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: * Write provisioning-time facts that `PATCH /api/lanes/:id` must never reach:
* `base_branch`, resolved after `git worktree add` succeeds, and the runtime * `base_branch`, resolved after `git worktree add` succeeds, and the runtime
@@ -928,8 +891,6 @@ module.exports = {
deleteLane, deleteLane,
setStage, setStage,
clearCodexPipelines, clearCodexPipelines,
setRouterProfile,
lanesByRouter,
recordDetection, recordDetection,
clearLane, clearLane,
recoverInterruptedProvisioning, recoverInterruptedProvisioning,
+18 -11
View File
@@ -30,7 +30,7 @@
*/ */
const tmux = require("./tmux"); const tmux = require("./tmux");
const { routerArgvForSpawn, routerEnvForSpawn } = require("./router-apply"); const { resolveRouter, routerForSpawn } = require("./router-apply");
const { desktopEnv } = require("./desktop-env"); const { desktopEnv } = require("./desktop-env");
const { getCodexHome } = require("./codex-home"); const { getCodexHome } = require("./codex-home");
const aiTools = require("./ai-tools"); const aiTools = require("./ai-tools");
@@ -111,6 +111,7 @@ function buildClaudeArgv({
initialPrompt, initialPrompt,
addDirs = [], addDirs = [],
systemPromptFile = null, systemPromptFile = null,
routerArgv = [],
}) { }) {
const argv = ["claude"]; const argv = ["claude"];
// `--add-dir` is VARIADIC (`<directories...>`): it swallows every following // `--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. // always ends it — after the prompt it would eat the prompt as a directory.
if (addDirs.length) argv.push("--add-dir", ...addDirs); if (addDirs.length) argv.push("--add-dir", ...addDirs);
argv.push("--permission-mode", permissionMode || "auto"); 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 // Claude Code makes the worktree itself (`.claude/worktrees/<name>`, branch
// `worktree-<name>`, `.worktreeinclude`, the user's WorktreeCreate hook). // `worktree-<name>`, `.worktreeinclude`, the user's WorktreeCreate hook).
// `=` form: `-w [name]` takes an OPTIONAL value and would otherwise swallow // `=` form: `-w [name]` takes an OPTIONAL value and would otherwise swallow
@@ -158,7 +161,7 @@ function buildCodexArgv({
resumeLast, resumeLast,
bypass, bypass,
initialPrompt, initialPrompt,
routerProfile, routerArgv = [],
addDirs = [], addDirs = [],
systemPrompt = null, systemPrompt = null,
systemPromptFile = null, systemPromptFile = null,
@@ -167,7 +170,7 @@ function buildCodexArgv({
// Before the subcommand and before the prompt: `-p` layers // Before the subcommand and before the prompt: `-p` layers
// `$CODEX_HOME/<name>.config.toml` over the base config, and an option that // `$CODEX_HOME/<name>.config.toml` over the base config, and an option that
// lands after the positional prompt is not an option any more. // 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); if (resumeSessionId) argv.push("resume", resumeSessionId);
else if (resumeLast) argv.push("resume", "--last"); else if (resumeLast) argv.push("resume", "--last");
if (model) argv.push("-m", model); 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 * @param {boolean} [args.bypass] the user ticked "bypass permissions" for THIS
* run; appends the catalog's bypass args/env. Claude has no catalog bypass. * run; appends the catalog's bypass args/env. Claude has no catalog bypass.
* @param {string} [args.initialPrompt] * @param {string} [args.initialPrompt]
* @param {string} [args.routerProfile] AI Router profile the LANE runs against * @param {string} [args.router] AI Router profile THIS session runs on — a
* (`lanes.router_profile`), never a request field. * 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) { function spawnRun(args) {
const { const {
@@ -263,7 +267,7 @@ function spawnRun(args) {
resumeLast, resumeLast,
bypass, bypass,
initialPrompt, initialPrompt,
routerProfile, router,
} = args || {}; } = args || {};
if (typeof laneId !== "number" || !Number.isInteger(laneId)) { if (typeof laneId !== "number" || !Number.isInteger(laneId)) {
throw makeErr("EBADLANE", "laneId must be an integer"); throw makeErr("EBADLANE", "laneId must be an integer");
@@ -315,6 +319,7 @@ function spawnRun(args) {
if (worktree === true && entry.launcher !== "claude") { if (worktree === true && entry.launcher !== "claude") {
throw makeErr("EUNSUPPORTED", `${tool} cannot start a session in its own worktree`); throw makeErr("EUNSUPPORTED", `${tool} cannot start a session in its own worktree`);
} }
const routerProfile = resolveRouter(tool, router); // ENOTFOUND, EUNSUPPORTED
if (forkSession === true) { if (forkSession === true) {
if (!resumeSessionId) throw makeErr("EBADSESSION", "a fork needs resumeSessionId"); if (!resumeSessionId) throw makeErr("EBADSESSION", "a fork needs resumeSessionId");
if (entry.launcher !== "claude") { if (entry.launcher !== "claude") {
@@ -351,6 +356,7 @@ function spawnRun(args) {
laneId, laneId,
tool tool
); );
const routerSpawn = routerForSpawn(tool, routerProfile);
const argv = buildArgv( const argv = buildArgv(
{ {
addDirs, addDirs,
@@ -368,7 +374,7 @@ function spawnRun(args) {
resumeLast, resumeLast,
bypass: bypass === true, bypass: bypass === true,
initialPrompt, initialPrompt,
routerProfile, routerArgv: routerSpawn.argv,
}, },
entry entry
); );
@@ -394,6 +400,7 @@ function spawnRun(args) {
effort: effort || null, effort: effort || null,
resumeSessionId: resumeSessionId || null, resumeSessionId: resumeSessionId || null,
worktree: worktree === true || inWorktree === true, worktree: worktree === true || inWorktree === true,
router: routerProfile ? routerProfile.name : null,
prompt: initialPrompt || "", prompt: initialPrompt || "",
status: "running", status: "running",
startedAt, startedAt,
@@ -407,9 +414,9 @@ function spawnRun(args) {
// from inside it then resolves the lane exactly, instead of matching on cwd — // 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 // 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 (see bin/agenthub.js's resolveLaneArg).
// A codex lane on an AI Router also needs the key its layer's `env_key` // A codex session 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 // names. A claude one needs nothing here — its `--settings` file carries the
// the router — and a lane on the vendor's own API needs nothing either. // 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 // 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 // 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 // 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 // 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. // tmux server's own env may predate, or never have had, CODEX_HOME.
...(tool === "codex" ? { CODEX_HOME: getCodexHome() } : {}), ...(tool === "codex" ? { CODEX_HOME: getCodexHome() } : {}),
...routerEnvForSpawn({ tool, router_profile: routerProfile || null }), ...routerSpawn.env,
...(bypass === true && entry.bypassEnv ? entry.bypassEnv : {}), ...(bypass === true && entry.bypassEnv ? entry.bypassEnv : {}),
}; };
+149 -248
View File
@@ -1,30 +1,31 @@
/** /**
* @file router-apply.js * @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 * The two CLIs are configured in different places, and the difference is
* theirs, not a choice made here: * theirs, not a choice made here:
* *
* - **Claude Code** reads an `env` block out of the settings layers, so a * - **Claude Code** gets `--settings=<file>`: a settings file holding the
* router is eight env keys merged into the lane's own * profile's eight `env` keys, `<data dir>/router-settings/<name>.json`
* `.claude/settings.local.json`. `settings.local.json` and not * (0600). The command-line layer outranks the project-local and user
* `settings.json` because the value written includes an API key, and the * settings (verified on 2.1.286), so two sessions of one workspace can run
* `.local` file is the one Claude Code's own convention keeps out of git. * 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 * - **Codex** has no project-level config at all. What it does have is
* single loaded file, `~/.codex/config.toml`. What it does have is * `-p <name>`, which layers `$CODEX_HOME/<name>.config.toml` on top of the
* `-p <name>`, which layers `$CODEX_HOME/<name>.config.toml` on top of that
* base. So a profile becomes a layer file, the base is never touched, and * 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 * the key reaches the pane through tmux's own `-e` under the variable the
* variable named by `env_key`, so the key stays out of the file entirely and * layer's `env_key` names, never as typed text.
* reaches the pane through tmux's own `-e`, never as typed text.
* *
* Target directories come from the lane row, never from a request body — the * Both files are (re)written at every spawn, so an edited profile reaches the
* same narrowing every other lane write path in this codebase uses. * 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 * Workspaces once carried a router of their own in `lanes.router_profile` and
* once a lane exists. `reapplyToLanes`/`detachFromLanes` are what keep an edit * `<cwd>/.claude/settings.local.json`; `clearLaneRouters` takes that out once.
* or a delete in Settings from being silently inert on the lanes using it.
* *
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @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 path = require("node:path");
const routers = require("./ai-routers"); const routers = require("./ai-routers");
const { patchSettings } = require("./cc-mutate"); const { getDataDir } = require("./claude-home");
const { excludeFromGit } = require("./git-exclude");
const { getCodexHome } = require("./codex-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 * 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"; 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) { function envForProfile(profile) {
return { return {
ANTHROPIC_BASE_URL: profile.baseUrl, ANTHROPIC_BASE_URL: profile.baseUrl,
@@ -59,91 +59,27 @@ function envForProfile(profile) {
}; };
} }
/** function claudeSettingsPath(name) {
* Read a profile, or resolve to null when it has since been deleted. Used on return path.join(getDataDir(), "router-settings", `${name}.json`);
* 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 codexLayerPath(name) {
* Idempotently keep `.claude/settings.local.json` out of git, for two reasons return path.join(getCodexHome(), `${name}.config.toml`);
* 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 writeSecret(file, body) {
* Merge the eight keys into the lane's settings, leaving every other key — fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
* including every other env var — exactly as it was. fs.writeFileSync(file, body, { mode: 0o600 });
* fs.chmodSync(file, 0o600); // an existing file keeps its mode on write
* `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;
}
const result = patchSettings({ scope: "project-local", cwd: lane.cwd, set, unset }); function writeClaudeSettings(profile) {
// Both branches, not just the router one. A native lane's blanked connection const file = claudeSettingsPath(profile.name);
// pair is load-bearing too — `git clean -fd` deleting it would let a // Empty values are left out: a blank key in the command-line layer would
// user-level router quietly take over a lane that asked for native. // blank the user's own setting, not leave it alone.
excludeSettingsFromGit(lane.cwd); const env = Object.fromEntries(Object.entries(envForProfile(profile)).filter(([, v]) => v));
for (const file of [result.path, result.backupPath]) { writeSecret(file, `${JSON.stringify({ env }, null, 2)}\n`);
if (!file) continue; return file;
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 };
} }
/** TOML string literal — router names and URLs are validated, quotes are not free. */ /** 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 * 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. * top of the base config. The base file is never read or written here.
*/ */
function applyToCodexLane(lane, profile) { function writeCodexLayer(profile) {
const file = path.join(getCodexHome(), `${profile.name}.config.toml`); const file = codexLayerPath(profile.name);
const body = const body =
`# Written by AgentHub — AI Router profile ${profile.label}.\n` + `# Written by AgentHub — AI Router profile ${profile.label}.\n` +
`# Loaded by \`codex -p ${profile.name}\`; the base config.toml is untouched.\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. // is never set makes Codex refuse to start.
(profile.apiKey ? `env_key = ${tomlString(CODEX_KEY_ENV)}\n` : "") + (profile.apiKey ? `env_key = ${tomlString(CODEX_KEY_ENV)}\n` : "") +
// Not "chat": codex-cli 0.149 rejects it outright — `wire_api = "chat" is // 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 // no longer supported`, a hard config error before the first request.
// user's own working 9Router provider uses "responses" too.
`wire_api = "responses"\n`; `wire_api = "responses"\n`;
writeSecret(file, body);
fs.mkdirSync(path.dirname(file), { recursive: true }); return file;
fs.writeFileSync(file, body, { mode: 0o600 });
return { tool: "codex", file, profile: profile.name };
} }
/** /**
* Attach `profileName` to `lane`. Called once, when the lane is created. * The profile a new session asked for, checked against its tool. `null`,
* * `""` and `"native"` mean the vendor's own API and resolve to null.
* @param {{id:number, cwd:string, tool:string}} lane the lane row * @throws EBADROUTER for a profile the store does not have, EUNSUPPORTED for a
* @param {string} profileName a saved profile's name, or `"native"` * router on a tool that has no endpoint to point at one.
*/ */
const { ROUTER_CAPABLE } = require("./ai-tools"); function resolveRouter(tool, name) {
if (name == null || name === "" || name === routers.BUILTIN_NAME) return null;
function applyRouterToLane(lane, profileName) { if (typeof name !== "string") {
const profile = routers.getRouter(profileName); // throws ENOTFOUND throw Object.assign(new Error("router must be a profile name"), { code: "EBADROUTER" });
// 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. let profile;
if (!ROUTER_CAPABLE.has(lane.tool || "claude")) { try {
if (profile.builtin) return { tool: lane.tool, file: null, profile: profile.name }; profile = routers.getRouter(name);
throw Object.assign(new Error(`a ${lane.tool} workspace cannot use an AI Router`), { } 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", code: "EUNSUPPORTED",
}); });
} }
if (lane.tool === "codex") { return profile;
// 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);
} }
/** /**
* Extra argv for a lane's spawn. Codex needs `-p <name>`; a Claude lane needs * The router a resume that names none goes back to: the one its session last
* nothing, its settings file already carries the router. * ran on, while that profile still exists — a deleted one resumes on native.
* @returns {string[]}
*/ */
function routerArgvForSpawn(lane) { function resumeRouter(sessionId) {
if (!lane || lane.tool !== "codex") return []; if (!sessionId) return null;
const profile = readOrNull(lane.router_profile); const name = require("./dashboard-runs").routerForSession(sessionId);
return profile ? ["-p", profile.name] : []; if (!name) return null;
}
/**
* 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}`);
}
}
try { try {
fs.rmSync(path.join(getCodexHome(), `${profileName}.config.toml`), { force: true }); routers.getRouter(name);
return name;
} catch { } 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 = { module.exports = {
applyRouterToLane, resolveRouter,
reapplyToLanes, resumeRouter,
reapplyCodexLayers, routerForSpawn,
detachFromLanes, refreshProfileFiles,
routerArgvForSpawn, removeProfileFiles,
routerEnvForSpawn, clearLaneRouters,
lanesWithMissingRouter,
CODEX_KEY_ENV, CODEX_KEY_ENV,
}; };
+13 -17
View File
@@ -1,14 +1,12 @@
/** /**
* @file ai-routers.js (routes) * @file ai-routers.js (routes)
* @description CRUD for AI Router profiles — the endpoints behind the * @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 * **AI Router** tab of Settings. CHOOSING a profile is not here: a new session
* happens once, when the lane is created (`POST /api/lanes/:id/router/apply`), * names one (`router` on `POST /api/lanes/:id/start` and `POST /api/run`).
* because the write target is the lane's own directory.
* *
* An edit or a delete here does reach the lanes that already chose it, though. * An edit or a delete here does reach the sessions using it: each reads the
* Attaching COPIES the profile into the lane's own config, so a store-only * profile through a file written at its spawn (`server/lib/router-apply.js`),
* write would be inert exactly where it matters most — a rotated key would * which an edit rewrites and a delete removes.
* never reach the lanes still authenticating with the revoked one.
* *
* No response on this router carries key material. `GET /` returns the store's * No response on this router carries key material. `GET /` returns the store's
* public view (`hasKey: true|false`), and there is deliberately no * public view (`hasKey: true|false`), and there is deliberately no
@@ -21,7 +19,7 @@
const express = require("express"); const express = require("express");
const routers = require("../lib/ai-routers"); 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 { sameOriginGuard } = require("../lib/same-origin");
const router = express.Router(); 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` // 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. // 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 // A session reads the profile through a file written at its spawn, so the
// wrote the store here would be inert: rotating a leaked key would leave every // edit rewrites those files too — a rotated key reaches live Claude sessions.
// claude lane on the revoked one. `reapplyToLanes` pushes the change out, and
// reports per-lane failures instead of failing the edit.
router.patch("/:name", sameOriginGuard, (req, res) => { router.patch("/:name", sameOriginGuard, (req, res) => {
try { try {
const saved = routers.updateRouter(req.params.name, req.body || {}); const saved = routers.updateRouter(req.params.name, req.body || {});
res.json({ ...saved, lanes: reapplyToLanes(saved.name) }); refreshProfileFiles(saved.name);
res.json(saved);
} catch (e) { } catch (e) {
return fail(res, e); return fail(res, e);
} }
}); });
// DELETE /api/ai-routers/:name — every lane using it is reset to native, in its // DELETE /api/ai-routers/:name — and the session files that copy its key.
// 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.
router.delete("/:name", sameOriginGuard, (req, res) => { router.delete("/:name", sameOriginGuard, (req, res) => {
try { try {
const removed = routers.removeRouter(req.params.name); const removed = routers.removeRouter(req.params.name);
res.json({ ...removed, lanes: detachFromLanes(req.params.name) }); removeProfileFiles(req.params.name);
res.json(removed);
} catch (e) { } catch (e) {
return fail(res, e); return fail(res, e);
} }
+7 -46
View File
@@ -27,7 +27,7 @@ const laneFeatures = require("../lib/lane-features");
const proofLib = require("../lib/proof"); const proofLib = require("../lib/proof");
const { broadcast } = require("../websocket"); const { broadcast } = require("../websocket");
const runs = require("../lib/pty-run"); const runs = require("../lib/pty-run");
const { applyRouterToLane } = require("../lib/router-apply"); const { resumeRouter } = require("../lib/router-apply");
const { sameOriginGuard } = require("./run"); const { sameOriginGuard } = require("./run");
const { writeLaneFile } = require("../lib/lane-file-write"); const { writeLaneFile } = require("../lib/lane-file-write");
const { preflight } = require("../lib/lane-preflight"); 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 * Detect a Node.js project at this lane's OWN directory and scaffold
* .agenthub/profile/ if one is found — the HTTP equivalent of * .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 // asked for the other CLI would otherwise get a pane that doesn't
// match the lane's badge, its history, or its binary probe. // match the lane's badge, its history, or its binary probe.
tool: current.tool || "claude", tool: current.tool || "claude",
// Same reasoning as `tool`: the router is the lane's fact, written // The SESSION's AI Router, by name; a resume that names none goes
// into its config when the lane was created. // back to the one its session last ran on.
routerProfile: current.router_profile || null, router:
body.router !== undefined
? body.router
: resumeRouter(body.resumeSessionId || (body.resume ? current.session_id : null)),
model: body.model, model: body.model,
permissionMode: body.permissionMode, permissionMode: body.permissionMode,
effort: body.effort, effort: body.effort,
+7 -2
View File
@@ -30,6 +30,7 @@ const tmux = require("../lib/tmux");
const lanesLib = require("../lib/lanes"); const lanesLib = require("../lib/lanes");
const { sameOriginGuard } = require("../lib/same-origin"); const { sameOriginGuard } = require("../lib/same-origin");
const aiTools = require("../lib/ai-tools"); const aiTools = require("../lib/ai-tools");
const { resumeRouter } = require("../lib/router-apply");
const router = Router(); const router = Router();
@@ -321,8 +322,12 @@ router.post("/", (req, res) => {
laneId, laneId,
cwd, cwd,
tool: lane?.tool || "claude", tool: lane?.tool || "claude",
// The lane's own router, exactly like its tool — never a body field. // The session's AI Router, by name (key and endpoint from the store); a
routerProfile: lane?.router_profile || null, // resume that names none goes back to the one its session last ran on.
router:
body.router !== undefined
? body.router
: resumeRouter(typeof body.resumeSessionId === "string" ? body.resumeSessionId : null),
model: typeof body.model === "string" && body.model ? body.model : null, model: typeof body.model === "string" && body.model ? body.model : null,
permissionMode: permissionMode:
typeof body.permissionMode === "string" && ALLOWED_PERMISSION_MODES.has(body.permissionMode) typeof body.permissionMode === "string" && ALLOWED_PERMISSION_MODES.has(body.permissionMode)
+7 -13
View File
@@ -25,7 +25,6 @@ const APP_VERSION = (() => {
const { getSettingsPath, getClaudeHome, setClaudeHome } = require("../lib/claude-home"); const { getSettingsPath, getClaudeHome, setClaudeHome } = require("../lib/claude-home");
const { getCodexHome, setCodexHome } = require("../lib/codex-home"); const { getCodexHome, setCodexHome } = require("../lib/codex-home");
const { envOwned } = require("../lib/settings-env"); const { envOwned } = require("../lib/settings-env");
const { reapplyCodexLayers, lanesWithMissingRouter } = require("../lib/router-apply");
const pricingSync = require("../lib/pricing-sync"); const pricingSync = require("../lib/pricing-sync");
function getDbSize() { function getDbSize() {
@@ -285,7 +284,7 @@ router.get("/export", (_req, res) => {
// overridden again at the next start), PUT validates, applies and persists // overridden again at the next start), PUT validates, applies and persists
// (server/lib/settings-env.js). EBADHOME = too broad, INVALID_PATH = any other // (server/lib/settings-env.js). EBADHOME = too broad, INVALID_PATH = any other
// bad path, 500 ESAVE = the settings file could not be written. // bad path, 500 ESAVE = the settings file could not be written.
function homeRoutes(route, key, field, get, set, after = () => ({})) { function homeRoutes(route, key, field, get, set) {
router.get(route, (_req, res) => { router.get(route, (_req, res) => {
res.json({ [field]: get(), env_owned: envOwned.has(key) }); res.json({ [field]: get(), env_owned: envOwned.has(key) });
}); });
@@ -308,20 +307,15 @@ function homeRoutes(route, key, field, get, set, after = () => ({})) {
error: { code: bad ? err.code : "ESAVE", message: err.message }, error: { code: bad ? err.code : "ESAVE", message: err.message },
}); });
} }
res.json({ ok: true, [field]: resolved, env_owned: envOwned.has(key), ...after() }); res.json({ ok: true, [field]: resolved, env_owned: envOwned.has(key) });
}); });
} }
// GET/PUT /api/settings/claude-home — a PUT names the lanes whose AI Router // GET/PUT /api/settings/claude-home
// profile the new home's store does not have (they would start on native). homeRoutes("/claude-home", "CLAUDE_HOME", "claude_home", getClaudeHome, setClaudeHome);
homeRoutes("/claude-home", "CLAUDE_HOME", "claude_home", getClaudeHome, setClaudeHome, () => ({ // GET/PUT /api/settings/codex-home — a session's AI Router layer is written
routers: { missing: lanesWithMissingRouter() }, // into the current home at its spawn, so a move needs no repair here.
})); homeRoutes("/codex-home", "CODEX_HOME", "codex_home", getCodexHome, setCodexHome);
// GET/PUT /api/settings/codex-home — a PUT re-writes every codex lane's AI
// Router layer into the new home, or `codex -p <name>` fails at the next start.
homeRoutes("/codex-home", "CODEX_HOME", "codex_home", getCodexHome, setCodexHome, () => ({
routers: reapplyCodexLayers(),
}));
// POST /api/settings/cleanup — abandon stale sessions, purge old data // POST /api/settings/cleanup — abandon stale sessions, purge old data
router.post("/cleanup", (req, res) => { router.post("/cleanup", (req, res) => {