This file said there was no way to create a second account. There is, and has been since 69a31051 — POST /api/users, owner-gated, with a form under Settings. platform/CLAUDE.md and docs/roles-and-teams.md carried the same stale claim and were corrected in the platform repo; this one was missed because I said it was not in a git repository. It is, through the symlink. Also records roles as a table with foreign keys onto roles.name, USER_ROLES gone, and UserRole as a plain string guarded at the write path instead of by the compiler. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
19 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 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-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
- 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.