feat(ai-tools): Settings → AI Tools, Default/Other lane picker, per-run bypass switch (#40 A1)

Settings → Dashboard gains an AI Tools section (installed tools with
Enabled/Disabled and Set default, available ones with a homepage link,
Refresh). useAiTools shares one catalog copy across the app and replaces
LANE_TOOL_LABEL. The Add-lane modal picks Default (<tool>) or Other…; the
router row reads Native (<tool>) and disables AI Router profiles for tools
that cannot use one. The run form gives catalog tools resume and a red
per-run Bypass permissions switch (Codex too). Claude Code / Codex settings
tabs hide while that tool is disabled. The Workspace probes each lane tool's
CLI fresh.

Docs: CLAUDE.md tool/bypass rule, AGENTS.md, LANES.md, API.md, CLI.md,
server/README.md, OpenAPI fragment + regenerated openapi.yaml.
This commit is contained in:
2026-09-28 11:19:30 +07:00
parent 77917203fd
commit fcfebe44f1
33 changed files with 1306 additions and 153 deletions
+1 -1
View File
@@ -13,7 +13,7 @@
## Lane stage
AgentHub's `codex` (and `antigravity`) lanes carry no pipeline: the dashboard shows no stage for
AgentHub's `codex` lanes (like every non-Claude AI tool lane) carry no pipeline: the dashboard shows no stage for
them and never infers or advances one, so there is no `agenthub stage` call to
make while you work here as Codex.
+3 -3
View File
File diff suppressed because one or more lines are too long
+67 -30
View File
@@ -25,7 +25,8 @@ import { CwdAutocomplete } from "../run/RunSetup";
import { FolderBrowseModal } from "./FolderBrowseModal";
import { api } from "../../lib/api";
import type { AiRouter, CwdSuggestion } from "../../lib/api";
import type { Lane, LaneTool, LaneWorktree } from "../../lib/types";
import type { Lane, LaneWorktree } from "../../lib/types";
import { useAiTools } from "../../hooks/useAiTools";
/** Polls the lane until the background `git worktree add` finishes (status
* leaves "provisioning"), or gives up after `timeoutMs`. Returns the final
@@ -109,7 +110,15 @@ export function AddLaneModal({
const [routers, setRouters] = useState<AiRouter[]>([]);
const [routersLoaded, setRoutersLoaded] = useState(false);
const [branchesError, setBranchesError] = useState<string | null>(null);
const [tool, setTool] = useState<LaneTool>("claude");
// null = the default AI tool (Settings → AI Tools), resolved at submit time.
const [pickedTool, setPickedTool] = useState<string | null>(null);
const aiTools = useAiTools();
const defaultTool = aiTools.defaultId ?? "claude";
const tool = pickedTool ?? defaultTool;
const otherTools = aiTools.tools.filter((x) => x.installed && x.enabled && x.id !== defaultTool);
const noTools = aiTools.loaded && aiTools.defaultId === null;
const toolRouterCapable =
aiTools.byId(tool)?.routerCapable ?? (tool === "claude" || tool === "codex");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [browseOpen, setBrowseOpen] = useState(false);
@@ -130,7 +139,7 @@ export function AddLaneModal({
setBase("");
setRouter("native");
setRoutersLoaded(false);
setTool("claude");
setPickedTool(null);
setBranchesError(null);
setError(null);
setBusy(false);
@@ -414,27 +423,49 @@ export function AddLaneModal({
<div>
<label className="mb-1 block text-xs text-fg-secondary">{t("addLaneToolHint")}</label>
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
<Seg
active={tool === "claude"}
label={t("addLaneToolClaude")}
onClick={() => setTool("claude")}
/>
<Seg
active={tool === "codex"}
label={t("addLaneToolCodex")}
onClick={() => setTool("codex")}
/>
<Seg
active={tool === "antigravity"}
label={t("addLaneToolAntigravity")}
onClick={() => {
setTool("antigravity");
// `agy` cannot run behind an AI Router (see router-apply.js).
setRouter("native");
}}
/>
</div>
{noTools ? (
<p className="rounded-md border border-status-warning/40 bg-status-warning/10 px-2 py-1.5 text-xs text-status-warning">
{t("addLaneNoTools")}{" "}
<a href="/settings#ai-tools" className="underline">
{t("addLaneNoToolsLink")}
</a>
</p>
) : (
<div className="flex items-center gap-1.5">
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
<Seg
active={tool === defaultTool}
label={t("addLaneToolDefault", { tool: aiTools.label(defaultTool) })}
onClick={() => setPickedTool(null)}
/>
</div>
{otherTools.length > 0 && (
<select
aria-label={t("addLaneToolOther")}
value={tool === defaultTool ? "" : tool}
onChange={(e) => {
const next = e.target.value || null;
setPickedTool(next);
// A tool with no endpoint to rewrite runs native only
// (see router-apply.js).
if (next && !aiTools.byId(next)?.routerCapable) setRouter("native");
}}
className={`rounded-md border bg-surface-2 px-2 py-1 text-xs ${
tool !== defaultTool
? "border-accent text-accent"
: "border-border text-fg-secondary"
}`}
>
<option value="">{t("addLaneToolOther")}</option>
{otherTools.map((x) => (
<option key={x.id} value={x.id}>
{x.label}
</option>
))}
</select>
)}
</div>
)}
</div>
<div>
@@ -640,17 +671,17 @@ export function AddLaneModal({
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"
hidden={tool === "antigravity"}
>
<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("addLaneRouterNative") },
{
value: "native",
label: t("addLaneRouterNativeTool", { tool: aiTools.label(tool) }),
},
{ value: "router", label: t("addLaneRouterCustom") },
] as const
).map((option) => {
@@ -658,10 +689,16 @@ export function AddLaneModal({
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;
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.
+17 -2
View File
@@ -30,6 +30,7 @@ import { eventBus } from "../../lib/eventBus";
import { keepIfSame } from "../../lib/keepIfSame";
import type { AiRouter } from "../../lib/api";
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
* git refresh because a stack dying is exactly what the user needs to see, and slower than
@@ -120,6 +121,13 @@ function useLaneLocks(laneSlot: number | null): NamedLock[] {
*/
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);
@@ -225,8 +233,15 @@ export function LaneRouterRow({ lane }: { lane: Lane }) {
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}>
{r.label}
<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>
@@ -18,7 +18,7 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { Lane } from "../../lib/types";
import { LANE_TOOL_LABEL } from "../../lib/types";
import { useAiTools } from "../../hooks/useAiTools";
const LIVENESS_DOT: Record<Lane["liveness"], string> = {
active: "bg-status-success",
@@ -65,6 +65,7 @@ export default function LaneStripCard({
onReorder: (from: number, to: number) => void;
}) {
const { t } = useTranslation(["lanes"]);
const { label: toolLabel } = useAiTools();
const [dropTarget, setDropTarget] = useState(false);
return (
@@ -120,7 +121,7 @@ export default function LaneStripCard({
installed; the others show their CLI's tag in place of the stage chip. */}
{lane.tool !== "claude" || lane.pipeline_nodes.length === 0 ? (
<span className="shrink-0 rounded border border-border bg-surface-2 px-1.5 py-0.5 text-[10px] text-fg-muted">
{LANE_TOOL_LABEL[lane.tool]}
{toolLabel(lane.tool)}
</span>
) : (
<span className="truncate rounded bg-surface-4 px-1.5 py-0.5 text-fg-secondary">
@@ -14,7 +14,8 @@ import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AddLaneModal } from "../AddLaneModal";
import { api } from "../../../lib/api";
import type { CwdSuggestion } from "../../../lib/api";
import type { AiTool, AiToolsView, CwdSuggestion } from "../../../lib/api";
import { __resetAiTools } from "../../../hooks/useAiTools";
import type { Lane } from "../../../lib/types";
vi.mock("../../../lib/api", () => ({
@@ -31,10 +32,38 @@ vi.mock("../../../lib/api", () => ({
routerApply: vi.fn(),
},
aiRouters: { list: vi.fn() },
aiTools: { list: vi.fn() },
run: { start: vi.fn() },
},
}));
const tool = (id: string, label: string, over: Partial<AiTool> = {}): AiTool => ({
id,
label,
command: id,
homepageUrl: null,
installed: true,
path: `/usr/bin/${id}`,
enabled: true,
source: "builtin",
launcher: null,
prompt: { mode: "argv" },
resume: false,
bypass: false,
routerCapable: false,
...over,
});
const TOOLS: AiToolsView = {
default: "claude",
tools: [
tool("claude", "Claude Code", { launcher: "claude", routerCapable: true, prompt: null }),
tool("codex", "Codex", { launcher: "codex", routerCapable: true, prompt: null }),
tool("gemini", "Gemini"),
tool("aider", "Aider", { installed: false, path: null }),
tool("grok", "Grok", { enabled: false }),
],
};
function laneFixture(over: Partial<Lane> = {}): Lane {
// `as Lane`: spreading a Partial<Lane> widens every field it may carry to
// `T | undefined`, which no longer satisfies Lane's required fields. The
@@ -107,6 +136,8 @@ async function fillWorktreeForm(
}
beforeEach(() => {
__resetAiTools();
vi.mocked(api.aiTools.list).mockReset().mockResolvedValue(TOOLS);
vi.mocked(api.lanes.branches).mockReset();
vi.mocked(api.lanes.worktree).mockReset();
vi.mocked(api.lanes.ensure).mockReset();
@@ -138,7 +169,7 @@ beforeEach(() => {
function nativeRouter() {
return {
name: "native",
label: "Native (Claude / Codex)",
label: "Native",
builtin: true,
baseUrl: "",
hasKey: false,
@@ -649,7 +680,7 @@ describe("AddLaneModal — AI Router", () => {
const user = userEvent.setup();
await user.click(await screen.findByRole("radio", { name: "AI Router" }));
expect(screen.getByRole("radio", { name: "Native" })).toBeChecked();
expect(screen.getByRole("radio", { name: /^Native/ })).toBeChecked();
expect(screen.getByText(/No router profile saved yet/)).toBeInTheDocument();
});
@@ -716,7 +747,7 @@ describe("AddLaneModal — a router profile that disappeared", () => {
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());
await waitFor(() => expect(screen.getByRole("radio", { name: /^Native/ })).toBeChecked());
});
it("does not offer AI Router until the profile list has arrived", async () => {
@@ -751,7 +782,14 @@ describe("AddLaneModal — tool selection", () => {
const dirField = screen.getByLabelText("Directory");
await focusField(user, dirField);
await user.type(dirField, "/tmp/demo");
await user.click(screen.getByRole("button", { name: /codex/i }));
const other = await screen.findByRole("combobox", { name: "Other…" });
// Installed + enabled only, and never the default itself.
expect(Array.from((other as HTMLSelectElement).options).map((o) => o.value)).toEqual([
"",
"codex",
"gemini",
]);
await user.selectOptions(other, "codex");
await user.click(screen.getByRole("button", { name: /add lane|create/i }));
await waitFor(() =>
@@ -759,6 +797,24 @@ 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.selectOptions(await screen.findByRole("combobox", { name: "Other…" }), "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 () => {
vi.mocked(api.aiTools.list).mockResolvedValue({ default: null, tools: [] });
renderModal({});
expect(await screen.findByText(/No AI tool is installed and enabled/)).toBeInTheDocument();
expect(screen.getByRole("link", { name: /AI Tools/ })).toHaveAttribute(
"href",
"/settings#ai-tools"
);
});
it("defaults to claude code", async () => {
vi.mocked(api.lanes.ensure).mockResolvedValue({
lane: laneFixture({ id: 24, kind: "adopted", status: "idle" }),
@@ -378,7 +378,7 @@ describe("LaneToolsMenu", () => {
describe("LaneRouterRow — the Model picker", () => {
const native = {
name: "native",
label: "Native (Claude / Codex)",
label: "Native",
builtin: true,
baseUrl: "",
hasKey: false,
@@ -406,6 +406,16 @@ describe("LaneRouterRow — the Model picker", () => {
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({
@@ -417,9 +427,7 @@ describe("LaneRouterRow — the Model picker", () => {
});
render(<LaneRouterRow lane={makeLane()} />);
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
"9router",
]);
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude)"), ["9router"]);
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(1, "9router"));
expect(await screen.findByDisplayValue("9Router")).toBeInTheDocument();
});
@@ -440,9 +448,7 @@ describe("LaneRouterRow — the Model picker", () => {
vi.mocked(api.run.start).mockResolvedValue({} as never);
render(<LaneRouterRow lane={makeLane()} />);
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
"9router",
]);
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude)"), ["9router"]);
const restart = await screen.findByRole("button", { name: "Restart" });
await userEvent.click(restart);
@@ -464,9 +470,7 @@ describe("LaneRouterRow — the Model picker", () => {
});
render(<LaneRouterRow lane={makeLane()} />);
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
"9router",
]);
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude)"), ["9router"]);
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalled());
expect(screen.queryByRole("button", { name: "Restart" })).toBeNull();
});
@@ -487,14 +491,14 @@ describe("LaneRouterRow — the Model picker", () => {
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 / Codex)");
await screen.findByDisplayValue("Native (Claude)");
rerender(<LaneRouterRow lane={laneB} />);
await userEvent.selectOptions(screen.getByDisplayValue("Native (Claude / Codex)"), ["9router"]);
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 / Codex)")).toBeInTheDocument();
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 () => {
@@ -510,9 +514,7 @@ describe("LaneRouterRow — the Model picker", () => {
const laneB = makeLane({ id: 2, router_profile: null });
const { rerender } = render(<LaneRouterRow lane={laneB} />);
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
"9router",
]);
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude)"), ["9router"]);
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(2, "9router"));
rerender(<LaneRouterRow lane={laneA} />);
@@ -521,7 +523,7 @@ describe("LaneRouterRow — the Model picker", () => {
await Promise.resolve();
});
expect(screen.getByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument();
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();
@@ -532,10 +534,8 @@ describe("LaneRouterRow — the Model picker", () => {
vi.mocked(api.lanes.routerApply).mockRejectedValue(new Error("EBADJSON"));
render(<LaneRouterRow lane={makeLane()} />);
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
"9router",
]);
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude)"), ["9router"]);
await waitFor(() => expect(screen.getByTitle("EBADJSON")).toBeInTheDocument());
expect(screen.getByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument();
expect(screen.getByDisplayValue("Native (Claude)")).toBeInTheDocument();
});
});
+12 -7
View File
@@ -43,8 +43,8 @@ import type {
CodexSandbox,
CodexApproval,
} from "../../lib/api";
import type { Session, Lane, LaneTool } from "../../lib/types";
import { LANE_TOOL_LABEL } from "../../lib/types";
import type { Session, Lane } from "../../lib/types";
import { useAiTools } from "../../hooks/useAiTools";
import { TerminalView } from "./TerminalView";
import { RunSetup } from "./RunSetup";
import { ActiveRunsSwitcher } from "./RunHistory";
@@ -69,7 +69,7 @@ export interface LaneConsolePaneProps {
showLaneSelector: boolean;
onLaneIdChange: (id: number) => void;
onLaneCreated: (lane: Lane) => void;
binaryStatus: Record<LaneTool, { found: boolean; path: string | null }> | null;
binaryStatus: Record<string, { found: boolean; path: string | null }> | null;
cwdSuggestions: CwdSuggestion[];
activeRuns: RunListResponse | null;
/** Active Claude Code sessions started outside the dashboard, listed in the
@@ -150,7 +150,8 @@ export function LaneConsolePane({
: (externalSessions ?? []).filter((s) => s.cwd && owningLaneId(s.cwd, lanes) === laneId),
[externalSessions, laneId, lanes]
);
const toolLabel = LANE_TOOL_LABEL[laneTool];
const aiTools = useAiTools();
const toolLabel = aiTools.label(laneTool);
useEffect(() => {
onHasActiveRunChange?.(handle !== null);
@@ -313,6 +314,7 @@ export function LaneConsolePane({
sandbox: args.sandbox,
approval: args.approval,
resumeLast: args.resumeLast,
bypass: args.bypass,
});
} catch (laneErr: unknown) {
// A code-only check: the server's message is human prose ("lane
@@ -566,12 +568,13 @@ export function LaneConsolePane({
/>
</header>
{binaryStatus && !binaryStatus[laneTool]?.found && (
{/* Not probed yet (a lane on a tool the page has not probed) is not "missing". */}
{binaryStatus?.[laneTool] && !binaryStatus[laneTool].found && (
<div className="rounded-lg border border-status-danger/40 bg-status-danger/10 px-4 py-3 text-sm text-status-danger flex items-center gap-2">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span>
{t("binary.missing", {
cli: laneTool === "antigravity" ? "agy" : laneTool,
cli: aiTools.byId(laneTool)?.command ?? laneTool,
tool: toolLabel,
})}
</span>
@@ -613,7 +616,9 @@ export function LaneConsolePane({
onSandboxChange={setSandbox}
approval={approval}
onApprovalChange={setApproval}
onResumeLast={() => onStartFromSetup({ laneId: laneId ?? 0, cwd, resumeLast: true })}
onResumeLast={(bypass) =>
onStartFromSetup({ laneId: laneId ?? 0, cwd, resumeLast: true, bypass })
}
/>
) : (
<div className="flex-1 min-h-0 flex flex-col">
+70 -36
View File
@@ -56,7 +56,7 @@ import type {
CcMdItem,
} from "../../lib/api";
import type { Session, LaneTool } from "../../lib/types";
import { LANE_TOOL_LABEL } from "../../lib/types";
import { useAiTools } from "../../hooks/useAiTools";
import { Select } from "../Select";
export interface SlashCommand {
@@ -389,9 +389,10 @@ interface RunSetupProps {
onSandboxChange: (s: CodexSandbox) => void;
approval: CodexApproval;
onApprovalChange: (a: CodexApproval) => void;
/** Codex lanes resume with `--last`: the sessions table holds Claude
/** Every non-Claude lane resumes its tool's LAST session (`codex resume
* --last`, a catalog tool's `resume` args): the sessions table holds Claude
* sessions only, so there is nothing for the picker to list. */
onResumeLast: () => void;
onResumeLast: (bypass?: boolean) => void;
}
export function RunSetup(props: RunSetupProps) {
@@ -399,6 +400,15 @@ export function RunSetup(props: RunSetupProps) {
const atCap = false; // TODO: re-add when concurrency info is available
const isResume = !!props.resumeSession;
const [resumePicked, setResumePicked] = useState(isResume);
const aiTools = useAiTools();
const entry = aiTools.byId(props.tool);
// Claude and Codex keep their hand-written fields; every other catalog tool
// gets the plain form — cwd, prompt, resume and bypass when it has them.
const catalogTool = props.tool !== "claude" && props.tool !== "codex";
const canResume = !catalogTool || !!entry?.resume;
// Per run, never remembered: the form starts unticked every time it mounts.
const [bypass, setBypass] = useState(false);
const canBypass = props.tool !== "claude" && !!entry?.bypass;
// Keep "resume picked" in sync with the parent. Parent set a resume session
// (e.g. user clicked Resume in the runs modal) - flip the radio so the picker
// is shown and the selection is visible.
@@ -431,13 +441,13 @@ export function RunSetup(props: RunSetupProps) {
</div>
)}
</Field>
{/* `agy` takes no model, permission or effort flag. */}
{props.tool !== "antigravity" && (
{/* A catalog tool takes no model, permission or effort flag. */}
{!catalogTool && (
<Field label={t("fields.model")}>
<ModelPicker value={props.model} onChange={props.onModelChange} tool={props.tool} />
</Field>
)}
{props.tool === "antigravity" ? null : props.tool === "codex" ? (
{catalogTool ? null : props.tool === "codex" ? (
<>
<Field label={t("fields.sandbox")}>
<Select<CodexSandbox>
@@ -476,7 +486,7 @@ export function RunSetup(props: RunSetupProps) {
/>
</Field>
)}
{props.tool !== "antigravity" && (
{!catalogTool && (
<Field label={t("fields.effort")}>
<Select<EffortLevel>
value={props.effort}
@@ -490,23 +500,25 @@ export function RunSetup(props: RunSetupProps) {
</Field>
)}
</div>
<div className="flex shrink-0 items-center rounded-md border border-border bg-surface-2 p-0.5 text-[11.5px]">
<Seg
active={!resumePicked}
label={t("resume.freshOption")}
title={t("resume.freshHint")}
onClick={() => {
setResumePicked(false);
props.onResumeSessionChange(null);
}}
/>
<Seg
active={resumePicked}
label={t("resume.resumeOption")}
title={t("resume.resumeHint")}
onClick={() => setResumePicked(true)}
/>
</div>
{canResume && (
<div className="flex shrink-0 items-center rounded-md border border-border bg-surface-2 p-0.5 text-[11.5px]">
<Seg
active={!resumePicked}
label={t("resume.freshOption")}
title={t("resume.freshHint")}
onClick={() => {
setResumePicked(false);
props.onResumeSessionChange(null);
}}
/>
<Seg
active={resumePicked}
label={t("resume.resumeOption")}
title={t("resume.resumeHint")}
onClick={() => setResumePicked(true)}
/>
</div>
)}
</div>
{resumePicked && (
@@ -520,7 +532,7 @@ export function RunSetup(props: RunSetupProps) {
// Pass the picked session explicitly: the parent's state
// update has not landed yet on this tick, so reading
// props.resumeSession here would resume nothing.
if (s && !props.busy) handleStart(props, s);
if (s && !props.busy) handleStart(props, s, bypass);
}}
// Fall back to the typed cwd when no lane is selected — otherwise
// an unfiltered picker lists every session from every repo.
@@ -528,7 +540,7 @@ export function RunSetup(props: RunSetupProps) {
/>
) : (
<button
onClick={() => props.onResumeLast()}
onClick={() => props.onResumeLast(canBypass && bypass ? true : undefined)}
disabled={props.busy}
className="px-3 py-1.5 text-[11px] rounded-md bg-surface-2 border border-border hover:border-accent/50 text-fg-primary hover:text-accent transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
@@ -547,8 +559,8 @@ export function RunSetup(props: RunSetupProps) {
<PromptEditor
value={props.prompt}
onChange={props.onPromptChange}
onSubmit={() => handleStart(props)}
placeholder={t("fields.promptPlaceholderTerminal", { tool: LANE_TOOL_LABEL[props.tool] })}
onSubmit={() => handleStart(props, undefined, canBypass && bypass)}
placeholder={t("fields.promptPlaceholderTerminal", { tool: aiTools.label(props.tool) })}
rows={5}
slash={props.tool === "claude"}
fileCwd={props.resumeSession?.cwd || props.cwd}
@@ -558,7 +570,27 @@ export function RunSetup(props: RunSetupProps) {
</div>
</div>
{props.tool === "codex" && props.sandbox === "danger-full-access" && (
{canBypass && (
<div className="px-4 pb-3">
<label className="inline-flex items-center gap-2 text-[11.5px] text-status-danger cursor-pointer">
<input
type="checkbox"
checked={bypass}
onChange={(e) => setBypass(e.target.checked)}
className="accent-[rgb(var(--status-danger))]"
/>
{t("fields.bypass")}
</label>
{bypass && (
<div className="mt-2 rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-[11px] text-status-danger flex items-start gap-2">
<ShieldAlert className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
<span>{t("hint.bypassWarning", { tool: aiTools.label(props.tool) })}</span>
</div>
)}
</div>
)}
{props.tool === "codex" && !bypass && props.sandbox === "danger-full-access" && (
<div className="mx-4 mb-3 rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-[11px] text-status-danger flex items-start gap-2">
<ShieldAlert className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
<span>{t("hint.sandboxWarning")}</span>
@@ -583,7 +615,7 @@ export function RunSetup(props: RunSetupProps) {
) : null}
</div>
<button
onClick={() => handleStart(props)}
onClick={() => handleStart(props, undefined, canBypass && bypass)}
disabled={
!props.binaryFound ||
// A resume needs no prompt — the session it continues is the input.
@@ -612,25 +644,27 @@ export function RunSetup(props: RunSetupProps) {
/** `session` overrides `props.resumeSession` for the auto-start fired straight
* out of the picker, before the parent's state has caught up. */
function handleStart(props: RunSetupProps, session?: Session) {
function handleStart(props: RunSetupProps, session?: Session, bypass?: boolean) {
const resume = session ?? props.resumeSession;
const codex = props.tool === "codex";
const agy = props.tool === "antigravity";
// Any catalog tool: none of Claude's or Codex's own flags apply.
const other = props.tool !== "claude" && !codex;
props.onStart({
laneId: props.laneId,
// A resume is pinned to its own session's folder — that's what the locked
// cwd field shows, so it's what gets sent.
cwd: resume?.cwd || props.cwd || undefined,
model: agy ? undefined : props.model || undefined,
permissionMode: codex || agy ? undefined : props.permissionMode || undefined,
model: other ? undefined : props.model || undefined,
permissionMode: codex || other ? undefined : props.permissionMode || undefined,
sandbox: codex ? props.sandbox : undefined,
approval: codex ? props.approval : undefined,
effort: agy ? undefined : props.effort || undefined,
resumeSessionId: codex || agy ? undefined : resume?.id || undefined,
effort: other ? undefined : props.effort || undefined,
resumeSessionId: codex || other ? undefined : resume?.id || undefined,
// A resume continues a session that already has its own history — sending
// whatever is sitting in the prompt box would fire an extra turn nobody
// asked for, so a resume carries no prompt at all.
initialPrompt: resume ? undefined : props.prompt || undefined,
bypass: bypass || undefined,
});
}
@@ -25,6 +25,17 @@ vi.mock("../../../lib/api", async (importOriginal) => {
return {
...actual,
api: {
aiTools: {
list: r({
default: "claude",
tools: [
{ id: "claude", label: "Claude Code", resume: false, bypass: false },
{ id: "codex", label: "Codex", resume: false, bypass: true },
{ id: "antigravity", label: "Antigravity", resume: true, bypass: true },
{ id: "gemini", label: "Gemini", resume: false, bypass: false },
].map((x) => ({ ...x, installed: true, enabled: true, command: x.id })),
}),
},
run: { files: r({ items: [] }) },
sessions: { list: r({ sessions: [], total: 0, limit: 100, offset: 0 }) },
ccConfig: {
@@ -443,6 +454,41 @@ describe("RunSetup — codex lanes", () => {
const { spies } = renderSetup({ tool: "codex" });
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
expect(spies.onStart).toHaveBeenCalledOnce();
expect(spies.onStart.mock.calls[0][0].resumeLast).toBeUndefined();
expect(spies.onStart.mock.calls[0]?.[0].resumeLast).toBeUndefined();
});
});
describe("RunSetup — catalog tools", () => {
it("offers a per-run bypass switch with a warning, and sends it", async () => {
const { spies } = renderSetup({ tool: "antigravity", prompt: "go" });
const box = await screen.findByRole("checkbox", { name: i18n.t("run:fields.bypass") });
expect(box).not.toBeChecked();
expect(screen.queryByText(/without asking/)).toBeNull();
fireEvent.click(box);
expect(screen.getByText(/Antigravity will run every command/)).toBeTruthy();
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
expect(spies.onStart.mock.calls[0]?.[0].bypass).toBe(true);
// Resume is offered because the catalog gives the tool a resume flag.
expect(screen.getByText(i18n.t("run:resume.resumeOption"))).toBeTruthy();
});
it("leaves out resume and bypass for a tool with neither", async () => {
const { spies } = renderSetup({ tool: "gemini", prompt: "go" });
expect(await screen.findByPlaceholderText("Ask Gemini anything…")).toBeTruthy();
expect(screen.queryByRole("checkbox", { name: i18n.t("run:fields.bypass") })).toBeNull();
expect(screen.queryByText(i18n.t("run:resume.resumeOption"))).toBeNull();
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
expect(spies.onStart.mock.calls[0]?.[0].bypass).toBeUndefined();
});
it("gives Codex the switch but not Claude", async () => {
renderSetup({ tool: "codex" });
expect(await screen.findByRole("checkbox", { name: i18n.t("run:fields.bypass") })).toBeTruthy();
});
it("gives Claude no bypass switch (it has the bypassPermissions mode)", async () => {
renderSetup({ tool: "claude" });
await screen.findByPlaceholderText("Ask Claude Code anything…");
expect(screen.queryByRole("checkbox", { name: i18n.t("run:fields.bypass") })).toBeNull();
});
});
@@ -0,0 +1,184 @@
/**
* @file AiToolsSettings.tsx
* @description Settings → AI Tools: every AI coding CLI in the server's catalog
* (GET /api/ai-tools), split into the ones found on PATH — each with
* Enabled/Disabled and Set default — and the ones available to install, which
* only link to their homepage. Refresh re-reads the login shell's PATH and
* re-detects. A disabled tool is hidden from Add lane and the server refuses a
* new lane for it; existing lanes keep working.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Bot, ExternalLink, RefreshCw } from "lucide-react";
import { useAiTools } from "../../hooks/useAiTools";
import type { AiTool } from "../../lib/api";
function Avatar({ label }: { label: string }) {
return (
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-surface-4 text-xs font-semibold text-fg-secondary">
{label.charAt(0).toUpperCase()}
</span>
);
}
function Homepage({ tool }: { tool: AiTool }) {
const { t } = useTranslation("settings");
if (!tool.homepageUrl) return null;
return (
<a
href={tool.homepageUrl}
target="_blank"
rel="noreferrer noopener"
title={t("aiTools.homepage")}
aria-label={t("aiTools.homepageOf", { tool: tool.label })}
className="text-fg-muted hover:text-accent"
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
);
}
export function AiToolsSettings() {
const { t } = useTranslation("settings");
const { tools, loaded, defaultId, refresh, update } = useAiTools();
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const sectionRef = useRef<HTMLElement | null>(null);
// The Add-lane modal links here as /settings#ai-tools; the section renders
// after the browser's own hash jump, so it has to scroll itself into view.
useEffect(() => {
if (window.location.hash === "#ai-tools") sectionRef.current?.scrollIntoView?.();
}, []);
const run = async (key: string, fn: () => Promise<void>) => {
setBusy(key);
setError(null);
try {
await fn();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(null);
}
};
const installed = tools.filter((x) => x.installed);
const available = tools.filter((x) => !x.installed);
return (
<section id="ai-tools" ref={sectionRef} className="scroll-mt-24">
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
<Bot className="w-4 h-4 text-fg-muted" />
{t("aiTools.title")}
</h3>
<p className="text-xs text-fg-muted mb-4">{t("aiTools.description")}</p>
<div className="card p-5 space-y-5">
<div>
<div className="flex items-center justify-between gap-3 mb-2">
<p className="text-xs font-medium text-fg-secondary">
{t("aiTools.installed", { count: installed.length })}
</p>
<button
type="button"
onClick={() => void run("refresh", refresh)}
disabled={busy !== null}
className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:opacity-50"
>
<RefreshCw className={`w-3.5 h-3.5 ${busy === "refresh" ? "animate-spin" : ""}`} />
{t("aiTools.refresh")}
</button>
</div>
{error && <p className="mb-2 text-xs text-status-danger">{error}</p>}
{loaded && installed.length === 0 && (
<p className="text-xs text-fg-muted">{t("aiTools.noneInstalled")}</p>
)}
<ul className="divide-y divide-border rounded-md border border-border">
{installed.map((tool) => {
const isDefault = tool.id === defaultId;
return (
<li
key={tool.id}
data-testid={`ai-tool-${tool.id}`}
className={`flex items-center gap-3 px-3 py-2 ${tool.enabled ? "" : "opacity-60"}`}
>
<Avatar label={tool.label} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 text-sm text-fg-primary">
{tool.label}
<Homepage tool={tool} />
</div>
<div
className="truncate font-mono text-[11px] text-fg-muted"
title={tool.path ?? ""}
>
{tool.command}
</div>
</div>
{isDefault ? (
<span className="text-xs text-accent">✓ {t("aiTools.default")}</span>
) : (
<button
type="button"
disabled={!tool.enabled || busy !== null}
onClick={() => void run(tool.id, () => update(tool.id, { default: true }))}
className="rounded-md border border-border px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:opacity-50"
>
{t("aiTools.setDefault")}
</button>
)}
<button
type="button"
role="switch"
aria-checked={tool.enabled}
aria-label={t("aiTools.enabledOf", { tool: tool.label })}
disabled={busy !== null}
onClick={() =>
void run(tool.id, () => update(tool.id, { enabled: !tool.enabled }))
}
className={`w-24 rounded-md border px-2 py-1 text-xs disabled:opacity-50 ${
tool.enabled
? "border-status-success/40 bg-status-success/10 text-status-success"
: "border-border bg-surface-2 text-fg-muted"
}`}
>
{tool.enabled ? t("aiTools.enabled") : t("aiTools.disabled")}
</button>
</li>
);
})}
</ul>
</div>
{available.length > 0 && (
<div>
<p className="text-xs font-medium text-fg-secondary mb-2">
{t("aiTools.available", { count: available.length })}
</p>
<ul className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{available.map((tool) => (
<li
key={tool.id}
data-testid={`ai-tool-${tool.id}`}
className="flex items-center gap-2 rounded-md border border-border px-2.5 py-1.5"
>
<Avatar label={tool.label} />
<span className="min-w-0 flex-1 truncate text-xs text-fg-secondary">
{tool.label}
<span className="ml-1.5 font-mono text-[10px] text-fg-muted">
{tool.command}
</span>
</span>
<Homepage tool={tool} />
</li>
))}
</ul>
</div>
)}
</div>
</section>
);
}
@@ -0,0 +1,111 @@
/**
* @file AiToolsSettings.test.tsx
* @description Settings → AI Tools: installed rows carry Enabled/Disabled and
* Set default, available rows only a homepage link, Refresh re-detects, and a
* disabled tool's Settings tab disappears from the hub.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { MemoryRouter } from "react-router-dom";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
import { AiToolsSettings } from "../AiToolsSettings";
import { api, type AiTool, type AiToolsView } from "../../../lib/api";
import { __resetAiTools } from "../../../hooks/useAiTools";
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: { aiTools: { list: vi.fn(), refresh: vi.fn(), update: vi.fn() } },
};
});
const tool = (id: string, label: string, over: Partial<AiTool> = {}): AiTool => ({
id,
label,
command: id,
homepageUrl: `https://${id}.example/`,
installed: true,
path: `/usr/bin/${id}`,
enabled: true,
source: "builtin",
launcher: null,
prompt: { mode: "argv" },
resume: false,
bypass: false,
routerCapable: false,
...over,
});
const VIEW: AiToolsView = {
default: "claude",
tools: [
tool("claude", "Claude Code"),
tool("gemini", "Gemini"),
tool("grok", "Grok", { enabled: false }),
tool("aider", "Aider", { installed: false, path: null }),
],
};
beforeEach(() => {
i18n.changeLanguage("en");
__resetAiTools();
vi.mocked(api.aiTools.list).mockReset().mockResolvedValue(VIEW);
vi.mocked(api.aiTools.update).mockReset().mockResolvedValue(VIEW);
vi.mocked(api.aiTools.refresh).mockReset().mockResolvedValue(VIEW);
});
describe("AiToolsSettings", () => {
it("splits installed tools from the ones available to install", async () => {
render(<AiToolsSettings />);
expect(await screen.findByText("Installed (3 detected)")).toBeInTheDocument();
expect(screen.getByText("Available to install (1)")).toBeInTheDocument();
const claude = screen.getByTestId("ai-tool-claude");
expect(within(claude).getByText(/Default/)).toBeInTheDocument();
const aider = screen.getByTestId("ai-tool-aider");
expect(within(aider).queryByRole("switch")).toBeNull();
expect(within(aider).queryByText("Set default")).toBeNull();
expect(within(aider).getByRole("link")).toHaveAttribute("href", "https://aider.example/");
});
it("toggles a tool and sets the default through the API", async () => {
const user = userEvent.setup();
render(<AiToolsSettings />);
const gemini = await screen.findByTestId("ai-tool-gemini");
await user.click(within(gemini).getByRole("switch", { name: "Gemini enabled" }));
expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { enabled: false });
await user.click(within(gemini).getByText("Set default"));
expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { default: true });
// A disabled tool cannot become the default.
const grok = screen.getByTestId("ai-tool-grok");
expect(within(grok).getByText("Set default")).toBeDisabled();
expect(within(grok).getByRole("switch")).toHaveTextContent("Disabled");
});
it("refreshes detection", async () => {
const user = userEvent.setup();
render(<AiToolsSettings />);
await user.click(await screen.findByRole("button", { name: "Refresh" }));
expect(api.aiTools.refresh).toHaveBeenCalledOnce();
});
});
describe("SettingsHub — AI tool tabs", () => {
it("hides the Codex tab while Codex is disabled", async () => {
vi.mocked(api.aiTools.list).mockResolvedValue({
default: "claude",
tools: [tool("claude", "Claude Code"), tool("codex", "Codex", { enabled: false })],
});
vi.doMock("../../../pages/Settings", () => ({ Settings: () => null }));
const { SettingsHub } = await import("../../../pages/SettingsHub");
render(
<MemoryRouter>
<SettingsHub />
</MemoryRouter>
);
expect(await screen.findByRole("tab", { name: /Claude Code/ })).toBeInTheDocument();
expect(screen.queryByRole("tab", { name: /^Codex/ })).toBeNull();
});
});
+80
View File
@@ -0,0 +1,80 @@
/**
* @file useAiTools — the AI Tools catalog (GET /api/ai-tools) shared by every
* component that names a lane's tool or offers one: the Add-lane modal, the
* run form, the lane strip and Settings → AI Tools. One module-level copy, so
* N components mean one request; refetched on window focus and after every
* change made through `update`/`refresh`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useSyncExternalStore } from "react";
import { api, type AiTool, type AiToolsView } from "../lib/api";
let view: AiToolsView | null = null;
let inflight: Promise<void> | null = null;
const listeners = new Set<() => void>();
function publish(next: AiToolsView) {
view = next;
listeners.forEach((l) => l());
}
function load(): Promise<void> {
if (!inflight) {
inflight = Promise.resolve()
.then(() => api.aiTools.list())
.then(publish)
// Unreachable server: keep what we had; every caller has a fallback.
.catch(() => undefined)
.finally(() => {
inflight = null;
});
}
return inflight;
}
function subscribe(l: () => void) {
listeners.add(l);
if (listeners.size === 1) window.addEventListener("focus", onFocus);
return () => {
listeners.delete(l);
if (listeners.size === 0) window.removeEventListener("focus", onFocus);
};
}
function onFocus() {
void load();
}
/** Built-in names for the three tools a lane may carry before the catalog loads. */
const FALLBACK: Record<string, string> = {
claude: "Claude",
codex: "Codex",
antigravity: "Antigravity",
};
/** Test seam: forget the cached catalog. */
export function __resetAiTools() {
view = null;
inflight = null;
}
export function useAiTools() {
const v = useSyncExternalStore(subscribe, () => view);
useEffect(() => {
if (!view) void load();
}, []);
const tools: AiTool[] = v?.tools ?? [];
const byId = (id: string) => tools.find((t) => t.id === id) ?? null;
return {
tools,
loaded: v !== null,
defaultId: v?.default ?? null,
byId,
/** A tool's display name; the id itself for a tool the catalog no longer knows. */
label: (id: string) => byId(id)?.label ?? FALLBACK[id] ?? id,
reload: load,
refresh: async () => publish(await api.aiTools.refresh()),
update: async (id: string, body: { enabled?: boolean; default?: true }) =>
publish(await api.aiTools.update(id, body)),
};
}
+7 -4
View File
@@ -45,7 +45,6 @@
"addLaneRouterHint": "Native talks to the vendor's own API. An AI Router profile is written into this lane's own config when it is created.",
"addLaneRouterLabel": "Model endpoint",
"addLaneRouterLoading": "Loading router profiles…",
"addLaneRouterNative": "Native",
"addLaneRouterProfileLabel": "Router profile",
"addLaneSetupClose": "Close",
"addLaneSetupMcp": "MCP servers",
@@ -54,9 +53,11 @@
"addLaneSetupTitle": "Setup",
"addLaneTitleLabel": "Title",
"addLaneTitlePlaceholder": "Optional",
"addLaneToolClaude": "Claude Code",
"addLaneToolCodex": "Codex",
"addLaneToolAntigravity": "Antigravity",
"addLaneToolDefault": "Default ({{tool}})",
"addLaneToolOther": "Other…",
"addLaneNoTools": "No AI tool is installed and enabled.",
"addLaneNoToolsLink": "Open Settings → AI Tools",
"addLaneRouterNativeTool": "Native ({{tool}})",
"addLaneToolHint": "Which CLI drives this lane. Cannot be changed later.",
"addLaneWorktreeDetached": "(detached)",
"addLaneWorktreesMore": "+{{count}} more",
@@ -218,6 +219,8 @@
"renameLabel": "Rename this lane",
"reorderHint": "Drag to reorder · Alt+←/→",
"router.applied": "Applied.",
"router.native": "Native ({{tool}})",
"router.nativeOnly": "{{tool}} runs on its own settings — AI Router profiles work with Claude Code and Codex only.",
"router.appliedRestart": "Applied, but the session running in this lane still uses the old models — restart it to take effect.",
"router.label": "Model",
"router.restart": "Restart",
+2
View File
@@ -76,6 +76,7 @@
"permissionAcceptEdits": "acceptEdits (file edits without asking)",
"permissionAuto": "auto (recommended - asks only for risky actions)",
"permissionBypass": "bypassPermissions (DANGER - allows everything without asking)",
"bypass": "Bypass permissions for this run",
"permissionDefault": "default (interactive prompts - sessions may hang headlessly)",
"permissionMode": "Permission mode",
"permissionPlan": "plan (read-only planning)",
@@ -97,6 +98,7 @@
"hint": {
"headlessExplain": "Headless mode is best for scripted tasks where you know exactly what you want. The session can't ask follow-up questions and will hang on permission prompts unless you stay in acceptEdits.",
"permissionWarning": "bypassPermissions allows ALL tool invocations without confirming. Only use for trusted prompts.",
"bypassWarning": "{{tool}} will run every command and edit every file without asking. Only for a prompt and a folder you trust.",
"sandboxWarning": "Codex will run commands with no sandbox.",
"shortcut": "Enter to send · Shift+Enter for newline"
},
+15
View File
@@ -495,6 +495,21 @@
"testOk": "The endpoint answered — {{model}} is reachable with this key.",
"testFailed": "No answer ({{status}}): {{message}}"
},
"aiTools": {
"title": "AI Tools",
"description": "AI coding CLIs AgentHub can run in a lane. Detected on this machine's PATH; a disabled tool is hidden from Add lane.",
"installed": "Installed ({{count}} detected)",
"available": "Available to install ({{count}})",
"refresh": "Refresh",
"noneInstalled": "No AI tool found on PATH. Install one, then press Refresh.",
"default": "Default",
"setDefault": "Set default",
"enabled": "Enabled",
"disabled": "Disabled",
"enabledOf": "{{tool}} enabled",
"homepage": "Homepage",
"homepageOf": "{{tool}} homepage"
},
"appearance": {
"title": "Appearance",
"description": "Colour mode and theme. Saved in this browser only.",
+7 -4
View File
@@ -45,7 +45,6 @@
"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 lane này lúc tạo.",
"addLaneRouterLabel": "Endpoint model",
"addLaneRouterLoading": "Đang tải danh sách router…",
"addLaneRouterNative": "Native",
"addLaneRouterProfileLabel": "Profile router",
"addLaneSetupClose": "Đóng",
"addLaneSetupMcp": "MCP server",
@@ -54,9 +53,11 @@
"addLaneSetupTitle": "Thiết lập",
"addLaneTitleLabel": "Tiêu đề",
"addLaneTitlePlaceholder": "Không bắt buộc",
"addLaneToolClaude": "Claude Code",
"addLaneToolCodex": "Codex",
"addLaneToolAntigravity": "Antigravity",
"addLaneToolDefault": "Mặc định ({{tool}})",
"addLaneToolOther": "Khác…",
"addLaneNoTools": "Chưa có AI tool nào được cài và bật.",
"addLaneNoToolsLink": "Mở Cài đặt → AI Tools",
"addLaneRouterNativeTool": "Native ({{tool}})",
"addLaneToolHint": "CLI nào điều khiển lane này. Không thể thay đổi sau này.",
"addLaneWorktreeDetached": "(detached)",
"addLaneWorktreesMore": "+{{count}} nữa",
@@ -218,6 +219,8 @@
"renameLabel": "Đổi tên làn đường này",
"reorderHint": "Kéo để sắp xếp · Alt+←/→",
"router.applied": "Đã áp dụng.",
"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.appliedRestart": "Đã áp dụng, nhưng phiên đang chạy trong lane 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",
+2
View File
@@ -75,6 +75,7 @@
"permissionAcceptEdits": "acceptEdits (sửa file không hỏi)",
"permissionAuto": "auto (khuyên dùng - chỉ hỏi khi hành động rủi ro)",
"permissionBypass": "bypassPermissions (NGUY HIỂM - cho phép tất cả mà không hỏi)",
"bypass": "Bỏ qua xin quyền cho lần chạy này",
"permissionDefault": "default (prompt tương tác - headless sẽ treo)",
"permissionMode": "Permission mode",
"permissionPlan": "plan (chỉ đọc, lập kế hoạch)",
@@ -96,6 +97,7 @@
"hint": {
"headlessExplain": "Headless phù hợp cho tác vụ tự động. Phiên không thể hỏi lại, sẽ treo ở prompt xin quyền nếu bạn không giữ acceptEdits.",
"permissionWarning": "bypassPermissions cho phép gọi mọi tool không cần xác nhận. Chỉ dùng cho prompt bạn tin tưởng.",
"bypassWarning": "{{tool}} sẽ chạy mọi lệnh và sửa mọi file mà không hỏi. Chỉ dùng với prompt và thư mục bạn tin tưởng.",
"sandboxWarning": "Codex sẽ chạy lệnh mà không có sandbox.",
"shortcut": "Enter để gửi · Shift+Enter xuống dòng"
},
+15
View File
@@ -495,6 +495,21 @@
"testOk": "Endpoint đã trả lời — {{model}} dùng được với key này.",
"testFailed": "Không trả lời ({{status}}): {{message}}"
},
"aiTools": {
"title": "AI Tools",
"description": "Các CLI lập trình AI mà AgentHub chạy được trong lane. Nhận diện qua PATH của máy; tool bị tắt sẽ ẩn khỏi Add lane.",
"installed": "Đã cài ({{count}} tool)",
"available": "Có thể cài ({{count}})",
"refresh": "Làm mới",
"noneInstalled": "Chưa tìm thấy AI tool nào trong PATH. Cài một tool rồi bấm Làm mới.",
"default": "Mặc định",
"setDefault": "Đặt mặc định",
"enabled": "Đang bật",
"disabled": "Đang tắt",
"enabledOf": "Bật {{tool}}",
"homepage": "Trang chủ",
"homepageOf": "Trang chủ {{tool}}"
},
"appearance": {
"title": "Giao diện",
"description": "Chế độ màu và theme. Chỉ lưu trong trình duyệt này.",
+44 -2
View File
@@ -1939,6 +1939,18 @@ export const api = {
{ method: "DELETE" }
),
},
aiTools: {
/** GET /api/ai-tools - the catalog, what is installed, enabled, and the default. */
list: () => request<AiToolsView>("/ai-tools"),
/** POST /api/ai-tools/refresh - re-read the login shell's PATH and re-detect. */
refresh: () => request<AiToolsView>("/ai-tools/refresh", { method: "POST" }),
/** PUT /api/ai-tools/:id - enable/disable a tool, or make it the default. */
update: (id: string, body: { enabled?: boolean; default?: true }) =>
request<AiToolsView>(`/ai-tools/${encodeURIComponent(id)}`, {
method: "PUT",
body: JSON.stringify(body),
}),
},
aiRouters: {
/** GET /api/ai-routers - every profile, the built-in `native` one first. */
list: () => request<{ items: AiRouter[] }>("/ai-routers"),
@@ -1987,7 +1999,7 @@ export const api = {
if (options?.laneId !== undefined) qs.set("laneId", String(options.laneId));
return request<{ items: DashboardRunHistoryItem[] }>(`/run/history?${qs.toString()}`);
},
/** GET /api/run/binary - whether the lane's CLI (`claude` or `codex`) was found on PATH. */
/** GET /api/run/binary - whether the lane tool's CLI was found on PATH (fresh look). */
binary: (tool: LaneTool = "claude") =>
request<{ found: boolean; path: string | null }>(`/run/binary?tool=${tool}`),
/** GET /api/run/tmux - whether the `tmux` binary was found on PATH. */
@@ -2963,6 +2975,34 @@ export interface GitAuthNeeded {
rejected: boolean;
}
/** One AI coding CLI from the server's AI Tools catalog (GET /api/ai-tools). */
export interface AiTool {
id: string;
label: string;
/** The command that was found (or would be looked for) on PATH. */
command: string | null;
homepageUrl: string | null;
installed: boolean;
path: string | null;
enabled: boolean;
source: "builtin" | "custom";
/** `claude` / `codex` keep their own run form; null for every other tool. */
launcher: "claude" | "codex" | null;
prompt: { mode: "argv" | "flag" | "after-start" } | null;
/** Offers "Resume last session". */
resume: boolean;
/** Offers the per-run "Bypass permissions" switch. */
bypass: boolean;
/** Can use an AI Router profile (Claude, Codex); others are native only. */
routerCapable: boolean;
}
export interface AiToolsView {
tools: AiTool[];
/** The effective default tool id, or null when nothing is installed and enabled. */
default: string | null;
}
export interface AiRouter {
/** Identifier, derived from the label. Stable: a lane stores this. */
name: string;
@@ -3667,8 +3707,10 @@ export interface RunStartArgs {
sandbox?: CodexSandbox;
/** Codex only: `-a`. Ignored by a claude lane. */
approval?: CodexApproval;
/** Codex only: `codex resume --last` instead of picking a session id. */
/** Continue the tool's last session: Codex `resume --last`, a catalog tool's `resume` args. */
resumeLast?: boolean;
/** Turn off the tool's permission prompts for THIS run (its catalog bypass args). */
bypass?: boolean;
}
/** A lane's tmux-backed terminal run — one per lane, id is the tmux session
+6 -9
View File
@@ -2409,15 +2409,12 @@ export interface ProofFeature {
mtime: number;
}
/** Which CLI drives a lane's pane. Fixed at lane creation and never patched. */
export type LaneTool = "claude" | "codex" | "antigravity";
/** How each CLI is named on screen (strip tag, console title, prompt box). */
export const LANE_TOOL_LABEL: Record<LaneTool, string> = {
claude: "Claude",
codex: "Codex",
antigravity: "Antigravity",
};
/**
* Which CLI drives a lane's pane: an AI Tools catalog id (`claude`, `codex`,
* `antigravity`, `gemini`, …). Fixed at lane creation and never patched. Its
* label comes from `useAiTools().label(id)`.
*/
export type LaneTool = string;
export interface Lane {
id: number;
+5
View File
@@ -60,6 +60,7 @@ import { useTranslation } from "react-i18next";
import {
DollarSign,
Palette,
Bot,
Plus,
Pencil,
Trash2,
@@ -120,6 +121,7 @@ import { RemoteSources } from "../components/RemoteSources";
import { Skeleton } from "../components/Skeleton";
import { AlertsNotifications } from "../components/AlertsNotifications";
import { AppearanceSettings } from "../components/settings/AppearanceSettings";
import { AiToolsSettings } from "../components/settings/AiToolsSettings";
import type { ModelPricing, WSMessage } from "../lib/types";
// In-page navigation for the (dense) Settings screen. Each entry maps to a
@@ -131,6 +133,7 @@ const SETTINGS_SECTIONS: {
Icon: typeof DollarSign;
}[] = [
{ id: "appearance", labelKey: "appearance.title", Icon: Palette },
{ id: "ai-tools", labelKey: "aiTools.title", Icon: Bot },
{ id: "pricing", labelKey: "pricing.title", Icon: DollarSign },
{ id: "hooks", labelKey: "hooks.title", Icon: Plug },
{ id: "lanes", labelKey: "lanesSection.title", Icon: GitBranch },
@@ -1081,6 +1084,8 @@ export function Settings() {
<AppearanceSettings />
<AiToolsSettings />
{/* ─── MODEL PRICING ─── */}
<section id="pricing" className="scroll-mt-24">
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
+24 -5
View File
@@ -1,6 +1,6 @@
/**
* @file SettingsHub.tsx
* @description Tabbed shell for /settings that hosts the dashboard's own settings plus one configuration panel per AI tool (Claude Code and Codex) plus the AI Router profiles they can be pointed at and the git logins dashboard fetch/pull/push use, with the active tab kept in the `?tool=` query param.
* @description Tabbed shell for /settings that hosts the dashboard's own settings plus one configuration panel per AI tool (Claude Code and Codex) plus the AI Router profiles they can be pointed at and the git logins dashboard fetch/pull/push use, with the active tab kept in the `?tool=` query param. A tool's tab is hidden while that tool is disabled in Settings → AI Tools.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { ComponentType } from "react";
@@ -13,12 +13,15 @@ import { CcConfig } from "./CcConfig";
import { CodexConfig } from "./CodexConfig";
import { AiRouterConfig } from "./AiRouterConfig";
import { GitCredentialsConfig } from "./GitCredentialsConfig";
import { useAiTools } from "../hooks/useAiTools";
interface ToolTab {
id: string;
labelKey: string;
Icon: ComponentType<{ className?: string }>;
Panel: ComponentType;
/** The AI Tools catalog id this tab configures; hidden while it is disabled. */
aiTool?: string;
}
const DASHBOARD_TAB: ToolTab = {
@@ -31,8 +34,20 @@ const DASHBOARD_TAB: ToolTab = {
/** Add a tool here to give it a tab; that is the whole extension point. */
const TOOL_TABS: ToolTab[] = [
DASHBOARD_TAB,
{ id: "claude-code", labelKey: "tools.claudeCode", Icon: Boxes, Panel: CcConfig },
{ id: "codex", labelKey: "tools.codex", Icon: SquareTerminal, Panel: CodexConfig },
{
id: "claude-code",
labelKey: "tools.claudeCode",
Icon: Boxes,
Panel: CcConfig,
aiTool: "claude",
},
{
id: "codex",
labelKey: "tools.codex",
Icon: SquareTerminal,
Panel: CodexConfig,
aiTool: "codex",
},
{ id: "ai-router", labelKey: "tools.aiRouter", Icon: Router, Panel: AiRouterConfig },
{
id: "git-credentials",
@@ -50,7 +65,11 @@ export function SettingsHub() {
const { t } = useTranslation("settings");
const [params, setParams] = useSearchParams();
const requested = params.get("tool");
const active = TOOL_TABS.find((tab) => tab.id === requested) ?? DASHBOARD_TAB;
const aiTools = useAiTools();
const tabs = TOOL_TABS.filter(
(tab) => !tab.aiTool || aiTools.byId(tab.aiTool)?.enabled !== false
);
const active = tabs.find((tab) => tab.id === requested) ?? DASHBOARD_TAB;
return (
<div className="space-y-6">
@@ -58,7 +77,7 @@ export function SettingsHub() {
ariaLabel={t("tools.label", "Tools")}
activeId={active.id}
onSelect={(id) => setParams(id === DASHBOARD_TAB.id ? {} : { tool: id }, { replace: true })}
tabs={TOOL_TABS.map(({ id, labelKey, Icon }) => ({
tabs={tabs.map(({ id, labelKey, Icon }) => ({
id,
Icon,
label: t(labelKey),
+19 -7
View File
@@ -56,7 +56,7 @@ import {
} from "lucide-react";
import { api } from "../lib/api";
import type { CwdSuggestion, RunListResponse } from "../lib/api";
import type { Lane, LaneFeature, ProofFeature, Session, WSMessage, LaneTool } from "../lib/types";
import type { Lane, LaneFeature, ProofFeature, Session, WSMessage } from "../lib/types";
import { eventBus } from "../lib/eventBus";
import { keepIfSame } from "../lib/keepIfSame";
import {
@@ -175,7 +175,7 @@ function ConsoleArea({
selectedLaneId: number | null;
splitView: SplitViewState;
setLayout: (layout: SplitLayout) => void;
binaryStatus: Record<LaneTool, { found: boolean; path: string | null }> | null;
binaryStatus: Record<string, { found: boolean; path: string | null }> | null;
cwdSuggestions: CwdSuggestion[];
activeRuns: RunListResponse | null;
externalSessions: Session[];
@@ -296,7 +296,7 @@ export function Workspace() {
// would otherwise show nothing while two agents are working.
const [externalSessions, setExternalSessions] = useState<Session[]>([]);
const [binaryStatus, setBinaryStatus] = useState<Record<
LaneTool,
string,
{ found: boolean; path: string | null }
> | null>(null);
const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
@@ -394,13 +394,25 @@ export function Workspace() {
);
}, [lanes, searchParams, setSearchParams]);
// Probe the CLI of every tool a lane uses — fresh on the server, so a CLI
// installed since the page loaded clears its missing-binary banner. Claude is
// always probed: the console's no-lane state runs it.
const laneToolsKey = Array.from(new Set(["claude", ...lanes.map((l) => l.tool || "claude")]))
.sort()
.join(",");
useEffect(() => {
Promise.all([api.run.binary("claude"), api.run.binary("codex"), api.run.binary("antigravity")])
.then(([claude, codex, antigravity]) => setBinaryStatus({ claude, codex, antigravity }))
const tools = laneToolsKey.split(",");
Promise.all(tools.map((tool) => api.run.binary(tool)))
.then((found) =>
setBinaryStatus(Object.fromEntries(tools.map((tool, i) => [tool, found[i]!])))
)
// Fetch failure (server unreachable, proxy misrouted, etc.) isn't proof
// `claude` or `codex` is missing from PATH — leave the probe unresolved rather than
// a CLI is missing from PATH — leave the probe unresolved rather than
// showing a misleading "binary missing" banner for an unrelated fault.
.catch(() => undefined);
}, [laneToolsKey]);
useEffect(() => {
api.lanes
.pipelines()
.then((r) => setPipelineTemplates(r.pipelines))
@@ -860,7 +872,7 @@ export function Workspace() {
</select>
)}
{/* `agy` has no endpoint to point at a router. */}
{currentLane.tool !== "antigravity" && <LaneRouterRow lane={currentLane} />}
<LaneRouterRow lane={currentLane} />
{/* No stage chip and no "auto: <stage>" chip here: the
PipelineMap right below this header already draws both
— the declared node as the current ring, the detected
@@ -41,7 +41,7 @@ const lane = (id: number, title: string): LaneFixture => ({
const h = vi.hoisted(() => {
const nativeProfile = {
name: "native",
label: "Native (Claude / Codex)",
label: "Native",
builtin: true,
baseUrl: "",
hasKey: false,
@@ -205,10 +205,10 @@ describe("Workspace — the lane Model picker across a lane switch", () => {
const user = userEvent.setup();
await selectLane(user, "Lane A");
expect(await screen.findByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument();
expect(await screen.findByDisplayValue("Native (Claude)")).toBeInTheDocument();
await selectLane(user, "Lane B");
await user.selectOptions(screen.getByDisplayValue("Native (Claude / Codex)"), ["9router"]);
await user.selectOptions(screen.getByDisplayValue("Native (Claude)"), ["9router"]);
await waitFor(() => expect(routerApply).toHaveBeenCalledWith(2, "9router"));
await settle();
expect(screen.getByDisplayValue("9Router")).toBeInTheDocument();
@@ -216,7 +216,7 @@ describe("Workspace — the lane Model picker across a lane switch", () => {
// 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 / Codex)")).toBeInTheDocument();
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());
});
@@ -6996,6 +6996,47 @@ exports[`screen snapshots > Settings 1`] = `
</svg>
Appearance
</button>
<button
class="inline-flex items-center gap-1.5 text-xs whitespace-nowrap px-2.5 py-1.5 rounded-lg border transition-colors flex-shrink-0 border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
type="button"
>
<svg
class="lucide lucide-bot w-3.5 h-3.5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 8V4H8"
/>
<rect
height="12"
rx="2"
width="16"
x="4"
y="8"
/>
<path
d="M2 14h2"
/>
<path
d="M20 14h2"
/>
<path
d="M15 13v2"
/>
<path
d="M9 13v2"
/>
</svg>
AI Tools
</button>
<button
class="inline-flex items-center gap-1.5 text-xs whitespace-nowrap px-2.5 py-1.5 rounded-lg border transition-colors flex-shrink-0 bg-accent/15 border-accent/30 text-accent"
type="button"
@@ -8089,6 +8130,105 @@ exports[`screen snapshots > Settings 1`] = `
</div>
</div>
</section>
<section
class="scroll-mt-24"
id="ai-tools"
>
<h3
class="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1"
>
<svg
class="lucide lucide-bot w-4 h-4 text-fg-muted"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 8V4H8"
/>
<rect
height="12"
rx="2"
width="16"
x="4"
y="8"
/>
<path
d="M2 14h2"
/>
<path
d="M20 14h2"
/>
<path
d="M15 13v2"
/>
<path
d="M9 13v2"
/>
</svg>
AI Tools
</h3>
<p
class="text-xs text-fg-muted mb-4"
>
AI coding CLIs AgentHub can run in a lane. Detected on this machine's PATH; a disabled tool is hidden from Add lane.
</p>
<div
class="card p-5 space-y-5"
>
<div>
<div
class="flex items-center justify-between gap-3 mb-2"
>
<p
class="text-xs font-medium text-fg-secondary"
>
Installed (0 detected)
</p>
<button
class="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:opacity-50"
type="button"
>
<svg
class="lucide lucide-refresh-cw w-3.5 h-3.5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"
/>
<path
d="M21 3v5h-5"
/>
<path
d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"
/>
<path
d="M8 16H3v5"
/>
</svg>
Refresh
</button>
</div>
<ul
class="divide-y divide-border rounded-md border border-border"
/>
</div>
</div>
</section>
<section
class="scroll-mt-24"
id="pricing"
+14 -2
View File
@@ -2075,14 +2075,16 @@ GET /api/run/history?limit=&laneId= Persisted run history; laneId narrows to
GET /api/run/files?cwd=&q= Fuzzy file search inside cwd for the @-file autocomplete
POST /api/run/drop-image Raw image body (png/jpeg/gif/webp, ≤20 MB) → {path}: saved 0600 under <tmpdir>/agenthub-drops-<uid>/ with a random name, pruned after a day; the terminal pastes the path
(skips node_modules, .git, dist, build, .next, .cache, coverage, vendor)
POST /api/run Start/attach run — Body: { laneId, cwd?, model?, permissionMode?, effort?, resumeSessionId?, initialPrompt? }
POST /api/run Start/attach run — Body: { laneId, cwd?, model?, permissionMode?, effort?, resumeSessionId?, initialPrompt?, sandbox?, approval?, resumeLast?, bypass? }
GET /api/run/:id Run handle (returns live run state)
DELETE /api/run/:id Kill (SIGTERM → SIGKILL after 5 s)
```
`:id` must be a lane pane name, `agenthub-lane-<n>`; anything else answers 404 without reaching tmux, so these two routes can never touch a tmux session the dashboard did not start.
**`GET /api/run/binary`:** Probes for the binary (`claude`, `codex` or `agy`). Optional query parameter `?tool=codex` probes for Codex and `?tool=antigravity` for Antigravity's `agy`; omitted or `?tool=claude` probes for Claude. Returns `{ found: bool, path: string | null }`.
**`GET /api/run/binary`:** Probes PATH for a lane tool's CLI, looked up in the AI Tools catalog (`?tool=<id>`, default `claude`; an unknown id is `400 EBADTOOL`). Always a fresh look, not the catalog's cached detection. Returns `{ found: bool, path: string | null }`.
**`bypass: true`** (both `POST /api/run` and `POST /api/lanes/:id/start`) appends the lane tool's catalog bypass args/env for that run only; Codex's replaces `-s`/`-a`, Claude has none. A lane whose tool is no longer in the catalog answers `400 ENOTOOL`.
**`POST /api/run` (start/attach):** Requires `laneId` (the lane this run belongs to). Creates or attaches an existing tmux session named `agenthub-lane-<id>` in the lane's working directory. If that session already exists and its pane is idling at a shell prompt, the appropriate command line (including `--resume` and any initial prompt) is typed into that pane instead of being dropped; if the pane is running a program, the request adopts the session unchanged. Optionally accepts `initialPrompt` to immediately type/send into the session (if empty or omitted, the session is created/attached with no initial input). Returns `{ id, laneId, status, cwd, model, permissionMode, effort, resumeSessionId, sessionId, startedAt, promptPreview }` where `id` is the tmux session name. The dashboard self-heals a lane's `run_id`/`status` on every read if the tmux session has been killed externally.
@@ -2094,6 +2096,16 @@ Every history row carries `lane_id` (the lane whose run it belongs to) and `tool
---
### AI Tools
```
GET /api/ai-tools Catalog + detection — { tools: [...], default }
POST /api/ai-tools/refresh Re-read the login shell's PATH, re-detect, same body
PUT /api/ai-tools/:id Body: { enabled?: boolean, default?: true } — same body
```
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. `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`.
### AI Router profiles
```
+3 -3
View File
@@ -232,8 +232,8 @@ A lane is a durable unit of parallel agent work — one working directory, many
| Command | Description |
| ------- | ----------- |
| `agenthub lanes` | List lanes with stage, status, liveness and progress |
| `agenthub lanes add --cwd <path> --title <text>` | Adopt an existing directory as a lane |
| `agenthub lanes add --repo <path> [--title <text>] [--base <branch>] [--slug <slug>]` | Provision a dashboard-managed git worktree as a new lane |
| `agenthub lanes add --cwd <path> --title <text> [--tool <id>]` | Adopt an existing directory as a lane; `--tool` is an AI Tools id (default `claude`) |
| `agenthub lanes add --repo <path> [--title <text>] [--base <branch>] [--slug <slug>] [--tool <id>]` | Provision a dashboard-managed git worktree as a new lane |
| `agenthub lanes profile init <repo> [--force]` | Detect a Node.js project (single-service or backend+frontend monorepo) and scaffold `.agenthub/profile/`. Refuses to overwrite an existing one without `--force` |
| `agenthub lanes pipeline [<template-id>] [<id>]` | Show which pipeline template a lane renders against, plus the available templates and their node counts (none until you install one in `DASHBOARD_PIPELINES_DIR`); pass a template id to switch. An unknown id is refused (`400 EBADPIPELINE`), and a switch that leaves the lane's current stage unresolvable warns |
| `agenthub lanes profile check [<path>]` | Validate a profile — parses, every referenced hook exists and is executable, no leftover `TODO:`, declared ports free. `<path>` defaults to the current directory (not a lane id) |
@@ -259,7 +259,7 @@ A lane is a durable unit of parallel agent work — one working directory, many
Omit `<id>` and the command addresses the lane owning the current directory, so a session running inside a lane never needs to know its own id. Only a leading all-digits argument is read as an id — `agenthub lanes logs web --tail 4096` addresses the lane by directory, not lane 4096.
Resolution order, shared by every cwd-resolving command: `--lane`/leading id → `AGENTHUB_LANE_ID` (exported into every tmux pane the dashboard starts) → `--tool claude|codex`, else the harness's marker (`CLAUDECODE` means claude; Codex has no equivalent marker, so a codex session outside a dashboard pane passes `--tool codex`) → the lane owning the directory. One directory can back a `claude` lane and a `codex` lane at once, so when two candidates remain the command fails and names both instead of picking one — see [Which lane a CLI command addresses](LANES.md#which-lane-a-cli-command-addresses).
Resolution order, shared by every cwd-resolving command: `--lane`/leading id → `AGENTHUB_LANE_ID` (exported into every tmux pane the dashboard starts) → `--tool <id>`, else the harness's marker (`CLAUDECODE` means claude; Codex has no equivalent marker, so a codex session outside a dashboard pane passes `--tool codex`) → the lane owning the directory. One directory can back a `claude` lane and a `codex` lane at once, so when two candidates remain the command fails and names both instead of picking one — see [Which lane a CLI command addresses](LANES.md#which-lane-a-cli-command-addresses).
Because a lane's services are fully detached, restarting the dashboard (or `agenthub update`) never stops a running stack.
+24 -4
View File
@@ -448,7 +448,7 @@ curl -X POST http://localhost:4820/api/lanes/ensure \
}
```
- If a lane already owns the `cwd` **for the requested `tool`**, returns that lane with `created: false` and HTTP `200`. `tool` (`claude`, `codex` or `antigravity`, default `claude`) is part of the identity: adopting the same `cwd` with a different `tool` creates a second, sibling lane rather than adopting the existing one — one `cwd` can back at most one `claude` lane and one `codex` lane at a time. This applies to Repo/Adopt mode only; Worktree mode always gets its own directory.
- If a lane already owns the `cwd` **for the requested `tool`**, returns that lane with `created: false` and HTTP `200`. `tool` (an AI Tools catalog id — `claude`, `codex`, `antigravity`, `gemini`, … — default `claude`) is part of the identity: adopting the same `cwd` with a different `tool` creates a second, sibling lane rather than adopting the existing one — one `cwd` can back at most one lane per tool. An unknown id answers `400 EBADTOOL`; creating a NEW lane for a tool disabled in Settings → AI Tools answers `400 EDISABLED` (an existing lane is still found). This applies to Repo/Adopt mode only; Worktree mode always gets its own directory.
- If the `cwd` is a subdirectory of an existing lane with that same tool, returns the parent lane that owns it. Pass `"exact": true` to skip that: only a lane on this very directory is returned, and a nested one is created as its own lane — the Add-lane modal does this, and shows which lane already owns the folder when the answer is `created: false`.
- If no lane owns the `(cwd, tool)` pair, creates an **adopted** lane and returns it with `created: true` and HTTP `201`.
- A relative or missing `cwd` returns `400 EBADCWD`.
@@ -866,7 +866,7 @@ This command:
### Full command signature
```bash
agenthub stage <stage> [--lane <id>] [--cwd <path>] [--tool claude|codex] [--status <s>] [--evidence <text>] [--note <text>] [--result pass|fail]
agenthub stage <stage> [--lane <id>] [--cwd <path>] [--tool <id>] [--status <s>] [--evidence <text>] [--note <text>] [--result pass|fail]
```
**Parameters:**
@@ -890,7 +890,7 @@ agenthub stage <stage> [--lane <id>] [--cwd <path>] [--tool claude|codex] [--sta
- `--cwd <path>` (optional): working directory to match against a lane's cwd. If omitted, uses the current working directory. Useful when calling from outside the repo.
- `--tool claude|codex` (optional): which tool's lane to address when one directory backs both (see "Which lane a CLI command addresses" below).
- `--tool <id>` (optional): which tool's lane to address when one directory backs both (see "Which lane a CLI command addresses" below).
- `--status <s>` (optional): the lane's overall status (`running`, `idle`, `provisioning`). Allows bundling a status update with the stage report.
@@ -953,7 +953,7 @@ activate`, `lanes up/down/logs`, …) shares one resolver, `resolveLaneArg` in
carries it (`tmux new-session -e`, or an assignment prefix when the argv is
typed into a pane already at a shell prompt). A session inside a
dashboard-started pane therefore never has to guess.
3. `--tool claude|codex`, then the harness's own marker: Claude Code exports
3. `--tool <id>`, then the harness's own marker: Claude Code exports
`CLAUDECODE`/`CLAUDE_CODE_ENTRYPOINT`, so a Claude session identifies itself
for free. **Codex has no equivalent marker** (it only reads `CODEX_HOME`, and
sets `CODEX_SANDBOX*` under a sandbox policy), so a codex session outside a
@@ -1657,3 +1657,23 @@ agenthub stage <stage> [--evidence "..."] # Report the current stage
- `server/routes/lanes.js` — REST API surface
- `bin/agenthub.js` — CLI commands for lanes and stage reporting
- `server/routes/hooks.js` — hook-to-lane binding by cwd, and where detection is invoked
## Lanes on any AI tool
A lane can run any AI coding CLI in the AI Tools catalog (`server/data/ai-tools/*.json`, plus any `*.json` in `DASHBOARD_AI_TOOLS_DIR`, which replaces a built-in file with the same `id` but may not set `launcher`). Settings → Dashboard → **AI Tools** lists what is installed (found on PATH; **Refresh** re-reads the login shell's PATH first) with **Enabled/Disabled** and **Set default**, and what is available to install with a homepage link. The Add-lane modal offers **Default (<tool>)** and **Other…**; `agenthub lanes add --tool <id>` does the same from a terminal.
A catalog file:
```json
{
"id": "antigravity",
"label": "Antigravity",
"detect": ["agy"],
"homepageUrl": "https://antigravity.google/docs/cli/overview",
"prompt": { "mode": "flag", "flag": "-i" },
"resume": ["--continue"],
"bypass": ["--dangerously-skip-permissions"]
}
```
`detect` lists bare command names (the first found on PATH is what runs), `requires` other commands that must be present, `args` fixed leading args, `prompt.mode` one of `argv` (optional `separator: "--"`), `flag` (`flag`), or `after-start` (`delayMs`, default 3000 — the prompt is pasted into the pane once the CLI is up). `resume` enables **Resume last session**; `bypass`/`bypassEnv` enable the run form's per-run **Bypass permissions** switch, which is off every time the form opens. Every non-Claude lane has no pipeline, no agents install and no AI Router (native only, except Codex); running/idle comes from the exited-pane heal sweep.
+160
View File
@@ -60,6 +60,8 @@ tags:
description: Durable parallel-work lanes and dashboard-managed git worktrees
- name: Push
description: Web Push notification subscriptions and broadcast (VAPID); also fires native Electron notifications when hosted in the desktop app
- name: AI Tools
description: The catalog of AI coding CLIs a lane can run (server/data/ai-tools/*.json + DASHBOARD_AI_TOOLS_DIR), what is installed on PATH, and the user's enable/default choice
components:
parameters:
SessionIdPath:
@@ -4078,6 +4080,91 @@ components:
type: string
description: Response body or error message, truncated to 200 characters.
example: UnauthorizedRegistration
AiToolsTool:
type: object
required:
- id
- label
- installed
- enabled
- source
properties:
id:
type: string
example: antigravity
label:
type: string
example: Antigravity
command:
type: string
nullable: true
example: agy
homepageUrl:
type: string
nullable: true
installed:
type: boolean
path:
type: string
nullable: true
example: /home/u/.local/bin/agy
enabled:
type: boolean
source:
type: string
enum:
- builtin
- custom
launcher:
type: string
nullable: true
enum:
- claude
- codex
- null
prompt:
type: object
nullable: true
properties:
mode:
type: string
enum:
- argv
- flag
- after-start
resume:
type: boolean
description: Offers Resume last session
bypass:
type: boolean
description: Offers the per-run Bypass permissions switch
routerCapable:
type: boolean
description: Can use an AI Router profile
AiToolsView:
type: object
required:
- tools
- default
properties:
tools:
type: array
items:
$ref: '#/components/schemas/AiToolsTool'
default:
type: string
nullable: true
description: Effective default tool id
AiToolsError:
type: object
properties:
error:
type: object
properties:
code:
type: string
message:
type: string
SessionsFacetsResponse:
type: object
description: 'Facet values for the Sessions page filter UI: the distinct working directories (cwd) and the distinct data-source ids seen across all sessions.'
@@ -11442,6 +11529,79 @@ paths:
example:
error:
message: Push service unavailable
/api/ai-tools:
get:
tags:
- AI Tools
summary: List the AI Tools catalog
description: Every catalog tool with whether it was found on PATH, whether it is enabled, and the effective default (stored default if installed+enabled, else claude, else the first installed enabled tool, else null). Arg arrays are never returned.
operationId: aiToolsList
responses:
'200':
description: The catalog with detection and the effective default
content:
application/json:
schema:
$ref: '#/components/schemas/AiToolsView'
/api/ai-tools/refresh:
post:
tags:
- AI Tools
summary: Re-detect installed tools
description: Merges the login shell's PATH ($SHELL -ilc, 5 s timeout, off with AGENTHUB_SHELL_PATH=0) into the server's, re-reads the catalog and re-detects. Same-origin only.
operationId: aiToolsRefresh
responses:
'200':
description: The catalog with detection and the effective default
content:
application/json:
schema:
$ref: '#/components/schemas/AiToolsView'
/api/ai-tools/{id}:
put:
tags:
- AI Tools
summary: Enable/disable a tool or make it the default
description: A disabled tool is hidden from Add lane and a NEW lane for it answers 400 EDISABLED; existing lanes keep working. Disabling the default clears it. Same-origin only.
operationId: aiToolsUpdate
parameters:
- name: id
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
enabled:
type: boolean
default:
type: boolean
enum:
- true
responses:
'200':
description: The catalog with detection and the effective default
content:
application/json:
schema:
$ref: '#/components/schemas/AiToolsView'
'400':
description: EBADDEFAULT (not installed or disabled) or EBADFIELD
content:
application/json:
schema:
$ref: '#/components/schemas/AiToolsError'
'404':
description: ENOTOOL — unknown id
content:
application/json:
schema:
$ref: '#/components/schemas/AiToolsError'
/api/sessions/facets:
get:
tags:
+8 -1
View File
@@ -535,6 +535,9 @@ Request body shape:
| `GET` | `/api/git-credentials` | AgentHub's own git logins (one per host, shared by every user), `hasToken` only — never a password. |
| `POST` | `/api/git-credentials` | Same-origin guarded save/replace: `{protocol, host, username, password}`. `400 EBADHOST`/`EBADCRED`. |
| `DELETE` | `/api/git-credentials/:protocol/:host` | Same-origin guarded delete. `404 ENOTFOUND`. |
| `GET` | `/api/ai-tools` | The AI Tools catalog (`server/lib/ai-tools.js`: `server/data/ai-tools/*.json` + `DASHBOARD_AI_TOOLS_DIR`) with detection, enabled flag and the effective default. Flags only, never arg arrays. |
| `POST` | `/api/ai-tools/refresh` | Same-origin guarded: merge the login shell's PATH (`server/lib/shell-path.js`), re-detect. |
| `PUT` | `/api/ai-tools/:id` | Same-origin guarded `{enabled?, default?: true}`. `404 ENOTOOL`, `400 EBADDEFAULT`/`EBADFIELD`. |
| `POST` | `/api/lanes/:id/git/:action` | Same-origin guarded git write, serialized under the lane lock: `fetch`, `pull` (always `--ff-only`), `push` (`-u origin HEAD`), `stage`/`unstage`/`discard` (per file; a path must be one git reports changed, and `discard` needs `confirm:true`), `stage-hunk`/`discard-hunk` (one hunk by its `@@` line, re-read fresh — `409 EHUNKGONE` when it moved; `discard-hunk` needs `confirm:true`), `commit` (`add -A` + `-m`, or `only:"staged"`), `switch` (refuses a dirty tree), `branch-create` (never switches) `branch-delete` (`-d`, refuses protected/current/busy branches) and `publish` (`{url}`: `remote add origin` + `push -u origin HEAD`, only when the repo has no remote; URL allowlisted to https-without-userinfo/ssh/scp-like; remote rolled back on a failed push). `merge` (clean tree, repo-root lane, a ref that exists; conflicts come back as `conflicts[]`), `resolve` (`side: ours|theirs|done`, conflicted paths only, `done` refuses leftover markers), `merge-commit`, `merge-abort` (`confirm:true`); `EMERGING` blocks pull/switch/merge/hunk actions mid-merge. No rebase/reset/stash by design. A remote wanting a login is `409 EGITAUTH` with `auth: {protocol, host, username, rejected}`; resend with `credentials: {protocol, host, username, password, remember}` (a repo remote's host only, else `400 EBADHOST`; saved only after git succeeds and `remember` is set). |
The route accepts `{ sourceRepo, title, base?, slug? }`. `sourceRepo` must be
@@ -679,7 +682,7 @@ Reads — and carefully gated mutations for low-risk text-file artifacts — for
### Run a lane's CLI (`/api/run`)
HTTP surface for the tmux-backed terminal run: one session per lane, named `agenthub-lane-<laneId>`, running the CLI that lane declares (`claude`, `codex`, or `agy` for an `antigravity` lane). There is no in-memory handle map — every read is computed from `tmux list-sessions`, so a session killed out-of-band self-corrects on the next call. Every route enforces a same-origin / loopback-Origin guard against browser CSRF.
HTTP surface for the tmux-backed terminal run: one session per lane, named `agenthub-lane-<laneId>`, running the CLI that lane declares — any AI Tools catalog id: `claude`/`codex` through their own argv builders, every other tool by its detected absolute path with the args its catalog file gives (an `after-start` prompt is pasted into the pane through a tmux buffer once the CLI is up; `bypass: true` adds the file's bypass args for that run only). There is no in-memory handle map — every read is computed from `tmux list-sessions`, so a session killed out-of-band self-corrects on the next call. Every route enforces a same-origin / loopback-Origin guard against browser CSRF.
| Method | Path | Description |
| -------- | ----------------------------- | ----------- |
@@ -1516,6 +1519,10 @@ DASHBOARD_ALLOWED_HOSTS= # Extra Host-header names to allow (comma-sep
# Database
DASHBOARD_DB_PATH=./data/dashboard.db # SQLite database path
# AI Tools
DASHBOARD_AI_TOOLS_DIR= # Extra AI Tools catalog JSON files; an id here replaces the built-in one
AGENTHUB_SHELL_PATH=1 # 0 = never run the login shell to read its PATH (startup + Refresh)
# Background services
DASHBOARD_SESSION_SYNC_MS=30000 # Continuous project-sync poll interval (ms); 0 disables the poll (watcher stays)
DASHBOARD_LIVENESS_PROBE=1 # 0 disables the dead-session liveness reap (use when hooks arrive from another machine)
+2
View File
@@ -15,6 +15,7 @@ const codexConfig = require("./openapi-extra/codex-config");
const run = require("./openapi-extra/run");
const lanes = require("./openapi-extra/lanes");
const push = require("./openapi-extra/push");
const aiTools = require("./openapi-extra/ai-tools");
const misc = require("./openapi-extra/misc");
// Enriched overrides of already-documented endpoints — same operationId, tags,
// and request/response `$ref` schemas as the base, with added examples and
@@ -49,6 +50,7 @@ module.exports = combine(
run,
lanes,
push,
aiTools,
misc,
overrideSessionsAgents,
overrideCore,
+128
View File
@@ -0,0 +1,128 @@
/**
* @file Supplementary OpenAPI 3.0 fragments for the AI Tools routes mounted at
* `/api/ai-tools` (see server/routes/ai-tools.js + server/lib/ai-tools.js):
* list the catalog with detection, re-detect after hydrating PATH, and
* enable/disable a tool or make it the default. Exports `{ tags, schemas,
* paths }` for server/openapi-extra.js; schemas are prefixed `AiTools`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const tags = [
{
name: "AI Tools",
description:
"The catalog of AI coding CLIs a lane can run (server/data/ai-tools/*.json + DASHBOARD_AI_TOOLS_DIR), what is installed on PATH, and the user's enable/default choice",
},
];
const errorRef = { $ref: "#/components/schemas/AiToolsError" };
const schemas = {
AiToolsTool: {
type: "object",
required: ["id", "label", "installed", "enabled", "source"],
properties: {
id: { type: "string", example: "antigravity" },
label: { type: "string", example: "Antigravity" },
command: { type: "string", nullable: true, example: "agy" },
homepageUrl: { type: "string", nullable: true },
installed: { type: "boolean" },
path: { type: "string", nullable: true, example: "/home/u/.local/bin/agy" },
enabled: { type: "boolean" },
source: { type: "string", enum: ["builtin", "custom"] },
launcher: { type: "string", nullable: true, enum: ["claude", "codex", null] },
prompt: {
type: "object",
nullable: true,
properties: { mode: { type: "string", enum: ["argv", "flag", "after-start"] } },
},
resume: { type: "boolean", description: "Offers Resume last session" },
bypass: { type: "boolean", description: "Offers the per-run Bypass permissions switch" },
routerCapable: { type: "boolean", description: "Can use an AI Router profile" },
},
},
AiToolsView: {
type: "object",
required: ["tools", "default"],
properties: {
tools: { type: "array", items: { $ref: "#/components/schemas/AiToolsTool" } },
default: { type: "string", nullable: true, description: "Effective default tool id" },
},
},
AiToolsError: {
type: "object",
properties: {
error: {
type: "object",
properties: { code: { type: "string" }, message: { type: "string" } },
},
},
},
};
const ok = {
200: {
description: "The catalog with detection and the effective default",
content: { "application/json": { schema: { $ref: "#/components/schemas/AiToolsView" } } },
},
};
const paths = {
"/api/ai-tools": {
get: {
tags: ["AI Tools"],
summary: "List the AI Tools catalog",
description:
"Every catalog tool with whether it was found on PATH, whether it is enabled, and the effective default (stored default if installed+enabled, else claude, else the first installed enabled tool, else null). Arg arrays are never returned.",
operationId: "aiToolsList",
responses: ok,
},
},
"/api/ai-tools/refresh": {
post: {
tags: ["AI Tools"],
summary: "Re-detect installed tools",
description:
"Merges the login shell's PATH ($SHELL -ilc, 5 s timeout, off with AGENTHUB_SHELL_PATH=0) into the server's, re-reads the catalog and re-detects. Same-origin only.",
operationId: "aiToolsRefresh",
responses: ok,
},
},
"/api/ai-tools/{id}": {
put: {
tags: ["AI Tools"],
summary: "Enable/disable a tool or make it the default",
description:
"A disabled tool is hidden from Add lane and a NEW lane for it answers 400 EDISABLED; existing lanes keep working. Disabling the default clears it. Same-origin only.",
operationId: "aiToolsUpdate",
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
properties: {
enabled: { type: "boolean" },
default: { type: "boolean", enum: [true] },
},
},
},
},
},
responses: {
...ok,
400: {
description: "EBADDEFAULT (not installed or disabled) or EBADFIELD",
content: { "application/json": { schema: errorRef } },
},
404: {
description: "ENOTOOL — unknown id",
content: { "application/json": { schema: errorRef } },
},
},
},
},
};
module.exports = { tags, schemas, paths };