mounting: generated then restart, reversing the call for runtime dynamic
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 <noreply@anthropic.com>
This commit is contained in:
+28
-131
@@ -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) => <Route path={`${plugin.route}/*`} element={<plugin.Router />} />);
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user