Files
platform/src/servers/api/command-hq/commander-block.ts
T
pastilhasandClaude Opus 5 75affec091 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>
2026-08-22 18:07:00 +00:00

71 lines
3.8 KiB
TypeScript

import { getCommandHqSession, getUserById } from 'officerdb';
import { loopPidPath, spawnedRecordPath } from './spawned';
/*
What a commander is told about ITSELF, once per session.
── Why this exists ──
A commander must record its own loop's pid and every agent that loop spawns, or it cannot be stopped and
cannot be fully deleted. The instruction for that used to live in ONE place: the preamble the
background-job dialog composes. Which was fine while the only way to arm a commander was through that
dialog, and wrong the moment a commander arms itself from the conversation — the intended flow. An agent
starting its own background job never sees that preamble, so it would record nothing, and delete would go
back to leaving orphaned loops without anybody noticing it had.
── Why the paths come from here and the method comes from the skill ──
The split is not arbitrary. The METHOD — how to write the record, when, what to append — is reference
material that belongs with everything else about commanders, in the skill, where it can be as long as it
needs to be and is read only when relevant. The PATHS are a fact about THIS session that nothing else can
supply: they are rooted in DATA_PATH and keyed on the commander's own transcript id, and an agent knows
neither. So they are compiled in, always, like an affordance — because a fact the agent cannot derive is
exactly what an always-loaded block is for.
Short for the same reason every affordance is short: it is charged to every turn of every commander.
*/
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;
};
export async function commanderBlock(params: CommanderBlockParams): Promise<string | null> {
if (!params.sessionId) 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',
'',
'This conversation is a commander: an agent that keeps working when nobody is watching. Open the',
'**Commanders** skill before starting or changing a watcher — it carries how the pieces fit, what to',
'establish with the owner first, and the mistakes that are expensive.',
'',
'Two paths only this session can be told. **Write to them or you cannot be stopped:**',
'',
`- your loop's own pid: \`${loopPidPath(email, params.sessionId)}\``,
`- one session id per line, for every agent your loop spawns: \`${spawnedRecordPath(email, params.sessionId)}\``,
'',
'The platform cannot find your process and cannot recover a spawned agent id after it exits, so this',
'record is the only one. Without it, deleting this commander leaves your loop running.',
'',
'**Never arm a watcher on your first turn.** The first turn is for settling what to watch and what to do',
'about it — arming before that is arming a guess, and a wrong condition either never fires or fires',
'constantly and is found out days later. If you were given no paths above, you ARE on your first turn:',
'that is what their absence means. Ask, and arm once it is agreed.',
].join('\n');
}