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.
This commit is contained in:
Mohamed Boudra
2026-11-02 03:21:11 +01:00
parent d0a5d4d970
commit 6bf7a47a96
26 changed files with 591 additions and 62 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);
},
+12 -1
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,7 +122,12 @@ 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">;
}
+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,
@@ -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"],
@@ -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,
};
@@ -240,6 +240,149 @@ 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-"));
try {
const first = await createTestPaseoDaemon({ paseoHomeRoot: root, cleanup: false });
const firstClient = new DaemonClient({ url: `ws://127.0.0.1:${first.port}/ws` });
let agentId: string;
let workspaceId: string;
try {
await firstClient.connect();
await firstClient.fetchAgents({ subscribe: {} });
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;
expect(
await firstClient.setAgentMetadata(agentId, { "b.agent": null, "c.agent": true }),
).toEqual({
"a.agent": { nested: [1, 2] },
"c.agent": true,
});
expect(
await firstClient.setWorkspaceMetadata(workspaceId, {
"b.workspace": null,
"c.workspace": [],
}),
).toEqual({ "a.workspace": 1, "c.workspace": [] });
} finally {
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": [],
});
} finally {
await secondClient.close();
await second.close();
}
} finally {
await rm(root, { recursive: true, force: true });
await rm(directory, { 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: {},
},
@@ -116,6 +117,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,7 @@ const beforeSchemas = {
purpose: z.enum(["interactive", "history"]),
title: z.string().nullable(),
labels: z.record(z.string(), z.string()),
metadata: z.record(z.string(), z.custom<JsonValue>()),
tools: z.array(agentToolMetadataSchema),
env: z.record(z.string(), z.string()),
mcpServers: CreateAgentRequestMessageSchema.shape.config.shape.mcpServers.unwrap(),
@@ -152,6 +155,7 @@ export function describeHookWorkspace(workspace: PersistedWorkspaceRecord): Plug
cwd: workspace.cwd,
name: workspace.title,
archivedAt: workspace.archivedAt,
metadata: workspace.metadata,
};
}
@@ -162,6 +166,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 +175,7 @@ export function describeHookAgent(agent: {
provider: agent.provider,
cwd: agent.cwd,
title: agent.title ?? null,
metadata: agent.metadata,
};
}
@@ -229,7 +235,8 @@ 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) ||
!isDeepStrictEqual(previous.metadata, next.metadata)
) {
throw new Error("agent.open.before hooks can only change env, tools, and mcpServers");
}
@@ -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,
@@ -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` |