Ran the whole table against platform.officer.dev rather than reasoning about it. install / API / range requests / dock / permissions / disable / enable / uninstall / db:push-while-uninstalled / reinstall / pm2 restart officer — all pass, recorded in plugins/music/PLUGIN.md with the actual numbers. Two results worth naming. The reinstall was a RESTORE: a favourite and a playlist seeded before uninstall came back untouched, which is the whole point of the barrel following directories rather than the install table. And `bun db:push` while uninstalled said `No changes detected` with the rows still there — the property that makes running push by hand safe at any moment. Range survives the proxy hop: 206 with a correct Content-Range and exactly the bytes asked for, 416 unsatisfiable, 400 on a path escaping the Music root. Two findings from the machine rather than the code. ffmpeg and ffprobe are not installed here, so X-Audio-Duration never appears and indexing would produce no tags or covers — the manifest already says a host binary cannot be declared, and now says it was checked. And ~/Music did not exist at all, so the library is empty; the sidecar handles both absences and logs them rather than failing. Music is left INSTALLED and enabled. It had been switched off since 2026-08-13, so this restores it. The runbook gains what generalises: map what the PLATFORM still needs from your feature before planning the split, because two imports pointing the wrong way dictated music's boundary rather than any judgement about what music is. Offscale had none, which made the job look cleaner than it is. Also two new traps — delete the app-store catalogue entry or the screen goes blank, and compare test FILE COUNTS across a run, since that is the only thing that shows a test which stopped being discovered. The global-overlay question is recorded as answered: no. A shell slot for a plugin-provided component reopens "there is no way to export a component", and that rule is what the frontend contract rests on. Seeded rows and the audio fixture removed; ~/Music deleted again. bunx tsgo clean. 772 tests, 762 pass, 7 fail — all pre-existing.
11 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 partsplugins/music/PLUGIN.md— the MESSY worked example: three pieces that stayed behind, and why each is a seam rather than a loose end. Read it if your feature has anything the platform also uses.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
- 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. - Delete the feature's
app-store/catalogue.tsentry, or its screen goes blank.capabilityAvailabilityderives fromsidecar_installs, and a plugin never gets a row there — its install state isplugin_installs. A leftover catalogue entry therefore makes the capability permanentlyunavailable, which puts its route intodeniedRoutesand withholds the dock tile, on a server where the plugin is installed and healthy. This has now bitten twice: headscale (2026-08-14) and nearly music. The note incatalogue.tsis the one to read. - Moving a
*.test.tsintoplugins/used to stop it running, silently.[test] rootwas./srcuntil music; it is now.. If that ever goes back, every extraction quietly shrinks the suite. Compare the FILE COUNT across a run, not just pass/fail — that is the only thing that shows it. - A plugin importing platform code is fine (
@@/…); the reverse is not. If something insrc/imports from your feature and cannot move — a widget, a relay — that piece stays, and the boundary goes around it. Find those before you plan the split; they decide it for you.
Music is done. What it changed about this runbook
Extracted 2026-08-15 and verified live through the whole table above. plugins/music/PLUGIN.md is the
record; the parts worth carrying forward are already folded into the rules and traps above.
The one thing that generalises: map what the PLATFORM still needs from your feature before you plan the
split. Music's boundary was not chosen — it was dictated by two imports pointing the wrong way (a
dashboard widget reaching for useMusicPlayer, a cliamp relay reaching for getMusicServerWsUrl), and
both were found by reading the import graph rather than by reasoning about what music "is". Offscale had
none, so it came out whole and made the job look cleaner than it is.
The three pieces music left behind are officerdev/src/MusicPlayer/, src/servers/api/music/router.ts
and everything cliamp. Each is documented where it sits. None of them is work waiting for you — do
not tidy them into a plugin as a warm-up.
The global-overlay question is answered, and the answer is no
Music was the first feature wanting to render on every route. It does not get to, and neither will the
next one: a shell slot for a plugin-provided component reopens "there is no way to export a component",
which is the rule the whole frontend contract rests on. MusicPlayerHost stays in DashboardLayout,
gated on its plugin's permission so it switches itself off with the plugin.
Reopen this only for a feature where the overlay is the whole product, and expect to argue for it.
Which one next
No decision has been made. What the tree says, for whoever picks it:
schema.tsstill lists eight commented plugin schemas — email, notify, dav, photos, jellyfin, invoiceshelf, soulseek, vault, wallet. Each line names its tables and the file that defines them, which is exactly what its extraction needs.hono.tsstill has fifteen commented mounts. Same list, roughly.- Soulseek is the interesting one, and not because it is easy:
docs/navigation-audit.mdrecords its panels making 37 raw upstream calls, which is the mistake the offscale sidecar exists to avoid. Its extraction is a rewrite wearing a move's clothes. Say so up front rather than discovering it at 2am. - Email and wallet both hold credentials, so they meet
secret-storeandservice_connectionsin a way neither of the first two did. Readdocs/secret-store.mdfirst.
Still open, platform-wide. Do not rediscover these
- Websocket providers —
server.reload({ routes })proven, never called. No plugin owns a socket yet; music would have been the first and cliamp being out of scope is what let it pass. 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. The live example is the two cliamp sockets: served in the route table, claimed by no capability, and invisible to the check. Pinned by a test inregistry.test.tsso it stays a known fact.- 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.
protocol.tsdeclares<name>:serverper sidecar.music:serverandheadscale:serverare both still there for plugins that have left. Generalising the union to`${string}:server`is the fix.hasPersonalWritesreadsc.personalonly, so a plugin declaring the same thing throughreadOnlyWritesreportsfalse. Nothing renders it, so it is dead on the wire.