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>
71 lines
3.8 KiB
TypeScript
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');
|
|
}
|