From 0841d3e5c84fce817efe2293675c1b8ff1e241df Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Wed, 28 Oct 2026 15:09:30 +0700 Subject: [PATCH] fix(plan): a Claude plan is the one Anthropic reports now, not the sign-in's An account signed in on Pro and upgraded to Max 5x kept showing "Pro": the plan was read only from .credentials.json / .claude.json, whose subscriptionType and rate-limit tiers are written at sign-in. fetchOwner already asks GET /api/oauth/profile for a token's owner; it now also reads organization_type / rate_limit_tier / seat_tier into a plan (planFromProfile), remembered per account uuid (rememberLivePlan). The live row, saved profiles (identityOf) and pool accounts (poolStatus, whose quota refresh now asks a login's owner too) show that plan when one was seen (shownPlan), else planLabel of the files as before. --- docs/API.md | 2 +- server/__tests__/cc-profiles.test.js | 38 +++++++++++- server/__tests__/claude-pool.test.js | 3 + server/__tests__/pool-account-choice.test.js | 1 + server/__tests__/pool-groups.test.js | 1 + server/__tests__/quota.test.js | 59 +++++++++++++++++++ .../server-mode-account-pool.test.js | 1 + .../server-mode-user-details.test.js | 1 + server/lib/cc-profiles.js | 41 ++++++++++++- server/lib/cc-usage.js | 12 +++- server/lib/claude-pool.js | 11 +++- server/lib/quota.js | 19 +++--- 12 files changed, 171 insertions(+), 18 deletions(-) diff --git a/docs/API.md b/docs/API.md index 8d822c4d..ebd267bd 100644 --- a/docs/API.md +++ b/docs/API.md @@ -2588,7 +2588,7 @@ curl "http://localhost:4820/api/pricing/cost?tz_offset=-420&from=2026-09-01&to=2 GET /api/pricing/quota[?force=1] ``` -How much of each native tool's plan quota is spent: `{ tools: [{ id: "claude"|"codex"|"antigravity"|"cursor", active, label?, account?, plan?, expiresAt?, windows: [{ label, usedPercent, resetsAt, group? }], error?, asOf? }], checkedAt }` (`server/lib/quota.js`). **Claude** asks Anthropic's `GET /api/oauth/usage` with the access token the CLI holds right now — never refreshed, so an expired token answers `error: "ELIVEEXPIRED"` until Claude Code refreshes it — cached 5 min (`force=1`, the status bar's click, re-asks at most every 30 s); no OAuth login, no entry. That live account is `active: true`; every OTHER saved auth profile follows as `active: false` with its `label`, asked with its OWN stored token only while that token is valid — never refreshed here, since a refresh rotates the saved credentials (only the Profiles modal's **Check quota** does that) — and, once expired, showing the last figure this server read (`asOf`) or `error: "EPROFILEEXPIRED"`. Each Claude token's owner is also asked (`GET /api/oauth/profile`): the live row carries the label and email of the account its token belongs to (`.claude.json` can name another), and a saved profile whose token belongs to a different account answers `error: "EWRONGACCOUNT"` with no windows, never that other account's usage. **Codex** reads the newest `rate_limits` from its rollout journals, no network (`asOf` = when the journal recorded it; a window past its reset reads 0). **Antigravity** runs the catalog-detected `agy` as `agy --print /usage --output-format json` — the CLI's own `/usage` command, no model call (`num_turns` 0, no conversation) — in a private empty temp directory with a 60 s timeout, only while the tool is enabled in AI Tools and `agy --version` is ≥ 1.1.11 (an older agy may bill `/usage` as a model request); cached like Claude, a failed run shows the last figure (`asOf`) or `error: "EUPSTREAM"`. One window per pool bucket (`5h`, `wk`), `group` naming the pool ("Gemini Models", "Claude and GPT models"); the report names no account and no plan. Running agy may let it update itself; `AGENTHUB_AGY_USAGE=0` never runs it (no entry). **Cursor** reads the Cursor CLI's (`cursor-agent`) own login, `$XDG_CONFIG_HOME/cursor/auth.json` (default `~/.config`), and sends it ONLY to cursor.com's undocumented `GET /api/usage-summary` as the `WorkosCursorSessionToken=::` cookie (user id from `~/.cursor/cli-config.json` `authInfo`), while its JWT is valid and never refreshed — expired or a 401 is `error: "ETOKENEXPIRED"`; cached like Claude; no file (macOS keeps it in the keychain), no entry. One `mo` window: `individualUsage.plan.totalPercentUsed`, resetting at `billingCycleEnd` (an Enterprise seat without `plan` shows its plan name and no window). `plan` is the subscription's name — Claude's read from the rate-limit tier and seat ("Max 5x", "Team Standard", "Team Premium", else `subscriptionType` title-cased; `subscriptionType` alone says `team` for both seats and keeps `pro` after an upgrade), Codex's `chatgpt_plan_type` id-token claim, Cursor's `membershipType` — shown as a badge beside the account. `account` is the signed-in email and `expiresAt` when the stored access token runs out — Claude's `claudeAiOauth.expiresAt`, Codex's and Cursor's access-token `exp` (JWT claims read unverified; no token leaves the server but Cursor's own, to cursor.com). Labels: `5h`, `wk`, `mo`, else `d`/`h` (the hover spells `wk`/`mo` out as week/month). The status bar shows one "N% used" chip per tool (its live account) at its right end and, on hover, every account grouped per tool with a row per window — bar, percent, reset time. **Server mode with S3** (one Linux user per account): a developer or analyst gets ONLY their own account's rows, read from their Linux home at the tools' default paths (`~/.claude`, `~/.codex`, `~/.config/cursor` + `~/.cursor`) — never the host's — and only from files that account owns (open + `fstat` owner check, a FIFO refused). No saved Claude profiles and no Antigravity row for them (agy's `/usage` needs the self-updating binary, so it stays the host's); an account that is not provisioned gets `tools: []`. An admin gets the host's, as before. +How much of each native tool's plan quota is spent: `{ tools: [{ id: "claude"|"codex"|"antigravity"|"cursor", active, label?, account?, plan?, expiresAt?, windows: [{ label, usedPercent, resetsAt, group? }], error?, asOf? }], checkedAt }` (`server/lib/quota.js`). **Claude** asks Anthropic's `GET /api/oauth/usage` with the access token the CLI holds right now — never refreshed, so an expired token answers `error: "ELIVEEXPIRED"` until Claude Code refreshes it — cached 5 min (`force=1`, the status bar's click, re-asks at most every 30 s); no OAuth login, no entry. That live account is `active: true`; every OTHER saved auth profile follows as `active: false` with its `label`, asked with its OWN stored token only while that token is valid — never refreshed here, since a refresh rotates the saved credentials (only the Profiles modal's **Check quota** does that) — and, once expired, showing the last figure this server read (`asOf`) or `error: "EPROFILEEXPIRED"`. Each Claude token's owner is also asked (`GET /api/oauth/profile`): the live row carries the label and email of the account its token belongs to (`.claude.json` can name another), and a saved profile whose token belongs to a different account answers `error: "EWRONGACCOUNT"` with no windows, never that other account's usage. **Codex** reads the newest `rate_limits` from its rollout journals, no network (`asOf` = when the journal recorded it; a window past its reset reads 0). **Antigravity** runs the catalog-detected `agy` as `agy --print /usage --output-format json` — the CLI's own `/usage` command, no model call (`num_turns` 0, no conversation) — in a private empty temp directory with a 60 s timeout, only while the tool is enabled in AI Tools and `agy --version` is ≥ 1.1.11 (an older agy may bill `/usage` as a model request); cached like Claude, a failed run shows the last figure (`asOf`) or `error: "EUPSTREAM"`. One window per pool bucket (`5h`, `wk`), `group` naming the pool ("Gemini Models", "Claude and GPT models"); the report names no account and no plan. Running agy may let it update itself; `AGENTHUB_AGY_USAGE=0` never runs it (no entry). **Cursor** reads the Cursor CLI's (`cursor-agent`) own login, `$XDG_CONFIG_HOME/cursor/auth.json` (default `~/.config`), and sends it ONLY to cursor.com's undocumented `GET /api/usage-summary` as the `WorkosCursorSessionToken=::` cookie (user id from `~/.cursor/cli-config.json` `authInfo`), while its JWT is valid and never refreshed — expired or a 401 is `error: "ETOKENEXPIRED"`; cached like Claude; no file (macOS keeps it in the keychain), no entry. One `mo` window: `individualUsage.plan.totalPercentUsed`, resetting at `billingCycleEnd` (an Enterprise seat without `plan` shows its plan name and no window). `plan` is the subscription's name — Claude's as `GET /api/oauth/profile` reports it NOW (`organization.organization_type` / `rate_limit_tier` / `seat_tier`, remembered per account so saved profiles and pool accounts show it too), else read from the stored rate-limit tier and seat ("Max 5x", "Team Standard", "Team Premium", else `subscriptionType` title-cased; the stored fields are written at sign-in, so they keep `pro` after an upgrade to Max), Codex's `chatgpt_plan_type` id-token claim, Cursor's `membershipType` — shown as a badge beside the account. `account` is the signed-in email and `expiresAt` when the stored access token runs out — Claude's `claudeAiOauth.expiresAt`, Codex's and Cursor's access-token `exp` (JWT claims read unverified; no token leaves the server but Cursor's own, to cursor.com). Labels: `5h`, `wk`, `mo`, else `d`/`h` (the hover spells `wk`/`mo` out as week/month). The status bar shows one "N% used" chip per tool (its live account) at its right end and, on hover, every account grouped per tool with a row per window — bar, percent, reset time. **Server mode with S3** (one Linux user per account): a developer or analyst gets ONLY their own account's rows, read from their Linux home at the tools' default paths (`~/.claude`, `~/.codex`, `~/.config/cursor` + `~/.cursor`) — never the host's — and only from files that account owns (open + `fstat` owner check, a FIFO refused). No saved Claude profiles and no Antigravity row for them (agy's `/usage` needs the self-updating binary, so it stays the host's); an account that is not provisioned gets `tools: []`. An admin gets the host's, as before. #### Analytics ```http diff --git a/server/__tests__/cc-profiles.test.js b/server/__tests__/cc-profiles.test.js index 54f3bb5c..307a8ff3 100644 --- a/server/__tests__/cc-profiles.test.js +++ b/server/__tests__/cc-profiles.test.js @@ -448,8 +448,14 @@ describe("cc-profiles — identity and token disagree", () => { it("planLabel reads the plan from the rate-limit tier and seat, not subscriptionType alone", () => { const { planLabel } = profiles; // An upgrade keeps `subscriptionType: "pro"` from sign-in; the tier is the truth. - assert.strictEqual(planLabel({ subscriptionType: "pro", rateLimitTier: "default_claude_max_5x" }, null), "Max 5x"); - assert.strictEqual(planLabel({ subscriptionType: "max", rateLimitTier: "default_claude_max_20x" }, null), "Max 20x"); + assert.strictEqual( + planLabel({ subscriptionType: "pro", rateLimitTier: "default_claude_max_5x" }, null), + "Max 5x" + ); + assert.strictEqual( + planLabel({ subscriptionType: "max", rateLimitTier: "default_claude_max_20x" }, null), + "Max 20x" + ); assert.strictEqual(planLabel({ subscriptionType: "pro" }, null), "Pro"); const team = { subscriptionType: "team", rateLimitTier: "default_raven" }; assert.strictEqual(planLabel(team, { seatTier: "team_standard" }), "Team Standard"); @@ -462,3 +468,31 @@ it("planLabel reads the plan from the rate-limit tier and seat, not subscription assert.strictEqual(planLabel(team, null), "Team"); assert.strictEqual(planLabel(null, null), null); }); + +it("the profile endpoint's live plan wins over files written at sign-in", () => { + const { planFromProfile, rememberLivePlan, shownPlan } = profiles; + assert.strictEqual( + planFromProfile({ organization_type: "claude_max", rate_limit_tier: "default_claude_max_5x" }), + "Max 5x" + ); + assert.strictEqual( + planFromProfile({ + organization_type: "claude_team", + rate_limit_tier: "default_raven", + seat_tier: "team_standard", + }), + "Team Standard" + ); + assert.strictEqual(planFromProfile({ organization_type: "claude_pro" }), "Pro"); + assert.strictEqual(planFromProfile(null), null); + + // Signed in on Pro, upgraded to Max 5x since: the files still say Pro. + const oauth = { subscriptionType: "pro", rateLimitTier: "default_claude_pro" }; + const account = { accountUuid: "acct-upgraded" }; + assert.strictEqual(shownPlan(oauth, account), "Pro"); + rememberLivePlan("acct-upgraded", "Max 5x"); + assert.strictEqual(shownPlan(oauth, account), "Max 5x"); + rememberLivePlan("acct-upgraded", null); // an unknown answer keeps the last one + assert.strictEqual(shownPlan(oauth, account), "Max 5x"); + assert.strictEqual(shownPlan(oauth, { accountUuid: "other" }), "Pro"); +}); diff --git a/server/__tests__/claude-pool.test.js b/server/__tests__/claude-pool.test.js index 07e40a24..c3192bf0 100644 --- a/server/__tests__/claude-pool.test.js +++ b/server/__tests__/claude-pool.test.js @@ -26,6 +26,7 @@ const pty = require("../lib/pty-run"); const TOKEN = (c) => `sk-ant-oat01-${c.repeat(40)}`; const realFetchUsage = ccUsage.fetchUsage; +const realFetchOwner = ccUsage.fetchOwner; function run(id, account, { status = "completed", session = null, started, ended = null } = {}) { db.prepare( @@ -61,10 +62,12 @@ before(() => { ccUsage.fetchUsage = async () => { throw Object.assign(new Error("offline"), { code: "EUNAUTHORIZED" }); }; + ccUsage.fetchOwner = async () => null; }); after(() => { ccUsage.fetchUsage = realFetchUsage; + ccUsage.fetchOwner = realFetchOwner; tmux.__setExecImpl(null); fs.rmSync(TMP, { recursive: true, force: true }); }); diff --git a/server/__tests__/pool-account-choice.test.js b/server/__tests__/pool-account-choice.test.js index 881182c1..442048e0 100644 --- a/server/__tests__/pool-account-choice.test.js +++ b/server/__tests__/pool-account-choice.test.js @@ -30,6 +30,7 @@ const claudePool = require("../lib/claude-pool"); const pins = require("../lib/account-pins"); require("../lib/cc-usage").fetchUsage = async () => ({}); +require("../lib/cc-usage").fetchOwner = async () => null; const KEY = (c) => `key_${c.repeat(40)}`; const TOKEN = (c) => `sk-ant-oat01-${c.repeat(40)}`; diff --git a/server/__tests__/pool-groups.test.js b/server/__tests__/pool-groups.test.js index 64c57bb2..6c2c40ce 100644 --- a/server/__tests__/pool-groups.test.js +++ b/server/__tests__/pool-groups.test.js @@ -30,6 +30,7 @@ const claudePool = require("../lib/claude-pool"); const pins = require("../lib/account-pins"); require("../lib/cc-usage").fetchUsage = async () => ({}); +require("../lib/cc-usage").fetchOwner = async () => null; const BOSS_PASS = "admin-pass-1"; const KEY = (c) => `key_${c.repeat(40)}`; diff --git a/server/__tests__/quota.test.js b/server/__tests__/quota.test.js index 4aeb2b50..d0dc9111 100644 --- a/server/__tests__/quota.test.js +++ b/server/__tests__/quota.test.js @@ -170,6 +170,65 @@ test("a profile holding another account's token shows EWRONGACCOUNT, not that ac } }); +test("the live plan is the profile endpoint's, not the one written at sign-in", async () => { + const fs = require("node:fs"); + const os = require("node:os"); + const path = require("node:path"); + const { quotas, __resetCache } = require("../lib/quota"); + const root = fs.mkdtempSync(path.join(os.tmpdir(), "quota-plan-")); + const home = path.join(root, ".claude"); + fs.mkdirSync(home, { recursive: true }); + // Signed in on Pro; upgraded to Max 5x since — the files never learned. + fs.writeFileSync( + path.join(home, ".credentials.json"), + JSON.stringify({ + claudeAiOauth: { + accessToken: "live-upgraded", + expiresAt: Date.now() + 3600_000, + subscriptionType: "pro", + rateLimitTier: "default_claude_pro", + }, + }) + ); + fs.writeFileSync( + `${home}.json`, + JSON.stringify({ oauthAccount: { accountUuid: "uuid-up", emailAddress: "up@x.y" } }) + ); + const prev = { home: process.env.CLAUDE_HOME, fetch: global.fetch }; + process.env.CLAUDE_HOME = home; + global.fetch = async (url) => ({ + status: 200, + ok: true, + json: async () => + url.endsWith("/profile") + ? { + account: { uuid: "uuid-up", email: "up@x.y" }, + organization: { + organization_type: "claude_max", + rate_limit_tier: "default_claude_max_5x", + }, + } + : { five_hour: { utilization: 10, resets_at: null } }, + }); + try { + __resetCache(); + const live = (await quotas()).tools.find((t) => t.id === "claude" && t.active); + assert.strictEqual(live.plan, "Max 5x"); + // Remembered: a later read from the files alone says the same. + const { shownPlan } = require("../lib/cc-profiles"); + assert.strictEqual( + shownPlan({ subscriptionType: "pro" }, { accountUuid: "uuid-up" }), + "Max 5x" + ); + } finally { + if (prev.home === undefined) delete process.env.CLAUDE_HOME; + else process.env.CLAUDE_HOME = prev.home; + global.fetch = prev.fetch; + __resetCache(); + fs.rmSync(root, { recursive: true, force: true }); + } +}); + test("the live row is the account its token belongs to, not the one .claude.json names", async () => { const fs = require("node:fs"); const os = require("node:os"); diff --git a/server/__tests__/server-mode-account-pool.test.js b/server/__tests__/server-mode-account-pool.test.js index 3cfa4b8f..c57876fa 100644 --- a/server/__tests__/server-mode-account-pool.test.js +++ b/server/__tests__/server-mode-account-pool.test.js @@ -29,6 +29,7 @@ const cursorPool = require("../lib/cursor-pool"); // The quota read would go to the network; the pool's list must not. require("../lib/cc-usage").fetchUsage = async () => ({}); +require("../lib/cc-usage").fetchOwner = async () => null; let server; let BASE; diff --git a/server/__tests__/server-mode-user-details.test.js b/server/__tests__/server-mode-user-details.test.js index 712ccf08..06d53d73 100644 --- a/server/__tests__/server-mode-user-details.test.js +++ b/server/__tests__/server-mode-user-details.test.js @@ -26,6 +26,7 @@ const auth = require("../lib/auth"); // The quota read would go to the network. require("../lib/cc-usage").fetchUsage = async () => ({}); +require("../lib/cc-usage").fetchOwner = async () => null; let server; let BASE; diff --git a/server/lib/cc-profiles.js b/server/lib/cc-profiles.js index 77a275b1..6ec8dba1 100644 --- a/server/lib/cc-profiles.js +++ b/server/lib/cc-profiles.js @@ -220,6 +220,40 @@ function planLabel(oauth, account) { return sub ? cap(sub) : null; } +/** + * The plan Anthropic's profile endpoint reports for an account RIGHT NOW, + * keyed by account uuid (cc-usage.js `fetchOwner` fills it). The stored + * `subscriptionType` and rate-limit tiers are written at sign-in, so an + * account upgraded since (Pro → Max 5x) keeps reading as its old plan from + * the files alone; the live answer wins wherever one was seen. + */ +const livePlans = new Map(); + +/** + * `planLabel` of the profile endpoint's `organization` (`organization_type` + * `claude_max`, `rate_limit_tier` `default_claude_max_5x`, `seat_tier`), or null. + */ +function planFromProfile(org) { + if (!org || typeof org !== "object") return null; + const type = String(org.organization_type || ""); + return planLabel( + { subscriptionType: type.replace(/^claude_/, ""), rateLimitTier: org.rate_limit_tier || "" }, + { seatTier: org.seat_tier || "", organizationType: type } + ); +} + +/** Remember an account's live plan (null/unknown leaves the last one). */ +function rememberLivePlan(accountUuid, plan) { + if (!accountUuid || !plan) return; + if (livePlans.size > 100) livePlans.clear(); // a few accounts per machine + livePlans.set(accountUuid, plan); +} + +/** The plan to show: the live one when seen, else what the files say. */ +function shownPlan(oauth, account) { + return livePlans.get(account?.accountUuid) || planLabel(oauth, account); +} + /** Token-free description of an account, safe to send to the browser. */ function identityOf({ oauth, account }) { return { @@ -229,8 +263,8 @@ function identityOf({ oauth, account }) { organization: (account && account.organizationName) || null, seatTier: (account && account.seatTier) || null, subscriptionType: (oauth && oauth.subscriptionType) || null, - /** The readable plan (`planLabel`) — what the UI shows. */ - plan: planLabel(oauth, account), + /** The readable plan (`shownPlan`: live when seen, else `planLabel`) — what the UI shows. */ + plan: shownPlan(oauth, account), /** When the stored access token expires; a switch still works after that * (the refresh token is stored too), it just refreshes on first use. */ expiresAt: (oauth && oauth.expiresAt) || null, @@ -588,6 +622,9 @@ module.exports = { liveCredentialsFor, readActiveAuth, planLabel, + planFromProfile, + rememberLivePlan, + shownPlan, // exported for tests profilesDir, configPaths, diff --git a/server/lib/cc-usage.js b/server/lib/cc-usage.js index ae92f062..d78287ab 100644 --- a/server/lib/cc-usage.js +++ b/server/lib/cc-usage.js @@ -165,7 +165,8 @@ async function fetchUsage(accessToken) { const owners = new Map(); /** - * The account a token belongs to — `{uuid, email}` — or null when the profile + * The account a token belongs to — `{uuid, email, plan}` (`plan` as the + * profile endpoint reports it now, see cc-profiles.js `livePlans`) — or null when the profile * endpoint does not say. A saved profile's label and its token come from two * different files (`oauthAccount` and the credentials store), and a running * Claude Code session can leave them naming different accounts, so the token's @@ -179,9 +180,14 @@ async function fetchOwner(accessToken) { signal: AbortSignal.timeout(TIMEOUT_MS), }); if (!res.ok) return null; - const account = (await res.json())?.account; + const body = await res.json(); + const account = body?.account; if (!account?.uuid) return null; - const owner = { uuid: account.uuid, email: account.email || null }; + // The plan as it is NOW (an upgrade since sign-in included): the stored + // files keep the plan the account had when it signed in. + const plan = profiles.planFromProfile(body.organization); + profiles.rememberLivePlan(account.uuid, plan); + const owner = { uuid: account.uuid, email: account.email || null, plan }; if (owners.size > 50) owners.clear(); // ponytail: crude cap; a few accounts per machine owners.set(accessToken, owner); return owner; diff --git a/server/lib/claude-pool.js b/server/lib/claude-pool.js index 0b758bed..ec1da18e 100644 --- a/server/lib/claude-pool.js +++ b/server/lib/claude-pool.js @@ -379,7 +379,7 @@ const inFlight = new Set(); /** Read every account's quota whose reading is older than QUOTA_TTL_MS. */ async function refreshQuotas({ force = false } = {}) { - const { fetchUsage } = require("./cc-usage"); + const { fetchUsage, fetchOwner } = require("./cc-usage"); await Promise.all( allAccounts().map(async (a) => { const cached = quotaCache.get(a.name); @@ -388,7 +388,12 @@ async function refreshQuotas({ force = false } = {}) { } inFlight.add(a.name); try { - const u = await fetchUsage(bearerOf(a)); + // A login's owner too: the profile endpoint's answer is its plan as it + // is now (cc-profiles.js livePlans), the stored one is the sign-in's. + const [u] = await Promise.all([ + fetchUsage(bearerOf(a)), + a.kind === "login" ? fetchOwner(bearerOf(a)) : null, + ]); quotaCache.set(a.name, { at: Date.now(), ...u }); } catch (e) { // Keep the last good windows: a hiccup must not unblock a full account. @@ -436,7 +441,7 @@ function poolStatus() { kind: a.kind, email: a.kind === "login" ? (a.account?.emailAddress ?? null) : null, // "Max 5x", "Team Premium"…; a setup-token says nothing of its plan. - plan: a.kind === "login" ? require("./cc-profiles").planLabel(a.oauth, a.account) : null, + plan: a.kind === "login" ? require("./cc-profiles").shownPlan(a.oauth, a.account) : null, refreshError: a.kind === "login" ? (a.refreshError ?? null) : null, addedAt: a.addedAt, expiresAt: expiresAt(a), diff --git a/server/lib/quota.js b/server/lib/quota.js index 8bbb973c..e4c2905c 100644 --- a/server/lib/quota.js +++ b/server/lib/quota.js @@ -36,7 +36,7 @@ * `AGENTHUB_AGY_USAGE=0` never runs it. * * Each tool also carries its signed-in `account` (an email), its `plan` (the - * subscription's name: Claude's `planLabel` (tier + seat, not the coarse `subscriptionType`), + * subscription's name: Claude's plan as the profile endpoint reports it now, else `planLabel` of the stored files (tier + seat, not the coarse `subscriptionType`), * Codex's `chatgpt_plan_type` claim, Cursor's `membershipType`) and * `expiresAt`, when its stored access token runs out — Claude's * `claudeAiOauth.expiresAt`, Codex's and Cursor's access-token `exp` (claims @@ -60,7 +60,7 @@ const { execFile } = require("node:child_process"); const { promisify } = require("node:util"); const aiTools = require("./ai-tools"); const { getCodexHome } = require("./codex-home"); -const { readActiveAuth, readProfile, listProfiles, planLabel } = require("./cc-profiles"); +const { readActiveAuth, readProfile, listProfiles, shownPlan } = require("./cc-profiles"); const { fetchUsage, fetchOwner } = require("./cc-usage"); const { allRolloutFiles } = require("./codex-ingest"); const { getCursorDir } = require("./cursor-usage"); @@ -122,7 +122,8 @@ async function usageFor(token, uuid) { code: "EWRONGACCOUNT", }); } - return claudeWindows(usage); + // The plan as the profile endpoint says it now (null: it did not answer). + return { windows: claudeWindows(usage), plan: owner?.plan || null }; } /** A member's Claude login: `/.claude/.credentials.json` + `.claude.json`. */ @@ -142,7 +143,7 @@ async function liveClaude(readAuth = readActiveAuth) { if (!oauth || !oauth.accessToken) return null; // API-key user or not logged in who = { account: account?.emailAddress || account?.displayName || null, - plan: planLabel(oauth, account), + plan: shownPlan(oauth, account), expiresAt: iso(oauth.expiresAt), }; if (!fresh(oauth.expiresAt)) return { ...who, windows: [], error: "ELIVEEXPIRED" }; @@ -154,6 +155,8 @@ async function liveClaude(readAuth = readActiveAuth) { return { ...who, account: owner?.email || who.account, + // Live: the files keep the plan of the sign-in, not of an upgrade since. + plan: owner?.plan || who.plan, ownerUuid: owner?.uuid || account?.accountUuid || null, windows: claudeWindows(usage), }; @@ -183,9 +186,9 @@ async function profileClaude(item) { } if (creds?.accessToken && fresh(creds.expiresAt)) { try { - const windows = await usageFor(creds.accessToken, item.accountUuid); + const { windows, plan } = await usageFor(creds.accessToken, item.accountUuid); lastGood.set(item.name, { windows, at: Date.now() }); - return { ...who, windows }; + return { ...who, plan: plan || who.plan, windows }; } catch (e) { return { ...who, windows: [], error: e.code || "EUPSTREAM" }; } @@ -289,7 +292,9 @@ function jwtClaims(token) { /** Codex's ChatGPT login from `auth.json`: email, plan + access-token expiry. */ function codexAccount(codexHome = getCodexHome()) { try { - const { tokens } = JSON.parse(readFileSafe(path.join(codexHome, "auth.json"), "utf8", { max: 1 << 20 })); + const { tokens } = JSON.parse( + readFileSafe(path.join(codexHome, "auth.json"), "utf8", { max: 1 << 20 }) + ); const { exp } = jwtClaims(tokens.access_token); const id = jwtClaims(tokens.id_token); return {