mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
p2 (Pi Mobile on Chord v2) moves from pi-private into pi as one commit. Root scripts that loop all workspaces use --if-present; npm test, ./test.sh and the release script cover packages/* only (p2: cd apps/p2 && npm test); sync-versions also updates apps' workspace pins.
711 lines
54 KiB
Markdown
711 lines
54 KiB
Markdown
# 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 `<data>` below.
|
||
|
||
## 3. Sources
|
||
|
||
| Source | Where | Declared by | Facets | Gate |
|
||
| --- | --- | --- | --- | --- |
|
||
| Built-in | the app's `plugins/` | the app | all | none |
|
||
| Global | `<data>/plugins/<name>/` | files present | all | the owner put them there |
|
||
| Global package | `<data>/packages/…` (8) | `packages` in `<data>/settings.json` | all | the owner installed it |
|
||
| Project | `<cwd>/.pi/p2/plugins/<name>/` | 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 `<data>/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).
|
||
`<cwd>/.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:** `<cwd>/.pi/skills` and `<cwd>/.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 -- <plugin>`, 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/<name>/<path>`** across roots: a virtual specifier resolved **per scope** to wherever plugin `<name>`
|
||
lives in that scope (built-in, global, package or snapshot), by the facet bundler's resolver (14.2).
|
||
`@earendil-works/p2/plugins/<name>/…` 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 | `<plugin>.<name>` |
|
||
| Durable extensions, task kinds | `<plugin>.<name>` |
|
||
| Agent tools | `<plugin>_<name>` (provider APIs allow only `[a-zA-Z0-9_-]{1,64}`; see A.1) |
|
||
| Transcript entry kinds | `<plugin>.<name>` |
|
||
|
||
- **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/<name>/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/<id>`) or the tab's sessionStorage route stack, asks `/__p2_web/scope?session=<id>` (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=<id>` (`/__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 <dir> 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
|
||
|
||
- `<data>/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:
|
||
`<data>/project-snapshots/<digest>/` with `plugins/**` and `settings.json` from `<cwd>/.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 `<cwd>/.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 `<script>`. What revision 2 asked of the hooks moves into the bundler's resolver:
|
||
|
||
- **resolve:** host-package specifiers and `apps/p2/src/**` stay external (supplied by the host's base bundle,
|
||
whatever copy a plugin's own `node_modules` holds); `p2:plugins/…` maps per scope (4.2); project snapshot imports are
|
||
confined to the snapshot, the scope's plugins and host packages.
|
||
- **load:** esbuild strips TypeScript (also inside `node_modules`, and `.tsx`).
|
||
- **`import.meta.url`:** a script has none; the bundler injects each file's real path (as
|
||
`src/hosts/worker-bundle.ts` `sourceRewrites` already does), or the loader passes the facet path (14.4, B4).
|
||
- **Node builtins:** a script has no `require`; the loader passes one (14.4, B7).
|
||
|
||
Interim: until D, worker facets may load as variant C (base bundle plus native TypeScript facets through
|
||
`nodeLoader`, 14.1); server facets stay on `nodeLoader`, which leaks a module copy per reload.
|
||
|
||
### 7.2 Global plugins
|
||
|
||
`<data>/plugins/<name>/` are TypeScript, bundled per facet like built-ins (7.1). A global plugin with npm
|
||
dependencies has its own `package.json` and `node_modules` (installed by the owner with `npm install
|
||
--ignore-scripts`); those dependencies are bundled into each of its facets (one copy per facet), host packages still
|
||
come from the host.
|
||
|
||
### 7.3 Project worker facets
|
||
|
||
The worker's init list (fixed at spawn by the server) is the base worker facets plus the facet bundles the server
|
||
built from the session's approved snapshot (D, 14.2). They load in the same load, inside the start barrier
|
||
(`target-design.md` §14), so the harness resumes only after all of them are installed, and loading needs no
|
||
environment access (the snapshot is local). A remote environment still provides tools, files and watching at
|
||
runtime.
|
||
|
||
### 7.4 Project facets reload in place
|
||
|
||
A project revision is installed **for that worker only**: the server builds the snapshot's facet scripts and sends that
|
||
worker an install of its scope's revision, outside any base rollout. One broken project blocks neither base reloads
|
||
nor other sessions. The install follows the per-host reload of `target-design.md` §15 (load, publish, after); running calls and jobs finish with their old code. A restart when idle is not
|
||
used: sessions with background work (subagent reporters, code mode scripts) are never idle.
|
||
|
||
### 7.5 Rollout of a plugin reload
|
||
|
||
```
|
||
owner: npm run reload -- drafts (explicit, once the edits are done; never on save)
|
||
server: builds the facet scripts of drafts for every surface (a failed build refuses the reload)
|
||
server: installs: load → publish → after → report
|
||
server → every running worker, then every web page and TUI: "install revision R"
|
||
each host: load → publish → after → reports installed | installed_failed | rejected
|
||
a host that starts or reconnects installs the current revision before anything is routed to it
|
||
```
|
||
|
||
The per-host steps, revisions and mismatch rules are in `target-design.md` §15: a revision is (server incarnation,
|
||
sequence) naming the complete code set; a facet whose setup throws keeps its previous version and is reported as
|
||
rejected for that plugin; mismatch responses carry the provider's revision and status, so bindings wait for an install
|
||
that is coming and fail fast for one that was rejected. Each host installs on its own; there are no votes, gates or
|
||
code versions. Order: server, workers, clients.
|
||
|
||
## 8. Global packages
|
||
|
||
| Aspect | Rule |
|
||
| --- | --- |
|
||
| Sources | `npm:@scope/name@1.2.3`, `git:https://host/repo@ref` (https only), local paths |
|
||
| Declaration | `packages` in `<data>/settings.json`; object form narrows plugins: `{ "source": "…", "plugins": ["todo", "!legacy"] }` |
|
||
| Layout in package | `plugins/<name>/…` by convention, or `"p2": { "plugins": ["./src/foo"] }` |
|
||
| Install location | `<data>/packages/{npm,git}/<id>/<revision>/`, one directory and one kept lockfile per package; local paths load in place |
|
||
| Install command | `npm install --ignore-scripts --legacy-peer-deps --no-audit --no-fund` (no lifecycle scripts; no automatic host peer installs) |
|
||
| Git | shallow clone of the ref, commit recorded; no `prepare` build: TypeScript sources are bundled per facet (7.1) |
|
||
| Updates | staged into a new revision directory, then the active revision is switched atomically; a failed install leaves the old one; old revisions stay until no process uses them |
|
||
| Dependencies | runtime deps in `dependencies` (bundled into each facet); host packages in `peerDependencies` (external; the host supplies them) |
|
||
| Native addons | refused (`binding.gyp`, `.node` files) with a notice; on Termux most would not build anyway; `npm` and `git` must be installed (`pkg install`) |
|
||
| Identity | npm package name, git URL without ref, local absolute path; one package loads once |
|
||
| UI / CLI | a Plugins sheet (owner) lists, enables, disables, updates; a future CLI `p2 install|remove|list|update` (no such command exists yet) |
|
||
|
||
No facet bundle, of any surface, contains a host package (9.2).
|
||
|
||
## 9. Host packages and identity
|
||
|
||
### 9.1 Why one instance
|
||
|
||
Chord binds services, registries and contracts **by name** (and contract versions by fingerprint when binding), not
|
||
by object identity. One instance per process still matters for:
|
||
|
||
- declaration and member brands (module-local symbols in `chord/src/declare.ts`, `contract.ts`);
|
||
- `Ctx`/`Handle` classes and resource facades (`instanceof CtxImpl`, WeakMaps in `record.ts`);
|
||
- `isChordError` (`instanceof ChordError`);
|
||
- React (hooks, `HostContext` in `runtime/web.tsx`);
|
||
- p2 runtime module state (`runtime/logs.ts`, `runtime/linger.ts` and others), React contexts in `src/web/*`
|
||
(`composer-state.tsx`), `TuiRuntime`/`TuiScope`;
|
||
- pi-tui classes that plugins test with `instanceof` (`Text`, `Container`: `plugins/artifacts/tui.ts:29`,
|
||
`plugins/send-file/tui.ts:37`, `plugins/subagents/tui.ts:23`).
|
||
|
||
So everything under `apps/p2/src/**` is host code too (14.4, B3), although plugins import it by relative path.
|
||
|
||
### 9.2 Mapping
|
||
|
||
Under D (14.2) the base bundle contains every host package and `apps/p2/src/**` once. Each facet bundle marks them
|
||
external (an esbuild `onResolve` plugin: bare host specifiers, `@earendil-works/p2/*`, and relative paths that leave
|
||
the plugin into `apps/p2/src/`) and receives them as parameters when it is evaluated. A check fails the build if a
|
||
facet bundle contains a host package or a `src/**` file (14.5).
|
||
|
||
### 9.2a Contracts are never host packages
|
||
|
||
Adapters (`accepts`) keep a plugin built against an older contract version working: Chord binds by the description and
|
||
version the **caller** brings (the type token passed to `get`), and a provider at version N serves a caller at M < N
|
||
through `accepts[M]`. That only works if the plugin carries its own contract version. If its contract imports
|
||
resolved to the running p2's modules, every plugin would carry the current version, the adapter would never apply, and
|
||
a plugin compiled against v1 types would break at runtime.
|
||
|
||
- p2 publishes its contracts as a versioned package (e.g. `@earendil-works/p2-contracts`). A contract's `version` is its
|
||
major version: additive changes need no bump (Chord's coverage check admits them); a breaking change bumps it and
|
||
adds an `accepts` adapter for the previous version for as long as it is supported.
|
||
- A plugin depends on that package (`"@earendil-works/p2-contracts": "^1"`) and builds against it.
|
||
- The loader never maps contract modules to the host's instance, unlike host packages (9.1, 9.2). Contracts are plain
|
||
data (14.3), so copies are harmless. Under D each facet bundle inlines the contract modules it imports, resolved
|
||
**from the plugin's own `node_modules`**, never from p2's tree; the bundle check (14.5) must allow them and fail on
|
||
host packages only.
|
||
- Gaps, open in `changes.md` ("API stability"): registries (routes, Durable extensions and tools, slots, TUI commands)
|
||
have no versions or adapters, and native APIs handed to plugins (`opaque` members, host resources such as the Durable
|
||
harness) cannot be adapted at all.
|
||
|
||
### 9.3 Publishing
|
||
|
||
When p2 becomes an npm package (`@earendil-works/p2`, compiled JavaScript with an `exports` map for `./runtime/*`,
|
||
`./plugins/*` contracts and the CLI), built-ins live in `dist/plugins/`. Plugin authors list p2 as a
|
||
`devDependency` and `peerDependency`; the loader checks the peer range (meaningless in repo mode, where the package
|
||
is `p2@0.0.1`, so skipped there). Needed work: an installation-relative resolver (the source resolver reads the
|
||
checkout's `tsconfig.json`), compiled worker and CLI entry points, and plugins that spawn source files
|
||
(`plugins/terminals/detached.ts` starts `src/source.ts`). `apps/` is private today; publishing needs an owner decision
|
||
(13.2).
|
||
|
||
## 10. Leaving a scope: Durable traces
|
||
|
||
A plugin leaves a session on relocation, disable, removal from disk, trust revocation, a build or load failure, or an
|
||
offline environment without a cached snapshot. Today Durable silently skips missing selected extensions
|
||
(`durable/src/harness/agent.ts`); pending tasks whose definition is missing stay blocked, and only aborted ones become
|
||
`orphaned` (`durable/src/harness/scheduler.ts`). New work:
|
||
|
||
- **Provenance map:** the per-plugin wrapper records plugin → extensions, task kinds, tools and entry kinds; persisted
|
||
in the session store so a later worker without the plugin can name it.
|
||
- **Owner-initiated exits** (Reload, disable, revoke, relocate): before the change applies, live tasks of the leaving
|
||
plugins refuse the change ("Stop … first") or, if the owner chooses, are aborted while their definitions are still
|
||
loaded. Relocation keeps its existing stricter rule (no live tasks or submissions at all).
|
||
- **Involuntary exits** (offline, failed load): blocked tasks show "waiting for plugin foo" with an Abort action.
|
||
|
||
| Trace | Handling |
|
||
| --- | --- |
|
||
| Extensions selected in a conversation | kept by name; the session shows "Plugin foo is not available here" (new) |
|
||
| Entries of the plugin's kinds | generic fallback renderer: kind and data, collapsed (new) |
|
||
| Finished tool calls | generic tool card (new) |
|
||
| Documents | left alone, inert until the plugin returns |
|
||
| Tasks | as above; never resumed by another plugin's definition (13.2, open question 3) |
|
||
|
||
The same fallbacks render project entries for guests, who always get the base scope.
|
||
|
||
## 11. Security
|
||
|
||
| Code | Runs where | Rights |
|
||
| --- | --- | --- |
|
||
| Built-in, global, package: server and worker facets | the p2 host | the owner's OS account (like pi extensions) |
|
||
| Project worker facets | the p2 host, **also for remote sessions** (the phone, next to `auth.json`) | the owner's OS account |
|
||
| Web facets (base, or project for the owner) | the viewer's browser, main thread, same origin | **all of the viewer's rights**: for the owner every owner API, including a host shell through terminals |
|
||
| TUI facets | the viewer's machine, permission-dropped worker | only the launcher's grants |
|
||
|
||
- Trusting a project means granting its code the owner's OS account on the p2 host and owner rights in the browser.
|
||
The trust notice says so: "runs on this device with your account".
|
||
- CSP (`script-src 'self'`, `connect-src 'self'`) does not restrict same-origin code: it can open its own `/chord`
|
||
socket as the owner and use origin storage shared by tabs of other scopes. HttpOnly only hides the cookie value.
|
||
- Scopes keep contributions from mixing; slot restrictions are UX. Neither isolates hostile code.
|
||
- Trust is not filesystem isolation. A guest with send access can make the agent write plugin files; that never
|
||
activates code (6.1). On a local session the agent already has a host shell (and could edit `trust.json`); the gate
|
||
prevents automatic activation, not a hostile agent with shell access.
|
||
- Guests get only base-scope assets; project bundles are owner-only. Source maps are served to owners only.
|
||
|
||
## 12. Migration
|
||
|
||
### 12.1 Order
|
||
|
||
The Chord and p2 rebuild of `target-design.md` (§22: document store, jobs, routines, resources replaced in place,
|
||
registries, host values, rollout with revisions) comes first; plugin loading builds on it.
|
||
|
||
1. **The rebuild** (`target-design.md` §22), including the per-host install with revisions (7.5) and the per-worker
|
||
install (7.4).
|
||
2. **Blockers B1–B8** (14.4): typed TUI surfaces instead of `instanceof` across plugins; cross-plugin imports limited
|
||
to contracts and pure modules; `ws` and `yaml` as host packages; `require` for builtins; web CSS, wasm and
|
||
`import.meta.hot`.
|
||
3. **D for server and worker** (14.2): stable script filename per (facet, digest); V8's compilation cache off on hosts
|
||
that reload in place; the dev leak detector, GC tests and static checks (14.5).
|
||
4. **D for web and TUI** (12.2, step 4).
|
||
5. **Plugin SDK** (`target-design.md` §23, deferred): built-in plugins import only from the SDK, like third-party ones,
|
||
semver per major with a compatibility layer for the previous major. Measured on `private` (`5da70d0f3`): 109
|
||
plugin files import 73 `src/**` modules (most used: `runtime/chord.ts` 72, `runtime/contribute.ts` 47, which goes
|
||
away with facet registrations, `runtime/principals.ts` 27). Recount with
|
||
`grep -rhoE 'from "(\.\./)+src/[^"]+"' plugins | sort | uniq -c | sort -rn` in `apps/p2`.
|
||
|
||
The worker-bundle prototype that measured the loader variants is on branch `p2-worker-bundle-prototype` (not for
|
||
merge); what to reuse: 14.6.
|
||
|
||
### 12.2 Pipeline steps
|
||
|
||
Each step lands with its tests; later steps assume earlier ones.
|
||
|
||
| # | Step | Acceptance (tests) |
|
||
| --- | --- | --- |
|
||
| 1 | **Roots and discovery:** multi-root `listPlugins`, module→plugin map, `plugins.disabled`, name rules, per-plugin loading. Under Vite, the hard-coded web list becomes a generated virtual module of the selected plugins, so disabling works on every surface. | a disabled plugin whose module top level records a side effect is never imported (server, worker, web, TUI); a plugin whose `setup` throws yields a notice and the others load; two plugins with one name, or a contract name without prefix, are refused with a notice; load order stays irrelevant |
|
||
| 2 | **Host mapping and global plugins:** D bundles for server and worker facets (12.1, step 5), `p2:plugins/…`, `<data>/plugins`. External web facets are listed as "waiting for the web bundle" until step 4; TUI works now (esbuild). | a global plugin with its own `node_modules/@earendil-works/chord/old` gets the host instance (`isChordError` and `Ctx` checks pass across); its own npm dependency is bundled and runs; `import.meta.url` is its real file (injected); a global plugin named like a built-in is refused; reloading it 6× leaves no more generations alive than 3× |
|
||
| 3 | **Global packages:** install/remove/update/list, staged atomic installs, lockfiles, resources (skills, prompts, themes). | npm and https-git installs load; a package with a host peer gets no physical host copy at runtime; an interrupted update keeps the old revision working; a native addon is refused; an http git source is refused; a skill from a package appears in `/skill:` |
|
||
| 4 | **D web bundles with push** (a base bundle plus one classic script and one CSS file per facet; the TUI likewise), then remove Vite (list below). | two owner tabs reload via `ClientBundle` after a web edit and Reload, without `visibilitychange`; a guest can fetch the base bundle's assets but no `/src/…` path; the terminal's wasm loads; the page has a CSP nonce and works with the existing CSP; bundles are cached immutable and manifests `no-store`; a failed web build keeps the old bundle and no backend change commits |
|
||
| 5 | **Project scopes and trust:** snapshots, `trust.json`, catalog scope, bootstrap and `/__p2_web/scope`, TUI `?session=`, launcher partitioning, per-worker install (7.4), provenance and fallbacks, skills/prompts gate, slot restriction. | see below |
|
||
| 6 | **Publishing:** `dist/`, `exports`, installation-relative resolver, CLI. | a packed tarball installed into an empty directory serves a session with a global plugin and a package |
|
||
|
||
Step 5 acceptance:
|
||
|
||
- **Two tabs in different scopes:** tab 1 on session A (project scope), tab 2 on session B (base); each loads its own
|
||
bundle; navigating tab 2 to A is a full load; a Reload of A reloads tab 1 only.
|
||
- **Guests:** a guest of A gets the base bundle, sees project entries through the fallback renderer, cannot fetch A's
|
||
bundle or its TUI manifest, never sees trust prompts.
|
||
- **Offline remote:** with the environment down, a worker restart of A loads the cached approved snapshot; with the
|
||
snapshot deleted it loads base and shows the notice; no local directory is read instead.
|
||
- **Busy session:** Reload during a turn installs the new revision in place (7.4); a phase that pinned the old tool
|
||
table keeps calling the old, consistent code; a call that started on the old revision finishes on it.
|
||
- **Failed build:** a candidate with a syntax error in a web facet is refused at Reload; the old revision keeps
|
||
running everywhere; a worker facet that throws at load yields a notice and the other project plugins load.
|
||
- **Name clash:** a project plugin named like a global plugin, or a project tool without prefix, is refused with a
|
||
notice; the session works.
|
||
- **Trust change:** an agent (or guest-prompted) write to `.pi/p2/plugins` only marks dirty; approving loads exactly
|
||
the summarized digest (a file changed after capture is not executed); revocation applies the base scope like a new
|
||
revision (7.4), refusing while tasks of the leaving plugin are live; pin mode requires the review for a one-byte
|
||
change; project `.pi/skills` do not load in an untrusted directory.
|
||
- **TUI:** A→B swap does not replay A's pending send into B; B→A restores A's draft.
|
||
- **Relocation:** moving a session between two project directories switches pages and TUIs via `ClientBundle`.
|
||
|
||
**Removing Vite (step 4)** requires, besides the bundle itself:
|
||
|
||
- `ghostty-vt.wasm?url` in `plugins/terminals/Terminal.tsx:4`: esbuild `file` loader plus an asset manifest.
|
||
- Plugin CSS (`plugins/chat-mode/web.tsx:10`, `plugins/logs/web.tsx:16`, `plugins/terminals/Terminal.tsx:23`): one
|
||
CSS file per facet bundle, linked as `<link data-facet=…>`; an in-place reload adds the new link and removes the
|
||
old one once the new one has loaded. Without that, rules of every generation accumulate (14.4, B8).
|
||
- `import.meta.hot` in `src/hosts/web/main.tsx` and the `/version` check: replaced by `ClientBundle`.
|
||
- HTML: the server renders `index.html` with the nonce on the inline theme script and the bootstrap (Vite's
|
||
`html.cspNonce` does it today); `public/` icons and `manifest.webmanifest` served statically.
|
||
- `envPrefix: "P2_PUBLIC_"`: no current uses in web code; drop it (or esbuild `define` if one appears).
|
||
- The non-owner `/@vite/client` stub, `GUEST_EXTRAS` and the module-graph allowlist: replaced by per-principal
|
||
bundle authorization.
|
||
- Caching: the server sets `Cache-Control: no-store` on every response (`src/hosts/server.ts`); bundles and assets
|
||
need `private, max-age=31536000, immutable`; HTML, bootstrap, manifests and `/__p2_web/scope` stay `no-store`.
|
||
- JSX (`@vitejs/plugin-react` → esbuild `jsx: automatic`), tsconfig paths (esbuild reads them), CSS order.
|
||
- Dev loop: today web edits are HMR-only and not marked dirty (p2-notes, Dev mode); the watcher must mark web facets
|
||
and `src/web`, `src/hosts/web`, `src/runtime/web.tsx` dirty, and Reload rebuilds and pushes. The dev tests for
|
||
"HMR-only web edits" change accordingly.
|
||
|
||
**Bundle builds and cache:** measured on slayer by the reviewers: web ≈ 0.1 s, ≈ 1 MiB minified JS + 54 KiB CSS
|
||
(+ 0.67 MiB wasm); TUI ≈ 0.2 s, ≈ 1 MiB. Not measured on the phone (expect seconds; measure in step 4). Builds are
|
||
single-flight per input key (host build, base revision, snapshot digest, disabled set, esbuild version), at most one
|
||
or two at a time, failures cached per key until inputs change. Disk cache under `<data>/bundles/` with an LRU budget
|
||
that never evicts bundles referenced by a catalog session. The TUI's 8 MiB cap stays; the web bundle gets a cap too.
|
||
D removes `nodeLoader`'s module copy per reload.
|
||
|
||
## 13. Decisions and open questions
|
||
|
||
### 13.1 Decided
|
||
|
||
| Question | Decision |
|
||
| --- | --- |
|
||
| Project plugin directory | `<cwd>/.pi/p2/plugins` (pi ignores `.pi/p2`; mirrors `<data>/plugins`) |
|
||
| Project packages | not in v1 |
|
||
| Trust granularity | per directory, changes only by explicit Reload with summary; optional pin to content (6) |
|
||
| Project web facets for guests | no in v1; base bundle with fallback renderers; TUI likewise (5.1) |
|
||
| Vite | dropped after the D web bundles and `ClientBundle` push exist (12.2, step 4) |
|
||
| Loader | model D on every surface: base bundle plus one script per facet, host packages as parameters, in-place reload through Chord (7.1, 14.2); A (restart when idle) and B rejected (14.1) |
|
||
| Plugin reload | per-host install of a revision, rollout server → workers → clients, mismatches against a host behind wait (7.5) |
|
||
| Project worker reload | in place, a per-worker install outside base rollouts (7.4) |
|
||
| Timers | no raw `setTimeout`/`setInterval` in facets: routines (`every`, `loop`) and jobs (`target-design.md` §§10–11) |
|
||
| Identity across plugins | no `instanceof` on another plugin's classes; typed surfaces resolved by name (14.3) |
|
||
| Resource values | native values only; behaviour in routines (`target-design.md` §12) |
|
||
| Dev loop | runtime changes restart the affected hosts; plugin changes reload in place (4.1) |
|
||
| Scope selection | catalog scope, bootstrap script, TUI `?session=`, launcher-owned snapshots (5.2) |
|
||
| Names | prefixes, checked at load; per-plugin loading (4.3) |
|
||
| Project `.pi/skills`, `.pi/prompts` | gated by project trust (behavior change; 3) |
|
||
| Global plugin dependencies | own `package.json`/`node_modules`; cycles keep current order rules; `.js` allowed for packages |
|
||
|
||
### 13.2 Open
|
||
|
||
1. **Skills of base plugins on remote sessions.** Their files are on the host. Recommended: upload bounded,
|
||
content-addressed copies (`~/.pi/p2/plugin-skills/<plugin>/<hash>/` on the remote, like the daemon deployment)
|
||
and offer the skill only after the upload succeeded. Until then: local sessions only, listed as unavailable on
|
||
remote sessions (not hidden).
|
||
2. **Publishing:** whether and when `apps/p2` leaves the private tree, and which modules `exports` makes public.
|
||
3. **Plugin identity across projects:** project A and project B may both have a plugin `foo` with different code.
|
||
A session relocated from A to B (when idle) would render A's `foo.*` entries and resolve stored `foo.*` task kinds
|
||
with B's code. Options: include a per-project plugin identity in provenance and treat a mismatch as "not available
|
||
here", or accept it as the owner's choice.
|
||
4. **Guest opt-in per share** for project web facets: disclosure and consent design.
|
||
5. **Project root:** the session cwd only (as pi), or the nearest ancestor with `.pi/p2` (sessions in subdirectories
|
||
of a repository would then share its plugins).
|
||
6. **Browser compile cache** (14.2): whether Blink/V8 keeps every classic script it compiled, as Node does; measure
|
||
before web D, else bound in-place web reloads (a full reload after N generations).
|
||
|
||
## 14. Loader model D
|
||
|
||
|
||
### 14.1 Loader variants (measured)
|
||
|
||
Measured with the worker-bundle prototype (`src/hosts/worker-bundle.ts`, `P2_WORKER_BUNDLE`), Node 26, worker spawn
|
||
to ready. Packages, runtime and hosts are ~85% of the load time (1,251 modules), plugin files ~14% (73 modules): the
|
||
win is bundling the base, not the plugins.
|
||
|
||
| Variant | Spawn → ready | Verdict |
|
||
| --- | --- | --- |
|
||
| Today: TypeScript through the source resolver, facets through `nodeLoader` | 549 ms | leaks a module copy per server reload |
|
||
| A: one bundle per worker plugin set, facet changes by restart when idle | 218 ms | **rejected**: sessions with long background tasks (subagent reporters, code mode scripts) are never idle, and a restart loses Chord's in-place reload (worker state, documents, running jobs) |
|
||
| B: base bundle plus one ESM bundle per plugin through `nodeLoader` | 254 ms | rejected: copies cross-plugin files (31 copies of 14 files), renames facets, breaks the agent/codemode direct-reload tests |
|
||
| C: base bundle plus native TypeScript facets through `nodeLoader` | 317 ms | passes all worker tests; **interim** fallback (still one module copy per reload) |
|
||
| **D** (14.2) | not measured (expected near B) | **target** |
|
||
|
||
### 14.2 Model D
|
||
|
||
- **Base bundle:** host packages (Chord, pi-durable, pi-ai, pi-env, pi-codemode, pi-tui, pi-schema, React, and `ws`,
|
||
`yaml`, 14.4 B7), p2's runtime and hosts (`apps/p2/src/**`). One per surface; it changes only with p2 itself.
|
||
- **One script per facet:** an esbuild bundle of the facet and its plugin's files, wrapped as a function of its host
|
||
packages: `(require, …) => facet`. Host packages and `src/**` are external and passed in (9.2); the facet's file
|
||
paths are injected for `import.meta.url` (B4). Server, worker and TUI worker evaluate it with
|
||
`vm.Script.runInThisContext`; the web loads it as a classic `<script>` (plus one CSS file, B8).
|
||
- **No module map.** A generation is the set of closures of one evaluation. Once nothing references them, the GC
|
||
reclaims its code and module state; there is nothing to evict.
|
||
- **Reload stays Chord's:** a changed facet's new script is evaluated and installed on its host (7.5): documents and
|
||
resources stay, behaviour is replaced, running calls and jobs finish with their old code.
|
||
- **Script identity:** each script is compiled with a **stable filename per (facet, content digest)**, and hosts that
|
||
reload in place run with V8's compilation cache off (`--no-compilation-cache`, or `v8.setFlagsFromString` at
|
||
startup), else every distinct script stays in memory (14.3, C6).
|
||
- **Full reload** (process restart, page load, TUI worker swap) remains for base changes, scope switches and stuck
|
||
generations (one the leak detector reports alive after N reloads, 14.5).
|
||
|
||
D only reclaims a generation if nothing keeps it. The rebuild removes most of what kept generations alive (resources
|
||
hold natives only, behaviour lives in routines that restart, registry tables are plain values, no carrier objects); the
|
||
leaks still open are tracked in `changes.md` ("Tracked, not scheduled").
|
||
|
||
**V8 compilation cache** (measured): every distinct (source, filename) compiled by `vm.Script`, `compileFunction`, eval
|
||
or `new Function` stays cached and is not released under memory pressure (100 compiles of a 72 KB bundle with distinct
|
||
filenames: +5.6 MiB; 1,500 unique compiles at `--max-old-space-size=60`: out of memory). Hence the stable filename per
|
||
(facet, digest) and `--no-compilation-cache` (or `v8.setFlagsFromString`) on hosts that reload in place (+0.1 MiB).
|
||
Browsers are measured before web D (13.2, question 6).
|
||
|
||
### 14.3 No `instanceof` across plugins
|
||
|
||
It exists only because the TUI's `ctx.surface("chat")` is untyped (`plugins/chat-mode/tui.ts`, `plugins/send-file/tui.ts`,
|
||
importing `ChatSurface` from `../chat/tui.ts`). Under D it is always false. Fix: `ctx.surface` takes a typed key
|
||
(`defineSurface<ChatApi>("chat")`) resolved by name, like contracts; chat-mode's reply policy becomes a registry entry or
|
||
document chat reads; send-file reads its tool calls from the `Chat` view. `instanceof` on base classes (pi-tui `Text`,
|
||
`Container`) is fine.
|
||
|
||
### 14.4 Blockers for D
|
||
|
||
- **B1 `instanceof` across bundles** (14.3).
|
||
- **B2 Cross-plugin files are copied** into each importing facet, with their module state: `envs/DirectoryPicker.tsx`,
|
||
`subagents/tools.ts`, `files/downloads.ts`, `envs/remote-env.ts` and `config.ts`, `conversations/origin.ts`,
|
||
`workspace/protocol.ts`, `chat/tui.ts`. Rule: contracts and side-effect-free modules only (4.2).
|
||
- **B3 `apps/p2/src/**` is single-instance host code** (9.1): `HostContext`, `src/web` contexts, `TuiRuntime`, pi-tui
|
||
classes. The bundler externalizes relative paths into `src/` (9.2).
|
||
- **B4 `import.meta.url`:** `plugins/dev-tools/worker.ts`, `plugins/prompt/worker.ts`, `plugins/terminals/detached.ts`.
|
||
Inject per file.
|
||
- **B5 Dynamic `import()`:** none in plugin code; the base keeps resolving pi-ai's lazy API loaders and pi-codemode's
|
||
worker-thread entries.
|
||
- **B6 Top-level await:** none in facets; `plugins/terminals/main.ts` and `plugins/radius/login.ts` are process entries.
|
||
- **B7 CommonJS and builtins:** `ws` and `yaml` become host packages; Node builtins come through a `require` the loader
|
||
passes.
|
||
- **B8 Web:** `?url` wasm (`plugins/terminals/Terminal.tsx`), plugin CSS (12.2 "Removing Vite"), `import.meta.hot` in
|
||
`src/hosts/web/main.tsx`.
|
||
|
||
### 14.5 Guardrails
|
||
|
||
- **Generation-GC tests:** Chord unit tests (documents, registrations, kept resource, routines, jobs) asserting old
|
||
generations are collected; a p2 test that loads every server and worker facet with fakes through the D loader,
|
||
reloads 3× and 6×, and asserts the alive set does not grow between 3 and 6; the TUI inside its worker runtime; the web
|
||
through CDP heap snapshots.
|
||
- **Dev leak detector:** the loader registers each generation's sentinel in a `FinalizationRegistry`; more than two
|
||
generations of a facet alive posts a Logs notice naming the facet; a `/__p2_dev` action writes a heap snapshot.
|
||
- **Static checks:** cross-plugin imports only of contracts and allowlisted pure modules; no `instanceof` on another
|
||
plugin's classes; no `import.meta`, `import()`, top-level await or `require` in facet import graphs (esbuild
|
||
metafile); no module-level side effects in facets; no raw timers in facets; no handlers inside resource `open`.
|
||
- **Bundle check:** the build fails if a facet bundle contains a host package or a file under `apps/p2/src/`.
|
||
|
||
### 14.6 The worker-bundle prototype
|
||
|
||
Branch `p2-worker-bundle-prototype`, commit `e290c4fe1` (not for merge). It built variants A–C for session workers and
|
||
measured them. Reuse for D: the scan pass (`onResolve` plus `builder.resolve`, also the bundle check), the base-bundle
|
||
build with one entry per host module and shared chunks, `sourceRewrites` (`import.meta.url` injection, made AST-safe),
|
||
the bundle cache (content-addressed directories, staging and atomic rename, single-flight, prune), the server hand-off
|
||
(`SessionHosts.bundle()`, build before install, metafile inputs for dirty marking), its cache and stack-trace tests.
|
||
Drop: restart when idle, variants A and B, C's hook.
|
||
|
||
## Appendix A. Review notes
|
||
|
||
Every finding was checked against the code; the claims cited above were confirmed (Chord `not_coordinator` and
|
||
longest-prefix module lists; name-based binding; `discoverPluginTypes` importing every facet, since removed;
|
||
`/version` on `visibilitychange` only; the fixed TUI manifest and cross-identity snapshots; global `no-store`; Durable
|
||
silently skipping extensions and blocking tasks; ungated `.pi/skills`/`.pi/prompts`; `?url` wasm; `import.meta.url`
|
||
users).
|
||
Where this design deviates from a reviewer or the owner's brief:
|
||
|
||
1. **Tool names use `<plugin>_`, not `<plugin>.`.** Provider APIs (Anthropic, OpenAI) restrict tool names to
|
||
`[a-zA-Z0-9_-]{1,64}`; a dot would break the tool on those providers. All other names use `<plugin>.`.
|
||
2. **No `defaultProjectTrust: "always"`** (pi has it): it would activate project code without the owner, contrary to
|
||
6.1.
|
||
3. **In-place project reload protocols** (Fable B1/B2, Sol B2, Astra B3: server-coordinated per-session reloads,
|
||
stable facet names, transactional base+project reloads): revision 2 found them unnecessary because project facets
|
||
applied by restart. Revision 3 rejects restart at idle (14.1), so a server-coordinated per-worker reload is needed
|
||
again (7.4, 13.2 question 6).
|
||
4. **Server-built project worker bundles** (Fable B3, Opus I2) and **bundling package backends** (old 9.2):
|
||
revision 2 replaced them by Node hooks; revision 3 adopts them in the form of D, which keeps one host instance by
|
||
passing host packages in and injects `import.meta.url` (7.1, 14.2).
|
||
5. **esbuild metafile instead of the TypeScript parser** (Opus I4): moot. Discovery no longer parses anything (owner
|
||
decision); dependencies resolve at runtime (4.2).
|
||
6. **Project packages under `~/.pi/p2/project-packages`** (Fable B3, Opus I7, Astra/Sol §13.2): deferred with
|
||
project packages (not in v1).
|
||
7. **Guests get approved project web facets** (Fable, Astra §13.4): overridden by the owner decision (base scope only).
|
||
8. **Directory trust with automatic in-place reload** (Fable B4): overridden; changes apply only by Reload.
|
||
9. **Opus M3** (refuse a project plugin with `server` as a whole) adopted; the old text refused only the facet.
|
||
10. **Opus I12** (`P2_PUBLIC_` prefix): kept in the removal list, but no web code uses `import.meta.env` today, so the
|
||
work is deletion.
|
||
11. **Revision 3** (owner decisions after the worker-bundle prototype and a reload-leak audit, section 14): loader
|
||
model D replaces Node hooks and restart at idle (7.1, 7.4); declared timers, typed surfaces and plain resource
|
||
values (14.5); the leak fixes precede D (12.1).
|