# AGENTS.md Guidance for any coding agent working in the Officer platform repo. **This is the file with the instructions in it**; `CLAUDE.md` beside it only points here. Both exist because the two harnesses discover different names — opencode walks the tree for `AGENTS.md` and nothing else, Claude Code for `CLAUDE.md` — and one set of instructions must reach both. ## Orientation Officer is built around **one owner** (user id 1, role `Super Admin`, who bypasses every permission check), which since 2026-08-07 also admits **additional accounts holding a strict subset of it**. What each may reach is decided by per-role permission grants, resolved on every request. If a design question turns on "which user", the answer depends on the surface: real for the **app** permissions (gitea, music, photos, calendar…), and still always **the owner** for anything that executes code or touches the disk — terminal, chat, tasks, files, desktop and browser are `kind: 'execution'` and can never be granted. `src/servers/permissions/registry.ts` is the authority. **Mounting a router without a registry entry makes the server refuse to boot.** Read the Permissions section below before adding one. This repo is one of two. The other, `capabilities/`, holds the agent's tasks, tools and skills as plain files, and is where many changes belong — adding or changing a task needs no code change here and no restart. - [`docs/working-on-officer.md`](docs/working-on-officer.md) — the deployment-wide guide: which directory a change belongs in, how to run and verify it, the task system, and the failure modes worth knowing about - `CONVENTIONS.md` — component organisation and React patterns, with rationale - `TODO.md` — current direction; takes precedence where it disagrees with anything else Treat the code as the source of truth where anything disagrees with it. ## Project Overview Officer is a self-hosted platform built around one person — the server owner — which since 2026-08-07 also admits **additional accounts holding a strict subset of it**. It bundles an AI agent, a terminal, a file browser, a code editor, a bitcoin wallet, a remote desktop and customisable dashboards behind a single web app. **The owner/member split, and where the line falls.** This file said "single-user is a hard invariant, not a stage" until 2026-08-07. That is no longer true and had already stopped being true when it was written: `users` holds six rows. The accurate statement is narrower and more useful — - **One owner.** User id 1, role `Super Admin`, created by `POST /auth/bootstrap` while the table is empty, pinned there by a CHECK constraint. The owner bypasses every permission check. - **Other accounts get only what their ROLE is granted.** Roles are `Admin`, `Member`, `Developer`; grants live in `role_permissions`, keyed on role, never on user. Absence denies — there is no row meaning "no", so an empty table is a server where members reach nothing but their own profile. - **Some things can never be shared, structurally.** Tasks, items, desktop and browser are `kind: 'execution'`: they run as the owner's OS user in the owner's home, so there is no level of "read" that makes them safe. They have no level at all and the grants API refuses to store one. - **And some are shared only because the kernel enforces it.** Terminal, chat and files are `kind: 'confined'`, added 2026-08-11 with per-user Linux accounts. They still touch the filesystem and still run processes — but not the _owner's_, because the account has its own Linux user, its own home, and the kernel refusing everything above it. The distinction earns its keep in one place: **a confined grant means nothing without that Linux user.** `authorize.ts` drops it for an account whose `osUser` is null, so "granted but unconfined" resolves to no access rather than to the owner's home — which is what it would otherwise resolve to, since `getOwnerHomeDir` ignores the email it is passed. That rule lives there once and covers the HTTP routes, the websocket doors and the dock together. So "which user is this" has a real answer for the **app** surface (gitea, music, photos, calendar…) and for the **confined** one (terminal, chat, files), and is still always "the owner" for anything under `execution`. `src/servers/permissions/registry.ts` is the authority and reads as the design document for this. **Mounting a router without a registry entry makes the server refuse to boot** — see "Permissions" below before adding one. **Account creation exists.** `POST /api/users` (owner-gated), with a form under Settings → User Management: it validates the password with the same rules bootstrap uses, refuses a role that is not a row in `roles`, refuses `Super Admin` outright because there is exactly one owner, optionally takes an inbound SSH key, and provisions the Linux account. `POST /users/:id/provision-linux` retries that half if the host was not ready. This file said "there is no way to create a second account" for some days after `69a31051` shipped one, and `docs/roles-and-teams.md` sequenced a whole phase around building it. Neither is checkable by `bun run docs:check` — it compares numbers, and this was prose. Worth knowing as the shape of the failure: the counts stay right and the sentences rot. ## Architecture One Bun process (`src/server.tsx`) serves everything: - the React SPA, via Bun's HTML import of `src/apps/officer-web/index.html` (HMR in dev) - the REST API, a Hono app mounted at `/api` (`src/servers/hono.ts`) - eight WebSocket providers — terminal, chat, task-runner, pipeline, cliamp, cliamp-audio, desktop, vault — plus a sidecar registration socket. `terminal` is a byte relay onto the pty sidecar's own listener, not a translating bridge; `vault` is the same shape onto Vaultwarden's notifications hub. - ~~a browser relay on its own port~~ — switched off 2026-08-13, awaiting extraction into a plugin. The extension and `api/browser/` stay on disk; the listener and the `/api/browser` mount do not. Long-running and privileged work lives in **sidecars**: separate processes that dial back in over `/api/sidecar/register` and are tracked in `src/servers/sidecar-registry.ts`. PM2 runs them (the generated `ecosystem.config.cjs` — see below): `officer` (the server), `officer-anthropic-proxy`, `officer-claude-code`, `officer-opencode`, `officer-pty`, `officer-vnc`, `officer-music`, `officer-vault`, `officer-slskd`, `officer-headscale`, `officer-transmission`, `officer-invoiceshelf`, `officer-wallet`, `officer-photos`, `officer-notify`, `officer-caldav`, `officer-memos`, `officer-jellyfin`, `officer-gitea` — twenty as of 2026-08-06, and a list that goes stale every time a sidecar lands. `pm2 jlist` is the source of truth. **`officer-anthropic-proxy` and `officer-claude-code` are not the same thing.** (The second was called `officer-agent` until 2026-08-13; older docs use that name.) The proxy holds the Anthropic credential and forwards API traffic; the agent is the process that spawns `claude`. They were one entry named `officer-claude` until the sidecar-isolation work — which is exactly how the false claim that "restarting officer doesn't disturb the agent" survived so long. Every sidecar is a PM2 peer of `officer`, so **no sidecar is a child of the server and restarting the server does not kill one.** Agents run **unsandboxed as the server owner**, with `--dangerously-skip-permissions`. This is deliberate — it is the owner's own machine. Do not add a jail without being asked. ## Repository Layout ``` src/ ├── server.tsx # the single entrypoint: routes, WS upgrades, static files ├── apps/ │ └── officer-web/ # the SPA shell — Screens/Authentication + Screens/Dashboard ├── servers/ │ ├── hono.ts # router composition; everything under /api │ ├── _middlewares/ # auth, body parsing, the permission gate, rate limiting │ ├── api// # one folder per feature, each exporting a router │ ├── channels/ # send-claude-code / send-opencode — how /chat drives an agent turn │ ├── queue/ # background job engine │ └── sidecar/ # sidecar implementations + the wire protocol ├── databases/officer_db/ # the only database (Postgres + Drizzle) ├── extensions/browser-relay/ └── workspaces/ # shared packages, each a bun workspace ``` `src/workspaces/officerdev` is the biggest of these: the windowed "apps" (FileBrowser, Chat, Terminal, CodeEditor, Desktop, Dashboards, Wallet…) that the shell hosts, behind an `AppRegistry`. `src/apps/officer-web` is only the shell — screens, routing and settings. **Path aliases** (`tsconfig.json`): `@/*` → `src/apps/officer-web`, `@/components/*` → `src/workspaces/components`, `@@/*` → `src/servers`, `@/public/*` → `public`. Workspace packages are imported by their package name (`officerdev`, `hooks`, `state`, `types`, `helpers`, …). ## Tech Stack - **Runtime**: Bun (Node 22 or newer is enforced by a `preinstall` check) That check demanded _exactly_ 22 until 2026-08-12. The reason was a `node-pty` build failure some months earlier, whose details were not recorded. It was relaxed to `>= 22` after confirming node-pty ships **no Linux prebuilds** — its install script always falls through to `node-gyp rebuild`, so it compiles against whatever Node is present and there is no ABI to mismatch. Untested on 24 at the time of the change. If `bun install` fails building node-pty, or `officer-pty` cannot load its native module, restore the exact pin first. The source build also needs `build-essential` and `python3`. **Bun itself is held at the 1.3 series, on purpose and temporarily.** 1.4.0 was published 2026-08-20; the owner asked on 2026-08-22 to stay on 1.3 for a few months until 1.4 has been watched. Two places carry that number and they move together: `BUN_SERIES` in `scripts/setup/machine-setup/lib/dev.sh`, which resolves the newest `1.3.x` from the npm registry and passes it to the installer as a release tag, and `engines.bun` in `package.json`, which is what `bun run doctor` reads. bun ignores `engines` (verified — an install declaring `1.9.x` ran clean under 1.3.14), so that field is the declaration and doctor is the enforcement. **Do not run `bun upgrade` on a machine running Officer.** It takes no version argument — latest stable or `--canary`, nothing else — so it jumps straight past the pin and says nothing. That is the whole reason `doctor` checks the running version at all; `machine-setup.sh --only 'JavaScript runtimes'` offers to pin a drifted machine back down. Lifting the hold is `todo/infra/`. - **Language**: TypeScript, strict. `bunx tsgo` is clean — keep it that way. - **Frontend**: React 19, React Router 7, React Query, Tailwind 4, shadcn/ui + custom components - **Backend**: Hono - **Database**: Postgres via Drizzle — `officer` for the platform, plus `officer_marketplace` on the host that serves marketplace.officer.dev (see `docs/marketplace.md`) - **Agents**: Claude Code and opencode, driven through sidecars; tools exposed over MCP ## Data Two stores, and the split matters: **Postgres** (`src/databases/officer_db`) holds the account, passkeys, settings, dashboards, queue and pipeline jobs. Since 2026-08-16 there is a SECOND database, `officer_marketplace` (`src/databases/marketplace_db`), and the split is deliberate: it is a public catalogue served by a process with no login, no session and no user, so sharing a connection would put an unauthenticated surface one query away from every private table. It runs only on the host serving marketplace.officer.dev — `docs/marketplace.md`. One directory per feature holding `schema.ts` and `queries.ts` beside each other; `src/schema.ts` is what `db:push` reads, and it lists the core tables with the plugin ones commented out. Types inferred from the schema in `src/types.ts`. **The filesystem** holds everything the agent authors. `OFFICER_ITEMS_DIR` (`$OFFICER_ROOT/capabilities`) contains one directory per item under `skills/`, `tools/`, `tasks/`, `processes/`, `extensions/` — no database rows, no scope tiers. `DATA_PATH//` holds the managed home and attachments. It also holds `email_accounts/`, which is the **email plugin's** — the platform pre-creates the directory and never opens what is inside it. **None of those paths is configured.** Since 2026-08-13 `src/servers/data-path.ts` derives the install root as `resolve(process.cwd(), '..')` and hangs `data/`, `permissions/` and `dockers/` off it. That replaced `DATA_PATH`, `OFFICER_ITEMS_DIR` and `HOME_DIR` in `.env` — three values that had to agree with each other and with the tree on disk. `assertInstallLayout` refuses to boot when the working directory is not the repo, because otherwise a wrong `cwd` relocates the whole install silently rather than failing. Note `getHomeDir` (the managed home under `DATA_PATH`, now used only for NON-owner accounts and by pipeline-executor) versus `getOwnerHomeDir` (the owner's real login home, where terminals, chats and task runs execute — captured from `homedir()` once at module load, and it ignores the email it is passed). ### Schema changes use `push`, not migrations This database is kept in sync with `bun db:push`, which diffs the schema code against the live database and alters it directly. **`drizzle-kit migrate` has never been run here** — there is no `__drizzle_migrations` table. Change the schema, run `bun db:push`, done. `bun db:gen` writes files to `officer_db/migrations/`, but nothing applies them; the old numbered history was deleted because it had drifted from the real schema. Treat the schema code, not those files, as the source of truth. **Declare multi-column uniqueness as `uniqueIndex('uq_…').on(a, b)`, never `unique('uq_…').on(a, b)`** — drizzle-kit mis-diffs named composite unique _constraints_ and re-creates them on every push, which used to stop `db:push` on an unanswerable truncate prompt. Same for any foreign key whose generated name would exceed Postgres's 63-character identifier limit: name it explicitly. See `src/databases/CLAUDE.md` → "Composite keys" before adding either. ## Security Model - `IS_DEV_BUILD` (`src/servers/build-env.ts`) is true **only** when `PUBLIC_BUILD_ENV` is explicitly `dev`/`development`. Everything else, including unset, is hardened. Rate limiting and password rules key off it — they fail closed. - **There is no origin checking.** It was removed on 2026-08-13, along with `ALLOW_ANY_ORIGIN` and `ALLOW_ANY_ORIGIN_MUSIC`. The flag defaulted to ON, so origin validation ran on no real install — what came out was documented defence in depth that was already switched off. Origin was never authentication here anyway: an app's `officer://` origin is chosen by the client, forgeable outside a browser, and extractable from a shipped binary. The perimeter is the tailnet, and the lock is a valid token on every protected route plus the permission gate below. - JWTs are 30-day, blacklisted on signout, and invalidated by a password change (`passwordChangedAt`). **The role is deliberately not a claim** — every authorization decision re-reads `users.role` from Postgres, so a grant or a revoke takes effect on the next request rather than at next sign-in. - A panic lockdown (`src/servers/api/auth/panic.ts`) is in-memory only and refuses every authenticated request until the server restarts. ### Permissions — read this before mounting a router Authorization is one system, and it is not in `userMiddleware` (which only answers "is this token valid"). It is `_middlewares/permission-gate.ts` → `permissions/authorize.ts`, mounted globally in `hono.ts` ahead of everything, and it re-verifies the token itself so it covers routes that never mount `userMiddleware`. - `permissions/registry.ts` — the single enumeration of what the platform can do, in five kinds: `core` (every account, not deniable), `app` (**the grantable surface**), `confined` (grantable, but only to an account that has a Linux user), `execution` and `admin` (owner only, and `execution` is never grantable at any level). **21 entries as of 2026-08-27** — verified by `bun run docs:check`, which fails if this number drifts again: 6 core, 2 app, 4 confined, 3 execution, 6 admin (`todos`, the todo board, is the sixth). It was 22 until caldav became a plugin and took `calendar` with it — a plugin's permission is registered at runtime from its manifest, so it is real but not in this file's count. `notify` moved from `app` to `core` on 2026-08-24: it was grantable, nothing granted it, and every Member's app was silently 403'd registering its own push token. It said 27 for nine days. The execution count dropped by one when Minesweeper became a plugin — see `plugins/minesweeper/manifest.ts` for why that was a fix rather than a downgrade. - `permissions/authorize.ts` — resolves "may this account do this". Owner short-circuits first; every other answer is role grants plus core, with `execution`/`admin` stripped even if a row grants them, and `confined` stripped for an account with no `osUser`. **Every catch returns deny.** Grants are cached by role and the cache's whole invalidation contract is `invalidateRoleGrants`, called by the one writer in `api/users/permissions-routes.ts`. - `permissions/totality.ts` — `assertPermissionTotality` runs in `server.tsx` **before `serve()` and throws**. Mount a router or a socket without a registry entry and `pm2 restart officer` fails, naming what is missing. That is deliberate: the hole it closes was a Member 403'ing on `GET /api/tasks` and opening `/api/tasks/pipeline/ws` with a 101 in the same minute, because Bun's route table matches the socket before the `/api/*` catch-all that reaches Hono. A patch does not survive the next door; refusing to boot does. So **adding a router means adding one line to `CAPABILITIES`**. If the surface genuinely is not user-gated, add it to `EXEMPT_API_PREFIXES` in `totality.ts` _with a reason_ — an unexplained exemption is how the hole happened the first time. The frontend hook `usePermissions` **fails open** on purpose: hiding a dock icon is a courtesy, the 403 is the lock, and an owner locked out by a transient network error is worse than a member clicking into a refusal. ## Commands ```bash bun dev # the whole app — SPA + API + WebSockets, watched bun start # production bunx tsgo # typecheck (not tsc) bun test # tests bun format # prettier over every dirty file — see the note below before running it bun db:push # apply the schema to Postgres bun docs:check # do the docs still match the code? REQUIRED before tagging a release bun setup # runs scripts/install.sh — blank machine to running platform ``` **`bun docs:check` before every release, and leave it green.** It compares documented claims against values derived at run time — the permission count, the PM2 entry count, every settings route Spotlight offers — and exits non-zero on drift. A check must never compare a doc against a second hard-coded copy; that is one more place to drift, and it drifts in the same edit that misses the doc. It is narrow on purpose. It cannot read prose and judge it, only claims with a machine-checkable counterpart — which is a small part of the documentation and the part that rots fastest, because a number is exactly what nobody re-derives while editing the sentence around it. Both claims it found on the day it was written had been wrong for over a week. `scripts/install.sh` is only an orchestrator — it runs the two halves in order and does nothing itself: `setup/machine-setup/machine-setup.sh` (28 sections: packages, tailnet, runtimes, docker, shell) then `setup/officer-setup.sh` (11: pre-flight, layout, repository, dependencies, database, environment, secrets, schema, build, services, verify). Either runs alone — `--machine-only`, `--officer-only`, or by path — because a machine you already trust needs only the second. Both are re-runnable: each records the steps it finished and skips them, so stopping halfway costs nothing. **Run it as yourself**; it re-execs through `sudo` when it needs to, and on macOS never does, because Homebrew refuses to run as root. Sidecar control is PM2, not npm scripts: `pm2 restart officer-`, `pm2 logs officer-`. See `docs/working-on-officer.md` for which process a given change needs restarted. ### Installs are frozen. Never resolve a dependency you did not ask for. `bunfig.toml` sets `[install] frozenLockfile = true`, so **`bun install` resolves from `bun.lock` and nothing else** — it fails rather than quietly picking up a newer version, including a transitive one nobody chose. Verified on bun 1.3.10: a lockfile that no longer satisfies `package.json` exits 1 with `error: lockfile had changes, but lockfile is frozen`. It is config rather than a habit because a supply-chain compromise does not wait for the one time somebody forgets a flag. On **2026-08-04** eleven cache packages — `keyv`, `flat-cache`, `file-entry-cache`, `cacheable-request`, `cache-manager`, the `@cacheable/*` scope, `ecto` — were published with a `preinstall` dropper that harvested npm and GitHub tokens, AWS and Kubernetes credentials, SSH and PEM keys, `.env` files and `.claude/settings.json`, then republished itself through any npm token it found. It reached 434 further packages across 1,381 versions. This machine was unaffected only because nothing had installed since 2026-08-02. **To change a dependency:** edit `package.json`, allow the lockfile to change deliberately, **read the diff**, and commit it. Note that `--no-frozen-lockfile` — which this file and `bunfig.toml` both used to name — is NOT a flag bun 1.3.14 has; it is silently ignored and the install still fails frozen. Comment out `frozenLockfile` in `bunfig.toml` for the one command, then put it back. The friction is the point — an unexplained lockfile change in a diff is the signal this exists to produce. **Do not** add `--no-frozen-lockfile` to a script, a Dockerfile or CI to make an error go away. The error means the lockfile and `package.json` disagree, and that is worth a human look every time. **Format your own files, not the whole dirty tree.** `bun format` globs `git diff --name-only HEAD`, so it rewrites every uncommitted file — including work in progress that isn't yours, which then shows up as unexplained whitespace churn in someone else's diff. Run `bunx prettier --write ` on the files you actually touched. `bun format` is only safe when the tree is otherwise clean. ## Code Style - **Paradigm**: functional — pure functions, immutability, composition - **TypeScript**: strict, no `any`. Type-only imports are required (`verbatimModuleSyntax`). - **Comments**: minimal, and about _why_. Don't narrate what the code already says. - **Async**: always async/await - **Exports**: named only, no defaults - **Files**: `PascalCase.tsx` for components, `kebab-case.ts` for everything else - **Prettier**: single quotes in JS, double in JSX, semicolons, trailing commas, 120 cols ### Imports Order: types → external → workspace → relative. ```ts import type { User } from 'types'; import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useClient } from 'hooks/useClient'; import { formatDate } from 'helpers/formatters'; import { useWebsites } from '../useWebsites'; ``` In app code import types from `'types'`, which re-exports the database types. Only server and database code imports from `officerdb/types` directly. ### Functions Arrow functions for one-liners, regular functions for anything with real logic. No multiline parameter lists — extract a params type instead. ```ts type ProcessDataParams = { body: Record | undefined; query: Record }; export function processData({ body, query }: ProcessDataParams): Result { ... } ``` ### Components and hooks ```tsx type ExperimentCardProps = { experiment: Experiment; onSelect: (id: number) => void }; export const ExperimentCard = ({ experiment, onSelect }: ExperimentCardProps) => { ... }; ``` Complex hooks return objects; simple state hooks return `as const` tuples. Event parameters are always named `ev`, never `e`. ### API calls `useClient()` returns typed verbs — `client.get('/foo')` really does return `Foo`, so pass the type parameter and let inference flow from it. ## Working With Me - **Ask first** — confirm the approach before a significant change - **Explore thoroughly** — read the related files before editing - **Keep it simple** — no over-engineering, no premature abstraction - **Be explicit** — no magic, no implicit behaviour - **Stay focused** — note unrelated problems, don't fix them uninvited - **Report honestly** — say what you verified and what you didn't **Always commit and push when you finish implementing — including when it went wrong.** Don't wait to be asked, and don't hold a branch back because it is unfinished, untested or turned out to be a dead end. The history of the mistakes is worth having: a reverted commit and its message explain why an approach was abandoned, which is exactly the thing that gets lost when a failed attempt is quietly discarded. Say what state it is in — in the commit message and in `COMMS/` if another agent will pick it up — rather than withholding the commit until it is good. Commit messages: simple lowercase, no prefixes. **Stage and commit in ONE step, naming the paths.** `git add ` then a bare `git commit` commits the whole **index**, not the path just named — so anything a concurrent session had staged goes into your commit, under your message and your session trailer. Use `git commit -- ` instead, which does what the two-step form only looks like it does. This is not hypothetical and it is not rare: two agents share this working tree most nights. On 2026-08-23 commit `6e092c32` says "marketplace: minesweeper in the catalogue" and contains nine files of another session's roles work, swept up mid-`git add`. The other session was right not to amend it — rewriting shared history to fix a message is the worse trade — so the record was corrected with a follow-up note instead. Cheap to avoid, awkward to undo. Two things about the form itself, both learned the same night. `-m`/`-F` goes BEFORE the `--`, or git reads the message as a pathspec. And it only works on **tracked** files — a new file must be `git add`ed first, so a commit introducing one is `git add ` then `git commit -- `, naming every path. Getting that wrong fails loudly, which is the good case: the release tag that followed it went onto the wrong commit, and only because the tag was checked afterwards did that surface at all. **An agent's commit names the session that made it.** A trailer, below `Co-Authored-By`: ``` Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 ``` The transcript id, not the session name — names are not stable. On 2026-08-23 one session was `platform-e2` and then `platform-18`, and another `platform-59` then `platform-72`, both renamed by a restart in a single night. The transcript id survived both. Why it earns a line in every commit: three agents committed to this repository in one night and `git log` could not tell them apart. `Co-Authored-By` says "an agent", which stopped being useful the moment there was more than one. And the transcript holds the reasoning a commit message cannot — the arguments considered and dropped, what was measured, what the owner decided and why. A commit that names its session is a commit whose "why" is still answerable in six months. **The precondition, and it is not built yet:** an agent cannot reliably discover its own transcript id. The only exposure is that a background task's output path contains it, which means starting a job to learn who you are. `commanderBlock` already hands a commander its id in the preamble, so the mechanism exists — `todo/agent-coordination/` carries giving it to every session. Until then, an agent that knows its id should add the trailer and one that does not should leave it out rather than guess. ## Frontend route conventions (apply to EVERY new dashboard route) Worked examples: `/soulseek`, `/music`, `/chat`. - **Workspace/Panel framework, always — never a standalone single screen.** The screen renders `` where `ws = useDashboardState('screens/', defaultLayout)`, guarded by a `normalizeLayout` that pins `appType`s to an allow-list. Panels are windowed apps under `src/workspaces/officerdev/src/apps//`, each exporting `appRegistryMetas` (`{ key, name, icon, component, availableOnPanel: false }`) and registered in `AppRegistry.tsx`. The Workspace/Panel framework is orthogonal to routing — it contains no route navigation, and panels live inside the Route element tree, so they can call `useParams`/`useSearchParams` directly. - **Page title by route.** Automatic — no rule to add. `titleForPath` in `src/apps/officer-web/state/usePageTitle.ts` takes the URL's first path segment, turns `kebab-case` into `Title Case`, and that's the tab title; `usePageTitleSync` does the rest. A screen that wants to say more than its route does (a chat's own name, not just "Chat") publishes an override instead — `usePublishPageTitle` in `officerdev`. ### The URL is the source of truth for selection `docs/navigation-audit.md` is the authority here — read it before building a screen that selects things. It names the **"opaque click" anti-pattern**: an element that opens something addressable but keeps the id in an onClick closure instead of the DOM, leaves the URL unchanged, holds the selection in `usePanelChannel`/`useGlobal`, and has no anchor semantics (no cmd-click, no middle-click, not link-focusable). Half the app still does this; none of the new code should. - **Addressable state goes in the URL** (`useParams` / `useSearchParams`), never in a channel. `usePanelChannel` is for genuine signals and refresh buses (`files:refresh-signal`, `SLSKD_REFRESH_CHANNEL`, `MUSIC_RESYNC_CHANNEL`) — and declare one with `defineChannel` in `officerdev/src/channels.ts` rather than spelling the name and type at each site. "Which thing is open" is a URL. Panels each read the URL rather than passing it between themselves. - **Rows and nav items are real links.** `` for rows (exemplar: the `/chat` session list, `f35c145`); **react-router's ``** for nav chrome, so active state comes from the router. The hand-rolled `isActive` in `Dock`/`Header` is scheduled for replacement (audit Phase 4) — don't copy it. A disabled entry renders as a ``; a disabled `` is not a thing. A control that _mutates_ rather than navigates stays a `