Expand plugin host surface (#5205)

## Thinking Path

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

## What Changed

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

## Verification

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

## Risks

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

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

## Model Used

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

## Checklist

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

---------

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

View file

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