# 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/.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 THIRTEEN PM2 entries here today** (verified 2026-08-23 by `bun run docs:check`, which fails if this drifts): `officer`, `officer-anthropic-proxy`, `officer-atelier`, `officer-browser`, `officer-caldav`, `officer-claude-code`, `officer-email`, `officer-marketplace`, `officer-notify`, `officer-oas`, `officer-offscale`, `officer-opencode`, `officer-pty`. **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 `, 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//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 = `~//` (**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_` 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/.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 ``s; nav chrome uses **react-router's ``** so active state comes from the router instead of hand-rolled JS. `src/workspaces/components/NavLink.tsx` is a *different* thing — a `` wrapper that carries the global query string, with no active state. - Screens come in route pairs (`/x` + `/x/:param`) with a single `` guard. A master list with a live preview uses `?selected=`, 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 - **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`.