command hq: the web creation path, and a member could not have used it
`context: 'command-hq'` on a chat websocket turn now resolves to
getCommandersDir(email) regardless of any client cwd, and the row gets
written the moment a real transcript id is known — the same
session:claude/result seed chat_session_models already waits on, a sibling
closure (fileAsCommander) hung off the same two triggers.
found while wiring it, not guessed at: a member's commander turn would have
failed to spawn at all. getCommandersDir is a sibling of home, and
confineUserTree confines every sibling of home to 700 owned by the service
user — unreachable "even by name" — which is exactly wrong for a directory a
member's own turn needs to cd into via setpriv. same shape of bug
resolveChatCwd already tells a story about (general_chat_sessions,
abandoned for the same reason). fixed by chowning commanders/ to the member
the same way confineUserTree already chowns home, and excluding it from the
loop that confines every other sibling. no ACL needed, unlike home — command
hq reads a member's transcripts through listTranscriptsAs, the same
as-the-member read /chat already uses, never as the service user directly.
the gap this leaves: an account provisioned before this commit needs one
manual reprovision (provision-linux-route.ts / manage-users.ts, already
idempotent) before its first commander turn works. documented, not silently
left.
pulled COMMS/command-hq/{mobile,watchers-what-broke}.md from the mobile
side's session — apps/commandhq already exists and typechecks against the
router. their finding: mobile cannot create a commander the way the web
composer does (a context flag two independent clients would have to carry
correctly), and decided with the owner on POST /command-hq/commanders/new
instead. not built server-side yet — the real blocker is that claude does
not assign a transcript id until a turn actually runs, so the endpoint has
to run one and wait for session:claude, which means extracting the
turn-starting half of handleClaudeChat rather than a second copy of it.
replied in COMMS/command-hq/platform.md with the two open questions before
building it (prompt omitted, and wanting a second look at that refactor).
docs/command-hq.md updated throughout to say plainly what's built vs planned
per path (web vs mobile) rather than one blended status.
bunx tsgo clean. suite at baseline (same 7 pre-existing failures).
confineUserTree has no unit tests today (sudo/uid-dependent, matching this
file's existing coverage posture) — not inventing a new harness for one
block inside it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+83
-23
@@ -3,11 +3,15 @@
|
||||
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 and REST API 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. **Frontend
|
||||
not built yet.** Every claim below about what exists is checked against the code, not aspirational — see
|
||||
"API reference" for what a client can call today.
|
||||
**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.
|
||||
|
||||
## API reference — build against this
|
||||
|
||||
@@ -46,10 +50,12 @@ 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**, and that is deliberate, not a gap — see "How a commander
|
||||
is created". A commander begins as an ordinary chat turn over the existing `/chat` websocket, 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, not the thing a composer calls directly.
|
||||
**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".
|
||||
|
||||
## What it is
|
||||
|
||||
@@ -90,21 +96,37 @@ Two columns.
|
||||
|
||||
## How a commander is created
|
||||
|
||||
Resolved: there is no separate "file this into Command HQ" step. Starting a new commander **is** the
|
||||
creation flow.
|
||||
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.
|
||||
|
||||
1. The user opens Command HQ, starts a new commander through its own composer — same send-a-prompt
|
||||
mechanics as `/chat`, same background-job dialog for turning it into a watcher.
|
||||
2. The moment the session has a real transcript id, a `command_hq_sessions` row is written — `user_id`,
|
||||
`session_id`, and (for v1) an empty/default category.
|
||||
3. **"Immediately"**, per the owner, mirrors an existing pattern rather than inventing one: `websocket.ts`'s
|
||||
`createMessageHandler` already tracks a session's transcript id across a turn (seeded from
|
||||
`session:claude`, confirmed on `result` — see the `transcriptId` handling used for `chat_session_models`)
|
||||
because the id is not known until the harness reports it. The commander row should be written on the same
|
||||
signal, gated on the request having come from Command HQ's composer (a flag on the initial chat message,
|
||||
the same shape as `durable`/`member` are already threaded through `ClaudeSpawnStreamingParams`).
|
||||
1. The web 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.
|
||||
3. The moment the session has a real transcript id, `createMessageHandler` writes the `command_hq_sessions`
|
||||
row — the same `session:claude`-then-`result` seed `chat_session_models`' `record()` already waits on,
|
||||
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.
|
||||
|
||||
No name is stored at creation — see "Naming", next.
|
||||
**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 —
|
||||
|
||||
```
|
||||
POST /command-hq/commanders/new
|
||||
body: { categoryId?: number | null, prompt?: string }
|
||||
-> { 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).
|
||||
|
||||
No name is stored at creation, on either path — see "Naming", next.
|
||||
|
||||
## Naming — resolved, and it changes the schema
|
||||
|
||||
@@ -187,6 +209,30 @@ otherwise reserved for non-owner accounts and the pipeline executor. Commanders
|
||||
project transcripts and out of `/chat`'s history for the same underlying reason categories exist: a
|
||||
commander is not a project conversation, and should not be filed as if it were.
|
||||
|
||||
### A member could not reach it, and this is now fixed — but only going forward
|
||||
|
||||
Found while wiring the creation flow, not guessed at: `getCommandersDir` is a sibling of `home`, and
|
||||
`confineUserTree` (`os-user.ts`) confines **every** sibling of `home` to 700, owned by the SERVICE user,
|
||||
"unreachable even by name" — correct for platform-written directories like `attachments/`, and exactly
|
||||
wrong here, because a member's commander turn runs as the MEMBER's own Linux account via `setpriv`. This is
|
||||
the identical shape of bug `resolveChatCwd`'s own comment already tells a story about (`general_chat_sessions`,
|
||||
abandoned for the same reason) — a working directory the turn cannot `cd` into, surfacing as an agent that
|
||||
fails on its own first command.
|
||||
|
||||
**Fixed in `confineUserTree` itself**, not by abandoning the directory: it now chowns `commanders/` to the
|
||||
member exactly as it already chowns `home`, and excludes it from the loop that confines every other
|
||||
sibling. No ACL, unlike `home` — the file browser needs one because it reads a member's home from inside
|
||||
the platform process as the service user; Command HQ has no equivalent, since it reads a member's commander
|
||||
transcripts through `listTranscriptsAs`, the same as-the-member read `/chat` already uses for anyone's
|
||||
transcripts. Plain ownership is the whole requirement, same as `~/.claude/projects` itself.
|
||||
|
||||
**The gap this leaves:** `confineUserTree` runs at provisioning time. An account provisioned _before_ this
|
||||
fix landed will not have `commanders/` chowned until it is provisioned again — the existing admin
|
||||
reprovision action (`provision-linux-route.ts` / `manage-users.ts`) already re-runs `confineUserTree`
|
||||
idempotently, so no new machinery was needed, but a member who existed before tonight needs that one
|
||||
manual re-run before their first commander turn will actually spawn. Worth a release note, not a code
|
||||
change.
|
||||
|
||||
## The `/chat` ↔ `/command-hq` relationship
|
||||
|
||||
**One predicate, opposite sign, both directions read from the same join.**
|
||||
@@ -267,4 +313,18 @@ scope for tonight."_
|
||||
|
||||
## Still open
|
||||
|
||||
Nothing blocking as of this revision.
|
||||
**`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.
|
||||
|
||||
Reference in New Issue
Block a user