@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.
- package/README.md +36 -34
- package/dist/{FileTree-Dvaud3jU.js → FileTree-DHVB9rpk.js} +15 -15
- package/dist/{MarkdownEditor-sLkqTXDj.js → MarkdownEditor-L1KDH0bM.js} +1 -1
- package/dist/{WorkspaceLoadingState-zLzh1tGc.js → WorkspaceLoadingState-DYDxUYnx.js} +114 -110
- package/dist/WorkspaceProvider-CDPaAO5u.js +5971 -0
- package/dist/app-front.d.ts +94 -107
- package/dist/app-front.js +243 -233
- package/dist/app-server.d.ts +130 -15
- package/dist/app-server.js +1569 -304
- package/dist/{bootstrapServer-BreQ9QBc.d.ts → createInMemoryBridge-BDxDzihm.d.ts} +11 -26
- package/dist/manifest-CyNNdfYz.d.ts +58 -0
- package/dist/plugin.d.ts +199 -0
- package/dist/plugin.js +300 -0
- package/dist/server.d.ts +239 -4
- package/dist/server.js +901 -78
- package/dist/shared.d.ts +4 -112
- package/dist/surface-COYagY2m.d.ts +111 -0
- package/dist/testing.d.ts +19 -1
- package/dist/testing.js +2 -2
- package/dist/{agent-tool-DEtfQPVB.d.ts → ui-bridge-Gfh1MMgl.d.ts} +30 -30
- package/dist/workspace.css +36 -0
- package/dist/workspace.d.ts +165 -120
- package/dist/workspace.js +330 -377
- package/docs/INTERFACES.md +9 -9
- package/docs/PLUGIN_STRUCTURE.md +39 -145
- package/docs/PLUGIN_SYSTEM.md +355 -0
- package/docs/README.md +6 -1
- package/docs/plans/README.md +1 -0
- package/docs/plans/archive/HOT_RELOADABLE_AGENT_PLUGINS_PLAN.md +218 -0
- package/docs/plans/archive/RELOAD_PLUGGABILITY_PLAN.md +174 -0
- package/docs/plans/archive/UNIFIED_PLUGIN_SYSTEM_PLAN.md +769 -0
- package/package.json +11 -5
- package/dist/CommandPalette-CJHuTJlD.js +0 -5716
- package/docs/bridge.md +0 -135
- package/docs/panels.md +0 -102
- package/docs/plugins.md +0 -158
- /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.
|