Files
platform/CLAUDE.md
T
pastilhasandClaude Opus 5 10ff23c5dd gitea: the app — repos, code, issues, pulls, notifications
Replicates what Gitea's own web UI offers, on top of the sidecar's /_api pass-through.

Repository browsing (tree, file view with the FileViewer's shiki renderer, README), commits,
branches, tags, releases, issues and pull requests both per-repo and cross-repo, notifications,
explore/search and organizations. Routes are /gitea/:section plus /gitea/repo/:owner/:name/:tab/:item,
all real Links with the URL as the source of truth — no selection channel.

Markdown is rendered client-side (react-markdown + remark-gfm + rehype-sanitize, rehype-raw
deliberately absent) rather than through the instance's /api/v1/markdown, because consuming that
means dangerouslySetInnerHTML and there is no DOMPurify in the tree with installs frozen. The cost
is Gitea's #123 and @mention cross-references; relative links and images are resolved instead.
The /markdown and /markup allow-list entries stay, so that door is open when a sanitiser lands.

retargetUrls rebases instance-minted URLs onto a browser-reachable origin, IN ONE DIRECTION ONLY.
This instance answers with two: /user and /repos build from its configured ROOT_URL
(http://localhost:9004), /contents from the public host. An unconditional rewrite onto the
connection URL therefore broke the second set to match the first, turning working https links into
dead loopback ones. Only a private/loopback URL is rewritten now, and only when the target is
itself public; when the connection URL is a dial address nothing is touched and the connection
screen says why avatars will not load.

Also carries the frontend half of the one-instance-many-tokens model: the connection form draws a
URL field only for the owner and sends no url key at all for anyone else, ServiceConnection.url is
string | null to match officerdb, and the rebase origin comes from the resolved instanceUrl rather
than connection.url, which is null for a member.

Not verified: no runtime pass since the last four changes, the issues and pull views have never
rendered a row (the instance has none), and the member path has never executed (one account).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 17:10:36 +00:00

16 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-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-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.

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.

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.