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
+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>,