From eb94af87f4b3e39811767086be3b6ecfe951c4c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 18 Aug 2026 06:10:50 +0000 Subject: [PATCH] command hq: the api, mounted and documented MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/command-hq.md | 50 ++++- src/servers/api/chat/chat.ts | 20 +- src/servers/api/chat/websocket.ts | 6 +- src/servers/api/command-hq/router.ts | 324 +++++++++++++++++++++++++++ src/servers/hono.ts | 2 + src/servers/permissions/registry.ts | 13 ++ 6 files changed, 410 insertions(+), 5 deletions(-) create mode 100644 src/servers/api/command-hq/router.ts diff --git a/docs/command-hq.md b/docs/command-hq.md index 07c52a68..8d57082e 100644 --- a/docs/command-hq.md +++ b/docs/command-hq.md @@ -1,7 +1,55 @@ # 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. +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=\|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 diff --git a/src/servers/api/chat/chat.ts b/src/servers/api/chat/chat.ts index 467bc2a9..3fcde09b 100644 --- a/src/servers/api/chat/chat.ts +++ b/src/servers/api/chat/chat.ts @@ -4,7 +4,7 @@ import { pauseBackgroundTask, restartBackgroundTask } from './task-restart'; import { createRouter } from '../../create-router'; import * as errors from '@@/custom-errors'; import * as sidecar from '@@/sidecar-registry'; -import { getUserSettings, getChatSessionModel, getChatSessionModels } from 'officerdb'; +import { getUserSettings, getChatSessionModel, getChatSessionModels, listCommandHqSessionIds } from 'officerdb'; import { listClaudePwds, listClaudeSessions, @@ -109,7 +109,8 @@ chatRouter.get('/pwds', async (ctx) => { // GET /chat/sessions[?cwd=] — conversations for a working directory, merged across both harnesses // (Claude transcripts + OpenCode's session store), newest first. 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); // `?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 // 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 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()); + const sessions = merged.filter((s) => !filed.has(s.id)); /* The model each session is on, batched. diff --git a/src/servers/api/chat/websocket.ts b/src/servers/api/chat/websocket.ts index 32b6eeea..17b062fe 100644 --- a/src/servers/api/chat/websocket.ts +++ b/src/servers/api/chat/websocket.ts @@ -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. * 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. + * + * 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(); for (const event of events) { if (event.type === 'task:started') { diff --git a/src/servers/api/command-hq/router.ts b/src/servers/api/command-hq/router.ts new file mode 100644 index 00000000..be400e3c --- /dev/null +++ b/src/servers/api/command-hq/router.ts @@ -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 { + 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 { + const events = await getChatEventsSince(sessionId, 0); + return collectRunningTasks(events.map((e) => e.event as ServerMessage)).length > 0; +} + +/** + * GET /command-hq/commanders[?category=|none] + * + * `category` omitted → every commander, categorised or not. `category=none` → only the uncategorised + * bucket. `category=` → 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 }); +}); diff --git a/src/servers/hono.ts b/src/servers/hono.ts index ae7fe76a..34d60027 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -55,6 +55,7 @@ import { queueRouter } from './api/queue/queue'; import { bugReportRouter } from './api/bug-report/bug-report'; import { agentStatusRouter } from './api/agent-status/router'; import { chatRouter } from './api/chat/chat'; +import { commandHqRouter } from './api/command-hq/router'; import { pipelineJobsRouter } from './api/tasks/pipeline-jobs-routes'; import { CustomError } from './custom-errors'; import { userMiddleware, bodyParser, permissionGateMiddleware } from './_middlewares'; @@ -156,6 +157,7 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType ['/bug-report', bugReportRouter], ['/agent-status', agentStatusRouter], ['/chat', chatRouter], + ['/command-hq', commandHqRouter], ['/pipeline-jobs', pipelineJobsRouter], ['/jobs', pipelineJobsRouter], // unified jobs API (script + pipeline); /pipeline-jobs kept for the existing UI // ['/desktop', desktopRouter], // plugin — switched off 2026-08-13 diff --git a/src/servers/permissions/registry.ts b/src/servers/permissions/registry.ts index faf60599..71928e18 100644 --- a/src/servers/permissions/registry.ts +++ b/src/servers/permissions/registry.ts @@ -266,6 +266,19 @@ const CORE_REGISTRY: Permission[] = [ routes: ['/dashboards'], 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 ────────────────────────────────────────────────────────────────── // Confined since 2026-08-11. A member's shell is spawned by the pty sidecar through `sudo setpriv` as their