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>
This commit is contained in:
Omer Cohen
2026-09-06 21:40:23 +02:00
committed by GitHub
co-authored by paseo-bot[bot]
parent dd7671be20
commit b403dea32b
3 changed files with 73 additions and 3 deletions
+57
View File
@@ -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" });
+10
View File
@@ -104,6 +104,11 @@ export type PaseoProjectListOptions = Omit<ProjectListRequestMessage, "type" | "
requestId?: string;
};
export type PaseoProjectListResult = ProjectListResponseMessage["payload"];
export type PaseoProjectUpdate = Extract<
SessionOutboundMessage,
{ type: "project.update" }
>["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<PaseoProjectListResult>;
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),
+6 -3
View File
@@ -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`