Files
platform/plugins/EXTRACTING-A-PLUGIN.md
T
pastilhasandClaude Opus 5 965ced52a6 a runbook for the next extraction, and a warning about music
plugins/EXTRACTING-A-PLUGIN.md: the rules that are not preferences, the order
that worked, the verification cycle the owner actually ran, and the traps that
each cost real time.

the music section is the important half. music is NOT a bigger offscale — three
of its parts have nowhere to go: two websocket providers (and no plugin can own
a socket), a global UI overlay rendered by DashboardLayout on every route, and a
dashboard widget in a third workspace package. its relay is platform code that
imports the music router, so deleting that router breaks the platform. it also
needs six external binaries a manifest cannot declare, ships a non-TS asset, and
is the worked example the capability tests are written through.

recorded before starting rather than discovered halfway, because a half-extracted
music is worse than an unextracted one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 01:11:14 +00:00

10 KiB

Extracting a feature into a plugin

The runbook, written the day offscale became the first one. Follow it for music, then for the rest.

Read first, in this order:

  1. plugins/offscale/PLUGIN.md — every decision and why, including the three that reversed
  2. plugins/example/ — the reference implementation, deliberately the smallest real plugin
  3. plugins/offscale/ — the worked example, all four parts
  4. src/servers/plugins/ — the system itself: manifest, discover, mount, install, ecosystem, schema, generate

The rules. These are not preferences

Every plugin route renders a Workspace with at least one panel. A plugin contributes web/panels.ts (appRegistryMetas, at least one) and web/layout.ts (defaultLayout); the shell renders WorkspaceView around them. There is no way to export a component — a web/ directory missing either file is refused at discovery, by name. Non-compliance is unrepresentable, not forbidden.

Every plugin permission is grantable, per role, at read or write. No kind, no ownerOnly, no field of any sort. The platform's answer is uniform; what a grant means — whose rows a member sees, whether a resource is shared or per-user — is the plugin's own job, in its own queries.

Say permissions, never the other word. It already means three things in this codebase.

The manifest holds only what a directory listing cannot say. Identity facts and human choices: publisher, version, platform, label, summary, icon, color, permissions. Everything structural is convention — presence is the declaration:

manifest.ts        required
api/router.ts      a backend router, mounted at mountPrefix()
db/schema.ts       tables, prefixed <app-name>_
sidecar/index.ts   a process (.mjs instead means node)
web/panels.ts      panels — REQUIRED with web/
web/layout.ts      layout — REQUIRED with web/

appName is the directory name. The sidecar runtime is the file extension.

Nothing may branch on provenance except mountPrefix(). First-party and third-party differing anywhere else means two systems, and only one gets tested.

Uninstall never destroys data. The generated schema barrel follows plugin directories, not the install table — db:push drops what it cannot see, so following installs would delete a plugin's tables on uninstall. Only deleting a plugin's source can lose its data.


The order that worked

  1. Map it first. Sidecar, api router, db, frontend, and every line of platform wiring that names it.
  2. Move the backend: sidecar/plugins/<name>/sidecar/, api/<name>/router.tsplugins/<name>/api/router.ts (export router, not <name>Router), officer_db/src/<name>/*plugins/<name>/db/.
  3. Rewrite imports. Platform code becomes @@/… (resolves from plugins/ — verified). Queries take officerdb/db and officerdb/crypto. Schema takes officerdb/auth/schemausers.id is the one reference a plugin may make.
  4. Write manifest.ts.
  5. Move the frontend to web/, as panels.ts + layout.ts. Imports of platform UI become officerdev (the barrel exports WorkspaceView, TerminalView, AppRegistryMeta); hooks/useClient and helpers/clipboard stay as they are.
  6. Remove every trace from the platform, and delete rather than comment out: hono.ts mount and import, the capabilities/registry.ts entry, App.tsx routes, Screens/Dashboard/index.tsx, AppRegistry.tsx, officerdev/src/index.ts re-exports, Dock.tsx tile, usePageTitle.ts rule, and both database barrels (index.ts and schema.ts).
  7. bunx tsgo until clean. It finds the wiring you missed.
  8. Verify on the live server — see below.
  9. Commit and push. Message says what moved, what it found, and what is still open.

Verification — run all of it

bun test                     # 757 pass, 10 pre-existing failures. Any 11th is yours
pm2 restart officer

Then through /plugins, watching PM2 and the browser at each step:

Step Expect
install streamed log; schema applied; sidecar online; route mounted
the plugin's API answers
the plugin's screen renders as a Workspace
dock tile appears
permissions page its permission is listed, read/write/none
disable route 404s, sidecar stops, tables and rows survive
enable comes back
uninstall route gone, pm2 list loses it, data still there
bun db:push while uninstalled No changes detected — data survives
install again identical to the first install

A normal refresh is enough; the shell is no-store. When the log's last line appears, the bundle exists.


Traps, all of which cost real time once

  • Mount before starting the sidecar. createSidecarProxy learns its port from a one-shot <name>:server event and subscribes when the router is first imported — at mount. Start first and the announcement fires into a void: online process, mounted routes, every request 503. Already fixed in install.ts; do not reorder it.
  • src/servers/sidecar/protocol.ts still declares <name>:server per sidecar. Music will need its line kept, or the union generalised to `${string}:server` — which is the better fix and is pending for the whole protocol.
  • bunfig.toml plugins do not reach Bun.build(). Tailwind is passed explicitly in generate.ts.
  • The shell output is named for the entrypoint (index.gen.html), and naming does not change it.
  • A stale generated file (Plugins.gen.tsx, plugin-schemas.gen.ts) will fail the typecheck after a contract change. Regenerate rather than hand-edit.

Music specifically — READ THIS BEFORE STARTING

Music is not a bigger offscale. It does not fit the shape, and three of its parts have nowhere to go. Mapped 2026-08-15. Decide what to do about each before moving a file, or the extraction strands half-done.

What has no home yet

  1. Two websocket providers. cliamp and cliamp-audio, upgraded at /api/cliamp/ws and /api/cliamp/audio/ws in server.tsx — the routes are still live while their handlers are commented out, so they currently upgrade into nothing. No plugin can own a socket: server.reload({ routes }) is proven and never called. Worse, the relay itself (src/servers/api/cliamp/relay.ts, 110 lines) is platform code that imports getMusicServerWsUrl from the music router — so deleting that router breaks the platform.
  2. A global UI overlay. MusicPlayerHost is rendered by DashboardLayout on every route, not inside a panel, and gates itself on can('music'). A plugin contributes panels and a layout; there is no "render this everywhere" slot, and inventing one is a platform change.
  3. A dashboard widget. src/workspaces/widgets/MusicPlayer/ in a third workspace package, wired through WidgetRegistry. Plugins cannot contribute widgets.

What else differs

  • External binaries: cliamp, ffmpeg, ffprobe, parec, pulseaudio, pactl. pulse-audio.ts starts a daemon and loads a null sink at boot. A manifest has no way to declare a host dependency.
  • A non-TS asset: sidecar/music/asoundrc, loaded via import.meta.dir. It must travel.
  • Range requests: stream-audio.ts does 206 / Content-Range / 416 and a custom X-Audio-Duration; the proxy runs at timeoutSeconds: 1800 for reindexes. Verify Range survives the hop.
  • Cross-capability: MusicBrowser calls /file-browser/ls, not the music sidecar.
  • A CLI tool: scripts/reindex-music.ts reads DATA_PATH/music/.server.
  • Music is the platform's worked example. registry.ts explains personal through it and registry.test.ts tests the mechanism through the music capability. Those tests must be rewritten against something else, not deleted.
  • Already half-disabled: hono.ts mount and schema.ts export are commented out since 2026-08-13; officer_db/src/index.ts:49 is still live. So the tree is mid-migration already.

The per-user shape, which is why music was chosen

personal: ['/favorites', '/now-playing', '/playlists', '/queue'],

No readOnlyWrites. Note /queueno such route exists, in the sidecar or the frontend. Dead or aspirational; find out which.

Four tables, all userId + cascade to users.id: music_favorites, music_playlists, music_playlist_items, music_now_playing (composite PK (userId, device)).

And the mismatch that makes music the interesting case: the state is per-user, the library is one global directory. MUSIC_ROOT = join(homedir(), 'Music'), and stream-audio.ts and cliamp-ws.ts each take homedir() as their root independently. So a granted member's favourites are theirs, while /stream serves them the owner's files and cliamp spawns a process in the owner's home. The app-store entry promises a configurable library folder and nothing reads it.

Suggested order

Do not invent the visibility model during the move. Extract with the semantics it already has, or the two changes tangle and a bug in either is a bug in both.

  1. Decide the three homeless parts first — close the platform gaps, or leave those pieces behind and say so explicitly in the manifest. Either is defensible; drifting is not.
  2. assertCapabilityTotality should be fixed before this one: the cliamp routes are the live example of the exact drift it cannot currently see.
  3. Then the move, in the order above.

Still open, platform-wide. Do not rediscover these

  • Websocket providersserver.reload({ routes }) proven, never called
  • assertCapabilityTotality reads the wrong listObject.keys(handlers) while Bun serves the route table, and plugin routes are not in PROTECTED_API_PREFIXES at all. It belongs in buildHonoApp(), now the single place routes are mounted. Security-adjacent; close it before members reach plugin routes.
  • Two dock sources — the app store keeps its own catalogue; one when it is rebuilt on this
  • Offscale's queries scope by caller, so a granted member sees their own empty list rather than the owner's. Its own job, not the platform's.