command hq: the api, mounted and documented

full REST surface for categories, tags and commanders. core permission
(personal: ['/'], no grant to hold — every query is scoped to the caller's
own user_id at the sql layer regardless). registered in the permission
registry and PROTECTED_MOUNTS together, so totality holds by construction.

GET /commanders is the grid endpoint: everything /chat/sessions already
returns per session, plus categoryId/tagIds/running. `running` is derived
live off collectRunningTasks (now exported from websocket.ts) folded over
chat_session_events — not a stored column, on purpose, matching the design
doc's stance that a status field would go stale exactly when a sidecar
restart silently kills a watcher.

GET /commanders/:sessionId deliberately does NOT duplicate the transcript —
that stays GET /chat/sessions/:id, unchanged. a client calls both.

/chat/sessions now excludes anything filed as a commander (listCommandHqSessionIds,
.catch()'d so a hiccup on this feature can't take the whole chat list down)
— the other half of the one predicate this whole design turns on.

there is deliberately no POST /commanders/new: creation is an ordinary chat
turn in the commanders directory, which is the next commit.

docs/command-hq.md gains an API reference table for the mobile dev, and a
status line at the top saying plainly what is real and what is not yet:
needs `bun db:push` before any route returns real data, frontend not
started.

bunx tsgo clean. suite at baseline (same 7 pre-existing failures, all
registry/totality drift and the pty socket test — none touch this router).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 06:10:50 +00:00
co-authored by Claude Opus 5
parent 56fe3b22df
commit eb94af87f4
6 changed files with 410 additions and 5 deletions
+49 -1
View File
@@ -1,7 +1,55 @@
# Command HQ — design record # Command HQ — design record
Spec conversation 2026-08-18, owner + agent, back and forth. This is the write-up asked for partway through 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. it, kept current as answers landed.
**Status:** data model and REST API built and mounted 2026-08-18 (`bunx tsgo` clean, suite at its 7
pre-existing baseline failures — none of them this feature's). **Needs `bun db:push`** before any route
answers with real data; without it every route throws a Postgres "relation does not exist" 500. **Frontend
not built yet.** Every claim below about what exists is checked against the code, not aspirational — see
"API reference" for what a client can call today.
## API reference — build against this
Base path `/command-hq`, `core` permission (every signed-in account, no grant to hold), every query scoped
to the caller's own `user_id` regardless. Router: `src/servers/api/command-hq/router.ts` — read it directly
for exact request/response shapes; the table below is the map, not the full contract.
| Method | Path | Body | Notes |
| ------ | ------------------------------------ | -------------------------- | ------------------------------------------------------------------------------------------- |
| GET | `/categories` | — | `{ categories: [{id,name,createdAt}] }` |
| POST | `/categories` | `{name}` | 409 on a duplicate name for this user |
| PATCH | `/categories/:id` | `{name}` | rename |
| DELETE | `/categories/:id` | — | uncategorises its commanders, does not delete them |
| GET | `/tags` | — | schema exists; no UI planned for v1 |
| POST | `/tags` | `{name}` | |
| DELETE | `/tags/:id` | — | |
| GET | `/commanders[?category=<id>\|none]` | — | omitted = all, `none` = uncategorised bucket. See below |
| GET | `/commanders/:sessionId` | — | Command HQ's own metadata only — NOT the transcript, see below |
| POST | `/commanders` | `{sessionId, categoryId?}` | files an existing session; idempotent; 404 if the session isn't in the commanders directory |
| PATCH | `/commanders/:sessionId` | `{categoryId?, name?}` | `name` proxies to `renameClaudeSession` — the same rename `/chat` uses |
| POST | `/purge` | — | sweeps rows whose transcript no longer resolves; returns `{removed}` |
| POST | `/commanders/:sessionId/tags` | `{tagId}` | |
| DELETE | `/commanders/:sessionId/tags/:tagId` | — | |
**A commander's conversation is `GET /chat/sessions/:id`, unchanged.** This router deliberately does not
duplicate it — `GET /commanders/:sessionId` returns only `{sessionId, categoryId, tagIds, running,
createdAt}`. A client wanting the full picture calls both.
**`GET /commanders` is the grid endpoint** and returns the richest shape: every field `/chat/sessions`
already returns per session (`id`, `title`, `cwd`, `createdAt`, `updatedAt`, `messageCount`, …) plus
`categoryId`, `tagIds`, and `running` — a boolean, derived live from the same `task:started`/
`task:notification` fold `/chat`'s own background-job tray uses, not a stored value. This is the field to
poll if a client wants to know "did my watcher die" without opening it.
**A row whose transcript is gone is silently absent from every response.** Never a tombstone, never a 404
with a special shape — it simply is not in the list. See "The /chat ↔ /command-hq relationship" below for
why that is the design rather than an oversight.
**There is no "create a new commander" endpoint**, and that is deliberate, not a gap — see "How a commander
is created". A commander begins as an ordinary chat turn over the existing `/chat` websocket, in the
commanders directory, and the row is written server-side the moment a real transcript id exists. `POST
/commanders` above is the low-level primitive that write uses, not the thing a composer calls directly.
## What it is ## What it is
+17 -3
View File
@@ -4,7 +4,7 @@ import { pauseBackgroundTask, restartBackgroundTask } from './task-restart';
import { createRouter } from '../../create-router'; import { createRouter } from '../../create-router';
import * as errors from '@@/custom-errors'; import * as errors from '@@/custom-errors';
import * as sidecar from '@@/sidecar-registry'; import * as sidecar from '@@/sidecar-registry';
import { getUserSettings, getChatSessionModel, getChatSessionModels } from 'officerdb'; import { getUserSettings, getChatSessionModel, getChatSessionModels, listCommandHqSessionIds } from 'officerdb';
import { import {
listClaudePwds, listClaudePwds,
listClaudeSessions, listClaudeSessions,
@@ -109,7 +109,8 @@ chatRouter.get('/pwds', async (ctx) => {
// GET /chat/sessions[?cwd=] — conversations for a working directory, merged across both harnesses // GET /chat/sessions[?cwd=] — conversations for a working directory, merged across both harnesses
// (Claude transcripts + OpenCode's session store), newest first. // (Claude transcripts + OpenCode's session store), newest first.
chatRouter.get('/sessions', async (ctx) => { chatRouter.get('/sessions', async (ctx) => {
const who = await chatIdentity(ctx.get('user')); const user = ctx.get('user');
const who = await chatIdentity(user);
const cwd = cwdOf(ctx, who.home); const cwd = cwdOf(ctx, who.home);
// `?all=1` — every directory at once, for the list's Latest tab. A separate parameter rather than a // `?all=1` — every directory at once, for the list's Latest tab. A separate parameter rather than a
@@ -128,7 +129,20 @@ chatRouter.get('/sessions', async (ctx) => {
// inventing one by looping would need a directory list it does not publish. So Latest is Claude-only // inventing one by looping would need a directory list it does not publish. So Latest is Claude-only
// for now, which is honest — every OpenCode session is still reachable from its own directory in Tree. // for now, which is honest — every OpenCode session is still reachable from its own directory in Tree.
const opencode = who.isOwner && !all ? await listOpenCodeSessions(cwd) : []; const opencode = who.isOwner && !all ? await listOpenCodeSessions(cwd) : [];
const sessions = [...claude, ...opencode].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); const merged = [...claude, ...opencode].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
/*
Command HQ's filed commanders are excluded here, never rendered as a second conversation somewhere
else. This is the opposite side of the ONE predicate `command_hq_sessions` exists to be — Command HQ's
own grid includes exactly what this excludes. Fetched fresh per request rather than cached: the set is
small (one row per commander) and staleness here would mean a filed commander briefly reappearing in
`/chat`, which is a worse failure than the extra query.
Never fatal — a database hiccup on a feature /chat does not otherwise depend on must not take the whole
conversation list down with it, the same posture `getChatSessionModels` below already takes.
*/
const filed = await listCommandHqSessionIds(user.id).catch(() => new Set<string>());
const sessions = merged.filter((s) => !filed.has(s.id));
/* /*
The model each session is on, batched. The model each session is on, batched.
+5 -1
View File
@@ -1185,8 +1185,12 @@ export function decideResume(knownModel: string | undefined, claimedModel: strin
* status — so started-minus-notified is the answer, and a Map keyed by taskId keeps the last word on each. * status — so started-minus-notified is the answer, and a Map keyed by taskId keeps the last word on each.
* There is no third event: a task the agent abandoned without notifying stays here until the log is pruned, * There is no third event: a task the agent abandoned without notifying stays here until the log is pruned,
* which is the honest reading of the record rather than a bug to paper over. * which is the honest reading of the record rather than a bug to paper over.
*
* Exported for `api/command-hq/router.ts`, which uses it the same way and for the same reason: a commander's
* liveness is derived from this fold rather than a stored column, so it cannot go stale independently of the
* log it is read from. Do not reimplement this elsewhere — reuse it.
*/ */
function collectRunningTasks(events: ServerMessage[]): RunningTask[] { export function collectRunningTasks(events: ServerMessage[]): RunningTask[] {
const running = new Map<string, RunningTask>(); const running = new Map<string, RunningTask>();
for (const event of events) { for (const event of events) {
if (event.type === 'task:started') { if (event.type === 'task:started') {
+324
View File
@@ -0,0 +1,324 @@
import type { Context } from 'hono';
import { createRouter } from '../../create-router';
import * as errors from '@@/custom-errors';
import { resolveHomeDir } from '@@/user-home';
import { getCommandersDir } from '@@/data-path';
import { getChatEventsSince } from 'officerdb';
import type { ServerMessage } from '../chat/types';
import {
listClaudeSessions,
loadClaudeSessionById,
renameClaudeSession,
type ChatIdentity,
type ClaudeSessionSummary,
} from '../chat/claude-sessions';
import { collectRunningTasks } from '../chat/websocket';
import {
listCommandHqCategories,
createCommandHqCategory,
renameCommandHqCategory,
deleteCommandHqCategory,
createCommandHqSession,
listCommandHqSessions,
getCommandHqSession,
setCommandHqSessionCategory,
purgeCommandHqSessions,
listCommandHqSessionTags,
listCommandHqTags,
createCommandHqTag,
deleteCommandHqTag,
addCommandHqSessionTag,
removeCommandHqSessionTag,
} from 'officerdb';
/**
* Command HQ — commanders (long-running background jobs the owner starts, names and organises separately
* from `/chat`). Design record: `docs/command-hq.md`. That file is the "why"; this file is the "what",
* kept deliberately explicit in its doc comments because it is the contract a mobile client builds against.
*
* ── Identity ──
*
* Every route resolves `commandHqIdentity`, the same shape `/chat` resolves as `chatIdentity` (duplicated
* rather than imported — that function is private to `chat.ts` and this is five lines, not worth coupling
* two routers over). `core` permission, no grant to hold: every account reaches this router, and every
* query is additionally scoped to the caller's own `user_id` regardless.
*
* ── Where commanders live ──
*
* Every commander shares ONE working directory per user — `getCommandersDir(email)` — never one per
* commander. That means listing a user's commanders is exactly `listClaudeSessions` (the same function
* `/chat` uses for its own per-directory list) pointed at that one directory, filtered to sessions with a
* `command_hq_sessions` row. No new session-scanning code exists here; this router is a client of the same
* transcript reader `/chat` already is.
*/
async function commandHqIdentity(user: { id: number; email: string }): Promise<ChatIdentity> {
const resolved = await resolveHomeDir(user.id);
if (!resolved.ok) throw errors.FORBIDDEN(resolved.reason);
return { email: user.email, home: resolved.home, osUser: resolved.osUser, isOwner: resolved.isOwner };
}
export const commandHqRouter = createRouter();
// ── Categories ──
// GET /command-hq/categories
commandHqRouter.get('/categories', async (ctx) => {
const user = ctx.get('user');
return ctx.json({ categories: await listCommandHqCategories(user.id) });
});
// POST /command-hq/categories { name: string }
commandHqRouter.post('/categories', async (ctx) => {
const user = ctx.get('user');
const body = await ctx.req.json<{ name?: string }>().catch(() => ({}) as { name?: string });
const name = body.name?.trim();
if (!name) throw errors.BAD_REQUEST('name is required');
try {
return ctx.json(await createCommandHqCategory(user.id, name));
} catch {
// The unique index (`uq_command_hq_categories_user_name`) is the real guard; a Postgres constraint
// violation is the only way this throws, so it is read back as the one thing it can mean.
throw errors.CONFLICT('a category with this name already exists');
}
});
// PATCH /command-hq/categories/:id { name: string }
commandHqRouter.patch('/categories/:id', async (ctx) => {
const user = ctx.get('user');
const id = Number(ctx.req.param('id'));
const body = await ctx.req.json<{ name?: string }>().catch(() => ({}) as { name?: string });
const name = body.name?.trim();
if (!name) throw errors.BAD_REQUEST('name is required');
const ok = await renameCommandHqCategory(user.id, id, name);
if (!ok) throw errors.NOT_FOUND('no such category');
return ctx.json({ ok: true });
});
// DELETE /command-hq/categories/:id — uncategorises its commanders (onDelete: 'set null'), does not delete them.
commandHqRouter.delete('/categories/:id', async (ctx) => {
const user = ctx.get('user');
const id = Number(ctx.req.param('id'));
const ok = await deleteCommandHqCategory(user.id, id);
if (!ok) throw errors.NOT_FOUND('no such category');
return ctx.json({ ok: true });
});
// ── Tags (schema only — no UI planned yet, but the endpoints are real) ──
// GET /command-hq/tags
commandHqRouter.get('/tags', async (ctx) => {
const user = ctx.get('user');
return ctx.json({ tags: await listCommandHqTags(user.id) });
});
// POST /command-hq/tags { name: string }
commandHqRouter.post('/tags', async (ctx) => {
const user = ctx.get('user');
const body = await ctx.req.json<{ name?: string }>().catch(() => ({}) as { name?: string });
const name = body.name?.trim();
if (!name) throw errors.BAD_REQUEST('name is required');
try {
return ctx.json(await createCommandHqTag(user.id, name));
} catch {
throw errors.CONFLICT('a tag with this name already exists');
}
});
// DELETE /command-hq/tags/:id
commandHqRouter.delete('/tags/:id', async (ctx) => {
const user = ctx.get('user');
const id = Number(ctx.req.param('id'));
const ok = await deleteCommandHqTag(user.id, id);
if (!ok) throw errors.NOT_FOUND('no such tag');
return ctx.json({ ok: true });
});
// ── Commanders ──
/** A grid card's worth of information — the transcript summary plus what Command HQ knows about it. */
type CommanderSummary = ClaudeSessionSummary & {
categoryId: number | null;
tagIds: number[];
/**
* Derived from `chat_session_events` at request time — `task:started` minus `task:notification` — the
* same fold `/chat`'s own background-job tray uses (`collectRunningTasks`). NOT a stored value: see
* `docs/command-hq.md` for why a status column would go stale exactly when this feature matters most.
*/
running: boolean;
};
async function isRunning(sessionId: string): Promise<boolean> {
const events = await getChatEventsSince(sessionId, 0);
return collectRunningTasks(events.map((e) => e.event as ServerMessage)).length > 0;
}
/**
* GET /command-hq/commanders[?category=<id>|none]
*
* `category` omitted → every commander, categorised or not. `category=none` → only the uncategorised
* bucket. `category=<id>` → one category.
*
* A row whose transcript no longer resolves is silently dropped, never rendered as a tombstone — the
* design's explicit stance (see "The /chat ↔ /command-hq relationship" in the design doc). Response order
* is newest-updated first, matching `/chat`'s own list.
*/
commandHqRouter.get('/commanders', async (ctx) => {
const user = ctx.get('user');
const who = await commandHqIdentity(user);
const categoryParam = ctx.req.query('category');
const categoryId = categoryParam === undefined ? undefined : categoryParam === 'none' ? null : Number(categoryParam);
const [rows, live] = await Promise.all([
listCommandHqSessions(user.id, categoryId),
listClaudeSessions(who, getCommandersDir(who.email)),
]);
const liveById = new Map(live.map((s) => [s.id, s]));
const filed = rows.filter((row) => liveById.has(row.sessionId));
const tagsByRow = await listCommandHqSessionTags(filed.map((row) => row.id));
const runningById = new Map(
await Promise.all(filed.map(async (row) => [row.sessionId, await isRunning(row.sessionId)] as const)),
);
const commanders: CommanderSummary[] = filed
.map((row) => ({
...liveById.get(row.sessionId)!,
categoryId: row.categoryId,
tagIds: tagsByRow.get(row.id) ?? [],
running: runningById.get(row.sessionId) ?? false,
}))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
return ctx.json({ commanders });
});
/**
* GET /command-hq/commanders/:sessionId
*
* Command HQ's OWN metadata for one commander — category, tags, liveness. Deliberately does NOT return the
* conversation itself: the transcript is `GET /chat/sessions/:id`, unchanged, and a client fetches both
* rather than this route duplicating it. 404 both when the session was never filed and when its transcript
* has since vanished — from the caller's side those are the same fact, "not a commander right now".
*/
commandHqRouter.get('/commanders/:sessionId', async (ctx) => {
const user = ctx.get('user');
const who = await commandHqIdentity(user);
const sessionId = ctx.req.param('sessionId');
const row = await getCommandHqSession(user.id, sessionId);
if (!row) throw errors.NOT_FOUND('not a commander');
if (!loadClaudeSessionById(who, sessionId)) throw errors.NOT_FOUND('not a commander');
const [tagsByRow, running] = await Promise.all([listCommandHqSessionTags([row.id]), isRunning(sessionId)]);
return ctx.json({
sessionId: row.sessionId,
categoryId: row.categoryId,
tagIds: tagsByRow.get(row.id) ?? [],
running,
createdAt: row.createdAt,
});
});
/**
* POST /command-hq/commanders { sessionId: string, categoryId?: number }
*
* Files an EXISTING session as a commander. Idempotent — filing an already-filed session is a no-op, and
* does not move it to a different category (use PATCH for that).
*
* This is a low-level primitive, not the normal creation path. The normal path is starting a new commander
* through Command HQ's own composer, which writes this row itself the moment the harness reports a real
* transcript id — see `docs/command-hq.md` → "How a commander is created". This route exists for that
* wiring to call, and for anything else (a purge-and-refile flow, a script) that needs to file a session
* without going through a live chat turn.
*
* The session must actually exist in the commanders directory, or this refuses — filing a `/chat` session
* makes no sense given commanders and `/chat` never share a working directory.
*/
commandHqRouter.post('/commanders', async (ctx) => {
const user = ctx.get('user');
const who = await commandHqIdentity(user);
const body = await ctx.req
.json<{ sessionId?: string; categoryId?: number }>()
.catch(() => ({}) as { sessionId?: string; categoryId?: number });
const sessionId = body.sessionId?.trim();
if (!sessionId) throw errors.BAD_REQUEST('sessionId is required');
const live = listClaudeSessions(who, getCommandersDir(who.email));
if (!live.some((s) => s.id === sessionId)) throw errors.NOT_FOUND('no such session in the commanders directory');
const row = await createCommandHqSession(user.id, sessionId, body.categoryId ?? null);
return ctx.json(row);
});
/**
* PATCH /command-hq/commanders/:sessionId
*
* Body may carry either or both:
* `categoryId: number | null` — move to a category, or `null` to uncategorise
* `name: string` — rename. Proxies straight to `renameClaudeSession`, the SAME function `/chat`'s own
* rename action calls — there is no separate name field on this table to disagree with it.
*/
commandHqRouter.patch('/commanders/:sessionId', async (ctx) => {
const user = ctx.get('user');
const who = await commandHqIdentity(user);
const sessionId = ctx.req.param('sessionId');
const body = await ctx.req
.json<{ categoryId?: number | null; name?: string }>()
.catch(() => ({}) as { categoryId?: number | null; name?: string });
const row = await getCommandHqSession(user.id, sessionId);
if (!row) throw errors.NOT_FOUND('not a commander');
if (body.categoryId !== undefined) {
await setCommandHqSessionCategory(user.id, sessionId, body.categoryId);
}
if (body.name !== undefined) {
const ok = renameClaudeSession(who, getCommandersDir(who.email), sessionId, body.name.trim());
if (!ok) throw errors.NOT_FOUND('transcript not found');
}
return ctx.json({ ok: true });
});
/**
* POST /command-hq/purge
*
* Deletes every `command_hq_sessions` row whose transcript no longer resolves in the commanders directory.
* Housekeeping only — nothing about correctness ever depended on this running, since a dangling row is
* already invisible everywhere. Returns how many rows were removed.
*/
commandHqRouter.post('/purge', async (ctx) => {
const user = ctx.get('user');
const who = await commandHqIdentity(user);
const live = listClaudeSessions(who, getCommandersDir(who.email));
const removed = await purgeCommandHqSessions(user.id, new Set(live.map((s) => s.id)));
return ctx.json({ removed });
});
// ── Tag assignment ──
// POST /command-hq/commanders/:sessionId/tags { tagId: number }
commandHqRouter.post('/commanders/:sessionId/tags', async (ctx) => {
const user = ctx.get('user');
const sessionId = ctx.req.param('sessionId');
const body = await ctx.req.json<{ tagId?: number }>().catch(() => ({}) as { tagId?: number });
if (!body.tagId) throw errors.BAD_REQUEST('tagId is required');
const row = await getCommandHqSession(user.id, sessionId);
if (!row) throw errors.NOT_FOUND('not a commander');
await addCommandHqSessionTag(row.id, body.tagId);
return ctx.json({ ok: true });
});
// DELETE /command-hq/commanders/:sessionId/tags/:tagId
commandHqRouter.delete('/commanders/:sessionId/tags/:tagId', async (ctx: Context) => {
const user = ctx.get('user');
const sessionId = ctx.req.param('sessionId');
const tagId = Number(ctx.req.param('tagId'));
const row = await getCommandHqSession(user.id, sessionId);
if (!row) throw errors.NOT_FOUND('not a commander');
await removeCommandHqSessionTag(row.id, tagId);
return ctx.json({ ok: true });
});
+2
View File
@@ -55,6 +55,7 @@ import { queueRouter } from './api/queue/queue';
import { bugReportRouter } from './api/bug-report/bug-report'; import { bugReportRouter } from './api/bug-report/bug-report';
import { agentStatusRouter } from './api/agent-status/router'; import { agentStatusRouter } from './api/agent-status/router';
import { chatRouter } from './api/chat/chat'; import { chatRouter } from './api/chat/chat';
import { commandHqRouter } from './api/command-hq/router';
import { pipelineJobsRouter } from './api/tasks/pipeline-jobs-routes'; import { pipelineJobsRouter } from './api/tasks/pipeline-jobs-routes';
import { CustomError } from './custom-errors'; import { CustomError } from './custom-errors';
import { userMiddleware, bodyParser, permissionGateMiddleware } from './_middlewares'; import { userMiddleware, bodyParser, permissionGateMiddleware } from './_middlewares';
@@ -156,6 +157,7 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
['/bug-report', bugReportRouter], ['/bug-report', bugReportRouter],
['/agent-status', agentStatusRouter], ['/agent-status', agentStatusRouter],
['/chat', chatRouter], ['/chat', chatRouter],
['/command-hq', commandHqRouter],
['/pipeline-jobs', pipelineJobsRouter], ['/pipeline-jobs', pipelineJobsRouter],
['/jobs', pipelineJobsRouter], // unified jobs API (script + pipeline); /pipeline-jobs kept for the existing UI ['/jobs', pipelineJobsRouter], // unified jobs API (script + pipeline); /pipeline-jobs kept for the existing UI
// ['/desktop', desktopRouter], // plugin — switched off 2026-08-13 // ['/desktop', desktopRouter], // plugin — switched off 2026-08-13
+13
View File
@@ -266,6 +266,19 @@ const CORE_REGISTRY: Permission[] = [
routes: ['/dashboards'], routes: ['/dashboards'],
personal: ['/'], personal: ['/'],
}, },
// Core because there is nothing to grant a level of: a commander is the caller's own session, in the
// caller's own directory, in a table scoped to the caller's own user_id at every query. Withholding
// this from a role would not restrict what they can reach, it would just hide their own conversations
// from a second list of the same conversations — the same reasoning `dashboards` above rests on.
{
key: 'command-hq',
label: 'Command HQ',
description: 'Long-running commanders you started, organised into categories',
kind: 'core',
api: ['/command-hq'],
routes: ['/command-hq'],
personal: ['/'],
},
// ── execution: never grantable ────────────────────────────────────────────────────────────────── // ── execution: never grantable ──────────────────────────────────────────────────────────────────
// Confined since 2026-08-11. A member's shell is spawned by the pty sidecar through `sudo setpriv` as their // Confined since 2026-08-11. A member's shell is spawned by the pty sidecar through `sudo setpriv` as their