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 } : {}),