the server says which live sessions are commanders, and can return just one kind

/chat/live already classified every row from `command_hq_sessions`; what it did
not do was filter, so each client repeated that itself. Fine for the web list,
which shows both tabs at once. Wrong for the iPhone app, which swipes between
live chats, has no room for tabs, and must simply never be handed a commander.

`?kind=agent|commander` does it server-side. Counts always describe the whole
set regardless of the filter — the web tabs show both numbers side by side and
both must be right whichever tab is open, so a filter that also hid the other
count would force a second request to render one screen. An unrecognised kind is
a 400 rather than "everything": a mistyped `?kind=agents` silently returning
commanders is the precise swipe-order bug this exists to prevent, so the plural
forms are accepted too, since the client's own type is already plural.

And /chat/sessions no longer tags rows it is about to delete. It called
listCommandHqSessionIds twice — once to mark commanders, once to remove exactly
those rows four lines later — so isCommander was false on every row that
survived. A field that could only ever lie, plus a second identical query per
request to produce it. A list that excludes commanders has nothing to say about
which of its rows is one; withCommanderFlag had no other caller and is gone.

Not restarted: the shared checkout currently fails typecheck on another agent's
untracked Minesweeper work, and a restart is when half-applied work ships.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 22:01:28 +00:00
co-authored by Claude Opus 5
parent af918251d3
commit 749c80c877
2 changed files with 41 additions and 23 deletions
+40 -11
View File
@@ -20,7 +20,6 @@ import {
listClaudePwds,
listClaudeSessions,
listAllClaudeSessions,
withCommanderFlag,
loadClaudeSession,
loadClaudeSessionById,
deleteClaudeSession,
@@ -130,15 +129,10 @@ chatRouter.get('/sessions', async (ctx) => {
// the row renderer) is indifferent to which directories the rows came from.
const all = ctx.req.query('all') === '1';
// Which of these are commanders comes from the ROWS, not from where the transcript lives — a commander
// chooses its own working directory now, so the directory answers nothing. One query for the whole set.
const commanderIds = await listCommandHqSessionIds(user.id);
const claude = withCommanderFlag(all ? listAllClaudeSessions(who) : listClaudeSessions(who, cwd), commanderIds).map(
(s) => ({
...s,
harness: 'claude' as const,
}),
);
const claude = (all ? listAllClaudeSessions(who) : listClaudeSessions(who, cwd)).map((s) => ({
...s,
harness: 'claude' as const,
}));
// OpenCode's store is shared and unscoped, so for anyone but the owner this list is other people's
// conversations. Empty rather than filtered: there is no per-user field to filter ON.
//
@@ -157,6 +151,12 @@ chatRouter.get('/sessions', async (ctx) => {
Never fatal — a database hiccup on a feature /chat does not otherwise depend on must not take the whole
conversation list down with it, the same posture `getChatSessionModels` below already takes.
This route does NOT report `isCommander` on its rows, and that is not an omission. It used to: rows were
tagged from this same set a few lines above and then removed by it here, so the flag was false on every
row that survived — a field that could only ever lie, plus a second identical query to produce it. A list
that excludes commanders has nothing to say about which of its rows is one. `/chat/live` is where the
flag is real, because that route keeps both kinds.
*/
const filed = await listCommandHqSessionIds(user.id).catch(() => new Set<string>());
const sessions = merged.filter((s) => !filed.has(s.id));
@@ -366,7 +366,36 @@ chatRouter.get('/live', async (ctx) => {
};
});
return ctx.json({ sessions: [...sessions, ...openCodeSessions] });
const rows = [...sessions, ...openCodeSessions];
/*
`?kind=agent|commander` — the split, done here rather than by each client.
The classification was already server-side; what was not was the FILTER, so every client repeated it.
That is fine for the web list, which has both tabs on screen, and wrong for the iPhone app, which swipes
between live chats, has no room for tabs, and must simply never be handed a commander.
Counts always describe the WHOLE set, filtered or not. The web tabs show `Agents (n) / Commanders (n)`
side by side and both numbers have to be right whichever tab is open — a filter that also hid the other
count would force a second request to render one screen, which is how a server-side filter usually ends
up costing more than the client-side one it replaced.
An unrecognised `kind` is a 400, never "everything". The plural forms are accepted because the client's
own type is already `'agents' | 'commanders'`, and the failure that silence would buy is precise: a
mistyped `?kind=agents` returning commanders is exactly the swipe-order bug this exists to prevent.
*/
const kind = ctx.req.query('kind');
const counts = {
agents: rows.filter((s) => !s.isCommander).length,
commanders: rows.filter((s) => s.isCommander).length,
};
if (kind !== undefined && !['agent', 'agents', 'commander', 'commanders'].includes(kind)) {
return ctx.json({ error: `Unknown kind '${kind}' — expected 'agent' or 'commander'` }, 400);
}
const wantCommanders = kind === 'commander' || kind === 'commanders';
const shown = kind === undefined ? rows : rows.filter((s) => s.isCommander === wantCommanders);
return ctx.json({ sessions: shown, counts });
});
// DELETE /chat/sessions/:id[?cwd=] — remove a conversation from the owning harness's store. For a
+1 -12
View File
@@ -997,7 +997,7 @@ function scanGroup(who: ChatIdentity, cwd: string): TranscriptSummary[] {
*
* Identity is now the `command_hq_sessions` row, which is the only thing that survives the cwd being free.
* It is applied by the CALLER, because that requires a database read and this function is synchronous over a
* directory scan — see `withCommanderFlag`.
* directory scan.
*/
export function listClaudeSessions(who: ChatIdentity, cwd: string): ClaudeSessionSummary[] {
return mergeChains(scanGroup(who, cwd))
@@ -1005,17 +1005,6 @@ export function listClaudeSessions(who: ChatIdentity, cwd: string): ClaudeSessio
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}
/**
* Mark which of these are commanders, from the rows rather than from where they live.
*
* One query per request rather than one per session: `listCommandHqSessionIds` returns the whole set for an
* account, and a set membership test is what the per-row decoration needs. Commanders are few, so the set is
* small; the alternative — a lookup per transcript — would turn one list into fifty queries.
*/
export function withCommanderFlag<T extends { id: string }>(rows: T[], commanderIds: ReadonlySet<string>): T[] {
return rows.map((row) => (commanderIds.has(row.id) ? { ...row, isCommander: true } : row));
}
/**
* Is this the default commanders directory?
*