feat(sdk): expose a session's commands and full snapshot on the agent handle (#3719)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* docs(sdk): clarify nullable snapshot getters

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: marvin-ambrozio <marvin@gustavo.eng.br>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
Gustavo Ambrozio
2026-08-28 07:09:20 +00:00
committed by GitHub
co-authored by Claude Opus 5 marvin-ambrozio paseo-bot[bot]
parent 1fc5250544
commit a501e1e051
4 changed files with 245 additions and 14 deletions
+121
View File
@@ -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();
+59
View File
@@ -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<SessionOutboundMessage, { type: "agent_update" }>["payload"];
export type PaseoAgentStream = Extract<SessionOutboundMessage, { type: "agent_stream" }>["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<PaseoAgent["activeTurn"]> | null;
readonly lastUsage: NonNullable<PaseoAgent["lastUsage"]> | null;
readonly lastError: NonNullable<PaseoAgent["lastError"]> | null;
readonly features: NonNullable<PaseoAgent["features"]> | null;
readonly runtimeInfo: NonNullable<PaseoAgent["runtimeInfo"]> | null;
readonly archivedAt: NonNullable<PaseoAgent["archivedAt"]> | null;
readonly timeline: PaseoAgentTimelineHandle;
current(): PaseoAgent | null;
refresh(requestId?: string): Promise<PaseoAgentRefetchResult | null>;
@@ -270,6 +293,14 @@ export interface PaseoAgentHandle {
run(text: string, options?: PaseoAgentRunOptions): Promise<PaseoAgentRunResult>;
/** Waits for the current turn, including one started with `prompt`. */
waitForFinish(timeoutMs?: number): Promise<PaseoAgentRunResult>;
/**
* 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<PaseoAgentCommandsResult>;
archive(): Promise<{ archivedAt: string }>;
detach(): Promise<void>;
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) {
+37
View File
@@ -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
+28 -14
View File
@@ -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<void>` | 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<void>` | 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<void>` | 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<void>` | 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`.