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:
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user