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; /** * The owner pressed Start rather than typing an opening message. * * They supplied a name and a working directory and nothing else, so there is no request to act on and * the commander has to run the interview itself. Only ever true on the turn the button sends — a * commander asked something later is an ordinary conversation and must not restart its intake. */ selfStarted?: boolean; }; export async function commanderBlock(params: CommanderBlockParams): Promise { 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.', /* The button case. A pointer, not the interview itself: the questions live in the skill, which is re-read from disk on every run, so rewording one costs nothing. The same words here would cost a `pm2 restart officer` every time — and this file is the wrong home for doctrine that will be edited far more often than the mechanism around it. */ ...(params.selfStarted ? [ '', '**Nobody has told you what they want yet.** They gave a name and a working directory and', 'pressed Start, which is a request to run the interview — not a request to do anything else.', 'Read the **Commanders** skill now and follow "When you started yourself": orient in this', 'directory first, then open with what you found and ONE question.', ] : []), ].join('\n'); }