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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user