command hq: displayText, caught by the mobile side before either of us ran this

mobile-2.md found it: a commander created to become a background job sends
buildBackgroundJobPrompt's full mechanics as `prompt` — a page of preamble,
the owner's actual sentence only at the end — and command_hq_sessions has no
name column, so the title comes straight from `(displayText ?? prompt).slice(0,100)`.
without it every such commander titles itself with the mechanics text, and
the grid becomes a column of identical unreadable cards. same precedence the
websocket path already uses on both clients (sendWithHiddenPrefix on web,
useSessionView.ts on mobile) — now the same on this endpoint too.

fixed before either side had run this against a real server, so there's
nothing to clean up on a live grid.

also confirmed rather than assumed, per mobile's other note: cwd cannot be
talked into a different one on this path. resolveChatCwd's command-hq branch
ignores msg.cwd unconditionally and this endpoint never sets it.

replied in COMMS/command-hq/platform.md. also read watcher-loop-proposal.md
(exit/act/drain/re-arm as harness infrastructure, "Andre's call" to build it
before first tests) — not starting it off a COMMS note alone, taking it to
the owner directly since it's a real scope addition beyond what tonight's
command hq build was scoped to.

bunx tsgo clean, suite at baseline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 07:33:09 +00:00
co-authored by Claude Opus 5
parent cc121f3156
commit f810bdc48e
3 changed files with 65 additions and 22 deletions
+28 -18
View File
@@ -20,23 +20,23 @@ Base path `/command-hq`, `core` permission (every signed-in account, no grant to
to the caller's own `user_id` regardless. Router: `src/servers/api/command-hq/router.ts` — read it directly
for exact request/response shapes; the table below is the map, not the full contract.
| Method | Path | Body | Notes |
| ------ | ------------------------------------ | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| GET | `/categories` | — | `{ categories: [{id,name,createdAt}] }` |
| POST | `/categories` | `{name}` | 409 on a duplicate name for this user |
| PATCH | `/categories/:id` | `{name}` | rename |
| DELETE | `/categories/:id` | — | uncategorises its commanders, does not delete them |
| GET | `/tags` | — | schema exists; no UI planned for v1 |
| POST | `/tags` | `{name}` | |
| DELETE | `/tags/:id` | — | |
| GET | `/commanders[?category=<id>\|none]` | — | omitted = all, `none` = uncategorised bucket. See below |
| POST | `/commanders/new` | `{prompt, categoryId?}` | creates a commander; `prompt` required. `-> {sessionId, cwd}` — `sessionId` is Claude's real transcript id, not a placeholder |
| GET | `/commanders/:sessionId` | — | Command HQ's own metadata only — NOT the transcript, see below |
| POST | `/commanders` | `{sessionId, categoryId?}` | files an EXISTING session; idempotent; 404 if the session isn't in the commanders directory. Not the creation path — see `/commanders/new` |
| PATCH | `/commanders/:sessionId` | `{categoryId?, name?}` | `name` proxies to `renameClaudeSession` — the same rename `/chat` uses |
| POST | `/purge` | — | sweeps rows whose transcript no longer resolves; returns `{removed}` |
| POST | `/commanders/:sessionId/tags` | `{tagId}` | |
| DELETE | `/commanders/:sessionId/tags/:tagId` | — | |
| Method | Path | Body | Notes |
| ------ | ------------------------------------ | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| GET | `/categories` | — | `{ categories: [{id,name,createdAt}] }` |
| POST | `/categories` | `{name}` | 409 on a duplicate name for this user |
| PATCH | `/categories/:id` | `{name}` | rename |
| DELETE | `/categories/:id` | — | uncategorises its commanders, does not delete them |
| GET | `/tags` | — | schema exists; no UI planned for v1 |
| POST | `/tags` | `{name}` | |
| DELETE | `/tags/:id` | — | |
| GET | `/commanders[?category=<id>\|none]` | — | omitted = all, `none` = uncategorised bucket. See below |
| POST | `/commanders/new` | `{prompt, displayText?, categoryId?}` | creates a commander; `prompt` required. `displayText ?? prompt` titles it, same precedence as the websocket. `-> {sessionId, cwd}` — `sessionId` is Claude's real transcript id, not a placeholder |
| GET | `/commanders/:sessionId` | — | Command HQ's own metadata only — NOT the transcript, see below |
| POST | `/commanders` | `{sessionId, categoryId?}` | files an EXISTING session; idempotent; 404 if the session isn't in the commanders directory. Not the creation path — see `/commanders/new` |
| PATCH | `/commanders/:sessionId` | `{categoryId?, name?}` | `name` proxies to `renameClaudeSession` — the same rename `/chat` uses |
| POST | `/purge` | — | sweeps rows whose transcript no longer resolves; returns `{removed}` |
| POST | `/commanders/:sessionId/tags` | `{tagId}` | |
| DELETE | `/commanders/:sessionId/tags/:tagId` | — | |
**A commander's conversation is `GET /chat/sessions/:id`, unchanged.** This router deliberately does not
duplicate it — `GET /commanders/:sessionId` returns only `{sessionId, categoryId, tagIds, running,
@@ -121,7 +121,7 @@ conversation in the wrong directory rather than failing loudly. Decided with the
```
POST /command-hq/commanders/new
body: { prompt: string, categoryId?: number | null }
body: { prompt: string, displayText?: string, categoryId?: number | null }
-> { sessionId, cwd }
```
@@ -131,6 +131,16 @@ long before a background job is ever armed via the existing dialog — sometimes
just "we've already discussed this, do it." Either way there is always a real first message, the same as
any conversation; there is no "empty commander" case to design around.
**`displayText` was missing from the first version of this route, and the mobile side caught it** before
it was exercised against a real server. A commander created to become a background job sends the FULL
`buildBackgroundJobPrompt` mechanics as `prompt` — a page of preamble, the owner's actual sentence only at
the end — and `command_hq_sessions` has no name column, so the title comes from `session.meta.title =
(displayText ?? prompt).slice(0, 100)`, exactly the precedence the websocket path already uses (both
clients already send `displayText` there — `sendWithHiddenPrefix` on web, `useSessionView.ts` on mobile).
Without it, every commander created through this endpoint would have titled itself with the mechanics
text, and the grid would have been a column of identical, unreadable cards. Fixed same session, before
anyone had run it for real.
**How it actually gets the id.** Claude assigns a transcript id only once a turn has run far enough to
report `system.init` — there is no way to reserve one in advance. So the endpoint genuinely starts the
turn: `startClaudeTurn` (`websocket.ts`), the turn-starting half of `handleClaudeCodeChat` extracted so both