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:
@@ -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
@@ -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();
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user