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
@@ -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;