# 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/<feature>/      # 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/<email>/` 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.

**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

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-<name>`, `pm2 logs officer-<name>`.
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`, run `bun install --no-frozen-lockfile` deliberately,
**read the lockfile diff**, and commit it. 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 <paths>` 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<string, unknown> | undefined; query: Record<string, string> };
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>('/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
  `<WorkspaceView workspace={ws} locked />` where
  `ws = useDashboardState<LayoutNode>('screens/<name>', defaultLayout)`, guarded by a `normalizeLayout`
  that pins `appType`s to an allow-list. Panels are windowed apps under
  `src/workspaces/officerdev/src/apps/<Feature>/`, 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('/<name>'), title: '<Name>' }`, 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.** `<Link>` for rows (exemplar: the `/chat` session list,
  `f35c145`); **react-router's `<NavLink>`** 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 `<span>`; a disabled `<a>` is not a thing. A control that
  *mutates* rather than navigates stays a `<button>`.
- **Route pairs.** A bare screen route plus a param route rendering the same component: `/chat` +
  `/chat/:sessionId`, `/jobs` + `/jobs/:id`, `/email` + `/email/:emailId`, `/headscale` +
  `/headscale/:section`. One `<Navigate … replace />` guard in the screen, placed after all hooks,
  canonicalises both the bare route and a bogus param.
- **Master list + live preview uses `?selected=<id>`**, not the detail route — linking rows straight
  to `/x/:id` destroys preview-on-list, because that route is the full page. Rows link to
  `/x?selected=id` on-page and `/x/:id` off-page; list, mobile panel and preview all read the param.
  Action buttons are **siblings** of the anchor, never nested inside it.
- **`src/workspaces/components/NavLink.tsx` is not react-router's `NavLink`** — it's a `<Link>`
  wrapper that appends `useGlobalQueryString()` and provides no active state. Import react-router's
  when you want `isActive`.
- Route helpers shared between an `officerdev` panel app and an `officer-web` screen must be
  re-exported from `src/workspaces/officerdev/src/index.ts` (named exports only — the barrel
  deliberately avoids `export *` for app modules to keep `appRegistryMetas` from colliding).

## Further Reading

- `docs/navigation-audit.md` — **authoritative** on routing/navigation: the opaque-click anti-pattern,
  a severity-ranked findings table, the channel-selection map and the four-phase plan
- `docs/sidecar-topology.md` — where the sidecar architecture is going, and what was considered and dropped
- `docs/working-on-officer.md` — how to run, restart and check your work on this machine
- `docs/wallet-key-custody.md` — what the platform can and cannot see of the wallet
- `TODO.md` — current direction and deferred work; **takes precedence over this file where they disagree**
- `src/apps/CLAUDE.md` — shared frontend patterns
- `src/databases/CLAUDE.md` — database patterns
- `CONVENTIONS.md` — component organisation, state management and React patterns, with rationale;
  `src/workspaces/officerdev/APP_CONVENTIONS.md` and `HOOK_CONVENTIONS.md` for panel apps and hooks

Treat the code as the source of truth where anything here disagrees with it.
