feat: simplify plugin facet lifecycle

Remove handshake-based facet delivery and host only the selected Session presentation branch. Simplify worker selection, startup options, package profiles, and serialized builds.
This commit is contained in:
Armin Ronacher 2026-08-30 22:36:30 +02:00
parent 429f4e756c
commit 5245aba7c8
18 changed files with 251 additions and 471 deletions

View file

@ -22,7 +22,7 @@ Repeat `-e` to select multiple plugin packages. Client paths are resolved locall
`server -e` establishes the server profile's default Session and TUI facets. Starting an explicit foreground server without `-e` clears that default. Client selection never changes the server's root facet generation.
Run `/facet-hello Armin` in the TUI. After editing a facet, run `/reload`. The server atomically rebuilds the package, reloads the attached Session-worker generation, updates the current TUI generation, and advertises the new artifact to future clients.
Run `/facet-hello Armin` in the TUI. After editing a facet, run `/reload`. The server atomically rebuilds the package, reloads the attached Session-worker generation, updates the current TUI generation, and serves the new artifact to future clients.
Package metadata can override or disable conventional entries:

View file

@ -1,11 +1,9 @@
import { basename } from "node:path";
import type { FacetLoader } from "@earendil-works/chord";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { Client, ServerError } from "@earendil-works/pi-client";
import { createUnixTransportFactory, discoverUnixServers, type UnixServerRoute } from "@earendil-works/pi-client/unix";
import { isServerId, type ServerId } from "@earendil-works/pi-protocol";
import type { ClientCommand } from "../cli/experimental/commands/client.ts";
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";
@ -27,7 +25,6 @@ export type ClientRuntimeRoute =
export interface ClientRuntimeServer {
readonly route: ClientRuntimeRoute;
readonly client: Client;
readonly presentationFacetLoaders: readonly FacetLoader[];
readonly server: ServerServiceSource;
readonly session: SessionServiceSource;
}
@ -157,13 +154,7 @@ export async function openClientRuntime(
serviceSources.push(server);
const session = createSessionServiceSource(client);
serviceSources.push(session);
servers.push({
route,
client,
presentationFacetLoaders: createPresentationFacetLoaders(client.hello?.data),
server,
session,
});
servers.push({ route, client, server, session });
}
return { servers, dispose };
} catch (error) {

View file

@ -58,7 +58,6 @@ 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;
@ -66,24 +65,12 @@ export interface ClientTuiServer {
interface SessionFeature {
readonly serverId: string;
readonly directory: SessionDirectory;
readonly management: SessionManagement;
readonly session: SessionServiceSource;
readonly laneWatches: LaneWatchSource;
}
interface SlashCommandFeature {
readonly serverId: string;
readonly commands: SlashCommands;
}
interface AgentFeature {
readonly serverId: string;
readonly controller: AgentController;
}
interface PreparedClientSession {
readonly serverId: string;
readonly server: ClientTuiServer;
readonly summary: SessionSummary;
readonly presentationPlugins: JsonValue;
}
@ -116,13 +103,13 @@ export class ExperimentalClientTui implements Component {
readonly #footerComponent = new Text("", 1, 0);
readonly #layoutRoot: Component;
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>();
readonly #agents = new Map<string, AgentFeature>();
readonly #keybindings = KeybindingsManager.create();
#presentationFacets: LoadedFacets | undefined;
#facetHost: FacetHost | undefined;
#facetReloadTail = Promise.resolve();
#session: SessionFeature | undefined;
#slashCommands: SlashCommands | undefined;
#controller: AgentController | undefined;
readonly #chatInput: CustomEditor;
#selectList: SelectList | undefined;
#selection: PendingSelection | undefined;
@ -143,7 +130,6 @@ export class ExperimentalClientTui implements Component {
this.#requestRender = requestRender;
this.#finish = finish;
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);
@ -184,7 +170,7 @@ export class ExperimentalClientTui implements Component {
]).load();
const component = new ExperimentalClientTui(options.ui, options.requestRender, options.finish, loadedFacets);
try {
await component.#start(options.servers, prepared);
await component.#start(prepared);
await component.#openPreparedSession(prepared);
return component;
} catch (error) {
@ -253,105 +239,96 @@ export class ExperimentalClientTui implements Component {
return this.#closePromise;
}
async #start(servers: readonly ClientTuiServer[], prepared: PreparedClientSession): Promise<void> {
for (const server of servers) {
const presentationFacetLoaders =
server.serverId === prepared.serverId
? createPresentationFacetLoaders(prepared.presentationPlugins)
: (server.presentationFacetLoaders ?? []);
let presentationFacets = await combineFacetLoaders(presentationFacetLoaders).load();
this.#loadedFacetGenerations.add(presentationFacets);
let facetHost!: FacetHost;
const reloadState = { tail: Promise.resolve() };
this.#facetReloads.add(reloadState);
const reloadPresentationPlugins = (data: JsonValue): Promise<void> => {
const operation = reloadState.tail.then(async () => {
const candidate = await combineFacetLoaders(createPresentationFacetLoaders(data)).load();
async #start(prepared: PreparedClientSession): Promise<void> {
const server = prepared.server;
let presentationFacets = await combineFacetLoaders(
createPresentationFacetLoaders(prepared.presentationPlugins),
).load();
this.#presentationFacets = presentationFacets;
let facetHost!: FacetHost;
const reloadPresentationPlugins = (data: JsonValue): Promise<void> => {
const operation = this.#facetReloadTail.then(async () => {
const candidate = await combineFacetLoaders(createPresentationFacetLoaders(data)).load();
try {
await facetHost.reload(candidate.facets);
} catch (error) {
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;
await candidate.dispose();
} catch (cleanupError) {
throw new AggregateError([error, cleanupError], "TUI plugin reload and cleanup failed");
}
const retired = presentationFacets;
presentationFacets = candidate;
this.#loadedFacetGenerations.delete(retired);
this.#loadedFacetGenerations.add(candidate);
await retired.dispose();
throw error;
}
const retired = presentationFacets;
presentationFacets = candidate;
this.#presentationFacets = candidate;
await retired.dispose();
});
this.#facetReloadTail = 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();
},
});
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 commands = env.use(SlashCommands);
const controller = env.use(AgentController);
const sessionFeature: SessionFeature = {
serverId: server.serverId,
session: server.session,
laneWatches: server.laneWatches,
};
env.onActivate(() => {
if (this.#session !== undefined || this.#slashCommands !== undefined || this.#controller !== undefined) {
throw new Error("Presentation services are already active");
}
this.#session = sessionFeature;
this.#slashCommands = commands;
this.#controller = controller;
env.own(() => {
if (this.#session === sessionFeature) this.#session = undefined;
if (this.#slashCommands === commands) this.#slashCommands = undefined;
if (this.#controller === controller) this.#controller = undefined;
});
const directory = env.use(SessionDirectory);
const management = env.use(SessionManagement);
const commands = env.use(SlashCommands);
const controller = env.use(AgentController);
const sessionFeature: SessionFeature = {
serverId: server.serverId,
directory,
management,
session: server.session,
laneWatches: server.laneWatches,
};
env.onActivate(() => {
env.own(this.#register(this.#sessions, "Sessions", sessionFeature));
env.own(commands.subscribe(() => this.#updateAutocomplete()));
if (server.radius) {
env.own(
this.#register(this.#slashCommands, "Slash commands", { serverId: server.serverId, commands }),
server.server.connection.subscribe((state) => this.#handleConnectionState(server.serverId, state)),
);
env.own(this.#register(this.#agents, "Agent controller", { serverId: server.serverId, controller }));
env.own(directory.state.subscribe(() => this.#rebuild()));
env.own(commands.subscribe(() => this.#updateAutocomplete()));
if (server.radius) {
env.own(
server.server.connection.subscribe((state) =>
this.#handleConnectionState(server.serverId, state),
),
);
env.own(
server.session.attachment.subscribe((state) =>
this.#handleAttachmentState(sessionFeature, state),
),
);
}
});
},
});
facetHost = await createFacetHost({
facets: [
createSlashCommandsRuntimeFacet(),
presentationBridgeFacet,
createBuiltInSlashCommandsFacet({ reloadPresentationPlugins }),
...this.#sharedFacets.facets,
...presentationFacets.facets,
],
serviceSources: [server.server, server.session],
});
this.#facetHosts.push(facetHost);
}
env.own(
server.session.attachment.subscribe((state) => this.#handleAttachmentState(sessionFeature, state)),
);
}
});
},
});
facetHost = await createFacetHost({
facets: [
createSlashCommandsRuntimeFacet(),
presentationBridgeFacet,
createBuiltInSlashCommandsFacet({ reloadPresentationPlugins }),
...this.#sharedFacets.facets,
...presentationFacets.facets,
],
serviceSources: [server.server, server.session],
});
this.#facetHost = facetHost;
}
async #openPreparedSession(prepared: PreparedClientSession): Promise<void> {
const feature = this.#sessions.get(prepared.serverId);
if (feature === undefined) throw new Error(`No Session service is available for ${prepared.serverId}`);
const feature = this.#session;
if (feature === undefined) throw new Error(`No Session service is available for ${prepared.server.serverId}`);
await feature.session.whenAttached(prepared.summary.sessionId, BACKGROUND_CONTEXT);
this.#selectedServerId = feature.serverId;
this.#sessionId = prepared.summary.sessionId;
@ -369,37 +346,28 @@ export class ExperimentalClientTui implements Component {
try {
await this.#recoveryTransition;
await this.#closeLane();
await this.#facetReloadTail;
} 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)
.reverse()
.map((host) => host.dispose()),
if (this.#facetHost !== undefined) {
try {
await this.#facetHost.dispose();
} catch (error) {
errors.push(error);
}
this.#facetHost = undefined;
}
const generations = [this.#presentationFacets, this.#sharedFacets].filter(
(generation): generation is LoadedFacets => generation !== undefined,
);
this.#presentationFacets = undefined;
const results = await Promise.allSettled(generations.map((generation) => generation.dispose()));
errors.push(...results.flatMap((result) => (result.status === "rejected" ? [result.reason] : [])));
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");
}
#register<T extends { readonly serverId: string }>(features: Map<string, T>, label: string, feature: T): () => void {
if (features.has(feature.serverId))
throw new Error(`${label} is already registered for server ${feature.serverId}`);
features.set(feature.serverId, feature);
return () => {
if (features.get(feature.serverId) !== feature) return;
features.delete(feature.serverId);
};
}
#rebuild(): void {
this.#sessionHeading.setText(
this.#sessionId === undefined || this.#selectedServerId === undefined
@ -476,9 +444,7 @@ export class ExperimentalClientTui implements Component {
}
#selectedSlashCommands(): SlashCommands | undefined {
if (this.#selectedServerId !== undefined) return this.#slashCommands.get(this.#selectedServerId)?.commands;
if (this.#slashCommands.size === 1) return this.#slashCommands.values().next().value?.commands;
return undefined;
return this.#slashCommands;
}
#handleConnectionState(serverId: string, state: ServerConnectionState): void {
@ -650,7 +616,7 @@ export class ExperimentalClientTui implements Component {
}
#selectedController(): AgentController | undefined {
return this.#selectedServerId === undefined ? undefined : this.#agents.get(this.#selectedServerId)?.controller;
return this.#controller;
}
#footer(): string {
@ -749,7 +715,7 @@ async function prepareClientSession(
await selected.management.attach(selected.summary.sessionId, BACKGROUND_CONTEXT);
await selected.server.session.whenAttached(selected.summary.sessionId, BACKGROUND_CONTEXT);
return {
serverId: selected.server.serverId,
server: selected.server,
summary: selected.summary,
presentationPlugins,
};
@ -805,7 +771,6 @@ 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,

View file

@ -10,34 +10,26 @@ import type { JsonValue } from "@earendil-works/pi-protocol";
const PRESENTATION_FACET_BUNDLES_KEY = "presentationFacetBundles";
const PI_PLUGIN_API = "@earendil-works/pi-coding-agent/experimental/plugin";
export function createConfiguredPluginFacetLoader(
entry: "session",
manifestPaths: readonly string[],
): FacetLoader | undefined {
export function createSessionPluginFacetLoader(manifestPaths: readonly string[]): FacetLoader | undefined {
if (manifestPaths.length === 0) return undefined;
return combineFacetLoaders(manifestPaths.map((manifestPath) => createOptionalFacetLoader(manifestPath, entry)));
return combineFacetLoaders(manifestPaths.map(createOptionalSessionFacetLoader));
}
function createOptionalFacetLoader(manifestPath: string, entry: "session"): FacetLoader {
function createOptionalSessionFacetLoader(manifestPath: string): FacetLoader {
const loader = createFacetBundleLoader({
manifestPath,
entry,
entry: "session",
resolveExternal: resolvePluginExternal,
});
return {
async load() {
const manifest = await readFacetBundleManifest(manifestPath);
if (manifest.entries[entry] !== undefined) return loader.load();
if (manifest.entries.session !== undefined) return loader.load();
return { facets: Object.freeze([]), async dispose() {} };
},
};
}
export function createPresentationFacetHelloData(artifacts: readonly FacetBundleArtifact[]): JsonValue | undefined {
if (artifacts.length === 0) return undefined;
return createPresentationFacetData(artifacts);
}
export function createPresentationFacetData(artifacts: readonly FacetBundleArtifact[]): JsonValue {
return {
[PRESENTATION_FACET_BUNDLES_KEY]: artifacts.map((artifact) => artifact as unknown as JsonValue),
@ -45,14 +37,13 @@ export function createPresentationFacetData(artifacts: readonly FacetBundleArtif
}
/** 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 [];
export function createPresentationFacetLoaders(data: JsonValue): readonly FacetLoader[] {
if (data === null || Array.isArray(data) || typeof data !== "object") {
throw new Error("Invalid experimental server hello data");
throw new Error("Invalid presentation plugin 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");
if (!Array.isArray(artifacts)) throw new Error("Invalid presentation plugin bundle list");
return artifacts.map((artifact) =>
createFacetBundleArtifactLoader({ artifact, resolveExternal: resolvePluginExternal }),
);

View file

@ -10,11 +10,9 @@ import {
import type { ServerId } from "@earendil-works/pi-protocol";
const PLUGIN_PACKAGE_PROFILE_VERSION = 1;
const SESSION_PLUGIN_PACKAGE_PROFILE_VERSION = 1;
const DEFAULT_PLUGIN_FACETS = Object.freeze({ session: "src/session.ts", tui: "src/tui.ts" });
export interface ConfiguredServerPluginPackage {
readonly packagePath: string;
readonly manifestPath: string;
build(): Promise<readonly FacetBundleArtifact[]>;
}
@ -26,73 +24,22 @@ export async function restoreServerPluginPackageProfile(
configuredPackagePaths?: readonly string[],
): Promise<readonly string[]> {
const path = join(directory, `plugin-packages-${serverId}.json`);
if (configuredPackagePaths !== undefined) {
const packagePaths = normalizePluginPackagePaths(configuredPackagePaths);
if (packagePaths.length === 0) {
await rm(path, { force: true });
return packagePaths;
}
await writeFile(path, `${JSON.stringify({ version: PLUGIN_PACKAGE_PROFILE_VERSION, packagePaths }, null, 2)}\n`, {
encoding: "utf8",
mode: 0o600,
});
return packagePaths;
if (configuredPackagePaths === undefined) {
return (await readPluginPackageProfile(path, false)) ?? [];
}
let parsed: unknown;
try {
parsed = JSON.parse(await readFile(path, "utf8"));
} catch (error) {
if (error instanceof Error && "code" in error && error.code === "ENOENT") return [];
throw new Error(`Could not read experimental plugin package profile ${path}`, { cause: error });
}
if (
typeof parsed !== "object" ||
parsed === null ||
Array.isArray(parsed) ||
Object.keys(parsed).some((key) => key !== "version" && key !== "packagePaths") ||
!("version" in parsed) ||
parsed.version !== PLUGIN_PACKAGE_PROFILE_VERSION ||
!("packagePaths" in parsed) ||
!Array.isArray(parsed.packagePaths) ||
parsed.packagePaths.length === 0 ||
parsed.packagePaths.some((packagePath) => typeof packagePath !== "string" || packagePath.length === 0)
) {
throw new Error(`Invalid experimental plugin package profile ${path}`);
}
return normalizePluginPackagePaths(parsed.packagePaths as readonly string[]);
const packagePaths = normalizePluginPackagePaths(configuredPackagePaths);
if (packagePaths.length === 0) await rm(path, { force: true });
else await writePluginPackageProfile(path, packagePaths);
return packagePaths;
}
/** Read the explicit plugin selection stored for one durable Session. */
export async function readSessionPluginPackageProfile(
export function readSessionPluginPackageProfile(
directory: string,
serverId: ServerId,
sessionPath: string,
): Promise<readonly string[] | undefined> {
const path = sessionPluginProfilePath(directory, serverId, sessionPath);
let parsed: unknown;
try {
parsed = JSON.parse(await readFile(path, "utf8"));
} catch (error) {
if (error instanceof Error && "code" in error && error.code === "ENOENT") return undefined;
throw new Error(`Could not read experimental Session plugin profile ${path}`, { cause: error });
}
if (
typeof parsed !== "object" ||
parsed === null ||
Array.isArray(parsed) ||
Object.keys(parsed).some((key) => key !== "version" && key !== "sessionPath" && key !== "packagePaths") ||
!("version" in parsed) ||
parsed.version !== SESSION_PLUGIN_PACKAGE_PROFILE_VERSION ||
!("sessionPath" in parsed) ||
parsed.sessionPath !== sessionPath ||
!("packagePaths" in parsed) ||
!Array.isArray(parsed.packagePaths) ||
parsed.packagePaths.some((packagePath) => typeof packagePath !== "string" || packagePath.length === 0)
) {
throw new Error(`Invalid experimental Session plugin profile ${path}`);
}
return normalizePluginPackagePaths(parsed.packagePaths as readonly string[]);
return readPluginPackageProfile(sessionPluginProfilePath(directory, serverId, sessionPath), true, sessionPath);
}
/** Remove the plugin selection stored for one deleted Session. */
@ -105,22 +52,16 @@ export async function removeSessionPluginPackageProfile(
}
/** Persist the plugin selection for one durable Session. */
export async function writeSessionPluginPackageProfile(
export function writeSessionPluginPackageProfile(
directory: string,
serverId: ServerId,
sessionPath: string,
packagePaths: readonly string[],
): Promise<void> {
const normalized = normalizePluginPackagePaths(packagePaths);
const path = sessionPluginProfilePath(directory, serverId, sessionPath);
await writeFile(
path,
`${JSON.stringify(
{ version: SESSION_PLUGIN_PACKAGE_PROFILE_VERSION, sessionPath, packagePaths: normalized },
null,
2,
)}\n`,
{ encoding: "utf8", mode: 0o600 },
return writePluginPackageProfile(
sessionPluginProfilePath(directory, serverId, sessionPath),
normalizePluginPackagePaths(packagePaths),
sessionPath,
);
}
@ -135,7 +76,6 @@ export function createServerPluginPackage(
const manifestPath = join(outdir, FACET_BUNDLE_MANIFEST_FILE);
let buildTail = Promise.resolve();
return {
packagePath: normalizedPackagePath,
manifestPath,
build() {
const operation = buildTail.then(async () => {
@ -156,6 +96,56 @@ export function createServerPluginPackage(
};
}
async function readPluginPackageProfile(
path: string,
allowEmpty: boolean,
sessionPath?: string,
): Promise<readonly string[] | undefined> {
let parsed: unknown;
try {
parsed = JSON.parse(await readFile(path, "utf8"));
} catch (error) {
if (error instanceof Error && "code" in error && error.code === "ENOENT") return undefined;
throw new Error(`Could not read experimental plugin package profile ${path}`, { cause: error });
}
if (
typeof parsed !== "object" ||
parsed === null ||
Array.isArray(parsed) ||
Object.keys(parsed).some(
(key) => key !== "version" && key !== "packagePaths" && !(key === "sessionPath" && sessionPath !== undefined),
) ||
!("version" in parsed) ||
parsed.version !== PLUGIN_PACKAGE_PROFILE_VERSION ||
(sessionPath === undefined
? "sessionPath" in parsed
: !("sessionPath" in parsed) || parsed.sessionPath !== sessionPath) ||
!("packagePaths" in parsed) ||
!Array.isArray(parsed.packagePaths) ||
(!allowEmpty && parsed.packagePaths.length === 0) ||
parsed.packagePaths.some((packagePath) => typeof packagePath !== "string" || packagePath.length === 0)
) {
throw new Error(`Invalid experimental plugin package profile ${path}`);
}
return normalizePluginPackagePaths(parsed.packagePaths as readonly string[]);
}
function writePluginPackageProfile(path: string, packagePaths: readonly string[], sessionPath?: string): Promise<void> {
return writeFile(
path,
`${JSON.stringify(
{
version: PLUGIN_PACKAGE_PROFILE_VERSION,
...(sessionPath === undefined ? {} : { sessionPath }),
packagePaths,
},
null,
2,
)}\n`,
{ encoding: "utf8", mode: 0o600 },
);
}
export function normalizePluginPackagePaths(packagePaths: readonly string[]): readonly string[] {
const normalized = packagePaths.map((packagePath) => {
if (packagePath.length === 0) throw new Error("Plugin package path must not be empty");

View file

@ -13,7 +13,7 @@ import {
import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node";
import { Client, ServerError as ClientServerError, DisconnectedError } from "@earendil-works/pi-client";
import { createUnixTransportFactory, type UnixServerRoute } from "@earendil-works/pi-client/unix";
import { isServerId, type JsonValue, type ServerId, type SessionSummary } from "@earendil-works/pi-protocol";
import { isServerId, type ServerId, type SessionSummary } from "@earendil-works/pi-protocol";
import {
ServerError as RoutedServerError,
type Server,
@ -27,7 +27,7 @@ import type { AuthInput } from "../cli/experimental/command-options.ts";
import { getAgentDir } from "../config.ts";
import { resolvePath } from "../utils/paths.ts";
import { CoordinatorConnection, type CoordinatorStartupLease, ensureCoordinator } from "./coordinator.ts";
import { createPresentationFacetData, createPresentationFacetHelloData } from "./plugins/bundled.ts";
import { createPresentationFacetData } from "./plugins/bundled.ts";
import {
createServerPluginPackage,
normalizePluginPackagePaths,
@ -161,17 +161,16 @@ export async function activateServer(options: ActivateServerOptions): Promise<Ac
}
return { client: existing, route };
}
const startupOptions =
const modelArgs =
options.model === undefined
? {}
: {
model: {
? []
: [
JSON.stringify({
...(options.provider === undefined ? {} : { provider: options.provider }),
model: options.model,
},
};
const startupArgs = Object.keys(startupOptions).length === 0 ? [] : [JSON.stringify(startupOptions)];
const child = spawnInternalProcess("server", [options.directory, serverId, options.sessionDir, ...startupArgs]);
}),
];
const child = spawnInternalProcess("server", [options.directory, serverId, options.sessionDir, ...modelArgs]);
let spawnError: Error | undefined;
child.once("error", (error) => {
spawnError = error;
@ -347,8 +346,6 @@ export interface StartServerOptions {
readonly relayAuth?: AuthInput;
/** Explicit plugin packages. Undefined restores the logical server profile; an empty list clears it. */
readonly pluginPackages?: readonly string[];
/** Presentation facets selected by this server. Defaults to configured plugin packages' `tui` entries. */
readonly presentationFacetBundles?: readonly FacetBundleArtifact[];
readonly onRelayStatus?: (status: RadiusRelayHostStatus) => void;
}
@ -362,16 +359,13 @@ interface StartServerBackendOptions {
readonly path: string;
readonly serverId: ServerId;
readonly sessionDir?: string;
readonly helloData?: JsonValue;
resolveSessionPlugins(
metadata: JsonlSessionMetadata,
packagePaths: readonly string[] | undefined,
context: Context,
): Promise<ResolvedSessionPlugins>;
removeSessionPlugins(metadata: JsonlSessionMetadata): Promise<void>;
reloadPresentationFacetBundles(
packagePaths: readonly string[],
): Promise<{ readonly artifacts: readonly FacetBundleArtifact[]; readonly updateHello: boolean }>;
reloadPresentationFacetBundles(packagePaths: readonly string[]): Promise<readonly FacetBundleArtifact[]>;
}
interface RunningServerBackend extends RunningServer {
@ -411,7 +405,6 @@ 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))
@ -446,10 +439,7 @@ async function startServerBackend(
};
},
async reloadPresentationPlugins(packagePaths) {
const reloaded = await options.reloadPresentationFacetBundles(packagePaths);
const data = createPresentationFacetData(reloaded.artifacts);
if (reloaded.updateHello) setHelloData(data);
return data;
return createPresentationFacetData(await options.reloadPresentationFacetBundles(packagePaths));
},
}).catch(async (error: unknown) => {
try {
@ -479,9 +469,7 @@ 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) {
@ -544,16 +532,23 @@ export async function startServer(options: StartServerOptions = {}): Promise<Run
try {
await ensurePrivateServerDirectory(directory);
const pluginPackagePaths = await restoreServerPluginPackageProfile(directory, serverId, options.pluginPackages);
const pluginPackages = new Map<string, ReturnType<typeof createServerPluginPackage>>();
const getPluginPackage = (packagePath: string): ReturnType<typeof createServerPluginPackage> => {
let plugin = pluginPackages.get(packagePath);
if (plugin === undefined) {
plugin = createServerPluginPackage(directory, serverId, packagePath);
pluginPackages.set(packagePath, plugin);
}
return plugin;
};
const buildPluginSelection = async (packagePaths: readonly string[]): Promise<ResolvedSessionPlugins> => {
const normalizedPackagePaths = normalizePluginPackagePaths(packagePaths);
const plugins = normalizedPackagePaths.map((packagePath) =>
createServerPluginPackage(directory, serverId, packagePath),
);
const plugins = normalizedPackagePaths.map(getPluginPackage);
const built = (await Promise.all(plugins.map((plugin) => plugin.build()))).flat();
return {
packagePaths: normalizedPackagePaths,
manifestPaths: plugins.map((plugin) => plugin.manifestPath),
presentationArtifacts: options.presentationFacetBundles ?? built,
presentationArtifacts: built,
};
};
let defaultPluginSelection = await buildPluginSelection(pluginPackagePaths);
@ -568,7 +563,7 @@ export async function startServer(options: StartServerOptions = {}): Promise<Run
const current = sessionPluginSelections.get(metadata.path);
if (current !== undefined && sameStrings(current.packagePaths, normalizedPackagePaths)) return current;
const requestedManifestPaths = normalizedPackagePaths.map(
(packagePath) => createServerPluginPackage(directory, serverId, packagePath).manifestPath,
(packagePath) => getPluginPackage(packagePath).manifestPath,
);
workers?.assertSessionPluginManifestPaths(metadata, requestedManifestPaths);
const candidate = await buildPluginSelection(normalizedPackagePaths);
@ -594,18 +589,18 @@ export async function startServer(options: StartServerOptions = {}): Promise<Run
};
const reloadPresentationFacetBundles = async (
packagePaths: readonly string[],
): Promise<{ readonly artifacts: readonly FacetBundleArtifact[]; readonly updateHello: boolean }> => {
): Promise<readonly FacetBundleArtifact[]> => {
const reloaded = await buildPluginSelection(packagePaths);
const updateHello = sameStrings(defaultPluginSelection.packagePaths, reloaded.packagePaths);
if (updateHello) defaultPluginSelection = reloaded;
if (sameStrings(defaultPluginSelection.packagePaths, reloaded.packagePaths)) {
defaultPluginSelection = reloaded;
}
for (const [sessionPath, selected] of sessionPluginSelections) {
if (sameStrings(selected.packagePaths, reloaded.packagePaths)) {
sessionPluginSelections.set(sessionPath, reloaded);
}
}
return { artifacts: reloaded.presentationArtifacts, updateHello };
return reloaded.presentationArtifacts;
};
const helloData = createPresentationFacetHelloData(defaultPluginSelection.presentationArtifacts);
const socketPath = getUnixSocketPath(serverId, directory);
const controlPath = join(directory, `control-${serverId}.sock`);
const serverNonce = randomUUID().replaceAll("-", "").slice(0, 12);
@ -613,12 +608,8 @@ export async function startServer(options: StartServerOptions = {}): Promise<Run
startupLease = await ensureCoordinator(socketPath, controlPath);
coordinator = new CoordinatorConnection({ controlPath, endpoint: serverPath });
const sessionDir = resolveSessionDirectory(options.sessionDir);
workers = new SessionWorkerManager(
coordinator,
sessionDir,
workerModel,
(count) => lifetime.setWorkerCount(count),
defaultPluginSelection.manifestPaths,
workers = new SessionWorkerManager(coordinator, sessionDir, workerModel, (count) =>
lifetime.setWorkerCount(count),
);
backend = await startServerBackend(
{
@ -628,7 +619,6 @@ export async function startServer(options: StartServerOptions = {}): Promise<Run
resolveSessionPlugins,
removeSessionPlugins,
reloadPresentationFacetBundles,
...(helloData === undefined ? {} : { helloData }),
},
workers,
(count) => lifetime.setConnectionCount(count),
@ -732,71 +722,47 @@ export async function startForegroundServer(
}
}
function parseServerStartupOptions(value: string | undefined): {
readonly provider?: string;
readonly model?: string;
} {
if (value === undefined) return {};
function parseServerModelOptions(value: string | undefined): { provider?: string; model: string } | undefined {
if (value === undefined) return undefined;
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch (error) {
throw new Error("Internal server received invalid startup options", { cause: error });
throw new Error("Internal server received invalid model options", { cause: error });
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
throw new Error("Internal server received invalid model options");
}
const keys = Object.keys(parsed);
const model = "model" in parsed ? parsed.model : undefined;
const provider = "provider" in parsed ? parsed.provider : undefined;
if (
typeof parsed !== "object" ||
parsed === null ||
Array.isArray(parsed) ||
Object.keys(parsed).some((key) => key !== "model")
keys.some((key) => key !== "provider" && key !== "model") ||
typeof model !== "string" ||
model.length === 0 ||
(provider !== undefined && (typeof provider !== "string" || provider.length === 0))
) {
throw new Error("Internal server received invalid startup options");
throw new Error("Internal server received invalid model options");
}
const modelOptions = "model" in parsed ? parsed.model : undefined;
let model: string | undefined;
let provider: string | undefined;
if (modelOptions !== undefined) {
if (
typeof modelOptions !== "object" ||
modelOptions === null ||
Array.isArray(modelOptions) ||
Object.keys(modelOptions).some((key) => key !== "provider" && key !== "model")
) {
throw new Error("Internal server received invalid startup options");
}
const modelCandidate = "model" in modelOptions ? modelOptions.model : undefined;
const providerCandidate = "provider" in modelOptions ? modelOptions.provider : undefined;
if (
typeof modelCandidate !== "string" ||
modelCandidate.length === 0 ||
(providerCandidate !== undefined && (typeof providerCandidate !== "string" || providerCandidate.length === 0))
) {
throw new Error("Internal server received invalid startup options");
}
model = modelCandidate;
provider = providerCandidate;
}
return {
...(provider === undefined ? {} : { provider }),
...(model === undefined ? {} : { model }),
};
return provider === undefined ? { model } : { provider, model };
}
/** Run an automatically activated server until its client and Session demand disappears. */
export async function runServerProcess(args: readonly string[]): Promise<void> {
const [directory, serverId, sessionDir, serializedStartupOptions] = args;
const [directory, serverId, sessionDir, serializedModel] = args;
if (args.length > 4) throw new Error("Internal server received unexpected arguments");
if (!directory || !isAbsolute(directory)) throw new Error("Internal server requires an absolute server directory");
if (!isServerId(serverId)) throw new Error("Internal server requires a canonical server ID");
if (!sessionDir || !isAbsolute(sessionDir))
throw new Error("Internal server requires an absolute Session directory");
const startupOptions = parseServerStartupOptions(serializedStartupOptions);
const workerModel = parseServerModelOptions(serializedModel);
const runtime = await startServer({
directory,
serverId,
sessionDir,
keepAlive: false,
...startupOptions,
...workerModel,
});
const close = (): void => {
void runtime.close().catch(() => {});

View file

@ -128,7 +128,6 @@ export class SessionWorkerManager {
readonly #serviceSubscriptions = new Map<string, WorkerServiceSubscription>();
readonly #removeListener: () => void;
readonly #onWorkerCountChanged: ((count: number) => void) | undefined;
readonly #pluginManifestPaths: readonly string[];
#discoveryPeers?: Set<string>;
#resolveDiscovery?: () => void;
#detached = false;
@ -142,13 +141,11 @@ export class SessionWorkerManager {
sessionDir: string,
model?: { readonly provider?: string; readonly model: string },
onWorkerCountChanged?: (count: number) => void,
pluginManifestPaths: readonly string[] = [],
) {
this.#coordinator = coordinator;
this.#sessionDir = sessionDir;
this.#model = model;
this.#onWorkerCountChanged = onWorkerCountChanged;
this.#pluginManifestPaths = Object.freeze([...pluginManifestPaths]);
this.#removeListener = coordinator.onEvent((event) => this.#handleCoordinatorEvent(event));
}
@ -198,7 +195,7 @@ export class SessionWorkerManager {
async openSession(
metadata: JsonlSessionMetadata,
context: Context,
pluginManifestPaths: readonly string[] = this.#pluginManifestPaths,
pluginManifestPaths: readonly string[],
): Promise<RoutedSessionHandle> {
if (this.#detached || this.#shuttingDown) throw new Error("Experimental server is shutting down");
this.assertSessionPluginManifestPaths(metadata, pluginManifestPaths);
@ -540,8 +537,8 @@ export class SessionWorkerManager {
const options: SessionWorkerOptions = {
sessionDir: this.#sessionDir,
metadata,
pluginManifestPaths: [...pluginManifestPaths],
...(this.#model ?? {}),
...(pluginManifestPaths.length === 0 ? {} : { pluginManifestPaths: [...pluginManifestPaths] }),
};
child = spawnInternalProcess("session-worker", [JSON.stringify(options)], {
env: {

View file

@ -47,7 +47,7 @@ import {
toWireLaneSnapshot,
toWireRunResult,
} from "./harness-wire-adapter.ts";
import { createConfiguredPluginFacetLoader } from "./plugins/bundled.ts";
import { createSessionPluginFacetLoader } from "./plugins/bundled.ts";
import {
consumeInternalProcessRole,
encodeControlLine,
@ -87,7 +87,7 @@ export const SessionWorkerOptionsSchema = StrictObject({
metadata: SessionWorkerMetadataSchema,
provider: Type.Optional(Type.String({ minLength: 1 })),
model: Type.Optional(Type.String({ minLength: 1 })),
pluginManifestPaths: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
pluginManifestPaths: Type.Array(Type.String({ minLength: 1 })),
});
export type SessionWorkerOptions = Static<typeof SessionWorkerOptionsSchema>;
@ -570,7 +570,7 @@ async function run(options: SessionWorkerOptions, createHarness: CreateSessionWo
const token = process.env[SESSION_WORKER_CONTROL_TOKEN_ENV]!;
const sessionKey = Buffer.from(process.env[SESSION_WORKER_SESSION_KEY_ENV]!, "base64url").toString();
failureControl = control;
const pluginManifestPaths = options.pluginManifestPaths ?? [];
const pluginManifestPaths = options.pluginManifestPaths;
const releaseOwnership = await lockfile.lock(metadata.path, {
realpath: true,
stale: 2_000,
@ -958,7 +958,7 @@ async function createCodingAgentHarness(
lane,
modelRuntime,
settingsManager,
facetLoader: createConfiguredPluginFacetLoader("session", options.pluginManifestPaths ?? []),
facetLoader: createSessionPluginFacetLoader(options.pluginManifestPaths),
};
} catch (error) {
try {

View file

@ -2,8 +2,6 @@ import { createHash } from "node:crypto";
import { resolve } from "node:path";
import {
createRemoteServiceBinding,
defineFacet,
type FacetLoader,
RemoteServiceProvider,
type RemoteServiceTransport,
replicatedState,
@ -19,7 +17,7 @@ import type { LaneEvent, LaneSnapshot, SessionSummary } from "@earendil-works/pi
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 { createPresentationFacetData } 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 {
@ -204,8 +202,7 @@ describe("experimental client TUI", () => {
},
source: reloadSource,
};
const reloadData = createPresentationFacetHelloData([reloadArtifact]);
if (reloadData === undefined) throw new Error("Expected presentation reload data");
const reloadData = createPresentationFacetData([reloadArtifact]);
const prepareSessionPlugins = vi.fn(async () => reloadData);
const reloadPresentationPlugins = vi.fn(async () => reloadData);
const reloadSessionPlugins = vi.fn(async () => {});
@ -296,27 +293,9 @@ describe("experimental client TUI", () => {
attachment.set({ status: "detached" }, BACKGROUND_CONTEXT);
},
});
const disposeLoadedFacets = vi.fn(async () => {});
const facetLoader: FacetLoader = {
async load() {
return {
facets: [
defineFacet({
id: "test-tui-facet",
setup(env) {
const models = env.use(Models);
env.onActivate(() => env.own(models.state.subscribe(() => {})));
},
}),
],
dispose: disposeLoadedFacets,
};
},
};
const server: ClientTuiServer = {
serverId,
radius: true,
presentationFacetLoaders: [facetLoader],
laneWatches: { watchSession },
server: serverServices,
session: sessionServices,
@ -423,7 +402,6 @@ describe("experimental client TUI", () => {
await vi.waitFor(() => expect(finished).toBe(true));
await component.close();
expect(disposeLoadedFacets).not.toHaveBeenCalled();
const rendersAfterClose = requestRender.mock.calls.length;
directoryState.set({ revision: 3, sessions: [] }, BACKGROUND_CONTEXT);
attachment.set({ status: "detached" }, BACKGROUND_CONTEXT);

View file

@ -1,44 +1,30 @@
import { createHash, randomUUID } from "node:crypto";
import { randomUUID } from "node:crypto";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import {
FACET_BUNDLE_ARTIFACT_FORMAT,
FACET_BUNDLE_ARTIFACT_FORMAT_VERSION,
type FacetBundleArtifact,
readFacetBundleManifest,
} from "@earendil-works/chord/node";
import { TestServerHost } from "@earendil-works/pi-server/testing";
import { createUnixServer } from "@earendil-works/pi-server/unix";
import { readFacetBundleManifest } from "@earendil-works/chord/node";
import { afterEach, describe, expect, test } from "vitest";
import {
activateBuiltinClientServices,
type ClientRuntime,
openClientRuntime,
} from "../src/experimental/client-runtime.ts";
import {
createPresentationFacetData,
createPresentationFacetHelloData,
createPresentationFacetLoaders,
} from "../src/experimental/plugins/bundled.ts";
import { createPresentationFacetData, createPresentationFacetLoaders } from "../src/experimental/plugins/bundled.ts";
import { createServerPluginPackage, restoreServerPluginPackageProfile } from "../src/experimental/plugins/package.ts";
import { type RunningServer, startServer } from "../src/experimental/server.ts";
import { PresentationPlugins } from "../src/experimental/services/plugins.ts";
const runtimes = new Set<ClientRuntime>();
const runningServers = new Set<RunningServer>();
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([...runningServers].map((server) => server.close()));
await Promise.allSettled([...servers].map((server) => server.close()));
await Promise.allSettled([...directories].map((directory) => rm(directory, { force: true, recursive: true })));
runtimes.clear();
runningServers.clear();
servers.clear();
directories.clear();
});
@ -184,46 +170,4 @@ describe("server-selected presentation facets", () => {
expect(loaded.facets.map(({ id }) => id)).toEqual(["@earendil-works/pi-example-plugin/tui"]);
await loaded.dispose();
});
test("receives and loads a TUI facet without client-side plugin configuration", async () => {
const source =
'import { defineFacet } from "@earendil-works/chord";\nexport default defineFacet({ id: "server-selected-tui", setup() {} });\n';
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();
}
});
});

View file

@ -67,7 +67,7 @@ async function createAttachedWorker(): Promise<{
const coordinator = new FakeCoordinator();
const workers = new SessionWorkerManager(coordinator, "/tmp");
await workers.discover(new Set(["worker-1"]));
const handle = await workers.openSession(metadata, BACKGROUND_CONTEXT);
const handle = await workers.openSession(metadata, BACKGROUND_CONTEXT, []);
coordinator.onSend = (peerId, payload) => {
if (payload.type !== "session_demand") return;
queueMicrotask(() =>
@ -98,9 +98,7 @@ async function createAttachedWorker(): Promise<{
describe("Session worker lifecycle failures", () => {
test("adopts a discovered worker with its existing Session plugin selection", async () => {
const coordinator = new FakeCoordinator();
const workers = new SessionWorkerManager(coordinator, "/tmp", undefined, undefined, [
"/tmp/plugin/chord-facets.json",
]);
const workers = new SessionWorkerManager(coordinator, "/tmp");
await workers.discover(new Set(["worker-1"]));
expect(coordinator.sent).not.toContainEqual({ peerId: "worker-1", payload: { type: "shutdown" } });
expect(workers.workerPids.size).toBe(1);
@ -122,7 +120,7 @@ describe("Session worker lifecycle failures", () => {
const coordinator = new FakeCoordinator();
const workers = new SessionWorkerManager(coordinator, "/tmp");
await workers.discover(new Set(["worker-1"]));
const handle = await workers.openSession(metadata, BACKGROUND_CONTEXT);
const handle = await workers.openSession(metadata, BACKGROUND_CONTEXT, []);
const demands: { attachmentId: string; attached: boolean }[] = [];
coordinator.onSend = (peerId, payload) => {
if (
@ -165,7 +163,7 @@ describe("Session worker lifecycle failures", () => {
const coordinator = new FakeCoordinator();
const workers = new SessionWorkerManager(coordinator, "/tmp");
await workers.discover(new Set(["worker-1"]));
const handle = await workers.openSession(metadata, BACKGROUND_CONTEXT);
const handle = await workers.openSession(metadata, BACKGROUND_CONTEXT, []);
coordinator.onSend = () => {};
const attaching = expect(handle.attachClient!(BACKGROUND_CONTEXT)).rejects.toThrow("worker was terminated");

View file

@ -2,9 +2,9 @@
Runtime-neutral schemas, types, CBOR encoding, and byte-stream framing for the experimental Pi protocol.
Protocol version `5` carries the current server- and Session-scoped facet-service slice, including Session-branch plugin preparation:
Protocol version `6` carries the current server- and Session-scoped facet-service slice, including Session-branch plugin preparation:
- a version handshake that identifies the logical `serverId` and can carry application bootstrap data;
- a version handshake that identifies the logical `serverId`;
- 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;

View file

@ -13,7 +13,7 @@ import {
type RpcResultUnion,
} from "./rpc.ts";
export const PROTOCOL_VERSION = 5 as const;
export const PROTOCOL_VERSION = 6 as const;
const IdSchema = Type.String({ minLength: 1 });
const TimestampSchema = Type.Integer({ minimum: 0 });
@ -352,8 +352,6 @@ 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"),

View file

@ -207,11 +207,11 @@ describe("RPC manifest", () => {
});
describe("protocol validation", () => {
test("negotiates protocol version 5", () => {
expect(PROTOCOL_VERSION).toBe(5);
expect(isSupportedProtocolVersion(5)).toBe(true);
expect(isSupportedProtocolVersion(4)).toBe(false);
expect(isSupportedProtocolVersion(5.5)).toBe(false);
test("negotiates protocol version 6", () => {
expect(PROTOCOL_VERSION).toBe(6);
expect(isSupportedProtocolVersion(6)).toBe(true);
expect(isSupportedProtocolVersion(5)).toBe(false);
expect(isSupportedProtocolVersion(6.5)).toBe(false);
});
test.each([0, PROTOCOL_VERSION, PROTOCOL_VERSION + 1])(
@ -617,11 +617,6 @@ 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 }],

View file

@ -10,7 +10,6 @@ import {
encodeServerMessage,
isServerId,
isSupportedProtocolVersion,
type JsonValue,
PROTOCOL_VERSION,
type ProtocolError,
type ProtocolRpcResult,
@ -44,7 +43,6 @@ 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;
@ -64,7 +62,6 @@ 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;
@ -88,11 +85,6 @@ 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"));
@ -291,7 +283,6 @@ 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";

View file

@ -23,7 +23,6 @@ export function createUnixServer<TMetadata extends SessionMetadata>(
handshakeTimeoutMs: options.handshakeTimeoutMs,
onConnectionCountChanged: options.onConnectionCountChanged,
serverId: options.serverId,
helloData: options.helloData,
onError: options.onError,
});
}

View file

@ -1,6 +1,5 @@
import type { Context, SessionMetadata } from "@earendil-works/pi-agent-core";
import type {
JsonValue,
LaneEvent,
LaneSnapshot,
PromptArguments,
@ -16,8 +15,6 @@ 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;

View file

@ -3,19 +3,14 @@ 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, ServerOptions } from "../src/types.ts";
import type { ServerHost } from "../src/types.ts";
const servers = new Set<Server>();
function createServer(
host: ServerHost,
serverId = "00000000-0000-4000-8000-000000000001",
helloData?: ServerOptions["helloData"],
): Server {
function createServer(host: ServerHost, serverId = "00000000-0000-4000-8000-000000000001"): Server {
const server = new Server(host, {
listeners: [],
serverId,
...(helloData === undefined ? {} : { helloData }),
});
servers.add(server);
return server;
@ -73,21 +68,6 @@ 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");