# Plugin loading Status: **design, not implemented** (revision 4), except for the built-in parts of 4.1 and 4.2 (listing, runtime resolution), which are. Revision 4 aligns the document with `target-design.md`: a plugin reload swaps code on each host without waiting for running work (documents migrate, resources are replaced in place, section 15 there), and the cluster is a rollout of revisions, not a transaction. Loader model D (section 14) stays: one base bundle plus one script per facet, evaluated without a module map, so old generations are reclaimed by the GC. p2 is experimental; nothing here is a stable public API. **Chord v2 only.** This document predates p2's move to Chord v2; Chord v1 has since been deleted. Where it says "today" about `nodeLoader` and per-surface loading, it describes p2 on Chord v1. p2 now builds and loads its server and worker facets with Chord's plugins component (`createPlugins`, `src/hosts/plugins.ts`; Chord's `docs/plugin-authoring.md`), plugins import p2's API (`@p2/app/v1` and its per-kind modules) instead of Chord directly, and the web and TUI bundles load their facets with `host.load`. Global, package and project plugins (sections 3 to 13) remain a design. Today p2 loads one plugin root, `apps/p2/plugins/`, differently per surface: server and worker facets through Chord's `nodeLoader` (dynamic `import()`, a new module copy per reload that is never evicted), TUI facets through a server-built esbuild bundle, web facets from a hard-coded list in `src/hosts/web/main.tsx` served by Vite. An uncommitted prototype (`src/hosts/worker-bundle.ts`) measures worker bundle variants (14.1). This document designs one mechanism for built-in plugins, the owner's own plugins, plugins installed from npm or git, and plugins that come with a session's working directory, on every surface. ## 1. Goals and non-goals Goals: - **One pipeline** for built-in, global, package and project plugins; built-ins are not special. - **Every surface from the same plugin set:** server, session worker, web, TUI. - **Global packages from npm and git**, like pi's packages (`packages/coding-agent/docs/packages.md`). - **Project plugins** from a session's working directory, local or remote, without leaking into other sessions. - **Nothing from a project runs before the owner approves it, and only the approved bytes run.** - **No leaks across scope changes:** a web page, TUI worker or session worker that loaded a plugin set is thrown away as a whole, never partially unloaded. - **In-place reload everywhere, without leaks:** a changed facet reloads through Chord (documents and resources stay, running calls finish with their old code) and its old generation is reclaimed by the GC once nothing references it (14). A full reload (host restart, page load, TUI worker swap) remains for base changes, scope switches and stuck generations. - **Publishable later:** p2 as an npm package loads external plugins the same way. Non-goals (v1): - Isolation between plugins in one process, or of web code from the page (that needs iframes; 11). - Project packages (npm dependencies of project plugins); project plugins import only host packages and plugins. - Project web or TUI facets for guests (base scope only; opt-in per share later). - Native addons in packages. ## 2. Terms - **Plugin:** a directory with facets (`server`, `worker`, `web`, `tui` modules) and the modules they import, and/or resource directories (`skills/`, `prompts/`, `themes/`). Its name is the directory name, `[a-z0-9-]+`. - **Facet:** the part of a plugin that runs on one surface. - **Root:** a directory whose subdirectories are plugins. - **Package:** an npm or git source, or a local directory, providing one or more plugins. - **Scope:** the plugin set one surface instance loads. **Base scope:** built-in, global and global-package plugins. **Project scope:** the base scope plus the approved project plugins of one directory. - **Scope key:** `(env id, canonical session cwd)`. **Revision:** the digest of an immutable project snapshot (6.2). - **Host packages:** packages the running p2 supplies to every plugin as single instances: p2's API (`@p2/app/v1`, re-exporting the part of Chord v2 plugins use, with per-kind modules), `@earendil-works/pi-ai`, `pi-codemode`, `pi-durable`, `pi-env`, `pi-tui`, `pi-schema` (all `@earendil-works/`), `react`, `react-dom` (with subpaths such as `react/jsx-runtime`, `react-dom/client`), and p2 itself (`@earendil-works/p2/runtime/*`, `@earendil-works/p2/plugins/*`). - **Data directory:** `P2_DATA_DIR` (default `~/.pi/p2`), written `` below. ## 3. Sources | Source | Where | Declared by | Facets | Gate | | --- | --- | --- | --- | --- | | Built-in | the app's `plugins/` | the app | all | none | | Global | `/plugins//` | files present | all | the owner put them there | | Global package | `/packages/…` (8) | `packages` in `/settings.json` | all | the owner installed it | | Project | `/.pi/p2/plugins//` | files present | worker, web, tui | project trust (6) | - **Server facets never come from a project.** The server serves every session. A project plugin with a `server` module is refused as a whole (owner notice); loading its other facets would leave it half-working. - **Disabling:** `plugins.disabled` in `/settings.json` lists names not to load, built-ins included. A plugin that holds a disabled plugin's contracts fails at activation with a notice naming it and the type (4.2). `/.pi/p2/settings.json` (part of the project snapshot) may only disable **project** plugins; any other key is ignored with a notice. - **Project skills and prompts:** `/.pi/skills` and `/.pi/prompts`, which p2 loads today without asking (`plugins/prompt/context-files.ts`), move under the same directory trust decision (pi parity). **Behavior change:** they no longer load in untrusted directories. `AGENTS.md` files stay ungated, as in pi. ## 4. Discovery, dependencies, names ### 4.1 Discovery: a directory listing (implemented for built-ins) Discovery lists directories; it never parses, imports or runs plugin code (owner decision: p2 does not parse plugin source at all). - A plugin is a directory under a root with facet files: `server`, `worker`, `web`, `tui` with `.ts`/`.tsx` (and, for packages, `.js`/`.mjs`). `readdir` plus an existence check per facet kind is all the loader knows (`listPlugins`, `src/hosts/plugins.ts`). Its name is the directory name; a facet module's plugin is the directory it lives in (`pluginOfModule`). - **Selection** is exactly the named plugins (default: all), loaded in stable alphabetical order. Nothing is added for imports: a fixture or `plugins.disabled` that leaves out a provider makes its consumers fail loudly at activation (4.2). - **The server selects; workers scan nothing.** The server passes the selected plugins (name and worker facet module) in each worker's init. The TUI bundle ships the TUI facet of every selected plugin. - **Type ownership is recorded at runtime,** per host, from the `serve` calls of the facets that host actually loaded (`PluginRuntime`). No process imports another kind's facets: a worker never imports server facets just to learn names. Diagnostics name the consumer plugin and the missing type (`plugin login needs p2.model-config; no loaded plugin serves p2.model-config on server`), not an unloaded provider, which nobody parsed. - **Dev mode:** a save belongs to the plugin whose directory contains it. It marks that plugin dirty when it is a backend facet file, a plugin set change (directory or backend facet added/removed), or a file some live server, worker or TUI bundle actually loaded (the facet bundles' esbuild metafiles under D, 14.2); other saves (web-only code) stay on Vite HMR until the web moves to D. Files outside plugin directories are runtime code, tracked by the content of the modules each process loaded ("Server code changed"). - **Dev loop:** a change to runtime code (the base bundle: Chord, Durable, other packages, `apps/p2/src/**`) restarts the affected hosts (server, workers, page reload, TUI worker swap); a plugin change reloads that plugin in place on every host that runs one of its facets (`npm run reload -- `, rollout in 7.5). ### 4.2 Dependencies: runtime resolution A plugin refers to another plugin's modules in one of two ways: - **Relative imports** within its own root (`../chat/contract.ts`), as built-ins do. - **`p2:plugins//`** across roots: a virtual specifier resolved **per scope** to wherever plugin `` lives in that scope (built-in, global, package or snapshot), by the facet bundler's resolver (14.2). `@earendil-works/p2/plugins//…` stays the name for built-in contracts (it resolves to the host's files). Under D each facet is its own bundle, so another plugin's file is **copied into the importing facet** (14.4, B2). Cross-plugin imports are therefore limited to contracts and side-effect-free modules (`contract.ts`, `protocol.ts`, an allowlist), and never used for identity (`instanceof`, module state, React contexts); a check enforces it (14.5). Imports are not dependencies. What a plugin needs is resolved by Chord at runtime, by contract name: - **Load order does not matter.** `host.load` installs every facet of a load before it activates any eager object (Chord guide, "Startup readiness"), so an eager consumer may hold a type a later facet serves. Workers load with `{ awaitEager: false }` and then await `host.ready()` once the server admitted their link (Chord request #11). (Today facet modules of one load share one copy of each other's files because Chord's `nodeLoader` registers every facet directory before importing; D drops that, see above.) - **A missing provider fails at activation,** with Chord's `unavailable/unknown_type`, which p2 extends by the consumer plugin's name before the hold activates (local types) or when the remote error comes back. - **Removal safety is a runtime check.** A reload that removes a plugin first asks the server and every running worker which plugins outside the batch still hold one of the removed plugin's types (live factory holds, recorded per host). Any holder refuses the reload before any host prepares, naming them (`Cannot remove p2 plugin dev-added: plugin dev-dependent still holds test.dev-added (server)`). While the reload prepares, a new hold on those types, including the new code of a plugin reloaded in the same batch, refuses it as well. Project plugins may depend on base plugins and on project plugins of the same snapshot; base plugins never depend on project plugins. Anything a project plugin imports must be its own file, a scope plugin or a host package; any other specifier is a load error for that plugin. ### 4.3 Names Chord binds services and registries **by name**: two contracts named `todo.store` in one host throw `already served`; two Durable extensions defining one task name throw at registry construction (`durable/src/harness/registry.ts`), and extensions are looked up by name. Built-ins use `p2.` names. Rules for external plugins, checked at load (a violation refuses that plugin with a notice): | Name | Required form | | --- | --- | | Chord contracts, registries | `.` | | Durable extensions, task kinds | `.` | | Agent tools | `_` (provider APIs allow only `[a-zA-Z0-9_-]{1,64}`; see A.1) | | Transcript entry kinds | `.` | - **Name clashes are detected at load,** not by reading source: two facets serving one contract name make Chord's `serve` throw `already served` while the host installs them, and two Durable extensions or task kinds with one name throw at registry construction. The loader reports the refusing plugin by name. - **Plugin name clashes** within a scope refuse the later source (order: built-in, global, package, project); project plugins may not reuse base names. Package clashes surface at install; directory clashes at listing; a global install that shadows an existing project plugin notifies in both places. To replace a built-in, disable it and use another name. - **Each plugin loads on its own:** the loader installs plugins one by one (per-plugin wrapper, as `PluginRuntime.guard` today); an import error, `setup` throw or name violation becomes a notice naming the plugin. Plugins that hold its contracts fail at activation with the missing-provider diagnostic (4.2). A broken plugin never kills a worker or blanks a page. ### 4.4 Resources Like a pi package, a plugin may carry `skills//SKILL.md`, `prompts/*.md` and `themes/*.json` (or list them under `"p2": { "skills": […], "prompts": […], "themes": […] }` in `package.json`). They follow the facet scope rules: base resources in every scope, project plugin resources only in their approved scope. Clashes follow existing precedence (global before project) and are reported. A resource-only plugin needs no code. - Project plugin resources are read from the snapshot; a skill's referenced files are in the project directory in the session's environment, as today. - Skills of base plugins on remote sessions refer to host files: see 13.2, open question 1. - Themes reach the TUI picker as data through the scope's TUI bundle. ## 5. Scopes ### 5.1 Per surface | Surface | Loads | How a change applies | | --- | --- | --- | | Server | base scope (server facets) | in place, first host of the rollout (7.5) | | Session worker | its session's scope (worker facets) | in place; base changes in the rollout, project changes for that worker only (7.4) | | Web page | the scope of the session it shows; base without a session; base for guests | changed facets in place (D, 14.2); full page load for base code, a scope switch or a stuck generation | | TUI worker | the same, chosen by the launcher; base for guests | changed facets in place (D); worker swap for base code, a scope switch or a stuck generation | A page or TUI worker holds **exactly one scope**: moving between sessions of different scopes is a full page load or a worker swap. (During a TUI swap the candidate worker is prepared while the old one still runs; they are never both active.) Between sessions of one scope, navigation stays in-app. A base rollout changes only base facets in a worker; project facets are installed only by their own per-worker install (7.4). If a base reload removes something a project plugin depends on, that plugin's consumers fail like any consumer and the scope is marked dirty. ### 5.2 Scope identity and selection - The session catalog stores each session's **scope key** and **revision** (approved snapshot digest, or none). The server can therefore pick a bundle for a session whose worker is not running. - **Web:** the server renders `index.html` with a fixed, nonce'd bootstrap script. It takes the session from the URL (`/s/`) or the tab's sessionStorage route stack, asks `/__p2_web/scope?session=` (authorized per principal: owners any session, guests their own and always get the base scope), and loads the returned JS and CSS. Selection is per tab, never a cookie. - **Session lists carry the scope revision**, so in-app navigation to a session of another scope becomes `location.assign` (web) or a swap request to the launcher (TUI) instead of an in-app route. - **TUI:** the manifest takes `?session=` (`/__p2_tui?session=…`, same authorization). The launcher owns the scope: it fetches the manifest for its current target session and swaps when the hash differs. It caches bundles by hash (bounded), so switching back is not another download of up to 8 MiB. - **TUI snapshots** carry drafts, pending send IDs and anchors for every visited identity (`src/tui/snapshot.ts`). The launcher keeps them **partitioned per scope key**: a swap A→B stores A's snapshot, hands B only the snapshot last saved for B (if any) with the destination session set explicitly, and never replays A's pending sends into B. A same-scope revision swap passes the snapshot through, as today. - **Offline environment:** the worker loads the catalog revision from the local snapshot if present; if not, it loads the base scope and shows "Project plugins of unavailable: environment offline". It never substitutes a local directory and never treats unreadable files as "no plugins". - **Subagents** with their own cwd use the session's scope; their directory neither confers trust nor loads plugins. ### 5.3 Push: `ClientBundle` Today nothing tells a page or TUI that its bundle is stale: `/version` is a boot ID plus file mtimes, checked only on `visibilitychange` (`src/hosts/web/main.tsx`); the TUI polls one global manifest. New: a Chord object `ClientBundle({ session? })` (readable by owners, and by guests for their session) publishes `{ scopeKey, revision, web, tui }` hashes. A page or TUI holds it for the session it shows; on a hash mismatch the web reloads (route stack in sessionStorage, drafts in the server's `DraftStore`) and the TUI swaps. Applies to rebuilds, trust changes, relocations and Reloads started on another device. ### 5.4 Where project facets may contribute Chrome (sidebar, top bar, global sheets, owner notices, footer slots outside the session) is shared by all scopes. Project web and TUI facets contribute only to session-bound slots: entry and tool renderers, markdown transformers, the session's composer, sheets and panels, session-bound TUI commands. Registries record the contributing plugin (new, through the per-plugin wrapper) and refuse project contributions to chrome slots. This is **UX consistency, not isolation**: project web code can do anything same-origin code can (11). ## 6. Project trust and snapshots ### 6.1 Trust decisions - `/trust.json` (p2's own, separate from pi's `~/.pi/agent/trust.json`) stores decisions per canonical directory `(env, path)`; the closest saved decision of the directory or a parent applies, as in pi. - `defaultProjectTrust`: `"ask"` (default) or `"never"`. There is no `"always"` (A.2). - Modes per trusted directory: - **Directory (default):** the owner's Reload (6.3) is the approval of each new revision. - **Pin to content:** each new revision needs the full trust review again (file list and diffs against the approved revision); a generic "Reload all" never includes it. - Approved revisions are stored per scope key; every session with that key uses the latest approved one. - **Guests never see trust prompts and cannot grant or apply.** Changes are never activated automatically: no watcher, worker restart, new session or agent tool loads an unapproved revision. ### 6.2 Snapshots A snapshot is an immutable, content-addressed copy of a project's plugin inputs on the p2 host: `/project-snapshots//` with `plugins/**` and `settings.json` from `/.pi/p2/`. - **Capture:** the session worker reads the files through its environment (local or remote, as it reads `AGENTS.md`), bounded (initial limits: 500 files, 8 MiB total, 1 MiB per file; regular files only, symlinks refused), and hands them to the server as data. The server lists them (4.1), writes the directory to a temporary name, makes it read-only and renames it to its digest (sha256 over sorted paths and contents). - **Approved bytes = executed bytes:** the approval names a digest; workers load worker facets from that directory and the server builds web and TUI bundles from it. Nothing reads the project directory again for execution. - **Retention:** snapshots referenced by a catalog session or a trust record are kept; others are collected after a grace period. ### 6.3 Change flow 1. The worker watches `/.pi/p2/` through its environment (`runtime/observe.ts`, including directory creation and rescans after overflow or reconnect). A change only **marks the scope dirty**; the worker captures a candidate snapshot. 2. The owner sees "Project plugins changed: Reload" with a **change summary**: files added/removed/changed, facets added/removed per plugin, plugins disabled by project settings. First-time trust shows the same summary in the trust notice (directory, plugins, facets, "runs on this device", see 11). 3. **Reload** (owner, interactive web or TUI) approves exactly the candidate's digest: the server builds its web and TUI bundles (a failed build refuses the apply with a notice; the old revision keeps running), then records the revision in `trust.json` and the catalog. 4. Each affected session's worker installs the new revision of its project facets **in place** (7.4): running calls and jobs finish with their old code, new work runs on the new one. Leaving plugins are handled first (10). 5. `ClientBundle` publishes the new hashes; pages and TUIs reload the changed facets in place (a full reload or swap for a scope switch). Revocation and relocation use steps 4–5 with the base scope or the new scope key. A live session never drops to the base scope mid-turn. Edits arriving during an apply leave the scope dirty again. ## 7. Loading ### 7.1 Facets of every surface: loader model D (revised) Revision 2 loaded server and worker facets with native `import()` plus Node resolve/load hooks and rejected backend bundles because a bundle would carry its own copy of Chord. **Superseded by loader model D (14.2):** every facet is one esbuild bundle whose host packages are **external and passed in as parameters**, so there is exactly one Chord (9.1) without a module map. Server, worker and TUI worker evaluate it with `vm.Script.runInThisContext`, the web as a classic `