Files
pastilhasandClaude Opus 5 89fc3241f6 officer-dev is twenty-six PM2 entries, not thirteen
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>
2026-08-27 08:44:58 +00:00

21 KiB
Raw Permalink Blame History

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-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 — plus officer-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-minesweeper and officer-transcribe were 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-api and mybiblepal-audio are 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 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/<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 PhaseBars 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.

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

  • 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.md before 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 at officer.dev/auto-pull.log.