commanders: a commander is a directory, not a session

Implements phase 1 of docs/commander-directory.md. The row owns its own identity
and its own directory; session_id becomes a nullable pointer to whichever
conversation is current.

The ordering was the bug. POST /command-hq/commanders took a sessionId the caller
already held, so a chat existed first and was filed as a commander second — and
the working directory is fixed when that session spawns, before any id exists. It
now takes {name, referencePath}, creates the row, its directory and its CLAUDE.md,
and returns the cwd to start the chat in. Nothing waits on the CLI.

commander-block.ts is deleted. Claude Code reads a CLAUDE.md from the working
directory, so what it injected per turn is written once at creation and the
commander extends it as the job is agreed. resolveChatCwd no longer trusts
msg.cwd at all: it looks the row up server-side. The containment reasoning that
made a client-chosen path safe still holds — it just no longer applies, because
the client has nothing left to choose.

Four things found in review, none of which the build had.

Ownership. confineUserTree chowns commanders/ once, at provisioning, so anything
created afterwards belongs to the service user — a MEMBER's commander directory
would be one their own claude cannot write into, failing as an agent that dies on
its first command in a directory it can see. Invisible because it works perfectly
for the owner, who has no osUser. Handed over on creation, best effort, and the
error is returned as well as logged.

getCommanderDir is guarded. Two callers rm -rf what it returns, and createCommander
inserts dir_name as '' before fixing it up from the serial id — an empty name
resolves to the commanders ROOT and would take every commander the account has.
The transaction closes that window; the guard means it does not have to.

The fallback in resolveChatCwd was silent. A commander whose row is not found runs
in the account's home with no CLAUDE.md, which reads as an agent that forgot its
job rather than a lookup that missed. It says so now.

And sv-SE is renamed alongside the other five locales. It did not exist when this
work was written.

Verified: SQL applied against a scratch database first — idempotent, several
commanders can hold a null pointer at once, one-per-transcript still enforced.
Then here: db:push emits no DDL and does not prompt, officer restarts clean, and
POST /commanders returns a real directory holding state/ and a CLAUDE.md.
1428 pass, tsgo clean.

The single existing commander was deleted rather than migrated, per the design.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
2026-08-24 02:10:37 +00:00
co-authored by Claude Opus 5
parent 75d5a795a2
commit b6d6de7e53
25 changed files with 870 additions and 674 deletions
@@ -11,8 +11,11 @@ type EmbeddableChatProps = {
* What the transcript shows before there is anything in it. `null` shows nothing.
*
* The default, t('chat.composer.startHint'), is true on the chat screen and wrong wherever something else
* already explains what to do — a new commander has a setup card and a Start button above it, so the
* message contradicts them and takes 200px of height while doing it.
* already explains what to do, or where nothing shows it at all: a new commander used to render its setup
* card and Start button inside the same pane as this composer, which is what this prop was added for —
* `docs/commander-directory.md` moved setup into its own component, mounted before any chat exists, so
* there is no longer a caller passing `null` here. Left in place; a future caller with the same shape of
* conflict should use it rather than reinvent it.
*/
emptyState?: ReactNode;
/** Which panel this is — see `queue-store`. Absent on the screen, where there is only one chat. */
@@ -44,9 +47,12 @@ type EmbeddableChatProps = {
/**
* Refuse input entirely, with a reason the composer shows.
*
* For a surface where something must be chosen before a conversation can start — a new commander needs a
* name and a working directory, both unchangeable afterwards. Disabling the composer is the only honest
* place to enforce that: anywhere later and the person has already written what they meant to say.
* For a surface where something must be chosen before a conversation can start at all. Disabling the
* composer is the only honest place to enforce that: anywhere later and the person has already written
* what they meant to say. No current caller uses this — a new commander used to (name and working
* directory, both unchangeable afterwards), until `docs/commander-directory.md` moved that choice into a
* separate setup step BEFORE any chat exists to disable, which needed no `disabled` at all — but the prop
* is generic and worth keeping for the next surface with the same shape of requirement.
*/
disabled?: false | { reason: string };
onMessageComplete?: () => void;
@@ -17,7 +17,9 @@ import {
useCommandHqChildren,
useCommandHqCommander,
useCommandHqCommanderActions,
useCreateCommander,
} from './useCommandHq';
import type { CommandHqCommanderCreated } from './types';
import { formatCommanderCost, formatTokens } from './format';
import { NEW_COMMANDER_GUIDE } from './newCommanderGuide';
import { useTranslation } from '../../locales';
@@ -26,9 +28,10 @@ import { useTranslation } from '../../locales';
// read the route directly, per `docs/navigation-audit.md`):
//
// no :sessionId → empty state, "select or start one"
// :sessionId === 'new' → the composer that CREATES a commander (an ordinary chat turn with
// `context: 'command-hq'`, per `docs/command-hq.md` → "How a commander is
// created" — there is no separate "file this" step)
// :sessionId === 'new' → the composer. `POST /command-hq/commanders` creates the row, ITS directory
// and `CLAUDE.md` first (`NewCommanderSetup`, below); only once that exists does
// a chat turn start, with `cwd` set to that directory (`ActiveNewCommander`) —
// see `docs/commander-directory.md` for why the ordering is the fix
// :sessionId is a real id → the commander's own transcript, resumed, plus a metadata bar
const CHAT_TAIL = 20;
@@ -89,12 +92,13 @@ const NewCommanderGuide = () => {
* 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 startBrief = (name: string): string =>
const startBrief = (name: string, referencePath: string): string =>
[
// i18n-exempt: agent brief — the MODEL reads this; the transcript shows displayText to the person
`You have been named "${name}", and that is the only thing I have told you about what I want.`,
'It is not decoration — I chose it before anything else, so treat it as the subject and read this',
'directory looking for what it refers to.',
`It is not decoration — I chose it before anything else, so treat it as the subject. Your own`,
`CLAUDE.md already names what to look at: \`${referencePath}\`. Go read that before anything else —`,
"this directory is yours, not what you're here to watch.",
'',
'Read the **Commanders** skill before you reply. It is how this works, and it is short.',
'',
@@ -148,20 +152,20 @@ type StartCommanderButtonProps = {
* these choices without leaving the page, and somebody who already knows can fill both in without opening
* anything.
*
* The directory is a button rather than a text field. It is an absolute path on the server and typing one is
* how you get a commander running in a directory that does not exist — `DirPickerModal` browses the account's
* own tree, which is the same containment the server applies to whatever it is handed.
* The reference path is a button rather than a text field. It is an absolute path on the server and typing
* one is how you get a commander pointed at a path that does not exist — `DirPickerModal` browses the
* account's own tree, which is the same containment the server applies to whatever it is handed.
*/
const CommanderSetup = ({
name,
onName,
cwd,
onPickDirectory,
referencePath,
onPickReferencePath,
}: {
name: string;
onName: (next: string) => void;
cwd: string | null;
onPickDirectory: () => void;
referencePath: string | null;
onPickReferencePath: () => void;
}) => {
const { t, tUi } = useTranslation();
return (
@@ -186,34 +190,95 @@ const CommanderSetup = ({
<div className="flex flex-col gap-1">
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t('commandHq.workingDirectory')}
{t('commandHq.referencePath')}
</span>
<button
type="button"
onClick={onPickDirectory}
onClick={onPickReferencePath}
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-left text-sm transition-colors ${
cwd
referencePath
? 'border-border bg-background'
: 'border-dashed border-border text-muted-foreground hover:bg-muted/40'
}`}
>
<FolderOpen className="h-4 w-4 shrink-0" />
<span className="min-w-0 flex-1 truncate font-mono text-xs">{cwd ?? 'Choose a directory…'}</span>
<span className="min-w-0 flex-1 truncate font-mono text-xs">
{referencePath ?? 'Choose a directory…'}
</span>
</button>
<span className="text-[11px] text-muted-foreground">{t('commandHq.cwdHint')}</span>
<span className="text-[11px] text-muted-foreground">{t('commandHq.referencePathHint')}</span>
</div>
</div>
</div>
);
};
/**
* The setup step — name and reference path, nothing live yet. No `useChat` here at all: there is nothing to
* talk to until `POST /command-hq/commanders` returns a row, its directory and its `CLAUDE.md`
* (`docs/commander-directory.md`). `onCreated` hands the result up to `NewCommander`, which switches to
* `ActiveNewCommander` — a fresh mount, not a state transition inside one component, which is what lets
* that component's own `useChat` be constructed with `commanderId` already known from its very first render
* rather than raced against a `setState` that has not landed yet.
*/
function NewCommanderSetup({
category,
onCreated,
}: {
category: number | 'none' | undefined;
onCreated: (commander: CommandHqCommanderCreated) => void;
}) {
const [name, setName] = useState('');
const [referencePath, setReferencePath] = useState<string | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [creating, setCreating] = useState(false);
const ready = name.trim().length > 0 && !!referencePath;
const createCommander = useCreateCommander();
const onStart = async () => {
if (!ready || creating) return;
setCreating(true);
try {
const commander = await createCommander.mutateAsync({
name: name.trim(),
referencePath: referencePath!,
categoryId: typeof category === 'number' ? category : undefined,
});
onCreated(commander);
} catch (err) {
toast.error(errorText(err, 'could not create this commander'));
setCreating(false);
}
};
return (
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
<CommanderSetup
name={name}
onName={setName}
referencePath={referencePath}
onPickReferencePath={() => setPickerOpen(true)}
/>
<StartCommanderButton
ready={ready && !creating}
blockedBy={!name.trim() ? 'Name it first' : !referencePath ? 'Choose a reference path' : 'Creating…'}
onStart={() => void onStart()}
/>
<NewCommanderGuide />
<DirPickerModal open={pickerOpen} onClose={() => setPickerOpen(false)} onSelect={setReferencePath} />
</div>
);
}
/**
* A commander started in THIS pane, and it keeps the same live chat for as long as the pane is open.
*
* `useChat` mints the transcript id on the harness's first response (`session:claude`); once that lands the
* URL is replaced onto it so a refresh resumes this conversation instead of starting a second one.
* `replaceUrl: false` because `useChat`'s own permalink always writes `/chat/<id>` (see `writePermalink`),
* which is the wrong screen here.
* Mounted only once `NewCommanderSetup` has a real row — `commander` is a prop, not state raced against a
* `useChat` built before it existed, which is the whole reason this is a SEPARATE component rather than a
* branch inside one. `useChat` mints the transcript id on the harness's first response (`session:claude`);
* once that lands the URL is replaced onto it so a refresh resumes this conversation instead of starting a
* second one. `replaceUrl: false` because `useChat`'s own permalink always writes `/chat/<id>` (see
* `writePermalink`), which is the wrong screen here.
*
* **The URL change must not remount anything.** It used to: the pane swapped to `ExistingCommander`, which
* tore down the live websocket and re-read the transcript from the API. The whole conversation vanished at
@@ -222,103 +287,79 @@ const CommanderSetup = ({
* Tearing down mid-turn is worse than it looks with a commander, whose turns are long: the pane would drop
* the stream and the reader would watch nothing happen for twenty minutes.
*
* So this component holds the session from the composer through to the end, exactly as `/chat` does — a
* permalink is for a refresh, not an instruction to reload. `CommandHqDetail` keeps it mounted by
* So this component holds the session from the first message through to the end, exactly as `/chat` does —
* a permalink is for a refresh, not an instruction to reload. `CommandHqDetail` keeps it mounted by
* remembering which id started here.
*/
function NewCommander({
function ActiveNewCommander({
commander,
category,
onStarted,
}: {
commander: CommandHqCommanderCreated;
category: number | 'none' | undefined;
onStarted: (id: string) => void;
}) {
const { t, tUi } = useTranslation();
const navigate = useNavigate();
/*
A name and a working directory, both required BEFORE the first message.
Neither can be set afterwards without cost. The directory decides where every relative path in every turn
resolves and which project group holds the transcripts, so changing it later would strand a conversation
away from its own history. The name has to reach the transcript the moment it exists — `commanderName` on
the turn — because Claude Code names a new session from its first prompt, and the platform's rename only
wins by getting there first.
So the composer is disabled until both are chosen. That is why this lives on the card rather than in a
dialog: you read what a commander is and make the two decisions in the same place.
*/
const [name, setName] = useState('');
const [cwd, setCwd] = useState<string | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const ready = name.trim().length > 0 && !!cwd;
const chat = useChat(undefined, undefined, {
replaceUrl: false,
context: 'command-hq',
// The name rides the turn; the DIRECTORY is a prop on EmbeddableChat below, because that is what feeds
// `sendPrompt`'s `cwdParam`. Two different paths for two values chosen in the same place, which is worth
// a note rather than a discovery.
...(name.trim() ? { commanderName: name.trim() } : {}),
// Known from this component's very first render — see the docstring above for why that timing is the
// whole point of the split. Rides every turn, not just the first: `resolveChatCwd` reads it server-side
// on every one, and `fileAsCommander` uses it to point this row at the transcript id the moment it
// exists.
commanderId: commander.id,
});
// Also the transcript id: `command_hq_sessions` is keyed on it, so an action addressed by officer's key
// updates nothing and says nothing. Same root cause as the navigate below.
const { setCategory } = useCommandHqCommanderActions(chat.claudeSessionId ?? '');
const [filed, setFiled] = useState(false);
/*
Has this commander been started — which is NOT the same as "has it any messages".
`messages.length === 0` was standing in for it, and it lies in exactly the situation that matters.
Lose the socket mid-turn and the messages come back empty, so the setup pane and its Start button
reappear on a commander that is already running; pressing Start then sends the opening brief a
SECOND time, and the agent answers a question nobody asked twice. Seen tonight, after a restart I
ran while a commander was live.
A latch, not a derived value. Once started, always started — the point is to survive the moment the
live state goes empty, so anything recomputed from that state is the bug rather than the fix.
Sent exactly once, the moment this component mounts — not on a button click captured in a closure, so
there is nothing here racing `setState` the way the old single-component version did. `useRef`, not
`useState`: a ref write is visible to the very next synchronous check, which is what makes "send it
exactly once" enforceable at all without a second render to notice a state flag had already flipped.
*/
const [started, setStarted] = useState(false);
const sentRef = useRef(false);
useEffect(() => {
if (chat.messages.length > 0 || chat.claudeSessionId || chat.isGenerating) setStarted(true);
}, [chat.messages.length, chat.claudeSessionId, chat.isGenerating]);
if (sentRef.current) return;
sentRef.current = true;
chat.sendPrompt(
startBrief(commander.name ?? '', commander.referencePaths[0] ?? ''),
undefined,
undefined,
{ path: commander.cwd },
undefined,
undefined,
'Set me up.',
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
/*
Refresh the grid when the first turn ends.
The commander's NAME is written server-side, into the transcript's summary record, on the first
`result` — the rename can only land once the transcript exists. Nothing tells the client that
happened, so the list kept whatever title it had fetched before, which for a self-started commander
is the opening brief: the pill read "I have given you a name and a working directory…" while the
header beside it read the real name from local state.
The falling edge of `isGenerating` is the signal, rather than a poll or a shorter `staleTime`: it is
the one moment the server is known to have written something the list is showing.
Command HQ's own metadata query (`GET /commanders/:sessionId`) has nothing to show until the row has a
`sessionId` — which lands mid-turn, off `fileAsCommander` — so the grid and any open detail view are
stale until this fires once.
*/
const wasGenerating = useRef(false);
const qc = useQueryClient();
useEffect(() => {
if (wasGenerating.current && !chat.isGenerating) {
// Both shapes, not just the grid: the header reads the per-commander query and went stale the same
// way, so the first attempt at this fixed the pill and left the title beside it still wrong.
qc.invalidateQueries({ queryKey: ['command-hq', 'commanders'] });
qc.invalidateQueries({ queryKey: ['command-hq', 'commander'] });
}
wasGenerating.current = chat.isGenerating;
}, [chat.isGenerating, qc]);
const [filed, setFiled] = useState(false);
useEffect(() => {
// `claudeSessionId`, NOT `sessionId`. The commander is filed under Claude's transcript id
// (`fileAsCommander` in websocket.ts), and this URL has to match or the detail pane fetches an id
// nothing knows. `sessionId` arrives FIRST — `session:init` sets it to officer's own key — so
// navigating on it sent the page to an id that 404s, and `filed` then blocked the correction when the
// real one arrived. A commander that had run and answered correctly rendered as "Send a message to
// start". Waiting for the transcript id costs one more round trip and is the only id that works.
// `claudeSessionId`, NOT `sessionId`. The commander is addressed by Claude's transcript id everywhere
// else in Command HQ, and this URL has to match or the detail pane fetches an id nothing knows.
// `sessionId` arrives FIRST — `session:init` sets it to officer's own key — so navigating on it sent the
// page to an id that 404s, and `filed` then blocked the correction when the real one arrived.
if (!chat.claudeSessionId || filed) return;
setFiled(true);
// `fileAsCommander` (websocket.ts) always files with no category — it has no way to know one was
// requested from here. The same race the `/commanders/new` HTTP route resolves server-side by always
// running its UPDATE last; done client-side here for the same reason, since the web composer goes
// through the websocket path instead.
if (typeof category === 'number') void setCategory.mutateAsync(category).catch(() => {});
onStarted(chat.claudeSessionId);
navigate(commandHqSessionPath(chat.claudeSessionId, category), { replace: true });
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -329,15 +370,9 @@ function NewCommander({
{chat.claudeSessionId ? (
<MetaBar
sessionId={chat.claudeSessionId}
/*
The name the owner typed, straight from state.
It cannot come from the server yet: the rename lands at the END of the first turn (the transcript
does not exist when the turn starts), and this bar has no fetched title of its own — a brand-new
commander has nothing to resume. So the header read "Untitled commander" until a refresh, showing
the one thing on screen that nobody had chosen while the chosen name sat in a field above it.
*/
title={name.trim() || undefined}
// The name chosen at creation, straight from the row this component was handed — it does not need
// to come from the server again: `POST /commanders` already wrote it before this component mounted.
title={commander.name ?? undefined}
isConnected={chat.isConnected}
isGenerating={chat.isGenerating}
onDisconnect={chat.disconnectSession}
@@ -351,61 +386,37 @@ function NewCommander({
`useEmbeddableChat(params)` builds the manager from these props, and `manager.context` is what the
composer reads to decide whether to offer "exit and hand back, then re-arm". Passing it to the hook
and not to the component left that radio hidden inside a commander — the one place it exists for. */}
{/* Before the first message, the pane explains itself. `EmbeddableChat`'s own empty state is "Send a
message to start", which is true and tells a first-time user nothing about what to send. */}
{/*
One scroll region for the whole pre-start pane, rather than three `shrink-0` siblings.
They stacked to whatever height they wanted and nothing could scroll: the guide carried its own
`overflow-y-auto`, which never engaged, because `shrink-0` had already given it its full natural
height. Fine at 100%, and the panel system is zoomable — at 120% the setup ran off the bottom of
the panel with no way to reach it.
So the scroller is here, where the height is actually constrained, and the composer below keeps
`shrink-0` so it stays reachable at the bottom whatever the zoom.
*/}
{!started && (
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
<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(
startBrief(name.trim()),
undefined,
undefined,
cwd ? { path: cwd } : undefined,
undefined,
undefined,
'Set me up.',
undefined,
{ selfStarted: true },
)
}
/>
<NewCommanderGuide />
</div>
)}
{/* Disabled until both are chosen. The composer is where somebody would otherwise type first and
discover the requirement afterwards, having already written what they meant to say. */}
<EmbeddableChat
chat={chat}
sessionId={undefined}
context="command-hq"
{...(cwd ? { cwd: { path: cwd } } : {})}
disabled={
!started && !ready ? { reason: !name.trim() ? 'Name it first…' : 'Choose a working directory first…' } : false
}
className={!started ? 'shrink-0' : 'min-h-0 flex-1'}
{...(!started ? { emptyState: null } : {})}
cwd={{ path: commander.cwd }}
className="min-h-0 flex-1"
/>
<DirPickerModal open={pickerOpen} onClose={() => setPickerOpen(false)} onSelect={setCwd} />
</div>
);
}
/**
* The composer screen — a thin switch between the two phases described above. `commander` is local state,
* not derived from the URL: once set, it never goes back to `NewCommanderSetup`, even if the live chat
* state momentarily looks empty (a restart mid-turn, say) — the switch depends on "did we create a row",
* not on "does the chat have messages right now", which is exactly the class of bug the OLD single-component
* version had (a `messages.length === 0` latch that could not tell "never started" from "started, and the
* socket dropped").
*/
function NewCommander({
category,
onStarted,
}: {
category: number | 'none' | undefined;
onStarted: (id: string) => void;
}) {
const [commander, setCommander] = useState<CommandHqCommanderCreated | null>(null);
if (!commander) return <NewCommanderSetup category={category} onCreated={setCommander} />;
return <ActiveNewCommander commander={commander} category={category} onStarted={onStarted} />;
}
/**
* The agents this commander spawned, each a link to its own transcript.
*
@@ -40,6 +40,8 @@ export type CommandHqCommander = {
/** `GET /command-hq/commanders/:sessionId` — Command HQ's own metadata, not the transcript. */
export type CommandHqCommanderDetail = {
sessionId: string;
name: string | null;
referencePaths: string[];
categoryId: number | null;
tagIds: number[];
running: boolean;
@@ -51,6 +53,21 @@ export type CommandHqCommanderDetail = {
createdAt: string;
};
/**
* `POST /command-hq/commanders` — the row just created, no transcript yet. `cwd` is the commander's own
* directory (`$DATA_PATH/<email>/commanders/<dirName>/`), computed server-side — see
* `docs/commander-directory.md`. This is what a client starts its first chat turn's `cwd` from.
*/
export type CommandHqCommanderCreated = {
id: number;
dirName: string;
cwd: string;
name: string | null;
referencePaths: string[];
categoryId: number | null;
createdAt: string;
};
export type CommandHqTurn = {
id: number;
fireId: number | null;
@@ -1,6 +1,12 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import type { CommandHqCategory, CommandHqCategoryFilter, CommandHqCommander, CommandHqCommanderDetail } from './types';
import type {
CommandHqCategory,
CommandHqCategoryFilter,
CommandHqCommander,
CommandHqCommanderCreated,
CommandHqCommanderDetail,
} from './types';
const CATEGORIES_KEY = ['command-hq', 'categories'] as const;
const commandersKey = (category: CommandHqCategoryFilter) => ['command-hq', 'commanders', category ?? 'all'] as const;
@@ -9,6 +15,23 @@ const commanderKey = (sessionId: string) => ['command-hq', 'commander', sessionI
const EMPTY_CATEGORIES: CommandHqCategory[] = [];
const EMPTY_COMMANDERS: CommandHqCommander[] = [];
/**
* Create a commander — the row, its directory and its `CLAUDE.md`, all before any turn spawns. See
* `docs/commander-directory.md`. The caller starts its chat turn with `cwd` set to the response's `cwd` and
* `commanderId` set to its `id`, which is what lets the server point this same row at the transcript id the
* moment one exists rather than creating a second row for it.
*/
export function useCreateCommander() {
const { post } = useClient();
const qc = useQueryClient();
return useMutation({
mutationFn: (input: { name: string; referencePath: string; categoryId?: number | null }) =>
post<CommandHqCommanderCreated>('/command-hq/commanders', input),
onSuccess: () => qc.invalidateQueries({ queryKey: ['command-hq', 'commanders'] }),
});
}
/** The user's categories, flat — v1 has no nesting and no search, per `docs/command-hq.md` → "Layout, v1". */
export function useCommandHqCategories() {
const { get, post } = useClient();
+12 -23
View File
@@ -27,14 +27,18 @@ type UsePiChatOptions = {
context?: string;
contextId?: string;
/**
* The name chosen for a COMMANDER before its first message.
* The COMMANDER row this conversation belongs to — `command_hq_sessions.id`, created by
* `POST /command-hq/commanders` BEFORE this hook's first turn is ever sent. See
* `docs/commander-directory.md`.
*
* Rides every turn rather than just the first: the server applies it once, in `fileAsCommander`, and
* sending it again costs a few bytes. Sending it only on the first turn would mean losing it if that turn
* failed and was retried, which is the case that matters — a commander named after its second prompt is
* the bug this exists to prevent.
* Rides every turn rather than just the first: the server uses it twice — to point the row at the
* transcript id the moment one exists (`fileAsCommander`), and on EVERY turn to resolve `cwd` to the
* commander's own directory server-side (`resolveChatCwd`) rather than trusting a client-chosen path.
* Dropping it after turn one would mean turn two runs with nothing telling the server which directory
* this is, other than the transcript id it resolves to once a resume id exists — which is one more
* lookup than just continuing to say so.
*/
commanderName?: string;
commanderId?: number;
resumeSummary?: string;
resumeSessionId?: string;
initialMessages?: ChatMessage[];
@@ -82,7 +86,7 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
projectScoped,
context,
contextId,
commanderName,
commanderId,
resumeSummary: initialResumeSummary,
resumeSessionId,
initialMessages: preloadedMessages,
@@ -802,20 +806,6 @@ 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) {
@@ -856,7 +846,6 @@ 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 } : {}),
@@ -869,7 +858,7 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
...(thinking ? { thinking } : {}),
...(context ? { context } : {}),
...(contextId ? { contextId } : {}),
...(commanderName ? { commanderName } : {}),
...(commanderId !== undefined ? { commanderId } : {}),
...(pendingResumeSummary ? { resumeSummary: pendingResumeSummary } : {}),
...(resumeSessionId ? { resumeSessionId } : {}),
...(armWatcher ? { armWatcher } : {}),
@@ -207,8 +207,8 @@
"startConversation": "Unterhaltung starten",
"name": "Name",
"nameHint": "Wird der Unterhaltung bei ihrem Start zugewiesen, statt sie vom Harness anhand Ihrer ersten Nachricht benennen zu lassen.",
"workingDirectory": "Arbeitsverzeichnis",
"cwdHint": "Von hier aus werden alle ausgeführten Befehle aufgelöst, und hier wird der Verlauf gespeichert. Kann später nicht verschoben werden.",
"referencePath": "Referenzpfad",
"referencePathHint": "Worauf dieser Kommandant achtet oder woran er arbeitet — nicht, wo er lebt. Er kann später selbst weitere hinzufügen.",
"new": "Neuer Commander",
"spawned": "Gestartet",
"deleteQ": "Löschen?",
@@ -242,7 +242,7 @@
"whileRunningBody1": "TODO: was der Eigentümer sieht — wo die Kosten angezeigt werden, wie man erkennt, was die von ihm gestarteten Agenten gerade tun, und wie man ihn stoppt.",
"draft": "Entwurf"
},
"startHint": "Er sieht sich zunächst in diesem Verzeichnis um und fragt Sie dann, worauf Sie achten möchten.",
"startHint": "Er sieht sich zunächst an, worauf Sie ihn hingewiesen haben, und fragt Sie dann, worauf er achten soll.",
"startBlocked": "{{missing}} — dann startet dies ihn.",
"confirmDeletingNamed": "Löschen von {{name}} bestätigen"
},
@@ -207,8 +207,8 @@
"startConversation": "Start the conversation",
"name": "Name",
"nameHint": "Applied to the conversation as it starts, instead of the harness naming it after your first message.",
"workingDirectory": "Working directory",
"cwdHint": "Where every command it runs resolves from, and where its history is kept. It cannot be moved later.",
"referencePath": "Reference path",
"referencePathHint": "What this commander watches or works on — not where it lives. It can add more of these itself once it's running.",
"new": "New commander",
"spawned": "Spawned",
"deleteQ": "Delete?",
@@ -242,7 +242,7 @@
"whileRunningBody1": "TODO: what the owner sees — where its cost shows, how to read what its spawned agents are doing, and how to stop it.",
"draft": "draft"
},
"startHint": "It will look around this directory, then ask you what to watch for.",
"startHint": "It will look at what you pointed it to, then ask you what to watch for.",
"startBlocked": "{{missing}} — then this starts it.",
"confirmDeletingNamed": "Confirm deleting {{name}}"
},
@@ -207,8 +207,8 @@
"startConversation": "Iniciar la conversación",
"name": "Nombre",
"nameHint": "Se aplica a la conversación al iniciarse, en lugar de que el harness la nombre a partir de tu primer mensaje.",
"workingDirectory": "Directorio de trabajo",
"cwdHint": "Desde donde se resuelve cada comando que ejecuta, y donde se guarda su historial. No se puede mover después.",
"referencePath": "Ruta de referencia",
"referencePathHint": "Lo que este comandante vigila o en lo que trabaja — no dónde vive. Puede añadir más por sí mismo una vez en marcha.",
"new": "Nuevo commander",
"spawned": "Iniciado",
"deleteQ": "¿Eliminar?",
@@ -242,7 +242,7 @@
"whileRunningBody1": "TODO: lo que ve el propietario — dónde aparece su coste, cómo leer lo que están haciendo los agentes que ha iniciado, y cómo detenerlo.",
"draft": "borrador"
},
"startHint": "Explorará este directorio y luego te preguntará qué debe vigilar.",
"startHint": "Mirará aquello que le has señalado y luego te preguntará qué debe vigilar.",
"startBlocked": "{{missing}} — y entonces esto lo inicia.",
"confirmDeletingNamed": "Confirmar la eliminación de {{name}}"
},
@@ -207,8 +207,8 @@
"startConversation": "Démarrer la conversation",
"name": "Nom",
"nameHint": "Appliqué à la conversation dès son démarrage, au lieu de laisser le harness la nommer d'après votre premier message.",
"workingDirectory": "Répertoire de travail",
"cwdHint": "D'où toute commande qu'il exécute est résolue, et où son historique est conservé. Il ne peut pas être déplacé ensuite.",
"referencePath": "Chemin de référence",
"referencePathHint": "Ce que ce commandant surveille ou sur quoi il travaille — pas où il vit. Il peut en ajouter d'autres lui-même une fois en cours d'exécution.",
"new": "Nouveau commander",
"spawned": "Lancé",
"deleteQ": "Supprimer ?",
@@ -242,7 +242,7 @@
"whileRunningBody1": "TODO : ce que le propriétaire voit — où son coût s'affiche, comment lire ce que font les agents qu'il a lancés, et comment l'arrêter.",
"draft": "brouillon"
},
"startHint": "Il va regarder autour de ce répertoire, puis vous demander ce qu'il doit surveiller.",
"startHint": "Il va regarder ce vers quoi vous l'avez orienté, puis vous demander ce qu'il doit surveiller.",
"startBlocked": "{{missing}} — ensuite ceci le démarre.",
"confirmDeletingNamed": "Confirmer la suppression de {{name}}"
},
@@ -207,8 +207,8 @@
"startConversation": "Iniciar a conversa",
"name": "Nome",
"nameHint": "Aplicado à conversa quando esta começa, em vez de o harness a nomear a partir da sua primeira mensagem.",
"workingDirectory": "Diretório de trabalho",
"cwdHint": "A partir de onde todos os comandos que executa são resolvidos, e onde o histórico é guardado. Não pode ser movido depois.",
"referencePath": "Caminho de referência",
"referencePathHint": "Aquilo que este comandante vigia ou em que trabalha — não onde vive. Pode acrescentar mais por si próprio depois de estar em funcionamento.",
"new": "Novo comandante",
"spawned": "Iniciado",
"deleteQ": "Eliminar?",
@@ -242,7 +242,7 @@
"whileRunningBody1": "TODO: o que o proprietário vê — onde aparece o custo, como ler o que os agentes que ele inicia estão a fazer, e como o parar.",
"draft": "rascunho"
},
"startHint": "Vai observar este diretório e depois perguntar-lhe o que deve vigiar.",
"startHint": "Vai olhar para aquilo que lhe indicou e depois perguntar-lhe o que deve vigiar.",
"startBlocked": "{{missing}} — depois é isto que o inicia.",
"confirmDeletingNamed": "Confirmar a eliminação de {{name}}"
},
@@ -207,8 +207,8 @@
"startConversation": "Starta konversationen",
"name": "Namn",
"nameHint": "Används för konversationen från start, i stället för att harnesset döper den efter ditt första meddelande.",
"workingDirectory": "Arbetskatalog",
"cwdHint": "Varifrån varje kommando den kör utgår, och var dess historik sparas. Den kan inte flyttas efteråt.",
"referencePath": "Referenssökväg",
"referencePathHint": "Vad denna commander bevakar eller arbetar med — inte var den bor. Den kan lägga till fler av dessa själv när den väl körs.",
"new": "Ny commander",
"spawned": "Startade",
"deleteQ": "Ta bort?",