sub-agents: a spawn cannot take the last of a window

The failure this prevents is specific — a sub-agent burns the end of a limit window and the session the
owner is actually sitting in dies mid-turn, because the credits went somewhere nobody was watching. So
it is a condition on spawning rather than a refinement: an endpoint that can start unattended work
without checking is wrong from its first call, and the symptom lands on someone else's conversation.

Wired into POST /command-hq/commanders/new, which — this is the finding — already IS the spawn
mechanism the sub-agents item was asking for. It starts a turn with ws: null, waits only for
session:claude, and returns the transcript id, with command_hq_sessions naming, listing and costing it.
So "commander or something lighter" answers itself: a commander costs nothing extra and a lighter thing
would need its own registry to be visible at all. What is still missing is only the agent-callable
surface.

Refuses rather than queues or downgrades. Queueing means holding work against a window Officer does not
control, and the reset it would wait for is a claim about a shared pool — every claude on the machine
spends these, including terminals and pipelines. A refusal naming the window, the percentage and the
reset is something a caller can act on now.

The floor is 15% and the reasoning is in the file: the interactive failure is not "cannot start a chat",
it is "a turn already in flight is cut off", and a turn costs a fraction of a window.

Fails OPEN, deliberately. The numbers come from an external endpoint with a credential Officer does not
own, so a blip or a signed-out CLI must not stop every spawn on the machine with an error about usage
that has nothing to do with usage. The problem is reported so a caller can say "started, but unchecked".

Seven tests on the pure rule — tightest window decides rather than the average, no limits allowed rather
than blocked, exactly-at-the-ceiling refuses. Verified against the live account, where it correctly
refuses right now at Session 90%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 06:42:27 +00:00
co-authored by Claude Opus 5
parent 4c26e13535
commit ff7358188d
6 changed files with 216 additions and 3 deletions
+85
View File
@@ -0,0 +1,85 @@
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<BudgetVerdict> {
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) };
}
}