a commander is told where to record itself on a TURN, because the preamble never reaches it

Its own first test caught this: the commander armed a perfectly good watcher, established a baseline
correctly, and wrote no loop-pid — so deleting it would have left the loop running.

The block was composed into the system preamble, which `createSession` builds ONCE, on a conversation's
first turn. For a commander that is exactly the turn where the block cannot exist: no command_hq_sessions
row, no transcript id, so `commanderBlock` returns null. Every later turn reuses the session and never
rebuilds the preamble. So a commander never received it at all — the "first turn gets no block" limitation I
closed with a rule was really "no turn ever gets it".

It rides `effectivePrompt` now: the string the model sees, as against `displayText` which the transcript
shows. Same seam the background-job dialog already uses for its hidden preamble, so nothing new — the agent
gets the paths without them appearing in the conversation. Once per session, on the first turn where the
block resolves, and it stays in the conversation's history from then on.

`briefedSessions` is in memory, so a restart re-briefs. That is the safe direction: a few hundred tokens on
one turn against a commander that was never told and therefore cannot be stopped.

The preamble path is removed rather than left dormant — a dead branch that reads as the mechanism is how this
went unnoticed in the first place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 21:19:28 +00:00
co-authored by Claude Opus 5
parent fc8ce04f2b
commit aad382f4be
2 changed files with 49 additions and 19 deletions
+9 -18
View File
@@ -6,7 +6,6 @@ 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';
@@ -541,11 +540,6 @@ function createSession(
params: ClaudeSpawnStreamingParams,
onEvent: (event: ChatEvent) => void,
onNotify: (kind: 'turn-ended' | 'input-needed') => void,
/**
* The commander block, or null. Passed IN because building it needs two database reads and this function
* is synchronous — hoisted to `spawnClaudeStreaming`, which is async and already has the account.
*/
commander: string | null,
): PersistentSession {
const { sessionKey } = params;
const input = makeInputQueue();
@@ -621,8 +615,14 @@ function createSession(
preamble, which is where this used to live and which an agent arming itself never sees. See
`api/command-hq/commander-block.ts`.
*/
// `commander` is a parameter, not computed here — see the note on it.
const preamble = [affordances, skills, commander].filter(Boolean).join('\n\n') || null;
/*
No commander block here, deliberately.
It used to be composed in, and could never arrive: this runs once per session, and a commander's first
turn has no row and no transcript id, so the block resolved to null and no later turn rebuilt the
preamble. It rides `effectivePrompt` instead — see `startClaudeTurn` in api/chat/websocket.ts.
*/
const preamble = [affordances, skills].filter(Boolean).join('\n\n') || null;
const q = query({
prompt: input.gen as AsyncIterable<SdkUserMessage>,
@@ -894,16 +894,7 @@ export async function spawnClaudeStreaming(
await applyModelChange(session, params.model);
await applyPlanModeChange(session, params.planMode);
} else {
/*
The commander block is built here, before the session exists, because it needs the database and
`createSession` is synchronous. It resolves the row for this transcript id and returns null for
anything that is not a commander — which is every ordinary chat, at the cost of one indexed lookup.
*/
const commander = await commanderBlock({
userId: params.userId,
sessionId: getClaudeSession(params.sessionKey, params.userId) ?? params.resumeSessionId ?? null,
});
session = createSession(params, onEvent, onNotify, commander);
session = createSession(params, onEvent, onNotify);
}
session.pushTurn(params.prompt, params.images);
}