Files
platform/docs/command-hq.md
T
pastilhasandClaude Opus 5 eb94af87f4 command hq: the api, mounted and documented
full REST surface for categories, tags and commanders. core permission
(personal: ['/'], no grant to hold — every query is scoped to the caller's
own user_id at the sql layer regardless). registered in the permission
registry and PROTECTED_MOUNTS together, so totality holds by construction.

GET /commanders is the grid endpoint: everything /chat/sessions already
returns per session, plus categoryId/tagIds/running. `running` is derived
live off collectRunningTasks (now exported from websocket.ts) folded over
chat_session_events — not a stored column, on purpose, matching the design
doc's stance that a status field would go stale exactly when a sidecar
restart silently kills a watcher.

GET /commanders/:sessionId deliberately does NOT duplicate the transcript —
that stays GET /chat/sessions/:id, unchanged. a client calls both.

/chat/sessions now excludes anything filed as a commander (listCommandHqSessionIds,
.catch()'d so a hiccup on this feature can't take the whole chat list down)
— the other half of the one predicate this whole design turns on.

there is deliberately no POST /commanders/new: creation is an ordinary chat
turn in the commanders directory, which is the next commit.

docs/command-hq.md gains an API reference table for the mobile dev, and a
status line at the top saying plainly what is real and what is not yet:
needs `bun db:push` before any route returns real data, frontend not
started.

bunx tsgo clean. suite at baseline (same 7 pre-existing failures, all
registry/totality drift and the pty socket test — none touch this router).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 06:10:50 +00:00

20 KiB
Raw Blame History

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 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.

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, 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.

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: 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).

No name is stored at creation — 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.

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

Nothing blocking as of this revision.