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
+44
View File
@@ -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", () => {