Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ maxpool server
```

When running from a TTY, shows an interactive TUI with:
- Account table with session/weekly quota progress bars and reset countdowns
- Account table with session, Fable weekly and weekly quota progress bars and reset countdowns
- Real-time activity log with request tracking
- Keyboard shortcuts (see below)

Expand Down Expand Up @@ -297,6 +297,7 @@ TEAMCLAUDE_CONFIG=./my-config.json maxpool server
"name": "[email protected]",
"type": "oauth",
"accountUuid": "...",
"organizationUuid": "...",
"accessToken": "sk-ant-oat01-...",
"refreshToken": "sk-ant-ort01-...",
"expiresAt": 1774384968427
Expand Down
24 changes: 17 additions & 7 deletions src/account-manager.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { refreshAccessToken, isTokenExpiringSoon, modelFamily, tokenFingerprint } from './oauth.js';
import { refreshAccessToken, isTokenExpiringSoon, modelFamily, tokenFingerprint, sameUser, sameAccountIdentity } from './oauth.js';
import { computeDuplicates, credentialFp, accountIdentity, remoteAccountIdentity } from './pool-link.js';
import { appendEventLog } from './event-log.js';
import { hourFloor } from './outside-usage.js';
Expand Down Expand Up @@ -625,6 +625,7 @@ export class AccountManager {
type: acct.type,
provider: acct.provider || (acct.type === 'provider' ? 'provider' : 'anthropic'),
accountUuid: acct.accountUuid || null,
organizationUuid: acct.organizationUuid || null,
credential: acct.accessToken || acct.authToken || acct.apiKey,
upstream: acct.upstream || null,
authHeader: acct.authHeader || null,
Expand Down Expand Up @@ -4860,7 +4861,7 @@ export class AccountManager {
* fingerprint (same key shape). null = unresolvable (never merged by name). */
capacityIdentity(a) {
if (!a) return null;
if (a.remote) return remoteAccountIdentity({ type: a.type, provider: a.provider, accountUuid: a.remoteUuid, keyFingerprint: a.remoteKeyFp });
if (a.remote) return remoteAccountIdentity({ type: a.type, provider: a.provider, accountUuid: a.remoteUuid, organizationUuid: a.remoteOrganizationUuid, keyFingerprint: a.remoteKeyFp });
return accountIdentity(a);
}

Expand Down Expand Up @@ -5428,6 +5429,7 @@ export class AccountManager {
type: acctData.type,
provider: acctData.provider || (acctData.type === 'provider' ? 'provider' : 'anthropic'),
accountUuid: acctData.accountUuid || null,
organizationUuid: acctData.organizationUuid || null,
credential: acctData.accessToken || acctData.authToken || acctData.apiKey,
upstream: acctData.upstream || null,
authHeader: acctData.authHeader || null,
Expand Down Expand Up @@ -5689,11 +5691,17 @@ export class AccountManager {
return true;
}

// Match a saved state entry to a live account by stable identity: prefer the
// account UUID when both have one, otherwise fall back to the name.
/**
* Match a saved state entry to a live account by stable identity: the account
* UUID + organization UUID when both have a UUID, otherwise the name. A saved
* entry without an organization (written before organizations were recorded)
* also matches the same user under the same name, so quota survives the first
* restart after an account learns its organization.
*/
_sameIdentity(saved, account) {
if (saved.accountUuid && account.accountUuid) return saved.accountUuid === account.accountUuid;
return saved.name === account.name;
if (!saved.accountUuid || !account.accountUuid) return saved.name === account.name;
return sameAccountIdentity(saved, account)
|| (!saved.organizationUuid && sameUser(saved, account) && saved.name === account.name);
}

/**
Expand All @@ -5704,7 +5712,7 @@ export class AccountManager {
return this.accounts.filter(a => !a.remote).map(a => {
const quota = {};
for (const f of PERSISTED_QUOTA_FIELDS) quota[f] = a.quota[f];
const out = { accountUuid: a.accountUuid, name: a.name, quota };
const out = { accountUuid: a.accountUuid, organizationUuid: a.organizationUuid, name: a.name, quota };
// OUTSIDE-USAGE flag survives restart (red-team R1.2): a restart is exactly
// when a leak's cards are most at risk — remediation rotates keys and
// restarts the pool, and the in-memory flag would vanish mid-remediation.
Expand Down Expand Up @@ -5901,6 +5909,7 @@ export class AccountManager {
// persistence, re-auth, disk sync) can never resolve to a mirror row.
a.accountUuid = null;
a.remoteUuid = ra.accountUuid || null;
a.remoteOrganizationUuid = ra.organizationUuid || null;
a.provider = ra.provider || a.provider;
a.quota = { ...emptyQuota(), ...(ra.quota || {}) };
a.status = ra.status || 'active';
Expand Down Expand Up @@ -6181,6 +6190,7 @@ export class AccountManager {
// fingerprint. Without these a linked pool can't see a duplicate Claude login
// (live-rehearsal 2026-10-29: status omitted accountUuid → no bench).
accountUuid: a.type === 'oauth' ? (a.accountUuid || null) : null,
organizationUuid: a.type === 'oauth' ? (a.organizationUuid || null) : null,
keyFingerprint: (a.type === 'provider' && a.credential) ? credentialFp(a.credential) : null,
// Plan config (display-only) + this pool's closed cycles per window so a linked
// pool can UNION history instead of picking one ledger (bounded: 12 weekly /
Expand Down
1 change: 1 addition & 0 deletions src/cloud-login.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export async function sendLoginToPool({ url, key, name, profile, creds, fetchImp
account: {
name, type: 'oauth',
accountUuid: profile?.accountUuid,
organizationUuid: profile?.organizationUuid,
accessToken: creds.accessToken,
refreshToken: creds.refreshToken,
expiresAt: creds.expiresAt,
Expand Down
63 changes: 33 additions & 30 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ net.setDefaultAutoSelectFamilyAttemptTimeout(
);
import { Prober } from './prober.js';
import { CapacityLedger } from './capacity-ledger.js';
import { loginOAuth, fetchProfile, refreshAccessToken, isTokenExpiringSoon, tokenFingerprint, isLoginCancelled } from './oauth.js';
import { loginOAuth, fetchProfile, refreshAccessToken, isTokenExpiringSoon, tokenFingerprint, isLoginCancelled, sameAccountIdentity, organizationUnknownMatch, resolveOrganizations, suggestAccountName } from './oauth.js';
import { TUI, writeTerminalSync, reclaimRawModeLogged, whenTerminalCaughtUp } from './tui.js';
import { createTerminalModeSwitcher } from './terminal-mode.js';
import { RestartController } from './restart-controller.js';
Expand Down Expand Up @@ -939,8 +939,7 @@ async function serverWorkerCommand() {
// Pick up any new accounts from disk so index matching stays correct
// (only add, don't refresh credentials — we're about to write the authoritative tokens)
for (const diskAcct of diskConfig.accounts) {
const known = (diskAcct.accountUuid && config.accounts.some(a => a.accountUuid === diskAcct.accountUuid))
|| config.accounts.some(a => a.name === diskAcct.name);
const known = config.accounts.some(a => sameAccountIdentity(a, diskAcct) || a.name === diskAcct.name);
if (!known) {
config.accounts.push(diskAcct);
accountManager.addAccount(diskAcct);
Expand Down Expand Up @@ -1423,7 +1422,7 @@ async function serverWorkerCommand() {
// Use live tokens from AccountManager (not the stale config.accounts copy).
diskConfig.accounts = config.accounts.map(a => {
const am = accountManager.accounts.find(candidate =>
(a.accountUuid && candidate.accountUuid === a.accountUuid) || candidate.name === a.name
sameAccountIdentity(candidate, a) || candidate.name === a.name
);
const live = am ? {
...a,
Expand All @@ -1432,7 +1431,7 @@ async function serverWorkerCommand() {
expiresAt: am.expiresAt,
} : a;
const diskAcct = diskConfig.accounts.find(
d => (a.accountUuid && d.accountUuid === a.accountUuid) || d.name === a.name
d => sameAccountIdentity(d, a) || d.name === a.name
);
return diskAcct ? { ...diskAcct, ...live } : live;
});
Expand Down Expand Up @@ -1990,7 +1989,8 @@ async function loginOAuthCommand() {
// Cloud-aware: a linked team pool with a usable key may receive the login instead.
// Profile FIRST (once): a local match must win over the cloud default.
const profile = await fetchProfile(creds.accessToken);
const localMatch = (profile?.accountUuid && config.accounts.find(a => a.type === 'oauth' && a.accountUuid === profile.accountUuid))
const localMatch = (profile?.accountUuid && config.accounts.find(a => a.type === 'oauth' && sameAccountIdentity(a, profile)))
|| (profile?.accountUuid && organizationUnknownMatch(config.accounts, profile))
|| (profile?.email && config.accounts.find(a => a.type === 'oauth' && a.name === profile.email))
|| null;
const { usable, unresolved, error } = await resolveUsablePools(config.pools, async (names) => {
Expand Down Expand Up @@ -2304,23 +2304,28 @@ async function accountsCommand() {
)
);

// Deduplicate by accountUuid — keep the last (most recently added) entry
// Deduplicate by user + organization — keep the last (most recently added) entry
const seen = new Map();
let removed = 0;
for (let i = config.accounts.length - 1; i >= 0; i--) {
const a = config.accounts[i];
const uuid = profiles[i]?.accountUuid || a.accountUuid;
if (uuid) {
if (seen.has(uuid)) {
const org = profiles[i]?.organizationUuid || a.organizationUuid || '';
const identity = uuid ? `${uuid}/${org}` : null;
if (identity) {
if (seen.has(identity)) {
config.accounts.splice(i, 1);
profiles.splice(i, 1);
removed++;
} else {
seen.set(uuid, i);
// Update stored UUID and name from profile
seen.set(identity, i);
// Update stored UUIDs and name from profile. Two organizations of one user
// share the email, so the name is only taken when no other account has it.
if (profiles[i] && !profiles[i].error) {
a.accountUuid = profiles[i].accountUuid;
if (profiles[i].email) a.name = profiles[i].email;
a.organizationUuid = profiles[i].organizationUuid;
const email = profiles[i].email;
if (email && !config.accounts.some((other, j) => j !== i && other.name === email)) a.name = email;
}
}
}
Expand Down Expand Up @@ -2526,30 +2531,31 @@ async function upsertOAuthAccount(config, name, creds, source = 'unknown', prefe
if (!profileOk) {
console.error(`Warning: could not fetch account profile — ${profile?.error || 'no token'}`);
}
if (profileOk) await resolveOrganizations(config.accounts.filter(a => a.type === 'oauth'), profile.accountUuid);
const uncertain = profileOk && !name ? organizationUnknownMatch(config.accounts, profile) : null;
if (uncertain) {
name = uncertain.name;
console.log(`Same Claude user as "${name}", and its organization could not be checked: re-authenticating it in place. To add this login as a second account, run again with --name <new name>.`);
}
if (!name && profile?.email) {
name = profile.email;
const tier = profile.hasClaudeMax ? 'Max' : profile.hasClaudePro ? 'Pro' : null;
if (tier) console.log(`Detected Claude ${tier} account: ${profile.email}`);
}
if (!name) {
const n = config.accounts.filter(a => a.name.startsWith('account-')).length + 1;
name = `account-${n}`;
}
if (!name) name = suggestAccountName(config.accounts, profile);

const account = {
name,
type: 'oauth',
source,
accountUuid: profile?.accountUuid || null,
organizationUuid: profile?.organizationUuid || null,
accessToken: creds.accessToken,
refreshToken: creds.refreshToken,
expiresAt: creds.expiresAt,
};

// Deduplicate: match by UUID first, then by name
let idx = profile?.accountUuid
? config.accounts.findIndex(a => a.accountUuid === profile.accountUuid)
: -1;
// Deduplicate: match by user + organization first, then by name
let idx = config.accounts.findIndex(a => sameAccountIdentity(a, account));
if (idx < 0) idx = config.accounts.findIndex(a => a.name === name);

if (idx >= 0) {
Expand All @@ -2567,13 +2573,12 @@ async function upsertOAuthAccount(config, name, creds, source = 'unknown', prefe
// ── config sync helpers ─────────────────────────────────────

/**
* Find a config account entry matching an in-memory account (by UUID, then name).
* Find a config account entry matching an in-memory account (by user + organization,
* then name).
*/
function findConfigAccount(diskConfig, account) {
if (account.accountUuid) {
const idx = diskConfig.accounts.findIndex(a => a.accountUuid === account.accountUuid);
if (idx >= 0) return idx;
}
const idx = diskConfig.accounts.findIndex(a => sameAccountIdentity(a, account));
if (idx >= 0) return idx;
return diskConfig.accounts.findIndex(a => a.name === account.name);
}

Expand All @@ -2585,9 +2590,7 @@ function findConfigAccount(diskConfig, account) {
async function syncAccountsFromDisk(diskConfig, memConfig, accountManager) {
let added = 0;
for (const diskAcct of diskConfig.accounts) {
const matchByUuid = diskAcct.accountUuid
? memConfig.accounts.findIndex(a => a.accountUuid === diskAcct.accountUuid)
: -1;
const matchByUuid = memConfig.accounts.findIndex(a => sameAccountIdentity(a, diskAcct));
const matchByName = memConfig.accounts.findIndex(a => a.name === diskAcct.name);
const memIdx = (matchByUuid >= 0 ? matchByUuid : null) ?? (matchByName >= 0 ? matchByName : -1);

Expand All @@ -2614,7 +2617,7 @@ async function syncAccountsFromDisk(diskConfig, memConfig, accountManager) {

// Find the corresponding AccountManager entry and update credentials
const mgr = accountManager.accounts.find(a =>
(diskAcct.accountUuid && a.accountUuid === diskAcct.accountUuid) || a.name === diskAcct.name
sameAccountIdentity(a, diskAcct) || a.name === diskAcct.name
);
if (!mgr) continue;
const enabled = diskAcct.enabled !== false;
Expand Down
65 changes: 65 additions & 0 deletions src/oauth.js
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ export async function fetchProfile(accessToken) {
const data = await res.json();
return {
accountUuid: data.account?.uuid,
organizationUuid: data.organization?.uuid,
email: data.account?.email,
name: data.account?.display_name,
orgName: data.organization?.name,
Expand All @@ -165,6 +166,70 @@ export async function fetchProfile(accessToken) {
}
}

/** True when both records belong to the same Claude user (account.uuid). */
export function sameUser(a, b) {
return Boolean(a?.accountUuid) && a.accountUuid === b?.accountUuid;
}

/**
* True when both records are the same Claude login: the same user in the same
* organization. One user can hold a personal Max org and a Team org under one
* email; each is its own account. Records saved before organizations were
* recorded have no organizationUuid and match each other on the user alone; an
* entry with an org never matches one without, so a rotated single-use refresh
* token can never be written to the wrong entry.
*/
export function sameAccountIdentity(a, b) {
return sameUser(a, b) && (a.organizationUuid || null) === (b.organizationUuid || null);
}

/**
* The one OAuth entry of the login's user whose organization is still unknown,
* when no entry matches the login exactly; null otherwise. The TUI then asks
* "re-authenticate or add?", the CLI re-authenticates it in place. One shared
* rule for both so they can never drift apart.
*/
export function organizationUnknownMatch(accounts, profile) {
if (accounts.some(a => sameAccountIdentity(a, profile))) return null;
return accounts.find(a => a.type === 'oauth' && sameUser(a, profile) && !a.organizationUuid) || null;
}

/**
* Fill in organizationUuid on the accounts of one user that lack it, by reading
* each one's profile with its own current access token. Read only: an expired or
* missing token is skipped, never refreshed. `credentialsOf` supplies the live
* token of a running account; by default the stored one is used. Returns the
* accounts still without an organization.
*/
export async function resolveOrganizations(accounts, accountUuid, credentialsOf = a => a) {
const missing = accounts.filter(a => sameUser(a, { accountUuid }) && !a.organizationUuid);
for (const account of missing) {
const organizationUuid = await readOrganization(credentialsOf(account));
if (organizationUuid) account.organizationUuid = organizationUuid;
}
return missing.filter(a => !a.organizationUuid);
}

/** The organization uuid behind an unexpired access token (read only), else null. */
async function readOrganization({ accessToken, expiresAt } = {}) {
if (!accessToken || isTokenExpiringSoon(expiresAt, 0)) return null;
const profile = await fetchProfile(accessToken);
return profile?.organizationUuid || null;
}

/**
* Default name for a new account: its email, or "<email> (<plan type>)" when
* another account already carries that email as its name (the same user in
* another organization), or a numbered fallback when the profile has no email.
*/
export function suggestAccountName(accounts, profile) {
const email = profile?.email;
if (!email) return `account-${accounts.filter(a => a.name.startsWith('account-')).length + 1}`;
if (!accounts.some(a => a.name === email)) return email;
const type = String(profile.orgType || '').replace(/^claude_/, '');
return `${email} (${type || '2'})`;
}

const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
const OAUTH_USAGE_BETA = 'oauth-2025-04-20';

Expand Down
7 changes: 4 additions & 3 deletions src/pool-link.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ export function credentialFp(credential) {
export function accountIdentity(account) {
if (!account) return null;
if (account.type === 'oauth') {
return account.accountUuid ? `uuid:${account.accountUuid}` : null;
if (!account.accountUuid) return null;
return account.organizationUuid ? `uuid:${account.accountUuid}:${account.organizationUuid}` : `uuid:${account.accountUuid}`;
}
if (account.type === 'provider' && account.provider && account.credential) {
return `key:${account.provider}:${credentialFp(account.credential)}`;
Expand All @@ -47,7 +48,7 @@ export function accountIdentity(account) {
/** Identity of a REMOTE status row (same key shape as accountIdentity). */
export function remoteAccountIdentity(a) {
if (!a) return null;
if (a.type === 'oauth') return a.accountUuid ? `uuid:${a.accountUuid}` : null;
if (a.type === 'oauth') return accountIdentity(a);
if (a.type === 'provider' && a.provider && a.keyFingerprint) return `key:${a.provider}:${a.keyFingerprint}`;
return null;
}
Expand All @@ -62,7 +63,7 @@ export function remoteIdentities(status) {
for (const a of status.accounts) {
if (a.enabled === false) continue; // a benched remote row doesn't bench the local copy
let id = null;
if (a.type === 'oauth' && a.accountUuid) id = `uuid:${a.accountUuid}`;
if (a.type === 'oauth') id = accountIdentity(a);
else if (a.type === 'provider' && a.keyFingerprint) id = `key:${a.provider}:${a.keyFingerprint}`;
if (id) out.set(id, { name: a.name, provider: a.provider, type: a.type });
}
Expand Down
Loading