From 7954babca6f9e62e55b4108a63a6cbbc05ac54e4 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 10 Aug 2026 13:22:20 +0200 Subject: [PATCH] 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. --- README.md | 26 +- packages/client/README.md | 41 +- packages/client/examples/README.md | 9 +- .../client/examples/agents-and-providers.ts | 37 +- packages/client/examples/issue-to-agent.ts | 35 ++ packages/client/examples/parallel-review.ts | 37 ++ packages/client/examples/quickstart.ts | 24 + packages/client/examples/workspaces.ts | 16 +- packages/client/src/index.test.ts | 389 +++++++++++++-- packages/client/src/index.ts | 447 +++++++++++++----- packages/protocol/src/messages.ts | 3 + .../provider/provider-catalog-session.test.ts | 17 + .../provider/provider-catalog-session.ts | 6 +- .../websocket-server.relay-reconnect.test.ts | 1 + .../server/src/server/websocket-server.ts | 2 + public-docs/sdk/agents.md | 150 ++++++ public-docs/sdk/events.md | 102 ++++ public-docs/sdk/index.md | 50 ++ public-docs/sdk/provider-options.md | 172 +++++++ public-docs/sdk/providers.md | 114 +++++ public-docs/sdk/quickstart.md | 90 ++++ public-docs/sdk/recipes.md | 136 ++++++ public-docs/sdk/reference.md | 135 ++++++ public-docs/sdk/workspaces.md | 114 +++++ 24 files changed, 1956 insertions(+), 197 deletions(-) create mode 100644 packages/client/examples/issue-to-agent.ts create mode 100644 packages/client/examples/parallel-review.ts create mode 100644 packages/client/examples/quickstart.ts create mode 100644 public-docs/sdk/agents.md create mode 100644 public-docs/sdk/events.md create mode 100644 public-docs/sdk/index.md create mode 100644 public-docs/sdk/provider-options.md create mode 100644 public-docs/sdk/providers.md create mode 100644 public-docs/sdk/quickstart.md create mode 100644 public-docs/sdk/recipes.md create mode 100644 public-docs/sdk/reference.md create mode 100644 public-docs/sdk/workspaces.md diff --git a/README.md b/README.md index 4382873e3..f16d2c6c4 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ Everything you can do in the app, you can do from the terminal. ```bash paseo run --provider claude/opus-4.6 "implement user authentication" -paseo run --provider codex/gpt-5.4 --worktree feature-x "implement feature X" +paseo run --provider codex/gpt-5.5 --worktree feature-x "implement feature X" paseo ls # list running agents paseo attach abc123 # stream live output @@ -117,6 +117,30 @@ paseo --host workstation.local:6767 run "run the full test suite" See the [full CLI reference](https://paseo.sh/docs/cli) for more. +## TypeScript SDK + +Build issue integrations, dashboards, and orchestration services with `@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(); +``` + +See the [SDK quickstart](https://paseo.sh/docs/sdk/quickstart), [recipes](https://paseo.sh/docs/sdk/recipes), and [API reference](https://paseo.sh/docs/sdk/reference). + ## Skills Skills teach your agent to use Paseo to orchestrate other agents. diff --git a/packages/client/README.md b/packages/client/README.md index e525bc5b4..444f1ddc7 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -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. diff --git a/packages/client/examples/README.md b/packages/client/examples/README.md index dc3cbfcb0..94ae18437 100644 --- a/packages/client/examples/README.md +++ b/packages/client/examples/README.md @@ -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. diff --git a/packages/client/examples/agents-and-providers.ts b/packages/client/examples/agents-and-providers.ts index ed8ad4b23..c27ca1871 100644 --- a/packages/client/examples/agents-and-providers.ts +++ b/packages/client/examples/agents-and-providers.ts @@ -14,13 +14,11 @@ export async function createCodexAgent(url: string, cwd: string): Promise 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 { + 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(); + } +} diff --git a/packages/client/examples/issue-to-agent.ts b/packages/client/examples/issue-to-agent.ts new file mode 100644 index 000000000..9625f1dec --- /dev/null +++ b/packages/client/examples/issue-to-agent.ts @@ -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 }); +} diff --git a/packages/client/examples/parallel-review.ts b/packages/client/examples/parallel-review.ts new file mode 100644 index 000000000..0432653b0 --- /dev/null +++ b/packages/client/examples/parallel-review.ts @@ -0,0 +1,37 @@ +import type { PaseoAgentHandle, PaseoClient } from "@getpaseo/client"; + +export async function reviewInParallel(client: PaseoClient, cwd: string): Promise { + 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())); + } +} diff --git a/packages/client/examples/quickstart.ts b/packages/client/examples/quickstart.ts new file mode 100644 index 000000000..159dc3956 --- /dev/null +++ b/packages/client/examples/quickstart.ts @@ -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(); +} diff --git a/packages/client/examples/workspaces.ts b/packages/client/examples/workspaces.ts index 3605d68b6..6bb718737 100644 --- a/packages/client/examples/workspaces.ts +++ b/packages/client/examples/workspaces.ts @@ -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(); } diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index dedf7ce2e..a89191ae7 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -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 = { 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(); }); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index a0fdbd5e7..ddfb28b26 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -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 & { + 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; + readonly projectId: string | null; + readonly directory: string | null; + readonly name: string | null; + readonly status: PaseoWorkspace["status"] | null; + readonly agents: { + create(options: PaseoWorkspaceAgentCreateOptions): Promise; + }; + current(): PaseoWorkspace | null; + refresh(options?: { requestId?: string }): Promise; archive(requestId?: string): Promise; /** * 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; - create( - input: string | PaseoWorkspaceOpenOptions, - requestId?: string, - ): Promise; + ): Promise; + create(options: PaseoWorkspaceCreateOptions): Promise; archive( workspace: string | PaseoWorkspaceHandle, requestId?: string, @@ -160,25 +162,43 @@ export interface PaseoWorkspaceActions { } type PaseoAgentSessionConfig = CreateAgentRequestMessage["config"]; -type PaseoAgentProvider = PaseoAgentSessionConfig["provider"]; -type PaseoAgentConfigOverrides = Partial>; +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; + +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; images?: CreateAgentRequestMessage["images"]; attachments?: CreateAgentRequestMessage["attachments"]; git?: CreateAgentRequestMessage["git"]; + worktree?: CreateAgentRequestMessage["worktree"]; + autoArchive?: CreateAgentRequestMessage["autoArchive"]; requestId?: string; labels?: Record; } +export type PaseoWorkspaceAgentCreateOptions = Omit; + 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["payload"]; export type PaseoAgentStream = Extract["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; /** @@ -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; + current(): PaseoAgent | null; + refresh(requestId?: string): Promise; send(text: string, options?: PaseoAgentSendOptions): Promise; + /** Sends a prompt and resolves when that turn finishes or needs attention. */ + run(text: string, options?: PaseoAgentRunOptions): Promise; + /** Waits for the current turn, including one started with `prompt`. */ + waitForFinish(timeoutMs?: number): Promise; archive(): Promise<{ archivedAt: string }>; detach(): Promise; subscribe(handler: (update: PaseoAgentUpdate) => void): () => void; } export interface PaseoAgentActions { + list(options?: PaseoAgentListOptions): Promise; ref(agent: string | PaseoAgent): PaseoAgentHandle; create(options: PaseoAgentCreateOptions): Promise; /** @@ -245,21 +273,16 @@ export interface PaseoAgentActions { subscribe(handler: PaseoAgentUpdateHandler): () => void; } -export interface PaseoProviderConfig extends PaseoProviderConfigInput { - provider: PaseoAgentProvider; -} -export type PaseoProviderFeatureValues = Record; - -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; listAvailable(options?: { requestId?: string }): Promise; snapshot(options?: PaseoProviderListOptions): Promise; + /** Resolves after the daemon's lazy provider discovery has finished. */ + waitForReady(options?: PaseoProviderWaitOptions): Promise; refresh(options?: PaseoProviderRefreshOptions): Promise; 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; -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 { +): Promise { 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 { + // 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(); + 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" diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 98cf90d1a..4421080c9 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -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(), diff --git a/packages/server/src/server/session/provider/provider-catalog-session.test.ts b/packages/server/src/server/session/provider/provider-catalog-session.test.ts index 53d869b0d..63000ca0d 100644 --- a/packages/server/src/server/session/provider/provider-catalog-session.test.ts +++ b/packages/server/src/server/session/provider/provider-catalog-session.test.ts @@ -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, diff --git a/packages/server/src/server/session/provider/provider-catalog-session.ts b/packages/server/src/server/session/provider/provider-catalog-session.ts index 7b122411c..ed7509a91 100644 --- a/packages/server/src/server/session/provider/provider-catalog-session.ts +++ b/packages/server/src/server/session/provider/provider-catalog-session.ts @@ -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, ): Promise { // 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, diff --git a/packages/server/src/server/websocket-server.relay-reconnect.test.ts b/packages/server/src/server/websocket-server.relay-reconnect.test.ts index 800fa3c8c..790d06338 100644 --- a/packages/server/src/server/websocket-server.relay-reconnect.test.ts +++ b/packages/server/src/server/websocket-server.relay-reconnect.test.ts @@ -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(); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 7797e466c..a6d0d5565 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -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, diff --git a/public-docs/sdk/agents.md b/public-docs/sdk/agents.md new file mode 100644 index 000000000..bcba28dd7 --- /dev/null +++ b/public-docs/sdk/agents.md @@ -0,0 +1,150 @@ +--- +title: Agents with the SDK +description: Create, run, find, reuse, parent, and archive coding agents from TypeScript. +nav: Agents +order: 52 +category: TypeScript SDK +--- + +# Agents with the SDK + +An agent handle keeps a stable agent ID and exposes the turn lifecycle without exposing daemon RPCs. + +## Run an initial prompt + +```ts +const agent = await client.agents.create({ + config: { + provider: "claude/claude-sonnet-5", + }, + cwd: "/Users/me/dev/storefront", + prompt: "Review the checkout flow and propose one focused fix.", + labels: { source: "checkout-review" }, +}); + +const result = await agent.waitForFinish(); +console.log(result.status, result.lastMessage); +``` + +`waitForFinish()` returns one of four statuses: + +| Status | Meaning | +| ------------ | ----------------------------------------------------------------- | +| `idle` | The turn completed and the agent can accept another prompt. | +| `permission` | The agent needs a person to answer a permission request in Paseo. | +| `error` | The provider ended the turn with an error. | +| `timeout` | The wait deadline elapsed; the agent may still be running. | + +A timeout does not cancel the agent. + +## Keep a session alive for follow-ups + +Create an idle session when prompts arrive later: + +```ts +const reviewer = await client.agents.create({ + config: { + provider: "codex/gpt-5.5", + }, + cwd: "/Users/me/dev/storefront", + title: "Checkout reviewer", +}); + +const first = await reviewer.run("Review the current diff."); + +if (first.status === "idle") { + const second = await reviewer.run("Now focus on failure recovery."); + console.log(second.lastMessage); +} +``` + +Use `send()` for fire-and-forget delivery. Use `run()` when the caller needs the outcome of that turn. + +## Find agents by label + +Set `labels` at creation, then filter on them. The daemon does the matching: + +```ts +const page = await client.agents.list({ + filter: { labels: { "issue-provider": "my-tracker" } }, +}); + +for (const { agent } of page.entries) { + console.log(agent.id, agent.title, agent.status); +} +``` + +## Continue an agent by ID + +```ts +const agent = client.agents.ref("agent_01H8X..."); + +const result = await agent.run("Now write the fix."); +console.log(result.lastMessage); +``` + +`ref()` does not contact the daemon. Call `refresh()` first when you need to know whether the agent still exists; it returns `null` if it does not. + +## Create a subagent + +Create a child through its workspace. The handle owns placement, so the caller does not repeat its directory: + +```ts +if (!parent.workspaceId) throw new Error("Parent has no workspace"); + +const workspace = client.workspaces.ref(parent.workspaceId); +const child = await workspace.agents.create({ + config: { + provider: "codex/gpt-5.5", + }, + parent, + title: "Implement checkout fix", + prompt: "Implement the accepted checkout plan and run focused tests.", +}); +``` + +`parent` establishes parentage. Archiving a parent cascade-archives its children. Call `detach()` first when a child should continue independently. + +## Request structured output + +```ts +const schema = { + type: "object", + properties: { + summary: { type: "string" }, + risk: { type: "string", enum: ["low", "medium", "high"] }, + }, + required: ["summary", "risk"], + additionalProperties: false, +}; + +const agent = await client.agents.create({ + config: { + provider: "codex/gpt-5.5", + }, + cwd: "/Users/me/dev/storefront", + outputSchema: schema, + prompt: "Assess the release risk of the current diff.", +}); + +const result = await agent.waitForFinish(); +if (result.status !== "idle" || !result.lastMessage) { + throw new Error(result.error ?? "Agent returned no structured output"); +} + +const assessment = JSON.parse(result.lastMessage) as { + summary: string; + risk: "low" | "medium" | "high"; +}; +``` + +Validate the parsed value in your application before using it as trusted input. + +## Archive or detach + +```ts +await agent.archive(); // Soft-deletes the agent and closes its runtime. +await child.detach(); // Keeps the child alive but removes its parent relationship. +``` + +Closing the SDK connection does not archive agents. Archive temporary agents explicitly, preferably in `finally`. diff --git a/public-docs/sdk/events.md b/public-docs/sdk/events.md new file mode 100644 index 000000000..36384554e --- /dev/null +++ b/public-docs/sdk/events.md @@ -0,0 +1,102 @@ +--- +title: SDK events +description: Subscribe to agent status, timeline, workspace, and provider updates without maintaining a second state model. +nav: Events +order: 56 +category: TypeScript SDK +--- + +# SDK events + +Subscriptions report changes after they happen. Fetch an initial snapshot first, then apply updates to it. + +Every `subscribe()` method returns a local unsubscribe function. It removes your callback; it does not stop or archive the underlying resource. + +## Follow one agent's status + +The daemon sends agent-directory updates only after the connection opens an agent-list subscription: + +```ts +await client.agents.list({ + filter: { includeArchived: false }, + subscribe: { subscriptionId: "issue-board-agents" }, +}); + +const agent = client.agents.ref(agentId); +await agent.refresh(); + +const unsubscribe = agent.subscribe((update) => { + if (update.kind === "upsert") { + console.log(update.agent.status); + } else { + console.log("Agent removed from the active directory"); + } +}); +``` + +The handle updates its properties and `current()` value before it calls your handler. + +## Follow timeline events + +```ts +const unsubscribe = agent.timeline.subscribe(({ event, timestamp }) => { + if (event.type === "timeline" && event.item.type === "assistant_message") { + process.stdout.write(event.item.text); + } + + if (event.type === "turn_completed") { + console.log(`\nCompleted at ${timestamp}`); + } +}); +``` + +Assistant messages can arrive in pieces. Concatenate their text when you need a complete message, or use `run()` and read `lastMessage` when you only need the final reply. + +Turn completion comes from `turn_completed`, `turn_failed`, or `turn_canceled`. Do not infer turn completion from an `agent_update` transition to `idle`. + +## Fetch timeline history + +```ts +const page = await agent.timeline.refetch({ + direction: "before", + limit: 100, + projection: "projected", +}); + +for (const entry of page.entries) { + console.log(entry.seq, entry.event.type); +} +``` + +Use `startCursor`, `endCursor`, `hasOlder`, and `hasNewer` from the result to page without inventing offsets. + +## Follow workspace updates + +Workspace updates also require a directory subscription: + +```ts +await client.workspaces.list({ + subscribe: { subscriptionId: "issue-board-workspaces" }, +}); + +const workspace = client.workspaces.ref(workspaceId); +const unsubscribe = workspace.subscribe((update) => { + if (update.kind === "upsert") { + console.log(update.workspace.status); + } +}); +``` + +## Follow provider catalog changes + +```ts +const unsubscribe = client.providers.subscribe((update) => { + const ready = update.entries.filter((entry) => entry.status === "ready"); + console.log( + "Ready providers:", + ready.map((entry) => entry.provider), + ); +}); +``` + +Always call the returned unsubscribe functions before discarding the owning object. Call `client.close()` when the application no longer needs the daemon connection. diff --git a/public-docs/sdk/index.md b/public-docs/sdk/index.md new file mode 100644 index 000000000..5fb85c5dd --- /dev/null +++ b/public-docs/sdk/index.md @@ -0,0 +1,50 @@ +--- +title: TypeScript SDK +description: Run Paseo coding agents from a TypeScript program. +nav: Overview +order: 50 +category: TypeScript SDK +--- + +# TypeScript SDK + +`@getpaseo/client` is a TypeScript library that drives a Paseo daemon from your own program. You pick a provider and model, give an agent a prompt and a directory, and wait for the answer. + +The daemon does the work: it launches the provider CLI, keeps the session alive, and streams it to the Paseo app. Your program is a client. Agents you create show up in Paseo next to the ones you started by hand, and they stay there after your program exits. + +Use it to: + +- turn an issue, alert, or webhook into a coding task; +- run several agents in parallel and collect their answers; +- hold a session open and send follow-up prompts; +- build a dashboard over your agents. + +## Start a daemon + +```bash +npx @getpaseo/cli +``` + +It listens on `ws://127.0.0.1:6767/ws`. + +## Run an agent + +```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(); +``` + +[Quickstart](/docs/sdk/quickstart) walks through this line by line and covers passwords and remote daemons. diff --git a/public-docs/sdk/provider-options.md b/public-docs/sdk/provider-options.md new file mode 100644 index 000000000..a8bd5bad0 --- /dev/null +++ b/public-docs/sdk/provider-options.md @@ -0,0 +1,172 @@ +--- +title: Provider options +description: Sandboxing, permissions, and network rules for Codex, Claude, and OpenCode agents created from the SDK. +nav: Provider options +order: 55 +category: TypeScript SDK +--- + +# Provider options + +`config.options` passes settings straight through to the provider CLI. Paseo validates the object against that provider's strict schema before starting the agent, so an unknown or misspelled key fails agent creation instead of silently doing nothing. + +Options are provider-native. A Codex sandbox key is not a Claude sandbox key. Codex, Claude, and OpenCode accept options; every other provider rejects a non-empty `options`. + +Provider options are not a host boundary. They constrain the agent CLI, which runs as your user on your machine. For untrusted work, run the daemon in a container or on a separate machine. See [Security](/docs/security). + +## Codex + +Confine writes to one directory, cut off network access, and stop approval prompts so the run is unattended: + +```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", + options: { + approval_policy: "never", + sandbox_mode: "workspace-write", + sandbox_workspace_write: { + writable_roots: ["/Users/me/dev/storefront"], + network_access: false, + exclude_slash_tmp: true, + }, + web_search: "disabled", + }, + }, + cwd: "/Users/me/dev/storefront", + prompt: "Fix the failing checkout test.", +}); + +const result = await agent.waitForFinish(); +console.log(result.status, result.lastMessage); + +await client.close(); +``` + +| Option | Values | +| ------------------------- | --------------------------------------------------------------------------------- | +| `approval_policy` | `untrusted`, `on-request`, `never`, or `{ granular: { … } }` | +| `sandbox_mode` | `read-only`, `workspace-write`, `danger-full-access` | +| `sandbox_workspace_write` | `writable_roots`, `network_access`, `exclude_slash_tmp`, `exclude_tmpdir_env_var` | +| `web_search` | `disabled`, `cached`, `indexed`, `live` | +| `features` | `multi_agent_v2`, `network_proxy` (boolean or a proxy/domain policy object) | + +`approval_policy: "never"` only removes the prompts. What the agent is allowed to touch is `sandbox_mode`. Setting `never` without a sandbox mode gives an unattended agent full access. + +## Claude + +Turn on Claude's own sandbox, restrict writes to the project, deny reads of credential directories, and allow only two domains: + +```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: "claude/claude-sonnet-5", + options: { + disallowedTools: ["WebFetch"], + sandbox: { + enabled: true, + failIfUnavailable: true, + allowUnsandboxedCommands: false, + filesystem: { + allowWrite: ["/Users/me/dev/storefront"], + denyRead: ["/Users/me/.ssh", "/Users/me/.aws"], + }, + network: { + allowedDomains: ["registry.npmjs.org", "github.com"], + strictAllowlist: true, + }, + }, + }, + }, + cwd: "/Users/me/dev/storefront", + prompt: "Install dependencies and run the focused test.", +}); + +const result = await agent.waitForFinish(); +console.log(result.status, result.lastMessage); + +await client.close(); +``` + +| Option | Values | +| ----------------------- | ------------------------------------------------------------------------------------------------------- | +| `allowedTools` | Tool names the agent may use without asking | +| `disallowedTools` | Tool names the agent may never use | +| `additionalDirectories` | Extra directories the agent may access | +| `sandbox` | `enabled`, `failIfUnavailable`, `allowUnsandboxedCommands`, `excludedCommands`, `filesystem`, `network` | +| `settings` | `permissions` (`allow`/`ask`/`deny` rule lists) and a nested `sandbox` | + +`failIfUnavailable: true` makes agent startup fail when the sandbox cannot be established. Leave it off and Claude runs unsandboxed instead. + +`sandbox.filesystem` takes `allowWrite`, `denyWrite`, `allowRead`, and `denyRead`. `sandbox.network` takes `allowedDomains`, `deniedDomains`, `strictAllowlist`, and proxy settings. + +## OpenCode + +Allow reads and edits, deny anything that reaches outside the project, and gate shell commands by pattern: + +```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: "opencode/opencode/gpt-5.5", + options: { + permission: { + read: "allow", + edit: "allow", + webfetch: "deny", + external_directory: "deny", + bash: { + "git status": "allow", + "git diff*": "allow", + "git push*": "deny", + "*": "ask", + }, + }, + }, + }, + cwd: "/Users/me/dev/storefront", + prompt: "Implement the requested change and show me the diff.", +}); + +const result = await agent.waitForFinish(); +console.log(result.status, result.lastMessage); + +await client.close(); +``` + +Every permission is `ask`, `allow`, or `deny`. Tools that take a target — `read`, `edit`, `glob`, `grep`, `list`, `bash`, `task`, `external_directory`, `repo_clone`, `repo_overview`, `lsp`, `skill` — also accept a pattern map, where later keys act as the fallback. Tools without a target — `todowrite`, `question`, `webfetch`, `websearch`, `codesearch`, `doom_loop` — take a bare action. + +`permission: "deny"` as a bare string applies to everything at once. + +An `ask` resolves to a permission request in Paseo, and `waitForFinish()` returns `permission` while it is pending. Use `allow` and `deny` for unattended runs. See [Events](/docs/sdk/events) for reacting to a pending request. + +## Modes and options + +`modeId` picks a Paseo mode from the provider's published list, which the daemon shows in the app and reports through [provider discovery](/docs/sdk/providers). `options` is the provider's own configuration. They are separate controls and you can set both: + +```ts +config: { + provider: "codex/gpt-5.5", + modeId: "full-access", + options: { + approval_policy: "never", + sandbox_mode: "read-only", + }, +} +``` + +Where the two overlap, `options` wins. Above, Codex runs read-only even though `full-access` would otherwise grant more. diff --git a/public-docs/sdk/providers.md b/public-docs/sdk/providers.md new file mode 100644 index 000000000..3abddafb5 --- /dev/null +++ b/public-docs/sdk/providers.md @@ -0,0 +1,114 @@ +--- +title: Providers with the SDK +description: Select a provider and model, discover available configurations, and configure an agent session. +nav: Providers +order: 54 +category: TypeScript SDK +--- + +# Providers with the SDK + +Every agent configuration names both the provider and model: + +```ts +config: { + provider: "codex/gpt-5.5", +} +``` + +The first `/` separates the provider from the model. Model IDs can contain additional slashes. + +## Configure a session + +```ts +const agent = await client.agents.create({ + config: { + provider: "codex/gpt-5.5", + modeId: "full-access", + thinkingOptionId: "high", + featureValues: { + web_search: false, + }, + }, + cwd: process.cwd(), + prompt: "Implement the accepted plan and run focused tests.", +}); +``` + +| Field | Meaning | +| ------------------ | -------------------------------------------------------------- | +| `provider` | Required `provider/model` selection. | +| `modeId` | Provider operating or permission mode. | +| `thinkingOptionId` | Provider reasoning level. | +| `featureValues` | Values for features returned by `providers.listFeatures()`. | +| `options` | Provider-native settings such as sandbox and permission rules. | +| `systemPrompt` | Additional system or developer instructions. | +| `mcpServers` | Session-scoped MCP servers. | +| `toolPolicy` | Exact preapproval rules for MCP tools. | + +[Provider options](/docs/sdk/provider-options) lists the accepted sandbox and permission settings. + +## Discover installed providers and models + +```ts +const snapshot = await client.providers.waitForReady({ + cwd: process.cwd(), + timeoutMs: 60_000, +}); + +for (const entry of snapshot.entries) { + if (entry.status !== "ready") continue; + + for (const model of entry.models ?? []) { + console.log(`${entry.provider}/${model.id}`); + } +} +``` + +An entry finishes as `ready`, `unavailable`, or `error`. `snapshot()` returns immediately and can include `loading` entries. + +## Select a discovered model + +```ts +const snapshot = await client.providers.waitForReady({ cwd: process.cwd() }); +const entry = snapshot.entries.find((candidate) => candidate.status === "ready"); +const model = entry?.models?.find((candidate) => candidate.isDefault) ?? entry?.models?.[0]; + +if (!entry || !model) throw new Error("No provider model is ready"); + +const agent = await client.agents.create({ + config: { + provider: `${entry.provider}/${model.id}`, + }, + cwd: process.cwd(), + prompt: "Summarize this repository.", +}); +``` + +## Discover modes, thinking levels, and features + +```ts +const models = await client.providers.listModels("codex", { cwd: process.cwd() }); +const modes = await client.providers.listModes("codex", { cwd: process.cwd() }); + +const selectedModel = models.models[0]?.id; +const selectedMode = modes.modes[0]?.id; +if (!selectedModel) throw new Error("No Codex model is available"); + +const features = await client.providers.listFeatures({ + provider: `codex/${selectedModel}`, + cwd: process.cwd(), + modeId: selectedMode, +}); +``` + +Use IDs returned by the daemon. Provider installations and configured models differ between hosts. + +## Diagnose an unavailable provider + +```ts +const result = await client.providers.diagnostic("codex"); +console.error(result.diagnostic); +``` + +Host-level profiles, binaries, credentials, and custom providers belong in [Custom providers](/docs/custom-providers). diff --git a/public-docs/sdk/quickstart.md b/public-docs/sdk/quickstart.md new file mode 100644 index 000000000..11ab34c0a --- /dev/null +++ b/public-docs/sdk/quickstart.md @@ -0,0 +1,90 @@ +--- +title: SDK quickstart +description: Connect to a Paseo daemon, run one coding agent, and read its reply. +nav: Quickstart +order: 51 +category: TypeScript SDK +--- + +# SDK quickstart + +```bash +npm install @getpaseo/client +``` + +Requires Node.js 22 or newer. + +## Connect + +```ts +import { createPaseoClient } from "@getpaseo/client"; + +const client = createPaseoClient({ url: "ws://127.0.0.1:6767/ws" }); +await client.connect(); +``` + +`connect()` resolves once the daemon has identified itself. If the daemon has a password, pass it: + +```ts +const client = createPaseoClient({ + url: "wss://devbox.example.com/ws", + password: "my-secret", +}); +``` + +## Create an agent + +```ts +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.", +}); +``` + +`config.provider` is always `provider/model`. [Providers](/docs/sdk/providers) lists what the daemon has available and how to discover it at runtime. + +`cwd` is the directory the agent works in. Paseo creates the workspace behind it. [Workspaces](/docs/sdk/workspaces) covers reusing one instead. + +`create()` resolves as soon as the session exists. The prompt is still running. + +## Wait for the reply + +```ts +const result = await agent.waitForFinish(); + +if (result.status === "idle") { + console.log(result.lastMessage); +} +``` + +`waitForFinish()` waits up to 10 minutes by default; pass milliseconds to change it. It returns one of four statuses: + +| Status | Meaning | +| ------------ | ----------------------------------------------------------------- | +| `idle` | The turn finished and the agent can take another prompt. | +| `permission` | The agent needs a person to answer a permission request in Paseo. | +| `error` | The provider ended the turn with an error. | +| `timeout` | The deadline elapsed. The agent is still running. | + +## Disconnect + +```ts +await client.close(); +``` + +The agent keeps running on the daemon and stays visible in Paseo. A later program reaches it again by ID: + +```ts +const agent = client.agents.ref("agent_01H8X..."); +await agent.refresh(); +await agent.run("Now write the fix."); +``` + +## Next + +- [Agents](/docs/sdk/agents), follow-up prompts, finding existing agents, archiving. +- [Providers](/docs/sdk/providers), discovering models and modes from the daemon. +- [Provider options](/docs/sdk/provider-options), sandboxing and provider-native settings. +- [Workspaces](/docs/sdk/workspaces), reusing a workspace or creating a worktree. +- [Events](/docs/sdk/events), streaming updates instead of waiting. diff --git a/public-docs/sdk/recipes.md b/public-docs/sdk/recipes.md new file mode 100644 index 000000000..5493d57c2 --- /dev/null +++ b/public-docs/sdk/recipes.md @@ -0,0 +1,136 @@ +--- +title: SDK recipes +description: Complete patterns for issue integrations, parallel review, resident agents, and safe temporary-agent cleanup. +nav: Recipes +order: 57 +category: TypeScript SDK +--- + +# SDK recipes + +Examples use a connected `client` imported from the public package root. + +## Turn an issue into visible work + +After selecting an issue: + +```ts +type Issue = { + id: string; + title: string; + description: string; + repositoryPath: string; +}; + +async function startIssue(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 }; +} +``` + +Persist the returned IDs in your integration. On the next webhook or page load, recover handles with `workspaces.ref()` and `agents.ref()` instead of creating duplicates. + +## Run parallel reviewers + +```ts +const prompts = [ + "Review the diff for correctness and missed edge cases.", + "Review the diff for security and unsafe input handling.", + "Review the diff for unnecessary complexity.", +]; + +const reviewers = await Promise.all( + prompts.map((prompt, index) => + client.agents.create({ + config: { + provider: index === 1 ? "claude/claude-sonnet-5" : "codex/gpt-5.5", + }, + cwd: process.cwd(), + title: `Review ${index + 1}`, + prompt, + }), + ), +); + +const results = await Promise.all(reviewers.map((reviewer) => reviewer.waitForFinish())); + +for (const result of results) { + console.log(result.status, result.lastMessage); +} +``` + +## Keep a resident role across process restarts + +```ts +async function getPlanner() { + const listed = await client.agents.list({ + filter: { includeArchived: false }, + page: { limit: 100 }, + }); + + const existing = listed.entries.find(({ agent }) => agent.labels["my-app-role"] === "planner"); + + if (existing) return client.agents.ref(existing.agent); + + return client.agents.create({ + config: { + provider: "claude/claude-sonnet-5", + }, + cwd: process.cwd(), + title: "Planner", + labels: { "my-app-role": "planner" }, + }); +} + +const planner = await getPlanner(); +const plan = await planner.run("Plan the next small, shippable improvement."); +``` + +Labels are application-owned metadata. Namespace keys when several tools may manage agents on the same daemon. + +## Clean up temporary agents + +```ts +const temporaryAgents = []; + +try { + const agent = await client.agents.create({ + config: { + provider: "codex/gpt-5.5", + }, + cwd: process.cwd(), + title: "Temporary smoke test", + }); + temporaryAgents.push(agent); + + const result = await agent.run("Reply with READY and nothing else.", { + timeoutMs: 2 * 60_000, + }); + + if (result.status !== "idle") { + throw new Error(result.error ?? result.status); + } +} finally { + await Promise.allSettled(temporaryAgents.map((agent) => agent.archive())); +} +``` + +Do not archive agents your integration did not create. diff --git a/public-docs/sdk/reference.md b/public-docs/sdk/reference.md new file mode 100644 index 000000000..c7fe6397f --- /dev/null +++ b/public-docs/sdk/reference.md @@ -0,0 +1,135 @@ +--- +title: SDK API reference +description: Public configuration, methods, handles, results, defaults, and lifecycle behavior for @getpaseo/client. +nav: API reference +order: 58 +category: TypeScript SDK +--- + +# SDK API reference + +Import every supported runtime value and TypeScript type from `@getpaseo/client`. + +## `createPaseoClient(config)` + +Creates a client without opening the connection. + +Required configuration: + +| Field | Type | Meaning | +| ----- | -------- | ------------------------------------------- | +| `url` | `string` | Daemon WebSocket endpoint, including `/ws`. | + +Common optional configuration: + +| Field | Type | Default | Meaning | +| ----------------------- | ------------- | -------------- | ------------------------------------------------ | +| `clientId` | `string` | Generated | Stable identifier for logs and subscriptions. | +| `password` | `string` | Unset | Daemon password. | +| `authHeader` | `string` | Unset | Complete authorization-header value for a proxy. | +| `connectTimeoutMs` | `number` | Client default | Connection deadline. | +| `reconnect.enabled` | `boolean` | Client default | Reconnect after an unexpected disconnect. | +| `reconnect.baseDelayMs` | `number` | Client default | Initial reconnect delay. | +| `reconnect.maxDelayMs` | `number` | Client default | Maximum reconnect delay. | +| `logger` | `PaseoLogger` | Unset | Debug, info, warning, and error sink. | + +Relay E2EE clients can also pass `e2ee.enabled` and `e2ee.daemonPublicKeyB64`. `appVersion`, `runtimeGeneration`, and runtime-metrics options exist for Paseo client surfaces; ordinary integrations can omit them. + +## Client lifecycle + +| Method | Result | Behavior | +| ---------------------- | ----------------- | ------------------------------------------------------------------------- | +| `connect()` | `Promise` | Resolves after the daemon sends its server information. | +| `close()` | `Promise` | Closes the connection and disposes this client. | +| `ensureConnected()` | `void` | Throws unless the client is connected. | +| `getConnectionState()` | `ConnectionState` | Returns `idle`, `connecting`, `connected`, `disconnected`, or `disposed`. | + +Create a new client after `close()`. + +## `client.agents` + +| Method | Result | Behavior | +| -------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------ | +| `list(options?)` | `PaseoAgentListResult` | Lists a page of agents. `scope`, `filter`, `sort`, `page`, and `subscribe` match the daemon directory query. | +| `create(options)` | `PaseoAgentHandle` | Creates an agent and a fresh workspace for `cwd`. Requires `config`. | +| `ref(agentOrId)` | `PaseoAgentHandle` | Creates a local handle without fetching. | +| `subscribe(handler)` | Unsubscribe function | Listens for connection-local agent directory updates. Call `list({ subscribe })` first. | + +Creation options include `config`, `cwd`, `parent`, `title`, `prompt`, `env`, `outputSchema`, `images`, `attachments`, `git`, `worktree`, `autoArchive`, and `labels`. + +`config` accepts: + +| Field | Type | Meaning | +| ------------------ | ------------------------- | ------------------------------------------------------------------------------------------------- | +| `provider` | `string` | Required `provider/model` selection. | +| `modeId` | `string` | Provider operating or permission mode. | +| `thinkingOptionId` | `string` | Provider reasoning level. | +| `featureValues` | `Record` | Values for features discovered through `providers.listFeatures`. | +| `options` | JSON object | Provider-native settings, strictly validated. See [Provider options](/docs/sdk/provider-options). | +| `systemPrompt` | `string` | Additional system or developer instructions. | +| `mcpServers` | MCP server map | Session-scoped MCP servers. | +| `toolPolicy` | MCP tool policy | Exact preapproval rules for MCP tools. | + +### Agent handle + +| Member | Result | Behavior | +| --------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------- | +| `id` | `string` | Stable daemon agent ID. | +| `workspaceId` | `string \| null` | Current workspace placement. | +| `cwd` | `string \| null` | Current working directory. | +| `status` | Agent status or `null` | Current lifecycle status. | +| `current()` | `PaseoAgent \| null` | Current detailed value observed by this handle; never fetches. | +| `refresh(requestId?)` | `PaseoAgentRefetchResult \| null` | Fetches the current agent and project placement. | +| `send(text, options?)` | `Promise` | Resolves when the daemon accepts the prompt. | +| `run(text, options?)` | `PaseoAgentRunResult` | Sends a prompt and waits for that turn. `timeoutMs` controls the wait; it defaults to 10 minutes. | +| `waitForFinish(timeoutMs?)` | `PaseoAgentRunResult` | Waits for the active turn, including an initial prompt. Default timeout: 10 minutes. | +| `subscribe(handler)` | Unsubscribe function | Filters agent-directory updates to this ID and refreshes the handle properties. | +| `archive()` | `{ archivedAt }` | Soft-deletes the agent and closes its runtime. | +| `detach()` | `Promise` | Removes the parent relationship without stopping the agent. | + +`PaseoAgentRunResult` contains `status`, `final`, `error`, and `lastMessage`. `final` refreshes the handle when present. + +### Timeline handle + +`agent.timeline.refetch(options?)` fetches a page. Options are `direction`, `cursor`, `limit`, `projection`, and `requestId`. + +`agent.timeline.subscribe(handler)` listens for stream events belonging to the agent and returns a local unsubscribe function. + +## `client.workspaces` + +| Method | Result | Behavior | +| ------------------------ | ----------------------------- | --------------------------------------------------------------------------------- | +| `list(options?)` | `PaseoWorkspaceListResult` | Lists, filters, pages, or subscribes to the workspace directory. | +| `open(cwd)` | `PaseoWorkspaceHandle` | Reuses the active workspace for a directory or creates one. | +| `create(options)` | `PaseoWorkspaceHandle` | Always creates a fresh directory-backed or Paseo-worktree workspace. | +| `ref(workspaceOrId)` | `PaseoWorkspaceHandle` | Creates a local handle. | +| `archive(workspaceOrId)` | `PaseoWorkspaceArchiveResult` | Archives without first creating a handle. | +| `subscribe(handler)` | Unsubscribe function | Listens for connection-local workspace updates. Call `list({ subscribe })` first. | + +A workspace handle exposes `id`, `projectId`, `directory`, `name`, `status`, `current()`, `refresh()`, `archive()`, and `subscribe()`. Use `workspace.agents.create(options)` to create an agent without repeating the workspace ID or directory. + +## `client.providers` + +| Method | Result | Behavior | +| -------------------------------- | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `waitForReady(options?)` | `PaseoProviderSnapshotResult` | Waits until no provider is loading. Default timeout: 60 seconds. Rejects with an update-host error when the daemon cannot correlate workspace snapshots. | +| `snapshot(options?)` | `PaseoProviderSnapshotResult` | Returns the current catalog immediately. | +| `refresh(options?)` | Acknowledgement | Forces catalog refresh for all or selected providers. | +| `listAvailable()` | Availability result | Reports installed provider availability. | +| `listModels(provider, options?)` | Models result | Discovers models for one provider and directory. | +| `listModes(provider, options?)` | Modes result | Discovers permission or operating modes. | +| `listFeatures(draftConfig)` | Features result | Discovers features for the current draft provider configuration. | +| `diagnostic(provider)` | Diagnostic result | Returns human-readable setup diagnostics. | +| `subscribe(handler)` | Unsubscribe function | Listens for catalog updates. | + +## `client.config` + +`config.get(requestId?)` returns the daemon's mutable configuration. + +`config.patch(patch, requestId?)` validates, persists, and returns an updated configuration. Use this administrative surface for host configuration, not per-agent choices. A patch affects every client and future agent using that daemon. + +## Errors and cleanup + +Connection, validation, rejection, and timeout failures reject their promise. Turn outcomes are returned through `PaseoAgentRunResult.status` because permission and provider errors are expected agent states. + +Always close the client in `finally`. Closing a client removes its local listeners and network connection; it does not stop agents or archive workspaces. diff --git a/public-docs/sdk/workspaces.md b/public-docs/sdk/workspaces.md new file mode 100644 index 000000000..1bc72c5f0 --- /dev/null +++ b/public-docs/sdk/workspaces.md @@ -0,0 +1,114 @@ +--- +title: Workspaces with the SDK +description: Open directories as Paseo workspaces, place agents in them, follow changes, and archive them. +nav: Workspaces +order: 53 +category: TypeScript SDK +--- + +# Workspaces with the SDK + +Use a workspace when an integration needs a durable place in the Paseo app for agents, terminals, browsers, and files related to one task. + +## Open a directory + +```ts +const workspace = await client.workspaces.open("/Users/me/dev/storefront"); + +console.log(workspace.id); +console.log(workspace.directory); +``` + +`open()` creates the project when needed and reuses the active workspace for that exact directory. Use it when the directory is the identity you care about. + +## Create a fresh workspace + +`create()` always creates a new workspace, even when another workspace already uses the directory: + +```ts +const workspace = await client.workspaces.create({ + source: { + kind: "directory", + path: "/Users/me/dev/storefront", + }, + title: "Checkout issue 42", +}); +``` + +Create a Paseo-owned worktree when concurrent work needs an isolated checkout: + +```ts +const workspace = await client.workspaces.create({ + source: { + kind: "worktree", + cwd: "/Users/me/dev/storefront", + action: "branch-off", + refName: "main", + branchName: "fix/checkout-42", + }, + title: "Checkout issue 42", +}); +``` + +You can pass `projectId` in either source when you already have one. Most integrations should omit it; the daemon finds or creates the project from the directory. + +## Start an agent in a workspace + +Create through the workspace handle: + +```ts +const agent = await workspace.agents.create({ + config: { + provider: "claude/claude-sonnet-5", + }, + prompt: "Map the checkout flow before changing anything.", +}); +``` + +The handle supplies both the workspace identity and its actual directory. This avoids mismatched placement arguments. + +For a one-off agent, you can skip the workspace call: + +```ts +const agent = await client.agents.create({ + config: { + provider: "claude/claude-sonnet-5", + }, + cwd: "/Users/me/dev/storefront", + prompt: "Map the checkout flow before changing anything.", +}); +``` + +The daemon still creates a project and a fresh workspace. Read `agent.workspaceId` when you need the generated workspace ID. + +## List workspaces + +```ts +let cursor: string | undefined; + +do { + const page = await client.workspaces.list({ + filter: { query: "storefront" }, + page: { limit: 50, cursor }, + }); + + for (const workspace of page.entries) { + console.log(workspace.id, workspace.name, workspace.status); + } + + cursor = page.pageInfo.nextCursor ?? undefined; +} while (cursor); +``` + +## Refresh and archive a handle + +```ts +const workspace = client.workspaces.ref(savedWorkspaceId); +const snapshot = await workspace.refresh(); + +if (snapshot) { + await workspace.archive(); +} +``` + +Workspace archive is separate from agent archive. Archive each resource according to the lifecycle your integration owns.