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); }