Extract client SDK package (#1052)

* Extract client SDK package

* Polish SDK client identity defaults

* Build client before dependent CI jobs

* Restore daemon client server export

* Extract protocol and client SDK packages

* Fix provider override schema validation

* Fix app test daemon client imports

* Simplify workspace build targets

* Fix CLI test server build bootstrap

* Run SDK package tests in CI

* Fix rebase package split drift

* Restore lockfile registry metadata

* Update SDK config test for prompt default

* Move terminal stream router test to client package

* Fix rebase drift for protocol imports

* Fix SDK agent capability fixture

* Restore legacy server client exports

* Fix server export compatibility test

* Advertise custom mode icon client capability

* Remove server daemon-client exports

* Format rebased mode control import

* Fix rebase drift for protocol imports

Files added by upstream PRs (#893, #1147, #1154) referenced the pre-split
shared/ paths that this branch moves into @getpaseo/protocol. Redirect
those imports to the protocol package so typecheck stays green after the
rebase.
This commit is contained in:
Mohamed Boudra
2026-05-28 01:58:18 +08:00
committed by GitHub
parent 0ea41378a4
commit 53c14d9855
403 changed files with 3193 additions and 1417 deletions
@@ -0,0 +1,199 @@
import {
createClientChannel,
type EncryptedChannel,
type Transport as RelayTransport,
} from "@getpaseo/relay/e2ee";
import type {
DaemonTransport,
DaemonTransportFactory,
TransportLogger,
} from "./daemon-client-transport-types.js";
import {
extractRelayMessageData,
normalizeTransportPayload,
} from "./daemon-client-transport-utils.js";
type OpenHandler = () => void;
type CloseHandler = (event?: unknown) => void;
type ErrorHandler = (event?: unknown) => void;
type MessageHandler = (data: unknown) => void;
export function createRelayE2eeTransportFactory(args: {
baseFactory: DaemonTransportFactory;
daemonPublicKeyB64: string;
logger: TransportLogger;
}): DaemonTransportFactory {
return ({ url, headers }) => {
const base = args.baseFactory({ url, headers });
return createEncryptedTransport(base, args.daemonPublicKeyB64, args.logger);
};
}
export function createEncryptedTransport(
base: DaemonTransport,
daemonPublicKeyB64: string,
logger: TransportLogger,
): DaemonTransport {
let channel: EncryptedChannel | null = null;
let opened = false;
let closed = false;
const openHandlers = new Set<OpenHandler>();
const closeHandlers = new Set<CloseHandler>();
const errorHandlers = new Set<ErrorHandler>();
const messageHandlers = new Set<MessageHandler>();
const emitOpen = () => {
if (opened || closed) {
return;
}
opened = true;
emitHandlers(openHandlers);
};
const emitClose = (event?: unknown) => {
if (closed) {
return;
}
closed = true;
emitHandlers(closeHandlers, event);
};
const emitError = (event?: unknown) => {
if (closed) {
return;
}
emitHandlers(errorHandlers, event);
};
const emitMessage = (data: unknown) => {
if (closed) {
return;
}
emitHandlers(messageHandlers, data);
};
const relayTransport: RelayTransport = {
send: (data) => {
if (typeof data === "string") {
base.send(data);
return;
}
if (ArrayBuffer.isView(data)) {
base.send(normalizeTransportPayload(data));
return;
}
if (data instanceof ArrayBuffer) {
base.send(data);
return;
}
base.send(String(data));
},
close: (code?: number, reason?: string) => base.close(code, reason),
onmessage: null,
onclose: null,
onerror: null,
};
const startHandshake = async () => {
try {
channel = await createClientChannel(relayTransport, daemonPublicKeyB64, {
onopen: emitOpen,
onmessage: (data) => emitMessage(data),
onclose: (code, reason) => emitClose({ code, reason }),
onerror: (error) => emitError(error),
});
} catch (error) {
logger.warn({ err: normalizeTransportError(error) }, "relay_e2ee_handshake_failed");
emitError(error);
// Browser WebSocket.close only accepts 1000 or 3000-4999.
// Use an app-defined code so this path works in browser and Node runtimes.
base.close(4001, "E2EE handshake failed");
}
};
base.onOpen(() => {
void startHandshake();
});
base.onMessage((event) => {
relayTransport.onmessage?.(extractRelayMessageData(event));
});
base.onClose((event) => {
const record = event as { code?: number; reason?: string } | undefined;
relayTransport.onclose?.(record?.code ?? 0, record?.reason ?? "");
emitClose(event);
});
base.onError((event) => {
relayTransport.onerror?.(event instanceof Error ? event : new Error(String(event)));
emitError(event);
});
return {
send: (data) => {
if (!channel) {
throw new Error("Encrypted channel not ready");
}
void channel.send(normalizeTransportPayload(data)).catch((error) => {
emitError(error);
});
},
close: (code?: number, reason?: string) => {
if (channel) {
channel.close(code, reason);
} else {
base.close(code, reason);
}
emitClose({ code, reason });
},
onMessage: (handler) => {
messageHandlers.add(handler);
return () => messageHandlers.delete(handler);
},
onOpen: (handler) => {
openHandlers.add(handler);
if (opened) {
invokeHandler(handler);
}
return () => openHandlers.delete(handler);
},
onClose: (handler) => {
closeHandlers.add(handler);
if (closed) {
invokeHandler(handler);
}
return () => closeHandlers.delete(handler);
},
onError: (handler) => {
errorHandlers.add(handler);
return () => errorHandlers.delete(handler);
},
};
}
function emitHandlers<TArgs extends unknown[]>(
handlers: Set<(...args: TArgs) => void>,
...args: TArgs
) {
for (const handler of handlers) {
invokeHandler(handler, ...args);
}
}
function invokeHandler<TArgs extends unknown[]>(handler: (...args: TArgs) => void, ...args: TArgs) {
try {
handler(...args);
} catch {
// no-op
}
}
function normalizeTransportError(error: unknown): Record<string, string> {
if (error instanceof Error) {
return {
name: error.name,
message: error.message,
...(typeof error.stack === "string" ? { stack: error.stack } : {}),
};
}
return { message: String(error) };
}
@@ -0,0 +1,258 @@
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
interface RuntimeMetricsLogger {
info(obj: object, msg?: string): void;
}
interface RuntimeMetricsHandlerTiming {
count: number;
totalMs: number;
maxMs: number;
}
interface RuntimeMetricsBucket {
inboundMessageCounts: Map<string, number>;
inboundMessageBytes: Map<string, number>;
inboundMessageHandlerMs: Map<string, RuntimeMetricsHandlerTiming>;
inboundAgentStreamCounts: Map<string, number>;
inboundAgentStreamByAgentCounts: Map<string, number>;
inboundBinaryFrameCounts: Map<string, number>;
endedAt: number;
}
interface RuntimeMetricsContext {
connectionPath: "direct" | "relay";
serverId: string | null;
getConnectionStatus: () => string;
}
interface RuntimeMetricsOptions {
windowMs?: number;
}
const DEFAULT_ROLLING_WINDOW_MS = 60_000;
export class DaemonClientRuntimeMetrics {
private readonly startedAt = Date.now();
private readonly windowMs: number;
private readonly buckets: RuntimeMetricsBucket[] = [];
private readonly inboundMessageCounts = new Map<string, number>();
private readonly inboundMessageBytes = new Map<string, number>();
private readonly inboundMessageHandlerMs = new Map<string, RuntimeMetricsHandlerTiming>();
private readonly inboundAgentStreamCounts = new Map<string, number>();
private readonly inboundAgentStreamByAgentCounts = new Map<string, number>();
private readonly inboundBinaryFrameCounts = new Map<string, number>();
constructor(
private readonly logger: RuntimeMetricsLogger,
private readonly context: RuntimeMetricsContext,
options?: RuntimeMetricsOptions,
) {
this.windowMs =
typeof options?.windowMs === "number" && options.windowMs > 0
? options.windowMs
: DEFAULT_ROLLING_WINDOW_MS;
}
recordMessage(type: string, bytes: number, handlerMs: number): void {
incrementCount(this.inboundMessageCounts, type, 1);
incrementCount(this.inboundMessageBytes, type, bytes);
incrementHandlerTiming(this.inboundMessageHandlerMs, type, handlerMs);
}
recordAgentStream(
payload: Extract<SessionOutboundMessage, { type: "agent_stream" }>["payload"],
): void {
const { agentId, event } = payload;
const eventType = event.type === "timeline" ? `timeline:${event.item.type}` : event.type;
incrementCount(this.inboundAgentStreamCounts, eventType, 1);
incrementCount(this.inboundAgentStreamByAgentCounts, agentId, 1);
}
recordBinaryFrame(kind: string, bytes: number, handlerMs: number): void {
incrementCount(this.inboundBinaryFrameCounts, kind, 1);
incrementCount(this.inboundMessageBytes, `binary:${kind}`, bytes);
incrementHandlerTiming(this.inboundMessageHandlerMs, `binary:${kind}`, handlerMs);
}
flush(options?: { final?: boolean }): void {
const now = Date.now();
const bucket = this.consumeCurrentBucket(now);
if (bucket) {
this.buckets.push(bucket);
}
this.pruneBuckets(now);
const aggregate = this.aggregateBuckets();
const hasActivity =
aggregate.inboundMessageCounts.size > 0 || aggregate.inboundBinaryFrameCounts.size > 0;
if (!hasActivity && !options?.final) {
return;
}
this.logger.info(
{
windowMs: Math.min(this.windowMs, Math.max(0, now - this.startedAt)),
rollingWindowMs: this.windowMs,
bucketCount: this.buckets.length,
final: Boolean(options?.final),
connectionPath: this.context.connectionPath,
serverId: this.context.serverId,
connectionStatus: this.context.getConnectionStatus(),
inboundMessageTypesTop: getTopCounts(aggregate.inboundMessageCounts, 20),
inboundMessageBytesTop: getTopCounts(aggregate.inboundMessageBytes, 20),
inboundAgentStreamTypesTop: getTopCounts(aggregate.inboundAgentStreamCounts, 20),
inboundAgentStreamAgentsTop: getTopCounts(aggregate.inboundAgentStreamByAgentCounts, 20),
inboundBinaryFrameTypesTop: getTopCounts(aggregate.inboundBinaryFrameCounts, 12),
handlerTimingTop: getTopHandlerTimings(aggregate.inboundMessageHandlerMs, 20),
},
"ws_runtime_metrics_client",
);
}
private consumeCurrentBucket(now: number): RuntimeMetricsBucket | null {
const hasActivity =
this.inboundMessageCounts.size > 0 || this.inboundBinaryFrameCounts.size > 0;
if (!hasActivity) {
return null;
}
const bucket = {
inboundMessageCounts: new Map(this.inboundMessageCounts),
inboundMessageBytes: new Map(this.inboundMessageBytes),
inboundMessageHandlerMs: cloneHandlerTimingMap(this.inboundMessageHandlerMs),
inboundAgentStreamCounts: new Map(this.inboundAgentStreamCounts),
inboundAgentStreamByAgentCounts: new Map(this.inboundAgentStreamByAgentCounts),
inboundBinaryFrameCounts: new Map(this.inboundBinaryFrameCounts),
endedAt: now,
};
this.inboundMessageCounts.clear();
this.inboundMessageBytes.clear();
this.inboundMessageHandlerMs.clear();
this.inboundAgentStreamCounts.clear();
this.inboundAgentStreamByAgentCounts.clear();
this.inboundBinaryFrameCounts.clear();
return bucket;
}
private pruneBuckets(now: number): void {
const cutoff = now - this.windowMs;
while (this.buckets.length > 0 && this.buckets[0].endedAt < cutoff) {
this.buckets.shift();
}
}
private aggregateBuckets(): RuntimeMetricsBucket {
const aggregate = createEmptyBucket(Date.now());
for (const bucket of this.buckets) {
mergeCountMap(aggregate.inboundMessageCounts, bucket.inboundMessageCounts);
mergeCountMap(aggregate.inboundMessageBytes, bucket.inboundMessageBytes);
mergeHandlerTimingMap(aggregate.inboundMessageHandlerMs, bucket.inboundMessageHandlerMs);
mergeCountMap(aggregate.inboundAgentStreamCounts, bucket.inboundAgentStreamCounts);
mergeCountMap(
aggregate.inboundAgentStreamByAgentCounts,
bucket.inboundAgentStreamByAgentCounts,
);
mergeCountMap(aggregate.inboundBinaryFrameCounts, bucket.inboundBinaryFrameCounts);
}
return aggregate;
}
}
function createEmptyBucket(endedAt: number): RuntimeMetricsBucket {
return {
inboundMessageCounts: new Map(),
inboundMessageBytes: new Map(),
inboundMessageHandlerMs: new Map(),
inboundAgentStreamCounts: new Map(),
inboundAgentStreamByAgentCounts: new Map(),
inboundBinaryFrameCounts: new Map(),
endedAt,
};
}
function incrementCount(map: Map<string, number>, key: string, amount: number): void {
map.set(key, (map.get(key) ?? 0) + amount);
}
function incrementHandlerTiming(
map: Map<string, RuntimeMetricsHandlerTiming>,
key: string,
handlerMs: number,
): void {
const existing = map.get(key);
if (existing) {
existing.count += 1;
existing.totalMs += handlerMs;
existing.maxMs = Math.max(existing.maxMs, handlerMs);
return;
}
map.set(key, {
count: 1,
totalMs: handlerMs,
maxMs: handlerMs,
});
}
function cloneHandlerTimingMap(
map: Map<string, RuntimeMetricsHandlerTiming>,
): Map<string, RuntimeMetricsHandlerTiming> {
return new Map(
[...map.entries()].map(([key, value]) => [
key,
{ count: value.count, totalMs: value.totalMs, maxMs: value.maxMs },
]),
);
}
function mergeCountMap(target: Map<string, number>, source: Map<string, number>): void {
for (const [key, value] of source) {
incrementCount(target, key, value);
}
}
function mergeHandlerTimingMap(
target: Map<string, RuntimeMetricsHandlerTiming>,
source: Map<string, RuntimeMetricsHandlerTiming>,
): void {
for (const [key, value] of source) {
const existing = target.get(key);
if (existing) {
existing.count += value.count;
existing.totalMs += value.totalMs;
existing.maxMs = Math.max(existing.maxMs, value.maxMs);
continue;
}
target.set(key, {
count: value.count,
totalMs: value.totalMs,
maxMs: value.maxMs,
});
}
}
function getTopCounts(map: Map<string, number>, limit: number): Array<[string, number]> {
return [...map.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit);
}
function getTopHandlerTimings(
map: Map<string, RuntimeMetricsHandlerTiming>,
limit: number,
): Array<{
type: string;
count: number;
totalMs: number;
avgMs: number;
maxMs: number;
}> {
const rows = [...map.entries()].map(([type, value]) => ({
type,
count: value.count,
totalMs: Math.round(value.totalMs),
avgMs: Math.round((value.totalMs / value.count) * 100) / 100,
maxMs: Math.round(value.maxMs * 100) / 100,
}));
rows.sort((a, b) => b.totalMs - a.totalMs);
return rows.slice(0, limit);
}
@@ -0,0 +1,39 @@
export interface DaemonTransport {
send: (data: string | Uint8Array | ArrayBuffer) => void;
close: (code?: number, reason?: string) => void;
onMessage: (handler: (data: unknown) => void) => () => void;
onOpen: (handler: () => void) => () => void;
onClose: (handler: (event?: unknown) => void) => () => void;
onError: (handler: (event?: unknown) => void) => () => void;
}
export type DaemonTransportFactory = (options: {
url: string;
headers?: Record<string, string>;
protocols?: string[];
}) => DaemonTransport;
export type WebSocketFactory = (
url: string,
options?: { headers?: Record<string, string>; protocols?: string[] },
) => WebSocketLike;
export interface WebSocketLike {
readyState: number;
send: (data: string | Uint8Array | ArrayBuffer) => void;
close: (code?: number, reason?: string) => void;
binaryType?: string;
on?: (event: string, listener: (...args: unknown[]) => void) => void;
off?: (event: string, listener: (...args: unknown[]) => void) => void;
removeListener?: (event: string, listener: (...args: unknown[]) => void) => void;
addEventListener?: (event: string, listener: (event: unknown) => void) => void;
removeEventListener?: (event: string, listener: (event: unknown) => void) => void;
onopen?: ((event: unknown) => void) | null;
onclose?: ((event: unknown) => void) | null;
onerror?: ((event: unknown) => void) | null;
onmessage?: ((event: unknown) => void) | null;
}
export interface TransportLogger {
warn(obj: object, msg?: string): void;
}
@@ -0,0 +1,127 @@
export function copyArrayBufferViewToBuffer(data: ArrayBufferView): ArrayBuffer {
const view = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
const out = new Uint8Array(view.byteLength);
out.set(view);
return out.buffer;
}
export function normalizeTransportPayload(
data: string | Uint8Array | ArrayBuffer,
): string | ArrayBuffer {
if (typeof data === "string" || data instanceof ArrayBuffer) {
return data;
}
return copyArrayBufferViewToBuffer(data);
}
export function extractRelayMessageData(event: unknown): string | ArrayBuffer {
const raw =
event && typeof event === "object" && "data" in event
? (event as { data: unknown }).data
: event;
if (typeof raw === "string") return raw;
if (raw instanceof ArrayBuffer) return raw;
if (ArrayBuffer.isView(raw)) {
return copyArrayBufferViewToBuffer(raw);
}
return String(raw ?? "");
}
export function describeTransportClose(event?: unknown): string {
if (!event) {
return "Transport closed";
}
if (event instanceof Error) {
return event.message;
}
if (typeof event === "string") {
return event;
}
if (typeof event === "object") {
const record = event as { reason?: unknown; message?: unknown; code?: unknown };
if (typeof record.reason === "string" && record.reason.trim().length > 0) {
return record.reason.trim();
}
if (typeof record.message === "string" && record.message.trim().length > 0) {
return record.message.trim();
}
if (typeof record.code === "number") {
return `Transport closed (code ${record.code})`;
}
}
return "Transport closed";
}
export function describeTransportError(event?: unknown): string {
if (!event) {
return "Transport error";
}
if (event instanceof Error) {
return event.message;
}
if (typeof event === "string") {
return event;
}
if (typeof event === "object") {
const record = event as { message?: unknown };
if (typeof record.message === "string" && record.message.trim().length > 0) {
return record.message.trim();
}
}
return "Transport error";
}
export function safeRandomId(): string {
try {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
} catch {
// ignore
}
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
}
export function decodeMessageData(data: unknown): string | null {
if (data === null || data === undefined) {
return null;
}
if (typeof data === "string") {
return data;
}
if (typeof ArrayBuffer !== "undefined" && data instanceof ArrayBuffer) {
if (typeof Buffer !== "undefined") {
return Buffer.from(data).toString("utf8");
}
if (typeof TextDecoder !== "undefined") {
return new TextDecoder().decode(data);
}
}
if (ArrayBuffer.isView(data)) {
const view = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
if (typeof Buffer !== "undefined") {
return Buffer.from(view).toString("utf8");
}
if (typeof TextDecoder !== "undefined") {
return new TextDecoder().decode(view);
}
}
if (typeof (data as { toString?: () => string }).toString === "function") {
return (data as { toString: () => string }).toString();
}
return null;
}
export function encodeUtf8String(value: string): Uint8Array {
if (typeof TextEncoder !== "undefined") {
return new TextEncoder().encode(value);
}
if (typeof Buffer !== "undefined") {
return new Uint8Array(Buffer.from(value, "utf8"));
}
const out = new Uint8Array(value.length);
for (let i = 0; i < value.length; i++) {
out[i] = value.charCodeAt(i) & 0xff;
}
return out;
}
@@ -0,0 +1,183 @@
import { EventEmitter } from "node:events";
import { describe, expect, test, vi } from "vitest";
import {
createEncryptedTransport,
createWebSocketTransportFactory,
decodeMessageData,
describeTransportClose,
describeTransportError,
encodeUtf8String,
extractRelayMessageData,
} from "./daemon-client-transport.js";
const createClientChannelMock = vi.hoisted(() => vi.fn());
vi.mock("@getpaseo/relay/e2ee", () => ({
createClientChannel: createClientChannelMock,
}));
describe("daemon-client transport helpers", () => {
test("createEncryptedTransport closes handshake failures with browser-safe code", async () => {
createClientChannelMock.mockReset();
createClientChannelMock.mockRejectedValueOnce(new Error("handshake failed"));
let openHandler: (() => void) | null = null;
const close = vi.fn();
createEncryptedTransport(
{
send: vi.fn(),
close,
onOpen: (handler) => {
openHandler = handler;
return () => {
if (openHandler === handler) {
openHandler = null;
}
};
},
onClose: () => () => {},
onError: () => () => {},
onMessage: () => () => {},
},
"daemon-public-key",
{ warn: vi.fn() },
);
expect(openHandler).not.toBeNull();
openHandler?.();
await vi.waitFor(() => {
expect(close).toHaveBeenCalledWith(4001, "E2EE handshake failed");
});
});
test("createWebSocketTransportFactory forwards sends when socket is open", () => {
const addEventListener = vi.fn();
const removeEventListener = vi.fn();
const send = vi.fn();
const close = vi.fn();
const factory = createWebSocketTransportFactory(() => ({
readyState: 1,
send,
close,
binaryType: "blob",
addEventListener,
removeEventListener,
}));
const transport = factory({ url: "ws://example.test" });
transport.send("hello");
transport.close(1000, "bye");
expect(send).toHaveBeenCalledWith("hello");
expect(close).toHaveBeenCalledWith(1000, "bye");
});
test("createWebSocketTransportFactory passes WebSocket protocols to the socket factory", () => {
const socketFactory = vi.fn(() => ({
readyState: 1,
send: vi.fn(),
close: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}));
createWebSocketTransportFactory(socketFactory)({
url: "ws://example.test",
headers: { Authorization: "Bearer shared-secret" },
protocols: ["paseo.bearer.shared-secret"],
});
expect(socketFactory).toHaveBeenCalledWith("ws://example.test", {
headers: { Authorization: "Bearer shared-secret" },
protocols: ["paseo.bearer.shared-secret"],
});
});
test("createWebSocketTransportFactory rejects sends when socket is not open", () => {
const factory = createWebSocketTransportFactory(() => ({
readyState: 3,
send: vi.fn(),
close: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}));
const transport = factory({ url: "ws://example.test" });
expect(() => transport.send("hello")).toThrow("WebSocket not open (readyState=3)");
});
test("createWebSocketTransportFactory binds and unbinds event listeners", () => {
const listeners = new Map<string, (...args: unknown[]) => void>();
const ws = {
readyState: 1,
send: vi.fn(),
close: vi.fn(),
addEventListener: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
listeners.set(event, handler);
}),
removeEventListener: vi.fn((event: string) => {
listeners.delete(event);
}),
};
const transport = createWebSocketTransportFactory(() => ws)({ url: "ws://example.test" });
const onMessage = vi.fn();
const unsubscribe = transport.onMessage(onMessage);
const message = { data: "payload" };
listeners.get("message")?.(message);
expect(onMessage).toHaveBeenCalledWith(message);
unsubscribe();
expect(ws.removeEventListener).toHaveBeenCalledWith("message", expect.any(Function));
});
test("createWebSocketTransportFactory suppresses close-before-open ws errors", () => {
class MockNodeWebSocket extends EventEmitter {
readyState = 0;
send = vi.fn();
close = vi.fn((code?: number, reason?: string) => {
this.emit("error", new Error("WebSocket was closed before the connection was established"));
this.emit("close", { code, reason });
});
}
const ws = new MockNodeWebSocket();
const transport = createWebSocketTransportFactory(() => ws)({ url: "ws://example.test" });
expect(() => transport.close(1001, "Connection timed out")).not.toThrow();
});
test("describeTransportClose prefers reason, then message, then code", () => {
expect(describeTransportClose({ reason: "peer closed" })).toBe("peer closed");
expect(describeTransportClose({ message: "closed" })).toBe("closed");
expect(describeTransportClose({ code: 1001 })).toBe("Transport closed (code 1001)");
expect(describeTransportClose()).toBe("Transport closed");
});
test("describeTransportError returns normalized messages", () => {
expect(describeTransportError(new Error("boom"))).toBe("boom");
expect(describeTransportError({ message: "bad frame" })).toBe("bad frame");
expect(describeTransportError()).toBe("Transport error");
});
test("extractRelayMessageData returns strings and array buffers", () => {
expect(extractRelayMessageData({ data: "hello" })).toBe("hello");
const view = new Uint8Array([1, 2, 3]);
const extracted = extractRelayMessageData({ data: view });
expect(extracted).toBeInstanceOf(ArrayBuffer);
expect(Array.from(new Uint8Array(extracted as ArrayBuffer))).toEqual([1, 2, 3]);
});
test("decodeMessageData decodes strings, array buffers, and typed arrays", () => {
const bytes = encodeUtf8String("hello");
expect(decodeMessageData("hello")).toBe("hello");
expect(decodeMessageData(bytes.buffer)).toBe("hello");
expect(decodeMessageData(bytes)).toBe("hello");
});
});
@@ -0,0 +1,25 @@
export type {
DaemonTransport,
DaemonTransportFactory,
TransportLogger,
WebSocketFactory,
WebSocketLike,
} from "./daemon-client-transport-types.js";
export {
decodeMessageData,
describeTransportClose,
describeTransportError,
encodeUtf8String,
extractRelayMessageData,
normalizeTransportPayload,
safeRandomId,
} from "./daemon-client-transport-utils.js";
export {
createEncryptedTransport,
createRelayE2eeTransportFactory,
} from "./daemon-client-relay-e2ee-transport.js";
export {
bindWsHandler,
createWebSocketTransportFactory,
defaultWebSocketFactory,
} from "./daemon-client-websocket-transport.js";
@@ -0,0 +1,140 @@
import type {
DaemonTransportFactory,
WebSocketFactory,
WebSocketLike,
} from "./daemon-client-transport-types.js";
export function defaultWebSocketFactory(
url: string,
options?: { headers?: Record<string, string>; protocols?: string[] },
): WebSocketLike {
const globalWs = (
globalThis as {
WebSocket?: new (url: string, protocols?: string | string[]) => WebSocketLike;
}
).WebSocket;
if (!globalWs) {
throw new Error("WebSocket is not available in this runtime");
}
return new globalWs(url, options?.protocols);
}
export function createWebSocketTransportFactory(factory: WebSocketFactory): DaemonTransportFactory {
return ({ url, headers, protocols }) => {
const ws = factory(url, { headers, protocols });
if ("binaryType" in ws) {
try {
ws.binaryType = "arraybuffer";
} catch {
// no-op
}
}
return {
send: (data) => {
if (typeof ws.readyState === "number" && ws.readyState !== 1) {
throw new Error(`WebSocket not open (readyState=${ws.readyState})`);
}
ws.send(data);
},
close: (code?: number, reason?: string) => {
// Node's `ws` may emit an `error` when a connecting socket is closed before the
// handshake completes. Keep a temporary no-op handler attached so cleanup during
// connect timeouts does not crash the CLI with an unhandled error event.
const suppressEarlyCloseError = bindTemporaryEarlyCloseErrorHandler(ws);
try {
ws.close(code, reason);
} finally {
if (typeof ws.on !== "function" && typeof ws.addEventListener !== "function") {
suppressEarlyCloseError();
}
}
},
onOpen: (handler) => bindWsHandler(ws, "open", handler),
onClose: (handler) => bindWsHandler(ws, "close", handler),
onError: (handler) => bindWsHandler(ws, "error", handler),
onMessage: (handler) => bindWsHandler(ws, "message", handler),
};
};
}
function bindTemporaryEarlyCloseErrorHandler(ws: WebSocketLike): () => void {
const noop = () => {};
if (typeof ws.addEventListener === "function") {
ws.addEventListener("error", noop);
const removeOnClose = bindWsHandler(ws, "close", () => {
removeOnClose();
if (typeof ws.removeEventListener === "function") {
ws.removeEventListener("error", noop);
}
});
return () => {
removeOnClose();
if (typeof ws.removeEventListener === "function") {
ws.removeEventListener("error", noop);
}
};
}
if (typeof ws.on === "function") {
ws.on("error", noop);
const removeOnClose = bindWsHandler(ws, "close", () => {
removeOnClose();
if (typeof ws.off === "function") {
ws.off("error", noop);
return;
}
if (typeof ws.removeListener === "function") {
ws.removeListener("error", noop);
}
});
return () => {
removeOnClose();
if (typeof ws.off === "function") {
ws.off("error", noop);
return;
}
if (typeof ws.removeListener === "function") {
ws.removeListener("error", noop);
}
};
}
return () => {};
}
export function bindWsHandler(
ws: WebSocketLike,
event: "open" | "close" | "error" | "message",
handler: (...args: unknown[]) => void,
): () => void {
if (typeof ws.addEventListener === "function") {
ws.addEventListener(event, handler);
return () => {
if (typeof ws.removeEventListener === "function") {
ws.removeEventListener(event, handler);
}
};
}
if (typeof ws.on === "function") {
ws.on(event, handler);
return () => {
if (typeof ws.off === "function") {
ws.off(event, handler);
return;
}
if (typeof ws.removeListener === "function") {
ws.removeListener(event, handler);
}
};
}
const prop = `on${event}`;
const wsRecord = ws as unknown as Record<string, unknown>;
const previous = wsRecord[prop];
wsRecord[prop] = handler;
return () => {
if (wsRecord[prop] === handler) {
wsRecord[prop] = previous ?? null;
}
};
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+795
View File
@@ -0,0 +1,795 @@
import { afterEach, expect, test, vi } from "vitest";
import { createPaseoClient } from "./index.js";
import type { PaseoAgent, PaseoClient, PaseoProviderConfig, PaseoWorkspace } from "./index.js";
type FakeWebSocketHandler = (...args: unknown[]) => void;
class FakeWebSocket {
static instances: FakeWebSocket[] = [];
readyState = 1;
sent: Array<string | ArrayBuffer | Uint8Array> = [];
onopen: FakeWebSocketHandler | null = null;
onmessage: FakeWebSocketHandler | null = null;
onclose: FakeWebSocketHandler | null = null;
onerror: FakeWebSocketHandler | null = null;
constructor(
readonly url: string,
readonly protocols?: string | string[],
) {
FakeWebSocket.instances.push(this);
}
send(data: string | ArrayBuffer | Uint8Array): void {
this.sent.push(data);
}
close(): void {
this.readyState = 3;
}
open(): void {
this.onopen?.();
}
message(data: string): void {
this.onmessage?.(data);
}
}
afterEach(() => {
vi.unstubAllGlobals();
FakeWebSocket.instances.length = 0;
});
function sessionMessage(message: object): string {
return JSON.stringify({
type: "session",
message,
});
}
function parseSentSessionMessage(data: string | ArrayBuffer | Uint8Array | undefined): {
type: string;
requestId?: string;
agentId?: string;
workspaceId?: string;
provider?: string;
providers?: string[];
cwd?: string;
config?: unknown;
draftConfig?: unknown;
filter?: unknown;
page?: unknown;
text?: string;
} {
if (typeof data !== "string") {
throw new Error("Expected string WebSocket frame");
}
const parsed = JSON.parse(data);
return parsed.message;
}
function parseSentFrame(
data: string | ArrayBuffer | Uint8Array | undefined,
): Record<string, unknown> {
if (typeof data !== "string") {
throw new Error("Expected string WebSocket frame");
}
return JSON.parse(data);
}
async function connectClient(): Promise<{ client: PaseoClient; ws: FakeWebSocket }> {
vi.stubGlobal("WebSocket", FakeWebSocket);
const client = createPaseoClient({
url: "ws://daemon.test",
reconnect: { enabled: false },
});
const connectPromise = client.connect();
const ws = FakeWebSocket.instances[0];
ws.open();
const hello = parseSentFrame(ws.sent.at(-1));
expect(hello).toMatchObject({
type: "hello",
clientType: "cli",
protocolVersion: 1,
});
expect(hello.clientId).toEqual(expect.stringMatching(/^paseo-sdk-/));
ws.message(
sessionMessage({
type: "status",
payload: {
status: "server_info",
serverId: "srv_sdk_test",
hostname: null,
version: null,
},
}),
);
await connectPromise;
return { client, ws };
}
function createWorkspace(input: Partial<PaseoWorkspace> = {}): PaseoWorkspace {
return {
id: "workspace_sdk",
projectId: "project_sdk",
projectDisplayName: "SDK",
projectRootPath: "/repo/sdk",
workspaceDirectory: "/repo/sdk",
projectKind: "git",
workspaceKind: "directory",
name: "sdk",
archivingAt: null,
status: "done",
activityAt: "2026-05-16T00:00:00.000Z",
scripts: [],
gitRuntime: null,
githubRuntime: null,
...input,
};
}
function createAgent(input: Partial<PaseoAgent> = {}): PaseoAgent {
return {
id: "agent_sdk",
provider: "codex",
cwd: "/repo/sdk",
model: null,
createdAt: "2026-05-16T00:00:00.000Z",
updatedAt: "2026-05-16T00:00:00.000Z",
lastUserMessageAt: null,
status: "idle",
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: false,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsRewindBoth: false,
supportsRewindConversation: false,
supportsRewindFiles: false,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
title: null,
labels: {},
archivedAt: null,
...input,
};
}
test("createPaseoClient exposes workspace list through the daemon client", async () => {
const { client, ws } = await connectClient();
const listPromise = client.workspaces.list({
filter: { query: "sdk" },
page: { limit: 10 },
});
const request = parseSentSessionMessage(ws.sent.at(-1));
expect(request).toMatchObject({
type: "fetch_workspaces_request",
filter: { query: "sdk" },
page: { limit: 10 },
});
ws.message(
sessionMessage({
type: "fetch_workspaces_response",
payload: {
requestId: request.requestId,
entries: [],
pageInfo: {
nextCursor: null,
prevCursor: null,
hasMore: false,
},
},
}),
);
await expect(listPromise).resolves.toEqual({
requestId: request.requestId,
entries: [],
pageInfo: {
nextCursor: null,
prevCursor: null,
hasMore: false,
},
});
expect(client.getConnectionState()).toEqual({ status: "connected" });
await client.close();
});
test("workspace handles keep identity and refresh snapshots through existing driver calls", async () => {
const { client, ws } = await connectClient();
const openedWorkspace = createWorkspace();
const openPromise = client.workspaces.open("/repo/sdk", "open-workspace-request");
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
type: "open_project_request",
cwd: "/repo/sdk",
});
ws.message(
sessionMessage({
type: "open_project_response",
payload: {
requestId: "open-workspace-request",
workspace: openedWorkspace,
error: null,
},
}),
);
const opened = await openPromise;
const workspace = opened.workspace;
expect(workspace?.id).toBe("workspace_sdk");
expect(workspace?.latest()).toEqual(openedWorkspace);
const refreshedWorkspace = createWorkspace({ name: "sdk refreshed" });
const refetchPromise = workspace?.refetch({ requestId: "workspace-refetch-request" });
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
type: "fetch_workspaces_request",
requestId: "workspace-refetch-request",
filter: { idPrefix: "workspace_sdk" },
page: { limit: 25 },
});
ws.message(
sessionMessage({
type: "fetch_workspaces_response",
payload: {
requestId: "workspace-refetch-request",
entries: [refreshedWorkspace],
pageInfo: {
nextCursor: null,
prevCursor: null,
hasMore: false,
},
},
}),
);
await expect(refetchPromise).resolves.toEqual(refreshedWorkspace);
expect(workspace?.latest()).toEqual(refreshedWorkspace);
const updates: string[] = [];
const unsubscribe = workspace?.subscribe((update) => {
if (update.kind === "upsert") {
updates.push(update.workspace.name);
}
});
const pushedWorkspace = createWorkspace({ name: "sdk pushed" });
ws.message(
sessionMessage({
type: "workspace_update",
payload: {
kind: "upsert",
workspace: pushedWorkspace,
},
}),
);
expect(updates).toEqual(["sdk pushed"]);
expect(workspace?.latest()).toEqual(pushedWorkspace);
unsubscribe?.();
ws.message(
sessionMessage({
type: "workspace_update",
payload: {
kind: "upsert",
workspace: createWorkspace({ name: "sdk after unsubscribe" }),
},
}),
);
expect(updates).toEqual(["sdk pushed"]);
await client.close();
});
test("agent handles delegate create, send, timeline refetch, archive, and local updates", async () => {
const { client, ws } = await connectClient();
const createdAgent = createAgent();
const createPromise = client.agents.create({
provider: "codex",
cwd: "/repo/sdk",
initialPrompt: "ship it",
});
const createRequest = parseSentSessionMessage(ws.sent.at(-1));
expect(createRequest).toMatchObject({
type: "create_agent_request",
config: {
provider: "codex",
cwd: "/repo/sdk",
},
initialPrompt: "ship it",
});
ws.message(
sessionMessage({
type: "status",
payload: {
status: "agent_created",
requestId: createRequest.requestId,
agentId: "agent_sdk",
agent: createdAgent,
},
}),
);
const agent = await createPromise;
expect(agent.id).toBe("agent_sdk");
expect(agent.latest()).toEqual(createdAgent);
const updatedAgents: string[] = [];
const unsubscribe = agent.subscribe((update) => {
if (update.kind === "upsert") {
updatedAgents.push(update.agent.title ?? "");
}
});
const updatedAgent = createAgent({ title: "Updated" });
ws.message(
sessionMessage({
type: "agent_update",
payload: {
kind: "upsert",
agent: updatedAgent,
project: null,
},
}),
);
expect(updatedAgents).toEqual(["Updated"]);
expect(agent.latest()).toEqual(updatedAgent);
const sendPromise = agent.send("hello", { messageId: "message-sdk" });
const sendRequest = parseSentSessionMessage(ws.sent.at(-1));
expect(sendRequest).toMatchObject({
type: "send_agent_message_request",
agentId: "agent_sdk",
text: "hello",
messageId: "message-sdk",
});
ws.message(
sessionMessage({
type: "send_agent_message_response",
payload: {
requestId: sendRequest.requestId,
agentId: "agent_sdk",
accepted: true,
error: null,
},
}),
);
await sendPromise;
const timelineAgent = createAgent({ title: "Timeline" });
const timelinePromise = agent.timeline.refetch({ limit: 5 });
const timelineRequest = parseSentSessionMessage(ws.sent.at(-1));
expect(timelineRequest).toMatchObject({
type: "fetch_agent_timeline_request",
agentId: "agent_sdk",
limit: 5,
});
ws.message(
sessionMessage({
type: "fetch_agent_timeline_response",
payload: {
requestId: timelineRequest.requestId,
agentId: "agent_sdk",
agent: timelineAgent,
direction: "tail",
projection: "projected",
epoch: "epoch-sdk",
reset: false,
staleCursor: false,
gap: false,
window: {
minSeq: 0,
maxSeq: 0,
nextSeq: 0,
},
startCursor: null,
endCursor: null,
hasOlder: false,
hasNewer: false,
entries: [],
error: null,
},
}),
);
await timelinePromise;
expect(agent.latest()).toEqual(timelineAgent);
const archivePromise = agent.archive();
const archiveRequest = parseSentSessionMessage(ws.sent.at(-1));
expect(archiveRequest).toMatchObject({
type: "archive_agent_request",
agentId: "agent_sdk",
});
ws.message(
sessionMessage({
type: "agent_archived",
payload: {
requestId: archiveRequest.requestId,
agentId: "agent_sdk",
archivedAt: "2026-05-16T01:00:00.000Z",
},
}),
);
await expect(archivePromise).resolves.toEqual({
archivedAt: "2026-05-16T01:00:00.000Z",
});
expect(agent.latest()?.archivedAt).toBe("2026-05-16T01:00:00.000Z");
unsubscribe();
await client.close();
});
test("provider actions delegate to existing provider RPCs and local snapshot updates", async () => {
const { client, ws } = await connectClient();
const modelsPromise = client.providers.listModels("codex", {
cwd: "/repo/sdk",
requestId: "provider-models-request",
});
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
type: "list_provider_models_request",
requestId: "provider-models-request",
provider: "codex",
cwd: "/repo/sdk",
});
ws.message(
sessionMessage({
type: "list_provider_models_response",
payload: {
requestId: "provider-models-request",
provider: "codex",
models: [{ provider: "codex", id: "gpt-5.4", label: "GPT-5.4" }],
error: null,
fetchedAt: "2026-05-16T00:00:00.000Z",
},
}),
);
await expect(modelsPromise).resolves.toMatchObject({
provider: "codex",
models: [{ provider: "codex", id: "gpt-5.4", label: "GPT-5.4" }],
});
const modesPromise = client.providers.listModes("codex", {
cwd: "/repo/sdk",
requestId: "provider-modes-request",
});
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
type: "list_provider_modes_request",
requestId: "provider-modes-request",
provider: "codex",
cwd: "/repo/sdk",
});
ws.message(
sessionMessage({
type: "list_provider_modes_response",
payload: {
requestId: "provider-modes-request",
provider: "codex",
modes: [{ id: "full-access", label: "Full Access" }],
error: null,
fetchedAt: "2026-05-16T00:00:00.000Z",
},
}),
);
await expect(modesPromise).resolves.toMatchObject({
provider: "codex",
modes: [{ id: "full-access", label: "Full Access" }],
});
const featuresPromise = client.providers.listFeatures(
{
provider: "codex",
cwd: "/repo/sdk",
modeId: "full-access",
model: "gpt-5.4",
thinkingOptionId: "high",
featureValues: { webSearch: true },
},
{ requestId: "provider-features-request" },
);
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
type: "list_provider_features_request",
requestId: "provider-features-request",
draftConfig: {
provider: "codex",
cwd: "/repo/sdk",
modeId: "full-access",
model: "gpt-5.4",
thinkingOptionId: "high",
featureValues: { webSearch: true },
},
});
ws.message(
sessionMessage({
type: "list_provider_features_response",
payload: {
requestId: "provider-features-request",
provider: "codex",
features: [{ type: "toggle", id: "webSearch", label: "Web Search", value: true }],
error: null,
fetchedAt: "2026-05-16T00:00:00.000Z",
},
}),
);
await expect(featuresPromise).resolves.toMatchObject({
provider: "codex",
features: [{ type: "toggle", id: "webSearch", label: "Web Search", value: true }],
});
const availablePromise = client.providers.listAvailable({
requestId: "providers-available-request",
});
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
type: "list_available_providers_request",
requestId: "providers-available-request",
});
ws.message(
sessionMessage({
type: "list_available_providers_response",
payload: {
requestId: "providers-available-request",
providers: [{ provider: "codex", available: true, error: null }],
error: null,
fetchedAt: "2026-05-16T00:00:00.000Z",
},
}),
);
await expect(availablePromise).resolves.toMatchObject({
providers: [{ provider: "codex", available: true, error: null }],
});
const snapshotPromise = client.providers.snapshot({
cwd: "/repo/sdk",
requestId: "providers-snapshot-request",
});
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
type: "get_providers_snapshot_request",
requestId: "providers-snapshot-request",
cwd: "/repo/sdk",
});
ws.message(
sessionMessage({
type: "get_providers_snapshot_response",
payload: {
requestId: "providers-snapshot-request",
entries: [{ provider: "codex", status: "ready", enabled: true }],
generatedAt: "2026-05-16T00:00:00.000Z",
},
}),
);
await expect(snapshotPromise).resolves.toMatchObject({
entries: [{ provider: "codex", status: "ready", enabled: true }],
});
const refreshPromise = client.providers.refresh({
cwd: "/repo/sdk",
providers: ["codex"],
requestId: "providers-refresh-request",
});
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
type: "refresh_providers_snapshot_request",
requestId: "providers-refresh-request",
cwd: "/repo/sdk",
providers: ["codex"],
});
ws.message(
sessionMessage({
type: "refresh_providers_snapshot_response",
payload: {
requestId: "providers-refresh-request",
acknowledged: true,
},
}),
);
await expect(refreshPromise).resolves.toEqual({
requestId: "providers-refresh-request",
acknowledged: true,
});
const diagnosticPromise = client.providers.diagnostic("codex", {
requestId: "provider-diagnostic-request",
});
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
type: "provider_diagnostic_request",
requestId: "provider-diagnostic-request",
provider: "codex",
});
ws.message(
sessionMessage({
type: "provider_diagnostic_response",
payload: {
requestId: "provider-diagnostic-request",
provider: "codex",
diagnostic: "Codex is ready.",
},
}),
);
await expect(diagnosticPromise).resolves.toEqual({
requestId: "provider-diagnostic-request",
provider: "codex",
diagnostic: "Codex is ready.",
});
const snapshotUpdates: string[] = [];
const unsubscribe = client.providers.subscribe((update) => {
snapshotUpdates.push(update.generatedAt);
});
ws.message(
sessionMessage({
type: "providers_snapshot_update",
payload: {
cwd: "/repo/sdk",
entries: [{ provider: "codex", status: "ready", enabled: true }],
generatedAt: "2026-05-16T01:00:00.000Z",
},
}),
);
expect(snapshotUpdates).toEqual(["2026-05-16T01:00:00.000Z"]);
unsubscribe();
await client.close();
});
test("config actions delegate to existing daemon config RPCs", async () => {
const { client, ws } = await connectClient();
const getPromise = client.config.get("config-get-request");
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
type: "get_daemon_config_request",
requestId: "config-get-request",
});
ws.message(
sessionMessage({
type: "get_daemon_config_response",
payload: {
requestId: "config-get-request",
config: {
mcp: { injectIntoAgents: true },
providers: {},
autoArchiveAfterMerge: false,
},
},
}),
);
await expect(getPromise).resolves.toEqual({
requestId: "config-get-request",
config: {
mcp: { injectIntoAgents: true },
providers: {},
autoArchiveAfterMerge: false,
appendSystemPrompt: "",
},
});
const patchPromise = client.config.patch(
{
providers: {
codex: {
enabled: false,
},
},
},
"config-patch-request",
);
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
type: "set_daemon_config_request",
requestId: "config-patch-request",
config: {
providers: {
codex: {
enabled: false,
},
},
},
});
ws.message(
sessionMessage({
type: "set_daemon_config_response",
payload: {
requestId: "config-patch-request",
config: {
mcp: { injectIntoAgents: true },
providers: {
codex: {
enabled: false,
},
},
autoArchiveAfterMerge: false,
},
},
}),
);
await expect(patchPromise).resolves.toEqual({
requestId: "config-patch-request",
config: {
mcp: { injectIntoAgents: true },
providers: {
codex: {
enabled: false,
},
},
autoArchiveAfterMerge: false,
appendSystemPrompt: "",
},
});
await client.close();
});
test("provider config builders shape existing create-agent config fields", async () => {
const { client, ws } = await connectClient();
const provider = client.providers.codex({
model: "gpt-5.4",
modeId: "full-access",
thinkingOptionId: "high",
featureValues: { webSearch: true },
});
const expectedProviderConfig = {
provider: "codex",
model: "gpt-5.4",
modeId: "full-access",
thinkingOptionId: "high",
featureValues: { webSearch: true },
} satisfies PaseoProviderConfig;
expect(provider).toEqual(expectedProviderConfig);
const createdAgent = createAgent({
model: "gpt-5.4",
currentModeId: "full-access",
});
const createPromise = client.agents.create({
config: {
...provider,
cwd: "/repo/sdk",
},
initialPrompt: "use configured provider",
});
const request = parseSentSessionMessage(ws.sent.at(-1));
expect(request).toMatchObject({
type: "create_agent_request",
config: {
provider: "codex",
cwd: "/repo/sdk",
model: "gpt-5.4",
modeId: "full-access",
thinkingOptionId: "high",
featureValues: { webSearch: true },
},
initialPrompt: "use configured provider",
});
ws.message(
sessionMessage({
type: "status",
payload: {
status: "agent_created",
requestId: request.requestId,
agentId: "agent_sdk",
agent: createdAgent,
},
}),
);
const agent = await createPromise;
expect(agent.latest()).toEqual(createdAgent);
await client.close();
});
+539
View File
@@ -0,0 +1,539 @@
import type {
AgentSnapshotPayload,
CreateAgentRequestMessage,
FetchWorkspacesRequestMessage,
FetchWorkspacesResponseMessage,
GetProvidersSnapshotResponseMessage,
ListAvailableProvidersResponse,
ListProviderFeaturesRequestMessage,
ListProviderFeaturesResponseMessage,
ListProviderModelsResponseMessage,
ListProviderModesResponseMessage,
MutableDaemonConfig,
MutableDaemonConfigPatch,
ProviderDiagnosticResponseMessage,
ProjectPlacementPayload,
RefreshProvidersSnapshotResponseMessage,
SendAgentMessageRequest,
SessionOutboundMessage,
WorkspaceDescriptorPayload,
} from "@getpaseo/protocol/messages";
import { DaemonClient } from "./daemon-client.js";
import type {
FetchAgentTimelineCursor,
FetchAgentTimelineDirection,
FetchAgentTimelinePayload,
FetchAgentTimelineProjection,
} from "./daemon-client.js";
export { DaemonClient };
export type {
DaemonClientConfig,
DaemonEvent,
WebSocketFactory,
WebSocketLike,
} from "./daemon-client.js";
export type ConnectionState =
| { status: "idle" }
| { status: "connecting"; attempt: number }
| { status: "connected" }
| { status: "disconnected"; reason?: string }
| { status: "disposed" };
export interface PaseoLogger {
debug(obj: object, msg?: string): void;
info(obj: object, msg?: string): void;
warn(obj: object, msg?: string): void;
error(obj: object, msg?: string): void;
}
export interface PaseoClientConfig {
url: string;
clientId?: string;
appVersion?: string;
runtimeGeneration?: number | null;
password?: string;
authHeader?: string;
suppressSendErrors?: boolean;
logger?: PaseoLogger;
connectTimeoutMs?: number;
e2ee?: {
enabled?: boolean;
daemonPublicKeyB64?: string;
};
reconnect?: {
enabled?: boolean;
baseDelayMs?: number;
maxDelayMs?: number;
};
runtimeMetricsIntervalMs?: number;
runtimeMetricsWindowMs?: number;
}
export type PaseoWorkspace = WorkspaceDescriptorPayload;
export type PaseoAgent = AgentSnapshotPayload;
export type PaseoWorkspaceListOptions = Omit<
FetchWorkspacesRequestMessage,
"type" | "requestId"
> & {
requestId?: string;
};
export interface PaseoWorkspaceListResult {
requestId: string;
subscriptionId?: string | null;
entries: PaseoWorkspace[];
pageInfo: FetchWorkspacesResponseMessage["payload"]["pageInfo"];
}
export interface PaseoWorkspaceOpenOptions {
cwd: string;
requestId?: string;
}
export interface PaseoWorkspaceOpenResult {
requestId: string;
workspace: PaseoWorkspaceHandle | null;
error: string | null;
}
export interface PaseoWorkspaceArchiveResult {
requestId: string;
workspaceId: string;
archivedAt: string | null;
error: string | null;
}
export type PaseoWorkspaceUpdate = Extract<
SessionOutboundMessage,
{ type: "workspace_update" }
>["payload"];
export type PaseoWorkspaceUpdateHandler = (update: PaseoWorkspaceUpdate) => void;
/**
* A handle is a stable typed reference to a daemon resource. Its identity is the
* daemon id, and `latest()` only returns the most recent snapshot this handle has
* seen through construction, `refetch()`, or this handle's local subscription.
*/
export interface PaseoWorkspaceHandle {
readonly id: string;
latest(): PaseoWorkspace | null;
/**
* Fetches a fresh workspace snapshot through the existing workspace list RPC,
* exact-matches this handle id from the result, and updates `latest()`.
*/
refetch(options?: { requestId?: string }): Promise<PaseoWorkspace | null>;
archive(requestId?: string): Promise<PaseoWorkspaceArchiveResult>;
/**
* Subscribes to already-emitted daemon workspace_update events for this id.
* This returns a local unsubscribe function; it does not own app cache state or
* send a daemon unsubscribe RPC. Call `workspaces.list({ subscribe: {} })` when
* the daemon should start streaming workspace directory updates.
*/
subscribe(handler: (update: PaseoWorkspaceUpdate) => void): () => void;
}
export interface PaseoWorkspaceActions {
list(options?: PaseoWorkspaceListOptions): Promise<PaseoWorkspaceListResult>;
ref(workspace: string | PaseoWorkspace): PaseoWorkspaceHandle;
open(
input: string | PaseoWorkspaceOpenOptions,
requestId?: string,
): Promise<PaseoWorkspaceOpenResult>;
create(
input: string | PaseoWorkspaceOpenOptions,
requestId?: string,
): Promise<PaseoWorkspaceOpenResult>;
archive(
workspace: string | PaseoWorkspaceHandle,
requestId?: string,
): Promise<PaseoWorkspaceArchiveResult>;
/**
* Local event subscription over the low-level driver's workspace_update stream.
* The returned function only removes this SDK listener.
*/
subscribe(handler: PaseoWorkspaceUpdateHandler): () => void;
}
type PaseoAgentSessionConfig = CreateAgentRequestMessage["config"];
type PaseoAgentProvider = PaseoAgentSessionConfig["provider"];
type PaseoAgentConfigOverrides = Partial<Omit<PaseoAgentSessionConfig, "provider" | "cwd">>;
export interface PaseoAgentCreateOptions extends PaseoAgentConfigOverrides {
config?: PaseoAgentSessionConfig;
provider?: CreateAgentRequestMessage["config"]["provider"];
cwd?: string;
workspaceId?: string;
initialPrompt?: string;
clientMessageId?: string;
outputSchema?: Record<string, unknown>;
images?: CreateAgentRequestMessage["images"];
attachments?: CreateAgentRequestMessage["attachments"];
git?: CreateAgentRequestMessage["git"];
worktreeName?: string;
requestId?: string;
labels?: Record<string, string>;
}
export interface PaseoAgentRefetchResult {
agent: PaseoAgent;
project: ProjectPlacementPayload | null;
}
export interface PaseoAgentTimelineRefetchOptions {
direction?: FetchAgentTimelineDirection;
cursor?: FetchAgentTimelineCursor;
limit?: number;
projection?: FetchAgentTimelineProjection;
requestId?: string;
}
export interface PaseoAgentSendOptions {
messageId?: string;
images?: Array<{ data: string; mimeType: string }>;
attachments?: SendAgentMessageRequest["attachments"];
}
export type PaseoAgentUpdate = Extract<SessionOutboundMessage, { type: "agent_update" }>["payload"];
export type PaseoAgentStream = Extract<SessionOutboundMessage, { type: "agent_stream" }>["payload"];
export type PaseoAgentUpdateHandler = (update: PaseoAgentUpdate) => void;
export interface PaseoAgentTimelineHandle {
/**
* Fetches a fresh timeline page through the existing daemon RPC. If the daemon
* includes an agent snapshot in the response, the parent handle's `latest()`
* is updated to that snapshot.
*/
refetch(options?: PaseoAgentTimelineRefetchOptions): Promise<FetchAgentTimelinePayload>;
/**
* Local listener for agent_stream events matching this handle id. It does not
* retain timeline entries or own application cache state.
*/
subscribe(handler: (event: PaseoAgentStream) => void): () => void;
}
/**
* Agent handles follow the same identity/snapshot rule as workspace handles:
* `id` is stable, while `latest()` is only the newest snapshot observed by this
* handle through construction, `refetch()`, timeline refetch, archive, or local
* agent_update subscription.
*/
export interface PaseoAgentHandle {
readonly id: string;
readonly timeline: PaseoAgentTimelineHandle;
latest(): PaseoAgent | null;
refetch(requestId?: string): Promise<PaseoAgentRefetchResult | null>;
send(text: string, options?: PaseoAgentSendOptions): Promise<void>;
archive(): Promise<{ archivedAt: string }>;
subscribe(handler: (update: PaseoAgentUpdate) => void): () => void;
}
export interface PaseoAgentActions {
ref(agent: string | PaseoAgent): PaseoAgentHandle;
create(options: PaseoAgentCreateOptions): Promise<PaseoAgentHandle>;
/**
* Local event subscription over the low-level driver's agent_update stream.
* The returned function only removes this SDK listener.
*/
subscribe(handler: PaseoAgentUpdateHandler): () => void;
}
export interface PaseoProviderConfig extends PaseoProviderConfigInput {
provider: PaseoAgentProvider;
}
export type PaseoProviderFeatureValues = Record<string, unknown>;
export interface PaseoProviderConfigInput {
model?: string;
modeId?: string;
thinkingOptionId?: string;
featureValues?: PaseoProviderFeatureValues;
}
export type PaseoProviderModelsResult = ListProviderModelsResponseMessage["payload"];
export type PaseoProviderModesResult = ListProviderModesResponseMessage["payload"];
export type PaseoProviderFeaturesInput = ListProviderFeaturesRequestMessage["draftConfig"];
export type PaseoProviderFeaturesResult = ListProviderFeaturesResponseMessage["payload"];
export type PaseoProviderAvailabilityResult = ListAvailableProvidersResponse["payload"];
export type PaseoProviderSnapshotResult = GetProvidersSnapshotResponseMessage["payload"];
export type PaseoProviderSnapshotUpdate = Extract<
SessionOutboundMessage,
{ type: "providers_snapshot_update" }
>["payload"];
export type PaseoProviderRefreshResult = RefreshProvidersSnapshotResponseMessage["payload"];
export type PaseoProviderDiagnosticResult = ProviderDiagnosticResponseMessage["payload"];
export interface PaseoProviderListOptions {
cwd?: string;
requestId?: string;
}
export interface PaseoProviderRefreshOptions {
cwd?: string;
providers?: PaseoAgentProvider[];
requestId?: string;
}
export interface PaseoProviderActions {
codex(input?: PaseoProviderConfigInput): PaseoProviderConfig;
claude(input?: PaseoProviderConfigInput): PaseoProviderConfig;
opencode(input?: PaseoProviderConfigInput): PaseoProviderConfig;
copilot(input?: PaseoProviderConfigInput): PaseoProviderConfig;
config(provider: PaseoAgentProvider, input?: PaseoProviderConfigInput): PaseoProviderConfig;
listModels(
provider: PaseoAgentProvider,
options?: PaseoProviderListOptions,
): Promise<PaseoProviderModelsResult>;
listModes(
provider: PaseoAgentProvider,
options?: PaseoProviderListOptions,
): Promise<PaseoProviderModesResult>;
listFeatures(
draftConfig: PaseoProviderFeaturesInput,
options?: { requestId?: string },
): Promise<PaseoProviderFeaturesResult>;
listAvailable(options?: { requestId?: string }): Promise<PaseoProviderAvailabilityResult>;
snapshot(options?: PaseoProviderListOptions): Promise<PaseoProviderSnapshotResult>;
refresh(options?: PaseoProviderRefreshOptions): Promise<PaseoProviderRefreshResult>;
diagnostic(
provider: PaseoAgentProvider,
options?: { requestId?: string },
): Promise<PaseoProviderDiagnosticResult>;
subscribe(handler: (update: PaseoProviderSnapshotUpdate) => void): () => void;
}
export interface PaseoConfigActions {
/**
* Reads daemon config through the existing config RPC. Provider profiles,
* custom provider entries, keys/env, custom binaries, and provider enablement
* are currently config-file-shaped daemon state, so the SDK exposes this raw
* typed surface instead of pretending there are higher-level provider-settings
* RPCs.
*/
get(requestId?: string): Promise<{ requestId: string; config: MutableDaemonConfig }>;
/**
* Patches daemon config through the existing config RPC. The daemon validates
* and persists supported fields; unsupported provider/settings workflows remain
* daemon gaps until first-class RPCs exist.
*/
patch(
config: MutableDaemonConfigPatch,
requestId?: string,
): Promise<{ requestId: string; config: MutableDaemonConfig }>;
}
export interface PaseoClient {
readonly workspaces: PaseoWorkspaceActions;
readonly agents: PaseoAgentActions;
readonly providers: PaseoProviderActions;
readonly config: PaseoConfigActions;
connect(): Promise<void>;
close(): Promise<void>;
ensureConnected(): void;
getConnectionState(): ConnectionState;
}
export function createPaseoClient(config: PaseoClientConfig): PaseoClient {
const daemonClient = new DaemonClient({
...config,
clientId: config.clientId ?? createGeneratedClientId(),
clientType: "cli",
});
const createWorkspaceHandle = createWorkspaceHandleFactory(daemonClient);
const createAgentHandle = createAgentHandleFactory(daemonClient);
return {
workspaces: {
list: (options) => daemonClient.fetchWorkspaces(options),
ref: (workspace) => createWorkspaceHandle(workspace),
open: (input, requestId) =>
openWorkspace(daemonClient, createWorkspaceHandle, input, requestId),
create: (input, requestId) =>
openWorkspace(daemonClient, createWorkspaceHandle, input, requestId),
archive: (workspace, requestId) =>
daemonClient.archiveWorkspace(resolveWorkspaceId(workspace), requestId),
subscribe: (handler) =>
daemonClient.on("workspace_update", (message) => {
handler(message.payload);
}),
},
agents: {
ref: (agent) => createAgentHandle(agent),
create: async (options) => {
const agent = await daemonClient.createAgent(options);
return createAgentHandle(agent);
},
subscribe: (handler) =>
daemonClient.on("agent_update", (message) => {
handler(message.payload);
}),
},
providers: {
codex: (input) => providerConfig("codex", input),
claude: (input) => providerConfig("claude", input),
opencode: (input) => providerConfig("opencode", input),
copilot: (input) => providerConfig("copilot", input),
config: (provider, input) => providerConfig(provider, input),
listModels: (provider, options) => daemonClient.listProviderModels(provider, options),
listModes: (provider, options) => daemonClient.listProviderModes(provider, options),
listFeatures: (draftConfig, options) =>
daemonClient.listProviderFeatures(draftConfig, options),
listAvailable: (options) => daemonClient.listAvailableProviders(options),
snapshot: (options) => daemonClient.getProvidersSnapshot(options),
refresh: (options) => daemonClient.refreshProvidersSnapshot(options),
diagnostic: (provider, options) => daemonClient.getProviderDiagnostic(provider, options),
subscribe: (handler) =>
daemonClient.on("providers_snapshot_update", (message) => {
handler(message.payload);
}),
},
config: {
get: (requestId) => daemonClient.getDaemonConfig(requestId),
patch: (patch, requestId) => daemonClient.patchDaemonConfig(patch, requestId),
},
connect: () => daemonClient.connect(),
close: () => daemonClient.close(),
ensureConnected: () => daemonClient.ensureConnected(),
getConnectionState: () => daemonClient.getConnectionState(),
};
}
type WorkspaceHandleFactory = (workspace: string | PaseoWorkspace) => PaseoWorkspaceHandle;
type AgentHandleFactory = (agent: string | PaseoAgent) => PaseoAgentHandle;
function createWorkspaceHandleFactory(daemonClient: DaemonClient): WorkspaceHandleFactory {
return (workspace) => {
const id = typeof workspace === "string" ? workspace : workspace.id;
let latest = typeof workspace === "string" ? null : workspace;
return {
id,
latest: () => latest,
refetch: async (options) => {
const result = await daemonClient.fetchWorkspaces({
requestId: options?.requestId,
filter: { idPrefix: id },
page: { limit: 25 },
});
latest = result.entries.find((entry) => entry.id === id) ?? null;
return latest;
},
archive: async (requestId) => {
const result = await daemonClient.archiveWorkspace(id, requestId);
if (latest) {
latest = { ...latest, archivingAt: result.archivedAt };
}
return result;
},
subscribe: (handler) =>
daemonClient.on("workspace_update", (message) => {
const update = message.payload;
if (update.kind === "upsert" && update.workspace.id === id) {
latest = update.workspace;
handler(update);
}
if (update.kind === "remove" && update.id === id) {
latest = null;
handler(update);
}
}),
};
};
}
function createAgentHandleFactory(daemonClient: DaemonClient): AgentHandleFactory {
return (agent) => {
const id = typeof agent === "string" ? agent : agent.id;
let latest = typeof agent === "string" ? null : agent;
const handle: PaseoAgentHandle = {
id,
timeline: {
refetch: async (options) => {
const result = await daemonClient.fetchAgentTimeline(id, options);
if (result.agent) {
latest = result.agent;
}
return result;
},
subscribe: (handler) =>
daemonClient.on("agent_stream", (message) => {
if (message.payload.agentId === id) {
handler(message.payload);
}
}),
},
latest: () => latest,
refetch: async (requestId) => {
const result = await daemonClient.fetchAgent(id, requestId);
latest = result?.agent ?? null;
return result;
},
send: (text, options) => daemonClient.sendAgentMessage(id, text, options),
archive: async () => {
const result = await daemonClient.archiveAgent(id);
if (latest) {
latest = { ...latest, archivedAt: result.archivedAt };
}
return result;
},
subscribe: (handler) =>
daemonClient.on("agent_update", (message) => {
const update = message.payload;
if (update.kind === "upsert" && update.agent.id === id) {
latest = update.agent;
handler(update);
}
if (update.kind === "remove" && update.agentId === id) {
latest = null;
handler(update);
}
}),
};
return handle;
};
}
async function openWorkspace(
daemonClient: DaemonClient,
createWorkspaceHandle: WorkspaceHandleFactory,
input: string | PaseoWorkspaceOpenOptions,
requestId?: string,
): Promise<PaseoWorkspaceOpenResult> {
const options = typeof input === "string" ? { cwd: input, requestId } : input;
const result = await daemonClient.openProject(options.cwd, options.requestId);
return {
...result,
workspace: result.workspace ? createWorkspaceHandle(result.workspace) : null,
};
}
function resolveWorkspaceId(workspace: string | PaseoWorkspaceHandle): string {
return typeof workspace === "string" ? workspace : workspace.id;
}
function providerConfig(
provider: PaseoAgentProvider,
input: PaseoProviderConfigInput = {},
): PaseoProviderConfig {
return {
provider,
...(input.model !== undefined ? { model: input.model } : {}),
...(input.modeId !== undefined ? { modeId: input.modeId } : {}),
...(input.thinkingOptionId !== undefined ? { thinkingOptionId: input.thinkingOptionId } : {}),
...(input.featureValues !== undefined ? { featureValues: input.featureValues } : {}),
};
}
function createGeneratedClientId(): string {
const randomId =
typeof globalThis.crypto?.randomUUID === "function"
? globalThis.crypto.randomUUID()
: Math.random().toString(36).slice(2);
return `paseo-sdk-${randomId}`;
}
@@ -0,0 +1,28 @@
import { TerminalStreamOpcode } from "@getpaseo/protocol/binary-frames/index";
import { describe, expect, test } from "vitest";
import { TerminalStreamRouter, type TerminalStreamEvent } from "./terminal-stream-router.js";
describe("terminal-stream-router", () => {
test("routes restore frames as restore events", () => {
const router = new TerminalStreamRouter();
const events: TerminalStreamEvent[] = [];
const payload = new TextEncoder().encode("restored screen");
router.setSlot("term-1", 7);
router.onEvent((event) => events.push(event));
router.handleFrame({
opcode: TerminalStreamOpcode.Restore,
slot: 7,
payload,
});
expect(events).toEqual([
{
terminalId: "term-1",
type: "restore",
data: payload,
},
]);
});
});
@@ -0,0 +1,132 @@
import {
decodeTerminalSnapshotPayload,
encodeTerminalResizePayload,
encodeTerminalStreamFrame,
TerminalStreamOpcode,
type TerminalStreamFrame,
} from "@getpaseo/protocol/binary-frames/index";
import type { TerminalInput, TerminalState } from "@getpaseo/protocol/messages";
export type TerminalStreamEvent =
| { terminalId: string; type: "output"; data: Uint8Array }
| { terminalId: string; type: "snapshot"; state: TerminalState }
| { terminalId: string; type: "restore"; data: Uint8Array };
export class TerminalStreamRouter {
private readonly terminalSlots = new Map<string, number>();
private readonly slotTerminals = new Map<number, string>();
private readonly listeners = new Set<(event: TerminalStreamEvent) => void>();
onEvent(handler: (event: TerminalStreamEvent) => void): () => void {
this.listeners.add(handler);
return () => {
this.listeners.delete(handler);
};
}
setSlot(terminalId: string, slot: number): void {
const existingTerminalId = this.slotTerminals.get(slot);
if (existingTerminalId && existingTerminalId !== terminalId) {
this.terminalSlots.delete(existingTerminalId);
}
const existingSlot = this.terminalSlots.get(terminalId);
if (typeof existingSlot === "number" && existingSlot !== slot) {
this.slotTerminals.delete(existingSlot);
}
this.terminalSlots.set(terminalId, slot);
this.slotTerminals.set(slot, terminalId);
}
removeTerminal(terminalId: string): void {
const slot = this.terminalSlots.get(terminalId);
if (typeof slot !== "number") {
return;
}
this.terminalSlots.delete(terminalId);
if (this.slotTerminals.get(slot) === terminalId) {
this.slotTerminals.delete(slot);
}
}
clearSlots(): void {
this.terminalSlots.clear();
this.slotTerminals.clear();
}
encodeInput(terminalId: string, message: TerminalInput["message"]): Uint8Array | null {
const slot = this.terminalSlots.get(terminalId);
if (typeof slot !== "number") {
return null;
}
if (message.type === "input") {
return encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Input,
slot,
payload: message.data,
});
}
if (message.type === "resize") {
return encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Resize,
slot,
payload: encodeTerminalResizePayload({
rows: message.rows,
cols: message.cols,
}),
});
}
return null;
}
handleFrame(frame: TerminalStreamFrame): void {
const terminalId = this.slotTerminals.get(frame.slot);
if (!terminalId) {
return;
}
if (frame.opcode === TerminalStreamOpcode.Output) {
this.emit({
terminalId,
type: "output",
data: frame.payload,
});
return;
}
if (frame.opcode === TerminalStreamOpcode.Restore) {
this.emit({
terminalId,
type: "restore",
data: frame.payload,
});
return;
}
if (frame.opcode === TerminalStreamOpcode.Snapshot) {
const state = decodeTerminalSnapshotPayload(frame.payload);
if (!state) {
return;
}
this.emit({
terminalId,
type: "snapshot",
state,
});
}
}
private emit(event: TerminalStreamEvent): void {
for (const listener of this.listeners) {
try {
listener(event);
} catch {
// no-op
}
}
}
}