@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
174 lines (173 loc) • 10 kB
JavaScript
// Which Claude account a launch runs under when none is named, and which one
// a worker that hit its usage limit continues on. See docs/accounts.md.
//
// Every account stays in its own home and is used only by Claude Code there:
// this chooses a CLAUDE_CONFIG_DIR, it never moves a token or a request.
import { readFile } from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
import { readInstallPreferences } from "../init/preferences.js";
import { logger } from "../logger.js";
import { findPhrenPath } from "../phren-paths.js";
import { claudeHomes, DEFAULT_ACCOUNT } from "./claude-accounts.js";
import { harnessInventoryWithin } from "./harnesses.js";
import { atomic, bridgeRoot } from "./protocol.js";
/** Claude's 5-hour session window and its 7-day window over all models. */
export const FIVE_HOUR = "five_hour", WEEK = "seven_day";
function windowRoom(window, now) {
if (!window)
return undefined;
const reset = window.resetsAt ? Date.parse(window.resetsAt) : NaN;
if (window.reset || Number.isFinite(reset) && reset <= now)
return { leftPercent: 100 };
const left = window.limited ? 0 : typeof window.usedPercent === "number" ? Math.max(0, Math.min(100, Math.round(100 - window.usedPercent))) : undefined;
return { ...(left !== undefined ? { leftPercent: left } : {}), ...(window.resetsAt ? { resetsAt: window.resetsAt } : {}) };
}
/** The 5-hour and weekly rooms of one usage row. */
export function accountWindows(usage, now) {
const fiveHour = windowRoom(usage.windows.find(w => w.id === FIVE_HOUR), now);
const week = windowRoom(usage.windows.find(w => w.id === WEEK), now);
return { ...(fiveHour ? { fiveHour } : {}), ...(week ? { week } : {}) };
}
const left = (room) => room?.leftPercent;
/** Unknown room ranks below any known room: an account never reported is tried after one that is. */
const rank = (room) => left(room) ?? -1;
const describe = (room) => {
const part = (name, window) => left(window) === undefined ? `${name} unknown` : `${left(window)}% ${name} left`;
return `${room.account} (${part("5-hour", room.fiveHour)}, ${part("weekly", room.week)})`;
};
/**
* The account with the most headroom: the most left on the 5-hour window,
* then on the weekly one, then `default`, then by id. An account that is not
* usable (signed out), is out of quota, or that `blocked` names (it hit its
* limit and its window has not reset) is never chosen. Undefined when none is left.
*/
export function chooseAccount(rooms, blocked = () => undefined) {
const skipped = [];
const open = rooms.filter(room => {
const why = room.usable === false ? "not signed in" : room.exhausted ? `out of quota${room.until ? ` until ${room.until}` : ""}` : blocked(room);
if (why)
skipped.push(`${room.account}: ${why}`);
return !why;
});
const sorted = [...open].sort((a, b) => rank(b.fiveHour) - rank(a.fiveHour) || rank(b.week) - rank(a.week)
|| Number(b.account === DEFAULT_ACCOUNT) - Number(a.account === DEFAULT_ACCOUNT) || a.account.localeCompare(b.account));
const best = sorted[0];
if (!best)
return { skipped };
const others = sorted.slice(1).map(describe);
const reason = `most headroom: ${describe(best)}${others.length ? `; over ${others.join(", ")}` : ""}${skipped.length ? `; skipped ${skipped.join(", ")}` : ""}`;
return { choice: { account: best.account, reason: reason.slice(0, 400) }, skipped };
}
/** This computer's signed-in Claude accounts with their room, from the Hook's inventory and usage rows. */
export function claudeRooms(inventory, usage, now) {
const accounts = inventory?.harnesses.find(entry => entry.source === "claude")?.accounts ?? [];
return accounts.map(account => {
const row = usage.find(item => item.source === "claude" && (item.account?.id ?? DEFAULT_ACCOUNT) === account.id);
const windows = row ? accountWindows(row, now) : {};
const out = [windows.fiveHour, windows.week].filter(w => w?.leftPercent === 0);
const until = out.map(w => w?.resetsAt).filter((value) => Boolean(value)).sort().at(-1);
return { account: account.id, ...(account.key ? { key: account.key } : {}), usable: account.usable, ...windows,
...(out.length ? { exhausted: true, ...(until ? { until } : {}) } : {}) };
});
}
// ── Accounts that hit their limit ────────────────────────────────────────────
/** How long an account that hit its limit is left alone when its reset time is unknown: one 5-hour window. */
export const UNKNOWN_RESET_MS = 5 * 60 * 60_000;
const limitsFile = () => path.join(bridgeRoot(), "account-limits.json");
const limitSchema = z.object({ key: z.string().max(200), account: z.string().max(64), until: z.string().datetime(), at: z.string().datetime() }).strict();
const ledgerSchema = z.object({ limits: z.array(limitSchema).max(64) }).strict();
/** How a login is told apart: its key, else its id on that computer (two unknown logins are not one). */
export const limitKey = (room, computer) => room.key && !room.key.includes(":home:") ? room.key : `${computer}/${room.account}`;
export async function readAccountLimits(now = Date.now()) {
try {
const parsed = ledgerSchema.parse(JSON.parse(await readFile(limitsFile(), "utf8")));
return parsed.limits.filter(entry => Date.parse(entry.until) > now);
}
catch (error) {
if (error.code !== "ENOENT")
logger.warn("accounts", `Could not read account-limits.json: ${error instanceof Error ? error.message : String(error)}`);
return [];
}
}
let ledgerWrites = Promise.resolve();
/** Records that a login hit its limit: no launch or failover chooses it again before `until`. */
export function noteAccountLimit(entry, now = Date.now()) {
const run = ledgerWrites.then(async () => {
const until = entry.until && Date.parse(entry.until) > now ? entry.until : new Date(now + UNKNOWN_RESET_MS).toISOString();
const kept = (await readAccountLimits(now)).filter(item => item.key !== entry.key);
await atomic(limitsFile(), { limits: [...kept, { key: entry.key, account: entry.account, until, at: new Date(now).toISOString() }].slice(-64) });
});
ledgerWrites = run.catch(() => undefined);
return run;
}
/** Why `room` on `computer` is held back by the ledger, or undefined. */
export function limitedBy(limits, computer) {
return room => {
const hit = limits.find(entry => entry.key === limitKey(room, computer));
return hit ? `hit its limit, held until ${hit.until}` : undefined;
};
}
let usageReader;
let inventoryReader;
/** The Hook's usage reader (`AccountUsageReader.limits`); tests pass fakes for both. */
export function setChoiceReaders(readers) {
usageReader = readers.usage;
inventoryReader = readers.inventory;
}
/** The value, or undefined once `ms` passes or it fails: a launch never waits on a slow reader. */
async function within(ms, value) {
let timer;
const late = new Promise(resolve => { timer = setTimeout(() => resolve(undefined), ms); timer.unref(); });
try {
return await Promise.race([value.catch(() => undefined), late]);
}
finally {
clearTimeout(timer);
}
}
/**
* The account a Claude launch that names none runs under on this computer, or
* undefined to launch as before (`default`): only when more than one account is
* signed in here and their room can be read. Logged with the reason.
*/
export async function pickClaudeAccount(what, computer = "this computer", now = Date.now()) {
if (!usageReader)
return undefined;
// One Claude home means nothing to choose. Checking the folders first keeps a
// launch from running `claude auth status` against the config it is about to
// pretrust: that process can write .claude.json back over the new trust entry.
if (!inventoryReader && claudeHomes().length < 2)
return undefined;
const inventory = await within(2_500, (inventoryReader ?? (() => harnessInventoryWithin(2_500)))());
const rooms = claudeRooms(inventory, [], now);
if (rooms.filter(room => room.usable !== false).length < 2)
return undefined;
const usage = await within(2_500, usageReader());
if (!usage)
return undefined;
const { choice, skipped } = chooseAccount(claudeRooms(inventory, usage, now), limitedBy(await readAccountLimits(now), computer));
if (choice)
logger.info("accounts", `${what}: Claude account ${choice.account}, ${choice.reason}.`);
else
logger.info("accounts", `${what}: no Claude account has room (${skipped.join("; ")}); launching under default.`);
return choice;
}
// ── The switch ───────────────────────────────────────────────────────────────
const TRUE = new Set(["1", "true", "on", "yes"]), FALSE = new Set(["0", "false", "off", "no"]);
/** Whether a dispatched Claude worker stopped at its usage limit continues on another signed-in account.
* Off unless the owner opts in with `phren config account-failover on` or PHREN_ACCOUNT_FAILOVER=on. */
export function resolveAccountFailover(env = process.env, store = findPhrenPath()) {
const raw = env.PHREN_ACCOUNT_FAILOVER?.trim().toLowerCase();
if (raw && TRUE.has(raw))
return { on: true, source: "PHREN_ACCOUNT_FAILOVER" };
if (raw && FALSE.has(raw))
return { on: false, source: "PHREN_ACCOUNT_FAILOVER" };
try {
const value = store ? readInstallPreferences(store).accountFailover : undefined;
if (typeof value === "boolean")
return { on: value, source: "install preferences" };
}
catch { /* No preferences yet: the default applies. */ }
return { on: false, source: "default" };
}