diff --git a/docs/plugins.md b/docs/plugins.md index 6b21d8550c..3ad422107b 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -164,8 +164,8 @@ RPC contracts validate inputs and outputs in both the app and plugin subprocess. typed async function. Use the host-provided `@tanstack/react-query` for request state and caching; Paseo gives each plugin installation its own query client. -`usePaseo()` and the handler's `{ paseo }` context expose the same `PaseoApi`: workspaces, agents, -providers, and daemon config. They do not expose connection lifecycle. A surface borrows the +`usePaseo()` and the handler's `{ paseo }` context expose the same `PaseoApi`: projects, +workspaces, agents, providers, and daemon config. They do not expose connection lifecycle. A surface borrows the selected host's existing connection; switching the screen's host changes both `usePaseo()` and `useRpc()` to that host. An offline selected host fails there and never falls through to another installation. A server handler owns an IPC-backed daemon session for the life of its subprocess. diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index 6d6b2e826d..361d40a733 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -63,6 +63,7 @@ function parseSentSessionMessage(data: string | ArrayBuffer | Uint8Array | undef draftConfig?: unknown; filter?: unknown; page?: unknown; + sync?: unknown; text?: string; } { if (typeof data !== "string") { @@ -224,12 +225,84 @@ test("createPaseoApi borrows daemon capabilities without exposing connection own const paseo = createPaseoApi(daemonClient); - expect(Object.keys(paseo).sort()).toEqual(["agents", "config", "providers", "workspaces"]); + expect(Object.keys(paseo).sort()).toEqual([ + "agents", + "config", + "projects", + "providers", + "workspaces", + ]); expect("connect" in paseo).toBe(false); expect("close" in paseo).toBe(false); expect("skills" in paseo.agents).toBe(false); }); +test("project actions list registered projects through the existing RPC", async () => { + const { client, ws } = await connectClient(); + + const listPromise = client.projects.list({ + requestId: "projects-list-request", + sync: { generation: "daemon-generation", afterSeq: 7 }, + }); + expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({ + type: "project.list.request", + requestId: "projects-list-request", + sync: { generation: "daemon-generation", afterSeq: 7 }, + }); + + ws.message( + sessionMessage({ + type: "project.list.response", + payload: { + requestId: "projects-list-request", + projects: [ + { + projectId: "project_sdk", + projectKey: "sdk", + projectDisplayName: "SDK", + projectCustomName: null, + projectCustomIconRevision: null, + projectIconRevision: "icon-revision", + projectRootPath: "/repo/sdk", + projectKind: "git", + syncSeq: 8, + }, + ], + sync: { + generation: "daemon-generation", + headSeq: 8, + mode: "changes", + removals: [], + }, + }, + }), + ); + + await expect(listPromise).resolves.toEqual({ + requestId: "projects-list-request", + projects: [ + { + projectId: "project_sdk", + projectKey: "sdk", + projectDisplayName: "SDK", + projectCustomName: null, + projectCustomIconRevision: null, + projectIconRevision: "icon-revision", + projectRootPath: "/repo/sdk", + projectKind: "git", + syncSeq: 8, + }, + ], + sync: { + generation: "daemon-generation", + headSeq: 8, + mode: "changes", + removals: [], + }, + }); + 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" }); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 6a308cd297..1bd372908d 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -8,11 +8,14 @@ import type { ListProviderFeaturesRequestMessage, ListProviderFeaturesResponseMessage, ListProviderModelsResponseMessage, + ProjectListRequestMessage, + ProjectListResponseMessage, ListProviderModesResponseMessage, MutableDaemonConfig, MutableDaemonConfigPatch, ProviderDiagnosticResponseMessage, ProjectPlacementPayload, + WorkspaceProjectDescriptorPayload, RefreshProvidersSnapshotResponseMessage, SendAgentMessageRequest, SessionOutboundMessage, @@ -77,6 +80,11 @@ export interface PaseoClientConfig { export type PaseoWorkspace = WorkspaceDescriptorPayload; export type PaseoAgent = AgentSnapshotPayload; export type PaseoAgentListOptions = FetchAgentsOptions; +export type PaseoProject = WorkspaceProjectDescriptorPayload; +export type PaseoProjectListOptions = Omit & { + requestId?: string; +}; +export type PaseoProjectListResult = ProjectListResponseMessage["payload"]; export interface PaseoAgentListResult { requestId: string; @@ -143,6 +151,10 @@ export interface PaseoWorkspaceHandle { subscribe(handler: (update: PaseoWorkspaceUpdate) => void): () => void; } +export interface PaseoProjectActions { + list(options?: PaseoProjectListOptions): Promise; +} + export interface PaseoWorkspaceActions { list(options?: PaseoWorkspaceListOptions): Promise; ref(workspace: string | PaseoWorkspace): PaseoWorkspaceHandle; @@ -356,6 +368,7 @@ export interface PaseoConfigActions { export interface PaseoApi { readonly workspaces: PaseoWorkspaceActions; + readonly projects: PaseoProjectActions; readonly agents: PaseoAgentActions; readonly providers: PaseoProviderActions; readonly config: PaseoConfigActions; @@ -412,6 +425,9 @@ export function createPaseoApi(daemonClient: DaemonClient): PaseoApi { const createWorkspaceHandle = createWorkspaceHandleFactory(daemonClient, createAgent); return { + projects: { + list: (options) => daemonClient.listProjects(options), + }, workspaces: { list: (options) => daemonClient.fetchWorkspaces(options), ref: (workspace) => createWorkspaceHandle(workspace), diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 4bced93ff6..d5031a7aed 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -445,7 +445,7 @@ function PullRequestAction() { } ``` -The returned API covers workspaces, agents, providers, and daemon config. See the [SDK API reference](/docs/sdk/reference) for its methods. Connection lifecycle methods are intentionally absent because Paseo owns the connection. +The returned API covers projects, workspaces, agents, providers, and daemon config. See the [SDK API reference](/docs/sdk/reference) for its methods. Connection lifecycle methods are intentionally absent because Paseo owns the connection. ## Add plugin-specific backend behavior diff --git a/public-docs/sdk/reference.md b/public-docs/sdk/reference.md index 3f69041e3b..66fb5e772f 100644 --- a/public-docs/sdk/reference.md +++ b/public-docs/sdk/reference.md @@ -95,6 +95,12 @@ Creation options include `config`, `cwd`, `parent`, `title`, `prompt`, `env`, `o `agent.timeline.subscribe(handler)` listens for stream events belonging to the agent and returns a local unsubscribe function. +## `client.projects` + +| Method | Result | Behavior | +| ---------------- | ------------------------ | ----------------------------------------------------------------------------- | +| `list(options?)` | `PaseoProjectListResult` | Lists every registered project, including projects with no active workspaces. | + ## `client.workspaces` | Method | Result | Behavior |