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

18 KiB
Raw 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 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 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.

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.