From a5c013c0c6b9d770d87e4e190d9d793aec35ca90 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 17 Aug 2026 02:20:02 +0200 Subject: [PATCH] docs(agent): track extension v2 design --- .gitignore | 1 - .../agent/docs/extensions/pi-extensions-v2.md | 868 ++++++++++++++++++ 2 files changed, 868 insertions(+), 1 deletion(-) create mode 100644 packages/agent/docs/extensions/pi-extensions-v2.md diff --git a/.gitignore b/.gitignore index cbbfaea64..e0b9621e9 100644 --- a/.gitignore +++ b/.gitignore @@ -42,5 +42,4 @@ plans/ .pi/hf-sessions/ .pi/hf-sessions-backup/ collect.sh -packages/agent/docs/extensions/pi-extensions-v2.md packages/agent/docs/extensions/pi-server-artifact/index.md diff --git a/packages/agent/docs/extensions/pi-extensions-v2.md b/packages/agent/docs/extensions/pi-extensions-v2.md new file mode 100644 index 000000000..5b4e7d66c --- /dev/null +++ b/packages/agent/docs/extensions/pi-extensions-v2.md @@ -0,0 +1,868 @@ +# pi extension system v2 — design notes + +Status: **exploratory**. A working document, not a spec. Records what we learned +from other harnesses, what we take and reject, and the current shape. Expected to +change. + +--- + +## 1. Sources + +### Ours + +| Doc | Status | What it settles | +|---|---|---| +| `harness.md` | current | Durable session runtime: storage, tree, lanes, operations, hooks, events, restore. **Supersedes the two below wherever they conflict.** | +| Remote session vertical slice | current | Supervisor process model: stable server ID, Unix socket, process-per-session, replacement with handoff. | +| `plans/pi-server.md` | superseded, still useful | Snapshot+delta discipline, state taxonomy, server/client split, missing-events inventory. | +| `plans/pi-extensions.md` | superseded, still useful | Runtime split, manifest + multiple factories, `api: "major.minor"`, web asset serving, dogfooding rule. | + +Read the superseded pair for *why*; read `harness.md` for what exists. + +### External + +- **opencode** `packages/plugin/src/v2/effect/PLAN.md` — transform/hook/rebuild. + Short, and the most directly applicable document in the set. +- **bb** `packages/plugin-sdk/src/app-contract.ts` and + `apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md` — + slots, atomic reload with rollback, bundle serving. +- **deepseek-harness** `packages/bundle/base/cordis.patch.yml`, + `packages/client/ui-layout/src/client/index.ts`, `vendor/loader/src/internal.ts` + — "everything is a plugin" at full commitment, plus the ESM-internals reload trick. +- **eve** `docs/concepts/execution-model-and-durability.mdx`, `docs/extensions.md`, + `packages/eve/src/compiler/module-map.ts` — `defineState`, namespace-on-mount, + compile-to-module-map. +- Cordis paper (*A Programming Paradigm for Spatiotemporal Composability*) — skim. + §6.1 (system boundary), §6.5 (granularity), §6.6 (dependency typing) are the + honest parts. The metatheory is not load-bearing for us. + +--- + +## 2. What the others do + +### DeepSeek harness (Cordis) + +Everything is a plugin, literally: ~200 npm packages composed by layered YAML. +`bash`, `fs`, the system prompt, session persistence, the agent loop are config +rows. The UI is ~35 more plugins with an open typed slot system where declaring a +slot claims render authority over it. + +Effects revert by running stored inverses (`ctx.effect(setup)` returns a disposer). +Dependencies declared (`inject`/`provide`), resolution reactive. + +Module loading reaches into Node internals: +`require("internal/modules/esm/loader").getOrInitializeCascadedLoader()`, walks +`job.linked`, deletes from `loadCache`. Version-branched for Node 22 (`v1`) vs 24 +(`v2`); needs `--expose-internals` or a native addon. + +**Observed:** 218 of 342 `inject` declarations are one cross-cutting service; the +real graph is ~60-80 edges, nearly all core-to-core. Component props are a +four-way type intersection. **HMR is disabled for the web profile.** + +**Verdict:** composition-as-config is genuinely good. 200 packages, cross-package +typing, and an inverse-correctness obligation per author are not. + +### bb + +One backend tier that always runs. A plugin is a package with `server` and +optional `app` entries, per-plugin SQLite, KV namespace, RPC namespace, auth +token. **No inter-plugin dependencies at all.** + +Registrations return `void` because they are **host-owned and load-scoped** — the +host drops the whole per-plugin bucket on reload. `onDispose` covers only what +escapes that bookkeeping. + +Reload is atomic: run the factory against a *candidate* set; on failure the +previous set stays live entirely; on success abort background services with a +bounded await, dispose LIFO, drain in-flight handlers, close DB handles, +invalidate the old handle (stale use throws), swap wholesale. + +Slots are a fixed host enum, additive by default. One exclusive slot, arbitrated +by a user setting with a built-in fallback. Content scripts get an `AbortSignal`. + +**Verdict:** the most practically useful of the four. + +### opencode + +*Internal and external plugins use the same public API.* Core provider adapters +and config domains are written as plugins. Two verbs: + +- `transform` — replayable contribution to a domain +- `hook` — intercept a live operation + +Plus `rebuild()`. A domain is rebuilt by replaying every active transform over a +fresh draft. Removing a plugin = drop its transforms, rebuild. + +Rebuilds fire on registration, disposal, explicit `rebuild()`, and once after bulk +boot. Serialized and coalesced; each snapshots its list at start. Reentrancy +banned. Same-ID replacement retains order position and disables the old first. + +`PluginContext` is **closed** — `agent`, `aisdk`, `catalog`, `command`, +`integration`, `plugin`, `reference`, `skill`, `options`. No `provide`/`use`, no +service registry, no Effect Layers exposed. The domain list is the entire surface. + +Runs on Bun, so `.ts` imports natively. "Whole-Location generation reload" is a +**Deferred Decision** — they do not reload modules; they replay transforms. + +**Verdict:** this is the model. + +### eve + +Filesystem-first, no registration API: `agent/tools/*.ts`, `agent/hooks/*.ts`, +`agent/channels/`, `agent/schedules/`. Slug = path basename. + +Durability delegated to Workflow SDK; `"use step"` marks a durable step. Pluggable +"worlds" (local disk, Vercel, postgres). session -> turn -> step. + +Loading is **compiled**: `packages/eve/src/compiler/module-map.ts` emits a +generated file of static imports keyed by node id. No runtime module loading. + +`defineState(name, initial)` — a typed named slot of durable per-session memory, +auto-namespaced per extension, `get()`/`update()`. + +Namespace on **mount**, not declaration: author writes `tools/search.ts`, the +consumer's mount filename makes it `crm__search`. + +Compatibility metadata generated at build, checked at consumption — not npm peer +ranges. + +**Verdict:** typed bound addresses and namespace-on-mount are worth taking. They +map to the ordinary `value()` / `list()` storage model rather than a separate +plugin-state API. The workflow dependency is not. Neither is the +filesystem-as-authoring-interface: pi has always been registration-based, that is +the idiom extension authors know, and a directory convention would break every +existing extension for no gain. + +--- + +## 3. Steal / adapt / reject + +### Steal + +| From | What | Why | +|---|---|---| +| opencode | replay contributions to rebuild a registry | Correct removal by construction. No inverses, no commutativity. | +| opencode | strict contribute/intercept split | Config-shaped state and per-operation behavior are different problems. | +| opencode | same API for built-in and third-party | Makes the dogfooding rule mechanical. | +| opencode | closed context, no service resolver | Everything shared is a registry. | +| opencode | rebuild snapshots its list; reentrancy banned | Replay stays a fold, not a fixed point. | +| bb | host-owned registration buckets | Registrations need no disposers. | +| bb | candidate-then-swap reload with rollback | Failed reload leaves the previous set live, entirely. | +| bb | stale-handle error | Loud failure beats mysterious no-ops. | +| bb | fixed slot enum, additive default, user-arbitrated exclusives | Avoids competition instead of resolving it. | +| bb | `AbortSignal` into every factory | Covers timers/sockets/watchers/fetch with no new concepts. | +| eve | typed durable addresses | Fixes the string-keyed `kv` naming problem without adding a second state API. | +| eve | namespace on mount | Gives each plugin a stable collision-resistant prefix. | +| eve | compatibility metadata checked at load | Better than a manifest version field alone. | +| DeepSeek | composition as declarative config | Profiles become data; an app is a manifest. | + +### Adapt + +- **"Everything is a plugin"** -> yes, but internal modules from a registry table, + not 200 published packages. Sidesteps the independent-build typing problem. +- **bb's single backend tier** -> split into supervisor + session host. Different + lifetimes, different plugin tiers. +- **opencode's domains** -> ours are `tools`, `commands`, `providers`, `resources`, + `settings`, and the client-side ones. + +### Reject + +| What | Why | +|---|---| +| `inject`/`provide` dependency resolution | Evidence says plugins rarely depend on each other; core-to-core edges are ours to wire. | +| Cordis inverse-tracking as the main mechanism | Unchecked obligation on the author. Replay is correct by construction. `effect` stays for the residue. | +| Independence / commutativity discipline | We do not need arbitrary revert order. | +| Open slot registries (DeepSeek `SlotMap`) | Cross-plugin declaration merging with no shared build. | +| Component-splitting to break cycles | Quadratic integration packages. Detect and error. | +| Confluence guarantees | Theory, not a product requirement. | +| Async contributions | opencode allows arbitrary I/O in transforms — their worst decision. Makes replay expensive and non-deterministic. | +| ESM-internals cache eviction | Works, zero-leak, two undocumented APIs plus a version branch and a native-addon fallback. | +| Compiled module map (eve) | Needs a build step. pi is a CLI. | +| Filesystem conventions as the authoring interface (eve) | pi has always been registration-based. Same file, same mental model, and existing extensions keep working. | +| Workers | Providers carry live methods on the streaming hot path. Set aside. | + +--- + +## 4. Vocabulary + +| ours | theirs | why | +|---|---|---| +| **registry** | domain | Concrete; pi already says `ModelRegistry`. | +| **contribution** | transform | A noun for a thing you add. Most contributions do not modify anything. | +| **draft** | editor | A draft is a mutable work-in-progress. | +| **build** | core finalization | One word; it is what happens. | +| **rebuild** | rebuild | Keep. | +| **commit** | commit | Keep. | +| **hook** | hook | Universal. | +| **view** | — | The one session-to-client primitive. | + +--- + +## 5. Tiers + +``` +supervisor long-lived daemon. socket, stable ID, spawn, replacement. + +- session host one process per session. Session + AgentHarness. cwd-bound. + ^v sync + client TUI or web. one per attachment. +``` + +Not client/server — **where the session lives** vs **where the pixels are**. In +`pi -p`, rpc, and sdk mode the supervisor is absent and the bottom two collapse +into one process. Plugin code is identical. + +Call the supervisor the **server**; do not call the session tier that. + +```ts +export const manifest = { id: "todo", api: "1.0" }; + +export function server(pi: ServerApi) {} // supervisor, once, global-only +export function session(pi: SessionApi) {} // per session process +export function tui(ui: TuiApi) {} // per TUI attachment +export function web(ui: WebApi) {} // per browser attachment +``` + +Module loaded once per process; factories run per instance. + +### Load scopes + +| Tier | Global | Project (cwd) | Why | +|---|---|---|---| +| server | yes | **no** | One process, no cwd, outlives everything. Project code in the daemon has no containment. | +| session | yes | yes | Process-per-session bound to one cwd *is* the containment. | +| client | yes | yes | Resolved against the **session's** cwd, not the client's. | + +Project code reaches the supervisor by **declaring data** a globally-installed +plugin interprets — the `.github/workflows` pattern. The repo declares; the runner +you trust executes. + +### What each tier is for + +**Server** — anything that must work with no session running: automations and +schedules, session-list decoration, auth and connection policy, cross-session +state, HTTP routes, discovery/relay, retention policy. + +**Session** — tools, hooks, session mutation, agent behavior, plugin UI. + +**Client** — components, renderers, slot contributions, keybindings. Never touches +the filesystem; its own cwd is meaningless. + +--- + +## 6. The four verbs + +```ts +interface SessionApi { + readonly id: string; + readonly config: JsonValue; // resolved settings, typed + + // 1. contribute — replayable, SYNCHRONOUS + tools: Registry; + commands: Registry; + providers: Registry; + resources: Registry; + settings: Registry; + + // 2. intercept — harness hooks, verbatim + hook(name: K, h: HookHandler, localId?: string): void; + + // 3. observe — harness events, passive + on(type: T, l: Listener): void; + + // 4. effect — the non-replayable residue, and where I/O lives + effect(setup: (signal: AbortSignal) => void | Promise): void; + + // core capabilities — always present, no resolution + fs: Fs; + credentials: Credentials; + session: SessionOps; // includes bound value/list operations + sessions: SessionsApi; + diagnostics: Diagnostics; + view(component: string, state: unknown, opts?: ViewOptions): View; +} +``` + +Registration is the existing pi idiom and stays. The only change is that a +registration lands in a replayable list instead of a mutable table: + +```ts +// old +pi.registerTool({ name: "bash", ... }); + +// new +pi.tools.add((d) => d.set("bash", { ... })); +``` + +Same file, same shape, same mental model — and that is what buys removal and +reload. + +### Registry shape + +```ts +interface Registry { + add(fn: Contribution): () => void; // owner inferred from the plugin + rebuild(): Rebuilt; + current(): S; +} + +type Contribution = (draft: D) => void; // NOT Promise + +interface Rebuilt { + state: S; + added: string[]; removed: string[]; changed: string[]; + errors: Array<{ owner: string; message: string }>; +} +``` + +```ts +function rebuild(): Rebuilt { + const contributions = [...this.contributions]; // snapshot before replay + const draft = this.newDraft(); + const errors = []; + + for (const { owner, fn } of contributions) { + try { fn(draft); } + catch (e) { errors.push({ owner, message: msg(e) }); } // skip, do not abort + } + + const next = this.build(draft); // host projection step + const diff = diffState(this.state, next); + this.state = next; + return { state: next, ...diff, errors }; +} +``` + +`build` is the host's finalization: `composeModelProvider` for providers, schema +validation for tools. Not a contribution, not reorderable, not droppable. + +### Improvements on opencode + +1. **Contributions are synchronous.** No I/O, no `await`. Their models.dev + *example* becomes our *rule*. +2. **A throwing contribution is skipped, not fatal.** Recorded against its owner, + replay continues. One bad plugin cannot empty the tool registry. +3. **Rebuild emits a diff.** Feeds the sync layer directly. +4. **Order is manifest order**, not registration order — deterministic, + user-visible, reorderable by editing a file. +5. **One draft shape across registries** — `set`/`update`/`delete`/`has`/`ids`, + with per-registry sugar on top. Learn it once. + +### Draft base + +```ts +interface Draft { + set(id: string, value: T): void; + update(id: string, fn: (v: T) => void): void; // warns if absent + delete(id: string): void; + has(id: string): boolean; + ids(): readonly string[]; +} +``` + +`update` must hand the callback a **fresh clone** per replay, or in-place wrapper +mutations accumulate across rebuilds. + +### Wrapping and replacing + +```ts +// replace — last contribution wins, manifest order decides +pi.tools.add((d) => d.set("bash", myBash)); + +// wrap — capture inside the contribution so it is rebuilt each replay +pi.tools.add((d) => { + d.wrap("bash", (inner) => async (args, ctx) => { + if (!(await approve(args))) return blocked(); + return inner(args, ctx); + }); +}); +``` + +Two wrappers compose; nesting order is manifest order. Removing the middle one and +rebuilding gives exactly what you would have had without it — no unwrapping. + +| | wrap | hook | +|---|---|---| +| changes schema/description the model sees | yes | no | +| targets one named tool | yes | either | +| applies to every tool | awkward | yes | +| must run when the tool does not exist | no | yes | +| runs at rebuild time | yes | no | + +`d.update` on a missing id **warns**, so a wrapper degrades to a no-op rather than +breaking the registry. Diagnostics record the wrap stack: +`bash: core-tools -> metrics -> permission`. + +### Where I/O runs + +``` +I/O -> plugin-scope variable -> rebuild() -> contribution reads the variable +``` + +Three places: `pi.effect(async signal => ...)` at activation, later triggers +(commands, timers, events), and `execute` at invocation. Never a contribution. + +### Cross-registry reads + +Allowed, untracked, and usually unnecessary: + +- Read at **execute/render time** — always fine, no coupling. Prefer this. +- Read at **build time** — you own the invalidation: + `pi.providers.onRebuild(() => pi.tools.rebuild())`. One line, same file. +- **Automatic tracking — no.** Rebuild storms (availability refreshes are + network-backed and frequent) and cycles you would then have to detect. + +Only things the *model* must see up front — tool schemas and descriptions — need +build-time embedding. That is a small set. + +--- + +## 7. Views — the one session-to-client primitive + +`broadcast`, `open`, `show`, and `channel` were four APIs answering the same four +questions with different defaults hardcoded: + +1. Who sees it? +2. What is the current state? (the late-join problem) +3. Can clients talk back? +4. When does it end, and with what? + +```ts +const v = pi.view("todo-panel", { items }); + +v.state = { items: next }; // synced to its clients +v.on((msg, from) => { ... }); // clients send back +await v.done; // resolves when closed, with a value +v.close(result); +``` + +```ts +pi.view(component, initial, { + to: "originator" | "all" | clientId[], // default "all" + durable: boolean, // default false +}) +``` + +| old name | is a view with... | +|---|---| +| `broadcast` | state, all clients, no messages, never closes | +| `open` / `ask` | state, originator, one message, closes with result | +| `show` | state, all clients, maybe messages, closes explicitly | +| `channel` | messages only, state unused | +| multiplayer game | state + messages, all clients, closes when it ends | + +Sugar: `const ask = (c, d) => pi.view(c, d, { to: "originator", durable: true }).done;` + +### Late join is the constraint + +- A view component **must be reconstructible from `state` alone.** If it can only + render correctly having seen the deltas, late join is broken. This is the rule + plugin authors need told. +- `durable` means *survives process restart*, not *has state*. + +| | in memory | in snapshot | survives restart | +|---|---|---|---| +| `durable: false` | yes | yes | no | +| `durable: true` | yes | yes | yes | + +- High-frequency traffic (60fps game state) goes over messages; the *board* goes in + view state, so a late joiner sees the game. The plugin decides the split, + explicitly. + +### A view is a mini-session + +Snapshot (`state`), deltas (changes), commands up (`on`). Reuses the sync +machinery. `SessionSnapshot` gets **one** section and the protocol **three** delta +types, permanently: + +```ts +views: Array<{ viewId: string; component: string; state: unknown; to: string[] | "all" }> +``` +``` +view_state | view_message | view_closed +``` + +No plugin ever adds an event type. + +### Durable views are steps + +An effect whose settlement arrives from a client instead of a provider. +`harness.md` supplies the machinery: `replay: "safe"`, `effect_pending`, +deterministic ids off `${runId}:${toolCallId}`. On resume the view reopens with +the same id and the parked tool keeps waiting. + +### Reconnect vs detach + +- Reattach -> fresh snapshot, all views. Same path as late join. +- `durable: false` + last addressee leaves -> close with `no_clients`. +- `durable: true` + last addressee leaves -> stay open; the tool is still parked. + +--- + +## 8. Core capabilities vs plugin services + +**Core capabilities** are properties of `pi`. Statically typed, always present, no +resolution, no ordering, no "what if two plugins provide it." Swapped by whoever +constructs the harness, not by a plugin racing to `provide()`. + +`fs`, `credentials`, `session`, `sessions`, `diagnostics`, `view`. + +**Plugin services** are for the genuinely singleton, stateful, method-bearing +things one plugin owns and another calls. Typed token, exported by the provider, +imported by the consumer — because there is **no shared build**, so declaration +merging is unavailable. + +```ts +// @pi/lsp/token.ts — contract only +export interface Lsp { definition(f: string, l: number, c: number): Promise; } +export const lsp = defineService("@pi/lsp:lsp"); + +// provider +pi.provide(lsp, impl); + +// consumer — the import IS the dependency +import { lsp } from "@pi/lsp/token"; +const l = pi.use(lsp); // throws naming both plugins if absent +const t = pi.tryUse(telemetry); +pi.onService(telemetry, (t) => { ... }); +``` + +Rules: **one provider per token** (two is a collision, error at mount), many +consumers, many tokens per plugin. Ordering by manifest position, with a good +error rather than a lazy proxy. + +Before adding a token, ask: *does more than one plugin contribute to it?* If yes +it is a registry, not a service. Most things that feel like services are registries +with a query on top — which is why the count keeps coming out at two or three. + +**Start with zero services.** Put `fs` and `credentials` in core as constructor +arguments, the way `ModelRuntime` already takes a `CredentialStore`. Add the token +mechanism (~40 lines) only if someone actually needs to swap one from a plugin. + +--- + +## 9. Storage + +| | durable | synced | shape | naming | +|---|---|---|---|---| +| entries | yes | yes | append-only, tree-placed | settled | +| values | yes | yes | latest-wins at one bound typed address | settled | +| lists | yes | **no** (publish views) | append-only elements at one bound typed address | settled | +| tables | yes | **no** (publish views) | rows, queryable | **TBD** | +| settings | yes | yes | schema'd, layered | settled | +| presence | no | yes | per-attachment | TBD | +| view state | see 7 | yes | per-view | TBD | + +- **`entryProjectors` returning `undefined`** makes an entry durable, ordered, + tree-placed, and synced but **invisible to the model**. That is the mechanism + for chat logs, audit trails, plugin history. Return messages instead and the + agent sees it — one line's difference. +- **Values and lists are ordinary bound storage.** Plugins construct + `value(namespace, key?)` and `list(namespace, key?)` addresses with a + stable collision-resistant namespace. Namespace `pi` and `pi.*` are reserved + for built-ins by contract. There is no plugin-specific state API, global type + map, registry, or catalog. +- **Views publish client-facing state.** Do not turn values, lists, or the sync + layer into a plugin event channel; project durable data into a view instead. +- **Tables are not auto-synced.** Publish a *view* of a table. The sync layer must + not become a database replicator. +- **Settings** declared with a schema so clients render a settings UI without the + plugin shipping one; layered defaults -> user -> project -> session; a change + triggers a **rebuild**, not a reload. +- **Presence** is per-attachment, dies with the connection, must *not* survive + restart. + +Naming: plugins export ordinary typed address constants or constructors and use +those bound addresses for every later operation. The mount supplies or derives a +stable plugin namespace prefix; the storage API does not auto-register addresses +or add a `defineState` wrapper. + +--- + +## 10. Slots + +Fixed host-declared enum. Ids scoped to `(pluginId, localId)`, so collisions are +impossible. Additive by default, contained per-slot (a throwing footer widget +disappears; the footer survives). Genuinely exclusive surfaces get a user-visible +picker with a host fallback. + +```ts +type SlotName = "footer" | "header" | "editor.above" | "editor.below" + | "status" | "overlay" | "editor" /* exclusive */; +``` + +--- + +## 11. Module loading and reload + +Verified on Node 22.22: **`await import("./ext.ts")` works with no flag, no jiti, +no build step.** `module.stripTypeScriptTypes` and `module.registerHooks` are both +available. + +Three tiers by reload frequency: + +| Case | Mechanism | Leak | +|---|---|---| +| Normal run — load once | `await import()`, native `.ts` | none | +| Dev reload — human editing | `registerHooks()` resolve hook appending `?gen=N` to specifiers under an extension root | bytecode per generation, bounded by patience | +| Agent-authored, long-lived daemon | worker per generation, or accept it | ~9.5MB/worker, full reclamation | + +Measured: ~9.5MB RSS per worker; terminate reclaims most. Worker-per-extension is +dead; worker-per-generation is the only viable shape — and it is blocked on +providers carrying live methods on the streaming hot path. + +**The reframing that matters:** opencode barely reloads modules at all. Settings +changes, config changes, and plugin toggles all go through `rebuild()` with the +module still cached. Only *source bytes changed* needs a second `import()`. Make +the common cases rebuilds and the leak becomes a few hundred KB a day. + +### The single-file multi-tier hazard + +Shared mutable module state between `session()` and `tui()` works in local mode and +breaks in remote mode. Two mitigations: + +1. **Serializing in-process transport** in local mode — round-trip through the wire + codec so unserializable payloads (class instances, `Map`, `Date`, functions) + fail immediately. Keep this unconditionally. +2. **Static check at load** — if the module graph exports 2+ tier factories and + contains top-level mutable bindings, refuse with a diagnostic naming the file + and line. Zero runtime cost. Single-tier plugins exempt entirely. + +Module-splitting per tier (`?runtime=session`) catches only the shallow case (child +imports are shared) and costs double evaluation at startup. Opt-in +`--strict-plugins` at most. + +--- + +## 12. An app is a manifest + +```jsonc +{ + "id": "coding-agent", + "plugins": [ + "@pi/fs-local", + "@pi/auth", + "@pi/providers-builtin", + "@pi/providers-catalog", + "@pi/providers-models-json", // last: user config wins + "@pi/system-prompt", + "@pi/skills", + "@pi/tool-bash", + "@pi/tool-read", + "@pi/tool-edit", + "@pi/tool-glob", + "@pi/tool-grep", + "@pi/tool-todo", + "@pi/tool-task", + { "id": "@pi/permissions", "config": { "mode": "ask" } }, + "@pi/commands-core", + "@pi/tui-footer", + "@pi/tui-header", + "@pi/tui-themes", + "@pi/tui-tool-renderers", + "./my-extension" + ] +} +``` + +`@pi/*` resolves through a built-in registry — internal modules, not published +packages. Third-party by npm or path. Same list, same API. + +**Order is precedence**, visible and reorderable. A coding agent, a personal +assistant, and a Cowork-alike are three manifests over one core. + +~24 plugins, 3 core capabilities. Compare DeepSeek's 200 packages and DI graph. + +--- + +## 13. Worked example: the provider registry as plugins + +Today's `ModelRuntime.rebuildProviders()` is already the pattern hand-rolled: +clear, replay every source, commit a snapshot. `recomposeProvider` is already +layered (`native ?? builtin` + models.json + extension). Failure isolation already +falls back to base. `validateExtensionProvider` already throws before mutating. + +Two gaps: `extensionProviders` is `Map` — **one extension +per provider**, hand-merged per field — and the layers are fixed slots rather than +an ordered list. + +```ts +interface ProviderEntry { + native?: Provider; // opaque live Provider; replaces base + layers: ProviderConfigInput[]; // ordered, merged at build + overrides?: ModelOverrides; // always last, regardless of position +} + +interface ProviderDraft extends Draft { + native(id: string, p: Provider): void; + layer(id: string, c: ProviderConfigInput): void; + override(id: string, o: ModelOverrides): void; + models(id: string, fn: (m: ModelSpec[]) => ModelSpec[]): void; +} +``` + +**`@pi/providers-builtin`** — pure data, static import: + +```ts +pi.providers.add((d) => { + for (const p of catalog.builtinProviders()) d.native(p.id, p); +}); +``` + +**`@pi/providers-catalog`** — the I/O template: + +```ts +let remote = new Map(); + +pi.providers.add((d) => { // sync, reads the variable + for (const [id, models] of remote) if (d.has(id)) d.models(id, () => models); +}); + +pi.effect(async (signal) => { // the I/O + remote = await loadCachedCatalog(pi.config.catalogPath); + pi.providers.rebuild(); // cache first — CLI usable in ~0ms + if (!pi.config.offline) { + remote = await fetchCatalog(pi.config.catalogBaseUrl, { signal }); + pi.providers.rebuild(); // network second + } +}); +``` + +**`@pi/providers-models-json`** — user config, watched, hot-applies (today needs a +restart): + +```ts +let config = ModelConfig.empty(); + +pi.providers.add((d) => { + for (const [id, p] of config.providers()) { + d.layer(id, p); + if (p.modelOverrides) d.override(id, p.modelOverrides); + } +}); + +pi.effect(async (signal) => { + config = await ModelConfig.load(pi.config.modelsPath); + pi.providers.rebuild(); + for await (const _ of watch(pi.config.modelsPath, { signal })) { + try { config = await ModelConfig.load(pi.config.modelsPath); pi.providers.rebuild(); } + catch (e) { pi.diagnostics.warn(`models.json: ${msg(e)}`); } // keep last good + } +}); +``` + +**`@pi/auth`** — credentials are *not* a contribution. They do not change which +providers exist, only whether they are usable: + +```ts +pi.effect(async (signal) => { + await store.load(); + for await (const _ of watch(pi.config.authPath, { signal })) { + await store.load(); + pi.providers.probe(); // re-probe availability, NOT rebuild + } +}); +``` + +**Availability is derived state, not a registry.** Async, network-touching, +sequence-coalesced — today's `runAvailabilityRefresh` unchanged: + +``` +providers.current() -> every composed Provider (build, sync) +availability.state -> which are configured + auth type (probe, async) +models.available -> derived: current() intersect configured +``` + +**Build** is the host's finalization — `composeModelProvider` keeps its job; its +signature loses `ModelConfig` (`(id, native, mergedLayers, overrides)`), which is +what lets models.json become a plugin at all. + +Two decisions: `override()` stays a distinct always-last field (order cannot +express "after custom-model upserts, extension replacement, and OAuth +projection"); and rebuild must kick `availability.probe()` when it adds ids — an +explicit `onRebuild` in core rather than the fire-and-forget inside +`registerProvider`. + +Also: `extensionProviders` is already an instance field, so pi-server.md's +"process-global provider registration must be removed" prerequisite looks +**already satisfied**. Worth confirming. + +--- + +## 14. What harness.md gives for free + +- **`missing_identities`** on restore names any tool whose plugin is not loaded, + rather than silently dropping it. Better than anything the other four have. +- **Settings snapshotted at operation acceptance** — a rebuild mid-run cannot + corrupt an in-flight generation. +- **Hook `resumeData` keyed by stable id** — durable per-run plugin state for + free. Key it on `manifest.id`, which makes the id immutable across versions. +- **Events passive by construction** — an observer cannot accidentally intercept. +- **`before_tool` fails closed** — wrap plugin handlers so a throw degrades to + skip rather than blocking tool execution. + +--- + +## 15. Open questions + +**Naming.** `table`, `kv`, `view` are placeholders. `view` is least-bad among +`surface`, `panel`, `handle`. Registry naming plural-vs-singular unsettled. + +**Can `ask` address all clients?** Approve-from-any-device is real. If yes, pending +asks are snapshot state too and the ambient/blocking separation blurs. Lean: +allow `to: "all", settle: "first"` and accept it. + +**Where do plugin custom entries land in the tree?** They need a parent, and a chat +message mid-run attaches to a leaf about to move. Two concurrent sends need a total +order. Possibly ambient plugin entries should not be tree-placed at all. + +**Per-session vs per-connection registration.** `tui()` runs per attachment; two +clients on one session both register footer widgets. Probably: session rows +per-session, client rows and slot arbitration per-connection. + +**Identity.** The chatroom case forces a minimal participant model. Durable entries +store a *snapshot* of the participant, not a reference. + +**Cross-tier version skew.** `api: "1.0"` is per-extension, but a remote TUI client +and a session host may be different pi versions. Which governs? Consider eve's +build-time compatibility metadata. + +**Permissions.** A plugin declaring what it needs (fs, network, spawn) so a manifest +is reviewable at install. bb does capability requests. Reserve the field. + +**Diagnostics.** Every tier needs "what loaded, what failed, why." +`resources_changed` is this for the session tier; the others need one. + +**Startup ordering.** `AgentHarness.create()` returns before effects start. What +happens in that window? Do plugins run before the first prompt is accepted? + +**Cancellation.** Every entry point needs an `AbortSignal` — hooks, commands, view +message handlers, not just tool execute. + +**Legacy extensions.** Unresolved. Local mode only, at best. + +**Supervisor plugin tier is unbuilt.** Deliberately — the vertical slice excludes +session creation, deletion, and forking. Do not build it until a real automations or +session-list case demands it. + +--- + +## 16. Still feels unresolved + +The four verbs plus views plus storage is close but not yet *one thing*. + +Concrete suspicion: **bound values, view state, and settings are three flavors +of one idea** — durable-ish, synced, snapshot-included, delta-emitting, +plugin-namespaced. They differ only in who writes and whether a component is +attached: + +| | writer | rendered | layered | +|---|---|---|---| +| settings | user | by a settings UI | yes | +| bound values | plugin | no | no | +| view state | plugin | by a named component | no | + +This does **not** imply another storage primitive: durable plugin data remains +ordinary bound values/lists. The open question is whether their client-facing +projections and settings can share one sync abstraction, leaving **synced +projections, ephemeral messages, durable rows, four verbs.** It might still be a +false economy that makes settings worse. Attack during the naming pass.