a commander's card gave its name and its cost and nothing to connect it to `commanders/<n>/` on disk. the owner is the only person who will ever look at that directory, and finding out which one a card was meant opening it and reading a path out of a tool call. #40 beside the title, monospaced and muted, with the full path on hover. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
868 lines
43 KiB
TypeScript
868 lines
43 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, COMMANDER_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 } : {}),
|
|
// Its directory name, which is also its id. Shown beside the title so the owner can tell which
|
|
// `commanders/<n>/` on disk a card is, without opening it and reading a path out of a tool call.
|
|
dirName: row.dirName,
|
|
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,
|
|
// Whether the LOOP is alive right now, separate from `running` (an agent turn in progress) and from
|
|
// `watcherArmed` (which is also true for a row that says armed but whose loop has gone).
|
|
watcherRunning: 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();
|
|
// Not the account default: a commander runs on Sonnet — see COMMANDER_DEFAULT_MODEL.
|
|
const model = COMMANDER_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,
|
|
// The commander's OWN directory name, so a client can open a file browser on it. Its directory sits
|
|
// under `user-data` (`$DATA_PATH/<email>/commanders/<dirName>`), which the `home` root the file
|
|
// browser normally serves cannot reach at all — see `file-browser/router.ts` → `getRootDir`.
|
|
dirName: row.dirName,
|
|
referencePaths: row.referencePaths,
|
|
categoryId: row.categoryId,
|
|
tagIds: tagsByRow.get(row.id) ?? [],
|
|
running,
|
|
watcherArmed: row.watcherArmed || liveLoopPid(who.email, row.dirName) !== null,
|
|
watcherRunning: 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 });
|
|
});
|