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`.