Files
platform/src/servers/api/command-hq/router.ts
T
pastilhasandClaude Opus 5 6d6e8a841e spawned children are database rows, linked to their commander
The file was doing a job it is not good at. A newline-delimited list appended by a shell cannot express a
link, cannot be queried, cannot be updated in place, and cannot hold a fact that arrives later — and a
child's cost arrives later, from the child, long after the loop wrote its id.

So the file becomes an INBOX and command_hq_spawned becomes the record.

The constraint that shapes it: the loop is bash and cannot reach Postgres. Giving it a credential so it could
would put an authenticated writer inside a background job, which is worse than the problem. So the loop
appends with a shell redirect and the platform ingests — idempotently, on the unique index, which is what
makes ingest-on-READ safe. A cron for this would be a moving part that can stop without anybody noticing;
the read is the only moment the answer is wanted.

What the rows buy that the file cannot:

- the link, ENFORCED. onDelete cascade means deleting a commander cannot leave an orphaned child row, which
  is the reason this became important.
- queries. Cost per commander, how many agents a trigger spawned, which are still running.
- somewhere for finishedAt, totalUsd and outcome, which the loop cannot know when it appends.

totalUsd is NULLABLE and null means unknown, never zero. The children endpoint returns `unknownCost` as a
count beside the total, because a commander whose children's costs are unknown must not display a confident
total that happens to be too low — that understatement is the whole reason this exists.

Delete ingests before reading, so a child appended since the last read is still deleted: the file is allowed
to be lossy, the rows are not, but a row never ingested does not exist yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 17:26:55 +00:00

635 lines
30 KiB
TypeScript

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';
import { getChatEventsSince, ingestSpawnedChildren } from 'officerdb';
import {
clearSpawnedRecord,
listSpawnedSessions,
loopPidPath,
spawnedRecordPath,
stopCommanderProcesses,
} from './spawned';
import type { ServerMessage } from '../chat/types';
import {
listClaudeSessions,
loadClaudeSessionById,
renameClaudeSession,
deleteClaudeSession,
type ChatIdentity,
type ClaudeSessionSummary,
} from '../chat/claude-sessions';
import { collectRunningTasks, startClaudeTurn, getUserDefaultModel, DEFAULT_MODEL } from '../chat/websocket';
import {
listCommandHqCategories,
createCommandHqCategory,
renameCommandHqCategory,
deleteCommandHqCategory,
createCommandHqSession,
listCommandHqSessions,
getCommandHqSession,
setCommandHqSessionCategory,
purgeCommandHqSessions,
deleteCommandHqSession,
disarmCommandHqWatcher,
getCommandHqCostSummary,
listCommandHqTurns,
listCommandHqWatcherFires,
getLastFiredAtBySessionRowIds,
listCommandHqSessionTags,
listCommandHqTags,
createCommandHqTag,
deleteCommandHqTag,
addCommandHqSessionTag,
removeCommandHqSessionTag,
type CommandHqCostSummary,
} from 'officerdb';
/**
* Command HQ — commanders (long-running background jobs the owner starts, names and organises separately
* from `/chat`). Design record: `docs/command-hq.md`. That file is the "why"; this file is the "what",
* kept deliberately explicit in its doc comments because it is the contract a mobile client builds against.
*
* ── Identity ──
*
* Every route resolves `commandHqIdentity`, the same shape `/chat` resolves as `chatIdentity` (duplicated
* rather than imported — that function is private to `chat.ts` and this is five lines, not worth coupling
* two routers over). `core` permission, no grant to hold: every account reaches this router, and every
* query is additionally scoped to the caller's own `user_id` regardless.
*
* ── Where commanders live ──
*
* Every commander shares ONE working directory per user — `getCommandersDir(email)` — never one per
* commander. That means listing a user's commanders is exactly `listClaudeSessions` (the same function
* `/chat` uses for its own per-directory list) pointed at that one directory, filtered to sessions with a
* `command_hq_sessions` row. No new session-scanning code exists here; this router is a client of the same
* transcript reader `/chat` already is.
*/
async function commandHqIdentity(user: { id: number; email: string }): Promise<ChatIdentity> {
const resolved = await resolveHomeDir(user.id);
if (!resolved.ok) throw errors.FORBIDDEN(resolved.reason);
return { email: user.email, home: resolved.home, osUser: resolved.osUser, isOwner: resolved.isOwner };
}
export const commandHqRouter = createRouter();
// ── Categories ──
// GET /command-hq/categories
commandHqRouter.get('/categories', async (ctx) => {
const user = ctx.get('user');
return ctx.json({ categories: await listCommandHqCategories(user.id) });
});
// POST /command-hq/categories { name: string }
commandHqRouter.post('/categories', async (ctx) => {
const user = ctx.get('user');
const body = await ctx.req.json<{ name?: string }>().catch(() => ({}) as { name?: string });
const name = body.name?.trim();
if (!name) throw errors.BAD_REQUEST('name is required');
try {
return ctx.json(await createCommandHqCategory(user.id, name));
} catch {
// The unique index (`uq_command_hq_categories_user_name`) is the real guard; a Postgres constraint
// violation is the only way this throws, so it is read back as the one thing it can mean.
throw errors.CONFLICT('a category with this name already exists');
}
});
// PATCH /command-hq/categories/:id { name: string }
commandHqRouter.patch('/categories/:id', async (ctx) => {
const user = ctx.get('user');
const id = Number(ctx.req.param('id'));
const body = await ctx.req.json<{ name?: string }>().catch(() => ({}) as { name?: string });
const name = body.name?.trim();
if (!name) throw errors.BAD_REQUEST('name is required');
const ok = await renameCommandHqCategory(user.id, id, name);
if (!ok) throw errors.NOT_FOUND('no such category');
return ctx.json({ ok: true });
});
// DELETE /command-hq/categories/:id — uncategorises its commanders (onDelete: 'set null'), does not delete them.
commandHqRouter.delete('/categories/:id', async (ctx) => {
const user = ctx.get('user');
const id = Number(ctx.req.param('id'));
const ok = await deleteCommandHqCategory(user.id, id);
if (!ok) throw errors.NOT_FOUND('no such category');
return ctx.json({ ok: true });
});
// ── Tags (schema only — no UI planned yet, but the endpoints are real) ──
// GET /command-hq/tags
commandHqRouter.get('/tags', async (ctx) => {
const user = ctx.get('user');
return ctx.json({ tags: await listCommandHqTags(user.id) });
});
// POST /command-hq/tags { name: string }
commandHqRouter.post('/tags', async (ctx) => {
const user = ctx.get('user');
const body = await ctx.req.json<{ name?: string }>().catch(() => ({}) as { name?: string });
const name = body.name?.trim();
if (!name) throw errors.BAD_REQUEST('name is required');
try {
return ctx.json(await createCommandHqTag(user.id, name));
} catch {
throw errors.CONFLICT('a tag with this name already exists');
}
});
// DELETE /command-hq/tags/:id
commandHqRouter.delete('/tags/:id', async (ctx) => {
const user = ctx.get('user');
const id = Number(ctx.req.param('id'));
const ok = await deleteCommandHqTag(user.id, id);
if (!ok) throw errors.NOT_FOUND('no such tag');
return ctx.json({ ok: true });
});
// ── Commanders ──
/** A grid card's worth of information — the transcript summary plus what Command HQ knows about it. */
type CommanderSummary = ClaudeSessionSummary & {
categoryId: number | null;
tagIds: number[];
/**
* Derived from `chat_session_events` at request time — `task:started` minus `task:notification` — the
* same fold `/chat`'s own background-job tray uses (`collectRunningTasks`). NOT a stored value: see
* `docs/command-hq.md` for why a status column would go stale exactly when this feature matters most.
*/
running: boolean;
/**
* Whether the platform is keeping this commander's watcher re-armed — see `docs/command-hq.md` → "The
* watcher loop". Unlike `running`, this IS stored: it is the owner's own intent, not a live-process fact
* the platform is reporting on. `watcherTaskId` itself is not exposed — an internal correlation id with
* no display meaning, the join key `armCommandHqWatcher` uses and nothing a card needs to show.
*/
watcherArmed: boolean;
/** Since when the CURRENT run has been going, or null if never armed. Untouched by every self-re-arm. */
watcherArmedAt: Date | null;
/** When the current run ended, or null while still armed (or never armed). Paired with `watcherArmedAt`. */
watcherStoppedAt: Date | null;
/** `MAX(firedAt)` for this commander — no column of its own, see `docs/command-hq.md` → "The watcher loop". */
watcherLastFiredAt: Date | null;
/** The running total — every turn this commander has ever cost, summed. See `GET .../turns` for the raw series. */
cost: CommandHqCostSummary;
};
async function isRunning(sessionId: string): Promise<boolean> {
const events = await getChatEventsSince(sessionId, 0);
return collectRunningTasks(events.map((e) => e.event as ServerMessage)).length > 0;
}
/**
* GET /command-hq/commanders[?category=<id>|none]
*
* `category` omitted → every commander, categorised or not. `category=none` → only the uncategorised
* bucket. `category=<id>` → one category.
*
* A row whose transcript no longer resolves is silently dropped, never rendered as a tombstone — the
* design's explicit stance (see "The /chat ↔ /command-hq relationship" in the design doc). Response order
* is newest-updated first, matching `/chat`'s own list.
*/
commandHqRouter.get('/commanders', async (ctx) => {
const user = ctx.get('user');
const who = await commandHqIdentity(user);
const categoryParam = ctx.req.query('category');
const categoryId = categoryParam === undefined ? undefined : categoryParam === 'none' ? null : Number(categoryParam);
const [rows, live] = await Promise.all([
listCommandHqSessions(user.id, categoryId),
listClaudeSessions(who, getCommandersDir(who.email)),
]);
const liveById = new Map(live.map((s) => [s.id, s]));
const filed = rows.filter((row) => liveById.has(row.sessionId));
const [tagsByRow, lastFiredByRow, runningById, costByRow] = await Promise.all([
listCommandHqSessionTags(filed.map((row) => row.id)),
getLastFiredAtBySessionRowIds(filed.map((row) => row.id)),
Promise.all(filed.map(async (row) => [row.sessionId, await isRunning(row.sessionId)] as const)).then(
(pairs) => new Map(pairs),
),
// One aggregate query per commander, the same cost class `isRunning` above already accepts for this
// grid — not batched, because a running SUM/COUNT is not a lookup on a small shared table the way tags
// and last-fired are.
Promise.all(filed.map(async (row) => [row.id, await getCommandHqCostSummary(row.id)] as const)).then(
(pairs) => new Map(pairs),
),
]);
const commanders: CommanderSummary[] = filed
.map((row) => ({
...liveById.get(row.sessionId)!,
categoryId: row.categoryId,
tagIds: tagsByRow.get(row.id) ?? [],
running: runningById.get(row.sessionId) ?? false,
watcherArmed: row.watcherArmed,
watcherArmedAt: row.watcherArmedAt,
watcherStoppedAt: row.watcherStoppedAt,
watcherLastFiredAt: lastFiredByRow.get(row.id) ?? null,
cost: costByRow.get(row.id) ?? { turnCount: 0, inputTokens: 0, outputTokens: 0, totalUsd: 0 },
}))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
return ctx.json({ commanders });
});
/**
* POST /command-hq/commanders/new { prompt: string, displayText?: string, categoryId?: number | null }
*
* Creates a commander for a client that cannot open a websocket with the right parameters — mobile,
* notably. The web composer never calls this: it sends an ordinary `/chat` turn with `context:
* 'command-hq'`, and the row is written the moment the harness reports a transcript id (see
* `websocket.ts` → `fileAsCommander`). This route drives the SAME path — `startClaudeTurn`, the function
* that logic was extracted into — with no live socket, and waits for that same id before answering.
*
* `prompt` is required, deliberately. A commander begins as an ordinary conversation — sometimes a real
* back-and-forth before a background job is ever armed, sometimes just "you know what to do" once that
* has already happened elsewhere — but it always begins with something, the same as any `/chat`
* conversation does. There is no "empty commander" to create and later fill in.
*
* `displayText`, separately, is what TITLES the commander — found missing here by the mobile side after
* this route first shipped without it. A commander created to become a background job sends the full
* `buildBackgroundJobPrompt` mechanics as `prompt` (a page of preamble) with the owner's actual sentence
* only at the end, and `command_hq_sessions` has no name column — the title comes from
* `session.meta.title = (displayText ?? prompt).slice(0, 100)`, same precedence the websocket path
* already uses. Without this field every commander created this way titles itself with the mechanics
* text, and the grid becomes a column of identical, unreadable cards.
*
* The response's `sessionId` is Claude's own transcript id — not a placeholder, not Officer's — so the
* client has something it can immediately resume, attach to, or list among its commanders. Getting it
* costs actually starting the turn: Claude assigns no id until one has run at least far enough to report
* `system.init`, which `startClaudeTurn` surfaces as `session:claude`. Waiting for that alone, not the
* whole turn, is what keeps this endpoint fast — the conversation keeps streaming afterward exactly like
* any other session, reachable the normal way from here on.
*/
commandHqRouter.post('/commanders/new', async (ctx) => {
const user = ctx.get('user');
const body = await ctx.req
.json<{ prompt?: string; displayText?: string; categoryId?: number | null }>()
.catch(() => ({}) as { prompt?: string; displayText?: string; categoryId?: number | null });
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;
const outcome = await new Promise<{ ok: true; transcriptId: string } | { ok: false; error: string }>((resolve) => {
// 30s covers a slow spawn (member login checks, a cold sidecar) without holding an HTTP connection
// open indefinitely if something upstream never reports back at all.
const timeout = setTimeout(
() => resolve({ ok: false, error: 'Timed out waiting for the harness to start' }),
30_000,
);
const settle = (result: { ok: true; transcriptId: string } | { ok: false; error: string }) => {
clearTimeout(timeout);
resolve(result);
};
void startClaudeTurn({
email: user.email,
username: toShellUsername(user.username ?? '', user.email),
userId: user.id,
ws: null,
sessionId,
model,
msg: { prompt, displayText: body.displayText?.trim() || undefined, context: 'command-hq' },
effectivePrompt: prompt,
onOutcome: settle,
});
});
if (!outcome.ok) throw errors.INTERNAL_SERVER_ERROR(outcome.error);
/*
The row already exists — `fileAsCommander` wrote it, off the same `session:claude` event this promise
resolved on. What might not have landed yet is the CATEGORY: that write is fire-and-forget and always
files with none, so a race between it and this request is possible in either order. `createCommandHqSession`
is an upsert-on-conflict-do-nothing (safe either way), and the `setCommandHqSessionCategory` UPDATE below
is unconditional and runs after both, which is what makes the outcome deterministic regardless of which
of the two insert attempts actually won: whichever category this request asked for is the one that is
left standing.
*/
await createCommandHqSession(user.id, outcome.transcriptId, body.categoryId ?? null);
await setCommandHqSessionCategory(user.id, outcome.transcriptId, body.categoryId ?? null);
return ctx.json({ sessionId: outcome.transcriptId, cwd: getCommandersDir(user.email) });
});
/**
* GET /command-hq/commanders/:sessionId
*
* Command HQ's OWN metadata for one commander — category, tags, liveness. Deliberately does NOT return the
* conversation itself: the transcript is `GET /chat/sessions/:id`, unchanged, and a client fetches both
* rather than this route duplicating it. 404 both when the session was never filed and when its transcript
* has since vanished — from the caller's side those are the same fact, "not a commander right now".
*/
commandHqRouter.get('/commanders/:sessionId', async (ctx) => {
const user = ctx.get('user');
const who = await commandHqIdentity(user);
const sessionId = ctx.req.param('sessionId');
const row = await getCommandHqSession(user.id, sessionId);
if (!row) throw errors.NOT_FOUND('not a commander');
if (!loadClaudeSessionById(who, sessionId)) throw errors.NOT_FOUND('not a commander');
const [tagsByRow, running, lastFiredByRow, cost] = await Promise.all([
listCommandHqSessionTags([row.id]),
isRunning(sessionId),
getLastFiredAtBySessionRowIds([row.id]),
getCommandHqCostSummary(row.id),
]);
return ctx.json({
sessionId: row.sessionId,
categoryId: row.categoryId,
tagIds: tagsByRow.get(row.id) ?? [],
running,
watcherArmed: row.watcherArmed,
watcherArmedAt: row.watcherArmedAt,
watcherStoppedAt: row.watcherStoppedAt,
watcherLastFiredAt: lastFiredByRow.get(row.id) ?? null,
cost,
createdAt: row.createdAt,
});
});
/**
* GET /command-hq/commanders/:sessionId/turns
*
* The raw per-turn cost ledger, oldest first — what a client plots "cost over time" from, and what
* `cost` on the detail route above is the sum of. Not included inline on that route because it is
* unbounded (a commander running for months accumulates one row per turn), so a client that only wants
* the running total is not paying for the whole history on every open.
*/
commandHqRouter.get('/commanders/:sessionId/turns', async (ctx) => {
const user = ctx.get('user');
const sessionId = ctx.req.param('sessionId');
const row = await getCommandHqSession(user.id, sessionId);
if (!row) throw errors.NOT_FOUND('not a commander');
return ctx.json({ turns: await listCommandHqTurns(row.id) });
});
/**
* GET /command-hq/commanders/:sessionId/fires
*
* Every trigger this commander's watcher has ever fired, oldest first — the boundaries a client uses to
* group `.../turns` by trigger, and the full history behind the single `watcherLastFiredAt` the other
* routes report.
*/
commandHqRouter.get('/commanders/:sessionId/fires', async (ctx) => {
const user = ctx.get('user');
const sessionId = ctx.req.param('sessionId');
const row = await getCommandHqSession(user.id, sessionId);
if (!row) throw errors.NOT_FOUND('not a commander');
return ctx.json({ fires: await listCommandHqWatcherFires(row.id) });
});
/**
* POST /command-hq/commanders { sessionId: string, categoryId?: number }
*
* Files an EXISTING session as a commander. Idempotent — filing an already-filed session is a no-op, and
* does not move it to a different category (use PATCH for that).
*
* This is a low-level primitive, not the normal creation path. The normal path is starting a new commander
* through Command HQ's own composer, which writes this row itself the moment the harness reports a real
* transcript id — see `docs/command-hq.md` → "How a commander is created". This route exists for that
* wiring to call, and for anything else (a purge-and-refile flow, a script) that needs to file a session
* without going through a live chat turn.
*
* The session must actually exist in the commanders directory, or this refuses — filing a `/chat` session
* makes no sense given commanders and `/chat` never share a working directory.
*/
commandHqRouter.post('/commanders', async (ctx) => {
const user = ctx.get('user');
const who = await commandHqIdentity(user);
const body = await ctx.req
.json<{ sessionId?: string; categoryId?: number }>()
.catch(() => ({}) as { sessionId?: string; categoryId?: number });
const sessionId = body.sessionId?.trim();
if (!sessionId) throw errors.BAD_REQUEST('sessionId is required');
const live = listClaudeSessions(who, getCommandersDir(who.email));
if (!live.some((s) => s.id === sessionId)) throw errors.NOT_FOUND('no such session in the commanders directory');
const row = await createCommandHqSession(user.id, sessionId, body.categoryId ?? null);
return ctx.json(row);
});
/**
* PATCH /command-hq/commanders/:sessionId
*
* Body may carry any of:
* `categoryId: number | null` — move to a category, or `null` to uncategorise
* `name: string` — rename. Proxies straight to `renameClaudeSession`, the SAME function `/chat`'s own
* rename action calls — there is no separate name field on this table to disagree with it.
* `watcherArmed: false` — disarm a watcher, so the harness stops re-arming it on its next fire. Only
* `false` is accepted here: arming requires a live task id (`armCommandHqWatcher`), which only the turn
* that starts the watcher has — there is no product path to arm one from outside a running turn, and
* this route is not it.
*/
commandHqRouter.patch('/commanders/:sessionId', async (ctx) => {
const user = ctx.get('user');
const who = await commandHqIdentity(user);
const sessionId = ctx.req.param('sessionId');
const body = await ctx.req
.json<{ categoryId?: number | null; name?: string; watcherArmed?: false }>()
.catch(() => ({}) as { categoryId?: number | null; name?: string; watcherArmed?: false });
const row = await getCommandHqSession(user.id, sessionId);
if (!row) throw errors.NOT_FOUND('not a commander');
if (body.categoryId !== undefined) {
await setCommandHqSessionCategory(user.id, sessionId, body.categoryId);
}
if (body.name !== undefined) {
const ok = renameClaudeSession(who, getCommandersDir(who.email), sessionId, body.name.trim());
if (!ok) throw errors.NOT_FOUND('transcript not found');
}
if (body.watcherArmed === false) {
await disarmCommandHqWatcher(user.id, sessionId);
}
return ctx.json({ ok: true });
});
/**
* DELETE /command-hq/commanders/:sessionId
*
* Deletes the commander outright: the transcript itself (via `deleteClaudeSession`, the same function
* `DELETE /chat/sessions/:id` calls — a `/clear` chain goes with it, every part), then the
* `command_hq_sessions` row. Unlike everywhere else in this file, this is NOT the "row disagrees with
* disk, so hide it" pattern `purgeCommandHqSessions` exists for — it is the one place that makes the two
* agree by deleting the thing itself, on purpose, because the owner asked to.
*
* ── It cascades, and it has to ──
*
* Deleting a commander means being finished with it and with everything it produced. So, in this order:
*
* 1. STOP the processes. The loop the commander started is a descendant of the commander's own `claude`,
* and any agent the loop spawned is a descendant of the loop, so one `killTree` from the session's
* process takes all of it. The comment here used to claim the background process "dies on its own" —
* it does not. Nothing in this route touched a process, so a deleted commander left its loop running:
* still polling, still able to fire, still able to spawn an agent, with no chip to stop it from and no
* row left to disarm.
* 2. DELETE the children's transcripts, read from the spawned record. They cannot be found any other way
* — see `spawned.ts` for the three approaches that do not work.
* 3. DELETE the commander's own transcript and row, as before.
*
* Stopping first is deliberate: a live agent writing to a transcript being deleted is a race, and the loop
* could spawn a new one between steps 2 and 3.
*
* A watcher still armed is not disarmed first, and does not need to be — the row goes away entirely, and
* `armCommandHqWatcher`/`recordCommandHqWatcherFired` can no longer find one to update.
*/
commandHqRouter.delete('/commanders/:sessionId', async (ctx) => {
const user = ctx.get('user');
const who = await commandHqIdentity(user);
const sessionId = ctx.req.param('sessionId');
const row = await getCommandHqSession(user.id, sessionId);
if (!row) throw errors.NOT_FOUND('not a commander');
// 1. Everything this commander is still running.
const stopped = stopCommanderProcesses(who.email, sessionId);
// 2. Everything it spawned. Best-effort per child: one transcript that has already been cleaned up must
// not stop the rest from going, and a child is often gone before its parent.
// Ingest the inbox before reading it, so a child appended since the last read is still deleted. The file
// is lossy by design and the rows are not — but a row that has never been ingested does not exist yet.
const children = (await ingestSpawnedChildren(row.id, listSpawnedSessions(who.email, sessionId))).map(
(c) => c.spawnedSessionId,
);
let childrenDeleted = 0;
for (const child of children) {
try {
// A spawned agent runs in the platform checkout, not the commanders directory, so its transcript
// lives in that project's group — the same lookup `/chat/sessions/:id` falls back to.
// The cwd is a HINT here, not an authority — `deleteClaudeSession` falls back to locating the
// transcript wherever it actually is, which is what makes this work for a child that ran in the
// platform checkout rather than the commanders directory.
if (deleteClaudeSession(who, getCommandersDir(who.email), child)) childrenDeleted += 1;
} catch {
// Already gone, or never written. Counted as not-deleted rather than failing the request.
}
}
clearSpawnedRecord(who.email, sessionId);
// 3. The commander itself.
deleteClaudeSession(who, getCommandersDir(who.email), sessionId);
await deleteCommandHqSession(user.id, sessionId);
// Said back rather than a bare ok: this is the one irreversible action here, and the counts are what
// make it auditable after the fact.
return ctx.json({ ok: true, processesStopped: stopped, childrenFound: children.length, childrenDeleted });
});
/**
* POST /command-hq/purge
*
* Deletes every `command_hq_sessions` row whose transcript no longer resolves in the commanders directory.
* Housekeeping only — nothing about correctness ever depended on this running, since a dangling row is
* already invisible everywhere. Returns how many rows were removed.
*/
commandHqRouter.post('/purge', async (ctx) => {
const user = ctx.get('user');
const who = await commandHqIdentity(user);
const live = listClaudeSessions(who, getCommandersDir(who.email));
const removed = await purgeCommandHqSessions(user.id, new Set(live.map((s) => s.id)));
return ctx.json({ removed });
});
// ── Tag assignment ──
// POST /command-hq/commanders/:sessionId/tags { tagId: number }
commandHqRouter.post('/commanders/:sessionId/tags', async (ctx) => {
const user = ctx.get('user');
const sessionId = ctx.req.param('sessionId');
const body = await ctx.req.json<{ tagId?: number }>().catch(() => ({}) as { tagId?: number });
if (!body.tagId) throw errors.BAD_REQUEST('tagId is required');
const row = await getCommandHqSession(user.id, sessionId);
if (!row) throw errors.NOT_FOUND('not a commander');
await addCommandHqSessionTag(row.id, body.tagId);
return ctx.json({ ok: true });
});
/**
* GET /command-hq/commanders/:sessionId/children
*
* The agents this commander spawned, ingested from the loop's inbox on the way through.
*
* Ingesting on READ rather than on a timer is deliberate: a cron for this is a moving part that can stop
* without anybody noticing, and the read is the only moment the answer is wanted. `onConflictDoNothing` on
* the unique index makes re-ingesting the whole file cost nothing, so this is safe to call as often as the
* UI likes.
*/
commandHqRouter.get('/commanders/:sessionId/children', async (ctx) => {
const user = ctx.get('user');
const who = await commandHqIdentity(user);
const sessionId = ctx.req.param('sessionId');
const row = await getCommandHqSession(user.id, sessionId);
if (!row) throw errors.NOT_FOUND('not a commander');
const children = await ingestSpawnedChildren(row.id, listSpawnedSessions(who.email, sessionId));
// `unknownCost` said out loud rather than summed as zero: a commander whose children's costs are unknown
// must not display a confident total that happens to be too low. That understatement is the whole reason
// this exists.
const known = children.filter((c) => c.totalUsd !== null);
return ctx.json({
children,
totalUsd: known.reduce((sum, c) => sum + (c.totalUsd ?? 0), 0),
unknownCost: children.length - known.length,
});
});
/**
* GET /command-hq/commanders/:sessionId/record-paths
*
* Where this commander's loop must write its pid and the session ids it spawns.
*
* The composer needs these to build the background-job prompt, and cannot derive them: they are rooted in
* `DATA_PATH`, which is a server fact. Returning paths rather than accepting an upload is deliberate — the
* loop writes them itself with a shell redirect, which needs no token and works from inside a background
* job, where an authenticated POST would need a credential the loop has no business holding.
*
* Not gated on the commander existing as a ROW: a brand-new commander asks for these before its first turn
* has filed it, and refusing then would mean the very first loop is the one that cannot record itself.
*/
commandHqRouter.get('/commanders/:sessionId/record-paths', async (ctx) => {
const user = ctx.get('user');
const who = await commandHqIdentity(user);
const sessionId = ctx.req.param('sessionId');
return ctx.json({
loopPidPath: loopPidPath(who.email, sessionId),
spawnedPath: spawnedRecordPath(who.email, sessionId),
});
});
// DELETE /command-hq/commanders/:sessionId/tags/:tagId
commandHqRouter.delete('/commanders/:sessionId/tags/:tagId', async (ctx: Context) => {
const user = ctx.get('user');
const sessionId = ctx.req.param('sessionId');
const tagId = Number(ctx.req.param('tagId'));
const row = await getCommandHqSession(user.id, sessionId);
if (!row) throw errors.NOT_FOUND('not a commander');
await removeCommandHqSessionTag(row.id, tagId);
return ctx.json({ ok: true });
});