command hq: data model and the shared commanders directory

first commit of command-hq/command-hq.md's design. two tables plus a
many-to-many for tags, no server surface yet — this is data layer only.

command_hq_sessions has no name column: /chat never persists a title to
postgres, renameClaudeSession appends a summary entry straight into the
transcript's own jsonl, and that stays the ONLY place a commander's name can
live. no status column either — liveness is derived from chat_session_events
the same way the background-job tray already derives it, because a stored
status would go stale the moment the harness sidecar restarts without a
final task:notification and nothing would ever correct it.

category is a plain FK (one per commander, confirmed in spec); tags are the
many-to-many case, schema only, no ui planned yet.

also: getCommandersDir(email) -> DATA_PATH/<email>/commanders, one shared
directory for every commander a user has. the first design was per-commander
directories and was dropped same-night: creating a fresh directory timed
against the harness's own spawn is a race, and a shared pre-existing
directory removes it instead of handling it. deliberately DATA_PATH-rooted
even for the owner, unlike ordinary /chat which runs in the owner's real
home — that's what keeps commanders out of both the owner's own project
transcripts and the /chat list.

bunx tsgo clean. no db:push yet — next commit is the router, and the table
needs to exist before anything can call it; will note the push requirement
clearly when that lands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 06:03:13 +00:00
co-authored by Claude Opus 5
parent 0f93262a8c
commit 56fe3b22df
8 changed files with 597 additions and 0 deletions
+222
View File
@@ -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: `<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.
@@ -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';
@@ -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<CommandHqCategory[]> {
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<CommandHqCategory> {
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<boolean> {
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<boolean> {
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<CommandHqSessionRow> {
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<CommandHqSessionRow[]> {
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<Set<string>> {
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<CommandHqSessionRow | null> {
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<boolean> {
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<boolean> {
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<string>): Promise<number> {
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<CommandHqTag[]> {
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<CommandHqTag> {
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<boolean> {
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<Map<number, number[]>> {
if (!sessionRowIds.length) return new Map();
const rows = await db
.select({ sessionRowId: commandHqSessionTags.sessionRowId, tagId: commandHqSessionTags.tagId })
.from(commandHqSessionTags)
.where(inArray(commandHqSessionTags.sessionRowId, sessionRowIds));
const byRow = new Map<number, number[]>();
for (const { sessionRowId, tagId } of rows) byRow.set(sessionRowId, [...(byRow.get(sessionRowId) ?? []), tagId]);
return byRow;
}
export async function addCommandHqSessionTag(sessionRowId: number, tagId: number): Promise<void> {
await db.insert(commandHqSessionTags).values({ sessionRowId, tagId }).onConflictDoNothing();
}
export async function removeCommandHqSessionTag(sessionRowId: number, tagId: number): Promise<void> {
await db
.delete(commandHqSessionTags)
.where(and(eq(commandHqSessionTags.sessionRowId, sessionRowId), eq(commandHqSessionTags.tagId, tagId)));
}
@@ -0,0 +1,97 @@
import { integer, pgTable, serial, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { users } from '../auth/schema';
/**
* Command HQ — a home for **commanders**: long-running background jobs (watchers) the owner starts, names
* and organises separately from an ordinary `/chat` conversation. Full design record: `docs/command-hq.md`.
*
* ── What is deliberately NOT here ──
*
* No title anywhere in this file. `/chat` never persists a session's title to Postgres — `renameClaudeSession`
* appends a `summary` entry into the transcript's own JSONL, Claude's own on-disk format, and that stays the
* ONLY place a commander's name lives. A `name` column here would be a second place the same fact could be
* written, and the day it disagreed with the transcript there would be no way to say which was right.
*
* No `status` column. Whether a commander is running is derived the same way the background-job tray already
* derives it — folding `chat_session_events` for the session (`task:started` minus `task:notification`,
* `collectRunningTasks` in `api/chat/websocket.ts`). A stored status would be a second source of truth that
* goes stale the moment the harness sidecar restarts without emitting a final `task:notification`, and
* nothing would ever correct it.
*/
/**
* A user-created bucket. Not a fixed enum — the owner names these as they go, so the legal values are
* whatever this table holds rather than a `CHECK`.
*/
export const commandHqCategories = pgTable(
'command_hq_categories',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
// A user's own categories are named things they pick themselves — `Personal`, `Email`, `Repo watchers` —
// and a second `Email` typed by mistake is clutter with no way to tell the two apart in the left list.
(table) => [uniqueIndex('uq_command_hq_categories_user_name').on(table.userId, table.name)],
);
/**
* One row per commander — the mapping from a Claude transcript to Command HQ's own metadata about it.
*
* `/chat` and `/command-hq` read the SAME underlying session list (transcripts on disk) and this table is
* the filter between them: `/chat` excludes any session with a row here, `/command-hq` includes only
* sessions that have one. The table is never itself the list of what exists — a row whose transcript is
* gone is simply absent from both lists, on purpose. See `docs/command-hq.md` → "The /chat ↔ /command-hq
* relationship".
*/
export const commandHqSessions = pgTable(
'command_hq_sessions',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
/**
* Claude's own transcript uuid — NOT Officer's `sessionId`. The same id `chat_session_models` keys on,
* for the same reason: everything that joins back to a session on disk (models, events, this table)
* uses the id the transcript itself carries, because that is the one Officer's `sessionId` and Claude's
* id spaces both agree on only sometimes.
*/
sessionId: text('session_id').notNull(),
/** Nullable — a commander may be uncategorised. `onDelete: 'set null'`: deleting a category uncategorises its commanders rather than deleting them. */
categoryId: integer('category_id').references(() => commandHqCategories.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [uniqueIndex('uq_command_hq_sessions_user_session').on(table.userId, table.sessionId)],
);
/** A user-created label. Many-to-many with commanders, unlike categories — schema only for v1; no UI yet. */
export const commandHqTags = pgTable(
'command_hq_tags',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [uniqueIndex('uq_command_hq_tags_user_name').on(table.userId, table.name)],
);
/** The join. Named FKs — see `src/databases/CLAUDE.md` → "Composite keys" for why a long generated name is refused. */
export const commandHqSessionTags = pgTable(
'command_hq_session_tags',
{
sessionRowId: integer('session_row_id')
.notNull()
.references(() => commandHqSessions.id, { onDelete: 'cascade' }),
tagId: integer('tag_id')
.notNull()
.references(() => commandHqTags.id, { onDelete: 'cascade' }),
},
(table) => [uniqueIndex('uq_command_hq_session_tags').on(table.sessionRowId, table.tagId)],
);
+1
View File
@@ -35,6 +35,7 @@ export * from './permissions';
export * from './chat-events';
export * from './chat-session-models';
export * from './ai-provider-usage';
export * from './command-hq';
export * from './dashboards';
export * from './integrations';
export * from './pipeline-jobs';
+1
View File
@@ -33,6 +33,7 @@ export * from './pipeline-jobs/schema'; // pipeline_jobs
export * from './chat-events/schema'; // chat_session_events
export * from './chat-session-models/schema'; // chat_session_models
export * from './ai-provider-usage/schema'; // ai_provider_usage — last usage reading per AI account
export * from './command-hq/schema'; // command_hq_categories, command_hq_sessions, command_hq_tags, command_hq_session_tags
export * from './agent-panels/schema'; // agent_panels
// Core because the tailnet is the perimeter — a security model resting on it cannot treat administering
+11
View File
@@ -86,3 +86,14 @@ export type ApiKeyInsert = typeof Schema.apiKeys.$inferInsert;
export type AiProviderUsageSelect = typeof Schema.aiProviderUsage.$inferSelect;
export type AiProviderUsageInsert = typeof Schema.aiProviderUsage.$inferInsert;
// ── Command HQ ──
export type CommandHqCategorySelect = typeof Schema.commandHqCategories.$inferSelect;
export type CommandHqCategoryInsert = typeof Schema.commandHqCategories.$inferInsert;
export type CommandHqSessionSelect = typeof Schema.commandHqSessions.$inferSelect;
export type CommandHqSessionInsert = typeof Schema.commandHqSessions.$inferInsert;
export type CommandHqTagSelect = typeof Schema.commandHqTags.$inferSelect;
export type CommandHqTagInsert = typeof Schema.commandHqTags.$inferInsert;
+15
View File
@@ -197,6 +197,21 @@ export const getEmailDbPath = (ownerEmail: string, accountEmail: string) =>
export const getEmailAttachmentCacheDir = (ownerEmail: string) =>
join(getEmailAccountsDir(ownerEmail), 'attachment_cache');
/**
* Every commander a user has shares this ONE directory as its cwd — deliberately not one directory per
* commander. The first design was per-commander, and was dropped the same night it was proposed: a fresh
* directory would have to exist before or exactly when a commander's first turn fires, and racing that
* creation against the harness's own spawn is precisely the kind of timing hazard Claude Code's
* directory-per-project convention was never built to be driven by. One shared, pre-existing directory
* removes the race instead of handling it.
*
* `DATA_PATH`-rooted even for the OWNER, which is a deliberate departure from ordinary `/chat` — an owner's
* normal chat runs in `getOwnerHomeDir` (their real login home). Commanders use the managed directory
* instead, on both sides of the account split, which is what keeps them out of the owner's own project
* transcripts as surely as `command_hq_sessions` keeps them out of the `/chat` list. See `docs/command-hq.md`.
*/
export const getCommandersDir = (email: string) => join(DATA_PATH, email, 'commanders');
/**
* Which mail account's store to use, found on DISK rather than by asking the database.
*