diff --git a/package-lock.json b/package-lock.json index 3bc401b29..d67889780 100644 --- a/package-lock.json +++ b/package-lock.json @@ -832,7 +832,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -849,7 +848,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -866,7 +864,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -883,7 +880,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -900,7 +896,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -917,7 +912,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -934,7 +928,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -951,7 +944,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -968,7 +960,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -985,7 +976,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1002,7 +992,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1019,7 +1008,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1036,7 +1024,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1053,7 +1040,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1070,7 +1056,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1087,7 +1072,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1104,7 +1088,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1121,7 +1104,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1138,7 +1120,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1155,7 +1136,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1172,7 +1152,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1189,7 +1168,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1206,7 +1184,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1223,7 +1200,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1240,7 +1216,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1257,7 +1232,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2814,7 +2788,6 @@ "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -5394,6 +5367,9 @@ "name": "@earendil-works/chord", "version": "0.84.4", "license": "MIT", + "dependencies": { + "esbuild": "0.28.1" + }, "devDependencies": { "shx": "0.4.0", "vitest": "4.1.9" diff --git a/packages/chord/PLANNING.md b/packages/chord/PLANNING.md index 411c1f155..545b61a48 100644 --- a/packages/chord/PLANNING.md +++ b/packages/chord/PLANNING.md @@ -1,6 +1,6 @@ # Chord implementation plan -> **Status:** Active implementation plan. Context, strict JSON, replicated state, service publication/consumption, and the facet host/loader now live in Chord. Symmetric RPC, generation loading, and bundling remain planned. This is not a stable public API contract yet. +> **Status:** Active implementation plan. Context, strict JSON, replicated state, service publication/consumption, the facet host/loader, and initial Node facet bundling and generation loading now live in Chord. Symmetric RPC and structural generation replacement remain planned. This is not a stable public API contract yet. ## 1. Goal diff --git a/packages/chord/README.md b/packages/chord/README.md index fa2ffe7c6..5bd1872da 100644 --- a/packages/chord/README.md +++ b/packages/chord/README.md @@ -57,5 +57,49 @@ the root API. Chord-owned identifiers use the `chord.*` namespace and its reserved service prefix is `$chord.*`. -See [PLANNING.md](PLANNING.md) for the broader RPC, generation-loading, and -bundling architecture. +## Bundling and loading facets + +`@earendil-works/chord/bundler` uses esbuild to turn opaque application entry +names into independent, content-addressed ESM files. It bundles dependencies by +default and always externalizes Chord so every facet shares the host's runtime +and service branding: + +```ts +import { bundleFacets } from "@earendil-works/chord/bundler"; + +await bundleFacets({ + plugin: { id: "example-plugin", version: "1.0.0" }, + entries: { worker: "src/worker.ts", presentation: "src/presentation.ts" }, + outdir: "dist/facets", + sourceMap: true, +}); +``` + +The output directory contains one `.js` file per entry plus +`chord-facets.json`. Load one application-selected entry through the Node-only +loader: + +```ts +import { createFacetBundleLoader } from "@earendil-works/chord/node"; + +const loader = createFacetBundleLoader({ + manifestPath: "dist/facets/chord-facets.json", + entry: "worker", +}); +const loaded = await loader.load(); +``` + +Each `load()` verifies SHA-256 integrity and evaluates a fresh ESM generation. +For transport to another Node host, `readFacetBundleArtifact()` packages one +verified manifest entry with its source, and `createFacetBundleArtifactLoader()` +materializes fresh temporary generations while resolving externals against the +receiving host. + +To reload, load a candidate, pass its facets to `FacetHost.reload()`, dispose the +candidate on failure, and dispose the retired `LoadedFacets` only after a +successful cutover. Reload retains the host's existing shape-preservation rules. +The bundler writes a complete temporary directory before replacing the previous +output, so loaders do not observe partially built generations. + +See [PLANNING.md](PLANNING.md) for the broader RPC and generation-loading +architecture. diff --git a/packages/chord/package.json b/packages/chord/package.json index 5a0d6d3f4..8a0287af9 100644 --- a/packages/chord/package.json +++ b/packages/chord/package.json @@ -16,6 +16,16 @@ "types": "./dist/context/index.d.ts", "import": "./dist/context/index.js" }, + "./bundler": { + "source": "./src/bundler.ts", + "types": "./dist/bundler.d.ts", + "import": "./dist/bundler.js" + }, + "./node": { + "source": "./src/node.ts", + "types": "./dist/node.d.ts", + "import": "./dist/node.js" + }, "./package.json": "./package.json" }, "sideEffects": false, @@ -46,6 +56,9 @@ "engines": { "node": ">=22.19.0" }, + "dependencies": { + "esbuild": "0.28.1" + }, "devDependencies": { "shx": "0.4.0", "vitest": "4.1.9" diff --git a/packages/chord/src/bundler.ts b/packages/chord/src/bundler.ts new file mode 100644 index 000000000..c57340750 --- /dev/null +++ b/packages/chord/src/bundler.ts @@ -0,0 +1,7 @@ +export type { + BundleFacetsOptions, + BundleFacetsResult, + FacetBundlePlatform, +} from "./node/bundle.ts"; +export { bundleFacets } from "./node/bundle.ts"; +export type { FacetBundleEntry, FacetBundleManifest } from "./node/manifest.ts"; diff --git a/packages/chord/src/node.ts b/packages/chord/src/node.ts new file mode 100644 index 000000000..083248b3a --- /dev/null +++ b/packages/chord/src/node.ts @@ -0,0 +1,20 @@ +export type { FacetBundleArtifactLoaderOptions, FacetBundleLoaderOptions } from "./node/bundle-loader.ts"; +export { + createFacetBundleArtifactLoader, + createFacetBundleLoader, + readFacetBundleArtifact, + readFacetBundleManifest, +} from "./node/bundle-loader.ts"; +export type { + FacetBundleArtifact, + FacetBundleEntry, + FacetBundleManifest, + FacetBundlePlugin, +} from "./node/manifest.ts"; +export { + FACET_BUNDLE_ARTIFACT_FORMAT, + FACET_BUNDLE_ARTIFACT_FORMAT_VERSION, + FACET_BUNDLE_FORMAT, + FACET_BUNDLE_FORMAT_VERSION, + FACET_BUNDLE_MANIFEST_FILE, +} from "./node/manifest.ts"; diff --git a/packages/chord/src/node/bundle-loader.ts b/packages/chord/src/node/bundle-loader.ts new file mode 100644 index 000000000..646f84091 --- /dev/null +++ b/packages/chord/src/node/bundle-loader.ts @@ -0,0 +1,415 @@ +import { createHash, randomUUID } from "node:crypto"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { isBuiltin } from "node:module"; +import { tmpdir } from "node:os"; +import { basename, dirname, isAbsolute, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import type { Facet, FacetLoader, LoadedFacets } from "../types.ts"; +import { + FACET_BUNDLE_ARTIFACT_FORMAT, + FACET_BUNDLE_ARTIFACT_FORMAT_VERSION, + FACET_BUNDLE_FORMAT, + FACET_BUNDLE_FORMAT_VERSION, + FACET_BUNDLE_MANIFEST_FILE, + type FacetBundleArtifact, + type FacetBundleEntry, + type FacetBundleManifest, +} from "./manifest.ts"; + +export interface FacetBundleLoaderOptions { + readonly manifestPath: string | URL; + readonly entry: string; + /** Verify the entry's SHA-256 integrity before evaluating it. Defaults to true. */ + readonly verifyIntegrity?: boolean; +} + +export interface FacetBundleArtifactLoaderOptions { + readonly artifact: unknown; + /** Parent directory for materialized module generations. Defaults to the operating system temp directory. */ + readonly temporaryDirectory?: string; +} + +type DynamicImport = (specifier: string) => Promise; + +// ESM has no static API for a runtime-selected module. Keeping the dynamic import here also keeps +// Node-specific loading out of Chord's platform-neutral root entry. +const importModule: DynamicImport = (specifier) => import(specifier); + +/** Read and validate a versioned facet bundle manifest. */ +export async function readFacetBundleManifest(path: string | URL): Promise { + const manifestPath = toFilePath(path); + let parsed: unknown; + try { + parsed = JSON.parse(await readFile(manifestPath, "utf8")); + } catch (error) { + throw new Error(`Could not read facet bundle manifest ${manifestPath}`, { cause: error }); + } + return validateManifest(parsed, manifestPath); +} + +/** Read and verify one transportable entry from a facet bundle on disk. */ +export async function readFacetBundleArtifact(options: { + readonly manifestPath: string | URL; + readonly entry: string; +}): Promise { + if (options.entry.length === 0) throw new TypeError("Facet bundle entry name must not be empty"); + const manifestPath = toFilePath(options.manifestPath); + const manifest = await readFacetBundleManifest(manifestPath); + const entry = manifest.entries[options.entry]; + if (entry === undefined) throw new Error(`Facet bundle ${manifest.plugin.id} has no entry named ${options.entry}`); + const modulePath = resolveBundleFile(manifestPath, entry.file, "entry"); + const source = await readFile(modulePath, "utf8"); + verifySource(source, entry); + const sourceMapContents = + entry.sourceMap === undefined + ? undefined + : await readFile(resolveBundleFile(manifestPath, entry.sourceMap, "source map"), "utf8"); + return Object.freeze({ + format: FACET_BUNDLE_ARTIFACT_FORMAT, + formatVersion: FACET_BUNDLE_ARTIFACT_FORMAT_VERSION, + plugin: manifest.plugin, + entryName: options.entry, + entry, + source, + ...(sourceMapContents === undefined ? {} : { sourceMapContents }), + }); +} + +/** Materialize a transported artifact and create a fresh Node ESM generation for each load. */ +export function createFacetBundleArtifactLoader(options: FacetBundleArtifactLoaderOptions): FacetLoader { + const artifact = validateArtifact(options.artifact); + const temporaryParent = resolve(options.temporaryDirectory ?? tmpdir()); + return { + async load(): Promise { + await mkdir(temporaryParent, { recursive: true }); + const directory = await mkdtemp(join(temporaryParent, "chord-facet-")); + try { + await materializeArtifact(directory, artifact); + const loaded = await createFacetBundleLoader({ + manifestPath: join(directory, FACET_BUNDLE_MANIFEST_FILE), + entry: artifact.entryName, + }).load(); + let disposed = false; + return { + get facets() { + return loaded.facets; + }, + async dispose() { + if (disposed) return; + disposed = true; + const errors: unknown[] = []; + try { + await loaded.dispose(); + } catch (error) { + errors.push(error); + } + try { + await rm(directory, { force: true, recursive: true }); + } catch (error) { + errors.push(error); + } + if (errors.length === 1) throw errors[0]; + if (errors.length > 1) throw new AggregateError(errors, "Failed to dispose facet bundle artifact"); + }, + }; + } catch (error) { + try { + await rm(directory, { force: true, recursive: true }); + } catch (cleanupError) { + throw new AggregateError([error, cleanupError], "Facet bundle artifact loading and cleanup failed"); + } + throw error; + } + }, + }; +} + +/** Create a reusable loader for one opaque entry in a facet bundle manifest. */ +export function createFacetBundleLoader(options: FacetBundleLoaderOptions): FacetLoader { + if (options.entry.length === 0) throw new TypeError("Facet bundle entry name must not be empty"); + const manifestPath = toFilePath(options.manifestPath); + const loaderId = randomUUID(); + let generation = 0; + return { + async load(): Promise { + const manifest = await readFacetBundleManifest(manifestPath); + const entry = manifest.entries[options.entry]; + if (entry === undefined) { + throw new Error(`Facet bundle ${manifest.plugin.id} has no entry named ${options.entry}`); + } + const modulePath = resolveBundleFile(manifestPath, entry.file, "entry"); + if (options.verifyIntegrity !== false) await verifyEntry(modulePath, entry); + const moduleUrl = pathToFileURL(modulePath); + moduleUrl.searchParams.set("chordGeneration", `${loaderId}-${++generation}`); + let imported: unknown; + try { + imported = await importModule(moduleUrl.href); + } catch (error) { + throw new Error(`Could not load facet bundle entry ${manifest.plugin.id}/${options.entry}`, { + cause: error, + }); + } + let facets = facetsFromModule(imported, manifest.plugin.id, options.entry); + let disposed = false; + return { + get facets() { + return facets; + }, + async dispose() { + if (disposed) return; + disposed = true; + facets = Object.freeze([]); + }, + }; + }, + }; +} + +function toFilePath(path: string | URL): string { + if (typeof path === "string") return resolve(path); + if (path.protocol !== "file:") throw new TypeError(`Facet bundle manifest must be a file URL, not ${path.protocol}`); + return fileURLToPath(path); +} + +function resolveBundleFile(manifestPath: string, file: string, label: string): string { + if (file.length === 0 || isAbsolute(file) || basename(file) !== file || file === "." || file === "..") { + throw new Error(`Facet bundle ${label} must be a filename relative to its manifest`); + } + return resolve(dirname(manifestPath), file); +} + +async function verifyEntry(path: string, entry: FacetBundleEntry): Promise { + const contents = await readFile(path); + const expected = parseIntegrity(entry.integrity); + const actual = createHash("sha256").update(contents).digest("base64"); + if (actual !== expected) throw new Error(`Facet bundle integrity check failed for ${entry.file}`); +} + +function verifySource(source: string, entry: FacetBundleEntry): void { + const expected = parseIntegrity(entry.integrity); + const actual = createHash("sha256").update(source, "utf8").digest("base64"); + if (actual !== expected) throw new Error(`Facet bundle integrity check failed for ${entry.file}`); +} + +function parseIntegrity(integrity: string): string { + const prefix = "sha256-"; + if (!integrity.startsWith(prefix) || integrity.length === prefix.length) { + throw new Error("Facet bundle entry has an invalid SHA-256 integrity value"); + } + return integrity.slice(prefix.length); +} + +function facetsFromModule(imported: unknown, pluginId: string, entryName: string): readonly Facet[] { + if (!isRecord(imported)) throw new Error(`Facet bundle entry ${pluginId}/${entryName} did not export a module`); + const exported = imported.default; + const candidates: readonly unknown[] = Array.isArray(exported) ? (exported as readonly unknown[]) : [exported]; + if (candidates.length === 0) { + throw new Error(`Facet bundle entry ${pluginId}/${entryName} exported no facets`); + } + const facets: Facet[] = []; + for (const candidate of candidates) { + if (!isRecord(candidate) || typeof candidate.id !== "string" || candidate.id.length === 0) { + throw new Error(`Facet bundle entry ${pluginId}/${entryName} has a facet with an invalid ID`); + } + if (typeof candidate.setup !== "function") { + throw new Error(`Facet bundle entry ${pluginId}/${entryName} facet ${candidate.id} has no setup function`); + } + facets.push(candidate as unknown as Facet); + } + const ids = facets.map(({ id }) => id); + if (new Set(ids).size !== ids.length) { + throw new Error(`Facet bundle entry ${pluginId}/${entryName} exports duplicate facet IDs`); + } + return Object.freeze(facets); +} + +function validateArtifact(value: unknown): FacetBundleArtifact { + if ( + !isRecord(value) || + value.format !== FACET_BUNDLE_ARTIFACT_FORMAT || + value.formatVersion !== FACET_BUNDLE_ARTIFACT_FORMAT_VERSION || + typeof value.entryName !== "string" || + value.entryName.length === 0 || + typeof value.source !== "string" + ) { + throw new Error("Invalid facet bundle artifact"); + } + const manifest = validateManifest( + { + format: FACET_BUNDLE_FORMAT, + formatVersion: FACET_BUNDLE_FORMAT_VERSION, + plugin: value.plugin, + entries: { [value.entryName]: value.entry }, + }, + "facet bundle artifact", + ); + const entry = manifest.entries[value.entryName]!; + if (entry.sourceMap === undefined) { + if (value.sourceMapContents !== undefined) { + throw new Error("Facet bundle artifact has source map contents without a source map"); + } + } else if (typeof value.sourceMapContents !== "string") { + throw new Error("Facet bundle artifact is missing its source map contents"); + } + verifySource(value.source, entry); + return Object.freeze({ + format: FACET_BUNDLE_ARTIFACT_FORMAT, + formatVersion: FACET_BUNDLE_ARTIFACT_FORMAT_VERSION, + plugin: manifest.plugin, + entryName: value.entryName, + entry, + source: value.source, + ...(value.sourceMapContents === undefined ? {} : { sourceMapContents: value.sourceMapContents }), + }); +} + +async function materializeArtifact(directory: string, artifact: FacetBundleArtifact): Promise { + const manifest: FacetBundleManifest = { + format: FACET_BUNDLE_FORMAT, + formatVersion: FACET_BUNDLE_FORMAT_VERSION, + plugin: artifact.plugin, + entries: { [artifact.entryName]: artifact.entry }, + }; + await Promise.all([ + writeFile(join(directory, artifact.entry.file), artifact.source, "utf8"), + writeFile(join(directory, FACET_BUNDLE_MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`, "utf8"), + ...(artifact.entry.sourceMap === undefined + ? [] + : [writeFile(join(directory, artifact.entry.sourceMap), artifact.sourceMapContents!, "utf8")]), + materializeExternalProxies(directory, artifact.entry.externalImports), + ]); +} + +async function materializeExternalProxies(directory: string, specifiers: readonly string[]): Promise { + const packages = new Map>(); + for (const specifier of specifiers) { + if (isBuiltin(specifier)) continue; + const parsed = parsePackageSpecifier(specifier); + let entries = packages.get(parsed.packageName); + if (entries === undefined) { + entries = new Map(); + packages.set(parsed.packageName, entries); + } + entries.set(parsed.exportName, resolveExternalTarget(specifier)); + } + for (const [packageName, entries] of packages) { + const packageDirectory = join(directory, "node_modules", ...packageName.split("/")); + await mkdir(packageDirectory, { recursive: true }); + const exports: Record = {}; + const writes: Promise[] = []; + let index = 0; + for (const [exportName, target] of entries) { + const file = `proxy-${index++}.js`; + exports[exportName] = `./${file}`; + writes.push( + writeFile( + join(packageDirectory, file), + `import * as target from ${JSON.stringify(target)};\nexport * from ${JSON.stringify(target)};\nexport default target.default;\n`, + "utf8", + ), + ); + } + writes.push( + writeFile( + join(packageDirectory, "package.json"), + `${JSON.stringify({ name: packageName, type: "module", exports }, null, 2)}\n`, + "utf8", + ), + ); + await Promise.all(writes); + } +} + +function resolveExternalTarget(specifier: string): string { + const extension = import.meta.url.endsWith(".ts") ? "ts" : "js"; + if (specifier === "@earendil-works/chord") return new URL(`../index.${extension}`, import.meta.url).href; + if (specifier === "@earendil-works/chord/context") { + return new URL(`../context/index.${extension}`, import.meta.url).href; + } + if (specifier === "@earendil-works/chord/node") return new URL(`../node.${extension}`, import.meta.url).href; + return import.meta.resolve(specifier); +} + +function parsePackageSpecifier(specifier: string): { packageName: string; exportName: string } { + const segments = specifier.split("/"); + const packageParts = specifier.startsWith("@") ? 2 : 1; + if ( + specifier.startsWith(".") || + specifier.startsWith("/") || + specifier.startsWith("#") || + specifier.includes(":") || + specifier.includes("\\") || + segments.length < packageParts || + segments.some((part) => part.length === 0 || part === "." || part === "..") + ) { + throw new Error(`Facet bundle artifact has an unsupported external import: ${specifier}`); + } + const packageName = segments.slice(0, packageParts).join("/"); + const subpath = segments.slice(packageParts).join("/"); + return { packageName, exportName: subpath.length === 0 ? "." : `./${subpath}` }; +} + +function validateManifest(value: unknown, path: string): FacetBundleManifest { + if (!isRecord(value) || value.format !== FACET_BUNDLE_FORMAT) { + throw new Error(`Invalid facet bundle manifest format in ${path}`); + } + if (value.formatVersion !== FACET_BUNDLE_FORMAT_VERSION) { + throw new Error(`Unsupported facet bundle manifest version in ${path}: ${String(value.formatVersion)}`); + } + if (!isRecord(value.plugin) || typeof value.plugin.id !== "string" || value.plugin.id.length === 0) { + throw new Error(`Facet bundle manifest has an invalid plugin identity in ${path}`); + } + if ( + value.plugin.version !== undefined && + (typeof value.plugin.version !== "string" || value.plugin.version.length === 0) + ) { + throw new Error(`Facet bundle manifest has an invalid plugin version in ${path}`); + } + if (!isRecord(value.entries) || Object.keys(value.entries).length === 0) { + throw new Error(`Facet bundle manifest has no entries in ${path}`); + } + const entries: Record = {}; + for (const [name, candidate] of Object.entries(value.entries)) { + if (name.length === 0 || !isRecord(candidate)) { + throw new Error(`Facet bundle manifest has an invalid entry in ${path}`); + } + if (typeof candidate.file !== "string") throw new Error(`Facet bundle entry ${name} has no file`); + resolveBundleFile(path, candidate.file, `entry ${name}`); + if (typeof candidate.integrity !== "string") throw new Error(`Facet bundle entry ${name} has no integrity`); + parseIntegrity(candidate.integrity); + if ( + !Array.isArray(candidate.externalImports) || + candidate.externalImports.some((item) => typeof item !== "string") + ) { + throw new Error(`Facet bundle entry ${name} has invalid external imports`); + } + const externalImports = Object.freeze([...(candidate.externalImports as readonly string[])]); + if (new Set(externalImports).size !== externalImports.length) { + throw new Error(`Facet bundle entry ${name} has duplicate external imports`); + } + if (candidate.sourceMap !== undefined) { + if (typeof candidate.sourceMap !== "string") + throw new Error(`Facet bundle entry ${name} has an invalid source map`); + resolveBundleFile(path, candidate.sourceMap, `entry ${name} source map`); + } + entries[name] = Object.freeze({ + file: candidate.file, + integrity: candidate.integrity, + externalImports, + ...(candidate.sourceMap === undefined ? {} : { sourceMap: candidate.sourceMap }), + }); + } + return Object.freeze({ + format: FACET_BUNDLE_FORMAT, + formatVersion: FACET_BUNDLE_FORMAT_VERSION, + plugin: Object.freeze({ + id: value.plugin.id, + ...(value.plugin.version === undefined ? {} : { version: value.plugin.version }), + }), + entries: Object.freeze(entries), + }); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/chord/src/node/bundle.ts b/packages/chord/src/node/bundle.ts new file mode 100644 index 000000000..bc46ccd41 --- /dev/null +++ b/packages/chord/src/node/bundle.ts @@ -0,0 +1,218 @@ +import { createHash, randomUUID } from "node:crypto"; +import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; +import { basename, dirname, extname, join, relative, resolve, sep } from "node:path"; +import { type BuildOptions, build, type Message } from "esbuild"; +import { + FACET_BUNDLE_FORMAT, + FACET_BUNDLE_FORMAT_VERSION, + FACET_BUNDLE_MANIFEST_FILE, + type FacetBundleEntry, + type FacetBundleManifest, +} from "./manifest.ts"; + +export type FacetBundlePlatform = "node" | "browser" | "neutral"; + +export interface BundleFacetsOptions { + readonly plugin: { + readonly id: string; + readonly version?: string; + }; + /** Opaque application-selected entry names mapped to TypeScript or JavaScript source files. */ + readonly entries: Readonly>; + readonly outdir: string; + readonly workingDirectory?: string; + /** Additional package imports intentionally left for the loading application to resolve. */ + readonly external?: readonly string[]; + readonly sourceMap?: boolean; + readonly minify?: boolean; + readonly define?: Readonly>; + readonly platform?: FacetBundlePlatform; + readonly target?: string | readonly string[]; +} + +export interface BundleFacetsResult { + readonly manifest: FacetBundleManifest; + readonly manifestPath: string; +} + +/** Bundle each opaque facet entry into an independent content-addressed ESM file. */ +export async function bundleFacets(options: BundleFacetsOptions): Promise { + validateOptions(options); + const workingDirectory = resolve(options.workingDirectory ?? process.cwd()); + const outputDirectory = resolve(workingDirectory, options.outdir); + const outputParent = dirname(outputDirectory); + await mkdir(outputParent, { recursive: true }); + const temporaryDirectory = join(outputParent, `.${basename(outputDirectory)}.tmp-${randomUUID()}`); + await mkdir(temporaryDirectory); + try { + const entries: Record = {}; + for (const [entryName, source] of Object.entries(options.entries).sort(([left], [right]) => + left.localeCompare(right), + )) { + entries[entryName] = await bundleEntry({ + entryName, + source: resolve(workingDirectory, source), + temporaryDirectory, + workingDirectory, + options, + }); + } + const manifest: FacetBundleManifest = Object.freeze({ + format: FACET_BUNDLE_FORMAT, + formatVersion: FACET_BUNDLE_FORMAT_VERSION, + plugin: Object.freeze({ + id: options.plugin.id, + ...(options.plugin.version === undefined ? {} : { version: options.plugin.version }), + }), + entries: Object.freeze(entries), + }); + await writeFile(join(temporaryDirectory, FACET_BUNDLE_MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`); + await replaceDirectory(temporaryDirectory, outputDirectory); + return Object.freeze({ + manifest, + manifestPath: join(outputDirectory, FACET_BUNDLE_MANIFEST_FILE), + }); + } catch (error) { + await rm(temporaryDirectory, { force: true, recursive: true }); + throw error; + } +} + +async function bundleEntry(input: { + readonly entryName: string; + readonly source: string; + readonly temporaryDirectory: string; + readonly workingDirectory: string; + readonly options: BundleFacetsOptions; +}): Promise { + const entryPrefix = `facet-${shortHash(input.entryName)}`; + const buildOptions: BuildOptions = { + absWorkingDir: input.workingDirectory, + bundle: true, + define: input.options.define, + entryNames: `${entryPrefix}-[hash]`, + entryPoints: [input.source], + external: [...new Set(["@earendil-works/chord", "@earendil-works/chord/*", ...(input.options.external ?? [])])], + format: "esm", + legalComments: "none", + logLevel: "silent", + metafile: true, + minify: input.options.minify ?? false, + outdir: input.temporaryDirectory, + platform: input.options.platform ?? "node", + sourcemap: input.options.sourceMap === true ? "external" : false, + target: + input.options.target === undefined + ? input.options.platform === undefined || input.options.platform === "node" + ? "node22.19" + : "es2022" + : typeof input.options.target === "string" + ? input.options.target + : [...input.options.target], + write: true, + }; + let result: Awaited>; + try { + result = await build(buildOptions); + } catch (error) { + const diagnostics = esbuildMessages(error); + const detail = diagnostics.length === 0 ? "" : `\n${diagnostics.join("\n")}`; + throw new Error(`Could not bundle facet entry ${input.entryName}${detail}`, { cause: error }); + } + const metafile = result.metafile; + if (metafile === undefined) throw new Error(`Facet entry ${input.entryName} produced no build metadata`); + const outputs = Object.entries(metafile.outputs).filter( + ([path, output]) => output.entryPoint !== undefined && extname(path) === ".js", + ); + if (outputs.length !== 1) + throw new Error(`Facet entry ${input.entryName} did not produce exactly one JavaScript file`); + const [outputPath, metadata] = outputs[0]!; + const absoluteOutputPath = resolve(input.workingDirectory, outputPath); + const file = relative(input.temporaryDirectory, absoluteOutputPath); + if (file.length === 0 || file.startsWith(`..${sep}`) || basename(file) !== file) { + throw new Error(`Facet entry ${input.entryName} produced an invalid output path`); + } + const sourceMap = input.options.sourceMap === true ? `${file}.map` : undefined; + const allowedOutputs = new Set([absoluteOutputPath]); + if (sourceMap !== undefined) allowedOutputs.add(join(input.temporaryDirectory, sourceMap)); + const unexpectedOutputs = Object.keys(metafile.outputs) + .map((path) => resolve(input.workingDirectory, path)) + .filter((path) => !allowedOutputs.has(path)); + if (unexpectedOutputs.length > 0) { + throw new Error(`Facet entry ${input.entryName} produced files other than its JavaScript bundle and source map`); + } + const contents = await readFile(absoluteOutputPath); + if (sourceMap !== undefined) await stat(join(input.temporaryDirectory, sourceMap)); + return Object.freeze({ + file, + integrity: `sha256-${createHash("sha256").update(contents).digest("base64")}`, + externalImports: Object.freeze( + [...new Set(metadata.imports.filter(({ external }) => external).map(({ path }) => path))].sort(), + ), + ...(sourceMap === undefined ? {} : { sourceMap }), + }); +} + +function validateOptions(options: BundleFacetsOptions): void { + if (options.plugin.id.length === 0) throw new TypeError("Facet bundle plugin ID must not be empty"); + if (options.plugin.version !== undefined && options.plugin.version.length === 0) { + throw new TypeError("Facet bundle plugin version must not be empty"); + } + const entries = Object.entries(options.entries); + if (entries.length === 0) throw new TypeError("Facet bundle must contain at least one entry"); + for (const [name, source] of entries) { + if (name.length === 0) throw new TypeError("Facet bundle entry name must not be empty"); + if (source.length === 0) throw new TypeError(`Facet bundle entry ${name} must have a source path`); + } + for (const external of options.external ?? []) { + if (external.length === 0) throw new TypeError("Facet bundle external import must not be empty"); + } +} + +async function replaceDirectory(temporaryDirectory: string, outputDirectory: string): Promise { + const backupDirectory = `${outputDirectory}.old-${randomUUID()}`; + let movedExisting = false; + try { + await rename(outputDirectory, backupDirectory); + movedExisting = true; + } catch (error) { + if (!isMissingPath(error)) throw error; + } + try { + await rename(temporaryDirectory, outputDirectory); + } catch (error) { + if (movedExisting) await rename(backupDirectory, outputDirectory); + throw error; + } + if (movedExisting) await rm(backupDirectory, { force: true, recursive: true }); +} + +function shortHash(value: string): string { + return createHash("sha256").update(value).digest("hex").slice(0, 12); +} + +function esbuildMessages(error: unknown): string[] { + if (!isRecord(error) || !Array.isArray(error.errors)) return []; + return (error.errors as readonly unknown[]).flatMap((candidate) => + isEsbuildMessage(candidate) ? [formatEsbuildMessage(candidate)] : [], + ); +} + +function formatEsbuildMessage(message: Message): string { + const location = message.location; + return location === null + ? message.text + : `${location.file}:${location.line}:${location.column + 1}: ${message.text}`; +} + +function isEsbuildMessage(value: unknown): value is Message { + return isRecord(value) && typeof value.text === "string" && (value.location === null || isRecord(value.location)); +} + +function isMissingPath(error: unknown): boolean { + return isRecord(error) && error.code === "ENOENT"; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/chord/src/node/manifest.ts b/packages/chord/src/node/manifest.ts new file mode 100644 index 000000000..dd7130201 --- /dev/null +++ b/packages/chord/src/node/manifest.ts @@ -0,0 +1,39 @@ +export const FACET_BUNDLE_FORMAT = "chord.facet-bundle"; +export const FACET_BUNDLE_FORMAT_VERSION = 1; +export const FACET_BUNDLE_MANIFEST_FILE = "chord-facets.json"; +export const FACET_BUNDLE_ARTIFACT_FORMAT = "chord.facet-bundle-artifact"; +export const FACET_BUNDLE_ARTIFACT_FORMAT_VERSION = 1; + +export interface FacetBundleEntry { + /** Content-addressed JavaScript filename relative to the manifest. */ + readonly file: string; + /** SHA-256 subresource-integrity value for the JavaScript file. */ + readonly integrity: string; + /** Imports intentionally left for the loading application to resolve. */ + readonly externalImports: readonly string[]; + /** Source map filename relative to the manifest, when emitted. */ + readonly sourceMap?: string; +} + +export interface FacetBundleManifest { + readonly format: typeof FACET_BUNDLE_FORMAT; + readonly formatVersion: typeof FACET_BUNDLE_FORMAT_VERSION; + readonly plugin: FacetBundlePlugin; + readonly entries: Readonly>; +} + +export interface FacetBundlePlugin { + readonly id: string; + readonly version?: string; +} + +/** One self-contained manifest entry suitable for storage or transport to another Node host. */ +export interface FacetBundleArtifact { + readonly format: typeof FACET_BUNDLE_ARTIFACT_FORMAT; + readonly formatVersion: typeof FACET_BUNDLE_ARTIFACT_FORMAT_VERSION; + readonly plugin: FacetBundlePlugin; + readonly entryName: string; + readonly entry: FacetBundleEntry; + readonly source: string; + readonly sourceMapContents?: string; +} diff --git a/packages/chord/test/bundle.test.ts b/packages/chord/test/bundle.test.ts new file mode 100644 index 000000000..2dab71c9f --- /dev/null +++ b/packages/chord/test/bundle.test.ts @@ -0,0 +1,149 @@ +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, test } from "vitest"; +import { bundleFacets } from "../src/bundler.ts"; +import { createFacetHost, defineFacet, defineService } from "../src/index.ts"; +import { + createFacetBundleArtifactLoader, + createFacetBundleLoader, + readFacetBundleArtifact, + readFacetBundleManifest, +} from "../src/node.ts"; + +interface GenerationValue { + read(): string; +} + +const packageDirectory = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const temporaryDirectories: string[] = []; +const GenerationValue = defineService("test.bundle.generation", { local: true }); + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { force: true, recursive: true }))); +}); + +describe("facet bundles", () => { + test("builds independent content-addressed entries and loads fresh reloadable generations", async () => { + const directory = await mkdtemp(join(packageDirectory, ".bundle-test-")); + temporaryDirectories.push(directory); + const sourceDirectory = join(directory, "src"); + const outputDirectory = join(directory, "bundle"); + await mkdir(sourceDirectory); + await writeFile( + join(sourceDirectory, "helper.ts"), + 'export const decorate = (value: string): string => "generation:" + value;\n', + ); + const entryPath = join(sourceDirectory, "entry.ts"); + const presentationPath = join(sourceDirectory, "presentation.ts"); + await writeGeneration(entryPath, "A"); + await writeFile(presentationPath, 'export default { id: "bundle-presentation", setup() {} };\n'); + const facetEntries = { presentation: presentationPath, worker: entryPath }; + + const firstBuild = await bundleFacets({ + plugin: { id: "test-bundle", version: "1" }, + entries: facetEntries, + outdir: outputDirectory, + sourceMap: true, + }); + const firstEntry = firstBuild.manifest.entries.worker!; + expect(firstEntry.file).toMatch(/^facet-[a-f0-9]{12}-[A-Z0-9]+\.js$/u); + expect(firstEntry.sourceMap).toBe(`${firstEntry.file}.map`); + expect(firstEntry.externalImports).toEqual(["@earendil-works/chord"]); + expect(firstBuild.manifest.entries.presentation!.file).not.toBe(firstEntry.file); + expect((await readdir(outputDirectory)).filter((path) => path.endsWith(".js"))).toHaveLength(2); + expect((await readFile(firstBuild.manifestPath, "utf8")).endsWith("\n")).toBe(true); + + const presentation = await createFacetBundleLoader({ + manifestPath: firstBuild.manifestPath, + entry: "presentation", + }).load(); + expect(presentation.facets.map(({ id }) => id)).toEqual(["bundle-presentation"]); + await presentation.dispose(); + + const artifact = await readFacetBundleArtifact({ + manifestPath: firstBuild.manifestPath, + entry: "worker", + }); + const materializedDirectory = join(directory, "materialized"); + const transported = await createFacetBundleArtifactLoader({ + artifact: structuredClone(artifact), + temporaryDirectory: materializedDirectory, + }).load(); + expect(transported.facets.map(({ id }) => id)).toEqual(["bundle-provider"]); + expect(await readdir(materializedDirectory)).toHaveLength(1); + await transported.dispose(); + expect(await readdir(materializedDirectory)).toEqual([]); + + const secondBuild = await bundleFacets({ + plugin: { id: "test-bundle", version: "1" }, + entries: facetEntries, + outdir: outputDirectory, + sourceMap: true, + }); + expect(secondBuild.manifest.entries.worker).toEqual(firstEntry); + + const loader = createFacetBundleLoader({ manifestPath: secondBuild.manifestPath, entry: "worker" }); + const loadedA = await loader.load(); + const loadedACopy = await loader.load(); + expect(loadedACopy.facets[0]).not.toBe(loadedA.facets[0]); + await loadedACopy.dispose(); + + let retained: GenerationValue | undefined; + const consumer = defineFacet({ + id: "bundle-consumer", + setup(env) { + retained = env.use(GenerationValue); + }, + }); + const host = await createFacetHost({ facets: [consumer, ...loadedA.facets] }); + expect(retained!.read()).toBe("generation:A"); + + await writeGeneration(entryPath, "B"); + const thirdBuild = await bundleFacets({ + plugin: { id: "test-bundle", version: "2" }, + entries: facetEntries, + outdir: outputDirectory, + sourceMap: true, + }); + expect(thirdBuild.manifest.entries.worker!.file).not.toBe(firstEntry.file); + const loadedB = await loader.load(); + await host.reload(loadedB.facets); + await loadedA.dispose(); + expect(retained!.read()).toBe("generation:B"); + + await host.dispose(); + await loadedB.dispose(); + }); + + test("rejects corrupt entries and invalid module exports", async () => { + const directory = await mkdtemp(join(packageDirectory, ".bundle-test-")); + temporaryDirectories.push(directory); + const entryPath = join(directory, "entry.ts"); + const outputDirectory = join(directory, "bundle"); + await writeFile(entryPath, "export default { id: 'missing-setup' };\n"); + const result = await bundleFacets({ + plugin: { id: "invalid-bundle" }, + entries: { invalid: entryPath }, + outdir: outputDirectory, + }); + const loader = createFacetBundleLoader({ manifestPath: result.manifestPath, entry: "invalid" }); + await expect(loader.load()).rejects.toThrow("has no setup function"); + + const manifest = await readFacetBundleManifest(result.manifestPath); + await writeFile(join(outputDirectory, manifest.entries.invalid!.file), "export default {};\n"); + await expect(loader.load()).rejects.toThrow("integrity check failed"); + }); +}); + +async function writeGeneration(path: string, generation: string): Promise { + await writeFile( + path, + `import "@earendil-works/chord";\n` + + `import { decorate } from "./helper.ts";\n` + + `const Value = { id: "test.bundle.generation", local: true };\n` + + `export default { id: "bundle-provider", setup(env) {\n` + + ` env.provide(Value, { read() { return decorate(${JSON.stringify(generation)}); } });\n` + + `}};\n`, + ); +} diff --git a/packages/coding-agent/examples/README.md b/packages/coding-agent/examples/README.md index 875051843..ddab5268f 100644 --- a/packages/coding-agent/examples/README.md +++ b/packages/coding-agent/examples/README.md @@ -18,6 +18,9 @@ Example extensions demonstrating: - External integrations (SSH, file watchers, system theme sync) - Custom providers (Anthropic with custom streaming, GitLab Duo) +### [facets/](facets/) +An experimental Chord plugin bundled into separate Session-worker and TUI facet files. + ## Documentation - [SDK Reference](sdk/README.md) diff --git a/packages/coding-agent/examples/facets/README.md b/packages/coding-agent/examples/facets/README.md new file mode 100644 index 000000000..86eef4d7c --- /dev/null +++ b/packages/coding-agent/examples/facets/README.md @@ -0,0 +1,27 @@ +# Experimental bundled facets + +This example builds one plugin into independent `session` and `tui` facet bundles. The Session-worker facet provides a remote greeting service. The TUI facet contributes `/facet-hello` and calls that service. + +From the repository root: + +```bash +node --conditions=source packages/coding-agent/examples/facets/build.mjs +``` + +The build script is plain JavaScript and imports the public `@earendil-works/chord/bundler` entry. The `source` condition selects workspace source in a repository checkout; an installed Chord package uses its compiled `import` export. Node's built-in erasable TypeScript support executes the selected workspace source, while esbuild compiles the facet entries. Neither path uses tsx or jiti. + +The build prints the manifest path. Configure the server with that path: + +```bash +PI_EXPERIMENTAL=1 \ +PI_EXPERIMENTAL_FACET_BUNDLE="$PWD/packages/coding-agent/examples/facets/dist/chord-facets.json" \ +./pi-test.sh server +``` + +The server selects the `tui` artifact and sends it to clients during the protocol handshake. The manifest path is stored in the logical server profile, so later automatically activated server generations retain the selection after an idle server retires. Set `PI_EXPERIMENTAL_FACET_BUNDLE=` explicitly when starting a generation to clear it. The client therefore needs no plugin configuration: + +```bash +PI_EXPERIMENTAL=1 ./pi-test.sh client +``` + +Run `/facet-hello Armin` in the TUI. Chord loads `session` in the Session worker and the server-selected `tui` artifact in the presentation. After editing and rebuilding the facets, run `/reload` in each connected TUI that should cut over to the new presentation generation. The command also reloads the attached Session-worker generation and updates the server artifact advertised to future clients. Rebuilding produces content-addressed files; repeated `FacetLoader.load()` calls use fresh module generations suitable for `FacetHost.reload()`. diff --git a/packages/coding-agent/examples/facets/build.mjs b/packages/coding-agent/examples/facets/build.mjs new file mode 100644 index 000000000..55254c137 --- /dev/null +++ b/packages/coding-agent/examples/facets/build.mjs @@ -0,0 +1,16 @@ +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { bundleFacets } from "@earendil-works/chord/bundler"; + +const directory = dirname(fileURLToPath(import.meta.url)); +const result = await bundleFacets({ + plugin: { id: "@pi/example-bundled", version: "1" }, + entries: { + session: join(directory, "session.ts"), + tui: join(directory, "tui.ts"), + }, + outdir: join(directory, "dist"), + sourceMap: true, +}); + +console.log(result.manifestPath); diff --git a/packages/coding-agent/examples/facets/contract.ts b/packages/coding-agent/examples/facets/contract.ts new file mode 100644 index 000000000..47e4f3015 --- /dev/null +++ b/packages/coding-agent/examples/facets/contract.ts @@ -0,0 +1,13 @@ +import { type Context, defineService, type ReplicatedState } from "@earendil-works/chord"; + +export interface ExampleFacetReply { + readonly message: string; + readonly workerActivations: number; +} + +export interface ExampleFacetService { + readonly workerActivations: ReplicatedState; + greet(input: { readonly name: string }, context: Context): Promise; +} + +export const ExampleFacetService = defineService("pi.example.bundled-facet"); diff --git a/packages/coding-agent/examples/facets/session.ts b/packages/coding-agent/examples/facets/session.ts new file mode 100644 index 000000000..aa39b31b3 --- /dev/null +++ b/packages/coding-agent/examples/facets/session.ts @@ -0,0 +1,22 @@ +import { defineFacet } from "@earendil-works/chord"; +import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; +import { ExampleFacetService } from "./contract.ts"; + +export default defineFacet({ + id: "@pi/example-bundled:session", + setup(env) { + const workerActivations = env.replicatedState(0); + env.provide(ExampleFacetService, { + workerActivations, + async greet({ name }) { + return { + message: `Hello ${name} from the bundled Session worker facet.`, + workerActivations: workerActivations.value, + }; + }, + }); + env.onActivate(() => { + workerActivations.set(workerActivations.value + 1, BACKGROUND_CONTEXT); + }); + }, +}); diff --git a/packages/coding-agent/examples/facets/tui.ts b/packages/coding-agent/examples/facets/tui.ts new file mode 100644 index 000000000..97b298f77 --- /dev/null +++ b/packages/coding-agent/examples/facets/tui.ts @@ -0,0 +1,30 @@ +import { defineFacet } from "@earendil-works/chord"; +import { AgentController } from "../../src/experimental/services/agent-controller.ts"; +import { PresentationUI } from "../../src/experimental/services/presentation-ui.ts"; +import { SlashCommands } from "../../src/experimental/services/slash-commands.ts"; +import { ExampleFacetService } from "./contract.ts"; + +export default defineFacet({ + id: "@pi/example-bundled:tui", + setup(env) { + const example = env.use(ExampleFacetService); + const commands = env.use(SlashCommands); + const controller = env.use(AgentController); + const ui = env.use(PresentationUI); + env.onActivate(() => { + env.own( + commands.register({ + name: "facet-hello", + description: "Call the bundled Session worker facet", + argumentHint: "", + async run(args, context) { + const message = args.length === 0 ? "from the TUI" : args; + const reply = await example.greet({ name: message }, context); + ui.showStatus(`${reply.message} Worker activations: ${reply.workerActivations}.`, context); + return controller.prompt({ message: `pong: ${message}`, images: null }, context); + }, + }), + ); + }); + }, +}); diff --git a/packages/coding-agent/install-lock/package-lock.json b/packages/coding-agent/install-lock/package-lock.json index 76cf6e642..fdd5f0a3d 100644 --- a/packages/coding-agent/install-lock/package-lock.json +++ b/packages/coding-agent/install-lock/package-lock.json @@ -453,6 +453,9 @@ "version": "0.84.4", "resolved": "https://registry.npmjs.org/@earendil-works/chord/-/chord-0.84.4.tgz", "license": "MIT", + "dependencies": { + "esbuild": "0.28.1" + }, "engines": { "node": ">=22.19.0" } @@ -578,6 +581,422 @@ "node": ">=22.19.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "aix" + ], + "cpu": [ + "ppc64" + ], + "optional": true + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "android" + ], + "cpu": [ + "arm" + ], + "optional": true + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "android" + ], + "cpu": [ + "arm64" + ], + "optional": true + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "android" + ], + "cpu": [ + "x64" + ], + "optional": true + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "darwin" + ], + "cpu": [ + "arm64" + ], + "optional": true + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "darwin" + ], + "cpu": [ + "x64" + ], + "optional": true + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "freebsd" + ], + "cpu": [ + "arm64" + ], + "optional": true + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "freebsd" + ], + "cpu": [ + "x64" + ], + "optional": true + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "linux" + ], + "cpu": [ + "arm" + ], + "optional": true + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "linux" + ], + "cpu": [ + "arm64" + ], + "optional": true + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "linux" + ], + "cpu": [ + "ia32" + ], + "optional": true + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "linux" + ], + "cpu": [ + "loong64" + ], + "optional": true + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "linux" + ], + "cpu": [ + "mips64el" + ], + "optional": true + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "linux" + ], + "cpu": [ + "ppc64" + ], + "optional": true + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "linux" + ], + "cpu": [ + "riscv64" + ], + "optional": true + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "linux" + ], + "cpu": [ + "s390x" + ], + "optional": true + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "linux" + ], + "cpu": [ + "x64" + ], + "optional": true + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "netbsd" + ], + "cpu": [ + "arm64" + ], + "optional": true + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "netbsd" + ], + "cpu": [ + "x64" + ], + "optional": true + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "openbsd" + ], + "cpu": [ + "arm64" + ], + "optional": true + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "openbsd" + ], + "cpu": [ + "x64" + ], + "optional": true + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "openharmony" + ], + "cpu": [ + "arm64" + ], + "optional": true + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "sunos" + ], + "cpu": [ + "x64" + ], + "optional": true + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "win32" + ], + "cpu": [ + "arm64" + ], + "optional": true + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "win32" + ], + "cpu": [ + "ia32" + ], + "optional": true + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "win32" + ], + "cpu": [ + "x64" + ], + "optional": true + }, "node_modules/@google/genai": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", @@ -1126,6 +1545,47 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "license": "MIT", + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + }, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "hasInstallScript": true + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index ff7468a33..2a9eb4095 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -480,6 +480,9 @@ "version": "0.84.4", "resolved": "https://registry.npmjs.org/@earendil-works/chord/-/chord-0.84.4.tgz", "license": "MIT", + "dependencies": { + "esbuild": "0.28.1" + }, "engines": { "node": ">=22.19.0" } @@ -568,6 +571,422 @@ "node": ">=22.19.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "aix" + ], + "cpu": [ + "ppc64" + ], + "optional": true + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "android" + ], + "cpu": [ + "arm" + ], + "optional": true + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "android" + ], + "cpu": [ + "arm64" + ], + "optional": true + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "android" + ], + "cpu": [ + "x64" + ], + "optional": true + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "darwin" + ], + "cpu": [ + "arm64" + ], + "optional": true + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "darwin" + ], + "cpu": [ + "x64" + ], + "optional": true + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "freebsd" + ], + "cpu": [ + "arm64" + ], + "optional": true + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "freebsd" + ], + "cpu": [ + "x64" + ], + "optional": true + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "linux" + ], + "cpu": [ + "arm" + ], + "optional": true + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "linux" + ], + "cpu": [ + "arm64" + ], + "optional": true + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "linux" + ], + "cpu": [ + "ia32" + ], + "optional": true + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "linux" + ], + "cpu": [ + "loong64" + ], + "optional": true + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "linux" + ], + "cpu": [ + "mips64el" + ], + "optional": true + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "linux" + ], + "cpu": [ + "ppc64" + ], + "optional": true + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "linux" + ], + "cpu": [ + "riscv64" + ], + "optional": true + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "linux" + ], + "cpu": [ + "s390x" + ], + "optional": true + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "linux" + ], + "cpu": [ + "x64" + ], + "optional": true + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "netbsd" + ], + "cpu": [ + "arm64" + ], + "optional": true + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "netbsd" + ], + "cpu": [ + "x64" + ], + "optional": true + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "openbsd" + ], + "cpu": [ + "arm64" + ], + "optional": true + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "openbsd" + ], + "cpu": [ + "x64" + ], + "optional": true + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "openharmony" + ], + "cpu": [ + "arm64" + ], + "optional": true + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "sunos" + ], + "cpu": [ + "x64" + ], + "optional": true + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "win32" + ], + "cpu": [ + "arm64" + ], + "optional": true + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "win32" + ], + "cpu": [ + "ia32" + ], + "optional": true + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "win32" + ], + "cpu": [ + "x64" + ], + "optional": true + }, "node_modules/@google/genai": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", @@ -1116,6 +1535,47 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "license": "MIT", + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + }, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "hasInstallScript": true + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", diff --git a/packages/coding-agent/src/experimental/client-runtime.ts b/packages/coding-agent/src/experimental/client-runtime.ts index 4bebf6191..3dbe4bce8 100644 --- a/packages/coding-agent/src/experimental/client-runtime.ts +++ b/packages/coding-agent/src/experimental/client-runtime.ts @@ -1,9 +1,11 @@ import { basename } from "node:path"; +import type { FacetLoader } from "@earendil-works/chord"; import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; import { Client } from "@earendil-works/pi-client"; import { createUnixTransportFactory, discoverUnixServers, type UnixServerRoute } from "@earendil-works/pi-client/unix"; import { isServerId, type ServerId } from "@earendil-works/pi-protocol"; import type { ClientCommand } from "../cli/experimental/commands/client.ts"; +import { createPresentationFacetLoaders } from "./plugins/bundled.ts"; import { RadiusRelayAuthResolver } from "./radius-auth.ts"; import { createRadiusClientTransportFactory, RadiusClientReconnect } from "./radius-relay.ts"; import { activateServer, ENV_SERVER_ID, resolveServerDirectory, resolveSessionDirectory } from "./server.ts"; @@ -24,6 +26,7 @@ export type ClientRuntimeRoute = export interface ClientRuntimeServer { readonly route: ClientRuntimeRoute; readonly client: Client; + readonly presentationFacetLoaders: readonly FacetLoader[]; readonly server: ServerServiceSource; readonly session: SessionServiceSource; } @@ -126,7 +129,13 @@ export async function openClientRuntime( const server = createServerServiceSource(client); const session = createSessionServiceSource(client); serviceSources.push(server, session); - servers.push({ route, client, server, session }); + servers.push({ + route, + client, + presentationFacetLoaders: createPresentationFacetLoaders(client.hello?.data), + server, + session, + }); } return { servers, dispose }; } catch (error) { diff --git a/packages/coding-agent/src/experimental/client-tui.ts b/packages/coding-agent/src/experimental/client-tui.ts index 6074867e5..e3c669918 100644 --- a/packages/coding-agent/src/experimental/client-tui.ts +++ b/packages/coding-agent/src/experimental/client-tui.ts @@ -8,7 +8,7 @@ import { type LoadedFacets, } from "@earendil-works/chord"; import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; -import type { SessionSummary } from "@earendil-works/pi-protocol"; +import type { JsonValue, SessionSummary } from "@earendil-works/pi-protocol"; import { CombinedAutocompleteProvider, type Component, @@ -32,6 +32,7 @@ import { createInteractiveTui } from "../modes/interactive/tui-renderer.ts"; import { type OpenClientRuntimeOptions, openClientRuntime } from "./client-runtime.ts"; import { ExperimentalChatView } from "./client-tui-chat.ts"; import { type LaneReplica, type LaneWatchSource, openLaneReplica } from "./lane-replica.ts"; +import { createPresentationFacetLoaders } from "./plugins/bundled.ts"; import helloPluginFacet from "./plugins/hello.ts"; import { AgentController, type AgentOperationResponse, type AgentQueueResponse } from "./services/agent-controller.ts"; import type { @@ -40,8 +41,9 @@ import type { SessionAttachmentState, SessionServiceSource, } from "./services/connection.ts"; +import { PresentationUI } from "./services/presentation-ui.ts"; import { SessionDirectory, SessionManagement } from "./services/sessions.ts"; -import { type SlashCommandExecutionContext, SlashCommands } from "./services/slash-commands.ts"; +import { SlashCommands } from "./services/slash-commands.ts"; import { createBuiltInSlashCommandsFacet, createSlashCommandsRuntimeFacet, @@ -54,6 +56,7 @@ export interface RunClientTuiOptions extends OpenClientRuntimeOptions { export interface ClientTuiServer { readonly serverId: string; readonly radius: boolean; + readonly presentationFacetLoaders?: readonly FacetLoader[]; readonly laneWatches: LaneWatchSource; readonly server: ServerServiceSource; readonly session: SessionServiceSource; @@ -104,7 +107,9 @@ export class ExperimentalClientTui implements Component { readonly #editorContainer = new Container(); readonly #footerComponent = new Text("", 1, 0); readonly #layoutRoot: Component; - readonly #loadedFacets: LoadedFacets; + readonly #sharedFacets: LoadedFacets; + readonly #loadedFacetGenerations = new Set(); + readonly #facetReloads = new Set<{ tail: Promise }>(); readonly #facetHosts: FacetHost[] = []; readonly #sessions = new Map(); readonly #slashCommands = new Map(); @@ -129,7 +134,8 @@ export class ExperimentalClientTui implements Component { this.#ui = ui; this.#requestRender = requestRender; this.#finish = finish; - this.#loadedFacets = loadedFacets; + this.#sharedFacets = loadedFacets; + this.#loadedFacetGenerations.add(loadedFacets); setKeybindings(this.#keybindings); this.#chatInput = new CustomEditor(ui, getEditorTheme(), this.#keybindings, { paddingX: 1 }); this.#chatInput.onSubmit = (message) => void this.#runPrompt(message); @@ -240,9 +246,48 @@ export class ExperimentalClientTui implements Component { async #start(servers: readonly ClientTuiServer[]): Promise { for (const server of servers) { + let presentationFacets = await combineFacetLoaders(server.presentationFacetLoaders ?? []).load(); + this.#loadedFacetGenerations.add(presentationFacets); + let facetHost!: FacetHost; + const reloadState = { tail: Promise.resolve() }; + this.#facetReloads.add(reloadState); + const reloadPresentationPlugins = (data: JsonValue): Promise => { + const operation = reloadState.tail.then(async () => { + const candidate = await combineFacetLoaders(createPresentationFacetLoaders(data)).load(); + try { + await facetHost.reload(candidate.facets); + } catch (error) { + try { + await candidate.dispose(); + } catch (cleanupError) { + throw new AggregateError([error, cleanupError], "TUI plugin reload and cleanup failed"); + } + throw error; + } + const retired = presentationFacets; + presentationFacets = candidate; + this.#loadedFacetGenerations.delete(retired); + this.#loadedFacetGenerations.add(candidate); + await retired.dispose(); + }); + reloadState.tail = operation.catch(() => {}); + return operation; + }; const presentationBridgeFacet = defineFacet({ id: "@pi/presentation-bridge", setup: (env) => { + env.provide(PresentationUI, { + select: (title, items, selectedValue) => + this.#select( + title, + items.map((item) => ({ ...item })), + selectedValue, + ), + showStatus: (status) => { + this.#status = status; + this.#rebuild(); + }, + }); const directory = env.use(SessionDirectory); const management = env.use(SessionManagement); const commands = env.use(SlashCommands); @@ -277,12 +322,13 @@ export class ExperimentalClientTui implements Component { }); }, }); - const facetHost = await createFacetHost({ + facetHost = await createFacetHost({ facets: [ createSlashCommandsRuntimeFacet(), presentationBridgeFacet, - createBuiltInSlashCommandsFacet(), - ...this.#loadedFacets.facets, + createBuiltInSlashCommandsFacet({ reloadPresentationPlugins }), + ...this.#sharedFacets.facets, + ...presentationFacets.facets, ], serviceSources: [server.server, server.session], }); @@ -357,6 +403,9 @@ export class ExperimentalClientTui implements Component { } catch (error) { errors.push(error); } + const reloadResults = await Promise.allSettled([...this.#facetReloads].map(({ tail }) => tail)); + this.#facetReloads.clear(); + errors.push(...reloadResults.flatMap((result) => (result.status === "rejected" ? [result.reason] : []))); const results = await Promise.allSettled( this.#facetHosts .splice(0) @@ -364,11 +413,10 @@ export class ExperimentalClientTui implements Component { .map((host) => host.dispose()), ); errors.push(...results.flatMap((result) => (result.status === "rejected" ? [result.reason] : []))); - try { - await this.#loadedFacets.dispose(); - } catch (error) { - errors.push(error); - } + const loaded = [...this.#loadedFacetGenerations].reverse(); + this.#loadedFacetGenerations.clear(); + const loadedResults = await Promise.allSettled(loaded.map((generation) => generation.dispose())); + errors.push(...loadedResults.flatMap((result) => (result.status === "rejected" ? [result.reason] : []))); if (errors.length === 1) throw errors[0]; if (errors.length > 1) throw new AggregateError(errors, "Failed to dispose experimental TUI facets"); } @@ -570,22 +618,12 @@ export class ExperimentalClientTui implements Component { this.#rebuild(); return; } - const context: SlashCommandExecutionContext = { - operation: BACKGROUND_CONTEXT, - select: (title, items, selectedValue) => - this.#select( - title, - items.map((item) => ({ ...item })), - selectedValue, - ), - submitPrompt: (message) => this.#submitPrompt(message), - showStatus: (status) => { - this.#status = status; - this.#rebuild(); - }, - }; try { - await command.run(args, context); + const result = await command.run(args, BACKGROUND_CONTEXT); + if (result !== undefined) { + if ("entryId" in result) this.#reportQueue(result); + else this.#reportOperation(result); + } } catch (error) { this.#status = `Error: ${message(error)}`; this.#rebuild(); @@ -648,8 +686,8 @@ export class ExperimentalClientTui implements Component { #footer(): string { const snapshot = this.#laneReplica?.state(); - if (!snapshot) return "/model · /thinking · /compact"; - return `${snapshot.configuration.model.provider}/${snapshot.configuration.model.modelId} · thinking:${snapshot.configuration.thinkingLevel} · ${snapshot.stats.messageCount} messages · /model · /thinking · /compact`; + if (!snapshot) return "/model · /thinking · /compact · /reload"; + return `${snapshot.configuration.model.provider}/${snapshot.configuration.model.modelId} · thinking:${snapshot.configuration.thinkingLevel} · ${snapshot.stats.messageCount} messages · /model · /thinking · /compact · /reload`; } } @@ -700,6 +738,7 @@ export async function runClientTui(command: ClientCommand, options: RunClientTui servers: runtime.servers.map((server) => ({ serverId: server.route.serverId, radius: server.route.transport === "radius", + presentationFacetLoaders: server.presentationFacetLoaders, laneWatches: server.client, server: server.server, session: server.session, diff --git a/packages/coding-agent/src/experimental/plugins/bundled.ts b/packages/coding-agent/src/experimental/plugins/bundled.ts new file mode 100644 index 000000000..bb6affbbc --- /dev/null +++ b/packages/coding-agent/src/experimental/plugins/bundled.ts @@ -0,0 +1,87 @@ +import { readFile, rm, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import type { FacetLoader } from "@earendil-works/chord"; +import { + createFacetBundleArtifactLoader, + createFacetBundleLoader, + type FacetBundleArtifact, + readFacetBundleArtifact, +} from "@earendil-works/chord/node"; +import type { JsonValue, ServerId } from "@earendil-works/pi-protocol"; + +const FACET_BUNDLE_ENV = "PI_EXPERIMENTAL_FACET_BUNDLE"; +const FACET_BUNDLE_PROFILE_VERSION = 1; +const PRESENTATION_FACET_BUNDLES_KEY = "presentationFacetBundles"; + +export function createConfiguredFacetBundleLoader(entry: "session"): FacetLoader | undefined { + const manifestPath = process.env[FACET_BUNDLE_ENV]; + if (manifestPath === undefined || manifestPath.length === 0) return undefined; + return createFacetBundleLoader({ manifestPath, entry }); +} + +/** Persist an explicit bundle selection and restore it for later server generations. */ +export async function restoreServerFacetBundleProfile(directory: string, serverId: ServerId): Promise { + const path = join(directory, `facet-bundle-${serverId}.json`); + const configured = process.env[FACET_BUNDLE_ENV]; + if (configured !== undefined) { + if (configured.length === 0) { + await rm(path, { force: true }); + return; + } + const manifestPath = resolve(configured); + await writeFile(path, `${JSON.stringify({ version: FACET_BUNDLE_PROFILE_VERSION, manifestPath }, null, 2)}\n`, { + encoding: "utf8", + mode: 0o600, + }); + process.env[FACET_BUNDLE_ENV] = manifestPath; + return; + } + + let parsed: unknown; + try { + parsed = JSON.parse(await readFile(path, "utf8")); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return; + throw new Error(`Could not read experimental facet bundle profile ${path}`, { cause: error }); + } + if ( + typeof parsed !== "object" || + parsed === null || + Array.isArray(parsed) || + Object.keys(parsed).some((key) => key !== "version" && key !== "manifestPath") || + !("version" in parsed) || + parsed.version !== FACET_BUNDLE_PROFILE_VERSION || + !("manifestPath" in parsed) || + typeof parsed.manifestPath !== "string" || + parsed.manifestPath.length === 0 + ) { + throw new Error(`Invalid experimental facet bundle profile ${path}`); + } + process.env[FACET_BUNDLE_ENV] = parsed.manifestPath; +} + +/** Read presentation facets on the server so their selection and contents can be sent during the handshake. */ +export async function readConfiguredPresentationFacetBundles(): Promise { + const manifestPath = process.env[FACET_BUNDLE_ENV]; + if (manifestPath === undefined || manifestPath.length === 0) return []; + return [await readFacetBundleArtifact({ manifestPath, entry: "tui" })]; +} + +export function createPresentationFacetHelloData(artifacts: readonly FacetBundleArtifact[]): JsonValue | undefined { + if (artifacts.length === 0) return undefined; + return { + [PRESENTATION_FACET_BUNDLES_KEY]: artifacts.map((artifact) => artifact as unknown as JsonValue), + }; +} + +/** Create local loaders only from artifacts selected and sent by the connected server. */ +export function createPresentationFacetLoaders(data: JsonValue | undefined): readonly FacetLoader[] { + if (data === undefined) return []; + if (data === null || Array.isArray(data) || typeof data !== "object") { + throw new Error("Invalid experimental server hello data"); + } + const artifacts = data[PRESENTATION_FACET_BUNDLES_KEY]; + if (artifacts === undefined) return []; + if (!Array.isArray(artifacts)) throw new Error("Invalid presentation facet bundle list in server hello"); + return artifacts.map((artifact) => createFacetBundleArtifactLoader({ artifact })); +} diff --git a/packages/coding-agent/src/experimental/plugins/hello.ts b/packages/coding-agent/src/experimental/plugins/hello.ts index 10b917c63..541ff7967 100644 --- a/packages/coding-agent/src/experimental/plugins/hello.ts +++ b/packages/coding-agent/src/experimental/plugins/hello.ts @@ -1,18 +1,23 @@ import { defineFacet } from "@earendil-works/chord"; +import { AgentController } from "../services/agent-controller.ts"; import { SlashCommands } from "../services/slash-commands.ts"; -/** Example presentation facet. A future plugin build emits this facet as one standalone bundle. */ +/** Static example presentation facet; examples/facets demonstrates the bundled plugin path. */ const helloPluginFacet = defineFacet({ id: "@pi/example-hello", setup(env) { const commands = env.use(SlashCommands); + const controller = env.use(AgentController); env.onActivate(() => { env.own( commands.register({ name: "hello", description: "Send a greeting from the example plugin", - async run(args, context) { - await context.submitPrompt(args.length === 0 ? "Hello from the example plugin." : args); + run(args, context) { + return controller.prompt( + { message: args.length === 0 ? "Hello from the example plugin" : args, images: null }, + context, + ); }, }), ); diff --git a/packages/coding-agent/src/experimental/server.ts b/packages/coding-agent/src/experimental/server.ts index a6c1ff0bb..5673b906b 100644 --- a/packages/coding-agent/src/experimental/server.ts +++ b/packages/coding-agent/src/experimental/server.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import { chmod, lstat, mkdir, readFile, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { isAbsolute, join } from "node:path"; +import type { FacetBundleArtifact } from "@earendil-works/chord/node"; import { BACKGROUND_CONTEXT, type JsonlSessionMetadata, @@ -11,7 +12,7 @@ import { import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node"; import { Client, DisconnectedError, ServerError } from "@earendil-works/pi-client"; import { createUnixTransportFactory, type UnixServerRoute } from "@earendil-works/pi-client/unix"; -import { isServerId, type ServerId, type SessionSummary } from "@earendil-works/pi-protocol"; +import { isServerId, type JsonValue, type ServerId, type SessionSummary } from "@earendil-works/pi-protocol"; import { type Server, type ServerHost, SessionAmbiguousError, SessionNotFoundError } from "@earendil-works/pi-server"; import { createUnixServer, getUnixSocketPath } from "@earendil-works/pi-server/unix"; import lockfile from "proper-lockfile"; @@ -19,6 +20,11 @@ import type { AuthInput } from "../cli/experimental/command-options.ts"; import { getAgentDir } from "../config.ts"; import { resolvePath } from "../utils/paths.ts"; import { CoordinatorConnection, type CoordinatorStartupLease, ensureCoordinator } from "./coordinator.ts"; +import { + createPresentationFacetHelloData, + readConfiguredPresentationFacetBundles, + restoreServerFacetBundleProfile, +} from "./plugins/bundled.ts"; import { consumeInternalProcessRole, isDirectInternalProcessEntry, @@ -327,6 +333,8 @@ export interface StartServerOptions { readonly keepAlive?: boolean; /** Optional explicit Radius credential. Stored Radius auth is used when omitted. */ readonly relayAuth?: AuthInput; + /** Presentation facets selected by this server. Defaults to the configured bundle's `tui` entry. */ + readonly presentationFacetBundles?: readonly FacetBundleArtifact[]; readonly onRelayStatus?: (status: RadiusRelayHostStatus) => void; } @@ -334,6 +342,8 @@ interface StartServerBackendOptions { readonly path: string; readonly serverId: ServerId; readonly sessionDir?: string; + readonly helloData?: JsonValue; + readonly reloadPresentationFacetBundles: () => Promise; } interface RunningServerBackend extends RunningServer { @@ -373,6 +383,7 @@ async function startServerBackend( if (errors.length === 1) throw errors[0]; if (errors.length > 1) throw new AggregateError(errors, "Experimental session storage cleanup failed"); }; + let setHelloData = (_data: JsonValue): void => {}; const serverServices = await createExperimentalServerServices({ list: async (context) => (await listSessions(context)) @@ -386,6 +397,12 @@ async function startServerBackend( await workers.closeSession(matches[0]!, context); await repo.delete(matches[0]!, context); }, + async reloadPresentationPlugins() { + const data = createPresentationFacetHelloData(await options.reloadPresentationFacetBundles()); + if (data === undefined) throw new Error("No presentation plugin bundle is configured"); + setHelloData(data); + return data; + }, }).catch(async (error: unknown) => { try { await closeStorage(); @@ -411,7 +428,9 @@ async function startServerBackend( path: socketPath, mode: 0o600, onConnectionCountChanged, + ...(options.helloData === undefined ? {} : { helloData: options.helloData }), }); + setHelloData = (data) => server.setHelloData(data); try { await server.start(); } catch (error) { @@ -473,6 +492,13 @@ export async function startServer(options: StartServerOptions = {}): Promise configuredPresentationFacetBundles; + const helloData = createPresentationFacetHelloData(await reloadPresentationFacetBundles()); const socketPath = getUnixSocketPath(serverId, directory); const controlPath = join(directory, `control-${serverId}.sock`); const serverNonce = randomUUID().replaceAll("-", "").slice(0, 12); @@ -484,7 +510,13 @@ export async function startServer(options: StartServerOptions = {}): Promise lifetime.setConnectionCount(count), ); diff --git a/packages/coding-agent/src/experimental/services/README.md b/packages/coding-agent/src/experimental/services/README.md index 3d686a4d6..4a111df0e 100644 --- a/packages/coding-agent/src/experimental/services/README.md +++ b/packages/coding-agent/src/experimental/services/README.md @@ -6,18 +6,21 @@ Facet setup generates each host's RPC service catalogue from its provided non-lo |---|---|---|---| | server | `SessionDirectory` | replicated state implemented | add authenticated per-client projection when identity lands | | server | `SessionManagement` | create, remove, attach, detach implemented | add authenticated workspace authorization | +| server | `PresentationPlugins` | returns fresh server-selected TUI artifacts and updates future handshakes | add authenticated plugin policy | +| session | `SessionPlugins` | reloads the configured Session facet generation | add coordinated multi-worker reload reporting | | session | `Models` | state, default-persisting selection, thinking, refresh implemented | move provider/auth composition behind plugin facets | | session | `AgentController` | presentation-safe `AgentLane` facade for prompting, queueing, abort, resume, compaction, and navigation | add new lane operations only when a presentation requires them | | session | `Accounts` | state declared; mutations throw | connect the local credential service to the presentation-safe facade | | session | `Transcript` | authoritative snapshot declared but throws | implement snapshot projection and remove the compatibility lane watch | -| presentation | `SlashCommands` | process-local contribution registry with model, thinking, compact, and example hello commands | add more presentation hookpoints only as concrete plugin slices require them | +| presentation | `SlashCommands` | process-local contribution registry with model, thinking, compact, reload, and example hello commands | add more presentation hookpoints only as concrete plugin slices require them | +| presentation | `PresentationUI` | process-local selection and status capabilities | add narrowly scoped UI capabilities only when commands require them | `ServerServiceSource.connection` and `SessionServiceSource.attachment` are implemented local control states. With `PI_EXPERIMENTAL=1`, an interactive `pi client` creates and attaches a Session before opening the service-only chat TUI. `pi client -c` and `pi client -r` attach the newest existing Session instead, preserving its durable model and thinking configuration. Model selection is available on demand through `/model`; it is never a startup screen. The presentation always uses the stable coding agent's alternate-screen renderer and shared transcript/dock viewport. It loads configured theme resources and uses the stable terminal light/dark detection and appearance-change notifications. Its replicated state feeds the stable editor, message, tool, status, theme, and tool-renderer components. The presentation drives the worker-owned main lane through `AgentController` and renders a coherent lane-watch replica with the Harness reducer while controller calls are pending. `Transcript` remains reserved for a later service-owned live-delta replacement of that compatibility watch. -The Session worker loads all built-in and configured Session facets through one `FacetLoader`, then creates one active `FacetHost`. Host-created implementation dependencies such as `AgentLane`, `ModelRuntime`, and `SettingsManager` are passed directly to built-in facet factories; they are not exposed as services. The TUI loads configured presentation facets and adds one private bridge facet that consumes the connected server and selected-Session services for `ExperimentalClientTui`. Its first concrete local presentation service is `SlashCommands`: built-in and plugin facets register command metadata and callbacks, and facet cleanup removes those contributions on reload or unload. Command callbacks receive narrow selection, status, and prompt-submission operations rather than the raw TUI. +The Session worker loads built-in facets and a separately owned configured-plugin generation, then creates one active `FacetHost`. `/reload` loads a fresh plugin candidate, cuts it over through `FacetHost.reload()`, and disposes the retired generation. Host-created implementation dependencies such as `AgentLane`, `ModelRuntime`, and `SettingsManager` are passed directly to built-in facet factories; they are not exposed as services. The TUI loads configured presentation facets and adds one private bridge facet that consumes the connected server and selected-Session services for `ExperimentalClientTui`. Its local presentation services are `SlashCommands`, which owns command contributions, and `PresentationUI`, which exposes selection and status rendering without exposing the raw TUI. Command callbacks receive Chord `Context` directly. Facets consume `AgentController` explicitly for prompting, steering, or queueing and return structured controller results to the command dispatcher. A host may reload selected facets when their declared service shape is unchanged; retained consumers keep the same service facades while replacement implementations and singleton snapshots are installed behind them. Synchronous setup-time `env.provide()`, `env.provideMany()`, `env.use()`, and `env.observe()` calls produce the internal dependency graph; setup does not repeat a declarative dependency list, and service handles remain disconnected until the complete graph validates. Providers activate before consumers, observations connect with their consuming facet, and facet replacement or host shutdown disposes affected lifecycles in reverse dependency order. Server and Session service tokens are non-local and automatically published by their providing host. Presentation-only hookpoints such as `SlashCommands` are explicitly local and never enter an RPC catalogue. -A facet always calls unqualified `env.use()` or `env.observe()`. The host resolves each token across facet-provided and connected services. The example `/hello` plugin is one presentation-facet module with a default facet export and is loaded through a `FacetLoader`; a future plugin build may emit that module as one self-contained bundle without changing the runtime facet contract. Other host facets from the same plugin will be separate bundle entries rather than one aggregate plugin object. Transport bindings remain internal machinery rather than part of the facet environment. `ExperimentalClientTui` currently owns terminal rendering, state subscriptions, navigation, and action dispatch directly. The server provider remains directly assembled until its complete host environment exists. The question dialog, diff review, Git, indexing-job, canvas, and rich local TUI service examples in `packages/agent/docs/plugins.md` are extension patterns, not built-in coding-agent services. Private references, trace carriers, and flow control remain protocol/host infrastructure slices rather than presentation service tokens. +A facet always calls unqualified `env.use()` or `env.observe()`. The host resolves each token across facet-provided and connected services. The example `/hello` plugin is one static presentation facet. `examples/facets/` demonstrates the bundled path: Chord emits separate `session` and `tui` files from one plugin, the server profile owns `PI_EXPERIMENTAL_FACET_BUNDLE`, and presentations receive TUI artifacts through the handshake or `PresentationPlugins.reload()`. Other host facets from the same plugin remain separate bundle entries rather than one aggregate plugin object. Transport bindings remain internal machinery rather than part of the facet environment. `ExperimentalClientTui` currently owns terminal rendering, state subscriptions, navigation, and action dispatch directly. The server provider remains directly assembled until its complete host environment exists. The question dialog, diff review, Git, indexing-job, canvas, and rich local TUI service examples in `packages/agent/docs/plugins.md` are extension patterns, not built-in coding-agent services. Private references, trace carriers, and flow control remain protocol/host infrastructure slices rather than presentation service tokens. diff --git a/packages/coding-agent/src/experimental/services/plugins.ts b/packages/coding-agent/src/experimental/services/plugins.ts new file mode 100644 index 000000000..ebf11d26d --- /dev/null +++ b/packages/coding-agent/src/experimental/services/plugins.ts @@ -0,0 +1,16 @@ +import { type Context, defineService } from "@earendil-works/chord"; +import type { JsonValue } from "@earendil-works/pi-protocol"; + +/** Server-owned plugin bundle generations available to presentations. */ +export interface PresentationPlugins { + reload(context: Context): Promise; +} + +export const PresentationPlugins = defineService("pi.presentation-plugins"); + +/** Plugin facets hosted in the currently attached Session worker. */ +export interface SessionPlugins { + reload(context: Context): Promise; +} + +export const SessionPlugins = defineService("pi.session-plugins"); diff --git a/packages/coding-agent/src/experimental/services/presentation-ui.ts b/packages/coding-agent/src/experimental/services/presentation-ui.ts new file mode 100644 index 000000000..11503eedd --- /dev/null +++ b/packages/coding-agent/src/experimental/services/presentation-ui.ts @@ -0,0 +1,20 @@ +import { type Context, defineService } from "@earendil-works/chord"; + +export interface PresentationSelectItem { + readonly value: string; + readonly label: string; + readonly description?: string; +} + +/** Narrow process-local UI capabilities available to presentation facets. */ +export interface PresentationUI { + select( + title: string, + items: readonly PresentationSelectItem[], + selectedValue: string | undefined, + context: Context, + ): Promise; + showStatus(message: string, context: Context): void; +} + +export const PresentationUI = defineService("pi.local.presentation-ui", { local: true }); diff --git a/packages/coding-agent/src/experimental/services/server.ts b/packages/coding-agent/src/experimental/services/server.ts index 3b808edec..35eda29b8 100644 --- a/packages/coding-agent/src/experimental/services/server.ts +++ b/packages/coding-agent/src/experimental/services/server.ts @@ -16,6 +16,7 @@ import { } from "@earendil-works/pi-protocol"; import type { RoutedServerServiceAttachment, RoutedServerServiceHost } from "@earendil-works/pi-server"; import { Check } from "typebox/value"; +import { PresentationPlugins } from "./plugins.ts"; import { SessionDirectory, type SessionDirectoryState, SessionManagement } from "./sessions.ts"; export interface ExperimentalServerServices { @@ -28,6 +29,7 @@ export async function createExperimentalServerServices(options: { list(context: Context): Promise; create(createOptions: SessionCreateOptions, context: Context): Promise; remove(sessionId: string, context: Context): Promise; + reloadPresentationPlugins(context: Context): Promise; }): Promise { let revision = 1; const directory = replicatedState({ @@ -57,8 +59,12 @@ export async function createExperimentalServerServices(options: { const provider = new RemoteServiceProvider([ { service: SessionDirectory, mode: "singleton" }, { service: SessionManagement, mode: "singleton" }, + { service: PresentationPlugins, mode: "singleton" }, ]); provider.provide(SessionDirectory, { state: directory }); + provider.provide(PresentationPlugins, { + reload: (_context) => options.reloadPresentationPlugins(_context), + }); provider.provide(SessionManagement, { create: (createOptions, context) => serialize(async () => { diff --git a/packages/coding-agent/src/experimental/services/slash-commands-provider.ts b/packages/coding-agent/src/experimental/services/slash-commands-provider.ts index 3aca7ec94..beb9aebf9 100644 --- a/packages/coding-agent/src/experimental/services/slash-commands-provider.ts +++ b/packages/coding-agent/src/experimental/services/slash-commands-provider.ts @@ -1,8 +1,11 @@ import { defineFacet, type Facet } from "@earendil-works/chord"; import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; -import { AgentController, type AgentOperationResponse } from "./agent-controller.ts"; +import type { JsonValue } from "@earendil-works/pi-protocol"; +import { AgentController } from "./agent-controller.ts"; import { type ModelSummary, Models, type Models as ModelsService } from "./models.ts"; -import { type SlashCommandContribution, type SlashCommandExecutionContext, SlashCommands } from "./slash-commands.ts"; +import { PresentationPlugins, SessionPlugins } from "./plugins.ts"; +import { PresentationUI } from "./presentation-ui.ts"; +import { type SlashCommandContribution, SlashCommands } from "./slash-commands.ts"; const THINKING_DESCRIPTIONS: Record = { off: "No reasoning", @@ -61,23 +64,42 @@ export function createSlashCommandsRuntimeFacet(registry = new SlashCommandRegis }); } -export function createBuiltInSlashCommandsFacet(): Facet { +export function createBuiltInSlashCommandsFacet(options: { + reloadPresentationPlugins(data: JsonValue): Promise; +}): Facet { return defineFacet({ id: "@pi/slash-commands-builtin", setup(env) { const commands = env.use(SlashCommands); const models = env.use(Models); const controller = env.use(AgentController); + const ui = env.use(PresentationUI); + const presentationPlugins = env.use(PresentationPlugins); + const sessionPlugins = env.use(SessionPlugins); env.onActivate(() => { - env.own(commands.register(modelCommand(models))); - env.own(commands.register(thinkingCommand(models))); - env.own(commands.register(compactCommand(controller))); + env.own(commands.register(modelCommand(models, ui))); + env.own(commands.register(thinkingCommand(models, ui))); + env.own(commands.register(compactCommand(controller, ui))); + env.own( + commands.register({ + name: "reload", + description: "Reload server-selected plugins", + async run(_args, context) { + ui.showStatus("Reloading plugins…", context); + const data = await presentationPlugins.reload(context); + await sessionPlugins.reload(context); + await options.reloadPresentationPlugins(data); + ui.showStatus("Reloaded plugins.", context); + return undefined; + }, + }), + ); }); }, }); } -function modelCommand(models: ModelsService): SlashCommandContribution { +function modelCommand(models: ModelsService, ui: PresentationUI): SlashCommandContribution { return { name: "model", description: "Select model", @@ -100,7 +122,7 @@ function modelCommand(models: ModelsService): SlashCommandContribution { throw new Error(`Unknown model: ${args}`); } if (selected === undefined) { - const value = await context.select( + const value = await ui.select( "Select model:", state.catalog.availableModels.map((model) => ({ value: `${model.provider}/${model.modelId}`, @@ -114,30 +136,32 @@ function modelCommand(models: ModelsService): SlashCommandContribution { state.configuration.model === null ? undefined : `${state.configuration.model.provider}/${state.configuration.model.modelId}`, + context, ); - if (value === undefined) return; + if (value === undefined) return undefined; selected = exactModel(state.catalog.availableModels, value); if (selected === undefined) throw new Error(`Unknown model: ${value}`); } - await models.select({ provider: selected.provider, modelId: selected.modelId }, context.operation); - context.showStatus(`Selected ${selected.provider}/${selected.modelId}.`); + await models.select({ provider: selected.provider, modelId: selected.modelId }, context); + ui.showStatus(`Selected ${selected.provider}/${selected.modelId}.`, context); + return undefined; }, }; } -function thinkingCommand(models: ModelsService): SlashCommandContribution { +function thinkingCommand(models: ModelsService, ui: PresentationUI): SlashCommandContribution { return { name: "thinking", description: "Set thinking level", argumentHint: "", async run(args, context) { - const levels = await models.getThinkingLevels(context.operation); + const levels = await models.getThinkingLevels(context); let selected = levels.find((level) => level === args.toLowerCase()); if (args.length > 0 && selected === undefined) { throw new Error(`Unknown thinking level "${args}". Available levels: ${levels.join(", ")}.`); } if (selected === undefined) { - const value = await context.select( + const value = await ui.select( "Select thinking level:", levels.map((level) => ({ value: level, @@ -145,28 +169,27 @@ function thinkingCommand(models: ModelsService): SlashCommandContribution { description: THINKING_DESCRIPTIONS[level], })), models.state.value?.configuration.thinkingLevel, + context, ); - if (value === undefined) return; + if (value === undefined) return undefined; selected = levels.find((level) => level === value); if (selected === undefined) throw new Error(`Unknown thinking level: ${value}`); } - await models.selectThinking(selected, context.operation); - context.showStatus(`Thinking level: ${selected}.`); + await models.selectThinking(selected, context); + ui.showStatus(`Thinking level: ${selected}.`, context); + return undefined; }, }; } -function compactCommand(controller: AgentController): SlashCommandContribution { +function compactCommand(controller: AgentController, ui: PresentationUI): SlashCommandContribution { return { name: "compact", description: "Manually compact the session context", argumentHint: "", - async run(args, context) { - context.showStatus("Compacting…"); - reportOperation( - await controller.compact({ customInstructions: args.length === 0 ? null : args }, context.operation), - context, - ); + run(args, context) { + ui.showStatus("Compacting…", context); + return controller.compact({ customInstructions: args.length === 0 ? null : args }, context); }, }; } @@ -181,13 +204,3 @@ function exactModel(models: readonly ModelSummary[], query: string): ModelSummar ); return matches.length === 1 ? matches[0] : undefined; } - -function reportOperation(response: AgentOperationResponse, context: SlashCommandExecutionContext): void { - context.showStatus( - response.accepted - ? response.error === null - ? "" - : `Operation failed: ${response.error.message}` - : `Operation rejected: ${response.error.message}`, - ); -} diff --git a/packages/coding-agent/src/experimental/services/slash-commands.ts b/packages/coding-agent/src/experimental/services/slash-commands.ts index 9e7a3b5d2..4fb80f1cc 100644 --- a/packages/coding-agent/src/experimental/services/slash-commands.ts +++ b/packages/coding-agent/src/experimental/services/slash-commands.ts @@ -1,4 +1,5 @@ import { type Context, defineService } from "@earendil-works/chord"; +import type { AgentOperationResponse, AgentQueueResponse } from "./agent-controller.ts"; export interface SlashCommandCompletion { readonly value: string; @@ -6,18 +7,7 @@ export interface SlashCommandCompletion { readonly description?: string; } -export interface SlashCommandSelectItem { - readonly value: string; - readonly label: string; - readonly description?: string; -} - -export interface SlashCommandExecutionContext { - readonly operation: Context; - select(title: string, items: readonly SlashCommandSelectItem[], selectedValue?: string): Promise; - submitPrompt(message: string): Promise; - showStatus(message: string): void; -} +export type SlashCommandRunResult = AgentOperationResponse | AgentQueueResponse | undefined; export interface SlashCommandContribution { readonly name: string; @@ -26,7 +16,7 @@ export interface SlashCommandContribution { getArgumentCompletions?( argumentPrefix: string, ): readonly SlashCommandCompletion[] | null | Promise; - run(args: string, context: SlashCommandExecutionContext): void | Promise; + run(args: string, context: Context): SlashCommandRunResult | Promise; } export interface SlashCommands { diff --git a/packages/coding-agent/src/experimental/services/worker.ts b/packages/coding-agent/src/experimental/services/worker.ts index f150301ef..c47a742de 100644 --- a/packages/coding-agent/src/experimental/services/worker.ts +++ b/packages/coding-agent/src/experimental/services/worker.ts @@ -27,6 +27,7 @@ import type { SettingsManager } from "../../core/settings-manager.ts"; import { AgentController } from "./agent-controller.ts"; import { createAgentController } from "./agent-controller-provider.ts"; import { createModelsServiceFacet } from "./models-provider.ts"; +import { SessionPlugins } from "./plugins.ts"; import { accountsServiceFacet, transcriptServiceFacet } from "./stubs-provider.ts"; export const ServiceOperationResultSchema = Type.Object( @@ -57,6 +58,7 @@ export interface SessionWorkerServices { readonly catalogue: readonly ServiceCatalogueEntry[]; invoke(call: ProtocolRpcCall, scope: WorkerServiceScope, context: Context): Promise; removeSubscriptions(matches: (scope: WorkerServiceScope) => boolean): void; + reload(): Promise; dispose(): Promise; } @@ -85,23 +87,51 @@ export async function createSessionWorkerServices(options: { env.provide(AgentController, createAgentController(options.lane)); }, }); - const loader = combineFacetLoaders([ - createStaticFacetLoader([agentControllerRuntimeFacet]), + let reloadPlugins = (): Promise => Promise.reject(new Error("Session plugins are not ready")); + const pluginRuntimeFacet = defineFacet({ + id: "@pi/session-plugins-runtime", + setup(env) { + env.provide(SessionPlugins, { reload: () => reloadPlugins() }); + }, + }); + const builtins = await combineFacetLoaders([ + createStaticFacetLoader([agentControllerRuntimeFacet, pluginRuntimeFacet]), createBuiltinSessionFacetLoader(options), - ...(options.facetLoader === undefined ? [] : [options.facetLoader]), - ]); - const loaded = await loader.load(); + ]).load(); + const pluginLoader = options.facetLoader ?? createStaticFacetLoader([]); + let loadedPlugins = await pluginLoader.load(); let facetHost: FacetHost; try { - facetHost = await createFacetHost({ facets: loaded.facets }); + facetHost = await createFacetHost({ facets: [...builtins.facets, ...loadedPlugins.facets] }); } catch (error) { - try { - await loaded.dispose(); - } catch (cleanupError) { - throw new AggregateError([error, cleanupError], "Session facets failed to start and clean up"); + const cleanup = await Promise.allSettled([loadedPlugins.dispose(), builtins.dispose()]); + const cleanupErrors = cleanup.flatMap((result) => (result.status === "rejected" ? [result.reason] : [])); + if (cleanupErrors.length > 0) { + throw new AggregateError([error, ...cleanupErrors], "Session facets failed to start and clean up"); } throw error; } + let reloadTail = Promise.resolve(); + reloadPlugins = () => { + const operation = reloadTail.then(async () => { + const candidate = await pluginLoader.load(); + try { + await facetHost.reload(candidate.facets); + } catch (error) { + try { + await candidate.dispose(); + } catch (cleanupError) { + throw new AggregateError([error, cleanupError], "Session plugin reload and cleanup failed"); + } + throw error; + } + const retired = loadedPlugins; + loadedPlugins = candidate; + await retired.dispose(); + }); + reloadTail = operation.catch(() => {}); + return operation; + }; const provider = facetHost.services; const subscriptions = new Map(); @@ -139,19 +169,18 @@ export async function createSessionWorkerServices(options: { return provider.invoke(call, context); }, removeSubscriptions, + reload: () => reloadPlugins(), async dispose() { removeSubscriptions(() => true); + await reloadTail; const errors: unknown[] = []; try { await facetHost.dispose(); } catch (error) { errors.push(error); } - try { - await loaded.dispose(); - } catch (error) { - errors.push(error); - } + const results = await Promise.allSettled([loadedPlugins.dispose(), builtins.dispose()]); + errors.push(...results.flatMap((result) => (result.status === "rejected" ? [result.reason] : []))); if (errors.length === 1) throw errors[0]; if (errors.length > 1) throw new AggregateError(errors, "Failed to dispose Session facets"); }, diff --git a/packages/coding-agent/src/experimental/session-worker.ts b/packages/coding-agent/src/experimental/session-worker.ts index 79910869b..65a3f96a5 100644 --- a/packages/coding-agent/src/experimental/session-worker.ts +++ b/packages/coding-agent/src/experimental/session-worker.ts @@ -47,6 +47,7 @@ import { toWireLaneSnapshot, toWireRunResult, } from "./harness-wire-adapter.ts"; +import { createConfiguredFacetBundleLoader } from "./plugins/bundled.ts"; import { consumeInternalProcessRole, encodeControlLine, @@ -940,7 +941,13 @@ async function createCodingAgentHarness( ) { await lane.setActiveTools(activeToolNames, TODO_CONTEXT); } - return { harness, lane, modelRuntime, settingsManager }; + return { + harness, + lane, + modelRuntime, + settingsManager, + facetLoader: createConfiguredFacetBundleLoader("session"), + }; } catch (error) { try { await harness.close(TODO_CONTEXT); diff --git a/packages/coding-agent/test/experimental-client-tui.test.ts b/packages/coding-agent/test/experimental-client-tui.test.ts index 8338bb955..dd655d75b 100644 --- a/packages/coding-agent/test/experimental-client-tui.test.ts +++ b/packages/coding-agent/test/experimental-client-tui.test.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { createRemoteServiceBinding, defineFacet, @@ -7,11 +8,17 @@ import { replicatedState, } from "@earendil-works/chord"; import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; +import { + FACET_BUNDLE_ARTIFACT_FORMAT, + FACET_BUNDLE_ARTIFACT_FORMAT_VERSION, + type FacetBundleArtifact, +} from "@earendil-works/chord/node"; import type { AgentLane } from "@earendil-works/pi-agent-core"; import type { LaneEvent, LaneSnapshot, SessionSummary } from "@earendil-works/pi-protocol"; import { ProcessTerminal, TuiMainScreen } from "@earendil-works/pi-tui"; import { beforeAll, describe, expect, test, vi } from "vitest"; import { type ClientTuiServer, ExperimentalClientTui } from "../src/experimental/client-tui.ts"; +import { createPresentationFacetHelloData } from "../src/experimental/plugins/bundled.ts"; import { AgentController } from "../src/experimental/services/agent-controller.ts"; import { createAgentController } from "../src/experimental/services/agent-controller-provider.ts"; import type { @@ -21,6 +28,7 @@ import type { SessionServiceSource, } from "../src/experimental/services/connection.ts"; import { Models, type ModelsState } from "../src/experimental/services/models.ts"; +import { PresentationPlugins, SessionPlugins } from "../src/experimental/services/plugins.ts"; import { SessionDirectory, type SessionDirectoryState, @@ -180,8 +188,27 @@ describe("experimental client TUI", () => { }; }); - const serverProvider = new RemoteServiceProvider([SessionDirectory, SessionManagement]); + const reloadSource = + 'import { defineFacet, defineService } from "@earendil-works/chord";\nconst Models = defineService("pi.models");\nexport default defineFacet({ id: "test-tui-facet", setup(env) { env.use(Models); } });\n'; + const reloadArtifact: FacetBundleArtifact = { + format: FACET_BUNDLE_ARTIFACT_FORMAT, + formatVersion: FACET_BUNDLE_ARTIFACT_FORMAT_VERSION, + plugin: { id: "test-tui-plugin" }, + entryName: "tui", + entry: { + file: "tui.js", + integrity: `sha256-${createHash("sha256").update(reloadSource).digest("base64")}`, + externalImports: ["@earendil-works/chord"], + }, + source: reloadSource, + }; + const reloadData = createPresentationFacetHelloData([reloadArtifact]); + if (reloadData === undefined) throw new Error("Expected presentation reload data"); + const reloadPresentationPlugins = vi.fn(async () => reloadData); + const reloadSessionPlugins = vi.fn(async () => {}); + const serverProvider = new RemoteServiceProvider([SessionDirectory, SessionManagement, PresentationPlugins]); serverProvider.provide(SessionDirectory, { state: directoryState }); + serverProvider.provide(PresentationPlugins, { reload: reloadPresentationPlugins }); serverProvider.provide(SessionManagement, { create, async remove() {}, @@ -192,7 +219,8 @@ describe("experimental client TUI", () => { attachment.set({ status: "detached" }, BACKGROUND_CONTEXT); }, }); - const sessionProvider = new RemoteServiceProvider([Models, AgentController]); + const sessionProvider = new RemoteServiceProvider([Models, AgentController, SessionPlugins]); + sessionProvider.provide(SessionPlugins, { reload: reloadSessionPlugins }); sessionProvider.provide(Models, { state: modelsState, async cycleThinking() {}, @@ -206,7 +234,7 @@ describe("experimental client TUI", () => { sessionProvider.provide(AgentController, createAgentController({ prompt } as unknown as AgentLane)); const serverNamespace = createRemoteServiceBinding({ - services: [SessionDirectory, SessionManagement], + services: [SessionDirectory, SessionManagement, PresentationPlugins], transport: createLoopbackServiceTransport(serverProvider), bound: false, }); @@ -226,7 +254,7 @@ describe("experimental client TUI", () => { }, }); const sessionNamespace = createRemoteServiceBinding({ - services: [Models, AgentController], + services: [Models, AgentController, SessionPlugins], transport: createLoopbackServiceTransport(sessionProvider), bound: false, }); @@ -249,15 +277,6 @@ describe("experimental client TUI", () => { attachment.set({ status: "detached" }, BACKGROUND_CONTEXT); }, }); - const server: ClientTuiServer = { - serverId, - radius: true, - laneWatches: { watchSession }, - server: serverServices, - session: sessionServices, - }; - let finished = false; - const requestRender = vi.fn(); const disposeLoadedFacets = vi.fn(async () => {}); const facetLoader: FacetLoader = { async load() { @@ -275,12 +294,21 @@ describe("experimental client TUI", () => { }; }, }; + const server: ClientTuiServer = { + serverId, + radius: true, + presentationFacetLoaders: [facetLoader], + laneWatches: { watchSession }, + server: serverServices, + session: sessionServices, + }; + let finished = false; + const requestRender = vi.fn(); const ui = new TuiMainScreen(new ProcessTerminal()); const component = await ExperimentalClientTui.create({ command, ui, servers: [server], - facetLoader, requestRender, finish() { finished = true; @@ -308,12 +336,21 @@ describe("experimental client TUI", () => { component.handleInput("\u001b"); component.handleInput("\r"); await vi.waitFor(() => - expect(prompt).toHaveBeenCalledWith("Hello from the example plugin.", undefined, expect.anything()), + expect(prompt).toHaveBeenCalledWith("Hello from the example plugin", undefined, BACKGROUND_CONTEXT), ); await vi.waitFor(() => expect(component.render(80).join("\n")).toContain("remote answer")); expect(component.render(80).join("\n")).toContain("hello"); expect(component.render(80).join("\n")).not.toContain("Operation run-1 completed"); + component.handleInput("/reload"); + component.handleInput("\u001b"); + component.handleInput("\r"); + await vi.waitFor(() => { + expect(reloadPresentationPlugins).toHaveBeenCalledOnce(); + expect(reloadSessionPlugins).toHaveBeenCalledOnce(); + }); + await vi.waitFor(() => expect(component.render(80).join("\n")).toContain("Reloaded plugins.")); + attachment.set({ status: "detached" }, BACKGROUND_CONTEXT); connectionState.set( { status: "disconnected", since: "later", reason: "network lost", retryAt: null }, diff --git a/packages/coding-agent/test/experimental-plugin-reload.test.ts b/packages/coding-agent/test/experimental-plugin-reload.test.ts new file mode 100644 index 000000000..8498f277b --- /dev/null +++ b/packages/coding-agent/test/experimental-plugin-reload.test.ts @@ -0,0 +1,55 @@ +import { defineFacet, type FacetLoader } from "@earendil-works/chord"; +import type { AgentLane } from "@earendil-works/pi-agent-core"; +import { describe, expect, test, vi } from "vitest"; +import { createSessionWorkerServices } from "../src/experimental/services/worker.ts"; + +describe("experimental plugin reload", () => { + test("loads and cuts over a fresh Session facet generation", async () => { + const activations: number[] = []; + const disposals: number[] = []; + let generation = 0; + const facetLoader: FacetLoader = { + async load() { + const current = ++generation; + return { + facets: [ + defineFacet({ + id: "reloadable-session-plugin", + setup(env) { + env.onActivate(() => { + activations.push(current); + }); + }, + }), + ], + async dispose() { + disposals.push(current); + }, + }; + }, + }; + const lane = { + async getModel() { + return undefined; + }, + async getThinkingLevel() { + return "off" as const; + }, + } as unknown as AgentLane; + const services = await createSessionWorkerServices({ + lane, + modelRuntime: undefined, + facetLoader, + publish: vi.fn(async () => {}), + }); + try { + expect(activations).toEqual([1]); + await services.reload(); + expect(activations).toEqual([1, 2]); + expect(disposals).toEqual([1]); + } finally { + await services.dispose(); + } + expect(disposals).toEqual([1, 2]); + }); +}); diff --git a/packages/coding-agent/test/experimental-presentation-facets.test.ts b/packages/coding-agent/test/experimental-presentation-facets.test.ts new file mode 100644 index 000000000..d9ac678eb --- /dev/null +++ b/packages/coding-agent/test/experimental-presentation-facets.test.ts @@ -0,0 +1,98 @@ +import { createHash, randomUUID } from "node:crypto"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { + FACET_BUNDLE_ARTIFACT_FORMAT, + FACET_BUNDLE_ARTIFACT_FORMAT_VERSION, + type FacetBundleArtifact, +} from "@earendil-works/chord/node"; +import { TestServerHost } from "@earendil-works/pi-server/testing"; +import { createUnixServer } from "@earendil-works/pi-server/unix"; +import { afterEach, describe, expect, test } from "vitest"; +import { type ClientRuntime, openClientRuntime } from "../src/experimental/client-runtime.ts"; +import { + createPresentationFacetHelloData, + restoreServerFacetBundleProfile, +} from "../src/experimental/plugins/bundled.ts"; + +const runtimes = new Set(); +const servers = new Set>(); +const directories = new Set(); + +afterEach(async () => { + await Promise.allSettled([...runtimes].map((runtime) => runtime.dispose())); + await Promise.allSettled([...servers].map((server) => server.close())); + await Promise.allSettled([...directories].map((directory) => rm(directory, { force: true, recursive: true }))); + runtimes.clear(); + servers.clear(); + directories.clear(); +}); + +describe("server-selected presentation facets", () => { + test("restores the bundle selection for later server generations", async () => { + const directory = await mkdtemp("/tmp/pi-presentation-profile-"); + directories.add(directory); + const serverId = randomUUID(); + const manifestPath = join(directory, "facets", "chord-facets.json"); + const original = process.env.PI_EXPERIMENTAL_FACET_BUNDLE; + try { + process.env.PI_EXPERIMENTAL_FACET_BUNDLE = manifestPath; + await restoreServerFacetBundleProfile(directory, serverId); + + delete process.env.PI_EXPERIMENTAL_FACET_BUNDLE; + await restoreServerFacetBundleProfile(directory, serverId); + expect(process.env.PI_EXPERIMENTAL_FACET_BUNDLE).toBe(manifestPath); + + process.env.PI_EXPERIMENTAL_FACET_BUNDLE = ""; + await restoreServerFacetBundleProfile(directory, serverId); + delete process.env.PI_EXPERIMENTAL_FACET_BUNDLE; + await restoreServerFacetBundleProfile(directory, serverId); + expect(process.env.PI_EXPERIMENTAL_FACET_BUNDLE).toBeUndefined(); + } finally { + if (original === undefined) delete process.env.PI_EXPERIMENTAL_FACET_BUNDLE; + else process.env.PI_EXPERIMENTAL_FACET_BUNDLE = original; + } + }); + + test("receives and loads a TUI facet without client-side plugin configuration", async () => { + const source = + 'import { defineFacet } from "@earendil-works/chord";\nexport default defineFacet({ id: "server-selected-tui", setup() {} });\n'; + const artifact: FacetBundleArtifact = { + format: FACET_BUNDLE_ARTIFACT_FORMAT, + formatVersion: FACET_BUNDLE_ARTIFACT_FORMAT_VERSION, + plugin: { id: "server-selected-plugin" }, + entryName: "tui", + entry: { + file: "tui.js", + integrity: `sha256-${createHash("sha256").update(source).digest("base64")}`, + externalImports: ["@earendil-works/chord"], + }, + source, + }; + const directory = await mkdtemp("/tmp/pi-presentation-facet-"); + directories.add(directory); + const serverId = randomUUID(); + const socketPath = join(directory, `${serverId}.sock`); + const server = createUnixServer(new TestServerHost(), { + path: socketPath, + serverId, + helloData: createPresentationFacetHelloData([artifact]), + }); + servers.add(server); + await server.start(); + + const runtime = await openClientRuntime({ + command: "client", + connect: { transport: "unix", path: socketPath }, + }); + runtimes.add(runtime); + expect(runtime.servers[0]?.presentationFacetLoaders).toHaveLength(1); + + const loaded = await runtime.servers[0]!.presentationFacetLoaders[0]!.load(); + try { + expect(loaded.facets.map(({ id }) => id)).toEqual(["server-selected-tui"]); + } finally { + await loaded.dispose(); + } + }); +}); diff --git a/packages/coding-agent/test/experimental-radius-relay.test.ts b/packages/coding-agent/test/experimental-radius-relay.test.ts index 7cf7bf7cd..afd64b630 100644 --- a/packages/coding-agent/test/experimental-radius-relay.test.ts +++ b/packages/coding-agent/test/experimental-radius-relay.test.ts @@ -1,4 +1,5 @@ import { Client } from "@earendil-works/pi-client"; +import { PROTOCOL_VERSION } from "@earendil-works/pi-protocol"; import { Server, type ServerHost } from "@earendil-works/pi-server"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { RadiusRelayAuthResolver } from "../src/experimental/radius-auth.ts"; @@ -215,7 +216,7 @@ describe("experimental Radius relay", () => { clientSocket.open(clientWebSockets.sockets[0]!.options.protocol); const client = await connecting; - expect(client.hello).toEqual({ type: "hello", version: 3, serverId }); + expect(client.hello).toEqual({ type: "hello", version: PROTOCOL_VERSION, serverId }); await client.dispose(); await relayHost.close(); await protocolServer.close(); diff --git a/packages/coding-agent/test/experimental-remote-runtime.test.ts b/packages/coding-agent/test/experimental-remote-runtime.test.ts index b83c7cc8a..71d7fa17a 100644 --- a/packages/coding-agent/test/experimental-remote-runtime.test.ts +++ b/packages/coding-agent/test/experimental-remote-runtime.test.ts @@ -16,6 +16,7 @@ import { type SessionAttachmentState, } from "../src/experimental/services/connection.ts"; import { Models } from "../src/experimental/services/models.ts"; +import { PresentationPlugins } from "../src/experimental/services/plugins.ts"; import { SessionDirectory, SessionManagement } from "../src/experimental/services/sessions.ts"; import { Transcript } from "../src/experimental/services/transcript.ts"; import { @@ -312,6 +313,7 @@ describe("experimental durable server composition", () => { await expect(firstServices.catalogue(BACKGROUND_CONTEXT)).resolves.toEqual([ { serviceId: SessionDirectory.id, mode: "singleton" }, { serviceId: SessionManagement.id, mode: "singleton" }, + { serviceId: PresentationPlugins.id, mode: "singleton" }, ]); const firstDirectory = firstServices.use(SessionDirectory); const secondDirectory = secondServices.use(SessionDirectory); diff --git a/packages/coding-agent/test/experimental-slash-commands.test.ts b/packages/coding-agent/test/experimental-slash-commands.test.ts index 7880298bb..1677c5163 100644 --- a/packages/coding-agent/test/experimental-slash-commands.test.ts +++ b/packages/coding-agent/test/experimental-slash-commands.test.ts @@ -1,6 +1,5 @@ import { createFacetHost, defineFacet } from "@earendil-works/chord"; import { describe, expect, test, vi } from "vitest"; -import helloPluginFacet from "../src/experimental/plugins/hello.ts"; import { SlashCommands } from "../src/experimental/services/slash-commands.ts"; import { createSlashCommandsRuntimeFacet, @@ -12,9 +11,9 @@ describe("experimental slash command facets", () => { const registry = new SlashCommandRegistry(); const snapshots: string[][] = []; const unsubscribe = registry.subscribe((commands) => snapshots.push(commands.map(({ name }) => name))); - const close = registry.register({ name: "hello", description: "Hello", run() {} }); + const close = registry.register({ name: "hello", description: "Hello", run: () => undefined }); expect(registry.list().map(({ name }) => name)).toEqual(["hello"]); - expect(() => registry.register({ name: "hello", run() {} })).toThrow("already registered"); + expect(() => registry.register({ name: "hello", run: () => undefined })).toThrow("already registered"); close(); close(); expect(registry.list()).toEqual([]); @@ -25,7 +24,16 @@ describe("experimental slash command facets", () => { test("tracks plugin facet reload and unload", async () => { const registry = new SlashCommandRegistry(); const host = await createFacetHost({ - facets: [createSlashCommandsRuntimeFacet(registry), helloPluginFacet], + facets: [ + createSlashCommandsRuntimeFacet(registry), + defineFacet({ + id: "@pi/example-hello", + setup(env) { + const commands = env.use(SlashCommands); + env.onActivate(() => env.own(commands.register({ name: "hello", run: () => undefined }))); + }, + }), + ], }); expect(registry.list().map(({ name }) => name)).toEqual(["hello"]); diff --git a/packages/coding-agent/tsconfig.build.json b/packages/coding-agent/tsconfig.build.json index 5fee9be1b..c5bef8ae0 100644 --- a/packages/coding-agent/tsconfig.build.json +++ b/packages/coding-agent/tsconfig.build.json @@ -4,6 +4,7 @@ "outDir": "./dist", "paths": { "@earendil-works/chord": ["../chord/dist/index.d.ts"], + "@earendil-works/chord/node": ["../chord/dist/node.d.ts"], "@earendil-works/pi-agent-core": ["../agent/dist/index.d.ts"], "@earendil-works/pi-agent-core/*": ["../agent/dist/*.d.ts"], "@earendil-works/pi-ai": ["../ai/dist/index.d.ts"], diff --git a/packages/protocol/README.md b/packages/protocol/README.md index 3f81fea53..c8d7cb2f8 100644 --- a/packages/protocol/README.md +++ b/packages/protocol/README.md @@ -2,9 +2,9 @@ Runtime-neutral schemas, types, CBOR encoding, and byte-stream framing for the experimental Pi protocol. -Protocol version `3` carries the current server- and Session-scoped facet-service slice: +Protocol version `4` carries the current server- and Session-scoped facet-service slice: -- a version handshake that identifies the logical `serverId`; +- a version handshake that identifies the logical `serverId` and can carry application bootstrap data; - explicit server and Session request targets; - contract-agnostic service calls with optional keyed-instance identity; - singleton and keyed-service subscription snapshots plus ordered state, spawn, and close updates; diff --git a/packages/protocol/src/protocol.ts b/packages/protocol/src/protocol.ts index e90f5e5a6..dc25d2477 100644 --- a/packages/protocol/src/protocol.ts +++ b/packages/protocol/src/protocol.ts @@ -13,7 +13,7 @@ import { type RpcResultUnion, } from "./rpc.ts"; -export const PROTOCOL_VERSION = 3 as const; +export const PROTOCOL_VERSION = 4 as const; const IdSchema = Type.String({ minLength: 1 }); const TimestampSchema = Type.Integer({ minimum: 0 }); @@ -352,6 +352,8 @@ export const ServerHelloSchema = StrictObject({ type: Type.Literal("hello"), version: Type.Literal(PROTOCOL_VERSION), serverId: ServerIdSchema, + /** Application-owned connection bootstrap data, such as presentation facet artifacts. */ + data: Type.Optional(JsonValueSchema), }); export const ServerHelloErrorSchema = StrictObject({ type: Type.Literal("hello_error"), diff --git a/packages/protocol/test/protocol.test.ts b/packages/protocol/test/protocol.test.ts index b8f566272..e975bcbbd 100644 --- a/packages/protocol/test/protocol.test.ts +++ b/packages/protocol/test/protocol.test.ts @@ -207,11 +207,11 @@ describe("RPC manifest", () => { }); describe("protocol validation", () => { - test("negotiates protocol version 3", () => { - expect(PROTOCOL_VERSION).toBe(3); - expect(isSupportedProtocolVersion(3)).toBe(true); - expect(isSupportedProtocolVersion(2)).toBe(false); - expect(isSupportedProtocolVersion(3.5)).toBe(false); + test("negotiates protocol version 4", () => { + expect(PROTOCOL_VERSION).toBe(4); + expect(isSupportedProtocolVersion(4)).toBe(true); + expect(isSupportedProtocolVersion(3)).toBe(false); + expect(isSupportedProtocolVersion(4.5)).toBe(false); }); test.each([0, PROTOCOL_VERSION, PROTOCOL_VERSION + 1])( @@ -617,6 +617,11 @@ describe("protocol validation", () => { }); }); + test("accepts application bootstrap data in the server hello", () => { + const message: ServerHello = { ...serverHello, data: { presentationFacets: [{ id: "tui" }] } }; + expect(parseServerMessage(message)).toEqual(message); + }); + test.each([ ["invalid server id", { ...serverHello, serverId: "server-1" }], ["extra response field", { type: "response", id: "request-1", ok: true, result: [], extra: true }], diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 8983c48a4..4d2a17d33 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -10,6 +10,7 @@ import { encodeServerMessage, isServerId, isSupportedProtocolVersion, + type JsonValue, PROTOCOL_VERSION, type ProtocolError, type ProtocolRpcResult, @@ -43,6 +44,7 @@ export class Server { private readonly host: ServerHost; private readonly listeners: readonly ServerListener[]; + private helloData: JsonValue | undefined; private readonly maxFrameLength: number; private readonly handshakeTimeoutMs: number; private readonly onConnectionCountChanged: ((count: number) => void) | undefined; @@ -62,6 +64,7 @@ export class Server { this.host = host; this.listeners = options.listeners; this.serverId = options.serverId; + this.helloData = options.helloData; this.maxFrameLength = resolved.maxFrameLength; this.handshakeTimeoutMs = resolved.handshakeTimeoutMs; this.onConnectionCountChanged = options.onConnectionCountChanged; @@ -85,6 +88,11 @@ export class Server { void this.closed.catch(() => {}); } + /** Replace application bootstrap data used by subsequent handshakes. */ + setHelloData(data: JsonValue | undefined): void { + this.helloData = data; + } + start(): Promise { if (this.started) return Promise.reject(new Error("Server is already started")); if (this.startPromise) return Promise.reject(new Error("Server is already starting")); @@ -283,6 +291,7 @@ export class Server { type: "hello", version: PROTOCOL_VERSION, serverId: this.serverId, + ...(this.helloData === undefined ? {} : { data: this.helloData }), } satisfies ServerHello); if (sent && !state.disconnected && state.stage === "handshaking") { state.stage = "ready"; diff --git a/packages/server/src/transports/unix/preset.ts b/packages/server/src/transports/unix/preset.ts index 2915f7cd2..52daae976 100644 --- a/packages/server/src/transports/unix/preset.ts +++ b/packages/server/src/transports/unix/preset.ts @@ -23,6 +23,7 @@ export function createUnixServer( handshakeTimeoutMs: options.handshakeTimeoutMs, onConnectionCountChanged: options.onConnectionCountChanged, serverId: options.serverId, + helloData: options.helloData, onError: options.onError, }); } diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts index 2a85e509e..5060757a1 100644 --- a/packages/server/src/types.ts +++ b/packages/server/src/types.ts @@ -1,5 +1,6 @@ import type { Context, SessionMetadata } from "@earendil-works/pi-agent-core"; import type { + JsonValue, LaneEvent, LaneSnapshot, PromptArguments, @@ -15,6 +16,8 @@ export interface ServerOptions { listeners: readonly ServerListener[]; /** Stable logical server identity supplied by the installation or profile. */ serverId: string; + /** Application-owned bootstrap data included in every successful server handshake. */ + helloData?: JsonValue; maxFrameLength?: number; handshakeTimeoutMs?: number; onConnectionCountChanged?: (count: number) => void; diff --git a/packages/server/test/conformance.test.ts b/packages/server/test/conformance.test.ts index 04aa385ba..fe864db7b 100644 --- a/packages/server/test/conformance.test.ts +++ b/packages/server/test/conformance.test.ts @@ -3,12 +3,20 @@ import { afterEach, describe, expect, test } from "vitest"; import type { ByteConnection, ByteConnectionHandler } from "../src/connection.ts"; import { Server } from "../src/server.ts"; import { Deferred, ProtocolTestClient, TestServerHost, type WireChannel } from "../src/testing/index.ts"; -import type { ServerHost } from "../src/types.ts"; +import type { ServerHost, ServerOptions } from "../src/types.ts"; const servers = new Set(); -function createServer(host: ServerHost, serverId = "00000000-0000-4000-8000-000000000001"): Server { - const server = new Server(host, { listeners: [], serverId }); +function createServer( + host: ServerHost, + serverId = "00000000-0000-4000-8000-000000000001", + helloData?: ServerOptions["helloData"], +): Server { + const server = new Server(host, { + listeners: [], + serverId, + ...(helloData === undefined ? {} : { helloData }), + }); servers.add(server); return server; } @@ -65,6 +73,21 @@ describe("Session protocol", () => { expect(host.harnesses.size).toBe(0); }); + test("handshake includes current application bootstrap data", async () => { + const host = new TestServerHost(); + const server = createServer(host, "00000000-0000-4000-8000-000000000001", { + presentationFacetBundles: [{ id: "tui-1" }], + }); + await expect(connect(server).hello()).resolves.toMatchObject({ + data: { presentationFacetBundles: [{ id: "tui-1" }] }, + }); + + server.setHelloData({ presentationFacetBundles: [{ id: "tui-2" }] }); + await expect(connect(server).hello()).resolves.toMatchObject({ + data: { presentationFacetBundles: [{ id: "tui-2" }] }, + }); + }); + test("list returns presentation-safe summaries without opening a Session", async () => { const host = new TestServerHost(); const metadata = await host.seed("session-1", "parent-1"); diff --git a/scripts/build-coding-agent-bundle.mjs b/scripts/build-coding-agent-bundle.mjs index 2c5d49a8c..a8c018dc7 100644 --- a/scripts/build-coding-agent-bundle.mjs +++ b/scripts/build-coding-agent-bundle.mjs @@ -16,6 +16,9 @@ const banner = { js: 'import { createRequire as __piCreateRequire } from "node:module"; const require = __piCreateRequire(import.meta.url);', }; const allowedExternalPackages = new Set([ + "@earendil-works/chord", + "@earendil-works/chord/context", + "@earendil-works/chord/node", "@silvia-odwyer/photon-node", "jiti", // Optional native accelerators. Their callers fall back to JavaScript when absent. @@ -79,7 +82,7 @@ function commonBuildOptions() { banner, bundle: true, define: { PI_BUNDLED_NODE: "true" }, - external: ["@silvia-odwyer/photon-node"], + external: ["@earendil-works/chord", "@silvia-odwyer/photon-node"], format: "esm", legalComments: "none", logLevel: "warning", diff --git a/scripts/generate-coding-agent-install-lock.mjs b/scripts/generate-coding-agent-install-lock.mjs index 57ecb1334..d1e347ef5 100644 --- a/scripts/generate-coding-agent-install-lock.mjs +++ b/scripts/generate-coding-agent-install-lock.mjs @@ -16,6 +16,7 @@ const internalPackageNames = new Set(["@earendil-works/chord"]); const installPackageName = "@earendil-works/pi-coding-agent-install"; const allowedInstallScriptPackages = new Map([ ["@google/genai@1.52.0", "preinstall is a no-op in the published package"], + ["esbuild@0.28.1", "postinstall selects and verifies the platform-specific esbuild binary"], ["protobufjs@7.6.5", "postinstall only warns about protobufjs version scheme mismatches"], ]); diff --git a/scripts/generate-coding-agent-shrinkwrap.mjs b/scripts/generate-coding-agent-shrinkwrap.mjs index b25008bd8..bd451d6f7 100644 --- a/scripts/generate-coding-agent-shrinkwrap.mjs +++ b/scripts/generate-coding-agent-shrinkwrap.mjs @@ -13,6 +13,7 @@ const internalPackagePrefix = "@earendil-works/pi-"; const internalPackageNames = new Set(["@earendil-works/chord"]); const allowedInstallScriptPackages = new Map([ ["@google/genai@1.52.0", "preinstall is a no-op in the published package"], + ["esbuild@0.28.1", "postinstall selects and verifies the platform-specific esbuild binary"], ["protobufjs@7.6.5", "postinstall only warns about protobufjs version scheme mismatches"], ]); diff --git a/tsconfig.json b/tsconfig.json index 2b7eb1f5c..a2d684e9c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,9 @@ "paths": { "*": ["./*"], "@earendil-works/chord": ["./packages/chord/src/index.ts"], + "@earendil-works/chord/bundler": ["./packages/chord/src/bundler.ts"], "@earendil-works/chord/context": ["./packages/chord/src/context/index.ts"], + "@earendil-works/chord/node": ["./packages/chord/src/node.ts"], "@earendil-works/pi-telemetry": ["./packages/telemetry/src/index.ts"], "@earendil-works/pi-telemetry/testing": ["./packages/telemetry/src/testing/index.ts"], "@earendil-works/pi-ai": ["./packages/ai/src/index.ts"],