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
+12
View File
@@ -0,0 +1,12 @@
# @getpaseo/client
Paseo's JavaScript/TypeScript client package.
## Stability
This package is public so Paseo's published packages can depend on it cleanly.
It is not a stable public SDK yet.
APIs, exports, runtime behavior, and types may change or disappear in any
release without advance notice. Use it outside Paseo at your own risk until the
package is explicitly documented as stable.
+18
View File
@@ -0,0 +1,18 @@
# Paseo Client SDK Examples
These examples use only the public SDK root:
```ts
import { createPaseoClient, type PaseoClient } from "@getpaseo/client";
```
Pass the daemon WebSocket URL into the exported functions. In worktree dev, read it
from the portless banner or `portless get daemon`; for the desktop-managed daemon,
use the URL for that daemon.
- `workspaces.ts` covers creating, opening, refetching, and archiving a workspace.
- `agents-and-providers.ts` covers creating agents and choosing providers with `client.providers.*`.
- `events-and-timeline.ts` covers subscribing to workspace, agent, and timeline events, plus refetching a timeline page.
- `provider-settings.ts` covers provider settings that are currently daemon config-backed.
Provider profiles, provider env vars, custom binaries, and additional models are still raw daemon config behavior. The SDK exposes them through `client.config.get()` and `client.config.patch()` until first-class provider settings RPCs exist.
@@ -0,0 +1,56 @@
import { createPaseoClient, type PaseoClient } from "@getpaseo/client";
export function createClient(url: string): PaseoClient {
return createPaseoClient({
url,
});
}
export async function createCodexAgent(url: string, cwd: string): Promise<string> {
const client = createClient(url);
try {
await client.connect();
const agent = await client.agents.create({
config: {
...client.providers.codex({
model: "gpt-5.2",
modeId: "full-auto",
}),
cwd,
},
initialPrompt: "Inspect this repository and summarize the next useful task.",
});
return agent.id;
} finally {
await client.close();
}
}
export async function chooseProviderFromSnapshot(url: string, cwd: string): Promise<string> {
const client = createClient(url);
try {
await client.connect();
const snapshot = await client.providers.snapshot({ cwd });
const readyProvider = snapshot.entries.find((provider) => provider.status === "ready");
const providerConfig = readyProvider
? client.providers.config(readyProvider.provider)
: client.providers.claude();
const agent = await client.agents.create({
config: {
...providerConfig,
cwd,
},
initialPrompt: "Start with a quick repository map.",
});
return agent.id;
} finally {
await client.close();
}
}
@@ -0,0 +1,62 @@
import { createPaseoClient, type PaseoClient } from "@getpaseo/client";
export function createClient(url: string): PaseoClient {
return createPaseoClient({
url,
});
}
export async function subscribeToEvents(
url: string,
agentId: string,
workspaceId: string,
): Promise<() => void> {
const client = createClient(url);
await client.connect();
await client.workspaces.list({
filter: { idPrefix: workspaceId },
subscribe: { subscriptionId: `workspace-${workspaceId}` },
});
const unsubscribeAgentUpdates = client.agents.subscribe((update) => {
if (update.kind === "upsert" && update.agent.id === agentId) {
void update.agent.status;
}
});
const unsubscribeWorkspaceUpdates = client.workspaces.subscribe((update) => {
if (update.kind === "upsert" && update.workspace.id === workspaceId) {
void update.workspace.workspaceDirectory;
}
});
const unsubscribeTimeline = client.agents.ref(agentId).timeline.subscribe((event) => {
void event.event;
});
return () => {
unsubscribeTimeline();
unsubscribeWorkspaceUpdates();
unsubscribeAgentUpdates();
void client.close();
};
}
export async function refetchTimeline(url: string, agentId: string): Promise<number> {
const client = createClient(url);
try {
await client.connect();
const timeline = await client.agents.ref(agentId).timeline.refetch({
direction: "before",
limit: 50,
projection: "projected",
});
return timeline.entries.length;
} finally {
await client.close();
}
}
@@ -0,0 +1,62 @@
import { createPaseoClient, type PaseoClient } from "@getpaseo/client";
export function createClient(url: string): PaseoClient {
return createPaseoClient({
url,
});
}
export async function setSupportedProviderSettings(url: string): Promise<void> {
const client = createClient(url);
try {
await client.connect();
await client.config.patch({
providers: {
codex: {
enabled: true,
additionalModels: [
{
id: "gpt-5.2",
label: "GPT-5.2",
isDefault: true,
},
],
},
},
});
} finally {
await client.close();
}
}
export async function addCustomCodexProfile(url: string): Promise<void> {
const client = createClient(url);
try {
await client.connect();
await client.config.patch({
providers: {
"codex-proxy": {
extends: "codex",
label: "Codex Proxy",
env: {
OPENAI_API_KEY: "sk-...",
OPENAI_BASE_URL: "https://example.test/v1",
},
models: [
{
id: "custom-model",
label: "Custom Model",
isDefault: true,
},
],
},
},
});
} finally {
await client.close();
}
}
+30
View File
@@ -0,0 +1,30 @@
import { createPaseoClient, type PaseoClient } from "@getpaseo/client";
export function createClient(url: string): PaseoClient {
return createPaseoClient({
url,
});
}
export async function createOpenAndArchiveWorkspace(url: string, cwd: string): Promise<void> {
const client = createClient(url);
try {
await client.connect();
const created = await client.workspaces.create(cwd);
if (!created.workspace) {
throw new Error(created.error ?? "Workspace creation failed");
}
const opened = await client.workspaces.open(cwd);
if (!opened.workspace) {
throw new Error(opened.error ?? "Workspace open failed");
}
await opened.workspace.refetch();
await opened.workspace.archive();
} finally {
await client.close();
}
}
+44
View File
@@ -0,0 +1,44 @@
{
"name": "@getpaseo/client",
"version": "0.1.83",
"description": "Paseo client SDK package",
"files": [
"dist",
"README.md"
],
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./internal/daemon-client": {
"types": "./dist/daemon-client.d.ts",
"default": "./dist/daemon-client.js"
},
"./internal/daemon-client-transport-types": {
"types": "./dist/daemon-client-transport-types.d.ts",
"default": "./dist/daemon-client-transport-types.js"
}
},
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "node -e \"require('node:fs').rmSync('dist',{ recursive: true, force: true })\" && tsc -p tsconfig.json --incremental false",
"prepack": "npm run build",
"typecheck": "tsgo --noEmit",
"typecheck:examples": "tsgo -p tsconfig.examples.json --noEmit",
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.1.83",
"@getpaseo/relay": "0.1.83",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^20.9.0",
"typescript": "^5.2.2",
"vitest": "^4.1.6"
}
}
@@ -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
}
}
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true
},
"include": ["examples/**/*.ts"]
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2023", "DOM"],
"types": ["node"],
"skipLibCheck": true,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
}