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>
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:
plugins/offscale/PLUGIN.md— every decision and why, including the three that reversedplugins/example/— the reference implementation, deliberately the smallest real pluginplugins/offscale/— the worked example, all four partssrc/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
- Map it first. Sidecar, api router, db, frontend, and every line of platform wiring that names it.
- Move the backend:
sidecar/→plugins/<name>/sidecar/,api/<name>/router.ts→plugins/<name>/api/router.ts(exportrouter, not<name>Router),officer_db/src/<name>/*→plugins/<name>/db/. - Rewrite imports. Platform code becomes
@@/…(resolves fromplugins/— verified). Queries takeofficerdb/dbandofficerdb/crypto. Schema takesofficerdb/auth/schema—users.idis the one reference a plugin may make. - Write
manifest.ts. - Move the frontend to
web/, aspanels.ts+layout.ts. Imports of platform UI becomeofficerdev(the barrel exportsWorkspaceView,TerminalView,AppRegistryMeta);hooks/useClientandhelpers/clipboardstay as they are. - Remove every trace from the platform, and delete rather than comment out:
hono.tsmount and import, thecapabilities/registry.tsentry,App.tsxroutes,Screens/Dashboard/index.tsx,AppRegistry.tsx,officerdev/src/index.tsre-exports,Dock.tsxtile,usePageTitle.tsrule, and both database barrels (index.tsandschema.ts). bunx tsgountil clean. It finds the wiring you missed.- Verify on the live server — see below.
- 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.
createSidecarProxylearns its port from a one-shot<name>:serverevent and subscribes when the router is first imported — at mount. Start first and the announcement fires into a void: online process, mounted routes, every request503. Already fixed ininstall.ts; do not reorder it. src/servers/sidecar/protocol.tsstill declares<name>:serverper 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.tomlplugins do not reachBun.build(). Tailwind is passed explicitly ingenerate.ts.- The shell output is named for the entrypoint (
index.gen.html), andnamingdoes 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
- Two websocket providers.
cliampandcliamp-audio, upgraded at/api/cliamp/wsand/api/cliamp/audio/wsinserver.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 importsgetMusicServerWsUrlfrom the music router — so deleting that router breaks the platform. - A global UI overlay.
MusicPlayerHostis rendered byDashboardLayouton every route, not inside a panel, and gates itself oncan('music'). A plugin contributes panels and a layout; there is no "render this everywhere" slot, and inventing one is a platform change. - A dashboard widget.
src/workspaces/widgets/MusicPlayer/in a third workspace package, wired throughWidgetRegistry. Plugins cannot contribute widgets.
What else differs
- External binaries:
cliamp,ffmpeg,ffprobe,parec,pulseaudio,pactl.pulse-audio.tsstarts 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 viaimport.meta.dir. It must travel. - Range requests:
stream-audio.tsdoes 206 /Content-Range/ 416 and a customX-Audio-Duration; the proxy runs attimeoutSeconds: 1800for reindexes. VerifyRangesurvives the hop. - Cross-capability:
MusicBrowsercalls/file-browser/ls, not the music sidecar. - A CLI tool:
scripts/reindex-music.tsreadsDATA_PATH/music/.server. - Music is the platform's worked example.
registry.tsexplainspersonalthrough it andregistry.test.tstests the mechanism through the music capability. Those tests must be rewritten against something else, not deleted. - Already half-disabled:
hono.tsmount andschema.tsexport are commented out since 2026-08-13;officer_db/src/index.ts:49is 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 /queue — no 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.
- 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.
assertCapabilityTotalityshould be fixed before this one: the cliamp routes are the live example of the exact drift it cannot currently see.- Then the move, in the order above.
Still open, platform-wide. Do not rediscover these
- Websocket providers —
server.reload({ routes })proven, never called assertCapabilityTotalityreads the wrong list —Object.keys(handlers)while Bun serves the route table, and plugin routes are not inPROTECTED_API_PREFIXESat all. It belongs inbuildHonoApp(), 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.