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
+70 -140
View File
@@ -15,7 +15,12 @@ import { sendClaudeCodeStreaming } from '@@/channels/send-claude-code';
import { sendOpenCodeStreaming } from '@@/channels/send-opencode';
import * as sidecar from '@@/sidecar-registry';
import { join } from 'path';
import { getOwnerHomeDir, getEmailAccountsDir, findEmailAccountName } from '../../../servers/data-path';
import {
getOwnerHomeDir,
getEmailAccountsDir,
findEmailAccountName,
getCommanderDir,
} from '../../../servers/data-path';
import { attachedFilePaths } from './attached-files';
import {
getUserSettings,
@@ -24,16 +29,15 @@ import {
getUserById,
setChatSessionModel,
setChatSessionPlanMode,
createCommandHqSession,
setCommanderSessionId,
getCommandHqSession,
getCommandHqSessionById,
armCommandHqWatcher,
recordCommandHqWatcherFired,
recordCommandHqTurn,
} from 'officerdb';
import { resolveHomeDir } from '@@/user-home';
import { renameClaudeSession, type ChatIdentity } from './claude-sessions';
import { chatIdentity } from './chat';
import { commanderBlock } from '../command-hq/commander-block';
import { claudeLoginState } from '@@/os-user-claude';
import { mkdirSync } from 'node:fs';
import { logger } from './logger';
@@ -146,30 +150,44 @@ async function resolveEmailCwd(ownerEmail: string, accountEmail?: string): Promi
// The working directory a chat turn runs in, by context: email → the account dir; /chat → a chosen
// pwd or the caller's own home; everything else (browser/project/dashboard) → the given cwd.
async function resolveChatCwd(
msg: { context?: string; contextId?: string; cwd?: string },
msg: { context?: string; contextId?: string; cwd?: string; resumeSessionId?: string; commanderId?: number },
email: string,
userId: number,
home: string,
): Promise<string> {
/*
A commander runs where the owner chose, and the choice is required.
A commander runs in its OWN directory — resolved here, server-side, never trusted from `msg.cwd`.
This used to force `getCommandersDir(email)` and ignore `msg.cwd` entirely — every commander shared one
empty directory whose only job was to BE a cwd, because the cwd was how a commander was recognised.
Identity is the `command_hq_sessions` row now, so the directory is free to be useful: a commander that
works on the platform can live in the platform checkout, where its relative paths mean something and its
transcripts sit beside the work they are about.
This honoured a client-chosen `msg.cwd` from 2026-08-22 until `docs/commander-directory.md` reversed
that call two days later, and the reversal is not just "a crafted message could misdirect a commander" —
the CONTAINMENT reasoning that made a client-chosen path safe (`resolveCwd(home, …)`, so "client-chosen"
never meant "anywhere") still holds, it just no longer applies, because the client has nothing left to
choose. `POST /command-hq/commanders` creates the row, ITS directory and `CLAUDE.md` before any turn
spawns, so this only ever has to look the directory back up: by `commanderId` on the turn that just
created the row (no transcript id yet to look up by), or by the transcript id (`resumeSessionId`, or the
officer session's own resume id on a later turn of the same conversation) once one exists. Either lookup
lands on the same row, and `getCommanderDir(email, row.dirName)` is the one true answer either way.
The chosen path is resolved through `resolveCwd(home, …)`, the same containment the ordinary chat branch
below uses — so "client-chosen" still does not mean "anywhere". A commander cannot be pointed outside
the account's own tree by a crafted message.
No fallback to the old shared directory when nothing is chosen: the UI requires a directory before the
first message can be sent, and a silent default would be a commander running somewhere nobody picked.
Falling back to the account's home is the honest last resort for a caller that predates the requirement.
Falls back to the account's home only when NEITHER is given, which should not happen once every path
pre-creates the row — the honest last resort for a caller that predates it, not a real branch anybody
expects to take.
*/
if (msg.context === 'command-hq') {
const dir = msg.cwd?.trim() ? resolveCwd(home, msg.cwd) : home;
const row =
msg.commanderId !== undefined
? await getCommandHqSessionById(userId, msg.commanderId)
: msg.resumeSessionId
? await getCommandHqSession(userId, msg.resumeSessionId)
: null;
if (!row) {
// Loud, because the degradation is otherwise invisible: a commander running in the account's home has
// no CLAUDE.md, so it knows nothing about itself and asks the owner what its job is on every turn —
// which reads as the agent being forgetful rather than as a lookup that missed.
console.warn(
`[command-hq] no commander row for ${msg.commanderId !== undefined ? `id ${msg.commanderId}` : `session ${msg.resumeSessionId ?? '(none given)'}`} — falling back to the account home`,
);
}
const dir = row ? getCommanderDir(email, row.dirName) : home;
mkdirSync(dir, { recursive: true });
return dir;
}
@@ -358,14 +376,12 @@ function createMessageHandler(
planMode?: boolean,
commandHq?: boolean,
/**
* The name the owner chose before the conversation started, for a commander.
*
* Claude Code will not accept a name for a NEW session — the harness names its own — so the platform
* renames it the moment the transcript exists. Done here rather than by the client because it must not
* depend on a browser still being open: the name was chosen before the first message, and losing it to a
* closed tab would leave a commander called after whatever it happened to say first.
* The commander row THIS turn belongs to — `command_hq_sessions.id`, created by `POST /commanders`
* before this turn ever started (`docs/commander-directory.md`). Used to point that row at the transcript
* id the moment one exists, so a commander's own row and directory (name, `CLAUDE.md`, `dirName`) are
* never something this handler creates — only something it finishes wiring up.
*/
commanderName?: string,
commanderId?: number,
) {
/*
The model messages are ATTRIBUTED to, which is not always the model the turn asked for.
@@ -451,68 +467,33 @@ function createMessageHandler(
};
/*
File this session as a commander, once the transcript id is known — the same wait `record` above is
built around, and for the same reason: there is nothing to key the row on before the harness announces
it. Gated on `commandHq`, which the caller sets only when `msg.context === 'command-hq'`, so an
ordinary `/chat` turn never touches this table.
Point this commander's row at its transcript id, once the transcript id is known — the same wait
`record` above is built around, and for the same reason: there is nothing to key the write on before the
harness announces it. Gated on `commandHq && commanderId`, so an ordinary `/chat` turn never touches
this table, and a commander turn with no id (should not happen once every caller pre-creates the row —
see `docs/commander-directory.md`) is a no-op rather than a guess at creating one.
Idempotent by construction (`createCommandHqSession` upserts on `(user_id, session_id)`), so calling it
again on every later turn of the same commander — which happens, because the client keeps sending
`context: 'command-hq'` for `resolveChatCwd` to keep resolving the right directory — costs one no-op
upsert rather than a duplicate row. That repetition is also a cheap self-heal: if the row were ever lost,
the next turn puts it back.
`setCommanderSessionId` is a plain UPDATE, not an upsert: the row, its directory and its name all exist
already, written by `POST /commanders` before this turn started. There is nothing left to create here —
only a pointer to set, which is why this is so much smaller than the `fileAsCommander` it replaces used
to be. Idempotent by construction (an UPDATE by id), so calling it again on every later turn of the same
commander costs one no-op write, and is a cheap self-heal if the pointer were ever lost.
Fire-and-forget, swallowed failure, same posture as `record`: a database hiccup here must not be a
reason a chat turn fails.
*/
// Whether the chosen name has actually reached the transcript. False until a rename RETURNS true — see
// the retry note inside `fileAsCommander`.
let commanderNamed = false;
const fileAsCommander = (): void => {
if (!commandHq || !transcriptId) return;
if (!commandHq || !transcriptId || commanderId === undefined) return;
const userId = sessionManager.getSession(sessionId)?.userId;
if (!userId) return;
const filedId = transcriptId;
// The name goes in with the row, not only to the transcript: the list reads it from here and is
// right immediately, rather than waiting on a summary record the harness cannot write until the
// first turn has finished.
void createCommandHqSession(userId, filedId, null, commanderName?.trim() || null)
.then(async () => {
/*
The chosen name, applied once — and RETRIED, because the first attempt usually fails.
`fileAsCommander` runs on `session:claude`, which arrives at the START of a turn: the transcript
file may not be on disk yet, and `renameClaudeSession` returns false when it cannot find one. It
returns a boolean that nothing was checking, so the name was silently dropped and the conversation
kept the harness's own `ai-title` — which is exactly what the first live test showed.
So the attempt is recorded and retried at the end of the turn, by which point the file certainly
exists. Idempotent: once it succeeds, `commanderNamed` stops it repeating.
It still writes a `summary` record, which sets `hasExplicitTitle` — and an explicit title is never
overwritten by `ai-title` or by the first-message fallback. So it wins whenever it lands.
*/
if (!commanderName?.trim() || commanderNamed) return;
// The same identity resolution every chat route uses, rather than a second copy of it: it is what
// decides the home and the OS user, and a rename writes a file as that user.
const user = await getUserById(userId).catch(() => null);
if (!user?.email) return;
const who = await chatIdentity({ id: userId, email: user.email }).catch(() => null);
if (!who) return;
// `who.home` as the hint, not the old shared commanders directory: a commander chooses its own
// working directory now, so that path is usually wrong. `findTranscript` treats the cwd as a HINT
// and locates the transcript wherever it actually is — so this works either way, and passing a
// path we know is probably wrong would be the kind of thing that reads as intent later.
commanderNamed = renameClaudeSession(who, who.home, filedId, commanderName.trim());
})
.catch((err: unknown) => {
logger.warn('Could not file this session as a commander', {
sessionId,
transcriptId: filedId,
error: err instanceof Error ? err.message : String(err),
});
void setCommanderSessionId(userId, commanderId, transcriptId).catch((err: unknown) => {
logger.warn('Could not point this commander at its transcript', {
sessionId,
transcriptId,
commanderId,
error: err instanceof Error ? err.message : String(err),
});
});
};
return (msg: TurnMessage, seq?: number): void => {
@@ -573,9 +554,6 @@ function createMessageHandler(
record();
fileAsCommander();
}
// And retried on every result until the name lands. The first attempt runs before the transcript file
// exists, so the end of the first turn is the earliest a rename can succeed.
if (msg.type === 'result' && commandHq && !commanderNamed) fileAsCommander();
/*
The watcher loop — see `docs/command-hq.md` → "The watcher loop" and `handoffContract` in
@@ -791,11 +769,6 @@ 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;
@@ -931,14 +904,6 @@ export function sessionKeyForTranscript(transcriptId: string): string | undefine
return undefined;
}
/**
* 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<string>();
export async function startClaudeTurn(params: {
email: string;
username: string;
@@ -949,11 +914,6 @@ 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;
@@ -962,8 +922,8 @@ export async function startClaudeTurn(params: {
images?: PromptImage[];
resumeSessionId?: string;
planMode?: boolean;
/** See `commanderName` on the wire type — the name chosen before a commander's first message. */
commanderName?: string;
/** See `commanderId` on the wire type — the row `POST /commanders` already created for this turn. */
commanderId?: number;
armWatcher?: boolean;
};
effectivePrompt: string;
@@ -972,34 +932,14 @@ export async function startClaudeTurn(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.
Unlike before `docs/commander-directory.md`, nothing is injected into the prompt here for a commander.
A commander's own `CLAUDE.md` — written by `POST /commanders`, before this turn ever started — covers
what a per-turn block used to have to compile on the fly: its name, its reference path, and the paths
it must record itself at. Claude Code reads a `CLAUDE.md` from the working directory at session start,
so a commander told about itself in the FILE needs no per-turn injection, no `effectivePrompt` splice,
and no first-turn special case — see the deleted `commander-block.ts` for what this replaced and why.
*/
let effectivePrompt = params.effectivePrompt;
if (!briefedSessions.has(sessionId)) {
const block = await commanderBlock({
userId,
sessionId: transcriptIdBySessionKey.get(sessionId) ?? msg.resumeSessionId ?? null,
selfStarted: msg.selfStarted === true,
});
if (block) {
briefedSessions.add(sessionId);
effectivePrompt = `${block}\n\n---\n\n${effectivePrompt}`;
}
}
const effectivePrompt = params.effectivePrompt;
const fail = (error: string) => {
sendToClient(ws, { type: 'error', message: error });
onOutcome?.({ ok: false, error });
@@ -1083,7 +1023,7 @@ export async function startClaudeTurn(params: {
msg.resumeSessionId,
msg.planMode,
msg.context === 'command-hq',
msg.commanderName,
msg.commanderId,
);
// Wrapped rather than passed straight to the spawn call, so `onOutcome` sees the transcript id at the
// exact moment `fileAsCommander` (inside `createMessageHandler`) does — both react to the same
@@ -1161,11 +1101,6 @@ 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;
@@ -1189,11 +1124,6 @@ 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;