Files
AgentHub/server/lib/claude-pool.js
T
nntrivi2001 1e3b34ec03 fix(server-mode): a pool login never strands a member on a dead pair
A home whose Claude raced the server's refresh kept a spent pair the pool
no longer recognised; the next beat dropped the home, and pick() then
treated it as the member's own login forever — the member was asked to
/login while every other home worked. The pool now remembers hashes of
its past refresh tokens (a home holding one stays the pool's and gets the
newest pair), never drops a home while fetchOwner cannot answer, and
re-reads the record after each await so a spawn's placement is not lost.
2026-11-06 14:44:14 +07:00

717 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file claude-pool.js
* @description The Claude account POOL: subscription accounts an admin added
* (`agenthub accounts add`, a `claude setup-token` token each), and the
* balancer that gives every new Claude pane one of them through
* `CLAUDE_CODE_OAUTH_TOKEN` (pty-run.js) — so sessions spread over the
* accounts without touching the machine's own login. An empty pool changes
* nothing: panes run on the machine's login as before.
*
* Tokens live in `<CLAUDE_HOME>/agenthub-accounts/<name>.json` (dir 0700, file
* 0600); no route returns one. A setup-token is valid for one year from when it
* was added; past that the account is skipped.
*
* `pickAccount` ranks the accounts by what AgentHub itself recorded:
* 1. an account whose last quota reading is ≥ `BLOCK_PERCENT` in either
* window goes last (still used when every account is that full);
* 2. fewest live panes (spreads concurrent sessions);
* 3. least $ spent in the last 5 hours, then in the last 7 days — the
* sessions its runs (`dashboard_runs.account`) held, over the hours they
* held them (pool-usage.js).
* An admin may switch a user to one account (account-pins.js): that user's
* panes skip the ranking whenever they may use it, and a login is written
* into their home at once (`switchHome`) — running panes take it at their
* next credentials read, without a restart.
* The quota is read with the account's own token in the background, at most
* every `QUOTA_TTL_MS`; a token the usage endpoint refuses simply has no
* reading, and ranking falls back to spend.
*
* Two kinds of account:
* - `token`: a `claude setup-token` token, given to the pane as
* `CLAUDE_CODE_OAUTH_TOKEN` (any pane, host or member).
* - `login`: a real Claude login (`claudeAiOauth` + `oauthAccount`), moved
* in from a saved switch-user profile (cc-profiles.js) or pasted from a
* `.credentials.json`. Claude Code reads it from no env var, so — like a
* Codex ChatGPT login — it is written as `~/.claude/.credentials.json`
* into a MEMBER's own home (S3), as the member (write-as, 0600), just
* before the pane starts: never into a host pane's home (the host's own
* login — unless that login itself was moved in, `hostHolds`, and the
* host's home is then one more placement), never over a login AgentHub
* did not place, never a second
* account into a home where a live pane of that member runs on one.
* Its refresh token is SINGLE USE, so the server is the one that
* refreshes it (`refreshLogins`, `REFRESH_AHEAD_MS` before expiry) and
* pushes the new pair into every home it placed the login in — Claude
* Code re-reads its credentials before refreshing and takes tokens
* "another process landed". A home whose Claude refreshed first is copied
* back, but only after Anthropic confirms the token is the same account
* (`fetchOwner`): a member could otherwise plant a login every other
* member's panes would get — and a home is never dropped while Anthropic
* does not answer. A home still holding one of the account's PAST pairs
* (hashes in `retired`: its Claude raced the server's refresh) stays the
* pool's and gets the newest pair, rather than being left on a dead
* login that asked its member to /login again.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const crypto = require("node:crypto");
const { getClaudeHome } = require("./claude-home");
const NAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
const TOKEN_RE = /^sk-ant-[A-Za-z0-9_-]{20,500}$/;
const TOKEN_LIFE_MS = 365 * 864e5;
const QUOTA_TTL_MS = 5 * 60 * 1000;
const QUOTA_FRESH_MS = 30 * 60 * 1000;
const BLOCK_PERCENT = 90;
const REFRESH_AHEAD_MS = 30 * 60 * 1000;
const FUTURE_SLACK_MS = 5 * 60 * 1000;
const err = (code, message) => Object.assign(new Error(message), { code });
const poolDir = () => path.join(getClaudeHome(), "agenthub-accounts");
const fileOf = (name) => path.join(poolDir(), `${name}.json`);
function validName(name) {
const n = String(name || "").trim();
if (!NAME_RE.test(n))
throw err("EBADNAME", "account name: 1–64 letters, digits, '.', '_' or '-'");
return n;
}
const isOauth = (o) =>
Boolean(o) &&
typeof o.accessToken === "string" &&
o.accessToken.length > 0 &&
typeof o.refreshToken === "string" &&
o.refreshToken.length > 0;
function readAccount(name) {
try {
const a = JSON.parse(fs.readFileSync(fileOf(name), "utf8"));
if (a.kind === "login") return isOauth(a.oauth) ? a : null;
return typeof a.token === "string" ? { ...a, kind: "token" } : null;
} catch {
return null;
}
}
/** Rewrite one record (0600, through a temp file). */
function save(record) {
const tmp = `${fileOf(record.name)}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(record, null, 2), { mode: 0o600 });
fs.renameSync(tmp, fileOf(record.name));
}
/** The secret the usage endpoint takes for `a`. */
const bearerOf = (a) => (a.kind === "login" ? a.oauth.accessToken : a.token);
function allAccounts() {
let names = [];
try {
names = fs.readdirSync(poolDir()).filter((f) => f.endsWith(".json"));
} catch {
return [];
}
return names
.map((f) => f.slice(0, -5))
.filter((n) => NAME_RE.test(n))
.map(readAccount)
.filter(Boolean)
.sort((a, b) => a.name.localeCompare(b.name));
}
// A setup-token lives a year; a login lives as long as its refresh token works.
const expiresAt = (a) =>
a.kind === "login" ? null : new Date(Date.parse(a.addedAt) + TOKEN_LIFE_MS).toISOString();
const expired = (a) =>
a.kind === "login" ? a.refreshError === "ESTALE" : Date.parse(expiresAt(a)) <= Date.now();
/** Add an account; refuses a name already in the pool. */
function addAccount(name, token) {
const n = validName(name);
const t = String(token || "").trim();
if (!TOKEN_RE.test(t)) {
throw err(
"EBADTOKEN",
"not a Claude token — paste what `claude setup-token` printed (sk-ant-…)"
);
}
if (readAccount(n)) throw err("EEXISTS", `account ${n} is already in the pool`);
fs.mkdirSync(poolDir(), { recursive: true, mode: 0o700 });
fs.chmodSync(poolDir(), 0o700);
const record = { name: n, token: t, addedAt: new Date().toISOString() };
fs.writeFileSync(fileOf(n), JSON.stringify(record, null, 2), { mode: 0o600, flag: "wx" });
quotaCache.delete(n);
return { name: n, addedAt: record.addedAt, expiresAt: expiresAt(record) };
}
/**
* Add a real login (`claudeAiOauth`, plus the `oauthAccount`/`userID` Claude
* Code keeps in `.claude.json`); refuses a name, or an account, already in the
* pool. The caller hands over its copy: two holders of one refresh token log
* each other out, so a source that keeps using it must not be offered — with
* one exception, `hostHolds`: the login this machine's own Claude Code runs
* on, whose home then becomes one more placement (`HOST`) the server
* refreshes and pushes into like any member's.
*/
function addLoginAccount(name, { oauth, account = null, userID = null, hostHolds = false } = {}) {
const n = validName(name);
if (!isOauth(oauth)) {
throw err(
"EBADTOKEN",
"not a Claude login — it needs claudeAiOauth with an accessToken and a refreshToken"
);
}
if (readAccount(n)) throw err("EEXISTS", `account ${n} is already in the pool`);
const uuid = account?.accountUuid;
const twin =
uuid && allAccounts().find((a) => a.kind === "login" && a.account?.accountUuid === uuid);
if (twin) throw err("EEXISTS", `that Claude account is already in the pool as ${twin.name}`);
fs.mkdirSync(poolDir(), { recursive: true, mode: 0o700 });
fs.chmodSync(poolDir(), 0o700);
const record = {
name: n,
kind: "login",
oauth,
account,
userID,
addedAt: new Date().toISOString(),
placed: hostHolds ? { [HOST]: oauth.refreshToken } : {},
};
fs.writeFileSync(fileOf(n), JSON.stringify(record, null, 2), { mode: 0o600, flag: "wx" });
quotaCache.delete(n);
return { name: n, kind: "login", addedAt: record.addedAt, email: account?.emailAddress ?? null };
}
function removeAccount(name) {
const n = validName(name);
const a = readAccount(n);
if (!a) throw err("ENOACCOUNT", `no account ${n} in the pool`);
// Take the login back out of every home it is still ours in.
if (a.kind === "login") {
for (const [member, acct] of placedHomes(a)) {
// The host keeps its own login: removing it would log this machine out.
if (acct.host || homeCreds(acct)?.refreshToken !== a.placed[member]) continue;
try {
runAs().writeAs(acct, credsPath(acct), "remove");
} catch {
/* best effort: the member can still /logout */
}
}
}
fs.rmSync(fileOf(n));
require("./account-pins").dropAccount("claude", n);
quotaCache.delete(n);
}
// ── A member's .credentials.json (kind `login`) ────────────────────────────
const runAs = () => require("./run-as");
/** The placement key of this machine's own Claude Code (`hostHolds`). */
const HOST = "@host";
const HOST_ACCT = { name: HOST, host: true };
const credsPath = (acct) =>
acct.host
? path.join(getClaudeHome(), ".credentials.json")
: path.join(acct.home, ".claude", ".credentials.json");
/** The whole `.credentials.json` of a placement (a member's read guarded), or null. */
function readCredsFile(acct) {
try {
const text = acct.host
? fs.readFileSync(credsPath(acct), "utf8")
: runAs().readFileSafe(credsPath(acct), "utf8", { max: 1 << 20 });
const c = JSON.parse(text);
return c && typeof c === "object" && !Array.isArray(c) ? c : null;
} catch {
return null;
}
}
/** The `claudeAiOauth` of a placement, or null. */
function homeCreds(acct) {
const c = readCredsFile(acct);
return c && typeof c.claudeAiOauth === "object" ? c.claudeAiOauth : null;
}
/** `[key, account]` for every home `a` is placed in that still runs as its member (or the host). */
function placedHomes(a) {
const out = [];
for (const member of Object.keys(a.placed || {})) {
if (member === HOST) {
out.push([HOST, HOST_ACCT]);
continue;
}
try {
const acct = runAs().accountForUser(member);
if (acct) out.push([member, acct]);
} catch {
/* a disabled member's home is left as it is */
}
}
return out;
}
/** How many of a login's past refresh tokens are remembered (as hashes). */
const RETIRED_KEEP = 20;
const rtHash = (rt) =>
crypto
.createHash("sha256")
.update(String(rt || ""))
.digest("hex")
.slice(0, 32);
/**
* `a` with `old`'s refresh token remembered as one of its own past pairs. A
* home still holding such a pair (its Claude raced the server's refresh, or a
* spawn wrote a pair the server spent a moment later) is still the pool's:
* nobody but this account ever held that token.
*/
function retire(a, old) {
if (!old?.refreshToken || old.refreshToken === a.oauth?.refreshToken) return a;
const h = rtHash(old.refreshToken);
return { ...a, retired: [h, ...(a.retired || []).filter((x) => x !== h)].slice(0, RETIRED_KEEP) };
}
/**
* Whether `member`'s credentials are the pool's `a`: the pair AgentHub last
* wrote there, or one of `a`'s own past pairs (stale — the next write fixes it).
*/
const ownsHome = (a, member, disk) =>
a.kind === "login" &&
Boolean(disk?.refreshToken) &&
(disk.refreshToken === a.placed?.[member] ||
(a.retired || []).includes(rtHash(disk.refreshToken)));
/**
* Write `a`'s login into a placement — a member's home as the member, the
* host's directly (0600, through a temp file) — keeping every other key of
* the file (`mcpOAuth`…), and remember the pair.
*/
function writeLogin(a, acct) {
const text = `${JSON.stringify({ ...(readCredsFile(acct) || {}), claudeAiOauth: a.oauth }, null, 2)}\n`;
if (acct.host) {
const tmp = `${credsPath(acct)}.agenthub-tmp`;
fs.writeFileSync(tmp, text, { mode: 0o600 });
fs.renameSync(tmp, credsPath(acct));
} else {
runAs().mkdirAs(acct, path.dirname(credsPath(acct)));
runAs().writeAs(acct, credsPath(acct), "private", text);
}
return { ...a, placed: { ...(a.placed || {}), [acct.name]: a.oauth.refreshToken } };
}
const refreshing = new Set();
/**
* Keep every login alive, run on a beat: copy back a home's newer pair (only
* once Anthropic says it is the same account), refresh what expires within
* `REFRESH_AHEAD_MS` — here, the one holder that may spend the refresh
* token — and push the newest pair into every home it is placed in. A home
* whose credentials are no longer ours (the member logged in themselves, or
* out) is dropped from the placement and never written again.
*/
async function refreshLogins() {
const usage = require("./cc-usage");
for (const stale of allAccounts().filter((x) => x.kind === "login")) {
if (refreshing.has(stale.name)) continue;
refreshing.add(stale.name);
try {
let a = stale;
const homes = placedHomes(a);
// 1. A home whose own Claude refreshed first holds the only live pair.
for (const [member, acct] of homes) {
const disk = homeCreds(acct);
if (!disk || ownsHome(a, member, disk)) continue;
const newer =
isOauth(disk) &&
Number(disk.expiresAt) > Number(a.oauth.expiresAt) &&
Number(disk.expiresAt) <= Date.now() + 24 * 3600e3 + FUTURE_SLACK_MS;
let owner = null;
if (newer) {
owner = await usage.fetchOwner(disk.accessToken);
// A spawn may have placed the login somewhere while we waited.
a = readAccount(a.name) || a;
// Anthropic did not answer (offline, a hiccup): decide on a later
// beat. Dropping the home now would strand it on a pair only it
// holds, and no spawn would ever write the pool's login there again.
if (!owner) continue;
}
const placed = { ...a.placed };
if (owner && owner.uuid === a.account?.accountUuid) {
a = retire({ ...a, oauth: { ...a.oauth, ...disk } }, a.oauth);
placed[member] = disk.refreshToken;
} else {
delete placed[member]; // their own login now: never ours to write over
}
a = { ...a, placed };
save(a);
}
// 2. The server refreshes ahead of every home.
if (!(Number(a.oauth.expiresAt) > Date.now() + REFRESH_AHEAD_MS)) {
const old = a.oauth;
let fresh = null;
let refreshError = null;
try {
fresh = await usage.exchangeRefresh(old);
} catch (e) {
refreshError = e.code || "EUPSTREAM";
}
// Re-read: a spawn's place() may have saved a new placement meanwhile.
a = readAccount(a.name) || a;
a = fresh
? retire({ ...a, oauth: fresh, refreshError: null }, old)
: { ...a, refreshError };
save(a);
quotaCache.delete(a.name);
}
// 3. Every home still ours gets the newest pair.
for (const [member, acct] of placedHomes(a)) {
const disk = homeCreds(acct);
if (!ownsHome(a, member, disk) || disk.refreshToken === a.oauth.refreshToken) continue;
try {
a = writeLogin(a, acct);
save(a);
} catch (e) {
console.warn(`[claude-pool] ${a.name} → ${member}: ${e.message}`);
}
}
} catch (e) {
console.warn(`[claude-pool] ${stale.name}: ${e.message}`);
} finally {
refreshing.delete(stale.name);
}
}
}
/**
* An admin's switch (account-pins.js): put pool login `name` into `member`'s
* home NOW, over the pool login it holds — never over a login the member made
* themselves. Every home's newer pair is copied back first (refreshLogins),
* so the login leaving the home keeps its only live refresh token. Running
* panes are not restarted: Claude Code takes the new pair when it next
* re-reads its credentials (before a refresh), new panes at once. Answers
* `switched`, `kept` (already there), `own-login` (left alone), `expired`, or
* `next-session` (a setup-token, or no member home: it goes in at spawn).
*/
async function switchHome(member, name) {
const first = readAccount(validName(name));
if (!first) throw err("ENOACCOUNT", `no account ${name} in the pool`);
if (first.kind !== "login" || !member || member.host) return "next-session";
await refreshLogins();
const a = readAccount(first.name);
if (!a || expired(a)) return "expired";
const disk = homeCreds(member);
if (disk && ownsHome(a, member.name, disk)) return "kept";
const holder = disk && allAccounts().find((x) => ownsHome(x, member.name, disk));
if (disk && !holder) return "own-login";
if (holder) {
const placed = { ...holder.placed };
delete placed[member.name];
save({ ...holder, placed });
}
save(writeLogin(a, member));
runAs().markClaudeOnboarded(member, {
...(a.account ? { oauthAccount: a.account } : {}),
...(a.userID ? { userID: a.userID } : {}),
});
return "switched";
}
// ── Quota, read in the background ──────────────────────────────────────────
/** name → { at, fiveHour?, sevenDay?, error? } */
const quotaCache = new Map();
const inFlight = new Set();
/** Read every account's quota whose reading is older than QUOTA_TTL_MS. */
async function refreshQuotas({ force = false } = {}) {
const { fetchUsage, fetchOwner } = require("./cc-usage");
await Promise.all(
allAccounts().map(async (a) => {
const cached = quotaCache.get(a.name);
if (inFlight.has(a.name) || (!force && cached && Date.now() - cached.at < QUOTA_TTL_MS)) {
return;
}
inFlight.add(a.name);
try {
// 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 });
} catch (e) {
// Keep the last good windows: a hiccup must not unblock a full account.
quotaCache.set(a.name, { ...cached, at: Date.now(), error: e.code || "EUPSTREAM" });
} finally {
inFlight.delete(a.name);
}
})
);
}
/** The highest known utilization (%) of a fresh reading, else null. */
function quotaPercent(name) {
const q = quotaCache.get(name);
if (!q || Date.now() - q.at > QUOTA_FRESH_MS) return null;
const now = Date.now();
// A window whose reset time has passed is empty again.
const used = [q.fiveHour, q.sevenDay]
.filter((w) => w && w.utilization != null)
.map((w) => (w.resetsAt && Date.parse(w.resetsAt) <= now ? 0 : w.utilization));
return used.length ? Math.max(...used) : null;
}
// ── What AgentHub recorded per account ─────────────────────────────────────
/** $ and tokens of the last 5 h / 7 d (pool-usage.js). */
const usageOf = (account) => require("./pool-usage").accountUsage("claude", account);
function livePanes(account) {
const { db } = require("../db");
return db
.prepare(
"SELECT COUNT(*) AS n FROM dashboard_runs WHERE tool = 'claude' AND account = ? AND status IN ('running', 'spawning')"
)
.get(account).n;
}
/** Every account with what the balancer sees — never a token. */
function poolStatus() {
return allAccounts().map((a) => {
const q = quotaCache.get(a.name);
const percent = quotaPercent(a.name);
return {
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").shownPlan(a.oauth, a.account) : null,
refreshError: a.kind === "login" ? (a.refreshError ?? null) : null,
addedAt: a.addedAt,
expiresAt: expiresAt(a),
expired: expired(a),
livePanes: livePanes(a.name),
...usageOf(a.name),
quota: q
? {
fiveHour: q.fiveHour || null,
sevenDay: q.sevenDay || null,
checkedAt: new Date(q.at).toISOString(),
error: q.error || null,
}
: null,
quotaPercent: percent,
full: percent != null && percent >= BLOCK_PERCENT,
};
});
}
function liveInHome(name, member) {
const { db } = require("../db");
return db
.prepare(
`SELECT lane_id FROM dashboard_runs
WHERE tool = 'claude' AND account = ? AND status IN ('running', 'spawning')
AND lane_id IS NOT NULL`
)
.all(name)
.some((r) => {
try {
return runAs().accountForLane(r.lane_id)?.name === member.name;
} catch {
return false; // a gone member's lane is not this (enabled) member's
}
});
}
/**
* The full record a new Claude pane should run on, or null. `member` = run-as
* account; `ownerId` = the workspace owner, whose pin (account-pins.js) wins
* over the ranking when this pane may use that account.
*/
function pick(member, ownerId = null) {
let usable = allAccounts().filter((a) => !expired(a));
if (usable.length === 0) return null;
let live = null;
if (!member) {
// A host pane's home holds the host's own login — a pool account only when
// it was moved in from there (`hostHolds`) and is still the pair on disk:
// the pane then runs on it as it is, and its usage is that account's.
const disk = homeCreds(HOST_ACCT);
usable = usable.filter((a) => a.kind === "token" || ownsHome(a, HOST, disk));
} else {
const disk = homeCreds(member);
const placed = disk && usable.find((a) => ownsHome(a, member.name, disk));
if (!placed && disk) {
// A login AgentHub did not place there is the member's own: keep it.
usable = usable.filter((a) => a.kind === "token");
} else {
// One .credentials.json per home: while a pane of theirs runs on a
// pool login, the next pane stays on that one.
// The login in the home is the one they run on — after an admin's
// switch (switchHome) it is no longer the one those panes started on.
const liveLogin = usable.find((a) => a.kind === "login" && liveInHome(a.name, member));
live = liveLogin ? placed || liveLogin : null;
}
}
// The owner's group's accounts only (pool-groups.js) — a login already live
// in their home stays: one .credentials.json per home.
const allowed = require("./pool-groups").allowed(ownerId, "claude");
usable = usable.filter((a) => allowed(a.name));
// A pinned (or the owner's own chosen) token goes in by env and wins
// outright; a pinned login waits while another login holds this home.
const pin = require("./account-pins").preferredFor(ownerId, "claude");
const pinned = pin ? usable.find((a) => a.name === pin) : null;
if (pinned && (pinned.kind === "token" || !live || live === pinned)) return pinned;
if (live) return live;
return rankAccounts(usable);
}
/**
* The best of `usable` by the balancer's order: not full, fewest live users
* (panes here plus remote clients' sessions the gateway relayed for in the
* last `REMOTE_LIVE_MS`), least spent in 5 h then 7 d. Null when empty.
*/
function rankAccounts(usable) {
if (usable.length === 0) return null;
refreshQuotas().catch(() => {});
const ranked = usable
.map((a) => {
const percent = quotaPercent(a.name);
const u = usageOf(a.name);
return {
a,
key: [
percent != null && percent >= BLOCK_PERCENT ? 1 : 0,
livePanes(a.name) + liveRemote(a.name),
u.spend5h,
u.spend7d,
],
};
})
.sort((x, y) => {
for (let i = 0; i < x.key.length; i++) {
if (x.key[i] !== y.key[i]) return x.key[i] - y.key[i];
}
return x.a.name.localeCompare(y.a.name);
});
return ranked[0].a;
}
/** Remote clients' sessions on `account` the gateway relayed for lately (server/hub/). */
const REMOTE_LIVE_MS = 10 * 60 * 1000;
function liveRemote(account) {
const { db } = require("../db");
const since = new Date(Date.now() - REMOTE_LIVE_MS).toISOString();
return db
.prepare(
"SELECT COUNT(*) AS n FROM remote_sessions WHERE account = ? AND ended_at IS NULL AND last_request_at >= ?"
)
.get(account, since).n;
}
/**
* The pool account a remote client's company-account session runs on through
* the hub's gateway (server/hub/remote-gateway.js): `{name, bearer}` or null.
* `sticky` — the account the session already ran on — is kept while it is
* still usable by this owner (one session, one account: the prompt cache is
* per account). Both kinds work: a setup-token as it is, a login by its
* access token, which this server keeps fresh (`refreshLogins`). Nothing is
* written into any home. The bearer never leaves the hub.
*/
function forRemote(ownerId, sticky = null) {
const allowed = require("./pool-groups").allowed(ownerId, "claude");
const usable = allAccounts().filter((a) => !expired(a) && allowed(a.name));
if (usable.length === 0) return null;
const keep = sticky ? usable.find((a) => a.name === sticky) : null;
const pin = keep ? null : require("./account-pins").preferredFor(ownerId, "claude");
const a = keep || (pin && usable.find((x) => x.name === pin)) || rankAccounts(usable);
return a ? { name: a.name, bearer: bearerOf(a), kind: a.kind } : null;
}
/**
* The account a new Claude pane should run on — `{name, kind, token?}` — or
* null when the pool has nothing this pane may use. Synchronous (a spawn
* cannot wait on the network): it ranks on the cached quota and starts a
* background re-read. A `login` is offered for a member, and for a host pane
* only when it is the host's own login (`hostHolds`), which stays as it is.
*/
function pickAccount(member = null, ownerId = null) {
const a = pick(member, ownerId);
if (!a) return null;
return a.kind === "login"
? { name: a.name, kind: "login" }
: { name: a.name, kind: "token", token: a.token };
}
/**
* What a new Claude pane needs from the pool, or null: `{name, env, place}`.
* `env` goes through tmux `-e` (a member's launch file); `place()` runs just
* before the pane starts and writes a login into the member's home.
*/
function forSpawn(member = null, ownerId = null) {
const a = pick(member, ownerId);
if (!a) return null;
if (a.kind === "token") {
return { name: a.name, env: { CLAUDE_CODE_OAUTH_TOKEN: a.token }, place: () => {} };
}
// The host's own login is already in place; the server keeps it fresh.
if (!member) return { name: a.name, env: {}, place: () => {} };
return {
name: a.name,
env: {},
place: () => {
const record = readAccount(a.name) || a;
save(writeLogin(record, member));
runAs().markClaudeOnboarded(member, {
...(record.account ? { oauthAccount: record.account } : {}),
...(record.userID ? { userID: record.userID } : {}),
});
},
};
}
/**
* What a pane a user runs in a workspace SHARED with them needs (sharing
* phase 2): a setup-token of THEIR OWN — their group's, their pin or choice,
* else the balancer's pick — by env, or null. Never a login: one would be
* written into the owner's home (`place()`), and the owner's own login there
* is the owner's money.
*/
function forCollaborator(userId) {
const allowed = require("./pool-groups").allowed(userId, "claude");
const usable = allAccounts().filter((a) => a.kind === "token" && !expired(a) && allowed(a.name));
if (usable.length === 0) return null;
const pin = require("./account-pins").preferredFor(userId, "claude");
const a = (pin && usable.find((x) => x.name === pin)) || rankAccounts(usable);
return { name: a.name, env: { CLAUDE_CODE_OAUTH_TOKEN: a.token }, place: () => {} };
}
/** Is `name` an account in the pool (any state)? */
const hasAccount = (name) => NAME_RE.test(String(name)) && Boolean(readAccount(String(name)));
module.exports = {
BLOCK_PERCENT,
hasAccount,
REFRESH_AHEAD_MS,
addAccount,
addLoginAccount,
removeAccount,
poolStatus,
pickAccount,
forSpawn,
forCollaborator,
forRemote,
switchHome,
refreshQuotas,
refreshLogins,
__read: readAccount,
poolDir,
__quotaCache: quotaCache,
};