nothing about the platform changed: all 21 plugins were installed on that machine so the marketplace UI had something real to render. the paragraph under this one already says the number tracks plugin installs rather than releases — this is that working, not an exception to it. caught by `bun run docs:check`, which is the whole reason the number is checkable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
280 lines
21 KiB
Markdown
280 lines
21 KiB
Markdown
# CLAUDE.md — officer.dev workspace root
|
||
|
||
This is the working root for **developing Officer**: authoring **capabilities** and making
|
||
**platform** changes that support them. Sessions started in this cwd are for exactly that loop —
|
||
write/fix a capability, and when it needs platform support, fix the platform too.
|
||
|
||
**You (Claude) are a standing collaborator here.** The owner spins up new chats from this cwd to build
|
||
capabilities and correct the platform. Assume that recurring intent. Be proactive about the runtime
|
||
facts below — most wasted time in past sessions came from forgetting them.
|
||
|
||
## Layout (root is NOT a git repo; the two subdirs are)
|
||
|
||
```
|
||
officer.dev/
|
||
├── platform/ # the Officer app — one Bun process. Git repo. See platform/CLAUDE.md
|
||
├── capabilities/ # OFFICER_ITEMS_DIR — file-based store of items. Git repo. See capabilities/CLAUDE.md
|
||
├── data/ # DATA_PATH — managed homes, attachments, job logs (data/jobs/<id>.log)
|
||
└── (marketplace/, mobile-apps/, Lyrics/, DOCS/, … — not usually in scope)
|
||
```
|
||
|
||
Read `platform/CLAUDE.md` and `capabilities/CLAUDE.md` — they are authoritative for their subtrees.
|
||
This file captures the **cross-cutting operational reality** that neither of those spells out.
|
||
|
||
## Runtime facts that WILL bite you (learned the hard way)
|
||
|
||
- **The server runs via PM2 as `bun start` — production, NO `--watch`.** Process `officer`
|
||
(`NODE_ENV=production bun src/server.tsx`). So **platform TypeScript changes do NOT hot-reload.**
|
||
After editing anything under `platform/src` (backend *or* frontend), it needs a
|
||
**`pm2 restart officer`** to take effect. Forgetting this looks exactly like "my fix didn't
|
||
work." Confirm the reload before diagnosing further.
|
||
- **Run the restart yourself, and say that you did.** This said "NEVER run pm2 yourself" until
|
||
2026-08-20. The owner granted the opposite on this machine: restart when a change needs it rather
|
||
than handing over a command to paste. What did NOT change is the scope — restart ONLY the
|
||
process(es) the change needs (for platform TS edits that is `pm2 restart officer` alone, never the
|
||
whole `ecosystem.config.cjs`, which bounces every sidecar).
|
||
- **Check who else is live first.** The owner edits the platform *using* the platform, and often has
|
||
several sessions open, so a restart can cut another agent off mid-turn or bounce the process you
|
||
are both running inside. Look at `pm2 list`, the session list, the process table. Restarting is
|
||
also the one moment a half-applied change from a shared checkout ships — confirm the tree is
|
||
coherent (typecheck clean, database schema matching the code) before you bounce anything.
|
||
- **Capabilities ARE hot — no restart.** Files under `capabilities/` are re-read from disk on each
|
||
run / request (Pi per agent session, the app per request). Edit `TASK.md` / `index.ts` /
|
||
`categories.yaml` and it's live immediately. This asymmetry is the #1 source of confusion:
|
||
the capability change shows up, the platform change doesn't, and it looks like a partial failure.
|
||
- **Frontend is served bundled from `index.gen.html`** (`platform/src/apps/officer-web/index.gen.html`,
|
||
imported by `src/server.tsx`, regenerated by `platform/scripts/gen-index.ts` at boot). After a
|
||
platform restart the browser can still hold a **stale JS bundle** → do a hard refresh
|
||
(Ctrl/Cmd-Shift-R). "Backend correct but UI unchanged" is almost always this.
|
||
- **React Query caches** `tasks` / `task-categories` with `staleTime: 60_000`. Menu/category changes
|
||
lag up to a minute or until the view remounts. Hard refresh forces it.
|
||
- **`pm2 restart officer` no longer kills the agent session.** Fixed and merged on 2026-07-30; verified
|
||
running. The agent is its own PM2 peer (`officer-agent`) and the Anthropic proxy is honestly named
|
||
(`officer-anthropic-proxy`) — they were one misnamed entry, `officer-claude`, which is how the *old*
|
||
claim in this file ("restarting officer doesn't disturb the agent") was both wrong and believed: the
|
||
process that runs `claude` was spawned by the main server, so PM2's tree-kill took it down every time.
|
||
A restart now costs the agent a socket reconnect, not the session, and output produced while officer is
|
||
down survives — the sidecar writes to `chat_session_events` itself. There is no `officer-claude`.
|
||
- **Officer is TWENTY-SIX PM2 entries here today** (verified 2026-08-27 by `bun run docs:check`, which
|
||
fails if this drifts): the five core processes — `officer`, `officer-anthropic-proxy`,
|
||
`officer-claude-code`, `officer-opencode`, `officer-pty` — plus `officer-notify`, `officer-marketplace`,
|
||
`officer-oas`, and one per installed plugin.
|
||
|
||
It was thirteen this morning. **Nothing about the platform changed**: all 21 plugins were installed on
|
||
this machine so the marketplace UI had something real to render. That is the paragraph below working
|
||
exactly as written, not an exception to it.
|
||
|
||
It was fifteen on 2026-08-25; `officer-minesweeper` and `officer-transcribe` were uninstalled since,
|
||
which is the point of the paragraph below rather than an exception to it.
|
||
|
||
**This number tracks PLUGIN INSTALLS, not platform releases.** It went to fourteen when Minesweeper was
|
||
installed and back to thirteen when it was removed, with no platform change either time — installing a
|
||
plugin that declares a sidecar writes it into the generated ecosystem file. Do not read a change here as
|
||
a platform change, and do not expect two machines to agree.
|
||
|
||
It said "twenty" and listed a different set until 2026-08-23 — including `officer-vault`, `officer-slskd`,
|
||
`officer-headscale`, `officer-transmission`, `officer-invoiceshelf`, `officer-wallet`, `officer-photos`,
|
||
`officer-jellyfin`, `officer-gitea`, `officer-music`, `officer-vnc`, none of which run here, while omitting
|
||
six that do. That is what a hand-maintained list of processes becomes: accurate on 2026-08-07, and nobody
|
||
re-derives a list while editing the sentence around it. `mybiblepal-api` and `mybiblepal-audio` are also
|
||
gone; nothing shares this daemon today.
|
||
|
||
Never `pm2 restart ecosystem.config.cjs` — it bounces every sidecar. Use `--only <name>`, or restart the
|
||
single process the change needs. `pm2 jlist` is still the source of truth; the check exists so the copy
|
||
cannot silently stop matching it.
|
||
|
||
- `OFFICER_ITEMS_DIR=/home/pastilhas/dockers/officer.dev/capabilities` ← items live here
|
||
- `DATA_PATH=/home/pastilhas/dockers/officer.dev/data`
|
||
- `HOME_DIR=/home/pastilhas` and `HOME=/home/pastilhas` — **equal here** (matters for `~` expansion).
|
||
`getOwnerHomeDir(email) = process.env.HOME_DIR ?? DATA_PATH/<email>/home`. Where task runs actually
|
||
execute = `getOwnerHomeDir` (the real login home), NOT the managed `getHomeDir`.
|
||
- `POSTGRES_URL=postgresql://postgres:postgres@127.0.0.1:5432/officer_dev` (single DB, Drizzle).
|
||
Schema changes: edit schema code → `bun db:push` (no migrations, no `__drizzle_migrations`).
|
||
Multi-column uniqueness is declared with **`uniqueIndex`, never `unique().on(...)`** — 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. Rules in `platform/src/databases/CLAUDE.md` →
|
||
"Composite keys".
|
||
- Typecheck with **`bunx tsgo`** (not tsc). Keep it clean.
|
||
- **Installs are frozen.** `bunfig.toml` sets `[install] frozenLockfile = true` in `platform/` and
|
||
`monorepo-mobile/`, so `bun install` resolves from `bun.lock` and fails rather than picking up a
|
||
version nobody chose. Changing a dependency means `bun install --no-frozen-lockfile` **deliberately**,
|
||
reading the lockfile diff, and committing it. Never add that flag to a script or CI to silence the
|
||
error. Full reasoning — and the 2026-08-04 npm compromise that prompted it — in `platform/CLAUDE.md`
|
||
→ "Installs are frozen".
|
||
|
||
## Task-runner data flow (script mode) — the mental model
|
||
|
||
File browser right-click → `TaskRunnerDialog` → `TaskRunnerModal`:
|
||
- `cwd = { root:'home', path: currentPath-without-leading-slash }` — the folder the browser is
|
||
**showing** (parent of the clicked entry).
|
||
- `entryName` = the clicked entry; `entry_path` autofill = `~/<cwd.path>/<entryName>` (**tilde** form).
|
||
- For a **directory trigger**, the `ScriptRunner` must descend into the entry:
|
||
`cwd = cwd.path ? `${cwd.path}/${entryName}` : entryName` (mirrors Pipeline/Agentic runners).
|
||
- `POST /jobs {taskDirName, inputs, cwd, action}` → job row in `pipeline_jobs`.
|
||
- `execute-script.ts`: cwd resolved as `isAbsolute(cwd) ? cwd : join(getOwnerHomeDir, cwd)` — so **cwd
|
||
must be home-relative WITHOUT a leading slash**. Inputs passed BOTH as positional `args` (in
|
||
`task.args` order) and as `INPUT_<NAME>` env vars (uppercased). Runs `bun run index.ts` in a temp
|
||
dir — **only `index.ts` is materialized**, so script tasks must be fully self-contained (no sibling
|
||
imports).
|
||
- `inline` tri-state on the task: `false`/absent = background job; `true` = ephemeral modal;
|
||
`'ask'` = modal offers the choice.
|
||
|
||
## Jobs view architecture
|
||
|
||
- `pipeline_jobs.mode` is a TS-only enum (`'pipeline' | 'script' | 'agentic'`) — no DB CHECK.
|
||
- `JobDetailPanel` fetches `GET /jobs/:id`, reads `mode`, dispatches:
|
||
`script → ScriptJobDetail`, `download → DownloadJobDetail` (legacy history), else `PipelineJobDetail`.
|
||
- `GET /jobs/:id` returns the whole row (`db.select().from(pipelineJobs)` → includes `progress`).
|
||
- **Progress protocol**: a script prints `@@officer:progress@@ {json}` lines. `execute-script`'s
|
||
`pumpStdout` line-buffers stdout, plucks those lines out (→ `progress` events, NOT written to the
|
||
`.log`), and passes everything else through to `data/jobs/<id>.log`. The job manager persists the
|
||
latest progress to the `progress` column (periodic flush, `PROGRESS_FLUSH_MS = 3000`).
|
||
`ScriptJobDetail` renders `PhaseBar`s when `progress` has `{meta, dl}` shape (`isDownloadProgress`).
|
||
- The persisted `.log` is the human narration; the bars come from the sentinel. Keep those two
|
||
channels separate — never let sentinels leak into the log.
|
||
- killTree: Stop → SIGTERM the process tree, SIGKILL after ~2s grace → scripts can cooperatively
|
||
abort on SIGTERM/SIGINT.
|
||
|
||
## Frontend navigation — the URL is the source of truth
|
||
|
||
Before building or changing any screen that *selects* something, read
|
||
**`platform/docs/navigation-audit.md`**. It is the authority, and the rules it sets are easy to violate
|
||
by accident because most of the existing app predates it:
|
||
|
||
- Selection state lives in the URL (`useParams` / `useSearchParams`), **not** in a `usePanelChannel`
|
||
or `useGlobal` channel. Channels are for refresh/signal buses only.
|
||
- Rows are real `<Link>`s; nav chrome uses **react-router's `<NavLink>`** so active state comes from
|
||
the router instead of hand-rolled JS. `src/workspaces/components/NavLink.tsx` is a *different* thing
|
||
— a `<Link>` wrapper that carries the global query string, with no active state.
|
||
- Screens come in route pairs (`/x` + `/x/:param`) with a single `<Navigate … replace />` guard.
|
||
A master list with a live preview uses `?selected=<id>`, because the detail route is the full page.
|
||
|
||
Full rationale and the per-case detail are in `platform/CLAUDE.md` → "Frontend route conventions".
|
||
|
||
## Reference capability: `download-media` (the worked example)
|
||
|
||
Script task (`mode: script`, `language: typescript`, `inline: ask`, directory trigger, `category:
|
||
Media`). Talks directly to self-hosted **ReClip** (its own yt-dlp) at `RECLIP_URL`
|
||
(default `http://localhost:8899`): `POST /api/playlist`, `/api/info`, `/api/download {url,format,title}
|
||
→{job_id}`, `GET /api/status/{job_id}`, `/api/file/{job_id}`. **Titles are mandatory** — ReClip names
|
||
the output file from the `title` sent (empty title → hash filename), which is why Phase 1 fetches
|
||
metadata before downloading. Two phases (metadata → download), per-item failures skipped not fatal,
|
||
description sidecars next to each file. Mix/radio playlists (`list=RD…`) return a different set each
|
||
call, so the download panel pre-expands to individual video URLs and the task passes those through
|
||
untouched.
|
||
- `resolveDestDir()`: honours an absolute `INPUT_PATH`, **including the `~/…` autofill form** (expanded
|
||
against `$HOME`); otherwise falls back to `process.cwd()` (which the platform sets to the target
|
||
folder). Relative paths are ignored (would double-resolve).
|
||
|
||
## Category ordering
|
||
|
||
`capabilities/categories.yaml` is a plain ordered list; a task joins a category via its `TASK.md`
|
||
`category:` field. Add a category = one line here + the tasks that use it, no code change. Unlisted
|
||
categories sort alphabetically after listed ones; no category → "Other" (always last).
|
||
|
||
## State of play (2026-08-07)
|
||
|
||
**Multi-user is the current direction, and it is half-built.** On 2026-08-07 a capability permission
|
||
model landed in `platform/src/servers/capabilities/`: `registry.ts` enumerates every capability in four
|
||
kinds (`core` / `app` / `execution` / `admin`), `authorize.ts` resolves them per request, and
|
||
`totality.ts` makes the server **refuse to boot** if a mounted router has no registry entry. Grants are
|
||
keyed on **role**, never on a user, and live in `role_capabilities`; absence denies. `execution`
|
||
capabilities (terminal, chat, files, tasks, items, desktop, browser) run as the owner's OS user in the
|
||
owner's home and are **never grantable at any level** — that is structural, not a policy default.
|
||
|
||
**Account creation now exists** — `POST /api/users`, owner-gated, with a form under Settings → User
|
||
Management, plus role change, password reset, Linux provisioning and delete. It landed in `69a31051`; this
|
||
file claimed the opposite for days afterwards, and so did `platform/CLAUDE.md` and
|
||
`docs/roles-and-teams.md`, which sequenced a phase around building the thing that already existed.
|
||
|
||
Roles are a table as of 2026-08-23, with `users.role` and `role_permissions.role` as foreign keys onto
|
||
`roles.name` with `ON UPDATE CASCADE`. `USER_ROLES` is gone and `UserRole` is `string`, so the guard the
|
||
compiler used to provide is now a runtime check at every write path that takes a role from outside.
|
||
|
||
The design write-up is `COMMS/capability-permissions-2026-08-07.md`; the roles plan and what is still open
|
||
is `docs/roles-and-teams.md`.
|
||
|
||
The single-user claims that used to be all over this repo's docs were corrected on 2026-08-07. If you
|
||
find another one, it is stale — `platform/src/servers/capabilities/registry.ts` is the authority.
|
||
|
||
**Both repos are clean and pushed** — platform to `gitea` and `origin`, capabilities to `gitea` only
|
||
(deliberately; never `rezio`). The sidecar isolation work and the navigation refactor are merged and
|
||
runtime-tested (41-check pass; the checklist was spent and deleted). Every `sidecars-*` branch
|
||
and `navigation-refactor` is deleted — merged, nothing stranded.
|
||
|
||
**Jellyfin is the newest capability** (`officer-jellyfin`, `/jellyfin`): server registry with the access
|
||
token write-only across the boundary, browse/detail/search, and a player with three transports — direct
|
||
file, HLS over a **vendored** hls.js, and a progressive fallback. Two things there are worth knowing before
|
||
touching it. `src/workspaces/officerdev/src/apps/Jellyfin/vendor/` holds hls.js as a committed, unminified
|
||
file with its provenance hashes in a README beside it: that is the frozen-install rule applied to a library
|
||
we genuinely need, and it is reformat-exempt in `.prettierignore` because the diff against upstream is the
|
||
only thing that makes it reviewable. And `useClient()` **rebuilds its verbs on every render** — anything
|
||
that puts one of them in a `useCallback` dependency list gets a new identity every render. That silently
|
||
disabled every playback report in the Jellyfin player for days (an unmount cleanup re-running mid-playback);
|
||
the fix is refs, and the same trap applies to any React Query mutation object.
|
||
|
||
**Projects is gone.** The feature was removed end to end on 2026-07-30 — screens, panel apps, routes,
|
||
dock entry, the `projects` table and its `proj-*` keys in the dashboards endpoint. `getUserProjectsDir`
|
||
in `data-path.ts` stays: the apps and dev-server routers resolve user apps under the same on-disk
|
||
`Projects/` directory and are unrelated. Nothing on disk was touched.
|
||
|
||
**Sidecar work still outstanding**, in rough order of size:
|
||
|
||
- **claude stages 3–5** — invert the transport so the agent listens on its own port instead of dialling
|
||
out, move the 361-line transcript reader in behind it, and survive a restart of the *agent sidecar*
|
||
itself. Stages 0–2 (PM2 peer, durable turn output) are done and running. This is the last sidecar still
|
||
driven by a typed command vocabulary over the registration socket.
|
||
- **the terminal session-orphan leak** — closing a panel for good leaves its shell running. Re-attach on
|
||
reopen and a Running Shells panel landed, so an orphan is now visible and killable; what is missing is
|
||
the panel system being able to tell a real close from an incidental unmount, which is what would let
|
||
anything send `pty:close` automatically.
|
||
- **slskd** is largely compliant. **The vault is off-limits.**
|
||
|
||
**Done since this list was written:** email (store, routes, accounts, resync and both syncs now live in
|
||
the sidecar, which schedules its own work and serves its own HTTP — the platform side is 22 lines),
|
||
pty stage 4 (the bridge is a byte relay onto the sidecar's own listener; the pty command vocabulary is
|
||
gone from the wire protocol), every HTTP sidecar moved onto the shared `createSidecarProxy` factory, and
|
||
five new sidecars built to that shape from the start: photos, notify, caldav, memos, jellyfin.
|
||
|
||
**Known and deliberately unfixed:** on mobile, tapping edit on a dashboard opens the form in a panel you
|
||
cannot see. The dashboard edit form is rendered into a panel that mobile layout never shows; mobile web
|
||
is being retired in favour of a native app, so it is not worth the fix.
|
||
|
||
## Test checklists live here, not in the repo
|
||
|
||
Working checklists for a migration in progress — what has been clicked through and what has not — belong
|
||
in this directory, deliberately untracked. That is state about *this machine at this moment*, not
|
||
something a clone of the repo should carry. **When a migration is finished, delete its checklist**; on
|
||
2026-08-08 ten spent ones went (~750 KB of nav, email, desktop and agent-status notes), and the only
|
||
reason they had survived was that nobody was sure whether anything still pointed at them.
|
||
|
||
`REVOKE-TOMORROW.md` is the exception and must stay until the Discord and Telegram tokens it names are
|
||
actually reset at the providers — it is the only record of which ones.
|
||
|
||
## Working agreement
|
||
|
||
- **Delegate the writing.** When a feature is agreed and what remains is typing — a change repeated
|
||
across many files, translations, a form, a migration over a list of call sites — hand it to a cheaper
|
||
agent, keep working, and review the DIFF when it returns. The owner's architecture, somebody else's
|
||
typing, your review in the middle. This is the default here, not a per-task decision, and it does not
|
||
need asking for. Read `capabilities/skills/delegating/SKILL.md` before writing the brief: an agent that
|
||
has to ask a question has already cost more than it saved. **Pick the cheapest model that will get it
|
||
right** — Haiku for mechanical, Sonnet by default, Opus only when the work still holds a decision, which
|
||
usually means the brief needs finishing rather than a bigger model. Do NOT delegate anything where a
|
||
wrong guess is quiet and expensive — permission checks, credentials, anything that deletes — because reviewing
|
||
those properly costs as much as writing them.
|
||
- **Fix what you find.** If a defect is diagnosed and you know the fix, apply it — don't stop to ask.
|
||
The owner retests immediately and restarts cost nothing. Still ask when it's a genuine product
|
||
decision rather than a defect, when it widens scope, or when it's hard to reverse.
|
||
- Explore related files before editing. Keep it simple, explicit, focused. Note unrelated problems,
|
||
don't fix them uninvited.
|
||
- **Report honestly** — distinguish what you verified (filesystem, DB, logs) from what you assumed.
|
||
- When something "doesn't work," check the restart/cache asymmetry above BEFORE re-diagnosing code.
|
||
- **A background timer must leave evidence, but only interrupt on action.** Any recurring/detached job
|
||
set up on the owner's behalf — a cron entry, a watcher, a poll loop — splits its reporting in two:
|
||
- **To the log, every tick, including no-ops.** A fresh timestamp is the only thing that distinguishes
|
||
"nothing to do" from "the timer died", and that question comes up. Cheap, silent, always on.
|
||
- **To the owner, only when the tick actually acted on something.** Say what changed and what was done
|
||
about it, unprompted. Never narrate a quiet tick at him — a heartbeat he has to read is noise.
|
||
Current instance: `auto-pull.sh` + the `* * * * *` crontab entry, log at `officer.dev/auto-pull.log`.
|