feat(cc-config,lanes): per-profile quota, and three fixes that made a write look lost

Closes #19, #20, #21, #27.

#19 — a saved auth profile now reports how much of its account's quota is
already spent, which is the only number that decides which one is worth
switching to. Nothing under CLAUDE_HOME records it, so it comes from the same
endpoint the CLI's own /usage reads, answered for whichever account's OAuth
token is presented. It is a POST and a button, never a poll: every profile that
is not the active one is normally expired, so answering means refreshing it
first, and a refresh ROTATES the stored refresh token. The rotated pair is
written straight back — dropping it would leave the profile holding a
credential Anthropic has already retired, i.e. an account needing a fresh
login. The token is read, put in an Authorization header, and never returned,
logged or echoed; what leaves the server is two percentages and two reset
times.

#20 — a settings patch that changes nothing wrote nothing but still took a
timestamped backup, so opening the config panel quietly grew a backup
directory inside every lane. patchSettings now compares the rendered output
with what is already on disk and returns early, fixed once where all callers
route through. A lane's git exclude also picks up .claude/.cc-config-backups/
beside settings.local.json.

#21 — the router apply route was the only mutating lane route that never
broadcast its lane. The write reached disk and the column, but the client's
cached row kept the old router_profile forever: the picker looked right until
the user switched lanes and back, at which point the effect re-read the stale
row and flipped the selection to the previous profile. One broadcastLane, plus
a lane-switch guard so an apply that resolves after the user has moved on no
longer paints its result onto whichever lane is now on screen.

#27 — Add-lane in worktree mode left the dialog up after a clean setup with a
dead confirm button and no obvious way out. It closes itself when nothing
failed, and when something did, the summary stays and the confirm becomes a
real Close.
This commit is contained in:
2026-09-17 22:20:32 +07:00
parent b0e8368c1d
commit 5ba9e07d78
25 changed files with 1169 additions and 30 deletions
+6 -1
View File
@@ -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 `<CLAUDE_HOME>/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: <n>, failed: ["<id> (<title>): <code>", …] }`; a lane whose directory is gone or whose settings file a human has broken is reported there, never fatal to the edit.