--- title: Slice 7b: reloading plugins in a running Chord app cover: Slice 7b / Reload kicker: A design brief · Chord v2 · slice 7b · 2026-10-29 subtitle: What a plugin is, how it is built and loaded, how a mini p2 is assembled from Chord, and what happens when a plugin's code changes on one host or across many stats: - 4 | steps of a reload on one host - 3 | fates of a live object - 26 | carry-over items from slices 3a to 7a - 11 ms | to rebuild one facet after an edit - 6 | decisions open note: | A brief before the slice 7b spec, written by the redesign session from `design.md` §21, the dependency model (`dependencies/model.md`, decided and built for 7a in pi `339847eb1`), the carry-over in `slices.md` and the code at `339847eb1`. Marks: **Decided** points cite the decision; **Open** points are the owner's, with a recommendation. colophon: Slice 7b brief · 2026-10-29 · Chord v2 --- # Before we begin {.preface} Mario edits the notes plugin while a session is open: a new method, a renamed field in a document, a second state member. He presses Reload. The server, the session's worker and two browser tabs run the old notes code; within a second they should all run the new one, with the notes, the open views and the running export job intact, and without restarting a single process. Chord v2 can do everything around that today: objects, references across hosts, the store, jobs, registries, and since 7a a dependency model in which a missing provider is a wait, not an error. What it cannot do yet is change code. `load` runs once per host (`host.ts`: "load runs once per host"). Slice 7b adds the reload, on one host and across hosts. ## Eight things to know :::findings 1. **A reload is a transaction per host.** Load the bundles, stage the new declarations, commit atomically, then let each object follow; any code error before the commit leaves the host entirely on its old code. 2. **A live object has three fates.** Pure objects swap in place; objects with natives drain and restart; objects of a removed type end, and their holders wait. 3. **Old calls finish on old code.** A pure swap keeps the running calls' old handles valid until they settle; only new calls see the new code. 4. **Versions are the provider's business.** A provider accepts older consumers through `accepts`; a consumer that does not fit waits (7a), it is not ended. 5. **7a already decided most of the hard part.** Removal, dropped versions and installs are waits woken by a publication; a reload's commit is such a publication. 6. **A plugin is a directory with a `package.json` and one file per surface.** Each facet is bundled alone; host packages are passed in, never copied; a whole plugin set builds in a few hundred milliseconds. 7. **Chord should ship the app kit** (proposed): loader, HTTP, workers, web, TUI and dev reload as optional components, so a mini p2 is a page of code. 8. **Six points are open:** the reload API, the rollout across hosts, a `writable` across a swap, the install watchdog, where the loader lives, and the app kit. ::: ## Words used throughout A **reload** changes the code of one or more plugins on one host. A **facet** is a plugin's code for one host kind; a **bundle** is the built JavaScript of a facet. **Staging** runs the new facets and factories against a recording `self` without activating anything. The **commit** switches the host to the new code at once and is a **publication** in 7a's sense. A **pure** object has no natives (`self.open`), in its old or its new code. A **rollout** is a reload of the same code set on every host, server first. # A Plugin, File by File {#plugin lede="What a plugin looks like on disk, and what each file is for."} A plugin is a directory. Its name is its `package.json` name; its code is one file per host kind it runs on, a **facet**; everything else is ordinary TypeScript the facets import. This is p2's layout today (`apps/p2/plugins/*`), plus the `package.json` that loader D needs (p2's `plugin-loading.md` §8; p2's built-in plugins have none yet and are named by their directory). ```text plugins/notes/ package.json name = the plugin's name; peerDependencies = host packages; dependencies = bundled npm deps contract.ts the plugin's object types and registries (shared by its facets and by other plugins) server.ts facet for the server (default export: defineFacet(...)) worker.ts facet for session workers web.tsx facet for browser tabs tui.ts facet for the TUI store.ts plain modules the facets import NoteList.tsx notes.css assets/ template.md files the plugin ships (read at runtime, or offered for download) icon.svg skills/notes/SKILL.md optional resources (p2 §4.4) ``` ```json { "name": "@pi/notes", "version": "1.4.0", "type": "module", "peerDependencies": { "@earendil-works/chord": "^2", "@earendil-works/pi-durable": "^1", "react": "^19" }, "dependencies": { "marked": "16.4.1" } } ``` - **`name`** identifies the plugin everywhere: its facets, its registry contributions, diagnostics, reloads. - **`peerDependencies`** are **host packages**: the running app supplies them, one instance per process (one Chord, one React; `instanceof ChordError` and React hooks only work that way). The bundler never copies them. - **`dependencies`** are the plugin's own npm packages (`marked`): installed into the plugin's `node_modules` with `--ignore-scripts` and bundled into every facet that imports them. **Decided** (owner, 2026-10-29): bundled by default; on Node a plugin may keep named packages external by an explicit list, `"chord": { "external": ["better-sqlite3"] }`. Server and worker facets then load them from the plugin's `node_modules` with `createRequire`, as pi 1.0 loads extension dependencies, native addons included; they stay in Node's module cache until the process ends, so a new version takes effect after a restart. A browser or TUI facet that imports one fails to build. - **No `tsconfig.json` is needed.** The bundler strips types and does not type-check; JSX is compiled with the automatic React runtime, a fixed setting of the loader. A plugin may keep a `tsconfig.json` for its editor and for `tsc --noEmit` (the SDK ships one to extend); the loader ignores it. A facet only declares (chapter III of the model): it serves object types, serves or contributes to registries, and returns nothing. ```ts // plugins/notes/contract.ts import { defineRegistry, local, remote, str, obj, arr, method } from "@earendil-works/chord"; import { Session } from "@app/core"; // the app's host kinds, a host package export const NoteStore = local("notes.store", { version: 1, params: {}, members: { /* … */ } }); export const Notes = remote("notes", { version: 2, params: { session: str() }, hostAddress: ({ session }) => Session({ id: session }), variants: { editor: { state: { list: arr(obj({ id: str(), text: str() })) }, methods: { add: method(obj({ text: str() })) } } }, }); // plugins/notes/worker.ts import { defineFacet } from "@app/core"; // bound to the app's principal type import { NoteStore, Notes } from "./contract.ts"; import { serveStore } from "./store.ts"; export default defineFacet((host) => { host.serve(NoteStore, serveStore); host.serve(Notes, ({ session }, principal, self) => { /* … */ }); }); // plugins/notes/web.tsx import { defineFacet } from "@app/core"; import { NoteList } from "./NoteList.tsx"; // a React component, bundled import "./notes.css"; // bundled into the facet's stylesheet export default defineFacet((host, self) => { self.contribute(Panels, { notes: NoteList }); // the app's UI registry }); ``` # Building and Loading {#loading lede="How the bundler knows what to pull in, and how the code gets into a running process."} Each facet is built on its own, as one script. The bundler starts at the facet file and follows its imports: | An import of | Becomes | | --- | --- | | the plugin's own files (`./store.ts`, `./NoteList.tsx`) | inlined into the facet's script | | another plugin's contract (`../sessions/contract.ts`, or `@pi/sessions/contract` for an installed package) | inlined too: a copy, so a plugin carries the contract version it was built against (`accepts` needs that); only contracts and side-effect-free modules may be imported across plugins | | an npm package in `dependencies` (`marked`) | inlined, resolved from the plugin's own `node_modules` | | an npm package listed in `chord.external` (Node facets only) | external: `require`d from the plugin's own `node_modules` at evaluation | | a host package (`@earendil-works/chord`, `react`, `@app/core`, …) | **external**: a parameter of the script, the host's instance at load time | | a Node builtin (`node:fs`) | external, through the `require` the loader passes (server and worker facets only; a web facet that imports one fails to build) | | CSS (`import "./notes.css"`) | collected into one stylesheet per web facet | | an asset by URL (`import iconUrl from "./assets/icon.svg?url"`) | a content-hashed file served by the app; the import is its URL | The built script is a function of its host packages. That is the whole trick of loader D: there is no module map, no second copy of Chord, and an old version is garbage-collected once nothing holds its closures. ```js // what the bundler emits for plugins/notes/worker.ts (simplified) (function (require, __files) { const { defineFacet } = require("@app/core"); // the host's instance const { local, remote, str } = require("@earendil-works/chord"); const { marked } = /* marked's code, inlined */; /* contract.ts, store.ts, inlined */ return defineFacet((host) => { /* … */ }); }) ``` ```ts // the loader (proposed @earendil-works/chord/loader), Node side import { buildFacet, evaluateFacet } from "@earendil-works/chord/loader"; const HOST_PACKAGES = { // the app's base bundle exports these "@earendil-works/chord": chord, "@earendil-works/pi-durable": durable, "@app/core": core, react, "react/jsx-runtime": jsxRuntime, }; const script = await buildFacet({ plugin: "/data/plugins/notes", facet: "worker.ts", hostPackages: Object.keys(HOST_PACKAGES), platform: "node" }); const facet = evaluateFacet(script, { hostPackages: HOST_PACKAGES, require: createRequire(import.meta.url), dir: "/data/plugins/notes" }); await host.reload([{ name: "@pi/notes", facet }], ctx); // or host.load the first time ``` - `buildFacet` is esbuild with: the facet as entry, the host packages and `node:*` external, `format: "cjs"` wrapped as the function above, minified, an `onResolve` that refuses anything outside the plugin, its `node_modules`, a contract of another plugin or a host package. It returns the script, its content hash and esbuild's metafile (the dev loop knows which files a facet used). - `evaluateFacet` runs the script with `vm.Script` and a stable filename per (facet, hash) on Node, or as a classic `