From 2aef23c8ba0fee37a78701612128a194194b8096 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 22 Aug 2026 23:04:25 +0000 Subject: [PATCH] a commander starts its own conversation, instead of being handed one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Starting a commander meant writing a good opening prompt, which is the hard part of the whole thing — so in practice somebody else wrote it and pasted it in. That is not a flow, it is a person acting as the intake form. Now the two decisions that cannot be guessed stay with the owner (a name and a working directory) and the rest is a button. It sends a brief the MODEL reads — "I have not told you what I want yet, orient yourself and ask me one question" — while the transcript shows "Set me up.", which is what a person would have typed. That split is `effectivePrompt` vs `displayText` and the chat path already had it. What to ask, in what order, and to read the directory BEFORE asking anything, all live in the Commanders skill under "When you started yourself". Deliberately not here: capabilities are re-read from disk every run, so rewording a question costs nothing, while the same words in commander-block.ts cost a restart each time. The block carries a pointer and no doctrine. Orienting first is what makes this work on a real request. Told "automate the platform deploys", a commander that has read the directory finds `deploy/`, the previous releases and the deploy skill, and asks whether that is still how you want it — instead of inventing a scheme next to the one already there. `sendPrompt` gained an options object rather than a tenth positional parameter. Nine is already past where a call site reads as a row of undefineds. The composer stays enabled beside the button: typing your own opener still works, it just is not the only way in any more. Co-Authored-By: Claude Opus 5 --- src/servers/api/chat/websocket.ts | 21 +++++++ src/servers/api/command-hq/commander-block.ts | 23 +++++++ .../src/apps/CommandHQ/CommandHqDetail.tsx | 60 +++++++++++++++++++ .../officerdev/src/hooks/useChat.ts | 15 +++++ 4 files changed, 119 insertions(+) diff --git a/src/servers/api/chat/websocket.ts b/src/servers/api/chat/websocket.ts index 7c24224d..c5dfde9b 100644 --- a/src/servers/api/chat/websocket.ts +++ b/src/servers/api/chat/websocket.ts @@ -788,6 +788,11 @@ async function handleChat( msg: { prompt: string; displayText?: string; + /** + * The owner pressed Start instead of typing an opener — see `commanderBlock`. Carried on the message + * rather than derived, because the only thing that can know it is the client that rendered the button. + */ + selfStarted?: boolean; sessionId?: string; model?: string; cwd?: string; @@ -941,6 +946,11 @@ export async function startClaudeTurn(params: { msg: { prompt: string; displayText?: string; + /** + * The owner pressed Start instead of typing an opener — see `commanderBlock`. Carried on the message + * rather than derived, because the only thing that can know it is the client that rendered the button. + */ + selfStarted?: boolean; groupSlug?: string; context?: string; contextId?: string; @@ -980,6 +990,7 @@ export async function startClaudeTurn(params: { const block = await commanderBlock({ userId, sessionId: transcriptIdBySessionKey.get(sessionId) ?? msg.resumeSessionId ?? null, + selfStarted: msg.selfStarted === true, }); if (block) { briefedSessions.add(sessionId); @@ -1147,6 +1158,11 @@ async function handleClaudeCodeChat( msg: { prompt: string; displayText?: string; + /** + * The owner pressed Start instead of typing an opener — see `commanderBlock`. Carried on the message + * rather than derived, because the only thing that can know it is the client that rendered the button. + */ + selfStarted?: boolean; groupSlug?: string; context?: string; contextId?: string; @@ -1170,6 +1186,11 @@ async function handleOpenCodeChat( msg: { prompt: string; displayText?: string; + /** + * The owner pressed Start instead of typing an opener — see `commanderBlock`. Carried on the message + * rather than derived, because the only thing that can know it is the client that rendered the button. + */ + selfStarted?: boolean; groupSlug?: string; context?: string; contextId?: string; diff --git a/src/servers/api/command-hq/commander-block.ts b/src/servers/api/command-hq/commander-block.ts index fc4eb04a..403342cc 100644 --- a/src/servers/api/command-hq/commander-block.ts +++ b/src/servers/api/command-hq/commander-block.ts @@ -30,6 +30,14 @@ type CommanderBlockParams = { userId: number; /** Claude's transcript id — what a commander is keyed on everywhere. */ sessionId: string | null; + /** + * The owner pressed Start rather than typing an opening message. + * + * They supplied a name and a working directory and nothing else, so there is no request to act on and + * the commander has to run the interview itself. Only ever true on the turn the button sends — a + * commander asked something later is an ordinary conversation and must not restart its intake. + */ + selfStarted?: boolean; }; export async function commanderBlock(params: CommanderBlockParams): Promise { @@ -66,5 +74,20 @@ export async function commanderBlock(params: CommanderBlockParams): Promise ( ); +/** + * The whole opening move, as one button. + * + * Writing a good first prompt is the hard part of starting a commander, and asking somebody to do it before + * they have talked to it gets it done badly or not at all. So this sends the opener instead: a brief the + * MODEL reads telling it to run the interview, while the transcript shows the short line a person would + * have written. That split is `effectivePrompt` vs `displayText`, which the chat path already had. + * + * The instruction itself is deliberately thin. What to ask, in what order, and to orient in the directory + * before asking anything at all, all live in the Commanders skill — which is re-read from disk every run, so + * it can be reworded without a deploy. Duplicating any of it here would create a second copy to drift. + */ +const START_BRIEF = [ + 'I have given you a name and a working directory. I have not told you what I want yet — that is what this', + 'conversation is for, and I would rather answer your questions than write a specification up front.', + '', + 'Orient yourself here first, then ask me one question.', +].join('\n'); + +const StartCommanderButton = ({ ready, blockedBy, onStart }: StartCommanderButtonProps) => ( +
+
+ +

+ {ready + ? 'It will look around this directory, then ask you what to watch for.' + : `${blockedBy} — then this starts it.`} +

+
+
+); + +type StartCommanderButtonProps = { + ready: boolean; + /** What is still missing, phrased to finish the sentence in the hint below the button. */ + blockedBy: string; + onStart: () => void; +}; + /** * The two decisions a commander cannot be started without. * @@ -236,6 +278,24 @@ function NewCommander({ {chat.messages.length === 0 && ( <> setPickerOpen(true)} /> + + // Positional, and the shape is why `opts` was added rather than a tenth slot: displayText is #7. + chat.sendPrompt( + START_BRIEF, + undefined, + undefined, + cwd ? { path: cwd } : undefined, + undefined, + undefined, + 'Set me up.', + undefined, + { selfStarted: true }, + ) + } + /> )} diff --git a/src/workspaces/officerdev/src/hooks/useChat.ts b/src/workspaces/officerdev/src/hooks/useChat.ts index f93d1c70..1b2c821b 100644 --- a/src/workspaces/officerdev/src/hooks/useChat.ts +++ b/src/workspaces/officerdev/src/hooks/useChat.ts @@ -792,6 +792,20 @@ export function useChat(initialSessionId?: string, initialModel?: string | null, * exact message that starts the watching turn, not a later one. */ armWatcher?: boolean, + /** + * Anything else this turn needs to say about ITSELF rather than about its content. + * + * An object rather than a tenth positional: nine is already past the point where a call site reads as + * a row of undefineds, and every flag added here would make that worse for every existing caller. + */ + opts?: { + /** + * The owner pressed Start instead of typing an opening message, so this turn is a request for the + * commander to interview them — see `commanderBlock`. Only the client that rendered the button can + * know this, which is why it rides the message rather than being derived on the server. + */ + selfStarted?: boolean; + }, ) { // Mark session as started on first message if (!hasStarted) { @@ -832,6 +846,7 @@ export function useChat(initialSessionId?: string, initialModel?: string | null, type: 'chat', prompt: text, ...(displayText ? { displayText } : {}), + ...(opts?.selfStarted ? { selfStarted: true } : {}), sessionId: sessionIdRef.current, ...(selectedModel ? { model: selectedModel } : {}), ...(cwdParam?.path ? { cwd: cwdParam.path } : {}),