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:
+71
-60
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user