Files
platform/docs/command-hq.md
T
pastilhasandClaude Opus 5 e9b1ce5e94 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>
2026-08-18 06:58:23 +00:00

331 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Command HQ — design record
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.
## API reference — build against this
Base path `/command-hq`, `core` permission (every signed-in account, no grant to hold), every query scoped
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` | — | |
**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,
createdAt}`. A client wanting the full picture calls both.
**`GET /commanders` is the grid endpoint** and returns the richest shape: every field `/chat/sessions`
already returns per session (`id`, `title`, `cwd`, `createdAt`, `updatedAt`, `messageCount`, …) plus
`categoryId`, `tagIds`, and `running` — a boolean, derived live from the same `task:started`/
`task:notification` fold `/chat`'s own background-job tray uses, not a stored value. This is the field to
poll if a client wants to know "did my watcher die" without opening it.
**A row whose transcript is gone is silently absent from every response.** Never a tombstone, never a 404
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".
## What it is
A home for **commanders** — long-running background jobs (watchers) the owner wants to find again, organise,
and check on. Deliberately separate from `/chat`: **no cross-pollination**. A commander is started from
inside Command HQ, not from an existing `/chat` conversation, and a `/chat` conversation never becomes a
commander after the fact.
Worked example the owner gave: a watcher that polls Gmail via IMAP every 10–60 minutes, compiles what came
in, and pushes a summary — so the owner can uninstall the Gmail app and stop getting a notification per
email. Command HQ is where that watcher lives, is named, is categorised, and is checked on.
## Route, surface, placement
- **`/command-hq`** — new dock item, new route, **core** (`kind: 'core'` in `permissions/registry.ts`, not
`app`). Every account gets it; there is no grant to hold. Core still needs the registry entry —
`assertPermissionTotality` refuses to boot on an unregistered router regardless of kind.
- **Per-user, not shared.** Every table carries `user_id`; every query is scoped to the caller. Nothing
about this is visible across accounts. The owner named a _possible_ future (a super-admin view of who is
running what) — do not build toward it, it costs nothing later because `user_id` is already there.
- Workspace/Panel framework, per the standing rule for every new dashboard route: `<WorkspaceView
workspace={ws} locked />`, panels under `officerdev/src/apps/CommandHQ/`, a rule added to `usePageTitle.ts`.
- **API-first.** `/command-hq` will also be a mobile app surface eventually — not this task's concern, but
every capability must be a real HTTP endpoint, not something only the web panel can reach.
## Layout, v1
Two columns.
- **Left: category list.** Simple for v1 — a flat list of the user's categories, click to select. Filtering
and search on this list are explicitly deferred (see "Later").
- **Right: commander grid**, scoped to the selected category. One card per commander.
- **Click a card → detail pane.** The right side splits to show it. What the detail pane contains beyond
"the conversation" is deferred — see "Later". For v1 it can be as simple as the chat transcript.
- **New commander** starts from inside Command HQ's own composer — not a repurposed `/chat` panel reached
from elsewhere. Almost certainly `EmbeddableChat` (`officerdev/src/apps/Chat/EmbeddableChat/`), which
already exists as a chat surface embeddable outside the `/chat` screen.
## 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.
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.
**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
**`/chat` never persists a title to Postgres.** `renameClaudeSession` (`claude-sessions.ts:646`) appends a
`summary` entry straight into the transcript's own JSONL — Claude's own on-disk format is the title's only
home. An unrenamed session's title is the first prompt, truncated, computed on the fly. There is no title
column anywhere in Postgres for a `/chat` session, ever.
Confirmed with the owner: **`command_hq_sessions` carries no `name` column.** The grid reads a commander's
title live, the exact same way `/chat`'s list already does. Renaming a commander **is** `renameClaudeSession`
— the same function, same route, same file on disk — not a second mechanism that could disagree with the
first. One title, one place it can live, zero drift possible by construction.
## Data model
Tables prefixed `command_hq_` per the house rule (`purge` finds a feature's tables by prefix; this one keeps
the platform's naming convention even though it lives in core, a deliberate choice made explicit rather than
an accident of copying a plugin schema).
### `command_hq_sessions`
One row per commander.
| column | notes |
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `user_id` | owner of the row, always the same account that owns the session |
| `session_id` | **Claude's transcript uuid**, not Officer's `sessionId` — the same id `chat_session_models` keys on, for the same reason: everything else that joins to a session (models, events) uses this id |
| `category_id` | FK to `command_hq_categories`, nullable — one category per commander (see below) |
| `created_at` | |
No `name` column (see "Naming"). No `status` column, deliberately: liveness is **derived**, the same way
the background-job tray already derives it — fold `chat_session_events` for the session, `task:started`
minus `task:notification` (`collectRunningTasks` in `websocket.ts` is the existing implementation of exactly
this fold; reuse it, do not reinvent it). A stored status would be a second source of truth that goes stale
precisely in the case this feature is used for (see "Later" — silent death on sidecar restart) and nothing
would ever correct it.
### `command_hq_categories`
User-created, not a fixed enum. One category per commander — a plain FK column on `command_hq_sessions`, not
a join table.
| column | notes |
| ------------ | ----- |
| `user_id` | |
| `name` | |
| `created_at` | |
### Tags — infrastructure now, UI not specified
Resolved: build the tag infrastructure alongside categories, not deferred. Many-to-many, unlike categories:
- `command_hq_tags` — `user_id`, `name`, `created_at`.
- `command_hq_session_tags` — join table, `(session_row_id, tag_id)`.
Nothing in the spec describes a tag picker or tag filter chips for v1's UI (only the category list was
described). Build the schema; treat the UI as open until asked for.
## Working directory — every commander shares one directory per user
Resolved, and it came with an explicit reversal worth keeping: the owner's first instinct was a directory
**per commander**, each with its own isolated cwd. Retracted on the spot — creating a fresh directory at the
moment a commander starts races the harness's own spawn (the cwd has to exist before or exactly when the
first turn fires, and centralising that creation moment is exactly the kind of timing hazard Claude Code's
own directory-per-project convention was not designed to be driven by). Simpler and safer: no per-commander
directory at all.
**`DATA_PATH/<email>/commanders/`** — one fixed directory, shared by every commander a user has. Every
commander's `cwd` is this same path. This is precedented, not new: `getEmailAccountsDir` in `data-path.ts`
already follows the identical shape (`join(DATA_PATH, ownerEmail, 'email_accounts')`) for a different
feature's per-user directory. A `getCommandersDir(email)` alongside it is the natural addition. Needs to
exist once, idempotently (`mkdirSync(..., { recursive: true })`, the pattern already used in
`websocket.ts`), created lazily before the first commander's first turn — never per-commander, which is the
exact race that was rejected.
**This is a deliberate departure from ordinary `/chat` cwd resolution for the owner.** A normal owner chat
runs in `getOwnerHomeDir` — the owner's real login home — while `getHomeDir` (`DATA_PATH/<email>/home`) is
otherwise reserved for non-owner accounts and the pipeline executor. Commanders use the managed,
`DATA_PATH`-rooted directory **even for the owner**, which is exactly what keeps them out of the owner's own
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.**
- `/chat`'s session lists (`listClaudeSessions`, `listAllClaudeSessions`, and the ChatHistory app) **exclude**
any session with a `command_hq_sessions` row.
- `/command-hq`'s card grid **includes only** sessions with a row, joined against the live session list.
**The source of truth is always the live session list** (transcripts on disk, exactly what `/chat` reads
today), never the table. `command_hq_sessions` is a filter and a set of annotations on top of that list, not
an independent record of what sessions exist. Consequences the owner confirmed explicitly:
- A row whose transcript is gone (deleted, home reset, whatever) is simply **absent from both lists** — it
is not rendered as a tombstone, and nothing needs to notice. It is "someone else's problem" by design; the
owner is not developing for people who will be surprised by this.
- A **purge button** can sweep `command_hq_sessions` for rows whose `session_id` no longer resolves to a
live session and delete them. Housekeeping only — correctness never depended on it running.
- A session **never moves the other direction**. There is no unfile path, and none is planned — a commander
is a commander for life, and (given commanders live in their own cwd, never a `/chat` cwd) the two spaces
never actually overlap.
## Built on, unchanged
Everything about _how a background job runs_ is out of scope — this only adds a place to browse, name and
categorise the sessions the existing system produces. In particular, unmodified:
- The Background Job dialog and `buildBackgroundJobPrompt` — Command vs Prompt, the watch/stop-on-fire
rules, "run until stopped" vs "run for N minutes" (both prose in the prompt, no server-side timer).
- The whole lifecycle: `run_in_background`/`Monitor`, `task:started`/`task:notification`,
`chat_session_events` as the durable log, `collectRunningTasks` as the fold, the tray.
- Stop/pause/restart routes under `/chat/tasks/:id`.
- `renameClaudeSession` — Command HQ's rename action IS this, not a new one.
- `officer-notify` — assume it works and is wired up separately; do not build a parallel notification path
for this feature.
## Later — explicitly deferred, not forgotten
Raised during spec and set aside for a second version, in the owner's words: _"that's not... that's out of
scope for tonight."_
- **Silent death is invisible.** A background job is a child of the agent sidecar process, not of Officer.
`pm2 restart officer-claude-code` kills every running watcher with **no** `task:notification` ever
emitted, so `collectRunningTasks` (started-minus-notified) reports it running until the 7-day event prune.
For a watcher meant to run for months, this is the actual failure mode — a card that says "running" and
has been dead since the last deploy. Deferred anyway, because tonight is development against a live server
that gets restarted constantly; the target environment (a server nobody restarts) makes this rare in
practice. When it is picked up: `task-restart.ts`'s `pidsHoldingFile` (`fuser` against the task's output
file, run as the owning user) is the existing liveness probe used by the restart watcher — comparing "log
says running" against "pid check says running" is what turns a false "running" into a third, honest state
("vanished"). Cost matters here: `fuser` is a subprocess spawn per task, so this is a detail-pane check
or a batched sweep on load, never a per-card check on every render of the grid.
- **No durable job record.** A commander's only record of its own history is `chat_session_events`, which is
swept after 7 days. The owner does not care how many times a watcher fired or what each firing found — the
chat transcript being the record is intentional, not a gap to fill.
- **Detail pane contents beyond "the conversation".** The owner asked for logs, status, and transcript
together in a split view; scoped down to "click a card, see its details" for v1, with the specifics
(separate log tail vs transcript vs live status) left open. `/chat/sessions/:id` (transcript) and
`/chat/tasks/:id` (log tail or subagent transcript) already cover the raw material.
- **Filtering and search on the category list.** Named explicitly as v2 in the first message of the spec.
- **Tag UI.** Schema is in scope for v1 (see above); a picker, filter chips, anything user-facing is not.
- **Category management UI details** — delete behaviour (default: uncategorise the affected commanders
rather than cascade-delete their rows, not explicitly confirmed), reordering, anything beyond create/list.
- **Multi-account visibility for the owner** — "sometime for the next version we will want the super user
to have visibility of who is running what." `user_id` on every table already makes this a query change,
not a schema one.
## Resolved, previously open
- **Creation flow** — resolved, see "How a commander is created".
- **Naming / title storage** — resolved, see "Naming".
- **Category cardinality** — resolved: one per commander, a column not a join table. Tags cover the
many-per-commander case.
- **`/clear` chains** — the owner's answer, plainly: nobody sane runs `/clear` on a commander, and this is
not being built for the case where they do. A commander's `session_id` is one transcript; chain-splicing
is not replicated.
- **Working directory** — resolved, see "Working directory" above. Not something the owner had originally
planned for; raised and settled within this same conversation.
## 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.