calendar and contacts are a plugin now — plugins/caldav, its own repository like every other one. a fresh machine runs five processes instead of seven and gets a calendar by installing one plugin, which is the whole point: the platform stops carrying a python calendar server for people who may not want one. what left: the radicale sidecar, four routers, the panel apps, the dav_app_passwords table, the two screens, the two dock tiles, the permission registry entry, the settings section, and radicale from every package list. three platform things had to change first, each committed on its own:6b5c87daa plugin can be more than one screen;e0df28ffa first-party plugin can claim a reserved root path. this commit adds the third, `personal` on a plugin permission — a member granted READ must still mint and revoke their own app password, because that password is how their own phone reaches their own calendar. those rows are theirs and nobody else's. two fixes the work surfaced rather than caused. the dock pinned mountPrefix, so a multi-screen plugin pinned a tile pointing at /caldav — a route no plugin serves. and the OPTIONS carve-out was a literal list of four DAV paths; it asks the claim registry now, so it follows whoever owns the route. /calendar stays in DEFAULT_DOCK_PATHS. it is installed by default and a fresh dock that silently dropped it would read as a downgrade. docs:check earned its keep twice here: it caught spotlight still offering /settings/integrations/dav-app-passwords after the section was deleted, and the permission count going 22 -> 21. neither is greppable and both were already wrong when it ran. verified against alpha, which is still on the pre-extraction code: /.well-known/ caldav 301s, /dav/ answers 401 with the same WWW-Authenticate realm byte for byte, /api/caldav/passwords 401s, and OPTIONS reaches the DAV router rather than being answered as a CORS preflight. installed here end to end — schema applied, sidecar started, two tiles with their own artwork behind one permission. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
38 KiB
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, 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 byPOST /auth/bootstrapwhile 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 inrole_permissions, 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.tsdrops it for an account whoseosUseris null, so "granted but unconfined" resolves to no access rather than to the owner's home — which is what it would otherwise resolve to, sincegetOwnerHomeDirignores 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,
calendar…) and for the confined one (terminal, chat, files), and is still always "the owner" for
anything under execution.
src/servers/permissions/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 "Permissions"
below before adding one.
Account creation exists. POST /api/users (owner-gated), with a form under Settings → User Management:
it validates the password with the same rules bootstrap uses, refuses a role that is not a row in roles,
refuses Super Admin outright because there is exactly one owner, optionally takes an inbound SSH key, and
provisions the Linux account. POST /users/:id/provision-linux retries that half if the host was not ready.
This file said "there is no way to create a second account" for some days after 69a31051 shipped one, and
docs/roles-and-teams.md sequenced a whole phase around building it. Neither is checkable by
bun run docs:check — it compares numbers, and this was prose. Worth knowing as the shape of the failure:
the counts stay right and the sentences rot.
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.
terminalis a byte relay onto the pty sidecar's own listener, not a translating bridge;vaultis 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 andapi/browser/stay on disk; the listener and the/api/browsermount 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-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
├── servers/
│ ├── hono.ts # router composition; everything under /api
│ ├── _middlewares/ # auth, body parsing, the permission 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
preinstallcheck)That check demanded exactly 22 until 2026-08-12. The reason was a
node-ptybuild failure some months earlier, whose details were not recorded. It was relaxed to>= 22after confirming node-pty ships no Linux prebuilds — its install script always falls through tonode-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. Ifbun installfails building node-pty, orofficer-ptycannot load its native module, restore the exact pin first. The source build also needsbuild-essentialandpython3.Bun itself is held at the 1.3 series, on purpose and temporarily. 1.4.0 was published 2026-08-20; the owner asked on 2026-08-22 to stay on 1.3 for a few months until 1.4 has been watched. Two places carry that number and they move together:
BUN_SERIESinscripts/setup/machine-setup/lib/dev.sh, which resolves the newest1.3.xfrom the npm registry and passes it to the installer as a release tag, andengines.buninpackage.json, which is whatbun run doctorreads. bun ignoresengines(verified — an install declaring1.9.xran clean under 1.3.14), so that field is the declaration and doctor is the enforcement.Do not run
bun upgradeon a machine running Officer. It takes no version argument — latest stable or--canary, nothing else — so it jumps straight past the pin and says nothing. That is the whole reasondoctorchecks the running version at all;machine-setup.sh --only 'JavaScript runtimes'offers to pin a drifted machine back down. Lifting the hold istodo/infra/. -
Language: TypeScript, strict.
bunx tsgois clean — keep it that way. -
Frontend: React 19, React Router 7, React Query, Tailwind 4, shadcn/ui + custom components
-
Backend: Hono
-
Database: Postgres via Drizzle —
officerfor the platform, plusofficer_marketplaceon the host that serves marketplace.officer.dev (seedocs/marketplace.md) -
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,
queue and pipeline jobs. Since 2026-08-16 there is a SECOND database,
officer_marketplace (src/databases/marketplace_db), and the split is deliberate: it is a public
catalogue served by a process with no login, no session and no user, so sharing a connection would put
an unauthenticated surface one query away from every private table. It runs only on the host serving
marketplace.officer.dev — docs/marketplace.md. 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 and attachments. It also holds
email_accounts/, which is the email plugin's — the platform pre-creates the directory and never opens
what is inside it.
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/, permissions/ 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 whenPUBLIC_BUILD_ENVis explicitlydev/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_ORIGINandALLOW_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'sofficer://<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 permission 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-readsusers.rolefrom 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.
Permissions — 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/permission-gate.ts → permissions/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.
permissions/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),executionandadmin(owner only, andexecutionis never grantable at any level). 21 entries as of 2026-08-27 — verified bybun run docs:check, which fails if this number drifts again: 6 core, 2 app, 4 confined, 3 execution, 6 admin (todos, the todo board, is the sixth). It was 22 until caldav became a plugin and tookcalendarwith it — a plugin's permission is registered at runtime from its manifest, so it is real but not in this file's count.notifymoved fromapptocoreon 2026-08-24: it was grantable, nothing granted it, and every Member's app was silently 403'd registering its own push token. It said 27 for nine days. The execution count dropped by one when Minesweeper became a plugin — seeplugins/minesweeper/manifest.tsfor why that was a fix rather than a downgrade.permissions/authorize.ts— resolves "may this account do this". Owner short-circuits first; every other answer is role grants plus core, withexecution/adminstripped even if a row grants them, andconfinedstripped for an account with noosUser. Every catch returns deny. Grants are cached by role and the cache's whole invalidation contract isinvalidateRoleGrants, called by the one writer inapi/users/permissions-routes.ts.permissions/totality.ts—assertPermissionTotalityruns inserver.tsxbeforeserve()and throws. Mount a router or a socket without a registry entry andpm2 restart officerfails, naming what is missing. That is deliberate: the hole it closes was a Member 403'ing onGET /api/tasksand opening/api/tasks/pipeline/wswith 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 usePermissions 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
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 docs:check # do the docs still match the code? REQUIRED before tagging a release
bun setup # runs scripts/install.sh — blank machine to running platform
bun docs:check before every release, and leave it green. It compares documented claims against values
derived at run time — the permission count, the PM2 entry count, every settings route Spotlight offers — and
exits non-zero on drift. A check must never compare a doc against a second hard-coded copy; that is one more
place to drift, and it drifts in the same edit that misses the doc.
It is narrow on purpose. It cannot read prose and judge it, only claims with a machine-checkable counterpart — which is a small part of the documentation and the part that rots fastest, because a number is exactly what nobody re-derives while editing the sentence around it. Both claims it found on the day it was written had been wrong for over a week.
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, allow the lockfile to change deliberately, read the
diff, and commit it. Note that --no-frozen-lockfile — which this file and bunfig.toml both used to
name — is NOT a flag bun 1.3.14 has; it is silently ignored and the install still fails frozen. Comment
out frozenLockfile in bunfig.toml for the one command, then put it back. 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.tsxfor components,kebab-case.tsfor 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
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.
Stage and commit in ONE step, naming the paths. git add <path> then a bare git commit commits the
whole index, not the path just named — so anything a concurrent session had staged goes into your
commit, under your message and your session trailer. Use git commit -- <paths> instead, which does what
the two-step form only looks like it does.
This is not hypothetical and it is not rare: two agents share this working tree most nights. On
2026-08-23 commit 6e092c32 says "marketplace: minesweeper in the catalogue" and contains nine files of
another session's roles work, swept up mid-git add. The other session was right not to amend it —
rewriting shared history to fix a message is the worse trade — so the record was corrected with a
follow-up note instead. Cheap to avoid, awkward to undo.
Two things about the form itself, both learned the same night. -m/-F goes BEFORE the --, or git
reads the message as a pathspec. And it only works on tracked files — a new file must be git added
first, so a commit introducing one is git add <new> then git commit -- <paths>, naming every path.
Getting that wrong fails loudly, which is the good case: the release tag that followed it went onto the
wrong commit, and only because the tag was checked afterwards did that surface at all.
An agent's commit names the session that made it. A trailer, below Co-Authored-By:
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
The transcript id, not the session name — names are not stable. On 2026-08-23 one session was
platform-e2 and then platform-18, and another platform-59 then platform-72, both renamed by a
restart in a single night. The transcript id survived both.
Why it earns a line in every commit: three agents committed to this repository in one night and git log
could not tell them apart. Co-Authored-By says "an agent", which stopped being useful the moment there was
more than one. And the transcript holds the reasoning a commit message cannot — the arguments considered and
dropped, what was measured, what the owner decided and why. A commit that names its session is a commit
whose "why" is still answerable in six months.
The precondition, and it is not built yet: an agent cannot reliably discover its own transcript id. The
only exposure is that a background task's output path contains it, which means starting a job to learn who
you are. commanderBlock already hands a commander its id in the preamble, so the mechanism exists —
todo/agent-coordination/ carries giving it to every session. Until then, an agent that knows its id should
add the trailer and one that does not should leave it out rather than guess.
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 />wherews = useDashboardState<LayoutNode>('screens/<name>', defaultLayout), guarded by anormalizeLayoutthat pinsappTypes to an allow-list. Panels are windowed apps undersrc/workspaces/officerdev/src/apps/<Feature>/, each exportingappRegistryMetas({ key, name, icon, component, availableOnPanel: false }) and registered inAppRegistry.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 calluseParams/useSearchParamsdirectly. - Page title by route. Automatic — no rule to add.
titleForPathinsrc/apps/officer-web/state/usePageTitle.tstakes the URL's first path segment, turnskebab-caseintoTitle Case, and that's the tab title;usePageTitleSyncdoes the rest. A screen that wants to say more than its route does (a chat's own name, not just "Chat") publishes an override instead —usePublishPageTitleinofficerdev.
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.usePanelChannelis for genuine signals and refresh buses (files:refresh-signal,SLSKD_REFRESH_CHANNEL,MUSIC_RESYNC_CHANNEL) — and declare one withdefineChannelinofficerdev/src/channels.tsrather 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/chatsession list,f35c145); react-router's<NavLink>for nav chrome, so active state comes from the router. The hand-rolledisActiveinDock/Headeris 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,/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/:iddestroys preview-on-list, because that route is the full page. Rows link to/x?selected=idon-page and/x/:idoff-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.tsxis not react-router'sNavLink— it's a<Link>wrapper that appendsuseGlobalQueryString()and provides no active state. Import react-router's when you wantisActive.- Route helpers shared between an
officerdevpanel app and anofficer-webscreen must be re-exported fromsrc/workspaces/officerdev/src/index.ts(named exports only — the barrel deliberately avoidsexport *for app modules to keepappRegistryMetasfrom 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 toTODO.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 plandocs/email-recall.md— design, not built: recall over your own correspondence. The split that matters is counting (needs every message judged) versus retrieval (needs the best few, fast) — and why a number produced by searching is confidently wrong with no signal that it isdocs/find-my-device.md— design, not built: locating your own devices from Officer. What is assembly (most of it), what is impossible (offline finding — there is no crowd to relay it), and the question to settle first, which is retention rather than anything technicaldocs/affordances.md— what an affordance is, the three tests for whether something qualifies (including the one that catches most bad ones: can it be fixed instead?), the file format, and how they reach the agent. Read before writing onedocs/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 whatdocs/workspace-panel-todo.mdis 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 isdocs/workspace-panel-todo.md.docs/multi-server-push.md— design, not built: one Officer app receiving notifications from every server it is signed into. Works already, bar one missing field — and why themvcatalogue-version check turns into an endless resync between servers without it. The server identifier is an SSO decisiondocs/visual-regression-plan.md— plan, not built: nightly screenshot regression against a JWT dashboard on a second server. The blocker is that a JWT lives inlocalStorageand iris copies only cookies, so an authenticated capture silently renders the login page — plus why diffing must happen before any agent is asked to lookdocs/plugin-dependency-scope.md— a plugin declares WHO must be able to run each host binary. Why the owner's login shell is the wrong question for aconfinedplugin, the two-part reachability check (world-executable AND every parent traversable), and why members' binaries on alpha worked only because one home directory happened to be mode 751docs/plugin-route-authorization.md— design, not built: making a plugin's settings routes owner-only while the plugin itself stays grantable. What the proxy should pass (isOwnerand a level, never the role name), why declaring owner-only paths in the manifest beats trusting every plugin to remember, and the credential-resolution work that has to land firstdocs/roles-and-teams.md— plan, phase 0 built: roles as a table, teams as scopes, and the one change that has to land first — the owner becomes an identity rather than whoever holds a role name, because otherwise "add a role" and "become the owner" are the same featuredocs/localization.md— plan, not built, with a working 5% skeleton already in the tree: taking the web UI to more than one language. What exists and what it cannot do yet, why the server should return error CODES rather than prose (and why a code must not leak more than the prose it replaced), the key naming convention, and the four phases. Read before adding a user-facing stringdocs/secret-store.md— design, not built: moving the encryption and signing keys out of.envinto a SQLite store, why they cannot live in Postgres, and key rotation. Also records the core/plugin split it assumes — light plusofficer-headscaleis the core; Vaultwarden and the wallet are pluginsdocs/sidecar-topology.md— where the sidecar architecture is going, and what was considered and droppeddocs/working-on-officer.md— how to run, restart and check your work on this machinedocs/wallet-key-custody.md— what the platform can and cannot see of the walletdocs/editor-buffer-corruption.md— an open bug with a trap set for it: background-job output appearing inside a file open in the editor. Read it BEFORE investigating a report of that, and before re-testing anything it already rules outdocs/commander-directory.md— designed, not built: a commander is a directory, not a session. Why its identity is currently borrowed from Claude's transcript id and what that costs — an ordering problem, a first-turn interlock that is a side effect rather than a signal, and a commander that dies with its conversation. The CLAUDE.md in its own working directory is the living document, for freedocs/claude-md-touchpoints.md— audit: every place the platform writes, appends to or reads aCLAUDE.md— three writers total (commander creation, the plugin chat-brief copier, and a one-off credential-backfill migration) plus one read-only doc-drift check, and three places that deliberately do NOT write one. Read before changing any part of the mechanism; includes a per-plugin table (onlyproxy-managerships a chat brief today)docs/plugin-capabilities.md— built: a plugin's tasks, skills and affordances are READ where they live, never copied intocapabilities/. Why copying breaks a forkable repository, why it goes stale on update and survives a disable, and why separate directories beat namespaced paths — a namespace makes provenance readable, separate directories make it structuraldocs/filesystem-quotas.md— planned, not done: the host-side half of per-user and per-team storage limits. Why the kernel has to enforce it rather than the platform (members haveterminaland can write past anything we count), whyusrquota+grpquotaand not project quotas, and the one thing easiest to get wrong — a team folder that is notsetgidcharges nothing to the teamTODO.md— current direction and deferred work; takes precedence over this file where they disagreesrc/apps/CLAUDE.md— shared frontend patternssrc/databases/CLAUDE.md— database patternsCONVENTIONS.md— component organisation, state management and React patterns, with rationale;src/workspaces/officerdev/APP_CONVENTIONS.mdandHOOK_CONVENTIONS.mdfor panel apps and hooks
Treat the code as the source of truth where anything here disagrees with it.