pointing a commander at a mailbox meant typing its path, which meant a file
browser and an account allowed to walk the disk. only the owner has that — so
"watch my email and tell me when X" was a super-user feature, in a product whose
whole point is that it should not be.
every plugin chat already mounts `ChatHeader` with a `chatContext`, and that
context IS the address: the server turns `{context:'email', contextId:'me@…'}`
into the mailbox's own directory, beside its `emails.db`. so one button on that
header reaches every plugin chat there will ever be, and a new plugin gets it by
declaring the context it already had to declare to have a chat at all. that is
what makes this common to all those chats rather than copied into each.
THE CLIENT SENDS NO PATH, because it has none. a plugin's chats live under
$DATA_PATH/<email>/plugins/<app>/chats and an email chat beside its own database;
both are built server-side and deliberately never sent to the browser. the
resolver moved out of chat.ts into `chat-context.ts` so command-hq uses the SAME
mapping — two copies would be two chances for a commander to watch a directory
its own chat has never been in.
it NAVIGATES rather than creating on the spot. a commander with no first turn has
no transcript, and the grid filters those rows out — so "create it silently"
makes something the person cannot then find. the setup screen is also where the
first turn happens, which is where the job actually gets settled. the button
carries the context there, pre-fills the name, and leaves one button to press.
the reference is shown as what it WATCHES rather than as a path, because there is
no path to show.
caught by testing rather than by reading: `resolveChatContextDir` reads the query
string, and command-hq takes its context in a JSON body — so the first version
resolved every commander to the owner's home. that failure is invisible, since a
commander watching the wrong directory just answers about the wrong thing
forever. the value-taking `chatContextDir` is what command-hq calls.
verified live: context 'email' resolves to email_accounts, plugin/proxy-manager
to its own chats directory.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
889 lines
44 KiB
TypeScript
889 lines
44 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 { chatContextDir } from '../chat/chat-context';
|
|
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);
|
|
type Body = {
|
|
name?: string;
|
|
referencePath?: string;
|
|
categoryId?: number | null;
|
|
/** A chat context to watch, instead of a path — see below. */
|
|
context?: string;
|
|
contextId?: string;
|
|
};
|
|
const body = await ctx.req.json<Body>().catch(() => ({}) as Body);
|
|
const name = body.name?.trim();
|
|
if (!name) throw errors.BAD_REQUEST('name is required');
|
|
|
|
/*
|
|
A commander started FROM a chat watches the directory that chat runs in.
|
|
|
|
The client sends the context it already has — `{context:'email', contextId:'me@…'}` — and never a
|
|
path, because it cannot know one: a plugin's chats live under `$DATA_PATH/<email>/plugins/<app>/chats`
|
|
and an email chat beside its own `emails.db`, both built server-side and deliberately not sent to the
|
|
browser. `resolveChatContextDir` is the same function the chat router uses, so a commander cannot end
|
|
up watching a directory its own chat has never been in.
|
|
|
|
This is also what makes the feature reachable by somebody who is not a super user. Pointing a
|
|
commander at a mailbox previously meant knowing its path and typing it, which meant a file browser
|
|
and an account allowed to walk the disk. The button asks for neither.
|
|
*/
|
|
const referencePath = body.context ? chatContextDir(body.context, body.contextId, who) : body.referencePath?.trim();
|
|
if (!referencePath) throw errors.BAD_REQUEST('referencePath or context 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 });
|
|
});
|