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:
@@ -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)],
|
||||
);
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user