mounting at runtime after all: rebuild the app and swap it

this went round twice — runtime dynamic, then generated-plus-restart on the
belief that hono could not mount after serving, then back once that was actually
tested. the doc keeps the route rather than just the destination.

tested: SmartRouter (hono's default) and RegExpRouter both throw 'Can not add a
route since the matcher is already built'. TrieRouter and PatternRouter accept
it. so runtime adding is possible but costs the fast matcher, and hono has no
remove-route api at all, which uninstall needs.

what solves both is not adding routes but rebuilding: construct a fresh app from
the current plugin set and reassign the variable. the fetch closure reads it per
request, so the reassignment is the swap — atomic, no dropped connections, no
server.reload, and the default SmartRouter is kept. verified 404 before install,
200 after, 404 again after uninstall, with core routes unaffected throughout.

the mechanical cost is one line: server.tsx:322 is '/api/*': honoServer.fetch, a
bound method evaluated once at serve(), and has to become a closure or the swap
does nothing.

websockets stay open: six providers live in bun's route table rather than
hono's, so a plugin owning a socket needs server.reload({routes}), untested.
offscale has none.

and totality stops being a boot check — buildApp() is now the single place
routes are mounted, so it is where the assertion belongs, refusing the swap
rather than refusing the boot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 19:45:22 +00:00
co-authored by Claude Opus 5
parent 13437e0e48
commit 6ab838c77f
+58 -27
View File
@@ -131,46 +131,77 @@ costume of a normal result.
---
## Mounting — generated, then restart
## Mounting — rebuild and swap, at runtime
Three options were considered:
**Runtime mounting, no restart.** This went round twice — C, then B on the belief that Hono could not
mount at runtime, then back — so the reasoning is recorded rather than the conclusion alone.
- **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
### What was actually tested
**B is the decision** — and this reverses an earlier call for C, recorded rather than overwritten because
the reasoning moved.
| Router | `app.route()` after serving has begun |
| -------------------------------- | --------------------------------------------------------------------- |
| `SmartRouter` _(Hono's default)_ | **throws**`Can not add a route since the matcher is already built` |
| `RegExpRouter` | **throws**, same reason |
| `TrieRouter` | works |
| `PatternRouter` | works |
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:
So adding at runtime is possible, but only by giving up the fast matcher — and Hono has **no API to
remove a route**, which uninstall needs.
**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.
### The approach that solves both
So: install a plugin → write the generated files → `pm2 restart officer` → the frontend rebuilds and the
page refreshes. Close to imperceptible.
Rebuild the whole app from the current plugin set and **reassign the variable**:
```ts
let app = buildApp(installedPlugins()); // core routes + one .route() per plugin
serve({ fetch: (req, server) => app.fetch(req, server) }); // closure, NOT app.fetch
// install: app = buildApp([...installed, 'offscale'])
// uninstall: app = buildApp(installed.filter(p => p !== 'offscale'))
```
The `fetch` closure reads `app` on every request, so reassigning it **is** the swap. Verified end to end:
```
no plugins /offscale/x -> 404 | /core -> 200
installed /offscale/x -> 200 | /core -> 200
uninstalled /offscale/x -> 404 | /core -> 200
```
Better than the TrieRouter route on both counts: the default `SmartRouter` is kept, so the fast
`RegExpRouter` path survives — and **uninstall works**, which an add-only API cannot express.
### The one line that has to change
`server.tsx:322` is `'/api/*': honoServer.fetch` — a **bound method**, evaluated once at `serve()`. It has
to become `(req, server) => honoServer.fetch(req, server)`, or reassigning the app has no effect at all.
This is the whole mechanical cost.
### Websockets are a separate table, and still open
Six providers are declared in **Bun's route table**, not Hono's: `/api/tasks/run/ws`,
`/api/tasks/pipeline/ws`, `/api/terminal/ws`, `/api/chat/ws`, `/api/cliamp/ws`, `/api/cliamp/audio/ws`.
The Hono swap above does not reach them, so a plugin contributing a websocket provider needs
`server.reload({ routes })` — untested here.
**Not blocking offscale**, which has none. But it is the reason a plugin cannot yet own a socket, and
it is the same seam as the totality bug below: Bun's route table and Hono's are two different lists.
### What this means for `assertCapabilityTotality`
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.
It can no longer be only a boot check, because the mount set changes after boot. The question moves to
**per rebuild**: `buildApp()` is the one place routes are mounted, so it is the one place to assert that
every mounted route has a permission — and to refuse the swap if one does not. Same invariant, asserted
where mounting actually happens instead of once at start-up.
Two things it must survive, both live today:
- 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 premise in `sidecar-app-store.md` that "every API route stays mounted regardless" is **retired**. An
uninstalled plugin's routes are not mounted, so nothing can reach them.
- 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.
serves the _route table_, and the two diverged when plugins were switched off. Moving the assertion into
`buildApp()` fixes this by construction for Hono routes, and leaves the websocket table as the part that
still needs pointing at reality.
---