docs(agent): track extension v2 design

This commit is contained in:
Mario Zechner
2026-08-17 02:27:18 +02:00
parent a829de0a7f
commit a5c013c0c6
2 changed files with 868 additions and 1 deletions
-1
View File
@@ -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
@@ -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<T>()` / `list<T>()` 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<ToolDraft, ToolState>;
commands: Registry<CommandDraft, CommandState>;
providers: Registry<ProviderDraft, ProviderState>;
resources: Registry<ResourceDraft, ResourceState>;
settings: Registry<SettingsDraft, SettingsState>;
// 2. intercept — harness hooks, verbatim
hook<K extends HookName>(name: K, h: HookHandler<K>, localId?: string): void;
// 3. observe — harness events, passive
on<T extends EventType>(type: T, l: Listener<T>): void;
// 4. effect — the non-replayable residue, and where I/O lives
effect(setup: (signal: AbortSignal) => void | Promise<void>): 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<D, S> {
add(fn: Contribution<D>): () => void; // owner inferred from the plugin
rebuild(): Rebuilt<S>;
current(): S;
}
type Contribution<D> = (draft: D) => void; // NOT Promise<void>
interface Rebuilt<S> {
state: S;
added: string[]; removed: string[]; changed: string[];
errors: Array<{ owner: string; message: string }>;
}
```
```ts
function rebuild(): Rebuilt<S> {
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<T> {
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<Loc[]>; }
export const lsp = defineService<Lsp>("@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<T>(namespace, key?)` and `list<T>(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<id, ProviderConfigInput>` — **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<ProviderEntry> {
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<string, ModelSpec[]>();
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.