Command HQ shows what plugins offer, and what has not been started

the UI half. a row per plugin that ships commanders, below the owner's own
categories and separated from them — because they are not the same kind of thing.
a category is the owner's filing and they may rename it; a plugin is where
something came from and they may not. mixed into one list, "av-club" reads as a
folder somebody made, and the first thing you do with a folder you made is rename
it.

offers sit ABOVE the grid rather than in it. a commander in the grid runs, has a
transcript and has cost money; an offer is a description on disk. drawn as the same
card with a badge, the two look interchangeable, and the first consequence is
somebody hunting for the transcript of a thing that has never run.

an offer STAYS after it is used. it is a template and there is no reason to have
only one — two folders watched for two formats is two commanders from one offer,
and hiding it would make the second look impossible.

the form is drawn from the template's own `inputs`. nothing in the component knows
what av-club wants: options become buttons, boolean becomes yes/no, everything else
is a field. the button names what is missing rather than marking it red.

TWO REAL BUGS FOUND BY WIRING IT UP, both in the list route:

`?category=plugin:av-club` reached `Number('plugin:av-club')` — NaN — and the query
compared `category_id = NaN`. that matches nothing and looks exactly like "this
plugin has no commanders", which is the worst way for a filter to fail. parsed
before the numeric branch now.

and the grid DROPS every row without a transcript. that was right when the only way
to get a row was to start a conversation; it is wrong now, because a commander from
a template arrives deliberately not running — so the thing somebody just created was
invisible, under a toast saying it worked. a row with a `plugin` and no session is
now projected from the row itself, with the zeroes that are true of it, and sorted
FIRST: it is the one that needs a decision. ordinary rows keep the old behaviour,
since a hand-made row with no transcript is a conversation that failed to start and
the design refuses to draw a tombstone for it.

four locale keys, all six languages, parity test green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
2026-08-29 03:10:29 +01:00
co-authored by Claude Opus 5
parent 766b18b7f9
commit 347acf0171
15 changed files with 412 additions and 23 deletions
+63 -3
View File
@@ -203,6 +203,17 @@ type CommanderSummary = ClaudeSessionSummary & {
watcherLastFiredAt: Date | null;
/** The running total — every turn this commander has ever cost, summed. See `GET .../turns` for the raw series. */
cost: CommandHqCostSummary;
/** Where it came from, when a plugin's template made it. Null for one somebody wrote themselves. */
plugin?: string | null;
/** The owner's answers to that template's inputs, so the card can say what it is set to watch. */
pluginConfig?: Record<string, string> | null;
/**
* Created, never opened — so there is no transcript behind it and its id is not a session id.
*
* Only ever true for a plugin's commander. A hand-made row without a transcript is one whose
* conversation failed to start, and the design deliberately refuses to draw a tombstone for it.
*/
pending?: boolean;
};
async function isRunning(sessionId: string): Promise<boolean> {
@@ -224,7 +235,15 @@ 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);
/*
`plugin:<appName>` is a filter on provenance, not a category, and is parsed BEFORE the numeric branch.
Without this it reached `Number('plugin:av-club')` — NaN — and the query compared `category_id = NaN`,
which matches nothing and looks exactly like "this plugin has no commanders".
*/
const pluginFilter = categoryParam?.startsWith('plugin:') ? categoryParam.slice('plugin:'.length) : undefined;
const categoryId =
pluginFilter || 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
@@ -237,7 +256,7 @@ commandHqRouter.get('/commanders', async (ctx) => {
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 rows = await listCommandHqSessions(user.id, categoryId, pluginFilter);
const withTranscript = rows.filter((row) => row.sessionId !== null);
const summaries = withTranscript
.map((row) => loadClaudeSessionSummaryById(who, row.sessionId!))
@@ -260,6 +279,39 @@ commandHqRouter.get('/commanders', async (ctx) => {
),
]);
/*
A commander CREATED but never opened has no transcript, and every branch above drops it.
That was right when the only way to get a row was to start a conversation. It is wrong now: a
commander made from a plugin's template arrives deliberately not running, and dropping it means the
thing somebody just created is invisible — with a toast saying it worked. So a row with a `plugin`
and no session is projected from the ROW alone, with the zeroes that are actually true of it.
Ordinary commanders keep the old behaviour. A hand-made row with no transcript is a row whose
conversation failed to start, and a tombstone for it is what the design deliberately refused.
*/
const pending = rows
.filter((row) => row.sessionId === null && row.plugin)
.map(
(row) =>
({
id: `pending-${row.id}`,
title: row.name ?? 'Commander',
dirName: row.dirName,
categoryId: row.categoryId,
tagIds: [],
running: false,
watcherArmed: false,
watcherArmedAt: null,
watcherStoppedAt: null,
watcherLastFiredAt: null,
cost: { turnCount: 0, inputTokens: 0, outputTokens: 0, totalUsd: 0 },
plugin: row.plugin,
pluginConfig: row.pluginConfig,
pending: true,
}) as unknown as CommanderSummary,
);
const commanders: CommanderSummary[] = filed
.map((row) => ({
...liveById.get(row.sessionId!)!,
@@ -279,6 +331,10 @@ commandHqRouter.get('/commanders', async (ctx) => {
// 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.
// Provenance, so the UI can put a plugin's commanders in their own section rather than inferring
// it from a category name somebody can rename.
plugin: row.plugin,
pluginConfig: row.pluginConfig,
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).
@@ -290,7 +346,9 @@ commandHqRouter.get('/commanders', async (ctx) => {
}))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
return ctx.json({ commanders });
// Pending ones FIRST: a commander that was just created and needs a decision is the thing somebody came
// to the screen for, and sorting it by `updatedAt` among running ones would bury it on a busy account.
return ctx.json({ commanders: [...pending, ...commanders] });
});
/**
@@ -465,6 +523,8 @@ commandHqRouter.get('/commanders/:sessionId', async (ctx) => {
categoryId: row.categoryId,
tagIds: tagsByRow.get(row.id) ?? [],
running,
plugin: row.plugin,
pluginConfig: row.pluginConfig,
watcherArmed: row.watcherArmed || liveLoopPid(who.email, row.dirName) !== null,
watcherRunning: liveLoopPid(who.email, row.dirName) !== null,
watcherArmedAt: row.watcherArmedAt,