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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user