It said `bun setup` runs officer-setup.sh and was "IN PROGRESS, sections 1-6 of 10". It runs scripts/install.sh, and both halves are finished — machine-setup has 28 sections, officer-setup 11. That line is probably why the orchestrator got doubted: the one document you would check to find out how to install says the wrong entry point. Added what install.sh actually is — an orchestrator that runs the two halves and nothing else, either half runnable alone, both re-runnable, run it as yourself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
436 lines
27 KiB
Markdown
436 lines
27 KiB
Markdown
# CLAUDE.md
|
|
|
|
## 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, email, 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_capabilities`, 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, email,
|
|
calendar…) and for the **confined** one (terminal, chat, files), and is still always "the owner" for
|
|
anything under `execution`.
|
|
|
|
`src/servers/capabilities/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 "Capabilities"
|
|
below before adding one.
|
|
|
|
**Still single-user: account creation.** `createUser` has exactly one call site, `auth/bootstrap.ts`,
|
|
gated on an empty table. There is no signup route, no invite flow and no admin create-user handler, so
|
|
every existing member was inserted into Postgres by hand. That is the largest gap in the model, not a
|
|
deliberate boundary.
|
|
|
|
## 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-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-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
|
|
│ └── landing/ # marketing landing page
|
|
├── servers/
|
|
│ ├── hono.ts # router composition; everything under /api
|
|
│ ├── _middlewares/ # auth, body parsing, the capability gate, 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 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`.
|
|
- **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. 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/<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.
|
|
|
|
**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/`, `capabilities/` 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://<hex>` 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 capability 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.
|
|
|
|
### Capabilities — 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/capability-gate.ts` → `capabilities/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`.
|
|
|
|
- `capabilities/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). 27 entries as of 2026-08-13.
|
|
- `capabilities/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/capabilities-routes.ts`.
|
|
- `capabilities/totality.ts` — `assertCapabilityTotality` 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 `useCapabilities` **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 setup # runs scripts/install.sh — blank machine to running platform
|
|
```
|
|
|
|
`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-<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
|
|
|
|
**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.
|
|
|
|
## 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 (`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.** `<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).
|
|
|
|
## COMMS — a channel between agents, when one is open
|
|
|
|
`COMMS/<work-stream>/` is a **tracked** channel between agents working on this repo from different machines.
|
|
It exists because findings used to reach each other by the owner relaying them from memory at the end of long
|
|
sessions.
|
|
|
|
**There is no open channel right now.** `COMMS/sidecar-app-store/` ran for one night — per-user Linux
|
|
accounts through to a member's first agent turn — and was deleted when the work landed, which is the
|
|
convention rather than an oversight: a spent channel left in place gets read as current.
|
|
|
|
If you open one:
|
|
|
|
- **Read it before starting**, if your task touches its work stream. It carries what is verified, what is
|
|
assumed, what is broken, and what is waiting on a decision — the parts a commit message does not hold.
|
|
- **Number the files and alternate**, one per turn, odd for one agent and even for the other. The parity is
|
|
the author; the alternation is the protocol. A push with no doc is then visibly a break rather than
|
|
something to find by diffing, and "nothing to report" is still a turn worth taking — silence and a crashed
|
|
agent read identically.
|
|
- **End on a checkable condition**, not on either party's judgement: no open item is actionable by a
|
|
participant. "I think we're done" can close a thread with work still in it.
|
|
- **Durable reasoning goes in `docs/` or next to the code.** The channel is for coordination. When the work
|
|
lands, delete the channel and move anything still open to `TODO.md`.
|
|
|
|
Two things that made it work, and neither is about either agent being more careful. One writes, the other
|
|
verifies, and only the verifier runs things on a real machine — most of what was caught was invisible to
|
|
reading and needed a live filesystem. And the author of a comment is the worst-placed person to notice the
|
|
code disagrees with it: the two most serious defects were both found by whoever had not written the sentence
|
|
explaining why it was safe.
|
|
|
|
## 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/agent-coordination.md` — **the north star** for the workspace/panel work: agents on one
|
|
dashboard coordinating with each other instead of through the human, the handoff protocol, and what
|
|
is deliberately _not_ being built. Read it before ranking, deferring or starting any panel item —
|
|
it is what `docs/workspace-panel-todo.md` is ranked against.
|
|
- `docs/workspace-panels.md` — how the Workspace/Panel framework works: the layout tree, how a panel is
|
|
mounted and what it knows, the URL-vs-channel split for panel-to-panel communication, and the
|
|
persistence key families. Read before building a panel app. Its defect list is
|
|
`docs/workspace-panel-todo.md`.
|
|
- `docs/secret-store.md` — **design, not built**: moving the encryption and signing keys out of `.env`
|
|
into a SQLite store, why they cannot live in Postgres, and key rotation. Also records the core/plugin
|
|
split it assumes — light plus `officer-headscale` is the core; Vaultwarden and the wallet are plugins
|
|
- `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.
|