mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 06:33:05 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77f90d3460 | ||
|
|
7a651fceb3 | ||
|
|
4095d3784d | ||
|
|
21365c571b | ||
|
|
f4dea7413e | ||
|
|
6bf7a47a96 |
@@ -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. |
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -283,6 +283,10 @@ describe("lifecycle hook SDK types", () => {
|
||||
server.addHook("workspace.create.before", async ({ request }) => ({ ...request, title: "Changed" }));
|
||||
server.addHook("workspace.create.before", async () => {});
|
||||
server.addHook("agent.open.before", async () => {});
|
||||
server.addHook("agent.open.before", async ({ request }) => {
|
||||
const { metadata, ...withoutMetadata } = request;
|
||||
return withoutMetadata;
|
||||
});
|
||||
server.addHook("agent.turn.after", async ({ agent, outcome, timeline }, { paseo, signal }) => {
|
||||
const kind: "completed" | "failed" | "canceled" = outcome.kind;
|
||||
// @ts-expect-error Notification payloads have no creation request.
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
AgentPermissionResponse,
|
||||
AgentTimelineItem,
|
||||
AgentSessionConfig,
|
||||
JsonValue,
|
||||
} from "@getpaseo/protocol/agent-types";
|
||||
import type { PaseoApi } from "@getpaseo/client";
|
||||
import type { WorkspaceCreateRequest } from "@getpaseo/protocol/messages";
|
||||
@@ -19,6 +20,8 @@ export interface PluginHookWorkspace {
|
||||
cwd: string;
|
||||
name: string | null;
|
||||
archivedAt: string | null;
|
||||
/** Data plugins stored on the workspace. Paseo never reads a key. */
|
||||
metadata: Record<string, JsonValue>;
|
||||
}
|
||||
|
||||
export interface PluginHookWorktree {
|
||||
@@ -68,6 +71,8 @@ export interface PluginHookAgent {
|
||||
provider: string;
|
||||
cwd: string;
|
||||
title: string | null;
|
||||
/** Data plugins stored on the agent. Paseo never reads a key. */
|
||||
metadata: Record<string, JsonValue>;
|
||||
}
|
||||
|
||||
export interface PluginSessionOpenRequest {
|
||||
@@ -79,6 +84,7 @@ export interface PluginSessionOpenRequest {
|
||||
purpose: "interactive" | "history";
|
||||
readonly title: string | null;
|
||||
readonly labels: Readonly<Record<string, string>>;
|
||||
readonly metadata: Readonly<Record<string, JsonValue>>;
|
||||
tools: AgentToolDefinition[];
|
||||
env: Record<string, string>;
|
||||
mcpServers: NonNullable<AgentSessionConfig["mcpServers"]>;
|
||||
@@ -116,15 +122,27 @@ export interface PluginHookEvents {
|
||||
|
||||
export interface PluginHookRequests {
|
||||
"worktree.create.before": PluginWorktreeCreateRequest;
|
||||
"agent.create.before": { config: AgentSessionConfig; env?: Record<string, string> };
|
||||
"agent.create.before": {
|
||||
config: AgentSessionConfig;
|
||||
env?: Record<string, string>;
|
||||
/** Stored on the new agent. Always present in the payload; return it changed to change it. */
|
||||
metadata?: Record<string, JsonValue>;
|
||||
};
|
||||
"agent.open.before": PluginSessionOpenRequest;
|
||||
"workspace.create.before": Omit<WorkspaceCreateRequest, "type" | "requestId">;
|
||||
}
|
||||
|
||||
/** What a hook may return. An omitted `metadata` stays as it was. */
|
||||
type PluginHookRequestResults = Omit<PluginHookRequests, "agent.open.before"> & {
|
||||
"agent.open.before": Omit<PluginSessionOpenRequest, "metadata"> & {
|
||||
metadata?: Readonly<Record<string, JsonValue>>;
|
||||
};
|
||||
};
|
||||
|
||||
export type PluginHooks = {
|
||||
[Name in keyof PluginHookRequests]: {
|
||||
request: PluginHookRequests[Name];
|
||||
result: PluginHookRequests[Name];
|
||||
result: PluginHookRequestResults[Name];
|
||||
};
|
||||
} & { [Name in keyof PluginHookEvents]: { payload: PluginHookEvents[Name] } } & {
|
||||
"worktree.create.after": { payload: { worktree: PluginHookWorktree } };
|
||||
|
||||
@@ -303,6 +303,9 @@ import type {
|
||||
// unknown avoids zod-aot's recursive z.json() object-codegen regression.
|
||||
const JsonWireValueSchema = z.unknown() as z.ZodType<JsonValue>;
|
||||
|
||||
/** Plugin-owned data on an agent or workspace. Paseo stores it and never reads a key. */
|
||||
const EntityMetadataSchema = z.record(z.string(), JsonWireValueSchema);
|
||||
|
||||
export const AgentStatusSchema = z.enum(AGENT_LIFECYCLE_STATUSES);
|
||||
|
||||
const AgentModeSchema: z.ZodType<AgentMode> = z.object({
|
||||
@@ -922,6 +925,7 @@ export const AgentSnapshotPayloadSchema = z.object({
|
||||
lastError: z.string().optional(),
|
||||
title: z.string().nullable(),
|
||||
labels: z.record(z.string(), z.string()).default({}),
|
||||
metadata: EntityMetadataSchema.optional(),
|
||||
requiresAttention: z.boolean().optional(),
|
||||
attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(),
|
||||
attentionTimestamp: z.string().nullable().optional(),
|
||||
@@ -1068,6 +1072,22 @@ export const WorkspaceTitleSetRequestSchema = z.object({
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const AgentMetadataUpdateRequestSchema = z.object({
|
||||
type: z.literal("agent.metadata.update.request"),
|
||||
agentId: z.string(),
|
||||
// Top-level keys replace; null removes the key.
|
||||
patch: EntityMetadataSchema,
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const WorkspaceMetadataUpdateRequestSchema = z.object({
|
||||
type: z.literal("workspace.metadata.update.request"),
|
||||
workspaceId: z.string(),
|
||||
// Top-level keys replace; null removes the key.
|
||||
patch: EntityMetadataSchema,
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const WorkspacePinSetRequestSchema = z.object({
|
||||
type: z.literal("workspace.pin.set.request"),
|
||||
workspaceId: z.string(),
|
||||
@@ -1826,6 +1846,7 @@ export const CreateAgentRequestMessageSchema = z.object({
|
||||
// Workspace creation intent, rejected when selecting an existing workspace.
|
||||
background: z.boolean().optional(),
|
||||
labels: z.record(z.string(), z.string()).default({}),
|
||||
metadata: EntityMetadataSchema.optional(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
@@ -2204,6 +2225,28 @@ export const WorkspaceTitleSetResponseSchema = z.object({
|
||||
payload: WorkspaceTitleSetResponsePayloadSchema,
|
||||
});
|
||||
|
||||
export const AgentMetadataUpdateResponseSchema = z.object({
|
||||
type: z.literal("agent.metadata.update.response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
agentId: z.string(),
|
||||
accepted: z.boolean(),
|
||||
metadata: EntityMetadataSchema.nullable(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const WorkspaceMetadataUpdateResponseSchema = z.object({
|
||||
type: z.literal("workspace.metadata.update.response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
workspaceId: z.string(),
|
||||
accepted: z.boolean(),
|
||||
metadata: EntityMetadataSchema.nullable(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const WorkspacePinSetResponsePayloadSchema = z.object({
|
||||
requestId: z.string(),
|
||||
workspaceId: z.string(),
|
||||
@@ -2759,6 +2802,7 @@ export const WorkspaceCreateRequestSchema = z.object({
|
||||
idempotencyKey: z.string().min(1).max(512).optional(),
|
||||
// Optional user-set title applied to the created workspace.
|
||||
title: z.string().optional(),
|
||||
metadata: EntityMetadataSchema.optional(),
|
||||
// Optional prompt context for workspace-level name/branch generation.
|
||||
firstAgentContext: FirstAgentContextSchema.optional(),
|
||||
// Retained for wire parsing; public internal creation is no longer supported.
|
||||
@@ -3355,6 +3399,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
ProjectIconSetRequestSchema,
|
||||
ProjectRemoveRequestSchema,
|
||||
WorkspaceTitleSetRequestSchema,
|
||||
AgentMetadataUpdateRequestSchema,
|
||||
WorkspaceMetadataUpdateRequestSchema,
|
||||
WorkspacePinSetRequestSchema,
|
||||
WorkspaceLabelListRequestSchema,
|
||||
WorkspaceLabelAssignmentSetRequestSchema,
|
||||
@@ -3888,6 +3934,7 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
explicitEventSubscriptions: z.boolean().optional(),
|
||||
ownedSubscriptions: z.boolean().optional(),
|
||||
providerOptionControls: z.boolean().optional(),
|
||||
entityMetadata: z.boolean().optional(),
|
||||
binaryChunkingV1: z.boolean().optional(),
|
||||
workspaceSetupObservation: z.boolean().optional(),
|
||||
workspaceSortTimes: z.boolean().optional(),
|
||||
@@ -4222,6 +4269,7 @@ export const WorkspaceDescriptorPayloadSchema = z
|
||||
pinnedAt: z.string().nullable().optional(),
|
||||
// COMPAT(workspaceLabels): added in v0.5.0, remove optional after 2027-08-14.
|
||||
labels: z.array(z.string()).optional(),
|
||||
metadata: EntityMetadataSchema.optional(),
|
||||
// Retained for wire parsing of older daemon descriptors.
|
||||
internal: z.boolean().optional(),
|
||||
background: z.boolean().optional(),
|
||||
@@ -7200,6 +7248,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
ProjectIconSetResponseSchema,
|
||||
ProjectRemoveResponseSchema,
|
||||
WorkspaceTitleSetResponseSchema,
|
||||
AgentMetadataUpdateResponseSchema,
|
||||
WorkspaceMetadataUpdateResponseSchema,
|
||||
WorkspacePinSetResponseSchema,
|
||||
WorkspaceRecoveryInspectResponseSchema,
|
||||
WorkspaceRecoveryRestoreResponseSchema,
|
||||
@@ -7566,6 +7616,10 @@ export type ProjectIconSetRequest = z.infer<typeof ProjectIconSetRequestSchema>;
|
||||
export type ProjectRemoveRequest = z.infer<typeof ProjectRemoveRequestSchema>;
|
||||
export type WorkspaceTitleSetRequest = z.infer<typeof WorkspaceTitleSetRequestSchema>;
|
||||
export type WorkspacePinSetRequest = z.infer<typeof WorkspacePinSetRequestSchema>;
|
||||
export type AgentMetadataUpdateRequest = z.infer<typeof AgentMetadataUpdateRequestSchema>;
|
||||
export type WorkspaceMetadataUpdateRequest = z.infer<typeof WorkspaceMetadataUpdateRequestSchema>;
|
||||
export type AgentMetadataUpdateResponse = z.infer<typeof AgentMetadataUpdateResponseSchema>;
|
||||
export type WorkspaceMetadataUpdateResponse = z.infer<typeof WorkspaceMetadataUpdateResponseSchema>;
|
||||
export type WorkspaceRecoveryInspectRequest = z.infer<typeof WorkspaceRecoveryInspectRequestSchema>;
|
||||
export type WorkspaceRecoveryRestoreRequest = z.infer<typeof WorkspaceRecoveryRestoreRequestSchema>;
|
||||
export type SetAgentModeRequestMessage = z.infer<typeof SetAgentModeRequestMessageSchema>;
|
||||
|
||||
@@ -133,6 +133,7 @@ export async function ensureAgentLoaded(
|
||||
}
|
||||
snapshot = await deps.agentManager.createAgent(config, agentId, {
|
||||
labels: record.labels,
|
||||
metadata: record.metadata,
|
||||
workspaceId: record.workspaceId,
|
||||
owner: record.owner,
|
||||
env: record.launchEnv,
|
||||
|
||||
@@ -12475,6 +12475,7 @@ test.each([
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
title: null,
|
||||
metadata: {},
|
||||
},
|
||||
turnId: "hook-turn",
|
||||
outcome,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ensureAgentLoaded } from "./agent-loading.js";
|
||||
import { projectTimelineRows } from "./timeline-projection.js";
|
||||
import type { PluginLifecycle } from "../plugins/lifecycle/index.js";
|
||||
import { describeHookAgent, publishAgentStream } from "../plugins/lifecycle/index.js";
|
||||
import { applyRecordPatch } from "../record-patch.js";
|
||||
import type { PluginSessionOpenRequest } from "@getpaseo/plugin/server";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { basename, resolve } from "node:path";
|
||||
@@ -60,7 +61,7 @@ import {
|
||||
type ListImportableSessionsOptions,
|
||||
} from "./agent-sdk-types.js";
|
||||
import { buildArchivedAgentRecord, type ArchivedStoredAgentRecord } from "./agent-archive.js";
|
||||
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import type { JsonValue, ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import { toStoredAgentRecord } from "./agent-projections.js";
|
||||
import {
|
||||
applyProviderOptionsPatch,
|
||||
@@ -324,6 +325,7 @@ type ProviderClientMap = Partial<Record<AgentProvider, AgentClient>>;
|
||||
|
||||
export interface CreateAgentOptions {
|
||||
labels?: Record<string, string>;
|
||||
metadata?: AgentMetadata;
|
||||
initialPrompt?: string;
|
||||
env?: Record<string, string>;
|
||||
persistSession?: boolean;
|
||||
@@ -465,6 +467,8 @@ interface ManagedAgentBase {
|
||||
* User-defined labels for categorizing agents (e.g., { surface: "workspace" }).
|
||||
*/
|
||||
labels: Record<string, string>;
|
||||
/** Plugin-owned data. Paseo persists and forwards it and never reads a key. */
|
||||
metadata: AgentMetadata;
|
||||
}
|
||||
|
||||
type ManagedAgentWithSession = ManagedAgentBase & {
|
||||
@@ -524,6 +528,7 @@ type ActiveManagedAgent =
|
||||
|
||||
type LiveManagedAgent = ActiveManagedAgent;
|
||||
type AgentLabelPatch = Record<string, string | null>;
|
||||
type AgentMetadata = Record<string, JsonValue>;
|
||||
|
||||
function attachManagedTurnIdentity(
|
||||
agent: ActiveManagedAgent,
|
||||
@@ -571,6 +576,7 @@ interface WriteLabelsResult {
|
||||
interface AgentMetadataPatch {
|
||||
title?: string;
|
||||
labels?: AgentLabelPatch;
|
||||
metadata?: Record<string, JsonValue | null>;
|
||||
}
|
||||
|
||||
const SYSTEM_ERROR_PREFIX = "[System Error]";
|
||||
@@ -629,6 +635,11 @@ function createAbortError(signal: AbortSignal | undefined, fallbackMessage: stri
|
||||
return Object.assign(new Error(message), { name: "AbortError" });
|
||||
}
|
||||
|
||||
/** The user labels and plugin metadata an agent carries; both default to empty. */
|
||||
function agentEntityData(source?: { labels?: Record<string, string>; metadata?: AgentMetadata }) {
|
||||
return { labels: source?.labels ?? {}, metadata: source?.metadata ?? {} };
|
||||
}
|
||||
|
||||
function validateAgentId(agentId: string, source: string): string {
|
||||
const result = AgentIdSchema.safeParse(agentId);
|
||||
if (!result.success) {
|
||||
@@ -637,21 +648,6 @@ function validateAgentId(agentId: string, source: string): string {
|
||||
return result.data;
|
||||
}
|
||||
|
||||
function applyLabelPatch(
|
||||
labels: Record<string, string>,
|
||||
patch: AgentLabelPatch,
|
||||
): Record<string, string> {
|
||||
const nextLabels = { ...labels };
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value === null) {
|
||||
delete nextLabels[key];
|
||||
} else {
|
||||
nextLabels[key] = value;
|
||||
}
|
||||
}
|
||||
return nextLabels;
|
||||
}
|
||||
|
||||
function buildExplicitTimelineSeedForRegister(
|
||||
now: Date,
|
||||
options:
|
||||
@@ -1386,6 +1382,7 @@ export class AgentManager {
|
||||
const request = await this.pluginLifecycle.before("agent.create.before", {
|
||||
config: sent,
|
||||
env: options.env,
|
||||
metadata: options.metadata ?? {},
|
||||
});
|
||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. A plugin written
|
||||
// against the previous SDK edits `config.featureValues`.
|
||||
@@ -1397,7 +1394,7 @@ export class AgentManager {
|
||||
}),
|
||||
internal: config.internal,
|
||||
};
|
||||
options = { ...options, env: request.env };
|
||||
options = { ...options, env: request.env, metadata: request.metadata ?? options.metadata };
|
||||
}
|
||||
await this.deleteAgentState(resolvedAgentId);
|
||||
const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig(
|
||||
@@ -1422,6 +1419,7 @@ export class AgentManager {
|
||||
workspaceId: options.workspaceId ?? null,
|
||||
title: options.initialTitle ?? storedConfig.title ?? null,
|
||||
labels: options.labels ?? {},
|
||||
metadata: options.metadata ?? {},
|
||||
},
|
||||
);
|
||||
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
|
||||
@@ -1432,6 +1430,7 @@ export class AgentManager {
|
||||
const agent = await this.registerSession(session, storedConfig, resolvedAgentId, {
|
||||
launchedOptions,
|
||||
labels: options.labels,
|
||||
metadata: options.metadata,
|
||||
initialTitle: options.initialTitle,
|
||||
workspaceId: options.workspaceId,
|
||||
owner: options.owner,
|
||||
@@ -1465,6 +1464,7 @@ export class AgentManager {
|
||||
updatedAt?: Date;
|
||||
lastUserMessageAt?: Date | null;
|
||||
labels?: Record<string, string>;
|
||||
metadata?: AgentMetadata;
|
||||
workspaceId?: string;
|
||||
owner?: AgentOwner;
|
||||
attention?: AttentionState;
|
||||
@@ -1497,6 +1497,7 @@ export class AgentManager {
|
||||
updatedAt?: Date;
|
||||
lastUserMessageAt?: Date | null;
|
||||
labels?: Record<string, string>;
|
||||
metadata?: AgentMetadata;
|
||||
workspaceId?: string;
|
||||
owner?: AgentOwner;
|
||||
attention?: AttentionState;
|
||||
@@ -1549,7 +1550,7 @@ export class AgentManager {
|
||||
purpose,
|
||||
workspaceId: options?.workspaceId ?? null,
|
||||
title: record?.title ?? mergedConfig.title ?? null,
|
||||
labels: record?.labels ?? options?.labels ?? {},
|
||||
...agentEntityData(record ?? options),
|
||||
},
|
||||
);
|
||||
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
|
||||
@@ -1720,6 +1721,7 @@ export class AgentManager {
|
||||
workspaceId: existing.workspaceId,
|
||||
title: await this.resolveInitialPersistedTitle(agentId, existing.config, null),
|
||||
labels: existing.labels,
|
||||
metadata: existing.metadata,
|
||||
},
|
||||
);
|
||||
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
|
||||
@@ -1764,6 +1766,7 @@ export class AgentManager {
|
||||
return this.registerSession(session, storedConfig, agentId, {
|
||||
launchedOptions,
|
||||
labels: existing.labels,
|
||||
metadata: existing.metadata,
|
||||
workspaceId: existing.workspaceId,
|
||||
owner: existing.owner,
|
||||
launchEnv: existing.launchEnv,
|
||||
@@ -2224,6 +2227,7 @@ export class AgentManager {
|
||||
attention,
|
||||
internal: record.internal,
|
||||
labels: record.labels,
|
||||
metadata: record.metadata,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -2425,10 +2429,30 @@ export class AgentManager {
|
||||
});
|
||||
}
|
||||
|
||||
/** Applies a plugin-metadata patch (null removes a key) and returns the stored result. */
|
||||
async setMetadata(
|
||||
agentId: string,
|
||||
patch: Record<string, JsonValue | null>,
|
||||
): Promise<AgentMetadata> {
|
||||
return this.runLifecycleMutation(agentId, async () => {
|
||||
const liveAgent = this.agents.get(agentId);
|
||||
if (liveAgent) {
|
||||
liveAgent.metadata = applyRecordPatch<JsonValue>(liveAgent.metadata, patch);
|
||||
this.touchUpdatedAt(liveAgent);
|
||||
await this.persistSnapshot(liveAgent);
|
||||
this.emitState(liveAgent, { persist: false });
|
||||
return liveAgent.metadata;
|
||||
}
|
||||
const record = await this.writeStoredMetadata(agentId, { metadata: patch });
|
||||
if (!record.internal) this.dispatchStoredAgentState(record);
|
||||
return record.metadata;
|
||||
});
|
||||
}
|
||||
|
||||
private async writeLabels(agentId: string, patch: AgentLabelPatch): Promise<WriteLabelsResult> {
|
||||
const liveAgent = this.agents.get(agentId);
|
||||
if (liveAgent) {
|
||||
liveAgent.labels = applyLabelPatch(liveAgent.labels, patch);
|
||||
liveAgent.labels = applyRecordPatch(liveAgent.labels, patch);
|
||||
this.touchUpdatedAt(liveAgent);
|
||||
await this.persistSnapshot(liveAgent);
|
||||
this.emitState(liveAgent, { persist: false });
|
||||
@@ -2453,7 +2477,8 @@ export class AgentManager {
|
||||
const nextRecord = {
|
||||
...record,
|
||||
...(patch.title ? { title: patch.title } : {}),
|
||||
...(patch.labels ? { labels: applyLabelPatch(record.labels, patch.labels) } : {}),
|
||||
...(patch.labels ? { labels: applyRecordPatch(record.labels, patch.labels) } : {}),
|
||||
...(patch.metadata ? { metadata: applyRecordPatch(record.metadata, patch.metadata) } : {}),
|
||||
updatedAt: this.nextStoredUpdatedAt(record),
|
||||
};
|
||||
await registry.upsert(nextRecord);
|
||||
@@ -2632,7 +2657,7 @@ export class AgentManager {
|
||||
await registry.upsert({
|
||||
...record,
|
||||
...(updates?.workspaceId ? { workspaceId: updates.workspaceId } : {}),
|
||||
...(updates?.labels ? { labels: applyLabelPatch(record.labels, updates.labels) } : {}),
|
||||
...(updates?.labels ? { labels: applyRecordPatch(record.labels, updates.labels) } : {}),
|
||||
archivedAt: null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
@@ -3919,6 +3944,7 @@ export class AgentManager {
|
||||
updatedAt?: Date;
|
||||
lastUserMessageAt?: Date | null;
|
||||
labels?: Record<string, string>;
|
||||
metadata?: AgentMetadata;
|
||||
timeline?: AgentTimelineItem[];
|
||||
timelineRows?: AgentTimelineRow[];
|
||||
timelineNextSeq?: number;
|
||||
@@ -4128,6 +4154,7 @@ export class AgentManager {
|
||||
updatedAt?: Date;
|
||||
lastUserMessageAt?: Date | null;
|
||||
labels?: Record<string, string>;
|
||||
metadata?: AgentMetadata;
|
||||
historyPrimed?: boolean;
|
||||
lastUsage?: AgentUsage;
|
||||
lastError?: string;
|
||||
@@ -4186,7 +4213,7 @@ export class AgentManager {
|
||||
lastError: options?.lastError,
|
||||
attention: resolveInitialAttention(options?.attention),
|
||||
internal: config.internal ?? false,
|
||||
labels: options?.labels ?? {},
|
||||
...agentEntityData(options),
|
||||
} as ActiveManagedAgent;
|
||||
}
|
||||
|
||||
@@ -5770,6 +5797,7 @@ export class AgentManager {
|
||||
workspaceId?: string | null;
|
||||
title?: string | null;
|
||||
labels?: Record<string, string>;
|
||||
metadata?: AgentMetadata;
|
||||
},
|
||||
): Promise<AgentLaunchContext> {
|
||||
// A plugin changing during this open must still make the resulting session stale.
|
||||
@@ -5787,7 +5815,7 @@ export class AgentManager {
|
||||
purpose: opening?.purpose ?? "interactive",
|
||||
env: { ...env },
|
||||
title: opening?.title ?? null,
|
||||
labels: opening?.labels ?? {},
|
||||
...agentEntityData(opening),
|
||||
tools: [],
|
||||
mcpServers: {},
|
||||
};
|
||||
|
||||
@@ -94,6 +94,7 @@ export function toStoredAgentRecord(
|
||||
lastUserMessageAt: agent.lastUserMessageAt ? agent.lastUserMessageAt.toISOString() : null,
|
||||
title: options?.title ?? null,
|
||||
labels: agent.labels,
|
||||
metadata: agent.metadata,
|
||||
lastStatus: agent.lifecycle,
|
||||
lastModeId: agent.currentModeId ?? config?.modeId ?? null,
|
||||
config: config ?? null,
|
||||
@@ -155,6 +156,7 @@ export function toAgentPayload(
|
||||
persistence: projectPersistenceHandleForWire(agent.persistence),
|
||||
title: options?.title ?? null,
|
||||
labels: agent.labels,
|
||||
metadata: agent.metadata,
|
||||
archivedAt: options?.archivedAt ?? null,
|
||||
...(agent.internal ? { internal: true } : {}),
|
||||
};
|
||||
@@ -284,6 +286,7 @@ export function buildStoredAgentPayload(
|
||||
archivedAt: record.archivedAt ?? null,
|
||||
...(record.internal ? { internal: true } : {}),
|
||||
labels: normalizeLabels(record.labels),
|
||||
metadata: record.metadata,
|
||||
...(typeof record.lastError === "string" ? { lastError: record.lastError } : {}),
|
||||
...(providerAvailable ? {} : { providerUnavailable: true }),
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { promises as fs, type Dirent } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { z } from "zod";
|
||||
import type { Logger } from "pino";
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
|
||||
import { writeJsonFileAtomic } from "../atomic-file.js";
|
||||
import { AgentFeatureSchema, AgentOptionControlSchema, AgentStatusSchema } from "../messages.js";
|
||||
@@ -56,6 +57,7 @@ const RAW_STORED_AGENT_SCHEMA = z.object({
|
||||
lastUserMessageAt: z.string().nullable().optional(),
|
||||
title: z.string().nullable().optional(),
|
||||
labels: z.record(z.string(), z.string()).default({}),
|
||||
metadata: z.record(z.string(), z.custom<JsonValue>()).default({}),
|
||||
lastStatus: AgentStatusSchema.default("closed"),
|
||||
resumeOnStartup: z.boolean().optional(),
|
||||
lastModeId: z.string().nullable().optional(),
|
||||
|
||||
@@ -59,6 +59,7 @@ export interface CreateAgentFromSessionInput {
|
||||
images?: Array<{ data: string; mimeType: string }>;
|
||||
attachments?: AgentAttachment[];
|
||||
labels: Record<string, string>;
|
||||
metadata?: CreateAgentOptions["metadata"];
|
||||
env?: Record<string, string>;
|
||||
provisionalTitle: string | null;
|
||||
firstAgentContext: FirstAgentContext;
|
||||
@@ -251,6 +252,7 @@ async function resolveSessionCreateAgent(
|
||||
config: sessionConfig,
|
||||
createOptions: {
|
||||
labels: input.labels,
|
||||
metadata: input.metadata,
|
||||
initialPrompt: trimmedPrompt,
|
||||
env: input.env,
|
||||
initialTitle: input.provisionalTitle,
|
||||
|
||||
@@ -562,6 +562,7 @@ function createManagedAgent(overrides: Partial<ManagedAgent> = {}): ManagedAgent
|
||||
pendingPermissions: new Map(),
|
||||
persistence: null,
|
||||
labels: {},
|
||||
metadata: {},
|
||||
attention: { requiresAttention: false },
|
||||
...overrides,
|
||||
} as ManagedAgent;
|
||||
|
||||
@@ -214,6 +214,8 @@ const INBOUND_PERMISSION = {
|
||||
"workspace.label.delete.request": "workspace.manage",
|
||||
"workspace.label.list.request": "workspace.read",
|
||||
"workspace.label.update.request": "workspace.manage",
|
||||
"agent.metadata.update.request": "workspace.write",
|
||||
"workspace.metadata.update.request": "workspace.manage",
|
||||
"workspace.pin.set.request": "workspace.manage",
|
||||
"workspace.recovery.inspect.request": ["workspace.read", "hub.execute"],
|
||||
"workspace.recovery.restore.request": ["workspace.manage", "hub.execute"],
|
||||
@@ -453,6 +455,8 @@ const OUTBOUND_PERMISSION = {
|
||||
"workspace.label.list.response": "workspace.read",
|
||||
"workspace.label.update": "workspace.read",
|
||||
"workspace.label.update.response": "workspace.manage",
|
||||
"agent.metadata.update.response": "workspace.write",
|
||||
"workspace.metadata.update.response": "workspace.manage",
|
||||
"workspace.pin.set.response": "workspace.manage",
|
||||
"workspace.recovery.inspect.response": ["workspace.read", "hub.execute"],
|
||||
"workspace.recovery.restore.response": ["workspace.manage", "hub.execute"],
|
||||
|
||||
@@ -1109,6 +1109,7 @@ test("disabled recovery closes stale active records without resuming sessions",
|
||||
lastStatus: status as StoredAgentRecord["lastStatus"],
|
||||
title: `Keep ${status}`,
|
||||
labels: { "paseo.parent-agent-id": "parent", custom: "keep" },
|
||||
metadata: {},
|
||||
persistence: { provider: "codex", sessionId: `session-${index}` },
|
||||
config: { modeId: "full-access" },
|
||||
requiresAttention: true,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import type { PluginLifecycle } from "./plugins/lifecycle/index.js";
|
||||
import { stat } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
@@ -34,6 +35,7 @@ export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput {
|
||||
workspaceId?: string;
|
||||
projectId?: string;
|
||||
title?: string;
|
||||
metadata?: Record<string, JsonValue>;
|
||||
background?: boolean;
|
||||
callerWorkspaceId?: string;
|
||||
}
|
||||
@@ -113,6 +115,7 @@ async function createPaseoWorktreeWithPriority(
|
||||
branch: createdWorktree.worktree.branchName || null,
|
||||
baseBranch: createdWorktree.worktree.comparisonBaseRef,
|
||||
title: input.title?.trim() || resolveFirstAgentPromptTitle(input.firstAgentContext),
|
||||
metadata: input.metadata,
|
||||
expectsInitialAgent: Boolean(input.firstAgentContext),
|
||||
background: input.background,
|
||||
callerWorkspaceId: input.callerWorkspaceId,
|
||||
|
||||
@@ -137,6 +137,7 @@ export function extractTimestamps(record: StoredAgentRecord): {
|
||||
updatedAt: Date;
|
||||
lastUserMessageAt: Date | null;
|
||||
labels?: Record<string, string>;
|
||||
metadata?: StoredAgentRecord["metadata"];
|
||||
workspaceId?: string;
|
||||
owner?: StoredAgentRecord["owner"];
|
||||
} {
|
||||
@@ -145,6 +146,7 @@ export function extractTimestamps(record: StoredAgentRecord): {
|
||||
updatedAt: new Date(resolveStoredAgentUpdatedAt(record)),
|
||||
lastUserMessageAt: record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null,
|
||||
labels: record.labels,
|
||||
metadata: record.metadata,
|
||||
workspaceId: record.workspaceId,
|
||||
owner: record.owner,
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import { resolveDaemonVersion } from "../daemon-version.js";
|
||||
import { BuiltinPluginLoader } from "./builtin/index.js";
|
||||
import { createPaseoApi } from "@getpaseo/client";
|
||||
|
||||
test("a plugin transforms workspace creation once across receipt replays and observes its committed lifecycle", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-lifecycle-"));
|
||||
@@ -240,6 +241,193 @@ export default function contribute(server) {
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("metadata set at creation reaches the hooks, a before hook can add to it, and snapshots carry it", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-metadata-hooks-"));
|
||||
const daemon = await createTestPaseoDaemon();
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
try {
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "metadata-hooks",
|
||||
requirements: { paseo: `>=${resolveDaemonVersion(import.meta.url)}` },
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(directory, "index.server.ts"),
|
||||
`
|
||||
export default function contribute(server) {
|
||||
const log = (hook, metadata) => console.log(JSON.stringify({ hook, metadata }));
|
||||
server.addHook("workspace.create.before", async ({ request }) => {
|
||||
log("workspace.create.before", request.metadata);
|
||||
return request;
|
||||
});
|
||||
server.addHook("workspace.create.after", async ({ workspace }) => {
|
||||
log("workspace.create.after", workspace.metadata);
|
||||
});
|
||||
server.addHook("agent.create.before", async ({ request }) => {
|
||||
log("agent.create.before", request.metadata);
|
||||
return { ...request, metadata: { ...request.metadata, "hook.added": true } };
|
||||
});
|
||||
server.addHook("agent.open.before", async ({ request }) => {
|
||||
log("agent.open.before", request.metadata);
|
||||
return request;
|
||||
});
|
||||
server.addHook("agent.create.after", async ({ agent }) => {
|
||||
log("agent.create.after", agent.metadata);
|
||||
});
|
||||
return () => {};
|
||||
}
|
||||
`,
|
||||
);
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: {} });
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(directory);
|
||||
const workspace = await client.createWorkspace({
|
||||
source: { kind: "directory", path: directory },
|
||||
metadata: { "linear.issue": { id: "ENG-123" } },
|
||||
});
|
||||
expect(workspace.workspace?.metadata).toEqual({ "linear.issue": { id: "ENG-123" } });
|
||||
const agent = await client.createAgent({
|
||||
provider: "claude",
|
||||
cwd: directory,
|
||||
workspaceId: workspace.workspace!.id,
|
||||
metadata: { "linear.issue": { id: "ENG-123" } },
|
||||
});
|
||||
expect(agent.metadata).toEqual({ "linear.issue": { id: "ENG-123" }, "hook.added": true });
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const logs = await client.getPluginLogs("metadata-hooks");
|
||||
return logs
|
||||
.filter((entry) => entry.message.startsWith('{"hook":'))
|
||||
.map((entry) => JSON.parse(entry.message));
|
||||
})
|
||||
.toMatchObject([
|
||||
{ hook: "workspace.create.before", metadata: { "linear.issue": { id: "ENG-123" } } },
|
||||
{ hook: "workspace.create.after", metadata: { "linear.issue": { id: "ENG-123" } } },
|
||||
{ hook: "agent.create.before", metadata: { "linear.issue": { id: "ENG-123" } } },
|
||||
{
|
||||
hook: "agent.open.before",
|
||||
metadata: { "linear.issue": { id: "ENG-123" }, "hook.added": true },
|
||||
},
|
||||
{
|
||||
hook: "agent.create.after",
|
||||
metadata: { "linear.issue": { id: "ENG-123" }, "hook.added": true },
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("metadata set at creation and through setMetadata survives a daemon restart", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "paseo-metadata-restart-"));
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-metadata-cwd-"));
|
||||
const pluginDirectory = await mkdtemp(path.join(tmpdir(), "paseo-metadata-plugin-"));
|
||||
try {
|
||||
await writeFile(
|
||||
path.join(pluginDirectory, "paseo-plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "metadata-restart",
|
||||
requirements: { paseo: `>=${resolveDaemonVersion(import.meta.url)}` },
|
||||
}),
|
||||
);
|
||||
// Returns an explicit object without `metadata`, as a plugin written before metadata existed.
|
||||
await writeFile(
|
||||
path.join(pluginDirectory, "index.server.ts"),
|
||||
`
|
||||
export default function contribute(server) {
|
||||
server.addHook("agent.open.before", async ({ request }) => {
|
||||
console.log(JSON.stringify({ hook: "agent.open.before", metadata: request.metadata }));
|
||||
const { metadata, ...old } = request;
|
||||
return { ...old, env: { ...old.env, HOOK_OPEN: "opened" } };
|
||||
});
|
||||
return () => {};
|
||||
}
|
||||
`,
|
||||
);
|
||||
const first = await createTestPaseoDaemon({ paseoHomeRoot: root, cleanup: false });
|
||||
const firstClient = new DaemonClient({ url: `ws://127.0.0.1:${first.port}/ws` });
|
||||
const firstApi = createPaseoApi(firstClient);
|
||||
let agentId: string;
|
||||
let workspaceId: string;
|
||||
try {
|
||||
await firstClient.connect();
|
||||
await firstClient.fetchAgents({ subscribe: {} });
|
||||
await firstClient.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await firstClient.installDirectoryPlugin(pluginDirectory);
|
||||
const workspace = await firstClient.createWorkspace({
|
||||
source: { kind: "directory", path: directory },
|
||||
metadata: { "a.workspace": 1, "b.workspace": "to remove" },
|
||||
});
|
||||
workspaceId = workspace.workspace!.id;
|
||||
const agent = await firstClient.createAgent({
|
||||
provider: "claude",
|
||||
cwd: directory,
|
||||
workspaceId,
|
||||
metadata: { "a.agent": { nested: [1, 2] }, "b.agent": "to remove" },
|
||||
});
|
||||
agentId = agent.id;
|
||||
const agentHandle = firstApi.agents.ref(agentId);
|
||||
await agentHandle.setMetadata({ "b.agent": null, "c.agent": true });
|
||||
await firstApi.workspaces.ref(workspaceId).setMetadata({
|
||||
"b.workspace": null,
|
||||
"c.workspace": [],
|
||||
});
|
||||
expect((await firstClient.fetchAgent({ agentId }))?.agent.metadata).toEqual({
|
||||
"a.agent": { nested: [1, 2] },
|
||||
"c.agent": true,
|
||||
});
|
||||
} finally {
|
||||
await firstApi.dispose();
|
||||
await firstClient.close();
|
||||
await first.close();
|
||||
}
|
||||
|
||||
const second = await createTestPaseoDaemon({ paseoHomeRoot: root, cleanup: false });
|
||||
const secondClient = new DaemonClient({ url: `ws://127.0.0.1:${second.port}/ws` });
|
||||
try {
|
||||
await secondClient.connect();
|
||||
const agent = await secondClient.fetchAgent({ agentId });
|
||||
expect(agent?.agent.metadata).toEqual({ "a.agent": { nested: [1, 2] }, "c.agent": true });
|
||||
const workspaces = await secondClient.fetchWorkspaces({});
|
||||
expect(workspaces.entries.find((entry) => entry.id === workspaceId)?.metadata).toEqual({
|
||||
"a.workspace": 1,
|
||||
"c.workspace": [],
|
||||
});
|
||||
await secondClient.fetchAgents({ subscribe: {} });
|
||||
await secondClient.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await secondClient.installDirectoryPlugin(pluginDirectory);
|
||||
await secondClient.refreshAgent(agentId);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const logs = await secondClient.getPluginLogs("metadata-restart");
|
||||
return logs
|
||||
.filter((entry) => entry.message.startsWith('{"hook":'))
|
||||
.map((entry) => JSON.parse(entry.message));
|
||||
})
|
||||
.toContainEqual({
|
||||
hook: "agent.open.before",
|
||||
metadata: { "a.agent": { nested: [1, 2] }, "c.agent": true },
|
||||
});
|
||||
expect((await secondClient.fetchAgent({ agentId }))?.agent.metadata).toEqual({
|
||||
"a.agent": { nested: [1, 2] },
|
||||
"c.agent": true,
|
||||
});
|
||||
} finally {
|
||||
await secondClient.close();
|
||||
await second.close();
|
||||
}
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
await rm(pluginDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("agent creation hooks change the provider and environment before the session opens", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-agent-hooks-"));
|
||||
const pluginDirectory = await createProviderPlugin(directory, {
|
||||
|
||||
@@ -92,6 +92,7 @@ test("session-open hooks reject changes to session identity instead of silently
|
||||
env: {},
|
||||
title: null,
|
||||
labels: {},
|
||||
metadata: {},
|
||||
tools: [],
|
||||
mcpServers: {},
|
||||
},
|
||||
@@ -100,6 +101,105 @@ test("session-open hooks reject changes to session identity instead of silently
|
||||
).rejects.toThrow("agent.open.before hooks can only change env");
|
||||
});
|
||||
|
||||
test("a create hook that omits metadata passes on what an earlier hook added", async () => {
|
||||
const hooks = new PluginHookHandlers(() => {});
|
||||
hooks.addHook("agent.create.before", async ({ request }) => ({
|
||||
...request,
|
||||
metadata: { ...request.metadata, "new.plugin": { ticket: 42 } },
|
||||
}));
|
||||
hooks.addHook("agent.create.before", async ({ request }) => ({
|
||||
config: request.config,
|
||||
env: request.env,
|
||||
}));
|
||||
const seen: unknown[] = [];
|
||||
hooks.addHook("agent.create.before", async ({ request }) => {
|
||||
seen.push(request.metadata);
|
||||
});
|
||||
const result = await hooks.invoke(
|
||||
"operation",
|
||||
"agent.create.before",
|
||||
{ config: { provider: "codex", cwd: "/project" }, env: {}, metadata: { original: true } },
|
||||
paseo,
|
||||
);
|
||||
expect(result).toMatchObject({ metadata: { original: true, "new.plugin": { ticket: 42 } } });
|
||||
expect(seen).toEqual([{ original: true, "new.plugin": { ticket: 42 } }]);
|
||||
});
|
||||
|
||||
test("a workspace create hook that omits metadata passes on what an earlier hook added", async () => {
|
||||
const hooks = new PluginHookHandlers(() => {});
|
||||
hooks.addHook("workspace.create.before", async ({ request }) => ({
|
||||
...request,
|
||||
metadata: { ...request.metadata, "new.plugin": { ticket: 42 } },
|
||||
}));
|
||||
hooks.addHook("workspace.create.before", async ({ request }) => ({ source: request.source }));
|
||||
const seen: unknown[] = [];
|
||||
hooks.addHook("workspace.create.before", async ({ request }) => {
|
||||
seen.push(request.metadata);
|
||||
});
|
||||
const result = await hooks.invoke(
|
||||
"operation",
|
||||
"workspace.create.before",
|
||||
{ source: { kind: "directory", path: "/project" }, metadata: { original: true } },
|
||||
paseo,
|
||||
);
|
||||
expect(result).toMatchObject({ metadata: { original: true, "new.plugin": { ticket: 42 } } });
|
||||
expect(seen).toEqual([{ original: true, "new.plugin": { ticket: 42 } }]);
|
||||
|
||||
const replacing = new PluginHookHandlers(() => {});
|
||||
replacing.addHook("workspace.create.before", async ({ request }) => ({
|
||||
...request,
|
||||
metadata: {},
|
||||
}));
|
||||
await expect(
|
||||
replacing.invoke(
|
||||
"operation",
|
||||
"workspace.create.before",
|
||||
{ source: { kind: "directory", path: "/project" }, metadata: { original: true } },
|
||||
paseo,
|
||||
),
|
||||
).resolves.toMatchObject({ metadata: {} });
|
||||
});
|
||||
|
||||
test("a session-open hook that returns its own object without metadata leaves metadata unchanged", async () => {
|
||||
const hooks = new PluginHookHandlers(() => {});
|
||||
hooks.addHook("agent.open.before", async ({ request }) => ({
|
||||
agentId: request.agentId,
|
||||
workspaceId: request.workspaceId,
|
||||
provider: request.provider,
|
||||
cwd: request.cwd,
|
||||
reason: request.reason,
|
||||
purpose: request.purpose,
|
||||
title: request.title,
|
||||
labels: request.labels,
|
||||
tools: request.tools,
|
||||
env: { HOOK: "1" },
|
||||
mcpServers: request.mcpServers,
|
||||
}));
|
||||
const result = await hooks.invoke(
|
||||
"operation",
|
||||
"agent.open.before",
|
||||
{
|
||||
agentId: "agent",
|
||||
workspaceId: null,
|
||||
provider: "codex",
|
||||
cwd: "/project",
|
||||
title: null,
|
||||
labels: {},
|
||||
metadata: { "linear.issue": { id: "ENG-1" } },
|
||||
tools: [],
|
||||
mcpServers: {},
|
||||
env: {},
|
||||
reason: "resume",
|
||||
purpose: "interactive",
|
||||
},
|
||||
paseo,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
env: { HOOK: "1" },
|
||||
metadata: { "linear.issue": { id: "ENG-1" } },
|
||||
});
|
||||
});
|
||||
|
||||
test.each([{ title: "Changed" }, { labels: { changed: "true" } }])(
|
||||
"session-open targeting metadata is read-only: %j",
|
||||
async (patch) => {
|
||||
@@ -116,6 +216,7 @@ test.each([{ title: "Changed" }, { labels: { changed: "true" } }])(
|
||||
cwd: "/project",
|
||||
title: "Original",
|
||||
labels: {},
|
||||
metadata: {},
|
||||
tools: [],
|
||||
mcpServers: {},
|
||||
env: {},
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import type { AgentToolDefinition } from "@getpaseo/plugin/server";
|
||||
import type { AgentStreamEvent } from "../../agent/agent-sdk-types.js";
|
||||
import { z } from "zod";
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import { CreateAgentRequestMessageSchema } from "@getpaseo/protocol/messages";
|
||||
import type {
|
||||
PluginHookAgent,
|
||||
@@ -91,6 +92,7 @@ const beforeSchemas = {
|
||||
"agent.create.before": CreateAgentRequestMessageSchema.pick({
|
||||
config: true,
|
||||
env: true,
|
||||
metadata: true,
|
||||
}).strict(),
|
||||
"agent.open.before": z
|
||||
.object({
|
||||
@@ -102,6 +104,8 @@ const beforeSchemas = {
|
||||
purpose: z.enum(["interactive", "history"]),
|
||||
title: z.string().nullable(),
|
||||
labels: z.record(z.string(), z.string()),
|
||||
// Optional so a plugin that returns its own object keeps working; omitted means unchanged.
|
||||
metadata: z.record(z.string(), z.custom<JsonValue>()).optional(),
|
||||
tools: z.array(agentToolMetadataSchema),
|
||||
env: z.record(z.string(), z.string()),
|
||||
mcpServers: CreateAgentRequestMessageSchema.shape.config.shape.mcpServers.unwrap(),
|
||||
@@ -152,6 +156,7 @@ export function describeHookWorkspace(workspace: PersistedWorkspaceRecord): Plug
|
||||
cwd: workspace.cwd,
|
||||
name: workspace.title,
|
||||
archivedAt: workspace.archivedAt,
|
||||
metadata: workspace.metadata,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -162,6 +167,7 @@ export function describeHookAgent(agent: {
|
||||
cwd: string;
|
||||
title?: string | null;
|
||||
labels: Record<string, string>;
|
||||
metadata: Record<string, JsonValue>;
|
||||
}): PluginHookAgent {
|
||||
return {
|
||||
id: agent.id,
|
||||
@@ -170,6 +176,7 @@ export function describeHookAgent(agent: {
|
||||
provider: agent.provider,
|
||||
cwd: agent.cwd,
|
||||
title: agent.title ?? null,
|
||||
metadata: agent.metadata,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -229,10 +236,12 @@ export async function validateBeforeResult<Name extends keyof PluginWireBeforeRe
|
||||
previous.reason !== next.reason ||
|
||||
previous.purpose !== next.purpose ||
|
||||
previous.title !== next.title ||
|
||||
!isDeepStrictEqual(previous.labels, next.labels)
|
||||
!isDeepStrictEqual(previous.labels, next.labels) ||
|
||||
(next.metadata !== undefined && !isDeepStrictEqual(previous.metadata, next.metadata))
|
||||
) {
|
||||
throw new Error("agent.open.before hooks can only change env, tools, and mcpServers");
|
||||
}
|
||||
return { ...result, metadata: previous.metadata };
|
||||
}
|
||||
if (name === "agent.create.before") {
|
||||
const previous = beforeSchemas["agent.create.before"].parse(input);
|
||||
@@ -240,6 +249,14 @@ export async function validateBeforeResult<Name extends keyof PluginWireBeforeRe
|
||||
if (previous.config.cwd !== next.config.cwd) {
|
||||
throw new Error("agent.create.before hooks cannot change the workspace directory");
|
||||
}
|
||||
// Omitted means unchanged: the next hook sees what this one was handed.
|
||||
return { ...result, metadata: next.metadata ?? previous.metadata };
|
||||
}
|
||||
if (name === "workspace.create.before") {
|
||||
const previous = beforeSchemas["workspace.create.before"].parse(input);
|
||||
const next = beforeSchemas["workspace.create.before"].parse(result);
|
||||
// Omitted means unchanged; an explicit {} still replaces.
|
||||
return { ...result, metadata: next.metadata ?? previous.metadata };
|
||||
}
|
||||
if (name === "worktree.create.before") {
|
||||
const previous = beforeSchemas["worktree.create.before"].parse(input);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import type { PluginHookAgent } from "@getpaseo/plugin/server";
|
||||
import { UsageScopeSchema, type UsageScope } from "@getpaseo/plugin/server/usage";
|
||||
import type {
|
||||
@@ -157,6 +158,7 @@ export const PluginProcessRequestSchema: z.ZodType<PluginProcessRequest> = z.dis
|
||||
provider: z.string(),
|
||||
cwd: z.string(),
|
||||
title: z.string().nullable(),
|
||||
metadata: z.record(z.string(), z.custom<JsonValue>()),
|
||||
})
|
||||
.strict(),
|
||||
name: z.string(),
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Top-level keys replace; a null value removes the key. */
|
||||
export function applyRecordPatch<Value>(
|
||||
record: Record<string, Value>,
|
||||
patch: Record<string, Value | null>,
|
||||
): Record<string, Value> {
|
||||
const next = { ...record };
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value === null) {
|
||||
delete next[key];
|
||||
} else {
|
||||
next[key] = value;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
+6
@@ -1,3 +1,4 @@
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import type { PluginLifecycle } from "../../plugins/lifecycle/index.js";
|
||||
import { describeHookWorkspace } from "../../plugins/lifecycle/index.js";
|
||||
import { basename, resolve } from "node:path";
|
||||
@@ -50,6 +51,7 @@ export interface CreateWorktreeWorkspaceInput {
|
||||
branch: string | null;
|
||||
baseBranch: string | null;
|
||||
title: string | null;
|
||||
metadata?: Record<string, JsonValue>;
|
||||
expectsInitialAgent?: boolean;
|
||||
untrustedSource?: UntrustedWorkspaceSource;
|
||||
background?: boolean;
|
||||
@@ -70,6 +72,7 @@ export interface WorkspaceProvisioningService {
|
||||
context?: {
|
||||
expectsInitialAgent?: boolean;
|
||||
workspaceId?: string;
|
||||
metadata?: Record<string, JsonValue>;
|
||||
background?: boolean;
|
||||
callerWorkspaceId?: string;
|
||||
},
|
||||
@@ -242,6 +245,7 @@ export function createWorkspaceProvisioningService(deps: {
|
||||
context?: {
|
||||
expectsInitialAgent?: boolean;
|
||||
workspaceId?: string;
|
||||
metadata?: Record<string, JsonValue>;
|
||||
background?: boolean;
|
||||
callerWorkspaceId?: string;
|
||||
},
|
||||
@@ -258,6 +262,7 @@ export function createWorkspaceProvisioningService(deps: {
|
||||
projectId: project.projectId,
|
||||
...initialWorkspacePlacement({ source: "checkout", cwd: normalizedCwd, checkout }),
|
||||
title: title?.trim() || null,
|
||||
metadata: context?.metadata,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
background: await resolveBackground(context),
|
||||
@@ -294,6 +299,7 @@ export function createWorkspaceProvisioningService(deps: {
|
||||
mainRepoRoot: repoRoot,
|
||||
}),
|
||||
title: input.title,
|
||||
metadata: input.metadata,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
...(input.untrustedSource ? { untrustedSource: input.untrustedSource } : {}),
|
||||
|
||||
@@ -2012,6 +2012,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
usageSources: true,
|
||||
ownedSubscriptions: true,
|
||||
providerOptionControls: true,
|
||||
entityMetadata: true,
|
||||
binaryChunkingV1: true,
|
||||
agentRequestReceipts: true,
|
||||
workspaceRequestReceipts: true,
|
||||
|
||||
@@ -1100,6 +1100,7 @@ describe("WorkspaceReconciliationService", () => {
|
||||
baseBranch: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
metadata: {},
|
||||
createdAt: timestamp,
|
||||
updatedAt: expect.any(String),
|
||||
archivedAt: expect.any(String),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import { promises as fs } from "node:fs";
|
||||
|
||||
import type { Logger } from "pino";
|
||||
@@ -101,6 +102,8 @@ const PersistedWorkspaceRecordSchema = z.object({
|
||||
.optional()
|
||||
.transform((value) => value ?? null),
|
||||
labels: z.array(z.string()).optional(),
|
||||
// Plugin-owned data. Paseo stores it and never reads a key.
|
||||
metadata: z.record(z.string(), z.custom<JsonValue>()).default({}),
|
||||
untrustedSource: UntrustedWorkspaceSourceSchema.optional(),
|
||||
// Public discovery visibility. Contents retain their ordinary lifecycle.
|
||||
background: z.boolean().optional().default(false),
|
||||
@@ -678,6 +681,7 @@ export function createPersistedWorkspaceRecord(input: {
|
||||
autoArchivedChangeRequestUrl?: string | null;
|
||||
pinnedAt?: string | null;
|
||||
labels?: string[];
|
||||
metadata?: Record<string, JsonValue>;
|
||||
untrustedSource?: UntrustedWorkspaceSource;
|
||||
background?: boolean;
|
||||
}): PersistedWorkspaceRecord {
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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` |
|
||||
|
||||
Reference in New Issue
Block a user