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> { + 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(); + for (const { sessionRowId, tagId } of rows) byRow.set(sessionRowId, [...(byRow.get(sessionRowId) ?? []), tagId]); + return byRow; +} + +export async function addCommandHqSessionTag(sessionRowId: number, tagId: number): Promise { + await db.insert(commandHqSessionTags).values({ sessionRowId, tagId }).onConflictDoNothing(); +} + +export async function removeCommandHqSessionTag(sessionRowId: number, tagId: number): Promise { + await db + .delete(commandHqSessionTags) + .where(and(eq(commandHqSessionTags.sessionRowId, sessionRowId), eq(commandHqSessionTags.tagId, tagId))); +} diff --git a/src/databases/officer_db/src/command-hq/schema.ts b/src/databases/officer_db/src/command-hq/schema.ts new file mode 100644 index 00000000..8dd489a9 --- /dev/null +++ b/src/databases/officer_db/src/command-hq/schema.ts @@ -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)], +); diff --git a/src/databases/officer_db/src/index.ts b/src/databases/officer_db/src/index.ts index 437c3a50..1e755bc1 100644 --- a/src/databases/officer_db/src/index.ts +++ b/src/databases/officer_db/src/index.ts @@ -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'; diff --git a/src/databases/officer_db/src/schema.ts b/src/databases/officer_db/src/schema.ts index 40359609..7d76a4f0 100644 --- a/src/databases/officer_db/src/schema.ts +++ b/src/databases/officer_db/src/schema.ts @@ -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 diff --git a/src/databases/officer_db/src/types.ts b/src/databases/officer_db/src/types.ts index 97548aef..40bcfbfc 100644 --- a/src/databases/officer_db/src/types.ts +++ b/src/databases/officer_db/src/types.ts @@ -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; diff --git a/src/servers/data-path.ts b/src/servers/data-path.ts index ced550fb..97b11829 100644 --- a/src/servers/data-path.ts +++ b/src/servers/data-path.ts @@ -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. *