From a501e1e05170c9984fbc0ff2dd14d863b487e28a Mon Sep 17 00:00:00 2001 From: Gustavo Ambrozio Date: Thu, 27 Aug 2026 21:09:20 -1000 Subject: [PATCH] feat(sdk): expose a session's commands and full snapshot on the agent handle (#3719) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sdk): expose a session's own commands on the agent handle `DaemonClient.listCommands` has always been able to ask a running session which slash commands and skills it loaded, but `PaseoAgentHandle` never surfaced it, so SDK and plugin consumers could only see what they could find on disk. Provider built-ins — the ones bundled inside the agent binary rather than installed into a scannable directory — were therefore invisible to them. Add `agent.commands()` as a thin delegation to the existing RPC. Every provider that implements `listCommands` (claude, codex, opencode, acp, omp, pi) answers it, so a consumer no longer needs per-provider filesystem knowledge to enumerate what a session can run. Co-Authored-By: Claude Opus 5 * feat(sdk): expose the observed agent snapshot on the agent handle The handle mirrored four snapshot fields (workspaceId, cwd, status, id) and hid the rest behind current(). Add getters for capabilities, availableModes, pendingPermissions, activeTurn, lastUsage, lastError, features, runtimeInfo, and archivedAt so callers can read one field without destructuring the whole snapshot and null-checking it. Each reads the last observed snapshot and never fetches, matching the existing getters. null means "not observed yet". Co-Authored-By: Claude Opus 5 * docs(sdk): clarify nullable snapshot getters --------- Co-authored-by: Claude Opus 5 Co-authored-by: marvin-ambrozio Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- packages/client/src/index.test.ts | 121 ++++++++++++++++++++++++++++++ packages/client/src/index.ts | 59 +++++++++++++++ public-docs/sdk/agents.md | 37 +++++++++ public-docs/sdk/reference.md | 42 +++++++---- 4 files changed, 245 insertions(+), 14 deletions(-) diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index 361d40a733..50de0f2f98 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -757,6 +757,127 @@ test("agent handles delegate create, send, timeline refetch, archive, and local archivedAt: "2026-05-16T01:00:00.000Z", }); expect(agent.current()?.archivedAt).toBe("2026-05-16T01:00:00.000Z"); + expect(agent.archivedAt).toBe("2026-05-16T01:00:00.000Z"); + + unsubscribe(); + await client.close(); +}); + +test("agent handles list the session's own commands through the existing daemon RPC", async () => { + const { client, ws } = await connectClient(); + const agent = client.agents.ref("agent_sdk"); + + const commandsPromise = agent.commands({ requestId: "agent-commands-request" }); + const request = parseSentSessionMessage(ws.sent.at(-1)); + expect(request).toMatchObject({ + type: "list_commands_request", + agentId: "agent_sdk", + requestId: "agent-commands-request", + }); + + ws.message( + sessionMessage({ + type: "list_commands_response", + payload: { + requestId: "agent-commands-request", + agentId: "agent_sdk", + commands: [ + { + name: "brainstorming", + description: "Turn an idea into a design", + argumentHint: "[topic]", + kind: "skill", + }, + { + name: "usage", + description: "Show usage", + argumentHint: "", + kind: "command", + }, + ], + error: null, + }, + }), + ); + + await expect(commandsPromise).resolves.toMatchObject({ + agentId: "agent_sdk", + error: null, + commands: [ + { name: "brainstorming", kind: "skill" }, + { name: "usage", kind: "command" }, + ], + }); + await client.close(); +}); + +test("agent handles expose the observed snapshot through readonly properties", async () => { + const { client, ws } = await connectClient(); + const agent = client.agents.ref("agent_sdk"); + + expect(agent.capabilities).toBeNull(); + expect(agent.availableModes).toBeNull(); + expect(agent.pendingPermissions).toBeNull(); + expect(agent.activeTurn).toBeNull(); + expect(agent.lastUsage).toBeNull(); + expect(agent.lastError).toBeNull(); + expect(agent.features).toBeNull(); + expect(agent.runtimeInfo).toBeNull(); + expect(agent.archivedAt).toBeNull(); + + const observedAgent = createAgent({ + capabilities: { + supportsStreaming: false, + supportsSessionPersistence: false, + supportsDynamicModes: true, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsRewindBoth: true, + supportsRewindConversation: true, + supportsRewindFiles: true, + supportsToolInvocations: false, + }, + availableModes: [{ id: "plan", label: "Plan" }], + pendingPermissions: [{ id: "permission-sdk", provider: "codex", name: "shell", kind: "tool" }], + activeTurn: { turnId: "turn-sdk", startedAt: "2026-05-16T00:05:00.000Z" }, + lastUsage: { inputTokens: 120, outputTokens: 45, totalCostUsd: 0.02 }, + lastError: "provider exited", + features: [{ type: "toggle", id: "web-search", label: "Web search", value: true }], + runtimeInfo: { provider: "codex", sessionId: "session-sdk", model: "gpt-5.4" }, + archivedAt: "2026-05-16T02:00:00.000Z", + }); + const unsubscribe = agent.subscribe(() => {}); + ws.message( + sessionMessage({ + type: "agent_update", + payload: { + kind: "upsert", + agent: observedAgent, + project: null, + }, + }), + ); + + expect(agent.capabilities).toEqual(observedAgent.capabilities); + expect(agent.availableModes).toEqual([{ id: "plan", label: "Plan" }]); + expect(agent.pendingPermissions).toEqual([ + { id: "permission-sdk", provider: "codex", name: "shell", kind: "tool" }, + ]); + expect(agent.activeTurn).toEqual({ + turnId: "turn-sdk", + startedAt: "2026-05-16T00:05:00.000Z", + }); + expect(agent.lastUsage).toEqual({ inputTokens: 120, outputTokens: 45, totalCostUsd: 0.02 }); + expect(agent.lastError).toBe("provider exited"); + expect(agent.features).toEqual([ + { type: "toggle", id: "web-search", label: "Web search", value: true }, + ]); + expect(agent.runtimeInfo).toEqual({ + provider: "codex", + sessionId: "session-sdk", + model: "gpt-5.4", + }); + expect(agent.archivedAt).toBe("2026-05-16T02:00:00.000Z"); unsubscribe(); await client.close(); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 1bd372908d..268e200ab2 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -5,6 +5,7 @@ import type { FetchWorkspacesResponseMessage, GetProvidersSnapshotResponseMessage, ListAvailableProvidersResponse, + ListCommandsResponse, ListProviderFeaturesRequestMessage, ListProviderFeaturesResponseMessage, ListProviderModelsResponseMessage, @@ -237,6 +238,12 @@ export interface PaseoAgentRunOptions extends PaseoAgentSendOptions { export type PaseoAgentRunResult = WaitForFinishResult; +export interface PaseoAgentCommandsOptions { + requestId?: string; +} + +export type PaseoAgentCommandsResult = ListCommandsResponse["payload"]; + export type PaseoAgentUpdate = Extract["payload"]; export type PaseoAgentStream = Extract["payload"]; @@ -259,9 +266,25 @@ export interface PaseoAgentTimelineHandle { export interface PaseoAgentHandle { readonly id: string; + /** + * `workspaceId` through `archivedAt` mirror the last snapshot this handle + * observed. A handle from `ref()` reads `null` for all of them until + * `refresh()`, `run()`, `waitForFinish()`, a timeline refetch, or + * `subscribe()` delivers a snapshot. Optional snapshot values also read as + * `null`; use `current()` when you need to distinguish those states. + */ readonly workspaceId: string | null; readonly cwd: string | null; readonly status: PaseoAgent["status"] | null; + readonly capabilities: PaseoAgent["capabilities"] | null; + readonly availableModes: PaseoAgent["availableModes"] | null; + readonly pendingPermissions: PaseoAgent["pendingPermissions"] | null; + readonly activeTurn: NonNullable | null; + readonly lastUsage: NonNullable | null; + readonly lastError: NonNullable | null; + readonly features: NonNullable | null; + readonly runtimeInfo: NonNullable | null; + readonly archivedAt: NonNullable | null; readonly timeline: PaseoAgentTimelineHandle; current(): PaseoAgent | null; refresh(requestId?: string): Promise; @@ -270,6 +293,14 @@ export interface PaseoAgentHandle { run(text: string, options?: PaseoAgentRunOptions): Promise; /** Waits for the current turn, including one started with `prompt`. */ waitForFinish(timeoutMs?: number): Promise; + /** + * Asks the running session for the slash commands and skills it actually + * loaded. Providers answer from the live session, so this sees built-in and + * bundled entries that no directory scan can find. The payload carries its own + * `error` string; a provider that cannot answer reports it there rather than + * rejecting. + */ + commands(options?: PaseoAgentCommandsOptions): Promise; archive(): Promise<{ archivedAt: string }>; detach(): Promise; subscribe(handler: (update: PaseoAgentUpdate) => void): () => void; @@ -598,6 +629,33 @@ function createAgentHandleFactory(daemonClient: DaemonClient): AgentHandleFactor get status() { return current?.status ?? null; }, + get capabilities() { + return current?.capabilities ?? null; + }, + get availableModes() { + return current?.availableModes ?? null; + }, + get pendingPermissions() { + return current?.pendingPermissions ?? null; + }, + get activeTurn() { + return current?.activeTurn ?? null; + }, + get lastUsage() { + return current?.lastUsage ?? null; + }, + get lastError() { + return current?.lastError ?? null; + }, + get features() { + return current?.features ?? null; + }, + get runtimeInfo() { + return current?.runtimeInfo ?? null; + }, + get archivedAt() { + return current?.archivedAt ?? null; + }, current: () => current, refresh: async (requestId) => { const result = await daemonClient.fetchAgent({ agentId: id, requestId }); @@ -629,6 +687,7 @@ function createAgentHandleFactory(daemonClient: DaemonClient): AgentHandleFactor } return result; }, + commands: (options) => daemonClient.listCommands({ agentId: id, ...options }), archive: async () => { const result = await daemonClient.archiveAgent(id); if (current) { diff --git a/public-docs/sdk/agents.md b/public-docs/sdk/agents.md index bcba28dd72..565bedbef8 100644 --- a/public-docs/sdk/agents.md +++ b/public-docs/sdk/agents.md @@ -140,6 +140,43 @@ const assessment = JSON.parse(result.lastMessage) as { Validate the parsed value in your application before using it as trusted input. +## Read the current state of an agent + +```ts +const agent = client.agents.ref("agent_01H8X..."); +await agent.refresh(); + +if (agent.pendingPermissions?.length) { + console.log(`Waiting on ${agent.pendingPermissions.length} permission request(s)`); +} +console.log(agent.lastUsage?.totalCostUsd, agent.runtimeInfo?.sessionId); +``` + +The handle exposes `status`, `capabilities`, `availableModes`, `pendingPermissions`, `activeTurn`, `lastUsage`, `lastError`, `features`, `runtimeInfo`, `archivedAt`, `workspaceId`, and `cwd` as properties. All of them read the last snapshot the handle observed and never fetch. + +A handle from `ref()` has observed nothing, so every one of them is `null` until `refresh()`, `run()`, `waitForFinish()`, a timeline refetch, or `subscribe()` delivers a snapshot. Optional values in an observed snapshot also read as `null`. Use `current()` when you need the whole snapshot or need to distinguish those states. + +`subscribe()` keeps the properties current, so a long-lived handle can poll them without another RPC: + +```ts +const unsubscribe = agent.subscribe(() => { + if (agent.status === "error") console.error(agent.lastError); +}); +``` + +## List the commands a session loaded + +```ts +const { commands, error } = await agent.commands(); +if (error) throw new Error(error); + +const skills = commands.filter((command) => command.kind === "skill"); +``` + +The answer comes from the running session, not from a directory scan, so it includes commands and skills built into the provider that never appear on disk. `kind` is the provider's own classification and is optional; treat a missing `kind` as unclassified rather than assuming `"command"`. + +A provider that cannot produce a list reports that in `error` and returns an empty `commands` array. The call does not reject. + ## Archive or detach ```ts diff --git a/public-docs/sdk/reference.md b/public-docs/sdk/reference.md index 66fb5e772f..869fa06ffd 100644 --- a/public-docs/sdk/reference.md +++ b/public-docs/sdk/reference.md @@ -72,23 +72,37 @@ Creation options include `config`, `cwd`, `parent`, `title`, `prompt`, `env`, `o ### 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. | +| 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. | +| `capabilities` | Capability flags or `null` | What the provider session supports. | +| `availableModes` | Agent modes or `null` | Modes the session can switch to. | +| `pendingPermissions` | Permission requests or `null` | Requests waiting on an answer. | +| `activeTurn` | Active turn or `null` | The turn in flight, with `turnId` and `startedAt`. | +| `lastUsage` | Usage or `null` | Token counts, cost, and context-window use from the last turn. | +| `lastError` | `string \| null` | Last error the daemon recorded for the agent. | +| `features` | Agent features or `null` | Provider feature toggles and selects with their current values. | +| `runtimeInfo` | Runtime info or `null` | Live provider, session ID, model, thinking option, and mode. | +| `archivedAt` | `string \| null` | Archive timestamp; `null` while the agent is active. | +| `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. | +| `commands(options?)` | `PaseoAgentCommandsResult` | Asks the live session for its slash commands and skills, including built-in ones. Options: `requestId`. | +| `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. | + +`workspaceId` through `archivedAt` mirror the last snapshot the handle observed. A handle from `ref()` reads `null` for all of them until `refresh()`, `run()`, `waitForFinish()`, a timeline refetch, or `subscribe()` delivers a snapshot. Optional values in an observed snapshot also read as `null`. Call `current()` when you need the whole snapshot or need to distinguish those states. `PaseoAgentRunResult` contains `status`, `final`, `error`, and `lastMessage`. `final` refreshes the handle when present. +`PaseoAgentCommandsResult` contains `agentId`, `commands`, and `error`. Each command has `name`, `description`, `argumentHint`, and an optional `kind` of `"command"` or `"skill"`. A provider that cannot answer reports it in `error` rather than rejecting; providers that expose no command list at all return an empty array. + ### Timeline handle `agent.timeline.refetch(options?)` fetches a page. Options are `direction`, `cursor`, `limit`, `projection`, and `requestId`.