`chat_session_events` is keyed on officer's SESSION KEY. The other three tables are keyed on the harness's TRANSCRIPT ID. Both are uuids for the same conversation, and `forgetChatSessions` took one list and passed it to all four — so the events delete matched nothing. Not sometimes: by construction, every time. A deleted conversation left its entire durable output behind. It survived two verifications because counting events BY TRANSCRIPT ID also returns zero by construction, and zero read as "cleaned". What settled it was reading an orphaned event instead of counting it — the payload carries both ids side by side and says outright which key the row is filed under. So the parameter is now named for its id space. That is the whole defence and it is the only one available: nothing else distinguishes the two, since both are uuids of the same shape, and a positional `string[]` cannot be got wrong loudly. Empty `sessionKeys` is legitimate and common — a session spawned by `claude -p` outside officer's websocket never had a key and never wrote an event, which is exactly a commander's children. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
734 lines
35 KiB
TypeScript
734 lines
35 KiB
TypeScript
import type { Context } from 'hono';
|
|
import { randomUUID } from 'node:crypto';
|
|
import { createRouter } from '../../create-router';
|
|
import { checkBudget } from '../sub-agents/budget';
|
|
import * as errors from '@@/custom-errors';
|
|
import { resolveHomeDir } from '@@/user-home';
|
|
import { getCommandersDir, toShellUsername } from '@@/data-path';
|
|
import { forgetChatSessions, getChatEventsSince, ingestSpawnedChildren } from 'officerdb';
|
|
import { killClaudeSession } from '@@/sidecar/claude/claude-manager';
|
|
import { findSessionKeyByClaudeSession } from '@@/sidecar/claude/state';
|
|
import { sessionKeyForTranscript } from '../chat/websocket';
|
|
import {
|
|
clearSpawnedRecord,
|
|
listSpawnedSessions,
|
|
loopPidPath,
|
|
spawnedRecordPath,
|
|
stopCommanderProcesses,
|
|
} from './spawned';
|
|
import type { ServerMessage } from '../chat/types';
|
|
import {
|
|
listClaudeSessions,
|
|
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,
|
|
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 ──
|
|
*
|
|
* **Wherever the owner chose.** Each commander names its own working directory when it is created, and that
|
|
* directory is where its relative paths resolve and where its transcripts are grouped.
|
|
*
|
|
* It was 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. Identity is the `command_hq_sessions` row now, so the
|
|
* directory became free to be useful, and a commander that works on the platform can live in the platform.
|
|
*
|
|
* The consequence for this router is that listing is no longer a directory scan. 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 the old scan
|
|
* was, and `POST /purge` is what tidies those.
|
|
*/
|
|
|
|
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.
|
|
|
|
It used to be `listClaudeSessions(who, getCommandersDir(...))` — every commander lived in one place, so
|
|
one scan found them all and a row without a match there was a row whose transcript had been deleted. A
|
|
commander chooses its own working directory now, so that scan finds only the ones that happen to still
|
|
live in the old shared folder and would silently hide every other.
|
|
|
|
One `locateTranscript` per row instead of one scan for all of them. 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
|
|
still treated exactly as before: absent, which is what `purgeCommandHqSessions` exists to tidy.
|
|
*/
|
|
const rows = await listCommandHqSessions(user.id, categoryId);
|
|
const summaries = rows
|
|
.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 = 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; cwd?: string; name?: string }>()
|
|
.catch(
|
|
() => ({}) as { prompt?: string; displayText?: string; categoryId?: number | null; cwd?: string; name?: string },
|
|
);
|
|
const prompt = body.prompt?.trim();
|
|
if (!prompt) throw errors.BAD_REQUEST('prompt is required');
|
|
|
|
// ── Room to run, before starting work nobody is watching ──
|
|
//
|
|
// A commander created here runs unattended. If it burns the last of a window, the session the owner is
|
|
// actually sitting in dies mid-turn — the credits went somewhere nobody was looking. So the floor is
|
|
// checked at the one door that starts unattended work, rather than left to whoever calls it.
|
|
//
|
|
// It fails OPEN by design (see budget.ts): the numbers come from an external endpoint with a credential
|
|
// Officer does not own, and a network blip must not stop every spawn on the machine.
|
|
const budget = await checkBudget();
|
|
if (!budget.ok) throw errors.TOO_MANY_REQUESTS(budget.reason ?? 'not enough usage headroom to start an agent');
|
|
|
|
const sessionId = randomUUID();
|
|
const model = (await getUserDefaultModel(user.id)) || DEFAULT_MODEL;
|
|
|
|
const outcome = await new Promise<{ ok: true; transcriptId: string } | { ok: false; error: string }>((resolve) => {
|
|
// 30s covers a slow spawn (member login checks, a cold sidecar) without holding an HTTP connection
|
|
// open indefinitely if something upstream never reports back at all.
|
|
const timeout = setTimeout(
|
|
() => resolve({ ok: false, error: 'Timed out waiting for the harness to start' }),
|
|
30_000,
|
|
);
|
|
const settle = (result: { ok: true; transcriptId: string } | { ok: false; error: string }) => {
|
|
clearTimeout(timeout);
|
|
resolve(result);
|
|
};
|
|
void startClaudeTurn({
|
|
email: user.email,
|
|
username: toShellUsername(user.username ?? '', user.email),
|
|
userId: user.id,
|
|
ws: null,
|
|
sessionId,
|
|
model,
|
|
msg: {
|
|
prompt,
|
|
displayText: body.displayText?.trim() || undefined,
|
|
context: 'command-hq',
|
|
// Where this commander runs, and its name. Both are chosen before the first message — the UI makes
|
|
// them mandatory — so this programmatic path takes them too rather than inventing a default. An
|
|
// absent cwd falls back to the account's home in `resolveChatCwd`, which is the honest last resort
|
|
// for a caller that predates the requirement.
|
|
...(body.cwd?.trim() ? { cwd: body.cwd.trim() } : {}),
|
|
...(body.name?.trim() ? { commanderName: body.name.trim() } : {}),
|
|
},
|
|
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);
|
|
|
|
// The cwd it ACTUALLY ran in, from the spawn outcome — not a guess at the old shared directory, which is
|
|
// no longer where a commander necessarily lives.
|
|
return ctx.json({ sessionId: outcome.transcriptId, cwd: body.cwd?.trim() ?? null });
|
|
});
|
|
|
|
/**
|
|
* 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');
|
|
|
|
// Located by id wherever it lives, not required to be in the old shared directory. A commander chooses
|
|
// its own working directory, so "is it in the commanders folder" stopped being a meaningful check — and
|
|
// as a GUARD it only ever established that the transcript exists, which this still does.
|
|
if (!loadClaudeSessionSummaryById(who, sessionId)) throw errors.NOT_FOUND('no such conversation');
|
|
|
|
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) {
|
|
// `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.
|
|
const sessionKey = sessionKeyForTranscript(sessionId) ?? findSessionKeyByClaudeSession(sessionId, user.id);
|
|
const sessionKilled = sessionKey ? killClaudeSession(sessionKey, user.id) : false;
|
|
const stopped = stopCommanderProcesses(who.email, sessionId);
|
|
|
|
// 2. Everything it spawned. Best-effort per child: one transcript that has already been cleaned up must
|
|
// not stop the rest from going, and a child is often gone before its parent.
|
|
// Ingest the inbox before reading it, so a child appended since the last read is still deleted. The file
|
|
// is lossy by design and the rows are not — but a row that has never been ingested does not exist yet.
|
|
const children = (await ingestSpawnedChildren(row.id, listSpawnedSessions(who.email, sessionId))).map(
|
|
(c) => c.spawnedSessionId,
|
|
);
|
|
let childrenDeleted = 0;
|
|
// 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.
|
|
}
|
|
}
|
|
clearSpawnedRecord(who.email, sessionId);
|
|
|
|
// 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.
|
|
*/
|
|
const rows = await listCommandHqSessions(user.id);
|
|
const stillThere = new Set(rows.map((row) => row.sessionId).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, sessionId));
|
|
// `unknownCost` said out loud rather than summed as zero: a commander whose children's costs are unknown
|
|
// must not display a confident total that happens to be too low. That understatement is the whole reason
|
|
// this exists.
|
|
const known = children.filter((c) => c.totalUsd !== null);
|
|
return ctx.json({
|
|
children,
|
|
totalUsd: known.reduce((sum, c) => sum + (c.totalUsd ?? 0), 0),
|
|
unknownCost: children.length - known.length,
|
|
});
|
|
});
|
|
|
|
/**
|
|
* GET /command-hq/commanders/:sessionId/record-paths
|
|
*
|
|
* Where this commander's loop must write its pid and the session ids it spawns.
|
|
*
|
|
* The composer needs these to build the background-job prompt, and cannot derive them: they are rooted in
|
|
* `DATA_PATH`, which is a server fact. Returning paths rather than accepting an upload is deliberate — the
|
|
* loop writes them itself with a shell redirect, which needs no token and works from inside a background
|
|
* job, where an authenticated POST would need a credential the loop has no business holding.
|
|
*
|
|
* Not gated on the commander existing as a ROW: a brand-new commander asks for these before its first turn
|
|
* has filed it, and refusing then would mean the very first loop is the one that cannot record itself.
|
|
*/
|
|
commandHqRouter.get('/commanders/:sessionId/record-paths', async (ctx) => {
|
|
const user = ctx.get('user');
|
|
const who = await commandHqIdentity(user);
|
|
const sessionId = ctx.req.param('sessionId');
|
|
return ctx.json({
|
|
loopPidPath: loopPidPath(who.email, sessionId),
|
|
spawnedPath: spawnedRecordPath(who.email, sessionId),
|
|
});
|
|
});
|
|
|
|
// DELETE /command-hq/commanders/:sessionId/tags/:tagId
|
|
commandHqRouter.delete('/commanders/:sessionId/tags/:tagId', async (ctx: Context) => {
|
|
const user = ctx.get('user');
|
|
const sessionId = ctx.req.param('sessionId');
|
|
const tagId = Number(ctx.req.param('tagId'));
|
|
|
|
const row = await getCommandHqSession(user.id, sessionId);
|
|
if (!row) throw errors.NOT_FOUND('not a commander');
|
|
await removeCommandHqSessionTag(row.id, tagId);
|
|
return ctx.json({ ok: true });
|
|
});
|