diff --git a/docs/data-model.md b/docs/data-model.md index dc65096b66..296720fe08 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -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` | 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` | 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` | 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. | diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index ca6b615eef..26e7dc88ea 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -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(); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 512db1da1d..d9add1631b 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -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; + /** 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, + requestId?: string, + ): Promise> { + 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, + requestId?: string, + ): Promise> { + 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; diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index b476a35cda..d5c7cc6bf0 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -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 | null; readonly agents: { create(options: PaseoWorkspaceAgentCreateOptions): Promise; }; @@ -191,6 +193,8 @@ export interface PaseoWorkspaceHandle { current(): PaseoWorkspace | null; refresh(options?: { requestId?: string }): Promise; 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): Promise; archive(requestId?: string): Promise; /** * 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; + /** Data plugins store on the agent: JSON values under keys such as `"linear.issue"`. */ + metadata?: CreateAgentRequestMessage["metadata"]; } export type PaseoWorkspaceAgentCreateOptions = Omit; @@ -376,6 +382,8 @@ export interface PaseoAgentHandle { readonly options: NonNullable | null; /** How the provider wants its options drawn. Controls hold no value; read `options`. */ readonly controls: NonNullable | null; + /** Data plugins stored on the agent. Paseo never reads a key. */ + readonly metadata: NonNullable | null; readonly runtimeInfo: NonNullable | null; readonly archivedAt: NonNullable | 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): Promise; + /** Top-level keys replace; `null` removes the key. Needs a host with `entityMetadata`. */ + setMetadata(patch: Record): Promise; /** Sends a prompt and resolves when that turn finishes or needs attention. */ run(text: string, options?: PaseoAgentRunOptions): Promise; /** 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); }, diff --git a/packages/plugin/src/server/lifecycle.ts b/packages/plugin/src/server/lifecycle.ts index 20df9c2dcb..256ff45c0f 100644 --- a/packages/plugin/src/server/lifecycle.ts +++ b/packages/plugin/src/server/lifecycle.ts @@ -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; } 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; } export interface PluginSessionOpenRequest { @@ -79,6 +84,7 @@ export interface PluginSessionOpenRequest { purpose: "interactive" | "history"; readonly title: string | null; readonly labels: Readonly>; + readonly metadata: Readonly>; tools: AgentToolDefinition[]; env: Record; mcpServers: NonNullable; @@ -116,7 +122,12 @@ export interface PluginHookEvents { export interface PluginHookRequests { "worktree.create.before": PluginWorktreeCreateRequest; - "agent.create.before": { config: AgentSessionConfig; env?: Record }; + "agent.create.before": { + config: AgentSessionConfig; + env?: Record; + /** Stored on the new agent. Always present in the payload; return it changed to change it. */ + metadata?: Record; + }; "agent.open.before": PluginSessionOpenRequest; "workspace.create.before": Omit; } diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 30519993f2..439a271135 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -303,6 +303,9 @@ import type { // unknown avoids zod-aot's recursive z.json() object-codegen regression. const JsonWireValueSchema = z.unknown() as z.ZodType; +/** 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 = 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; export type ProjectRemoveRequest = z.infer; export type WorkspaceTitleSetRequest = z.infer; export type WorkspacePinSetRequest = z.infer; +export type AgentMetadataUpdateRequest = z.infer; +export type WorkspaceMetadataUpdateRequest = z.infer; +export type AgentMetadataUpdateResponse = z.infer; +export type WorkspaceMetadataUpdateResponse = z.infer; export type WorkspaceRecoveryInspectRequest = z.infer; export type WorkspaceRecoveryRestoreRequest = z.infer; export type SetAgentModeRequestMessage = z.infer; diff --git a/packages/server/src/server/agent/agent-loading.ts b/packages/server/src/server/agent/agent-loading.ts index ba444a394d..e9eb56fe0e 100644 --- a/packages/server/src/server/agent/agent-loading.ts +++ b/packages/server/src/server/agent/agent-loading.ts @@ -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, diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index a630d58d2a..063f4da855 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -12475,6 +12475,7 @@ test.each([ provider: "codex", cwd: workdir, title: null, + metadata: {}, }, turnId: "hook-turn", outcome, diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index ce4cabaef6..2814d80f0f 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -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>; export interface CreateAgentOptions { labels?: Record; + metadata?: AgentMetadata; initialPrompt?: string; env?: Record; persistSession?: boolean; @@ -465,6 +467,8 @@ interface ManagedAgentBase { * User-defined labels for categorizing agents (e.g., { surface: "workspace" }). */ labels: Record; + /** 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; +type AgentMetadata = Record; function attachManagedTurnIdentity( agent: ActiveManagedAgent, @@ -571,6 +576,7 @@ interface WriteLabelsResult { interface AgentMetadataPatch { title?: string; labels?: AgentLabelPatch; + metadata?: Record; } 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; 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, - patch: AgentLabelPatch, -): Record { - 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; + metadata?: AgentMetadata; workspaceId?: string; owner?: AgentOwner; attention?: AttentionState; @@ -1497,6 +1497,7 @@ export class AgentManager { updatedAt?: Date; lastUserMessageAt?: Date | null; labels?: Record; + 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, + ): Promise { + return this.runLifecycleMutation(agentId, async () => { + const liveAgent = this.agents.get(agentId); + if (liveAgent) { + liveAgent.metadata = applyRecordPatch(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 { 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; + metadata?: AgentMetadata; timeline?: AgentTimelineItem[]; timelineRows?: AgentTimelineRow[]; timelineNextSeq?: number; @@ -4128,6 +4154,7 @@ export class AgentManager { updatedAt?: Date; lastUserMessageAt?: Date | null; labels?: Record; + 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; + metadata?: AgentMetadata; }, ): Promise { // 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: {}, }; diff --git a/packages/server/src/server/agent/agent-projections.ts b/packages/server/src/server/agent/agent-projections.ts index c727a045b3..47d2052414 100644 --- a/packages/server/src/server/agent/agent-projections.ts +++ b/packages/server/src/server/agent/agent-projections.ts @@ -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 }), }; diff --git a/packages/server/src/server/agent/agent-storage.ts b/packages/server/src/server/agent/agent-storage.ts index 03c99cfb0b..2ffdceb923 100644 --- a/packages/server/src/server/agent/agent-storage.ts +++ b/packages/server/src/server/agent/agent-storage.ts @@ -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()).default({}), lastStatus: AgentStatusSchema.default("closed"), resumeOnStartup: z.boolean().optional(), lastModeId: z.string().nullable().optional(), diff --git a/packages/server/src/server/agent/create-agent/create.ts b/packages/server/src/server/agent/create-agent/create.ts index 3e47eb0978..d8dc3f3884 100644 --- a/packages/server/src/server/agent/create-agent/create.ts +++ b/packages/server/src/server/agent/create-agent/create.ts @@ -59,6 +59,7 @@ export interface CreateAgentFromSessionInput { images?: Array<{ data: string; mimeType: string }>; attachments?: AgentAttachment[]; labels: Record; + metadata?: CreateAgentOptions["metadata"]; env?: Record; 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, diff --git a/packages/server/src/server/authorization/operation-permissions.ts b/packages/server/src/server/authorization/operation-permissions.ts index c972771bf8..3fd4e153dc 100644 --- a/packages/server/src/server/authorization/operation-permissions.ts +++ b/packages/server/src/server/authorization/operation-permissions.ts @@ -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"], diff --git a/packages/server/src/server/paseo-worktree-service.ts b/packages/server/src/server/paseo-worktree-service.ts index 1205554d4a..322e8704ab 100644 --- a/packages/server/src/server/paseo-worktree-service.ts +++ b/packages/server/src/server/paseo-worktree-service.ts @@ -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; 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, diff --git a/packages/server/src/server/persistence-hooks.ts b/packages/server/src/server/persistence-hooks.ts index 31d8c45ed0..fe235c3f9c 100644 --- a/packages/server/src/server/persistence-hooks.ts +++ b/packages/server/src/server/persistence-hooks.ts @@ -137,6 +137,7 @@ export function extractTimestamps(record: StoredAgentRecord): { updatedAt: Date; lastUserMessageAt: Date | null; labels?: Record; + 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, }; diff --git a/packages/server/src/server/plugins/lifecycle.e2e.test.ts b/packages/server/src/server/plugins/lifecycle.e2e.test.ts index bd1dfa8859..951d59b5fa 100644 --- a/packages/server/src/server/plugins/lifecycle.e2e.test.ts +++ b/packages/server/src/server/plugins/lifecycle.e2e.test.ts @@ -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, { diff --git a/packages/server/src/server/plugins/lifecycle/handlers.test.ts b/packages/server/src/server/plugins/lifecycle/handlers.test.ts index e17cca1ed5..8c57598f92 100644 --- a/packages/server/src/server/plugins/lifecycle/handlers.test.ts +++ b/packages/server/src/server/plugins/lifecycle/handlers.test.ts @@ -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: {}, diff --git a/packages/server/src/server/plugins/lifecycle/index.ts b/packages/server/src/server/plugins/lifecycle/index.ts index d4f58cc78c..a55b61f6d6 100644 --- a/packages/server/src/server/plugins/lifecycle/index.ts +++ b/packages/server/src/server/plugins/lifecycle/index.ts @@ -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()), 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; + metadata: Record; }): 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 = z.dis provider: z.string(), cwd: z.string(), title: z.string().nullable(), + metadata: z.record(z.string(), z.custom()), }) .strict(), name: z.string(), diff --git a/packages/server/src/server/record-patch.ts b/packages/server/src/server/record-patch.ts new file mode 100644 index 0000000000..490ad0b6b0 --- /dev/null +++ b/packages/server/src/server/record-patch.ts @@ -0,0 +1,15 @@ +/** Top-level keys replace; a null value removes the key. */ +export function applyRecordPatch( + record: Record, + patch: Record, +): Record { + const next = { ...record }; + for (const [key, value] of Object.entries(patch)) { + if (value === null) { + delete next[key]; + } else { + next[key] = value; + } + } + return next; +} diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 7c4f3405a2..7817694386 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -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, + ): Promise { + 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, + ): Promise { + 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), }, diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts index 08d7b93bf4..a22b650bf5 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts @@ -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; expectsInitialAgent?: boolean; untrustedSource?: UntrustedWorkspaceSource; background?: boolean; @@ -70,6 +72,7 @@ export interface WorkspaceProvisioningService { context?: { expectsInitialAgent?: boolean; workspaceId?: string; + metadata?: Record; background?: boolean; callerWorkspaceId?: string; }, @@ -242,6 +245,7 @@ export function createWorkspaceProvisioningService(deps: { context?: { expectsInitialAgent?: boolean; workspaceId?: string; + metadata?: Record; 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 } : {}), diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 2c3828353e..3c4da5f69b 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -2012,6 +2012,7 @@ export class VoiceAssistantWebSocketServer { usageSources: true, ownedSubscriptions: true, providerOptionControls: true, + entityMetadata: true, binaryChunkingV1: true, agentRequestReceipts: true, workspaceRequestReceipts: true, diff --git a/packages/server/src/server/workspace-registry.ts b/packages/server/src/server/workspace-registry.ts index 61fcb4f720..d49be7d001 100644 --- a/packages/server/src/server/workspace-registry.ts +++ b/packages/server/src/server/workspace-registry.ts @@ -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()).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; untrustedSource?: UntrustedWorkspaceSource; background?: boolean; }): PersistedWorkspaceRecord { diff --git a/public-docs/plugins/hooks.md b/public-docs/plugins/hooks.md index 788a3df112..19fff88f68 100644 --- a/public-docs/plugins/hooks.md +++ b/public-docs/plugins/hooks.md @@ -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; } interface PluginHookWorkspace { @@ -84,6 +85,7 @@ interface PluginHookWorkspace { cwd: string; name: string | null; archivedAt: string | null; + metadata: Record; } 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` | diff --git a/public-docs/plugins/hooks.zh-CN.md b/public-docs/plugins/hooks.zh-CN.md index b971b5df76..cd5daf2f50 100644 --- a/public-docs/plugins/hooks.zh-CN.md +++ b/public-docs/plugins/hooks.zh-CN.md @@ -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; } interface PluginHookWorkspace { @@ -82,6 +83,7 @@ interface PluginHookWorkspace { cwd: string; name: string | null; archivedAt: string | null; + metadata: Record; } 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` |