# CLAUDE.md ## Project Overview Officer is a self-hosted personal platform for one person: the server owner. It bundles an AI agent, a terminal, a file browser, a code editor, email, a bitcoin wallet, a remote desktop and customisable dashboards behind a single web app. **Single-user is a hard invariant, not a stage.** There is exactly one account, created once by `POST /auth/bootstrap` while the user table is empty. There are no roles, no invitations, no sandboxing of one user from another, and no per-user isolation anywhere in the codebase. If a change seems to need "which user is this", the answer is always the owner. ## 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 (`BROWSER_RELAY_PORT`, default 18792) 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 (`ecosystem.config.cjs`): `officer` (the server), `officer-anthropic-proxy`, `officer-agent`, `officer-opencode`, `officer-email`, `officer-pty`, `officer-vnc`, `officer-music`, `officer-vault`, `officer-slskd`, `officer-headscale`, `officer-transmission`, `officer-invoiceshelf`, `officer-wallet`. **`officer-anthropic-proxy` and `officer-agent` are not the same thing.** 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 │ └── landing/ # marketing landing page ├── servers/ │ ├── hono.ts # router composition; everything under /api │ ├── _middlewares/ # auth, body parsing, origin validation, 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 is enforced by a `preinstall` check) - **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**: one Postgres database via Drizzle - **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, email accounts, queue and pipeline jobs. Schema in `src/schema/`, hand-written queries in `src/queries/`, types inferred from the schema in `src/types.ts`. **The filesystem** holds everything the agent authors. `OFFICER_ITEMS_DIR` contains one directory per item under `skills/`, `tools/`, `tasks/`, `processes/`, `extensions/` — no database rows, no scope tiers. `DATA_PATH//` holds the managed home, attachments and the per-account email SQLite stores — those are the **email sidecar's**, and nothing in the platform opens them. Path helpers live in `src/servers/data-path.ts`; note `getHomeDir` (the managed home under `DATA_PATH`) versus `getOwnerHomeDir` (the owner's real login home when `HOME_DIR` is set, which is where terminals, chats and task runs actually execute). ### 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. ## Security Model The perimeter is one credential, so the guards matter: - `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. Origin validation, rate limiting and password rules all key off it — they fail closed. - Allowed origins come from `PUBLIC_URL`. Officer always sits behind an HTTPS reverse proxy, so the forwarded `Host` must equal `PUBLIC_URL`'s authority exactly. - JWTs are 30-day, blacklisted on signout, and invalidated by a password change (`passwordChangedAt`). - A panic lockdown (`src/servers/api/auth/panic.ts`) is in-memory only and refuses every authenticated request until the server restarts. ## 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 setup # guided install (writes .env, incl. PUBLIC_BUILD_ENV=production) ``` 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. **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 Commit messages: simple lowercase, no prefixes. ## 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.** Add a rule to `RULES` in `src/apps/officer-web/state/usePageTitle.ts` (`{ match: (p) => p.startsWith('/'), title: '' }`, most-specific first); `usePageTitleSync` does the rest. `startsWith` means nested routes are covered. ### 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 (`preview:refresh`, `files:refresh-signal`, `SLSKD_REFRESH_CHANNEL`, `MUSIC_RESYNC_CHANNEL`, `FILE_VIEWER_CHANNEL`). "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 `