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

@ -0,0 +1,29 @@
CREATE TABLE IF NOT EXISTS "plugin_managed_resources" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"plugin_id" uuid NOT NULL,
"plugin_key" text NOT NULL,
"resource_kind" text NOT NULL,
"resource_key" text NOT NULL,
"resource_id" uuid NOT NULL,
"defaults_json" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "plugin_managed_resources" ADD CONSTRAINT "plugin_managed_resources_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "plugin_managed_resources" ADD CONSTRAINT "plugin_managed_resources_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "plugin_managed_resources_company_idx" ON "plugin_managed_resources" USING btree ("company_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "plugin_managed_resources_plugin_idx" ON "plugin_managed_resources" USING btree ("plugin_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "plugin_managed_resources_resource_idx" ON "plugin_managed_resources" USING btree ("resource_kind","resource_id");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "plugin_managed_resources_company_plugin_resource_uq" ON "plugin_managed_resources" USING btree ("company_id","plugin_id","resource_kind","resource_key");

File diff suppressed because it is too large Load diff

View file

@ -533,6 +533,13 @@
"when": 1777572332006,
"tag": "0075_cultured_sebastian_shaw",
"breakpoints": true
},
{
"idx": 76,
"version": "7",
"when": 1777675301279,
"tag": "0076_useful_elektra",
"breakpoints": true
}
]
}
}

View file

@ -65,6 +65,7 @@ export { companySkills } from "./company_skills.js";
export { plugins } from "./plugins.js";
export { pluginConfig } from "./plugin_config.js";
export { pluginCompanySettings } from "./plugin_company_settings.js";
export { pluginManagedResources } from "./plugin_managed_resources.js";
export { pluginState } from "./plugin_state.js";
export { pluginEntities } from "./plugin_entities.js";
export { pluginDatabaseNamespaces, pluginMigrations } from "./plugin_database.js";

View file

@ -0,0 +1,34 @@
import { pgTable, uuid, text, timestamp, jsonb, index, uniqueIndex } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { plugins } from "./plugins.js";
export const pluginManagedResources = pgTable(
"plugin_managed_resources",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id")
.notNull()
.references(() => companies.id, { onDelete: "cascade" }),
pluginId: uuid("plugin_id")
.notNull()
.references(() => plugins.id, { onDelete: "cascade" }),
pluginKey: text("plugin_key").notNull(),
resourceKind: text("resource_kind").notNull(),
resourceKey: text("resource_key").notNull(),
resourceId: uuid("resource_id").notNull(),
defaultsJson: jsonb("defaults_json").$type<Record<string, unknown>>().notNull().default({}),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyIdx: index("plugin_managed_resources_company_idx").on(table.companyId),
pluginIdx: index("plugin_managed_resources_plugin_idx").on(table.pluginId),
resourceIdx: index("plugin_managed_resources_resource_idx").on(table.resourceKind, table.resourceId),
companyPluginResourceUq: uniqueIndex("plugin_managed_resources_company_plugin_resource_uq").on(
table.companyId,
table.pluginId,
table.resourceKind,
table.resourceKey,
),
}),
);

View file

@ -27,7 +27,7 @@ Generates:
- `esbuild` and `rollup` config files using SDK bundler presets
- dev server script for hot-reload (`paperclip-plugin-dev-server`)
The scaffold intentionally uses plain React elements rather than host-provided UI kit components, because the current plugin runtime does not ship a stable shared component library yet.
The scaffold starts with plain React elements so the generated plugin stays minimal. For Paperclip-native controls, import shared host components such as `MarkdownEditor`, `FileTree`, `AssigneePicker`, and `ProjectPicker` from `@paperclipai/plugin-sdk/ui`.
Inside this repo, the generated package uses `@paperclipai/plugin-sdk` via `workspace:*`.

View file

@ -1,11 +1,12 @@
import type {
FileTreeNode,
PluginProjectSidebarItemProps,
PluginDetailTabProps,
PluginCommentAnnotationProps,
PluginCommentContextMenuItemProps,
} from "@paperclipai/plugin-sdk/ui";
import { usePluginAction, usePluginData } from "@paperclipai/plugin-sdk/ui";
import { useMemo, useState, useEffect, useRef, type MouseEvent, type RefObject } from "react";
import { FileTree, usePluginAction, usePluginData } from "@paperclipai/plugin-sdk/ui";
import { useCallback, useMemo, useState, useEffect, useRef, type MouseEvent, type RefObject } from "react";
import { EditorView } from "@codemirror/view";
import { basicSetup } from "codemirror";
import { javascript } from "@codemirror/lang-javascript";
@ -129,15 +130,31 @@ const editorLightHighlightStyle = HighlightStyle.define([
type Workspace = { id: string; projectId: string; name: string; path: string; isPrimary: boolean };
type FileEntry = { name: string; path: string; isDirectory: boolean };
type FileTreeNodeProps = {
entry: FileEntry;
companyId: string | null;
projectId: string;
workspaceId: string;
selectedPath: string | null;
onSelect: (path: string) => void;
depth?: number;
};
function entryToFileTreeNode(entry: FileEntry): FileTreeNode {
return {
name: entry.name,
path: entry.path,
kind: entry.isDirectory ? "dir" : "file",
children: [],
};
}
function entriesToFileTreeNodes(entries: FileEntry[]): FileTreeNode[] {
return entries.map(entryToFileTreeNode);
}
function setChildrenAtPath(nodes: FileTreeNode[], path: string, children: FileTreeNode[]): FileTreeNode[] {
return nodes.map((node) => {
if (node.path === path) {
return { ...node, children };
}
if (node.kind === "dir" && node.children.length > 0 && (path === node.path || path.startsWith(`${node.path}/`))) {
return { ...node, children: setChildrenAtPath(node.children, path, children) };
}
return node;
});
}
const PathLikePattern = /[\\/]/;
const WindowsDrivePathPattern = /^[A-Za-z]:[\\/]/;
@ -235,109 +252,6 @@ function useAvailableHeight(
return height;
}
function FileTreeNode({
entry,
companyId,
projectId,
workspaceId,
selectedPath,
onSelect,
depth = 0,
}: FileTreeNodeProps) {
const [isExpanded, setIsExpanded] = useState(false);
const isSelected = selectedPath === entry.path;
if (entry.isDirectory) {
return (
<li>
<button
type="button"
className="flex w-full items-center gap-2 rounded-none px-2 py-1.5 text-left text-sm text-foreground hover:bg-accent/60"
style={{ paddingLeft: `${depth * 14 + 8}px` }}
onClick={() => setIsExpanded((value) => !value)}
aria-expanded={isExpanded}
>
<span className="w-3 text-xs text-muted-foreground">{isExpanded ? "▾" : "▸"}</span>
<span className="truncate font-medium">{entry.name}</span>
</button>
{isExpanded ? (
<ExpandedDirectoryChildren
directoryPath={entry.path}
companyId={companyId}
projectId={projectId}
workspaceId={workspaceId}
selectedPath={selectedPath}
onSelect={onSelect}
depth={depth}
/>
) : null}
</li>
);
}
return (
<li>
<button
type="button"
className={`block w-full rounded-none px-2 py-1.5 text-left text-sm transition-colors ${
isSelected ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
}`}
style={{ paddingLeft: `${depth * 14 + 23}px` }}
onClick={() => onSelect(entry.path)}
>
<span className="truncate">{entry.name}</span>
</button>
</li>
);
}
function ExpandedDirectoryChildren({
directoryPath,
companyId,
projectId,
workspaceId,
selectedPath,
onSelect,
depth,
}: {
directoryPath: string;
companyId: string | null;
projectId: string;
workspaceId: string;
selectedPath: string | null;
onSelect: (path: string) => void;
depth: number;
}) {
const { data: childData } = usePluginData<{ entries: FileEntry[] }>("fileList", {
companyId,
projectId,
workspaceId,
directoryPath,
});
const children = childData?.entries ?? [];
if (children.length === 0) {
return null;
}
return (
<ul className="space-y-0.5">
{children.map((child) => (
<FileTreeNode
key={child.path}
entry={child}
companyId={companyId}
projectId={projectId}
workspaceId={workspaceId}
selectedPath={selectedPath}
onSelect={onSelect}
depth={depth + 1}
/>
))}
</ul>
);
}
/**
* Project sidebar item: link "Files" that opens the project detail with the Files plugin tab.
*/
@ -430,11 +344,60 @@ export function FilesTab({ context }: PluginDetailTabProps) {
() => (selectedWorkspace ? { projectId, companyId, workspaceId: selectedWorkspace.id } : {}),
[companyId, projectId, selectedWorkspace],
);
const { data: fileListData, loading: fileListLoading } = usePluginData<{ entries: FileEntry[] }>(
const { data: fileListData, loading: fileListLoading, error: fileListError } = usePluginData<{ entries: FileEntry[] }>(
"fileList",
fileListParams,
);
const entries = fileListData?.entries ?? [];
// Lazy-load directory children through an imperative action so the shared
// FileTree can reuse `expandedPaths` for state without spawning a hook per
// expanded directory.
const loadFileList = usePluginAction("loadFileList");
const [nodes, setNodes] = useState<FileTreeNode[]>([]);
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set());
const [loadedDirs, setLoadedDirs] = useState<Set<string>>(() => new Set());
const [loadingDirs, setLoadingDirs] = useState<Set<string>>(() => new Set());
useEffect(() => {
setNodes(fileListData?.entries ? entriesToFileTreeNodes(fileListData.entries) : []);
setExpandedPaths(new Set());
setLoadedDirs(new Set());
setLoadingDirs(new Set());
}, [fileListData, selectedWorkspace?.id]);
const handleToggleDir = useCallback(
(dirPath: string) => {
setExpandedPaths((current) => {
const next = new Set(current);
if (next.has(dirPath)) next.delete(dirPath);
else next.add(dirPath);
return next;
});
if (!selectedWorkspace) return;
if (loadedDirs.has(dirPath) || loadingDirs.has(dirPath)) return;
setLoadingDirs((current) => new Set(current).add(dirPath));
void loadFileList({
projectId,
companyId,
workspaceId: selectedWorkspace.id,
directoryPath: dirPath,
})
.then((response) => {
const entries = (response as { entries?: FileEntry[] })?.entries ?? [];
const children = entriesToFileTreeNodes(entries);
setNodes((current) => setChildrenAtPath(current, dirPath, children));
setLoadedDirs((current) => new Set(current).add(dirPath));
})
.finally(() => {
setLoadingDirs((current) => {
const next = new Set(current);
next.delete(dirPath);
return next;
});
});
},
[companyId, loadFileList, loadedDirs, loadingDirs, projectId, selectedWorkspace],
);
// Track the `?file=` query parameter across navigations (popstate).
const [urlFilePath, setUrlFilePath] = useState<string | null>(() => {
@ -610,28 +573,23 @@ export function FilesTab({ context }: PluginDetailTabProps) {
</div>
<div className="min-h-0 flex-1 overflow-auto p-2">
{selectedWorkspace ? (
fileListLoading ? (
<p className="px-2 py-3 text-sm text-muted-foreground">Loading files...</p>
) : entries.length > 0 ? (
<ul className="space-y-0.5">
{entries.map((entry) => (
<FileTreeNode
key={entry.path}
entry={entry}
companyId={companyId}
projectId={projectId}
workspaceId={selectedWorkspace.id}
selectedPath={selectedPath}
onSelect={(path) => {
setSelectedPath(path);
setMobileView("editor");
}}
/>
))}
</ul>
) : (
<p className="px-2 py-3 text-sm text-muted-foreground">No files found in this workspace.</p>
)
<FileTree
nodes={nodes}
selectedFile={selectedPath}
expandedPaths={expandedPaths}
onToggleDir={handleToggleDir}
onSelectFile={(path: string) => {
setSelectedPath(path);
setMobileView("editor");
}}
loading={fileListLoading}
error={fileListError ? { message: fileListError.message } : null}
empty={{
title: "No files",
description: "No files found in this workspace.",
}}
ariaLabel="Workspace files"
/>
) : (
<p className="px-2 py-3 text-sm text-muted-foreground">Select a workspace to browse files.</p>
)}

View file

@ -106,43 +106,46 @@ const plugin = definePlugin({
}));
});
ctx.data.register(
"fileList",
async (params: Record<string, unknown>) => {
const projectId = params.projectId as string;
const companyId = typeof params.companyId === "string" ? params.companyId : "";
const workspaceId = params.workspaceId as string;
const directoryPath = typeof params.directoryPath === "string" ? params.directoryPath : "";
if (!projectId || !companyId || !workspaceId) return { entries: [] };
const workspaces = await ctx.projects.listWorkspaces(projectId, companyId);
const workspace = workspaces.find((w) => w.id === workspaceId);
if (!workspace) return { entries: [] };
const workspacePath = sanitizeWorkspacePath(workspace.path);
if (!workspacePath) return { entries: [] };
const dirPath = resolveWorkspace(workspacePath, directoryPath);
if (!dirPath) {
return { entries: [] };
}
if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
return { entries: [] };
}
const names = fs.readdirSync(dirPath).sort((a, b) => a.localeCompare(b));
const entries = names.map((name) => {
const full = path.join(dirPath, name);
const stat = fs.lstatSync(full);
const relativePath = path.relative(workspacePath, full);
return {
name,
path: relativePath,
isDirectory: stat.isDirectory(),
};
}).sort((a, b) => {
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
return a.name.localeCompare(b.name);
});
return { entries };
},
);
async function readFileList(params: Record<string, unknown>) {
const projectId = params.projectId as string;
const companyId = typeof params.companyId === "string" ? params.companyId : "";
const workspaceId = params.workspaceId as string;
const directoryPath = typeof params.directoryPath === "string" ? params.directoryPath : "";
if (!projectId || !companyId || !workspaceId) return { entries: [] };
const workspaces = await ctx.projects.listWorkspaces(projectId, companyId);
const workspace = workspaces.find((w) => w.id === workspaceId);
if (!workspace) return { entries: [] };
const workspacePath = sanitizeWorkspacePath(workspace.path);
if (!workspacePath) return { entries: [] };
const dirPath = resolveWorkspace(workspacePath, directoryPath);
if (!dirPath) {
return { entries: [] };
}
if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
return { entries: [] };
}
const names = fs.readdirSync(dirPath).sort((a, b) => a.localeCompare(b));
const entries = names.map((name) => {
const full = path.join(dirPath, name);
const stat = fs.lstatSync(full);
const relativePath = path.relative(workspacePath, full);
return {
name,
path: relativePath,
isDirectory: stat.isDirectory(),
};
}).sort((a, b) => {
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
return a.name.localeCompare(b.name);
});
return { entries };
}
ctx.data.register("fileList", readFileList);
// Mirror `fileList` as an action so the UI can lazily fetch directory
// children on tree expand without spawning a usePluginData hook per dir.
ctx.actions.register("loadFileList", readFileList);
ctx.data.register(
"fileContent",

View file

@ -1,6 +1,9 @@
import { useEffect, useMemo, useState, type CSSProperties, type FormEvent, type ReactNode } from "react";
import {
AssigneePicker,
ProjectPicker,
useHostContext,
useHostNavigation,
usePluginAction,
usePluginData,
usePluginStream,
@ -248,14 +251,6 @@ const mutedTextStyle: CSSProperties = {
lineHeight: 1.45,
};
function hostPath(companyPrefix: string | null | undefined, suffix: string): string {
return companyPrefix ? `/${companyPrefix}${suffix}` : suffix;
}
function pluginPagePath(companyPrefix: string | null | undefined): string {
return hostPath(companyPrefix, `/${PAGE_ROUTE}`);
}
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
@ -521,6 +516,7 @@ function CompactSurfaceSummary({ label, entityType }: { label: string; entityTyp
function KitchenSinkPageWidgets({ context }: { context: PluginPageProps["context"] }) {
const overview = usePluginOverview(context.companyId);
const toast = usePluginToast();
const hostNavigation = useHostNavigation();
const emitDemoEvent = usePluginAction("emit-demo-event");
const startProgressStream = usePluginAction("start-progress-stream");
const writeMetric = usePluginAction("write-metric");
@ -591,7 +587,7 @@ function KitchenSinkPageWidgets({ context }: { context: PluginPageProps["context
tone: "info",
action: {
label: "Go",
href: hostPath(context.companyPrefix, "/dashboard"),
href: hostNavigation.resolveHref("/dashboard"),
},
})}
>
@ -1079,6 +1075,7 @@ function KitchenSinkCompanyCrudDemo({ context }: { context: PluginPageProps["con
}
function KitchenSinkTopRow({ context }: { context: PluginPageProps["context"] }) {
const hostNavigation = useHostNavigation();
return (
<div
style={{
@ -1098,8 +1095,8 @@ function KitchenSinkTopRow({ context }: { context: PluginPageProps["context"] })
<div style={mutedTextStyle}>
The company sidebar entry opens this route directly, so the plugin feels like a first-class company page instead of a settings subpage.
</div>
<a href={pluginPagePath(context.companyPrefix)} style={{ fontSize: "12px" }}>
{pluginPagePath(context.companyPrefix)}
<a {...hostNavigation.linkProps(`/${PAGE_ROUTE}`)} style={{ fontSize: "12px" }}>
{hostNavigation.resolveHref(`/${PAGE_ROUTE}`)}
</a>
</Section>
<Section title="Paperclip Animation">
@ -1193,6 +1190,7 @@ function KitchenSinkStorageDemo({ context }: { context: PluginPageProps["context
}
function KitchenSinkHostIntegrationDemo({ context }: { context: PluginPageProps["context"] }) {
const hostNavigation = useHostNavigation();
const [liveRuns, setLiveRuns] = useState<HostLiveRunRecord[]>([]);
const [recentRuns, setRecentRuns] = useState<HostHeartbeatRunRecord[]>([]);
const [loading, setLoading] = useState(false);
@ -1228,7 +1226,7 @@ function KitchenSinkHostIntegrationDemo({ context }: { context: PluginPageProps[
<div style={subtleCardStyle}>
<div style={rowStyle}>
<strong>Company Route</strong>
<Pill label={pluginPagePath(context.companyPrefix)} />
<Pill label={hostNavigation.resolveHref(`/${PAGE_ROUTE}`)} />
</div>
<div style={mutedTextStyle}>
This page is mounted as a real company route instead of living only under `/plugins/:pluginId`.
@ -1260,7 +1258,7 @@ function KitchenSinkHostIntegrationDemo({ context }: { context: PluginPageProps[
</div>
<div>{run.id}</div>
{run.agentId ? (
<a href={hostPath(context.companyPrefix, `/agents/${run.agentId}/runs/${run.id}`)}>
<a {...hostNavigation.linkProps(`/agents/${run.agentId}/runs/${run.id}`)}>
Open run
</a>
) : null}
@ -1294,6 +1292,44 @@ function KitchenSinkHostIntegrationDemo({ context }: { context: PluginPageProps[
);
}
function KitchenSinkSharedPickerDemo({ context }: { context: PluginPageProps["context"] }) {
const [assigneeValue, setAssigneeValue] = useState("");
const [projectId, setProjectId] = useState(context.projectId ?? "");
useEffect(() => {
setProjectId(context.projectId ?? "");
}, [context.projectId]);
return (
<Section title="Shared Host Pickers">
<div style={mutedTextStyle}>
These controls are imported from `@paperclipai/plugin-sdk/ui` and reuse the host's assignee and project pickers from the new issue pane.
</div>
{!context.companyId ? (
<div style={mutedTextStyle}>Select a company to load picker options.</div>
) : (
<div style={subtleCardStyle}>
<div style={{ display: "flex", flexWrap: "wrap", gap: "8px", alignItems: "center" }}>
<AssigneePicker
companyId={context.companyId}
value={assigneeValue}
onChange={(value) => setAssigneeValue(value)}
/>
<ProjectPicker
companyId={context.companyId}
value={projectId}
onChange={setProjectId}
/>
</div>
<div style={{ ...mutedTextStyle, marginTop: "8px" }}>
Selected assignee: {assigneeValue || "none"}, selected project: {projectId || "none"}
</div>
</div>
)}
</Section>
);
}
function KitchenSinkEmbeddedApp({ context }: { context: PluginPageProps["context"] }) {
return (
<div style={{ display: "grid", gap: "14px" }}>
@ -1301,12 +1337,14 @@ function KitchenSinkEmbeddedApp({ context }: { context: PluginPageProps["context
<KitchenSinkStorageDemo context={context} />
<KitchenSinkIssueCrudDemo context={context} />
<KitchenSinkCompanyCrudDemo context={context} />
<KitchenSinkSharedPickerDemo context={context} />
<KitchenSinkHostIntegrationDemo context={context} />
</div>
);
}
function KitchenSinkConsole({ context }: { context: { companyId: string | null; companyPrefix?: string | null; projectId?: string | null; entityId?: string | null; entityType?: string | null } }) {
const hostNavigation = useHostNavigation();
const companyId = context.companyId;
const overview = usePluginOverview(companyId);
const [companiesLimit, setCompaniesLimit] = useState(20);
@ -1531,10 +1569,10 @@ function KitchenSinkConsole({ context }: { context: { companyId: string | null;
<Section title="UI Surfaces">
<div style={rowStyle}>
<a href={pluginPagePath(context.companyPrefix)} style={{ fontSize: "12px" }}>Open plugin page</a>
<a {...hostNavigation.linkProps(`/${PAGE_ROUTE}`)} style={{ fontSize: "12px" }}>Open plugin page</a>
{projectRef ? (
<a
href={hostPath(context.companyPrefix, `/projects/${projectRef}?tab=plugin:${PLUGIN_ID}:${SLOT_IDS.projectTab}`)}
{...hostNavigation.linkProps(`/projects/${projectRef}?tab=plugin:${PLUGIN_ID}:${SLOT_IDS.projectTab}`)}
style={{ fontSize: "12px" }}
>
Open project tab
@ -1542,7 +1580,7 @@ function KitchenSinkConsole({ context }: { context: { companyId: string | null;
) : null}
{selectedIssueId ? (
<a
href={hostPath(context.companyPrefix, `/issues/${selectedIssueId}`)}
{...hostNavigation.linkProps(`/issues/${selectedIssueId}`)}
style={{ fontSize: "12px" }}
>
Open selected issue
@ -2199,6 +2237,7 @@ export function KitchenSinkSettingsPage({ context }: PluginSettingsPageProps) {
}
export function KitchenSinkDashboardWidget({ context }: PluginWidgetProps) {
const hostNavigation = useHostNavigation();
const overview = usePluginOverview(context.companyId);
const writeMetric = usePluginAction("write-metric");
@ -2217,7 +2256,7 @@ export function KitchenSinkDashboardWidget({ context }: PluginWidgetProps) {
<div>Issues: {overview.data?.counts.issues ?? 0}</div>
</div>
<div style={rowStyle}>
<a href={pluginPagePath(context.companyPrefix)} style={{ fontSize: "12px" }}>Open page</a>
<a {...hostNavigation.linkProps(`/${PAGE_ROUTE}`)} style={{ fontSize: "12px" }}>Open page</a>
<button
type="button"
style={buttonStyle}
@ -2234,13 +2273,14 @@ export function KitchenSinkDashboardWidget({ context }: PluginWidgetProps) {
}
export function KitchenSinkSidebarLink({ context }: PluginSidebarProps) {
const hostNavigation = useHostNavigation();
const config = usePluginConfigData();
if (config.data && config.data.showSidebarEntry === false) return null;
const href = pluginPagePath(context.companyPrefix);
const href = hostNavigation.resolveHref(`/${PAGE_ROUTE}`);
const isActive = typeof window !== "undefined" && window.location.pathname === href;
return (
<a
href={href}
{...hostNavigation.linkProps(`/${PAGE_ROUTE}`)}
aria-current={isActive ? "page" : undefined}
className={[
"flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium transition-colors",
@ -2267,6 +2307,7 @@ export function KitchenSinkSidebarLink({ context }: PluginSidebarProps) {
export function KitchenSinkSidebarPanel() {
const context = useHostContext();
const hostNavigation = useHostNavigation();
const config = usePluginConfigData();
const overview = usePluginOverview(context.companyId);
if (config.data && config.data.showSidebarPanel === false) return null;
@ -2274,17 +2315,18 @@ export function KitchenSinkSidebarPanel() {
<div style={{ ...layoutStack, ...subtleCardStyle, fontSize: "12px" }}>
<strong>Kitchen Sink Panel</strong>
<div>Recent plugin records: {overview.data?.recentRecords.length ?? 0}</div>
<a href={pluginPagePath(context.companyPrefix)}>Open plugin page</a>
<a {...hostNavigation.linkProps(`/${PAGE_ROUTE}`)}>Open plugin page</a>
</div>
);
}
export function KitchenSinkProjectSidebarItem({ context }: PluginProjectSidebarItemProps) {
const hostNavigation = useHostNavigation();
const config = usePluginConfigData();
if (config.data && config.data.showProjectSidebarItem === false) return null;
return (
<a
href={hostPath(context.companyPrefix, `/projects/${context.entityId}?tab=plugin:${PLUGIN_ID}:${SLOT_IDS.projectTab}`)}
{...hostNavigation.linkProps(`/projects/${context.entityId}?tab=plugin:${PLUGIN_ID}:${SLOT_IDS.projectTab}`)}
style={{ fontSize: "12px", textDecoration: "none" }}
>
Kitchen Sink

View file

@ -15,7 +15,7 @@ Reference: `doc/plugins/PLUGIN_SPEC.md`
| Import | Purpose |
|--------|--------|
| `@paperclipai/plugin-sdk` | Worker entry: `definePlugin`, `runWorker`, context types, protocol helpers |
| `@paperclipai/plugin-sdk/ui` | UI entry: `usePluginData`, `usePluginAction`, `usePluginStream`, `useHostContext`, slot prop types |
| `@paperclipai/plugin-sdk/ui` | UI entry: `usePluginData`, `usePluginAction`, `usePluginStream`, `useHostContext`, `useHostNavigation`, slot prop types |
| `@paperclipai/plugin-sdk/ui/hooks` | Hooks only |
| `@paperclipai/plugin-sdk/ui/types` | UI types and slot prop interfaces |
| `@paperclipai/plugin-sdk/testing` | `createTestHarness` for unit/integration tests |
@ -47,7 +47,7 @@ The SDK is stable enough for local development and first-party examples, but the
- For deployed plugins, publish an npm package and install that package into the Paperclip instance at runtime.
- The current host runtime expects a writable filesystem, `npm` available at runtime, and network access to the package registry used for plugin installation.
- Dynamic plugin install is currently best suited to single-node persistent deployments. Multi-instance cloud deployments still need a shared artifact/distribution model before runtime installs are reliable across nodes.
- The host does not currently ship a real shared React component kit for plugins. Build your plugin UI with ordinary React components and CSS.
- The host ships a small shared React component kit through `@paperclipai/plugin-sdk/ui`. Use it for native Paperclip controls; custom React and CSS are still supported.
- `ctx.assets` is not part of the supported runtime in this build. Do not depend on asset upload/read APIs yet.
If you are authoring a plugin for others to deploy, treat npm-packaged installation as the supported path and treat repo-local example installs as a development convenience.
@ -100,12 +100,14 @@ runWorker(plugin, import.meta.url);
| `onValidateConfig?(config)` | Optional. Return `{ ok, warnings?, errors? }` for settings UI / Test Connection. |
| `onWebhook?(input)` | Optional. Handle `POST /api/plugins/:pluginId/webhooks/:endpointKey`; required if webhooks declared. |
**Context (`ctx`) in setup:** `config`, `events`, `jobs`, `launchers`, `http`, `secrets`, `activity`, `state`, `entities`, `projects`, `companies`, `issues`, `agents`, `goals`, `data`, `actions`, `streams`, `tools`, `metrics`, `logger`, `manifest`. Worker-side host APIs are capability-gated; declare capabilities in the manifest.
**Context (`ctx`) in setup:** `config`, `localFolders`, `events`, `jobs`, `launchers`, `http`, `secrets`, `activity`, `state`, `entities`, `projects`, `companies`, `issues`, `agents`, `goals`, `data`, `actions`, `streams`, `tools`, `metrics`, `logger`, `manifest`. Worker-side host APIs are capability-gated; declare capabilities in the manifest.
**Agents:** `ctx.agents.invoke(agentId, companyId, opts)` for one-shot invocation. `ctx.agents.sessions` for two-way chat: `create`, `list`, `sendMessage` (with streaming `onEvent` callback), `close`. See the [Plugin Authoring Guide](../../doc/plugins/PLUGIN_AUTHORING_GUIDE.md#agent-sessions-two-way-chat) for details.
**Jobs:** Declare in `manifest.jobs` with `jobKey`, `displayName`, `schedule` (cron). Register handler with `ctx.jobs.register(jobKey, fn)`. **Webhooks:** Declare in `manifest.webhooks` with `endpointKey`; handle in `onWebhook(input)`. **State:** `ctx.state.get/set/delete(scopeKey)`; scope kinds: `instance`, `company`, `project`, `project_workspace`, `agent`, `issue`, `goal`, `run`.
**Trusted local folders:** Declare `manifest.localFolders[]` and the `local.folders` capability when a plugin needs an operator-configured company-scoped folder. Use `ctx.localFolders.configure()`, `status()`, `readText()`, and `writeTextAtomic()` instead of resolving arbitrary filesystem paths yourself. The host validates absolute roots, read/write access, required relative folders/files, traversal attempts, symlink escapes, and writes through temp-file-plus-rename atomic replacement.
## Events
Subscribe in `setup` with `ctx.events.on(name, handler)` or `ctx.events.on(name, filter, handler)`. Emit plugin-scoped events with `ctx.events.emit(name, companyId, payload)` (requires `events.emit`).
@ -201,12 +203,13 @@ Slots are mount points for plugin React components. Launchers are host-rendered
### Slot types / launcher placement zones
The same set of values is used as **slot types** (where a component mounts) and **launcher placement zones** (where a launcher can appear). Hierarchy:
Slot types describe where a component mounts. Most values also exist as launcher placement zones.
| Slot type / placement zone | Scope | Entity types (when context-sensitive) |
|----------------------------|-------|---------------------------------------|
| `page` | Global | — |
| `sidebar` | Global | — |
| `routeSidebar` | Global | — |
| `sidebarPanel` | Global | — |
| `settingsPage` | Global | — |
| `dashboardWidget` | Global | — |
@ -233,6 +236,10 @@ A full-page extension mounted at `/plugins/:pluginId` (global) or `/:company/plu
Adds a navigation-style entry to the main company sidebar navigation area, rendered alongside the core nav items (Dashboard, Issues, Goals, etc.). Use this for lightweight, always-visible links or status indicators that feel native to the sidebar. Receives `PluginSidebarProps` with `context.companyId` set to the active company. Requires the `ui.sidebar.register` capability.
#### `routeSidebar`
Replaces the normal company sidebar while the current route is a plugin page route with the same `routePath`. Use this for full-page plugin workspaces that need their own local navigation while keeping the company rail and account footer. Receives `PluginRouteSidebarProps` with `context.companyId` and `context.companyPrefix` set to the active company. Requires the `ui.sidebar.register` capability.
#### `sidebarPanel`
Renders richer inline content in a dedicated panel area below the company sidebar navigation sections. Use this for mini-widgets, summary cards, quick-action panels, or at-a-glance status views that need more vertical space than a nav link. Receives `context.companyId` set to the active company via `useHostContext()`. Requires the `ui.sidebar.register` capability.
@ -338,6 +345,7 @@ Declare in `manifest.capabilities`. Grouped by scope:
| | `http.outbound` |
| | `secrets.read-ref` |
| | `environment.drivers.register` |
| | `local.folders` |
| **Agent** | `agent.tools.register` |
| | `agents.invoke` |
| | `agent.sessions.create` |
@ -372,6 +380,38 @@ only inside the plugin namespace. Runtime `ctx.db.query()` allows `SELECT` from
`ctx.db.execute()` allows `INSERT`, `UPDATE`, and `DELETE` only against the
plugin namespace.
### Trusted Local Folders
Trusted local plugins can request operator-configured folders per company:
```ts
export const manifest = {
// ...
capabilities: ["local.folders"],
localFolders: [
{
folderKey: "content-root",
displayName: "Content root",
access: "readWrite",
requiredDirectories: ["sources", "pages"],
requiredFiles: ["schema.md"],
},
],
};
```
The host stores the selected path in company-scoped plugin settings and exposes
readiness through:
- `GET /api/plugins/:pluginId/companies/:companyId/local-folders`
- `GET /api/plugins/:pluginId/companies/:companyId/local-folders/:folderKey/status`
- `POST /api/plugins/:pluginId/companies/:companyId/local-folders/:folderKey/validate`
- `PUT /api/plugins/:pluginId/companies/:companyId/local-folders/:folderKey`
Worker code should access files through `ctx.localFolders.readText()` and
`ctx.localFolders.writeTextAtomic()`. Relative paths must stay inside the
configured root; symlinks that escape the root are rejected.
### Scoped API Routes
Manifest-declared `apiRoutes` expose JSON routes under
@ -599,6 +639,23 @@ export function IssueLinearLink({ context }: PluginDetailTabProps) {
}
```
#### `useHostNavigation()`
Routes Paperclip-internal plugin links through the host router without a full document reload. Use `linkProps()` for anchors so the browser still gets a real `href` for copy-link, modifier-click, middle-click, and open-in-new-tab behavior.
```tsx
import { useHostNavigation } from "@paperclipai/plugin-sdk/ui";
export function WikiSidebarLink() {
const hostNavigation = useHostNavigation();
return <a {...hostNavigation.linkProps("/wiki")}>Wiki</a>;
}
```
`linkProps("/wiki")` resolves against the active company prefix, so in company `PAP` it renders `href="/PAP/wiki"`. Already-prefixed paths such as `/PAP/wiki` are not prefixed again. For button-style commands, call `hostNavigation.navigate("/issues/PAP-123")`.
Avoid raw same-origin `href`s or `window.location.assign()` for Paperclip-internal navigation from plugin UI. Those bypass the host router and can reload the whole app. External links should keep normal anchors with `target="_blank"` and `rel="noopener noreferrer"` as appropriate.
#### `usePluginStream<T>(channel, options?)`
Subscribes to a real-time event stream pushed from the plugin worker via SSE. The worker pushes events using `ctx.streams.emit(channel, event)` and the hook receives them as they arrive. Returns `{ events, lastEvent, connecting, connected, error, close }`.
@ -629,7 +686,118 @@ The SSE connection targets `GET /api/plugins/:pluginId/bridge/stream/:channel?co
### UI authoring note
The current host does **not** provide a real shared component library to plugins yet. Use normal React components, your own CSS, or your own small design primitives inside the plugin package.
The host provides selected shared UI components through `@paperclipai/plugin-sdk/ui`.
Plugins can also use normal React components, their own CSS, or small design
primitives inside the plugin package.
Use the shared components when the plugin needs to look and behave like a native
Paperclip surface:
| Component | Use when |
|---|---|
| `MarkdownBlock` | Rendering markdown from plugin or host data |
| `MarkdownEditor` | Editing markdown with the host editor treatment |
| `FileTree` | Showing serializable workspace/wiki/import paths |
| `IssuesList` | Embedding a company-scoped native issue list |
| `AssigneePicker` | Selecting an agent or board user with the same picker as the new issue pane |
| `ProjectPicker` | Selecting a project with the same picker as the new issue pane |
| `ManagedRoutinesList` | Showing plugin-managed routines in settings UI |
#### Shared Markdown Components
Plugin UI can render markdown and edit markdown using the same host components
used by Paperclip issue comments and documents:
```tsx
import { MarkdownBlock, MarkdownEditor } from "@paperclipai/plugin-sdk/ui";
export function WikiPageEditor() {
const [body, setBody] = useState("# Wiki page");
return (
<>
<MarkdownBlock content={body} />
<MarkdownEditor value={body} onChange={setBody} bordered />
</>
);
}
```
`MarkdownBlock` can opt into Obsidian-style wikilinks when a plugin owns the
target URL shape:
```tsx
<MarkdownBlock
content={"See [[wiki/entities/paperclip|Paperclip]]."}
enableWikiLinks
wikiLinkRoot="/wiki/page"
/>
```
#### Shared FileTree
Plugin UI can render the host file tree without importing host internals:
```tsx
import { FileTree, type FileTreeNode } from "@paperclipai/plugin-sdk/ui";
const nodes: FileTreeNode[] = [
{ name: "AGENTS.md", path: "AGENTS.md", kind: "file", children: [] },
{
name: "wiki",
path: "wiki",
kind: "dir",
children: [
{ name: "index.md", path: "wiki/index.md", kind: "file", children: [] },
],
},
];
export function WikiFiles() {
return (
<FileTree
nodes={nodes}
expandedPaths={["wiki"]}
selectedFile="wiki/index.md"
onToggleDir={(path) => console.log("toggle", path)}
onSelectFile={(path) => console.log("select", path)}
/>
);
}
```
#### Shared Assignee and Project Pickers
Use `AssigneePicker` and `ProjectPicker` when a plugin needs to create, filter,
or configure work against Paperclip entities. Both are controlled components and
load their options from the host for the provided company.
```tsx
import { AssigneePicker, ProjectPicker } from "@paperclipai/plugin-sdk/ui";
export function AssignmentControls({ companyId }: { companyId: string }) {
const [assignee, setAssignee] = useState("");
const [projectId, setProjectId] = useState("");
return (
<>
<AssigneePicker
companyId={companyId}
value={assignee}
onChange={(value, selection) => {
setAssignee(value);
console.log(selection.assigneeAgentId, selection.assigneeUserId);
}}
/>
<ProjectPicker
companyId={companyId}
value={projectId}
onChange={setProjectId}
/>
</>
);
}
```
### Slot component props
@ -639,6 +807,7 @@ Each slot type receives a typed props object with `context: PluginHostContext`.
|-----------|----------------|------------------|
| `page` | `PluginPageProps` | — |
| `sidebar` | `PluginSidebarProps` | — |
| `routeSidebar` | `PluginRouteSidebarProps` | — |
| `settingsPage` | `PluginSettingsPageProps` | — |
| `dashboardWidget` | `PluginWidgetProps` | — |
| `globalToolbarButton` | `PluginGlobalToolbarButtonProps` | — |
@ -741,14 +910,17 @@ Plugins can add a link under each project in the sidebar via the `projectSidebar
Minimal React component that links to the projects plugin tab (see project detail tabs in the spec):
```tsx
import type { PluginProjectSidebarItemProps } from "@paperclipai/plugin-sdk/ui";
import {
useHostNavigation,
type PluginProjectSidebarItemProps,
} from "@paperclipai/plugin-sdk/ui";
export function FilesLink({ context }: PluginProjectSidebarItemProps) {
const hostNavigation = useHostNavigation();
const projectId = context.entityId;
const prefix = context.companyPrefix ? `/${context.companyPrefix}` : "";
const projectRef = projectId; // or resolve from host; entityId is project id
return (
<a href={`${prefix}/projects/${projectRef}?tab=plugin:your-plugin:files`}>
<a {...hostNavigation.linkProps(`/projects/${projectRef}?tab=plugin:your-plugin:files`)}>
Files
</a>
);

View file

@ -89,11 +89,12 @@ export function createPluginBundlerPresets(input: PluginBundlerPresetInput = {})
const esbuildManifest: EsbuildLikeOptions = {
entryPoints: [manifestEntry],
outdir,
bundle: false,
bundle: true,
format: "esm",
platform: "node",
target: "node20",
sourcemap,
external: ["@paperclipai/plugin-sdk"],
};
const esbuildUi = uiEntry

View file

@ -90,6 +90,16 @@ export interface HostServices {
get(): Promise<Record<string, unknown>>;
};
/** Provides trusted company-scoped local folder helpers. */
localFolders: {
declarations(params: WorkerToHostMethods["localFolders.declarations"][0]): Promise<WorkerToHostMethods["localFolders.declarations"][1]>;
configure(params: WorkerToHostMethods["localFolders.configure"][0]): Promise<WorkerToHostMethods["localFolders.configure"][1]>;
status(params: WorkerToHostMethods["localFolders.status"][0]): Promise<WorkerToHostMethods["localFolders.status"][1]>;
list(params: WorkerToHostMethods["localFolders.list"][0]): Promise<WorkerToHostMethods["localFolders.list"][1]>;
readText(params: WorkerToHostMethods["localFolders.readText"][0]): Promise<WorkerToHostMethods["localFolders.readText"][1]>;
writeTextAtomic(params: WorkerToHostMethods["localFolders.writeTextAtomic"][0]): Promise<WorkerToHostMethods["localFolders.writeTextAtomic"][1]>;
};
/** Provides `state.get`, `state.set`, `state.delete`. */
state: {
get(params: WorkerToHostMethods["state.get"][0]): Promise<WorkerToHostMethods["state.get"][1]>;
@ -165,6 +175,18 @@ export interface HostServices {
listWorkspaces(params: WorkerToHostMethods["projects.listWorkspaces"][0]): Promise<WorkerToHostMethods["projects.listWorkspaces"][1]>;
getPrimaryWorkspace(params: WorkerToHostMethods["projects.getPrimaryWorkspace"][0]): Promise<WorkerToHostMethods["projects.getPrimaryWorkspace"][1]>;
getWorkspaceForIssue(params: WorkerToHostMethods["projects.getWorkspaceForIssue"][0]): Promise<WorkerToHostMethods["projects.getWorkspaceForIssue"][1]>;
getManaged(params: WorkerToHostMethods["projects.managed.get"][0]): Promise<WorkerToHostMethods["projects.managed.get"][1]>;
reconcileManaged(params: WorkerToHostMethods["projects.managed.reconcile"][0]): Promise<WorkerToHostMethods["projects.managed.reconcile"][1]>;
resetManaged(params: WorkerToHostMethods["projects.managed.reset"][0]): Promise<WorkerToHostMethods["projects.managed.reset"][1]>;
};
/** Provides `routines.managed.*`. */
routines: {
managedGet(params: WorkerToHostMethods["routines.managed.get"][0]): Promise<WorkerToHostMethods["routines.managed.get"][1]>;
managedReconcile(params: WorkerToHostMethods["routines.managed.reconcile"][0]): Promise<WorkerToHostMethods["routines.managed.reconcile"][1]>;
managedReset(params: WorkerToHostMethods["routines.managed.reset"][0]): Promise<WorkerToHostMethods["routines.managed.reset"][1]>;
managedUpdate(params: WorkerToHostMethods["routines.managed.update"][0]): Promise<WorkerToHostMethods["routines.managed.update"][1]>;
managedRun(params: WorkerToHostMethods["routines.managed.run"][0]): Promise<WorkerToHostMethods["routines.managed.run"][1]>;
};
/** Provides issue read/write, relation, checkout, wakeup, summary, comment methods. */
@ -202,6 +224,9 @@ export interface HostServices {
pause(params: WorkerToHostMethods["agents.pause"][0]): Promise<WorkerToHostMethods["agents.pause"][1]>;
resume(params: WorkerToHostMethods["agents.resume"][0]): Promise<WorkerToHostMethods["agents.resume"][1]>;
invoke(params: WorkerToHostMethods["agents.invoke"][0]): Promise<WorkerToHostMethods["agents.invoke"][1]>;
managedGet(params: WorkerToHostMethods["agents.managed.get"][0]): Promise<WorkerToHostMethods["agents.managed.get"][1]>;
managedReconcile(params: WorkerToHostMethods["agents.managed.reconcile"][0]): Promise<WorkerToHostMethods["agents.managed.reconcile"][1]>;
managedReset(params: WorkerToHostMethods["agents.managed.reset"][0]): Promise<WorkerToHostMethods["agents.managed.reset"][1]>;
};
/** Provides `agents.sessions.create`, `agents.sessions.list`, `agents.sessions.sendMessage`, `agents.sessions.close`. */
@ -281,6 +306,14 @@ const METHOD_CAPABILITY_MAP: Record<WorkerToHostMethodName, PluginCapability | n
// Config — always allowed
"config.get": null,
// Trusted local folders
"localFolders.declarations": null,
"localFolders.configure": "local.folders",
"localFolders.status": "local.folders",
"localFolders.list": "local.folders",
"localFolders.readText": "local.folders",
"localFolders.writeTextAtomic": "local.folders",
// State
"state.get": "plugin.state.read",
"state.set": "plugin.state.write",
@ -326,6 +359,14 @@ const METHOD_CAPABILITY_MAP: Record<WorkerToHostMethodName, PluginCapability | n
"projects.listWorkspaces": "project.workspaces.read",
"projects.getPrimaryWorkspace": "project.workspaces.read",
"projects.getWorkspaceForIssue": "project.workspaces.read",
"projects.managed.get": "projects.managed",
"projects.managed.reconcile": "projects.managed",
"projects.managed.reset": "projects.managed",
"routines.managed.get": "routines.managed",
"routines.managed.reconcile": "routines.managed",
"routines.managed.reset": "routines.managed",
"routines.managed.update": "routines.managed",
"routines.managed.run": "routines.managed",
// Issues
"issues.list": "issues.read",
@ -357,6 +398,9 @@ const METHOD_CAPABILITY_MAP: Record<WorkerToHostMethodName, PluginCapability | n
"agents.pause": "agents.pause",
"agents.resume": "agents.resume",
"agents.invoke": "agents.invoke",
"agents.managed.get": "agents.managed",
"agents.managed.reconcile": "agents.managed",
"agents.managed.reset": "agents.managed",
// Agent Sessions
"agents.sessions.create": "agent.sessions.create",
@ -439,6 +483,25 @@ export function createHostClientHandlers(
return services.config.get();
}),
"localFolders.declarations": gated("localFolders.declarations", async (params) => {
return services.localFolders.declarations(params);
}),
"localFolders.configure": gated("localFolders.configure", async (params) => {
return services.localFolders.configure(params);
}),
"localFolders.status": gated("localFolders.status", async (params) => {
return services.localFolders.status(params);
}),
"localFolders.list": gated("localFolders.list", async (params) => {
return services.localFolders.list(params);
}),
"localFolders.readText": gated("localFolders.readText", async (params) => {
return services.localFolders.readText(params);
}),
"localFolders.writeTextAtomic": gated("localFolders.writeTextAtomic", async (params) => {
return services.localFolders.writeTextAtomic(params);
}),
// State
"state.get": gated("state.get", async (params) => {
return services.state.get(params);
@ -530,6 +593,32 @@ export function createHostClientHandlers(
"projects.getWorkspaceForIssue": gated("projects.getWorkspaceForIssue", async (params) => {
return services.projects.getWorkspaceForIssue(params);
}),
"projects.managed.get": gated("projects.managed.get", async (params) => {
return services.projects.getManaged(params);
}),
"projects.managed.reconcile": gated("projects.managed.reconcile", async (params) => {
return services.projects.reconcileManaged(params);
}),
"projects.managed.reset": gated("projects.managed.reset", async (params) => {
return services.projects.resetManaged(params);
}),
// Routines
"routines.managed.get": gated("routines.managed.get", async (params) => {
return services.routines.managedGet(params);
}),
"routines.managed.reconcile": gated("routines.managed.reconcile", async (params) => {
return services.routines.managedReconcile(params);
}),
"routines.managed.reset": gated("routines.managed.reset", async (params) => {
return services.routines.managedReset(params);
}),
"routines.managed.update": gated("routines.managed.update", async (params) => {
return services.routines.managedUpdate(params);
}),
"routines.managed.run": gated("routines.managed.run", async (params) => {
return services.routines.managedRun(params);
}),
// Issues
"issues.list": gated("issues.list", async (params) => {
@ -611,6 +700,15 @@ export function createHostClientHandlers(
"agents.invoke": gated("agents.invoke", async (params) => {
return services.agents.invoke(params);
}),
"agents.managed.get": gated("agents.managed.get", async (params) => {
return services.agents.managedGet(params);
}),
"agents.managed.reconcile": gated("agents.managed.reconcile", async (params) => {
return services.agents.managedReconcile(params);
}),
"agents.managed.reset": gated("agents.managed.reset", async (params) => {
return services.agents.managedReset(params);
}),
// Agent Sessions
"agents.sessions.create": gated("agents.sessions.create", async (params) => {

View file

@ -180,6 +180,13 @@ export type {
export type {
PluginContext,
PluginConfigClient,
PluginLocalFolderProblem,
PluginLocalFolderStatus,
PluginLocalFolderConfigureInput,
PluginLocalFolderListOptions,
PluginLocalFolderEntry,
PluginLocalFolderListing,
PluginLocalFoldersClient,
PluginEventsClient,
PluginJobsClient,
PluginLaunchersClient,
@ -255,6 +262,14 @@ export type {
PluginWebhookDeclaration,
PluginToolDeclaration,
PluginEnvironmentDriverDeclaration,
PluginManagedAgentDeclaration,
PluginManagedAgentResolution,
PluginManagedProjectDeclaration,
PluginManagedProjectResolution,
PluginManagedRoutineDeclaration,
PluginManagedRoutineResolution,
PluginManagedResourceKind,
PluginManagedResourceRef,
PluginUiSlotDeclaration,
PluginUiDeclaration,
PluginLauncherActionDeclaration,
@ -264,6 +279,8 @@ export type {
PluginDatabaseDeclaration,
PluginApiRouteCompanyResolution,
PluginApiRouteDeclaration,
PluginLocalFolderDeclaration,
PluginCompanySettings,
PluginRecord,
PluginDatabaseNamespaceRecord,
PluginMigrationRecord,

View file

@ -29,8 +29,14 @@ import type {
IssueDocumentSummary,
IssueThreadInteraction,
CreateIssueThreadInteraction,
PluginManagedAgentResolution,
PluginManagedProjectResolution,
PluginManagedRoutineResolution,
Routine,
RoutineRun,
Agent,
Goal,
PluginLocalFolderDeclaration,
} from "@paperclipai/shared";
export type { PluginLauncherRenderContextSnapshot } from "@paperclipai/shared";
@ -46,6 +52,8 @@ import type {
PluginWorkspace,
ToolRunContext,
ToolResult,
PluginLocalFolderListing,
PluginLocalFolderStatus,
} from "./types.js";
import type {
PluginHealthDiagnostics,
@ -566,6 +574,44 @@ export interface WorkerToHostMethods {
// Config
"config.get": [params: Record<string, never>, result: Record<string, unknown>];
// Trusted local folders
"localFolders.declarations": [
params: Record<string, never>,
result: PluginLocalFolderDeclaration[],
];
"localFolders.configure": [
params: {
companyId: string;
folderKey: string;
path: string;
access?: "read" | "readWrite";
requiredDirectories?: string[];
requiredFiles?: string[];
},
result: PluginLocalFolderStatus,
];
"localFolders.status": [
params: { companyId: string; folderKey: string },
result: PluginLocalFolderStatus,
];
"localFolders.list": [
params: { companyId: string; folderKey: string; relativePath?: string | null; recursive?: boolean; maxEntries?: number },
result: PluginLocalFolderListing,
];
"localFolders.readText": [
params: { companyId: string; folderKey: string; relativePath: string },
result: string,
];
"localFolders.writeTextAtomic": [
params: {
companyId: string;
folderKey: string;
relativePath: string;
contents: string;
},
result: PluginLocalFolderStatus,
];
// State
"state.get": [
params: { scopeKind: string; scopeId?: string; namespace?: string; stateKey: string },
@ -724,6 +770,57 @@ export interface WorkerToHostMethods {
params: { issueId: string; companyId: string },
result: PluginWorkspace | null,
];
"projects.managed.get": [
params: { projectKey: string; companyId: string },
result: PluginManagedProjectResolution,
];
"projects.managed.reconcile": [
params: { projectKey: string; companyId: string },
result: PluginManagedProjectResolution,
];
"projects.managed.reset": [
params: { projectKey: string; companyId: string },
result: PluginManagedProjectResolution,
];
"routines.managed.get": [
params: { routineKey: string; companyId: string },
result: PluginManagedRoutineResolution,
];
"routines.managed.reconcile": [
params: {
routineKey: string;
companyId: string;
assigneeAgentId?: string | null;
projectId?: string | null;
},
result: PluginManagedRoutineResolution,
];
"routines.managed.reset": [
params: {
routineKey: string;
companyId: string;
assigneeAgentId?: string | null;
projectId?: string | null;
},
result: PluginManagedRoutineResolution,
];
"routines.managed.update": [
params: {
routineKey: string;
companyId: string;
status?: string;
},
result: Routine,
];
"routines.managed.run": [
params: {
routineKey: string;
companyId: string;
assigneeAgentId?: string | null;
projectId?: string | null;
},
result: RoutineRun,
];
// Issues
"issues.list": [
@ -732,8 +829,10 @@ export interface WorkerToHostMethods {
projectId?: string;
assigneeAgentId?: string;
originKind?: string;
originKindPrefix?: string;
originId?: string;
status?: string;
includePluginOperations?: boolean;
limit?: number;
offset?: number;
},
@ -758,6 +857,7 @@ export interface WorkerToHostMethods {
assigneeUserId?: string | null;
requestDepth?: number;
billingCode?: string | null;
surfaceVisibility?: string | null;
originKind?: string | null;
originId?: string | null;
originRunId?: string | null;
@ -940,6 +1040,18 @@ export interface WorkerToHostMethods {
params: { agentId: string; companyId: string; prompt: string; reason?: string },
result: { runId: string },
];
"agents.managed.get": [
params: { agentKey: string; companyId: string },
result: PluginManagedAgentResolution,
];
"agents.managed.reconcile": [
params: { agentKey: string; companyId: string },
result: PluginManagedAgentResolution,
];
"agents.managed.reset": [
params: { agentKey: string; companyId: string },
result: PluginManagedAgentResolution,
];
// Agent Sessions
"agents.sessions.create": [

View file

@ -1,11 +1,16 @@
import { randomUUID } from "node:crypto";
import { pluginOperationIssueOriginKind } from "@paperclipai/shared";
import type {
PaperclipPluginManifestV1,
PluginCapability,
PluginEventType,
PluginIssueOriginKind,
PluginManagedAgentResolution,
PluginManagedRoutineResolution,
Company,
Project,
Routine,
RoutineRun,
Issue,
IssueComment,
IssueThreadInteraction,
@ -419,6 +424,8 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
const entityExternalIndex = new Map<string, string>();
const companies = new Map<string, Company>();
const projects = new Map<string, Project>();
const routines = new Map<string, Routine>();
const routineRuns = new Map<string, RoutineRun>();
const issues = new Map<string, Issue>();
const blockedByIssueIds = new Map<string, string[]>();
const issueComments = new Map<string, IssueComment[]>();
@ -465,6 +472,53 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
}
const defaultPluginOriginKind: PluginIssueOriginKind = `plugin:${manifest.id}`;
function managedAgentDeclaration(agentKey: string) {
const declaration = manifest.agents?.find((agent) => agent.agentKey === agentKey);
if (!declaration) throw new Error(`Managed agent declaration not found: ${agentKey}`);
return declaration;
}
function isManagedAgent(agent: Agent, agentKey: string) {
const marker = agent.metadata?.paperclipManagedResource;
return Boolean(
marker
&& typeof marker === "object"
&& !Array.isArray(marker)
&& (marker as Record<string, unknown>).pluginKey === manifest.id
&& (marker as Record<string, unknown>).resourceKind === "agent"
&& (marker as Record<string, unknown>).resourceKey === agentKey,
);
}
function managedAgentMetadata(agentKey: string, existing?: Record<string, unknown> | null) {
return {
...(existing ?? {}),
paperclipManagedResource: {
pluginKey: manifest.id,
resourceKind: "agent",
resourceKey: agentKey,
},
};
}
function managedResolution(
agentKey: string,
companyId: string,
agent: Agent | null,
status: PluginManagedAgentResolution["status"],
): PluginManagedAgentResolution {
return {
pluginKey: manifest.id,
resourceKind: "agent",
resourceKey: agentKey,
companyId,
agentId: agent?.id ?? null,
agent,
status,
approvalId: null,
};
}
function normalizePluginOriginKind(originKind: unknown = defaultPluginOriginKind): PluginIssueOriginKind {
if (originKind == null || originKind === "") return defaultPluginOriginKind;
if (typeof originKind !== "string") throw new Error("Plugin issue originKind must be a string");
@ -481,6 +535,81 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
return { ...currentConfig };
},
},
localFolders: {
declarations() {
return manifest.localFolders ?? [];
},
async configure(input) {
requireCapability(manifest, capabilitySet, "local.folders");
return {
folderKey: input.folderKey,
configured: true,
path: input.path,
realPath: input.path,
access: input.access ?? "readWrite",
readable: true,
writable: input.access === "read" ? false : true,
requiredDirectories: input.requiredDirectories ?? [],
requiredFiles: input.requiredFiles ?? [],
missingDirectories: [],
missingFiles: [],
healthy: true,
problems: [],
checkedAt: new Date().toISOString(),
};
},
async status(_companyId, folderKey) {
requireCapability(manifest, capabilitySet, "local.folders");
return {
folderKey,
configured: false,
path: null,
realPath: null,
access: "readWrite",
readable: false,
writable: false,
requiredDirectories: [],
requiredFiles: [],
missingDirectories: [],
missingFiles: [],
healthy: false,
problems: [{ code: "not_configured", message: "No local folder path is configured." }],
checkedAt: new Date().toISOString(),
};
},
async list(_companyId, folderKey, options) {
requireCapability(manifest, capabilitySet, "local.folders");
return {
folderKey,
relativePath: options?.relativePath ?? null,
entries: [],
truncated: false,
};
},
async readText() {
requireCapability(manifest, capabilitySet, "local.folders");
throw new Error("Test harness local folder readText is not implemented");
},
async writeTextAtomic(_companyId, folderKey) {
requireCapability(manifest, capabilitySet, "local.folders");
return {
folderKey,
configured: false,
path: null,
realPath: null,
access: "readWrite",
readable: false,
writable: false,
requiredDirectories: [],
requiredFiles: [],
missingDirectories: [],
missingFiles: [],
healthy: false,
problems: [{ code: "not_configured", message: "No local folder path is configured." }],
checkedAt: new Date().toISOString(),
};
},
},
events: {
on(name: PluginEventType | `plugin.${string}`, filterOrFn: EventFilter | ((event: PluginEvent) => Promise<void>), maybeFn?: (event: PluginEvent) => Promise<void>): () => void {
requireCapability(manifest, capabilitySet, "events.subscribe");
@ -647,6 +776,314 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
const workspaces = projectWorkspaces.get(projectId) ?? [];
return workspaces.find((workspace) => workspace.isPrimary) ?? null;
},
managed: {
async get(projectKey, companyId) {
requireCapability(manifest, capabilitySet, "projects.managed");
const declaration = manifest.projects?.find((project) => project.projectKey === projectKey);
if (!declaration) {
return {
pluginKey: manifest.id,
resourceKind: "project",
resourceKey: projectKey,
companyId,
projectId: null,
project: null,
status: "missing",
};
}
const externalId = `${manifest.id}:project:${projectKey}`;
const existingEntity = [...entities.values()].find((entity) =>
entity.entityType === "managed_resource"
&& entity.scopeKind === "company"
&& entity.scopeId === companyId
&& entity.externalId === externalId
);
const existingProject = existingEntity ? projects.get(String(existingEntity.data?.projectId ?? "")) : null;
if (existingProject && isInCompany(existingProject, companyId)) {
return {
pluginKey: manifest.id,
resourceKind: "project",
resourceKey: projectKey,
companyId,
projectId: existingProject.id,
project: existingProject,
status: "resolved",
};
}
const now = new Date();
const project = {
id: `project-${projects.size + 1}`,
companyId,
urlKey: declaration.projectKey,
goalId: null,
goalIds: [],
goals: [],
name: declaration.displayName,
description: declaration.description ?? null,
status: declaration.status ?? "in_progress",
leadAgentId: null,
targetDate: null,
color: declaration.color ?? null,
env: null,
pauseReason: null,
pausedAt: null,
executionWorkspacePolicy: null,
codebase: {
workspaceId: null,
repoUrl: null,
repoRef: null,
defaultRef: null,
repoName: null,
localFolder: null,
managedFolder: `/tmp/${declaration.projectKey}`,
effectiveLocalFolder: `/tmp/${declaration.projectKey}`,
origin: "managed_checkout",
},
workspaces: [],
primaryWorkspace: null,
managedByPlugin: {
id: `managed-${projects.size + 1}`,
pluginId: manifest.id,
pluginKey: manifest.id,
pluginDisplayName: manifest.displayName,
resourceKind: "project",
resourceKey: projectKey,
defaultsJson: { displayName: declaration.displayName, settings: declaration.settings ?? {} },
createdAt: now,
updatedAt: now,
},
archivedAt: null,
createdAt: now,
updatedAt: now,
} as Project;
projects.set(project.id, project);
const externalKey = `managed_resource|company|${companyId}|${externalId}`;
const nowIso = now.toISOString();
const record: PluginEntityRecord = {
id: randomUUID(),
entityType: "managed_resource",
scopeKind: "company",
scopeId: companyId,
externalId,
title: declaration.displayName,
status: null,
data: { resourceKind: "project", resourceKey: projectKey, projectId: project.id },
createdAt: nowIso,
updatedAt: nowIso,
};
entities.set(record.id, record);
entityExternalIndex.set(externalKey, record.id);
return {
pluginKey: manifest.id,
resourceKind: "project",
resourceKey: projectKey,
companyId,
projectId: project.id,
project,
status: "created",
};
},
async reconcile(projectKey, companyId) {
return this.get(projectKey, companyId);
},
async reset(projectKey, companyId) {
const resolved = await this.get(projectKey, companyId);
return { ...resolved, status: resolved.project ? "reset" : resolved.status };
},
},
},
routines: {
managed: {
async get(routineKey, companyId) {
requireCapability(manifest, capabilitySet, "routines.managed");
const declaration = manifest.routines?.find((routine) => routine.routineKey === routineKey);
if (!declaration) {
return {
pluginKey: manifest.id,
resourceKind: "routine",
resourceKey: routineKey,
companyId,
routineId: null,
routine: null,
status: "missing",
missingRefs: [],
} satisfies PluginManagedRoutineResolution;
}
const externalId = `${manifest.id}:routine:${routineKey}`;
const existingEntity = [...entities.values()].find((entity) =>
entity.entityType === "managed_resource"
&& entity.scopeKind === "company"
&& entity.scopeId === companyId
&& entity.externalId === externalId
);
const existingRoutine = existingEntity ? routines.get(String(existingEntity.data?.routineId ?? "")) : null;
if (existingRoutine && isInCompany(existingRoutine, companyId)) {
return {
pluginKey: manifest.id,
resourceKind: "routine",
resourceKey: routineKey,
companyId,
routineId: existingRoutine.id,
routine: existingRoutine,
status: "resolved",
missingRefs: [],
} satisfies PluginManagedRoutineResolution;
}
return {
pluginKey: manifest.id,
resourceKind: "routine",
resourceKey: routineKey,
companyId,
routineId: null,
routine: null,
status: "missing",
missingRefs: [],
} satisfies PluginManagedRoutineResolution;
},
async reconcile(routineKey, companyId, overrides) {
const existing = await this.get(routineKey, companyId);
if (existing.routine) return existing;
const declaration = manifest.routines?.find((routine) => routine.routineKey === routineKey);
if (!declaration) return existing;
const now = new Date();
const agentRef = declaration.assigneeRef;
const projectRef = declaration.projectRef;
const assigneeAgentId = overrides?.assigneeAgentId
?? (agentRef?.resourceKind === "agent"
? [...agents.values()].find((agent) => isInCompany(agent, companyId) && isManagedAgent(agent, agentRef.resourceKey))?.id
: null)
?? null;
const projectId = overrides?.projectId
?? (projectRef?.resourceKind === "project"
? [...projects.values()].find((project) => (
isInCompany(project, companyId)
&& project.managedByPlugin?.pluginKey === manifest.id
&& project.managedByPlugin?.resourceKey === projectRef.resourceKey
))?.id
: null)
?? null;
const missingRefs: NonNullable<PluginManagedRoutineResolution["missingRefs"]> = [];
if (agentRef && !assigneeAgentId) missingRefs.push({ ...agentRef, pluginKey: manifest.id });
if (projectRef && !projectId) missingRefs.push({ ...projectRef, pluginKey: manifest.id });
if (missingRefs.length > 0) {
return {
pluginKey: manifest.id,
resourceKind: "routine",
resourceKey: routineKey,
companyId,
routineId: null,
routine: null,
status: "missing_refs",
missingRefs,
} satisfies PluginManagedRoutineResolution;
}
const routine = {
id: `routine-${routines.size + 1}`,
companyId,
projectId,
goalId: declaration.goalId ?? null,
parentIssueId: null,
title: declaration.title,
description: declaration.description ?? null,
assigneeAgentId,
priority: declaration.priority ?? "medium",
status: declaration.status ?? (assigneeAgentId ? "active" : "paused"),
concurrencyPolicy: declaration.concurrencyPolicy ?? "coalesce_if_active",
catchUpPolicy: declaration.catchUpPolicy ?? "skip_missed",
variables: declaration.variables ?? [],
createdByAgentId: null,
createdByUserId: null,
updatedByAgentId: null,
updatedByUserId: null,
lastTriggeredAt: null,
lastEnqueuedAt: null,
createdAt: now,
updatedAt: now,
managedByPlugin: {
id: `managed-routine-${routines.size + 1}`,
pluginId: manifest.id,
pluginKey: manifest.id,
pluginDisplayName: manifest.displayName,
resourceKind: "routine",
resourceKey: routineKey,
defaultsJson: { title: declaration.title, issueTemplate: declaration.issueTemplate ?? null },
createdAt: now,
updatedAt: now,
},
} as Routine;
routines.set(routine.id, routine);
const nowIso = now.toISOString();
const record: PluginEntityRecord = {
id: randomUUID(),
entityType: "managed_resource",
scopeKind: "company",
scopeId: companyId,
externalId: `${manifest.id}:routine:${routineKey}`,
title: declaration.title,
status: null,
data: { resourceKind: "routine", resourceKey: routineKey, routineId: routine.id },
createdAt: nowIso,
updatedAt: nowIso,
};
entities.set(record.id, record);
return {
pluginKey: manifest.id,
resourceKind: "routine",
resourceKey: routineKey,
companyId,
routineId: routine.id,
routine,
status: "created",
missingRefs: [],
} satisfies PluginManagedRoutineResolution;
},
async reset(routineKey, companyId, overrides) {
const resolved = await this.reconcile(routineKey, companyId, overrides);
return { ...resolved, status: resolved.routine ? "reset" : resolved.status } satisfies PluginManagedRoutineResolution;
},
async update(routineKey, companyId, patch) {
const resolved = await this.get(routineKey, companyId);
if (!resolved.routine) throw new Error(`Managed routine not found: ${routineKey}`);
const next = {
...resolved.routine,
...(patch.status !== undefined ? { status: patch.status } : {}),
updatedAt: new Date(),
};
routines.set(next.id, next);
return next;
},
async run(routineKey, companyId) {
const resolved = await this.get(routineKey, companyId);
if (!resolved.routine) throw new Error(`Managed routine not found: ${routineKey}`);
const now = new Date();
const run = {
id: `routine-run-${routineRuns.size + 1}`,
companyId,
routineId: resolved.routine.id,
triggerId: null,
source: "manual",
status: "queued",
triggeredAt: now,
idempotencyKey: null,
triggerPayload: null,
dispatchFingerprint: null,
linkedIssueId: null,
coalescedIntoRunId: null,
failureReason: null,
completedAt: null,
createdAt: now,
updatedAt: now,
} satisfies RoutineRun;
routineRuns.set(run.id, run);
routines.set(resolved.routine.id, {
...resolved.routine,
lastTriggeredAt: now,
lastEnqueuedAt: now,
updatedAt: now,
});
return run;
},
},
},
companies: {
async list(input) {
@ -673,6 +1110,12 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
if (input.originKind.startsWith("plugin:")) normalizePluginOriginKind(input.originKind);
out = out.filter((issue) => issue.originKind === input.originKind);
}
if (input?.originKindPrefix) {
const prefix = input.originKindPrefix;
out = out.filter((issue) =>
typeof issue.originKind === "string" && issue.originKind.startsWith(prefix),
);
}
if (input?.originId) out = out.filter((issue) => issue.originId === input.originId);
if (input?.status) out = out.filter((issue) => issue.status === input.status);
if (input?.offset) out = out.slice(input.offset);
@ -687,6 +1130,11 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
async create(input) {
requireCapability(manifest, capabilitySet, "issues.create");
const now = new Date();
const originKind = normalizePluginOriginKind(
input.surfaceVisibility === "plugin_operation" && !input.originKind
? pluginOperationIssueOriginKind(manifest.id)
: input.originKind,
);
const record: Issue = {
id: randomUUID(),
companyId: input.companyId,
@ -708,7 +1156,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
createdByUserId: null,
issueNumber: null,
identifier: null,
originKind: normalizePluginOriginKind(input.originKind),
originKind,
originId: input.originId ?? null,
originRunId: input.originRunId ?? null,
requestDepth: input.requestDepth ?? 0,
@ -1064,6 +1512,115 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
}
return { runId: randomUUID() };
},
managed: {
async get(agentKey, companyId) {
requireCapability(manifest, capabilitySet, "agents.managed");
const cid = requireCompanyId(companyId);
managedAgentDeclaration(agentKey);
const agent = [...agents.values()].find((candidate) =>
candidate.companyId === cid &&
candidate.status !== "terminated" &&
isManagedAgent(candidate, agentKey),
) ?? null;
return managedResolution(agentKey, cid, agent, agent ? "resolved" : "missing");
},
async reconcile(agentKey, companyId) {
requireCapability(manifest, capabilitySet, "agents.managed");
const cid = requireCompanyId(companyId);
const declaration = managedAgentDeclaration(agentKey);
const existingAgent = [...agents.values()].find((candidate) =>
candidate.companyId === cid &&
candidate.status !== "terminated" &&
isManagedAgent(candidate, agentKey),
) ?? null;
const existing = managedResolution(agentKey, cid, existingAgent, existingAgent ? "resolved" : "missing");
if (existing.agent) return existing;
const now = new Date();
const created: Agent = {
id: randomUUID(),
companyId: cid,
name: declaration.displayName,
urlKey: declaration.displayName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""),
role: (declaration.role ?? "general") as Agent["role"],
title: declaration.title ?? null,
icon: declaration.icon ?? null,
status: declaration.status ?? "idle",
reportsTo: null,
capabilities: declaration.capabilities ?? null,
adapterType: (declaration.adapterType ?? "process") as Agent["adapterType"],
adapterConfig: declaration.adapterConfig ?? {},
runtimeConfig: declaration.runtimeConfig ?? {},
budgetMonthlyCents: declaration.budgetMonthlyCents ?? 0,
spentMonthlyCents: 0,
pauseReason: null,
pausedAt: null,
permissions: { canCreateAgents: Boolean(declaration.permissions?.canCreateAgents) },
lastHeartbeatAt: null,
metadata: managedAgentMetadata(agentKey),
createdAt: now,
updatedAt: now,
};
agents.set(created.id, created);
return managedResolution(agentKey, cid, created, "created");
},
async reset(agentKey, companyId) {
requireCapability(manifest, capabilitySet, "agents.managed");
const cid = requireCompanyId(companyId);
const declaration = managedAgentDeclaration(agentKey);
let agent = [...agents.values()].find((candidate) =>
candidate.companyId === cid &&
candidate.status !== "terminated" &&
isManagedAgent(candidate, agentKey),
) ?? null;
if (!agent) {
const now = new Date();
agent = {
id: randomUUID(),
companyId: cid,
name: declaration.displayName,
urlKey: declaration.displayName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""),
role: (declaration.role ?? "general") as Agent["role"],
title: declaration.title ?? null,
icon: declaration.icon ?? null,
status: declaration.status ?? "idle",
reportsTo: null,
capabilities: declaration.capabilities ?? null,
adapterType: (declaration.adapterType ?? "process") as Agent["adapterType"],
adapterConfig: declaration.adapterConfig ?? {},
runtimeConfig: declaration.runtimeConfig ?? {},
budgetMonthlyCents: declaration.budgetMonthlyCents ?? 0,
spentMonthlyCents: 0,
pauseReason: null,
pausedAt: null,
permissions: { canCreateAgents: Boolean(declaration.permissions?.canCreateAgents) },
lastHeartbeatAt: null,
metadata: managedAgentMetadata(agentKey),
createdAt: now,
updatedAt: now,
};
agents.set(agent.id, agent);
}
const resolved = managedResolution(agentKey, cid, agent, "resolved");
if (!resolved.agent) return resolved;
const updated: Agent = {
...resolved.agent,
name: declaration.displayName,
role: (declaration.role ?? "general") as Agent["role"],
title: declaration.title ?? null,
icon: declaration.icon ?? null,
capabilities: declaration.capabilities ?? null,
adapterType: (declaration.adapterType ?? "process") as Agent["adapterType"],
adapterConfig: declaration.adapterConfig ?? {},
runtimeConfig: declaration.runtimeConfig ?? {},
budgetMonthlyCents: declaration.budgetMonthlyCents ?? 0,
permissions: { canCreateAgents: Boolean(declaration.permissions?.canCreateAgents) },
metadata: managedAgentMetadata(agentKey, resolved.agent.metadata),
updatedAt: new Date(),
};
agents.set(updated.id, updated);
return managedResolution(agentKey, cid, updated, "reset");
},
},
sessions: {
async create(agentId, companyId, opts) {
requireCapability(manifest, capabilitySet, "agent.sessions.create");

View file

@ -28,6 +28,12 @@ import type {
RequestConfirmationInteraction,
CreateIssueThreadInteraction,
PluginIssueOriginKind,
IssueSurfaceVisibility,
PluginManagedAgentResolution,
PluginManagedProjectResolution,
PluginManagedRoutineResolution,
Routine,
RoutineRun,
Agent,
Goal,
} from "@paperclipai/shared";
@ -42,6 +48,18 @@ export type {
PluginWebhookDeclaration,
PluginToolDeclaration,
PluginEnvironmentDriverDeclaration,
PluginManagedAgentDeclaration,
PluginManagedAgentResolution,
PluginManagedProjectDeclaration,
PluginManagedProjectResolution,
PluginManagedRoutineDeclaration,
PluginManagedRoutineResolution,
Routine,
RoutineRun,
PluginLocalFolderDeclaration,
PluginCompanySettings,
PluginManagedResourceKind,
PluginManagedResourceRef,
PluginUiSlotDeclaration,
PluginUiDeclaration,
PluginLauncherActionDeclaration,
@ -92,6 +110,7 @@ export type {
RequestConfirmationInteraction,
CreateIssueThreadInteraction,
PluginIssueOriginKind,
IssueSurfaceVisibility,
Agent,
Goal,
} from "@paperclipai/shared";
@ -349,6 +368,90 @@ export interface PluginConfigClient {
get(): Promise<Record<string, unknown>>;
}
export interface PluginLocalFolderProblem {
code:
| "not_configured"
| "not_absolute"
| "missing"
| "not_directory"
| "not_readable"
| "not_writable"
| "missing_directory"
| "missing_file"
| "path_traversal"
| "symlink_escape"
| "atomic_write_failed";
message: string;
path?: string;
}
export interface PluginLocalFolderStatus {
folderKey: string;
configured: boolean;
path: string | null;
realPath: string | null;
access: "read" | "readWrite";
readable: boolean;
writable: boolean;
requiredDirectories: string[];
requiredFiles: string[];
missingDirectories: string[];
missingFiles: string[];
healthy: boolean;
problems: PluginLocalFolderProblem[];
checkedAt: string;
}
export interface PluginLocalFolderConfigureInput {
companyId: string;
folderKey: string;
path: string;
access?: "read" | "readWrite";
requiredDirectories?: string[];
requiredFiles?: string[];
}
export interface PluginLocalFolderListOptions {
relativePath?: string | null;
recursive?: boolean;
maxEntries?: number;
}
export interface PluginLocalFolderEntry {
path: string;
name: string;
kind: "file" | "directory";
size: number | null;
modifiedAt: string | null;
}
export interface PluginLocalFolderListing {
folderKey: string;
relativePath: string | null;
entries: PluginLocalFolderEntry[];
truncated: boolean;
}
export interface PluginLocalFoldersClient {
/** Manifest-declared local folders for this plugin. */
declarations(): import("@paperclipai/shared").PluginLocalFolderDeclaration[];
/** Persist a company-scoped local folder path after validating it. */
configure(input: PluginLocalFolderConfigureInput): Promise<PluginLocalFolderStatus>;
/** Check the stored folder readiness for a company and folder key. */
status(companyId: string, folderKey: string): Promise<PluginLocalFolderStatus>;
/** List entries below a configured folder after containment checks. */
list(companyId: string, folderKey: string, options?: PluginLocalFolderListOptions): Promise<PluginLocalFolderListing>;
/** Read a UTF-8 text file below a configured folder after containment checks. */
readText(companyId: string, folderKey: string, relativePath: string): Promise<string>;
/** Write a UTF-8 text file below a configured folder using atomic rename. */
writeTextAtomic(
companyId: string,
folderKey: string,
relativePath: string,
contents: string,
): Promise<PluginLocalFolderStatus>;
}
/**
* `ctx.events` subscribe to and emit Paperclip domain events.
*
@ -697,6 +800,44 @@ export interface PluginProjectsClient {
* @see PLUGIN_SPEC.md §20 Local Tooling
*/
getWorkspaceForIssue(issueId: string, companyId: string): Promise<PluginWorkspace | null>;
/** Resolve and reconcile manifest-declared plugin-managed projects by stable key. Requires `projects.managed`. */
managed: {
get(projectKey: string, companyId: string): Promise<PluginManagedProjectResolution>;
reconcile(projectKey: string, companyId: string): Promise<PluginManagedProjectResolution>;
reset(projectKey: string, companyId: string): Promise<PluginManagedProjectResolution>;
};
}
/**
* `ctx.routines` resolve and reconcile plugin-managed Paperclip routines.
*
* Requires `routines.managed` capability.
*/
export interface PluginRoutinesClient {
managed: {
get(routineKey: string, companyId: string): Promise<PluginManagedRoutineResolution>;
reconcile(
routineKey: string,
companyId: string,
overrides?: { assigneeAgentId?: string | null; projectId?: string | null },
): Promise<PluginManagedRoutineResolution>;
reset(
routineKey: string,
companyId: string,
overrides?: { assigneeAgentId?: string | null; projectId?: string | null },
): Promise<PluginManagedRoutineResolution>;
update(
routineKey: string,
companyId: string,
patch: { status?: string },
): Promise<Routine>;
run(
routineKey: string,
companyId: string,
overrides?: { assigneeAgentId?: string | null; projectId?: string | null },
): Promise<RoutineRun>;
};
}
/**
@ -1099,8 +1240,10 @@ export interface PluginIssuesClient {
projectId?: string;
assigneeAgentId?: string;
originKind?: PluginIssueOriginKind;
originKindPrefix?: string;
originId?: string;
status?: Issue["status"];
includePluginOperations?: boolean;
limit?: number;
offset?: number;
}): Promise<Issue[]>;
@ -1119,6 +1262,7 @@ export interface PluginIssuesClient {
assigneeUserId?: string | null;
requestDepth?: number;
billingCode?: string | null;
surfaceVisibility?: IssueSurfaceVisibility;
originKind?: PluginIssueOriginKind;
originId?: string | null;
originRunId?: string | null;
@ -1241,6 +1385,12 @@ export interface PluginAgentsClient {
resume(agentId: string, companyId: string): Promise<Agent>;
/** Invoke (wake up) an agent with a prompt payload. Throws if paused, terminated, pending_approval, or not found. Requires `agents.invoke`. */
invoke(agentId: string, companyId: string, opts: { prompt: string; reason?: string }): Promise<{ runId: string }>;
/** Resolve and reconcile manifest-declared plugin-managed agents by stable key. Requires `agents.managed`. */
managed: {
get(agentKey: string, companyId: string): Promise<PluginManagedAgentResolution>;
reconcile(agentKey: string, companyId: string): Promise<PluginManagedAgentResolution>;
reset(agentKey: string, companyId: string): Promise<PluginManagedAgentResolution>;
};
/** Create, message, and close agent chat sessions. Requires `agent.sessions.*` capabilities. */
sessions: PluginAgentSessionsClient;
}
@ -1436,6 +1586,9 @@ export interface PluginContext {
/** Read resolved operator configuration. */
config: PluginConfigClient;
/** Configure and safely access trusted company-scoped local folders. */
localFolders: PluginLocalFoldersClient;
/** Subscribe to and emit domain events. Requires `events.subscribe` / `events.emit`. */
events: PluginEventsClient;
@ -1466,6 +1619,9 @@ export interface PluginContext {
/** Read project and workspace metadata. Requires `projects.read` / `project.workspaces.read`. */
projects: PluginProjectsClient;
/** Resolve and reconcile plugin-managed routines. Requires `routines.managed`. */
routines: PluginRoutinesClient;
/** Read company metadata. Requires `companies.read`. */
companies: PluginCompaniesClient;

View file

@ -125,6 +125,36 @@ export interface TimeseriesChartProps {
export interface MarkdownBlockProps {
/** Markdown content to render. */
content: string;
/** Optional CSS class name forwarded to the host renderer. */
className?: string;
/** Opt into Obsidian-style [[target]] / [[target|label]] wikilinks. */
enableWikiLinks?: boolean;
/** Base href used for wikilinks when no resolver is supplied. */
wikiLinkRoot?: string;
/** Optional href resolver for wikilinks. Return null to leave a token as plain text. */
resolveWikiLinkHref?: (target: string, label: string) => string | null | undefined;
}
/** Props for `MarkdownEditor`. */
export interface MarkdownEditorProps {
/** Markdown source controlled by the plugin. */
value: string;
/** Called whenever the markdown source changes. */
onChange: (value: string) => void;
/** Placeholder text shown when the document is empty. */
placeholder?: string;
/** Optional wrapper CSS class name. */
className?: string;
/** Optional editable content CSS class name. */
contentClassName?: string;
/** Called when the editor loses focus. */
onBlur?: () => void;
/** Render the editor with a host border treatment. */
bordered?: boolean;
/** Render the rich editor without allowing edits. */
readOnly?: boolean;
/** Called on Cmd/Ctrl+Enter. */
onSubmit?: () => void;
}
/** A single key-value pair for `KeyValueList`. */
@ -217,6 +247,211 @@ export interface ErrorBoundaryProps {
onError?: (error: Error, info: React.ErrorInfo) => void;
}
/** File or directory node rendered by `FileTree`. */
export interface FileTreeNode {
/** Display name for this path segment. */
name: string;
/** Slash-separated path relative to the tree root. */
path: string;
/** Whether this node is a directory or file. */
kind: "dir" | "file";
/** Child nodes. Files should use an empty array. */
children: FileTreeNode[];
/** Optional stable action metadata for host/plugin workflows. */
action?: string | null;
}
/** Badge status variants supported by `FileTree`. */
export type FileTreeBadgeVariant = "ok" | "warning" | "error" | "info" | "pending";
/** Serializable badge metadata keyed by file path. */
export interface FileTreeBadge {
label: string;
status: FileTreeBadgeVariant;
tooltip?: string;
}
/** Row tone variants supported by `FileTree`. */
export type FileTreeTone = "default" | "warning" | "error" | "muted";
/** Empty-state content shown when a tree has no nodes. */
export interface FileTreeEmptyState {
title?: string;
description?: string;
}
/** Error-state content shown when a tree cannot be loaded. */
export interface FileTreeErrorState {
message: string;
retry?: () => void;
}
/** Accepted path collection shape for expanded and checked file tree state. */
export type FileTreePathCollection = ReadonlySet<string> | readonly string[];
/** Props for `FileTree`. */
export interface FileTreeProps {
/** Tree nodes to render. */
nodes: FileTreeNode[];
/** Currently selected file path. */
selectedFile?: string | null;
/** Expanded directory paths. */
expandedPaths?: FileTreePathCollection;
/** Checked file paths. */
checkedPaths?: FileTreePathCollection;
/** Called when a directory row is toggled. */
onToggleDir?: (path: string) => void;
/** Called when a file row is selected. */
onSelectFile?: (path: string) => void;
/** Called when a checkbox is toggled. */
onToggleCheck?: (path: string, kind: "file" | "dir") => void;
/** Badge metadata keyed by path. */
fileBadges?: Record<string, FileTreeBadge | undefined>;
/** Row tone metadata keyed by path. */
fileTones?: Record<string, FileTreeTone | undefined>;
/** Whether to render checkboxes. Defaults to false for plugin UIs. */
showCheckboxes?: boolean;
/** Allow long file and directory names to wrap. */
wrapLabels?: boolean;
/** Render a loading skeleton instead of nodes. */
loading?: boolean;
/** Render a structured error state instead of nodes. */
error?: FileTreeErrorState | null;
/** Empty state content. */
empty?: FileTreeEmptyState;
/** Accessible label for the tree. */
ariaLabel?: string;
}
export interface IssuesListFilters {
status?: string;
projectId?: string;
parentId?: string;
assigneeAgentId?: string;
participantAgentId?: string;
assigneeUserId?: string;
labelId?: string;
workspaceId?: string;
executionWorkspaceId?: string;
originKind?: string;
originKindPrefix?: string;
originId?: string;
descendantOf?: string;
includeRoutineExecutions?: boolean;
}
export interface IssuesListProps {
companyId: string | null;
projectId?: string | null;
filters?: IssuesListFilters;
viewStateKey?: string;
initialSearch?: string;
createIssueLabel?: string;
searchWithinLoadedIssues?: boolean;
}
export interface AssigneePickerSelection {
assigneeAgentId: string | null;
assigneeUserId: string | null;
}
export interface AssigneePickerProps {
/** Company whose agents and users should be listed. Defaults to host context. */
companyId?: string | null;
/** Controlled value. Use `agent:<id>`, `user:<id>`, or an empty string. */
value: string;
/** Called with the encoded value plus parsed assignee IDs. */
onChange: (value: string, selection: AssigneePickerSelection) => void;
/** Button placeholder when no assignee is selected. */
placeholder?: string;
/** Label for the empty option. */
noneLabel?: string;
/** Search input placeholder. */
searchPlaceholder?: string;
/** Empty search result message. */
emptyMessage?: string;
/** Include active board users alongside agents. Defaults to true. */
includeUsers?: boolean;
/** Include terminated agents. Defaults to false. */
includeTerminatedAgents?: boolean;
/** CSS class forwarded to the trigger button. */
className?: string;
/** Called after the user confirms a selection with Enter, Tab, or click. */
onConfirm?: () => void;
}
export interface ProjectPickerProps {
/** Company whose projects should be listed. Defaults to host context. */
companyId?: string | null;
/** Controlled project id, or an empty string for no project. */
value: string;
/** Called with the selected project id. Empty string means no project. */
onChange: (projectId: string) => void;
/** Button placeholder when no project is selected. */
placeholder?: string;
/** Label for the empty option. */
noneLabel?: string;
/** Search input placeholder. */
searchPlaceholder?: string;
/** Empty search result message. */
emptyMessage?: string;
/** Include archived projects. Defaults to false. */
includeArchived?: boolean;
/** CSS class forwarded to the trigger button. */
className?: string;
/** Called after the user confirms a selection with Enter, Tab, or click. */
onConfirm?: () => void;
}
export interface ManagedRoutinesListAgent {
id: string;
name: string;
icon?: string | null;
}
export interface ManagedRoutinesListProject {
id: string;
name: string;
color?: string | null;
}
export interface ManagedRoutineMissingRef {
resourceKind: string;
resourceKey: string;
}
export interface ManagedRoutinesListItem {
key: string;
title: string;
status: string;
routineId?: string | null;
href?: string | null;
resourceKey?: string | null;
projectId?: string | null;
assigneeAgentId?: string | null;
cronExpression?: string | null;
lastRunAt?: Date | string | null;
lastRunStatus?: string | null;
managedByPluginDisplayName?: string | null;
missingRefs?: ManagedRoutineMissingRef[];
}
export interface ManagedRoutinesListProps {
routines: ManagedRoutinesListItem[];
agents?: ManagedRoutinesListAgent[];
projects?: ManagedRoutinesListProject[];
pluginDisplayName?: string | null;
emptyMessage?: string;
runningRoutineKey?: string | null;
statusMutationRoutineKey?: string | null;
reconcilingRoutineKey?: string | null;
resettingRoutineKey?: string | null;
onRunNow?: (routine: ManagedRoutinesListItem) => void;
onToggleEnabled?: (routine: ManagedRoutinesListItem, enabled: boolean) => void;
onReconcile?: (routine: ManagedRoutinesListItem) => void;
onReset?: (routine: ManagedRoutinesListItem) => void;
}
// ---------------------------------------------------------------------------
// Component declarations (provided by host at runtime)
// ---------------------------------------------------------------------------
@ -266,6 +501,13 @@ export const TimeseriesChart = createSdkUiComponent<TimeseriesChartProps>("Times
*/
export const MarkdownBlock = createSdkUiComponent<MarkdownBlockProps>("MarkdownBlock");
/**
* Renders Paperclip's shared Markdown editor.
*
* @see PLUGIN_SPEC.md §19.6 Shared Components
*/
export const MarkdownEditor = createSdkUiComponent<MarkdownEditorProps>("MarkdownEditor");
/**
* Renders a definition-list of label/value pairs.
*
@ -308,3 +550,40 @@ export const Spinner = createSdkUiComponent<SpinnerProps>("Spinner");
* @see PLUGIN_SPEC.md §19.7 Error Propagation Through The Bridge
*/
export const ErrorBoundary = createSdkUiComponent<ErrorBoundaryProps>("ErrorBoundary");
/**
* Renders the host file tree component with a stable plugin-safe prop surface.
*
* @example
* ```tsx
* import { FileTree, type FileTreeNode } from "@paperclipai/plugin-sdk/ui";
*
* const nodes: FileTreeNode[] = [
* { name: "README.md", path: "README.md", kind: "file", children: [] },
* ];
*
* <FileTree nodes={nodes} onSelectFile={(path) => console.log(path)} />;
* ```
*/
export const FileTree = createSdkUiComponent<FileTreeProps>("FileTree");
/**
* Renders Paperclip's native issue list component for company-scoped plugin
* pages that need a standard board issue view.
*/
export const IssuesList = createSdkUiComponent<IssuesListProps>("IssuesList");
/**
* Renders the same host assignee picker used by the new issue pane.
*/
export const AssigneePicker = createSdkUiComponent<AssigneePickerProps>("AssigneePicker");
/**
* Renders the same host project picker used by the new issue pane.
*/
export const ProjectPicker = createSdkUiComponent<ProjectPickerProps>("ProjectPicker");
/**
* Renders Paperclip's native managed routines list for plugin settings pages.
*/
export const ManagedRoutinesList = createSdkUiComponent<ManagedRoutinesListProps>("ManagedRoutinesList");

View file

@ -1,6 +1,8 @@
import type {
PluginDataResult,
PluginActionFn,
HostLocation,
HostNavigation,
PluginHostContext,
PluginStreamResult,
PluginToastFn,
@ -115,6 +117,57 @@ export function useHostContext(): PluginHostContext {
return impl();
}
// ---------------------------------------------------------------------------
// useHostNavigation
// ---------------------------------------------------------------------------
/**
* Navigate within the Paperclip host without forcing a full document reload.
*
* Use `linkProps()` for links so browser-native behavior still works:
* modifier-click, middle-click, copy-link, and open-in-new-tab all use the
* returned real `href`.
*
* @example
* ```tsx
* function WikiSidebarLink() {
* const hostNavigation = useHostNavigation();
* return <a {...hostNavigation.linkProps("/wiki")}>Wiki</a>;
* }
* ```
*/
export function useHostNavigation(): HostNavigation {
const impl = getSdkUiRuntimeValue<() => HostNavigation>("useHostNavigation");
return impl();
}
// ---------------------------------------------------------------------------
// useHostLocation
// ---------------------------------------------------------------------------
/**
* Observe the current host router location.
*
* Returns a snapshot of the active `pathname`, `search`, and `hash`. The
* component re-renders when any of these change (e.g. after the host router
* pushes a new entry, or after the browser back/forward gestures). Use this
* for URL-driven plugin UI such as a takeover sidebar with section-aware
* active state.
*
* @example
* ```tsx
* function WikiSection() {
* const { pathname } = useHostLocation();
* const section = pathname.split("/").filter(Boolean).at(-1) ?? "wiki";
* return <div>Active section: {section}</div>;
* }
* ```
*/
export function useHostLocation(): HostLocation {
const impl = getSdkUiRuntimeValue<() => HostLocation>("useHostLocation");
return impl();
}
// ---------------------------------------------------------------------------
// usePluginStream
// ---------------------------------------------------------------------------

View file

@ -43,20 +43,89 @@
* - `usePluginData(key, params)` fetch data from the worker's `getData` handler
* - `usePluginAction(key)` get a callable that invokes the worker's `performAction` handler
* - `useHostContext()` read the current active company, project, entity, and user IDs
* - `useHostNavigation()` navigate Paperclip-internal links through the host router
* - `useHostLocation()` observe the current host pathname/search/hash for URL-driven UI
* - `usePluginStream(channel)` subscribe to real-time SSE events from the worker
*/
export {
usePluginData,
usePluginAction,
useHostContext,
useHostNavigation,
useHostLocation,
usePluginStream,
usePluginToast,
} from "./hooks.js";
export {
MetricCard,
StatusBadge,
DataTable,
TimeseriesChart,
MarkdownBlock,
MarkdownEditor,
KeyValueList,
ActionBar,
LogView,
JsonTree,
Spinner,
ErrorBoundary,
FileTree,
IssuesList,
AssigneePicker,
ProjectPicker,
ManagedRoutinesList,
} from "./components.js";
export type {
MetricTrend,
MetricCardProps,
StatusBadgeVariant,
StatusBadgeProps,
DataTableColumn,
DataTableProps,
TimeseriesDataPoint,
TimeseriesChartProps,
MarkdownBlockProps,
MarkdownEditorProps,
KeyValuePair,
KeyValueListProps,
ActionBarItem,
ActionBarProps,
LogViewEntry,
LogViewProps,
JsonTreeProps,
SpinnerProps,
ErrorBoundaryProps,
FileTreeNode,
FileTreeBadgeVariant,
FileTreeBadge,
FileTreeTone,
FileTreeEmptyState,
FileTreeErrorState,
FileTreePathCollection,
FileTreeProps,
IssuesListFilters,
IssuesListProps,
AssigneePickerSelection,
AssigneePickerProps,
ProjectPickerProps,
ManagedRoutineMissingRef,
ManagedRoutinesListAgent,
ManagedRoutinesListItem,
ManagedRoutinesListProject,
ManagedRoutinesListProps,
} from "./components.js";
// Bridge error and host context types
export type {
PluginBridgeError,
PluginBridgeErrorCode,
HostNavigation,
HostNavigationOptions,
HostNavigationLinkOptions,
HostNavigationLinkProps,
HostLocation,
PluginHostContext,
PluginModalBoundsRequest,
PluginRenderCloseEvent,
@ -80,6 +149,7 @@ export type {
PluginWidgetProps,
PluginDetailTabProps,
PluginSidebarProps,
PluginRouteSidebarProps,
PluginProjectSidebarItemProps,
PluginCommentAnnotationProps,
PluginCommentContextMenuItemProps,

View file

@ -14,6 +14,10 @@
* @see PLUGIN_SPEC.md §29.2 SDK Versioning
*/
import type {
AnchorHTMLAttributes,
MouseEvent as ReactMouseEvent,
} from "react";
import type {
PluginBridgeErrorCode,
PluginLauncherBounds,
@ -131,6 +135,83 @@ export interface PluginRenderEnvironmentContext
closeLifecycle?: PluginRenderCloseLifecycle | null;
}
// ---------------------------------------------------------------------------
// Host navigation
// ---------------------------------------------------------------------------
/**
* Options for host-managed Paperclip navigation from plugin UI.
*/
export interface HostNavigationOptions {
/** Replace the current history entry instead of pushing a new one. */
replace?: boolean;
/** Optional state forwarded to the host router. */
state?: unknown;
}
/**
* Options for `useHostNavigation().linkProps()`.
*/
export interface HostNavigationLinkOptions extends HostNavigationOptions {
/** Standard anchor target. Non-`_self` targets are not intercepted. */
target?: AnchorHTMLAttributes<HTMLAnchorElement>["target"];
/** Standard anchor rel attribute. */
rel?: AnchorHTMLAttributes<HTMLAnchorElement>["rel"];
}
/**
* Anchor props returned by `useHostNavigation().linkProps()`.
*
* The `href` is always real so browser affordances such as copy-link,
* modifier-click, middle-click, and open-in-new-tab continue to work.
*/
export interface HostNavigationLinkProps
extends Pick<AnchorHTMLAttributes<HTMLAnchorElement>, "href" | "target" | "rel"> {
onClick: (event: ReactMouseEvent<HTMLAnchorElement>) => void;
}
/**
* Snapshot of the host router location, exposed to plugin UI through
* `useHostLocation()`. Mirrors the relevant subset of `Location` from
* `react-router-dom` so plugins can react to URL changes without importing
* router internals.
*
* @see PLUGIN_SPEC.md §19 UI Extension Model
*/
export interface HostLocation {
/** Current pathname, e.g. `/PAP/wiki`. */
pathname: string;
/** Current search string, e.g. `?tab=config` (includes the leading `?`). */
search: string;
/** Current hash, e.g. `#document-plan` (includes the leading `#`). */
hash: string;
/** Optional state forwarded by the host router for same-tab SPA navigation. */
state?: unknown;
}
/**
* Host-managed navigation helpers for plugin UI.
*/
export interface HostNavigation {
/**
* Resolve a Paperclip-internal path using the active company prefix.
*
* For example, in company `PAP`, `resolveHref("/wiki")` returns
* `"/PAP/wiki"`, while `resolveHref("/PAP/wiki")` stays unchanged.
*/
resolveHref(to: string): string;
/** Navigate through the host router without reloading the document. */
navigate(to: string, options?: HostNavigationOptions): void;
/**
* Build anchor props for host-managed links.
*
* Plain left-clicks are routed through the host SPA router. Browser-native
* link gestures are left alone because the returned props include a real
* `href`.
*/
linkProps(to: string, options?: HostNavigationLinkOptions): HostNavigationLinkProps;
}
// ---------------------------------------------------------------------------
// Slot component prop interfaces
// ---------------------------------------------------------------------------
@ -188,6 +269,19 @@ export interface PluginSidebarProps {
context: PluginHostContext;
}
/**
* Props passed to a plugin route sidebar component.
*
* A route sidebar replaces the normal company sidebar while the user is on a
* matching plugin page route declared with the same `routePath`.
*
* @see PLUGIN_SPEC.md §19.5 Sidebar Entries
*/
export interface PluginRouteSidebarProps {
/** The current host context. */
context: PluginHostContext;
}
/**
* Props passed to a plugin project sidebar item component.
*

View file

@ -387,6 +387,51 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
},
},
localFolders: {
declarations() {
if (!manifest) throw new Error("Plugin context accessed before initialization");
return manifest.localFolders ?? [];
},
async configure(input) {
return callHost("localFolders.configure", {
companyId: input.companyId,
folderKey: input.folderKey,
path: input.path,
access: input.access,
requiredDirectories: input.requiredDirectories,
requiredFiles: input.requiredFiles,
});
},
async status(companyId: string, folderKey: string) {
return callHost("localFolders.status", { companyId, folderKey });
},
async list(companyId: string, folderKey: string, options = {}) {
return callHost("localFolders.list", {
companyId,
folderKey,
relativePath: options.relativePath,
recursive: options.recursive,
maxEntries: options.maxEntries,
});
},
async readText(companyId: string, folderKey: string, relativePath: string) {
return callHost("localFolders.readText", { companyId, folderKey, relativePath });
},
async writeTextAtomic(companyId: string, folderKey: string, relativePath: string, contents: string) {
return callHost("localFolders.writeTextAtomic", {
companyId,
folderKey,
relativePath,
contents,
});
},
},
events: {
on(
name: string,
@ -580,6 +625,50 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
async getWorkspaceForIssue(issueId: string, companyId: string) {
return callHost("projects.getWorkspaceForIssue", { issueId, companyId });
},
managed: {
async get(projectKey: string, companyId: string) {
return callHost("projects.managed.get", { projectKey, companyId });
},
async reconcile(projectKey: string, companyId: string) {
return callHost("projects.managed.reconcile", { projectKey, companyId });
},
async reset(projectKey: string, companyId: string) {
return callHost("projects.managed.reset", { projectKey, companyId });
},
},
},
routines: {
managed: {
async get(routineKey: string, companyId: string) {
return callHost("routines.managed.get", { routineKey, companyId });
},
async reconcile(
routineKey: string,
companyId: string,
overrides?: { assigneeAgentId?: string | null; projectId?: string | null },
) {
return callHost("routines.managed.reconcile", { routineKey, companyId, ...overrides });
},
async reset(
routineKey: string,
companyId: string,
overrides?: { assigneeAgentId?: string | null; projectId?: string | null },
) {
return callHost("routines.managed.reset", { routineKey, companyId, ...overrides });
},
async update(routineKey: string, companyId: string, patch: { status?: string }) {
return callHost("routines.managed.update", { routineKey, companyId, ...patch });
},
async run(
routineKey: string,
companyId: string,
overrides?: { assigneeAgentId?: string | null; projectId?: string | null },
) {
return callHost("routines.managed.run", { routineKey, companyId, ...overrides });
},
},
},
companies: {
@ -602,8 +691,10 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
projectId: input.projectId,
assigneeAgentId: input.assigneeAgentId,
originKind: input.originKind,
originKindPrefix: input.originKindPrefix,
originId: input.originId,
status: input.status,
includePluginOperations: input.includePluginOperations,
limit: input.limit,
offset: input.offset,
});
@ -628,6 +719,7 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
assigneeUserId: input.assigneeUserId,
requestDepth: input.requestDepth,
billingCode: input.billingCode,
surfaceVisibility: input.surfaceVisibility,
originKind: input.originKind,
originId: input.originId,
originRunId: input.originRunId,
@ -863,6 +955,20 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
return callHost("agents.invoke", { agentId, companyId, prompt: opts.prompt, reason: opts.reason });
},
managed: {
async get(agentKey: string, companyId: string) {
return callHost("agents.managed.get", { agentKey, companyId });
},
async reconcile(agentKey: string, companyId: string) {
return callHost("agents.managed.reconcile", { agentKey, companyId });
},
async reset(agentKey: string, companyId: string) {
return callHost("agents.managed.reset", { agentKey, companyId });
},
},
sessions: {
async create(agentId: string, companyId: string, opts?: { taskKey?: string; reason?: string }) {
return callHost("agents.sessions.create", {

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) {