From 963c96505690565ea22295e6a5baa2a27252df0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 18 Aug 2026 10:18:17 +0000 Subject: [PATCH] command hq: auto-name a commander from its watcher's own task description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The initial title (the mechanics preamble, truncated, when a background job is the very first message) is expected — that's just Claude Code naming the session from its first message, same as always. But by the time the watcher's task actually starts, the harness has already handed it a real description ("Watch google.com reachability" for a ping watcher), and that reads far better than the preamble ever will. Hooked into the exact moment pendingArmWatcher is consumed — the FIRST arm, never a self-re-arm, which the watcher-loop code already had to distinguish for its own reasons. Resolves a fresh ChatIdentity via resolveHomeDir and calls renameClaudeSession with msg.description. Best-effort: a failure is logged and does not touch watcherArmed. Known gap, noted in the doc rather than fixed: the detail panel's title bar is fetched once on mount and won't live-refresh when this fires while it's open. The grid's cards don't have this problem since GET /commanders already polls every 15s. Co-Authored-By: Claude Opus 5 --- docs/command-hq.md | 17 ++++++++++++++++ src/servers/api/chat/websocket.ts | 34 +++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/docs/command-hq.md b/docs/command-hq.md index 6e5dd18a..99ac2f37 100644 --- a/docs/command-hq.md +++ b/docs/command-hq.md @@ -477,6 +477,23 @@ computes `running` from the same fold `GET /commanders` uses for every card, a c ago, nothing has run since" without any new mechanism, purely by reading what already exists. Not built tonight; noted here so it is not lost, not because it was rejected. +**Auto-naming from the watcher's own description, added 2026-08-18 after the web frontend surfaced why it +matters.** A fresh commander's title is whatever the first message was — the mechanics preamble, truncated, +when that first message is a `handoff` background job. Expected, not a bug: that IS the first message, and +Claude Code names sessions from it the same way it always has (see "Naming" above). But by the time the +watcher's task actually STARTS, the harness has already given it a real description — `msg.description` on +`task:started`, "Watch google.com reachability" for a ping watcher — and that reads far better than the +preamble ever will. So the exact moment `pendingArmWatcher` is consumed (the FIRST arm, never a self-re-arm +— see "known, accepted imprecision" above for why that distinction is already load-bearing here) now also +calls `renameClaudeSession` with that description, resolving a fresh `ChatIdentity` via `resolveHomeDir` +for the filesystem call. Best-effort, like everything else in this block: a failure is logged and does not +touch `watcherArmed`. + +**Known gap, not closed:** the detail panel's title bar is fetched once on mount and does not live-refresh, +so a commander open in the panel at the moment this fires keeps showing the old title until the next +navigation or reload. The grid's cards do not have this problem — `GET /commanders` is already polled every +15s. Flagged rather than fixed; worth a `refetch()` hook if it turns out to matter in practice. + **A real bug, caught by tracing rather than by the compiler, worth stating plainly.** When the cost-tracking schema changed `recordCommandHqWatcherFired`'s signature from `(userId, sessionId)` to `(sessionRowId, taskId)`, the ONE existing call site was not updated in the same pass — and `bunx tsgo` stayed clean, because diff --git a/src/servers/api/chat/websocket.ts b/src/servers/api/chat/websocket.ts index c3b8e391..92539c03 100644 --- a/src/servers/api/chat/websocket.ts +++ b/src/servers/api/chat/websocket.ts @@ -36,6 +36,7 @@ import { recordCommandHqTurn, } from 'officerdb'; import { resolveHomeDir } from '@@/user-home'; +import { renameClaudeSession, type ChatIdentity } from './claude-sessions'; import { claudeLoginState } from '@@/os-user-claude'; import { mkdirSync } from 'node:fs'; import { logger } from './logger'; @@ -539,6 +540,39 @@ function createMessageHandler( void armCommandHqWatcher(userId, transcriptId, taskId).catch((err: unknown) => { logger.warn('Could not arm commander watcher', { sessionId, transcriptId, error: String(err) }); }); + + /* + Auto-name the commander from the agent's own description of what it just started — + "Watch google.com reachability" reads better than the mechanics-preamble text every fresh + commander is titled with initially (expected, not a bug: that IS the first message, and + Claude Code names sessions from it the same way it always has). This is the one moment we + know for certain this is the FIRST task the watcher produced, not a self-re-arm — the + `else` branch below is exactly what handles every later one, and never reaches here. + */ + const description = msg.description?.trim(); + if (description) { + const capturedTranscriptId = transcriptId; + const capturedCwd = session.cwd; + const capturedEmail = session.email; + void resolveHomeDir(userId) + .then((resolved) => { + if (!resolved.ok) return; + const who: ChatIdentity = { + email: capturedEmail, + home: resolved.home, + osUser: resolved.osUser, + isOwner: resolved.isOwner, + }; + renameClaudeSession(who, capturedCwd, capturedTranscriptId, description); + }) + .catch((err: unknown) => { + logger.warn('Could not auto-name commander from watcher description', { + sessionId, + transcriptId: capturedTranscriptId, + error: String(err), + }); + }); + } } else { // Not a fresh arm — but if this commander is ALREADY armed, a new task starting is exactly what // the agent's own self-re-arm produces (`handoffContract`: same turn, same command, new task id).