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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user