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 -37
View File
@@ -1,5 +1,4 @@
import { basename, dirname } from 'node:path';
import { isCommandersDir } from '../chat/claude-sessions';
import { getCommandHqSession, getUserById } from 'officerdb';
import { loopPidPath, spawnedRecordPath } from './spawned';
/*
@@ -27,46 +26,26 @@ import { loopPidPath, spawnedRecordPath } from './spawned';
*/
type CommanderBlockParams = {
/** The Officer account, for the row lookup and for the paths. */
userId: number;
/** Claude's transcript id — what a commander is keyed on everywhere. */
sessionId: string | null;
/** The session's working directory, which is what makes it a commander at all — and names the account. */
cwd: string | null;
};
/**
* The account this cwd belongs to, or null if the cwd is not a commanders directory.
*
* Derived from the path rather than taken as an argument, because the caller does not have it: the sidecar
* knows a member's osUser and home, and the owner's turn carries no email at all. The cwd is
* `DATA_PATH/<email>/commanders`, so the email is right there — and it is verified by reconstructing the
* directory from it, so a coincidentally-shaped path cannot pass.
*/
function accountFromCommandersCwd(cwd: string): string | null {
const email = basename(dirname(cwd));
if (!email) return null;
return isCommandersDir(email, cwd) ? email : null;
}
export async function commanderBlock(params: CommanderBlockParams): Promise<string | null> {
if (!params.sessionId) return null;
/**
* The block, or null when this session is not a commander.
*
* **A brand-new commander's FIRST turn gets no block**, because there is no transcript id yet — the harness
* mints it as that turn begins, and nothing can be told a path keyed on an id that does not exist.
*
* That is not a gap, because arming on the first turn is not permitted: the first turn is for agreeing what
* to watch, and a watcher armed before that agreement is a guess. So the two facts interlock rather than
* conflict — an agent with no paths is by definition on its first turn, which is the turn it must not arm on
* anyway. The block says so explicitly, so the absence reads as a signal rather than as something missing.
*
* Keyed on the cwd rather than a database lookup, and deliberately: `isCommandersDir` is the same test the
* sessions list, `/chat/live` and the detail route all use, so a session cannot be a commander in one place
* and not in another. It also works before the row exists — a brand-new commander's first turn is the one
* that starts its loop, and a lookup would come back empty exactly then.
*/
export function commanderBlock(params: CommanderBlockParams): string | null {
if (!params.cwd || !params.sessionId) return null;
const email = accountFromCommandersCwd(params.cwd);
if (!email) return null;
// The ROW decides, not the working directory. A commander chooses its own cwd, so the directory answers
// nothing — and a session in the old shared directory is not a commander unless it was filed as one.
const row = await getCommandHqSession(params.userId, params.sessionId).catch(() => null);
if (!row) return null;
// The paths need the account's email and the row carries a user id, so this is a second read. Once per
// session spawn, alongside the affordances and the skills index, which are also read here rather than
// cached — the same trade for the same reason.
const user = await getUserById(params.userId).catch(() => null);
if (!user?.email) return null;
const email = user.email;
return [
'# You are a commander',