@hachej/boring-workspace 0.1.17 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +36 -34
  2. package/dist/{FileTree-Dvaud3jU.js → FileTree-DHVB9rpk.js} +15 -15
  3. package/dist/{MarkdownEditor-sLkqTXDj.js → MarkdownEditor-L1KDH0bM.js} +1 -1
  4. package/dist/{WorkspaceLoadingState-zLzh1tGc.js → WorkspaceLoadingState-DYDxUYnx.js} +114 -110
  5. package/dist/WorkspaceProvider-CDPaAO5u.js +5971 -0
  6. package/dist/app-front.d.ts +94 -107
  7. package/dist/app-front.js +243 -233
  8. package/dist/app-server.d.ts +130 -15
  9. package/dist/app-server.js +1569 -304
  10. package/dist/{bootstrapServer-BreQ9QBc.d.ts → createInMemoryBridge-BDxDzihm.d.ts} +11 -26
  11. package/dist/manifest-CyNNdfYz.d.ts +58 -0
  12. package/dist/plugin.d.ts +199 -0
  13. package/dist/plugin.js +300 -0
  14. package/dist/server.d.ts +239 -4
  15. package/dist/server.js +901 -78
  16. package/dist/shared.d.ts +4 -112
  17. package/dist/surface-COYagY2m.d.ts +111 -0
  18. package/dist/testing.d.ts +19 -1
  19. package/dist/testing.js +2 -2
  20. package/dist/{agent-tool-DEtfQPVB.d.ts → ui-bridge-Gfh1MMgl.d.ts} +30 -30
  21. package/dist/workspace.css +36 -0
  22. package/dist/workspace.d.ts +165 -120
  23. package/dist/workspace.js +330 -377
  24. package/docs/INTERFACES.md +9 -9
  25. package/docs/PLUGIN_STRUCTURE.md +39 -145
  26. package/docs/PLUGIN_SYSTEM.md +355 -0
  27. package/docs/README.md +6 -1
  28. package/docs/plans/README.md +1 -0
  29. package/docs/plans/archive/HOT_RELOADABLE_AGENT_PLUGINS_PLAN.md +218 -0
  30. package/docs/plans/archive/RELOAD_PLUGGABILITY_PLAN.md +174 -0
  31. package/docs/plans/archive/UNIFIED_PLUGIN_SYSTEM_PLAN.md +769 -0
  32. package/package.json +11 -5
  33. package/dist/CommandPalette-CJHuTJlD.js +0 -5716
  34. package/docs/bridge.md +0 -135
  35. package/docs/panels.md +0 -102
  36. package/docs/plugins.md +0 -158
  37. /package/docs/plans/{MACRO_PLUGIN_GENERIC_HELPERS_AUDIT.md → archive/MACRO_PLUGIN_GENERIC_HELPERS_AUDIT.md} +0 -0
@@ -0,0 +1,769 @@
1
+ # Unified plugin system — one shape, one install, hot reload as a flag
2
+
3
+ Status: proposal, follow-up to PR #18 reload-pluggability work.
4
+ Scope: collapse today's two plugin shapes (static `defineXxxPlugin` factories
5
+ vs `package.json#boring`-driven hot plugins) into a single shape with a
6
+ single install pipeline. Hot reload becomes a per-entry opt-in flag, not a
7
+ fork of the manifest.
8
+
9
+ ## Current state (May 2026)
10
+
11
+ ### Plugin authoring — already unified
12
+
13
+ `packages/cli/templates/plugin/` codifies one shape, locked in by PR #40
14
+ (`refactor/plugin-template`) and the `/boring-plugin-build` skill:
15
+
16
+ ```
17
+ plugins/<name>/
18
+ package.json private workspace package
19
+ src/front/index.tsx exports createXxxPlugin(): WorkspaceFrontPlugin
20
+ src/server/index.ts exports createXxxServerPlugin(opts): WorkspaceServerPlugin
21
+ src/shared/ constants, types
22
+ ```
23
+
24
+ The three real plugins are aligned to this shape:
25
+
26
+ | Plugin | Front | Server | Notes |
27
+ |---|---|---|---|
28
+ | `plugins/ask-user/` | `defineFrontPlugin` with panel + provider + surface-resolver + command | `defineServerPlugin` with `agentTools`, `routes`, `systemPrompt`, `preservedUiStateKeys` | Owns `AskUserRuntime` singleton; bridge subscriber |
29
+ | `plugins/data-catalog/` | `defineFrontPlugin` with panel + catalog + left-tab + surface-resolver | `defineServerPlugin` with `agentTools`, `systemPrompt` | Adapter passed by caller |
30
+ | `plugins/data-explorer/` | UI library only | — | Not a plugin; consumed as a normal dep |
31
+
32
+ ### Plugin installation — still two paths
33
+
34
+ Despite one authoring shape, the **install** path is double-tracked:
35
+
36
+ | | Static install (today's default) | Hot install (`.pi/extensions/*`) |
37
+ |---|---|---|
38
+ | Where plugin lives | Anywhere; imported by app at build time | Plugin dir scanned at runtime |
39
+ | Workspace API | `plugins: [...]` / `pluginFactories: [...]` | `BoringPluginAssetManager` discovers from disk |
40
+ | Author-facing manifest | `defineFrontPlugin` + `defineServerPlugin` | `package.json#boring` + `#pi` |
41
+ | Re-evaluation on `/reload` | ❌ never | ✅ scan + jiti |
42
+ | Front delivery | Bundled by app's Vite | `frontUrl: /@fs/<absolute-path>` via SSE |
43
+ | Server delivery | Routes mounted at boot | Namespaced dispatcher; jiti-loaded |
44
+ | Agent tools | Captured in `tools[]` at session creation | Pi extensions via jiti |
45
+ | systemPrompt | Concatenated into `systemPromptAppend` at boot | Refreshed via `systemPromptDynamic` getter |
46
+ | Provider/binding | React tree at mount | Not expressible |
47
+
48
+ This is the smell the design notice is calling out: same author shape, two
49
+ runtimes. The hot path even has its own JSON-shaped manifest fields
50
+ (`package.json#boring.front`, `boring.server`, `pi.extensions`) that
51
+ duplicate what `defineFrontPlugin`/`defineServerPlugin` already say.
52
+
53
+ ## Target
54
+
55
+ **One plugin shape. One install pipeline. Hot reload is a per-entry flag.**
56
+
57
+ ```
58
+ createWorkspaceAgentServer({
59
+ plugins: [
60
+ askUserPlugin, // module-source, static
61
+ { spec: { module: dataCatalogPlugin }, options: { adapter } }, // module-source with options
62
+ { spec: { dir: "plugins/my-plugin" }, hotReload: true }, // directory-source, hot
63
+ ],
64
+ })
65
+ ```
66
+
67
+ The author writes one shape. The host wires one array. Whether a plugin
68
+ hot-reloads depends only on the install entry, not the plugin's structure.
69
+
70
+ ### Why this is achievable now
71
+
72
+ PR #18 + the reload-pluggability work I just landed:
73
+
74
+ - Pi consumes plugin contributions via two clean seams (`systemPromptDynamic`,
75
+ `getDynamicResources`). No workspace-injected Pi extensions.
76
+ - Workspace owns server-route hot-swap via a dispatcher map (already works).
77
+ - Front hot-swap already works via SSE + Vite `/@fs/` URLs.
78
+
79
+ What's missing is a single install pipeline that produces
80
+ `WorkspaceFrontPlugin` + `WorkspaceServerPlugin` from *either* source, and
81
+ applies the result identically.
82
+
83
+ ## Architecture
84
+
85
+ ### Single install pipeline
86
+
87
+ ```
88
+ PluginEntry → RESOLVE → WorkspaceFrontPlugin + WorkspaceServerPlugin
89
+
90
+
91
+ INSTALL into the shared registries
92
+ (PanelRegistry, CommandRegistry, ...,
93
+ bootstrapServer, Fastify dispatcher)
94
+
95
+ ┌────────────────────────┴────────────────────────┐
96
+ │ │
97
+ hotReload: false hotReload: true
98
+ └─ done ┌── subscribe to dir watcher
99
+ ├── on /reload:
100
+ │ SERVER: teardown + RE-RESOLVE + rebuild
101
+ │ (Pi parity: rebuild over diff)
102
+ │ FRONT: surgical swap for diff-safe outputs
103
+ │ (can't rebuild a live React tree)
104
+ └── emit diagnostics for what can't apply
105
+ ```
106
+
107
+ Resolution rules:
108
+
109
+ | Entry type | How it resolves |
110
+ |---|---|
111
+ | `WorkspaceFrontPlugin` / `WorkspaceServerPlugin` object | Use directly. |
112
+ | `{ spec: { module: M }, options? }` | `M(options)` — call the factory the plugin's package already exports. |
113
+ | `{ spec: { dir }, options?, hotReload? }` | Read `<dir>/package.json`, locate front+server entries (convention: `dist/front/index.js` for built packages, `src/front/index.tsx` via jiti for dev), import via jiti when `hotReload`, regular import otherwise. Call factory with `options`. |
114
+
115
+ The author writes the **same** factory shape regardless. The resolver picks
116
+ the import strategy.
117
+
118
+ ### Reload semantics — rebuild on server, swap on front
119
+
120
+ Two asymmetric strategies, each chosen because of what the underlying
121
+ runtime can support:
122
+
123
+ **Server: rebuild over diff (Pi parity).**
124
+ Pi's `AgentSession.reload()`
125
+ (`node_modules/@mariozechner/pi-coding-agent/dist/core/agent-session.js:1896`)
126
+ tears the runtime down (`session_shutdown`), re-imports extension modules
127
+ via `jiti` (`moduleCache: false`), rebuilds the runtime registry from
128
+ scratch, then fires `session_start` with `reason: "reload"`. Active tool
129
+ names and flag values are snapshotted before teardown and replayed after
130
+ rebuild. No diff, no transactional rollback.
131
+
132
+ We mirror this on our server side:
133
+
134
+ ```
135
+ on /reload:
136
+ snapshot { activeToolNames, activeSessionId, openSurfaces, ... }
137
+ for each plugin (rebuild order = registration order):
138
+ emit "plugin_shutdown" to give the plugin a chance to cleanup
139
+ re-resolve all hotReload: true entries via jiti
140
+ re-run bootstrapServer with the fresh plugin objects
141
+ rebuild handler maps (route dispatcher, systemPromptDynamic source set)
142
+ fire "plugin_start" with reason: "reload"
143
+ emit diagnostics for any plugin that failed to resolve/load
144
+ restore snapshot
145
+ ```
146
+
147
+ Why rebuild instead of diff:
148
+ - Pi has lived with this design in production; diff-based reload is *not*
149
+ what mature jiti-based reload systems use.
150
+ - No half-applied state risk — either the rebuild completes (registry now
151
+ reflects fresh modules) or it doesn't (previous registry stays in place,
152
+ errors surface as diagnostics).
153
+ - No "stable ID" contract to enforce on plugin authors. Same names across
154
+ reloads = same registry entries; new names = new entries; removed names
155
+ drop. The registry **is** the diff.
156
+ - Pi-style conflict detection runs after rebuild
157
+ (`resource-loader.js:281` `detectExtensionConflicts`): duplicate
158
+ tool/command/flag names from different extensions are surfaced as
159
+ diagnostics; load-order decides precedence. Reload keeps going.
160
+
161
+ **Front: surgical swap (React parity).**
162
+ Server can rebuild because the agent runtime is stateless between turns.
163
+ Front can't — a React tree carries user state (open panel, scroll
164
+ position, form input, selection) that the user expects to survive a
165
+ hot-swap. So front reload is per-output-type:
166
+
167
+ | Output | Swap strategy | What if rebuild changes it |
168
+ |---|---|---|
169
+ | `panel` | swap component in `PanelRegistry` keyed by id | mounted panes re-render with new component; React handles |
170
+ | `command` | swap handler in `CommandRegistry` keyed by id | next invocation uses new handler |
171
+ | `catalog` | swap adapter keyed by id | open catalog views re-query |
172
+ | `left-tab` | swap component keyed by id | tab re-renders |
173
+ | `surface-resolver` | replace fn keyed by id | next resolve uses new fn |
174
+ | `binding` | swap component keyed by id | React unmount/mount |
175
+ | `provider` | structural | **cannot safely swap** — emit `boring.plugin.needs-page-reload`; UI offers a reload toast |
176
+
177
+ The front registries already index by id; this is "rebuild the map, emit a
178
+ change event so subscribers re-read." It's *not* a per-entry diff with
179
+ add/move/remove semantics — it's a wholesale Map replacement, structurally
180
+ the same as Pi's server rebuild, just preserving the React tree above it.
181
+
182
+ **Other surfaces** that don't fit either model:
183
+
184
+ | Surface | Behavior on reload |
185
+ |---|---|
186
+ | `agentTools` registered via `pi.extensions` | Pi's reload handles natively — fresh module via jiti, fresh `registerTool` calls, fresh registry. Tool body changes land in next agent turn. |
187
+ | `agentTools` registered via `WorkspaceServerPlugin.agentTools` (static) | Captured in `tools[]` at session creation; Pi has no public API to swap mid-session. Emit `boring.plugin.needs-session-restart`. Authors who want full hot coverage move tools to `pi.extensions` (Phase 7). |
188
+ | `systemPrompt` | Already covered by `systemPromptDynamic` getter (re-aggregates each `before_agent_start`). |
189
+ | `piPackages` / `extensionPaths` / `additionalSkillPaths` | Already covered by `getDynamicResources` (Pi re-reads on each `reloadSession`). |
190
+ | `routes` (namespaced under `/api/boring-plugins/<id>/*`) | Dispatcher map entry rewritten; Fastify routes untouched. Already works. |
191
+ | `routes` (free-form, registered via `pluginFactories`) | Fastify can't safely re-register. Emit `boring.plugin.needs-server-restart`. |
192
+ | `preservedUiStateKeys` | Recompute the merged set; the existing UI-state route already consults it on every PUT. |
193
+
194
+ The shell never lies: if a change can't apply, it surfaces an honest event
195
+ with what's needed (page reload, session restart, server restart). Errors
196
+ during rebuild are *diagnostics* — they don't block the reload, they show
197
+ up alongside the partially-completed result, same as Pi.
198
+
199
+ ### What plugin authors learn
200
+
201
+ Nothing new. The same shape that already works for static install.
202
+ **Hot reload becomes free for plugins that contribute only diff-safe output
203
+ types.** Plugins that contribute providers or free-form routes get partial
204
+ hot reload (everything else swaps; provider changes prompt page reload).
205
+
206
+ ## Per-plugin migration analysis
207
+
208
+ The unification has three migration levels, each plugin chooses how far to go:
209
+
210
+ | Level | What changes in the plugin | What you get |
211
+ |---|---|---|
212
+ | **L0 — install-call-site only** | Zero plugin-code change. Host updates `dev.ts` to use the unified `plugins:` array. | Same behaviour as today. Validates the plugin still installs through the new pipeline. |
213
+ | **L1 — declare manifest entries** | Add `package.json#boring.front`/`boring.server`. Optionally `pi.systemPrompt`. No source-code changes. | Plugin becomes installable as `{ spec: { dir }, hotReload: true }`. Front + server modules participate in directory-source hot reload. Limited by which output types are diff-safe (provider edits still require page reload; static `agentTools` still require session restart). |
214
+ | **L2 — full hot-reload coverage** | Move `agentTools` body into `agent/index.ts` Pi extension; bridge-proxy to long-lived workspace state. Move free-form routes under namespaced `/api/boring-plugins/<id>/*`. | All output types swap on `/reload`. Edits to the tool body, prompt, panel, command, resolver, etc. land in the next agent turn or the next request without restart. Cost: bridge protocol per plugin, breaking URL changes for routes. |
215
+
216
+ Each plugin can sit at a different level. L0 is the floor — every plugin
217
+ gets that automatically. L1 and L2 are opt-in per-plugin upgrades the
218
+ plugin author chooses.
219
+
220
+ ### Concrete adaptation by plugin (current main)
221
+
222
+ Each subsection lists the specific files that change and the LoC ballpark.
223
+
224
+ #### `plugins/ask-user/` (`@hachej/boring-ask-user`)
225
+
226
+ Today's contributions (read from
227
+ `plugins/ask-user/src/server/askUserServerPlugin.ts` +
228
+ `plugins/ask-user/src/front/index.tsx`):
229
+
230
+ | Output | Diff-safe? |
231
+ |---|---|
232
+ | Provider (`AskUserProvider` React context) | ❌ page reload to change |
233
+ | Panel (`ASK_USER_PANEL_ID`) | ✅ swappable |
234
+ | Surface resolver | ✅ swappable |
235
+ | Command (`open` event dispatcher) | ✅ swappable |
236
+ | `agentTools: [ask_user]` (closure captures `runtime`, `sessionId`, `bridge`) | ❌ session restart |
237
+ | `routes` (free-form `/api/questions/*`) | ❌ server restart |
238
+ | `preservedUiStateKeys: [ASK_USER_UI_STATE_SLOTS.PENDING]` | ✅ recomputable |
239
+
240
+ **L1 migration** (recommended next step):
241
+
242
+ ```jsonc
243
+ // plugins/ask-user/package.json — add
244
+ {
245
+ "boring": {
246
+ "front": "src/front/index.tsx",
247
+ "server": "src/server/askUserServerPlugin.ts"
248
+ }
249
+ }
250
+ ```
251
+
252
+ No source changes. Plugin installs as `{ spec: { dir }, hotReload: true }`.
253
+ Effective coverage ~60% (panel/resolver/command swap; provider/tool/routes
254
+ require restart). **Cost: ~5 lines in `package.json`.**
255
+
256
+ **L2 migration** (when full hot reload is wanted):
257
+
258
+ 1. New file `plugins/ask-user/src/agent/index.ts` (~30 LoC) — Pi extension
259
+ factory that registers `ask_user` via `pi.registerTool`. Handler
260
+ bridge-proxies to `AskUserRuntime`.
261
+
262
+ ```ts
263
+ import { z } from "zod"
264
+ export default function (pi) {
265
+ pi.registerTool("ask_user", {
266
+ description: "Ask the user a blocking question.",
267
+ inputSchema: z.object({ /* same schema */ }),
268
+ handler: async (args, ctx) => ctx.bridge.request("askUser:ask", args),
269
+ })
270
+ }
271
+ ```
272
+
273
+ 2. Remove `agentTools: [ask_user]` from `askUserServerPlugin.ts`. Add
274
+ bridge subscriber: `bridge.handle("askUser:ask", (args) => runtime.ask(args))`.
275
+ 3. Add `pi.extensions: ["src/agent/index.ts"]` and
276
+ `pi.systemPrompt: "When you need a blocking decision..."` to
277
+ `package.json`.
278
+ 4. Migrate `/api/questions/*` URLs to `/api/boring-plugins/ask-user/*`.
279
+ **Breaking change** — front client (`createQuestionsClient`) and any
280
+ external consumer must update.
281
+ 5. `AskUserRuntime`, `AskUserStore`, `AskUserStatePublisher` stay in
282
+ `server/index.ts` — long-lived state survives reload.
283
+
284
+ **Cost: ~150 LoC across 4 files.** Effective coverage ~95% after.
285
+
286
+ #### `plugins/data-catalog/` (`@hachej/boring-data-catalog`)
287
+
288
+ Today's contributions (`plugins/data-catalog/src/server/index.ts` +
289
+ `plugins/data-catalog/src/front/index.tsx`):
290
+
291
+ | Output | Diff-safe? |
292
+ |---|---|
293
+ | Panel + catalog + left-tab + surface resolver | ✅ all swappable |
294
+ | `agentTools: [data_catalog]` (closure captures caller-supplied `adapter`) | ❌ session restart |
295
+ | `systemPrompt` | ✅ via `systemPromptDynamic` |
296
+ | No routes, no provider | n/a |
297
+
298
+ **L1 migration** (3 lines in `package.json`):
299
+
300
+ ```jsonc
301
+ {
302
+ "boring": {
303
+ "front": "src/front/index.tsx",
304
+ "server": "src/server/index.ts"
305
+ }
306
+ }
307
+ ```
308
+
309
+ Effective coverage ~80%. Only the tool body needs session restart.
310
+
311
+ **L2 migration** is light because there are no routes or providers:
312
+
313
+ 1. New `plugins/data-catalog/src/agent/index.ts` (~25 LoC) — registers
314
+ `data_catalog` Pi tool that bridge-proxies to the adapter.
315
+ 2. Remove `agentTools` from `defineServerPlugin` call. Server side keeps
316
+ the adapter and registers a bridge handler.
317
+ 3. Caller passes adapter via the unified install entry's `options` field:
318
+ `{ spec: { module: dataCatalogServerPlugin }, options: { adapter } }`
319
+ instead of `createDataCatalogServerPlugin({ adapter })`.
320
+
321
+ **Cost: ~80 LoC across 2 files.** Effective coverage 100% after.
322
+
323
+ #### `plugins/data-explorer/` (`@hachej/boring-data-explorer`)
324
+
325
+ Per its `package.json#exports` (no `./server` export), this is a UI
326
+ component library, not a plugin. **No migration needed.** It stays a
327
+ regular dep that `data-catalog` and the workspace shell consume.
328
+
329
+ #### `apps/workspace-playground/src/plugins/playgroundDataCatalog/`
330
+
331
+ Playground-internal plugin (`createPlaygroundDataServerPlugin`), wired
332
+ statically into `dev.ts`. Contributes seed data + a server tool. **L0
333
+ only** — there is no scenario for hot-reloading it because the playground
334
+ is the host. The unified install array in Phase 2 already covers it.
335
+
336
+ ### Migration order recommendation
337
+
338
+ 1. **`data-catalog` first.** Lighter (no routes, no provider, no URL break).
339
+ Smallest blast radius. Validates L1 + L2 paths end-to-end.
340
+ 2. **`ask-user` second.** Heavier (routes namespace migration is breaking;
341
+ provider stays L1-bounded). Migrate L1 immediately, L2 only if the
342
+ `/api/questions/*` URL break is acceptable.
343
+ 3. **playground / future first-party plugins**: stay at L0 unless someone
344
+ needs to hot-edit them during dev.
345
+
346
+ ### What changes in the plugin-authoring skill
347
+
348
+ The `/boring-plugin-build` skill needs three additions:
349
+
350
+ 1. New "**Installing your plugin in a host app**" section showing the
351
+ unified `plugins: [...]` shape with the four entry variants
352
+ (object / factory / `{ spec: { module } }` / `{ spec: { dir } }`).
353
+ 2. New "**Hot-reload coverage matrix**" section explaining what each
354
+ output type does on `/reload` (the L1 vs L2 table above).
355
+ 3. Update the "**Server side**" section to mention the optional Pi
356
+ extension path (`src/agent/index.ts` + `pi.extensions` manifest field)
357
+ for tools that want full hot reload.
358
+
359
+ The template (`packages/cli/templates/plugin/`) stays as-is for L0/L1; an optional
360
+ `agent/index.ts` example can be added later when L2 migration of any
361
+ shipped plugin proves the pattern.
362
+
363
+ ## Implementation phasing
364
+
365
+ Each phase is independently shippable. Stop after Phase 2 and you already
366
+ have a unified system with most of the value.
367
+
368
+ ### Phase 0 — Single install entry type (no behavior change)
369
+
370
+ Replace `plugins: WorkspaceServerPlugin[]` + `pluginFactories:
371
+ WorkspaceAgentServerPluginFactory[]` with one array that accepts both
372
+ shapes. Same for front. Pure type widening; existing callers still work.
373
+
374
+ ```ts
375
+ // packages/workspace/src/app/server/createWorkspaceAgentServer.ts
376
+ type PluginEntry =
377
+ | WorkspaceServerPlugin
378
+ | ((ctx: WorkspaceServerPluginContext) => WorkspaceServerPlugin)
379
+ | { spec: PluginSpec; options?: unknown; hotReload?: boolean }
380
+ ```
381
+
382
+ `pluginFactories` becomes a soft-deprecated alias.
383
+
384
+ **Cost:** ~50 lines. Pure refactor. Zero risk.
385
+
386
+ ### Phase 1 — Resolver: `{ spec: { module } }` and `{ spec: { dir } }`
387
+
388
+ Add a resolver that turns a `PluginEntry` into a `WorkspaceServerPlugin` +
389
+ `WorkspaceFrontPlugin` pair.
390
+
391
+ ```ts
392
+ interface PluginSpec {
393
+ module?: () => Promise<unknown> | unknown // imported package or factory
394
+ dir?: string // workspace dir on disk
395
+ }
396
+ ```
397
+
398
+ For `module`: call the factory with `options` if it's a function, otherwise
399
+ treat as pre-built.
400
+
401
+ For `dir`: use **manifest-first, convention-fallback** resolution, mirroring
402
+ Pi's mechanism (`@mariozechner/pi-coding-agent` → `core/package-manager.js:
403
+ resolveExtensionEntries`):
404
+
405
+ ```ts
406
+ function resolvePluginEntries(dir: string, hotReload: boolean) {
407
+ const pkg = readPackageJson(dir)
408
+ return {
409
+ front: resolveOne(dir, pkg?.boring?.front,
410
+ ["src/front/index.tsx", "src/front/index.ts",
411
+ "dist/front/index.js"],
412
+ hotReload),
413
+ server: resolveOne(dir, pkg?.boring?.server,
414
+ ["src/server/index.ts",
415
+ "dist/server/index.js"],
416
+ hotReload),
417
+ manifest: pkg.boring,
418
+ }
419
+ }
420
+
421
+ function resolveOne(dir, explicit, conventions, hotReload) {
422
+ // 1. Explicit field wins (Pi parity: manifest is the contract)
423
+ if (explicit) {
424
+ const path = resolve(dir, explicit)
425
+ if (existsSync(path)) return path
426
+ throw new Error(`boring.* entry declared but missing: ${path}`)
427
+ // Pi parity: declared-but-missing fails loudly. No silent fallback.
428
+ }
429
+ // 2. Conventions only when no explicit declaration
430
+ for (const candidate of conventions) {
431
+ const path = resolve(dir, candidate)
432
+ if (existsSync(path)) return path
433
+ }
434
+ return null
435
+ }
436
+ ```
437
+
438
+ Two safety properties carried over from Pi:
439
+
440
+ 1. **Explicit-but-missing fails loudly.** Declaring `boring.front: "x"` and
441
+ not shipping that file is an error, not a silent convention fallback.
442
+ 2. **Conventions only kick in when no explicit declaration is present.**
443
+ Plugin authors who follow the template get free discovery; authors who
444
+ need a non-standard layout declare it.
445
+
446
+ For `hotReload: true`, the resolver prefers `src/*` entries via `jiti` so
447
+ edits take effect; for `hotReload: false`, it prefers `dist/*` entries via
448
+ regular `import()` so production behavior matches bundled output.
449
+
450
+ **Cost:** ~180 lines. Reuses existing `BoringPluginAssetManager` jiti import.
451
+
452
+ ### Phase 2 — Migrate playground to the unified API
453
+
454
+ `apps/workspace-playground/src/server/dev.ts`:
455
+
456
+ ```ts
457
+ import { askUserPlugin } from "@hachej/boring-ask-user/front"
458
+ import { dataCatalogPlugin } from "@hachej/boring-data-catalog/front"
459
+ import { createAskUserServerPlugin } from "@hachej/boring-ask-user/server"
460
+ import { createDataCatalogServerPlugin } from "@hachej/boring-data-catalog/server"
461
+
462
+ await createWorkspaceAgentServer({
463
+ workspaceRoot,
464
+ plugins: [
465
+ (ctx) => createAskUserServerPlugin({ workspaceRoot, bridge: ctx.bridge }),
466
+ (ctx) => createDataCatalogServerPlugin({ adapter: myAdapter }),
467
+ (ctx) => createPlaygroundDataServerPlugin({ workspaceRoot }),
468
+ ],
469
+ })
470
+ ```
471
+
472
+ Drops the `pluginFactories` knob. Single array. Same plugin code.
473
+
474
+ **Cost:** ~20 lines in `dev.ts`. Removes a workspace API surface.
475
+
476
+ ### Phase 3 — Front-side registry rebuild + plugin lifecycle events
477
+
478
+ The front side keeps the registry maps that the React shell consumes. We
479
+ rebuild those maps wholesale on reload, then emit a change event so
480
+ subscribers re-read. The React tree above the registries stays mounted.
481
+
482
+ Add `plugin_shutdown` and `plugin_start` events on the front plugin
483
+ lifecycle (Pi parity — `extensions/runner.js:48` and `agent-session.js:1912`
484
+ fire `session_shutdown` and `session_start { reason: "reload" }`). Plugins
485
+ can opt into either by registering a handler; the rebuild gates on
486
+ `hasHandlers()` before emitting, same as Pi.
487
+
488
+ ```ts
489
+ // front rebuild flow on /reload
490
+ for (const plugin of mountedPlugins) {
491
+ if (plugin.hasHandlers("plugin_shutdown")) {
492
+ await plugin.emit({ type: "plugin_shutdown", reason: "reload" })
493
+ }
494
+ }
495
+ const fresh = await resolveAllFront(entries)
496
+ const conflicts = detectFrontConflicts(fresh)
497
+ for (const conflict of conflicts) {
498
+ diagnostics.push({ path: conflict.path, error: conflict.message })
499
+ }
500
+ panelRegistry.replaceAll(collectPanels(fresh))
501
+ commandRegistry.replaceAll(collectCommands(fresh))
502
+ catalogRegistry.replaceAll(collectCatalogs(fresh))
503
+ surfaceResolverRegistry.replaceAll(collectSurfaceResolvers(fresh))
504
+ leftTabRegistry.replaceAll(collectLeftTabs(fresh))
505
+ bindingRegistry.replaceAll(collectBindings(fresh))
506
+ if (providersChanged(prev, fresh)) {
507
+ emitEvent("boring.plugin.needs-page-reload", { diagnostics })
508
+ } else {
509
+ for (const plugin of fresh) {
510
+ if (plugin.hasHandlers("plugin_start")) {
511
+ await plugin.emit({ type: "plugin_start", reason: "reload" })
512
+ }
513
+ }
514
+ }
515
+ ```
516
+
517
+ `replaceAll` is what makes this "rebuild over diff": the registry computes
518
+ its own structural change set internally (which subscribers updated, which
519
+ panel ids vanished) and fires one change event. Subscribers re-render.
520
+ There is no per-entry add/remove API the caller has to maintain.
521
+
522
+ **Cost:** ~250 lines. Touches the registry classes + adds the front
523
+ plugin lifecycle.
524
+
525
+ ### Phase 4 — Server-side rebuild
526
+
527
+ Pi's `AgentSession.reload()` is our reference. Implement
528
+ `rebuildServerPlugins()` that mirrors Pi's flow:
529
+
530
+ ```ts
531
+ async function rebuildServerPlugins() {
532
+ const snapshot = {
533
+ activeSessionId,
534
+ activeToolNames: harness.getActiveToolNames?.(),
535
+ uiState: bridge.snapshotState(), // pre-shutdown, like Pi's previousFlagValues
536
+ }
537
+
538
+ // 1. Teardown
539
+ for (const plugin of currentPlugins) {
540
+ if (plugin.hasHandlers("plugin_shutdown")) {
541
+ await plugin.emit({ type: "plugin_shutdown", reason: "reload" })
542
+ }
543
+ }
544
+
545
+ // 2. Reset registries to a clean state (Pi: resetApiProviders())
546
+ routeDispatcher.clear()
547
+ systemPromptSources.clear()
548
+ preservedUiStateKeys.clear()
549
+
550
+ // 3. Re-resolve hot entries via jiti, regular import for static ones
551
+ const fresh = await resolveAll(entries)
552
+
553
+ // 4. Re-run bootstrapServer with the fresh plugin list
554
+ const bootResult = bootstrapServer({ plugins: fresh, defaults, excludeDefaults })
555
+
556
+ // 5. Conflict detection (Pi: detectExtensionConflicts at resource-loader.js:690)
557
+ const conflicts = detectServerConflicts(fresh)
558
+ for (const conflict of conflicts) {
559
+ diagnostics.push(conflict)
560
+ }
561
+
562
+ // 6. Wire bootResult into the runtime
563
+ for (const route of bootResult.routeContributions) {
564
+ routeDispatcher.set(route.id, route.routes)
565
+ }
566
+ systemPromptSources.replaceAll(bootResult.systemPromptAppend)
567
+ preservedUiStateKeys.replaceAll(bootResult.preservedUiStateKeys)
568
+
569
+ // 7. Pi-side resources via existing seam (no change required)
570
+ // getDynamicResources() already returns fresh piPackages/extensionPaths/skills
571
+
572
+ // 8. Restore snapshot
573
+ if (snapshot.activeToolNames) harness.setActiveToolNames?.(snapshot.activeToolNames)
574
+ bridge.restoreState(snapshot.uiState)
575
+
576
+ // 9. Fire plugin_start with reason: "reload" (Pi parity)
577
+ for (const plugin of fresh) {
578
+ if (plugin.hasHandlers("plugin_start")) {
579
+ await plugin.emit({ type: "plugin_start", reason: "reload" })
580
+ }
581
+ }
582
+
583
+ return { ok: diagnostics.length === 0, diagnostics, plugins: fresh.map(p => p.id) }
584
+ }
585
+ ```
586
+
587
+ Diagnostics carry the failed entries' paths/ids and reasons, surfaced via
588
+ the existing reload SSE channel. Failed plugin load doesn't block the
589
+ others (Pi parity — `loaders/extensions/loader.js:288` records error and
590
+ continues).
591
+
592
+ The harness layer still consumes `systemPromptDynamic` and
593
+ `getDynamicResources` as added in PR #18 — no change there; those getters
594
+ just see fresh state after rebuild.
595
+
596
+ **Cost:** ~200 lines. Half is the lifecycle event plumbing.
597
+
598
+ ### Phase 5 — Wire directory-source plugins to `/reload`
599
+
600
+ For each `{ spec: { dir }, hotReload: true }` entry, the asset manager
601
+ watches the dir, re-resolves on `/reload`, hands the new plugin object to
602
+ the diff applier.
603
+
604
+ This collapses `BoringPluginAssetManager`'s plugin-specific knowledge into a
605
+ generic "watch dir, jiti-import, hand to install pipeline" loop. No more
606
+ `BoringServerPluginManifest` JSON shape.
607
+
608
+ **Cost:** ~100 lines + cleanup of ~150 lines from `manager.ts`.
609
+
610
+ ### Phase 6 — Solidify the manifest as the primary contract
611
+
612
+ Keep `package.json#boring.front`/`boring.server` as the canonical
613
+ directory-source contract (Pi parity — see Phase 1 resolver). Document the
614
+ manifest-first + convention-fallback rule in
615
+ `@hachej/boring-pi/skills/boring-plugin-authoring/SKILL.md` and the
616
+ `/boring-plugin-build` skill. Plugins that follow the template skip the
617
+ fields; plugins with non-standard layouts declare them.
618
+
619
+ Also remove the redundant `package.json#boring`-driven hot-discovery code
620
+ inside `BoringPluginAssetManager` once Phase 5 funnels everything through
621
+ the unified resolver — there is one read site for `boring.*`, not two.
622
+
623
+ **Cost:** cleanup + ~1 page of doc rewrites.
624
+
625
+ ### Optional Phase 7 — Per-plugin hot-reload upgrade
626
+
627
+ For each plugin that wants 100% hot coverage:
628
+ - Move statically-registered `agentTools` to `pi.extensions` + bridge proxy.
629
+ - Move free-form routes to the `/api/boring-plugins/<id>/*` namespace.
630
+
631
+ These are *plugin-author opt-ins*, not workspace requirements.
632
+
633
+ ## What this does NOT change
634
+
635
+ - The plugin template stays as-is.
636
+ - `defineFrontPlugin` and `defineServerPlugin` remain the authoring
637
+ primitives.
638
+ - The `/boring-plugin-build` skill stays mostly accurate; only the
639
+ installation section needs updating to describe the new entry shape.
640
+ - Existing plugin tests don't move.
641
+ - Production bundling is unaffected (module-source plugins still bundle
642
+ through Vite/tsup the same way).
643
+
644
+ ## What this DOES change in plugin authoring docs
645
+
646
+ A new section: "Installing your plugin in a host app":
647
+
648
+ ```ts
649
+ // Static install (production default):
650
+ plugins: [
651
+ (ctx) => createMyPlugin({ adapter: ctx.workspaceRoot }),
652
+ ]
653
+
654
+ // Hot install (dev iteration):
655
+ plugins: [
656
+ { spec: { dir: "plugins/my-plugin" }, hotReload: true },
657
+ ]
658
+ ```
659
+
660
+ That's the only change plugin authors see.
661
+
662
+ ## Alignment with Pi — borrowed mechanisms (with code refs)
663
+
664
+ Every reload-related design decision in this plan is grounded in something
665
+ Pi already does and has shipped. Code refs are relative to
666
+ `node_modules/@mariozechner/pi-coding-agent/dist/`.
667
+
668
+ | Mechanism | Pi reference | Where we use it |
669
+ |---|---|---|
670
+ | **Manifest-first, convention-fallback resolution** for directory plugins | `core/package-manager.js:333` `resolveExtensionEntries` — reads `package.json#pi.extensions` first; falls back to `index.ts` → `index.js` | Phase 1 resolver for `package.json#boring.front`/`boring.server` |
671
+ | **Declared-but-missing fails loudly**, no silent fallback | `core/package-manager.js:339-347` filters `existsSync` only after explicit manifest field is set | Phase 1 `resolveOne` throws if explicit and missing |
672
+ | **Rebuild over diff** on reload | `core/agent-session.js:1896` `reload()` — emits `session_shutdown`, wipes resource loader state, re-imports, rebuilds registry from scratch | Phase 4 `rebuildServerPlugins`; Phase 3 `replaceAll` on registries |
673
+ | **Lifecycle events** `plugin_shutdown` / `plugin_start { reason: "reload" }` | `core/extensions/runner.js:48` `emitSessionShutdownEvent`; `core/agent-session.js:1912` `session_start { reason: "reload" }` | Phase 3 + 4 emit these around the rebuild; plugins can register handlers for cleanup/replay |
674
+ | **`hasHandlers` gate** before emitting events | `core/extensions/runner.js:48` `extensionRunner?.hasHandlers("session_shutdown")` | Plugin lifecycle event emission only fires when at least one plugin listens |
675
+ | **Conflict detection as diagnostics, not failures** | `core/resource-loader.js:281` calls `detectExtensionConflicts`, appends to `extensionsResult.errors[]`, keeps all extensions loaded with load-order precedence | Phase 4 `detectServerConflicts` returns conflicts as diagnostics; rebuild continues |
676
+ | **Conflict algorithm**: `Map<name, ownerPath>` walk across registries | `core/resource-loader.js:690` `detectExtensionConflicts` — tracks `toolOwners` and `flagOwners`; first owner wins | Same algorithm against our `panel`/`command`/`catalog`/`surfaceResolver`/`leftTab` id maps |
677
+ | **Continue on individual load failure** | `core/extensions/loader.js:288` `loadExtensions` — failed extension recorded as `{ path, error }` in `errors[]`, loop continues with remaining paths | Phase 4 resolver records failures into diagnostics; other plugins still rebuild |
678
+ | **Snapshot user-set state before teardown, replay after rebuild** | `core/agent-session.js:1897` `previousFlagValues = this._extensionRunner?.getFlagValues()`, replayed at `_buildRuntime({ flagValues: previousFlagValues, ... })` | Phase 4 snapshots `activeSessionId`, `activeToolNames`, `bridge.snapshotState()`; replays after rebuild |
679
+ | **`reset*` before rebuild** to clear stale state | `core/agent-session.js:1900` `resetApiProviders()` between settings reload and resource loader reload | Phase 4 explicitly clears `routeDispatcher`, `systemPromptSources`, `preservedUiStateKeys` before re-running `bootstrapServer` |
680
+ | **Per-resource diagnostic arrays**, queried by consumers separately | `core/resource-loader.js:167-173` `skillDiagnostics`, `promptDiagnostics`, `themeDiagnostics` — surface via SDK getters, not thrown | Reload response carries `{ diagnostics: [{ pluginId, source, error }, ...] }` — same shape, surfaced via SSE |
681
+ | **Source metadata for diagnostics** (where did this resource come from?) | `core/resource-loader.js:218` `metadataByPath` correlates each resource path to `{ source, scope, origin }` for diagnostic provenance | Resolver tags each plugin entry with `{ source: "module" \| "directory", path }` so diagnostics point at the offender |
682
+ | **Stable load-order precedence** when multiple sources contribute | `core/resource-loader.js` various `mergePaths` calls preserving order: cli → auto → explicit | Plugin entries register in array order; first wins on id collision (matches Pi behaviour) |
683
+ | **Path validation surfaces as diagnostic, not crash** | `core/resource-loader.js:287` `existsSync(p)` check pushes `Extension path does not exist` into errors | Phase 1 resolver: `dir` not found pushes diagnostic; doesn't throw |
684
+ | **`jiti` with `moduleCache: false`** for hot module replacement | `core/extensions/loader.js:224` `createJiti(import.meta.url, { moduleCache: false })` | Already in our `BoringPluginAssetManager`; Phase 1 resolver uses the same primitive |
685
+
686
+ What we **do not** borrow from Pi:
687
+
688
+ - **Auto-discovery from filesystem walk** of subdirs without explicit
689
+ registration. Pi does this for skills (`SKILL.md`) and extensions in
690
+ certain modes (`core/package-manager.js:362` `collectAutoExtensionEntries`).
691
+ For our plugin system we keep registration explicit at the workspace
692
+ level (the host's `plugins: [...]` array is the truth). `.pi/extensions/*`
693
+ auto-discovery is preserved as a *thin layer* that injects
694
+ `{ spec: { dir }, hotReload: true }` entries — same downstream code.
695
+ - **Transactional rollback on partial failure**. Pi doesn't do it; neither
696
+ do we. Failed plugin → diagnostic; rebuild result keeps the rest.
697
+
698
+ ## Risks & open questions
699
+
700
+ 1. **jiti and React duplicate.** Hot install via jiti for a plugin that
701
+ imports React must dedupe to the host shell's React, same constraint
702
+ the existing hot-reload path already documents. No new infra; inherit
703
+ the existing Vite alias rules. Pi sidesteps this because Pi extensions
704
+ are server-only — front-side dedupe is *our* problem, not borrowable.
705
+
706
+ 2. **Provider changes during hot install.** React doesn't support
707
+ re-rooting providers around a live tree. `needs-page-reload` event +
708
+ toast. Pi doesn't have a React tree to preserve, so this is a
709
+ front-only constraint we add on top of Pi's rebuild model.
710
+
711
+ 3. **Dev/prod fidelity (`jiti` vs Vite/tsup bundling)** — flagged by the
712
+ Gemini review. Pi only faces this on the server (always `jiti`), so
713
+ doesn't help us here. Mitigation: a CI invariant that boot-runs every
714
+ plugin through *both* the directory resolver (`hotReload: true`) and
715
+ the module resolver (`hotReload: false`), asserts the resulting
716
+ `WorkspaceFrontPlugin`/`WorkspaceServerPlugin` shapes match. Catches
717
+ drift before merge.
718
+
719
+ 4. **Plugin options at install time.** The `options` field on
720
+ `{ spec, options }` is `unknown` and depends on each plugin's factory
721
+ shape. Type-safe via generics: `PluginEntry<TOptions>` parameterized on
722
+ factory signature. Pi doesn't have an analogue (extensions are
723
+ self-contained); we own this.
724
+
725
+ 5. **What happens when a plugin DIR is added at runtime?** Auto-discovery
726
+ stays as a thin layer that injects `{ spec: { dir }, hotReload: true }`
727
+ entries before the install pipeline runs. Same downstream code as
728
+ explicit registration. Pi parity: Pi has `collectAutoExtensionEntries`
729
+ (`core/package-manager.js:362`) doing the same thing — auto-discovery
730
+ sits in front of the explicit registration path, doesn't replace it.
731
+
732
+ 6. **Breaking changes for external API consumers.** Free-form routes
733
+ registered by `ask-user` (`/api/questions/*`) stay free-form unless the
734
+ plugin author opts into namespacing. No breaking change forced by this
735
+ plan.
736
+
737
+ 7. **State the snapshot can't capture.** Pi snapshots `flagValues` and
738
+ `activeToolNames`. We snapshot `activeSessionId`, `activeToolNames`,
739
+ `bridge.snapshotState()`. Things we *can't* meaningfully snapshot:
740
+ in-flight tool calls, streaming agent turns, half-completed user
741
+ forms. Reload aborts in-flight work — same as Pi (`session_shutdown`
742
+ triggers cleanup, agent turns crash if mid-stream). Document this; it's
743
+ a feature, not a bug.
744
+
745
+ 8. **Watcher debouncing at scale.** Flagged by xAI. Pi doesn't have a
746
+ reload-on-watch model — Pi reloads on explicit user command. We
747
+ already do too: `/reload` is user-triggered, not file-watcher-driven.
748
+ The asset manager's existing signature-hash short-circuit handles
749
+ "nothing changed" cases. If we later add a watcher mode, debounce per
750
+ directory.
751
+
752
+ ## Done criteria
753
+
754
+ Plan is "done" when:
755
+
756
+ - One install array. `pluginFactories` deleted.
757
+ - One resolver that handles both `{ spec: { module } }` and
758
+ `{ spec: { dir } }` entries, with manifest-first + convention-fallback
759
+ rules matching Pi.
760
+ - Server reload uses `rebuildServerPlugins` (rebuild over diff, Pi parity).
761
+ - Front reload uses registry `replaceAll` + plugin lifecycle events.
762
+ - Conflicts surface as diagnostics on reload, never block.
763
+ - The three first-party plugins (`ask-user`, `data-catalog`, an example
764
+ in `.pi/extensions/`) all install through the same code path.
765
+ - `/reload` returns `{ ok, diagnostics, plugins: [...] }` and the shell
766
+ routes diagnostics by source (server-side → chat surface, front-side
767
+ → toast).
768
+ - Plugin-authoring skill teaches exactly one shape and one install
769
+ pattern, citing the manifest-first + convention-fallback rule.