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
+12
View File
@@ -1,6 +1,7 @@
import type { Context } from 'hono';
import { randomUUID } from 'node:crypto';
import { createRouter } from '../../create-router';
import { checkBudget } from '../sub-agents/budget';
import * as errors from '@@/custom-errors';
import { resolveHomeDir } from '@@/user-home';
import { getCommandersDir, toShellUsername } from '@@/data-path';
@@ -269,6 +270,17 @@ commandHqRouter.post('/commanders/new', async (ctx) => {
const prompt = body.prompt?.trim();
if (!prompt) throw errors.BAD_REQUEST('prompt is required');
// ── Room to run, before starting work nobody is watching ──
//
// A commander created here runs unattended. If it burns the last of a window, the session the owner is
// actually sitting in dies mid-turn — the credits went somewhere nobody was looking. So the floor is
// checked at the one door that starts unattended work, rather than left to whoever calls it.
//
// It fails OPEN by design (see budget.ts): the numbers come from an external endpoint with a credential
// Officer does not own, and a network blip must not stop every spawn on the machine.
const budget = await checkBudget();
if (!budget.ok) throw errors.TOO_MANY_REQUESTS(budget.reason ?? 'not enough usage headroom to start an agent');
const sessionId = randomUUID();
const model = (await getUserDefaultModel(user.id)) || DEFAULT_MODEL;
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, test } from 'bun:test';
import { RESERVE_PERCENT, verdictFor } from './budget';
import type { UsageLimit } from '../chat/claude-usage';
// The rule is one comparison, so what is worth pinning is the JUDGEMENT around it: which window decides,
// what happens when there are none, and that the refusal names something a person can act on.
const limit = (label: string, percent: number, resetsAt: string | null = null): UsageLimit => ({
label,
percent,
resetsAt,
});
describe('spawn budget', () => {
test('room to spare is allowed', () => {
expect(verdictFor([limit('Session', 10), limit('Weekly', 40)]).ok).toBe(true);
});
test('the TIGHTEST window decides, not the average', () => {
// Averaged, these are 50% and would pass. The session window is spent, so they must not.
const v = verdictFor([limit('Session', 95), limit('Weekly', 5)]);
expect(v.ok).toBe(false);
expect(v.blocking?.label).toBe('Session');
});
test('the refusal names the window and the reset, because that is what someone acts on', () => {
const v = verdictFor([limit('Weekly', 92, '2026-08-22T09:00:00Z')]);
expect(v.reason).toContain('Weekly');
expect(v.reason).toContain('92%');
expect(v.reason).toContain('2026-08-22T09:00:00Z');
});
test('no reset time reported means the sentence simply stops', () => {
const v = verdictFor([limit('Session', 99)]);
expect(v.reason).toContain('99%');
expect(v.reason).not.toContain('resets at');
});
test('exactly at the ceiling refuses — the reserve is a floor, not a target', () => {
expect(verdictFor([limit('Session', 100 - RESERVE_PERCENT)]).ok).toBe(false);
expect(verdictFor([limit('Session', 100 - RESERVE_PERCENT - 0.1)]).ok).toBe(true);
});
test('no limits at all is allowed, not blocked', () => {
// An account with no windows reported is not an account with no room. Blocking here would stop every
// spawn on a machine whose CLI is signed out, for a reason that has nothing to do with usage.
expect(verdictFor([]).ok).toBe(true);
});
test('the reserve is adjustable, and 0 means only a truly full window refuses', () => {
expect(verdictFor([limit('Session', 99)], 0).ok).toBe(true);
expect(verdictFor([limit('Session', 100)], 0).ok).toBe(false);
});
});
+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) };
}
}
@@ -1,8 +1,8 @@
---
id: shell/a-spotlight-style-launcher-on-ctrl-space
title: "A Spotlight-style launcher, on `ctrl-space`"
title: "A Spotlight-style launcher, on `shift-space`"
theme: shell
status: open
status: done
created: 2026-08-20
date_source: assumed
order: 1
@@ -15,3 +15,24 @@ and hit enter. Dock settings already exist, so the other half is pruning the doc
actually used and letting the launcher carry the rest.
**Trigger to confirm:** `alt-space` is the owner's preferred chord but is already load-bearing for
him elsewhere, so `ctrl-space` unless that clashes too.
---
**Built 2026-08-21** (`81f32a1f`, retriggered in `a2cca526`). `shift-space` in the end: `ctrl-space`
focuses the address bar in Brave and never reached the app.
It **indexes the dock** rather than keeping a list of its own — the dock is already the composed,
permission-filtered answer to "what can this account open here", so a plugin appears in the launcher for
the reason it appears in the dock, and nothing is edited when one is installed. Settings sections are
added on top, hand-listed, which is a known cost written into the file.
Matching is subsequence with weighted scoring in its own testable file: `chq` finds Command HQ, `apik`
finds API keys, `fi` gives Files rather than Profile. Nine tests, all about ORDER rather than matching.
Stands down inside inputs and textareas, because shift-space types a space there. The consequence is
real and stated: focus is usually in the chat composer, so opening it from there means clicking out
first — double-tap shift is the escape hatch if that bites.
**Still open, and the other half of this item:** pruning the dock, and dock auto-hide with effects. Those
live in `shell/dock-effects-and-auto-hide`. Chats, files and actions are not in the index yet — routes
and settings are known client-side, so this slice needed no API.
@@ -27,3 +27,19 @@ what Officer fixes.
**Bounded by the budget rule in `sub-agents/spawning-must-respect-the-account-s-own`, which is not
optional.** An endpoint that spawns freely is exactly how a weekly limit gets spent by something nobody
was watching.
---
**2026-08-21, narrowing this considerably.** The endpoint largely exists:
`POST /api/command-hq/commanders/new` takes a prompt, starts a turn with `ws: null`, waits only for
`session:claude` rather than the whole turn, and returns Claude's own transcript id. A commander is named,
listed and costed by `command_hq_sessions` already.
So the open question "commander or something lighter" answers itself from the code: making a spawned agent
a commander costs nothing and gets all three for free, where a lighter thing needs its own registry to
become visible at all. **The budget condition is now on that route**, so anything spawned through it
already respects the window.
What is genuinely left is the SURFACE an agent can call — MCP tool or authenticated route — and how output
returns to the caller. That is the remaining work on this item; the mechanism underneath it is done.
@@ -2,7 +2,7 @@
id: sub-agents/spawning-must-respect-the-account-s-own
title: "Spawning must respect the account's own session and weekly limits"
theme: sub-agents
status: open
status: done
created: 2026-08-21
date_source: stated
order: 2
@@ -30,3 +30,28 @@ Two facts from that file that shape this, both stated in its own header:
Open: what the endpoint does when the room is thin — refuse, queue until the window resets (the reset
time is in the same payload), or downgrade the model; and whether the reserve is a fixed floor or a
share the owner sets.
---
**Built 2026-08-21.** `api/sub-agents/budget.ts`, wired into `POST /command-hq/commanders/new` — the one
door that starts unattended work.
The open questions, answered:
- **Refuse, not queue or downgrade.** Queueing means holding work against a window Officer does not
control, and the reset time it would wait for is a claim about a shared pool. Refusing with the window
name, the percentage and the reset is something the caller can act on immediately.
- **A fixed floor, 15%.** The interactive failure is not "cannot start a new chat", it is "a turn already
in flight is cut off" — and a turn costs a small fraction of a window, so the reserve only has to cover
what might be running. Adjustable per call; the default lives in one constant.
**It 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 rather than swallowed.
Seven tests on the pure rule — the tightest window decides rather than the average, no limits is allowed
rather than blocked, and the refusal names something actionable. Verified live: it refused at Session 90%.
**Still true and unsolved:** this cannot answer "how much has THIS user left". `claude-usage.ts` takes no
identity, structurally, so a per-account budget needs something that does not exist yet.