Files
platform/plugins/music/PLUGIN.md
T
pastilhas 027b10bd6e step 4/4: the docs say permissions too, and capability means one thing again
44 files of prose — CLAUDE.md, AGENTS.md, TODO.md, 20 docs, both plugin design
documents, and the comment surface the earlier steps could not reach.

Applied against an explicit keep-list, not swept, because the word turned out to
have SIX meanings in this repository rather than the three the offscale doc
recorded:

  permissions          renamed (steps 1–2)
  $OFFICER_ROOT/capabilities/  KEPT — the item store, and now the only thing
                               the word means that is ours
  sidecar routing keys renamed to `handles` (step 3)
  Lightning wallet     KEPT — a domain term, and on the wire to the mobile apps
  terminfo queries     KEPT — XTGETTCAP, in the pty sidecar
  InvoiceShelf         KEPT — per-resource { write, bulkDelete } flags

The sweep still falsified two things, both caught by checking rather than by
review, and both in prose that discusses more than one meaning at once:

CLAUDE.md began claiming the item store lives at `$OFFICER_ROOT/permissions`.
It does not; that directory is on disk and full of skills and tools.

And the offscale doc's own note about the collision became
"Named `permissions`, NOT `permissions`" — a sentence that had eaten the thing
it existed to warn about.

Both restored, and the note rewritten to say what is now true: capability means
one thing of ours, and three that belong to somebody else's vocabulary.

Verified live after restart: self and admin permission endpoints 200, gated
route 200, agent-status 200, 9 grants intact with 6 permissions offered.
tsgo clean, 797 tests, 787 pass, same 7.

The rename is done. Four steps, no data lost, no client break that survived
the step it was introduced in.
2026-08-15 16:31:11 +00:00

18 KiB

Music — the second plugin

Status: extracted 2026-08-15. Written after the fact rather than during, because unlike offscale this one had no design questions left open — the runbook (plugins/EXTRACTING-A-PLUGIN.md) had already decided everything except one call. This records what moved, what did not, and the two bugs the extraction found.

Read plugins/offscale/PLUGIN.md first. It is the design document for the plugin system; this is a worked second case, and it is interesting mainly for being the messy one.


What music is

The /music screen, the library index, and the phone and tablet apps that stream from it. The contract those apps speak is MUSIC_API.md, next to this file — it is the reason the sidecar's HTTP shape is not free to change.

manifest.ts          identity, one permission
api/router.ts        re-exports the platform's proxy — see below
sidecar/index.ts     the whole /api/music contract (503 lines)
sidecar/indexer.ts   the library walker → cache tree + manifest (1079 lines)
sidecar/stream-audio.ts   206 / Content-Range / 416, and X-Audio-Duration
sidecar/nightly-reindex.ts  3am full rebuild, staged and swapped
db/                  music_favorites, _playlists, _playlist_items, _now_playing
web/                 two panels and a layout; the shell renders the Workspace
scripts/             the reindex CLI, which talks to the sidecar port directly

The three things that stayed, and why

Offscale left nothing behind. Music leaves three, and calling them seams rather than loose ends only means each one is written down with what would close it.

1. cliamp — parked in the plugin, not left in the platform

cliamp and cliamp-audio are a second playback path: the cliamp TUI run on the server, with its terminal and its PulseAudio null sink piped to the browser. Out of scope by the owner's decision.

All of it now lives in ./cliamp/ — moved 2026-08-15, in two passes on the same day:

sidecar/music/{cliamp-ws,pulse-audio}.ts, asoundrc, cliamp-ws.test.ts   →  cliamp/
api/cliamp/relay.ts                                                     →  cliamp/relay.ts
apps/FileBrowser/{CliampPanel,AudioStreamPlayer}.tsx                    →  cliamp/

src/servers/sidecar/music/, src/servers/api/cliamp/ and src/servers/api/music/ are gone, and server.tsx has no cliamp import, provider name, handler entry or route left.

Two things went with it that were live rather than inert:

  • The file browser's Play action. A context-menu item on any audio file or folder set ?play=, which rendered a cliamp terminal panel pointed at /api/cliamp/ws — a route that upgraded into a handlers entry that was commented out, so handlers[provider]!.open(ws) asserted non-null on undefined. Using that menu item crashed the socket handler. The action, its layout, its panel and its two menu entries are removed; the components are parked here.
  • The two socket routes. They now 404. Verified live.

That closed the totality drift as a side effect: server.tsx's route table and its handlers map agree again, which they had not since 2026-08-13. registry.test.ts keeps an assertion on it.

What it takes to bring cliamp back: a plugin owning a websocket. server.reload({ routes }) is proven and never called. A platform gap, not a music one.

2. src/servers/api/music/router.ts — deleted, and the reason it existed was nothing

The proxy was constructed in PLATFORM code that knew the string 'music', and this plugin's api/router.ts merely re-exported it. The stated reason: api/cliamp/relay.ts imported getMusicServerWsUrl from it, so it could not move.

That reason was three layers of nothing:

  • server.tsx:20 imported the relay's two exports — used only on commented-out lines
  • so the relay's functions were never invoked, and its call to getMusicServerWsUrl never ran
  • and the file's other export, getMusicServerUrl, had no consumers at all

A dead import held a music-named file in the platform. The proxy is now built in plugins/music/api/router.ts; the relay takes its URL from there.

And the prefix is derived rather than written. It was the literal '/api/music', which the proxy uses to strip characters off the path. That is correct only because mountPrefix returns /music for a first-party publisher — the same plugin published by anyone else mounts at /api/p/<publisher>/music and would have forwarded /alice/music/stream to a sidecar expecting /stream. A latent bug only third parties would ever hit, and a quiet violation of the rule that mountPrefix is the one function allowed to know about provenance. It now calls mountPrefix.

[open] appName is still a literal there, because a plugin's router cannot see its own directory name — the platform imports the module and reads router, so there is nowhere to inject it. The fix is api/router.ts exporting a factory the installer calls with the plugin's own identity.

3. The player — moved, and the reasoning that kept it was removed rather than refuted

The first version of this document said the player stayed in the platform and called the decision settled by a hard constraint:

useMusicPlayer and PlayerTrack are imported from officerdev by widgets/MusicPlayer/, the dashboard widget — and the platform cannot import from a plugin. So the player state stays whatever is decided about the UI.

True at the time. The owner then moved the widget into the plugin, and the constraint evaporated: the complete remaining platform dependency became one line, DashboardLayout.tsx:66.

So the whole of officerdev/src/MusicPlayer/ now lives in web/ — engine, state, bar, favourites, lyrics toggle and the library vocabulary. src/ contains no music code at all.

Where the engine is mounted, and why it is not the shell. MusicPlayerHost renders inside MusicDetail, at the foot of the library view. That reads odd until you notice what it already did:

if (pathname.startsWith('/music')) return null; // the panel draws its own MusicMiniBar

On /music the host has always rendered nothing and existed purely to own the GaplessEngine. Mounted in the panel it does exactly that, and the bar code stays intact for whenever there is a slot to put it in.

[phase 2] Leaving /music unmounts the host, which stops playback. Deliberately deferred rather than solved: making audio outlive the route needs either a shell slot a plugin can contribute to — which reopens "there is no way to export a component" — or the engine hoisted to module scope so a panel attaches and detaches from a singleton. The second keeps the rule and loses only the off-route transport controls, and is the better idea, but it is a rewrite of the host's lifecycle rather than a move.

Nothing breaks in the meantime, and that was the bar: player-time's seekPlayer is optional-chained so a call with no host registered is a no-op, registerPlayerSeek clears only its own registration, the host's cleanup destroys the engine and nulls its ref, and the queue lives in global state — so returning to /music remounts the host and reloads it.

Two bugs, neither visible from reading

The app-store catalogue still listed music, and that would have blanked the screen. permissionAvailability() derives from sidecar_installs, and a plugin never gets a row there — its install state is plugin_installs. So music would have been permanently unavailable, which puts /music into deniedRoutes: dock tile withheld, screen blank, on a server where the plugin was installed, enabled and healthy.

This is the headscale bug, exactly — and it is documented six lines above where the music entry sat, in the same file. Found by reading that note rather than by hitting it again, which is the only reason it cost minutes instead of an evening. Entry removed.

[test] root = "./src", so moving lyrics.test.ts into plugins/ stopped running it silently. The count fell by nine and the suite still read green-ish. A test that quietly stops running is worse than one that fails, and every future extraction would have taken its tests out of the suite the same way. Root is now the repo. Positional filters cannot fix this — bun test plugins matches paths under root, so it finds src/servers/plugins/ and not plugins/.


Permissions

One permission, music, and the key is deliberately unchanged from the registry entry it replaces — so every existing role_permissions grant keeps meaning what it meant, and can('music') keeps resolving for the overlay. Renaming it would have been a silent data change.

The old entry carried personal: ['/favorites', '/now-playing', '/playlists', '/queue']. A manifest has no personal field and should not grow one: that is the per-user visibility model, which is the plugin's own job and explicitly not this extraction's work. They ride across on readOnlyWrites instead, because isRequestAllowedAtLevel concatenates the two lists — one mechanism under two names. A read grant therefore permits exactly the four paths it permitted yesterday, and no field was added.

/queue is in that list because it was. No such route exists, in the sidecar or anywhere else.

[open] What a member's grant means is unfinished, and music is where the richer model was always going to be designed (plugins/offscale/PLUGIN.md says so). It is genuinely non-uniform here in a way offscale's is not: favourites, playlists and now-playing are already per-caller — the sidecar scopes every one by the X-Officer-User header the proxy injects — while the library is one shared index for the household. So "whose row is this" already has a real answer on one side and not the other. That is a change inside db/queries.ts, not a flag on the manifest.


Host dependencies — the field music created

ffmpeg and ffprobe. Offscale needed nothing, so until music there was no reason to build this and no way to say it; the first draft of this document said "there is no field for a host binary" and left it at that. That was the wrong answer, because of HOW music fails without them.

It does not fail. ffprobe missing means the indexer catches the spawn error and returns a track carrying its filename and nothing else — no title, artist, album, duration or embedded lyrics — then walks the whole library, writes a complete cache tree and reports success. Five swallowed catches in indexer.ts and stream-audio.ts, no log, no counter. The only tell is coversSaved: 0 in a report nobody reads. A refusal wearing the costume of a normal result.

So osDependencies is a manifest field now (servers/plugins/manifest.ts, servers/plugins/os-deps.ts):

osDependencies: [
  { binary: 'ffprobe', reason: '…', packages: { apt: 'ffmpeg', pacman: 'ffmpeg', dnf: 'ffmpeg', brew: 'ffmpeg' } },
  { binary: 'ffmpeg',  reason: '…', packages: {  } },
]

Both are declared even though one package provides both, because the platform probes BINARIES and these two fail differently — and the owner should be told which one they are missing. The installer dedupes to a single ffmpeg before anything reaches a command line.

The shape is scripts/setup-old/setup.sh's, not invented: probe the binary, map to a package name per manager. Probing the binary is what makes "built-in on this OS" free — if it is on PATH the package map is never consulted. Per-manager names rather than canonical-with-overrides because packages.sh already recorded why that indirection was rejected.

Verified end to end on 2026-08-15. Both binaries were absent on this machine all evening. The plugins page showed ffprobe missing — ffmpeg and ffmpeg missing — ffmpeg with the exact root command it would run; installing streamed dependencies: installing ffmpeg with aptdependencies: ffprobe, ffmpeg now on PATH, and X-Audio-Duration: 7.026939 appeared on a stream response for the first time. The refusal path was exercised separately against a temporary probe dependency: HTTP 400, steps: [], and the reason named — nothing had happened, so there was nothing to undo.

~/Music still does not exist, so there is no library to index.

cliamp, parec, pulseaudio and pactl stayed behind with cliamp. The sidecar logs pulseaudio not installed, skipping audio setup and carries on, which is the right shape.


Verified on the live server, 2026-08-15

The runbook's table, run against platform.officer.dev rather than reasoned about.

Step Result
install streamed 5 steps; schema applied in 2082ms; officer-music online; /example, /music, /offscale mounted
the API /api/music/manifest 200, /api/music/favorites returns per-user JSON
range requests full 200 + Accept-Ranges; bytes=100-199206, correct Content-Range, exactly 100 bytes; unsatisfiable → 416; ../../etc/passwd400
the screen route generated in Plugins.gen.tsx, panels in the built bundle, PluginScreen wraps WorkspaceView. Structural — not eyeballed in a browser
dock tile present in /api/user/permissions; /music in routes, not in deniedRoutes
permissions page music listed among the grantable
disable route 404s, sidecar stopped, rows survive
enable 200 again, sidecar online, favourites still there
uninstall route 404s, absent from pm2, ecosystem entry removed, rows survive
bun db:push while uninstalled No changes detected, rows survive
install again byte-identical steps, and a restore — the seeded favourite and playlist came back
pm2 restart officer boots clean, all three plugins mount, music answers 200

Seeded rows and the audio fixture were removed afterwards; ~/Music was deleted again, since it did not exist before.

Music is left INSTALLED and enabled. It had been switched off since 2026-08-13, so this restores it.


Still open

The library browser reads the filesystem, not this plugin — and that is a PERMISSION dependency

Found 2026-08-15, after the extraction landed, by reading the code rather than by anything failing.

MusicBrowser.tsx lists folders with GET /file-browser/ls, not through the music sidecar (MusicBrowser.tsx:63,81). /file-browser belongs to the files permission, and files is confined — so:

  • a member granted music but not files gets a working player, working favourites, and an empty library, because every listing 403s;
  • and files is not a grant that can simply be handed over. authorize.ts drops a confined grant for an account with no osUser, so it means nothing without a per-user Linux account.

This is the first cross-plugin permission dependency in the system, and it is a different animal from the one offscale has. Offscale's ConsoleViewTerminalView is a CODE dependency: it resolves at build time, and the worst case is a plugin that will not compile. This one resolves at request time, per account, and its failure mode is a screen that renders perfectly and shows nothing.

Three possible shapes, none chosen:

  1. The sidecar lists. Music already walks the library for its index — GET /music/ls would put the listing behind the music permission where it belongs, and the plugin stops needing files at all. Most self-contained, and the most work.
  2. The manifest declares a permission dependency, and the platform refuses the grant or warns. Honest, but it makes one plugin's grant conditional on another permission, which is new machinery.
  3. Leave it and document it — a member needs files too. Cheapest, and it quietly ties a music grant to a Linux account, which is a much bigger commitment than the owner is agreeing to on that page.

(1) is probably right, and it is the same shape as offscale's rule that the sidecar absorbs everything. Not tonight's call.

  • hasPersonalWrites reads c.personal only, so the permissions API reports false for a plugin that declares the same thing through readOnlyWrites. Nothing renders the field, so it is dead on the wire — noted rather than fixed.
  • Two dock sources. The app store keeps its own catalogue while the plugin system builds tiles from manifests, and the self endpoint concatenates both. One when the store is rebuilt on the plugin system.
  • src/servers/sidecar/protocol.ts still declares music:server per sidecar. Generalising the union to `${string}:server` is the better fix and is pending for the whole protocol.
  • The cliamp sockets are claimed by no permission, and are served. Now pinned by a test in registry.test.ts rather than left to be rediscovered — closing it is the totality work.