diff --git a/docs/command-hq.md b/docs/command-hq.md
index c4b1c428..6e5dd18a 100644
--- a/docs/command-hq.md
+++ b/docs/command-hq.md
@@ -5,14 +5,27 @@ it, kept current as answers landed.
**Status:** data model, the full REST API including both creation paths (web and `POST
/commanders/new`), 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. A member also needs
-`confineUserTree` re-run once if their account predates tonight — see "A member could not reach it" under
-"Working directory". **Frontend not built yet on the web side.** The mobile side is further along —
-`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 commander, though it has not yet been exercised against a
-running server on either side. Every claim in this file is checked against the code, not aspirational —
-see "API reference" for what a client can call today.
+failures — none of them this feature's). `bun db:push` and a restart of `officer` have both since run
+against the dev machine, so the backend is live. A member still needs `confineUserTree` re-run once if
+their account predates that — see "A member could not reach it" under "Working directory".
+
+**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.
+
+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
+commander, though it has not yet been exercised against a running server on either side either. Every claim
+in this file is checked against the code, not aspirational — see "API reference" for what a client can call
+today.
## API reference — build against this
@@ -527,5 +540,8 @@ scope for tonight."_
## Still open
Nothing blocking as of this revision. `POST /command-hq/commanders/new` is built — see "How a commander is
-created" — with `prompt` required, confirmed with the owner 2026-08-18. Neither path (web or mobile) has
-been exercised against a running server yet; that is the next real test, not a design question.
+created" — with `prompt` required, confirmed with the owner 2026-08-18. The web frontend v1 is built too
+(see the status line at the top). **Neither client has actually been clicked through against a running
+server yet** — that is the next real test, not a design question, and it is the thing most likely to turn
+up something this file gets wrong: the composer's redirect-on-real-id, the watcher-disarm button, the
+category-race PATCH, none of them have run once outside a typecheck.
diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx
index 809e24a0..2158cef6 100644
--- a/src/apps/officer-web/App.tsx
+++ b/src/apps/officer-web/App.tsx
@@ -82,6 +82,8 @@ export function App() {
} />
} />
} />
+ } />
+ } />
} />
} />
} />
diff --git a/src/apps/officer-web/Screens/Dashboard/CommandHQ/CommandHqScreen.tsx b/src/apps/officer-web/Screens/Dashboard/CommandHQ/CommandHqScreen.tsx
new file mode 100644
index 00000000..4a9874eb
--- /dev/null
+++ b/src/apps/officer-web/Screens/Dashboard/CommandHQ/CommandHqScreen.tsx
@@ -0,0 +1,25 @@
+import type { LayoutNode } from 'officerdev';
+import { WorkspaceView } from 'officerdev';
+import { useDashboardState } from 'state/useDashboardState';
+import { defaultLayout } from './defaultLayout';
+
+// /command-hq: nav + grid + detail, all three always mounted (like /chat), selection carried by the URL
+// — `?category=` for the nav filter, `:sessionId` for the open commander. See `docs/command-hq.md` →
+// "Route, surface, placement" and "Layout, v1".
+
+export const CommandHqScreen = () => {
+ const workspace = useDashboardState('screens/command-hq', defaultLayout);
+
+ return (
+
+
+
+ );
+};
diff --git a/src/apps/officer-web/Screens/Dashboard/CommandHQ/defaultLayout.ts b/src/apps/officer-web/Screens/Dashboard/CommandHQ/defaultLayout.ts
new file mode 100644
index 00000000..74fba76d
--- /dev/null
+++ b/src/apps/officer-web/Screens/Dashboard/CommandHQ/defaultLayout.ts
@@ -0,0 +1,14 @@
+import type { LayoutNode } from 'officerdev';
+
+// Three columns, per `docs/command-hq.md` → "Layout, v1": category nav, commander grid, detail pane. The
+// detail pane gets the most room because it is a live chat transcript once something is selected.
+export const defaultLayout: LayoutNode = {
+ type: 'group',
+ id: 'command-hq-root',
+ direction: 'horizontal',
+ children: [
+ { node: { type: 'panel', id: 'command-hq-categories', appType: 'command-hq-categories' }, size: 16 },
+ { node: { type: 'panel', id: 'command-hq-grid', appType: 'command-hq-grid' }, size: 34 },
+ { node: { type: 'panel', id: 'command-hq-detail', appType: 'command-hq-detail' }, size: 50 },
+ ],
+};
diff --git a/src/apps/officer-web/Screens/Dashboard/CommandHQ/index.tsx b/src/apps/officer-web/Screens/Dashboard/CommandHQ/index.tsx
new file mode 100644
index 00000000..85a5b91c
--- /dev/null
+++ b/src/apps/officer-web/Screens/Dashboard/CommandHQ/index.tsx
@@ -0,0 +1 @@
+export * from './CommandHqScreen';
diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx
index ccaf691b..33ae6cc7 100644
--- a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx
@@ -154,6 +154,7 @@ import {
GitBranch,
Store,
Puzzle,
+ Radar,
} from 'lucide-react';
/**
@@ -171,6 +172,7 @@ export const CORE_DOCK_ITEMS: DockItem[] = [
{ label: 'Home', to: '/', icon: Home, color: '#f59e0b' },
{ label: 'Files', to: '/files', icon: FolderOpen, color: '#fbbf24' },
{ label: 'Chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' },
+ { label: 'Command HQ', to: '/command-hq', icon: Radar, color: '#f43f5e' },
{ label: 'Gitea', to: '/gitea', icon: GitBranch, color: '#609926' },
{ label: 'Editor', to: '/code-editor', icon: Code, color: '#a78bfa' },
{ label: 'Jobs', to: '/jobs', icon: Workflow, color: '#14b8a6' },
diff --git a/src/apps/officer-web/Screens/Dashboard/index.tsx b/src/apps/officer-web/Screens/Dashboard/index.tsx
index a0244019..52597f3e 100644
--- a/src/apps/officer-web/Screens/Dashboard/index.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/index.tsx
@@ -14,6 +14,7 @@ export * from './Files';
export * from './Calendar';
export * from './Contacts';
export * from './Soulseek';
+export * from './CommandHQ';
export * from './Photos';
export * from './Jellyfin';
export * from './Transmission';
diff --git a/src/apps/officer-web/state/usePageTitle.ts b/src/apps/officer-web/state/usePageTitle.ts
index 7a00eb16..a26b83b9 100644
--- a/src/apps/officer-web/state/usePageTitle.ts
+++ b/src/apps/officer-web/state/usePageTitle.ts
@@ -26,6 +26,7 @@ const RULES: TitleRule[] = [
{ match: (p) => p.startsWith('/photos'), title: 'Photos' },
{ match: (p) => p.startsWith('/jellyfin'), title: 'Video' },
{ match: (p) => p.startsWith('/soulseek'), title: 'Soulseek' },
+ { match: (p) => p.startsWith('/command-hq'), title: 'Command HQ' },
{ match: (p) => p.startsWith('/transmission'), title: 'Transmission' },
{ match: (p) => p.startsWith('/gitea'), title: 'Gitea' },
{ match: (p) => p.startsWith('/invoices'), title: 'Invoices' },
diff --git a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx
index a384ddb7..3d8f1061 100644
--- a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx
+++ b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx
@@ -20,6 +20,7 @@ import { appRegistryMetas as walletMetas } from '../apps/Wallet';
import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor';
import { appRegistryMetas as qrTransferMetas } from '../apps/QrTransfer';
import { appRegistryMetas as davMetas } from '../apps/Dav';
+import { appRegistryMetas as commandHqMetas } from '../apps/CommandHQ';
import type { QueryClient } from '@tanstack/react-query';
import { globalQueryKey } from 'hooks/useGlobal';
import { metasToRegistry } from './useAppRegistry';
@@ -44,6 +45,7 @@ export const apps = [
...monitorMetas,
...qrTransferMetas,
...davMetas,
+ ...commandHqMetas,
...appStoreMetas,
...pluginsMetas,
];
diff --git a/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqCategories.tsx b/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqCategories.tsx
new file mode 100644
index 00000000..93285ded
--- /dev/null
+++ b/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqCategories.tsx
@@ -0,0 +1,117 @@
+import { useState } from 'react';
+import { Link, useSearchParams } from 'react-router';
+import { Plus, Radar } from 'lucide-react';
+import { toast } from '@/components/ui/sonner';
+import { errorText } from 'helpers/error-text';
+import { categoryFromParam, commandHqListPath } from './command-hq-routes';
+import { useCommandHqCategories } from './useCommandHq';
+import type { CommandHqCategoryFilter } from './types';
+
+// Left panel of the /command-hq workspace — a flat category list, click to filter the grid. Deliberately
+// simple for v1: no nesting, no search, no drag-to-reorder — see `docs/command-hq.md` → "Layout, v1".
+// Entries are real ``s to `?category=…`, so back/forward and copy-paste work like any other filter.
+
+type RowProps = {
+ label: string;
+ active: boolean;
+ to: string;
+ count?: number;
+};
+
+const Row = ({ label, active, to, count }: RowProps) => (
+
+ {active && }
+ {label}
+ {count !== undefined && {count}}
+
+);
+
+export const CommandHqCategories = () => {
+ const [searchParams] = useSearchParams();
+ const active: CommandHqCategoryFilter = categoryFromParam(searchParams.get('category'));
+ const { categories, createCategory } = useCommandHqCategories();
+ const [adding, setAdding] = useState(false);
+ const [name, setName] = useState('');
+
+ const submitNew = async () => {
+ const trimmed = name.trim();
+ if (!trimmed) {
+ setAdding(false);
+ return;
+ }
+ try {
+ await createCategory.mutateAsync(trimmed);
+ setName('');
+ setAdding(false);
+ } catch (err) {
+ toast.error(errorText(err, 'could not create category'));
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
Command HQ
+
Watchers & long-running jobs
+
+
+
+
+
+
+ Categories
+
+
+
+
+
+ );
+};
diff --git a/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqDetail.tsx b/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqDetail.tsx
new file mode 100644
index 00000000..7451f588
--- /dev/null
+++ b/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqDetail.tsx
@@ -0,0 +1,251 @@
+import { useEffect, useState } from 'react';
+import { useNavigate, useParams, useSearchParams } from 'react-router';
+import { Radar, Unplug } from 'lucide-react';
+import { toast } from '@/components/ui/sonner';
+import { EditableTitle } from '@/components/EditableTitle';
+import { useClient } from 'hooks/useClient';
+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 { useCommandHqCategories, useCommandHqCommander, useCommandHqCommanderActions } from './useCommandHq';
+import { formatCommanderCost } from './format';
+
+// Right panel of the /command-hq workspace. Three states, chosen by the URL alone (no channel — panels
+// read the route directly, per `docs/navigation-audit.md`):
+//
+// no :sessionId → empty state, "select or start one"
+// :sessionId === 'new' → the composer that CREATES a commander (an ordinary chat turn with
+// `context: 'command-hq'`, per `docs/command-hq.md` → "How a commander is
+// created" — there is no separate "file this" step)
+// :sessionId is a real id → the commander's own transcript, resumed, plus a metadata bar
+
+const CHAT_TAIL = 20;
+
+function EmptyState() {
+ return (
+
+
+
Select a commander, or start a new one.
+
+ );
+}
+
+/**
+ * Starts a brand-new commander. `useChat` mints the transcript id on the harness's first response
+ * (`session:claude`) — once that lands, the URL is replaced onto the real id so a refresh resumes the
+ * same conversation instead of starting a second one. `replaceUrl: false` because `useChat`'s own permalink
+ * always writes `/chat/` (see `writePermalink` in `useChat.ts`), which is the wrong screen here.
+ */
+function NewCommander({ category }: { category: number | 'none' | undefined }) {
+ const navigate = useNavigate();
+ const chat = useChat(undefined, undefined, { replaceUrl: false, context: 'command-hq' });
+ const { setCategory } = useCommandHqCommanderActions(chat.sessionId ?? '');
+ const [filed, setFiled] = useState(false);
+
+ useEffect(() => {
+ if (!chat.sessionId || filed) return;
+ setFiled(true);
+ // `fileAsCommander` (websocket.ts) always files with no category — it has no way to know one was
+ // requested from here. The same race the `/commanders/new` HTTP route resolves server-side by always
+ // running its UPDATE last; done client-side here for the same reason, since the web composer goes
+ // through the websocket path instead.
+ if (typeof category === 'number') void setCategory.mutateAsync(category).catch(() => {});
+ navigate(commandHqSessionPath(chat.sessionId, category), { replace: true });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [chat.sessionId, filed]);
+
+ return (
+