diff --git a/src/servers/api/command-hq/commander-block.ts b/src/servers/api/command-hq/commander-block.ts new file mode 100644 index 00000000..022ffefb --- /dev/null +++ b/src/servers/api/command-hq/commander-block.ts @@ -0,0 +1,84 @@ +import { basename, dirname } from 'node:path'; +import { isCommandersDir } from '../chat/claude-sessions'; +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 = { + /** 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//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; +} + +/** + * 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. In + * practice the first turn is the conversation about what to watch, and the loop is started on a later one, + * by which point every turn carries it. Worth knowing rather than discovering: a commander that somehow + * armed itself on its very first message would not have been told where to record. + * + * 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; + + 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.', + ].join('\n'); +} diff --git a/src/servers/sidecar/claude/claude-manager.ts b/src/servers/sidecar/claude/claude-manager.ts index 1bf2126c..37c97ece 100644 --- a/src/servers/sidecar/claude/claude-manager.ts +++ b/src/servers/sidecar/claude/claude-manager.ts @@ -6,6 +6,7 @@ import { query, type Query, type SpawnOptions, type SpawnedProcess } from '@anth import type { ChatEvent, PromptImage } from '../../api/chat/types'; import { affordanceBlock } from '../../affordances/load'; import { skillsBlock } from '../../skills/load'; +import { commanderBlock } from '../../api/command-hq/commander-block'; import type { ClaudeSpawnParams, ClaudeSpawnStreamingParams, ClaudeCodeResult, LiveClaudeSession } from '../protocol'; import { setClaudeSession, clearClaudeSession, getClaudeSession } from './state'; import { countsAsOutput, diagnoseExit, stderrReason } from './exit-diagnosis'; @@ -608,7 +609,18 @@ function createSession( // the inverse of an affordance. Read here rather than at module load for the same reason: a skill // written an hour ago should be offered on the next session. const skills = skillsBlock(); - const preamble = [affordances, skills].filter(Boolean).join('\n\n') || null; + /* + And, for a commander, the two paths only this session can be told. + + Not in the skill, which is read on demand and cannot know them; not only in the background-job dialog's + preamble, which is where this used to live and which an agent arming itself never sees. See + `api/command-hq/commander-block.ts`. + */ + const commander = commanderBlock({ + sessionId: resumeId ?? null, + cwd: params.cwd ?? resumeCwd ?? null, + }); + const preamble = [affordances, skills, commander].filter(Boolean).join('\n\n') || null; const q = query({ prompt: input.gen as AsyncIterable,