diff --git a/docs/command-hq.md b/docs/command-hq.md index 5a482d22..78078336 100644 --- a/docs/command-hq.md +++ b/docs/command-hq.md @@ -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=\|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=\|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 diff --git a/src/servers/api/command-hq/router.ts b/src/servers/api/command-hq/router.ts index c37e0850..741af8a7 100644 --- a/src/servers/api/command-hq/router.ts +++ b/src/servers/api/command-hq/router.ts @@ -10,6 +10,7 @@ import { listClaudeSessions, loadClaudeSessionById, renameClaudeSession, + deleteClaudeSession, type ChatIdentity, type ClaudeSessionSummary, } from '../chat/claude-sessions'; @@ -24,6 +25,7 @@ import { getCommandHqSession, setCommandHqSessionCategory, purgeCommandHqSessions, + deleteCommandHqSession, disarmCommandHqWatcher, getCommandHqCostSummary, listCommandHqTurns, @@ -446,6 +448,34 @@ commandHqRouter.patch('/commanders/:sessionId', async (ctx) => { return ctx.json({ ok: true }); }); +/** + * DELETE /command-hq/commanders/:sessionId + * + * Deletes the commander outright: the transcript itself (via `deleteClaudeSession`, the same function + * `DELETE /chat/sessions/:id` calls — a `/clear` chain goes with it, every part), then the + * `command_hq_sessions` row. Unlike everywhere else in this file, this is NOT the "row disagrees with + * disk, so hide it" pattern `purgeCommandHqSessions` exists for — it is the one place that makes the two + * agree by deleting the thing itself, on purpose, because the owner asked to. + * + * A watcher still armed at the time of deletion is left as-is rather than disarmed first: the transcript + * is gone either way, `armCommandHqWatcher`/`recordCommandHqWatcherFired` can no longer find a row to + * update once this returns, and the running background process (if any) dies on its own the same way it + * would if the harness process were killed out from under any other conversation — not a new failure + * mode this route has to account for. + */ +commandHqRouter.delete('/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'); + + deleteClaudeSession(who, getCommandersDir(who.email), sessionId); + await deleteCommandHqSession(user.id, sessionId); + return ctx.json({ ok: true }); +}); + /** * POST /command-hq/purge * diff --git a/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqDetail.tsx b/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqDetail.tsx index dd98ee51..a02fcc91 100644 --- a/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqDetail.tsx +++ b/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqDetail.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; import { useNavigate, useParams, useSearchParams } from 'react-router'; -import { Radar, Unplug } from 'lucide-react'; +import { Check, Radar, Trash2, Unplug, X } from 'lucide-react'; import { toast } from '@/components/ui/sonner'; import { EditableTitle } from '@/components/EditableTitle'; import { useClient } from 'hooks/useClient'; @@ -8,7 +8,7 @@ import { errorText } from 'helpers/error-text'; import type { ClaudeSessionDetail } from 'state/useClaudeSessions'; import { useChat, EmbeddableChat } from '../Chat'; import type { ChatMessage } from '../Chat/types'; -import { categoryFromParam, commandHqSessionPath } from './command-hq-routes'; +import { categoryFromParam, commandHqListPath, commandHqSessionPath } from './command-hq-routes'; import { useCommandHqCategories, useCommandHqCommander, useCommandHqCommanderActions } from './useCommandHq'; import { formatCommanderCost, formatTokens } from './format'; @@ -75,9 +75,13 @@ type MetaBarProps = { }; function MetaBar({ sessionId, title, isConnected, isGenerating, onDisconnect }: MetaBarProps) { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const category = categoryFromParam(searchParams.get('category')); const { categories } = useCommandHqCategories(); const { commander } = useCommandHqCommander(sessionId); - const { setCategory, rename, disarmWatcher } = useCommandHqCommanderActions(sessionId); + const { setCategory, rename, disarmWatcher, deleteCommander } = useCommandHqCommanderActions(sessionId); + const [confirmingDelete, setConfirmingDelete] = useState(false); const commitRename = async (next: string) => { try { @@ -87,6 +91,16 @@ function MetaBar({ sessionId, title, isConnected, isGenerating, onDisconnect }: } }; + const confirmDelete = async () => { + setConfirmingDelete(false); + try { + await deleteCommander.mutateAsync(); + navigate(commandHqListPath(category)); + } catch (err) { + toast.error(errorText(err, 'could not delete this commander')); + } + }; + return (
@@ -118,6 +132,39 @@ function MetaBar({ sessionId, title, isConnected, isGenerating, onDisconnect }: )} + {confirmingDelete ? ( + + Delete? + + + + ) : ( + + )}
diff --git a/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqGrid.tsx b/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqGrid.tsx index c92ce5fe..03f15c7d 100644 --- a/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqGrid.tsx +++ b/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqGrid.tsx @@ -1,10 +1,13 @@ -import { Link, useParams, useSearchParams } from 'react-router'; +import { useState } from 'react'; +import { Link, useNavigate, useParams, useSearchParams } from 'react-router'; import { formatDistanceToNowStrict } from 'date-fns'; -import { Plus } from 'lucide-react'; -import { categoryFromParam, commandHqNewPath, commandHqSessionPath } from './command-hq-routes'; -import { useCommandHqCommanders } from './useCommandHq'; +import { Check, Plus, Trash2, X } from 'lucide-react'; +import { toast } from '@/components/ui/sonner'; +import { errorText } from 'helpers/error-text'; +import { categoryFromParam, commandHqListPath, commandHqNewPath, commandHqSessionPath } from './command-hq-routes'; +import { useCommandHqCommanderActions, useCommandHqCommanders } from './useCommandHq'; import { formatCommanderCost, formatTokens } from './format'; -import type { CommandHqCommander } from './types'; +import type { CommandHqCategoryFilter, CommandHqCommander } from './types'; // Right-middle panel of the /command-hq workspace — one card per commander in the selected category. // Clicking a card opens the detail panel via the URL (`/command-hq/:sessionId`), never a channel — see @@ -22,41 +25,105 @@ function StatusDot({ running, watcherArmed }: { running: boolean; watcherArmed: return ; } -type CardProps = { commander: CommandHqCommander; to: string; selected: boolean }; +type CardProps = { + commander: CommandHqCommander; + to: string; + selected: boolean; + onDeleted: (sessionId: string) => void; +}; -const Card = ({ commander, to, selected }: CardProps) => ( - -
- - {commander.title} -
-
- - {commander.cost.turnCount} turn{commander.cost.turnCount === 1 ? '' : 's'} ·{' '} - {formatTokens(commander.cost.inputTokens + commander.cost.outputTokens)} tok ·{' '} - {formatCommanderCost(commander.cost.totalUsd)} - - {commander.watcherLastFiredAt ? ( - - fired {formatDistanceToNowStrict(new Date(commander.watcherLastFiredAt), { addSuffix: true })} +/** + * The delete control is a SIBLING of the card's ``, not a child of it — a ` + +
+ ) : ( + + )} - -); + ); +}; export const CommandHqGrid = () => { const [searchParams] = useSearchParams(); - const category = categoryFromParam(searchParams.get('category')); + const category: CommandHqCategoryFilter = categoryFromParam(searchParams.get('category')); const { sessionId } = useParams<{ sessionId: string }>(); const { commanders, isLoading } = useCommandHqCommanders(category); + const navigate = useNavigate(); + + // Deleting the one open in the detail panel leaves that panel pointed at a session that no longer + // exists — back out to the bare list rather than let it try (and fail) to resolve a 404. + const handleDeleted = (deletedId: string) => { + if (deletedId === sessionId) navigate(commandHqListPath(category)); + }; return (
@@ -80,6 +147,7 @@ export const CommandHqGrid = () => { commander={commander} to={commandHqSessionPath(commander.id, category)} selected={commander.id === sessionId} + onDeleted={handleDeleted} /> ))}
diff --git a/src/workspaces/officerdev/src/apps/CommandHQ/useCommandHq.ts b/src/workspaces/officerdev/src/apps/CommandHQ/useCommandHq.ts index 86aaaced..5ac33f0b 100644 --- a/src/workspaces/officerdev/src/apps/CommandHQ/useCommandHq.ts +++ b/src/workspaces/officerdev/src/apps/CommandHQ/useCommandHq.ts @@ -68,7 +68,7 @@ export function useCommandHqCommander(sessionId: string | undefined) { * near-identical ones, since every caller needs the same invalidation regardless of which field changed. */ export function useCommandHqCommanderActions(sessionId: string) { - const { patch } = useClient(); + const { patch, delete: del } = useClient(); const qc = useQueryClient(); const invalidate = () => { @@ -91,5 +91,14 @@ export function useCommandHqCommanderActions(sessionId: string) { onSuccess: invalidate, }); - return { setCategory, rename, disarmWatcher }; + const deleteCommander = useMutation({ + mutationFn: () => del(`/command-hq/commanders/${sessionId}`), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['command-hq', 'commanders'] }); + // Removed, not invalidated — refetching a deleted commander's own detail query would just 404. + qc.removeQueries({ queryKey: commanderKey(sessionId) }); + }, + }); + + return { setCategory, rename, disarmWatcher, deleteCommander }; }