import type { UsageLimit } from '../chat/claude-usage'; import { readClaudeUsage } from '../chat/claude-usage'; // Whether there is room to start another agent. // // ── The failure this exists to prevent ── // // A sub-agent burns the last of a window and the session the owner is actually sitting in dies mid-turn, // because the credits went somewhere nobody was watching. That is why this is a CONDITION on spawning // rather than a refinement to add later: an endpoint that can start work without checking is wrong from // its first call, and the symptom lands on somebody else's conversation. // // ── What the numbers are, and what they are not ── // // The windows belong to the ANTHROPIC ACCOUNT, not to Officer. Every `claude` on the machine spends them — // this chat, a terminal, a task runner, a pipeline step — so this reasons about a shared pool it does not // control. A reservation Officer makes for itself is therefore a GUESS, and the honest shape of that guess // is a floor: refuse to spend the last N% on work nobody asked to watch. // // It follows that this cannot answer "how much has THIS user left". `claude-usage.ts` takes no identity at // all, deliberately, because `getOwnerHomeDir` ignores the email it is passed. Per-account budgets need // something that does not exist yet, and pretending otherwise would be worse than the floor. /** * How much of a window to keep back for interactive work. * * 15% rather than a rounder number for a reason: the interactive failure is not "the owner cannot start a * new chat", it is "a turn already in flight is cut off". A turn costs a small fraction of a window, so * the reserve only has to cover the ones that might be running — not a whole session's worth. */ export const RESERVE_PERCENT = 15; export type BudgetVerdict = { ok: boolean; limits: UsageLimit[]; /** Present when refused: which window blocked it, and why. */ reason?: string; blocking?: UsageLimit; /** Present when the numbers could not be read — see `checkBudget`. */ problem?: string; }; /** * The rule, pure so it can be tested without a network. * * The TIGHTEST window decides. Being fine on the weekly while the session window is spent is not fine, * and an average of the two would be a number that describes nothing. */ export function verdictFor(limits: UsageLimit[], reserve = RESERVE_PERCENT): BudgetVerdict { const ceiling = 100 - reserve; const tightest = [...limits].sort((a, b) => b.percent - a.percent)[0]; if (!tightest || tightest.percent < ceiling) return { ok: true, limits }; return { ok: false, limits, blocking: tightest, reason: `${tightest.label} is at ${Math.round(tightest.percent)}% — the last ${reserve}% is held back for ` + `work you are watching.${tightest.resetsAt ? ` It resets at ${tightest.resetsAt}.` : ''}`, }; } /** * Ask the live numbers. * * **Fails OPEN**, and that is a decision rather than an oversight. This reads an external endpoint with a * credential Officer does not own; a network blip, a signed-out CLI or a changed payload would otherwise * stop every spawn on the machine with an error about usage that has nothing to do with usage. The floor * is a courtesy to interactive work, not a safety mechanism — the real limit is Anthropic's and it * enforces itself. * * The problem is REPORTED, so a caller can say "started, but the budget could not be read" rather than * implying it checked. */ export async function checkBudget(reserve = RESERVE_PERCENT): Promise { try { const usage = await readClaudeUsage(); if (usage.limitsProblem) return { ok: true, limits: usage.limits, problem: usage.limitsProblem }; return verdictFor(usage.limits, reserve); } catch (err) { return { ok: true, limits: [], problem: err instanceof Error ? err.message : String(err) }; } }