From d08ae41f1ffe613600a77c8a1b560dc554d757e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 22 Aug 2026 18:54:11 +0000 Subject: [PATCH] a commander can be named before it says anything MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/servers/api/chat/chat.ts | 2 +- src/servers/api/chat/types.ts | 14 +++++++++++ src/servers/api/chat/websocket.ts | 40 ++++++++++++++++++++++++++----- 3 files changed, 49 insertions(+), 7 deletions(-) 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