0c2f49876d
- Users page: one Edit user form (display name, email, role, active, a new or generated password, workspaces given or taken back, sign out everywhere), validated as a whole before anything is written (PATCH /api/users/:id, GET /api/users/:id); users.display_name/email added migration-safe. - Settings → Account for every signed-in user: profile, own password (the current one required, this login kept), sign out other devices (/api/me/account, /api/me/password, /api/me/sign-out-others; login cookie only, never a pane token). - Display names wherever a person is named (sidebar, workspace owner groups, Users page and audit log); owner_display_name / reader_display additive. - Users page: admins add and remove Claude/Codex pool accounts. - Folder browser: an admin with their own Linux account opens in their home; the "Server folder" tick opens the host's (never remembered). - AI Proxy: "Config 9router" beside Add profile when 9router is installed, opened on the dashboard's own host (GET /api/ai-proxies/9router). - Terminal: a tmux selection reaches the clipboard over plain HTTP too (writeClipboard's textarea fallback, focus handed back). - Chat: a file the session touched outside the workspace previews (GET /api/sessions/:id/file). - install-system: `!requiretty` only when visudo accepts it (sudo-rs on Ubuntu 25.10+ rejects the setting). - openapi.yaml regenerated; docs (API, SERVER-MODE, LANES) updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
577 lines
21 KiB
JavaScript
577 lines
21 KiB
JavaScript
/**
|
||
* @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 — summed from
|
||
* `token_usage_hourly` for the sessions its runs (`dashboard_runs.account`)
|
||
* started.
|
||
* 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.
|
||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||
*/
|
||
|
||
const fs = require("node:fs");
|
||
const path = require("node:path");
|
||
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));
|
||
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;
|
||
}
|
||
|
||
/** Whether `member`'s credentials are still the pair AgentHub last wrote there for `a`. */
|
||
const ownsHome = (a, member, disk) =>
|
||
a.kind === "login" && Boolean(disk) && disk.refreshToken === a.placed?.[member];
|
||
|
||
/**
|
||
* 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;
|
||
const owner = newer ? await usage.fetchOwner(disk.accessToken) : null;
|
||
const placed = { ...a.placed };
|
||
if (owner && owner.uuid === a.account?.accountUuid) {
|
||
a = { ...a, oauth: { ...a.oauth, ...disk } };
|
||
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)) {
|
||
try {
|
||
a = { ...a, oauth: await usage.exchangeRefresh(a.oauth), refreshError: null };
|
||
} catch (e) {
|
||
a = { ...a, refreshError: e.code || "EUPSTREAM" };
|
||
}
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── 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 } = 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 {
|
||
const u = await fetchUsage(bearerOf(a));
|
||
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 ─────────────────────────────────────
|
||
|
||
const USAGE_COLS = [
|
||
"input_tokens",
|
||
"output_tokens",
|
||
"cache_read_tokens",
|
||
"cache_write_tokens",
|
||
"cache_write_1h_tokens",
|
||
"web_search_requests",
|
||
"web_fetch_requests",
|
||
"code_execution_requests",
|
||
"long_input_tokens",
|
||
"long_output_tokens",
|
||
"long_cache_read_tokens",
|
||
"long_cache_write_tokens",
|
||
"long_cache_write_1h_tokens",
|
||
];
|
||
|
||
/**
|
||
* $ spent on `account` since `sinceHour` (`YYYY-MM-DDTHH`, UTC): the hourly
|
||
* usage of every session one of its runs started, from that run's start hour.
|
||
* ponytail: a session later resumed on ANOTHER account keeps counting here
|
||
* too; split by run end time if that ever skews a ranking.
|
||
*/
|
||
function spendSince(account, sinceHour) {
|
||
const { db, stmts } = require("../db");
|
||
const { calculateCost } = require("../routes/pricing");
|
||
const rows = db
|
||
.prepare(
|
||
`WITH s AS (
|
||
SELECT session_id, MIN(substr(started_at, 1, 13)) AS since
|
||
FROM dashboard_runs
|
||
WHERE tool = 'claude' AND account = ? AND session_id IS NOT NULL
|
||
GROUP BY session_id)
|
||
SELECT h.model, h.speed, h.inference_geo, h.service_tier,
|
||
${USAGE_COLS.map((c) => `SUM(h.${c}) AS ${c}`).join(", ")}
|
||
FROM s JOIN token_usage_hourly h
|
||
ON h.session_id = s.session_id AND h.hour >= s.since AND h.hour >= ?
|
||
GROUP BY h.model, h.speed, h.inference_geo, h.service_tier`
|
||
)
|
||
.all(account, sinceHour);
|
||
return rows.length ? calculateCost(rows, stmts.listPricing.all()).total_cost : 0;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
const hourAgo = (hours) => new Date(Date.now() - hours * 3600e3).toISOString().slice(0, 13);
|
||
|
||
/** 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,
|
||
refreshError: a.kind === "login" ? (a.refreshError ?? null) : null,
|
||
addedAt: a.addedAt,
|
||
expiresAt: expiresAt(a),
|
||
expired: expired(a),
|
||
livePanes: livePanes(a.name),
|
||
spend5h: spendSince(a.name, hourAgo(5)),
|
||
spend7d: spendSince(a.name, hourAgo(24 * 7)),
|
||
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. */
|
||
function pick(member) {
|
||
let usable = allAccounts().filter((a) => !expired(a));
|
||
if (usable.length === 0) return null;
|
||
if (!member) {
|
||
// A host pane's home holds the host's own login.
|
||
usable = usable.filter((a) => a.kind === "token");
|
||
} 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.
|
||
const live = usable.find((a) => a.kind === "login" && liveInHome(a.name, member));
|
||
if (live) return live;
|
||
}
|
||
}
|
||
if (usable.length === 0) return null;
|
||
refreshQuotas().catch(() => {});
|
||
const ranked = usable
|
||
.map((a) => {
|
||
const percent = quotaPercent(a.name);
|
||
return {
|
||
a,
|
||
key: [
|
||
percent != null && percent >= BLOCK_PERCENT ? 1 : 0,
|
||
livePanes(a.name),
|
||
spendSince(a.name, hourAgo(5)),
|
||
spendSince(a.name, hourAgo(24 * 7)),
|
||
],
|
||
};
|
||
})
|
||
.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;
|
||
}
|
||
|
||
/**
|
||
* 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 only ever offered for a member.
|
||
*/
|
||
function pickAccount(member = null) {
|
||
const a = pick(member);
|
||
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) {
|
||
const a = pick(member);
|
||
if (!a) return null;
|
||
if (a.kind === "token") {
|
||
return { name: a.name, env: { CLAUDE_CODE_OAUTH_TOKEN: a.token }, 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 } : {}),
|
||
});
|
||
},
|
||
};
|
||
}
|
||
|
||
module.exports = {
|
||
BLOCK_PERCENT,
|
||
REFRESH_AHEAD_MS,
|
||
addAccount,
|
||
addLoginAccount,
|
||
removeAccount,
|
||
poolStatus,
|
||
pickAccount,
|
||
forSpawn,
|
||
refreshQuotas,
|
||
refreshLogins,
|
||
__read: readAccount,
|
||
poolDir,
|
||
__quotaCache: quotaCache,
|
||
};
|