a commander starts its own conversation, instead of being handed one

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 23:04:25 +00:00
co-authored by Claude Opus 5
parent eefcb06e35
commit 2aef23c8ba
4 changed files with 119 additions and 0 deletions
+21
View File
@@ -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;
@@ -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<string | null> {
@@ -66,5 +74,20 @@ export async function commanderBlock(params: CommanderBlockParams): Promise<stri
'about it — arming before that is arming a guess, and a wrong condition either never fires or fires',
'constantly and is found out days later. If you were given no paths above, you ARE on your first turn:',
'that is what their absence means. Ask, and arm once it is agreed.',
/*
The button case. A pointer, not the interview itself: the questions live in the skill, which is
re-read from disk on every run, so rewording one costs nothing. The same words here would cost a
`pm2 restart officer` every time — and this file is the wrong home for doctrine that will be
edited far more often than the mechanism around it.
*/
...(params.selfStarted
? [
'',
'**Nobody has told you what they want yet.** They gave a name and a working directory and',
'pressed Start, which is a request to run the interview — not a request to do anything else.',
'Read the **Commanders** skill now and follow "When you started yourself": orient in this',
'directory first, then open with what you found and ONE question.',
]
: []),
].join('\n');
}
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router';
import { Check, FolderOpen, Radar, Trash2, Unplug, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui/sonner';
import { EditableTitle } from '@/components/EditableTitle';
import { useClient } from 'hooks/useClient';
@@ -65,6 +66,47 @@ const NewCommanderGuide = () => (
</div>
);
/**
* 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) => (
<div className="shrink-0 border-b border-border px-5 py-4">
<div className="mx-auto flex max-w-2xl flex-col items-start gap-2">
<Button onClick={onStart} disabled={!ready} size="lg">
Start the conversation
</Button>
<p className="text-xs text-muted-foreground">
{ready
? 'It will look around this directory, then ask you what to watch for.'
: `${blockedBy} — then this starts it.`}
</p>
</div>
</div>
);
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 && (
<>
<CommanderSetup name={name} onName={setName} cwd={cwd} onPickDirectory={() => setPickerOpen(true)} />
<StartCommanderButton
ready={ready}
blockedBy={!name.trim() ? 'Name it first' : 'Choose a working directory'}
onStart={() =>
// 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 },
)
}
/>
<NewCommanderGuide />
</>
)}
@@ -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 } : {}),