Commit Graph
16 Commits
Author SHA1 Message Date
pastilhas 05eb947bd1 music is verified on the live server, and the runbook learns from it
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.
2026-08-15 01:56:16 +00:00
pastilhas de3340398c music becomes a plugin, and the player stays behind
The whole of music moves to plugins/music/: the sidecar (index, indexer,
stream-audio, nightly-reindex), the four Postgres tables and their queries,
the /music workspace panels, MUSIC_API.md and the reindex CLI. The platform
keeps no music routes, no music capability entry, no music screen and no
music schema.

Three things stayed, each on purpose.

cliamp and the widget were out of scope by the owner's decision. The plugin's
sidecar still serves the two cliamp sockets, so it imports cliamp-ws.ts and
pulse-audio.ts from @@/sidecar/music/ — the files stay where they were.

The player did not move, and that was the open judgement call. Deciding it
took one fact: the dashboard widget imports useMusicPlayer and PlayerTrack
from officerdev, and the platform cannot import from a plugin. So the player
STATE stays whatever is decided about the UI around it, and two copies would
mean two audio engines. Given that, the engine and the bar stayed with the
state rather than being split from the thing they drive. Moving them would
also have needed a shell slot rendering a plugin-provided component on every
route — the one escape hatch this system deleted on purpose. MusicPlayerHost
gates on can('music'), which is now the plugin's permission, so the seam
switches itself off with the plugin.

api/music/router.ts stays too: api/cliamp/relay.ts imports getMusicServerWsUrl
from it. The plugin's api/router.ts re-exports that proxy rather than building
a second one — two subscribers to the one-shot music:server port announcement
would work today and 503 on the first reconnect where only one was listening.

Two bugs found on the way, neither visible from reading.

The app-store catalogue still listed music. Availability is derived from
sidecar_installs and a PLUGIN never gets a row there, so `music` would have
been permanently unavailable — which puts /music into deniedRoutes and blanks
the screen on a server where the plugin was installed and healthy. Exactly
the headscale bug documented six lines above it in the same file, and it would
have fired on the first install. Entry removed.

[test] root was "./src", so moving lyrics.test.ts into plugins/ stopped it
running and said nothing — the count fell by nine and the suite still read
green. Root is now the repo. Positional filters cannot fix this: `bun test
plugins` matches under root and finds src/servers/plugins/ instead.

registry.test.ts tested the `personal` mechanism THROUGH the music capability.
Re-anchored on a fixture rather than on another entry, because borrowing a
feature only moves the problem to the next extraction — and three of those
four tests had been passing for the wrong reason since music's api was
commented out on 2026-08-13, when everything started resolving to "refused
because nothing is claimed". The cliamp sockets being claimed by nothing is
now pinned by a test instead of being rediscovered.

music's `personal` paths ride across on readOnlyWrites, the one field a
manifest has. isRequestAllowedAtLevel concatenates the two lists, so a read
grant permits exactly the four paths it permitted yesterday, and no field was
added to the manifest to design a per-user model that is not this work.

bunx tsgo clean. 772 tests, 762 pass, 7 fail — all seven pre-existing and
unrelated (cliamp, pty, and five capability tests that other switched-off
plugins break). Baseline was 757/10; the three that went green are the ones
re-anchored above.

Not yet verified on the live server — that is next.
2026-08-15 01:46:43 +00:00
pastilhasandClaude Opus 5 18c4ebd0b4 the per-user model is not tonight's work either
personal, the four user-scoped tables, and what a member's grant means all stay
exactly as they are. it gets built inside the plugin later, which is the entire
reason the platform's answer is a uniform read/write and nothing more.

leaving it alone is safe rather than lazy: the web frontend does not use those
routes at all — favourites, playlists and now-playing are used only by the phone
and tablet apps — so nothing visible in a browser can regress by carrying them
across verbatim. and /queue, which is in that list, has no route anywhere. dead
or aspirational; carried as-is, not investigated.

the one unavoidable consequence stays named: registry.test.ts tests the personal
mechanism through the music entry, so removing it breaks those tests. re-anchor
on another entry that has personal. a test fix, not a redesign.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 01:24:09 +00:00
pastilhasandClaude Opus 5 7d65732f77 the widget is out of scope too, leaving one open call
widgets/MusicPlayer stays in its third workspace package, registered where it
is. plugins cannot contribute widgets and are not going to learn how tonight.

with cliamp and the widget both cut, exactly one judgement is left in the music
extraction: whether the global MusicPlayerHost overlay gets a contribution slot
in the shell or stays in DashboardLayout gated as it already is. either is fine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 01:21:54 +00:00
pastilhasandClaude Opus 5 b5db3c47e1 cliamp is out of scope, which unblocks the music extraction
the two websocket providers exist for one thing: running the cliamp TUI on the
server and piping its terminal and audio to the browser, via a pulseaudio null
sink tapped by parec. a second, separate playback path — and the least important
part of music. wanted eventually, not tonight.

that removes the hardest of the three homeless parts entirely. no plugin can own
a socket yet, and now none needs to: both sockets are already inert, and
cliamp-ws.ts, pulse-audio.ts, asoundrc, the relay and the two providers all stay
exactly where they are. if the relay's import is the only thing keeping
api/music/router.ts alive, that stays too — a small documented seam.

what music actually is: the /music screen, the library, and the phone and tablet
apps that stream from it. that is what has to work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 01:19:18 +00:00
pastilhasandClaude Opus 5 8545b427dd the runbook tells the next agent to decide, not to ask
it previously said 'decide before moving a file, and tell me the decision',
which turns an overnight run into a blocked one. every open call here has two
defensible answers, a corrected decision is cheap, and a stalled extraction is
not.

adds a default for each of the three homeless parts — close the platform gap
when there is runway, because every later plugin needs it too, but a working
music with cliamp left in the platform beats a perfect design that did not land.
the bar at the end is unchanged: install, enable, disable, uninstall cleanly. a
piece left in the platform is a documented seam; a piece left dangling is a bug.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 01:13:49 +00:00
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
pastilhasandClaude Opus 5 4a9f23c759 the design doc moves into the plugin it produced
docs/offscale-plugin.md becomes plugins/offscale/PLUGIN.md. most of it is about
the plugin system generally rather than about offscale, which is exactly why it
belongs with the worked example — the reasoning is most useful beside the code
it produced, and the platform's docs should not carry the history of something
it no longer knows exists.

every reference updated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 01:07:48 +00:00
pastilhasandClaude Opus 5 585c046a64 the plugin system says permissions, not the other word
i named the field permissions and then narrated in the overloaded one anyway,
which is worse than either — the whole reason for the rename was that the word
already means three things here.

renamed what was mine: setPluginCapabilities -> setPluginPermissions,
pluginCapabilities -> pluginPermissions, and the prose throughout.

what remains is the platform's own vocabulary, not the plugin system's: the
 type it imports, and the  field on a dock manifest,
which is the shape the shell already renders. renaming those is a separate
change to a separate system and is the owner's to make.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:47:18 +00:00
pastilhasandClaude Opus 5 8cc51cfb40 every plugin permission is grantable, and there is no field to say otherwise
offscale was missing from the permissions page because it declared ownerOnly,
which mapped to kind admin, and admin capabilities are never offered for
granting. correct by the old rule and wrong by the standard: every plugin
follows the same platform-level permission model, appearing on the same page
with the same read/write/none per role.

so the field is gone rather than flipped. a plugin has no way to say owner-only,
which makes the standard structural instead of remembered — the same move as the
workspace rule. core, execution, confined and admin stay the platform's to
assign and a plugin cannot name any of them, so the escalation question is
removed rather than answered.

this is the second draft of this decision to be deleted: first a full
CapabilityKind with three of five values forbidden, then an ownerOnly boolean,
now nothing. the doc records all three so the reasoning is visible rather than
just the conclusion.

finer visibility stays the plugin's job. offscale is the worked example of the
gap that leaves and its manifest says so: it is grantable now, and its queries
still scope by the caller, so a granted member would see their own empty server
list rather than the owner's. closing that is a change inside the plugin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:42:35 +00:00
pastilhasandClaude Opus 5 e13128846b offscale is a plugin
headscale leaves the platform. 45 files move to plugins/offscale/ and the
platform stops knowing it exists.

  api/router.ts   the thin auth-gated proxy, now at /api/offscale
  sidecar/        18 files, the whole headscale contract and its admin keys
  db/             schema + queries, offscale_servers
  web/            26 files as panels and a layout — no screen, per the rule

removed from the platform: the hono mount, the `headscale` capability, the
App.tsx route pair, the screen and its barrel, the AppRegistry spread, the
officerdev re-exports, the dock tile, the page-title rule, and both database
barrels. tsgo is clean and nothing references it.

the imports tell the story of what the plugin↔host API actually is. the sidecar
takes @@/sidecar/protocol, @@/sidecar/connect, @@/data-path and
@@/officer-url.mjs; the queries take officerdb/db and officerdb/crypto; the
schema takes officerdb/auth/schema for the one reference a plugin may make; the
web half takes useClient, copyToClipboard, WorkspaceView and TerminalView from
the officerdev barrel. all of it resolves because a plugin lives inside the repo
— no publishing, no version negotiation.

AND IT FOUND A REAL BUG IN THE INSTALLER. createSidecarProxy learns its port
from a one-shot `<name>:server` event and subscribes when the plugin's router is
first imported — at mount. install started the sidecar BEFORE mounting, so the
announcement fired into a void: process online, routes mounted, every request
answering `503 sidecar not available` until something forced a reconnect. it
would have hit every plugin with an http sidecar. `example` never caught it
because it has no listener to announce.

install and enable now mount before starting; disable still unmounts before
stopping. neither direction leaves a mounted route in front of a sidecar that
cannot be reached.

verified live: /api/offscale/_officer/servers answers {"servers":[]}, /offscale
and /offscale/nodes serve, the old /api/headscale is 404, the offscale
capability is registered from the manifest, and officer-offscale is online.

757 pass, same 10 pre-existing failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:15:38 +00:00
pastilhasandClaude Opus 5 543e88a9a6 every plugin route renders a workspace, and it is not a rule you can forget
an exclusionary rule, made structural. a plugin does not render a screen: it
contributes panels and says how they are arranged, and the shell renders
WorkspaceView around them.

    web/panels.ts   appRegistryMetas — at least one panel
    web/layout.ts   defaultLayout — how they are arranged

both required the moment web/ exists, and missing either is refused at discovery
by name and with the reason. tested:

    probeplug: has a web/ directory but is missing web/layout.ts.
    Every plugin route renders a Workspace: contribute panels and a layout,
    not a screen.

there is deliberately no way to export a component. one that could would be free
to render a bare div, a full-page form, or its own navigation, and the platform
would become a shell hosting strangers' layouts rather than one application.
non-compliance is not so much refused as unrepresentable — there is nowhere to
put a screen.

the shell registers <prefix> and <prefix>/:section, exactly as the core screens
do, so a plugin's sections stay addressable and cmd-clickable, and panels read
useParams independently rather than passing state between themselves.
appTypes.allowed is pinned to that plugin's own keys, so a persisted layout
naming something else falls back instead of rendering another plugin's panel
inside this screen.

the example plugin is rebuilt to model it — two panels, a layout, one of them
calling its own /api/example/ping through useClient — because the reference
implementation is what everyone copies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 23:47:48 +00:00
pastilhasandClaude Opus 5 7b4137ccca a plugin's frontend, generated and rebuilt without a restart
the last piece. installing a plugin now brings its UI with it.

a bundler cannot follow import(runtimeString), so which plugins have a frontend
cannot be answered from the database at render time — it has to be written into
source first. Plugins.gen.tsx is that file: concrete imports, generated from
what is installed, gitignored because it describes THIS machine.

App.tsx keeps its core routes and gains one map. the wildcard hands the whole
subtree to the plugin's own router, which react-router nests natively.

serving moved to build/ in production. the html import is bundled once when the
module graph loads and can never change after, which is precisely why a plugin's
frontend needed a restart; Bun.build measures ~900ms for a 25MB bundle, so an
install can just rebuild. development keeps the html import, because that is
what gives HMR and bun --watch restarts on every source change anyway.

verified end to end against a running server, no restart at any point: install
regenerated the module, rebuilt the bundle (chunk hash changed), and the
plugin's own markup was in it; /example and /example/deeper both served; disable
took it back out of both the module and the bundle and 404'd the api; enable put
it back.

three things worth recording because they were found rather than reasoned:

the shell output is named after the ENTRYPOINT — index.gen.html, not index.html
— and naming: { entry: '[name].[ext]' } does not change it because [name] is
'index.gen'. found as a 503 on the first boot after the switch.

App.tsx already destructured a `plugins`, from useServerSettings — the DEAD
plugin system that scans a directory which does not exist and always returns [].
it silently shadowed the import. the new one is `installedPlugins` and says why.

seedAppRegistry takes plugin panels as an argument rather than importing them:
officerdev is a dependency of the shell, so importing upward would invert that.

756 pass, same 10 pre-existing failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 23:07:18 +00:00
pastilhasandClaude Opus 5 a00116b2c0 a plugin's permissions become real capabilities, and survive a restart
two gaps between "a plugin can mount routes" and "a plugin is part of the
platform". both closed.

FIRST: nothing registered a plugin's declared permissions, so the capability
gate could not resolve a plugin path at all. it resolved to null, and null is
denied — the owner never noticed because isSuperAdmin short-circuits every
check, which is exactly the shape of bug that reaches a member first.

the registry is now rebuildable the same way the hono app is: CORE_REGISTRY
holds the platform's own, CAPABILITIES is core plus whatever the installed
plugins declare, and setPluginCapabilities replaces the plugin half wholesale
rather than diffing it. two invariants hold by construction — DEFAULT_ROLE_-
CAPABILITIES and CORE_CAPABILITIES derive from CORE_REGISTRY, so a plugin can
never put itself in the fresh-install baseline and can never become `core`
(every account, undeniable). a key colliding with a core one is refused and
logged, because a plugin able to redefine `chat` could widen it.

ownerOnly maps to admin, everything else to app. those are the only kinds a
manifest can express, and it has no field for a kind at all.

capabilities are registered BEFORE routes are mounted: the gate runs ahead of
every router, so mounting a route whose permission is not yet registered would
403 the freshly installed plugin until something else happened to refresh.

SECOND: nothing mounted plugins at boot. honoServer is built with none at
import, because discovery reads disk and database and neither can be awaited at
module scope, and every install verb rebuilt — so it tested perfectly and would
have silently unmounted everything on the first restart.

server.tsx now refreshes before serve(), so there is no window where an
installed plugin 404s, and a plugin that will not load is logged rather than
fatal.

verified: after a restart, [plugins] mounted /example, the row survived,
officer-example came back online from its ecosystem entry, capabilityForApiPath
resolves /api/example/ping to the example capability at kind=app, and it appears
in the owner's grantable list. 756 pass, same 10 pre-existing failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 22:43:49 +00:00
pastilhasandClaude Opus 5 2634df7a04 the install runner: ecosystem entry, sidecar, row, mounts
closes the hole app-store/pm2.ts has carried since 2026-08-13 — "installing a
plugin has to append its entry here before starting it, that is the plugin
system's job and it is not built". this is that job, and it is why nothing in
the app-store catalogue installs end to end either.

verified against a running server with a sidecar in the tree:

  install    ecosystem added · sidecar started · recorded · mounted /example
             route 200, pm2 online
  disable    sidecar stopped · unmounted
             route 404, pm2 stopped
  enable     sidecar started · mounted
             route 200, pm2 online
  uninstall  record removed · unmounted · sidecar stopped, deleted, entry gone
             route 404, not in pm2, tables untouched

afterwards ecosystem.config.cjs is byte-identical to before, pm2 holds the same
five core apps, and plugin_installs is back to zero rows.

the ecosystem file is edited rather than regenerated: the core entries come from
officer-setup's shell array, so the platform does not know that list and a copy
here would be a second thing to drift. the header above module.exports is
preserved verbatim too — officer-setup's explains that bun auto-loads .env from
the working directory and that data-path derives the install root from its
PARENT, so a wrong cwd relocates the whole install rather than failing. losing
that to a plugin install would be a poor trade.

order is the design. bringing up goes outside-in, taking down goes inside-out,
so the worst intermediate state is "recorded but not running" — visible, and
fixed by a retry — never "running but forgotten", which nothing can see.

each verb returns what it actually did, in order, and the detail panel shows it.
an install that mounted routes but could not start a sidecar is a different
outcome from one that worked, and a spinner that stops cannot say which.

the schema push is still deliberately not wired, and the reason is now in the
code: db:push DROPS tables absent from the schema it is given, so an uninstall
that regenerated the barrel would delete a plugin's data as a side effect of
stopping it. offscale does not need it — headscale_servers already ships in the
platform schema.

720 pass, same 10 pre-existing failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 21:38:21 +00:00
pastilhasandClaude Opus 5 282a64a637 plugins install, enable, disable and uninstall at runtime
the rest of the mechanism, and it works end to end. against a real server, with
no restart at any point:

  /api/example/ping BEFORE install   404
  AFTER install                      200  {"plugin":"example","ok":true}
  AFTER disable                      404
  AFTER enable                       200
  AFTER uninstall                    404
  core route throughout              200

plugin_installs is a new table rather than a reuse of sidecar_installs. that one
belongs to the app store's model, where installing means provisioning a
container or pointing at a remote instance, and it carries mode, compose_dir and
completed_steps to say so. a plugin install has none of those, and reusing it
would have meant a `mode` that lies about every plugin. the two models coexist
until the app store is rebuilt on this one.

the row is needed because presence is not installation: plugins live in the
repository, so a developer writing one has the directory there and has installed
nothing. the tree says what could run, the table says what does.

mount.ts joins the two and rebuilds. an install row whose directory has gone is
dropped from the snapshot rather than reported — but the row is left in the
database, because deleting it there would turn "somebody moved the checkout"
into silent data loss. a plugin whose router will not load stays unmounted and
says why, rather than taking the other nine down with it.

/api/plugins is owner-only in its own right, like /api/app-store, and its
capability guards the MANAGEMENT surface only — a plugin's own permissions come
from its manifest, so a member can hold one at read without being able to
install anything.

plugins/example is the reference implementation and is meant to be read: the
smallest thing that is still a real plugin, with the directory layout as its own
documentation.

not wired yet, and marked [open] in the router: the schema push and the
sidecar's pm2 entry. a plugin with db/schema.ts or sidecar/ needs both before it
works end to end.

full suite: 719 pass, same 10 pre-existing failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:22:18 +00:00