From aad382f4be01231873c5d2b86dac59dfed631ef7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 22 Aug 2026 21:19:28 +0000 Subject: [PATCH] a commander is told where to record itself on a TURN, because the preamble never reaches it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/servers/api/chat/websocket.ts | 41 +++++++++++++++++++- src/servers/sidecar/claude/claude-manager.ts | 27 +++++-------- 2 files changed, 49 insertions(+), 19 deletions(-) diff --git a/src/servers/api/chat/websocket.ts b/src/servers/api/chat/websocket.ts index bbe432f6..908fa75b 100644 --- a/src/servers/api/chat/websocket.ts +++ b/src/servers/api/chat/websocket.ts @@ -33,6 +33,8 @@ import { import { resolveHomeDir } from '@@/user-home'; import { renameClaudeSession, type ChatIdentity } from './claude-sessions'; import { chatIdentity } from './chat'; +import { commanderBlock } from '../command-hq/commander-block'; +import { getClaudeSession } from '../../sidecar/claude/state'; import { claudeLoginState } from '@@/os-user-claude'; import { mkdirSync } from 'node:fs'; import { logger } from './logger'; @@ -902,6 +904,14 @@ export type ClaudeTurnOutcome = { ok: true; transcriptId: string } | { ok: false * generic timeout: the reasons below (member not signed in, Claude not installed, spawn failure) are the * same reasons a websocket caller already sees via `sendToClient`, just mirrored to a second listener. */ +/** + * Sessions already told what they are and where to record it. + * + * In memory, keyed on officer's session id: a restart re-briefs, which costs a few hundred tokens on one + * turn and is the safe direction — a commander that was never told is one that cannot be stopped. + */ +const briefedSessions = new Set(); + export async function startClaudeTurn(params: { email: string; username: string; @@ -927,7 +937,36 @@ export async function startClaudeTurn(params: { effectivePrompt: string; onOutcome?: (outcome: ClaudeTurnOutcome) => void; }): Promise { - const { email, username, userId, ws, sessionId, model, msg, effectivePrompt, onOutcome } = params; + const { email, username, userId, ws, sessionId, model, msg, onOutcome } = params; + + /* + A commander is told its record paths ON A TURN, not in the system preamble. + + The preamble is built inside `createSession`, which runs ONCE — on a conversation's first turn. For a + commander that is precisely the turn where the block cannot exist: it has no `command_hq_sessions` row + and no transcript id yet, so `commanderBlock` returns null, and every later turn reuses the same session + and never rebuilds the preamble. So a commander never received the block at all, and its loop had no + paths to record itself at. Its own test caught it: it armed a perfectly good watcher and wrote no + `loop-pid`. + + Riding `effectivePrompt` instead. That is the string the model sees; `displayText` is what the transcript + shows — the same seam the background-job dialog already uses to send a hidden preamble — so this reaches + the agent without appearing in the conversation. + + Once per session, on the first turn where the block resolves, which is the first turn after the commander + is filed. It stays available afterwards because it is in the conversation's own history from then on. + */ + let effectivePrompt = params.effectivePrompt; + if (!briefedSessions.has(sessionId)) { + const block = await commanderBlock({ + userId, + sessionId: getClaudeSession(sessionId, userId) ?? msg.resumeSessionId ?? null, + }); + if (block) { + briefedSessions.add(sessionId); + effectivePrompt = `${block}\n\n---\n\n${effectivePrompt}`; + } + } const fail = (error: string) => { sendToClient(ws, { type: 'error', message: error }); onOutcome?.({ ok: false, error }); diff --git a/src/servers/sidecar/claude/claude-manager.ts b/src/servers/sidecar/claude/claude-manager.ts index 53d105be..5b1824c8 100644 --- a/src/servers/sidecar/claude/claude-manager.ts +++ b/src/servers/sidecar/claude/claude-manager.ts @@ -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, @@ -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); }