Files
claude-mds/officer.dev/CLAUDE.md
T
pastilhas dc91f78f4f officer.dev: the restart rule was stale, and said the opposite
This file said "NEVER run pm2 yourself — tell the owner the exact command." The
owner granted the opposite on this machine on 2026-08-19, so the document and
the instruction disagreed for a day, and user/CLAUDE.md already flagged it as a
local rule to follow "while it says so".

It now says to run the restart and report it. The scope is unchanged and is the
part that mattered: only the process the change needs, never the whole
ecosystem.config.cjs.

Added the caveat the grant did not remove — check who else is live first. The
owner edits the platform using the platform, several sessions share one
checkout, and a restart is the moment a half-applied change from someone else's
work in progress would ship.
2026-08-21 00:24:29 +00:00

248 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 PM2 entries** (verified 2026-08-07 with `pm2 jlist`): `officer`,
`officer-anthropic-proxy`, `officer-agent`, `officer-opencode`, `officer-email`, `officer-pty`,
`officer-vnc`, `officer-music`, `officer-vault`, `officer-slskd`, `officer-headscale`,
`officer-transmission`, `officer-invoiceshelf`, `officer-wallet`, `officer-photos`, `officer-notify`,
`officer-caldav`, `officer-memos`, `officer-jellyfin`, `officer-gitea`.
**`pm2 list` shows twenty-two** — `mybiblepal-api` and `mybiblepal-audio` are a different project sharing
this PM2 daemon, and nothing here should touch them.
Never `pm2 restart ecosystem.config.cjs` — it bounces every
sidecar including the vault. Use `--only <name>`, or restart the single process the change needs.
This list goes stale every time a sidecar lands; `pm2 jlist` is the source of truth.
## Environment (this machine)
- `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.
What is NOT built: **there is no way to create a second account.** `createUser` has one call site,
`api/auth/bootstrap.ts`, gated on an empty `users` table. No signup route, no invites, no admin
create-user handler — the six accounts in Postgres were inserted by hand. Details and the open defect
list are in `platform/TODO.md` → "Multi-user"; the design write-up is `COMMS/capability-permissions-2026-08-07.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`.