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>
21 KiB
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. Processofficer(NODE_ENV=production bun src/server.tsx). So platform TypeScript changes do NOT hot-reload. After editing anything underplatform/src(backend or frontend), it needs apm2 restart officerto 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 officeralone, never the wholeecosystem.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.
- 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
-
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). EditTASK.md/index.ts/categories.yamland 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 bysrc/server.tsx, regenerated byplatform/scripts/gen-index.tsat 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-categorieswithstaleTime: 60_000. Menu/category changes lag up to a minute or until the view remounts. Hard refresh forces it. -
pm2 restart officerno 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 runsclaudewas 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 tochat_session_eventsitself. There is noofficer-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— plusofficer-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-minesweeperandofficer-transcribewere 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-apiandmybiblepal-audioare 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 jlistis 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/pastilhasandHOME=/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 managedgetHomeDir. -
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 withuniqueIndex, neverunique().on(...)— drizzle-kit mis-diffs named composite unique constraints and re-creates them on every push, which used to stopdb:pushon an unanswerable truncate prompt. Rules inplatform/src/databases/CLAUDE.md→ "Composite keys". -
Typecheck with
bunx tsgo(not tsc). Keep it clean. -
Installs are frozen.
bunfig.tomlsets[install] frozenLockfile = trueinplatform/andmonorepo-mobile/, sobun installresolves frombun.lockand fails rather than picking up a version nobody chose. Changing a dependency meansbun install --no-frozen-lockfiledeliberately, 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 — inplatform/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_pathautofill =~/<cwd.path>/<entryName>(tilde form).- For a directory trigger, the
ScriptRunnermust descend into the entry:cwd = cwd.path ?${cwd.path}/${entryName}: entryName(mirrors Pipeline/Agentic runners). POST /jobs {taskDirName, inputs, cwd, action}→ job row inpipeline_jobs.execute-script.ts: cwd resolved asisAbsolute(cwd) ? cwd : join(getOwnerHomeDir, cwd)— so cwd must be home-relative WITHOUT a leading slash. Inputs passed BOTH as positionalargs(intask.argsorder) and asINPUT_<NAME>env vars (uppercased). Runsbun run index.tsin a temp dir — onlyindex.tsis materialized, so script tasks must be fully self-contained (no sibling imports).inlinetri-state on the task:false/absent = background job;true= ephemeral modal;'ask'= modal offers the choice.
Jobs view architecture
pipeline_jobs.modeis a TS-only enum ('pipeline' | 'script' | 'agentic') — no DB CHECK.JobDetailPanelfetchesGET /jobs/:id, readsmode, dispatches:script → ScriptJobDetail,download → DownloadJobDetail(legacy history), elsePipelineJobDetail.GET /jobs/:idreturns the whole row (db.select().from(pipelineJobs)→ includesprogress).- Progress protocol: a script prints
@@officer:progress@@ {json}lines.execute-script'spumpStdoutline-buffers stdout, plucks those lines out (→progressevents, NOT written to the.log), and passes everything else through todata/jobs/<id>.log. The job manager persists the latest progress to theprogresscolumn (periodic flush,PROGRESS_FLUSH_MS = 3000).ScriptJobDetailrendersPhaseBars whenprogresshas{meta, dl}shape (isDownloadProgress). - The persisted
.logis 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 ausePanelChanneloruseGlobalchannel. 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.tsxis 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 absoluteINPUT_PATH, including the~/…autofill form (expanded against$HOME); otherwise falls back toprocess.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:closeautomatically. - 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.mdbefore 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 atofficer.dev/auto-pull.log.