import { createRouter } from '../../create-router'; import * as errors from '@@/custom-errors'; import * as sidecar from '@@/sidecar-registry'; import { chatIdentity } from '../chat/chat'; import { checkBudget } from './budget'; /* POST /sub-agents/spawn — an agent asks Officer to run another agent. ── Why this exists rather than leaning on the harness ── A chat agent already has a way to spawn a worker: the harness gives it one. Nothing else does. A watcher loop, a plugin, a background job, a cron entry — none of them can reach that tool, which is exactly why a commander's loop shells out to `claude -p` by hand and then has to invent its own record-keeping for what it started. So this is the door Officer defines: the shape, the limits and the vocabulary are the platform's, every caller gets the same one, and none of it changes when a CLI does. ── The condition that comes before the feature ── `checkBudget()` runs first and refuses on a floor. The windows belong to the Anthropic account and are spent by every `claude` on the machine, so an endpoint that can start work without asking would burn the last of a window and kill the turn the owner is sitting in — a failure that lands on somebody else's conversation, which is the worst place for it. The reserve is a guess and the honest shape of a guess is a floor: never spend the last 15%. ── What it is NOT ── Not a queue, not a supervisor, not a retry. One call starts one agent. Whether that agent's work was any good is the caller's problem, and a caller that wants three tries writes three calls — because a retry inside here would be a policy nobody chose, applied to work this endpoint cannot read. */ export const subAgentsRouter = createRouter(); const MAX_PROMPT = 100_000; subAgentsRouter.post('/spawn', async (ctx) => { const user = ctx.get('user'); const body = ctx.get('body') as { prompt?: string; cwd?: string; model?: string; wait?: boolean }; const prompt = typeof body?.prompt === 'string' ? body.prompt.trim() : ''; if (!prompt) throw errors.BAD_REQUEST('prompt is required'); if (prompt.length > MAX_PROMPT) throw errors.BAD_REQUEST(`prompt exceeds ${MAX_PROMPT} characters`); /* Refused BEFORE anything is spawned, and the reason travels with the refusal. A caller that gets "no room" needs to know which window and how close, or its only move is to try again immediately — which is the behaviour this is trying to prevent. 429 rather than 403: this is a "not now", not a "not you". */ const budget = await checkBudget(); if (!budget.ok) { return ctx.json( { error: budget.reason ?? 'no budget to start another agent', limits: budget.limits, problem: budget.problem }, 429, ); } /* The identity is resolved from the TOKEN, never from the body. `chatIdentity` is what decides the home directory and the OS user the agent runs as, and it is the same resolution every chat route uses. A caller naming its own email here would be naming whose filesystem to run in. */ const who = await chatIdentity(user); // Officer's own handle for the session. The harness's transcript uuid is assigned when the agent first // reports one, which is after this returns — see the note in the response below. const sessionKey = crypto.randomUUID(); const spawn = { userId: user.id, email: who.email, username: user.username ?? who.email.split('@')[0] ?? String(user.id), prompt, sessionKey, ...(body?.cwd ? { cwd: body.cwd } : {}), ...(body?.model ? { model: body.model } : {}), /* Stated explicitly, never omitted. The field is `required-but-may-be-undefined` on purpose — an optional one let a call site forget it, and forgetting it means a member's agent silently running as the owner in the owner's home. `undefined` here says "the owner", and that is only reached when `chatIdentity` says there is no Linux user to run as. */ member: who.osUser ? { osUser: who.osUser, home: who.home } : undefined, }; try { /* Two modes, and the default is the one a loop wants. `wait: false` returns as soon as the agent is running, so a watcher can start work and get on with watching. `wait: true` blocks for the answer, which is right for a question with a short answer and wrong for anything that touches a fleet — the request would hold a socket open for the length of the work. */ if (body?.wait === true) { const result = await sidecar.spawnClaude(spawn); return ctx.json({ sessionKey, result }); } await sidecar.spawnClaudeStreaming(spawn); /* `sessionKey` and not a transcript id, because the transcript does not exist yet. The agent announces its own uuid on its first turn, which is after this responds. A caller that needs the transcript — to open `/chat/`, or to record what it started — reads it from the live session list a moment later. Returning a guess here, or blocking until it arrived, would both be worse than saying which id this is. */ return ctx.json({ sessionKey, running: true }); } catch (err) { // The agent sidecar being down is a 503, not a 500: nothing about the request was wrong and retrying it // later is the correct response, which is a distinction a caller can act on. // 503 rather than 500: nothing about the request was wrong, and retrying it later is the correct // response — a distinction the caller can act on. throw errors.createError(503, err instanceof Error ? err.message : 'could not start an agent'); } }); /** * GET /sub-agents/budget — is there room to start one, without starting one. * * Separate from the spawn so a caller can decide before it commits. A watcher that would rather skip a cycle * than be refused mid-flight asks this first; the spawn checks again regardless, because between the two * calls somebody else may have spent the window. */ subAgentsRouter.get('/budget', async (ctx) => { const budget = await checkBudget(); return ctx.json(budget); });