diff --git a/src/servers/api/chat/chat.ts b/src/servers/api/chat/chat.ts index b06aa246..45c4459f 100644 --- a/src/servers/api/chat/chat.ts +++ b/src/servers/api/chat/chat.ts @@ -58,7 +58,7 @@ import { readSttConfig } from '../server-settings/stt'; * Reachable by a member since 2026-08-12 — see the note below on what replaced the wholesale refusal that * used to stand at the top of this router. */ -async function chatIdentity(user: { id: number; email: string }): Promise { +export async function chatIdentity(user: { id: number; email: string }): Promise { const resolved = await resolveHomeDir(user.id); if (!resolved.ok) throw errors.FORBIDDEN(resolved.reason); return { email: user.email, home: resolved.home, osUser: resolved.osUser, isOwner: resolved.isOwner }; diff --git a/src/servers/api/chat/types.ts b/src/servers/api/chat/types.ts index dcc4f220..5de0f058 100644 --- a/src/servers/api/chat/types.ts +++ b/src/servers/api/chat/types.ts @@ -76,6 +76,20 @@ export type ClientMessage = * first turn is the first moment the preference can be acted on OR written down. */ planMode?: boolean; + /** + * The name the owner chose for a COMMANDER before its first message. + * + * 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. Carried on the turn rather + * than sent afterwards because it must not depend on a browser still being open: the name was chosen + * before anything was said, and losing it to a closed tab would leave a commander called after + * whatever it happened to say first. + * + * Applied once, by `fileAsCommander`, via `renameClaudeSession` — which writes a `summary` record and + * therefore an EXPLICIT title, and an explicit title is never overwritten by `ai-title` or by the + * first-message fallback. + */ + commanderName?: string; /** * This turn is arming a Command HQ watcher — see `docs/command-hq.md` → "The watcher loop". Set only * by `BackgroundJobDialog` submitting a `handoff` job inside a commander (`isCommander` true); every diff --git a/src/servers/api/chat/websocket.ts b/src/servers/api/chat/websocket.ts index 227db196..0610e431 100644 --- a/src/servers/api/chat/websocket.ts +++ b/src/servers/api/chat/websocket.ts @@ -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