feat(agent): add routed plugin service runtime

This commit is contained in:
Armin Ronacher 2026-08-20 00:04:16 +02:00
parent de61e1ad2c
commit 8222adee86
30 changed files with 2815 additions and 83 deletions

View file

@ -42,6 +42,10 @@ agent.subscribe((event) => {
await agent.prompt("Hello!");
```
## Experimental plugin services
The package exports the transport-neutral plugin-service primitives used by the experimental application hosts: `defineRemoteService()`, `remoteState()`, `RemoteServiceProvider`, and `RemoteServiceNamespace`. Providers classify implementation methods and state members at runtime; consumers receive stable singleton facades through `use()` or lifecycle-scoped keyed instances through `observe()`. `RemoteState` values are borrowed immutable JSON: the runtime does not defensively clone them, so callers must not mutate or retain them. Wire framing and host routing remain outside this package.
## Core Concepts
### AgentMessage vs LLM Message

View file

@ -1,6 +1,6 @@
# Coding-Agent Application Hosts and Plugin Facets
> **Status:** Tentative design input, not a normative contract or implementation handoff. The examples use illustrative APIs that do not exist yet. Reconcile this design with `rpc.md`, `telemetry.md`, and the final harness contract before adding it to `harness.md` or creating a work package.
> **Status:** Tentative design input, not a normative contract or implementation handoff. The transport-neutral service token, provider, singleton `use()`, keyed `observe()`, and `RemoteState` substrate now have an experimental implementation; the application host contexts, plugin kernel, events, references, telemetry propagation, and most example facets remain illustrative. Reconcile this design with `rpc.md`, `telemetry.md`, and the final harness contract before adding it to `harness.md` or creating a work package.
This document assumes you already understand `AgentHarness`, `AgentLane`, `Session`, `SessionTree`, `SessionRepo`, invocation `Context`, and telemetry. Read `rpc.md` for wire frames, remote references, subscriptions, and trace carriers, and `telemetry.md` for context propagation and cancellation semantics.
@ -468,14 +468,16 @@ The auth plugin's session facet uses `Credentials` directly; presentations see p
```ts
interface RemoteState<T> {
/** `undefined` until the first authoritative snapshot arrives. */
/** Borrowed immutable value, or `undefined` until hydration. Do not mutate or retain it. */
readonly value: T | undefined;
/** Listener values are borrowed and must not be mutated or retained. */
subscribe(listener: (value: T, context: Context) => void): () => void;
}
interface MutableRemoteState<T> extends RemoteState<T> {
/** A providing state is always initialized. */
readonly value: T;
/** Transfers the JSON value to the state; the caller must not subsequently mutate it. */
set(value: T, context: Context): void;
}
```
@ -486,7 +488,7 @@ Required behavior:
2. A cold remote replica has no value. Its `.value` is `undefined`, and `subscribe()` registers the listener without invoking it. This `undefined` is local readiness state and never crosses the wire.
3. **Hydration** installs a complete snapshot atomically before updates flow. Subscribing before hydration is valid, and updates emitted concurrently with the snapshot are buffered, so the listener observes snapshot then updates with no gap.
4. Once hydrated, `.value` is synchronously readable and `subscribe()` immediately reports the current value, then future updates. The first snapshot callback uses a fresh **hydration context** parented to the subscription; an immediate callback for an already-present value uses a fresh local delivery context because the write that produced it may be long settled.
5. Values are detached with structured JSON semantics; one listener cannot mutate another's state.
5. State values are borrowed immutable JSON. The state runtime does not defensively clone reads, writes, snapshots, or listener deliveries. Callers transfer ownership to `set()` and must not mutate or retain values returned by `.value` or passed to listeners; copy explicitly when ownership is required. Process and transport serialization may naturally produce a detached value, but callers must not depend on object identity or detachment.
6. Disconnect retains the last value as stale display data alongside connection or attachment state. Reconnecting to the same provider replaces it with a fresh snapshot. Switching to a different provider clears readiness and `.value` becomes `undefined` until that provider hydrates; service-bound presentation resources are disposed as part of the switch.
7. `set(value, context)` carries source trace metadata; each live delivery invokes listeners with a reconstructed delivery `Context`. Background updates use an intentional background or lifecycle context, never a retained caller context.
@ -997,7 +999,7 @@ The handoff should include a reusable two-transport test matrix (loopback plus a
- **Composition:** hosts start from one plugin manifest, not hard-coded features; per-host entry-point resolution with no session modules reachable in a presentation bundle; provide/demand-resolve round trip; mixed singleton/keyed use rejected; shared proxy/replica across concurrent consumers; instance snapshot plus spawn/close/re-spawn reconciliation; local services unreachable remotely; duplicate/missing service failures; activation only after registration; reverse-order disposal.
- **Connections:** `Connect` performs one abortable attempt and the host owns retries; peer identity comes from the accepting server's handshake; session and presentation hosts connect to that server, and `server.local` runs the same host boundaries without sockets.
- **RPC and context:** JSON call and `void` result; invalid argument/result and unknown service/member rejection; client span → `rpc.client` → `rpc.server` → service span; per-call cancellation isolation; disconnect aborts active calls; no callback, context, signal, telemetry object, or secret in wire JSON.
- **State and events:** a cold replica has `.value === undefined` and does not invoke subscribers before hydration; snapshot plus concurrent update has no gap; instance discovery and member-state hydration do not miss a spawn, update, or close; hydrated subscriptions immediately receive the current value; update ordering and detached values; reconnect replaces stale state; provider switching clears readiness; subscribe/unsubscribe and disconnect cleanup; deliveries carry reconstructed source contexts; first snapshot callbacks run under the hydration context.
- **State and events:** a cold replica has `.value === undefined` and does not invoke subscribers before hydration; snapshot plus concurrent update has no gap; instance discovery and member-state hydration do not miss a spawn, update, or close; hydrated subscriptions immediately receive the current borrowed value; update ordering without defensive cloning; reconnect replaces stale state; provider switching clears readiness; subscribe/unsubscribe and disconnect cleanup; deliveries carry reconstructed source contexts; first snapshot callbacks run under the hydration context.
- **Registries:** ordered provider contributions rebuild deterministically; tool wrappers compose once and rebuild correctly after removal.
- **Presentation:** typed selections return values rather than labels; modal leases do not interleave multi-step dialogs; closing a queued instance removes it before display; closing an active instance aborts and dismisses only that dialog; non-cancellation observer failures reach host failure policy.
- **Routing:** attach authorizes at the server and rejects cross-workspace targets; attach/switch closes previous routed requests and instance tasks, then hydrates the selected session; a routed singleton or instance call reconstructs `Context` at the session worker, with cancellation and trace carriers traversing the server; stale instance generations are rejected; per-client keying prevents request-ID collisions; the server routes services whose contracts it does not load; session-worker failure leaves server services healthy and updates directory status; presentation disconnect cleanup reaches the session; summaries contain no `ownerId` or `cwd`.

View file

@ -134,6 +134,7 @@ export {
} from "./harness/types.ts";
export * from "./harness/utils/shell-output.ts";
export * from "./harness/utils/truncate.ts";
export * from "./plugins/services/index.ts";
export * from "./proxy.ts";
export * from "./search/index.ts";
export { setDefaultStreamFn } from "./stream-fn.ts";

View file

@ -0,0 +1,39 @@
export { RemoteServiceNamespace } from "./namespace.ts";
export {
createLoopbackServiceConnection,
RemoteServiceError,
type RemoteServiceErrorCode,
RemoteServiceProvider,
} from "./provider.ts";
export {
freshDeliveryContext,
getRemoteStateInternals,
REMOTE_STATE_INTERNALS,
type RemoteStateInternals,
type RemoteStateSource,
remoteState,
} from "./state.ts";
export {
cloneJson,
defineRemoteService,
isJsonValue,
type MutableRemoteState,
type RemoteService,
type RemoteServiceConnection,
type RemoteServiceInstance,
type RemoteServiceNamespaceApi,
type RemoteServiceNamespaceOptions,
type RemoteServiceSubscription,
type RemoteServiceType,
type RemoteState,
type ServiceCall,
type ServiceInstanceAddress,
type ServiceInstanceSnapshot,
type ServiceMemberDescription,
type ServiceMemberKind,
type ServiceMode,
type ServiceProviderSubscription,
type ServiceProviderUpdate,
type ServiceStateSnapshot,
type ServiceSubscriptionSnapshot,
} from "./types.ts";

View file

@ -0,0 +1,697 @@
import { BACKGROUND_CONTEXT, type Context, withCancel } from "../../harness/context.ts";
import type { JsonValue } from "../../harness/session/types.ts";
import { RemoteServiceError } from "./provider.ts";
import { freshDeliveryContext } from "./state.ts";
import {
cloneJson,
isJsonValue,
type RemoteService,
type RemoteServiceConnection,
type RemoteServiceInstance,
type RemoteServiceNamespaceApi,
type RemoteServiceNamespaceOptions,
type RemoteServiceSubscription,
type RemoteState,
type ServiceInstanceAddress,
type ServiceInstanceSnapshot,
type ServiceMemberDescription,
type ServiceMemberKind,
type ServiceProviderUpdate,
type ServiceStateSnapshot,
} from "./types.ts";
type ErrorReporter = (error: Error) => void;
class RemoteStateReplica implements RemoteState<JsonValue> {
readonly #listeners = new Set<(value: JsonValue, context: Context) => void>();
readonly #reportError: ErrorReporter;
#value: JsonValue | undefined;
#sequence: number | undefined;
constructor(reportError: ErrorReporter) {
this.#reportError = reportError;
}
get value(): JsonValue | undefined {
return this.#value;
}
subscribe(listener: (value: JsonValue, context: Context) => void): () => void {
this.#listeners.add(listener);
if (this.#value !== undefined) this.#deliver(listener, this.#value, freshDeliveryContext());
return () => this.#listeners.delete(listener);
}
hydrate(snapshot: ServiceStateSnapshot, context: Context): void {
this.#sequence = snapshot.sequence;
this.#value = snapshot.value;
this.#deliverAll(context);
}
update(sequence: number, value: JsonValue, context: Context): void {
if (this.#sequence === undefined) throw new Error("Remote state received an update before hydration");
if (sequence <= this.#sequence) return;
if (sequence !== this.#sequence + 1) throw new Error("Remote state update sequence has a gap");
this.#sequence = sequence;
this.#value = value;
this.#deliverAll(context);
}
clear(): void {
this.#value = undefined;
this.#sequence = undefined;
}
#deliverAll(context: Context): void {
if (this.#value === undefined) return;
for (const listener of this.#listeners) this.#deliver(listener, this.#value, context);
}
#deliver(listener: (value: JsonValue, context: Context) => void, value: JsonValue, context: Context): void {
try {
listener(value, context);
} catch (error) {
this.#reportError(toError(error));
}
}
}
class MemberSlot {
readonly #serviceId: string;
readonly #member: string;
readonly #invoke: (args: readonly JsonValue[], context: Context) => Promise<JsonValue | undefined>;
readonly #state: RemoteStateReplica;
readonly #isActive: () => boolean;
readonly value: unknown;
#kind: ServiceMemberKind | undefined;
#expectedKind: ServiceMemberKind | undefined;
constructor(
serviceId: string,
member: string,
invoke: (args: readonly JsonValue[], context: Context) => Promise<JsonValue | undefined>,
isActive: () => boolean,
reportError: ErrorReporter,
) {
this.#serviceId = serviceId;
this.#member = member;
this.#invoke = invoke;
this.#isActive = isActive;
this.#state = new RemoteStateReplica(reportError);
const callable = (): void => {};
this.value = new Proxy(callable, {
apply: (_target, _thisArg, args) => this.#call(args),
get: (_target, property) => {
if (property === "value") {
this.#expect("state");
return this.#state.value;
}
if (property === "subscribe") {
this.#expect("state");
return this.#state.subscribe.bind(this.#state);
}
if (property === Symbol.toStringTag) return "RemoteServiceMember";
if (property === "then") return undefined;
return undefined;
},
});
}
setDescription(kind: ServiceMemberKind): void {
if (this.#kind !== undefined && this.#kind !== kind) {
throw new Error(`Remote service member ${this.#serviceId}.${this.#member} changed kind`);
}
this.#kind = kind;
if (this.#expectedKind !== undefined && this.#expectedKind !== kind) {
throw new RemoteServiceError(
"service_member_mismatch",
`Remote service member ${this.#serviceId}.${this.#member} is ${kind}, not ${this.#expectedKind}`,
);
}
}
hydrate(snapshot: ServiceStateSnapshot, context: Context): void {
this.setDescription("state");
this.#state.hydrate(snapshot, context);
}
update(sequence: number, value: JsonValue, context: Context): void {
this.setDescription("state");
this.#state.update(sequence, value, context);
}
clear(): void {
this.#state.clear();
}
#expect(kind: ServiceMemberKind): void {
if (this.#expectedKind !== undefined && this.#expectedKind !== kind) {
throw new RemoteServiceError(
"service_member_mismatch",
`Remote service member ${this.#serviceId}.${this.#member} was used as two different kinds`,
);
}
this.#expectedKind = kind;
if (this.#kind !== undefined && this.#kind !== kind) {
throw new RemoteServiceError(
"service_member_mismatch",
`Remote service member ${this.#serviceId}.${this.#member} is ${this.#kind}, not ${kind}`,
);
}
}
#call(args: unknown[]): Promise<JsonValue | undefined> {
this.#expect("method");
if (!this.#isActive()) {
return Promise.reject(
new RemoteServiceError("service_stale_instance", `Remote service ${this.#serviceId} binding is closed`),
);
}
const context = args.at(-1);
if (!isContext(context)) {
return Promise.reject(
new RemoteServiceError(
"service_invalid_value",
`Remote service method ${this.#serviceId}.${this.#member} requires a trailing Context`,
),
);
}
const businessArgs = args.slice(0, -1);
if (!businessArgs.every(isJsonValue)) {
return Promise.reject(
new RemoteServiceError(
"service_invalid_value",
`Remote service method ${this.#serviceId}.${this.#member} arguments must be strict JSON`,
),
);
}
return this.#invoke(
businessArgs.map((value) => cloneJson(value)),
context,
);
}
}
class ServiceFacade {
readonly #serviceId: string;
readonly #address: ServiceInstanceAddress | undefined;
readonly #connection: RemoteServiceConnection;
readonly #reportError: ErrorReporter;
readonly #slots = new Map<string, MemberSlot>();
readonly #descriptions = new Map<string, ServiceMemberKind>();
readonly #stateSnapshots = new Map<string, ServiceStateSnapshot>();
readonly #isActive: () => boolean;
readonly proxy: object;
constructor(
serviceId: string,
address: ServiceInstanceAddress | undefined,
connection: RemoteServiceConnection,
isActive: () => boolean,
reportError: ErrorReporter,
) {
this.#serviceId = serviceId;
this.#address = address;
this.#connection = connection;
this.#isActive = isActive;
this.#reportError = reportError;
this.proxy = new Proxy(Object.create(null) as object, {
get: (_target, property) => {
if (typeof property !== "string") return undefined;
return this.#slot(property).value;
},
});
}
install(snapshot: ServiceInstanceSnapshot, context: Context): void {
if (!sameAddress(snapshot.instance, this.#address))
throw new Error("Remote service snapshot has the wrong address");
const descriptions = validateMemberDescriptions(snapshot.members);
for (const name of this.#slots.keys()) {
if (!descriptions.has(name)) {
throw new RemoteServiceError(
"service_member_not_found",
`Unknown remote service member ${this.#serviceId}.${name}`,
);
}
}
for (const [name, kind] of descriptions) {
if ((kind === "state") !== Object.hasOwn(snapshot.states, name)) {
throw new Error(`Remote service snapshot has invalid state metadata for ${this.#serviceId}.${name}`);
}
}
this.#descriptions.clear();
for (const [name, kind] of descriptions) {
this.#descriptions.set(name, kind);
this.#slots.get(name)?.setDescription(kind);
}
this.#stateSnapshots.clear();
for (const [name, state] of Object.entries(snapshot.states)) {
this.#stateSnapshots.set(name, state);
this.#slot(name).hydrate(state, context);
}
}
update(member: string, sequence: number, value: JsonValue, context: Context): void {
if (this.#descriptions.get(member) !== "state") {
throw new Error(`Remote service update targets non-state member ${this.#serviceId}.${member}`);
}
const snapshot = { sequence, value };
this.#stateSnapshots.set(member, snapshot);
this.#slot(member).update(sequence, value, context);
}
clear(): void {
for (const slot of this.#slots.values()) slot.clear();
this.#stateSnapshots.clear();
}
#slot(member: string): MemberSlot {
let slot = this.#slots.get(member);
if (slot !== undefined) return slot;
slot = new MemberSlot(
this.#serviceId,
member,
(args, context) =>
this.#connection.invoke(
{
serviceId: this.#serviceId,
...(this.#address === undefined ? {} : { instance: this.#address }),
member,
args,
},
context,
),
this.#isActive,
this.#reportError,
);
const kind = this.#descriptions.get(member);
if (kind !== undefined) slot.setDescription(kind);
const state = this.#stateSnapshots.get(member);
if (state !== undefined) slot.hydrate(state, freshDeliveryContext());
this.#slots.set(member, slot);
return slot;
}
}
interface SingletonBinding {
facade: ServiceFacade;
subscription?: RemoteServiceSubscription;
active: boolean;
revision: number;
}
interface KeyedInstance {
readonly address: ServiceInstanceAddress;
facade: ServiceFacade;
active: boolean;
}
interface ObserverTask {
readonly cancel: (reason?: unknown) => void;
}
interface ObserverRegistration<T> {
readonly handler: (instance: RemoteServiceInstance<T>, context: Context) => void | Promise<void>;
readonly tasks: Map<string, ObserverTask>;
closed: boolean;
}
class KeyedBinding<T> {
readonly #service: RemoteService<T>;
readonly #connection: RemoteServiceConnection;
readonly #reportError: ErrorReporter;
readonly #onEmpty: () => void;
readonly #instances = new Map<string, KeyedInstance>();
readonly #observers = new Set<ObserverRegistration<T>>();
#subscription: RemoteServiceSubscription | undefined;
#starting: Promise<void> | undefined;
#closed = false;
#bound: boolean;
#hydrated = false;
#revision = 0;
constructor(
service: RemoteService<T>,
connection: RemoteServiceConnection,
reportError: ErrorReporter,
onEmpty: () => void,
bound: boolean,
) {
this.#service = service;
this.#connection = connection;
this.#reportError = reportError;
this.#onEmpty = onEmpty;
this.#bound = bound;
}
observe(handler: ObserverRegistration<T>["handler"]): () => void {
if (this.#closed) throw new Error("Remote keyed service binding is closed");
const observer: ObserverRegistration<T> = { handler, tasks: new Map(), closed: false };
this.#observers.add(observer);
if (this.#hydrated) {
for (const instance of this.#instances.values()) this.#startTask(observer, instance);
}
if (this.#bound) {
this.#starting ??= this.#start(this.#revision);
void this.#starting.catch(this.#reportError);
}
return () => {
if (observer.closed) return;
observer.closed = true;
for (const task of observer.tasks.values()) task.cancel();
observer.tasks.clear();
this.#observers.delete(observer);
if (this.#observers.size === 0) this.#onEmpty();
};
}
async rebind(bound: boolean, context: Context): Promise<void> {
if (this.#closed) return;
this.#bound = bound;
this.#revision += 1;
await this.#reset(context);
if (bound && this.#observers.size > 0) {
this.#starting = this.#start(this.#revision);
void this.#starting.catch(this.#reportError);
}
}
async close(context: Context): Promise<void> {
if (this.#closed) return;
this.#closed = true;
this.#revision += 1;
await this.#reset(context);
for (const observer of this.#observers) observer.closed = true;
this.#observers.clear();
}
async #reset(context: Context): Promise<void> {
for (const observer of this.#observers) {
for (const task of observer.tasks.values()) task.cancel();
observer.tasks.clear();
}
for (const instance of this.#instances.values()) {
instance.active = false;
instance.facade.clear();
}
this.#instances.clear();
this.#hydrated = false;
this.#starting = undefined;
const subscription = this.#subscription;
this.#subscription = undefined;
await subscription?.close(context);
}
async #start(revision: number): Promise<void> {
const subscription = await this.#connection.subscribe(
this.#service.id,
"keyed",
(update, context) => {
if (this.#revision === revision) this.#update(update, context);
},
BACKGROUND_CONTEXT,
);
if (this.#closed || !this.#bound || this.#revision !== revision) {
await subscription.close(BACKGROUND_CONTEXT);
return;
}
this.#subscription = subscription;
if (subscription.snapshot.mode !== "keyed" || subscription.snapshot.serviceId !== this.#service.id) {
throw new Error(`Remote service ${this.#service.id} returned the wrong keyed snapshot`);
}
for (const snapshot of subscription.snapshot.instances) this.#spawn(snapshot, freshDeliveryContext(), false);
this.#hydrated = true;
for (const observer of this.#observers) {
for (const instance of this.#instances.values()) this.#startTask(observer, instance);
}
subscription.activate();
}
#update(update: ServiceProviderUpdate, context: Context): void {
if (this.#closed) return;
try {
switch (update.type) {
case "spawned":
this.#spawn(update.instance, context, true);
break;
case "closed":
this.#closeInstance(update.instance);
break;
case "state": {
if (update.instance === undefined) throw new Error("Keyed state update has no instance address");
const instance = this.#instances.get(update.instance.key);
if (instance?.address.generation !== update.instance.generation) return;
instance.facade.update(update.member, update.sequence, update.value, context);
break;
}
}
} catch (error) {
this.#reportError(toError(error));
}
}
#spawn(snapshot: ServiceInstanceSnapshot, context: Context, startTasks: boolean): void {
const address = snapshot.instance;
if (address === undefined) throw new Error("Keyed service instance snapshot has no address");
const previous = this.#instances.get(address.key);
if (previous !== undefined) {
if (previous.address.generation === address.generation)
throw new Error("Keyed service repeated a live generation");
this.#closeInstance(previous.address);
}
const instance: KeyedInstance = {
address,
active: true,
facade: undefined as unknown as ServiceFacade,
};
instance.facade = new ServiceFacade(
this.#service.id,
address,
this.#connection,
() => instance.active && !this.#closed,
this.#reportError,
);
instance.facade.install(snapshot, context);
this.#instances.set(address.key, instance);
if (startTasks && this.#hydrated) {
for (const observer of this.#observers) this.#startTask(observer, instance);
}
}
#closeInstance(address: ServiceInstanceAddress): void {
const instance = this.#instances.get(address.key);
if (instance?.address.generation !== address.generation) return;
instance.active = false;
instance.facade.clear();
this.#instances.delete(address.key);
const taskKey = instanceTaskKey(address);
for (const observer of this.#observers) {
observer.tasks.get(taskKey)?.cancel();
observer.tasks.delete(taskKey);
}
}
#startTask(observer: ObserverRegistration<T>, instance: KeyedInstance): void {
if (observer.closed || !instance.active) return;
const key = instanceTaskKey(instance.address);
if (observer.tasks.has(key)) return;
const { context, cancel } = withCancel(BACKGROUND_CONTEXT);
observer.tasks.set(key, { cancel });
const exposed = { key: instance.address.key, service: instance.facade.proxy as T };
try {
void Promise.resolve(observer.handler(exposed, context)).catch((error: unknown) => {
if (!context.abortSignal?.aborted) this.#reportError(toError(error));
});
} catch (error) {
if (!context.abortSignal?.aborted) this.#reportError(toError(error));
}
}
}
export class RemoteServiceNamespace implements RemoteServiceNamespaceApi {
readonly #connection: RemoteServiceConnection;
readonly #allowlist: ReadonlySet<string>;
readonly #reportError: ErrorReporter;
readonly #modes = new Map<string, "singleton" | "keyed">();
readonly #singletons = new Map<string, SingletonBinding>();
readonly #keyed = new Map<string, KeyedBinding<unknown>>();
#bound: boolean;
#disposed = false;
constructor(options: RemoteServiceNamespaceOptions) {
this.#connection = options.connection;
const ids = options.services.map(({ id }) => id);
if (new Set(ids).size !== ids.length) throw new TypeError("Remote service namespace has duplicate service IDs");
this.#allowlist = new Set(ids);
this.#reportError = options.onError ?? (() => {});
this.#bound = options.bound ?? true;
}
use<T>(service: RemoteService<T>): T {
this.#assertAvailable(service.id, "singleton");
let binding = this.#singletons.get(service.id);
if (binding !== undefined) return binding.facade.proxy as T;
binding = { facade: undefined as unknown as ServiceFacade, active: true, revision: 0 };
binding.facade = new ServiceFacade(
service.id,
undefined,
this.#connection,
() => binding!.active && !this.#disposed,
this.#reportError,
);
this.#singletons.set(service.id, binding);
if (this.#bound) void this.#startSingleton(service.id, binding, binding.revision).catch(this.#reportError);
return binding.facade.proxy as T;
}
observe<T>(
service: RemoteService<T>,
handler: (instance: RemoteServiceInstance<T>, context: Context) => void | Promise<void>,
): () => void {
this.#assertAvailable(service.id, "keyed");
let binding = this.#keyed.get(service.id) as KeyedBinding<T> | undefined;
if (binding === undefined) {
binding = new KeyedBinding(
service,
this.#connection,
this.#reportError,
() => {
if (this.#keyed.get(service.id) !== binding) return;
this.#keyed.delete(service.id);
void binding!.close(BACKGROUND_CONTEXT).catch(this.#reportError);
},
this.#bound,
);
this.#keyed.set(service.id, binding as KeyedBinding<unknown>);
}
return binding.observe(handler);
}
async rebind(bound: boolean, context: Context): Promise<void> {
if (this.#disposed) throw new Error("Remote service namespace is disposed");
this.#bound = bound;
const transitions: Promise<void>[] = [];
for (const [serviceId, binding] of this.#singletons) {
binding.revision += 1;
binding.facade.clear();
const subscription = binding.subscription;
delete binding.subscription;
const revision = binding.revision;
transitions.push(
(async () => {
await subscription?.close(context);
if (bound) await this.#startSingleton(serviceId, binding, revision);
})(),
);
}
for (const binding of this.#keyed.values()) transitions.push(binding.rebind(bound, context));
const results = await Promise.allSettled(transitions);
const errors = results.filter((result): result is PromiseRejectedResult => result.status === "rejected");
if (errors.length > 0)
throw new AggregateError(
errors.map(({ reason }) => reason),
"Failed to rebind services",
);
}
async dispose(context: Context): Promise<void> {
if (this.#disposed) return;
this.#disposed = true;
const closes: Promise<void>[] = [];
for (const binding of this.#singletons.values()) {
binding.active = false;
binding.facade.clear();
if (binding.subscription) closes.push(Promise.resolve(binding.subscription.close(context)));
}
for (const binding of this.#keyed.values()) closes.push(binding.close(context));
this.#singletons.clear();
this.#keyed.clear();
const results = await Promise.allSettled(closes);
const errors = results.filter((result): result is PromiseRejectedResult => result.status === "rejected");
if (errors.length > 0)
throw new AggregateError(
errors.map(({ reason }) => reason),
"Failed to dispose services",
);
}
async #startSingleton(serviceId: string, binding: SingletonBinding, revision: number): Promise<void> {
const subscription = await this.#connection.subscribe(
serviceId,
"singleton",
(update, context) => {
if (
!binding.active ||
binding.revision !== revision ||
update.type !== "state" ||
update.instance !== undefined
) {
return;
}
try {
binding.facade.update(update.member, update.sequence, update.value, context);
} catch (error) {
this.#reportError(toError(error));
}
},
BACKGROUND_CONTEXT,
);
if (!binding.active || this.#disposed || !this.#bound || binding.revision !== revision) {
await subscription.close(BACKGROUND_CONTEXT);
return;
}
binding.subscription = subscription;
const snapshot = subscription.snapshot;
if (snapshot.mode !== "singleton" || snapshot.serviceId !== serviceId || snapshot.instances.length !== 1) {
throw new Error(`Remote service ${serviceId} returned an invalid singleton snapshot`);
}
binding.facade.install(snapshot.instances[0]!, freshDeliveryContext());
subscription.activate();
}
#assertAvailable(serviceId: string, mode: "singleton" | "keyed"): void {
if (this.#disposed) throw new Error("Remote service namespace is disposed");
if (!this.#allowlist.has(serviceId)) {
throw new RemoteServiceError("service_not_allowed", `Remote service ${serviceId} is not allowlisted`);
}
const existing = this.#modes.get(serviceId);
if (existing !== undefined && existing !== mode) {
throw new RemoteServiceError(
"service_mode_mismatch",
`Remote service ${serviceId} is already used as ${existing}`,
);
}
this.#modes.set(serviceId, mode);
}
}
function validateMemberDescriptions(
descriptions: readonly ServiceMemberDescription[],
): ReadonlyMap<string, ServiceMemberKind> {
const result = new Map<string, ServiceMemberKind>();
for (const { name, kind } of descriptions) {
if (name.length === 0 || result.has(name)) throw new Error("Remote service has invalid member descriptions");
result.set(name, kind);
}
return result;
}
function sameAddress(left: ServiceInstanceAddress | undefined, right: ServiceInstanceAddress | undefined): boolean {
if (left === undefined || right === undefined) return left === right;
return left.key === right.key && left.generation === right.generation;
}
function instanceTaskKey(address: ServiceInstanceAddress): string {
return `${address.key}\0${address.generation}`;
}
function isContext(value: unknown): value is Context {
if (typeof value !== "object" || value === null) return false;
const candidate = value as Partial<Context>;
return typeof candidate.value === "function" && typeof candidate.toString === "function";
}
function toError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}

View file

@ -0,0 +1,392 @@
import type { Context } from "../../harness/context.ts";
import type { JsonValue } from "../../harness/session/types.ts";
import { freshDeliveryContext, getRemoteStateInternals, type RemoteStateInternals } from "./state.ts";
import {
cloneJson,
isJsonValue,
type RemoteService,
type RemoteServiceConnection,
type ServiceCall,
type ServiceInstanceAddress,
type ServiceInstanceSnapshot,
type ServiceMemberDescription,
type ServiceMode,
type ServiceProviderSubscription,
type ServiceProviderUpdate,
type ServiceSubscriptionSnapshot,
} from "./types.ts";
export type RemoteServiceErrorCode =
| "service_not_allowed"
| "service_not_found"
| "service_mode_mismatch"
| "service_member_not_found"
| "service_member_mismatch"
| "service_instance_not_found"
| "service_stale_instance"
| "service_invalid_value";
export class RemoteServiceError extends Error {
readonly code: RemoteServiceErrorCode;
constructor(code: RemoteServiceErrorCode, message: string) {
super(message);
this.name = "RemoteServiceError";
this.code = code;
}
}
type RemoteMethod = (...args: unknown[]) => unknown;
type InstanceMember =
| { readonly kind: "method"; readonly method: RemoteMethod }
| { readonly kind: "state"; readonly state: RemoteStateInternals };
interface ProviderInstance {
readonly address?: ServiceInstanceAddress;
readonly implementation: object;
readonly members: ReadonlyMap<string, InstanceMember>;
readonly removeStateListeners: readonly (() => void)[];
active: boolean;
}
interface ProviderSubscriber {
readonly listener: (update: ServiceProviderUpdate, context: Context) => void;
readonly buffer: { readonly update: ServiceProviderUpdate; readonly context: Context }[];
active: boolean;
closed: boolean;
}
interface ServiceRegistration {
readonly serviceId: string;
readonly mode: ServiceMode;
singleton?: ProviderInstance;
readonly instances: Map<string, ProviderInstance>;
readonly generations: Map<string, number>;
readonly subscribers: Set<ProviderSubscriber>;
}
export class RemoteServiceProvider {
readonly #allowlist: ReadonlySet<string>;
readonly #registrations = new Map<string, ServiceRegistration>();
#disposed = false;
constructor(services: readonly { readonly id: string }[]) {
const ids = services.map(({ id }) => id);
if (new Set(ids).size !== ids.length) throw new TypeError("Remote service allowlist contains duplicate IDs");
this.#allowlist = new Set(ids);
}
provide<T>(service: RemoteService<T>, implementation: T): void {
this.#assertActive();
this.#assertAllowed(service.id);
const registration = this.#registration(service.id, "singleton", true);
if (registration.singleton !== undefined) {
throw new RemoteServiceError("service_mode_mismatch", `Remote service ${service.id} already has a provider`);
}
const instance = this.#classifyInstance(registration, implementation, undefined);
registration.singleton = instance;
}
use<T>(service: RemoteService<T>): T {
this.#assertActive();
this.#assertAllowed(service.id);
const registration = this.#registrations.get(service.id);
if (registration?.mode !== "singleton" || registration.singleton === undefined) {
throw new RemoteServiceError("service_not_found", `Remote service ${service.id} has no local provider`);
}
return registration.singleton.implementation as T;
}
spawn<T>(service: RemoteService<T>, key: string, implementation: T): () => void {
this.#assertActive();
this.#assertAllowed(service.id);
if (key.length === 0) throw new TypeError("Remote service instance key must not be empty");
const registration = this.#registration(service.id, "keyed", true);
if (registration.instances.has(key)) {
throw new RemoteServiceError(
"service_mode_mismatch",
`Remote service ${service.id} already has a live instance with key ${key}`,
);
}
const generation = (registration.generations.get(key) ?? 0) + 1;
registration.generations.set(key, generation);
const address = { key, generation } satisfies ServiceInstanceAddress;
const instance = this.#classifyInstance(registration, implementation, address);
registration.instances.set(key, instance);
this.#emit(registration, { type: "spawned", instance: this.#snapshotInstance(instance) });
let closed = false;
return () => {
if (closed) return;
closed = true;
if (registration.instances.get(key) !== instance) return;
instance.active = false;
for (const remove of instance.removeStateListeners) remove();
registration.instances.delete(key);
this.#emit(registration, { type: "closed", instance: address });
};
}
async invoke(call: ServiceCall, context: Context): Promise<JsonValue | undefined> {
this.#assertActive();
this.#assertAllowed(call.serviceId);
if (!call.args.every(isJsonValue)) {
throw new RemoteServiceError("service_invalid_value", "Remote service arguments must be strict JSON");
}
const registration = this.#registrations.get(call.serviceId);
if (registration === undefined) {
throw new RemoteServiceError("service_not_found", `Unknown remote service ${call.serviceId}`);
}
const instance = this.#resolveInstance(registration, call.instance);
const member = instance.members.get(call.member);
if (member === undefined) {
throw new RemoteServiceError(
"service_member_not_found",
`Unknown remote service member ${call.serviceId}.${call.member}`,
);
}
if (member.kind !== "method") {
throw new RemoteServiceError(
"service_member_mismatch",
`Remote service member ${call.serviceId}.${call.member} is not a method`,
);
}
const args = call.args.map((value) => cloneJson(value));
const result: unknown = await Reflect.apply(member.method, instance.implementation, [...args, context]);
if (result === undefined) return undefined;
if (!isJsonValue(result)) {
throw new RemoteServiceError("service_invalid_value", "Remote service result must be strict JSON or void");
}
return cloneJson(result);
}
subscribe(
serviceId: string,
mode: ServiceMode,
listener: (update: ServiceProviderUpdate, context: Context) => void,
): ServiceProviderSubscription {
this.#assertActive();
this.#assertAllowed(serviceId);
const registration = this.#registration(serviceId, mode, mode === "keyed");
if (registration.mode === "singleton" && registration.singleton === undefined) {
throw new RemoteServiceError("service_not_found", `Remote service ${serviceId} has no provider`);
}
const subscriber: ProviderSubscriber = { listener, buffer: [], active: false, closed: false };
registration.subscribers.add(subscriber);
const snapshot = this.#snapshot(registration);
return {
snapshot,
activate: () => {
if (subscriber.closed || subscriber.active) return;
subscriber.active = true;
for (const entry of subscriber.buffer.splice(0)) listener(entry.update, entry.context);
},
close: () => {
if (subscriber.closed) return;
subscriber.closed = true;
subscriber.buffer.length = 0;
registration.subscribers.delete(subscriber);
},
};
}
dispose(): void {
if (this.#disposed) return;
this.#disposed = true;
for (const registration of this.#registrations.values()) {
for (const subscriber of registration.subscribers) {
subscriber.closed = true;
subscriber.buffer.length = 0;
}
registration.subscribers.clear();
const instances = [registration.singleton, ...registration.instances.values()];
for (const instance of instances) {
if (!instance) continue;
instance.active = false;
for (const remove of instance.removeStateListeners) remove();
}
registration.instances.clear();
delete registration.singleton;
}
this.#registrations.clear();
}
#registration(serviceId: string, mode: ServiceMode, create: boolean): ServiceRegistration {
const existing = this.#registrations.get(serviceId);
if (existing !== undefined) {
if (existing.mode !== mode) {
throw new RemoteServiceError(
"service_mode_mismatch",
`Remote service ${serviceId} is ${existing.mode}, not ${mode}`,
);
}
return existing;
}
if (!create) throw new RemoteServiceError("service_not_found", `Unknown remote service ${serviceId}`);
const registration: ServiceRegistration = {
serviceId,
mode,
instances: new Map(),
generations: new Map(),
subscribers: new Set(),
};
this.#registrations.set(serviceId, registration);
return registration;
}
#classifyInstance(
registration: ServiceRegistration,
implementation: unknown,
address: ServiceInstanceAddress | undefined,
): ProviderInstance {
if (typeof implementation !== "object" || implementation === null || Array.isArray(implementation)) {
throw new TypeError(`Remote service ${registration.serviceId} implementation must be an object`);
}
const members = new Map<string, InstanceMember>();
const removeStateListeners: (() => void)[] = [];
const instance: ProviderInstance = {
...(address === undefined ? {} : { address }),
implementation,
members,
removeStateListeners,
active: true,
};
for (const name of Object.keys(implementation).sort()) {
const descriptor = Object.getOwnPropertyDescriptor(implementation, name);
if (descriptor === undefined || !("value" in descriptor)) {
throw new TypeError(`Remote service member ${registration.serviceId}.${name} must be a data property`);
}
if (typeof descriptor.value === "function") {
members.set(name, { kind: "method", method: descriptor.value as RemoteMethod });
continue;
}
const state = getRemoteStateInternals(descriptor.value);
if (state === undefined) {
throw new TypeError(`Remote service member ${registration.serviceId}.${name} is not remotely exposable`);
}
members.set(name, { kind: "state", state });
removeStateListeners.push(
state.subscribe((value, sequence, context) => {
if (!instance.active) return;
if (!isJsonValue(value)) {
throw new RemoteServiceError("service_invalid_value", "Remote state update must be strict JSON");
}
this.#emit(
registration,
{
type: "state",
...(address === undefined ? {} : { instance: address }),
member: name,
sequence,
value,
},
context,
);
}),
);
}
if (members.size === 0) throw new TypeError(`Remote service ${registration.serviceId} has no members`);
return instance;
}
#resolveInstance(registration: ServiceRegistration, address: ServiceInstanceAddress | undefined): ProviderInstance {
if (registration.mode === "singleton") {
if (address !== undefined) {
throw new RemoteServiceError(
"service_mode_mismatch",
`Remote service ${registration.serviceId} is singleton`,
);
}
if (registration.singleton === undefined) {
throw new RemoteServiceError(
"service_not_found",
`Remote service ${registration.serviceId} has no provider`,
);
}
return registration.singleton;
}
if (address === undefined) {
throw new RemoteServiceError("service_mode_mismatch", `Remote service ${registration.serviceId} is keyed`);
}
const instance = registration.instances.get(address.key);
if (instance === undefined) {
throw new RemoteServiceError(
"service_instance_not_found",
`Remote service ${registration.serviceId} has no instance ${address.key}`,
);
}
if (instance.address?.generation !== address.generation) {
throw new RemoteServiceError(
"service_stale_instance",
`Remote service ${registration.serviceId} instance ${address.key} is stale`,
);
}
return instance;
}
#snapshot(registration: ServiceRegistration): ServiceSubscriptionSnapshot {
const instances =
registration.mode === "singleton"
? registration.singleton
? [this.#snapshotInstance(registration.singleton)]
: []
: [...registration.instances.values()]
.sort((left, right) => left.address!.key.localeCompare(right.address!.key))
.map((instance) => this.#snapshotInstance(instance));
return { serviceId: registration.serviceId, mode: registration.mode, instances };
}
#snapshotInstance(instance: ProviderInstance): ServiceInstanceSnapshot {
const members: ServiceMemberDescription[] = [];
const states: Record<string, { sequence: number; value: JsonValue }> = {};
for (const [name, member] of instance.members) {
members.push({ name, kind: member.kind });
if (member.kind === "state") {
if (!isJsonValue(member.state.value)) {
throw new RemoteServiceError("service_invalid_value", "Remote state snapshot must be strict JSON");
}
states[name] = { sequence: member.state.sequence, value: member.state.value };
}
}
return {
...(instance.address === undefined ? {} : { instance: instance.address }),
members,
states,
};
}
#emit(registration: ServiceRegistration, update: ServiceProviderUpdate, context?: Context): void {
if (registration.subscribers.size === 0) return;
const deliveryContext = context ?? freshDeliveryContext();
for (const subscriber of registration.subscribers) {
if (subscriber.closed) continue;
const entry = { update, context: deliveryContext };
if (subscriber.active) subscriber.listener(entry.update, entry.context);
else subscriber.buffer.push(entry);
}
}
#assertAllowed(serviceId: string): void {
if (!this.#allowlist.has(serviceId)) {
throw new RemoteServiceError("service_not_allowed", `Remote service ${serviceId} is not allowlisted`);
}
}
#assertActive(): void {
if (this.#disposed) throw new Error("Remote service provider is disposed");
}
}
export function createLoopbackServiceConnection(provider: RemoteServiceProvider): RemoteServiceConnection {
return {
invoke: (call, context) => provider.invoke(call, context),
subscribe: async (serviceId, mode, listener) => {
const subscription = provider.subscribe(serviceId, mode, (update) => listener(update, freshDeliveryContext()));
return {
snapshot: subscription.snapshot,
activate: () => subscription.activate(),
close: () => subscription.close(),
};
},
};
}

View file

@ -0,0 +1,82 @@
import { BACKGROUND_CONTEXT, type Context, withCancel } from "../../harness/context.ts";
import { isJsonValue, type MutableRemoteState, type RemoteState } from "./types.ts";
export const REMOTE_STATE_INTERNALS = Symbol("pi.remoteState.internals");
export interface RemoteStateInternals<T = unknown> {
readonly sequence: number;
readonly value: T;
subscribe(listener: (value: T, sequence: number, context: Context) => void): () => void;
}
export interface RemoteStateSource<T = unknown> extends MutableRemoteState<T> {
readonly [REMOTE_STATE_INTERNALS]: RemoteStateInternals<T>;
}
class MutableRemoteStateImpl<T> implements RemoteStateSource<T> {
readonly #listeners = new Set<(value: T, context: Context) => void>();
readonly #sourceListeners = new Set<(value: T, sequence: number, context: Context) => void>();
#value: T;
#sequence = 0;
constructor(initial: T) {
assertStateValue(initial);
this.#value = initial;
}
get value(): T {
return this.#value;
}
set(value: T, context: Context): void {
assertStateValue(value);
this.#value = value;
this.#sequence += 1;
for (const listener of this.#sourceListeners) listener(value, this.#sequence, context);
for (const listener of this.#listeners) listener(value, context);
}
subscribe(listener: (value: T, context: Context) => void): () => void {
this.#listeners.add(listener);
listener(this.value, freshDeliveryContext());
return () => this.#listeners.delete(listener);
}
get [REMOTE_STATE_INTERNALS](): RemoteStateInternals<T> {
const source = this;
return {
get sequence() {
return source.#sequence;
},
get value() {
return source.value;
},
subscribe: (listener) => {
this.#sourceListeners.add(listener);
return () => this.#sourceListeners.delete(listener);
},
};
}
}
export function remoteState<T>(initial: T): MutableRemoteState<T> {
return new MutableRemoteStateImpl(initial);
}
export function getRemoteStateInternals(value: unknown): RemoteStateInternals | undefined {
if (typeof value !== "object" || value === null || !(REMOTE_STATE_INTERNALS in value)) return undefined;
const candidate = value as { readonly [REMOTE_STATE_INTERNALS]?: unknown };
const internals = candidate[REMOTE_STATE_INTERNALS];
if (typeof internals !== "object" || internals === null) return undefined;
return internals as RemoteStateInternals;
}
export function freshDeliveryContext(): Context {
return withCancel(BACKGROUND_CONTEXT).context;
}
function assertStateValue(value: unknown): void {
if (!isJsonValue(value)) throw new TypeError("Remote service value must be strict JSON");
}
export type { RemoteState };

View file

@ -0,0 +1,191 @@
import type { Context } from "../../harness/context.ts";
import type { JsonValue } from "../../harness/session/types.ts";
const REMOTE_SERVICE_TYPE = Symbol("pi.remoteService.type");
export type ServiceMode = "singleton" | "keyed";
export type ServiceMemberKind = "method" | "state";
export interface RemoteState<T> {
/** Borrowed immutable value, or undefined until hydration. Do not mutate or retain it. */
readonly value: T | undefined;
/** Listener values are borrowed and must not be mutated or retained. */
subscribe(listener: (value: T, context: Context) => void): () => void;
}
export interface MutableRemoteState<T> extends RemoteState<T> {
readonly value: T;
/** Transfers the JSON value to the state; the caller must not subsequently mutate it. */
set(value: T, context: Context): void;
}
type JsonPrimitive = null | boolean | number | string;
type InvalidJsonPart<T> = T extends JsonPrimitive
? never
: T extends readonly (infer TItem)[]
? InvalidJsonPart<TItem>
: T extends (...args: never[]) => unknown
? T
: T extends object
? { [TKey in keyof T]-?: InvalidJsonPart<T[TKey]> }[keyof T]
: T;
type InvalidRemoteMember<T> = T extends RemoteState<infer TValue>
? InvalidJsonPart<TValue> extends never
? never
: "state value is not JSON"
: T extends (...args: [...infer TArgs, Context]) => Promise<infer TResult>
? InvalidJsonPart<TArgs[number]> extends never
? TResult extends void
? never
: InvalidJsonPart<TResult> extends never
? never
: "method result is not JSON or void"
: "method argument is not JSON"
: "member is not a remote method or RemoteState";
type InvalidRemoteMemberNames<T> = {
[TKey in keyof T]-?: InvalidRemoteMember<T[TKey]> extends never ? never : TKey;
}[keyof T];
type CheckedRemoteContract<T> = InvalidRemoteMemberNames<T> extends never ? T : never;
/** Stable identity for one shared TypeScript service contract. */
export interface RemoteService<T> {
readonly id: string;
readonly [REMOTE_SERVICE_TYPE]?: (value: T) => T;
}
export type RemoteServiceType<TService> = TService extends RemoteService<infer T> ? T : never;
export function defineRemoteService<T>(id: string): RemoteService<CheckedRemoteContract<T>> {
if (id.length === 0) throw new TypeError("Remote service ID must not be empty");
if (id.startsWith("$pi.")) throw new TypeError("Remote service IDs beginning with $pi. are reserved");
return Object.freeze({ id });
}
export interface ServiceInstanceAddress {
readonly key: string;
readonly generation: number;
}
export interface ServiceMemberDescription {
readonly name: string;
readonly kind: ServiceMemberKind;
}
export interface ServiceStateSnapshot {
readonly sequence: number;
readonly value: JsonValue;
}
export interface ServiceInstanceSnapshot {
readonly instance?: ServiceInstanceAddress;
readonly members: readonly ServiceMemberDescription[];
readonly states: Readonly<Record<string, ServiceStateSnapshot>>;
}
export interface ServiceSubscriptionSnapshot {
readonly serviceId: string;
readonly mode: ServiceMode;
readonly instances: readonly ServiceInstanceSnapshot[];
}
export type ServiceProviderUpdate =
| {
readonly type: "state";
readonly instance?: ServiceInstanceAddress;
readonly member: string;
readonly sequence: number;
readonly value: JsonValue;
}
| { readonly type: "spawned"; readonly instance: ServiceInstanceSnapshot }
| { readonly type: "closed"; readonly instance: ServiceInstanceAddress };
export interface ServiceCall {
readonly serviceId: string;
readonly instance?: ServiceInstanceAddress;
readonly member: string;
readonly args: readonly JsonValue[];
}
export interface ServiceProviderSubscription {
readonly snapshot: ServiceSubscriptionSnapshot;
activate(): void;
close(): void;
}
export interface RemoteServiceSubscription {
readonly snapshot: ServiceSubscriptionSnapshot;
activate(): void;
close(context: Context): void | Promise<void>;
}
/** Transport-neutral connection consumed by a remote service namespace. */
export interface RemoteServiceConnection {
invoke(call: ServiceCall, context: Context): Promise<JsonValue | undefined>;
subscribe(
serviceId: string,
mode: ServiceMode,
listener: (update: ServiceProviderUpdate, context: Context) => void,
context: Context,
): Promise<RemoteServiceSubscription>;
}
export interface RemoteServiceInstance<T> {
readonly key: string;
readonly service: T;
}
export interface RemoteServiceNamespaceOptions {
readonly services: readonly { readonly id: string }[];
readonly connection: RemoteServiceConnection;
readonly bound?: boolean;
readonly onError?: (error: Error) => void;
}
export interface RemoteServiceNamespaceApi {
use<T>(service: RemoteService<T>): T;
observe<T>(
service: RemoteService<T>,
handler: (instance: RemoteServiceInstance<T>, context: Context) => void | Promise<void>,
): () => void;
dispose(context: Context): Promise<void>;
}
export function isJsonValue(value: unknown): value is JsonValue {
return checkJsonValue(value, new Set<object>());
}
function checkJsonValue(value: unknown, ancestors: Set<object>): value is JsonValue {
if (value === null || typeof value === "boolean" || typeof value === "string") return true;
if (typeof value === "number") return Number.isFinite(value);
if (typeof value !== "object" || ancestors.has(value)) return false;
if (!Array.isArray(value)) {
const prototype = Object.getPrototypeOf(value);
if (prototype !== Object.prototype && prototype !== null) return false;
if (
Object.getOwnPropertySymbols(value).some((symbol) => Object.prototype.propertyIsEnumerable.call(value, symbol))
) {
return false;
}
}
ancestors.add(value);
const valid = Array.isArray(value)
? checkJsonArray(value, ancestors)
: Object.values(value).every((item) => checkJsonValue(item, ancestors));
ancestors.delete(value);
return valid;
}
function checkJsonArray(value: unknown[], ancestors: Set<object>): value is JsonValue[] {
for (let index = 0; index < value.length; index++) {
if (!Object.hasOwn(value, index) || !checkJsonValue(value[index], ancestors)) return false;
}
return true;
}
export function cloneJson<T>(value: T): T {
if (!isJsonValue(value)) throw new TypeError("Remote service value must be strict JSON");
return structuredClone(value);
}

View file

@ -0,0 +1,224 @@
import { describe, expect, test, vi } from "vitest";
import type { Context } from "../../src/harness/context.ts";
import {
BACKGROUND_CONTEXT,
createLoopbackServiceConnection,
defineRemoteService,
type RemoteServiceConnection,
RemoteServiceNamespace,
RemoteServiceProvider,
type RemoteState,
remoteState,
} from "../../src/index.ts";
type ModelRef = { provider: string; modelId: string };
type ModelsState = {
selected: ModelRef | null;
revision: number;
};
interface ModelsService {
readonly state: RemoteState<ModelsState>;
select(model: ModelRef, context: Context): Promise<void>;
}
const Models = defineRemoteService<ModelsService>("models");
type Question = { question: string };
interface QuestionDialogService {
readonly request: RemoteState<Question>;
submit(answer: string, context: Context): Promise<{ accepted: boolean }>;
}
const QuestionDialogs = defineRemoteService<QuestionDialogService>("question-dialog");
describe("plugin remote services", () => {
test("does not defensively clone borrowed state values", () => {
const initial: ModelsState = { selected: null, revision: 0 };
const state = remoteState(initial);
let delivered: ModelsState | undefined;
const unsubscribe = state.subscribe((value) => {
delivered = value;
});
expect(state.value).toBe(initial);
expect(delivered).toBe(initial);
const next: ModelsState = { selected: { provider: "test", modelId: "one" }, revision: 1 };
state.set(next, BACKGROUND_CONTEXT);
expect(state.value).toBe(next);
expect(delivered).toBe(next);
unsubscribe();
});
test("provides and consumes one singleton with replicated state", async () => {
const provider = new RemoteServiceProvider([Models]);
const initialState: ModelsState = { selected: null, revision: 0 };
const state = remoteState(initialState);
let publishedState: ModelsState | undefined;
provider.provide(Models, {
state,
async select(model, context) {
publishedState = { selected: model, revision: state.value.revision + 1 };
state.set(publishedState, context);
},
});
const errors: Error[] = [];
const namespace = new RemoteServiceNamespace({
services: [Models],
connection: createLoopbackServiceConnection(provider),
onError: (error) => errors.push(error),
});
const first = namespace.use(Models);
const second = namespace.use(Models);
expect(first).toBe(second);
expect(first.state.value).toBeUndefined();
await vi.waitFor(() => expect(first.state.value).toBe(initialState));
const updates: ModelsState[] = [];
const unsubscribe = second.state.subscribe((value) => updates.push(value));
await first.select({ provider: "test", modelId: "one" }, BACKGROUND_CONTEXT);
expect(first.state.value).toBe(publishedState);
expect(first.state.value).toEqual({
selected: { provider: "test", modelId: "one" },
revision: 1,
});
expect(updates).toEqual([
{ selected: null, revision: 0 },
{ selected: { provider: "test", modelId: "one" }, revision: 1 },
]);
expect(errors).toEqual([]);
const lateNamespace = new RemoteServiceNamespace({
services: [Models],
connection: createLoopbackServiceConnection(provider),
});
const lateModels = lateNamespace.use(Models);
await vi.waitFor(() => expect(lateModels.state.value?.revision).toBe(1));
unsubscribe();
await Promise.all([namespace.dispose(BACKGROUND_CONTEXT), lateNamespace.dispose(BACKGROUND_CONTEXT)]);
provider.dispose();
});
test("buffers state updates that race subscription hydration", async () => {
const provider = new RemoteServiceProvider([Models]);
const state = remoteState<ModelsState>({ selected: null, revision: 0 });
provider.provide(Models, {
state,
async select() {},
});
const connection: RemoteServiceConnection = {
invoke: (call, context) => provider.invoke(call, context),
subscribe: async (serviceId, mode, listener) => {
const subscription = provider.subscribe(serviceId, mode, listener);
state.set({ selected: null, revision: 1 }, BACKGROUND_CONTEXT);
return {
snapshot: subscription.snapshot,
activate: () => subscription.activate(),
close: () => subscription.close(),
};
},
};
const namespace = new RemoteServiceNamespace({ services: [Models], connection });
const models = namespace.use(Models);
const revisions: number[] = [];
models.state.subscribe((value) => revisions.push(value.revision));
await vi.waitFor(() => expect(revisions).toEqual([0, 1]));
expect(models.state.value?.revision).toBe(1);
await namespace.dispose(BACKGROUND_CONTEXT);
provider.dispose();
});
test("hydrates keyed state before observe handlers and fences reused keys", async () => {
const provider = new RemoteServiceProvider([QuestionDialogs]);
const connection = createLoopbackServiceConnection(provider);
const errors: Error[] = [];
const namespace = new RemoteServiceNamespace({
services: [QuestionDialogs],
connection,
onError: (error) => errors.push(error),
});
const observed: {
key: string;
question: Question | undefined;
service: QuestionDialogService;
context: Context;
}[] = [];
const stop = namespace.observe(QuestionDialogs, (instance, context) => {
observed.push({
key: instance.key,
question: instance.service.request.value,
service: instance.service,
context,
});
});
await vi.waitFor(() => expect(errors).toEqual([]));
const firstRequest = remoteState<Question>({ question: "First?" });
const firstSubmit = vi.fn(async () => ({ accepted: true }));
const closeFirst = provider.spawn(QuestionDialogs, "invocation-1", {
request: firstRequest,
submit: firstSubmit,
});
await vi.waitFor(() => expect(observed).toHaveLength(1));
expect(observed[0]).toMatchObject({ key: "invocation-1", question: { question: "First?" } });
const firstService = observed[0]!.service;
firstRequest.set({ question: "Updated?" }, BACKGROUND_CONTEXT);
expect(firstService.request.value).toEqual({ question: "Updated?" });
await expect(firstService.submit("yes", BACKGROUND_CONTEXT)).resolves.toEqual({ accepted: true });
expect(firstSubmit).toHaveBeenCalledWith("yes", expect.objectContaining({ abortSignal: undefined }));
closeFirst();
expect(observed[0]!.context.abortSignal?.aborted).toBe(true);
await expect(firstService.submit("late", BACKGROUND_CONTEXT)).rejects.toMatchObject({
code: "service_stale_instance",
});
const secondRequest = remoteState<Question>({ question: "Again?" });
const closeSecond = provider.spawn(QuestionDialogs, "invocation-1", {
request: secondRequest,
async submit() {
return { accepted: false };
},
});
await vi.waitFor(() => expect(observed).toHaveLength(2));
expect(observed[1]).toMatchObject({ key: "invocation-1", question: { question: "Again?" } });
expect(observed[1]!.service).not.toBe(firstService);
expect(errors).toEqual([]);
closeSecond();
stop();
await namespace.dispose(BACKGROUND_CONTEXT);
provider.dispose();
});
test("rejects mode mixing, unsupported members, and non-JSON values", async () => {
const provider = new RemoteServiceProvider([Models, QuestionDialogs]);
provider.provide(Models, {
state: remoteState<ModelsState>({ selected: null, revision: 0 }),
async select() {},
});
expect(() => provider.spawn(Models, "wrong", {} as ModelsService)).toThrow(/singleton/);
expect(() =>
provider.spawn(QuestionDialogs, "invalid", {
request: new Date() as unknown as RemoteState<Question>,
async submit() {
return { accepted: true };
},
}),
).toThrow(/not remotely exposable/);
await expect(
provider.invoke(
{
serviceId: "models",
member: "select",
args: [Number.NaN as unknown as number],
},
BACKGROUND_CONTEXT,
),
).rejects.toMatchObject({ code: "service_invalid_value" });
provider.dispose();
});
});

View file

@ -37,6 +37,8 @@ The client verifies that the physical endpoint reports the expected logical `ser
`watchSession()` creates a main-lane watch and returns its authoritative snapshot without starting event delivery. Install the listener with `await watch.start(listener)`; this then flushes events buffered after the snapshot and continues with live events while `promptSession()` is pending. `watch.dispose()` stops server delivery and waits for already-received listener work. A disconnected watch is stale and cannot be reused after reconnection.
Plugin presentation hosts use `request()` and `subscribeService()` as low-level transport adapters. A service subscription returns a complete provider snapshot; the host installs it and then calls `start()` to release updates buffered during hydration. `PiClient` deliberately does not construct typed service proxies or interpret plugin contracts.
On disconnect or disposal, pending prompts reject locally, but accepted work may still complete remotely before the attachment is released. The client clears its live attachment route. It never reconnects or replays requests automatically. After disconnection, call `reconnect()`, attach again, and explicitly repeat only operations known to be safe.
The experimental local coordinator only provides a stable endpoint and relays traffic. Replaceable server processes own session and worker lifecycle outside the public client protocol.

View file

@ -1,6 +1,7 @@
import {
createRpcClient,
type EventEnvelope,
createServiceSubscribeCall,
createServiceUnsubscribeCall,
encodeClientMessage,
encodeServiceRpcCall,
isServerId,
@ -10,12 +11,17 @@ import {
type PromptMessage,
type ProtocolRpcCall,
ProtocolValidationError,
parseServiceSubscriptionSnapshot,
type ResponseEnvelope,
type RpcTarget,
type ServerEventEnvelope,
type ServerHello,
type ServiceMode,
type ServiceProviderUpdate,
ServiceRpc,
type ServiceRpcCall,
type ServiceRpcResult,
type ServiceSubscriptionSnapshot,
type SessionAddress,
type SessionCreateOptions,
type SessionSummary,
@ -24,7 +30,15 @@ import {
import { Connection } from "./connection.ts";
import { PiClientDisposedError, PiDisconnectedError, PiServerError, toError } from "./errors.ts";
import { createPromiseResolvers } from "./promise.ts";
import type { ConnectionState, ConnectionStateChange, PiClientOptions, PiLaneWatch, Unsubscribe } from "./types.ts";
import type {
AttachmentChangeListener,
ConnectionState,
ConnectionStateChange,
PiClientOptions,
PiLaneWatch,
PiServiceSubscription,
Unsubscribe,
} from "./types.ts";
interface PendingRequest {
resolve(result: unknown): void;
@ -37,14 +51,25 @@ interface ActiveWatchListener {
deliveryTail: Promise<void>;
}
interface ActiveServiceListener {
readonly target: RpcTarget;
readonly listener: (update: ServiceProviderUpdate) => void | Promise<void>;
readonly queued: ServiceProviderUpdate[];
deliveryTail: Promise<void>;
ready: boolean;
}
export class PiClient {
readonly #options: PiClientOptions;
readonly #connection: Connection;
readonly #pendingRequests = new Map<string, PendingRequest>();
readonly #connectionStateListeners = new Set<(change: ConnectionStateChange) => void>();
readonly #attachmentListeners = new Set<AttachmentChangeListener>();
readonly #watchListeners = new Map<string, ActiveWatchListener>();
readonly #serviceListeners = new Map<string, ActiveServiceListener>();
readonly #rpc: ReturnType<typeof createRpcClient<typeof ServiceRpc>>;
#requestSequence = 0;
#serviceSubscriptionSequence = 0;
#hello: ServerHello | undefined;
#attachment: SessionTarget | undefined;
#disposed = false;
@ -123,6 +148,12 @@ export class PiClient {
return () => this.#connectionStateListeners.delete(listener);
}
onAttachmentChange(listener: AttachmentChangeListener): Unsubscribe {
this.#assertNotDisposed();
this.#attachmentListeners.add(listener);
return () => this.#attachmentListeners.delete(listener);
}
/** Invoke one low-level protocol call against an explicit routed target. */
request(target: RpcTarget, call: ProtocolRpcCall, signal?: AbortSignal): Promise<unknown> {
return this.#request(target, call, signal);
@ -142,7 +173,7 @@ export class PiClient {
throw new PiServerError({ code: "wrong_server", message: "Session belongs to another server" });
}
const attached = await this.#rpc.attach(sessionId);
this.#attachment = { serverId: this.#options.serverId, ...attached };
this.#setAttachment({ serverId: this.#options.serverId, ...attached });
return attached;
}
@ -162,6 +193,69 @@ export class PiClient {
return this.#rpc.prompt([message]);
}
async subscribeService(
target: RpcTarget,
serviceId: string,
mode: ServiceMode,
listener: (update: ServiceProviderUpdate) => void | Promise<void>,
signal?: AbortSignal,
): Promise<PiServiceSubscription> {
const subscriptionId = `service-${++this.#serviceSubscriptionSequence}`;
const active: ActiveServiceListener = {
target,
listener,
queued: [],
deliveryTail: Promise.resolve(),
ready: false,
};
this.#serviceListeners.set(subscriptionId, active);
let snapshot: ServiceSubscriptionSnapshot;
try {
const result = await this.#request(
target,
createServiceSubscribeCall(subscriptionId, serviceId, mode),
signal,
);
try {
snapshot = parseServiceSubscriptionSnapshot(result);
} catch (error) {
const validationError = new ProtocolValidationError(
error instanceof Error ? error.message : "Invalid service subscription snapshot",
);
this.#connection.fail(validationError);
throw validationError;
}
} catch (error) {
if (this.#serviceListeners.get(subscriptionId) === active) this.#serviceListeners.delete(subscriptionId);
throw error;
}
if (this.#serviceListeners.get(subscriptionId) !== active) throw new PiDisconnectedError();
let disposed = false;
return {
id: subscriptionId,
target,
snapshot,
start: () => {
if (disposed || active.ready) return;
active.ready = true;
for (const update of active.queued.splice(0)) this.#deliverServiceUpdate(active, update);
},
dispose: async () => {
if (disposed) return;
disposed = true;
if (this.#serviceListeners.get(subscriptionId) === active) this.#serviceListeners.delete(subscriptionId);
try {
if (this.connected && this.#targetIsCurrent(target)) {
await this.#request(target, createServiceUnsubscribeCall(subscriptionId));
}
await active.deliveryTail;
} finally {
active.queued.length = 0;
}
},
};
}
async watchSession(sessionId: string): Promise<PiLaneWatch> {
this.#requireSessionTarget(sessionId);
const { watchId, snapshot } = await this.#rpc.watch();
@ -256,7 +350,7 @@ export class PiClient {
return promise;
}
#handleMessage(message: ResponseEnvelope | EventEnvelope): void {
#handleMessage(message: ResponseEnvelope | ServerEventEnvelope): void {
if (message.type === "event") {
const active = this.#watchListeners.get(message.watchId);
if (active === undefined) return;
@ -265,6 +359,13 @@ export class PiClient {
.catch((error: unknown) => this.#reportListenerError(error));
return;
}
if (message.type === "service_event") {
const active = this.#serviceListeners.get(message.subscriptionId);
if (active === undefined) return;
if (active.ready) this.#deliverServiceUpdate(active, message.update);
else active.queued.push(message.update);
return;
}
const pending = this.#takePendingRequest(message.id);
if (!pending) {
this.#connection.fail(new ProtocolValidationError("Response has no matching request"));
@ -280,9 +381,10 @@ export class PiClient {
#handleConnectionStateChange(change: ConnectionStateChange): void {
if (change.state === "disconnected") {
this.#hello = undefined;
this.#attachment = undefined;
this.#setAttachment(undefined);
this.#rejectPendingRequests(change.error ?? new PiDisconnectedError());
this.#watchListeners.clear();
this.#serviceListeners.clear();
}
for (const listener of this.#connectionStateListeners) {
try {
@ -319,9 +421,11 @@ export class PiClient {
this.#rejectPendingRequests(error);
this.#connection.disconnect(error);
this.#hello = undefined;
this.#attachment = undefined;
this.#setAttachment(undefined);
this.#connectionStateListeners.clear();
this.#attachmentListeners.clear();
this.#watchListeners.clear();
this.#serviceListeners.clear();
return this.#disposePromise;
}
@ -351,6 +455,41 @@ export class PiClient {
return attachment;
}
#setAttachment(attachment: SessionTarget | undefined): void {
const previous = this.#attachment;
if (
previous?.serverId === attachment?.serverId &&
previous?.sessionId === attachment?.sessionId &&
previous?.attachmentId === attachment?.attachmentId
) {
return;
}
this.#attachment = attachment;
for (const listener of this.#attachmentListeners) {
try {
listener(attachment);
} catch (error) {
this.#reportListenerError(error);
}
}
}
#deliverServiceUpdate(active: ActiveServiceListener, update: ServiceProviderUpdate): void {
active.deliveryTail = active.deliveryTail
.then(() => active.listener(update))
.catch((error: unknown) => this.#reportListenerError(error));
}
#targetIsCurrent(target: RpcTarget): boolean {
if (!("sessionId" in target)) return this.#hello?.serverId === target.serverId;
const attachment = this.#attachment;
return (
attachment?.serverId === target.serverId &&
attachment.sessionId === target.sessionId &&
attachment.attachmentId === target.attachmentId
);
}
#assertNotDisposed(): void {
if (this.#disposed) throw new PiClientDisposedError();
}

View file

@ -2,10 +2,12 @@ export { PiClient } from "./client.ts";
export { PiClientDisposedError, PiDisconnectedError, PiServerError } from "./errors.ts";
export type { ByteTransport, ByteTransportFactory, ByteTransportHandlers } from "./transport.ts";
export type {
AttachmentChangeListener,
ConnectionState,
ConnectionStateChange,
ListenerErrorHandler,
PiClientOptions,
PiLaneWatch,
PiServiceSubscription,
Unsubscribe,
} from "./types.ts";

View file

@ -1,4 +1,10 @@
import type { LaneEvent, LaneSnapshot } from "@earendil-works/pi-protocol";
import type {
LaneEvent,
LaneSnapshot,
RpcTarget,
ServiceSubscriptionSnapshot,
SessionTarget,
} from "@earendil-works/pi-protocol";
import type { ByteTransportFactory } from "./transport.ts";
export type ConnectionState = "disconnected" | "connecting" | "connected";
@ -10,6 +16,7 @@ export interface ConnectionStateChange {
export type Unsubscribe = () => void;
export type ListenerErrorHandler = (error: Error) => void;
export type AttachmentChangeListener = (attachment: SessionTarget | undefined) => void;
export interface PiLaneWatch {
readonly id: string;
@ -19,6 +26,15 @@ export interface PiLaneWatch {
dispose(): Promise<void>;
}
export interface PiServiceSubscription {
readonly id: string;
readonly target: RpcTarget;
readonly snapshot: ServiceSubscriptionSnapshot;
/** Begin ordered update delivery after the caller has installed the snapshot. */
start(): void;
dispose(): Promise<void>;
}
export interface PiClientOptions {
transportFactory: ByteTransportFactory;
/** Logical server identity expected at the physical endpoint. */

View file

@ -5,7 +5,7 @@ import {
PROTOCOL_VERSION,
ProtocolValidationError,
} from "@earendil-works/pi-protocol";
import { describe, expect, test } from "vitest";
import { describe, expect, test, vi } from "vitest";
import { type ByteTransportFactory, PiClient, PiClientDisposedError, PiDisconnectedError } from "../src/index.ts";
import { MemoryByteServer } from "./support.ts";
@ -277,6 +277,71 @@ describe("PiClient service operations", () => {
await client.dispose();
});
test("buffers service updates until the subscription snapshot arrives", async () => {
const server = new MemoryByteServer();
const client = await connectClient(server);
const attaching = client.attachSession("session-1");
await server.waitForMessages(2);
server.send({
type: "response",
id: "request-1",
ok: true,
result: { sessionId: "session-1", attachmentId: "attachment-1" },
});
await attaching;
const target = client.attachment!;
const updates: string[] = [];
const opening = client.subscribeService(target, "models", "singleton", (update) => {
updates.push(update.type);
});
await server.waitForMessages(3);
expect(server.messages[2]).toMatchObject({
type: "request",
target,
call: {
serviceId: "$pi.service",
member: "subscribe",
args: ["service-1", "models", "singleton"],
},
});
server.send({
type: "service_event",
subscriptionId: "service-1",
update: { type: "state", member: "state", sequence: 1, value: { revision: 1 } },
});
await Promise.resolve();
expect(updates).toEqual([]);
server.send({
type: "response",
id: "request-2",
ok: true,
result: {
serviceId: "models",
mode: "singleton",
instances: [
{
members: [{ name: "state", kind: "state" }],
states: { state: { sequence: 0, value: { revision: 0 } } },
},
],
},
});
const subscription = await opening;
expect(updates).toEqual([]);
expect(subscription.snapshot.instances[0]?.states.state?.value).toEqual({ revision: 0 });
subscription.start();
await vi.waitFor(() => expect(updates).toEqual(["state"]));
const disposing = subscription.dispose();
await server.waitForMessages(4);
expect(server.messages[3]).toMatchObject({
call: { serviceId: "$pi.service", member: "unsubscribe", args: ["service-1"] },
});
server.send({ type: "response", id: "request-3", ok: true });
await disposing;
await client.dispose();
});
test("correlates out-of-order responses", async () => {
const server = new MemoryByteServer();
const client = await connectClient(server);

View file

@ -12,7 +12,7 @@ import {
spawnInternalProcess,
} from "./process.ts";
export const COORDINATOR_PROTOCOL_VERSION = 2;
export const COORDINATOR_PROTOCOL_VERSION = 3;
const COORDINATOR_START_TIMEOUT_MS = 10_000;
const COORDINATOR_RETRY_MS = 10;

View file

@ -0,0 +1,87 @@
import {
BACKGROUND_CONTEXT,
type Context,
type ServiceProviderUpdate as CoreServiceProviderUpdate,
freshDeliveryContext,
isJsonValue,
type RemoteServiceConnection,
RemoteServiceNamespace,
} from "@earendil-works/pi-agent-core";
import type { PiClient } from "@earendil-works/pi-client";
import type { ProtocolRpcCall, ServiceProviderUpdate } from "@earendil-works/pi-protocol";
export interface PiSessionServiceNamespaceOptions {
readonly services: readonly { readonly id: string }[];
readonly onError?: (error: Error) => void;
}
class PiSessionServiceNamespace extends RemoteServiceNamespace {
readonly #removeAttachmentListener: () => void;
readonly #onError: ((error: Error) => void) | undefined;
#transition = Promise.resolve();
constructor(client: PiClient, connection: RemoteServiceConnection, options: PiSessionServiceNamespaceOptions) {
super({
services: options.services,
connection,
bound: client.attachment !== undefined,
...(options.onError === undefined ? {} : { onError: options.onError }),
});
this.#onError = options.onError;
this.#removeAttachmentListener = client.onAttachmentChange((attachment) => {
this.#transition = this.#transition
.then(() => this.rebind(attachment !== undefined, BACKGROUND_CONTEXT))
.catch((error: unknown) => this.#onError?.(toError(error)));
});
}
override async dispose(context: Context): Promise<void> {
this.#removeAttachmentListener();
await this.#transition;
await super.dispose(context);
}
}
/** Bind plugin service facades to the Pi client's selected Session route. */
export function createPiSessionServiceNamespace(
client: PiClient,
options: PiSessionServiceNamespaceOptions,
): RemoteServiceNamespace {
const connection: RemoteServiceConnection = {
invoke: async (call, context) => {
const target = client.attachment;
if (target === undefined) throw new Error("No Session is attached");
const wireCall: ProtocolRpcCall = {
serviceId: call.serviceId,
...(call.instance === undefined ? {} : { instance: call.instance }),
member: call.member,
args: [...call.args],
};
const result = await client.request(target, wireCall, context.abortSignal);
if (result !== undefined && !isJsonValue(result)) throw new Error("Service returned a non-JSON result");
return result;
},
subscribe: async (serviceId, mode, listener, context) => {
const target = client.attachment;
if (target === undefined) throw new Error("No Session is attached");
const subscription = await client.subscribeService(
target,
serviceId,
mode,
(update: ServiceProviderUpdate) =>
listener(update as unknown as CoreServiceProviderUpdate, freshDeliveryContext()),
context.abortSignal,
);
return {
snapshot: subscription.snapshot,
activate: () => subscription.start(),
close: () => subscription.dispose(),
};
},
};
return new PiSessionServiceNamespace(client, connection, options);
}
function toError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}

View file

@ -0,0 +1,32 @@
import { type Context, defineRemoteService, type RemoteState, 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 ModelsService {
readonly state: RemoteState<ModelsState>;
cycleThinking(context: Context): Promise<void>;
refresh(context: Context): Promise<void>;
select(model: ModelRef, context: Context): Promise<void>;
}
export const Models = defineRemoteService<ModelsService>("models");

View file

@ -7,8 +7,21 @@ import {
type JsonlSessionMetadata,
TODO_CONTEXT,
} from "@earendil-works/pi-agent-core";
import { createRpcClient, type LaneEvent } from "@earendil-works/pi-protocol";
import type { RoutedSessionAttachment, RoutedSessionHandle, RoutedSessionWatch } from "@earendil-works/pi-server";
import {
createRpcClient,
createServiceUnsubscribeCall,
decodeServiceControlCall,
type LaneEvent,
type ProtocolRpcCall,
type ProtocolRpcResult,
type ServiceProviderUpdate,
} from "@earendil-works/pi-protocol";
import {
PiServerError,
type RoutedSessionAttachment,
type RoutedSessionHandle,
type RoutedSessionWatch,
} from "@earendil-works/pi-server";
import { Check } from "typebox/value";
import type { CoordinatorConnection, CoordinatorConnectionEvent } from "./coordinator.ts";
import { spawnInternalProcess } from "./process.ts";
@ -17,6 +30,7 @@ import {
SESSION_WORKER_CONTROL_TOKEN_ENV,
SESSION_WORKER_PEER_ID_ENV,
SESSION_WORKER_SESSION_KEY_ENV,
type ServiceOperationResult,
type SessionWorkerEvent,
SessionWorkerEventSchema,
type SessionWorkerOperationCall,
@ -64,10 +78,18 @@ interface WorkerLaneWatch {
interface PendingWorkerOperation {
readonly worker: WorkerRecord;
readonly scope: WorkerOperationScope;
cleanup(): void;
resolve(result: unknown): void;
reject(error: Error): void;
}
interface WorkerServiceSubscription {
readonly worker: WorkerRecord;
readonly scope: WorkerOperationScope;
readonly listener: (update: ServiceProviderUpdate, context: Context) => void | Promise<void>;
deliveryTail: Promise<void>;
}
interface PendingLaunch {
readonly sessionKey: string;
readonly peerId: string;
@ -94,6 +116,7 @@ export class SessionWorkerManager {
readonly #pendingDemand = new Map<string, PendingDemand>();
readonly #pendingOperations = new Map<string, PendingWorkerOperation>();
readonly #laneWatches = new Map<string, WorkerLaneWatch>();
readonly #serviceSubscriptions = new Map<string, WorkerServiceSubscription>();
readonly #removeListener: () => void;
readonly #onWorkerCountChanged: ((count: number) => void) | undefined;
#discoveryPeers?: Set<string>;
@ -189,6 +212,8 @@ export class SessionWorkerManager {
let released = false;
return {
prompt: (prompt, promptContext) => this.#operations(worker, scope, promptContext).prompt(prompt),
invokeService: (call, publish, serviceContext) =>
this.#invokeService(worker, scope, call, publish, serviceContext),
watch: (watchContext) => this.#createLaneWatch(worker, scope, watchContext),
release: async (releaseContext) => {
if (released) return;
@ -200,11 +225,50 @@ export class SessionWorkerManager {
if (!this.#detached && !this.#coordinator.wasReplaced) throw error;
} finally {
worker.attachmentIds.delete(attachmentId);
this.#removeServiceSubscriptions((entry) => entry.worker === worker && sameScope(entry.scope, scope));
}
},
};
}
async #invokeService(
worker: WorkerRecord,
scope: WorkerOperationScope,
call: ProtocolRpcCall,
publish: (subscriptionId: string, update: ServiceProviderUpdate, context: Context) => void | Promise<void>,
context: Context,
): Promise<ProtocolRpcResult> {
const control = decodeServiceControlCall(call);
let addedSubscriptionKey: string | undefined;
if (control?.type === "subscribe") {
const key = scopedServiceSubscriptionKey(scope, control.subscriptionId);
if (this.#serviceSubscriptions.has(key)) throw new Error("Service subscription ID is already active");
this.#serviceSubscriptions.set(key, {
worker,
scope,
listener: (update, updateContext) => publish(control.subscriptionId, update, updateContext),
deliveryTail: Promise.resolve(),
});
addedSubscriptionKey = key;
}
let response: ServiceOperationResult;
try {
response = await this.#operations(worker, scope, context).service(call);
} catch (error) {
if (addedSubscriptionKey !== undefined && control?.type === "subscribe") {
this.#serviceSubscriptions.delete(addedSubscriptionKey);
void this.#operations(worker, scope, BACKGROUND_CONTEXT)
.service(createServiceUnsubscribeCall(control.subscriptionId))
.catch(() => {});
}
throw error;
}
if (control?.type === "unsubscribe") {
this.#serviceSubscriptions.delete(scopedServiceSubscriptionKey(scope, control.subscriptionId));
}
return response.result;
}
async #createLaneWatch(
worker: WorkerRecord,
scope: WorkerOperationScope,
@ -293,13 +357,14 @@ export class SessionWorkerManager {
worker: WorkerRecord,
scope: WorkerOperationScope,
call: SessionWorkerOperationCall,
_context: Context,
context: Context,
): Promise<unknown> {
try {
this.#operationScope(worker, scope.attachmentId);
} catch (error) {
return Promise.reject(error);
}
if (context.abortSignal?.aborted) return Promise.reject(abortError(context.abortSignal));
const requestId = randomUUID();
let resolve!: (result: unknown) => void;
let reject!: (error: Error) => void;
@ -307,7 +372,20 @@ export class SessionWorkerManager {
resolve = resolvePromise;
reject = rejectPromise;
});
this.#pendingOperations.set(requestId, { worker, scope, resolve, reject });
const signal = context.abortSignal;
const onAbort = (): void => {
if (signal === undefined || !this.#pendingOperations.has(requestId)) return;
void this.#coordinator.send(worker.peerId, { type: "operation_cancel", requestId, scope }).catch(() => {});
this.#rejectOperation(requestId, abortError(signal));
};
if (signal !== undefined) signal.addEventListener("abort", onAbort, { once: true });
this.#pendingOperations.set(requestId, {
worker,
scope,
cleanup: () => signal?.removeEventListener("abort", onAbort),
resolve,
reject,
});
void this.#coordinator
.send(worker.peerId, { type: "operation", requestId, scope, call })
.catch((error: unknown) =>
@ -519,6 +597,17 @@ export class SessionWorkerManager {
);
return;
}
if (message.type === "service_event") {
this.#handleServiceEvent(
event.from,
message.token,
message.sessionKey,
message.scope,
message.subscriptionId,
message.update,
);
return;
}
this.#recordReadyWorker(event.from, message);
}
@ -544,8 +633,13 @@ export class SessionWorkerManager {
return;
}
this.#pendingOperations.delete(response.requestId);
pending.cleanup();
if (response.type === "operation_error") {
pending.reject(new Error(`Session worker operation failed: ${response.message}`));
pending.reject(
response.code === undefined
? new Error(`Session worker operation failed: ${response.message}`)
: new PiServerError(response.code, response.message),
);
} else {
pending.resolve(response.result);
}
@ -575,6 +669,27 @@ export class SessionWorkerManager {
watch.deliveryTail = watch.deliveryTail.then(() => listener(event, TODO_CONTEXT)).catch(() => {});
}
#handleServiceEvent(
peerId: string,
token: string,
sessionKey: string,
scope: WorkerOperationScope,
subscriptionId: string,
update: ServiceProviderUpdate,
): void {
const entry = this.#serviceSubscriptions.get(scopedServiceSubscriptionKey(scope, subscriptionId));
if (
entry === undefined ||
entry.worker.peerId !== peerId ||
entry.worker.token !== token ||
entry.worker.metadata.path !== sessionKey ||
!sameScope(entry.scope, scope)
) {
return;
}
entry.deliveryTail = entry.deliveryTail.then(() => entry.listener(update, TODO_CONTEXT)).catch(() => {});
}
#recordReadyWorker(peerId: string, message: Extract<SessionWorkerEvent, { type: "worker_ready" }>): void {
if (
message.sessionKey !== message.metadata.path ||
@ -665,6 +780,7 @@ export class SessionWorkerManager {
const pending = this.#pendingOperations.get(requestId);
if (!pending) return;
this.#pendingOperations.delete(requestId);
pending.cleanup();
pending.reject(error);
}
@ -711,6 +827,7 @@ export class SessionWorkerManager {
for (const [watchId, watch] of this.#laneWatches) {
if (watch.worker === worker) this.#laneWatches.delete(watchId);
}
this.#removeServiceSubscriptions((entry) => entry.worker === worker);
for (const pending of [...this.#pendingDemand.values()]) {
if (pending.worker === worker) {
this.#rejectDemand(pending.requestId, new Error("Session worker disconnected during demand update"));
@ -723,6 +840,12 @@ export class SessionWorkerManager {
this.#notifyWorkerCountChanged();
}
#removeServiceSubscriptions(matches: (entry: WorkerServiceSubscription) => boolean): void {
for (const [key, entry] of this.#serviceSubscriptions) {
if (matches(entry)) this.#serviceSubscriptions.delete(key);
}
}
#notifyWorkerCountChanged(): void {
this.#onWorkerCountChanged?.(this.#workersBySession.size + this.#pending.size);
}
@ -748,6 +871,7 @@ export class SessionWorkerManager {
}
this.#pending.clear();
this.#laneWatches.clear();
this.#serviceSubscriptions.clear();
this.#workersByPeer.clear();
this.#workersBySession.clear();
this.workerPids.clear();
@ -756,3 +880,16 @@ export class SessionWorkerManager {
this.#resolveDiscovery = undefined;
}
}
function sameScope(left: WorkerOperationScope, right: WorkerOperationScope): boolean {
return left.serverConnectionId === right.serverConnectionId && left.attachmentId === right.attachmentId;
}
function scopedServiceSubscriptionKey(scope: WorkerOperationScope, subscriptionId: string): string {
return `${scope.serverConnectionId}\0${scope.attachmentId}\0${subscriptionId}`;
}
function abortError(signal: AbortSignal): Error {
const reason: unknown = signal.reason;
return reason instanceof Error ? reason : new DOMException("The operation was aborted", "AbortError");
}

View file

@ -5,26 +5,46 @@ import { fileURLToPath } from "node:url";
import {
AgentHarness,
type AgentHarness as AgentHarnessInstance,
BACKGROUND_CONTEXT,
type Context,
type ServiceProviderUpdate as CoreServiceProviderUpdate,
createBashTool,
createReadTool,
createWriteTool,
type JsonlSessionMetadata,
JsonlSessionRepo,
type MutableRemoteState,
RemoteServiceError,
RemoteServiceProvider,
remoteState,
type ServiceProviderSubscription,
type Session,
TODO_CONTEXT,
withCancel,
} from "@earendil-works/pi-agent-core";
import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node";
import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
import {
createRpcCallSchema,
createRpcDispatcher,
createRpcResultSchema,
decodeServiceControlCall,
defineRpc,
isJsonValue,
type JsonValue,
JsonValueSchema,
LaneEventSchema,
LaneSnapshotSchema,
PromptArgumentsSchema,
type ProtocolRpcCall,
ProtocolRpcCallSchema,
type ServiceProviderUpdate as ProtocolServiceProviderUpdate,
type RpcCall,
type RpcResultUnion,
RunResultSchema,
type ServiceErrorCode,
ServiceErrorCodeSchema,
ServiceProviderUpdateSchema,
} from "@earendil-works/pi-protocol";
import lockfile from "proper-lockfile";
import Type, { type Static } from "typebox";
@ -40,6 +60,7 @@ import {
toWireRunResult,
} from "./harness-wire-adapter.ts";
import { consumeInternalProcessRole, encodeControlLine, MAX_CONTROL_LINE_BYTES } from "./process.ts";
import { Models, type ModelsState } from "./services/models.ts";
const StrictObject = <const T extends Parameters<typeof Type.Object>[0]>(properties: T) =>
Type.Object(properties, { additionalProperties: false });
@ -68,6 +89,10 @@ export const SessionWorkerOptionsSchema = StrictObject({
});
export type SessionWorkerOptions = Static<typeof SessionWorkerOptionsSchema>;
const ServiceOperationResultSchema = StrictObject({
result: Type.Optional(JsonValueSchema),
});
export const SessionWorkerOperations = defineRpc({
prompt: {
args: Type.Tuple([PromptArgumentsSchema]),
@ -85,6 +110,10 @@ export const SessionWorkerOperations = defineRpc({
args: Type.Tuple([Type.String({ minLength: 1 })]),
result: StrictObject({ watchId: Type.String({ minLength: 1 }) }),
},
service: {
args: Type.Tuple([ProtocolRpcCallSchema]),
result: ServiceOperationResultSchema,
},
});
export type SessionWorkerOperationCall = RpcCall<typeof SessionWorkerOperations>;
type SessionWorkerOperationResult = RpcResultUnion<typeof SessionWorkerOperations>;
@ -96,6 +125,10 @@ const SessionWorkerOperationResultSchema = Type.Unsafe<SessionWorkerOperationRes
createRpcResultSchema(SessionWorkerOperations),
);
export interface ServiceOperationResult {
readonly result?: JsonValue;
}
export const WorkerOperationScopeSchema = StrictObject({
serverConnectionId: Type.String(),
attachmentId: Type.String(),
@ -121,6 +154,7 @@ export const WorkerOperationResponseSchema = Type.Union([
type: Type.Literal("operation_error"),
requestId: Type.String({ minLength: 1 }),
scope: WorkerOperationScopeSchema,
code: Type.Optional(ServiceErrorCodeSchema),
message: Type.String(),
}),
]);
@ -137,6 +171,11 @@ export const SessionWorkerCommandSchema = Type.Union([
attached: Type.Boolean(),
}),
WorkerOperationRequestSchema,
StrictObject({
type: Type.Literal("operation_cancel"),
requestId: Type.String({ minLength: 1 }),
scope: WorkerOperationScopeSchema,
}),
]);
export type SessionWorkerCommand = Static<typeof SessionWorkerCommandSchema>;
@ -184,6 +223,14 @@ export const SessionWorkerEventSchema = Type.Union([
watchId: Type.String({ minLength: 1 }),
event: LaneEventSchema,
}),
Type.Object({
type: Type.Literal("service_event"),
token: Type.String(),
sessionKey: Type.String(),
scope: WorkerOperationScopeSchema,
subscriptionId: Type.String({ minLength: 1 }),
update: ServiceProviderUpdateSchema,
}),
]);
export type SessionWorkerEvent = Static<typeof SessionWorkerEventSchema>;
@ -379,6 +426,7 @@ async function readCommands(
onDiscovery(): void;
onDemand(command: Extract<SessionWorkerCommand, { type: "session_demand" }>): Promise<void>;
onOperation(command: WorkerOperationRequest): void;
onOperationCancel(command: Extract<SessionWorkerCommand, { type: "operation_cancel" }>): void;
onServerConnected(serverConnectionId: string): void;
onServerDisconnected(serverConnectionId: string): void;
},
@ -402,6 +450,7 @@ async function readCommands(
if (command.type === "shutdown") handlers.onShutdown();
else if (command.type === "discover_workers") handlers.onDiscovery();
else if (command.type === "session_demand") await handlers.onDemand(command);
else if (command.type === "operation_cancel") handlers.onOperationCancel(command);
else handlers.onOperation(command);
}
}
@ -462,6 +511,21 @@ function sameScope(left: WorkerOperationScope, right: WorkerOperationScope): boo
return left.serverConnectionId === right.serverConnectionId && left.attachmentId === right.attachmentId;
}
function scopedSubscriptionKey(scope: WorkerOperationScope, subscriptionId: string): string {
return `${scope.serverConnectionId}\0${scope.attachmentId}\0${subscriptionId}`;
}
function toProtocolJson(value: unknown): JsonValue {
if (!isJsonValue(value)) throw new Error("Service control value is not strict JSON");
return value;
}
function toProtocolServiceUpdate(update: CoreServiceProviderUpdate): ProtocolServiceProviderUpdate {
const candidate: unknown = update;
if (!Check(ServiceProviderUpdateSchema, candidate)) throw new Error("Service produced an invalid update");
return candidate;
}
function lifecycleDelay(name: string, fallback: number): number {
const value = process.env[name];
if (value === undefined) return fallback;
@ -472,12 +536,18 @@ function lifecycleDelay(name: string, fallback: number): number {
async function closeResources(resources: {
harness?: AgentHarnessInstance;
services?: RemoteServiceProvider;
session?: Session<JsonlSessionMetadata>;
repo: JsonlSessionRepo;
executionEnv: NodeExecutionEnv;
releaseOwnership: () => Promise<void>;
}): Promise<void> {
const errors: unknown[] = [];
try {
resources.services?.dispose();
} catch (error) {
errors.push(error);
}
try {
if (resources.harness) await resources.harness.close(TODO_CONTEXT);
else await resources.session?.close(TODO_CONTEXT);
@ -503,11 +573,101 @@ async function closeResources(resources: {
if (errors.length > 1) throw new AggregateError(errors, "Session worker cleanup failed");
}
export interface SessionWorkerRuntime {
readonly harness: AgentHarnessInstance;
readonly modelRuntime?: ModelRuntime;
readonly serviceTokens?: readonly { readonly id: string }[];
configureServices?(provider: RemoteServiceProvider): void | Promise<void>;
}
export type CreateSessionWorkerHarness = (
session: Session<JsonlSessionMetadata>,
options: SessionWorkerOptions,
executionEnv: NodeExecutionEnv,
) => Promise<AgentHarnessInstance>;
) => Promise<AgentHarnessInstance | SessionWorkerRuntime>;
async function provideModelsService(
provider: RemoteServiceProvider,
harness: AgentHarnessInstance,
modelRuntime: ModelRuntime | undefined,
): Promise<MutableRemoteState<ModelsState>> {
let catalogRevision = 0;
const readConfiguration = async (context: Context = BACKGROUND_CONTEXT): Promise<ModelsState["configuration"]> => {
const [selected, thinkingLevel] = await Promise.all([
harness.getModel(context),
harness.getThinkingLevel(context),
]);
return {
model: selected === undefined ? null : { provider: selected.provider, modelId: selected.id },
thinkingLevel,
};
};
const readCatalog = async (context: Context = BACKGROUND_CONTEXT): Promise<ModelsState["catalog"]> => {
const selected = await harness.getModel(context);
const available = modelRuntime?.getAvailableSnapshot() ?? (selected === undefined ? [] : [selected]);
catalogRevision += 1;
return {
revision: catalogRevision,
availableModels: available.map((model) => ({
provider: model.provider,
modelId: model.id,
name: model.name,
reasoning: model.reasoning,
})),
};
};
const state = remoteState<ModelsState>({
catalog: await readCatalog(),
configuration: await readConfiguration(),
refresh: { status: "idle" },
});
provider.provide(Models, {
state,
async cycleThinking(context) {
const selected = await harness.getModel(context);
if (selected === undefined) return;
const levels = getSupportedThinkingLevels(selected);
const current = await harness.getThinkingLevel(context);
const index = levels.indexOf(current);
const next = levels[(index + 1) % levels.length] ?? "off";
await harness.setThinkingLevel(next, context);
state.set({ ...state.value, configuration: await readConfiguration(context) }, context);
},
async refresh(context) {
state.set({ ...state.value, refresh: { status: "refreshing" } }, context);
if (modelRuntime === undefined) {
state.set(
{
...state.value,
catalog: await readCatalog(context),
configuration: await readConfiguration(context),
refresh: { status: "done" },
},
context,
);
return;
}
const result = await modelRuntime.refresh({ signal: context.abortSignal });
const errors = Object.fromEntries([...result.errors].map(([id, error]) => [id, error.message]));
state.set(
{
...state.value,
catalog: await readCatalog(context),
configuration: await readConfiguration(context),
refresh: Object.keys(errors).length === 0 ? { status: "done" } : { status: "warning", errors },
},
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 harness.setModel(selected, context);
state.set({ ...state.value, configuration: await readConfiguration(context) }, context);
},
});
return state;
}
async function run(options: SessionWorkerOptions, createHarness: CreateSessionWorkerHarness): Promise<void> {
const { sessionDir, metadata } = options;
@ -526,12 +686,23 @@ async function run(options: SessionWorkerOptions, createHarness: CreateSessionWo
const repo = new JsonlSessionRepo({ fileSystem: executionEnv, sessionsRoot: sessionDir });
let session: Session<JsonlSessionMetadata> | undefined;
let harness: AgentHarnessInstance | undefined;
let services: RemoteServiceProvider | undefined;
try {
session = await repo.open(metadata, TODO_CONTEXT);
harness = await createHarness(session, options, executionEnv);
const created = await createHarness(session, options, executionEnv);
if ("harness" in created) {
harness = created.harness;
services = new RemoteServiceProvider([Models, ...(created.serviceTokens ?? [])]);
await provideModelsService(services, harness, created.modelRuntime);
await created.configureServices?.(services);
} else {
harness = created;
services = new RemoteServiceProvider([Models]);
await provideModelsService(services, harness, undefined);
}
} catch (error) {
try {
await closeResources({ harness, session, repo, executionEnv, releaseOwnership });
await closeResources({ harness, services, session, repo, executionEnv, releaseOwnership });
} catch (cleanupError) {
throw new AggregateError([error, cleanupError], "Session worker startup and cleanup failed");
}
@ -542,6 +713,14 @@ async function run(options: SessionWorkerOptions, createHarness: CreateSessionWo
string,
{ readonly scope: WorkerOperationScope; readonly handle: Awaited<ReturnType<AgentHarnessInstance["watch"]>> }
>();
const serviceSubscriptions = new Map<
string,
{ readonly scope: WorkerOperationScope; readonly subscription: ServiceProviderSubscription }
>();
const activeRequests = new Map<
string,
{ readonly scope: WorkerOperationScope; readonly cancel: (reason?: unknown) => void }
>();
const removeLaneWatches = (matches: (scope: WorkerOperationScope) => boolean): void => {
for (const [watchId, watch] of laneWatches) {
if (!matches(watch.scope)) continue;
@ -549,6 +728,13 @@ async function run(options: SessionWorkerOptions, createHarness: CreateSessionWo
laneWatches.delete(watchId);
}
};
const removeServiceSubscriptions = (matches: (scope: WorkerOperationScope) => boolean): void => {
for (const [key, entry] of serviceSubscriptions) {
if (!matches(entry.scope)) continue;
entry.subscription.close();
serviceSubscriptions.delete(key);
}
};
let lifecycle: WorkerLifecycle | undefined;
let removeLifecycleListeners: (() => void)[] = [];
let closing: Promise<void> | undefined;
@ -556,9 +742,12 @@ async function run(options: SessionWorkerOptions, createHarness: CreateSessionWo
if (closing) return closing;
lifecycle?.close();
removeLaneWatches(() => true);
removeServiceSubscriptions(() => true);
for (const request of activeRequests.values()) request.cancel(new Error("Session worker is closing"));
activeRequests.clear();
for (const remove of removeLifecycleListeners) remove();
removeLifecycleListeners = [];
closing = closeResources({ harness, repo, executionEnv, releaseOwnership });
closing = closeResources({ harness, services, repo, executionEnv, releaseOwnership });
return closing;
};
const closeAndExit = (): void => {
@ -597,22 +786,26 @@ async function run(options: SessionWorkerOptions, createHarness: CreateSessionWo
harness.events.on("fault", closeAndExit),
];
interface WorkerOperationContext {
readonly scope: WorkerOperationScope;
readonly context: Context;
}
const dispatchWorkerOperation = createRpcDispatcher(SessionWorkerOperations, {
prompt: async (_scope: WorkerOperationScope, prompt) => {
prompt: async ({ context }: WorkerOperationContext, prompt) => {
const args = toHarnessPromptArguments(prompt);
const result =
typeof args[0] === "string"
? await harness.prompt(args[0], args[1], TODO_CONTEXT)
: await harness.prompt(args[0], TODO_CONTEXT);
? await harness.prompt(args[0], args[1], context)
: await harness.prompt(args[0], context);
return toWireRunResult(result);
},
watch: async (scope: WorkerOperationScope, ..._args: never[]) => {
const handle = await harness.watch(TODO_CONTEXT);
watch: async ({ scope, context }: WorkerOperationContext, ..._args: never[]) => {
const handle = await harness.watch(context);
const watchId = randomUUID();
laneWatches.set(watchId, { scope, handle });
return { watchId, snapshot: toWireLaneSnapshot(handle.snapshot) };
},
startWatch: async (scope: WorkerOperationScope, watchId) => {
startWatch: async ({ scope }: WorkerOperationContext, watchId) => {
const watch = laneWatches.get(watchId);
if (!watch || !sameScope(watch.scope, scope)) throw new Error("Session worker lane watch was not found");
watch.handle.start(async (event) => {
@ -622,19 +815,60 @@ async function run(options: SessionWorkerOptions, createHarness: CreateSessionWo
});
return { watchId };
},
stopWatch: async (scope: WorkerOperationScope, watchId) => {
stopWatch: async ({ scope }: WorkerOperationContext, watchId) => {
const watch = laneWatches.get(watchId);
if (!watch || !sameScope(watch.scope, scope)) throw new Error("Session worker lane watch was not found");
watch.handle.unsubscribe();
laneWatches.delete(watchId);
return { watchId };
},
service: async ({ scope, context }: WorkerOperationContext, call) => {
const controlCall = decodeServiceControlCall(call);
if (controlCall?.type === "subscribe") {
const key = scopedSubscriptionKey(scope, controlCall.subscriptionId);
if (serviceSubscriptions.has(key)) throw new Error("Service subscription ID is already active");
const subscription = services.subscribe(
controlCall.serviceId,
controlCall.mode,
(update: CoreServiceProviderUpdate, _updateContext: Context) => {
void control
.send({
type: "service_event",
token,
sessionKey,
scope,
subscriptionId: controlCall.subscriptionId,
update: toProtocolServiceUpdate(update),
})
.catch(() => {});
},
);
serviceSubscriptions.set(key, { scope, subscription });
subscription.activate();
return { result: toProtocolJson(subscription.snapshot) };
}
if (controlCall?.type === "unsubscribe") {
const key = scopedSubscriptionKey(scope, controlCall.subscriptionId);
const entry = serviceSubscriptions.get(key);
if (entry === undefined) throw new Error("Service subscription was not found");
entry.subscription.close();
serviceSubscriptions.delete(key);
return {};
}
const result = await services.invoke(call as ProtocolRpcCall, context);
return result === undefined ? {} : { result: toProtocolJson(result) };
},
});
const handleOperation = async (request: WorkerOperationRequest): Promise<void> => {
let releaseRequest = (): void => {};
const cancellable = withCancel(BACKGROUND_CONTEXT);
try {
releaseRequest = lifecycle!.beginRequest(request.scope.serverConnectionId, request.scope.attachmentId);
const result = await dispatchWorkerOperation(request.call, request.scope);
activeRequests.set(request.requestId, { scope: request.scope, cancel: cancellable.cancel });
const result = await dispatchWorkerOperation(request.call, {
scope: request.scope,
context: cancellable.context,
});
await control.send({
type: "operation_response",
token,
@ -642,6 +876,7 @@ async function run(options: SessionWorkerOptions, createHarness: CreateSessionWo
response: { type: "operation_result", requestId: request.requestId, scope: request.scope, result },
});
} catch (error) {
const code: ServiceErrorCode | undefined = error instanceof RemoteServiceError ? error.code : undefined;
await control.send({
type: "operation_response",
token,
@ -650,10 +885,14 @@ async function run(options: SessionWorkerOptions, createHarness: CreateSessionWo
type: "operation_error",
requestId: request.requestId,
scope: request.scope,
...(code === undefined ? {} : { code }),
message: error instanceof Error ? error.message : String(error),
},
});
} finally {
if (activeRequests.get(request.requestId)?.cancel === cancellable.cancel) {
activeRequests.delete(request.requestId);
}
releaseRequest();
}
};
@ -680,11 +919,11 @@ async function run(options: SessionWorkerOptions, createHarness: CreateSessionWo
try {
try {
if (!command.attached) {
removeLaneWatches(
(scope) =>
scope.serverConnectionId === command.serverConnectionId &&
scope.attachmentId === command.attachmentId,
);
const matches = (scope: WorkerOperationScope): boolean =>
scope.serverConnectionId === command.serverConnectionId &&
scope.attachmentId === command.attachmentId;
removeLaneWatches(matches);
removeServiceSubscriptions(matches);
}
lifecycle?.setDemand(command.serverConnectionId, command.attachmentId, command.attached);
} catch (error) {
@ -712,9 +951,20 @@ async function run(options: SessionWorkerOptions, createHarness: CreateSessionWo
onOperation: (request) => {
void handleOperation(request).catch(() => closeAndExit());
},
onOperationCancel: (command) => {
const active = activeRequests.get(command.requestId);
if (active !== undefined && sameScope(active.scope, command.scope)) {
active.cancel(new DOMException("Service operation cancelled", "AbortError"));
}
},
onServerConnected: (serverConnectionId) => lifecycle?.serverConnected(serverConnectionId),
onServerDisconnected: (serverConnectionId) => {
removeLaneWatches((scope) => scope.serverConnectionId === serverConnectionId);
const matches = (scope: WorkerOperationScope): boolean => scope.serverConnectionId === serverConnectionId;
removeLaneWatches(matches);
removeServiceSubscriptions(matches);
for (const request of activeRequests.values()) {
if (matches(request.scope)) request.cancel(new Error("Server disconnected"));
}
lifecycle?.serverDisconnected(serverConnectionId);
},
}).catch(() => closeAndExit());
@ -774,7 +1024,7 @@ async function createCodingAgentHarness(
session: Session<JsonlSessionMetadata>,
options: SessionWorkerOptions,
executionEnv: NodeExecutionEnv,
): Promise<AgentHarnessInstance> {
): Promise<SessionWorkerRuntime> {
const modelRuntime = await ModelRuntime.create();
let resolved: Awaited<ReturnType<typeof findInitialModel>> | ReturnType<typeof resolveCliModel>;
if (options.model === undefined) {
@ -837,7 +1087,7 @@ async function createCodingAgentHarness(
await harness.setThinkingLevel(resolved.thinkingLevel, TODO_CONTEXT);
}
}
return harness;
return { harness, modelRuntime };
} catch (error) {
try {
await harness.close(TODO_CONTEXT);

View file

@ -1,16 +1,20 @@
import { lstat, mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { BACKGROUND_CONTEXT, type Context, withCancel } from "@earendil-works/pi-agent-core";
import { PiClient } from "@earendil-works/pi-client";
import { createUnixTransportFactory } from "@earendil-works/pi-client/unix";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { runClient } from "../src/experimental/client.ts";
import * as processRuntime from "../src/experimental/process.ts";
import { activateServer, type RunningServer, startServer } from "../src/experimental/server.ts";
import { createPiSessionServiceNamespace } from "../src/experimental/services/connection.ts";
import { Models } from "../src/experimental/services/models.ts";
import {
configureExperimentalWorkerModel,
createExperimentalSessions,
readExperimentalSessionState,
} from "./experimental-session-support.ts";
import { KeyedProbe, type KeyedProbeService } from "./fixtures/keyed-service.ts";
const servers = new Set<RunningServer>();
const clients = new Set<PiClient>();
@ -292,6 +296,95 @@ describe("experimental durable server composition", () => {
expect(runtime.workerPids.get("demo-1")).toBe(firstPid);
});
test("hydrates and updates the Models service across concurrent framed clients", async () => {
const { runtime } = await makeServer();
const firstClient = await attachClient(runtime, "demo-1");
const secondClient = await attachClient(runtime, "demo-1");
const errors: Error[] = [];
const firstServices = createPiSessionServiceNamespace(firstClient, {
services: [Models],
onError: (error) => errors.push(error),
});
const secondServices = createPiSessionServiceNamespace(secondClient, {
services: [Models],
onError: (error) => errors.push(error),
});
const firstModels = firstServices.use(Models);
const secondModels = secondServices.use(Models);
await vi.waitFor(() => {
expect(firstModels.state.value?.configuration.model).toEqual({
provider: "anthropic",
modelId: "claude-sonnet-4-5",
});
expect(secondModels.state.value).toEqual(firstModels.state.value);
});
const previousThinking = firstModels.state.value!.configuration.thinkingLevel;
await firstModels.cycleThinking(BACKGROUND_CONTEXT);
await vi.waitFor(() => {
expect(firstModels.state.value!.configuration.thinkingLevel).not.toBe(previousThinking);
expect(secondModels.state.value).toEqual(firstModels.state.value);
});
expect(errors).toEqual([]);
await Promise.all([firstServices.dispose(BACKGROUND_CONTEXT), secondServices.dispose(BACKGROUND_CONTEXT)]);
});
test("observes keyed service instances and fences replacement generations over framed transport", async ({
onTestFinished,
}) => {
const spawn = vi
.spyOn(processRuntime, "spawnInternalProcess")
.mockImplementation((role, args, options) =>
realSpawnInternalProcess(
role,
args,
role === "session-worker" ? { ...options, entryUrl: fauxWorkerEntryUrl } : options,
),
);
onTestFinished(() => spawn.mockRestore());
const { runtime } = await makeServer();
const client = await attachClient(runtime, "demo-1");
const errors: Error[] = [];
const services = createPiSessionServiceNamespace(client, {
services: [KeyedProbe],
onError: (error) => errors.push(error),
});
const observed: { service: KeyedProbeService; context: Context; value: string | undefined }[] = [];
const stop = services.observe(KeyedProbe, (instance, context) => {
observed.push({ service: instance.service, context, value: instance.service.state.value?.value });
});
await vi.waitFor(() => expect(observed).toHaveLength(1));
expect(observed[0]!.value).toBe("first");
const stale = observed[0]!.service;
const cancellable = withCancel(BACKGROUND_CONTEXT);
const waiting = stale.wait(cancellable.context);
const cancellation = new Error("cancel probe wait");
cancellable.cancel(cancellation);
await expect(waiting).rejects.toBe(cancellation);
await expect(stale.replace("second", BACKGROUND_CONTEXT)).resolves.toBeUndefined();
await vi.waitFor(() => expect(observed).toHaveLength(2));
expect(observed[0]!.context.abortSignal?.aborted).toBe(true);
expect(observed[1]!.value).toBe("second");
await expect(stale.replace("late", BACKGROUND_CONTEXT)).rejects.toMatchObject({
code: "service_stale_instance",
});
const replaced = observed[1]!.service;
await expect(client.attachSession("demo-2")).resolves.toMatchObject({ sessionId: "demo-2" });
await vi.waitFor(() => expect(observed).toHaveLength(3));
expect(observed[1]!.context.abortSignal?.aborted).toBe(true);
expect(observed[2]!.value).toBe("first");
await expect(replaced.replace("late", BACKGROUND_CONTEXT)).rejects.toMatchObject({
code: "service_stale_instance",
});
expect(errors).toEqual([]);
stop();
await expect(services.dispose(BACKGROUND_CONTEXT)).resolves.toBeUndefined();
});
test("routes prompting through the worker-owned Harness and sanitizes runtime failures", async () => {
const { directory } = await makeServer();

View file

@ -1,9 +1,10 @@
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { AgentHarness, BACKGROUND_CONTEXT } from "@earendil-works/pi-agent-core";
import { AgentHarness, BACKGROUND_CONTEXT, remoteState } from "@earendil-works/pi-agent-core";
import { createModels, fauxAssistantMessage, fauxProvider } from "@earendil-works/pi-ai";
import { consumeInternalProcessRole } from "../../src/experimental/process.ts";
import { runSessionWorkerWithHarness } from "../../src/experimental/session-worker.ts";
import { KeyedProbe } from "./keyed-service.ts";
if (process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const role = consumeInternalProcessRole();
@ -16,7 +17,7 @@ if (process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(
faux.setResponses([fauxAssistantMessage("deterministic remote answer", { timestamp: 20 })]);
const models = createModels();
models.setProvider(faux.provider);
return (
const harness = (
await AgentHarness.create(
{
session,
@ -28,8 +29,39 @@ if (process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(
BACKGROUND_CONTEXT,
)
).harness;
return {
harness,
serviceTokens: [KeyedProbe],
configureServices(provider) {
const spawn = (value: string): void => {
const state = remoteState({ value });
let close = (): void => {};
close = provider.spawn(KeyedProbe, "probe", {
state,
async replace(next) {
close();
spawn(next);
},
async wait(context) {
const signal = context.abortSignal;
if (signal === undefined) throw new Error("Probe wait requires cancellation");
if (signal.aborted) throw abortError(signal);
await new Promise<void>((_resolve, reject) => {
signal.addEventListener("abort", () => reject(abortError(signal)), { once: true });
});
},
});
};
spawn("first");
},
};
}).catch((error: unknown) => {
console.error(error);
process.exit(1);
});
}
function abortError(signal: AbortSignal): Error {
const reason: unknown = signal.reason;
return reason instanceof Error ? reason : new DOMException("The operation was aborted", "AbortError");
}

View file

@ -0,0 +1,9 @@
import { type Context, defineRemoteService, type RemoteState } from "@earendil-works/pi-agent-core";
export interface KeyedProbeService {
readonly state: RemoteState<{ value: string }>;
replace(value: string, context: Context): Promise<void>;
wait(context: Context): Promise<void>;
}
export const KeyedProbe = defineRemoteService<KeyedProbeService>("test.keyed-probe");

View file

@ -2,17 +2,20 @@
Runtime-neutral schemas, types, CBOR encoding, and byte-stream framing for the experimental Pi protocol.
Protocol version `2` contains the first routed Session-operation slice:
Protocol version `3` adds the first plugin-service slice:
- a version handshake that identifies the logical `serverId`;
- explicit server and Session request targets;
- a service RPC manifest with Session discovery, attachment, prompting, and lane-watch operations;
- correlated responses, attachment-scoped lane events, and bounded protocol errors.
- contract-agnostic service calls with optional keyed-instance identity;
- singleton and keyed-service subscription snapshots plus ordered state, spawn, and close updates;
- correlated responses, request cancellation, attachment-scoped events, and bounded protocol errors.
The manifest generates typed client methods and validated endpoint dispatch. The transport carries contract-agnostic `{ serviceId, member, args }` calls. A server target contains `{ serverId }`; a Session target contains `{ serverId, sessionId, attachmentId }`. `list()` and `create()` return presentation-safe `SessionSummary` values containing no working directory, owner, storage, or parent metadata. The server derives private creation fields from authenticated workspace context. `attach()` creates or selects one presentation attachment and returns its Session and attachment IDs. `prompt()` targets that exact live attachment. The real `Session` and `AgentHarness` remain process-local. Disconnecting releases only that presentation's attachment after admitted work settles.
The transport carries `{ serviceId, instance?, member, args }` calls. A server target contains `{ serverId }`; a Session target contains `{ serverId, sessionId, attachmentId }`. Keyed addresses contain a plugin key and provider-owned generation so delayed calls cannot reach a replacement instance. Service subscriptions return a complete member/state snapshot before update delivery starts. `list()` and `create()` return presentation-safe `SessionSummary` values containing no working directory, owner, storage, or parent metadata. The server derives private creation fields from authenticated workspace context. `attach()` creates or selects one presentation attachment and returns its Session and attachment IDs. `prompt()` targets that exact live attachment. The real `Session` and `AgentHarness` remain process-local. Disconnecting releases only that presentation's attachment after admitted work settles.
`PromptArguments` contains one serializable `AgentLane.prompt()` overload. `PromptMessage` is the protocol's closed set of built-in message DTOs; application-defined `AgentMessage` extensions are not accepted implicitly. `RunResult` is the wire-safe structural equivalent of the Harness result and contains no JavaScript `Error` instances.
The temporary `ServiceRpc` manifest still backs discovery, attachment, prompting, and transcript compatibility methods while those features migrate to plugin services. Session plugin calls outside that facade are routed opaquely to the attached worker, where the provider validates and invokes them.
A lane watch uses three RPCs so snapshot ordering does not depend on response/event scheduling: `watch()` creates a buffering watch and returns its authoritative snapshot, `startWatch()` flushes buffered events and begins live delivery, and `stopWatch()` releases it. Events carry their watch ID. Streaming message updates carry compact assistant-message frames rather than cumulative partial messages. Reconnection creates a new watch and snapshot rather than replaying old events.
Server and worker lifecycle is intentionally outside this public protocol. The experimental local coordinator is only an opaque message router; each replaceable server process owns the private lifecycle protocol.

View file

@ -1,7 +1,7 @@
import Type, { type Static } from "typebox";
import { Check } from "typebox/value";
import { LaneEventSchema, LaneSnapshotSchema, PromptArgumentsSchema, RunResultSchema } from "./harness.ts";
import { JsonValueSchema } from "./json-value.ts";
import { type JsonValue, JsonValueSchema } from "./json-value.ts";
import {
createRpcCallSchema,
createRpcResultSchema,
@ -13,7 +13,7 @@ import {
type RpcResultUnion,
} from "./rpc.ts";
export const PROTOCOL_VERSION = 2 as const;
export const PROTOCOL_VERSION = 3 as const;
const IdSchema = Type.String({ minLength: 1 });
const TimestampSchema = Type.Integer({ minimum: 0 });
@ -44,7 +44,7 @@ export const SessionCreateOptionsSchema = StrictObject({
});
export type SessionCreateOptions = Static<typeof SessionCreateOptionsSchema>;
/** Session operations available to normal Pi clients in protocol v1. */
/** Temporary typed facade for the pre-plugin Session operations. */
export const ServiceRpc = defineRpc({
list: {
args: Type.Tuple([]),
@ -84,15 +84,123 @@ export type ServiceRpcResultUnion = RpcResultUnion<ServiceRpcManifest>;
export const ServiceRpcCallSchema = Type.Unsafe<ServiceRpcCall>(createRpcCallSchema(ServiceRpc));
export const ServiceRpcResultSchema = Type.Unsafe<ServiceRpcResultUnion>(createRpcResultSchema(ServiceRpc));
export const ServiceModeSchema = Type.Union([Type.Literal("singleton"), Type.Literal("keyed")]);
export type ServiceMode = Static<typeof ServiceModeSchema>;
export const ServiceInstanceAddressSchema = StrictObject({
key: IdSchema,
generation: Type.Integer({ minimum: 1 }),
});
export type ServiceInstanceAddress = Static<typeof ServiceInstanceAddressSchema>;
/** Contract-agnostic service/member invocation carried by the transport. */
export const ProtocolRpcCallSchema = StrictObject({
serviceId: Type.String({ minLength: 1 }),
instance: Type.Optional(ServiceInstanceAddressSchema),
member: Type.String({ minLength: 1 }),
args: Type.Array(JsonValueSchema),
});
export type ProtocolRpcCall = Static<typeof ProtocolRpcCallSchema>;
export type ProtocolRpcResult = unknown;
const ProtocolRpcResultSchema = Type.Unknown();
export type ProtocolRpcResult = JsonValue | undefined;
export const ServiceMemberDescriptionSchema = StrictObject({
name: IdSchema,
kind: Type.Union([Type.Literal("method"), Type.Literal("state")]),
});
export type ServiceMemberDescription = Static<typeof ServiceMemberDescriptionSchema>;
export const ServiceStateSnapshotSchema = StrictObject({
sequence: Type.Integer({ minimum: 0 }),
value: JsonValueSchema,
});
export type ServiceStateSnapshot = Static<typeof ServiceStateSnapshotSchema>;
export const ServiceInstanceSnapshotSchema = StrictObject({
instance: Type.Optional(ServiceInstanceAddressSchema),
members: Type.Array(ServiceMemberDescriptionSchema),
states: Type.Record(Type.String(), ServiceStateSnapshotSchema),
});
export type ServiceInstanceSnapshot = Static<typeof ServiceInstanceSnapshotSchema>;
export const ServiceSubscriptionSnapshotSchema = StrictObject({
serviceId: Type.String({ minLength: 1 }),
mode: ServiceModeSchema,
instances: Type.Array(ServiceInstanceSnapshotSchema),
});
export type ServiceSubscriptionSnapshot = Static<typeof ServiceSubscriptionSnapshotSchema>;
export function parseServiceSubscriptionSnapshot(value: unknown): ServiceSubscriptionSnapshot {
if (!Check(ServiceSubscriptionSnapshotSchema, value)) {
throw new TypeError("Invalid service subscription snapshot");
}
return value;
}
export const ServiceProviderUpdateSchema = Type.Union([
StrictObject({
type: Type.Literal("state"),
instance: Type.Optional(ServiceInstanceAddressSchema),
member: IdSchema,
sequence: Type.Integer({ minimum: 1 }),
value: JsonValueSchema,
}),
StrictObject({
type: Type.Literal("spawned"),
instance: ServiceInstanceSnapshotSchema,
}),
StrictObject({
type: Type.Literal("closed"),
instance: ServiceInstanceAddressSchema,
}),
]);
export type ServiceProviderUpdate = Static<typeof ServiceProviderUpdateSchema>;
export const SERVICE_CONTROL_ID = "$pi.service";
export const SERVICE_SUBSCRIBE_MEMBER = "subscribe";
export const SERVICE_UNSUBSCRIBE_MEMBER = "unsubscribe";
export type ServiceControlCall =
| {
readonly type: "subscribe";
readonly subscriptionId: string;
readonly serviceId: string;
readonly mode: ServiceMode;
}
| { readonly type: "unsubscribe"; readonly subscriptionId: string };
export function createServiceSubscribeCall(
subscriptionId: string,
serviceId: string,
mode: ServiceMode,
): ProtocolRpcCall {
return { serviceId: SERVICE_CONTROL_ID, member: SERVICE_SUBSCRIBE_MEMBER, args: [subscriptionId, serviceId, mode] };
}
export function createServiceUnsubscribeCall(subscriptionId: string): ProtocolRpcCall {
return { serviceId: SERVICE_CONTROL_ID, member: SERVICE_UNSUBSCRIBE_MEMBER, args: [subscriptionId] };
}
export function decodeServiceControlCall(call: ProtocolRpcCall): ServiceControlCall | undefined {
if (call.serviceId !== SERVICE_CONTROL_ID || call.instance !== undefined) return undefined;
if (
call.member === SERVICE_SUBSCRIBE_MEMBER &&
call.args.length === 3 &&
typeof call.args[0] === "string" &&
typeof call.args[1] === "string" &&
(call.args[2] === "singleton" || call.args[2] === "keyed")
) {
return {
type: "subscribe",
subscriptionId: call.args[0],
serviceId: call.args[1],
mode: call.args[2],
};
}
if (call.member === SERVICE_UNSUBSCRIBE_MEMBER && call.args.length === 1 && typeof call.args[0] === "string") {
return { type: "unsubscribe", subscriptionId: call.args[0] };
}
return undefined;
}
const ServiceRpcAddresses = {
list: { serviceId: "session-directory", member: "list" },
@ -111,6 +219,7 @@ export function encodeServiceRpcCall(call: ServiceRpcCall): ProtocolRpcCall {
/** Validate and decode one generic envelope against the current built-in contract. */
export function decodeServiceRpcCall(call: ProtocolRpcCall): ServiceRpcCall | undefined {
if (call.instance !== undefined) return undefined;
for (const [method, address] of Object.entries(ServiceRpcAddresses)) {
if (call.serviceId !== address.serviceId || call.member !== address.member) continue;
const candidate = { method, args: call.args };
@ -119,6 +228,18 @@ export function decodeServiceRpcCall(call: ProtocolRpcCall): ServiceRpcCall | un
return undefined;
}
export const ServiceErrorCodeSchema = Type.Union([
Type.Literal("service_not_allowed"),
Type.Literal("service_not_found"),
Type.Literal("service_mode_mismatch"),
Type.Literal("service_member_not_found"),
Type.Literal("service_member_mismatch"),
Type.Literal("service_instance_not_found"),
Type.Literal("service_stale_instance"),
Type.Literal("service_invalid_value"),
]);
export type ServiceErrorCode = Static<typeof ServiceErrorCodeSchema>;
export const ProtocolErrorCodeSchema = Type.Union([
Type.Literal("version"),
Type.Literal("wrong_server"),
@ -129,6 +250,7 @@ export const ProtocolErrorCodeSchema = Type.Union([
Type.Literal("watch_in_use"),
Type.Literal("not_supported"),
Type.Literal("server_draining"),
ServiceErrorCodeSchema,
Type.Literal("invalid_request"),
Type.Literal("cancelled"),
Type.Literal("internal_error"),
@ -204,7 +326,7 @@ export const ResponseEnvelopeSchema = Type.Union([
type: Type.Literal("response"),
id: IdSchema,
ok: Type.Literal(true),
result: ProtocolRpcResultSchema,
result: Type.Optional(JsonValueSchema),
}),
StrictObject({
type: Type.Literal("response"),
@ -218,14 +340,22 @@ export const EventEnvelopeSchema = StrictObject({
watchId: IdSchema,
event: LaneEventSchema,
});
export const ServiceEventEnvelopeSchema = StrictObject({
type: Type.Literal("service_event"),
subscriptionId: IdSchema,
update: ServiceProviderUpdateSchema,
});
export const ServerMessageSchema = Type.Union([
ServerHelloSchema,
ServerHelloErrorSchema,
ResponseEnvelopeSchema,
EventEnvelopeSchema,
ServiceEventEnvelopeSchema,
]);
export type ServerHello = Static<typeof ServerHelloSchema>;
export type ServerHelloError = Static<typeof ServerHelloErrorSchema>;
export type ResponseEnvelope = Static<typeof ResponseEnvelopeSchema>;
export type EventEnvelope = Static<typeof EventEnvelopeSchema>;
export type ServiceEventEnvelope = Static<typeof ServiceEventEnvelopeSchema>;
export type ServerEventEnvelope = EventEnvelope | ServiceEventEnvelope;
export type ServerMessage = Static<typeof ServerMessageSchema>;

View file

@ -6,7 +6,10 @@ import {
ClientMessageDecoder,
createRpcClient,
createRpcDispatcher,
createServiceSubscribeCall,
createServiceUnsubscribeCall,
decodeCbor,
decodeServiceControlCall,
decodeServiceRpcCall,
defineRpc,
encodeCbor,
@ -167,11 +170,11 @@ describe("RPC manifest", () => {
});
describe("protocol validation", () => {
test("negotiates protocol version 2", () => {
expect(PROTOCOL_VERSION).toBe(2);
expect(isSupportedProtocolVersion(2)).toBe(true);
expect(isSupportedProtocolVersion(1)).toBe(false);
expect(isSupportedProtocolVersion(2.5)).toBe(false);
test("negotiates protocol version 3", () => {
expect(PROTOCOL_VERSION).toBe(3);
expect(isSupportedProtocolVersion(3)).toBe(true);
expect(isSupportedProtocolVersion(2)).toBe(false);
expect(isSupportedProtocolVersion(3.5)).toBe(false);
});
test.each([0, PROTOCOL_VERSION, PROTOCOL_VERSION + 1])(
@ -204,6 +207,36 @@ describe("protocol validation", () => {
).toThrow(ProtocolValidationError);
});
test("encodes service control and keyed instance addresses", () => {
const subscribe = createServiceSubscribeCall("subscription-1", "models", "singleton");
expect(decodeServiceControlCall(subscribe)).toEqual({
type: "subscribe",
subscriptionId: "subscription-1",
serviceId: "models",
mode: "singleton",
});
expect(decodeServiceControlCall(createServiceUnsubscribeCall("subscription-1"))).toEqual({
type: "unsubscribe",
subscriptionId: "subscription-1",
});
const keyed: ClientMessage = {
type: "request",
id: "request-1",
target: {
serverId: "00000000-0000-4000-8000-000000000001",
sessionId: "session-1",
attachmentId: "attachment-1",
},
call: {
serviceId: "question-dialog",
instance: { key: "invocation-1", generation: 2 },
member: "submit",
args: [{ outcome: "selected", index: 0 }],
},
};
expect(parseClientMessage(keyed)).toEqual(keyed);
});
test("keeps transport RPC calls untyped while validating their envelope", () => {
const list: ClientMessage = {
type: "request",
@ -490,9 +523,16 @@ describe("protocol validation", () => {
expect(() => parseClientMessage(message)).toThrow(ProtocolValidationError);
});
test("accepts a successful void response without a result field", () => {
expect(parseServerMessage({ type: "response", id: "request-1", ok: true })).toEqual({
type: "response",
id: "request-1",
ok: true,
});
});
test.each([
["invalid server id", { ...serverHello, serverId: "server-1" }],
["missing response result", { type: "response", id: "request-1", ok: true }],
["extra response field", { type: "response", id: "request-1", ok: true, result: [], extra: true }],
] as const)("rejects malformed server boundaries: %s", (_label, message) => {
expect(() => parseServerMessage(message)).toThrow(ProtocolValidationError);

View file

@ -2,12 +2,14 @@
Experimental local server for the new durable Session and Agent Harness interfaces.
The current slice supports Session discovery, creation, multi-presentation attachment, prompting, and optional main-lane observation. `RoutedSessionHandle.attachClient()` returns a presentation-scoped capability whose optional `watch()` supplies an authoritative snapshot plus buffered events. The server owns each attachment's single watch ID, starts delivery only after the client has received the snapshot, and removes the watch when that attachment is released.
The current slice supports Session discovery, creation, multi-presentation attachment, plugin-service routing, prompting, and optional main-lane observation. `RoutedSessionHandle.attachClient()` returns a presentation-scoped capability. Its optional `invokeService()` forwards an opaque service/member envelope to the selected Session endpoint; the server validates the attachment route but does not load the plugin contract. Its optional `watch()` supplies an authoritative snapshot plus buffered events.
- `list` calls the host's private Session catalog without opening Sessions and projects presentation-safe summaries.
- `create` asks the host to persist an optional Session ID; private workspace and working-directory data are server-derived.
- `attach` finds the requested metadata, passes it to the host, and retains the returned routed Session handle in the server.
- `prompt` executes one serializable prompt through the requesting presentation's attachment capability.
- unknown Session service calls route through `invokeService` without server-side business-payload decoding;
- service subscription updates remain scoped to the requesting attachment;
- `watch`, `startWatch`, and `stopWatch` provide snapshot-first lane observation when supported by the host.
A Session may have multiple presentation attachments. Repeating `attach` from one connection is idempotent; every successful attachment has a server-generated `attachmentId`. Session requests carry `{ serverId, sessionId, attachmentId }`, and the server rejects stale or mismatched routes. Losing a connection rejects its local responses but releases its attachment only after admitted prompts settle. The host decides when zero presentation demand and worker-local Harness activity permit worker retirement. Server shutdown closes every routed Session handle, releasing its worker and Session writer ownership.

View file

@ -1,16 +1,18 @@
import type { ProtocolErrorCode } from "@earendil-works/pi-protocol";
import type { ProtocolErrorCode, ServiceErrorCode } from "@earendil-works/pi-protocol";
type PiServerOperationErrorCode = Extract<
ProtocolErrorCode,
| "wrong_server"
| "session_not_found"
| "session_ambiguous"
| "session_not_attached"
| "watch_not_found"
| "watch_in_use"
| "not_supported"
| "server_draining"
>;
type PiServerOperationErrorCode =
| ServiceErrorCode
| Extract<
ProtocolErrorCode,
| "wrong_server"
| "session_not_found"
| "session_ambiguous"
| "session_not_attached"
| "watch_not_found"
| "watch_in_use"
| "not_supported"
| "server_draining"
>;
export const INTERNAL_SERVER_ERROR_MESSAGE = "Internal server error";

View file

@ -298,17 +298,27 @@ export class PiServer<TMetadata extends SessionMetadata = SessionMetadata> {
},
context,
);
} else if ("sessionId" in envelope.target) {
result = await this.sessions.executeServiceCall(
envelope.call,
envelope.target,
state,
async (message, _context) => {
await this.sendMessage(state, message);
},
context,
);
} else {
throw new ProtocolValidationError(
`Unknown service member ${envelope.call.serviceId}.${envelope.call.member}`,
);
}
await this.sendMessage(state, {
type: "response",
id: envelope.id,
ok: true,
result,
} satisfies ResponseEnvelope);
await this.sendMessage(
state,
result === undefined
? { type: "response", id: envelope.id, ok: true }
: { type: "response", id: envelope.id, ok: true, result },
);
} catch (error) {
await this.sendMessage(state, {
type: "response",

View file

@ -4,9 +4,12 @@ import {
createRpcDispatcher,
type EventEnvelope,
type PromptArguments,
type ProtocolRpcCall,
type ProtocolRpcResult,
ProtocolValidationError,
type RpcTarget,
type RunResult,
type ServerEventEnvelope,
ServiceRpc,
type ServiceRpcCall,
type ServiceRpcResultUnion,
@ -39,7 +42,7 @@ interface ClientAttachment {
readonly id: string;
readonly client: object;
readonly session: HostedSession;
readonly prompts: Set<Promise<RunResult>>;
readonly operations: Set<Promise<unknown>>;
acquiring?: Promise<RoutedSessionAttachment>;
lease?: RoutedSessionAttachment;
releasing?: Promise<void>;
@ -128,6 +131,19 @@ export class SessionRouter<TMetadata extends SessionMetadata = SessionMetadata>
return this.dispatchRpc(call, { client, target, publish, context });
}
async executeServiceCall(
call: ProtocolRpcCall,
target: RpcTarget,
client: object,
publish: (message: ServerEventEnvelope, context: Context) => Promise<void>,
context: Context,
): Promise<ProtocolRpcResult> {
const admitted = await this.runForClient(client, () =>
this.startServiceCall(client, target, call, publish, context),
);
return admitted.result;
}
async disconnect(client: object, context: Context): Promise<void> {
this.disconnectedClients.add(client);
try {
@ -205,15 +221,14 @@ export class SessionRouter<TMetadata extends SessionMetadata = SessionMetadata>
if (this.options.isClosing() || this.disconnectedClients.has(client)) throw new ServerDrainingError();
const current = this.attachmentsByClient.get(client);
if (current?.session.id === sessionId) return { sessionId, attachmentId: current.id };
if (current) await this.releaseAttachment(current, context);
const hosted = await this.acquire(sessionId, context);
if (this.options.isClosing() || this.disconnectedClients.has(client)) throw new ServerDrainingError();
if (current) await this.releaseAttachment(current, context);
const attachment: ClientAttachment = {
id: randomUUID(),
client,
session: hosted,
prompts: new Set(),
operations: new Set(),
};
hosted.attachments.add(attachment);
try {
@ -247,12 +262,37 @@ export class SessionRouter<TMetadata extends SessionMetadata = SessionMetadata>
const lease = attachment.lease;
if (lease === undefined) throw new SessionNotAttachedError();
const result = lease.prompt(prompt, context);
attachment.prompts.add(result);
this.trackOperation(attachment, result);
return { result };
}
private async startServiceCall(
client: object,
target: RpcTarget,
call: ProtocolRpcCall,
publish: (message: ServerEventEnvelope, context: Context) => Promise<void>,
context: Context,
): Promise<{ result: Promise<ProtocolRpcResult> }> {
const attachment = this.requireAttachment(client, target);
const invoke = attachment.lease?.invokeService;
if (invoke === undefined) throw new NotSupportedError("Routed Session does not support plugin services");
const result = invoke.call(
attachment.lease,
call,
(subscriptionId, update, updateContext) =>
publish({ type: "service_event", subscriptionId, update }, updateContext),
context,
);
this.trackOperation(attachment, result);
return { result };
}
private trackOperation(attachment: ClientAttachment, result: Promise<unknown>): void {
attachment.operations.add(result);
const remove = (): void => {
attachment.prompts.delete(result);
attachment.operations.delete(result);
};
void result.then(remove, remove);
return { result };
}
private async createWatch(
@ -339,7 +379,7 @@ export class SessionRouter<TMetadata extends SessionMetadata = SessionMetadata>
} catch (error) {
errors.push(error);
}
await Promise.allSettled(attachment.prompts);
await Promise.allSettled(attachment.operations);
try {
const lease = attachment.lease ?? (await attachment.acquiring);
if (lease) await lease.release(context);

View file

@ -3,7 +3,10 @@ import type {
LaneEvent,
LaneSnapshot,
PromptArguments,
ProtocolRpcCall,
ProtocolRpcResult,
RunResult,
ServiceProviderUpdate,
SessionCreateOptions,
} from "@earendil-works/pi-protocol";
import type { PiServerListener } from "./listener.ts";
@ -24,6 +27,12 @@ export type MaybePromise<T> = T | Promise<T>;
export interface RoutedSessionAttachment {
/** Execute one serializable prompt through this attachment. */
prompt(prompt: PromptArguments, context: Context): Promise<RunResult>;
/** Route one contract-agnostic service operation to the attached Session endpoint. */
invokeService?(
call: ProtocolRpcCall,
publish: (subscriptionId: string, update: ServiceProviderUpdate, context: Context) => MaybePromise<void>,
context: Context,
): Promise<ProtocolRpcResult>;
/** Observe the attached main lane when supported by this host. */
watch?(context: Context): Promise<RoutedSessionWatch>;
release(context: Context): MaybePromise<void>;