From b403dea32beaed5e1f9a08db52b88d12579c6dd2 Mon Sep 17 00:00:00 2001 From: Omer Cohen <639682+omercnet@users.noreply.github.com> Date: Sun, 6 Sep 2026 22:40:23 +0300 Subject: [PATCH] feat(client): expose paseo.projects.subscribe() through the public SDK (#3983) * feat(client): expose project update subscriptions * docs(sdk): clarify project subscription initialization * docs(sdk): document race-free project initialization --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- packages/client/src/index.test.ts | 57 +++++++++++++++++++++++++++++++ packages/client/src/index.ts | 10 ++++++ public-docs/sdk/reference.md | 9 +++-- 3 files changed, 73 insertions(+), 3 deletions(-) diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index 392a53d6b6..7c02924c56 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -335,6 +335,63 @@ test("project actions list registered projects through the existing RPC", async await client.close(); }); +test("project actions subscribe to existing project updates", async () => { + const { client, ws } = await connectClient(); + const updates: string[] = []; + const unsubscribe = client.projects.subscribe((update) => { + updates.push(update.kind === "upsert" ? update.project.projectDisplayName : update.projectId); + }); + + ws.message( + sessionMessage({ + type: "project.update", + payload: { + kind: "upsert", + project: { + projectId: "project_sdk", + projectKey: "sdk", + projectDisplayName: "Renamed SDK", + projectCustomName: "Renamed SDK", + projectCustomIconRevision: null, + projectIconRevision: "icon-revision", + projectRootPath: "/repo/sdk", + projectKind: "git", + syncSeq: 9, + }, + generation: "daemon-generation", + seq: 9, + }, + }), + ); + ws.message( + sessionMessage({ + type: "project.update", + payload: { + kind: "remove", + projectId: "project_removed", + generation: "daemon-generation", + seq: 10, + }, + }), + ); + + expect(updates).toEqual(["Renamed SDK", "project_removed"]); + + unsubscribe(); + ws.message( + sessionMessage({ + type: "project.update", + payload: { + kind: "remove", + projectId: "project_after_unsubscribe", + }, + }), + ); + expect(updates).toEqual(["Renamed SDK", "project_removed"]); + + 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 3c6bf4b755..12615bd44f 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -104,6 +104,11 @@ export type PaseoProjectListOptions = Omit["payload"]; +export type PaseoProjectUpdateHandler = (update: PaseoProjectUpdate) => void; export interface PaseoAgentListResult { requestId: string; @@ -173,6 +178,7 @@ export interface PaseoWorkspaceHandle { export interface PaseoProjectActions { list(options?: PaseoProjectListOptions): Promise; + subscribe(handler: PaseoProjectUpdateHandler): () => void; } export interface PaseoWorkspaceActions { @@ -494,6 +500,10 @@ export function createPaseoApi(daemonClient: DaemonClient): PaseoApi { terminals, projects: { list: (options) => daemonClient.listProjects(options), + subscribe: (handler) => + daemonClient.on("project.update", (message) => { + handler(message.payload); + }), }, workspaces: { list: (options) => daemonClient.fetchWorkspaces(options), diff --git a/public-docs/sdk/reference.md b/public-docs/sdk/reference.md index aff12fa4e5..bc726bd5be 100644 --- a/public-docs/sdk/reference.md +++ b/public-docs/sdk/reference.md @@ -112,9 +112,12 @@ Creation options include `config`, `cwd`, `parent`, `title`, `prompt`, `env`, `o ## `client.projects` -| Method | Result | Behavior | -| ---------------- | ------------------------ | ----------------------------------------------------------------------------- | -| `list(options?)` | `PaseoProjectListResult` | Lists every registered project, including projects with no active workspaces. | +| Method | Result | Behavior | +| -------------------- | ------------------------ | --------------------------------------------------------------------------------------- | +| `list(options?)` | `PaseoProjectListResult` | Lists every registered project, including projects with no active workspaces. | +| `subscribe(handler)` | Unsubscribe function | Listens only for future project upserts/removals. Pair with `list()` for initial state. | + +To build a complete project cache without an initialization gap, subscribe and buffer updates before awaiting `list()`. Initialize the cache from the list result, then apply buffered updates in arrival order. ## `client.workspaces`