Expand plugin host surface (#5205)

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - The plugin system is the extension boundary for optional product
capabilities
> - Rich plugins need more than a worker entrypoint: they need scoped
database storage, local project folders, managed agents/routines, host
navigation, and reusable UI components
> - The LLM Wiki work exposed those missing host surfaces while keeping
plugin code outside the core control plane
> - This pull request expands the core plugin host, SDK, server APIs,
and UI bridge so plugins can declare and use those surfaces
> - The benefit is that future plugins can integrate with Paperclip
through documented, validated contracts instead of bespoke server or UI
imports

## What Changed

- Added plugin-managed database namespaces and migration tracking,
including Drizzle schema/migration files and SQL validation for
namespace isolation.
- Added server support for plugin local folders, managed agents, managed
routines, scoped plugin APIs, and plugin operation visibility.
- Expanded shared plugin manifest/types/validators and SDK
host/testing/UI exports for richer plugin surfaces.
- Added reusable UI pieces for file trees, managed routines, resizable
sidebars, route sidebars, and plugin bridge initialization.
- Updated plugin docs and example plugins to use the expanded host and
SDK surface.

## Verification

- `pnpm install --frozen-lockfile`
- `pnpm run preflight:workspace-links && pnpm exec vitest run
packages/shared/src/validators/plugin.test.ts
server/src/__tests__/plugin-database.test.ts
server/src/__tests__/plugin-local-folders.test.ts
server/src/__tests__/plugin-managed-agents.test.ts
server/src/__tests__/plugin-managed-routines.test.ts
server/src/__tests__/plugin-orchestration-apis.test.ts
ui/src/api/plugins.test.ts ui/src/components/FileTree.test.tsx
ui/src/components/ResizableSidebarPane.test.tsx
ui/src/pages/PluginPage.test.tsx ui/src/plugins/bridge.test.ts` passed:
11 files, 67 tests.
- Confirmed this PR changes 89 files and does not include
`pnpm-lock.yaml` or `.github/workflows/*`.

## Risks

- Medium: this expands plugin host contracts across db/shared/server/ui
and includes a new core migration (`0076_useful_elektra.sql`).
- The plugin database namespace validator is intentionally restrictive;
plugin authors may need follow-up affordances for SQL patterns that
remain blocked.
- Merge this before the LLM Wiki plugin PR so the plugin can resolve the
new SDK and host APIs.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5 coding agent, tool-enabled shell/git/GitHub
workflow. Context window size was not exposed by the runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-05-05 07:42:57 -05:00 committed by GitHub
parent d6bee62f02
commit 3c73ed26b5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
89 changed files with 27516 additions and 914 deletions

View file

@ -190,6 +190,16 @@ export const ISSUE_ORIGIN_KINDS = [
export type BuiltInIssueOriginKind = (typeof ISSUE_ORIGIN_KINDS)[number];
export type PluginIssueOriginKind = `plugin:${string}`;
export type IssueOriginKind = BuiltInIssueOriginKind | PluginIssueOriginKind;
export const ISSUE_SURFACE_VISIBILITIES = ["default", "plugin_operation"] as const;
export type IssueSurfaceVisibility = (typeof ISSUE_SURFACE_VISIBILITIES)[number];
export function pluginOperationIssueOriginKind(pluginKey: string): PluginIssueOriginKind {
return `plugin:${pluginKey}:operation`;
}
export function isPluginOperationIssueOriginKind(originKind: string | null | undefined): boolean {
return typeof originKind === "string" && /^plugin:[^:]+:operation(?::|$)/.test(originKind);
}
export const ISSUE_RELATION_TYPES = ["blocks"] as const;
export type IssueRelationType = (typeof ISSUE_RELATION_TYPES)[number];
@ -634,9 +644,12 @@ export const PLUGIN_CAPABILITIES = [
"issue.comments.create",
"issue.interactions.create",
"issue.documents.write",
"projects.managed",
"routines.managed",
"agents.pause",
"agents.resume",
"agents.invoke",
"agents.managed",
"agent.sessions.create",
"agent.sessions.list",
"agent.sessions.send",
@ -658,6 +671,7 @@ export const PLUGIN_CAPABILITIES = [
"http.outbound",
"secrets.read-ref",
"environment.drivers.register",
"local.folders",
// Agent Tools
"agent.tools.register",
// UI
@ -728,6 +742,7 @@ export const PLUGIN_UI_SLOT_TYPES = [
"taskDetailView",
"dashboardWidget",
"sidebar",
"routeSidebar",
"sidebarPanel",
"projectSidebarItem",
"globalToolbarButton",

View file

@ -25,6 +25,9 @@ export {
ISSUE_THREAD_INTERACTION_STATUSES,
ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES,
ISSUE_ORIGIN_KINDS,
ISSUE_SURFACE_VISIBILITIES,
pluginOperationIssueOriginKind,
isPluginOperationIssueOriginKind,
ISSUE_RELATION_TYPES,
ISSUE_TREE_CONTROL_MODES,
ISSUE_TREE_HOLD_RELEASE_POLICY_STRATEGIES,
@ -133,6 +136,7 @@ export {
type BuiltInIssueOriginKind,
type PluginIssueOriginKind,
type IssueOriginKind,
type IssueSurfaceVisibility,
type IssueRelationType,
type IssueTreeControlMode,
type IssueTreeHoldReleasePolicyStrategy,
@ -303,6 +307,7 @@ export type {
ProjectCodebase,
ProjectCodebaseOrigin,
ProjectGoalRef,
ProjectManagedByPlugin,
ProjectWorkspace,
ExecutionWorkspace,
ExecutionWorkspaceSummary,
@ -493,6 +498,7 @@ export type {
CompanySecret,
SecretProviderDescriptor,
Routine,
RoutineManagedByPlugin,
RoutineVariable,
RoutineVariableDefaultValue,
RoutineTrigger,
@ -507,6 +513,15 @@ export type {
PluginWebhookDeclaration,
PluginToolDeclaration,
PluginEnvironmentDriverDeclaration,
PluginManagedAgentDeclaration,
PluginManagedProjectDeclaration,
PluginManagedRoutineDeclaration,
PluginLocalFolderDeclaration,
PluginManagedAgentResolution,
PluginManagedProjectResolution,
PluginManagedRoutineResolution,
PluginManagedResourceKind,
PluginManagedResourceRef,
PluginUiSlotDeclaration,
PluginLauncherActionDeclaration,
PluginLauncherRenderDeclaration,
@ -523,6 +538,7 @@ export type {
PluginMigrationRecord,
PluginStateRecord,
PluginConfig,
PluginCompanySettings,
PluginEntityRecord,
PluginEntityQuery,
PluginJobRecord,

View file

@ -89,7 +89,7 @@ export type {
AdapterEnvironmentTestResult,
} from "./agent.js";
export type { AssetImage } from "./asset.js";
export type { Project, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectWorkspace } from "./project.js";
export type { Project, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectManagedByPlugin, ProjectWorkspace } from "./project.js";
export type {
ExecutionWorkspace,
ExecutionWorkspaceSummary,
@ -221,6 +221,7 @@ export type {
} from "./secrets.js";
export type {
Routine,
RoutineManagedByPlugin,
RoutineVariable,
RoutineVariableDefaultValue,
RoutineTrigger,
@ -315,6 +316,15 @@ export type {
PluginWebhookDeclaration,
PluginToolDeclaration,
PluginEnvironmentDriverDeclaration,
PluginManagedAgentDeclaration,
PluginManagedProjectDeclaration,
PluginManagedRoutineDeclaration,
PluginLocalFolderDeclaration,
PluginManagedAgentResolution,
PluginManagedProjectResolution,
PluginManagedRoutineResolution,
PluginManagedResourceKind,
PluginManagedResourceRef,
PluginUiSlotDeclaration,
PluginLauncherActionDeclaration,
PluginLauncherRenderDeclaration,
@ -331,6 +341,7 @@ export type {
PluginMigrationRecord,
PluginStateRecord,
PluginConfig,
PluginCompanySettings,
PluginEntityRecord,
PluginEntityQuery,
PluginJobRecord,

View file

@ -16,7 +16,19 @@ import type {
PluginDatabaseMigrationStatus,
PluginDatabaseNamespaceMode,
PluginDatabaseNamespaceStatus,
AgentAdapterType,
AgentRole,
AgentStatus,
IssuePriority,
ProjectStatus,
RoutineCatchUpPolicy,
RoutineConcurrencyPolicy,
RoutineStatus,
IssueSurfaceVisibility,
} from "../constants.js";
import type { Agent } from "./agent.js";
import type { Project } from "./project.js";
import type { Routine, RoutineTrigger, RoutineVariable } from "./routine.js";
// ---------------------------------------------------------------------------
// JSON Schema placeholder plugins declare config schemas as JSON Schema
@ -113,6 +125,162 @@ export interface PluginEnvironmentDriverDeclaration {
configSchema: JsonSchema;
}
/**
* Declares a normal Paperclip agent that a plugin can provision and later
* resolve by stable key within each company.
*/
export interface PluginManagedAgentDeclaration {
/** Stable identifier for this managed agent, unique within the plugin. */
agentKey: string;
/** Suggested visible agent name. */
displayName: string;
/** Optional suggested role. Defaults to `general`. */
role?: AgentRole | string;
/** Optional suggested title shown in agent surfaces. */
title?: string | null;
/** Optional icon for agent list/detail surfaces. */
icon?: string | null;
/** Suggested capability summary for the agent. */
capabilities?: string | null;
/** Suggested adapter type. Defaults to `process`. */
adapterType?: AgentAdapterType | string;
/**
* Optional ordered list of compatible adapter types. When present, the host
* prefers the most-used compatible adapter already configured in the company,
* falling back to `adapterType`.
*/
adapterPreference?: Array<AgentAdapterType | string>;
/** Suggested adapter configuration. */
adapterConfig?: Record<string, unknown>;
/** Suggested Paperclip runtime configuration. */
runtimeConfig?: Record<string, unknown>;
/** Suggested permissions object. Normalized by the host on create/reset. */
permissions?: Record<string, unknown>;
/** Suggested starting status when no board approval is required. */
status?: Extract<AgentStatus, "idle" | "paused">;
/** Suggested monthly budget in cents. */
budgetMonthlyCents?: number;
/** Optional managed instructions content or pointer metadata for plugin UI. */
instructions?: {
entryFile?: string;
content?: string;
assetPath?: string;
};
}
/**
* Declares a company-scoped local folder a trusted plugin wants the operator
* to configure. The host treats this as a generic filesystem root: plugin
* code may request required relative folders/files, then use SDK helpers for
* path-safe reads and atomic writes under that root.
*/
export interface PluginLocalFolderDeclaration {
/** Stable identifier for this folder, unique within the plugin. */
folderKey: string;
/** Human-readable name shown in plugin settings. */
displayName: string;
/** Optional operator-facing description. */
description?: string;
/** Access level requested by the plugin. Defaults to `readWrite`. */
access?: "read" | "readWrite";
/** Relative directories expected to exist under the configured root. */
requiredDirectories?: string[];
/** Relative files expected to exist under the configured root. */
requiredFiles?: string[];
}
/**
* Declares a normal Paperclip project that a plugin can provision and later
* resolve by stable key within each company.
*/
export interface PluginManagedProjectDeclaration {
/** Stable identifier for this managed project, unique within the plugin. */
projectKey: string;
/** Suggested visible project name. */
displayName: string;
/** Suggested project description. */
description?: string | null;
/** Suggested starting status. Defaults to `in_progress`. */
status?: ProjectStatus;
/** Suggested project color. Defaults to the normal project palette. */
color?: string | null;
/** Optional plugin-specific defaults retained for reset/reconcile UI. */
settings?: Record<string, unknown>;
}
export type PluginManagedResourceKind = "agent" | "project" | "routine";
export interface PluginManagedResourceRef {
pluginKey?: string;
resourceKind: PluginManagedResourceKind;
resourceKey: string;
}
export interface PluginManagedRoutineDeclaration {
/** Stable identifier for this managed routine, unique within the plugin. */
routineKey: string;
/** Suggested routine title template. */
title: string;
/** Suggested routine description template. */
description?: string | null;
/** Stable managed agent reference for the default assignee. */
assigneeRef?: PluginManagedResourceRef | null;
/** Stable managed project reference for routine-created issues. */
projectRef?: PluginManagedResourceRef | null;
/** Optional goal id to set on the routine in this company. */
goalId?: string | null;
/** Suggested starting status. Defaults to `paused` when no assignee is resolved, otherwise `active`. */
status?: RoutineStatus;
/** Suggested issue priority. Defaults to `medium`. */
priority?: IssuePriority;
/** Suggested concurrency behavior. Defaults to core routine default. */
concurrencyPolicy?: RoutineConcurrencyPolicy;
/** Suggested missed-trigger behavior. Defaults to core routine default. */
catchUpPolicy?: RoutineCatchUpPolicy;
/** Suggested routine variables. */
variables?: RoutineVariable[];
/** Suggested triggers created when the routine is first reconciled. */
triggers?: Array<Pick<RoutineTrigger, "kind" | "label" | "enabled" | "cronExpression" | "timezone" | "signingMode" | "replayWindowSec">>;
/** Defaults for issues created by this routine. */
issueTemplate?: {
surfaceVisibility?: IssueSurfaceVisibility;
originId?: string | null;
billingCode?: string | null;
};
}
export interface PluginManagedAgentResolution {
pluginKey: string;
resourceKind: "agent";
resourceKey: string;
companyId: string;
agentId: string | null;
agent: Agent | null;
status: "missing" | "resolved" | "created" | "relinked" | "reset";
approvalId?: string | null;
}
export interface PluginManagedProjectResolution {
pluginKey: string;
resourceKind: "project";
resourceKey: string;
companyId: string;
projectId: string | null;
project: Project | null;
status: "missing" | "resolved" | "created" | "relinked" | "reset";
}
export interface PluginManagedRoutineResolution {
pluginKey: string;
resourceKind: "routine";
resourceKey: string;
companyId: string;
routineId: string | null;
routine: Routine | null;
status: "missing" | "missing_refs" | "resolved" | "created" | "relinked" | "reset";
missingRefs?: PluginManagedResourceRef[];
}
/**
* Declares a UI extension slot the plugin fills with a React component.
*
@ -133,7 +301,7 @@ export interface PluginUiSlotDeclaration {
*/
entityTypes?: PluginUiSlotEntityType[];
/**
* Optional company-scoped route segment for page slots.
* Optional company-scoped route segment for page and routeSidebar slots.
* Example: `kitchensink` becomes `/:companyPrefix/kitchensink`.
*/
routePath?: string;
@ -322,6 +490,14 @@ export interface PaperclipPluginManifestV1 {
apiRoutes?: PluginApiRouteDeclaration[];
/** Environment drivers this plugin contributes. Requires `environment.drivers.register` capability. */
environmentDrivers?: PluginEnvironmentDriverDeclaration[];
/** Suggested company-scoped agents this plugin can provision and resolve by stable key. */
agents?: PluginManagedAgentDeclaration[];
/** Suggested company-scoped projects this plugin can provision and resolve by stable key. */
projects?: PluginManagedProjectDeclaration[];
/** Suggested company-scoped routines this plugin can provision and resolve by stable key. */
routines?: PluginManagedRoutineDeclaration[];
/** Trusted local folders this plugin can configure and access by stable key. */
localFolders?: PluginLocalFolderDeclaration[];
/**
* Legacy top-level launcher declarations.
* Prefer `ui.launchers` for new manifests.
@ -455,6 +631,22 @@ export interface PluginConfig {
updatedAt: Date;
}
/**
* Company-scoped plugin settings row. This is intentionally generic; plugin
* features such as local folders live inside `settingsJson` under namespaced
* keys instead of requiring feature-specific database columns.
*/
export interface PluginCompanySettings {
id: string;
companyId: string;
pluginId: string;
enabled: boolean;
settingsJson: Record<string, unknown>;
lastError: string | null;
createdAt: Date;
updatedAt: Date;
}
/**
* Query filter for `ctx.entities.list`.
*/

View file

@ -52,6 +52,18 @@ export interface ProjectCodebase {
origin: ProjectCodebaseOrigin;
}
export interface ProjectManagedByPlugin {
id: string;
pluginId: string;
pluginKey: string;
pluginDisplayName: string;
resourceKind: "project";
resourceKey: string;
defaultsJson: Record<string, unknown>;
createdAt: Date;
updatedAt: Date;
}
export interface Project {
id: string;
companyId: string;
@ -73,6 +85,7 @@ export interface Project {
codebase: ProjectCodebase;
workspaces: ProjectWorkspace[];
primaryWorkspace: ProjectWorkspace | null;
managedByPlugin?: ProjectManagedByPlugin | null;
archivedAt: Date | null;
createdAt: Date;
updatedAt: Date;

View file

@ -58,6 +58,19 @@ export interface Routine {
lastEnqueuedAt: Date | null;
createdAt: Date;
updatedAt: Date;
managedByPlugin?: RoutineManagedByPlugin | null;
}
export interface RoutineManagedByPlugin {
id: string;
pluginId: string;
pluginKey: string;
pluginDisplayName: string;
resourceKind: "routine";
resourceKey: string;
defaultsJson: Record<string, unknown>;
createdAt: Date;
updatedAt: Date;
}
export interface RoutineTrigger {

View file

@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { PLUGIN_CAPABILITIES } from "../constants.js";
import { pluginManagedRoutineDeclarationSchema, pluginUiSlotDeclarationSchema } from "./plugin.js";
describe("plugin capability constants", () => {
it("exposes each capability once", () => {
expect(new Set(PLUGIN_CAPABILITIES).size).toBe(PLUGIN_CAPABILITIES.length);
});
});
describe("plugin managed routine validators", () => {
it("accepts core issue surface visibility values in routine templates", () => {
const parsed = pluginManagedRoutineDeclarationSchema.parse({
routineKey: "wiki.refresh",
title: "Refresh Wiki",
issueTemplate: { surfaceVisibility: "default" },
});
expect(parsed.issueTemplate?.surfaceVisibility).toBe("default");
});
it("rejects non-core issue surface visibility values in routine templates", () => {
const parsed = pluginManagedRoutineDeclarationSchema.safeParse({
routineKey: "wiki.refresh",
title: "Refresh Wiki",
issueTemplate: { surfaceVisibility: "normal" },
});
expect(parsed.success).toBe(false);
});
});
describe("plugin UI slot validators", () => {
it("accepts route-scoped sidebar slots with a routePath", () => {
const parsed = pluginUiSlotDeclarationSchema.parse({
type: "routeSidebar",
id: "wiki-route-sidebar",
displayName: "Wiki Sidebar",
exportName: "WikiSidebar",
routePath: "wiki",
});
expect(parsed.routePath).toBe("wiki");
});
it("requires route-scoped sidebar slots to declare a routePath", () => {
const parsed = pluginUiSlotDeclarationSchema.safeParse({
type: "routeSidebar",
id: "wiki-route-sidebar",
displayName: "Wiki Sidebar",
exportName: "WikiSidebar",
});
expect(parsed.success).toBe(false);
if (parsed.success) return;
expect(parsed.error.issues[0]?.message).toBe("routeSidebar slots require routePath");
});
it("keeps reserved company route protection for route-scoped sidebars", () => {
const parsed = pluginUiSlotDeclarationSchema.safeParse({
type: "routeSidebar",
id: "settings-route-sidebar",
displayName: "Settings Sidebar",
exportName: "SettingsSidebar",
routePath: "settings",
});
expect(parsed.success).toBe(false);
if (parsed.success) return;
expect(parsed.error.issues.some((issue) => issue.message.includes("reserved by the host"))).toBe(true);
});
});

View file

@ -15,7 +15,15 @@ import {
PLUGIN_API_ROUTE_AUTH_MODES,
PLUGIN_API_ROUTE_CHECKOUT_POLICIES,
PLUGIN_API_ROUTE_METHODS,
ISSUE_PRIORITIES,
ROUTINE_CATCH_UP_POLICIES,
ROUTINE_CONCURRENCY_POLICIES,
ROUTINE_STATUSES,
ROUTINE_TRIGGER_KINDS,
ROUTINE_TRIGGER_SIGNING_MODES,
ISSUE_SURFACE_VISIBILITIES,
} from "../constants.js";
import { routineVariableSchema } from "./routine.js";
// ---------------------------------------------------------------------------
// JSON Schema placeholder a permissive validator for JSON Schema objects
@ -124,6 +132,106 @@ export type PluginEnvironmentDriverDeclarationInput = z.infer<
export type PluginToolDeclarationInput = z.infer<typeof pluginToolDeclarationSchema>;
export const pluginManagedAgentDeclarationSchema = z.object({
agentKey: z.string().min(1).max(100).regex(/^[a-z0-9][a-z0-9._:-]*$/, {
message: "agentKey must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, colons, underscores, or hyphens",
}),
displayName: z.string().min(1).max(100),
role: z.string().min(1).max(100).optional(),
title: z.string().max(200).nullable().optional(),
icon: z.string().max(100).nullable().optional(),
capabilities: z.string().max(2000).nullable().optional(),
adapterType: z.string().min(1).max(100).optional(),
adapterPreference: z.array(z.string().min(1).max(100)).max(10).optional(),
adapterConfig: z.record(z.unknown()).optional(),
runtimeConfig: z.record(z.unknown()).optional(),
permissions: z.record(z.unknown()).optional(),
status: z.enum(["idle", "paused"]).optional(),
budgetMonthlyCents: z.number().int().min(0).optional(),
instructions: z.object({
entryFile: z.string().min(1).max(200).optional(),
content: z.string().max(200_000).optional(),
assetPath: z.string().min(1).max(500).optional(),
}).optional(),
});
export type PluginManagedAgentDeclarationInput = z.infer<typeof pluginManagedAgentDeclarationSchema>;
export const pluginManagedProjectDeclarationSchema = z.object({
projectKey: z.string().min(1).max(100).regex(/^[a-z0-9][a-z0-9._:-]*$/, {
message: "projectKey must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, colons, underscores, or hyphens",
}),
displayName: z.string().min(1).max(120),
description: z.string().max(2000).nullable().optional(),
status: z.enum(["backlog", "planned", "in_progress", "completed", "cancelled"]).optional(),
color: z.string().max(32).nullable().optional(),
settings: z.record(z.unknown()).optional(),
});
export type PluginManagedProjectDeclarationInput = z.infer<typeof pluginManagedProjectDeclarationSchema>;
const pluginManagedResourceRefSchema = z.object({
pluginKey: z.string().min(1).max(100).optional(),
resourceKind: z.enum(["agent", "project", "routine"]),
resourceKey: z.string().min(1).max(100).regex(/^[a-z0-9][a-z0-9._:-]*$/, {
message: "resourceKey must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, colons, underscores, or hyphens",
}),
});
export const pluginManagedRoutineDeclarationSchema = z.object({
routineKey: z.string().min(1).max(100).regex(/^[a-z0-9][a-z0-9._:-]*$/, {
message: "routineKey must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, colons, underscores, or hyphens",
}),
title: z.string().trim().min(1).max(200),
description: z.string().max(10_000).nullable().optional(),
assigneeRef: pluginManagedResourceRefSchema.extend({ resourceKind: z.literal("agent") }).nullable().optional(),
projectRef: pluginManagedResourceRefSchema.extend({ resourceKind: z.literal("project") }).nullable().optional(),
goalId: z.string().uuid().nullable().optional(),
status: z.enum(ROUTINE_STATUSES).optional(),
priority: z.enum(ISSUE_PRIORITIES).optional(),
concurrencyPolicy: z.enum(ROUTINE_CONCURRENCY_POLICIES).optional(),
catchUpPolicy: z.enum(ROUTINE_CATCH_UP_POLICIES).optional(),
variables: z.array(routineVariableSchema).optional(),
triggers: z.array(z.object({
kind: z.enum(ROUTINE_TRIGGER_KINDS),
label: z.string().trim().max(120).nullable().optional(),
enabled: z.boolean().optional(),
cronExpression: z.string().trim().min(1).optional().nullable(),
timezone: z.string().trim().min(1).optional().nullable(),
signingMode: z.enum(ROUTINE_TRIGGER_SIGNING_MODES).optional().nullable(),
replayWindowSec: z.number().int().min(30).max(86_400).optional().nullable(),
})).max(20).optional(),
issueTemplate: z.object({
surfaceVisibility: z.enum(ISSUE_SURFACE_VISIBILITIES).optional(),
originId: z.string().trim().max(255).nullable().optional(),
billingCode: z.string().trim().max(200).nullable().optional(),
}).optional(),
});
export type PluginManagedRoutineDeclarationInput = z.infer<typeof pluginManagedRoutineDeclarationSchema>;
const pluginLocalFolderRelativePathSchema = z.string().min(1).max(500).refine(
(value) =>
!value.startsWith("/") &&
!value.includes("..") &&
!value.includes("\\") &&
!value.split("/").some((segment) => segment === "" || segment === "."),
{ message: "local folder paths must be relative paths without traversal, empty segments, or backslashes" },
);
export const pluginLocalFolderDeclarationSchema = z.object({
folderKey: z.string().min(1).max(100).regex(/^[a-z0-9][a-z0-9._:-]*$/, {
message: "folderKey must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, colons, underscores, or hyphens",
}),
displayName: z.string().min(1).max(100),
description: z.string().max(500).optional(),
access: z.enum(["read", "readWrite"]).optional(),
requiredDirectories: z.array(pluginLocalFolderRelativePathSchema).optional(),
requiredFiles: z.array(pluginLocalFolderRelativePathSchema).optional(),
});
export type PluginLocalFolderDeclarationInput = z.infer<typeof pluginLocalFolderDeclarationSchema>;
/**
* Validates a {@link PluginUiSlotDeclaration} a UI extension slot the plugin
* fills with a React component. Includes `superRefine` checks for slot-specific
@ -178,10 +286,17 @@ export const pluginUiSlotDeclarationSchema = z.object({
path: ["entityTypes"],
});
}
if (value.routePath && value.type !== "page") {
if (value.routePath && value.type !== "page" && value.type !== "routeSidebar") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "routePath is only supported for page slots",
message: "routePath is only supported for page and routeSidebar slots",
path: ["routePath"],
});
}
if (value.type === "routeSidebar" && !value.routePath) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "routeSidebar slots require routePath",
path: ["routePath"],
});
}
@ -471,6 +586,10 @@ export const pluginManifestV1Schema = z.object({
database: pluginDatabaseDeclarationSchema.optional(),
apiRoutes: z.array(pluginApiRouteDeclarationSchema).optional(),
environmentDrivers: z.array(pluginEnvironmentDriverDeclarationSchema).optional(),
agents: z.array(pluginManagedAgentDeclarationSchema).optional(),
projects: z.array(pluginManagedProjectDeclarationSchema).optional(),
routines: z.array(pluginManagedRoutineDeclarationSchema).optional(),
localFolders: z.array(pluginLocalFolderDeclarationSchema).optional(),
launchers: z.array(pluginLauncherDeclarationSchema).optional(),
ui: z.object({
slots: z.array(pluginUiSlotDeclarationSchema).min(1).optional(),
@ -529,6 +648,46 @@ export const pluginManifestV1Schema = z.object({
}
}
if (manifest.agents && manifest.agents.length > 0) {
if (!manifest.capabilities.includes("agents.managed")) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Capability 'agents.managed' is required when managed agents are declared",
path: ["capabilities"],
});
}
}
if (manifest.projects && manifest.projects.length > 0) {
if (!manifest.capabilities.includes("projects.managed")) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Capability 'projects.managed' is required when managed projects are declared",
path: ["capabilities"],
});
}
}
if (manifest.routines && manifest.routines.length > 0) {
if (!manifest.capabilities.includes("routines.managed")) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Capability 'routines.managed' is required when managed routines are declared",
path: ["capabilities"],
});
}
}
if (manifest.localFolders && manifest.localFolders.length > 0) {
if (!manifest.capabilities.includes("local.folders")) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Capability 'local.folders' is required when local folders are declared",
path: ["capabilities"],
});
}
}
// jobs require jobs.schedule (PLUGIN_SPEC.md §17)
if (manifest.jobs && manifest.jobs.length > 0) {
if (!manifest.capabilities.includes("jobs.schedule")) {
@ -664,6 +823,54 @@ export const pluginManifestV1Schema = z.object({
}
}
if (manifest.localFolders) {
const folderKeys = manifest.localFolders.map((folder) => folder.folderKey);
const duplicates = folderKeys.filter((key, i) => folderKeys.indexOf(key) !== i);
if (duplicates.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Duplicate local folder keys: ${[...new Set(duplicates)].join(", ")}`,
path: ["localFolders"],
});
}
}
if (manifest.agents) {
const agentKeys = manifest.agents.map((agent) => agent.agentKey);
const duplicates = agentKeys.filter((key, i) => agentKeys.indexOf(key) !== i);
if (duplicates.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Duplicate managed agent keys: ${[...new Set(duplicates)].join(", ")}`,
path: ["agents"],
});
}
}
if (manifest.projects) {
const projectKeys = manifest.projects.map((project) => project.projectKey);
const duplicates = projectKeys.filter((key, i) => projectKeys.indexOf(key) !== i);
if (duplicates.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Duplicate managed project keys: ${[...new Set(duplicates)].join(", ")}`,
path: ["projects"],
});
}
}
if (manifest.routines) {
const routineKeys = manifest.routines.map((routine) => routine.routineKey);
const duplicates = routineKeys.filter((key, i) => routineKeys.indexOf(key) !== i);
if (duplicates.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Duplicate managed routine keys: ${[...new Set(duplicates)].join(", ")}`,
path: ["routines"],
});
}
}
// UI slot ids must be unique within the plugin (namespaced at runtime)
if (manifest.ui) {
if (manifest.ui.slots) {