diff --git a/CLAUDE.md b/CLAUDE.md index 2bdbf3b2..a794cc39 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Claude Code Working Guide -_Last synced: v1.5.6 (2026-09-11) + the systemd handover rule below (`scripts/systemd-service.js`'s `writeUserService`/`restartUserService` split and `plugin-bootstrap.js`'s deferred `pendingSystemdRestart`) + eb63569 (2026-09-10) + release-based update detection (`server/lib/update-check.js` reads the forge releases API — a checkout via its `origin` remote, a plugin-cache install via the marketplace manifest on disk, since an archive-sourced cache dir has no `.git`; a `plugin_managed` install self-updates via the detached `plugin-update` chain) + the install-wide **Update all** batch and session reload (`server/lib/cc-update-all.js`, `server/lib/cc-reload.js`)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. +_Last synced: v1.6.0 (2026-09-14) + AI Router profiles (`server/lib/ai-routers.js`, `server/lib/router-apply.js`, the Settings tab `/settings?tool=ai-router`, and `lanes.router_profile`) + v1.5.6 (2026-09-11) + the systemd handover rule below (`scripts/systemd-service.js`'s `writeUserService`/`restartUserService` split and `plugin-bootstrap.js`'s deferred `pendingSystemdRestart`) + eb63569 (2026-09-10) + release-based update detection (`server/lib/update-check.js` reads the forge releases API — a checkout via its `origin` remote, a plugin-cache install via the marketplace manifest on disk, since an archive-sourced cache dir has no `.git`; a `plugin_managed` install self-updates via the detached `plugin-update` chain) + the install-wide **Update all** batch and session reload (`server/lib/cc-update-all.js`, `server/lib/cc-reload.js`)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. ## Project mission - Maintain a reliable local-first dashboard for Claude Code session monitoring. @@ -36,6 +36,7 @@ A **lane** is a durable unit of parallel agent work — one working directory, m - Liveness: a silent **watcher** (stage matching `/watch|poll/`) is dead after `LANE_DEAD_SEC` seconds (default 300); a silent **idle** lane is at rest, not dead. - **Never `rm -rf` a lane.** Destructive lane operations (`reset`, `remove`, `purge`) go through `server/lib/worktree.js`/`server/lib/lanes.js`, never a raw filesystem delete — that's what keeps the three-check destroy guard and the DB bookkeeping in lockstep. - **Never build a git command as a shell string.** Lane git operations use `execFile("git", [...args])` with an explicit argument array (see `server/lib/worktree.js`'s `git()` helper), never a concatenated/interpolated string passed to a shell. +- **A lane's AI Router profile is a lane fact too, and it never travels alone.** `lanes.router_profile` (nullable; null means the same as `native`) is written ONLY by `setRouterProfile`, called beside the disk write in `POST /api/lanes/:id/router/apply` — never through `PATCHABLE`. The column and the lane's own config must move together, or the row claims a router the lane's `settings.local.json` has never heard of. What is written comes from the lane row, never the request: a **claude** lane gets eight `env` keys merged into `/.claude/settings.local.json` (`.local` because the value carries an API key; every other key in that file is preserved and a non-JSON file is refused, not rewritten), a **codex** lane gets `$CODEX_HOME/.config.toml` — the layer `codex -p ` loads — because Codex has NO project-level config, and its base `config.toml` is never touched. Key material never leaves the server: profiles live at `/agenthub-routers/` (0700/0600), `GET /api/ai-routers` reports only `hasKey`, and there is deliberately no route that returns one profile in full. **Codex's key reaches the pane through tmux's `-e` argv and is never typed into it** — `tmux.sendCommand` takes over an idle pane with `respawn-pane` for exactly this reason; a `KEY='sk-…' codex …` line typed with `send-keys` would be rendered to every attached browser, kept in the scrollback and appended to the user's shell history. Do not reintroduce a typed command line there. **And attaching COPIES a profile**, so the store stops being the single source of truth the moment a lane exists: `PATCH`/`DELETE /api/ai-routers/:name` must push through to the lanes naming it (`reapplyToLanes`/`detachFromLanes`), or a rotated key silently never reaches the lane still using the revoked one. See `server/lib/ai-routers.js`, `server/lib/router-apply.js`. - **A lane's `tool` is a lane fact, immutable, and never read from a request.** `lanes.tool` (`claude` | `codex`, default `claude`) is set by `createLane` only — it is deliberately absent from `PATCHABLE`, so no PATCH can flip the CLI under a live pane. Every spawn path (`POST /api/lanes/:id/start`, `POST /api/run`) takes the tool from the lane row and ignores whatever the body says; the run panel's Codex-only fields (`sandbox`, `approval`, `resumeLast`) are validated against the fixed allowlists in `server/lib/pty-run.js` before they reach an argv, and the bypass flags (`--dangerously-bypass-approvals-and-sandbox`, `--dangerously-bypass-hook-trust`) are never emitted. `GET /api/run/binary?tool=` resolves the probed binary name from that same allowlist — the query string reaches `which`. - **Adopted lanes are not destroyable.** A lane with `kind === "adopted"` may never have its worktree reset or removed; "removing" one only drops the dashboard's own record of it. This is enforced in code (`assertDestroyable`'s first check) — do not add a path that bypasses it. - **A `cwd` can back at most one lane per tool, not one lane overall.** Uniqueness is the composite `idx_lanes_cwd_tool` (`cwd`, `tool`), not `cwd` alone — Repo/Adopt mode (`POST /api/lanes/ensure`) lets the same directory carry a `claude` lane and a `codex` lane side by side, shown as two separate cards. `resolveLaneByCwd(cwd, tool)` takes an optional `tool` filter for exactly this; hook ingestion (`touchLaneFromHook`, `resolveLaneForWork`) always pins it to `"claude"` because Codex has no hook mechanism in this codebase, so a hook event must never touch a `codex` lane sharing the path. Worktree mode is unaffected — it already allocates a unique directory per lane. diff --git a/client/src/components/lanes/AddLaneModal.tsx b/client/src/components/lanes/AddLaneModal.tsx index ac9a0831..52549bc6 100644 --- a/client/src/components/lanes/AddLaneModal.tsx +++ b/client/src/components/lanes/AddLaneModal.tsx @@ -26,7 +26,7 @@ import { ConfirmModal } from "../ConfirmModal"; import { CwdAutocomplete } from "../run/RunSetup"; import { FolderBrowseModal } from "./FolderBrowseModal"; import { api } from "../../lib/api"; -import type { CwdSuggestion } from "../../lib/api"; +import type { AiRouter, CwdSuggestion } from "../../lib/api"; import type { Lane, LaneTool } from "../../lib/types"; /** Polls the lane until the background `git worktree add` finishes (status @@ -97,6 +97,10 @@ export function AddLaneModal({ const [base, setBase] = useState(""); const [pipeline, setPipeline] = useState("default"); const [writeDocs, setWriteDocs] = useState(false); + // "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([]); + const [routersLoaded, setRoutersLoaded] = useState(false); const [pipelines, setPipelines] = useState<{ id: string; name: string; nodes: unknown[] }[]>([]); const [branchesError, setBranchesError] = useState(null); const [tool, setTool] = useState("claude"); @@ -107,6 +111,7 @@ export function AddLaneModal({ profile: "scaffolded" | "skipped" | "failed"; agents: "ok" | "failed"; mcp: "ok" | "failed"; + router: "ok" | "skipped" | "failed"; } | null>(null); const reset = () => { @@ -118,6 +123,8 @@ export function AddLaneModal({ setBase(""); setPipeline("default"); setWriteDocs(false); + setRouter("native"); + setRoutersLoaded(false); setTool("claude"); setBranchesError(null); setError(null); @@ -142,6 +149,26 @@ export function AddLaneModal({ // below, and the lane still gets created. if (!cancelled) setPipelines([]); }); + 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; }; @@ -189,6 +216,10 @@ export function AddLaneModal({ const startDocsRun = (laneId: number, cwd: string) => api.run.start({ laneId, cwd, initialPrompt: "/sg-all:sg-md-writing" }); + // 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 repo = sourceRepo.trim(); const name = title.trim(); @@ -204,8 +235,29 @@ export function AddLaneModal({ pipeline, tool, }); - if (writeDocs && tool === "claude") await startDocsRun(result.lane.id, result.lane.cwd); + // Report the lane the INSTANT it exists server-side. Everything after + // this is post-creation setup that can fail on its own, and letting one + // of those failures skip `onAdded` would leave a real lane invisible in + // the list until some unrelated refetch happened to pick it up. + // Worktree mode below already works this way. onAdded(result.lane); + + // Router first: a session that booted on the vendor's API would keep + // talking to it for its whole life. + const [routerOutcome, docsOutcome] = await Promise.allSettled([ + applyRouter(result.lane.id), + writeDocs && tool === "claude" + ? startDocsRun(result.lane.id, result.lane.cwd) + : Promise.resolve(null), + ]); + const failed = [routerOutcome, docsOutcome].find((o) => o.status === "rejected"); + if (failed && failed.status === "rejected") { + // The lane exists and is already listed — name what did not finish + // instead of closing on a half-set-up lane. + setError(failed.reason instanceof Error ? failed.reason.message : String(failed.reason)); + setBusy(false); + return; + } reset(); onClose(); } catch (err) { @@ -239,10 +291,11 @@ export function AddLaneModal({ return; } - const [profileOutcome, agentsOutcome, mcpOutcome] = await Promise.allSettled([ + const [profileOutcome, agentsOutcome, mcpOutcome, routerOutcome] = await Promise.allSettled([ api.lanes.profileInit(result.lane.id), api.lanes.agentsInstall(result.lane.id), api.lanes.mcpSync(result.lane.id), + applyRouter(result.lane.id), ]); setSetupResult({ profile: @@ -253,6 +306,8 @@ export function AddLaneModal({ : "failed", agents: agentsOutcome.status === "fulfilled" ? "ok" : "failed", mcp: mcpOutcome.status === "fulfilled" ? "ok" : "failed", + router: + routerOutcome.status !== "fulfilled" ? "failed" : router === "native" ? "skipped" : "ok", }); if (writeDocs && tool === "claude") { @@ -265,6 +320,9 @@ export function AddLaneModal({ profileOutcome.status === "fulfilled" ? profileOutcome.value : profileOutcome.reason, agents: agentsOutcome.status === "fulfilled" ? agentsOutcome.value : agentsOutcome.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, }); } @@ -407,6 +465,75 @@ export function AddLaneModal({

{t("addLanePipelineHint")}

+
+ {t("addLaneRouterLabel")} +
+ {( + [ + { value: "native", label: t("addLaneRouterNative") }, + { value: "router", label: t("addLaneRouterCustom") }, + ] as const + ).map((option) => { + const active = option.value === "native" ? router === "native" : router !== "native"; + return ( + + ); + })} +
+ + {router !== "native" && ( + + )} +

+ {!routersLoaded + ? t("addLaneRouterLoading") + : routers.some((r) => !r.builtin) + ? t("addLaneRouterHint") + : t("addLaneRouterEmptyHint")} +

+
+ {tool === "claude" && (