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
+34 -7
View File
@@ -1,12 +1,39 @@
# @getpaseo/client
Paseo's JavaScript/TypeScript client package.
TypeScript SDK for building integrations on top of a Paseo daemon.
```bash
npm install @getpaseo/client
```
```ts
import { createPaseoClient } from "@getpaseo/client";
const client = createPaseoClient({ url: "ws://127.0.0.1:6767/ws" });
await client.connect();
const agent = await client.agents.create({
config: { provider: "codex/gpt-5.5" },
cwd: "/Users/me/dev/storefront",
prompt: "Review the current diff and name the riskiest change.",
});
const result = await agent.waitForFinish();
console.log(result.lastMessage);
await client.close();
```
The public API is the package root. Imports under `@getpaseo/client/internal/*` are unsupported implementation details used by Paseo's own packages.
Read the [SDK documentation](https://paseo.sh/docs/sdk) for agents, workspaces, provider discovery, events, recipes, and the API reference. Runnable TypeScript patterns also live in [`examples/`](./examples/README.md).
## Runtime
The client needs a WebSocket implementation. Modern browsers and Node.js 22 provide one globally.
Use a WebSocket URL ending in `/ws`, such as `ws://127.0.0.1:6767/ws`. Pass `password` when the daemon requires authentication.
## Stability
This package is public so Paseo's published packages can depend on it cleanly.
It is not a stable public SDK yet.
APIs, exports, runtime behavior, and types may change or disappear in any
release without advance notice. Use it outside Paseo at your own risk until the
package is explicitly documented as stable.
The high-level API exported from `@getpaseo/client` is the supported SDK surface. The SDK and daemon remain protocol-compatible across versions, but newly added capabilities can require a newer daemon.
+6 -3
View File
@@ -6,13 +6,16 @@ These examples use only the public SDK root:
import { createPaseoClient, type PaseoClient } from "@getpaseo/client";
```
Pass the daemon WebSocket URL into the exported functions. In worktree dev, read it
Each example takes the daemon WebSocket URL as an argument. In worktree dev, read it
from the portless banner or `portless get daemon`; for the desktop-managed daemon,
use the URL for that daemon.
- `workspaces.ts` covers creating, opening, refetching, and archiving a workspace.
- `agents-and-providers.ts` covers creating agents and choosing providers with `client.providers.*`.
- `quickstart.ts` runs one agent and prints its reply. It is a standalone script with the URL at the top; the rest export functions.
- `workspaces.ts` covers creating a fresh workspace, opening by directory, refreshing, and archiving.
- `agents-and-providers.ts` covers provider discovery, creating agents, and waiting for turns.
- `events-and-timeline.ts` covers subscribing to workspace, agent, and timeline events, plus refetching a timeline page.
- `issue-to-agent.ts` turns an issue record into a visible Paseo workspace and agent.
- `parallel-review.ts` launches several reviewers concurrently and cleans them up.
- `provider-settings.ts` covers provider settings that are currently daemon config-backed.
Provider profiles, provider env vars, custom binaries, and additional models are still raw daemon config behavior. The SDK exposes them through `client.config.get()` and `client.config.patch()` until first-class provider settings RPCs exist.
@@ -14,13 +14,11 @@ export async function createCodexAgent(url: string, cwd: string): Promise<string
const agent = await client.agents.create({
config: {
...client.providers.codex({
model: "gpt-5.2",
modeId: "full-auto",
}),
cwd,
provider: "codex/gpt-5.5",
modeId: "full-access",
},
initialPrompt: "Inspect this repository and summarize the next useful task.",
cwd,
prompt: "Inspect this repository and summarize the next useful task.",
});
return agent.id;
@@ -35,18 +33,18 @@ export async function chooseProviderFromSnapshot(url: string, cwd: string): Prom
try {
await client.connect();
const snapshot = await client.providers.snapshot({ cwd });
const snapshot = await client.providers.waitForReady({ cwd });
const readyProvider = snapshot.entries.find((provider) => provider.status === "ready");
const providerConfig = readyProvider
? client.providers.config(readyProvider.provider)
: client.providers.claude();
const model =
readyProvider?.models?.find((candidate) => candidate.isDefault) ?? readyProvider?.models?.[0];
if (!readyProvider || !model) throw new Error("No provider model is ready");
const agent = await client.agents.create({
config: {
...providerConfig,
cwd,
provider: `${readyProvider.provider}/${model.id}`,
},
initialPrompt: "Start with a quick repository map.",
cwd,
prompt: "Start with a quick repository map.",
});
return agent.id;
@@ -54,3 +52,16 @@ export async function chooseProviderFromSnapshot(url: string, cwd: string): Prom
await client.close();
}
}
export async function runFollowUp(url: string, agentId: string): Promise<string | null> {
const client = createClient(url);
try {
await client.connect();
const result = await client.agents.ref(agentId).run("Summarize your progress and next step.");
if (result.status !== "idle") throw new Error(result.error ?? result.status);
return result.lastMessage;
} finally {
await client.close();
}
}
@@ -0,0 +1,35 @@
import { createPaseoClient, type PaseoClient } from "@getpaseo/client";
interface Issue {
id: string;
title: string;
description: string;
repositoryPath: string;
}
export async function startIssue(client: PaseoClient, issue: Issue) {
const workspace = await client.workspaces.open(issue.repositoryPath);
const agent = await workspace.agents.create({
config: {
provider: "codex/gpt-5.5",
},
title: issue.title,
labels: {
"issue-provider": "my-tracker",
"issue-id": issue.id,
},
prompt: [
`Implement issue ${issue.id}: ${issue.title}`,
"",
issue.description,
"",
"Run focused tests and summarize the result.",
].join("\n"),
});
return { workspaceId: workspace.id, agentId: agent.id };
}
export function createClient(url: string): PaseoClient {
return createPaseoClient({ url });
}
@@ -0,0 +1,37 @@
import type { PaseoAgentHandle, PaseoClient } from "@getpaseo/client";
export async function reviewInParallel(client: PaseoClient, cwd: string): Promise<string[]> {
const agents: PaseoAgentHandle[] = [];
try {
agents.push(
...(await Promise.all([
client.agents.create({
config: {
provider: "codex/gpt-5.5",
},
cwd,
title: "Correctness review",
prompt: "Review the current diff for correctness and missed edge cases.",
}),
client.agents.create({
config: {
provider: "claude/claude-sonnet-5",
},
cwd,
title: "Security review",
prompt: "Review the current diff for security and unsafe input handling.",
}),
])),
);
const results = await Promise.all(agents.map((agent) => agent.waitForFinish()));
return results.map((result) => {
if (result.status !== "idle") throw new Error(result.error ?? result.status);
return result.lastMessage ?? "";
});
} finally {
await Promise.allSettled(agents.map((agent) => agent.archive()));
}
}
+24
View File
@@ -0,0 +1,24 @@
import { createPaseoClient } from "@getpaseo/client";
const client = createPaseoClient({ url: "ws://127.0.0.1:6767/ws" });
await client.connect();
try {
const agent = await client.agents.create({
config: {
provider: "codex/gpt-5.5",
},
cwd: process.cwd(),
prompt: "Inspect this repository and name the next useful task.",
});
const result = await agent.waitForFinish();
if (result.status !== "idle") {
throw new Error(result.error ?? `Agent stopped with status: ${result.status}`);
}
console.log(result.lastMessage);
} finally {
await client.close();
}
+6 -10
View File
@@ -12,18 +12,14 @@ export async function createOpenAndArchiveWorkspace(url: string, cwd: string): P
try {
await client.connect();
const created = await client.workspaces.create(cwd);
if (!created.workspace) {
throw new Error(created.error ?? "Workspace creation failed");
}
const created = await client.workspaces.create({
source: { kind: "directory", path: cwd },
title: "Fresh SDK workspace",
});
const opened = await client.workspaces.open(cwd);
if (!opened.workspace) {
throw new Error(opened.error ?? "Workspace open failed");
}
await opened.workspace.refetch();
await opened.workspace.archive();
await opened.refresh();
await created.archive();
} finally {
await client.close();
}
+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"
+3
View File
@@ -2910,6 +2910,8 @@ export const ServerInfoStatusPayloadSchema = z
features: z
.object({
providersSnapshot: z.boolean().optional(),
// COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10.
providersSnapshotCwd: z.boolean().optional(),
// COMPAT(checkoutForgeSetAutoMerge): added in v0.1.106, remove old
// checkoutGithubSetAutoMerge fallback after 2026-12-28.
checkoutForgeSetAutoMerge: z.boolean().optional(),
@@ -5064,6 +5066,7 @@ export const ListAvailableProvidersResponseSchema = z.object({
export const GetProvidersSnapshotResponseMessageSchema = z.object({
type: z.literal("get_providers_snapshot_response"),
payload: z.object({
cwd: z.string().optional(),
entries: z.array(ProviderSnapshotEntrySchema),
compactSnapshot: CompactProviderSnapshotSchema.optional(),
snapshotHash: z.string().optional(),
@@ -116,6 +116,23 @@ describe("ProviderCatalogSession", () => {
expect(pull?.payload.entries).toEqual(push?.payload.entries);
});
it("returns the canonical cwd used by snapshot updates", async () => {
const getSnapshot = vi.fn((_cwd?: string) => makeEntries());
const { subsystem, emitted } = makeSubsystem({
snapshot: { getSnapshot },
});
await subsystem.handleGetProvidersSnapshotRequest({
type: "get_providers_snapshot_request",
requestId: "canonical-cwd",
cwd: "/repo/./sdk",
});
const canonicalCwd = getSnapshot.mock.calls[0]?.[0];
expect(canonicalCwd).toEqual(expect.any(String));
expect(findByType(emitted, "get_providers_snapshot_response")?.payload.cwd).toBe(canonicalCwd);
});
it("pushes the compact encoding to capable clients", () => {
const { subsystem, emitted, pushSnapshotChange } = makeSubsystem({
supportsCustomModeIcons: true,
@@ -8,6 +8,7 @@ import {
import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js";
import {
isGlobalProviderSnapshotKey,
resolveSnapshotCwd,
type ProviderSnapshotManager,
} from "../../agent/provider-snapshot-manager.js";
import {
@@ -391,8 +392,9 @@ export class ProviderCatalogSession {
msg: Extract<SessionInboundMessage, { type: "get_providers_snapshot_request" }>,
): Promise<void> {
// COMPAT(providersSnapshot): keep legacy provider-list RPCs alongside snapshot flow.
const snapshotCwd = msg.cwd?.trim() ? resolveSnapshotCwd(expandTilde(msg.cwd)) : undefined;
const entries = this.providerSnapshotManager
.getSnapshot(msg.cwd ? expandTilde(msg.cwd) : undefined)
.getSnapshot(snapshotCwd)
.filter((entry) => this.host.isProviderVisibleToClient(entry.provider));
const clientEntries = this.downgradeEntryModesForClient(entries);
@@ -402,6 +404,7 @@ export class ProviderCatalogSession {
this.host.emit({
type: "get_providers_snapshot_response",
payload: {
...(snapshotCwd ? { cwd: snapshotCwd } : {}),
entries: [],
...(!notModified ? { compactSnapshot: encoded.compactSnapshot } : {}),
snapshotHash: encoded.snapshotHash,
@@ -416,6 +419,7 @@ export class ProviderCatalogSession {
this.host.emit({
type: "get_providers_snapshot_response",
payload: {
...(snapshotCwd ? { cwd: snapshotCwd } : {}),
entries: clientEntries,
generatedAt: new Date().toISOString(),
requestId: msg.requestId,
@@ -933,6 +933,7 @@ describe("relay external socket reconnect behavior", () => {
expect(serverInfo.features?.stableProjectIdentity).toBe(true);
expect(serverInfo.features?.canonicalSubmittedPrompts).toBe(true);
expect(serverInfo.features?.providersSnapshotCwd).toBe(true);
expect(serverInfo.features?.["terminal-input-mode-replay"]).toBe(true);
expect(serverInfo.features?.["terminal-size-ownership"]).toBe(true);
expect(serverInfo.features?.agentTurnIdentity).toBeUndefined();
@@ -1526,6 +1526,8 @@ export class VoiceAssistantWebSocketServer {
features: {
// COMPAT(providersSnapshot): keep optional until all clients rely on snapshot flow.
providersSnapshot: true,
// COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10.
providersSnapshotCwd: true,
// COMPAT(checkoutForgeSetAutoMerge): added in v0.1.106, remove old
// checkoutGithubSetAutoMerge fallback after 2026-12-28.
checkoutForgeSetAutoMerge: true,