diff --git a/packages/chord/PLANNING.md b/packages/chord/PLANNING.md index 545b61a48..6f43fe708 100644 --- a/packages/chord/PLANNING.md +++ b/packages/chord/PLANNING.md @@ -505,6 +505,8 @@ The initial bundler should accept: - source-map and minification options; and - optional define/platform settings needed for Node ESM. +The package-level API additionally accepts a plugin package directory, derives identity and version from `package.json`, applies application-supplied conventional entry paths when those files exist, and lets `chord.facets` override or disable conventions. Package discovery does not install dependencies or run lifecycle scripts. + It should emit: - one content-addressed ESM file per entry; @@ -519,7 +521,7 @@ Writes should use a temporary output directory followed by an atomic rename so a ### 10.3 Bundle rules - `@earendil-works/chord` must be externalized so a plugin uses the host's one runtime and branding symbols. -- Other dependencies are bundled by default unless the application explicitly externalizes them. +- Other dependencies are bundled by default. The explicit bundler API uses an application external allowlist; the package-level API also externalizes peer dependencies because the host provides them. - Built-in module use may be allowed for Node entries but is not a trust or sandbox policy. - Dynamic imports and unresolved externals must be reported deterministically. - Bundle output must not depend on Pi's repository path aliases. diff --git a/packages/chord/README.md b/packages/chord/README.md index 5bd1872da..9f8f09b62 100644 --- a/packages/chord/README.md +++ b/packages/chord/README.md @@ -60,21 +60,46 @@ prefix is `$chord.*`. ## 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: +names into independent, content-addressed ESM files. The package-level API reads +plugin identity and build configuration from `package.json`, then applies facet +path conventions supplied by the host application: + +```json +{ + "name": "@example/my-plugin", + "version": "1.0.0", + "type": "module", + "peerDependencies": { + "@earendil-works/chord": "^0.84.4" + }, + "chord": { + "facets": { + "worker": "./src/custom-worker.ts", + "presentation": false + } + } +} +``` ```ts -import { bundleFacets } from "@earendil-works/chord/bundler"; +import { bundleFacetPackage } 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, +await bundleFacetPackage({ + packagePath: "/path/to/my-plugin", + outdir: "/application-owned/plugin-builds/my-plugin", + defaultFacets: { + worker: "src/worker.ts", + presentation: "src/presentation.ts", + }, }); ``` +Existing conventional files become entries unless `chord.facets` overrides or +disables them. Peer dependencies are externalized and resolved against the host +when loading. Chord never installs dependencies or runs package lifecycle +scripts. `bundleFacets()` remains available as the lower-level API for callers +that already have explicit plugin identity and entry mappings. + The output directory contains one `.js` file per entry plus `chord-facets.json`. Load one application-selected entry through the Node-only loader: @@ -83,8 +108,9 @@ loader: import { createFacetBundleLoader } from "@earendil-works/chord/node"; const loader = createFacetBundleLoader({ - manifestPath: "dist/facets/chord-facets.json", + manifestPath: "/application-owned/plugin-builds/my-plugin/chord-facets.json", entry: "worker", + resolveExternal: (specifier) => import.meta.resolve(specifier), }); const loaded = await loader.load(); ``` diff --git a/packages/chord/src/bundler.ts b/packages/chord/src/bundler.ts index c57340750..ef60ebc2a 100644 --- a/packages/chord/src/bundler.ts +++ b/packages/chord/src/bundler.ts @@ -5,3 +5,5 @@ export type { } from "./node/bundle.ts"; export { bundleFacets } from "./node/bundle.ts"; export type { FacetBundleEntry, FacetBundleManifest } from "./node/manifest.ts"; +export type { BundleFacetPackageOptions, BundleFacetPackageResult } from "./node/package.ts"; +export { bundleFacetPackage } from "./node/package.ts"; diff --git a/packages/chord/src/node.ts b/packages/chord/src/node.ts index 083248b3a..24f2f1564 100644 --- a/packages/chord/src/node.ts +++ b/packages/chord/src/node.ts @@ -1,4 +1,8 @@ -export type { FacetBundleArtifactLoaderOptions, FacetBundleLoaderOptions } from "./node/bundle-loader.ts"; +export type { + FacetBundleArtifactLoaderOptions, + FacetBundleExternalResolver, + FacetBundleLoaderOptions, +} from "./node/bundle-loader.ts"; export { createFacetBundleArtifactLoader, createFacetBundleLoader, diff --git a/packages/chord/src/node/bundle-loader.ts b/packages/chord/src/node/bundle-loader.ts index 646f84091..172b52f52 100644 --- a/packages/chord/src/node/bundle-loader.ts +++ b/packages/chord/src/node/bundle-loader.ts @@ -16,15 +16,23 @@ import { type FacetBundleManifest, } from "./manifest.ts"; +export type FacetBundleExternalResolver = (specifier: string) => string | URL | undefined; + 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; + /** Resolve host-provided external imports when the bundle is outside the host's package tree. */ + readonly resolveExternal?: FacetBundleExternalResolver; + /** Parent directory for materialized module generations. Defaults to the operating system temp directory. */ + readonly temporaryDirectory?: string; } export interface FacetBundleArtifactLoaderOptions { readonly artifact: unknown; + /** Resolve host-provided external imports against the receiving application. */ + readonly resolveExternal?: FacetBundleExternalResolver; /** Parent directory for materialized module generations. Defaults to the operating system temp directory. */ readonly temporaryDirectory?: string; } @@ -84,7 +92,7 @@ export function createFacetBundleArtifactLoader(options: FacetBundleArtifactLoad await mkdir(temporaryParent, { recursive: true }); const directory = await mkdtemp(join(temporaryParent, "chord-facet-")); try { - await materializeArtifact(directory, artifact); + await materializeArtifact(directory, artifact, options.resolveExternal); const loaded = await createFacetBundleLoader({ manifestPath: join(directory, FACET_BUNDLE_MANIFEST_FILE), entry: artifact.entryName, @@ -128,6 +136,7 @@ export function createFacetBundleArtifactLoader(options: FacetBundleArtifactLoad 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 temporaryParent = resolve(options.temporaryDirectory ?? tmpdir()); const loaderId = randomUUID(); let generation = 0; return { @@ -137,30 +146,68 @@ export function createFacetBundleLoader(options: FacetBundleLoaderOptions): Face 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; + const originalModulePath = resolveBundleFile(manifestPath, entry.file, "entry"); + if (options.verifyIntegrity !== false) await verifyEntry(originalModulePath, entry); + let generationDirectory: string | undefined; + let modulePath = originalModulePath; try { - imported = await importModule(moduleUrl.href); + if (options.resolveExternal !== undefined && entry.externalImports.length > 0) { + await mkdir(temporaryParent, { recursive: true }); + generationDirectory = await mkdtemp(join(temporaryParent, "chord-facet-")); + const source = await readFile(originalModulePath, "utf8"); + const sourceMapContents = + entry.sourceMap === undefined + ? undefined + : await readFile(resolveBundleFile(manifestPath, entry.sourceMap, "source map"), "utf8"); + await materializeArtifact( + generationDirectory, + { + format: FACET_BUNDLE_ARTIFACT_FORMAT, + formatVersion: FACET_BUNDLE_ARTIFACT_FORMAT_VERSION, + plugin: manifest.plugin, + entryName: options.entry, + entry, + source, + ...(sourceMapContents === undefined ? {} : { sourceMapContents }), + }, + options.resolveExternal, + ); + modulePath = join(generationDirectory, entry.file); + } + const moduleUrl = pathToFileURL(modulePath); + moduleUrl.searchParams.set("chordGeneration", `${loaderId}-${++generation}`); + const imported = await importModule(moduleUrl.href); + 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([]); + if (generationDirectory !== undefined) { + await rm(generationDirectory, { force: true, recursive: true }); + } + }, + }; } catch (error) { - throw new Error(`Could not load facet bundle entry ${manifest.plugin.id}/${options.entry}`, { + if (generationDirectory !== undefined) { + try { + await rm(generationDirectory, { force: true, recursive: true }); + } catch (cleanupError) { + throw new AggregateError( + [error, cleanupError], + `Could not load facet bundle entry ${manifest.plugin.id}/${options.entry}`, + ); + } + } + const detail = error instanceof Error ? `: ${error.message}` : ""; + throw new Error(`Could not load facet bundle entry ${manifest.plugin.id}/${options.entry}${detail}`, { 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([]); - }, - }; }, }; } @@ -263,7 +310,11 @@ function validateArtifact(value: unknown): FacetBundleArtifact { }); } -async function materializeArtifact(directory: string, artifact: FacetBundleArtifact): Promise { +async function materializeArtifact( + directory: string, + artifact: FacetBundleArtifact, + resolveExternal?: FacetBundleExternalResolver, +): Promise { const manifest: FacetBundleManifest = { format: FACET_BUNDLE_FORMAT, formatVersion: FACET_BUNDLE_FORMAT_VERSION, @@ -276,11 +327,15 @@ async function materializeArtifact(directory: string, artifact: FacetBundleArtif ...(artifact.entry.sourceMap === undefined ? [] : [writeFile(join(directory, artifact.entry.sourceMap), artifact.sourceMapContents!, "utf8")]), - materializeExternalProxies(directory, artifact.entry.externalImports), + materializeExternalProxies(directory, artifact.entry.externalImports, resolveExternal), ]); } -async function materializeExternalProxies(directory: string, specifiers: readonly string[]): Promise { +async function materializeExternalProxies( + directory: string, + specifiers: readonly string[], + resolveExternal?: FacetBundleExternalResolver, +): Promise { const packages = new Map>(); for (const specifier of specifiers) { if (isBuiltin(specifier)) continue; @@ -290,7 +345,7 @@ async function materializeExternalProxies(directory: string, specifiers: readonl entries = new Map(); packages.set(parsed.packageName, entries); } - entries.set(parsed.exportName, resolveExternalTarget(specifier)); + entries.set(parsed.exportName, resolveExternalTarget(specifier, resolveExternal)); } for (const [packageName, entries] of packages) { const packageDirectory = join(directory, "node_modules", ...packageName.split("/")); @@ -320,7 +375,9 @@ async function materializeExternalProxies(directory: string, specifiers: readonl } } -function resolveExternalTarget(specifier: string): string { +function resolveExternalTarget(specifier: string, resolver?: FacetBundleExternalResolver): string { + const resolved = resolver?.(specifier); + if (resolved !== undefined) return typeof resolved === "string" ? resolved : resolved.href; 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") { diff --git a/packages/chord/src/node/package.ts b/packages/chord/src/node/package.ts new file mode 100644 index 000000000..943374e39 --- /dev/null +++ b/packages/chord/src/node/package.ts @@ -0,0 +1,232 @@ +import { readFile, realpath, stat } from "node:fs/promises"; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { type BundleFacetsResult, bundleFacets } from "./bundle.ts"; + +export interface BundleFacetPackageOptions { + /** Plugin package directory or its package.json path. */ + readonly packagePath: string; + readonly outdir: string; + /** Application conventions applied when the corresponding source file exists. */ + readonly defaultFacets?: Readonly>; +} + +export interface BundleFacetPackageResult extends BundleFacetsResult { + readonly packageDirectory: string; + readonly packageJsonPath: string; +} + +interface FacetPackageMetadata { + readonly packageDirectory: string; + readonly packageJsonPath: string; + readonly name: string; + readonly version: string; + readonly peerDependencies: readonly string[]; + readonly configuredFacets: Readonly>; + readonly external: readonly string[]; + readonly sourceMap: boolean; +} + +/** Build a plugin package using package.json metadata and application-provided facet conventions. */ +export async function bundleFacetPackage(options: BundleFacetPackageOptions): Promise { + const metadata = await readFacetPackageMetadata(options.packagePath); + const entries = await resolveFacetEntries(metadata, options.defaultFacets ?? {}); + const external = [...metadata.peerDependencies, ...metadata.external].flatMap((specifier) => [ + specifier, + `${specifier}/*`, + ]); + const result = await bundleFacets({ + plugin: { id: metadata.name, version: metadata.version }, + entries, + outdir: options.outdir, + workingDirectory: metadata.packageDirectory, + external, + sourceMap: metadata.sourceMap, + }); + return Object.freeze({ + ...result, + packageDirectory: metadata.packageDirectory, + packageJsonPath: metadata.packageJsonPath, + }); +} + +async function readFacetPackageMetadata(packagePath: string): Promise { + if (packagePath.length === 0) throw new TypeError("Facet package path must not be empty"); + const candidate = resolve(packagePath); + let packageDirectory: string; + let packageJsonPath: string; + let candidateStats: Awaited>; + try { + candidateStats = await stat(candidate); + } catch (error) { + throw new Error(`Could not access facet package ${candidate}`, { cause: error }); + } + if (candidateStats.isDirectory()) { + packageDirectory = await realpath(candidate); + packageJsonPath = join(packageDirectory, "package.json"); + } else if (candidateStats.isFile() && basename(candidate) === "package.json") { + packageJsonPath = await realpath(candidate); + packageDirectory = dirname(packageJsonPath); + } else { + throw new Error(`Facet package path must name a directory or package.json: ${candidate}`); + } + + let parsed: unknown; + try { + parsed = JSON.parse(await readFile(packageJsonPath, "utf8")); + } catch (error) { + throw new Error(`Could not read facet package metadata ${packageJsonPath}`, { cause: error }); + } + if (!isRecord(parsed)) throw new Error(`Facet package metadata must be an object: ${packageJsonPath}`); + if (typeof parsed.name !== "string" || parsed.name.length === 0) { + throw new Error(`Facet package must have a non-empty name: ${packageJsonPath}`); + } + if (typeof parsed.version !== "string" || parsed.version.length === 0) { + throw new Error(`Facet package must have a non-empty version: ${packageJsonPath}`); + } + const peerDependencies = parsePeerDependencies(parsed.peerDependencies, packageJsonPath); + const chord = parseChordConfiguration(parsed.chord, packageJsonPath); + return { + packageDirectory, + packageJsonPath, + name: parsed.name, + version: parsed.version, + peerDependencies, + configuredFacets: chord.facets, + external: chord.external, + sourceMap: chord.sourceMap, + }; +} + +function parsePeerDependencies(value: unknown, packageJsonPath: string): readonly string[] { + if (value === undefined) return []; + if ( + !isRecord(value) || + Object.entries(value).some(([name, version]) => name.length === 0 || typeof version !== "string") + ) { + throw new Error(`Facet package has invalid peerDependencies: ${packageJsonPath}`); + } + return Object.freeze(Object.keys(value).sort()); +} + +function parseChordConfiguration( + value: unknown, + packageJsonPath: string, +): { + readonly facets: Readonly>; + readonly external: readonly string[]; + readonly sourceMap: boolean; +} { + if (value === undefined) return { facets: Object.freeze({}), external: Object.freeze([]), sourceMap: true }; + if (!isRecord(value)) throw new Error(`Facet package chord configuration must be an object: ${packageJsonPath}`); + if (Object.keys(value).some((key) => key !== "facets" && key !== "external" && key !== "sourceMap")) { + throw new Error(`Facet package chord configuration has an unknown field: ${packageJsonPath}`); + } + const facets: Record = {}; + if (value.facets !== undefined) { + if (!isRecord(value.facets)) { + throw new Error(`Facet package chord.facets must be an object: ${packageJsonPath}`); + } + for (const [name, source] of Object.entries(value.facets)) { + if (name.length === 0 || (typeof source !== "string" && source !== false) || source === "") { + throw new Error(`Facet package has an invalid chord.facets entry: ${packageJsonPath}`); + } + facets[name] = source; + } + } + let external: readonly string[] = []; + if (value.external !== undefined) { + if ( + !Array.isArray(value.external) || + value.external.some((specifier) => typeof specifier !== "string" || specifier.length === 0) + ) { + throw new Error(`Facet package chord.external must contain non-empty strings: ${packageJsonPath}`); + } + external = Object.freeze([...new Set(value.external as readonly string[])].sort()); + } + if (value.sourceMap !== undefined && typeof value.sourceMap !== "boolean") { + throw new Error(`Facet package chord.sourceMap must be a boolean: ${packageJsonPath}`); + } + return { + facets: Object.freeze(facets), + external, + sourceMap: value.sourceMap ?? true, + }; +} + +async function resolveFacetEntries( + metadata: FacetPackageMetadata, + defaultFacets: Readonly>, +): Promise>> { + const entries: Record = {}; + for (const [name, source] of Object.entries(defaultFacets)) { + validateFacetMapping(name, source, "default"); + const path = resolvePackageEntry(metadata.packageDirectory, source, name); + try { + const entryStats = await stat(path); + if (!entryStats.isFile()) throw new Error(`Default facet entry ${name} is not a file: ${path}`); + const canonicalPath = await realpath(path); + validateCanonicalPackageEntry(metadata.packageDirectory, canonicalPath, name); + entries[name] = canonicalPath; + } catch (error) { + if (isMissingPath(error)) continue; + throw error; + } + } + for (const [name, source] of Object.entries(metadata.configuredFacets)) { + if (source === false) { + delete entries[name]; + continue; + } + validateFacetMapping(name, source, "configured"); + const path = resolvePackageEntry(metadata.packageDirectory, source, name); + let entryStats: Awaited>; + try { + entryStats = await stat(path); + } catch (error) { + throw new Error(`Could not access configured facet entry ${name}: ${path}`, { cause: error }); + } + if (!entryStats.isFile()) throw new Error(`Configured facet entry ${name} is not a file: ${path}`); + const canonicalPath = await realpath(path); + validateCanonicalPackageEntry(metadata.packageDirectory, canonicalPath, name); + entries[name] = canonicalPath; + } + if (Object.keys(entries).length === 0) { + throw new Error(`Facet package ${metadata.name} has no configured or conventional facet entries`); + } + return Object.freeze(entries); +} + +function validateFacetMapping(name: string, source: string, kind: string): void { + if (name.length === 0) throw new Error(`Facet package ${kind} entry name must not be empty`); + if (source.length === 0) throw new Error(`Facet package ${kind} entry ${name} must have a source path`); +} + +function resolvePackageEntry(packageDirectory: string, source: string, name: string): string { + if (isAbsolute(source)) throw new Error(`Facet package entry ${name} must be relative to the package directory`); + const path = resolve(packageDirectory, source); + const relativePath = relative(packageDirectory, path); + if ( + relativePath.length === 0 || + relativePath === ".." || + relativePath.startsWith(`..${sep}`) || + isAbsolute(relativePath) + ) { + throw new Error(`Facet package entry ${name} escapes the package directory`); + } + return path; +} + +function validateCanonicalPackageEntry(packageDirectory: string, path: string, name: string): void { + const relativePath = relative(packageDirectory, path); + if (relativePath === ".." || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) { + throw new Error(`Facet package entry ${name} resolves outside the package directory`); + } +} + +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/test/bundle.test.ts b/packages/chord/test/bundle.test.ts index 2dab71c9f..1a8a44700 100644 --- a/packages/chord/test/bundle.test.ts +++ b/packages/chord/test/bundle.test.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promis 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 { bundleFacetPackage, bundleFacets } from "../src/bundler.ts"; import { createFacetHost, defineFacet, defineService } from "../src/index.ts"; import { createFacetBundleArtifactLoader, @@ -116,6 +116,79 @@ describe("facet bundles", () => { await loadedB.dispose(); }); + test("builds plugin packages from conventional and configured facet entries", async () => { + const directory = await mkdtemp(join(packageDirectory, ".bundle-package-test-")); + temporaryDirectories.push(directory); + const sourceDirectory = join(directory, "src"); + await mkdir(sourceDirectory); + await Promise.all([ + writeFile( + join(directory, "package.json"), + `${JSON.stringify({ + name: "@example/conventional-plugin", + version: "1.2.3", + peerDependencies: { "@example/host": "^1.0.0" }, + })}\n`, + ), + writeFile( + join(sourceDirectory, "session.ts"), + 'import "@example/host/plugin"; export default { id: "package-session", setup() {} };\n', + ), + writeFile(join(sourceDirectory, "tui.ts"), 'export default { id: "package-tui", setup() {} };\n'), + writeFile(join(sourceDirectory, "contract.ts"), "export const ignored = true;\n"), + writeFile(join(sourceDirectory, "presentation.ts"), 'export default { id: "configured-tui", setup() {} };\n'), + ]); + + const conventional = await bundleFacetPackage({ + packagePath: directory, + outdir: join(directory, "build"), + defaultFacets: { session: "src/session.ts", tui: "src/tui.ts", browser: "src/browser.ts" }, + }); + expect(conventional.packageDirectory).toBe(directory); + expect(conventional.manifest.plugin).toEqual({ id: "@example/conventional-plugin", version: "1.2.3" }); + expect(Object.keys(conventional.manifest.entries)).toEqual(["session", "tui"]); + expect(conventional.manifest.entries.session?.externalImports).toEqual(["@example/host/plugin"]); + expect(conventional.manifest.entries.tui?.sourceMap).toMatch(/\.js\.map$/u); + + await writeFile( + join(directory, "package.json"), + `${JSON.stringify({ + name: "@example/conventional-plugin", + version: "2.0.0", + chord: { facets: { session: false, tui: "src/presentation.ts" }, sourceMap: false }, + })}\n`, + ); + const configured = await bundleFacetPackage({ + packagePath: join(directory, "package.json"), + outdir: join(directory, "build"), + defaultFacets: { session: "src/session.ts", tui: "src/tui.ts" }, + }); + expect(Object.keys(configured.manifest.entries)).toEqual(["tui"]); + expect(configured.manifest.entries.tui?.sourceMap).toBeUndefined(); + const loaded = await createFacetBundleLoader({ + manifestPath: configured.manifestPath, + entry: "tui", + }).load(); + expect(loaded.facets.map(({ id }) => id)).toEqual(["configured-tui"]); + await loaded.dispose(); + }); + + test("rejects invalid plugin package entry configuration", async () => { + const directory = await mkdtemp(join(packageDirectory, ".bundle-package-test-")); + temporaryDirectories.push(directory); + await writeFile( + join(directory, "package.json"), + `${JSON.stringify({ + name: "invalid-plugin", + version: "1.0.0", + chord: { facets: { tui: "../outside.ts" } }, + })}\n`, + ); + await expect(bundleFacetPackage({ packagePath: directory, outdir: join(directory, "build") })).rejects.toThrow( + "escapes the package directory", + ); + }); + test("rejects corrupt entries and invalid module exports", async () => { const directory = await mkdtemp(join(packageDirectory, ".bundle-test-")); temporaryDirectories.push(directory); diff --git a/packages/coding-agent/examples/README.md b/packages/coding-agent/examples/README.md index ddab5268f..4733cdd1c 100644 --- a/packages/coding-agent/examples/README.md +++ b/packages/coding-agent/examples/README.md @@ -18,8 +18,8 @@ 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. +### [plugins/pi-example-plugin/](plugins/pi-example-plugin/) +An experimental plugin package that Pi automatically builds into separate Session-worker and TUI Chord facets. ## Documentation diff --git a/packages/coding-agent/examples/facets/README.md b/packages/coding-agent/examples/facets/README.md deleted file mode 100644 index 86eef4d7c..000000000 --- a/packages/coding-agent/examples/facets/README.md +++ /dev/null @@ -1,27 +0,0 @@ -# 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 deleted file mode 100644 index 55254c137..000000000 --- a/packages/coding-agent/examples/facets/build.mjs +++ /dev/null @@ -1,16 +0,0 @@ -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/plugins/pi-example-plugin/README.md b/packages/coding-agent/examples/plugins/pi-example-plugin/README.md new file mode 100644 index 000000000..c71f7e3ea --- /dev/null +++ b/packages/coding-agent/examples/plugins/pi-example-plugin/README.md @@ -0,0 +1,38 @@ +# `@earendil-works/pi-example-plugin` + +This package provides conventional `session` and `tui` Chord facets. The Session-worker facet provides a remote greeting service. The TUI facet contributes `/facet-hello` and calls that service. + +The package needs no build script. Pi asks Chord to discover `src/session.ts` and `src/tui.ts`, builds both entries into its server-owned plugin cache, and sends the TUI artifact to clients. + +From the repository root: + +```bash +PI_EXPERIMENTAL=1 ./pi-test.sh server \ + -e "$PWD/packages/coding-agent/examples/plugins/pi-example-plugin" +``` + +Alternatively, a client can select the plugin for the Session it creates or resumes on one local server: + +```bash +PI_EXPERIMENTAL=1 ./pi-test.sh client \ + -e "$PWD/packages/coding-agent/examples/plugins/pi-example-plugin" +``` + +Repeat `-e` to select multiple plugin packages. Client paths are resolved locally and sent only to a Unix server; Radius clients cannot select server filesystem paths. The Session and matching TUI facets are stored with that Session, so later server generations and clients can resume it without plugin arguments. Other Sessions and their workers are unaffected. An active Session rejects a different package selection instead of being restarted. + +`server -e` establishes the server profile's default Session and TUI facets. Starting an explicit foreground server without `-e` clears that default. Client selection never changes the server's root facet generation. + +Run `/facet-hello Armin` in the TUI. After editing a facet, run `/reload`. The server atomically rebuilds the package, reloads the attached Session-worker generation, updates the current TUI generation, and advertises the new artifact to future clients. + +Package metadata can override or disable conventional entries: + +```json +{ + "chord": { + "facets": { + "session": "./src/worker.ts", + "tui": false + } + } +} +``` diff --git a/packages/coding-agent/examples/plugins/pi-example-plugin/package.json b/packages/coding-agent/examples/plugins/pi-example-plugin/package.json new file mode 100644 index 000000000..434b255b8 --- /dev/null +++ b/packages/coding-agent/examples/plugins/pi-example-plugin/package.json @@ -0,0 +1,10 @@ +{ + "name": "@earendil-works/pi-example-plugin", + "version": "1.0.0", + "private": true, + "type": "module", + "peerDependencies": { + "@earendil-works/chord": "^0.84.4", + "@earendil-works/pi-coding-agent": "^0.84.4" + } +} diff --git a/packages/coding-agent/examples/facets/contract.ts b/packages/coding-agent/examples/plugins/pi-example-plugin/src/contract.ts similarity index 92% rename from packages/coding-agent/examples/facets/contract.ts rename to packages/coding-agent/examples/plugins/pi-example-plugin/src/contract.ts index 47e4f3015..8927a9790 100644 --- a/packages/coding-agent/examples/facets/contract.ts +++ b/packages/coding-agent/examples/plugins/pi-example-plugin/src/contract.ts @@ -10,4 +10,4 @@ export interface ExampleFacetService { greet(input: { readonly name: string }, context: Context): Promise; } -export const ExampleFacetService = defineService("pi.example.bundled-facet"); +export const ExampleFacetService = defineService("pi.example-plugin.greeting"); diff --git a/packages/coding-agent/examples/facets/session.ts b/packages/coding-agent/examples/plugins/pi-example-plugin/src/session.ts similarity index 91% rename from packages/coding-agent/examples/facets/session.ts rename to packages/coding-agent/examples/plugins/pi-example-plugin/src/session.ts index aa39b31b3..db7277874 100644 --- a/packages/coding-agent/examples/facets/session.ts +++ b/packages/coding-agent/examples/plugins/pi-example-plugin/src/session.ts @@ -3,14 +3,14 @@ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; import { ExampleFacetService } from "./contract.ts"; export default defineFacet({ - id: "@pi/example-bundled:session", + id: "@earendil-works/pi-example-plugin/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.`, + message: `Hello ${name} from the bundled Session worker facet!!!`, workerActivations: workerActivations.value, }; }, diff --git a/packages/coding-agent/examples/facets/tui.ts b/packages/coding-agent/examples/plugins/pi-example-plugin/src/tui.ts similarity index 75% rename from packages/coding-agent/examples/facets/tui.ts rename to packages/coding-agent/examples/plugins/pi-example-plugin/src/tui.ts index 97b298f77..e1edb87ec 100644 --- a/packages/coding-agent/examples/facets/tui.ts +++ b/packages/coding-agent/examples/plugins/pi-example-plugin/src/tui.ts @@ -1,11 +1,9 @@ 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 { AgentController, PresentationUI, SlashCommands } from "@earendil-works/pi-coding-agent/experimental/plugin"; import { ExampleFacetService } from "./contract.ts"; export default defineFacet({ - id: "@pi/example-bundled:tui", + id: "@earendil-works/pi-example-plugin/tui", setup(env) { const example = env.use(ExampleFacetService); const commands = env.use(SlashCommands); diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 918019366..daca3eaf0 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -22,6 +22,11 @@ "./client": { "types": "./dist/client/index.d.ts", "import": "./dist/client/index.js" + }, + "./experimental/plugin": { + "source": "./src/experimental/plugin.ts", + "types": "./dist/experimental/plugin.d.ts", + "import": "./dist/experimental/plugin.js" } }, "files": [ diff --git a/packages/coding-agent/src/cli/experimental/command.ts b/packages/coding-agent/src/cli/experimental/command.ts index d5038901d..b9c9c7687 100644 --- a/packages/coding-agent/src/cli/experimental/command.ts +++ b/packages/coding-agent/src/cli/experimental/command.ts @@ -15,19 +15,24 @@ export type CommandOptionParseResult = | { readonly ok: false; readonly error: string }; export interface CommandOption { - readonly name: `--${string}`; + readonly name: `-${string}`; + readonly repeatable?: boolean; parse(value: string): CommandOptionParseResult; } export function valueOption( - name: `--${string}`, + name: `-${string}`, parse: (value: string) => CommandOptionParseResult, + options: { readonly repeatable?: boolean } = {}, ): CommandOption { - return { name, parse }; + return { name, parse, ...options }; } -export function stringOption(name: `--${string}`): CommandOption { - return valueOption(name, (value) => ({ ok: true, value })); +export function stringOption( + name: `-${string}`, + options: { readonly repeatable?: boolean } = {}, +): CommandOption { + return valueOption(name, (value) => ({ ok: true, value }), options); } export interface ParsedCommandInput { @@ -188,7 +193,7 @@ export class Command< } const values = parsed.values.get(name) ?? []; - if (values.length > 0) { + if (values.length > 0 && option.repeatable !== true) { parsed.errors.push(`${name} may only be specified once`); continue; } diff --git a/packages/coding-agent/src/cli/experimental/commands/client.ts b/packages/coding-agent/src/cli/experimental/commands/client.ts index d442f621e..7aefbd84f 100644 --- a/packages/coding-agent/src/cli/experimental/commands/client.ts +++ b/packages/coding-agent/src/cli/experimental/commands/client.ts @@ -19,6 +19,7 @@ export interface ClientCommand { readonly resume?: boolean; readonly provider?: string; readonly model?: string; + readonly pluginPackages?: readonly string[]; readonly prompt?: string; } @@ -30,12 +31,14 @@ const connectOption = transportOption("--connect"); const sessionIdOption = stringOption("--session-id"); const providerOption = stringOption("--provider"); const modelOption = stringOption("--model"); +const pluginPackageOption = stringOption("-e", { repeatable: true }); export const clientCommand = new Command("client") .option(connectOption) .option(sessionIdOption) .option(providerOption) .option(modelOption) + .option(pluginPackageOption) .option(authTokenOption) .option(authTokenFileOption) .build((input) => { @@ -44,6 +47,7 @@ export const clientCommand = new Command("c const sessionId = input.value(sessionIdOption); const provider = input.value(providerOption); const model = input.value(modelOption); + const pluginPackages = input.values(pluginPackageOption); const promptArgs = input.remainingArgs[0] === "--" ? input.remainingArgs.slice(1) : input.remainingArgs; const prompt = promptArgs.length === 1 && @@ -77,6 +81,7 @@ export const clientCommand = new Command("c ...(options.resume === true ? { resume: true } : {}), ...(provider === undefined ? {} : { provider }), ...(model === undefined ? {} : { model }), + ...(pluginPackages.length === 0 ? {} : { pluginPackages }), ...(prompt === undefined ? {} : { prompt }), }, }; diff --git a/packages/coding-agent/src/cli/experimental/commands/server.ts b/packages/coding-agent/src/cli/experimental/commands/server.ts index 07c90bcf4..07d1291dd 100644 --- a/packages/coding-agent/src/cli/experimental/commands/server.ts +++ b/packages/coding-agent/src/cli/experimental/commands/server.ts @@ -17,6 +17,7 @@ export interface ServerCommand { readonly listen?: readonly TransportAddress[]; readonly provider?: string; readonly model?: string; + readonly pluginPackages?: readonly string[]; readonly serverId?: ServerId; readonly sessionDir?: string; } @@ -34,6 +35,7 @@ const serverIdOption = valueOption("--server-id", (value) => const sessionDirOption = stringOption("--session-dir"); const providerOption = stringOption("--provider"); const modelOption = stringOption("--model"); +const pluginPackageOption = stringOption("-e", { repeatable: true }); export const serverCommand = new Command("server") .option(listenOption) @@ -41,6 +43,7 @@ export const serverCommand = new Command("s .option(sessionDirOption) .option(providerOption) .option(modelOption) + .option(pluginPackageOption) .option(authTokenOption) .option(authTokenFileOption) .build((input) => { @@ -50,6 +53,7 @@ export const serverCommand = new Command("s const sessionDir = input.value(sessionDirOption); const provider = input.value(providerOption); const model = input.value(modelOption); + const pluginPackages = input.values(pluginPackageOption); const { errors: optionErrors } = parseLegacyOptions(input); const modelErrors = provider !== undefined && model === undefined ? ["--provider requires --model"] : []; const errors = [...authErrors, ...optionErrors, ...modelErrors, ...unsupportedLegacyOptions("server", input)]; @@ -62,6 +66,7 @@ export const serverCommand = new Command("s ...(listen.length === 0 ? {} : { listen }), ...(provider === undefined ? {} : { provider }), ...(model === undefined ? {} : { model }), + ...(pluginPackages.length === 0 ? {} : { pluginPackages }), ...(serverId === undefined ? {} : { serverId }), ...(sessionDir === undefined ? {} : { sessionDir }), }, diff --git a/packages/coding-agent/src/experimental/client-runtime.ts b/packages/coding-agent/src/experimental/client-runtime.ts index 3dbe4bce8..2986309d3 100644 --- a/packages/coding-agent/src/experimental/client-runtime.ts +++ b/packages/coding-agent/src/experimental/client-runtime.ts @@ -1,7 +1,7 @@ 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 { Client, ServerError } 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"; @@ -17,6 +17,7 @@ import { type SessionServiceSource, } from "./services/connection.ts"; import { Models } from "./services/models.ts"; +import { PresentationPlugins } from "./services/plugins.ts"; import { SessionDirectory, SessionManagement } from "./services/sessions.ts"; export type ClientRuntimeRoute = @@ -34,6 +35,7 @@ export interface ClientRuntimeServer { export interface ActivatedClientRuntimeServer extends ClientRuntimeServer { readonly directory: SessionDirectory; readonly management: SessionManagement; + readonly plugins: PresentationPlugins; readonly models: Models; readonly agent: AgentController; } @@ -62,6 +64,9 @@ export async function openClientRuntime( if (command.connect && command.model !== undefined) { throw new Error("Model selection is only valid when automatically activating a new server"); } + if (command.connect?.transport === "radius" && command.pluginPackages !== undefined) { + throw new Error("Plugin package paths can only be configured on a local Unix server"); + } const directory = resolveServerDirectory(options.directory); let routes: ClientRuntimeRoute[]; let activatedClient: Client | undefined; @@ -88,6 +93,9 @@ export async function openClientRuntime( activatedClient = activated.client; } } + if (command.pluginPackages !== undefined && routes.length !== 1) { + throw new Error("Plugin selection requires exactly one local server"); + } const clients: Client[] = []; const reconnectors: RadiusClientReconnect[] = []; @@ -111,24 +119,44 @@ export async function openClientRuntime( try { for (const route of routes) { - const client = - activatedClient ?? - (await Client.connect({ - serverId: route.serverId, - transportFactory: - route.transport === "unix" - ? createUnixTransportFactory({ path: route.path }) - : createRadiusClientTransportFactory({ - serverId: route.serverId, - auth: new RadiusRelayAuthResolver(command.auth), - }), - })); + let client = activatedClient; + if (client === undefined) { + try { + client = await Client.connect({ + serverId: route.serverId, + transportFactory: + route.transport === "unix" + ? createUnixTransportFactory({ path: route.path }) + : createRadiusClientTransportFactory({ + serverId: route.serverId, + auth: new RadiusRelayAuthResolver(command.auth), + }), + }); + } catch (error) { + if ( + command.connect !== undefined || + route.transport !== "unix" || + !(error instanceof ServerError) || + error.code !== "version" + ) { + throw error; + } + client = ( + await activateServer({ + directory, + requestedServerId: route.serverId, + sessionDir: resolveSessionDirectory(), + }) + ).client; + } + } activatedClient = undefined; clients.push(client); if (route.transport === "radius") reconnectors.push(new RadiusClientReconnect(client)); const server = createServerServiceSource(client); + serviceSources.push(server); const session = createSessionServiceSource(client); - serviceSources.push(server, session); + serviceSources.push(session); servers.push({ route, client, @@ -153,7 +181,7 @@ export async function activateBuiltinClientServices( server: ClientRuntimeServer, ): Promise { const serverServices = server.server.open({ - services: [SessionDirectory, SessionManagement], + services: [SessionDirectory, SessionManagement, PresentationPlugins], assertAccess() {}, onError() {}, }); @@ -180,10 +208,11 @@ export async function activateBuiltinClientServices( await server.session.whenDetached(context); }, }; + const plugins = serverServices.use(PresentationPlugins); const models = sessionServices.use(Models); const agent = sessionServices.use(AgentController); await Promise.all([serverServices.ready(BACKGROUND_CONTEXT), sessionServices.ready(BACKGROUND_CONTEXT)]); - return { ...server, directory, management, models, agent }; + return { ...server, directory, management, plugins, models, agent }; } function routeFromExplicitPath(path: string): UnixServerRoute { diff --git a/packages/coding-agent/src/experimental/client-tui.ts b/packages/coding-agent/src/experimental/client-tui.ts index e3c669918..4d8201710 100644 --- a/packages/coding-agent/src/experimental/client-tui.ts +++ b/packages/coding-agent/src/experimental/client-tui.ts @@ -1,3 +1,4 @@ +import { resolve } from "node:path"; import { combineFacetLoaders, createFacetHost, @@ -41,6 +42,7 @@ import type { SessionAttachmentState, SessionServiceSource, } from "./services/connection.ts"; +import { PresentationPlugins } from "./services/plugins.ts"; import { PresentationUI } from "./services/presentation-ui.ts"; import { SessionDirectory, SessionManagement } from "./services/sessions.ts"; import { SlashCommands } from "./services/slash-commands.ts"; @@ -80,6 +82,12 @@ interface AgentFeature { readonly controller: AgentController; } +interface PreparedClientSession { + readonly serverId: string; + readonly summary: SessionSummary; + readonly presentationPlugins: JsonValue; +} + interface PendingSelection { readonly title: string; readonly items: readonly SelectItem[]; @@ -169,14 +177,15 @@ export class ExperimentalClientTui implements Component { requestRender(): void; finish(): void; }): Promise { + const prepared = await prepareClientSession(options.command, options.servers); const loadedFacets = await combineFacetLoaders([ createStaticFacetLoader([helloPluginFacet]), ...(options.facetLoader === undefined ? [] : [options.facetLoader]), ]).load(); const component = new ExperimentalClientTui(options.ui, options.requestRender, options.finish, loadedFacets); try { - await component.#start(options.servers); - await component.#openInitialSession(options.command); + await component.#start(options.servers, prepared); + await component.#openPreparedSession(prepared); return component; } catch (error) { try { @@ -244,9 +253,13 @@ export class ExperimentalClientTui implements Component { return this.#closePromise; } - async #start(servers: readonly ClientTuiServer[]): Promise { + async #start(servers: readonly ClientTuiServer[], prepared: PreparedClientSession): Promise { for (const server of servers) { - let presentationFacets = await combineFacetLoaders(server.presentationFacetLoaders ?? []).load(); + const presentationFacetLoaders = + server.serverId === prepared.serverId + ? createPresentationFacetLoaders(prepared.presentationPlugins) + : (server.presentationFacetLoaders ?? []); + let presentationFacets = await combineFacetLoaders(presentationFacetLoaders).load(); this.#loadedFacetGenerations.add(presentationFacets); let facetHost!: FacetHost; const reloadState = { tail: Promise.resolve() }; @@ -336,58 +349,14 @@ export class ExperimentalClientTui implements Component { } } - async #openInitialSession(command: ClientCommand): Promise { - const features = [...this.#sessions.values()]; - if (features.length === 0) throw new Error("No Session service is available"); - let selected: { feature: SessionFeature; summary: SessionSummary } | undefined; - - if (command.sessionId !== undefined) { - const matches = features.flatMap((feature) => - (feature.directory.state.value?.sessions ?? []) - .filter((session) => session.sessionId === command.sessionId) - .map((summary) => ({ feature, summary })), - ); - if (matches.length > 1) throw new Error(`Session ${command.sessionId} is available from more than one server`); - selected = matches[0]; - if (selected === undefined) { - if (command.connect?.transport === "radius") { - throw new Error(`Remote server does not contain Session ${command.sessionId}`); - } - const feature = requireSingleServer(features); - selected = { - feature, - summary: await feature.management.create({ id: command.sessionId }, BACKGROUND_CONTEXT), - }; - } - } else if (command.continue === true || command.resume === true) { - selected = features - .flatMap((feature) => - (feature.directory.state.value?.sessions ?? []).map((summary) => ({ feature, summary })), - ) - .sort( - (left, right) => - right.summary.createdAt - left.summary.createdAt || - left.summary.serverId.localeCompare(right.summary.serverId) || - left.summary.sessionId.localeCompare(right.summary.sessionId), - )[0]; - } - - if (selected === undefined) { - const feature = requireSingleServer(features); - selected = { feature, summary: await feature.management.create({}, BACKGROUND_CONTEXT) }; - } - await this.#attachSession(selected.feature, selected.summary); - } - - async #attachSession(feature: SessionFeature, summary: SessionSummary): Promise { - this.#status = `Attaching ${summary.sessionId}…`; - this.#rebuild(); - await feature.management.attach(summary.sessionId, BACKGROUND_CONTEXT); - await feature.session.whenAttached(summary.sessionId, BACKGROUND_CONTEXT); + async #openPreparedSession(prepared: PreparedClientSession): Promise { + const feature = this.#sessions.get(prepared.serverId); + if (feature === undefined) throw new Error(`No Session service is available for ${prepared.serverId}`); + await feature.session.whenAttached(prepared.summary.sessionId, BACKGROUND_CONTEXT); this.#selectedServerId = feature.serverId; - this.#sessionId = summary.sessionId; + this.#sessionId = prepared.summary.sessionId; this.#updateAutocomplete(); - await this.#openLane(feature, summary.sessionId); + await this.#openLane(feature, prepared.summary.sessionId); this.#screen = "chat"; this.#status = ""; this.#rebuild(); @@ -691,6 +660,104 @@ export class ExperimentalClientTui implements Component { } } +async function prepareClientSession( + command: ClientCommand, + servers: readonly ClientTuiServer[], +): Promise { + const opened = servers.map((server) => ({ + server, + services: server.server.open({ + services: [SessionDirectory, SessionManagement, PresentationPlugins], + assertAccess() {}, + onError() {}, + }), + })); + try { + const features = opened.map(({ server, services }) => ({ + server, + directory: services.use(SessionDirectory), + management: services.use(SessionManagement), + plugins: services.use(PresentationPlugins), + })); + await Promise.all(opened.map(({ services }) => services.ready(BACKGROUND_CONTEXT))); + let selected: + | { + readonly server: ClientTuiServer; + readonly management: SessionManagement; + readonly plugins: PresentationPlugins; + readonly summary: SessionSummary; + } + | undefined; + if (command.sessionId !== undefined) { + const matches = features.flatMap((feature) => + (feature.directory.state.value?.sessions ?? []) + .filter((session) => session.sessionId === command.sessionId) + .map((summary) => ({ + server: feature.server, + management: feature.management, + plugins: feature.plugins, + summary, + })), + ); + if (matches.length > 1) throw new Error(`Session ${command.sessionId} is available from more than one server`); + selected = matches[0]; + if (selected === undefined) { + if (command.connect?.transport === "radius") { + throw new Error(`Remote server does not contain Session ${command.sessionId}`); + } + const feature = requireSingleServer(features); + selected = { + server: feature.server, + management: feature.management, + plugins: feature.plugins, + summary: await feature.management.create({ id: command.sessionId }, BACKGROUND_CONTEXT), + }; + } + } else if (command.continue === true || command.resume === true) { + selected = features + .flatMap((feature) => + (feature.directory.state.value?.sessions ?? []).map((summary) => ({ + server: feature.server, + management: feature.management, + plugins: feature.plugins, + summary, + })), + ) + .sort( + (left, right) => + right.summary.createdAt - left.summary.createdAt || + left.summary.serverId.localeCompare(right.summary.serverId) || + left.summary.sessionId.localeCompare(right.summary.sessionId), + )[0]; + } + if (selected === undefined) { + const feature = requireSingleServer(features); + selected = { + server: feature.server, + management: feature.management, + plugins: feature.plugins, + summary: await feature.management.create({}, BACKGROUND_CONTEXT), + }; + } + const presentationPlugins = await selected.plugins.prepareSession( + { + sessionId: selected.summary.sessionId, + packagePaths: command.pluginPackages?.map((packagePath) => resolve(packagePath)) ?? null, + }, + BACKGROUND_CONTEXT, + ); + await selected.management.attach(selected.summary.sessionId, BACKGROUND_CONTEXT); + await selected.server.session.whenAttached(selected.summary.sessionId, BACKGROUND_CONTEXT); + return { + serverId: selected.server.serverId, + summary: selected.summary, + presentationPlugins, + }; + } finally { + await Promise.allSettled(opened.map(({ services }) => services.dispose(BACKGROUND_CONTEXT))); + } +} + export async function runClientTui(command: ClientCommand, options: RunClientTuiOptions = {}): Promise { const cwd = process.cwd(); const agentDir = getAgentDir(); @@ -763,7 +830,7 @@ export async function runClientTui(command: ClientCommand, options: RunClientTui } } -function requireSingleServer(features: readonly SessionFeature[]): SessionFeature { +function requireSingleServer(features: readonly T[]): T { if (features.length !== 1) throw new Error("Starting a Session requires exactly one server"); return features[0]!; } diff --git a/packages/coding-agent/src/experimental/client.ts b/packages/coding-agent/src/experimental/client.ts index 2fc968d16..166ad6058 100644 --- a/packages/coding-agent/src/experimental/client.ts +++ b/packages/coding-agent/src/experimental/client.ts @@ -1,3 +1,4 @@ +import { resolve } from "node:path"; import { BACKGROUND_CONTEXT } from "@earendil-works/pi-agent-core"; import type { LaneEvent, PromptMessage, SessionAddress } from "@earendil-works/pi-protocol"; import type { ClientCommand } from "../cli/experimental/commands/client.ts"; @@ -65,6 +66,13 @@ export async function runClient(command: ClientCommand, options: RunClientOption await match.management.create({ id: selectedSessionId }, BACKGROUND_CONTEXT); } } + await match.plugins.prepareSession( + { + sessionId, + packagePaths: command.pluginPackages?.map((packagePath) => resolve(packagePath)) ?? null, + }, + BACKGROUND_CONTEXT, + ); await match.management.attach(sessionId, BACKGROUND_CONTEXT); if (command.prompt === undefined) { return { kind: "attached", serverId: match.route.serverId, sessionId }; diff --git a/packages/coding-agent/src/experimental/plugin.ts b/packages/coding-agent/src/experimental/plugin.ts new file mode 100644 index 000000000..b1c855147 --- /dev/null +++ b/packages/coding-agent/src/experimental/plugin.ts @@ -0,0 +1,16 @@ +export { + type AgentCompactionRequest, + AgentController, + type AgentNavigationRequest, + type AgentOperationError, + type AgentOperationResponse, + type AgentPromptRequest, + type AgentQueueResponse, +} from "./services/agent-controller.ts"; +export { type PresentationSelectItem, PresentationUI } from "./services/presentation-ui.ts"; +export { + type SlashCommandCompletion, + type SlashCommandContribution, + type SlashCommandRunResult, + SlashCommands, +} from "./services/slash-commands.ts"; diff --git a/packages/coding-agent/src/experimental/plugins/bundled.ts b/packages/coding-agent/src/experimental/plugins/bundled.ts index bb6affbbc..0c3503996 100644 --- a/packages/coding-agent/src/experimental/plugins/bundled.ts +++ b/packages/coding-agent/src/experimental/plugins/bundled.ts @@ -1,74 +1,44 @@ -import { readFile, rm, writeFile } from "node:fs/promises"; -import { join, resolve } from "node:path"; -import type { FacetLoader } from "@earendil-works/chord"; +import { combineFacetLoaders, type FacetLoader } from "@earendil-works/chord"; import { createFacetBundleArtifactLoader, createFacetBundleLoader, type FacetBundleArtifact, - readFacetBundleArtifact, + readFacetBundleManifest, } from "@earendil-works/chord/node"; -import type { JsonValue, ServerId } from "@earendil-works/pi-protocol"; +import type { JsonValue } 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"; +const PI_PLUGIN_API = "@earendil-works/pi-coding-agent/experimental/plugin"; -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 }); +export function createConfiguredPluginFacetLoader( + entry: "session", + manifestPaths: readonly string[], +): FacetLoader | undefined { + if (manifestPaths.length === 0) return undefined; + return combineFacetLoaders(manifestPaths.map((manifestPath) => createOptionalFacetLoader(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" })]; +function createOptionalFacetLoader(manifestPath: string, entry: "session"): FacetLoader { + const loader = createFacetBundleLoader({ + manifestPath, + entry, + resolveExternal: resolvePluginExternal, + }); + return { + async load() { + const manifest = await readFacetBundleManifest(manifestPath); + if (manifest.entries[entry] !== undefined) return loader.load(); + return { facets: Object.freeze([]), async dispose() {} }; + }, + }; } export function createPresentationFacetHelloData(artifacts: readonly FacetBundleArtifact[]): JsonValue | undefined { if (artifacts.length === 0) return undefined; + return createPresentationFacetData(artifacts); +} + +export function createPresentationFacetData(artifacts: readonly FacetBundleArtifact[]): JsonValue { return { [PRESENTATION_FACET_BUNDLES_KEY]: artifacts.map((artifact) => artifact as unknown as JsonValue), }; @@ -83,5 +53,11 @@ export function createPresentationFacetLoaders(data: JsonValue | undefined): rea 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 })); + return artifacts.map((artifact) => + createFacetBundleArtifactLoader({ artifact, resolveExternal: resolvePluginExternal }), + ); +} + +function resolvePluginExternal(specifier: string): string | undefined { + return specifier === PI_PLUGIN_API ? import.meta.resolve(specifier) : undefined; } diff --git a/packages/coding-agent/src/experimental/plugins/hello.ts b/packages/coding-agent/src/experimental/plugins/hello.ts index 541ff7967..9034c52a9 100644 --- a/packages/coding-agent/src/experimental/plugins/hello.ts +++ b/packages/coding-agent/src/experimental/plugins/hello.ts @@ -2,7 +2,7 @@ import { defineFacet } from "@earendil-works/chord"; import { AgentController } from "../services/agent-controller.ts"; import { SlashCommands } from "../services/slash-commands.ts"; -/** Static example presentation facet; examples/facets demonstrates the bundled plugin path. */ +/** Static example presentation facet; examples/plugins/pi-example-plugin demonstrates packaged plugins. */ const helloPluginFacet = defineFacet({ id: "@pi/example-hello", setup(env) { diff --git a/packages/coding-agent/src/experimental/plugins/package.ts b/packages/coding-agent/src/experimental/plugins/package.ts new file mode 100644 index 000000000..82191b5b9 --- /dev/null +++ b/packages/coding-agent/src/experimental/plugins/package.ts @@ -0,0 +1,181 @@ +import { createHash } from "node:crypto"; +import { readFile, rm, writeFile } from "node:fs/promises"; +import { basename, dirname, join, resolve } from "node:path"; +import { bundleFacetPackage } from "@earendil-works/chord/bundler"; +import { + FACET_BUNDLE_MANIFEST_FILE, + type FacetBundleArtifact, + readFacetBundleArtifact, +} from "@earendil-works/chord/node"; +import type { ServerId } from "@earendil-works/pi-protocol"; + +const PLUGIN_PACKAGE_PROFILE_VERSION = 1; +const SESSION_PLUGIN_PACKAGE_PROFILE_VERSION = 1; +const DEFAULT_PLUGIN_FACETS = Object.freeze({ session: "src/session.ts", tui: "src/tui.ts" }); + +export interface ConfiguredServerPluginPackage { + readonly packagePath: string; + readonly manifestPath: string; + build(): Promise; +} + +/** Persist an explicit plugin package selection or restore it for a later server generation. */ +export async function restoreServerPluginPackageProfile( + directory: string, + serverId: ServerId, + configuredPackagePaths?: readonly string[], +): Promise { + const path = join(directory, `plugin-packages-${serverId}.json`); + if (configuredPackagePaths !== undefined) { + const packagePaths = normalizePluginPackagePaths(configuredPackagePaths); + if (packagePaths.length === 0) { + await rm(path, { force: true }); + return packagePaths; + } + await writeFile(path, `${JSON.stringify({ version: PLUGIN_PACKAGE_PROFILE_VERSION, packagePaths }, null, 2)}\n`, { + encoding: "utf8", + mode: 0o600, + }); + return packagePaths; + } + + 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 plugin package profile ${path}`, { cause: error }); + } + if ( + typeof parsed !== "object" || + parsed === null || + Array.isArray(parsed) || + Object.keys(parsed).some((key) => key !== "version" && key !== "packagePaths") || + !("version" in parsed) || + parsed.version !== PLUGIN_PACKAGE_PROFILE_VERSION || + !("packagePaths" in parsed) || + !Array.isArray(parsed.packagePaths) || + parsed.packagePaths.length === 0 || + parsed.packagePaths.some((packagePath) => typeof packagePath !== "string" || packagePath.length === 0) + ) { + throw new Error(`Invalid experimental plugin package profile ${path}`); + } + return normalizePluginPackagePaths(parsed.packagePaths as readonly string[]); +} + +/** Read the explicit plugin selection stored for one durable Session. */ +export async function readSessionPluginPackageProfile( + directory: string, + serverId: ServerId, + sessionPath: string, +): Promise { + const path = sessionPluginProfilePath(directory, serverId, sessionPath); + let parsed: unknown; + try { + parsed = JSON.parse(await readFile(path, "utf8")); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return undefined; + throw new Error(`Could not read experimental Session plugin profile ${path}`, { cause: error }); + } + if ( + typeof parsed !== "object" || + parsed === null || + Array.isArray(parsed) || + Object.keys(parsed).some((key) => key !== "version" && key !== "sessionPath" && key !== "packagePaths") || + !("version" in parsed) || + parsed.version !== SESSION_PLUGIN_PACKAGE_PROFILE_VERSION || + !("sessionPath" in parsed) || + parsed.sessionPath !== sessionPath || + !("packagePaths" in parsed) || + !Array.isArray(parsed.packagePaths) || + parsed.packagePaths.some((packagePath) => typeof packagePath !== "string" || packagePath.length === 0) + ) { + throw new Error(`Invalid experimental Session plugin profile ${path}`); + } + return normalizePluginPackagePaths(parsed.packagePaths as readonly string[]); +} + +/** Remove the plugin selection stored for one deleted Session. */ +export async function removeSessionPluginPackageProfile( + directory: string, + serverId: ServerId, + sessionPath: string, +): Promise { + await rm(sessionPluginProfilePath(directory, serverId, sessionPath), { force: true }); +} + +/** Persist the plugin selection for one durable Session. */ +export async function writeSessionPluginPackageProfile( + directory: string, + serverId: ServerId, + sessionPath: string, + packagePaths: readonly string[], +): Promise { + const normalized = normalizePluginPackagePaths(packagePaths); + const path = sessionPluginProfilePath(directory, serverId, sessionPath); + await writeFile( + path, + `${JSON.stringify( + { version: SESSION_PLUGIN_PACKAGE_PROFILE_VERSION, sessionPath, packagePaths: normalized }, + null, + 2, + )}\n`, + { encoding: "utf8", mode: 0o600 }, + ); +} + +/** Create a serialized server-owned builder for one configured plugin package. */ +export function createServerPluginPackage( + directory: string, + serverId: ServerId, + packagePath: string, +): ConfiguredServerPluginPackage { + const normalizedPackagePath = resolve(packagePath); + const outdir = join(directory, "plugin-builds", serverId, pluginBuildDirectoryName(normalizedPackagePath)); + const manifestPath = join(outdir, FACET_BUNDLE_MANIFEST_FILE); + let buildTail = Promise.resolve(); + return { + packagePath: normalizedPackagePath, + manifestPath, + build() { + const operation = buildTail.then(async () => { + const result = await bundleFacetPackage({ + packagePath: normalizedPackagePath, + outdir, + defaultFacets: DEFAULT_PLUGIN_FACETS, + }); + if (result.manifest.entries.tui === undefined) return []; + return [await readFacetBundleArtifact({ manifestPath: result.manifestPath, entry: "tui" })]; + }); + buildTail = operation.then( + () => undefined, + () => undefined, + ); + return operation; + }, + }; +} + +export function normalizePluginPackagePaths(packagePaths: readonly string[]): readonly string[] { + const normalized = packagePaths.map((packagePath) => { + if (packagePath.length === 0) throw new Error("Plugin package path must not be empty"); + return resolve(packagePath); + }); + if (new Set(normalized).size !== normalized.length) { + throw new Error("Plugin package paths must be unique"); + } + return Object.freeze(normalized); +} + +function sessionPluginProfilePath(directory: string, serverId: ServerId, sessionPath: string): string { + const hash = createHash("sha256").update(sessionPath).digest("hex").slice(0, 24); + return join(directory, `session-plugin-packages-${serverId}-${hash}.json`); +} + +function pluginBuildDirectoryName(packagePath: string): string { + const packageDirectory = basename(packagePath) === "package.json" ? dirname(packagePath) : packagePath; + const base = basename(packageDirectory).replaceAll(/[^a-zA-Z0-9._-]/gu, "-") || "plugin"; + const label = base.endsWith("-plugin") ? base : `${base}-plugin`; + const hash = createHash("sha256").update(packagePath).digest("hex").slice(0, 12); + return `${label}-${hash}`; +} diff --git a/packages/coding-agent/src/experimental/server.ts b/packages/coding-agent/src/experimental/server.ts index 5673b906b..ad3821d63 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 { Context } from "@earendil-works/chord"; import type { FacetBundleArtifact } from "@earendil-works/chord/node"; import { BACKGROUND_CONTEXT, @@ -10,21 +11,31 @@ import { TODO_CONTEXT, } from "@earendil-works/pi-agent-core"; import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node"; -import { Client, DisconnectedError, ServerError } from "@earendil-works/pi-client"; +import { Client, ServerError as ClientServerError, DisconnectedError } from "@earendil-works/pi-client"; import { createUnixTransportFactory, type UnixServerRoute } from "@earendil-works/pi-client/unix"; 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 { + ServerError as RoutedServerError, + 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"; 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 { createPresentationFacetData, createPresentationFacetHelloData } from "./plugins/bundled.ts"; import { - createPresentationFacetHelloData, - readConfiguredPresentationFacetBundles, - restoreServerFacetBundleProfile, -} from "./plugins/bundled.ts"; + createServerPluginPackage, + normalizePluginPackagePaths, + readSessionPluginPackageProfile, + removeSessionPluginPackageProfile, + restoreServerPluginPackageProfile, + writeSessionPluginPackageProfile, +} from "./plugins/package.ts"; import { consumeInternalProcessRole, isDirectInternalProcessEntry, @@ -34,7 +45,7 @@ import { import { RadiusRelayAuthResolver } from "./radius-auth.ts"; import { RadiusRelayHost, type RadiusRelayHostStatus } from "./radius-relay.ts"; import { createExperimentalServerServices } from "./services/server.ts"; -import { SessionWorkerManager } from "./session-worker-manager.ts"; +import { SessionPluginSelectionConflictError, SessionWorkerManager } from "./session-worker-manager.ts"; export const ENV_SERVER_DIR = "PI_SERVER_DIR"; export const ENV_SERVER_ID = "PI_SERVER_ID"; @@ -143,23 +154,24 @@ export async function activateServer(options: ActivateServerOptions): Promise { spawnError = error; @@ -210,7 +222,7 @@ async function connect(route: UnixServerRoute): Promise { return client; } catch (error) { await client.dispose(); - if (error instanceof DisconnectedError || (error instanceof ServerError && error.code === "version")) { + if (error instanceof DisconnectedError || (error instanceof ClientServerError && error.code === "version")) { return undefined; } let current = error; @@ -333,17 +345,33 @@ 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. */ + /** Explicit plugin packages. Undefined restores the logical server profile; an empty list clears it. */ + readonly pluginPackages?: readonly string[]; + /** Presentation facets selected by this server. Defaults to configured plugin packages' `tui` entries. */ readonly presentationFacetBundles?: readonly FacetBundleArtifact[]; readonly onRelayStatus?: (status: RadiusRelayHostStatus) => void; } +interface ResolvedSessionPlugins { + readonly packagePaths: readonly string[]; + readonly manifestPaths: readonly string[]; + readonly presentationArtifacts: readonly FacetBundleArtifact[]; +} + interface StartServerBackendOptions { readonly path: string; readonly serverId: ServerId; readonly sessionDir?: string; readonly helloData?: JsonValue; - readonly reloadPresentationFacetBundles: () => Promise; + resolveSessionPlugins( + metadata: JsonlSessionMetadata, + packagePaths: readonly string[] | undefined, + context: Context, + ): Promise; + removeSessionPlugins(metadata: JsonlSessionMetadata): Promise; + reloadPresentationFacetBundles( + packagePaths: readonly string[], + ): Promise<{ readonly artifacts: readonly FacetBundleArtifact[]; readonly updateHello: boolean }>; } interface RunningServerBackend extends RunningServer { @@ -396,11 +424,31 @@ async function startServerBackend( if (matches.length > 1) throw new SessionAmbiguousError(); await workers.closeSession(matches[0]!, context); await repo.delete(matches[0]!, context); + await options.removeSessionPlugins(matches[0]!); }, - async reloadPresentationPlugins() { - const data = createPresentationFacetHelloData(await options.reloadPresentationFacetBundles()); - if (data === undefined) throw new Error("No presentation plugin bundle is configured"); - setHelloData(data); + async prepareSessionPlugins(sessionId, packagePaths, context) { + const matches = (await listSessions(context)).filter((metadata) => metadata.id === sessionId); + if (matches.length === 0) throw new SessionNotFoundError(`Unknown session: ${sessionId}`); + if (matches.length > 1) throw new SessionAmbiguousError(); + let selected: ResolvedSessionPlugins; + try { + selected = await options.resolveSessionPlugins(matches[0]!, packagePaths, context); + workers.assertSessionPluginManifestPaths(matches[0]!, selected.manifestPaths); + } catch (error) { + if (error instanceof SessionPluginSelectionConflictError) { + throw new RoutedServerError("service_invalid_value", error.message); + } + throw error; + } + return { + packagePaths: selected.packagePaths, + presentationPlugins: createPresentationFacetData(selected.presentationArtifacts), + }; + }, + async reloadPresentationPlugins(packagePaths) { + const reloaded = await options.reloadPresentationFacetBundles(packagePaths); + const data = createPresentationFacetData(reloaded.artifacts); + if (reloaded.updateHello) setHelloData(data); return data; }, }).catch(async (error: unknown) => { @@ -414,7 +462,10 @@ async function startServerBackend( const host: ServerHost = { serverServices: serverServices.host, sessions: { list: listSessions, create: createSession }, - openSession: (metadata, context) => workers.openSession(metadata, context), + openSession: async (metadata, context) => { + const selected = await options.resolveSessionPlugins(metadata, undefined, context); + return workers.openSession(metadata, context, selected.manifestPaths); + }, }; const socketPath = options.path; const closeCatalog = async (): Promise => { @@ -492,13 +543,69 @@ export async function startServer(options: StartServerOptions = {}): Promise configuredPresentationFacetBundles; - const helloData = createPresentationFacetHelloData(await reloadPresentationFacetBundles()); + const pluginPackagePaths = await restoreServerPluginPackageProfile(directory, serverId, options.pluginPackages); + const buildPluginSelection = async (packagePaths: readonly string[]): Promise => { + const normalizedPackagePaths = normalizePluginPackagePaths(packagePaths); + const plugins = normalizedPackagePaths.map((packagePath) => + createServerPluginPackage(directory, serverId, packagePath), + ); + const built = (await Promise.all(plugins.map((plugin) => plugin.build()))).flat(); + return { + packagePaths: normalizedPackagePaths, + manifestPaths: plugins.map((plugin) => plugin.manifestPath), + presentationArtifacts: options.presentationFacetBundles ?? built, + }; + }; + let defaultPluginSelection = await buildPluginSelection(pluginPackagePaths); + const sessionPluginSelections = new Map(); + const resolveSessionPlugins = async ( + metadata: JsonlSessionMetadata, + requestedPackagePaths: readonly string[] | undefined, + _context: Context, + ): Promise => { + if (requestedPackagePaths !== undefined) { + const normalizedPackagePaths = normalizePluginPackagePaths(requestedPackagePaths); + const current = sessionPluginSelections.get(metadata.path); + if (current !== undefined && sameStrings(current.packagePaths, normalizedPackagePaths)) return current; + const requestedManifestPaths = normalizedPackagePaths.map( + (packagePath) => createServerPluginPackage(directory, serverId, packagePath).manifestPath, + ); + workers?.assertSessionPluginManifestPaths(metadata, requestedManifestPaths); + const candidate = await buildPluginSelection(normalizedPackagePaths); + workers?.assertSessionPluginManifestPaths(metadata, candidate.manifestPaths); + await writeSessionPluginPackageProfile(directory, serverId, metadata.path, candidate.packagePaths); + sessionPluginSelections.set(metadata.path, candidate); + return candidate; + } + const cached = sessionPluginSelections.get(metadata.path); + if (cached !== undefined) return cached; + const storedPackagePaths = await readSessionPluginPackageProfile(directory, serverId, metadata.path); + const selected = + storedPackagePaths === undefined ? defaultPluginSelection : await buildPluginSelection(storedPackagePaths); + if (storedPackagePaths === undefined) { + await writeSessionPluginPackageProfile(directory, serverId, metadata.path, selected.packagePaths); + } + sessionPluginSelections.set(metadata.path, selected); + return selected; + }; + const removeSessionPlugins = async (metadata: JsonlSessionMetadata): Promise => { + sessionPluginSelections.delete(metadata.path); + await removeSessionPluginPackageProfile(directory, serverId, metadata.path); + }; + const reloadPresentationFacetBundles = async ( + packagePaths: readonly string[], + ): Promise<{ readonly artifacts: readonly FacetBundleArtifact[]; readonly updateHello: boolean }> => { + const reloaded = await buildPluginSelection(packagePaths); + const updateHello = sameStrings(defaultPluginSelection.packagePaths, reloaded.packagePaths); + if (updateHello) defaultPluginSelection = reloaded; + for (const [sessionPath, selected] of sessionPluginSelections) { + if (sameStrings(selected.packagePaths, reloaded.packagePaths)) { + sessionPluginSelections.set(sessionPath, reloaded); + } + } + return { artifacts: reloaded.presentationArtifacts, updateHello }; + }; + const helloData = createPresentationFacetHelloData(defaultPluginSelection.presentationArtifacts); const socketPath = getUnixSocketPath(serverId, directory); const controlPath = join(directory, `control-${serverId}.sock`); const serverNonce = randomUUID().replaceAll("-", "").slice(0, 12); @@ -506,14 +613,20 @@ export async function startServer(options: StartServerOptions = {}): Promise - lifetime.setWorkerCount(count), + workers = new SessionWorkerManager( + coordinator, + sessionDir, + workerModel, + (count) => lifetime.setWorkerCount(count), + defaultPluginSelection.manifestPaths, ); backend = await startServerBackend( { path: serverPath, serverId, sessionDir: options.sessionDir, + resolveSessionPlugins, + removeSessionPlugins, reloadPresentationFacetBundles, ...(helloData === undefined ? {} : { helloData }), }, @@ -619,47 +732,71 @@ export async function startForegroundServer( } } -function parseServerModelOptions(value: string | undefined): { provider?: string; model: string } | undefined { - if (value === undefined) return undefined; +function parseServerStartupOptions(value: string | undefined): { + readonly provider?: string; + readonly model?: string; +} { + if (value === undefined) return {}; let parsed: unknown; try { parsed = JSON.parse(value); } catch (error) { - throw new Error("Internal server received invalid model options", { cause: error }); + throw new Error("Internal server received invalid startup options", { cause: error }); } - if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { - throw new Error("Internal server received invalid model options"); - } - const keys = Object.keys(parsed); - const model = "model" in parsed ? parsed.model : undefined; - const provider = "provider" in parsed ? parsed.provider : undefined; if ( - keys.some((key) => key !== "provider" && key !== "model") || - typeof model !== "string" || - model.length === 0 || - (provider !== undefined && (typeof provider !== "string" || provider.length === 0)) + typeof parsed !== "object" || + parsed === null || + Array.isArray(parsed) || + Object.keys(parsed).some((key) => key !== "model") ) { - throw new Error("Internal server received invalid model options"); + throw new Error("Internal server received invalid startup options"); } - return provider === undefined ? { model } : { provider, model }; + const modelOptions = "model" in parsed ? parsed.model : undefined; + let model: string | undefined; + let provider: string | undefined; + if (modelOptions !== undefined) { + if ( + typeof modelOptions !== "object" || + modelOptions === null || + Array.isArray(modelOptions) || + Object.keys(modelOptions).some((key) => key !== "provider" && key !== "model") + ) { + throw new Error("Internal server received invalid startup options"); + } + const modelCandidate = "model" in modelOptions ? modelOptions.model : undefined; + const providerCandidate = "provider" in modelOptions ? modelOptions.provider : undefined; + if ( + typeof modelCandidate !== "string" || + modelCandidate.length === 0 || + (providerCandidate !== undefined && (typeof providerCandidate !== "string" || providerCandidate.length === 0)) + ) { + throw new Error("Internal server received invalid startup options"); + } + model = modelCandidate; + provider = providerCandidate; + } + return { + ...(provider === undefined ? {} : { provider }), + ...(model === undefined ? {} : { model }), + }; } /** Run an automatically activated server until its client and Session demand disappears. */ export async function runServerProcess(args: readonly string[]): Promise { - const [directory, serverId, sessionDir, serializedModel] = args; + const [directory, serverId, sessionDir, serializedStartupOptions] = args; if (args.length > 4) throw new Error("Internal server received unexpected arguments"); if (!directory || !isAbsolute(directory)) throw new Error("Internal server requires an absolute server directory"); if (!isServerId(serverId)) throw new Error("Internal server requires a canonical server ID"); if (!sessionDir || !isAbsolute(sessionDir)) throw new Error("Internal server requires an absolute Session directory"); - const workerModel = parseServerModelOptions(serializedModel); + const startupOptions = parseServerStartupOptions(serializedStartupOptions); const runtime = await startServer({ directory, serverId, sessionDir, keepAlive: false, - ...workerModel, + ...startupOptions, }); const close = (): void => { void runtime.close().catch(() => {}); @@ -675,6 +812,10 @@ export async function runServerProcess(args: readonly string[]): Promise { } } +function sameStrings(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + if (isDirectInternalProcessEntry(import.meta.url)) { const role = consumeInternalProcessRole(); if (role !== "server") throw new Error("Server entrypoint requires an internal server invocation"); diff --git a/packages/coding-agent/src/experimental/services/README.md b/packages/coding-agent/src/experimental/services/README.md index 4a111df0e..ec87e52c4 100644 --- a/packages/coding-agent/src/experimental/services/README.md +++ b/packages/coding-agent/src/experimental/services/README.md @@ -6,7 +6,7 @@ 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 | +| server | `PresentationPlugins` | prepares the selected Session branch's matching TUI artifacts and reloads that branch | 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 | @@ -19,8 +19,8 @@ Facet setup generates each host's RPC service catalogue from its provided non-lo 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 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 foreground server uses repeatable `-e` options to establish its default Session and TUI facets. A local client may instead select packages for the Session it creates or resumes; that branch selection is persisted with the Session and does not alter other workers or the server default. Before attaching, the server asks Chord to build conventional `src/session.ts` and `src/tui.ts` entries into separate `plugin-builds/` directories, passes the generated manifest paths to that Session worker, and returns the matching TUI artifacts. The Session worker loads built-in facets and separately owned plugin generations, then creates one active `FacetHost`. `/reload` atomically rebuilds the packages, loads fresh plugin candidates, cuts them over through `FacetHost.reload()`, and disposes the retired generations. 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 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. +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/plugins/pi-example-plugin/` is an actual `@earendil-works/pi-example-plugin` package: repeatable `-e` options select plugin packages as server defaults or for one Session branch, Chord discovers and builds their conventional facets, and the attached presentation receives matching TUI artifacts through `PresentationPlugins`. 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 index ebf11d26d..3986c2b36 100644 --- a/packages/coding-agent/src/experimental/services/plugins.ts +++ b/packages/coding-agent/src/experimental/services/plugins.ts @@ -1,8 +1,12 @@ import { type Context, defineService } from "@earendil-works/chord"; import type { JsonValue } from "@earendil-works/pi-protocol"; -/** Server-owned plugin bundle generations available to presentations. */ +/** Server-built plugin generations available to presentations. */ export interface PresentationPlugins { + prepareSession( + request: { readonly sessionId: string; readonly packagePaths: readonly string[] | null }, + context: Context, + ): Promise; reload(context: Context): Promise; } diff --git a/packages/coding-agent/src/experimental/services/server.ts b/packages/coding-agent/src/experimental/services/server.ts index 35eda29b8..e857fc2eb 100644 --- a/packages/coding-agent/src/experimental/services/server.ts +++ b/packages/coding-agent/src/experimental/services/server.ts @@ -29,7 +29,12 @@ 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; + prepareSessionPlugins( + sessionId: string, + packagePaths: readonly string[] | undefined, + context: Context, + ): Promise<{ readonly packagePaths: readonly string[]; readonly presentationPlugins: JsonValue }>; + reloadPresentationPlugins(packagePaths: readonly string[], context: Context): Promise; }): Promise { let revision = 1; const directory = replicatedState({ @@ -56,6 +61,7 @@ export async function createExperimentalServerServices(options: { return { host: { attachClient(presentation) { + let preparedPluginPackagePaths: readonly string[] | undefined; const provider = new RemoteServiceProvider([ { service: SessionDirectory, mode: "singleton" }, { service: SessionManagement, mode: "singleton" }, @@ -63,7 +69,23 @@ export async function createExperimentalServerServices(options: { ]); provider.provide(SessionDirectory, { state: directory }); provider.provide(PresentationPlugins, { - reload: (_context) => options.reloadPresentationPlugins(_context), + prepareSession: ({ sessionId, packagePaths }, context) => + serialize(async () => { + const selected = await options.prepareSessionPlugins( + sessionId, + packagePaths ?? undefined, + context, + ); + preparedPluginPackagePaths = selected.packagePaths; + return selected.presentationPlugins; + }), + reload: (context) => + serialize(() => { + if (preparedPluginPackagePaths === undefined) { + throw new Error("No Session plugin selection is prepared"); + } + return options.reloadPresentationPlugins(preparedPluginPackagePaths, context); + }), }); provider.provide(SessionManagement, { create: (createOptions, context) => @@ -85,6 +107,7 @@ export async function createExperimentalServerServices(options: { detach: (context) => serialize(async () => { await presentation.detachSession(context); + preparedPluginPackagePaths = undefined; }), }); const attachment = createProviderAttachment(provider, () => attachments.delete(attachment)); diff --git a/packages/coding-agent/src/experimental/session-worker-manager.ts b/packages/coding-agent/src/experimental/session-worker-manager.ts index 8ac55f024..71e8172b8 100644 --- a/packages/coding-agent/src/experimental/session-worker-manager.ts +++ b/packages/coding-agent/src/experimental/session-worker-manager.ts @@ -45,11 +45,19 @@ const WORKER_SHUTDOWN_TIMEOUT_MS = 10_000; const WORKER_DISCOVERY_TIMEOUT_MS = 5_000; const WORKER_DEMAND_TIMEOUT_MS = 5_000; +export class SessionPluginSelectionConflictError extends Error { + constructor(message: string) { + super(message); + this.name = "SessionPluginSelectionConflictError"; + } +} + interface WorkerRecord { readonly peerId: string; readonly metadata: JsonlSessionMetadata; readonly pid: number; readonly token: string; + readonly pluginManifestPaths: readonly string[]; readonly terminated: Promise; resolveTerminated(error: Error | undefined): void; readonly attachmentIds: Set; @@ -94,6 +102,7 @@ interface PendingLaunch { readonly sessionKey: string; readonly peerId: string; readonly token: string; + readonly pluginManifestPaths: readonly string[]; readonly child: ChildProcess; readonly timer: NodeJS.Timeout; readonly promise: Promise; @@ -119,6 +128,7 @@ export class SessionWorkerManager { readonly #serviceSubscriptions = new Map(); readonly #removeListener: () => void; readonly #onWorkerCountChanged: ((count: number) => void) | undefined; + readonly #pluginManifestPaths: readonly string[]; #discoveryPeers?: Set; #resolveDiscovery?: () => void; #detached = false; @@ -132,11 +142,13 @@ export class SessionWorkerManager { sessionDir: string, model?: { readonly provider?: string; readonly model: string }, onWorkerCountChanged?: (count: number) => void, + pluginManifestPaths: readonly string[] = [], ) { this.#coordinator = coordinator; this.#sessionDir = sessionDir; this.#model = model; this.#onWorkerCountChanged = onWorkerCountChanged; + this.#pluginManifestPaths = Object.freeze([...pluginManifestPaths]); this.#removeListener = coordinator.onEvent((event) => this.#handleCoordinatorEvent(event)); } @@ -144,6 +156,21 @@ export class SessionWorkerManager { return [...this.#workersBySession.values()].map((worker) => worker.metadata); } + assertSessionPluginManifestPaths(metadata: JsonlSessionMetadata, manifestPaths: readonly string[]): void { + const existing = this.#workersBySession.get(metadata.path); + if (existing !== undefined && !sameStrings(existing.pluginManifestPaths, manifestPaths)) { + throw new SessionPluginSelectionConflictError( + `Session ${metadata.id} is active with a different plugin selection`, + ); + } + const pending = this.#pending.get(metadata.path); + if (pending !== undefined && !sameStrings(pending.pluginManifestPaths, manifestPaths)) { + throw new SessionPluginSelectionConflictError( + `Session ${metadata.id} is starting with a different plugin selection`, + ); + } + } + async discover(peerIds: ReadonlySet): Promise { if (this.#detached) return; const undiscovered = new Set( @@ -168,13 +195,18 @@ export class SessionWorkerManager { this.#resolveDiscovery = undefined; } - async openSession(metadata: JsonlSessionMetadata, context: Context): Promise { + async openSession( + metadata: JsonlSessionMetadata, + context: Context, + pluginManifestPaths: readonly string[] = this.#pluginManifestPaths, + ): Promise { if (this.#detached || this.#shuttingDown) throw new Error("Experimental server is shutting down"); + this.assertSessionPluginManifestPaths(metadata, pluginManifestPaths); const existing = this.#workersBySession.get(metadata.path); if (existing) return this.#routedHandle(existing); const pending = this.#pending.get(metadata.path); if (pending) return this.#routedHandle(await pending.promise); - return this.#routedHandle(await this.#launch(metadata, context)); + return this.#routedHandle(await this.#launch(metadata, context, pluginManifestPaths)); } async closeSession(metadata: JsonlSessionMetadata, context: Context): Promise { @@ -495,7 +527,11 @@ export class SessionWorkerManager { this.#detachState(); } - #launch(metadata: JsonlSessionMetadata, _context: Context): Promise { + #launch( + metadata: JsonlSessionMetadata, + _context: Context, + pluginManifestPaths: readonly string[], + ): Promise { const sessionKey = metadata.path; const peerId = `worker-${randomUUID()}`; const token = randomUUID(); @@ -505,6 +541,7 @@ export class SessionWorkerManager { sessionDir: this.#sessionDir, metadata, ...(this.#model ?? {}), + ...(pluginManifestPaths.length === 0 ? {} : { pluginManifestPaths: [...pluginManifestPaths] }), }; child = spawnInternalProcess("session-worker", [JSON.stringify(options)], { env: { @@ -528,7 +565,17 @@ export class SessionWorkerManager { WORKER_STARTUP_TIMEOUT_MS, ); timer.unref(); - const pending = { sessionKey, peerId, token, child, timer, promise, resolve, reject }; + const pending = { + sessionKey, + peerId, + token, + pluginManifestPaths: Object.freeze([...pluginManifestPaths]), + child, + timer, + promise, + resolve, + reject, + }; this.#pending.set(sessionKey, pending); this.#notifyWorkerCountChanged(); child.once("error", (error) => this.#failPending(sessionKey, error)); @@ -711,12 +758,16 @@ export class SessionWorkerManager { return; } this.#markDiscovered(peerId); + const pending = this.#pending.get(message.sessionKey); + if (pending?.peerId === peerId && !sameStrings(message.pluginManifestPaths, pending.pluginManifestPaths)) { + this.#failPending(message.sessionKey, new Error("Session worker started with stale plugin packages")); + return; + } const existing = this.#workersBySession.get(message.sessionKey); if (existing && existing.peerId !== peerId) { void this.#coordinator.send(peerId, { type: "shutdown" }).catch(() => {}); return; } - const pending = this.#pending.get(message.sessionKey); if ( pending && (pending.peerId !== peerId || pending.token !== message.token || pending.child.pid !== message.pid) @@ -733,6 +784,7 @@ export class SessionWorkerManager { metadata: message.metadata, pid: message.pid, token: message.token, + pluginManifestPaths: Object.freeze([...message.pluginManifestPaths]), terminated, resolveTerminated, attachmentIds: new Set(), @@ -892,6 +944,10 @@ export class SessionWorkerManager { } } +function sameStrings(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + function sameScope(left: WorkerOperationScope, right: WorkerOperationScope): boolean { return left.serverConnectionId === right.serverConnectionId && left.attachmentId === right.attachmentId; } diff --git a/packages/coding-agent/src/experimental/session-worker.ts b/packages/coding-agent/src/experimental/session-worker.ts index 65a3f96a5..035ffbf61 100644 --- a/packages/coding-agent/src/experimental/session-worker.ts +++ b/packages/coding-agent/src/experimental/session-worker.ts @@ -47,7 +47,7 @@ import { toWireLaneSnapshot, toWireRunResult, } from "./harness-wire-adapter.ts"; -import { createConfiguredFacetBundleLoader } from "./plugins/bundled.ts"; +import { createConfiguredPluginFacetLoader } from "./plugins/bundled.ts"; import { consumeInternalProcessRole, encodeControlLine, @@ -87,6 +87,7 @@ export const SessionWorkerOptionsSchema = StrictObject({ metadata: SessionWorkerMetadataSchema, provider: Type.Optional(Type.String({ minLength: 1 })), model: Type.Optional(Type.String({ minLength: 1 })), + pluginManifestPaths: Type.Optional(Type.Array(Type.String({ minLength: 1 }))), }); export type SessionWorkerOptions = Static; @@ -184,6 +185,7 @@ export const SessionWorkerEventSchema = Type.Union([ sessionId: Type.String(), pid: Type.Integer({ minimum: 1 }), metadata: SessionWorkerMetadataSchema, + pluginManifestPaths: Type.Array(Type.String({ minLength: 1 })), }), Type.Object({ type: Type.Literal("worker_failed"), @@ -568,6 +570,7 @@ async function run(options: SessionWorkerOptions, createHarness: CreateSessionWo const token = process.env[SESSION_WORKER_CONTROL_TOKEN_ENV]!; const sessionKey = Buffer.from(process.env[SESSION_WORKER_SESSION_KEY_ENV]!, "base64url").toString(); failureControl = control; + const pluginManifestPaths = options.pluginManifestPaths ?? []; const releaseOwnership = await lockfile.lock(metadata.path, { realpath: true, stale: 2_000, @@ -777,6 +780,7 @@ async function run(options: SessionWorkerOptions, createHarness: CreateSessionWo sessionId, pid: process.pid, metadata, + pluginManifestPaths: [...pluginManifestPaths], }) .catch(() => closeAndExit()); }; @@ -844,7 +848,15 @@ async function run(options: SessionWorkerOptions, createHarness: CreateSessionWo try { ready = true; - await control.send({ type: "worker_ready", token, sessionKey, sessionId, pid: process.pid, metadata }); + await control.send({ + type: "worker_ready", + token, + sessionKey, + sessionId, + pid: process.pid, + metadata, + pluginManifestPaths: [...pluginManifestPaths], + }); } catch (error) { try { await close(); @@ -946,7 +958,7 @@ async function createCodingAgentHarness( lane, modelRuntime, settingsManager, - facetLoader: createConfiguredFacetBundleLoader("session"), + facetLoader: createConfiguredPluginFacetLoader("session", options.pluginManifestPaths ?? []), }; } catch (error) { try { diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index edceb07ed..6a58cef8c 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -606,6 +606,7 @@ async function runExperimentalServerCommand(command: ServerCommand): Promise { }); }); + test("parses repeatable server plugin packages", () => { + expect(cli.parse(["server", "-e", "./first-plugin", "-e=./second-plugin"])).toEqual({ + ok: true, + command: { + command: "server", + pluginPackages: ["./first-plugin", "./second-plugin"], + }, + }); + }); + test("leaves experimental-looking existing option values with the existing parser", () => { expect(cli.parse(["--system-prompt", "--listen", "unix:///tmp/pi.sock"])).toMatchObject({ ok: true, @@ -173,6 +183,16 @@ describe("experimental CLI commands", () => { }); }); + test("parses repeatable client plugin packages", () => { + expect(cli.parse(["client", "-e", "./first-plugin", "-e", "./second-plugin"])).toEqual({ + ok: true, + command: { + command: "client", + pluginPackages: ["./first-plugin", "./second-plugin"], + }, + }); + }); + test.each([ [["--auth-token", "secret"], { type: "token", token: "secret" }], [["--auth-token-file", "/tmp/token"], { type: "file", path: "/tmp/token" }], @@ -243,6 +263,7 @@ describe("experimental CLI commands", () => { [["server", "--server-id", "not-a-uuid"], "Invalid --server-id"], [["server", "--server-id"], "--server-id requires a value"], [["server", "--session-dir"], "--session-dir requires a value"], + [["client", "-e"], "-e requires a value"], [ ["server", "--session-dir", "/tmp/first", "--session-dir=/tmp/second"], "--session-dir may only be specified once", diff --git a/packages/coding-agent/test/experimental-client-tui.test.ts b/packages/coding-agent/test/experimental-client-tui.test.ts index dd655d75b..2557db3f8 100644 --- a/packages/coding-agent/test/experimental-client-tui.test.ts +++ b/packages/coding-agent/test/experimental-client-tui.test.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import { resolve } from "node:path"; import { createRemoteServiceBinding, defineFacet, @@ -89,6 +90,7 @@ describe("experimental client TUI", () => { test.each([ ["new", { command: "client" as const }, "two", 1], ["continued", { command: "client" as const, continue: true }, "one", 0], + ["plugin-selected", { command: "client" as const, pluginPackages: ["./example-plugin"] }, "two", 1], ] as const)( "opens a %s Session directly and renders a Harness-backed turn", async (_kind, command, sessionId, creates) => { @@ -204,11 +206,15 @@ describe("experimental client TUI", () => { }; const reloadData = createPresentationFacetHelloData([reloadArtifact]); if (reloadData === undefined) throw new Error("Expected presentation reload data"); + const prepareSessionPlugins = vi.fn(async () => reloadData); 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(PresentationPlugins, { + prepareSession: prepareSessionPlugins, + reload: reloadPresentationPlugins, + }); serverProvider.provide(SessionManagement, { create, async remove() {}, @@ -246,7 +252,15 @@ describe("experimental client TUI", () => { return serverProvider.catalogue; }, open() { - return serverNamespace; + return { + use: serverNamespace.use.bind(serverNamespace), + observe: serverNamespace.observe.bind(serverNamespace), + async ready() { + await serverNamespace.rebind(true, BACKGROUND_CONTEXT); + await serverNamespaceReady(BACKGROUND_CONTEXT); + }, + async dispose() {}, + }; }, async ready() { await serverNamespace.rebind(true, BACKGROUND_CONTEXT); @@ -265,7 +279,12 @@ describe("experimental client TUI", () => { return []; }, open() { - return sessionNamespace; + return { + use: sessionNamespace.use.bind(sessionNamespace), + observe: sessionNamespace.observe.bind(sessionNamespace), + ready: sessionNamespace.ready.bind(sessionNamespace), + async dispose() {}, + }; }, async whenAttached(sessionId: string) { await sessionNamespace.rebind(true, BACKGROUND_CONTEXT); @@ -316,6 +335,16 @@ describe("experimental client TUI", () => { }); try { expect(create).toHaveBeenCalledTimes(creates); + expect(prepareSessionPlugins).toHaveBeenCalledWith( + { + sessionId, + packagePaths: + "pluginPackages" in command + ? command.pluginPackages.map((packagePath) => resolve(packagePath)) + : null, + }, + expect.anything(), + ); expect(attachment.value).toEqual({ status: "attached", sessionId }); expect(watchSession).toHaveBeenCalledWith(sessionId); expect(select).not.toHaveBeenCalled(); @@ -394,7 +423,7 @@ describe("experimental client TUI", () => { await vi.waitFor(() => expect(finished).toBe(true)); await component.close(); - expect(disposeLoadedFacets).toHaveBeenCalledOnce(); + expect(disposeLoadedFacets).not.toHaveBeenCalled(); const rendersAfterClose = requestRender.mock.calls.length; directoryState.set({ revision: 3, sessions: [] }, BACKGROUND_CONTEXT); attachment.set({ status: "detached" }, BACKGROUND_CONTEXT); diff --git a/packages/coding-agent/test/experimental-presentation-facets.test.ts b/packages/coding-agent/test/experimental-presentation-facets.test.ts index d9ac678eb..a1bd540b0 100644 --- a/packages/coding-agent/test/experimental-presentation-facets.test.ts +++ b/packages/coding-agent/test/experimental-presentation-facets.test.ts @@ -1,59 +1,190 @@ import { createHash, randomUUID } from "node:crypto"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; import { FACET_BUNDLE_ARTIFACT_FORMAT, FACET_BUNDLE_ARTIFACT_FORMAT_VERSION, type FacetBundleArtifact, + readFacetBundleManifest, } 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 { + activateBuiltinClientServices, + type ClientRuntime, + openClientRuntime, +} from "../src/experimental/client-runtime.ts"; +import { + createPresentationFacetData, createPresentationFacetHelloData, - restoreServerFacetBundleProfile, + createPresentationFacetLoaders, } from "../src/experimental/plugins/bundled.ts"; +import { createServerPluginPackage, restoreServerPluginPackageProfile } from "../src/experimental/plugins/package.ts"; +import { type RunningServer, startServer } from "../src/experimental/server.ts"; +import { PresentationPlugins } from "../src/experimental/services/plugins.ts"; const runtimes = new Set(); +const runningServers = new Set(); const servers = new Set>(); const directories = new Set(); afterEach(async () => { await Promise.allSettled([...runtimes].map((runtime) => runtime.dispose())); + await Promise.allSettled([...runningServers].map((server) => server.close())); await Promise.allSettled([...servers].map((server) => server.close())); await Promise.allSettled([...directories].map((directory) => rm(directory, { force: true, recursive: true }))); runtimes.clear(); + runningServers.clear(); servers.clear(); directories.clear(); }); describe("server-selected presentation facets", () => { - test("restores the bundle selection for later server generations", async () => { + test("rejects local plugin paths for Radius servers", async () => { + await expect( + openClientRuntime({ + command: "client", + connect: { transport: "radius", serverId: randomUUID() }, + pluginPackages: ["./local-plugin"], + }), + ).rejects.toThrow("only be configured on a local Unix server"); + }); + + test("restores plugin package selections 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; + const packagePaths = [join(directory, "first-plugin"), join(directory, "second-plugin")]; + await expect(restoreServerPluginPackageProfile(directory, serverId, packagePaths)).resolves.toEqual(packagePaths); + await expect(restoreServerPluginPackageProfile(directory, serverId)).resolves.toEqual(packagePaths); + await expect(restoreServerPluginPackageProfile(directory, serverId, [])).resolves.toEqual([]); + await expect(restoreServerPluginPackageProfile(directory, serverId)).resolves.toEqual([]); + }); + + test("builds conventional plugin entries into the server-owned plugin cache", async () => { + const directory = await mkdtemp("/tmp/pi-presentation-package-"); + directories.add(directory); + const serverId = randomUUID(); + const packagePath = join(directory, "pi-example-plugin"); + await mkdir(join(packagePath, "src"), { recursive: true }); + await writeFile( + join(packagePath, "package.json"), + `${JSON.stringify({ + name: "@earendil-works/test-plugin", + version: "1.0.0", + peerDependencies: { + "@earendil-works/chord": "^0.84.4", + "@earendil-works/pi-coding-agent": "^0.84.4", + }, + })}\n`, + ); + const sourcePath = join(packagePath, "src", "tui.ts"); + await writeFile( + sourcePath, + 'import { defineFacet } from "@earendil-works/chord"; import { SlashCommands } from "@earendil-works/pi-coding-agent/experimental/plugin"; export default defineFacet({ id: "built-a", setup(env) { env.use(SlashCommands); } });\n', + ); + const plugin = createServerPluginPackage(directory, serverId, packagePath); + + const first = await plugin.build(); + expect(first).toHaveLength(1); + expect(plugin.manifestPath).toMatch( + new RegExp(`/plugin-builds/${serverId}/pi-example-plugin-[a-f0-9]{12}/chord-facets\\.json$`, "u"), + ); + expect(first[0]?.plugin).toEqual({ id: "@earendil-works/test-plugin", version: "1.0.0" }); + const firstLoaded = await createPresentationFacetLoaders(createPresentationFacetData(first))[0]!.load(); + expect(firstLoaded.facets.map(({ id }) => id)).toEqual(["built-a"]); + await firstLoaded.dispose(); + + await writeFile( + sourcePath, + 'import { defineFacet } from "@earendil-works/chord"; import { SlashCommands } from "@earendil-works/pi-coding-agent/experimental/plugin"; export default defineFacet({ id: "built-b", setup(env) { env.use(SlashCommands); } });\n', + ); + const second = await plugin.build(); + expect(second[0]?.source).not.toBe(first[0]?.source); + const secondLoaded = await createPresentationFacetLoaders(createPresentationFacetData(second))[0]!.load(); + expect(secondLoaded.facets.map(({ id }) => id)).toEqual(["built-b"]); + await secondLoaded.dispose(); + + const secondPackagePath = join(directory, "second-plugin"); + await mkdir(join(secondPackagePath, "src"), { recursive: true }); + await Promise.all([ + writeFile( + join(secondPackagePath, "package.json"), + `${JSON.stringify({ + name: "@earendil-works/second-test-plugin", + version: "1.0.0", + peerDependencies: { "@earendil-works/chord": "^0.84.4" }, + })}\n`, + ), + writeFile( + join(secondPackagePath, "src", "tui.ts"), + 'import { defineFacet } from "@earendil-works/chord"; export default defineFacet({ id: "second-built", setup() {} });\n', + ), + ]); + const running = await startServer({ + directory: join(directory, "server"), + sessionDir: join(directory, "sessions"), + }); + runningServers.add(running); + const runtime = await openClientRuntime({ + command: "client", + connect: { transport: "unix", path: running.socketPath }, + pluginPackages: [packagePath, secondPackagePath], + }); + runtimes.add(runtime); + const activated = await activateBuiltinClientServices(runtime.servers[0]!); + const sessionId = randomUUID(); + await activated.management.create({ id: sessionId }, BACKGROUND_CONTEXT); + const presentationPlugins = await activated.plugins.prepareSession( + { sessionId, packagePaths: [packagePath, secondPackagePath] }, + BACKGROUND_CONTEXT, + ); + await expect(restoreServerPluginPackageProfile(join(directory, "server"), running.serverId)).resolves.toEqual([]); + const serverLoaded = await Promise.all( + createPresentationFacetLoaders(presentationPlugins).map((loader) => loader.load()), + ); + expect(serverLoaded.flatMap(({ facets }) => facets.map(({ id }) => id))).toEqual(["built-b", "second-built"]); + await Promise.all(serverLoaded.map((loaded) => loaded.dispose())); + + await writeFile( + sourcePath, + 'import { defineFacet } from "@earendil-works/chord"; import { SlashCommands } from "@earendil-works/pi-coding-agent/experimental/plugin"; export default defineFacet({ id: "built-c", setup(env) { env.use(SlashCommands); } });\n', + ); + const services = runtime.servers[0]!.server.open({ + services: [PresentationPlugins], + assertAccess() {}, + onError() {}, + }); 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(); + await services.ready(BACKGROUND_CONTEXT); + const data = await services.use(PresentationPlugins).reload(BACKGROUND_CONTEXT); + const reloaded = await Promise.all(createPresentationFacetLoaders(data).map((loader) => loader.load())); + expect(reloaded.flatMap(({ facets }) => facets.map(({ id }) => id))).toEqual(["built-c", "second-built"]); + await Promise.all(reloaded.map((loaded) => loaded.dispose())); } finally { - if (original === undefined) delete process.env.PI_EXPERIMENTAL_FACET_BUNDLE; - else process.env.PI_EXPERIMENTAL_FACET_BUNDLE = original; + await services.dispose(BACKGROUND_CONTEXT); } }); + test("builds the example plugin package without a package-owned build script", async () => { + const directory = await mkdtemp("/tmp/pi-example-plugin-"); + directories.add(directory); + const serverId = randomUUID(); + const packagePath = fileURLToPath(new URL("../examples/plugins/pi-example-plugin", import.meta.url)); + const plugin = createServerPluginPackage(directory, serverId, packagePath); + + const artifacts = await plugin.build(); + const manifest = await readFacetBundleManifest(plugin.manifestPath); + expect(manifest.plugin).toEqual({ id: "@earendil-works/pi-example-plugin", version: "1.0.0" }); + expect(Object.keys(manifest.entries)).toEqual(["session", "tui"]); + const loaded = await createPresentationFacetLoaders(createPresentationFacetData(artifacts))[0]!.load(); + expect(loaded.facets.map(({ id }) => id)).toEqual(["@earendil-works/pi-example-plugin/tui"]); + await loaded.dispose(); + }); + 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'; diff --git a/packages/coding-agent/test/experimental-remote-runtime.test.ts b/packages/coding-agent/test/experimental-remote-runtime.test.ts index 71d7fa17a..a02a3f3e6 100644 --- a/packages/coding-agent/test/experimental-remote-runtime.test.ts +++ b/packages/coding-agent/test/experimental-remote-runtime.test.ts @@ -1,12 +1,15 @@ -import { lstat, mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; +import { lstat, mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; import { join, resolve } from "node:path"; -import { type Context, createFacetHost, defineFacet } from "@earendil-works/chord"; +import { fileURLToPath } from "node:url"; +import { type Context, createFacetHost, defineFacet, defineService } from "@earendil-works/chord"; import { BACKGROUND_CONTEXT, withCancel } from "@earendil-works/chord/context"; -import { Client } from "@earendil-works/pi-client"; +import { Client, ServerError as ClientServerError } from "@earendil-works/pi-client"; import { createUnixTransportFactory } from "@earendil-works/pi-client/unix"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { ExampleFacetService } from "../examples/plugins/pi-example-plugin/src/contract.ts"; import { runClient } from "../src/experimental/client.ts"; import { activateBuiltinClientServices, openClientRuntime } from "../src/experimental/client-runtime.ts"; +import { createPresentationFacetLoaders } from "../src/experimental/plugins/bundled.ts"; import * as processRuntime from "../src/experimental/process.ts"; import { activateServer, type RunningServer, startServer } from "../src/experimental/server.ts"; import { @@ -32,6 +35,7 @@ const directories = new Set(); const fauxWorkerEntryUrl = new URL("fixtures/faux-session-worker.ts", import.meta.url); const realSpawnInternalProcess = processRuntime.spawnInternalProcess; const sessionWorkerModel = { provider: "anthropic", model: "claude-sonnet-4-5" } as const; +const SecondPluginService = defineService<{ read(context: Context): Promise }>("test.second-plugin"); let agentDir: string; beforeEach(async () => { @@ -192,6 +196,20 @@ describe("experimental durable server composition", () => { ).rejects.toThrow("Model selection is only valid when automatically activating a new server"); }); + test("rechecks an auto-discovered server after a version mismatch", async () => { + const { directory, runtime } = await makeServer(); + const connect = Client.connect.bind(Client); + vi.spyOn(Client, "connect") + .mockRejectedValueOnce(new ClientServerError({ code: "version", message: "stale server" })) + .mockImplementation((options) => connect(options)); + const clientRuntime = await openClientRuntime({ command: "client" }, { directory }); + try { + expect(clientRuntime.servers.map(({ route }) => route.serverId)).toEqual([runtime.serverId]); + } finally { + await clientRuntime.dispose(); + } + }); + test("serializes concurrent cold activation and retires after both clients leave", async () => { const directory = await mkdtemp(join("/tmp", "pi-auto-server-")); directories.add(directory); @@ -221,6 +239,44 @@ describe("experimental durable server composition", () => { await expect.poll(() => pathExists(join(directory, `control-${serverId}.sock`)), { timeout: 5_000 }).toBe(false); }); + test("passes client plugin packages to a cold server and restores them for its next generation", async () => { + const directory = await mkdtemp(join("/tmp", "pi-auto-plugin-")); + directories.add(directory); + const serverId = "00000000-0000-4000-8000-000000000001"; + const packagePath = fileURLToPath(new URL("../examples/plugins/pi-example-plugin", import.meta.url)); + vi.stubEnv("PI_SERVER_DIR", directory); + vi.stubEnv("PI_SERVER_ID", serverId); + + const first = await openClientRuntime({ command: "client", ...sessionWorkerModel }); + try { + const activated = await activateBuiltinClientServices(first.servers[0]!); + const presentationPlugins = await activated.plugins.prepareSession( + { sessionId: "demo-1", packagePaths: [packagePath] }, + BACKGROUND_CONTEXT, + ); + await activated.management.attach("demo-1", BACKGROUND_CONTEXT); + const loaded = await createPresentationFacetLoaders(presentationPlugins)[0]!.load(); + expect(loaded.facets.map(({ id }) => id)).toEqual(["@earendil-works/pi-example-plugin/tui"]); + await loaded.dispose(); + } finally { + await first.dispose(); + } + await expect.poll(() => pathExists(join(directory, `${serverId}.sock`)), { timeout: 5_000 }).toBe(false); + + const second = await openClientRuntime({ command: "client", ...sessionWorkerModel }); + try { + const activated = await activateBuiltinClientServices(second.servers[0]!); + const presentationPlugins = await activated.plugins.prepareSession( + { sessionId: "demo-1", packagePaths: null }, + BACKGROUND_CONTEXT, + ); + await activated.management.attach("demo-1", BACKGROUND_CONTEXT); + expect(createPresentationFacetLoaders(presentationPlugins)).toHaveLength(1); + } finally { + await second.dispose(); + } + }); + test("retires a cold server after its only Session attachment disconnects", async () => { const directory = await mkdtemp(join("/tmp", "pi-auto-session-")); directories.add(directory); @@ -414,6 +470,79 @@ describe("experimental durable server composition", () => { await Promise.all([firstServices.dispose(BACKGROUND_CONTEXT), secondServices.dispose(BACKGROUND_CONTEXT)]); }); + test("loads conventional Session facets from multiple configured plugin packages", async () => { + const directory = await mkdtemp(join("/tmp", "pes-plugin-")); + directories.add(directory); + const secondPackagePath = join(directory, "second-plugin"); + await mkdir(join(secondPackagePath, "src"), { recursive: true }); + await Promise.all([ + writeFile( + join(secondPackagePath, "package.json"), + `${JSON.stringify({ + name: "@earendil-works/second-session-plugin", + version: "1.0.0", + peerDependencies: { "@earendil-works/chord": "^0.84.4" }, + })}\n`, + ), + writeFile( + join(secondPackagePath, "src", "session.ts"), + 'import { defineFacet, defineService } from "@earendil-works/chord"; const Service = defineService("test.second-plugin"); export default defineFacet({ id: "second-session-plugin", setup(env) { env.provide(Service, { async read() { return "second"; } }); } });\n', + ), + ]); + const runtime = await startServer({ ...sessionWorkerModel, directory }); + servers.add(runtime); + await attachClient(runtime, "demo-2"); + const unrelatedWorkerPid = runtime.workerPids.get("demo-2"); + const clientRuntime = await openClientRuntime( + { + command: "client", + pluginPackages: [ + fileURLToPath(new URL("../examples/plugins/pi-example-plugin", import.meta.url)), + secondPackagePath, + ], + }, + { directory }, + ); + const activated = await activateBuiltinClientServices(clientRuntime.servers[0]!); + const selectedPackagePaths = [ + fileURLToPath(new URL("../examples/plugins/pi-example-plugin", import.meta.url)), + secondPackagePath, + ]; + await expect( + activated.plugins.prepareSession( + { sessionId: "demo-2", packagePaths: selectedPackagePaths }, + BACKGROUND_CONTEXT, + ), + ).rejects.toThrow("active with a different plugin selection"); + expect(runtime.workerPids.get("demo-2")).toBe(unrelatedWorkerPid); + await activated.plugins.prepareSession( + { + sessionId: "demo-1", + packagePaths: selectedPackagePaths, + }, + BACKGROUND_CONTEXT, + ); + await activated.management.attach("demo-1", BACKGROUND_CONTEXT); + const client = clientRuntime.servers[0]!.client; + const services = createSessionServiceBinding(client, { + services: [ExampleFacetService, SecondPluginService], + }); + try { + await services.ready(BACKGROUND_CONTEXT); + const example = services.use(ExampleFacetService); + await expect(example.greet({ name: "Armin" }, BACKGROUND_CONTEXT)).resolves.toEqual({ + message: "Hello Armin from the bundled Session worker facet!!!", + workerActivations: 1, + }); + expect(example.workerActivations.value).toBe(1); + await expect(services.use(SecondPluginService).read(BACKGROUND_CONTEXT)).resolves.toBe("second"); + expect(runtime.workerPids.get("demo-2")).toBe(unrelatedWorkerPid); + } finally { + await services.dispose(BACKGROUND_CONTEXT); + await clientRuntime.dispose(); + } + }); + test("uses the most recently selected model for a new Session", async () => { const directory = await mkdtemp(join("/tmp", "pes-model-default-")); directories.add(directory); @@ -448,6 +577,7 @@ describe("experimental durable server composition", () => { }); try { const server = await activateBuiltinClientServices(clientRuntime.servers[0]!); + await server.plugins.prepareSession({ sessionId: "demo-1", packagePaths: null }, BACKGROUND_CONTEXT); await server.management.attach("demo-1", BACKGROUND_CONTEXT); expect(server.session.attachment.value).toEqual({ status: "attached", sessionId: "demo-1" }); diff --git a/packages/coding-agent/test/experimental-session-worker-manager.test.ts b/packages/coding-agent/test/experimental-session-worker-manager.test.ts index 34a34a01e..92d367418 100644 --- a/packages/coding-agent/test/experimental-session-worker-manager.test.ts +++ b/packages/coding-agent/test/experimental-session-worker-manager.test.ts @@ -42,6 +42,7 @@ class FakeCoordinator { sessionId: metadata.id, pid: 123, metadata, + pluginManifestPaths: [], }, }); } @@ -95,6 +96,27 @@ async function createAttachedWorker(): Promise<{ } describe("Session worker lifecycle failures", () => { + test("adopts a discovered worker with its existing Session plugin selection", async () => { + const coordinator = new FakeCoordinator(); + const workers = new SessionWorkerManager(coordinator, "/tmp", undefined, undefined, [ + "/tmp/plugin/chord-facets.json", + ]); + await workers.discover(new Set(["worker-1"])); + expect(coordinator.sent).not.toContainEqual({ peerId: "worker-1", payload: { type: "shutdown" } }); + expect(workers.workerPids.size).toBe(1); + expect(() => workers.assertSessionPluginManifestPaths(metadata, [])).not.toThrow(); + workers.detach(); + }); + + test("rejects a different plugin selection for an active Session without stopping it", async () => { + const { workers } = await createAttachedWorker(); + expect(() => workers.assertSessionPluginManifestPaths(metadata, ["/tmp/plugin/chord-facets.json"])).toThrow( + "active with a different plugin selection", + ); + expect(workers.workerPids.size).toBe(1); + workers.detach(); + }); + test("compensates a timed-out attachment before rejecting it", async () => { vi.useFakeTimers(); const coordinator = new FakeCoordinator(); diff --git a/packages/coding-agent/tsconfig.build.json b/packages/coding-agent/tsconfig.build.json index c5bef8ae0..87bc51b10 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/bundler": ["../chord/dist/bundler.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"], diff --git a/packages/protocol/README.md b/packages/protocol/README.md index c8d7cb2f8..cf265ce6d 100644 --- a/packages/protocol/README.md +++ b/packages/protocol/README.md @@ -2,7 +2,7 @@ Runtime-neutral schemas, types, CBOR encoding, and byte-stream framing for the experimental Pi protocol. -Protocol version `4` carries the current server- and Session-scoped facet-service slice: +Protocol version `5` carries the current server- and Session-scoped facet-service slice, including Session-branch plugin preparation: - a version handshake that identifies the logical `serverId` and can carry application bootstrap data; - explicit server and Session request targets; diff --git a/packages/protocol/src/protocol.ts b/packages/protocol/src/protocol.ts index dc25d2477..79ef05f05 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 = 4 as const; +export const PROTOCOL_VERSION = 5 as const; const IdSchema = Type.String({ minLength: 1 }); const TimestampSchema = Type.Integer({ minimum: 0 }); diff --git a/packages/protocol/test/protocol.test.ts b/packages/protocol/test/protocol.test.ts index e975bcbbd..619c6ba4d 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 4", () => { - expect(PROTOCOL_VERSION).toBe(4); - expect(isSupportedProtocolVersion(4)).toBe(true); - expect(isSupportedProtocolVersion(3)).toBe(false); - expect(isSupportedProtocolVersion(4.5)).toBe(false); + test("negotiates protocol version 5", () => { + expect(PROTOCOL_VERSION).toBe(5); + expect(isSupportedProtocolVersion(5)).toBe(true); + expect(isSupportedProtocolVersion(4)).toBe(false); + expect(isSupportedProtocolVersion(5.5)).toBe(false); }); test.each([0, PROTOCOL_VERSION, PROTOCOL_VERSION + 1])( diff --git a/scripts/build-coding-agent-bundle.mjs b/scripts/build-coding-agent-bundle.mjs index a8c018dc7..9b8cd7460 100644 --- a/scripts/build-coding-agent-bundle.mjs +++ b/scripts/build-coding-agent-bundle.mjs @@ -17,6 +17,7 @@ const banner = { }; const allowedExternalPackages = new Set([ "@earendil-works/chord", + "@earendil-works/chord/bundler", "@earendil-works/chord/context", "@earendil-works/chord/node", "@silvia-odwyer/photon-node", diff --git a/tsconfig.json b/tsconfig.json index a2d684e9c..5e12d1164 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -28,6 +28,9 @@ ], "@earendil-works/pi-session-backend-sqlite-node": ["./packages/session-backends/sqlite-node/src/index.ts"], "@earendil-works/pi-coding-agent": ["./packages/coding-agent/src/index.ts"], + "@earendil-works/pi-coding-agent/experimental/plugin": [ + "./packages/coding-agent/src/experimental/plugin.ts" + ], "@earendil-works/pi-coding-agent/hooks": ["./packages/coding-agent/src/core/hooks/index.ts"], "@earendil-works/pi-coding-agent/*": ["./packages/coding-agent/src/*"], "@earendil-works/pi-protocol": ["./packages/protocol/src/index.ts"],