diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 95d8060c..acdf35a3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -346,8 +346,9 @@ graph TD | `lib/cc-mutate.js` | Create / overwrite / delete for the **low-risk text-file surfaces only** (skills, subagents, slash commands, output styles, memory — including the per-project file-based auto-memory store, mutated via `scope: "auto-memory"`, `type: "auto-memory"`, `project`, `name`, with its backups landing in `/.cc-config-backups/auto-memory/`), plus `writeKeybindings()` for the structured `keybindings.json` editor (read-modify-write that preserves top-level metadata, rejects duplicate contexts/keys, and backs up to `/cc-config-backups/keybindings/`). Plugins, MCP, hooks-in-settings, and `settings.json` files are NEVER written from here — they have concurrent-write races with the live Claude Code CLI. Every mutation creates a timestamped backup at `/cc-config-backups//..bak[.dir]` BEFORE the change — backups land outside the directories Claude Code scans, so a deleted skill cannot resurface as a backup-named one. Writes are atomic: temp file in same dir → fsync → `renameSync`. Tmp removed on every failure path. Skill dirs are backed up whole (preserving bundled assets) before recursive removal. Strict `name` regex (`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`), 256 KB content cap, double-checked path containment via `isUnder()` | | `lib/cc-reload.js` | Types `/reload-plugins` / `/reload-skills` into the Claude Code sessions the dashboard already has open, over `tmux send-keys` (reusing `tmux.js`'s `sendText`). Both slash commands are scoped BY CLAUDE CODE to the session that runs them, so a session cannot be reloaded from the outside and no background process can do it on a session's behalf — typing into a live pane is the only real mechanism. Deliberately spawns NO `claude` process: a one-shot `claude -p "/reload-plugins"` would reload only its own short-lived session, exit, and leave a junk session in the dashboard's history, so this module creates no sessions at all. Skips panes sitting at a shell prompt (the text would run as a shell command) and addresses `claude` lanes only, since Codex has no such command. Sends to a busy pane on purpose — tmux queues the keystrokes into the REPL's input buffer, so a late reload still lands, where skipping busy panes would leave exactly the sessions in active use running stale code. The command set is a fixed constant, never caller-supplied: a pane's stdin is an arbitrary-code boundary. Never throws | | `lib/cc-update-all.js` | Starts and reports on the "Update all" batch, whose work lives in the detached `scripts/cc-update-all-helper.js`. Detached for a structural reason: the batch ends with the plugin THIS dashboard runs from, and that step's `plugin-bootstrap.js --force` SIGTERMs this very server, so an in-process loop would die with the remaining plugins unrun and nothing left to report. Order is marketplaces → every other plugin → AgentHub last → one reload. The worker journals every step transition to `cc-update-all.json` under the runtime dir — outside the plugin cache the update replaces — so whichever server is alive can serve progress from it; the POST's response is only an acknowledgement. `readUpdateAll()` DERIVES an `abandoned` status for a `running` journal older than `STALE_MS` (1 h), because a detached worker killed by a reboot leaves nobody to write the terminal entry and the button would otherwise stay wedged forever. `isRunning()` gates a concurrent batch, since two `claude plugin update` chains over one config dir race | -| `routes/cc-config.js` | HTTP surface for the Claude Config Explorer. Read endpoints for every surface (skills, agents, commands, output-styles, plugins, marketplaces, mcp, hooks, hook-scripts, keybindings, statusline, settings, memory, file, overview), plus mutation endpoints (`PUT /file`, `DELETE /file`, and a structured `PUT /keybindings`) that delegate to `cc-mutate.js`, plus a `GET /backups` listing for the recovery modal, plus the auth-profile routes (`GET/POST/PATCH/DELETE /profiles`, `POST /profiles/activate`) that delegate to `cc-profiles.js`, plus the install-wide update surface: `GET/POST /plugins/update-all` (a DETACHED batch — every marketplace, then every plugin, then a reload, with THIS dashboard's own plugin last because its bootstrap restarts the server; progress is polled from an on-disk journal, never from the POST's response) and `POST /reload` (types `/reload-plugins`+`/reload-skills` into the live lane panes; see `cc-reload.js`). After every successful PUT/DELETE the route broadcasts `cc_config_changed` over the WebSocket so any open `/cc-config` tab refetches without polling. All errors return structured `{error: {code, message}}` shapes mapped to 400/404/413/500 statuses | +| `routes/cc-config.js` | HTTP surface for the Claude Config Explorer. Read endpoints for every surface (skills, agents, commands, output-styles, plugins, marketplaces, mcp, hooks, hook-scripts, keybindings, statusline, settings, memory, file, overview), plus mutation endpoints (`PUT /file`, `DELETE /file`, and a structured `PUT /keybindings`) that delegate to `cc-mutate.js`, plus a `GET /backups` listing for the recovery modal, plus the auth-profile routes (`GET/POST/PATCH/DELETE /profiles`, `POST /profiles/activate`) that delegate to `cc-profiles.js`, plus `POST /profiles/usage` (a POST because answering it refreshes and rewrites that profile's stored OAuth tokens — see `cc-usage.js`), plus the install-wide update surface: `GET/POST /plugins/update-all` (a DETACHED batch — every marketplace, then every plugin, then a reload, with THIS dashboard's own plugin last because its bootstrap restarts the server; progress is polled from an on-disk journal, never from the POST's response) and `POST /reload` (types `/reload-plugins`+`/reload-skills` into the live lane panes; see `cc-reload.js`). After every successful PUT/DELETE the route broadcasts `cc_config_changed` over the WebSocket so any open `/cc-config` tab refetches without polling. All errors return structured `{error: {code, message}}` shapes mapped to 400/404/413/500 statuses | | `lib/cc-profiles.js` | Named Claude Code **auth profiles** — swap which Anthropic account (and therefore which quota) the local `claude` CLI runs as. A profile is exactly two identity artifacts: `claudeAiOauth` out of `/.credentials.json`, and `oauthAccount` + `userID` out of the `.claude.json` config file(s) (`.json` and `/.claude.json`, each key-merged in place and only when it already exists, so `projects`/`numStartups`/everything else survives). Skills, subagents, commands, memory, plugins, marketplaces, MCP servers and settings are never read or written here — that is what makes a switch keep the whole workspace and change only the account; `mcpOAuth` stays put too (machine MCP tokens, not account identity). Stored one JSON file per profile under `/agenthub-profiles/` (dir `0700`, files `0600`), holding live OAuth tokens, so **no route ever returns token material** — `listProfiles()` answers identity metadata only. A profile carries two names: `name`, the filename and the id every route takes, and `label`, the free-form display name (spaces and diacritics fine, since it never touches a path) that `renameProfile()` edits in place without moving the file. One account is one profile — `saveProfile()` refuses `EDUPLICATE` rather than making a second copy, because duplicates both report themselves active, and `deleteProfile()` blocks only the LAST copy of the live account so a duplicate stays cleanable. Cannot mint credentials (only `claude auth login` can): a new profile is captured by logging in, then snapshotting. `activateProfile()` re-snapshots the outgoing account first so refreshed tokens are not lost, and refuses (`EUNSAVED`) to switch away from an account that was never saved — that would discard the only copy of its credentials. Atomic temp + rename at `0600` | +| `lib/cc-usage.js` | How much of an account's quota is spent, for the profile switcher — the number that decides which saved profile is worth switching to. No local source exists (the credentials file has `subscriptionType`/`rateLimitTier` and nothing else), so it asks `GET https://api.anthropic.com/api/oauth/usage`, the same endpoint the CLI's own `/usage` reads, with that profile's OAuth token. Two rules: a profile's token is sent to Anthropic and nowhere else — what leaves the module is two percentages and two reset times, never token material — and **nothing here runs on a poll**. Any profile that is not the active one normally holds an expired access token, so answering means refreshing it, and the refresh ROTATES the stored refresh token; the new pair is written back through `cc-profiles.writeProfile()` because a dropped refresh leaves an account that needs a fresh `claude auth login`. A still-valid token is used as-is and rewrites nothing; a 401 on a token that looked valid is retried once through the refresh | | `lib/cc-watcher.js` | Best-effort `fs.watch` over `~/.claude/` (recursive where the platform / Node version honors it — macOS / Windows always; Linux from Node 20) plus `~/.claude.json`. Coalesces bursts at 500 ms and broadcasts `cc_config_changed` with `{ source: "fs", paths: [...] }` so the Config Explorer picks up changes from external tools (CLI installs a plugin, manual `settings.json` edits, dropping a new skill) without a manual refresh. Started from `server/index.js` after the HTTP server boots; failures are caught and logged so a flaky watcher can't take the server down | | `lib/codex-discovery.js` | Read-only discovery of the Codex CLI's file-backed config surfaces for the Codex Config panel — the Codex counterpart of `cc-discovery.js`. Surfaces: skills (`/skills//SKILL.md`), agents (`/agents/*.toml`), rules (`/rules/*`), memory (`AGENTS.md` at user `~/.codex` + project root), config (`config.toml` at user + project `.codex/`). No filesystem watcher counterpart to `cc-watcher.js` exists for Codex. `readFileSafe()` re-validates path containment (leaf/ancestor symlink-escape guard) independent of the write side's own check. Codex's scope union is `"user"` \| `"project"` only — it has no `"project-local"` settings layer and no per-project file-based auto-memory store | | `lib/codex-mutate.js` | Create / overwrite / delete for the Codex file-backed artifacts (skills, agents, rules, memory, config) — the Codex counterpart of `cc-mutate.js`. `name` is required for skills/agents/rules and rejected (`EBADNAME`) for memory/config, since each is a single fixed file per scope. Every mutation writes a timestamped backup first (`listBackups()` powers `GET /backups`); `project` scope always resolves against the server's own `cwd`, never a caller-supplied override | diff --git a/CLAUDE.md b/CLAUDE.md index 742622f4..9d97a950 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Claude Code Working Guide -_Last synced: v1.6.8 (2026-09-17) + a run being opened by a DECLARATION only — a backwards DETECTION now continues the run in flight (`appendStageEvent` in `server/lib/lanes.js`), because detection bounces within one feature and `runs` is what every pass rate on the Workflows page is divided by + `SETTLE_MS` in `server/lib/cc-reload.js` — the two reload commands are typed a second apart, and `reloadOpenSessions` is now async, because Claude Code swallows the Enter after a slash command and back-to-back sends merged into one composer line + v1.6.0 (2026-09-14) + the lane strip's drag/Alt-arrow reordering and inline rename (`client/src/lib/laneOrderStorage.ts`, `LaneStripCard`'s `index`/`onReorder` props, the Workspace detail header's rename field, and `normalizeTitle`/`bindable` in `server/lib/lanes.js`) + the uncommitted-files hover panel (`LaneGitFilesPanel` in `LaneCard`, and `files` from `GET /api/lanes/:id/git` now being `{path,status,added,deleted}` records off one extra `git diff --numstat HEAD`) + AI Router profiles (`server/lib/ai-routers.js`, `server/lib/router-apply.js`, the Settings tab `/settings?tool=ai-router`, its **Test** button / `POST /api/ai-routers/test`, the per-lane router select — labelled **Model**, `LaneRouterRow` exported from `LaneCard` and rendered in the Workspace lane-detail header row — and `lanes.router_profile`) + the bootstrap's superseded-build guard and plugin-cache pruner (`supersededBy`/`pruneRetiredPluginRoots` in `scripts/plugin-bootstrap.js`, `unitRoot` in `scripts/systemd-service.js`) + 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.9 (2026-09-17) + per-profile Claude quota (`server/lib/cc-usage.js`, `POST /api/cc-config/profiles/usage`, the **Check quota** button in the Profiles modal) — a POST because a saved profile's token is normally expired, so answering refreshes it and the refresh ROTATES the credentials written back to disk, which is also why it never runs on a poll + the settings patch that changes nothing now writing nothing (`patchSettings` in `server/lib/cc-mutate.js`, plus `.claude/.cc-config-backups/` in a lane's git exclude) + the Add-lane modal closing itself on a clean setup and offering a real Close when a step failed + a lane's router pick no longer landing on whichever lane the user switched to mid-apply (`LaneRouterRow`), nor reverting on the next lane switch — `POST /api/lanes/:id/router/apply` now `broadcastLane`s like every other mutating lane route, so the client's cached row stops disagreeing with what was written + a run being opened by a DECLARATION only — a backwards DETECTION now continues the run in flight (`appendStageEvent` in `server/lib/lanes.js`), because detection bounces within one feature and `runs` is what every pass rate on the Workflows page is divided by + `SETTLE_MS` in `server/lib/cc-reload.js` — the two reload commands are typed a second apart, and `reloadOpenSessions` is now async, because Claude Code swallows the Enter after a slash command and back-to-back sends merged into one composer line + v1.6.0 (2026-09-14) + the lane strip's drag/Alt-arrow reordering and inline rename (`client/src/lib/laneOrderStorage.ts`, `LaneStripCard`'s `index`/`onReorder` props, the Workspace detail header's rename field, and `normalizeTitle`/`bindable` in `server/lib/lanes.js`) + the uncommitted-files hover panel (`LaneGitFilesPanel` in `LaneCard`, and `files` from `GET /api/lanes/:id/git` now being `{path,status,added,deleted}` records off one extra `git diff --numstat HEAD`) + AI Router profiles (`server/lib/ai-routers.js`, `server/lib/router-apply.js`, the Settings tab `/settings?tool=ai-router`, its **Test** button / `POST /api/ai-routers/test`, the per-lane router select — labelled **Model**, `LaneRouterRow` exported from `LaneCard` and rendered in the Workspace lane-detail header row — and `lanes.router_profile`) + the bootstrap's superseded-build guard and plugin-cache pruner (`supersededBy`/`pruneRetiredPluginRoots` in `scripts/plugin-bootstrap.js`, `unitRoot` in `scripts/systemd-service.js`) + 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. diff --git a/client/src/components/lanes/AddLaneModal.tsx b/client/src/components/lanes/AddLaneModal.tsx index c542fa5e..7dd09107 100644 --- a/client/src/components/lanes/AddLaneModal.tsx +++ b/client/src/components/lanes/AddLaneModal.tsx @@ -297,7 +297,7 @@ export function AddLaneModal({ api.lanes.mcpSync(result.lane.id), applyRouter(result.lane.id), ]); - setSetupResult({ + const outcome = { profile: profileOutcome.status === "fulfilled" ? profileOutcome.value.scaffolded @@ -308,7 +308,9 @@ export function AddLaneModal({ mcp: mcpOutcome.status === "fulfilled" ? "ok" : "failed", router: routerOutcome.status !== "fulfilled" ? "failed" : router === "native" ? "skipped" : "ok", - }); + } as const; + const anyFailed = Object.values(outcome).includes("failed"); + setSetupResult(outcome); if (writeDocs && tool === "claude") { await startDocsRun(result.lane.id, provisionedLane.cwd); @@ -326,10 +328,17 @@ export function AddLaneModal({ }); } - // Leave the modal open so the setup summary below stays on screen; the - // user dismisses it themselves (Cancel/X) once they've seen it, rather - // than racing a timer that can close before they've looked at it. + // A clean setup has nothing to tell anyone, so the lane is made and the + // dialog is done — leaving it up read as "still working" (the confirm + // button is dead from here, since the lane already exists) with no + // obvious way out. A summary is kept on screen only when a step FAILED, + // and then the confirm button becomes a plain close, because that is the + // one case where the user has something to read before dismissing it. setBusy(false); + if (!anyFailed) { + reset(); + onClose(); + } } catch (err) { setError(err instanceof Error ? err.message : String(err)); setBusy(false); @@ -354,21 +363,24 @@ export function AddLaneModal({ onClose(); }, [onClose, browseOpen]); + // A summary on screen means the lane already exists and a step failed, so + // the confirm button has no second creation to run: it closes instead. That + // is the difference between a dialog that is finished and one that is stuck. + const done = !!setupResult; const disabled = - !!setupResult || - !sourceRepo.trim() || - (mode === "worktree" && (!title.trim() || !branches || !branch.trim())); + !done && + (!sourceRepo.trim() || (mode === "worktree" && (!title.trim() || !branches || !branch.trim()))); return (
diff --git a/client/src/components/lanes/LaneCard.tsx b/client/src/components/lanes/LaneCard.tsx index 930e702e..d457b423 100644 --- a/client/src/components/lanes/LaneCard.tsx +++ b/client/src/components/lanes/LaneCard.tsx @@ -168,6 +168,16 @@ export function LaneRouterRow({ lane }: { lane: Lane }) { setNote(null); }, [lane.id, lane.router_profile]); + // One instance of this row serves every lane — it lives in the Workspace's + // lane-detail header, and switching lanes swaps its `lane` prop rather than + // remounting it. So an apply that is still in flight when the user moves on + // would land its rollback and its note on whatever lane is showing by then: + // lane B's failed switch putting lane B's old selection into lane A's picker. + const shownLane = useRef(lane.id); + useEffect(() => { + shownLane.current = lane.id; + }, [lane.id]); + useEffect(() => { let alive = true; api.aiRouters @@ -188,17 +198,22 @@ export function LaneRouterRow({ lane }: { lane: Lane }) { if (!routers || routers.length <= 1) return null; const change = async (name: string) => { + const laneId = lane.id; const previous = current; setCurrent(name); setBusy(true); setNote(null); try { - await api.lanes.routerApply(lane.id, name); + await api.lanes.routerApply(laneId, name); + if (shownLane.current !== laneId) return; setNote({ ok: true, text: t("router.applied") }); } catch (err) { + if (shownLane.current !== laneId) return; setCurrent(previous); setNote({ ok: false, text: err instanceof Error ? err.message : String(err) }); } finally { + // `busy` belongs to the control on screen, not to the request, so it is + // cleared either way — a lane switch must never leave the picker locked. setBusy(false); } }; diff --git a/client/src/components/lanes/__tests__/AddLaneModal.test.tsx b/client/src/components/lanes/__tests__/AddLaneModal.test.tsx index 05767a57..a9cb7e69 100644 --- a/client/src/components/lanes/__tests__/AddLaneModal.test.tsx +++ b/client/src/components/lanes/__tests__/AddLaneModal.test.tsx @@ -242,9 +242,10 @@ describe("AddLaneModal — worktree mode (default)", () => { expect(onAdded).toHaveBeenCalledWith( expect.objectContaining({ id: 9, status: "provisioning" }) ); - // The modal stays open showing the setup summary until dismissed - it - // does not close itself just because the lane was added. - expect(onClose).not.toHaveBeenCalled(); + // Setup came back clean, so there is nothing to report and the dialog is + // done. Leaving it up read as "still working" — its confirm button is dead + // once the lane exists — which is how it looked stuck. + await waitFor(() => expect(onClose).toHaveBeenCalled()); }); it("shows a server error and leaves the modal open instead of closing silently", async () => { @@ -305,11 +306,12 @@ describe("AddLaneModal — worktree mode (default)", () => { expect(onClose).not.toHaveBeenCalled(); }); - it("shows the setup summary and lets the user dismiss it manually", async () => { + it("keeps the summary up when a setup step FAILED, and closes on the button that replaces Add lane", async () => { vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" }); vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: { id: 44, title: "demo3", cwd: "/lanes/demo3", status: "provisioning" } as Lane, }); + vi.mocked(api.lanes.mcpSync).mockRejectedValue(new Error("no mcp")); const onClose = vi.fn(); renderModal({ onClose }); const user = userEvent.setup(); @@ -317,12 +319,13 @@ describe("AddLaneModal — worktree mode (default)", () => { await fillWorktreeForm(user, { title: "demo3" }); await user.click(screen.getByRole("button", { name: "Add lane" })); + // A failure is the one case worth reading before the dialog goes away. expect(await screen.findByText("Setup")).toBeInTheDocument(); expect(onClose).not.toHaveBeenCalled(); - - const [dismissButton] = screen.getAllByRole("button", { name: "Cancel" }); - if (!dismissButton) throw new Error("Cancel button not found"); - await user.click(dismissButton); + // And then the primary button must close, not sit there disabled: the lane + // already exists, so "Add lane" has nothing left to do. + expect(screen.queryByRole("button", { name: "Add lane" })).toBeNull(); + await user.click(screen.getByRole("button", { name: "Close" })); expect(onClose).toHaveBeenCalled(); }); diff --git a/client/src/components/lanes/__tests__/LaneCard.test.tsx b/client/src/components/lanes/__tests__/LaneCard.test.tsx index 939ba3a2..70d1f49e 100644 --- a/client/src/components/lanes/__tests__/LaneCard.test.tsx +++ b/client/src/components/lanes/__tests__/LaneCard.test.tsx @@ -9,7 +9,7 @@ */ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, waitFor, fireEvent } from "@testing-library/react"; +import { render, screen, waitFor, fireEvent, act } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import LaneCard, { LaneToolsMenu, LaneRouterRow } from "../LaneCard"; import type { Lane, LaneRuntime } from "../../../lib/types"; @@ -513,6 +513,60 @@ describe("LaneRouterRow — the Model picker", () => { expect(await screen.findByDisplayValue("9Router")).toBeInTheDocument(); }); + it("does not carry a pick made on one lane back to the lane selected before it", async () => { + // One LaneRouterRow instance serves every lane (it lives in the Workspace's + // lane-detail header), so its selected value is component state that + // outlives a lane switch unless the sync effect puts the new lane's own + // profile back. + vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] }); + vi.mocked(api.lanes.routerApply).mockResolvedValue({ + tool: "claude", + file: "/work/b/.claude/settings.local.json", + profile: "9router", + }); + const laneA = makeLane({ id: 1, router_profile: null }); + const laneB = makeLane({ id: 2, router_profile: null }); + const { rerender } = render(); + await screen.findByDisplayValue("Native (Claude / Codex)"); + + rerender(); + await userEvent.selectOptions(screen.getByDisplayValue("Native (Claude / Codex)"), ["9router"]); + await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(2, "9router")); + + rerender(); + expect(screen.getByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument(); + }); + + it("does not roll a failed apply back onto a DIFFERENT lane the user has since switched to", async () => { + // The row is one instance shared by every lane, so an apply still in flight + // when the user moves on would paint its rollback and its error on whoever + // is on screen by then — lane B's failure changing lane A's picker. + vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] }); + let reject: (e: Error) => void = () => {}; + vi.mocked(api.lanes.routerApply).mockImplementation( + () => new Promise((_, r) => (reject = r)) as ReturnType + ); + const laneA = makeLane({ id: 1, router_profile: null }); + const laneB = makeLane({ id: 2, router_profile: null }); + + const { rerender } = render(); + await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [ + "9router", + ]); + await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(2, "9router")); + + rerender(); + await act(async () => { + reject(new Error("EBADJSON")); + await Promise.resolve(); + }); + + expect(screen.getByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument(); + expect(screen.queryByTitle("EBADJSON")).toBeNull(); + // And the picker must not be left locked by a request for another lane. + expect(screen.getByRole("combobox")).not.toBeDisabled(); + }); + it("puts the selection back when the apply fails", async () => { vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] }); vi.mocked(api.lanes.routerApply).mockRejectedValue(new Error("EBADJSON")); diff --git a/client/src/i18n/locales/en/ccConfig.json b/client/src/i18n/locales/en/ccConfig.json index a244de88..9790b052 100644 --- a/client/src/i18n/locales/en/ccConfig.json +++ b/client/src/i18n/locales/en/ccConfig.json @@ -235,6 +235,14 @@ "filterAll": "All types" }, "profiles": { + "usageCheck": "Check quota", + "usageChecking": "Checking…", + "usageRefresh": "Refresh", + "usageRetry": "Try again", + "usageFiveHour": "5h", + "usageSevenDay": "7d", + "usageResets": "Resets {{time}}", + "usageHint": "Asks Anthropic what this account has spent. Refreshes this profile's stored login token to do it.", "title": "Claude Code profiles", "openButton": "Switch profile", "subtitle": "A profile is one Anthropic account - its own quota. Switching swaps only the login (credentials + account identity); skills, subagents, memory, plugins, marketplaces, MCP servers and settings all stay exactly as they are.", diff --git a/client/src/i18n/locales/en/lanes.json b/client/src/i18n/locales/en/lanes.json index c5b3923d..fc162449 100644 --- a/client/src/i18n/locales/en/lanes.json +++ b/client/src/i18n/locales/en/lanes.json @@ -32,6 +32,7 @@ "addLaneRepoHintAdopt": "An existing directory. The dashboard tracks it as-is — no worktree, no new branch.", "addLaneRepoLabel": "Source repository", "addLaneRepoLabelAdopt": "Directory", + "addLaneSetupClose": "Close", "addLaneSetupAgents": "Agents", "addLaneSetupMcp": "MCP servers", "addLaneSetupProfile": "Profile", diff --git a/client/src/i18n/locales/vi/ccConfig.json b/client/src/i18n/locales/vi/ccConfig.json index 8b434f5d..40c7c1a1 100644 --- a/client/src/i18n/locales/vi/ccConfig.json +++ b/client/src/i18n/locales/vi/ccConfig.json @@ -230,6 +230,14 @@ "filterAll": "Tất cả loại" }, "profiles": { + "usageCheck": "Kiểm tra quota", + "usageChecking": "Đang kiểm tra…", + "usageRefresh": "Làm mới", + "usageRetry": "Thử lại", + "usageFiveHour": "5h", + "usageSevenDay": "7 ngày", + "usageResets": "Reset lúc {{time}}", + "usageHint": "Hỏi Anthropic tài khoản này đã dùng bao nhiêu. Thao tác này làm mới token đăng nhập đã lưu của profile.", "title": "Profile Claude Code", "openButton": "Đổi profile", "subtitle": "Mỗi profile là một tài khoản Anthropic - quota riêng. Khi đổi, chỉ phần đăng nhập (credentials + danh tính tài khoản) được hoán đổi; skills, subagent, memory, plugin, marketplace, MCP server và settings vẫn giữ nguyên.", diff --git a/client/src/i18n/locales/vi/lanes.json b/client/src/i18n/locales/vi/lanes.json index 66ace786..47b47d0a 100644 --- a/client/src/i18n/locales/vi/lanes.json +++ b/client/src/i18n/locales/vi/lanes.json @@ -32,6 +32,7 @@ "addLaneRepoHintAdopt": "Một thư mục có sẵn. Dashboard theo dõi nguyên trạng — không tạo worktree, không tạo nhánh mới.", "addLaneRepoLabel": "Repo nguồn", "addLaneRepoLabelAdopt": "Thư mục", + "addLaneSetupClose": "Đóng", "addLaneSetupAgents": "Agent", "addLaneSetupMcp": "MCP server", "addLaneSetupProfile": "Profile", diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 93e02272..2d7e5c1f 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -1584,6 +1584,21 @@ export const api = { "/cc-config/profiles/activate", { method: "POST", body: JSON.stringify({ name }) } ), + /** + * POST /api/cc-config/profiles/usage - how much of this profile's account + * quota is already spent. + * + * A POST because answering it is a write: a saved profile's access token has + * almost always expired, so the server refreshes it against Anthropic first + * and the refresh rotates the credentials stored on disk. That is also why + * this is never called on a timer — only when the user asks for one profile. + * No token material crosses the boundary in either direction. + */ + profileUsage: (name: string) => + request("/cc-config/profiles/usage", { + method: "POST", + body: JSON.stringify({ name }), + }), /** DELETE /api/cc-config/profiles - forget a saved profile (never the active one). */ deleteProfile: (name: string) => request<{ ok: true; name: string }>("/cc-config/profiles", { @@ -2656,6 +2671,25 @@ export interface CcProfile extends CcAccountIdentity { active: boolean; } +/** One quota window as POST /api/cc-config/profiles/usage reports it. */ +export interface CcUsageWindow { + /** Percent of the window's allowance already spent, 0-100. */ + utilization: number | null; + /** ISO timestamp the window rolls over at, when the account reports one. */ + resetsAt: string | null; +} + +/** An account's spent quota, for the profile switcher. Percentages and reset + * times only — the OAuth token stays on the server. */ +export interface CcProfileUsage { + name: string; + /** True when the stored token had expired and was refreshed to answer this. */ + refreshed: boolean; + fiveHour: CcUsageWindow | null; + sevenDay: CcUsageWindow | null; + checkedAt: string; +} + /** Scope filter accepted by most `ccConfig` list endpoints; "all" merges * user + project scope in one response. */ export type CcScope = "user" | "project" | "all"; diff --git a/client/src/pages/CcConfig.tsx b/client/src/pages/CcConfig.tsx index cf64f8b8..ead9c02c 100644 --- a/client/src/pages/CcConfig.tsx +++ b/client/src/pages/CcConfig.tsx @@ -141,6 +141,8 @@ import type { CcUpdateAllBatch, CcProfile, CcAccountIdentity, + CcProfileUsage, + CcUsageWindow, CcScope, CcSettingsSource, CcStatusline, @@ -4232,6 +4234,11 @@ function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t: /** `name` of the profile whose label is being edited inline, plus its draft. */ const [renaming, setRenaming] = useState(null); const [draft, setDraft] = useState(""); + /** Quota per profile, keyed by `name`. Empty until asked for: reading it + * refreshes that account's stored OAuth token, so it is never fetched with + * the list — the user asks per profile, one profile at a time. */ + const [usage, setUsage] = useState>({}); + const [usageError, setUsageError] = useState>({}); const load = useCallback(async () => { try { @@ -4276,6 +4283,26 @@ function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t: [load, onToast] ); + const checkUsage = useCallback(async (name: string) => { + setBusy(`usage:${name}`); + setUsageError((prev) => { + const next = { ...prev }; + delete next[name]; + return next; + }); + try { + const result = await api.ccConfig.profileUsage(name); + setUsage((prev) => ({ ...prev, [name]: result })); + } catch (err: unknown) { + setUsageError((prev) => ({ + ...prev, + [name]: err instanceof Error ? err.message : "unknown error", + })); + } finally { + setBusy(null); + } + }, []); + const activeSaved = items?.some((p) => p.active) ?? false; /** How many saved profiles hold the same account as `p`. */ @@ -4441,6 +4468,13 @@ function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t: )}
+ void checkUsage(p.name)} + /> {p.savedAt && (
{t("profiles.savedAt", { time: new Date(p.savedAt).toLocaleString() })} @@ -4459,6 +4493,83 @@ function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t: ); } +/** + * One profile's quota: a button until asked, then the two windows Anthropic + * reports — the 5-hour session allowance and the 7-day one. + * + * Deliberately not fetched with the profile list. Reading a saved profile's + * usage means refreshing its OAuth token, and that rotates the credentials on + * disk (see `server/lib/cc-usage.js`), so it happens once per click and never + * on a render or a poll. + */ +function ProfileUsageLine({ + usage, + error, + busy, + disabled, + onCheck, +}: { + usage?: CcProfileUsage; + error?: string; + busy: boolean; + disabled: boolean; + onCheck: () => void; +}) { + const { t } = useTranslation("ccConfig"); + const pct = (w: CcUsageWindow | null | undefined) => + w && w.utilization !== null ? `${Math.round(w.utilization)}%` : "—"; + const resetTitle = (w: CcUsageWindow | null | undefined) => + w && w.resetsAt + ? t("profiles.usageResets", { time: new Date(w.resetsAt).toLocaleString() }) + : undefined; + + if (error) { + return ( +
+ {error} + +
+ ); + } + if (!usage) { + return ( + + ); + } + return ( +
+ + {t("profiles.usageFiveHour")}{" "} + {pct(usage.fiveHour)} + + + {t("profiles.usageSevenDay")}{" "} + {pct(usage.sevenDay)} + + +
+ ); +} + /** Org / seat / plan line shared by the active card and each profile row. */ function IdentityLine({ identity }: { identity: CcAccountIdentity }) { const parts = [identity.organization, identity.seatTier, identity.subscriptionType].filter( diff --git a/client/src/pages/__tests__/Workspace.laneRouter.test.tsx b/client/src/pages/__tests__/Workspace.laneRouter.test.tsx new file mode 100644 index 00000000..5eb29d4a --- /dev/null +++ b/client/src/pages/__tests__/Workspace.laneRouter.test.tsx @@ -0,0 +1,223 @@ +/** + * @file Workspace.laneRouter.test.tsx + * @description The Model (AI Router) picker in the Workspace lane-detail + * header, exercised through the page rather than through `LaneRouterRow` on its + * own — one row instance serves every lane, so the bug this pins is a lane + * switch carrying the previous lane's selection with it: pick a router on one + * lane, come back to a lane that is on native, and see the router still + * selected there. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, act, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import userEvent from "@testing-library/user-event"; + +type LaneFixture = { + id: number; + cwd: string; + title: string; + pipeline: string; + pipeline_name: string; + pipeline_nodes: never[]; + detected_signal: string | null; + run_id: string | null; + router_profile: string | null; +}; + +const lane = (id: number, title: string): LaneFixture => ({ + id, + cwd: `/workspace/${id}`, + title, + pipeline: "default", + pipeline_name: "default", + pipeline_nodes: [], + detected_signal: null, + run_id: null, + router_profile: null, +}); + +const h = vi.hoisted(() => { + const nativeProfile = { + name: "native", + label: "Native (Claude / Codex)", + builtin: true, + baseUrl: "", + hasKey: false, + model: "claude-sonnet-5", + models: { + opus: "claude-opus-5", + sonnet: "claude-sonnet-5", + haiku: "claude-haiku-4-5-20251001", + smallFast: "claude-haiku-4-5-20251001", + subagent: "claude-haiku-4-5-20251001", + }, + }; + return { + lanes: [] as { id: number; router_profile: string | null }[], + routerApply: vi.fn(), + profiles: [ + nativeProfile, + { ...nativeProfile, name: "9router", label: "9Router", builtin: false }, + ], + }; +}); + +const routerApply = h.routerApply; +let lanesToReturn: LaneFixture[] = []; + +vi.mock("../../lib/api", async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + api: { + lanes: { + list: vi.fn().mockImplementation(async () => ({ + lanes: h.lanes, + counts: { total: h.lanes.length, running: 0, needs_you: 0, dead: 0 }, + })), + ensure: vi.fn(), + action: vi.fn(), + git: vi.fn().mockResolvedValue({ available: false }), + runtime: vi.fn().mockResolvedValue({ available: false }), + stage: vi.fn(), + update: vi.fn(), + pipelines: vi + .fn() + .mockResolvedValue({ pipelines: [{ id: "default", name: "default", nodes: [] }] }), + // The route the picker must use — never a lane PATCH. + routerApply: vi.fn().mockImplementation(async (id: number, profile: string) => { + h.routerApply(id, profile); + // The row moving with the call is the server's broadcast, not a test + // convenience: the apply route broadcasts the lane so the cached row + // stops disagreeing with what was just written. Drop the broadcast + // server-side and the next lane switch re-reads a stale profile. + const row = h.lanes.find((l) => l.id === id); + if (row) row.router_profile = profile === "native" ? null : profile; + return { tool: "claude", file: `/workspace/${id}/.claude/settings.local.json`, profile }; + }), + features: { list: vi.fn().mockResolvedValue({ features: [] }), show: vi.fn() }, + proof: { + list: vi.fn().mockResolvedValue({ features: [] }), + imageUrl: vi.fn(), + delete: vi.fn(), + }, + }, + aiRouters: { + list: vi.fn().mockImplementation(async () => ({ items: h.profiles })), + }, + run: { + list: vi.fn().mockResolvedValue({ runs: [], items: [] }), + history: vi.fn().mockResolvedValue({ items: [] }), + binary: vi.fn().mockResolvedValue({ found: true, path: "/usr/bin/claude" }), + cwds: vi.fn().mockResolvedValue({ items: [] }), + files: vi.fn().mockResolvedValue({ items: [] }), + start: vi.fn(), + get: vi.fn(), + send: vi.fn(), + kill: vi.fn(), + }, + ccConfig: { + commands: vi.fn().mockResolvedValue({ items: [] }), + plugins: vi.fn().mockResolvedValue({ plugins: [] }), + file: vi.fn().mockResolvedValue({ text: "", content: "" }), + }, + sessions: { + list: vi.fn().mockResolvedValue({ sessions: [], total: 0, limit: 50, offset: 0 }), + transcript: vi.fn().mockResolvedValue({ messages: [] }), + }, + }, + }; +}); + +vi.mock("../../lib/eventBus", () => ({ + eventBus: { + subscribe: () => () => {}, + publish: () => {}, + onConnection: () => () => {}, + connected: true, + setConnected: () => {}, + }, +})); + +vi.mock("../../components/run/TerminalView", () => ({ + TerminalView: ({ runId }: { runId: string }) => ( +
+ ), +})); + +import { Workspace } from "../Workspace"; + +class ObserverStub { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } +} + +globalThis.ResizeObserver = + globalThis.ResizeObserver || (ObserverStub as unknown as typeof ResizeObserver); +for (const fn of ["scrollIntoView", "scrollBy", "scrollTo"] as const) { + if (!(Element.prototype as unknown as Record)[fn]) { + (Element.prototype as unknown as Record)[fn] = function () {}; + } +} + +async function settle() { + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + await new Promise((r) => setTimeout(r, 0)); + }); +} + +beforeEach(() => { + routerApply.mockClear(); + lanesToReturn = [lane(1, "Lane A"), lane(2, "Lane B")]; + h.lanes = lanesToReturn as unknown as typeof h.lanes; +}); + +async function renderWorkspace() { + const utils = render( + + + + ); + await settle(); + await settle(); + await settle(); + return utils; +} + +/** Click the strip tile for a lane, which is how a lane is selected. */ +async function selectLane(user: ReturnType, title: string) { + const tile = screen.getAllByText(title)[0]; + if (!tile) throw new Error(`no strip tile for ${title}`); + await user.click(tile); + await settle(); +} + +describe("Workspace — the lane Model picker across a lane switch", () => { + it("leaves a lane on native after another lane was pointed at a router", async () => { + await renderWorkspace(); + const user = userEvent.setup(); + + await selectLane(user, "Lane A"); + expect(await screen.findByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument(); + + await selectLane(user, "Lane B"); + await user.selectOptions(screen.getByDisplayValue("Native (Claude / Codex)"), ["9router"]); + await waitFor(() => expect(routerApply).toHaveBeenCalledWith(2, "9router")); + await settle(); + expect(screen.getByDisplayValue("9Router")).toBeInTheDocument(); + + // Back to the lane that was never switched. It is still native server-side, + // so the picker must say so — the row is one instance shared by every lane. + await selectLane(user, "Lane A"); + expect(screen.getByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument(); + // And nothing may have been written to lane A on the way. + expect(routerApply).not.toHaveBeenCalledWith(1, expect.anything()); + }); +}); diff --git a/docs/API.md b/docs/API.md index 2831bebc..ce638a9f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1648,6 +1648,7 @@ GET /api/cc-config/profiles POST /api/cc-config/profiles Body: { name? } PATCH /api/cc-config/profiles Body: { name, label } POST /api/cc-config/profiles/activate Body: { name } +POST /api/cc-config/profiles/usage Body: { name } DELETE /api/cc-config/profiles Body: { name } PUT /api/cc-config/file Body: { scope, type, name?, content } DELETE /api/cc-config/file Body: { scope, type, name? } @@ -1697,6 +1698,10 @@ Batch `status` is `running`, `done`, `partial` (finished, at least one step fail Profiles live one JSON file per profile under `/agenthub-profiles/` (dir `0700`, files `0600`) and hold live OAuth tokens, so **no route ever returns token material** — `GET /profiles` answers `{ items, active }` with identity metadata only (`name`, `label`, `savedAt`, `active`, `accountUuid`, `email`, `displayName`, `organization`, `seatTier`, `subscriptionType`, `expiresAt`). +**`POST /profiles/usage` (how much quota this account has spent).** Answers `{ name, refreshed, fiveHour, sevenDay, checkedAt }`, where each window is `{ utilization, resetsAt }` — a percentage and a rollover time, nothing else. There is no local source for this: nothing under `CLAUDE_HOME` records a utilization, and the credentials file carries only `subscriptionType` and `rateLimitTier`, so the server asks the same endpoint the CLI's own `/usage` reads (`GET https://api.anthropic.com/api/oauth/usage`) with that profile's OAuth token. + +It is a **POST**, and behind the same-origin guard, because answering it is a write. A saved profile's access token lasts hours, so every profile that is not the active one is normally expired; reading its usage means refreshing it first, and the refresh **rotates the stored credentials** — Anthropic retires the refresh token it was handed. The new pair is therefore written straight back into the profile file (atomic temp + rename at `0600`, every other field preserved), because a refresh whose result is dropped leaves an account that can never be activated again without a fresh `claude auth login`. A token that is still valid is used as-is and nothing is rewritten; a token that looks valid but is refused (401) is retried once through the refresh path. **Nothing calls this on a timer** — it is one profile per user request, which is what keeps credential rewrites down to the moments the user asked for. Errors: `ENOTFOUND` (404), `ENOREFRESH`/`ENOAUTH` (409 — the profile has no usable token, re-save it from a login), `EUNAUTHORIZED` (401), anything upstream 502. See `server/lib/cc-usage.js`. + A profile has two names, and the split is what lets a profile be called `Tài khoản chính`. `name` is the internal identifier every mutation route takes, and it is the filename, so it must match `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,39}$`; callers normally never choose it, because `POST /profiles` derives it from the account. `label` is the display name the user edits with `PATCH /profiles`: free-form 1-60 characters, spaces and diacritics allowed since it is never used as a path, stored trimmed, rejected for control characters (`EBADLABEL`), and unique across profiles case-insensitively (`ELABELTAKEN`). A rename rewrites the label in place and **does not move `name`**, so it cannot half-fail and leave a profile unreachable. Profiles written before labels existed report `label` equal to their `name`. One account is one profile: `POST /profiles` refuses with `EDUPLICATE` when another profile already holds the logged-in account, naming the one that does. Two profiles of a single account would both report `active: true`, leaving nothing to switch between — and, under a blanket active-guard on delete, neither would ever be removable. Re-posting an account into its OWN profile is the token-refresh path, stays allowed, and preserves that profile's label. @@ -1797,7 +1802,7 @@ The stored key is only ever sent to the endpoint it was stored against. A blank Attaching to a **native** lane is not a no-op for claude: `ANTHROPIC_BASE_URL` and `ANTHROPIC_AUTH_TOKEN` are written as empty strings (removing them would let a *user-level* router apply to a lane that asked for native), while the six model names are removed (writing them would override the user's own global model choice). -The lane's `router_profile` column is set in the same call. Like `tool`, it is absent from the lane `PATCH` allowlist: the row and the config on disk may only move together. +The lane's `router_profile` column is set in the same call. Like `tool`, it is absent from the lane `PATCH` allowlist: the row and the config on disk may only move together. The updated lane is then broadcast as `lane_update`, same as every other mutating lane route — without it a client's cached row keeps the old `router_profile`, and re-selecting the lane re-reads that stale row and flips the picker back to the previous profile. **An edit reaches the lanes that already use the profile.** Attaching *copies* a profile — a claude lane carries the key and base URL in its own settings file from then on — so a store-only write would be inert exactly where it matters: rotating a leaked key in Settings would leave every claude lane authenticating with the revoked one. `PATCH` therefore re-applies the profile to each lane naming it, and `DELETE` resets those lanes to native (on disk and in the DB) and removes the Codex layer file. Both report `lanes: { updated: , failed: [" (): <code>", …] }`; a lane whose directory is gone or whose settings file a human has broken is reported there, never fatal to the edit. diff --git a/openapi.yaml b/openapi.yaml index 602d9438..6fc5e19a 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -3026,6 +3026,21 @@ components: example: - /Users/son/.claude/.credentials.json - /Users/son/.claude.json + CcConfigUsageWindow: + type: object + nullable: true + description: One quota window as the account reports it. + properties: + utilization: + type: number + nullable: true + description: Percent of the window's allowance already spent, 0-100. + example: 48 + resetsAt: + type: string + format: date-time + nullable: true + description: When the window rolls over, when the account reports one. CodexConfigScope: type: string enum: @@ -8979,6 +8994,72 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + /api/cc-config/profiles/usage: + post: + tags: + - CcConfig + summary: How much of a saved profile's account quota is already spent + description: |- + Answers `{ name, refreshed, fiveHour, sevenDay, checkedAt }`, each window being `{ utilization, resetsAt }` - a percentage and a rollover time. There is no local source for it: nothing under `CLAUDE_HOME` records a utilization and the credentials file carries only `subscriptionType`/`rateLimitTier`, so the server asks the endpoint the CLI's own `/usage` reads (`GET https://api.anthropic.com/api/oauth/usage`) with that profile's OAuth token. + + A POST, and behind the same-origin guard, because answering it is a WRITE. A saved profile's access token lasts hours, so every profile that is not the active one is normally expired; reading its usage refreshes it first, and the refresh ROTATES the stored credentials - Anthropic retires the refresh token it was handed. The new pair is written straight back into the profile file, because a refresh whose result is dropped leaves an account that can never be activated again without a fresh `claude auth login`. A still-valid token is used as-is and nothing is rewritten; a token that looks valid but is refused is retried once through the refresh. Nothing calls this on a timer - it is one profile per user request. The OAuth token is sent to Anthropic and nowhere else, and never appears in the response. + operationId: ccConfigProfileUsage + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - name + properties: + name: + type: string + example: work + responses: + '200': + description: Percentages and reset times for the two quota windows. + content: + application/json: + schema: + type: object + properties: + name: + type: string + refreshed: + type: boolean + description: True when the stored token was expired and had to be rotated. + fiveHour: + $ref: '#/components/schemas/CcConfigUsageWindow' + sevenDay: + $ref: '#/components/schemas/CcConfigUsageWindow' + checkedAt: + type: string + format: date-time + '401': + description: '`EUNAUTHORIZED` - the account refused the token even after a refresh.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: '`ENOTFOUND` - no profile by that name.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: '`ENOREFRESH`/`ENOAUTH` - the profile holds no usable token; re-save it from a login.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '502': + description: '`EUPSTREAM` - Anthropic''s token or usage endpoint did not cooperate.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/codex-config/overview: get: tags: diff --git a/server/__tests__/cc-mutate-settings.test.js b/server/__tests__/cc-mutate-settings.test.js index a4a214ce..c4a2f07d 100644 --- a/server/__tests__/cc-mutate-settings.test.js +++ b/server/__tests__/cc-mutate-settings.test.js @@ -606,6 +606,24 @@ describe("patchSettings — backup and atomicity", () => { assert.deepEqual(backup, original); }); + it("writes and backs up nothing when the patch changes nothing", () => { + // Re-attaching the same AI Router profile sends the same eight env keys + // every time. With an unconditional backup that left a `.bak` per apply + // piling up inside the user's own project — a dozen untracked files in a + // minute of switching lanes. + const file = path.join(FAKE_HOME, "settings.json"); + ccMutate.patchSettings({ scope: "user", set: { model: "sonnet" } }); + const before = fs.readFileSync(file, "utf8"); + + const result = ccMutate.patchSettings({ scope: "user", set: { model: "sonnet" } }); + + assert.equal(result.backupPath, null); + assert.deepEqual(result.changed, ["model"], "the request was still satisfied"); + assert.equal(fs.readFileSync(file, "utf8"), before); + const backupDir = path.join(FAKE_HOME, ".cc-config-backups", "settings"); + assert.deepEqual(fs.existsSync(backupDir) ? fs.readdirSync(backupDir) : [], []); + }); + it("returns null backupPath for a fresh file", () => { const result = ccMutate.patchSettings({ scope: "user", diff --git a/server/__tests__/cc-usage.test.js b/server/__tests__/cc-usage.test.js new file mode 100644 index 00000000..51a0eaf6 --- /dev/null +++ b/server/__tests__/cc-usage.test.js @@ -0,0 +1,205 @@ +/** + * @file cc-usage.test.js + * @description Tests for per-profile quota lookup. + * + * Two properties carry the whole feature. A profile's refreshed OAuth tokens + * must be written back — the exchange retires the refresh token it was given, + * so dropping the result leaves an account the user has to log into again. And + * a still-valid token must NOT be refreshed, because every refresh rewrites + * credentials on disk and the point of a read is to read. + * + * `fetch` is stubbed throughout: nothing here talks to Anthropic. + * + * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> + */ + +const { describe, it, beforeEach, afterEach, after } = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const os = require("node:os"); + +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "cc-usage-")); +process.env.CLAUDE_HOME = TMP; + +const profiles = require("../lib/cc-profiles"); +const usage = require("../lib/cc-usage"); + +const realFetch = global.fetch; +let calls = []; + +after(() => { + global.fetch = realFetch; + try { + fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 5 }); + } catch { + /* best-effort temp cleanup */ + } +}); + +function writeProfileFile(name, credentials) { + const dir = profiles.profilesDir(); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, `${name}.json`), + JSON.stringify({ + name, + label: "Label kept", + savedAt: 1, + account: { emailAddress: "a@b.c" }, + credentials, + }) + ); +} + +/** Answer the token endpoint and the usage endpoint, recording both. */ +function stubFetch({ usageStatus = 200, usageBody } = {}) { + global.fetch = async (url, init) => { + calls.push({ url: String(url), init }); + if (String(url).includes("/v1/oauth/token")) { + return { + ok: true, + status: 200, + text: async () => + JSON.stringify({ + access_token: "fresh-access", + refresh_token: "rotated-refresh", + expires_in: 28800, + }), + }; + } + return { + ok: usageStatus === 200, + status: usageStatus, + json: async () => + usageBody || { + five_hour: { utilization: 48.0, resets_at: "2026-09-17T13:00:00Z" }, + seven_day: { utilization: 66.0, resets_at: "2026-09-21T08:00:00Z" }, + seven_day_opus: null, + }, + }; + }; +} + +beforeEach(() => { + calls = []; + fs.rmSync(profiles.profilesDir(), { recursive: true, force: true }); +}); + +afterEach(() => { + global.fetch = realFetch; +}); + +describe("profileUsage", () => { + it("reports the two windows, and only those", async () => { + writeProfileFile("live", { + accessToken: "still-good", + refreshToken: "r", + expiresAt: Date.now() + 3_600_000, + }); + stubFetch(); + + const result = await usage.profileUsage("live"); + + assert.deepEqual(result.fiveHour, { utilization: 48, resetsAt: "2026-09-17T13:00:00Z" }); + assert.deepEqual(result.sevenDay, { utilization: 66, resetsAt: "2026-09-21T08:00:00Z" }); + // Nothing token-shaped may be in what a route is about to serialise. + assert.equal(JSON.stringify(result).includes("still-good"), false); + }); + + it("does not refresh a token that is still valid", async () => { + writeProfileFile("live", { + accessToken: "still-good", + refreshToken: "r", + expiresAt: Date.now() + 3_600_000, + }); + stubFetch(); + + const result = await usage.profileUsage("live"); + + assert.equal(result.refreshed, false); + assert.equal( + calls.some((c) => c.url.includes("/v1/oauth/token")), + false, + "a read must not rewrite credentials on disk" + ); + assert.match(calls[0].init.headers.Authorization, /still-good/); + }); + + it("refreshes an expired token and WRITES THE ROTATED PAIR BACK", async () => { + // The exchange retires the refresh token it was handed. A refresh whose + // result is not persisted leaves the profile holding a dead credential — + // the account could never be activated again without a fresh login. + writeProfileFile("stale", { + accessToken: "expired", + refreshToken: "original-refresh", + expiresAt: Date.now() - 1000, + subscriptionType: "team", + }); + stubFetch(); + + const result = await usage.profileUsage("stale"); + + assert.equal(result.refreshed, true); + const stored = profiles.readProfile("stale"); + assert.equal(stored.credentials.accessToken, "fresh-access"); + assert.equal(stored.credentials.refreshToken, "rotated-refresh"); + assert.ok(stored.credentials.expiresAt > Date.now()); + // Everything else about the profile survives the rewrite. + assert.equal(stored.label, "Label kept"); + assert.equal(stored.credentials.subscriptionType, "team"); + assert.equal(stored.account.emailAddress, "a@b.c"); + }); + + it("retries once through a refresh when a token that looked valid is refused", async () => { + writeProfileFile("revoked", { + accessToken: "revoked-but-unexpired", + refreshToken: "original-refresh", + expiresAt: Date.now() + 3_600_000, + }); + let first = true; + global.fetch = async (url) => { + calls.push({ url: String(url) }); + if (String(url).includes("/v1/oauth/token")) { + return { + ok: true, + status: 200, + text: async () => + JSON.stringify({ access_token: "fresh", refresh_token: "rot", expires_in: 100 }), + }; + } + if (first) { + first = false; + return { ok: false, status: 401, json: async () => ({}) }; + } + return { + ok: true, + status: 200, + json: async () => ({ five_hour: { utilization: 1, resets_at: null }, seven_day: null }), + }; + }; + + const result = await usage.profileUsage("revoked"); + + assert.equal(result.refreshed, true); + assert.deepEqual(result.fiveHour, { utilization: 1, resetsAt: null }); + }); + + it("refuses a profile with no refresh token instead of half-answering", async () => { + writeProfileFile("tokenless", { accessToken: "expired", expiresAt: Date.now() - 1000 }); + stubFetch(); + + await assert.rejects( + () => usage.profileUsage("tokenless"), + (e) => e.code === "ENOREFRESH" + ); + }); + + it("reports an unknown profile as ENOTFOUND", async () => { + stubFetch(); + await assert.rejects( + () => usage.profileUsage("nope"), + (e) => e.code === "ENOTFOUND" + ); + }); +}); diff --git a/server/__tests__/lanes-api.test.js b/server/__tests__/lanes-api.test.js index c2fb2ee0..4f141202 100644 --- a/server/__tests__/lanes-api.test.js +++ b/server/__tests__/lanes-api.test.js @@ -277,6 +277,50 @@ describe("/api/lanes", () => { ws.close(); }); + + it("broadcasts lane_update on router apply", async () => { + // Without this the client's cached row keeps the OLD router_profile: the + // picker looks right until the user switches lanes and back, at which + // point the effect re-reads the stale row and flips the selection. + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-router-bcast-")); + const created = await request("POST", "/api/lanes", { cwd, title: "Router Lane" }); + const id = created.body.lane.id; + // Start somewhere other than native, so the broadcast has to carry a + // CHANGED value rather than the one the row already held. + require("../lib/lanes").setRouterProfile(id, "some-router"); + + const ws = new WebSocket(`ws://127.0.0.1:${server.address().port}/ws`); + const messages = []; + await new Promise((resolve, reject) => { + ws.on("open", resolve); + ws.on("error", reject); + }); + ws.on("message", (msg) => { + try { + messages.push(JSON.parse(msg)); + } catch { + /* parse error */ + } + }); + + try { + const applied = await request("POST", `/api/lanes/${id}/router/apply`, { profile: "native" }); + assert.equal(applied.status, 200); + await new Promise((resolve) => setTimeout(resolve, 100)); + + const msg = messages.find((m) => m.type === "lane_update" && m.data.lane?.id === id); + assert.ok(msg, "router apply should broadcast lane_update"); + // `native` is stored as null — what matters is that the pushed row is the + // post-apply one, not the "some-router" the client still has cached. + assert.equal(msg.data.lane.router_profile, null); + } finally { + // A socket left open by a failed assertion keeps the whole run alive. + ws.close(); + } + + await request("DELETE", `/api/lanes/${id}`); + fs.rmSync(cwd, { recursive: true, force: true }); + }); }); describe("hook → lane binding", () => { diff --git a/server/lib/cc-mutate.js b/server/lib/cc-mutate.js index 8abe8142..22955a41 100644 --- a/server/lib/cc-mutate.js +++ b/server/lib/cc-mutate.js @@ -571,6 +571,17 @@ function patchSettings(args) { throw makeError("ETOOLARGE", "the resulting settings file would be too large"); } + // A patch that changes nothing writes nothing — and therefore backs up + // nothing. Re-applying the same AI Router profile to a lane is the common + // case (every re-attach sends the same eight env keys), and an unconditional + // backup turned that into a `.bak` per apply piling up inside the user's + // project. `changed` still lists the keys the caller asked for: they ARE the + // file's content now, the request was satisfied, there was just nothing to + // move to get there. + if (existed && output === raw) { + return { path: file, backupPath: null, created: false, changed }; + } + let backupPath = null; if (existed) { const root = settingsBackupRoot(scope, opts); diff --git a/server/lib/cc-profiles.js b/server/lib/cc-profiles.js index 5b5ea204..22f6ee7d 100644 --- a/server/lib/cc-profiles.js +++ b/server/lib/cc-profiles.js @@ -368,12 +368,26 @@ function deleteProfile(name) { return { ok: true, name }; } +/** + * Overwrite a stored profile wholesale. The one caller is `cc-usage.js`, which + * has to write a rotated OAuth token pair straight back: the exchange retires + * the refresh token it was given, so a profile whose refreshed credentials are + * not persisted is a profile that can no longer be activated. + */ +function writeProfile(name, value) { + writeJson(profileFile(name), value); + return { ok: true, name }; +} + module.exports = { listProfiles, saveProfile, renameProfile, activateProfile, deleteProfile, + // Token-bearing reads/writes: for cc-usage.js, never for a route. + readProfile, + writeProfile, // exported for tests profilesDir, configPaths, diff --git a/server/lib/cc-usage.js b/server/lib/cc-usage.js new file mode 100644 index 00000000..cbad55d5 --- /dev/null +++ b/server/lib/cc-usage.js @@ -0,0 +1,170 @@ +/** + * @file cc-usage.js + * @description How much of an Anthropic account's quota is already spent — + * the number that decides which saved auth profile (`cc-profiles.js`) is worth + * switching to. + * + * There is no local source for it. Nothing under CLAUDE_HOME records a + * utilization or a reset time; the credentials file carries only + * `subscriptionType` and `rateLimitTier`. The figure comes from the same + * endpoint the CLI's own `/usage` reads, `GET /api/oauth/usage`, answered for + * whichever account's OAuth token is presented. + * + * Two rules this module exists to hold: + * + * - **A profile's token is sent to exactly one place: Anthropic.** It is read + * from the profile file, put in an `Authorization` header, and never + * returned, logged or echoed. What leaves this module is two percentages + * and two reset timestamps. + * + * - **Nothing here runs on a poll.** A saved profile's access token is short + * lived — every profile that is not the active one is normally expired — so + * reading its usage means refreshing it first, and a refresh ROTATES the + * stored refresh token. Doing that on a page load would rewrite the user's + * credentials for every account they have, repeatedly, for a number on a + * screen. It is therefore driven by an explicit per-profile request, and the + * rotated tokens are written straight back (`cc-profiles.writeProfile`) — + * a refresh whose result is dropped leaves the profile holding a refresh + * token Anthropic has already retired, i.e. an account the user must log in + * to again. + * + * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> + */ + +const profiles = require("./cc-profiles"); + +/** Claude Code's own public OAuth client. A refresh is only accepted for the + * client the token was issued to, so this is not a configurable value. */ +const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; + +const USAGE_URL = "https://api.anthropic.com/api/oauth/usage"; +const TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"; + +/** Treat a token expiring within this window as already expired: a refresh is + * cheaper than a 401 that costs the whole call. */ +const EXPIRY_MARGIN_MS = 60_000; + +const TIMEOUT_MS = 15_000; + +function err(code, message) { + const e = new Error(message); + e.code = code; + return e; +} + +async function postJson(url, body, headers) { + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json", ...headers }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + const text = await res.text(); + if (!res.ok) throw err("EUPSTREAM", `${url} answered ${res.status}: ${text.slice(0, 200)}`); + try { + return JSON.parse(text); + } catch { + throw err("EUPSTREAM", `${url} did not answer JSON`); + } +} + +/** + * Exchange a profile's refresh token for a fresh access token AND persist what + * comes back. + * + * The persist is not an optimisation. Anthropic returns a new refresh token and + * retires the one just used, so a refresh that is not written back leaves the + * profile file holding a dead credential — the account would have to be logged + * into again before it could ever be activated. + * + * @param {string} name a saved profile's identifier + * @returns {Promise<string>} the fresh access token (never returned to a route) + */ +async function refreshProfile(name) { + const stored = profiles.readProfile(name); + if (!stored) throw err("ENOTFOUND", `no saved profile named ${name}`); + const creds = stored.credentials || {}; + if (!creds.refreshToken) { + throw err("ENOREFRESH", `profile ${name} has no refresh token — re-save it from a login`); + } + + const data = await postJson(TOKEN_URL, { + grant_type: "refresh_token", + refresh_token: creds.refreshToken, + client_id: CLIENT_ID, + }); + if (!data.access_token) throw err("EUPSTREAM", "the token endpoint returned no access_token"); + + profiles.writeProfile(name, { + ...stored, + credentials: { + ...creds, + accessToken: data.access_token, + // Both are rotated by the exchange; keeping the old refresh token here + // would pin the profile to a credential that no longer works. + refreshToken: data.refresh_token || creds.refreshToken, + expiresAt: Date.now() + (Number(data.expires_in) || 0) * 1000, + }, + }); + return data.access_token; +} + +/** The two windows the UI reports, flattened out of a response that also + * carries a dozen experiment-named buckets this has no use for. */ +function windowOf(value) { + if (!value || typeof value !== "object") return null; + const utilization = Number(value.utilization); + return { + utilization: Number.isFinite(utilization) ? utilization : null, + resetsAt: typeof value.resets_at === "string" ? value.resets_at : null, + }; +} + +/** Ask Anthropic what this token's account has spent. */ +async function fetchUsage(accessToken) { + const res = await fetch(USAGE_URL, { + headers: { + Authorization: `Bearer ${accessToken}`, + "anthropic-beta": "oauth-2025-04-20", + }, + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + if (res.status === 401) throw err("EUNAUTHORIZED", "the account rejected this token"); + if (!res.ok) throw err("EUPSTREAM", `the usage endpoint answered ${res.status}`); + const data = await res.json(); + return { fiveHour: windowOf(data.five_hour), sevenDay: windowOf(data.seven_day) }; +} + +/** + * Usage for one saved profile, refreshing its token first when that token has + * expired (which, for any profile that is not the active one, it usually has). + * + * @param {string} name a saved profile's identifier + * @returns {Promise<{name:string, refreshed:boolean, fiveHour:object|null, + * sevenDay:object|null, checkedAt:string}>} percentages and reset times only + */ +async function profileUsage(name) { + const stored = profiles.readProfile(name); + if (!stored) throw err("ENOTFOUND", `no saved profile named ${name}`); + const creds = stored.credentials || {}; + const fresh = Number(creds.expiresAt) > Date.now() + EXPIRY_MARGIN_MS; + + let token = fresh ? creds.accessToken : await refreshProfile(name); + let refreshed = !fresh; + if (!token) throw err("ENOAUTH", `profile ${name} holds no access token`); + + try { + const usage = await fetchUsage(token); + return { name, refreshed, ...usage, checkedAt: new Date().toISOString() }; + } catch (e) { + // A token that looked fresh but was revoked server-side is worth one + // retry through the refresh path; a second 401 is a real answer. + if (e.code !== "EUNAUTHORIZED" || refreshed) throw e; + token = await refreshProfile(name); + refreshed = true; + const usage = await fetchUsage(token); + return { name, refreshed, ...usage, checkedAt: new Date().toISOString() }; + } +} + +module.exports = { profileUsage, fetchUsage, refreshProfile, CLIENT_ID, USAGE_URL, TOKEN_URL }; diff --git a/server/lib/router-apply.js b/server/lib/router-apply.js index 555ee16f..b6a0ecc3 100644 --- a/server/lib/router-apply.js +++ b/server/lib/router-apply.js @@ -105,18 +105,21 @@ function gitDirOf(laneDir) { * must not fail an otherwise-successful attach. */ function excludeSettingsFromGit(laneDir) { - const line = ".claude/settings.local.json"; + // The backup directory beside it, for the same reason plus one more: those + // copies hold the API key too, and they are what a user sees as a dozen + // untracked files appearing in their project while they switch routers. + const lines = [".claude/settings.local.json", ".claude/.cc-config-backups/"]; try { const gitDir = gitDirOf(laneDir); if (!gitDir) return; const excludePath = path.join(gitDir, "info", "exclude"); fs.mkdirSync(path.dirname(excludePath), { recursive: true }); - const existing = fs.existsSync(excludePath) ? fs.readFileSync(excludePath, "utf8") : ""; - if (existing.split("\n").includes(line)) return; - fs.writeFileSync( - excludePath, - existing && !existing.endsWith("\n") ? `${existing}\n${line}\n` : `${existing}${line}\n` - ); + let existing = fs.existsSync(excludePath) ? fs.readFileSync(excludePath, "utf8") : ""; + const present = new Set(existing.split("\n")); + const missing = lines.filter((line) => !present.has(line)); + if (missing.length === 0) return; + if (existing && !existing.endsWith("\n")) existing += "\n"; + fs.writeFileSync(excludePath, `${existing}${missing.join("\n")}\n`); } catch { /* best effort — see above */ } diff --git a/server/openapi-extra/cc-config.js b/server/openapi-extra/cc-config.js index 75a28c1f..ab8692ac 100644 --- a/server/openapi-extra/cc-config.js +++ b/server/openapi-extra/cc-config.js @@ -1297,6 +1297,25 @@ const schemas = { }, }, }, + CcConfigUsageWindow: { + type: "object", + nullable: true, + description: "One quota window as the account reports it.", + properties: { + utilization: { + type: "number", + nullable: true, + description: "Percent of the window's allowance already spent, 0-100.", + example: 48, + }, + resetsAt: { + type: "string", + format: "date-time", + nullable: true, + description: "When the window rolls over, when the account reports one.", + }, + }, + }, }; // ── Reusable parameters (inlined per-operation to match base style) ──── @@ -2649,6 +2668,74 @@ const paths = { }, }, }, + "/api/cc-config/profiles/usage": { + post: { + tags: ["CcConfig"], + summary: "How much of a saved profile's account quota is already spent", + description: + "Answers `{ name, refreshed, fiveHour, sevenDay, checkedAt }`, each window being `{ utilization, resetsAt }` - a percentage and a rollover time. There is no local source for it: nothing under `CLAUDE_HOME` records a utilization and the credentials file carries only `subscriptionType`/`rateLimitTier`, so the server asks the endpoint the CLI's own `/usage` reads (`GET https://api.anthropic.com/api/oauth/usage`) with that profile's OAuth token.\n\nA POST, and behind the same-origin guard, because answering it is a WRITE. A saved profile's access token lasts hours, so every profile that is not the active one is normally expired; reading its usage refreshes it first, and the refresh ROTATES the stored credentials - Anthropic retires the refresh token it was handed. The new pair is written straight back into the profile file, because a refresh whose result is dropped leaves an account that can never be activated again without a fresh `claude auth login`. A still-valid token is used as-is and nothing is rewritten; a token that looks valid but is refused is retried once through the refresh. Nothing calls this on a timer - it is one profile per user request. The OAuth token is sent to Anthropic and nowhere else, and never appears in the response.", + operationId: "ccConfigProfileUsage", + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + required: ["name"], + properties: { name: { type: "string", example: "work" } }, + }, + }, + }, + }, + responses: { + 200: { + description: "Percentages and reset times for the two quota windows.", + content: { + "application/json": { + schema: { + type: "object", + properties: { + name: { type: "string" }, + refreshed: { + type: "boolean", + description: "True when the stored token was expired and had to be rotated.", + }, + fiveHour: { $ref: "#/components/schemas/CcConfigUsageWindow" }, + sevenDay: { $ref: "#/components/schemas/CcConfigUsageWindow" }, + checkedAt: { type: "string", format: "date-time" }, + }, + }, + }, + }, + }, + 401: { + description: "`EUNAUTHORIZED` - the account refused the token even after a refresh.", + content: { + "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } }, + }, + }, + 404: { + description: "`ENOTFOUND` - no profile by that name.", + content: { + "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } }, + }, + }, + 409: { + description: + "`ENOREFRESH`/`ENOAUTH` - the profile holds no usable token; re-save it from a login.", + content: { + "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } }, + }, + }, + 502: { + description: "`EUPSTREAM` - Anthropic's token or usage endpoint did not cooperate.", + content: { + "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } }, + }, + }, + }, + }, + }, }; module.exports = { tags, schemas, paths }; diff --git a/server/routes/cc-config.js b/server/routes/cc-config.js index 48a3f1f2..4ef3b935 100644 --- a/server/routes/cc-config.js +++ b/server/routes/cc-config.js @@ -30,6 +30,7 @@ const ccMutate = require("../lib/cc-mutate"); const ccCli = require("../lib/cc-plugin-cli"); const ccMcpCli = require("../lib/cc-mcp-cli"); const ccProfiles = require("../lib/cc-profiles"); +const ccUsage = require("../lib/cc-usage"); const projectMemory = require("../lib/project-memory"); const { sameOriginGuard } = require("../lib/same-origin"); const { broadcast } = require("../websocket"); @@ -577,6 +578,31 @@ router.post("/profiles/activate", (req, res) => { } }); +/** + * POST /profiles/usage — how much of this profile's account quota is spent. + * + * A POST, and behind the same-origin guard, because it is not a read: a saved + * profile's access token has almost always expired, so answering means + * refreshing it, and the refresh rotates the stored credentials on disk (see + * `cc-usage.js`). That is also why nothing calls this on a timer — the UI asks + * per profile, when the user asks. + * + * The response is two percentages and two reset timestamps. No token material + * crosses this boundary, in either direction: the name is all the caller sends. + */ +router.post("/profiles/usage", sameOriginGuard, async (req, res) => { + const name = (req.body || {}).name; + try { + res.json(await ccUsage.profileUsage(name)); + } catch (e) { + const status = + { ENOTFOUND: 404, EBADNAME: 400, ENOREFRESH: 409, ENOAUTH: 409, EUNAUTHORIZED: 401 }[ + e.code + ] || 502; + res.status(status).json({ error: { code: e.code || "EUPSTREAM", message: e.message } }); + } +}); + // Identifier travels in the body, not the URL — same reason as DELETE /file. router.delete("/profiles", (req, res) => { try { diff --git a/server/routes/lanes.js b/server/routes/lanes.js index a960ed5e..554c6497 100644 --- a/server/routes/lanes.js +++ b/server/routes/lanes.js @@ -649,6 +649,10 @@ router.post("/:id/router/apply", sameOriginGuard, (req, res) => { try { const result = applyRouterToLane(lane, profile); lanesLib.setRouterProfile(lane.id, profile); + // Every other mutating lane route broadcasts; without it the client's + // cached row keeps the OLD router_profile, and the next lane switch + // re-reads that stale row and flips the picker back. + broadcastLane(lane.id); res.json(result); } catch (err) { const status = { ENOTFOUND: 404, EBADJSON: 409, EBADNAME: 400 }[err.code] || 500;