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
+16 -5
View File
@@ -541,6 +541,11 @@ function createSession(
params: ClaudeSpawnStreamingParams,
onEvent: (event: ChatEvent) => void,
onNotify: (kind: 'turn-ended' | 'input-needed') => void,
/**
* The commander block, or null. Passed IN because building it needs two database reads and this function
* is synchronous — hoisted to `spawnClaudeStreaming`, which is async and already has the account.
*/
commander: string | null,
): PersistentSession {
const { sessionKey } = params;
const input = makeInputQueue();
@@ -616,10 +621,7 @@ function createSession(
preamble, which is where this used to live and which an agent arming itself never sees. See
`api/command-hq/commander-block.ts`.
*/
const commander = commanderBlock({
sessionId: resumeId ?? null,
cwd: params.cwd ?? resumeCwd ?? null,
});
// `commander` is a parameter, not computed here — see the note on it.
const preamble = [affordances, skills, commander].filter(Boolean).join('\n\n') || null;
const q = query({
@@ -892,7 +894,16 @@ export async function spawnClaudeStreaming(
await applyModelChange(session, params.model);
await applyPlanModeChange(session, params.planMode);
} else {
session = createSession(params, onEvent, onNotify);
/*
The commander block is built here, before the session exists, because it needs the database and
`createSession` is synchronous. It resolves the row for this transcript id and returns null for
anything that is not a commander — which is every ordinary chat, at the cost of one indexed lookup.
*/
const commander = await commanderBlock({
userId: params.userId,
sessionId: getClaudeSession(params.sessionKey, params.userId) ?? params.resumeSessionId ?? null,
});
session = createSession(params, onEvent, onNotify, commander);
}
session.pushTurn(params.prompt, params.images);
}