Publish the TypeScript SDK and integration guides (#3141)

* feat(client): publish the TypeScript SDK surface

Make the high-level client API usable for external integrations and document complete agent, workspace, provider, event, and recipe flows.

* fix(client): correlate provider snapshots by daemon cwd

Return the daemon's canonical snapshot identity so waitForReady can match updates for relative, tilde, and otherwise non-canonical paths without duplicating filesystem rules in the SDK.

* test(server): make snapshot cwd assertion portable

* fix(client): gate canonical provider snapshot waits

Older daemons do not expose the canonical snapshot identity needed to correlate workspace updates safely. Require the advertised capability and fail with an update-host message instead of guessing across workspaces.
This commit is contained in:
Mohamed Boudra
2026-08-10 13:22:20 +02:00
committed by GitHub
parent 05a0cabc07
commit 7954babca6
24 changed files with 1956 additions and 197 deletions
+348 -41
View File
@@ -1,6 +1,6 @@
import { afterEach, expect, test, vi } from "vitest";
import { createPaseoClient } from "./index.js";
import type { PaseoAgent, PaseoClient, PaseoProviderConfig, PaseoWorkspace } from "./index.js";
import type { PaseoAgent, PaseoClient, PaseoWorkspace } from "./index.js";
type FakeWebSocketHandler = (...args: unknown[]) => void;
@@ -80,7 +80,9 @@ function parseSentFrame(
return JSON.parse(data);
}
async function connectClient(): Promise<{ client: PaseoClient; ws: FakeWebSocket }> {
async function connectClient(
features: Record<string, boolean> = { providersSnapshotCwd: true },
): Promise<{ client: PaseoClient; ws: FakeWebSocket }> {
vi.stubGlobal("WebSocket", FakeWebSocket);
const client = createPaseoClient({
url: "ws://daemon.test",
@@ -105,6 +107,7 @@ async function connectClient(): Promise<{ client: PaseoClient; ws: FakeWebSocket
serverId: "srv_sdk_test",
hostname: null,
version: null,
features,
},
}),
);
@@ -211,6 +214,59 @@ test("createPaseoClient exposes workspace list through the daemon client", async
await client.close();
});
test("agent actions list the daemon directory without exposing the low-level client", async () => {
const { client, ws } = await connectClient();
const listedAgent = createAgent({ title: "Planner" });
const listPromise = client.agents.list({
filter: { includeArchived: false },
page: { limit: 10 },
});
const request = parseSentSessionMessage(ws.sent.at(-1));
expect(request).toMatchObject({
type: "fetch_agents_request",
filter: { includeArchived: false },
page: { limit: 10 },
});
ws.message(
sessionMessage({
type: "fetch_agents_response",
payload: {
requestId: request.requestId,
entries: [
{
agent: listedAgent,
project: {
projectKey: "project_sdk",
projectName: "SDK",
workspaceName: "main",
checkout: {
cwd: "/repo/sdk",
isGit: false,
currentBranch: null,
remoteUrl: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
},
},
],
pageInfo: {
nextCursor: null,
prevCursor: null,
hasMore: false,
},
},
}),
);
await expect(listPromise).resolves.toMatchObject({
entries: [{ agent: { id: "agent_sdk", title: "Planner" } }],
});
await client.close();
});
test("workspace handles keep identity and refresh snapshots through existing driver calls", async () => {
const { client, ws } = await connectClient();
const openedWorkspace = createWorkspace();
@@ -232,17 +288,18 @@ test("workspace handles keep identity and refresh snapshots through existing dri
}),
);
const opened = await openPromise;
const workspace = opened.workspace;
expect(workspace?.id).toBe("workspace_sdk");
expect(workspace?.latest()).toEqual(openedWorkspace);
const workspace = await openPromise;
expect(workspace.id).toBe("workspace_sdk");
expect(workspace.projectId).toBe("project_sdk");
expect(workspace.directory).toBe("/repo/sdk");
expect(workspace.current()).toEqual(openedWorkspace);
const refreshedWorkspace = createWorkspace({ name: "sdk refreshed" });
const refetchPromise = workspace?.refetch({ requestId: "workspace-refetch-request" });
const refetchPromise = workspace.refresh({ requestId: "workspace-refetch-request" });
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
type: "fetch_workspaces_request",
requestId: "workspace-refetch-request",
page: { limit: 25 },
page: { limit: 200 },
});
ws.message(
@@ -250,6 +307,27 @@ test("workspace handles keep identity and refresh snapshots through existing dri
type: "fetch_workspaces_response",
payload: {
requestId: "workspace-refetch-request",
entries: [],
pageInfo: {
nextCursor: "workspace-page-2",
prevCursor: null,
hasMore: true,
},
},
}),
);
await new Promise((resolve) => setTimeout(resolve, 0));
const secondPageRequest = parseSentSessionMessage(ws.sent.at(-1));
expect(secondPageRequest).toMatchObject({
type: "fetch_workspaces_request",
page: { limit: 200, cursor: "workspace-page-2" },
});
ws.message(
sessionMessage({
type: "fetch_workspaces_response",
payload: {
requestId: secondPageRequest.requestId,
entries: [refreshedWorkspace],
pageInfo: {
nextCursor: null,
@@ -261,10 +339,10 @@ test("workspace handles keep identity and refresh snapshots through existing dri
);
await expect(refetchPromise).resolves.toEqual(refreshedWorkspace);
expect(workspace?.latest()).toEqual(refreshedWorkspace);
expect(workspace.current()).toEqual(refreshedWorkspace);
const updates: string[] = [];
const unsubscribe = workspace?.subscribe((update) => {
const unsubscribe = workspace.subscribe((update) => {
if (update.kind === "upsert") {
updates.push(update.workspace.name);
}
@@ -280,9 +358,9 @@ test("workspace handles keep identity and refresh snapshots through existing dri
}),
);
expect(updates).toEqual(["sdk pushed"]);
expect(workspace?.latest()).toEqual(pushedWorkspace);
expect(workspace.current()).toEqual(pushedWorkspace);
unsubscribe?.();
unsubscribe();
ws.message(
sessionMessage({
type: "workspace_update",
@@ -297,20 +375,79 @@ test("workspace handles keep identity and refresh snapshots through existing dri
await client.close();
});
test("workspace create is fresh and workspace-scoped agent create owns placement", async () => {
const { client, ws } = await connectClient();
const createdWorkspace = createWorkspace({ id: "workspace_fresh", name: "Issue 42" });
const workspacePromise = client.workspaces.create({
source: { kind: "directory", path: "/repo/sdk", projectId: "project_sdk" },
title: "Issue 42",
});
const workspaceRequest = parseSentSessionMessage(ws.sent.at(-1));
expect(workspaceRequest).toMatchObject({
type: "workspace.create.request",
source: { kind: "directory", path: "/repo/sdk", projectId: "project_sdk" },
title: "Issue 42",
});
ws.message(
sessionMessage({
type: "workspace.create.response",
payload: {
requestId: workspaceRequest.requestId,
workspace: createdWorkspace,
setupTerminalId: null,
error: null,
},
}),
);
const workspace = await workspacePromise;
const agentPromise = workspace.agents.create({
config: { provider: "codex/gpt-5.4" },
parent: client.agents.ref("parent_sdk"),
prompt: "Implement issue 42.",
});
const agentRequest = parseSentSessionMessage(ws.sent.at(-1));
expect(agentRequest).toMatchObject({
type: "create_agent_request",
config: { provider: "codex", model: "gpt-5.4", cwd: "/repo/sdk" },
workspaceId: "workspace_fresh",
callerAgentId: "parent_sdk",
initialPrompt: "Implement issue 42.",
});
ws.message(
sessionMessage({
type: "status",
payload: {
status: "agent_created",
requestId: agentRequest.requestId,
agentId: "agent_sdk",
agent: createAgent({ workspaceId: "workspace_fresh" }),
},
}),
);
const agent = await agentPromise;
expect(agent.workspaceId).toBe("workspace_fresh");
expect(agent.cwd).toBe("/repo/sdk");
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",
config: { provider: "codex/gpt-5.4" },
cwd: "/repo/sdk",
initialPrompt: "ship it",
prompt: "ship it",
});
const createRequest = parseSentSessionMessage(ws.sent.at(-1));
expect(createRequest).toMatchObject({
type: "create_agent_request",
config: {
provider: "codex",
model: "gpt-5.4",
cwd: "/repo/sdk",
},
initialPrompt: "ship it",
@@ -330,7 +467,7 @@ test("agent handles delegate create, send, timeline refetch, archive, and local
const agent = await createPromise;
expect(agent.id).toBe("agent_sdk");
expect(agent.latest()).toEqual(createdAgent);
expect(agent.current()).toEqual(createdAgent);
const updatedAgents: string[] = [];
const unsubscribe = agent.subscribe((update) => {
@@ -350,7 +487,7 @@ test("agent handles delegate create, send, timeline refetch, archive, and local
}),
);
expect(updatedAgents).toEqual(["Updated"]);
expect(agent.latest()).toEqual(updatedAgent);
expect(agent.current()).toEqual(updatedAgent);
const sendPromise = agent.send("hello", { messageId: "message-sdk" });
const sendRequest = parseSentSessionMessage(ws.sent.at(-1));
@@ -374,6 +511,74 @@ test("agent handles delegate create, send, timeline refetch, archive, and local
);
await sendPromise;
const runPromise = agent.run("finish the task", {
messageId: "run-message-sdk",
timeoutMs: 30_000,
});
const runSendRequest = parseSentSessionMessage(ws.sent.at(-1));
expect(runSendRequest).toMatchObject({
type: "send_agent_message_request",
agentId: "agent_sdk",
text: "finish the task",
messageId: "run-message-sdk",
});
ws.message(
sessionMessage({
type: "send_agent_message_response",
payload: {
requestId: runSendRequest.requestId,
agentId: "agent_sdk",
accepted: true,
error: null,
},
}),
);
await new Promise((resolve) => setTimeout(resolve, 0));
const waitRequest = parseSentSessionMessage(ws.sent.at(-1));
expect(waitRequest).toMatchObject({
type: "wait_for_finish_request",
agentId: "agent_sdk",
timeoutMs: 30_000,
});
const finishedAgent = createAgent({ title: "Finished" });
ws.message(
sessionMessage({
type: "wait_for_finish_response",
payload: {
requestId: waitRequest.requestId,
agentId: "agent_sdk",
status: "idle",
final: finishedAgent,
error: null,
lastMessage: "DONE",
},
}),
);
await expect(runPromise).resolves.toMatchObject({ status: "idle", lastMessage: "DONE" });
expect(agent.current()).toEqual(finishedAgent);
const defaultWaitPromise = agent.waitForFinish();
const defaultWaitRequest = parseSentSessionMessage(ws.sent.at(-1));
expect(defaultWaitRequest).toMatchObject({
type: "wait_for_finish_request",
agentId: "agent_sdk",
timeoutMs: 10 * 60_000,
});
ws.message(
sessionMessage({
type: "wait_for_finish_response",
payload: {
requestId: defaultWaitRequest.requestId,
agentId: "agent_sdk",
status: "idle",
final: finishedAgent,
error: null,
lastMessage: "DONE",
},
}),
);
await expect(defaultWaitPromise).resolves.toMatchObject({ status: "idle" });
const timelineAgent = createAgent({ title: "Timeline" });
const timelinePromise = agent.timeline.refetch({ limit: 5 });
const timelineRequest = parseSentSessionMessage(ws.sent.at(-1));
@@ -410,7 +615,7 @@ test("agent handles delegate create, send, timeline refetch, archive, and local
}),
);
await timelinePromise;
expect(agent.latest()).toEqual(timelineAgent);
expect(agent.current()).toEqual(timelineAgent);
const archivePromise = agent.archive();
const archiveRequest = parseSentSessionMessage(ws.sent.at(-1));
@@ -431,7 +636,7 @@ test("agent handles delegate create, send, timeline refetch, archive, and local
await expect(archivePromise).resolves.toEqual({
archivedAt: "2026-05-16T01:00:00.000Z",
});
expect(agent.latest()?.archivedAt).toBe("2026-05-16T01:00:00.000Z");
expect(agent.current()?.archivedAt).toBe("2026-05-16T01:00:00.000Z");
unsubscribe();
await client.close();
@@ -496,10 +701,9 @@ test("provider actions delegate to existing provider RPCs and local snapshot upd
const featuresPromise = client.providers.listFeatures(
{
provider: "codex",
provider: "codex/gpt-5.4",
cwd: "/repo/sdk",
modeId: "full-access",
model: "gpt-5.4",
thinkingOptionId: "high",
featureValues: { webSearch: true },
},
@@ -579,6 +783,81 @@ test("provider actions delegate to existing provider RPCs and local snapshot upd
entries: [{ provider: "codex", status: "ready", enabled: true }],
});
const readyPromise = client.providers.waitForReady({ cwd: "/repo/sdk", timeoutMs: 5_000 });
const readyRequest = parseSentSessionMessage(ws.sent.at(-1));
expect(readyRequest).toMatchObject({
type: "get_providers_snapshot_request",
cwd: "/repo/sdk",
});
ws.message(
sessionMessage({
type: "get_providers_snapshot_response",
payload: {
requestId: readyRequest.requestId,
cwd: "/repo/sdk",
entries: [{ provider: "codex", status: "loading", enabled: true }],
generatedAt: "2026-05-16T00:00:00.000Z",
},
}),
);
ws.message(
sessionMessage({
type: "providers_snapshot_update",
payload: {
cwd: "/repo/other",
entries: [{ provider: "codex", status: "ready", enabled: true }],
generatedAt: "2026-05-16T00:00:30.000Z",
},
}),
);
ws.message(
sessionMessage({
type: "providers_snapshot_update",
payload: {
cwd: "/repo/sdk",
entries: [{ provider: "codex", status: "ready", enabled: true }],
generatedAt: "2026-05-16T00:00:01.000Z",
},
}),
);
await expect(readyPromise).resolves.toMatchObject({
requestId: readyRequest.requestId,
entries: [{ provider: "codex", status: "ready", enabled: true }],
generatedAt: "2026-05-16T00:00:01.000Z",
});
const canonicalReadyPromise = client.providers.waitForReady({
cwd: "/repo/./sdk",
timeoutMs: 5_000,
});
const canonicalReadyRequest = parseSentSessionMessage(ws.sent.at(-1));
ws.message(
sessionMessage({
type: "providers_snapshot_update",
payload: {
cwd: "/repo/sdk",
entries: [{ provider: "codex", status: "ready", enabled: true }],
generatedAt: "2026-05-16T00:00:02.000Z",
},
}),
);
ws.message(
sessionMessage({
type: "get_providers_snapshot_response",
payload: {
requestId: canonicalReadyRequest.requestId,
cwd: "/repo/sdk",
entries: [{ provider: "codex", status: "loading", enabled: true }],
generatedAt: "2026-05-16T00:00:01.000Z",
},
}),
);
await expect(canonicalReadyPromise).resolves.toMatchObject({
requestId: canonicalReadyRequest.requestId,
cwd: "/repo/sdk",
entries: [{ provider: "codex", status: "ready", enabled: true }],
});
const refreshPromise = client.providers.refresh({
cwd: "/repo/sdk",
providers: ["codex"],
@@ -665,6 +944,18 @@ test("provider actions delegate to existing provider RPCs and local snapshot upd
await client.close();
});
test("waitForReady requires canonical provider snapshot identity from the host", async () => {
const { client, ws } = await connectClient({});
const sentBeforeWait = ws.sent.length;
await expect(client.providers.waitForReady({ cwd: "/repo/./sdk" })).rejects.toThrow(
"Update the host to wait for provider discovery.",
);
expect(ws.sent).toHaveLength(sentBeforeWait);
await client.close();
});
test("config actions delegate to existing daemon config RPCs", async () => {
const { client, ws } = await connectClient();
@@ -757,34 +1048,29 @@ test("config actions delegate to existing daemon config RPCs", async () => {
await client.close();
});
test("provider config builders shape existing create-agent config fields", async () => {
test("agent config maps provider/model and provider-native options to the daemon", 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",
provider: "codex/gpt-5.4",
modeId: "full-access",
thinkingOptionId: "high",
featureValues: { webSearch: true },
options: {
approval_policy: "never",
sandbox_mode: "workspace-write",
sandbox_workspace_write: {
writable_roots: ["/repo/sdk"],
network_access: false,
},
},
},
initialPrompt: "use configured provider",
cwd: "/repo/sdk",
prompt: "use configured provider",
});
const request = parseSentSessionMessage(ws.sent.at(-1));
expect(request).toMatchObject({
@@ -796,6 +1082,14 @@ test("provider config builders shape existing create-agent config fields", async
modeId: "full-access",
thinkingOptionId: "high",
featureValues: { webSearch: true },
providerOptions: {
approval_policy: "never",
sandbox_mode: "workspace-write",
sandbox_workspace_write: {
writable_roots: ["/repo/sdk"],
network_access: false,
},
},
},
initialPrompt: "use configured provider",
});
@@ -813,7 +1107,20 @@ test("provider config builders shape existing create-agent config fields", async
);
const agent = await createPromise;
expect(agent.latest()).toEqual(createdAgent);
expect(agent.current()).toEqual(createdAgent);
await client.close();
});
test("agent config requires provider/model syntax", async () => {
const { client } = await connectClient();
await expect(
client.agents.create({
config: { provider: "codex" },
cwd: "/repo/sdk",
}),
).rejects.toThrow('Expected config.provider in "provider/model" format');
await client.close();
});
+326 -121
View File
@@ -17,24 +17,25 @@ import type {
SendAgentMessageRequest,
SessionOutboundMessage,
WorkspaceDescriptorPayload,
WorkspaceCreateRequest,
} from "@getpaseo/protocol/messages";
import { DaemonClient } from "./daemon-client.js";
import type {
FetchAgentsEntry,
FetchAgentsOptions,
FetchAgentsPageInfo,
FetchAgentTimelineCursor,
FetchAgentTimelineDirection,
FetchAgentTimelinePayload,
FetchAgentTimelineProjection,
WaitForFinishResult,
} from "./daemon-client.js";
export { DaemonClient };
export type {
DaemonClientConfig,
DaemonEvent,
BrowserAutomationExecuteRequestMessage,
BrowserAutomationExecuteResponseMessage,
WebSocketFactory,
WebSocketLike,
} from "./daemon-client.js";
/**
* Coding turns routinely run for minutes, so the handle waits far longer than
* the transport's own conservative default.
*/
const DEFAULT_WAIT_FOR_FINISH_MS = 10 * 60_000;
export type ConnectionState =
| { status: "idle" }
@@ -75,6 +76,14 @@ export interface PaseoClientConfig {
export type PaseoWorkspace = WorkspaceDescriptorPayload;
export type PaseoAgent = AgentSnapshotPayload;
export type PaseoAgentListOptions = FetchAgentsOptions;
export interface PaseoAgentListResult {
requestId: string;
subscriptionId?: string | null;
entries: FetchAgentsEntry[];
pageInfo: FetchAgentsPageInfo;
}
export type PaseoWorkspaceListOptions = Omit<
FetchWorkspacesRequestMessage,
"type" | "requestId"
@@ -94,11 +103,9 @@ export interface PaseoWorkspaceOpenOptions {
requestId?: string;
}
export interface PaseoWorkspaceOpenResult {
requestId: string;
workspace: PaseoWorkspaceHandle | null;
error: string | null;
}
export type PaseoWorkspaceCreateOptions = Omit<WorkspaceCreateRequest, "type" | "requestId"> & {
requestId?: string;
};
export interface PaseoWorkspaceArchiveResult {
requestId: string;
@@ -114,19 +121,17 @@ export type PaseoWorkspaceUpdate = Extract<
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>;
readonly projectId: string | null;
readonly directory: string | null;
readonly name: string | null;
readonly status: PaseoWorkspace["status"] | null;
readonly agents: {
create(options: PaseoWorkspaceAgentCreateOptions): Promise<PaseoAgentHandle>;
};
current(): PaseoWorkspace | null;
refresh(options?: { requestId?: string }): Promise<PaseoWorkspace | null>;
archive(requestId?: string): Promise<PaseoWorkspaceArchiveResult>;
/**
* Subscribes to already-emitted daemon workspace_update events for this id.
@@ -143,11 +148,8 @@ export interface PaseoWorkspaceActions {
open(
input: string | PaseoWorkspaceOpenOptions,
requestId?: string,
): Promise<PaseoWorkspaceOpenResult>;
create(
input: string | PaseoWorkspaceOpenOptions,
requestId?: string,
): Promise<PaseoWorkspaceOpenResult>;
): Promise<PaseoWorkspaceHandle>;
create(options: PaseoWorkspaceCreateOptions): Promise<PaseoWorkspaceHandle>;
archive(
workspace: string | PaseoWorkspaceHandle,
requestId?: string,
@@ -160,25 +162,43 @@ export interface PaseoWorkspaceActions {
}
type PaseoAgentSessionConfig = CreateAgentRequestMessage["config"];
type PaseoAgentProvider = PaseoAgentSessionConfig["provider"];
type PaseoAgentConfigOverrides = Partial<Omit<PaseoAgentSessionConfig, "provider" | "cwd">>;
export type PaseoAgentProvider = PaseoAgentSessionConfig["provider"];
export interface PaseoAgentCreateOptions extends PaseoAgentConfigOverrides {
config?: PaseoAgentSessionConfig;
provider?: CreateAgentRequestMessage["config"]["provider"];
cwd?: string;
workspaceId?: string;
callerAgentId?: string;
initialPrompt?: string;
export type PaseoProviderFeatureValues = Record<string, unknown>;
export interface PaseoAgentConfig {
/** Provider and model in `provider/model` format. */
provider: string;
modeId?: PaseoAgentSessionConfig["modeId"];
thinkingOptionId?: PaseoAgentSessionConfig["thinkingOptionId"];
featureValues?: PaseoProviderFeatureValues;
/** JSON-safe provider-native settings, validated by the selected provider. */
options?: PaseoAgentSessionConfig["providerOptions"];
systemPrompt?: PaseoAgentSessionConfig["systemPrompt"];
toolPolicy?: PaseoAgentSessionConfig["toolPolicy"];
mcpServers?: PaseoAgentSessionConfig["mcpServers"];
}
export interface PaseoAgentCreateOptions {
config: PaseoAgentConfig;
cwd: string;
parent?: string | PaseoAgentHandle;
title?: PaseoAgentSessionConfig["title"];
env?: CreateAgentRequestMessage["env"];
prompt?: string;
clientMessageId?: string;
outputSchema?: Record<string, unknown>;
images?: CreateAgentRequestMessage["images"];
attachments?: CreateAgentRequestMessage["attachments"];
git?: CreateAgentRequestMessage["git"];
worktree?: CreateAgentRequestMessage["worktree"];
autoArchive?: CreateAgentRequestMessage["autoArchive"];
requestId?: string;
labels?: Record<string, string>;
}
export type PaseoWorkspaceAgentCreateOptions = Omit<PaseoAgentCreateOptions, "cwd">;
export interface PaseoAgentRefetchResult {
agent: PaseoAgent;
project: ProjectPlacementPayload | null;
@@ -198,6 +218,12 @@ export interface PaseoAgentSendOptions {
attachments?: SendAgentMessageRequest["attachments"];
}
export interface PaseoAgentRunOptions extends PaseoAgentSendOptions {
timeoutMs?: number;
}
export type PaseoAgentRunResult = WaitForFinishResult;
export type PaseoAgentUpdate = Extract<SessionOutboundMessage, { type: "agent_update" }>["payload"];
export type PaseoAgentStream = Extract<SessionOutboundMessage, { type: "agent_stream" }>["payload"];
@@ -207,8 +233,8 @@ 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.
* includes an agent snapshot in the response, the parent handle is updated to
* that value.
*/
refetch(options?: PaseoAgentTimelineRefetchOptions): Promise<FetchAgentTimelinePayload>;
/**
@@ -218,24 +244,26 @@ export interface PaseoAgentTimelineHandle {
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 workspaceId: string | null;
readonly cwd: string | null;
readonly status: PaseoAgent["status"] | null;
readonly timeline: PaseoAgentTimelineHandle;
latest(): PaseoAgent | null;
refetch(requestId?: string): Promise<PaseoAgentRefetchResult | null>;
current(): PaseoAgent | null;
refresh(requestId?: string): Promise<PaseoAgentRefetchResult | null>;
send(text: string, options?: PaseoAgentSendOptions): Promise<void>;
/** Sends a prompt and resolves when that turn finishes or needs attention. */
run(text: string, options?: PaseoAgentRunOptions): Promise<PaseoAgentRunResult>;
/** Waits for the current turn, including one started with `prompt`. */
waitForFinish(timeoutMs?: number): Promise<PaseoAgentRunResult>;
archive(): Promise<{ archivedAt: string }>;
detach(): Promise<void>;
subscribe(handler: (update: PaseoAgentUpdate) => void): () => void;
}
export interface PaseoAgentActions {
list(options?: PaseoAgentListOptions): Promise<PaseoAgentListResult>;
ref(agent: string | PaseoAgent): PaseoAgentHandle;
create(options: PaseoAgentCreateOptions): Promise<PaseoAgentHandle>;
/**
@@ -245,21 +273,16 @@ export interface PaseoAgentActions {
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"];
type PaseoProviderFeaturesDraft = ListProviderFeaturesRequestMessage["draftConfig"];
export interface PaseoProviderFeaturesInput extends Omit<
PaseoProviderFeaturesDraft,
"provider" | "model"
> {
/** Provider and model in `provider/model` format. */
provider: string;
}
export type PaseoProviderFeaturesResult = ListProviderFeaturesResponseMessage["payload"];
export type PaseoProviderAvailabilityResult = ListAvailableProvidersResponse["payload"];
export type PaseoProviderSnapshotResult = GetProvidersSnapshotResponseMessage["payload"];
@@ -281,12 +304,11 @@ export interface PaseoProviderRefreshOptions {
requestId?: string;
}
export interface PaseoProviderWaitOptions extends PaseoProviderListOptions {
timeoutMs?: number;
}
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,
@@ -301,6 +323,8 @@ export interface PaseoProviderActions {
): Promise<PaseoProviderFeaturesResult>;
listAvailable(options?: { requestId?: string }): Promise<PaseoProviderAvailabilityResult>;
snapshot(options?: PaseoProviderListOptions): Promise<PaseoProviderSnapshotResult>;
/** Resolves after the daemon's lazy provider discovery has finished. */
waitForReady(options?: PaseoProviderWaitOptions): Promise<PaseoProviderSnapshotResult>;
refresh(options?: PaseoProviderRefreshOptions): Promise<PaseoProviderRefreshResult>;
diagnostic(
provider: PaseoAgentProvider,
@@ -346,8 +370,32 @@ export function createPaseoClient(config: PaseoClientConfig): PaseoClient {
clientId: config.clientId ?? createGeneratedClientId(),
clientType: "cli",
});
const createWorkspaceHandle = createWorkspaceHandleFactory(daemonClient);
const createAgentHandle = createAgentHandleFactory(daemonClient);
const createAgent = async (
options: PaseoAgentCreateOptions,
placement?: { workspaceId: string; cwd: string },
) => {
const { config: agentConfig, cwd, parent, title, prompt, ...requestOptions } = options;
const { provider: providerModel, options: providerOptions, ...runtimeConfig } = agentConfig;
const { provider, model } = parseProviderModel(providerModel);
const effectiveCwd = placement?.cwd ?? cwd;
const agent = await daemonClient.createAgent({
...requestOptions,
config: {
...runtimeConfig,
provider,
model,
cwd: effectiveCwd,
...(title !== undefined ? { title } : {}),
...(providerOptions !== undefined ? { providerOptions } : {}),
},
...(placement ? { workspaceId: placement.workspaceId } : {}),
...(parent ? { callerAgentId: resolveAgentId(parent) } : {}),
...(prompt !== undefined ? { initialPrompt: prompt } : {}),
});
return createAgentHandle(agent);
};
const createWorkspaceHandle = createWorkspaceHandleFactory(daemonClient, createAgent);
return {
workspaces: {
@@ -355,8 +403,13 @@ export function createPaseoClient(config: PaseoClientConfig): PaseoClient {
ref: (workspace) => createWorkspaceHandle(workspace),
open: (input, requestId) =>
openWorkspace(daemonClient, createWorkspaceHandle, input, requestId),
create: (input, requestId) =>
openWorkspace(daemonClient, createWorkspaceHandle, input, requestId),
create: async ({ requestId, ...options }) => {
const result = await daemonClient.createWorkspace(options, requestId);
if (result.error || !result.workspace) {
throw new Error(result.error ?? "The daemon did not create a workspace");
}
return createWorkspaceHandle(result.workspace);
},
archive: (workspace, requestId) =>
daemonClient.archiveWorkspace(resolveWorkspaceId(workspace), requestId),
subscribe: (handler) =>
@@ -365,28 +418,24 @@ export function createPaseoClient(config: PaseoClientConfig): PaseoClient {
}),
},
agents: {
list: (options) => daemonClient.fetchAgents(options),
ref: (agent) => createAgentHandle(agent),
create: async (options) => {
const agent = await daemonClient.createAgent(options);
return createAgentHandle(agent);
},
create: (options) => createAgent(options),
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),
listFeatures: ({ provider: providerModel, ...draftConfig }, options) => {
const { provider, model } = parseProviderModel(providerModel);
return daemonClient.listProviderFeatures({ ...draftConfig, provider, model }, options);
},
listAvailable: (options) => daemonClient.listAvailableProviders(options),
snapshot: (options) => daemonClient.getProvidersSnapshot(options),
waitForReady: (options) => waitForProvidersReady(daemonClient, options),
refresh: (options) => daemonClient.refreshProvidersSnapshot(options),
diagnostic: (provider, options) => daemonClient.getProviderDiagnostic(provider, options),
subscribe: (handler) =>
@@ -407,30 +456,71 @@ export function createPaseoClient(config: PaseoClientConfig): PaseoClient {
type WorkspaceHandleFactory = (workspace: string | PaseoWorkspace) => PaseoWorkspaceHandle;
type AgentHandleFactory = (agent: string | PaseoAgent) => PaseoAgentHandle;
type CreateAgent = (
options: PaseoAgentCreateOptions,
placement?: { workspaceId: string; cwd: string },
) => Promise<PaseoAgentHandle>;
function createWorkspaceHandleFactory(daemonClient: DaemonClient): WorkspaceHandleFactory {
function createWorkspaceHandleFactory(
daemonClient: DaemonClient,
createAgent: CreateAgent,
): WorkspaceHandleFactory {
return (workspace) => {
const id = typeof workspace === "string" ? workspace : workspace.id;
let latest = typeof workspace === "string" ? null : workspace;
let current = typeof workspace === "string" ? null : workspace;
const refresh = async (options?: { requestId?: string }) => {
let cursor: string | undefined;
let requestId = options?.requestId;
do {
const result = await daemonClient.fetchWorkspaces({
requestId,
page: { limit: 200, ...(cursor ? { cursor } : {}) },
});
const match = result.entries.find((entry) => entry.id === id);
if (match) {
current = match;
return current;
}
cursor = result.pageInfo.nextCursor ?? undefined;
requestId = undefined;
} while (cursor);
current = null;
return current;
};
return {
id,
latest: () => latest,
refetch: async (options) => {
// Best-effort: fetches one page and matches by id client-side, so a workspace beyond
// the first page won't be found. TODO: add a "get workspace by id" lookup and resolve
// by exact id instead of paging.
const result = await daemonClient.fetchWorkspaces({
requestId: options?.requestId,
page: { limit: 25 },
});
latest = result.entries.find((entry) => entry.id === id) ?? null;
return latest;
get projectId() {
return current?.projectId ?? null;
},
get directory() {
return current?.workspaceDirectory ?? null;
},
get name() {
return current?.name ?? null;
},
get status() {
return current?.status ?? null;
},
agents: {
create: async (options) => {
const snapshot = current ?? (await refresh());
if (!snapshot?.workspaceDirectory) {
throw new Error(`Workspace ${id} has no available directory`);
}
return createAgent(
{ ...options, cwd: snapshot.workspaceDirectory },
{ workspaceId: id, cwd: snapshot.workspaceDirectory },
);
},
},
current: () => current,
refresh,
archive: async (requestId) => {
const result = await daemonClient.archiveWorkspace(id, requestId);
if (latest) {
latest = { ...latest, archivingAt: result.archivedAt };
if (current) {
current = { ...current, archivingAt: result.archivedAt };
}
return result;
},
@@ -438,11 +528,11 @@ function createWorkspaceHandleFactory(daemonClient: DaemonClient): WorkspaceHand
daemonClient.on("workspace_update", (message) => {
const update = message.payload;
if (update.kind === "upsert" && update.workspace.id === id) {
latest = update.workspace;
current = update.workspace;
handler(update);
}
if (update.kind === "remove" && update.id === id) {
latest = null;
current = null;
handler(update);
}
}),
@@ -453,7 +543,7 @@ function createWorkspaceHandleFactory(daemonClient: DaemonClient): WorkspaceHand
function createAgentHandleFactory(daemonClient: DaemonClient): AgentHandleFactory {
return (agent) => {
const id = typeof agent === "string" ? agent : agent.id;
let latest = typeof agent === "string" ? null : agent;
let current = typeof agent === "string" ? null : agent;
const handle: PaseoAgentHandle = {
id,
@@ -461,7 +551,7 @@ function createAgentHandleFactory(daemonClient: DaemonClient): AgentHandleFactor
refetch: async (options) => {
const result = await daemonClient.fetchAgentTimeline(id, options);
if (result.agent) {
latest = result.agent;
current = result.agent;
}
return result;
},
@@ -472,19 +562,50 @@ function createAgentHandleFactory(daemonClient: DaemonClient): AgentHandleFactor
}
}),
},
latest: () => latest,
refetch: async (requestId) => {
get workspaceId() {
return current?.workspaceId ?? null;
},
get cwd() {
return current?.cwd ?? null;
},
get status() {
return current?.status ?? null;
},
current: () => current,
refresh: async (requestId) => {
const result = await daemonClient.fetchAgent({ agentId: id, requestId });
latest = result?.agent ?? null;
current = result?.agent ?? null;
return result;
},
send: async (text, options) => {
await daemonClient.sendAgentMessage(id, text, options);
},
run: async (text, options) => {
const { timeoutMs, ...sendOptions } = options ?? {};
await daemonClient.sendAgentMessage(id, text, sendOptions);
const result = await daemonClient.waitForFinish(
id,
timeoutMs ?? DEFAULT_WAIT_FOR_FINISH_MS,
);
if (result.final) {
current = result.final;
}
return result;
},
waitForFinish: async (timeoutMs) => {
const result = await daemonClient.waitForFinish(
id,
timeoutMs ?? DEFAULT_WAIT_FOR_FINISH_MS,
);
if (result.final) {
current = result.final;
}
return result;
},
archive: async () => {
const result = await daemonClient.archiveAgent(id);
if (latest) {
latest = { ...latest, archivedAt: result.archivedAt };
if (current) {
current = { ...current, archivedAt: result.archivedAt };
}
return result;
},
@@ -495,11 +616,11 @@ function createAgentHandleFactory(daemonClient: DaemonClient): AgentHandleFactor
daemonClient.on("agent_update", (message) => {
const update = message.payload;
if (update.kind === "upsert" && update.agent.id === id) {
latest = update.agent;
current = update.agent;
handler(update);
}
if (update.kind === "remove" && update.agentId === id) {
latest = null;
current = null;
handler(update);
}
}),
@@ -514,32 +635,116 @@ async function openWorkspace(
createWorkspaceHandle: WorkspaceHandleFactory,
input: string | PaseoWorkspaceOpenOptions,
requestId?: string,
): Promise<PaseoWorkspaceOpenResult> {
): Promise<PaseoWorkspaceHandle> {
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,
};
if (result.error || !result.workspace) {
throw new Error(result.error ?? `The daemon did not open a workspace for ${options.cwd}`);
}
return createWorkspaceHandle(result.workspace);
}
function resolveWorkspaceId(workspace: string | PaseoWorkspaceHandle): string {
return typeof workspace === "string" ? workspace : workspace.id;
}
function providerConfig(
provider: PaseoAgentProvider,
input: PaseoProviderConfigInput = {},
): PaseoProviderConfig {
function resolveAgentId(agent: string | PaseoAgentHandle): string {
return typeof agent === "string" ? agent : agent.id;
}
function parseProviderModel(selection: string): { provider: string; model: string } {
const separator = selection.indexOf("/");
if (separator <= 0 || separator === selection.length - 1) {
throw new Error('Expected config.provider in "provider/model" format');
}
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 } : {}),
provider: selection.slice(0, separator),
model: selection.slice(separator + 1),
};
}
function waitForProvidersReady(
daemonClient: DaemonClient,
options: PaseoProviderWaitOptions = {},
): Promise<PaseoProviderSnapshotResult> {
// COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10.
if (daemonClient.getLastServerInfoMessage()?.features?.providersSnapshotCwd !== true) {
return Promise.reject(new Error("Update the host to wait for provider discovery."));
}
const { timeoutMs = 60_000, ...snapshotOptions } = options;
return new Promise((resolve, reject) => {
let settled = false;
let requestId: string | null = null;
let snapshotCwd: string | undefined;
const pendingUpdates = new Map<string | undefined, PaseoProviderSnapshotUpdate>();
let latestEntries: PaseoProviderSnapshotResult["entries"] = [];
const cleanup = () => {
clearTimeout(timeout);
unsubscribe();
};
const finish = (snapshot: PaseoProviderSnapshotResult) => {
if (settled) return;
settled = true;
cleanup();
resolve(snapshot);
};
const fail = (error: unknown) => {
if (settled) return;
settled = true;
cleanup();
reject(error instanceof Error ? error : new Error(String(error)));
};
const updateMatches = (update: PaseoProviderSnapshotUpdate) => update.cwd === snapshotCwd;
const unsubscribe = daemonClient.on("providers_snapshot_update", (message) => {
const update = message.payload;
if (!requestId) {
pendingUpdates.set(update.cwd, update);
return;
}
if (!updateMatches(update)) return;
latestEntries = update.entries;
if (update.entries.some((entry) => entry.status === "loading")) return;
finish({ ...update, requestId });
});
const timeout = setTimeout(() => {
const loading = latestEntries
.filter((entry) => entry.status === "loading")
.map((entry) => entry.provider)
.join(", ");
fail(
new Error(
loading
? `Timed out waiting for providers: ${loading}`
: "Timed out waiting for provider discovery",
),
);
}, timeoutMs);
void daemonClient
.getProvidersSnapshot(snapshotOptions)
.then((snapshot) => {
requestId = snapshot.requestId;
snapshotCwd = snapshot.cwd;
latestEntries = snapshot.entries;
if (!snapshot.entries.some((entry) => entry.status === "loading")) {
finish(snapshot);
return;
}
const pendingUpdate = pendingUpdates.get(snapshotCwd);
if (pendingUpdate && !pendingUpdate.entries.some((entry) => entry.status === "loading")) {
finish({ ...pendingUpdate, requestId });
}
return undefined;
})
.catch(fail);
});
}
function createGeneratedClientId(): string {
const randomId =
typeof globalThis.crypto?.randomUUID === "function"