feat(pool): one board of pools with split/merge by drag; plan names from tier and seat

- The Users page account pool is one board: a column per pool holding its
  Claude Code, Codex and Cursor accounts (plan, quota, live panes) and its
  members. Accounts sit in one pool; drag moves them. Drop on New pool to
  split one off; drag a pool's only account into another pool to merge
  (members follow, the emptied pool is deleted on Save). One password Save.
- Claude plan label (planLabel in cc-profiles.js) reads rateLimitTier and
  seatTier: Max 5x/20x, Team Standard/Premium — subscriptionType alone said
  'pro' after an upgrade and 'team' for both seats. Shown on profiles, the
  status bar quota badge and pool cards; Codex pool cards show the ChatGPT plan.
This commit is contained in:
2026-10-28 11:47:19 +07:00
parent 9d07f64154
commit 26aabd6660
18 changed files with 1638 additions and 642 deletions
+18
View File
@@ -444,3 +444,21 @@ describe("cc-profiles — identity and token disagree", () => {
assert.throws(() => profiles.saveProfile(undefined, { owner }), { code: "EMISMATCH" });
});
});
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" }, null), "Pro");
const team = { subscriptionType: "team", rateLimitTier: "default_raven" };
assert.strictEqual(planLabel(team, { seatTier: "team_standard" }), "Team Standard");
assert.strictEqual(planLabel(team, { seatTier: "team_premium" }), "Team Premium");
// `team_tier_1` names no kind; a Max-sized limit is what a premium seat gives.
assert.strictEqual(
planLabel(team, { seatTier: "team_tier_1", userRateLimitTier: "default_claude_max_5x" }),
"Team Premium"
);
assert.strictEqual(planLabel(team, null), "Team");
assert.strictEqual(planLabel(null, null), null);
});
+1 -1
View File
@@ -222,7 +222,7 @@ test("the live row is the account its token belongs to, not the one .claude.json
assert.strictEqual(live.label, "Victor");
assert.strictEqual(live.account, "victor@x.y");
assert.strictEqual(live.ownerUuid, undefined);
assert.strictEqual(live.plan, "max");
assert.strictEqual(live.plan, "Max");
assert.deepStrictEqual(
claude.filter((t) => !t.active).map((t) => t.label),
["Thắng"]
+44
View File
@@ -179,6 +179,47 @@ function readActiveAuth() {
return { oauth: (creds && creds.claudeAiOauth) || null, account, userID, keychain };
}
const cap = (w) => w.charAt(0).toUpperCase() + w.slice(1);
/**
* The plan's name as the account's owner knows it. `subscriptionType` alone is
* too coarse — it says `team` for both seat kinds, and it is written at sign-in,
* so an account upgraded from Pro to Max keeps saying `pro` — while the rate
* limit tier (`default_claude_max_5x`) and the seat (`team_standard`) carry the
* real one. "Max 5x", "Team Premium", "Team Standard", else the type itself.
*/
function planLabel(oauth, account) {
const sub = String(oauth?.subscriptionType || "").toLowerCase();
// The token's tier, else the user's own: on a Team account the token may
// carry the organization's tier while the member's seat is the larger one.
const tier = `${oauth?.rateLimitTier || ""} ${account?.userRateLimitTier || ""}`;
const seat = String(account?.seatTier || "").toLowerCase();
const max = /max_(\d+)x/i.exec(tier);
const org = String(account?.organizationType || "");
const kind =
sub === "team" || sub === "enterprise"
? sub
: /team/.test(org) || seat.startsWith("team")
? "team"
: /enterprise/.test(org)
? "enterprise"
: null;
if (kind) {
// A premium seat lifts the member onto a Max-sized limit; the seat name
// says so for `team_premium`, the tier for `team_tier_1`.
const level = /premium/.test(seat)
? "Premium"
: /standard/.test(seat)
? "Standard"
: max
? "Premium"
: null;
return [cap(kind), level].filter(Boolean).join(" ");
}
if (max) return `Max ${max[1]}x`;
return sub ? cap(sub) : null;
}
/** Token-free description of an account, safe to send to the browser. */
function identityOf({ oauth, account }) {
return {
@@ -188,6 +229,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),
/** 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,
@@ -544,6 +587,7 @@ module.exports = {
writeProfile,
liveCredentialsFor,
readActiveAuth,
planLabel,
// exported for tests
profilesDir,
configPaths,
+2
View File
@@ -435,6 +435,8 @@ function poolStatus() {
name: a.name,
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,
refreshError: a.kind === "login" ? (a.refreshError ?? null) : null,
addedAt: a.addedAt,
expiresAt: expiresAt(a),
+14
View File
@@ -245,6 +245,7 @@ function poolStatus() {
return {
name: a.name,
kind: a.kind,
plan: a.kind === "chatgpt" ? planOf(a.auth) : null,
addedAt: a.addedAt,
livePanes: livePanes(a.name),
...usageOf(a.name),
@@ -285,6 +286,19 @@ function identity(auth) {
}
}
/** The ChatGPT plan its id_token names, title-cased ("Plus", "Pro", "Team"), or null. */
function planOf(auth) {
try {
const c = JSON.parse(
Buffer.from(String(auth.tokens.id_token).split(".")[1], "base64url").toString("utf8")
);
const p = c[AUTH_CLAIM]?.chatgpt_plan_type;
return typeof p === "string" && p ? p.charAt(0).toUpperCase() + p.slice(1) : null;
} catch {
return null;
}
}
/**
* Whether `disk` (a member's auth.json) may replace pool account `a`: AgentHub
* placed `a` in that home itself (`placedIn`), its `last_refresh` is no later
+4 -4
View File
@@ -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 as the tool reports it: Claude's `subscriptionType`,
* subscription's name: Claude's `planLabel` (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 } = require("./cc-profiles");
const { readActiveAuth, readProfile, listProfiles, planLabel } = require("./cc-profiles");
const { fetchUsage, fetchOwner } = require("./cc-usage");
const { allRolloutFiles } = require("./codex-ingest");
const { getCursorDir } = require("./cursor-usage");
@@ -142,7 +142,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: oauth.subscriptionType || null,
plan: planLabel(oauth, account),
expiresAt: iso(oauth.expiresAt),
};
if (!fresh(oauth.expiresAt)) return { ...who, windows: [], error: "ELIVEEXPIRED" };
@@ -172,7 +172,7 @@ async function liveClaude(readAuth = readActiveAuth) {
async function profileClaude(item) {
const who = {
account: item.email || item.displayName || null,
plan: item.subscriptionType || null,
plan: item.plan || item.subscriptionType || null,
expiresAt: iso(item.expiresAt),
};
let creds = null;
+1 -1
View File
@@ -88,7 +88,7 @@ router.get("/profiles", (req, res) => {
name: p.name,
label: p.label,
email: p.email,
subscriptionType: p.subscriptionType,
subscriptionType: p.plan || p.subscriptionType,
active: p.active,
})),
});