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
30 changed files with 760 additions and 63 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. |
+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 } };
+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;
@@ -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 {
+42 -18
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,7 +224,7 @@ 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`:
@@ -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` |
+42 -18
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,7 +221,7 @@ 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`:
@@ -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` |