pi-mono/packages/chord/docs/slice-7c.md
Mario Zechner e9c0ff4d84 feat(chord): Chord v2 is the package's root
src/next is src (the main export), src/next-node is src/node with short subpaths (/http, /workers, /plugins,
/plugins/runtime, /app, /surfaces, /web, /react, /tui, /tui/launcher); the foundations get /json and /watch;
Chord v1 moves to src/old (/old, /old/node) until p2 runs on v2; tests and docs move likewise (test/, docs/,
test/old, docs/old). Durable and env import the foundations from their subpaths.
2026-11-02 22:36:55 +00:00

165 lines
13 KiB
Markdown

# Slice 7c: plugins (spec)
Status: **ready to build** (owner, 2026-10-30: decisions "Slice 7c's points" and "Per plugin at startup and reload";
spec reviews by Fable and Opus, `/tmp/s7c-spec-review-{fable,opus}.md`, folded in). 7c builds `chord/plugins`: the
code that turns plugin directories into facets, loads and reloads them per plugin, and watches them. A Chord app is
self-modifying (the owner or an agent edits a plugin while it runs), so this is core machinery, not a dev tool (no
dev/prod split). It also changes the kernel's `load` and `reload` to per-plugin failure (§4). HTTP and workers are slice
10, the web page host and the TUI slice 11: 7c builds scripts for every surface and evaluates them on Node; its build
results are plain data those slices ship.
Read in full before writing code: this spec; `decisions.md` (2026-10-29/30: "Slice 7b's open points, as recommended",
"A plugin's npm dependencies are bundled", "Per plugin at startup and reload", "Slice 7c's points"); `design.md` (§3
`load`/`reload`, §16, §18, §21 in full); the 7b brief `slice-7b/book.md` (chapters "A Plugin, File by File", "Building
and Loading", "Assets and Files", "Performance", "A Mini p2 from Chord"); the two spec reviews; p2's
`apps/p2/src/hosts/{bundles,plugins}.ts` and `apps/p2/docs/plugin-loading.md` §4.1, §7.1, §8, §9, §14; coding-agent's
`packages/coding-agent/src/core/package-manager.ts` (dependency installs). Code lives in
`packages/chord/src/node/plugins/` (Node-only: `fs`, esbuild, `vm`; the kernel stays portable),
exported as `./next/plugins` when v2 gets its entry point; until then tests import the source.
## 1. A plugin on disk
```text
plugins/notes/
package.json "name": the plugin's name (required, npm style); "dependencies": bundled; "peerDependencies":
host packages; "chord": { "external": ["better-sqlite3"] } (optional, Node facets only)
server.ts worker.ts web.tsx tui.ts one file per facet kind the app names; default export: a facet
contract.ts store.ts NoteList.tsx notes.css assets/… plain files
node_modules/ its own npm dependencies (`installDependencies`)
```
- The plugin's name is its `package.json` `name`, never the directory name. A directory without a `package.json`, or
with an invalid name, is reported, not a plugin.
- Facet kinds are the app's (`["server", "worker", "web", "tui"]` in p3), each `.ts`, `.tsx`, `.js` or `.mjs`; two
files of one kind are reported (not chosen by extension). A plugin with none of the kinds (contracts only) is a
plugin with no facets: imported by others, never loaded.
- Discovery lists each root one level deep, reads only `package.json`, follows symlinks to their real paths, and keeps
the first root's plugin of a duplicate name (the other reported).
## 2. Building
```ts
const plugins = await discoverPlugins(["./plugins", join(dataDir, "plugins")], { kinds: KINDS }, ctx);
const built = await buildFacet(plugins[0], "worker", { hostPackages: HOST_NAMES, platform: "node" }, ctx);
// { kind: "script", plugin, facet: "worker", source, digest, inputs: [{ file, hash }], copied: [file],
// css?: { name, text }, assets: [{ name, bytes }] } | { kind: "failed", plugin, facet, errors }
```
One esbuild build per (plugin, kind), from cold (no kept contexts: about 10 ms per facet; p2 measured 237 ms for 25
worker facets). A failed build is a result, never a throw. The result is plain data (strings, bytes, arrays): the same
value a worker (slice 10) or a page (slice 11) evaluates where it runs.
| An import of | Becomes |
| --- | --- |
| the plugin's own files | inlined |
| a file of another discovered plugin (`../sessions/contract.ts`) | inlined (a copy), listed in `copied`; it may import only host packages and files of its own plugin, never packages (a build error otherwise): contracts and side-effect-free modules only (decision 4) |
| a package in the plugin's `dependencies` | inlined from its `node_modules` |
| a package in `chord.external` (Node builds) | external, `require`d at evaluation from the plugin's `node_modules`; a browser build fails |
| a host package (`options.hostPackages`, e.g. `@earendil-works/chord`, `react`, `react/jsx-runtime`, `@app/core`) | external, from the map at evaluation |
| `@earendil-works/chord/plugins` | external always (its `pluginFile` is per facet, §3) |
| a Node builtin (`node:fs`, `fs`) | external (Node builds); a browser build fails |
| CSS (browser builds) | one stylesheet per facet (`css`) |
| an asset (`import url from "./icon.svg?url"`, `.wasm`, browser builds) | a content-hashed file in `assets`; the import is its URL under the app's `options.publicPath` |
| `import.meta.url`, `__dirname`, `__filename`, a literal dynamic `import("x")`, top-level `await` | a build error pointing to `pluginFile` (§3); a computed `import(x)` cannot be seen at build and fails when it runs, through the facet's `require` naming the facet |
| anything else (a relative path into base code, a package not in `dependencies`) | a build error naming the file and the rule |
- Output: `format: "cjs"`, wrapped `(function (require, module, exports) { … })`; TypeScript stripped, JSX automatic
(`react/jsx-runtime`); no type check; minified; an inline source map whose `sources` are relative to the plugin's
directory (so the script and its `digest`, a hash of the source, are the same on every machine and checkout).
- `inputs` are captured where esbuild loads each file (an `onLoad` that hashes the exact bytes it hands esbuild, as p2's
`captureBundleInputs`; the metafile has no hashes), absolute paths, for the watcher.
## 3. Evaluating (Node)
```ts
const facet = evaluateFacet(built, { hostPackages: HOST, plugin }); // returns the facet, or throws its error
```
- `vm.Script` under a stable filename per (plugin, kind, digest), path-like (`/chord-plugins/@pi/notes/worker.ts`
plus the digest), run in this context; the default export, checked to be a facet (a declaration error otherwise).
- **The `require` it passes answers, in order:** the host-package map (each value is what Node's `require` would give:
a CommonJS package's exports, an ESM package's namespace wrapped once with `__esModule: true`, so default and named
imports both work); `@earendil-works/chord/plugins` → `{ pluginFile }` bound to this plugin's directory;
`chord.external` packages and Node builtins through `createRequire(join(plugin.dir, "package.json"))`; anything else
throws naming the facet.
- **`pluginFile(path)`** gives the absolute path of a file inside the plugin's directory; `..` out of it is refused.
- **No module map, no cache:** an old facet's closures are collected once nothing holds them. The component turns V8's
compilation cache off when it is created (`v8.setFlagsFromString("--no-compilation-cache")`, once per process; an
app may pass the flag itself).
- **Stack traces** (decision Q1 (b)): `mapStack(error)` returns the error's stack with plugin scripts' positions mapped
through their source maps (a `module.SourceMap` per evaluated script, kept while the script is) to `notes/worker.ts:12`;
the app's reporters call it. Nothing global (no `Error.prepareStackTrace`, no process-wide source-map switch).
## 4. Loading and reloading, per plugin (kernel change)
Decision "Per plugin at startup and reload" changes `host.load` and `host.reload` (slice 7b) and the component's
`load`/`reload` helpers:
- **`host.load(facets, ctx)`** resolves with `{ failed: [{ plugin, error }] }`: a plugin whose setup throws (or that is
refused at declaration: invalid name, a type or registry served twice, a remote type on a host without principal or
address, `checkAccepts`) is left out and reported; the others publish; its consumers wait `failed { waiting }`. `load`
never closes the host for a plugin (base code decides whether its core failing is fatal). A plugin left out is
loaded by the next reload after an edit rebuilt it (it is dirty).
- **`host.reload(facets, ctx)`**: staging per plugin. A plugin whose new code fails in staging (setup, a factory against
the recording `self`, a declaration error, `checkAccepts`) keeps its loaded facet (as if passed unchanged), and
staging runs again without its new code. **The closure rule:** a changed plugin whose recorded requests (staging
records them, for its existing objects, with their contract versions) would not bind (the same check as a real
bind, the provider's host address included) because the plugin they need was kept old, or because nobody serves the
name while any plugin of this reload failed (a failed new setup may be what would have served it: owner, 2026-10-30,
"keep"), is kept old too (repeat until stable). The report gains `failed: [{ plugin, error }]` (the same list as `load`'s; an added plugin that fails is left out); the commit applies the rest.
- **The component** builds and evaluates per plugin: a plugin whose build fails or whose evaluation throws is passed to
the host as its loaded facet at a reload, with `reload(facets, context, { failed })` naming it (the host applies the
closure rule to it and lists it in `failed`), left out at startup and reported with the host's report.
`plugins.load(host, ctx)` and `plugins.reload(host, names, ctx)` keep, per host, the facet loaded for every plugin, so
`reload` always passes the complete set (unchanged plugins as the same facet object, removed ones left out) and never
restarts an unchanged plugin.
## 5. Watching and rollout
```ts
const plugins = createPlugins({ roots: ["./plugins"], kinds: KINDS, hostPackages: HOST, base: ["./src"] }, ctx);
plugins.state // a JSON watchable: { dirty: ["@pi/notes"], install: ["@pi/notes"], set: false, base: false }
const next = await plugins.build(["@pi/notes"], ctx); // built results per (plugin, kind), plain data
await rollout([serverStep, ...workerSteps, ...clientSteps], next, ctx); // the app's order (decision 3)
```
- **The watcher** (`fs.watch` recursive over roots and `base`) ignores `node_modules` and `.git`. A changed file marks
dirty every plugin whose facets used it (`inputs`; a copied contract dirties every user) or whose directory contains
it; a changed `package.json` marks `install`; a plugin directory added or removed sets `set` (discovery runs again
at the next build); a file under `base` sets `base` (the app restarts its hosts; Chord restarts nothing). A change
during a build keeps its mark. If the watcher cannot watch (the system's limit), it reports once and the app builds
on demand. It marks, never applies.
- **`installDependencies(plugin, ctx)`**: `npm install --omit=dev --legacy-peer-deps --ignore-scripts --no-audit
--no-fund` in the plugin's real directory, the command overridable (decision 3); it clears `install`.
- **`rollout(steps, built, ctx)`**: each step is `{ name, kinds, reload(built, ctx) }` and returns the host's report;
steps run in order; a step's `reload` evaluates the facets of its kinds where its host runs (an in-process step through
the component's per-host state, §4; a remote one, slice 10/11, gets the built data). It stops at a step that rejects
(a host closed, an abort); kept plugins do not stop it. Marks clear for plugins applied on every step; a plugin kept
on any host stays dirty.
## 6. Not in 7c
Installing plugins from npm or git (p2 §8), project plugins with trust and snapshots (design §26 #14), a base bundle per
surface, evaluating in a browser (slice 11), shipping built scripts to workers and pages (slices 10, 11), static checks
of cross-plugin imports beyond §2's rule.
## 7. Tests, examples, cost
- One test file per source file; fixture plugins under `test/node/fixtures/plugins/`. Discovery (names, duplicates,
missing or invalid `package.json`, two files of a kind, contracts-only plugins, symlinks); build (every row of §2's
table, failures as results, relative source maps and stable digests across two checkout paths, inputs hashed at
load); evaluation (host packages as the host's instances: `instanceof ChordError` across a plugin; default and named
imports of ESM and CommonJS host packages; `pluginFile` and its `..` refusal; an import-time throw; a non-facet
export); `mapStack` naming plugin files and lines; per plugin at startup and reload (§4: a failing setup, factory,
declaration, build, evaluation; the closure rule with a provider and consumer changed together; the report's `failed`
and `failed`; `load` never closing the host); the watcher (dirty marks from inputs and directories, `install`, `set`,
`base`, a change during a build, `node_modules` ignored); `rollout` (order, stopping at a rejecting step, marks).
- **GC:** 200 reloads of an evaluated plugin facet: old closures collected, heap flat after warm-up.
- **End to end:** a server and a worker (memory links) loading plugin directories, a plugin edited on disk, built and
rolled out (server, then worker), a holder seeing one frame; a broken edit kept old on both hosts and reported.
- Examples (owner, 2026-10-30: full coverage, each a folder `NN-topic/` with the hosts in `main.ts` and real plugin
directories under `plugins/`): `35-plugins` (load, edit, build, rollout), `36-plugin-failures` (per plugin at startup
and reload, the closure rule, an abort after the commit), `37-plugin-imports` (copied contracts, dependencies,
`chord.external`, host packages, `pluginFile`, the refusals), `38-watch-and-rollout` (the marks, plugins added and
removed, a stopped rollout), `39-browser-build` (CSS, assets, the built result as plain data). Benchmark (`benchmarks.md`): cold build of a 20-plugin set, a rebuild of one facet,
evaluation per facet, heap after 100 reloads.