a commander is identified by its row, not by where it lives

Groundwork for choosing a commander's working directory. Identity was the CWD — every commander shared one
directory, so the directory was the answer and it needed no query. The moment a commander can choose its own,
that test is wrong in BOTH directions: a commander in the platform checkout reads as an ordinary chat, and an
ordinary chat in the old shared directory reads as a commander.

So identity is now the `command_hq_sessions` row, which is the only thing that survives the cwd being free.
The isolation the shared directory provided is no longer carrying anything — the surfaces that needed it now
filter explicitly, which is what made this possible.

Four surfaces moved, and the shape of the change is the same in each: the transcript scanner stops inferring
it and the ROUTE decorates from the rows.

- `listClaudeSessions` says nothing about commanders any more. It is synchronous over a directory scan and
  identity now needs a database read, so `withCommanderFlag` applies it at the caller.
- the sessions list, `/chat/live` and `GET /chat/sessions/:id` each read `listCommandHqSessionIds` once per
  request. A set membership test per row rather than a lookup per row: commanders are few, and the
  alternative turns one list into fifty queries.
- `commanderBlock` resolves the row instead of parsing the account out of the path. It needs the email for
  the record paths, so that is a second read — once per session spawn, beside the affordances and the skills
  index, which are read there for the same reason.

`commanderBlock` had to be hoisted out of `createSession`, which is synchronous, into `spawnClaudeStreaming`,
which is async and already holds the account. Passing it in as a parameter rather than making the whole
session constructor async.

`isCommandersDir` survives but is documented as no longer being the identity test — it remains only for the
places that legitimately care about that specific path: creating it for a new OS account, and as the
historical home of commanders created before this.

A commander mid-first-turn has no row and so reports false for one turn. That is the same turn it must not
arm anything on, and it corrects itself the moment it is filed.

Verified against real data: a filed commander renders the block, an unknown id and a null id both return
null, and the live gate still answers 401.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 18:07:00 +00:00
co-authored by Claude Opus 5
parent de9d229cfa
commit 75affec091
4 changed files with 79 additions and 59 deletions
+18 -10
View File
@@ -19,6 +19,7 @@ import {
listClaudePwds,
listClaudeSessions,
listAllClaudeSessions,
withCommanderFlag,
loadClaudeSession,
loadClaudeSessionById,
deleteClaudeSession,
@@ -27,7 +28,6 @@ import {
stopBackgroundTask,
claudeSessionContext,
liveSessionTitle,
isCommandersDir,
} from './claude-sessions';
import {
listOpenCodeSessions,
@@ -129,10 +129,15 @@ chatRouter.get('/sessions', async (ctx) => {
// the row renderer) is indifferent to which directories the rows came from.
const all = ctx.req.query('all') === '1';
const claude = (all ? listAllClaudeSessions(who) : listClaudeSessions(who, cwd)).map((s) => ({
...s,
harness: 'claude' as const,
}));
// 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,
}),
);
// 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.
//
@@ -266,7 +271,7 @@ chatRouter.get('/sessions/:id', async (ctx) => {
commander wherever it is rendered, and `detail.cwd` is the transcript's own directory, so this answer
travels with the conversation.
*/
isCommander: isCommandersDir(who.email, detail.cwd),
isCommander: (await listCommandHqSessionIds(user.id)).has(id),
});
});
@@ -292,6 +297,7 @@ chatRouter.get('/live', async (ctx) => {
sidecar.listLiveClaudeSessions(user.id),
who.isOwner ? sidecar.listLiveOpenCodeSessions() : Promise.resolve([]),
]);
const commanderIds = await listCommandHqSessionIds(user.id);
const sessions = live.map((session) => {
// Resolve by Claude's id, never by the session key — the key is officer's handle and the transcript
// is named after Claude's. Null until the first turn reports one, which is a conversation that has
@@ -304,14 +310,16 @@ chatRouter.get('/live', async (ctx) => {
harness: 'claude' as const,
title: resolved?.title ?? null,
cwd,
// Derived from the cwd rather than looked up in `command_hq_sessions`, for the same reason the
// history listing does it: every commander shares one directory, so the directory IS the answer and
// it needs no query. It also stays right for a commander mid-first-turn, before any row exists.
// From the `command_hq_sessions` rows, because a commander chooses its own working directory and the
// directory therefore answers nothing. One query for the whole set, before the map below.
//
// A commander mid-first-turn has no row yet and so reports false here for one turn. That is the same
// turn on which it must not arm anything, and it corrects itself the moment it is filed.
//
// This field is why the route changed at all. The iPhone app swipes between LIVE chats and has no
// room for tabs, so it can only exclude commanders if the payload says which ones they are — and a
// deploy commander turning up in that swipe order is exactly what it must not do.
isCommander: cwd ? isCommandersDir(who.email, cwd) : false,
isCommander: transcriptId ? commanderIds.has(transcriptId) : false,
};
});