live sessions default to agents only, and you ask by name for a commander

`?kind=` was opt-in, so a client that said nothing got everything — which is the
wrong default for the one caller that cannot cope. The phone swipes between live
chats one at a time, has no room for tabs and no way to skip a row, so a commander
in that list is a commander you are stuck on. Defaulting to `agent` means
forgetting the parameter can only ever under-report.

The two callers that want both now say `kind=all` explicitly, and that is a fair
price: they are few, they are on screens built to show both kinds, and they are
the ones positioned to notice when the answer is wrong. `counts` still describes
every live session whatever the filter, so a filtered caller renders both tab
numbers from one request rather than two.

Also corrects useLiveSessions' comment, which said `isCommander` was derived from
the cwd. It stopped being true when a commander gained the right to choose its own
working directory — it comes from the `command_hq_sessions` row, and the directory
answers nothing.

Unrelated but caught by docs:check on the way past: the registry gained a
nineteenth entry (minesweeper, execution) and CLAUDE.md still said eighteen, which
also left the generated platform skill two lines stale. Both corrected; the check
is green again. The minesweeper feature itself is half-landed and not mine —
`rfb.ts` fails typecheck and its registry test fails on a websocket that is not
served.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 22:11:12 +00:00
co-authored by Claude Opus 5
parent 749c80c877
commit 3837af657d
4 changed files with 58 additions and 18 deletions
+1 -1
View File
@@ -207,7 +207,7 @@ ahead of everything, and it re-verifies the token itself so it covers routes tha
- `permissions/registry.ts` — the single enumeration of what the platform can do, in five kinds:
`core` (every account, not deniable), `app` (**the grantable surface**), `confined` (grantable, but
only to an account that has a Linux user), `execution` and `admin` (owner only, and `execution` is
never grantable at any level). **18 entries as of 2026-08-22** — verified by `bun run docs:check`, which fails if this number drifts again: 5 core, 3 app, 3 confined, 3 execution, 4 admin. It said 27 for nine days.
never grantable at any level). **19 entries as of 2026-08-22** — verified by `bun run docs:check`, which fails if this number drifts again: 5 core, 3 app, 3 confined, 4 execution, 4 admin. It said 27 for nine days.
- `permissions/authorize.ts` — resolves "may this account do this". Owner short-circuits first; every
other answer is role grants plus core, with `execution`/`admin` stripped even if a row grants them,
and `confined` stripped for an account with no `osUser`.
+19
View File
@@ -57,6 +57,25 @@ so the walk exists — this is a second consumer of it, not a new class of work.
live count needs no new endpoint. Correlate `live.claudeSessionId === session.id`; a live row whose id is
still null contributes to no directory (see above).
**`/chat/live` returns only agents unless asked otherwise.** `?kind=` takes `agent` (the default),
`commander`, or `all`; anything else is a 400 rather than a silent fallback, and the plural forms are
accepted because the web client's own type is already `'agents' | 'commanders'`.
The default is the point. Commanders run background work and the phone swipes between live chats one at a
time, with no room for tabs and no way to skip a row — so a client that forgets the parameter must not be
handed a deploy commander. Defaulting to `agent` means forgetting can only ever under-report. The two
callers that genuinely want both say `kind=all`, which is a fair price: they are few, they are on screens
built to show both, and they are the ones positioned to notice if the answer is wrong.
`counts` describes **every** live session regardless of the filter, so a filtered caller still renders
`Agents (n) / Commanders (n)` without a second request. That is what stops a server-side filter from
costing more round trips than the client-side one it replaced.
Which rows are commanders comes from `command_hq_sessions`, **not** from the working directory — a
commander chooses its own cwd, so the directory answers nothing. `GET /chat/sessions` reports no
`isCommander` at all, because it removes commanders entirely; a list that excludes them has nothing to say
about which of its rows is one.
---
## Landmines
+20 -14
View File
@@ -369,31 +369,37 @@ chatRouter.get('/live', async (ctx) => {
const rows = [...sessions, ...openCodeSessions];
/*
`?kind=agent|commander` — the split, done here rather than by each client.
`?kind=` — 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.
That is fine for the web list, which has both tabs on screen, and wrong for a phone that swipes between
live chats one at a time 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.
**The default is `agent`, not "everything".** A caller that says nothing gets ordinary chats, so the way
to receive a commander is to ask for one by name. That is the whole point: forgetting the parameter can
only ever under-report, never put a deploy commander into a swipe order that has no way to skip it. It
costs the two callers that DO want both an explicit `kind=all`, which is the right trade — they are few,
they are on screens built for it, and they are the ones in a position to notice if it is wrong.
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.
Counts always describe the WHOLE set, whatever the filter. 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 ends up costing more than the client-side one it replaced.
An unrecognised `kind` is a 400, never a silent fallback. Plural forms are accepted because the client's
own type is already `'agents' | 'commanders'`, and the failure silence would buy is precise: a mistyped
`?kind=agents` resolving to something other than agents is the exact bug this exists to prevent.
*/
const kind = ctx.req.query('kind');
const kind = ctx.req.query('kind') ?? 'agent';
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);
if (!['agent', 'agents', 'commander', 'commanders', 'all'].includes(kind)) {
return ctx.json({ error: `Unknown kind '${kind}' — expected 'agent', 'commander' or 'all'` }, 400);
}
const wantCommanders = kind === 'commander' || kind === 'commanders';
const shown = kind === undefined ? rows : rows.filter((s) => s.isCommander === wantCommanders);
const shown = kind === 'all' ? rows : rows.filter((s) => s.isCommander === wantCommanders);
return ctx.json({ sessions: shown, counts });
});
+18 -3
View File
@@ -27,7 +27,8 @@ export type LiveSession = {
title: string | null;
cwd: string | null;
/**
* A commander rather than an ordinary chat, derived server-side from the cwd.
* A commander rather than an ordinary chat, derived server-side from its `command_hq_sessions` row.
* NOT from the cwd — a commander chooses its own working directory, so the directory answers nothing.
*
* Every consumer of this list needs it, and one of them cannot work around its absence. The sessions
* panel splits Live into two tabs; the iPhone app swipes between live chats, has no tabs, and so can only
@@ -36,6 +37,12 @@ export type LiveSession = {
isCommander: boolean;
};
/**
* Counts describe every live session, not just the ones returned — so a filtered request still renders
* `Agents (n) / Commanders (n)` without a second round trip.
*/
type LivePayload = { sessions: LiveSession[]; counts: { agents: number; commanders: number } };
const LIVE_KEY = 'CHAT_LIVE_SESSIONS';
/**
@@ -50,9 +57,17 @@ export function useLiveSessions() {
const client = useClient();
const { isAuthenticated } = useAuth();
const { data, isLoading, error, refetch } = useQuery<{ sessions: LiveSession[] }>({
/*
`kind=all` is required, not a default. `/chat/live` returns ONLY agents unless asked otherwise, so that
a caller which forgets can never be handed a commander — a phone swiping between live chats one at a
time has no way to skip one. This list is the other case: it renders both kinds as tabs, so it asks for
both, explicitly, and the explicitness is what makes it reviewable.
`counts` describes the whole set either way, which is why both tab numbers are right from one request.
*/
const { data, isLoading, error, refetch } = useQuery<LivePayload>({
queryKey: [LIVE_KEY],
queryFn: () => client.get<{ sessions: LiveSession[] }>('/chat/live'),
queryFn: () => client.get<LivePayload>('/chat/live?kind=all'),
enabled: isAuthenticated,
refetchInterval: 10_000,
staleTime: 5_000,