pi-mono/packages/chord/docs/slice-12.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

9.2 KiB

Slice 12: the plugin model (spec)

Status: approved (owner, 2026-11-01: the plugin model's decisions, then "go ahead, just do all the things"; this spec written by the redesign session from them, no separate spec review). Builds on 7c's plugins component (src/node/plugins/) and slice 10's kinds and createPluginRuntime.

Read in full before writing code: this spec; decisions.md entries of 2026-11-01 "Contracts come from where the plugin's code came from", "Plugins import only the app's versioned API", "The plugin model, points 3, 4, 5 and 8, imports and builds", "Single-file plugins", "Testing a plugin alone"; design.md §21 (all of it, "The plugin model" included) and §16-§17; slices.md (carry-over "Slice 12"); slice-7c.md, slice-10.md §3 "Plugins in workers"; src/node/plugins/README.md and its sources; the primer docs/plugin-primer/book.md.

1. The app's API (createPlugins option api)

const plugins = createPlugins({
	roots: [{ path: "./plugins", required: true }, { path: join(dataDir, "plugins") }],
	api: { name: "@acme/app", versions: ["v3", "v2"] },          // newest first
	kinds: {
		server: { platform: "node", hostPackages: ["@acme/app/v3", "@acme/app/v3/server", "@acme/app/v2"] },
		worker: { platform: "node", hostPackages: ["@acme/app/v3", "@acme/app/v3/worker", "@acme/app/v2"] },
		web: { platform: "browser", hostPackages: ["@acme/app/v3", "@acme/app/v3/web", "@acme/app/v2", "react", "react/jsx-runtime"] },
	},
	onError,
}, ctx);
  • api names the app's API package and the versions it provides. A plugin import of <name>/<version>[/<kind>]: a version not listed is a build error naming the listed ones ("@acme/app/v1 is not available: this app provides v3, v2"); a listed one that the kind's hostPackages lacks (@acme/app/v3/web from a worker facet) is a build error naming the kind. One facet's imports of the API all use one version (a mix is a build error naming both).
  • Chord itself (@earendil-works/chord and its subpaths other than /plugins, which stays as in 7c for pluginFile) is not importable by plugins: a build error pointing to the app's API. Apps that do not set api keep 7c's behaviour (host packages as listed), so Chord's own tests and examples keep importing Chord directly where they model base code.
  • roots entries may be strings (as today) or { path, required? }. A plugin of a required root whose build, evaluation or setup fails at plugins.load makes load reject (PluginError naming it), after the host's own load settled; failures in other roots are reported as today. At a reload, a required plugin failing keeps its old code as any plugin (reported).

2. Imports between plugins (replaces slice 10's by-name lookup)

  • Relative paths within a root: an import from one plugin into another plugin's directory under the same root (../notes/contract.ts) is inlined (listed in copied), the copied file under 7c's rule (only host packages and files of its own plugin). A relative path leaving the root (base code, another root) is a build error naming the file and pointing to the API or a dependency. A git checkout of several plugins cloned into a root is "within a root".
  • Package names across repositories: an import of a package in the plugin's dependencies resolves as npm does through that package's exports (@bob/notes → its contract.ts, @bob/notes/web → contract.web.tsx), inlined at the installed version. A dependency may be an npm version or a git URL with a ref (github:bob/notes#<commit>, git+https://...#<tag>); installDependencies installs it with npm as today. A plugin inside a multi-plugin repository is reached through a dependency on the whole repository, by path into it (@bob/plugins/notes/contract.ts) or its exports (decision "chord.plugins is a package's manifest of plugins").
  • chord.plugins in a package's package.json lists the plugin folders it provides (globs, exclusions); used by discovery of a root's packages and by installPlugin; without it, conventional discovery.
  • The by-name lookup (build.ts #byName), its error texts and the six tsconfig.json path aliases go; fixtures and examples 35-38 import relatively again.

3. Per-kind files and the import check

  • A file whose name has a kind segment before its extension (contract.web.tsx, ui.tui.ts, any *.<kind>.<ext> with <kind> one of kinds) is a file of that kind: importing it (directly or through other files) from a facet of another kind is a build error naming the chain. Facet files keep their names (web.tsx); contract files are contract.ts (shared) and contract.<kind>.ts(x).
  • The check runs in the existing esbuild onResolve path, on the resolved file, for relative imports, dependencies and copied files alike.
  • Documented, not checked: a contract file mentions native things only through import type (TypeScript erases it; a runtime import of a package the kind lacks already fails as above).

4. Single-file plugins

  • Discovery also lists files at a root's top named <name>.<kind>.(ts|tsx|js|mjs); files of one <name> form one plugin named <name> (an unscoped valid npm name, else reported), one facet per file, dir the root, no package.json (no dependencies, no chord.external). A directory plugin of the same name in the same root is a reported duplicate (the directory wins).
  • Its build allows imports of host packages only (the app's API); anything else is a build error saying to make it a directory. pluginFile refuses (it has no directory of its own).
  • The watcher: such a file appearing or disappearing at a root's top sets set; a change marks the plugin dirty.

5. Installing plugins

installPlugin(source, root, ctx): source is { git: url, ref } (a shallow clone of the ref into <root>/<repository name>; a repository whose top has a package.json is one plugin, otherwise its subdirectories are plugins) or { npm: name, version } (npm pack and extract into <root>/<name>), then installDependencies for each new plugin; marks set. Returns the installed plugin names. Removal is deleting the directory (set). No updating in place: install another ref (a new directory) or delete and install.

6. testPlugin

const t = await testPlugin("./plugins/stats", {
	kind: "worker",
	api: { name: "@acme/app", versions: ["v3"] },
	modules: { "@acme/app/v3": apiV3, "@acme/app/v3/worker": apiV3Worker },   // the real API modules
	stubs: [{ name: "@acme/notes", facet: notesStub }],                        // facets serving what it uses
	principal: { kind: "user", id: "u1" },                                      // the host's principal (optional)
}, ctx);
const stats = await t.host.get(Stats, { project: "p1" }, ctx);
t.contributions(Panels);   // what the plugin contributed to a registry nobody serves (the helper serves it)
t.unserved();              // contract names requests wait for unbound `not_served`
await t.close(ctx);
  • Built with the same build as production (buildFacet with the kind's options), evaluated with modules, loaded on a host with address/principal as given, an in-memory store (:memory: SQLite); stubs loaded beside it.
  • The helper serves, as a collecting registry, every registry the plugin contributes to that no stub or the plugin itself serves (t.contributions(R): the current entries).
  • t.unserved() lists the names of contracts whose requests wait unbound not_served on the host (the unbound index), so a test knows what to stub. t.root(fn, ctx, { principal }) runs as another principal.
  • Exported from chord/plugins (testing.ts); no test-only modes elsewhere.

7. Docs for authors and agents

  • docs/plugin-authoring.md: a plugin's layout (directory and single-file), its facet and contract files per kind, what it may import and how each import resolves, versions, how the server builds it (esbuild, what the build handles, no build hooks), installing from git or npm, testing with testPlugin; written for agents (short, exact, with one complete example plugin).
  • Update src/node/plugins/README.md, the guide docs/guide-plugins-hosts/book.md (chapters "Plugins Using Plugins" and "Base Code and Plugins"; rebuild index.html), design §21's slice-10 note.

8. Tests, examples

  • One test file per source file; build tests for every refusal and allowance of §1-§4 (an unlisted version, a kind module from another kind, mixed versions, a Chord import, a relative path leaving the root, a per-kind file through a chain, a package from dependencies through exports, a git dependency with a ref (a local bare repository in a temp directory, no network), a subfolder dependency); discovery of single-file plugins; required roots at load; installPlugin from a local git repository and a local npm tarball; testPlugin (stubs, collected contributions, unserved, a principal).
  • Examples: 49-plugin-api (an app API with two versions and per-kind modules, a plugin per version, the refusals), 50-plugin-imports (relative within a root, a git dependency pinned to a commit, per-kind contract files), 51-single-file-plugin, 52-test-plugin (a plugin tested with a stub). Examples 35-38 updated.
  • No hot path changes; no benchmark beyond a cold build of the 20-plugin set (as 7c's) to show the checks cost nothing measurable.