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:
2026-08-14 19:35:55 +00:00
co-authored by Claude Opus 5
parent 7befaf032a
commit 13437e0e48
+28 -131
View File
@@ -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.
---