diff --git a/docs/command-hq.md b/docs/command-hq.md
new file mode 100644
index 00000000..07c52a68
--- /dev/null
+++ b/docs/command-hq.md
@@ -0,0 +1,222 @@
+# 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. **Not implemented yet** — this is the plan, not the code.
+
+## 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: ``, 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//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//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.
diff --git a/src/databases/officer_db/src/command-hq/index.ts b/src/databases/officer_db/src/command-hq/index.ts
new file mode 100644
index 00000000..33d5fb88
--- /dev/null
+++ b/src/databases/officer_db/src/command-hq/index.ts
@@ -0,0 +1,20 @@
+export {
+ listCommandHqCategories,
+ createCommandHqCategory,
+ renameCommandHqCategory,
+ deleteCommandHqCategory,
+ createCommandHqSession,
+ listCommandHqSessions,
+ listCommandHqSessionIds,
+ getCommandHqSession,
+ setCommandHqSessionCategory,
+ deleteCommandHqSession,
+ purgeCommandHqSessions,
+ listCommandHqTags,
+ createCommandHqTag,
+ deleteCommandHqTag,
+ listCommandHqSessionTags,
+ addCommandHqSessionTag,
+ removeCommandHqSessionTag,
+} from './queries';
+export type { CommandHqCategory, CommandHqSessionRow, CommandHqTag } from './queries';
diff --git a/src/databases/officer_db/src/command-hq/queries.ts b/src/databases/officer_db/src/command-hq/queries.ts
new file mode 100644
index 00000000..1512c804
--- /dev/null
+++ b/src/databases/officer_db/src/command-hq/queries.ts
@@ -0,0 +1,230 @@
+import { and, eq, inArray, isNull } from 'drizzle-orm';
+import { db } from '../db';
+import { commandHqCategories, commandHqSessionTags, commandHqSessions, commandHqTags } from './schema';
+
+// ── Categories ──
+
+export type CommandHqCategory = { id: number; name: string; createdAt: Date };
+
+export async function listCommandHqCategories(userId: number): Promise {
+ return db
+ .select({ id: commandHqCategories.id, name: commandHqCategories.name, createdAt: commandHqCategories.createdAt })
+ .from(commandHqCategories)
+ .where(eq(commandHqCategories.userId, userId))
+ .orderBy(commandHqCategories.name);
+}
+
+/** Throws on a duplicate name for this user — `uq_command_hq_categories_user_name` is the actual guard. */
+export async function createCommandHqCategory(userId: number, name: string): Promise {
+ const [row] = await db.insert(commandHqCategories).values({ userId, name }).returning({
+ id: commandHqCategories.id,
+ name: commandHqCategories.name,
+ createdAt: commandHqCategories.createdAt,
+ });
+ return row!;
+}
+
+export async function renameCommandHqCategory(userId: number, id: number, name: string): Promise {
+ const rows = await db
+ .update(commandHqCategories)
+ .set({ name })
+ .where(and(eq(commandHqCategories.id, id), eq(commandHqCategories.userId, userId)))
+ .returning({ id: commandHqCategories.id });
+ return rows.length > 0;
+}
+
+/** `onDelete: 'set null'` on `command_hq_sessions.category_id` does the uncategorising; this just removes the row. */
+export async function deleteCommandHqCategory(userId: number, id: number): Promise {
+ const rows = await db
+ .delete(commandHqCategories)
+ .where(and(eq(commandHqCategories.id, id), eq(commandHqCategories.userId, userId)))
+ .returning({ id: commandHqCategories.id });
+ return rows.length > 0;
+}
+
+// ── Commanders (command_hq_sessions) ──
+
+export type CommandHqSessionRow = { id: number; sessionId: string; categoryId: number | null; createdAt: Date };
+
+/**
+ * File a session as a commander. Upsert on `(user_id, session_id)` — a second call (a retried request, a
+ * client that raced itself) is a no-op rather than a duplicate-key error, and does NOT move an
+ * already-categorised commander back to uncategorised.
+ */
+export async function createCommandHqSession(
+ userId: number,
+ sessionId: string,
+ categoryId: number | null = null,
+): Promise {
+ const [row] = await db
+ .insert(commandHqSessions)
+ .values({ userId, sessionId, categoryId })
+ .onConflictDoNothing({ target: [commandHqSessions.userId, commandHqSessions.sessionId] })
+ .returning({
+ id: commandHqSessions.id,
+ sessionId: commandHqSessions.sessionId,
+ categoryId: commandHqSessions.categoryId,
+ createdAt: commandHqSessions.createdAt,
+ });
+ // `onConflictDoNothing` returns nothing on the conflict path, so the existing row is fetched explicitly —
+ // the caller still needs an id back (to attach tags, say) even when this call raced an earlier one.
+ if (row) return row;
+ const [existing] = await db
+ .select({
+ id: commandHqSessions.id,
+ sessionId: commandHqSessions.sessionId,
+ categoryId: commandHqSessions.categoryId,
+ createdAt: commandHqSessions.createdAt,
+ })
+ .from(commandHqSessions)
+ .where(and(eq(commandHqSessions.userId, userId), eq(commandHqSessions.sessionId, sessionId)));
+ return existing!;
+}
+
+/**
+ * Every commander row for this user, optionally narrowed to one category. Callers cross-reference
+ * `sessionId` against the live transcript list — this table is never itself the list of what exists.
+ */
+export async function listCommandHqSessions(
+ userId: number,
+ categoryId?: number | null,
+): Promise {
+ const where =
+ categoryId === undefined
+ ? eq(commandHqSessions.userId, userId)
+ : categoryId === null
+ ? and(eq(commandHqSessions.userId, userId), isCategoryNull())
+ : and(eq(commandHqSessions.userId, userId), eq(commandHqSessions.categoryId, categoryId));
+
+ return db
+ .select({
+ id: commandHqSessions.id,
+ sessionId: commandHqSessions.sessionId,
+ categoryId: commandHqSessions.categoryId,
+ createdAt: commandHqSessions.createdAt,
+ })
+ .from(commandHqSessions)
+ .where(where)
+ .orderBy(commandHqSessions.createdAt);
+}
+
+// drizzle's `eq` cannot express `IS NULL`.
+const isCategoryNull = () => isNull(commandHqSessions.categoryId);
+
+/**
+ * Just the session ids this user has filed — the whole shape `/chat`'s list needs to exclude them, and
+ * `/command-hq`'s grid needs to include only them. One query, no join against transcripts (they are not in
+ * Postgres).
+ */
+export async function listCommandHqSessionIds(userId: number): Promise> {
+ const rows = await db
+ .select({ sessionId: commandHqSessions.sessionId })
+ .from(commandHqSessions)
+ .where(eq(commandHqSessions.userId, userId));
+ return new Set(rows.map((r) => r.sessionId));
+}
+
+export async function getCommandHqSession(userId: number, sessionId: string): Promise {
+ const [row] = await db
+ .select({
+ id: commandHqSessions.id,
+ sessionId: commandHqSessions.sessionId,
+ categoryId: commandHqSessions.categoryId,
+ createdAt: commandHqSessions.createdAt,
+ })
+ .from(commandHqSessions)
+ .where(and(eq(commandHqSessions.userId, userId), eq(commandHqSessions.sessionId, sessionId)));
+ return row ?? null;
+}
+
+/** Move a commander to a category, or `null` to uncategorise it explicitly (distinct from never having had one). */
+export async function setCommandHqSessionCategory(
+ userId: number,
+ sessionId: string,
+ categoryId: number | null,
+): Promise {
+ const rows = await db
+ .update(commandHqSessions)
+ .set({ categoryId })
+ .where(and(eq(commandHqSessions.userId, userId), eq(commandHqSessions.sessionId, sessionId)))
+ .returning({ id: commandHqSessions.id });
+ return rows.length > 0;
+}
+
+/**
+ * Unfile a commander. There is no product path to this yet (filing is one-way, per the design doc) — this
+ * exists for the purge sweep, which removes rows whose transcript no longer resolves.
+ */
+export async function deleteCommandHqSession(userId: number, sessionId: string): Promise {
+ const rows = await db
+ .delete(commandHqSessions)
+ .where(and(eq(commandHqSessions.userId, userId), eq(commandHqSessions.sessionId, sessionId)))
+ .returning({ id: commandHqSessions.id });
+ return rows.length > 0;
+}
+
+/**
+ * Delete every row whose `session_id` is not in `liveSessionIds` — the purge sweep. The caller supplies the
+ * live set (read from disk, the same way `/chat`'s list is built); this file has no way to check a
+ * transcript's existence itself and must not pretend to.
+ */
+export async function purgeCommandHqSessions(userId: number, liveSessionIds: Set): Promise {
+ const all = await listCommandHqSessions(userId);
+ const dead = all.filter((row) => !liveSessionIds.has(row.sessionId)).map((row) => row.sessionId);
+ if (!dead.length) return 0;
+ const rows = await db
+ .delete(commandHqSessions)
+ .where(and(eq(commandHqSessions.userId, userId), inArray(commandHqSessions.sessionId, dead)))
+ .returning({ id: commandHqSessions.id });
+ return rows.length;
+}
+
+// ── Tags ──
+
+export type CommandHqTag = { id: number; name: string; createdAt: Date };
+
+export async function listCommandHqTags(userId: number): Promise {
+ return db
+ .select({ id: commandHqTags.id, name: commandHqTags.name, createdAt: commandHqTags.createdAt })
+ .from(commandHqTags)
+ .where(eq(commandHqTags.userId, userId))
+ .orderBy(commandHqTags.name);
+}
+
+export async function createCommandHqTag(userId: number, name: string): Promise {
+ const [row] = await db
+ .insert(commandHqTags)
+ .values({ userId, name })
+ .returning({ id: commandHqTags.id, name: commandHqTags.name, createdAt: commandHqTags.createdAt });
+ return row!;
+}
+
+export async function deleteCommandHqTag(userId: number, id: number): Promise {
+ const rows = await db
+ .delete(commandHqTags)
+ .where(and(eq(commandHqTags.id, id), eq(commandHqTags.userId, userId)))
+ .returning({ id: commandHqTags.id });
+ return rows.length > 0;
+}
+
+/** Tag ids attached to each of the given commander rows, batched — one query for a whole grid rather than one per card. */
+export async function listCommandHqSessionTags(sessionRowIds: number[]): Promise