import type { Context } from 'hono'; import { randomUUID } from 'node:crypto'; import { createRouter } from '../../create-router'; import * as errors from '@@/custom-errors'; import { resolveHomeDir } from '@@/user-home'; import { getCommandersDir, toShellUsername } from '@@/data-path'; import { getChatEventsSince } from 'officerdb'; 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 { 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 { const events = await getChatEventsSince(sessionId, 0); return collectRunningTasks(events.map((e) => e.event as ServerMessage)).length > 0; } /** * GET /command-hq/commanders[?category=|none] * * `category` omitted → every commander, categorised or not. `category=none` → only the uncategorised * bucket. `category=` → 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'); 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. * * A watcher still armed at the time of deletion is left as-is rather than disarmed first: the transcript * is gone either way, `armCommandHqWatcher`/`recordCommandHqWatcherFired` can no longer find a row to * update once this returns, and the running background process (if any) dies on its own the same way it * would if the harness process were killed out from under any other conversation — not a new failure * mode this route has to account for. */ 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'); deleteClaudeSession(who, getCommandersDir(who.email), sessionId); await deleteCommandHqSession(user.id, sessionId); return ctx.json({ ok: true }); }); /** * 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 }); }); // 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 }); });