diff --git a/package-lock.json b/package-lock.json index ac4e14b13..9c236ad71 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index f7e0c62c8..0c7faa6d8 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/client/CHANGELOG.md b/packages/client/CHANGELOG.md deleted file mode 100644 index 5e263a077..000000000 --- a/packages/client/CHANGELOG.md +++ /dev/null @@ -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. diff --git a/packages/client/README.md b/packages/client/README.md deleted file mode 100644 index 3c5458b0e..000000000 --- a/packages/client/README.md +++ /dev/null @@ -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/.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. diff --git a/packages/client/package.json b/packages/client/package.json deleted file mode 100644 index 82a08dc9c..000000000 --- a/packages/client/package.json +++ /dev/null @@ -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" - } -} diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts deleted file mode 100644 index b5f416809..000000000 --- a/packages/client/src/client.ts +++ /dev/null @@ -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; - readonly decoder: ServiceStateDecoder; - readonly queuedWireUpdates: JsonValue[]; - readonly queued: ServiceProviderUpdate[]; - deliveryTail: Promise; - hydrated: boolean; - ready: boolean; -} - -export class Client { - readonly #options: ClientOptions; - readonly #connection: Connection; - readonly #pendingRequests = new Map(); - readonly #connectionStateListeners = new Set<(change: ConnectionStateChange) => void>(); - readonly #attachmentListeners = new Set(); - readonly #serviceListeners = new Map(); - #requestSequence = 0; - #serviceSubscriptionSequence = 0; - #hello: ServerHello | undefined; - #attachment: SessionTarget | undefined; - #disposed = false; - #disposePromise: Promise | 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 { - const client = new Client(options); - try { - await client.connect(); - return client; - } catch (error) { - await client.dispose(); - throw error; - } - } - - connect(): Promise { - if (this.#disposed) return Promise.reject(new ClientDisposedError()); - this.#hello = undefined; - return this.#connection.connect(); - } - - reconnect(): Promise { - 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 { - return this.#request(target, call, signal); - } - - async serviceCatalogue(target: RpcTarget, signal?: AbortSignal): Promise { - 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, - signal?: AbortSignal, - ): Promise { - 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( - target: RpcTarget, - call: ServiceCall, - signal?: AbortSignal, - transform?: (result: ServiceResult) => T, - ): Promise { - 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(); - 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 { - 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 { - 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"); -} diff --git a/packages/client/src/connection.ts b/packages/client/src/connection.ts deleted file mode 100644 index 53fc94c14..000000000 --- a/packages/client/src/connection.ts +++ /dev/null @@ -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 } & ActiveConnection) - | ({ - state: "connected"; - transport: ByteTransport; - handshake: PromiseResolvers | undefined; - } & ActiveConnection); - -interface ConnectionOptions { - transportFactory: ByteTransportFactory; - serverId: string; - maxFrameLength?: number; - onHandshake(hello: ServerHello): void; - onMessage(message: Exclude): 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 { - if (this.#lifecycle.state !== "disconnected") { - return Promise.reject(new DisconnectedError(`Client is already ${this.#lifecycle.state}`)); - } - const id = ++this.#sequence; - const handshake = createPromiseResolvers(); - 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; - 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 { - 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; - 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; - } -} diff --git a/packages/client/src/errors.ts b/packages/client/src/errors.ts deleted file mode 100644 index 13f82f60a..000000000 --- a/packages/client/src/errors.ts +++ /dev/null @@ -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); -} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts deleted file mode 100644 index 375d44c3b..000000000 --- a/packages/client/src/index.ts +++ /dev/null @@ -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"; diff --git a/packages/client/src/promise.ts b/packages/client/src/promise.ts deleted file mode 100644 index f5a65fc36..000000000 --- a/packages/client/src/promise.ts +++ /dev/null @@ -1,16 +0,0 @@ -export interface PromiseResolvers { - promise: Promise; - resolve(value: T | PromiseLike): void; - reject(reason?: unknown): void; -} - -/** Remove in favor of `Promise.withResolvers()` when the repository's TypeScript lib baseline moves to ES2024. */ -export function createPromiseResolvers(): PromiseResolvers { - let resolve!: PromiseResolvers["resolve"]; - let reject!: PromiseResolvers["reject"]; - const promise = new Promise((resolvePromise, rejectPromise) => { - resolve = resolvePromise; - reject = rejectPromise; - }); - return { promise, resolve, reject }; -} diff --git a/packages/client/src/transport.ts b/packages/client/src/transport.ts deleted file mode 100644 index 0916f9e62..000000000 --- a/packages/client/src/transport.ts +++ /dev/null @@ -1,18 +0,0 @@ -export interface ByteTransport { - /** Sends one byte chunk. Calls must be delivered in invocation order. */ - send(chunk: Uint8Array): Promise; - /** 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; diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts deleted file mode 100644 index bb036920b..000000000 --- a/packages/client/src/types.ts +++ /dev/null @@ -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; -} - -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; -} diff --git a/packages/client/src/unix.ts b/packages/client/src/unix.ts deleted file mode 100644 index 334ffe458..000000000 --- a/packages/client/src/unix.ts +++ /dev/null @@ -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 { - 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 { - return new Promise((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 = Promise.resolve(); - - constructor(socket: Socket, maxPendingBytes: number, markLocalClose: () => void) { - this.#socket = socket; - this.#maxPendingBytes = maxPendingBytes; - this.#markLocalClose = markLocalClose; - } - - send(chunk: Uint8Array): Promise { - 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 { - if (this.#closed || !this.#socket.writable) return Promise.reject(new Error("Unix transport is closed")); - return new Promise((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 { - 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 | undefined; - try { - await Promise.race([ - client.connect(), - new Promise((_, 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((resolve) => activeSocket.once("close", resolve)); - } - } -} - -class UnixDiscoveryTimeoutError extends Error {} - -function isErrorCode(error: unknown, code: string): boolean { - let current = error; - const seen = new Set(); - while (current instanceof Error && !seen.has(current)) { - seen.add(current); - if ("code" in current && current.code === code) return true; - current = current.cause; - } - return false; -} diff --git a/packages/client/test/client.test.ts b/packages/client/test/client.test.ts deleted file mode 100644 index 8bee49502..000000000 --- a/packages/client/test/client.test.ts +++ /dev/null @@ -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 { - return Client.connect({ serverId: expectedServerId, transportFactory: (handlers) => server.connect(handlers) }); -} - -async function attachClient(client: Client, server: MemoryByteServer, sessionId: string): Promise { - 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 = []; - 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[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); - }); -}); diff --git a/packages/client/test/fixtures/stale-socket-server.mjs b/packages/client/test/fixtures/stale-socket-server.mjs deleted file mode 100644 index e84582766..000000000 --- a/packages/client/test/fixtures/stale-socket-server.mjs +++ /dev/null @@ -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"); -}); diff --git a/packages/client/test/support.ts b/packages/client/test/support.ts deleted file mode 100644 index c3dcc9540..000000000 --- a/packages/client/test/support.ts +++ /dev/null @@ -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 { - 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(); - } - } -} diff --git a/packages/client/test/unix-transport.test.ts b/packages/client/test/unix-transport.test.ts deleted file mode 100644 index 0896b0045..000000000 --- a/packages/client/test/unix-transport.test.ts +++ /dev/null @@ -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(); -const servers = new Set(); -const sockets = new Set(); - -async function makeSocketPath(): Promise { - 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 { - servers.add(server); - server.on("connection", (socket) => { - sockets.add(socket); - socket.once("close", () => sockets.delete(socket)); - }); - await new Promise((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((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" }); - }); -}); diff --git a/packages/client/test/unix.test.ts b/packages/client/test/unix.test.ts deleted file mode 100644 index e9b6d5ca1..000000000 --- a/packages/client/test/unix.test.ts +++ /dev/null @@ -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(); -const servers = new Set(); -const rawServers = new Set(); -const rawSockets = new Set(); -const children = new Set(); - -async function makeDirectory(): Promise { - 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 { - 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 { - 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((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((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((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" }); - }); -}); diff --git a/packages/client/tsconfig.build.json b/packages/client/tsconfig.build.json deleted file mode 100644 index 0e72ae51a..000000000 --- a/packages/client/tsconfig.build.json +++ /dev/null @@ -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"] -} diff --git a/packages/client/tsconfig.test.json b/packages/client/tsconfig.test.json deleted file mode 100644 index 1ebf66d5a..000000000 --- a/packages/client/tsconfig.test.json +++ /dev/null @@ -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"] -} diff --git a/packages/client/vitest.config.ts b/packages/client/vitest.config.ts deleted file mode 100644 index d4dc421e3..000000000 --- a/packages/client/vitest.config.ts +++ /dev/null @@ -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"] } }, -}); diff --git a/packages/coding-agent/examples/README.md b/packages/coding-agent/examples/README.md index 80bc5e4e0..714f90640 100644 --- a/packages/coding-agent/examples/README.md +++ b/packages/coding-agent/examples/README.md @@ -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) diff --git a/packages/coding-agent/examples/plugins/pi-example-plugin/README.md b/packages/coding-agent/examples/plugins/pi-example-plugin/README.md deleted file mode 100644 index 0fb12f942..000000000 --- a/packages/coding-agent/examples/plugins/pi-example-plugin/README.md +++ /dev/null @@ -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 - } - } -} -``` diff --git a/packages/coding-agent/examples/plugins/pi-example-plugin/package.json b/packages/coding-agent/examples/plugins/pi-example-plugin/package.json deleted file mode 100644 index 434b255b8..000000000 --- a/packages/coding-agent/examples/plugins/pi-example-plugin/package.json +++ /dev/null @@ -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" - } -} diff --git a/packages/coding-agent/examples/plugins/pi-example-plugin/src/contract.ts b/packages/coding-agent/examples/plugins/pi-example-plugin/src/contract.ts deleted file mode 100644 index 14e26ad93..000000000 --- a/packages/coding-agent/examples/plugins/pi-example-plugin/src/contract.ts +++ /dev/null @@ -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; -} - -export const ExampleFacetService = defineService("pi.example-plugin.greeting"); diff --git a/packages/coding-agent/examples/plugins/pi-example-plugin/src/session.ts b/packages/coding-agent/examples/plugins/pi-example-plugin/src/session.ts deleted file mode 100644 index 71cbbc131..000000000 --- a/packages/coding-agent/examples/plugins/pi-example-plugin/src/session.ts +++ /dev/null @@ -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; - }); - }); - }, -}); diff --git a/packages/coding-agent/examples/plugins/pi-example-plugin/src/tui.ts b/packages/coding-agent/examples/plugins/pi-example-plugin/src/tui.ts deleted file mode 100644 index 9ba3d2775..000000000 --- a/packages/coding-agent/examples/plugins/pi-example-plugin/src/tui.ts +++ /dev/null @@ -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: "", - 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); - }, - }), - ); - }); - }, -}); diff --git a/packages/coding-agent/install-lock/package-lock.json b/packages/coding-agent/install-lock/package-lock.json index 1ac32ac63..d0999e69c 100644 --- a/packages/coding-agent/install-lock/package-lock.json +++ b/packages/coding-agent/install-lock/package-lock.json @@ -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", diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index dddd82ce6..8b86d70a9 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -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", diff --git a/packages/coding-agent/src/cli/experimental/cli.ts b/packages/coding-agent/src/cli/experimental/cli.ts deleted file mode 100644 index 4843e86be..000000000 --- a/packages/coding-agent/src/cli/experimental/cli.ts +++ /dev/null @@ -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("experimental").build(() => ({ - ok: false, - errors: ["Expected experimental command: server or client"], -})); - -export const cli = experimentalCommand.command(serverCommand).command(clientCommand); diff --git a/packages/coding-agent/src/cli/experimental/command-options.ts b/packages/coding-agent/src/cli/experimental/command-options.ts deleted file mode 100644 index d0fe30257..000000000 --- a/packages/coding-agent/src/cli/experimental/command-options.ts +++ /dev/null @@ -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`]; -} diff --git a/packages/coding-agent/src/cli/experimental/command.ts b/packages/coding-agent/src/cli/experimental/command.ts deleted file mode 100644 index 990794295..000000000 --- a/packages/coding-agent/src/cli/experimental/command.ts +++ /dev/null @@ -1,225 +0,0 @@ -export interface NamedCommandInvocation { - readonly command: string; -} - -export type CommandParseResult = - | { readonly ok: true; readonly command: TInvocation } - | { readonly ok: false; readonly errors: readonly string[] }; - -export type CommandExecutionResult = - | { readonly ok: true; readonly command: TInvocation } - | { readonly ok: false; readonly errors: readonly string[] }; - -export type CommandOptionParseResult = - | { readonly ok: true; readonly value: TValue } - | { readonly ok: false; readonly error: string }; - -export interface CommandOption { - readonly name: `-${string}`; - readonly flag?: boolean; - readonly repeatable?: boolean; - parse(value: string): CommandOptionParseResult; -} - -export function valueOption( - name: `-${string}`, - parse: (value: string) => CommandOptionParseResult, - options: { readonly repeatable?: boolean } = {}, -): CommandOption { - return { name, parse, ...options }; -} - -export function stringOption( - name: `-${string}`, - options: { readonly repeatable?: boolean } = {}, -): CommandOption { - return valueOption(name, (value) => ({ ok: true, value }), options); -} - -export function flagOption(name: `-${string}`): CommandOption { - return { name, flag: true, parse: () => ({ ok: true, value: true }) }; -} - -export interface ParsedCommandInput { - readonly remainingArgs: readonly string[]; - value(option: CommandOption): TValue | undefined; - values(option: CommandOption): readonly TValue[]; -} - -export type CommandBuildResult = - | { readonly ok: true; readonly command: TInvocation } - | { readonly ok: false; readonly errors: readonly string[] }; - -interface MutableParsedCommandInput { - readonly values: Map; - readonly remainingArgs: string[]; - readonly errors: string[]; -} - -type CommandBuilder = ( - input: ParsedCommandInput, -) => CommandBuildResult; - -type CommandAction = ( - command: TInvocation, - context: TContext, -) => void | Promise; - -interface RegisteredCommand { - parse(argv: readonly string[]): CommandParseResult; - execute(argv: readonly string[], context: unknown): Promise; -} - -export class Command< - TOwnInvocation extends NamedCommandInvocation, - TContext, - TInvocation extends NamedCommandInvocation = TOwnInvocation, -> { - readonly name: string; - private readonly options = new Map>(); - private readonly subcommands = new Map(); - private builder?: CommandBuilder; - private commandAction?: CommandAction; - - constructor(name: string) { - this.name = name; - } - - option(option: CommandOption): 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): this { - this.builder = builder; - return this; - } - - action(action: CommandAction): this { - this.commandAction = action; - return this; - } - - command< - TSubcommandOwnInvocation extends NamedCommandInvocation, - TSubcommandContext, - TSubcommandInvocation extends NamedCommandInvocation, - >( - command: Command, - ): Command { - 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 { - const selected = this.select(argv); - if (selected) return selected.command.parse(selected.argv) as CommandParseResult; - return this.parseOwn(argv) as CommandParseResult; - } - - async execute(argv: readonly string[], context: TContext): Promise> { - const selected = this.select(argv); - if (selected) { - return selected.command.execute(selected.argv, context) as Promise>; - } - - 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 { - 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: (option: CommandOption) => parsed.values.get(option.name)?.[0] as TValue | undefined, - values: (option: CommandOption) => (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; - } -} diff --git a/packages/coding-agent/src/cli/experimental/commands/client.ts b/packages/coding-agent/src/cli/experimental/commands/client.ts deleted file mode 100644 index e44fbfe9d..000000000 --- a/packages/coding-agent/src/cli/experimental/commands/client.ts +++ /dev/null @@ -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; -} - -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("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)); diff --git a/packages/coding-agent/src/cli/experimental/commands/server.ts b/packages/coding-agent/src/cli/experimental/commands/server.ts deleted file mode 100644 index 469b4268b..000000000 --- a/packages/coding-agent/src/cli/experimental/commands/server.ts +++ /dev/null @@ -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; -} - -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("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)); diff --git a/packages/coding-agent/src/client/index.ts b/packages/coding-agent/src/client/index.ts deleted file mode 100644 index 24a94a283..000000000 --- a/packages/coding-agent/src/client/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@earendil-works/pi-client"; diff --git a/packages/coding-agent/src/experimental/cli.ts b/packages/coding-agent/src/experimental/cli.ts deleted file mode 100644 index 8ff1750da..000000000 --- a/packages/coding-agent/src/experimental/cli.ts +++ /dev/null @@ -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); -} diff --git a/packages/coding-agent/src/experimental/client-runtime.ts b/packages/coding-agent/src/experimental/client-runtime.ts deleted file mode 100644 index 03e4d14e4..000000000 --- a/packages/coding-agent/src/experimental/client-runtime.ts +++ /dev/null @@ -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; -} - -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 { - 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 = []; - const servers: ClientRuntimeServer[] = []; - let disposed = false; - const dispose = async (): Promise => { - 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 { - 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 .sock"); - return { serverId, path }; -} diff --git a/packages/coding-agent/src/experimental/client-tui-chat.ts b/packages/coding-agent/src/experimental/client-tui-chat.ts deleted file mode 100644 index 90abef609..000000000 --- a/packages/coding-agent/src/experimental/client-tui-chat.ts +++ /dev/null @@ -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 = 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(); - /** 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(); - #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)); - } -} diff --git a/packages/coding-agent/src/experimental/client-tui.ts b/packages/coding-agent/src/experimental/client-tui.ts deleted file mode 100644 index dfb3f4965..000000000 --- a/packages/coding-agent/src/experimental/client-tui.ts +++ /dev/null @@ -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 | undefined; - #recoveryTransition: Promise = 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 { - 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 { - this.#closePromise ??= this.#close(); - return this.#closePromise; - } - - async #start(prepared: PreparedClientSession): Promise { - const server = prepared.server; - let presentationFacets = await combineFacetLoaders( - createPresentationFacetLoaders(prepared.presentationPlugins), - ).load(); - this.#presentationFacets = presentationFacets; - let facetHost!: FacetHost; - const reloadPresentationPlugins = (data: JsonValue): Promise => { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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((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(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); -} diff --git a/packages/coding-agent/src/experimental/client.ts b/packages/coding-agent/src/experimental/client.ts deleted file mode 100644 index dc2b01e83..000000000 --- a/packages/coding-agent/src/experimental/client.ts +++ /dev/null @@ -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 { - 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(); - } -} diff --git a/packages/coding-agent/src/experimental/commands.ts b/packages/coding-agent/src/experimental/commands.ts deleted file mode 100644 index b1d824b11..000000000 --- a/packages/coding-agent/src/experimental/commands.ts +++ /dev/null @@ -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 { - 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((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 { - 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 { - 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; -} diff --git a/packages/coding-agent/src/experimental/coordinator-entry.ts b/packages/coding-agent/src/experimental/coordinator-entry.ts deleted file mode 100644 index 339106295..000000000 --- a/packages/coding-agent/src/experimental/coordinator-entry.ts +++ /dev/null @@ -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); -}); diff --git a/packages/coding-agent/src/experimental/coordinator.ts b/packages/coding-agent/src/experimental/coordinator.ts deleted file mode 100644 index dde1ba003..000000000 --- a/packages/coding-agent/src/experimental/coordinator.ts +++ /dev/null @@ -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; - -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; - readonly peerIds = new Set(); - 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 { - 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((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 { - return this.#write({ type: "send", to: peerId, payload }); - } - - broadcast(payload: unknown): Promise { - 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 { - 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 { - 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((resolve) => setTimeout(resolve, COORDINATOR_RETRY_MS)); - } -} - -function connectSocket(path: string): Promise { - 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 { - 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 { - 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(); -const controlConnections = new Set(); -const publicConnections = new Map(); -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 { - 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 { - 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 { - 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 { - 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 { - if (!server.listening) return Promise.resolve(); - return new Promise((resolve) => server.close(() => resolve())); -} - -async function restrictSocket(path: string): Promise { - if (process.platform !== "win32") await chmod(path, 0o600); -} - -async function removeStaleSocket(path: string): Promise { - 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((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 { - 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); - }); -} diff --git a/packages/coding-agent/src/experimental/durable/README.md b/packages/coding-agent/src/experimental/durable/README.md deleted file mode 100644 index c5a1410c2..000000000 --- a/packages/coding-agent/src/experimental/durable/README.md +++ /dev/null @@ -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///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`. diff --git a/packages/coding-agent/src/experimental/durable/harness-setup.ts b/packages/coding-agent/src/experimental/durable/harness-setup.ts deleted file mode 100644 index 4d6a4be53..000000000 --- a/packages/coding-agent/src/experimental/durable/harness-setup.ts +++ /dev/null @@ -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(); - - 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 { - 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 { - 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 }), - }; -} diff --git a/packages/coding-agent/src/experimental/durable/main.ts b/packages/coding-agent/src/experimental/durable/main.ts deleted file mode 100644 index d3d843d5f..000000000 --- a/packages/coding-agent/src/experimental/durable/main.ts +++ /dev/null @@ -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(); -} diff --git a/packages/coding-agent/src/experimental/durable/prompt.ts b/packages/coding-agent/src/experimental/durable/prompt.ts deleted file mode 100644 index 8e2f5d527..000000000 --- a/packages/coding-agent/src/experimental/durable/prompt.ts +++ /dev/null @@ -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(); - 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>(); - const build = (input: PromptInput): Record => { - let sections = built.get(input); - if (sections === undefined) { - sections = buildSections(input); - built.set(input, sections); - } - return sections; - }; - const buildSections = (input: PromptInput): Record => { - const cwd = input.env?.cwd ?? input.agent.cwd ?? fallbackCwd; - const selectedTools = input.agent.tools.map((tool) => tool.name); - const snippets: Record = {}; - const guidelines: Record = {}; - 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 })), - }); -} diff --git a/packages/coding-agent/src/experimental/durable/runtime.ts b/packages/coding-agent/src/experimental/durable/runtime.ts deleted file mode 100644 index 083f3f115..000000000 --- a/packages/coding-agent/src/experimental/durable/runtime.ts +++ /dev/null @@ -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; - compact(instructions: string | undefined): Promise; - abort(): Promise; - cycleThinking(): Promise; - setModel(model: ModelRef): Promise; - toggleTasks(): Promise; - /** Show and talk to another conversation. */ - switchConversation(id: ConversationId): Promise; -} - -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; -} - -/** 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 { - 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 = 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): 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 | 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): Promise => { - 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 | 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; - } -} diff --git a/packages/coding-agent/src/experimental/durable/sessions.ts b/packages/coding-agent/src/experimental/durable/sessions.ts deleted file mode 100644 index 34f9ceeea..000000000 --- a/packages/coding-agent/src/experimental/durable/sessions.ts +++ /dev/null @@ -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; -} - -/** A new session for `cwd`, or its newest one with `continueSession`. */ -export async function selectSession(cwdInput: string, continueSession: boolean): Promise { - 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; - 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 }; -} diff --git a/packages/coding-agent/src/experimental/durable/subagent.ts b/packages/coding-agent/src/experimental/durable/subagent.ts deleted file mode 100644 index e767d54e0..000000000 --- a/packages/coding-agent/src/experimental/durable/subagent.ts +++ /dev/null @@ -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 { - 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 } }; - }, - }), - ], -}); diff --git a/packages/coding-agent/src/experimental/durable/tui.ts b/packages/coding-agent/src/experimental/durable/tui.ts deleted file mode 100644 index 6eef2e775..000000000 --- a/packages/coding-agent/src/experimental/durable/tui.ts +++ /dev/null @@ -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 = 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(); - /** 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(); - 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 { - 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((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(); -} diff --git a/packages/coding-agent/src/experimental/plugin.ts b/packages/coding-agent/src/experimental/plugin.ts deleted file mode 100644 index 274671137..000000000 --- a/packages/coding-agent/src/experimental/plugin.ts +++ /dev/null @@ -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"; diff --git a/packages/coding-agent/src/experimental/plugins/bundled.ts b/packages/coding-agent/src/experimental/plugins/bundled.ts deleted file mode 100644 index 3ab43dcb0..000000000 --- a/packages/coding-agent/src/experimental/plugins/bundled.ts +++ /dev/null @@ -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; -} diff --git a/packages/coding-agent/src/experimental/plugins/package.ts b/packages/coding-agent/src/experimental/plugins/package.ts deleted file mode 100644 index 57c353738..000000000 --- a/packages/coding-agent/src/experimental/plugins/package.ts +++ /dev/null @@ -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; -} - -/** 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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}`; -} diff --git a/packages/coding-agent/src/experimental/process.ts b/packages/coding-agent/src/experimental/process.ts deleted file mode 100644 index cea91b0e7..000000000 --- a/packages/coding-agent/src/experimental/process.ts +++ /dev/null @@ -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 { - if (child.pid === undefined || child.exitCode !== null || child.signalCode !== null) return; - const terminated = new Promise((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; -} diff --git a/packages/coding-agent/src/experimental/radius-auth.ts b/packages/coding-agent/src/experimental/radius-auth.ts deleted file mode 100644 index 85265cc79..000000000 --- a/packages/coding-agent/src/experimental/radius-auth.ts +++ /dev/null @@ -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 | 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 { - 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 { - 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; - } -} diff --git a/packages/coding-agent/src/experimental/radius-relay.ts b/packages/coding-agent/src/experimental/radius-relay.ts deleted file mode 100644 index 0eaffe805..000000000 --- a/packages/coding-agent/src/experimental/radius-relay.ts +++ /dev/null @@ -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; - close(finalChunk?: Uint8Array): Promise; -} - -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; - 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 | undefined; - #closed = false; - - constructor(options: RadiusRelayHostOptions) { - this.#options = options; - } - - start(): void { - if (this.#loop !== undefined) return; - this.#loop = this.#run(); - } - - async close(): Promise { - 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 { - 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 { - this.#socket = socket; - this.#writer = new OrderedWebSocketWriter(socket); - return new Promise((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 { - 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 { - 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 { - 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; - readonly #abortController = new AbortController(); - readonly #removeConnectionListener: () => void; - readonly #removeAttachmentListener: () => void; - #desiredSessionId: string | undefined; - #reconnecting: Promise | undefined; - #disposed = false; - - constructor(client: RadiusReconnectClient, reattach: (sessionId: string) => Promise) { - 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 { - 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 { - 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; - readonly #closeConnection: (finalChunk?: Uint8Array) => Promise; - #closed = false; - - constructor( - sendChunk: (chunk: Uint8Array) => Promise, - closeConnection: (finalChunk?: Uint8Array) => Promise, - ) { - this.#sendChunk = sendChunk; - this.#closeConnection = closeConnection; - } - - get closed(): boolean { - return this.#closed; - } - - send(chunk: Uint8Array): Promise { - if (this.#closed) return Promise.reject(new Error("Radius relay connection is closed")); - return this.#sendChunk(chunk); - } - - async close(finalChunk?: Uint8Array): Promise { - 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 { - 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 = Promise.resolve(); - #pendingBytes = 0; - #closed = false; - - constructor(socket: RadiusRelayWebSocket) { - this.#socket = socket; - } - - send(value: string | ArrayBuffer): Promise { - 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 { - 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 { - 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; -} diff --git a/packages/coding-agent/src/experimental/server.ts b/packages/coding-agent/src/experimental/server.ts deleted file mode 100644 index 387280784..000000000 --- a/packages/coding-agent/src/experimental/server.ts +++ /dev/null @@ -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 { - 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; -} - -/** Lock one logical server ID in a shared experimental server directory. */ -export async function acquireServerProfile(directory: string, requestedServerId?: string): Promise { - 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 { - 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((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> { - 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 { - 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(); - 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; - readonly closed: Promise; - close(): Promise; -} - -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; - removeSessionPlugins(metadata: SessionCatalogMetadata): Promise; - reloadPresentationFacetBundles(packagePaths: readonly string[]): Promise; -} - -interface RunningServerBackend extends RunningServer { - refreshSessions(): Promise; -} - -async function startServerBackend( - options: StartServerBackendOptions, - workers: SessionWorkerManager, - onConnectionCountChanged?: (count: number) => void, -): Promise { - const serverId = options.serverId; - const sessionDir = resolveSessionDirectory(options.sessionDir); - const listSessions = async (): Promise => { - 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 => { - 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 => - 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 = { - 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 => 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 | 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 { - 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>(); - const getPluginPackage = (packagePath: string): ReturnType => { - 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 => { - 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(); - const resolveSessionPlugins = async ( - metadata: SessionCatalogMetadata, - requestedPackagePaths: readonly string[] | undefined, - _context: Context, - ): Promise => { - 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 => { - sessionPluginSelections.delete(metadata.path); - await removeSessionPluginPackageProfile(directory, serverId, metadata.path); - }; - const reloadPresentationFacetBundles = async ( - packagePaths: readonly string[], - ): Promise => { - 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 | 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 = {}, -): Promise { - 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 { - 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); - }); -} diff --git a/packages/coding-agent/src/experimental/services/README.md b/packages/coding-agent/src/experimental/services/README.md deleted file mode 100644 index 2281961a3..000000000 --- a/packages/coding-agent/src/experimental/services/README.md +++ /dev/null @@ -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()`. diff --git a/packages/coding-agent/src/experimental/services/agent-controller-provider.ts b/packages/coding-agent/src/experimental/services/agent-controller-provider.ts deleted file mode 100644 index 3c597884f..000000000 --- a/packages/coding-agent/src/experimental/services/agent-controller-provider.ts +++ /dev/null @@ -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 => { - 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 { - 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) }; -} diff --git a/packages/coding-agent/src/experimental/services/agent-controller.ts b/packages/coding-agent/src/experimental/services/agent-controller.ts deleted file mode 100644 index f431785c4..000000000 --- a/packages/coding-agent/src/experimental/services/agent-controller.ts +++ /dev/null @@ -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; - /** Steer the active run, or start one when idle. */ - steer(request: AgentPromptRequest, context: Context): Promise; - /** Queue input for after the active run, or start one when idle. */ - followUp(request: AgentPromptRequest, context: Context): Promise; - 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; - compact(request: AgentCompactionRequest, context: Context): Promise; - /** Wait until the prompt with this `operationId` is answered or settles unanswered. */ - waitForPrompt(operationId: string, context: Context): Promise; -} - -export const AgentController = defineService("pi.agent-controller"); diff --git a/packages/coding-agent/src/experimental/services/connection.ts b/packages/coding-agent/src/experimental/services/connection.ts deleted file mode 100644 index 3a167ff7b..000000000 --- a/packages/coding-agent/src/experimental/services/connection.ts +++ /dev/null @@ -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; - dispose(context: Context): Promise; -} - -export interface SessionServiceSource extends RemoteServiceSource { - readonly attachment: ReplicatedState; - /** Wait for the exact current attachment generation to finish hydrating. */ - whenAttached(sessionId: string, context: Context): Promise; - /** Wait for every binding to finish releasing the previous attachment. */ - whenDetached(context: Context): Promise; - dispose(context: Context): Promise; -} - -export interface ServiceSourceOptions { - readonly onError?: (error: Error) => void; -} - -class RoutedServiceBinding implements RemoteServices { - readonly #services: RemoteServiceBinding; - readonly #getBound: () => boolean; - readonly #onActivate: (context: Context) => Promise; - 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; - 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(service: Service): T { - return this.#services.use(service); - } - - observe(service: Service, handler: (service: T, context: Context) => void | Promise): () => void { - return this.#services.observe(service, handler); - } - - async ready(context: Context): Promise { - 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 { - return this.#services.ready(context); - } - - updateBound(bound: boolean, context: Context): Promise { - return this.#activated ? this.#services.rebind(bound, context) : Promise.resolve(); - } - - async dispose(context: Context): Promise { - this.#remove(); - await this.#services.dispose(context); - } -} - -class ServerServiceSourceImpl implements ServerServiceSource { - readonly acceptsUnavailableServices = false; - readonly connection: ReplicatedState; - readonly #client: Client; - readonly #transport: RemoteServiceTransport; - readonly #bindings = new Set(); - 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( - 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 { - 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; - readonly #client: Client; - readonly #transport: RemoteServiceTransport; - readonly #attachmentState: MutableReplicatedState; - readonly #bindings = new Set(); - readonly #removeAttachmentListener: () => void; - readonly #onError: (error: Error) => void; - readonly #transitions = new Set>(); - #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( - 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 { - 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 { - 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 { - if (this.#client.attachment !== undefined) throw new Error("A Session is still attached"); - await this.#whenDetached(this.#attachmentRevision, context); - } - - async dispose(context: Context): Promise { - 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 { - 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 { - 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 { - 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(state: MutableReplicatedState, 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[], 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)); -} diff --git a/packages/coding-agent/src/experimental/services/models-provider.ts b/packages/coding-agent/src/experimental/services/models-provider.ts deleted file mode 100644 index d690a332c..000000000 --- a/packages/coding-agent/src/experimental/services/models-provider.ts +++ /dev/null @@ -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; - /** 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, - modelRuntime: ModelRuntime | undefined, - settingsManager: SettingsManager | undefined, - createState: (initial: ModelsState) => MutableReplicatedState, -): ModelsServiceRuntime { - let catalogRevision = 0; - const configurationOf = (value: Readonly | 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 { - 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); -} diff --git a/packages/coding-agent/src/experimental/services/models.ts b/packages/coding-agent/src/experimental/services/models.ts deleted file mode 100644 index 65be15480..000000000 --- a/packages/coding-agent/src/experimental/services/models.ts +++ /dev/null @@ -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 }; -} - -export interface Models { - readonly state: ReplicatedState; - cycleThinking(context: Context): Promise; - getThinkingLevels(context: Context): Promise; - refresh(context: Context): Promise; - select(model: ModelRef, context: Context): Promise; - selectThinking(level: ModelThinkingLevel, context: Context): Promise; -} - -export const Models = defineService("pi.models"); diff --git a/packages/coding-agent/src/experimental/services/plugins.ts b/packages/coding-agent/src/experimental/services/plugins.ts deleted file mode 100644 index 55adf7dc5..000000000 --- a/packages/coding-agent/src/experimental/services/plugins.ts +++ /dev/null @@ -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; - reload(context: Context): Promise; -} - -export const PresentationPlugins = defineService("pi.presentation-plugins"); - -/** Plugin facets hosted in the currently attached Session worker. */ -export interface SessionPlugins { - reload(context: Context): Promise; -} - -export const SessionPlugins = defineService("pi.session-plugins"); diff --git a/packages/coding-agent/src/experimental/services/presentation-ui.ts b/packages/coding-agent/src/experimental/services/presentation-ui.ts deleted file mode 100644 index 11503eedd..000000000 --- a/packages/coding-agent/src/experimental/services/presentation-ui.ts +++ /dev/null @@ -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; - showStatus(message: string, context: Context): void; -} - -export const PresentationUI = defineService("pi.local.presentation-ui", { local: true }); diff --git a/packages/coding-agent/src/experimental/services/server.ts b/packages/coding-agent/src/experimental/services/server.ts deleted file mode 100644 index 3cc501cc4..000000000 --- a/packages/coding-agent/src/experimental/services/server.ts +++ /dev/null @@ -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; - dispose(): Promise; -} - -export async function createExperimentalServerServices(options: { - list(context: Context): Promise; - create(createOptions: SessionCreateOptions, context: Context): Promise; - remove(sessionId: string, context: Context): Promise; - prepareSessionPlugins( - sessionId: string, - packagePaths: readonly string[] | undefined, - context: Context, - ): Promise<{ readonly packagePaths: readonly string[]; readonly presentationPlugins: JsonValue }>; - reloadPresentationPlugins(packagePaths: readonly string[], context: Context): Promise; -}): Promise { - let revision = 1; - const directory = replicatedState({ - revision, - sessions: await options.list(BACKGROUND_CONTEXT), - }); - const attachments = new Set(); - let mutationTail = Promise.resolve(); - - const refreshNow = async (context: Context): Promise => { - const sessions = await options.list(context); - revision += 1; - directory.change(context, (draft) => { - draft.revision = revision; - draft.sessions = sessions; - }); - }; - const serialize = (operation: () => Promise): Promise => { - 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(); - }, - }; -} diff --git a/packages/coding-agent/src/experimental/services/sessions.ts b/packages/coding-agent/src/experimental/services/sessions.ts deleted file mode 100644 index 79dbec2f5..000000000 --- a/packages/coding-agent/src/experimental/services/sessions.ts +++ /dev/null @@ -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; -} - -export const SessionDirectory = defineService("pi.session-directory"); - -export interface SessionManagement { - create(options: SessionCreateOptions, context: Context): Promise; - remove(sessionId: string, context: Context): Promise; - attach(sessionId: string, context: Context): Promise; - detach(context: Context): Promise; -} - -export const SessionManagement = defineService("pi.session-management"); diff --git a/packages/coding-agent/src/experimental/services/slash-commands-provider.ts b/packages/coding-agent/src/experimental/services/slash-commands-provider.ts deleted file mode 100644 index 977f5b1a7..000000000 --- a/packages/coding-agent/src/experimental/services/slash-commands-provider.ts +++ /dev/null @@ -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 = { - 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(); - 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; -}): 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: "", - 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: "", - 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: "", - 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; -} diff --git a/packages/coding-agent/src/experimental/services/slash-commands.ts b/packages/coding-agent/src/experimental/services/slash-commands.ts deleted file mode 100644 index 62927dbb5..000000000 --- a/packages/coding-agent/src/experimental/services/slash-commands.ts +++ /dev/null @@ -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; - run(args: string, context: Context): SlashCommandRunResult | Promise; -} - -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("pi.local.slash-commands", { local: true }); diff --git a/packages/coding-agent/src/experimental/services/transcript-provider.ts b/packages/coding-agent/src/experimental/services/transcript-provider.ts deleted file mode 100644 index 9a01e418b..000000000 --- a/packages/coding-agent/src/experimental/services/transcript-provider.ts +++ /dev/null @@ -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 { - const state = await conversation.viewState(context); - return defineFacet({ - id: "@pi/transcript", - setup(env) { - env.own(() => state.dispose()); - env.provide(Transcript, { state }); - }, - }); -} diff --git a/packages/coding-agent/src/experimental/services/transcript.ts b/packages/coding-agent/src/experimental/services/transcript.ts deleted file mode 100644 index e588d6fa9..000000000 --- a/packages/coding-agent/src/experimental/services/transcript.ts +++ /dev/null @@ -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; -} - -export const Transcript = defineService("pi.transcript"); diff --git a/packages/coding-agent/src/experimental/services/worker.ts b/packages/coding-agent/src/experimental/services/worker.ts deleted file mode 100644 index 00c4f34bc..000000000 --- a/packages/coding-agent/src/experimental/services/worker.ts +++ /dev/null @@ -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; -} - -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; - removeSubscriptions(matches: (scope: WorkerServiceScope) => boolean): void; - dispose(): Promise; -} - -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; -}): Promise { - const agentControllerRuntimeFacet = defineFacet({ - id: "@pi/agent-controller-runtime", - setup(env) { - env.provide(AgentController, createAgentController(options.harness, options.conversation)); - }, - }); - let reloadPlugins = (): Promise => 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(); - 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}`; -} diff --git a/packages/coding-agent/src/experimental/session-catalog.ts b/packages/coding-agent/src/experimental/session-catalog.ts deleted file mode 100644 index e48343df6..000000000 --- a/packages/coding-agent/src/experimental/session-catalog.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - await rm(metadata.path, { recursive: true, force: true }); -} - -function isNotFound(error: unknown): boolean { - return (error as NodeJS.ErrnoException | undefined)?.code === "ENOENT"; -} diff --git a/packages/coding-agent/src/experimental/session-worker-manager.ts b/packages/coding-agent/src/experimental/session-worker-manager.ts deleted file mode 100644 index c5b2513d9..000000000 --- a/packages/coding-agent/src/experimental/session-worker-manager.ts +++ /dev/null @@ -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; - resolveTerminated(error: Error | undefined): void; - readonly attachmentIds: Set; - expectedStop: boolean; - stopPromise?: Promise; - 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; - deliveryTail: Promise; -} - -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; - 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(); - 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(); - readonly #workersByPeer = new Map(); - readonly #pending = new Map(); - readonly #pendingDemand = new Map(); - readonly #pendingOperations = new Map(); - readonly #serviceSubscriptions = new Map(); - readonly #removeListener: () => void; - readonly #onWorkerCountChanged: ((count: number) => void) | undefined; - #discoveryPeers?: Set; - #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): Promise { - 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((resolve) => { - this.#resolveDiscovery = resolve; - }); - await this.#coordinator.broadcast({ type: "discover_workers" }); - let timer: NodeJS.Timeout | undefined; - await Promise.race([ - discovered, - new Promise((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 { - 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 { - 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 { - 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, - context: Context, - ): Promise { - 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 { - 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((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 { - 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((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 { - 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 { - 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((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 { - 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((resolve) => pending.child.once("exit", () => resolve())); - }), - ).then(() => undefined); - let timer: NodeJS.Timeout | undefined; - const pendingTimedOut = new Promise((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 { - 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((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): 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((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 { - 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"); -} diff --git a/packages/coding-agent/src/experimental/session-worker.ts b/packages/coding-agent/src/experimental/session-worker.ts deleted file mode 100644 index 498404a99..000000000 --- a/packages/coding-agent/src/experimental/session-worker.ts +++ /dev/null @@ -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 = [0]>(properties: T) => - Type.Object(properties, { additionalProperties: false }); -const OpaqueJsonValueSchema = Type.Unsafe(Type.Unknown()); -const ServiceCallSchema = Type.Unsafe( - 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( - 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; - -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; - -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; - -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; - -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; - -/** 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(); - #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; - -interface WorkerControl { - readonly initialServerConnectionId?: string; - readonly messages: AsyncIterable; - readonly socket: Socket; - send(event: SessionWorkerEvent): Promise; -} - -let failureControl: WorkerControl | undefined; - -async function connectControl(): Promise { - 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((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): Promise; - onOperation(command: WorkerOperationRequest): void; - onOperationCancel(command: Extract): void; - onServerConnected(serverConnectionId: string): void; - onServerDisconnected(serverConnectionId: string): void; - }, -): Promise { - 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 { - 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((resolve) => waiters.push(resolve))); - return { done: false as const, value }; - }, - }; - }, - }; -} - -function writeJsonLine(socket: Socket, message: unknown): Promise { - 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; -}): Promise { - 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; - -async function run(options: SessionWorkerOptions, createHarness: CreateSessionWorkerHarness): Promise { - 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 | 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 | undefined; - const close = (): Promise => { - 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 => { - 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 { - 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 { - 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 { - 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)); -} diff --git a/packages/coding-agent/src/experimental/vacation/README.md b/packages/coding-agent/src/experimental/vacation/README.md deleted file mode 100644 index 82ec2c03c..000000000 --- a/packages/coding-agent/src/experimental/vacation/README.md +++ /dev/null @@ -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///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 | diff --git a/packages/coding-agent/src/experimental/vacation/harness-setup.ts b/packages/coding-agent/src/experimental/vacation/harness-setup.ts deleted file mode 100644 index bffa15ee5..000000000 --- a/packages/coding-agent/src/experimental/vacation/harness-setup.ts +++ /dev/null @@ -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 { - 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 }), - }; -} diff --git a/packages/coding-agent/src/experimental/vacation/main.ts b/packages/coding-agent/src/experimental/vacation/main.ts deleted file mode 100644 index d3d843d5f..000000000 --- a/packages/coding-agent/src/experimental/vacation/main.ts +++ /dev/null @@ -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(); -} diff --git a/packages/coding-agent/src/experimental/vacation/runtime.ts b/packages/coding-agent/src/experimental/vacation/runtime.ts deleted file mode 100644 index ee7a0c7a6..000000000 --- a/packages/coding-agent/src/experimental/vacation/runtime.ts +++ /dev/null @@ -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; - compact(instructions: string | undefined): Promise; - abort(): Promise; - cycleThinking(): Promise; - setModel(model: ModelRef): Promise; - toggleTasks(): Promise; - /** Show and talk to another conversation. */ - switchConversation(id: ConversationId): Promise; -} - -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; -} - -/** 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 { - 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 = 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): 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 | 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): Promise => { - 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 | 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; - } -} diff --git a/packages/coding-agent/src/experimental/vacation/sessions.ts b/packages/coding-agent/src/experimental/vacation/sessions.ts deleted file mode 100644 index f5dabe2a9..000000000 --- a/packages/coding-agent/src/experimental/vacation/sessions.ts +++ /dev/null @@ -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; -} - -/** A new session for `cwd`, or its newest one with `continueSession`. */ -export async function selectSession(cwdInput: string, continueSession: boolean): Promise { - 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; - 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 }; -} diff --git a/packages/coding-agent/src/experimental/vacation/tui.ts b/packages/coding-agent/src/experimental/vacation/tui.ts deleted file mode 100644 index 6eef2e775..000000000 --- a/packages/coding-agent/src/experimental/vacation/tui.ts +++ /dev/null @@ -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 = 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(); - /** 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(); - 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 { - 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((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(); -} diff --git a/packages/coding-agent/src/experimental/vacation/vacation.ts b/packages/coding-agent/src/experimental/vacation/vacation.ts deleted file mode 100644 index 8dde4bb81..000000000 --- a/packages/coding-agent/src/experimental/vacation/vacation.ts +++ /dev/null @@ -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 }, - ), - ], -}); diff --git a/packages/coding-agent/test/experimental-agent-controller.test.ts b/packages/coding-agent/test/experimental-agent-controller.test.ts deleted file mode 100644 index b81938f82..000000000 --- a/packages/coding-agent/test/experimental-agent-controller.test.ts +++ /dev/null @@ -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(); - } - }); -}); diff --git a/packages/coding-agent/test/experimental-cli-command.test.ts b/packages/coding-agent/test/experimental-cli-command.test.ts deleted file mode 100644 index c1c5b131c..000000000 --- a/packages/coding-agent/test/experimental-cli-command.test.ts +++ /dev/null @@ -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], - }); - }); -}); diff --git a/packages/coding-agent/test/experimental-cli-entry.test.ts b/packages/coding-agent/test/experimental-cli-entry.test.ts deleted file mode 100644 index ae3b53906..000000000 --- a/packages/coding-agent/test/experimental-cli-entry.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/packages/coding-agent/test/experimental-cli-resolution.test.ts b/packages/coding-agent/test/experimental-cli-resolution.test.ts deleted file mode 100644 index b9ceaeb29..000000000 --- a/packages/coding-agent/test/experimental-cli-resolution.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/packages/coding-agent/test/experimental-client-tui.test.ts b/packages/coding-agent/test/experimental-client-tui.test.ts deleted file mode 100644 index c4328af8d..000000000 --- a/packages/coding-agent/test/experimental-client-tui.test.ts +++ /dev/null @@ -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(state: MutableReplicatedState, 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({ revision: 1, sessions: [session("one", 1)] }); - const attachment = replicatedState({ status: "detached" }); - const connectionState = replicatedState({ status: "connected", since: "now" }); - const modelsState = replicatedState({ - 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((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(); - } - }, - ); -}); diff --git a/packages/coding-agent/test/experimental-durable-support.ts b/packages/coding-agent/test/experimental-durable-support.ts deleted file mode 100644 index e73d7c81c..000000000 --- a/packages/coding-agent/test/experimental-durable-support.ts +++ /dev/null @@ -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; -} - -/** An in-memory durable Harness whose root conversation answers with the faux provider. */ -export async function openFauxConversation(responses: FauxResponseStep[] = []): Promise { - 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 } { - let reach!: () => void; - const reached = new Promise((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 }; -} diff --git a/packages/coding-agent/test/experimental-internal-process.test.ts b/packages/coding-agent/test/experimental-internal-process.test.ts deleted file mode 100644 index a58c66586..000000000 --- a/packages/coding-agent/test/experimental-internal-process.test.ts +++ /dev/null @@ -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(); -const directories = new Set(); - -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 { - return new Promise((resolve) => { - const socket = createConnection(path); - socket.once("connect", () => { - socket.destroy(); - resolve(true); - }); - socket.once("error", () => resolve(false)); - }); -} diff --git a/packages/coding-agent/test/experimental-plugin-reload.test.ts b/packages/coding-agent/test/experimental-plugin-reload.test.ts deleted file mode 100644 index 86f028d24..000000000 --- a/packages/coding-agent/test/experimental-plugin-reload.test.ts +++ /dev/null @@ -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]); - }); -}); diff --git a/packages/coding-agent/test/experimental-presentation-facets.test.ts b/packages/coding-agent/test/experimental-presentation-facets.test.ts deleted file mode 100644 index 37f007772..000000000 --- a/packages/coding-agent/test/experimental-presentation-facets.test.ts +++ /dev/null @@ -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(); -const runningServers = new Set(); -const directories = new Set(); - -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(); - }); -}); diff --git a/packages/coding-agent/test/experimental-radius-relay.test.ts b/packages/coding-agent/test/experimental-radius-relay.test.ts deleted file mode 100644 index 1048798c0..000000000 --- a/packages/coding-agent/test/experimental-radius-relay.test.ts +++ /dev/null @@ -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 = []; - onSend: ((data: string | ArrayBuffer) => void) | undefined; - readonly listeners = new Map>(); - 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[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; - close(finalChunk?: Uint8Array): Promise; - } - | 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; - 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, - 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(); - }); -}); diff --git a/packages/coding-agent/test/experimental-remote-runtime.test.ts b/packages/coding-agent/test/experimental-remote-runtime.test.ts deleted file mode 100644 index 7117e10b6..000000000 --- a/packages/coding-agent/test/experimental-remote-runtime.test.ts +++ /dev/null @@ -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(); -const clients = new Set(); -const directories = new Set(); -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 }>("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 { - 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 { - 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((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 { - 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"; - } -} diff --git a/packages/coding-agent/test/experimental-server-lifecycle.test.ts b/packages/coding-agent/test/experimental-server-lifecycle.test.ts deleted file mode 100644 index b4eadc461..000000000 --- a/packages/coding-agent/test/experimental-server-lifecycle.test.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/packages/coding-agent/test/experimental-server-profile.test.ts b/packages/coding-agent/test/experimental-server-profile.test.ts deleted file mode 100644 index ce53103ef..000000000 --- a/packages/coding-agent/test/experimental-server-profile.test.ts +++ /dev/null @@ -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(); -const FIRST_SERVER_ID = "00000000-0000-4000-8000-000000000001"; -const SECOND_SERVER_ID = "00000000-0000-4000-8000-000000000002"; - -async function makeDirectory(): Promise { - 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((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"); - }); -}); diff --git a/packages/coding-agent/test/experimental-service-binding.ts b/packages/coding-agent/test/experimental-service-binding.ts deleted file mode 100644 index ecd725479..000000000 --- a/packages/coding-agent/test/experimental-service-binding.ts +++ /dev/null @@ -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 => { - 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 => { - 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[], 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); -} diff --git a/packages/coding-agent/test/experimental-session-directory.test.ts b/packages/coding-agent/test/experimental-session-directory.test.ts deleted file mode 100644 index 416032679..000000000 --- a/packages/coding-agent/test/experimental-session-directory.test.ts +++ /dev/null @@ -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")); - }); -}); diff --git a/packages/coding-agent/test/experimental-session-support.ts b/packages/coding-agent/test/experimental-session-support.ts deleted file mode 100644 index 8c4bf5d8f..000000000 --- a/packages/coding-agent/test/experimental-session-support.ts +++ /dev/null @@ -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 { - const metadata: SessionCatalogMetadata[] = []; - for (const id of ids) metadata.push(await createCatalogSession(sessionsRoot, { id, cwd })); - return metadata; -} - -export async function configureExperimentalWorkerModel(agentDir: string): Promise { - 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); - } -} diff --git a/packages/coding-agent/test/experimental-session-worker-lifecycle.test.ts b/packages/coding-agent/test/experimental-session-worker-lifecycle.test.ts deleted file mode 100644 index 69e3c9d21..000000000 --- a/packages/coding-agent/test/experimental-session-worker-lifecycle.test.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/packages/coding-agent/test/experimental-session-worker-manager.test.ts b/packages/coding-agent/test/experimental-session-worker-manager.test.ts deleted file mode 100644 index 1b89bd6ba..000000000 --- a/packages/coding-agent/test/experimental-session-worker-manager.test.ts +++ /dev/null @@ -1,308 +0,0 @@ -import type { ServiceCall } from "@earendil-works/chord"; -import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; -import { afterEach, describe, expect, test, vi } from "vitest"; -import type { CoordinatorConnectionEvent } from "../src/experimental/coordinator.ts"; -import type { SessionCatalogMetadata } from "../src/experimental/session-catalog.ts"; -import { SessionWorkerManager } from "../src/experimental/session-worker-manager.ts"; - -const metadata: SessionCatalogMetadata = { - id: "session-1", - createdAt: 1, - cwd: "/tmp", - path: "/tmp/session-1", -}; - -class FakeCoordinator { - readonly controlPath = "/tmp/control.sock"; - readonly serverConnectionId = "server-generation-1"; - readonly wasReplaced = false; - readonly sent: { peerId: string; payload: unknown }[] = []; - readonly #listeners = new Set<(event: CoordinatorConnectionEvent) => void>(); - onSend?: (peerId: string, payload: Record) => void; - - onEvent(listener: (event: CoordinatorConnectionEvent) => void): () => void { - this.#listeners.add(listener); - return () => this.#listeners.delete(listener); - } - - async send(peerId: string, payload: unknown): Promise { - this.sent.push({ peerId, payload }); - this.onSend?.(peerId, asObject(payload)); - } - - async broadcast(payload: unknown): Promise { - if (asObject(payload).type !== "discover_workers") return; - this.emit({ - type: "message", - from: "worker-1", - payload: { - type: "worker_ready", - token: "worker-token", - sessionKey: metadata.path, - sessionId: metadata.id, - pid: 123, - metadata, - pluginManifestPaths: [], - }, - }); - } - - emit(event: CoordinatorConnectionEvent): void { - for (const listener of this.#listeners) listener(event); - } -} - -afterEach(() => { - vi.useRealTimers(); - vi.restoreAllMocks(); -}); - -async function createAttachedWorker(): Promise<{ - coordinator: FakeCoordinator; - workers: SessionWorkerManager; - handle: Awaited>; - attachment: Awaited>["attachClient"]>>; - release(): Promise; -}> { - const coordinator = new FakeCoordinator(); - const workers = new SessionWorkerManager(coordinator, "/tmp"); - await workers.discover(new Set(["worker-1"])); - const handle = await workers.openSession(metadata, BACKGROUND_CONTEXT, []); - coordinator.onSend = (peerId, payload) => { - if (payload.type !== "session_demand") return; - queueMicrotask(() => - coordinator.emit({ - type: "message", - from: peerId, - payload: { - type: "demand_applied", - token: "worker-token", - sessionKey: metadata.path, - requestId: payload.requestId, - attachmentId: payload.attachmentId, - attached: payload.attached, - }, - }), - ); - }; - const attachment = await handle.attachClient!(BACKGROUND_CONTEXT); - return { - coordinator, - workers, - handle, - attachment, - release: () => Promise.resolve(attachment.release(BACKGROUND_CONTEXT)), - }; -} - -describe("Session worker lifecycle failures", () => { - test("adopts a discovered worker with its existing Session plugin selection", async () => { - const coordinator = new FakeCoordinator(); - const workers = new SessionWorkerManager(coordinator, "/tmp"); - await workers.discover(new Set(["worker-1"])); - expect(coordinator.sent).not.toContainEqual({ peerId: "worker-1", payload: { type: "shutdown" } }); - expect(workers.workerPids.size).toBe(1); - expect(() => workers.assertSessionPluginManifestPaths(metadata, [])).not.toThrow(); - workers.detach(); - }); - - test("rejects a different plugin selection for an active Session without stopping it", async () => { - const { workers } = await createAttachedWorker(); - expect(() => workers.assertSessionPluginManifestPaths(metadata, ["/tmp/plugin/chord-facets.json"])).toThrow( - "active with a different plugin selection", - ); - expect(workers.workerPids.size).toBe(1); - workers.detach(); - }); - - test("compensates a timed-out attachment before rejecting it", async () => { - vi.useFakeTimers(); - const coordinator = new FakeCoordinator(); - const workers = new SessionWorkerManager(coordinator, "/tmp"); - await workers.discover(new Set(["worker-1"])); - const handle = await workers.openSession(metadata, BACKGROUND_CONTEXT, []); - const demands: { attachmentId: string; attached: boolean }[] = []; - coordinator.onSend = (peerId, payload) => { - if ( - payload.type !== "session_demand" || - typeof payload.attachmentId !== "string" || - typeof payload.attached !== "boolean" - ) { - return; - } - demands.push({ attachmentId: payload.attachmentId, attached: payload.attached }); - if (payload.attached) return; - queueMicrotask(() => - coordinator.emit({ - type: "message", - from: peerId, - payload: { - type: "demand_applied", - token: "worker-token", - sessionKey: metadata.path, - requestId: payload.requestId, - attachmentId: payload.attachmentId, - attached: false, - }, - }), - ); - }; - - const attaching = expect(handle.attachClient!(BACKGROUND_CONTEXT)).rejects.toThrow("timed out"); - await vi.advanceTimersByTimeAsync(5_000); - await attaching; - expect(demands).toHaveLength(2); - expect(demands[0]).toMatchObject({ attachmentId: expect.any(String), attached: true }); - expect(demands[1]).toEqual({ attachmentId: demands[0]!.attachmentId, attached: false }); - workers.detach(); - }); - - test("kills a worker when timed-out demand cannot be reconciled", async () => { - vi.useFakeTimers(); - const kill = vi.spyOn(process, "kill").mockReturnValue(true); - const coordinator = new FakeCoordinator(); - const workers = new SessionWorkerManager(coordinator, "/tmp"); - await workers.discover(new Set(["worker-1"])); - const handle = await workers.openSession(metadata, BACKGROUND_CONTEXT, []); - coordinator.onSend = () => {}; - - const attaching = expect(handle.attachClient!(BACKGROUND_CONTEXT)).rejects.toThrow("worker was terminated"); - await vi.advanceTimersByTimeAsync(5_000); - await vi.advanceTimersByTimeAsync(5_000); - await vi.advanceTimersByTimeAsync(10_000); - await attaching; - expect(kill).toHaveBeenCalledWith(123, "SIGKILL"); - expect(workers.workerPids.size).toBe(0); - workers.detach(); - }); - - test("bounds Harness-driven worker shutdown", async () => { - vi.useFakeTimers(); - const kill = vi.spyOn(process, "kill").mockReturnValue(true); - const { coordinator, workers, handle, release } = await createAttachedWorker(); - await release(); - coordinator.onSend = () => {}; - - const closing = handle.close(BACKGROUND_CONTEXT); - await vi.advanceTimersByTimeAsync(10_000); - await closing; - expect(kill).toHaveBeenCalledWith(123, "SIGKILL"); - expect(workers.workerPids.size).toBe(0); - workers.detach(); - }); -}); - -describe("Session worker operations", () => { - const serviceCall = { serviceId: "test.session", member: "run", args: ["Hello"] } satisfies ServiceCall; - - test("correlates service results to the worker generation and attachment", async () => { - const { coordinator, workers, attachment, release } = await createAttachedWorker(); - coordinator.onSend = (peerId, payload) => { - if (payload.type !== "operation") return; - const scope = asObject(payload.scope); - queueMicrotask(() => { - coordinator.emit({ - type: "message", - from: peerId, - payload: { - type: "operation_response", - token: "worker-token", - sessionKey: metadata.path, - response: { - type: "operation_result", - requestId: payload.requestId, - scope, - result: { accepted: true }, - }, - }, - }); - }); - }; - await expect(attachment.invokeService(serviceCall, () => {}, BACKGROUND_CONTEXT)).resolves.toEqual({ - accepted: true, - }); - const operation = coordinator.sent - .map(({ payload }) => asObject(payload)) - .find(({ type }) => type === "operation"); - expect(operation).toMatchObject({ - scope: { serverConnectionId: "server-generation-1", attachmentId: expect.any(String) }, - call: serviceCall, - }); - workers.detach(); - await release(); - }); - - test("rejects a correlated response with mismatched worker identity", async () => { - const { coordinator, workers, attachment, release } = await createAttachedWorker(); - coordinator.onSend = (peerId, payload) => { - if (payload.type !== "operation") return; - queueMicrotask(() => - coordinator.emit({ - type: "message", - from: peerId, - payload: { - type: "operation_response", - token: "wrong-token", - sessionKey: metadata.path, - response: { - type: "operation_result", - requestId: payload.requestId, - scope: payload.scope, - result: { accepted: true }, - }, - }, - }), - ); - }; - await expect(attachment.invokeService(serviceCall, () => {}, BACKGROUND_CONTEXT)).rejects.toThrow( - /mismatched operation response/, - ); - workers.detach(); - await release(); - }); - - test("rejects a null request scope", async () => { - const { coordinator, workers, attachment, release } = await createAttachedWorker(); - coordinator.onSend = (peerId, payload) => { - if (payload.type !== "operation") return; - queueMicrotask(() => - coordinator.emit({ - type: "message", - from: peerId, - payload: { - type: "operation_response", - token: "worker-token", - sessionKey: metadata.path, - response: { - type: "operation_result", - requestId: payload.requestId, - scope: null, - result: { accepted: true }, - }, - }, - }), - ); - }; - await expect(attachment.invokeService(serviceCall, () => {}, BACKGROUND_CONTEXT)).rejects.toThrow( - /invalid operation response/, - ); - workers.detach(); - await release(); - }); - - test("rejects pending service calls on replacement without stopping the worker", async () => { - const { coordinator, workers, attachment } = await createAttachedWorker(); - coordinator.onSend = () => {}; - const calling = attachment.invokeService(serviceCall, () => {}, BACKGROUND_CONTEXT); - workers.detach(); - - await expect(calling).rejects.toThrow(/replaced during a worker operation/); - expect(coordinator.sent.map(({ payload }) => asObject(payload).type)).not.toContain("shutdown"); - expect(workers.workerPids.size).toBe(0); - }); -}); - -function asObject(value: unknown): Record { - if (typeof value !== "object" || value === null || Array.isArray(value)) throw new TypeError("Expected object"); - return value as Record; -} diff --git a/packages/coding-agent/test/experimental-slash-commands.test.ts b/packages/coding-agent/test/experimental-slash-commands.test.ts deleted file mode 100644 index 3fa75fd74..000000000 --- a/packages/coding-agent/test/experimental-slash-commands.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { createFacetHost, defineFacet } from "@earendil-works/chord"; -import { describe, expect, test, vi } from "vitest"; -import { SlashCommands } from "../src/experimental/services/slash-commands.ts"; -import { - createSlashCommandsRuntimeFacet, - SlashCommandRegistry, -} from "../src/experimental/services/slash-commands-provider.ts"; - -describe("experimental slash command facets", () => { - test("registers and removes contributions", () => { - const registry = new SlashCommandRegistry(); - const snapshots: string[][] = []; - const unsubscribe = registry.subscribe((commands) => snapshots.push(commands.map(({ name }) => name))); - const close = registry.register({ name: "hello", description: "Hello", run: () => undefined }); - expect(registry.list().map(({ name }) => name)).toEqual(["hello"]); - expect(() => registry.register({ name: "hello", run: () => undefined })).toThrow("already registered"); - close(); - close(); - expect(registry.list()).toEqual([]); - expect(snapshots).toEqual([[], ["hello"], []]); - unsubscribe(); - }); - - test("stages replacements until the previous registration retires", () => { - const registry = new SlashCommandRegistry(); - const first = { name: "hello", description: "First", run: () => undefined }; - const second = { name: "hello", description: "Second", run: () => undefined }; - const closeFirst = registry.register(first); - const closeSecond = registry.replace(second); - expect(registry.list()).toEqual([first]); - - closeSecond(); - expect(registry.list()).toEqual([first]); - const closeReplacement = registry.replace(second); - closeFirst(); - expect(registry.list()).toEqual([second]); - closeReplacement(); - expect(registry.list()).toEqual([]); - }); - - test("tracks plugin facet reload and unload", async () => { - const registry = new SlashCommandRegistry(); - const originalRun = vi.fn(); - const host = await createFacetHost({ - facets: [ - createSlashCommandsRuntimeFacet(registry), - defineFacet({ - id: "@test/example-hello", - setup(env) { - const commands = env.use(SlashCommands); - env.onActivate(() => - env.own(commands.replace({ name: "hello", description: "Original", run: originalRun })), - ); - }, - }), - ], - }); - expect(registry.list().map(({ name }) => name)).toEqual(["hello"]); - - const failure = new Error("replacement failed"); - await expect( - host.reload([ - defineFacet({ - id: "@test/example-hello", - setup(env) { - const commands = env.use(SlashCommands); - env.onActivate(() => { - env.own(commands.replace({ name: "hello", description: "Failing", run: () => undefined })); - throw failure; - }); - }, - }), - ]), - ).rejects.toBe(failure); - expect(registry.list()).toEqual([expect.objectContaining({ name: "hello", run: originalRun })]); - - const replacementRun = vi.fn(); - await host.reload([ - defineFacet({ - id: "@test/example-hello", - setup(env) { - const commands = env.use(SlashCommands); - env.onActivate(() => - env.own(commands.replace({ name: "hello", description: "Replacement", run: replacementRun })), - ); - }, - }), - ]); - expect(registry.list()).toEqual([ - expect.objectContaining({ name: "hello", description: "Replacement", run: replacementRun }), - ]); - - await host.dispose(); - expect(registry.list()).toEqual([]); - }); -}); diff --git a/packages/coding-agent/test/experimental-transcript-provider.test.ts b/packages/coding-agent/test/experimental-transcript-provider.test.ts deleted file mode 100644 index 4de655e78..000000000 --- a/packages/coding-agent/test/experimental-transcript-provider.test.ts +++ /dev/null @@ -1,34 +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 { ConversationView } from "@earendil-works/pi-durable"; -import { describe, expect, test } from "vitest"; -import { Transcript } from "../src/experimental/services/transcript.ts"; -import { createTranscriptServiceFacet } from "../src/experimental/services/transcript-provider.ts"; -import { openFauxConversation } from "./experimental-durable-support.ts"; - -describe("Transcript service", () => { - test("replicates the conversation view as it changes", async () => { - const { conversation, close } = await openFauxConversation([fauxAssistantMessage("answer")]); - const views: ConversationView[] = []; - const consumer = defineFacet({ - id: "test-transcript-consumer", - setup(env) { - const transcript = env.use(Transcript); - env.onActivate(() => env.own(transcript.state.subscribe((value) => void views.push(value)))); - }, - }); - const host = await createFacetHost({ - facets: [await createTranscriptServiceFacet(conversation, BACKGROUND_CONTEXT), consumer], - }); - try { - expect(views[0]?.entries).toEqual([]); - const submission = await conversation.submit({ type: "input", content: "question" }, BACKGROUND_CONTEXT); - await submission.wait(BACKGROUND_CONTEXT); - await expect.poll(() => views.at(-1)?.entries.map((entry) => entry.kind)).toEqual(["pi.user", "pi.assistant"]); - } finally { - await host.dispose(); - await close(); - } - }); -}); diff --git a/packages/coding-agent/test/fixtures/faux-session-worker.ts b/packages/coding-agent/test/fixtures/faux-session-worker.ts deleted file mode 100644 index 502af2a21..000000000 --- a/packages/coding-agent/test/fixtures/faux-session-worker.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import { createStaticFacetLoader, defineFacet } from "@earendil-works/chord"; -import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; -import { createModels, fauxAssistantMessage, fauxProvider } from "@earendil-works/pi-ai"; -import { createRegistry, Harness } from "@earendil-works/pi-durable"; -import { openNodeSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite/node"; -import { consumeInternalProcessRole } from "../../src/experimental/process.ts"; -import { runSessionWorkerWithHarness } from "../../src/experimental/session-worker.ts"; -import { KeyedProbe } from "./keyed-service.ts"; - -if (process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { - const role = consumeInternalProcessRole(); - if (role !== "session-worker") throw new Error("Faux Session worker requires a session-worker invocation"); - void runSessionWorkerWithHarness(process.argv.slice(2), async (databasePath, options) => { - if (options.provider !== "anthropic" || options.model !== "claude-sonnet-4-5") { - throw new Error(`Unexpected faux worker model: ${options.provider}/${options.model}`); - } - const faux = fauxProvider(); - const answer = fauxAssistantMessage("deterministic remote answer", { timestamp: 20 }); - faux.setResponses([answer, answer]); - const models = createModels(); - models.setProvider(faux.provider); - const harness = await Harness.open( - await openNodeSqliteStorage(databasePath), - { models, registry: createRegistry() }, - BACKGROUND_CONTEXT, - ); - const model = faux.getModel(); - const conversation = await harness.root(BACKGROUND_CONTEXT, { - agent: { cwd: options.metadata.cwd, model: { provider: model.provider, modelId: model.id } }, - }); - const keyedProbeFacet = defineFacet({ - id: "@test/keyed-probe", - setup(env) { - const probes = env.provideMany(KeyedProbe); - const spawn = (value: string): void => { - const state = env.replicatedState({ value }); - let close = (): void => {}; - close = probes.spawn("probe", { - state, - async replace(next) { - close(); - spawn(next); - }, - async wait(context) { - const signal = context.abortSignal; - if (signal === undefined) throw new Error("Probe wait requires cancellation"); - if (signal.aborted) throw abortError(signal); - await new Promise((_resolve, reject) => { - signal.addEventListener("abort", () => reject(abortError(signal)), { once: true }); - }); - }, - }); - }; - env.onActivate(() => spawn("first")); - }, - }); - return { harness, conversation, facetLoader: createStaticFacetLoader([keyedProbeFacet]) }; - }).catch((error: unknown) => { - console.error(error); - process.exit(1); - }); -} - -function abortError(signal: AbortSignal): Error { - const reason: unknown = signal.reason; - return reason instanceof Error ? reason : new DOMException("The operation was aborted", "AbortError"); -} diff --git a/packages/coding-agent/test/fixtures/keyed-service.ts b/packages/coding-agent/test/fixtures/keyed-service.ts deleted file mode 100644 index 0ebc4cb02..000000000 --- a/packages/coding-agent/test/fixtures/keyed-service.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { type Context, defineService, type ReplicatedState } from "@earendil-works/chord"; - -export interface KeyedProbe { - readonly state: ReplicatedState<{ value: string }>; - replace(value: string, context: Context): Promise; - wait(context: Context): Promise; -} - -export const KeyedProbe = defineService("test.keyed-probe"); diff --git a/packages/coding-agent/test/package-distribution.test.ts b/packages/coding-agent/test/package-distribution.test.ts index 69e39fb8f..0cbf4f23e 100644 --- a/packages/coding-agent/test/package-distribution.test.ts +++ b/packages/coding-agent/test/package-distribution.test.ts @@ -6,8 +6,6 @@ interface CodingAgentPackageJson { main: string; exports: { ".": { import: string; types: string }; - "./client": { source: string }; - "./experimental/plugin": { source: string }; "./rpc-entry": { import: string }; }; } @@ -23,10 +21,4 @@ describe("package distribution entrypoints", () => { expect(packageJson.exports["."].import).toBe("./dist/index.js"); expect(packageJson.exports["./rpc-entry"].import).toBe("./dist/bundle/rpc-entry.js"); }); - - // Regression for #9132: internal experimental entrypoints must not be published runtime exports. - test("keeps experimental exports source-only", () => { - expect(packageJson.exports["./client"]).toEqual({ source: "./src/client/index.ts" }); - expect(packageJson.exports["./experimental/plugin"]).toEqual({ source: "./src/experimental/plugin.ts" }); - }); }); diff --git a/packages/coding-agent/tsconfig.build.json b/packages/coding-agent/tsconfig.build.json index b747af7b8..0eeb7bd2b 100644 --- a/packages/coding-agent/tsconfig.build.json +++ b/packages/coding-agent/tsconfig.build.json @@ -3,9 +3,6 @@ "compilerOptions": { "outDir": "./dist", "paths": { - "@earendil-works/chord": ["../chord/dist/index.d.ts"], - "@earendil-works/chord/bundler": ["../chord/dist/bundler.d.ts"], - "@earendil-works/chord/node": ["../chord/dist/node.d.ts"], "@earendil-works/pi-agent-core": ["../agent/dist/index.d.ts"], "@earendil-works/pi-agent-core/*": ["../agent/dist/*.d.ts"], "@earendil-works/pi-ai": ["../ai/dist/index.d.ts"], @@ -16,5 +13,5 @@ "rootDir": "./src" }, "include": ["src/**/*.ts", "src/**/*.d.ts"], - "exclude": ["node_modules", "dist", "src/client", "src/experimental", "src/cli/experimental"] + "exclude": ["node_modules", "dist", "src/experimental"] } diff --git a/packages/protocol/CHANGELOG.md b/packages/protocol/CHANGELOG.md deleted file mode 100644 index c171fb08d..000000000 --- a/packages/protocol/CHANGELOG.md +++ /dev/null @@ -1,53 +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 - -- Restricted assistant and tool transcript lifecycle schemas to valid state combinations and terminal items. -- Replaced `SessionSummarySchema` and `SessionSummary` with durable `SessionMetadataSchema` and `SessionMetadata` for session lists; runtime state remains in acquired `SessionSnapshot` values ([#7708](https://github.com/earendil-works/pi/pull/7708)). - -### Added - -- Added transport-neutral CBOR protocol schemas, codecs, and length-prefixed framing for remote pi sessions. -- Added `not_implemented` and `internal_error` protocol error codes for sanitized server failures ([#7644](https://github.com/earendil-works/pi/pull/7644)). diff --git a/packages/protocol/README.md b/packages/protocol/README.md deleted file mode 100644 index 675688549..000000000 --- a/packages/protocol/README.md +++ /dev/null @@ -1,41 +0,0 @@ -# @earendil-works/pi-protocol - -Runtime-neutral routed envelopes, CBOR encoding, and byte-stream framing for the experimental Pi protocol. - -Protocol version `8` defines: - -- a version handshake that identifies the logical `serverId`; -- explicit server and Session request targets; -- correlated requests and responses with opaque strict-JSON payloads; -- request cancellation, opaque subscription updates, and out-of-band attachment changes; -- non-empty opaque error codes and bounded transport messages. - -A server target contains `{ serverId }`; a Session target contains `{ serverId, sessionId, attachmentId }`. The combined route fences calls to one logical server, durable Session, and live presentation attachment. Management `attach()` and `detach()` return no routing identifiers; the server publishes the selected live route in an out-of-band `attachment` message. Disconnecting releases only that presentation's attachment after admitted calls settle. - -Chord owns the payload semantics carried inside these envelopes: `{ serviceId, instance?, member, args }` calls, the `$chord.service` control vocabulary, service catalogues, subscription snapshots and updates, service error codes, and the independent Delta path codecs for replicated states. `pi-protocol` validates that each opaque payload is strict JSON but does not validate or export its Chord grammar. Clients and servers parse those values through `@earendil-works/chord` at the service adapter boundary. - -Session-directory state, management results, transcripts, models, plugins, and all other application values remain opaque service data. The durable Session and its Harness remain local to the Session worker process. Server and Session calls route opaquely to their owning providers, where Chord and the application validate and invoke them. - -Server and worker lifecycle is intentionally outside this public protocol. The experimental local coordinator is only an opaque message router; each replaceable server process owns the private lifecycle protocol. - -Each wire frame consists of a four-byte unsigned big-endian payload length followed by one definite-length CBOR item. `encodeClientMessage()` and `encodeServerMessage()` validate and encode complete frames. `ClientMessageDecoder` and `ServerMessageDecoder` accept arbitrary stream fragmentation and coalescing. - -```ts -import { - PROTOCOL_VERSION, - encodeClientMessage, - ServerMessageDecoder, - type ClientHello, -} from "@earendil-works/pi-protocol"; - -const hello: ClientHello = { type: "hello", version: PROTOCOL_VERSION }; -transport.send(encodeClientMessage(hello)); - -const decoder = new ServerMessageDecoder({ maxFrameLength: 1024 * 1024 }); -for (const message of decoder.push(incomingChunk)) handleServerMessage(message); -decoder.end(); -``` - -All envelope schemas reject unknown object properties, and codecs recursively reject non-JSON opaque payloads, including non-finite numbers, byte arrays, `undefined`, prototypes, and cycles. Envelope violations, malformed CBOR, and invalid framing throw `ProtocolValidationError`. Payload-specific adapters must perform their own semantic validation after decoding. Transports must preserve byte order. Peer authentication and authenticated service contexts are not implemented by the experimental transport. - -Default limits are 16 MiB per CBOR payload/frame, 1,000,000 array elements or map entries, and 64 nested item levels. The protocol is experimental and has no compatibility guarantees. diff --git a/packages/protocol/package.json b/packages/protocol/package.json deleted file mode 100644 index b7f335fe9..000000000 --- a/packages/protocol/package.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "name": "@earendil-works/pi-protocol", - "version": "1.1.0", - "description": "Transport-neutral CBOR protocol for remote pi sessions", - "type": "module", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - } - }, - "files": [ - "dist", - "README.md" - ], - "scripts": { - "clean": "shx rm -rf dist", - "build": "tsc -p tsconfig.build.json", - "test": "vitest --run", - "prepublishOnly": "npm run clean && npm run build" - }, - "keywords": [ - "pi", - "protocol", - "cbor", - "binary", - "framing" - ], - "author": "Earendil Works", - "license": "MIT", - "repository": { - "type": "git", - "url": "git+https://github.com/earendil-works/pi.git", - "directory": "packages/protocol" - }, - "engines": { - "node": ">=22.19.0" - }, - "dependencies": { - "@earendil-works/chord": "^1.1.0", - "typebox": "1.3.27" - }, - "devDependencies": { - "shx": "0.3.4", - "vitest": "4.1.11" - } -} diff --git a/packages/protocol/src/cbor/decoder.ts b/packages/protocol/src/cbor/decoder.ts deleted file mode 100644 index 4a4b580dd..000000000 --- a/packages/protocol/src/cbor/decoder.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { - CborError, - type CborOptions, - type ResolvedCborOptions, - resolveOptions, - textDecoder, - UINT32_BASE, -} from "./options.ts"; - -class CborReader { - private readonly bytes: Uint8Array; - private offset = 0; - private readonly options: ResolvedCborOptions; - - constructor(bytes: Uint8Array, options: ResolvedCborOptions) { - this.bytes = bytes; - this.options = options; - } - - decode(): unknown { - const value = this.readItem(0); - if (this.offset !== this.bytes.byteLength) throw new CborError("CBOR payload contains trailing data"); - return value; - } - - private readItem(depth: number): unknown { - if (depth > this.options.maxDepth) { - throw new CborError(`CBOR nesting depth exceeds configured limit of ${this.options.maxDepth}`); - } - const initial = this.readByte(); - const majorType = initial >>> 5; - const additionalInformation = initial & 0x1f; - - switch (majorType) { - case 0: - return this.readArgument(additionalInformation); - case 1: { - const value = -1 - this.readArgument(additionalInformation); - if (!Number.isSafeInteger(value)) throw new CborError("Decoded CBOR integer is outside the safe range"); - return value; - } - case 2: { - const length = this.readLength(additionalInformation, "byte string", this.options.maxByteLength); - return new Uint8Array(this.readBytes(length)); - } - case 3: { - const length = this.readLength(additionalInformation, "text string", this.options.maxByteLength); - const bytes = this.readBytes(length); - try { - return textDecoder.decode(bytes); - } catch (_error) { - throw new CborError("CBOR text string contains invalid UTF-8"); - } - } - case 4: { - const length = this.readLength(additionalInformation, "array", this.options.maxContainerLength); - const result: unknown[] = []; - for (let index = 0; index < length; index++) result.push(this.readItem(depth + 1)); - return result; - } - case 5: { - const length = this.readLength(additionalInformation, "map", this.options.maxContainerLength); - const result: Record = {}; - const keys = new Set(); - for (let index = 0; index < length; index++) { - const key = this.readItem(depth + 1); - if (typeof key !== "string") throw new CborError("CBOR map keys must be strings"); - if (keys.has(key)) throw new CborError("CBOR map contains a duplicate key"); - keys.add(key); - Object.defineProperty(result, key, { - configurable: true, - enumerable: true, - value: this.readItem(depth + 1), - writable: true, - }); - } - return result; - } - case 6: - throw new CborError("CBOR tags are not supported"); - case 7: - return this.readSimple(additionalInformation); - default: - throw new CborError("Malformed CBOR major type"); - } - } - - private readSimple(additionalInformation: number): unknown { - switch (additionalInformation) { - case 20: - return false; - case 21: - return true; - case 22: - return null; - case 27: { - const bytes = this.readBytes(8); - const value = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getFloat64(0, false); - if (!Number.isFinite(value)) throw new CborError("Decoded CBOR number must be finite"); - if (Number.isInteger(value) && !Number.isSafeInteger(value)) { - throw new CborError("Decoded CBOR integer is outside the safe range"); - } - return value; - } - case 31: - throw new CborError("CBOR break marker is not supported"); - default: - throw new CborError("Unsupported CBOR simple value or floating-point width"); - } - } - - private readLength(additionalInformation: number, kind: string, limit: number): number { - if (additionalInformation === 31) throw new CborError(`Indefinite-length CBOR ${kind}s are not supported`); - const length = this.readArgument(additionalInformation); - if (length > limit) throw new CborError(`CBOR ${kind} length exceeds configured limit of ${limit}`); - return length; - } - - private readArgument(additionalInformation: number): number { - if (additionalInformation < 24) return additionalInformation; - switch (additionalInformation) { - case 24: - return this.readByte(); - case 25: { - const bytes = this.readBytes(2); - return bytes[0]! * 0x100 + bytes[1]!; - } - case 26: { - const bytes = this.readBytes(4); - return bytes[0]! * 0x1_000_000 + bytes[1]! * 0x1_0000 + bytes[2]! * 0x100 + bytes[3]!; - } - case 27: { - const high = this.readArgument(26); - const low = this.readArgument(26); - if (high > 0x1f_ffff) throw new CborError("Decoded CBOR integer or length is outside the safe range"); - return high * UINT32_BASE + low; - } - case 31: - throw new CborError("Indefinite-length CBOR items are not supported"); - default: - throw new CborError("Malformed CBOR additional information"); - } - } - - private readByte(): number { - if (this.offset >= this.bytes.byteLength) throw new CborError("Truncated CBOR payload"); - const value = this.bytes[this.offset]!; - this.offset++; - return value; - } - - private readBytes(length: number): Uint8Array { - if (length > this.bytes.byteLength - this.offset) throw new CborError("Truncated CBOR payload"); - const value = this.bytes.subarray(this.offset, this.offset + length); - this.offset += length; - return value; - } -} - -/** Decodes exactly one item from the protocol's strict RFC 8949 subset. */ -export function decodeCbor(bytes: Uint8Array, options?: CborOptions): unknown { - if (!(bytes instanceof Uint8Array)) throw new TypeError("CBOR input must be a Uint8Array"); - const resolved = resolveOptions(options); - if (bytes.byteLength > resolved.maxByteLength) { - throw new CborError(`CBOR byte length exceeds configured limit of ${resolved.maxByteLength}`); - } - return new CborReader(bytes, resolved).decode(); -} diff --git a/packages/protocol/src/cbor/encoder.ts b/packages/protocol/src/cbor/encoder.ts deleted file mode 100644 index 892910f7a..000000000 --- a/packages/protocol/src/cbor/encoder.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { - CborError, - type CborOptions, - MAX_UINT32, - type ResolvedCborOptions, - resolveOptions, - textDecoder, - textEncoder, - UINT32_BASE, -} from "./options.ts"; - -class CborWriter { - private buffer: Uint8Array; - private offset = 0; - private readonly maxByteLength: number; - - constructor(maxByteLength: number) { - this.maxByteLength = maxByteLength; - this.buffer = new Uint8Array(Math.min(256, maxByteLength)); - } - - writeByte(value: number): void { - this.ensureCapacity(1); - this.buffer[this.offset] = value; - this.offset++; - } - - writeBytes(bytes: Uint8Array): void { - this.ensureCapacity(bytes.byteLength); - this.buffer.set(bytes, this.offset); - this.offset += bytes.byteLength; - } - - writeUint16(value: number): void { - this.ensureCapacity(2); - this.buffer[this.offset] = value >>> 8; - this.buffer[this.offset + 1] = value; - this.offset += 2; - } - - writeUint32(value: number): void { - this.ensureCapacity(4); - this.buffer[this.offset] = value >>> 24; - this.buffer[this.offset + 1] = value >>> 16; - this.buffer[this.offset + 2] = value >>> 8; - this.buffer[this.offset + 3] = value; - this.offset += 4; - } - - writeUint64(value: number): void { - const high = Math.floor(value / UINT32_BASE); - const low = value - high * UINT32_BASE; - this.writeUint32(high); - this.writeUint32(low); - } - - writeFloat64(value: number): void { - this.ensureCapacity(9); - this.buffer[this.offset] = 0xfb; - new DataView(this.buffer.buffer).setFloat64(this.offset + 1, value, false); - this.offset += 9; - } - - finish(): Uint8Array { - return this.buffer.slice(0, this.offset); - } - - private ensureCapacity(additionalBytes: number): void { - const required = this.offset + additionalBytes; - if (required > this.maxByteLength) { - throw new CborError(`CBOR byte length exceeds configured limit of ${this.maxByteLength}`); - } - if (required <= this.buffer.byteLength) return; - - let capacity = Math.max(1, this.buffer.byteLength); - while (capacity < required) capacity = Math.min(this.maxByteLength, Math.max(required, capacity * 2)); - const expanded = new Uint8Array(capacity); - expanded.set(this.buffer); - this.buffer = expanded; - } -} - -function writeArgument(writer: CborWriter, majorType: number, value: number): void { - const prefix = majorType << 5; - if (value < 24) { - writer.writeByte(prefix | value); - } else if (value <= 0xff) { - writer.writeByte(prefix | 24); - writer.writeByte(value); - } else if (value <= 0xffff) { - writer.writeByte(prefix | 25); - writer.writeUint16(value); - } else if (value <= MAX_UINT32) { - writer.writeByte(prefix | 26); - writer.writeUint32(value); - } else { - writer.writeByte(prefix | 27); - writer.writeUint64(value); - } -} - -function isPlainObject(value: unknown): value is Record { - if (typeof value !== "object" || value === null) return false; - const prototype = Object.getPrototypeOf(value); - return prototype === Object.prototype || prototype === null; -} - -function encodeText(writer: CborWriter, value: string, options: ResolvedCborOptions): void { - const bytes = textEncoder.encode(value); - if (bytes.byteLength > options.maxByteLength) { - throw new CborError(`CBOR text string length exceeds configured limit of ${options.maxByteLength}`); - } - if (textDecoder.decode(bytes) !== value) - throw new CborError("CBOR text strings must contain valid Unicode scalar values"); - writeArgument(writer, 3, bytes.byteLength); - writer.writeBytes(bytes); -} - -function encodeValue( - writer: CborWriter, - value: unknown, - options: ResolvedCborOptions, - depth: number, - ancestors: Set, -): void { - if (depth > options.maxDepth) - throw new CborError(`CBOR nesting depth exceeds configured limit of ${options.maxDepth}`); - - if (value === null) { - writer.writeByte(0xf6); - return; - } - if (typeof value === "boolean") { - writer.writeByte(value ? 0xf5 : 0xf4); - return; - } - if (typeof value === "number") { - if (!Number.isFinite(value)) throw new CborError("CBOR numbers must be finite"); - if (Number.isInteger(value) && !Object.is(value, -0)) { - if (!Number.isSafeInteger(value)) throw new CborError("CBOR integers must be safe JavaScript integers"); - if (value >= 0) writeArgument(writer, 0, value); - else writeArgument(writer, 1, -1 - value); - } else { - writer.writeFloat64(value); - } - return; - } - if (typeof value === "string") { - encodeText(writer, value, options); - return; - } - if (value instanceof Uint8Array) { - if (value.byteLength > options.maxByteLength) { - throw new CborError(`CBOR byte string length exceeds configured limit of ${options.maxByteLength}`); - } - writeArgument(writer, 2, value.byteLength); - writer.writeBytes(value); - return; - } - if (Array.isArray(value)) { - if (ancestors.has(value)) throw new CborError("CBOR values must not contain cycles"); - if (value.length > options.maxContainerLength) { - throw new CborError(`CBOR array length exceeds configured limit of ${options.maxContainerLength}`); - } - ancestors.add(value); - try { - writeArgument(writer, 4, value.length); - for (let index = 0; index < value.length; index++) { - if (!Object.hasOwn(value, index) || value[index] === undefined) { - throw new CborError("CBOR arrays must not contain holes or undefined values"); - } - encodeValue(writer, value[index], options, depth + 1, ancestors); - } - } finally { - ancestors.delete(value); - } - return; - } - if (isPlainObject(value)) { - if (ancestors.has(value)) throw new CborError("CBOR values must not contain cycles"); - for (const symbol of Object.getOwnPropertySymbols(value)) { - if (Object.prototype.propertyIsEnumerable.call(value, symbol)) { - throw new CborError("CBOR map keys must be strings"); - } - } - const entries: Array = []; - for (const key of Object.keys(value)) { - const entryValue = value[key]; - if (entryValue !== undefined) entries.push([key, entryValue]); - } - if (entries.length > options.maxContainerLength) { - throw new CborError(`CBOR map length exceeds configured limit of ${options.maxContainerLength}`); - } - ancestors.add(value); - try { - writeArgument(writer, 5, entries.length); - for (const [key, entryValue] of entries) { - encodeText(writer, key, options); - encodeValue(writer, entryValue, options, depth + 1, ancestors); - } - } finally { - ancestors.delete(value); - } - return; - } - - throw new CborError(`Unsupported CBOR value type: ${typeof value}`); -} - -/** Encodes the protocol's strict, definite-length RFC 8949 subset. */ -export function encodeCbor(value: unknown, options?: CborOptions): Uint8Array { - const resolved = resolveOptions(options); - const writer = new CborWriter(resolved.maxByteLength); - encodeValue(writer, value, resolved, 0, new Set()); - return writer.finish(); -} diff --git a/packages/protocol/src/cbor/index.ts b/packages/protocol/src/cbor/index.ts deleted file mode 100644 index e1eb92b1e..000000000 --- a/packages/protocol/src/cbor/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export { decodeCbor } from "./decoder.ts"; -export { encodeCbor } from "./encoder.ts"; -export { - CborError, - type CborOptions, - DEFAULT_MAX_CBOR_BYTE_LENGTH, - DEFAULT_MAX_CBOR_CONTAINER_LENGTH, - DEFAULT_MAX_CBOR_DEPTH, -} from "./options.ts"; diff --git a/packages/protocol/src/cbor/options.ts b/packages/protocol/src/cbor/options.ts deleted file mode 100644 index a831768f2..000000000 --- a/packages/protocol/src/cbor/options.ts +++ /dev/null @@ -1,52 +0,0 @@ -export const UINT32_BASE = 0x1_0000_0000; -export const MAX_UINT32 = 0xffff_ffff; -const MAX_CONFIGURED_DEPTH = 512; - -/** Safe defaults for untrusted protocol payloads. */ -export const DEFAULT_MAX_CBOR_BYTE_LENGTH = 16 * 1024 * 1024; -export const DEFAULT_MAX_CBOR_CONTAINER_LENGTH = 1_000_000; -export const DEFAULT_MAX_CBOR_DEPTH = 64; - -export interface CborOptions { - /** Maximum encoded input/output bytes and maximum byte/text string length. */ - maxByteLength?: number; - /** Maximum number of elements in an array or entries in a map. */ - maxContainerLength?: number; - /** Maximum recursive item depth. */ - maxDepth?: number; -} - -export interface ResolvedCborOptions { - maxByteLength: number; - maxContainerLength: number; - maxDepth: number; -} - -export class CborError extends Error { - constructor(message: string) { - super(message); - this.name = "CborError"; - } -} - -export const textEncoder = new TextEncoder(); -export const textDecoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); - -function resolveLimit(name: string, value: number, maximum: number): number { - if (!Number.isSafeInteger(value) || value < 0 || value > maximum) { - throw new RangeError(`${name} must be an integer between 0 and ${maximum}`); - } - return value; -} - -export function resolveOptions(options: CborOptions | undefined): ResolvedCborOptions { - return { - maxByteLength: resolveLimit("maxByteLength", options?.maxByteLength ?? DEFAULT_MAX_CBOR_BYTE_LENGTH, MAX_UINT32), - maxContainerLength: resolveLimit( - "maxContainerLength", - options?.maxContainerLength ?? DEFAULT_MAX_CBOR_CONTAINER_LENGTH, - MAX_UINT32, - ), - maxDepth: resolveLimit("maxDepth", options?.maxDepth ?? DEFAULT_MAX_CBOR_DEPTH, MAX_CONFIGURED_DEPTH), - }; -} diff --git a/packages/protocol/src/codec.ts b/packages/protocol/src/codec.ts deleted file mode 100644 index c98da1c02..000000000 --- a/packages/protocol/src/codec.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { isJsonValue } from "@earendil-works/chord"; -import { Check } from "typebox/value"; -import { decodeCbor, encodeCbor } from "./cbor/index.ts"; -import { DEFAULT_MAX_FRAME_LENGTH, encodeFrame, FrameDecoder, type FrameDecoderOptions } from "./framing.ts"; -import { - type ClientMessage, - ClientMessageSchema, - PROTOCOL_VERSION, - type ServerMessage, - ServerMessageSchema, -} from "./protocol.ts"; - -export class ProtocolValidationError extends Error { - constructor(message: string) { - super(message); - this.name = "ProtocolValidationError"; - } -} - -export function parseClientMessage(value: unknown): ClientMessage { - if (!Check(ClientMessageSchema, value) || !isJsonValue(value)) { - throw new ProtocolValidationError("Invalid client protocol message"); - } - return value; -} - -export function parseServerMessage(value: unknown): ServerMessage { - if (!Check(ServerMessageSchema, value) || !isJsonValue(value)) { - throw new ProtocolValidationError("Invalid server protocol message"); - } - return value; -} - -function boundedErrorMessage(error: unknown): string { - if (!(error instanceof Error)) return "Unknown codec error"; - return error.message.length <= 500 ? error.message : `${error.message.slice(0, 497)}...`; -} - -function encodeProtocolMessage( - value: T, - parse: (candidate: unknown) => T, - kind: string, - options?: FrameDecoderOptions, -): Uint8Array { - const validated = parse(value); - try { - const maxFrameLength = options?.maxFrameLength ?? DEFAULT_MAX_FRAME_LENGTH; - return encodeFrame(encodeCbor(validated, { maxByteLength: maxFrameLength })); - } catch (error) { - if (error instanceof ProtocolValidationError) throw error; - throw new ProtocolValidationError(`Unable to encode ${kind} protocol message: ${boundedErrorMessage(error)}`); - } -} - -/** Validates and encodes one complete length-prefixed client message. */ -export function encodeClientMessage(message: ClientMessage, options?: FrameDecoderOptions): Uint8Array { - return encodeProtocolMessage(message, parseClientMessage, "client", options); -} - -/** Validates and encodes one complete length-prefixed server message. */ -export function encodeServerMessage(message: ServerMessage, options?: FrameDecoderOptions): Uint8Array { - return encodeProtocolMessage(message, parseServerMessage, "server", options); -} - -class ValidatedMessageDecoder { - private failed = false; - private readonly frames: FrameDecoder; - private readonly kind: string; - private readonly maxFrameLength: number; - private readonly parse: (candidate: unknown) => T; - - constructor(kind: string, parse: (candidate: unknown) => T, options?: FrameDecoderOptions) { - this.frames = new FrameDecoder(options); - this.kind = kind; - this.maxFrameLength = options?.maxFrameLength ?? DEFAULT_MAX_FRAME_LENGTH; - this.parse = parse; - } - - push(chunk: Uint8Array): T[] { - if (this.failed) throw new ProtocolValidationError(`${this.kind} message decoder has failed`); - try { - const messages: T[] = []; - for (const frame of this.frames.push(chunk)) { - messages.push(this.parse(decodeCbor(frame, { maxByteLength: this.maxFrameLength }))); - } - return messages; - } catch (error) { - this.failed = true; - if (error instanceof ProtocolValidationError) throw error; - throw new ProtocolValidationError(`Invalid ${this.kind} protocol frame: ${boundedErrorMessage(error)}`); - } - } - - end(): void { - if (this.failed) throw new ProtocolValidationError(`${this.kind} message decoder has failed`); - try { - this.frames.end(); - } catch (error) { - this.failed = true; - throw new ProtocolValidationError(`Invalid ${this.kind} protocol framing: ${boundedErrorMessage(error)}`); - } - } -} - -/** Incrementally decodes and validates framed client messages. */ -export class ClientMessageDecoder { - private readonly decoder: ValidatedMessageDecoder; - - constructor(options?: FrameDecoderOptions) { - this.decoder = new ValidatedMessageDecoder("client", parseClientMessage, options); - } - - push(chunk: Uint8Array): ClientMessage[] { - return this.decoder.push(chunk); - } - - end(): void { - this.decoder.end(); - } -} - -/** Incrementally decodes and validates framed server messages. */ -export class ServerMessageDecoder { - private readonly decoder: ValidatedMessageDecoder; - - constructor(options?: FrameDecoderOptions) { - this.decoder = new ValidatedMessageDecoder("server", parseServerMessage, options); - } - - push(chunk: Uint8Array): ServerMessage[] { - return this.decoder.push(chunk); - } - - end(): void { - this.decoder.end(); - } -} - -export function isSupportedProtocolVersion(version: number): version is typeof PROTOCOL_VERSION { - return Number.isInteger(version) && version === PROTOCOL_VERSION; -} diff --git a/packages/protocol/src/framing.ts b/packages/protocol/src/framing.ts deleted file mode 100644 index 1c6ad286a..000000000 --- a/packages/protocol/src/framing.ts +++ /dev/null @@ -1,151 +0,0 @@ -const FRAME_HEADER_LENGTH = 4; -const MAX_UINT32 = 0xffff_ffff; -const PAYLOAD_BLOCK_SIZE = 64 * 1024; - -/** Default upper bound for one framed CBOR payload. */ -export const DEFAULT_MAX_FRAME_LENGTH = 16 * 1024 * 1024; - -export interface FrameDecoderOptions { - maxFrameLength?: number; -} - -export class FrameError extends Error { - constructor(message: string) { - super(message); - this.name = "FrameError"; - } -} - -function resolveMaxFrameLength(options: FrameDecoderOptions | undefined): number { - const value = options?.maxFrameLength ?? DEFAULT_MAX_FRAME_LENGTH; - if (!Number.isSafeInteger(value) || value < 0 || value > MAX_UINT32) { - throw new RangeError(`maxFrameLength must be an integer between 0 and ${MAX_UINT32}`); - } - return value; -} - -/** Prefixes a payload with its unsigned 32-bit big-endian byte length. */ -export function encodeFrame(payload: Uint8Array): Uint8Array { - if (!(payload instanceof Uint8Array)) throw new TypeError("Frame payload must be a Uint8Array"); - if (payload.byteLength > MAX_UINT32) throw new RangeError("Frame payload exceeds the unsigned 32-bit length limit"); - const frame = new Uint8Array(FRAME_HEADER_LENGTH + payload.byteLength); - const length = payload.byteLength; - frame[0] = length >>> 24; - frame[1] = length >>> 16; - frame[2] = length >>> 8; - frame[3] = length; - frame.set(payload, FRAME_HEADER_LENGTH); - return frame; -} - -type DecoderState = "open" | "ended" | "failed"; - -/** Incrementally splits arbitrary byte chunks into length-prefixed payloads. */ -export class FrameDecoder { - private readonly header = new Uint8Array(FRAME_HEADER_LENGTH); - private headerLength = 0; - private readonly maxFrameLength: number; - private payloadBlocks: Uint8Array[] = []; - private currentPayloadBlock: Uint8Array | undefined; - private currentPayloadBlockLength = 0; - private expectedPayloadLength: number | undefined; - private payloadLength = 0; - private state: DecoderState = "open"; - - constructor(options?: FrameDecoderOptions) { - this.maxFrameLength = resolveMaxFrameLength(options); - } - - push(chunk: Uint8Array): Uint8Array[] { - if (this.state === "ended") throw new FrameError("Frame decoder has ended"); - if (this.state === "failed") throw new FrameError("Frame decoder has failed"); - if (!(chunk instanceof Uint8Array)) throw new TypeError("Frame chunk must be a Uint8Array"); - - const frames: Uint8Array[] = []; - let chunkOffset = 0; - while (chunkOffset < chunk.byteLength) { - if (this.expectedPayloadLength === undefined) { - const headerBytes = Math.min(FRAME_HEADER_LENGTH - this.headerLength, chunk.byteLength - chunkOffset); - this.header.set(chunk.subarray(chunkOffset, chunkOffset + headerBytes), this.headerLength); - this.headerLength += headerBytes; - chunkOffset += headerBytes; - if (this.headerLength < FRAME_HEADER_LENGTH) continue; - - const frameLength = - this.header[0]! * 0x1_000_000 + this.header[1]! * 0x1_0000 + this.header[2]! * 0x100 + this.header[3]!; - this.headerLength = 0; - if (frameLength > this.maxFrameLength) { - this.fail(`Frame length ${frameLength} exceeds configured limit of ${this.maxFrameLength}`); - } - if (frameLength === 0) { - frames.push(new Uint8Array()); - continue; - } - this.expectedPayloadLength = frameLength; - this.payloadBlocks = []; - this.currentPayloadBlock = undefined; - this.currentPayloadBlockLength = 0; - this.payloadLength = 0; - } - - const expectedPayloadLength = this.expectedPayloadLength; - if (expectedPayloadLength === undefined) continue; - while (chunkOffset < chunk.byteLength && this.payloadLength < expectedPayloadLength) { - let block = this.currentPayloadBlock; - if (!block || this.currentPayloadBlockLength === block.byteLength) { - block = new Uint8Array(Math.min(PAYLOAD_BLOCK_SIZE, expectedPayloadLength - this.payloadLength)); - this.payloadBlocks.push(block); - this.currentPayloadBlock = block; - this.currentPayloadBlockLength = 0; - } - const payloadBytes = Math.min( - block.byteLength - this.currentPayloadBlockLength, - chunk.byteLength - chunkOffset, - ); - block.set(chunk.subarray(chunkOffset, chunkOffset + payloadBytes), this.currentPayloadBlockLength); - this.currentPayloadBlockLength += payloadBytes; - this.payloadLength += payloadBytes; - chunkOffset += payloadBytes; - } - if (this.payloadLength === expectedPayloadLength) { - if (this.payloadBlocks.length === 1) { - frames.push(this.payloadBlocks[0]!); - } else { - const payload = new Uint8Array(expectedPayloadLength); - let offset = 0; - for (const payloadBlock of this.payloadBlocks) { - payload.set(payloadBlock, offset); - offset += payloadBlock.byteLength; - } - frames.push(payload); - } - this.payloadBlocks = []; - this.currentPayloadBlock = undefined; - this.currentPayloadBlockLength = 0; - this.expectedPayloadLength = undefined; - this.payloadLength = 0; - } - } - return frames; - } - - end(): void { - if (this.state === "ended") throw new FrameError("Frame decoder has ended"); - if (this.state === "failed") throw new FrameError("Frame decoder has failed"); - if (this.headerLength !== 0 || this.expectedPayloadLength !== undefined) { - this.fail("Truncated frame at end of stream"); - } - this.state = "ended"; - } - - private fail(message: string): never { - this.state = "failed"; - this.headerLength = 0; - this.payloadBlocks = []; - this.currentPayloadBlock = undefined; - this.currentPayloadBlockLength = 0; - this.expectedPayloadLength = undefined; - this.payloadLength = 0; - throw new FrameError(message); - } -} diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts deleted file mode 100644 index ff6df4611..000000000 --- a/packages/protocol/src/index.ts +++ /dev/null @@ -1,22 +0,0 @@ -export * from "./cbor/index.ts"; -export * from "./codec.ts"; -export * from "./framing.ts"; -export { - type AttachmentEnvelope, - type CancelEnvelope, - type ClientHello, - type ClientMessage, - isServerId, - PROTOCOL_VERSION, - type ProtocolError, - type ProtocolErrorCode, - type RequestEnvelope, - type ResponseEnvelope, - type RpcTarget, - type ServerHello, - type ServerHelloError, - type ServerId, - type ServerMessage, - type ServiceEventEnvelope, - type SessionTarget, -} from "./protocol.ts"; diff --git a/packages/protocol/src/protocol.ts b/packages/protocol/src/protocol.ts deleted file mode 100644 index 643151a24..000000000 --- a/packages/protocol/src/protocol.ts +++ /dev/null @@ -1,110 +0,0 @@ -import type { JsonValue } from "@earendil-works/chord"; -import Type, { type Static } from "typebox"; -import { Check } from "typebox/value"; - -export const PROTOCOL_VERSION = 8 as const; - -const IdSchema = Type.String({ minLength: 1 }); -const OpaqueJsonValueSchema = Type.Unsafe(Type.Unknown()); -const StrictObject = [0]>(properties: T) => - Type.Object(properties, { additionalProperties: false }); - -const ServerIdSchema = Type.String({ - pattern: "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", -}); -export type ServerId = Static; - -export function isServerId(value: unknown): value is ServerId { - return Check(ServerIdSchema, value); -} - -const ProtocolErrorSchema = StrictObject({ - code: IdSchema, - message: Type.String(), -}); -export type ProtocolErrorCode = string; -export type ProtocolError = Static; - -/** Must be the first frame sent by a client. */ -const ClientHelloSchema = StrictObject({ - type: Type.Literal("hello"), - version: Type.Integer({ minimum: 0 }), -}); -export type ClientHello = Static; - -/** A server-wide call, fenced to one logical server. */ -const ServerTargetSchema = StrictObject({ - serverId: ServerIdSchema, -}); -/** A session call, fenced to one logical server, durable session, and live attachment. */ -const SessionTargetSchema = StrictObject({ - serverId: ServerIdSchema, - sessionId: IdSchema, - attachmentId: IdSchema, -}); -export type SessionTarget = Static; -const RpcTargetSchema = Type.Union([ServerTargetSchema, SessionTargetSchema]); -export type RpcTarget = Static; - -const RequestEnvelopeSchema = StrictObject({ - type: Type.Literal("request"), - id: IdSchema, - target: RpcTargetSchema, - call: OpaqueJsonValueSchema, -}); -const CancelEnvelopeSchema = StrictObject({ - type: Type.Literal("cancel"), - id: IdSchema, - target: RpcTargetSchema, -}); -export type RequestEnvelope = Static; -export type CancelEnvelope = Static; -export const ClientMessageSchema = Type.Union([ClientHelloSchema, RequestEnvelopeSchema, CancelEnvelopeSchema]); -export type ClientMessage = Static; - -const ServerHelloSchema = StrictObject({ - type: Type.Literal("hello"), - version: Type.Literal(PROTOCOL_VERSION), - serverId: ServerIdSchema, -}); -const ServerHelloErrorSchema = StrictObject({ - type: Type.Literal("hello_error"), - error: ProtocolErrorSchema, -}); -const ResponseEnvelopeSchema = Type.Union([ - StrictObject({ - type: Type.Literal("response"), - id: IdSchema, - ok: Type.Literal(true), - result: Type.Optional(OpaqueJsonValueSchema), - }), - StrictObject({ - type: Type.Literal("response"), - id: IdSchema, - ok: Type.Literal(false), - error: ProtocolErrorSchema, - }), -]); -const ServiceEventEnvelopeSchema = StrictObject({ - type: Type.Literal("service_update"), - subscriptionId: IdSchema, - update: OpaqueJsonValueSchema, -}); -/** Out-of-band update to this presentation's selected Session route. */ -const AttachmentEnvelopeSchema = StrictObject({ - type: Type.Literal("attachment"), - attachment: Type.Union([SessionTargetSchema, Type.Null()]), -}); -export const ServerMessageSchema = Type.Union([ - ServerHelloSchema, - ServerHelloErrorSchema, - ResponseEnvelopeSchema, - ServiceEventEnvelopeSchema, - AttachmentEnvelopeSchema, -]); -export type ServerHello = Static; -export type ServerHelloError = Static; -export type ResponseEnvelope = Static; -export type ServiceEventEnvelope = Static; -export type AttachmentEnvelope = Static; -export type ServerMessage = Static; diff --git a/packages/protocol/test/cbor/cbor.test.ts b/packages/protocol/test/cbor/cbor.test.ts deleted file mode 100644 index 20da27b2f..000000000 --- a/packages/protocol/test/cbor/cbor.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { - CborError, - DEFAULT_MAX_CBOR_BYTE_LENGTH, - DEFAULT_MAX_CBOR_CONTAINER_LENGTH, - DEFAULT_MAX_CBOR_DEPTH, - decodeCbor, - encodeCbor, -} from "../../src/index.ts"; - -function fromHex(hex: string): Uint8Array { - if (hex.length % 2 !== 0) throw new Error("Hex fixture must contain whole bytes"); - const bytes = new Uint8Array(hex.length / 2); - for (let index = 0; index < bytes.length; index++) - bytes[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16); - return bytes; -} - -function toHex(bytes: Uint8Array): string { - return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); -} - -const knownVectors: ReadonlyArray = [ - [null, "f6"], - [false, "f4"], - [true, "f5"], - [0, "00"], - [1, "01"], - [10, "0a"], - [23, "17"], - [24, "1818"], - [25, "1819"], - [100, "1864"], - [1000, "1903e8"], - [1_000_000, "1a000f4240"], - [1_000_000_000_000, "1b000000e8d4a51000"], - [Number.MAX_SAFE_INTEGER, "1b001fffffffffffff"], - [-1, "20"], - [-10, "29"], - [-24, "37"], - [-25, "3818"], - [-100, "3863"], - [-1000, "3903e7"], - [-1_000_000, "3a000f423f"], - [Number.MIN_SAFE_INTEGER, "3b001ffffffffffffe"], - [1.1, "fb3ff199999999999a"], - [-0, "fb8000000000000000"], - [new Uint8Array([1, 2, 3, 4]), "4401020304"], - ["", "60"], - ["IETF", "6449455446"], - ["ü", "62c3bc"], - ["水", "63e6b0b4"], - ["𐅑", "64f0908591"], - [[], "80"], - [[1, 2, 3], "83010203"], - [[1, [2, 3], [4, 5]], "8301820203820405"], - [{ a: 1, b: [2, 3] }, "a26161016162820203"], -]; - -describe("CBOR codec", () => { - test.each(knownVectors)("encodes and decodes RFC 8949 vector %#", (value, wire) => { - const encoded = encodeCbor(value); - expect(toHex(encoded)).toBe(wire); - const decoded = decodeCbor(fromHex(wire)); - if (typeof value === "number" && Object.is(value, -0)) expect(Object.is(decoded, -0)).toBe(true); - else expect(decoded).toEqual(value); - }); - - test("omits undefined object properties without omitting falsey values", () => { - const value = { omitted: undefined, zero: 0, empty: "", no: false, nil: null }; - expect(decodeCbor(encodeCbor(value))).toEqual({ zero: 0, empty: "", no: false, nil: null }); - }); - - test("preserves a leading Unicode BOM and treats __proto__ as data", () => { - expect(decodeCbor(fromHex("63efbbbf"))).toBe("\ufeff"); - const value: Record = {}; - Object.defineProperty(value, "__proto__", { enumerable: true, value: "safe" }); - const decoded = decodeCbor(encodeCbor(value)); - expect(decoded).toEqual(value); - expect(Object.getPrototypeOf(decoded)).toBe(Object.prototype); - expect(Object.hasOwn(decoded as object, "__proto__")).toBe(true); - }); - - test.each([ - ["top-level undefined", undefined], - ["undefined array element", [undefined]], - ["array hole", new Array(1)], - ["NaN", Number.NaN], - ["positive infinity", Number.POSITIVE_INFINITY], - ["negative infinity", Number.NEGATIVE_INFINITY], - ["unsafe positive integer", Number.MAX_SAFE_INTEGER + 1], - ["unsafe negative integer", Number.MIN_SAFE_INTEGER - 1], - ["bigint", 1n], - ["symbol", Symbol("unsupported")], - ["function", () => undefined], - ["Date", new Date(0)], - ["Map", new Map()], - ] as const)("rejects unsupported encoder value: %s", (_label, value) => { - expect(() => encodeCbor(value)).toThrow(CborError); - }); - - test("rejects maps with enumerable symbol keys", () => { - const value = { valid: true }; - Object.defineProperty(value, Symbol("key"), { enumerable: true, value: false }); - expect(() => encodeCbor(value)).toThrow(CborError); - }); - - test("rejects lossy strings, cycles, and excessive encoder depth", () => { - expect(() => encodeCbor("\ud800")).toThrow(/Unicode/i); - - const cyclic: unknown[] = []; - cyclic.push(cyclic); - expect(() => encodeCbor(cyclic)).toThrow(/cycles/i); - - let tooDeep: unknown = null; - for (let depth = 0; depth <= DEFAULT_MAX_CBOR_DEPTH; depth++) tooDeep = [tooDeep]; - expect(() => encodeCbor(tooDeep)).toThrow(/depth/i); - }); - - test.each([ - ["empty input", ""], - ["truncated integer", "18"], - ["reserved additional information", "1c"], - ["indefinite byte string", "5f"], - ["indefinite text string", "7f"], - ["indefinite array", "9f"], - ["indefinite map", "bf"], - ["tag", "c000"], - ["undefined", "f7"], - ["unsupported simple value", "e0"], - ["break outside an indefinite item", "ff"], - ["float16", "f93c00"], - ["float32", "fa3f800000"], - ["positive infinity", "fb7ff0000000000000"], - ["NaN", "fb7ff8000000000000"], - ["truncated float64", "fb3ff00000"], - ["truncated byte string", "44010203"], - ["truncated text string", "636162"], - ["truncated array", "8201"], - ["truncated map", "a16161"], - ["trailing data", "0000"], - ["non-string map key", "a10102"], - ["duplicate map key", "a2616101616102"], - ["invalid UTF-8 byte", "61ff"], - ["overlong UTF-8", "62c080"], - ["UTF-8 surrogate", "63eda080"], - ["unsafe positive integer", "1b0020000000000000"], - ["unsafe negative integer", "3b001fffffffffffff"], - ["unsafe integer encoded as float64", "fb4340000000000000"], - ] as const)("rejects invalid decoder input: %s", (_label, wire) => { - expect(() => decodeCbor(fromHex(wire))).toThrow(CborError); - }); - - test("enforces depth and declared length limits before traversing values", () => { - const tooDeep = new Uint8Array(DEFAULT_MAX_CBOR_DEPTH + 2); - tooDeep.fill(0x81, 0, -1); - tooDeep[tooDeep.length - 1] = 0xf6; - expect(() => decodeCbor(tooDeep)).toThrow(/depth/i); - - const oversizedBytes = fromHex(`5a${(DEFAULT_MAX_CBOR_BYTE_LENGTH + 1).toString(16).padStart(8, "0")}`); - const oversizedText = fromHex(`7a${(DEFAULT_MAX_CBOR_BYTE_LENGTH + 1).toString(16).padStart(8, "0")}`); - const oversizedArray = fromHex(`9a${(DEFAULT_MAX_CBOR_CONTAINER_LENGTH + 1).toString(16).padStart(8, "0")}`); - const oversizedMap = fromHex(`ba${(DEFAULT_MAX_CBOR_CONTAINER_LENGTH + 1).toString(16).padStart(8, "0")}`); - for (const wire of [oversizedBytes, oversizedText, oversizedArray, oversizedMap]) { - expect(() => decodeCbor(wire)).toThrow(/limit/i); - } - }); - - test("supports stricter caller-provided limits", () => { - expect(() => decodeCbor(fromHex("83010203"), { maxContainerLength: 2 })).toThrow(/limit/i); - expect(() => decodeCbor(fromHex("626162"), { maxByteLength: 2 })).toThrow(/limit/i); - expect(() => encodeCbor([1, 2, 3], { maxContainerLength: 2 })).toThrow(/limit/i); - expect(() => encodeCbor("ab", { maxByteLength: 2 })).toThrow(/limit/i); - }); -}); diff --git a/packages/protocol/test/framing.test.ts b/packages/protocol/test/framing.test.ts deleted file mode 100644 index 7dd1ec258..000000000 --- a/packages/protocol/test/framing.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { DEFAULT_MAX_FRAME_LENGTH, encodeFrame, FrameDecoder, FrameError } from "../src/index.ts"; - -function concatenate(...chunks: Uint8Array[]): Uint8Array { - const result = new Uint8Array(chunks.reduce((length, chunk) => length + chunk.byteLength, 0)); - let offset = 0; - for (const chunk of chunks) { - result.set(chunk, offset); - offset += chunk.byteLength; - } - return result; -} - -describe("binary framing", () => { - test("prefixes payloads with a four-byte big-endian length", () => { - expect(encodeFrame(new Uint8Array([0xaa, 0xbb, 0xcc]))).toEqual( - new Uint8Array([0x00, 0x00, 0x00, 0x03, 0xaa, 0xbb, 0xcc]), - ); - expect(encodeFrame(new Uint8Array())).toEqual(new Uint8Array([0, 0, 0, 0])); - }); - - test("decodes fragmented, coalesced, and empty frames in order", () => { - const wire = concatenate( - encodeFrame(new Uint8Array([1, 2, 3])), - encodeFrame(new Uint8Array()), - encodeFrame(new Uint8Array([4])), - ); - const decoder = new FrameDecoder(); - const frames: Uint8Array[] = []; - for (const byte of wire) frames.push(...decoder.push(new Uint8Array([byte]))); - decoder.end(); - expect(frames).toEqual([new Uint8Array([1, 2, 3]), new Uint8Array(), new Uint8Array([4])]); - - const coalesced = new FrameDecoder(); - expect(coalesced.push(wire)).toEqual(frames); - coalesced.end(); - }); - - test("assembles payloads spanning multiple internal blocks", () => { - const payload = Uint8Array.from({ length: 70_000 }, (_, index) => index % 251); - const wire = encodeFrame(payload); - const decoder = new FrameDecoder(); - const frames = [ - ...decoder.push(wire.subarray(0, 101)), - ...decoder.push(wire.subarray(101, 65_541)), - ...decoder.push(wire.subarray(65_541)), - ]; - decoder.end(); - expect(frames).toEqual([payload]); - }); - - test("handles every split point across a frame", () => { - const wire = encodeFrame(new Uint8Array([10, 20, 30, 40])); - for (let split = 0; split <= wire.byteLength; split++) { - const decoder = new FrameDecoder(); - const frames = [...decoder.push(wire.subarray(0, split)), ...decoder.push(wire.subarray(split))]; - decoder.end(); - expect(frames).toEqual([new Uint8Array([10, 20, 30, 40])]); - } - }); - - test("copies payload bytes instead of retaining or aliasing input chunks", () => { - const chunk = encodeFrame(new Uint8Array([1, 2, 3])); - const decoder = new FrameDecoder(); - const frames = decoder.push(chunk); - chunk.fill(9); - expect(frames).toEqual([new Uint8Array([1, 2, 3])]); - }); - - test("accepts empty chunks and a clean empty stream", () => { - const decoder = new FrameDecoder(); - expect(decoder.push(new Uint8Array())).toEqual([]); - expect(() => decoder.end()).not.toThrow(); - }); - - test.each([ - ["partial header", new Uint8Array([0, 0, 0])], - ["partial payload", new Uint8Array([0, 0, 0, 2, 1])], - ] as const)("rejects a truncated stream at end: %s", (_label, wire) => { - const decoder = new FrameDecoder(); - expect(decoder.push(wire)).toEqual([]); - expect(() => decoder.end()).toThrow(FrameError); - }); - - test("rejects an oversized declared length as soon as its header is complete", () => { - const decoder = new FrameDecoder({ maxFrameLength: 3 }); - expect(() => decoder.push(new Uint8Array([0, 0, 0, 4]))).toThrow(/limit/i); - expect(() => decoder.push(new Uint8Array([1]))).toThrow(/failed/i); - }); - - test("accepts a frame exactly at the configured maximum", () => { - const decoder = new FrameDecoder({ maxFrameLength: 3 }); - expect(decoder.push(encodeFrame(new Uint8Array([1, 2, 3])))).toEqual([new Uint8Array([1, 2, 3])]); - decoder.end(); - }); - - test("cannot be pushed after end", () => { - const decoder = new FrameDecoder(); - decoder.end(); - expect(() => decoder.push(new Uint8Array())).toThrow(/ended/i); - expect(() => decoder.end()).toThrow(/ended/i); - }); - - test.each([-1, 1.5, Number.NaN, DEFAULT_MAX_FRAME_LENGTH * 1_000])( - "rejects invalid maximum frame length: %s", - (maxFrameLength) => { - expect(() => new FrameDecoder({ maxFrameLength })).toThrow(RangeError); - }, - ); -}); diff --git a/packages/protocol/test/protocol.test.ts b/packages/protocol/test/protocol.test.ts deleted file mode 100644 index 7d85c67ab..000000000 --- a/packages/protocol/test/protocol.test.ts +++ /dev/null @@ -1,282 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { - type ClientHello, - type ClientMessage, - ClientMessageDecoder, - decodeCbor, - encodeCbor, - encodeClientMessage, - encodeFrame, - encodeServerMessage, - FrameDecoder, - isSupportedProtocolVersion, - PROTOCOL_VERSION, - ProtocolValidationError, - parseClientMessage, - parseServerMessage, - type ServerHello, - type ServerMessage, - ServerMessageDecoder, -} from "../src/index.ts"; - -const clientHello: ClientHello = { type: "hello", version: PROTOCOL_VERSION }; -const serverHello: ServerHello = { - type: "hello", - version: PROTOCOL_VERSION, - serverId: "00000000-0000-4000-8000-000000000001", -}; - -describe("protocol validation", () => { - test("negotiates protocol version 8", () => { - expect(PROTOCOL_VERSION).toBe(8); - expect(isSupportedProtocolVersion(8)).toBe(true); - expect(isSupportedProtocolVersion(7)).toBe(false); - expect(isSupportedProtocolVersion(8.5)).toBe(false); - }); - - test.each([0, PROTOCOL_VERSION, PROTOCOL_VERSION + 1])( - "accepts integer client hello version %s for negotiation", - (version) => expect(parseClientMessage({ ...clientHello, version })).toEqual({ ...clientHello, version }), - ); - - test.each([ - { type: "hello", version: String(PROTOCOL_VERSION) }, - { type: "hello", version: PROTOCOL_VERSION + 0.5 }, - { type: "hello", version: PROTOCOL_VERSION, extra: true }, - ])("rejects an invalid client hello", (message) => { - expect(() => parseClientMessage(message)).toThrow(ProtocolValidationError); - }); - - test.each([ - "", - "server-1", - "00000000-0000-7000-8000-000000000001", - "00000000-0000-4000-7000-000000000001", - "00000000-0000-4000-8000-00000000000A", - ])("rejects non-canonical UUIDv4 server ID %j", (serverId) => { - expect(() => - parseClientMessage({ - type: "request", - id: "request-1", - target: { serverId }, - call: { serviceId: "pi.models", member: "list", args: [] }, - }), - ).toThrow(ProtocolValidationError); - }); - - test("keeps routed request and event payloads opaque", () => { - const request: ClientMessage = { - type: "request", - id: "request-1", - target: { - serverId: "00000000-0000-4000-8000-000000000001", - sessionId: "session-1", - attachmentId: "attachment-1", - }, - call: { - serviceId: "application.custom", - instance: { key: "instance-1", generation: 2 }, - member: "invoke", - args: [{ arbitrary: true }, ["opaque"]], - }, - }; - expect(parseClientMessage(request)).toEqual(request); - expect( - parseClientMessage({ - ...request, - call: { arbitrary: "strict JSON whose service meaning belongs to Chord" }, - }), - ).toMatchObject({ call: { arbitrary: expect.any(String) } }); - expect( - parseServerMessage({ - type: "service_update", - subscriptionId: "subscription-1", - update: { applicationDefined: true }, - }), - ).toMatchObject({ update: { applicationDefined: true } }); - }); - - test("rejects non-JSON opaque payloads", () => { - const request = { - type: "request", - id: "request-1", - target: { serverId: "00000000-0000-4000-8000-000000000001" }, - call: { serviceId: "application.custom", member: "invoke", args: [] }, - }; - const cyclic: { self?: unknown } = {}; - cyclic.self = cyclic; - for (const [label, value] of [ - ["byte array", new Uint8Array([1])], - ["non-finite number", Number.NaN], - ["undefined property", { value: undefined }], - ["cycle", cyclic], - ] as const) { - expect(() => parseClientMessage({ ...request, call: { ...request.call, args: [value] } }), label).toThrow( - ProtocolValidationError, - ); - expect( - () => parseServerMessage({ type: "response", id: "request-1", ok: true, result: value }), - label, - ).toThrow(ProtocolValidationError); - } - }); - - test("validates request cancellation envelopes", () => { - const cancel: ClientMessage = { - type: "cancel", - id: "request-1", - target: { serverId: "00000000-0000-4000-8000-000000000001" }, - }; - expect(parseClientMessage(cancel)).toEqual(cancel); - expect(() => parseClientMessage({ ...cancel, id: "" })).toThrow(ProtocolValidationError); - expect(() => parseClientMessage({ ...cancel, extra: true })).toThrow(ProtocolValidationError); - }); - - test("validates attachment route updates", () => { - const attached: ServerMessage = { - type: "attachment", - attachment: { - serverId: "00000000-0000-4000-8000-000000000001", - sessionId: "session-1", - attachmentId: "attachment-1", - }, - }; - const detached: ServerMessage = { type: "attachment", attachment: null }; - expect(parseServerMessage(attached)).toEqual(attached); - expect(parseServerMessage(detached)).toEqual(detached); - expect(() => parseServerMessage({ ...attached, attachment: { sessionId: "session-1" } })).toThrow( - ProtocolValidationError, - ); - }); - - test.each([ - [ - "empty request id", - { - type: "request", - id: "", - target: { serverId: "00000000-0000-4000-8000-000000000001" }, - call: { serviceId: "pi.models", member: "list", args: [] }, - }, - ], - [ - "extra envelope field", - { - type: "request", - id: "request-1", - target: { serverId: "00000000-0000-4000-8000-000000000001" }, - call: { serviceId: "pi.models", member: "list", args: [] }, - extra: true, - }, - ], - ] as const)("rejects malformed request boundaries: %s", (_label, message) => { - expect(() => parseClientMessage(message)).toThrow(ProtocolValidationError); - }); - - test("accepts a successful void response without a result field", () => { - expect(parseServerMessage({ type: "response", id: "request-1", ok: true })).toEqual({ - type: "response", - id: "request-1", - ok: true, - }); - }); - - test.each([ - ["invalid server id", { ...serverHello, serverId: "server-1" }], - ["extra response field", { type: "response", id: "request-1", ok: true, result: [], extra: true }], - ["empty error code", { type: "response", id: "request-1", ok: false, error: { code: "", message: "bad" } }], - ] as const)("rejects malformed server boundaries: %s", (_label, message) => { - expect(() => parseServerMessage(message)).toThrow(ProtocolValidationError); - }); - - test.each(["wrong_server", "cancelled", "service_not_found", "application_error"] as const)( - "accepts the opaque %s error code", - (code) => { - const message: ServerMessage = { - type: "response", - id: "request-1", - ok: false, - error: { code, message: "safe" }, - }; - expect(parseServerMessage(message)).toEqual(message); - }, - ); - - test("rejects unknown messages and fields", () => { - expect(() => parseServerMessage({ ...serverHello, snapshot: {} })).toThrow(ProtocolValidationError); - expect(() => parseServerMessage({ type: "unknown", event: {} })).toThrow(ProtocolValidationError); - }); - - test("does not parse JSON strings as messages", () => { - expect(() => parseClientMessage(JSON.stringify(clientHello))).toThrow(ProtocolValidationError); - expect(() => parseServerMessage(JSON.stringify(serverHello))).toThrow(ProtocolValidationError); - }); -}); - -describe("validated framed protocol APIs", () => { - test("encodes complete client and server frames", () => { - const clientFrames = new FrameDecoder().push(encodeClientMessage(clientHello)); - expect(parseClientMessage(decodeCbor(clientFrames[0]!))).toEqual(clientHello); - const serverFrames = new FrameDecoder().push(encodeServerMessage(serverHello)); - expect(parseServerMessage(decodeCbor(serverFrames[0]!))).toEqual(serverHello); - }); - - test("enforces outbound frame limits", () => { - expect(() => encodeClientMessage(clientHello, { maxFrameLength: 8 })).toThrow(ProtocolValidationError); - expect(() => encodeServerMessage(serverHello, { maxFrameLength: 8 })).toThrow(ProtocolValidationError); - }); - - test("incrementally decodes fragmented and coalesced client messages", () => { - const request: ClientMessage = { - type: "request", - id: "request-1", - target: { serverId: "00000000-0000-4000-8000-000000000001" }, - call: { serviceId: "pi.session-directory", member: "list", args: [] }, - }; - const first = encodeClientMessage(clientHello); - const second = encodeClientMessage(request); - const wire = new Uint8Array(first.byteLength + second.byteLength); - wire.set(first); - wire.set(second, first.byteLength); - - for (let split = 0; split <= wire.byteLength; split++) { - const decoder = new ClientMessageDecoder(); - const messages = [...decoder.push(wire.subarray(0, split)), ...decoder.push(wire.subarray(split))]; - decoder.end(); - expect(messages).toEqual([clientHello, request]); - } - }); - - test("incrementally decodes fragmented and coalesced server messages", () => { - const response: ServerMessage = { type: "response", id: "request-1", ok: true, result: [] }; - const first = encodeServerMessage(serverHello); - const second = encodeServerMessage(response); - const wire = new Uint8Array(first.byteLength + second.byteLength); - wire.set(first); - wire.set(second, first.byteLength); - - const split = first.byteLength + Math.floor(second.byteLength / 2); - const decoder = new ServerMessageDecoder(); - expect(decoder.push(wire.subarray(0, split))).toEqual([serverHello]); - expect(decoder.push(wire.subarray(split))).toEqual([response]); - decoder.end(); - }); - - test.each([ - ["empty CBOR payload", encodeFrame(new Uint8Array())], - ["malformed CBOR", encodeFrame(new Uint8Array([0xff]))], - ["schema-invalid CBOR", encodeFrame(encodeCbor({ type: "hello", version: 1, extra: true }))], - ] as const)("rejects invalid framed input: %s", (_label, wire) => { - const decoder = new ClientMessageDecoder(); - expect(() => decoder.push(wire)).toThrow(ProtocolValidationError); - expect(() => decoder.push(encodeClientMessage(clientHello))).toThrow(/failed/i); - }); - - test("rejects truncated and oversized framing", () => { - const truncated = new ServerMessageDecoder(); - expect(truncated.push(new Uint8Array([0, 0, 0, 2, 1]))).toEqual([]); - expect(() => truncated.end()).toThrow(ProtocolValidationError); - const oversized = new ClientMessageDecoder({ maxFrameLength: 3 }); - expect(() => oversized.push(new Uint8Array([0, 0, 0, 4]))).toThrow(ProtocolValidationError); - }); -}); diff --git a/packages/protocol/tsconfig.build.json b/packages/protocol/tsconfig.build.json deleted file mode 100644 index 695dd9ad2..000000000 --- a/packages/protocol/tsconfig.build.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src" - }, - "include": ["src/**/*.ts"], - "exclude": ["node_modules", "dist", "**/*.d.ts", "src/**/*.d.ts"] -} diff --git a/packages/protocol/tsconfig.test.json b/packages/protocol/tsconfig.test.json deleted file mode 100644 index e1276ec0b..000000000 --- a/packages/protocol/tsconfig.test.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "noEmit": true, - "module": "NodeNext", - "moduleResolution": "NodeNext", - "types": ["node", "vitest"] - }, - "include": ["src/**/*.ts", "test/**/*.ts"] -} diff --git a/packages/protocol/vitest.config.ts b/packages/protocol/vitest.config.ts deleted file mode 100644 index 18733b4d4..000000000 --- a/packages/protocol/vitest.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -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"] }, - ssr: { resolve: { conditions: ["source"] } }, -}); diff --git a/packages/server/CHANGELOG.md b/packages/server/CHANGELOG.md deleted file mode 100644 index f35f2563c..000000000 --- a/packages/server/CHANGELOG.md +++ /dev/null @@ -1,87 +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 - -### Breaking Changes - -- `SessionMetadata` is now exported by `@earendil-works/pi-server` and requires only `id`; the package no longer depends on `@earendil-works/pi-agent-core`. The testing `TestServerHost` keeps an in-memory session map instead of a `MemorySessionRepo`, and `TestHarness` exposes `metadata` instead of `session`. - -## [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 - -- Changed `toProtocolToolResultMessage()` to require the original `ToolCall` and verify tool result association. -- Changed `PiServerService.listSessions()` to return durable `SessionMetadata` instead of runtime `SessionSummary` values ([#7708](https://github.com/earendil-works/pi/pull/7708)). - -### Fixed - -- Hardened protocol adapters against contradictory lifecycle states, invalid identifiers and timestamps, sparse execution arrays, and additive `pi-ai` contract drift. -- Sanitized service and runtime failures into stable `not_implemented` and `internal_error` responses without exposing private error details ([#7644](https://github.com/earendil-works/pi/pull/7644)). - -## [0.83.0] - 2026-07-29 - -## [0.82.1] - 2026-07-25 - -## [0.82.0] - 2026-07-24 - -## [0.81.1] - 2026-07-21 - -## [0.81.0] - 2026-07-21 - -### Changed - -- Renamed the orchestrator workspace package and internal server references to server ([#6898](https://github.com/earendil-works/pi/pull/6898) by [@cristinaponcela](https://github.com/cristinaponcela)). - -## [0.80.10] - 2026-07-16 - -## [0.80.9] - 2026-07-16 - -## [0.80.8] - 2026-07-16 - -## [0.80.7] - 2026-07-14 - -## [0.80.6] - 2026-07-09 - -## [0.80.5] - 2026-07-09 - -## [0.80.4] - 2026-07-09 - -## [0.80.3] - 2026-06-30 diff --git a/packages/server/README.md b/packages/server/README.md deleted file mode 100644 index 2cddb3fbb..000000000 --- a/packages/server/README.md +++ /dev/null @@ -1,63 +0,0 @@ -# @earendil-works/pi-server - -Experimental local server that routes clients to application-hosted durable Sessions. - -The current slice supports server- and Session-scoped facet-service routing and multi-presentation attachment. `RoutedServerServiceHost.attachClient()` creates one connection-scoped server service endpoint with narrow attachment-management capabilities. `RoutedSessionHandle.attachClient()` returns a presentation-scoped Session capability. Its `invokeService()` forwards an opaque service/member envelope to the selected Session endpoint; the server validates the attachment route but does not load the facet contract. - -- server service calls and subscriptions route opaquely through the connection's `RoutedServerServiceAttachment`; -- the application-owned `SessionDirectory` projects the private catalog into replicated presentation-safe state; -- the application-owned `SessionManagement` creates, removes, attaches, and detaches Sessions without exposing route IDs in business results; -- attachment changes are published out of band after the router installs or clears the live route; -- Session service calls route through `invokeService` without server-side business-payload decoding; -- service subscription updates remain scoped to the requesting attachment; -- application observations such as transcripts route as ordinary service state without server-owned business schemas. - -A Session may have multiple presentation attachments. Repeating `attach` from one connection is idempotent; every successful attachment has a server-generated `attachmentId` delivered only as routing control data. Session requests carry `{ serverId, sessionId, attachmentId }`, and the server rejects stale or mismatched routes. Losing a connection rejects its local responses but releases its attachment only after admitted service calls settle. The host decides when zero presentation demand and worker-local Harness activity permit worker retirement. Server shutdown closes every routed Session handle, releasing its worker and Session writer ownership. - -```ts -import { randomUUID } from "node:crypto"; -import { - type RoutedServerServiceHost, - type RoutedSessionHandle, - type ServerHost, - type SessionMetadata, - SessionNotFoundError, -} from "@earendil-works/pi-server"; -import { createUnixServer, getUnixSocketPath } from "@earendil-works/pi-server/unix"; - -interface StoredSession extends SessionMetadata { - path: string; -} - -async function startServer( - serverServices: RoutedServerServiceHost, - sessions: Map, - openRoutedSession: (session: StoredSession) => Promise, -) { - const host: ServerHost = { - serverServices, - async resolveSession(sessionId) { - const metadata = sessions.get(sessionId); - if (!metadata) throw new SessionNotFoundError(`Unknown session: ${sessionId}`); - return metadata; - }, - openSession: (metadata) => openRoutedSession(metadata), - }; - - const serverId = randomUUID(); - const server = createUnixServer(host, { - serverId, - path: getUnixSocketPath(serverId, "/run/user/1000/pi"), - }); - await server.start(); - return server; -} -``` - -Applications supply a required server service host, a bounded Session resolver, and a routed Session factory. `SessionMetadata` only requires an `id`; applications may extend it with their own storage fields. Session discovery and management are application-owned services; the protocol server only asks the resolver for metadata when routing an attachment. The host owns acquiring the worker-local Session and Harness. Failures are cleaned up in that worker. Neither an open JavaScript Session nor a Harness crosses the process boundary. - -`serverId` is a logical identity supplied by the launcher, not a socket address. The Unix preset requires an explicit physical `path`; `getUnixSocketPath()` derives one from a caller-selected directory. Choose a short, private runtime directory rather than deriving the route from an unbounded home-directory path. A long-lived launcher can reuse the same ID and path when replacing a server process. - -`Server` composes transports through `ServerListener`; peer authentication remains application policy and is not implemented by the experimental Unix transport. The Unix submodule provides `createUnixListener()` and `createUnixServer()`. Low-level routed-envelope validation, CBOR, and framing come from `@earendil-works/pi-protocol`; Chord owns service-control parsing, error codes, snapshots and updates, and each subscription's replicated-state encoder. - -Server and worker lifecycle is managed outside the public Pi protocol. The replaceable application server converts connection attachments into private demand updates; the worker combines generation-tagged demand with authoritative Harness activity. The experimental coordinator only supplies stable routing and reports generic server-generation connection changes. diff --git a/packages/server/package.json b/packages/server/package.json deleted file mode 100644 index dac8e6f15..000000000 --- a/packages/server/package.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "name": "@earendil-works/pi-server", - "version": "1.1.0", - "description": "experimental server package for pi", - "type": "module", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - }, - "./testing": { - "types": "./dist/testing/index.d.ts", - "import": "./dist/testing/index.js" - }, - "./unix": { - "types": "./dist/transports/unix/index.d.ts", - "import": "./dist/transports/unix/index.js" - } - }, - "files": [ - "dist", - "README.md", - "CHANGELOG.md" - ], - "scripts": { - "clean": "shx rm -rf dist", - "dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput", - "build": "tsc -p tsconfig.build.json", - "test": "vitest --run", - "typecheck": "tsc -p tsconfig.test.json", - "prepublishOnly": "npm run clean && npm run build" - }, - "keywords": [ - "pi", - "server" - ], - "author": "Earendil Works", - "license": "MIT", - "repository": { - "type": "git", - "url": "git+https://github.com/earendil-works/pi.git", - "directory": "packages/server" - }, - "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" - } -} diff --git a/packages/server/src/connection.ts b/packages/server/src/connection.ts deleted file mode 100644 index 9543af03f..000000000 --- a/packages/server/src/connection.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { ServiceStateEncoder } from "@earendil-works/chord"; -import type { ClientMessageDecoder, RpcTarget } from "@earendil-works/pi-protocol"; - -import type { MaybePromise, RoutedServerServiceAttachment } from "./types.ts"; - -/** An established, authorized ordered byte connection. */ -export interface ByteConnection { - readonly closed: boolean; - send(chunk: Uint8Array): Promise; - close(finalChunk?: Uint8Array): MaybePromise; -} - -export interface ByteConnectionHandler { - onData(chunk: Uint8Array): void; - onClose(): void; - onError(error: Error): void; -} - -export type ByteConnectionAcceptor = (connection: ByteConnection) => ByteConnectionHandler; - -export type ConnectionStage = "awaitingHello" | "handshaking" | "ready" | "closing" | "closed"; - -export interface ConnectionState { - connection: ByteConnection; - decoder: ClientMessageDecoder; - serviceStateEncoders: Map; - stage: ConnectionStage; - disconnected: boolean; - handshake?: Promise; - handshakeTimeout: NodeJS.Timeout; - serverServices?: RoutedServerServiceAttachment; - activeRequests: Map; -} - -export function isTerminalConnection(state: ConnectionState): boolean { - return state.disconnected || state.stage === "closing" || state.stage === "closed"; -} diff --git a/packages/server/src/errors.ts b/packages/server/src/errors.ts deleted file mode 100644 index 26b688b59..000000000 --- a/packages/server/src/errors.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { RemoteServiceErrorCode } from "@earendil-works/chord"; - -type ServerOperationErrorCode = - | RemoteServiceErrorCode - | "wrong_server" - | "session_not_found" - | "session_ambiguous" - | "session_not_attached" - | "server_draining"; - -export const INTERNAL_SERVER_ERROR_MESSAGE = "Internal server error"; - -/** A host or lifecycle error that can safely cross the protocol boundary. */ -export class ServerError extends Error { - readonly code: ServerOperationErrorCode; - - constructor(code: ServerOperationErrorCode, message: string) { - super(message); - this.name = "ServerError"; - this.code = code; - } -} - -export class WrongServerError extends ServerError { - constructor() { - super("wrong_server", "Request was addressed to another server"); - this.name = "WrongServerError"; - } -} - -export class SessionNotFoundError extends ServerError { - constructor(message = "Session was not found") { - super("session_not_found", message); - this.name = "SessionNotFoundError"; - } -} - -export class SessionAmbiguousError extends ServerError { - constructor() { - super("session_ambiguous", "Session ID matches more than one session"); - this.name = "SessionAmbiguousError"; - } -} - -export class SessionNotAttachedError extends ServerError { - constructor() { - super("session_not_attached", "Session is not attached to this client"); - this.name = "SessionNotAttachedError"; - } -} - -export class ServerDrainingError extends ServerError { - constructor() { - super("server_draining", "Server is draining"); - this.name = "ServerDrainingError"; - } -} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts deleted file mode 100644 index ad61d7cec..000000000 --- a/packages/server/src/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from "./errors.ts"; -export * from "./listener.ts"; -export * from "./server.ts"; -export * from "./types.ts"; diff --git a/packages/server/src/listener.ts b/packages/server/src/listener.ts deleted file mode 100644 index 02c917c5c..000000000 --- a/packages/server/src/listener.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { ByteConnectionAcceptor } from "./connection.ts"; - -/** Supplies established byte connections after any required transport authentication. */ -export interface ServerListener { - /** Starts listening and passes authorized connections to accept. */ - start(accept: ByteConnectionAcceptor): Promise; - close(): Promise; -} diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts deleted file mode 100644 index 0936aae5b..000000000 --- a/packages/server/src/server.ts +++ /dev/null @@ -1,576 +0,0 @@ -import { - createServiceStateEncoder, - decodeServiceControlCall, - type JsonValue, - parseServiceCall, - parseServiceSubscriptionSnapshot, - RemoteServiceError, - type ServiceCall, - type ServiceProviderUpdate, -} from "@earendil-works/chord"; -import { BACKGROUND_CONTEXT, TODO_CONTEXT, withAbortSignal } from "@earendil-works/chord/context"; -import { - type CancelEnvelope, - type ClientHello, - type ClientMessage, - ClientMessageDecoder, - DEFAULT_MAX_FRAME_LENGTH, - encodeServerMessage, - isServerId, - isSupportedProtocolVersion, - PROTOCOL_VERSION, - type ProtocolError, - ProtocolValidationError, - type RequestEnvelope, - type ResponseEnvelope, - type RpcTarget, - type ServerHello, - type ServerHelloError, - type ServerMessage, -} from "@earendil-works/pi-protocol"; -import { - type ByteConnection, - type ByteConnectionHandler, - type ConnectionState, - isTerminalConnection, -} from "./connection.ts"; -import { INTERNAL_SERVER_ERROR_MESSAGE, ServerError, WrongServerError } from "./errors.ts"; -import type { ServerListener } from "./listener.ts"; -import { SessionRouter } from "./session-router.ts"; -import type { ServerHost, ServerOptions, SessionMetadata } from "./types.ts"; - -const DEFAULT_HANDSHAKE_TIMEOUT_MS = 5_000; -const MAX_UINT32 = 0xffff_ffff; -const MAX_TIMER_DELAY_MS = 2_147_483_647; - -export class Server { - readonly serverId: string; - /** Resolves after shutdown, or rejects when listener or routed-Session cleanup fails. */ - readonly closed: Promise; - - private readonly host: ServerHost; - private readonly listeners: readonly ServerListener[]; - private readonly maxFrameLength: number; - private readonly handshakeTimeoutMs: number; - private readonly onConnectionCountChanged: ((count: number) => void) | undefined; - private readonly onError: ((error: Error) => void) | undefined; - private readonly connections = new Set(); - private readonly sessions: SessionRouter; - private closing = false; - private closePromise?: Promise; - private closedSettled = false; - private rejectClosed!: (error: unknown) => void; - private resolveClosed!: () => void; - private startPromise?: Promise; - private started = false; - - constructor(host: ServerHost, options: ServerOptions) { - const resolved = resolveOptions(options); - this.host = host; - this.listeners = options.listeners; - this.serverId = options.serverId; - this.maxFrameLength = resolved.maxFrameLength; - this.handshakeTimeoutMs = resolved.handshakeTimeoutMs; - this.onConnectionCountChanged = options.onConnectionCountChanged; - this.onError = options.onError; - this.sessions = new SessionRouter({ - host, - serverId: this.serverId, - isClosing: () => this.closing, - publishAttachment: async (client, attachment) => { - await this.sendMessage(client as ConnectionState, { - type: "attachment", - attachment: attachment ?? null, - }); - }, - reportError: (error) => this.reportError(error), - }); - this.closed = new Promise((resolve, reject) => { - this.resolveClosed = resolve; - this.rejectClosed = reject; - }); - void this.closed.catch(() => {}); - } - - start(): Promise { - if (this.started) return Promise.reject(new Error("Server is already started")); - if (this.startPromise) return Promise.reject(new Error("Server is already starting")); - if (this.closing) return Promise.reject(new Error("Server is closing or closed")); - this.startPromise = this.startInternal(); - return this.startPromise; - } - - private async startInternal(): Promise { - const started: ServerListener[] = []; - try { - for (const listener of this.listeners) { - await listener.start((connection) => this.accept(connection)); - started.push(listener); - } - this.started = true; - return this; - } catch (error) { - this.closing = true; - const cleanupErrors: unknown[] = []; - const listenerResults = await Promise.allSettled(started.map((listener) => listener.close())); - for (const result of listenerResults) { - if (result.status === "rejected") cleanupErrors.push(result.reason); - } - try { - await this.closeServerState(); - } catch (cleanupError) { - cleanupErrors.push(cleanupError); - } - if (cleanupErrors.length > 0) { - const failure = new AggregateError([error, ...cleanupErrors], "Server startup and cleanup failed"); - this.settleClosed(failure); - throw failure; - } - this.settleClosed(); - throw error; - } finally { - this.startPromise = undefined; - } - } - - accept(connection: ByteConnection): ByteConnectionHandler { - if (this.closing) { - void this.closeConnection(connection); - return { - onData: () => {}, - onClose: () => {}, - onError: (error) => this.reportError(error), - }; - } - - let state: ConnectionState; - const handshakeTimeout = setTimeout(() => { - void this.failProtocol(state, { - code: "invalid_request", - message: "Handshake timeout", - }); - }, this.handshakeTimeoutMs); - handshakeTimeout.unref(); - state = { - connection, - decoder: new ClientMessageDecoder({ maxFrameLength: this.maxFrameLength }), - serviceStateEncoders: new Map(), - stage: "awaitingHello", - disconnected: false, - handshakeTimeout, - activeRequests: new Map(), - }; - this.connections.add(state); - this.notifyConnectionCountChanged(); - - return { - onData: (chunk) => this.receive(state, chunk), - onClose: () => this.transportClosed(state), - onError: (error) => { - this.reportError(error); - void this.closeConnection(connection).then(() => this.disconnect(state)); - }, - }; - } - - async close(): Promise { - if (this.closePromise) return this.closePromise; - this.closing = true; - this.closePromise = this.closeInternal(); - return this.closePromise; - } - - private async closeInternal(): Promise { - const starting = this.startPromise; - if (starting) await starting.catch(() => {}); - const errors: unknown[] = []; - const listenerResults = await Promise.allSettled(this.listeners.map((listener) => listener.close())); - for (const result of listenerResults) { - if (result.status === "rejected") errors.push(result.reason); - } - try { - await this.closeServerState(); - } catch (error) { - errors.push(error); - } - this.started = false; - if (errors.length > 0) { - const failure = - errors.length === 1 && errors[0] instanceof Error - ? errors[0] - : new AggregateError(errors, "Server shutdown failed"); - this.settleClosed(failure); - throw failure; - } - this.settleClosed(); - } - - private receive(state: ConnectionState, chunk: Uint8Array): void { - if (isTerminalConnection(state)) return; - let messages: ClientMessage[]; - try { - messages = state.decoder.push(chunk); - } catch (error) { - void this.failProtocol(state, this.toProtocolError(error)); - return; - } - for (const message of messages) { - if (isTerminalConnection(state)) return; - this.dispatchMessage(state, message); - } - } - - private dispatchMessage(state: ConnectionState, message: ClientMessage): void { - if (state.stage === "awaitingHello") { - if (message.type !== "hello") { - void this.failProtocol(state, { - code: "invalid_request", - message: "The first client message must be hello", - }); - return; - } - state.stage = "handshaking"; - state.handshake = this.finishHandshake(state, message).catch((error: unknown) => - this.failProtocol(state, this.toProtocolError(error)), - ); - return; - } - - if (message.type === "hello") { - void this.failProtocol(state, { - code: "invalid_request", - message: "hello may only be sent as the first message", - }); - return; - } - - if (state.stage === "ready") { - if (message.type === "cancel") this.handleCancel(state, message); - else void this.handleRequest(state, message); - return; - } - if (state.stage !== "handshaking") return; - const handshake = state.handshake; - if (!handshake) return; - void handshake.then(() => { - if (state.stage !== "ready" || state.disconnected) return; - if (message.type === "cancel") this.handleCancel(state, message); - else void this.handleRequest(state, message); - }); - } - - private async finishHandshake(state: ConnectionState, hello: ClientHello): Promise { - if (!isSupportedProtocolVersion(hello.version)) { - await this.failProtocol(state, { - code: "version", - message: `Unsupported protocol version ${hello.version}; expected ${PROTOCOL_VERSION}`, - }); - return; - } - - if (this.closing || state.disconnected || state.stage !== "handshaking" || state.connection.closed) return; - const services = await this.host.serverServices.attachClient( - { - attachSession: async (sessionId, context) => { - await this.sessions.attachClient(state, sessionId, context); - }, - detachSession: (context) => this.sessions.detachClient(state, context), - prepareSessionRemoval: (sessionId, context) => this.sessions.removeSession(sessionId, context), - }, - TODO_CONTEXT, - ); - if (this.closing || state.disconnected || state.stage !== "handshaking" || state.connection.closed) { - await services.release(TODO_CONTEXT); - return; - } - state.serverServices = services; - const sent = await this.sendMessage(state, { - type: "hello", - version: PROTOCOL_VERSION, - serverId: this.serverId, - } satisfies ServerHello); - if (sent && !state.disconnected && state.stage === "handshaking") { - state.stage = "ready"; - clearTimeout(state.handshakeTimeout); - } - } - - private handleCancel(state: ConnectionState, envelope: CancelEnvelope): void { - if (envelope.target.serverId !== this.serverId) return; - const active = state.activeRequests.get(envelope.id); - if (active !== undefined && sameTarget(active.target, envelope.target)) { - active.controller.abort(new DOMException("RPC request cancelled", "AbortError")); - } - } - - private async handleRequest(state: ConnectionState, envelope: RequestEnvelope): Promise { - if (state.activeRequests.has(envelope.id)) { - await this.sendMessage(state, { - type: "response", - id: envelope.id, - ok: false, - error: { code: "invalid_request", message: "Request ID is already active" }, - } satisfies ResponseEnvelope); - return; - } - let call: ServiceCall; - try { - call = parseServiceCall(envelope.call); - } catch { - await this.sendMessage(state, { - type: "response", - id: envelope.id, - ok: false, - error: { code: "invalid_request", message: "Invalid service call" }, - } satisfies ResponseEnvelope); - return; - } - const controller = new AbortController(); - const active = { controller, target: envelope.target }; - state.activeRequests.set(envelope.id, active); - const context = withAbortSignal(controller.signal, TODO_CONTEXT); - const control = decodeServiceControlCall(call); - const subscribing = control?.type === "subscribe" ? control : undefined; - const pendingUpdates: { readonly update: ServiceProviderUpdate }[] = []; - let subscriptionReady = subscribing === undefined; - let installedSubscriptionEncoder = false; - let responded = false; - const publish = async (subscriptionId: string, update: ServiceProviderUpdate): Promise => { - if (subscribing !== undefined && subscriptionId === subscribing.subscriptionId && !subscriptionReady) { - pendingUpdates.push({ update }); - return; - } - await this.sendServiceUpdate(state, subscriptionId, update); - }; - try { - if (envelope.target.serverId !== this.serverId) throw new WrongServerError(); - if (subscribing !== undefined && state.serviceStateEncoders.has(subscribing.subscriptionId)) { - throw new ProtocolValidationError(`Duplicate service subscription ${subscribing.subscriptionId}`); - } - let result: JsonValue | undefined; - if ("sessionId" in envelope.target) { - result = await this.sessions.executeServiceCall(call, envelope.target, state, publish, context); - } else if (state.serverServices !== undefined) { - result = await state.serverServices.invokeService(call, publish, context); - } else { - throw new ProtocolValidationError(`Unknown service member ${call.serviceId}.${call.member}`); - } - if (subscribing !== undefined) { - if (result === undefined) - throw new ProtocolValidationError("Service subscription did not return a snapshot"); - const stateEncoder = createServiceStateEncoder(); - result = stateEncoder.encodeSnapshot(parseServiceSubscriptionSnapshot(result)) as unknown as JsonValue; - state.serviceStateEncoders.set(subscribing.subscriptionId, stateEncoder); - installedSubscriptionEncoder = true; - } else if (control?.type === "unsubscribe") { - state.serviceStateEncoders.delete(control.subscriptionId); - } - await this.sendMessage( - state, - result === undefined - ? { type: "response", id: envelope.id, ok: true } - : { type: "response", id: envelope.id, ok: true, result }, - ); - responded = true; - if (subscribing !== undefined) { - while (pendingUpdates.length > 0) { - const pending = pendingUpdates.shift(); - if (pending !== undefined) - await this.sendServiceUpdate(state, subscribing.subscriptionId, pending.update); - } - subscriptionReady = true; - } - } catch (error) { - if (subscribing !== undefined && installedSubscriptionEncoder && !responded) { - state.serviceStateEncoders.delete(subscribing.subscriptionId); - } - if (responded) { - this.reportError(error); - await this.closeConnection(state.connection); - this.disconnect(state); - } else { - await this.sendMessage(state, { - type: "response", - id: envelope.id, - ok: false, - error: controller.signal.aborted - ? { code: "cancelled", message: "RPC request cancelled" } - : this.toProtocolError(error), - } satisfies ResponseEnvelope); - } - } finally { - if (state.activeRequests.get(envelope.id) === active) state.activeRequests.delete(envelope.id); - } - } - - private transportClosed(connection: ConnectionState): void { - if (!connection.disconnected && connection.stage !== "closing") { - try { - connection.decoder.end(); - } catch (error) { - this.reportError(error); - } - } - this.disconnect(connection); - } - - private disconnect(connection: ConnectionState): void { - if (connection.disconnected) return; - connection.disconnected = true; - connection.stage = "closed"; - clearTimeout(connection.handshakeTimeout); - for (const { controller } of connection.activeRequests.values()) { - controller.abort(new Error("Client disconnected")); - } - connection.activeRequests.clear(); - connection.serviceStateEncoders.clear(); - if (this.connections.delete(connection)) this.notifyConnectionCountChanged(); - const serverServices = connection.serverServices; - delete connection.serverServices; - void Promise.allSettled([ - this.sessions.disconnect(connection, TODO_CONTEXT), - serverServices?.release(TODO_CONTEXT), - ]).then((results) => { - for (const result of results) if (result.status === "rejected") this.reportError(result.reason); - }); - } - - private async sendServiceUpdate( - connection: ConnectionState, - subscriptionId: string, - update: ServiceProviderUpdate, - ): Promise { - const stateEncoder = connection.serviceStateEncoders.get(subscriptionId); - if (stateEncoder === undefined) return; - await this.sendMessage(connection, { - type: "service_update", - subscriptionId, - update: stateEncoder.encodeUpdate(update) as unknown as JsonValue, - }); - } - - private async sendMessage(connection: ConnectionState, message: ServerMessage): Promise { - if (connection.disconnected || connection.connection.closed) return false; - let frame: Uint8Array; - try { - frame = encodeServerMessage(message, { maxFrameLength: this.maxFrameLength }); - } catch (error) { - this.reportError(error); - await this.closeConnection(connection.connection); - this.disconnect(connection); - return false; - } - try { - await connection.connection.send(frame); - return true; - } catch (error) { - this.reportError(error); - await this.closeConnection(connection.connection); - this.disconnect(connection); - return false; - } - } - - private async failProtocol(connection: ConnectionState, error: ProtocolError): Promise { - if (connection.disconnected || connection.stage === "closing" || connection.stage === "closed") return; - connection.stage = "closing"; - clearTimeout(connection.handshakeTimeout); - const message: ServerHelloError = { type: "hello_error", error }; - let finalFrame: Uint8Array | undefined; - try { - finalFrame = encodeServerMessage(message, { maxFrameLength: this.maxFrameLength }); - } catch (encodeError) { - this.reportError(encodeError); - } - await this.closeConnection(connection.connection, finalFrame); - this.disconnect(connection); - } - - private async closeServerState(): Promise { - const connections = [...this.connections]; - for (const connection of connections) { - connection.stage = "closing"; - clearTimeout(connection.handshakeTimeout); - } - await Promise.all(connections.map((connection) => this.closeConnection(connection.connection))); - for (const connection of connections) this.disconnect(connection); - const cleanup = await Promise.allSettled([this.sessions.close(BACKGROUND_CONTEXT)]); - this.connections.clear(); - const errors = cleanup.flatMap((result) => (result.status === "rejected" ? [result.reason] : [])); - if (errors.length === 1) throw errors[0]; - if (errors.length > 1) throw new AggregateError(errors, "Failed to close server Sessions"); - } - - private async closeConnection(connection: ByteConnection, finalChunk?: Uint8Array): Promise { - try { - await connection.close(finalChunk); - } catch (error) { - this.reportError(error); - } - } - - private toProtocolError(error: unknown): ProtocolError { - if (error instanceof ServerError || error instanceof RemoteServiceError) { - return { code: error.code, message: error.message }; - } - if (error instanceof ProtocolValidationError) { - return { code: "invalid_request", message: error.message }; - } - this.reportError(error); - return { code: "internal_error", message: INTERNAL_SERVER_ERROR_MESSAGE }; - } - - private notifyConnectionCountChanged(): void { - try { - this.onConnectionCountChanged?.(this.connections.size); - } catch (error) { - this.reportError(error); - } - } - - private reportError(error: unknown): void { - try { - this.onError?.(error instanceof Error ? error : new Error(String(error))); - } catch { - // Error observers cannot affect server state. - } - } - - private settleClosed(error?: unknown): void { - if (this.closedSettled) return; - this.closedSettled = true; - if (error === undefined) this.resolveClosed(); - else this.rejectClosed(error); - } -} - -function sameTarget(left: RpcTarget, right: RpcTarget): boolean { - if (left.serverId !== right.serverId) return false; - if (!("sessionId" in left) || !("sessionId" in right)) { - return !("sessionId" in left) && !("sessionId" in right); - } - return left.sessionId === right.sessionId && left.attachmentId === right.attachmentId; -} - -function resolveOptions(options: ServerOptions): { - maxFrameLength: number; - handshakeTimeoutMs: number; -} { - if (!Array.isArray(options.listeners)) throw new TypeError("Server listeners must be an array"); - if (!isServerId(options.serverId)) { - throw new TypeError("serverId must be a canonical lowercase UUIDv4"); - } - const maxFrameLength = options.maxFrameLength ?? DEFAULT_MAX_FRAME_LENGTH; - if (!Number.isSafeInteger(maxFrameLength) || maxFrameLength <= 0 || maxFrameLength > MAX_UINT32) { - throw new TypeError(`Server maxFrameLength must be an integer between 1 and ${MAX_UINT32}`); - } - const handshakeTimeoutMs = options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS; - if ( - !Number.isSafeInteger(handshakeTimeoutMs) || - handshakeTimeoutMs <= 0 || - handshakeTimeoutMs > MAX_TIMER_DELAY_MS - ) { - throw new TypeError(`Server handshakeTimeoutMs must be an integer between 1 and ${MAX_TIMER_DELAY_MS}`); - } - return { maxFrameLength, handshakeTimeoutMs }; -} diff --git a/packages/server/src/session-router.ts b/packages/server/src/session-router.ts deleted file mode 100644 index 271692497..000000000 --- a/packages/server/src/session-router.ts +++ /dev/null @@ -1,312 +0,0 @@ -import { randomUUID } from "node:crypto"; -import type { Context, JsonValue, ServiceCall, ServiceProviderUpdate } from "@earendil-works/chord"; -import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; -import type { RpcTarget, SessionTarget } from "@earendil-works/pi-protocol"; -import { ServerDrainingError, SessionNotAttachedError } from "./errors.ts"; -import type { RoutedSessionAttachment, RoutedSessionHandle, ServerHost, SessionMetadata } from "./types.ts"; - -class SessionCleanupError extends AggregateError {} - -interface ClientAttachment { - readonly id: string; - readonly client: object; - readonly session: HostedSession; - readonly operations: Set>; - acquiring?: Promise; - lease?: RoutedSessionAttachment; - releasing?: Promise; -} - -interface HostedSession { - readonly id: string; - readonly handle: RoutedSessionHandle; - readonly attachments: Set; -} - -interface SessionRouterOptions { - host: ServerHost; - serverId: string; - isClosing: () => boolean; - publishAttachment(client: object, attachment: SessionTarget | undefined, context: Context): Promise; - reportError: (error: unknown) => void; -} - -export class SessionRouter { - private readonly options: SessionRouterOptions; - private readonly hostedSessions = new Map(); - private readonly openingSessions = new Map>(); - private readonly attachmentsByClient = new Map(); - private readonly disconnectedClients = new Set(); - private readonly clientOperations = new Map>(); - private closePromise?: Promise; - - constructor(options: SessionRouterOptions) { - this.options = options; - } - - async executeServiceCall( - call: ServiceCall, - target: RpcTarget, - client: object, - publish: (subscriptionId: string, update: ServiceProviderUpdate, context: Context) => Promise, - context: Context, - ): Promise { - const admitted = await this.runForClient(client, () => - this.startServiceCall(client, target, call, publish, context), - ); - return admitted.result; - } - - attachClient(client: object, sessionId: string, context: Context): Promise { - if (this.options.isClosing()) return Promise.reject(new ServerDrainingError()); - return this.runForClient(client, () => this.attachClientNow(client, sessionId, context)); - } - - detachClient(client: object, context: Context): Promise { - return this.runForClient(client, async () => { - const attachment = this.attachmentsByClient.get(client); - if (attachment) await this.releaseAttachment(attachment, context); - }); - } - - async removeSession(sessionId: string, context: Context): Promise { - if (this.options.isClosing()) throw new ServerDrainingError(); - const hosted = this.hostedSessions.get(sessionId); - if (hosted === undefined) return; - const errors: unknown[] = []; - const releases = await Promise.allSettled( - [...hosted.attachments].map((attachment) => this.releaseAttachment(attachment, context)), - ); - for (const result of releases) if (result.status === "rejected") errors.push(result.reason); - try { - await hosted.handle.close(context); - } catch (error) { - errors.push(error); - } - if (this.hostedSessions.get(sessionId) === hosted) this.hostedSessions.delete(sessionId); - if (errors.length === 1) throw errors[0]; - if (errors.length > 1) throw new AggregateError(errors, `Failed to close Session ${sessionId}`); - } - - async disconnect(client: object, context: Context): Promise { - this.disconnectedClients.add(client); - try { - await this.runForClient(client, async () => { - const attachment = this.attachmentsByClient.get(client); - if (attachment) await this.releaseAttachment(attachment, context, false); - }); - } finally { - this.disconnectedClients.delete(client); - } - } - - close(context: Context): Promise { - this.closePromise ??= this.closeInternal(context); - return this.closePromise; - } - - private async closeInternal(context: Context): Promise { - const operationPromises = [...this.clientOperations.values()]; - const openingPromises = [...this.openingSessions.values()]; - const [operationResults, openingResults] = await Promise.all([ - Promise.allSettled(operationPromises), - Promise.allSettled(openingPromises), - ]); - const closeErrors: unknown[] = []; - for (const result of [...operationResults, ...openingResults]) { - if (result.status !== "rejected") continue; - this.options.reportError(result.reason); - if (result.reason instanceof SessionCleanupError) closeErrors.push(result.reason); - } - const attachmentResults = await Promise.allSettled( - [...this.hostedSessions.values()].flatMap((session) => - [...session.attachments].map((attachment) => this.releaseAttachment(attachment, context)), - ), - ); - for (const result of attachmentResults) { - if (result.status === "rejected") closeErrors.push(result.reason); - } - const hosted = [...this.hostedSessions.values()]; - const closeResults = await Promise.allSettled(hosted.map(({ handle }) => handle.close(context))); - for (let index = 0; index < closeResults.length; index++) { - const result = closeResults[index]!; - const session = hosted[index]!; - if (result.status === "fulfilled") { - if (this.hostedSessions.get(session.id) === session) this.hostedSessions.delete(session.id); - continue; - } - this.options.reportError(result.reason); - closeErrors.push(result.reason); - } - this.attachmentsByClient.clear(); - this.clientOperations.clear(); - if (closeErrors.length > 0) throw new AggregateError(closeErrors, "Failed to close routed Sessions"); - } - - private runForClient(client: object, operation: () => Promise): Promise { - const previous = this.clientOperations.get(client) ?? Promise.resolve(); - const result = previous.catch(() => {}).then(operation); - const tail = result.then( - () => undefined, - () => undefined, - ); - this.clientOperations.set(client, tail); - void tail.finally(() => { - if (this.clientOperations.get(client) === tail) this.clientOperations.delete(client); - }); - return result; - } - - private async attachClientNow(client: object, sessionId: string, context: Context): Promise { - if (this.options.isClosing() || this.disconnectedClients.has(client)) throw new ServerDrainingError(); - const current = this.attachmentsByClient.get(client); - if (current?.session.id === sessionId) return; - const hosted = await this.acquire(sessionId, context); - if (this.options.isClosing() || this.disconnectedClients.has(client)) throw new ServerDrainingError(); - if (current) await this.releaseAttachment(current, context, false); - const attachment: ClientAttachment = { - id: randomUUID(), - client, - session: hosted, - operations: new Set(), - }; - hosted.attachments.add(attachment); - try { - const acquiring = Promise.resolve(hosted.handle.attachClient(context)); - attachment.acquiring = acquiring; - attachment.lease = await acquiring; - } catch (error) { - hosted.attachments.delete(attachment); - throw error; - } - if ( - this.hostedSessions.get(hosted.id) !== hosted || - !hosted.attachments.has(attachment) || - this.disconnectedClients.has(client) || - this.options.isClosing() - ) { - await this.releaseAttachment(attachment, context); - throw new ServerDrainingError(); - } - this.attachmentsByClient.set(client, attachment); - await this.options.publishAttachment( - client, - { serverId: this.options.serverId, sessionId, attachmentId: attachment.id }, - context, - ); - } - - private async startServiceCall( - client: object, - target: RpcTarget, - call: ServiceCall, - publish: (subscriptionId: string, update: ServiceProviderUpdate, context: Context) => Promise, - context: Context, - ): Promise<{ result: Promise }> { - const attachment = this.requireAttachment(client, target); - const result = attachment.lease!.invokeService( - call, - (subscriptionId, update, updateContext) => publish(subscriptionId, update, updateContext), - context, - ); - this.trackOperation(attachment, result); - return { result }; - } - - private trackOperation(attachment: ClientAttachment, result: Promise): void { - attachment.operations.add(result); - const remove = (): void => { - attachment.operations.delete(result); - }; - void result.then(remove, remove); - } - - private requireAttachment(client: object, target: RpcTarget): ClientAttachment { - if (this.options.isClosing() || this.disconnectedClients.has(client)) throw new ServerDrainingError(); - if (!("sessionId" in target)) throw new SessionNotAttachedError(); - const attachment = this.attachmentsByClient.get(client); - if (!attachment || attachment.session.id !== target.sessionId || attachment.id !== target.attachmentId) { - throw new SessionNotAttachedError(); - } - return attachment; - } - - private releaseAttachment(attachment: ClientAttachment, context: Context, publish = true): Promise { - attachment.releasing ??= (async () => { - const errors: unknown[] = []; - try { - await Promise.allSettled(attachment.operations); - try { - const lease = attachment.lease ?? (await attachment.acquiring); - if (lease) await lease.release(context); - } catch (error) { - errors.push(error); - } - if (errors.length === 1) throw errors[0]; - if (errors.length > 1) throw new AggregateError(errors, "Failed to release Session attachment"); - } finally { - await this.clearAttachment(attachment, context, publish); - } - })(); - return attachment.releasing; - } - - private async clearAttachment(attachment: ClientAttachment, context: Context, publish: boolean): Promise { - attachment.session.attachments.delete(attachment); - if (this.attachmentsByClient.get(attachment.client) === attachment) { - this.attachmentsByClient.delete(attachment.client); - if (publish) await this.options.publishAttachment(attachment.client, undefined, context); - } - } - - private async acquire(sessionId: string, context: Context): Promise { - const existing = this.hostedSessions.get(sessionId); - if (existing) return existing; - const opening = this.openingSessions.get(sessionId); - if (opening) return opening; - const pending = this.open(sessionId, context); - this.openingSessions.set(sessionId, pending); - try { - return await pending; - } finally { - if (this.openingSessions.get(sessionId) === pending) this.openingSessions.delete(sessionId); - } - } - - private async open(sessionId: string, context: Context): Promise { - const metadata = await this.options.host.resolveSession(sessionId, context); - const handle = await this.options.host.openSession(metadata, context); - if (this.options.isClosing()) { - try { - await handle.close(context); - } catch (error) { - this.options.reportError(error); - throw new SessionCleanupError( - [new ServerDrainingError(), error], - "Failed to close routed Session acquired while draining", - ); - } - throw new ServerDrainingError(); - } - const hosted: HostedSession = { id: metadata.id, handle, attachments: new Set() }; - this.hostedSessions.set(hosted.id, hosted); - if (handle.terminated) { - void handle.terminated.then( - (error) => this.invalidate(hosted, error), - (error: unknown) => this.invalidate(hosted, error instanceof Error ? error : new Error(String(error))), - ); - } - return hosted; - } - - private invalidate(hosted: HostedSession, error: Error | undefined): void { - if (this.hostedSessions.get(hosted.id) !== hosted) return; - this.hostedSessions.delete(hosted.id); - for (const attachment of hosted.attachments) { - void this.releaseAttachment(attachment, BACKGROUND_CONTEXT).catch((releaseError: unknown) => - this.options.reportError(releaseError), - ); - } - if (error) this.options.reportError(error); - } -} diff --git a/packages/server/src/testing/client.ts b/packages/server/src/testing/client.ts deleted file mode 100644 index 98b7d2cfe..000000000 --- a/packages/server/src/testing/client.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { once } from "node:events"; -import { createConnection, type Socket } from "node:net"; -import type { JsonValue, ServiceCall } from "@earendil-works/chord"; -import { - type ClientMessage, - encodeClientMessage, - PROTOCOL_VERSION, - type ResponseEnvelope, - type RpcTarget, - type ServerMessage, - ServerMessageDecoder, -} from "@earendil-works/pi-protocol"; -import { Deferred } from "./host.ts"; - -interface MessageWaiter { - predicate: (message: ServerMessage) => boolean; - resolve: (message: ServerMessage) => void; - reject: (error: Error) => void; -} - -export interface WireChannel { - send(chunk: Uint8Array): Promise; - sendFragmented(chunk: Uint8Array, splitAt: number): Promise; - close(): Promise; -} - -export class ProtocolTestClient { - readonly messages: ServerMessage[] = []; - private readonly channel: WireChannel; - private readonly decoder = new ServerMessageDecoder(); - private readonly waiters = new Set(); - private readonly closedDeferred = new Deferred(); - private requestSequence = 0; - private attachment: { sessionId: string; attachmentId: string } | undefined; - private closedValue = false; - - constructor(channel: WireChannel) { - this.channel = channel; - } - - get closed(): boolean { - return this.closedValue; - } - - hello(version: number = PROTOCOL_VERSION): Promise { - const response = this.next((message) => message.type === "hello" || message.type === "hello_error"); - void this.sendMessage({ type: "hello", version }); - return response; - } - - async requestService( - target: RpcTarget, - call: ServiceCall, - id = `request-${++this.requestSequence}`, - ): Promise { - const response = this.next( - (message): message is ResponseEnvelope => message.type === "response" && message.id === id, - ); - await this.sendMessage({ type: "request", id, target, call: call as unknown as JsonValue }); - return (await response) as ResponseEnvelope; - } - - attach(serverId: string, sessionId: string): Promise { - return this.requestService( - { serverId }, - { serviceId: "pi.session-management", member: "attach", args: [sessionId] }, - ); - } - - requestSessionService( - serverId: string, - sessionId: string, - call: ServiceCall, - id?: string, - ): Promise { - const attachment = this.attachment; - const target: RpcTarget = - attachment === undefined || attachment.sessionId !== sessionId - ? { serverId, sessionId, attachmentId: "missing-attachment" } - : { serverId, ...attachment }; - return this.requestService(target, call, id); - } - - sendMessage(message: ClientMessage): Promise { - return this.channel.send(encodeClientMessage(message)); - } - - sendBytes(chunk: Uint8Array): Promise { - return this.channel.send(chunk); - } - - sendFragmentedMessage(message: ClientMessage, splitAt: number): Promise { - return this.channel.sendFragmented(encodeClientMessage(message), splitAt); - } - - next(predicate: (message: ServerMessage) => boolean): Promise { - return this.nextFrom(0, predicate); - } - - nextFrom(index: number, predicate: (message: ServerMessage) => boolean): Promise { - const existing = this.messages.slice(index).find(predicate); - if (existing) return Promise.resolve(existing); - if (this.closedValue) return Promise.reject(new Error("Wire client is closed")); - return new Promise((resolve, reject) => this.waiters.add({ predicate, resolve, reject })); - } - - waitForClose(): Promise { - return this.closedValue ? Promise.resolve() : this.closedDeferred.promise; - } - - close(): Promise { - return this.channel.close(); - } - - receive(chunk: Uint8Array): void { - try { - for (const message of this.decoder.push(chunk)) { - if (message.type === "attachment") { - this.attachment = - message.attachment === null - ? undefined - : { - sessionId: message.attachment.sessionId, - attachmentId: message.attachment.attachmentId, - }; - } - this.messages.push(message); - for (const waiter of this.waiters) { - if (!waiter.predicate(message)) continue; - this.waiters.delete(waiter); - waiter.resolve(message); - } - } - } catch (error) { - this.fail(error instanceof Error ? error : new Error(String(error))); - } - } - - markClosed(): void { - if (this.closedValue) return; - this.closedValue = true; - this.closedDeferred.resolve(undefined); - this.fail(new Error("Wire connection closed")); - } - - fail(error: Error): void { - for (const waiter of this.waiters) waiter.reject(error); - this.waiters.clear(); - } -} - -export async function connectUnixTestClient(path: string): Promise { - const socket = createConnection(path); - await once(socket, "connect"); - const client = new ProtocolTestClient({ - send: (chunk) => writeSocket(socket, chunk), - async sendFragmented(chunk, splitAt) { - await writeSocket(socket, chunk.subarray(0, splitAt)); - await writeSocket(socket, chunk.subarray(splitAt)); - }, - async close() { - if (socket.destroyed) return; - const closed = once(socket, "close"); - socket.destroy(); - await closed; - }, - }); - socket.on("data", (chunk) => { - client.receive(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength)); - }); - socket.on("error", (error) => client.fail(error)); - socket.once("close", () => client.markClosed()); - return client; -} - -function writeSocket(socket: Socket, chunk: Uint8Array): Promise { - return new Promise((resolve, reject) => { - socket.write(chunk, (error) => { - if (error) reject(error); - else resolve(); - }); - }); -} diff --git a/packages/server/src/testing/host.ts b/packages/server/src/testing/host.ts deleted file mode 100644 index d56ae4dd6..000000000 --- a/packages/server/src/testing/host.ts +++ /dev/null @@ -1,204 +0,0 @@ -import type { Context, JsonValue, ServiceCall } from "@earendil-works/chord"; -import { SessionNotFoundError } from "../errors.ts"; -import type { RoutedServerServiceHost, RoutedSessionHandle, ServerHost, SessionMetadata } from "../types.ts"; - -export class Deferred { - readonly promise: Promise; - private resolvePromise!: (value: T) => void; - - constructor() { - this.promise = new Promise((resolve) => { - this.resolvePromise = resolve; - }); - } - - resolve(value: T): void { - this.resolvePromise(value); - } -} - -interface OpenGate { - entered: Deferred; - release: Deferred; -} - -export class TestHarness { - readonly metadata: SessionMetadata; - readonly closed = new Deferred(); - readonly #termination = new Deferred(); - readonly terminated = this.#termination.promise; - attachedClients = 0; - attachmentReleaseCount = 0; - closeCount = 0; - readonly serviceCalls: ServiceCall[] = []; - failAttachmentRelease?: Error; - failClose?: Error; - nextServiceError?: Error; - nextServiceResult: JsonValue | undefined = { ok: true }; - private nextCloseGate?: OpenGate; - private nextServiceGate?: OpenGate; - - constructor(metadata: SessionMetadata) { - this.metadata = metadata; - } - - attachClient(_context: Context): { - invokeService: TestHarness["invokeService"]; - release(context: Context): void; - } { - this.attachedClients += 1; - let released = false; - return { - invokeService: (call) => this.invokeService(call), - release: (_context) => { - if (released) return; - this.attachmentReleaseCount += 1; - if (this.failAttachmentRelease) throw this.failAttachmentRelease; - released = true; - this.attachedClients -= 1; - }, - }; - } - - async invokeService(call: ServiceCall): Promise { - this.serviceCalls.push(call); - if (this.nextServiceError) { - const error = this.nextServiceError; - this.nextServiceError = undefined; - throw error; - } - const gate = this.nextServiceGate; - if (gate) { - this.nextServiceGate = undefined; - gate.entered.resolve(undefined); - await gate.release.promise; - } - const result = this.nextServiceResult; - this.nextServiceResult = { ok: true }; - return result; - } - - async close(_context: Context): Promise { - this.closeCount += 1; - const gate = this.nextCloseGate; - if (gate) { - this.nextCloseGate = undefined; - gate.entered.resolve(undefined); - await gate.release.promise; - } - if (this.failClose) { - const error = this.failClose; - this.failClose = undefined; - throw error; - } - this.closed.resolve(undefined); - this.#termination.resolve(undefined); - } - - async terminate(error: Error): Promise { - this.#termination.resolve(error); - } - - gateNextClose(): OpenGate { - const gate = { entered: new Deferred(), release: new Deferred() }; - this.nextCloseGate = gate; - return gate; - } - - gateNextServiceCall(): OpenGate { - const gate = { entered: new Deferred(), release: new Deferred() }; - this.nextServiceGate = gate; - return gate; - } -} - -export function createTestServerServices(): RoutedServerServiceHost { - return { - attachClient(presentation) { - return { - async invokeService(call, _publish, context) { - if ( - call.instance === undefined && - call.serviceId === "pi.session-management" && - call.member === "attach" && - call.args.length === 1 && - typeof call.args[0] === "string" - ) { - await presentation.attachSession(call.args[0], context); - return null; - } - if ( - call.instance === undefined && - call.serviceId === "pi.session-management" && - call.member === "detach" && - call.args.length === 0 - ) { - await presentation.detachSession(context); - return null; - } - throw new Error(`Unsupported test server service ${call.serviceId}.${call.member}`); - }, - release() {}, - }; - }, - }; -} - -export class TestServerHost implements ServerHost { - readonly serverServices = createTestServerServices(); - readonly sessions = new Map(); - readonly harnesses = new Map(); - openSessionCount = 0; - nextOpenSessionError?: Error; - nextHarnessCloseError?: Error; - private nextOpenSessionGate?: OpenGate; - - async resolveSession(sessionId: string, _context: Context): Promise { - const metadata = this.sessions.get(sessionId); - if (!metadata) throw new SessionNotFoundError(`Unknown session: ${sessionId}`); - return metadata; - } - - async openSession(metadata: SessionMetadata, _context: Context): Promise { - this.openSessionCount += 1; - const gate = this.nextOpenSessionGate; - if (gate) { - this.nextOpenSessionGate = undefined; - gate.entered.resolve(undefined); - await gate.release.promise; - } - if (!this.sessions.has(metadata.id)) throw new SessionNotFoundError(`Unknown session: ${metadata.id}`); - if (this.nextOpenSessionError) { - const error = this.nextOpenSessionError; - this.nextOpenSessionError = undefined; - throw error; - } - const harness = new TestHarness(metadata); - if (this.nextHarnessCloseError) { - harness.failClose = this.nextHarnessCloseError; - this.nextHarnessCloseError = undefined; - } - const harnesses = this.harnesses.get(metadata.id) ?? []; - harnesses.push(harness); - this.harnesses.set(metadata.id, harnesses); - return harness; - } - - async seed(id = "session-1"): Promise { - const metadata: SessionMetadata = { id }; - this.sessions.set(id, metadata); - return metadata; - } - - gateNextOpenSession(): OpenGate { - const gate = { entered: new Deferred(), release: new Deferred() }; - this.nextOpenSessionGate = gate; - return gate; - } - - latestHarness(id: string): TestHarness { - const harnesses = this.harnesses.get(id); - if (!harnesses?.length) throw new Error(`No harness for ${id}`); - return harnesses.at(-1)!; - } -} diff --git a/packages/server/src/testing/index.ts b/packages/server/src/testing/index.ts deleted file mode 100644 index d348debb1..000000000 --- a/packages/server/src/testing/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type { WireChannel } from "./client.ts"; -export { connectUnixTestClient, ProtocolTestClient } from "./client.ts"; -export { createTestServerServices, Deferred, TestHarness, TestServerHost } from "./host.ts"; -export type { TestServer, TestServerOptions } from "./server.ts"; -export { createTestServer } from "./server.ts"; diff --git a/packages/server/src/testing/server.ts b/packages/server/src/testing/server.ts deleted file mode 100644 index 4254b5825..000000000 --- a/packages/server/src/testing/server.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Server } from "../server.ts"; -import type { ServerHost, ServerOptions } from "../types.ts"; -import { TestServerHost } from "./host.ts"; - -export interface TestServerOptions extends Omit { - host?: ServerHost; - serverId?: string; -} - -export interface TestServer { - server: Server; - host: ServerHost; -} - -/** Create an unstarted Server with deterministic defaults for transport conformance tests. */ -export function createTestServer(options: TestServerOptions): TestServer { - const host = options.host ?? new TestServerHost(); - return { - server: new Server(host, { - listeners: options.listeners, - maxFrameLength: options.maxFrameLength, - handshakeTimeoutMs: options.handshakeTimeoutMs, - serverId: options.serverId ?? "00000000-0000-4000-8000-000000000001", - onError: options.onError, - }), - host, - }; -} diff --git a/packages/server/src/transports/unix/address.ts b/packages/server/src/transports/unix/address.ts deleted file mode 100644 index 1dd1c5557..000000000 --- a/packages/server/src/transports/unix/address.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { join } from "node:path"; - -/** Derive the local Unix socket path for one logical server identity. */ -export function getUnixSocketPath(serverId: string, serverDirectory: string): string { - if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(serverId)) { - throw new TypeError("Unix serverId must be a canonical lowercase UUIDv4"); - } - return join(serverDirectory, `${serverId}.sock`); -} diff --git a/packages/server/src/transports/unix/index.ts b/packages/server/src/transports/unix/index.ts deleted file mode 100644 index 55920048b..000000000 --- a/packages/server/src/transports/unix/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { getUnixSocketPath } from "./address.ts"; -export { createUnixListener } from "./listener.ts"; -export { createUnixServer } from "./preset.ts"; -export type { UnixListenerOptions, UnixServerOptions } from "./types.ts"; diff --git a/packages/server/src/transports/unix/listener.ts b/packages/server/src/transports/unix/listener.ts deleted file mode 100644 index a0e8850bc..000000000 --- a/packages/server/src/transports/unix/listener.ts +++ /dev/null @@ -1,421 +0,0 @@ -import { createHash, randomUUID } from "node:crypto"; -import type { Stats } from "node:fs"; -import { chmod, link, lstat, mkdir, rename, unlink } from "node:fs/promises"; -import { createConnection, createServer, type Server, type Socket } from "node:net"; -import { dirname, join } from "node:path"; -import { DEFAULT_MAX_FRAME_LENGTH } from "@earendil-works/pi-protocol"; -import type { ByteConnection, ByteConnectionAcceptor } from "../../connection.ts"; -import type { ServerListener } from "../../listener.ts"; -import type { UnixListenerOptions } from "./types.ts"; - -const DEFAULT_SOCKET_MODE = 0o600; -const DEFAULT_GRACEFUL_CLOSE_TIMEOUT_MS = 5_000; -const MAX_UINT32 = 0xffff_ffff; -const MAX_TIMER_DELAY_MS = 2_147_483_647; -const SOCKET_PROBE_TIMEOUT_MS = 1_000; - -interface ResolvedUnixListenerOptions { - path: string; - mode: number; - gracefulCloseTimeoutMs: number; - maxPendingBytes: number; - onError?: (error: Error) => void; -} - -interface FileIdentity { - dev: number; - ino: number; -} -class UnixListener implements ServerListener { - private readonly options: ResolvedUnixListenerOptions; - private readonly path: string; - private readonly mode: number; - private readonly connections = new Set(); - private server?: Server; - private socketIdentity?: FileIdentity; - private ownedBindPath?: string; - private closing = false; - private closePromise?: Promise; - private accept?: ByteConnectionAcceptor; - - constructor(options: UnixListenerOptions) { - this.options = resolveUnixListenerOptions(options); - this.path = this.options.path; - this.mode = this.options.mode; - } - - async start(accept: ByteConnectionAcceptor): Promise { - if (this.server) throw new Error("Unix listener is already started"); - if (this.closing) throw new Error("Unix listener is closing or closed"); - this.accept = accept; - - const ownedBindPath = getOwnedBindPath(this.path); - await mkdir(dirname(this.path), { recursive: true, mode: 0o700 }); - await removeStaleSocket(this.path); - await removeStaleSocket(ownedBindPath); - this.ownedBindPath = ownedBindPath; - const server = createServer((socket) => this.acceptSocket(socket)); - server.on("error", (error) => this.reportError(error)); - this.server = server; - try { - await 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(ownedBindPath); - }); - const stats = await lstat(ownedBindPath); - if (!stats.isSocket()) throw new Error(`Unix listener path is not a socket after binding: ${ownedBindPath}`); - this.socketIdentity = { dev: stats.dev, ino: stats.ino }; - await link(ownedBindPath, this.path); - await setSocketMode(this.path, this.mode); - await removePath(ownedBindPath); - this.ownedBindPath = undefined; - } catch (error) { - await this.closeServerAndCleanup(server); - this.server = undefined; - throw error; - } - } - - async close(): Promise { - if (this.closePromise) return this.closePromise; - this.closing = true; - this.closePromise = this.closeInternal(); - return this.closePromise; - } - - private acceptSocket(socket: Socket): void { - if (this.closing) { - socket.destroy(); - return; - } - const connection = new UnixByteConnection( - socket, - this.options.gracefulCloseTimeoutMs, - this.options.maxPendingBytes, - ); - this.connections.add(connection); - const accept = this.accept; - if (!accept) { - socket.destroy(); - return; - } - const handler = accept(connection); - socket.on("data", (chunk) => { - handler.onData(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength)); - }); - socket.on("error", (error) => { - handler.onError(error); - socket.destroy(); - }); - socket.once("close", () => { - connection.markClosed(); - this.connections.delete(connection); - handler.onClose(); - }); - } - - private async closeInternal(): Promise { - const serverClosed = this.server ? this.closeServerAndCleanup(this.server) : this.cleanupOwnedSocket(); - await Promise.all([...this.connections].map((connection) => connection.close())); - await serverClosed; - if (this.ownedBindPath) await removePath(this.ownedBindPath); - this.ownedBindPath = undefined; - this.connections.clear(); - this.server = undefined; - } - - private async closeServerAndCleanup(server: Server): Promise { - try { - await closeNetServer(server, (error) => this.reportError(error)); - } finally { - // Remove an unpublished startup bind path before the public route. - if (this.ownedBindPath) await removePath(this.ownedBindPath); - this.ownedBindPath = undefined; - await this.cleanupOwnedSocket(); - } - } - - private async cleanupOwnedSocket(): Promise { - const identity = this.socketIdentity; - this.socketIdentity = undefined; - if (!identity) return; - let current: Stats; - try { - current = await lstat(this.path); - } catch (error) { - if (isErrorCode(error, "ENOENT")) return; - throw error; - } - if (!current.isSocket() || current.dev !== identity.dev || current.ino !== identity.ino) return; - - const preserved = join(dirname(this.path), `cleanup-${randomUUID().slice(0, 6)}`); - try { - await rename(this.path, preserved); - } catch (error) { - if (isErrorCode(error, "ENOENT")) return; - throw error; - } - const moved = await lstat(preserved); - if (moved.isSocket() && moved.dev === identity.dev && moved.ino === identity.ino) { - await removePath(preserved); - return; - } - try { - await lstat(this.path); - } catch (error) { - if (isErrorCode(error, "ENOENT")) await rename(preserved, this.path); - else throw error; - } - throw new Error(`Unix listener path changed during cleanup; preserved replacement at ${preserved}`); - } - - private reportError(error: unknown): void { - try { - this.options.onError?.(error instanceof Error ? error : new Error(String(error))); - } catch { - // Error observers cannot affect listener state. - } - } -} - -/** @internal Exported only for transport-level verification. */ -export class UnixByteConnection implements ByteConnection { - private readonly socket: Socket; - private readonly gracefulCloseTimeoutMs: number; - private readonly maxPendingBytes: number; - private pendingBytes = 0; - private closedValue = false; - private closing = false; - private writeTail: Promise = Promise.resolve(); - private closePromise?: Promise; - private resolveClose?: () => void; - - constructor(socket: Socket, gracefulCloseTimeoutMs: number, maxPendingBytes: number) { - this.socket = socket; - this.gracefulCloseTimeoutMs = gracefulCloseTimeoutMs; - this.maxPendingBytes = maxPendingBytes; - } - - get closed(): boolean { - return this.closedValue; - } - - send(chunk: Uint8Array): Promise { - if (!(chunk instanceof Uint8Array)) { - return Promise.reject(new TypeError("Unix connection chunks must be Uint8Array")); - } - if (this.closedValue || this.closing) return Promise.reject(new Error("Unix connection is closed")); - if (this.pendingBytes + chunk.byteLength > this.maxPendingBytes) { - return Promise.reject(new Error("Unix connection 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(finalChunk?: Uint8Array): Promise { - if (this.closedValue || this.socket.destroyed) { - this.markClosed(); - return Promise.resolve(); - } - if (this.closePromise) return this.closePromise; - this.closing = true; - const finalBytes = finalChunk?.slice(); - this.closePromise = new Promise((resolve) => { - this.resolveClose = resolve; - const timer = setTimeout(() => { - if (!this.socket.destroyed) this.socket.destroy(); - this.markClosed(); - }, this.gracefulCloseTimeoutMs); - timer.unref(); - this.socket.once("close", () => clearTimeout(timer)); - void this.writeTail.then(() => { - if (this.socket.destroyed) { - this.markClosed(); - return; - } - try { - if (finalBytes) this.socket.end(finalBytes); - else this.socket.end(); - } catch { - this.socket.destroy(); - } - }); - }); - return this.closePromise; - } - - markClosed(): void { - if (this.closedValue) return; - this.closedValue = true; - this.closing = true; - this.resolveClose?.(); - this.resolveClose = undefined; - } - - private write(chunk: Uint8Array): Promise { - if (this.closedValue || this.closing || !this.socket.writable) { - return Promise.reject(new Error("Unix connection is closed")); - } - return new Promise((resolve, reject) => { - let settled = false; - const onClose = (): void => finish(new Error("Unix connection closed during write")); - const finish = (error?: Error | null): void => { - if (settled) return; - settled = true; - this.socket.off("close", onClose); - if (error) reject(error); - else resolve(); - }; - this.socket.once("close", onClose); - try { - this.socket.write(chunk, finish); - } catch (error) { - finish(error instanceof Error ? error : new Error(String(error))); - } - }); - } -} - -function getOwnedBindPath(path: string): string { - const suffix = createHash("sha256").update(path).digest("hex").slice(0, 8); - return join(dirname(path), `bind-${suffix}`); -} - -async function removeStaleSocket(path: string): Promise { - let original: Stats; - try { - original = await lstat(path); - } catch (error) { - if (isErrorCode(error, "ENOENT")) return; - throw error; - } - if (!original.isSocket()) throw new Error(`Refusing to remove non-socket Unix listener path: ${path}`); - if (await isSocketLive(path)) throw new Error(`Unix listener is already running: ${path}`); - - const preserved = join(dirname(path), `stale-${randomUUID().slice(0, 6)}`); - try { - await rename(path, preserved); - } catch (error) { - if (isErrorCode(error, "ENOENT")) return; - throw error; - } - const current = await lstat(preserved); - if (!current.isSocket() || current.dev !== original.dev || current.ino !== original.ino) { - try { - await lstat(path); - } catch (error) { - if (isErrorCode(error, "ENOENT")) await rename(preserved, path); - else throw error; - } - throw new Error(`Unix listener path changed while checking for a stale socket: ${path}`); - } - await removePath(preserved); -} - -async function removePath(path: string): Promise { - try { - await unlink(path); - } catch (error) { - if (!isErrorCode(error, "ENOENT")) throw error; - } -} - -function isSocketLive(path: string): Promise { - return new Promise((resolve, reject) => { - const socket = createConnection(path); - let settled = false; - let timer: NodeJS.Timeout | undefined; - const finish = (result: boolean, error?: Error): void => { - if (settled) return; - settled = true; - if (timer) clearTimeout(timer); - socket.removeAllListeners(); - socket.destroy(); - if (error) reject(error); - else resolve(result); - }; - socket.once("connect", () => finish(true)); - socket.once("error", (error: NodeJS.ErrnoException) => { - if (["ECONNREFUSED", "ENOENT", "EPIPE", "ECONNRESET"].includes(error.code ?? "")) { - finish(false); - return; - } - finish(false, error); - }); - timer = setTimeout(() => finish(true), SOCKET_PROBE_TIMEOUT_MS); - timer.unref(); - }); -} - -async function setSocketMode(path: string, mode: number): Promise { - if (process.platform === "win32") return; - try { - await chmod(path, mode); - } catch (error) { - if (!isErrorCode(error, "ENOSYS") && !isErrorCode(error, "ENOTSUP")) throw error; - } -} - -function closeNetServer(server: Server, reportError: (error: Error) => void): Promise { - if (!server.listening) return Promise.resolve(); - return new Promise((resolve) => { - server.close((error) => { - if (error) reportError(error); - resolve(); - }); - }); -} - -function isErrorCode(error: unknown, code: string): boolean { - return error instanceof Error && "code" in error && error.code === code; -} - -export function createUnixListener(options: UnixListenerOptions): ServerListener { - return new UnixListener(options); -} - -function resolveUnixListenerOptions(options: UnixListenerOptions): ResolvedUnixListenerOptions { - if (!options.path) throw new TypeError("Server Unix socket path must not be empty"); - const mode = options.mode ?? DEFAULT_SOCKET_MODE; - if (!Number.isInteger(mode) || mode < 0 || mode > 0o777) { - throw new TypeError("Server Unix socket mode must be an integer between 0 and 0o777"); - } - const maxFrameLength = options.maxFrameLength ?? DEFAULT_MAX_FRAME_LENGTH; - if (!Number.isSafeInteger(maxFrameLength) || maxFrameLength <= 0 || maxFrameLength > MAX_UINT32) { - throw new TypeError(`Server maxFrameLength must be an integer between 1 and ${MAX_UINT32}`); - } - const maxPendingBytes = options.maxPendingBytes ?? maxFrameLength * 4; - if (!Number.isSafeInteger(maxPendingBytes) || maxPendingBytes < maxFrameLength + 4) { - throw new TypeError("Server maxPendingBytes must be a safe integer at least maxFrameLength + 4"); - } - const gracefulCloseTimeoutMs = options.gracefulCloseTimeoutMs ?? DEFAULT_GRACEFUL_CLOSE_TIMEOUT_MS; - if ( - !Number.isSafeInteger(gracefulCloseTimeoutMs) || - gracefulCloseTimeoutMs <= 0 || - gracefulCloseTimeoutMs > MAX_TIMER_DELAY_MS - ) { - throw new TypeError(`Server gracefulCloseTimeoutMs must be an integer between 1 and ${MAX_TIMER_DELAY_MS}`); - } - return { - path: options.path, - mode, - maxPendingBytes, - gracefulCloseTimeoutMs, - onError: options.onError, - }; -} diff --git a/packages/server/src/transports/unix/preset.ts b/packages/server/src/transports/unix/preset.ts deleted file mode 100644 index 11afffaf6..000000000 --- a/packages/server/src/transports/unix/preset.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Server } from "../../server.ts"; -import type { ServerHost, SessionMetadata } from "../../types.ts"; -import { createUnixListener } from "./listener.ts"; -import type { UnixServerOptions } from "./types.ts"; - -/** Compose Server with one Unix-domain socket listener. */ -export function createUnixServer( - host: ServerHost, - options: UnixServerOptions, -): Server { - const listener = createUnixListener({ - path: options.path, - mode: options.mode, - maxFrameLength: options.maxFrameLength, - maxPendingBytes: options.maxPendingBytes, - gracefulCloseTimeoutMs: options.gracefulCloseTimeoutMs, - onError: options.onError, - }); - return new Server(host, { - listeners: [listener], - maxFrameLength: options.maxFrameLength, - handshakeTimeoutMs: options.handshakeTimeoutMs, - onConnectionCountChanged: options.onConnectionCountChanged, - serverId: options.serverId, - onError: options.onError, - }); -} diff --git a/packages/server/src/transports/unix/types.ts b/packages/server/src/transports/unix/types.ts deleted file mode 100644 index 2cf1aae68..000000000 --- a/packages/server/src/transports/unix/types.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { ServerOptions } from "../../types.ts"; - -export interface UnixListenerOptions { - path: string; - /** Socket filesystem permissions. Defaults to owner read/write only (0o600). */ - mode?: number; - /** Maximum framed bytes queued per connection before a slow peer is disconnected. */ - maxPendingBytes?: number; - gracefulCloseTimeoutMs?: number; - /** Used to derive and validate maxPendingBytes. Must match the server when customized. */ - maxFrameLength?: number; - onError?: (error: Error) => void; -} - -export interface UnixServerOptions extends Omit, UnixListenerOptions {} diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts deleted file mode 100644 index e2f6da431..000000000 --- a/packages/server/src/types.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { Context, JsonValue, ServiceCall, ServiceProviderUpdate } from "@earendil-works/chord"; -import type { ServerListener } from "./listener.ts"; - -export interface ServerOptions { - listeners: readonly ServerListener[]; - /** Stable logical server identity supplied by the installation or profile. */ - serverId: string; - maxFrameLength?: number; - handshakeTimeoutMs?: number; - onConnectionCountChanged?: (count: number) => void; - onError?: (error: Error) => void; -} - -export type MaybePromise = T | Promise; - -/** Minimal durable Session metadata the server needs for routing. Applications may extend it. */ -export interface SessionMetadata { - readonly id: string; -} - -/** One presentation connection's live capability for a hosted Session. */ -export interface RoutedSessionAttachment { - /** Route one contract-agnostic service operation to the attached Session endpoint. */ - invokeService( - call: ServiceCall, - publish: (subscriptionId: string, update: ServiceProviderUpdate, context: Context) => MaybePromise, - context: Context, - ): Promise; - release(context: Context): MaybePromise; -} - -/** Presentation-scoped routing capabilities available to server service implementations. */ -export interface RoutedServerPresentation { - attachSession(sessionId: string, context: Context): Promise; - detachSession(context: Context): Promise; - /** Release routed attachments and handles before the application deletes durable metadata. */ - prepareSessionRemoval(sessionId: string, context: Context): Promise; -} - -/** One connection's server-scoped service endpoint. */ -export interface RoutedServerServiceAttachment { - invokeService( - call: ServiceCall, - publish: (subscriptionId: string, update: ServiceProviderUpdate, context: Context) => MaybePromise, - context: Context, - ): Promise; - release(context: Context): MaybePromise; -} - -export interface RoutedServerServiceHost { - attachClient(presentation: RoutedServerPresentation, context: Context): MaybePromise; -} - -/** A process-safe handle that acquires presentation-scoped Session capabilities. */ -export interface RoutedSessionHandle { - attachClient(context: Context): MaybePromise; - /** Resolves with an error for unexpected termination, or undefined after an expected close. */ - readonly terminated?: Promise; - close(context: Context): Promise; -} - -/** Application capabilities used by server-wide management and Session routing. */ -export interface ServerHost { - readonly serverServices: RoutedServerServiceHost; - /** Resolve one durable Session ID or throw a bounded routing error. */ - resolveSession(sessionId: string, context: Context): Promise; - openSession(metadata: TMetadata, context: Context): Promise; -} diff --git a/packages/server/test/conformance.test.ts b/packages/server/test/conformance.test.ts deleted file mode 100644 index da73b21d7..000000000 --- a/packages/server/test/conformance.test.ts +++ /dev/null @@ -1,499 +0,0 @@ -import type { ServiceCall } from "@earendil-works/chord"; -import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; -import { afterEach, describe, expect, test } from "vitest"; -import type { ByteConnection, ByteConnectionHandler } from "../src/connection.ts"; -import { SessionAmbiguousError } from "../src/errors.ts"; -import { Server } from "../src/server.ts"; -import { - createTestServerServices, - Deferred, - ProtocolTestClient, - TestServerHost, - type WireChannel, -} from "../src/testing/index.ts"; -import type { ServerHost, SessionMetadata } from "../src/types.ts"; - -const serverId = "00000000-0000-4000-8000-000000000001"; -const servers = new Set(); - -function createServer(host: ServerHost, id = serverId): Server { - const server = new Server(host, { listeners: [], serverId: id }); - servers.add(server); - return server; -} - -function connect(server: Server): ProtocolTestClient { - let handler: ByteConnectionHandler; - let client: ProtocolTestClient; - let closed = false; - const connection: ByteConnection = { - get closed() { - return closed; - }, - async send(chunk) { - client.receive(chunk); - }, - close(finalChunk) { - if (finalChunk) client.receive(finalChunk); - closed = true; - client.markClosed(); - }, - }; - const channel: WireChannel = { - async send(chunk) { - handler.onData(chunk); - }, - async sendFragmented(chunk, splitAt) { - handler.onData(chunk.subarray(0, splitAt)); - handler.onData(chunk.subarray(splitAt)); - }, - async close() { - if (closed) return; - closed = true; - handler.onClose(); - client.markClosed(); - }, - }; - client = new ProtocolTestClient(channel); - handler = server.accept(connection); - return client; -} - -function sessionCall(member: string, args: ServiceCall["args"] = []): ServiceCall { - return { serviceId: "test.session", member, args }; -} - -function latestAttachmentId(client: ProtocolTestClient, sessionId: string): string { - for (const message of [...client.messages].reverse()) { - if (message.type === "attachment" && message.attachment?.sessionId === sessionId) { - return message.attachment.attachmentId; - } - } - throw new Error(`Missing attachment for ${sessionId}`); -} - -afterEach(async () => { - await Promise.all([...servers].map((server) => server.close())); - servers.clear(); -}); - -describe("Session protocol", () => { - test("handshake identifies the logical server without listing sessions", async () => { - const host = new TestServerHost(); - await host.seed(); - const client = connect(createServer(host)); - - expect(await client.hello()).toMatchObject({ type: "hello", serverId }); - expect(host.harnesses.size).toBe(0); - }); - - test("rejects a semantically invalid service call after envelope decoding", async () => { - const host = new TestServerHost(); - const client = connect(createServer(host)); - await client.hello(); - const response = client.next((message) => message.type === "response" && message.id === "invalid-call"); - await client.sendMessage({ - type: "request", - id: "invalid-call", - target: { serverId }, - call: { arbitrary: true }, - }); - expect(await response).toMatchObject({ ok: false, error: { code: "invalid_request" } }); - }); - - test("attach passes concrete repository metadata to the Harness host", async () => { - type BackendMetadata = SessionMetadata & { path: string; modifiedAt: number }; - const metadata: BackendMetadata = { - id: "session-1", - path: "/sessions/session-1.jsonl", - modifiedAt: 2, - }; - let received: BackendMetadata | undefined; - const host: ServerHost = { - serverServices: createTestServerServices(), - resolveSession: async () => metadata, - openSession: async (candidate) => { - received = candidate; - return { - attachClient: () => ({ invokeService: async () => undefined, release() {} }), - close: async () => {}, - }; - }, - }; - const client = connect(createServer(host)); - await client.hello(); - - await expect(client.attach(serverId, "session-1")).resolves.toMatchObject({ ok: true }); - expect(received).toBe(metadata); - }); - - test("routes opaque server services and publishes attachment changes out of band", async () => { - const backing = new TestServerHost(); - await backing.seed("session-1"); - let releaseCount = 0; - const host: ServerHost = { - resolveSession: (sessionId, context) => backing.resolveSession(sessionId, context), - openSession: (metadata, context) => backing.openSession(metadata, context), - serverServices: { - attachClient(presentation) { - return { - async invokeService(call, _publish, context) { - if (call.serviceId !== "pi.session-management") throw new Error("Unexpected service"); - if (call.member === "attach" && typeof call.args[0] === "string") { - await presentation.attachSession(call.args[0], context); - return null; - } - if (call.member === "detach") { - await presentation.detachSession(context); - return null; - } - throw new Error("Unexpected service member"); - }, - release() { - releaseCount += 1; - }, - }; - }, - }, - }; - const client = connect(createServer(host)); - await client.hello(); - const attached = client.next((message) => message.type === "attachment" && message.attachment !== null); - await expect(client.attach(serverId, "session-1")).resolves.toMatchObject({ ok: true }); - await expect(attached).resolves.toMatchObject({ - type: "attachment", - attachment: { sessionId: "session-1", attachmentId: expect.any(String) }, - }); - expect(backing.latestHarness("session-1").attachedClients).toBe(1); - - const detached = client.next((message) => message.type === "attachment" && message.attachment === null); - await expect( - client.requestService({ serverId }, { serviceId: "pi.session-management", member: "detach", args: [] }), - ).resolves.toMatchObject({ ok: true }); - await expect(detached).resolves.toMatchObject({ type: "attachment", attachment: null }); - expect(backing.latestHarness("session-1").attachedClients).toBe(0); - await client.close(); - await expect.poll(() => releaseCount).toBe(1); - }); - - test("permits multiple client attachments per Session", async () => { - const host = new TestServerHost(); - await host.seed("session-1"); - const server = createServer(host); - const first = connect(server); - const second = connect(server); - await Promise.all([first.hello(), second.hello()]); - - await expect(first.attach(serverId, "session-1")).resolves.toMatchObject({ ok: true }); - await expect(first.attach(serverId, "session-1")).resolves.toMatchObject({ ok: true }); - expect(host.latestHarness("session-1").attachedClients).toBe(1); - await expect(second.attach(serverId, "session-1")).resolves.toMatchObject({ ok: true }); - expect(host.harnesses.get("session-1")).toHaveLength(1); - expect(host.latestHarness("session-1").attachedClients).toBe(2); - - await first.close(); - await expect.poll(() => host.latestHarness("session-1").attachedClients).toBe(1); - await expect(second.attach(serverId, "session-1")).resolves.toMatchObject({ ok: true }); - }); - - test("clears connection ownership when attachment release fails", async () => { - const host = new TestServerHost(); - await host.seed("session-1"); - const errors: Error[] = []; - const server = new Server(host, { listeners: [], serverId, onError: (error) => errors.push(error) }); - servers.add(server); - const first = connect(server); - const second = connect(server); - await Promise.all([first.hello(), second.hello()]); - await first.attach(serverId, "session-1"); - const harness = host.latestHarness("session-1"); - const releaseError = new Error("release failed"); - harness.failAttachmentRelease = releaseError; - - await first.close(); - await expect.poll(() => harness.attachmentReleaseCount).toBe(1); - await expect.poll(() => errors).toContain(releaseError); - harness.failAttachmentRelease = undefined; - await expect(second.attach(serverId, "session-1")).resolves.toMatchObject({ ok: true }); - }); - - test("requires the requesting client to hold the targeted Session attachment", async () => { - const host = new TestServerHost(); - await Promise.all([host.seed("session-1"), host.seed("session-2")]); - const server = createServer(host); - const attached = connect(server); - const unattached = connect(server); - await Promise.all([attached.hello(), unattached.hello()]); - - await expect(unattached.requestSessionService(serverId, "session-1", sessionCall("run"))).resolves.toMatchObject({ - ok: false, - error: { code: "session_not_attached" }, - }); - await attached.attach(serverId, "session-1"); - await expect(attached.requestSessionService(serverId, "session-2", sessionCall("run"))).resolves.toMatchObject({ - ok: false, - error: { code: "session_not_attached" }, - }); - await expect( - attached.requestSessionService(serverId, "session-1", sessionCall("run", ["Hello"])), - ).resolves.toMatchObject({ ok: true, result: { ok: true } }); - expect(host.latestHarness("session-1").serviceCalls).toEqual([sessionCall("run", ["Hello"])]); - }); - - test("rejects a stale attachment route after switching Sessions", async () => { - const host = new TestServerHost(); - await Promise.all([host.seed("session-1"), host.seed("session-2")]); - const client = connect(createServer(host)); - await client.hello(); - await client.attach(serverId, "session-1"); - const firstAttachmentId = latestAttachmentId(client, "session-1"); - await client.attach(serverId, "session-2"); - - await expect( - client.requestService( - { serverId, sessionId: "session-1", attachmentId: firstAttachmentId }, - sessionCall("run", ["stale"]), - ), - ).resolves.toMatchObject({ ok: false, error: { code: "session_not_attached" } }); - expect(host.latestHarness("session-1").serviceCalls).toEqual([]); - }); - - test("preserves opaque service results and bounds adapter defects", async () => { - const host = new TestServerHost(); - await host.seed("session-1"); - const client = connect(createServer(host)); - await client.hello(); - await client.attach(serverId, "session-1"); - const harness = host.latestHarness("session-1"); - harness.nextServiceResult = { accepted: false, reason: "closed" }; - await expect(client.requestSessionService(serverId, "session-1", sessionCall("run"))).resolves.toMatchObject({ - ok: true, - result: { accepted: false, reason: "closed" }, - }); - - harness.nextServiceError = new Error("private adapter detail"); - await expect(client.requestSessionService(serverId, "session-1", sessionCall("run"))).resolves.toMatchObject({ - ok: false, - error: { code: "internal_error", message: "Internal server error" }, - }); - }); - - test("admits concurrent service calls to the attached Session", async () => { - const host = new TestServerHost(); - await host.seed("session-1"); - const client = connect(createServer(host)); - await client.hello(); - await client.attach(serverId, "session-1"); - const harness = host.latestHarness("session-1"); - const gate = harness.gateNextServiceCall(); - const first = client.requestSessionService(serverId, "session-1", sessionCall("run", ["first"])); - await gate.entered.promise; - const second = client.requestSessionService(serverId, "session-1", sessionCall("run", ["second"])); - - await expect(second).resolves.toMatchObject({ ok: true }); - expect(harness.serviceCalls).toEqual([sessionCall("run", ["first"]), sessionCall("run", ["second"])]); - gate.release.resolve(undefined); - await expect(first).resolves.toMatchObject({ ok: true }); - }); - - test("keeps attachment demand until an accepted service call settles after disconnect", async () => { - const host = new TestServerHost(); - await host.seed("session-1"); - const client = connect(createServer(host)); - await client.hello(); - await client.attach(serverId, "session-1"); - const harness = host.latestHarness("session-1"); - const gate = harness.gateNextServiceCall(); - const calling = client.requestSessionService(serverId, "session-1", sessionCall("run")); - const disconnectedCall = expect(calling).rejects.toThrow(/closed/i); - await gate.entered.promise; - - await client.close(); - expect(harness.attachedClients).toBe(1); - gate.release.resolve(undefined); - await disconnectedCall; - await expect.poll(() => harness.attachedClients).toBe(0); - }); - - test("rejects requests addressed to another server before repository access", async () => { - const host = new TestServerHost(); - await host.seed("session-1"); - const client = connect(createServer(host)); - await client.hello(); - - await expect(client.attach("00000000-0000-4000-8000-000000000002", "session-1")).resolves.toMatchObject({ - ok: false, - error: { code: "wrong_server" }, - }); - expect(host.harnesses.size).toBe(0); - }); - - test("reports an unknown session without creating a Harness", async () => { - const host = new TestServerHost(); - const client = connect(createServer(host)); - await client.hello(); - - await expect(client.attach(serverId, "missing")).resolves.toMatchObject({ - ok: false, - error: { code: "session_not_found" }, - }); - expect(host.harnesses.size).toBe(0); - }); - - test("rejects an ambiguous session ID without creating a Harness", async () => { - type BackendMetadata = SessionMetadata & { path: string }; - const host: ServerHost = { - serverServices: createTestServerServices(), - resolveSession: async () => { - throw new SessionAmbiguousError(); - }, - openSession: async () => { - throw new Error("must not create a Harness for an ambiguous session"); - }, - }; - const client = connect(createServer(host)); - await client.hello(); - - await expect(client.attach(serverId, "duplicate")).resolves.toMatchObject({ - ok: false, - error: { code: "session_ambiguous" }, - }); - }); - - test("invalidates a terminated Harness handle and allows a later attach", async () => { - const host = new TestServerHost(); - await host.seed("session-1"); - const client = connect(createServer(host)); - await client.hello(); - await client.attach(serverId, "session-1"); - const firstHarness = host.latestHarness("session-1"); - - await firstHarness.terminate(new Error("worker crashed")); - await firstHarness.terminated; - await expect.poll(() => firstHarness.attachedClients).toBe(0); - expect(firstHarness.attachmentReleaseCount).toBe(1); - - await expect(client.attach(serverId, "session-1")).resolves.toMatchObject({ ok: true }); - expect(host.harnesses.get("session-1")).toHaveLength(2); - }); - - test("connection loss releases its attachment, while server shutdown closes the Harness", async () => { - const host = new TestServerHost(); - await host.seed("session-1"); - const server = createServer(host); - const client = connect(server); - await client.hello(); - await client.attach(serverId, "session-1"); - const harness = host.latestHarness("session-1"); - - await client.close(); - await expect.poll(() => harness.attachedClients).toBe(0); - expect(harness.closeCount).toBe(0); - await server.close(); - expect(harness.closeCount).toBe(1); - }); -}); - -describe("routed Session acquisition failures", () => { - test("releases a lease acquired concurrently with Harness termination", async () => { - const metadata: SessionMetadata = { id: "session-1" }; - const acquiring = new Deferred(); - const continueAcquiring = new Deferred(); - const terminated = new Deferred(); - let releaseCount = 0; - const host: ServerHost = { - serverServices: createTestServerServices(), - resolveSession: async () => metadata, - openSession: async () => ({ - terminated: terminated.promise, - attachClient: async () => { - acquiring.resolve(undefined); - await continueAcquiring.promise; - return { - invokeService: async () => undefined, - release: () => { - releaseCount += 1; - }, - }; - }, - close: async () => {}, - }), - }; - const client = connect(createServer(host)); - await client.hello(); - const attach = client.attach(serverId, "session-1"); - await acquiring.promise; - - terminated.resolve(new Error("worker crashed")); - continueAcquiring.resolve(undefined); - await expect(attach).resolves.toMatchObject({ ok: false, error: { code: "server_draining" } }); - expect(releaseCount).toBe(1); - }); - - test("shares a Harness creation failure, releases the Session, and allows a later retry", async () => { - const host = new TestServerHost(); - await host.seed("session-1"); - host.nextOpenSessionError = new Error("Harness creation failed"); - const server = createServer(host); - const first = connect(server); - const second = connect(server); - await Promise.all([first.hello(), second.hello()]); - const gate = host.gateNextOpenSession(); - - const firstAttach = first.attach(serverId, "session-1"); - await gate.entered.promise; - const secondAttach = second.attach(serverId, "session-1"); - gate.release.resolve(undefined); - - await expect(Promise.all([firstAttach, secondAttach])).resolves.toMatchObject([ - { ok: false, error: { code: "internal_error" } }, - { ok: false, error: { code: "internal_error" } }, - ]); - expect(host.openSessionCount).toBe(1); - - await expect(first.attach(serverId, "session-1")).resolves.toMatchObject({ ok: true }); - expect(host.openSessionCount).toBe(2); - expect(host.harnesses.get("session-1")).toHaveLength(1); - }); - - test("closes a Harness acquired while server shutdown is in progress", async () => { - const host = new TestServerHost(); - await host.seed("session-1"); - const server = createServer(host); - const client = connect(server); - await client.hello(); - const gate = host.gateNextOpenSession(); - const attach = client.attach(serverId, "session-1"); - await gate.entered.promise; - const closing = server.close(); - gate.release.resolve(undefined); - - await closing; - await expect(attach).rejects.toThrow(/closed/i); - expect(host.latestHarness("session-1").closeCount).toBe(1); - }); - - test("fails shutdown when an in-flight acquisition cannot release its Harness", async () => { - const host = new TestServerHost(); - await host.seed("session-1"); - const cleanupError = new Error("close failed"); - host.nextHarnessCloseError = cleanupError; - const server = createServer(host); - const client = connect(server); - await client.hello(); - const gate = host.gateNextOpenSession(); - const attach = client.attach(serverId, "session-1"); - await gate.entered.promise; - - const closing = server.close(); - gate.release.resolve(undefined); - - await expect(closing).rejects.toThrow(/Failed to close routed Sessions/); - await expect(server.closed).rejects.toThrow(/Failed to close routed Sessions/); - await expect(attach).rejects.toThrow(/closed/i); - expect(host.latestHarness("session-1").closeCount).toBe(1); - - servers.delete(server); - await host.latestHarness("session-1").close(BACKGROUND_CONTEXT); - }); -}); diff --git a/packages/server/test/fixtures/stale-socket-server.mjs b/packages/server/test/fixtures/stale-socket-server.mjs deleted file mode 100644 index e84582766..000000000 --- a/packages/server/test/fixtures/stale-socket-server.mjs +++ /dev/null @@ -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"); -}); diff --git a/packages/server/test/listener.test.ts b/packages/server/test/listener.test.ts deleted file mode 100644 index dcc282644..000000000 --- a/packages/server/test/listener.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, expect, test } from "vitest"; -import type { ByteConnectionAcceptor } from "../src/connection.ts"; -import type { ServerListener } from "../src/listener.ts"; -import { createTestServer } from "../src/testing/index.ts"; - -class TestListener implements ServerListener { - accept?: ByteConnectionAcceptor; - startCount = 0; - closeCount = 0; - readonly startError: Error | undefined; - - constructor(startError?: Error) { - this.startError = startError; - } - - async start(accept: ByteConnectionAcceptor): Promise { - this.startCount += 1; - this.accept = accept; - if (this.startError) throw this.startError; - } - - async close(): Promise { - this.closeCount += 1; - } -} - -describe("Server listener composition", () => { - test("starts and closes every configured listener", async () => { - const first = new TestListener(); - const second = new TestListener(); - const { server } = createTestServer({ listeners: [first, second] }); - - await server.start(); - expect(first.accept).toBeTypeOf("function"); - expect(second.accept).toBeTypeOf("function"); - - await server.close(); - expect(first.closeCount).toBe(1); - expect(second.closeCount).toBe(1); - }); - - test("closes previously started listeners when startup fails", async () => { - const first = new TestListener(); - const failure = new Error("listener failed"); - const second = new TestListener(failure); - const { server } = createTestServer({ listeners: [first, second] }); - - await expect(server.start()).rejects.toBe(failure); - expect(first.closeCount).toBe(1); - expect(second.closeCount).toBe(0); - }); -}); diff --git a/packages/server/test/protocol.test.ts b/packages/server/test/protocol.test.ts deleted file mode 100644 index 44e272b2d..000000000 --- a/packages/server/test/protocol.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { encodeCbor, encodeClientMessage, encodeFrame, PROTOCOL_VERSION } from "@earendil-works/pi-protocol"; -import { afterEach, expect, test } from "vitest"; -import type { ByteConnection, ByteConnectionHandler } from "../src/connection.ts"; -import { Server } from "../src/server.ts"; -import { ProtocolTestClient, TestServerHost, type WireChannel } from "../src/testing/index.ts"; - -let server: Server | undefined; - -function connect(): ProtocolTestClient { - server = new Server(new TestServerHost(), { listeners: [], serverId: "00000000-0000-4000-8000-000000000001" }); - let handler: ByteConnectionHandler; - let client: ProtocolTestClient; - let closed = false; - const connection: ByteConnection = { - get closed() { - return closed; - }, - async send(chunk) { - client.receive(chunk); - }, - close(finalChunk) { - if (finalChunk) client.receive(finalChunk); - closed = true; - client.markClosed(); - }, - }; - const channel: WireChannel = { - async send(chunk) { - handler.onData(chunk); - }, - async sendFragmented(chunk, splitAt) { - handler.onData(chunk.subarray(0, splitAt)); - handler.onData(chunk.subarray(splitAt)); - }, - async close() { - closed = true; - handler.onClose(); - client.markClosed(); - }, - }; - client = new ProtocolTestClient(channel); - handler = server.accept(connection); - return client; -} - -afterEach(async () => { - await server?.close(); - server = undefined; -}); - -test("requires hello as the first message", async () => { - const client = connect(); - await client.sendMessage({ - type: "request", - id: "request-1", - target: { serverId: "00000000-0000-4000-8000-000000000001" }, - call: { serviceId: "pi.session-directory", member: "list", args: [] }, - }); - await expect(client.next((message) => message.type === "hello_error")).resolves.toMatchObject({ - type: "hello_error", - error: { code: "invalid_request" }, - }); - await client.waitForClose(); -}); - -test("rejects unsupported protocol versions", async () => { - const client = connect(); - await expect(client.hello(PROTOCOL_VERSION + 1)).resolves.toMatchObject({ - type: "hello_error", - error: { code: "version" }, - }); - await client.waitForClose(); -}); - -test("accepts fragmented hello and request frames", async () => { - const client = connect(); - const hello = encodeClientMessage({ type: "hello", version: PROTOCOL_VERSION }); - const helloResponse = client.next((message) => message.type === "hello"); - await client.sendFragmentedMessage({ type: "hello", version: PROTOCOL_VERSION }, Math.floor(hello.byteLength / 2)); - await expect(helloResponse).resolves.toMatchObject({ - type: "hello", - serverId: "00000000-0000-4000-8000-000000000001", - }); - - const response = client.next((message) => message.type === "response"); - const request = { - type: "request" as const, - id: "request-1", - target: { serverId: "00000000-0000-4000-8000-000000000001" }, - call: { serviceId: "pi.session-directory" as const, member: "list" as const, args: [] as [] }, - }; - const frame = encodeClientMessage(request); - await client.sendFragmentedMessage(request, Math.floor(frame.byteLength / 2)); - await expect(response).resolves.toMatchObject({ ok: false, error: { code: "internal_error" } }); -}); - -test.each([ - ["malformed CBOR", encodeFrame(Uint8Array.of(0xff))], - ["schema-invalid CBOR", encodeFrame(encodeCbor({ type: "hello", version: 1, extra: true }))], - ["oversized frame", new Uint8Array([1, 0, 0, 1])], -] as const)("rejects hostile framed input: %s", async (_label, bytes) => { - const client = connect(); - await client.sendBytes(bytes); - await expect(client.next((message) => message.type === "hello_error")).resolves.toMatchObject({ - type: "hello_error", - error: { code: "invalid_request" }, - }); - await client.waitForClose(); -}); - -test("rejects a second hello after completing the handshake", async () => { - const client = connect(); - await client.hello(); - await client.sendMessage({ type: "hello", version: PROTOCOL_VERSION }); - await expect(client.next((message) => message.type === "hello_error")).resolves.toMatchObject({ - type: "hello_error", - error: { code: "invalid_request", message: expect.stringMatching(/first message/) }, - }); - await client.waitForClose(); -}); - -test("processes a hello and request coalesced in one byte chunk", async () => { - const client = connect(); - const hello = encodeClientMessage({ type: "hello", version: PROTOCOL_VERSION }); - const request = encodeClientMessage({ - type: "request", - id: "request-1", - target: { serverId: "00000000-0000-4000-8000-000000000001" }, - call: { serviceId: "pi.session-directory", member: "list", args: [] }, - }); - const wire = new Uint8Array(hello.byteLength + request.byteLength); - wire.set(hello); - wire.set(request, hello.byteLength); - - await client.sendBytes(wire); - await expect(client.next((message) => message.type === "hello")).resolves.toMatchObject({ type: "hello" }); - await expect(client.next((message) => message.type === "response")).resolves.toMatchObject({ - type: "response", - id: "request-1", - ok: false, - error: { code: "internal_error" }, - }); -}); - -test("reports a truncated final frame when the peer closes", async () => { - const errors: Error[] = []; - server = new Server(new TestServerHost(), { - listeners: [], - serverId: "00000000-0000-4000-8000-000000000001", - onError: (error) => errors.push(error), - }); - let closed = false; - const connection: ByteConnection = { - get closed() { - return closed; - }, - async send() {}, - close() { - closed = true; - }, - }; - const handler: ByteConnectionHandler = server.accept(connection); - handler.onData(new Uint8Array([0, 0, 0, 2, 1])); - handler.onClose(); - - expect(closed).toBe(false); - expect(errors).toEqual([expect.objectContaining({ message: expect.stringMatching(/truncated/i) })]); -}); diff --git a/packages/server/test/server.test.ts b/packages/server/test/server.test.ts deleted file mode 100644 index fa657d747..000000000 --- a/packages/server/test/server.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { lstat, mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { ServerMessageDecoder } from "@earendil-works/pi-protocol"; -import { afterEach, expect, test } from "vitest"; -import type { ByteConnection } from "../src/connection.ts"; -import { Server } from "../src/index.ts"; -import type { ServerListener } from "../src/listener.ts"; -import { TestServerHost } from "../src/testing/index.ts"; -import { createUnixServer } from "../src/transports/unix/index.ts"; - -const host = new TestServerHost(); - -let server: Server | undefined; -let tempDirectory: string | undefined; - -async function makeSocketPath(): Promise { - tempDirectory = await mkdtemp(join(tmpdir(), "pss-")); - return join(tempDirectory, "server.sock"); -} - -afterEach(async () => { - await server?.close(); - if (tempDirectory) await rm(tempDirectory, { recursive: true, force: true }); - server = undefined; - tempDirectory = undefined; -}); - -test("requires explicit listeners and a canonical UUIDv4 server identity", () => { - expect(() => Reflect.construct(Server, [host, {}])).toThrow(/listeners/); - expect(() => new Server(host, { listeners: [], serverId: "" })).toThrow(/serverId/); - expect(() => new Server(host, { listeners: [], serverId: "invalid-server" })).toThrow(/serverId/); -}); - -test("rejects concurrent start calls without leaking the Unix listener", async () => { - const path = await makeSocketPath(); - server = createUnixServer(host, { path, serverId: "00000000-0000-4000-8000-000000000001" }); - const starting = server.start(); - await expect(server.start()).rejects.toThrow(/starting/); - await starting; - await server.close(); - await expect(lstat(path)).rejects.toMatchObject({ code: "ENOENT" }); -}); - -test("handshake timeout closes with a final hello_error frame", async () => { - let resolveClosed: (() => void) | undefined; - const closed = new Promise((resolve) => { - resolveClosed = resolve; - }); - class TimedOutConnection implements ByteConnection { - closed = false; - finalChunk?: Uint8Array; - - send(): Promise { - return Promise.reject(new Error("handshake timeout must use the terminal close frame")); - } - - close(finalChunk?: Uint8Array): void { - this.finalChunk = finalChunk; - this.closed = true; - resolveClosed?.(); - } - } - const core = new Server(host, { - listeners: [], - serverId: "00000000-0000-4000-8000-000000000001", - maxFrameLength: 1024, - handshakeTimeoutMs: 10, - }); - const connection = new TimedOutConnection(); - core.accept(connection); - - await closed; - expect(connection.closed).toBe(true); - expect(connection.finalChunk).toBeInstanceOf(Uint8Array); - const messages = new ServerMessageDecoder().push(connection.finalChunk!); - expect(messages).toMatchObject([{ type: "hello_error", error: { code: "invalid_request" } }]); - await core.close(); -}); - -test("rejects timeout values above Node's maximum timer delay", () => { - const path = "/tmp/pi-server-timeout-test.sock"; - expect(() => - createUnixServer(host, { - path, - serverId: "00000000-0000-4000-8000-000000000001", - handshakeTimeoutMs: 2_147_483_648, - }), - ).toThrow(/handshakeTimeoutMs/); - expect(() => - createUnixServer(host, { - path, - serverId: "00000000-0000-4000-8000-000000000001", - gracefulCloseTimeoutMs: 2_147_483_648, - }), - ).toThrow(/gracefulCloseTimeoutMs/); -}); - -test("rejects pending-byte limits smaller than one maximum frame", async () => { - const path = await makeSocketPath(); - expect(() => - createUnixServer(host, { - path, - serverId: "00000000-0000-4000-8000-000000000001", - maxFrameLength: 128, - maxPendingBytes: 131, - }), - ).toThrow(/maxPendingBytes/); -}); - -test("rejects close and closed when listener shutdown fails", async () => { - const failure = new Error("listener close failed"); - const listener: ServerListener = { - start: async () => {}, - close: async () => { - throw failure; - }, - }; - const core = new Server(host, { - listeners: [listener], - serverId: "00000000-0000-4000-8000-000000000001", - }); - await core.start(); - - await expect(core.close()).rejects.toBe(failure); - await expect(core.closed).rejects.toBe(failure); -}); diff --git a/packages/server/test/unix-connection.test.ts b/packages/server/test/unix-connection.test.ts deleted file mode 100644 index 9b239facd..000000000 --- a/packages/server/test/unix-connection.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { EventEmitter } from "node:events"; -import type { Socket } from "node:net"; -import { encodeServerMessage, type ServerHelloError, ServerMessageDecoder } from "@earendil-works/pi-protocol"; -import { expect, test, vi } from "vitest"; -import { UnixByteConnection } from "../src/transports/unix/listener.ts"; - -class ControlledSocket extends EventEmitter { - destroyed = false; - writable = true; - writableLength = 0; - ended = false; - finalChunk?: Uint8Array; - private writeCallback?: (error?: Error | null) => void; - - write(_chunk: Uint8Array, callback: (error?: Error | null) => void): boolean { - this.writableLength = 1; - this.writeCallback = callback; - return false; - } - - end(chunk?: Uint8Array): this { - this.ended = true; - this.finalChunk = chunk?.slice(); - return this; - } - - destroy(): this { - if (this.destroyed) return this; - this.destroyed = true; - this.writable = false; - this.emit("close"); - return this; - } - - completeWrite(): void { - const callback = this.writeCallback; - if (!callback) throw new Error("No pending write"); - this.writeCallback = undefined; - this.writableLength = 0; - callback(null); - } -} - -test("queues a final protocol error behind pending output before closing", async () => { - const socket = new ControlledSocket(); - const connection = new UnixByteConnection(socket as unknown as Socket, 1_000, 64 * 1024); - const pendingWrite = connection.send(new Uint8Array([1, 2, 3])); - await vi.waitFor(() => expect(socket.writableLength).toBe(1)); - const finalMessage: ServerHelloError = { - type: "hello_error", - error: { code: "invalid_request", message: "Protocol violation" }, - }; - const closing = connection.close(encodeServerMessage(finalMessage)); - - expect(socket.ended).toBe(false); - expect(socket.destroyed).toBe(false); - - socket.completeWrite(); - await pendingWrite; - await vi.waitFor(() => expect(socket.ended).toBe(true)); - expect(new ServerMessageDecoder().push(socket.finalChunk!)).toEqual([finalMessage]); - - connection.markClosed(); - await closing; -}); diff --git a/packages/server/test/unix.test.ts b/packages/server/test/unix.test.ts deleted file mode 100644 index b3e320009..000000000 --- a/packages/server/test/unix.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { type ChildProcess, fork } from "node:child_process"; -import { once } from "node:events"; -import { lstat, mkdtemp, readdir, readFile, rm, unlink, writeFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; -import { afterEach, describe, expect, test } from "vitest"; -import type { Server } from "../src/index.ts"; -import { connectUnixTestClient, type ProtocolTestClient, TestServerHost } from "../src/testing/index.ts"; -import { createUnixServer, getUnixSocketPath } from "../src/transports/unix/index.ts"; - -const servers = new Set(); -const clients = new Set(); -const children = new Set(); -const tempDirectories = new Set(); - -async function makeSocketPath(nested = false): Promise { - const directory = await mkdtemp(join("/tmp", "ps-")); - tempDirectories.add(directory); - return nested ? join(directory, "p", "n", "server.sock") : join(directory, "server.sock"); -} - -function makeServer(path: string): Server { - const server = createUnixServer(new TestServerHost(), { path, serverId: "00000000-0000-4000-8000-000000000001" }); - servers.add(server); - return server; -} - -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([...clients].map((client) => client.close())); - clients.clear(); - await Promise.all([...servers].map((server) => server.close())); - servers.clear(); - await Promise.all([...tempDirectories].map((directory) => rm(directory, { recursive: true, force: true }))); - tempDirectories.clear(); -}); - -test("creates an in-memory server ID and derives its explicit Unix socket path", async () => { - const directory = await mkdtemp(join("/tmp", "pi-server-")); - tempDirectories.add(directory); - const serverId = "00000000-0000-4000-8000-000000000001"; - const path = getUnixSocketPath(serverId, directory); - - expect(path).toBe(join(directory, `${serverId}.sock`)); - const first = createUnixServer(new TestServerHost(), { serverId, path }); - servers.add(first); - await first.start(); - const firstClient = await connectUnixTestClient(path); - clients.add(firstClient); - expect(await firstClient.hello()).toMatchObject({ serverId }); - await firstClient.close(); - clients.delete(firstClient); - await first.close(); - servers.delete(first); - - const replacement = createUnixServer(new TestServerHost(), { serverId, path }); - servers.add(replacement); - await replacement.start(); - const replacementClient = await connectUnixTestClient(path); - clients.add(replacementClient); - expect(await replacementClient.hello()).toMatchObject({ serverId }); -}); - -describe("Unix listener filesystem lifecycle", () => { - test("rejects a live listener without unlinking it", async () => { - const path = await makeSocketPath(); - const first = makeServer(path); - await first.start(); - const firstIdentity = await lstat(path); - - const second = makeServer(path); - await expect(second.start()).rejects.toThrow(/already running/); - const currentIdentity = await lstat(path); - expect(currentIdentity.isSocket()).toBe(true); - expect({ dev: currentIdentity.dev, ino: currentIdentity.ino }).toEqual({ - dev: firstIdentity.dev, - ino: firstIdentity.ino, - }); - - const client = await connectUnixTestClient(path); - clients.add(client); - expect(await client.hello()).toMatchObject({ type: "hello" }); - }); - - test("never unlinks a regular file at the configured path", async () => { - const path = await makeSocketPath(); - await writeFile(path, "do not remove", { mode: 0o640 }); - const server = makeServer(path); - await expect(server.start()).rejects.toThrow(/non-socket/); - expect(await readFile(path, "utf8")).toBe("do not remove"); - }); - - test("creates nested temp parents, restricts permissions, and removes its own socket", async () => { - const path = await makeSocketPath(true); - const server = makeServer(path); - await server.start(); - const stats = await lstat(path); - expect(stats.isSocket()).toBe(true); - if (process.platform !== "win32") expect(stats.mode & 0o777).toBe(0o600); - expect(await readdir(dirname(path))).toEqual(["server.sock"]); - - await server.close(); - await expect(lstat(path)).rejects.toMatchObject({ code: "ENOENT" }); - }); - - test("does not remove a replacement inode during shutdown", async () => { - const path = await makeSocketPath(); - const server = makeServer(path); - await server.start(); - await unlink(path); - await writeFile(path, "replacement"); - - const closing = server.close(); - expect(await readFile(path, "utf8")).toBe("replacement"); - await closing; - expect(await readFile(path, "utf8")).toBe("replacement"); - }); - - test("removes a genuinely stale socket before binding", async () => { - const path = await makeSocketPath(); - 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"); - const staleIdentity = await lstat(path); - expect(staleIdentity.isSocket()).toBe(true); - child.kill("SIGKILL"); - await once(child, "exit"); - children.delete(child); - - const server = makeServer(path); - await server.start(); - const liveIdentity = await lstat(path); - expect(liveIdentity.isSocket()).toBe(true); - const client = await connectUnixTestClient(path); - clients.add(client); - expect(await client.hello()).toMatchObject({ type: "hello" }); - }); -}); diff --git a/packages/server/tsconfig.build.json b/packages/server/tsconfig.build.json deleted file mode 100644 index 695dd9ad2..000000000 --- a/packages/server/tsconfig.build.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src" - }, - "include": ["src/**/*.ts"], - "exclude": ["node_modules", "dist", "**/*.d.ts", "src/**/*.d.ts"] -} diff --git a/packages/server/tsconfig.test.json b/packages/server/tsconfig.test.json deleted file mode 100644 index b5e61b3d6..000000000 --- a/packages/server/tsconfig.test.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "noEmit": true, - "module": "NodeNext", - "moduleResolution": "NodeNext", - "types": ["node", "vitest"], - "paths": { - "@earendil-works/pi-protocol": ["../protocol/src/index.ts"] - } - }, - "include": ["src/**/*.ts", "test/**/*.ts"] -} diff --git a/packages/server/vitest.config.ts b/packages/server/vitest.config.ts deleted file mode 100644 index 775aaa32b..000000000 --- a/packages/server/vitest.config.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { fileURLToPath } from "node:url"; -import { defineConfig } from "vitest/config"; - -const src = (path: string): string => fileURLToPath(new URL(path, import.meta.url)); - -/** - * Exact matches for bare specifiers, plus one rule per package for subpath exports such as - * `@earendil-works/pi-ai/utils/uuid`. A prefix alias would rewrite those onto `index.ts/utils/uuid`. - */ -export default defineConfig({ - test: { - globals: true, - environment: "node", - reporters: process.env.GITHUB_ACTIONS ? ["dot", "github-actions"] : ["dot"], - }, - resolve: { - conditions: ["source"], - alias: [ - { find: /^@earendil-works\/pi-agent-core$/, replacement: src("../agent/src/index.ts") }, - { find: /^@earendil-works\/pi-agent-core\/(.+)$/, replacement: `${src("../agent/src/")}$1.ts` }, - { find: /^@earendil-works\/pi-ai$/, replacement: src("../ai/src/index.ts") }, - { find: /^@earendil-works\/pi-ai\/(.+)$/, replacement: `${src("../ai/src/")}$1.ts` }, - { find: /^@earendil-works\/pi-telemetry$/, replacement: src("../telemetry/src/index.ts") }, - { find: /^@earendil-works\/pi-protocol$/, replacement: src("../protocol/src/index.ts") }, - ], - }, - ssr: { resolve: { conditions: ["source"] } }, -}); diff --git a/scripts/browser-smoke-entry.ts b/scripts/browser-smoke-entry.ts index 62d99a5c4..84f257788 100644 --- a/scripts/browser-smoke-entry.ts +++ b/scripts/browser-smoke-entry.ts @@ -1,8 +1,6 @@ -import { Client } from "@earendil-works/pi-client"; import { createAssistantMessageEventStream, Type } from "@earendil-works/pi-ai"; import { complete, getModel, getProviders, streamSimple } from "@earendil-works/pi-ai/compat"; import { Agent, streamProxy } from "@earendil-works/pi-agent-core"; -import { decodeCbor, encodeCbor, PROTOCOL_VERSION } from "@earendil-works/pi-protocol"; // Keep this entry browser-safe. It is bundled by scripts/check-browser-smoke.mjs // to catch accidental Node-only runtime imports in browser-facing package exports. @@ -21,7 +19,4 @@ console.log( typeof stream.push, agent.hasQueuedMessages(), typeof streamProxy, - typeof Client, - PROTOCOL_VERSION, - decodeCbor(encodeCbor({ browser: true })), ); diff --git a/scripts/build-coding-agent-bundle.mjs b/scripts/build-coding-agent-bundle.mjs index 2e16fb145..d2f622d6b 100644 --- a/scripts/build-coding-agent-bundle.mjs +++ b/scripts/build-coding-agent-bundle.mjs @@ -16,11 +16,6 @@ const banner = { js: 'import { createRequire as __piCreateRequire } from "node:module"; const require = __piCreateRequire(import.meta.url);', }; const allowedExternalPackages = new Set([ - "@earendil-works/chord", - "@earendil-works/chord/bundler", - "@earendil-works/chord/context", - "@earendil-works/chord/delta", - "@earendil-works/chord/node", "@silvia-odwyer/photon-node", "jiti", // Optional native accelerators. Their callers fall back to JavaScript when absent. @@ -86,7 +81,7 @@ function commonBuildOptions() { banner, bundle: true, define: { PI_BUNDLED_NODE: "true" }, - external: ["@earendil-works/chord", "@silvia-odwyer/photon-node"], + external: ["@silvia-odwyer/photon-node"], format: "esm", legalComments: "none", logLevel: "warning", diff --git a/scripts/coding-agent-smoke.mjs b/scripts/coding-agent-smoke.mjs index cef0fa1bd..9f2e37efa 100644 --- a/scripts/coding-agent-smoke.mjs +++ b/scripts/coding-agent-smoke.mjs @@ -1,36 +1,13 @@ import { execFileSync } from "node:child_process"; -import { existsSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; export const codingAgentName = "@earendil-works/pi-coding-agent"; -const developmentPackages = new Set(["pi-client", "pi-protocol", "pi-server"].map((name) => `@earendil-works/${name}`)); - -function checkInstalledPackages(nodeModules, seen = new Set()) { - if (!existsSync(nodeModules)) return; - const directories = readdirSync(nodeModules) - .filter((name) => !name.startsWith(".")) - .flatMap((name) => name.startsWith("@") - ? readdirSync(join(nodeModules, name)).map((child) => join(nodeModules, name, child)) - : [join(nodeModules, name)]); - for (const directory of directories) { - if (!existsSync(join(directory, "package.json"))) continue; - const path = realpathSync(directory); - if (seen.has(path)) continue; - seen.add(path); - const manifest = JSON.parse(readFileSync(join(path, "package.json"), "utf8")); - if (developmentPackages.has(manifest.name)) throw new Error(`${manifest.name} must not be installed: ${path}`); - checkInstalledPackages(join(path, "node_modules"), seen); - } -} export function smokeTestCodingAgent(directory, runtime = process.execPath) { - checkInstalledPackages(join(directory, "node_modules")); const packageDir = join(directory, "node_modules", codingAgentName); const manifest = JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8")); - for (const path of ["dist/client", "dist/experimental", "dist/cli/experimental", "dist/bundle/client.js", "dist/bundle/coordinator.js"]) { - if (existsSync(join(packageDir, path))) throw new Error(`Published package contains development-only code: ${path}`); - } const home = mkdtempSync(join(tmpdir(), "pi-coding-agent-smoke-home-")); const entry = join(directory, "coding-agent-smoke.mjs"); const env = { @@ -54,9 +31,6 @@ import { createAgentSession, SessionManager, ModelRuntime } from "${codingAgentN assert.equal(typeof createAgentSession, "function"); assert.equal(typeof SessionManager.inMemory, "function"); assert.equal(typeof ModelRuntime.create, "function"); -for (const subpath of ["/client", "/experimental/plugin"]) { - assert.throws(() => import.meta.resolve("${codingAgentName}" + subpath), /not exported|not defined|Cannot find|cannot find/); -} `); execFileSync(runtime, [entry], { cwd: directory, env, stdio: ["inherit", "pipe", "pipe"], timeout: 30_000 }); for (const cli of new Set([manifest.bin.pi, "dist/cli.js"])) { diff --git a/scripts/coding-agent-smoke.test.mjs b/scripts/coding-agent-smoke.test.mjs index e4827921a..f984d0458 100644 --- a/scripts/coding-agent-smoke.test.mjs +++ b/scripts/coding-agent-smoke.test.mjs @@ -7,8 +7,6 @@ import { installConsumer } from "./local-package-install.mjs"; import { produceArtifactSet } from "./package-artifacts.mjs"; import { codingAgentName, smokeTestCodingAgent } from "./coding-agent-smoke.mjs"; -const devPackages = ["pi-client", "pi-protocol", "pi-server"].map((name) => `@earendil-works/${name}`); - function writePackage(directory, manifest, files) { mkdirSync(directory, { recursive: true }); writeFileSync(join(directory, "package.json"), `${JSON.stringify(manifest, null, "\t")}\n`); @@ -18,86 +16,48 @@ function writePackage(directory, manifest, files) { } } -function createFixture(t, { importServer = false, declareServer = false } = {}) { +function createFixture(t, { importUndeclared = false } = {}) { const temporaryRoot = mkdtempSync(join(tmpdir(), "pi-coding-agent-smoke-test-")); t.after(() => rmSync(temporaryRoot, { recursive: true, force: true })); const root = join(temporaryRoot, "fixture with spaces"); const repoRoot = join(root, "repo"); mkdirSync(repoRoot, { recursive: true }); writeFileSync(join(repoRoot, "package.json"), '{"name":"fixture","private":true}\n'); - const packageNames = [codingAgentName, ...devPackages]; - for (const name of packageNames) { - const isAgent = name === codingAgentName; - writePackage( - join(repoRoot, "packages", name.split("/")[1]), - { - name, - version: "1.0.0", - type: "module", - main: "./dist/index.js", - types: "./dist/index.d.ts", - exports: isAgent - ? { - ".": { types: "./dist/index.d.ts", import: "./dist/index.js" }, - "./client": { source: "./src/client/index.ts" }, - "./experimental/plugin": { source: "./src/experimental/plugin.ts" }, - } - : { ".": { types: "./dist/index.d.ts", import: "./dist/index.js" } }, - files: ["dist"], - ...(isAgent - ? { - bin: { pi: "dist/bundle/cli.js" }, - ...(declareServer ? { dependencies: { "@earendil-works/pi-server": "1.0.0" } } : {}), - devDependencies: Object.fromEntries(devPackages.map((packageName) => [packageName, "1.0.0"])), - } - : {}), - }, - { - "dist/index.js": isAgent - ? `${importServer ? 'import "@earendil-works/pi-server";' : ""} + writePackage( + join(repoRoot, "packages", codingAgentName.split("/")[1]), + { + name: codingAgentName, + version: "1.0.0", + type: "module", + main: "./dist/index.js", + types: "./dist/index.d.ts", + exports: { ".": { types: "./dist/index.d.ts", import: "./dist/index.js" } }, + files: ["dist"], + bin: { pi: "dist/bundle/cli.js" }, + }, + { + "dist/index.js": `${importUndeclared ? 'import "@earendil-works/undeclared";' : ""} export function createAgentSession() {} export class SessionManager { static inMemory() {} } export class ModelRuntime { static create() {} } -` - : "export {};\n", - "dist/index.d.ts": "export {};\n", - ...(isAgent - ? { - "dist/cli.js": 'console.log("1.0.0");\n', - "dist/bundle/cli.js": 'console.log("1.0.0");\n', - } - : {}), - }, - ); - } +`, + "dist/index.d.ts": "export {};\n", + "dist/cli.js": 'console.log("1.0.0");\n', + "dist/bundle/cli.js": 'console.log("1.0.0");\n', + }, + ); const artifactSet = produceArtifactSet({ build: false, outDir: join(root, "artifacts"), repoRoot, source: null }); const directory = join(root, "consumer"); installConsumer({ artifactSet, directory, packageNames: [codingAgentName] }); return directory; } -test("accepts a valid coding-agent package and rejects development-only packages and files", (t) => { - const directory = createFixture(t); - smokeTestCodingAgent(directory); - - const nested = join(directory, "node_modules", codingAgentName, "node_modules/@earendil-works/pi-server"); - mkdirSync(nested, { recursive: true }); - writeFileSync(join(nested, "package.json"), JSON.stringify({ name: "@earendil-works/pi-server", version: "1.0.0" })); - assert.throws(() => smokeTestCodingAgent(directory), /pi-server must not be installed/); - rmSync(nested, { recursive: true }); - - const experimental = join(directory, "node_modules", codingAgentName, "dist/experimental"); - mkdirSync(experimental); - assert.throws(() => smokeTestCodingAgent(directory), /contains development-only code/); +test("accepts a valid coding-agent package", (t) => { + smokeTestCodingAgent(createFixture(t)); }); // #9132: smoke-test the public SDK, not just a bundled CLI that hides missing imports. -test("fails when the SDK imports an undeclared server despite a working CLI", (t) => { - const directory = createFixture(t, { importServer: true }); - assert.throws(() => smokeTestCodingAgent(directory), /Cannot find package '@earendil-works\/pi-server'/); -}); - -test("fails if a development-only dependency is added back to the published dependency tree", (t) => { - const directory = createFixture(t, { declareServer: true }); - assert.throws(() => smokeTestCodingAgent(directory), /pi-server must not be installed/); +test("fails when the SDK imports an undeclared package despite a working CLI", (t) => { + const directory = createFixture(t, { importUndeclared: true }); + assert.throws(() => smokeTestCodingAgent(directory), /Cannot find package '@earendil-works\/undeclared'/); }); diff --git a/scripts/generate-coding-agent-install-lock.mjs b/scripts/generate-coding-agent-install-lock.mjs index a0646f237..b4106a98e 100644 --- a/scripts/generate-coding-agent-install-lock.mjs +++ b/scripts/generate-coding-agent-install-lock.mjs @@ -16,7 +16,6 @@ const internalPackageNames = new Set(["@earendil-works/chord"]); const installPackageName = "@earendil-works/pi-coding-agent-install"; const allowedInstallScriptPackages = new Map([ ["@google/genai@2.21.0", "preinstall is a no-op in the published package"], - ["esbuild@0.28.2", "postinstall selects and verifies the platform-specific esbuild binary"], ["protobufjs@7.6.6", "postinstall only warns about protobufjs version scheme mismatches"], ]); @@ -364,12 +363,6 @@ function validateGeneratedFiles(installerPackageJson, installLock, internalNames } } - const platformPackageCount = Object.values(installLock.packages).filter((entry) => entry.os || entry.cpu || entry.libc) - .length; - if (platformPackageCount === 0) { - errors.push("no platform-specific optional dependency entries found"); - } - if (errors.length > 0) { throw new Error(`Generated installer lock failed validation:\n${errors.map((error) => ` - ${error}`).join("\n")}`); } diff --git a/scripts/profile-coding-agent-node.mjs b/scripts/profile-coding-agent-node.mjs index 3d2fb5b78..e496c32d9 100644 --- a/scripts/profile-coding-agent-node.mjs +++ b/scripts/profile-coding-agent-node.mjs @@ -305,10 +305,6 @@ async function runBuild(bundle) { "packages/ai", "--workspace", "packages/agent", - "--workspace", - "packages/protocol", - "--workspace", - "packages/client", ], }, { diff --git a/tsconfig.json b/tsconfig.json index ea4f8ae37..b0868c4e4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -35,19 +35,8 @@ "@earendil-works/pi-env": ["./packages/env/src/index.ts"], "@earendil-works/pi-agent-core": ["./packages/agent/src/index.ts"], "@earendil-works/pi-coding-agent": ["./packages/coding-agent/src/index.ts"], - "@earendil-works/pi-coding-agent/experimental/plugin": [ - "./packages/coding-agent/src/experimental/plugin.ts" - ], "@earendil-works/pi-coding-agent/hooks": ["./packages/coding-agent/src/core/hooks/index.ts"], "@earendil-works/pi-coding-agent/*": ["./packages/coding-agent/src/*"], - "@earendil-works/pi-protocol": ["./packages/protocol/src/index.ts"], - "@earendil-works/pi-protocol/*": ["./packages/protocol/src/*"], - "@earendil-works/pi-client": ["./packages/client/src/index.ts"], - "@earendil-works/pi-client/unix": ["./packages/client/src/unix.ts"], - "@earendil-works/pi-client/*": ["./packages/client/src/*"], - "@earendil-works/pi-server": ["./packages/server/src/index.ts"], - "@earendil-works/pi-server/unix": ["./packages/server/src/transports/unix/index.ts"], - "@earendil-works/pi-server/*": ["./packages/server/src/*"], "typebox": ["./node_modules/typebox"], "@earendil-works/pi-tui": ["./packages/tui/src/index.ts"], "@earendil-works/pi-tui/*": ["./packages/tui/src/*"], diff --git a/vitest.base.ts b/vitest.base.ts index fe2290453..4eaff52e1 100644 --- a/vitest.base.ts +++ b/vitest.base.ts @@ -22,11 +22,6 @@ export const workspaceSourcePaths = { codemodeIndex: fileURLToPath(new URL("./packages/codemode/src/index.ts", import.meta.url)), codemodeDeclarations: fileURLToPath(new URL("./packages/codemode/src/declarations.ts", import.meta.url)), codemodeSource: fileURLToPath(new URL("./packages/codemode/src/source.ts", import.meta.url)), - protocolIndex: fileURLToPath(new URL("./packages/protocol/src/index.ts", import.meta.url)), - clientIndex: fileURLToPath(new URL("./packages/client/src/index.ts", import.meta.url)), - clientUnix: fileURLToPath(new URL("./packages/client/src/unix.ts", import.meta.url)), - serverIndex: fileURLToPath(new URL("./packages/server/src/index.ts", import.meta.url)), - serverUnix: fileURLToPath(new URL("./packages/server/src/transports/unix/index.ts", import.meta.url)), codingAgentIndex: fileURLToPath(new URL("./packages/coding-agent/src/index.ts", import.meta.url)), tuiIndex: fileURLToPath(new URL("./packages/tui/src/index.ts", import.meta.url)), } as const; @@ -63,11 +58,6 @@ export default defineConfig({ { find: /^@earendil-works\/pi-codemode$/, replacement: workspaceSourcePaths.codemodeIndex }, { find: /^@earendil-works\/pi-codemode\/declarations$/, replacement: workspaceSourcePaths.codemodeDeclarations }, { find: /^@earendil-works\/pi-codemode\/source$/, replacement: workspaceSourcePaths.codemodeSource }, - { find: /^@earendil-works\/pi-protocol$/, replacement: workspaceSourcePaths.protocolIndex }, - { find: /^@earendil-works\/pi-client$/, replacement: workspaceSourcePaths.clientIndex }, - { find: /^@earendil-works\/pi-client\/unix$/, replacement: workspaceSourcePaths.clientUnix }, - { find: /^@earendil-works\/pi-server$/, replacement: workspaceSourcePaths.serverIndex }, - { find: /^@earendil-works\/pi-server\/unix$/, replacement: workspaceSourcePaths.serverUnix }, { find: /^@earendil-works\/pi-tui$/, replacement: workspaceSourcePaths.tuiIndex }, ], },