From ff7358188d100f37a56039e8028dddb10c2c61c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 21 Aug 2026 06:42:27 +0000 Subject: [PATCH] sub-agents: a spawn cannot take the last of a window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/servers/api/command-hq/router.ts | 12 +++ src/servers/api/sub-agents/budget.test.ts | 54 ++++++++++++ src/servers/api/sub-agents/budget.ts | 85 +++++++++++++++++++ ...-spotlight-style-launcher-on-ctrl-space.md | 25 +++++- ...i-endpoint-for-spawning-an-agent-with-a.md | 16 ++++ ...spawning-must-respect-the-account-s-own.md | 27 +++++- 6 files changed, 216 insertions(+), 3 deletions(-) create mode 100644 src/servers/api/sub-agents/budget.test.ts create mode 100644 src/servers/api/sub-agents/budget.ts diff --git a/src/servers/api/command-hq/router.ts b/src/servers/api/command-hq/router.ts index 741af8a7..513ab6bd 100644 --- a/src/servers/api/command-hq/router.ts +++ b/src/servers/api/command-hq/router.ts @@ -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; diff --git a/src/servers/api/sub-agents/budget.test.ts b/src/servers/api/sub-agents/budget.test.ts new file mode 100644 index 00000000..4a78fceb --- /dev/null +++ b/src/servers/api/sub-agents/budget.test.ts @@ -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); + }); +}); diff --git a/src/servers/api/sub-agents/budget.ts b/src/servers/api/sub-agents/budget.ts new file mode 100644 index 00000000..2173c254 --- /dev/null +++ b/src/servers/api/sub-agents/budget.ts @@ -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 { + 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) }; + } +} diff --git a/todo/shell/a-spotlight-style-launcher-on-ctrl-space.md b/todo/shell/a-spotlight-style-launcher-on-ctrl-space.md index aab1aa3e..b0c7224f 100644 --- a/todo/shell/a-spotlight-style-launcher-on-ctrl-space.md +++ b/todo/shell/a-spotlight-style-launcher-on-ctrl-space.md @@ -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. diff --git a/todo/sub-agents/an-api-endpoint-for-spawning-an-agent-with-a.md b/todo/sub-agents/an-api-endpoint-for-spawning-an-agent-with-a.md index d388c4f0..837615f8 100644 --- a/todo/sub-agents/an-api-endpoint-for-spawning-an-agent-with-a.md +++ b/todo/sub-agents/an-api-endpoint-for-spawning-an-agent-with-a.md @@ -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. diff --git a/todo/sub-agents/spawning-must-respect-the-account-s-own.md b/todo/sub-agents/spawning-must-respect-the-account-s-own.md index 94f55b00..73952731 100644 --- a/todo/sub-agents/spawning-must-respect-the-account-s-own.md +++ b/todo/sub-agents/spawning-must-respect-the-account-s-own.md @@ -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.