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.
248 lines
18 KiB
Markdown
248 lines
18 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 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`.
|