mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
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:
parent
d6bee62f02
commit
3c73ed26b5
89 changed files with 27516 additions and 914 deletions
|
|
@ -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:*`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 project’s 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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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", {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue