a commander is told where to record itself, however its loop gets started

The record instruction lived in one place: the preamble the background-job dialog composes. Fine while the
dialog was the only way to arm a commander, and wrong for the flow being built — the commander arming itself
from the conversation, with the owner never touching the launcher. An agent starting its own background job
never sees that preamble, so it would record nothing, and delete would quietly go back to leaving orphaned
loops.

So the paths are compiled into a commander's session preamble, beside the affordances and the skills index.

THE SPLIT, which is the part worth keeping: the METHOD lives in the Commanders skill — read on demand, as
long as it needs to be — and the PATHS are compiled in always, because they are a fact about this session
that the agent cannot derive. That is exactly the line between a skill and an always-loaded block, and it
falls in the same place here as everywhere else.

The account is derived from the CWD rather than passed in, because the caller does not have it: the sidecar
knows a member's osUser and home, and an owner's turn carries no email at all. A commander's cwd is
DATA_PATH/<email>/commanders, so the email is in the path — and it is verified by reconstructing the
directory from it, so a coincidentally-shaped path cannot pass.

Keyed on the cwd rather than a database lookup, for the same reason every other surface uses
isCommandersDir: a session must not be a commander in one place and not another. It also works before the
row exists, which a lookup would not — and the first turn is when the row does not exist yet.

Stated in the file rather than left to be found: a brand-new commander's FIRST turn gets no block, because
the transcript id does not exist until that turn begins. In practice the first turn is the conversation about
what to watch and the loop starts on a later one, but a commander that armed itself on its opening message
would not have been told where to record.

Verified on the three cases: a real commanders cwd renders the block with both paths, an ordinary chat gets
null, and a session with no id yet gets null.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 17:47:55 +00:00
co-authored by Claude Opus 5
parent 6d6e8a841e
commit 87ff6ed326
2 changed files with 97 additions and 1 deletions
@@ -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/<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;
}
/**
* 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');
}
+13 -1
View File
@@ -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<SdkUserMessage>,