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:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user