Files
platform/CLAUDE.md
T
pastilhasandClaude Opus 5 a9b6494f56 db: stop push re-creating every composite key on every run
`bun db:push` planned 32 statements against a database that already matched the
schema, and stopped on a "do you want to truncate screens?" prompt that answering
could not resolve — the same question came back next run. Root cause found and
fixed rather than worked around.

drizzle-kit mis-diffs named composite unique CONSTRAINTS. It reads one back,
compares it against a schema declaring the identical name, columns and order,
decides they differ, and emits DROP + ADD. Fifteen of those, forever. Reproduced
on a database drizzle had itself created seconds earlier, so it is not drift.
Single-column .unique() is diffed correctly; only unique('name').on(a, b) is
affected. Unique indexes go through a different code path and are stable, so all
fifteen are now uniqueIndex.

A unique index enforces exactly what the constraint did — verified, a duplicate
insert still fails on uq_screens_user_name — and onConflictDoUpdate accepts it as
an arbiter. It cannot be a foreign-key target, but nothing here targets a
composite key; checked before converting.

Separately, user_integrations_server_integration_id_server_integrations_id_fk is
65 characters and Postgres truncates identifiers at 63, so drizzle compared its
generated name against the stored, truncated one and re-created the FK every run.
Declared explicitly as fk_user_integrations_server_integration.

Measured on a scratch database, pushing twice each time:
  before        32 statements, interactive prompt
  after uniqueIndex   4
  after FK fix        2

The two that remain are a composite primaryKey with the same bug and no index
form to escape to — music_now_playing re-creates pk_music_now_playing every push.
Silent, no prompt even with rows, data unaffected, and naming it explicitly does
not help. Documented as expected.

The conversion itself was tested against populated tables, since that is what the
real database will do: no prompt, and all rows survived.

Docs rewritten in src/databases/CLAUDE.md — the rules committed an hour ago
described the broken behaviour and would have been wrong the moment this landed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 14:35:26 +00:00

15 KiB

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

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.

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.

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.

type ProcessDataParams = { body: Record<string, unknown> | undefined; query: Record<string, string> };
export function processData({ body, query }: ProcessDataParams): Result { ... }

Components and hooks

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 appTypes 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.mdauthoritative 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.