feat(coding-agent): remove the experimental server stack and the client, protocol, and server packages

Deletes packages/client, packages/protocol (CBOR codec), packages/server, coding-agent's
src/experimental (except source-resolver.ts, which source runs still preload), src/cli/experimental,
src/client, the example plugin, and their tests. coding-agent no longer depends on @earendil-works/chord.
The installer lock drops esbuild, so its platform-package sanity check goes too.
This commit is contained in:
Mario Zechner 2026-10-12 10:19:42 +00:00
parent 2ee5c1e56d
commit 7472cda0d6
161 changed files with 32 additions and 21561 deletions

64
package-lock.json generated
View file

@ -677,10 +677,6 @@
"resolved": "packages/ai",
"link": true
},
"node_modules/@earendil-works/pi-client": {
"resolved": "packages/client",
"link": true
},
"node_modules/@earendil-works/pi-codemode": {
"resolved": "packages/codemode",
"link": true
@ -705,14 +701,6 @@
"resolved": "packages/mcp",
"link": true
},
"node_modules/@earendil-works/pi-protocol": {
"resolved": "packages/protocol",
"link": true
},
"node_modules/@earendil-works/pi-server": {
"resolved": "packages/server",
"link": true
},
"node_modules/@earendil-works/pi-telemetry": {
"resolved": "packages/telemetry",
"link": true
@ -5129,22 +5117,6 @@
"node": ">=22.19.0"
}
},
"packages/client": {
"name": "@earendil-works/pi-client",
"version": "1.1.0",
"license": "MIT",
"dependencies": {
"@earendil-works/chord": "^1.1.0",
"@earendil-works/pi-protocol": "^1.1.0"
},
"devDependencies": {
"shx": "0.3.4",
"vitest": "4.1.11"
},
"engines": {
"node": ">=22.19.0"
}
},
"packages/codemode": {
"name": "@earendil-works/pi-codemode",
"version": "1.1.0",
@ -5165,7 +5137,6 @@
"version": "1.1.0",
"license": "MIT",
"dependencies": {
"@earendil-works/chord": "^1.1.0",
"@earendil-works/pi-agent-core": "^1.1.0",
"@earendil-works/pi-ai": "^1.1.0",
"@earendil-works/pi-codemode": "^1.1.0",
@ -5193,9 +5164,6 @@
"pi": "dist/bundle/cli.js"
},
"devDependencies": {
"@earendil-works/pi-client": "^1.1.0",
"@earendil-works/pi-protocol": "^1.1.0",
"@earendil-works/pi-server": "^1.1.0",
"@types/cross-spawn": "6.0.6",
"@types/hosted-git-info": "3.0.5",
"@types/node": "22.19.19",
@ -5354,38 +5322,6 @@
"node": ">=22.19.0"
}
},
"packages/protocol": {
"name": "@earendil-works/pi-protocol",
"version": "1.1.0",
"license": "MIT",
"dependencies": {
"@earendil-works/chord": "^1.1.0",
"typebox": "1.3.27"
},
"devDependencies": {
"shx": "0.3.4",
"vitest": "4.1.11"
},
"engines": {
"node": ">=22.19.0"
}
},
"packages/server": {
"name": "@earendil-works/pi-server",
"version": "1.1.0",
"license": "MIT",
"dependencies": {
"@earendil-works/chord": "^1.1.0",
"@earendil-works/pi-protocol": "^1.1.0"
},
"devDependencies": {
"shx": "0.3.4",
"vitest": "4.1.11"
},
"engines": {
"node": ">=22.19.0"
}
},
"packages/telemetry": {
"name": "@earendil-works/pi-telemetry",
"version": "1.1.0",

View file

@ -13,8 +13,8 @@
],
"scripts": {
"clean": "npm run clean --workspaces",
"build": "cd packages/chord && npm run build && cd ../tui && npm run build && cd ../telemetry && npm run build && cd ../codemode && npm run build && cd ../mcp && npm run build && cd ../ai && npm run build && cd ../durable && npm run build && cd ../env && npm run build && cd ../agent && npm run build && cd ../protocol && npm run build && cd ../client && npm run build && cd ../server && npm run build && cd ../coding-agent && npm run build",
"build:offline": "cd packages/chord && npm run build && cd ../tui && npm run build && cd ../telemetry && npm run build && cd ../codemode && npm run build && cd ../mcp && npm run build && cd ../ai && npm run build:offline && cd ../durable && npm run build && cd ../env && npm run build && cd ../agent && npm run build && cd ../protocol && npm run build && cd ../client && npm run build && cd ../server && npm run build && cd ../coding-agent && npm run build",
"build": "cd packages/chord && npm run build && cd ../tui && npm run build && cd ../telemetry && npm run build && cd ../codemode && npm run build && cd ../mcp && npm run build && cd ../ai && npm run build && cd ../durable && npm run build && cd ../env && npm run build && cd ../agent && npm run build && cd ../coding-agent && npm run build",
"build:offline": "cd packages/chord && npm run build && cd ../tui && npm run build && cd ../telemetry && npm run build && cd ../codemode && npm run build && cd ../mcp && npm run build && cd ../ai && npm run build:offline && cd ../durable && npm run build && cd ../env && npm run build && cd ../agent && npm run build && cd ../coding-agent && npm run build",
"build:native:darwin": "npm --prefix packages/tui run build:native:darwin",
"build:native:linux": "npm --prefix packages/tui run build:native:linux",
"build:native:win32": "npm --prefix packages/tui run build:native:win32",

View file

@ -1,51 +0,0 @@
# Changelog
## [Unreleased]
## [1.1.0] - 2026-10-07
## [1.0.4] - 2026-10-05
## [1.0.3] - 2026-10-05
## [1.0.2] - 2026-10-04
## [1.0.1] - 2026-10-03
## [1.0.0] - 2026-10-01
## [0.99.2] - 2026-09-30
## [0.99.1] - 2026-09-29
## [0.99.0] - 2026-09-29
## [0.87.1] - 2026-09-22
## [0.87.0] - 2026-09-21
## [0.86.1] - 2026-09-20
## [0.86.0] - 2026-09-19
## [0.85.1] - 2026-09-05
## [0.85.0] - 2026-09-04
## [0.84.4] - 2026-08-28
## [0.84.3] - 2026-08-24
## [0.84.2] - 2026-08-14
## [0.84.1] - 2026-08-07
## [0.84.0] - 2026-08-06
### Breaking Changes
- Replaced `SessionSummary` with durable `SessionMetadata` for `PiClient.listSessions()` and server snapshots; runtime state is available only from acquired session snapshots ([#7708](https://github.com/earendil-works/pi/pull/7708)).
### Added
- Added the experimental transport-neutral `PiClient` and multi-session `PiSessionHandle` APIs with structured `PiServerError` responses.

View file

@ -1,72 +0,0 @@
# @earendil-works/pi-client
Transport-neutral client for the experimental Pi service protocol.
```ts
import { Client, type ByteTransportFactory } from "@earendil-works/pi-client";
const transportFactory: ByteTransportFactory = async (handlers) => {
// Connect using WebSocket, Unix socket, or another ordered byte transport.
return {
async send(chunk) {
// Deliver bytes in invocation order and honor backpressure.
},
close() {},
};
};
const client = await Client.connect({
serverId: "01234567-89ab-4def-8123-456789abcdef",
transportFactory,
});
const result = await client.request(
{ serverId: client.hello.serverId },
{ serviceId: "example.service", member: "read", args: [] },
);
```
The client verifies that the physical endpoint reports the expected logical `serverId`. Server-wide requests carry that ID, and every Session request carries the full live target `{ serverId, sessionId, attachmentId }`. The combined durable address prevents cross-server or cross-session misrouting; the server-generated attachment ID rejects delayed frames after switching or reattaching.
Typed server and Session APIs are provided by Chord service bindings owned by the application. `createClientServiceTransport()` adapts a lazily resolved server or Session route to a Chord transport; `request()` and `subscribeService()` remain its low-level primitives. The client uses Chord's service-control parsers and per-subscription state decoder; `pi-protocol` only validates the routed envelope and strict-JSON boundary. A service subscription returns a complete provider snapshot; the binding installs it and then calls `start()` to release updates buffered during hydration. `Client` applies ordered out-of-band attachment changes but deliberately does not construct typed service proxies or interpret application contracts.
Application observation APIs such as the coding agent's `Transcript` are ordinary Chord services. The client does not interpret their snapshots or updates.
On disconnect or disposal, pending requests reject locally, but accepted work may still complete remotely before the attachment is released. The client clears its live attachment route. It never reconnects or replays requests automatically. After disconnection, call `reconnect()`, attach through the application's management service again, and explicitly repeat only operations known to be safe.
The experimental local coordinator only provides a stable endpoint and relays traffic. Replaceable server processes own Session and worker lifecycle outside the public client protocol.
Call transport handlers as follows:
- `handlers.onData(chunk)` for inbound bytes;
- `handlers.onClose()` for an orderly terminal close;
- `handlers.onError(error)` for transport failures.
A transport factory creates a fresh authenticated connection for each attempt. Requests are correlated by ID, and server failures are exposed as `ServerError`.
## Unix-domain sockets
Node.js and Bun consumers can use the separate Unix transport:
```ts
import { Client } from "@earendil-works/pi-client";
import { createUnixTransportFactory } from "@earendil-works/pi-client/unix";
const client = new Client({
serverId: "01234567-89ab-4def-8123-456789abcdef",
transportFactory: createUnixTransportFactory({ path: "/tmp/pi.sock" }),
});
await client.connect();
```
Unix discovery scans an explicit physical-route directory, derives each expected server ID from its filename, and verifies it through the existing handshake:
```ts
import { discoverUnixServers } from "@earendil-works/pi-client/unix";
const routes = await discoverUnixServers({ directory: "/run/user/1000/pi" });
// [{ serverId: "...", path: "/run/user/1000/pi/<serverId>.sock" }]
```
Malformed entries, non-sockets, stale or unresponsive endpoints, and server-ID mismatches are ignored. Discovery is read-only and probes at most 16 sockets concurrently. Unexpected filesystem and socket errors reject discovery. Pass `timeoutMs` to override the default probe timeout.
`ClientOptions.maxFrameLength` bounds protocol payloads. `maxPendingBytes` bounds queued Unix transport output. Configure matching limits on both peers.

View file

@ -1,57 +0,0 @@
{
"name": "@earendil-works/pi-client",
"version": "1.1.0",
"description": "Transport-neutral client for remote pi sessions over framed CBOR bytes",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./unix": {
"types": "./dist/unix.d.ts",
"import": "./dist/unix.js"
},
"./package.json": "./package.json"
},
"sideEffects": false,
"files": [
"dist",
"README.md",
"CHANGELOG.md"
],
"scripts": {
"clean": "shx rm -rf dist",
"build": "tsc -p tsconfig.build.json",
"test": "vitest --run",
"typecheck": "tsc -p tsconfig.test.json",
"prepublishOnly": "npm run clean && npm run build"
},
"keywords": [
"pi",
"client",
"protocol",
"cbor",
"binary"
],
"author": "Earendil Works",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/earendil-works/pi.git",
"directory": "packages/client"
},
"engines": {
"node": ">=22.19.0"
},
"dependencies": {
"@earendil-works/chord": "^1.1.0",
"@earendil-works/pi-protocol": "^1.1.0"
},
"devDependencies": {
"shx": "0.3.4",
"vitest": "4.1.11"
}
}

View file

@ -1,479 +0,0 @@
import {
createServiceCatalogueCall,
createServiceStateDecoder,
createServiceSubscribeCall,
createServiceUnsubscribeCall,
type JsonValue,
parseServiceCall,
parseServiceCatalogue,
parseWireServiceProviderUpdate,
parseWireServiceSubscriptionSnapshot,
type RemoteServiceTransport,
type ServiceCall,
type ServiceCatalogueEntry,
type ServiceMode,
type ServiceProviderUpdate,
type ServiceStateDecoder,
type ServiceSubscriptionSnapshot,
} from "@earendil-works/chord";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import {
type AttachmentEnvelope,
encodeClientMessage,
isServerId,
ProtocolValidationError,
type ResponseEnvelope,
type RpcTarget,
type ServerHello,
type ServiceEventEnvelope,
type SessionTarget,
} from "@earendil-works/pi-protocol";
import { Connection } from "./connection.ts";
import { ClientDisposedError, DisconnectedError, ServerError, toError } from "./errors.ts";
import { createPromiseResolvers } from "./promise.ts";
import type {
AttachmentChangeListener,
ClientOptions,
ConnectionState,
ConnectionStateChange,
ServiceSubscription,
Unsubscribe,
} from "./types.ts";
type ServiceResult = JsonValue | undefined;
interface PendingRequest {
resolve(result: ServiceResult): void;
reject(error: Error): void;
cleanup(): void;
}
interface ActiveServiceListener {
readonly target: RpcTarget;
readonly listener: (update: ServiceProviderUpdate) => void | Promise<void>;
readonly decoder: ServiceStateDecoder;
readonly queuedWireUpdates: JsonValue[];
readonly queued: ServiceProviderUpdate[];
deliveryTail: Promise<void>;
hydrated: boolean;
ready: boolean;
}
export class Client {
readonly #options: ClientOptions;
readonly #connection: Connection;
readonly #pendingRequests = new Map<string, PendingRequest>();
readonly #connectionStateListeners = new Set<(change: ConnectionStateChange) => void>();
readonly #attachmentListeners = new Set<AttachmentChangeListener>();
readonly #serviceListeners = new Map<string, ActiveServiceListener>();
#requestSequence = 0;
#serviceSubscriptionSequence = 0;
#hello: ServerHello | undefined;
#attachment: SessionTarget | undefined;
#disposed = false;
#disposePromise: Promise<void> | undefined;
constructor(options: ClientOptions) {
if (!isServerId(options.serverId)) {
throw new TypeError("serverId must be a canonical lowercase UUIDv4");
}
this.#options = options;
this.#connection = new Connection({
transportFactory: options.transportFactory,
serverId: options.serverId,
maxFrameLength: options.maxFrameLength,
onHandshake: (hello) => {
this.#hello = hello;
},
onMessage: (message) => this.#handleMessage(message),
onStateChange: (change) => this.#handleConnectionStateChange(change),
});
}
get disposed(): boolean {
return this.#disposed;
}
get connectionState(): ConnectionState {
return this.#connection.state;
}
get connected(): boolean {
return this.#connection.state === "connected";
}
get serverId(): string {
return this.#options.serverId;
}
get hello(): ServerHello | undefined {
return this.#hello;
}
get attachment(): SessionTarget | undefined {
return this.#attachment;
}
static async connect(options: ClientOptions): Promise<Client> {
const client = new Client(options);
try {
await client.connect();
return client;
} catch (error) {
await client.dispose();
throw error;
}
}
connect(): Promise<ServerHello> {
if (this.#disposed) return Promise.reject(new ClientDisposedError());
this.#hello = undefined;
return this.#connection.connect();
}
reconnect(): Promise<ServerHello> {
return this.connect();
}
disconnect(reason = "Client disconnected"): void {
this.#connection.disconnect(reason);
}
onConnectionStateChange(listener: (change: ConnectionStateChange) => void): Unsubscribe {
this.#assertNotDisposed();
this.#connectionStateListeners.add(listener);
return () => this.#connectionStateListeners.delete(listener);
}
onAttachmentChange(listener: AttachmentChangeListener): Unsubscribe {
this.#assertNotDisposed();
this.#attachmentListeners.add(listener);
return () => this.#attachmentListeners.delete(listener);
}
/** Invoke one low-level protocol call against an explicit routed target. */
request(target: RpcTarget, call: ServiceCall, signal?: AbortSignal): Promise<ServiceResult> {
return this.#request(target, call, signal);
}
async serviceCatalogue(target: RpcTarget, signal?: AbortSignal): Promise<readonly ServiceCatalogueEntry[]> {
const result = await this.#request(target, createServiceCatalogueCall(), signal);
try {
return parseServiceCatalogue(result);
} catch (error) {
const validationError = new ProtocolValidationError(
error instanceof Error ? error.message : "Invalid service catalogue",
);
this.#connection.fail(validationError);
throw validationError;
}
}
async subscribeService(
target: RpcTarget,
serviceId: string,
mode: ServiceMode,
listener: (update: ServiceProviderUpdate) => void | Promise<void>,
signal?: AbortSignal,
): Promise<ServiceSubscription> {
const subscriptionId = `service-${++this.#serviceSubscriptionSequence}`;
const active: ActiveServiceListener = {
target,
listener,
decoder: createServiceStateDecoder(),
queuedWireUpdates: [],
queued: [],
deliveryTail: Promise.resolve(),
hydrated: false,
ready: false,
};
this.#serviceListeners.set(subscriptionId, active);
let snapshot: ServiceSubscriptionSnapshot;
try {
snapshot = await this.#request(
target,
createServiceSubscribeCall(subscriptionId, serviceId, mode),
signal,
(result) => {
const decoded = active.decoder.decodeSnapshot(parseWireServiceSubscriptionSnapshot(result));
active.hydrated = true;
for (const update of active.queuedWireUpdates.splice(0)) {
active.queued.push(active.decoder.decodeUpdate(parseWireServiceProviderUpdate(update)));
}
return decoded;
},
);
} catch (error) {
if (this.#serviceListeners.get(subscriptionId) === active) this.#serviceListeners.delete(subscriptionId);
throw error;
}
if (this.#serviceListeners.get(subscriptionId) !== active) throw new DisconnectedError();
let disposed = false;
return {
id: subscriptionId,
target,
snapshot,
start: () => {
if (disposed || active.ready) return;
active.ready = true;
for (const update of active.queued.splice(0)) this.#deliverServiceUpdate(active, update);
},
dispose: async () => {
if (disposed) return;
disposed = true;
if (this.#serviceListeners.get(subscriptionId) === active) this.#serviceListeners.delete(subscriptionId);
try {
if (this.connected && this.#targetIsCurrent(target)) {
await this.#request(target, createServiceUnsubscribeCall(subscriptionId));
}
await active.deliveryTail;
} finally {
active.queuedWireUpdates.length = 0;
active.queued.length = 0;
}
},
};
}
#request<T = ServiceResult>(
target: RpcTarget,
call: ServiceCall,
signal?: AbortSignal,
transform?: (result: ServiceResult) => T,
): Promise<T> {
if (this.#disposed) return Promise.reject(new ClientDisposedError());
if (!this.connected) return Promise.reject(new DisconnectedError());
if (signal?.aborted) return Promise.reject(abortError(signal));
const id = `request-${++this.#requestSequence}`;
const { promise, resolve, reject } = createPromiseResolvers<T>();
let sent = false;
let aborted = false;
let onAbort: (() => void) | undefined;
const sendCancel = (): void => {
if (!sent || !this.connected) return;
try {
this.#connection.send(
encodeClientMessage({ type: "cancel", id, target }, { maxFrameLength: this.#connection.maxFrameLength }),
);
} catch (error) {
this.#connection.fail(toError(error));
}
};
if (signal !== undefined) {
onAbort = () => {
if (aborted) return;
aborted = true;
reject(abortError(signal));
sendCancel();
};
signal.addEventListener("abort", onAbort, { once: true });
}
this.#pendingRequests.set(id, {
resolve: (result) => {
try {
resolve(transform === undefined ? (result as T) : transform(result));
} catch (error) {
const validationError = new ProtocolValidationError(
error instanceof Error ? error.message : "Invalid service operation stream",
);
this.#connection.fail(validationError);
reject(validationError);
}
},
reject,
cleanup: () => {
if (signal !== undefined && onAbort !== undefined) signal.removeEventListener("abort", onAbort);
},
});
let frame: Uint8Array;
try {
frame = encodeClientMessage(
{ type: "request", id, target, call: parseServiceCall(call) as unknown as JsonValue },
{ maxFrameLength: this.#connection.maxFrameLength },
);
} catch (error) {
this.#takePendingRequest(id)?.reject(toError(error));
return promise;
}
this.#connection.send(frame);
sent = true;
if (aborted) sendCancel();
return promise;
}
#handleMessage(message: ResponseEnvelope | ServiceEventEnvelope | AttachmentEnvelope): void {
if (message.type === "attachment") {
if (message.attachment !== null && message.attachment.serverId !== this.#options.serverId) {
this.#connection.fail(new ProtocolValidationError("Attachment update belongs to another server"));
return;
}
this.#setAttachment(message.attachment ?? undefined);
return;
}
if (message.type === "service_update") {
const active = this.#serviceListeners.get(message.subscriptionId);
if (active === undefined) return;
if (!active.hydrated) {
active.queuedWireUpdates.push(message.update);
return;
}
let update: ServiceProviderUpdate;
try {
update = active.decoder.decodeUpdate(parseWireServiceProviderUpdate(message.update));
} catch (error) {
this.#connection.fail(
new ProtocolValidationError(error instanceof Error ? error.message : "Invalid service operation stream"),
);
return;
}
if (active.ready) this.#deliverServiceUpdate(active, update);
else active.queued.push(update);
return;
}
const pending = this.#takePendingRequest(message.id);
if (!pending) {
this.#connection.fail(new ProtocolValidationError("Response has no matching request"));
return;
}
if (!message.ok) {
pending.reject(new ServerError(message.error));
return;
}
pending.resolve(message.result);
}
#handleConnectionStateChange(change: ConnectionStateChange): void {
if (change.state === "disconnected") {
this.#hello = undefined;
this.#setAttachment(undefined);
this.#rejectPendingRequests(change.error ?? new DisconnectedError());
this.#serviceListeners.clear();
}
for (const listener of this.#connectionStateListeners) {
try {
listener(change);
} catch (error) {
this.#reportListenerError(error);
}
}
}
#takePendingRequest(id: string): PendingRequest | undefined {
const request = this.#pendingRequests.get(id);
if (request) {
this.#pendingRequests.delete(id);
request.cleanup();
}
return request;
}
#rejectPendingRequests(error: Error): void {
const requests = [...this.#pendingRequests.values()];
this.#pendingRequests.clear();
for (const request of requests) {
request.cleanup();
request.reject(error);
}
}
dispose(): Promise<void> {
if (this.#disposePromise) return this.#disposePromise;
this.#disposed = true;
this.#disposePromise = Promise.resolve();
const error = new ClientDisposedError();
this.#rejectPendingRequests(error);
this.#connection.disconnect(error);
this.#hello = undefined;
this.#setAttachment(undefined);
this.#connectionStateListeners.clear();
this.#attachmentListeners.clear();
this.#serviceListeners.clear();
return this.#disposePromise;
}
[Symbol.asyncDispose](): Promise<void> {
return this.dispose();
}
#setAttachment(attachment: SessionTarget | undefined): void {
const previous = this.#attachment;
if (
previous?.serverId === attachment?.serverId &&
previous?.sessionId === attachment?.sessionId &&
previous?.attachmentId === attachment?.attachmentId
) {
return;
}
this.#attachment = attachment;
for (const listener of this.#attachmentListeners) {
try {
listener(attachment);
} catch (error) {
this.#reportListenerError(error);
}
}
}
#deliverServiceUpdate(active: ActiveServiceListener, update: ServiceProviderUpdate): void {
active.deliveryTail = active.deliveryTail
.then(() => active.listener(update))
.catch((error: unknown) => this.#reportListenerError(error));
}
#targetIsCurrent(target: RpcTarget): boolean {
if (!("sessionId" in target)) return this.#hello?.serverId === target.serverId;
const attachment = this.#attachment;
return (
attachment?.serverId === target.serverId &&
attachment.sessionId === target.sessionId &&
attachment.attachmentId === target.attachmentId
);
}
#assertNotDisposed(): void {
if (this.#disposed) throw new ClientDisposedError();
}
#reportListenerError(error: unknown): void {
if (!this.#options.onListenerError) return;
try {
this.#options.onListenerError(toError(error));
} catch {
// Diagnostics cannot affect protocol or transport state.
}
}
}
/** Adapts a lazily resolved routed client target to a Chord service transport. */
export function createClientServiceTransport(
client: Client,
getTarget: () => RpcTarget | undefined,
): RemoteServiceTransport {
const target = (): RpcTarget => {
const resolved = getTarget();
if (resolved === undefined) throw new Error("Remote service target is unavailable");
return resolved;
};
return {
invoke: async (call, context) => client.request(target(), call, context.abortSignal),
async subscribe(serviceId, mode, listener, context) {
const subscription = await client.subscribeService(
target(),
serviceId,
mode,
(update) => listener(update, BACKGROUND_CONTEXT),
context.abortSignal,
);
return {
snapshot: subscription.snapshot,
activate: () => subscription.start(),
close: () => subscription.dispose(),
};
},
};
}
function abortError(signal: AbortSignal): Error {
const reason: unknown = signal.reason;
return reason instanceof Error ? reason : new DOMException("The operation was aborted", "AbortError");
}

View file

@ -1,245 +0,0 @@
import {
DEFAULT_MAX_FRAME_LENGTH,
encodeClientMessage,
PROTOCOL_VERSION,
ProtocolValidationError,
type ServerHello,
type ServerMessage,
ServerMessageDecoder,
} from "@earendil-works/pi-protocol";
import { DisconnectedError, ServerError, toDisconnectedError, toError } from "./errors.ts";
import { createPromiseResolvers, type PromiseResolvers } from "./promise.ts";
import type { ByteTransport, ByteTransportFactory, ByteTransportHandlers } from "./transport.ts";
import type { ConnectionState, ConnectionStateChange } from "./types.ts";
const MAX_UINT32 = 0xffff_ffff;
type ActiveConnection = {
id: number;
decoder: ServerMessageDecoder;
transport?: ByteTransport;
};
type ConnectionLifecycle =
| { state: "disconnected" }
| ({ state: "connecting"; handshake: PromiseResolvers<ServerHello> } & ActiveConnection)
| ({
state: "connected";
transport: ByteTransport;
handshake: PromiseResolvers<ServerHello> | undefined;
} & ActiveConnection);
interface ConnectionOptions {
transportFactory: ByteTransportFactory;
serverId: string;
maxFrameLength?: number;
onHandshake(hello: ServerHello): void;
onMessage(message: Exclude<ServerMessage, { type: "hello" | "hello_error" }>): void;
onStateChange(change: ConnectionStateChange): void;
}
export class Connection {
readonly #options: ConnectionOptions;
readonly #maxFrameLength: number;
#lifecycle: ConnectionLifecycle = { state: "disconnected" };
#sequence = 0;
constructor(options: ConnectionOptions) {
this.#options = options;
this.#maxFrameLength = options.maxFrameLength ?? DEFAULT_MAX_FRAME_LENGTH;
if (
!Number.isSafeInteger(this.#maxFrameLength) ||
this.#maxFrameLength <= 0 ||
this.#maxFrameLength > MAX_UINT32
) {
throw new TypeError(`Client maxFrameLength must be an integer between 1 and ${MAX_UINT32}`);
}
}
get state(): ConnectionState {
return this.#lifecycle.state;
}
get maxFrameLength(): number {
return this.#maxFrameLength;
}
connect(): Promise<ServerHello> {
if (this.#lifecycle.state !== "disconnected") {
return Promise.reject(new DisconnectedError(`Client is already ${this.#lifecycle.state}`));
}
const id = ++this.#sequence;
const handshake = createPromiseResolvers<ServerHello>();
this.#lifecycle = {
state: "connecting",
id,
decoder: new ServerMessageDecoder({ maxFrameLength: this.#maxFrameLength }),
handshake,
};
this.#options.onStateChange({ state: "connecting" });
const handlers = {
onData: (chunk) => this.#handleData(id, chunk),
onClose: () => {
if (this.#isCurrent(id)) this.#handleClose();
},
onError: (error) => {
if (this.#isCurrent(id)) this.#failAndClose(toDisconnectedError(error));
},
} satisfies ByteTransportHandlers;
void this.#openTransport(id, handlers);
return handshake.promise;
}
disconnect(reason: string | Error = "Client disconnected"): void {
if (this.#lifecycle.state === "disconnected") return;
this.#failAndClose(typeof reason === "string" ? new DisconnectedError(reason) : reason);
}
fail(error: Error): void {
this.#failAndClose(error);
}
send(frame: Uint8Array): void {
const lifecycle = this.#lifecycle;
if (lifecycle.state !== "connected") throw new DisconnectedError();
let sending: Promise<void>;
try {
sending = lifecycle.transport.send(frame);
} catch (error) {
this.#failAndClose(toDisconnectedError(error));
return;
}
void sending.catch((error: unknown) => {
const current = this.#lifecycle;
if (current.state !== "disconnected" && current.transport === lifecycle.transport) {
this.#failAndClose(toDisconnectedError(error));
}
});
}
async #openTransport(id: number, handlers: ByteTransportHandlers): Promise<void> {
let transport: ByteTransport;
try {
transport = await this.#options.transportFactory(handlers);
} catch (error) {
if (this.#isCurrent(id)) this.#fail(toDisconnectedError(error));
return;
}
const lifecycle = this.#lifecycle;
if (lifecycle.state !== "connecting" || lifecycle.id !== id) {
transport.close();
return;
}
this.#lifecycle = { ...lifecycle, transport };
try {
await transport.send(
encodeClientMessage({ type: "hello", version: PROTOCOL_VERSION }, { maxFrameLength: this.#maxFrameLength }),
);
} catch (error) {
if (this.#isCurrent(id)) this.#failAndClose(toDisconnectedError(error));
}
}
#handleData(id: number, chunk: Uint8Array): void {
const lifecycle = this.#lifecycle;
if (lifecycle.state === "disconnected" || lifecycle.id !== id) return;
if (lifecycle.state === "connecting" && !lifecycle.transport) {
this.#failAndClose(new ProtocolValidationError("Received server data before the client hello was sent"));
return;
}
let messages: ServerMessage[];
try {
messages = lifecycle.decoder.push(chunk);
} catch (error) {
this.#failAndClose(toError(error));
return;
}
for (const message of messages) {
if (this.#lifecycle.state === "disconnected") return;
this.#handleMessage(message);
}
}
#handleMessage(message: ServerMessage): void {
const lifecycle = this.#lifecycle;
if (lifecycle.state === "connecting") {
if (message.type === "hello_error") {
this.#failAndClose(new ServerError(message.error));
return;
}
if (message.type !== "hello") {
this.#failAndClose(new ProtocolValidationError("Expected server hello as first message"));
return;
}
if (message.serverId !== this.#options.serverId) {
this.#failAndClose(
new ProtocolValidationError(
`Connected server ${JSON.stringify(message.serverId)} does not match ${JSON.stringify(this.#options.serverId)}`,
),
);
return;
}
if (!lifecycle.transport) {
this.#failAndClose(new ProtocolValidationError("Received server hello before the client hello was sent"));
return;
}
const connected = {
state: "connected",
id: lifecycle.id,
decoder: lifecycle.decoder,
transport: lifecycle.transport,
handshake: lifecycle.handshake,
} satisfies Extract<ConnectionLifecycle, { state: "connected" }>;
this.#lifecycle = connected;
try {
this.#options.onHandshake(message);
} catch (error) {
if (this.#lifecycle === connected) this.#failAndClose(toError(error));
return;
}
if (this.#lifecycle !== connected) return;
this.#options.onStateChange({ state: "connected" });
if (this.#lifecycle !== connected) return;
this.#lifecycle = { ...connected, handshake: undefined };
lifecycle.handshake.resolve(message);
return;
}
if (lifecycle.state !== "connected") return;
if (message.type === "hello" || message.type === "hello_error") {
this.#failAndClose(new ProtocolValidationError("Unexpected handshake message"));
return;
}
this.#options.onMessage(message);
}
#handleClose(): void {
const lifecycle = this.#lifecycle;
if (lifecycle.state === "disconnected") return;
let error: Error = new DisconnectedError("Byte transport closed");
try {
lifecycle.decoder.end();
} catch (decoderError) {
error = toError(decoderError);
}
this.#fail(error);
}
#failAndClose(error: Error): void {
const lifecycle = this.#lifecycle;
const transport = lifecycle.state === "disconnected" ? undefined : lifecycle.transport;
this.#fail(error);
transport?.close();
}
#fail(error: Error): void {
const lifecycle = this.#lifecycle;
if (lifecycle.state === "disconnected") return;
this.#lifecycle = { state: "disconnected" };
lifecycle.handshake?.reject(error);
this.#options.onStateChange({ state: "disconnected", error });
}
#isCurrent(id: number): boolean {
return this.#lifecycle.state !== "disconnected" && this.#lifecycle.id === id;
}
}

View file

@ -1,34 +0,0 @@
import type { ProtocolError, ProtocolErrorCode } from "@earendil-works/pi-protocol";
export class ServerError extends Error {
readonly code: ProtocolErrorCode;
constructor(error: ProtocolError) {
super(error.message);
this.name = "ServerError";
this.code = error.code;
}
}
export class DisconnectedError extends Error {
constructor(message = "Client is disconnected", cause?: Error) {
super(message, cause === undefined ? undefined : { cause });
this.name = "DisconnectedError";
}
}
export class ClientDisposedError extends Error {
constructor() {
super("Client is disposed");
this.name = "ClientDisposedError";
}
}
export function toError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
export function toDisconnectedError(error: unknown): DisconnectedError {
const cause = toError(error);
return cause instanceof DisconnectedError ? cause : new DisconnectedError(cause.message, cause);
}

View file

@ -1,12 +0,0 @@
export { Client, createClientServiceTransport } from "./client.ts";
export { ClientDisposedError, DisconnectedError, ServerError } from "./errors.ts";
export type { ByteTransport, ByteTransportFactory, ByteTransportHandlers } from "./transport.ts";
export type {
AttachmentChangeListener,
ClientOptions,
ConnectionState,
ConnectionStateChange,
ListenerErrorHandler,
ServiceSubscription,
Unsubscribe,
} from "./types.ts";

View file

@ -1,16 +0,0 @@
export interface PromiseResolvers<T> {
promise: Promise<T>;
resolve(value: T | PromiseLike<T>): void;
reject(reason?: unknown): void;
}
/** Remove in favor of `Promise.withResolvers()` when the repository's TypeScript lib baseline moves to ES2024. */
export function createPromiseResolvers<T>(): PromiseResolvers<T> {
let resolve!: PromiseResolvers<T>["resolve"];
let reject!: PromiseResolvers<T>["reject"];
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}

View file

@ -1,18 +0,0 @@
export interface ByteTransport {
/** Sends one byte chunk. Calls must be delivered in invocation order. */
send(chunk: Uint8Array): Promise<void>;
/** Closes the transport. Implementations must make repeated calls harmless. */
close(): void;
}
export interface ByteTransportHandlers {
/** Delivers an arbitrary inbound byte chunk. */
onData(chunk: Uint8Array): void;
/** Reports an orderly terminal close. */
onClose(): void;
/** Reports a terminal transport failure. */
onError(error: Error): void;
}
/** Creates a fresh connected, authenticated transport. Exactly one terminal handler is expected. */
export type ByteTransportFactory = (handlers: ByteTransportHandlers) => ByteTransport | Promise<ByteTransport>;

View file

@ -1,32 +0,0 @@
import type { ServiceSubscriptionSnapshot } from "@earendil-works/chord";
import type { RpcTarget, SessionTarget } from "@earendil-works/pi-protocol";
import type { ByteTransportFactory } from "./transport.ts";
export type ConnectionState = "disconnected" | "connecting" | "connected";
export interface ConnectionStateChange {
state: ConnectionState;
error?: Error;
}
export type Unsubscribe = () => void;
export type ListenerErrorHandler = (error: Error) => void;
export type AttachmentChangeListener = (attachment: SessionTarget | undefined) => void;
export interface ServiceSubscription {
readonly id: string;
readonly target: RpcTarget;
readonly snapshot: ServiceSubscriptionSnapshot;
/** Begin ordered update delivery after the caller has installed the snapshot. */
start(): void;
dispose(): Promise<void>;
}
export interface ClientOptions {
transportFactory: ByteTransportFactory;
/** Logical server identity expected at the physical endpoint. */
serverId: string;
maxFrameLength?: number;
/** Reports subscriber failures without allowing them to corrupt client state. */
onListenerError?: ListenerErrorHandler;
}

View file

@ -1,299 +0,0 @@
import { lstat, readdir } from "node:fs/promises";
import { createConnection, type Socket } from "node:net";
import { join } from "node:path";
import {
DEFAULT_MAX_FRAME_LENGTH,
isServerId,
ProtocolValidationError,
type ServerId,
} from "@earendil-works/pi-protocol";
import { Client } from "./client.ts";
import { DisconnectedError, ServerError } from "./errors.ts";
import type { ByteTransport, ByteTransportFactory, ByteTransportHandlers } from "./transport.ts";
const DEFAULT_DISCOVERY_TIMEOUT_MS = 1_000;
const MAX_TIMER_DELAY_MS = 2_147_483_647;
const UNIX_SOCKET_SUFFIX = ".sock";
const MAX_CONCURRENT_DISCOVERY_PROBES = 16;
export interface UnixTransportOptions {
path: string;
maxPendingBytes?: number;
}
export interface UnixServerRoute {
serverId: ServerId;
path: string;
}
export interface DiscoverUnixServersOptions {
/** Directory containing server-addressed Unix sockets. */
directory: string;
/** Maximum time for each connection and handshake. Defaults to 1,000 ms. */
timeoutMs?: number;
}
/** Discover reachable local servers by probing server-addressed Unix sockets. */
export async function discoverUnixServers(options: DiscoverUnixServersOptions): Promise<UnixServerRoute[]> {
if (process.platform === "win32") throw new Error("Unix transport is not supported on Windows");
const directory = options.directory;
const timeoutMs = options.timeoutMs ?? DEFAULT_DISCOVERY_TIMEOUT_MS;
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > MAX_TIMER_DELAY_MS) {
throw new TypeError(`Unix discovery timeoutMs must be an integer between 1 and ${MAX_TIMER_DELAY_MS}`);
}
let names: string[];
try {
names = await readdir(directory);
} catch (error) {
if (isErrorCode(error, "ENOENT")) return [];
throw error;
}
const candidates = names.flatMap((name): UnixServerRoute[] => {
if (!name.endsWith(UNIX_SOCKET_SUFFIX)) return [];
const serverId = name.slice(0, -UNIX_SOCKET_SUFFIX.length);
return isServerId(serverId) ? [{ serverId, path: join(directory, name) }] : [];
});
const routes: UnixServerRoute[] = [];
let nextIndex = 0;
let failure: { error: unknown } | undefined;
const workerCount = Math.min(MAX_CONCURRENT_DISCOVERY_PROBES, candidates.length);
await Promise.all(
Array.from({ length: workerCount }, async () => {
while (!failure) {
const candidate = candidates[nextIndex++];
if (!candidate) return;
try {
try {
if (!(await lstat(candidate.path)).isSocket()) continue;
} catch (error) {
// A socket can disappear between readdir and lstat during normal server shutdown.
if (isErrorCode(error, "ENOENT")) continue;
throw error;
}
const route = await probeUnixServer(candidate, timeoutMs);
if (route) routes.push(route);
} catch (error) {
failure ??= { error };
}
}
}),
);
if (failure) throw failure.error;
return routes.sort((left, right) => left.serverId.localeCompare(right.serverId));
}
/** Creates fresh Unix-domain socket transports for Client connection attempts in Node-compatible runtimes. */
export function createUnixTransportFactory(options: UnixTransportOptions): ByteTransportFactory {
const maxPendingBytes = validateUnixTransportOptions(options);
return (handlers) => connectUnixSocket(options.path, maxPendingBytes, handlers);
}
function validateUnixTransportOptions(options: UnixTransportOptions): number {
if (options.path.length === 0) throw new TypeError("Unix transport path must not be empty");
const maxPendingBytes = options.maxPendingBytes ?? DEFAULT_MAX_FRAME_LENGTH * 4;
if (!Number.isSafeInteger(maxPendingBytes) || maxPendingBytes <= 0) {
throw new TypeError("Unix transport maxPendingBytes must be a positive safe integer");
}
if (process.platform === "win32") throw new Error("Unix transport is not supported on Windows");
return maxPendingBytes;
}
function connectUnixSocket(
path: string,
maxPendingBytes: number,
handlers: ByteTransportHandlers,
onSocket?: (socket: Socket) => void,
): Promise<ByteTransport> {
return new Promise<ByteTransport>((resolve, reject) => {
const socket = createConnection(path);
onSocket?.(socket);
let connected = false;
let terminal = false;
const close = (): void => {
if (terminal) return;
terminal = true;
socket.destroy();
if (connected) handlers.onClose();
else reject(new Error("Unix transport closed before connecting"));
};
socket.once("connect", () => {
if (terminal) return;
connected = true;
resolve(
new UnixByteTransport(socket, maxPendingBytes, () => {
terminal = true;
}),
);
});
socket.on("data", (chunk) => {
if (!terminal) handlers.onData(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength));
});
socket.once("end", close);
socket.once("close", close);
socket.once("error", (error) => {
if (terminal) return;
terminal = true;
socket.destroy();
if (connected) handlers.onError(error);
else reject(error);
});
});
}
class UnixByteTransport implements ByteTransport {
readonly #socket: Socket;
readonly #maxPendingBytes: number;
readonly #markLocalClose: () => void;
#closed = false;
#pendingBytes = 0;
#writeTail: Promise<void> = Promise.resolve();
constructor(socket: Socket, maxPendingBytes: number, markLocalClose: () => void) {
this.#socket = socket;
this.#maxPendingBytes = maxPendingBytes;
this.#markLocalClose = markLocalClose;
}
send(chunk: Uint8Array): Promise<void> {
if (!(chunk instanceof Uint8Array)) {
return Promise.reject(new TypeError("Unix transport chunks must be Uint8Array"));
}
if (this.#closed) return Promise.reject(new Error("Unix transport is closed"));
if (this.#pendingBytes + chunk.byteLength > this.#maxPendingBytes) {
return Promise.reject(new Error("Unix transport exceeded its pending byte limit"));
}
this.#pendingBytes += chunk.byteLength;
const bytes = chunk.slice();
const write = this.#writeTail.then(() => this.#write(bytes));
const tracked = write.finally(() => {
this.#pendingBytes -= bytes.byteLength;
});
this.#writeTail = tracked.catch(() => {});
return tracked;
}
close(): void {
if (this.#closed) return;
this.#closed = true;
this.#markLocalClose();
this.#socket.destroy();
}
#write(chunk: Uint8Array): Promise<void> {
if (this.#closed || !this.#socket.writable) return Promise.reject(new Error("Unix transport is closed"));
return new Promise<void>((resolve, reject) => {
let callbackComplete = false;
let drainComplete = false;
let requiresDrain: boolean | undefined;
let settled = false;
const onDrain = (): void => {
drainComplete = true;
finish();
};
const cleanup = (): void => {
this.#socket.off("drain", onDrain);
this.#socket.off("close", onClose);
};
const fail = (error: Error): void => {
if (settled) return;
settled = true;
cleanup();
reject(error);
};
const finish = (): void => {
if (settled || !callbackComplete || requiresDrain === undefined) return;
if (requiresDrain && !drainComplete) return;
settled = true;
cleanup();
resolve();
};
const onClose = (): void => fail(new Error("Unix transport closed during write"));
try {
this.#socket.once("close", onClose);
const accepted = this.#socket.write(chunk, (error) => {
if (error) {
fail(error);
return;
}
callbackComplete = true;
finish();
});
requiresDrain = !accepted;
if (requiresDrain) this.#socket.once("drain", onDrain);
finish();
} catch (error) {
fail(error instanceof Error ? error : new Error(String(error)));
}
});
}
}
async function probeUnixServer(route: UnixServerRoute, timeoutMs: number): Promise<UnixServerRoute | undefined> {
const maxPendingBytes = validateUnixTransportOptions({ path: route.path });
let socket: Socket | undefined;
const client = new Client({
serverId: route.serverId,
transportFactory: (handlers) =>
connectUnixSocket(route.path, maxPendingBytes, handlers, (created) => {
socket = created;
}),
});
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
client.connect(),
new Promise<never>((_, reject) => {
timeout = setTimeout(() => {
socket?.destroy();
reject(new UnixDiscoveryTimeoutError());
}, timeoutMs);
timeout.unref();
}),
]);
return route;
} catch (error) {
// Missing/refused sockets are stale or shutting down. Protocol failures mean
// the endpoint is not the advertised server. Both are safe to omit.
if (
error instanceof UnixDiscoveryTimeoutError ||
error instanceof ProtocolValidationError ||
(error instanceof DisconnectedError && error.cause === undefined) ||
(error instanceof ServerError && error.code === "version") ||
isErrorCode(error, "ENOENT") ||
isErrorCode(error, "ECONNREFUSED") ||
isErrorCode(error, "ECONNRESET") ||
isErrorCode(error, "EPIPE") ||
isErrorCode(error, "ETIMEDOUT")
) {
return undefined;
}
throw error;
} finally {
if (timeout) clearTimeout(timeout);
await client.dispose();
const activeSocket = socket;
if (activeSocket && !activeSocket.destroyed) activeSocket.destroy();
if (activeSocket && !activeSocket.closed) {
await new Promise<void>((resolve) => activeSocket.once("close", resolve));
}
}
}
class UnixDiscoveryTimeoutError extends Error {}
function isErrorCode(error: unknown, code: string): boolean {
let current = error;
const seen = new Set<unknown>();
while (current instanceof Error && !seen.has(current)) {
seen.add(current);
if ("code" in current && current.code === code) return true;
current = current.cause;
}
return false;
}

View file

@ -1,384 +0,0 @@
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import {
encodeCbor,
encodeFrame,
encodeServerMessage,
PROTOCOL_VERSION,
ProtocolValidationError,
} from "@earendil-works/pi-protocol";
import { describe, expect, test, vi } from "vitest";
import {
type ByteTransportFactory,
Client,
ClientDisposedError,
createClientServiceTransport,
DisconnectedError,
} from "../src/index.ts";
import { MemoryByteServer } from "./support.ts";
const serverId = "00000000-0000-4000-8000-000000000001";
const serverTarget = { serverId } as const;
async function connectClient(server: MemoryByteServer, expectedServerId = serverId): Promise<Client> {
return Client.connect({ serverId: expectedServerId, transportFactory: (handlers) => server.connect(handlers) });
}
async function attachClient(client: Client, server: MemoryByteServer, sessionId: string): Promise<void> {
const expectedMessages = server.messages.length + 1;
const attaching = client.request(serverTarget, {
serviceId: "pi.session-management",
member: "attach",
args: [sessionId],
});
await server.waitForMessages(expectedMessages);
const request = server.messages.at(-1);
if (request?.type !== "request") throw new Error("Missing attach request");
server.send({
type: "attachment",
attachment: { serverId, sessionId, attachmentId: `attachment-${sessionId}` },
});
server.send({ type: "response", id: request.id, ok: true, result: null });
await attaching;
}
test("requires a canonical UUIDv4 server identity", () => {
expect(() => new Client({ serverId: "invalid-server", transportFactory: () => Promise.reject() })).toThrow(
/serverId/,
);
});
describe("Client service operations", () => {
test("connects only to the expected logical server", async () => {
const matching = new MemoryByteServer();
const client = await connectClient(matching);
expect(client.hello).toMatchObject({ serverId });
await client.dispose();
const wrong = new MemoryByteServer("00000000-0000-4000-8000-000000000002");
await expect(connectClient(wrong)).rejects.toBeInstanceOf(ProtocolValidationError);
});
test("updates attachment state from out-of-band server routing", async () => {
const server = new MemoryByteServer();
const client = await connectClient(server);
const changes: Array<string | undefined> = [];
client.onAttachmentChange((attachment) => changes.push(attachment?.sessionId));
await attachClient(client, server, "session-1");
expect(client.attachment).toMatchObject({ sessionId: "session-1", attachmentId: "attachment-session-1" });
expect(server.messages[1]).toMatchObject({
type: "request",
target: serverTarget,
call: { serviceId: "pi.session-management", member: "attach", args: ["session-1"] },
});
server.send({ type: "attachment", attachment: null });
expect(client.attachment).toBeUndefined();
expect(changes).toEqual(["session-1", undefined]);
await client.dispose();
});
test("buffers service updates until the subscription snapshot arrives", async () => {
const server = new MemoryByteServer();
const client = await connectClient(server);
await attachClient(client, server, "session-1");
const target = client.attachment!;
const transport = createClientServiceTransport(client, () => client.attachment);
const updates: Array<{ readonly type: string; readonly ops?: readonly unknown[] }> = [];
const opening = transport.subscribe(
"pi.models",
"singleton",
(update) => {
updates.push(update);
},
BACKGROUND_CONTEXT,
);
await server.waitForMessages(3);
expect(server.messages[2]).toMatchObject({
type: "request",
target,
call: {
serviceId: "$chord.service",
member: "subscribe",
args: ["service-1", "pi.models", "singleton"],
},
});
server.send({
type: "service_update",
subscriptionId: "service-1",
update: { type: "state", member: "state", sequence: 1, ops: [["s", ["revision"], 1]] },
});
await Promise.resolve();
expect(updates).toEqual([]);
server.send({
type: "response",
id: "request-2",
ok: true,
result: {
serviceId: "pi.models",
mode: "singleton",
instances: [{ members: [{ name: "state", kind: "state", sequence: 0, ops: [["r", { revision: 0 }]] }] }],
},
});
const subscription = await opening;
expect(updates).toEqual([]);
server.send({
type: "service_update",
subscriptionId: "closed-subscription",
update: { type: "state", member: "state", sequence: 99, ops: [["s", 99, 99]] },
});
await Promise.resolve();
expect(client.connected).toBe(true);
expect(subscription.snapshot.instances[0]?.members).toEqual([
{ name: "state", kind: "state", sequence: 0, ops: [["r", { revision: 0 }]] },
]);
subscription.activate();
await vi.waitFor(() => expect(updates.map(({ type }) => type)).toEqual(["state"]));
server.send({
type: "service_update",
subscriptionId: "service-1",
update: { type: "state", member: "state", sequence: 2, ops: [["s", ["revision"], 2]] },
});
server.send({
type: "service_update",
subscriptionId: "service-1",
update: {
type: "state",
member: "state",
sequence: 3,
ops: [
["#", 0, ["revision"]],
["s", 0, 3],
],
},
});
await vi.waitFor(() => expect(updates).toHaveLength(3));
expect(updates[2]?.ops).toEqual([["s", ["revision"], 3]]);
const disposing = subscription.close(BACKGROUND_CONTEXT);
await server.waitForMessages(4);
expect(server.messages[3]).toMatchObject({
call: { serviceId: "$chord.service", member: "unsubscribe", args: ["service-1"] },
});
server.send({ type: "response", id: "request-3", ok: true });
await disposing;
await client.dispose();
});
test("correlates out-of-order generic service responses", async () => {
const server = new MemoryByteServer();
const client = await connectClient(server);
const first = client.request(serverTarget, { serviceId: "test", member: "first", args: [] });
const second = client.request(serverTarget, { serviceId: "test", member: "second", args: [] });
await server.waitForMessages(3);
server.send({ type: "response", id: "request-2", ok: true, result: "second" });
server.send({ type: "response", id: "request-1", ok: true, result: "first" });
await expect(Promise.all([first, second])).resolves.toEqual(["first", "second"]);
await client.dispose();
});
test("exposes bounded server errors", async () => {
const server = new MemoryByteServer();
const client = await connectClient(server);
const pending = client.request(serverTarget, { serviceId: "test", member: "missing", args: [] });
await server.waitForMessages(2);
server.send({
type: "response",
id: "request-1",
ok: false,
error: { code: "session_not_found", message: "Unknown session" },
});
await expect(pending).rejects.toMatchObject({ code: "session_not_found" });
await client.dispose();
});
test("does not send a pre-aborted untyped RPC request", async () => {
const server = new MemoryByteServer();
const client = await connectClient(server);
const controller = new AbortController();
const reason = new Error("already cancelled");
controller.abort(reason);
await expect(
client.request(serverTarget, { serviceId: "test", member: "noop", args: [] }, controller.signal),
).rejects.toBe(reason);
expect(server.messages).toHaveLength(1);
await client.dispose();
});
test("cancels one untyped RPC request without disconnecting", async () => {
const server = new MemoryByteServer();
const client = await connectClient(server);
const controller = new AbortController();
const reason = new Error("stop this request");
const pending = client.request(
serverTarget,
{ serviceId: "test", member: "mutate", args: [{ value: 42 }] },
controller.signal,
);
await server.waitForMessages(2);
expect(server.messages[1]).toMatchObject({
type: "request",
id: "request-1",
target: serverTarget,
call: { serviceId: "test", member: "mutate", args: [{ value: 42 }] },
});
controller.abort(reason);
await expect(pending).rejects.toBe(reason);
await server.waitForMessages(3);
expect(server.messages[2]).toEqual({ type: "cancel", id: "request-1", target: serverTarget });
server.send({
type: "response",
id: "request-1",
ok: false,
error: { code: "cancelled", message: "cancelled" },
});
expect(client.connected).toBe(true);
await client.dispose();
});
test("rejects pending requests after disconnect or disposal", async () => {
const server = new MemoryByteServer();
const client = await connectClient(server);
const pending = client.request(serverTarget, { serviceId: "test", member: "pending", args: [] });
server.disconnect();
await expect(pending).rejects.toBeInstanceOf(DisconnectedError);
await client.dispose();
await expect(
client.request(serverTarget, { serviceId: "test", member: "disposed", args: [] }),
).rejects.toBeInstanceOf(ClientDisposedError);
});
});
describe("Client connection lifecycle", () => {
test("rejects server data delivered before the client hello is sent", async () => {
let closeCount = 0;
let sendCount = 0;
const client = new Client({
serverId,
transportFactory: (handlers) => {
handlers.onData(encodeServerMessage({ type: "hello", version: PROTOCOL_VERSION, serverId }));
return {
async send() {
sendCount += 1;
},
close() {
closeCount += 1;
},
};
},
});
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("rejects typed handshake errors and closes the transport", async () => {
let handlers: Parameters<ByteTransportFactory>[0];
let closeCount = 0;
const client = new Client({
serverId,
transportFactory: (createdHandlers) => {
handlers = createdHandlers;
return {
async send() {
handlers.onData(
encodeServerMessage({
type: "hello_error",
error: { code: "version", message: "Unsupported protocol version" },
}),
);
},
close() {
closeCount += 1;
},
};
},
});
await expect(client.connect()).rejects.toMatchObject({
name: "ServerError",
code: "version",
message: "Unsupported protocol version",
});
expect(client.connectionState).toBe("disconnected");
expect(closeCount).toBe(1);
});
test("rejects pending requests and reconnects through a fresh transport", async () => {
const first = new MemoryByteServer();
const second = new MemoryByteServer();
let connection = 0;
const transportFactory: ByteTransportFactory = (handlers) =>
(connection++ === 0 ? first : second).connect(handlers);
const client = new Client({ serverId, transportFactory });
const states: string[] = [];
client.onConnectionStateChange(({ state }) => states.push(state));
await client.connect();
await attachClient(client, first, "session-1");
const target = client.attachment;
if (target === undefined) throw new Error("Missing attachment");
const pending = client.request(target, { serviceId: "test.session", member: "run", args: [] });
await first.waitForMessages(3);
expect(first.messages[2]).toMatchObject({ call: { serviceId: "test.session", member: "run" } });
first.disconnect();
await expect(pending).rejects.toBeInstanceOf(DisconnectedError);
await expect(client.reconnect()).resolves.toMatchObject({ serverId });
expect(connection).toBe(2);
expect(client.connected).toBe(true);
expect(second.messages).toHaveLength(1);
expect(states).toEqual(["connecting", "connected", "disconnected", "connecting", "connected"]);
await client.dispose();
});
test("reports transport failures without leaving requests pending", async () => {
const server = new MemoryByteServer();
const client = await connectClient(server);
const pending = client.request(serverTarget, { serviceId: "test", member: "pending", args: [] });
await server.waitForMessages(2);
server.error(new Error("read failed"));
await expect(pending).rejects.toMatchObject({
name: "DisconnectedError",
message: "read failed",
cause: expect.objectContaining({ message: "read failed" }),
});
expect(client.connectionState).toBe("disconnected");
});
test("disconnects on invalid or truncated server framing", async () => {
const invalidServer = new MemoryByteServer();
const invalidClient = await connectClient(invalidServer);
invalidServer.sendRaw(encodeFrame(encodeCbor({ type: "response", id: "unknown", ok: true, result: 1 })));
expect(invalidClient.connectionState).toBe("disconnected");
const truncatedServer = new MemoryByteServer();
const truncatedClient = await connectClient(truncatedServer);
const pending = truncatedClient.request(serverTarget, { serviceId: "test", member: "pending", args: [] });
await truncatedServer.waitForMessages(2);
truncatedServer.sendRaw(new Uint8Array([0, 0, 0, 2, 1]));
truncatedServer.disconnect();
await expect(pending).rejects.toMatchObject({
name: "ProtocolValidationError",
message: expect.stringMatching(/truncated/i),
});
expect(truncatedClient.connectionState).toBe("disconnected");
});
test("disconnects when a response has no matching request", async () => {
const server = new MemoryByteServer();
const client = await connectClient(server);
server.send({ type: "response", id: "unknown-request", ok: true, result: [] });
expect(client.connectionState).toBe("disconnected");
expect(server.clientCloseCount).toBe(1);
});
});

View file

@ -1,9 +0,0 @@
import { createServer } from "node:net";
const path = process.argv[2];
if (!path) throw new Error("Socket path argument is required");
const server = createServer();
server.listen(path, () => {
if (process.send) process.send("listening");
});

View file

@ -1,84 +0,0 @@
import {
type ClientMessage,
ClientMessageDecoder,
encodeServerMessage,
PROTOCOL_VERSION,
type ServerMessage,
} from "@earendil-works/pi-protocol";
import type { ByteTransport, ByteTransportHandlers } from "../src/index.ts";
export class MemoryByteServer {
readonly messages: ClientMessage[] = [];
readonly serverId: string;
clientCloseCount = 0;
private handlers?: ByteTransportHandlers;
private decoder = new ClientMessageDecoder();
private readonly messageWaiters: Array<{ count: number; resolve: () => void }> = [];
constructor(serverId = "00000000-0000-4000-8000-000000000001") {
this.serverId = serverId;
}
connect(handlers: ByteTransportHandlers): ByteTransport {
this.handlers = handlers;
this.decoder = new ClientMessageDecoder();
let closed = false;
return {
send: async (chunk) => {
for (const message of this.decoder.push(chunk)) {
this.messages.push(message);
this.resolveMessageWaiters();
if (message.type === "hello") {
this.send({
type: "hello",
version: PROTOCOL_VERSION,
serverId: this.serverId,
});
}
}
},
close: () => {
if (closed) return;
closed = true;
this.clientCloseCount += 1;
if (this.handlers === handlers) this.handlers = undefined;
},
};
}
waitForMessages(count: number): Promise<void> {
if (this.messages.length >= count) return Promise.resolve();
return new Promise((resolve) => this.messageWaiters.push({ count, resolve }));
}
send(message: ServerMessage): void {
if (!this.handlers) throw new Error("No client connection");
this.handlers.onData(encodeServerMessage(message));
}
sendRaw(chunk: Uint8Array): void {
if (!this.handlers) throw new Error("No client connection");
this.handlers.onData(chunk);
}
disconnect(): void {
const handlers = this.handlers;
this.handlers = undefined;
handlers?.onClose();
}
error(error: Error): void {
const handlers = this.handlers;
this.handlers = undefined;
handlers?.onError(error);
}
private resolveMessageWaiters(): void {
for (let index = this.messageWaiters.length - 1; index >= 0; index--) {
const waiter = this.messageWaiters[index]!;
if (this.messages.length < waiter.count) continue;
this.messageWaiters.splice(index, 1);
waiter.resolve();
}
}
}

View file

@ -1,147 +0,0 @@
import { mkdtemp, rm } from "node:fs/promises";
import { createServer, type Server, type Socket } from "node:net";
import { join } from "node:path";
import { parseServiceCall } from "@earendil-works/chord";
import { ClientMessageDecoder, encodeServerMessage, PROTOCOL_VERSION } from "@earendil-works/pi-protocol";
import { afterEach, describe, expect, test } from "vitest";
import { Client } from "../src/index.ts";
import { createUnixTransportFactory } from "../src/unix.ts";
const serverId = "00000000-0000-4000-8000-000000000001";
const tempDirectories = new Set<string>();
const servers = new Set<Server>();
const sockets = new Set<Socket>();
async function makeSocketPath(): Promise<string> {
const directory = await mkdtemp(join("/tmp", "pi-client-transport-"));
tempDirectories.add(directory);
return join(directory, "pi.sock");
}
async function listen(server: Server, path: string): Promise<void> {
servers.add(server);
server.on("connection", (socket) => {
sockets.add(socket);
socket.once("close", () => sockets.delete(socket));
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(path, resolve);
});
}
afterEach(async () => {
for (const socket of sockets) socket.destroy();
sockets.clear();
await Promise.all(
[...servers].map(
(server) =>
new Promise<void>((resolve) => {
server.close(() => resolve());
}),
),
);
servers.clear();
await Promise.all([...tempDirectories].map((directory) => rm(directory, { recursive: true, force: true })));
tempDirectories.clear();
});
test("rejects invalid Unix transport options", () => {
expect(() => createUnixTransportFactory({ path: "" })).toThrow(/must not be empty/);
expect(() => createUnixTransportFactory({ path: "/tmp/pi.sock", maxPendingBytes: 0 })).toThrow(/positive/);
});
describe.runIf(process.platform !== "win32")("createUnixTransportFactory", () => {
test("carries a complete Client handshake and request over a real Unix socket", async () => {
const path = await makeSocketPath();
const receivedMembers: string[] = [];
const server = createServer((socket) => {
const decoder = new ClientMessageDecoder();
socket.on("data", (chunk) => {
for (const message of decoder.push(chunk)) {
if (message.type === "hello") {
const frame = encodeServerMessage({
type: "hello",
version: PROTOCOL_VERSION,
serverId,
});
for (const byte of frame) socket.write(Uint8Array.of(byte));
continue;
}
if (message.type === "cancel") continue;
const call = parseServiceCall(message.call);
receivedMembers.push(`${call.serviceId}.${call.member}`);
const frame = encodeServerMessage({
type: "response",
id: message.id,
ok: true,
result: [],
});
const split = Math.floor(frame.byteLength / 2);
socket.write(frame.subarray(0, split));
socket.write(frame.subarray(split));
}
});
});
await listen(server, path);
const client = new Client({ serverId, transportFactory: createUnixTransportFactory({ path }) });
try {
await expect(client.connect()).resolves.toMatchObject({ serverId });
await expect(
client.request({ serverId }, { serviceId: "test.server", member: "list", args: [] }),
).resolves.toEqual([]);
expect(receivedMembers).toEqual(["test.server.list"]);
} finally {
await client.dispose();
}
});
test("reports truncated final frames through Client", async () => {
const path = await makeSocketPath();
const server = createServer((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,
serverId,
}),
);
} else {
socket.end(new Uint8Array([0, 0, 0, 2, 1]));
}
}
});
});
await listen(server, path);
const client = new Client({ serverId, transportFactory: createUnixTransportFactory({ path }) });
try {
await client.connect();
await expect(
client.request({ serverId }, { serviceId: "test.server", member: "list", args: [] }),
).rejects.toMatchObject({
name: "ProtocolValidationError",
message: expect.stringMatching(/truncated/i),
});
expect(client.connectionState).toBe("disconnected");
} finally {
await client.dispose();
}
});
test("rejects connection attempts to missing sockets", async () => {
const path = await makeSocketPath();
await expect(
createUnixTransportFactory({ path })({
onData: () => {},
onClose: () => {},
onError: () => {},
}),
).rejects.toMatchObject({ code: "ENOENT" });
});
});

View file

@ -1,184 +0,0 @@
import { type ChildProcess, fork } from "node:child_process";
import { once } from "node:events";
import { lstat, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { createServer, type Server, type Socket } from "node:net";
import { join } from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import { Server as RuntimeServer } from "../../server/src/server.ts";
import { createTestServerServices } from "../../server/src/testing/host.ts";
import { createUnixListener } from "../../server/src/transports/unix/listener.ts";
import { discoverUnixServers } from "../src/unix.ts";
const tempDirectories = new Set<string>();
const servers = new Set<RuntimeServer>();
const rawServers = new Set<Server>();
const rawSockets = new Set<Socket>();
const children = new Set<ChildProcess>();
async function makeDirectory(): Promise<string> {
const directory = await mkdtemp(join("/tmp", "pc-"));
tempDirectories.add(directory);
return directory;
}
function serverId(value: number): string {
return `00000000-0000-4000-8000-${value.toString(16).padStart(12, "0")}`;
}
async function startServer(
directory: string,
fileServerId: string,
reportedServerId = fileServerId,
): Promise<RuntimeServer> {
const path = join(directory, `${fileServerId}.sock`);
const server = new RuntimeServer(
{
serverServices: createTestServerServices(),
resolveSession: async () => Promise.reject(new Error("unused")),
openSession: async () => Promise.reject(new Error("unused")),
},
{ listeners: [createUnixListener({ path })], serverId: reportedServerId },
);
servers.add(server);
await server.start();
return server;
}
async function startSilentSocket(
path: string,
connections?: { active: number; maximum: number; total: number },
): Promise<void> {
const server = createServer((socket) => {
rawSockets.add(socket);
if (connections) {
connections.active += 1;
connections.maximum = Math.max(connections.maximum, connections.active);
connections.total += 1;
}
socket.once("close", () => {
rawSockets.delete(socket);
if (connections) connections.active -= 1;
});
});
rawServers.add(server);
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(path, resolve);
});
}
afterEach(async () => {
for (const child of children) {
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
}
await Promise.all([...children].map((child) => (child.exitCode === null ? once(child, "exit") : undefined)));
children.clear();
await Promise.all([...servers].map((server) => server.close()));
servers.clear();
for (const socket of rawSockets) socket.destroy();
rawSockets.clear();
await Promise.all(
[...rawServers].map(
(server) =>
new Promise<void>((resolve) => {
server.close(() => resolve());
}),
),
);
rawServers.clear();
await Promise.all([...tempDirectories].map((directory) => rm(directory, { recursive: true, force: true })));
tempDirectories.clear();
});
describe("discoverUnixServers", () => {
test("returns no routes when the server directory is missing", async () => {
const directory = join(await makeDirectory(), "missing");
await expect(discoverUnixServers({ directory })).resolves.toEqual([]);
});
test("discovers reachable servers in server ID order", async () => {
const directory = await makeDirectory();
const first = serverId(1);
const second = serverId(2);
await startServer(directory, second);
await startServer(directory, first);
await expect(discoverUnixServers({ directory })).resolves.toEqual([
{ serverId: first, path: join(directory, `${first}.sock`) },
{ serverId: second, path: join(directory, `${second}.sock`) },
]);
});
test("ignores malformed entries, non-sockets, and mismatched servers", async () => {
const directory = await makeDirectory();
await writeFile(join(directory, `${serverId(1)}.sock`), "not a socket");
await writeFile(join(directory, "not-a-server.sock"), "ignored");
await mkdir(join(directory, `${serverId(2)}.sock`));
await startServer(directory, serverId(3), serverId(4));
await expect(discoverUnixServers({ directory })).resolves.toEqual([]);
});
test("ignores stale sockets without deleting them", async () => {
const directory = await makeDirectory();
const id = serverId(1);
const path = join(directory, `${id}.sock`);
const child = fork(new URL("fixtures/stale-socket-server.mjs", import.meta.url), [path], {
stdio: ["ignore", "ignore", "inherit", "ipc"],
});
children.add(child);
await once(child, "message");
child.kill("SIGKILL");
await once(child, "exit");
children.delete(child);
await expect(discoverUnixServers({ directory })).resolves.toEqual([]);
expect((await lstat(path)).isSocket()).toBe(true);
});
test("times out an unresponsive socket without deleting it", async () => {
const directory = await makeDirectory();
const id = serverId(1);
const path = join(directory, `${id}.sock`);
await startSilentSocket(path);
await expect(discoverUnixServers({ directory, timeoutMs: 20 })).resolves.toEqual([]);
expect((await lstat(path)).isSocket()).toBe(true);
});
test("limits concurrent probes to 16", async () => {
const directory = await makeDirectory();
const connections = { active: 0, maximum: 0, total: 0 };
for (let index = 1; index <= 20; index++) {
await startSilentSocket(join(directory, `${serverId(index)}.sock`), connections);
}
const discovery = discoverUnixServers({ directory, timeoutMs: 100 });
await expect.poll(() => connections.active).toBe(16);
expect(connections.maximum).toBe(16);
await expect(discovery).resolves.toEqual([]);
expect(connections.total).toBe(20);
});
test("ignores an endpoint that closes before its handshake", async () => {
const directory = await makeDirectory();
const id = serverId(1);
const path = join(directory, `${id}.sock`);
const server = createServer((socket) => socket.destroy());
rawServers.add(server);
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(path, resolve);
});
await expect(discoverUnixServers({ directory })).resolves.toEqual([]);
});
test("propagates unexpected filesystem errors", async () => {
const directory = await makeDirectory();
const file = join(directory, "not-a-directory");
await writeFile(file, "content");
await expect(discoverUnixServers({ directory: file })).rejects.toMatchObject({ code: "ENOTDIR" });
});
});

View file

@ -1,12 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"paths": {
"@earendil-works/pi-protocol": ["../protocol/dist/index.d.ts"]
}
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "**/*.d.ts", "src/**/*.d.ts"]
}

View file

@ -1,17 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"module": "NodeNext",
"moduleResolution": "NodeNext",
"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-protocol": ["../protocol/src/index.ts"],
"@earendil-works/pi-telemetry": ["../telemetry/src/index.ts"]
}
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}

View file

@ -1,17 +0,0 @@
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
reporters: process.env.GITHUB_ACTIONS ? ["dot", "github-actions"] : ["dot"],
},
resolve: {
conditions: ["source"],
alias: {
"@earendil-works/pi-protocol": fileURLToPath(new URL("../protocol/src/index.ts", import.meta.url)),
},
},
ssr: { resolve: { conditions: ["source"] } },
});

View file

@ -28,9 +28,6 @@ Example extensions demonstrating:
- External integrations (SSH, file watchers, system theme sync)
- Custom providers (Anthropic with custom streaming, GitLab Duo)
### [plugins/pi-example-plugin/](plugins/pi-example-plugin/)
An experimental plugin package that Pi automatically builds into separate Session-worker and TUI Chord facets.
## Documentation
- [SDK Examples](sdk/README.md)

View file

@ -1,38 +0,0 @@
# `@earendil-works/pi-example-plugin`
This package provides conventional `session` and `tui` Chord facets. The Session-worker facet provides a remote greeting service. The TUI facet contributes `/hello` and calls that service.
The package needs no build script. Pi asks Chord to discover `src/session.ts` and `src/tui.ts`, builds both entries into its server-owned plugin cache, and sends the TUI artifact to clients.
From the repository root:
```bash
PI_EXPERIMENTAL=1 ./pi-test.sh server \
-e "$PWD/packages/coding-agent/examples/plugins/pi-example-plugin"
```
Alternatively, a client can select the plugin for the Session it creates or resumes on one local server:
```bash
PI_EXPERIMENTAL=1 ./pi-test.sh client \
-e "$PWD/packages/coding-agent/examples/plugins/pi-example-plugin"
```
Repeat `-e` to select multiple plugin packages. Client paths are resolved locally and sent only to a Unix server; Radius clients cannot select server filesystem paths. The Session and matching TUI facets are stored with that Session, so later server generations and clients can resume it without plugin arguments. Other Sessions and their workers are unaffected. An active Session rejects a different package selection instead of being restarted.
`server -e` establishes the server profile's default Session and TUI facets. Starting an explicit foreground server without `-e` clears that default. Client selection never changes the server's root facet generation.
Run `/hello Armin` in the TUI. After editing a facet, run `/reload`. The server atomically rebuilds the package, reloads the attached Session-worker generation, updates the current TUI generation, and serves the new artifact to future clients.
Package metadata can override or disable conventional entries:
```json
{
"chord": {
"facets": {
"session": "./src/worker.ts",
"tui": false
}
}
}
```

View file

@ -1,10 +0,0 @@
{
"name": "@earendil-works/pi-example-plugin",
"version": "1.0.0",
"private": true,
"type": "module",
"peerDependencies": {
"@earendil-works/chord": "^0.84.4",
"@earendil-works/pi-coding-agent": "^0.84.4"
}
}

View file

@ -1,13 +0,0 @@
import { type Context, defineService, type ReplicatedState } from "@earendil-works/chord";
export interface ExampleFacetReply {
readonly message: string;
readonly workerActivations: number;
}
export interface ExampleFacetService {
readonly workerActivations: ReplicatedState<{ count: number }>;
greet(input: { readonly name: string }, context: Context): Promise<ExampleFacetReply>;
}
export const ExampleFacetService = defineService<ExampleFacetService>("pi.example-plugin.greeting");

View file

@ -1,24 +0,0 @@
import { defineFacet } from "@earendil-works/chord";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { ExampleFacetService } from "./contract.ts";
export default defineFacet({
id: "@earendil-works/pi-example-plugin/session",
setup(env) {
const workerActivations = env.replicatedState({ count: 0 });
env.provide(ExampleFacetService, {
workerActivations,
async greet({ name }) {
return {
message: `Hello ${name} from the bundled Session worker facet!!!`,
workerActivations: workerActivations.value.count,
};
},
});
env.onActivate(() => {
workerActivations.change(BACKGROUND_CONTEXT, (draft) => {
draft.count += 1;
});
});
},
});

View file

@ -1,31 +0,0 @@
import { defineFacet } from "@earendil-works/chord";
import { AgentController, PresentationUI, SlashCommands } from "@earendil-works/pi-coding-agent/experimental/plugin";
import { ExampleFacetService } from "./contract.ts";
export default defineFacet({
id: "@earendil-works/pi-example-plugin/tui",
setup(env) {
const example = env.use(ExampleFacetService);
const commands = env.use(SlashCommands);
const controller = env.use(AgentController);
const ui = env.use(PresentationUI);
env.onActivate(() => {
env.own(
commands.replace({
name: "hello",
description: "Call the bundled Session worker facet",
argumentHint: "<name>",
async run(args, context) {
const message = args.length === 0 ? "from the TUI" : args;
const reply = await example.greet({ name: message }, context);
ui.showStatus(
`We got this: ${reply.message} Worker activations: ${reply.workerActivations}.`,
context,
);
return controller.prompt({ message: `pong: ${message}`, images: null }, context);
},
}),
);
});
},
});

View file

@ -385,17 +385,6 @@
"node": ">=6.9.0"
}
},
"node_modules/@earendil-works/chord": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@earendil-works/chord/-/chord-1.1.0.tgz",
"license": "MIT",
"dependencies": {
"esbuild": "0.28.2"
},
"engines": {
"node": ">=22.19.0"
}
},
"node_modules/@earendil-works/pi-agent-core": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-1.1.0.tgz",
@ -507,7 +496,6 @@
"resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-1.1.0.tgz",
"license": "MIT",
"dependencies": {
"@earendil-works/chord": "^1.1.0",
"@earendil-works/pi-agent-core": "^1.1.0",
"@earendil-works/pi-ai": "^1.1.0",
"@earendil-works/pi-codemode": "^1.1.0",
@ -569,422 +557,6 @@
"node": ">=22.19.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"os": [
"aix"
],
"cpu": [
"ppc64"
],
"optional": true
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"os": [
"android"
],
"cpu": [
"arm"
],
"optional": true
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"os": [
"android"
],
"cpu": [
"arm64"
],
"optional": true
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"os": [
"android"
],
"cpu": [
"x64"
],
"optional": true
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"os": [
"darwin"
],
"cpu": [
"arm64"
],
"optional": true
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"os": [
"darwin"
],
"cpu": [
"x64"
],
"optional": true
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"os": [
"freebsd"
],
"cpu": [
"arm64"
],
"optional": true
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"os": [
"freebsd"
],
"cpu": [
"x64"
],
"optional": true
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"os": [
"linux"
],
"cpu": [
"arm"
],
"optional": true
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"os": [
"linux"
],
"cpu": [
"arm64"
],
"optional": true
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"os": [
"linux"
],
"cpu": [
"ia32"
],
"optional": true
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"os": [
"linux"
],
"cpu": [
"loong64"
],
"optional": true
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"os": [
"linux"
],
"cpu": [
"mips64el"
],
"optional": true
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"os": [
"linux"
],
"cpu": [
"ppc64"
],
"optional": true
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"os": [
"linux"
],
"cpu": [
"riscv64"
],
"optional": true
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"os": [
"linux"
],
"cpu": [
"s390x"
],
"optional": true
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"os": [
"linux"
],
"cpu": [
"x64"
],
"optional": true
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"os": [
"netbsd"
],
"cpu": [
"arm64"
],
"optional": true
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"os": [
"netbsd"
],
"cpu": [
"x64"
],
"optional": true
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"os": [
"openbsd"
],
"cpu": [
"arm64"
],
"optional": true
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"os": [
"openbsd"
],
"cpu": [
"x64"
],
"optional": true
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"os": [
"openharmony"
],
"cpu": [
"arm64"
],
"optional": true
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"os": [
"sunos"
],
"cpu": [
"x64"
],
"optional": true
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"os": [
"win32"
],
"cpu": [
"arm64"
],
"optional": true
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"os": [
"win32"
],
"cpu": [
"ia32"
],
"optional": true
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"os": [
"win32"
],
"cpu": [
"x64"
],
"optional": true
},
"node_modules/@google/genai": {
"version": "2.21.0",
"resolved": "https://registry.npmjs.org/@google/genai/-/genai-2.21.0.tgz",
@ -1309,47 +881,6 @@
"safe-buffer": "^5.0.1"
}
},
"node_modules/esbuild": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
"license": "MIT",
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.2",
"@esbuild/android-arm": "0.28.2",
"@esbuild/android-arm64": "0.28.2",
"@esbuild/android-x64": "0.28.2",
"@esbuild/darwin-arm64": "0.28.2",
"@esbuild/darwin-x64": "0.28.2",
"@esbuild/freebsd-arm64": "0.28.2",
"@esbuild/freebsd-x64": "0.28.2",
"@esbuild/linux-arm": "0.28.2",
"@esbuild/linux-arm64": "0.28.2",
"@esbuild/linux-ia32": "0.28.2",
"@esbuild/linux-loong64": "0.28.2",
"@esbuild/linux-mips64el": "0.28.2",
"@esbuild/linux-ppc64": "0.28.2",
"@esbuild/linux-riscv64": "0.28.2",
"@esbuild/linux-s390x": "0.28.2",
"@esbuild/linux-x64": "0.28.2",
"@esbuild/netbsd-arm64": "0.28.2",
"@esbuild/netbsd-x64": "0.28.2",
"@esbuild/openbsd-arm64": "0.28.2",
"@esbuild/openbsd-x64": "0.28.2",
"@esbuild/openharmony-arm64": "0.28.2",
"@esbuild/sunos-x64": "0.28.2",
"@esbuild/win32-arm64": "0.28.2",
"@esbuild/win32-ia32": "0.28.2",
"@esbuild/win32-x64": "0.28.2"
},
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"hasInstallScript": true
},
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",

View file

@ -18,19 +18,10 @@
},
"./rpc-entry": {
"import": "./dist/bundle/rpc-entry.js"
},
"./client": {
"source": "./src/client/index.ts"
},
"./experimental/plugin": {
"source": "./src/experimental/plugin.ts"
}
},
"files": [
"dist",
"!dist/client",
"!dist/experimental",
"!dist/cli/experimental",
"docs",
"examples",
"schemas",
@ -41,7 +32,7 @@
"clean": "shx rm -rf dist",
"build": "npm run build:unbundled && node ../../scripts/build-coding-agent-bundle.mjs",
"build:unbundled": "tsc -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 --no-compile-autoload-dotenv ./dist/bun/cli.js ./src/utils/image-resize-worker.ts ./src/extensions/codemode/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 --no-compile-autoload-dotenv ./dist/bun/cli.js ./src/utils/image-resize-worker.ts ./src/extensions/codemode/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",
@ -49,7 +40,6 @@
"prepublishOnly": "npm run clean && npm run build"
},
"dependencies": {
"@earendil-works/chord": "^1.1.0",
"@earendil-works/pi-agent-core": "^1.1.0",
"@earendil-works/pi-ai": "^1.1.0",
"@earendil-works/pi-codemode": "^1.1.0",
@ -77,9 +67,6 @@
"protobufjs": "7.6.6"
},
"devDependencies": {
"@earendil-works/pi-client": "^1.1.0",
"@earendil-works/pi-protocol": "^1.1.0",
"@earendil-works/pi-server": "^1.1.0",
"@types/cross-spawn": "6.0.6",
"@types/hosted-git-info": "3.0.5",
"@types/node": "22.19.19",

View file

@ -1,16 +0,0 @@
import { Command } from "./command.ts";
import { type ClientCommandContext, clientCommand } from "./commands/client.ts";
import { type ServerCommandContext, serverCommand } from "./commands/server.ts";
interface ExperimentalCommandGroup {
readonly command: "experimental";
}
export type CliContext = ServerCommandContext & ClientCommandContext;
const experimentalCommand = new Command<ExperimentalCommandGroup, CliContext>("experimental").build(() => ({
ok: false,
errors: ["Expected experimental command: server or client"],
}));
export const cli = experimentalCommand.command(serverCommand).command(clientCommand);

View file

@ -1,106 +0,0 @@
import { posix } from "node:path";
import { isServerId, type ServerId } from "@earendil-works/pi-protocol";
import { type ParsedCommandInput, stringOption, valueOption } from "./command.ts";
export type AuthInput =
| { readonly type: "token"; readonly token: string }
| { readonly type: "file"; readonly path: string };
interface UnixTransportAddress {
readonly transport: "unix";
readonly path: string;
}
interface RadiusTransportAddress {
readonly transport: "radius";
readonly serverId: ServerId;
}
export type TransportAddress = UnixTransportAddress | RadiusTransportAddress;
export const authTokenOption = stringOption("--auth-token");
export const authTokenFileOption = stringOption("--auth-token-file");
function parseAuthInput(options: { readonly authToken?: string; readonly authTokenFile?: string }): {
auth?: AuthInput;
errors: string[];
} {
if (options.authToken !== undefined && options.authTokenFile !== undefined) {
return { errors: ["--auth-token and --auth-token-file are mutually exclusive"] };
}
if (options.authToken !== undefined) {
return { auth: { type: "token", token: options.authToken }, errors: [] };
}
if (options.authTokenFile !== undefined) {
return { auth: { type: "file", path: options.authTokenFile }, errors: [] };
}
return { errors: [] };
}
function parseTransportAddress(value: string): { address?: TransportAddress; error?: string } {
let url: URL;
try {
url = new URL(value);
} catch {
return { error: `Invalid --connect address "${value}"` };
}
if (url.protocol === "radius:") {
if (
url.username ||
url.password ||
url.port ||
(url.pathname !== "" && url.pathname !== "/") ||
url.search ||
url.hash ||
value !== `radius://${url.hostname}${url.pathname}`
) {
return { error: `Invalid --connect address "${value}"` };
}
const serverId = url.hostname;
if (!isServerId(serverId)) {
return { error: "Radius transport address requires a lowercase UUIDv4 server ID" };
}
return { address: { transport: "radius", serverId } };
}
if (url.protocol !== "unix:") return { error: `Unsupported --connect transport "${url.protocol}"` };
if (url.hostname || url.port || url.username || url.password) {
return { error: "Unix transport address must not include an authority" };
}
if (
!value.startsWith("unix:///") ||
value.startsWith("unix:////") ||
value.includes("?") ||
value.includes("#") ||
url.href !== value
) {
return { error: `Invalid --connect address "${value}"` };
}
let path: string;
try {
path = decodeURIComponent(url.pathname);
} catch {
return { error: `Invalid --connect address "${value}"` };
}
if (path.includes("\0")) return { error: `Invalid --connect address "${value}"` };
if (!posix.isAbsolute(path)) return { error: "Unix transport address requires an absolute path" };
return { address: { transport: "unix", path } };
}
export const connectOption = valueOption("--connect", (value) => {
const result = parseTransportAddress(value);
return result.address
? { ok: true, value: result.address }
: { ok: false, error: result.error ?? `Invalid --connect address "${value}"` };
});
export function parseAuth(input: ParsedCommandInput): { auth?: AuthInput; errors: string[] } {
return parseAuthInput({
authToken: input.value(authTokenOption),
authTokenFile: input.value(authTokenFileOption),
});
}
export function unsupportedOptions(command: string, input: ParsedCommandInput): string[] {
if (input.remainingArgs.length === 0) return [];
return [`The experimental ${command} command does not support existing CLI options yet`];
}

View file

@ -1,225 +0,0 @@
export interface NamedCommandInvocation {
readonly command: string;
}
export type CommandParseResult<TInvocation extends NamedCommandInvocation = NamedCommandInvocation> =
| { readonly ok: true; readonly command: TInvocation }
| { readonly ok: false; readonly errors: readonly string[] };
export type CommandExecutionResult<TInvocation extends NamedCommandInvocation = NamedCommandInvocation> =
| { readonly ok: true; readonly command: TInvocation }
| { readonly ok: false; readonly errors: readonly string[] };
export type CommandOptionParseResult<TValue> =
| { readonly ok: true; readonly value: TValue }
| { readonly ok: false; readonly error: string };
export interface CommandOption<TValue> {
readonly name: `-${string}`;
readonly flag?: boolean;
readonly repeatable?: boolean;
parse(value: string): CommandOptionParseResult<TValue>;
}
export function valueOption<TValue>(
name: `-${string}`,
parse: (value: string) => CommandOptionParseResult<TValue>,
options: { readonly repeatable?: boolean } = {},
): CommandOption<TValue> {
return { name, parse, ...options };
}
export function stringOption(
name: `-${string}`,
options: { readonly repeatable?: boolean } = {},
): CommandOption<string> {
return valueOption(name, (value) => ({ ok: true, value }), options);
}
export function flagOption(name: `-${string}`): CommandOption<boolean> {
return { name, flag: true, parse: () => ({ ok: true, value: true }) };
}
export interface ParsedCommandInput {
readonly remainingArgs: readonly string[];
value<TValue>(option: CommandOption<TValue>): TValue | undefined;
values<TValue>(option: CommandOption<TValue>): readonly TValue[];
}
export type CommandBuildResult<TInvocation extends NamedCommandInvocation> =
| { readonly ok: true; readonly command: TInvocation }
| { readonly ok: false; readonly errors: readonly string[] };
interface MutableParsedCommandInput {
readonly values: Map<string, unknown[]>;
readonly remainingArgs: string[];
readonly errors: string[];
}
type CommandBuilder<TInvocation extends NamedCommandInvocation> = (
input: ParsedCommandInput,
) => CommandBuildResult<TInvocation>;
type CommandAction<TInvocation extends NamedCommandInvocation, TContext> = (
command: TInvocation,
context: TContext,
) => void | Promise<void>;
interface RegisteredCommand {
parse(argv: readonly string[]): CommandParseResult;
execute(argv: readonly string[], context: unknown): Promise<CommandExecutionResult>;
}
export class Command<
TOwnInvocation extends NamedCommandInvocation,
TContext,
TInvocation extends NamedCommandInvocation = TOwnInvocation,
> {
readonly name: string;
private readonly options = new Map<string, CommandOption<unknown>>();
private readonly subcommands = new Map<string, RegisteredCommand>();
private builder?: CommandBuilder<TOwnInvocation>;
private commandAction?: CommandAction<TOwnInvocation, TContext>;
constructor(name: string) {
this.name = name;
}
option<TValue>(option: CommandOption<TValue>): this {
if (this.options.has(option.name)) {
throw new Error(`Option ${option.name} is already registered for ${this.name}`);
}
this.options.set(option.name, option);
return this;
}
build(builder: CommandBuilder<TOwnInvocation>): this {
this.builder = builder;
return this;
}
action(action: CommandAction<TOwnInvocation, TContext>): this {
this.commandAction = action;
return this;
}
command<
TSubcommandOwnInvocation extends NamedCommandInvocation,
TSubcommandContext,
TSubcommandInvocation extends NamedCommandInvocation,
>(
command: Command<TSubcommandOwnInvocation, TSubcommandContext, TSubcommandInvocation>,
): Command<TOwnInvocation, TContext & TSubcommandContext, TInvocation | TSubcommandInvocation> {
if (this.subcommands.has(command.name)) throw new Error(`Command ${command.name} is already registered`);
this.subcommands.set(command.name, {
parse: (argv) => command.parse(argv),
execute: (argv, context) => command.execute(argv, context as TSubcommandContext),
});
return this as unknown as Command<
TOwnInvocation,
TContext & TSubcommandContext,
TInvocation | TSubcommandInvocation
>;
}
parse(argv: readonly string[]): CommandParseResult<TInvocation> {
const selected = this.select(argv);
if (selected) return selected.command.parse(selected.argv) as CommandParseResult<TInvocation>;
return this.parseOwn(argv) as CommandParseResult<TInvocation>;
}
async execute(argv: readonly string[], context: TContext): Promise<CommandExecutionResult<TInvocation>> {
const selected = this.select(argv);
if (selected) {
return selected.command.execute(selected.argv, context) as Promise<CommandExecutionResult<TInvocation>>;
}
const parsed = this.parseOwn(argv);
if (!parsed.ok) return parsed;
if (!this.commandAction) throw new Error(`Command ${this.name} does not define an action`);
await this.commandAction(parsed.command, context);
return { ok: true, command: parsed.command as unknown as TInvocation };
}
private select(argv: readonly string[]): { command: RegisteredCommand; argv: readonly string[] } | undefined {
const candidate = argv[0];
if (candidate === undefined) return undefined;
const command = this.subcommands.get(candidate);
return command ? { command, argv: argv.slice(1) } : undefined;
}
private parseOwn(argv: readonly string[]): CommandParseResult<TOwnInvocation> {
if (!this.builder) throw new Error(`Command ${this.name} does not define a builder`);
const parsed = this.parseOptions(argv);
const input: ParsedCommandInput = {
remainingArgs: parsed.remainingArgs,
value: <TValue>(option: CommandOption<TValue>) => parsed.values.get(option.name)?.[0] as TValue | undefined,
values: <TValue>(option: CommandOption<TValue>) => (parsed.values.get(option.name) ?? []) as readonly TValue[],
};
const built = this.builder(input);
const errors = [...parsed.errors, ...(built.ok ? [] : built.errors)];
if (errors.length > 0) return { ok: false, errors };
if (!built.ok) throw new Error(`Command ${this.name} failed without an error`);
return { ok: true, command: built.command };
}
private parseOptions(argv: readonly string[]): MutableParsedCommandInput {
const parsed: MutableParsedCommandInput = {
values: new Map(),
remainingArgs: [],
errors: [],
};
for (let index = 0; index < argv.length; index++) {
const argument = argv[index]!;
if (argument === "--") {
parsed.remainingArgs.push(...argv.slice(index));
break;
}
const equals = argument.indexOf("=");
const name = equals === -1 ? argument : argument.slice(0, equals);
const option = this.options.get(name);
if (!option) {
parsed.remainingArgs.push(...argv.slice(index));
break;
}
let value: string;
if (option.flag === true) {
if (equals !== -1) {
parsed.errors.push(`${name} does not take a value`);
continue;
}
value = "";
} else {
let candidate = equals === -1 ? undefined : argument.slice(equals + 1);
if (candidate === undefined) {
const next = argv[index + 1];
if (next !== undefined && !next.startsWith("-")) {
candidate = next;
index++;
}
}
if (candidate === undefined || candidate === "") {
parsed.errors.push(`${name} requires a value`);
continue;
}
value = candidate;
}
const values = parsed.values.get(name) ?? [];
if (values.length > 0 && option.repeatable !== true) {
parsed.errors.push(`${name} may only be specified once`);
continue;
}
const result = option.parse(value);
if (!result.ok) {
parsed.errors.push(result.error);
continue;
}
values.push(result.value);
parsed.values.set(name, values);
}
return parsed;
}
}

View file

@ -1,91 +0,0 @@
import { Command, flagOption, stringOption } from "../command.ts";
import {
type AuthInput,
authTokenFileOption,
authTokenOption,
connectOption,
parseAuth,
type TransportAddress,
unsupportedOptions,
} from "../command-options.ts";
export interface ClientCommand {
readonly command: "client";
readonly auth?: AuthInput;
readonly connect?: TransportAddress;
readonly sessionId?: string;
readonly continue?: boolean;
readonly resume?: boolean;
readonly provider?: string;
readonly model?: string;
readonly pluginPackages?: readonly string[];
readonly prompt?: string;
}
export interface ClientCommandContext {
runClient(command: ClientCommand): void | Promise<void>;
}
const sessionIdOption = stringOption("--session-id");
const continueOption = flagOption("--continue");
const continueShortOption = flagOption("-c");
const resumeOption = flagOption("--resume");
const resumeShortOption = flagOption("-r");
const providerOption = stringOption("--provider");
const modelOption = stringOption("--model");
const pluginPackageOption = stringOption("-e", { repeatable: true });
export const clientCommand = new Command<ClientCommand, ClientCommandContext>("client")
.option(connectOption)
.option(sessionIdOption)
.option(continueOption)
.option(continueShortOption)
.option(resumeOption)
.option(resumeShortOption)
.option(providerOption)
.option(modelOption)
.option(pluginPackageOption)
.option(authTokenOption)
.option(authTokenFileOption)
.build((input) => {
const { auth, errors: authErrors } = parseAuth(input);
const connect = input.value(connectOption);
const sessionId = input.value(sessionIdOption);
const shouldContinue = input.value(continueOption) === true || input.value(continueShortOption) === true;
const shouldResume = input.value(resumeOption) === true || input.value(resumeShortOption) === true;
const provider = input.value(providerOption);
const model = input.value(modelOption);
const pluginPackages = input.values(pluginPackageOption);
const promptArgs = input.remainingArgs[0] === "--" ? input.remainingArgs.slice(1) : input.remainingArgs;
const prompt =
promptArgs.length === 1 &&
(input.remainingArgs[0] === "--" || !promptArgs[0]!.startsWith("-")) &&
promptArgs[0]!.length > 0
? promptArgs[0]
: undefined;
const modelErrors = provider !== undefined && model === undefined ? ["--provider requires --model"] : [];
const sessionSelectionErrors =
[sessionId !== undefined, shouldContinue, shouldResume].filter(Boolean).length > 1
? ["--session-id, --continue, and --resume are mutually exclusive"]
: [];
const unsupportedErrors =
input.remainingArgs.length === 0 || prompt !== undefined ? [] : unsupportedOptions("client", input);
const errors = [...authErrors, ...modelErrors, ...sessionSelectionErrors, ...unsupportedErrors];
if (errors.length > 0) return { ok: false, errors };
return {
ok: true,
command: {
command: "client",
...(auth === undefined ? {} : { auth }),
...(connect === undefined ? {} : { connect }),
...(sessionId === undefined ? {} : { sessionId }),
...(shouldContinue ? { continue: true } : {}),
...(shouldResume ? { resume: true } : {}),
...(provider === undefined ? {} : { provider }),
...(model === undefined ? {} : { model }),
...(pluginPackages.length === 0 ? {} : { pluginPackages }),
...(prompt === undefined ? {} : { prompt }),
},
};
})
.action((command, context) => context.runClient(command));

View file

@ -1,66 +0,0 @@
import { isServerId, type ServerId } from "@earendil-works/pi-protocol";
import { Command, stringOption, valueOption } from "../command.ts";
import {
type AuthInput,
authTokenFileOption,
authTokenOption,
parseAuth,
unsupportedOptions,
} from "../command-options.ts";
export interface ServerCommand {
readonly command: "server";
readonly auth?: AuthInput;
readonly provider?: string;
readonly model?: string;
readonly pluginPackages?: readonly string[];
readonly serverId?: ServerId;
readonly sessionDir?: string;
}
export interface ServerCommandContext {
runServer(command: ServerCommand): void | Promise<void>;
}
const serverIdOption = valueOption("--server-id", (value) =>
isServerId(value)
? { ok: true, value }
: { ok: false, error: `Invalid --server-id "${value}"; expected a lowercase UUIDv4` },
);
const sessionDirOption = stringOption("--session-dir");
const providerOption = stringOption("--provider");
const modelOption = stringOption("--model");
const pluginPackageOption = stringOption("-e", { repeatable: true });
export const serverCommand = new Command<ServerCommand, ServerCommandContext>("server")
.option(serverIdOption)
.option(sessionDirOption)
.option(providerOption)
.option(modelOption)
.option(pluginPackageOption)
.option(authTokenOption)
.option(authTokenFileOption)
.build((input) => {
const { auth, errors: authErrors } = parseAuth(input);
const serverId = input.value(serverIdOption);
const sessionDir = input.value(sessionDirOption);
const provider = input.value(providerOption);
const model = input.value(modelOption);
const pluginPackages = input.values(pluginPackageOption);
const modelErrors = provider !== undefined && model === undefined ? ["--provider requires --model"] : [];
const errors = [...authErrors, ...modelErrors, ...unsupportedOptions("server", input)];
if (errors.length > 0) return { ok: false, errors };
return {
ok: true,
command: {
command: "server",
...(auth === undefined ? {} : { auth }),
...(provider === undefined ? {} : { provider }),
...(model === undefined ? {} : { model }),
...(pluginPackages.length === 0 ? {} : { pluginPackages }),
...(serverId === undefined ? {} : { serverId }),
...(sessionDir === undefined ? {} : { sessionDir }),
},
};
})
.action((command, context) => context.runServer(command));

View file

@ -1 +0,0 @@
export * from "@earendil-works/pi-client";

View file

@ -1,12 +0,0 @@
#!/usr/bin/env node
import { setupCli } from "../cli/setup.ts";
import { main } from "../main.ts";
import { runExperimentalCommand } from "./commands.ts";
setupCli();
const args = process.argv.slice(2);
if (await runExperimentalCommand(args)) {
if (args[0] === "client") process.exit(process.exitCode ?? 0);
} else {
await main(args);
}

View file

@ -1,230 +0,0 @@
import { basename } from "node:path";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { Client, ServerError } from "@earendil-works/pi-client";
import { createUnixTransportFactory, discoverUnixServers, type UnixServerRoute } from "@earendil-works/pi-client/unix";
import { isServerId, type ServerId } from "@earendil-works/pi-protocol";
import type { ClientCommand } from "../cli/experimental/commands/client.ts";
import { RadiusRelayAuthResolver } from "./radius-auth.ts";
import { createRadiusClientTransportFactory, RadiusClientReconnect } from "./radius-relay.ts";
import { activateServer, ENV_SERVER_ID, resolveServerDirectory, resolveSessionDirectory } from "./server.ts";
import { AgentController } from "./services/agent-controller.ts";
import {
createServerServiceSource,
createSessionServiceSource,
type ServerServiceSource,
type SessionServiceSource,
} from "./services/connection.ts";
import { Models } from "./services/models.ts";
import { PresentationPlugins } from "./services/plugins.ts";
import { SessionDirectory, SessionManagement } from "./services/sessions.ts";
import { Transcript } from "./services/transcript.ts";
export type ClientRuntimeRoute =
| ({ readonly transport: "unix" } & UnixServerRoute)
| { readonly transport: "radius"; readonly serverId: ServerId };
export interface ClientRuntimeServer {
readonly route: ClientRuntimeRoute;
readonly client: Client;
readonly server: ServerServiceSource;
readonly session: SessionServiceSource;
}
export interface ActivatedClientRuntimeServer extends ClientRuntimeServer {
readonly directory: SessionDirectory;
readonly management: SessionManagement;
readonly plugins: PresentationPlugins;
readonly models: Models;
readonly agent: AgentController;
readonly transcript: Transcript;
}
export interface ClientRuntime {
readonly servers: readonly ClientRuntimeServer[];
dispose(): Promise<void>;
}
export interface OpenClientRuntimeOptions {
/** Directory searched when --connect is omitted. Defaults to PI_SERVER_DIR or ~/.pi/server. */
readonly directory?: string;
}
/** Open live server/session service namespaces for one experimental presentation. */
export async function openClientRuntime(
command: ClientCommand,
options: OpenClientRuntimeOptions = {},
): Promise<ClientRuntime> {
if (command.auth !== undefined && command.connect?.transport !== "radius") {
throw new Error("Authentication is only supported for experimental Radius connections");
}
if (command.provider !== undefined && command.model === undefined) {
throw new Error("Server model provider requires a model");
}
if (command.connect && command.model !== undefined) {
throw new Error("Model selection is only valid when automatically activating a new server");
}
if (command.connect?.transport === "radius" && command.pluginPackages !== undefined) {
throw new Error("Plugin package paths can only be configured on a local Unix server");
}
const directory = resolveServerDirectory(options.directory);
let routes: ClientRuntimeRoute[];
let activatedClient: Client | undefined;
if (command.connect) {
routes = [
command.connect.transport === "radius"
? { transport: "radius", serverId: command.connect.serverId }
: { transport: "unix", ...routeFromExplicitPath(command.connect.path) },
];
} else {
routes = (await discoverUnixServers({ directory })).map((route) => ({ transport: "unix", ...route }));
if (routes.length > 0 && command.model !== undefined) {
throw new Error("Model selection is only valid when automatically activating a new server");
}
if (routes.length === 0) {
const activated = await activateServer({
directory,
requestedServerId: process.env[ENV_SERVER_ID],
sessionDir: resolveSessionDirectory(),
provider: command.provider,
model: command.model,
});
routes = [{ transport: "unix", ...activated.route }];
activatedClient = activated.client;
}
}
if (command.pluginPackages !== undefined && routes.length !== 1) {
throw new Error("Plugin selection requires exactly one local server");
}
const clients: Client[] = [];
const reconnectors: RadiusClientReconnect[] = [];
const serviceSources: Array<ServerServiceSource | SessionServiceSource> = [];
const servers: ClientRuntimeServer[] = [];
let disposed = false;
const dispose = async (): Promise<void> => {
if (disposed) return;
disposed = true;
const reconnectResults = await Promise.allSettled(reconnectors.map((reconnector) => reconnector.dispose()));
const sourceResults = await Promise.allSettled(
serviceSources.map((source) => source.dispose(BACKGROUND_CONTEXT)),
);
const clientResults = await Promise.allSettled(clients.map((client) => client.dispose()));
const errors = [...reconnectResults, ...sourceResults, ...clientResults].flatMap((result) =>
result.status === "rejected" ? [result.reason] : [],
);
if (errors.length === 1) throw errors[0];
if (errors.length > 1) throw new AggregateError(errors, "Failed to dispose experimental client runtime");
};
try {
for (const route of routes) {
let client = activatedClient;
if (client === undefined) {
try {
client = await Client.connect({
serverId: route.serverId,
transportFactory:
route.transport === "unix"
? createUnixTransportFactory({ path: route.path })
: createRadiusClientTransportFactory({
serverId: route.serverId,
auth: new RadiusRelayAuthResolver(command.auth),
}),
});
} catch (error) {
if (
command.connect !== undefined ||
route.transport !== "unix" ||
!(error instanceof ServerError) ||
error.code !== "version"
) {
throw error;
}
client = (
await activateServer({
directory,
requestedServerId: route.serverId,
sessionDir: resolveSessionDirectory(),
})
).client;
}
}
activatedClient = undefined;
clients.push(client);
const server = createServerServiceSource(client);
serviceSources.push(server);
if (route.transport === "radius") {
const reconnectServices = server.open({
services: [SessionManagement],
assertAccess() {},
onError() {},
});
const reconnectManagement = reconnectServices.use(SessionManagement);
reconnectors.push(
new RadiusClientReconnect(client, async (sessionId) => {
await reconnectServices.ready(BACKGROUND_CONTEXT);
await reconnectManagement.attach(sessionId, BACKGROUND_CONTEXT);
}),
);
}
const session = createSessionServiceSource(client);
serviceSources.push(session);
servers.push({ route, client, server, session });
}
return { servers, dispose };
} catch (error) {
try {
await dispose();
} catch (cleanupError) {
throw new AggregateError([error, cleanupError], "Experimental client startup and cleanup failed");
}
throw error;
}
}
/** Acquire and connect the built-in service facades used by the non-interactive client. */
export async function activateBuiltinClientServices(
server: ClientRuntimeServer,
): Promise<ActivatedClientRuntimeServer> {
const serverServices = server.server.open({
services: [SessionDirectory, SessionManagement, PresentationPlugins],
assertAccess() {},
onError() {},
});
const sessionServices = server.session.open({
services: [Models, AgentController, Transcript],
assertAccess() {},
onError() {},
});
const directory = serverServices.use(SessionDirectory);
const remoteManagement = serverServices.use(SessionManagement);
const management: SessionManagement = {
create: (options, context) => remoteManagement.create(options, context),
async remove(sessionId, context) {
const removesCurrentAttachment = server.client.attachment?.sessionId === sessionId;
await remoteManagement.remove(sessionId, context);
if (removesCurrentAttachment) await server.session.whenDetached(context);
},
async attach(sessionId, context) {
await remoteManagement.attach(sessionId, context);
await server.session.whenAttached(sessionId, context);
},
async detach(context) {
await remoteManagement.detach(context);
await server.session.whenDetached(context);
},
};
const plugins = serverServices.use(PresentationPlugins);
const models = sessionServices.use(Models);
const agent = sessionServices.use(AgentController);
const transcript = sessionServices.use(Transcript);
await Promise.all([serverServices.ready(BACKGROUND_CONTEXT), sessionServices.ready(BACKGROUND_CONTEXT)]);
return { ...server, directory, management, plugins, models, agent, transcript };
}
function routeFromExplicitPath(path: string): UnixServerRoute {
const name = basename(path);
const serverId = name.endsWith(".sock") ? name.slice(0, -".sock".length) : "";
if (!isServerId(serverId)) throw new Error("--connect path must end with <uuidv4-server-id>.sock");
return { serverId, path };
}

View file

@ -1,232 +0,0 @@
import type { AssistantMessage, ToolResultMessage, UserMessage } from "@earendil-works/pi-ai";
import type { ConversationView, EntryRecord, InboxState, LiveState } from "@earendil-works/pi-durable";
import { Container, Spacer, Text, TruncatedText, type TUI } from "@earendil-works/pi-tui";
import { createAllToolRenderers } from "../core/tools/renderers/index.ts";
import { AssistantMessageComponent } from "../modes/interactive/components/assistant-message.ts";
import { type StatusIndicator, WorkingStatusIndicator } from "../modes/interactive/components/status-indicator.ts";
import { ToolExecutionComponent, type ToolRenderers } from "../modes/interactive/components/tool-execution.ts";
import { UserMessageComponent } from "../modes/interactive/components/user-message.ts";
import { theme } from "../modes/interactive/theme/theme.ts";
/** The `pi.live` document of a view: the active run, the streaming answer, and running tools. */
export function liveOf(view: ConversationView): LiveState {
return (view.docs["pi.live"] ?? {}) as LiveState;
}
function userText(content: UserMessage["content"]): string {
if (typeof content === "string") return content;
return content
.filter((block) => block.type === "text")
.map((block) => block.text)
.join("");
}
/** Renders the root conversation's durable view for the service-only experimental presentation. */
export class ExperimentalChatView {
static readonly #renderers: Record<string, ToolRenderers> = createAllToolRenderers();
readonly transcript = new Container();
readonly pendingMessages = new Container();
readonly status = new Container();
readonly #ui: TUI;
readonly #cwd: string;
/** The newest card per call ID; provider call IDs may repeat across turns. */
readonly #tools = new Map<string, ToolExecutionComponent>();
/** Every card shown, also older ones whose call ID a later turn reused. */
readonly #cards: ToolExecutionComponent[] = [];
/** Call IDs whose cards the streaming answer created; its entry takes them over. */
readonly #streamingCalls = new Set<string>();
#renderedEntryIds: number[] = [];
#streaming: AssistantMessageComponent | undefined;
#indicator: StatusIndicator | undefined;
#statusText = "";
constructor(ui: TUI, cwd: string) {
this.#ui = ui;
this.#cwd = cwd;
}
apply(view: ConversationView): void {
const live = liveOf(view);
this.#syncTranscript(view.entries);
const message = live.generation?.message as AssistantMessage | undefined;
// A partial without its entry was dropped, for example by a retry: render the transcript again.
if (message === undefined && this.#streaming !== undefined) this.#rebuild(view.entries);
if (message !== undefined) this.#syncStreaming(message);
for (const slot of live.tools ?? []) {
if (slot.status === "pending") continue;
const component = this.#tool(slot.name, slot.callId);
component.setArgsComplete();
if (slot.status !== "running") continue;
component.markExecutionStarted();
if (slot.output !== undefined) {
component.updateResult(
{ content: [{ type: "text", text: slot.output }], details: slot.details, isError: false },
true,
);
}
}
this.#syncQueue((view.docs["pi.inbox"] ?? { items: [] }) as InboxState);
this.#syncStatus(live);
this.transcript.invalidate();
this.pendingMessages.invalidate();
this.status.invalidate();
}
refreshTheme(view: ConversationView): void {
this.#indicator?.dispose();
this.#indicator = undefined;
this.#statusText = "";
this.status.clear();
this.#rebuild(view.entries);
this.apply(view);
}
dispose(): void {
this.#discardTools();
this.#indicator?.dispose();
}
/** Finish every card: a running bash card keeps a timer until it gets a final result. */
#discardTools(): void {
for (const component of this.#cards) component.updateResult({ content: [], isError: false }, false);
this.#cards.length = 0;
this.#tools.clear();
this.#streamingCalls.clear();
}
#syncQueue(inbox: InboxState): void {
this.pendingMessages.clear();
for (const item of inbox.items) {
const text =
item.mode === "write"
? `<${String(item.entry.kind)}>`
: userText(item.content as UserMessage["content"]).replace(/\s+/g, " ");
this.pendingMessages.addChild(new TruncatedText(theme.fg("muted", `[${item.mode}] ${text}`), 1, 0));
}
}
#syncStatus(live: LiveState): void {
const generation = live.generation;
const compaction = live.compactions?.[0];
const runningTool = live.tools?.find((slot) => slot.status === "running");
let text = "";
if (generation?.retry !== undefined) {
text = `Retrying (attempt ${generation.attempt + 1}): ${generation.retry.error}`;
} else if (generation?.deferred !== undefined) text = "Waiting for deferred response...";
else if (compaction !== undefined) {
text = compaction.retry
? `Retrying ${compaction.reason} compaction (attempt ${compaction.attempt + 1})...`
: `Compacting (${compaction.reason})...`;
} else if (runningTool !== undefined) text = `Running ${runningTool.name}... (esc to abort)`;
else if (live.run !== undefined) text = "Working... (esc to abort)";
if (text === this.#statusText) return;
this.#statusText = text;
this.#indicator?.dispose();
this.#indicator = undefined;
this.status.clear();
if (text.length > 0) {
this.#indicator = new WorkingStatusIndicator(this.#ui, text);
this.status.addChild(this.#indicator);
}
}
#syncTranscript(entries: readonly EntryRecord[]): void {
// Compaction and resets replace the head of the active transcript.
if (this.#renderedEntryIds.some((id, index) => entries[index]?.id !== id)) this.#rebuild(entries);
for (const entry of entries.slice(this.#renderedEntryIds.length)) {
this.#addEntry(entry);
this.#renderedEntryIds.push(entry.id);
}
}
#rebuild(entries: readonly EntryRecord[]): void {
this.transcript.clear();
this.#discardTools();
this.#renderedEntryIds = [];
this.#streaming = undefined;
for (const entry of entries) {
this.#addEntry(entry);
this.#renderedEntryIds.push(entry.id);
}
}
#addEntry(entry: EntryRecord): void {
const message = entry.model?.[0];
if (entry.kind === "pi.user" && message?.role === "user") {
this.transcript.addChild(new Spacer(1));
this.transcript.addChild(new UserMessageComponent(userText(message.content)));
} else if (entry.kind === "pi.assistant" && message?.role === "assistant") {
const component = this.#streaming ?? new AssistantMessageComponent();
if (this.#streaming === undefined) this.transcript.addChild(component);
this.#streaming = undefined;
component.updateContent(message, false);
// Only a tool-calling answer runs its calls; an aborted, failed, or truncated one never does.
const ran = message.stopReason === "toolUse";
for (const content of message.content) {
if (content.type !== "toolCall") continue;
const streamed = this.#streamingCalls.has(content.id);
if (!ran && !streamed) continue;
const card = this.#tool(content.name, content.id, content.arguments, !streamed);
card.setArgsComplete();
if (!ran) {
const text = "Not run: the answer was interrupted.";
card.updateResult({ content: [{ type: "text", text }], isError: true }, false);
}
}
this.#streamingCalls.clear();
} else if (entry.kind === "pi.tool-result" && message?.role === "toolResult") {
const result = message as ToolResultMessage;
this.#tool(result.toolName, result.toolCallId).updateResult(result);
} else if (entry.kind === "pi.compaction") {
this.#addText(theme.fg("muted", "[compaction]"));
if (message?.role === "user") this.#addText(userText(message.content));
} else if (entry.kind === "pi.reset") {
this.#addText(theme.fg("muted", "[new context]"));
} else if (!entry.kind.startsWith("pi.") && message?.role === "user" && entry.model?.length === 1) {
// An input placed as an application entry.
this.transcript.addChild(new Spacer(1));
this.transcript.addChild(new UserMessageComponent(userText(message.content)));
}
}
#syncStreaming(message: AssistantMessage): void {
if (this.#streaming === undefined) {
this.#streaming = new AssistantMessageComponent();
this.transcript.addChild(this.#streaming);
}
this.#streaming.updateContent(message, true);
for (const content of message.content) {
if (content.type !== "toolCall") continue;
this.#tool(content.name, content.id, content.arguments, !this.#streamingCalls.has(content.id));
this.#streamingCalls.add(content.id);
}
}
/** The card of a call; `fresh` starts a new one for a call ID an earlier turn used. */
#tool(toolName: string, toolCallId: string, args?: unknown, fresh = false): ToolExecutionComponent {
const existing = fresh ? undefined : this.#tools.get(toolCallId);
if (existing !== undefined) {
if (args !== undefined) existing.updateArgs(args);
return existing;
}
const component = new ToolExecutionComponent(
toolName,
toolCallId,
args ?? {},
{},
ExperimentalChatView.#renderers[toolName],
this.#ui,
this.#cwd,
);
this.transcript.addChild(component);
this.#cards.push(component);
this.#tools.set(toolCallId, component);
return component;
}
#addText(text: string): void {
this.transcript.addChild(new Spacer(1));
this.transcript.addChild(new Text(text, 1, 0));
}
}

View file

@ -1,809 +0,0 @@
import { resolve } from "node:path";
import {
combineFacetLoaders,
createFacetHost,
defineFacet,
type FacetHost,
type FacetLoader,
type JsonValue,
type LoadedFacets,
} from "@earendil-works/chord";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import type { AgentState, ConversationView } from "@earendil-works/pi-durable";
import {
CombinedAutocompleteProvider,
type Component,
Container,
type SelectItem,
SelectList,
setKeybindings,
Text,
type TUI,
} from "@earendil-works/pi-tui";
import type { ClientCommand } from "../cli/experimental/commands/client.ts";
import { getAgentDir } from "../config.ts";
import { KeybindingsManager } from "../core/keybindings.ts";
import { DefaultResourceLoader } from "../core/resource-loader.ts";
import { SettingsManager } from "../core/settings-manager.ts";
import { createChatViewport } from "../modes/interactive/chat-viewport.ts";
import { CustomEditor } from "../modes/interactive/components/custom-editor.ts";
import { getEditorTheme, setRegisteredThemes, stopThemeWatcher, theme } from "../modes/interactive/theme/theme.ts";
import { InteractiveThemeController } from "../modes/interactive/theme/theme-controller.ts";
import { createInteractiveTui } from "../modes/interactive/tui-renderer.ts";
import { type OpenClientRuntimeOptions, openClientRuntime } from "./client-runtime.ts";
import { ExperimentalChatView, liveOf } from "./client-tui-chat.ts";
import { createPresentationFacetLoaders } from "./plugins/bundled.ts";
import { AgentController, type AgentOperationResponse, type AgentQueueResponse } from "./services/agent-controller.ts";
import type {
ServerConnectionState,
ServerServiceSource,
SessionAttachmentState,
SessionServiceSource,
} from "./services/connection.ts";
import { PresentationPlugins } from "./services/plugins.ts";
import { PresentationUI } from "./services/presentation-ui.ts";
import { SessionDirectory, SessionManagement, type SessionSummary } from "./services/sessions.ts";
import { SlashCommands } from "./services/slash-commands.ts";
import {
createBuiltInSlashCommandsFacet,
createSlashCommandsRuntimeFacet,
} from "./services/slash-commands-provider.ts";
import { Transcript, type Transcript as TranscriptService } from "./services/transcript.ts";
export interface RunClientTuiOptions extends OpenClientRuntimeOptions {
readonly facetLoader?: FacetLoader;
}
export interface ClientTuiServer {
readonly serverId: string;
readonly radius: boolean;
readonly server: ServerServiceSource;
readonly session: SessionServiceSource;
}
interface SessionFeature {
readonly serverId: string;
readonly session: SessionServiceSource;
readonly transcript: TranscriptService;
}
interface PreparedClientSession {
readonly server: ClientTuiServer;
readonly summary: SessionSummary;
readonly presentationPlugins: JsonValue;
}
interface PendingSelection {
readonly title: string;
readonly items: readonly SelectItem[];
readonly selectedValue?: string;
resolve(value: string | undefined): void;
}
const selectTheme = {
selectedPrefix: (text: string) => theme.fg("accent", text),
selectedText: (text: string) => theme.fg("accent", text),
description: (text: string) => theme.fg("muted", text),
scrollInfo: (text: string) => theme.fg("dim", text),
noMatch: (text: string) => theme.fg("warning", text),
};
/** Service-only presentation driven by a replicated main-lane snapshot. */
export class ExperimentalClientTui implements Component {
readonly #ui: TUI;
readonly #requestRender: () => void;
readonly #finish: () => void;
readonly #documentContainer = new Container();
readonly #sessionHeading = new Text("", 1, 0);
readonly #pendingMessagesContainer = new Container();
readonly #statusContainer = new Container();
readonly #editorContainer = new Container();
readonly #footerComponent = new Text("", 1, 0);
readonly #layoutRoot: Component;
readonly #sharedFacets: LoadedFacets;
readonly #keybindings = KeybindingsManager.create();
#presentationFacets: LoadedFacets | undefined;
#facetHost: FacetHost | undefined;
#facetReloadTail = Promise.resolve();
#session: SessionFeature | undefined;
#slashCommands: SlashCommands | undefined;
#controller: AgentController | undefined;
readonly #chatInput: CustomEditor;
#selectList: SelectList | undefined;
#selection: PendingSelection | undefined;
#screen: "select" | "chat" = "chat";
#selectedServerId: string | undefined;
#sessionId: string | undefined;
#status = "Starting Session…";
#busy = false;
#closed = false;
#closePromise: Promise<void> | undefined;
#recoveryTransition: Promise<void> = Promise.resolve();
#laneUnsubscribe: (() => void) | undefined;
#chatView: ExperimentalChatView | undefined;
private constructor(ui: TUI, requestRender: () => void, finish: () => void, loadedFacets: LoadedFacets) {
this.#ui = ui;
this.#requestRender = requestRender;
this.#finish = finish;
this.#sharedFacets = loadedFacets;
setKeybindings(this.#keybindings);
this.#chatInput = new CustomEditor(ui, getEditorTheme(), this.#keybindings, { paddingX: 1 });
this.#chatInput.onSubmit = (message) => void this.#runPrompt(message);
this.#chatInput.onEscape = () => this.#interrupt();
this.#chatInput.onCtrlD = finish;
this.#chatInput.onAction("app.clear", finish);
this.#chatInput.onAction("app.model.select", () => void this.#executeSlashCommand("model", ""));
this.#chatInput.onAction("app.message.followUp", () => {
const text = this.#chatInput.getText().trim();
if (text.length === 0) return;
this.#chatInput.setText("");
void this.#queueFollowUp(text);
});
this.#editorContainer.addChild(this.#chatInput);
this.#layoutRoot = createChatViewport({
document: this.#documentContainer,
pendingMessages: this.#pendingMessagesContainer,
status: this.#statusContainer,
editor: this.#editorContainer,
footer: this.#footerComponent,
scrollbarTrackStyle: (text) => theme.fg("scrollbarTrack", text),
scrollbarThumbStyle: (text) => theme.fg("scrollbarThumb", text),
}).root;
this.#rebuild();
}
static async create(options: {
readonly command: ClientCommand;
readonly ui: TUI;
readonly servers: readonly ClientTuiServer[];
readonly facetLoader?: FacetLoader;
requestRender(): void;
finish(): void;
}): Promise<ExperimentalClientTui> {
const prepared = await prepareClientSession(options.command, options.servers);
const loadedFacets = await combineFacetLoaders(
options.facetLoader === undefined ? [] : [options.facetLoader],
).load();
const component = new ExperimentalClientTui(options.ui, options.requestRender, options.finish, loadedFacets);
try {
await component.#start(prepared);
await component.#openPreparedSession(prepared);
return component;
} catch (error) {
try {
await component.close();
} catch (cleanupError) {
throw new AggregateError([error, cleanupError], "Experimental TUI startup and cleanup failed");
}
throw error;
}
}
get layoutRoot(): Component {
return this.#layoutRoot;
}
render(width: number): string[] {
return [
...this.#documentContainer.render(width),
...this.#pendingMessagesContainer.render(width),
...this.#statusContainer.render(width),
...this.#editorContainer.render(width),
...this.#footerComponent.render(width),
];
}
handleInput(data: string): void {
if (this.#busy) {
if (
this.#keybindings.matches(data, "app.clear") ||
(this.#chatInput.getText().length === 0 && this.#keybindings.matches(data, "app.exit"))
) {
this.#finish();
}
return;
}
if (this.#screen === "chat") {
this.#chatInput.handleInput(data);
this.#requestRender();
return;
}
this.#selectList?.handleInput(data);
}
invalidate(): void {
this.#layoutRoot.invalidate();
}
dispose(): void {
void this.close().catch(() => {});
}
refreshTheme(): void {
const view = this.#conversationView();
if (view !== undefined) this.#chatView?.refreshTheme(view);
this.#rebuild();
}
showError(error: string): void {
this.#status = `Error: ${error}`;
this.#rebuild();
}
close(): Promise<void> {
this.#closePromise ??= this.#close();
return this.#closePromise;
}
async #start(prepared: PreparedClientSession): Promise<void> {
const server = prepared.server;
let presentationFacets = await combineFacetLoaders(
createPresentationFacetLoaders(prepared.presentationPlugins),
).load();
this.#presentationFacets = presentationFacets;
let facetHost!: FacetHost;
const reloadPresentationPlugins = (data: JsonValue): Promise<void> => {
const operation = this.#facetReloadTail.then(async () => {
const candidate = await combineFacetLoaders(createPresentationFacetLoaders(data)).load();
try {
await facetHost.reload(candidate.facets);
} catch (error) {
try {
await candidate.dispose();
} catch (cleanupError) {
throw new AggregateError([error, cleanupError], "TUI plugin reload and cleanup failed");
}
throw error;
}
const retired = presentationFacets;
presentationFacets = candidate;
this.#presentationFacets = candidate;
await retired.dispose();
});
this.#facetReloadTail = operation.catch(() => {});
return operation;
};
const presentationBridgeFacet = defineFacet({
id: "@pi/presentation-bridge",
setup: (env) => {
env.provide(PresentationUI, {
select: (title, items, selectedValue) =>
this.#select(
title,
items.map((item) => ({ ...item })),
selectedValue,
),
showStatus: (status) => {
this.#status = status;
this.#rebuild();
},
});
const commands = env.use(SlashCommands);
const controller = env.use(AgentController);
const transcript = env.use(Transcript);
const sessionFeature: SessionFeature = {
serverId: server.serverId,
session: server.session,
transcript,
};
env.onActivate(() => {
if (this.#session !== undefined || this.#slashCommands !== undefined || this.#controller !== undefined) {
throw new Error("Presentation services are already active");
}
this.#session = sessionFeature;
this.#slashCommands = commands;
this.#controller = controller;
env.own(() => {
if (this.#session === sessionFeature) this.#session = undefined;
if (this.#slashCommands === commands) this.#slashCommands = undefined;
if (this.#controller === controller) this.#controller = undefined;
});
env.own(commands.subscribe(() => this.#updateAutocomplete()));
if (server.radius) {
env.own(
server.server.connection.subscribe((state) => this.#handleConnectionState(server.serverId, state)),
);
env.own(
server.session.attachment.subscribe((state) => this.#handleAttachmentState(sessionFeature, state)),
);
}
});
},
});
facetHost = await createFacetHost({
facets: [
createSlashCommandsRuntimeFacet(),
presentationBridgeFacet,
createBuiltInSlashCommandsFacet({ reloadPresentationPlugins }),
...this.#sharedFacets.facets,
...presentationFacets.facets,
],
serviceSources: [server.server, server.session],
});
this.#facetHost = facetHost;
}
async #openPreparedSession(prepared: PreparedClientSession): Promise<void> {
const feature = this.#session;
if (feature === undefined) throw new Error(`No Session service is available for ${prepared.server.serverId}`);
await feature.session.whenAttached(prepared.summary.sessionId, BACKGROUND_CONTEXT);
this.#selectedServerId = feature.serverId;
this.#sessionId = prepared.summary.sessionId;
this.#updateAutocomplete();
await this.#openLane(feature);
this.#screen = "chat";
this.#status = "";
this.#rebuild();
}
async #close(): Promise<void> {
this.#closed = true;
this.#completeSelection(undefined);
const errors: unknown[] = [];
try {
await this.#recoveryTransition;
await this.#closeLane();
await this.#facetReloadTail;
} catch (error) {
errors.push(error);
}
if (this.#facetHost !== undefined) {
try {
await this.#facetHost.dispose();
} catch (error) {
errors.push(error);
}
this.#facetHost = undefined;
}
const generations = [this.#presentationFacets, this.#sharedFacets].filter(
(generation): generation is LoadedFacets => generation !== undefined,
);
this.#presentationFacets = undefined;
const results = await Promise.allSettled(generations.map((generation) => generation.dispose()));
errors.push(...results.flatMap((result) => (result.status === "rejected" ? [result.reason] : [])));
if (errors.length === 1) throw errors[0];
if (errors.length > 1) throw new AggregateError(errors, "Failed to dispose experimental TUI facets");
}
#rebuild(): void {
this.#sessionHeading.setText(
this.#sessionId === undefined || this.#selectedServerId === undefined
? ""
: theme.fg("dim", `Server: ${this.#selectedServerId}\nSession: ${this.#sessionId}`),
);
this.#statusContainer.clear();
if (this.#status.length > 0) {
this.#statusContainer.addChild(new Text(theme.fg("dim", this.#status), 1, 0));
}
if (this.#chatView !== undefined) this.#statusContainer.addChild(this.#chatView.status);
this.#footerComponent.setText(theme.fg("dim", this.#footer()));
this.#editorContainer.clear();
if (this.#screen === "select" && this.#selection !== undefined) {
this.#chatInput.focused = false;
const selector = new Container();
selector.addChild(new Text(theme.bold(this.#selection.title), 1, 1));
const items = [...this.#selection.items];
this.#selectList = new SelectList(items, Math.min(Math.max(items.length, 1), 12), selectTheme);
const selectedIndex = items.findIndex((item) => item.value === this.#selection?.selectedValue);
if (selectedIndex >= 0) this.#selectList.setSelectedIndex(selectedIndex);
this.#selectList.onSelect = (item) => this.#completeSelection(item.value);
this.#selectList.onCancel = () => this.#completeSelection(undefined);
selector.addChild(this.#selectList);
this.#editorContainer.addChild(selector);
} else {
this.#selectList = undefined;
this.#chatInput.focused = !this.#busy;
this.#editorContainer.addChild(this.#chatInput);
}
this.#layoutRoot.invalidate();
this.#requestRender();
}
#select(title: string, items: readonly SelectItem[], selectedValue?: string): Promise<string | undefined> {
if (this.#selection !== undefined) throw new Error("A slash command selector is already active");
return new Promise((resolve) => {
this.#selection = { title, items, ...(selectedValue === undefined ? {} : { selectedValue }), resolve };
this.#screen = "select";
this.#rebuild();
});
}
#completeSelection(value: string | undefined): void {
const selection = this.#selection;
if (selection === undefined) return;
this.#selection = undefined;
this.#screen = "chat";
selection.resolve(value);
if (!this.#closed) this.#rebuild();
}
#updateAutocomplete(): void {
const commands = this.#selectedSlashCommands()?.list() ?? [];
this.#chatInput.setAutocompleteProvider(
new CombinedAutocompleteProvider(
commands.map((command) => ({
name: command.name,
description: command.description,
...(command.argumentHint === undefined ? {} : { argumentHint: command.argumentHint }),
...(command.getArgumentCompletions === undefined
? {}
: {
getArgumentCompletions: async (prefix: string) => {
const items = await command.getArgumentCompletions!(prefix);
return items === null ? null : [...items];
},
}),
})),
process.cwd(),
),
);
this.#requestRender();
}
#selectedSlashCommands(): SlashCommands | undefined {
return this.#slashCommands;
}
#handleConnectionState(serverId: string, state: ServerConnectionState): void {
if (this.#closed || this.#selectedServerId !== serverId) return;
if (state.status === "connected") {
if (this.#laneUnsubscribe === undefined) {
this.#busy = true;
this.#status = "Reattaching Session…";
this.#rebuild();
}
return;
}
this.#busy = true;
this.#status = state.status === "connecting" ? "Reconnecting to Radius…" : "Radius disconnected; retrying…";
this.#queueRecovery(() => this.#closeLane());
this.#rebuild();
}
#handleAttachmentState(feature: SessionFeature, state: SessionAttachmentState): void {
if (this.#closed || this.#selectedServerId !== feature.serverId || this.#sessionId === undefined) return;
if (state.status === "attached" && state.sessionId === this.#sessionId) {
this.#queueRecovery(async () => {
if (this.#laneUnsubscribe === undefined) await this.#openLane(feature);
this.#busy = false;
this.#status = "";
this.#rebuild();
});
return;
}
if (state.status === "attaching" && state.sessionId === this.#sessionId) {
this.#busy = true;
this.#status = "Reattaching Session…";
this.#rebuild();
}
}
#queueRecovery(operation: () => Promise<void>): void {
this.#recoveryTransition = this.#recoveryTransition
.then(async () => {
if (!this.#closed) await operation();
})
.catch((error: unknown) => {
if (this.#closed) return;
this.#busy = true;
this.#status = `Reconnect error: ${message(error)}`;
this.#rebuild();
});
}
async #openLane(feature: SessionFeature): Promise<void> {
await this.#closeLane();
const view = new ExperimentalChatView(this.#ui, process.cwd());
this.#chatView = view;
this.#documentContainer.addChild(this.#sessionHeading);
this.#documentContainer.addChild(view.transcript);
this.#pendingMessagesContainer.addChild(view.pendingMessages);
this.#laneUnsubscribe = feature.transcript.state.subscribe((value) => {
view.apply(value);
this.#rebuild();
});
if (feature.transcript.state.value === undefined) {
await this.#closeLane();
throw new Error("Transcript has no initialized view");
}
}
async #closeLane(): Promise<void> {
this.#laneUnsubscribe?.();
this.#laneUnsubscribe = undefined;
this.#chatView?.dispose();
this.#chatView = undefined;
this.#documentContainer.clear();
this.#pendingMessagesContainer.clear();
this.#statusContainer.clear();
}
async #runPrompt(messageText: string): Promise<void> {
const prompt = messageText.trim();
if (prompt.length === 0) return;
if (prompt.startsWith("/")) {
const separator = prompt.indexOf(" ");
const name = prompt.slice(1, separator === -1 ? undefined : separator);
const args = separator === -1 ? "" : prompt.slice(separator + 1).trim();
await this.#executeSlashCommand(name, args);
return;
}
this.#chatInput.setText("");
try {
await this.#submitPrompt(prompt);
} catch (error) {
this.#status = `Error: ${message(error)}`;
this.#rebuild();
}
}
async #executeSlashCommand(name: string, args: string): Promise<void> {
const command = this.#selectedSlashCommands()
?.list()
.find((candidate) => candidate.name === name);
this.#chatInput.setText("");
if (command === undefined) {
this.#status = `Unknown slash command: /${name}`;
this.#rebuild();
return;
}
try {
const result = await command.run(args, BACKGROUND_CONTEXT);
if (result !== undefined) {
if ("entryId" in result) this.#reportQueue(result);
else this.#reportOperation(result);
}
} catch (error) {
this.#status = `Error: ${message(error)}`;
this.#rebuild();
}
}
async #submitPrompt(prompt: string): Promise<void> {
const controller = this.#selectedController();
if (controller === undefined) throw new Error("No Session AgentController service is available");
const view = this.#conversationView();
const running = view !== undefined && liveOf(view).run !== undefined;
this.#status = running ? "Queueing steering message…" : "Running turn…";
this.#rebuild();
if (running) this.#reportQueue(await controller.steer({ message: prompt, images: null }, BACKGROUND_CONTEXT));
else this.#reportOperation(await controller.prompt({ message: prompt, images: null }, BACKGROUND_CONTEXT));
}
async #queueFollowUp(text: string): Promise<void> {
const controller = this.#selectedController();
if (controller === undefined) return;
try {
this.#status = "Queueing follow-up…";
this.#rebuild();
this.#reportQueue(await controller.followUp({ message: text, images: null }, BACKGROUND_CONTEXT));
} catch (error) {
this.#status = `Error: ${message(error)}`;
this.#rebuild();
}
}
#reportOperation(response: AgentOperationResponse): void {
this.#status = response.accepted ? "" : `Operation rejected: ${response.error.message}`;
this.#rebuild();
}
#reportQueue(response: AgentQueueResponse): void {
this.#status = response.accepted ? `Queued ${response.entryId}.` : `Message rejected: ${response.error.message}`;
this.#rebuild();
}
#interrupt(): void {
const view = this.#conversationView();
const controller = this.#selectedController();
if (view === undefined || liveOf(view).run === undefined || controller === undefined) return;
this.#status = "Aborting…";
this.#rebuild();
void controller.abort(BACKGROUND_CONTEXT).then(
() => {
if (this.#status !== "Aborting…") return;
this.#status = "";
this.#rebuild();
},
(error: unknown) => {
this.#status = `Error: ${message(error)}`;
this.#rebuild();
},
);
}
#selectedController(): AgentController | undefined {
return this.#controller;
}
#conversationView(): ConversationView | undefined {
return this.#session?.transcript.state.value;
}
#footer(): string {
const view = this.#conversationView();
if (!view) return "/model · /thinking · /compact · /reload";
const agent = (view.docs["pi.agent"] ?? {}) as AgentState;
const model = agent.model === undefined ? "no model" : `${agent.model.provider}/${agent.model.modelId}`;
return `${model} · thinking:${agent.thinkingLevel ?? "off"} · ${view.entries.length} entries · /model · /thinking · /compact · /reload`;
}
}
async function prepareClientSession(
command: ClientCommand,
servers: readonly ClientTuiServer[],
): Promise<PreparedClientSession> {
const opened = servers.map((server) => ({
server,
services: server.server.open({
services: [SessionDirectory, SessionManagement, PresentationPlugins],
assertAccess() {},
onError() {},
}),
}));
try {
const features = opened.map(({ server, services }) => ({
server,
directory: services.use(SessionDirectory),
management: services.use(SessionManagement),
plugins: services.use(PresentationPlugins),
}));
await Promise.all(opened.map(({ services }) => services.ready(BACKGROUND_CONTEXT)));
let selected:
| {
readonly server: ClientTuiServer;
readonly management: SessionManagement;
readonly plugins: PresentationPlugins;
readonly summary: SessionSummary;
}
| undefined;
if (command.sessionId !== undefined) {
const matches = features.flatMap((feature) =>
(feature.directory.state.value?.sessions ?? [])
.filter((session) => session.sessionId === command.sessionId)
.map((summary) => ({
server: feature.server,
management: feature.management,
plugins: feature.plugins,
summary,
})),
);
if (matches.length > 1) throw new Error(`Session ${command.sessionId} is available from more than one server`);
selected = matches[0];
if (selected === undefined) {
if (command.connect?.transport === "radius") {
throw new Error(`Remote server does not contain Session ${command.sessionId}`);
}
const feature = requireSingleServer(features);
selected = {
server: feature.server,
management: feature.management,
plugins: feature.plugins,
summary: await feature.management.create({ id: command.sessionId }, BACKGROUND_CONTEXT),
};
}
} else if (command.continue === true || command.resume === true) {
selected = features
.flatMap((feature) =>
(feature.directory.state.value?.sessions ?? []).map((summary) => ({
server: feature.server,
management: feature.management,
plugins: feature.plugins,
summary,
})),
)
.sort(
(left, right) =>
right.summary.createdAt - left.summary.createdAt ||
left.summary.serverId.localeCompare(right.summary.serverId) ||
left.summary.sessionId.localeCompare(right.summary.sessionId),
)[0];
}
if (selected === undefined) {
const feature = requireSingleServer(features);
selected = {
server: feature.server,
management: feature.management,
plugins: feature.plugins,
summary: await feature.management.create({}, BACKGROUND_CONTEXT),
};
}
const presentationPlugins = await selected.plugins.prepareSession(
{
sessionId: selected.summary.sessionId,
packagePaths: command.pluginPackages?.map((packagePath) => resolve(packagePath)) ?? null,
},
BACKGROUND_CONTEXT,
);
await selected.management.attach(selected.summary.sessionId, BACKGROUND_CONTEXT);
await selected.server.session.whenAttached(selected.summary.sessionId, BACKGROUND_CONTEXT);
return {
server: selected.server,
summary: selected.summary,
presentationPlugins,
};
} finally {
await Promise.allSettled(opened.map(({ services }) => services.dispose(BACKGROUND_CONTEXT)));
}
}
export async function runClientTui(command: ClientCommand, options: RunClientTuiOptions = {}): Promise<void> {
const cwd = process.cwd();
const agentDir = getAgentDir();
const settingsManager = SettingsManager.create(cwd, agentDir);
const resourceLoader = new DefaultResourceLoader({
cwd,
agentDir,
settingsManager,
noExtensions: true,
noSkills: true,
noPromptTemplates: true,
noContextFiles: true,
});
await resourceLoader.reload();
setRegisteredThemes(resourceLoader.getThemes().themes);
const runtime = await openClientRuntime(command, options);
const tui = createInteractiveTui({
tuiMode: "fullscreen",
showHardwareCursor: settingsManager.getShowHardwareCursor(),
logDirectory: agentDir,
fullscreenWheelScrollLines: settingsManager.getFullscreenWheelScrollLines(),
});
tui.setClearOnShrink(settingsManager.getClearOnShrink());
let component: ExperimentalClientTui | undefined;
let tuiStarted = false;
const themeController = new InteractiveThemeController(tui, {
getSettingsManager: () => settingsManager,
showError: (error) => component?.showError(error),
onChanged: () => component?.refreshTheme(),
});
try {
let finish!: () => void;
const finished = new Promise<void>((resolve) => {
finish = () => {
themeController.disableAutoSync();
if (tuiStarted) {
tui.stop();
tuiStarted = false;
}
resolve();
};
});
component = await ExperimentalClientTui.create({
command,
ui: tui,
servers: runtime.servers.map((server) => ({
serverId: server.route.serverId,
radius: server.route.transport === "radius",
server: server.server,
session: server.session,
})),
facetLoader: options.facetLoader,
requestRender: () => tui.requestRender(),
finish,
});
tui.addChild(component);
tui.setLayoutRoot(component.layoutRoot);
tui.setFocus(component);
tuiStarted = true;
tui.start();
themeController.applyFromSettings();
await finished;
} finally {
themeController.dispose();
stopThemeWatcher();
if (tuiStarted) tui.stop();
await component?.close();
await runtime.dispose();
}
}
function requireSingleServer<T>(features: readonly T[]): T {
if (features.length !== 1) throw new Error("Starting a Session requires exactly one server");
return features[0]!;
}
function message(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

View file

@ -1,86 +0,0 @@
import { resolve } from "node:path";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import type { ClientCommand } from "../cli/experimental/commands/client.ts";
import { activateBuiltinClientServices, openClientRuntime } from "./client-runtime.ts";
import type { SessionAddress } from "./services/sessions.ts";
export type ClientResult =
| {
readonly kind: "list";
readonly sessions: readonly SessionAddress[];
}
| { readonly kind: "attached"; readonly serverId: string; readonly sessionId: string }
| { readonly kind: "prompted"; readonly serverId: string; readonly sessionId: string; readonly text: string };
export interface RunClientOptions {
/** Directory searched when --connect is omitted. Defaults to PI_SERVER_DIR or ~/.pi/server. */
readonly directory?: string;
}
/** Discover servers, then list Sessions, attach to one, or create one for a prompt. */
export async function runClient(command: ClientCommand, options: RunClientOptions = {}): Promise<ClientResult> {
const runtime = await openClientRuntime(command, { directory: options.directory });
try {
const discovered = await Promise.all(runtime.servers.map(activateBuiltinClientServices));
let sessionId = command.sessionId;
if (sessionId === undefined && command.prompt === undefined) {
return {
kind: "list",
sessions: discovered
.flatMap(({ route, directory }) =>
directory.state.value!.sessions.map(({ sessionId }) => ({ serverId: route.serverId, sessionId })),
)
.sort(
(left, right) =>
left.serverId.localeCompare(right.serverId) || left.sessionId.localeCompare(right.sessionId),
),
};
}
let match: (typeof discovered)[number];
if (sessionId === undefined) {
if (discovered.length !== 1) {
throw new Error("Client prompt requires exactly one discovered server to create a Session");
}
match = discovered[0]!;
sessionId = (await match.management.create({}, BACKGROUND_CONTEXT)).sessionId;
} else {
const selectedSessionId = sessionId;
const matches = discovered.filter((candidate) =>
candidate.directory.state.value!.sessions.some(({ sessionId }) => sessionId === selectedSessionId),
);
if (matches.length > 1) {
throw new Error(`Session ${selectedSessionId} is available from more than one server`);
}
const existing = matches[0];
if (existing) {
match = existing;
} else {
if (command.connect?.transport === "radius" || command.prompt === undefined || discovered.length !== 1) {
throw new Error(`No discovered server contains session ${selectedSessionId}`);
}
match = discovered[0]!;
await match.management.create({ id: selectedSessionId }, BACKGROUND_CONTEXT);
}
}
await match.plugins.prepareSession(
{
sessionId,
packagePaths: command.pluginPackages?.map((packagePath) => resolve(packagePath)) ?? null,
},
BACKGROUND_CONTEXT,
);
await match.management.attach(sessionId, BACKGROUND_CONTEXT);
if (command.prompt === undefined) {
return { kind: "attached", serverId: match.route.serverId, sessionId };
}
const response = await match.agent.prompt({ message: command.prompt, images: null }, BACKGROUND_CONTEXT);
if (!response.accepted) throw new Error(response.error.message);
const result = await match.agent.waitForPrompt(response.operationId, BACKGROUND_CONTEXT);
if (result.status === "unanswered") throw new Error(`Prompt was not answered: ${result.reason}`);
return { kind: "prompted", serverId: match.route.serverId, sessionId, text: result.text };
} finally {
await runtime.dispose();
}
}

View file

@ -1,98 +0,0 @@
import chalk from "chalk";
import { cli } from "../cli/experimental/cli.ts";
import type { ClientCommand } from "../cli/experimental/commands/client.ts";
import type { ServerCommand } from "../cli/experimental/commands/server.ts";
import { areExperimentalFeaturesEnabled } from "../core/experimental.ts";
import { runClient } from "./client.ts";
import { runClientTui } from "./client-tui.ts";
import type { RadiusRelayHostStatus } from "./radius-relay.ts";
import { startForegroundServer } from "./server.ts";
async function runServerCommand(command: ServerCommand): Promise<void> {
let previousRelayStatus = "";
let relayOutputReady = false;
let pendingRelayStatus: RadiusRelayHostStatus | undefined;
const reportRelayStatus = (status: RadiusRelayHostStatus): void => {
const description =
status.status === "connected"
? "connected"
: status.status === "not_authenticated"
? "not connected; local only"
: status.status === "retrying"
? `reconnecting: ${status.error}`
: "connecting";
if (description === previousRelayStatus || status.status === "connecting") return;
previousRelayStatus = description;
console.log(`Radius: ${description}`);
};
const runtime = await startForegroundServer({
serverId: command.serverId,
sessionDir: command.sessionDir,
provider: command.provider,
model: command.model,
pluginPackages: command.pluginPackages ?? [],
relayAuth: command.auth,
onRelayStatus(status) {
if (relayOutputReady) reportRelayStatus(status);
else pendingRelayStatus = status;
},
});
console.log(`Server: ${runtime.serverId}`);
console.log(`Socket: ${runtime.socketPath}`);
relayOutputReady = true;
if (pendingRelayStatus !== undefined) reportRelayStatus(pendingRelayStatus);
try {
await new Promise<void>((resolve, reject) => {
const cleanup = (): void => {
process.off("SIGINT", finish);
process.off("SIGTERM", finish);
};
const finish = (): void => {
cleanup();
resolve();
};
const fail = (error: unknown): void => {
cleanup();
reject(error);
};
process.once("SIGINT", finish);
process.once("SIGTERM", finish);
void runtime.closed.then(finish, fail);
});
} finally {
await runtime.close();
}
}
async function runClientCommand(command: ClientCommand): Promise<void> {
if (command.prompt === undefined && process.stdin.isTTY === true && process.stdout.isTTY === true) {
await runClientTui(command);
return;
}
const result = await runClient(command);
if (result.kind === "attached") {
console.log(`${result.serverId}\t${result.sessionId}\tattached`);
return;
}
if (result.kind === "prompted") {
console.log(result.text);
return;
}
for (const session of result.sessions) console.log(`${session.serverId}\t${session.sessionId}`);
}
/** Development-only command dispatch. Published entrypoints must not import this module. */
export async function runExperimentalCommand(args: string[]): Promise<boolean> {
if (!areExperimentalFeaturesEnabled() || (args[0] !== "server" && args[0] !== "client")) return false;
try {
const result = await cli.execute(args, { runServer: runServerCommand, runClient: runClientCommand });
if (!result.ok) {
for (const error of result.errors) console.error(chalk.red(`Error: ${error}`));
process.exitCode = 1;
}
} catch (error) {
console.error(chalk.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
process.exitCode = 1;
}
return true;
}

View file

@ -1,10 +0,0 @@
#!/usr/bin/env node
import { runCoordinatorProcess } from "./coordinator.ts";
import { consumeInternalProcessRole } from "./process.ts";
const role = consumeInternalProcessRole();
if (role !== "coordinator") throw new Error("Coordinator entrypoint requires an internal coordinator invocation");
void runCoordinatorProcess(process.argv.slice(2)).catch((error: unknown) => {
console.error(error);
process.exit(1);
});

View file

@ -1,612 +0,0 @@
import { randomUUID } from "node:crypto";
import { chmod, lstat, unlink } from "node:fs/promises";
import { createConnection, createServer, type Server, type Socket } from "node:net";
import Type, { type Static } from "typebox";
import { Check } from "typebox/value";
import {
consumeInternalProcessRole,
encodeControlLine,
isDirectInternalProcessEntry,
MAX_CONTROL_LINE_BYTES,
spawnInternalProcess,
} from "./process.ts";
export const COORDINATOR_PROTOCOL_VERSION = 3;
const COORDINATOR_START_TIMEOUT_MS = 10_000;
const COORDINATOR_RETRY_MS = 10;
const CoordinatorMessageSchema = Type.Union([
Type.Object({
type: Type.Literal("server_registered"),
serverConnectionId: Type.String(),
peers: Type.Array(Type.String()),
}),
Type.Object({ type: Type.Literal("server_replaced") }),
Type.Object({ type: Type.Literal("peer_connected"), peerId: Type.String() }),
Type.Object({ type: Type.Literal("peer_disconnected"), peerId: Type.String() }),
Type.Object({ type: Type.Literal("message"), from: Type.String(), payload: Type.Unknown() }),
]);
type CoordinatorMessage = Static<typeof CoordinatorMessageSchema>;
export type CoordinatorConnectionEvent =
| { readonly type: "peer_connected"; readonly peerId: string }
| { readonly type: "peer_disconnected"; readonly peerId: string }
| { readonly type: "message"; readonly from: string; readonly payload: unknown };
export interface CoordinatorConnectionOptions {
readonly controlPath: string;
readonly endpoint: string;
readonly serverConnectionId?: string;
}
/** The server-side endpoint of the coordinator's intentionally opaque message router. */
export class CoordinatorConnection {
readonly serverConnectionId: string;
readonly replaced: Promise<void>;
readonly peerIds = new Set<string>();
readonly #controlPath: string;
readonly #endpoint: string;
readonly #listeners = new Set<(event: CoordinatorConnectionEvent) => void>();
#socket?: Socket;
#registered = false;
#closed = false;
#replacedValue = false;
#resolveRegistered?: () => void;
#rejectRegistered?: (error: Error) => void;
#resolveReplaced!: () => void;
constructor(options: CoordinatorConnectionOptions) {
this.serverConnectionId = options.serverConnectionId ?? randomUUID();
this.#controlPath = options.controlPath;
this.#endpoint = options.endpoint;
this.replaced = new Promise((resolve) => {
this.#resolveReplaced = resolve;
});
}
get controlPath(): string {
return this.#controlPath;
}
get wasReplaced(): boolean {
return this.#replacedValue;
}
onEvent(listener: (event: CoordinatorConnectionEvent) => void): () => void {
this.#listeners.add(listener);
return () => this.#listeners.delete(listener);
}
async connect(): Promise<void> {
if (this.#socket) throw new Error("Coordinator server is already connected");
const socket = await connectSocket(this.#controlPath);
this.#socket = socket;
attachJsonLineReader(socket, (message) => this.#handleMessage(message));
const registered = new Promise<void>((resolve, reject) => {
this.#resolveRegistered = resolve;
this.#rejectRegistered = reject;
});
socket.once("close", () => this.#disconnected(new Error("Coordinator connection closed")));
socket.once("error", (error) => this.#disconnected(error));
await writeJsonLine(socket, {
type: "register_server",
protocol: COORDINATOR_PROTOCOL_VERSION,
serverConnectionId: this.serverConnectionId,
endpoint: this.#endpoint,
});
await registered;
}
send(peerId: string, payload: unknown): Promise<void> {
return this.#write({ type: "send", to: peerId, payload });
}
broadcast(payload: unknown): Promise<void> {
return this.#write({ type: "broadcast", payload });
}
close(): void {
if (this.#closed) return;
this.#closed = true;
this.#socket?.destroy();
this.#socket = undefined;
this.peerIds.clear();
this.#rejectRegistered?.(new Error("Coordinator server closed"));
this.#resolveRegistered = undefined;
this.#rejectRegistered = undefined;
}
async #write(message: unknown): Promise<void> {
if (!this.#registered || !this.#socket || this.#closed) throw new Error("Coordinator server is not connected");
await writeJsonLine(this.#socket, message);
}
#handleMessage(value: unknown): void {
if (!Check(CoordinatorMessageSchema, value)) {
this.#socket?.destroy(new Error("Coordinator sent an invalid message"));
return;
}
const message: CoordinatorMessage = value;
if (message.type === "server_registered") {
if (message.serverConnectionId !== this.serverConnectionId) {
this.#socket?.destroy(new Error("Coordinator returned an invalid server registration"));
return;
}
for (const peerId of message.peers) this.peerIds.add(peerId);
this.#registered = true;
this.#resolveRegistered?.();
this.#resolveRegistered = undefined;
this.#rejectRegistered = undefined;
return;
}
if (message.type === "server_replaced") {
this.#markReplaced();
return;
}
if (message.type === "peer_connected") {
this.peerIds.add(message.peerId);
this.#emit({ type: "peer_connected", peerId: message.peerId });
return;
}
if (message.type === "peer_disconnected") {
this.peerIds.delete(message.peerId);
this.#emit({ type: "peer_disconnected", peerId: message.peerId });
return;
}
if (message.type === "message") {
this.#emit({ type: "message", from: message.from, payload: message.payload });
return;
}
this.#socket?.destroy(new Error("Coordinator sent an unsupported message"));
}
#emit(event: CoordinatorConnectionEvent): void {
for (const listener of this.#listeners) listener(event);
}
#disconnected(error: Error): void {
this.#socket = undefined;
if (this.#closed) return;
this.#rejectRegistered?.(error);
this.#resolveRegistered = undefined;
this.#rejectRegistered = undefined;
this.#markReplaced();
}
#markReplaced(): void {
if (this.#replacedValue) return;
this.#replacedValue = true;
this.#resolveReplaced();
}
}
export interface CoordinatorStartupLease {
close(): void;
}
export async function ensureCoordinator(publicPath: string, controlPath: string): Promise<CoordinatorStartupLease> {
const existing = await tryConnect(controlPath);
if (existing) return { close: () => existing.destroy() };
const child = spawnInternalProcess("coordinator", [publicPath, controlPath]);
const deadline = Date.now() + COORDINATOR_START_TIMEOUT_MS;
while (true) {
const socket = await tryConnect(controlPath);
if (socket) return { close: () => socket.destroy() };
if (child.exitCode !== null || child.signalCode !== null) throw new Error("Coordinator exited during startup");
if (Date.now() >= deadline) throw new Error("Timed out waiting for coordinator startup");
await new Promise<void>((resolve) => setTimeout(resolve, COORDINATOR_RETRY_MS));
}
}
function connectSocket(path: string): Promise<Socket> {
return new Promise((resolve, reject) => {
const socket = createConnection(path);
const onConnect = (): void => {
socket.off("error", onError);
resolve(socket);
};
const onError = (error: Error): void => {
socket.off("connect", onConnect);
reject(error);
};
socket.once("connect", onConnect);
socket.once("error", onError);
});
}
async function tryConnect(path: string): Promise<Socket | undefined> {
try {
return await connectSocket(path);
} catch (error) {
const code = error instanceof Error && "code" in error ? error.code : undefined;
if (code === "ENOENT" || code === "ECONNREFUSED") return undefined;
throw error;
}
}
function attachJsonLineReader(socket: Socket, onMessage: (message: unknown) => void): void {
let buffered = "";
socket.setEncoding("utf8");
socket.on("data", (chunk: string) => {
buffered += chunk;
if (Buffer.byteLength(buffered) > MAX_CONTROL_LINE_BYTES) {
socket.destroy(new Error("Coordinator message is too large"));
return;
}
while (true) {
const newline = buffered.indexOf("\n");
if (newline === -1) return;
const line = buffered.slice(0, newline);
buffered = buffered.slice(newline + 1);
try {
onMessage(JSON.parse(line));
} catch {
socket.destroy(new Error("Coordinator sent invalid JSON"));
return;
}
}
});
}
function writeJsonLine(socket: Socket, message: unknown): Promise<void> {
return new Promise((resolve, reject) => {
socket.write(encodeControlLine(message), (error) => {
if (error) reject(error);
else resolve();
});
});
}
// This process is intentionally a transport shim. It depends only on Node
// built-ins and never interprets Pi, session, worker, or lifecycle payloads.
const EMPTY_STARTUP_GRACE_MS = 30_000;
const EMPTY_SHUTDOWN_GRACE_MS = 250;
interface ServerPeer {
readonly serverConnectionId: string;
readonly endpoint: string;
readonly socket: Socket;
}
interface RoutedPeer {
readonly peerId: string;
readonly socket: Socket;
}
interface ControlMessage {
readonly type: string;
readonly protocol?: unknown;
readonly serverConnectionId?: unknown;
readonly endpoint?: unknown;
readonly peerId?: unknown;
readonly to?: unknown;
readonly payload?: unknown;
}
let publicPath: string;
let controlPath: string;
let running = false;
const peers = new Map<string, RoutedPeer>();
const controlConnections = new Set<Socket>();
const publicConnections = new Map<Socket, Socket>();
let currentServer: ServerPeer | undefined;
let shuttingDown = false;
let emptyTimer: NodeJS.Timeout | undefined;
const publicServer = createServer((socket) => acceptPublicConnection(socket));
const controlServer = createServer((socket) => acceptControlConnection(socket));
export async function runCoordinatorProcess(args: readonly string[]): Promise<void> {
if (running) throw new Error("Coordinator process is already running");
const [requestedPublicPath, requestedControlPath] = args;
if (!requestedPublicPath || !requestedControlPath) {
throw new Error("Coordinator requires public and control socket paths");
}
publicPath = requestedPublicPath;
controlPath = requestedControlPath;
running = true;
process.once("SIGINT", () => void shutdownCoordinator());
process.once("SIGTERM", () => void shutdownCoordinator());
await main();
}
async function main(): Promise<void> {
await removeStaleSocket(controlPath);
await removeStaleSocket(publicPath);
try {
await listen(controlServer, controlPath);
await restrictSocket(controlPath);
await listen(publicServer, publicPath);
await restrictSocket(publicPath);
} catch (error) {
await Promise.all([cleanupSocket(controlPath), cleanupSocket(publicPath)]);
throw error;
}
scheduleEmptyShutdown(EMPTY_STARTUP_GRACE_MS);
}
function acceptControlConnection(socket: Socket): void {
if (shuttingDown) {
socket.destroy();
return;
}
controlConnections.add(socket);
let server: ServerPeer | undefined;
let peer: RoutedPeer | undefined;
attachRoutedLineReader(socket, (message) => {
if (!server && !peer) {
if (message.type === "register_server") {
server = registerServer(socket, message);
return;
}
if (message.type === "register_peer") {
peer = registerPeer(socket, message);
return;
}
throw new Error("Coordinator connection did not register a role");
}
if (server) {
if (server === currentServer) handleRoutedMessage("server", message);
return;
}
handleRoutedMessage(peer!.peerId, message);
});
const disconnect = (): void => {
controlConnections.delete(socket);
if (server && currentServer === server) {
currentServer = undefined;
notifyPeers({ type: "server_disconnected", serverConnectionId: server.serverConnectionId });
}
if (peer && peers.get(peer.peerId) === peer) {
peers.delete(peer.peerId);
if (currentServer) writeRoutedLine(currentServer.socket, { type: "peer_disconnected", peerId: peer.peerId });
}
checkEmpty();
};
socket.once("close", disconnect);
socket.once("error", () => socket.destroy());
}
function registerServer(socket: Socket, message: ControlMessage): ServerPeer {
if (message.protocol !== COORDINATOR_PROTOCOL_VERSION) throw new Error("Unsupported coordinator protocol");
if (typeof message.serverConnectionId !== "string" || message.serverConnectionId.length === 0) {
throw new Error("Coordinator serverConnectionId must be a string");
}
if (typeof message.endpoint !== "string" || message.endpoint.length === 0) {
throw new Error("Coordinator endpoint must be a string");
}
const { serverConnectionId, endpoint } = message;
const server = { serverConnectionId, endpoint, socket };
const previous = currentServer;
currentServer = server;
cancelEmptyShutdown();
writeRoutedLine(socket, {
type: "server_registered",
serverConnectionId,
peers: [...peers.keys()],
});
if (previous && previous !== server) {
closePublicConnections();
notifyPeers({ type: "server_disconnected", serverConnectionId: previous.serverConnectionId });
writeRoutedLine(previous.socket, { type: "server_replaced" });
}
notifyPeers({ type: "server_connected", serverConnectionId });
return server;
}
function registerPeer(socket: Socket, message: ControlMessage): RoutedPeer {
if (message.protocol !== COORDINATOR_PROTOCOL_VERSION) throw new Error("Unsupported coordinator protocol");
if (typeof message.peerId !== "string" || message.peerId.length === 0) {
throw new Error("Coordinator peerId must be a string");
}
const { peerId } = message;
if (peerId === "server" || peers.has(peerId)) throw new Error(`Coordinator peer is already connected: ${peerId}`);
const peer = { peerId, socket };
peers.set(peerId, peer);
cancelEmptyShutdown();
writeRoutedLine(socket, {
type: "peer_registered",
peerId,
...(currentServer === undefined ? {} : { serverConnectionId: currentServer.serverConnectionId }),
});
if (currentServer) {
writeRoutedLine(currentServer.socket, { type: "peer_connected", peerId });
}
return peer;
}
function notifyPeers(message: unknown): void {
for (const peer of peers.values()) writeRoutedLine(peer.socket, message);
}
function handleRoutedMessage(from: string, message: ControlMessage): void {
if (message.type === "send") {
if (typeof message.to !== "string" || message.to.length === 0) {
throw new Error("Coordinator message target must be a string");
}
const { to } = message;
const target = to === "server" ? currentServer?.socket : peers.get(to)?.socket;
if (target) writeRoutedLine(target, { type: "message", from, payload: message.payload });
return;
}
if (message.type === "broadcast") {
if (from !== "server") throw new Error("Only the current server may broadcast");
for (const peer of peers.values()) {
writeRoutedLine(peer.socket, { type: "message", from, payload: message.payload });
}
return;
}
throw new Error(`Unknown coordinator routing message: ${message.type}`);
}
function acceptPublicConnection(client: Socket): void {
if (shuttingDown || !currentServer) {
client.destroy();
return;
}
cancelEmptyShutdown();
const upstream = createConnection(currentServer.endpoint);
publicConnections.set(client, upstream);
let finalized = false;
const finalize = (): void => {
if (finalized) return;
finalized = true;
publicConnections.delete(client);
checkEmpty();
};
upstream.once("connect", () => {
client.pipe(upstream);
upstream.pipe(client);
});
upstream.once("error", () => client.destroy());
upstream.once("close", () => {
client.destroy();
finalize();
});
client.once("error", () => upstream.destroy());
client.once("close", () => {
upstream.destroy();
finalize();
});
}
function closePublicConnections(): void {
for (const [client, upstream] of publicConnections) {
client.destroy();
upstream.destroy();
}
publicConnections.clear();
}
function checkEmpty(): void {
if (shuttingDown || currentServer || peers.size > 0 || publicConnections.size > 0 || controlConnections.size > 0) {
cancelEmptyShutdown();
return;
}
scheduleEmptyShutdown(EMPTY_SHUTDOWN_GRACE_MS);
}
function scheduleEmptyShutdown(delayMs: number): void {
if (emptyTimer || shuttingDown) return;
emptyTimer = setTimeout(() => {
emptyTimer = undefined;
if (!currentServer && peers.size === 0 && publicConnections.size === 0 && controlConnections.size === 0) {
void shutdownCoordinator();
}
}, delayMs);
emptyTimer.unref();
}
function cancelEmptyShutdown(): void {
if (!emptyTimer) return;
clearTimeout(emptyTimer);
emptyTimer = undefined;
}
async function shutdownCoordinator(): Promise<void> {
if (shuttingDown) return;
shuttingDown = true;
cancelEmptyShutdown();
closePublicConnections();
for (const connection of controlConnections) connection.destroy();
await Promise.all([closeServer(publicServer), closeServer(controlServer)]);
await Promise.all([cleanupSocket(publicPath), cleanupSocket(controlPath)]);
process.exit(0);
}
function writeRoutedLine(socket: Socket, message: unknown): void {
if (!socket.destroyed) socket.write(encodeControlLine(message));
}
function attachRoutedLineReader(socket: Socket, onMessage: (message: ControlMessage) => void): void {
let buffered = "";
socket.setEncoding("utf8");
socket.on("data", (chunk: string) => {
buffered += chunk;
if (Buffer.byteLength(buffered) > MAX_CONTROL_LINE_BYTES) {
socket.destroy(new Error("Coordinator message is too large"));
return;
}
while (true) {
const newline = buffered.indexOf("\n");
if (newline === -1) return;
const line = buffered.slice(0, newline);
buffered = buffered.slice(newline + 1);
try {
const message = JSON.parse(line) as ControlMessage | null;
if (typeof message?.type !== "string") throw new Error("Coordinator message must have a type");
onMessage(message);
} catch (error) {
socket.destroy(error instanceof Error ? error : new Error(String(error)));
return;
}
}
});
}
function listen(server: Server, path: string): Promise<void> {
return new Promise((resolve, reject) => {
const onError = (error: Error): void => {
server.off("listening", onListening);
reject(error);
};
const onListening = (): void => {
server.off("error", onError);
resolve();
};
server.once("error", onError);
server.once("listening", onListening);
server.listen(path);
});
}
function closeServer(server: Server): Promise<void> {
if (!server.listening) return Promise.resolve();
return new Promise((resolve) => server.close(() => resolve()));
}
async function restrictSocket(path: string): Promise<void> {
if (process.platform !== "win32") await chmod(path, 0o600);
}
async function removeStaleSocket(path: string): Promise<void> {
try {
const stats = await lstat(path);
if (!stats.isSocket()) throw new Error(`Coordinator path is not a socket: ${path}`);
} catch (error) {
if (error instanceof Error && "code" in error && error.code === "ENOENT") return;
throw error;
}
const live = await new Promise<boolean>((resolve, reject) => {
const socket = createConnection(path);
socket.once("connect", () => {
socket.destroy();
resolve(true);
});
socket.once("error", (error: NodeJS.ErrnoException) => {
socket.destroy();
if (error.code === "ECONNREFUSED" || error.code === "ENOENT") resolve(false);
else reject(error);
});
});
if (live) throw new Error(`Coordinator socket is already active: ${path}`);
await cleanupSocket(path);
}
async function cleanupSocket(path: string): Promise<void> {
if (process.platform === "win32") return;
await unlink(path).catch((error: unknown) => {
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
});
}
if (isDirectInternalProcessEntry(import.meta.url)) {
const role = consumeInternalProcessRole();
if (role !== "coordinator") throw new Error("Coordinator entrypoint requires an internal coordinator invocation");
void runCoordinatorProcess(process.argv.slice(2)).catch((error: unknown) => {
console.error(error);
process.exit(1);
});
}

View file

@ -1,66 +0,0 @@
# durable
A small local coding agent on `@earendil-works/pi-durable`. One process owns the model runtime, the durable
Harness, its SQLite storage, and the TUI. It reuses pi's model runtime, auth, settings, system prompt, keybindings,
theme, and interactive components; the agent itself is the durable Harness with its built-in `CodingTools`.
```bash
node --import ./packages/coding-agent/src/experimental/source-resolver.ts packages/coding-agent/src/experimental/durable/main.ts
node --import ./packages/coding-agent/src/experimental/source-resolver.ts packages/coding-agent/src/experimental/durable/main.ts --continue
```
A new session starts with pi's default model and thinking level from `settings.json`. `--continue` opens the newest
session for the current directory. Sessions live under
`~/.pi/agent/experimental/durable-sessions/<cwd-hash>/<session>/session.sqlite`; a lock keeps a second process out
(a lock left by a crash goes stale after 10 seconds, and the next start waits for that). Log in with pi itself;
credentials are shared.
## What it shows
- **Durability:** every streamed partial, tool output, queue, and turn is committed. Kill the process in the middle
of a tool call and start it again with `--continue`: the interrupted call gets an interrupted result and the turn
finishes. Nothing in the TUI handles recovery; it only renders the conversation view.
- **One view:** the TUI renders `Conversation.viewState()`, the structural mount of the transcript and the built-in
documents (`pi.live`, `pi.inbox`, `pi.agent`, `pi.usage`). Streaming, tool progress, the queue, retries,
compaction status, the model, and usage all come from it.
- **Subagents:** the `subagent` tool runs a task in a child conversation owned by the call. `/agents` switches the
view to any conversation, also while the subagent works, and the editor then talks to it: steer it while busy, or
keep chatting after the call returned. Esc aborts the shown conversation; aborting the main turn aborts its
subagents.
- **Task graph:** a live panel of `Harness.taskGraph()`, shown by default and toggled with `/tasks`: every live task, what it waits on, and the
conversations it owns.
## Commands
- submit: prompt while idle, steer while busy
- follow-up key (`app.message.followUp`): queue a follow-up
- Esc: abort the shown conversation's work, including a manual compaction
- `/model` or the model key: select a model for the shown conversation
- thinking key (Shift+Tab): cycle thinking levels
- `/compact [instructions]`: summarize older context; reports "Nothing to compact" when the context fits in
`compaction.keepRecentTokens`
- `/agents`: switch conversations
- `/tasks`: hide or show the task panel
- tools expand key (Ctrl+O): expand tool output and compaction summaries
- clear key (Ctrl+C) or Ctrl+D: exit at once, unlike pi's clear-first Ctrl+C; work in flight resumes with
`--continue`
## Layout
| file | role |
| --- | --- |
| `main.ts` | arguments, open, run, close |
| `sessions.ts` | session directories and the lock |
| `runtime.ts` | Harness, registry, settings, environments; the plain `DurableView` and `DurableController` |
| `prompt.ts` | pi's system prompt sections (tools, rules, docs, AGENTS.md, skills, cwd) as one extension |
| `subagent.ts` | the foreground subagent tool |
| `tui.ts` | rendering with pi's interactive components |
Compaction thresholds, retry policy, queue modes, and request timeouts come from pi's `settings.json` as loaded at
startup, read through Harness settings getters at each use. pi's HTTP dispatcher is configured as in pi; without it some provider streams end
early.
A turn that ends without an answer shows a notice; one recovered after a restart does not, since only submissions
made by this process are watched.
Not here: sessions list and resume picker, forks and tree navigation, extensions, prompt templates, images, `/login`.

View file

@ -1,123 +0,0 @@
import type { Context } from "@earendil-works/chord";
import type { ModelThinkingLevel } from "@earendil-works/pi-ai";
import {
createRegistry,
type EnvTarget,
type HarnessSettings,
type ModelRef,
type Registry,
} from "@earendil-works/pi-durable";
import { NodeExecutionEnv } from "@earendil-works/pi-durable/env/node";
import { CodingTools } from "@earendil-works/pi-durable/tools";
import { applyHttpProxySettings, configureHttpDispatcher } from "../../core/http-dispatcher.ts";
import { findInitialModel, resolveCliModel } from "../../core/model-resolver.ts";
import type { ModelRuntime } from "../../core/model-runtime.ts";
import type { SettingsManager } from "../../core/settings-manager.ts";
import { createPiPrompt } from "./prompt.ts";
/** pi's HTTP setup: proxy, idle timeouts, and one undici for fetch. Without it, some provider streams break off. */
export function configureHarnessHttp(settingsManager: SettingsManager): void {
applyHttpProxySettings(settingsManager.getGlobalSettings().httpProxy);
configureHttpDispatcher(settingsManager.getHttpIdleTimeoutMs());
}
/** Harness settings read at every use from pi's settings as loaded at startup. */
export function createHarnessSettings(settingsManager: SettingsManager): HarnessSettings {
return {
get stream() {
const provider = settingsManager.getProviderRetrySettings();
const idle = settingsManager.getHttpIdleTimeoutMs();
return {
timeoutMs: provider.timeoutMs ?? (idle === 0 ? 2147483647 : idle),
maxRetryDelayMs: provider.maxRetryDelayMs,
...(provider.maxRetries === undefined ? {} : { maxRetries: provider.maxRetries }),
};
},
get compaction() {
return settingsManager.getCompactionSettings();
},
get retry() {
return settingsManager.getRetrySettings();
},
get steeringMode() {
return settingsManager.getSteeringMode();
},
get followUpMode() {
return settingsManager.getFollowUpMode();
},
};
}
/** A registry with pi's coding tools and system prompt. */
export function createCodingRegistry(settingsManager: SettingsManager, cwd: string): Registry {
const registry = createRegistry();
registry.install(CodingTools);
registry.install(createPiPrompt(settingsManager, cwd));
return registry;
}
/** One execution environment per directory, shared by every conversation in it. */
export class ExecutionEnvs {
readonly #defaultCwd: string;
readonly #envs = new Map<string, NodeExecutionEnv>();
constructor(defaultCwd: string) {
this.#defaultCwd = defaultCwd;
}
readonly env = ({ cwd = this.#defaultCwd }: EnvTarget): NodeExecutionEnv => {
let env = this.#envs.get(cwd);
if (env === undefined) {
env = new NodeExecutionEnv({ cwd });
this.#envs.set(cwd, env);
}
return env;
};
async cleanup(context: Context): Promise<void> {
const envs = [...this.#envs.values()];
this.#envs.clear();
for (const env of envs) await env.cleanup(context);
}
}
export interface InitialModel {
readonly model?: ModelRef;
readonly thinkingLevel?: ModelThinkingLevel;
readonly fallbackMessage?: string;
}
/** The model a new root conversation starts with: an explicit `--provider`/`--model`, or pi's default resolution. */
export async function findInitialAgentModel(
settingsManager: SettingsManager,
modelRuntime: ModelRuntime,
cli?: { readonly provider?: string; readonly model: string },
): Promise<InitialModel> {
if (cli !== undefined) {
const resolved = resolveCliModel({ cliProvider: cli.provider, cliModel: cli.model, modelRuntime });
if (resolved.error !== undefined || resolved.model === undefined) {
throw new Error(`Could not resolve model: ${resolved.error ?? cli.model}`);
}
return {
model: { provider: resolved.model.provider, modelId: resolved.model.id },
...(resolved.thinkingLevel === undefined ? {} : { thinkingLevel: resolved.thinkingLevel }),
};
}
const initial = await findInitialModel({
scopedModels: [],
isContinuing: false,
defaultProvider: settingsManager.getDefaultProvider(),
defaultModelId: settingsManager.getDefaultModel(),
defaultThinkingLevel: settingsManager.getDefaultThinkingLevel(),
modelRuntime,
});
return {
...(initial.model === undefined
? {}
: {
model: { provider: initial.model.provider, modelId: initial.model.id },
thinkingLevel: initial.thinkingLevel,
}),
...(initial.fallbackMessage === undefined ? {} : { fallbackMessage: initial.fallbackMessage }),
};
}

View file

@ -1,20 +0,0 @@
#!/usr/bin/env node
import { type OpenDurableOptions, openDurable } from "./runtime.ts";
import { runDurableTui } from "./tui.ts";
function parseArgs(argv: readonly string[]): OpenDurableOptions {
let continueSession = false;
for (const arg of argv) {
if (arg === "--continue" || arg === "-c") continueSession = true;
else throw new Error(`Unknown argument: ${arg}`);
}
return { continueSession };
}
const durable = await openDurable(parseArgs(process.argv.slice(2)));
try {
await runDurableTui(durable.view, durable.controller, durable.settings);
} finally {
await durable.close();
}

View file

@ -1,74 +0,0 @@
import { defineExtension, type PromptInput, section } from "@earendil-works/pi-durable";
import { getAgentDir } from "../../config.ts";
import { loadProjectContextFiles } from "../../core/resource-loader.ts";
import type { SettingsManager } from "../../core/settings-manager.ts";
import { loadSkills, type Skill } from "../../core/skills.ts";
import { buildSystemPromptSections } from "../../core/system-prompt.ts";
import { bashToolSystemPromptContribution } from "../../core/tools/bash.ts";
import { editToolSystemPromptContribution } from "../../core/tools/edit.ts";
import { readToolSystemPromptContribution } from "../../core/tools/read.ts";
import { writeToolSystemPromptContribution } from "../../core/tools/write.ts";
const CONTRIBUTIONS = {
read: readToolSystemPromptContribution,
bash: bashToolSystemPromptContribution,
edit: editToolSystemPromptContribution,
write: writeToolSystemPromptContribution,
};
/** pi's section order; `buildSystemPromptSections()` omits the ones without content. */
const KEYS = ["preamble", "tools", "rules", "docs", "project_context", "skills", "cwd"] as const;
/**
* pi's system prompt as one extension: the sections of `buildSystemPromptSections()` for the request's tools and the
* conversation's directory. Context files and skills load once per directory, like pi at startup.
*/
export function createPiPrompt(settings: SettingsManager, fallbackCwd: string) {
const resources = new Map<string, { contextFiles: { path: string; content: string }[]; skills: Skill[] }>();
const load = (cwd: string) => {
let found = resources.get(cwd);
if (found === undefined) {
const agentDir = getAgentDir();
found = {
contextFiles: loadProjectContextFiles({ cwd, agentDir }),
skills: loadSkills({ cwd, agentDir, skillPaths: settings.getSkillPaths(), includeDefaults: true }).skills,
};
resources.set(cwd, found);
}
return found;
};
// The sections of one request render from one build.
const built = new WeakMap<PromptInput, Record<string, string>>();
const build = (input: PromptInput): Record<string, string> => {
let sections = built.get(input);
if (sections === undefined) {
sections = buildSections(input);
built.set(input, sections);
}
return sections;
};
const buildSections = (input: PromptInput): Record<string, string> => {
const cwd = input.env?.cwd ?? input.agent.cwd ?? fallbackCwd;
const selectedTools = input.agent.tools.map((tool) => tool.name);
const snippets: Record<string, string> = {};
const guidelines: Record<string, string[]> = {};
for (const name of selectedTools) {
const contribution = CONTRIBUTIONS[name as keyof typeof CONTRIBUTIONS];
if (contribution === undefined) continue;
snippets[name] = contribution.snippet;
guidelines[name] = [...contribution.guidelines];
}
return buildSystemPromptSections({
cwd,
selectedTools,
toolSnippets: snippets,
toolGuidelines: guidelines,
...load(cwd),
});
};
return defineExtension({
name: "pi-prompt",
// The built sections carry their own tags.
sections: KEYS.map((key) => section(key, (input) => build(input)[key], { tag: false })),
});
}

View file

@ -1,373 +0,0 @@
import type { AttachedReplicatedState } from "@earendil-works/chord";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { clampThinkingLevel, getSupportedThinkingLevels, type ModelThinkingLevel } from "@earendil-works/pi-ai";
import {
type AgentState,
type Conversation,
type ConversationId,
type ConversationView,
type Cursor,
type EntryRecord,
Harness,
type ModelRef,
ROOT_CONVERSATION_ID,
type Submission,
type TaskGraph,
} from "@earendil-works/pi-durable";
import { openNodeSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite/node";
import { ModelRuntime } from "../../core/model-runtime.ts";
import { SettingsManager } from "../../core/settings-manager.ts";
import {
configureHarnessHttp,
createCodingRegistry,
createHarnessSettings,
ExecutionEnvs,
findInitialAgentModel,
} from "./harness-setup.ts";
import { selectSession } from "./sessions.ts";
import { Subagent } from "./subagent.ts";
const context = BACKGROUND_CONTEXT;
export interface ModelSummary extends ModelRef {
readonly name: string;
readonly contextWindow: number;
}
export interface Notice {
readonly id: number;
readonly level: "info" | "warning" | "error";
readonly message: string;
}
/** A conversation the user can switch to: the main one, or a subagent's. */
export interface ConversationSummary {
readonly id: ConversationId;
readonly label: string;
/** The first user message, for a subagent its task. */
readonly title?: string;
}
/** Everything the TUI renders. Plain values; no Harness objects cross this boundary. */
export interface DurableView {
readonly session: { readonly id: string; readonly directory: string; readonly cwd: string };
/** The conversation shown and talked to. */
readonly conversation: ConversationView;
readonly conversations: readonly ConversationSummary[];
readonly models: readonly ModelSummary[];
readonly notices: readonly Notice[];
/** The live task graph while the task panel is open. */
readonly tasks?: TaskGraph;
}
export interface DurableViewSource {
current(): DurableView;
subscribe(listener: () => void): () => void;
}
/** What the TUI may ask for. */
export interface DurableController {
/** Prompt when idle; otherwise steer or queue a follow-up. */
submit(text: string, whenBusy: "steer" | "followUp"): Promise<void>;
compact(instructions: string | undefined): Promise<void>;
abort(): Promise<void>;
cycleThinking(): Promise<void>;
setModel(model: ModelRef): Promise<void>;
toggleTasks(): Promise<void>;
/** Show and talk to another conversation. */
switchConversation(id: ConversationId): Promise<void>;
}
export interface OpenDurableOptions {
readonly cwd?: string;
readonly continueSession?: boolean;
}
export interface OpenDurableResult {
readonly view: DurableViewSource;
readonly controller: DurableController;
/** pi's settings, for the TUI's theme and terminal capabilities. */
readonly settings: SettingsManager;
close(): Promise<void>;
}
/** The agent document of a view; absent while the conversation has none. */
export function agentOf(view: ConversationView): AgentState {
return (view.docs["pi.agent"] ?? {}) as AgentState;
}
/** A subagent's task: the oldest user message of its conversation. The main conversation needs no title. */
async function firstInput(harness: Harness, id: ConversationId): Promise<{ title?: string }> {
if (id === ROOT_CONVERSATION_ID) return {};
const conversation = (await harness.conversation(id, context))!;
let first: EntryRecord | undefined;
let cursor: Cursor | undefined;
do {
const page = await conversation.entries({}, 256, cursor, context);
first = page.items.findLast(isInput) ?? first;
cursor = page.next;
} while (cursor !== undefined);
return titleOf(first);
}
/** The text of a user entry, as a one-line title. */
/** A placed input: `pi.user`, or an application entry whose model is one user message. */
function isInput(entry: EntryRecord): boolean {
if (entry.kind === "pi.user") return true;
return !entry.kind.startsWith("pi.") && entry.model?.length === 1 && entry.model[0]!.role === "user";
}
function titleOf(entry: EntryRecord | undefined): { title?: string } {
const message = entry?.model?.[0];
if (message?.role !== "user") return {};
const text =
typeof message.content === "string"
? message.content
: message.content.flatMap((block) => (block.type === "text" ? [block.text] : [])).join(" ");
return { title: text.replace(/\s+/g, " ").trim() };
}
export async function openDurable(options: OpenDurableOptions = {}): Promise<OpenDurableResult> {
const location = await selectSession(options.cwd ?? process.cwd(), options.continueSession ?? false);
const envs = new ExecutionEnvs(location.cwd);
let harness: Harness | undefined;
try {
const modelRuntime = await ModelRuntime.create();
const settingsManager = SettingsManager.create(location.cwd);
configureHarnessHttp(settingsManager);
const settings = createHarnessSettings(settingsManager);
const registry = createCodingRegistry(settingsManager, location.cwd);
registry.install(Subagent);
const pendingReports: unknown[] = [];
let report: (error: unknown) => void = (error) => pendingReports.push(error);
harness = await Harness.open(
await openNodeSqliteStorage(location.database),
{
models: modelRuntime,
registry,
settings,
env: envs.env,
onReport: (error) => report(error),
},
context,
);
const initial = location.created ? await findInitialAgentModel(settingsManager, modelRuntime) : undefined;
const root = await harness.root(context, {
agent: {
cwd: location.cwd,
...(initial?.model === undefined ? {} : { model: initial.model }),
...(initial?.thinkingLevel === undefined ? {} : { thinkingLevel: initial.thinkingLevel }),
},
});
const label = (id: ConversationId): string => (id === root.id ? "main" : `subagent ${id}`);
const opened = harness;
const summaries: ConversationSummary[] = [];
let cursor: Cursor | undefined;
do {
const page = await opened.commit((tx) => tx.scanConversations({}, 256, cursor), context);
for (const { id } of page.items) summaries.push({ id, label: label(id), ...(await firstInput(opened, id)) });
cursor = page.next;
} while (cursor !== undefined);
let current: Conversation = root;
let conversation: AttachedReplicatedState<ConversationView> = await root.viewState(context);
const models = (): ModelSummary[] =>
modelRuntime.getAvailableSnapshot().map((model) => ({
provider: model.provider,
modelId: model.id,
name: model.name,
contextWindow: model.contextWindow,
}));
let state: DurableView = {
session: { id: location.id, directory: location.directory, cwd: location.cwd },
conversation: conversation.value,
conversations: summaries,
models: models(),
notices: [],
};
const listeners = new Set<() => void>();
let notifying = false;
// Commit listeners and Chord frames call this on the Session line; rendering runs afterwards, once per burst.
const update = (patch: Partial<DurableView>): void => {
state = { ...state, ...patch };
if (notifying) return;
notifying = true;
setImmediate(() => {
notifying = false;
for (const listener of listeners) listener();
});
};
let nextNotice = 1;
const notice = (level: Notice["level"], message: string): void => {
update({ notices: [...state.notices, { id: nextNotice++, level, message }].slice(-20) });
};
const fail = (error: unknown): void => notice("error", error instanceof Error ? error.message : String(error));
report = (error) => notice("warning", error instanceof Error ? error.message : String(error));
for (const error of pendingReports) report(error);
let unsubscribe = conversation.subscribe((value) => update({ conversation: value }));
// Subagents appear as their conversations are created. A commit listener only records; it calls no Session API.
const unsubscribeCommits = harness.subscribeCommits((publication) => {
let conversations = state.conversations;
for (const change of publication.changes) {
if (change.type === "conversation") {
conversations = [...conversations, { id: change.value.id, label: label(change.value.id) }];
} else if (change.type === "entry" && isInput(change.value)) {
const id = change.value.conversationId;
conversations = conversations.map((summary) =>
summary.id === id && summary.title === undefined ? { ...summary, ...titleOf(change.value) } : summary,
);
}
}
if (conversations !== state.conversations) update({ conversations });
});
let tasks: AttachedReplicatedState<TaskGraph> | undefined;
let unsubscribeTasks = (): void => {};
const closeTasks = (): void => {
unsubscribeTasks();
tasks?.dispose();
tasks = undefined;
};
let queue = Promise.resolve();
// One at a time, so toggles, switches, and key presses apply in order.
const command = (operation: () => Promise<void>): Promise<void> => {
queue = queue.then(operation).catch(fail);
return queue;
};
const watchAnswer = (submission: Submission): void => {
void submission.wait(context).then((settled) => {
if (settled.status === "unanswered" && settled.reason !== "aborted") {
notice(
"error",
`No answer: ${settled.reason}${settled.detail === undefined ? "" : ` ${JSON.stringify(settled.detail)}`}`,
);
}
}, fail);
};
const agentModel = () => {
const ref = agentOf(state.conversation).model;
const model = ref === undefined ? undefined : modelRuntime.getModel(ref.provider, ref.modelId);
if (model === undefined)
throw new Error(ref === undefined ? "No model selected" : "Current model is unavailable");
return model;
};
const controller: DurableController = {
submit: (text, whenBusy) =>
command(async () => watchAnswer(await current.submit({ type: "input", content: text, whenBusy }, context))),
compact: (instructions) =>
command(async () => {
const id = await current.compact(instructions, context);
// Report the outcome once it is known; the status line shows the compaction meanwhile.
void opened.waitForTask(id, context).then(async (receipt) => {
const outcome = receipt.state.outcome;
if (outcome.status === "completed") {
const { entryId, submissionId } = outcome.result;
// A summary written while busy is a submission: placed now, queued, or dropped as stale.
const status =
submissionId === undefined
? undefined
: (await (await opened.submission(submissionId, context))?.status(context))?.status;
notice(
"info",
entryId !== undefined || status === "done"
? "Compacted."
: status === "queued"
? "Compaction summary queued; it is placed at the next turn boundary."
: status === "unanswered"
? "Compaction summary dropped: the context changed under it."
: "Nothing to compact: the context fits in the recent window that is kept verbatim.",
);
} else if (outcome.status === "aborted") notice("info", "Compaction aborted.");
else
notice("error", `Compaction ${outcome.status}: ${outcome.error?.message ?? outcome.reason ?? ""}`);
}, fail);
}),
// Not queued: it waits until the conversation is idle.
abort: () => current.abort(context).catch(fail),
cycleThinking: () =>
command(async () => {
const model = agentModel();
if (!model.reasoning) throw new Error("Current model does not support thinking");
const levels = getSupportedThinkingLevels(model);
const level = agentOf(state.conversation).thinkingLevel ?? "off";
const next = levels[(levels.indexOf(level) + 1) % levels.length] ?? "off";
await current.configure({ thinkingLevel: next }, context);
}),
setModel: (ref) =>
command(async () => {
const model = modelRuntime.getModel(ref.provider, ref.modelId);
if (model === undefined) throw new Error(`Unknown model: ${ref.provider}/${ref.modelId}`);
const thinking: ModelThinkingLevel = agentOf(state.conversation).thinkingLevel ?? "off";
await current.configure({ model: ref, thinkingLevel: clampThinkingLevel(model, thinking) }, context);
}),
toggleTasks: () =>
command(async () => {
if (tasks !== undefined) {
closeTasks();
update({ tasks: undefined });
return;
}
const graph = await opened.taskGraph(context);
tasks = graph;
unsubscribeTasks = graph.subscribe((value) => update({ tasks: value }));
}),
switchConversation: (id) =>
command(async () => {
const next = await opened.conversation(id, context);
if (next === undefined) throw new Error(`Conversation ${id} does not exist`);
const nextState = await next.viewState(context);
unsubscribe();
conversation.dispose();
current = next;
conversation = nextState;
unsubscribe = nextState.subscribe((value) => update({ conversation: value }));
}),
};
const saved = agentOf(state.conversation).model;
if (saved === undefined) notice("warning", "No model configured; select one with /model.");
else if (modelRuntime.getModel(saved.provider, saved.modelId) === undefined) {
notice("warning", `Saved model is unavailable: ${saved.provider}/${saved.modelId}`);
}
if (initial?.fallbackMessage !== undefined) notice("info", initial.fallbackMessage);
// The task panel starts open; /tasks hides it.
await controller.toggleTasks();
// Recovered work from an interrupted turn continues now.
harness.resume();
let closing: Promise<void> | undefined;
return {
view: {
current: () => state,
subscribe: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
},
controller,
settings: settingsManager,
close() {
closing ??= (async () => {
unsubscribe();
unsubscribeCommits();
conversation.dispose();
closeTasks();
try {
// Close writes no outcome: a running turn resumes with --continue.
await opened.close(context);
await envs.cleanup(context);
} finally {
await location.release();
}
})();
return closing;
},
};
} catch (error) {
await harness?.close(context).catch(() => {});
await location.release().catch(() => {});
throw error;
}
}

View file

@ -1,56 +0,0 @@
import { createHash, randomUUID } from "node:crypto";
import { mkdir, readdir, realpath } from "node:fs/promises";
import { basename, join, resolve } from "node:path";
import lockfile from "proper-lockfile";
import { getAgentDir } from "../../config.ts";
/** One session directory holding `session.sqlite`, locked by this process. */
export interface SessionLocation {
id: string;
directory: string;
database: string;
cwd: string;
created: boolean;
release(): Promise<void>;
}
/** A new session for `cwd`, or its newest one with `continueSession`. */
export async function selectSession(cwdInput: string, continueSession: boolean): Promise<SessionLocation> {
const cwd = await realpath(resolve(cwdInput));
const root = join(
getAgentDir(),
"experimental",
"durable-sessions",
createHash("sha256").update(cwd).digest("hex").slice(0, 24),
);
await mkdir(root, { recursive: true });
let directory: string;
let created = false;
if (continueSession) {
const entries = await readdir(root, { withFileTypes: true });
const newest = entries
.filter((entry) => entry.isDirectory() && /^\d{13}-[0-9a-f-]{36}$/u.test(entry.name))
.map((entry) => entry.name)
.sort()
.at(-1);
if (!newest) throw new Error(`No durable session exists for ${cwd}`);
directory = join(root, newest);
} else {
directory = join(root, `${String(Date.now()).padStart(13, "0")}-${randomUUID()}`);
await mkdir(directory);
created = true;
}
let release: () => Promise<void>;
try {
// A lock left by a crashed process goes stale after 10 s; wait that long before giving up.
release = await lockfile.lock(directory, {
realpath: false,
retries: { retries: 12, minTimeout: 1000, maxTimeout: 1000 },
});
} catch (error) {
throw new Error(`Session is already open in another process: ${directory}`, { cause: error });
}
return { id: basename(directory), directory, database: join(directory, "session.sqlite"), cwd, created, release };
}

View file

@ -1,55 +0,0 @@
import type { Context } from "@earendil-works/chord";
import { type AssistantMessage, Type } from "@earendil-works/pi-ai";
import {
AssistantEntry,
configure,
defineExtension,
defineTool,
type EntryId,
type Extension,
type ToolExecutionApi,
} from "@earendil-works/pi-durable";
async function answerText(api: ToolExecutionApi, answer: EntryId, context: Context): Promise<string> {
const entry = await api.commit((tx) => tx.entry(AssistantEntry, answer), context);
const message = entry?.model?.[0] as AssistantMessage | undefined;
return message?.content.flatMap((content) => (content.type === "text" ? [content.text] : [])).join("") ?? "";
}
/**
* A foreground subagent: each call creates a child conversation owned by the call's task, runs the task there, and
* returns the child's answer. Aborting the call aborts the child. The child outlives the call, so the user can switch
* to it and keep talking.
*/
export const Subagent: Extension = defineExtension({
name: "subagent",
tools: [
defineTool({
name: "subagent",
description:
"Delegate a self-contained task to a subagent with the same tools and get its answer back. Give it everything it needs to know; it does not see this conversation.",
parameters: Type.Object({ task: Type.String({ description: "What the subagent should do" }) }),
// A rerun after a crash finds the child it created and the submission it made.
replay: "safe",
execute: async (args, api, context) => {
const child = await api.commit(async (tx) => {
const existing = (await tx.scanConversations({ ownerTaskId: api.taskId }, 1)).items[0];
if (existing !== undefined) return existing.id;
// Starts as a copy of this conversation's agent; without this extension it cannot delegate further.
const created = await tx.createConversation({ ownership: { kind: "task", taskId: api.taskId } });
await configure(tx, created.id, { extensions: { remove: [Subagent] } });
return created.id;
}, context);
await api.details({ conversationId: child }, context);
const handle = (await api.conversation(child, context))!;
const request = { type: "input", content: args.task, requestId: `subagent:${api.taskId}` } as const;
const settled = await (await handle.submit(request, context)).wait(context);
if (settled.status !== "done" || settled.type !== "input") {
throw new Error(`Subagent ${child} failed: ${settled.status}`);
}
const text = await answerText(api, settled.answer, context);
return { content: [{ type: "text", text }], details: { conversationId: child } };
},
}),
],
});

View file

@ -1,660 +0,0 @@
import type { AssistantMessage, ToolResultMessage, Usage, UserMessage } from "@earendil-works/pi-ai";
import type {
ConversationId,
EntryRecord,
InboxState,
LiveState,
TaskGraph,
TaskGraphNode,
UsageState,
} from "@earendil-works/pi-durable";
import {
Box,
type Component,
Container,
type Focusable,
fuzzyFilter,
getKeybindings,
Input,
Markdown,
ProcessTerminal,
ScrollView,
type SelectItem,
SelectList,
type SelectListTheme,
Spacer,
setCapabilityOverrides,
setKeybindings,
Text,
TruncatedText,
TuiAltScreen,
VStack,
} from "@earendil-works/pi-tui";
import { getAgentDir } from "../../config.ts";
import { KeybindingsManager } from "../../core/keybindings.ts";
import type { SettingsManager } from "../../core/settings-manager.ts";
import { createAllToolRenderers } from "../../core/tools/renderers/index.ts";
import { AssistantMessageComponent } from "../../modes/interactive/components/assistant-message.ts";
import { CustomEditor } from "../../modes/interactive/components/custom-editor.ts";
import { DynamicBorder } from "../../modes/interactive/components/dynamic-border.ts";
import { formatTokens } from "../../modes/interactive/components/footer.ts";
import { keyText } from "../../modes/interactive/components/keybinding-hints.ts";
import { type StatusIndicator, WorkingStatusIndicator } from "../../modes/interactive/components/status-indicator.ts";
import { ToolExecutionComponent, type ToolRenderers } from "../../modes/interactive/components/tool-execution.ts";
import { UserMessageComponent } from "../../modes/interactive/components/user-message.ts";
import { getEditorTheme, getMarkdownTheme, initTheme, theme } from "../../modes/interactive/theme/theme.ts";
import { InteractiveThemeController } from "../../modes/interactive/theme/theme-controller.ts";
import { agentOf, type DurableController, type DurableView, type DurableViewSource } from "./runtime.ts";
const SELECT_THEME: SelectListTheme = {
selectedPrefix: (text) => theme.fg("accent", text),
selectedText: (text) => theme.fg("accent", text),
description: (text) => theme.fg("muted", text),
scrollInfo: (text) => theme.fg("dim", text),
noMatch: (text) => theme.fg("warning", text),
};
/** A filterable list in place of the editor. */
class ListSelector extends Container implements Focusable {
readonly #input = new Input();
readonly #listContainer = new Container();
readonly #items: SelectItem[];
readonly #onSelect: (value: string) => void;
readonly #onCancel: () => void;
#list: SelectList;
#focused = false;
constructor(title: string, items: SelectItem[], onSelect: (value: string) => void, onCancel: () => void) {
super();
this.#items = items;
this.#onSelect = onSelect;
this.#onCancel = onCancel;
this.#list = this.#build(items);
this.addChild(new DynamicBorder());
this.addChild(new Spacer(1));
this.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0));
this.addChild(this.#input);
this.addChild(new Spacer(1));
this.addChild(this.#listContainer);
this.addChild(new DynamicBorder());
}
get focused(): boolean {
return this.#focused;
}
set focused(value: boolean) {
this.#focused = value;
this.#input.focused = value;
}
handleInput(data: string): void {
const keybindings = getKeybindings();
const forwarded = ["tui.select.up", "tui.select.down", "tui.select.confirm", "tui.select.cancel"] as const;
if (forwarded.some((action) => keybindings.matches(data, action))) {
this.#list.handleInput(data);
return;
}
this.#input.handleInput(data);
const query = this.#input.getValue();
const filtered =
query.length === 0 ? this.#items : fuzzyFilter(this.#items, query, (item) => `${item.label} ${item.value}`);
this.#list = this.#build(filtered);
}
#build(items: SelectItem[]): SelectList {
const list = new SelectList(items, 10, SELECT_THEME);
list.onSelect = (item) => this.#onSelect(item.value);
list.onCancel = this.#onCancel;
this.#listContainer.clear();
this.#listContainer.addChild(list);
return list;
}
}
/** The summary that replaced earlier context: collapsed to one line until expanded. */
class CompactionComponent extends Box {
readonly #summary: string;
constructor(summary: string, expanded: boolean) {
super(1, 1, (text) => theme.bg("customMessageBg", text));
this.#summary = summary;
this.setExpanded(expanded);
}
setExpanded(expanded: boolean): void {
this.clear();
this.addChild(new Text(theme.fg("customMessageLabel", theme.bold("[compaction]")), 0, 0));
this.addChild(new Spacer(1));
this.addChild(
expanded
? new Markdown(this.#summary, 0, 0, getMarkdownTheme(), {
color: (text: string) => theme.fg("customMessageText", text),
})
: new Text(
theme.fg("customMessageText", "Earlier context summarized (") +
theme.fg("dim", keyText("app.tools.expand")) +
theme.fg("customMessageText", " to expand)"),
0,
0,
),
);
}
}
interface Handlers {
submit(text: string): void;
followUp(text: string): void;
abort(): void;
exit(): void;
selectModel(): void;
cycleThinking(): void;
}
class DurableTui {
static readonly #renderers: Record<string, ToolRenderers> = createAllToolRenderers();
readonly #ui: TuiAltScreen;
readonly #chat = new Container();
readonly #tasks = new Container();
readonly #queue = new Container();
readonly #notices = new Container();
readonly #footer = new Container();
readonly #footerStats = new Text("", 1, 0);
readonly #footerHints = new Text("", 1, 0);
readonly #editorContainer = new Container();
readonly #editor: CustomEditor;
readonly #cwd: string;
/** The newest card per call ID; provider call IDs may repeat across turns. */
readonly #tools = new Map<string, ToolExecutionComponent>();
/** Every card shown, also older ones whose call ID a later turn reused. */
readonly #cards: ToolExecutionComponent[] = [];
/** Call IDs whose cards the streaming answer created; its entry takes them over. */
readonly #streamingCalls = new Set<string>();
readonly #summaries: CompactionComponent[] = [];
/** Tool output and summaries shown in full; toggled like pi. */
#expanded = false;
#renderedEntryIds: number[] = [];
#streaming: AssistantMessageComponent | undefined;
#indicator: StatusIndicator | undefined;
#statusText = "";
/** Set when the transcript was rebuilt: the next render repaints the screen and shows the end. */
#rebuilt = false;
#transcript: ScrollView;
constructor(cwd: string, handlers: Handlers) {
this.#cwd = cwd;
this.#ui = new TuiAltScreen(new ProcessTerminal(), false, getAgentDir());
const keybindings = KeybindingsManager.create();
setKeybindings(keybindings);
this.#editor = new CustomEditor(this.#ui, getEditorTheme(), keybindings, {
paddingX: 1,
embedWorkingStatus: true,
});
this.#editor.onSubmit = handlers.submit;
this.#editor.onEscape = handlers.abort;
this.#editor.onCtrlD = handlers.exit;
this.#editor.onAction("app.clear", handlers.exit);
this.#editor.onAction("app.model.select", handlers.selectModel);
this.#editor.onAction("app.thinking.cycle", handlers.cycleThinking);
this.#editor.onAction("app.tools.expand", () => {
this.#expanded = !this.#expanded;
for (const component of [...this.#cards, ...this.#summaries]) component.setExpanded(this.#expanded);
this.#ui.requestRender();
});
this.#editor.onAction("app.message.followUp", () => {
const text = this.#editor.getText().trim();
if (!text) return;
this.#editor.setText("");
handlers.followUp(text);
});
this.#editorContainer.addChild(this.#editor);
this.#footer.addChild(this.#footerStats);
this.#footer.addChild(this.#footerHints);
// One empty line between the transcript and everything below it.
const content = new Container();
content.addChild(this.#chat);
content.addChild(new Spacer(1));
const transcript = new ScrollView(content, { follow: "end", primary: true, overscroll: "chain" });
this.#transcript = transcript;
const dock = new VStack([
{ component: this.#tasks, shrink: 1, minSize: 0 },
{ component: this.#queue, shrink: 1, minSize: 0 },
{ component: this.#notices, shrink: 1, minSize: 0 },
{ component: this.#editorContainer, shrink: 1, minSize: 3 },
{ component: this.#footer, shrink: 1, minSize: 0 },
]);
for (const component of [
this.#chat,
this.#tasks,
this.#queue,
this.#notices,
this.#editorContainer,
this.#footer,
]) {
this.#ui.addChild(component);
}
this.#ui.setLayoutRoot(
new VStack([
{ component: transcript, basis: 0, grow: 1, shrink: 1, minSize: 1 },
{ component: dock, basis: "auto", grow: 0, shrink: 1, minSize: 1 },
]),
);
this.#ui.setFocus(this.#editor);
}
get ui(): TuiAltScreen {
return this.#ui;
}
start(): void {
this.#ui.start();
}
stop(): void {
this.#discardTools();
this.#indicator?.dispose();
this.#ui.stop();
}
/** Finish every card: a running bash card keeps a timer until it gets a final result. */
#discardTools(): void {
for (const component of this.#cards) component.updateResult({ content: [], isError: false }, false);
this.#cards.length = 0;
this.#tools.clear();
this.#streamingCalls.clear();
}
mount(component: Component): void {
this.#editorContainer.clear();
this.#editorContainer.addChild(component);
this.#ui.setFocus(component);
this.#ui.requestRender();
}
restoreEditor(): void {
this.mount(this.#editor);
}
apply(view: DurableView): void {
const live = (view.conversation.docs["pi.live"] ?? {}) as LiveState;
this.#syncTranscript(view.conversation.entries);
const message = live.generation?.message as AssistantMessage | undefined;
// A partial without its entry was dropped, for example by a retry: render the transcript again.
if (message === undefined && this.#streaming !== undefined) this.#rebuild(view.conversation.entries);
if (message !== undefined) this.#syncStreaming(message);
for (const slot of live.tools ?? []) {
if (slot.status === "pending") continue;
const component = this.#tool(slot.name, slot.callId);
component.setArgsComplete();
if (slot.status !== "running") continue;
component.markExecutionStarted();
const child = (slot.details as { conversationId?: number } | undefined)?.conversationId;
if (slot.output === undefined && child !== undefined) {
const text = `Subagent ${child} is working. /agents switches to it.`;
component.updateResult({ content: [{ type: "text", text }], details: slot.details, isError: false }, true);
} else if (slot.output !== undefined) {
component.updateResult(
{ content: [{ type: "text", text: slot.output }], details: slot.details, isError: false },
true,
);
}
}
this.#syncTasks(view.tasks);
this.#syncQueue((view.conversation.docs["pi.inbox"] ?? { items: [] }) as InboxState);
this.#syncNotices(view);
this.#editor.borderColor = theme.getThinkingBorderColor(agentOf(view.conversation).thinkingLevel ?? "off");
this.#syncStatus(live);
this.#syncFooter(view);
if (this.#rebuilt) this.#transcript.scrollToEnd();
this.#ui.requestRender(this.#rebuilt);
this.#rebuilt = false;
}
#syncTasks(graph: TaskGraph | undefined): void {
this.#tasks.clear();
if (graph === undefined) return;
const nodes = Object.values(graph.tasks);
const lines: string[] = [theme.fg("accent", `Tasks (${nodes.length} live, /tasks to hide)`)];
// A conversation-owned task sits under the task that owns its conversation, when that task is live.
const owned = new Set(nodes.flatMap((node) => node.conversations));
const children = (node: TaskGraphNode) =>
nodes.filter(
(candidate) =>
candidate.owner === node.id ||
(candidate.owner === undefined && node.conversations.includes(candidate.conversationId)),
);
const visit = (node: TaskGraphNode, depth: number): void => {
lines.push(`${" ".repeat(depth + 1)}${describeTask(node)}`);
for (const child of children(node)) visit(child, depth + 1);
};
for (const node of nodes) {
if (node.owner === undefined && !owned.has(node.conversationId)) visit(node, 0);
}
for (const line of lines) this.#tasks.addChild(new TruncatedText(theme.fg("muted", line), 1, 0));
}
#syncQueue(inbox: InboxState): void {
this.#queue.clear();
for (const item of inbox.items) {
const text =
item.mode === "write" ? `<${String(item.entry.kind)}>` : userText(item.content as UserMessage["content"]);
this.#queue.addChild(new TruncatedText(theme.fg("muted", `[${item.mode}] ${text}`), 1, 0));
}
}
#syncNotices(view: DurableView): void {
this.#notices.clear();
for (const item of view.notices.slice(-4)) {
const color = item.level === "error" ? "error" : item.level === "warning" ? "warning" : "muted";
this.#notices.addChild(new TruncatedText(theme.fg(color, item.message), 1, 0));
}
}
#syncStatus(live: LiveState): void {
const generation = live.generation;
const compaction = live.compactions?.[0];
const runningTool = live.tools?.find((slot) => slot.status === "running");
let text = "";
if (generation?.retry !== undefined) {
text = `Retrying (attempt ${generation.attempt + 1}): ${generation.retry.error}`;
} else if (generation?.deferred !== undefined) text = "Waiting for deferred response...";
else if (compaction !== undefined) {
text = compaction.retry
? `Retrying ${compaction.reason} compaction (attempt ${compaction.attempt + 1})...`
: `Compacting (${compaction.reason})...`;
} else if (runningTool !== undefined) text = `Running ${runningTool.name}... (esc to abort)`;
else if (live.run !== undefined) text = "Working... (esc to abort)";
if (text === this.#statusText) return;
this.#statusText = text;
this.#indicator?.dispose();
this.#indicator = text
? new WorkingStatusIndicator(this.#ui, text, undefined, (part) => this.#editor.borderColor(part))
: undefined;
this.#editor.setWorkingStatusIndicator(this.#indicator);
}
#syncFooter(view: DurableView): void {
const agent = agentOf(view.conversation);
const usage = totalUsage((view.conversation.docs["pi.usage"] ?? { models: {}, tools: {} }) as UsageState);
const stats: string[] = [];
if (usage.input) stats.push(`↑${formatTokens(usage.input)}`);
if (usage.output) stats.push(`↓${formatTokens(usage.output)}`);
if (usage.cacheRead) stats.push(`R${formatTokens(usage.cacheRead)}`);
if (usage.cacheWrite) stats.push(`W${formatTokens(usage.cacheWrite)}`);
stats.push(`$${usage.cost.total.toFixed(3)}`);
const contextWindow =
view.models.find((model) => model.provider === agent.model?.provider && model.modelId === agent.model.modelId)
?.contextWindow ?? 0;
if (contextWindow > 0) {
const tokens = contextTokens(view.conversation.entries);
const percent = tokens === undefined ? undefined : (tokens / contextWindow) * 100;
const text = `${percent === undefined ? "?" : percent.toFixed(1)}%/${formatTokens(contextWindow)}`;
stats.push(percent !== undefined && percent > 90 ? theme.fg("error", text) : text);
}
this.#footerStats.setText(theme.fg("dim", `${stats.join(" ")} ${view.session.cwd}`));
const model = agent.model === undefined ? "no model" : `${agent.model.provider}/${agent.model.modelId}`;
const shown = view.conversations.find((candidate) => candidate.id === view.conversation.conversation.id);
const label = shown?.label ?? `conversation ${view.conversation.conversation.id}`;
this.#footerHints.setText(
`${theme.fg(label === "main" ? "dim" : "accent", label)}${theme.fg(
"dim",
` · ${model} · thinking:${agent.thinkingLevel ?? "off"} (${keyText("app.thinking.cycle")}) · ${keyText("app.model.select")} or /model · /agents · /compact · /tasks · ${keyText("app.message.followUp")} follow-up · ${keyText("app.clear")} exit`,
)}`,
);
}
#syncTranscript(entries: readonly EntryRecord[]): void {
// Compaction and resets replace the head of the active transcript.
if (this.#renderedEntryIds.some((id, index) => entries[index]?.id !== id)) this.#rebuild(entries);
for (const entry of entries.slice(this.#renderedEntryIds.length)) {
this.#addEntry(entry);
this.#renderedEntryIds.push(entry.id);
}
}
#rebuild(entries: readonly EntryRecord[]): void {
this.#chat.clear();
this.#discardTools();
this.#summaries.length = 0;
this.#rebuilt = true;
this.#renderedEntryIds = [];
this.#streaming = undefined;
for (const entry of entries) {
this.#addEntry(entry);
this.#renderedEntryIds.push(entry.id);
}
}
#addEntry(entry: EntryRecord): void {
const message = entry.model?.[0];
if (entry.kind === "pi.user" && message?.role === "user") {
this.#chat.addChild(new Spacer(1));
this.#chat.addChild(new UserMessageComponent(userText(message.content)));
} else if (entry.kind === "pi.assistant" && message?.role === "assistant") {
const component = this.#streaming ?? new AssistantMessageComponent();
if (this.#streaming === undefined) this.#chat.addChild(component);
this.#streaming = undefined;
component.updateContent(message, false);
// Only a tool-calling answer runs its calls; an aborted, failed, or truncated one never does.
const ran = message.stopReason === "toolUse";
for (const content of message.content) {
if (content.type !== "toolCall") continue;
const streamed = this.#streamingCalls.has(content.id);
// Only cards the stream already showed are kept for calls that never run.
if (!ran && !streamed) continue;
const card = this.#tool(content.name, content.id, content.arguments, !streamed);
card.setArgsComplete();
if (!ran) {
const text = "Not run: the answer was interrupted.";
card.updateResult({ content: [{ type: "text", text }], isError: true }, false);
}
}
this.#streamingCalls.clear();
} else if (entry.kind === "pi.tool-result" && message?.role === "toolResult") {
const result = message as ToolResultMessage;
this.#tool(result.toolName, result.toolCallId).updateResult(result);
} else if (entry.kind === "pi.compaction") {
const summary = new CompactionComponent(
message?.role === "user" ? userText(message.content) : "",
this.#expanded,
);
this.#summaries.push(summary);
this.#chat.addChild(new Spacer(1));
this.#chat.addChild(summary);
} else if (entry.kind === "pi.reset") this.#addText("[new context]");
else if (!entry.kind.startsWith("pi.") && message?.role === "user" && entry.model?.length === 1) {
// An input placed as an application entry.
this.#chat.addChild(new Spacer(1));
this.#chat.addChild(new UserMessageComponent(userText(message.content)));
}
}
#syncStreaming(message: AssistantMessage): void {
if (this.#streaming === undefined) {
this.#streaming = new AssistantMessageComponent();
this.#chat.addChild(this.#streaming);
}
this.#streaming.updateContent(message, true);
for (const content of message.content) {
if (content.type !== "toolCall") continue;
this.#tool(content.name, content.id, content.arguments, !this.#streamingCalls.has(content.id));
this.#streamingCalls.add(content.id);
}
}
#addText(text: string): void {
this.#chat.addChild(new Spacer(1));
this.#chat.addChild(new Text(theme.fg("muted", text), 1, 0));
}
/** The card of a call; `fresh` starts a new one for a call ID an earlier turn used. */
#tool(name: string, callId: string, args?: unknown, fresh = false): ToolExecutionComponent {
const existing = fresh ? undefined : this.#tools.get(callId);
if (existing !== undefined) {
if (args !== undefined) existing.updateArgs(args);
return existing;
}
const component = new ToolExecutionComponent(
name,
callId,
args ?? {},
{},
DurableTui.#renderers[name],
this.#ui,
this.#cwd,
);
component.setExpanded(this.#expanded);
this.#chat.addChild(component);
this.#cards.push(component);
this.#tools.set(callId, component);
return component;
}
}
function describeTask(node: TaskGraphNode): string {
const state = node.state;
const status =
state.status === "waiting"
? `waiting on ${state.on.join(", ")}`
: state.status === "completing"
? `completing (${state.outcome})`
: `${state.status} ${state.phase}`;
const flags = [node.background ? "background" : "", node.abortRequested ? "aborting" : ""].filter(Boolean);
const owned = node.conversations.length > 0 ? ` owns conversation ${node.conversations.join(", ")}` : "";
return `${node.kind} #${node.id}: ${status}${flags.length > 0 ? ` [${flags.join(", ")}]` : ""}${owned}`;
}
function userText(content: UserMessage["content"]): string {
if (typeof content === "string") return content;
return content
.filter((block) => block.type === "text")
.map((block) => block.text)
.join("");
}
function totalUsage(state: UsageState): Usage {
const total: Usage = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
for (const usage of [...Object.values(state.models), ...Object.values(state.tools)]) {
total.input += usage.input;
total.output += usage.output;
total.cacheRead += usage.cacheRead;
total.cacheWrite += usage.cacheWrite;
total.cost.total += usage.cost.total;
}
return total;
}
/** Context size from the newest successful answer after the newest compaction; unknown before one. */
function contextTokens(entries: readonly EntryRecord[]): number | undefined {
// Kept entries follow the summary in the view but are older than it; only later answers measure the new context.
const compacted = Math.max(0, ...entries.filter((entry) => entry.kind === "pi.compaction").map((entry) => entry.id));
for (const entry of [...entries].reverse()) {
const message = entry.model?.[0];
if (entry.id < compacted || entry.kind !== "pi.assistant" || message?.role !== "assistant") continue;
if (message.stopReason === "aborted" || message.stopReason === "error") continue;
const usage = message.usage;
return usage.totalTokens || usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
}
return undefined;
}
export async function runDurableTui(
source: DurableViewSource,
controller: DurableController,
settings: SettingsManager,
): Promise<void> {
setCapabilityOverrides(settings.getTerminalCapabilityOverrides());
// The system theme until the controller resolves the user's theme against the terminal's colors.
initTheme();
let exit = (): void => {};
const exited = new Promise<void>((resolve) => {
exit = resolve;
});
let view!: DurableTui;
const selectModel = (): void => {
const snapshot = source.current();
const current = agentOf(snapshot.conversation).model;
const isCurrent = (model: { provider: string; modelId: string }) =>
model.provider === current?.provider && model.modelId === current.modelId;
const items: SelectItem[] = [...snapshot.models]
.sort((left, right) => Number(isCurrent(right)) - Number(isCurrent(left)))
.map((model) => ({
value: `${model.provider}/${model.modelId}`,
label: model.modelId,
description: model.provider,
}));
const selector = new ListSelector(
"Select model:",
items,
(value) => {
view.restoreEditor();
const separator = value.indexOf("/");
void controller.setModel({ provider: value.slice(0, separator), modelId: value.slice(separator + 1) });
},
() => view.restoreEditor(),
);
view.mount(selector);
};
const selectConversation = (): void => {
const snapshot = source.current();
const items: SelectItem[] = [...snapshot.conversations].reverse().map((candidate) => ({
value: String(candidate.id),
label: candidate.label,
description: `${candidate.id === snapshot.conversation.conversation.id ? "(shown) " : ""}${candidate.title ?? ""}`,
}));
const selector = new ListSelector(
"Switch to:",
items,
(value) => {
view.restoreEditor();
void controller.switchConversation(Number(value) as ConversationId);
},
() => view.restoreEditor(),
);
view.mount(selector);
};
view = new DurableTui(source.current().session.cwd, {
submit: (text) => {
const trimmed = text.trim();
if (!trimmed) return;
if (trimmed === "/model") return selectModel();
if (trimmed === "/tasks") return void controller.toggleTasks();
if (trimmed === "/agents") return selectConversation();
if (trimmed === "/compact" || trimmed.startsWith("/compact ")) {
const instructions = trimmed.slice("/compact".length).trim();
return void controller.compact(instructions || undefined);
}
void controller.submit(trimmed, "steer");
},
followUp: (text) => void controller.submit(text, "followUp"),
abort: () => void controller.abort(),
exit,
selectModel,
cycleThinking: () => void controller.cycleThinking(),
});
// pi's theme handling: the theme setting (also light/dark pairs) resolved against the terminal's reported colors.
const themes = new InteractiveThemeController(view.ui, {
getSettingsManager: () => settings,
showError: (message) => console.error(message),
onChanged: () => view.ui.requestRender(),
});
const unsubscribe = source.subscribe(() => view.apply(source.current()));
view.start();
themes.applyFromSettings();
view.apply(source.current());
await exited;
unsubscribe();
themes.dispose();
view.stop();
}

View file

@ -1,16 +0,0 @@
export {
type AgentCompactionRequest,
AgentController,
type AgentOperationError,
type AgentOperationResponse,
type AgentPromptRequest,
type AgentPromptResult,
type AgentQueueResponse,
} from "./services/agent-controller.ts";
export { type PresentationSelectItem, PresentationUI } from "./services/presentation-ui.ts";
export {
type SlashCommandCompletion,
type SlashCommandContribution,
type SlashCommandRunResult,
SlashCommands,
} from "./services/slash-commands.ts";

View file

@ -1,55 +0,0 @@
import { combineFacetLoaders, type FacetLoader, type JsonValue } from "@earendil-works/chord";
import {
createFacetBundleArtifactLoader,
createFacetBundleLoader,
type FacetBundleArtifact,
readFacetBundleManifest,
} from "@earendil-works/chord/node";
const PRESENTATION_FACET_BUNDLES_KEY = "presentationFacetBundles";
const PI_PLUGIN_API = "@earendil-works/pi-coding-agent/experimental/plugin";
export function createSessionPluginFacetLoader(manifestPaths: readonly string[]): FacetLoader | undefined {
if (manifestPaths.length === 0) return undefined;
return combineFacetLoaders(manifestPaths.map(createOptionalSessionFacetLoader));
}
function createOptionalSessionFacetLoader(manifestPath: string): FacetLoader {
const loader = createFacetBundleLoader({
manifestPath,
entry: "session",
resolveExternal: resolvePluginExternal,
});
return {
async load() {
const manifest = await readFacetBundleManifest(manifestPath);
if (manifest.entries.session !== undefined) return loader.load();
return { facets: Object.freeze([]), async dispose() {} };
},
};
}
export function createPresentationFacetData(artifacts: readonly FacetBundleArtifact[]): JsonValue {
return {
[PRESENTATION_FACET_BUNDLES_KEY]: artifacts.map((artifact) => artifact as unknown as JsonValue),
};
}
/** Create local loaders only from artifacts selected and sent by the connected server. */
export function createPresentationFacetLoaders(data: JsonValue): readonly FacetLoader[] {
if (data === null || Array.isArray(data) || typeof data !== "object") {
throw new Error("Invalid presentation plugin data");
}
const artifacts = data[PRESENTATION_FACET_BUNDLES_KEY];
if (artifacts === undefined) return [];
if (!Array.isArray(artifacts)) throw new Error("Invalid presentation plugin bundle list");
return artifacts.map((artifact) =>
createFacetBundleArtifactLoader({ artifact, resolveExternal: resolvePluginExternal }),
);
}
function resolvePluginExternal(specifier: string): string | undefined {
if (specifier !== PI_PLUGIN_API) return undefined;
const extension = import.meta.url.endsWith(".ts") ? "ts" : "js";
return new URL(`../plugin.${extension}`, import.meta.url).href;
}

View file

@ -1,171 +0,0 @@
import { createHash } from "node:crypto";
import { readFile, rm, writeFile } from "node:fs/promises";
import { basename, dirname, join, resolve } from "node:path";
import { bundleFacetPackage } from "@earendil-works/chord/bundler";
import {
FACET_BUNDLE_MANIFEST_FILE,
type FacetBundleArtifact,
readFacetBundleArtifact,
} from "@earendil-works/chord/node";
import type { ServerId } from "@earendil-works/pi-protocol";
const PLUGIN_PACKAGE_PROFILE_VERSION = 1;
const DEFAULT_PLUGIN_FACETS = Object.freeze({ session: "src/session.ts", tui: "src/tui.ts" });
export interface ConfiguredServerPluginPackage {
readonly manifestPath: string;
build(): Promise<readonly FacetBundleArtifact[]>;
}
/** Persist an explicit plugin package selection or restore it for a later server generation. */
export async function restoreServerPluginPackageProfile(
directory: string,
serverId: ServerId,
configuredPackagePaths?: readonly string[],
): Promise<readonly string[]> {
const path = join(directory, `plugin-packages-${serverId}.json`);
if (configuredPackagePaths === undefined) {
return (await readPluginPackageProfile(path, false)) ?? [];
}
const packagePaths = normalizePluginPackagePaths(configuredPackagePaths);
if (packagePaths.length === 0) await rm(path, { force: true });
else await writePluginPackageProfile(path, packagePaths);
return packagePaths;
}
/** Read the explicit plugin selection stored for one durable Session. */
export function readSessionPluginPackageProfile(
directory: string,
serverId: ServerId,
sessionPath: string,
): Promise<readonly string[] | undefined> {
return readPluginPackageProfile(sessionPluginProfilePath(directory, serverId, sessionPath), true, sessionPath);
}
/** Remove the plugin selection stored for one deleted Session. */
export async function removeSessionPluginPackageProfile(
directory: string,
serverId: ServerId,
sessionPath: string,
): Promise<void> {
await rm(sessionPluginProfilePath(directory, serverId, sessionPath), { force: true });
}
/** Persist the plugin selection for one durable Session. */
export function writeSessionPluginPackageProfile(
directory: string,
serverId: ServerId,
sessionPath: string,
packagePaths: readonly string[],
): Promise<void> {
return writePluginPackageProfile(
sessionPluginProfilePath(directory, serverId, sessionPath),
normalizePluginPackagePaths(packagePaths),
sessionPath,
);
}
/** Create a serialized server-owned builder for one configured plugin package. */
export function createServerPluginPackage(
directory: string,
serverId: ServerId,
packagePath: string,
): ConfiguredServerPluginPackage {
const normalizedPackagePath = resolve(packagePath);
const outdir = join(directory, "plugin-builds", serverId, pluginBuildDirectoryName(normalizedPackagePath));
const manifestPath = join(outdir, FACET_BUNDLE_MANIFEST_FILE);
let buildTail = Promise.resolve();
return {
manifestPath,
build() {
const operation = buildTail.then(async () => {
const result = await bundleFacetPackage({
packagePath: normalizedPackagePath,
outdir,
defaultFacets: DEFAULT_PLUGIN_FACETS,
});
if (result.manifest.entries.tui === undefined) return [];
return [await readFacetBundleArtifact({ manifestPath: result.manifestPath, entry: "tui" })];
});
buildTail = operation.then(
() => undefined,
() => undefined,
);
return operation;
},
};
}
async function readPluginPackageProfile(
path: string,
allowEmpty: boolean,
sessionPath?: string,
): Promise<readonly string[] | undefined> {
let parsed: unknown;
try {
parsed = JSON.parse(await readFile(path, "utf8"));
} catch (error) {
if (error instanceof Error && "code" in error && error.code === "ENOENT") return undefined;
throw new Error(`Could not read experimental plugin package profile ${path}`, { cause: error });
}
if (
typeof parsed !== "object" ||
parsed === null ||
Array.isArray(parsed) ||
Object.keys(parsed).some(
(key) => key !== "version" && key !== "packagePaths" && !(key === "sessionPath" && sessionPath !== undefined),
) ||
!("version" in parsed) ||
parsed.version !== PLUGIN_PACKAGE_PROFILE_VERSION ||
(sessionPath === undefined
? "sessionPath" in parsed
: !("sessionPath" in parsed) || parsed.sessionPath !== sessionPath) ||
!("packagePaths" in parsed) ||
!Array.isArray(parsed.packagePaths) ||
(!allowEmpty && parsed.packagePaths.length === 0) ||
parsed.packagePaths.some((packagePath) => typeof packagePath !== "string" || packagePath.length === 0)
) {
throw new Error(`Invalid experimental plugin package profile ${path}`);
}
return normalizePluginPackagePaths(parsed.packagePaths as readonly string[]);
}
function writePluginPackageProfile(path: string, packagePaths: readonly string[], sessionPath?: string): Promise<void> {
return writeFile(
path,
`${JSON.stringify(
{
version: PLUGIN_PACKAGE_PROFILE_VERSION,
...(sessionPath === undefined ? {} : { sessionPath }),
packagePaths,
},
null,
2,
)}\n`,
{ encoding: "utf8", mode: 0o600 },
);
}
export function normalizePluginPackagePaths(packagePaths: readonly string[]): readonly string[] {
const normalized = packagePaths.map((packagePath) => {
if (packagePath.length === 0) throw new Error("Plugin package path must not be empty");
return resolve(packagePath);
});
if (new Set(normalized).size !== normalized.length) {
throw new Error("Plugin package paths must be unique");
}
return Object.freeze(normalized);
}
function sessionPluginProfilePath(directory: string, serverId: ServerId, sessionPath: string): string {
const hash = createHash("sha256").update(sessionPath).digest("hex").slice(0, 24);
return join(directory, `session-plugin-packages-${serverId}-${hash}.json`);
}
function pluginBuildDirectoryName(packagePath: string): string {
const packageDirectory = basename(packagePath) === "package.json" ? dirname(packagePath) : packagePath;
const base = basename(packageDirectory).replaceAll(/[^a-zA-Z0-9._-]/gu, "-") || "plugin";
const label = base.endsWith("-plugin") ? base : `${base}-plugin`;
const hash = createHash("sha256").update(packagePath).digest("hex").slice(0, 12);
return `${label}-${hash}`;
}

View file

@ -1,106 +0,0 @@
import { type ChildProcess, spawn } from "node:child_process";
import { join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { getPackageDir, isBunBinary, isBundledNode } from "../config.ts";
export const INTERNAL_PROCESS_ENV = "__PI_INTERNAL_SPAWN";
export type InternalProcessRole = "coordinator" | "server" | "session-worker";
/** Detect a directly executed source or unbundled internal-process module. */
export function isDirectInternalProcessEntry(moduleUrl: string): boolean {
return (
!isBunBinary &&
!isBundledNode &&
process.argv[1] !== undefined &&
resolve(process.argv[1]) === fileURLToPath(moduleUrl)
);
}
/** Read and validate an internal process role without consuming it. */
export function getInternalProcessRole(): InternalProcessRole | undefined {
const role = process.env[INTERNAL_PROCESS_ENV];
if (role === undefined) return undefined;
if (role === "coordinator" || role === "server" || role === "session-worker") return role;
throw new Error(`Unsupported internal process role: ${role}`);
}
/** Read, validate, and remove the role so descendants do not inherit it. */
export function consumeInternalProcessRole(): InternalProcessRole | undefined {
const role = getInternalProcessRole();
delete process.env[INTERNAL_PROCESS_ENV];
return role;
}
export interface InternalProcessSpawnOptions {
readonly entryUrl?: URL;
readonly env?: NodeJS.ProcessEnv;
}
/** Spawn a detached Pi-owned process consistently across Node and compiled Bun. */
export function spawnInternalProcess(
role: InternalProcessRole,
args: readonly string[],
options: InternalProcessSpawnOptions = {},
): ChildProcess {
if (isBunBinary && options.entryUrl) {
throw new Error("A compiled Bun executable cannot launch an external internal-process entrypoint");
}
const entryUrl = defaultEntryUrl(role, options.entryUrl);
// --import takes a module specifier; a raw path breaks on Windows drives and on `#`, `?`, or `%` in the path.
const sourceRuntimeArgs = import.meta.url.endsWith(".ts")
? ["--import", new URL("source-resolver.ts", import.meta.url).href]
: [];
const child = spawn(
process.execPath,
isBunBinary ? [...args] : [...sourceRuntimeArgs, fileURLToPath(entryUrl), ...args],
{
cwd: process.cwd(),
detached: true,
env: {
...process.env,
...options.env,
[INTERNAL_PROCESS_ENV]: role,
},
stdio: "ignore",
windowsHide: true,
},
);
child.unref();
return child;
}
/** Force a spawned internal process to exit and wait until it can no longer take ownership. */
export async function terminateInternalProcess(child: ChildProcess): Promise<void> {
if (child.pid === undefined || child.exitCode !== null || child.signalCode !== null) return;
const terminated = new Promise<void>((resolve) => {
child.once("exit", () => resolve());
child.once("error", () => resolve());
});
child.kill("SIGKILL");
await terminated;
}
function defaultEntryUrl(role: InternalProcessRole, override: URL | undefined): URL {
if (override) return override;
if (isBundledNode) {
const entry = role === "coordinator" ? "coordinator.js" : "cli.js";
return pathToFileURL(join(getPackageDir(), "dist", "bundle", entry));
}
const javaScript = import.meta.url.endsWith(".js");
if (role === "coordinator") {
return new URL(javaScript ? "coordinator.js" : "coordinator.ts", import.meta.url);
}
if (role === "server") {
return new URL(javaScript ? "server.js" : "server.ts", import.meta.url);
}
return new URL(javaScript ? "session-worker.js" : "session-worker.ts", import.meta.url);
}
export const MAX_CONTROL_LINE_BYTES = 128 * 1024 * 1024;
export function encodeControlLine(message: unknown): string {
const line = `${JSON.stringify(message)}\n`;
if (Buffer.byteLength(line) > MAX_CONTROL_LINE_BYTES) throw new Error("Internal control message is too large");
return line;
}

View file

@ -1,67 +0,0 @@
import { readFile } from "node:fs/promises";
import { normalizeRadiusGatewayUrl } from "@earendil-works/pi-ai/providers/radius-config";
import { getAuthCredential } from "../cli/auth-command.ts";
import type { AuthInput } from "../cli/experimental/command-options.ts";
import { ModelRuntime } from "../core/model-runtime.ts";
import { getRadiusGatewayUrl } from "../core/radius.ts";
import { resolvePath } from "../utils/paths.ts";
export { ENV_RADIUS_GATEWAY } from "../core/radius.ts";
export interface RadiusRelayAuth {
readonly gateway: string;
readonly token: string;
}
/** Resolve explicit or stored Radius credentials anew for every relay connection attempt. */
export class RadiusRelayAuthResolver {
readonly #input: AuthInput | undefined;
readonly #gateway: string;
#modelRuntime: Promise<ModelRuntime> | undefined;
constructor(input?: AuthInput, gateway = getRadiusGatewayUrl()) {
this.#input = input;
this.#gateway = normalizeRadiusGatewayUrl(gateway);
}
get gateway(): string {
return this.#gateway;
}
async resolve(options: {
readonly required: boolean;
readonly signal?: AbortSignal;
}): Promise<RadiusRelayAuth | undefined> {
options.signal?.throwIfAborted();
if (process.env.PI_OFFLINE !== undefined) {
if (options.required) throw new Error("Radius relay connections are unavailable in offline mode");
return undefined;
}
const explicit = await this.#explicitToken(options.signal);
if (explicit !== undefined) return { gateway: this.#gateway, token: explicit };
this.#modelRuntime ??= ModelRuntime.create({ refreshOnCreate: false, allowModelNetwork: false });
const runtime = await this.#modelRuntime;
options.signal?.throwIfAborted();
const token = getAuthCredential(
await runtime.getAuth("radius", { minOAuthValidityMs: 5 * 60_000, signal: options.signal }),
);
if (token !== undefined && token.length > 0) return { gateway: this.#gateway, token };
if (options.required) {
throw new Error("Radius authentication is required; start Pi and run /login radius, then retry");
}
return undefined;
}
async #explicitToken(signal: AbortSignal | undefined): Promise<string | undefined> {
if (this.#input === undefined) return undefined;
const value =
this.#input.type === "token"
? this.#input.token
: await readFile(resolvePath(this.#input.path), { encoding: "utf8", signal });
const token = value.trim();
if (token.length === 0) throw new Error("Radius authentication token must not be empty");
return token;
}
}

View file

@ -1,724 +0,0 @@
import type { ByteTransport, ByteTransportFactory, ByteTransportHandlers, Client } from "@earendil-works/pi-client";
import { DEFAULT_MAX_FRAME_LENGTH, type ServerId } from "@earendil-works/pi-protocol";
import type { Server } from "@earendil-works/pi-server";
import { WebSocket } from "undici";
import type { RadiusRelayAuthResolver } from "./radius-auth.ts";
export const RADIUS_RELAY_HOST_SUBPROTOCOL = "pi-session-relay.host.v1";
export const RADIUS_RELAY_CLIENT_SUBPROTOCOL = "pi-session-relay.client.v1";
const RELAY_DATA_HEADER_BYTES = 18;
const RELAY_DATA_FRAME_VERSION = 1;
const RELAY_DATA_FRAME_TYPE = 1;
const CONNECTION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
const MAX_PENDING_BYTES = DEFAULT_MAX_FRAME_LENGTH * 4;
const DRAIN_THRESHOLD_BYTES = 1024 * 1024;
const HOST_RETRY_INITIAL_MS = 1_000;
const HOST_RETRY_MAX_MS = 30_000;
const MISSING_AUTH_RETRY_MS = 30_000;
const CLIENT_RETRY_INITIAL_MS = 1_000;
const CLIENT_RETRY_MAX_MS = 30_000;
// Undici implements the browser WebSocket API, which permits callers to send
// only code 1000 or application codes from 3000 through 4999. RFC protocol
// codes such as 1002 and 1011 may be received but cannot be passed to close().
const LOCAL_PROTOCOL_ERROR_CLOSE_CODE = 4000;
const LOCAL_TRANSPORT_ERROR_CLOSE_CODE = 4001;
interface RadiusRelayMessageEvent extends Event {
readonly data: unknown;
}
interface RadiusRelayCloseEvent extends Event {
readonly code: number;
readonly reason: string;
}
interface RadiusRelayErrorEvent extends Event {
readonly error?: unknown;
readonly message?: string;
}
export interface RadiusRelayWebSocket {
binaryType: "arraybuffer" | "blob";
readonly bufferedAmount: number;
readonly protocol: string;
readonly readyState: number;
readonly OPEN: number;
send(data: string | ArrayBuffer): void;
close(code?: number, reason?: string): void;
addEventListener(type: "open", listener: (event: Event) => void, options?: { once?: boolean }): void;
addEventListener(
type: "message",
listener: (event: RadiusRelayMessageEvent) => void,
options?: { once?: boolean },
): void;
addEventListener(
type: "close",
listener: (event: RadiusRelayCloseEvent) => void,
options?: { once?: boolean },
): void;
addEventListener(
type: "error",
listener: (event: RadiusRelayErrorEvent) => void,
options?: { once?: boolean },
): void;
removeEventListener(type: "open", listener: (event: Event) => void): void;
removeEventListener(type: "message", listener: (event: RadiusRelayMessageEvent) => void): void;
removeEventListener(type: "close", listener: (event: RadiusRelayCloseEvent) => void): void;
removeEventListener(type: "error", listener: (event: RadiusRelayErrorEvent) => void): void;
}
export type RadiusRelayWebSocketFactory = (options: {
readonly url: string;
readonly protocol: string;
readonly authorization: string;
}) => RadiusRelayWebSocket;
interface RelayByteConnection {
readonly closed: boolean;
send(chunk: Uint8Array): Promise<void>;
close(finalChunk?: Uint8Array): Promise<void>;
}
interface RelayByteConnectionHandler {
onData(chunk: Uint8Array): void;
onClose(): void;
onError(error: Error): void;
}
export type RadiusRelayHostStatus =
| { readonly status: "not_authenticated" }
| { readonly status: "connecting" }
| { readonly status: "connected" }
| { readonly status: "retrying"; readonly error: string };
export interface RadiusRelayHostOptions {
readonly serverId: ServerId;
readonly server: Pick<Server, "accept">;
readonly auth: RadiusRelayAuthResolver;
readonly webSocketFactory?: RadiusRelayWebSocketFactory;
readonly onStatus?: (status: RadiusRelayHostStatus) => void;
}
/** Maintain the experimental server's multiplexed, authenticated Radius host connection. */
export class RadiusRelayHost {
readonly #options: RadiusRelayHostOptions;
readonly #abortController = new AbortController();
readonly #connections = new Map<
string,
{ connection: RelayServerByteConnection; handler: RelayByteConnectionHandler }
>();
#socket: RadiusRelayWebSocket | undefined;
#writer: OrderedWebSocketWriter | undefined;
#loop: Promise<void> | undefined;
#closed = false;
constructor(options: RadiusRelayHostOptions) {
this.#options = options;
}
start(): void {
if (this.#loop !== undefined) return;
this.#loop = this.#run();
}
async close(): Promise<void> {
if (this.#closed) return;
this.#closed = true;
this.#abortController.abort();
this.#writer?.close();
this.#socket?.close(1000, "Pi server stopped");
this.#dropConnections();
await this.#loop;
}
async #run(): Promise<void> {
let retryMs = HOST_RETRY_INITIAL_MS;
while (!this.#closed) {
try {
const auth = await this.#options.auth.resolve({
required: false,
signal: this.#abortController.signal,
});
if (auth === undefined) {
this.#options.onStatus?.({ status: "not_authenticated" });
await delay(MISSING_AUTH_RETRY_MS, this.#abortController.signal, true);
continue;
}
this.#options.onStatus?.({ status: "connecting" });
const socket = await openRadiusRelayWebSocket(
relayWebSocketUrl(auth.gateway, this.#options.serverId),
RADIUS_RELAY_HOST_SUBPROTOCOL,
auth.token,
this.#options.webSocketFactory ?? defaultWebSocketFactory,
this.#abortController.signal,
);
retryMs = HOST_RETRY_INITIAL_MS;
this.#options.onStatus?.({ status: "connected" });
await this.#serve(socket);
if (!this.#closed) throw new Error("Radius relay host disconnected");
} catch (error) {
if (this.#closed || this.#abortController.signal.aborted) break;
this.#options.onStatus?.({ status: "retrying", error: errorMessage(error) });
try {
await delay(retryMs, this.#abortController.signal, true);
} catch {
break;
}
retryMs = Math.min(retryMs * 2, HOST_RETRY_MAX_MS);
}
}
}
#serve(socket: RadiusRelayWebSocket): Promise<void> {
this.#socket = socket;
this.#writer = new OrderedWebSocketWriter(socket);
return new Promise<void>((resolve, reject) => {
let terminal = false;
const cleanup = (): void => {
socket.removeEventListener("message", onMessage);
socket.removeEventListener("close", onClose);
socket.removeEventListener("error", onError);
this.#writer?.close();
if (this.#socket === socket) {
this.#socket = undefined;
this.#writer = undefined;
}
};
const finish = (error?: Error): void => {
if (terminal) return;
terminal = true;
cleanup();
this.#dropConnections(error);
if (error) reject(error);
else resolve();
};
const onMessage = (event: RadiusRelayMessageEvent): void => {
try {
this.#handleHostMessage(event.data);
} catch (error) {
closeWebSocket(socket, LOCAL_PROTOCOL_ERROR_CLOSE_CODE, "Radius relay protocol error");
finish(toError(error));
}
};
const onClose = (event: RadiusRelayCloseEvent): void => {
finish(
event.code === 1000
? undefined
: new Error(`Radius relay host closed (${event.code}${event.reason ? `: ${event.reason}` : ""})`),
);
};
const onError = (event: RadiusRelayErrorEvent): void => finish(webSocketError(event));
socket.addEventListener("message", onMessage);
socket.addEventListener("close", onClose);
socket.addEventListener("error", onError);
});
}
#handleHostMessage(value: unknown): void {
if (typeof value === "string") {
const control = parseHostControlMessage(value);
switch (control.type) {
case "ping":
void this.#sendControl({ version: 1, type: "pong" }).catch(() => {
if (this.#socket !== undefined) {
closeWebSocket(this.#socket, LOCAL_TRANSPORT_ERROR_CLOSE_CODE, "Radius relay send failed");
}
});
return;
case "pong":
return;
case "connection_open":
this.#openConnection(control.connection_id);
return;
case "connection_close":
this.#remoteCloseConnection(control.connection_id);
return;
}
}
if (!(value instanceof ArrayBuffer)) throw new Error("Radius relay data message must be binary");
const frame = parseRelayDataFrame(value);
if (!frame) throw new Error("Invalid Radius relay data frame");
const active = this.#connections.get(frame.connectionId);
if (!active) {
void this.#sendControl({
version: 1,
type: "connection_close",
connection_id: frame.connectionId,
code: 1000,
});
return;
}
active.handler.onData(new Uint8Array(frame.payload));
}
#openConnection(connectionId: string): void {
if (this.#connections.has(connectionId)) throw new Error("Radius relay reused a connection ID");
const connection = new RelayServerByteConnection(
(chunk) => this.#sendData(connectionId, chunk),
(finalChunk) => this.#serverCloseConnection(connectionId, finalChunk),
);
const handler = this.#options.server.accept(connection);
if (connection.closed) {
void this.#sendControl({ version: 1, type: "connection_close", connection_id: connectionId, code: 1012 });
} else {
this.#connections.set(connectionId, { connection, handler });
}
}
#remoteCloseConnection(connectionId: string): void {
const active = this.#connections.get(connectionId);
if (!active) return;
this.#connections.delete(connectionId);
active.connection.markClosed();
active.handler.onClose();
}
async #serverCloseConnection(connectionId: string, finalChunk?: Uint8Array): Promise<void> {
if (!this.#connections.delete(connectionId)) return;
if (finalChunk !== undefined) await this.#requireWriter().send(encodeRelayDataFrame(connectionId, finalChunk));
await this.#sendControl({
version: 1,
type: "connection_close",
connection_id: connectionId,
code: 1000,
});
}
#sendData(connectionId: string, chunk: Uint8Array): Promise<void> {
if (!this.#connections.has(connectionId)) return Promise.reject(new Error("Radius relay connection is closed"));
return this.#requireWriter().send(encodeRelayDataFrame(connectionId, chunk));
}
#sendControl(message: HostOutputControlMessage): Promise<void> {
return this.#requireWriter().send(JSON.stringify(message));
}
#requireWriter(): OrderedWebSocketWriter {
if (!this.#writer) throw new Error("Radius relay host is disconnected");
return this.#writer;
}
#dropConnections(error?: Error): void {
const connections = [...this.#connections.values()];
this.#connections.clear();
for (const active of connections) {
active.connection.markClosed();
if (error) active.handler.onError(error);
else active.handler.onClose();
}
}
}
export function createRadiusClientTransportFactory(options: {
readonly serverId: ServerId;
readonly auth: RadiusRelayAuthResolver;
readonly webSocketFactory?: RadiusRelayWebSocketFactory;
}): ByteTransportFactory {
return async (handlers) => {
const auth = await options.auth.resolve({ required: true });
if (!auth) throw new Error("Radius authentication is required");
const socket = await openRadiusRelayWebSocket(
relayWebSocketUrl(auth.gateway, options.serverId),
RADIUS_RELAY_CLIENT_SUBPROTOCOL,
auth.token,
options.webSocketFactory ?? defaultWebSocketFactory,
);
return new RadiusClientByteTransport(socket, handlers);
};
}
type RadiusReconnectClient = Pick<
Client,
| "attachment"
| "connected"
| "connectionState"
| "disconnect"
| "onAttachmentChange"
| "onConnectionStateChange"
| "reconnect"
>;
/** Reconnect one established Radius client and restore its last selected Session. */
export class RadiusClientReconnect {
readonly #client: RadiusReconnectClient;
readonly #reattach: (sessionId: string) => Promise<void>;
readonly #abortController = new AbortController();
readonly #removeConnectionListener: () => void;
readonly #removeAttachmentListener: () => void;
#desiredSessionId: string | undefined;
#reconnecting: Promise<void> | undefined;
#disposed = false;
constructor(client: RadiusReconnectClient, reattach: (sessionId: string) => Promise<void>) {
this.#client = client;
this.#reattach = reattach;
this.#desiredSessionId = client.attachment?.sessionId;
this.#removeAttachmentListener = client.onAttachmentChange((attachment) => {
if (attachment !== undefined) this.#desiredSessionId = attachment.sessionId;
else if (client.connected) this.#desiredSessionId = undefined;
});
this.#removeConnectionListener = client.onConnectionStateChange(({ state }) => {
if (state === "disconnected" && !this.#disposed) this.#startReconnect();
});
}
async dispose(): Promise<void> {
if (this.#disposed) return;
this.#disposed = true;
this.#removeConnectionListener();
this.#removeAttachmentListener();
this.#abortController.abort();
if (this.#client.connectionState !== "disconnected") {
this.#client.disconnect("Radius reconnect stopped");
}
await this.#reconnecting;
}
#startReconnect(): void {
if (this.#reconnecting !== undefined) return;
this.#reconnecting = this.#runReconnect().finally(() => {
this.#reconnecting = undefined;
});
}
async #runReconnect(): Promise<void> {
let retryMs = CLIENT_RETRY_INITIAL_MS;
while (!this.#disposed && !this.#client.connected) {
try {
await delay(retryMs, this.#abortController.signal, false);
await this.#client.reconnect();
const sessionId = this.#desiredSessionId;
if (sessionId !== undefined) await this.#reattach(sessionId);
return;
} catch (error) {
if (this.#disposed || this.#abortController.signal.aborted) return;
if (this.#client.connected) this.#client.disconnect(errorMessage(error));
retryMs = Math.min(retryMs * 2, CLIENT_RETRY_MAX_MS);
}
}
}
}
class RelayServerByteConnection implements RelayByteConnection {
readonly #sendChunk: (chunk: Uint8Array) => Promise<void>;
readonly #closeConnection: (finalChunk?: Uint8Array) => Promise<void>;
#closed = false;
constructor(
sendChunk: (chunk: Uint8Array) => Promise<void>,
closeConnection: (finalChunk?: Uint8Array) => Promise<void>,
) {
this.#sendChunk = sendChunk;
this.#closeConnection = closeConnection;
}
get closed(): boolean {
return this.#closed;
}
send(chunk: Uint8Array): Promise<void> {
if (this.#closed) return Promise.reject(new Error("Radius relay connection is closed"));
return this.#sendChunk(chunk);
}
async close(finalChunk?: Uint8Array): Promise<void> {
if (this.#closed) return;
this.#closed = true;
await this.#closeConnection(finalChunk);
}
markClosed(): void {
this.#closed = true;
}
}
class RadiusClientByteTransport implements ByteTransport {
readonly #socket: RadiusRelayWebSocket;
readonly #handlers: ByteTransportHandlers;
readonly #writer: OrderedWebSocketWriter;
readonly #onMessage: (event: RadiusRelayMessageEvent) => void;
readonly #onClose: (event: RadiusRelayCloseEvent) => void;
readonly #onError: (event: RadiusRelayErrorEvent) => void;
#closed = false;
constructor(socket: RadiusRelayWebSocket, handlers: ByteTransportHandlers) {
this.#socket = socket;
this.#handlers = handlers;
this.#writer = new OrderedWebSocketWriter(socket);
this.#onMessage = (event) => {
if (this.#closed) return;
if (!(event.data instanceof ArrayBuffer)) {
this.#fail(new Error("Radius relay client received a non-binary message"));
return;
}
this.#handlers.onData(new Uint8Array(event.data));
};
this.#onClose = () => {
if (this.#markClosed()) this.#handlers.onClose();
};
this.#onError = (event) => this.#fail(webSocketError(event));
socket.addEventListener("message", this.#onMessage);
socket.addEventListener("close", this.#onClose);
socket.addEventListener("error", this.#onError);
}
send(chunk: Uint8Array): Promise<void> {
if (!(chunk instanceof Uint8Array))
return Promise.reject(new TypeError("Radius relay chunks must be Uint8Array"));
if (this.#closed) return Promise.reject(new Error("Radius relay client is closed"));
return this.#writer.send(chunk.slice().buffer);
}
close(): void {
if (!this.#markClosed()) return;
this.#socket.close(1000, "Pi client closed");
}
#fail(error: Error): void {
if (!this.#markClosed()) return;
closeWebSocket(this.#socket, LOCAL_TRANSPORT_ERROR_CLOSE_CODE, "Radius relay transport error");
this.#handlers.onError(error);
}
#markClosed(): boolean {
if (this.#closed) return false;
this.#closed = true;
this.#writer.close();
this.#socket.removeEventListener("message", this.#onMessage);
this.#socket.removeEventListener("close", this.#onClose);
this.#socket.removeEventListener("error", this.#onError);
return true;
}
}
class OrderedWebSocketWriter {
readonly #socket: RadiusRelayWebSocket;
#tail: Promise<void> = Promise.resolve();
#pendingBytes = 0;
#closed = false;
constructor(socket: RadiusRelayWebSocket) {
this.#socket = socket;
}
send(value: string | ArrayBuffer): Promise<void> {
if (this.#closed) return Promise.reject(new Error("Radius relay WebSocket is closed"));
const copied = typeof value === "string" ? value : value.slice(0);
const byteLength = typeof copied === "string" ? Buffer.byteLength(copied) : copied.byteLength;
if (this.#pendingBytes + byteLength > MAX_PENDING_BYTES) {
return Promise.reject(new Error("Radius relay exceeded its pending byte limit"));
}
this.#pendingBytes += byteLength;
const operation = this.#tail.then(async () => {
if (this.#closed || this.#socket.readyState !== this.#socket.OPEN) {
throw new Error("Radius relay WebSocket is closed");
}
this.#socket.send(copied);
while (this.#socket.bufferedAmount > DRAIN_THRESHOLD_BYTES) {
if (this.#closed || this.#socket.readyState !== this.#socket.OPEN) {
throw new Error("Radius relay WebSocket closed during write");
}
await delay(5, undefined, false);
}
});
const tracked = operation.finally(() => {
this.#pendingBytes -= byteLength;
});
this.#tail = tracked.catch(() => {});
return tracked;
}
close(): void {
this.#closed = true;
}
}
type HostInputControlMessage =
| { readonly version: 1; readonly type: "ping" | "pong" }
| { readonly version: 1; readonly type: "connection_open"; readonly connection_id: string }
| {
readonly version: 1;
readonly type: "connection_close";
readonly connection_id: string;
readonly code?: number;
};
type HostOutputControlMessage =
| { readonly version: 1; readonly type: "pong" }
| {
readonly version: 1;
readonly type: "connection_close";
readonly connection_id: string;
readonly code?: number;
};
function parseHostControlMessage(value: string): HostInputControlMessage {
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
throw new Error("Invalid Radius relay control message");
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("Invalid Radius relay control message");
}
const message = parsed as { version?: unknown; type?: unknown; connection_id?: unknown; code?: unknown };
if (message.version !== 1) throw new Error("Unsupported Radius relay control version");
if (message.type === "ping" || message.type === "pong") return { version: 1, type: message.type };
if (
(message.type === "connection_open" || message.type === "connection_close") &&
typeof message.connection_id === "string" &&
CONNECTION_ID_PATTERN.test(message.connection_id) &&
(message.code === undefined ||
(typeof message.code === "number" &&
Number.isInteger(message.code) &&
message.code >= 1000 &&
message.code <= 4999))
) {
return message.type === "connection_open"
? { version: 1, type: "connection_open", connection_id: message.connection_id }
: {
version: 1,
type: "connection_close",
connection_id: message.connection_id,
...(message.code === undefined ? {} : { code: message.code }),
};
}
throw new Error("Invalid Radius relay control message");
}
export function encodeRelayDataFrame(connectionId: string, payload: Uint8Array): ArrayBuffer {
if (!CONNECTION_ID_PATTERN.test(connectionId)) throw new TypeError("Invalid Radius relay connection ID");
const frame = new Uint8Array(RELAY_DATA_HEADER_BYTES + payload.byteLength);
frame[0] = RELAY_DATA_FRAME_VERSION;
frame[1] = RELAY_DATA_FRAME_TYPE;
const hex = connectionId.replaceAll("-", "");
for (let index = 0; index < 16; index++) {
frame[index + 2] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16);
}
frame.set(payload, RELAY_DATA_HEADER_BYTES);
return frame.buffer;
}
export function parseRelayDataFrame(
frame: ArrayBuffer,
): { readonly connectionId: string; readonly payload: ArrayBuffer } | undefined {
if (frame.byteLength < RELAY_DATA_HEADER_BYTES) return undefined;
const bytes = new Uint8Array(frame);
if (bytes[0] !== RELAY_DATA_FRAME_VERSION || bytes[1] !== RELAY_DATA_FRAME_TYPE) return undefined;
let hex = "";
for (const byte of bytes.subarray(2, RELAY_DATA_HEADER_BYTES)) hex += byte.toString(16).padStart(2, "0");
const connectionId = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
if (!CONNECTION_ID_PATTERN.test(connectionId)) return undefined;
return { connectionId, payload: frame.slice(RELAY_DATA_HEADER_BYTES) };
}
function relayWebSocketUrl(gateway: string, serverId: ServerId): string {
const url = new URL(`/v1/session-relays/${serverId}/connect`, gateway);
if (url.protocol === "https:") url.protocol = "wss:";
else if (url.protocol === "http:") url.protocol = "ws:";
else throw new Error(`Unsupported Radius gateway protocol: ${url.protocol}`);
return url.toString();
}
function defaultWebSocketFactory(options: {
readonly url: string;
readonly protocol: string;
readonly authorization: string;
}): RadiusRelayWebSocket {
return new WebSocket(options.url, {
protocols: [options.protocol],
headers: { authorization: options.authorization },
}) as unknown as RadiusRelayWebSocket;
}
function openRadiusRelayWebSocket(
url: string,
protocol: string,
token: string,
factory: RadiusRelayWebSocketFactory,
signal?: AbortSignal,
): Promise<RadiusRelayWebSocket> {
signal?.throwIfAborted();
return new Promise((resolve, reject) => {
const socket = factory({ url, protocol, authorization: `Bearer ${token}` });
socket.binaryType = "arraybuffer";
let settled = false;
const cleanup = (): void => {
socket.removeEventListener("open", onOpen);
socket.removeEventListener("close", onClose);
socket.removeEventListener("error", onError);
signal?.removeEventListener("abort", onAbort);
};
const fail = (error: Error): void => {
if (settled) return;
settled = true;
cleanup();
closeWebSocket(socket, 1000, "Radius relay connection failed");
reject(error);
};
const onOpen = (): void => {
if (settled) return;
if (socket.protocol !== protocol) {
fail(new Error(`Radius relay selected unexpected WebSocket protocol ${JSON.stringify(socket.protocol)}`));
return;
}
settled = true;
cleanup();
resolve(socket);
};
const onClose = (event: RadiusRelayCloseEvent): void =>
fail(new Error(`Radius relay closed before connecting (${event.code})`));
const onError = (event: RadiusRelayErrorEvent): void => fail(webSocketError(event));
const onAbort = (): void => fail(new Error("Radius relay connection cancelled"));
socket.addEventListener("open", onOpen, { once: true });
socket.addEventListener("close", onClose, { once: true });
socket.addEventListener("error", onError, { once: true });
signal?.addEventListener("abort", onAbort, { once: true });
});
}
function closeWebSocket(socket: RadiusRelayWebSocket, code: number, reason: string): void {
try {
socket.close(code, reason);
} catch {
// A WebSocket can reject close() while its opening handshake is still pending.
}
}
function webSocketError(event: RadiusRelayErrorEvent): Error {
if (event.error instanceof Error && event.error.message.trim().length > 0) return event.error;
return new Error(event.message?.trim() || "Radius WebSocket connection failed");
}
function delay(milliseconds: number, signal: AbortSignal | undefined, unref: boolean): Promise<void> {
signal?.throwIfAborted();
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
cleanup();
resolve();
}, milliseconds);
if (unref) timer.unref();
const onAbort = (): void => {
clearTimeout(timer);
cleanup();
try {
signal?.throwIfAborted();
} catch (error) {
reject(error);
}
};
const cleanup = (): void => signal?.removeEventListener("abort", onAbort);
signal?.addEventListener("abort", onAbort, { once: true });
});
}
function toError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
function errorMessage(error: unknown): string {
return toError(error).message;
}

View file

@ -1,770 +0,0 @@
import { randomUUID } from "node:crypto";
import { chmod, lstat, mkdir, readFile, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { isAbsolute, join } from "node:path";
import type { Context } from "@earendil-works/chord";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import type { FacetBundleArtifact } from "@earendil-works/chord/node";
import { Client, ServerError as ClientServerError, DisconnectedError } from "@earendil-works/pi-client";
import { createUnixTransportFactory, type UnixServerRoute } from "@earendil-works/pi-client/unix";
import { isServerId, type ServerId } from "@earendil-works/pi-protocol";
import {
ServerError as RoutedServerError,
type Server,
type ServerHost,
SessionNotFoundError,
} from "@earendil-works/pi-server";
import { createUnixServer, getUnixSocketPath } from "@earendil-works/pi-server/unix";
import lockfile from "proper-lockfile";
import type { AuthInput } from "../cli/experimental/command-options.ts";
import { getAgentDir } from "../config.ts";
import { resolvePath } from "../utils/paths.ts";
import { CoordinatorConnection, type CoordinatorStartupLease, ensureCoordinator } from "./coordinator.ts";
import { createPresentationFacetData } from "./plugins/bundled.ts";
import {
createServerPluginPackage,
normalizePluginPackagePaths,
readSessionPluginPackageProfile,
removeSessionPluginPackageProfile,
restoreServerPluginPackageProfile,
writeSessionPluginPackageProfile,
} from "./plugins/package.ts";
import {
consumeInternalProcessRole,
isDirectInternalProcessEntry,
spawnInternalProcess,
terminateInternalProcess,
} from "./process.ts";
import { RadiusRelayAuthResolver } from "./radius-auth.ts";
import { RadiusRelayHost, type RadiusRelayHostStatus } from "./radius-relay.ts";
import { createExperimentalServerServices } from "./services/server.ts";
import type { SessionCreateOptions, SessionSummary } from "./services/sessions.ts";
import {
createSession as createCatalogSession,
deleteSession,
listSessions as listCatalogSessions,
readSession,
type SessionCatalogMetadata,
} from "./session-catalog.ts";
import { SessionPluginSelectionConflictError, SessionWorkerManager } from "./session-worker-manager.ts";
export const ENV_SERVER_DIR = "PI_SERVER_DIR";
export const ENV_SERVER_ID = "PI_SERVER_ID";
export function resolveServerDirectory(directory?: string): string {
return resolvePath(directory ?? process.env[ENV_SERVER_DIR] ?? join(homedir(), ".pi", "server"));
}
export async function ensurePrivateServerDirectory(directory: string): Promise<void> {
if (typeof process.getuid !== "function") throw new Error("Unix socket directory requires a POSIX user ID");
await mkdir(directory, { recursive: true, mode: 0o700 });
const stats = await lstat(directory);
if (!stats.isDirectory()) throw new Error(`Unix socket directory is not a directory: ${directory}`);
if (stats.uid !== process.getuid()) {
throw new Error(`Unix socket directory is not owned by the current user: ${directory}`);
}
await chmod(directory, 0o700);
}
export function resolveSessionDirectory(sessionDir?: string): string {
return resolvePath(sessionDir ?? join(getAgentDir(), "experimental", "sessions"));
}
const LOCK_STALE_MS = 30_000;
const LOCK_RETRY_MS = 25;
const LOCK_WAIT_MS = 30_000;
const DEFAULT_SERVER_ID_FILE = "default-server-id";
export interface ServerProfile {
readonly serverId: ServerId;
release(): Promise<void>;
}
/** Lock one logical server ID in a shared experimental server directory. */
export async function acquireServerProfile(directory: string, requestedServerId?: string): Promise<ServerProfile> {
await mkdir(directory, { recursive: true, mode: 0o700 });
let serverId: ServerId;
if (requestedServerId !== undefined) {
if (!isServerId(requestedServerId)) throw new Error(`Invalid experimental server ID: ${requestedServerId}`);
serverId = requestedServerId;
} else {
const path = join(directory, DEFAULT_SERVER_ID_FILE);
try {
const value = (await readFile(path, "utf8")).trim();
if (!isServerId(value)) throw new Error(`Invalid default experimental server identity in ${path}`);
serverId = value;
} catch (error) {
const code = error instanceof Error && "code" in error ? error.code : undefined;
if (code !== "ENOENT") throw error;
const candidate = randomUUID();
try {
await writeFile(path, candidate, { encoding: "utf8", mode: 0o600, flag: "wx" });
serverId = candidate;
} catch (writeError) {
const writeCode = writeError instanceof Error && "code" in writeError ? writeError.code : undefined;
if (writeCode !== "EEXIST") throw writeError;
const value = (await readFile(path, "utf8")).trim();
if (!isServerId(value)) throw new Error(`Invalid default experimental server identity in ${path}`);
serverId = value;
}
}
}
const release = await lockfile.lock(join(directory, `launcher-${serverId}`), {
realpath: false,
stale: LOCK_STALE_MS,
update: LOCK_STALE_MS / 3,
retries: {
retries: Math.ceil(LOCK_WAIT_MS / LOCK_RETRY_MS),
factor: 1,
minTimeout: LOCK_RETRY_MS,
maxTimeout: LOCK_RETRY_MS,
maxRetryTime: LOCK_WAIT_MS,
},
});
return { serverId, release };
}
const ACTIVATION_TIMEOUT_MS = 10_000;
const ACTIVATION_RETRY_MS = 10;
export interface ActivatedServer {
readonly client: Client;
readonly route: UnixServerRoute;
}
export interface ActivateServerOptions {
readonly directory: string;
readonly requestedServerId?: ServerId | string;
readonly sessionDir: string;
readonly provider?: string;
readonly model?: string;
}
/** Ensure the selected logical server is reachable, launching the current Pi installation if needed. */
export async function activateServer(options: ActivateServerOptions): Promise<ActivatedServer> {
if (options.provider !== undefined && options.model === undefined) {
throw new Error("Server model provider requires a model");
}
await ensurePrivateServerDirectory(options.directory);
const profile = await acquireServerProfile(options.directory, options.requestedServerId);
const serverId = profile.serverId;
await profile.release();
const route = { serverId, path: getUnixSocketPath(serverId, options.directory) };
const release = await acquireServerActivation(options.directory, serverId);
try {
const existing = await connect(route);
if (existing) {
// Another activator won the race, so startup-only selections can no longer be applied.
if (options.model !== undefined) {
await existing.dispose();
throw new Error("Model selection is only valid when automatically activating a new server");
}
return { client: existing, route };
}
const modelArgs =
options.model === undefined
? []
: [
JSON.stringify({
...(options.provider === undefined ? {} : { provider: options.provider }),
model: options.model,
}),
];
const child = spawnInternalProcess("server", [options.directory, serverId, options.sessionDir, ...modelArgs]);
let spawnError: Error | undefined;
child.once("error", (error) => {
spawnError = error;
});
try {
const deadline = Date.now() + ACTIVATION_TIMEOUT_MS;
while (true) {
const client = await connect(route);
if (client) return { client, route };
if (spawnError) throw new Error("Failed to automatically activate server", { cause: spawnError });
if (child.exitCode !== null || child.signalCode !== null) {
throw new Error("Automatically activated server exited during startup");
}
if (Date.now() >= deadline) throw new Error("Timed out waiting for automatically activated server");
await new Promise<void>((resolve) => setTimeout(resolve, ACTIVATION_RETRY_MS));
}
} catch (error) {
await terminateInternalProcess(child);
throw error;
}
} finally {
await release();
}
}
export function acquireServerActivation(directory: string, serverId: ServerId): Promise<() => Promise<void>> {
return lockfile.lock(join(directory, `activation-${serverId}`), {
realpath: false,
stale: ACTIVATION_TIMEOUT_MS * 2,
update: ACTIVATION_TIMEOUT_MS,
retries: {
retries: Math.ceil(ACTIVATION_TIMEOUT_MS / 25),
factor: 1,
minTimeout: 25,
maxTimeout: 25,
maxRetryTime: ACTIVATION_TIMEOUT_MS,
},
});
}
async function connect(route: UnixServerRoute): Promise<Client | undefined> {
const client = new Client({
serverId: route.serverId,
transportFactory: createUnixTransportFactory({ path: route.path }),
});
try {
await client.connect();
return client;
} catch (error) {
await client.dispose();
if (error instanceof DisconnectedError || (error instanceof ClientServerError && error.code === "version")) {
return undefined;
}
let current = error;
const seen = new Set<unknown>();
while (current instanceof Error && !seen.has(current)) {
seen.add(current);
if (
"code" in current &&
["ENOENT", "ECONNREFUSED", "ECONNRESET", "EPIPE", "ETIMEDOUT"].includes(String(current.code))
) {
return undefined;
}
current = current.cause;
}
throw error;
}
}
const AUTO_SERVER_STARTUP_GRACE_MS = 10_000;
const AUTO_SERVER_IDLE_GRACE_MS = 1_000;
/** Reconcile operator, startup, client, and worker holds for one server generation. */
export class ServerLifetime {
readonly #keepAlive: boolean;
#connectionCount = 0;
#workerCount = 0;
#startupHeld: boolean;
#startupTimer: NodeJS.Timeout | undefined;
#retirementTimer: NodeJS.Timeout | undefined;
#retire: (() => void) | undefined;
#stopped = false;
constructor(keepAlive: boolean) {
this.#keepAlive = keepAlive;
this.#startupHeld = !keepAlive;
}
start(retire: () => void): void {
this.#retire = retire;
if (this.#startupHeld) {
this.#startupTimer = setTimeout(() => {
this.#startupTimer = undefined;
this.#startupHeld = false;
this.#reconcile();
}, AUTO_SERVER_STARTUP_GRACE_MS);
this.#startupTimer.unref();
}
this.#reconcile();
}
setConnectionCount(count: number): void {
this.#connectionCount = count;
if (count > 0 && this.#startupHeld) {
this.#startupHeld = false;
if (this.#startupTimer) clearTimeout(this.#startupTimer);
this.#startupTimer = undefined;
}
this.#reconcile();
}
setWorkerCount(count: number): void {
this.#workerCount = count;
this.#reconcile();
}
stop(): void {
this.#stopped = true;
if (this.#startupTimer) clearTimeout(this.#startupTimer);
if (this.#retirementTimer) clearTimeout(this.#retirementTimer);
this.#startupTimer = undefined;
this.#retirementTimer = undefined;
}
#reconcile(): void {
if (
this.#stopped ||
this.#keepAlive ||
this.#startupHeld ||
this.#connectionCount !== 0 ||
this.#workerCount !== 0
) {
if (this.#retirementTimer) clearTimeout(this.#retirementTimer);
this.#retirementTimer = undefined;
return;
}
const retire = this.#retire;
if (this.#retirementTimer || !retire) return;
this.#retirementTimer = setTimeout(() => {
this.#retirementTimer = undefined;
if (!this.#stopped && !this.#startupHeld && this.#connectionCount === 0 && this.#workerCount === 0) {
retire();
}
}, AUTO_SERVER_IDLE_GRACE_MS);
this.#retirementTimer.unref();
}
}
export interface RunningServer {
readonly serverId: string;
readonly sessionDir: string;
readonly socketPath: string;
readonly server: Server;
readonly workerPids: ReadonlyMap<string, number>;
readonly closed: Promise<void>;
close(): Promise<void>;
}
export interface StartServerOptions {
/** Server profile and socket directory. Defaults to PI_SERVER_DIR or ~/.pi/server. */
readonly directory?: string;
/** Logical service ID. Defaults to PI_SERVER_ID or the directory's default-server-id. */
readonly serverId?: ServerId;
/** Durable session directory. Defaults to the experimental directory under the configured agent directory. */
readonly sessionDir?: string;
/** Optional provider for an explicitly selected Session worker model. */
readonly provider?: string;
/** Optional model override for newly started Session workers. */
readonly model?: string;
/** Hold the server open without client or Session demand. Defaults to true for foreground servers. */
readonly keepAlive?: boolean;
/** Optional explicit Radius credential. Stored Radius auth is used when omitted. */
readonly relayAuth?: AuthInput;
/** Explicit plugin packages. Undefined restores the logical server profile; an empty list clears it. */
readonly pluginPackages?: readonly string[];
readonly onRelayStatus?: (status: RadiusRelayHostStatus) => void;
}
interface ResolvedSessionPlugins {
readonly packagePaths: readonly string[];
readonly manifestPaths: readonly string[];
readonly presentationArtifacts: readonly FacetBundleArtifact[];
}
interface StartServerBackendOptions {
readonly path: string;
readonly serverId: ServerId;
readonly sessionDir?: string;
resolveSessionPlugins(
metadata: SessionCatalogMetadata,
packagePaths: readonly string[] | undefined,
context: Context,
): Promise<ResolvedSessionPlugins>;
removeSessionPlugins(metadata: SessionCatalogMetadata): Promise<void>;
reloadPresentationFacetBundles(packagePaths: readonly string[]): Promise<readonly FacetBundleArtifact[]>;
}
interface RunningServerBackend extends RunningServer {
refreshSessions(): Promise<void>;
}
async function startServerBackend(
options: StartServerBackendOptions,
workers: SessionWorkerManager,
onConnectionCountChanged?: (count: number) => void,
): Promise<RunningServerBackend> {
const serverId = options.serverId;
const sessionDir = resolveSessionDirectory(options.sessionDir);
const listSessions = async (): Promise<SessionCatalogMetadata[]> => {
const sessions = new Map((await listCatalogSessions(sessionDir)).map((metadata) => [metadata.path, metadata]));
for (const metadata of workers.trackedSessions) sessions.set(metadata.path, metadata);
return [...sessions.values()];
};
const resolveSession = async (sessionId: string, _context: Context): Promise<SessionCatalogMetadata> => {
const metadata =
workers.trackedSessions.find((candidate) => candidate.id === sessionId) ??
(await readSession(sessionDir, sessionId));
if (metadata === undefined) throw new SessionNotFoundError(`Unknown session: ${sessionId}`);
return metadata;
};
const createSession = (createOptions: SessionCreateOptions): Promise<SessionCatalogMetadata> =>
createCatalogSession(sessionDir, { ...createOptions, cwd: process.cwd() });
const summarize = (metadata: SessionCatalogMetadata): SessionSummary => ({
serverId,
sessionId: metadata.id,
createdAt: metadata.createdAt,
});
const serverServices = await createExperimentalServerServices({
list: async () =>
(await listSessions())
.map(summarize)
.sort((left, right) => left.sessionId.localeCompare(right.sessionId) || left.createdAt - right.createdAt),
create: async (createOptions) => summarize(await createSession(createOptions)),
remove: async (sessionId, context) => {
const metadata = await resolveSession(sessionId, context);
await workers.closeSession(metadata, context);
await deleteSession(metadata);
await options.removeSessionPlugins(metadata);
},
async prepareSessionPlugins(sessionId, packagePaths, context) {
const metadata = await resolveSession(sessionId, context);
let selected: ResolvedSessionPlugins;
try {
selected = await options.resolveSessionPlugins(metadata, packagePaths, context);
workers.assertSessionPluginManifestPaths(metadata, selected.manifestPaths);
} catch (error) {
if (error instanceof SessionPluginSelectionConflictError) {
throw new RoutedServerError("service_invalid_value", error.message);
}
throw error;
}
return {
packagePaths: selected.packagePaths,
presentationPlugins: createPresentationFacetData(selected.presentationArtifacts),
};
},
async reloadPresentationPlugins(packagePaths) {
return createPresentationFacetData(await options.reloadPresentationFacetBundles(packagePaths));
},
});
const host: ServerHost<SessionCatalogMetadata> = {
serverServices: serverServices.host,
resolveSession,
openSession: async (metadata, context) => {
const selected = await options.resolveSessionPlugins(metadata, undefined, context);
return workers.openSession(metadata, context, selected.manifestPaths);
},
};
const socketPath = options.path;
const closeCatalog = (): Promise<void> => serverServices.dispose();
const server = createUnixServer(host, {
serverId,
path: socketPath,
mode: 0o600,
onConnectionCountChanged,
});
try {
await server.start();
} catch (error) {
const cleanup = await Promise.allSettled([server.close(), closeCatalog()]);
const cleanupErrors = cleanup.flatMap((result) => (result.status === "rejected" ? [result.reason] : []));
if (cleanupErrors.length > 0) {
throw new AggregateError([error, ...cleanupErrors], "Experimental server startup and cleanup failed");
}
throw error;
}
let closePromise: Promise<void> | undefined;
const closed = server.closed.then(
() => closeCatalog(),
async (serverError: unknown) => {
try {
await closeCatalog();
} catch (repoError) {
throw new AggregateError([serverError, repoError], "Server and repository shutdown failed");
}
throw serverError;
},
);
return {
serverId,
sessionDir,
socketPath,
server,
workerPids: workers.workerPids,
closed,
refreshSessions: () => serverServices.refresh(BACKGROUND_CONTEXT),
close() {
closePromise ??= server.close().then(
() => closed,
() => closed,
);
return closePromise;
},
};
}
/** Start a replaceable experimental server behind the stable coordinator endpoint. */
export async function startServer(options: StartServerOptions = {}): Promise<RunningServer> {
if (options.provider !== undefined && options.model === undefined) {
throw new Error("Server model provider requires a model");
}
const workerModel =
options.model === undefined
? undefined
: { ...(options.provider === undefined ? {} : { provider: options.provider }), model: options.model };
const directory = resolveServerDirectory(options.directory);
const { serverId, release } = await acquireServerProfile(directory, options.serverId ?? process.env[ENV_SERVER_ID]);
const lifetime = new ServerLifetime(options.keepAlive ?? true);
let backend: RunningServerBackend | undefined;
let coordinator: CoordinatorConnection | undefined;
let startupLease: CoordinatorStartupLease | undefined;
let workers: SessionWorkerManager | undefined;
let relay: RadiusRelayHost | undefined;
let released = false;
try {
await ensurePrivateServerDirectory(directory);
const pluginPackagePaths = await restoreServerPluginPackageProfile(directory, serverId, options.pluginPackages);
const pluginPackages = new Map<string, ReturnType<typeof createServerPluginPackage>>();
const getPluginPackage = (packagePath: string): ReturnType<typeof createServerPluginPackage> => {
let plugin = pluginPackages.get(packagePath);
if (plugin === undefined) {
plugin = createServerPluginPackage(directory, serverId, packagePath);
pluginPackages.set(packagePath, plugin);
}
return plugin;
};
const buildPluginSelection = async (packagePaths: readonly string[]): Promise<ResolvedSessionPlugins> => {
const normalizedPackagePaths = normalizePluginPackagePaths(packagePaths);
const plugins = normalizedPackagePaths.map(getPluginPackage);
const built = (await Promise.all(plugins.map((plugin) => plugin.build()))).flat();
return {
packagePaths: normalizedPackagePaths,
manifestPaths: plugins.map((plugin) => plugin.manifestPath),
presentationArtifacts: built,
};
};
let defaultPluginSelection = await buildPluginSelection(pluginPackagePaths);
const sessionPluginSelections = new Map<string, ResolvedSessionPlugins>();
const resolveSessionPlugins = async (
metadata: SessionCatalogMetadata,
requestedPackagePaths: readonly string[] | undefined,
_context: Context,
): Promise<ResolvedSessionPlugins> => {
if (requestedPackagePaths !== undefined) {
const normalizedPackagePaths = normalizePluginPackagePaths(requestedPackagePaths);
const current = sessionPluginSelections.get(metadata.path);
if (current !== undefined && sameStrings(current.packagePaths, normalizedPackagePaths)) return current;
const requestedManifestPaths = normalizedPackagePaths.map(
(packagePath) => getPluginPackage(packagePath).manifestPath,
);
workers?.assertSessionPluginManifestPaths(metadata, requestedManifestPaths);
const candidate = await buildPluginSelection(normalizedPackagePaths);
workers?.assertSessionPluginManifestPaths(metadata, candidate.manifestPaths);
await writeSessionPluginPackageProfile(directory, serverId, metadata.path, candidate.packagePaths);
sessionPluginSelections.set(metadata.path, candidate);
return candidate;
}
const cached = sessionPluginSelections.get(metadata.path);
if (cached !== undefined) return cached;
const storedPackagePaths = await readSessionPluginPackageProfile(directory, serverId, metadata.path);
const selected =
storedPackagePaths === undefined ? defaultPluginSelection : await buildPluginSelection(storedPackagePaths);
if (storedPackagePaths === undefined) {
await writeSessionPluginPackageProfile(directory, serverId, metadata.path, selected.packagePaths);
}
sessionPluginSelections.set(metadata.path, selected);
return selected;
};
const removeSessionPlugins = async (metadata: SessionCatalogMetadata): Promise<void> => {
sessionPluginSelections.delete(metadata.path);
await removeSessionPluginPackageProfile(directory, serverId, metadata.path);
};
const reloadPresentationFacetBundles = async (
packagePaths: readonly string[],
): Promise<readonly FacetBundleArtifact[]> => {
const reloaded = await buildPluginSelection(packagePaths);
if (sameStrings(defaultPluginSelection.packagePaths, reloaded.packagePaths)) {
defaultPluginSelection = reloaded;
}
for (const [sessionPath, selected] of sessionPluginSelections) {
if (sameStrings(selected.packagePaths, reloaded.packagePaths)) {
sessionPluginSelections.set(sessionPath, reloaded);
}
}
return reloaded.presentationArtifacts;
};
const socketPath = getUnixSocketPath(serverId, directory);
const controlPath = join(directory, `control-${serverId}.sock`);
const serverNonce = randomUUID().replaceAll("-", "").slice(0, 12);
const serverPath = join(directory, `server-${serverId}-${serverNonce}.sock`);
startupLease = await ensureCoordinator(socketPath, controlPath);
coordinator = new CoordinatorConnection({ controlPath, endpoint: serverPath });
const sessionDir = resolveSessionDirectory(options.sessionDir);
workers = new SessionWorkerManager(coordinator, sessionDir, workerModel, (count) =>
lifetime.setWorkerCount(count),
);
backend = await startServerBackend(
{
path: serverPath,
serverId,
sessionDir: options.sessionDir,
resolveSessionPlugins,
removeSessionPlugins,
reloadPresentationFacetBundles,
},
workers,
(count) => lifetime.setConnectionCount(count),
);
await coordinator.connect();
startupLease.close();
startupLease = undefined;
await workers.discover(coordinator.peerIds);
await backend.refreshSessions();
relay = new RadiusRelayHost({
serverId,
server: backend.server,
auth: new RadiusRelayAuthResolver(options.relayAuth),
onStatus: options.onRelayStatus,
});
relay.start();
const activeBackend = backend;
const activeCoordinator = coordinator;
const activeWorkers = workers;
const activeRelay = relay;
void activeCoordinator.replaced
.then(async () => {
lifetime.stop();
activeWorkers.detach();
await activeRelay.close();
await activeBackend.close();
})
.finally(() => activeCoordinator.close())
.catch(() => {});
let closePromise: Promise<void> | undefined;
const runtime: RunningServer = {
serverId,
sessionDir: activeBackend.sessionDir,
socketPath,
server: activeBackend.server,
workerPids: activeWorkers.workerPids,
closed: activeBackend.closed.finally(() => activeRelay.close()),
close() {
lifetime.stop();
closePromise ??= (async () => {
try {
await activeRelay.close();
await activeBackend.close();
} finally {
try {
if (activeCoordinator.wasReplaced) activeWorkers.detach();
else await activeWorkers.shutdown();
} finally {
activeCoordinator.close();
}
}
})();
return closePromise;
},
};
lifetime.start(() => {
void runtime.close().catch(() => {});
});
released = true;
await release();
return runtime;
} catch (error) {
lifetime.stop();
startupLease?.close();
if (coordinator?.wasReplaced) workers?.detach();
const cleanup = await Promise.allSettled([
relay?.close(),
backend?.close(),
coordinator?.wasReplaced ? undefined : workers?.shutdown(),
Promise.resolve(coordinator?.close()),
released ? undefined : release(),
]);
const cleanupErrors = cleanup.flatMap((result) => (result.status === "rejected" ? [result.reason] : []));
if (cleanupErrors.length > 0) {
throw new AggregateError([error, ...cleanupErrors], "Server runtime startup and cleanup failed");
}
throw error;
}
}
/** Start an operator-held server while serializing against automatic cold activation. */
export async function startForegroundServer(
options: Omit<StartServerOptions, "keepAlive"> = {},
): Promise<RunningServer> {
const directory = resolveServerDirectory(options.directory);
await ensurePrivateServerDirectory(directory);
const profile = await acquireServerProfile(directory, options.serverId ?? process.env[ENV_SERVER_ID]);
const serverId = profile.serverId;
await profile.release();
const release = await acquireServerActivation(directory, serverId);
try {
return await startServer({
...options,
directory,
serverId,
keepAlive: true,
});
} finally {
await release();
}
}
function parseServerModelOptions(value: string | undefined): { provider?: string; model: string } | undefined {
if (value === undefined) return undefined;
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch (error) {
throw new Error("Internal server received invalid model options", { cause: error });
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
throw new Error("Internal server received invalid model options");
}
const keys = Object.keys(parsed);
const model = "model" in parsed ? parsed.model : undefined;
const provider = "provider" in parsed ? parsed.provider : undefined;
if (
keys.some((key) => key !== "provider" && key !== "model") ||
typeof model !== "string" ||
model.length === 0 ||
(provider !== undefined && (typeof provider !== "string" || provider.length === 0))
) {
throw new Error("Internal server received invalid model options");
}
return provider === undefined ? { model } : { provider, model };
}
/** Run an automatically activated server until its client and Session demand disappears. */
export async function runServerProcess(args: readonly string[]): Promise<void> {
const [directory, serverId, sessionDir, serializedModel] = args;
if (args.length > 4) throw new Error("Internal server received unexpected arguments");
if (!directory || !isAbsolute(directory)) throw new Error("Internal server requires an absolute server directory");
if (!isServerId(serverId)) throw new Error("Internal server requires a canonical server ID");
if (!sessionDir || !isAbsolute(sessionDir))
throw new Error("Internal server requires an absolute Session directory");
const workerModel = parseServerModelOptions(serializedModel);
const runtime = await startServer({
directory,
serverId,
sessionDir,
keepAlive: false,
...workerModel,
});
const close = (): void => {
void runtime.close().catch(() => {});
};
process.once("SIGINT", close);
process.once("SIGTERM", close);
try {
await runtime.closed;
} finally {
process.off("SIGINT", close);
process.off("SIGTERM", close);
await runtime.close();
}
}
function sameStrings(left: readonly string[], right: readonly string[]): boolean {
return left.length === right.length && left.every((value, index) => value === right[index]);
}
if (isDirectInternalProcessEntry(import.meta.url)) {
const role = consumeInternalProcessRole();
if (role !== "server") throw new Error("Server entrypoint requires an internal server invocation");
void runServerProcess(process.argv.slice(2)).catch((error: unknown) => {
console.error(error);
process.exit(1);
});
}

View file

@ -1,45 +0,0 @@
# Experimental client/server service slices
Facet setup generates each host's RPC service catalogue from its provided non-local tokens. Remote service sources obtain those catalogues and bind only services required by consuming facets; there is no handwritten built-in service inventory. With no selected Session, its deferred source admits unresolved requirements as unavailable and keeps their handles disconnected. Attachment validates them against the worker's generated catalogue, which is cached for later detached generations. Keyed services hydrate as an empty directory until their owning feature spawns an instance.
Run the source-only server and client from the repository root:
```bash
PI_EXPERIMENTAL=1 ./pi-test.sh server
PI_EXPERIMENTAL=1 ./pi-test.sh client
```
`PI_SERVER_DIR` overrides the server profile and socket directory, which defaults to `~/.pi/server`. `PI_SERVER_ID` selects the logical server ID when `--server-id` is omitted. The server, client, and experimental package subpaths are excluded from npm packages and standalone binaries.
| Scope | Service | Current slice | Continuation point |
|---|---|---|---|
| server | `SessionDirectory` | replicated state implemented | add authenticated per-client projection when identity lands |
| server | `SessionManagement` | create, remove, attach, detach implemented | add authenticated workspace authorization |
| server | `PresentationPlugins` | prepares the selected Session branch's matching TUI artifacts and reloads that branch | add authenticated plugin policy |
| session | `SessionPlugins` | reloads the configured Session facet generation | add coordinated multi-worker reload reporting |
| session | `Models` | state, default-persisting selection, thinking, refresh implemented | move provider/auth composition behind plugin facets |
| session | `AgentController` | presentation-safe facade over the root durable conversation for prompting, steering, follow-ups, queue cancellation, abort, compaction, and waiting for a prompt's answer | add new conversation operations only when a presentation requires them |
| session | `Transcript` | the root conversation's durable `ConversationView` as replicated state | add projections only when another presentation needs them |
| presentation | `SlashCommands` | process-local contribution registry with model, thinking, compact, reload, and example hello commands | add more presentation hookpoints only as concrete plugin slices require them |
| presentation | `PresentationUI` | process-local selection and status capabilities | add narrowly scoped UI capabilities only when commands require them |
`ServerServiceSource.connection` and `SessionServiceSource.attachment` are implemented local control states. Session directory, creation, and address DTOs are owned by these coding-agent service contracts rather than `pi-protocol`; the transport treats their payloads as opaque service data.
With `PI_EXPERIMENTAL=1`, an interactive `pi client` creates and attaches a Session before opening the service-only chat TUI. `pi client -c` and `pi client -r` attach the newest existing Session instead, preserving its durable model and thinking configuration. Model selection is available on demand through `/model`; it is never a startup screen. The presentation always uses the stable coding agent's alternate-screen renderer and shared transcript/dock viewport. It loads configured theme resources and uses the stable terminal light/dark detection and appearance-change notifications. Its replicated state feeds the stable editor, message, tool, status, theme, and tool-renderer components. The presentation drives the worker-owned root conversation through `AgentController` and renders the `Transcript` service's complete replicated value while controller calls are pending. `Transcript` serves `Conversation.viewState()` directly: the durable Harness publishes exact operations per commit, so the worker keeps no reducer of its own. Chord flushes compact operations once per publication, while each client/state pairing encodes them with an independent path dictionary. Chord reconstructs presentation replicas and owns hydration, sequencing, and gap detection.
The server keeps each Session in its own directory under the session directory: `meta.json` holds the ID, creation time, and working directory, and `session.sqlite` is the `@earendil-works/pi-durable` storage. The server lists and creates Sessions from `meta.json` only. The Session worker locks the directory, opens the storage, and owns it until it retires; the worker stays alive while the Harness task graph has live tasks.
A foreground server uses repeatable `-e` options to establish its default Session and TUI facets. A local client may instead select packages for the Session it creates or resumes; that branch selection is persisted with the Session and does not alter other workers or the server default. Before attaching, the server asks Chord to build conventional `src/session.ts` and `src/tui.ts` entries into separate `plugin-builds/` directories, passes the generated manifest paths to that Session worker, and returns the matching TUI artifacts. The Session worker loads built-in facets and separately owned plugin generations, then creates one active `FacetHost`. `/reload` atomically rebuilds the packages, loads fresh plugin candidates, cuts them over through `FacetHost.reload()`, and disposes the retired generations. Host-created implementation dependencies such as the durable `Harness`, its root `Conversation`, `ModelRuntime`, and `SettingsManager` are passed directly to built-in facet factories; they are not exposed as services. The TUI loads configured presentation facets and adds one private bridge facet that consumes the connected server and selected-Session services for `ExperimentalClientTui`. Its local presentation services are `SlashCommands`, which owns command contributions, and `PresentationUI`, which exposes selection and status rendering without exposing the raw TUI. Command callbacks receive Chord `Context` directly. Facets consume `AgentController` explicitly for prompting, steering, or queueing and return structured controller results to the command dispatcher.
A host may reload selected facets when their declared service shape is unchanged; retained consumers keep the same service facades while replacement implementations and singleton snapshots are installed behind them. Synchronous setup-time `env.provide()`, `env.provideMany()`, `env.use()`, and `env.observe()` calls produce the internal dependency graph; setup does not repeat a declarative dependency list, and service handles remain disconnected until the complete graph validates. Providers activate before consumers, observations connect with their consuming facet, and facet replacement or host shutdown disposes affected lifecycles in reverse dependency order. Server and Session service tokens are non-local and automatically published by their providing host. Presentation-only hookpoints such as `SlashCommands` are explicitly local and never enter an RPC catalogue.
A facet always calls unqualified `env.use()` or `env.observe()`. The host resolves each token across facet-provided and connected services. The example plugin package contributes `/hello` through its TUI facet. `examples/plugins/pi-example-plugin/` is an actual `@earendil-works/pi-example-plugin` package: repeatable `-e` options select plugin packages as server defaults or for one Session branch, Chord discovers and builds their conventional facets, and the attached presentation receives matching TUI artifacts through `PresentationPlugins`. Other host facets from the same plugin remain separate bundle entries rather than one aggregate plugin object. Transport bindings remain internal machinery rather than part of the facet environment. `ExperimentalClientTui` currently owns terminal rendering, state subscriptions, navigation, and action dispatch directly. The server provider remains directly assembled until its complete host environment exists. The canvas, diff-review, and task-observation examples in `packages/durable/docs/pico-v5-chord-usage.md` are extension patterns, not built-in coding-agent services. Private references, trace carriers, and flow control remain protocol/host infrastructure slices rather than presentation service tokens.
## TODO
Deferred while the worker moves from the removed `AgentHarness` to `@earendil-works/pi-durable`:
- Tree navigation: `AgentController.navigate()` is dropped. Durable branches by forking into a new conversation, so navigation needs a fork, a summary entry that starts the new context, and a pointer to the Session's current conversation.
- Next-run queue: `AgentController.nextRun()` is dropped. Durable queues only steering and follow-up input. `resume()` is dropped as well: the worker resumes interrupted work when it opens the Session.
- Subagents: the services cover only the root conversation. Exposing subagent conversations needs keyed service instances per conversation.
- Transcript history: the `Transcript` state is the conversation's view, which holds only the active context since the newest reset or compaction. Older entries need a paging method over `Conversation.entries()`.

View file

@ -1,95 +0,0 @@
import type { Context } from "@earendil-works/chord";
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
import {
type Conversation,
ConversationBusy,
type Harness,
type SubmissionId,
type UserInput,
} from "@earendil-works/pi-durable";
import type {
AgentController as AgentControllerService,
AgentOperationError,
AgentPromptRequest,
AgentPromptResult,
AgentQueueResponse,
} from "./agent-controller.ts";
export function createAgentController(harness: Harness, conversation: Conversation): AgentControllerService {
const queue = async (
whenBusy: "steer" | "followUp",
request: AgentPromptRequest,
context: Context,
): Promise<AgentQueueResponse> => {
try {
const submission = await conversation.submit({ type: "input", content: toInput(request), whenBusy }, context);
return { accepted: true, entryId: String(submission.id), error: null };
} catch (error) {
return { accepted: false, entryId: null, error: toAgentError(error) };
}
};
return {
async prompt(request, context) {
try {
const submission = await conversation.submit(
{ type: "input", content: toInput(request), whenBusy: "reject" },
context,
);
return { accepted: true, operationId: String(submission.id), error: null };
} catch (error) {
return { accepted: false, operationId: null, error: toAgentError(error) };
}
},
steer: (request, context) => queue("steer", request, context),
followUp: (request, context) => queue("followUp", request, context),
async cancelQueued(entryId, context) {
const id = parseSubmissionId(entryId);
if (id === undefined) return { outcome: "not_found" };
const result = await harness.abortSubmission(id, context, conversation.id);
return {
outcome: result === "aborted" ? "cancelled" : result === "not_found" ? "not_found" : "already_consumed",
};
},
abort: (context) => conversation.abort(context),
async compact(request, context) {
try {
const id = await conversation.compact(request.customInstructions ?? undefined, context);
return { accepted: true, operationId: String(id), error: null };
} catch (error) {
return { accepted: false, operationId: null, error: toAgentError(error) };
}
},
async waitForPrompt(operationId, context): Promise<AgentPromptResult> {
const id = parseSubmissionId(operationId);
const submission = id === undefined ? undefined : await harness.submission(id, context);
if (submission === undefined) throw new Error(`Unknown prompt: ${operationId}`);
const settled = await submission.wait(context);
if (settled.status === "unanswered") return { status: "unanswered", text: null, reason: settled.reason };
const answer = settled.type === "input" ? settled.answer : undefined;
const message =
answer === undefined ? undefined : (await harness.commit((tx) => tx.entry(answer), context))?.model?.[0];
const text =
message?.role === "assistant"
? message.content.flatMap((content) => (content.type === "text" ? [content.text] : [])).join("")
: "";
return { status: "done", text, reason: null };
},
};
}
function parseSubmissionId(value: string): SubmissionId | undefined {
const id = Number(value);
return /^[1-9][0-9]*$/.test(value) && Number.isSafeInteger(id) ? (id as SubmissionId) : undefined;
}
function toInput(request: AgentPromptRequest): UserInput {
if (request.images === null || request.images.length === 0) return request.message;
const content: (TextContent | ImageContent)[] = [{ type: "text", text: request.message }, ...request.images];
return content;
}
function toAgentError(error: unknown): AgentOperationError {
if (error instanceof ConversationBusy) return { code: "busy", message: error.message };
return { code: "operation_failed", message: error instanceof Error ? error.message : String(error) };
}

View file

@ -1,57 +0,0 @@
import { type Context, defineService } from "@earendil-works/chord";
export interface AgentPromptImage {
type: "image";
data: string;
mimeType: string;
}
export interface AgentPromptRequest {
message: string;
images: AgentPromptImage[] | null;
}
export interface AgentOperationError {
code: string;
message: string;
}
/** `operationId` identifies the durable submission of a prompt, or the task of a compaction. */
export type AgentOperationResponse =
| { accepted: true; operationId: string; error: null }
| { accepted: false; operationId: null; error: AgentOperationError };
/** `entryId` identifies the durable submission; `cancelQueued()` withdraws it while it is still queued. */
export type AgentQueueResponse =
| { accepted: true; entryId: string; error: null }
| { accepted: false; entryId: null; error: AgentOperationError };
/** The settled outcome of a prompt: the answer's text, or why it got none. */
export type AgentPromptResult =
| { status: "done"; text: string; reason: null }
| { status: "unanswered"; text: null; reason: string };
export interface AgentCompactionRequest {
customInstructions: string | null;
}
/** Presentation-safe command facade over the worker-owned root conversation. */
export interface AgentController {
/** Start a run; rejected with `busy` while one is active. */
prompt(request: AgentPromptRequest, context: Context): Promise<AgentOperationResponse>;
/** Steer the active run, or start one when idle. */
steer(request: AgentPromptRequest, context: Context): Promise<AgentQueueResponse>;
/** Queue input for after the active run, or start one when idle. */
followUp(request: AgentPromptRequest, context: Context): Promise<AgentQueueResponse>;
cancelQueued(
entryId: string,
context: Context,
): Promise<{ outcome: "cancelled" | "already_consumed" | "not_found" }>;
/** Withdraw queued input and abort the active run and compaction. */
abort(context: Context): Promise<void>;
compact(request: AgentCompactionRequest, context: Context): Promise<AgentOperationResponse>;
/** Wait until the prompt with this `operationId` is answered or settles unanswered. */
waitForPrompt(operationId: string, context: Context): Promise<AgentPromptResult>;
}
export const AgentController = defineService<AgentController>("pi.agent-controller");

View file

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

View file

@ -1,173 +0,0 @@
import { type Context, defineFacet, type Facet, type MutableReplicatedState } from "@earendil-works/chord";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { clampThinkingLevel, getSupportedThinkingLevels, type ModelThinkingLevel } from "@earendil-works/pi-ai";
import {
AgentDoc,
type AgentState,
type Conversation,
type DocumentState,
type Harness,
} from "@earendil-works/pi-durable";
import type { ModelRuntime } from "../../core/model-runtime.ts";
import type { SettingsManager } from "../../core/settings-manager.ts";
import { Models, type Models as ModelsService, type ModelsState } from "./models.ts";
export interface ModelsServiceRuntime {
readonly service: ModelsService;
activate(context: Context): Promise<void>;
/** Publish the agent document's model and thinking level when they changed. */
syncConfiguration(context: Context): void;
}
/**
* The Models service over one conversation. `agent` is the conversation's replicated `pi.agent` document: its
* configuration follows every change, also those made by other clients.
*/
export function createModelsService(
conversation: Conversation,
agent: DocumentState<AgentState>,
modelRuntime: ModelRuntime | undefined,
settingsManager: SettingsManager | undefined,
createState: (initial: ModelsState) => MutableReplicatedState<ModelsState>,
): ModelsServiceRuntime {
let catalogRevision = 0;
const configurationOf = (value: Readonly<AgentState> | null): ModelsState["configuration"] => ({
model: value?.model === undefined ? null : { provider: value.model.provider, modelId: value.model.modelId },
thinkingLevel: value?.thinkingLevel ?? "off",
});
const state = createState({
catalog: { revision: 0, availableModels: [] },
configuration: configurationOf(agent.value),
refresh: { status: "idle" },
});
const selectedModel = () => {
const ref = agent.value?.model;
return ref === undefined ? undefined : modelRuntime?.getModel(ref.provider, ref.modelId);
};
const readThinkingLevels = (): ModelThinkingLevel[] => {
const selected = selectedModel();
return selected === undefined ? ["off"] : getSupportedThinkingLevels(selected);
};
const readCatalog = (): ModelsState["catalog"] => {
const selected = selectedModel();
const available = modelRuntime?.getAvailableSnapshot() ?? [];
const catalog =
selected === undefined || includesModel(available, selected) ? available : [...available, selected];
catalogRevision += 1;
return {
revision: catalogRevision,
availableModels: catalog.map((model) => ({
provider: model.provider,
modelId: model.id,
name: model.name,
reasoning: model.reasoning,
})),
};
};
const service: ModelsService = {
state,
async cycleThinking(context) {
const levels = readThinkingLevels();
const current = agent.value?.thinkingLevel ?? "off";
const next = levels[(levels.indexOf(current) + 1) % levels.length] ?? "off";
await conversation.configure({ thinkingLevel: next }, context);
},
async getThinkingLevels() {
return readThinkingLevels();
},
async refresh(context) {
state.change(context, (draft) => {
draft.refresh = { status: "refreshing" };
});
const refresh = modelRuntime?.refresh({ signal: context.abortSignal });
const errors =
refresh === undefined
? {}
: Object.fromEntries([...(await refresh).errors].map(([id, error]) => [id, error.message]));
const catalog = readCatalog();
state.change(context, (draft) => {
draft.catalog = catalog;
draft.refresh = Object.keys(errors).length === 0 ? { status: "done" } : { status: "warning", errors };
});
},
async select(model, context) {
const selected = modelRuntime?.getModel(model.provider, model.modelId);
if (selected === undefined) throw new Error(`Unknown model: ${model.provider}/${model.modelId}`);
const thinkingLevel = clampThinkingLevel(selected, agent.value?.thinkingLevel ?? "off");
await conversation.configure(
{ model: { provider: selected.provider, modelId: selected.id }, thinkingLevel },
context,
);
settingsManager?.setDefaultModelAndProvider(selected.provider, selected.id);
await settingsManager?.flush();
},
async selectThinking(level, context) {
const levels = readThinkingLevels();
if (!levels.includes(level)) {
throw new Error(`Thinking level ${level} is unavailable; choose one of: ${levels.join(", ")}`);
}
await conversation.configure({ thinkingLevel: level }, context);
},
};
return {
service,
async activate(context) {
const catalog = readCatalog();
state.change(context, (draft) => {
draft.catalog = catalog;
draft.refresh = { status: "idle" };
});
},
syncConfiguration(context) {
const next = configurationOf(agent.value);
const current = state.value.configuration;
if (
current.model?.provider === next.model?.provider &&
current.model?.modelId === next.model?.modelId &&
current.thinkingLevel === next.thinkingLevel
) {
return;
}
state.change(context, (draft) => {
draft.configuration = next;
});
},
};
}
/** Acquire the conversation's agent document, then build the facet that owns it. */
export async function createModelsServiceFacet(options: {
readonly harness: Harness;
readonly conversation: Conversation;
readonly modelRuntime: ModelRuntime | undefined;
readonly settingsManager?: SettingsManager;
readonly context: Context;
}): Promise<Facet> {
const agent = await options.harness.documentState(AgentDoc, options.conversation.id, options.context);
if (agent === undefined) throw new Error(`Conversation ${options.conversation.id} has no agent document`);
return defineFacet({
id: "@pi/models",
setup(env) {
env.own(() => agent.dispose());
const runtime = createModelsService(
options.conversation,
agent,
options.modelRuntime,
options.settingsManager,
env.replicatedState,
);
env.provide(Models, runtime.service);
env.onActivate(async () => {
await runtime.activate(BACKGROUND_CONTEXT);
env.own(agent.subscribe((_value, context) => runtime.syncConfiguration(context)));
});
},
});
}
function includesModel(
models: readonly { readonly provider: string; readonly id: string }[],
selected: { readonly provider: string; readonly id: string },
): boolean {
return models.some((model) => model.provider === selected.provider && model.id === selected.id);
}

View file

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

View file

@ -1,19 +0,0 @@
import { type Context, defineService, type JsonValue } from "@earendil-works/chord";
/** Server-built plugin generations available to presentations. */
export interface PresentationPlugins {
prepareSession(
request: { readonly sessionId: string; readonly packagePaths: readonly string[] | null },
context: Context,
): Promise<JsonValue>;
reload(context: Context): Promise<JsonValue>;
}
export const PresentationPlugins = defineService<PresentationPlugins>("pi.presentation-plugins");
/** Plugin facets hosted in the currently attached Session worker. */
export interface SessionPlugins {
reload(context: Context): Promise<void>;
}
export const SessionPlugins = defineService<SessionPlugins>("pi.session-plugins");

View file

@ -1,20 +0,0 @@
import { type Context, defineService } from "@earendil-works/chord";
export interface PresentationSelectItem {
readonly value: string;
readonly label: string;
readonly description?: string;
}
/** Narrow process-local UI capabilities available to presentation facets. */
export interface PresentationUI {
select(
title: string,
items: readonly PresentationSelectItem[],
selectedValue: string | undefined,
context: Context,
): Promise<string | undefined>;
showStatus(message: string, context: Context): void;
}
export const PresentationUI = defineService<PresentationUI>("pi.local.presentation-ui", { local: true });

View file

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

View file

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

View file

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

View file

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

View file

@ -1,15 +0,0 @@
import { type Context, defineFacet, type Facet } from "@earendil-works/chord";
import type { Conversation } from "@earendil-works/pi-durable";
import { Transcript } from "./transcript.ts";
/** Serve the conversation's durable view state. The facet owns and disposes the attached state. */
export async function createTranscriptServiceFacet(conversation: Conversation, context: Context): Promise<Facet> {
const state = await conversation.viewState(context);
return defineFacet({
id: "@pi/transcript",
setup(env) {
env.own(() => state.dispose());
env.provide(Transcript, { state });
},
});
}

View file

@ -1,9 +0,0 @@
import { defineService, type ReplicatedState } from "@earendil-works/chord";
import type { ConversationView } from "@earendil-works/pi-durable";
/** The root conversation's durable view: active entries and its live, inbox, agent, and usage documents. */
export interface Transcript {
readonly state: ReplicatedState<ConversationView>;
}
export const Transcript = defineService<Transcript>("pi.transcript");

View file

@ -1,157 +0,0 @@
import {
type Context,
createFacetHost,
createRemoteServiceEndpoint,
createStaticFacetLoader,
defineFacet,
type FacetHost,
type FacetLoader,
type JsonValue,
type RemoteServiceEndpoint,
type ServiceCall,
type ServiceProviderUpdate,
} from "@earendil-works/chord";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import type { Conversation, Harness } from "@earendil-works/pi-durable";
import type { ModelRuntime } from "../../core/model-runtime.ts";
import type { SettingsManager } from "../../core/settings-manager.ts";
import { AgentController } from "./agent-controller.ts";
import { createAgentController } from "./agent-controller-provider.ts";
import { createModelsServiceFacet } from "./models-provider.ts";
import { SessionPlugins } from "./plugins.ts";
import { createTranscriptServiceFacet } from "./transcript-provider.ts";
export interface SessionWorkerRuntime {
readonly harness: Harness;
/** The root conversation the services expose. */
readonly conversation: Conversation;
readonly modelRuntime?: ModelRuntime;
readonly settingsManager?: SettingsManager;
readonly facetLoader?: FacetLoader;
/** Release resources the Harness does not own, such as execution environments, after it closed. */
cleanup?(context: Context): Promise<void>;
}
export interface WorkerServiceScope {
readonly serverConnectionId: string;
readonly attachmentId: string;
}
interface ScopedServiceEndpoint {
readonly scope: WorkerServiceScope;
readonly endpoint: RemoteServiceEndpoint;
}
export interface SessionWorkerServices {
invoke(call: ServiceCall, scope: WorkerServiceScope, context: Context): Promise<JsonValue | undefined>;
removeSubscriptions(matches: (scope: WorkerServiceScope) => boolean): void;
dispose(): Promise<void>;
}
export async function createSessionWorkerServices(options: {
readonly harness: Harness;
readonly conversation: Conversation;
readonly modelRuntime: ModelRuntime | undefined;
readonly settingsManager?: SettingsManager;
readonly facetLoader?: FacetLoader;
publish(scope: WorkerServiceScope, subscriptionId: string, update: ServiceProviderUpdate): Promise<void>;
}): Promise<SessionWorkerServices> {
const agentControllerRuntimeFacet = defineFacet({
id: "@pi/agent-controller-runtime",
setup(env) {
env.provide(AgentController, createAgentController(options.harness, options.conversation));
},
});
let reloadPlugins = (): Promise<void> => Promise.reject(new Error("Session plugins are not ready"));
const pluginRuntimeFacet = defineFacet({
id: "@pi/session-plugins-runtime",
setup(env) {
env.provide(SessionPlugins, { reload: () => reloadPlugins() });
},
});
const builtins = await createStaticFacetLoader([
agentControllerRuntimeFacet,
pluginRuntimeFacet,
await createModelsServiceFacet({ ...options, context: BACKGROUND_CONTEXT }),
await createTranscriptServiceFacet(options.conversation, BACKGROUND_CONTEXT),
]).load();
const pluginLoader = options.facetLoader ?? createStaticFacetLoader([]);
let loadedPlugins = await pluginLoader.load();
let facetHost: FacetHost;
try {
facetHost = await createFacetHost({ facets: [...builtins.facets, ...loadedPlugins.facets] });
} catch (error) {
const cleanup = await Promise.allSettled([loadedPlugins.dispose(), builtins.dispose()]);
const cleanupErrors = cleanup.flatMap((result) => (result.status === "rejected" ? [result.reason] : []));
if (cleanupErrors.length > 0) {
throw new AggregateError([error, ...cleanupErrors], "Session facets failed to start and clean up");
}
throw error;
}
let reloadTail = Promise.resolve();
reloadPlugins = () => {
const operation = reloadTail.then(async () => {
const candidate = await pluginLoader.load();
try {
await facetHost.reload(candidate.facets);
} catch (error) {
try {
await candidate.dispose();
} catch (cleanupError) {
throw new AggregateError([error, cleanupError], "Session plugin reload and cleanup failed");
}
throw error;
}
const retired = loadedPlugins;
loadedPlugins = candidate;
await retired.dispose();
});
reloadTail = operation.catch(() => {});
return operation;
};
const provider = facetHost.services;
const endpoints = new Map<string, ScopedServiceEndpoint>();
const removeSubscriptions = (matches: (scope: WorkerServiceScope) => boolean): void => {
for (const [key, entry] of endpoints) {
if (!matches(entry.scope)) continue;
entry.endpoint.dispose();
endpoints.delete(key);
}
};
return {
invoke(call, scope, context) {
const key = serviceScopeKey(scope);
let entry = endpoints.get(key);
if (entry === undefined) {
entry = { scope, endpoint: createRemoteServiceEndpoint(provider) };
endpoints.set(key, entry);
}
return entry.endpoint.invoke(
call,
(subscriptionId, update) => options.publish(scope, subscriptionId, update),
context,
);
},
removeSubscriptions,
async dispose() {
removeSubscriptions(() => true);
await reloadTail;
const errors: unknown[] = [];
try {
await facetHost.dispose();
} catch (error) {
errors.push(error);
}
const results = await Promise.allSettled([loadedPlugins.dispose(), builtins.dispose()]);
errors.push(...results.flatMap((result) => (result.status === "rejected" ? [result.reason] : [])));
if (errors.length === 1) throw errors[0];
if (errors.length > 1) throw new AggregateError(errors, "Failed to dispose Session facets");
},
};
}
function serviceScopeKey(scope: WorkerServiceScope): string {
return `${scope.serverConnectionId}\0${scope.attachmentId}`;
}

View file

@ -1,87 +0,0 @@
import { randomUUID } from "node:crypto";
import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
/** One server-hosted Session: a directory holding `meta.json` and the worker-owned `session.sqlite`. */
export interface SessionCatalogMetadata {
readonly id: string;
readonly createdAt: number;
/** The working directory the Session's agent runs in. */
readonly cwd: string;
/** The Session directory. Workers lock it and own the storage inside it. */
readonly path: string;
}
const METADATA_FILE = "meta.json";
const STORAGE_FILE = "session.sqlite";
const SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
export function isSessionId(id: string): boolean {
return SESSION_ID.test(id);
}
/** The durable storage file of a Session. Only the Session's worker opens it. */
export function sessionStoragePath(metadata: SessionCatalogMetadata): string {
return join(metadata.path, STORAGE_FILE);
}
/** Every Session in the directory. Entries without valid metadata are skipped. */
export async function listSessions(sessionDir: string): Promise<SessionCatalogMetadata[]> {
let names: string[];
try {
names = await readdir(sessionDir);
} catch (error) {
if (isNotFound(error)) return [];
throw error;
}
const sessions = await Promise.all(names.filter(isSessionId).map((name) => readSession(sessionDir, name)));
return sessions.filter((metadata) => metadata !== undefined);
}
/** One Session by ID, or undefined when it does not exist. */
export async function readSession(sessionDir: string, id: string): Promise<SessionCatalogMetadata | undefined> {
if (!isSessionId(id)) return undefined;
const path = join(sessionDir, id);
let value: unknown;
try {
value = JSON.parse(await readFile(join(path, METADATA_FILE), "utf8"));
} catch {
return undefined;
}
if (typeof value !== "object" || value === null) return undefined;
const { createdAt, cwd } = value as { createdAt?: unknown; cwd?: unknown };
if (typeof createdAt !== "number" || typeof cwd !== "string") return undefined;
return { id, createdAt, cwd, path };
}
/** Create an empty Session. Its worker creates the storage on first open. */
export async function createSession(
sessionDir: string,
options: { readonly id?: string; readonly cwd: string },
): Promise<SessionCatalogMetadata> {
const id = options.id ?? randomUUID();
if (!isSessionId(id)) throw new Error(`Invalid session ID: ${id}`);
const path = join(sessionDir, id);
await mkdir(sessionDir, { recursive: true });
try {
await mkdir(path);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new Error(`Session ${id} already exists`);
throw error;
}
const metadata: SessionCatalogMetadata = { id, createdAt: Date.now(), cwd: options.cwd, path };
await writeFile(
join(path, METADATA_FILE),
`${JSON.stringify({ createdAt: metadata.createdAt, cwd: metadata.cwd }, null, "\t")}\n`,
);
return metadata;
}
/** Delete a Session directory. Its worker must be closed first. */
export async function deleteSession(metadata: SessionCatalogMetadata): Promise<void> {
await rm(metadata.path, { recursive: true, force: true });
}
function isNotFound(error: unknown): boolean {
return (error as NodeJS.ErrnoException | undefined)?.code === "ENOENT";
}

View file

@ -1,848 +0,0 @@
import type { ChildProcess } from "node:child_process";
import { randomUUID } from "node:crypto";
import { isAbsolute } from "node:path";
import {
type Context,
createServiceUnsubscribeCall,
decodeServiceControlCall,
type JsonValue,
parseServiceProviderUpdate,
type ServiceCall,
type ServiceProviderUpdate,
} from "@earendil-works/chord";
import { BACKGROUND_CONTEXT, TODO_CONTEXT } from "@earendil-works/chord/context";
import { type RoutedSessionAttachment, type RoutedSessionHandle, ServerError } from "@earendil-works/pi-server";
import { Check } from "typebox/value";
import type { CoordinatorConnection, CoordinatorConnectionEvent } from "./coordinator.ts";
import { spawnInternalProcess } from "./process.ts";
import type { SessionCatalogMetadata } from "./session-catalog.ts";
import {
SESSION_WORKER_CONTROL_ADDRESS_ENV,
SESSION_WORKER_CONTROL_TOKEN_ENV,
SESSION_WORKER_PEER_ID_ENV,
SESSION_WORKER_SESSION_KEY_ENV,
type SessionWorkerEvent,
SessionWorkerEventSchema,
type SessionWorkerOptions,
type WorkerOperationResponse,
type WorkerOperationScope,
} from "./session-worker.ts";
const WORKER_STARTUP_TIMEOUT_MS = 15_000;
const WORKER_SHUTDOWN_TIMEOUT_MS = 10_000;
const WORKER_DISCOVERY_TIMEOUT_MS = 5_000;
const WORKER_DEMAND_TIMEOUT_MS = 5_000;
export class SessionPluginSelectionConflictError extends Error {
constructor(message: string) {
super(message);
this.name = "SessionPluginSelectionConflictError";
}
}
interface WorkerRecord {
readonly peerId: string;
readonly metadata: SessionCatalogMetadata;
readonly pid: number;
readonly token: string;
readonly pluginManifestPaths: readonly string[];
readonly terminated: Promise<Error | undefined>;
resolveTerminated(error: Error | undefined): void;
readonly attachmentIds: Set<string>;
expectedStop: boolean;
stopPromise?: Promise<void>;
stopping: boolean;
}
interface PendingDemand {
readonly attachmentId: string;
readonly attached: boolean;
readonly requestId: string;
readonly timer: NodeJS.Timeout;
readonly worker: WorkerRecord;
resolve(): void;
reject(error: Error): void;
}
interface PendingWorkerOperation {
readonly worker: WorkerRecord;
readonly scope: WorkerOperationScope;
cleanup(): void;
resolve(result: JsonValue | undefined): void;
reject(error: Error): void;
}
interface WorkerServiceSubscription {
readonly worker: WorkerRecord;
readonly scope: WorkerOperationScope;
readonly listener: (update: ServiceProviderUpdate, context: Context) => void | Promise<void>;
deliveryTail: Promise<void>;
}
interface PendingLaunch {
readonly sessionKey: string;
readonly peerId: string;
readonly token: string;
readonly pluginManifestPaths: readonly string[];
readonly child: ChildProcess;
readonly timer: NodeJS.Timeout;
readonly promise: Promise<WorkerRecord>;
resolve(worker: WorkerRecord): void;
reject(error: Error): void;
}
/** Session and process bookkeeping owned by one replaceable server process. */
export class SessionWorkerManager {
readonly workerPids = new Map<string, number>();
readonly #coordinator: Pick<
CoordinatorConnection,
"controlPath" | "serverConnectionId" | "wasReplaced" | "onEvent" | "send" | "broadcast"
>;
readonly #sessionDir: string;
readonly #model: { readonly provider?: string; readonly model: string } | undefined;
readonly #workersBySession = new Map<string, WorkerRecord>();
readonly #workersByPeer = new Map<string, WorkerRecord>();
readonly #pending = new Map<string, PendingLaunch>();
readonly #pendingDemand = new Map<string, PendingDemand>();
readonly #pendingOperations = new Map<string, PendingWorkerOperation>();
readonly #serviceSubscriptions = new Map<string, WorkerServiceSubscription>();
readonly #removeListener: () => void;
readonly #onWorkerCountChanged: ((count: number) => void) | undefined;
#discoveryPeers?: Set<string>;
#resolveDiscovery?: () => void;
#detached = false;
#shuttingDown = false;
constructor(
coordinator: Pick<
CoordinatorConnection,
"controlPath" | "serverConnectionId" | "wasReplaced" | "onEvent" | "send" | "broadcast"
>,
sessionDir: string,
model?: { readonly provider?: string; readonly model: string },
onWorkerCountChanged?: (count: number) => void,
) {
this.#coordinator = coordinator;
this.#sessionDir = sessionDir;
this.#model = model;
this.#onWorkerCountChanged = onWorkerCountChanged;
this.#removeListener = coordinator.onEvent((event) => this.#handleCoordinatorEvent(event));
}
get trackedSessions(): readonly SessionCatalogMetadata[] {
return [...this.#workersBySession.values()].map((worker) => worker.metadata);
}
assertSessionPluginManifestPaths(metadata: SessionCatalogMetadata, manifestPaths: readonly string[]): void {
const existing = this.#workersBySession.get(metadata.path);
if (existing !== undefined && !sameStrings(existing.pluginManifestPaths, manifestPaths)) {
throw new SessionPluginSelectionConflictError(
`Session ${metadata.id} is active with a different plugin selection`,
);
}
const pending = this.#pending.get(metadata.path);
if (pending !== undefined && !sameStrings(pending.pluginManifestPaths, manifestPaths)) {
throw new SessionPluginSelectionConflictError(
`Session ${metadata.id} is starting with a different plugin selection`,
);
}
}
async discover(peerIds: ReadonlySet<string>): Promise<void> {
if (this.#detached) return;
const undiscovered = new Set(
[...peerIds].filter((peerId) => !this.#workersByPeer.has(peerId) && !this.#pendingPeer(peerId)),
);
if (undiscovered.size === 0) return;
this.#discoveryPeers = undiscovered;
const discovered = new Promise<void>((resolve) => {
this.#resolveDiscovery = resolve;
});
await this.#coordinator.broadcast({ type: "discover_workers" });
let timer: NodeJS.Timeout | undefined;
await Promise.race([
discovered,
new Promise<void>((resolve) => {
timer = setTimeout(resolve, WORKER_DISCOVERY_TIMEOUT_MS);
timer.unref();
}),
]);
if (timer) clearTimeout(timer);
this.#discoveryPeers = undefined;
this.#resolveDiscovery = undefined;
}
async openSession(
metadata: SessionCatalogMetadata,
context: Context,
pluginManifestPaths: readonly string[],
): Promise<RoutedSessionHandle> {
if (this.#detached || this.#shuttingDown) throw new Error("Experimental server is shutting down");
this.assertSessionPluginManifestPaths(metadata, pluginManifestPaths);
const existing = this.#workersBySession.get(metadata.path);
if (existing) return this.#routedHandle(existing);
const pending = this.#pending.get(metadata.path);
if (pending) return this.#routedHandle(await pending.promise);
return this.#routedHandle(await this.#launch(metadata, context, pluginManifestPaths));
}
async closeSession(metadata: SessionCatalogMetadata, context: Context): Promise<void> {
const worker = this.#workersBySession.get(metadata.path) ?? (await this.#pending.get(metadata.path)?.promise);
if (worker !== undefined) await this.#stopWorker(worker, context);
}
#routedHandle(worker: WorkerRecord): RoutedSessionHandle {
return {
terminated: worker.terminated,
attachClient: (context) => this.#attachClient(worker, context),
close: (context) => this.#stopWorker(worker, context),
};
}
async #attachClient(worker: WorkerRecord, context: Context): Promise<RoutedSessionAttachment> {
if (this.#detached || this.#shuttingDown || worker.stopping) {
throw new Error("Experimental Session worker is stopping");
}
if (this.#workersByPeer.get(worker.peerId) !== worker) {
throw new Error("Experimental Session worker is no longer available");
}
const attachmentId = randomUUID();
worker.attachmentIds.add(attachmentId);
try {
await this.#applyDemand(worker, attachmentId, true, true, context);
} catch (error) {
worker.attachmentIds.delete(attachmentId);
throw error;
}
const scope = this.#operationScope(worker, attachmentId);
let released = false;
return {
invokeService: (call, publish, serviceContext) =>
this.#invokeService(worker, scope, call, publish, serviceContext),
release: async (releaseContext) => {
if (released) return;
released = true;
if (this.#detached || !worker.attachmentIds.has(attachmentId)) return;
try {
await this.#applyDemand(worker, attachmentId, false, true, releaseContext);
} catch (error) {
if (!this.#detached && !this.#coordinator.wasReplaced) throw error;
} finally {
worker.attachmentIds.delete(attachmentId);
this.#removeServiceSubscriptions((entry) => entry.worker === worker && sameScope(entry.scope, scope));
}
},
};
}
async #invokeService(
worker: WorkerRecord,
scope: WorkerOperationScope,
call: ServiceCall,
publish: (subscriptionId: string, update: ServiceProviderUpdate, context: Context) => void | Promise<void>,
context: Context,
): Promise<JsonValue | undefined> {
const control = decodeServiceControlCall(call);
let addedSubscriptionKey: string | undefined;
if (control?.type === "subscribe") {
const key = scopedServiceSubscriptionKey(scope, control.subscriptionId);
if (this.#serviceSubscriptions.has(key)) throw new Error("Service subscription ID is already active");
this.#serviceSubscriptions.set(key, {
worker,
scope,
listener: (update, updateContext) => publish(control.subscriptionId, update, updateContext),
deliveryTail: Promise.resolve(),
});
addedSubscriptionKey = key;
}
let response: JsonValue | undefined;
try {
response = await this.#invoke(worker, scope, call, context);
} catch (error) {
if (addedSubscriptionKey !== undefined && control?.type === "subscribe") {
this.#serviceSubscriptions.delete(addedSubscriptionKey);
void this.#invoke(
worker,
scope,
createServiceUnsubscribeCall(control.subscriptionId),
BACKGROUND_CONTEXT,
).catch(() => {});
}
throw error;
}
if (control?.type === "unsubscribe") {
this.#serviceSubscriptions.delete(scopedServiceSubscriptionKey(scope, control.subscriptionId));
}
return response;
}
async #applyDemand(
worker: WorkerRecord,
attachmentId: string,
attached: boolean,
compensateOnTimeout: boolean,
_context: Context,
): Promise<void> {
if (worker.stopping || this.#workersByPeer.get(worker.peerId) !== worker) {
throw new Error("Experimental Session worker is stopping");
}
const requestId = randomUUID();
let resolve!: () => void;
let reject!: (error: Error) => void;
const applied = new Promise<void>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
const timer = setTimeout(() => {
if (compensateOnTimeout) void this.#reconcileDemandTimeout(requestId);
else this.#rejectDemand(requestId, new Error("Session worker demand update timed out"));
}, WORKER_DEMAND_TIMEOUT_MS);
timer.unref();
const pending = { attachmentId, attached, requestId, timer, worker, resolve, reject };
this.#pendingDemand.set(requestId, pending);
try {
await this.#coordinator.send(worker.peerId, {
type: "session_demand",
serverConnectionId: this.#coordinator.serverConnectionId,
requestId,
attachmentId,
attached,
});
} catch (error) {
this.#rejectDemand(requestId, error instanceof Error ? error : new Error(String(error)));
}
return applied;
}
/** Worker operations have no wall-clock timeout: completion, disconnect, replacement, or shutdown settles them. */
#invoke(
worker: WorkerRecord,
scope: WorkerOperationScope,
call: ServiceCall,
context: Context,
): Promise<JsonValue | undefined> {
try {
this.#operationScope(worker, scope.attachmentId);
} catch (error) {
return Promise.reject(error);
}
if (context.abortSignal?.aborted) return Promise.reject(abortError(context.abortSignal));
const requestId = randomUUID();
let resolve!: (result: JsonValue | undefined) => void;
let reject!: (error: Error) => void;
const result = new Promise<JsonValue | undefined>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
const signal = context.abortSignal;
const onAbort = (): void => {
if (signal === undefined || !this.#pendingOperations.has(requestId)) return;
void this.#coordinator.send(worker.peerId, { type: "operation_cancel", requestId, scope }).catch(() => {});
this.#rejectOperation(requestId, abortError(signal));
};
if (signal !== undefined) signal.addEventListener("abort", onAbort, { once: true });
this.#pendingOperations.set(requestId, {
worker,
scope,
cleanup: () => signal?.removeEventListener("abort", onAbort),
resolve,
reject,
});
void this.#coordinator
.send(worker.peerId, { type: "operation", requestId, scope, call })
.catch((error: unknown) =>
this.#rejectOperation(requestId, error instanceof Error ? error : new Error(String(error))),
);
return result;
}
#operationScope(worker: WorkerRecord, attachmentId: string): WorkerOperationScope {
if (this.#detached || this.#shuttingDown || worker.stopping) {
throw new Error("Experimental Session worker is stopping");
}
if (this.#workersByPeer.get(worker.peerId) !== worker || !worker.attachmentIds.has(attachmentId)) {
throw new Error("Experimental Session worker has no active attachment");
}
return {
serverConnectionId: this.#coordinator.serverConnectionId,
attachmentId,
};
}
#stopWorker(worker: WorkerRecord, _context: Context): Promise<void> {
if (this.#detached || this.#workersByPeer.get(worker.peerId) !== worker) return Promise.resolve();
worker.stopPromise ??= this.#stopWorkerInternal(worker);
return worker.stopPromise;
}
async #stopWorkerInternal(worker: WorkerRecord): Promise<void> {
this.#rejectWorkerOperations(worker, new Error("Session worker is stopping"));
worker.stopping = true;
worker.expectedStop = true;
void this.#coordinator.send(worker.peerId, { type: "shutdown" }).catch(() => {});
let timer: NodeJS.Timeout | undefined;
const timedOut = new Promise<boolean>((resolve) => {
timer = setTimeout(() => resolve(true), WORKER_SHUTDOWN_TIMEOUT_MS);
timer.unref();
});
try {
if ((await Promise.race([worker.terminated.then(() => false), timedOut])) === true) {
try {
process.kill(worker.pid, "SIGKILL");
} catch (error) {
if (!(error instanceof Error && "code" in error && error.code === "ESRCH")) throw error;
}
this.#removeWorker(worker, undefined);
await worker.terminated;
}
} finally {
if (timer) clearTimeout(timer);
}
}
async shutdown(): Promise<void> {
if (this.#detached || this.#shuttingDown) return;
this.#shuttingDown = true;
const pendingWorkers = [...this.#pending.values()];
for (const pending of pendingWorkers) {
void this.#coordinator.send(pending.peerId, { type: "shutdown" }).catch(() => {});
}
const pendingFinished = Promise.all(
pendingWorkers.map((pending) => {
if (pending.child.exitCode !== null || pending.child.signalCode !== null) return Promise.resolve();
return new Promise<void>((resolve) => pending.child.once("exit", () => resolve()));
}),
).then(() => undefined);
let timer: NodeJS.Timeout | undefined;
const pendingTimedOut = new Promise<boolean>((resolve) => {
timer = setTimeout(() => resolve(true), WORKER_SHUTDOWN_TIMEOUT_MS);
timer.unref();
});
const stopPending = (async () => {
if ((await Promise.race([pendingFinished.then(() => false), pendingTimedOut])) === true) {
for (const pending of pendingWorkers) pending.child.kill("SIGKILL");
await pendingFinished;
}
if (timer) clearTimeout(timer);
})();
await Promise.all([
stopPending,
...[...this.#workersBySession.values()].map((worker) => this.#stopWorker(worker, BACKGROUND_CONTEXT)),
]);
this.#detachState();
}
/** Forget workers without stopping them when this server is replaced. */
detach(): void {
if (this.#detached) return;
this.#detached = true;
for (const pending of this.#pending.values()) {
clearTimeout(pending.timer);
pending.reject(new Error("Experimental server was replaced"));
}
for (const requestId of [...this.#pendingOperations.keys()]) {
this.#rejectOperation(requestId, new Error("Experimental server was replaced during a worker operation"));
}
this.#detachState();
}
#launch(
metadata: SessionCatalogMetadata,
_context: Context,
pluginManifestPaths: readonly string[],
): Promise<WorkerRecord> {
const sessionKey = metadata.path;
const peerId = `worker-${randomUUID()}`;
const token = randomUUID();
let child: ChildProcess;
try {
const options: SessionWorkerOptions = {
sessionDir: this.#sessionDir,
metadata: { id: metadata.id, createdAt: metadata.createdAt, cwd: metadata.cwd, path: metadata.path },
pluginManifestPaths: [...pluginManifestPaths],
...(this.#model ?? {}),
};
child = spawnInternalProcess("session-worker", [JSON.stringify(options)], {
env: {
[SESSION_WORKER_CONTROL_ADDRESS_ENV]: this.#coordinator.controlPath,
[SESSION_WORKER_CONTROL_TOKEN_ENV]: token,
[SESSION_WORKER_SESSION_KEY_ENV]: Buffer.from(sessionKey).toString("base64url"),
[SESSION_WORKER_PEER_ID_ENV]: peerId,
},
});
} catch (error) {
return Promise.reject(error);
}
let resolve!: (worker: WorkerRecord) => void;
let reject!: (error: Error) => void;
const promise = new Promise<WorkerRecord>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
const timer = setTimeout(
() => this.#failPending(sessionKey, new Error("Session worker startup timed out")),
WORKER_STARTUP_TIMEOUT_MS,
);
timer.unref();
const pending = {
sessionKey,
peerId,
token,
pluginManifestPaths: Object.freeze([...pluginManifestPaths]),
child,
timer,
promise,
resolve,
reject,
};
this.#pending.set(sessionKey, pending);
this.#notifyWorkerCountChanged();
child.once("error", (error) => this.#failPending(sessionKey, error));
child.once("exit", (code, signal) => this.#childExited(pending, code, signal));
return promise;
}
#handleCoordinatorEvent(event: CoordinatorConnectionEvent): void {
if (this.#detached) return;
if (event.type === "peer_disconnected") {
this.#markDiscovered(event.peerId);
const worker = this.#workersByPeer.get(event.peerId);
if (worker) {
this.#removeWorker(
worker,
worker.expectedStop
? undefined
: new Error(`Session worker ${worker.metadata.id} disconnected unexpectedly`),
);
}
const pending = this.#pendingPeer(event.peerId);
if (pending) this.#failPending(pending.sessionKey, new Error("Session worker disconnected during startup"));
return;
}
if (event.type !== "message") return;
if (!Check(SessionWorkerEventSchema, event.payload)) {
if (
typeof event.payload === "object" &&
event.payload !== null &&
"type" in event.payload &&
event.payload.type === "operation_response"
) {
const worker = this.#workersByPeer.get(event.from);
if (worker)
this.#rejectWorkerOperations(worker, new Error("Session worker returned an invalid operation response"));
}
return;
}
const message: SessionWorkerEvent = event.payload;
if (message.type === "worker_failed") {
const pending = this.#pending.get(message.sessionKey);
if (pending?.peerId === event.from && pending.token === message.token) {
this.#failPending(message.sessionKey, new Error(`Session worker failed: ${message.message}`));
}
return;
}
if (message.type === "demand_applied" || message.type === "demand_rejected") {
const pending = this.#pendingDemand.get(message.requestId);
if (
!pending ||
pending.worker.peerId !== event.from ||
pending.worker.token !== message.token ||
pending.worker.metadata.path !== message.sessionKey ||
(message.type === "demand_applied" &&
(pending.attachmentId !== message.attachmentId || pending.attached !== message.attached))
) {
return;
}
this.#pendingDemand.delete(message.requestId);
clearTimeout(pending.timer);
if (message.type === "demand_applied") pending.resolve();
else pending.reject(new Error(`Session worker rejected demand: ${message.message}`));
return;
}
if (message.type === "operation_response") {
this.#handleOperationResponse(event.from, message.token, message.sessionKey, message.response);
return;
}
if (message.type === "service_update") {
this.#handleServiceEvent(
event.from,
message.token,
message.sessionKey,
message.scope,
message.subscriptionId,
message.update,
);
return;
}
this.#recordReadyWorker(event.from, message);
}
#handleOperationResponse(
peerId: string,
token: string,
sessionKey: string,
response: WorkerOperationResponse,
): void {
const pending = this.#pendingOperations.get(response.requestId);
if (!pending) return;
if (
pending.worker.peerId !== peerId ||
pending.worker.token !== token ||
pending.worker.metadata.path !== sessionKey ||
response.scope.serverConnectionId !== pending.scope.serverConnectionId ||
response.scope.attachmentId !== pending.scope.attachmentId
) {
this.#rejectOperation(
response.requestId,
new Error("Session worker returned a mismatched operation response"),
);
return;
}
this.#pendingOperations.delete(response.requestId);
pending.cleanup();
if (response.type === "operation_error") {
pending.reject(
response.code === undefined
? new Error(`Session worker operation failed: ${response.message}`)
: new ServerError(response.code, response.message),
);
} else {
pending.resolve(response.result);
}
}
#handleServiceEvent(
peerId: string,
token: string,
sessionKey: string,
scope: WorkerOperationScope,
subscriptionId: string,
update: unknown,
): void {
const entry = this.#serviceSubscriptions.get(scopedServiceSubscriptionKey(scope, subscriptionId));
if (
entry === undefined ||
entry.worker.peerId !== peerId ||
entry.worker.token !== token ||
entry.worker.metadata.path !== sessionKey ||
!sameScope(entry.scope, scope)
) {
return;
}
let parsed: ServiceProviderUpdate;
try {
parsed = parseServiceProviderUpdate(update);
} catch {
return;
}
entry.deliveryTail = entry.deliveryTail.then(() => entry.listener(parsed, TODO_CONTEXT)).catch(() => {});
}
#recordReadyWorker(peerId: string, message: Extract<SessionWorkerEvent, { type: "worker_ready" }>): void {
if (
message.sessionKey !== message.metadata.path ||
message.sessionId !== message.metadata.id ||
!isAbsolute(message.metadata.cwd) ||
!isAbsolute(message.metadata.path)
) {
return;
}
this.#markDiscovered(peerId);
const pending = this.#pending.get(message.sessionKey);
if (pending?.peerId === peerId && !sameStrings(message.pluginManifestPaths, pending.pluginManifestPaths)) {
this.#failPending(message.sessionKey, new Error("Session worker started with stale plugin packages"));
return;
}
const existing = this.#workersBySession.get(message.sessionKey);
if (existing && existing.peerId !== peerId) {
void this.#coordinator.send(peerId, { type: "shutdown" }).catch(() => {});
return;
}
if (
pending &&
(pending.peerId !== peerId || pending.token !== message.token || pending.child.pid !== message.pid)
) {
return;
}
if (existing) return;
let resolveTerminated!: (error: Error | undefined) => void;
const terminated = new Promise<Error | undefined>((resolve) => {
resolveTerminated = resolve;
});
const worker: WorkerRecord = {
peerId,
metadata: message.metadata,
pid: message.pid,
token: message.token,
pluginManifestPaths: Object.freeze([...message.pluginManifestPaths]),
terminated,
resolveTerminated,
attachmentIds: new Set(),
expectedStop: false,
stopping: false,
};
this.#workersBySession.set(message.sessionKey, worker);
this.#workersByPeer.set(peerId, worker);
this.workerPids.set(message.sessionId, message.pid);
if (pending) {
this.#pending.delete(message.sessionKey);
clearTimeout(pending.timer);
pending.resolve(worker);
}
this.#notifyWorkerCountChanged();
}
#childExited(pending: PendingLaunch, code: number | null, signal: NodeJS.Signals | null): void {
if (this.#pending.get(pending.sessionKey) === pending) {
this.#failPending(
pending.sessionKey,
new Error(`Session worker exited before readiness (${signal ?? code ?? "unknown"})`),
);
return;
}
const worker = this.#workersByPeer.get(pending.peerId);
if (worker) {
this.#removeWorker(
worker,
worker.expectedStop
? undefined
: new Error(`Session worker ${worker.metadata.id} exited unexpectedly (${signal ?? code ?? "unknown"})`),
);
}
}
#failPending(sessionKey: string, error: Error): void {
const pending = this.#pending.get(sessionKey);
if (!pending) return;
this.#pending.delete(sessionKey);
clearTimeout(pending.timer);
this.#notifyWorkerCountChanged();
if (pending.child.exitCode === null && pending.child.signalCode === null) pending.child.kill("SIGKILL");
pending.reject(error);
}
#rejectDemand(requestId: string, error: Error): void {
const pending = this.#pendingDemand.get(requestId);
if (!pending) return;
this.#pendingDemand.delete(requestId);
clearTimeout(pending.timer);
pending.reject(error);
}
#rejectOperation(requestId: string, error: Error): void {
const pending = this.#pendingOperations.get(requestId);
if (!pending) return;
this.#pendingOperations.delete(requestId);
pending.cleanup();
pending.reject(error);
}
#rejectWorkerOperations(worker: WorkerRecord, error: Error): void {
for (const [requestId, pending] of this.#pendingOperations) {
if (pending.worker === worker) this.#rejectOperation(requestId, error);
}
}
async #reconcileDemandTimeout(requestId: string): Promise<void> {
const pending = this.#pendingDemand.get(requestId);
if (!pending) return;
this.#pendingDemand.delete(requestId);
clearTimeout(pending.timer);
const timeoutError = new Error("Session worker demand update timed out");
try {
await this.#applyDemand(pending.worker, pending.attachmentId, false, false, BACKGROUND_CONTEXT);
if (pending.attached) pending.reject(timeoutError);
else pending.resolve();
} catch (cleanupError) {
try {
await this.#stopWorker(pending.worker, BACKGROUND_CONTEXT);
} catch (stopError) {
pending.reject(
new AggregateError(
[timeoutError, cleanupError, stopError],
"Session worker demand reconciliation and termination failed",
),
);
return;
}
pending.reject(
new AggregateError(
[timeoutError, cleanupError],
"Session worker demand reconciliation failed; worker was terminated",
),
);
}
}
#removeWorker(worker: WorkerRecord, error: Error | undefined): void {
if (this.#workersByPeer.get(worker.peerId) !== worker) return;
this.#rejectWorkerOperations(worker, new Error("Session worker disconnected during an operation"));
this.#removeServiceSubscriptions((entry) => entry.worker === worker);
for (const pending of [...this.#pendingDemand.values()]) {
if (pending.worker === worker) {
this.#rejectDemand(pending.requestId, new Error("Session worker disconnected during demand update"));
}
}
this.#workersByPeer.delete(worker.peerId);
this.#workersBySession.delete(worker.metadata.path);
if (this.workerPids.get(worker.metadata.id) === worker.pid) this.workerPids.delete(worker.metadata.id);
worker.resolveTerminated(error);
this.#notifyWorkerCountChanged();
}
#removeServiceSubscriptions(matches: (entry: WorkerServiceSubscription) => boolean): void {
for (const [key, entry] of this.#serviceSubscriptions) {
if (matches(entry)) this.#serviceSubscriptions.delete(key);
}
}
#notifyWorkerCountChanged(): void {
this.#onWorkerCountChanged?.(this.#workersBySession.size + this.#pending.size);
}
#pendingPeer(peerId: string): PendingLaunch | undefined {
return [...this.#pending.values()].find((pending) => pending.peerId === peerId);
}
#markDiscovered(peerId: string): void {
if (!this.#discoveryPeers?.delete(peerId) || this.#discoveryPeers.size !== 0) return;
this.#resolveDiscovery?.();
}
#detachState(): void {
this.#removeListener();
for (const requestId of [...this.#pendingOperations.keys()]) {
this.#rejectOperation(requestId, new Error("Experimental server detached during a worker operation"));
}
for (const pending of [...this.#pendingDemand.values()]) {
this.#pendingDemand.delete(pending.requestId);
clearTimeout(pending.timer);
pending.resolve();
}
this.#pending.clear();
this.#serviceSubscriptions.clear();
this.#workersByPeer.clear();
this.#workersBySession.clear();
this.workerPids.clear();
this.#discoveryPeers = undefined;
this.#resolveDiscovery?.();
this.#resolveDiscovery = undefined;
}
}
function sameStrings(left: readonly string[], right: readonly string[]): boolean {
return left.length === right.length && left.every((value, index) => value === right[index]);
}
function sameScope(left: WorkerOperationScope, right: WorkerOperationScope): boolean {
return left.serverConnectionId === right.serverConnectionId && left.attachmentId === right.attachmentId;
}
function scopedServiceSubscriptionKey(scope: WorkerOperationScope, subscriptionId: string): string {
return `${scope.serverConnectionId}\0${scope.attachmentId}\0${subscriptionId}`;
}
function abortError(signal: AbortSignal): Error {
const reason: unknown = signal.reason;
return reason instanceof Error ? reason : new DOMException("The operation was aborted", "AbortError");
}

View file

@ -1,840 +0,0 @@
import { createConnection, type Socket } from "node:net";
import { isAbsolute } from "node:path";
import {
type AttachedReplicatedState,
isJsonValue,
type JsonValue,
parseServiceProviderUpdate,
REMOTE_SERVICE_ERROR_CODES,
RemoteServiceError,
type RemoteServiceErrorCode,
type ServiceCall,
type ServiceProviderUpdate,
} from "@earendil-works/chord";
import { BACKGROUND_CONTEXT, TODO_CONTEXT, withCancel } from "@earendil-works/chord/context";
import { Harness, ROOT_CONVERSATION_ID, type TaskGraph } from "@earendil-works/pi-durable";
import { openNodeSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite/node";
import lockfile from "proper-lockfile";
import Type, { type Static } from "typebox";
import { Check } from "typebox/value";
import { ModelRuntime } from "../core/model-runtime.ts";
import { SettingsManager } from "../core/settings-manager.ts";
import { COORDINATOR_PROTOCOL_VERSION } from "./coordinator.ts";
import {
configureHarnessHttp,
createCodingRegistry,
createHarnessSettings,
ExecutionEnvs,
findInitialAgentModel,
} from "./durable/harness-setup.ts";
import { createSessionPluginFacetLoader } from "./plugins/bundled.ts";
import {
consumeInternalProcessRole,
encodeControlLine,
isDirectInternalProcessEntry,
MAX_CONTROL_LINE_BYTES,
} from "./process.ts";
import {
createSessionWorkerServices,
type SessionWorkerRuntime,
type SessionWorkerServices,
type WorkerServiceScope,
} from "./services/worker.ts";
import { sessionStoragePath } from "./session-catalog.ts";
export type { SessionWorkerRuntime } from "./services/worker.ts";
const StrictObject = <const T extends Parameters<typeof Type.Object>[0]>(properties: T) =>
Type.Object(properties, { additionalProperties: false });
const OpaqueJsonValueSchema = Type.Unsafe<JsonValue>(Type.Unknown());
const ServiceCallSchema = Type.Unsafe<ServiceCall>(
StrictObject({
serviceId: Type.String({ minLength: 1 }),
instance: Type.Optional(
StrictObject({ key: Type.String({ minLength: 1 }), generation: Type.Integer({ minimum: 1 }) }),
),
member: Type.String({ minLength: 1 }),
args: Type.Array(Type.Unknown()),
}),
);
const RemoteServiceErrorCodeSchema = Type.Unsafe<RemoteServiceErrorCode>(
Type.String({ pattern: `^(?:${REMOTE_SERVICE_ERROR_CODES.join("|")})$` }),
);
export const SESSION_WORKER_CONTROL_ADDRESS_ENV = "PI_SESSION_WORKER_CONTROL_ADDRESS";
export const SESSION_WORKER_CONTROL_TOKEN_ENV = "PI_SESSION_WORKER_CONTROL_TOKEN";
export const SESSION_WORKER_SESSION_KEY_ENV = "PI_SESSION_WORKER_SESSION_KEY_BASE64";
export const SESSION_WORKER_PEER_ID_ENV = "PI_SESSION_WORKER_PEER_ID";
export const SessionWorkerMetadataSchema = StrictObject({
id: Type.String({ minLength: 1 }),
createdAt: Type.Number(),
cwd: Type.String(),
path: Type.String(),
});
export const SessionWorkerOptionsSchema = StrictObject({
sessionDir: Type.String({ minLength: 1 }),
metadata: SessionWorkerMetadataSchema,
provider: Type.Optional(Type.String({ minLength: 1 })),
model: Type.Optional(Type.String({ minLength: 1 })),
pluginManifestPaths: Type.Array(Type.String({ minLength: 1 })),
});
export type SessionWorkerOptions = Static<typeof SessionWorkerOptionsSchema>;
export const WorkerOperationScopeSchema = StrictObject({
serverConnectionId: Type.String(),
attachmentId: Type.String(),
});
export type WorkerOperationScope = WorkerServiceScope;
export const WorkerOperationRequestSchema = StrictObject({
type: Type.Literal("operation"),
requestId: Type.String({ minLength: 1 }),
scope: WorkerOperationScopeSchema,
call: ServiceCallSchema,
});
export type WorkerOperationRequest = Static<typeof WorkerOperationRequestSchema>;
export const WorkerOperationResponseSchema = Type.Union([
StrictObject({
type: Type.Literal("operation_result"),
requestId: Type.String({ minLength: 1 }),
scope: WorkerOperationScopeSchema,
result: Type.Optional(OpaqueJsonValueSchema),
}),
StrictObject({
type: Type.Literal("operation_error"),
requestId: Type.String({ minLength: 1 }),
scope: WorkerOperationScopeSchema,
code: Type.Optional(RemoteServiceErrorCodeSchema),
message: Type.String(),
}),
]);
export type WorkerOperationResponse = Static<typeof WorkerOperationResponseSchema>;
export const SessionWorkerCommandSchema = Type.Union([
Type.Object({ type: Type.Literal("shutdown") }),
Type.Object({ type: Type.Literal("discover_workers") }),
Type.Object({
type: Type.Literal("session_demand"),
serverConnectionId: Type.String(),
requestId: Type.String(),
attachmentId: Type.String(),
attached: Type.Boolean(),
}),
WorkerOperationRequestSchema,
StrictObject({
type: Type.Literal("operation_cancel"),
requestId: Type.String({ minLength: 1 }),
scope: WorkerOperationScopeSchema,
}),
]);
export type SessionWorkerCommand = Static<typeof SessionWorkerCommandSchema>;
export const SessionWorkerEventSchema = Type.Union([
Type.Object({
type: Type.Literal("worker_ready"),
token: Type.String(),
sessionKey: Type.String(),
sessionId: Type.String(),
pid: Type.Integer({ minimum: 1 }),
metadata: SessionWorkerMetadataSchema,
pluginManifestPaths: Type.Array(Type.String({ minLength: 1 })),
}),
Type.Object({
type: Type.Literal("worker_failed"),
token: Type.String(),
sessionKey: Type.String(),
message: Type.String(),
}),
Type.Object({
type: Type.Literal("demand_applied"),
token: Type.String(),
sessionKey: Type.String(),
requestId: Type.String(),
attachmentId: Type.String(),
attached: Type.Boolean(),
}),
Type.Object({
type: Type.Literal("demand_rejected"),
token: Type.String(),
sessionKey: Type.String(),
requestId: Type.String(),
message: Type.String(),
}),
Type.Object({
type: Type.Literal("operation_response"),
token: Type.String(),
sessionKey: Type.String(),
response: WorkerOperationResponseSchema,
}),
Type.Object({
type: Type.Literal("service_update"),
token: Type.String(),
sessionKey: Type.String(),
scope: WorkerOperationScopeSchema,
subscriptionId: Type.String({ minLength: 1 }),
update: Type.Unknown(),
}),
]);
export type SessionWorkerEvent = Static<typeof SessionWorkerEventSchema>;
/** Worker-local reconciliation of server-generation demand and Harness activity. */
export class WorkerLifecycle {
readonly #initialDemandGraceMs: number;
readonly #orphanDemandGraceMs: number;
readonly #onRetire: () => void;
readonly #demands = new Map<string, { serverConnectionId: string; attachmentId: string; timer?: NodeJS.Timeout }>();
#harnessActive = false;
#currentServerConnectionId: string | undefined;
#initialTimer: NodeJS.Timeout | undefined;
#demandInitialized: boolean;
#retirementHolds = 0;
#retiring = false;
constructor(options: {
initialServerConnectionId?: string;
initialDemandGraceMs: number;
orphanDemandGraceMs: number;
onRetire(): void;
}) {
this.#currentServerConnectionId = options.initialServerConnectionId;
this.#initialDemandGraceMs = options.initialDemandGraceMs;
this.#orphanDemandGraceMs = options.orphanDemandGraceMs;
this.#onRetire = options.onRetire;
this.#demandInitialized = false;
this.#initialTimer = setTimeout(() => {
this.#initialTimer = undefined;
this.#demandInitialized = true;
this.#reconcile();
}, this.#initialDemandGraceMs);
this.#initialTimer.unref();
}
serverConnected(serverConnectionId: string): void {
this.#currentServerConnectionId = serverConnectionId;
for (const demand of this.#demands.values()) {
if (demand.serverConnectionId !== serverConnectionId || !demand.timer) continue;
clearTimeout(demand.timer);
delete demand.timer;
}
}
serverDisconnected(serverConnectionId: string): void {
if (this.#currentServerConnectionId === serverConnectionId) this.#currentServerConnectionId = undefined;
for (const [key, demand] of this.#demands) {
if (demand.serverConnectionId !== serverConnectionId || demand.timer) continue;
demand.timer = setTimeout(() => {
if (this.#demands.get(key) !== demand) return;
this.#demands.delete(key);
this.#reconcile();
}, this.#orphanDemandGraceMs);
demand.timer.unref();
}
}
beginRequest(serverConnectionId: string, attachmentId: string): () => void {
if (this.#retiring) throw new Error("Session worker is retiring");
if (serverConnectionId !== this.#currentServerConnectionId) {
throw new Error("Session worker received a request from a stale server generation");
}
const demand = this.#demands.get(demandKey(serverConnectionId, attachmentId));
if (!demand || demand.timer) {
throw new Error("Session worker request does not match the active attachment");
}
return this.holdRetirement();
}
holdRetirement(): () => void {
this.#retirementHolds += 1;
let released = false;
return () => {
if (released) return;
released = true;
this.#retirementHolds -= 1;
this.#reconcile();
};
}
setDemand(serverConnectionId: string, attachmentId: string, attached: boolean): void {
if (this.#retiring) throw new Error("Session worker is retiring");
if (serverConnectionId !== this.#currentServerConnectionId) {
throw new Error("Session worker received demand from a stale server generation");
}
this.#demandInitialized = true;
if (this.#initialTimer) {
clearTimeout(this.#initialTimer);
this.#initialTimer = undefined;
}
const key = demandKey(serverConnectionId, attachmentId);
const previous = this.#demands.get(key);
if (previous?.timer) clearTimeout(previous.timer);
if (attached) this.#demands.set(key, { serverConnectionId, attachmentId });
else this.#demands.delete(key);
this.#reconcile();
}
/** Live Harness tasks, such as runs and compactions, hold the worker until they end. */
setHarnessActive(active: boolean): void {
this.#harnessActive = active;
if (!active) this.#reconcile();
}
close(): void {
if (this.#initialTimer) clearTimeout(this.#initialTimer);
for (const demand of this.#demands.values()) {
if (demand.timer) clearTimeout(demand.timer);
}
this.#demands.clear();
}
#reconcile(): void {
if (
this.#retiring ||
!this.#demandInitialized ||
this.#retirementHolds !== 0 ||
this.#harnessActive ||
this.#demands.size !== 0
) {
return;
}
this.#retiring = true;
this.#onRetire();
}
}
const DEFAULT_INITIAL_DEMAND_GRACE_MS = 10_000;
const DEFAULT_ORPHAN_DEMAND_GRACE_MS = 30_000;
export const SESSION_WORKER_INITIAL_DEMAND_GRACE_ENV = "__PI_SESSION_WORKER_INITIAL_DEMAND_GRACE_MS";
export const SESSION_WORKER_ORPHAN_DEMAND_GRACE_ENV = "__PI_SESSION_WORKER_ORPHAN_DEMAND_GRACE_MS";
const CoordinatorInputSchema = Type.Union([
Type.Object({
type: Type.Literal("peer_registered"),
peerId: Type.String(),
serverConnectionId: Type.Optional(Type.String()),
}),
Type.Object({ type: Type.Literal("server_connected"), serverConnectionId: Type.String() }),
Type.Object({ type: Type.Literal("server_disconnected"), serverConnectionId: Type.String() }),
Type.Object({ type: Type.Literal("message"), from: Type.Literal("server"), payload: Type.Unknown() }),
]);
type CoordinatorInput = Static<typeof CoordinatorInputSchema>;
interface WorkerControl {
readonly initialServerConnectionId?: string;
readonly messages: AsyncIterable<unknown>;
readonly socket: Socket;
send(event: SessionWorkerEvent): Promise<void>;
}
let failureControl: WorkerControl | undefined;
async function connectControl(): Promise<WorkerControl> {
const address = process.env[SESSION_WORKER_CONTROL_ADDRESS_ENV];
const token = process.env[SESSION_WORKER_CONTROL_TOKEN_ENV];
const encodedSessionKey = process.env[SESSION_WORKER_SESSION_KEY_ENV];
if (!address || !token || !encodedSessionKey) throw new Error("Session worker requires a control address");
const peerId = process.env[SESSION_WORKER_PEER_ID_ENV];
if (!peerId) throw new Error("Session worker requires a peer ID");
const socket = createConnection(address);
await new Promise<void>((resolve, reject) => {
socket.once("connect", resolve);
socket.once("error", reject);
});
const messages = createJsonLineMessages(socket);
await writeJsonLine(socket, { type: "register_peer", protocol: COORDINATOR_PROTOCOL_VERSION, peerId });
const registered = await messages[Symbol.asyncIterator]().next();
if (
registered.done ||
!Check(CoordinatorInputSchema, registered.value) ||
registered.value.type !== "peer_registered"
) {
throw new Error("Coordinator rejected the session worker registration");
}
return {
...(registered.value.serverConnectionId === undefined
? {}
: { initialServerConnectionId: registered.value.serverConnectionId }),
messages,
socket,
send: (event) => writeJsonLine(socket, { type: "send", to: "server", payload: event }),
};
}
async function readCommands(
control: WorkerControl,
handlers: {
onShutdown(): void;
onDiscovery(): void;
onDemand(command: Extract<SessionWorkerCommand, { type: "session_demand" }>): Promise<void>;
onOperation(command: WorkerOperationRequest): void;
onOperationCancel(command: Extract<SessionWorkerCommand, { type: "operation_cancel" }>): void;
onServerConnected(serverConnectionId: string): void;
onServerDisconnected(serverConnectionId: string): void;
},
): Promise<void> {
for await (const value of control.messages) {
if (!Check(CoordinatorInputSchema, value)) {
control.socket.destroy(new Error("Coordinator sent an invalid worker message"));
return;
}
const message: CoordinatorInput = value;
if (message.type === "server_connected") {
handlers.onServerConnected(message.serverConnectionId);
continue;
}
if (message.type === "server_disconnected") {
handlers.onServerDisconnected(message.serverConnectionId);
continue;
}
if (message.type !== "message" || !Check(SessionWorkerCommandSchema, message.payload)) continue;
const command: SessionWorkerCommand = message.payload;
if (command.type === "shutdown") handlers.onShutdown();
else if (command.type === "discover_workers") handlers.onDiscovery();
else if (command.type === "session_demand") await handlers.onDemand(command);
else if (command.type === "operation_cancel") handlers.onOperationCancel(command);
else handlers.onOperation(command);
}
}
function createJsonLineMessages(socket: Socket): AsyncIterable<unknown> {
const queued: unknown[] = [];
const waiters: ((value: unknown) => void)[] = [];
let buffered = "";
socket.setEncoding("utf8");
socket.on("data", (chunk: string) => {
buffered += chunk;
if (Buffer.byteLength(buffered) > MAX_CONTROL_LINE_BYTES) {
socket.destroy(new Error("Session worker control message is too large"));
return;
}
while (true) {
const newline = buffered.indexOf("\n");
if (newline === -1) return;
const line = buffered.slice(0, newline);
buffered = buffered.slice(newline + 1);
try {
const value: unknown = JSON.parse(line);
const waiter = waiters.shift();
if (waiter) waiter(value);
else queued.push(value);
} catch {
socket.destroy(new Error("Session worker received invalid control JSON"));
return;
}
}
});
return {
[Symbol.asyncIterator]() {
return {
next: async () => {
const value = queued.shift() ?? (await new Promise<unknown>((resolve) => waiters.push(resolve)));
return { done: false as const, value };
},
};
},
};
}
function writeJsonLine(socket: Socket, message: unknown): Promise<void> {
return new Promise((resolve, reject) => {
socket.write(encodeControlLine(message), (error) => {
if (error) reject(error);
else resolve();
});
});
}
function toWorkerServiceUpdate(update: ServiceProviderUpdate): ServiceProviderUpdate {
if (!isJsonValue(update)) throw new Error("Service produced a non-JSON update");
return parseServiceProviderUpdate(update);
}
function demandKey(serverConnectionId: string, attachmentId: string): string {
return `${serverConnectionId}\0${attachmentId}`;
}
function sameScope(left: WorkerOperationScope, right: WorkerOperationScope): boolean {
return left.serverConnectionId === right.serverConnectionId && left.attachmentId === right.attachmentId;
}
function lifecycleDelay(name: string, fallback: number): number {
const value = process.env[name];
if (value === undefined) return fallback;
const parsed = Number(value);
if (!Number.isSafeInteger(parsed) || parsed < 0) throw new Error(`${name} must be a non-negative safe integer`);
return parsed;
}
async function closeResources(resources: {
runtime?: SessionWorkerRuntime;
services?: SessionWorkerServices;
releaseOwnership: () => Promise<void>;
}): Promise<void> {
const errors: unknown[] = [];
try {
await resources.services?.dispose();
} catch (error) {
errors.push(error);
}
try {
await resources.runtime?.harness.close(TODO_CONTEXT);
} catch (error) {
errors.push(error);
}
try {
await resources.runtime?.cleanup?.(TODO_CONTEXT);
} catch (error) {
errors.push(error);
}
try {
await resources.releaseOwnership();
} catch (error) {
errors.push(error);
}
if (errors.length === 1) throw errors[0];
if (errors.length > 1) throw new AggregateError(errors, "Session worker cleanup failed");
}
/**
* Open the Session's Harness over `databasePath` and return it with its root conversation. Called while the worker
* holds the Session lock; on failure it closes whatever it opened.
*/
export type CreateSessionWorkerHarness = (
databasePath: string,
options: SessionWorkerOptions,
) => Promise<SessionWorkerRuntime>;
async function run(options: SessionWorkerOptions, createHarness: CreateSessionWorkerHarness): Promise<void> {
const { metadata } = options;
const sessionId = metadata.id;
const control = await connectControl();
const token = process.env[SESSION_WORKER_CONTROL_TOKEN_ENV]!;
const sessionKey = Buffer.from(process.env[SESSION_WORKER_SESSION_KEY_ENV]!, "base64url").toString();
failureControl = control;
const pluginManifestPaths = options.pluginManifestPaths;
const releaseOwnership = await lockfile.lock(metadata.path, {
realpath: true,
stale: 2_000,
update: 1_000,
retries: { retries: 320, factor: 1, minTimeout: 25, maxTimeout: 25, maxRetryTime: 8_000 },
});
let runtime: SessionWorkerRuntime | undefined;
let services: SessionWorkerServices | undefined;
let taskGraph: AttachedReplicatedState<TaskGraph> | undefined;
try {
runtime = await createHarness(sessionStoragePath(metadata), options);
taskGraph = await runtime.harness.taskGraph(TODO_CONTEXT);
services = await createSessionWorkerServices({
harness: runtime.harness,
conversation: runtime.conversation,
modelRuntime: runtime.modelRuntime,
settingsManager: runtime.settingsManager,
facetLoader: runtime.facetLoader,
publish: (scope, subscriptionId, update) =>
control.send({
type: "service_update",
token,
sessionKey,
scope,
subscriptionId,
update: toWorkerServiceUpdate(update),
}),
});
} catch (error) {
taskGraph?.dispose();
try {
await closeResources({ runtime, services, releaseOwnership });
} catch (cleanupError) {
throw new AggregateError([error, cleanupError], "Session worker startup and cleanup failed");
}
throw error;
}
const activeRequests = new Map<
string,
{ readonly scope: WorkerOperationScope; readonly cancel: (reason?: unknown) => void }
>();
const harness = runtime.harness;
const activity = taskGraph;
let lifecycle: WorkerLifecycle | undefined;
let stopActivity = (): void => {};
let closing: Promise<void> | undefined;
const close = (): Promise<void> => {
if (closing) return closing;
lifecycle?.close();
stopActivity();
activity.dispose();
services.removeSubscriptions(() => true);
for (const request of activeRequests.values()) request.cancel(new Error("Session worker is closing"));
activeRequests.clear();
closing = closeResources({ runtime, services, releaseOwnership });
return closing;
};
const closeAndExit = (): void => {
void close().then(
() => process.exit(0),
(error: unknown) => {
console.error(error);
process.exit(1);
},
);
};
lifecycle = new WorkerLifecycle({
initialServerConnectionId: control.initialServerConnectionId,
initialDemandGraceMs: lifecycleDelay(SESSION_WORKER_INITIAL_DEMAND_GRACE_ENV, DEFAULT_INITIAL_DEMAND_GRACE_MS),
orphanDemandGraceMs: lifecycleDelay(SESSION_WORKER_ORPHAN_DEMAND_GRACE_ENV, DEFAULT_ORPHAN_DEMAND_GRACE_MS),
onRetire: closeAndExit,
});
// Every live task, background work included, keeps the worker and its Session open.
stopActivity = activity.subscribe((graph) => lifecycle?.setHarnessActive(Object.keys(graph.tasks).length > 0));
// Recovered work from an interrupted turn continues now.
harness.resume();
const handleOperation = async (request: WorkerOperationRequest): Promise<void> => {
let releaseRequest = (): void => {};
const cancellable = withCancel(BACKGROUND_CONTEXT);
try {
releaseRequest = lifecycle!.beginRequest(request.scope.serverConnectionId, request.scope.attachmentId);
activeRequests.set(request.requestId, { scope: request.scope, cancel: cancellable.cancel });
const result = await services.invoke(request.call, request.scope, cancellable.context);
if (result !== undefined && !isJsonValue(result)) throw new Error("Service produced a non-JSON result");
await control.send({
type: "operation_response",
token,
sessionKey,
response: {
type: "operation_result",
requestId: request.requestId,
scope: request.scope,
...(result === undefined ? {} : { result }),
},
});
} catch (error) {
let code: RemoteServiceErrorCode | undefined;
if (error instanceof RemoteServiceError) {
code = error.code;
} else if (error instanceof Error && "code" in error) {
const candidate = error.code;
if (Check(RemoteServiceErrorCodeSchema, candidate)) code = candidate;
}
await control.send({
type: "operation_response",
token,
sessionKey,
response: {
type: "operation_error",
requestId: request.requestId,
scope: request.scope,
...(code === undefined ? {} : { code }),
message: error instanceof Error ? error.message : String(error),
},
});
} finally {
if (activeRequests.get(request.requestId)?.cancel === cancellable.cancel) {
activeRequests.delete(request.requestId);
}
releaseRequest();
}
};
let ready = false;
const announce = (): void => {
if (!ready) return;
void control
.send({
type: "worker_ready",
token,
sessionKey,
sessionId,
pid: process.pid,
metadata,
pluginManifestPaths: [...pluginManifestPaths],
})
.catch(() => closeAndExit());
};
void readCommands(control, {
onShutdown: closeAndExit,
onDiscovery: announce,
onDemand: async (command) => {
const releaseRetirement = lifecycle?.holdRetirement() ?? (() => {});
try {
try {
if (!command.attached) {
const matches = (scope: WorkerOperationScope): boolean =>
scope.serverConnectionId === command.serverConnectionId &&
scope.attachmentId === command.attachmentId;
services.removeSubscriptions(matches);
}
lifecycle?.setDemand(command.serverConnectionId, command.attachmentId, command.attached);
} catch (error) {
await control.send({
type: "demand_rejected",
token,
sessionKey,
requestId: command.requestId,
message: error instanceof Error ? error.message : String(error),
});
return;
}
await control.send({
type: "demand_applied",
token,
sessionKey,
requestId: command.requestId,
attachmentId: command.attachmentId,
attached: command.attached,
});
} finally {
releaseRetirement();
}
},
onOperation: (request) => {
void handleOperation(request).catch(() => closeAndExit());
},
onOperationCancel: (command) => {
const active = activeRequests.get(command.requestId);
if (active !== undefined && sameScope(active.scope, command.scope)) {
active.cancel(new DOMException("Service operation cancelled", "AbortError"));
}
},
onServerConnected: (serverConnectionId) => lifecycle?.serverConnected(serverConnectionId),
onServerDisconnected: (serverConnectionId) => {
const matches = (scope: WorkerOperationScope): boolean => scope.serverConnectionId === serverConnectionId;
services.removeSubscriptions(matches);
for (const request of activeRequests.values()) {
if (matches(request.scope)) request.cancel(new Error("Server disconnected"));
}
lifecycle?.serverDisconnected(serverConnectionId);
},
}).catch(() => closeAndExit());
control.socket.once("close", closeAndExit);
control.socket.once("error", () => closeAndExit());
process.once("SIGTERM", closeAndExit);
process.once("SIGINT", closeAndExit);
try {
ready = true;
await control.send({
type: "worker_ready",
token,
sessionKey,
sessionId,
pid: process.pid,
metadata,
pluginManifestPaths: [...pluginManifestPaths],
});
} catch (error) {
try {
await close();
} catch (cleanupError) {
throw new AggregateError([error, cleanupError], "Session worker readiness and cleanup failed");
}
throw error;
}
}
export async function runSessionWorkerWithHarness(
args: readonly string[],
createHarness: CreateSessionWorkerHarness,
): Promise<void> {
try {
if (args.length !== 1) throw new Error("Session worker requires one options argument");
let options: unknown;
try {
options = JSON.parse(args[0]!);
} catch (error) {
throw new Error("Session worker received invalid options", { cause: error });
}
if (
!Check(SessionWorkerOptionsSchema, options) ||
!isAbsolute(options.sessionDir) ||
!isAbsolute(options.metadata.cwd) ||
!isAbsolute(options.metadata.path) ||
(options.provider !== undefined && options.model === undefined)
) {
throw new Error("Session worker received invalid options");
}
await run(options, createHarness);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const token = process.env[SESSION_WORKER_CONTROL_TOKEN_ENV];
const encodedSessionKey = process.env[SESSION_WORKER_SESSION_KEY_ENV];
if (token && encodedSessionKey) {
const sessionKey = Buffer.from(encodedSessionKey, "base64url").toString();
await failureControl?.send({ type: "worker_failed", token, sessionKey, message }).catch(() => {});
}
throw error;
}
}
async function createCodingAgentHarness(
databasePath: string,
options: SessionWorkerOptions,
): Promise<SessionWorkerRuntime> {
const { cwd } = options.metadata;
const modelRuntime = await ModelRuntime.create();
const settingsManager = SettingsManager.create(cwd);
configureHarnessHttp(settingsManager);
const envs = new ExecutionEnvs(cwd);
let harness: Harness | undefined;
try {
harness = await Harness.open(
await openNodeSqliteStorage(databasePath),
{
models: modelRuntime,
registry: createCodingRegistry(settingsManager, cwd),
settings: createHarnessSettings(settingsManager),
env: envs.env,
onReport: (error) => console.error(error),
},
TODO_CONTEXT,
);
// The initial model applies only when the root conversation is created; later starts keep its durable model.
const created = (await harness.conversation(ROOT_CONVERSATION_ID, TODO_CONTEXT)) === undefined;
const initial = created
? await findInitialAgentModel(
settingsManager,
modelRuntime,
options.model === undefined ? undefined : { provider: options.provider, model: options.model },
)
: undefined;
const conversation = await harness.root(TODO_CONTEXT, {
agent: {
cwd,
...(initial?.model === undefined ? {} : { model: initial.model }),
...(initial?.thinkingLevel === undefined ? {} : { thinkingLevel: initial.thinkingLevel }),
},
});
return {
harness,
conversation,
modelRuntime,
settingsManager,
facetLoader: createSessionPluginFacetLoader(options.pluginManifestPaths),
cleanup: (context) => envs.cleanup(context),
};
} catch (error) {
try {
await harness?.close(TODO_CONTEXT);
await envs.cleanup(TODO_CONTEXT);
} catch (cleanupError) {
throw new AggregateError([error, cleanupError], "Session worker Harness startup and cleanup failed");
}
throw error;
}
}
export function runSessionWorkerProcess(args: readonly string[]): Promise<void> {
return runSessionWorkerWithHarness(args, createCodingAgentHarness);
}
if (isDirectInternalProcessEntry(import.meta.url)) {
const role = consumeInternalProcessRole();
if (role !== "session-worker") {
throw new Error("Session worker entrypoint requires an internal session-worker invocation");
}
void runSessionWorkerProcess(process.argv.slice(2)).catch(() => process.exit(1));
}

View file

@ -1,53 +0,0 @@
# vacation
A durable vacation planning agent with a TUI, built on `@earendil-works/pi-durable`. It is a copy of the durable coding
agent in [`../durable`](../durable) with the coding tools and pi's coding prompt replaced by a vacation planner. It only
looks like a coding agent because it reuses pi's interactive TUI components.
From a pi checkout, after `npm install` and `npm run build`:
```bash
node packages/coding-agent/src/experimental/vacation/main.ts
node packages/coding-agent/src/experimental/vacation/main.ts --continue
```
Without a build, preload the source resolver so the workspace packages load from `src` instead of `dist`:
```bash
node --import ./packages/coding-agent/src/experimental/source-resolver.ts packages/coding-agent/src/experimental/vacation/main.ts
```
A new session starts with pi's default model and thinking level from `settings.json`. `--continue` opens the newest
session for the current directory. Sessions live under
`~/.pi/agent/experimental/vacation-sessions/<cwd-hash>/<session>/session.sqlite`. Log in with pi itself; credentials are
shared.
## What it shows
- **Background subagent:** the `research` tool starts a subagent in its own conversation and returns at once. A
background task delivers the request and posts the subagent's report back to the main conversation as a new message,
so you keep chatting with the main agent while the research runs.
- **Parallel durable tool calls:** the subagent's `search` calls run in parallel, each as its own task in the task
panel. The results are canned; each topic takes a different time.
- **Recovery:** `search` is safe to rerun. Kill the process while a search runs and start it again with `--continue`:
finished searches are kept, the unfinished one runs again, and the report still arrives. Request IDs keep a restart
from sending the request or the report twice.
- **Everything else from the durable coding agent:** steering, follow-ups, `/agents`, `/compact`, `/tasks`, `/model`.
## Try it
1. "Plan a weekend in Vienna for two. Hand the research to a subagent."
2. While the searches run, ask the main agent something else.
3. Ctrl+C while only the trains search is left, then start again with `--continue`.
4. `/agents` to watch or steer the subagent, `/agents` again to go back to main.
## Layout
| file | role |
| --- | --- |
| `vacation.ts` | the vacation extensions: `search`, `research`, the report task, and the prompt |
| `main.ts` | arguments, open, run, close |
| `sessions.ts` | session directories and the lock |
| `harness-setup.ts` | HTTP setup, settings, the registry, the initial model |
| `runtime.ts` | Harness and the plain `DurableView` and `DurableController` |
| `tui.ts` | rendering with pi's interactive components |

View file

@ -1,89 +0,0 @@
import type { ModelThinkingLevel } from "@earendil-works/pi-ai";
import { createRegistry, type HarnessSettings, type ModelRef, type Registry } from "@earendil-works/pi-durable";
import { applyHttpProxySettings, configureHttpDispatcher } from "../../core/http-dispatcher.ts";
import { findInitialModel, resolveCliModel } from "../../core/model-resolver.ts";
import type { ModelRuntime } from "../../core/model-runtime.ts";
import type { SettingsManager } from "../../core/settings-manager.ts";
import { Search, Vacation } from "./vacation.ts";
/** pi's HTTP setup: proxy, idle timeouts, and one undici for fetch. Without it, some provider streams break off. */
export function configureHarnessHttp(settingsManager: SettingsManager): void {
applyHttpProxySettings(settingsManager.getGlobalSettings().httpProxy);
configureHttpDispatcher(settingsManager.getHttpIdleTimeoutMs());
}
/** Harness settings read at every use from pi's settings as loaded at startup. */
export function createHarnessSettings(settingsManager: SettingsManager): HarnessSettings {
return {
get stream() {
const provider = settingsManager.getProviderRetrySettings();
const idle = settingsManager.getHttpIdleTimeoutMs();
return {
timeoutMs: provider.timeoutMs ?? (idle === 0 ? 2147483647 : idle),
maxRetryDelayMs: provider.maxRetryDelayMs,
...(provider.maxRetries === undefined ? {} : { maxRetries: provider.maxRetries }),
};
},
get compaction() {
return settingsManager.getCompactionSettings();
},
get retry() {
return settingsManager.getRetrySettings();
},
get steeringMode() {
return settingsManager.getSteeringMode();
},
get followUpMode() {
return settingsManager.getFollowUpMode();
},
};
}
/** A registry with the vacation planner and its research subagent's search. No coding tools, no pi prompt. */
export function createVacationRegistry(): Registry {
const registry = createRegistry();
registry.install(Vacation);
registry.install(Search);
return registry;
}
export interface InitialModel {
readonly model?: ModelRef;
readonly thinkingLevel?: ModelThinkingLevel;
readonly fallbackMessage?: string;
}
/** The model a new root conversation starts with: an explicit `--provider`/`--model`, or pi's default resolution. */
export async function findInitialAgentModel(
settingsManager: SettingsManager,
modelRuntime: ModelRuntime,
cli?: { readonly provider?: string; readonly model: string },
): Promise<InitialModel> {
if (cli !== undefined) {
const resolved = resolveCliModel({ cliProvider: cli.provider, cliModel: cli.model, modelRuntime });
if (resolved.error !== undefined || resolved.model === undefined) {
throw new Error(`Could not resolve model: ${resolved.error ?? cli.model}`);
}
return {
model: { provider: resolved.model.provider, modelId: resolved.model.id },
...(resolved.thinkingLevel === undefined ? {} : { thinkingLevel: resolved.thinkingLevel }),
};
}
const initial = await findInitialModel({
scopedModels: [],
isContinuing: false,
defaultProvider: settingsManager.getDefaultProvider(),
defaultModelId: settingsManager.getDefaultModel(),
defaultThinkingLevel: settingsManager.getDefaultThinkingLevel(),
modelRuntime,
});
return {
...(initial.model === undefined
? {}
: {
model: { provider: initial.model.provider, modelId: initial.model.id },
thinkingLevel: initial.thinkingLevel,
}),
...(initial.fallbackMessage === undefined ? {} : { fallbackMessage: initial.fallbackMessage }),
};
}

View file

@ -1,20 +0,0 @@
#!/usr/bin/env node
import { type OpenDurableOptions, openDurable } from "./runtime.ts";
import { runDurableTui } from "./tui.ts";
function parseArgs(argv: readonly string[]): OpenDurableOptions {
let continueSession = false;
for (const arg of argv) {
if (arg === "--continue" || arg === "-c") continueSession = true;
else throw new Error(`Unknown argument: ${arg}`);
}
return { continueSession };
}
const durable = await openDurable(parseArgs(process.argv.slice(2)));
try {
await runDurableTui(durable.view, durable.controller, durable.settings);
} finally {
await durable.close();
}

View file

@ -1,369 +0,0 @@
import type { AttachedReplicatedState } from "@earendil-works/chord";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { clampThinkingLevel, getSupportedThinkingLevels, type ModelThinkingLevel } from "@earendil-works/pi-ai";
import {
type AgentState,
type Conversation,
type ConversationId,
type ConversationView,
type Cursor,
type EntryRecord,
Harness,
type ModelRef,
ROOT_CONVERSATION_ID,
type Submission,
type TaskGraph,
} from "@earendil-works/pi-durable";
import { openNodeSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite/node";
import { ModelRuntime } from "../../core/model-runtime.ts";
import { SettingsManager } from "../../core/settings-manager.ts";
import {
configureHarnessHttp,
createHarnessSettings,
createVacationRegistry,
findInitialAgentModel,
} from "./harness-setup.ts";
import { selectSession } from "./sessions.ts";
import { Vacation } from "./vacation.ts";
const context = BACKGROUND_CONTEXT;
export interface ModelSummary extends ModelRef {
readonly name: string;
readonly contextWindow: number;
}
export interface Notice {
readonly id: number;
readonly level: "info" | "warning" | "error";
readonly message: string;
}
/** A conversation the user can switch to: the main one, or a subagent's. */
export interface ConversationSummary {
readonly id: ConversationId;
readonly label: string;
/** The first user message, for a subagent its task. */
readonly title?: string;
}
/** Everything the TUI renders. Plain values; no Harness objects cross this boundary. */
export interface DurableView {
readonly session: { readonly id: string; readonly directory: string; readonly cwd: string };
/** The conversation shown and talked to. */
readonly conversation: ConversationView;
readonly conversations: readonly ConversationSummary[];
readonly models: readonly ModelSummary[];
readonly notices: readonly Notice[];
/** The live task graph while the task panel is open. */
readonly tasks?: TaskGraph;
}
export interface DurableViewSource {
current(): DurableView;
subscribe(listener: () => void): () => void;
}
/** What the TUI may ask for. */
export interface DurableController {
/** Prompt when idle; otherwise steer or queue a follow-up. */
submit(text: string, whenBusy: "steer" | "followUp"): Promise<void>;
compact(instructions: string | undefined): Promise<void>;
abort(): Promise<void>;
cycleThinking(): Promise<void>;
setModel(model: ModelRef): Promise<void>;
toggleTasks(): Promise<void>;
/** Show and talk to another conversation. */
switchConversation(id: ConversationId): Promise<void>;
}
export interface OpenDurableOptions {
readonly cwd?: string;
readonly continueSession?: boolean;
}
export interface OpenDurableResult {
readonly view: DurableViewSource;
readonly controller: DurableController;
/** pi's settings, for the TUI's theme and terminal capabilities. */
readonly settings: SettingsManager;
close(): Promise<void>;
}
/** The agent document of a view; absent while the conversation has none. */
export function agentOf(view: ConversationView): AgentState {
return (view.docs["pi.agent"] ?? {}) as AgentState;
}
/** A subagent's task: the oldest user message of its conversation. The main conversation needs no title. */
async function firstInput(harness: Harness, id: ConversationId): Promise<{ title?: string }> {
if (id === ROOT_CONVERSATION_ID) return {};
const conversation = (await harness.conversation(id, context))!;
let first: EntryRecord | undefined;
let cursor: Cursor | undefined;
do {
const page = await conversation.entries({}, 256, cursor, context);
first = page.items.findLast(isInput) ?? first;
cursor = page.next;
} while (cursor !== undefined);
return titleOf(first);
}
/** The text of a user entry, as a one-line title. */
/** A placed input: `pi.user`, or an application entry whose model is one user message. */
function isInput(entry: EntryRecord): boolean {
if (entry.kind === "pi.user") return true;
return !entry.kind.startsWith("pi.") && entry.model?.length === 1 && entry.model[0]!.role === "user";
}
function titleOf(entry: EntryRecord | undefined): { title?: string } {
const message = entry?.model?.[0];
if (message?.role !== "user") return {};
const text =
typeof message.content === "string"
? message.content
: message.content.flatMap((block) => (block.type === "text" ? [block.text] : [])).join(" ");
return { title: text.replace(/\s+/g, " ").trim() };
}
export async function openDurable(options: OpenDurableOptions = {}): Promise<OpenDurableResult> {
const location = await selectSession(options.cwd ?? process.cwd(), options.continueSession ?? false);
let harness: Harness | undefined;
try {
const modelRuntime = await ModelRuntime.create();
const settingsManager = SettingsManager.create(location.cwd);
configureHarnessHttp(settingsManager);
const settings = createHarnessSettings(settingsManager);
const registry = createVacationRegistry();
const pendingReports: unknown[] = [];
let report: (error: unknown) => void = (error) => pendingReports.push(error);
harness = await Harness.open(
await openNodeSqliteStorage(location.database),
{
models: modelRuntime,
registry,
settings,
onReport: (error) => report(error),
},
context,
);
const initial = location.created ? await findInitialAgentModel(settingsManager, modelRuntime) : undefined;
const root = await harness.root(context, {
agent: {
// Only the vacation planner; the research subagent selects search itself.
extensions: [Vacation],
...(initial?.model === undefined ? {} : { model: initial.model }),
...(initial?.thinkingLevel === undefined ? {} : { thinkingLevel: initial.thinkingLevel }),
},
});
const label = (id: ConversationId): string => (id === root.id ? "main" : `subagent ${id}`);
const opened = harness;
const summaries: ConversationSummary[] = [];
let cursor: Cursor | undefined;
do {
const page = await opened.commit((tx) => tx.scanConversations({}, 256, cursor), context);
for (const { id } of page.items) summaries.push({ id, label: label(id), ...(await firstInput(opened, id)) });
cursor = page.next;
} while (cursor !== undefined);
let current: Conversation = root;
let conversation: AttachedReplicatedState<ConversationView> = await root.viewState(context);
const models = (): ModelSummary[] =>
modelRuntime.getAvailableSnapshot().map((model) => ({
provider: model.provider,
modelId: model.id,
name: model.name,
contextWindow: model.contextWindow,
}));
let state: DurableView = {
session: { id: location.id, directory: location.directory, cwd: location.cwd },
conversation: conversation.value,
conversations: summaries,
models: models(),
notices: [],
};
const listeners = new Set<() => void>();
let notifying = false;
// Commit listeners and Chord frames call this on the Session line; rendering runs afterwards, once per burst.
const update = (patch: Partial<DurableView>): void => {
state = { ...state, ...patch };
if (notifying) return;
notifying = true;
setImmediate(() => {
notifying = false;
for (const listener of listeners) listener();
});
};
let nextNotice = 1;
const notice = (level: Notice["level"], message: string): void => {
update({ notices: [...state.notices, { id: nextNotice++, level, message }].slice(-20) });
};
const fail = (error: unknown): void => notice("error", error instanceof Error ? error.message : String(error));
report = (error) => notice("warning", error instanceof Error ? error.message : String(error));
for (const error of pendingReports) report(error);
let unsubscribe = conversation.subscribe((value) => update({ conversation: value }));
// Subagents appear as their conversations are created. A commit listener only records; it calls no Session API.
const unsubscribeCommits = harness.subscribeCommits((publication) => {
let conversations = state.conversations;
for (const change of publication.changes) {
if (change.type === "conversation") {
conversations = [...conversations, { id: change.value.id, label: label(change.value.id) }];
} else if (change.type === "entry" && isInput(change.value)) {
const id = change.value.conversationId;
conversations = conversations.map((summary) =>
summary.id === id && summary.title === undefined ? { ...summary, ...titleOf(change.value) } : summary,
);
}
}
if (conversations !== state.conversations) update({ conversations });
});
let tasks: AttachedReplicatedState<TaskGraph> | undefined;
let unsubscribeTasks = (): void => {};
const closeTasks = (): void => {
unsubscribeTasks();
tasks?.dispose();
tasks = undefined;
};
let queue = Promise.resolve();
// One at a time, so toggles, switches, and key presses apply in order.
const command = (operation: () => Promise<void>): Promise<void> => {
queue = queue.then(operation).catch(fail);
return queue;
};
const watchAnswer = (submission: Submission): void => {
void submission.wait(context).then((settled) => {
if (settled.status === "unanswered" && settled.reason !== "aborted") {
notice(
"error",
`No answer: ${settled.reason}${settled.detail === undefined ? "" : ` ${JSON.stringify(settled.detail)}`}`,
);
}
}, fail);
};
const agentModel = () => {
const ref = agentOf(state.conversation).model;
const model = ref === undefined ? undefined : modelRuntime.getModel(ref.provider, ref.modelId);
if (model === undefined)
throw new Error(ref === undefined ? "No model selected" : "Current model is unavailable");
return model;
};
const controller: DurableController = {
submit: (text, whenBusy) =>
command(async () => watchAnswer(await current.submit({ type: "input", content: text, whenBusy }, context))),
compact: (instructions) =>
command(async () => {
const id = await current.compact(instructions, context);
// Report the outcome once it is known; the status line shows the compaction meanwhile.
void opened.waitForTask(id, context).then(async (receipt) => {
const outcome = receipt.state.outcome;
if (outcome.status === "completed") {
const { entryId, submissionId } = outcome.result;
// A summary written while busy is a submission: placed now, queued, or dropped as stale.
const status =
submissionId === undefined
? undefined
: (await (await opened.submission(submissionId, context))?.status(context))?.status;
notice(
"info",
entryId !== undefined || status === "done"
? "Compacted."
: status === "queued"
? "Compaction summary queued; it is placed at the next turn boundary."
: status === "unanswered"
? "Compaction summary dropped: the context changed under it."
: "Nothing to compact: the context fits in the recent window that is kept verbatim.",
);
} else if (outcome.status === "aborted") notice("info", "Compaction aborted.");
else
notice("error", `Compaction ${outcome.status}: ${outcome.error?.message ?? outcome.reason ?? ""}`);
}, fail);
}),
// Not queued: it waits until the conversation is idle.
abort: () => current.abort(context).catch(fail),
cycleThinking: () =>
command(async () => {
const model = agentModel();
if (!model.reasoning) throw new Error("Current model does not support thinking");
const levels = getSupportedThinkingLevels(model);
const level = agentOf(state.conversation).thinkingLevel ?? "off";
const next = levels[(levels.indexOf(level) + 1) % levels.length] ?? "off";
await current.configure({ thinkingLevel: next }, context);
}),
setModel: (ref) =>
command(async () => {
const model = modelRuntime.getModel(ref.provider, ref.modelId);
if (model === undefined) throw new Error(`Unknown model: ${ref.provider}/${ref.modelId}`);
const thinking: ModelThinkingLevel = agentOf(state.conversation).thinkingLevel ?? "off";
await current.configure({ model: ref, thinkingLevel: clampThinkingLevel(model, thinking) }, context);
}),
toggleTasks: () =>
command(async () => {
if (tasks !== undefined) {
closeTasks();
update({ tasks: undefined });
return;
}
const graph = await opened.taskGraph(context);
tasks = graph;
unsubscribeTasks = graph.subscribe((value) => update({ tasks: value }));
}),
switchConversation: (id) =>
command(async () => {
const next = await opened.conversation(id, context);
if (next === undefined) throw new Error(`Conversation ${id} does not exist`);
const nextState = await next.viewState(context);
unsubscribe();
conversation.dispose();
current = next;
conversation = nextState;
unsubscribe = nextState.subscribe((value) => update({ conversation: value }));
}),
};
const saved = agentOf(state.conversation).model;
if (saved === undefined) notice("warning", "No model configured; select one with /model.");
else if (modelRuntime.getModel(saved.provider, saved.modelId) === undefined) {
notice("warning", `Saved model is unavailable: ${saved.provider}/${saved.modelId}`);
}
if (initial?.fallbackMessage !== undefined) notice("info", initial.fallbackMessage);
// The task panel starts open; /tasks hides it.
await controller.toggleTasks();
// Recovered work from an interrupted turn continues now.
harness.resume();
let closing: Promise<void> | undefined;
return {
view: {
current: () => state,
subscribe: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
},
controller,
settings: settingsManager,
close() {
closing ??= (async () => {
unsubscribe();
unsubscribeCommits();
conversation.dispose();
closeTasks();
try {
// Close writes no outcome: a running turn resumes with --continue.
await opened.close(context);
} finally {
await location.release();
}
})();
return closing;
},
};
} catch (error) {
await harness?.close(context).catch(() => {});
await location.release().catch(() => {});
throw error;
}
}

View file

@ -1,56 +0,0 @@
import { createHash, randomUUID } from "node:crypto";
import { mkdir, readdir, realpath } from "node:fs/promises";
import { basename, join, resolve } from "node:path";
import lockfile from "proper-lockfile";
import { getAgentDir } from "../../config.ts";
/** One session directory holding `session.sqlite`, locked by this process. */
export interface SessionLocation {
id: string;
directory: string;
database: string;
cwd: string;
created: boolean;
release(): Promise<void>;
}
/** A new session for `cwd`, or its newest one with `continueSession`. */
export async function selectSession(cwdInput: string, continueSession: boolean): Promise<SessionLocation> {
const cwd = await realpath(resolve(cwdInput));
const root = join(
getAgentDir(),
"experimental",
"vacation-sessions",
createHash("sha256").update(cwd).digest("hex").slice(0, 24),
);
await mkdir(root, { recursive: true });
let directory: string;
let created = false;
if (continueSession) {
const entries = await readdir(root, { withFileTypes: true });
const newest = entries
.filter((entry) => entry.isDirectory() && /^\d{13}-[0-9a-f-]{36}$/u.test(entry.name))
.map((entry) => entry.name)
.sort()
.at(-1);
if (!newest) throw new Error(`No vacation session exists for ${cwd}`);
directory = join(root, newest);
} else {
directory = join(root, `${String(Date.now()).padStart(13, "0")}-${randomUUID()}`);
await mkdir(directory);
created = true;
}
let release: () => Promise<void>;
try {
// A lock left by a crashed process goes stale after 10 s; wait that long before giving up.
release = await lockfile.lock(directory, {
realpath: false,
retries: { retries: 12, minTimeout: 1000, maxTimeout: 1000 },
});
} catch (error) {
throw new Error(`Session is already open in another process: ${directory}`, { cause: error });
}
return { id: basename(directory), directory, database: join(directory, "session.sqlite"), cwd, created, release };
}

View file

@ -1,660 +0,0 @@
import type { AssistantMessage, ToolResultMessage, Usage, UserMessage } from "@earendil-works/pi-ai";
import type {
ConversationId,
EntryRecord,
InboxState,
LiveState,
TaskGraph,
TaskGraphNode,
UsageState,
} from "@earendil-works/pi-durable";
import {
Box,
type Component,
Container,
type Focusable,
fuzzyFilter,
getKeybindings,
Input,
Markdown,
ProcessTerminal,
ScrollView,
type SelectItem,
SelectList,
type SelectListTheme,
Spacer,
setCapabilityOverrides,
setKeybindings,
Text,
TruncatedText,
TuiAltScreen,
VStack,
} from "@earendil-works/pi-tui";
import { getAgentDir } from "../../config.ts";
import { KeybindingsManager } from "../../core/keybindings.ts";
import type { SettingsManager } from "../../core/settings-manager.ts";
import { createAllToolRenderers } from "../../core/tools/renderers/index.ts";
import { AssistantMessageComponent } from "../../modes/interactive/components/assistant-message.ts";
import { CustomEditor } from "../../modes/interactive/components/custom-editor.ts";
import { DynamicBorder } from "../../modes/interactive/components/dynamic-border.ts";
import { formatTokens } from "../../modes/interactive/components/footer.ts";
import { keyText } from "../../modes/interactive/components/keybinding-hints.ts";
import { type StatusIndicator, WorkingStatusIndicator } from "../../modes/interactive/components/status-indicator.ts";
import { ToolExecutionComponent, type ToolRenderers } from "../../modes/interactive/components/tool-execution.ts";
import { UserMessageComponent } from "../../modes/interactive/components/user-message.ts";
import { getEditorTheme, getMarkdownTheme, initTheme, theme } from "../../modes/interactive/theme/theme.ts";
import { InteractiveThemeController } from "../../modes/interactive/theme/theme-controller.ts";
import { agentOf, type DurableController, type DurableView, type DurableViewSource } from "./runtime.ts";
const SELECT_THEME: SelectListTheme = {
selectedPrefix: (text) => theme.fg("accent", text),
selectedText: (text) => theme.fg("accent", text),
description: (text) => theme.fg("muted", text),
scrollInfo: (text) => theme.fg("dim", text),
noMatch: (text) => theme.fg("warning", text),
};
/** A filterable list in place of the editor. */
class ListSelector extends Container implements Focusable {
readonly #input = new Input();
readonly #listContainer = new Container();
readonly #items: SelectItem[];
readonly #onSelect: (value: string) => void;
readonly #onCancel: () => void;
#list: SelectList;
#focused = false;
constructor(title: string, items: SelectItem[], onSelect: (value: string) => void, onCancel: () => void) {
super();
this.#items = items;
this.#onSelect = onSelect;
this.#onCancel = onCancel;
this.#list = this.#build(items);
this.addChild(new DynamicBorder());
this.addChild(new Spacer(1));
this.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0));
this.addChild(this.#input);
this.addChild(new Spacer(1));
this.addChild(this.#listContainer);
this.addChild(new DynamicBorder());
}
get focused(): boolean {
return this.#focused;
}
set focused(value: boolean) {
this.#focused = value;
this.#input.focused = value;
}
handleInput(data: string): void {
const keybindings = getKeybindings();
const forwarded = ["tui.select.up", "tui.select.down", "tui.select.confirm", "tui.select.cancel"] as const;
if (forwarded.some((action) => keybindings.matches(data, action))) {
this.#list.handleInput(data);
return;
}
this.#input.handleInput(data);
const query = this.#input.getValue();
const filtered =
query.length === 0 ? this.#items : fuzzyFilter(this.#items, query, (item) => `${item.label} ${item.value}`);
this.#list = this.#build(filtered);
}
#build(items: SelectItem[]): SelectList {
const list = new SelectList(items, 10, SELECT_THEME);
list.onSelect = (item) => this.#onSelect(item.value);
list.onCancel = this.#onCancel;
this.#listContainer.clear();
this.#listContainer.addChild(list);
return list;
}
}
/** The summary that replaced earlier context: collapsed to one line until expanded. */
class CompactionComponent extends Box {
readonly #summary: string;
constructor(summary: string, expanded: boolean) {
super(1, 1, (text) => theme.bg("customMessageBg", text));
this.#summary = summary;
this.setExpanded(expanded);
}
setExpanded(expanded: boolean): void {
this.clear();
this.addChild(new Text(theme.fg("customMessageLabel", theme.bold("[compaction]")), 0, 0));
this.addChild(new Spacer(1));
this.addChild(
expanded
? new Markdown(this.#summary, 0, 0, getMarkdownTheme(), {
color: (text: string) => theme.fg("customMessageText", text),
})
: new Text(
theme.fg("customMessageText", "Earlier context summarized (") +
theme.fg("dim", keyText("app.tools.expand")) +
theme.fg("customMessageText", " to expand)"),
0,
0,
),
);
}
}
interface Handlers {
submit(text: string): void;
followUp(text: string): void;
abort(): void;
exit(): void;
selectModel(): void;
cycleThinking(): void;
}
class DurableTui {
static readonly #renderers: Record<string, ToolRenderers> = createAllToolRenderers();
readonly #ui: TuiAltScreen;
readonly #chat = new Container();
readonly #tasks = new Container();
readonly #queue = new Container();
readonly #notices = new Container();
readonly #footer = new Container();
readonly #footerStats = new Text("", 1, 0);
readonly #footerHints = new Text("", 1, 0);
readonly #editorContainer = new Container();
readonly #editor: CustomEditor;
readonly #cwd: string;
/** The newest card per call ID; provider call IDs may repeat across turns. */
readonly #tools = new Map<string, ToolExecutionComponent>();
/** Every card shown, also older ones whose call ID a later turn reused. */
readonly #cards: ToolExecutionComponent[] = [];
/** Call IDs whose cards the streaming answer created; its entry takes them over. */
readonly #streamingCalls = new Set<string>();
readonly #summaries: CompactionComponent[] = [];
/** Tool output and summaries shown in full; toggled like pi. */
#expanded = false;
#renderedEntryIds: number[] = [];
#streaming: AssistantMessageComponent | undefined;
#indicator: StatusIndicator | undefined;
#statusText = "";
/** Set when the transcript was rebuilt: the next render repaints the screen and shows the end. */
#rebuilt = false;
#transcript: ScrollView;
constructor(cwd: string, handlers: Handlers) {
this.#cwd = cwd;
this.#ui = new TuiAltScreen(new ProcessTerminal(), false, getAgentDir());
const keybindings = KeybindingsManager.create();
setKeybindings(keybindings);
this.#editor = new CustomEditor(this.#ui, getEditorTheme(), keybindings, {
paddingX: 1,
embedWorkingStatus: true,
});
this.#editor.onSubmit = handlers.submit;
this.#editor.onEscape = handlers.abort;
this.#editor.onCtrlD = handlers.exit;
this.#editor.onAction("app.clear", handlers.exit);
this.#editor.onAction("app.model.select", handlers.selectModel);
this.#editor.onAction("app.thinking.cycle", handlers.cycleThinking);
this.#editor.onAction("app.tools.expand", () => {
this.#expanded = !this.#expanded;
for (const component of [...this.#cards, ...this.#summaries]) component.setExpanded(this.#expanded);
this.#ui.requestRender();
});
this.#editor.onAction("app.message.followUp", () => {
const text = this.#editor.getText().trim();
if (!text) return;
this.#editor.setText("");
handlers.followUp(text);
});
this.#editorContainer.addChild(this.#editor);
this.#footer.addChild(this.#footerStats);
this.#footer.addChild(this.#footerHints);
// One empty line between the transcript and everything below it.
const content = new Container();
content.addChild(this.#chat);
content.addChild(new Spacer(1));
const transcript = new ScrollView(content, { follow: "end", primary: true, overscroll: "chain" });
this.#transcript = transcript;
const dock = new VStack([
{ component: this.#tasks, shrink: 1, minSize: 0 },
{ component: this.#queue, shrink: 1, minSize: 0 },
{ component: this.#notices, shrink: 1, minSize: 0 },
{ component: this.#editorContainer, shrink: 1, minSize: 3 },
{ component: this.#footer, shrink: 1, minSize: 0 },
]);
for (const component of [
this.#chat,
this.#tasks,
this.#queue,
this.#notices,
this.#editorContainer,
this.#footer,
]) {
this.#ui.addChild(component);
}
this.#ui.setLayoutRoot(
new VStack([
{ component: transcript, basis: 0, grow: 1, shrink: 1, minSize: 1 },
{ component: dock, basis: "auto", grow: 0, shrink: 1, minSize: 1 },
]),
);
this.#ui.setFocus(this.#editor);
}
get ui(): TuiAltScreen {
return this.#ui;
}
start(): void {
this.#ui.start();
}
stop(): void {
this.#discardTools();
this.#indicator?.dispose();
this.#ui.stop();
}
/** Finish every card: a running bash card keeps a timer until it gets a final result. */
#discardTools(): void {
for (const component of this.#cards) component.updateResult({ content: [], isError: false }, false);
this.#cards.length = 0;
this.#tools.clear();
this.#streamingCalls.clear();
}
mount(component: Component): void {
this.#editorContainer.clear();
this.#editorContainer.addChild(component);
this.#ui.setFocus(component);
this.#ui.requestRender();
}
restoreEditor(): void {
this.mount(this.#editor);
}
apply(view: DurableView): void {
const live = (view.conversation.docs["pi.live"] ?? {}) as LiveState;
this.#syncTranscript(view.conversation.entries);
const message = live.generation?.message as AssistantMessage | undefined;
// A partial without its entry was dropped, for example by a retry: render the transcript again.
if (message === undefined && this.#streaming !== undefined) this.#rebuild(view.conversation.entries);
if (message !== undefined) this.#syncStreaming(message);
for (const slot of live.tools ?? []) {
if (slot.status === "pending") continue;
const component = this.#tool(slot.name, slot.callId);
component.setArgsComplete();
if (slot.status !== "running") continue;
component.markExecutionStarted();
const child = (slot.details as { conversationId?: number } | undefined)?.conversationId;
if (slot.output === undefined && child !== undefined) {
const text = `Subagent ${child} is working. /agents switches to it.`;
component.updateResult({ content: [{ type: "text", text }], details: slot.details, isError: false }, true);
} else if (slot.output !== undefined) {
component.updateResult(
{ content: [{ type: "text", text: slot.output }], details: slot.details, isError: false },
true,
);
}
}
this.#syncTasks(view.tasks);
this.#syncQueue((view.conversation.docs["pi.inbox"] ?? { items: [] }) as InboxState);
this.#syncNotices(view);
this.#editor.borderColor = theme.getThinkingBorderColor(agentOf(view.conversation).thinkingLevel ?? "off");
this.#syncStatus(live);
this.#syncFooter(view);
if (this.#rebuilt) this.#transcript.scrollToEnd();
this.#ui.requestRender(this.#rebuilt);
this.#rebuilt = false;
}
#syncTasks(graph: TaskGraph | undefined): void {
this.#tasks.clear();
if (graph === undefined) return;
const nodes = Object.values(graph.tasks);
const lines: string[] = [theme.fg("accent", `Tasks (${nodes.length} live, /tasks to hide)`)];
// A conversation-owned task sits under the task that owns its conversation, when that task is live.
const owned = new Set(nodes.flatMap((node) => node.conversations));
const children = (node: TaskGraphNode) =>
nodes.filter(
(candidate) =>
candidate.owner === node.id ||
(candidate.owner === undefined && node.conversations.includes(candidate.conversationId)),
);
const visit = (node: TaskGraphNode, depth: number): void => {
lines.push(`${" ".repeat(depth + 1)}${describeTask(node)}`);
for (const child of children(node)) visit(child, depth + 1);
};
for (const node of nodes) {
if (node.owner === undefined && !owned.has(node.conversationId)) visit(node, 0);
}
for (const line of lines) this.#tasks.addChild(new TruncatedText(theme.fg("muted", line), 1, 0));
}
#syncQueue(inbox: InboxState): void {
this.#queue.clear();
for (const item of inbox.items) {
const text =
item.mode === "write" ? `<${String(item.entry.kind)}>` : userText(item.content as UserMessage["content"]);
this.#queue.addChild(new TruncatedText(theme.fg("muted", `[${item.mode}] ${text}`), 1, 0));
}
}
#syncNotices(view: DurableView): void {
this.#notices.clear();
for (const item of view.notices.slice(-4)) {
const color = item.level === "error" ? "error" : item.level === "warning" ? "warning" : "muted";
this.#notices.addChild(new TruncatedText(theme.fg(color, item.message), 1, 0));
}
}
#syncStatus(live: LiveState): void {
const generation = live.generation;
const compaction = live.compactions?.[0];
const runningTool = live.tools?.find((slot) => slot.status === "running");
let text = "";
if (generation?.retry !== undefined) {
text = `Retrying (attempt ${generation.attempt + 1}): ${generation.retry.error}`;
} else if (generation?.deferred !== undefined) text = "Waiting for deferred response...";
else if (compaction !== undefined) {
text = compaction.retry
? `Retrying ${compaction.reason} compaction (attempt ${compaction.attempt + 1})...`
: `Compacting (${compaction.reason})...`;
} else if (runningTool !== undefined) text = `Running ${runningTool.name}... (esc to abort)`;
else if (live.run !== undefined) text = "Working... (esc to abort)";
if (text === this.#statusText) return;
this.#statusText = text;
this.#indicator?.dispose();
this.#indicator = text
? new WorkingStatusIndicator(this.#ui, text, undefined, (part) => this.#editor.borderColor(part))
: undefined;
this.#editor.setWorkingStatusIndicator(this.#indicator);
}
#syncFooter(view: DurableView): void {
const agent = agentOf(view.conversation);
const usage = totalUsage((view.conversation.docs["pi.usage"] ?? { models: {}, tools: {} }) as UsageState);
const stats: string[] = [];
if (usage.input) stats.push(`↑${formatTokens(usage.input)}`);
if (usage.output) stats.push(`↓${formatTokens(usage.output)}`);
if (usage.cacheRead) stats.push(`R${formatTokens(usage.cacheRead)}`);
if (usage.cacheWrite) stats.push(`W${formatTokens(usage.cacheWrite)}`);
stats.push(`$${usage.cost.total.toFixed(3)}`);
const contextWindow =
view.models.find((model) => model.provider === agent.model?.provider && model.modelId === agent.model.modelId)
?.contextWindow ?? 0;
if (contextWindow > 0) {
const tokens = contextTokens(view.conversation.entries);
const percent = tokens === undefined ? undefined : (tokens / contextWindow) * 100;
const text = `${percent === undefined ? "?" : percent.toFixed(1)}%/${formatTokens(contextWindow)}`;
stats.push(percent !== undefined && percent > 90 ? theme.fg("error", text) : text);
}
this.#footerStats.setText(theme.fg("dim", `${stats.join(" ")} ${view.session.cwd}`));
const model = agent.model === undefined ? "no model" : `${agent.model.provider}/${agent.model.modelId}`;
const shown = view.conversations.find((candidate) => candidate.id === view.conversation.conversation.id);
const label = shown?.label ?? `conversation ${view.conversation.conversation.id}`;
this.#footerHints.setText(
`${theme.fg(label === "main" ? "dim" : "accent", label)}${theme.fg(
"dim",
` · ${model} · thinking:${agent.thinkingLevel ?? "off"} (${keyText("app.thinking.cycle")}) · ${keyText("app.model.select")} or /model · /agents · /compact · /tasks · ${keyText("app.message.followUp")} follow-up · ${keyText("app.clear")} exit`,
)}`,
);
}
#syncTranscript(entries: readonly EntryRecord[]): void {
// Compaction and resets replace the head of the active transcript.
if (this.#renderedEntryIds.some((id, index) => entries[index]?.id !== id)) this.#rebuild(entries);
for (const entry of entries.slice(this.#renderedEntryIds.length)) {
this.#addEntry(entry);
this.#renderedEntryIds.push(entry.id);
}
}
#rebuild(entries: readonly EntryRecord[]): void {
this.#chat.clear();
this.#discardTools();
this.#summaries.length = 0;
this.#rebuilt = true;
this.#renderedEntryIds = [];
this.#streaming = undefined;
for (const entry of entries) {
this.#addEntry(entry);
this.#renderedEntryIds.push(entry.id);
}
}
#addEntry(entry: EntryRecord): void {
const message = entry.model?.[0];
if (entry.kind === "pi.user" && message?.role === "user") {
this.#chat.addChild(new Spacer(1));
this.#chat.addChild(new UserMessageComponent(userText(message.content)));
} else if (entry.kind === "pi.assistant" && message?.role === "assistant") {
const component = this.#streaming ?? new AssistantMessageComponent();
if (this.#streaming === undefined) this.#chat.addChild(component);
this.#streaming = undefined;
component.updateContent(message, false);
// Only a tool-calling answer runs its calls; an aborted, failed, or truncated one never does.
const ran = message.stopReason === "toolUse";
for (const content of message.content) {
if (content.type !== "toolCall") continue;
const streamed = this.#streamingCalls.has(content.id);
// Only cards the stream already showed are kept for calls that never run.
if (!ran && !streamed) continue;
const card = this.#tool(content.name, content.id, content.arguments, !streamed);
card.setArgsComplete();
if (!ran) {
const text = "Not run: the answer was interrupted.";
card.updateResult({ content: [{ type: "text", text }], isError: true }, false);
}
}
this.#streamingCalls.clear();
} else if (entry.kind === "pi.tool-result" && message?.role === "toolResult") {
const result = message as ToolResultMessage;
this.#tool(result.toolName, result.toolCallId).updateResult(result);
} else if (entry.kind === "pi.compaction") {
const summary = new CompactionComponent(
message?.role === "user" ? userText(message.content) : "",
this.#expanded,
);
this.#summaries.push(summary);
this.#chat.addChild(new Spacer(1));
this.#chat.addChild(summary);
} else if (entry.kind === "pi.reset") this.#addText("[new context]");
else if (!entry.kind.startsWith("pi.") && message?.role === "user" && entry.model?.length === 1) {
// An input placed as an application entry.
this.#chat.addChild(new Spacer(1));
this.#chat.addChild(new UserMessageComponent(userText(message.content)));
}
}
#syncStreaming(message: AssistantMessage): void {
if (this.#streaming === undefined) {
this.#streaming = new AssistantMessageComponent();
this.#chat.addChild(this.#streaming);
}
this.#streaming.updateContent(message, true);
for (const content of message.content) {
if (content.type !== "toolCall") continue;
this.#tool(content.name, content.id, content.arguments, !this.#streamingCalls.has(content.id));
this.#streamingCalls.add(content.id);
}
}
#addText(text: string): void {
this.#chat.addChild(new Spacer(1));
this.#chat.addChild(new Text(theme.fg("muted", text), 1, 0));
}
/** The card of a call; `fresh` starts a new one for a call ID an earlier turn used. */
#tool(name: string, callId: string, args?: unknown, fresh = false): ToolExecutionComponent {
const existing = fresh ? undefined : this.#tools.get(callId);
if (existing !== undefined) {
if (args !== undefined) existing.updateArgs(args);
return existing;
}
const component = new ToolExecutionComponent(
name,
callId,
args ?? {},
{},
DurableTui.#renderers[name],
this.#ui,
this.#cwd,
);
component.setExpanded(this.#expanded);
this.#chat.addChild(component);
this.#cards.push(component);
this.#tools.set(callId, component);
return component;
}
}
function describeTask(node: TaskGraphNode): string {
const state = node.state;
const status =
state.status === "waiting"
? `waiting on ${state.on.join(", ")}`
: state.status === "completing"
? `completing (${state.outcome})`
: `${state.status} ${state.phase}`;
const flags = [node.background ? "background" : "", node.abortRequested ? "aborting" : ""].filter(Boolean);
const owned = node.conversations.length > 0 ? ` owns conversation ${node.conversations.join(", ")}` : "";
return `${node.kind} #${node.id}: ${status}${flags.length > 0 ? ` [${flags.join(", ")}]` : ""}${owned}`;
}
function userText(content: UserMessage["content"]): string {
if (typeof content === "string") return content;
return content
.filter((block) => block.type === "text")
.map((block) => block.text)
.join("");
}
function totalUsage(state: UsageState): Usage {
const total: Usage = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
for (const usage of [...Object.values(state.models), ...Object.values(state.tools)]) {
total.input += usage.input;
total.output += usage.output;
total.cacheRead += usage.cacheRead;
total.cacheWrite += usage.cacheWrite;
total.cost.total += usage.cost.total;
}
return total;
}
/** Context size from the newest successful answer after the newest compaction; unknown before one. */
function contextTokens(entries: readonly EntryRecord[]): number | undefined {
// Kept entries follow the summary in the view but are older than it; only later answers measure the new context.
const compacted = Math.max(0, ...entries.filter((entry) => entry.kind === "pi.compaction").map((entry) => entry.id));
for (const entry of [...entries].reverse()) {
const message = entry.model?.[0];
if (entry.id < compacted || entry.kind !== "pi.assistant" || message?.role !== "assistant") continue;
if (message.stopReason === "aborted" || message.stopReason === "error") continue;
const usage = message.usage;
return usage.totalTokens || usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
}
return undefined;
}
export async function runDurableTui(
source: DurableViewSource,
controller: DurableController,
settings: SettingsManager,
): Promise<void> {
setCapabilityOverrides(settings.getTerminalCapabilityOverrides());
// The system theme until the controller resolves the user's theme against the terminal's colors.
initTheme();
let exit = (): void => {};
const exited = new Promise<void>((resolve) => {
exit = resolve;
});
let view!: DurableTui;
const selectModel = (): void => {
const snapshot = source.current();
const current = agentOf(snapshot.conversation).model;
const isCurrent = (model: { provider: string; modelId: string }) =>
model.provider === current?.provider && model.modelId === current.modelId;
const items: SelectItem[] = [...snapshot.models]
.sort((left, right) => Number(isCurrent(right)) - Number(isCurrent(left)))
.map((model) => ({
value: `${model.provider}/${model.modelId}`,
label: model.modelId,
description: model.provider,
}));
const selector = new ListSelector(
"Select model:",
items,
(value) => {
view.restoreEditor();
const separator = value.indexOf("/");
void controller.setModel({ provider: value.slice(0, separator), modelId: value.slice(separator + 1) });
},
() => view.restoreEditor(),
);
view.mount(selector);
};
const selectConversation = (): void => {
const snapshot = source.current();
const items: SelectItem[] = [...snapshot.conversations].reverse().map((candidate) => ({
value: String(candidate.id),
label: candidate.label,
description: `${candidate.id === snapshot.conversation.conversation.id ? "(shown) " : ""}${candidate.title ?? ""}`,
}));
const selector = new ListSelector(
"Switch to:",
items,
(value) => {
view.restoreEditor();
void controller.switchConversation(Number(value) as ConversationId);
},
() => view.restoreEditor(),
);
view.mount(selector);
};
view = new DurableTui(source.current().session.cwd, {
submit: (text) => {
const trimmed = text.trim();
if (!trimmed) return;
if (trimmed === "/model") return selectModel();
if (trimmed === "/tasks") return void controller.toggleTasks();
if (trimmed === "/agents") return selectConversation();
if (trimmed === "/compact" || trimmed.startsWith("/compact ")) {
const instructions = trimmed.slice("/compact".length).trim();
return void controller.compact(instructions || undefined);
}
void controller.submit(trimmed, "steer");
},
followUp: (text) => void controller.submit(text, "followUp"),
abort: () => void controller.abort(),
exit,
selectModel,
cycleThinking: () => void controller.cycleThinking(),
});
// pi's theme handling: the theme setting (also light/dark pairs) resolved against the terminal's reported colors.
const themes = new InteractiveThemeController(view.ui, {
getSettingsManager: () => settings,
showError: (message) => console.error(message),
onChanged: () => view.ui.requestRender(),
});
const unsubscribe = source.subscribe(() => view.apply(source.current()));
view.start();
themes.applyFromSettings();
view.apply(source.current());
await exited;
unsubscribe();
themes.dispose();
view.stop();
}

View file

@ -1,142 +0,0 @@
import { setTimeout as sleep } from "node:timers/promises";
import { Type } from "@earendil-works/pi-ai";
import {
AssistantEntry,
type ConversationId,
configure,
defineExtension,
defineTask,
defineTool,
section,
} from "@earendil-works/pi-durable";
// ─── search: slow, fake, and safe to rerun ──────────────────────────────────
/** Canned results; each topic takes a different time, so a crash can land between them. */
const SEARCHES = {
weather: {
seconds: 6,
results: ["Saturday: 24°C and sunny", "Sunday: 21°C, a short shower around 3 pm"],
},
museums: {
seconds: 10,
results: [
"Kunsthistorisches Museum: open 10-18, book a time slot",
"Belvedere: Klimt's The Kiss, quietest before 11",
"Albertina: Monet to Picasso, open until 21 on Friday",
],
},
trains: {
seconds: 30,
results: ["Railjet from Salzburg: 2h 22m, every 30 min", "Nightjet from Munich: arrives 06:20"],
},
} as const;
const search = defineTool({
name: "search",
description:
"Search travel information about a city. Topics: weather, museums, trains. Slow; call several in parallel.",
parameters: Type.Object({
topic: Type.Union([Type.Literal("weather"), Type.Literal("museums"), Type.Literal("trains")]),
city: Type.String(),
}),
// Only reads, so a call cut off by a crash simply runs again.
replay: "safe",
execute: async (args, api, context) => {
const found = SEARCHES[args.topic];
const steps = Math.ceil(found.seconds / 2);
for (let step = 1; step <= steps; step++) {
api.output(`searching ${args.topic} in ${args.city}: source ${step}/${steps}\n`);
await sleep(2000, undefined, { signal: context.abortSignal });
}
for (const result of found.results) api.output(`- ${result}\n`);
return {};
},
});
/** What the research subagent gets: only `search`. */
export const Search = defineExtension({ name: "vacation-search", tools: [search] });
// ─── research: a background subagent that reports back ──────────────────────
type ResearchState = { phase: "deliver" } | { phase: "report"; report: string };
/** Delivers the task to its subagent and posts the report to the main conversation as a new message. */
const Research = defineTask<{ task: string }, ResearchState, null>({
name: "vacation.research",
version: 1,
initial: () => ({ phase: "deliver" }),
phases: {
deliver: async (task, runtime, context) => {
// The subagent's conversation is owned by this task. No Session call may run inside a commit.
let owned: ConversationId | undefined;
await runtime.commit(async (tx) => {
owned = (await tx.scanConversations({ ownerTaskId: task.id }, 1)).items[0]?.id;
return undefined;
}, context);
const child = await runtime.conversation(owned!, context);
// A rerun after a crash gets the same submission back.
const request = { type: "input", content: task.input.task, requestId: `research:${task.id}` } as const;
const settled = await (await child!.submit(request, context)).wait(context);
await runtime.commit(async (tx) => {
let report = `[research report] The research failed: ${settled.status === "unanswered" ? settled.reason : "?"}`;
if (settled.status === "done" && settled.type === "input") {
const message = (await tx.entry(AssistantEntry, settled.answer))?.model?.[0];
const text =
message?.role === "assistant"
? message.content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("")
: "";
report = `[research report] ${text}`;
}
return { status: "running", checkpoint: { phase: "report", report } };
}, context);
},
report: async (task, runtime, context) => {
const main = await runtime.conversation(runtime.conversationId, context);
const report = { type: "input", content: task.state.checkpoint.report, whenBusy: "followUp" } as const;
await main!.submit({ ...report, requestId: `research-report:${task.id}` }, context);
await runtime.commit(() => ({ status: "terminal", outcome: { status: "completed", result: null } }), context);
},
},
abort: (_task, runtime, context) =>
runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), context),
});
const research = defineTool({
name: "research",
description:
"Start a research subagent in the background. It searches while you keep talking with the user; its report arrives later as a message starting with [research report].",
parameters: Type.Object({ task: Type.String({ description: "What to research, with the city and dates" }) }),
execute: async (args, api, context) => {
await api.commit(async (tx) => {
// Background: the main conversation stays free while the research runs.
const owner = await tx.createTask(
Research,
{ task: args.task },
{ ownership: { kind: "conversation" }, background: true },
);
const child = await tx.createConversation({ ownership: { kind: "task", taskId: owner } });
await configure(tx, child.id, {
extensions: [Search],
instructions:
"You are a research subagent for a vacation planner. Search weather, museums, and trains in parallel, in one step, then report the findings in a few short bullet points.",
});
}, context);
return { output: [{ type: "text", text: "Research started in the background." }] };
},
});
/** What the main conversation gets: the prompt, `research`, and the task that delivers its report. */
export const Vacation = defineExtension({
name: "vacation",
tools: [research],
tasks: [Research],
sections: [
section(
"preamble",
() =>
"You are a friendly vacation planning assistant. Keep answers short. Hand all research to the research tool; while it runs, keep chatting with the user. When a [research report] arrives, turn it into a short plan.",
{ tag: false },
),
],
});

View file

@ -1,113 +0,0 @@
import { createFacetHost, defineFacet } from "@earendil-works/chord";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { fauxAssistantMessage } from "@earendil-works/pi-ai";
import type { InboxState, LiveState } from "@earendil-works/pi-durable";
import { describe, expect, test } from "vitest";
import { AgentController } from "../src/experimental/services/agent-controller.ts";
import { createAgentController } from "../src/experimental/services/agent-controller-provider.ts";
import { openFauxConversation, pendingResponse } from "./experimental-durable-support.ts";
describe("AgentController service", () => {
test("prompts the root conversation through the service catalogue", async () => {
const { harness, conversation, close } = await openFauxConversation([fauxAssistantMessage("hello back")]);
const host = await createFacetHost({
facets: [
defineFacet({
id: "test-agent-controller",
setup(env) {
env.provide(AgentController, createAgentController(harness, conversation));
},
}),
],
});
try {
expect(host.services.catalogue).toEqual([{ serviceId: AgentController.id, mode: "singleton" }]);
const response = await host.services.invoke(
{ serviceId: AgentController.id, member: "prompt", args: [{ message: "hello", images: null }] },
BACKGROUND_CONTEXT,
);
expect(response).toEqual({ accepted: true, operationId: expect.any(String), error: null });
const operationId = (response as { operationId: string }).operationId;
await expect(
host.services.invoke(
{ serviceId: AgentController.id, member: "waitForPrompt", args: [operationId] },
BACKGROUND_CONTEXT,
),
).resolves.toEqual({ status: "done", text: "hello back", reason: null });
} finally {
await host.dispose();
await close();
}
});
test("rejects a prompt while busy and queues steering and follow-up input", async () => {
const pending = pendingResponse();
const { harness, conversation, close } = await openFauxConversation([pending.step]);
const controller = createAgentController(harness, conversation);
try {
const first = await controller.prompt({ message: "first", images: null }, BACKGROUND_CONTEXT);
expect(first.accepted).toBe(true);
await pending.reached;
await expect(controller.prompt({ message: "second", images: null }, BACKGROUND_CONTEXT)).resolves.toEqual({
accepted: false,
operationId: null,
error: { code: "busy", message: expect.stringContaining("busy") },
});
const steer = await controller.steer({ message: "steer", images: null }, BACKGROUND_CONTEXT);
const followUp = await controller.followUp({ message: "later", images: null }, BACKGROUND_CONTEXT);
expect(steer).toEqual({ accepted: true, entryId: expect.any(String), error: null });
expect(followUp).toEqual({ accepted: true, entryId: expect.any(String), error: null });
const inbox = await conversation.viewState(BACKGROUND_CONTEXT);
try {
expect((inbox.value.docs["pi.inbox"] as InboxState).items.map((item) => item.mode)).toEqual([
"steer",
"followUp",
]);
} finally {
inbox.dispose();
}
if (!followUp.accepted) throw new Error("Follow-up was rejected");
await expect(controller.cancelQueued(followUp.entryId, BACKGROUND_CONTEXT)).resolves.toEqual({
outcome: "cancelled",
});
await expect(controller.cancelQueued(followUp.entryId, BACKGROUND_CONTEXT)).resolves.toEqual({
outcome: "already_consumed",
});
await expect(controller.cancelQueued("999", BACKGROUND_CONTEXT)).resolves.toEqual({ outcome: "not_found" });
await expect(controller.cancelQueued("not-an-id", BACKGROUND_CONTEXT)).resolves.toEqual({
outcome: "not_found",
});
await controller.abort(BACKGROUND_CONTEXT);
const view = await conversation.viewState(BACKGROUND_CONTEXT);
try {
expect((view.value.docs["pi.live"] as LiveState | undefined)?.run).toBeUndefined();
} finally {
view.dispose();
}
if (!first.accepted) throw new Error("Prompt was rejected");
await expect(controller.waitForPrompt(first.operationId, BACKGROUND_CONTEXT)).resolves.toMatchObject({
status: "unanswered",
text: null,
});
} finally {
await close();
}
});
test("starts a compaction task", async () => {
const { harness, conversation, close } = await openFauxConversation();
const controller = createAgentController(harness, conversation);
try {
await expect(controller.compact({ customInstructions: "short" }, BACKGROUND_CONTEXT)).resolves.toEqual({
accepted: true,
operationId: expect.any(String),
error: null,
});
} finally {
await close();
}
});
});

View file

@ -1,147 +0,0 @@
import { describe, expect, test } from "vitest";
import { cli } from "../src/cli/experimental/cli.ts";
const UNSUPPORTED_SERVER_OPTIONS = "The experimental server command does not support existing CLI options yet";
const UNSUPPORTED_CLIENT_OPTIONS = "The experimental client command does not support existing CLI options yet";
describe("experimental CLI commands", () => {
test("parses server configuration", () => {
expect(
cli.parse([
"server",
"--server-id",
"00000000-0000-4000-8000-000000000001",
"--session-dir",
"~/pi-sessions",
"--provider",
"anthropic",
"--model",
"claude-sonnet-4-5",
"-e",
"./first-plugin",
"-e=./second-plugin",
]),
).toEqual({
ok: true,
command: {
command: "server",
serverId: "00000000-0000-4000-8000-000000000001",
sessionDir: "~/pi-sessions",
provider: "anthropic",
model: "claude-sonnet-4-5",
pluginPackages: ["./first-plugin", "./second-plugin"],
},
});
});
test("parses provider-qualified server models", () => {
expect(cli.parse(["server", "--model", "anthropic/claude-sonnet-4-5:high"])).toEqual({
ok: true,
command: { command: "server", model: "anthropic/claude-sonnet-4-5:high" },
});
});
test("parses client transport addresses", () => {
expect(cli.parse(["client", "--connect", "unix:///tmp/pi.sock"])).toEqual({
ok: true,
command: { command: "client", connect: { transport: "unix", path: "/tmp/pi.sock" } },
});
expect(
cli.parse(["client", "--connect", "radius://00000000-0000-4000-8000-000000000001", "--session-id", "demo-1"]),
).toEqual({
ok: true,
command: {
command: "client",
connect: { transport: "radius", serverId: "00000000-0000-4000-8000-000000000001" },
sessionId: "demo-1",
},
});
});
test.each([
["-c", "continue"],
["--continue", "continue"],
["-r", "resume"],
["--resume", "resume"],
] as const)("parses client Session selection %s", (option, property) => {
expect(cli.parse(["client", option])).toEqual({
ok: true,
command: { command: "client", [property]: true },
});
});
test("parses a Session selection followed by a one-shot prompt", () => {
expect(cli.parse(["client", "-r", "Explain this project"])).toEqual({
ok: true,
command: { command: "client", resume: true, prompt: "Explain this project" },
});
});
test("parses client model and plugin configuration", () => {
expect(
cli.parse([
"client",
"--model",
"anthropic/claude-sonnet-4-5:high",
"-e",
"./first-plugin",
"-e",
"./second-plugin",
]),
).toEqual({
ok: true,
command: {
command: "client",
model: "anthropic/claude-sonnet-4-5:high",
pluginPackages: ["./first-plugin", "./second-plugin"],
},
});
});
test.each([
[["--auth-token", "secret"], { type: "token", token: "secret" }],
[["--auth-token-file", "/tmp/token"], { type: "file", path: "/tmp/token" }],
] as const)("parses authentication source %j", (argv, auth) => {
for (const command of ["server", "client"] as const) {
expect(cli.parse([command, ...argv])).toMatchObject({ ok: true, command: { command, auth } });
}
});
test.each(["server", "client"] as const)("permits omitted authentication for %s", (command) => {
expect(cli.parse([command])).toEqual({ ok: true, command: { command } });
});
test.each([
[["client", "--listen", "unix:///tmp/pi.sock"], UNSUPPORTED_CLIENT_OPTIONS],
[["server", "--listen", "unix:///tmp/pi.sock"], UNSUPPORTED_SERVER_OPTIONS],
[["server", "--connect", "unix:///tmp/pi.sock"], UNSUPPORTED_SERVER_OPTIONS],
[["client", "--connect", "ws://localhost:8080"], 'Unsupported --connect transport "ws:"'],
[["client", "--connect", "radius://not-a-server"], "Radius transport address requires"],
[["client", "--connect", "unix://relative.sock"], "Unix transport address must not include an authority"],
[["client", "--connect", "unix:///tmp/pi.sock?wrong=value"], "Invalid --connect address"],
[["client", "--provider", "anthropic"], "--provider requires --model"],
[["client", "-c", "-r"], "--session-id, --continue, and --resume are mutually exclusive"],
[["client", "--continue=true"], "--continue does not take a value"],
[["server", "--provider", "anthropic"], "--provider requires --model"],
[["server", "--server-id", "not-a-uuid"], "Invalid --server-id"],
[["server", "--server-id"], "--server-id requires a value"],
[["server", "--session-dir"], "--session-dir requires a value"],
[["client", "-e"], "-e requires a value"],
[
["server", "--session-dir", "/tmp/first", "--session-dir=/tmp/second"],
"--session-dir may only be specified once",
],
[["client", "--connect="], "--connect requires a value"],
] as const)("rejects invalid experimental input %j", (argv, error) => {
const result = cli.parse(argv);
expect(result).toMatchObject({ ok: false });
if (!result.ok) expect(result.errors).toContainEqual(expect.stringContaining(error));
});
test("rejects unsupported options without parsing them through the stable CLI", () => {
expect(cli.parse(["client", "--tui-mode", "wrong", "--model", "claude-sonnet"])).toEqual({
ok: false,
errors: [UNSUPPORTED_CLIENT_OPTIONS],
});
});
});

View file

@ -1,67 +0,0 @@
import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import { VERSION } from "../src/config.ts";
// --import takes a module specifier, not a filesystem path.
const sourceResolverUrl = pathToFileURL(resolve(__dirname, "../src/experimental/source-resolver.ts")).href;
const tempDirs: string[] = [];
afterEach(() => {
for (const directory of tempDirs.splice(0)) rmSync(directory, { recursive: true, force: true });
});
function runEntry(entry: string, experimental: boolean) {
const directory = mkdtempSync(join(tmpdir(), "pi-cli-boundary-"));
tempDirs.push(directory);
return spawnSync(
process.execPath,
[
"--import",
sourceResolverUrl,
resolve(__dirname, "../src", entry),
"server",
"--server-id",
"invalid",
"--version",
],
{
cwd: directory,
encoding: "utf8",
timeout: 15_000,
env: {
...process.env,
HOME: directory,
USERPROFILE: directory,
PI_CODING_AGENT_DIR: join(directory, "agent"),
PI_OFFLINE: "1",
PI_EXPERIMENTAL: experimental ? "1" : "0",
},
},
);
}
describe("stable and development CLI entrypoints", () => {
// #9132: enabling experiments must not pull remote-server dependencies into the published CLI.
it("does not dispatch experimental commands from the stable entrypoint", () => {
const result = runEntry("cli.ts", true);
expect(result.status, result.stderr).toBe(0);
expect(result.stdout.trim()).toBe(VERSION);
});
it("keeps experimental dispatch in the development entrypoint", () => {
const result = runEntry("experimental/cli.ts", true);
expect(result.status, result.stderr).toBe(1);
expect(result.stderr).toContain("Invalid --server-id");
expect(result.stdout).not.toContain(VERSION);
});
it("falls back to the stable CLI when experiments are disabled", () => {
const result = runEntry("experimental/cli.ts", false);
expect(result.status, result.stderr).toBe(0);
expect(result.stdout.trim()).toBe(VERSION);
});
});

View file

@ -1,51 +0,0 @@
import { describe, expect, test, vi } from "vitest";
import { cli } from "../src/cli/experimental/cli.ts";
describe("experimental CLI command composition", () => {
test("requires an experimental subcommand", () => {
expect(cli.parse([])).toEqual({
ok: false,
errors: ["Expected experimental command: server or client"],
});
});
test("passes server options to the command action", async () => {
const runServer = vi.fn(() => undefined);
const result = await cli.execute(
[
"server",
"--server-id",
"00000000-0000-4000-8000-000000000001",
"--session-dir",
"./sessions",
"--provider",
"anthropic",
"--model",
"claude-sonnet-4-5",
],
{ runServer, runClient: vi.fn(() => undefined) },
);
const command = {
command: "server" as const,
serverId: "00000000-0000-4000-8000-000000000001",
sessionDir: "./sessions",
provider: "anthropic",
model: "claude-sonnet-4-5",
};
expect(result).toEqual({ ok: true, command });
expect(runServer).toHaveBeenCalledWith(command);
});
test.each(["server", "client"] as const)("executes the parsed %s command", async (name) => {
const context = {
runServer: vi.fn(() => undefined),
runClient: vi.fn(() => undefined),
};
const result = await cli.execute([name], context);
expect(result).toEqual({ ok: true, command: { command: name } });
expect(context.runServer).toHaveBeenCalledTimes(name === "server" ? 1 : 0);
expect(context.runClient).toHaveBeenCalledTimes(name === "client" ? 1 : 0);
});
});

View file

@ -1,356 +0,0 @@
import { createHash } from "node:crypto";
import { resolve } from "node:path";
import {
createRemoteServiceBinding,
type MutableReplicatedState,
RemoteServiceProvider,
type RemoteServiceTransport,
replicatedState,
} from "@earendil-works/chord";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import {
FACET_BUNDLE_ARTIFACT_FORMAT,
FACET_BUNDLE_ARTIFACT_FORMAT_VERSION,
type FacetBundleArtifact,
} from "@earendil-works/chord/node";
import { fauxAssistantMessage } from "@earendil-works/pi-ai";
import { ProcessTerminal, TuiMainScreen } from "@earendil-works/pi-tui";
import { beforeAll, describe, expect, test, vi } from "vitest";
import { type ClientTuiServer, ExperimentalClientTui } from "../src/experimental/client-tui.ts";
import { createPresentationFacetData } from "../src/experimental/plugins/bundled.ts";
import { AgentController } from "../src/experimental/services/agent-controller.ts";
import { createAgentController } from "../src/experimental/services/agent-controller-provider.ts";
import type {
ServerConnectionState,
ServerServiceSource,
SessionAttachmentState,
SessionServiceSource,
} from "../src/experimental/services/connection.ts";
import { Models, type ModelsState } from "../src/experimental/services/models.ts";
import { PresentationPlugins, SessionPlugins } from "../src/experimental/services/plugins.ts";
import {
SessionDirectory,
type SessionDirectoryState,
SessionManagement,
type SessionSummary,
} from "../src/experimental/services/sessions.ts";
import { Transcript } from "../src/experimental/services/transcript.ts";
import { initTheme } from "../src/modes/interactive/theme/theme.ts";
import { openFauxConversation } from "./experimental-durable-support.ts";
const serverId = "00000000-0000-4000-8000-000000000001";
function session(sessionId: string, createdAt: number): SessionSummary {
return { serverId, sessionId, createdAt };
}
function createLoopbackServiceTransport(provider: RemoteServiceProvider): RemoteServiceTransport {
return {
invoke: (call, context) => provider.invoke(call, context),
subscribe: async (serviceId, mode, listener) => {
const subscription = provider.subscribe(serviceId, mode, (update) => listener(update, BACKGROUND_CONTEXT));
return {
snapshot: subscription.snapshot,
activate: () => subscription.activate(),
close: () => subscription.close(),
};
},
};
}
function publishReplacement<T extends object>(state: MutableReplicatedState<T>, value: T): void {
state.replace(BACKGROUND_CONTEXT, value);
}
describe("experimental client TUI", () => {
beforeAll(() => initTheme("dark"));
test.each([
["new", { command: "client" as const }, "two", 1],
["continued", { command: "client" as const, continue: true }, "one", 0],
["plugin-selected", { command: "client" as const, pluginPackages: ["./example-plugin"] }, "two", 1],
] as const)(
"opens a %s Session directly and exercises the full lifecycle only for a new Session",
async (kind, command, sessionId, creates) => {
const directoryState = replicatedState<SessionDirectoryState>({ revision: 1, sessions: [session("one", 1)] });
const attachment = replicatedState<SessionAttachmentState>({ status: "detached" });
const connectionState = replicatedState<ServerConnectionState>({ status: "connected", since: "now" });
const modelsState = replicatedState<ModelsState>({
catalog: {
revision: 1,
availableModels: [
{ provider: "test", modelId: "one", name: "Model One", reasoning: false },
{ provider: "test", modelId: "two", name: "Model Two", reasoning: true },
],
},
configuration: { model: { provider: "test", modelId: "one" }, thinkingLevel: "off" },
refresh: { status: "idle" },
});
const create = vi.fn(async () => {
const created = session("two", 2);
directoryState.change(BACKGROUND_CONTEXT, (draft) => {
draft.revision = 2;
draft.sessions.push(created);
});
return created;
});
const select = vi.fn(async (model: { provider: string; modelId: string }) => {
modelsState.change(BACKGROUND_CONTEXT, (draft) => {
draft.configuration.model = model;
});
});
const selectThinking = vi.fn(async (thinkingLevel: "off" | "high") => {
modelsState.change(BACKGROUND_CONTEXT, (draft) => {
draft.configuration.thinkingLevel = thinkingLevel;
});
});
let finishPrompt!: () => void;
const promptFinished = new Promise<void>((resolve) => {
finishPrompt = resolve;
});
const prompts: string[] = [];
const durable = await openFauxConversation([
async (context) => {
const last = context.messages.at(-1);
if (last?.role === "user") {
prompts.push(typeof last.content === "string" ? last.content : JSON.stringify(last.content));
}
await promptFinished;
return fauxAssistantMessage("remote answer");
},
]);
const transcriptState = await durable.conversation.viewState(BACKGROUND_CONTEXT);
const reloadSource =
'"use strict";\nconst { defineFacet, defineService } = require("@earendil-works/chord");\nconst Models = defineService("pi.models");\nmodule.exports = { __esModule: true, default: defineFacet({ id: "test-tui-facet", setup(env) { env.use(Models); } }) };\n';
const reloadArtifact: FacetBundleArtifact = {
format: FACET_BUNDLE_ARTIFACT_FORMAT,
formatVersion: FACET_BUNDLE_ARTIFACT_FORMAT_VERSION,
plugin: { id: "test-tui-plugin" },
entryName: "tui",
entry: {
file: "tui.cjs",
integrity: `sha256-${createHash("sha256").update(reloadSource).digest("base64")}`,
externalImports: ["@earendil-works/chord"],
},
source: reloadSource,
};
const reloadData = createPresentationFacetData([reloadArtifact]);
const prepareSessionPlugins = vi.fn(async () => reloadData);
const reloadPresentationPlugins = vi.fn(async () => reloadData);
const reloadSessionPlugins = vi.fn(async () => {});
const serverProvider = new RemoteServiceProvider([SessionDirectory, SessionManagement, PresentationPlugins]);
serverProvider.provide(SessionDirectory, { state: directoryState });
serverProvider.provide(PresentationPlugins, {
prepareSession: prepareSessionPlugins,
reload: reloadPresentationPlugins,
});
serverProvider.provide(SessionManagement, {
create,
async remove() {},
async attach(sessionId) {
publishReplacement(attachment, { status: "attaching", sessionId });
},
async detach() {
publishReplacement(attachment, { status: "detached" });
},
});
const sessionProvider = new RemoteServiceProvider([Models, AgentController, SessionPlugins, Transcript]);
sessionProvider.provide(SessionPlugins, { reload: reloadSessionPlugins });
sessionProvider.provide(Models, {
state: modelsState,
async cycleThinking() {},
async getThinkingLevels() {
return ["off", "high"];
},
async refresh() {},
select,
selectThinking,
});
sessionProvider.provide(AgentController, createAgentController(durable.harness, durable.conversation));
sessionProvider.provide(Transcript, { state: transcriptState });
const serverNamespace = createRemoteServiceBinding({
services: [SessionDirectory, SessionManagement, PresentationPlugins],
transport: createLoopbackServiceTransport(serverProvider),
bound: false,
});
const serverNamespaceReady = serverNamespace.ready.bind(serverNamespace);
const serverServices: ServerServiceSource = Object.assign(serverNamespace, {
acceptsUnavailableServices: false,
connection: connectionState,
async catalogue() {
return serverProvider.catalogue;
},
open() {
return {
use: serverNamespace.use.bind(serverNamespace),
observe: serverNamespace.observe.bind(serverNamespace),
async ready() {
await serverNamespace.rebind(true, BACKGROUND_CONTEXT);
await serverNamespaceReady(BACKGROUND_CONTEXT);
},
async dispose() {},
};
},
async ready() {
await serverNamespace.rebind(true, BACKGROUND_CONTEXT);
await serverNamespaceReady(BACKGROUND_CONTEXT);
},
});
const sessionNamespace = createRemoteServiceBinding({
services: [Models, AgentController, SessionPlugins, Transcript],
transport: createLoopbackServiceTransport(sessionProvider),
bound: false,
});
const sessionServices: SessionServiceSource = Object.assign(sessionNamespace, {
acceptsUnavailableServices: true,
attachment,
async catalogue() {
return [];
},
open() {
return {
use: sessionNamespace.use.bind(sessionNamespace),
observe: sessionNamespace.observe.bind(sessionNamespace),
ready: sessionNamespace.ready.bind(sessionNamespace),
async dispose() {},
};
},
async whenAttached(sessionId: string) {
await sessionNamespace.rebind(true, BACKGROUND_CONTEXT);
await sessionNamespace.ready(BACKGROUND_CONTEXT);
publishReplacement(attachment, { status: "attached", sessionId });
},
async whenDetached() {
await sessionNamespace.rebind(false, BACKGROUND_CONTEXT);
publishReplacement(attachment, { status: "detached" });
},
});
const server: ClientTuiServer = {
serverId,
radius: true,
server: serverServices,
session: sessionServices,
};
let finished = false;
const requestRender = vi.fn();
const ui = new TuiMainScreen(new ProcessTerminal());
const component = await ExperimentalClientTui.create({
command,
ui,
servers: [server],
requestRender,
finish() {
finished = true;
},
});
try {
expect(create).toHaveBeenCalledTimes(creates);
expect(prepareSessionPlugins).toHaveBeenCalledWith(
{
sessionId,
packagePaths:
"pluginPackages" in command
? command.pluginPackages.map((packagePath) => resolve(packagePath))
: null,
},
expect.anything(),
);
expect(attachment.value).toEqual({ status: "attached", sessionId });
expect(select).not.toHaveBeenCalled();
expect(component.render(80).join("\n")).toContain(`Server: ${serverId}`);
expect(component.render(80).join("\n")).toContain(`Session: ${sessionId}`);
expect(component.render(80).join("\n")).toContain("faux/faux-1");
expect(component.render(80).join("\n")).not.toContain("Experimental Sessions");
expect(component.render(80).join("\n")).not.toContain("Experimental Models");
// Startup selection is the only behavior specific to continue and plugin-selected Sessions.
if (kind !== "new") return;
component.handleInput("hello");
component.handleInput("\r");
await vi.waitFor(() => expect(prompts).toEqual(["hello"]));
await vi.waitFor(() => expect(component.render(80).join("\n")).toContain("Working..."));
finishPrompt();
await vi.waitFor(() => expect(component.render(80).join("\n")).toContain("remote answer"));
expect(component.render(80).join("\n")).toContain("hello");
expect(component.render(80).join("\n")).not.toContain("Working...");
component.handleInput("/reload");
component.handleInput("\u001b");
component.handleInput("\r");
await vi.waitFor(() => {
expect(reloadPresentationPlugins).toHaveBeenCalledOnce();
expect(reloadSessionPlugins).toHaveBeenCalledOnce();
});
await vi.waitFor(() => expect(component.render(80).join("\n")).toContain("Reloaded plugins."));
publishReplacement(attachment, { status: "detached" });
publishReplacement(connectionState, {
status: "disconnected",
since: "later",
reason: "network lost",
retryAt: null,
});
await vi.waitFor(() => expect(component.render(80).join("\n")).toContain("retrying"));
component.handleInput("\u0003");
expect(finished).toBe(true);
finished = false;
component.handleInput("\u0004");
expect(finished).toBe(true);
finished = false;
publishReplacement(connectionState, { status: "connecting", attempt: 1 });
publishReplacement(connectionState, { status: "connected", since: "reconnected" });
publishReplacement(attachment, { status: "attached", sessionId });
await vi.waitFor(() => expect(component.render(80).join("\n")).not.toContain("Reattaching"));
component.handleInput("/model");
component.handleInput("\u001b");
component.handleInput("\r");
await vi.waitFor(() => expect(component.render(80).join("\n")).toContain("Select model:"));
component.handleInput("\u001b[B");
component.handleInput("\r");
await vi.waitFor(() =>
expect(select).toHaveBeenCalledWith({ provider: "test", modelId: "two" }, expect.anything()),
);
expect(modelsState.value.configuration.model).toEqual({ provider: "test", modelId: "two" });
await vi.waitFor(() => expect(component.render(80).join("\n")).not.toContain("Select model:"));
component.handleInput("/thinking");
component.handleInput("\u001b");
component.handleInput("\r");
await vi.waitFor(() => expect(component.render(80).join("\n")).toContain("Select thinking level:"));
component.handleInput("\u001b[B");
component.handleInput("\r");
await vi.waitFor(() => expect(selectThinking).toHaveBeenCalledWith("high", expect.anything()));
expect(modelsState.value.configuration.thinkingLevel).toBe("high");
component.handleInput("\u0003");
await vi.waitFor(() => expect(finished).toBe(true));
await component.close();
const rendersAfterClose = requestRender.mock.calls.length;
directoryState.change(BACKGROUND_CONTEXT, (draft) => {
draft.revision = 3;
draft.sessions = [];
});
publishReplacement(attachment, { status: "detached" });
modelsState.change(BACKGROUND_CONTEXT, (draft) => {
draft.refresh = { status: "refreshing" };
});
expect(requestRender).toHaveBeenCalledTimes(rendersAfterClose);
} finally {
await component.close();
await Promise.all([
serverNamespace.dispose(BACKGROUND_CONTEXT),
sessionNamespace.dispose(BACKGROUND_CONTEXT),
]);
serverProvider.dispose();
sessionProvider.dispose();
transcriptState.dispose();
await durable.close();
}
},
);
});

View file

@ -1,39 +0,0 @@
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { createModels, type FauxProviderHandle, type FauxResponseStep, fauxProvider } from "@earendil-works/pi-ai";
import { type Conversation, createRegistry, Harness, MemoryStorage } from "@earendil-works/pi-durable";
export interface FauxConversation {
readonly faux: FauxProviderHandle;
readonly harness: Harness;
readonly conversation: Conversation;
close(): Promise<void>;
}
/** An in-memory durable Harness whose root conversation answers with the faux provider. */
export async function openFauxConversation(responses: FauxResponseStep[] = []): Promise<FauxConversation> {
const faux = fauxProvider();
faux.setResponses(responses);
const models = createModels();
models.setProvider(faux.provider);
const harness = await Harness.open(new MemoryStorage(), { models, registry: createRegistry() }, BACKGROUND_CONTEXT);
const model = faux.getModel();
const conversation = await harness.root(BACKGROUND_CONTEXT, {
agent: { model: { provider: model.provider, modelId: model.id } },
});
return { faux, harness, conversation, close: () => harness.close(BACKGROUND_CONTEXT) };
}
/** A faux response that never arrives; it ends only when its generation is aborted. */
export function pendingResponse(): { readonly step: FauxResponseStep; readonly reached: Promise<void> } {
let reach!: () => void;
const reached = new Promise<void>((resolve) => {
reach = resolve;
});
const step: FauxResponseStep = (_context, options) =>
new Promise((_resolve, reject) => {
reach();
const signal = options!.signal!;
signal.addEventListener("abort", () => reject(signal.reason), { once: true });
});
return { step, reached };
}

View file

@ -1,61 +0,0 @@
import type { ChildProcess } from "node:child_process";
import { once } from "node:events";
import { mkdtemp, rm } from "node:fs/promises";
import { createConnection } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import { spawnInternalProcess, terminateInternalProcess } from "../src/experimental/process.ts";
const children = new Set<ChildProcess>();
const directories = new Set<string>();
afterEach(async () => {
for (const child of children) {
if (child.exitCode === null && child.signalCode === null) {
const exited = once(child, "exit");
child.kill("SIGTERM");
await exited;
}
}
children.clear();
await Promise.all([...directories].map((directory) => rm(directory, { recursive: true, force: true })));
directories.clear();
});
describe.skipIf(process.platform === "win32")("experimental internal process launcher", () => {
test("starts the coordinator through the current runtime", async () => {
// Keep Unix socket paths below macOS's short sun_path limit.
const directory = await mkdtemp(join(tmpdir(), "pi-ip-"));
directories.add(directory);
const publicPath = join(directory, "p.sock");
const controlPath = join(directory, "c.sock");
const child = spawnInternalProcess("coordinator", [publicPath, controlPath]);
children.add(child);
await expect.poll(() => canConnect(controlPath), { timeout: 10_000 }).toBe(true);
expect(child.pid).not.toBe(process.pid);
});
test("waits for a failed activation child to terminate", async () => {
const directory = await mkdtemp(join(tmpdir(), "pi-ip-stop-"));
directories.add(directory);
const child = spawnInternalProcess("coordinator", [join(directory, "p.sock"), join(directory, "c.sock")]);
children.add(child);
await expect.poll(() => child.pid).toEqual(expect.any(Number));
await terminateInternalProcess(child);
expect(child.signalCode).toBe("SIGKILL");
});
});
function canConnect(path: string): Promise<boolean> {
return new Promise((resolve) => {
const socket = createConnection(path);
socket.once("connect", () => {
socket.destroy();
resolve(true);
});
socket.once("error", () => resolve(false));
});
}

View file

@ -1,56 +0,0 @@
import { defineFacet, type FacetLoader } from "@earendil-works/chord";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { describe, expect, test, vi } from "vitest";
import { SessionPlugins } from "../src/experimental/services/plugins.ts";
import { createSessionWorkerServices } from "../src/experimental/services/worker.ts";
import { openFauxConversation } from "./experimental-durable-support.ts";
describe("experimental plugin reload", () => {
test("loads and cuts over a fresh Session facet generation", async () => {
const activations: number[] = [];
const disposals: number[] = [];
let generation = 0;
const facetLoader: FacetLoader = {
async load() {
const current = ++generation;
return {
facets: [
defineFacet({
id: "reloadable-session-plugin",
setup(env) {
env.onActivate(() => {
activations.push(current);
});
},
}),
],
async dispose() {
disposals.push(current);
},
};
},
};
const { harness, conversation, close } = await openFauxConversation();
const services = await createSessionWorkerServices({
harness,
conversation,
modelRuntime: undefined,
facetLoader,
publish: vi.fn(async () => {}),
});
try {
expect(activations).toEqual([1]);
await services.invoke(
{ serviceId: SessionPlugins.id, member: "reload", args: [] },
{ serverConnectionId: "server-1", attachmentId: "attachment-1" },
BACKGROUND_CONTEXT,
);
expect(activations).toEqual([1, 2]);
expect(disposals).toEqual([1]);
} finally {
await services.dispose();
await close();
}
expect(disposals).toEqual([1, 2]);
});
});

View file

@ -1,173 +0,0 @@
import { randomUUID } from "node:crypto";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { readFacetBundleManifest } from "@earendil-works/chord/node";
import { afterEach, describe, expect, test } from "vitest";
import {
activateBuiltinClientServices,
type ClientRuntime,
openClientRuntime,
} from "../src/experimental/client-runtime.ts";
import { createPresentationFacetData, createPresentationFacetLoaders } from "../src/experimental/plugins/bundled.ts";
import { createServerPluginPackage, restoreServerPluginPackageProfile } from "../src/experimental/plugins/package.ts";
import { type RunningServer, startServer } from "../src/experimental/server.ts";
import { PresentationPlugins } from "../src/experimental/services/plugins.ts";
const runtimes = new Set<ClientRuntime>();
const runningServers = new Set<RunningServer>();
const directories = new Set<string>();
afterEach(async () => {
await Promise.allSettled([...runtimes].map((runtime) => runtime.dispose()));
await Promise.allSettled([...runningServers].map((server) => server.close()));
await Promise.allSettled([...directories].map((directory) => rm(directory, { force: true, recursive: true })));
runtimes.clear();
runningServers.clear();
directories.clear();
});
describe("server-selected presentation facets", () => {
test("rejects local plugin paths for Radius servers", async () => {
await expect(
openClientRuntime({
command: "client",
connect: { transport: "radius", serverId: randomUUID() },
pluginPackages: ["./local-plugin"],
}),
).rejects.toThrow("only be configured on a local Unix server");
});
test("restores plugin package selections for later server generations", async () => {
const directory = await mkdtemp("/tmp/pi-presentation-profile-");
directories.add(directory);
const serverId = randomUUID();
const packagePaths = [join(directory, "first-plugin"), join(directory, "second-plugin")];
await expect(restoreServerPluginPackageProfile(directory, serverId, packagePaths)).resolves.toEqual(packagePaths);
await expect(restoreServerPluginPackageProfile(directory, serverId)).resolves.toEqual(packagePaths);
await expect(restoreServerPluginPackageProfile(directory, serverId, [])).resolves.toEqual([]);
await expect(restoreServerPluginPackageProfile(directory, serverId)).resolves.toEqual([]);
});
test("builds conventional plugin entries into the server-owned plugin cache", async () => {
const directory = await mkdtemp("/tmp/pi-presentation-package-");
directories.add(directory);
const serverId = randomUUID();
const packagePath = join(directory, "pi-example-plugin");
await mkdir(join(packagePath, "src"), { recursive: true });
await writeFile(
join(packagePath, "package.json"),
`${JSON.stringify({
name: "@earendil-works/test-plugin",
version: "1.0.0",
peerDependencies: {
"@earendil-works/chord": "^0.84.4",
"@earendil-works/pi-coding-agent": "^0.84.4",
},
})}\n`,
);
const sourcePath = join(packagePath, "src", "tui.ts");
await writeFile(
sourcePath,
'import { defineFacet } from "@earendil-works/chord"; import { SlashCommands } from "@earendil-works/pi-coding-agent/experimental/plugin"; export default defineFacet({ id: "built-a", setup(env) { env.use(SlashCommands); } });\n',
);
const plugin = createServerPluginPackage(directory, serverId, packagePath);
const first = await plugin.build();
expect(first).toHaveLength(1);
expect(plugin.manifestPath).toMatch(
new RegExp(`/plugin-builds/${serverId}/pi-example-plugin-[a-f0-9]{12}/chord-facets\\.json$`, "u"),
);
expect(first[0]?.plugin).toEqual({ id: "@earendil-works/test-plugin", version: "1.0.0" });
const firstLoaded = await createPresentationFacetLoaders(createPresentationFacetData(first))[0]!.load();
expect(firstLoaded.facets.map(({ id }) => id)).toEqual(["built-a"]);
await firstLoaded.dispose();
await writeFile(
sourcePath,
'import { defineFacet } from "@earendil-works/chord"; import { SlashCommands } from "@earendil-works/pi-coding-agent/experimental/plugin"; export default defineFacet({ id: "built-b", setup(env) { env.use(SlashCommands); } });\n',
);
const second = await plugin.build();
expect(second[0]?.source).not.toBe(first[0]?.source);
const secondLoaded = await createPresentationFacetLoaders(createPresentationFacetData(second))[0]!.load();
expect(secondLoaded.facets.map(({ id }) => id)).toEqual(["built-b"]);
await secondLoaded.dispose();
const secondPackagePath = join(directory, "second-plugin");
await mkdir(join(secondPackagePath, "src"), { recursive: true });
await Promise.all([
writeFile(
join(secondPackagePath, "package.json"),
`${JSON.stringify({
name: "@earendil-works/second-test-plugin",
version: "1.0.0",
peerDependencies: { "@earendil-works/chord": "^0.84.4" },
})}\n`,
),
writeFile(
join(secondPackagePath, "src", "tui.ts"),
'import { defineFacet } from "@earendil-works/chord"; export default defineFacet({ id: "second-built", setup() {} });\n',
),
]);
const running = await startServer({
directory: join(directory, "server"),
sessionDir: join(directory, "sessions"),
});
runningServers.add(running);
const runtime = await openClientRuntime({
command: "client",
connect: { transport: "unix", path: running.socketPath },
pluginPackages: [packagePath, secondPackagePath],
});
runtimes.add(runtime);
const activated = await activateBuiltinClientServices(runtime.servers[0]!);
const sessionId = randomUUID();
await activated.management.create({ id: sessionId }, BACKGROUND_CONTEXT);
const presentationPlugins = await activated.plugins.prepareSession(
{ sessionId, packagePaths: [packagePath, secondPackagePath] },
BACKGROUND_CONTEXT,
);
await expect(restoreServerPluginPackageProfile(join(directory, "server"), running.serverId)).resolves.toEqual([]);
const serverLoaded = await Promise.all(
createPresentationFacetLoaders(presentationPlugins).map((loader) => loader.load()),
);
expect(serverLoaded.flatMap(({ facets }) => facets.map(({ id }) => id))).toEqual(["built-b", "second-built"]);
await Promise.all(serverLoaded.map((loaded) => loaded.dispose()));
await writeFile(
sourcePath,
'import { defineFacet } from "@earendil-works/chord"; import { SlashCommands } from "@earendil-works/pi-coding-agent/experimental/plugin"; export default defineFacet({ id: "built-c", setup(env) { env.use(SlashCommands); } });\n',
);
const services = runtime.servers[0]!.server.open({
services: [PresentationPlugins],
assertAccess() {},
onError() {},
});
try {
await services.ready(BACKGROUND_CONTEXT);
const data = await services.use(PresentationPlugins).reload(BACKGROUND_CONTEXT);
const reloaded = await Promise.all(createPresentationFacetLoaders(data).map((loader) => loader.load()));
expect(reloaded.flatMap(({ facets }) => facets.map(({ id }) => id))).toEqual(["built-c", "second-built"]);
await Promise.all(reloaded.map((loaded) => loaded.dispose()));
} finally {
await services.dispose(BACKGROUND_CONTEXT);
}
});
test("builds the example plugin package without a package-owned build script", async () => {
const directory = await mkdtemp("/tmp/pi-example-plugin-");
directories.add(directory);
const serverId = randomUUID();
const packagePath = fileURLToPath(new URL("../examples/plugins/pi-example-plugin", import.meta.url));
const plugin = createServerPluginPackage(directory, serverId, packagePath);
const artifacts = await plugin.build();
const manifest = await readFacetBundleManifest(plugin.manifestPath);
expect(manifest.plugin).toEqual({ id: "@earendil-works/pi-example-plugin", version: "1.0.0" });
expect(Object.keys(manifest.entries)).toEqual(["session", "tui"]);
const loaded = await createPresentationFacetLoaders(createPresentationFacetData(artifacts))[0]!.load();
expect(loaded.facets.map(({ id }) => id)).toEqual(["@earendil-works/pi-example-plugin/tui"]);
await loaded.dispose();
});
});

View file

@ -1,286 +0,0 @@
import type { Client } from "@earendil-works/pi-client";
import type { Server } from "@earendil-works/pi-server";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { RadiusRelayAuthResolver } from "../src/experimental/radius-auth.ts";
import {
createRadiusClientTransportFactory,
encodeRelayDataFrame,
parseRelayDataFrame,
RadiusClientReconnect,
RadiusRelayHost,
type RadiusRelayWebSocket,
type RadiusRelayWebSocketFactory,
} from "../src/experimental/radius-relay.ts";
import { allowNetwork } from "./test-network-env.ts";
const serverId = "00000000-0000-4000-8000-000000000001";
const connectionId = "00000000-0000-4000-8000-000000000002";
type Listener = (event: unknown) => void;
class FakeWebSocket {
readonly sent: Array<string | ArrayBuffer> = [];
onSend: ((data: string | ArrayBuffer) => void) | undefined;
readonly listeners = new Map<string, Set<Listener>>();
binaryType: "arraybuffer" | "blob" = "blob";
bufferedAmount = 0;
protocol = "";
readyState = 0;
readonly OPEN = 1;
addEventListener(type: string, listener: Listener): void {
const listeners = this.listeners.get(type) ?? new Set();
listeners.add(listener);
this.listeners.set(type, listeners);
}
removeEventListener(type: string, listener: Listener): void {
this.listeners.get(type)?.delete(listener);
}
send(data: string | ArrayBuffer): void {
if (this.readyState !== this.OPEN) throw new Error("socket is not open");
this.sent.push(data);
this.onSend?.(data);
}
close(code = 1000, reason = ""): void {
if (code !== 1000 && (code < 3000 || code > 4999)) {
throw new DOMException("Invalid close code", "InvalidAccessError");
}
if (this.readyState === 3) return;
this.readyState = 3;
this.emit("close", { code, reason });
}
open(protocol: string): void {
this.protocol = protocol;
this.readyState = this.OPEN;
this.emit("open", {});
}
message(data: unknown): void {
this.emit("message", { data });
}
remoteClose(code = 1006, reason = "lost"): void {
this.readyState = 3;
this.emit("close", { code, reason });
}
fail(error: Error): void {
this.emit("error", { error, message: error.message });
}
abnormalClose(error: Error): void {
this.readyState = 3;
this.emit("error", { error, message: error.message });
this.emit("close", { code: 1006, reason: "" });
}
private emit(type: string, event: unknown): void {
for (const listener of [...(this.listeners.get(type) ?? [])]) listener(event);
}
}
function socketFactory() {
const sockets: Array<{ socket: FakeWebSocket; options: Parameters<RadiusRelayWebSocketFactory>[0] }> = [];
const factory: RadiusRelayWebSocketFactory = (options) => {
const socket = new FakeWebSocket();
sockets.push({ socket, options });
return socket as unknown as RadiusRelayWebSocket;
};
return { sockets, factory };
}
beforeEach(() => allowNetwork());
afterEach(() => {
vi.useRealTimers();
vi.unstubAllEnvs();
});
describe("experimental Radius relay", () => {
test("matches the Radius multiplexing envelope", () => {
const payload = Uint8Array.from([0, 1, 2, 255]);
const parsed = parseRelayDataFrame(encodeRelayDataFrame(connectionId, payload));
expect(parsed?.connectionId).toBe(connectionId);
expect([...new Uint8Array(parsed!.payload)]).toEqual([...payload]);
});
test("bridges multiplexed host clients into independent server connections", async () => {
const webSockets = socketFactory();
const received: Uint8Array[] = [];
let accepted:
| {
send(chunk: Uint8Array): Promise<void>;
close(finalChunk?: Uint8Array): Promise<void>;
}
| undefined;
const onClose = vi.fn();
const server = {
accept(connection: typeof accepted) {
accepted = connection;
return {
onData: (chunk: Uint8Array) => received.push(chunk.slice()),
onClose,
onError: vi.fn(),
};
},
} as unknown as Pick<Server, "accept">;
const statuses: string[] = [];
const host = new RadiusRelayHost({
serverId,
server,
auth: new RadiusRelayAuthResolver({ type: "token", token: "secret" }),
webSocketFactory: webSockets.factory,
onStatus: (status) => statuses.push(status.status),
});
host.start();
await vi.waitFor(() => expect(webSockets.sockets).toHaveLength(1));
const { socket, options } = webSockets.sockets[0]!;
expect(options.authorization).toBe("Bearer secret");
socket.open(options.protocol);
await vi.waitFor(() => expect(statuses).toContain("connected"));
socket.message(JSON.stringify({ version: 1, type: "connection_open", connection_id: connectionId }));
expect(accepted).toBeDefined();
const fromClient = Uint8Array.from([1, 2, 3]);
socket.message(encodeRelayDataFrame(connectionId, fromClient));
expect(received.map((chunk) => [...chunk])).toEqual([[1, 2, 3]]);
await accepted!.send(Uint8Array.from([4, 5, 6]));
const outbound = parseRelayDataFrame(socket.sent.at(-1) as ArrayBuffer);
expect(outbound?.connectionId).toBe(connectionId);
expect([...new Uint8Array(outbound!.payload)]).toEqual([4, 5, 6]);
socket.message(JSON.stringify({ version: 1, type: "connection_close", connection_id: connectionId }));
expect(onClose).toHaveBeenCalledOnce();
await host.close();
});
test("reconnects the server host after the relay connection drops", async () => {
vi.useFakeTimers();
const webSockets = socketFactory();
const statuses: string[] = [];
const host = new RadiusRelayHost({
serverId,
server: { accept: vi.fn() } as unknown as Pick<Server, "accept">,
auth: new RadiusRelayAuthResolver({ type: "token", token: "secret" }),
webSocketFactory: webSockets.factory,
onStatus: (status) => statuses.push(status.status),
});
host.start();
await vi.waitFor(() => expect(webSockets.sockets).toHaveLength(1));
webSockets.sockets[0]!.socket.open(webSockets.sockets[0]!.options.protocol);
await vi.waitFor(() => expect(statuses).toContain("connected"));
webSockets.sockets[0]!.socket.remoteClose();
await vi.advanceTimersByTimeAsync(1_000);
await vi.waitFor(() => expect(webSockets.sockets).toHaveLength(2));
await host.close();
});
test("uses a raw authenticated WebSocket as the client byte transport", async () => {
const webSockets = socketFactory();
const onData = vi.fn();
const onClose = vi.fn();
const onError = vi.fn();
const transportPromise = createRadiusClientTransportFactory({
serverId,
auth: new RadiusRelayAuthResolver({ type: "token", token: "secret" }),
webSocketFactory: webSockets.factory,
})({ onData, onClose, onError });
await vi.waitFor(() => expect(webSockets.sockets).toHaveLength(1));
const { socket, options } = webSockets.sockets[0]!;
expect(options.authorization).toBe("Bearer secret");
socket.open(options.protocol);
const transport = await transportPromise;
await transport.send(Uint8Array.from([1, 2, 3]));
expect([...new Uint8Array(socket.sent[0] as ArrayBuffer)]).toEqual([1, 2, 3]);
socket.message(Uint8Array.from([4, 5, 6]).buffer);
expect(onData).toHaveBeenCalledOnce();
expect([...onData.mock.calls[0]![0]]).toEqual([4, 5, 6]);
socket.remoteClose();
expect(onClose).toHaveBeenCalledOnce();
expect(onError).not.toHaveBeenCalled();
});
test("reports established abnormal closures so the client can reconnect", async () => {
const webSockets = socketFactory();
const onClose = vi.fn();
const onError = vi.fn();
const transportPromise = createRadiusClientTransportFactory({
serverId,
auth: new RadiusRelayAuthResolver({ type: "token", token: "secret" }),
webSocketFactory: webSockets.factory,
})({ onData: vi.fn(), onClose, onError });
await vi.waitFor(() => expect(webSockets.sockets).toHaveLength(1));
const { socket, options } = webSockets.sockets[0]!;
socket.open(options.protocol);
await transportPromise;
expect(() => socket.abnormalClose(new Error("network lost"))).not.toThrow();
expect(onError).toHaveBeenCalledOnce();
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: "network lost" }));
expect(onClose).not.toHaveBeenCalled();
});
test("reports a useful error when Undici omits WebSocket failure details", async () => {
const webSockets = socketFactory();
const opening = createRadiusClientTransportFactory({
serverId,
auth: new RadiusRelayAuthResolver({ type: "token", token: "secret" }),
webSocketFactory: webSockets.factory,
})({ onData: vi.fn(), onClose: vi.fn(), onError: vi.fn() });
const failure = expect(opening).rejects.toThrow("Radius WebSocket connection failed");
await vi.waitFor(() => expect(webSockets.sockets).toHaveLength(1));
webSockets.sockets[0]!.socket.fail(new Error(""));
await failure;
});
test("reconnects an established client and restores its selected Session", async () => {
vi.useFakeTimers();
const connectionListeners = new Set<(change: { state: string }) => void>();
const attachmentListeners = new Set<(attachment: { sessionId: string } | undefined) => void>();
let attempts = 0;
const reattach = vi.fn(async () => {});
const client = {
connected: true,
connectionState: "connected",
attachment: { sessionId: "demo-1" },
onConnectionStateChange(listener: (change: { state: string }) => void) {
connectionListeners.add(listener);
return () => connectionListeners.delete(listener);
},
onAttachmentChange(listener: (attachment: { sessionId: string } | undefined) => void) {
attachmentListeners.add(listener);
return () => attachmentListeners.delete(listener);
},
async reconnect() {
attempts++;
if (attempts === 1) throw new Error("temporary failure");
this.connected = true;
this.connectionState = "connected";
for (const listener of connectionListeners) listener({ state: "connected" });
return { serverId };
},
disconnect() {
this.connected = false;
this.connectionState = "disconnected";
for (const listener of connectionListeners) listener({ state: "disconnected" });
},
};
const reconnect = new RadiusClientReconnect(client as unknown as Client, reattach);
client.disconnect();
await vi.advanceTimersByTimeAsync(1_000);
expect(attempts).toBe(1);
await vi.advanceTimersByTimeAsync(2_000);
expect(attempts).toBe(2);
expect(reattach).toHaveBeenCalledWith("demo-1");
await reconnect.dispose();
});
});

View file

@ -1,820 +0,0 @@
import { lstat, mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { type Context, createFacetHost, defineFacet, defineService } from "@earendil-works/chord";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { Client, ServerError as ClientServerError } from "@earendil-works/pi-client";
import { createUnixTransportFactory } from "@earendil-works/pi-client/unix";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { ExampleFacetService } from "../examples/plugins/pi-example-plugin/src/contract.ts";
import { runClient } from "../src/experimental/client.ts";
import { activateBuiltinClientServices, openClientRuntime } from "../src/experimental/client-runtime.ts";
import { createPresentationFacetLoaders } from "../src/experimental/plugins/bundled.ts";
import * as processRuntime from "../src/experimental/process.ts";
import { type RunningServer, startServer } from "../src/experimental/server.ts";
import { AgentController } from "../src/experimental/services/agent-controller.ts";
import { createSessionServiceSource, type SessionAttachmentState } from "../src/experimental/services/connection.ts";
import { Models } from "../src/experimental/services/models.ts";
import { PresentationPlugins, SessionPlugins } from "../src/experimental/services/plugins.ts";
import { SessionDirectory, SessionManagement } from "../src/experimental/services/sessions.ts";
import { Transcript } from "../src/experimental/services/transcript.ts";
import { createServerServiceBinding, createSessionServiceBinding } from "./experimental-service-binding.ts";
import {
configureExperimentalWorkerModel,
createExperimentalSessions,
readExperimentalSessionState,
} from "./experimental-session-support.ts";
import { KeyedProbe } from "./fixtures/keyed-service.ts";
const servers = new Set<RunningServer>();
const clients = new Set<Client>();
const directories = new Set<string>();
const fauxWorkerEntryUrl = new URL("fixtures/faux-session-worker.ts", import.meta.url);
const realSpawnInternalProcess = processRuntime.spawnInternalProcess;
const sessionWorkerModel = { provider: "anthropic", model: "claude-sonnet-4-5" } as const;
const SecondPluginService = defineService<{ read(context: Context): Promise<string> }>("test.second-plugin");
let agentDir: string;
beforeEach(async () => {
agentDir = await mkdtemp(join("/tmp", "pi-experimental-agent-"));
directories.add(agentDir);
await configureExperimentalWorkerModel(agentDir);
vi.stubEnv("PI_CODING_AGENT_DIR", agentDir);
await createExperimentalSessions(join(agentDir, "experimental", "sessions"), ["demo-1", "demo-2"]);
});
async function makeServer(): Promise<{ directory: string; runtime: RunningServer }> {
const directory = await mkdtemp(join("/tmp", "pes-"));
directories.add(directory);
const runtime = await startServer({ ...sessionWorkerModel, directory });
servers.add(runtime);
return { directory, runtime };
}
async function attachSession(client: Client, sessionId: string): Promise<void> {
const services = createServerServiceBinding(client, { services: [SessionManagement] });
try {
await services.ready(BACKGROUND_CONTEXT);
await services.use(SessionManagement).attach(sessionId, BACKGROUND_CONTEXT);
} finally {
await services.dispose(BACKGROUND_CONTEXT);
}
}
async function attachClient(runtime: RunningServer, sessionId: string): Promise<Client> {
const client = await Client.connect({
serverId: runtime.serverId,
transportFactory: createUnixTransportFactory({ path: runtime.socketPath }),
});
clients.add(client);
await attachSession(client, sessionId);
return client;
}
afterEach(async () => {
await Promise.all([...clients].map((client) => client.dispose()));
clients.clear();
await Promise.all([...servers].map((server) => server.close()));
servers.clear();
vi.unstubAllEnvs();
await Promise.all([...directories].map((directory) => rm(directory, { recursive: true, force: true })));
directories.clear();
});
describe("experimental durable server composition", () => {
test("uses PI_SERVER_DIR and PI_SERVER_ID", async () => {
const directory = await mkdtemp(join("/tmp", "pi-server-dir-"));
directories.add(directory);
const serverId = "00000000-0000-4000-8000-000000000001";
vi.stubEnv("PI_SERVER_DIR", directory);
vi.stubEnv("PI_SERVER_ID", serverId);
const runtime = await startServer();
servers.add(runtime);
expect(runtime.serverId).toBe(serverId);
expect(runtime.socketPath).toBe(join(directory, `${serverId}.sock`));
expect((await lstat(directory)).mode & 0o777).toBe(0o700);
const publicSocket = await lstat(runtime.socketPath);
const controlSocket = await lstat(join(directory, `control-${runtime.serverId}.sock`));
expect(publicSocket.isSocket()).toBe(true);
expect(publicSocket.mode & 0o777).toBe(0o600);
expect(controlSocket.isSocket()).toBe(true);
expect(controlSocket.mode & 0o777).toBe(0o600);
const entries = await readdir(directory);
expect(entries).toHaveLength(3);
expect(entries.every((entry) => !entry.startsWith("."))).toBe(true);
expect(entries).toContain(`${serverId}.sock`);
expect(entries).toContain(`control-${serverId}.sock`);
expect(entries).toContainEqual(expect.stringMatching(new RegExp(`^server-${serverId}-[0-9a-f]{12}\\.sock$`)));
await expect(runClient({ command: "client" })).resolves.toMatchObject({
kind: "list",
sessions: [{ sessionId: "demo-1" }, { sessionId: "demo-2" }],
});
});
test("rejects a provider without a model", async () => {
const directory = await mkdtemp(join("/tmp", "pes-"));
directories.add(directory);
await expect(startServer({ directory, provider: "anthropic" })).rejects.toThrow("provider requires a model");
});
test("preserves an existing Session model when the server default changes", async () => {
await writeFile(
join(agentDir, "settings.json"),
JSON.stringify({ defaultProvider: "anthropic", defaultModel: "claude-opus-4-6" }),
);
const directory = await mkdtemp(join("/tmp", "pes-"));
directories.add(directory);
const first = await startServer({ directory });
servers.add(first);
const firstClient = await attachClient(first, "demo-1");
await firstClient.dispose();
clients.delete(firstClient);
await expect.poll(() => first.workerPids.has("demo-1")).toBe(false);
await first.close();
const second = await startServer({ ...sessionWorkerModel, directory });
servers.add(second);
const secondClient = await attachClient(second, "demo-1");
await secondClient.dispose();
clients.delete(secondClient);
await expect.poll(() => second.workerPids.has("demo-1")).toBe(false);
const state = await readExperimentalSessionState(second.sessionDir, "demo-1");
expect(state.model).toEqual({ provider: "anthropic", modelId: "claude-opus-4-6" });
});
test("rejects model options when discovery selects an existing server", async () => {
const { directory } = await makeServer();
await expect(runClient({ command: "client", model: "anthropic/claude-opus-4-6" }, { directory })).rejects.toThrow(
"Model selection is only valid when automatically activating a new server",
);
});
test("rechecks an auto-discovered server after a version mismatch", async () => {
const { directory, runtime } = await makeServer();
const connect = Client.connect.bind(Client);
vi.spyOn(Client, "connect")
.mockRejectedValueOnce(new ClientServerError({ code: "version", message: "stale server" }))
.mockImplementation((options) => connect(options));
const clientRuntime = await openClientRuntime({ command: "client" }, { directory });
try {
expect(clientRuntime.servers.map(({ route }) => route.serverId)).toEqual([runtime.serverId]);
} finally {
await clientRuntime.dispose();
}
});
test("serializes concurrent cold activation and retires after both clients leave", async () => {
const directory = await mkdtemp(join("/tmp", "pi-auto-server-"));
directories.add(directory);
const serverId = "00000000-0000-4000-8000-000000000001";
vi.stubEnv("PI_SERVER_DIR", directory);
vi.stubEnv("PI_SERVER_ID", serverId);
const results = await Promise.all([runClient({ command: "client" }), runClient({ command: "client" })]);
expect(results).toEqual([
{
kind: "list",
sessions: [
{ serverId, sessionId: "demo-1" },
{ serverId, sessionId: "demo-2" },
],
},
{
kind: "list",
sessions: [
{ serverId, sessionId: "demo-1" },
{ serverId, sessionId: "demo-2" },
],
},
]);
expect(await pathExists(join(directory, `${serverId}.sock`))).toBe(true);
await expect.poll(() => pathExists(join(directory, `${serverId}.sock`)), { timeout: 5_000 }).toBe(false);
await expect.poll(() => pathExists(join(directory, `control-${serverId}.sock`)), { timeout: 5_000 }).toBe(false);
});
test("passes client plugin packages to a cold server and restores them for its next generation", async () => {
const directory = await mkdtemp(join("/tmp", "pi-auto-plugin-"));
directories.add(directory);
const serverId = "00000000-0000-4000-8000-000000000001";
const packagePath = fileURLToPath(new URL("../examples/plugins/pi-example-plugin", import.meta.url));
vi.stubEnv("PI_SERVER_DIR", directory);
vi.stubEnv("PI_SERVER_ID", serverId);
const first = await openClientRuntime({ command: "client", ...sessionWorkerModel });
try {
const activated = await activateBuiltinClientServices(first.servers[0]!);
const presentationPlugins = await activated.plugins.prepareSession(
{ sessionId: "demo-1", packagePaths: [packagePath] },
BACKGROUND_CONTEXT,
);
await activated.management.attach("demo-1", BACKGROUND_CONTEXT);
const loaded = await createPresentationFacetLoaders(presentationPlugins)[0]!.load();
expect(loaded.facets.map(({ id }) => id)).toEqual(["@earendil-works/pi-example-plugin/tui"]);
await loaded.dispose();
} finally {
await first.dispose();
}
await expect.poll(() => pathExists(join(directory, `${serverId}.sock`)), { timeout: 5_000 }).toBe(false);
const second = await openClientRuntime({ command: "client", ...sessionWorkerModel });
try {
const activated = await activateBuiltinClientServices(second.servers[0]!);
const presentationPlugins = await activated.plugins.prepareSession(
{ sessionId: "demo-1", packagePaths: null },
BACKGROUND_CONTEXT,
);
await activated.management.attach("demo-1", BACKGROUND_CONTEXT);
expect(createPresentationFacetLoaders(presentationPlugins)).toHaveLength(1);
} finally {
await second.dispose();
}
});
test("retires a cold server after its only Session attachment disconnects", async () => {
const directory = await mkdtemp(join("/tmp", "pi-auto-session-"));
directories.add(directory);
const serverId = "00000000-0000-4000-8000-000000000001";
vi.stubEnv("PI_SERVER_DIR", directory);
vi.stubEnv("PI_SERVER_ID", serverId);
await expect(runClient({ command: "client", sessionId: "demo-1", ...sessionWorkerModel })).resolves.toEqual({
kind: "attached",
serverId,
sessionId: "demo-1",
});
await expect.poll(() => pathExists(join(directory, `${serverId}.sock`)), { timeout: 5_000 }).toBe(false);
await expect.poll(() => pathExists(join(directory, `control-${serverId}.sock`)), { timeout: 5_000 }).toBe(false);
});
test("runs and discovers multiple logical servers from one directory", async () => {
const directory = await mkdtemp(join("/tmp", "pi-multi-server-"));
directories.add(directory);
const firstId = "00000000-0000-4000-8000-000000000001";
const secondId = "00000000-0000-4000-8000-000000000002";
const [first, second] = await Promise.all([
startServer({ directory, serverId: firstId }),
startServer({ directory, serverId: secondId }),
]);
servers.add(first);
servers.add(second);
expect((await lstat(join(directory, `control-${firstId}.sock`))).isSocket()).toBe(true);
expect((await lstat(join(directory, `control-${secondId}.sock`))).isSocket()).toBe(true);
await expect(runClient({ command: "client" }, { directory })).resolves.toEqual({
kind: "list",
sessions: [
{ serverId: firstId, sessionId: "demo-1" },
{ serverId: firstId, sessionId: "demo-2" },
{ serverId: secondId, sessionId: "demo-1" },
{ serverId: secondId, sessionId: "demo-2" },
],
});
await first.close();
await expect.poll(() => pathExists(first.socketPath)).toBe(false);
await expect(runClient({ command: "client" }, { directory })).resolves.toEqual({
kind: "list",
sessions: [
{ serverId: secondId, sessionId: "demo-1" },
{ serverId: secondId, sessionId: "demo-2" },
],
});
});
test("hydrates and mutates server Session services across framed clients", async () => {
const { runtime } = await makeServer();
const firstClient = await Client.connect({
serverId: runtime.serverId,
transportFactory: createUnixTransportFactory({ path: runtime.socketPath }),
});
const secondClient = await Client.connect({
serverId: runtime.serverId,
transportFactory: createUnixTransportFactory({ path: runtime.socketPath }),
});
clients.add(firstClient);
clients.add(secondClient);
const errors: Error[] = [];
const firstServices = createServerServiceBinding(firstClient, {
services: [SessionDirectory, SessionManagement],
onError: (error) => errors.push(error),
});
const secondServices = createServerServiceBinding(secondClient, {
services: [SessionDirectory, SessionManagement],
onError: (error) => errors.push(error),
});
expect(firstServices.connection.value).toMatchObject({ status: "connected" });
expect(secondServices.connection.value).toMatchObject({ status: "connected" });
await expect(firstServices.catalogue(BACKGROUND_CONTEXT)).resolves.toEqual([
{ serviceId: SessionDirectory.id, mode: "singleton" },
{ serviceId: SessionManagement.id, mode: "singleton" },
{ serviceId: PresentationPlugins.id, mode: "singleton" },
]);
const firstDirectory = firstServices.use(SessionDirectory);
const secondDirectory = secondServices.use(SessionDirectory);
const firstManagement = firstServices.use(SessionManagement);
const secondManagement = secondServices.use(SessionManagement);
await Promise.all([firstServices.ready(BACKGROUND_CONTEXT), secondServices.ready(BACKGROUND_CONTEXT)]);
expect(firstDirectory.state.value?.sessions.map(({ sessionId }) => sessionId)).toEqual(["demo-1", "demo-2"]);
expect(secondDirectory.state.value).toEqual(firstDirectory.state.value);
await firstManagement.create({ id: "demo-3" }, BACKGROUND_CONTEXT);
await vi.waitFor(() => {
expect(firstDirectory.state.value?.sessions.map(({ sessionId }) => sessionId)).toContain("demo-3");
expect(secondDirectory.state.value).toEqual(firstDirectory.state.value);
});
await Promise.all([
firstManagement.attach("demo-1", BACKGROUND_CONTEXT),
secondManagement.attach("demo-1", BACKGROUND_CONTEXT),
]);
expect(firstClient.attachment?.sessionId).toBe("demo-1");
expect(secondClient.attachment?.sessionId).toBe("demo-1");
await firstManagement.remove("demo-1", BACKGROUND_CONTEXT);
await vi.waitFor(() => {
expect(firstClient.attachment).toBeUndefined();
expect(secondClient.attachment).toBeUndefined();
expect(firstDirectory.state.value?.sessions.map(({ sessionId }) => sessionId)).not.toContain("demo-1");
expect(secondDirectory.state.value).toEqual(firstDirectory.state.value);
});
await secondManagement.detach(BACKGROUND_CONTEXT);
expect(errors).toEqual([]);
await Promise.all([firstServices.dispose(BACKGROUND_CONTEXT), secondServices.dispose(BACKGROUND_CONTEXT)]);
});
test("hydrates and updates the Models service across concurrent framed clients", async () => {
const { runtime } = await makeServer();
const firstClient = await attachClient(runtime, "demo-1");
const workerPid = runtime.workerPids.get("demo-1");
expect(workerPid).toEqual(expect.any(Number));
const secondClient = await attachClient(runtime, "demo-1");
expect(runtime.workerPids.get("demo-1")).toBe(workerPid);
const errors: Error[] = [];
const firstServices = createSessionServiceBinding(firstClient, {
services: [Models],
onError: (error) => errors.push(error),
});
const secondServices = createSessionServiceBinding(secondClient, {
services: [Models],
onError: (error) => errors.push(error),
});
const firstModels = firstServices.use(Models);
const secondModels = secondServices.use(Models);
await Promise.all([firstServices.ready(BACKGROUND_CONTEXT), secondServices.ready(BACKGROUND_CONTEXT)]);
expect(firstServices.attachment.value).toEqual({ status: "attached", sessionId: "demo-1" });
expect(secondServices.attachment.value).toEqual({ status: "attached", sessionId: "demo-1" });
expect(firstModels.state.value?.configuration.model).toEqual({
provider: "anthropic",
modelId: "claude-sonnet-4-5",
});
expect(secondModels.state.value).toEqual(firstModels.state.value);
const previousThinking = firstModels.state.value!.configuration.thinkingLevel;
await firstModels.cycleThinking(BACKGROUND_CONTEXT);
await vi.waitFor(() => {
expect(firstModels.state.value!.configuration.thinkingLevel).not.toBe(previousThinking);
expect(secondModels.state.value).toEqual(firstModels.state.value);
});
expect(errors).toEqual([]);
await Promise.all([firstServices.dispose(BACKGROUND_CONTEXT), secondServices.dispose(BACKGROUND_CONTEXT)]);
});
test("loads conventional Session facets from multiple configured plugin packages", async () => {
const directory = await mkdtemp(join("/tmp", "pes-plugin-"));
directories.add(directory);
const secondPackagePath = join(directory, "second-plugin");
await mkdir(join(secondPackagePath, "src"), { recursive: true });
await Promise.all([
writeFile(
join(secondPackagePath, "package.json"),
`${JSON.stringify({
name: "@earendil-works/second-session-plugin",
version: "1.0.0",
peerDependencies: { "@earendil-works/chord": "^0.84.4" },
})}\n`,
),
writeFile(
join(secondPackagePath, "src", "session.ts"),
'import { defineFacet, defineService } from "@earendil-works/chord"; const Service = defineService("test.second-plugin"); export default defineFacet({ id: "second-session-plugin", setup(env) { env.provide(Service, { async read() { return "second"; } }); } });\n',
),
]);
const runtime = await startServer({ ...sessionWorkerModel, directory });
servers.add(runtime);
const clientRuntime = await openClientRuntime({ command: "client" }, { directory });
const activated = await activateBuiltinClientServices(clientRuntime.servers[0]!);
await activated.plugins.prepareSession(
{
sessionId: "demo-1",
packagePaths: [
fileURLToPath(new URL("../examples/plugins/pi-example-plugin", import.meta.url)),
secondPackagePath,
],
},
BACKGROUND_CONTEXT,
);
await activated.management.attach("demo-1", BACKGROUND_CONTEXT);
const services = createSessionServiceBinding(clientRuntime.servers[0]!.client, {
services: [ExampleFacetService, SecondPluginService],
});
try {
await services.ready(BACKGROUND_CONTEXT);
await expect(
services.use(ExampleFacetService).greet({ name: "Armin" }, BACKGROUND_CONTEXT),
).resolves.toMatchObject({ workerActivations: 1 });
await expect(services.use(SecondPluginService).read(BACKGROUND_CONTEXT)).resolves.toBe("second");
} finally {
await services.dispose(BACKGROUND_CONTEXT);
await clientRuntime.dispose();
}
});
test("uses the most recently selected model for a new Session", async () => {
const directory = await mkdtemp(join("/tmp", "pes-model-default-"));
directories.add(directory);
const runtime = await startServer({ directory });
servers.add(runtime);
const firstClient = await attachClient(runtime, "demo-1");
const firstServices = createSessionServiceBinding(firstClient, { services: [Models] });
const firstModels = firstServices.use(Models);
await firstServices.ready(BACKGROUND_CONTEXT);
await firstModels.select({ provider: "anthropic", modelId: "claude-opus-4-6" }, BACKGROUND_CONTEXT);
await firstServices.dispose(BACKGROUND_CONTEXT);
await firstClient.dispose();
clients.delete(firstClient);
await expect.poll(() => runtime.workerPids.has("demo-1")).toBe(false);
const secondClient = await attachClient(runtime, "demo-2");
const secondServices = createSessionServiceBinding(secondClient, { services: [Models] });
const secondModels = secondServices.use(Models);
await secondServices.ready(BACKGROUND_CONTEXT);
expect(secondModels.state.value?.configuration.model).toEqual({
provider: "anthropic",
modelId: "claude-opus-4-6",
});
await secondServices.dispose(BACKGROUND_CONTEXT);
});
test("composes management attachment with Session service hydration", async () => {
const { runtime } = await makeServer();
const clientRuntime = await openClientRuntime({
command: "client",
connect: { transport: "unix", path: runtime.socketPath },
});
try {
const server = await activateBuiltinClientServices(clientRuntime.servers[0]!);
await server.plugins.prepareSession({ sessionId: "demo-1", packagePaths: null }, BACKGROUND_CONTEXT);
await server.management.attach("demo-1", BACKGROUND_CONTEXT);
expect(server.session.attachment.value).toEqual({ status: "attached", sessionId: "demo-1" });
expect(server.models.state.value?.configuration.model).toEqual({
provider: "anthropic",
modelId: "claude-sonnet-4-5",
});
} finally {
await clientRuntime.dispose();
}
});
test("fences superseded attachment hydration by attachment generation", async ({ onTestFinished }) => {
const { runtime } = await makeServer();
const client = await Client.connect({
serverId: runtime.serverId,
transportFactory: createUnixTransportFactory({ path: runtime.socketPath }),
});
clients.add(client);
const errors: Error[] = [];
const services = createSessionServiceBinding(client, {
services: [Models],
onError: (error) => errors.push(error),
});
const models = services.use(Models);
await services.ready(BACKGROUND_CONTEXT);
const states: SessionAttachmentState[] = [];
const removeStateListener = services.attachment.subscribe((state) => states.push(state));
let releaseDelay!: () => void;
const delayed = new Promise<void>((resolve) => {
releaseDelay = resolve;
});
const subscribeService = client.subscribeService.bind(client);
const subscribe = vi
.spyOn(client, "subscribeService")
.mockImplementation(async (target, serviceId, mode, listener, signal) => {
if ("sessionId" in target && target.sessionId === "demo-2" && serviceId === Models.id) {
await delayed;
}
return subscribeService(target, serviceId, mode, listener, signal);
});
onTestFinished(() => subscribe.mockRestore());
try {
await attachSession(client, "demo-2");
expect(services.attachment.value).toEqual({ status: "attaching", sessionId: "demo-2" });
await attachSession(client, "demo-1");
await services.whenAttached("demo-1", BACKGROUND_CONTEXT);
expect(services.attachment.value).toEqual({ status: "attached", sessionId: "demo-1" });
expect(models.state.value?.configuration.model).toEqual({
provider: "anthropic",
modelId: "claude-sonnet-4-5",
});
} finally {
releaseDelay();
removeStateListener();
await services.dispose(BACKGROUND_CONTEXT);
}
const latestAttach = states
.map((state) => state.status === "attaching" && state.sessionId === "demo-1")
.lastIndexOf(true);
expect(latestAttach).toBeGreaterThanOrEqual(0);
expect(states.slice(latestAttach)).not.toContainEqual({ status: "attached", sessionId: "demo-2" });
expect(states.slice(latestAttach)).not.toContainEqual({ status: "degraded", sessionId: "demo-2" });
expect(errors).toEqual([]);
});
test("observes keyed service instances and fences replacement generations over framed transport", async ({
onTestFinished,
}) => {
const spawn = vi
.spyOn(processRuntime, "spawnInternalProcess")
.mockImplementation((role, args, options) =>
realSpawnInternalProcess(
role,
args,
role === "session-worker" ? { ...options, entryUrl: fauxWorkerEntryUrl } : options,
),
);
onTestFinished(() => spawn.mockRestore());
const { runtime } = await makeServer();
const client = await attachClient(runtime, "demo-1");
const errors: Error[] = [];
const services = createSessionServiceSource(client, {
onError: (error) => errors.push(error),
});
await expect(services.catalogue(BACKGROUND_CONTEXT)).resolves.toContainEqual({
serviceId: KeyedProbe.id,
mode: "keyed",
});
const observed: { service: KeyedProbe; value: string | undefined }[] = [];
const consumer = defineFacet({
id: "@test/keyed-probe-consumer",
setup(env) {
env.observe(KeyedProbe, (service) => {
observed.push({ service, value: service.state.value?.value });
});
},
});
const facetHost = await createFacetHost({ facets: [consumer], serviceSources: [services] });
expect(services.attachment.value).toEqual({ status: "attached", sessionId: "demo-1" });
await vi.waitFor(() => expect(observed).toHaveLength(1));
expect(observed[0]!.value).toBe("first");
const staleReplace = observed[0]!.service.replace;
await expect(staleReplace("second", BACKGROUND_CONTEXT)).resolves.toBeUndefined();
await vi.waitFor(() => expect(observed).toHaveLength(2));
expect(observed[1]!.value).toBe("second");
expect(() => staleReplace("late", BACKGROUND_CONTEXT)).toThrow("observation is closed");
const replacedReplace = observed[1]!.service.replace;
await expect(attachSession(client, "demo-2")).resolves.toBeUndefined();
await services.whenAttached("demo-2", BACKGROUND_CONTEXT);
expect(observed).toHaveLength(3);
expect(services.attachment.value).toEqual({ status: "attached", sessionId: "demo-2" });
expect(observed[2]!.value).toBe("first");
expect(() => replacedReplace("late", BACKGROUND_CONTEXT)).toThrow("observation is closed");
expect(errors).toEqual([]);
await facetHost.dispose();
await expect(services.dispose(BACKGROUND_CONTEXT)).resolves.toBeUndefined();
});
test("answers a client prompt through the worker-owned service provider", async ({ onTestFinished }) => {
const spawn = vi
.spyOn(processRuntime, "spawnInternalProcess")
.mockImplementation((role, args, options) =>
realSpawnInternalProcess(
role,
args,
role === "session-worker" ? { ...options, entryUrl: fauxWorkerEntryUrl } : options,
),
);
onTestFinished(() => spawn.mockRestore());
const { directory } = await makeServer();
const result = await runClient({ command: "client", sessionId: "demo-1", prompt: "question" }, { directory });
expect(result).toMatchObject({ kind: "prompted", text: "deterministic remote answer" });
});
test("replicates the transcript after consecutive prompts", async ({ onTestFinished }) => {
const spawn = vi
.spyOn(processRuntime, "spawnInternalProcess")
.mockImplementation((role, args, options) =>
realSpawnInternalProcess(
role,
args,
role === "session-worker" ? { ...options, entryUrl: fauxWorkerEntryUrl } : options,
),
);
onTestFinished(() => spawn.mockRestore());
const { runtime } = await makeServer();
const client = await attachClient(runtime, "demo-1");
const services = createSessionServiceBinding(client, { services: [AgentController, SessionPlugins, Transcript] });
const controller = services.use(AgentController);
const transcript = services.use(Transcript);
await services.ready(BACKGROUND_CONTEXT);
await services.use(SessionPlugins).reload(BACKGROUND_CONTEXT);
try {
for (const message of ["first question", "second question"]) {
const response = await controller.prompt({ message, images: null }, BACKGROUND_CONTEXT);
expect(response).toMatchObject({ accepted: true, operationId: expect.any(String) });
if (!response.accepted) throw new Error(response.error.message);
await expect(controller.waitForPrompt(response.operationId, BACKGROUND_CONTEXT)).resolves.toEqual({
status: "done",
text: "deterministic remote answer",
reason: null,
});
await vi.waitFor(() => {
expect(transcript.state.value?.entries.filter((entry) => entry.kind === "pi.assistant")).toHaveLength(
message === "first question" ? 1 : 2,
);
});
}
} finally {
await services.dispose(BACKGROUND_CONTEXT);
}
});
test("stops an idle Session worker after its client disconnects", async () => {
const { runtime } = await makeServer();
const client = await attachClient(runtime, "demo-1");
const pid = runtime.workerPids.get("demo-1");
expect(pid).toEqual(expect.any(Number));
await client.dispose();
clients.delete(client);
await expect.poll(() => runtime.workerPids.has("demo-1")).toBe(false);
expect(processExists(pid!)).toBe(false);
});
test("starts one process per attached session and stops them during shutdown", async () => {
const { runtime } = await makeServer();
await Promise.all([attachClient(runtime, "demo-1"), attachClient(runtime, "demo-2")]);
const pids = [...runtime.workerPids.values()];
expect(pids).toHaveLength(2);
expect(new Set(pids).size).toBe(2);
for (const pid of pids) expect(processExists(pid)).toBe(true);
await runtime.close();
expect(runtime.workerPids.size).toBe(0);
await Promise.all(pids.map((pid) => expect.poll(() => processExists(pid)).toBe(false)));
});
test("server runtime replaces an exited worker on the next attach", async () => {
const directory = await mkdtemp(join("/tmp", "pew-"));
directories.add(directory);
const runtime = await startServer({ ...sessionWorkerModel, directory });
servers.add(runtime);
const client = await attachClient(runtime, "demo-1");
const firstPid = runtime.workerPids.get("demo-1");
expect(firstPid).toEqual(expect.any(Number));
process.kill(firstPid!, "SIGKILL");
await expect.poll(() => runtime.workerPids.has("demo-1")).toBe(false);
await attachSession(client, "demo-1");
const replacementPid = runtime.workerPids.get("demo-1");
expect(replacementPid).toEqual(expect.any(Number));
expect(replacementPid).not.toBe(firstPid);
});
test("discovers workers after replacing the server", async () => {
const firstDirectory = await mkdtemp(join("/tmp", "per-"));
directories.add(firstDirectory);
const first = await startServer({ ...sessionWorkerModel, directory: firstDirectory });
servers.add(first);
await attachClient(first, "demo-1");
const firstWorkerPid = first.workerPids.get("demo-1");
expect(firstWorkerPid).toEqual(expect.any(Number));
const replacement = await startServer({ ...sessionWorkerModel, directory: firstDirectory });
servers.add(replacement);
await first.closed;
expect(replacement.serverId).toBe(first.serverId);
expect(replacement.workerPids.get("demo-1")).toBe(firstWorkerPid);
await expect.poll(() => first.workerPids.size).toBe(0);
expect(processExists(firstWorkerPid!)).toBe(true);
await expect(runClient({ command: "client" }, { directory: firstDirectory })).resolves.toMatchObject({
kind: "list",
sessions: [
{ serverId: first.serverId, sessionId: "demo-1" },
{ serverId: first.serverId, sessionId: "demo-2" },
],
});
await attachClient(replacement, "demo-1");
expect(replacement.workerPids.get("demo-1")).toBe(firstWorkerPid);
await attachClient(replacement, "demo-2");
expect(replacement.workerPids.get("demo-2")).toEqual(expect.any(Number));
expect(replacement.workerPids.get("demo-2")).not.toBe(firstWorkerPid);
});
test("retires an unclaimed idle worker after replacement demand expires", async () => {
const directory = await mkdtemp(join("/tmp", "pi-orphan-worker-"));
directories.add(directory);
vi.stubEnv("__PI_SESSION_WORKER_ORPHAN_DEMAND_GRACE_MS", "50");
const first = await startServer({ ...sessionWorkerModel, directory });
servers.add(first);
await attachClient(first, "demo-1");
const workerPid = first.workerPids.get("demo-1");
expect(workerPid).toEqual(expect.any(Number));
const replacement = await startServer({ ...sessionWorkerModel, directory });
servers.add(replacement);
await first.closed;
expect(replacement.workerPids.get("demo-1")).toBe(workerPid);
await expect.poll(() => replacement.workerPids.has("demo-1"), { timeout: 5_000 }).toBe(false);
expect(processExists(workerPid!)).toBe(false);
});
test("restores tracked sessions that are outside the replacement catalog", async () => {
const directory = await mkdtemp(join("/tmp", "pet-"));
const emptySessionDir = await mkdtemp(join("/tmp", "pet-sessions-"));
directories.add(directory);
directories.add(emptySessionDir);
const first = await startServer({ ...sessionWorkerModel, directory });
servers.add(first);
await attachClient(first, "demo-1");
const workerPid = first.workerPids.get("demo-1");
const replacement = await startServer({
...sessionWorkerModel,
directory,
sessionDir: emptySessionDir,
});
servers.add(replacement);
await first.closed;
await expect(runClient({ command: "client" }, { directory })).resolves.toEqual({
kind: "list",
sessions: [{ serverId: first.serverId, sessionId: "demo-1" }],
});
await attachClient(replacement, "demo-1");
expect(replacement.workerPids.get("demo-1")).toBe(workerPid);
});
test("reports missing and ambiguous session selections", async () => {
const sharedDirectory = await mkdtemp(join("/tmp", "ped-"));
directories.add(sharedDirectory);
const firstShared = await startServer({
...sessionWorkerModel,
directory: sharedDirectory,
serverId: "00000000-0000-4000-8000-000000000001",
});
const secondShared = await startServer({
...sessionWorkerModel,
directory: sharedDirectory,
serverId: "00000000-0000-4000-8000-000000000002",
});
servers.add(firstShared);
servers.add(secondShared);
await expect(
runClient({ command: "client", sessionId: "missing" }, { directory: sharedDirectory }),
).rejects.toThrow("No discovered server contains session missing");
await expect(
runClient({ command: "client", sessionId: "demo-1" }, { directory: sharedDirectory }),
).rejects.toThrow("Session demo-1 is available from more than one server");
});
test("rejects a duplicate session ID within one session directory", async () => {
await expect(
createExperimentalSessions(
join(agentDir, "experimental", "sessions"),
["demo-1"],
join(agentDir, "other-cwd"),
),
).rejects.toThrow("Session demo-1 already exists");
});
});
async function pathExists(path: string): Promise<boolean> {
try {
await lstat(path);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
throw error;
}
}
function processExists(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException).code === "EPERM";
}
}

View file

@ -1,49 +0,0 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { ServerLifetime } from "../src/experimental/server.ts";
afterEach(() => {
vi.useRealTimers();
});
describe("server lifecycle", () => {
test("holds a foreground server until explicit shutdown", () => {
vi.useFakeTimers();
const retire = vi.fn();
const lifetime = new ServerLifetime(true);
lifetime.start(retire);
vi.advanceTimersByTime(60_000);
expect(retire).not.toHaveBeenCalled();
lifetime.stop();
});
test("retires an automatic server if no first client arrives", () => {
vi.useFakeTimers();
const retire = vi.fn();
const lifetime = new ServerLifetime(false);
lifetime.start(retire);
vi.advanceTimersByTime(10_999);
expect(retire).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(retire).toHaveBeenCalledOnce();
lifetime.stop();
});
test("requires both client and worker demand to disappear", () => {
vi.useFakeTimers();
const retire = vi.fn();
const lifetime = new ServerLifetime(false);
lifetime.start(retire);
lifetime.setConnectionCount(1);
lifetime.setWorkerCount(1);
lifetime.setConnectionCount(0);
vi.advanceTimersByTime(10_000);
expect(retire).not.toHaveBeenCalled();
lifetime.setWorkerCount(0);
vi.advanceTimersByTime(999);
expect(retire).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(retire).toHaveBeenCalledOnce();
lifetime.stop();
});
});

View file

@ -1,81 +0,0 @@
import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import { acquireServerProfile } from "../src/experimental/server.ts";
const directories = new Set<string>();
const FIRST_SERVER_ID = "00000000-0000-4000-8000-000000000001";
const SECOND_SERVER_ID = "00000000-0000-4000-8000-000000000002";
async function makeDirectory(): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), "psp-"));
directories.add(directory);
return directory;
}
afterEach(async () => {
await Promise.all([...directories].map((directory) => rm(directory, { recursive: true, force: true })));
directories.clear();
});
describe("experimental server profile", () => {
test("serializes launchers and preserves the server identity", async () => {
const directory = await makeDirectory();
const first = await acquireServerProfile(directory);
let secondAcquired = false;
const pendingSecond = acquireServerProfile(directory).then((profile) => {
secondAcquired = true;
return profile;
});
await new Promise<void>((resolve) => setImmediate(resolve));
expect(secondAcquired).toBe(false);
await first.release();
const second = await pendingSecond;
expect(secondAcquired).toBe(true);
expect(second.serverId).toBe(first.serverId);
expect((await readFile(join(directory, "default-server-id"), "utf8")).trim()).toBe(first.serverId);
await second.release();
});
test("does not serialize different server IDs in one directory", async () => {
const directory = await makeDirectory();
const [first, second] = await Promise.all([
acquireServerProfile(directory, FIRST_SERVER_ID),
acquireServerProfile(directory, SECOND_SERVER_ID),
]);
expect(first.serverId).toBe(FIRST_SERVER_ID);
expect(second.serverId).toBe(SECOND_SERVER_ID);
expect((await readdir(directory)).sort()).toEqual([
`launcher-${FIRST_SERVER_ID}.lock`,
`launcher-${SECOND_SERVER_ID}.lock`,
]);
await Promise.all([first.release(), second.release()]);
});
test("does not share the default identity across server directories", async () => {
const firstDirectory = await makeDirectory();
const secondDirectory = await makeDirectory();
const [first, second] = await Promise.all([
acquireServerProfile(firstDirectory),
acquireServerProfile(secondDirectory),
]);
expect(first.serverId).not.toBe(second.serverId);
await Promise.all([first.release(), second.release()]);
});
test("rejects a corrupt default identity", async () => {
const directory = await makeDirectory();
await writeFile(join(directory, "default-server-id"), "invalid\n");
await expect(acquireServerProfile(directory)).rejects.toThrow(/Invalid default experimental server identity/);
});
test("rejects an invalid explicit server ID", async () => {
const directory = await makeDirectory();
await expect(acquireServerProfile(directory, "invalid")).rejects.toThrow("Invalid experimental server ID");
});
});

View file

@ -1,90 +0,0 @@
import type { Context, RemoteServices } from "@earendil-works/chord";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import type { Client } from "@earendil-works/pi-client";
import {
createServerServiceSource,
createSessionServiceSource,
type ServerServiceSource,
type ServiceSourceOptions,
type SessionServiceSource,
} from "../src/experimental/services/connection.ts";
interface ServiceBindingOptions extends ServiceSourceOptions {
readonly services: readonly { readonly id: string }[];
}
/** Test helper for binding server services without a facet host. */
export function createServerServiceBinding(
client: Client,
options: ServiceBindingOptions,
): ServerServiceSource & RemoteServices {
const source = createServerServiceSource(client, options);
const services = source.open({
services: options.services,
assertAccess() {},
onError: options.onError ?? (() => {}),
});
const activation = services.ready(BACKGROUND_CONTEXT);
void activation.catch(options.onError ?? (() => {}));
const ready = async (context: Context): Promise<void> => {
await activation;
await services.ready(context);
};
return {
acceptsUnavailableServices: source.acceptsUnavailableServices,
connection: source.connection,
catalogue: (context) => source.catalogue(context),
open: (openOptions) => source.open(openOptions),
use: (service) => services.use(service),
observe: (service, handler) => services.observe(service, handler),
ready,
async dispose(context) {
const results = await Promise.allSettled([services.dispose(context), source.dispose(context)]);
throwFailures(results, "Failed to dispose server service binding");
},
};
}
/** Test helper for binding selected-Session services without a facet host. */
export function createSessionServiceBinding(
client: Client,
options: ServiceBindingOptions,
): SessionServiceSource & RemoteServices {
const source = createSessionServiceSource(client, options);
const services = source.open({
services: options.services,
assertAccess() {},
onError: options.onError ?? (() => {}),
});
const activation = services.ready(BACKGROUND_CONTEXT);
void activation.catch(options.onError ?? (() => {}));
const ready = async (context: Context): Promise<void> => {
await activation;
const attachment = source.attachment.value;
if (attachment?.status === "detached") await source.whenDetached(context);
else if (attachment !== undefined) await source.whenAttached(attachment.sessionId, context);
};
return {
get acceptsUnavailableServices() {
return source.acceptsUnavailableServices;
},
attachment: source.attachment,
catalogue: (context) => source.catalogue(context),
open: (openOptions) => source.open(openOptions),
use: (service) => services.use(service),
observe: (service, handler) => services.observe(service, handler),
ready,
whenAttached: (sessionId, context) => source.whenAttached(sessionId, context),
whenDetached: (context) => source.whenDetached(context),
async dispose(context) {
const results = await Promise.allSettled([services.dispose(context), source.dispose(context)]);
throwFailures(results, "Failed to dispose Session service binding");
},
};
}
function throwFailures(results: readonly PromiseSettledResult<unknown>[], message: string): void {
const errors = results.flatMap((result) => (result.status === "rejected" ? [result.reason] : []));
if (errors.length === 1) throw errors[0];
if (errors.length > 1) throw new AggregateError(errors, message);
}

View file

@ -1,24 +0,0 @@
import { homedir } from "node:os";
import { resolve } from "node:path";
import { afterEach, describe, expect, test, vi } from "vitest";
import { resolveSessionDirectory } from "../src/experimental/server.ts";
afterEach(() => vi.unstubAllEnvs());
describe("experimental server session directory", () => {
test("uses the experimental directory under the configured agent directory by default", () => {
vi.stubEnv("PI_CODING_AGENT_DIR", "/tmp/pi-agent-config");
expect(resolveSessionDirectory()).toBe("/tmp/pi-agent-config/experimental/sessions");
});
test("resolves an explicit relative directory from the current working directory", () => {
vi.stubEnv("PI_CODING_AGENT_DIR", "/tmp/pi-agent-config");
expect(resolveSessionDirectory("relative/sessions")).toBe(resolve("relative/sessions"));
});
test("expands a tilde in an explicit directory", () => {
expect(resolveSessionDirectory("~/custom-sessions")).toBe(resolve(homedir(), "custom-sessions"));
});
});

View file

@ -1,44 +0,0 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { AgentDoc, createSession, type ModelRef, ROOT_CONVERSATION_ID } from "@earendil-works/pi-durable";
import { openNodeSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite/node";
import {
createSession as createCatalogSession,
readSession,
type SessionCatalogMetadata,
sessionStoragePath,
} from "../src/experimental/session-catalog.ts";
export async function createExperimentalSessions(
sessionsRoot: string,
ids: readonly string[],
cwd = process.cwd(),
): Promise<SessionCatalogMetadata[]> {
const metadata: SessionCatalogMetadata[] = [];
for (const id of ids) metadata.push(await createCatalogSession(sessionsRoot, { id, cwd }));
return metadata;
}
export async function configureExperimentalWorkerModel(agentDir: string): Promise<void> {
await mkdir(agentDir, { recursive: true });
await writeFile(join(agentDir, "auth.json"), JSON.stringify({ anthropic: { type: "api_key", key: "test-key" } }), {
mode: 0o600,
});
}
/** The root conversation's model, read from the Session's durable storage while no worker owns it. */
export async function readExperimentalSessionState(
sessionsRoot: string,
sessionId: string,
): Promise<{ model: ModelRef | undefined }> {
const metadata = await readSession(sessionsRoot, sessionId);
if (metadata === undefined) throw new Error(`Expected Session ${sessionId}`);
const session = createSession(await openNodeSqliteStorage(sessionStoragePath(metadata)));
try {
const agent = await session.snapshot(AgentDoc, ROOT_CONVERSATION_ID, BACKGROUND_CONTEXT);
return { model: agent?.model };
} finally {
await session.close(BACKGROUND_CONTEXT);
}
}

View file

@ -1,157 +0,0 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { WorkerLifecycle } from "../src/experimental/session-worker.ts";
const GENERATION = "generation-1";
function createLifecycle(options: { initialDemandGraceMs?: number; orphanDemandGraceMs?: number } = {}) {
const retire = vi.fn();
const lifecycle = new WorkerLifecycle({
initialServerConnectionId: GENERATION,
initialDemandGraceMs: options.initialDemandGraceMs ?? 100,
orphanDemandGraceMs: options.orphanDemandGraceMs ?? 200,
onRetire: retire,
});
return { lifecycle, retire };
}
afterEach(() => {
vi.useRealTimers();
});
describe("Session worker lifecycle", () => {
test("retires only after client demand and Harness activity are both gone", async () => {
vi.useFakeTimers();
const { lifecycle, retire } = createLifecycle();
lifecycle.setDemand(GENERATION, "attachment-1", true);
lifecycle.setHarnessActive(true);
lifecycle.setDemand(GENERATION, "attachment-1", false);
expect(retire).not.toHaveBeenCalled();
lifecycle.setHarnessActive(false);
await vi.runAllTicks();
expect(retire).toHaveBeenCalledOnce();
lifecycle.close();
});
test("retains the worker until every presentation attachment is released", () => {
vi.useFakeTimers();
const { lifecycle, retire } = createLifecycle();
lifecycle.setDemand(GENERATION, "attachment-1", true);
lifecycle.setDemand(GENERATION, "attachment-2", true);
lifecycle.setDemand(GENERATION, "attachment-1", false);
expect(retire).not.toHaveBeenCalled();
lifecycle.setDemand(GENERATION, "attachment-2", false);
expect(retire).toHaveBeenCalledOnce();
lifecycle.close();
});
test("does not retire on repeated activity updates while the Harness stays active", () => {
vi.useFakeTimers();
const { lifecycle, retire } = createLifecycle();
lifecycle.setDemand(GENERATION, "attachment-1", true);
lifecycle.setHarnessActive(true);
lifecycle.setDemand(GENERATION, "attachment-1", false);
lifecycle.setHarnessActive(true);
expect(retire).not.toHaveBeenCalled();
lifecycle.setHarnessActive(false);
expect(retire).toHaveBeenCalledOnce();
lifecycle.close();
});
test("an idle Harness update does not retire while demand remains", () => {
vi.useFakeTimers();
const { lifecycle, retire } = createLifecycle();
lifecycle.setDemand(GENERATION, "attachment-1", true);
lifecycle.setHarnessActive(false);
expect(retire).not.toHaveBeenCalled();
lifecycle.close();
});
test("does not retire while a demand acknowledgement holds reconciliation", () => {
vi.useFakeTimers();
const { lifecycle, retire } = createLifecycle();
lifecycle.setDemand(GENERATION, "attachment-1", true);
const release = lifecycle.holdRetirement();
lifecycle.setDemand(GENERATION, "attachment-1", false);
expect(retire).not.toHaveBeenCalled();
release();
expect(retire).toHaveBeenCalledOnce();
lifecycle.close();
});
test("holds retirement only for requests from the active attachment", () => {
vi.useFakeTimers();
const { lifecycle, retire } = createLifecycle();
lifecycle.setDemand(GENERATION, "attachment-1", true);
const release = lifecycle.beginRequest(GENERATION, "attachment-1");
lifecycle.setDemand(GENERATION, "attachment-1", false);
expect(retire).not.toHaveBeenCalled();
release();
expect(retire).toHaveBeenCalledOnce();
expect(() => lifecycle.beginRequest(GENERATION, "attachment-1")).toThrow(/retiring/);
lifecycle.close();
});
test("rejects requests from stale generations and attachments", () => {
vi.useFakeTimers();
const { lifecycle } = createLifecycle();
lifecycle.setDemand(GENERATION, "attachment-1", true);
expect(() => lifecycle.beginRequest("stale", "attachment-1")).toThrow(/stale server generation/);
expect(() => lifecycle.beginRequest(GENERATION, "wrong-attachment")).toThrow(/active attachment/);
lifecycle.close();
});
test("retains disconnected-generation demand for the orphan grace", async () => {
vi.useFakeTimers();
const { lifecycle, retire } = createLifecycle();
lifecycle.setDemand(GENERATION, "attachment-1", true);
lifecycle.serverDisconnected(GENERATION);
vi.advanceTimersByTime(199);
expect(retire).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
await vi.runAllTicks();
expect(retire).toHaveBeenCalledOnce();
lifecycle.close();
});
test("allows a replacement generation to retain the worker", async () => {
vi.useFakeTimers();
const { lifecycle, retire } = createLifecycle();
lifecycle.setDemand(GENERATION, "attachment-1", true);
lifecycle.serverDisconnected(GENERATION);
lifecycle.serverConnected("generation-2");
lifecycle.setDemand("generation-2", "attachment-2", true);
vi.advanceTimersByTime(200);
expect(retire).not.toHaveBeenCalled();
lifecycle.setDemand("generation-2", "attachment-2", false);
await vi.runAllTicks();
expect(retire).toHaveBeenCalledOnce();
lifecycle.close();
});
test("retires a launched worker that never receives initial demand", async () => {
vi.useFakeTimers();
const { lifecycle, retire } = createLifecycle();
vi.advanceTimersByTime(100);
await vi.runAllTicks();
expect(retire).toHaveBeenCalledOnce();
lifecycle.close();
});
test("rejects demand after retirement has won the race", () => {
vi.useFakeTimers();
const { lifecycle } = createLifecycle();
vi.advanceTimersByTime(100);
expect(() => lifecycle.setDemand(GENERATION, "attachment-1", true)).toThrow(/retiring/);
lifecycle.close();
});
test("rejects demand from a stale server generation", () => {
vi.useFakeTimers();
const { lifecycle } = createLifecycle();
expect(() => lifecycle.setDemand("stale", "attachment-1", true)).toThrow(/stale server generation/);
lifecycle.close();
});
});

Some files were not shown because too many files have changed in this diff Show more