Files
platform/src/servers/api/command-hq/router.ts
T
pastilhasandClaude Opus 5 e29a4ee380 a credential can be narrower than its holder
a commander is an agent working unattended for months, and the only
credential in the tree lived in the MCP tool server's environment where
the agent's own shell cannot see it. so the first one built here curled
the whisper container directly on localhost: right answer, wrong door, and
a door the platform does not control.

api_keys.permissions. null is what every key a human minted still is —
their full authority, exactly what the password that made it could do. a
list narrows: the credential reaches only those permission keys, and still
only where the account itself could. intersection, never union.

checked BEFORE the owner bypass, which is the whole point. a Super Admin
consults no grants table, so a token carrying the owner's identity is
unbounded by construction — fine for a browser session somebody is sitting
in front of, not fine for a process that runs for months. this is the only
thing in the platform that can bound them. verified against the running
server: an owner key scoped to [account] gets 200 on /api/user/settings
and 403 on /api/users, /api/tasks and /api/plugins.

it propagates. a narrowed credential can write a task and run it, so the
token buildTaskApiEnv hands that script carries the same narrowing —
otherwise the script comes back through the API with full authority and
the limit bought nothing. the gate stashes it on the request for the one
kind of route that passes authority onward.

POST /tasks/:name/run: runs a script task, waits, returns code and output.
both existing runners need something watching them — the socket needs a
live connection, a job needs somebody to open Jobs. a commander has
neither. deliberately not a job: transcribing a memo takes seconds and
produces a file, and a queue plus a row plus a progress bar is a screen
nobody opened between the request and the result.

script-run.ts is the third thing this needed and the one worth having on
its own. the socket runner, the job runner and now this each carried the
same twenty lines — materialize, INPUT_ env, positional args, resolve cwd,
mint the api env — identical, with nothing saying they had to stay that
way. they differ in how they REPORT, which is real and stays with each of
them. what the script SEES is now decided once.

a commander gets its key at creation, in officer.env at 0600 inside its own
directory, and CLAUDE.md tells it where and says plainly that a sidecar
listening on localhost with no auth is still the wrong door. a file rather
than an env var because the detached loop a commander arms is spawned by
the agent and inherits nothing the platform chose. deleting the commander
revokes the row — the directory removal takes the secret, not the thing
that makes it work.

default scope is everything except `admin`: a commander that can reach
nothing gets widened in a hurry rather than thought about, and installing
plugins, managing users and changing server settings are never part of a
watcher's job. it is a default, stored per key, and for a member it
narrows itself against their role.

also fixed: POST /command-hq/commanders/new never awaited
createCommanderDirectory, so a turn could spawn before its CLAUDE.md
existed — the exact ordering that redesign was for — and the cwd it
returned was a Promise serialising as {}.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 13:40:42 +00:00

856 lines
42 KiB
TypeScript

import type { Context } from 'hono';
import { randomUUID } from 'node:crypto';
import { rmSync } from 'node:fs';
import { createRouter } from '../../create-router';
import { checkBudget } from '../sub-agents/budget';
import * as errors from '@@/custom-errors';
import { resolveHomeDir } from '@@/user-home';
import { toShellUsername, getCommanderDir } from '@@/data-path';
import {
forgetChatSessions,
getChatEventsSince,
ingestSpawnedChildren,
revokeApiKeysByName,
sessionKeyForTranscriptId,
} from 'officerdb';
import * as sidecar from '@@/sidecar-registry';
import { findSessionKeyByClaudeSession } from '@@/sidecar/claude/state';
import { sessionKeyForTranscript } from '../chat/websocket';
import { listSpawnedSessions, loopPidPath, spawnedRecordPath, stopCommanderProcesses, liveLoopPid } from './spawned';
import { createCommanderDirectory } from './directory';
import { commanderKeyName } from './credential';
import type { ServerMessage } from '../chat/types';
import {
loadClaudeSessionById,
loadClaudeSessionSummaryById,
renameClaudeSession,
deleteClaudeSession,
type ChatIdentity,
type ClaudeSessionSummary,
} from '../chat/claude-sessions';
import { collectRunningTasks, startClaudeTurn, getUserDefaultModel, DEFAULT_MODEL } from '../chat/websocket';
import {
listCommandHqCategories,
createCommandHqCategory,
renameCommandHqCategory,
deleteCommandHqCategory,
createCommander,
setCommanderSessionId,
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 ──
*
* **Its own directory, created by this router before any turn spawns.** `POST /commanders` creates the row
* AND `$DATA_PATH/<email>/commanders/<dirName>/` together, writes `CLAUDE.md`, and only then does the
* caller start a turn with `cwd` set to that directory — see `docs/commander-directory.md` for why the
* ordering is the entire fix.
*
* Two earlier designs are worth knowing so this one is not mistaken for a third try at the same idea. One
* shared directory per user, until 2026-08-22, because the directory WAS the identity — the only way to
* tell a commander from an ordinary chat, which is also what made this router's own listing impossible to
* do any other way than a directory scan. Then, briefly, a commander chose an arbitrary directory anywhere
* in its owner's tree, once identity moved to the `command_hq_sessions` row — free to be useful, but free
* of a directory a commander could depend on existing, or write anything durable into before its first
* turn even started. This router owning creation is what fixes both: identity is still the row, and the
* directory is still guaranteed to exist and be this commander's own before anything runs in it.
*
* The consequence for this router is that listing is not a directory scan either way. It reads its rows and
* loads each transcript BY ID (`loadClaudeSessionSummaryById`) — a handful of lookups rather than one walk.
* A row whose transcript cannot be found anywhere is treated as absent, exactly as a missing file in a scan
* would have been, and `POST /purge` is what tidies those. A row with no transcript YET (freshly created,
* before its first turn) is treated the same way — absent from the grid until it has one — which is fine:
* the composer that just created it already has everything it needs to keep going without a grid entry.
*/
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);
/*
Each commander's transcript is loaded BY ID, not found by scanning one directory — a commander's own
directory holds `CLAUDE.md` and `state/`, never a transcript, so there is nothing to scan there any more
either. One `locateTranscript` per row instead. Commanders are few — this is a handful of stats, not a
directory walk per row — and a row whose transcript cannot be found anywhere is treated as absent,
which is what `purgeCommandHqSessions` exists to tidy.
A row with `sessionId: null` — created but never yet turned — is absent from this grid for the same
reason: there is no transcript to summarise. Filtered out before the lookup rather than after, so
`loadClaudeSessionSummaryById` is never asked about an id that cannot exist.
*/
const rows = await listCommandHqSessions(user.id, categoryId);
const withTranscript = rows.filter((row) => row.sessionId !== null);
const summaries = withTranscript
.map((row) => loadClaudeSessionSummaryById(who, row.sessionId!))
.filter((s): s is NonNullable<typeof s> => !!s);
const liveById = new Map(summaries.map((s) => [s.id, s]));
const filed = withTranscript.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!)!,
// The row's name wins over the transcript's. It is written when the commander is CREATED, before any
// turn runs, while the transcript's own summary record cannot exist until the first turn ends — so
// until then the transcript still reports the opening brief, and the row is the only source that is
// right from the start.
...(row.name ? { title: row.name } : {}),
categoryId: row.categoryId,
tagIds: tagsByRow.get(row.id) ?? [],
running: runningById.get(row.sessionId!) ?? false,
// The pid file is the FACT; `watcher_armed` is a claim only one path sets. A commander that started
// its loop as a plain background job recorded its pid correctly and still reported unarmed, so the
// grid offered nothing to stop — which is the one thing this column exists to make possible. Keyed
// on `dirName`, not the transcript id: the pid file lives in this commander's OWN `state/`, not
// beside the transcript.
watcherArmed: row.watcherArmed || liveLoopPid(who.email, row.dirName) !== null,
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, name: string, referencePath: 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 starts a chat turn directly, in the directory
* `POST /commanders` (below) already created for it. This route drives the identical two-step order for a
* caller with no live socket — create the row and its directory FIRST, via the same `createCommander` +
* `createCommanderDirectory` this router's bare `POST /commanders` uses, THEN start the turn with `cwd` set
* to that directory — rather than a second copy that could drift from it. See
* `docs/commander-directory.md` for why the ordering is the fix and not an implementation detail.
*
* **This changed shape 2026-08-24.** `name` and `referencePath` are now required, and `cwd` is no longer
* accepted as the actual working directory — a commander runs in its OWN directory now, never one a caller
* names. Neither client had been exercised against a running server yet when this changed (`docs/command-
* hq.md`), so there is no live caller to keep compatible with the old shape.
*
* `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 the TRANSCRIPT shows for the opening message — the row's own `name`
* already titles the commander everywhere Command HQ reads it, but the harness still names its own summary
* record from the first prompt, and `prompt` here is often the full `buildBackgroundJobPrompt` mechanics
* rather than something a person would want to read back.
*
* 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 who = await commandHqIdentity(user);
const body = await ctx.req
.json<{
prompt?: string;
displayText?: string;
categoryId?: number | null;
name?: string;
referencePath?: string;
}>()
.catch(
() =>
({}) as {
prompt?: string;
displayText?: string;
categoryId?: number | null;
name?: string;
referencePath?: string;
},
);
const prompt = body.prompt?.trim();
const name = body.name?.trim();
const referencePath = body.referencePath?.trim();
if (!prompt) throw errors.BAD_REQUEST('prompt is required');
if (!name) throw errors.BAD_REQUEST('name is required');
if (!referencePath) throw errors.BAD_REQUEST('referencePath 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');
// Row and directory first — see `POST /commanders` below for the same two calls with the full reasoning.
const row = await createCommander({ userId: user.id, name, referencePath, categoryId: body.categoryId ?? null });
// AWAITED. It was not, and that is the ordering this whole redesign exists to get right: Claude Code
// reads CLAUDE.md from the working directory at session start, so a turn spawned before the file is
// written is a commander that never learns what it is. The un-awaited call also made the `cwd` returned
// below a Promise, which serialised as `{}`.
const { dir, ownershipError, credentialError } = await createCommanderDirectory(who.email, row.dirName, {
name,
referencePath,
userId: user.id,
});
if (ownershipError)
console.warn(`[command-hq] ${row.dirName}: directory not handed to the account — ${ownershipError}`);
if (credentialError) console.warn(`[command-hq] ${row.dirName}: no credential minted — ${credentialError}`);
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',
// No `cwd` here: `resolveChatCwd` resolves the commander's directory itself from `commanderId`
// (`docs/commander-directory.md`), the same as the web composer's turn does. `dir`, computed above,
// is used only for the JSON response below.
commanderId: row.id,
},
effectivePrompt: prompt,
onOutcome: settle,
});
});
if (!outcome.ok) throw errors.INTERNAL_SERVER_ERROR(outcome.error);
/*
Belt and braces: `fileAsCommander` (`websocket.ts`) already points this row at the transcript id off the
same `session:claude` event this promise resolved on, but that write is fire-and-forget so it is not
guaranteed to have landed by the time this handler resumes. `setCommanderSessionId` is a plain,
idempotent UPDATE — calling it again here costs nothing and guarantees the row is correct before this
response goes out, rather than trusting a race this handler has no way to observe.
*/
await setCommanderSessionId(user.id, row.id, outcome.transcriptId);
return ctx.json({ sessionId: outcome.transcriptId, cwd: dir, id: row.id, dirName: row.dirName });
});
/**
* 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,
name: row.name,
referencePaths: row.referencePaths,
categoryId: row.categoryId,
tagIds: tagsByRow.get(row.id) ?? [],
running,
watcherArmed: row.watcherArmed || liveLoopPid(who.email, row.dirName) !== null,
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 { name: string, referencePath: string, categoryId?: number | null }
*
* Creates a commander. **This is the creation path now, and it changed shape 2026-08-24** — it used to file
* an EXISTING session (`{ sessionId, categoryId? }`), which is the wrong order per
* `docs/commander-directory.md`: a chat session existed first and was filed as a commander second, so the
* working directory was fixed before any commander id existed and a commander could never start in a
* directory named after itself. There is no live caller of the old shape to keep compatible with — see the
* design doc's "Order of work".
*
* Creates, in order: the row (`createCommander`, no transcript — `sessionId` stays null until a turn
* reports one), THEN the directory and its `CLAUDE.md` (`createCommanderDirectory`) — the row has to exist
* first because `dirName` is derived from its own id. Returns the row, including `cwd`: the caller (the
* web composer, `CommandHqDetail.tsx`) starts its chat turn with `cwd` set to exactly this path, and
* threads `commanderId: row.id` on that turn so `fileAsCommander` (`websocket.ts`) can point this same row
* at the transcript id the moment one exists — the same server-driven, don't-depend-on-a-live-browser
* pattern this router already used for the commander's name, now doing the same job for its `sessionId`.
* No separate `PATCH … { sessionId }` route exists for this: it would only reintroduce the thing this
* design removes — a client that has to remember to follow through, on a value the server already knows
* first.
*/
commandHqRouter.post('/commanders', async (ctx) => {
const user = ctx.get('user');
const who = await commandHqIdentity(user);
const body = await ctx.req
.json<{ name?: string; referencePath?: string; categoryId?: number | null }>()
.catch(() => ({}) as { name?: string; referencePath?: string; categoryId?: number | null });
const name = body.name?.trim();
const referencePath = body.referencePath?.trim();
if (!name) throw errors.BAD_REQUEST('name is required');
if (!referencePath) throw errors.BAD_REQUEST('referencePath is required');
const row = await createCommander({ userId: user.id, name, referencePath, categoryId: body.categoryId ?? null });
const { dir, ownershipError, credentialError } = await createCommanderDirectory(who.email, row.dirName, {
name,
referencePath,
userId: user.id,
});
if (credentialError) console.warn(`[command-hq] ${row.dirName}: no credential minted — ${credentialError}`);
if (ownershipError) {
console.warn(`[command-hq] ${row.dirName}: directory not handed to the account — ${ownershipError}`);
}
// `ownershipError` is returned as well as logged. A member whose commander directory could not be handed
// over gets an agent that dies on its first command, and "the platform knew and only wrote it to a log"
// is the worst version of that.
return ctx.json({ ...row, cwd: dir, ...(ownershipError ? { ownershipError } : {}) });
});
/**
* 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) {
// `who.home` as the HINT only — `findTranscript` locates the transcript wherever it actually is, and a
// commander's directory is its own choice now.
const ok = renameClaudeSession(who, who.home, 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 — the SESSION first, then the loop.
Killing the loop alone is not enough, and its own test proved it: the loop is a grandchild of the
commander's `claude` process, so ending the loop leaves the session alive — and a live session REWRITES
its transcript after the file is deleted. Twice tonight a deleted commander's transcript reappeared, and
twice it read as the delete having failed.
`killClaudeSession` takes officer's session key while this route holds a transcript id, so the map is
read backwards — `findSessionKeyByClaudeSession`, which exists for the same reason a refreshed browser
holds only the uuid. Ending the session takes its children with it, and the pid-based stop below then
catches anything that outlived its parent.
*/
// Same mapping problem as the briefing: the sidecar's map is not visible from here, so the platform's own
// is asked first and the sidecar's is kept as a fallback for a session that predates this process.
/*
Third fallback, and the only one that survives a restart.
Both maps above are in-memory and answer only for a session younger than this process. After a
restart they both return undefined, no kill is sent, and the transcript is deleted while the harness
is still writing — so it writes it back and the deleted commander returns as an untitled stub. Seen
twice tonight, after the fix that was meant to prevent exactly this.
*/
const sessionKey =
sessionKeyForTranscript(sessionId) ??
findSessionKeyByClaudeSession(sessionId, user.id) ??
(await sessionKeyForTranscriptId(sessionId).catch(() => undefined));
/*
Across the socket, and AWAITED — both halves matter.
`killClaudeSession` from `claude-manager` was called directly here, and it could only ever return false:
the session map lives in the `officer-claude-code` process and this route runs in `officer`, so it was
reading an empty map in the wrong process. The kill silently never happened.
Awaited because the next thing this route does is delete the transcript, and the `claude` CLI owns that
file. Deleting it while the CLI is still unwinding removes a file that is written straight back — which
is exactly how a deleted commander came back as an untitled ordinary chat.
*/
const sessionKilled = sessionKey ? await sidecar.killClaudeAndWait(sessionKey, user.id) : false;
const stopped = stopCommanderProcesses(who.email, row.dirName);
// 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, row.dirName))).map(
(c) => c.spawnedSessionId,
);
let childrenDeleted = 0;
// Every transcript id actually removed — the commander's and its children's, chains included. Collected
// rather than assumed, because a `/clear` chain is several ids behind one conversation.
const forgotten: string[] = [];
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 a
// different directory from its parent.
//
// `.length`, not truthiness: this returns the ids it removed, and an empty array is truthy.
const removedChild = deleteClaudeSession(who, who.home, child);
if (removedChild.length) {
childrenDeleted += 1;
forgotten.push(...removedChild);
}
} catch {
// Already gone, or never written. Counted as not-deleted rather than failing the request.
}
}
/*
The commander's OWN directory — `CLAUDE.md`, `state/`, and anything else it wrote for itself. Not just
`state/` (which `clearSpawnedRecord` alone would remove): per `docs/commander-directory.md`, deleting a
commander is deleting its directory, whole, which is also the only way to be sure nothing is left
behind. Best-effort, same posture as the children above — a directory already gone is not a failure.
*/
try {
rmSync(getCommanderDir(who.email, row.dirName), { recursive: true, force: true });
} catch {
// Already gone, or never fully created. The row is still deleted below regardless.
}
/*
And its credential, which the directory removal does NOT cover — the secret file goes with the
directory, but the row that makes it work lives in Postgres. A commander whose files are gone and whose
key still authenticates is the worst of both: nothing on disk to find it by, and a live credential.
Revoked rather than deleted, so what it was and when it was last used survive the commander.
*/
await revokeApiKeysByName(user.id, commanderKeyName(row.dirName)).catch((err) => {
console.warn(`[command-hq] ${row.dirName}: credential not revoked — ${err}`);
});
// 3. The commander itself.
forgotten.push(...deleteClaudeSession(who, who.home, sessionId));
await deleteCommandHqSession(user.id, sessionId);
/*
Forget what the platform stored ABOUT every transcript that just went — the commander's and its
children's, in one call.
The `command_hq_*` tables cascade from the row, because they reference it. These four do not: they are
keyed on a transcript id, which is a FILE, so there is no row for them to reference and nothing to
cascade from. Left behind they are mostly inert — a model, a bell preference, a durable copy of turn
output — except `nudges`, which is a live schedule that would keep firing at a conversation that no
longer exists.
*/
/*
`sessionKeys` carries the commander's own key and nothing else, on purpose. Children are spawned by
`claude -p` outside officer's websocket, so they never had a key and never wrote a durable event — an
empty entry for them would be noise, not caution.
`sessionKey` is resolved above for the kill and reused here rather than looked up twice; when it is null
the session was already gone, and so were its events.
*/
await forgetChatSessions({ transcriptIds: forgotten, sessionKeys: sessionKey ? [sessionKey] : [] });
// 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,
sessionKilled,
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);
/*
"Still exists" is now per row, wherever that row's transcript lives.
Scanning one directory would have deleted every commander that chose a different one — a housekeeping
route quietly unfiling live commanders is the worst possible version of this bug, because the rows are
what identity depends on now. A row with no transcript yet (`sessionId: null`, mid-creation) is never a
purge candidate at all — see `purgeCommandHqSessions`'s own guard — so it needs no entry in this set.
*/
const rows = await listCommandHqSessions(user.id);
const stillThere = new Set(
rows
.map((row) => row.sessionId)
.filter((id): id is string => id !== null)
.filter((id) => !!loadClaudeSessionSummaryById(who, id)),
);
const removed = await purgeCommandHqSessions(user.id, stillThere);
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, row.dirName));
// `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 same two paths
* `CLAUDE.md` already states at creation (`docs/commander-directory.md`). This route still exists for
* `BackgroundJobDialog`, which needs them again at ARM time, well after creation, to build the watch loop's
* own prompt (`background-job-prompt.ts`) — a live turn's `CLAUDE.md` is not something this route re-reads.
*
* Rooted in `state/` inside this commander's own directory, keyed by `dirName` — which means it now needs
* the ROW (to learn `dirName`), unlike before, when the path was a pure function of the transcript id and
* nothing else. A commander always has a row by the time any turn exists, so this is not a new restriction
* in practice — it only makes explicit what used to be implicit.
*/
commandHqRouter.get('/commanders/:sessionId/record-paths', 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');
return ctx.json({
loopPidPath: loopPidPath(who.email, row.dirName),
spawnedPath: spawnedRecordPath(who.email, row.dirName),
});
});
// 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 });
});