a commander can be named before it says anything

Claude Code will not accept a name for a new session — the harness names its own from the first prompt — so
the platform renames the transcript the moment it exists. The owner names the commander up front, beside
choosing its working directory, and never sees the rename happen.

It works because of what rename actually writes. `renameClaudeSession` stores a `summary` record, which is
what sets `hasExplicitTitle`, and an explicit title is never overwritten — not by the harness's `ai-title`,
not by the first-message fallback. So the chosen name wins permanently rather than racing the titling. That
property already existed and was documented; nothing needed adding to make it hold.

Applied SERVER-side, in `fileAsCommander`, on the `session:claude` that first carries a transcript id. Not by
the client, because it must not depend on a browser still being open: the name is chosen before the first
message, and losing it to a closed tab would leave a commander called after whatever it happened to say
first.

`chatIdentity` is exported and reused rather than reconstructed. It decides the home and the OS user, and a
rename writes a file as that user — a second copy of that resolution is the kind of thing that works until a
member uses it.

The wire field is carried on the turn rather than sent afterwards, for the same reason the plan-mode flag is:
a brand-new conversation has no live session to configure and no stored row to update, so its first message
is the first moment anything can be acted on.

UI comes with the directory picker — both are chosen in the same place, and neither is optional.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 18:54:11 +00:00
co-authored by Claude Opus 5
parent a356656a60
commit d08ae41f1f
3 changed files with 49 additions and 7 deletions
+34 -6
View File
@@ -37,6 +37,7 @@ import {
} from 'officerdb';
import { resolveHomeDir } from '@@/user-home';
import { renameClaudeSession, type ChatIdentity } from './claude-sessions';
import { chatIdentity } from './chat';
import { claudeLoginState } from '@@/os-user-claude';
import { mkdirSync } from 'node:fs';
import { logger } from './logger';
@@ -351,6 +352,15 @@ function createMessageHandler(
resumeSessionId?: string,
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.
*/
commanderName?: string,
) {
/*
The model messages are ATTRIBUTED to, which is not always the model the turn asked for.
@@ -454,13 +464,30 @@ function createMessageHandler(
if (!commandHq || !transcriptId) return;
const userId = sessionManager.getSession(sessionId)?.userId;
if (!userId) return;
void createCommandHqSession(userId, transcriptId).catch((err: unknown) => {
logger.warn('Could not file this session as a commander', {
sessionId,
transcriptId,
error: err instanceof Error ? err.message : String(err),
const filedId = transcriptId;
void createCommandHqSession(userId, filedId)
.then(async () => {
// The chosen name, applied once and only if one was chosen.
//
// `renameClaudeSession` writes a `summary` record, which is what sets `hasExplicitTitle` — and an
// explicit title is never overwritten by the harness's own `ai-title` or by the first-message
// fallback. So this wins permanently rather than racing the titling.
if (!commanderName?.trim()) 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;
renameClaudeSession(who, getCommandersDir(who.email), 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),
});
});
});
};
return (msg: TurnMessage, seq?: number): void => {
@@ -958,6 +985,7 @@ export async function startClaudeTurn(params: {
msg.resumeSessionId,
msg.planMode,
msg.context === 'command-hq',
msg.commanderName,
);
// 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