From 3837af657d2a9dd82e96d294889a221489ca30f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 22 Aug 2026 22:11:12 +0000 Subject: [PATCH] live sessions default to agents only, and you ask by name for a commander MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `?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 --- CLAUDE.md | 2 +- docs/chat-sessions-list.md | 19 ++++++++++++ src/servers/api/chat/chat.ts | 34 ++++++++++++--------- src/workspaces/state/src/useLiveSessions.ts | 21 +++++++++++-- 4 files changed, 58 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b16cdad8..26f55c16 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. diff --git a/docs/chat-sessions-list.md b/docs/chat-sessions-list.md index a9edd926..a436a23f 100644 --- a/docs/chat-sessions-list.md +++ b/docs/chat-sessions-list.md @@ -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 diff --git a/src/servers/api/chat/chat.ts b/src/servers/api/chat/chat.ts index 50f1b770..f7d085a4 100644 --- a/src/servers/api/chat/chat.ts +++ b/src/servers/api/chat/chat.ts @@ -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 }); }); diff --git a/src/workspaces/state/src/useLiveSessions.ts b/src/workspaces/state/src/useLiveSessions.ts index 2cd93c76..234f33cf 100644 --- a/src/workspaces/state/src/useLiveSessions.ts +++ b/src/workspaces/state/src/useLiveSessions.ts @@ -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({ queryKey: [LIVE_KEY], - queryFn: () => client.get<{ sessions: LiveSession[] }>('/chat/live'), + queryFn: () => client.get('/chat/live?kind=all'), enabled: isAuthenticated, refetchInterval: 10_000, staleTime: 5_000,