Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ca55ff8ec5 | |||
| 0841d3e5c8 |
@@ -1,6 +1,6 @@
|
|||||||
# Claude Code Working Guide
|
# Claude Code Working Guide
|
||||||
|
|
||||||
_Last synced: v2.8.31 (2026-10-28) — spend quotas per user; the branch switcher lists and tracks remote-only branches._ 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. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
|
_Last synced: v2.8.32 (2026-10-28) — a Claude plan is the one Anthropic's profile endpoint reports now._ 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. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
|
||||||
|
|
||||||
## Project mission
|
## Project mission
|
||||||
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
||||||
|
|||||||
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
|||||||
<span
|
<span
|
||||||
class="text-status-warning/90"
|
class="text-status-warning/90"
|
||||||
>
|
>
|
||||||
UI build v2.8.31 (rebuild client to match)
|
UI build v2.8.32 (rebuild client to match)
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
-1
@@ -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]
|
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=<user id>::<token>` 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 `<n>d`/`<n>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=<user id>::<token>` 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 `<n>d`/`<n>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
|
#### Analytics
|
||||||
|
|
||||||
```http
|
```http
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.31",
|
"version": "2.8.32",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.31",
|
"version": "2.8.32",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.31",
|
"version": "2.8.32",
|
||||||
"bin": {
|
"bin": {
|
||||||
"agenthub": "bin/agenthub.js"
|
"agenthub": "bin/agenthub.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", () => {
|
it("planLabel reads the plan from the rate-limit tier and seat, not subscriptionType alone", () => {
|
||||||
const { planLabel } = profiles;
|
const { planLabel } = profiles;
|
||||||
// An upgrade keeps `subscriptionType: "pro"` from sign-in; the tier is the truth.
|
// 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(
|
||||||
assert.strictEqual(planLabel({ subscriptionType: "max", rateLimitTier: "default_claude_max_20x" }, null), "Max 20x");
|
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");
|
assert.strictEqual(planLabel({ subscriptionType: "pro" }, null), "Pro");
|
||||||
const team = { subscriptionType: "team", rateLimitTier: "default_raven" };
|
const team = { subscriptionType: "team", rateLimitTier: "default_raven" };
|
||||||
assert.strictEqual(planLabel(team, { seatTier: "team_standard" }), "Team Standard");
|
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(team, null), "Team");
|
||||||
assert.strictEqual(planLabel(null, null), null);
|
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");
|
||||||
|
});
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const pty = require("../lib/pty-run");
|
|||||||
|
|
||||||
const TOKEN = (c) => `sk-ant-oat01-${c.repeat(40)}`;
|
const TOKEN = (c) => `sk-ant-oat01-${c.repeat(40)}`;
|
||||||
const realFetchUsage = ccUsage.fetchUsage;
|
const realFetchUsage = ccUsage.fetchUsage;
|
||||||
|
const realFetchOwner = ccUsage.fetchOwner;
|
||||||
|
|
||||||
function run(id, account, { status = "completed", session = null, started, ended = null } = {}) {
|
function run(id, account, { status = "completed", session = null, started, ended = null } = {}) {
|
||||||
db.prepare(
|
db.prepare(
|
||||||
@@ -61,10 +62,12 @@ before(() => {
|
|||||||
ccUsage.fetchUsage = async () => {
|
ccUsage.fetchUsage = async () => {
|
||||||
throw Object.assign(new Error("offline"), { code: "EUNAUTHORIZED" });
|
throw Object.assign(new Error("offline"), { code: "EUNAUTHORIZED" });
|
||||||
};
|
};
|
||||||
|
ccUsage.fetchOwner = async () => null;
|
||||||
});
|
});
|
||||||
|
|
||||||
after(() => {
|
after(() => {
|
||||||
ccUsage.fetchUsage = realFetchUsage;
|
ccUsage.fetchUsage = realFetchUsage;
|
||||||
|
ccUsage.fetchOwner = realFetchOwner;
|
||||||
tmux.__setExecImpl(null);
|
tmux.__setExecImpl(null);
|
||||||
fs.rmSync(TMP, { recursive: true, force: true });
|
fs.rmSync(TMP, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const claudePool = require("../lib/claude-pool");
|
|||||||
const pins = require("../lib/account-pins");
|
const pins = require("../lib/account-pins");
|
||||||
|
|
||||||
require("../lib/cc-usage").fetchUsage = async () => ({});
|
require("../lib/cc-usage").fetchUsage = async () => ({});
|
||||||
|
require("../lib/cc-usage").fetchOwner = async () => null;
|
||||||
|
|
||||||
const KEY = (c) => `key_${c.repeat(40)}`;
|
const KEY = (c) => `key_${c.repeat(40)}`;
|
||||||
const TOKEN = (c) => `sk-ant-oat01-${c.repeat(40)}`;
|
const TOKEN = (c) => `sk-ant-oat01-${c.repeat(40)}`;
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const claudePool = require("../lib/claude-pool");
|
|||||||
const pins = require("../lib/account-pins");
|
const pins = require("../lib/account-pins");
|
||||||
|
|
||||||
require("../lib/cc-usage").fetchUsage = async () => ({});
|
require("../lib/cc-usage").fetchUsage = async () => ({});
|
||||||
|
require("../lib/cc-usage").fetchOwner = async () => null;
|
||||||
|
|
||||||
const BOSS_PASS = "admin-pass-1";
|
const BOSS_PASS = "admin-pass-1";
|
||||||
const KEY = (c) => `key_${c.repeat(40)}`;
|
const KEY = (c) => `key_${c.repeat(40)}`;
|
||||||
|
|||||||
@@ -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 () => {
|
test("the live row is the account its token belongs to, not the one .claude.json names", async () => {
|
||||||
const fs = require("node:fs");
|
const fs = require("node:fs");
|
||||||
const os = require("node:os");
|
const os = require("node:os");
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const cursorPool = require("../lib/cursor-pool");
|
|||||||
|
|
||||||
// The quota read would go to the network; the pool's list must not.
|
// The quota read would go to the network; the pool's list must not.
|
||||||
require("../lib/cc-usage").fetchUsage = async () => ({});
|
require("../lib/cc-usage").fetchUsage = async () => ({});
|
||||||
|
require("../lib/cc-usage").fetchOwner = async () => null;
|
||||||
|
|
||||||
let server;
|
let server;
|
||||||
let BASE;
|
let BASE;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const auth = require("../lib/auth");
|
|||||||
|
|
||||||
// The quota read would go to the network.
|
// The quota read would go to the network.
|
||||||
require("../lib/cc-usage").fetchUsage = async () => ({});
|
require("../lib/cc-usage").fetchUsage = async () => ({});
|
||||||
|
require("../lib/cc-usage").fetchOwner = async () => null;
|
||||||
|
|
||||||
let server;
|
let server;
|
||||||
let BASE;
|
let BASE;
|
||||||
|
|||||||
@@ -220,6 +220,40 @@ function planLabel(oauth, account) {
|
|||||||
return sub ? cap(sub) : null;
|
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. */
|
/** Token-free description of an account, safe to send to the browser. */
|
||||||
function identityOf({ oauth, account }) {
|
function identityOf({ oauth, account }) {
|
||||||
return {
|
return {
|
||||||
@@ -229,8 +263,8 @@ function identityOf({ oauth, account }) {
|
|||||||
organization: (account && account.organizationName) || null,
|
organization: (account && account.organizationName) || null,
|
||||||
seatTier: (account && account.seatTier) || null,
|
seatTier: (account && account.seatTier) || null,
|
||||||
subscriptionType: (oauth && oauth.subscriptionType) || null,
|
subscriptionType: (oauth && oauth.subscriptionType) || null,
|
||||||
/** The readable plan (`planLabel`) — what the UI shows. */
|
/** The readable plan (`shownPlan`: live when seen, else `planLabel`) — what the UI shows. */
|
||||||
plan: planLabel(oauth, account),
|
plan: shownPlan(oauth, account),
|
||||||
/** When the stored access token expires; a switch still works after that
|
/** When the stored access token expires; a switch still works after that
|
||||||
* (the refresh token is stored too), it just refreshes on first use. */
|
* (the refresh token is stored too), it just refreshes on first use. */
|
||||||
expiresAt: (oauth && oauth.expiresAt) || null,
|
expiresAt: (oauth && oauth.expiresAt) || null,
|
||||||
@@ -588,6 +622,9 @@ module.exports = {
|
|||||||
liveCredentialsFor,
|
liveCredentialsFor,
|
||||||
readActiveAuth,
|
readActiveAuth,
|
||||||
planLabel,
|
planLabel,
|
||||||
|
planFromProfile,
|
||||||
|
rememberLivePlan,
|
||||||
|
shownPlan,
|
||||||
// exported for tests
|
// exported for tests
|
||||||
profilesDir,
|
profilesDir,
|
||||||
configPaths,
|
configPaths,
|
||||||
|
|||||||
@@ -165,7 +165,8 @@ async function fetchUsage(accessToken) {
|
|||||||
const owners = new Map();
|
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
|
* 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
|
* different files (`oauthAccount` and the credentials store), and a running
|
||||||
* Claude Code session can leave them naming different accounts, so the token's
|
* 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),
|
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||||
});
|
});
|
||||||
if (!res.ok) return null;
|
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;
|
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
|
if (owners.size > 50) owners.clear(); // ponytail: crude cap; a few accounts per machine
|
||||||
owners.set(accessToken, owner);
|
owners.set(accessToken, owner);
|
||||||
return owner;
|
return owner;
|
||||||
|
|||||||
@@ -379,7 +379,7 @@ const inFlight = new Set();
|
|||||||
|
|
||||||
/** Read every account's quota whose reading is older than QUOTA_TTL_MS. */
|
/** Read every account's quota whose reading is older than QUOTA_TTL_MS. */
|
||||||
async function refreshQuotas({ force = false } = {}) {
|
async function refreshQuotas({ force = false } = {}) {
|
||||||
const { fetchUsage } = require("./cc-usage");
|
const { fetchUsage, fetchOwner } = require("./cc-usage");
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
allAccounts().map(async (a) => {
|
allAccounts().map(async (a) => {
|
||||||
const cached = quotaCache.get(a.name);
|
const cached = quotaCache.get(a.name);
|
||||||
@@ -388,7 +388,12 @@ async function refreshQuotas({ force = false } = {}) {
|
|||||||
}
|
}
|
||||||
inFlight.add(a.name);
|
inFlight.add(a.name);
|
||||||
try {
|
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 });
|
quotaCache.set(a.name, { at: Date.now(), ...u });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Keep the last good windows: a hiccup must not unblock a full account.
|
// Keep the last good windows: a hiccup must not unblock a full account.
|
||||||
@@ -436,7 +441,7 @@ function poolStatus() {
|
|||||||
kind: a.kind,
|
kind: a.kind,
|
||||||
email: a.kind === "login" ? (a.account?.emailAddress ?? null) : null,
|
email: a.kind === "login" ? (a.account?.emailAddress ?? null) : null,
|
||||||
// "Max 5x", "Team Premium"…; a setup-token says nothing of its plan.
|
// "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,
|
refreshError: a.kind === "login" ? (a.refreshError ?? null) : null,
|
||||||
addedAt: a.addedAt,
|
addedAt: a.addedAt,
|
||||||
expiresAt: expiresAt(a),
|
expiresAt: expiresAt(a),
|
||||||
|
|||||||
+12
-7
@@ -36,7 +36,7 @@
|
|||||||
* `AGENTHUB_AGY_USAGE=0` never runs it.
|
* `AGENTHUB_AGY_USAGE=0` never runs it.
|
||||||
*
|
*
|
||||||
* Each tool also carries its signed-in `account` (an email), its `plan` (the
|
* 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
|
* Codex's `chatgpt_plan_type` claim, Cursor's `membershipType`) and
|
||||||
* `expiresAt`, when its stored access token runs out — Claude's
|
* `expiresAt`, when its stored access token runs out — Claude's
|
||||||
* `claudeAiOauth.expiresAt`, Codex's and Cursor's access-token `exp` (claims
|
* `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 { promisify } = require("node:util");
|
||||||
const aiTools = require("./ai-tools");
|
const aiTools = require("./ai-tools");
|
||||||
const { getCodexHome } = require("./codex-home");
|
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 { fetchUsage, fetchOwner } = require("./cc-usage");
|
||||||
const { allRolloutFiles } = require("./codex-ingest");
|
const { allRolloutFiles } = require("./codex-ingest");
|
||||||
const { getCursorDir } = require("./cursor-usage");
|
const { getCursorDir } = require("./cursor-usage");
|
||||||
@@ -122,7 +122,8 @@ async function usageFor(token, uuid) {
|
|||||||
code: "EWRONGACCOUNT",
|
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: `<home>/.claude/.credentials.json` + `.claude.json`. */
|
/** A member's Claude login: `<home>/.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
|
if (!oauth || !oauth.accessToken) return null; // API-key user or not logged in
|
||||||
who = {
|
who = {
|
||||||
account: account?.emailAddress || account?.displayName || null,
|
account: account?.emailAddress || account?.displayName || null,
|
||||||
plan: planLabel(oauth, account),
|
plan: shownPlan(oauth, account),
|
||||||
expiresAt: iso(oauth.expiresAt),
|
expiresAt: iso(oauth.expiresAt),
|
||||||
};
|
};
|
||||||
if (!fresh(oauth.expiresAt)) return { ...who, windows: [], error: "ELIVEEXPIRED" };
|
if (!fresh(oauth.expiresAt)) return { ...who, windows: [], error: "ELIVEEXPIRED" };
|
||||||
@@ -154,6 +155,8 @@ async function liveClaude(readAuth = readActiveAuth) {
|
|||||||
return {
|
return {
|
||||||
...who,
|
...who,
|
||||||
account: owner?.email || who.account,
|
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,
|
ownerUuid: owner?.uuid || account?.accountUuid || null,
|
||||||
windows: claudeWindows(usage),
|
windows: claudeWindows(usage),
|
||||||
};
|
};
|
||||||
@@ -183,9 +186,9 @@ async function profileClaude(item) {
|
|||||||
}
|
}
|
||||||
if (creds?.accessToken && fresh(creds.expiresAt)) {
|
if (creds?.accessToken && fresh(creds.expiresAt)) {
|
||||||
try {
|
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() });
|
lastGood.set(item.name, { windows, at: Date.now() });
|
||||||
return { ...who, windows };
|
return { ...who, plan: plan || who.plan, windows };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { ...who, windows: [], error: e.code || "EUPSTREAM" };
|
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. */
|
/** Codex's ChatGPT login from `auth.json`: email, plan + access-token expiry. */
|
||||||
function codexAccount(codexHome = getCodexHome()) {
|
function codexAccount(codexHome = getCodexHome()) {
|
||||||
try {
|
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 { exp } = jwtClaims(tokens.access_token);
|
||||||
const id = jwtClaims(tokens.id_token);
|
const id = jwtClaims(tokens.id_token);
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user