feat(protocol): add service-addressed session RPC

This commit is contained in:
Christian Klotz 2026-08-13 18:24:27 +03:00
parent 48bfdbaff5
commit e52de91d08
No known key found for this signature in database
58 changed files with 970 additions and 6298 deletions

5
package-lock.json generated
View file

@ -5500,8 +5500,6 @@
"dependencies": {
"@earendil-works/pi-agent-core": "^0.84.1",
"@earendil-works/pi-ai": "^0.84.1",
"@earendil-works/pi-client": "^0.84.1",
"@earendil-works/pi-protocol": "^0.84.1",
"@earendil-works/pi-tui": "^0.84.1",
"@silvia-odwyer/photon-node": "0.3.4",
"chalk": "5.6.2",
@ -5673,6 +5671,7 @@
"version": "0.84.1",
"license": "MIT",
"dependencies": {
"@earendil-works/pi-agent-core": "^0.84.1",
"typebox": "1.3.7"
},
"devDependencies": {
@ -5688,7 +5687,7 @@
"version": "0.84.1",
"license": "MIT",
"dependencies": {
"@earendil-works/pi-ai": "^0.84.1",
"@earendil-works/pi-agent-core": "^0.84.1",
"@earendil-works/pi-protocol": "^0.84.1"
},
"devDependencies": {

View file

@ -1,6 +1,6 @@
# @earendil-works/pi-client
Transport-neutral client for remote pi sessions. `PiClient` exchanges length-prefixed CBOR messages through a small `ByteTransport` interface. The package has no Node-specific imports.
Transport-neutral client for the initial Pi `list` and `attach` protocol slice.
```ts
import { PiClient, type ByteTransportFactory } from "@earendil-works/pi-client";
@ -9,55 +9,45 @@ const transportFactory: ByteTransportFactory = async (handlers) => {
// Connect using WebSocket, Unix socket, or another ordered byte transport.
return {
async send(chunk) {
// Deliver chunks in invocation order and honor backpressure.
// Deliver bytes in invocation order and honor backpressure.
},
close() {},
};
};
const client = new PiClient({ transportFactory });
await client.connect();
const session = await client.createSession({ cwd: "/workspace" });
const unsubscribe = session.subscribe((snapshot) => render(snapshot));
await session.prompt("Inspect this project");
unsubscribe();
const client = await PiClient.connect({
serviceId: "expected-logical-service-id",
transportFactory,
});
const sessions = await client.listSessions();
const attachment = await client.attachSession(sessions[0].id);
```
Call `handlers.onData(chunk)` for inbound bytes, `handlers.onClose()` for an orderly terminal close, and `handlers.onError(error)` for transport failures. A factory must create a fresh transport for every connection attempt and complete any transport-specific authentication before resolving. For example, a WebSocket factory can provide credentials in its upgrade request.
The client verifies that the physical endpoint reports the expected logical `serviceId`. Every list and attach request carries that ID again so the final server can reject misdelivery.
`PiClient` does not reconnect automatically. Call `reconnect()` after disconnection. One connection can attach several sessions. Requests are correlated by ID. Server snapshots and successful response snapshots are authoritative, while progress events do not mutate snapshot state optimistically. Read cached session metadata from `client.snapshot?.sessions`; call `listSessions()` to request refreshed durable metadata from the server. Runtime state is available after acquiring a session.
`attachSession()` currently returns only `{ sessionId }`. Remote Session and Harness methods will be added directly from the new shared interfaces in a later slice. The client does not reconnect or replay requests automatically. After disconnection, call `reconnect()` and explicitly repeat safe control-plane actions.
`acquireSession()` returns an independent `SessionLease`; leases cannot be constructed directly. Use `{ mode: "exclusive" }` for a lifecycle or mutation coordinator and `{ mode: "shared" }` when multiple low-level consumers intentionally share the session. Exclusive acquisition fails with `PiSessionOwnershipError` while any lease exists, and shared acquisition fails while an exclusive lease exists. `attachSession()` is a shared-acquisition convenience method. `createSession()` returns an exclusive lease for the newly created session.
Call transport handlers as follows:
Calling `dispose()` or `detach()` releases only that lease. A lease rejects commands as soon as release begins. The client sends the protocol detach request after the final lease is released. If explicit `detach()` fails, the lease becomes active again for retry. If cleanup-oriented `dispose()` fails, it reports the protocol error but relinquishes local ownership; `PiClient` reconciles the failed protocol cleanup before the next acquisition. A released lease becomes unavailable without affecting other shared leases. Server removal or disconnection invalidates every lease for the affected attachment, and disposing an invalidated lease is a no-op. Commands fail with `PiDisconnectedError` while the client is disconnected and `PiSessionDetachedError` when the client is connected but a lease is releasing, released, or invalidated. Leases implement `AsyncDisposable`.
- `handlers.onData(chunk)` for inbound bytes;
- `handlers.onClose()` for an orderly terminal close;
- `handlers.onError(error)` for transport failures.
`subscribe()` observes authoritative snapshots. `onEvent()` observes protocol events. Both return an unsubscribe function. Structured errors returned by the server are exposed as `PiServerError`.
## Limits and security
`PiClientOptions.maxFrameLength` bounds inbound and outbound CBOR payloads. Configure matching limits on the client and server. Transports should separately bound queued outbound bytes and preserve send order.
Treat peers as untrusted. Use a secure transport with appropriate access controls and authenticate during transport establishment.
Subscriber exceptions are isolated from protocol state. Set `onListenerError` in `PiClientOptions` to report them to application logging or diagnostics.
A transport factory creates a fresh authenticated connection for each attempt. Requests are correlated by ID, and server failures are exposed as `PiServerError`.
## Unix-domain sockets
Node.js and Bun consumers can use the separately exported Unix-domain socket transport:
Node.js and Bun consumers can use the separate Unix transport:
```ts
import { PiClient } from "@earendil-works/pi-client";
import { createUnixTransportFactory } from "@earendil-works/pi-client/unix";
const client = new PiClient({
transportFactory: createUnixTransportFactory({
path: "/tmp/pi.sock",
}),
serviceId: "expected-logical-service-id",
transportFactory: createUnixTransportFactory({ path: "/tmp/pi.sock" }),
});
await client.connect();
```
`maxPendingBytes` bounds queued outbound data. It defaults to four times the protocol frame limit. The transport preserves send order and waits for socket backpressure before resolving each send.
The `@earendil-works/pi-client` root remains transport- and runtime-neutral. Importing the Node-compatible transport requires the explicit `@earendil-works/pi-client/unix` subpath.
`PiClientOptions.maxFrameLength` bounds protocol payloads. `maxPendingBytes` bounds queued Unix transport output. Configure matching limits on both peers.

View file

@ -1,80 +1,55 @@
import {
type Command,
type CommandResult,
type EventEnvelope,
createRpcClient,
encodeClientMessage,
ProtocolValidationError,
type ResponseEnvelope,
type ResultForCommand,
type ServerEvent,
type ServerSnapshot,
type ServerHello,
ServiceRpc,
type ServiceRpcCall,
type ServiceRpcResult,
type ServiceRpcResultUnion,
type SessionMetadata,
} from "@earendil-works/pi-protocol";
import { Connection } from "./connection.ts";
import {
PiClientDisposedError,
PiDisconnectedError,
PiServerError,
PiSessionDetachedError,
PiSessionOwnershipError,
toError,
} from "./errors.ts";
import { PiClientDisposedError, PiDisconnectedError, PiServerError, toError } from "./errors.ts";
import { createPromiseResolvers } from "./promise.ts";
import {
type AcquireSessionOptions,
type PiSessionHandle,
SessionHandle,
type SessionHandleCallbacks,
type SessionLeaseMode,
} from "./session-handle.ts";
import { ClientState } from "./state.ts";
import type {
ConnectionState,
ConnectionStateChange,
CreateSessionOptions,
PiClientOptions,
Unsubscribe,
} from "./types.ts";
type SessionLeaseState = "active" | "releasing" | "released" | "invalidated";
interface SessionLeaseToken {
readonly mode: SessionLeaseMode;
}
import type { ConnectionState, ConnectionStateChange, PiClientOptions, Unsubscribe } from "./types.ts";
interface PendingRequest {
command: Command;
resolve(result: CommandResult): void;
call: ServiceRpcCall;
resolve(result: ServiceRpcResultUnion): void;
reject(error: Error): void;
}
export class PiClient {
readonly #options: PiClientOptions;
readonly #connection: Connection;
readonly #state: ClientState;
readonly #pendingRequests = new Map<string, PendingRequest>();
readonly #sessionLeaseCounts = new Map<string, number>();
readonly #exclusiveSessionLeases = new Map<string, SessionLeaseToken>();
readonly #sessionLeaseGenerations = new Map<string, number>();
readonly #sessionAttachments = new Map<string, Promise<void>>();
readonly #sessionDetachments = new Map<string, Promise<void>>();
readonly #sessionCleanupRequired = new Set<string>();
readonly #sessionReconciliations = new Map<string, Promise<void>>();
readonly #connectionStateListeners = new Set<(change: ConnectionStateChange) => void>();
readonly #rpc: ReturnType<typeof createRpcClient<typeof ServiceRpc>>;
#requestSequence = 0;
#hello: ServerHello | undefined;
#disposed = false;
#disposePromise: Promise<void> | undefined;
constructor(options: PiClientOptions) {
if (!options.serviceId) throw new TypeError("PiClient serviceId must not be empty");
this.#options = options;
this.#state = new ClientState(options.onListenerError);
this.#connection = new Connection({
transportFactory: options.transportFactory,
serviceId: options.serviceId,
maxFrameLength: options.maxFrameLength,
onHandshake: (snapshot) => this.#state.applyServerSnapshot(snapshot),
onHandshake: (hello) => {
this.#hello = hello;
},
onMessage: (message) => this.#handleMessage(message),
onStateChange: (change) => this.#handleConnectionStateChange(change),
});
this.#rpc = createRpcClient(
ServiceRpc,
(call) => this.#request(call),
(message) => new ProtocolValidationError(message),
);
}
get disposed(): boolean {
@ -89,8 +64,8 @@ export class PiClient {
return this.#connection.state === "connected";
}
get snapshot(): ServerSnapshot | undefined {
return this.#state.snapshot;
get hello(): ServerHello | undefined {
return this.#hello;
}
static async connect(options: PiClientOptions): Promise<PiClient> {
@ -104,13 +79,13 @@ export class PiClient {
}
}
connect(): Promise<ServerSnapshot> {
connect(): Promise<ServerHello> {
if (this.#disposed) return Promise.reject(new PiClientDisposedError());
if (this.#connection.state === "disconnected") this.#state.reset();
this.#hello = undefined;
return this.#connection.connect();
}
reconnect(): Promise<ServerSnapshot> {
reconnect(): Promise<ServerHello> {
return this.connect();
}
@ -118,185 +93,41 @@ export class PiClient {
this.#connection.disconnect(reason);
}
subscribe(listener: (snapshot: ServerSnapshot) => void): Unsubscribe {
this.#assertNotDisposed();
return this.#state.subscribe(listener);
}
onEvent(listener: (event: ServerEvent) => void): Unsubscribe {
this.#assertNotDisposed();
return this.#state.onEvent(listener);
}
onConnectionStateChange(listener: (change: ConnectionStateChange) => void): Unsubscribe {
this.#assertNotDisposed();
this.#connectionStateListeners.add(listener);
return () => this.#connectionStateListeners.delete(listener);
}
async listSessions(): Promise<readonly SessionMetadata[]> {
return (await this.#request({ command: "list" })).sessions;
listSessions(): Promise<readonly SessionMetadata[]> {
return this.#rpc.list();
}
async createSession(options: CreateSessionOptions = {}): Promise<PiSessionHandle> {
const result = await this.#request({ command: "create", ...options });
const token = this.#reserveSessionLease(result.session.id, "exclusive");
return this.#createSessionLease(result.session.id, token);
attachSession(sessionId: string): Promise<ServiceRpcResult<"attach">> {
return this.#rpc.attach(sessionId);
}
async attachSession(sessionId: string): Promise<PiSessionHandle> {
return this.acquireSession(sessionId, { mode: "shared" });
}
async acquireSession(sessionId: string, options: AcquireSessionOptions): Promise<PiSessionHandle> {
this.#assertNotDisposed();
const token = this.#reserveSessionLease(sessionId, options.mode);
try {
const detachment = this.#sessionDetachments.get(sessionId);
if (detachment) await detachment.catch(() => {});
const reconciled = this.#sessionCleanupRequired.has(sessionId)
? await this.#reconcileSessionCleanup(sessionId)
: false;
if (reconciled || !this.#state.isSessionAttached(sessionId)) {
let attachment = this.#sessionAttachments.get(sessionId);
if (!attachment) {
attachment = this.#attachSession(sessionId);
this.#sessionAttachments.set(sessionId, attachment);
}
try {
await attachment;
} finally {
if (this.#sessionAttachments.get(sessionId) === attachment) this.#sessionAttachments.delete(sessionId);
}
}
return this.#createSessionLease(sessionId, token);
} catch (error) {
this.#releaseSessionLease(sessionId, token);
throw error;
}
}
async #attachSession(sessionId: string): Promise<void> {
const previous = this.#state.forgetSessionSnapshot(sessionId);
try {
await this.#request({ command: "attach", sessionId });
} catch (error) {
if (previous) this.#state.restoreSessionSnapshot(previous);
throw error;
}
}
#request<const TCommand extends Command>(command: TCommand): Promise<ResultForCommand<TCommand>> {
#request(call: ServiceRpcCall): Promise<ServiceRpcResultUnion> {
if (this.#disposed) return Promise.reject(new PiClientDisposedError());
if (!this.connected) return Promise.reject(new PiDisconnectedError());
const id = `request-${++this.#requestSequence}`;
const { promise, resolve, reject } = createPromiseResolvers<CommandResult>();
this.#pendingRequests.set(id, { command, resolve, reject });
const { promise, resolve, reject } = createPromiseResolvers<ServiceRpcResultUnion>();
this.#pendingRequests.set(id, { call, resolve, reject });
let frame: Uint8Array;
try {
frame = encodeClientMessage(
{ type: "request", id, request: command },
{ type: "request", id, serviceId: this.#options.serviceId, call },
{ maxFrameLength: this.#connection.maxFrameLength },
);
} catch (error) {
this.#takePendingRequest(id)?.reject(toError(error));
return promise as Promise<ResultForCommand<TCommand>>;
return promise;
}
this.#connection.send(frame);
return promise as Promise<ResultForCommand<TCommand>>;
return promise;
}
#createSessionLease(sessionId: string, token: SessionLeaseToken): PiSessionHandle {
const generation = this.#sessionLeaseGenerations.get(sessionId) ?? 0;
this.#sessionLeaseGenerations.set(sessionId, generation);
let state: SessionLeaseState = "active";
let releasePromise: Promise<void> | undefined;
const refreshState = () => {
if (
(state === "active" || state === "releasing") &&
this.#sessionLeaseGenerations.get(sessionId) !== generation
) {
state = "invalidated";
}
};
const isActive = () => {
refreshState();
return state === "active" && this.#state.isSessionAttached(sessionId);
};
const assertActive = () => {
this.#assertNotDisposed();
if (!this.connected) throw new PiDisconnectedError();
if (!isActive()) throw new PiSessionDetachedError(sessionId);
};
const release = (relinquishOnFailure: boolean): Promise<void> => {
refreshState();
if (state === "released" || state === "invalidated") return Promise.resolve();
if (releasePromise) return releasePromise;
assertActive();
state = "releasing";
releasePromise = (async () => {
const count = this.#sessionLeaseCounts.get(sessionId) ?? 0;
if (count <= 1) {
const detachment = this.#request({ command: "detach", sessionId }).then(() => undefined);
this.#sessionDetachments.set(sessionId, detachment);
try {
await detachment;
this.#releaseSessionLease(sessionId, token);
} finally {
if (this.#sessionDetachments.get(sessionId) === detachment) {
this.#sessionDetachments.delete(sessionId);
}
}
} else {
this.#releaseSessionLease(sessionId, token);
}
state = "released";
})().catch((error: unknown) => {
refreshState();
if (state === "invalidated") return;
if (relinquishOnFailure) {
this.#releaseSessionLease(sessionId, token);
this.#sessionCleanupRequired.add(sessionId);
state = "released";
} else {
state = "active";
releasePromise = undefined;
}
throw error;
});
return releasePromise;
};
const callbacks: SessionHandleCallbacks = {
isAttached: isActive,
getSnapshot: () => (isActive() ? this.#state.getSessionSnapshot(sessionId) : undefined),
subscribe: (listener) => {
assertActive();
return this.#state.subscribeSession(sessionId, (snapshot) => {
if (isActive()) listener(snapshot);
});
},
onEvent: (listener) => {
assertActive();
return this.#state.onSessionEvent(sessionId, (event) => {
if (isActive() || event.type === "session_removed") listener(event);
});
},
detach: () => release(false),
dispose: () => release(true),
request: (command) => {
assertActive();
return this.#request(command);
},
};
return new SessionHandle(sessionId, callbacks);
}
#handleMessage(message: ResponseEnvelope | EventEnvelope): void {
if (message.type === "event") {
if (message.event.type === "session_removed") this.#invalidateSessionLeases(message.event.sessionId);
this.#state.applyEvent(message.event);
return;
}
#handleMessage(message: ResponseEnvelope): void {
const pending = this.#takePendingRequest(message.id);
if (!pending) {
this.#connection.fail(new ProtocolValidationError("Response has no matching request"));
@ -306,25 +137,21 @@ export class PiClient {
pending.reject(new PiServerError(message.error));
return;
}
if (message.result.command !== pending.command.command) {
const error = new ProtocolValidationError(
`Response command ${message.result.command} does not match ${pending.command.command}`,
);
pending.reject(error);
this.#connection.fail(error);
return;
}
this.#state.applyResult(message.result);
pending.resolve(message.result);
}
#handleConnectionStateChange(change: ConnectionStateChange): void {
if (change.state === "disconnected") {
this.#state.clearAttachments();
this.#invalidateAllSessionLeases();
this.#hello = undefined;
this.#rejectPendingRequests(change.error ?? new PiDisconnectedError());
}
this.#notifyConnectionStateListeners(change);
for (const listener of this.#connectionStateListeners) {
try {
listener(change);
} catch (error) {
this.#reportListenerError(error);
}
}
}
#takePendingRequest(id: string): PendingRequest | undefined {
@ -346,8 +173,7 @@ export class PiClient {
const error = new PiClientDisposedError();
this.#rejectPendingRequests(error);
this.#connection.disconnect(error);
this.#state.dispose();
this.#invalidateAllSessionLeases();
this.#hello = undefined;
this.#connectionStateListeners.clear();
return this.#disposePromise;
}
@ -360,67 +186,6 @@ export class PiClient {
if (this.#disposed) throw new PiClientDisposedError();
}
async #reconcileSessionCleanup(sessionId: string): Promise<boolean> {
if (!this.#sessionCleanupRequired.has(sessionId)) return false;
let reconciliation = this.#sessionReconciliations.get(sessionId);
if (!reconciliation) {
reconciliation = this.#request({ command: "detach", sessionId })
.then(() => undefined)
.then(() => {
this.#sessionCleanupRequired.delete(sessionId);
})
.finally(() => {
this.#sessionReconciliations.delete(sessionId);
});
this.#sessionReconciliations.set(sessionId, reconciliation);
}
await reconciliation;
return true;
}
#reserveSessionLease(sessionId: string, mode: SessionLeaseMode): SessionLeaseToken {
const count = this.#sessionLeaseCounts.get(sessionId) ?? 0;
if (mode === "exclusive" && count > 0) {
throw new PiSessionOwnershipError(sessionId, `Session ${sessionId} already has an active lease`);
}
if (mode === "shared" && this.#exclusiveSessionLeases.has(sessionId)) {
throw new PiSessionOwnershipError(sessionId, `Session ${sessionId} has an exclusive lease`);
}
const token: SessionLeaseToken = { mode };
this.#sessionLeaseCounts.set(sessionId, count + 1);
if (mode === "exclusive") this.#exclusiveSessionLeases.set(sessionId, token);
return token;
}
#releaseSessionLease(sessionId: string, token: SessionLeaseToken): void {
const count = this.#sessionLeaseCounts.get(sessionId) ?? 0;
if (count <= 1) this.#sessionLeaseCounts.delete(sessionId);
else this.#sessionLeaseCounts.set(sessionId, count - 1);
if (this.#exclusiveSessionLeases.get(sessionId) === token) this.#exclusiveSessionLeases.delete(sessionId);
}
#invalidateSessionLeases(sessionId: string): void {
this.#sessionLeaseCounts.delete(sessionId);
this.#exclusiveSessionLeases.delete(sessionId);
this.#sessionCleanupRequired.delete(sessionId);
this.#sessionLeaseGenerations.set(sessionId, (this.#sessionLeaseGenerations.get(sessionId) ?? 0) + 1);
}
#invalidateAllSessionLeases(): void {
for (const sessionId of this.#sessionLeaseCounts.keys()) this.#invalidateSessionLeases(sessionId);
this.#sessionCleanupRequired.clear();
}
#notifyConnectionStateListeners(change: ConnectionStateChange): void {
for (const listener of this.#connectionStateListeners) {
try {
listener(change);
} catch (error) {
this.#reportListenerError(error);
}
}
}
#reportListenerError(error: unknown): void {
if (!this.#options.onListenerError) return;
try {

View file

@ -3,9 +3,9 @@ import {
encodeClientMessage,
PROTOCOL_VERSION,
ProtocolValidationError,
type ServerHello,
type ServerMessage,
ServerMessageDecoder,
type ServerSnapshot,
} from "@earendil-works/pi-protocol";
import { PiDisconnectedError, PiServerError, toDisconnectedError, toError } from "./errors.ts";
import { createPromiseResolvers, type PromiseResolvers } from "./promise.ts";
@ -22,17 +22,18 @@ type ActiveConnection = {
type ConnectionLifecycle =
| { state: "disconnected" }
| ({ state: "connecting"; handshake: PromiseResolvers<ServerSnapshot> } & ActiveConnection)
| ({ state: "connecting"; handshake: PromiseResolvers<ServerHello> } & ActiveConnection)
| ({
state: "connected";
transport: ByteTransport;
handshake: PromiseResolvers<ServerSnapshot> | undefined;
handshake: PromiseResolvers<ServerHello> | undefined;
} & ActiveConnection);
interface ConnectionOptions {
transportFactory: ByteTransportFactory;
serviceId: string;
maxFrameLength?: number;
onHandshake(snapshot: ServerSnapshot): void;
onHandshake(hello: ServerHello): void;
onMessage(message: Exclude<ServerMessage, { type: "hello" | "hello_error" }>): void;
onStateChange(change: ConnectionStateChange): void;
}
@ -63,12 +64,12 @@ export class Connection {
return this.#maxFrameLength;
}
connect(): Promise<ServerSnapshot> {
connect(): Promise<ServerHello> {
if (this.#lifecycle.state !== "disconnected") {
return Promise.reject(new PiDisconnectedError(`PiClient is already ${this.#lifecycle.state}`));
}
const id = ++this.#sequence;
const handshake = createPromiseResolvers<ServerSnapshot>();
const handshake = createPromiseResolvers<ServerHello>();
this.#lifecycle = {
state: "connecting",
id,
@ -170,6 +171,14 @@ export class Connection {
this.#failAndClose(new ProtocolValidationError("Expected server hello as first message"));
return;
}
if (message.serviceId !== this.#options.serviceId) {
this.#failAndClose(
new ProtocolValidationError(
`Connected service ${JSON.stringify(message.serviceId)} does not match ${JSON.stringify(this.#options.serviceId)}`,
),
);
return;
}
if (!lifecycle.transport) {
this.#failAndClose(new ProtocolValidationError("Received server hello before the client hello was sent"));
return;
@ -183,7 +192,7 @@ export class Connection {
} satisfies Extract<ConnectionLifecycle, { state: "connected" }>;
this.#lifecycle = connected;
try {
this.#options.onHandshake(message.snapshot);
this.#options.onHandshake(message);
} catch (error) {
if (this.#lifecycle === connected) this.#failAndClose(toError(error));
return;
@ -192,7 +201,7 @@ export class Connection {
this.#options.onStateChange({ state: "connected" });
if (this.#lifecycle !== connected) return;
this.#lifecycle = { ...connected, handshake: undefined };
lifecycle.handshake.resolve(message.snapshot);
lifecycle.handshake.resolve(message);
return;
}
if (lifecycle.state !== "connected") return;

View file

@ -26,26 +26,6 @@ export class PiClientDisposedError extends Error {
}
}
export class PiSessionOwnershipError extends Error {
readonly sessionId: string;
constructor(sessionId: string, message: string) {
super(message);
this.name = "PiSessionOwnershipError";
this.sessionId = sessionId;
}
}
export class PiSessionDetachedError extends Error {
readonly sessionId: string;
constructor(sessionId: string) {
super(`Session ${sessionId} is not attached`);
this.name = "PiSessionDetachedError";
this.sessionId = sessionId;
}
}
export function toError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}

View file

@ -1,17 +1,9 @@
export { PiClient } from "./client.ts";
export {
PiClientDisposedError,
PiDisconnectedError,
PiServerError,
PiSessionDetachedError,
PiSessionOwnershipError,
} from "./errors.ts";
export type { AcquireSessionOptions, PiSessionHandle, SessionLease, SessionLeaseMode } from "./session-handle.ts";
export { PiClientDisposedError, PiDisconnectedError, PiServerError } from "./errors.ts";
export type { ByteTransport, ByteTransportFactory, ByteTransportHandlers } from "./transport.ts";
export type {
ConnectionState,
ConnectionStateChange,
CreateSessionOptions,
ListenerErrorHandler,
PiClientOptions,
Unsubscribe,

View file

@ -1,111 +0,0 @@
import type {
Command,
ModelRef,
ResultForCommand,
ServerEvent,
SessionSnapshot,
ThinkingLevel,
} from "@earendil-works/pi-protocol";
import type { Unsubscribe } from "./types.ts";
type SessionCommand = Extract<Command, { sessionId: string }>;
export type SessionLeaseMode = "shared" | "exclusive";
export interface AcquireSessionOptions {
mode: SessionLeaseMode;
}
export interface SessionLease extends AsyncDisposable {
readonly id: string;
readonly active: boolean;
readonly attached: boolean;
readonly snapshot: SessionSnapshot | undefined;
subscribe(listener: (snapshot: SessionSnapshot) => void): Unsubscribe;
onEvent(listener: (event: ServerEvent) => void): Unsubscribe;
detach(): Promise<void>;
dispose(): Promise<void>;
prompt(text: string): Promise<SessionSnapshot>;
steer(text: string): Promise<SessionSnapshot>;
abort(): Promise<SessionSnapshot>;
setModel(model: ModelRef): Promise<SessionSnapshot>;
setThinking(thinkingLevel: ThinkingLevel): Promise<SessionSnapshot>;
}
export type PiSessionHandle = SessionLease;
export interface SessionHandleCallbacks {
isAttached(): boolean;
getSnapshot(): SessionSnapshot | undefined;
subscribe(listener: (snapshot: SessionSnapshot) => void): Unsubscribe;
onEvent(listener: (event: ServerEvent) => void): Unsubscribe;
detach(): Promise<void>;
dispose(): Promise<void>;
request<const TCommand extends SessionCommand>(command: TCommand): Promise<ResultForCommand<TCommand>>;
}
export class SessionHandle implements SessionLease {
readonly id: string;
readonly #callbacks: SessionHandleCallbacks;
constructor(id: string, callbacks: SessionHandleCallbacks) {
this.id = id;
this.#callbacks = callbacks;
}
get attached(): boolean {
return this.#callbacks.isAttached();
}
get active(): boolean {
return this.attached;
}
get snapshot(): SessionSnapshot | undefined {
return this.#callbacks.getSnapshot();
}
subscribe(listener: (snapshot: SessionSnapshot) => void): Unsubscribe {
return this.#callbacks.subscribe(listener);
}
onEvent(listener: (event: ServerEvent) => void): Unsubscribe {
return this.#callbacks.onEvent(listener);
}
async detach(): Promise<void> {
await this.#callbacks.detach();
}
dispose(): Promise<void> {
return this.#callbacks.dispose();
}
[Symbol.asyncDispose](): Promise<void> {
return this.dispose();
}
async prompt(text: string): Promise<SessionSnapshot> {
return (await this.#request({ command: "prompt", sessionId: this.id, text })).session;
}
async steer(text: string): Promise<SessionSnapshot> {
return (await this.#request({ command: "steer", sessionId: this.id, text })).session;
}
async abort(): Promise<SessionSnapshot> {
return (await this.#request({ command: "abort", sessionId: this.id })).session;
}
async setModel(model: ModelRef): Promise<SessionSnapshot> {
return (await this.#request({ command: "set_model", sessionId: this.id, model })).session;
}
async setThinking(thinkingLevel: ThinkingLevel): Promise<SessionSnapshot> {
return (await this.#request({ command: "set_thinking", sessionId: this.id, thinkingLevel })).session;
}
#request<const TCommand extends SessionCommand>(command: TCommand): Promise<ResultForCommand<TCommand>> {
return this.#callbacks.request(command);
}
}

View file

@ -1,156 +0,0 @@
import type { CommandResult, ServerEvent, ServerSnapshot, SessionSnapshot } from "@earendil-works/pi-protocol";
import { toError } from "./errors.ts";
import type { ListenerErrorHandler, Unsubscribe } from "./types.ts";
export class ClientState {
readonly #sessionSnapshots = new Map<string, SessionSnapshot>();
readonly #attachedSessionIds = new Set<string>();
readonly #snapshotListeners = new Set<(snapshot: ServerSnapshot) => void>();
readonly #eventListeners = new Set<(event: ServerEvent) => void>();
readonly #sessionSnapshotListeners = new Map<string, Set<(snapshot: SessionSnapshot) => void>>();
readonly #sessionEventListeners = new Map<string, Set<(event: ServerEvent) => void>>();
readonly #onListenerError: ListenerErrorHandler | undefined;
#snapshot: ServerSnapshot | undefined;
constructor(onListenerError?: ListenerErrorHandler) {
this.#onListenerError = onListenerError;
}
get snapshot(): ServerSnapshot | undefined {
return this.#snapshot;
}
reset(): void {
this.#snapshot = undefined;
this.#sessionSnapshots.clear();
this.#attachedSessionIds.clear();
}
clearAttachments(): void {
this.#attachedSessionIds.clear();
}
dispose(): void {
this.reset();
this.#snapshotListeners.clear();
this.#eventListeners.clear();
this.#sessionSnapshotListeners.clear();
this.#sessionEventListeners.clear();
}
getSessionSnapshot(sessionId: string): SessionSnapshot | undefined {
return this.#sessionSnapshots.get(sessionId);
}
isSessionAttached(sessionId: string): boolean {
return this.#attachedSessionIds.has(sessionId);
}
forgetSessionSnapshot(sessionId: string): SessionSnapshot | undefined {
const previous = this.#sessionSnapshots.get(sessionId);
this.#sessionSnapshots.delete(sessionId);
return previous;
}
restoreSessionSnapshot(snapshot: SessionSnapshot): void {
if (!this.#sessionSnapshots.has(snapshot.id)) this.#sessionSnapshots.set(snapshot.id, snapshot);
}
subscribe(listener: (snapshot: ServerSnapshot) => void): Unsubscribe {
this.#snapshotListeners.add(listener);
return () => this.#snapshotListeners.delete(listener);
}
onEvent(listener: (event: ServerEvent) => void): Unsubscribe {
this.#eventListeners.add(listener);
return () => this.#eventListeners.delete(listener);
}
subscribeSession(sessionId: string, listener: (snapshot: SessionSnapshot) => void): Unsubscribe {
return addMappedListener(this.#sessionSnapshotListeners, sessionId, listener);
}
onSessionEvent(sessionId: string, listener: (event: ServerEvent) => void): Unsubscribe {
return addMappedListener(this.#sessionEventListeners, sessionId, listener);
}
applyResult(result: CommandResult): void {
if (result.command === "list") return;
if (result.command === "detach") {
this.#attachedSessionIds.delete(result.sessionId);
const snapshot = this.#sessionSnapshots.get(result.sessionId);
if (snapshot) this.#applySessionSnapshot({ ...snapshot, attached: false }, true);
return;
}
this.#applySessionSnapshot(result.session);
}
applyEvent(event: ServerEvent): void {
if (event.type === "server_snapshot") this.applyServerSnapshot(event.snapshot);
if (event.type === "session_snapshot") this.#applySessionSnapshot(event.snapshot);
if (event.type === "session_removed") {
this.#sessionSnapshots.delete(event.sessionId);
this.#attachedSessionIds.delete(event.sessionId);
}
this.#notify(this.#eventListeners, event);
const sessionId = getEventSessionId(event);
if (sessionId) this.#notify(this.#sessionEventListeners.get(sessionId), event);
}
applyServerSnapshot(snapshot: ServerSnapshot): void {
if (this.#snapshot && snapshot.revision < this.#snapshot.revision) return;
this.#snapshot = snapshot;
this.#notify(this.#snapshotListeners, snapshot);
}
#applySessionSnapshot(snapshot: SessionSnapshot, force = false): void {
const current = this.#sessionSnapshots.get(snapshot.id);
if (!force && current && snapshot.revision < current.revision) return;
this.#sessionSnapshots.set(snapshot.id, snapshot);
if (snapshot.attached) this.#attachedSessionIds.add(snapshot.id);
else this.#attachedSessionIds.delete(snapshot.id);
this.#notify(this.#sessionSnapshotListeners.get(snapshot.id), snapshot);
}
#notify<T>(listeners: Iterable<(value: T) => void> | undefined, value: T): void {
for (const listener of listeners ?? []) {
try {
listener(value);
} catch (error) {
this.#reportListenerError(error);
}
}
}
#reportListenerError(error: unknown): void {
if (!this.#onListenerError) return;
try {
this.#onListenerError(toError(error));
} catch {
// Diagnostics cannot affect client state.
}
}
}
function addMappedListener<T>(
listenersById: Map<string, Set<(value: T) => void>>,
id: string,
listener: (value: T) => void,
): Unsubscribe {
let listeners = listenersById.get(id);
if (!listeners) {
listeners = new Set();
listenersById.set(id, listeners);
}
listeners.add(listener);
return () => {
listeners.delete(listener);
if (listeners.size === 0) listenersById.delete(id);
};
}
function getEventSessionId(event: ServerEvent): string | undefined {
if (event.type === "session_snapshot") return event.snapshot.id;
if (event.type === "session_progress" || event.type === "session_removed") return event.sessionId;
return undefined;
}

View file

@ -1,4 +1,3 @@
import type { ModelRef, ThinkingLevel } from "@earendil-works/pi-protocol";
import type { ByteTransportFactory } from "./transport.ts";
export type ConnectionState = "disconnected" | "connecting" | "connected";
@ -13,14 +12,9 @@ export type ListenerErrorHandler = (error: Error) => void;
export interface PiClientOptions {
transportFactory: ByteTransportFactory;
/** Logical service identity expected at the physical endpoint. */
serviceId: string;
maxFrameLength?: number;
/** Reports subscriber failures without allowing them to corrupt client state. */
onListenerError?: ListenerErrorHandler;
}
export interface CreateSessionOptions {
cwd?: string;
name?: string;
model?: ModelRef;
thinkingLevel?: ThinkingLevel;
}

View file

@ -0,0 +1,103 @@
import { ProtocolValidationError } from "@earendil-works/pi-protocol";
import { describe, expect, test, vi } from "vitest";
import { PiClient, PiClientDisposedError, PiDisconnectedError } from "../src/index.ts";
import { MemoryByteServer } from "./support.ts";
async function connectClient(server: MemoryByteServer, serviceId = "service-1"): Promise<PiClient> {
return PiClient.connect({ serviceId, transportFactory: (handlers) => server.connect(handlers) });
}
describe("PiClient list and attach", () => {
test("connects only to the expected logical service", async () => {
const matching = new MemoryByteServer();
const client = await connectClient(matching);
expect(client.hello).toMatchObject({ serviceId: "service-1", connectionId: "connection-1" });
await client.dispose();
const wrong = new MemoryByteServer("other-service");
await expect(connectClient(wrong)).rejects.toBeInstanceOf(ProtocolValidationError);
});
test("addresses list and attach requests to the configured service", async () => {
const server = new MemoryByteServer();
const client = await connectClient(server);
const listing = client.listSessions();
await vi.waitFor(() => expect(server.messages).toHaveLength(2));
expect(server.messages[1]).toEqual({
type: "request",
id: "request-1",
serviceId: "service-1",
call: { method: "list", args: [] },
});
server.send({
type: "response",
id: "request-1",
ok: true,
result: [{ id: "session-1", createdAt: 1, storageVersion: 1 }],
});
await expect(listing).resolves.toEqual([{ id: "session-1", createdAt: 1, storageVersion: 1 }]);
const attaching = client.attachSession("session-1");
await vi.waitFor(() => expect(server.messages).toHaveLength(3));
expect(server.messages[2]).toMatchObject({
type: "request",
serviceId: "service-1",
call: { method: "attach", args: ["session-1"] },
});
server.send({
type: "response",
id: "request-2",
ok: true,
result: { sessionId: "session-1" },
});
await expect(attaching).resolves.toEqual({ sessionId: "session-1" });
await client.dispose();
});
test("correlates out-of-order responses", async () => {
const server = new MemoryByteServer();
const client = await connectClient(server);
const first = client.listSessions();
const second = client.attachSession("session-1");
await vi.waitFor(() => expect(server.messages).toHaveLength(3));
server.send({
type: "response",
id: "request-2",
ok: true,
result: { sessionId: "session-1" },
});
server.send({
type: "response",
id: "request-1",
ok: true,
result: [],
});
await expect(Promise.all([first, second])).resolves.toEqual([[], { sessionId: "session-1" }]);
await client.dispose();
});
test("exposes bounded server errors", async () => {
const server = new MemoryByteServer();
const client = await connectClient(server);
const attaching = client.attachSession("missing");
await vi.waitFor(() => expect(server.messages).toHaveLength(2));
server.send({
type: "response",
id: "request-1",
ok: false,
error: { code: "session_not_found", message: "Unknown session" },
});
await expect(attaching).rejects.toMatchObject({ code: "session_not_found" });
await client.dispose();
});
test("rejects pending requests after disconnect or disposal", async () => {
const server = new MemoryByteServer();
const client = await connectClient(server);
const listing = client.listSessions();
server.disconnect();
await expect(listing).rejects.toBeInstanceOf(PiDisconnectedError);
await client.dispose();
await expect(client.listSessions()).rejects.toBeInstanceOf(PiClientDisposedError);
});
});

View file

@ -1,327 +0,0 @@
import {
type ClientMessage,
encodeCbor,
encodeFrame,
encodeServerMessage,
PROTOCOL_VERSION,
ProtocolValidationError,
type ServerSnapshot,
} from "@earendil-works/pi-protocol";
import { describe, expect, test } from "vitest";
import { type ByteTransportFactory, PiClient, PiDisconnectedError } from "../src/index.ts";
import {
attachSession,
baseServerSnapshot,
connectClient,
createClient,
MemoryByteServer,
sessionSnapshot,
} from "./support.ts";
describe("PiClient", () => {
test("sends a framed version before accepting a fragmented server hello", async () => {
const server = new MemoryByteServer();
const received: ClientMessage[] = [];
server.onMessage((message) => {
received.push(message);
if (message.type === "hello") {
server.send(
{
type: "hello",
version: PROTOCOL_VERSION,
connectionId: "connection-1",
snapshot: baseServerSnapshot,
},
3,
);
}
});
const client = createClient(server);
await expect(client.connect()).resolves.toEqual(baseServerSnapshot);
expect(received[0]).toEqual({ type: "hello", version: PROTOCOL_VERSION });
expect(client.connectionState).toBe("connected");
});
test("rejects server data delivered before sending the client hello", async () => {
let closeCount = 0;
let sendCount = 0;
const client = new PiClient({
transportFactory: (handlers) => {
handlers.onData(
encodeServerMessage({
type: "hello",
version: PROTOCOL_VERSION,
connectionId: "connection-1",
snapshot: baseServerSnapshot,
}),
);
return {
async send() {
sendCount++;
},
close() {
closeCount++;
},
};
},
});
await expect(client.connect()).rejects.toMatchObject({
name: "ProtocolValidationError",
message: "Received server data before the client hello was sent",
});
expect(client.connectionState).toBe("disconnected");
expect(sendCount).toBe(0);
expect(closeCount).toBe(1);
});
test("isolates subscriber failures from handshake and transport state", async () => {
const server = new MemoryByteServer();
server.onMessage((message) => {
if (message.type === "hello") {
server.send({
type: "hello",
version: PROTOCOL_VERSION,
connectionId: "connection-1",
snapshot: baseServerSnapshot,
});
}
});
const client = createClient(server);
client.subscribe(() => {
throw new Error("consumer failure");
});
await expect(client.connect()).resolves.toEqual(baseServerSnapshot);
expect(client.connectionState).toBe("connected");
});
test("reports subscriber failures without changing connection state", async () => {
const server = new MemoryByteServer();
const listenerErrors: Error[] = [];
server.onMessage((message) => {
if (message.type === "hello") {
server.send({
type: "hello",
version: PROTOCOL_VERSION,
connectionId: "connection-1",
snapshot: baseServerSnapshot,
});
}
});
const client = new PiClient({
transportFactory: (handlers) => server.connect(handlers),
onListenerError: (error) => listenerErrors.push(error),
});
client.subscribe(() => {
throw new Error("consumer failure");
});
await expect(client.connect()).resolves.toEqual(baseServerSnapshot);
expect(listenerErrors).toEqual([expect.objectContaining({ message: "consumer failure" })]);
expect(client.connectionState).toBe("connected");
});
test("does not restore a connection after a snapshot listener disconnects during handshake", async () => {
const server = new MemoryByteServer();
server.onMessage((message) => {
if (message.type !== "hello") return;
server.send({
type: "hello",
version: PROTOCOL_VERSION,
connectionId: "connection-1",
snapshot: baseServerSnapshot,
});
});
const client = createClient(server);
client.subscribe(() => client.disconnect());
await expect(client.connect()).rejects.toBeInstanceOf(PiDisconnectedError);
expect(client.connectionState).toBe("disconnected");
expect(server.clientCloseCount).toBe(1);
});
test("does not restore a stale connection when a snapshot listener reconnects during handshake", async () => {
const first = new MemoryByteServer();
const second = new MemoryByteServer();
let connection = 0;
for (const server of [first, second]) {
server.onMessage((message) => {
if (message.type !== "hello") return;
server.send({
type: "hello",
version: PROTOCOL_VERSION,
connectionId: `connection-${connection}`,
snapshot: { ...baseServerSnapshot, revision: connection },
});
});
}
const client = new PiClient({
transportFactory: (handlers) => (connection++ === 0 ? first : second).connect(handlers),
});
let reconnect: Promise<ServerSnapshot> | undefined;
let reconnectRequested = false;
client.subscribe(() => {
if (reconnectRequested) return;
reconnectRequested = true;
client.disconnect();
reconnect = client.reconnect();
});
await expect(client.connect()).rejects.toBeInstanceOf(PiDisconnectedError);
expect(reconnect).toBeDefined();
await expect(reconnect).resolves.toMatchObject({ revision: 2 });
expect(client.connectionState).toBe("connected");
expect(first.clientCloseCount).toBe(1);
});
test("rejects a typed handshake version error", async () => {
const server = new MemoryByteServer();
server.onMessage(() => {
server.send({
type: "hello_error",
error: { code: "version", message: "Unsupported protocol version" },
});
});
const client = createClient(server);
await expect(client.connect()).rejects.toMatchObject({
name: "PiServerError",
code: "version",
message: "Unsupported protocol version",
});
expect(client.connectionState).toBe("disconnected");
expect(server.clientCloseCount).toBe(1);
});
test("rejects pending requests on close and reconnects through a fresh factory result", async () => {
const first = new MemoryByteServer();
const second = new MemoryByteServer();
let connection = 0;
for (const server of [first, second]) {
server.onMessage((message) => {
if (message.type === "hello") {
server.send({
type: "hello",
version: PROTOCOL_VERSION,
connectionId: `connection-${connection}`,
snapshot: { ...baseServerSnapshot, revision: connection },
});
}
});
}
const transportFactory: ByteTransportFactory = (handlers) =>
(connection++ === 0 ? first : second).connect(handlers);
const client = new PiClient({ transportFactory });
const states: string[] = [];
client.onConnectionStateChange(({ state }) => states.push(state));
await client.connect();
const pending = client.listSessions();
first.close();
await expect(pending).rejects.toBeInstanceOf(PiDisconnectedError);
expect(client.connectionState).toBe("disconnected");
await expect(client.reconnect()).resolves.toMatchObject({ revision: 2 });
expect(client.connectionState).toBe("connected");
expect(states).toEqual(["connecting", "connected", "disconnected", "connecting", "connected"]);
});
test("supports synchronous reconnect from a disconnection listener", async () => {
const first = new MemoryByteServer();
const second = new MemoryByteServer();
let connection = 0;
for (const server of [first, second]) {
server.onMessage((message) => {
if (message.type !== "hello") return;
server.send({
type: "hello",
version: PROTOCOL_VERSION,
connectionId: `connection-${connection}`,
snapshot: { ...baseServerSnapshot, revision: connection },
});
});
}
const client = new PiClient({
transportFactory: (handlers) => (connection++ === 0 ? first : second).connect(handlers),
});
await client.connect();
let reconnect: Promise<ServerSnapshot> | undefined;
client.onConnectionStateChange(({ state }) => {
if (state === "disconnected") reconnect = client.reconnect();
});
first.close();
expect(reconnect).toBeDefined();
await expect(reconnect).resolves.toMatchObject({ revision: 2 });
expect(client.connectionState).toBe("connected");
});
test("rejects pending requests on transport errors", async () => {
const server = new MemoryByteServer();
const client = await connectClient(server);
const pending = client.listSessions();
server.error(new Error("read failed"));
await expect(pending).rejects.toMatchObject({ name: "PiDisconnectedError", message: "read failed" });
expect(client.connectionState).toBe("disconnected");
});
test("enforces the configured frame limit for outbound and inbound messages", async () => {
const server = new MemoryByteServer();
server.onMessage((message) => {
if (message.type === "hello") {
server.send({
type: "hello",
version: PROTOCOL_VERSION,
connectionId: "connection-1",
snapshot: baseServerSnapshot,
});
}
});
const client = new PiClient({
maxFrameLength: 512,
transportFactory: (handlers) => server.connect(handlers),
});
await client.connect();
const handle = await attachSession(client, server, sessionSnapshot("session-1"));
const sentBefore = server.sentByClient.length;
await expect(handle.prompt("x".repeat(1_000))).rejects.toBeInstanceOf(ProtocolValidationError);
expect(server.sentByClient).toHaveLength(sentBefore);
server.sendRaw(new Uint8Array([0, 0, 2, 1]));
expect(client.connectionState).toBe("disconnected");
});
test("disconnects on invalid protocol data", async () => {
const server = new MemoryByteServer();
const client = await connectClient(server);
server.sendRaw(encodeFrame(encodeCbor({ type: "event", event: { type: "session_removed", sessionId: 1 } })));
expect(client.connectionState).toBe("disconnected");
});
test("reports truncated framing when the transport closes", async () => {
const server = new MemoryByteServer();
const client = await connectClient(server);
const pending = client.listSessions();
server.sendRaw(new Uint8Array([0, 0, 0, 2, 1]));
server.close();
await expect(pending).rejects.toMatchObject({
name: "ProtocolValidationError",
message: expect.stringMatching(/truncated/i),
});
expect(client.connectionState).toBe("disconnected");
});
test("rejects frame limits outside the unsigned 32-bit range", () => {
const server = new MemoryByteServer();
expect(
() =>
new PiClient({
maxFrameLength: 0x1_0000_0000,
transportFactory: (handlers) => server.connect(handlers),
}),
).toThrow(/maxFrameLength/);
});
});

View file

@ -1,54 +0,0 @@
import { PROTOCOL_VERSION } from "@earendil-works/pi-protocol";
import { describe, expect, test } from "vitest";
import { PiClient, PiClientDisposedError } from "../src/index.ts";
import { attachSession, baseServerSnapshot, connectClient, MemoryByteServer, sessionSnapshot } from "./support.ts";
describe("PiClient disposal", () => {
test("connects through its ownership factory", async () => {
const server = new MemoryByteServer();
server.onMessage((message) => {
if (message.type !== "hello") return;
server.send({
type: "hello",
version: PROTOCOL_VERSION,
connectionId: "connection-1",
snapshot: baseServerSnapshot,
});
});
const client = await PiClient.connect({
transportFactory: (handlers) => server.connect(handlers),
});
expect(client.connected).toBe(true);
await client.dispose();
});
test("disconnects, invalidates child handles, and rejects pending requests", async () => {
const server = new MemoryByteServer();
const client = await connectClient(server);
const handle = await attachSession(client, server, sessionSnapshot("session-1"));
const pending = client.listSessions();
const firstDisposal = client.dispose();
const secondDisposal = client.dispose();
expect(secondDisposal).toBe(firstDisposal);
expect(client.disposed).toBe(true);
expect(client.connected).toBe(false);
expect(handle.attached).toBe(false);
await expect(pending).rejects.toBeInstanceOf(PiClientDisposedError);
await expect(handle.prompt("after disposal")).rejects.toBeInstanceOf(PiClientDisposedError);
await firstDisposal;
});
test("supports explicit async disposal", async () => {
const server = new MemoryByteServer();
const client = await connectClient(server);
await client[Symbol.asyncDispose]();
expect(client.disposed).toBe(true);
expect(client.connectionState).toBe("disconnected");
});
});

View file

@ -1,68 +0,0 @@
import { describe, expect, test } from "vitest";
import { collectRequests, connectClient, MemoryByteServer, sessionSnapshot } from "./support.ts";
describe("PiClient", () => {
test("correlates coalesced out-of-order responses", async () => {
const server = new MemoryByteServer();
const client = await connectClient(server);
const requests = collectRequests(server);
const listed = client.listSessions();
const attached = client.attachSession("session-1");
expect(requests).toHaveLength(2);
const attachRequest = requests.find((request) => request.request.command === "attach");
const listRequest = requests.find((request) => request.request.command === "list");
if (!attachRequest || !listRequest) throw new Error("Missing requests");
server.sendTogether([
{
type: "response",
id: attachRequest.id,
ok: true,
result: { command: "attach", session: sessionSnapshot("session-1") },
},
{
type: "response",
id: listRequest.id,
ok: true,
result: { command: "list", sessions: [] },
},
]);
await expect(listed).resolves.toEqual([]);
await expect(attached).resolves.toMatchObject({ id: "session-1", attached: true });
});
test("rejects a mismatched response instead of leaving its request pending", async () => {
const server = new MemoryByteServer();
const client = await connectClient(server);
const requests = collectRequests(server);
const listed = client.listSessions();
expect(requests).toMatchObject([{ request: { command: "list" } }]);
server.send({
type: "response",
id: requests[0]!.id,
ok: true,
result: { command: "attach", session: sessionSnapshot("session-1") },
});
await expect(listed).rejects.toMatchObject({
name: "ProtocolValidationError",
message: "Response command attach does not match list",
});
expect(client.connectionState).toBe("disconnected");
});
test("surfaces typed request errors", async () => {
const server = new MemoryByteServer();
const client = await connectClient(server);
const requests = collectRequests(server);
const attaching = client.attachSession("locked");
server.send({
type: "response",
id: requests[0]?.id ?? "missing",
ok: false,
error: { code: "session_locked", message: "Already attached" },
});
await expect(attaching).rejects.toMatchObject({ name: "PiServerError", code: "session_locked" });
});
});

View file

@ -1,261 +0,0 @@
import { describe, expect, test } from "vitest";
import { PiSessionDetachedError, PiSessionOwnershipError } from "../src/index.ts";
import { connectClient, MemoryByteServer, sessionSnapshot } from "./support.ts";
describe("PiClient", () => {
test("keeps multiple session handles independent and enforces detach", async () => {
const server = new MemoryByteServer();
const client = await connectClient(server);
server.onMessage((message) => {
if (message.type !== "request") return;
const request = message.request;
if (request.command === "attach") {
server.send({
type: "response",
id: message.id,
ok: true,
result: { command: "attach", session: sessionSnapshot(request.sessionId) },
});
}
if (request.command === "detach") {
server.send({
type: "response",
id: message.id,
ok: true,
result: { command: "detach", sessionId: request.sessionId },
});
}
});
const first = await client.attachSession("session-1");
const second = await client.attachSession("session-2");
expect(first.attached).toBe(true);
expect(second.attached).toBe(true);
await first.detach();
expect(first.attached).toBe(false);
expect(second.attached).toBe(true);
await expect(first.abort()).rejects.toBeInstanceOf(PiSessionDetachedError);
});
test("detaches a shared session only after its final lease is released", async () => {
const server = new MemoryByteServer();
const client = await connectClient(server);
const requests: string[] = [];
server.onMessage((message) => {
if (message.type !== "request") return;
requests.push(message.request.command);
if (message.request.command === "attach") {
server.send({
type: "response",
id: message.id,
ok: true,
result: { command: "attach", session: sessionSnapshot("session-1") },
});
}
if (message.request.command === "detach") {
server.send({
type: "response",
id: message.id,
ok: true,
result: { command: "detach", sessionId: "session-1" },
});
}
});
const first = await client.attachSession("session-1");
const second = await client.attachSession("session-1");
expect(second).not.toBe(first);
expect(requests).toEqual(["attach"]);
await first.detach();
expect(first.attached).toBe(false);
expect(second.attached).toBe(true);
expect(requests).toEqual(["attach"]);
await second.detach();
expect(second.attached).toBe(false);
expect(requests).toEqual(["attach", "detach"]);
});
test("enforces exclusive and shared lease modes", async () => {
const server = new MemoryByteServer();
const client = await connectClient(server);
server.onMessage((message) => {
if (message.type !== "request") return;
if (message.request.command === "attach") {
server.send({
type: "response",
id: message.id,
ok: true,
result: { command: "attach", session: sessionSnapshot("session-1") },
});
}
if (message.request.command === "detach") {
server.send({
type: "response",
id: message.id,
ok: true,
result: { command: "detach", sessionId: "session-1" },
});
}
});
const shared = await client.acquireSession("session-1", { mode: "shared" });
await expect(client.acquireSession("session-1", { mode: "exclusive" })).rejects.toBeInstanceOf(
PiSessionOwnershipError,
);
await shared.dispose();
const exclusive = await client.acquireSession("session-1", { mode: "exclusive" });
await expect(client.acquireSession("session-1", { mode: "shared" })).rejects.toBeInstanceOf(
PiSessionOwnershipError,
);
await exclusive[Symbol.asyncDispose]();
});
test("invalidated leases dispose without protocol cleanup", async () => {
const server = new MemoryByteServer();
const client = await connectClient(server);
server.onMessage((message) => {
if (message.type !== "request" || message.request.command !== "attach") return;
server.send({
type: "response",
id: message.id,
ok: true,
result: { command: "attach", session: sessionSnapshot("session-1") },
});
});
const lease = await client.acquireSession("session-1", { mode: "exclusive" });
client.disconnect();
await expect(lease.dispose()).resolves.toBeUndefined();
expect(lease.active).toBe(false);
});
test("rejects commands while releasing and restores an explicit detach after failure", async () => {
const server = new MemoryByteServer();
const client = await connectClient(server);
const requests: Array<{ id: string; command: string }> = [];
server.onMessage((message) => {
if (message.type !== "request") return;
requests.push({ id: message.id, command: message.request.command });
});
const acquiring = client.acquireSession("session-1", { mode: "exclusive" });
const attachRequest = requests.at(-1);
if (!attachRequest) throw new Error("Missing attach request");
server.send({
type: "response",
id: attachRequest.id,
ok: true,
result: { command: "attach", session: sessionSnapshot("session-1") },
});
const lease = await acquiring;
const firstDetach = lease.detach();
const failedDetachRequest = requests.at(-1);
await expect(lease.abort()).rejects.toBeInstanceOf(PiSessionDetachedError);
if (!failedDetachRequest) throw new Error("Missing detach request");
server.send({
type: "response",
id: failedDetachRequest.id,
ok: false,
error: { code: "invalid_request", message: "retry" },
});
await expect(firstDetach).rejects.toThrow("retry");
expect(lease.active).toBe(true);
const secondDetach = lease.detach();
const successfulDetachRequest = requests.at(-1);
if (!successfulDetachRequest) throw new Error("Missing retry detach request");
server.send({
type: "response",
id: successfulDetachRequest.id,
ok: true,
result: { command: "detach", sessionId: "session-1" },
});
await secondDetach;
expect(lease.active).toBe(false);
});
test("serializes reacquisition behind final lease detachment", async () => {
const server = new MemoryByteServer();
const client = await connectClient(server);
const requests: Array<{ id: string; command: string }> = [];
server.onMessage((message) => {
if (message.type !== "request") return;
requests.push({ id: message.id, command: message.request.command });
});
const firstAttachment = client.attachSession("session-1");
const firstAttachRequest = requests.at(-1);
if (!firstAttachRequest) throw new Error("Missing first attach request");
server.send({
type: "response",
id: firstAttachRequest.id,
ok: true,
result: { command: "attach", session: sessionSnapshot("session-1") },
});
const first = await firstAttachment;
const detaching = first.detach();
const detachRequest = requests.at(-1);
if (!detachRequest) throw new Error("Missing detach request");
const reacquiring = client.attachSession("session-1");
expect(requests.map(({ command }) => command)).toEqual(["attach", "detach"]);
server.send({
type: "response",
id: detachRequest.id,
ok: true,
result: { command: "detach", sessionId: "session-1" },
});
await detaching;
await Promise.resolve();
const secondAttachRequest = requests.at(-1);
expect(secondAttachRequest?.command).toBe("attach");
if (!secondAttachRequest) throw new Error("Missing second attach request");
server.send({
type: "response",
id: secondAttachRequest.id,
ok: true,
result: { command: "attach", session: sessionSnapshot("session-1", { revision: 2 }) },
});
await expect(reacquiring).resolves.toMatchObject({ attached: true });
});
test("accepts a lower revision after detaching and reacquiring the same session", async () => {
const server = new MemoryByteServer();
const client = await connectClient(server);
let attachCount = 0;
server.onMessage((message) => {
if (message.type !== "request") return;
if (message.request.command === "attach") {
server.send({
type: "response",
id: message.id,
ok: true,
result: {
command: "attach",
session: sessionSnapshot("session-1", { revision: attachCount++ === 0 ? 10 : 0 }),
},
});
}
if (message.request.command === "detach") {
server.send({
type: "response",
id: message.id,
ok: true,
result: { command: "detach", sessionId: "session-1" },
});
}
});
const first = await client.attachSession("session-1");
expect(first.snapshot?.revision).toBe(10);
await first.detach();
const reopened = await client.attachSession("session-1");
expect(reopened).not.toBe(first);
expect(reopened.snapshot?.revision).toBe(0);
});
});

View file

@ -1,146 +0,0 @@
import { describe, expect, test } from "vitest";
import {
attachSession,
baseServerSnapshot,
collectRequests,
connectClient,
MemoryByteServer,
sessionSnapshot,
} from "./support.ts";
describe("PiClient", () => {
test("reduces only authoritative snapshots and supports unsubscribe", async () => {
const server = new MemoryByteServer();
const client = await connectClient(server);
const requests = collectRequests(server);
const initial = sessionSnapshot("session-1", { revision: 1, phase: "idle" });
const handle = await attachSession(client, server, initial);
const observed: number[] = [];
const progressTypes: string[] = [];
const unsubscribe = handle.subscribe((snapshot) => observed.push(snapshot.revision));
const unsubscribeEvents = handle.onEvent((event) => progressTypes.push(event.type));
server.send({
type: "event",
event: {
type: "session_progress",
sessionId: "session-1",
progress: {
type: "assistant_delta",
messageId: "assistant-1",
contentIndex: 0,
kind: "text",
delta: "hi",
},
},
});
expect(progressTypes).toEqual(["session_progress"]);
expect(handle.snapshot).toEqual(initial);
const prompting = handle.prompt("hello");
expect(handle.snapshot).toEqual(initial);
const promptRequest = requests.find((request) => request.request.command === "prompt");
if (!promptRequest) throw new Error("Missing prompt request");
const updated = sessionSnapshot("session-1", { revision: 2, phase: "turn" });
server.send({
type: "response",
id: promptRequest.id,
ok: true,
result: { command: "prompt", session: updated },
});
await expect(prompting).resolves.toEqual(updated);
expect(handle.snapshot).toEqual(updated);
expect(observed).toEqual([2]);
unsubscribe();
unsubscribeEvents();
server.send({
type: "event",
event: { type: "session_snapshot", snapshot: sessionSnapshot("session-1", { revision: 3 }) },
});
expect(observed).toEqual([2]);
});
test("keeps session leases attached across server metadata snapshots", async () => {
const server = new MemoryByteServer();
const client = await connectClient(server);
const handle = await attachSession(client, server, sessionSnapshot("session-1"));
server.send({
type: "event",
event: {
type: "server_snapshot",
snapshot: {
...baseServerSnapshot,
revision: 2,
sessions: [{ id: "session-1", createdAt: 1, sessionName: "Named session" }],
},
},
});
expect(handle.attached).toBe(true);
});
test("does not let a delayed command response replace a newer event snapshot", async () => {
const server = new MemoryByteServer();
const client = await connectClient(server);
const initial = sessionSnapshot("session-1", { revision: 1, thinkingLevel: "off" });
const handle = await attachSession(client, server, initial);
const requests = collectRequests(server);
const changing = handle.setThinking("high");
const request = requests.find((candidate) => candidate.request.command === "set_thinking");
if (!request) throw new Error("Missing set_thinking request");
server.send({
type: "event",
event: {
type: "session_snapshot",
snapshot: sessionSnapshot("session-1", { revision: 3, thinkingLevel: "high" }),
},
});
server.send({
type: "response",
id: request.id,
ok: true,
result: {
command: "set_thinking",
session: sessionSnapshot("session-1", { revision: 2, thinkingLevel: "medium" }),
},
});
await changing;
expect(handle.snapshot).toMatchObject({ revision: 3, thinkingLevel: "high" });
});
test("does not let an attach response replace a newer snapshot from the reacquired runtime", async () => {
const server = new MemoryByteServer();
const client = await connectClient(server);
server.send({
type: "event",
event: {
type: "session_snapshot",
snapshot: sessionSnapshot("session-1", { revision: 10, attached: false }),
},
});
server.onMessage((message) => {
if (message.type !== "request" || message.request.command !== "attach") return;
server.send({
type: "event",
event: {
type: "session_snapshot",
snapshot: sessionSnapshot("session-1", { revision: 3, thinkingLevel: "high" }),
},
});
server.send({
type: "response",
id: message.id,
ok: true,
result: {
command: "attach",
session: sessionSnapshot("session-1", { revision: 2, thinkingLevel: "medium" }),
},
});
});
const handle = await client.attachSession("session-1");
expect(handle.snapshot).toMatchObject({ revision: 3, thinkingLevel: "high" });
});
});

View file

@ -3,151 +3,48 @@ import {
ClientMessageDecoder,
encodeServerMessage,
PROTOCOL_VERSION,
type RequestEnvelope,
type ServerMessage,
type ServerSnapshot,
type SessionSnapshot,
} from "@earendil-works/pi-protocol";
import type { ByteTransport, ByteTransportHandlers, PiSessionHandle } from "../src/index.ts";
import { PiClient } from "../src/index.ts";
import type { ByteTransport, ByteTransportHandlers } from "../src/index.ts";
export class MemoryByteServer {
private handlers: ByteTransportHandlers | undefined;
private readonly decoder = new ClientMessageDecoder();
private readonly messageListeners = new Set<(message: ClientMessage) => void>();
public readonly sentByClient: Uint8Array[] = [];
public clientCloseCount = 0;
readonly messages: ClientMessage[] = [];
readonly serviceId: string;
private handlers?: ByteTransportHandlers;
private decoder = new ClientMessageDecoder();
constructor(serviceId = "service-1") {
this.serviceId = serviceId;
}
connect(handlers: ByteTransportHandlers): ByteTransport {
this.handlers = handlers;
let closed = false;
this.decoder = new ClientMessageDecoder();
return {
send: async (chunk) => {
if (closed) throw new Error("Transport is closed");
this.sentByClient.push(chunk.slice());
for (const message of this.decoder.push(chunk)) {
for (const listener of this.messageListeners) listener(message);
this.messages.push(message);
if (message.type === "hello") {
this.send({
type: "hello",
version: PROTOCOL_VERSION,
connectionId: "connection-1",
serviceId: this.serviceId,
});
}
}
},
close: () => {
if (closed) return;
closed = true;
this.clientCloseCount++;
},
close: () => {},
};
}
onMessage(listener: (message: ClientMessage) => void): () => void {
this.messageListeners.add(listener);
return () => this.messageListeners.delete(listener);
send(message: ServerMessage): void {
if (!this.handlers) throw new Error("No client connection");
this.handlers.onData(encodeServerMessage(message));
}
send(message: ServerMessage, splitAt?: number): void {
const frame = encodeServerMessage(message);
if (splitAt === undefined) {
this.sendRaw(frame);
return;
}
this.sendRaw(frame.subarray(0, splitAt));
this.sendRaw(frame.subarray(splitAt));
}
sendTogether(messages: ServerMessage[]): void {
const frames = messages.map((message) => encodeServerMessage(message));
const length = frames.reduce((total, frame) => total + frame.byteLength, 0);
const chunk = new Uint8Array(length);
let offset = 0;
for (const frame of frames) {
chunk.set(frame, offset);
offset += frame.byteLength;
}
this.sendRaw(chunk);
}
sendRaw(chunk: Uint8Array): void {
this.handlers?.onData(chunk);
}
close(): void {
disconnect(): void {
this.handlers?.onClose();
}
error(error: Error): void {
this.handlers?.onError(error);
this.handlers = undefined;
}
}
export const baseServerSnapshot: ServerSnapshot = {
serverId: "server-1",
protocolVersion: PROTOCOL_VERSION,
revision: 1,
sessions: [],
models: [],
};
export function sessionSnapshot(id: string, overrides: Partial<SessionSnapshot> = {}): SessionSnapshot {
return {
id,
cwd: "/workspace",
createdAt: 1,
updatedAt: 1,
phase: "idle",
model: { provider: "faux", id: "model" },
thinkingLevel: "off",
attached: true,
locked: true,
revision: 1,
transcript: [],
queuedSteer: [],
queuedSteerCount: 0,
...overrides,
};
}
export function createClient(server: MemoryByteServer): PiClient {
return new PiClient({
transportFactory: (handlers) => server.connect(handlers),
});
}
export async function connectClient(server: MemoryByteServer): Promise<PiClient> {
const client = createClient(server);
server.onMessage((message) => {
if (message.type === "hello") {
server.send({
type: "hello",
version: PROTOCOL_VERSION,
connectionId: "connection-1",
snapshot: baseServerSnapshot,
});
}
});
await client.connect();
return client;
}
export function collectRequests(server: MemoryByteServer): RequestEnvelope[] {
const requests: RequestEnvelope[] = [];
server.onMessage((message) => {
if (message.type === "request") requests.push(message);
});
return requests;
}
export async function attachSession(
client: PiClient,
server: MemoryByteServer,
snapshot: SessionSnapshot,
): Promise<PiSessionHandle> {
const requests = collectRequests(server);
const attaching = client.attachSession(snapshot.id);
const request = requests.find((candidate) => candidate.request.command === "attach");
if (!request) throw new Error("Missing attach request");
server.send({
type: "response",
id: request.id,
ok: true,
result: { command: "attach", session: snapshot },
});
return attaching;
}

View file

@ -1,223 +0,0 @@
import { mkdtemp, rm } from "node:fs/promises";
import { createServer, type Server, type Socket } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
ClientMessageDecoder,
encodeServerMessage,
PROTOCOL_VERSION,
type ServerSnapshot,
} from "@earendil-works/pi-protocol";
import { describe, expect, test } from "vitest";
import { PiClient } from "../src/index.ts";
import { createUnixTransportFactory } from "../src/unix.ts";
const serverSnapshot: ServerSnapshot = {
serverId: "unix-server",
protocolVersion: PROTOCOL_VERSION,
revision: 4,
sessions: [],
models: [],
};
async function listen(server: Server, path: string): Promise<void> {
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(path, () => {
server.off("error", reject);
resolve();
});
});
}
async function closeServer(server: Server, sockets: Set<Socket>): Promise<void> {
for (const socket of sockets) socket.destroy();
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
test("rejects invalid Unix transport options", () => {
expect(() => createUnixTransportFactory({ path: "" })).toThrow(/must not be empty/);
expect(() => createUnixTransportFactory({ path: `/tmp/${"x".repeat(512)}` })).toThrow(/too long/);
expect(() => createUnixTransportFactory({ path: "/tmp/pi.sock", maxPendingBytes: 0 })).toThrow(/positive/);
});
describe.runIf(process.platform !== "win32")("Unix-domain sockets", () => {
test("PiClient exchanges fragmented framed messages over a real Unix socket", async () => {
const directory = await mkdtemp(join(tmpdir(), "pi-client-"));
const socketPath = join(directory, "pi.sock");
const sockets = new Set<Socket>();
const server = createServer((socket) => {
sockets.add(socket);
socket.once("close", () => sockets.delete(socket));
const decoder = new ClientMessageDecoder();
socket.on("data", (chunk) => {
for (const message of decoder.push(chunk)) {
if (message.type === "hello") {
const hello = encodeServerMessage({
type: "hello",
version: PROTOCOL_VERSION,
connectionId: "unix-connection",
snapshot: serverSnapshot,
});
for (const byte of hello) socket.write(new Uint8Array([byte]));
} else {
const response = encodeServerMessage({
type: "response",
id: message.id,
ok: true,
result: { command: "list", sessions: [] },
});
const split = Math.floor(response.byteLength / 2);
socket.write(response.subarray(0, split));
socket.write(response.subarray(split));
}
}
});
});
await listen(server, socketPath);
const client = new PiClient({
transportFactory: createUnixTransportFactory({ path: socketPath }),
});
try {
await expect(client.connect()).resolves.toEqual(serverSnapshot);
await expect(Promise.all([client.listSessions(), client.listSessions()])).resolves.toEqual([[], []]);
} finally {
client.disconnect();
await closeServer(server, sockets);
await rm(directory, { recursive: true, force: true });
}
});
test("bounds pending writes, preserves order, and reports remote end once", async () => {
const directory = await mkdtemp(join(tmpdir(), "pi-client-"));
const socketPath = join(directory, "pi.sock");
const sockets = new Set<Socket>();
const first = new Uint8Array(2 * 1024 * 1024).fill(1);
const second = new Uint8Array(2 * 1024 * 1024).fill(2);
const expectedLength = first.byteLength + second.byteLength;
let receivedLength = 0;
let invalidOrder = false;
let resolveReceived: (() => void) | undefined;
const received = new Promise<void>((resolve) => {
resolveReceived = resolve;
});
let resumeServer: (() => void) | undefined;
let resolveServerReady: (() => void) | undefined;
const serverReady = new Promise<void>((resolve) => {
resolveServerReady = resolve;
});
const server = createServer((socket) => {
sockets.add(socket);
socket.once("close", () => sockets.delete(socket));
socket.pause();
resumeServer = () => socket.resume();
resolveServerReady?.();
socket.on("data", (chunk) => {
for (let index = 0; index < chunk.byteLength; index++) {
const expected = receivedLength + index < first.byteLength ? 1 : 2;
if (chunk[index] !== expected) invalidOrder = true;
}
receivedLength += chunk.byteLength;
if (receivedLength === expectedLength) {
socket.end(new Uint8Array([9]));
resolveReceived?.();
}
});
});
await listen(server, socketPath);
const inbound: number[] = [];
const errors: Error[] = [];
let closeCount = 0;
let resolveClosed: (() => void) | undefined;
const closed = new Promise<void>((resolve) => {
resolveClosed = resolve;
});
const transport = await createUnixTransportFactory({ path: socketPath, maxPendingBytes: expectedLength })({
onData: (chunk) => inbound.push(...chunk),
onClose: () => {
closeCount += 1;
resolveClosed?.();
},
onError: (error) => errors.push(error),
});
try {
await serverReady;
const firstWrite = transport.send(first);
const secondWrite = transport.send(second);
await expect(transport.send(Uint8Array.of(3))).rejects.toThrow(/pending byte limit/);
resumeServer?.();
await Promise.all([firstWrite, secondWrite, received, closed]);
expect(receivedLength).toBe(expectedLength);
expect(invalidOrder).toBe(false);
expect(inbound).toEqual([9]);
expect(errors).toEqual([]);
await new Promise<void>((resolve) => setImmediate(resolve));
expect(closeCount).toBe(1);
} finally {
transport.close();
await closeServer(server, sockets);
await rm(directory, { recursive: true, force: true });
}
});
test("PiClient rejects a truncated final frame from a real Unix socket", async () => {
const directory = await mkdtemp(join(tmpdir(), "pi-client-"));
const socketPath = join(directory, "pi.sock");
const sockets = new Set<Socket>();
const server = createServer((socket) => {
sockets.add(socket);
socket.once("close", () => sockets.delete(socket));
const decoder = new ClientMessageDecoder();
socket.on("data", (chunk) => {
for (const message of decoder.push(chunk)) {
if (message.type === "hello") {
socket.write(
encodeServerMessage({
type: "hello",
version: PROTOCOL_VERSION,
connectionId: "unix-truncated",
snapshot: serverSnapshot,
}),
);
} else {
socket.end(new Uint8Array([0, 0, 0, 2, 1]));
}
}
});
});
await listen(server, socketPath);
const client = new PiClient({
transportFactory: createUnixTransportFactory({ path: socketPath }),
});
try {
await client.connect();
await expect(client.listSessions()).rejects.toMatchObject({ name: "ProtocolValidationError" });
expect(client.connectionState).toBe("disconnected");
} finally {
client.disconnect();
await closeServer(server, sockets);
await rm(directory, { recursive: true, force: true });
}
});
test("rejects connection errors", async () => {
const directory = await mkdtemp(join(tmpdir(), "pi-client-"));
const missingPath = join(directory, "missing.sock");
try {
await expect(
createUnixTransportFactory({ path: missingPath })({
onData: () => {},
onClose: () => {},
onError: () => {},
}),
).rejects.toMatchObject({ code: "ENOENT" });
} finally {
await rm(directory, { recursive: true, force: true });
}
});
});

View file

@ -6,7 +6,11 @@
"moduleResolution": "NodeNext",
"types": ["node", "vitest"],
"paths": {
"@earendil-works/pi-protocol": ["../protocol/src/index.ts"]
"@earendil-works/pi-agent-core": ["../agent/src/index.ts"],
"@earendil-works/pi-ai": ["../ai/src/index.ts"],
"@earendil-works/pi-ai/*": ["../ai/src/*.ts", "../ai/src/providers/*.ts"],
"@earendil-works/pi-protocol": ["../protocol/src/index.ts"],
"@earendil-works/pi-telemetry": ["../telemetry/src/index.ts"]
}
},
"include": ["src/**/*.ts", "test/**/*.ts"]

View file

@ -489,17 +489,6 @@
"node": ">=22.19.0"
}
},
"node_modules/@earendil-works/pi-client": {
"version": "0.84.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-client/-/pi-client-0.84.1.tgz",
"license": "MIT",
"dependencies": {
"@earendil-works/pi-protocol": "^0.84.1"
},
"engines": {
"node": ">=22.19.0"
}
},
"node_modules/@earendil-works/pi-coding-agent": {
"version": "0.84.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.84.1.tgz",
@ -507,8 +496,6 @@
"dependencies": {
"@earendil-works/pi-agent-core": "^0.84.1",
"@earendil-works/pi-ai": "^0.84.1",
"@earendil-works/pi-client": "^0.84.1",
"@earendil-works/pi-protocol": "^0.84.1",
"@earendil-works/pi-tui": "^0.84.1",
"@silvia-odwyer/photon-node": "0.3.4",
"chalk": "5.6.2",
@ -537,17 +524,6 @@
"node": ">=22.19.0"
}
},
"node_modules/@earendil-works/pi-protocol": {
"version": "0.84.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-protocol/-/pi-protocol-0.84.1.tgz",
"license": "MIT",
"dependencies": {
"typebox": "1.3.7"
},
"engines": {
"node": ">=22.19.0"
}
},
"node_modules/@earendil-works/pi-telemetry": {
"version": "0.84.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.84.1.tgz",

View file

@ -11,8 +11,6 @@
"dependencies": {
"@earendil-works/pi-agent-core": "^0.84.1",
"@earendil-works/pi-ai": "^0.84.1",
"@earendil-works/pi-client": "^0.84.1",
"@earendil-works/pi-protocol": "^0.84.1",
"@earendil-works/pi-tui": "^0.84.1",
"@silvia-odwyer/photon-node": "0.3.4",
"chalk": "5.6.2",
@ -516,28 +514,6 @@
"node": ">=22.19.0"
}
},
"node_modules/@earendil-works/pi-client": {
"version": "0.84.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-client/-/pi-client-0.84.1.tgz",
"license": "MIT",
"dependencies": {
"@earendil-works/pi-protocol": "^0.84.1"
},
"engines": {
"node": ">=22.19.0"
}
},
"node_modules/@earendil-works/pi-protocol": {
"version": "0.84.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-protocol/-/pi-protocol-0.84.1.tgz",
"license": "MIT",
"dependencies": {
"typebox": "1.3.7"
},
"engines": {
"node": ">=22.19.0"
}
},
"node_modules/@earendil-works/pi-telemetry": {
"version": "0.84.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.84.1.tgz",

View file

@ -18,10 +18,6 @@
},
"./rpc-entry": {
"import": "./dist/rpc-entry.js"
},
"./client": {
"types": "./dist/client/index.d.ts",
"import": "./dist/client/index.js"
}
},
"files": [
@ -35,7 +31,7 @@
"scripts": {
"clean": "shx rm -rf dist",
"build": "tsgo -p tsconfig.build.json && shx chmod +x dist/cli.js dist/rpc-entry.js && npm run copy-assets",
"build:binary": "npm --prefix ../tui run build && npm --prefix ../telemetry run build && npm --prefix ../ai run build && npm --prefix ../agent run build && npm --prefix ../protocol run build && npm --prefix ../client run build && npm run build && bun build --compile --no-compile-autoload-bunfig ./dist/bun/cli.js ./src/utils/image-resize-worker.ts --outfile dist/pi && npm run copy-binary-assets",
"build:binary": "npm --prefix ../tui run build && npm --prefix ../telemetry run build && npm --prefix ../ai run build && npm --prefix ../agent run build && npm run build && bun build --compile --no-compile-autoload-bunfig ./dist/bun/cli.js ./src/utils/image-resize-worker.ts --outfile dist/pi && npm run copy-binary-assets",
"copy-assets": "shx mkdir -p dist/modes/interactive/theme && shx cp src/modes/interactive/theme/*.json dist/modes/interactive/theme/ && shx mkdir -p dist/modes/interactive/assets && shx cp src/modes/interactive/assets/*.png dist/modes/interactive/assets/ && shx mkdir -p dist/core/export-html/vendor && shx cp src/core/export-html/template.html src/core/export-html/template.css src/core/export-html/template.js dist/core/export-html/ && shx cp src/core/export-html/vendor/*.js dist/core/export-html/vendor/",
"copy-binary-assets": "shx cp package.json dist/ && shx cp README.md dist/ && shx cp CHANGELOG.md dist/ && shx mkdir -p dist/theme && shx cp src/modes/interactive/theme/*.json dist/theme/ && shx mkdir -p dist/assets && shx cp src/modes/interactive/assets/*.png dist/assets/ && shx mkdir -p dist/export-html/vendor && shx cp src/core/export-html/template.html dist/export-html/ && shx cp src/core/export-html/vendor/*.js dist/export-html/vendor/ && shx cp -r docs dist/ && shx cp -r examples dist/ && shx cp ../../node_modules/@silvia-odwyer/photon-node/photon_rs_bg.wasm dist/",
"test": "vitest --run",
@ -45,8 +41,6 @@
"dependencies": {
"@earendil-works/pi-agent-core": "^0.84.1",
"@earendil-works/pi-ai": "^0.84.1",
"@earendil-works/pi-client": "^0.84.1",
"@earendil-works/pi-protocol": "^0.84.1",
"@earendil-works/pi-tui": "^0.84.1",
"@silvia-odwyer/photon-node": "0.3.4",
"chalk": "5.6.2",

View file

@ -1,15 +0,0 @@
export {
type CreateRemoteSessionOptions,
RemoteSession,
type RemoteSessionLifecycle,
type RemoteSessionOperation,
type RemoteSessionOptions,
type RemoteSessionState,
} from "./remote-session.ts";
export {
applyTranscriptProgress,
applyTranscriptSnapshot,
createTranscriptState,
selectTranscript,
type TranscriptState,
} from "./transcript.ts";

View file

@ -1,420 +0,0 @@
import type {
ConnectionState,
ConnectionStateChange,
PiClient,
SessionLease,
Unsubscribe,
} from "@earendil-works/pi-client";
import type {
ModelMetadata,
ModelRef,
ServerEvent,
SessionMetadata,
SessionPhase,
SessionSnapshot,
ThinkingLevel,
TranscriptItem,
} from "@earendil-works/pi-protocol";
import {
applyTranscriptProgress,
applyTranscriptSnapshot,
createTranscriptState,
selectTranscript,
type TranscriptState,
} from "./transcript.ts";
export type RemoteSessionOperation = "open" | "create" | "submit" | "abort" | "setModel" | "setThinking" | "reconnect";
export type RemoteSessionLifecycle =
| { readonly status: "unbound" }
| { readonly status: "ready" }
| { readonly status: "busy"; readonly operation: RemoteSessionOperation }
| { readonly status: "disposed" };
export interface RemoteSessionState {
readonly lifecycle: RemoteSessionLifecycle;
readonly snapshot?: SessionSnapshot;
readonly transcript: readonly TranscriptItem[];
}
export interface CreateRemoteSessionOptions {
cwd: string;
model?: ModelRef;
thinkingLevel?: ThinkingLevel;
}
export interface RemoteSessionOptions {
onListenerError?: (error: Error) => void;
}
class RemoteSessionDisposedError extends Error {
constructor() {
super("Remote session is disposed");
this.name = "RemoteSessionDisposedError";
}
}
async function settleRemoteSessionDisposal(cleanup: readonly Promise<void>[]): Promise<void> {
const results = await Promise.allSettled(cleanup);
const errors = results.flatMap((result) =>
result.status === "rejected" && !(result.reason instanceof RemoteSessionDisposedError) ? [result.reason] : [],
);
if (errors.length === 1) throw errors[0];
if (errors.length > 1) throw new AggregateError(errors, "Failed to dispose remote session");
}
export class RemoteSession {
readonly #client: PiClient;
readonly #onListenerError: ((error: Error) => void) | undefined;
#lifecycle: RemoteSessionLifecycle = { status: "unbound" };
#handle: SessionLease | undefined;
#transcript: TranscriptState | undefined;
#unsubscribeSnapshot: Unsubscribe | undefined;
#unsubscribeEvents: Unsubscribe | undefined;
readonly #listeners = new Set<(state: RemoteSessionState) => void>();
readonly #pendingAttachmentOperations = new Set<Promise<void>>();
readonly #activeOperationStates = new Set<RemoteSessionLifecycle>();
#disposePromise: Promise<void> | undefined;
#resolveDisposeSignal: () => void = () => {};
readonly #disposeSignal = new Promise<void>((resolve) => {
this.#resolveDisposeSignal = resolve;
});
private constructor(client: PiClient, options: RemoteSessionOptions = {}) {
this.#client = client;
this.#onListenerError = options.onListenerError;
}
get id(): string | undefined {
return this.#handle?.id;
}
get state(): RemoteSessionState {
return {
lifecycle: this.#lifecycle,
snapshot: this.#transcript?.snapshot,
transcript: this.#transcript ? selectTranscript(this.#transcript) : [],
};
}
get snapshot(): SessionSnapshot | undefined {
return this.#transcript?.snapshot;
}
get phase(): SessionPhase | undefined {
return this.snapshot?.phase;
}
get operation(): RemoteSessionOperation | undefined {
return this.#lifecycle.status === "busy" ? this.#lifecycle.operation : undefined;
}
get models(): readonly ModelMetadata[] {
return this.#client.snapshot?.models ?? [];
}
get sessions(): readonly SessionMetadata[] {
return this.#client.snapshot?.sessions ?? [];
}
get connectionState(): ConnectionState {
return this.#client.connectionState;
}
get disposed(): boolean {
return this.#lifecycle.status === "disposed";
}
subscribe(listener: (state: RemoteSessionState) => void): Unsubscribe {
this.#assertNotDisposed();
this.#listeners.add(listener);
this.#callListener(listener, this.state);
return () => this.#listeners.delete(listener);
}
onConnectionStateChange(listener: (change: ConnectionStateChange) => void): Unsubscribe {
this.#assertNotDisposed();
return this.#client.onConnectionStateChange(listener);
}
static async open(client: PiClient, sessionId: string, options: RemoteSessionOptions = {}): Promise<RemoteSession> {
const session = new RemoteSession(client, options);
try {
await session.open(sessionId);
return session;
} catch (error) {
await session.dispose();
throw error;
}
}
async open(sessionId: string): Promise<void> {
if (this.#handle?.id === sessionId && this.#lifecycle.status === "ready") return;
await this.#replace("open", () => this.#client.acquireSession(sessionId, { mode: "exclusive" }));
}
static async create(
client: PiClient,
createOptions: CreateRemoteSessionOptions,
options: RemoteSessionOptions = {},
): Promise<RemoteSession> {
const session = new RemoteSession(client, options);
try {
await session.create(createOptions);
return session;
} catch (error) {
await session.dispose();
throw error;
}
}
async create(options: CreateRemoteSessionOptions): Promise<void> {
await this.#replace("create", () => this.#client.createSession(options));
}
async submit(text: string): Promise<void> {
const normalized = text.trim();
if (!normalized) return;
this.#assertAvailable();
const handle = this.#requireHandle();
if (this.phase !== "idle" && this.phase !== "turn") {
throw new Error(`Session cannot accept input during ${this.phase ?? "unknown"} phase`);
}
await this.#runOperation("submit", () =>
(this.phase === "idle" ? handle.prompt(normalized) : handle.steer(normalized)).then(() => undefined),
);
}
async abort(): Promise<void> {
const preemptingSubmit = this.#lifecycle.status === "busy" && this.#lifecycle.operation === "submit";
if (preemptingSubmit) this.#assertNotDisposed();
else this.#assertAvailable();
const handle = this.#requireHandle();
if (this.phase === "idle" && !preemptingSubmit) return;
await this.#runOperation("abort", () => handle.abort().then(() => undefined), preemptingSubmit);
}
async setModel(model: ModelRef): Promise<void> {
await this.#runIdleOperation("setModel", "change model", () =>
this.#requireHandle()
.setModel(model)
.then(() => undefined),
);
}
async setThinking(thinkingLevel: ThinkingLevel): Promise<void> {
await this.#runIdleOperation("setThinking", "change thinking level", () =>
this.#requireHandle()
.setThinking(thinkingLevel)
.then(() => undefined),
);
}
async reconnect(): Promise<void> {
this.#assertAvailable();
const sessionId = this.#requireHandle().id;
await this.#runOperation("reconnect", () =>
this.#trackAttachmentOperation(async () => {
await this.#client.reconnect();
const handle = await this.#client.acquireSession(sessionId, { mode: "exclusive" });
await this.#assertNotDisposedAfterAwait(handle);
this.#bind(handle);
}),
);
}
dispose(): Promise<void> {
if (this.#disposePromise) return this.#disposePromise;
const handle = this.#handle;
this.#lifecycle = { status: "disposed" };
this.#resolveDisposeSignal();
this.#clearSubscriptions();
this.#handle = undefined;
this.#transcript = undefined;
const cleanup = [...this.#pendingAttachmentOperations];
if (handle) cleanup.push(handle.dispose());
this.#disposePromise = settleRemoteSessionDisposal(cleanup);
this.#notify();
this.#listeners.clear();
return this.#disposePromise;
}
[Symbol.asyncDispose](): Promise<void> {
return this.dispose();
}
async #replace(operation: "open" | "create", prepare: () => Promise<SessionLease>): Promise<void> {
this.#assertAvailable();
if (this.#handle && this.phase !== "idle") {
throw new Error(`Cannot ${operation} a session while session is ${this.phase ?? "unavailable"}`);
}
await this.#runOperation(operation, () =>
this.#trackAttachmentOperation(() => this.#prepareReplacement(operation, prepare)),
);
}
async #trackAttachmentOperation(run: () => Promise<void>): Promise<void> {
const pending = run();
this.#pendingAttachmentOperations.add(pending);
try {
await pending;
} finally {
this.#pendingAttachmentOperations.delete(pending);
}
}
async #prepareReplacement(operation: "open" | "create", prepare: () => Promise<SessionLease>): Promise<void> {
const previous = this.#handle;
const next = await prepare();
await this.#assertNotDisposedAfterAwait(next);
const snapshot = next.snapshot;
if (!snapshot) {
await this.#detach(next);
throw new Error(`Session ${next.id} did not provide a snapshot`);
}
if (previous && previous.id !== next.id && previous.attached && this.phase !== "idle") {
await this.#detach(next);
throw new Error(`Cannot ${operation} a session while session is ${this.phase ?? "unavailable"}`);
}
if (previous && previous.id !== next.id && previous.attached) {
try {
await previous.detach();
} catch (error) {
try {
await this.#detach(next);
} catch (cleanupError) {
throw new AggregateError([error, cleanupError], "Failed to replace remote session attachment");
}
throw error;
}
}
await this.#assertNotDisposedAfterAwait(next);
this.#bind(next, snapshot);
}
async #runIdleOperation(
operation: "setModel" | "setThinking",
description: string,
run: () => Promise<void>,
): Promise<void> {
this.#assertAvailable();
this.#requireHandle();
if (this.phase !== "idle") {
throw new Error(`Cannot ${description} while session is ${this.phase ?? "unavailable"}`);
}
await this.#runOperation(operation, run);
}
async #runOperation(operation: RemoteSessionOperation, run: () => Promise<void>, preempt = false): Promise<void> {
if (preempt) this.#assertNotDisposed();
else this.#assertAvailable();
const previous = this.#lifecycle;
const busy: RemoteSessionLifecycle = { status: "busy", operation };
this.#lifecycle = busy;
this.#activeOperationStates.add(busy);
this.#notify();
const running = run();
try {
await Promise.race([
running,
this.#disposeSignal.then(() => {
throw new Error("Remote session is disposed");
}),
]);
} finally {
this.#activeOperationStates.delete(busy);
if (!this.disposed && this.#lifecycle === busy) {
this.#lifecycle =
preempt && this.#activeOperationStates.has(previous)
? previous
: this.#handle
? { status: "ready" }
: { status: "unbound" };
this.#notify();
}
}
}
#bind(handle: SessionLease, knownSnapshot?: SessionSnapshot): void {
const snapshot = knownSnapshot ?? handle.snapshot;
if (!snapshot) throw new Error(`Session ${handle.id} did not provide a snapshot`);
this.#clearSubscriptions();
this.#handle = handle;
this.#transcript = createTranscriptState(snapshot);
this.#unsubscribeSnapshot = handle.subscribe((next) => {
if (!this.#transcript) return;
this.#transcript = applyTranscriptSnapshot(this.#transcript, next);
this.#notify();
});
this.#unsubscribeEvents = handle.onEvent((event) => this.#handleEvent(event));
}
#handleEvent(event: ServerEvent): void {
if (event.type === "session_removed") {
this.#clearSubscriptions();
this.#handle = undefined;
this.#transcript = undefined;
if (this.#lifecycle.status !== "busy") this.#lifecycle = { status: "unbound" };
this.#notify();
return;
}
if (event.type !== "session_progress" || !this.#transcript) return;
this.#transcript = applyTranscriptProgress(this.#transcript, event.progress);
this.#notify();
}
#notify(): void {
const state = this.state;
for (const listener of this.#listeners) this.#callListener(listener, state);
}
#callListener(listener: (state: RemoteSessionState) => void, state: RemoteSessionState): void {
try {
listener(state);
} catch (error) {
this.#reportListenerError(error);
}
}
#reportListenerError(error: unknown): void {
if (!this.#onListenerError) return;
try {
this.#onListenerError(error instanceof Error ? error : new Error(String(error)));
} catch {
// Diagnostics cannot affect session or transport state.
}
}
#clearSubscriptions(): void {
this.#unsubscribeSnapshot?.();
this.#unsubscribeEvents?.();
this.#unsubscribeSnapshot = undefined;
this.#unsubscribeEvents = undefined;
}
#requireHandle(): SessionLease {
if (!this.#handle) throw new Error("No remote session is attached");
return this.#handle;
}
#assertAvailable(): void {
this.#assertNotDisposed();
if (this.#lifecycle.status === "busy") {
throw new Error(`Remote session is busy with ${this.#lifecycle.operation}`);
}
}
#assertNotDisposed(): void {
if (this.disposed) throw new Error("Remote session is disposed");
}
async #assertNotDisposedAfterAwait(handle: SessionLease): Promise<void> {
if (!this.disposed) return;
await this.#detach(handle);
throw new RemoteSessionDisposedError();
}
async #detach(handle: SessionLease): Promise<void> {
await handle.dispose();
}
}

View file

@ -1,101 +0,0 @@
import type { JsonValue, SessionSnapshot, TranscriptItem, TranscriptProgress } from "@earendil-works/pi-protocol";
export interface TranscriptState {
readonly snapshot: SessionSnapshot;
readonly progressItems: ReadonlyMap<string, TranscriptItem>;
readonly progressOrder: readonly string[];
readonly toolCallBuffers: ReadonlyMap<string, string>;
}
function isJsonValue(value: unknown): value is JsonValue {
if (value === null || typeof value === "boolean" || typeof value === "string") return true;
if (typeof value === "number") return Number.isFinite(value);
if (Array.isArray(value)) return value.every(isJsonValue);
if (typeof value !== "object" || Object.getPrototypeOf(value) !== Object.prototype) return false;
return Object.values(value).every(isJsonValue);
}
function parsePartialToolInput(value: string): JsonValue {
try {
const parsed: unknown = JSON.parse(value);
if (isJsonValue(parsed)) return parsed;
} catch {
// Tool arguments are incomplete while streaming. Preserve their raw prefix until they form valid JSON.
}
return value;
}
export function createTranscriptState(snapshot: SessionSnapshot): TranscriptState {
return {
snapshot: structuredClone(snapshot),
progressItems: new Map(),
progressOrder: [],
toolCallBuffers: new Map(),
};
}
export function applyTranscriptSnapshot(state: TranscriptState, snapshot: SessionSnapshot): TranscriptState {
if (state.snapshot.id === snapshot.id && snapshot.revision < state.snapshot.revision) return state;
return createTranscriptState(snapshot);
}
export function applyTranscriptProgress(state: TranscriptState, progress: TranscriptProgress): TranscriptState {
if (progress.type === "item_started" || progress.type === "item_updated") {
return setProgressItem(state, progress.item);
}
if (progress.type === "item_finished") {
const toolCallBuffers = new Map(state.toolCallBuffers);
for (const key of toolCallBuffers.keys()) {
if (key.startsWith(`${progress.item.id}:`)) toolCallBuffers.delete(key);
}
return setProgressItem({ ...state, toolCallBuffers }, progress.item);
}
const item =
state.progressItems.get(progress.messageId) ??
state.snapshot.transcript.find(({ id }) => id === progress.messageId);
if (!item || item.role !== "assistant") return state;
let toolCallBuffers = state.toolCallBuffers;
const content = item.content.map((part, index) => {
if (index !== progress.contentIndex) return structuredClone(part);
if (progress.kind === "text" && part.type === "text") return { ...part, text: part.text + progress.delta };
if (progress.kind === "thinking" && part.type === "thinking") {
return { ...part, thinking: part.thinking + progress.delta };
}
if (progress.kind === "toolCall" && part.type === "toolCall") {
const key = `${progress.messageId}:${progress.contentIndex}`;
const existing = state.toolCallBuffers.get(key) ?? (typeof part.input === "string" ? part.input : "");
const buffer = existing + progress.delta;
toolCallBuffers = new Map(state.toolCallBuffers).set(key, buffer);
return { ...part, input: parsePartialToolInput(buffer) };
}
return structuredClone(part);
});
return setProgressItem({ ...state, toolCallBuffers }, { ...item, content });
}
export function selectTranscript(state: TranscriptState): readonly TranscriptItem[] {
const transcript = state.snapshot.transcript.map((item) => state.progressItems.get(item.id) ?? item);
const ids = new Set(transcript.map((item) => item.id));
for (const id of state.progressOrder) {
if (ids.has(id)) continue;
const item = state.progressItems.get(id);
if (item) {
transcript.push(item);
ids.add(id);
}
}
for (const item of state.snapshot.queuedSteer) {
if (ids.has(item.id)) continue;
transcript.push(item);
ids.add(item.id);
}
return transcript;
}
function setProgressItem(state: TranscriptState, item: TranscriptItem): TranscriptState {
const progressItems = new Map(state.progressItems);
const progressOrder = progressItems.has(item.id) ? state.progressOrder : [...state.progressOrder, item.id];
progressItems.set(item.id, structuredClone(item));
return { ...state, progressItems, progressOrder };
}

View file

@ -1,190 +0,0 @@
import type { RequestEnvelope } from "@earendil-works/pi-protocol";
import { describe, expect, test } from "vitest";
import { collectRequests, connectClient, MemoryServer, openRemoteSession, sessionSnapshot } from "./support.ts";
function nextRequest(server: MemoryServer, command: RequestEnvelope["request"]["command"]): Promise<RequestEnvelope> {
return new Promise((resolve) => {
const unsubscribe = server.onMessage((message) => {
if (message.type !== "request" || message.request.command !== command) return;
unsubscribe();
resolve(message);
});
});
}
describe("RemoteSession lifecycle", () => {
test("opens a replacement before detaching the current session", async () => {
const server = new MemoryServer();
const remoteSession = await openRemoteSession(await connectClient(server), server, sessionSnapshot("session-1"));
const requests = collectRequests(server);
const opening = remoteSession.open("session-2");
const attachRequest = requests.at(-1);
if (!attachRequest) throw new Error("Missing attach request");
const detachRequestPromise = nextRequest(server, "detach");
server.send({
type: "response",
id: attachRequest.id,
ok: true,
result: { command: "attach", session: sessionSnapshot("session-2") },
});
const detachRequest = await detachRequestPromise;
expect(detachRequest.request).toEqual({ command: "detach", sessionId: "session-1" });
server.send({
type: "response",
id: detachRequest.id,
ok: true,
result: { command: "detach", sessionId: "session-1" },
});
await opening;
expect(remoteSession.id).toBe("session-2");
});
test("rejects another mutation while replacement attachment is pending", async () => {
const server = new MemoryServer();
const remoteSession = await openRemoteSession(await connectClient(server), server, sessionSnapshot("session-1"));
const requests = collectRequests(server);
const opening = remoteSession.open("session-2");
await expect(remoteSession.submit("race")).rejects.toThrow("Remote session is busy with open");
await expect(remoteSession.create({ cwd: "/other" })).rejects.toThrow("Remote session is busy with open");
expect(requests.map(({ request }) => request)).toEqual([{ command: "attach", sessionId: "session-2" }]);
const attachRequest = requests[0];
if (!attachRequest) throw new Error("Missing attach request");
const detachRequestPromise = nextRequest(server, "detach");
server.send({
type: "response",
id: attachRequest.id,
ok: true,
result: { command: "attach", session: sessionSnapshot("session-2") },
});
const detachRequest = await detachRequestPromise;
server.send({
type: "response",
id: detachRequest.id,
ok: true,
result: { command: "detach", sessionId: "session-1" },
});
await opening;
});
test("rolls back a replacement when the current server session becomes active", async () => {
const server = new MemoryServer();
const remoteSession = await openRemoteSession(await connectClient(server), server, sessionSnapshot("session-1"));
const requests = collectRequests(server);
const opening = remoteSession.open("session-2");
const attachRequest = requests.at(-1);
if (!attachRequest) throw new Error("Missing attach request");
const detachRequestPromise = nextRequest(server, "detach");
server.send({
type: "event",
event: {
type: "session_snapshot",
snapshot: sessionSnapshot("session-1", { phase: "turn", revision: 2 }),
},
});
server.send({
type: "response",
id: attachRequest.id,
ok: true,
result: { command: "attach", session: sessionSnapshot("session-2") },
});
const detachRequest = await detachRequestPromise;
expect(detachRequest.request).toEqual({ command: "detach", sessionId: "session-2" });
server.send({
type: "response",
id: detachRequest.id,
ok: true,
result: { command: "detach", sessionId: "session-2" },
});
await expect(opening).rejects.toThrow("Cannot open a session while session is turn");
expect(remoteSession.id).toBe("session-1");
expect(remoteSession.state.lifecycle).toEqual({ status: "ready" });
});
test("dispose awaits attachment cleanup started by reconnect", async () => {
const server = new MemoryServer();
const client = await connectClient(server);
const remoteSession = await openRemoteSession(client, server, sessionSnapshot("session-1"));
client.disconnect("test reconnect");
const attachRequestPromise = nextRequest(server, "attach");
const reconnecting = remoteSession.reconnect();
const attachRequest = await attachRequestPromise;
const disposing = remoteSession.dispose();
let disposalSettled = false;
void disposing.then(() => {
disposalSettled = true;
});
await expect(reconnecting).rejects.toThrow("Remote session is disposed");
const detachRequestPromise = nextRequest(server, "detach");
server.send({
type: "response",
id: attachRequest.id,
ok: true,
result: { command: "attach", session: sessionSnapshot("session-1", { revision: 2 }) },
});
const detachRequest = await detachRequestPromise;
expect(disposalSettled).toBe(false);
server.send({
type: "response",
id: detachRequest.id,
ok: true,
result: { command: "detach", sessionId: "session-1" },
});
await disposing;
expect(disposalSettled).toBe(true);
expect(client.connected).toBe(true);
});
test("dispose immediately preempts pending work and awaits attachment cleanup", async () => {
const server = new MemoryServer();
const client = await connectClient(server);
const remoteSession = await openRemoteSession(client, server, sessionSnapshot("session-1"));
const states: string[] = [];
remoteSession.subscribe(({ lifecycle }) => states.push(lifecycle.status));
const requests = collectRequests(server);
const opening = remoteSession.open("session-2");
const attachRequest = requests.find(({ request }) => request.command === "attach");
if (!attachRequest) throw new Error("Missing attach request");
const disposing = remoteSession.dispose();
const currentDetachRequest = requests.find(
({ request }) => request.command === "detach" && request.sessionId === "session-1",
);
if (!currentDetachRequest) throw new Error("Missing current detach request");
expect(client.connected).toBe(true);
expect(remoteSession.state.lifecycle).toEqual({ status: "disposed" });
await expect(opening).rejects.toThrow();
const replacementDetachRequestPromise = nextRequest(server, "detach");
server.send({
type: "response",
id: attachRequest.id,
ok: true,
result: { command: "attach", session: sessionSnapshot("session-2") },
});
server.send({
type: "response",
id: currentDetachRequest.id,
ok: true,
result: { command: "detach", sessionId: "session-1" },
});
const replacementDetachRequest = await replacementDetachRequestPromise;
expect(replacementDetachRequest.request).toEqual({ command: "detach", sessionId: "session-2" });
server.send({
type: "response",
id: replacementDetachRequest.id,
ok: true,
result: { command: "detach", sessionId: "session-2" },
});
await disposing;
expect(states).toContain("disposed");
expect(() => remoteSession.subscribe(() => {})).toThrow("Remote session is disposed");
});
});

View file

@ -1,174 +0,0 @@
import { PiSessionOwnershipError } from "@earendil-works/pi-client";
import { describe, expect, test } from "vitest";
import { RemoteSession } from "../../src/client/remote-session.ts";
import { collectRequests, connectClient, MemoryServer, sessionSnapshot } from "./support.ts";
function respondToAttach(server: MemoryServer, sessionId: string): void {
server.onMessage((message) => {
if (message.type !== "request" || message.request.command !== "attach") return;
server.send({
type: "response",
id: message.id,
ok: true,
result: { command: "attach", session: sessionSnapshot(sessionId) },
});
});
}
describe("RemoteSession ownership", () => {
test("factory opens a session and disposal awaits detach without disconnecting the borrowed client", async () => {
const server = new MemoryServer();
const client = await connectClient(server);
respondToAttach(server, "session-1");
const remoteSession = await RemoteSession.open(client, "session-1");
const requests = collectRequests(server);
const firstDisposal = remoteSession.dispose();
const secondDisposal = remoteSession.dispose();
let disposalSettled = false;
void firstDisposal.then(() => {
disposalSettled = true;
});
expect(secondDisposal).toBe(firstDisposal);
await Promise.resolve();
expect(disposalSettled).toBe(false);
const detachRequest = requests.at(-1);
expect(detachRequest?.request).toEqual({ command: "detach", sessionId: "session-1" });
if (!detachRequest) throw new Error("Missing detach request");
server.send({
type: "response",
id: detachRequest.id,
ok: true,
result: { command: "detach", sessionId: "session-1" },
});
await firstDisposal;
expect(remoteSession.disposed).toBe(true);
expect(client.connected).toBe(true);
});
test("rejects an exclusive coordinator while a direct shared lease is active", async () => {
const server = new MemoryServer();
const client = await connectClient(server);
respondToAttach(server, "session-1");
server.onMessage((message) => {
if (message.type !== "request" || message.request.command !== "detach") return;
server.send({
type: "response",
id: message.id,
ok: true,
result: { command: "detach", sessionId: "session-1" },
});
});
const directHandle = await client.attachSession("session-1");
const requests = collectRequests(server);
await expect(RemoteSession.open(client, "session-1")).rejects.toBeInstanceOf(PiSessionOwnershipError);
expect(requests).toEqual([]);
expect(directHandle.active).toBe(true);
await directHandle.detach();
expect(requests.map(({ request }) => request.command)).toEqual(["detach"]);
});
test("factory creates a session", async () => {
const server = new MemoryServer();
const client = await connectClient(server);
server.onMessage((message) => {
if (message.type !== "request" || message.request.command !== "create") return;
server.send({
type: "response",
id: message.id,
ok: true,
result: { command: "create", session: sessionSnapshot("session-1") },
});
});
const remoteSession = await RemoteSession.create(client, { cwd: "/workspace" });
expect(remoteSession.id).toBe("session-1");
expect(remoteSession.state.lifecycle).toEqual({ status: "ready" });
});
test("disposal reports cleanup failure without retaining exclusive ownership", async () => {
const server = new MemoryServer();
const client = await connectClient(server);
respondToAttach(server, "session-1");
const remoteSession = await RemoteSession.open(client, "session-1");
let detachCount = 0;
server.onMessage((message) => {
if (message.type !== "request" || message.request.command !== "detach") return;
detachCount++;
if (detachCount === 1) {
server.send({
type: "response",
id: message.id,
ok: false,
error: { code: "invalid_request", message: "no" },
});
} else {
server.send({
type: "response",
id: message.id,
ok: true,
result: { command: "detach", sessionId: "session-1" },
});
}
});
await expect(remoteSession.dispose()).rejects.toThrow("no");
expect(remoteSession.disposed).toBe(true);
expect(client.connected).toBe(true);
const replacement = await RemoteSession.open(client, "session-1");
expect(replacement.state.lifecycle).toEqual({ status: "ready" });
expect(detachCount).toBe(2);
});
test("multiple sessions borrow one client independently", async () => {
const server = new MemoryServer();
const client = await connectClient(server);
server.onMessage((message) => {
if (message.type !== "request") return;
if (message.request.command === "attach") {
server.send({
type: "response",
id: message.id,
ok: true,
result: { command: "attach", session: sessionSnapshot(message.request.sessionId) },
});
}
if (message.request.command === "detach") {
server.send({
type: "response",
id: message.id,
ok: true,
result: { command: "detach", sessionId: message.request.sessionId },
});
}
});
const first = await RemoteSession.open(client, "session-1");
const second = await RemoteSession.open(client, "session-2");
await first.dispose();
expect(first.disposed).toBe(true);
expect(second.disposed).toBe(false);
expect(client.connected).toBe(true);
await second.dispose();
});
test("session disposal treats a client-first disposal as released", async () => {
const server = new MemoryServer();
const client = await connectClient(server);
respondToAttach(server, "session-1");
const remoteSession = await RemoteSession.open(client, "session-1");
const requests = collectRequests(server);
await client.dispose();
await remoteSession[Symbol.asyncDispose]();
expect(remoteSession.disposed).toBe(true);
expect(requests).toEqual([]);
});
});

View file

@ -1,196 +0,0 @@
import { describe, expect, test } from "vitest";
import { collectRequests, connectClient, MemoryServer, openRemoteSession, sessionSnapshot } from "./support.ts";
describe("RemoteSession operations", () => {
test("projects progress for subscribers without changing the authoritative snapshot", async () => {
const server = new MemoryServer();
const remoteSession = await openRemoteSession(
await connectClient(server),
server,
sessionSnapshot("session-1", {
phase: "turn",
transcript: [
{
id: "assistant-1",
role: "assistant",
content: [{ type: "text", text: "hello" }],
status: "streaming",
model: { provider: "faux", id: "model" },
timestamp: 1,
},
],
}),
);
const views: string[] = [];
remoteSession.subscribe((state) => {
const item = state.transcript[0];
if (item?.role === "assistant" && item.content[0]?.type === "text") views.push(item.content[0].text);
});
server.send({
type: "event",
event: {
type: "session_progress",
sessionId: "session-1",
progress: {
type: "assistant_delta",
messageId: "assistant-1",
contentIndex: 0,
kind: "text",
delta: " world",
},
},
});
expect(views).toEqual(["hello", "hello world"]);
expect(remoteSession.snapshot?.transcript[0]).toMatchObject({ content: [{ text: "hello" }] });
});
test("becomes unbound and can reopen after its session is removed", async () => {
const server = new MemoryServer();
const client = await connectClient(server);
const remoteSession = await openRemoteSession(client, server, sessionSnapshot("session-1"));
server.send({ type: "event", event: { type: "session_removed", sessionId: "session-1" } });
expect(remoteSession.id).toBeUndefined();
expect(remoteSession.snapshot).toBeUndefined();
expect(remoteSession.state.transcript).toEqual([]);
expect(remoteSession.state.lifecycle).toEqual({ status: "unbound" });
const requests = collectRequests(server);
const reopening = remoteSession.open("session-1");
const request = requests.at(-1);
expect(request?.request).toEqual({ command: "attach", sessionId: "session-1" });
if (!request) throw new Error("Missing attach request");
server.send({
type: "response",
id: request.id,
ok: true,
result: { command: "attach", session: sessionSnapshot("session-1", { revision: 2 }) },
});
await reopening;
expect(remoteSession.state.lifecycle).toEqual({ status: "ready" });
});
test("exposes the active operation while prompting", async () => {
const server = new MemoryServer();
const remoteSession = await openRemoteSession(await connectClient(server), server, sessionSnapshot("session-1"));
const requests = collectRequests(server);
const lifecycles: string[] = [];
remoteSession.subscribe(({ lifecycle }) => {
lifecycles.push(lifecycle.status === "busy" ? `${lifecycle.status}:${lifecycle.operation}` : lifecycle.status);
});
const prompting = remoteSession.submit(" first prompt ");
const request = requests.at(-1);
expect(request?.request).toEqual({ command: "prompt", sessionId: "session-1", text: "first prompt" });
expect(remoteSession.operation).toBe("submit");
if (!request) throw new Error("Missing prompt request");
server.send({
type: "response",
id: request.id,
ok: true,
result: { command: "prompt", session: sessionSnapshot("session-1", { revision: 2, phase: "turn" }) },
});
await prompting;
expect(lifecycles).toEqual(["ready", "busy:submit", "busy:submit", "ready"]);
expect(remoteSession.state.lifecycle).toEqual({ status: "ready" });
});
test("steers when the server session is in a turn", async () => {
const server = new MemoryServer();
const remoteSession = await openRemoteSession(
await connectClient(server),
server,
sessionSnapshot("session-1", { phase: "turn" }),
);
const requests = collectRequests(server);
const steering = remoteSession.submit("adjust");
const request = requests.at(-1);
expect(request?.request).toEqual({ command: "steer", sessionId: "session-1", text: "adjust" });
if (!request) throw new Error("Missing steer request");
server.send({
type: "response",
id: request.id,
ok: true,
result: { command: "steer", session: sessionSnapshot("session-1", { revision: 2, phase: "turn" }) },
});
await steering;
});
test("aborts while a prompt response is pending", async () => {
const server = new MemoryServer();
const remoteSession = await openRemoteSession(await connectClient(server), server, sessionSnapshot("session-1"));
const requests = collectRequests(server);
const prompting = remoteSession.submit("hello");
const promptRequest = requests.at(-1);
if (!promptRequest) throw new Error("Missing prompt request");
server.send({
type: "event",
event: { type: "session_snapshot", snapshot: sessionSnapshot("session-1", { revision: 2, phase: "turn" }) },
});
const aborting = remoteSession.abort();
const abortRequest = requests.at(-1);
expect(abortRequest?.request).toEqual({ command: "abort", sessionId: "session-1" });
expect(remoteSession.operation).toBe("abort");
if (!abortRequest) throw new Error("Missing abort request");
server.send({
type: "response",
id: promptRequest.id,
ok: true,
result: { command: "prompt", session: sessionSnapshot("session-1", { revision: 3, phase: "turn" }) },
});
await prompting;
expect(remoteSession.operation).toBe("abort");
server.send({
type: "response",
id: abortRequest.id,
ok: true,
result: { command: "abort", session: sessionSnapshot("session-1", { revision: 4 }) },
});
await aborting;
expect(remoteSession.state.lifecycle).toEqual({ status: "ready" });
});
test("rejects conflicting operations while locally busy", async () => {
const server = new MemoryServer();
const remoteSession = await openRemoteSession(await connectClient(server), server, sessionSnapshot("session-1"));
const requests = collectRequests(server);
const prompting = remoteSession.submit("hello");
await expect(remoteSession.setThinking("high")).rejects.toThrow("Remote session is busy with submit");
await expect(remoteSession.open("session-2")).rejects.toThrow("Remote session is busy with submit");
const request = requests.at(-1);
if (!request) throw new Error("Missing prompt request");
server.send({
type: "response",
id: request.id,
ok: true,
result: { command: "prompt", session: sessionSnapshot("session-1", { revision: 2, phase: "turn" }) },
});
await prompting;
});
test("reports subscriber failures without interrupting other subscribers", async () => {
const server = new MemoryServer();
const listenerErrors: Error[] = [];
const remoteSession = await openRemoteSession(await connectClient(server), server, sessionSnapshot("session-1"), {
onListenerError: (error) => listenerErrors.push(error),
});
remoteSession.subscribe(() => {
throw new Error("render failed");
});
let notified = false;
remoteSession.subscribe(() => {
notified = true;
});
expect(listenerErrors.map(({ message }) => message)).toEqual(["render failed"]);
expect(notified).toBe(true);
});
});

View file

@ -1,111 +0,0 @@
import { type ByteTransport, type ByteTransportHandlers, PiClient } from "@earendil-works/pi-client";
import {
type ClientMessage,
ClientMessageDecoder,
encodeServerMessage,
PROTOCOL_VERSION,
type RequestEnvelope,
type ServerMessage,
type ServerSnapshot,
type SessionSnapshot,
} from "@earendil-works/pi-protocol";
import { RemoteSession, type RemoteSessionOptions } from "../../src/client/remote-session.ts";
export class MemoryServer {
#handlers: ByteTransportHandlers | undefined;
readonly #decoder = new ClientMessageDecoder();
readonly #messageListeners = new Set<(message: ClientMessage) => void>();
connect(handlers: ByteTransportHandlers): ByteTransport {
this.#handlers = handlers;
let closed = false;
return {
send: async (chunk) => {
if (closed) throw new Error("Transport is closed");
for (const message of this.#decoder.push(chunk)) {
for (const listener of this.#messageListeners) listener(message);
}
},
close: () => {
closed = true;
},
};
}
onMessage(listener: (message: ClientMessage) => void): () => void {
this.#messageListeners.add(listener);
return () => this.#messageListeners.delete(listener);
}
send(message: ServerMessage): void {
this.#handlers?.onData(encodeServerMessage(message));
}
close(): void {
this.#handlers?.onClose();
}
}
export const serverSnapshot: ServerSnapshot = {
serverId: "server-1",
protocolVersion: PROTOCOL_VERSION,
revision: 1,
sessions: [],
models: [],
};
export function sessionSnapshot(id: string, overrides: Partial<SessionSnapshot> = {}): SessionSnapshot {
return {
id,
cwd: "/workspace",
createdAt: 1,
updatedAt: 1,
phase: "idle",
model: { provider: "faux", id: "model" },
thinkingLevel: "off",
attached: true,
locked: true,
revision: 1,
transcript: [],
queuedSteer: [],
queuedSteerCount: 0,
...overrides,
};
}
export async function connectClient(server: MemoryServer): Promise<PiClient> {
const client = new PiClient({ transportFactory: (handlers) => server.connect(handlers) });
server.onMessage((message) => {
if (message.type !== "hello") return;
server.send({
type: "hello",
version: PROTOCOL_VERSION,
connectionId: "connection-1",
snapshot: serverSnapshot,
});
});
await client.connect();
return client;
}
export function collectRequests(server: MemoryServer): RequestEnvelope[] {
const requests: RequestEnvelope[] = [];
server.onMessage((message) => {
if (message.type === "request") requests.push(message);
});
return requests;
}
export async function openRemoteSession(
client: PiClient,
server: MemoryServer,
snapshot: SessionSnapshot,
options?: RemoteSessionOptions,
): Promise<RemoteSession> {
const requests = collectRequests(server);
const opening = RemoteSession.open(client, snapshot.id, options);
const request = requests.find((candidate) => candidate.request.command === "attach");
if (!request) throw new Error("Missing attach request");
server.send({ type: "response", id: request.id, ok: true, result: { command: "attach", session: snapshot } });
return opening;
}

View file

@ -1,220 +0,0 @@
import type { SessionSnapshot } from "@earendil-works/pi-protocol";
import { describe, expect, test } from "vitest";
import {
applyTranscriptProgress,
applyTranscriptSnapshot,
createTranscriptState,
selectTranscript,
} from "../../src/client/transcript.ts";
function snapshot(revision: number, text = "saved"): SessionSnapshot {
return {
id: "session-1",
cwd: "/workspace",
createdAt: 1,
updatedAt: revision + 1,
phase: "turn",
model: { provider: "faux", id: "faux-1" },
thinkingLevel: "off",
attached: true,
locked: true,
revision,
transcript: [
{
id: "assistant-1",
role: "assistant",
content: [{ type: "text", text }],
status: "streaming",
model: { provider: "faux", id: "faux-1" },
timestamp: 1,
},
],
queuedSteer: [],
queuedSteerCount: 0,
};
}
describe("remote transcript projection", () => {
test("projects progress without mutating the authoritative snapshot", () => {
let state = createTranscriptState(snapshot(1));
state = applyTranscriptProgress(state, {
type: "assistant_delta",
messageId: "assistant-1",
contentIndex: 0,
kind: "text",
delta: " response",
});
expect(state.snapshot.transcript[0]).toMatchObject({ content: [{ type: "text", text: "saved" }] });
expect(selectTranscript(state)[0]).toMatchObject({ content: [{ type: "text", text: "saved response" }] });
});
test("applies streamed tool-call argument deltas", () => {
let state = createTranscriptState({
...snapshot(1),
transcript: [
{
id: "assistant-1",
role: "assistant",
content: [{ type: "toolCall", toolCallId: "call-1", toolName: "bash", input: null }],
status: "streaming",
model: { provider: "faux", id: "faux-1" },
timestamp: 1,
},
],
});
state = applyTranscriptProgress(state, {
type: "assistant_delta",
messageId: "assistant-1",
contentIndex: 0,
kind: "toolCall",
delta: '{"command":',
});
expect(selectTranscript(state)[0]).toMatchObject({ content: [{ input: '{"command":' }] });
state = applyTranscriptProgress(state, {
type: "item_updated",
item: {
id: "assistant-1",
role: "assistant",
content: [{ type: "toolCall", toolCallId: "call-1", toolName: "bash", input: null }],
status: "streaming",
model: { provider: "faux", id: "faux-1" },
timestamp: 1,
},
});
state = applyTranscriptProgress(state, {
type: "assistant_delta",
messageId: "assistant-1",
contentIndex: 0,
kind: "toolCall",
delta: '"pwd"}',
});
expect(selectTranscript(state)[0]).toMatchObject({ content: [{ input: { command: "pwd" } }] });
});
test("appends tool-call deltas to a partial input restored from a snapshot", () => {
let state = createTranscriptState({
...snapshot(1),
transcript: [
{
id: "assistant-1",
role: "assistant",
content: [{ type: "toolCall", toolCallId: "call-1", toolName: "bash", input: '{"command":' }],
status: "streaming",
model: { provider: "faux", id: "faux-1" },
timestamp: 1,
},
],
});
state = applyTranscriptProgress(state, {
type: "assistant_delta",
messageId: "assistant-1",
contentIndex: 0,
kind: "toolCall",
delta: '"pwd"}',
});
expect(selectTranscript(state)[0]).toMatchObject({ content: [{ input: { command: "pwd" } }] });
});
test("appends transient tool progress and replaces it by id", () => {
let state = createTranscriptState(snapshot(1));
state = applyTranscriptProgress(state, {
type: "item_started",
item: {
id: "tool-call-1",
role: "tool",
toolCallId: "call-1",
toolName: "bash",
input: { command: "printf hi" },
content: [],
status: "running",
isError: false,
timestamp: 2,
},
});
expect(selectTranscript(state).at(-1)).toMatchObject({
id: "tool-call-1",
role: "tool",
status: "running",
content: [],
});
state = applyTranscriptProgress(state, {
type: "item_updated",
item: {
id: "tool-call-1",
role: "tool",
toolCallId: "call-1",
toolName: "bash",
input: { command: "printf hi" },
content: [{ type: "text", text: "hi" }],
status: "running",
isError: false,
timestamp: 2,
},
});
expect(selectTranscript(state)).toHaveLength(2);
expect(selectTranscript(state)[1]).toMatchObject({
role: "tool",
status: "running",
content: [{ type: "text", text: "hi" }],
});
});
test("resets revision history when the same session runtime is reacquired", () => {
let state = createTranscriptState(snapshot(50, "old runtime"));
state = createTranscriptState(snapshot(0, "new runtime"));
expect(state.snapshot.revision).toBe(0);
expect(selectTranscript(state)[0]).toMatchObject({ content: [{ text: "new runtime" }] });
});
test("accepts a lower revision when switching to a different session", () => {
let state = createTranscriptState(snapshot(50, "old session"));
state = applyTranscriptSnapshot(state, { ...snapshot(0, "new session"), id: "session-2" });
expect(state.snapshot.id).toBe("session-2");
expect(selectTranscript(state)[0]).toMatchObject({ content: [{ text: "new session" }] });
});
test("renders accepted steering messages from authoritative queued state", () => {
const state = createTranscriptState({
...snapshot(2),
queuedSteerCount: 1,
queuedSteer: [
{
id: "user-steer",
role: "user",
content: [{ type: "text", text: "adjust the approach" }],
timestamp: 2,
},
],
});
expect(selectTranscript(state).at(-1)).toMatchObject({
role: "user",
content: [{ text: "adjust the approach" }],
});
});
test("a newer snapshot is authoritative and stale snapshots are ignored", () => {
let state = createTranscriptState(snapshot(3, "new"));
state = applyTranscriptProgress(state, {
type: "assistant_delta",
messageId: "assistant-1",
contentIndex: 0,
kind: "text",
delta: " transient",
});
state = applyTranscriptSnapshot(state, snapshot(4, "authoritative"));
state = applyTranscriptSnapshot(state, snapshot(2, "stale"));
expect(state.snapshot.revision).toBe(4);
expect(selectTranscript(state)[0]).toMatchObject({
content: [{ type: "text", text: "authoritative" }],
});
});
});

View file

@ -1,19 +1,16 @@
# @earendil-works/pi-protocol
Runtime-neutral schemas, types, CBOR encoding, and byte-stream framing for the experimental pi protocol.
Runtime-neutral schemas, types, CBOR encoding, and byte-stream framing for the experimental Pi protocol.
Protocol version `1` uses binary messages with this wire layout:
Protocol version `1` currently contains the first control-plane slice:
1. A four-byte unsigned big-endian payload length.
2. One definite-length CBOR item containing the message.
- a version handshake that identifies the logical `serviceId`;
- a schema-defined service RPC manifest with `list()` and `attach(sessionId)`;
- correlated responses and bounded protocol errors.
The first client message is always `hello`, containing `PROTOCOL_VERSION`. Subsequent messages use correlated request/response envelopes and server event envelopes. Session and server snapshots are authoritative. Progress events are transient UI hints and must not be reduced into authoritative state. Transports complete authentication before protocol bytes are exchanged.
The manifest generates typed client methods and validated server dispatch. The wire uses generic `{ serviceId, method, args }` calls rather than a hand-written command union. `list()` returns the durable `SessionMetadata` values from `SessionRepo.list()`. `attach()` returns only the attached `sessionId`; the real `Session` and `AgentHarness` remain hosted by the server.
Session lists contain `SessionMetadata`, the normalized durable metadata available without acquiring a session runtime. Only `id` and `createdAt` are required; `updatedAt`, `parentSessionId`, `sessionName`, and `cwd` are included when supported by the backing store. Runtime state such as phase, model, thinking level, attachment, and locking appears only in an acquired `SessionSnapshot`.
## Validated message API
`encodeClientMessage()` and `encodeServerMessage()` validate a message and return a complete framed `Uint8Array`. The incremental decoders accept arbitrary fragmentation or coalescing, so they work with streams, sockets, and custom byte transports.
Each wire frame consists of a four-byte unsigned big-endian payload length followed by one definite-length CBOR item. `encodeClientMessage()` and `encodeServerMessage()` validate and encode complete frames. `ClientMessageDecoder` and `ServerMessageDecoder` accept arbitrary stream fragmentation and coalescing.
```ts
import {
@ -23,47 +20,14 @@ import {
type ClientHello,
} from "@earendil-works/pi-protocol";
const hello: ClientHello = {
type: "hello",
version: PROTOCOL_VERSION,
};
const hello: ClientHello = { type: "hello", version: PROTOCOL_VERSION };
transport.send(encodeClientMessage(hello));
const decoder = createServerMessageDecoder({ maxFrameLength: 1024 * 1024 });
for (const message of decoder.push(incomingChunk)) {
handleServerMessage(message);
}
decoder.end(); // Call when the byte stream closes to detect truncation.
for (const message of decoder.push(incomingChunk)) handleServerMessage(message);
decoder.end();
```
`ClientMessageDecoder` and `ServerMessageDecoder` are also available directly. Schema violations, malformed CBOR, and invalid framing throw `ProtocolValidationError`. Validation errors do not retain rejected payloads.
All schemas reject unknown object properties. Schema violations, malformed CBOR, and invalid framing throw `ProtocolValidationError`. Transports authenticate peers before passing protocol bytes and must preserve byte order.
`parseClientMessage()` and `parseServerMessage()` only validate already-decoded values. They do not parse JSON strings.
## Transport support
Every transport carries the same complete bytes: `[uint32-be CBOR length][CBOR payload]`. Transports may split or coalesce those bytes arbitrarily.
This package does not bundle a transport. Consumers provide a byte-stream transport that preserves byte order and reports stream closure. Custom transports must handle arbitrary frame fragmentation and coalescing.
All transports are untrusted. Configure matching frame limits and enforce access controls appropriate for the transport before exposing a connection to the protocol. Unix sockets can use filesystem permissions, while network transports can authenticate during connection establishment.
## Encoding and framing
`encodeCbor()` and `decodeCbor()` implement the protocol's strict RFC 8949 subset. `encodeFrame()` and `FrameDecoder` handle framing independently of schemas and CBOR.
The CBOR subset supports:
- `null` and booleans
- finite numbers, with integers restricted to JavaScript's safe range and non-integers encoded as float64
- UTF-8 strings
- `Uint8Array` byte strings
- definite-length arrays
- definite-length maps represented by objects with unique string keys
Undefined object properties are omitted. JSON-valued protocol fields reject CBOR byte strings and non-plain objects. Top-level undefined, undefined array entries, sparse arrays, non-finite or unsafe numbers, tags, indefinite-length items, malformed UTF-8, trailing data, excessive nesting, and oversized values are rejected.
Default limits are 16 MiB per CBOR payload/frame, 1,000,000 array elements or map entries, and 64 nested item levels. Options can configure these limits. A frame decoder validates the declared length before buffering payload bytes.
All schemas reject unknown object properties. The protocol is experimental and has no compatibility guarantees.
Default limits are 16 MiB per CBOR payload/frame, 1,000,000 array elements or map entries, and 64 nested item levels. The protocol is experimental and has no compatibility guarantees.

View file

@ -39,6 +39,7 @@
"node": ">=22.19.0"
},
"dependencies": {
"@earendil-works/pi-agent-core": "^0.84.1",
"typebox": "1.3.7"
},
"devDependencies": {

View file

@ -1,4 +1,5 @@
export * from "./cbor/index.ts";
export * from "./codec.ts";
export * from "./framing.ts";
export * from "./rpc.ts";
export * from "./schemas.ts";

View file

@ -0,0 +1,112 @@
import Type, { type Static, type TSchema } from "typebox";
import { Check } from "typebox/value";
export interface RpcMethodSchema<TArgs extends TSchema = TSchema, TResult extends TSchema = TSchema> {
readonly args: TArgs;
readonly result: TResult;
}
export type RpcManifest = Record<string, RpcMethodSchema>;
export function defineRpc<const TManifest extends RpcManifest>(manifest: TManifest): TManifest {
if (Object.keys(manifest).length === 0) throw new TypeError("RPC manifest must define at least one method");
return manifest;
}
export function createRpcCallSchema<TManifest extends RpcManifest>(manifest: TManifest): TSchema {
const variants = Object.entries(manifest).map(([method, definition]) =>
Type.Object(
{
method: Type.Literal(method),
args: definition.args,
},
{ additionalProperties: false },
),
);
return Type.Union(variants);
}
export function createRpcResultSchema<TManifest extends RpcManifest>(manifest: TManifest): TSchema {
return Type.Union(Object.values(manifest).map(({ result }) => result));
}
export type RpcMethodName<TManifest extends RpcManifest> = Extract<keyof TManifest, string>;
export type RpcArgs<TManifest extends RpcManifest, TMethod extends RpcMethodName<TManifest>> = Static<
TManifest[TMethod]["args"]
> &
readonly unknown[];
export type RpcResult<TManifest extends RpcManifest, TMethod extends RpcMethodName<TManifest>> = Static<
TManifest[TMethod]["result"]
>;
export type RpcCall<TManifest extends RpcManifest> = {
[TMethod in RpcMethodName<TManifest>]: {
readonly method: TMethod;
readonly args: RpcArgs<TManifest, TMethod>;
};
}[RpcMethodName<TManifest>];
export type RpcResultUnion<TManifest extends RpcManifest> = {
[TMethod in RpcMethodName<TManifest>]: RpcResult<TManifest, TMethod>;
}[RpcMethodName<TManifest>];
export type RpcClient<TManifest extends RpcManifest> = {
[TMethod in RpcMethodName<TManifest>]: (
...args: RpcArgs<TManifest, TMethod>
) => Promise<RpcResult<TManifest, TMethod>>;
};
export type RpcImplementation<TManifest extends RpcManifest, TContext> = {
[TMethod in RpcMethodName<TManifest>]: (
context: TContext,
...args: RpcArgs<TManifest, TMethod>
) => RpcResult<TManifest, TMethod> | Promise<RpcResult<TManifest, TMethod>>;
};
export type RpcValidationErrorFactory = (message: string) => Error;
function defaultValidationError(message: string): Error {
return new TypeError(message);
}
/**
* Create typed methods backed by one generic RPC call function.
*
* Only methods declared by the manifest are created. Arguments and returned
* values are validated at this boundary.
*/
export function createRpcClient<TManifest extends RpcManifest>(
manifest: TManifest,
call: (call: RpcCall<TManifest>) => Promise<unknown>,
validationError: RpcValidationErrorFactory = defaultValidationError,
): RpcClient<TManifest> {
const client: Record<string, (...args: unknown[]) => Promise<unknown>> = {};
for (const method of Object.keys(manifest)) {
const definition = manifest[method];
if (!definition) continue;
client[method] = async (...args: unknown[]) => {
if (!Check(definition.args, args)) throw validationError(`Invalid arguments for RPC method ${method}`);
const result = await call({ method, args } as RpcCall<TManifest>);
if (!Check(definition.result, result)) throw validationError(`Invalid result for RPC method ${method}`);
return result;
};
}
return client as unknown as RpcClient<TManifest>;
}
/** Bind a typed implementation to a validated RPC manifest. */
export function createRpcDispatcher<TManifest extends RpcManifest, TContext>(
manifest: TManifest,
implementation: RpcImplementation<TManifest, TContext>,
validationError: RpcValidationErrorFactory = defaultValidationError,
): (call: RpcCall<TManifest>, context: TContext) => Promise<RpcResultUnion<TManifest>> {
return async (call, context) => {
const definition = manifest[call.method];
const method = implementation[call.method];
if (!definition || typeof method !== "function") throw validationError(`Unknown RPC method ${call.method}`);
if (!Check(definition.args, call.args)) throw validationError(`Invalid arguments for RPC method ${call.method}`);
const result: unknown = await Reflect.apply(method, implementation, [context, ...call.args]);
if (!Check(definition.result, result)) throw validationError(`Invalid result for RPC method ${call.method}`);
return result as RpcResultUnion<TManifest>;
};
}

View file

@ -1,4 +1,15 @@
import type { SessionMetadata as AgentSessionMetadata } from "@earendil-works/pi-agent-core";
import Type, { type Static } from "typebox";
import {
createRpcCallSchema,
createRpcResultSchema,
defineRpc,
type RpcArgs,
type RpcCall,
type RpcMethodName,
type RpcResult,
type RpcResultUnion,
} from "./rpc.ts";
export const PROTOCOL_VERSION = 1 as const;
@ -23,256 +34,52 @@ const JsonValueRecursiveSchema = Type.Cyclic(
);
export const JsonValueSchema = Type.Unsafe<JsonValue>(JsonValueRecursiveSchema);
export const ThinkingLevelSchema = Type.Union([
Type.Literal("off"),
Type.Literal("minimal"),
Type.Literal("low"),
Type.Literal("medium"),
Type.Literal("high"),
Type.Literal("xhigh"),
Type.Literal("max"),
]);
export type ThinkingLevel = Static<typeof ThinkingLevelSchema>;
export const ServiceIdSchema = IdSchema;
export const SessionIdSchema = IdSchema;
export type ServiceId = Static<typeof ServiceIdSchema>;
export type SessionId = Static<typeof SessionIdSchema>;
/** Matches AgentHarnessPhase so adapters do not need a second phase vocabulary. */
export const SessionPhaseSchema = Type.Union([
Type.Literal("idle"),
Type.Literal("turn"),
Type.Literal("compaction"),
Type.Literal("branch_summary"),
Type.Literal("retry"),
]);
export type SessionPhase = Static<typeof SessionPhaseSchema>;
export const ModelRefSchema = StrictObject({
provider: IdSchema,
id: IdSchema,
});
export type ModelRef = Static<typeof ModelRefSchema>;
export const ModelCostSchema = StrictObject({
input: Type.Number({ minimum: 0 }),
output: Type.Number({ minimum: 0 }),
cacheRead: Type.Number({ minimum: 0 }),
cacheWrite: Type.Number({ minimum: 0 }),
});
export const ModelMetadataSchema = StrictObject({
provider: IdSchema,
id: IdSchema,
name: Type.String({ minLength: 1 }),
api: IdSchema,
reasoning: Type.Boolean(),
input: Type.Array(Type.Union([Type.Literal("text"), Type.Literal("image")])),
contextWindow: Type.Integer({ minimum: 1 }),
maxTokens: Type.Integer({ minimum: 1 }),
cost: ModelCostSchema,
supportedThinkingLevels: Type.Array(ThinkingLevelSchema, { minItems: 1 }),
authenticated: Type.Boolean(),
});
export type ModelMetadata = Static<typeof ModelMetadataSchema>;
export const TextContentSchema = StrictObject({
type: Type.Literal("text"),
text: Type.String(),
});
export const ThinkingContentSchema = StrictObject({
type: Type.Literal("thinking"),
thinking: Type.String(),
redacted: Type.Optional(Type.Boolean()),
});
export const ImageContentSchema = StrictObject({
type: Type.Literal("image"),
data: Type.String(),
mimeType: Type.String({ minLength: 1 }),
});
export const ToolCallContentSchema = StrictObject({
type: Type.Literal("toolCall"),
toolCallId: IdSchema,
toolName: IdSchema,
input: JsonValueSchema,
});
export const UserContentSchema = Type.Union([TextContentSchema, ImageContentSchema]);
export const AssistantContentSchema = Type.Union([TextContentSchema, ThinkingContentSchema, ToolCallContentSchema]);
export const ToolContentSchema = Type.Union([TextContentSchema, ImageContentSchema]);
export type TextContent = Static<typeof TextContentSchema>;
export type ThinkingContent = Static<typeof ThinkingContentSchema>;
export type ImageContent = Static<typeof ImageContentSchema>;
export type ToolCallContent = Static<typeof ToolCallContentSchema>;
export const UsageSchema = StrictObject({
input: Type.Integer({ minimum: 0 }),
output: Type.Integer({ minimum: 0 }),
cacheRead: Type.Integer({ minimum: 0 }),
cacheWrite: Type.Integer({ minimum: 0 }),
reasoning: Type.Optional(Type.Integer({ minimum: 0 })),
totalTokens: Type.Integer({ minimum: 0 }),
cost: StrictObject({
input: Type.Number({ minimum: 0 }),
output: Type.Number({ minimum: 0 }),
cacheRead: Type.Number({ minimum: 0 }),
cacheWrite: Type.Number({ minimum: 0 }),
total: Type.Number({ minimum: 0 }),
}),
});
export type Usage = Static<typeof UsageSchema>;
export const UserTranscriptItemSchema = StrictObject({
id: IdSchema,
role: Type.Literal("user"),
content: Type.Array(UserContentSchema),
timestamp: TimestampSchema,
});
const AssistantTranscriptItemProperties = {
id: IdSchema,
role: Type.Literal("assistant"),
content: Type.Array(AssistantContentSchema),
model: ModelRefSchema,
responseModel: Type.Optional(Type.String({ minLength: 1 })),
usage: Type.Optional(UsageSchema),
timestamp: TimestampSchema,
} as const;
const StreamingAssistantTranscriptItemSchema = StrictObject({
...AssistantTranscriptItemProperties,
status: Type.Literal("streaming"),
});
const CompleteAssistantTranscriptItemSchema = StrictObject({
...AssistantTranscriptItemProperties,
status: Type.Literal("complete"),
stopReason: Type.Union([Type.Literal("stop"), Type.Literal("length"), Type.Literal("toolUse")]),
});
const ErrorAssistantTranscriptItemSchema = StrictObject({
...AssistantTranscriptItemProperties,
status: Type.Literal("error"),
stopReason: Type.Literal("error"),
errorMessage: Type.Optional(Type.String({ minLength: 1 })),
});
const AbortedAssistantTranscriptItemSchema = StrictObject({
...AssistantTranscriptItemProperties,
status: Type.Literal("aborted"),
stopReason: Type.Literal("aborted"),
errorMessage: Type.Optional(Type.String()),
});
export const AssistantTranscriptItemSchema = Type.Union([
StreamingAssistantTranscriptItemSchema,
CompleteAssistantTranscriptItemSchema,
ErrorAssistantTranscriptItemSchema,
AbortedAssistantTranscriptItemSchema,
]);
const ToolTranscriptItemProperties = {
id: IdSchema,
role: Type.Literal("tool"),
toolCallId: IdSchema,
toolName: IdSchema,
input: JsonValueSchema,
content: Type.Array(ToolContentSchema),
details: Type.Optional(JsonValueSchema),
usage: Type.Optional(UsageSchema),
timestamp: TimestampSchema,
} as const;
const RunningToolTranscriptItemSchema = StrictObject({
...ToolTranscriptItemProperties,
status: Type.Literal("running"),
isError: Type.Literal(false),
});
const CompleteToolTranscriptItemSchema = StrictObject({
...ToolTranscriptItemProperties,
status: Type.Literal("complete"),
isError: Type.Literal(false),
});
const ErrorToolTranscriptItemSchema = StrictObject({
...ToolTranscriptItemProperties,
status: Type.Literal("error"),
isError: Type.Literal(true),
});
export const ToolTranscriptItemSchema = Type.Union([
RunningToolTranscriptItemSchema,
CompleteToolTranscriptItemSchema,
ErrorToolTranscriptItemSchema,
]);
export const TranscriptItemSchema = Type.Union([
UserTranscriptItemSchema,
AssistantTranscriptItemSchema,
ToolTranscriptItemSchema,
]);
export type UserTranscriptItem = Static<typeof UserTranscriptItemSchema>;
export type AssistantTranscriptItem = Static<typeof AssistantTranscriptItemSchema>;
export type ToolTranscriptItem = Static<typeof ToolTranscriptItemSchema>;
export type TranscriptItem = Static<typeof TranscriptItemSchema>;
/** Normalized incremental activity. Snapshots remain authoritative. */
export const TranscriptProgressSchema = Type.Union([
/** The durable metadata returned directly by SessionRepo.list(). */
export type SessionMetadata = AgentSessionMetadata;
export const SessionMetadataSchema = Type.Unsafe<SessionMetadata>(
StrictObject({
type: Type.Literal("item_started"),
item: TranscriptItemSchema,
id: SessionIdSchema,
createdAt: TimestampSchema,
storageVersion: Type.Integer({ minimum: 1 }),
cwd: Type.Optional(Type.String({ minLength: 1 })),
parentSessionId: Type.Optional(SessionIdSchema),
legacyParentSessionPath: Type.Optional(Type.String({ minLength: 1 })),
}),
StrictObject({
type: Type.Literal("assistant_delta"),
messageId: IdSchema,
contentIndex: Type.Integer({ minimum: 0 }),
kind: Type.Union([Type.Literal("text"), Type.Literal("thinking"), Type.Literal("toolCall")]),
delta: Type.String(),
}),
StrictObject({
type: Type.Literal("item_updated"),
item: Type.Union([AssistantTranscriptItemSchema, ToolTranscriptItemSchema]),
}),
StrictObject({
type: Type.Literal("item_finished"),
item: Type.Union([
CompleteAssistantTranscriptItemSchema,
ErrorAssistantTranscriptItemSchema,
AbortedAssistantTranscriptItemSchema,
CompleteToolTranscriptItemSchema,
ErrorToolTranscriptItemSchema,
]),
}),
]);
export type TranscriptProgress = Static<typeof TranscriptProgressSchema>;
);
export const SessionMetadataSchema = StrictObject({
id: IdSchema,
createdAt: TimestampSchema,
updatedAt: Type.Optional(TimestampSchema),
parentSessionId: Type.Optional(IdSchema),
sessionName: Type.Optional(Type.String()),
cwd: Type.Optional(Type.String({ minLength: 1 })),
/** Remote methods available on a Pi service in protocol v1. */
export const ServiceRpc = defineRpc({
list: {
args: Type.Tuple([]),
result: Type.Array(SessionMetadataSchema),
},
attach: {
args: Type.Tuple([SessionIdSchema]),
result: StrictObject({ sessionId: SessionIdSchema }),
},
});
export const SessionSnapshotSchema = StrictObject({
id: IdSchema,
name: Type.Optional(Type.String()),
cwd: Type.String({ minLength: 1 }),
createdAt: TimestampSchema,
updatedAt: TimestampSchema,
phase: SessionPhaseSchema,
model: ModelRefSchema,
thinkingLevel: ThinkingLevelSchema,
attached: Type.Boolean(),
locked: Type.Boolean(),
revision: Type.Integer({ minimum: 0 }),
transcript: Type.Array(TranscriptItemSchema),
queuedSteer: Type.Array(UserTranscriptItemSchema),
queuedSteerCount: Type.Integer({ minimum: 0 }),
});
export type SessionMetadata = Static<typeof SessionMetadataSchema>;
export type SessionSnapshot = Static<typeof SessionSnapshotSchema>;
export const ServerSnapshotSchema = StrictObject({
serverId: IdSchema,
protocolVersion: Type.Literal(PROTOCOL_VERSION),
revision: Type.Integer({ minimum: 0 }),
sessions: Type.Array(SessionMetadataSchema),
models: Type.Array(ModelMetadataSchema),
});
export type ServerSnapshot = Static<typeof ServerSnapshotSchema>;
export type ServiceRpcManifest = typeof ServiceRpc;
export type ServiceRpcMethod = RpcMethodName<ServiceRpcManifest>;
export type ServiceRpcArgs<TMethod extends ServiceRpcMethod> = RpcArgs<ServiceRpcManifest, TMethod>;
export type ServiceRpcResult<TMethod extends ServiceRpcMethod> = RpcResult<ServiceRpcManifest, TMethod>;
export type ServiceRpcCall = RpcCall<ServiceRpcManifest>;
export type ServiceRpcResultUnion = RpcResultUnion<ServiceRpcManifest>;
export const ServiceRpcCallSchema = Type.Unsafe<ServiceRpcCall>(createRpcCallSchema(ServiceRpc));
export const ServiceRpcResultSchema = Type.Unsafe<ServiceRpcResultUnion>(createRpcResultSchema(ServiceRpc));
export const ProtocolErrorCodeSchema = Type.Union([
Type.Literal("version"),
Type.Literal("busy"),
Type.Literal("wrong_service"),
Type.Literal("session_not_found"),
Type.Literal("session_locked"),
Type.Literal("not_found"),
Type.Literal("server_busy"),
Type.Literal("server_restarting"),
Type.Literal("invalid_request"),
Type.Literal("not_implemented"),
Type.Literal("internal_error"),
]);
export const ProtocolErrorSchema = StrictObject({
@ -283,105 +90,7 @@ export const ProtocolErrorSchema = StrictObject({
export type ProtocolErrorCode = Static<typeof ProtocolErrorCodeSchema>;
export type ProtocolError = Static<typeof ProtocolErrorSchema>;
const PromptPayloadProperties = {
sessionId: IdSchema,
text: Type.String(),
} as const;
export const ListCommandSchema = StrictObject({ command: Type.Literal("list") });
export const CreateCommandSchema = StrictObject({
command: Type.Literal("create"),
cwd: Type.Optional(Type.String({ minLength: 1 })),
name: Type.Optional(Type.String()),
model: Type.Optional(ModelRefSchema),
thinkingLevel: Type.Optional(ThinkingLevelSchema),
});
export const AttachCommandSchema = StrictObject({ command: Type.Literal("attach"), sessionId: IdSchema });
export const DetachCommandSchema = StrictObject({ command: Type.Literal("detach"), sessionId: IdSchema });
export const PromptCommandSchema = StrictObject({ command: Type.Literal("prompt"), ...PromptPayloadProperties });
export const SteerCommandSchema = StrictObject({ command: Type.Literal("steer"), ...PromptPayloadProperties });
export const AbortCommandSchema = StrictObject({ command: Type.Literal("abort"), sessionId: IdSchema });
export const SetModelCommandSchema = StrictObject({
command: Type.Literal("set_model"),
sessionId: IdSchema,
model: ModelRefSchema,
});
export const SetThinkingCommandSchema = StrictObject({
command: Type.Literal("set_thinking"),
sessionId: IdSchema,
thinkingLevel: ThinkingLevelSchema,
});
export const CommandSchema = Type.Union([
ListCommandSchema,
CreateCommandSchema,
AttachCommandSchema,
DetachCommandSchema,
PromptCommandSchema,
SteerCommandSchema,
AbortCommandSchema,
SetModelCommandSchema,
SetThinkingCommandSchema,
]);
export type Command = Static<typeof CommandSchema>;
export type CommandName = Command["command"];
export const CreateResultSchema = StrictObject({
command: Type.Literal("create"),
session: SessionSnapshotSchema,
});
export const AttachResultSchema = StrictObject({
command: Type.Literal("attach"),
session: SessionSnapshotSchema,
});
export const PromptResultSchema = StrictObject({
command: Type.Literal("prompt"),
session: SessionSnapshotSchema,
});
export const SteerResultSchema = StrictObject({
command: Type.Literal("steer"),
session: SessionSnapshotSchema,
});
export const AbortResultSchema = StrictObject({
command: Type.Literal("abort"),
session: SessionSnapshotSchema,
});
export const SetModelResultSchema = StrictObject({
command: Type.Literal("set_model"),
session: SessionSnapshotSchema,
});
export const SetThinkingResultSchema = StrictObject({
command: Type.Literal("set_thinking"),
session: SessionSnapshotSchema,
});
export const ListResultSchema = StrictObject({
command: Type.Literal("list"),
sessions: Type.Array(SessionMetadataSchema),
});
export const DetachResultSchema = StrictObject({
command: Type.Literal("detach"),
sessionId: IdSchema,
});
export const CommandResultSchema = Type.Union([
ListResultSchema,
CreateResultSchema,
AttachResultSchema,
DetachResultSchema,
PromptResultSchema,
SteerResultSchema,
AbortResultSchema,
SetModelResultSchema,
SetThinkingResultSchema,
]);
export type CommandResult = Static<typeof CommandResultSchema>;
export type ResultForCommand<TCommand extends Command> = TCommand["command"] extends "list"
? Static<typeof ListResultSchema>
: TCommand["command"] extends "detach"
? Static<typeof DetachResultSchema>
: Extract<CommandResult, { command: TCommand["command"] }>;
/** Must be the first frame sent by a client. Version is intentionally an integer, not a coercible string. */
/** Must be the first frame sent by a client. */
export const ClientHelloSchema = StrictObject({
type: Type.Literal("hello"),
version: Type.Integer({ minimum: 0 }),
@ -391,29 +100,18 @@ export type ClientHello = Static<typeof ClientHelloSchema>;
export const RequestEnvelopeSchema = StrictObject({
type: Type.Literal("request"),
id: IdSchema,
request: CommandSchema,
serviceId: ServiceIdSchema,
call: ServiceRpcCallSchema,
});
export type RequestEnvelope = Static<typeof RequestEnvelopeSchema>;
export const ClientMessageSchema = Type.Union([ClientHelloSchema, RequestEnvelopeSchema]);
export type ClientMessage = Static<typeof ClientMessageSchema>;
export const ServerEventSchema = Type.Union([
StrictObject({ type: Type.Literal("server_snapshot"), snapshot: ServerSnapshotSchema }),
StrictObject({ type: Type.Literal("session_snapshot"), snapshot: SessionSnapshotSchema }),
StrictObject({
type: Type.Literal("session_progress"),
sessionId: IdSchema,
progress: TranscriptProgressSchema,
}),
StrictObject({ type: Type.Literal("session_removed"), sessionId: IdSchema }),
]);
export type ServerEvent = Static<typeof ServerEventSchema>;
export const ServerHelloSchema = StrictObject({
type: Type.Literal("hello"),
version: Type.Literal(PROTOCOL_VERSION),
connectionId: IdSchema,
snapshot: ServerSnapshotSchema,
serviceId: ServiceIdSchema,
});
export const ServerHelloErrorSchema = StrictObject({
type: Type.Literal("hello_error"),
@ -424,7 +122,7 @@ export const ResponseEnvelopeSchema = Type.Union([
type: Type.Literal("response"),
id: IdSchema,
ok: Type.Literal(true),
result: CommandResultSchema,
result: ServiceRpcResultSchema,
}),
StrictObject({
type: Type.Literal("response"),
@ -433,18 +131,8 @@ export const ResponseEnvelopeSchema = Type.Union([
error: ProtocolErrorSchema,
}),
]);
export const EventEnvelopeSchema = StrictObject({
type: Type.Literal("event"),
event: ServerEventSchema,
});
export const ServerMessageSchema = Type.Union([
ServerHelloSchema,
ServerHelloErrorSchema,
ResponseEnvelopeSchema,
EventEnvelopeSchema,
]);
export const ServerMessageSchema = Type.Union([ServerHelloSchema, ServerHelloErrorSchema, ResponseEnvelopeSchema]);
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 ServerMessage = Static<typeof ServerMessageSchema>;

View file

@ -3,6 +3,8 @@ import {
type ClientHello,
type ClientMessage,
ClientMessageDecoder,
createRpcClient,
createRpcDispatcher,
decodeCbor,
encodeCbor,
encodeClientMessage,
@ -17,42 +19,56 @@ import {
type ServerHello,
type ServerMessage,
ServerMessageDecoder,
type ServerSnapshot,
ServiceRpc,
} from "../src/index.ts";
const emptyServerSnapshot: ServerSnapshot = {
serverId: "server-1",
protocolVersion: PROTOCOL_VERSION,
revision: 0,
sessions: [],
models: [],
};
const clientHello: ClientHello = {
type: "hello",
version: PROTOCOL_VERSION,
};
const clientHello: ClientHello = { type: "hello", version: PROTOCOL_VERSION };
const serverHello: ServerHello = {
type: "hello",
version: PROTOCOL_VERSION,
connectionId: "connection-1",
snapshot: emptyServerSnapshot,
serviceId: "service-1",
};
function itemMessage(item: unknown, type: "item_updated" | "item_finished" = "item_finished") {
return {
type: "event",
event: {
type: "session_progress",
const metadata = {
id: "session-1",
createdAt: 1,
storageVersion: 1,
cwd: "/workspace",
parentSessionId: "parent-1",
} as const;
describe("RPC manifest", () => {
test("creates typed client methods from the manifest", async () => {
const calls: unknown[] = [];
const client = createRpcClient(ServiceRpc, async (call) => {
calls.push(call);
return call.method === "list" ? [metadata] : { sessionId: call.args[0] };
});
await expect(client.list()).resolves.toEqual([metadata]);
await expect(client.attach("session-1")).resolves.toEqual({ sessionId: "session-1" });
expect(calls).toEqual([
{ method: "list", args: [] },
{ method: "attach", args: ["session-1"] },
]);
});
test("dispatches only methods and values allowed by the manifest", async () => {
const dispatch = createRpcDispatcher(ServiceRpc, {
list: () => [metadata],
attach: (_context, sessionId) => ({ sessionId }),
});
await expect(dispatch({ method: "list", args: [] }, undefined)).resolves.toEqual([metadata]);
await expect(dispatch({ method: "attach", args: ["session-1"] }, undefined)).resolves.toEqual({
sessionId: "session-1",
progress: { type, item },
},
};
}
});
await expect(dispatch({ method: "attach", args: [] } as never, undefined)).rejects.toThrow(/Invalid arguments/);
});
});
describe("protocol validation", () => {
test("uses protocol version 1", () => {
test("negotiates protocol version 1", () => {
expect(PROTOCOL_VERSION).toBe(1);
expect(isSupportedProtocolVersion(1)).toBe(true);
expect(isSupportedProtocolVersion(2)).toBe(false);
@ -61,78 +77,71 @@ describe("protocol validation", () => {
test.each([0, PROTOCOL_VERSION, PROTOCOL_VERSION + 1])(
"accepts integer client hello version %s for negotiation",
(version) => {
const message = { ...clientHello, version };
expect(parseClientMessage(message)).toEqual(message);
},
(version) => expect(parseClientMessage({ ...clientHello, version })).toEqual({ ...clientHello, version }),
);
test.each([
["string version", { type: "hello", version: String(PROTOCOL_VERSION) }],
["fractional version", { type: "hello", version: PROTOCOL_VERSION + 0.5 }],
["credential field", { type: "hello", version: PROTOCOL_VERSION, token: "secret" }],
["unknown field", { type: "hello", version: PROTOCOL_VERSION, extra: true }],
] as const)("rejects a handshake with %s", (_label, message) => {
{ type: "hello", version: String(PROTOCOL_VERSION) },
{ type: "hello", version: PROTOCOL_VERSION + 0.5 },
{ type: "hello", version: PROTOCOL_VERSION, extra: true },
])("rejects an invalid client hello", (message) => {
expect(() => parseClientMessage(message)).toThrow(ProtocolValidationError);
});
test("does not parse JSON strings as wire messages", () => {
expect(() => parseClientMessage(JSON.stringify(clientHello))).toThrow(ProtocolValidationError);
expect(() => parseServerMessage(JSON.stringify(serverHello))).toThrow(ProtocolValidationError);
test("validates list and attach RPC calls with logical targets", () => {
const list: ClientMessage = {
type: "request",
id: "request-1",
serviceId: "service-1",
call: { method: "list", args: [] },
};
const attach: ClientMessage = {
type: "request",
id: "request-2",
serviceId: "service-1",
call: { method: "attach", args: ["session-1"] },
};
expect(parseClientMessage(list)).toEqual(list);
expect(parseClientMessage(attach)).toEqual(attach);
expect(() => parseClientMessage({ ...attach, call: { method: "attach", args: [] } })).toThrow(
ProtocolValidationError,
);
expect(() => parseClientMessage({ ...attach, call: { method: "unknown", args: [] } })).toThrow(
ProtocolValidationError,
);
});
test("rejects image input while the MVP remains text-only", () => {
expect(() =>
parseClientMessage({
type: "request",
id: "request-1",
request: {
command: "prompt",
sessionId: "session-1",
text: "inspect",
images: [{ type: "image", data: "abc", mimeType: "image/png" }],
},
}),
).toThrow(ProtocolValidationError);
});
test("parses a server handshake snapshot", () => {
expect(parseServerMessage(serverHello)).toEqual(serverHello);
});
test("represents listed sessions as durable metadata", () => {
const message = {
test("validates SessionRepo metadata without a presentation projection", () => {
const message: ServerMessage = {
type: "response",
id: "request-1",
ok: true,
result: {
command: "list",
sessions: [
{
id: "session-1",
createdAt: 1,
updatedAt: 2,
parentSessionId: "parent-1",
sessionName: "Named session",
cwd: "/workspace",
},
],
},
} as const;
result: [metadata],
};
expect(parseServerMessage(message)).toEqual(message);
expect(() =>
parseServerMessage({
...message,
result: {
...message.result,
sessions: [{ id: "session-1", createdAt: 1, phase: "idle" }],
},
}),
).toThrow(ProtocolValidationError);
expect(() => parseServerMessage({ ...message, result: [{ id: "session-1", createdAt: 1 }] })).toThrow(
ProtocolValidationError,
);
});
test.each(["not_implemented", "internal_error"] as const)("accepts the %s error code", (code) => {
test("validates attach results", () => {
const message: ServerMessage = {
type: "response",
id: "request-1",
ok: true,
result: { sessionId: "session-1" },
};
expect(parseServerMessage(message)).toEqual(message);
});
test.each([
"wrong_service",
"session_not_found",
"session_locked",
"server_busy",
"server_restarting",
"internal_error",
] as const)("accepts the %s error code", (code) => {
const message: ServerMessage = {
type: "response",
id: "request-1",
@ -142,226 +151,54 @@ describe("protocol validation", () => {
expect(parseServerMessage(message)).toEqual(message);
});
test.each([
{
type: "hello",
version: PROTOCOL_VERSION + 1,
connectionId: "connection-1",
snapshot: emptyServerSnapshot,
},
{ type: "hello_error", error: { code: "auth", message: "Authentication failed" } },
{ type: "response", id: "request-1", ok: true, result: { command: "unknown" } },
{ type: "event", event: { type: "session_removed", sessionId: 42 } },
])("rejects invalid server messages", (wire) => {
expect(() => parseServerMessage(wire)).toThrow(ProtocolValidationError);
test("rejects unknown messages and fields", () => {
expect(() => parseServerMessage({ ...serverHello, snapshot: {} })).toThrow(ProtocolValidationError);
expect(() => parseServerMessage({ type: "event", event: {} })).toThrow(ProtocolValidationError);
});
test("validates nested JSON tool details", () => {
const message = {
type: "event",
event: {
type: "session_progress",
sessionId: "session-1",
progress: {
type: "item_finished",
item: {
id: "tool-1",
role: "tool",
toolCallId: "call-1",
toolName: "read",
input: { path: "/tmp/file" },
content: [{ type: "text", text: "done" }],
details: { lines: [1, 2, 3], cached: false },
status: "complete",
isError: false,
timestamp: 1,
},
},
},
};
expect(parseServerMessage(message)).toEqual(message);
test("does not parse JSON strings as messages", () => {
expect(() => parseClientMessage(JSON.stringify(clientHello))).toThrow(ProtocolValidationError);
expect(() => parseServerMessage(JSON.stringify(serverHello))).toThrow(ProtocolValidationError);
});
test.each([
{ status: "streaming" },
{ status: "complete", stopReason: "stop" },
{ status: "error", stopReason: "error" },
{ status: "error", stopReason: "error", errorMessage: "failed" },
{ status: "aborted", stopReason: "aborted" },
])("accepts a consistent $status assistant item", (state) => {
const message = itemMessage(
{
id: "assistant-1",
role: "assistant",
content: [{ type: "text", text: "hello" }],
model: { provider: "test", id: "model" },
timestamp: 1,
...state,
},
state.status === "streaming" ? "item_updated" : "item_finished",
);
expect(parseServerMessage(message)).toEqual(message);
});
test.each([
{ status: "streaming", stopReason: "stop" },
{ status: "complete" },
{ status: "complete", stopReason: "error" },
{ status: "error", stopReason: "error", errorMessage: "" },
{ status: "aborted", stopReason: "stop" },
])("rejects an inconsistent $status assistant item", (state) => {
expect(() =>
parseServerMessage(
itemMessage({
id: "assistant-1",
role: "assistant",
content: [{ type: "text", text: "hello" }],
model: { provider: "test", id: "model" },
timestamp: 1,
...state,
}),
),
).toThrow(ProtocolValidationError);
});
test.each([
{ status: "running", isError: false },
{ status: "complete", isError: false },
{ status: "error", isError: true },
])("accepts a consistent $status tool item", (state) => {
const message = itemMessage(
{
id: "tool-1",
role: "tool",
toolCallId: "call-1",
toolName: "read",
input: {},
content: [],
timestamp: 1,
...state,
},
state.status === "running" ? "item_updated" : "item_finished",
);
expect(parseServerMessage(message)).toEqual(message);
});
test("rejects nonterminal items reported as finished", () => {
const assistant = {
id: "assistant-1",
role: "assistant",
content: [],
model: { provider: "test", id: "model" },
status: "streaming",
timestamp: 1,
};
const tool = {
id: "tool-1",
role: "tool",
toolCallId: "call-1",
toolName: "read",
input: {},
content: [],
status: "running",
isError: false,
timestamp: 1,
};
expect(() => parseServerMessage(itemMessage(assistant))).toThrow(ProtocolValidationError);
expect(() => parseServerMessage(itemMessage(tool))).toThrow(ProtocolValidationError);
});
test.each([
{ status: "running", isError: true },
{ status: "complete", isError: true },
{ status: "error", isError: false },
])("rejects an inconsistent $status tool item", (state) => {
expect(() =>
parseServerMessage(
itemMessage({
id: "tool-1",
role: "tool",
toolCallId: "call-1",
toolName: "read",
input: {},
content: [],
timestamp: 1,
...state,
}),
),
).toThrow(ProtocolValidationError);
});
test("rejects cyclic protocol values with a protocol validation error", () => {
test("rejects cyclic JSON error details without retaining the payload", () => {
const details: Record<string, unknown> = {};
details.self = details;
const message = {
type: "response",
id: "request-1",
ok: false,
error: { code: "invalid_request", message: "invalid", details },
};
expect(() => parseServerMessage(message)).toThrow(ProtocolValidationError);
});
test("validation errors do not retain rejected payloads", () => {
let thrown: unknown;
try {
parseClientMessage({
type: "hello",
version: String(PROTOCOL_VERSION),
extra: "x".repeat(2_000_000),
parseServerMessage({
type: "response",
id: "request-1",
ok: false,
error: { code: "invalid_request", message: "invalid", details },
});
} catch (error) {
thrown = error;
}
expect(thrown).toBeInstanceOf(ProtocolValidationError);
expect(Object.hasOwn(thrown as object, "value")).toBe(false);
expect((thrown as Error).message.length).toBeLessThan(1_000);
});
});
describe("validated framed protocol APIs", () => {
test("encodes complete client and server frames", () => {
const clientFrames = new FrameDecoder().push(encodeClientMessage(clientHello));
expect(clientFrames).toHaveLength(1);
expect(parseClientMessage(decodeCbor(clientFrames[0]!))).toEqual(clientHello);
const serverFrames = new FrameDecoder().push(encodeServerMessage(serverHello));
expect(serverFrames).toHaveLength(1);
expect(parseServerMessage(decodeCbor(serverFrames[0]!))).toEqual(serverHello);
});
test("enforces an outbound frame limit before returning encoded bytes", () => {
test("enforces outbound frame limits", () => {
expect(() => encodeClientMessage(clientHello, { maxFrameLength: 8 })).toThrow(ProtocolValidationError);
expect(() => encodeServerMessage(serverHello, { maxFrameLength: 8 })).toThrow(ProtocolValidationError);
});
test("validates messages before encoding", () => {
expect(() => encodeClientMessage({ type: "hello", version: PROTOCOL_VERSION + 0.5 })).toThrow(
ProtocolValidationError,
);
});
test("omits explicit undefined optional properties on the wire", () => {
const message: ClientMessage = {
type: "request",
id: "request-1",
request: { command: "create", cwd: undefined, name: undefined },
};
const [payload] = new FrameDecoder().push(encodeClientMessage(message));
expect(decodeCbor(payload!)).toEqual({
type: "request",
id: "request-1",
request: { command: "create" },
});
});
test("incrementally decodes fragmented and coalesced client messages", () => {
const request: ClientMessage = {
type: "request",
id: "request-1",
request: { command: "list" },
serviceId: "service-1",
call: { method: "list", args: [] },
};
const first = encodeClientMessage(clientHello);
const second = encodeClientMessage(request);
@ -377,47 +214,20 @@ describe("validated framed protocol APIs", () => {
}
});
test("incrementally decodes server messages", () => {
const errorMessage: ServerMessage = {
type: "hello_error",
error: { code: "version", message: "Unsupported protocol version" },
};
const decoder = new ServerMessageDecoder();
expect(decoder.push(encodeServerMessage(errorMessage))).toEqual([errorMessage]);
decoder.end();
});
test.each([
["empty CBOR payload", encodeFrame(new Uint8Array())],
["malformed CBOR", encodeFrame(new Uint8Array([0xff]))],
["schema-invalid CBOR", encodeFrame(encodeCbor({ type: "hello", version: PROTOCOL_VERSION, extra: true }))],
] as const)("rejects invalid framed client input: %s", (_label, wire) => {
["schema-invalid CBOR", encodeFrame(encodeCbor({ type: "hello", version: 1, extra: true }))],
] as const)("rejects invalid framed input: %s", (_label, wire) => {
const decoder = new ClientMessageDecoder();
expect(() => decoder.push(wire)).toThrow(ProtocolValidationError);
expect(() => decoder.push(encodeClientMessage(clientHello))).toThrow(/failed/i);
});
test("rejects CBOR byte strings nested in JSON-valued fields", () => {
const wire = encodeFrame(
encodeCbor({
type: "response",
id: "request-1",
ok: false,
error: {
code: "invalid_request",
message: "invalid",
details: { nested: new Uint8Array([1, 2, 3]) },
},
}),
);
expect(() => new ServerMessageDecoder().push(wire)).toThrow(ProtocolValidationError);
});
test("rejects truncated and oversized framing through the validated decoder", () => {
test("rejects truncated and oversized framing", () => {
const truncated = new ServerMessageDecoder();
expect(truncated.push(new Uint8Array([0, 0, 0, 2, 1]))).toEqual([]);
expect(() => truncated.end()).toThrow(ProtocolValidationError);
const oversized = new ClientMessageDecoder({ maxFrameLength: 3 });
expect(() => oversized.push(new Uint8Array([0, 0, 0, 4]))).toThrow(ProtocolValidationError);
});

View file

@ -4,7 +4,13 @@
"noEmit": true,
"module": "NodeNext",
"moduleResolution": "NodeNext",
"types": ["node", "vitest"]
"types": ["node", "vitest"],
"paths": {
"@earendil-works/pi-agent-core": ["../agent/src/index.ts"],
"@earendil-works/pi-ai": ["../ai/src/index.ts"],
"@earendil-works/pi-ai/*": ["../ai/src/*.ts", "../ai/src/providers/*.ts"],
"@earendil-works/pi-telemetry": ["../telemetry/src/index.ts"]
}
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}

View file

@ -1,50 +1,34 @@
# @earendil-works/pi-server
Experimental. This package is under active development and may change or be removed without notice. Its APIs and behavior are not yet stable.
Experimental local server for the new durable Session and Agent Harness interfaces.
Server package for pi.
The current slice supports two control-plane operations:
## Session server core
- `list` calls `SessionRepo.list()` without opening sessions.
- `attach` finds the requested metadata, calls `SessionRepo.open()`, creates an `AgentHarness`, and retains that Harness in the server.
The package exports the `PiServer` session server.
Concurrent attachments to one session reuse one hosted Harness. Losing a client connection removes its attachment but does not close the Harness. Server shutdown closes every hosted Harness, releasing its Session writer ownership.
```ts
import { MemorySessionRepo } from "@earendil-works/pi-agent-core";
import type { PiServerService } from "@earendil-works/pi-server";
import { createUnixServer } from "@earendil-works/pi-server/unix";
const sessions = new MemorySessionRepo();
const service: PiServerService = {
async listSessions() {
return storage.listSessions();
},
async listModels() {
return modelRegistry.listModels();
},
async createSession(options) {
return storage.createAndOpen(options);
},
async openSession(sessionId) {
return storage.open(sessionId);
sessions,
async createHarness(session) {
return createApplicationHarness({ session });
},
};
const server = createUnixServer(service, {
path: "/tmp/pi/server.sock",
serviceId: loadStableServiceId(),
});
await server.start();
```
`PiServer` composes transport listeners through the `PiServerListener` interface. Each listener must complete any transport-specific authentication and authorization before passing a connection to `PiServer`. For example, a WebSocket listener can validate credentials during the HTTP upgrade, while the Unix listener relies on socket filesystem permissions. The Unix submodule exports the `createUnixListener()` building block and `createUnixServer()` preset, keeping the common case concise without coupling the primary server to Unix sockets. The listener uses length-prefixed CBOR messages from `@earendil-works/pi-protocol`.
Applications supply the repository and Harness factory. `serviceId` is a logical identity supplied by the installation or server profile, not a socket address.
This package does not provide a standalone CLI or coding-agent service. Applications supply the `PiServerService` implementation.
`PiServerService.listSessions()` returns protocol `SessionMetadata`, not acquired runtime state. Services should map the durable fields their storage supports and may omit `updatedAt`, `parentSessionId`, `sessionName`, and `cwd`. `PiServer` refreshes available metadata from live snapshots without requiring stored sessions to fabricate phase, model, thinking-level, attachment, or lock values.
## Transport testing
Custom transports can use `@earendil-works/pi-server/testing` for deterministic protocol conformance tests. It exports `createTestServer()`, `TestServerService`, `ProtocolTestClient`, and the transport-neutral `WireChannel` contract. `connectUnixTestClient()` is provided for Unix transport tests.
## `pi-ai` protocol bridge
`@earendil-works/pi-ai` domain objects and `@earendil-works/pi-protocol` wire DTOs remain independent. This package owns their boundary and exports `toProtocolModelMetadata()`, `toProtocolAssistantMessage()`, `toProtocolUserMessage()`, and `toProtocolToolResultMessage()`.
The adapters reject invalid tool inputs, identifiers, timestamps, and mismatched tool results; `toProtocolToolResultMessage()` requires the original `ToolCall` so it can verify the association and convert its arguments itself. Diagnostic details are explicitly sanitized. Closed `pi-ai` unions are mapped exhaustively, and compile-time field manifests enumerate current `pi-ai` properties so additions require an explicit review. The protocol mirrors `pi-ai` vocabulary such as `toolCall` and `toolUse` where the semantics are identical. Protocol schemas enforce consistent lifecycle states, and tests encode adapter output through the runtime schemas so incompatible changes fail in the bridging package.
`PiServer` composes authenticated transports through `PiServerListener`. The Unix submodule provides `createUnixListener()` and `createUnixServer()`. Low-level CBOR framing and validation come from `@earendil-works/pi-protocol`.

View file

@ -47,7 +47,7 @@
"node": ">=22.19.0"
},
"dependencies": {
"@earendil-works/pi-ai": "^0.84.1",
"@earendil-works/pi-agent-core": "^0.84.1",
"@earendil-works/pi-protocol": "^0.84.1"
},
"devDependencies": {

View file

@ -2,13 +2,12 @@ import type { JsonValue, ProtocolErrorCode } from "@earendil-works/pi-protocol";
export type PiServerOperationErrorCode = Extract<
ProtocolErrorCode,
"busy" | "session_locked" | "not_found" | "invalid_request" | "not_implemented"
"wrong_service" | "session_not_found" | "session_locked" | "server_busy" | "server_restarting" | "invalid_request"
>;
export const INTERNAL_SERVER_ERROR_MESSAGE = "Internal server error";
export const NOT_IMPLEMENTED_MESSAGE = "Operation is not implemented";
/** A service/runtime error that can safely cross the protocol boundary. */
/** A host or lifecycle error that can safely cross the protocol boundary. */
export class PiServerError extends Error {
readonly code: PiServerOperationErrorCode;
readonly details: JsonValue | undefined;
@ -21,10 +20,10 @@ export class PiServerError extends Error {
}
}
export class SessionBusyError extends PiServerError {
constructor(message = "Session is busy", details?: JsonValue) {
super("busy", message, details);
this.name = "SessionBusyError";
export class WrongServiceError extends PiServerError {
constructor() {
super("wrong_service", "Request was addressed to another service");
this.name = "WrongServiceError";
}
}
@ -37,15 +36,22 @@ export class SessionLockedError extends PiServerError {
export class SessionNotFoundError extends PiServerError {
constructor(message = "Session was not found", details?: JsonValue) {
super("not_found", message, details);
super("session_not_found", message, details);
this.name = "SessionNotFoundError";
}
}
export class NotImplementedError extends PiServerError {
export class ServerBusyError extends PiServerError {
constructor(message = "Server is busy", details?: JsonValue) {
super("server_busy", message, details);
this.name = "ServerBusyError";
}
}
export class ServerRestartingError extends PiServerError {
constructor() {
super("not_implemented", NOT_IMPLEMENTED_MESSAGE);
this.name = "NotImplementedError";
super("server_restarting", "Server is restarting");
this.name = "ServerRestartingError";
}
}

View file

@ -0,0 +1,112 @@
import type { AgentHarness, SessionMetadata } from "@earendil-works/pi-agent-core";
import {
createRpcDispatcher,
ProtocolValidationError,
ServiceRpc,
type ServiceRpcCall,
type ServiceRpcResultUnion,
} from "@earendil-works/pi-protocol";
import type { ConnectionState } from "./connection.ts";
import { ServerRestartingError, SessionNotFoundError } from "./errors.ts";
import type { HostedSessionInfo, PiServerService } from "./types.ts";
interface HostedSession {
readonly id: string;
readonly metadata: SessionMetadata;
readonly harness: Pick<AgentHarness, "close">;
readonly connections: Set<ConnectionState>;
}
interface HostedHarnessManagerOptions {
service: PiServerService;
isClosing: () => boolean;
reportError: (error: unknown) => void;
}
export class HostedHarnessManager {
private readonly options: HostedHarnessManagerOptions;
private readonly hostedSessions = new Map<string, HostedSession>();
private readonly openingSessions = new Map<string, Promise<HostedSession>>();
private readonly dispatchRpc: (call: ServiceRpcCall, connection: ConnectionState) => Promise<ServiceRpcResultUnion>;
constructor(options: HostedHarnessManagerOptions) {
this.options = options;
this.dispatchRpc = createRpcDispatcher(
ServiceRpc,
{
list: () => this.options.service.sessions.list(),
attach: async (connection, sessionId) => {
if (this.options.isClosing()) throw new ServerRestartingError();
const hosted = await this.acquire(sessionId);
if (connection.disconnected || connection.stage !== "ready" || connection.connection.closed) {
throw new ServerRestartingError();
}
connection.sessionIds.add(hosted.id);
hosted.connections.add(connection);
return { sessionId: hosted.id };
},
},
(message) => new ProtocolValidationError(message),
);
}
get hosted(): readonly HostedSessionInfo[] {
return [...this.hostedSessions.values()].map(({ id, metadata }) => ({ sessionId: id, metadata }));
}
executeCall(connection: ConnectionState, call: ServiceRpcCall): Promise<ServiceRpcResultUnion> {
return this.dispatchRpc(call, connection);
}
disconnect(connection: ConnectionState): void {
for (const sessionId of connection.sessionIds) this.hostedSessions.get(sessionId)?.connections.delete(connection);
connection.sessionIds.clear();
}
async close(): Promise<void> {
const openingResults = await Promise.allSettled(this.openingSessions.values());
for (const result of openingResults) {
if (result.status === "rejected") this.options.reportError(result.reason);
}
const hosted = [...this.hostedSessions.values()];
this.hostedSessions.clear();
const closeResults = await Promise.allSettled(hosted.map(({ harness }) => harness.close()));
for (const result of closeResults) {
if (result.status === "rejected") this.options.reportError(result.reason);
}
}
private async acquire(sessionId: string): Promise<HostedSession> {
const existing = this.hostedSessions.get(sessionId);
if (existing) return existing;
const opening = this.openingSessions.get(sessionId);
if (opening) return opening;
const pending = this.open(sessionId);
this.openingSessions.set(sessionId, pending);
try {
return await pending;
} finally {
if (this.openingSessions.get(sessionId) === pending) this.openingSessions.delete(sessionId);
}
}
private async open(sessionId: string): Promise<HostedSession> {
const metadata = (await this.options.service.sessions.list()).find((candidate) => candidate.id === sessionId);
if (!metadata) throw new SessionNotFoundError(`Unknown session: ${sessionId}`);
const session = await this.options.service.sessions.open(metadata);
let harness: Pick<AgentHarness, "close">;
try {
harness = await this.options.service.createHarness(session);
} catch (error) {
await session.close().catch((closeError: unknown) => this.options.reportError(closeError));
throw error;
}
if (this.options.isClosing()) {
await harness.close().catch((error: unknown) => this.options.reportError(error));
throw new ServerRestartingError();
}
const hosted: HostedSession = { id: metadata.id, metadata, harness, connections: new Set() };
this.hostedSessions.set(hosted.id, hosted);
return hosted;
}
}

View file

@ -1,5 +1,4 @@
export * from "./errors.ts";
export * from "./listener.ts";
export * from "./protocol.ts";
export * from "./server.ts";
export * from "./types.ts";

View file

@ -1,382 +0,0 @@
import {
type ImageContent as AiImageContent,
type TextContent as AiTextContent,
type Usage as AiUsage,
type Api,
type AssistantMessage,
getSupportedThinkingLevels,
type Model,
type ModelThinkingLevel,
type ToolCall,
type ToolResultMessage,
type UserMessage,
} from "@earendil-works/pi-ai";
import type {
AssistantTranscriptItem,
JsonValue,
ModelMetadata,
ThinkingLevel,
ToolTranscriptItem,
Usage,
UserTranscriptItem,
} from "@earendil-works/pi-protocol";
type Assert<T extends true> = T;
type ExactKeys<T, Keys extends keyof T> = keyof T extends Keys ? true : false;
type _AiThinkingLevelsFitProtocol = Assert<ModelThinkingLevel extends ThinkingLevel ? true : false>;
type _ProtocolThinkingLevelsFitAi = Assert<ThinkingLevel extends ModelThinkingLevel ? true : false>;
type AiModelInput = Model<Api>["input"][number];
type ProtocolModelInput = ModelMetadata["input"][number];
type _AiModelInputsFitProtocol = Assert<AiModelInput extends ProtocolModelInput ? true : false>;
type _ProtocolModelInputsFitAi = Assert<ProtocolModelInput extends AiModelInput ? true : false>;
/**
* Enumerate mapped and intentionally omitted pi-ai fields so additions fail compilation here.
* Provider replay metadata, diagnostics, cache-write retention splits, model transport settings,
* model sampling defaults, pricing tiers, and deferred-tool availability remain intentionally
* server-side.
*/
type _AiTextContentFieldsAccountedFor = Assert<ExactKeys<AiTextContent, "type" | "text" | "textSignature">>;
type _AiThinkingContentFieldsAccountedFor = Assert<
ExactKeys<
Extract<AssistantMessage["content"][number], { type: "thinking" }>,
"type" | "thinking" | "thinkingSignature" | "redacted"
>
>;
type _AiImageContentFieldsAccountedFor = Assert<ExactKeys<AiImageContent, "type" | "data" | "mimeType">>;
type _AiToolCallFieldsAccountedFor = Assert<
ExactKeys<ToolCall, "type" | "id" | "name" | "arguments" | "thoughtSignature" | "namespace">
>;
type _AiUsageFieldsAccountedFor = Assert<
ExactKeys<
AiUsage,
"input" | "output" | "cacheRead" | "cacheWrite" | "cacheWrite1h" | "reasoning" | "totalTokens" | "cost"
>
>;
type _AiUsageCostFieldsAccountedFor = Assert<
ExactKeys<AiUsage["cost"], "input" | "output" | "cacheRead" | "cacheWrite" | "total">
>;
type _AiModelFieldsAccountedFor = Assert<
ExactKeys<
Model<Api>,
| "id"
| "name"
| "api"
| "provider"
| "baseUrl"
| "reasoning"
| "thinkingLevelMap"
| "input"
| "cost"
| "contextWindow"
| "maxTokens"
| "samplingParams"
| "headers"
| "compat"
>
>;
type _AiModelCostFieldsAccountedFor = Assert<
ExactKeys<Model<Api>["cost"], "input" | "output" | "cacheRead" | "cacheWrite" | "tiers">
>;
type _AiUserMessageFieldsAccountedFor = Assert<ExactKeys<UserMessage, "role" | "content" | "timestamp">>;
type _AiAssistantMessageFieldsAccountedFor = Assert<
ExactKeys<
AssistantMessage,
| "role"
| "content"
| "api"
| "provider"
| "model"
| "responseModel"
| "responseId"
| "diagnostics"
| "usage"
| "stopReason"
| "deferred"
| "errorMessage"
| "rawStopReason"
| "endTurn"
| "timestamp"
>
>;
type _AiToolResultMessageFieldsAccountedFor = Assert<
ExactKeys<
ToolResultMessage,
"role" | "toolCallId" | "toolName" | "content" | "details" | "usage" | "addedToolNames" | "isError" | "timestamp"
>
>;
export interface AssistantTranscriptOptions {
id: string;
}
export interface UserTranscriptOptions {
id: string;
}
export interface ToolTranscriptOptions {
id: string;
call: ToolCall;
}
function nonNegativeInteger(value: number | undefined): number | undefined {
if (value === undefined || !Number.isFinite(value)) return undefined;
return Math.max(0, Math.floor(value));
}
function nonNegativeNumber(value: number): number {
return Number.isFinite(value) ? Math.max(0, value) : 0;
}
function identifier(value: string, label: string): string {
if (typeof value !== "string" || value.length === 0) throw new TypeError(`${label} must be a non-empty string`);
return value;
}
function timestamp(value: number): number {
if (!Number.isSafeInteger(value) || value < 0)
throw new TypeError("Protocol timestamps must be non-negative integers");
return value;
}
/** Validate and copy a value from an execution boundary into the protocol's JSON-compatible subset. */
export function toProtocolJsonValue(value: unknown, seen = new Set<object>()): JsonValue {
if (value === null || typeof value === "string" || typeof value === "boolean") return value;
if (typeof value === "number") {
if (!Number.isFinite(value)) throw new TypeError("Protocol JSON numbers must be finite");
return value;
}
if (typeof value !== "object") throw new TypeError(`Unsupported protocol JSON value: ${typeof value}`);
if (seen.has(value)) throw new TypeError("Protocol JSON values must not contain circular references");
const prototype = Object.getPrototypeOf(value);
if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) {
throw new TypeError("Protocol JSON objects must be plain objects");
}
seen.add(value);
try {
if (Array.isArray(value)) return Array.from(value, (entry) => toProtocolJsonValue(entry, seen));
const result: Record<string, JsonValue> = {};
for (const [key, entry] of Object.entries(value)) result[key] = toProtocolJsonValue(entry, seen);
return result;
} finally {
seen.delete(value);
}
}
/** Lossily sanitize diagnostic tool details that must not affect execution semantics. */
export function sanitizeProtocolDetails(value: unknown, seen = new Set<object>()): JsonValue | undefined {
if (value === null || typeof value === "string" || typeof value === "boolean") return value;
if (typeof value === "number") return Number.isFinite(value) ? value : String(value);
if (typeof value === "bigint") return value.toString();
if (value === undefined || typeof value === "function" || typeof value === "symbol") return undefined;
if (value instanceof Date) return value.toISOString();
if (typeof value !== "object") return String(value);
if (seen.has(value)) return "[Circular]";
seen.add(value);
try {
if (Array.isArray(value)) return Array.from(value, (entry) => sanitizeProtocolDetails(entry, seen) ?? null);
const result: Record<string, JsonValue> = {};
for (const [key, entry] of Object.entries(value)) {
const normalized = sanitizeProtocolDetails(entry, seen);
if (normalized !== undefined) result[key] = normalized;
}
return result;
} finally {
seen.delete(value);
}
}
export function toProtocolUsage(usage: AiUsage | undefined): Usage | undefined {
if (!usage) return undefined;
const reasoning = nonNegativeInteger(usage.reasoning);
const result = {
input: nonNegativeInteger(usage.input) ?? 0,
output: nonNegativeInteger(usage.output) ?? 0,
cacheRead: nonNegativeInteger(usage.cacheRead) ?? 0,
cacheWrite: nonNegativeInteger(usage.cacheWrite) ?? 0,
...(reasoning === undefined ? {} : { reasoning }),
totalTokens: nonNegativeInteger(usage.totalTokens) ?? 0,
cost: {
input: nonNegativeNumber(usage.cost.input),
output: nonNegativeNumber(usage.cost.output),
cacheRead: nonNegativeNumber(usage.cost.cacheRead),
cacheWrite: nonNegativeNumber(usage.cost.cacheWrite),
total: nonNegativeNumber(usage.cost.total),
},
} satisfies Usage;
return result;
}
export function toProtocolModelMetadata(model: Model<Api>, authenticated: boolean): ModelMetadata {
const result = {
provider: identifier(model.provider, "Model provider"),
id: identifier(model.id, "Model id"),
name: identifier(model.name, "Model name"),
api: identifier(model.api, "Model API"),
reasoning: model.reasoning,
input: [...model.input],
contextWindow: Math.max(1, Math.floor(model.contextWindow)),
maxTokens: Math.max(1, Math.floor(model.maxTokens)),
cost: {
input: nonNegativeNumber(model.cost.input),
output: nonNegativeNumber(model.cost.output),
cacheRead: nonNegativeNumber(model.cost.cacheRead),
cacheWrite: nonNegativeNumber(model.cost.cacheWrite),
},
supportedThinkingLevels: getSupportedThinkingLevels(model),
authenticated,
} satisfies ModelMetadata;
return result;
}
function toProtocolUserContent(content: UserMessage["content"]): UserTranscriptItem["content"] {
if (typeof content === "string") return [{ type: "text", text: content }];
return content.map((part) => {
switch (part.type) {
case "text":
return { type: "text", text: part.text };
case "image":
return { type: "image", data: part.data, mimeType: part.mimeType };
default: {
const exhaustive: never = part;
return exhaustive;
}
}
});
}
export function toProtocolUserMessage(message: UserMessage, options: UserTranscriptOptions): UserTranscriptItem {
const result = {
id: identifier(options.id, "Transcript item id"),
role: "user",
content: toProtocolUserContent(message.content),
timestamp: timestamp(message.timestamp),
} satisfies UserTranscriptItem;
return result;
}
function toProtocolAssistantContent(message: AssistantMessage): AssistantTranscriptItem["content"] {
return message.content.map((part) => {
switch (part.type) {
case "text":
return { type: "text", text: part.text };
case "thinking":
return {
type: "thinking",
thinking: part.thinking,
...(part.redacted === undefined ? {} : { redacted: part.redacted }),
};
case "toolCall":
return {
type: "toolCall",
toolCallId: identifier(part.id, "Tool call id"),
toolName: identifier(part.name, "Tool call name"),
input: toProtocolJsonValue(part.arguments),
};
default: {
const exhaustive: never = part;
return exhaustive;
}
}
});
}
export function toProtocolAssistantMessage(
message: AssistantMessage,
options: AssistantTranscriptOptions,
): AssistantTranscriptItem {
const usage = toProtocolUsage(message.usage);
const common = {
id: identifier(options.id, "Transcript item id"),
role: "assistant",
content: toProtocolAssistantContent(message),
model: {
provider: identifier(message.provider, "Assistant provider"),
id: identifier(message.model, "Assistant model"),
},
...(message.responseModel === undefined
? {}
: { responseModel: identifier(message.responseModel, "Assistant response model") }),
...(usage ? { usage } : {}),
timestamp: timestamp(message.timestamp),
} as const;
switch (message.stopReason) {
case "pending":
return { ...common, status: "streaming" } satisfies AssistantTranscriptItem;
case "stop":
case "length":
case "toolUse":
return {
...common,
status: "complete",
stopReason: message.stopReason,
} satisfies AssistantTranscriptItem;
case "deferred":
throw new TypeError("Deferred assistant messages are not supported by protocol v1");
case "error":
if (message.errorMessage?.length === 0) {
throw new TypeError("Assistant error messages must not be empty");
}
return {
...common,
status: "error",
stopReason: "error",
...(message.errorMessage === undefined ? {} : { errorMessage: message.errorMessage }),
} satisfies AssistantTranscriptItem;
case "aborted":
return {
...common,
status: "aborted",
stopReason: "aborted",
...(message.errorMessage === undefined ? {} : { errorMessage: message.errorMessage }),
} satisfies AssistantTranscriptItem;
default: {
const exhaustive: never = message.stopReason;
return exhaustive;
}
}
}
function toProtocolToolContent(content: Array<AiTextContent | AiImageContent>): ToolTranscriptItem["content"] {
return content.map((part) => {
switch (part.type) {
case "text":
return { type: "text", text: part.text };
case "image":
return { type: "image", data: part.data, mimeType: part.mimeType };
default: {
const exhaustive: never = part;
return exhaustive;
}
}
});
}
export function toProtocolToolResultMessage(
message: ToolResultMessage,
options: ToolTranscriptOptions,
): ToolTranscriptItem {
const callId = identifier(options.call.id, "Tool call id");
const callName = identifier(options.call.name, "Tool call name");
if (identifier(message.toolCallId, "Tool result call id") !== callId) {
throw new TypeError(`Tool result ${message.toolCallId} does not match tool call ${callId}`);
}
if (identifier(message.toolName, "Tool result name") !== callName) {
throw new TypeError(`Tool result ${message.toolName} does not match tool call ${callName}`);
}
const details = sanitizeProtocolDetails(message.details);
const usage = toProtocolUsage(message.usage);
const common = {
id: identifier(options.id, "Transcript item id"),
role: "tool",
toolCallId: callId,
toolName: callName,
input: toProtocolJsonValue(options.call.arguments),
content: toProtocolToolContent(message.content),
...(details === undefined ? {} : { details }),
...(usage ? { usage } : {}),
timestamp: timestamp(message.timestamp),
} as const;
return message.isError
? ({ ...common, status: "error", isError: true } satisfies ToolTranscriptItem)
: ({ ...common, status: "complete", isError: false } satisfies ToolTranscriptItem);
}

View file

@ -21,31 +21,24 @@ import {
type ConnectionState,
isTerminalConnection,
} from "./connection.ts";
import {
INTERNAL_SERVER_ERROR_MESSAGE,
InternalServerError,
NOT_IMPLEMENTED_MESSAGE,
PiServerError,
} from "./errors.ts";
import { INTERNAL_SERVER_ERROR_MESSAGE, InternalServerError, PiServerError, WrongServiceError } from "./errors.ts";
import { HostedHarnessManager } from "./hosted-harness-manager.ts";
import type { PiServerListener } from "./listener.ts";
import { LiveSessionManager } from "./sessions.ts";
import { ServerSnapshotPublisher } from "./snapshots.ts";
import type { PiServerOptions, PiServerService } from "./types.ts";
import type { HostedSessionInfo, PiServerOptions, PiServerService } from "./types.ts";
const DEFAULT_HANDSHAKE_TIMEOUT_MS = 5_000;
const MAX_UINT32 = 0xffff_ffff;
const MAX_TIMER_DELAY_MS = 2_147_483_647;
export class PiServer {
readonly id: string;
readonly serviceId: string;
private readonly listeners: readonly PiServerListener[];
private readonly maxFrameLength: number;
private readonly handshakeTimeoutMs: number;
private readonly onError: ((error: Error) => void) | undefined;
private readonly connections = new Set<ConnectionState>();
private readonly sessions: LiveSessionManager;
private readonly snapshots: ServerSnapshotPublisher;
private readonly sessions: HostedHarnessManager;
private closing = false;
private closePromise?: Promise<void>;
private startPromise?: Promise<this>;
@ -54,26 +47,13 @@ export class PiServer {
constructor(service: PiServerService, options: PiServerOptions) {
const resolved = resolveOptions(options);
this.listeners = options.listeners;
this.id = options.serverId ?? randomUUID();
this.serviceId = options.serviceId;
this.maxFrameLength = resolved.maxFrameLength;
this.handshakeTimeoutMs = resolved.handshakeTimeoutMs;
this.onError = options.onError;
this.sessions = new LiveSessionManager({
this.sessions = new HostedHarnessManager({
service,
isClosing: () => this.closing,
sendMessage: (connection, message) => this.sendMessage(connection, message),
closeConnection: (connection) => this.closeConnection(connection),
disconnect: (connection) => this.disconnect(connection),
broadcastServerSnapshot: () => void this.snapshots.broadcast(),
reportError: (error) => this.reportError(error),
});
this.snapshots = new ServerSnapshotPublisher({
serverId: this.id,
service,
connections: this.connections,
isClosing: () => this.closing,
listSessions: () => this.sessions.listMetadata(),
sendMessage: (connection, message) => this.sendMessage(connection, message),
reportError: (error) => this.reportError(error),
});
}
@ -82,6 +62,10 @@ export class PiServer {
return this.listeners.flatMap((listener) => (listener.address === undefined ? [] : [listener.address]));
}
get hostedSessions(): readonly HostedSessionInfo[] {
return this.sessions.hosted;
}
start(): Promise<this> {
if (this.started) return Promise.reject(new Error("PiServer is already started"));
if (this.startPromise) return Promise.reject(new Error("PiServer is already starting"));
@ -227,31 +211,24 @@ export class PiServer {
return;
}
const snapshot = await this.snapshots.get();
if (this.closing || state.disconnected || state.stage !== "handshaking" || state.connection.closed) return;
const sent = await this.sendMessage(state, {
type: "hello",
version: PROTOCOL_VERSION,
connectionId: state.id,
snapshot,
serviceId: this.serviceId,
} satisfies ServerHello);
if (sent && !state.disconnected && state.stage === "handshaking") {
state.handshakeComplete = true;
state.stage = "ready";
clearTimeout(state.handshakeTimeout);
if (snapshot.revision !== this.snapshots.currentRevision) {
const current = await this.snapshots.get();
await this.sendMessage(state, {
type: "event",
event: { type: "server_snapshot", snapshot: current },
});
}
}
}
private async handleRequest(state: ConnectionState, envelope: RequestEnvelope): Promise<void> {
try {
const result = await this.sessions.executeCommand(state, envelope.request);
if (envelope.serviceId !== this.serviceId) throw new WrongServiceError();
const result = await this.sessions.executeCall(state, envelope.call);
await this.sendMessage(state, {
type: "response",
id: envelope.id,
@ -276,18 +253,16 @@ export class PiServer {
this.reportError(error);
}
}
void this.disconnect(connection);
this.disconnect(connection);
}
private async disconnect(connection: ConnectionState): Promise<void> {
private disconnect(connection: ConnectionState): void {
if (connection.disconnected) return;
const handshakeComplete = connection.handshakeComplete;
connection.disconnected = true;
connection.stage = "closed";
clearTimeout(connection.handshakeTimeout);
this.connections.delete(connection);
await this.sessions.disconnect(connection);
if (!this.closing && handshakeComplete) void this.snapshots.broadcast();
this.sessions.disconnect(connection);
}
private async sendMessage(connection: ConnectionState, message: ServerMessage): Promise<boolean> {
@ -298,7 +273,7 @@ export class PiServer {
} catch (error) {
this.reportError(error);
await this.closeConnection(connection.connection);
await this.disconnect(connection);
this.disconnect(connection);
return false;
}
try {
@ -307,7 +282,7 @@ export class PiServer {
} catch (error) {
this.reportError(error);
await this.closeConnection(connection.connection);
await this.disconnect(connection);
this.disconnect(connection);
return false;
}
}
@ -324,7 +299,7 @@ export class PiServer {
this.reportError(encodeError);
}
await this.closeConnection(connection.connection, finalFrame);
await this.disconnect(connection);
this.disconnect(connection);
}
private async closeServerState(): Promise<void> {
@ -334,8 +309,7 @@ export class PiServer {
clearTimeout(connection.handshakeTimeout);
}
await Promise.all(connections.map((connection) => this.closeConnection(connection.connection)));
await Promise.all(connections.map((connection) => this.disconnect(connection)));
for (const connection of connections) this.disconnect(connection);
await this.sessions.close();
this.connections.clear();
}
@ -354,9 +328,6 @@ export class PiServer {
return { code: "internal_error", message: INTERNAL_SERVER_ERROR_MESSAGE };
}
if (error instanceof PiServerError) {
if (error.code === "not_implemented") {
return { code: "not_implemented", message: NOT_IMPLEMENTED_MESSAGE };
}
return error.details === undefined
? { code: error.code, message: error.message }
: { code: error.code, message: error.message, details: error.details };
@ -379,7 +350,7 @@ export class PiServer {
function resolveOptions(options: PiServerOptions): { maxFrameLength: number; handshakeTimeoutMs: number } {
if (!Array.isArray(options.listeners)) throw new TypeError("PiServer listeners must be an array");
if (options.serverId === "") throw new TypeError("PiServer serverId must not be empty");
if (!options.serviceId) throw new TypeError("PiServer serviceId must not be empty");
const maxFrameLength = options.maxFrameLength ?? DEFAULT_MAX_FRAME_LENGTH;
if (!Number.isSafeInteger(maxFrameLength) || maxFrameLength <= 0 || maxFrameLength > MAX_UINT32) {
throw new TypeError(`PiServer maxFrameLength must be an integer between 1 and ${MAX_UINT32}`);

View file

@ -1,346 +0,0 @@
import { randomUUID } from "node:crypto";
import type { Command, EventEnvelope, SessionMetadata, SessionSnapshot } from "@earendil-works/pi-protocol";
import type { ByteConnection, ConnectionState } from "./connection.ts";
import { PiServerError } from "./errors.ts";
import type { CreateSessionOptions, PiServerService, PiSessionRuntime, PiSessionRuntimeEvent } from "./types.ts";
interface LiveSession {
id: string;
runtime: PiSessionRuntime;
connections: Set<ConnectionState>;
unsubscribe: () => void;
operationCount: number;
ready: boolean;
terminal: boolean;
disposing?: Promise<void>;
}
interface LiveSessionManagerOptions {
service: PiServerService;
isClosing: () => boolean;
sendMessage: (connection: ConnectionState, message: EventEnvelope) => Promise<boolean>;
closeConnection: (connection: ByteConnection) => Promise<void>;
disconnect: (connection: ConnectionState) => Promise<void>;
broadcastServerSnapshot: () => void;
reportError: (error: unknown) => void;
}
function toMetadata(snapshot: SessionSnapshot): SessionMetadata {
return {
id: snapshot.id,
createdAt: snapshot.createdAt,
updatedAt: snapshot.updatedAt,
sessionName: snapshot.name,
cwd: snapshot.cwd,
};
}
export class LiveSessionManager {
private readonly options: LiveSessionManagerOptions;
private readonly liveSessions = new Map<string, LiveSession>();
private readonly openingSessions = new Map<string, Promise<LiveSession>>();
constructor(options: LiveSessionManagerOptions) {
this.options = options;
}
async executeCommand(connection: ConnectionState, command: Command) {
switch (command.command) {
case "list":
return { command: "list" as const, sessions: await this.listMetadata() };
case "create": {
const id = randomUUID();
const options: CreateSessionOptions = {
id,
cwd: command.cwd,
name: command.name,
model: command.model,
thinkingLevel: command.thinkingLevel,
};
const live = await this.acquire(id, () => this.options.service.createSession(options));
await this.attach(connection, live);
const session = this.forConnection(await this.broadcastSnapshot(live), connection);
this.options.broadcastServerSnapshot();
return { command: "create" as const, session };
}
case "attach": {
const live = await this.acquire(command.sessionId, () =>
this.options.service.openSession(command.sessionId),
);
await this.attach(connection, live);
const session = this.forConnection(await this.broadcastSnapshot(live), connection);
this.options.broadcastServerSnapshot();
return { command: "attach" as const, session };
}
case "detach": {
const live = this.liveSessions.get(command.sessionId);
if (connection.sessionIds.has(command.sessionId)) {
connection.sessionIds.delete(command.sessionId);
if (live) {
live.connections.delete(connection);
if (live.connections.size > 0 && !live.terminal && !live.disposing) {
await this.broadcastSnapshot(live);
}
await this.maybeDispose(live);
}
this.options.broadcastServerSnapshot();
}
return { command: "detach" as const, sessionId: command.sessionId };
}
case "prompt": {
const live = this.requireAttached(connection, command.sessionId);
const session = await this.runOperation(connection, live, () =>
live.runtime.prompt({ text: command.text }),
);
return { command: "prompt" as const, session };
}
case "steer": {
const live = this.requireAttached(connection, command.sessionId);
const session = await this.runOperation(connection, live, () => live.runtime.steer({ text: command.text }));
return { command: "steer" as const, session };
}
case "abort": {
const live = this.requireAttached(connection, command.sessionId);
const session = await this.runOperation(connection, live, () => live.runtime.abort());
return { command: "abort" as const, session };
}
case "set_model": {
const live = this.requireAttached(connection, command.sessionId);
const session = await this.runOperation(connection, live, () => live.runtime.setModel(command.model));
return { command: "set_model" as const, session };
}
case "set_thinking": {
const live = this.requireAttached(connection, command.sessionId);
const session = await this.runOperation(connection, live, () =>
live.runtime.setThinking(command.thinkingLevel),
);
return { command: "set_thinking" as const, session };
}
}
}
async disconnect(connection: ConnectionState): Promise<void> {
const sessions = [...connection.sessionIds]
.map((id) => this.liveSessions.get(id))
.filter((live): live is LiveSession => live !== undefined);
connection.sessionIds.clear();
for (const live of sessions) live.connections.delete(connection);
const results = await Promise.allSettled(sessions.map((live) => this.maybeDispose(live)));
for (const result of results) {
if (result.status === "rejected") this.options.reportError(result.reason);
}
}
async listMetadata(): Promise<SessionMetadata[]> {
const stored = await this.options.service.listSessions();
const liveSnapshots = await Promise.all(
[...this.liveSessions.values()]
.filter((live) => !live.disposing)
.map(async (live) => [live.id, await this.normalizedSnapshot(live)] as const),
);
const liveById = new Map(liveSnapshots);
const metadata = stored.map((item) => {
const snapshot = liveById.get(item.id);
if (!snapshot) return item;
liveById.delete(item.id);
return { ...item, ...toMetadata(snapshot) };
});
for (const snapshot of liveById.values()) metadata.push(toMetadata(snapshot));
return metadata;
}
async close(): Promise<void> {
const openingResults = await Promise.allSettled([...this.openingSessions.values()]);
for (const result of openingResults) {
if (result.status === "rejected") this.options.reportError(result.reason);
}
const sessions = [...this.liveSessions.values()];
this.liveSessions.clear();
await Promise.all(
sessions.map(async (live) => {
if (live.disposing) {
await live.disposing;
return;
}
live.unsubscribe();
await live.runtime.dispose();
}),
);
}
private async runOperation(
connection: ConnectionState,
live: LiveSession,
operation: () => Promise<void>,
): Promise<SessionSnapshot> {
live.operationCount += 1;
try {
await operation();
return this.forConnection(await this.broadcastSnapshot(live), connection);
} finally {
live.operationCount -= 1;
this.scheduleMaybeDispose(live);
}
}
private async acquire(id: string, acquireRuntime: () => Promise<PiSessionRuntime>): Promise<LiveSession> {
for (;;) {
const existing = this.liveSessions.get(id);
if (existing) {
if (existing.terminal) throw new PiServerError("session_locked", `Session runtime is terminating: ${id}`);
if (existing.disposing) {
await existing.disposing;
continue;
}
return existing;
}
const opening = this.openingSessions.get(id);
if (opening) return opening;
const pending = this.create(id, acquireRuntime);
this.openingSessions.set(id, pending);
try {
return await pending;
} finally {
if (this.openingSessions.get(id) === pending) this.openingSessions.delete(id);
}
}
}
private async create(id: string, acquireRuntime: () => Promise<PiSessionRuntime>): Promise<LiveSession> {
const runtime = await acquireRuntime();
if (this.options.isClosing()) {
await runtime.dispose();
throw new Error("PiServer closed while acquiring a session runtime");
}
let live: LiveSession | undefined;
try {
const snapshot = await runtime.snapshot();
if (snapshot.id !== id) {
throw new PiServerError(
"invalid_request",
`Service returned session ${snapshot.id} for server-assigned session ${id}`,
);
}
live = {
id,
runtime,
connections: new Set(),
unsubscribe: () => {},
operationCount: 0,
ready: false,
terminal: false,
};
live.unsubscribe = runtime.subscribe((event) => this.handleRuntimeEvent(live!, event));
this.liveSessions.set(id, live);
live.ready = true;
return live;
} catch (error) {
if (live) live.unsubscribe();
try {
await runtime.dispose();
} catch (disposeError) {
this.options.reportError(disposeError);
}
throw error;
}
}
private handleRuntimeEvent(live: LiveSession, event: PiSessionRuntimeEvent): void {
if (event.type === "error") {
void this.terminate(live, event.error).catch((error: unknown) => this.options.reportError(error));
return;
}
if (event.type === "progress") {
const envelope: EventEnvelope = {
type: "event",
event: { type: "session_progress", sessionId: live.id, progress: event.progress },
};
for (const connection of live.connections) void this.options.sendMessage(connection, envelope);
} else {
void this.broadcastSnapshot(live).catch((error: unknown) => this.options.reportError(error));
}
this.scheduleMaybeDispose(live);
}
private async terminate(live: LiveSession, error: PiServerError): Promise<void> {
if (live.terminal) return;
live.terminal = true;
this.options.reportError(error);
live.unsubscribe();
const connections = [...live.connections];
await Promise.all(connections.map((connection) => this.options.closeConnection(connection.connection)));
await Promise.all(connections.map((connection) => this.options.disconnect(connection)));
await this.maybeDispose(live);
}
private async normalizedSnapshot(live: LiveSession): Promise<SessionSnapshot> {
const snapshot = await live.runtime.snapshot();
if (snapshot.id !== live.id) {
throw new PiServerError("invalid_request", `Runtime session ID changed from ${live.id} to ${snapshot.id}`);
}
return {
...snapshot,
phase: live.runtime.getPhase(),
attached: live.connections.size > 0,
locked: true,
};
}
private forConnection(snapshot: SessionSnapshot, connection: ConnectionState): SessionSnapshot {
return { ...snapshot, attached: connection.sessionIds.has(snapshot.id) };
}
private async broadcastSnapshot(live: LiveSession): Promise<SessionSnapshot> {
const snapshot = await this.normalizedSnapshot(live);
const envelope: EventEnvelope = { type: "event", event: { type: "session_snapshot", snapshot } };
for (const connection of live.connections) void this.options.sendMessage(connection, envelope);
return snapshot;
}
private async attach(connection: ConnectionState, live: LiveSession): Promise<void> {
if (connection.disconnected || connection.stage !== "ready" || connection.connection.closed) {
await this.maybeDispose(live);
throw new PiServerError("invalid_request", "Connection closed while attaching to a session");
}
connection.sessionIds.add(live.id);
live.connections.add(connection);
}
private requireAttached(connection: ConnectionState, sessionId: string): LiveSession {
if (!connection.sessionIds.has(sessionId)) {
throw new PiServerError("invalid_request", `Connection is not attached to session ${sessionId}`);
}
const live = this.liveSessions.get(sessionId);
if (!live || live.terminal || live.disposing) {
throw new PiServerError("not_found", `Session is not live: ${sessionId}`);
}
return live;
}
private scheduleMaybeDispose(live: LiveSession): void {
void this.maybeDispose(live).catch((error: unknown) => this.options.reportError(error));
}
private async maybeDispose(live: LiveSession): Promise<void> {
if (
this.options.isClosing() ||
!live.ready ||
live.disposing ||
live.connections.size > 0 ||
live.operationCount > 0 ||
(!live.terminal && live.runtime.getPhase() !== "idle")
) {
return live.disposing;
}
live.unsubscribe();
live.disposing = (async () => {
try {
await live.runtime.dispose();
} finally {
if (this.liveSessions.get(live.id) === live) this.liveSessions.delete(live.id);
}
})();
await live.disposing;
if (!this.options.isClosing()) this.options.broadcastServerSnapshot();
}
}

View file

@ -1,62 +0,0 @@
import {
type EventEnvelope,
type ModelMetadata,
PROTOCOL_VERSION,
type ServerSnapshot,
type SessionMetadata,
} from "@earendil-works/pi-protocol";
import type { ConnectionState } from "./connection.ts";
import type { PiServerService } from "./types.ts";
interface ServerSnapshotPublisherOptions {
serverId: string;
service: PiServerService;
connections: Set<ConnectionState>;
isClosing: () => boolean;
listSessions: () => Promise<SessionMetadata[]>;
sendMessage: (connection: ConnectionState, message: EventEnvelope) => Promise<boolean>;
reportError: (error: unknown) => void;
}
export class ServerSnapshotPublisher {
private readonly options: ServerSnapshotPublisherOptions;
private revision = 0;
private broadcastQueue: Promise<void> = Promise.resolve();
constructor(options: ServerSnapshotPublisherOptions) {
this.options = options;
}
get currentRevision(): number {
return this.revision;
}
async get(models?: ModelMetadata[]): Promise<ServerSnapshot> {
return {
serverId: this.options.serverId,
protocolVersion: PROTOCOL_VERSION,
revision: this.revision,
sessions: await this.options.listSessions(),
models: models ?? (await this.options.service.listModels()),
};
}
broadcast(): Promise<void> {
const broadcast = this.broadcastQueue.then(() => this.performBroadcast());
this.broadcastQueue = broadcast.catch((error: unknown) => this.options.reportError(error));
return broadcast;
}
private async performBroadcast(): Promise<void> {
const readyConnections = [...this.options.connections].filter(
(connection) => connection.stage === "ready" && !connection.disconnected,
);
if (readyConnections.length === 0 || this.options.isClosing()) return;
const revision = ++this.revision;
const models = await this.options.service.listModels();
const current = await this.get(models);
const snapshot: ServerSnapshot = { ...current, revision };
const envelope: EventEnvelope = { type: "event", event: { type: "server_snapshot", snapshot } };
for (const connection of readyConnections) await this.options.sendMessage(connection, envelope);
}
}

View file

@ -2,12 +2,12 @@ import { once } from "node:events";
import { createConnection, type Socket } from "node:net";
import {
type ClientMessage,
type Command,
encodeClientMessage,
PROTOCOL_VERSION,
type ResponseEnvelope,
type ServerMessage,
ServerMessageDecoder,
type ServiceRpcCall,
} from "@earendil-works/pi-protocol";
import { Deferred } from "./service.ts";
@ -46,11 +46,15 @@ export class ProtocolTestClient {
return response;
}
async request(command: Command, id = `request-${++this.requestSequence}`): Promise<ResponseEnvelope> {
async request(
serviceId: string,
call: ServiceRpcCall,
id = `request-${++this.requestSequence}`,
): Promise<ResponseEnvelope> {
const response = this.next(
(message): message is ResponseEnvelope => message.type === "response" && message.id === id,
);
await this.sendMessage({ type: "request", id, request: command });
await this.sendMessage({ type: "request", id, serviceId, call });
return (await response) as ResponseEnvelope;
}

View file

@ -2,4 +2,4 @@ export type { WireChannel } from "./client.ts";
export { connectUnixTestClient, ProtocolTestClient } from "./client.ts";
export type { TestServer, TestServerOptions } from "./server.ts";
export { createTestServer } from "./server.ts";
export { Deferred, TEST_MODEL, TestServerService, TestSessionRuntime } from "./service.ts";
export { Deferred, TestHarness, TestServerService } from "./service.ts";

View file

@ -2,8 +2,9 @@ import { PiServer } from "../server.ts";
import type { PiServerOptions, PiServerService } from "../types.ts";
import { TestServerService } from "./service.ts";
export interface TestServerOptions extends PiServerOptions {
export interface TestServerOptions extends Omit<PiServerOptions, "serviceId"> {
service?: PiServerService;
serviceId?: string;
}
export interface TestServer {
@ -19,7 +20,7 @@ export function createTestServer(options: TestServerOptions): TestServer {
listeners: options.listeners,
maxFrameLength: options.maxFrameLength,
handshakeTimeoutMs: options.handshakeTimeoutMs,
serverId: options.serverId,
serviceId: options.serviceId ?? "test-service",
onError: options.onError,
}),
service,

View file

@ -1,34 +1,7 @@
import type {
ModelMetadata,
ModelRef,
SessionMetadata,
SessionPhase,
SessionSnapshot,
ThinkingLevel,
TranscriptProgress,
} from "@earendil-works/pi-protocol";
import { PiServerError } from "../errors.ts";
import type {
CreateSessionOptions,
PiServerService,
PiSessionRuntime,
PiSessionRuntimeEvent,
PromptInput,
} from "../types.ts";
export const TEST_MODEL: ModelMetadata = {
provider: "test",
id: "small",
name: "Test Small",
api: "test-api",
reasoning: true,
input: ["text", "image"],
contextWindow: 16_000,
maxTokens: 2_000,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
supportedThinkingLevels: ["off", "medium", "high"],
authenticated: true,
};
import type { AgentHarness, Session, SessionMetadata } from "@earendil-works/pi-agent-core";
import { MemorySessionRepo } from "@earendil-works/pi-agent-core";
import { SessionLockedError } from "../errors.ts";
import type { PiServerService } from "../types.ts";
export class Deferred<T> {
readonly promise: Promise<T>;
@ -45,148 +18,19 @@ export class Deferred<T> {
}
}
interface StoredSession {
snapshot: SessionSnapshot;
}
export class TestHarness {
readonly session: Session;
readonly closed = new Deferred<void>();
closeCount = 0;
export class TestSessionRuntime implements PiSessionRuntime {
readonly disposed = new Deferred<void>();
disposeCount = 0;
readonly steers: PromptInput[] = [];
private readonly stored: StoredSession;
private readonly onDispose: () => void;
private readonly listeners = new Set<(event: PiSessionRuntimeEvent) => void>();
private pendingPrompt?: { input: PromptInput; done: Deferred<"complete" | "aborted"> };
constructor(stored: StoredSession, onDispose: () => void) {
this.stored = stored;
this.onDispose = onDispose;
constructor(session: Session) {
this.session = session;
}
snapshot(): SessionSnapshot {
return structuredClone(this.stored.snapshot);
}
getPhase(): SessionPhase {
return this.stored.snapshot.phase;
}
async prompt(input: PromptInput): Promise<void> {
if (this.getPhase() !== "idle") throw new PiServerError("busy", "A prompt is already running");
const done = new Deferred<"complete" | "aborted">();
this.pendingPrompt = { input, done };
this.update({
phase: "turn",
transcript: [
...this.stored.snapshot.transcript,
{
id: `user-${this.stored.snapshot.revision + 1}`,
role: "user",
content: [{ type: "text", text: input.text }],
timestamp: this.stored.snapshot.revision + 1,
},
],
});
const outcome = await done.promise;
const assistant =
outcome === "complete"
? {
id: `assistant-${this.stored.snapshot.revision + 1}`,
role: "assistant" as const,
content: [{ type: "text" as const, text: `reply:${input.text}` }],
status: "complete" as const,
model: this.stored.snapshot.model,
stopReason: "stop" as const,
timestamp: this.stored.snapshot.revision + 1,
}
: {
id: `assistant-${this.stored.snapshot.revision + 1}`,
role: "assistant" as const,
content: [{ type: "text" as const, text: "" }],
status: "aborted" as const,
model: this.stored.snapshot.model,
stopReason: "aborted" as const,
timestamp: this.stored.snapshot.revision + 1,
};
this.update({
phase: "idle",
transcript: [...this.stored.snapshot.transcript, assistant],
});
this.pendingPrompt = undefined;
}
async steer(input: PromptInput): Promise<void> {
if (this.getPhase() === "idle") throw new PiServerError("busy", "There is no active prompt to steer");
this.steers.push(input);
this.update({
queuedSteerCount: this.stored.snapshot.queuedSteerCount + 1,
queuedSteer: [
...this.stored.snapshot.queuedSteer,
{
id: `steer-${this.stored.snapshot.revision + 1}`,
role: "user",
content: [{ type: "text", text: input.text }],
timestamp: this.stored.snapshot.revision + 1,
},
],
});
}
async abort(): Promise<void> {
if (!this.pendingPrompt) throw new PiServerError("busy", "There is no active prompt to abort");
this.pendingPrompt.done.resolve("aborted");
}
async setModel(model: ModelRef): Promise<void> {
if (this.getPhase() !== "idle") throw new PiServerError("busy", "Session is busy");
this.update({ model });
}
async setThinking(thinkingLevel: ThinkingLevel): Promise<void> {
if (this.getPhase() !== "idle") throw new PiServerError("busy", "Session is busy");
this.update({ thinkingLevel });
}
subscribe(listener: (event: PiSessionRuntimeEvent) => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
async dispose(): Promise<void> {
this.disposeCount += 1;
this.onDispose();
this.disposed.resolve(undefined);
}
setPhase(phase: SessionPhase): void {
this.stored.snapshot = { ...this.stored.snapshot, phase };
}
finishPrompt(): void {
if (!this.pendingPrompt) throw new Error("No prompt is pending");
this.pendingPrompt.done.resolve("complete");
}
emitProgress(progress: TranscriptProgress): void {
for (const listener of this.listeners) listener({ type: "progress", progress });
}
emitError(error: PiServerError): void {
for (const listener of this.listeners) listener({ type: "error", error });
}
emitSnapshot(): void {
for (const listener of this.listeners) listener({ type: "snapshot" });
}
private update(updates: Partial<SessionSnapshot>): void {
this.stored.snapshot = {
...this.stored.snapshot,
...updates,
revision: this.stored.snapshot.revision + 1,
updatedAt: this.stored.snapshot.updatedAt + 1,
};
this.emitSnapshot();
async close(): Promise<void> {
this.closeCount += 1;
await this.session.close();
this.closed.resolve(undefined);
}
}
@ -196,70 +40,39 @@ interface ListDelay {
}
export class TestServerService implements PiServerService {
readonly sessions = new Map<string, StoredSession>();
readonly runtimes = new Map<string, TestSessionRuntime[]>();
readonly repo = new MemorySessionRepo({ now: () => 1 });
readonly harnesses = new Map<string, TestHarness[]>();
readonly locked = new Set<string>();
lastCreatedId?: string;
readonly sessions: PiServerService["sessions"] = {
list: async () => {
const delay = this.nextListDelay;
if (delay) {
this.nextListDelay = undefined;
delay.entered.resolve(undefined);
await delay.release.promise;
}
return this.repo.list();
},
open: async (metadata) => {
if (this.locked.has(metadata.id)) throw new SessionLockedError(`Session is locked: ${metadata.id}`);
return this.repo.open(metadata);
},
};
private nextListDelay?: ListDelay;
async listSessions(): Promise<SessionMetadata[]> {
const delay = this.nextListDelay;
if (delay) {
this.nextListDelay = undefined;
delay.entered.resolve(undefined);
await delay.release.promise;
}
return [...this.sessions.values()].map(({ snapshot }) => ({
id: snapshot.id,
createdAt: snapshot.createdAt,
updatedAt: snapshot.updatedAt,
sessionName: snapshot.name,
cwd: snapshot.cwd,
}));
async createHarness(session: Session): Promise<Pick<AgentHarness, "close">> {
const harness = new TestHarness(session);
const harnesses = this.harnesses.get(session.metadata.id) ?? [];
harnesses.push(harness);
this.harnesses.set(session.metadata.id, harnesses);
return harness;
}
async listModels(): Promise<ModelMetadata[]> {
return [TEST_MODEL];
}
async createSession(options: CreateSessionOptions): Promise<PiSessionRuntime> {
this.lastCreatedId = options.id;
if (this.sessions.has(options.id)) throw new PiServerError("session_locked", "Session already exists");
this.seed(options.id, options.name, options.cwd, options.model, options.thinkingLevel);
return this.acquire(options.id);
}
async openSession(sessionId: string): Promise<PiSessionRuntime> {
if (!this.sessions.has(sessionId)) throw new PiServerError("not_found", `Unknown session: ${sessionId}`);
if (this.locked.has(sessionId)) throw new PiServerError("session_locked", `Session is locked: ${sessionId}`);
return this.acquire(sessionId);
}
seed(
id = "session-1",
name = `Session ${id}`,
cwd = "/tmp/pi-server-conformance",
model: ModelRef = { provider: TEST_MODEL.provider, id: TEST_MODEL.id },
thinkingLevel: ThinkingLevel = "off",
): void {
this.sessions.set(id, {
snapshot: {
id,
name,
cwd,
createdAt: 1,
updatedAt: 1,
phase: "idle",
model,
thinkingLevel,
attached: false,
locked: false,
revision: 0,
transcript: [],
queuedSteer: [],
queuedSteerCount: 0,
},
});
async seed(id = "session-1", parentSessionId?: string): Promise<SessionMetadata> {
const session = await this.repo.create({ id, parentSessionId });
const metadata = session.metadata;
await session.close();
return metadata;
}
delayNextList(): ListDelay {
@ -268,20 +81,9 @@ export class TestServerService implements PiServerService {
return delay;
}
latestRuntime(id: string): TestSessionRuntime {
const runtimes = this.runtimes.get(id);
if (!runtimes?.length) throw new Error(`No runtime for ${id}`);
return runtimes.at(-1)!;
}
private acquire(id: string): TestSessionRuntime {
const stored = this.sessions.get(id);
if (!stored) throw new Error(`Unknown session: ${id}`);
this.locked.add(id);
const runtime = new TestSessionRuntime(stored, () => this.locked.delete(id));
const runtimes = this.runtimes.get(id) ?? [];
runtimes.push(runtime);
this.runtimes.set(id, runtimes);
return runtime;
latestHarness(id: string): TestHarness {
const harnesses = this.harnesses.get(id);
if (!harnesses?.length) throw new Error(`No harness for ${id}`);
return harnesses.at(-1)!;
}
}

View file

@ -17,7 +17,7 @@ export function createUnixServer(service: PiServerService, options: UnixServerOp
listeners: [listener],
maxFrameLength: options.maxFrameLength,
handshakeTimeoutMs: options.handshakeTimeoutMs,
serverId: options.serverId,
serviceId: options.serviceId,
onError: options.onError,
});
}

View file

@ -1,63 +1,24 @@
import type {
Command,
ModelMetadata,
ModelRef,
SessionMetadata,
SessionPhase,
SessionSnapshot,
ThinkingLevel,
TranscriptProgress,
} from "@earendil-works/pi-protocol";
import type { PiServerError } from "./errors.ts";
import type { AgentHarness, Session, SessionMetadata, SessionRepo } from "@earendil-works/pi-agent-core";
import type { PiServerListener } from "./listener.ts";
export interface PiServerOptions {
listeners: readonly PiServerListener[];
/** Stable logical identity supplied by the server installation or profile. */
serviceId: string;
maxFrameLength?: number;
handshakeTimeoutMs?: number;
serverId?: string;
onError?: (error: Error) => void;
}
export type MaybePromise<T> = T | Promise<T>;
export type PromptInput = Omit<Extract<Command, { command: "prompt" }>, "command" | "sessionId">;
export type SteerInput = Omit<Extract<Command, { command: "steer" }>, "command" | "sessionId">;
export interface CreateSessionOptions {
/** A collision-resistant ID assigned by PiServer. The service must persist this exact ID. */
id: string;
cwd?: string;
name?: string;
model?: ModelRef;
thinkingLevel?: ThinkingLevel;
}
export type PiSessionRuntimeEvent =
| { type: "snapshot" }
| { type: "progress"; progress: TranscriptProgress }
| { type: "error"; error: PiServerError };
/** One acquired durable session. Conflicting operations must reject rather than queue. */
export interface PiSessionRuntime {
snapshot(): MaybePromise<SessionSnapshot>;
getPhase(): SessionPhase;
prompt(input: PromptInput): Promise<void>;
steer(input: SteerInput): Promise<void>;
abort(): Promise<void>;
setModel(model: ModelRef): Promise<void>;
setThinking(thinkingLevel: ThinkingLevel): Promise<void>;
subscribe(listener: (event: PiSessionRuntimeEvent) => void): () => void;
dispose(): Promise<void>;
}
/** Service boundary for durable sessions and exclusively acquired runtimes. */
/** Host capabilities used directly by the list and attach control-plane operations. */
export interface PiServerService {
listSessions(): Promise<SessionMetadata[]>;
listModels(): Promise<ModelMetadata[]>;
createSession(options: CreateSessionOptions): Promise<PiSessionRuntime>;
openSession(sessionId: string): Promise<PiSessionRuntime>;
readonly sessions: Pick<SessionRepo, "list" | "open">;
createHarness(session: Session): Promise<Pick<AgentHarness, "close">>;
}
export type SessionRuntime = PiSessionRuntime;
export type SessionRuntimeEvent = PiSessionRuntimeEvent;
export interface HostedSessionInfo {
readonly sessionId: string;
readonly metadata: SessionMetadata;
}

View file

@ -1,378 +1,142 @@
import { lstat, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { encodeClientMessage, encodeFrame, PROTOCOL_VERSION } from "@earendil-works/pi-protocol";
import { afterEach, describe, expect, test } from "vitest";
import { InternalServerError, NotImplementedError, type PiServer, PiServerError } from "../src/index.ts";
import { connectUnixTestClient, Deferred, type ProtocolTestClient, TestServerService } from "../src/testing/index.ts";
import { createUnixServer, type UnixServerOptions } from "../src/transports/unix/index.ts";
import type { ByteConnection, ByteConnectionHandler } from "../src/connection.ts";
import { PiServer } from "../src/server.ts";
import { ProtocolTestClient, TestServerService, type WireChannel } from "../src/testing/index.ts";
const servers = new Set<PiServer>();
const clients = new Set<ProtocolTestClient>();
const tempDirectories = new Set<string>();
async function startServer(
service = new TestServerService(),
overrides: Partial<UnixServerOptions> = {},
): Promise<{ server: PiServer; service: TestServerService }> {
const directory = await mkdtemp(join(tmpdir(), "pis-"));
tempDirectories.add(directory);
const server = createUnixServer(service, {
path: join(directory, "server.sock"),
...overrides,
});
function createServer(service: TestServerService, serviceId = "service-1"): PiServer {
const server = new PiServer(service, { listeners: [], serviceId });
servers.add(server);
await server.start();
return { server, service };
return server;
}
async function connect(server: PiServer): Promise<ProtocolTestClient> {
const client = await connectUnixTestClient(server.addresses[0]!);
clients.add(client);
function connect(server: PiServer): ProtocolTestClient {
let handler: ByteConnectionHandler;
let client: ProtocolTestClient;
let closed = false;
const connection: ByteConnection = {
get closed() {
return closed;
},
async send(chunk) {
client.receive(chunk);
},
close(finalChunk) {
if (finalChunk) client.receive(finalChunk);
closed = true;
client.markClosed();
},
};
const channel: WireChannel = {
async send(chunk) {
handler.onData(chunk);
},
async sendFragmented(chunk, splitAt) {
handler.onData(chunk.subarray(0, splitAt));
handler.onData(chunk.subarray(splitAt));
},
async close() {
if (closed) return;
closed = true;
handler.onClose();
client.markClosed();
},
};
client = new ProtocolTestClient(channel);
handler = server.accept(connection);
return client;
}
afterEach(async () => {
await Promise.all([...clients].map((client) => client.close()));
clients.clear();
await Promise.all([...servers].map((server) => server.close()));
servers.clear();
await Promise.all([...tempDirectories].map((directory) => rm(directory, { recursive: true, force: true })));
tempDirectories.clear();
});
describe("Unix transport conformance", () => {
test("accepts a transport-fragmented framed-CBOR hello", async () => {
const { server } = await startServer();
const client = await connect(server);
const response = client.next((message) => message.type === "hello");
await client.sendFragmentedMessage({ type: "hello", version: PROTOCOL_VERSION }, 2);
expect(await response).toMatchObject({ type: "hello", version: PROTOCOL_VERSION });
});
test("enforces version and exactly one first-message hello", async () => {
const { server } = await startServer();
const badVersion = await connect(server);
expect(await badVersion.hello(PROTOCOL_VERSION + 1)).toMatchObject({
type: "hello_error",
error: { code: "version" },
});
await badVersion.waitForClose();
const requestFirst = await connect(server);
const firstError = requestFirst.next((message) => message.type === "hello_error");
await requestFirst.sendMessage({ type: "request", id: "too-early", request: { command: "list" } });
expect(await firstError).toMatchObject({ type: "hello_error", error: { code: "invalid_request" } });
await requestFirst.waitForClose();
const duplicate = await connect(server);
expect(await duplicate.hello()).toMatchObject({ type: "hello" });
const duplicateError = duplicate.next((message) => message.type === "hello_error");
await duplicate.sendMessage({ type: "hello", version: PROTOCOL_VERSION });
expect(await duplicateError).toMatchObject({ type: "hello_error", error: { code: "invalid_request" } });
await duplicate.waitForClose();
});
test("closes connections that do not complete hello before the timeout", async () => {
const { server } = await startServer(new TestServerService(), { handshakeTimeoutMs: 20 });
const client = await connect(server);
await client.waitForClose();
expect(client.messages).toContainEqual(
expect.objectContaining({ type: "hello_error", error: expect.objectContaining({ code: "invalid_request" }) }),
);
});
test("keeps the handshake timeout active until the server hello is sent", async () => {
describe("list and attach protocol", () => {
test("handshake identifies the logical service without listing sessions", async () => {
const service = new TestServerService();
await service.seed();
const client = connect(createServer(service));
expect(await client.hello()).toMatchObject({ type: "hello", serviceId: "service-1" });
expect(service.harnesses.size).toBe(0);
});
test("list returns SessionRepo metadata without opening a session", async () => {
const service = new TestServerService();
const metadata = await service.seed("session-1", "parent-1");
const client = connect(createServer(service));
await client.hello();
await expect(client.request("service-1", { method: "list", args: [] })).resolves.toEqual({
type: "response",
id: "request-1",
ok: true,
result: [metadata],
});
expect(service.harnesses.size).toBe(0);
});
test("attach opens the Session and creates one hosted Harness", async () => {
const service = new TestServerService();
await service.seed("session-1");
const server = createServer(service);
const first = connect(server);
const second = connect(server);
await Promise.all([first.hello(), second.hello()]);
const delay = service.delayNextList();
const { server } = await startServer(service, { handshakeTimeoutMs: 20 });
const client = await connect(server);
await client.sendMessage({ type: "hello", version: PROTOCOL_VERSION });
const firstAttach = first.request("service-1", { method: "attach", args: ["session-1"] });
await delay.entered.promise;
await client.waitForClose();
const secondAttach = second.request("service-1", { method: "attach", args: ["session-1"] });
delay.release.resolve(undefined);
expect(client.messages).toContainEqual(
expect.objectContaining({ type: "hello_error", error: expect.objectContaining({ code: "invalid_request" }) }),
);
await expect(Promise.all([firstAttach, secondAttach])).resolves.toMatchObject([
{ ok: true, result: { sessionId: "session-1" } },
{ ok: true, result: { sessionId: "session-1" } },
]);
expect(service.harnesses.get("session-1")).toHaveLength(1);
expect(server.hostedSessions.map(({ sessionId }) => sessionId)).toEqual(["session-1"]);
});
test("bounds and closes malformed or oversized frames", async () => {
const malformedServer = await startServer();
const malformed = await connect(malformedServer.server);
const malformedError = malformed.next((message) => message.type === "hello_error");
await malformed.sendBytes(encodeFrame(Uint8Array.of(0xff)));
expect(await malformedError).toMatchObject({
type: "hello_error",
error: { code: "invalid_request" },
});
await malformed.waitForClose();
const boundedServer = await startServer(new TestServerService(), { maxFrameLength: 128 });
const oversized = await connect(boundedServer.server);
const frame = new Uint8Array(4 + 129);
frame[3] = 129;
await oversized.sendBytes(frame);
await oversized.waitForClose();
expect(oversized.messages.some((message) => message.type === "hello")).toBe(false);
const outboundServer = await startServer(new TestServerService(), { maxFrameLength: 128 });
const outbound = await connect(outboundServer.server);
await outbound.sendMessage({ type: "hello", version: PROTOCOL_VERSION });
await outbound.waitForClose();
expect(outbound.messages).toEqual([]);
});
test("catches up a handshaking client after a concurrent server change", async () => {
class RacingService extends TestServerService {
readonly entered = new Deferred<void>();
readonly release = new Deferred<void>();
race = false;
override async listSessions() {
const sessions = await super.listSessions();
if (!this.race) return sessions;
this.entered.resolve(undefined);
await this.release.promise;
return sessions;
}
}
const service = new RacingService();
service.seed("shared");
const { server } = await startServer(service);
const controller = await connect(server);
await controller.hello();
service.race = true;
const joining = await connect(server);
const hello = joining.hello();
await service.entered.promise;
await controller.request({ command: "attach", sessionId: "shared" });
service.release.resolve(undefined);
const handshake = await hello;
if (handshake.type !== "hello") throw new Error("Expected server hello");
const catchup = await joining.next(
(message) =>
message.type === "event" &&
message.event.type === "server_snapshot" &&
message.event.snapshot.revision > handshake.snapshot.revision,
);
expect(catchup).toMatchObject({
type: "event",
event: {
type: "server_snapshot",
snapshot: { sessions: [{ id: "shared", sessionName: "Session shared" }] },
},
});
});
test("shares request, event, attachment, and disconnect behavior", async () => {
test("rejects requests addressed to another service before repository access", async () => {
const service = new TestServerService();
service.seed("first");
service.seed("second");
const { server } = await startServer(service);
const client = await connect(server);
expect(await client.hello()).toMatchObject({
type: "hello",
snapshot: { sessions: [{ id: "first" }, { id: "second" }] },
});
await service.seed("session-1");
const client = connect(createServer(service));
await client.hello();
const listed = await client.request({ command: "list" });
expect(listed).toMatchObject({
ok: true,
result: { command: "list", sessions: [{ id: "first" }, { id: "second" }] },
});
expect(await client.request({ command: "attach", sessionId: "first" })).toMatchObject({
ok: true,
result: { command: "attach", session: { id: "first", attached: true } },
});
expect(await client.request({ command: "attach", sessionId: "second" })).toMatchObject({
ok: true,
result: { command: "attach", session: { id: "second", attached: true } },
await expect(client.request("other", { method: "attach", args: ["session-1"] })).resolves.toMatchObject({
ok: false,
error: { code: "wrong_service" },
});
expect(service.harnesses.size).toBe(0);
});
const progress = {
type: "assistant_delta" as const,
messageId: "assistant-1",
contentIndex: 0,
kind: "text" as const,
delta: "hello",
};
const progressEvent = client.next(
(message) => message.type === "event" && message.event.type === "session_progress",
);
service.latestRuntime("first").emitProgress(progress);
expect(await progressEvent).toEqual({
type: "event",
event: { type: "session_progress", sessionId: "first", progress },
});
test("reports an unknown session without creating a Harness", async () => {
const service = new TestServerService();
const client = connect(createServer(service));
await client.hello();
expect(await client.request({ command: "detach", sessionId: "first" })).toMatchObject({
ok: true,
result: { command: "detach", sessionId: "first" },
await expect(client.request("service-1", { method: "attach", args: ["missing"] })).resolves.toMatchObject({
ok: false,
error: { code: "session_not_found" },
});
expect(service.latestRuntime("first").disposeCount).toBe(1);
expect(
await client.request({ command: "set_thinking", sessionId: "second", thinkingLevel: "high" }),
).toMatchObject({ ok: true, result: { session: { id: "second", thinkingLevel: "high" } } });
expect(service.harnesses.size).toBe(0);
});
test("connection loss does not close a hosted Harness, but server shutdown does", async () => {
const service = new TestServerService();
await service.seed("session-1");
const server = createServer(service);
const client = connect(server);
await client.hello();
await client.request("service-1", { method: "attach", args: ["session-1"] });
const harness = service.latestHarness("session-1");
const secondRuntime = service.latestRuntime("second");
await client.close();
await secondRuntime.disposed.promise;
expect(secondRuntime.disposeCount).toBe(1);
});
test("disconnects attached clients when a runtime reports a terminal error", async () => {
const service = new TestServerService();
service.seed("terminal");
const errors: Error[] = [];
const { server } = await startServer(service, { onError: (error) => errors.push(error) });
const client = await connect(server);
await client.hello();
await client.request({ command: "attach", sessionId: "terminal" });
const runtime = service.latestRuntime("terminal");
runtime.setPhase("turn");
runtime.emitError(new PiServerError("session_locked", "lock ownership lost"));
await client.waitForClose();
await runtime.disposed.promise;
expect(runtime.disposeCount).toBe(1);
expect(service.locked.has("terminal")).toBe(false);
expect(errors).toContainEqual(expect.objectContaining({ code: "session_locked" }));
const nextClient = await connect(server);
await nextClient.hello();
expect(await nextClient.request({ command: "attach", sessionId: "terminal" })).toMatchObject({
ok: true,
result: { command: "attach", session: { id: "terminal" } },
});
expect(service.latestRuntime("terminal")).not.toBe(runtime);
});
test("does not expose unexpected service errors to clients", async () => {
class FailingService extends TestServerService {
private listCount = 0;
override async listSessions() {
this.listCount += 1;
if (this.listCount > 1) throw new Error("private service detail");
return super.listSessions();
}
}
const errors: Error[] = [];
const service = new FailingService();
const { server } = await startServer(service, {
onError: (error) => {
errors.push(error);
throw new Error("observer failure");
},
});
const client = await connect(server);
await client.hello();
expect(await client.request({ command: "list" })).toMatchObject({
ok: false,
error: { code: "internal_error", message: "Internal server error" },
});
expect(errors).toContainEqual(expect.objectContaining({ message: "private service detail" }));
});
test("keeps not_implemented stable", async () => {
class IncompleteService extends TestServerService {
private listCount = 0;
override async listSessions() {
this.listCount += 1;
if (this.listCount > 1) throw new NotImplementedError();
return super.listSessions();
}
}
const { server } = await startServer(new IncompleteService());
const client = await connect(server);
await client.hello();
expect(await client.request({ command: "list" })).toMatchObject({
ok: false,
error: { code: "not_implemented", message: "Operation is not implemented" },
});
});
test("reports wrapped internal causes without exposing them", async () => {
const cause = new Error("private storage detail", { cause: new Error("private root cause") });
class WrappedFailureService extends TestServerService {
private listCount = 0;
override async listSessions() {
this.listCount += 1;
if (this.listCount > 1) throw new InternalServerError(cause);
return super.listSessions();
}
}
const errors: Error[] = [];
const { server } = await startServer(new WrappedFailureService(), { onError: (error) => errors.push(error) });
const client = await connect(server);
await client.hello();
const response = await client.request({ command: "list" });
expect(response).toMatchObject({
ok: false,
error: { code: "internal_error", message: "Internal server error" },
});
expect(JSON.stringify(response)).not.toContain("private");
expect(errors).toContain(cause);
expect(errors).not.toContainEqual(expect.objectContaining({ name: "InternalServerError" }));
});
test("can respond out of request order after the handshake", async () => {
const service = new TestServerService();
service.seed("first");
const { server } = await startServer(service);
const client = await connect(server);
await client.hello();
const delay = service.delayNextList();
const slow = client.request({ command: "list" }, "slow");
await delay.entered.promise;
const fast = client.request({ command: "attach", sessionId: "first" }, "fast");
expect(await fast).toMatchObject({ ok: true, id: "fast", result: { command: "attach" } });
expect(client.messages.some((message) => message.type === "response" && message.id === "slow")).toBe(false);
delay.release.resolve(undefined);
expect(await slow).toMatchObject({ ok: true, id: "slow", result: { command: "list" } });
const responseIds = client.messages
.filter((message) => message.type === "response" && (message.id === "slow" || message.id === "fast"))
.map((message) => (message.type === "response" ? message.id : ""));
expect(responseIds).toEqual(["fast", "slow"]);
});
test("gracefully closes connections, sessions, and listener resources", async () => {
const service = new TestServerService();
service.seed("first");
const { server } = await startServer(service);
const socketPath = server.addresses[0];
const client = await connect(server);
await client.hello();
await client.request({ command: "attach", sessionId: "first" });
const runtime = service.latestRuntime("first");
const clientClosed = client.waitForClose();
await server.close();
await clientClosed;
expect(runtime.disposeCount).toBe(1);
expect(server.addresses).toEqual([]);
if (socketPath) await expect(lstat(socketPath)).rejects.toMatchObject({ code: "ENOENT" });
expect(harness.closeCount).toBe(0);
await server.close();
expect(harness.closeCount).toBe(1);
});
});
test("Unix socket decodes multiple framed requests from one raw chunk", async () => {
const { server } = await startServer();
const client = await connect(server);
await client.hello();
const first = encodeClientMessage({ type: "request", id: "first", request: { command: "list" } });
const second = encodeClientMessage({ type: "request", id: "second", request: { command: "list" } });
const combined = new Uint8Array(first.byteLength + second.byteLength);
combined.set(first);
combined.set(second, first.byteLength);
const firstResponse = client.next((message) => message.type === "response" && message.id === "first");
const secondResponse = client.next((message) => message.type === "response" && message.id === "second");
await client.sendBytes(combined);
expect(await firstResponse).toMatchObject({ type: "response", id: "first", ok: true });
expect(await secondResponse).toMatchObject({ type: "response", id: "second", ok: true });
});

View file

@ -1,301 +1,89 @@
import type { Api, AssistantMessage, Model, ToolCall, ToolResultMessage, UserMessage } from "@earendil-works/pi-ai";
import { encodeServerMessage, PROTOCOL_VERSION } from "@earendil-works/pi-protocol";
import { describe, expect, test } from "vitest";
import {
sanitizeProtocolDetails,
toProtocolAssistantMessage,
toProtocolJsonValue,
toProtocolModelMetadata,
toProtocolToolResultMessage,
toProtocolUserMessage,
} from "../src/protocol.ts";
import { encodeClientMessage } from "@earendil-works/pi-protocol";
import { afterEach, expect, test } from "vitest";
import type { ByteConnection, ByteConnectionHandler } from "../src/connection.ts";
import { PiServer } from "../src/server.ts";
import { ProtocolTestClient, TestServerService, type WireChannel } from "../src/testing/index.ts";
const model = {
id: "model-1",
name: "Model One",
api: "test-api",
provider: "test-provider",
baseUrl: "https://example.test",
reasoning: true,
input: ["text", "image"],
cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0.2 },
contextWindow: 100_000,
maxTokens: 10_000,
} satisfies Model<Api>;
let server: PiServer | undefined;
type ProtocolTranscriptItem =
| ReturnType<typeof toProtocolAssistantMessage>
| ReturnType<typeof toProtocolUserMessage>
| ReturnType<typeof toProtocolToolResultMessage>;
function assertValidServerPayload(item: ProtocolTranscriptItem): void {
expect(() =>
encodeServerMessage({
type: "hello",
version: PROTOCOL_VERSION,
connectionId: "connection-1",
snapshot: {
serverId: "server-1",
protocolVersion: PROTOCOL_VERSION,
revision: 0,
sessions: [
{
id: "session-1",
createdAt: 1,
updatedAt: 1,
sessionName: "Session one",
cwd: "/workspace",
},
],
models: [toProtocolModelMetadata(model, true)],
},
}),
).not.toThrow();
expect(() =>
encodeServerMessage({
type: "event",
event: {
type: "session_snapshot",
snapshot: {
id: "session-1",
cwd: "/workspace",
createdAt: 1,
updatedAt: 1,
phase: "idle",
model: { provider: "test-provider", id: "model-1" },
thinkingLevel: "off",
attached: true,
locked: true,
revision: 1,
transcript: [item],
queuedSteer: [],
queuedSteerCount: 0,
},
},
}),
).not.toThrow();
function connect(): ProtocolTestClient {
server = new PiServer(new TestServerService(), { listeners: [], serviceId: "service-1" });
let handler: ByteConnectionHandler;
let client: ProtocolTestClient;
let closed = false;
const connection: ByteConnection = {
get closed() {
return closed;
},
async send(chunk) {
client.receive(chunk);
},
close(finalChunk) {
if (finalChunk) client.receive(finalChunk);
closed = true;
client.markClosed();
},
};
const channel: WireChannel = {
async send(chunk) {
handler.onData(chunk);
},
async sendFragmented(chunk, splitAt) {
handler.onData(chunk.subarray(0, splitAt));
handler.onData(chunk.subarray(splitAt));
},
async close() {
closed = true;
handler.onClose();
client.markClosed();
},
};
client = new ProtocolTestClient(channel);
handler = server.accept(connection);
return client;
}
describe("pi-ai protocol bridge", () => {
test("maps model metadata and produces protocol-valid output", () => {
const result = toProtocolModelMetadata(model, true);
expect(result).toMatchObject({
provider: "test-provider",
id: "model-1",
api: "test-api",
input: ["text", "image"],
authenticated: true,
});
expect(result.supportedThinkingLevels).toContain("off");
});
test("exhaustively maps assistant content and stop reasons", () => {
const message = {
role: "assistant",
content: [
{ type: "text", text: "hello" },
{ type: "thinking", thinking: "hmm", redacted: false },
{ type: "toolCall", id: "call-1", name: "read", arguments: { path: "README.md" } },
],
api: "test-api",
provider: "test-provider",
model: "model-1",
usage: {
input: 1,
output: 2,
cacheRead: 3,
cacheWrite: 4,
totalTokens: 10,
cost: { input: 0.1, output: 0.2, cacheRead: 0.3, cacheWrite: 0.4, total: 1 },
},
stopReason: "toolUse",
timestamp: 123,
} satisfies AssistantMessage;
const result = toProtocolAssistantMessage(message, { id: "message-1" });
expect(result).toMatchObject({
id: "message-1",
status: "complete",
stopReason: "toolUse",
model: { provider: "test-provider", id: "model-1" },
});
expect(result.content).toEqual([
{ type: "text", text: "hello" },
{ type: "thinking", thinking: "hmm", redacted: false },
{ type: "toolCall", toolCallId: "call-1", toolName: "read", input: { path: "README.md" } },
]);
assertValidServerPayload(result);
});
test("maps user and tool messages without leaking non-JSON details", () => {
const user = {
role: "user",
content: "hello",
timestamp: 1,
} satisfies UserMessage;
const circular: Record<string, unknown> = {};
circular.self = circular;
const tool = {
role: "toolResult",
toolCallId: "call-1",
toolName: "read",
content: [{ type: "text", text: "result" }],
details: circular,
isError: false,
timestamp: 2,
} satisfies ToolResultMessage;
const call = {
type: "toolCall",
id: "call-1",
name: "read",
arguments: { path: "README.md" },
} satisfies ToolCall;
const userResult = toProtocolUserMessage(user, { id: "user-1" });
expect(userResult).toMatchObject({
id: "user-1",
content: [{ type: "text", text: "hello" }],
});
assertValidServerPayload(userResult);
const toolResult = toProtocolToolResultMessage(tool, {
id: "tool-1",
call,
});
expect(toolResult).toMatchObject({
id: "tool-1",
toolName: "read",
input: { path: "README.md" },
details: { self: "[Circular]" },
status: "complete",
});
assertValidServerPayload(toolResult);
});
test("rejects tool results associated with a different call", () => {
const call = {
type: "toolCall",
id: "call-1",
name: "read",
arguments: { path: "README.md" },
} satisfies ToolCall;
const result = {
role: "toolResult",
toolCallId: "call-2",
toolName: "read",
content: [{ type: "text", text: "result" }],
isError: false,
timestamp: 2,
} satisfies ToolResultMessage;
expect(() => toProtocolToolResultMessage(result, { id: "tool-1", call })).toThrow(/tool call/i);
expect(() =>
toProtocolToolResultMessage({ ...result, toolCallId: "call-1", toolName: "write" }, { id: "tool-1", call }),
).toThrow(/tool call/i);
});
test("derives streaming status from a pending stop reason", () => {
const message = {
role: "assistant",
content: [{ type: "text", text: "partial" }],
api: "test-api",
provider: "test-provider",
model: "model-1",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "pending",
timestamp: 123,
} satisfies AssistantMessage;
const result = toProtocolAssistantMessage(message, { id: "message-pending" });
expect(result).toMatchObject({ status: "streaming" });
expect(result).not.toHaveProperty("stopReason");
assertValidServerPayload(result);
});
test("preserves optional non-empty assistant error messages", () => {
const message = {
role: "assistant",
content: [],
api: "test-api",
provider: "test-provider",
model: "model-1",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "error",
timestamp: 123,
} satisfies AssistantMessage;
const resultWithoutMessage = toProtocolAssistantMessage(message, { id: "message-error" });
expect(resultWithoutMessage).toMatchObject({ status: "error", stopReason: "error" });
expect(resultWithoutMessage).not.toHaveProperty("errorMessage");
assertValidServerPayload(resultWithoutMessage);
expect(() => toProtocolAssistantMessage({ ...message, errorMessage: "" }, { id: "message-error" })).toThrow(
TypeError,
);
const resultWithMessage = toProtocolAssistantMessage(
{ ...message, errorMessage: "failed" },
{ id: "message-error" },
);
expect(resultWithMessage).toMatchObject({ status: "error", stopReason: "error", errorMessage: "failed" });
assertValidServerPayload(resultWithMessage);
});
test("rejects invalid source identifiers and timestamps", () => {
const message = {
role: "assistant",
content: [{ type: "toolCall", id: "", name: "read", arguments: {} }],
api: "test-api",
provider: "test-provider",
model: "model-1",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
timestamp: 1,
} satisfies AssistantMessage;
expect(() => toProtocolAssistantMessage(message, { id: "assistant-1" })).toThrow(/tool call id/i);
expect(() =>
toProtocolUserMessage({ role: "user", content: "hello", timestamp: Number.NaN }, { id: "user-1" }),
).toThrow(/timestamp/i);
});
test("rejects lossy tool input conversions", () => {
const circular: Record<string, unknown> = {};
circular.self = circular;
expect(() => toProtocolJsonValue(Number.POSITIVE_INFINITY)).toThrow(TypeError);
expect(() => toProtocolJsonValue(1n)).toThrow(TypeError);
expect(() => toProtocolJsonValue(undefined)).toThrow(TypeError);
expect(() => toProtocolJsonValue(circular)).toThrow(TypeError);
});
test("rejects sparse execution data and normalizes sparse diagnostic arrays", () => {
const sparse = new Array<unknown>(2);
sparse[1] = "value";
expect(() => toProtocolJsonValue(sparse)).toThrow(/undefined/i);
expect(sanitizeProtocolDetails(sparse)).toEqual([null, "value"]);
});
afterEach(async () => {
await server?.close();
server = undefined;
});
test("requires hello as the first message", async () => {
const client = connect();
await client.sendMessage({
type: "request",
id: "request-1",
serviceId: "service-1",
call: { method: "list", args: [] },
});
await expect(client.next((message) => message.type === "hello_error")).resolves.toMatchObject({
type: "hello_error",
error: { code: "invalid_request" },
});
await client.waitForClose();
});
test("rejects unsupported protocol versions", async () => {
const client = connect();
await expect(client.hello(2)).resolves.toMatchObject({ type: "hello_error", error: { code: "version" } });
await client.waitForClose();
});
test("accepts fragmented hello and request frames", async () => {
const client = connect();
const hello = encodeClientMessage({ type: "hello", version: 1 });
const helloResponse = client.next((message) => message.type === "hello");
await client.sendFragmentedMessage({ type: "hello", version: 1 }, Math.floor(hello.byteLength / 2));
await expect(helloResponse).resolves.toMatchObject({ type: "hello", serviceId: "service-1" });
const response = client.next((message) => message.type === "response");
const request = {
type: "request" as const,
id: "request-1",
serviceId: "service-1",
call: { method: "list" as const, args: [] as [] },
};
const frame = encodeClientMessage(request);
await client.sendFragmentedMessage(request, Math.floor(frame.byteLength / 2));
await expect(response).resolves.toMatchObject({ ok: true, result: [] });
});

View file

@ -25,26 +25,29 @@ afterEach(async () => {
tempDirectory = undefined;
});
test("requires explicit listeners", () => {
test("requires explicit listeners and service identity", () => {
expect(() => Reflect.construct(PiServer, [service, {}])).toThrow(/listeners/);
expect(() => new PiServer(service, { listeners: [], serviceId: "" })).toThrow(/serviceId/);
});
test("rejects Unix socket paths that cannot fit in sockaddr_un", () => {
expect(() => createUnixServer(service, { path: `/tmp/${"x".repeat(512)}` })).toThrow(/too long/);
expect(() => createUnixServer(service, { path: `/tmp/${"x".repeat(512)}`, serviceId: "service-1" })).toThrow(
/too long/,
);
});
test("rejects an overlong derived private Unix bind path", async () => {
const maxLength = process.platform === "linux" ? 107 : 103;
const suffixLength = Buffer.byteLength("/tmp//s");
const path = `/tmp/${"x".repeat(maxLength - suffixLength)}/s`;
server = createUnixServer(service, { path });
server = createUnixServer(service, { path, serviceId: "service-1" });
await expect(server.start()).rejects.toThrow(/private Unix bind path.*too long/);
});
test("rejects concurrent start calls without leaking the Unix listener", async () => {
const path = await makeSocketPath();
server = createUnixServer(service, { path });
server = createUnixServer(service, { path, serviceId: "service-1" });
const starting = server.start();
await expect(server.start()).rejects.toThrow(/starting/);
await starting;
@ -69,6 +72,7 @@ test("handshake timeout cleanup does not wait for a blocked output queue", async
}
const core = new PiServer(service, {
listeners: [],
serviceId: "service-1",
maxFrameLength: 1024,
handshakeTimeoutMs: 10,
});
@ -83,18 +87,18 @@ test("handshake timeout cleanup does not wait for a blocked output queue", async
});
test("rejects timeout values above Node's maximum timer delay", () => {
const unix = { path: "/tmp/pi-server-timeout-test.sock" };
expect(() => createUnixServer(service, { path: unix.path, handshakeTimeoutMs: 2_147_483_648 })).toThrow(
const path = "/tmp/pi-server-timeout-test.sock";
expect(() => createUnixServer(service, { path, serviceId: "service-1", handshakeTimeoutMs: 2_147_483_648 })).toThrow(
/handshakeTimeoutMs/,
);
expect(() => createUnixServer(service, { path: unix.path, gracefulCloseTimeoutMs: 2_147_483_648 })).toThrow(
/gracefulCloseTimeoutMs/,
);
expect(() =>
createUnixServer(service, { path, serviceId: "service-1", gracefulCloseTimeoutMs: 2_147_483_648 }),
).toThrow(/gracefulCloseTimeoutMs/);
});
test("rejects pending-byte limits smaller than one maximum frame", async () => {
const path = await makeSocketPath();
expect(() => createUnixServer(service, { path, maxFrameLength: 128, maxPendingBytes: 131 })).toThrow(
/maxPendingBytes/,
);
expect(() =>
createUnixServer(service, { path, serviceId: "service-1", maxFrameLength: 128, maxPendingBytes: 131 }),
).toThrow(/maxPendingBytes/);
});

View file

@ -1,429 +0,0 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ModelMetadata, SessionSnapshot, TranscriptProgress } from "@earendil-works/pi-protocol";
import { afterEach, describe, expect, test } from "vitest";
import type { CreateSessionOptions, PiServer, PiSessionRuntime } from "../src/index.ts";
import {
connectUnixTestClient,
Deferred,
type ProtocolTestClient,
TEST_MODEL,
TestServerService,
} from "../src/testing/index.ts";
import { createUnixServer, type UnixServerOptions } from "../src/transports/unix/index.ts";
const MODEL = TEST_MODEL;
type Client = ProtocolTestClient;
class MemoryService extends TestServerService {}
class OrderedSnapshotService extends MemoryService {
readonly firstStarted = new Deferred<void>();
readonly secondStarted = new Deferred<void>();
readonly firstRelease = new Deferred<void>();
readonly secondRelease = new Deferred<void>();
controlled = false;
startedCount = 0;
override async listModels(): Promise<ModelMetadata[]> {
if (!this.controlled) return super.listModels();
this.startedCount += 1;
if (this.startedCount === 1) {
this.firstStarted.resolve(undefined);
await this.firstRelease.promise;
} else if (this.startedCount === 2) {
this.secondStarted.resolve(undefined);
await this.secondRelease.promise;
}
return [MODEL];
}
}
const servers = new Set<PiServer>();
const clients = new Set<Client>();
const tempDirectories = new Set<string>();
async function startServer(service = new MemoryService(), options: Partial<UnixServerOptions> = {}) {
const directory = await mkdtemp(join(tmpdir(), "pst-"));
tempDirectories.add(directory);
const server = createUnixServer(service, {
path: join(directory, "server.sock"),
...options,
});
servers.add(server);
await server.start();
return { server, service };
}
async function connect(server: PiServer): Promise<Client> {
const client = await connectUnixTestClient(server.addresses[0]!);
clients.add(client);
return client;
}
async function attach(client: Client, sessionId: string): Promise<SessionSnapshot> {
const response = await client.request({ command: "attach", sessionId });
if (!response.ok || response.result.command !== "attach") throw new Error("Attach failed");
return response.result.session;
}
afterEach(async () => {
await Promise.all([...clients].map((client) => client.close()));
clients.clear();
await Promise.all([...servers].map((server) => server.close()));
servers.clear();
await Promise.all([...tempDirectories].map((directory) => rm(directory, { recursive: true, force: true })));
tempDirectories.clear();
});
describe("PiServer Unix integration", () => {
test("serializes server snapshot revisions", async () => {
const service = new OrderedSnapshotService();
const { server } = await startServer(service);
const client = await connect(server);
await client.hello();
service.controlled = true;
const messageIndex = client.messages.length;
const firstCreate = client.request({ command: "create", name: "first" });
await service.firstStarted.promise;
const secondCreate = client.request({ command: "create", name: "second" });
await Promise.resolve();
expect(service.startedCount).toBe(1);
service.firstRelease.resolve(undefined);
await service.secondStarted.promise;
service.secondRelease.resolve(undefined);
await Promise.all([firstCreate, secondCreate]);
await client.nextFrom(
messageIndex,
(message) =>
message.type === "event" &&
message.event.type === "server_snapshot" &&
message.event.snapshot.revision === 2,
);
const revisions = client.messages
.slice(messageIndex)
.flatMap((message) =>
message.type === "event" && message.event.type === "server_snapshot"
? [message.event.snapshot.revision]
: [],
);
expect(revisions).toEqual([1, 2]);
});
test("creates server-assigned durable IDs and supports list, attach, and detach", async () => {
const { server, service } = await startServer();
const client = await connect(server);
await client.hello();
const created = await client.request({ command: "create", cwd: "/work", name: "Created" });
expect(created.ok).toBe(true);
if (!created.ok || created.result.command !== "create") throw new Error("Create failed");
const createdId = created.result.session.id;
expect(created.result.session).toMatchObject({
id: service.lastCreatedId,
cwd: "/work",
name: "Created",
attached: true,
locked: true,
});
const listed = await client.request({ command: "list" });
if (!listed.ok || listed.result.command !== "list") throw new Error("List failed");
expect(listed.result.sessions).toEqual([
{
id: service.lastCreatedId,
createdAt: 1,
updatedAt: 1,
sessionName: "Created",
cwd: "/work",
},
]);
const detached = await client.request({ command: "detach", sessionId: createdId });
expect(detached).toMatchObject({ ok: true, result: { command: "detach", sessionId: createdId } });
expect(service.latestRuntime(createdId).disposeCount).toBe(1);
const detachedAgain = await client.request({ command: "detach", sessionId: createdId });
expect(detachedAgain).toMatchObject({ ok: true, result: { command: "detach", sessionId: createdId } });
const attached = await attach(client, createdId);
expect(attached.id).toBe(service.lastCreatedId);
expect(service.runtimes.get(createdId)?.length).toBe(2);
});
test("preserves backend metadata while refreshing live session metadata", async () => {
class ExtendedMetadataService extends MemoryService {
override async listSessions() {
return (await super.listSessions()).map((metadata) => ({
...metadata,
parentSessionId: "parent-1",
sessionName: "stale name",
}));
}
}
const service = new ExtendedMetadataService();
service.seed("session-1", "Live name");
const { server } = await startServer(service);
const client = await connect(server);
await client.hello();
await attach(client, "session-1");
const listed = await client.request({ command: "list" });
if (!listed.ok || listed.result.command !== "list") throw new Error("List failed");
expect(listed.result.sessions).toEqual([
{
id: "session-1",
createdAt: 1,
updatedAt: 1,
parentSessionId: "parent-1",
sessionName: "Live name",
cwd: "/tmp/pi-server-conformance",
},
]);
});
test("keeps multiple attachments on one connection independent", async () => {
const service = new MemoryService();
service.seed("first");
service.seed("second");
const { server } = await startServer(service);
const client = await connect(server);
await client.hello();
await attach(client, "first");
await attach(client, "second");
await client.request({ command: "detach", sessionId: "first" });
expect(service.latestRuntime("first").disposeCount).toBe(1);
expect(service.latestRuntime("second").disposeCount).toBe(0);
const response = await client.request({
command: "set_thinking",
sessionId: "second",
thinkingLevel: "medium",
});
expect(response).toMatchObject({ ok: true, result: { session: { id: "second", thinkingLevel: "medium" } } });
});
test("broadcasts full snapshots and progress only to clients attached to that session", async () => {
const service = new MemoryService();
service.seed();
const { server } = await startServer(service);
const attachedClient = await connect(server);
const unattachedClient = await connect(server);
await attachedClient.hello();
await unattachedClient.hello();
await attach(attachedClient, "session-1");
const runtime = service.latestRuntime("session-1");
const progress: TranscriptProgress = {
type: "assistant_delta",
messageId: "assistant-1",
contentIndex: 0,
kind: "text",
delta: "hello",
};
runtime.emitProgress(progress);
const progressMessage = await attachedClient.next(
(message) => message.type === "event" && message.event.type === "session_progress",
);
expect(progressMessage).toEqual({
type: "event",
event: { type: "session_progress", sessionId: "session-1", progress },
});
expect(
unattachedClient.messages.some(
(message) => message.type === "event" && message.event.type === "session_progress",
),
).toBe(false);
const messageCount = attachedClient.messages.length;
runtime.emitSnapshot();
const snapshotMessage = await attachedClient.nextFrom(
messageCount,
(message) =>
message.type === "event" &&
message.event.type === "session_snapshot" &&
message.event.snapshot.revision === runtime.snapshot().revision,
);
expect(snapshotMessage).toMatchObject({
type: "event",
event: { type: "session_snapshot", snapshot: { id: "session-1", attached: true, locked: true } },
});
expect(
unattachedClient.messages.some(
(message) => message.type === "event" && message.event.type === "session_snapshot",
),
).toBe(false);
});
test("allows every attached client to control a singleton live runtime", async () => {
const service = new MemoryService();
service.seed();
const { server } = await startServer(service);
const first = await connect(server);
const second = await connect(server);
await first.hello();
await second.hello();
await attach(first, "session-1");
const secondList = await second.request({ command: "list" });
if (!secondList.ok || secondList.result.command !== "list") throw new Error("List failed");
expect(secondList.result.sessions).toEqual([
{
id: "session-1",
createdAt: 1,
updatedAt: 1,
sessionName: "Session session-1",
cwd: "/tmp/pi-server-conformance",
},
]);
await attach(second, "session-1");
expect(service.runtimes.get("session-1")?.length).toBe(1);
const modelResponse = await second.request({
command: "set_model",
sessionId: "session-1",
model: { provider: "test", id: "large" },
});
expect(modelResponse).toMatchObject({ ok: true, result: { session: { model: { id: "large" } } } });
await first.next(
(message) =>
message.type === "event" &&
message.event.type === "session_snapshot" &&
message.event.snapshot.model.id === "large",
);
const thinkingResponse = await first.request({
command: "set_thinking",
sessionId: "session-1",
thinkingLevel: "high",
});
expect(thinkingResponse).toMatchObject({ ok: true, result: { session: { thinkingLevel: "high" } } });
});
test("does not queue prompts and processes steer and abort while a prompt response is pending", async () => {
const service = new MemoryService();
service.seed();
const { server } = await startServer(service);
const client = await connect(server);
await client.hello();
await attach(client, "session-1");
const prompt = client.request({ command: "prompt", sessionId: "session-1", text: "first" });
await client.next(
(message) =>
message.type === "event" &&
message.event.type === "session_snapshot" &&
message.event.snapshot.phase === "turn",
);
const busy = await client.request({ command: "prompt", sessionId: "session-1", text: "second" });
expect(busy).toMatchObject({ ok: false, error: { code: "busy" } });
const steer = await client.request({ command: "steer", sessionId: "session-1", text: "adjust" });
expect(steer).toMatchObject({ ok: true, result: { command: "steer" } });
expect(service.latestRuntime("session-1").steers).toEqual([{ text: "adjust" }]);
const abort = await client.request({ command: "abort", sessionId: "session-1" });
expect(abort).toMatchObject({ ok: true, result: { command: "abort" } });
expect(await prompt).toMatchObject({ ok: true, result: { command: "prompt", session: { phase: "idle" } } });
});
test("returns operation attachment state relative to the requesting connection", async () => {
const service = new MemoryService();
service.seed();
const { server } = await startServer(service);
const first = await connect(server);
const second = await connect(server);
await first.hello();
await second.hello();
await attach(first, "session-1");
await attach(second, "session-1");
const prompt = first.request({ command: "prompt", sessionId: "session-1", text: "hello" });
await first.next(
(message) =>
message.type === "event" &&
message.event.type === "session_snapshot" &&
message.event.snapshot.phase === "turn",
);
await first.request({ command: "detach", sessionId: "session-1" });
service.latestRuntime("session-1").finishPrompt();
expect(await prompt).toMatchObject({
ok: true,
result: { command: "prompt", session: { id: "session-1", attached: false } },
});
});
test("keeps busy work alive after disconnect and disposes when it next becomes idle", async () => {
const service = new MemoryService();
service.seed();
const { server } = await startServer(service);
const client = await connect(server);
await client.hello();
await attach(client, "session-1");
const prompt = client.request({ command: "prompt", sessionId: "session-1", text: "survive" });
await client.next(
(message) =>
message.type === "event" &&
message.event.type === "session_snapshot" &&
message.event.snapshot.phase === "turn",
);
const runtime = service.latestRuntime("session-1");
await client.close();
await expect(prompt).rejects.toThrow(Error);
expect(runtime.disposeCount).toBe(0);
runtime.finishPrompt();
await runtime.disposed.promise;
expect(runtime.disposeCount).toBe(1);
const reconnect = await connect(server);
await reconnect.hello();
const snapshot = await attach(reconnect, "session-1");
expect(snapshot.transcript).toHaveLength(2);
expect(snapshot.transcript[1]).toMatchObject({ role: "assistant", content: [{ text: "reply:survive" }] });
});
test("restores persisted sessions lazily after a server restart", async () => {
const service = new MemoryService();
service.seed();
const { server: firstServer } = await startServer(service);
const firstClient = await connect(firstServer);
await firstClient.hello();
await attach(firstClient, "session-1");
await firstClient.request({ command: "set_thinking", sessionId: "session-1", thinkingLevel: "high" });
await firstClient.close();
await firstServer.close();
const { server: secondServer } = await startServer(service);
expect(service.runtimes.get("session-1")).toHaveLength(1);
const secondClient = await connect(secondServer);
await secondClient.hello();
const restored = await attach(secondClient, "session-1");
expect(restored.thinkingLevel).toBe("high");
expect(service.runtimes.get("session-1")).toHaveLength(2);
});
test("rejects and disposes a service runtime with the wrong server-assigned ID", async () => {
class WrongIdService extends MemoryService {
override async createSession(options: CreateSessionOptions): Promise<PiSessionRuntime> {
return super.createSession({ ...options, id: "wrong-id" });
}
}
const service = new WrongIdService();
const { server } = await startServer(service);
const client = await connect(server);
await client.hello();
const response = await client.request({ command: "create" });
expect(response).toMatchObject({ ok: false, error: { code: "invalid_request" } });
expect(service.latestRuntime("wrong-id").disposeCount).toBe(1);
});
test("maps service lock errors and rejects control from unattached clients", async () => {
const service = new MemoryService();
service.seed("locked");
service.locked.add("locked");
const { server } = await startServer(service);
const client = await connect(server);
await client.hello();
const locked = await client.request({ command: "attach", sessionId: "locked" });
expect(locked).toMatchObject({ ok: false, error: { code: "session_locked" } });
const unattached = await client.request({ command: "abort", sessionId: "locked" });
expect(unattached).toMatchObject({ ok: false, error: { code: "invalid_request" } });
});
});

View file

@ -20,7 +20,7 @@ async function makeSocketPath(nested = false): Promise<string> {
}
function makeServer(path: string): PiServer {
const server = createUnixServer(new TestServerService(), { path });
const server = createUnixServer(new TestServerService(), { path, serviceId: "service-1" });
servers.add(server);
return server;
}

View file

@ -6,7 +6,11 @@
"moduleResolution": "NodeNext",
"types": ["node", "vitest"],
"paths": {
"@earendil-works/pi-protocol": ["../protocol/src/index.ts"]
"@earendil-works/pi-agent-core": ["../agent/src/index.ts"],
"@earendil-works/pi-ai": ["../ai/src/index.ts"],
"@earendil-works/pi-ai/*": ["../ai/src/*.ts", "../ai/src/providers/*.ts"],
"@earendil-works/pi-protocol": ["../protocol/src/index.ts"],
"@earendil-works/pi-telemetry": ["../telemetry/src/index.ts"]
}
},
"include": ["src/**/*.ts", "test/**/*.ts"]

View file

@ -9,6 +9,9 @@ export default defineConfig({
},
resolve: {
alias: {
"@earendil-works/pi-agent-core": fileURLToPath(new URL("../agent/src/index.ts", import.meta.url)),
"@earendil-works/pi-ai": fileURLToPath(new URL("../ai/src/index.ts", import.meta.url)),
"@earendil-works/pi-telemetry": fileURLToPath(new URL("../telemetry/src/index.ts", import.meta.url)),
"@earendil-works/pi-protocol": fileURLToPath(new URL("../protocol/src/index.ts", import.meta.url)),
},
},