command hq: delete a commander, from the grid card and the detail bar

DELETE /command-hq/commanders/:sessionId — deletes the transcript itself via
deleteClaudeSession (the same function DELETE /chat/sessions/:id calls, so a
/clear chain goes with it, every part), then the command_hq_sessions row via
deleteCommandHqSession (existing, exported, never previously called from a
route — its docstring said as much: "no product path to this yet").

Deliberately real deletion, not an unfile-back-to-/chat path — there still
isn't one of those, and this doesn't create one. Documented next to "The
/chat <-> /command-hq relationship" so it doesn't read as contradicting the
"a session never moves the other direction" rule directly above it.

Frontend: both the grid card and the detail bar's meta row get an inline
confirm (trash icon -> check/cancel), same shape as ChatHistory's own
SessionRow delete. The grid card's delete control is a sibling of the card's
<Link>, not nested inside it, per docs/navigation-audit.md — the card is a
stretched full-cover Link (absolute inset-0, default stacking) with the
delete control in its own stacking context (z-10) on top of it, so the card
stays click-anywhere except where the trash icon sits. Deleting the commander
currently open in the detail panel navigates back to the bare list rather
than leaving it pointed at a 404.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 10:46:31 +00:00
co-authored by Claude Opus 5
parent a335fad9a6
commit f77a41d26a
5 changed files with 228 additions and 64 deletions
+37 -27
View File
@@ -12,14 +12,17 @@ their account predates that — see "A member could not reach it" under "Working
**Web frontend v1 built 2026-08-18**, same session: `/command-hq` and `/command-hq/:sessionId`, three
panels per "Layout, v1" below (`command-hq-categories`, `command-hq-grid`, `command-hq-detail`) under
`officerdev/src/apps/CommandHQ/`, registered in `AppRegistry.tsx`, a `CORE_DOCK_ITEMS` tile, and a
`usePageTitle` rule. **Not yet exercised against a running server** — typechecks and the suite is at
baseline, but nobody has clicked through it live. The composer creates a commander the same way `/chat`
always has (an ordinary turn with `context: 'command-hq'`); once the harness reports a transcript id the
URL is replaced onto it, and if a category was selected when composing, a `PATCH` sets it client-side —
`fileAsCommander` itself always files uncategorised, the same race `POST /commanders/new` resolves
server-side for mobile, resolved here the client-side equivalent. Rename, category and disarm all go
through Command HQ's own `PATCH` route, never `/chat`'s — the two are scoped to different directories. Tag
UI is still deliberately absent, per "Tags — infrastructure now, UI not specified" below.
`usePageTitle` rule. **Now exercised against a running server** — a real commander created, categorised,
armed as a watcher and observed running, its auto-generated name traced back to the actual process on this
machine (see "Auto-naming" and the `>/dev/null` finding below). The composer creates a commander the same
way `/chat` always has (an ordinary turn with `context: 'command-hq'`); once the harness reports a
transcript id the URL is replaced onto it, and if a category was selected when composing, a `PATCH` sets it
client-side — `fileAsCommander` itself always files uncategorised, the same race `POST /commanders/new`
resolves server-side for mobile, resolved here the client-side equivalent. Rename, category, disarm and
delete all go through Command HQ's own routes, never `/chat`'s — the two are scoped to different
directories. Delete asks for confirmation inline (grid card and detail bar both), same shape as `/chat`'s
own session-row delete. Tag UI is still deliberately absent, per "Tags — infrastructure now, UI not
specified" below.
The mobile side is further along on live exercise — `apps/commandhq` exists and typechecks against this
router (see `COMMS/command-hq/mobile.md`) — and as of this revision has what it asked for to create a
@@ -33,25 +36,26 @@ Base path `/command-hq`, `core` permission (every signed-in account, no grant to
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 |
| POST | `/commanders/new` | `{prompt, displayText?, categoryId?}` | creates a commander; `prompt` required. `displayText ?? prompt` titles it, same precedence as the websocket. `-> {sessionId, cwd}` — `sessionId` is Claude's real transcript id, not a placeholder |
| GET | `/commanders/:sessionId` | — | Command HQ's own metadata only — NOT the transcript, see below |
| GET | `/commanders/:sessionId/turns` | — | `{turns: [...]}` — the raw per-turn cost ledger, oldest first. What "cost over time" plots from |
| GET | `/commanders/:sessionId/fires` | — | `{fires: [...]}` — every trigger, oldest first. The full history behind `watcherLastFiredAt` |
| POST | `/commanders` | `{sessionId, categoryId?}` | files an EXISTING session; idempotent; 404 if the session isn't in the commanders directory. Not the creation path — see `/commanders/new` |
| PATCH | `/commanders/:sessionId` | `{categoryId?, name?, watcherArmed?}` | `name` proxies to `renameClaudeSession`; `watcherArmed: false` disarms (arming has no route — needs a live task id) |
| POST | `/purge` | — | sweeps rows whose transcript no longer resolves; returns `{removed}` |
| POST | `/commanders/:sessionId/tags` | `{tagId}` | |
| DELETE | `/commanders/:sessionId/tags/:tagId` | — | |
| 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 |
| POST | `/commanders/new` | `{prompt, displayText?, categoryId?}` | creates a commander; `prompt` required. `displayText ?? prompt` titles it, same precedence as the websocket. `-> {sessionId, cwd}` — `sessionId` is Claude's real transcript id, not a placeholder |
| GET | `/commanders/:sessionId` | — | Command HQ's own metadata only — NOT the transcript, see below |
| GET | `/commanders/:sessionId/turns` | — | `{turns: [...]}` — the raw per-turn cost ledger, oldest first. What "cost over time" plots from |
| GET | `/commanders/:sessionId/fires` | — | `{fires: [...]}` — every trigger, oldest first. The full history behind `watcherLastFiredAt` |
| POST | `/commanders` | `{sessionId, categoryId?}` | files an EXISTING session; idempotent; 404 if the session isn't in the commanders directory. Not the creation path — see `/commanders/new` |
| PATCH | `/commanders/:sessionId` | `{categoryId?, name?, watcherArmed?}` | `name` proxies to `renameClaudeSession`; `watcherArmed: false` disarms (arming has no route — needs a live task id) |
| DELETE | `/commanders/:sessionId` | — | deletes the TRANSCRIPT (`/clear` chain and all) via `deleteClaudeSession`, then the row. Not reversible, and not the same thing `/purge` does — see "The `/chat` ↔ `/command-hq` relationship" below |
| 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 Command HQ's own metadata: `{sessionId, categoryId,
@@ -346,6 +350,12 @@ an independent record of what sessions exist. Consequences the owner confirmed e
- 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.
- **`DELETE /commanders/:sessionId`, added 2026-08-18, is not an unfile path — it is real deletion**, and
deliberately does not conflict with the bullet above: the commander does not become a `/chat` session, it
stops existing. `deleteClaudeSession` (the SAME function `DELETE /chat/sessions/:id` calls) removes the
transcript — a `/clear` chain and all — and `deleteCommandHqSession` removes the row. Once the confirm
step lands, this is not recoverable: there is no undo, no trash, and the row disappearing is a side
effect of the transcript being gone, not the other way around.
## Built on, mostly unchanged