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