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
+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;