command hq: POST /commanders/new, for a client that cannot open the websocket

mobile's need, decided with the owner in COMMS/command-hq/mobile.md, now
built: { prompt (required), categoryId? } -> { sessionId, cwd }, sessionId
being claude's REAL transcript id, not a placeholder.

the owner confirmed prompt is required and described why: a commander
usually starts as an ordinary back-and-forth before a background job is ever
armed via the existing dialog, sometimes it's just "you know what to do" —
either way there's always a real first message, same as any conversation.
no empty-commander case to design around.

getting a real transcript id costs actually starting the turn, since claude
assigns one only once system.init has been reported. extracted the
turn-starting half of handleClaudeCodeChat into startClaudeTurn(ws: ... |
null, ..., onOutcome?) so both the websocket path and this endpoint drive
one spawn function instead of two that drift — handleClaudeCodeChat is now
a thin wrapper with an unchanged signature. onOutcome mirrors every
sendToClient(ws, {type:'error',...}) refusal point (member not signed in,
claude not installed, spawn failure) plus the session:claude success case,
so an http caller gets the real reason instead of a generic 30s timeout.

the category-write race: fileAsCommander (same hook the web path uses,
since context: 'command-hq' is set either way) always files with no
category. the endpoint's own createCommandHqSession is upsert-safe
regardless of which insert wins, and an unconditional setCommandHqSessionCategory
UPDATE runs after — always last, always wins, so the requested category is
what's left standing no matter the race.

said plainly in COMMS/command-hq/platform.md: this refactor of live spawn
machinery did not get the second-pair-of-eyes review i said i wanted before
touching it. bunx tsgo clean, suite at the same 7 pre-existing baseline
failures. not exercised against a running server on either side yet.

docs/command-hq.md updated throughout — API table, "how a commander is
created", "still open" now empty of blockers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 07:29:57 +00:00
co-authored by Claude Opus 5
parent e9b1ce5e94
commit 7ad64445ee
4 changed files with 248 additions and 86 deletions
+71 -60
View File
@@ -3,15 +3,16 @@
Spec conversation 2026-08-18, owner + agent, back and forth. This is the write-up asked for partway through
it, kept current as answers landed.
**Status:** data model, REST API, and the web creation path built and mounted 2026-08-18 (`bunx tsgo` clean,
suite at its 7 pre-existing baseline failures — none of them this feature's). **Needs `bun db:push`**
before any route answers with real data; without it every route throws a Postgres "relation does not
exist" 500. A member also needs `confineUserTree` re-run once if their account predates tonight — see
"A member could not reach it" under "Working directory". **Frontend not built yet on the web side.** The
mobile side is further along — `apps/commandhq` exists and typechecks against this router (see
`COMMS/command-hq/mobile.md`) — but cannot create a commander yet; that needs `POST
/command-hq/commanders/new`, not built server-side, see "Still open". Every claim in this file is checked
against the code, not aspirational — see "API reference" for what a client can call today.
**Status:** data model, the full REST API including both creation paths (web and `POST
/commanders/new`), built and mounted 2026-08-18 (`bunx tsgo` clean, suite at its 7 pre-existing baseline
failures — none of them this feature's). **Needs `bun db:push`** before any route answers with real data;
without it every route throws a Postgres "relation does not exist" 500. A member also needs
`confineUserTree` re-run once if their account predates tonight — see "A member could not reach it" under
"Working directory". **Frontend not built yet on the web side.** The mobile side is further along —
`apps/commandhq` exists and typechecks against this router (see `COMMS/command-hq/mobile.md`) — and as of
this revision has what it asked for to create a commander, though it has not yet been exercised against a
running server on either side. Every claim in this file is checked against the code, not aspirational —
see "API reference" for what a client can call today.
## API reference — build against this
@@ -19,22 +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 |
| 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 |
| 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, 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` | — | |
**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,
@@ -50,12 +52,13 @@ poll if a client wants to know "did my watcher die" without opening it.
with a special shape — it simply is not in the list. See "The /chat ↔ /command-hq relationship" below for
why that is the design rather than an oversight.
**There is no "create a new commander" endpoint yet — `POST /commanders/new` is planned, not built.** The web
composer does not need one: it sends an ordinary `/chat` websocket turn in the commanders directory, and the
row is written server-side the moment a real transcript id exists. `POST /commanders` above is the low-level
primitive that write uses internally, not something a client calls directly. A client that cannot open that
websocket with the right parameters — mobile, notably — has no way to create a commander today. See "How a
commander is created" and "Still open".
**Two creation paths, one underlying mechanism.** The web composer sends an ordinary `/chat` websocket turn
in the commanders directory and never calls `POST /commanders/new` — the row is written server-side the
moment a real transcript id exists. A client that cannot open that websocket with the right parameters —
mobile — calls `/commanders/new` instead, which drives the identical spawn path (`startClaudeTurn` in
`websocket.ts`, extracted so there is exactly one) with no live socket, and waits for the same transcript
id before answering. `POST /commanders` (bare) is neither of these — it is the low-level upsert both paths
ultimately rely on, not something a client calls to create anything new. See "How a commander is created".
## What it is
@@ -96,10 +99,12 @@ Two columns.
## How a commander is created
Resolved and **built for the web path** — there is no separate "file this into Command HQ" step. Starting a
new commander **is** the creation flow.
Resolved and **built, both paths** — there is no separate "file this into Command HQ" step on either one.
Starting a new commander **is** the creation flow.
1. The web composer sends an ordinary `/chat` websocket turn with `context: 'command-hq'`.
**Web:**
1. The composer sends an ordinary `/chat` websocket turn with `context: 'command-hq'`.
2. `resolveChatCwd` (`websocket.ts`) reads that context and resolves `getCommandersDir(email)` itself —
`msg.cwd` is never consulted on this branch. The client does not choose, or even need to know, where
commanders live.
@@ -108,23 +113,41 @@ new commander **is** the creation flow.
with a sibling closure (`fileAsCommander`) hung off the same two triggers, gated on a `commandHq` flag
threaded in from `msg.context === 'command-hq'` at the call site.
**Mobile needs a different door, and it is not built yet.** `COMMS/command-hq/mobile.md` — read by the
platform side 2026-08-18, after the mobile client had already been built against the router — found the
web mechanism does not transfer: mobile sends chat turns with its own parameter set, so a context flag
would have to be carried correctly by two independent clients, and a client that gets it wrong silently
creates an ordinary `/chat` conversation in the wrong directory rather than failing loudly. Decided (with
the owner, in that conversation): a dedicated endpoint —
**Mobile needed a different door.** `COMMS/command-hq/mobile.md` — read by the platform side 2026-08-18,
after the mobile client had already been built against the router — found the web mechanism does not
transfer: mobile sends chat turns with its own parameter set, so a context flag would have to be carried
correctly by two independent clients, and a client that gets it wrong silently creates an ordinary `/chat`
conversation in the wrong directory rather than failing loudly. Decided with the owner, and now built:
```
POST /command-hq/commanders/new
body: { categoryId?: number | null, prompt?: string }
body: { prompt: string, categoryId?: number | null }
-> { sessionId, cwd }
```
The server resolves the directory, runs the turn itself, and returns the real transcript id once the
harness has assigned one — the client is not responsible for constructing or guessing a path. **Not
implemented server-side yet** — see "Still open" for the design questions blocking it (mainly: what happens
when `prompt` is omitted, since Claude assigns a transcript id only once a turn has actually run).
`prompt` is required — confirmed with the owner 2026-08-18. The actual usage pattern it needs to support:
a commander often starts as an ordinary back-and-forth (figuring out the terms of the job with the agent)
long before a background job is ever armed via the existing dialog — sometimes the first message really is
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.
**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
the websocket path and this endpoint drive ONE spawn function rather than two that drift, called with
`ws: null`. It resolves as soon as the harness's `session:claude` arrives — not once the whole turn finishes
— via an `onOutcome` callback that mirrors what a live socket would already have been told (a refusal
reason, a spawn failure, or the transcript id), so an HTTP caller gets a real answer instead of a generic
timeout. The turn then keeps streaming exactly like any other session; the endpoint's job ends the moment
there is something real for the client to attach to.
**The category race, and why it resolves deterministically anyway.** `fileAsCommander` (the web path's
same hook, which this endpoint's turn ALSO passes through since `context: 'command-hq'` is set either way)
always files with no category — it has no way to know one was requested. So the endpoint does its own
`createCommandHqSession` (idempotent, upsert-on-conflict-do-nothing — safe whichever of the two insert
attempts actually lands first) followed by an unconditional `setCommandHqSessionCategory` UPDATE. The
UPDATE always runs last and always wins, so the category the client asked for is what's left standing
regardless of ordering.
No name is stored at creation, on either path — see "Naming", next.
@@ -313,18 +336,6 @@ scope for tonight."_
## Still open
**`POST /command-hq/commanders/new` — the endpoint mobile needs, not yet built server-side.** The shape is
decided (see "How a commander is created"), but the one field that decides its implementation is not:
- **When `prompt` is given**, the endpoint has to actually run a turn and wait for the harness to report a
real transcript id before it can respond — Claude does not assign one until a turn has run. That means
reusing the SAME spawn machinery `handleClaudeChat` already drives (`sendClaudeCodeStreaming`,
`sessionManager.getOrCreate`, listening for `session:claude`), awaited only up to that first event rather
than the whole turn, with the turn continuing to stream afterward exactly as any other session does. The
honest way to do this without a second, drifting copy of that logic is to extract the turn-starting half
of `handleClaudeChat` into something both call — not attempted yet, and worth review before it touches
live infrastructure a second time in one night.
- **When `prompt` is omitted**, there is nothing to run and therefore no real transcript id to return.
Whether that case is even needed for v1, or refused outright (a commander always begins with a real first
message, the same as any conversation), was not settled in `COMMS/command-hq/mobile.md` and needs a
decision before this is built rather than a guess baked into the router.
Nothing blocking as of this revision. `POST /command-hq/commanders/new` is built — see "How a commander is
created" — with `prompt` required, confirmed with the owner 2026-08-18. Neither path (web or mobile) has
been exercised against a running server yet; that is the next real test, not a design question.