From 13437e0e486fb49a69857f7380507a643ceab259 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 14 Aug 2026 19:35:55 +0000 Subject: [PATCH] mounting: generated then restart, reversing the call for runtime dynamic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit this reverses the earlier decision for C (mount and unmount at runtime) and says so rather than quietly overwriting it. the requirement behind C was that the platform must not need to know a plugin in advance. that is met either way: what it reads is a generated file listing the installed routers, analogous to Plugins.tsx on the frontend — nothing hardcoded, nothing read from a table at boot, the imports made concrete at install. C would have bought only the absence of a restart. and a restart is close to free here, because sidecars are pm2 peers rather than children — a property that was fought for, since officer used to spawn the agent and pm2's tree-kill took the owner's chat down on every restart. what a restart costs is websockets, which reconnect, and in-memory session records, which claude:list already recovers. the happy consequence is that assertCapabilityTotality stays a boot check instead of becoming a per-mount transaction. it does need to be fed the route table rather than Object.keys(handlers) first — generated mounts widen that gap rather than closing it, so that is a prerequisite and not a tidy-up beside it. Co-Authored-By: Claude Opus 5 --- docs/offscale-plugin.md | 159 +++++++--------------------------------- 1 file changed, 28 insertions(+), 131 deletions(-) diff --git a/docs/offscale-plugin.md b/docs/offscale-plugin.md index 715fd990..3a3a64b5 100644 --- a/docs/offscale-plugin.md +++ b/docs/offscale-plugin.md @@ -131,149 +131,46 @@ costume of a normal result. --- -## Mounting is dynamic +## Mounting — generated, then restart Three options were considered: -- **A** — mounted always, refuses when not installed _(what ships today)_ -- **B** — mount set decided at boot from the install table, restart `officer` on install +- **A** — mounted always, refuses when not installed _(what shipped before this work)_ +- **B** — the mount set is decided at start-up, `officer` restarts on install - **C** — genuinely dynamic, mounted and unmounted at runtime -**C is the decision.** Explicitly not an intermediate step: the platform must not need to know a plugin -exists in advance, and B still bakes the answer at boot. +**B is the decision** — and this reverses an earlier call for C, recorded rather than overwritten because +the reasoning moved. -This contradicts `sidecar-app-store.md`, which leans on "every API route stays mounted regardless" as a -simplification. That premise is retired. +C was chosen first on the requirement that _the platform must not need to know a plugin exists in +advance_. That requirement stands and is met either way: what the platform reads is a **generated** file +listing the installed routers, exactly analogous to `Plugins.tsx` on the frontend. Nothing is hardcoded and +nothing is read from a table at boot — the imports are made concrete at install time. C would buy only the +absence of a restart, and a restart here is close to free: -### What has to change with it +**Sidecars are PM2 peers, never children.** Restarting `officer` does not touch them — that property was +fought for (officer used to spawn the agent, so PM2's tree-kill took the owner's chat down on every +restart) and it is exactly what makes this cheap now. What a restart actually costs is websocket +connections, which reconnect, and officer's in-memory session records, which `claude:list` already exists +to recover. -`assertCapabilityTotality` runs before `serve()` and throws if a mounted router has no registry entry. It -exists for a real reason: a Member 403'd on `GET /api/tasks` while opening `/api/tasks/pipeline/ws` with a -101 in the same minute, because Bun's route table matches the socket before the `/api/*` catch-all. +So: install a plugin → write the generated files → `pm2 restart officer` → the frontend rebuilds and the +page refreshes. Close to imperceptible. -**It does not die, it relocates.** Today it asks "does every mounted route have a permission?" once, at -boot. Under C the same question is asked **per mount**: registering a router and registering its -capability become one transaction, and an incomplete one is refused. +### What this means for `assertCapabilityTotality` -### Foundations that already exist +It stays a boot check, which is the happy consequence of B over C — under C it would have had to become a +per-mount transaction. It just has to see the generated plugin routes, since those are what the boot is +mounting. -- Sidecars register at **runtime** over `/api/sidecar/register` and are found **by capability, never by - name** — which is how `officer-agent` was renamed to `officer-claude-code` without touching a caller. -- `sidecar/proxied-prefixes.ts` is already a runtime-mutable `Set`, self-registered by - `createSidecarProxy` "so a new sidecar cannot forget to add itself". +Two things it must survive, both live today: -What is compile-time is only the routes and permissions, not the sidecar's existence. - ---- - -## How the frontend ships - -**Everything moves to the plugin — the frontend does not stay in this repo.** That rules out treating -federation as a later problem, and it is what the build model below exists to answer. - -### Build to a directory, rebuild on install - -Today Bun compiles the SPA at server start, through the HTML import. That changes: - -1. `pm2` starts `officer` -2. it builds the frontend immediately into `build/` (untracked) -3. it serves `index.html` from that build -4. installing a plugin triggers a rebuild, and the page auto-refreshes or the user is told to -5. **no server restart** — `Bun.build()` is a runtime call, not a process lifecycle event - -**Same origin throughout.** A separate origin for the API was considered and dropped: it would mean CORS -and rewriting every endpoint in `useClient` for no gain, since Bun can serve the build itself. - -This is why there is no module federation, no import map and no iframe anywhere in this design. Everything -is compiled together; a plugin simply changes what "everything" is. - -### `Plugins.tsx`, generated at build time - -`App.tsx` keeps the core routes and gains one map: - -```tsx -plugins.map((plugin) => } />); -``` - -The wildcard delegates to the plugin's own router, which React Router nests natively. `plugins` comes from -a **generated `Plugins.tsx`**, written at build time from what is installed — because a bundler cannot -follow `import(someRuntimeString)`, the specifier has to be concrete before the build runs. - -Everything the shell currently hardcodes per plugin collapses into that one file. Today headscale is named -in six places, and every one of them is a place to forget: - -| Place | What it holds | -| ----------------------------- | --------------------------------------------------- | -| `App.tsx` | the `/headscale` + `/headscale/:section` route pair | -| `Screens/Dashboard/index.tsx` | the screen barrel export | -| `AppRegistry.tsx` | the panel-meta import and spread | -| `Dock.tsx` | the tile, in `CORE_DOCK_ITEMS` | -| `usePageTitle.ts` | the title rule | -| `officerdev/src/index.ts` | the section-helper re-exports | - -**Build time becomes the single source**, including the dock — the runtime `dockItemsFromPlugins` path is -to be corrected to follow this rather than left as a second source that can disagree. - -### Presentation is build-time; permission stays runtime - -The one line not to blur. `Plugins.tsx` says a tile exists at `/headscale`. Whether _this_ account sees it -is still asked per request, because grants change without a rebuild and the entire auth model rests on -re-reading the role rather than trusting a claim. - ---- - -## Dependencies between plugins - -The pilot has two, and they are **different kinds** — the same word covering two problems: - -- **service** — `assist.ts` needs `officer-anthropic-proxy`. A runtime call over a wire. Loosely coupled, - and it already degrades: `ProxyUnavailable` → 503 `assistant_unavailable`. -- **code** — `ConsoleView` imports `TerminalView` from the Terminal panel app, and relies on - `/api/terminal/ws`. That is in its own bundle at build time. It cannot degrade; it resolves or the panel - does not build. - -**The rule: a plugin may depend on another, and must degrade when it is absent.** Both of the above are -optional sections, which is why both are survivable. - -### Service dependencies go through the API - -**A plugin may call any API endpoint, carrying the user's token, with exactly the permissions that user has -anywhere else in the app.** A plugin is part of the application, not a guest in it. - -Which means a plugin offering a service to other plugins **exposes it as routes**, like everything else. No -private side channels — and that deletes a real piece of debt: `claude-proxy.ts` currently reaches the -anthropic proxy by reading its private state file (`DATA_PATH/sidecar/claude-state.json`) to lift a secret. -Invisible, unversioned, and silently broken the day the proxy moves. An authenticated API call is better -regardless of plugins. - -There is deliberately **no per-plugin permission list**. The bound is the user: a plugin can never exceed -the account calling it. What carries the weight instead is marketplace review — which makes review a -**security** boundary, not just a naming one. Worth knowing about the thing you are relying on. - ---- - -## Developing a plugin - -The platform is open source, so the development environment is a **platform checkout**. A developer clones -the platform, runs it in dev, and builds the plugin inside it — the WordPress model. - -That dissolves what looked like the hardest problem. There are 13 workspace packages (`components`, -`hooks`, `state`, `helpers`, `types`, `officerdev`, `widgets`, …) and they resolve purely because -`"workspaces": ["src/workspaces/*"]` links them by name. So a plugin author writes - -```ts -import { useClient } from 'hooks/useClient'; -import { Card } from 'components/Card'; -``` - -and it works, with no publishing, no package registry and no version negotiation — because the plugin sits -inside the workspace like any first-party code. - -**And it makes dev-time and build-time the same mechanism.** A plugin builds on the server exactly as it -built on the laptop, so it cannot work in one and fail in the other. - -`[open]` The plugin directory must be gitignored in the platform repo, so work in progress is not swept -into someone's commit. A plugin's own repository is cloned _into_ a platform checkout, never forked from it. +- The premise in `sidecar-app-store.md` that "every API route stays mounted regardless" is **retired**. A + route belonging to an uninstalled plugin is not mounted at all, so it cannot be reached by anything. +- The check is currently **fed the wrong list** — `Object.keys(handlers)` from `server.tsx`, while Bun + serves the _route table_, and the two diverged when plugins were switched off. Generated mounts will make + that gap wider, not narrower, so pointing totality at the route table is a prerequisite for this rather + than a tidy-up beside it. ---