Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 77f90d3460 docs(plugins): the hook result comment names metadata as the field that stays 2026-11-02 05:09:28 +01:00
Mohamed Boudra 7a651fceb3 test(server): fixtures for agents and workspaces carry metadata
Agent snapshots and workspace records now always carry metadata, so the
hand-built fixtures and expectations in three existing suites name it.
2026-11-02 04:09:28 +01:00
Mohamed Boudra 4095d3784d fix(plugins): omitted metadata in a workspace.create.before result means unchanged
Same handoff as agent.create.before: a legacy hook that returned only
{ source, ... } dropped the caller's metadata and anything an earlier
hook added, and workspace provisioning then defaulted it to {}. The
omitted field is carried forward at each handoff; an explicit {} still
replaces.
2026-11-02 03:50:36 +01:00
Mohamed Boudra 21365c571b fix(plugins): omitted metadata in a hook result means unchanged
An agent.create.before hook that returned its own { config, env } dropped
metadata that an earlier hook in the chain had added, because only the
caller's original metadata was restored at the end. The omitted field is
now carried forward at each hook handoff, as agent.open.before already
does.

The agent.open.before result type also required metadata, so an older
TypeScript plugin that returned its own object no longer compiled even
though the daemon accepted it. The result type now makes it optional.
2026-11-02 03:42:12 +01:00
Mohamed Boudra f4dea7413e fix(plugins): an agent.open.before result may omit metadata
The result schema required metadata, so a plugin written before metadata
existed that returns its own object instead of spreading the request
failed validation and the session open failed, even for agents with no
metadata. An omitted metadata now means unchanged and is filled back in
from the request so later plugins in the chain still see it; a present
value that differs is still rejected.

The restart test now sets metadata through the PaseoApi handles and
checks that agent.open.before sees the patched value after the restart.
2026-11-02 03:27:23 +01:00
Mohamed Boudra 6bf7a47a96 feat(plugins): agents and workspaces carry plugin metadata
Plugins had nowhere to keep their own data on an agent or workspace, so
Phase 5 and 6 surfaces would each have invented a side store. Both now
hold an opaque Record<string, JsonValue> that Paseo stores, snapshots and
hands to hooks without reading a key.

Set at creation or changed with agent.metadata.update.request /
workspace.metadata.update.request (top-level keys replace, null removes).
Persisted on the agent and workspace records, so it survives a restart.
Reaches agent.create.before (which may add to it), agent.open.before
(read-only), agent.create.after and workspace.create.before/after.

Gated once on server_info.features.entityMetadata; the client throws
"Update the host" instead of sending to a host without it.
2026-11-02 03:21:11 +01:00
Mohamed Boudra d0a5d4d970 fix(server): an apply never reads a requested option as one the plugin dropped
A plugin may run an option it never reports. Only a key it reported before can be
reported as removed, and a rewind reopens with the accepted request plus what the
plugin reported back.
2026-11-02 01:43:01 +01:00
Mohamed Boudra 377b06be0d fix(server): an apply reports the options the plugin reset along the way
Applying and unsolicited reports now share one conversion from the plugin's
report to a patch, so a key the plugin stops reporting is a removal on both.
2026-11-02 01:10:56 +01:00
Mohamed Boudra 25a3efe0ac fix(server): a plugin that stops reporting an option has reset it
The adapter only visited keys present in a new report, so a key the plugin
dropped to return to its default kept its stored value and its rewind value.
A previously reported key that disappears is now reported as removed.
2026-11-02 01:01:24 +01:00
Mohamed Boudra 5b4636b856 fix(server): a rewound plugin session reopens with its options as they stand
The reopen merged the plugin's reported options over the launch record, so a
key the plugin reset by leaving it out came back at its launch value. The
session now keeps the committed options, removals included. The provider-direct
example also refuses a combined request before changing anything.
2026-11-02 00:52:17 +01:00
Mohamed Boudra 00ec4bc80c fix(server): a plugin decides which options it applies live
A plugin reporting `controls` and `providerOptions` receives every key a live
change touches, with or without a control, and applies them or rejects the
whole request. The daemon's live-key guard now runs only for a plugin still
reporting `settings`, which cannot refuse a key.
2026-11-02 00:34:19 +01:00
Mohamed Boudra 970c281daf fix(server): a plugin provider's reported options are stored like any provider's
A control's `defaultValue` only says what the control shows for a key with no
value. Opening and applying store every option the session reports, defaults
and values nobody set included, as built-in providers do; the special case that
left a value at the default unset is removed.
2026-11-02 00:31:08 +01:00
Mohamed Boudra 389db81784 feat(plugin): provider plugins declare option controls and receive option changes
A provider plugin reports `controls` and the `providerOptions` it runs in
`session.config`, and receives running-session changes as
`session.configure` `changes.providerOptions` (null returns a key to its
default), under the same apply-or-throw contract as built-in providers.
A control may declare a `defaultValue`; a value a plugin reports at that
default for a key nobody set stays unset instead of being pinned to the agent.

A plugin that still reports `settings` keeps working: the daemon reads each
setting as a control plus an option and sends changes in both shapes
(COMPAT(plugin-provider-settings)).

The app resolves a control's icon as a Lucide name when it is not one of the
built-in kebab-case names, so a plugin's icon shows live and in drafts.
Public docs and the provider-direct example use only `providerOptions` and
controls; the migration doc gains the rows.
2026-11-02 00:29:50 +01:00
47 changed files with 1584 additions and 267 deletions
+2
View File
@@ -92,6 +92,7 @@ Each agent is stored as a separate JSON file, grouped by project directory.
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
| `title` | `string?` | User-visible title |
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). Paseo uses `paseo.parent-agent-id` for parentage and client-scoped `paseo.open-agent-tab.*` labels while managed subagent tabs are open — see [agent-lifecycle.md](./agent-lifecycle.md) |
| `metadata` | `Record<string, JsonValue>` | Plugin-owned data (default `{}`). Paseo stores and forwards it and never reads a key. Set at creation or through `agent.metadata.update.request`; `null` in a patch removes a key |
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"`. `closed` means the record is resumable but has no live provider runtime; archive remains represented separately by `archivedAt`. |
| `lastModeId` | `string?` | Last active mode ID |
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
@@ -478,6 +479,7 @@ Array of workspace records. A workspace is a specific working directory within a
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
| `autoArchivedChangeRequestUrl` | `string \| null` | Change request whose merged state triggered auto-archive. Restore replaces it with the current merged change request, when present, so repeated snapshots cannot archive the workspace again. |
| `labels` | `string[]?` | Normalized display names assigned from this host's shared label catalog. Missing means unlabelled. |
| `metadata` | `Record<string, JsonValue>` | Plugin-owned data (default `{}`). Paseo stores and forwards it and never reads a key. Set at creation or through `workspace.metadata.update.request`. |
| `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" |
| `untrustedSource` | `{ kind: "change_request", forge, number, headRepository }?` | Provenance captured when a cross-repository change request creates the workspace. Missing means repository automation is allowed; explicit setup removes the field. |
+11 -3
View File
@@ -511,13 +511,21 @@ need no change. See [catalogue ownership](providers.md#provider-snapshot-refresh
`send()` resolves after acceptance. Publish operation completion, prompt disposition, turn state,
configuration, permissions, persistence, and complete timeline snapshots through `onEvent()`.
Route messages, structured commands, steering, and command side effects through `session.prompt`.
Provider settings are toggle/select data that Paseo renders in the composer. Keep private options in
the opaque `ProviderSessionConfig.providerOptions` object on `session.open`.
It contains the provider defaults and per-agent overrides merged by the daemon.
A provider's configuration is the opaque `ProviderSessionConfig.providerOptions` object on
`session.open`. It contains the provider defaults and per-agent overrides merged by the daemon.
Validate and apply it inside the provider; core does not know your option shape.
See [provider options](custom-providers.md#provider-options) for configuration and
merge semantics.
The provider draws some of those keys in the composer by reporting `controls` and the values it runs
(`providerOptions`) in `session.config`. A running-agent change arrives as
`session.configure` with `changes.providerOptions` and follows the same apply-or-throw contract as
built-in providers ([options flow](providers.md#options-flow)). `PluginAgentSession` in
`packages/server/src/server/agent/plugin-provider.ts` is that adapter. A provider still reporting
`settings` is read through `plugin-provider-options.ts`, which turns each setting into a control and
its value into an option and is sent both shapes in `session.configure`; delete it with
`COMPAT(plugin-provider-settings)`. The ACP shim still reports `settings`.
Providers add an optional veto to daemon baseline cleanup through the per-session
[runtime safety query](../public-docs/plugins/providers.md#permit-idle-runtime-cleanup).
The daemon calls the same `AgentSession.canStopRuntime()` interface for built-ins and plugins;
@@ -0,0 +1,16 @@
import { Settings2 } from "lucide-react-native";
import { describe, expect, it } from "vitest";
import { getAgentFeatureIcon } from "./icons";
describe("getAgentFeatureIcon", () => {
it("draws the Lucide icon a plugin provider names", () => {
const icon = getAgentFeatureIcon("Mic");
expect(icon).not.toBe(Settings2);
expect(getAgentFeatureIcon("Mic")).toBe(icon);
});
it("falls back to the generic icon for a missing or unknown name", () => {
expect(getAgentFeatureIcon()).toBe(Settings2);
expect(getAgentFeatureIcon("not-an-icon")).toBe(Settings2);
});
});
+5 -1
View File
@@ -13,6 +13,7 @@ import {
ShieldQuestionMark,
Zap,
} from "lucide-react-native";
import { isLucideIconName, pluginIconComponent } from "@/plugins/icons";
import { getModeVisuals, type AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
export interface AgentControlIconProps {
@@ -60,6 +61,9 @@ export function getAgentModeOptionIcon(
return icon ? MODE_ICONS[icon] : undefined;
}
/** A built-in control names its icon in kebab case; a plugin provider names a Lucide icon. */
export function getAgentFeatureIcon(icon?: string): AgentControlIcon {
return (icon ? FEATURE_ICONS[icon] : undefined) ?? Settings2;
if (!icon) return Settings2;
if (FEATURE_ICONS[icon]) return FEATURE_ICONS[icon];
return isLucideIconName(icon) ? pluginIconComponent(icon) : Settings2;
}
+5
View File
@@ -25,6 +25,11 @@ function findLucideIcon(name: string): LucideIcon | null {
return isComponent(candidate) ? (candidate as LucideIcon) : null;
}
/** Whether `name` is a Lucide icon, so a caller can fall back before drawing nothing. */
export function isLucideIconName(name: string): boolean {
return findLucideIcon(name) !== null;
}
function isComponent(value: unknown): boolean {
return (
typeof value === "function" ||
+29
View File
@@ -3727,6 +3727,35 @@ test("rejects an internal create before sending when the host predates internal
expect(mock.sent).toHaveLength(0);
});
test("metadata needs a host that advertises entityMetadata and sends nothing to one that does not", async () => {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger: createMockLogger(),
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen({ features: {} });
await connectPromise;
const update = "Update the host to use metadata.";
await expect(
client.createAgent({ provider: "codex", cwd: "/tmp/project", metadata: { key: "value" } }),
).rejects.toThrow(update);
await expect(
client.createWorkspace({
source: { kind: "directory", path: "/tmp/project" },
metadata: { key: "value" },
}),
).rejects.toThrow(update);
await expect(client.setAgentMetadata("agent-1", { key: null })).rejects.toThrow(update);
await expect(client.setWorkspaceMetadata("workspace-1", { key: null })).rejects.toThrow(update);
expect(mock.sent).toHaveLength(0);
});
test("sends internal workspace creation and internal listings only to hosts that support them", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
+52
View File
@@ -21,6 +21,7 @@ import {
} from "./connection/index.js";
import { CreationClient } from "./creation/index.js";
import type { CreationSnapshot } from "@getpaseo/protocol/messages";
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import type { z } from "zod";
import type { SessionEventSubscription } from "@getpaseo/protocol/messages";
import type { LocalSpeechModel } from "@getpaseo/protocol/speech/rpc-schemas";
@@ -509,11 +510,15 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides {
worktreeName?: string;
requestId?: string;
labels?: Record<string, string>;
/** Plugin-owned data stored on the agent. Needs a host with `entityMetadata`. */
metadata?: CreateAgentRequestMessage["metadata"];
}
export interface CreateWorkspaceRequestOptions {
source: WorkspaceCreateRequest["source"];
title?: string;
/** Plugin-owned data stored on the workspace. Needs a host with `entityMetadata`. */
metadata?: WorkspaceCreateRequest["metadata"];
/** Workspace discovery visibility; contents retain normal durability. */
background?: boolean;
callerAgentId?: string;
@@ -3251,6 +3256,13 @@ export class DaemonClient {
legacyWorkspace: (input) => this.createLegacyWorkspace(input, input.requestId),
});
// Entity metadata is gated once at the transport boundary.
private requireEntityMetadata(): void {
if (this.lastServerInfoMessage?.features?.entityMetadata !== true) {
throw new Error("Update the host to use metadata.");
}
}
// Background workspaces are gated once at the transport boundary.
private requireBackgroundWorkspaces(): void {
if (this.lastServerInfoMessage?.features?.backgroundWorkspaces !== true) {
@@ -3269,6 +3281,7 @@ export class DaemonClient {
);
}
if (options.background !== undefined) this.requireBackgroundWorkspaces();
if (options.metadata !== undefined) this.requireEntityMetadata();
const config = resolveAgentConfig(options);
const result = await this.creations.createAgent({
...options,
@@ -3505,6 +3518,42 @@ export class DaemonClient {
return { removedWorkspaceIds: payload.removedWorkspaceIds };
}
/** Top-level keys replace; `null` removes the key. Resolves to the agent's metadata afterwards. */
async setAgentMetadata(
agentId: string,
patch: Record<string, JsonValue>,
requestId?: string,
): Promise<Record<string, JsonValue>> {
this.requireEntityMetadata();
const payload =
await this.sendNamespacedCorrelatedSessionRequest<"agent.metadata.update.response">({
requestId,
message: { type: "agent.metadata.update.request", agentId, patch },
});
if (!payload.accepted || !payload.metadata) {
throw new Error(payload.error ?? "setAgentMetadata rejected");
}
return payload.metadata;
}
/** Top-level keys replace; `null` removes the key. Resolves to the workspace's metadata afterwards. */
async setWorkspaceMetadata(
workspaceId: string,
patch: Record<string, JsonValue>,
requestId?: string,
): Promise<Record<string, JsonValue>> {
this.requireEntityMetadata();
const payload =
await this.sendNamespacedCorrelatedSessionRequest<"workspace.metadata.update.response">({
requestId,
message: { type: "workspace.metadata.update.request", workspaceId, patch },
});
if (!payload.accepted || !payload.metadata) {
throw new Error(payload.error ?? "setWorkspaceMetadata rejected");
}
return payload.metadata;
}
async setWorkspaceTitle(
workspaceId: string,
title: string | null,
@@ -5058,6 +5107,8 @@ export class DaemonClient {
if (input.background !== undefined || input.callerAgentId) this.requireBackgroundWorkspaces();
if (input.agent?.background !== undefined)
throw new Error("Configure background on the workspace, not its initial agent.");
if (input.metadata !== undefined || input.agent?.metadata !== undefined)
this.requireEntityMetadata();
let agentConfig = input.agent ? resolveAgentConfig(input.agent) : undefined;
if (agentConfig && this.carriesLegacyOptions(agentConfig)) {
agentConfig = await this.routeLegacyCreateConfig(agentConfig);
@@ -7687,6 +7738,7 @@ function resolveAgentConfig(options: CreateAgentRequestOptions): AgentSessionCon
worktreeName: _worktreeName,
requestId: _requestId,
labels: _labels,
metadata: _metadata,
...overrides
} = options;
+25 -1
View File
@@ -8,7 +8,7 @@ export type { HostAdd, HostUpdate, HostSummary, HostDirectory } from "@getpaseo/
import type { OwnedSubscription } from "./connection/index.js";
export type { OwnedSubscription, SubscriptionObserver } from "./connection/index.js";
import type { DaemonClientConfig } from "./daemon-client.js";
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
import type { AgentPermissionResponse, JsonValue } from "@getpaseo/protocol/agent-types";
import type {
AgentSnapshotPayload,
CreationSnapshot,
@@ -184,6 +184,8 @@ export interface PaseoWorkspaceHandle {
readonly directory: string | null;
readonly name: string | null;
readonly status: PaseoWorkspace["status"] | null;
/** Data plugins stored on the workspace. Paseo never reads a key. */
readonly metadata: NonNullable<PaseoWorkspace["metadata"]> | null;
readonly agents: {
create(options: PaseoWorkspaceAgentCreateOptions): Promise<PaseoAgentHandle>;
};
@@ -191,6 +193,8 @@ export interface PaseoWorkspaceHandle {
current(): PaseoWorkspace | null;
refresh(options?: { requestId?: string }): Promise<PaseoWorkspace | null>;
setTitle(title: string | null, requestId?: string): Promise<{ title: string | null }>;
/** Top-level keys replace; `null` removes the key. Needs a host with `entityMetadata`. */
setMetadata(patch: Record<string, JsonValue>): Promise<void>;
archive(requestId?: string): Promise<PaseoWorkspaceArchiveResult>;
/**
* Subscribes to already-emitted daemon workspace_update events for this id.
@@ -274,6 +278,8 @@ export interface PaseoAgentCreateOptions {
background?: CreateAgentRequestMessage["background"];
requestId?: string;
labels?: Record<string, string>;
/** Data plugins store on the agent: JSON values under keys such as `"linear.issue"`. */
metadata?: CreateAgentRequestMessage["metadata"];
}
export type PaseoWorkspaceAgentCreateOptions = Omit<PaseoAgentCreateOptions, "cwd">;
@@ -376,6 +382,8 @@ export interface PaseoAgentHandle {
readonly options: NonNullable<PaseoAgent["options"]> | null;
/** How the provider wants its options drawn. Controls hold no value; read `options`. */
readonly controls: NonNullable<PaseoAgent["controls"]> | null;
/** Data plugins stored on the agent. Paseo never reads a key. */
readonly metadata: NonNullable<PaseoAgent["metadata"]> | null;
readonly runtimeInfo: NonNullable<PaseoAgent["runtimeInfo"]> | null;
readonly archivedAt: NonNullable<PaseoAgent["archivedAt"]> | null;
readonly timeline: PaseoAgentTimelineHandle;
@@ -394,6 +402,8 @@ export interface PaseoAgentHandle {
* provider has applied the patch; a provider that cannot apply a key live rejects it.
*/
setOptions(patch: Record<string, unknown>): Promise<void>;
/** Top-level keys replace; `null` removes the key. Needs a host with `entityMetadata`. */
setMetadata(patch: Record<string, JsonValue>): Promise<void>;
/** Sends a prompt and resolves when that turn finishes or needs attention. */
run(text: string, options?: PaseoAgentRunOptions): Promise<PaseoAgentRunResult>;
/** Waits for the current turn, including one started with `prompt`. */
@@ -899,6 +909,9 @@ function createWorkspaceHandleFactory(
get status() {
return current?.status ?? null;
},
get metadata() {
return current?.metadata ?? null;
},
agents: {
create: async (options) => {
const snapshot = current ?? (await refresh());
@@ -918,6 +931,10 @@ function createWorkspaceHandleFactory(
current: () => current,
refresh,
setTitle: (title, requestId) => daemonClient.setWorkspaceTitle(id, title, requestId),
setMetadata: async (patch) => {
const metadata = await daemonClient.setWorkspaceMetadata(id, patch);
if (current) current = { ...current, metadata };
},
archive: async (requestId) => {
const result = await daemonClient.archiveWorkspace(id, requestId);
if (current) {
@@ -1025,6 +1042,9 @@ function createAgentHandleFactory(
get controls() {
return current?.controls ?? null;
},
get metadata() {
return current?.metadata ?? null;
},
get runtimeInfo() {
return current?.runtimeInfo ?? null;
},
@@ -1049,6 +1069,10 @@ function createAgentHandleFactory(
setOptions: async (patch) => {
await daemonClient.setAgentOptions(id, patch);
},
setMetadata: async (patch) => {
const metadata = await daemonClient.setAgentMetadata(id, patch);
if (current) current = { ...current, metadata };
},
respondToPermission: async ({ requestId, response }) => {
await daemonClient.respondToPermission(id, requestId, response);
},
+4
View File
@@ -283,6 +283,10 @@ describe("lifecycle hook SDK types", () => {
server.addHook("workspace.create.before", async ({ request }) => ({ ...request, title: "Changed" }));
server.addHook("workspace.create.before", async () => {});
server.addHook("agent.open.before", async () => {});
server.addHook("agent.open.before", async ({ request }) => {
const { metadata, ...withoutMetadata } = request;
return withoutMetadata;
});
server.addHook("agent.turn.after", async ({ agent, outcome, timeline }, { paseo, signal }) => {
const kind: "completed" | "failed" | "canceled" = outcome.kind;
// @ts-expect-error Notification payloads have no creation request.
+20 -2
View File
@@ -4,6 +4,7 @@ import type {
AgentPermissionResponse,
AgentTimelineItem,
AgentSessionConfig,
JsonValue,
} from "@getpaseo/protocol/agent-types";
import type { PaseoApi } from "@getpaseo/client";
import type { WorkspaceCreateRequest } from "@getpaseo/protocol/messages";
@@ -19,6 +20,8 @@ export interface PluginHookWorkspace {
cwd: string;
name: string | null;
archivedAt: string | null;
/** Data plugins stored on the workspace. Paseo never reads a key. */
metadata: Record<string, JsonValue>;
}
export interface PluginHookWorktree {
@@ -68,6 +71,8 @@ export interface PluginHookAgent {
provider: string;
cwd: string;
title: string | null;
/** Data plugins stored on the agent. Paseo never reads a key. */
metadata: Record<string, JsonValue>;
}
export interface PluginSessionOpenRequest {
@@ -79,6 +84,7 @@ export interface PluginSessionOpenRequest {
purpose: "interactive" | "history";
readonly title: string | null;
readonly labels: Readonly<Record<string, string>>;
readonly metadata: Readonly<Record<string, JsonValue>>;
tools: AgentToolDefinition[];
env: Record<string, string>;
mcpServers: NonNullable<AgentSessionConfig["mcpServers"]>;
@@ -116,15 +122,27 @@ export interface PluginHookEvents {
export interface PluginHookRequests {
"worktree.create.before": PluginWorktreeCreateRequest;
"agent.create.before": { config: AgentSessionConfig; env?: Record<string, string> };
"agent.create.before": {
config: AgentSessionConfig;
env?: Record<string, string>;
/** Stored on the new agent. Always present in the payload; return it changed to change it. */
metadata?: Record<string, JsonValue>;
};
"agent.open.before": PluginSessionOpenRequest;
"workspace.create.before": Omit<WorkspaceCreateRequest, "type" | "requestId">;
}
/** What a hook may return. An omitted `metadata` stays as it was. */
type PluginHookRequestResults = Omit<PluginHookRequests, "agent.open.before"> & {
"agent.open.before": Omit<PluginSessionOpenRequest, "metadata"> & {
metadata?: Readonly<Record<string, JsonValue>>;
};
};
export type PluginHooks = {
[Name in keyof PluginHookRequests]: {
request: PluginHookRequests[Name];
result: PluginHookRequests[Name];
result: PluginHookRequestResults[Name];
};
} & { [Name in keyof PluginHookEvents]: { payload: PluginHookEvents[Name] } } & {
"worktree.create.after": { payload: { worktree: PluginHookWorktree } };
+74 -2
View File
@@ -116,7 +116,9 @@ export interface ProviderSessionConfig {
model?: string;
mode?: string;
thinkingOption?: string;
/** @deprecated The same values as `providerOptions`. */
settings: Readonly<Record<string, JsonValue>>;
/** The agent's options, the provider defaults from `config.json` included. */
providerOptions?: Readonly<Record<string, unknown>>;
title?: string;
persist: boolean;
@@ -126,6 +128,13 @@ export interface ProviderConfigChanges {
model?: string | null;
mode?: string | null;
thinkingOption?: string | null;
/**
* The option keys to change on the running session, with or without a control. Apply every key
* or fail the whole request, for a key you only read when a session opens. `null` returns the
* key to the provider's own default. The committed values come back in `session.config`.
*/
providerOptions?: Readonly<Record<string, JsonValue>>;
/** @deprecated Sent next to `providerOptions` to a provider that reports `settings`. */
settings?: Readonly<Record<string, JsonValue>>;
}
@@ -311,6 +320,34 @@ export interface ProviderMode {
isUnattended?: boolean;
}
/**
* Draws one top-level option key in the composer. The control holds no value: Paseo reads it
* from the agent's options.
*/
export type ProviderOptionControl =
| {
type: "toggle";
key: string;
label: string;
description?: string;
/** A Lucide icon name. */
icon?: string;
/** Shown while the key has no value. */
defaultValue?: boolean;
}
| {
type: "select";
key: string;
label: string;
description?: string;
/** A Lucide icon name. */
icon?: string;
/** The choice shown while the key has no value. */
defaultValue?: string;
choices: ReadonlyArray<{ value: string; label: string; description?: string }>;
};
/** @deprecated Report `controls` and `providerOptions` instead. */
export type ProviderSetting =
| {
type: "toggle";
@@ -335,7 +372,12 @@ export interface ProviderConfigState {
models: readonly ProviderModel[];
modes: readonly ProviderMode[];
thinkingOptions: readonly ProviderThinkingOption[];
settings: readonly ProviderSetting[];
/** The option keys that get a composer control. May change at runtime, for example per model. */
controls?: readonly ProviderOptionControl[];
/** What the session runs with now, for the keys it reports. Publish the committed values. */
providerOptions?: Readonly<Record<string, JsonValue>>;
/** @deprecated Report `controls` and `providerOptions`. Each setting id is an option key. */
settings?: readonly ProviderSetting[];
}
export interface ProviderCatalog {
@@ -888,6 +930,7 @@ const configChangesSchema = z
model: z.string().nullable().optional(),
mode: z.string().nullable().optional(),
thinkingOption: z.string().nullable().optional(),
providerOptions: jsonObjectSchema.optional(),
settings: jsonObjectSchema.optional(),
})
.strict();
@@ -1024,6 +1067,33 @@ const settingSchema = z.discriminatedUnion("type", [
})
.strip(),
]);
const controlSchema = z.discriminatedUnion("type", [
z
.object({
type: z.literal("toggle"),
key: idSchema,
label: z.string(),
description: z.string().optional(),
icon: z.string().optional(),
defaultValue: z.boolean().optional(),
})
.strip(),
z
.object({
type: z.literal("select"),
key: idSchema,
label: z.string(),
description: z.string().optional(),
icon: z.string().optional(),
defaultValue: z.string().optional(),
choices: z.array(
z
.object({ value: z.string(), label: z.string(), description: z.string().optional() })
.strip(),
),
})
.strip(),
]);
const configStateSchema = z
.object({
model: z.string().optional(),
@@ -1032,7 +1102,9 @@ const configStateSchema = z
models: z.array(modelSchema),
modes: z.array(modeSchema),
thinkingOptions: z.array(selectOptionSchema),
settings: z.array(settingSchema),
controls: z.array(controlSchema).optional(),
providerOptions: jsonObjectSchema.optional(),
settings: z.array(settingSchema).optional(),
})
.strip();
const catalogSchema = z
+54
View File
@@ -303,6 +303,9 @@ import type {
// unknown avoids zod-aot's recursive z.json() object-codegen regression.
const JsonWireValueSchema = z.unknown() as z.ZodType<JsonValue>;
/** Plugin-owned data on an agent or workspace. Paseo stores it and never reads a key. */
const EntityMetadataSchema = z.record(z.string(), JsonWireValueSchema);
export const AgentStatusSchema = z.enum(AGENT_LIFECYCLE_STATUSES);
const AgentModeSchema: z.ZodType<AgentMode> = z.object({
@@ -922,6 +925,7 @@ export const AgentSnapshotPayloadSchema = z.object({
lastError: z.string().optional(),
title: z.string().nullable(),
labels: z.record(z.string(), z.string()).default({}),
metadata: EntityMetadataSchema.optional(),
requiresAttention: z.boolean().optional(),
attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(),
attentionTimestamp: z.string().nullable().optional(),
@@ -1068,6 +1072,22 @@ export const WorkspaceTitleSetRequestSchema = z.object({
requestId: z.string(),
});
export const AgentMetadataUpdateRequestSchema = z.object({
type: z.literal("agent.metadata.update.request"),
agentId: z.string(),
// Top-level keys replace; null removes the key.
patch: EntityMetadataSchema,
requestId: z.string(),
});
export const WorkspaceMetadataUpdateRequestSchema = z.object({
type: z.literal("workspace.metadata.update.request"),
workspaceId: z.string(),
// Top-level keys replace; null removes the key.
patch: EntityMetadataSchema,
requestId: z.string(),
});
export const WorkspacePinSetRequestSchema = z.object({
type: z.literal("workspace.pin.set.request"),
workspaceId: z.string(),
@@ -1826,6 +1846,7 @@ export const CreateAgentRequestMessageSchema = z.object({
// Workspace creation intent, rejected when selecting an existing workspace.
background: z.boolean().optional(),
labels: z.record(z.string(), z.string()).default({}),
metadata: EntityMetadataSchema.optional(),
requestId: z.string(),
});
@@ -2204,6 +2225,28 @@ export const WorkspaceTitleSetResponseSchema = z.object({
payload: WorkspaceTitleSetResponsePayloadSchema,
});
export const AgentMetadataUpdateResponseSchema = z.object({
type: z.literal("agent.metadata.update.response"),
payload: z.object({
requestId: z.string(),
agentId: z.string(),
accepted: z.boolean(),
metadata: EntityMetadataSchema.nullable(),
error: z.string().nullable(),
}),
});
export const WorkspaceMetadataUpdateResponseSchema = z.object({
type: z.literal("workspace.metadata.update.response"),
payload: z.object({
requestId: z.string(),
workspaceId: z.string(),
accepted: z.boolean(),
metadata: EntityMetadataSchema.nullable(),
error: z.string().nullable(),
}),
});
export const WorkspacePinSetResponsePayloadSchema = z.object({
requestId: z.string(),
workspaceId: z.string(),
@@ -2759,6 +2802,7 @@ export const WorkspaceCreateRequestSchema = z.object({
idempotencyKey: z.string().min(1).max(512).optional(),
// Optional user-set title applied to the created workspace.
title: z.string().optional(),
metadata: EntityMetadataSchema.optional(),
// Optional prompt context for workspace-level name/branch generation.
firstAgentContext: FirstAgentContextSchema.optional(),
// Retained for wire parsing; public internal creation is no longer supported.
@@ -3355,6 +3399,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
ProjectIconSetRequestSchema,
ProjectRemoveRequestSchema,
WorkspaceTitleSetRequestSchema,
AgentMetadataUpdateRequestSchema,
WorkspaceMetadataUpdateRequestSchema,
WorkspacePinSetRequestSchema,
WorkspaceLabelListRequestSchema,
WorkspaceLabelAssignmentSetRequestSchema,
@@ -3888,6 +3934,7 @@ export const ServerInfoStatusPayloadSchema = z
explicitEventSubscriptions: z.boolean().optional(),
ownedSubscriptions: z.boolean().optional(),
providerOptionControls: z.boolean().optional(),
entityMetadata: z.boolean().optional(),
binaryChunkingV1: z.boolean().optional(),
workspaceSetupObservation: z.boolean().optional(),
workspaceSortTimes: z.boolean().optional(),
@@ -4222,6 +4269,7 @@ export const WorkspaceDescriptorPayloadSchema = z
pinnedAt: z.string().nullable().optional(),
// COMPAT(workspaceLabels): added in v0.5.0, remove optional after 2027-08-14.
labels: z.array(z.string()).optional(),
metadata: EntityMetadataSchema.optional(),
// Retained for wire parsing of older daemon descriptors.
internal: z.boolean().optional(),
background: z.boolean().optional(),
@@ -7200,6 +7248,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
ProjectIconSetResponseSchema,
ProjectRemoveResponseSchema,
WorkspaceTitleSetResponseSchema,
AgentMetadataUpdateResponseSchema,
WorkspaceMetadataUpdateResponseSchema,
WorkspacePinSetResponseSchema,
WorkspaceRecoveryInspectResponseSchema,
WorkspaceRecoveryRestoreResponseSchema,
@@ -7566,6 +7616,10 @@ export type ProjectIconSetRequest = z.infer<typeof ProjectIconSetRequestSchema>;
export type ProjectRemoveRequest = z.infer<typeof ProjectRemoveRequestSchema>;
export type WorkspaceTitleSetRequest = z.infer<typeof WorkspaceTitleSetRequestSchema>;
export type WorkspacePinSetRequest = z.infer<typeof WorkspacePinSetRequestSchema>;
export type AgentMetadataUpdateRequest = z.infer<typeof AgentMetadataUpdateRequestSchema>;
export type WorkspaceMetadataUpdateRequest = z.infer<typeof WorkspaceMetadataUpdateRequestSchema>;
export type AgentMetadataUpdateResponse = z.infer<typeof AgentMetadataUpdateResponseSchema>;
export type WorkspaceMetadataUpdateResponse = z.infer<typeof WorkspaceMetadataUpdateResponseSchema>;
export type WorkspaceRecoveryInspectRequest = z.infer<typeof WorkspaceRecoveryInspectRequestSchema>;
export type WorkspaceRecoveryRestoreRequest = z.infer<typeof WorkspaceRecoveryRestoreRequestSchema>;
export type SetAgentModeRequestMessage = z.infer<typeof SetAgentModeRequestMessageSchema>;
@@ -133,6 +133,7 @@ export async function ensureAgentLoaded(
}
snapshot = await deps.agentManager.createAgent(config, agentId, {
labels: record.labels,
metadata: record.metadata,
workspaceId: record.workspaceId,
owner: record.owner,
env: record.launchEnv,
@@ -12475,6 +12475,7 @@ test.each([
provider: "codex",
cwd: workdir,
title: null,
metadata: {},
},
turnId: "hook-turn",
outcome,
@@ -6,6 +6,7 @@ import { ensureAgentLoaded } from "./agent-loading.js";
import { projectTimelineRows } from "./timeline-projection.js";
import type { PluginLifecycle } from "../plugins/lifecycle/index.js";
import { describeHookAgent, publishAgentStream } from "../plugins/lifecycle/index.js";
import { applyRecordPatch } from "../record-patch.js";
import type { PluginSessionOpenRequest } from "@getpaseo/plugin/server";
import { randomUUID } from "node:crypto";
import { basename, resolve } from "node:path";
@@ -60,7 +61,7 @@ import {
type ListImportableSessionsOptions,
} from "./agent-sdk-types.js";
import { buildArchivedAgentRecord, type ArchivedStoredAgentRecord } from "./agent-archive.js";
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
import type { JsonValue, ProviderOptions } from "@getpaseo/protocol/agent-types";
import { toStoredAgentRecord } from "./agent-projections.js";
import {
applyProviderOptionsPatch,
@@ -324,6 +325,7 @@ type ProviderClientMap = Partial<Record<AgentProvider, AgentClient>>;
export interface CreateAgentOptions {
labels?: Record<string, string>;
metadata?: AgentMetadata;
initialPrompt?: string;
env?: Record<string, string>;
persistSession?: boolean;
@@ -465,6 +467,8 @@ interface ManagedAgentBase {
* User-defined labels for categorizing agents (e.g., { surface: "workspace" }).
*/
labels: Record<string, string>;
/** Plugin-owned data. Paseo persists and forwards it and never reads a key. */
metadata: AgentMetadata;
}
type ManagedAgentWithSession = ManagedAgentBase & {
@@ -524,6 +528,7 @@ type ActiveManagedAgent =
type LiveManagedAgent = ActiveManagedAgent;
type AgentLabelPatch = Record<string, string | null>;
type AgentMetadata = Record<string, JsonValue>;
function attachManagedTurnIdentity(
agent: ActiveManagedAgent,
@@ -571,6 +576,7 @@ interface WriteLabelsResult {
interface AgentMetadataPatch {
title?: string;
labels?: AgentLabelPatch;
metadata?: Record<string, JsonValue | null>;
}
const SYSTEM_ERROR_PREFIX = "[System Error]";
@@ -629,6 +635,11 @@ function createAbortError(signal: AbortSignal | undefined, fallbackMessage: stri
return Object.assign(new Error(message), { name: "AbortError" });
}
/** The user labels and plugin metadata an agent carries; both default to empty. */
function agentEntityData(source?: { labels?: Record<string, string>; metadata?: AgentMetadata }) {
return { labels: source?.labels ?? {}, metadata: source?.metadata ?? {} };
}
function validateAgentId(agentId: string, source: string): string {
const result = AgentIdSchema.safeParse(agentId);
if (!result.success) {
@@ -637,21 +648,6 @@ function validateAgentId(agentId: string, source: string): string {
return result.data;
}
function applyLabelPatch(
labels: Record<string, string>,
patch: AgentLabelPatch,
): Record<string, string> {
const nextLabels = { ...labels };
for (const [key, value] of Object.entries(patch)) {
if (value === null) {
delete nextLabels[key];
} else {
nextLabels[key] = value;
}
}
return nextLabels;
}
function buildExplicitTimelineSeedForRegister(
now: Date,
options:
@@ -1386,6 +1382,7 @@ export class AgentManager {
const request = await this.pluginLifecycle.before("agent.create.before", {
config: sent,
env: options.env,
metadata: options.metadata ?? {},
});
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. A plugin written
// against the previous SDK edits `config.featureValues`.
@@ -1397,7 +1394,7 @@ export class AgentManager {
}),
internal: config.internal,
};
options = { ...options, env: request.env };
options = { ...options, env: request.env, metadata: request.metadata ?? options.metadata };
}
await this.deleteAgentState(resolvedAgentId);
const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig(
@@ -1422,6 +1419,7 @@ export class AgentManager {
workspaceId: options.workspaceId ?? null,
title: options.initialTitle ?? storedConfig.title ?? null,
labels: options.labels ?? {},
metadata: options.metadata ?? {},
},
);
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
@@ -1432,6 +1430,7 @@ export class AgentManager {
const agent = await this.registerSession(session, storedConfig, resolvedAgentId, {
launchedOptions,
labels: options.labels,
metadata: options.metadata,
initialTitle: options.initialTitle,
workspaceId: options.workspaceId,
owner: options.owner,
@@ -1465,6 +1464,7 @@ export class AgentManager {
updatedAt?: Date;
lastUserMessageAt?: Date | null;
labels?: Record<string, string>;
metadata?: AgentMetadata;
workspaceId?: string;
owner?: AgentOwner;
attention?: AttentionState;
@@ -1497,6 +1497,7 @@ export class AgentManager {
updatedAt?: Date;
lastUserMessageAt?: Date | null;
labels?: Record<string, string>;
metadata?: AgentMetadata;
workspaceId?: string;
owner?: AgentOwner;
attention?: AttentionState;
@@ -1549,7 +1550,7 @@ export class AgentManager {
purpose,
workspaceId: options?.workspaceId ?? null,
title: record?.title ?? mergedConfig.title ?? null,
labels: record?.labels ?? options?.labels ?? {},
...agentEntityData(record ?? options),
},
);
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
@@ -1720,6 +1721,7 @@ export class AgentManager {
workspaceId: existing.workspaceId,
title: await this.resolveInitialPersistedTitle(agentId, existing.config, null),
labels: existing.labels,
metadata: existing.metadata,
},
);
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
@@ -1764,6 +1766,7 @@ export class AgentManager {
return this.registerSession(session, storedConfig, agentId, {
launchedOptions,
labels: existing.labels,
metadata: existing.metadata,
workspaceId: existing.workspaceId,
owner: existing.owner,
launchEnv: existing.launchEnv,
@@ -2224,6 +2227,7 @@ export class AgentManager {
attention,
internal: record.internal,
labels: record.labels,
metadata: record.metadata,
},
});
}
@@ -2425,10 +2429,30 @@ export class AgentManager {
});
}
/** Applies a plugin-metadata patch (null removes a key) and returns the stored result. */
async setMetadata(
agentId: string,
patch: Record<string, JsonValue | null>,
): Promise<AgentMetadata> {
return this.runLifecycleMutation(agentId, async () => {
const liveAgent = this.agents.get(agentId);
if (liveAgent) {
liveAgent.metadata = applyRecordPatch<JsonValue>(liveAgent.metadata, patch);
this.touchUpdatedAt(liveAgent);
await this.persistSnapshot(liveAgent);
this.emitState(liveAgent, { persist: false });
return liveAgent.metadata;
}
const record = await this.writeStoredMetadata(agentId, { metadata: patch });
if (!record.internal) this.dispatchStoredAgentState(record);
return record.metadata;
});
}
private async writeLabels(agentId: string, patch: AgentLabelPatch): Promise<WriteLabelsResult> {
const liveAgent = this.agents.get(agentId);
if (liveAgent) {
liveAgent.labels = applyLabelPatch(liveAgent.labels, patch);
liveAgent.labels = applyRecordPatch(liveAgent.labels, patch);
this.touchUpdatedAt(liveAgent);
await this.persistSnapshot(liveAgent);
this.emitState(liveAgent, { persist: false });
@@ -2453,7 +2477,8 @@ export class AgentManager {
const nextRecord = {
...record,
...(patch.title ? { title: patch.title } : {}),
...(patch.labels ? { labels: applyLabelPatch(record.labels, patch.labels) } : {}),
...(patch.labels ? { labels: applyRecordPatch(record.labels, patch.labels) } : {}),
...(patch.metadata ? { metadata: applyRecordPatch(record.metadata, patch.metadata) } : {}),
updatedAt: this.nextStoredUpdatedAt(record),
};
await registry.upsert(nextRecord);
@@ -2632,7 +2657,7 @@ export class AgentManager {
await registry.upsert({
...record,
...(updates?.workspaceId ? { workspaceId: updates.workspaceId } : {}),
...(updates?.labels ? { labels: applyLabelPatch(record.labels, updates.labels) } : {}),
...(updates?.labels ? { labels: applyRecordPatch(record.labels, updates.labels) } : {}),
archivedAt: null,
updatedAt: new Date().toISOString(),
});
@@ -3919,6 +3944,7 @@ export class AgentManager {
updatedAt?: Date;
lastUserMessageAt?: Date | null;
labels?: Record<string, string>;
metadata?: AgentMetadata;
timeline?: AgentTimelineItem[];
timelineRows?: AgentTimelineRow[];
timelineNextSeq?: number;
@@ -4128,6 +4154,7 @@ export class AgentManager {
updatedAt?: Date;
lastUserMessageAt?: Date | null;
labels?: Record<string, string>;
metadata?: AgentMetadata;
historyPrimed?: boolean;
lastUsage?: AgentUsage;
lastError?: string;
@@ -4186,7 +4213,7 @@ export class AgentManager {
lastError: options?.lastError,
attention: resolveInitialAttention(options?.attention),
internal: config.internal ?? false,
labels: options?.labels ?? {},
...agentEntityData(options),
} as ActiveManagedAgent;
}
@@ -5770,6 +5797,7 @@ export class AgentManager {
workspaceId?: string | null;
title?: string | null;
labels?: Record<string, string>;
metadata?: AgentMetadata;
},
): Promise<AgentLaunchContext> {
// A plugin changing during this open must still make the resulting session stale.
@@ -5787,7 +5815,7 @@ export class AgentManager {
purpose: opening?.purpose ?? "interactive",
env: { ...env },
title: opening?.title ?? null,
labels: opening?.labels ?? {},
...agentEntityData(opening),
tools: [],
mcpServers: {},
};
@@ -94,6 +94,7 @@ export function toStoredAgentRecord(
lastUserMessageAt: agent.lastUserMessageAt ? agent.lastUserMessageAt.toISOString() : null,
title: options?.title ?? null,
labels: agent.labels,
metadata: agent.metadata,
lastStatus: agent.lifecycle,
lastModeId: agent.currentModeId ?? config?.modeId ?? null,
config: config ?? null,
@@ -155,6 +156,7 @@ export function toAgentPayload(
persistence: projectPersistenceHandleForWire(agent.persistence),
title: options?.title ?? null,
labels: agent.labels,
metadata: agent.metadata,
archivedAt: options?.archivedAt ?? null,
...(agent.internal ? { internal: true } : {}),
};
@@ -284,6 +286,7 @@ export function buildStoredAgentPayload(
archivedAt: record.archivedAt ?? null,
...(record.internal ? { internal: true } : {}),
labels: normalizeLabels(record.labels),
metadata: record.metadata,
...(typeof record.lastError === "string" ? { lastError: record.lastError } : {}),
...(providerAvailable ? {} : { providerUnavailable: true }),
};
@@ -2,6 +2,7 @@ import { promises as fs, type Dirent } from "node:fs";
import path from "node:path";
import { z } from "zod";
import type { Logger } from "pino";
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import { writeJsonFileAtomic } from "../atomic-file.js";
import { AgentFeatureSchema, AgentOptionControlSchema, AgentStatusSchema } from "../messages.js";
@@ -56,6 +57,7 @@ const RAW_STORED_AGENT_SCHEMA = z.object({
lastUserMessageAt: z.string().nullable().optional(),
title: z.string().nullable().optional(),
labels: z.record(z.string(), z.string()).default({}),
metadata: z.record(z.string(), z.custom<JsonValue>()).default({}),
lastStatus: AgentStatusSchema.default("closed"),
resumeOnStartup: z.boolean().optional(),
lastModeId: z.string().nullable().optional(),
@@ -59,6 +59,7 @@ export interface CreateAgentFromSessionInput {
images?: Array<{ data: string; mimeType: string }>;
attachments?: AgentAttachment[];
labels: Record<string, string>;
metadata?: CreateAgentOptions["metadata"];
env?: Record<string, string>;
provisionalTitle: string | null;
firstAgentContext: FirstAgentContext;
@@ -251,6 +252,7 @@ async function resolveSessionCreateAgent(
config: sessionConfig,
createOptions: {
labels: input.labels,
metadata: input.metadata,
initialPrompt: trimmedPrompt,
env: input.env,
initialTitle: input.provisionalTitle,
@@ -562,6 +562,7 @@ function createManagedAgent(overrides: Partial<ManagedAgent> = {}): ManagedAgent
pendingPermissions: new Map(),
persistence: null,
labels: {},
metadata: {},
attention: { requiresAttention: false },
...overrides,
} as ManagedAgent;
@@ -0,0 +1,60 @@
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import type { ProviderConfigState } from "@getpaseo/plugin/server/provider";
import type { AgentOptionControl } from "./agent-sdk-types.js";
/** How a provider session's composer options look to the daemon, whichever shape it reports. */
export interface PluginOptionState {
controls: AgentOptionControl[];
/** The values the session runs with, for the keys it reports. */
options: Record<string, JsonValue>;
/** The provider reports `settings`, so it also reads `changes.settings`. */
legacySettings: boolean;
}
/**
* A provider that reports `controls` and `providerOptions` is read as is.
* COMPAT(plugin-provider-settings): added in v0.15.0, remove after 2027-05-01. A provider still
* reporting `settings` has each setting id read as an option key and its value as the option.
*/
export function readPluginOptionState(config: ProviderConfigState): PluginOptionState {
if (config.controls || config.providerOptions) {
return {
controls: (config.controls ?? []).map(toControl),
options: { ...config.providerOptions },
legacySettings: false,
};
}
const settings = config.settings ?? [];
return {
controls: settings.map(
(setting): AgentOptionControl =>
setting.type === "toggle"
? {
type: "toggle",
key: setting.id,
label: setting.label,
...(setting.description ? { description: setting.description } : {}),
}
: {
type: "select",
key: setting.id,
label: setting.label,
...(setting.description ? { description: setting.description } : {}),
choices: setting.options.map((option) => ({
value: option.value,
label: option.label,
})),
},
),
options: Object.fromEntries(settings.map((setting) => [setting.id, setting.value])),
legacySettings: true,
};
}
function toControl(
control: NonNullable<ProviderConfigState["controls"]>[number],
): AgentOptionControl {
return control.type === "toggle"
? { ...control }
: { ...control, choices: control.choices.map((choice) => ({ ...choice })) };
}
@@ -58,6 +58,10 @@ interface ProviderHarnessOptions {
handleInput?: (input: ProviderInput, emit: (event: ProviderEvent) => void) => Promise<boolean>;
}
function controlKey(control: { key: string }): string {
return control.key;
}
function lastConfigure(inputs: ProviderInput[]): ProviderInput | undefined {
return inputs.findLast((input) => input.type === "session.configure");
}
@@ -501,6 +505,144 @@ test("combined plugin rewind replays the retained native branch", async () => {
});
});
test("a rewind reopens with the options as they stand, a reset included", async () => {
const options: Record<string, boolean> = {};
const report = (emit: (event: ProviderEvent) => void, sessionId: string) =>
emit({
type: "session.config",
sessionId,
config: {
model: "plugin-model",
mode: "build",
models: [{ id: "plugin-model", label: "Plugin model" }],
modes: [{ id: "build", label: "Build" }],
thinkingOptions: [],
controls: [{ type: "toggle", key: "concise", label: "Concise" }],
providerOptions: { ...options },
},
});
const harness = createRewindProviderHarness({
handleInput: async (input, emit) => {
if (input.type === "session.open" && input.history !== "replay") {
options.concise = true;
emit({
type: "session.opened",
requestId: input.requestId,
sessionId: input.sessionId,
capabilities: [...CAPABILITIES, "session.revert.conversation"],
restoration: "core",
persistence: { version: 1, data: { token: "root" } },
cwd: input.config.cwd,
});
report(emit, input.sessionId);
emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
return true;
}
if (input.type === "session.configure") {
// A plugin that represents the default by leaving the key out.
delete options.concise;
report(emit, input.sessionId);
emit({ type: "request.completed", requestId: input.requestId });
return true;
}
return false;
},
});
const registry = new PluginAgentClientRegistry(createTestLogger());
registry.replace([harness.registration]);
const session = await registry.clients()[harness.registration.id]!.createSession({
provider: harness.registration.id,
cwd: tmpdir(),
providerOptions: { concise: true },
});
await session.run("A");
await session.applyOptions?.({ concise: null });
await session.revertConversation!({ messageId: "user-A" });
const reopened = harness.inputs.findLast((input) => input.type === "session.open");
expect(reopened?.config.providerOptions).toEqual({});
await session.close();
await registry.shutdown();
});
// A plugin may report no options at all while it runs the ones it was given.
async function withUnreportedOptionSession(
run: (
session: Awaited<ReturnType<AgentClient["createSession"]>>,
harness: ReturnType<typeof createRewindProviderHarness>,
) => Promise<void>,
) {
const harness = createRewindProviderHarness({
handleInput: async (input, emit) => {
if (input.type === "session.open" && input.history !== "replay") {
emit({
type: "session.opened",
requestId: input.requestId,
sessionId: input.sessionId,
capabilities: [...CAPABILITIES, "session.revert.conversation"],
restoration: "core",
persistence: { version: 1, data: { token: "root" } },
cwd: input.config.cwd,
});
emit({
type: "session.config",
sessionId: input.sessionId,
config: {
model: "plugin-model",
mode: "build",
models: [{ id: "plugin-model", label: "Plugin model" }],
modes: [{ id: "build", label: "Build" }],
thinkingOptions: [],
controls: [],
providerOptions: {},
},
});
emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
return true;
}
if (input.type === "session.configure") {
emit({ type: "request.completed", requestId: input.requestId });
return true;
}
return false;
},
});
const registry = new PluginAgentClientRegistry(createTestLogger());
registry.replace([harness.registration]);
const session = await registry.clients()[harness.registration.id]!.createSession({
provider: harness.registration.id,
cwd: tmpdir(),
providerOptions: { token_budget: 1000 },
});
try {
await run(session, harness);
} finally {
await session.close();
await registry.shutdown();
}
}
test("applying an option the plugin never reports leaves the stored option alone", async () => {
await withUnreportedOptionSession(async (session) => {
// The same value as a whole-profile patch carries it, then a new one.
expect(await session.applyOptions?.({ token_budget: 1000 })).toEqual({});
expect(await session.applyOptions?.({ token_budget: 2000 })).toEqual({});
});
});
test("a rewind reopens with the value the plugin accepted for an option it never reports", async () => {
await withUnreportedOptionSession(async (session, harness) => {
await session.run("A");
await session.applyOptions?.({ token_budget: 2000 });
await session.revertConversation!({ messageId: "user-A" });
const reopened = harness.inputs.findLast((input) => input.type === "session.open");
expect(reopened?.config.providerOptions).toEqual({ token_budget: 2000 });
});
});
test("plugin rewind resumes the latest persistence rather than the original handle", async () => {
await withPluginRewindSession(
{
@@ -1004,27 +1146,63 @@ describe("PluginAgentClientRegistry", () => {
await agent.session?.close();
});
describe("settings", () => {
// The same contract holds for a plugin that reports `controls` and `providerOptions` and for one
// still reporting `settings`.
describe.each(["controls", "settings"] as const)("options of a plugin reporting %s", (shape) => {
// A plugin that keeps `voice` on "direct" whatever it is asked, and takes `loud` as given.
async function openStubbornVoiceSession(
given: Record<string, boolean | string>,
{ refuseAfterChanging = false }: { refuseAfterChanging?: boolean } = {},
) {
const state = { voice: "direct", loud: false };
const state: { voice: string; loud: boolean; tokenBudget?: number } = {
voice: "direct",
loud: false,
};
let openedSessionId = "";
const settings = () => [
{
type: "select" as const,
id: "voice",
label: "Voice",
value: state.voice,
options: [
{ label: "Direct", value: "direct" },
{ label: "Other", value: "other" },
],
},
{ type: "toggle" as const, id: "loud", label: "Loud", value: state.loud },
];
const reported = () =>
shape === "controls"
? {
controls: [
{
type: "select" as const,
key: "voice",
label: "Voice",
icon: "Mic",
defaultValue: "direct",
choices: [
{ label: "Direct", value: "direct" },
{ label: "Other", value: "other" },
],
},
{
type: "toggle" as const,
key: "loud",
label: "Loud",
icon: "Volume2",
defaultValue: false,
},
],
providerOptions: {
voice: state.voice,
loud: state.loud,
...(state.tokenBudget === undefined ? {} : { token_budget: state.tokenBudget }),
},
}
: {
settings: [
{
type: "select" as const,
id: "voice",
label: "Voice",
value: state.voice,
options: [
{ label: "Direct", value: "direct" },
{ label: "Other", value: "other" },
],
},
{ type: "toggle" as const, id: "loud", label: "Loud", value: state.loud },
],
};
const emitConfig = (emit: (event: ProviderEvent) => void, sessionId: string) =>
emit({
type: "session.config",
@@ -1035,7 +1213,7 @@ describe("PluginAgentClientRegistry", () => {
models: [{ id: "plugin-model", label: "Plugin model" }],
modes: [{ id: "build", label: "Build" }],
thinkingOptions: [],
settings: settings(),
...reported(),
},
});
const harness = createProviderHarness({
@@ -1056,8 +1234,22 @@ describe("PluginAgentClientRegistry", () => {
return true;
}
if (input.type === "session.configure") {
const loud = input.changes.settings?.loud;
const patch =
shape === "settings" ? input.changes.settings : input.changes.providerOptions;
const { loud, token_budget: tokenBudget } = patch ?? {};
// A plugin that takes `token_budget` live refuses the whole request on a bad value.
if (tokenBudget !== undefined && typeof tokenBudget !== "number") {
emit({
type: "request.failed",
requestId: input.requestId,
error: { message: "bad budget" },
});
return true;
}
if (typeof loud === "boolean") state.loud = loud;
if (typeof tokenBudget === "number") state.tokenBudget = tokenBudget;
// Going loud returns an unrequested token budget to its default, by leaving it out.
if (loud === true && tokenBudget === undefined) state.tokenBudget = undefined;
emitConfig(emit, input.sessionId);
emit(
refuseAfterChanging
@@ -1096,6 +1288,47 @@ describe("PluginAgentClientRegistry", () => {
await session.close();
});
test.runIf(shape === "controls")(
"reports an option the plugin stops reporting as removed, alone or with another change",
async () => {
const { session, events, changeOnItsOwn } = await openStubbornVoiceSession({});
changeOnItsOwn({ tokenBudget: 500 });
changeOnItsOwn({ tokenBudget: undefined });
changeOnItsOwn({ tokenBudget: 700 });
changeOnItsOwn({ tokenBudget: undefined, loud: true });
expect(eventsOfType(events, "options_changed")).toEqual([
{ type: "options_changed", provider: "plugin-direct", patch: { token_budget: 500 } },
{ type: "options_changed", provider: "plugin-direct", patch: { token_budget: null } },
{ type: "options_changed", provider: "plugin-direct", patch: { token_budget: 700 } },
{
type: "options_changed",
provider: "plugin-direct",
patch: { token_budget: null, loud: true },
},
]);
await session.close();
},
);
test.runIf(shape === "controls")(
"an apply returns the option the plugin reset along the way as removed",
async () => {
const { session, events, changeOnItsOwn } = await openStubbornVoiceSession({});
changeOnItsOwn({ tokenBudget: 500 });
const ended = await session.applyOptions?.({ loud: true });
expect(ended).toEqual({ token_budget: null });
// The apply reports it; the plugin's later identical report is not a second change.
expect(eventsOfType(events, "options_changed")).toEqual([
{ type: "options_changed", provider: "plugin-direct", patch: { token_budget: 500 } },
]);
await session.close();
},
);
test("a rejected apply reports what the plugin moved before refusing, since nothing is stored", async () => {
const { session, events } = await openStubbornVoiceSession({}, { refuseAfterChanging: true });
@@ -1115,7 +1348,7 @@ describe("PluginAgentClientRegistry", () => {
await session.close();
});
test("settings the plugin opened with that nobody set are in the open patch", async () => {
test("options the plugin opened with that nobody set are in the open patch, defaults included", async () => {
const { session, events } = await openStubbornVoiceSession({});
expect(session.openOptionsPatch).toEqual({ voice: "direct", loud: false });
@@ -1129,30 +1362,94 @@ describe("PluginAgentClientRegistry", () => {
await session.applyOptions?.({ loud: true });
const configure = lastConfigure(harness.inputs);
expect(configure).toMatchObject({ changes: { settings: { loud: true } } });
expect(
Object.keys((configure as { changes: { settings: object } }).changes.settings),
).toEqual(["loud"]);
await session.close();
});
test("rejects a key the plugin does not report as a setting, and configures none of a mixed patch", async () => {
const { harness, session } = await openStubbornVoiceSession({ launch_key: "original" });
const configureBefore = lastConfigure(harness.inputs);
await expect(session.applyOptions?.({ loud: true, launch_key: "changed" })).rejects.toThrow(
/launch_key.*cannot change on a running session/,
// Only a plugin that reports `settings` is also sent them.
expect((configure as { changes: object }).changes).toEqual(
shape === "settings"
? { providerOptions: { loud: true }, settings: { loud: true } }
: { providerOptions: { loud: true } },
);
expect(lastConfigure(harness.inputs)).toBe(configureBefore);
// The value it opened with is not a change, so a whole profile can be applied.
await session.applyOptions?.({ loud: true, launch_key: "original" });
const configure = lastConfigure(harness.inputs) as { changes: { settings: object } };
expect(Object.keys(configure.changes.settings)).toEqual(["loud"]);
await session.close();
});
test("a removal reaches the plugin as null", async () => {
const { harness, session } = await openStubbornVoiceSession({});
const ended = await session.applyOptions?.({ loud: null });
expect(lastConfigure(harness.inputs)).toMatchObject({
changes: { providerOptions: { loud: null } },
});
// It went back to its default, and what it ended up with is reported like any provider's.
expect(ended).toEqual({ loud: false });
await session.close();
});
test("draws a control from what the plugin reports", async () => {
const { session } = await openStubbornVoiceSession({});
expect((session.controls ?? []).map(controlKey)).toEqual(["voice", "loud"]);
await session.close();
});
test.runIf(shape === "controls")(
"keeps a control's icon, default and choices as the plugin declared them",
async () => {
const { session } = await openStubbornVoiceSession({});
expect(session.controls?.[0]).toMatchObject({
icon: "Mic",
defaultValue: "direct",
choices: [
{ label: "Direct", value: "direct" },
{ label: "Other", value: "other" },
],
});
await session.close();
},
);
test.runIf(shape === "controls")(
"sends a live option that has no control, and the plugin's answer decides",
async () => {
const { harness, session, events } = await openStubbornVoiceSession({});
await session.applyOptions?.({ token_budget: 2000 });
expect(lastConfigure(harness.inputs)).toMatchObject({
changes: { providerOptions: { token_budget: 2000 } },
});
// The plugin refuses the request as a whole, so the other key it was given is not applied.
await expect(session.applyOptions?.({ loud: true, token_budget: "lots" })).rejects.toThrow(
"bad budget",
);
expect(eventsOfType(events, "options_changed")).toEqual([]);
expect(await session.applyOptions?.({ token_budget: 3000 })).toEqual({});
await session.close();
},
);
test.runIf(shape === "settings")(
"rejects a key the plugin does not report as a setting, and configures none of a mixed patch",
async () => {
const { harness, session } = await openStubbornVoiceSession({ launch_key: "original" });
const configureBefore = lastConfigure(harness.inputs);
await expect(session.applyOptions?.({ loud: true, launch_key: "changed" })).rejects.toThrow(
/launch_key.*cannot change on a running session/,
);
expect(lastConfigure(harness.inputs)).toBe(configureBefore);
// The value it opened with is not a change, so a whole profile can be applied.
await session.applyOptions?.({ loud: true, launch_key: "original" });
const configure = lastConfigure(harness.inputs) as {
changes: { providerOptions: object };
};
expect(Object.keys(configure.changes.providerOptions)).toEqual(["loud"]);
await session.close();
},
);
test("applying options returns the settings that ended up different, and emits nothing", async () => {
const { session, events } = await openStubbornVoiceSession({});
@@ -65,6 +65,7 @@ import {
type ProviderRuntimeSettings,
} from "./provider-launch-config.js";
import { assertChangesAreLive } from "./provider-options.js";
import { readPluginOptionState } from "./plugin-provider-options.js";
import type { RegisteredProviderDefinition } from "./provider-registry.js";
import { runProviderTurn } from "./providers/provider-runner.js";
import { StaleProviderSessionError } from "./stale-provider-session-error.js";
@@ -606,7 +607,7 @@ class ProviderRuntimeSession {
private readonly activeTurnIds = new Set<string>();
private terminal = false;
private closePromise: Promise<void> | null = null;
config: ProviderConfigState = { models: [], modes: [], thinkingOptions: [], settings: [] };
config: ProviderConfigState = { models: [], modes: [], thinkingOptions: [] };
commands: Array<{ name: string; description: string; argumentHint?: string }> = [];
persistence: ProviderPersistence | null = null;
@@ -1117,7 +1118,7 @@ class PluginAgentClient implements AgentClient {
this.provider,
bridge,
forget,
async (persistence, state) => {
async (persistence, state, options) => {
forget();
return await this.runtime.openSession({
sessionId: randomUUID(),
@@ -1126,9 +1127,8 @@ class PluginAgentClient implements AgentClient {
model: state.model,
mode: state.mode,
thinkingOption: state.thinkingOption,
settings: Object.fromEntries(
state.settings.map((setting) => [setting.id, setting.value]),
),
settings: options,
providerOptions: options,
},
persistence,
history: "replay",
@@ -1204,9 +1204,12 @@ class PluginAgentSession implements AgentSession {
>();
private readonly childSnapshots = new Map<string, Map<string, TimelineTextSnapshot>>();
private unsubscribe: (() => void) | null = null;
/** How the opened session differs from the settings it was given. */
/** How the opened session differs from the options it was given. */
readonly openOptionsPatch: Record<string, JsonValue>;
private knownSettings: Record<string, JsonValue> = {};
private knownOptions: Record<string, JsonValue> = {};
// The options a reopened session starts with: what it launched with, then every value the
// plugin reported or was given, minus every key that was reset.
private readonly committedOptions: Record<string, JsonValue>;
/** While `applyOptions` waits on the plugin, its `session.config` answers that call. */
private applyingOptions = false;
private currentTurnId: string | null = null;
@@ -1227,14 +1230,16 @@ class PluginAgentSession implements AgentSession {
private readonly reopen: (
persistence: ProviderPersistence,
state: ProviderConfigState,
options: Readonly<Record<string, JsonValue>>,
) => Promise<ProviderRuntimeSession>,
private readonly onBind: (bridge: ProviderRuntimeSession) => void,
private readonly givenSettings: Readonly<Record<string, JsonValue>>,
private readonly givenOptions: Readonly<Record<string, JsonValue>>,
) {
this.subagentIdsBySession.set(bridge.id, null);
// Every setting the session opened with that was not given, including ones nobody set.
this.openOptionsPatch = this.settingDifferences(givenSettings);
this.knownSettings = this.currentSettings();
this.committedOptions = { ...givenOptions };
this.openOptionsPatch = this.optionDifferences(givenOptions);
this.commitOptions();
for (const event of bridge.history) this.accept(event, false);
// Opening notices arrive before the manager subscribes to the session.
// Commit them on registration and replay the same timestamps from history.
@@ -1258,28 +1263,8 @@ class PluginAgentSession implements AgentSession {
};
}
/** The plugin's `settings` are its option controls; each setting id is an option key. */
get controls(): AgentOptionControl[] {
return this.bridge.config.settings.map(
(setting): AgentOptionControl =>
setting.type === "toggle"
? {
type: "toggle",
key: setting.id,
label: setting.label,
...(setting.description ? { description: setting.description } : {}),
}
: {
type: "select",
key: setting.id,
label: setting.label,
...(setting.description ? { description: setting.description } : {}),
choices: setting.options.map((option) => ({
value: option.value,
label: option.label,
})),
},
);
return readPluginOptionState(this.bridge.config).controls;
}
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
@@ -1437,35 +1422,50 @@ class PluginAgentSession implements AgentSession {
async applyOptions(options: ProviderOptions): Promise<Record<string, JsonValue>> {
this.assertReplayComplete();
const before = this.currentSettings();
// A key the plugin does not report as a setting is one it only reads when the session opens;
// a value equal to the one it opened with is not a change.
assertChangesAreLive({
provider: this.provider,
patch: options,
current: this.givenSettings,
live: Object.keys(before),
});
const before = this.currentOptions();
const { controls, legacySettings } = readPluginOptionState(this.bridge.config);
const settings: Record<string, JsonValue> = {};
for (const [id, value] of Object.entries(options)) {
if (!(id in before)) continue;
settings[id] = toJsonValue(value, `setting '${id}'`);
// COMPAT(plugin-provider-settings): added in v0.15.0, remove after 2027-05-01. A plugin
// reporting `settings` cannot refuse a key, so only its settings are sent and any other
// changed key is rejected here; a value equal to the one it opened with is not a change.
if (legacySettings) {
const live = controls.map((control) => control.key);
assertChangesAreLive({
provider: this.provider,
patch: options,
current: this.givenOptions,
live,
});
for (const [id, value] of Object.entries(options)) {
if (live.includes(id)) settings[id] = toJsonValue(value, `option '${id}'`);
}
} else {
// The plugin applies every key it is given or rejects the whole request.
for (const [id, value] of Object.entries(options)) {
settings[id] = toJsonValue(value, `option '${id}'`);
}
}
if (Object.keys(settings).length === 0) return {};
this.applyingOptions = true;
try {
await this.bridge.configure({ settings });
// COMPAT(plugin-provider-settings): added in v0.15.0, remove after 2027-05-01.
await this.bridge.configure({
providerOptions: settings,
...(legacySettings ? { settings } : {}),
});
} catch (error) {
// Nothing is stored for a refused patch, so what the plugin moved is reported once the
// call has ended.
this.applyingOptions = false;
for (const event of this.reportSettingChanges()) this.publish(event);
for (const event of this.reportOptionChanges()) this.publish(event);
throw error;
} finally {
this.applyingOptions = false;
}
const ended = this.settingDifferences({ ...before, ...settings });
this.knownSettings = this.currentSettings();
// A requested key is a request, not a report: only a key the plugin reported before can be
// one it has now dropped.
const ended = this.optionChanges({ ...before, ...settings }, before);
this.commitOptions(settings);
return ended;
}
@@ -1648,28 +1648,58 @@ class PluginAgentSession implements AgentSession {
};
}
private currentSettings(): Record<string, JsonValue> {
return Object.fromEntries(
this.bridge.config.settings.map((setting) => [setting.id, setting.value]),
);
private currentOptions(): Record<string, JsonValue> {
return readPluginOptionState(this.bridge.config).options;
}
/** Settings whose value differs from `known`: the plugin's own change or its opening state. */
private settingDifferences(
known: Readonly<Record<string, JsonValue>>,
): Record<string, JsonValue> {
/** Options whose value differs from `known`: the plugin's own change or its opening state. */
private optionDifferences(known: Readonly<Record<string, JsonValue>>): Record<string, JsonValue> {
const patch: Record<string, JsonValue> = {};
for (const [id, value] of Object.entries(this.currentSettings())) {
for (const [id, value] of Object.entries(this.currentOptions())) {
if (!isDeepStrictEqual(known[id], value)) patch[id] = value;
}
return patch;
}
private reportSettingChanges(): AgentStreamEvent[] {
/**
* Settle the options a reopened session starts with: what is committed, then the request the
* plugin accepted, then what it reported back. A key it reported before and no longer reports
* was reset.
*/
private commitOptions(accepted: Readonly<Record<string, JsonValue>> = {}): void {
for (const [id, value] of Object.entries(accepted)) {
if (value === null) delete this.committedOptions[id];
else this.committedOptions[id] = value;
}
const current = this.currentOptions();
for (const id of Object.keys(this.knownOptions)) {
if (!(id in current)) delete this.committedOptions[id];
}
Object.assign(this.committedOptions, current);
this.knownOptions = current;
}
/**
* What changed against the options `known` to be set: values that differ, and `null` for a key
* in `reported` that the plugin no longer reports, which went back to its default.
*/
private optionChanges(
known: Readonly<Record<string, JsonValue>>,
reported: Readonly<Record<string, JsonValue>>,
): Record<string, JsonValue> {
const patch = this.optionDifferences(known);
const current = this.currentOptions();
for (const id of Object.keys(reported)) {
if (known[id] !== null && !(id in current)) patch[id] = null;
}
return patch;
}
private reportOptionChanges(): AgentStreamEvent[] {
if (this.applyingOptions) return [];
const patch = this.settingDifferences(this.knownSettings);
const patch = this.optionChanges(this.knownOptions, this.knownOptions);
if (Object.keys(patch).length === 0) return [];
this.knownSettings = this.currentSettings();
this.commitOptions();
return [{ type: "options_changed", provider: this.provider, patch }];
}
@@ -1677,7 +1707,7 @@ class PluginAgentSession implements AgentSession {
event: Extract<ProviderEvent, { type: "session.config" }>,
): AgentStreamEvent[] {
return [
...this.reportSettingChanges(),
...this.reportOptionChanges(),
{
type: "mode_changed",
provider: this.provider,
@@ -1828,7 +1858,7 @@ class PluginAgentSession implements AgentSession {
await this.bridge.close();
replay.closed = true;
}
const bridge = await this.reopen(replay.persistence, replay.config);
const bridge = await this.reopen(replay.persistence, replay.config, this.committedOptions);
this.bridge = bridge;
this.subagentIdsBySession.set(bridge.id, null);
for (const event of bridge.history) this.accept(event, false);
@@ -214,6 +214,8 @@ const INBOUND_PERMISSION = {
"workspace.label.delete.request": "workspace.manage",
"workspace.label.list.request": "workspace.read",
"workspace.label.update.request": "workspace.manage",
"agent.metadata.update.request": "workspace.write",
"workspace.metadata.update.request": "workspace.manage",
"workspace.pin.set.request": "workspace.manage",
"workspace.recovery.inspect.request": ["workspace.read", "hub.execute"],
"workspace.recovery.restore.request": ["workspace.manage", "hub.execute"],
@@ -453,6 +455,8 @@ const OUTBOUND_PERMISSION = {
"workspace.label.list.response": "workspace.read",
"workspace.label.update": "workspace.read",
"workspace.label.update.response": "workspace.manage",
"agent.metadata.update.response": "workspace.write",
"workspace.metadata.update.response": "workspace.manage",
"workspace.pin.set.response": "workspace.manage",
"workspace.recovery.inspect.response": ["workspace.read", "hub.execute"],
"workspace.recovery.restore.response": ["workspace.manage", "hub.execute"],
@@ -1109,6 +1109,7 @@ test("disabled recovery closes stale active records without resuming sessions",
lastStatus: status as StoredAgentRecord["lastStatus"],
title: `Keep ${status}`,
labels: { "paseo.parent-agent-id": "parent", custom: "keep" },
metadata: {},
persistence: { provider: "codex", sessionId: `session-${index}` },
config: { modeId: "full-access" },
requiresAttention: true,
@@ -1,3 +1,4 @@
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import type { PluginLifecycle } from "./plugins/lifecycle/index.js";
import { stat } from "node:fs/promises";
import { resolve } from "node:path";
@@ -34,6 +35,7 @@ export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput {
workspaceId?: string;
projectId?: string;
title?: string;
metadata?: Record<string, JsonValue>;
background?: boolean;
callerWorkspaceId?: string;
}
@@ -113,6 +115,7 @@ async function createPaseoWorktreeWithPriority(
branch: createdWorktree.worktree.branchName || null,
baseBranch: createdWorktree.worktree.comparisonBaseRef,
title: input.title?.trim() || resolveFirstAgentPromptTitle(input.firstAgentContext),
metadata: input.metadata,
expectsInitialAgent: Boolean(input.firstAgentContext),
background: input.background,
callerWorkspaceId: input.callerWorkspaceId,
@@ -137,6 +137,7 @@ export function extractTimestamps(record: StoredAgentRecord): {
updatedAt: Date;
lastUserMessageAt: Date | null;
labels?: Record<string, string>;
metadata?: StoredAgentRecord["metadata"];
workspaceId?: string;
owner?: StoredAgentRecord["owner"];
} {
@@ -145,6 +146,7 @@ export function extractTimestamps(record: StoredAgentRecord): {
updatedAt: new Date(resolveStoredAgentUpdatedAt(record)),
lastUserMessageAt: record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null,
labels: record.labels,
metadata: record.metadata,
workspaceId: record.workspaceId,
owner: record.owner,
};
@@ -8,6 +8,7 @@ import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { resolveDaemonVersion } from "../daemon-version.js";
import { BuiltinPluginLoader } from "./builtin/index.js";
import { createPaseoApi } from "@getpaseo/client";
test("a plugin transforms workspace creation once across receipt replays and observes its committed lifecycle", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-lifecycle-"));
@@ -240,6 +241,193 @@ export default function contribute(server) {
}
}, 60_000);
test("metadata set at creation reaches the hooks, a before hook can add to it, and snapshots carry it", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-metadata-hooks-"));
const daemon = await createTestPaseoDaemon();
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({
id: "metadata-hooks",
requirements: { paseo: `>=${resolveDaemonVersion(import.meta.url)}` },
}),
);
await writeFile(
path.join(directory, "index.server.ts"),
`
export default function contribute(server) {
const log = (hook, metadata) => console.log(JSON.stringify({ hook, metadata }));
server.addHook("workspace.create.before", async ({ request }) => {
log("workspace.create.before", request.metadata);
return request;
});
server.addHook("workspace.create.after", async ({ workspace }) => {
log("workspace.create.after", workspace.metadata);
});
server.addHook("agent.create.before", async ({ request }) => {
log("agent.create.before", request.metadata);
return { ...request, metadata: { ...request.metadata, "hook.added": true } };
});
server.addHook("agent.open.before", async ({ request }) => {
log("agent.open.before", request.metadata);
return request;
});
server.addHook("agent.create.after", async ({ agent }) => {
log("agent.create.after", agent.metadata);
});
return () => {};
}
`,
);
await client.connect();
await client.fetchAgents({ subscribe: {} });
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
const workspace = await client.createWorkspace({
source: { kind: "directory", path: directory },
metadata: { "linear.issue": { id: "ENG-123" } },
});
expect(workspace.workspace?.metadata).toEqual({ "linear.issue": { id: "ENG-123" } });
const agent = await client.createAgent({
provider: "claude",
cwd: directory,
workspaceId: workspace.workspace!.id,
metadata: { "linear.issue": { id: "ENG-123" } },
});
expect(agent.metadata).toEqual({ "linear.issue": { id: "ENG-123" }, "hook.added": true });
await expect
.poll(async () => {
const logs = await client.getPluginLogs("metadata-hooks");
return logs
.filter((entry) => entry.message.startsWith('{"hook":'))
.map((entry) => JSON.parse(entry.message));
})
.toMatchObject([
{ hook: "workspace.create.before", metadata: { "linear.issue": { id: "ENG-123" } } },
{ hook: "workspace.create.after", metadata: { "linear.issue": { id: "ENG-123" } } },
{ hook: "agent.create.before", metadata: { "linear.issue": { id: "ENG-123" } } },
{
hook: "agent.open.before",
metadata: { "linear.issue": { id: "ENG-123" }, "hook.added": true },
},
{
hook: "agent.create.after",
metadata: { "linear.issue": { id: "ENG-123" }, "hook.added": true },
},
]);
} finally {
await client.close();
await daemon.close();
await rm(directory, { recursive: true, force: true });
}
}, 60_000);
test("metadata set at creation and through setMetadata survives a daemon restart", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-metadata-restart-"));
const directory = await mkdtemp(path.join(tmpdir(), "paseo-metadata-cwd-"));
const pluginDirectory = await mkdtemp(path.join(tmpdir(), "paseo-metadata-plugin-"));
try {
await writeFile(
path.join(pluginDirectory, "paseo-plugin.json"),
JSON.stringify({
id: "metadata-restart",
requirements: { paseo: `>=${resolveDaemonVersion(import.meta.url)}` },
}),
);
// Returns an explicit object without `metadata`, as a plugin written before metadata existed.
await writeFile(
path.join(pluginDirectory, "index.server.ts"),
`
export default function contribute(server) {
server.addHook("agent.open.before", async ({ request }) => {
console.log(JSON.stringify({ hook: "agent.open.before", metadata: request.metadata }));
const { metadata, ...old } = request;
return { ...old, env: { ...old.env, HOOK_OPEN: "opened" } };
});
return () => {};
}
`,
);
const first = await createTestPaseoDaemon({ paseoHomeRoot: root, cleanup: false });
const firstClient = new DaemonClient({ url: `ws://127.0.0.1:${first.port}/ws` });
const firstApi = createPaseoApi(firstClient);
let agentId: string;
let workspaceId: string;
try {
await firstClient.connect();
await firstClient.fetchAgents({ subscribe: {} });
await firstClient.patchDaemonConfig({ pluginsEnabled: true });
await firstClient.installDirectoryPlugin(pluginDirectory);
const workspace = await firstClient.createWorkspace({
source: { kind: "directory", path: directory },
metadata: { "a.workspace": 1, "b.workspace": "to remove" },
});
workspaceId = workspace.workspace!.id;
const agent = await firstClient.createAgent({
provider: "claude",
cwd: directory,
workspaceId,
metadata: { "a.agent": { nested: [1, 2] }, "b.agent": "to remove" },
});
agentId = agent.id;
const agentHandle = firstApi.agents.ref(agentId);
await agentHandle.setMetadata({ "b.agent": null, "c.agent": true });
await firstApi.workspaces.ref(workspaceId).setMetadata({
"b.workspace": null,
"c.workspace": [],
});
expect((await firstClient.fetchAgent({ agentId }))?.agent.metadata).toEqual({
"a.agent": { nested: [1, 2] },
"c.agent": true,
});
} finally {
await firstApi.dispose();
await firstClient.close();
await first.close();
}
const second = await createTestPaseoDaemon({ paseoHomeRoot: root, cleanup: false });
const secondClient = new DaemonClient({ url: `ws://127.0.0.1:${second.port}/ws` });
try {
await secondClient.connect();
const agent = await secondClient.fetchAgent({ agentId });
expect(agent?.agent.metadata).toEqual({ "a.agent": { nested: [1, 2] }, "c.agent": true });
const workspaces = await secondClient.fetchWorkspaces({});
expect(workspaces.entries.find((entry) => entry.id === workspaceId)?.metadata).toEqual({
"a.workspace": 1,
"c.workspace": [],
});
await secondClient.fetchAgents({ subscribe: {} });
await secondClient.patchDaemonConfig({ pluginsEnabled: true });
await secondClient.installDirectoryPlugin(pluginDirectory);
await secondClient.refreshAgent(agentId);
await expect
.poll(async () => {
const logs = await secondClient.getPluginLogs("metadata-restart");
return logs
.filter((entry) => entry.message.startsWith('{"hook":'))
.map((entry) => JSON.parse(entry.message));
})
.toContainEqual({
hook: "agent.open.before",
metadata: { "a.agent": { nested: [1, 2] }, "c.agent": true },
});
expect((await secondClient.fetchAgent({ agentId }))?.agent.metadata).toEqual({
"a.agent": { nested: [1, 2] },
"c.agent": true,
});
} finally {
await secondClient.close();
await second.close();
}
} finally {
await rm(root, { recursive: true, force: true });
await rm(directory, { recursive: true, force: true });
await rm(pluginDirectory, { recursive: true, force: true });
}
}, 60_000);
test("agent creation hooks change the provider and environment before the session opens", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-agent-hooks-"));
const pluginDirectory = await createProviderPlugin(directory, {
@@ -92,6 +92,7 @@ test("session-open hooks reject changes to session identity instead of silently
env: {},
title: null,
labels: {},
metadata: {},
tools: [],
mcpServers: {},
},
@@ -100,6 +101,105 @@ test("session-open hooks reject changes to session identity instead of silently
).rejects.toThrow("agent.open.before hooks can only change env");
});
test("a create hook that omits metadata passes on what an earlier hook added", async () => {
const hooks = new PluginHookHandlers(() => {});
hooks.addHook("agent.create.before", async ({ request }) => ({
...request,
metadata: { ...request.metadata, "new.plugin": { ticket: 42 } },
}));
hooks.addHook("agent.create.before", async ({ request }) => ({
config: request.config,
env: request.env,
}));
const seen: unknown[] = [];
hooks.addHook("agent.create.before", async ({ request }) => {
seen.push(request.metadata);
});
const result = await hooks.invoke(
"operation",
"agent.create.before",
{ config: { provider: "codex", cwd: "/project" }, env: {}, metadata: { original: true } },
paseo,
);
expect(result).toMatchObject({ metadata: { original: true, "new.plugin": { ticket: 42 } } });
expect(seen).toEqual([{ original: true, "new.plugin": { ticket: 42 } }]);
});
test("a workspace create hook that omits metadata passes on what an earlier hook added", async () => {
const hooks = new PluginHookHandlers(() => {});
hooks.addHook("workspace.create.before", async ({ request }) => ({
...request,
metadata: { ...request.metadata, "new.plugin": { ticket: 42 } },
}));
hooks.addHook("workspace.create.before", async ({ request }) => ({ source: request.source }));
const seen: unknown[] = [];
hooks.addHook("workspace.create.before", async ({ request }) => {
seen.push(request.metadata);
});
const result = await hooks.invoke(
"operation",
"workspace.create.before",
{ source: { kind: "directory", path: "/project" }, metadata: { original: true } },
paseo,
);
expect(result).toMatchObject({ metadata: { original: true, "new.plugin": { ticket: 42 } } });
expect(seen).toEqual([{ original: true, "new.plugin": { ticket: 42 } }]);
const replacing = new PluginHookHandlers(() => {});
replacing.addHook("workspace.create.before", async ({ request }) => ({
...request,
metadata: {},
}));
await expect(
replacing.invoke(
"operation",
"workspace.create.before",
{ source: { kind: "directory", path: "/project" }, metadata: { original: true } },
paseo,
),
).resolves.toMatchObject({ metadata: {} });
});
test("a session-open hook that returns its own object without metadata leaves metadata unchanged", async () => {
const hooks = new PluginHookHandlers(() => {});
hooks.addHook("agent.open.before", async ({ request }) => ({
agentId: request.agentId,
workspaceId: request.workspaceId,
provider: request.provider,
cwd: request.cwd,
reason: request.reason,
purpose: request.purpose,
title: request.title,
labels: request.labels,
tools: request.tools,
env: { HOOK: "1" },
mcpServers: request.mcpServers,
}));
const result = await hooks.invoke(
"operation",
"agent.open.before",
{
agentId: "agent",
workspaceId: null,
provider: "codex",
cwd: "/project",
title: null,
labels: {},
metadata: { "linear.issue": { id: "ENG-1" } },
tools: [],
mcpServers: {},
env: {},
reason: "resume",
purpose: "interactive",
},
paseo,
);
expect(result).toMatchObject({
env: { HOOK: "1" },
metadata: { "linear.issue": { id: "ENG-1" } },
});
});
test.each([{ title: "Changed" }, { labels: { changed: "true" } }])(
"session-open targeting metadata is read-only: %j",
async (patch) => {
@@ -116,6 +216,7 @@ test.each([{ title: "Changed" }, { labels: { changed: "true" } }])(
cwd: "/project",
title: "Original",
labels: {},
metadata: {},
tools: [],
mcpServers: {},
env: {},
@@ -13,6 +13,7 @@ import {
import type { AgentToolDefinition } from "@getpaseo/plugin/server";
import type { AgentStreamEvent } from "../../agent/agent-sdk-types.js";
import { z } from "zod";
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import { CreateAgentRequestMessageSchema } from "@getpaseo/protocol/messages";
import type {
PluginHookAgent,
@@ -91,6 +92,7 @@ const beforeSchemas = {
"agent.create.before": CreateAgentRequestMessageSchema.pick({
config: true,
env: true,
metadata: true,
}).strict(),
"agent.open.before": z
.object({
@@ -102,6 +104,8 @@ const beforeSchemas = {
purpose: z.enum(["interactive", "history"]),
title: z.string().nullable(),
labels: z.record(z.string(), z.string()),
// Optional so a plugin that returns its own object keeps working; omitted means unchanged.
metadata: z.record(z.string(), z.custom<JsonValue>()).optional(),
tools: z.array(agentToolMetadataSchema),
env: z.record(z.string(), z.string()),
mcpServers: CreateAgentRequestMessageSchema.shape.config.shape.mcpServers.unwrap(),
@@ -152,6 +156,7 @@ export function describeHookWorkspace(workspace: PersistedWorkspaceRecord): Plug
cwd: workspace.cwd,
name: workspace.title,
archivedAt: workspace.archivedAt,
metadata: workspace.metadata,
};
}
@@ -162,6 +167,7 @@ export function describeHookAgent(agent: {
cwd: string;
title?: string | null;
labels: Record<string, string>;
metadata: Record<string, JsonValue>;
}): PluginHookAgent {
return {
id: agent.id,
@@ -170,6 +176,7 @@ export function describeHookAgent(agent: {
provider: agent.provider,
cwd: agent.cwd,
title: agent.title ?? null,
metadata: agent.metadata,
};
}
@@ -229,10 +236,12 @@ export async function validateBeforeResult<Name extends keyof PluginWireBeforeRe
previous.reason !== next.reason ||
previous.purpose !== next.purpose ||
previous.title !== next.title ||
!isDeepStrictEqual(previous.labels, next.labels)
!isDeepStrictEqual(previous.labels, next.labels) ||
(next.metadata !== undefined && !isDeepStrictEqual(previous.metadata, next.metadata))
) {
throw new Error("agent.open.before hooks can only change env, tools, and mcpServers");
}
return { ...result, metadata: previous.metadata };
}
if (name === "agent.create.before") {
const previous = beforeSchemas["agent.create.before"].parse(input);
@@ -240,6 +249,14 @@ export async function validateBeforeResult<Name extends keyof PluginWireBeforeRe
if (previous.config.cwd !== next.config.cwd) {
throw new Error("agent.create.before hooks cannot change the workspace directory");
}
// Omitted means unchanged: the next hook sees what this one was handed.
return { ...result, metadata: next.metadata ?? previous.metadata };
}
if (name === "workspace.create.before") {
const previous = beforeSchemas["workspace.create.before"].parse(input);
const next = beforeSchemas["workspace.create.before"].parse(result);
// Omitted means unchanged; an explicit {} still replaces.
return { ...result, metadata: next.metadata ?? previous.metadata };
}
if (name === "worktree.create.before") {
const previous = beforeSchemas["worktree.create.before"].parse(input);
@@ -1,3 +1,4 @@
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import type { PluginHookAgent } from "@getpaseo/plugin/server";
import { UsageScopeSchema, type UsageScope } from "@getpaseo/plugin/server/usage";
import type {
@@ -157,6 +158,7 @@ export const PluginProcessRequestSchema: z.ZodType<PluginProcessRequest> = z.dis
provider: z.string(),
cwd: z.string(),
title: z.string().nullable(),
metadata: z.record(z.string(), z.custom<JsonValue>()),
})
.strict(),
name: z.string(),
@@ -0,0 +1,15 @@
/** Top-level keys replace; a null value removes the key. */
export function applyRecordPatch<Value>(
record: Record<string, Value>,
patch: Record<string, Value | null>,
): Record<string, Value> {
const next = { ...record };
for (const [key, value] of Object.entries(patch)) {
if (value === null) {
delete next[key];
} else {
next[key] = value;
}
}
return next;
}
+83
View File
@@ -85,6 +85,7 @@ import { releaseWorkspaceServicePortPlan } from "./workspace-service-port-regist
import { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-utils";
import { getAgentStatusPriority } from "@getpaseo/protocol/agent-state-bucket";
import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels";
import { applyRecordPatch } from "./record-patch.js";
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js";
import type { ProjectUpdate } from "./workspace-reconciliation-service.js";
import {
@@ -3093,6 +3094,10 @@ export class Session {
return this.handleWorkspaceTitleSetRequest(msg.workspaceId, msg.title, msg.requestId);
case "workspace.pin.set.request":
return this.handleWorkspacePinSetRequest(msg.workspaceId, msg.pinned, msg.requestId);
case "agent.metadata.update.request":
return this.handleAgentMetadataUpdateRequest(msg);
case "workspace.metadata.update.request":
return this.handleWorkspaceMetadataUpdateRequest(msg);
default:
return undefined;
}
@@ -3942,6 +3947,79 @@ export class Session {
}
}
private async handleAgentMetadataUpdateRequest(
request: Extract<SessionInboundMessage, { type: "agent.metadata.update.request" }>,
): Promise<void> {
const { agentId, patch, requestId } = request;
this.sessionLogger.info({ agentId, requestId }, "session: agent.metadata.update.request");
try {
const metadata = await this.agentManager.setMetadata(agentId, patch);
this.emit({
type: "agent.metadata.update.response",
payload: { requestId, agentId, accepted: true, metadata, error: null },
});
} catch (error) {
this.sessionLogger.error(
{ err: error, agentId, requestId },
"session: agent.metadata.update.request error",
);
this.emit({
type: "agent.metadata.update.response",
payload: {
requestId,
agentId,
accepted: false,
metadata: null,
error: getErrorMessageOr(error, "Failed to update agent metadata"),
},
});
}
}
private async handleWorkspaceMetadataUpdateRequest(
request: Extract<SessionInboundMessage, { type: "workspace.metadata.update.request" }>,
): Promise<void> {
const { workspaceId, patch, requestId } = request;
this.sessionLogger.info(
{ workspaceId, requestId },
"session: workspace.metadata.update.request",
);
const reject = (error: string) =>
this.emit({
type: "workspace.metadata.update.response",
payload: { requestId, workspaceId, accepted: false, metadata: null, error },
});
try {
const updatedAt = new Date().toISOString();
const updated = await this.workspaceRegistry.update(workspaceId, (existing) => ({
...existing,
metadata: applyRecordPatch(existing.metadata, patch),
updatedAt,
}));
if (!updated) {
reject("Workspace not found");
return;
}
this.emit({
type: "workspace.metadata.update.response",
payload: {
requestId,
workspaceId,
accepted: true,
metadata: updated.metadata,
error: null,
},
});
await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId]);
} catch (error) {
this.sessionLogger.error(
{ err: error, workspaceId, requestId },
"session: workspace.metadata.update.request error",
);
reject(getErrorMessageOr(error, "Failed to update workspace metadata"));
}
}
private async handleWorkspacePinSetRequest(
workspaceId: string,
pinned: boolean,
@@ -4510,6 +4588,7 @@ export class Session {
images,
attachments,
labels: resolvedIntent.intent.labels,
metadata: msg.metadata,
env,
provisionalTitle,
firstAgentContext,
@@ -5710,6 +5789,7 @@ export class Session {
title: workspace.title,
pinnedAt: workspace.pinnedAt,
...(workspace.labels && workspace.labels.length > 0 ? { labels: workspace.labels } : {}),
metadata: workspace.metadata,
background: workspace.background,
archivingAt: null,
status: "done",
@@ -5808,6 +5888,7 @@ export class Session {
...(result.workspace.labels && result.workspace.labels.length > 0
? { labels: result.workspace.labels }
: {}),
metadata: result.workspace.metadata,
archivingAt: null,
status: "done",
statusEnteredAt: result.workspace.createdAt,
@@ -6870,6 +6951,7 @@ export class Session {
{
expectsInitialAgent: Boolean(request.firstAgentContext),
workspaceId,
metadata: request.metadata,
background: request.background,
callerWorkspaceId: this.getCallerWorkspaceId(request.callerAgentId),
},
@@ -6934,6 +7016,7 @@ export class Session {
githubPrNumber: source.githubPrNumber,
firstAgentContext: request.firstAgentContext,
title: request.title,
metadata: request.metadata,
background: request.background,
callerWorkspaceId: this.getCallerWorkspaceId(request.callerAgentId),
},
@@ -1,3 +1,4 @@
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import type { PluginLifecycle } from "../../plugins/lifecycle/index.js";
import { describeHookWorkspace } from "../../plugins/lifecycle/index.js";
import { basename, resolve } from "node:path";
@@ -50,6 +51,7 @@ export interface CreateWorktreeWorkspaceInput {
branch: string | null;
baseBranch: string | null;
title: string | null;
metadata?: Record<string, JsonValue>;
expectsInitialAgent?: boolean;
untrustedSource?: UntrustedWorkspaceSource;
background?: boolean;
@@ -70,6 +72,7 @@ export interface WorkspaceProvisioningService {
context?: {
expectsInitialAgent?: boolean;
workspaceId?: string;
metadata?: Record<string, JsonValue>;
background?: boolean;
callerWorkspaceId?: string;
},
@@ -242,6 +245,7 @@ export function createWorkspaceProvisioningService(deps: {
context?: {
expectsInitialAgent?: boolean;
workspaceId?: string;
metadata?: Record<string, JsonValue>;
background?: boolean;
callerWorkspaceId?: string;
},
@@ -258,6 +262,7 @@ export function createWorkspaceProvisioningService(deps: {
projectId: project.projectId,
...initialWorkspacePlacement({ source: "checkout", cwd: normalizedCwd, checkout }),
title: title?.trim() || null,
metadata: context?.metadata,
createdAt: timestamp,
updatedAt: timestamp,
background: await resolveBackground(context),
@@ -294,6 +299,7 @@ export function createWorkspaceProvisioningService(deps: {
mainRepoRoot: repoRoot,
}),
title: input.title,
metadata: input.metadata,
createdAt: timestamp,
updatedAt: timestamp,
...(input.untrustedSource ? { untrustedSource: input.untrustedSource } : {}),
@@ -2012,6 +2012,7 @@ export class VoiceAssistantWebSocketServer {
usageSources: true,
ownedSubscriptions: true,
providerOptionControls: true,
entityMetadata: true,
binaryChunkingV1: true,
agentRequestReceipts: true,
workspaceRequestReceipts: true,
@@ -1100,6 +1100,7 @@ describe("WorkspaceReconciliationService", () => {
baseBranch: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
metadata: {},
createdAt: timestamp,
updatedAt: expect.any(String),
archivedAt: expect.any(String),
@@ -1,3 +1,4 @@
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import { promises as fs } from "node:fs";
import type { Logger } from "pino";
@@ -101,6 +102,8 @@ const PersistedWorkspaceRecordSchema = z.object({
.optional()
.transform((value) => value ?? null),
labels: z.array(z.string()).optional(),
// Plugin-owned data. Paseo stores it and never reads a key.
metadata: z.record(z.string(), z.custom<JsonValue>()).default({}),
untrustedSource: UntrustedWorkspaceSourceSchema.optional(),
// Public discovery visibility. Contents retain their ordinary lifecycle.
background: z.boolean().optional().default(false),
@@ -678,6 +681,7 @@ export function createPersistedWorkspaceRecord(input: {
autoArchivedChangeRequestUrl?: string | null;
pinnedAt?: string | null;
labels?: string[];
metadata?: Record<string, JsonValue>;
untrustedSource?: UntrustedWorkspaceSource;
background?: boolean;
}): PersistedWorkspaceRecord {
+2 -1
View File
@@ -1,7 +1,8 @@
# Direct provider example
This plugin implements `ProviderRegistration` directly. It demonstrates connection negotiation,
catalog and session listing, session creation, prompt and command disposition, settings,
catalog and session listing, session creation, prompt and command disposition, option controls
with icons (`controls` and `providerOptions`),
persistence and replay, archive actions, and an ordinary child session.
The provider emits versioned `provider-result` timeline items for its root and child sessions.
@@ -40,7 +40,7 @@ describe("direct provider example", () => {
await connection.close();
});
it("demonstrates settings, commands, persistence, and provider-rendered timeline items", async () => {
it("demonstrates option controls, commands, persistence, and provider-rendered timeline items", async () => {
const connection = await createDirectExampleProvider().connect({
versions: [1],
capabilities: [
@@ -65,8 +65,8 @@ describe("direct provider example", () => {
mcpServers: {},
model: "example-1",
mode: "build",
settings: { concise: true, voice: "calm" },
providerOptions: { privateValue: 1 },
settings: {},
providerOptions: { concise: true, voice: "calm", privateValue: 1 },
persist: true,
},
history: "skip",
@@ -75,10 +75,11 @@ describe("direct provider example", () => {
expect.objectContaining({
type: "session.config",
config: expect.objectContaining({
settings: expect.arrayContaining([
expect.objectContaining({ id: "concise", value: true }),
expect.objectContaining({ id: "voice", value: "calm" }),
]),
controls: [
expect.objectContaining({ key: "concise", icon: "Zap" }),
expect.objectContaining({ key: "voice", icon: "Mic", defaultValue: "calm" }),
],
providerOptions: { concise: true, voice: "calm" },
}),
}),
);
@@ -88,7 +89,29 @@ describe("direct provider example", () => {
type: "session.configure",
requestId: "configure-complete",
sessionId: "session-complete",
changes: { settings: { voice: "direct" } },
changes: { providerOptions: { voice: "direct", concise: null } },
});
expect(events.findLast((event) => event.type === "session.config")).toMatchObject({
config: { providerOptions: { voice: "direct" } },
});
await connection.send({
type: "session.configure",
requestId: "configure-refused",
sessionId: "session-complete",
changes: { model: "other-model", providerOptions: { voice: "loud" } },
});
expect(events).toContainEqual(
expect.objectContaining({ type: "request.failed", requestId: "configure-refused" }),
);
// A refused request changes nothing, the model it named included.
await connection.send({
type: "session.configure",
requestId: "configure-after-refused",
sessionId: "session-complete",
changes: {},
});
expect(events.findLast((event) => event.type === "session.config")).not.toMatchObject({
config: { model: "other-model" },
});
await connection.send({
type: "session.prompt",
@@ -1,7 +1,9 @@
import {
negotiateProviderCapabilities,
requireProviderCapabilities,
type ProviderConfigChanges,
type ProviderConfigState,
type ProviderOptionControl,
type ProviderConnection,
type ProviderEvent,
type ProviderInput,
@@ -25,8 +27,29 @@ const CAPABILITIES = [
"timeline.plugin",
] as const;
const VOICES = [
{ value: "calm", label: "Calm" },
{ value: "direct", label: "Direct" },
];
// Which option keys get a composer control. The control holds no value: Paseo reads it from
// `providerOptions`, in a draft as well as on a running session.
const CONTROLS: ProviderOptionControl[] = [
{ type: "toggle", key: "concise", label: "Concise responses", icon: "Zap" },
{
type: "select",
key: "voice",
label: "Voice",
icon: "Mic",
defaultValue: "calm",
choices: VOICES,
},
];
interface ExampleSession {
config: ProviderSessionConfig;
/** What the session runs with. A key with no value runs its default. */
options: { concise?: boolean; voice?: string };
persistence: ProviderPersistence;
turn: number;
activeTurnId: string | null;
@@ -175,8 +198,13 @@ function openSession(
version: 1,
data: { nativeSessionId: `example:${input.sessionId}` },
};
const { concise, voice } = input.config.providerOptions ?? {};
const session: ExampleSession = {
config: input.config,
options: {
...(typeof concise === "boolean" ? { concise } : {}),
...(VOICES.some((candidate) => candidate.value === voice) ? { voice: voice as string } : {}),
},
persistence,
turn: 0,
activeTurnId: null,
@@ -344,6 +372,19 @@ function configureSession(
): void {
const session = requireSession(state, input.sessionId);
const changes = input.changes;
// Work out everything first, so a refused request changes nothing.
let options: ExampleSession["options"];
try {
options = applyOptions(session.options, changes.providerOptions ?? {});
} catch (error) {
state.emit({
type: "request.failed",
requestId: input.requestId,
error: { message: error instanceof Error ? error.message : String(error) },
});
return;
}
session.options = options;
session.config = {
...session.config,
model: changes.model === null ? undefined : (changes.model ?? session.config.model),
@@ -352,14 +393,31 @@ function configureSession(
changes.thinkingOption === null
? undefined
: (changes.thinkingOption ?? session.config.thinkingOption),
settings: changes.settings
? { ...session.config.settings, ...changes.settings }
: session.config.settings,
};
state.emit({ type: "session.config", sessionId: input.sessionId, config: configState(session) });
state.emit({ type: "request.completed", requestId: input.requestId });
}
/** Apply the whole patch or throw. `null` returns a key to its default. */
function applyOptions(
options: ExampleSession["options"],
patch: NonNullable<ProviderConfigChanges["providerOptions"]>,
): ExampleSession["options"] {
const next = { ...options };
for (const [key, value] of Object.entries(patch)) {
if (key === "concise" && (value === null || typeof value === "boolean")) {
if (value === null) delete next.concise;
else next.concise = value;
} else if (key === "voice" && (value === null || VOICES.some((v) => v.value === value))) {
if (value === null) delete next.voice;
else next.voice = value as string;
} else {
throw new Error(`Cannot set ${key} to ${JSON.stringify(value)}`);
}
}
return next;
}
function configState(session: ExampleSession): ProviderConfigState {
return {
model: session.config.model ?? "example-1",
@@ -368,25 +426,9 @@ function configState(session: ExampleSession): ProviderConfigState {
models: [{ id: "example-1", label: "Example 1" }],
modes: [{ id: "build", label: "Build" }],
thinkingOptions: [],
settings: [
{
type: "toggle",
id: "concise",
label: "Concise responses",
value: session.config.settings.concise === true,
},
{
type: "select",
id: "voice",
label: "Voice",
value:
typeof session.config.settings.voice === "string" ? session.config.settings.voice : null,
options: [
{ label: "Calm", value: "calm" },
{ label: "Direct", value: "direct" },
],
},
],
controls: CONTROLS,
// What the session ended up with, so Paseo stores what runs.
providerOptions: { ...session.options },
};
}
+50 -26
View File
@@ -49,22 +49,22 @@ Paseo only sees the returned promise, so detached work gets no error reporting a
## Hooks
| Hook | Waits | Payload | Handler may change |
| ------------------------- | ----- | -------------------------------------- | ---------------------------------------------- |
| `workspace.create.before` | yes | `{ request }` | Entire create request |
| `workspace.create.after` | no | `{ workspace }` | |
| `workspace.archive.after` | no | `{ workspace }` | |
| `worktree.create.before` | yes | `{ request }` | Creation options and destination |
| `worktree.create.after` | yes | `{ worktree }` | Files in the worktree; throw to abort creation |
| `agent.create.before` | yes | `{ request: { config, env? } }` | Saved config except `cwd`; `env` |
| `agent.create.after` | no | `{ agent }` | |
| `agent.open.before` | yes | `{ request }` | Launch-only `env`, `tools`, `mcpServers` |
| `agent.close.after` | no | `{ agent }` | |
| `agent.archive.after` | no | `{ agent, archivedAt }` | |
| `agent.turn.before` | no | `{ agent, turnId }` | |
| `agent.turn.after` | no | `{ agent, turnId, outcome, timeline }` | |
| `agent.permission.before` | no | `{ agent, request }` | |
| `agent.permission.after` | no | `{ agent, requestId, resolution }` | |
| Hook | Waits | Payload | Handler may change |
| ------------------------- | ----- | ----------------------------------------- | ---------------------------------------------- |
| `workspace.create.before` | yes | `{ request }` | Entire create request |
| `workspace.create.after` | no | `{ workspace }` | |
| `workspace.archive.after` | no | `{ workspace }` | |
| `worktree.create.before` | yes | `{ request }` | Creation options and destination |
| `worktree.create.after` | yes | `{ worktree }` | Files in the worktree; throw to abort creation |
| `agent.create.before` | yes | `{ request: { config, env?, metadata } }` | Saved config except `cwd`; `env`; `metadata` |
| `agent.create.after` | no | `{ agent }` | |
| `agent.open.before` | yes | `{ request }` | Launch-only `env`, `tools`, `mcpServers` |
| `agent.close.after` | no | `{ agent }` | |
| `agent.archive.after` | no | `{ agent, archivedAt }` | |
| `agent.turn.before` | no | `{ agent, turnId }` | |
| `agent.turn.after` | no | `{ agent, turnId, outcome, timeline }` | |
| `agent.permission.before` | no | `{ agent, request }` | |
| `agent.permission.after` | no | `{ agent, requestId, resolution }` | |
## Shared payloads
@@ -76,6 +76,7 @@ interface PluginHookAgent {
provider: string;
cwd: string;
title: string | null;
metadata: Record<string, JsonValue>;
}
interface PluginHookWorkspace {
@@ -84,6 +85,7 @@ interface PluginHookWorkspace {
cwd: string;
name: string | null;
archivedAt: string | null;
metadata: Record<string, JsonValue>;
}
type PluginTurnOutcome =
@@ -100,9 +102,29 @@ type PluginTurnOutcome =
| `resolution` | SDK `AgentPermissionResponse` |
| `archivedAt` | Timestamp string |
## Metadata
Agents and workspaces carry a `metadata` record for plugin data. Keys are strings, values are any JSON value, and Paseo stores the record without reading a key. Prefix keys with your plugin id so plugins do not collide. Labels stay strings for filtering and tagging; use metadata for structured data.
Set metadata when you create the agent or workspace, or change it later. `null` removes a key, and any other value replaces the key's whole value:
```ts
const agent = await paseo.agents.create({
config: { provider: "codex/gpt-5.5" },
cwd: "/projects/shop",
metadata: { "linear.issue": { id: "ENG-123" } },
});
agent.metadata; // { "linear.issue": { id: "ENG-123" } }
await agent.setMetadata({ "linear.issue": null });
await paseo.workspaces.ref(workspaceId).setMetadata({ "linear.issue": { id: "ENG-123" } });
```
Metadata survives a daemon restart. Every hook payload carries it: `workspace.create.before` and `agent.create.before` receive the metadata the caller sent, and the `after` hooks and `agent.open.before` receive what is stored. Against a host that predates metadata, these calls throw "Update the host to use metadata."
## workspace.create.before
Receives `{ request }` with `source`, optional `title`, and `firstAgentContext`. You may replace the entire explicit create request; a throw, invalid result, or timeout fails creation. Existing workspaces and directory lookup/import are unaffected.
Receives `{ request }` with `source`, optional `title`, optional `metadata`, and `firstAgentContext`. You may replace the entire explicit create request; a throw, invalid result, or timeout fails creation. Existing workspaces and directory lookup/import are unaffected.
### Choose workspace isolation
@@ -202,18 +224,18 @@ server.addHook("worktree.create.after", async ({ worktree }) => {
## agent.create.before
Receives `{ request: { config, env? } }`. Config changes are saved and remain after the plugin is disabled or removed; `env` applies to the initial launch and is not saved. You cannot change `config.cwd`. A throw, invalid result, or timeout fails creation.
Receives `{ request: { config, env?, metadata } }`. Config and `metadata` changes are saved and remain after the plugin is disabled or removed; `env` applies to the initial launch and is not saved. You cannot change `config.cwd`. A throw, invalid result, or timeout fails creation.
**`agent.create.before.config`** uses `AgentSessionConfig`:
| Fields | Constraint |
| --------------------------------------------- | -------------------------------------------------------------------------- |
| `provider`, `model` | Separate fields; changing provider may require changing model/mode/options |
| `modeId`, `thinkingOptionId`, `featureValues` | Provider-specific selections |
| `title`, `systemPrompt` | Agent configuration |
| `providerOptions` | Opaque provider-specific options |
| `mcpServers`, `toolPolicy` | MCP configuration and exact-tool preapprovals |
| `cwd` | Cannot change |
| Fields | Constraint |
| ---------------------------- | -------------------------------------------------------------------------- |
| `provider`, `model` | Separate fields; changing provider may require changing model/mode/options |
| `modeId`, `thinkingOptionId` | Provider-specific selections |
| `title`, `systemPrompt` | Agent configuration |
| `providerOptions` | Provider options, including speed and auto-accept selections |
| `mcpServers`, `toolPolicy` | MCP configuration and exact-tool preapprovals |
| `cwd` | Cannot change |
### Change saved configuration
@@ -271,6 +293,7 @@ Receives `{ request }`; only `env`, `tools`, and `mcpServers` may change, for th
"cwd": "/projects/shop",
"title": "Workspace notes",
"labels": { "notes/enabled": "true" },
"metadata": { "linear.issue": { "id": "ENG-123" } },
"tools": [],
"mcpServers": {},
"reason": "resume",
@@ -284,6 +307,7 @@ Receives `{ request }`; only `env`, `tools`, and `mcpServers` may change, for th
| `workspaceId` | String or `null` |
| `title` | Read-only string or `null`; creation title on create, stored title on resume. |
| `labels` | Read-only string map; creation labels on create, stored labels on resume. |
| `metadata` | Read-only JSON map; the [metadata](#metadata) stored on the agent. |
| `tools` | Editable `AgentToolDefinition[]`, local to this plugin. See [Attach tools to agents](#attach-tools-to-agents). |
| `mcpServers` | Editable launch-only server map, local to this plugin. See [Inject MCP servers](#inject-mcp-servers). |
| `reason` | `create`, `resume`, `refresh`, `import` |
+50 -26
View File
@@ -47,22 +47,22 @@ Paseo 只观察返回的 Promise,因此独立任务没有错误报告,也没
## 钩子一览 {#hooks}
| 钩子 | 等待 | 载荷 | 可修改 |
| ------------------------- | ---- | -------------------------------------- | ----------------------------------------- |
| `workspace.create.before` | 是 | `{ request }` | 整个创建请求 |
| `workspace.create.after` | 否 | `{ workspace }` | |
| `workspace.archive.after` | 否 | `{ workspace }` | |
| `worktree.create.before` | 是 | `{ request }` | 创建选项和目标路径 |
| `worktree.create.after` | 是 | `{ worktree }` | worktree 中的文件;抛出错误中止创建 |
| `agent.create.before` | 是 | `{ request: { config, env? } }` | 除 `cwd` 外的已保存配置;`env` |
| `agent.create.after` | 否 | `{ agent }` | |
| `agent.open.before` | 是 | `{ request }` | 仅用于启动的 `env`、`tools`、`mcpServers` |
| `agent.close.after` | 否 | `{ agent }` | |
| `agent.archive.after` | 否 | `{ agent, archivedAt }` | |
| `agent.turn.before` | 否 | `{ agent, turnId }` | |
| `agent.turn.after` | 否 | `{ agent, turnId, outcome, timeline }` | |
| `agent.permission.before` | 否 | `{ agent, request }` | |
| `agent.permission.after` | 否 | `{ agent, requestId, resolution }` | |
| 钩子 | 等待 | 载荷 | 可修改 |
| ------------------------- | ---- | ----------------------------------------- | ------------------------------------------ |
| `workspace.create.before` | 是 | `{ request }` | 整个创建请求 |
| `workspace.create.after` | 否 | `{ workspace }` | |
| `workspace.archive.after` | 否 | `{ workspace }` | |
| `worktree.create.before` | 是 | `{ request }` | 创建选项和目标路径 |
| `worktree.create.after` | 是 | `{ worktree }` | worktree 中的文件;抛出错误中止创建 |
| `agent.create.before` | 是 | `{ request: { config, env?, metadata } }` | 除 `cwd` 外的已保存配置;`env`;`metadata` |
| `agent.create.after` | 否 | `{ agent }` | |
| `agent.open.before` | 是 | `{ request }` | 仅用于启动的 `env`、`tools`、`mcpServers` |
| `agent.close.after` | 否 | `{ agent }` | |
| `agent.archive.after` | 否 | `{ agent, archivedAt }` | |
| `agent.turn.before` | 否 | `{ agent, turnId }` | |
| `agent.turn.after` | 否 | `{ agent, turnId, outcome, timeline }` | |
| `agent.permission.before` | 否 | `{ agent, request }` | |
| `agent.permission.after` | 否 | `{ agent, requestId, resolution }` | |
## 共享载荷 {#shared-payloads}
@@ -74,6 +74,7 @@ interface PluginHookAgent {
provider: string;
cwd: string;
title: string | null;
metadata: Record<string, JsonValue>;
}
interface PluginHookWorkspace {
@@ -82,6 +83,7 @@ interface PluginHookWorkspace {
cwd: string;
name: string | null;
archivedAt: string | null;
metadata: Record<string, JsonValue>;
}
type PluginTurnOutcome =
@@ -98,9 +100,29 @@ type PluginTurnOutcome =
| `resolution` | SDK 的 `AgentPermissionResponse` |
| `archivedAt` | 时间戳字符串 |
## 元数据 {#metadata}
Agent 和工作区带有 `metadata` 记录,用于保存插件数据。键是字符串,值可以是任意 JSON 值,Paseo 只保存、不读取任何键。给键加上插件 ID 前缀,避免不同插件冲突。标签仍是用于筛选和标记的字符串;结构化数据请用元数据。
可以在创建 Agent 或工作区时设置元数据,也可以之后修改。`null` 删除该键,其他值会替换该键的整个值:
```ts
const agent = await paseo.agents.create({
config: { provider: "codex/gpt-5.5" },
cwd: "/projects/shop",
metadata: { "linear.issue": { id: "ENG-123" } },
});
agent.metadata; // { "linear.issue": { id: "ENG-123" } }
await agent.setMetadata({ "linear.issue": null });
await paseo.workspaces.ref(workspaceId).setMetadata({ "linear.issue": { id: "ENG-123" } });
```
元数据在 daemon 重启后仍然保留。所有钩子载荷都带有元数据:`workspace.create.before` 和 `agent.create.before` 收到调用方发送的元数据,`after` 钩子和 `agent.open.before` 收到已保存的元数据。主机版本过旧时,这些调用会抛出 “Update the host to use metadata.”。
## workspace.create.before {#workspacecreatebefore}
收到 `{ request }`,其中包含 `source`、可选的 `title` 和 `firstAgentContext`。可替换整个显式创建请求;抛出错误、无效返回或超时会让创建失败。已有工作区和目录查找、导入不受影响。
收到 `{ request }`,其中包含 `source`、可选的 `title`、可选的 `metadata` 和 `firstAgentContext`。可替换整个显式创建请求;抛出错误、无效返回或超时会让创建失败。已有工作区和目录查找、导入不受影响。
### 选择工作区隔离方式 {#choose-workspace-isolation}
@@ -199,18 +221,18 @@ server.addHook("worktree.create.after", async ({ worktree }) => {
## agent.create.before {#agentcreatebefore}
收到 `{ request: { config, env? } }`。配置修改会保存,即使插件以后被停用或移除;`env` 只用于首次启动,不会保存。不能修改 `config.cwd`。抛出错误、无效返回或超时会让创建失败。
收到 `{ request: { config, env?, metadata } }`。配置和 `metadata` 的修改会保存,即使插件以后被停用或移除;`env` 只用于首次启动,不会保存。不能修改 `config.cwd`。抛出错误、无效返回或超时会让创建失败。
**`agent.create.before.config`** 使用 `AgentSessionConfig`:
| 字段 | 约束 |
| --------------------------------------------- | -------------------------------------------------------- |
| `provider`, `model` | 独立的字段;修改 Provider 可能需要同时修改模型/模式/选项 |
| `modeId`, `thinkingOptionId`, `featureValues` | 特定于 Provider 的选择 |
| `title`, `systemPrompt` | Agent 配置 |
| `providerOptions` | 不透明的、特定于 Provider 的选项 |
| `mcpServers`, `toolPolicy` | MCP 配置和精确的工具预批准 |
| `cwd` | 不能修改 |
| 字段 | 约束 |
| ---------------------------- | -------------------------------------------------------- |
| `provider`, `model` | 独立的字段;修改 Provider 可能需要同时修改模型/模式/选项 |
| `modeId`, `thinkingOptionId` | 特定于 Provider 的选择 |
| `title`, `systemPrompt` | Agent 配置 |
| `providerOptions` | Provider 选项,包括速度和自动批准的选择 |
| `mcpServers`, `toolPolicy` | MCP 配置和精确的工具预批准 |
| `cwd` | 不能修改 |
### 修改已保存的配置 {#change-saved-configuration}
@@ -267,6 +289,7 @@ server.addHook("agent.create.after", async ({ agent }) => {
"cwd": "/projects/shop",
"title": "Workspace notes",
"labels": { "notes/enabled": "true" },
"metadata": { "linear.issue": { "id": "ENG-123" } },
"tools": [],
"mcpServers": {},
"reason": "resume",
@@ -280,6 +303,7 @@ server.addHook("agent.create.after", async ({ agent }) => {
| `workspaceId` | 字符串或 `null` |
| `title` | 只读字符串或 `null`;创建时使用创建请求的标题,恢复时使用已保存的标题。 |
| `labels` | 只读的字符串映射;创建时使用创建请求的标签,恢复时使用已保存的标签。 |
| `metadata` | 只读的 JSON 映射;Agent 上已保存的[元数据](#metadata)。 |
| `tools` | 可编辑的 `AgentToolDefinition[]`,仅包含此插件的工具。参阅[为 Agent 附加工具](#attach-tools-to-agents)。 |
| `mcpServers` | 可编辑的服务器映射,仅用于启动且仅包含本插件的条目。参阅[注入 MCP 服务器](#inject-mcp-servers)。 |
| `reason` | `create`、`resume`、`refresh`、`import` |
+20
View File
@@ -184,6 +184,26 @@ For paced timeline text, replace `useRevealedText(text, phase)` and the surround
`<StreamedText text={text} phase={phase} style={style} />` from `/client/ui`.
Use `SettingsSection` for plugin settings headings; `SettingsGroup` is deprecated.
### Provider options in v0.15
A provider plugin has one configuration record, `providerOptions`, and describes which keys get a
composer control. A provider that still reports `settings` keeps working through v0.15: Paseo reads each
setting `id` as an option key and each `value` as that option, and sends changes in both
`changes.providerOptions` and `changes.settings`. Remove `settings` before the shim is deleted after
2027-05-01.
| Previous API | Replacement |
| ---------------------------------------------- | -------------------------------------------------------------------------- |
| `session.config` `settings: ProviderSetting[]` | `controls: ProviderOptionControl[]` plus `providerOptions` |
| Setting `id` | Control `key` |
| Setting `value` | `providerOptions[key]`; the control holds no value |
| Select setting `options: { label, value }[]` | Select control `choices: { value, label, description? }[]` |
| Setting with no icon or default | Control `icon` (a Lucide name) and `defaultValue` |
| `session.configure` `changes.settings` | `changes.providerOptions`; `null` returns a key to its default |
| `session.open` `config.settings` | `config.providerOptions` |
| `ProviderSetting` type | `ProviderOptionControl` |
| `agent.create.before` `config.featureValues` | `config.providerOptions`; a returned `featureValues` edit is still applied |
## 5. Recognize half-migration errors
| Compiler or load error | Meaning and fix |
+16
View File
@@ -152,6 +152,22 @@ pill.remove();
对于逐步显示的时间线文本,把 `useRevealedText(text, phase)` 及其外层的 `Text` 换成 `/client/ui` 中的 `<StreamedText text={text} phase={phase} style={style} />`。插件设置的标题使用 `SettingsSection`;`SettingsGroup` 已弃用。
### v0.15 中的 Provider 选项 {#provider-options-in-v015}
Provider 插件只有一个配置记录 `providerOptions`,并用控件描述哪些键显示在 Composer 中。仍然报告 `settings` 的 Provider 在 v0.15 中继续可用:Paseo 把每个 setting 的 `id` 当作选项键,把 `value` 当作该选项的值,并同时在 `changes.providerOptions` 和 `changes.settings` 中发送修改。请在 2027-05-01 之后兼容层被删除之前移除 `settings`。
| 之前的 API | 替代方式 |
| ------------------------------------------------- | --------------------------------------------------------------- |
| `session.config` 的 `settings: ProviderSetting[]` | `controls: ProviderOptionControl[]` 加 `providerOptions` |
| Setting 的 `id` | 控件的 `key` |
| Setting 的 `value` | `providerOptions[key]`;控件本身不保存值 |
| 选择类 setting 的 `options: { label, value }[]` | 选择类控件的 `choices: { value, label, description? }[]` |
| 没有图标或默认值的 setting | 控件的 `icon`(Lucide 名称)和 `defaultValue` |
| `session.configure` 的 `changes.settings` | `changes.providerOptions`;`null` 表示恢复该键的默认值 |
| `session.open` 的 `config.settings` | `config.providerOptions` |
| `ProviderSetting` 类型 | `ProviderOptionControl` |
| `agent.create.before` 的 `config.featureValues` | `config.providerOptions`;返回的 `featureValues` 修改仍会被应用 |
## 5. 识别只迁移了一半的错误 {#5-recognize-half-migration-errors}
| 编译器或加载错误 | 含义与修复方法 |
+45 -25
View File
@@ -22,7 +22,7 @@ Choose one implementation path:
The examples are:
- [`provider-direct`](https://github.com/getpaseo/paseo/tree/main/plugin-examples/provider-direct): sessions, settings, prompts, persistence, child sessions, and a provider-owned timeline renderer;
- [`provider-direct`](https://github.com/getpaseo/paseo/tree/main/plugin-examples/provider-direct): sessions, option controls, prompts, persistence, child sessions, and a provider-owned timeline renderer;
- [`provider-acp-transformer`](https://github.com/getpaseo/paseo/tree/main/plugin-examples/provider-acp-transformer): an ACP command template with a Zod-validated vendor edit transformer. Replace `example-acp --stdio` with an installed ACP agent before loading it;
- [`inline-thinking`](https://github.com/getpaseo/paseo/tree/main/plugin-examples/inline-thinking): a renderer-only plugin that does not implement a provider.
@@ -138,7 +138,7 @@ later through `session.config`.
## Open a session
`session.open` contains the complete launch configuration: working directory, environment, system
prompt, MCP servers, tool policy, model, mode, settings, opaque provider options, and persistence
prompt, MCP servers, tool policy, model, mode, `providerOptions`, and persistence
preference.
For persisted host-scoped plugin settings, use the handle returned by `server.registerSettings()`.
@@ -177,7 +177,8 @@ emit({
models,
modes,
thinkingOptions,
settings: [],
controls: [],
providerOptions: {},
},
});
emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
@@ -229,10 +230,16 @@ terminal `completed`, `failed`, or `canceled` event.
At this point the provider is usable: users can select a model, open a session, send a message, and
see the response.
## Add session-specific composer controls
## Add composer controls for provider options
Add `session.configure` only when the native agent has settings users should change after opening a
session. Publish them in `session.config` as toggle or select controls:
`providerOptions` is the provider's own configuration: one record of JSON values that Paseo
stores per agent and hands to `session.open`. It includes the defaults a user set in `config.json`
for your provider. Validate every key you read.
Add `controls` only when users should see and change some of those options in the composer. A
control draws one top-level key of `providerOptions` as a toggle or a select. It holds no value:
Paseo reads the value from `providerOptions`. Publish the controls and the values you run with in
`session.config`:
```ts
emit({
@@ -243,35 +250,48 @@ emit({
models: [{ id: "agent-large", label: "Agent Large" }],
modes: [],
thinkingOptions: [],
settings: [
{
type: "toggle",
id: "fast",
label: "Fast mode",
value: nativeSession.fast,
},
controls: [
{ type: "toggle", key: "fast", label: "Fast mode", icon: "Zap", defaultValue: false },
{
type: "select",
id: "approval",
key: "approval",
label: "Approvals",
value: nativeSession.approval,
options: [
{ label: "Ask", value: "ask" },
{ label: "Auto accept", value: "auto" },
icon: "Shield",
defaultValue: "ask",
choices: [
{ value: "ask", label: "Ask" },
{ value: "auto", label: "Auto accept" },
],
},
],
providerOptions: { fast: nativeSession.fast, approval: nativeSession.approval },
},
});
```
Paseo renders these controls in the composer. A user change arrives as `session.configure`; apply it
and publish the committed `session.config` before `request.completed`. The provider may change the
available controls at runtime, including after a model change.
`icon` is a Lucide icon name. `defaultValue` is what a control shows for a key that has no value.
Paseo stores every value you report when a session opens or a change is applied, including values
at the default and values nobody set. The same controls appear in the New workspace screen, in draft
tabs, and on running agents. Offer a control only for a key you can change on a running session.
The provider may change its controls at runtime, including after a model change.
`settings` are user-facing controls. `providerOptions` is opaque JSON supplied at session creation
for provider-specific configuration that Paseo does not render. A provider-specific token budget
belongs in one of those two places; it is not a dedicated prompt field.
A change arrives as `session.configure` with `changes.providerOptions` holding only the keys
that changed, whether or not they have a control. Apply every key and publish the committed
`session.config` before `request.completed`. Fail the request with `request.failed` and leave
every key as it was when you cannot apply one, for example a key you read only when a session
opens. A
value of `null` returns that key to the provider's default. Publish what the session ended up
with, not a copy of the request: Paseo stores your published values so the stored options match
what runs.
If the provider changes an option on its own, for example a model switch turns Fast off, publish
a new `session.config` with the new values. Paseo stores them and does not set the old ones
back.
Not every option needs a control. A provider-specific token budget, for example, belongs in
`providerOptions` and is not a dedicated prompt field. It is delivered in `session.open`, a caller
can change it on a running agent, and your `session.configure` handler decides whether it can
apply it live. Report it in `providerOptions` so Paseo stores what runs.
## Add persistence and replay
@@ -467,7 +487,7 @@ export const vendorEdits: AcpTransformer = {
Test the provider against the real agent, not only mocked frames:
1. install the plugin as a directory source;
2. confirm its SVG, models, modes, and settings appear;
2. confirm its SVG, models, modes, and option controls appear;
3. create a session and complete a real prompt;
4. exercise permissions, steering, cancellation, persistence, and replay when supported;
5. render every custom timeline item on desktop and mobile-width layouts;
+24 -20
View File
@@ -19,7 +19,7 @@ Provider 插件把一个编程 Agent 接入 Paseo,而无需把它加入 Paseo
示例有:
- [`provider-direct`](https://github.com/getpaseo/paseo/tree/main/plugin-examples/provider-direct):会话、设置、提示词、持久化、子会话,以及一个由 Provider 拥有的时间线渲染器;
- [`provider-direct`](https://github.com/getpaseo/paseo/tree/main/plugin-examples/provider-direct):会话、选项控件、提示词、持久化、子会话,以及一个由 Provider 拥有的时间线渲染器;
- [`provider-acp-transformer`](https://github.com/getpaseo/paseo/tree/main/plugin-examples/provider-acp-transformer):一个 ACP 命令模板,带有一个用 Zod 校验的厂商编辑转换器。加载之前,请把 `example-acp --stdio` 替换为一个已安装的 ACP Agent;
- [`inline-thinking`](https://github.com/getpaseo/paseo/tree/main/plugin-examples/inline-thinking):一个只包含渲染器、不实现 Provider 的插件。
@@ -125,7 +125,7 @@ if (input.type === "catalog") {
## 打开会话 {#open-a-session}
`session.open` 包含完整的启动配置:工作目录、环境变量、系统提示词、MCP 服务器、工具策略、模型、模式、设置、不透明的 Provider 选项以及持久化偏好。
`session.open` 包含完整的启动配置:工作目录、环境变量、系统提示词、MCP 服务器、工具策略、模型、模式、`providerOptions` 以及持久化偏好。
对于持久化的、作用于主机的插件设置,请使用 `server.registerSettings()` 返回的句柄。在目录和会话回调中读取它;当 Provider 在内存中保留配置时,订阅它。`read()` 和 `subscribe()` 的契约见[设置](./reference.md#persisted-values)。
@@ -160,7 +160,8 @@ emit({
models,
modes,
thinkingOptions,
settings: [],
controls: [],
providerOptions: {},
},
});
emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
@@ -207,9 +208,11 @@ emit({ type: "session.turn", sessionId, turnId, state: "completed" });
到这一步,Provider 已经可用:用户可以选择模型、打开会话、发送消息并看到回复。
## 添加会话专属的 Composer 控件 {#add-session-specific-composer-controls}
## 为 Provider 选项添加 Composer 控件 {#add-composer-controls-for-provider-options}
只有当原生 Agent 有用户在打开会话后需要修改的设置时,才添加 `session.configure`。在 `session.config` 中以开关或选择控件的形式发布它们:
`providerOptions` 是 Provider 自己的配置:一个由 JSON 值组成的记录,Paseo 按 Agent 保存它,并在 `session.open` 中交给你。它包含用户在 `config.json` 中为你的 Provider 设置的默认值。请校验你读取的每个键。
只有当用户需要在 Composer 中查看并修改其中一部分选项时,才添加 `controls`。一个控件把 `providerOptions` 的一个顶层键绘制为开关或选择器。控件本身不保存值:Paseo 从 `providerOptions` 读取值。在 `session.config` 中发布控件,以及你正在使用的值:
```ts
emit({
@@ -220,31 +223,32 @@ emit({
models: [{ id: "agent-large", label: "Agent Large" }],
modes: [],
thinkingOptions: [],
settings: [
{
type: "toggle",
id: "fast",
label: "Fast mode",
value: nativeSession.fast,
},
controls: [
{ type: "toggle", key: "fast", label: "Fast mode", icon: "Zap", defaultValue: false },
{
type: "select",
id: "approval",
key: "approval",
label: "Approvals",
value: nativeSession.approval,
options: [
{ label: "Ask", value: "ask" },
{ label: "Auto accept", value: "auto" },
icon: "Shield",
defaultValue: "ask",
choices: [
{ value: "ask", label: "Ask" },
{ value: "auto", label: "Auto accept" },
],
},
],
providerOptions: { fast: nativeSession.fast, approval: nativeSession.approval },
},
});
```
Paseo 会在 Composer 中渲染这些控件。用户的修改会以 `session.configure` 的形式到达;应用它,并在 `request.completed` 之前发布已确定的 `session.config`。Provider 可以在运行时改变可用的控件,包括在模型变化之后。
`icon` 是 Lucide 图标名称。`defaultValue` 是控件在键没有值时显示的值。会话打开或应用修改时,你报告的每个值(包括处于默认值的值和无人设置过的值)Paseo 都会保存。相同的控件会出现在“新建工作区”界面、草稿标签页和运行中的 Agent 上。只为你能在运行中的会话里修改的键提供控件。Provider 可以在运行时改变控件,包括在模型变化之后。
`settings` 是面向用户的控件。`providerOptions` 是在创建会话时提供的不透明 JSON,用于 Paseo 不会渲染的 Provider 专属配置。Provider 专属的 token 预算应放在这两处之一;它不是一个专门的提示词字段。
修改会以 `session.configure` 的形式到达,`changes.providerOptions` 只包含发生变化的键,无论这些键是否有控件。应用每一个键,并在 `request.completed` 之前发布已确定的 `session.config`。如果无法应用其中某个键(例如只在会话打开时读取的键),用 `request.failed` 使请求失败,并让每个键保持原样。值为 `null` 表示把该键恢复为 Provider 的默认值。请发布会话最终实际使用的值,而不是请求的副本:Paseo 会保存你发布的值,使保存的选项与实际运行的一致。
如果 Provider 自行改变了某个选项(例如切换模型后关闭了 Fast),请发布带有新值的 `session.config`。Paseo 会保存它们,不会把旧值设置回去。
并非每个选项都需要控件。例如 Provider 专属的 token 预算应放在 `providerOptions` 中,它不是一个专门的提示词字段。它会在 `session.open` 中交付,调用方可以在运行中的 Agent 上修改它,由你的 `session.configure` 处理程序决定能否实时应用。请在 `providerOptions` 中报告它,使 Paseo 保存实际运行的值。
## 添加持久化与重放 {#add-persistence-and-replay}
@@ -405,7 +409,7 @@ export const vendorEdits: AcpTransformer = {
用真实的 Agent 测试 Provider,而不只是模拟的数据帧:
1. 以目录来源安装插件;
2. 确认它的 SVG、模型、模式和设置都会显示;
2. 确认它的 SVG、模型、模式和选项控件都会显示;
3. 创建会话并完成一段真实的提示词;
4. 在支持的情况下,试用权限、引导、取消、持久化和重放;
5. 在桌面和移动端宽度的布局中渲染每一种自定义时间线条目;
+7 -5
View File
@@ -409,7 +409,7 @@ process, credential, and other machine-local work under `server/`. A plugin with
### Providers
Follow [Build a provider plugin](/docs/plugins/providers) for direct and ACP implementations,
session lifecycle, composer settings, timeline renderers, testing, and distribution.
session lifecycle, option controls, timeline renderers, testing, and distribution.
Call `server.registerProvider()` with a `ProviderRegistration` from
`@getpaseo/plugin/server/provider`. Its connection accepts inputs with `send()` and emits complete state
@@ -420,10 +420,12 @@ Use the single `session.prompt` input for messages, structured commands, steerin
effects. Repeat `clientMessageId` on the live user timeline item and publish exactly one matching
`session.prompt_result`. Publish provider-created children as sessions with `parentSessionId`.
Provider settings are toggle/select descriptors that Paseo renders in the composer. Keep
provider-private options under `ProviderSessionConfig.providerOptions` on `session.open`.
This record includes configured defaults and per-agent overrides; validate and apply it in your
provider. See [Provider options](/docs/sdk/provider-options) for configuration and merge semantics.
Provider options are one record, `ProviderSessionConfig.providerOptions` on `session.open`. It
includes configured defaults and per-agent overrides; validate and apply it in your provider. Report
`controls` in `session.config` for the top-level keys that get a toggle or select in the composer,
with the values the session runs under `providerOptions`. A running-session change arrives as
`session.configure` with `changes.providerOptions`. See [Provider options](/docs/sdk/provider-options)
for configuration and merge semantics.
Host tools arrive as MCP servers in the complete
session config.
+2 -2
View File
@@ -353,13 +353,13 @@ Paseo 为服务端代码提供 `@getpaseo/plugin`、`@getpaseo/plugin/server`、
### Provider {#providers}
直接实现和 ACP 实现、会话生命周期、Composer 设置、时间线渲染器、测试和分发,见[构建 Provider 插件](/docs/plugins/providers)。
直接实现和 ACP 实现、会话生命周期、选项控件、时间线渲染器、测试和分发,见[构建 Provider 插件](/docs/plugins/providers)。
使用来自 `@getpaseo/plugin/server/provider` 的 `ProviderRegistration` 调用 `server.registerProvider()`。它的连接通过 `send()` 接收输入,并通过 `onEvent()` 发出完整的状态快照。`send()` 只报告是否接受;提示词的处理结果、轮次、配置、持久化、权限和失败都是事件。
消息、结构化命令、引导和命令副作用都使用同一个 `session.prompt` 输入。在实时的用户时间线条目上重复 `clientMessageId`,并恰好发布一个与之匹配的 `session.prompt_result`。把 Provider 创建的子会话发布为带有 `parentSessionId` 的会话。
Provider 设置是 Paseo 在 Composer 中渲染的开关/选择描述符。把 Provider 私有的选项保留在 `session.open` 的 `ProviderSessionConfig.providerOptions` 中。这个记录包含配置的默认值和针对单个 Agent 的覆盖值;请在你的 Provider 中校验并应用它。配置和合并语义见 [Provider 选项](/docs/sdk/provider-options)。宿主工具以 MCP 服务器的形式出现在完整的会话配置中。
Provider 选项是一个记录,即 `session.open` 中的 `ProviderSessionConfig.providerOptions`。它包含配置的默认值和针对单个 Agent 的覆盖值;请在你的 Provider 中校验并应用它。在 `session.config` 中为需要在 Composer 里显示开关或选择器的顶层键报告 `controls`,并在 `providerOptions` 下报告会话正在使用的值。运行中会话的修改会以 `session.configure` 的形式到达,内容在 `changes.providerOptions` 中。配置和合并语义见 [Provider 选项](/docs/sdk/provider-options)。宿主工具以 MCP 服务器的形式出现在完整的会话配置中。
Paseo 刷新 Agent 的方式是关闭它当前的 Provider 会话,再用当前的配置和持久化值打开它。Provider 在 `session.open` 期间重新读取外部状态。