chore(vendor): import pi 0.85.1

Imported as a linear vendor commit so forge rebases do not replay the upstream root tree.

git-subtree-dir: third_party/pi

git-subtree-split: d981de1229ef899957bbe968bc8dcda02a21f477
This commit is contained in:
2026-09-14 10:48:47 -04:00
parent 5ebc7018c3
commit 4def357d09
1669 changed files with 397627 additions and 0 deletions

View File

@@ -0,0 +1,25 @@
# Experimental client/server service slices
Facet setup generates each host's RPC service catalogue from its provided non-local tokens. Remote service sources obtain those catalogues and bind only services required by consuming facets; there is no handwritten built-in service inventory. With no selected Session, its deferred source admits unresolved requirements as unavailable and keeps their handles disconnected. Attachment validates them against the worker's generated catalogue, which is cached for later detached generations. Keyed services hydrate as an empty directory until their owning feature spawns an instance.
| Scope | Service | Current slice | Continuation point |
|---|---|---|---|
| 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` | prepares the selected Session branch's matching TUI artifacts and reloads that branch | add authenticated plugin policy |
| session | `SessionPlugins` | reloads the configured Session facet generation | add coordinated multi-worker reload reporting |
| session | `Models` | state, default-persisting selection, thinking, refresh implemented | move provider/auth composition behind plugin facets |
| session | `AgentController` | presentation-safe `AgentLane` facade for prompting, queueing, abort, resume, compaction, and navigation | add new lane operations only when a presentation requires them |
| session | `Transcript` | replicated lane state with source-event metadata | add projections only when another presentation needs 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. Session directory, creation, and address DTOs are owned by these coding-agent service contracts rather than `pi-protocol`; the transport treats their payloads as opaque service data.
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 the `Transcript` service's complete replicated value while controller calls are pending. The provider remains the sole Harness reducer and mutates Transcript's tracked snapshot before publishing each event. Chord flushes compact operations once per publication, while each client/state pairing encodes them with an independent path dictionary. Chord reconstructs presentation replicas and owns hydration, sequencing, and gap detection.
A foreground server uses repeatable `-e` options to establish its default Session and TUI facets. A local client may instead select packages for the Session it creates or resumes; that branch selection is persisted with the Session and does not alter other workers or the server default. Before attaching, the server asks Chord to build conventional `src/session.ts` and `src/tui.ts` entries into separate `plugin-builds/` directories, passes the generated manifest paths to that Session worker, and returns the matching TUI artifacts. The Session worker loads built-in facets and separately owned plugin generations, then creates one active `FacetHost`. `/reload` atomically rebuilds the packages, loads fresh plugin candidates, cuts them over through `FacetHost.reload()`, and disposes the retired generations. Host-created implementation dependencies such as `AgentLane`, `ModelRuntime`, and `SettingsManager` are passed directly to built-in facet factories; they are not exposed as services. The TUI loads configured presentation facets and adds one private bridge facet that consumes the connected server and selected-Session services for `ExperimentalClientTui`. Its local presentation services are `SlashCommands`, which owns command contributions, and `PresentationUI`, which exposes selection and status rendering without exposing the raw TUI. Command callbacks receive Chord `Context` directly. Facets consume `AgentController` explicitly for prompting, steering, or queueing and return structured controller results to the command dispatcher.
A host may reload selected facets when their declared service shape is unchanged; retained consumers keep the same service facades while replacement implementations and singleton snapshots are installed behind them. Synchronous setup-time `env.provide()`, `env.provideMany()`, `env.use()`, and `env.observe()` calls produce the internal dependency graph; setup does not repeat a declarative dependency list, and service handles remain disconnected until the complete graph validates. Providers activate before consumers, observations connect with their consuming facet, and facet replacement or host shutdown disposes affected lifecycles in reverse dependency order. Server and Session service tokens are non-local and automatically published by their providing host. Presentation-only hookpoints such as `SlashCommands` are explicitly local and never enter an RPC catalogue.
A facet always calls unqualified `env.use()` or `env.observe()`. The host resolves each token across facet-provided and connected services. The example plugin package contributes `/hello` through its TUI facet. `examples/plugins/pi-example-plugin/` is an actual `@earendil-works/pi-example-plugin` package: repeatable `-e` options select plugin packages as server defaults or for one Session branch, Chord discovers and builds their conventional facets, and the attached presentation receives matching TUI artifacts through `PresentationPlugins`. Other host facets from the same plugin remain separate bundle entries rather than one aggregate plugin object. Transport bindings remain internal machinery rather than part of the facet environment. `ExperimentalClientTui` currently owns terminal rendering, state subscriptions, navigation, and action dispatch directly. The server provider remains directly assembled until its complete host environment exists. The question dialog, diff review, Git, indexing-job, canvas, and rich local TUI service examples in `packages/agent/docs/plugins.md` are extension patterns, not built-in coding-agent services. Private references, trace carriers, and flow control remain protocol/host infrastructure slices rather than presentation service tokens.

View File

@@ -0,0 +1,109 @@
import type { AgentLane, OperationResultRecord, SuspendedRun } from "@earendil-works/pi-agent-core";
import type { ImageContent } from "@earendil-works/pi-ai";
import type {
AgentController as AgentControllerService,
AgentOperationError,
AgentOperationResponse,
AgentPromptRequest,
AgentQueueResponse,
} from "./agent-controller.ts";
export function createAgentController(lane: AgentLane): AgentControllerService {
const queue = async (
operation: "steer" | "followUp" | "nextRun",
request: AgentPromptRequest,
context: Parameters<AgentControllerService["steer"]>[1],
): Promise<AgentQueueResponse> => {
const [message, images] = toTextPrompt(request);
const result = await lane[operation](message, images, context);
return result.ok
? { accepted: true, entryId: result.value.entryId, error: null }
: { accepted: false, entryId: null, error: toAgentError(result.error) };
};
return {
async prompt(request, context) {
const [message, images] = toTextPrompt(request);
const result = await lane.prompt(message, images, context);
return result.ok
? toOperationResponse(result.value)
: { accepted: false, operationId: operationId(result.error), error: toAgentError(result.error) };
},
async requestAbort(operationId, context) {
const result = await lane.requestAbort(operationId, context);
if (!result.ok) throw new Error(result.error.message);
},
steer: (request, context) => queue("steer", request, context),
followUp: (request, context) => queue("followUp", request, context),
nextRun: (request, context) => queue("nextRun", request, context),
async cancelQueued(entryId, context) {
const result = await lane.cancelQueued(entryId, context);
if (!result.ok) throw new Error(result.error.message);
return { outcome: result.value.kind };
},
async resume(context) {
const result = await lane.resume(context);
return result.ok
? toOperationResponse(result.value)
: { accepted: false, operationId: null, error: toAgentError(result.error) };
},
async compact(request, context) {
const result = await lane.compact(
request.customInstructions === null ? undefined : { customInstructions: request.customInstructions },
context,
);
return result.ok
? toOperationResponse(result.value.compaction)
: { accepted: false, operationId: operationId(result.error), error: toAgentError(result.error) };
},
async navigate(request, context) {
const result = await lane.navigateTree(
request.targetId,
{
summarize: request.summarize,
...(request.label === null ? {} : { label: request.label }),
...(request.customInstructions === null ? {} : { customInstructions: request.customInstructions }),
},
context,
);
return result.ok
? toOperationResponse(result.value.navigation)
: { accepted: false, operationId: operationId(result.error), error: toAgentError(result.error) };
},
};
}
function toOperationResponse(value: OperationResultRecord | SuspendedRun): AgentOperationResponse {
return {
accepted: true,
operationId: value.operationId,
error:
"status" in value && value.status === "failed" && value.error !== undefined
? { code: value.error.code, message: value.error.message }
: null,
};
}
function operationId(error: { readonly _tag: string }): string | null {
return "operationId" in error && typeof error.operationId === "string" ? error.operationId : null;
}
function toAgentError(error: { readonly _tag: string; readonly message: string }): AgentOperationError {
const code =
{
LaneBusy: "lane_busy",
InvalidMessage: "invalid_message",
UnknownSkill: "unknown_skill",
UnknownTemplate: "unknown_template",
NothingToCompact: "nothing_to_compact",
NothingToResume: "nothing_to_resume",
InvalidNavigation: "invalid_navigation",
UnknownTarget: "unknown_target",
Closed: "closed",
}[error._tag] ?? "operation_failed";
return { code, message: error.message };
}
function toTextPrompt(request: AgentPromptRequest): [message: string, images: ImageContent[] | undefined] {
return [request.message, request.images ?? undefined];
}

View File

@@ -0,0 +1,54 @@
import { type Context, defineService } from "@earendil-works/chord";
export interface AgentPromptImage {
type: "image";
data: string;
mimeType: string;
}
export interface AgentPromptRequest {
message: string;
images: AgentPromptImage[] | null;
}
export interface AgentOperationError {
code: string;
message: string;
}
export type AgentOperationResponse =
| { accepted: true; operationId: string; error: AgentOperationError | null }
| { accepted: false; operationId: string | null; error: AgentOperationError };
export type AgentQueueResponse =
| { accepted: true; entryId: string; error: null }
| { accepted: false; entryId: null; error: AgentOperationError };
export interface AgentCompactionRequest {
customInstructions: string | null;
}
export interface AgentNavigationRequest {
targetId: string | null;
summarize: boolean;
label: string | null;
customInstructions: string | null;
}
/** Presentation-safe command facade over the worker-owned main AgentLane. */
export interface AgentController {
prompt(request: AgentPromptRequest, context: Context): Promise<AgentOperationResponse>;
requestAbort(operationId: string, context: Context): Promise<void>;
steer(request: AgentPromptRequest, context: Context): Promise<AgentQueueResponse>;
followUp(request: AgentPromptRequest, context: Context): Promise<AgentQueueResponse>;
nextRun(request: AgentPromptRequest, context: Context): Promise<AgentQueueResponse>;
cancelQueued(
entryId: string,
context: Context,
): Promise<{ outcome: "cancelled" | "already_consumed" | "not_found" }>;
resume(context: Context): Promise<AgentOperationResponse>;
compact(request: AgentCompactionRequest, context: Context): Promise<AgentOperationResponse>;
navigate(request: AgentNavigationRequest, context: Context): Promise<AgentOperationResponse>;
}
export const AgentController = defineService<AgentController>("pi.agent-controller");

View File

@@ -0,0 +1,405 @@
import {
type Context,
createRemoteServiceBinding,
type MutableReplicatedState,
type RemoteServiceBinding,
type RemoteServiceSource,
type RemoteServices,
type RemoteServiceTransport,
type ReplicatedState,
replicatedState,
type Service,
type ServiceCatalogueEntry,
} from "@earendil-works/chord";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { type Client, createClientServiceTransport } from "@earendil-works/pi-client";
import type { SessionTarget } from "@earendil-works/pi-protocol";
export type ServerConnectionState =
| { status: "connecting"; attempt: number }
| { status: "connected"; since: string }
| { status: "disconnected"; since: string; reason: string; retryAt: string | null };
export type SessionAttachmentState =
| { status: "detached" }
| { status: "attaching" | "attached" | "degraded"; sessionId: string };
export interface ServerServiceSource extends RemoteServiceSource {
readonly connection: ReplicatedState<ServerConnectionState>;
dispose(context: Context): Promise<void>;
}
export interface SessionServiceSource extends RemoteServiceSource {
readonly attachment: ReplicatedState<SessionAttachmentState>;
/** Wait for the exact current attachment generation to finish hydrating. */
whenAttached(sessionId: string, context: Context): Promise<void>;
/** Wait for every binding to finish releasing the previous attachment. */
whenDetached(context: Context): Promise<void>;
dispose(context: Context): Promise<void>;
}
export interface ServiceSourceOptions {
readonly onError?: (error: Error) => void;
}
class RoutedServiceBinding implements RemoteServices {
readonly #services: RemoteServiceBinding;
readonly #getBound: () => boolean;
readonly #onActivate: (context: Context) => Promise<void>;
readonly #remove: () => void;
#activated = false;
#activationComplete = false;
constructor(options: {
readonly services: readonly { readonly id: string }[];
readonly transport: RemoteServiceTransport;
readonly getBound: () => boolean;
readonly assertAccess: () => void;
readonly onError: (error: Error) => void;
readonly onActivate?: (context: Context) => Promise<void>;
readonly remove: () => void;
}) {
this.#services = createRemoteServiceBinding({
services: options.services,
transport: options.transport,
bound: false,
assertAccess: options.assertAccess,
onError: options.onError,
});
this.#getBound = options.getBound;
this.#onActivate = options.onActivate ?? (() => Promise.resolve());
this.#remove = options.remove;
}
use<T>(service: Service<T>): T {
return this.#services.use(service);
}
observe<T>(service: Service<T>, handler: (service: T, context: Context) => void | Promise<void>): () => void {
return this.#services.observe(service, handler);
}
async ready(context: Context): Promise<void> {
if (!this.#activated) {
this.#activated = true;
await this.#services.rebind(this.#getBound(), context);
}
await this.serviceReady(context);
if (!this.#activationComplete) {
await this.#onActivate(context);
this.#activationComplete = true;
}
}
serviceReady(context: Context): Promise<void> {
return this.#services.ready(context);
}
updateBound(bound: boolean, context: Context): Promise<void> {
return this.#activated ? this.#services.rebind(bound, context) : Promise.resolve();
}
async dispose(context: Context): Promise<void> {
this.#remove();
await this.#services.dispose(context);
}
}
class ServerServiceSourceImpl implements ServerServiceSource {
readonly acceptsUnavailableServices = false;
readonly connection: ReplicatedState<ServerConnectionState>;
readonly #client: Client;
readonly #transport: RemoteServiceTransport;
readonly #bindings = new Set<RoutedServiceBinding>();
readonly #removeConnectionListener: () => void;
readonly #onError: (error: Error) => void;
#transition = Promise.resolve();
#connectionAttempt: number;
#disposed = false;
constructor(client: Client, options: ServiceSourceOptions) {
this.#client = client;
this.#transport = createClientServiceTransport(client, () => ({ serverId: client.serverId }));
this.#onError = options.onError ?? (() => {});
this.#connectionAttempt = client.connectionState === "connecting" ? 1 : 0;
const connectionState = replicatedState<ServerConnectionState>(
toServerConnectionState(client, this.#connectionAttempt),
);
this.connection = connectionState;
this.#removeConnectionListener = client.onConnectionStateChange(({ state, error }) => {
if (state === "connecting") this.#connectionAttempt += 1;
publishReplacement(
connectionState,
toServerConnectionState(client, this.#connectionAttempt, error),
BACKGROUND_CONTEXT,
);
this.#transition = this.#transition
.then(async () => {
const results = await Promise.allSettled(
[...this.#bindings].map((binding) => binding.updateBound(state === "connected", BACKGROUND_CONTEXT)),
);
const failures = results.filter(
(result): result is PromiseRejectedResult => result.status === "rejected",
);
if (failures.length > 0) throw new AggregateError(failures.map(({ reason }) => reason));
})
.catch((transitionError: unknown) => this.#onError(toError(transitionError)));
});
}
catalogue(context: Context) {
return this.#client.serviceCatalogue({ serverId: this.#client.serverId }, context.abortSignal);
}
open(options: {
readonly services: readonly { readonly id: string }[];
assertAccess(): void;
onError(error: Error): void;
}): RemoteServices {
if (this.#disposed) throw new Error("Server service source is disposed");
let binding!: RoutedServiceBinding;
binding = new RoutedServiceBinding({
services: options.services,
transport: this.#transport,
getBound: () => this.#client.connected,
assertAccess: options.assertAccess,
onError: options.onError,
remove: () => this.#bindings.delete(binding),
});
this.#bindings.add(binding);
return binding;
}
async dispose(context: Context): Promise<void> {
if (this.#disposed) return;
this.#disposed = true;
this.#removeConnectionListener();
await this.#transition;
const bindings = [...this.#bindings];
this.#bindings.clear();
const results = await Promise.allSettled(bindings.map((binding) => binding.dispose(context)));
throwFailures(results, "Failed to dispose server service source");
}
}
class SessionServiceSourceImpl implements SessionServiceSource {
readonly attachment: ReplicatedState<SessionAttachmentState>;
readonly #client: Client;
readonly #transport: RemoteServiceTransport;
readonly #attachmentState: MutableReplicatedState<SessionAttachmentState>;
readonly #bindings = new Set<RoutedServiceBinding>();
readonly #removeAttachmentListener: () => void;
readonly #onError: (error: Error) => void;
readonly #transitions = new Set<Promise<void>>();
#catalogue: readonly ServiceCatalogueEntry[] | undefined;
#attachmentRevision = 0;
#disposed = false;
get acceptsUnavailableServices(): boolean {
return this.#client.attachment === undefined && this.#catalogue === undefined;
}
constructor(client: Client, options: ServiceSourceOptions) {
this.#client = client;
this.#transport = createClientServiceTransport(client, () => client.attachment);
this.#onError = options.onError ?? (() => {});
this.#attachmentState = replicatedState<SessionAttachmentState>(
client.attachment === undefined
? { status: "detached" }
: { status: "attaching", sessionId: client.attachment.sessionId },
);
this.attachment = this.#attachmentState;
this.#removeAttachmentListener = client.onAttachmentChange((attachment) => {
const revision = ++this.#attachmentRevision;
if (attachment !== undefined) {
publishReplacement(
this.#attachmentState,
{ status: "attaching", sessionId: attachment.sessionId },
BACKGROUND_CONTEXT,
);
void this.#client.serviceCatalogue(attachment).then(
(catalogue) => {
if (this.#attachmentRevision === revision && sameAttachment(this.#client.attachment, attachment)) {
this.#catalogue = catalogue;
}
},
(error: unknown) => this.#onError(toError(error)),
);
}
const transition = this.#rebind(attachment !== undefined, BACKGROUND_CONTEXT);
this.#transitions.add(transition);
void transition.then(
() => {
this.#transitions.delete(transition);
if (this.#attachmentRevision !== revision || !sameAttachment(this.#client.attachment, attachment))
return;
publishReplacement(
this.#attachmentState,
attachment === undefined
? { status: "detached" }
: { status: "attached", sessionId: attachment.sessionId },
BACKGROUND_CONTEXT,
);
},
(error: unknown) => {
this.#transitions.delete(transition);
if (this.#attachmentRevision !== revision || !sameAttachment(this.#client.attachment, attachment))
return;
publishReplacement(
this.#attachmentState,
attachment === undefined
? { status: "detached" }
: { status: "degraded", sessionId: attachment.sessionId },
BACKGROUND_CONTEXT,
);
this.#onError(toError(error));
},
);
});
}
async catalogue(context: Context): Promise<readonly ServiceCatalogueEntry[]> {
const target = this.#client.attachment;
if (target === undefined) return this.#catalogue ?? [];
const catalogue = await this.#client.serviceCatalogue(target, context.abortSignal);
this.#catalogue = catalogue;
return catalogue;
}
open(options: {
readonly services: readonly { readonly id: string }[];
assertAccess(): void;
onError(error: Error): void;
}): RemoteServices {
if (this.#disposed) throw new Error("Session service source is disposed");
let binding!: RoutedServiceBinding;
binding = new RoutedServiceBinding({
services: options.services,
transport: this.#transport,
getBound: () => this.#client.attachment !== undefined,
onActivate: async (context) => {
const attachment = this.#client.attachment;
if (attachment === undefined) await this.#whenDetached(this.#attachmentRevision, context);
else await this.#whenAttached(attachment, this.#attachmentRevision, context);
},
assertAccess: options.assertAccess,
onError: options.onError,
remove: () => this.#bindings.delete(binding),
});
this.#bindings.add(binding);
return binding;
}
async whenAttached(sessionId: string, context: Context): Promise<void> {
const attachment = this.#client.attachment;
if (attachment === undefined || attachment.sessionId !== sessionId) {
throw new Error(`Session ${sessionId} is not the current attachment`);
}
await this.#whenAttached(attachment, this.#attachmentRevision, context);
}
async whenDetached(context: Context): Promise<void> {
if (this.#client.attachment !== undefined) throw new Error("A Session is still attached");
await this.#whenDetached(this.#attachmentRevision, context);
}
async dispose(context: Context): Promise<void> {
if (this.#disposed) return;
this.#disposed = true;
this.#removeAttachmentListener();
await Promise.allSettled(this.#transitions);
const bindings = [...this.#bindings];
this.#bindings.clear();
const results = await Promise.allSettled(bindings.map((binding) => binding.dispose(context)));
throwFailures(results, "Failed to dispose Session service source");
}
async #rebind(bound: boolean, context: Context): Promise<void> {
const results = await Promise.allSettled(
[...this.#bindings].map((binding) => binding.updateBound(bound, context)),
);
throwFailures(results, "Failed to rebind Session services");
}
async #whenAttached(attachment: SessionTarget, revision: number, context: Context): Promise<void> {
try {
await Promise.all([...this.#bindings].map((binding) => binding.serviceReady(context)));
} catch (error) {
if (this.#attachmentRevision === revision && sameAttachment(this.#client.attachment, attachment)) {
publishReplacement(this.#attachmentState, { status: "degraded", sessionId: attachment.sessionId }, context);
}
throw error;
}
if (this.#attachmentRevision !== revision || !sameAttachment(this.#client.attachment, attachment)) {
throw new Error(`Session ${attachment.sessionId} was replaced while attaching`);
}
const state = this.#attachmentState.value;
if (state.status !== "attached" || state.sessionId !== attachment.sessionId) {
publishReplacement(this.#attachmentState, { status: "attached", sessionId: attachment.sessionId }, context);
}
}
async #whenDetached(revision: number, context: Context): Promise<void> {
await Promise.all([...this.#bindings].map((binding) => binding.serviceReady(context)));
if (this.#attachmentRevision !== revision || this.#client.attachment !== undefined) {
throw new Error("The Session attachment changed while detaching");
}
if (this.#attachmentState.value.status !== "detached") {
publishReplacement(this.#attachmentState, { status: "detached" }, context);
}
}
}
/** Create the server-scoped remote service source for one presentation client. */
export function createServerServiceSource(client: Client, options: ServiceSourceOptions = {}): ServerServiceSource {
return new ServerServiceSourceImpl(client, options);
}
/** Create the selected-Session remote service source for one presentation client. */
export function createSessionServiceSource(client: Client, options: ServiceSourceOptions = {}): SessionServiceSource {
return new SessionServiceSourceImpl(client, options);
}
function publishReplacement<T extends object>(state: MutableReplicatedState<T>, value: T, context: Context): void {
const target = state.state as Record<string, unknown>;
const replacement = value as Record<string, unknown>;
for (const key of Object.keys(target)) {
if (!Object.hasOwn(replacement, key)) delete target[key];
}
Object.assign(target, replacement);
state.publish(context);
}
function toServerConnectionState(client: Client, attempt: number, error?: Error): ServerConnectionState {
const since = new Date().toISOString();
switch (client.connectionState) {
case "connecting":
return { status: "connecting", attempt };
case "connected":
return { status: "connected", since };
case "disconnected":
return {
status: "disconnected",
since,
reason: error?.message ?? "Client is disconnected",
retryAt: null,
};
}
}
function sameAttachment(left: SessionTarget | undefined, right: SessionTarget | undefined): boolean {
if (left === undefined || right === undefined) return left === right;
return (
left.serverId === right.serverId && left.sessionId === right.sessionId && left.attachmentId === right.attachmentId
);
}
function throwFailures(results: readonly PromiseSettledResult<unknown>[], message: string): void {
const errors = results.flatMap((result) => (result.status === "rejected" ? [result.reason] : []));
if (errors.length === 1) throw errors[0];
if (errors.length > 1) throw new AggregateError(errors, message);
}
function toError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}

View File

@@ -0,0 +1,143 @@
import { type Context, defineFacet, type Facet, type MutableReplicatedState } from "@earendil-works/chord";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import type { AgentLane, ThinkingLevel } from "@earendil-works/pi-agent-core";
import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
import type { ModelRuntime } from "../../core/model-runtime.ts";
import type { SettingsManager } from "../../core/settings-manager.ts";
import { Models, type Models as ModelsService, type ModelsState } from "./models.ts";
export interface ModelsServiceRuntime {
readonly service: ModelsService;
activate(context: Context): Promise<void>;
}
export function createModelsService(
lane: AgentLane,
modelRuntime: ModelRuntime | undefined,
settingsManager: SettingsManager | undefined,
createState: (initial: ModelsState) => MutableReplicatedState<ModelsState>,
): ModelsServiceRuntime {
let catalogRevision = 0;
const state = createState({
catalog: { revision: 0, availableModels: [] },
configuration: { model: null, thinkingLevel: "off" },
refresh: { status: "idle" },
});
const readConfiguration = async (context: Context): Promise<ModelsState["configuration"]> => {
const [selected, thinkingLevel] = await Promise.all([lane.getModel(context), lane.getThinkingLevel(context)]);
return {
model: selected === undefined ? null : { provider: selected.provider, modelId: selected.id },
thinkingLevel,
};
};
const readThinkingLevels = async (context: Context): Promise<ThinkingLevel[]> => {
const selected = await lane.getModel(context);
return selected === undefined ? ["off"] : getSupportedThinkingLevels(selected);
};
const readCatalog = async (context: Context): Promise<ModelsState["catalog"]> => {
const selected = await lane.getModel(context);
const available = modelRuntime?.getAvailableSnapshot() ?? [];
const catalog =
selected === undefined || includesModel(available, selected) ? available : [...available, selected];
catalogRevision += 1;
return {
revision: catalogRevision,
availableModels: catalog.map((model) => ({
provider: model.provider,
modelId: model.id,
name: model.name,
reasoning: model.reasoning,
})),
};
};
const service: ModelsService = {
state,
async cycleThinking(context) {
const levels = await readThinkingLevels(context);
const current = await lane.getThinkingLevel(context);
const index = levels.indexOf(current);
const next = levels[(index + 1) % levels.length] ?? "off";
await lane.setThinkingLevel(next, context);
state.state.configuration = await readConfiguration(context);
state.publish(context);
},
async getThinkingLevels(context) {
return [...(await readThinkingLevels(context))];
},
async refresh(context) {
state.state.refresh = { status: "refreshing" };
state.publish(context);
const refresh = modelRuntime?.refresh({ signal: context.abortSignal });
if (refresh === undefined) {
const [catalog, configuration] = await Promise.all([readCatalog(context), readConfiguration(context)]);
state.state.catalog = catalog;
state.state.configuration = configuration;
state.state.refresh = { status: "done" };
state.publish(context);
return;
}
const result = await refresh;
const errors = Object.fromEntries([...result.errors].map(([id, error]) => [id, error.message]));
const [catalog, configuration] = await Promise.all([readCatalog(context), readConfiguration(context)]);
state.state.catalog = catalog;
state.state.configuration = configuration;
state.state.refresh = Object.keys(errors).length === 0 ? { status: "done" } : { status: "warning", errors };
state.publish(context);
},
async select(model, context) {
const selected = modelRuntime?.getModel(model.provider, model.modelId);
if (selected === undefined) throw new Error(`Unknown model: ${model.provider}/${model.modelId}`);
await lane.setModel({ provider: selected.provider, modelId: selected.id }, context);
settingsManager?.setDefaultModelAndProvider(selected.provider, selected.id);
await settingsManager?.flush();
state.state.configuration = await readConfiguration(context);
state.publish(context);
},
async selectThinking(level, context) {
const levels = await readThinkingLevels(context);
if (!levels.includes(level)) {
throw new Error(`Thinking level ${level} is unavailable; choose one of: ${levels.join(", ")}`);
}
await lane.setThinkingLevel(level, context);
state.state.configuration = await readConfiguration(context);
state.publish(context);
},
};
return {
service,
async activate(context) {
const [catalog, configuration] = await Promise.all([readCatalog(context), readConfiguration(context)]);
state.state.catalog = catalog;
state.state.configuration = configuration;
state.state.refresh = { status: "idle" };
state.publish(context);
},
};
}
export function createModelsServiceFacet(options: {
readonly lane: AgentLane;
readonly modelRuntime: ModelRuntime | undefined;
readonly settingsManager?: SettingsManager;
}): Facet {
return defineFacet({
id: "@pi/models",
setup(env) {
const runtime = createModelsService(
options.lane,
options.modelRuntime,
options.settingsManager,
env.replicatedState,
);
env.provide(Models, runtime.service);
env.onActivate(() => runtime.activate(BACKGROUND_CONTEXT));
},
});
}
function includesModel(
models: readonly { readonly provider: string; readonly id: string }[],
selected: { readonly provider: string; readonly id: string },
): boolean {
return models.some((model) => model.provider === selected.provider && model.id === selected.id);
}

View File

@@ -0,0 +1,35 @@
import { type Context, defineService, type ReplicatedState } from "@earendil-works/chord";
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
export interface ModelRef {
provider: string;
modelId: string;
}
export interface ModelSummary extends ModelRef {
name: string;
reasoning: boolean;
}
export interface ModelsState {
catalog: {
revision: number;
availableModels: ModelSummary[];
};
configuration: {
model: ModelRef | null;
thinkingLevel: ThinkingLevel;
};
refresh: { status: "idle" | "refreshing" | "done" } | { status: "warning"; errors: Record<string, string> };
}
export interface Models {
readonly state: ReplicatedState<ModelsState>;
cycleThinking(context: Context): Promise<void>;
getThinkingLevels(context: Context): Promise<ThinkingLevel[]>;
refresh(context: Context): Promise<void>;
select(model: ModelRef, context: Context): Promise<void>;
selectThinking(level: ThinkingLevel, context: Context): Promise<void>;
}
export const Models = defineService<Models>("pi.models");

View File

@@ -0,0 +1,19 @@
import { type Context, defineService, type JsonValue } from "@earendil-works/chord";
/** Server-built plugin generations available to presentations. */
export interface PresentationPlugins {
prepareSession(
request: { readonly sessionId: string; readonly packagePaths: readonly string[] | null },
context: Context,
): Promise<JsonValue>;
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");

View File

@@ -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 });

View File

@@ -0,0 +1,150 @@
import {
type Context,
createRemoteServiceEndpoint,
type JsonValue,
RemoteServiceProvider,
replicatedState,
} from "@earendil-works/chord";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import type { RoutedServerServiceAttachment, RoutedServerServiceHost } from "@earendil-works/pi-server";
import { PresentationPlugins } from "./plugins.ts";
import {
type SessionCreateOptions,
SessionDirectory,
type SessionDirectoryState,
SessionManagement,
type SessionSummary,
} from "./sessions.ts";
export interface ExperimentalServerServices {
readonly host: RoutedServerServiceHost;
refresh(context?: Context): Promise<void>;
dispose(): Promise<void>;
}
export async function createExperimentalServerServices(options: {
list(context: Context): Promise<SessionSummary[]>;
create(createOptions: SessionCreateOptions, context: Context): Promise<SessionSummary>;
remove(sessionId: string, context: Context): Promise<void>;
prepareSessionPlugins(
sessionId: string,
packagePaths: readonly string[] | undefined,
context: Context,
): Promise<{ readonly packagePaths: readonly string[]; readonly presentationPlugins: JsonValue }>;
reloadPresentationPlugins(packagePaths: readonly string[], context: Context): Promise<JsonValue>;
}): Promise<ExperimentalServerServices> {
let revision = 1;
const directory = replicatedState<SessionDirectoryState>({
revision,
sessions: await options.list(BACKGROUND_CONTEXT),
});
const attachments = new Set<RoutedServerServiceAttachment>();
let mutationTail = Promise.resolve();
const refreshNow = async (context: Context): Promise<void> => {
const sessions = await options.list(context);
revision += 1;
directory.state.revision = revision;
directory.state.sessions = sessions;
directory.publish(context);
};
const serialize = <T>(operation: () => Promise<T>): Promise<T> => {
const result = mutationTail.catch(() => {}).then(operation);
mutationTail = result.then(
() => undefined,
() => undefined,
);
return result;
};
return {
host: {
attachClient(presentation) {
let preparedPluginPackagePaths: readonly string[] | undefined;
const provider = new RemoteServiceProvider([
{ service: SessionDirectory, mode: "singleton" },
{ service: SessionManagement, mode: "singleton" },
{ service: PresentationPlugins, mode: "singleton" },
]);
provider.provide(SessionDirectory, { state: directory });
provider.provide(PresentationPlugins, {
prepareSession: ({ sessionId, packagePaths }, context) =>
serialize(async () => {
const selected = await options.prepareSessionPlugins(
sessionId,
packagePaths ?? undefined,
context,
);
preparedPluginPackagePaths = selected.packagePaths;
return selected.presentationPlugins;
}),
reload: (context) =>
serialize(() => {
if (preparedPluginPackagePaths === undefined) {
throw new Error("No Session plugin selection is prepared");
}
return options.reloadPresentationPlugins(preparedPluginPackagePaths, context);
}),
});
provider.provide(SessionManagement, {
create: (createOptions, context) =>
serialize(async () => {
const created = await options.create(createOptions, context);
await refreshNow(context);
return created;
}),
remove: (sessionId, context) =>
serialize(async () => {
await presentation.prepareSessionRemoval(sessionId, context);
await options.remove(sessionId, context);
await refreshNow(context);
}),
attach: (sessionId, context) =>
serialize(async () => {
await presentation.attachSession(sessionId, context);
}),
detach: (context) =>
serialize(async () => {
await presentation.detachSession(context);
preparedPluginPackagePaths = undefined;
}),
});
const attachment = createProviderAttachment(provider, () => attachments.delete(attachment));
attachments.add(attachment);
return attachment;
},
},
refresh: (context = BACKGROUND_CONTEXT) => serialize(() => refreshNow(context)),
async dispose() {
const releases = await Promise.allSettled(
[...attachments].map((attachment) => attachment.release(BACKGROUND_CONTEXT)),
);
attachments.clear();
await mutationTail;
const errors = releases.flatMap((result) => (result.status === "rejected" ? [result.reason] : []));
if (errors.length === 1) throw errors[0];
if (errors.length > 1) throw new AggregateError(errors, "Failed to release server service attachments");
},
};
}
function createProviderAttachment(
provider: RemoteServiceProvider,
onRelease: () => void,
): RoutedServerServiceAttachment {
const endpoint = createRemoteServiceEndpoint(provider);
let released = false;
return {
invokeService(call, publish, context) {
if (released) return Promise.reject(new Error("Server service attachment is released"));
return endpoint.invoke(call, publish, context);
},
release() {
if (released) return;
released = true;
endpoint.dispose();
provider.dispose();
onRelease();
},
};
}

View File

@@ -0,0 +1,35 @@
import { type Context, defineService, type ReplicatedState } from "@earendil-works/chord";
import type { ServerId } from "@earendil-works/pi-protocol";
export interface SessionAddress {
serverId: ServerId;
sessionId: string;
}
export interface SessionSummary extends SessionAddress {
createdAt: number;
}
export interface SessionCreateOptions {
id?: string;
}
export interface SessionDirectoryState {
revision: number;
sessions: SessionSummary[];
}
export interface SessionDirectory {
readonly state: ReplicatedState<SessionDirectoryState>;
}
export const SessionDirectory = defineService<SessionDirectory>("pi.session-directory");
export interface SessionManagement {
create(options: SessionCreateOptions, context: Context): Promise<SessionSummary>;
remove(sessionId: string, context: Context): Promise<void>;
attach(sessionId: string, context: Context): Promise<void>;
detach(context: Context): Promise<void>;
}
export const SessionManagement = defineService<SessionManagement>("pi.session-management");

View File

@@ -0,0 +1,225 @@
import { defineFacet, type Facet, type JsonValue } from "@earendil-works/chord";
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import { AgentController } from "./agent-controller.ts";
import { type ModelSummary, Models, type Models as ModelsService } from "./models.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",
minimal: "Very brief reasoning",
low: "Light reasoning",
medium: "Moderate reasoning",
high: "Deep reasoning",
xhigh: "Extra-high reasoning",
max: "Maximum reasoning",
};
interface RegisteredSlashCommand {
readonly command: SlashCommandContribution;
closed: boolean;
}
export class SlashCommandRegistry implements SlashCommands {
readonly #commands = new Map<string, RegisteredSlashCommand[]>();
readonly #listeners = new Set<(commands: readonly SlashCommandContribution[]) => void>();
register(command: SlashCommandContribution): () => void {
this.#validate(command);
if (this.#commands.has(command.name)) throw new Error(`Slash command /${command.name} is already registered`);
return this.#add(command);
}
replace(command: SlashCommandContribution): () => void {
this.#validate(command);
return this.#add(command);
}
list(): readonly SlashCommandContribution[] {
return Object.freeze([...this.#commands.values()].map((entries) => entries[0]!.command));
}
subscribe(listener: (commands: readonly SlashCommandContribution[]) => void): () => void {
this.#listeners.add(listener);
listener(this.list());
return () => this.#listeners.delete(listener);
}
#add(command: SlashCommandContribution): () => void {
const entry: RegisteredSlashCommand = { command: Object.freeze({ ...command }), closed: false };
const entries = this.#commands.get(command.name) ?? [];
entries.push(entry);
this.#commands.set(command.name, entries);
if (entries.length === 1) this.#publish();
return () => {
if (entry.closed) return;
entry.closed = true;
if (entries[0] !== entry) return;
while (entries[0]?.closed) entries.shift();
if (entries.length === 0) this.#commands.delete(command.name);
this.#publish();
};
}
#validate(command: SlashCommandContribution): void {
if (!/^[a-z0-9][a-z0-9:-]*$/u.test(command.name)) {
throw new TypeError(`Invalid slash command name: ${command.name}`);
}
}
#publish(): void {
const commands = this.list();
for (const listener of this.#listeners) listener(commands);
}
}
export function createSlashCommandsRuntimeFacet(registry = new SlashCommandRegistry()): Facet {
return defineFacet({
id: "@pi/slash-commands-runtime",
setup(env) {
env.provide(SlashCommands, registry);
},
});
}
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.replace(modelCommand(models, ui)));
env.own(commands.replace(thinkingCommand(models, ui)));
env.own(commands.replace(compactCommand(controller, ui)));
env.own(
commands.replace({
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, ui: PresentationUI): SlashCommandContribution {
return {
name: "model",
description: "Select model",
argumentHint: "<provider/model>",
getArgumentCompletions(prefix) {
const normalized = prefix.toLowerCase();
return (models.state.value?.catalog.availableModels ?? [])
.filter((model) => `${model.provider}/${model.modelId} ${model.name}`.toLowerCase().includes(normalized))
.map((model) => ({
value: `${model.provider}/${model.modelId}`,
label: model.modelId,
description: model.provider,
}));
},
async run(args, context) {
const state = models.state.value;
if (state === undefined) throw new Error("Models service is not ready");
let selected = exactModel(state.catalog.availableModels, args);
if (args.length > 0 && selected === undefined) {
throw new Error(`Unknown model: ${args}`);
}
if (selected === undefined) {
const value = await ui.select(
"Select model:",
state.catalog.availableModels.map((model) => ({
value: `${model.provider}/${model.modelId}`,
label:
state.configuration.model?.provider === model.provider &&
state.configuration.model.modelId === model.modelId
? `${model.name} (selected)`
: model.name,
description: `${model.provider}/${model.modelId}`,
})),
state.configuration.model === null
? undefined
: `${state.configuration.model.provider}/${state.configuration.model.modelId}`,
context,
);
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);
ui.showStatus(`Selected ${selected.provider}/${selected.modelId}.`, context);
return undefined;
},
};
}
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);
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 ui.select(
"Select thinking level:",
levels.map((level) => ({
value: level,
label: models.state.value?.configuration.thinkingLevel === level ? `${level} (selected)` : level,
description: THINKING_DESCRIPTIONS[level],
})),
models.state.value?.configuration.thinkingLevel,
context,
);
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);
ui.showStatus(`Thinking level: ${selected}.`, context);
return undefined;
},
};
}
function compactCommand(controller: AgentController, ui: PresentationUI): SlashCommandContribution {
return {
name: "compact",
description: "Manually compact the session context",
argumentHint: "<instructions>",
run(args, context) {
ui.showStatus("Compacting…", context);
return controller.compact({ customInstructions: args.length === 0 ? null : args }, context);
},
};
}
function exactModel(models: readonly ModelSummary[], query: string): ModelSummary | undefined {
if (query.length === 0) return undefined;
const normalized = query.toLowerCase();
const matches = models.filter(
(model) =>
`${model.provider}/${model.modelId}`.toLowerCase() === normalized ||
model.modelId.toLowerCase() === normalized,
);
return matches.length === 1 ? matches[0] : undefined;
}

View File

@@ -0,0 +1,30 @@
import { type Context, defineService } from "@earendil-works/chord";
import type { AgentOperationResponse, AgentQueueResponse } from "./agent-controller.ts";
export interface SlashCommandCompletion {
readonly value: string;
readonly label: string;
readonly description?: string;
}
export type SlashCommandRunResult = AgentOperationResponse | AgentQueueResponse | undefined;
export interface SlashCommandContribution {
readonly name: string;
readonly description?: string;
readonly argumentHint?: string;
getArgumentCompletions?(
argumentPrefix: string,
): readonly SlashCommandCompletion[] | null | Promise<readonly SlashCommandCompletion[] | null>;
run(args: string, context: Context): SlashCommandRunResult | Promise<SlashCommandRunResult>;
}
export interface SlashCommands {
register(command: SlashCommandContribution): () => void;
/** Stage a same-name replacement while the previous facet generation retires. */
replace(command: SlashCommandContribution): () => void;
list(): readonly SlashCommandContribution[];
subscribe(listener: (commands: readonly SlashCommandContribution[]) => void): () => void;
}
export const SlashCommands = defineService<SlashCommands>("pi.local.slash-commands", { local: true });

View File

@@ -0,0 +1,128 @@
import { defineFacet, type Facet, type MutableReplicatedState } from "@earendil-works/chord";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import {
type AgentLane,
type HarnessEvent,
type LaneSnapshot,
type LaneTranscriptSnapshot,
type LaneWatchEvent,
reduceLaneSnapshot,
type WatchHandle,
} from "@earendil-works/pi-agent-core";
import { Transcript, type Transcript as TranscriptService, type TranscriptState } from "./transcript.ts";
interface TranscriptRuntime {
readonly service: TranscriptService;
activate(): Promise<void>;
dispose(): Promise<void>;
}
export function createTranscriptService(
lane: AgentLane,
createState: (initial: TranscriptState) => MutableReplicatedState<TranscriptState>,
): TranscriptRuntime {
const state = createState({ snapshot: null, event: null });
let watch: WatchHandle<LaneSnapshot> | undefined;
let rebase: Promise<void> | undefined;
let rebaseError: Error | undefined;
const publishSnapshot = (
next: LaneSnapshot,
event: LaneWatchEvent | null,
context: Parameters<typeof state.publish>[0],
): void => {
state.state.snapshot = next as LaneTranscriptSnapshot;
state.state.event = event;
state.publish(context);
};
const scheduleRebase = (context: Parameters<typeof state.publish>[0]): void => {
if (rebase !== undefined) return;
const activeWatch = watch;
if (activeWatch === undefined) return;
const pending = (async () => {
const refreshed = await activeWatch.resnapshot(context);
publishSnapshot(refreshed, null, context);
})();
rebase = pending;
void pending.then(
() => {
if (rebase === pending) rebase = undefined;
},
(error: unknown) => {
rebaseError = error instanceof Error ? error : new Error(String(error));
if (rebase === pending) rebase = undefined;
},
);
};
const onEvent = (event: HarnessEvent, context: Parameters<typeof state.publish>[0]): void => {
if (rebaseError !== undefined) throw rebaseError;
const forwarded = toLaneWatchEvent(event);
if (forwarded === undefined) return;
const snapshot = state.state.snapshot;
if (snapshot === null) throw new Error("Transcript service is not active");
if (reduceLaneSnapshot(snapshot, event) === "rebase") scheduleRebase(context);
state.state.event = forwarded;
state.publish(context);
};
return {
service: { state },
async activate() {
if (watch !== undefined) throw new Error("Transcript service is already active");
const opened = await lane.watch(BACKGROUND_CONTEXT);
watch = opened;
publishSnapshot(opened.snapshot, null, BACKGROUND_CONTEXT);
opened.start(onEvent);
},
async dispose() {
let failure: unknown;
try {
await rebase;
} catch (error) {
failure = error;
}
watch?.unsubscribe();
watch = undefined;
if (failure !== undefined) throw failure;
},
};
}
export function createTranscriptServiceFacet(lane: AgentLane): Facet {
return defineFacet({
id: "@pi/transcript",
setup(env) {
const runtime = createTranscriptService(lane, env.replicatedState);
env.provide(Transcript, runtime.service);
env.onActivate(() => runtime.activate());
env.own(() => runtime.dispose());
},
});
}
function toLaneWatchEvent(event: HarnessEvent): LaneWatchEvent | undefined {
switch (event.type) {
case "handler_error":
case "turn_start":
case "turn_end":
case "value_update":
case "lane_created":
return undefined;
case "config_update":
if (event.property !== "model" && event.property !== "thinkingLevel" && event.property !== "activeTools") {
return undefined;
}
return event as LaneWatchEvent;
case "message_update": {
if (event.message.role !== "assistant") {
throw new TypeError("Harness message_update did not carry an assistant message");
}
const { event: _providerEvent, ...update } = event;
return update as LaneWatchEvent;
}
default:
return event as LaneWatchEvent;
}
}

View File

@@ -0,0 +1,15 @@
import { defineService, type ReplicatedState } from "@earendil-works/chord";
import type { LaneTranscriptSnapshot, LaneWatchEvent } from "@earendil-works/pi-agent-core";
export interface TranscriptState {
snapshot: LaneTranscriptSnapshot | null;
/** The source event is retained for presentation side effects; hydration does not replay it. */
event: LaneWatchEvent | null;
}
/** Coherent main-lane state replicated through Chord's operation stream. */
export interface Transcript {
readonly state: ReplicatedState<TranscriptState>;
}
export const Transcript = defineService<Transcript>("pi.transcript");

View File

@@ -0,0 +1,152 @@
import {
type Context,
createFacetHost,
createRemoteServiceEndpoint,
createStaticFacetLoader,
defineFacet,
type FacetHost,
type FacetLoader,
type JsonValue,
type RemoteServiceEndpoint,
type ServiceCall,
type ServiceProviderUpdate,
} from "@earendil-works/chord";
import type { AgentHarness, AgentLane } from "@earendil-works/pi-agent-core";
import type { ModelRuntime } from "../../core/model-runtime.ts";
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 { createTranscriptServiceFacet } from "./transcript-provider.ts";
export interface SessionWorkerRuntime {
readonly harness: AgentHarness;
readonly lane?: AgentLane;
readonly modelRuntime?: ModelRuntime;
readonly settingsManager?: SettingsManager;
readonly facetLoader?: FacetLoader;
}
export interface WorkerServiceScope {
readonly serverConnectionId: string;
readonly attachmentId: string;
}
interface ScopedServiceEndpoint {
readonly scope: WorkerServiceScope;
readonly endpoint: RemoteServiceEndpoint;
}
export interface SessionWorkerServices {
invoke(call: ServiceCall, scope: WorkerServiceScope, context: Context): Promise<JsonValue | undefined>;
removeSubscriptions(matches: (scope: WorkerServiceScope) => boolean): void;
dispose(): Promise<void>;
}
export async function createSessionWorkerServices(options: {
readonly lane: AgentLane;
readonly modelRuntime: ModelRuntime | undefined;
readonly settingsManager?: SettingsManager;
readonly facetLoader?: FacetLoader;
publish(scope: WorkerServiceScope, subscriptionId: string, update: ServiceProviderUpdate): Promise<void>;
}): Promise<SessionWorkerServices> {
const agentControllerRuntimeFacet = defineFacet({
id: "@pi/agent-controller-runtime",
setup(env) {
env.provide(AgentController, createAgentController(options.lane));
},
});
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 createStaticFacetLoader([
agentControllerRuntimeFacet,
pluginRuntimeFacet,
createModelsServiceFacet(options),
createTranscriptServiceFacet(options.lane),
]).load();
const pluginLoader = options.facetLoader ?? createStaticFacetLoader([]);
let loadedPlugins = await pluginLoader.load();
let facetHost: FacetHost;
try {
facetHost = await createFacetHost({ facets: [...builtins.facets, ...loadedPlugins.facets] });
} catch (error) {
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 endpoints = new Map<string, ScopedServiceEndpoint>();
const removeSubscriptions = (matches: (scope: WorkerServiceScope) => boolean): void => {
for (const [key, entry] of endpoints) {
if (!matches(entry.scope)) continue;
entry.endpoint.dispose();
endpoints.delete(key);
}
};
return {
invoke(call, scope, context) {
const key = serviceScopeKey(scope);
let entry = endpoints.get(key);
if (entry === undefined) {
entry = { scope, endpoint: createRemoteServiceEndpoint(provider) };
endpoints.set(key, entry);
}
return entry.endpoint.invoke(
call,
(subscriptionId, update) => options.publish(scope, subscriptionId, update),
context,
);
},
removeSubscriptions,
async dispose() {
removeSubscriptions(() => true);
await reloadTail;
const errors: unknown[] = [];
try {
await facetHost.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");
},
};
}
function serviceScopeKey(scope: WorkerServiceScope): string {
return `${scope.serverConnectionId}\0${scope.attachmentId}`;
}