Enforce shared, client, and server plugin SDK boundaries (#4347)

* fix(plugins): keep React out of the server plugin host

* fix(plugins): align SDK guidance and tests with runtime boundaries

* fix(plugin): loosen the react peer to ~19.1.0

The plugin SDK pins its react peer to exactly 19.1.0, and that pin is
what a downstream `npm i -g @getpaseo/cli` resolves against. So every
user ends up with react 19.1.0 in their global node_modules, where
corporate dependency scanners flag it: the CVE-2025-55182 advisory range
covers 19.1.0 even though the vulnerability itself lives in
react-server-dom-webpack/-parcel/-turbopack, none of which the CLI
installs. The user is then left hand-patching node_modules or
uninstalling Paseo.

Widening the peer to ~19.1.0 lets a downstream install pick the latest
19.1.x patch (19.1.9 today) while this repo stays on 19.1.0, which the
root override still pins.

Keeping the repo itself on 19.1.0 is deliberate. react-native 0.81.5
embeds a 19.1.0 Paper renderer that hard-throws "Incompatible React
versions" on any exact mismatch, and it is reachable from Fabric builds
too: RendererImplementation.js requires the Paper shim unconditionally
in findNodeHandle, unstable_batchedUpdates, sendAccessibilityEvent and
three other entry points, and react-native-gesture-handler re-exports
findNodeHandle. Bumping the app's react is therefore not safe until
react-native moves.

The published CLI tree contains no react-native, so downstream installs
are not subject to that constraint.

Supersedes the earlier 19.1.5/19.1.9 version-bump approach on this
branch, which would have broken the mobile app.

* Enforce example import ownership including client types

* Update Nix dependency hash for plugin peer metadata

* Group plugin APIs under client and server entry trees

* Resolve plugin type dependencies through declarations

* Validate declarations for implicit plugin type imports

* Preserve optional imports and validate declaration references

* Refresh Nix hash for plugin declaration resolver

* Canonicalize plugin paths consistently on Windows

* Normalize plugin ownership paths across resolver boundaries

---------

Co-authored-by: liujin0506 <liujin0506@qq.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
Rafał Lindemann
2026-09-07 23:42:28 +02:00
committed by GitHub
co-authored by liujin0506 Mohamed Boudra
parent d779514f10
commit 1f5b6143d6
120 changed files with 1774 additions and 929 deletions
+7
View File
@@ -1,4 +1,11 @@
import { z } from "zod";
import type { PluginAttachmentSourceContribution } from "./contracts.js";
export function defineAttachmentSource<Definition extends PluginAttachmentSourceContribution>(
definition: Definition,
): Definition {
return definition;
}
export const PluginAttachmentItemSchema = z.object({
id: z.string(),
+123
View File
@@ -0,0 +1,123 @@
import { existsSync, readFileSync, readdirSync } from "node:fs";
import path from "node:path";
import { isBuiltin } from "node:module";
import { fileURLToPath } from "node:url";
import ts from "typescript";
import { describe, expect, it } from "vitest";
const sourceDirectory = path.dirname(fileURLToPath(import.meta.url));
const entries = {
".": "shared",
"./server": "server",
"./server/provider": "server",
"./server/acp": "server",
"./client": "client",
"./client/host": "client",
"./client/react-native": "client",
"./client/ui": "client",
} as const;
const uiDependency = /^(react(?:-dom|-native)?|use-sync-external-store)(\/|$)/;
const serverModule = /^server\//;
const clientModule = /^client\//;
function resolveLocal(importer: string, specifier: string): string {
const base = path.resolve(path.dirname(importer), specifier.replace(/\.js$/, ""));
const result = [base + ".ts", base + ".tsx"].find(existsSync);
if (!result) throw new Error(`Cannot resolve ${specifier} from ${importer}`);
return result;
}
function boundaryViolations(entry: string, runtime: "shared" | "server" | "client"): string[] {
const pending = [entry];
const visited = new Set<string>();
const violations: string[] = [];
while (pending.length) {
const file = pending.pop()!;
if (visited.has(file)) continue;
visited.add(file);
// Includes type imports, re-exports, import types, and literal dynamic imports.
const imports = ts.preProcessFile(readFileSync(file, "utf8"), true, true).importedFiles;
for (const { fileName: specifier } of imports) {
const label = `${path.relative(sourceDirectory, file)} -> ${specifier}`;
if (specifier.startsWith(".")) {
const local = resolveLocal(file, specifier);
const module = path
.relative(sourceDirectory, local)
.replace(/\\/g, "/")
.replace(/\.tsx?$/, ".js");
if (runtime !== "client" && clientModule.test(module)) violations.push(label);
if (runtime !== "server" && serverModule.test(module)) violations.push(label);
pending.push(local);
} else if (runtime === "shared") {
if (specifier !== "zod" && specifier !== "@getpaseo/protocol/agent-types")
violations.push(label);
} else if (runtime === "server") {
if (uiDependency.test(specifier) || /^@getpaseo\/plugin\/client(\/|$)/.test(specifier))
violations.push(label);
} else if (isBuiltin(specifier) || /^@getpaseo\/plugin\/server(\/|$)/.test(specifier)) {
violations.push(label);
}
}
}
return violations;
}
describe("plugin SDK import boundaries", () => {
it("classifies every published entry", () => {
const manifest = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
expect(Object.keys(manifest.exports).sort()).toEqual(Object.keys(entries).sort());
});
it.each(Object.entries(entries))(
"%s respects its %s boundary, including type dependencies",
(specifier, runtime) => {
const name =
specifier === "."
? "index"
: specifier.slice(2) +
(specifier === "./client" || specifier === "./server" ? "/index" : "");
expect(boundaryViolations(path.join(sourceDirectory, `${name}.ts`), runtime)).toEqual([]);
},
);
});
describe("plugin example import boundaries", () => {
const examples = path.resolve(sourceDirectory, "../../../plugin-examples");
const files = readdirSync(examples, { recursive: true }).filter((file) =>
/(?<!\.test)\.tsx?$/.test(file),
);
const owner = (file: string) => {
const [, directory] = path.relative(examples, file).split(path.sep);
if (directory?.startsWith("index.client.")) return "client";
if (directory?.startsWith("index.server.")) return "server";
return directory;
};
it.each(files)("%s respects runtime ownership, including type imports", (relative) => {
const file = path.join(examples, relative);
const runtime = owner(file);
expect(["shared", "client", "server"]).toContain(runtime);
const violations: string[] = [];
for (const { fileName: specifier } of ts.preProcessFile(readFileSync(file, "utf8"), true, true)
.importedFiles) {
if (specifier.startsWith(".")) {
const imported = owner(resolveLocal(file, specifier));
if (imported !== "shared" && imported !== runtime) violations.push(specifier);
} else if (specifier.startsWith("@getpaseo/plugin")) {
const entry = specifier.replace("@getpaseo/plugin", ".") as keyof typeof entries;
if (
entry === "./client/host" ||
!(entry in entries) ||
(entries[entry] !== "shared" && entries[entry] !== runtime)
)
violations.push(specifier);
} else if (
(runtime !== "client" && uiDependency.test(specifier)) ||
(runtime !== "server" && isBuiltin(specifier))
) {
violations.push(specifier);
}
}
expect(violations).toEqual([]);
});
});
@@ -1,6 +1,6 @@
import { createContext, useContext, type ReactNode } from "react";
import { useSyncExternalStoreWithSelector } from "use-sync-external-store/shim/with-selector";
import type { PluginAgentSnapshot, PluginWorkspaceSnapshot } from "./contracts.js";
import type { PluginAgentSnapshot, PluginWorkspaceSnapshot } from "../contracts.js";
import { shallowEqual } from "./shallow.js";
export interface PluginClientStateSource {
+246
View File
@@ -0,0 +1,246 @@
import type { ComponentType } from "react";
import type { PaseoApi } from "@getpaseo/client";
import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
import type { PluginRpcContract } from "../rpc.js";
import type {
PluginTheme,
PluginWorkspaceSnapshot,
PluginAgentSnapshot,
PluginThemeContribution,
PluginAttachmentSourceContribution,
PluginTimelineTransformResult,
PluginCleanup,
} from "../contracts.js";
export interface PluginHostProps {
theme: PluginTheme;
host: {
id: string;
label: string;
};
layout: {
compact: boolean;
platform: "ios" | "android" | "web";
};
}
interface PluginNavigableHostProps extends PluginHostProps {
/** Client-owned navigation. Undefined on older hosts; hide dependent affordances when absent. */
readonly navigation?: {
readonly openAgent: (input: { readonly agentId: string }) => void;
readonly openWorkspace: (input: { readonly workspaceId: string }) => void;
};
}
export interface PluginSurfaceProps extends PluginNavigableHostProps {}
export interface PluginIconProps {
name: string;
size?: number;
color?: string;
}
export type PluginPanelLocation = "workspace" | "explorer";
export interface PluginOpenPanelOptions {
location?: PluginPanelLocation;
}
interface PluginWorkspacePanelBase {
id: string;
title: string;
icon: string;
locations?: readonly PluginPanelLocation[];
}
export interface PluginWorkspacePanelProps extends PluginNavigableHostProps {
context: "workspace";
workspaceId: string;
}
export interface PluginAgentPanelProps extends PluginNavigableHostProps {
context: "agent";
workspaceId: string;
agentId: string;
}
export interface PluginComposerPillProps extends PluginHostProps {
workspaceId: string;
agentId: string;
}
export interface PluginComposerPillContribution {
id: string;
title: string;
workspaceId: string;
agentId: string;
Component: ComponentType<PluginComposerPillProps>;
onPress(): void | Promise<void>;
}
export interface PluginClientOpenPanelOptions extends PluginOpenPanelOptions {
workspaceId: string;
agentId?: string;
}
export interface PluginClientContext extends PluginCommandCapabilities {
addSettingsScreen(contribution: PluginSettingsScreenContribution): PluginCleanup;
addSurface(id: string, Component: ComponentType<PluginSurfaceProps>): PluginCleanup;
addSidebarItem(contribution: PluginSidebarContribution): PluginCleanup;
addWorkspacePanel(contribution: PluginWorkspacePanelContribution): PluginCleanup;
addCommandCenterItem(contribution: PluginCommandCenterItemContribution): PluginCleanup;
addSlashCommand(contribution: PluginClientSlashCommandContribution): PluginCleanup;
addComposerPill(contribution: PluginComposerPillContribution): PluginCleanup;
addAttachmentSource(contribution: PluginAttachmentSourceContribution): PluginCleanup;
addTheme(contribution: PluginThemeContribution): PluginCleanup;
addTimelineTransformer<ItemType extends AgentTimelineItem["type"]>(
contribution: PluginTimelineTransformerContribution<ItemType>,
): PluginCleanup;
addTimelineRenderer<Schema extends ZodType>(
contribution: PluginTimelineRendererContribution<Schema>,
): PluginCleanup;
openPanel(id: string, options: PluginClientOpenPanelOptions): void;
}
export type PluginClientContribution = (client: PluginClientContext) => PluginCleanup;
export type PluginWorkspacePanelContribution =
| (PluginWorkspacePanelBase & {
context: "workspace";
Component: ComponentType<PluginWorkspacePanelProps>;
})
| (PluginWorkspacePanelBase & {
context: "agent";
Component: ComponentType<PluginAgentPanelProps>;
});
export interface PluginSettingsScreenContribution {
id: string;
title: string;
icon: string;
Component: ComponentType<PluginSurfaceProps>;
}
export interface PluginSurfaceContribution {
id: string;
Component: ComponentType<PluginSurfaceProps>;
}
export interface PluginSidebarContribution {
id: string;
title: string;
icon: string;
surface: string;
}
export type PluginTimelineTransformerContribution<
ItemType extends AgentTimelineItem["type"] = AgentTimelineItem["type"],
> = ItemType extends AgentTimelineItem["type"]
? {
id: string;
query: {
itemType: ItemType;
};
transform(input: {
item: Extract<AgentTimelineItem, { type: ItemType }>;
phase: "streaming" | "complete";
}): PluginTimelineTransformResult | undefined;
}
: never;
export interface PluginTimelineItemProps<Data = unknown> extends PluginHostProps {
agentId: string;
item: {
type: "plugin";
kind: string;
version: number;
data: Data;
};
timestamp: Date;
}
export interface PluginTimelineRendererContribution<Schema extends ZodType = ZodType> {
kind: string;
version: number;
schema: Schema;
Component: ComponentType<PluginTimelineItemProps<ZodOutput<Schema>>>;
}
export interface PluginCommandCapabilities {
paseo: PaseoApi;
rpc<InputSchema extends ZodType, OutputSchema extends ZodType>(
contract: PluginRpcContract<InputSchema, OutputSchema>,
input: ZodInput<InputSchema>,
): Promise<ZodOutput<OutputSchema>>;
openSurface(id: string): void;
openSettings(id: string): void;
}
export interface PluginGlobalCommandContext extends PluginCommandCapabilities {
context: "global";
}
export interface PluginWorkspaceCommandContext extends PluginCommandCapabilities {
context: "workspace";
workspace: PluginWorkspaceSnapshot;
openPanel(id: string, options?: PluginOpenPanelOptions): void;
}
export interface PluginAgentCommandContext extends PluginCommandCapabilities {
context: "agent";
workspace: PluginWorkspaceSnapshot;
agent: PluginAgentSnapshot;
openPanel(id: string, options?: PluginOpenPanelOptions): void;
}
interface PluginCommandCenterItemBase {
id: string;
title: string;
icon: string;
keywords?: readonly string[];
}
export type PluginCommandCenterItemContribution =
| (PluginCommandCenterItemBase & {
context: "global";
onSelect(context: PluginGlobalCommandContext): void | Promise<void>;
})
| (PluginCommandCenterItemBase & {
context: "workspace";
onSelect(context: PluginWorkspaceCommandContext): void | Promise<void>;
})
| (PluginCommandCenterItemBase & {
context: "agent";
onSelect(context: PluginAgentCommandContext): void | Promise<void>;
});
interface PluginClientSlashCommandBase {
name: string;
description: string;
argumentHint: string;
}
export type PluginClientSlashCommandContribution =
| (PluginClientSlashCommandBase & {
context: "workspace";
onSubmit(context: PluginWorkspaceCommandContext & { args: string }): void | Promise<void>;
})
| (PluginClientSlashCommandBase & {
context: "agent";
onSubmit(context: PluginAgentCommandContext & { args: string }): void | Promise<void>;
});
export type SettingsState<Schema extends ZodType> = (
| { status: "loading" }
| { status: "error"; error: string }
| { status: "invalid"; error: string; revision: string }
| { status: "ready"; values: ZodOutput<Schema>; revision: string }
) & {
saving: boolean;
saveError: string | null;
/** Save an entire document against the revision currently displayed. Never throws. */
save(values: ZodOutput<Schema>, revision: string): Promise<boolean>;
reset(): Promise<boolean>;
reload(): Promise<void>;
};
@@ -1,6 +1,6 @@
import { describe, expect, expectTypeOf, it } from "vitest";
import { z } from "zod";
import { defineAttachmentSource, defineRpc, type RpcInput, type RpcOutput } from "./index.js";
import { defineAttachmentSource, defineRpc, type RpcInput, type RpcOutput } from "../index.js";
import { callPluginRpc, searchPluginAttachments } from "./host.js";
const contract = defineRpc({
@@ -1,14 +1,13 @@
export { settingsRpc } from "./settings.js";
import { PluginAttachmentSearchPayloadSchema } from "./attachments.js";
import { PluginAttachmentSearchPayloadSchema } from "../attachments.js";
export { PluginClientStateProvider, type PluginClientStateSource } from "./client-state.js";
export {
usePluginRuntimeContextBridge,
type PluginRuntimeContextBridge,
} from "./runtime-context-bridge.js";
import type { PluginAttachmentSourceContribution } from "./contracts.js";
import type { PluginAttachmentSourceContribution } from "../contracts.js";
import { PluginRpcProvider } from "./rpc-context.js";
import { PaseoApiProvider } from "./paseo-context.js";
import { callPluginRpc } from "./rpc.js";
import { callPluginRpc } from "../rpc.js";
export async function searchPluginAttachments(
source: PluginAttachmentSourceContribution,
+37
View File
@@ -0,0 +1,37 @@
export type {
PluginHostProps,
PluginSurfaceProps,
PluginIconProps,
PluginPanelLocation,
PluginOpenPanelOptions,
PluginWorkspacePanelProps,
PluginAgentPanelProps,
PluginComposerPillProps,
PluginComposerPillContribution,
PluginClientOpenPanelOptions,
PluginClientContext,
PluginClientContribution,
PluginWorkspacePanelContribution,
PluginSettingsScreenContribution,
PluginSurfaceContribution,
PluginSidebarContribution,
PluginTimelineTransformerContribution,
PluginTimelineItemProps,
PluginTimelineRendererContribution,
PluginCommandCapabilities,
PluginGlobalCommandContext,
PluginWorkspaceCommandContext,
PluginAgentCommandContext,
PluginCommandCenterItemContribution,
PluginClientSlashCommandContribution,
SettingsState,
} from "./contracts.js";
export { usePaseo } from "./paseo-context.js";
export { useAgent, useWorkspace } from "./client-state.js";
export { useRpc } from "./rpc-context.js";
import type { SettingsDefinition } from "../settings.js";
import type { SettingsState } from "./contracts.js";
import type { ZodType } from "zod";
export declare function useSettings<Schema extends ZodType>(
definition: SettingsDefinition<Schema>,
): SettingsState<Schema>;
@@ -1,6 +1,6 @@
import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react";
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
import { callPluginRpc, type PluginRpcContract } from "./rpc.js";
import { callPluginRpc, type PluginRpcContract } from "../rpc.js";
export interface PluginRpcContextValue {
invoke(method: string, input: unknown): Promise<unknown>;
+1 -244
View File
@@ -1,10 +1,5 @@
import type { PluginLifecycleRegistration } from "./lifecycle.js";
import type { ComponentType } from "react";
import type { PaseoApi } from "@getpaseo/client";
import type { AgentTimelineItem, JsonValue } from "@getpaseo/protocol/agent-types";
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import type { PluginRpcContract } from "./rpc.js";
import type { ProviderRegistration } from "./provider.js";
export interface PluginTheme {
readonly colors: {
@@ -22,34 +17,6 @@ export interface PluginTheme {
};
}
export interface PluginHostProps {
theme: PluginTheme;
host: {
id: string;
label: string;
};
layout: {
compact: boolean;
platform: "ios" | "android" | "web";
};
}
interface PluginNavigableHostProps extends PluginHostProps {
/** Client-owned navigation. Undefined on older hosts; hide dependent affordances when absent. */
readonly navigation?: {
readonly openAgent: (input: { readonly agentId: string }) => void;
readonly openWorkspace: (input: { readonly workspaceId: string }) => void;
};
}
export interface PluginSurfaceProps extends PluginNavigableHostProps {}
export interface PluginIconProps {
name: string;
size?: number;
color?: string;
}
export interface PluginWorkspaceSnapshot {
readonly id: string;
readonly projectId: string;
@@ -85,99 +52,6 @@ export interface PluginAgentSnapshot {
readonly labels: Readonly<Record<string, string>>;
}
export type PluginPanelLocation = "workspace" | "explorer";
export interface PluginOpenPanelOptions {
location?: PluginPanelLocation;
}
interface PluginWorkspacePanelBase {
id: string;
title: string;
icon: string;
locations?: readonly PluginPanelLocation[];
}
export interface PluginWorkspacePanelProps extends PluginNavigableHostProps {
context: "workspace";
workspaceId: string;
}
export interface PluginAgentPanelProps extends PluginNavigableHostProps {
context: "agent";
workspaceId: string;
agentId: string;
}
export interface PluginComposerPillProps extends PluginHostProps {
workspaceId: string;
agentId: string;
}
export interface PluginComposerPillContribution {
id: string;
title: string;
workspaceId: string;
agentId: string;
Component: ComponentType<PluginComposerPillProps>;
onPress(): void | Promise<void>;
}
export interface PluginClientOpenPanelOptions extends PluginOpenPanelOptions {
workspaceId: string;
agentId?: string;
}
export interface PluginClientContext extends PluginCommandCapabilities {
addSettingsScreen(contribution: PluginSettingsScreenContribution): PluginCleanup;
addSurface(id: string, Component: ComponentType<PluginSurfaceProps>): PluginCleanup;
addSidebarItem(contribution: PluginSidebarContribution): PluginCleanup;
addWorkspacePanel(contribution: PluginWorkspacePanelContribution): PluginCleanup;
addCommandCenterItem(contribution: PluginCommandCenterItemContribution): PluginCleanup;
addSlashCommand(contribution: PluginClientSlashCommandContribution): PluginCleanup;
addComposerPill(contribution: PluginComposerPillContribution): PluginCleanup;
addAttachmentSource(contribution: PluginAttachmentSourceContribution): PluginCleanup;
addTheme(contribution: PluginThemeContribution): PluginCleanup;
addTimelineTransformer<ItemType extends AgentTimelineItem["type"]>(
contribution: PluginTimelineTransformerContribution<ItemType>,
): PluginCleanup;
addTimelineRenderer<Schema extends ZodType>(
contribution: PluginTimelineRendererContribution<Schema>,
): PluginCleanup;
openPanel(id: string, options: PluginClientOpenPanelOptions): void;
}
export type PluginClientContribution = (client: PluginClientContext) => PluginCleanup;
export type PluginWorkspacePanelContribution =
| (PluginWorkspacePanelBase & {
context: "workspace";
Component: ComponentType<PluginWorkspacePanelProps>;
})
| (PluginWorkspacePanelBase & {
context: "agent";
Component: ComponentType<PluginAgentPanelProps>;
});
export interface PluginSettingsScreenContribution {
id: string;
title: string;
icon: string;
Component: ComponentType<PluginSurfaceProps>;
}
export interface PluginSurfaceContribution {
id: string;
Component: ComponentType<PluginSurfaceProps>;
}
export interface PluginSidebarContribution {
id: string;
title: string;
icon: string;
surface: string;
}
export interface PluginThemeColors {
background: string;
foreground: string;
@@ -219,121 +93,4 @@ export interface PluginTimelineTransformResult {
items: PluginTimelineItem[];
}
export type PluginTimelineTransformerContribution<
ItemType extends AgentTimelineItem["type"] = AgentTimelineItem["type"],
> = ItemType extends AgentTimelineItem["type"]
? {
id: string;
query: {
itemType: ItemType;
};
transform(input: {
item: Extract<AgentTimelineItem, { type: ItemType }>;
phase: "streaming" | "complete";
}): PluginTimelineTransformResult | undefined;
}
: never;
export interface PluginTimelineItemProps<Data = unknown> extends PluginHostProps {
agentId: string;
item: {
type: "plugin";
kind: string;
version: number;
data: Data;
};
timestamp: Date;
}
export interface PluginTimelineRendererContribution<Schema extends ZodType = ZodType> {
kind: string;
version: number;
schema: Schema;
Component: ComponentType<PluginTimelineItemProps<ZodOutput<Schema>>>;
}
export interface PluginCommandCapabilities {
paseo: PaseoApi;
rpc<InputSchema extends ZodType, OutputSchema extends ZodType>(
contract: PluginRpcContract<InputSchema, OutputSchema>,
input: ZodInput<InputSchema>,
): Promise<ZodOutput<OutputSchema>>;
openSurface(id: string): void;
openSettings(id: string): void;
}
export interface PluginGlobalCommandContext extends PluginCommandCapabilities {
context: "global";
}
export interface PluginWorkspaceCommandContext extends PluginCommandCapabilities {
context: "workspace";
workspace: PluginWorkspaceSnapshot;
openPanel(id: string, options?: PluginOpenPanelOptions): void;
}
export interface PluginAgentCommandContext extends PluginCommandCapabilities {
context: "agent";
workspace: PluginWorkspaceSnapshot;
agent: PluginAgentSnapshot;
openPanel(id: string, options?: PluginOpenPanelOptions): void;
}
interface PluginCommandCenterItemBase {
id: string;
title: string;
icon: string;
keywords?: readonly string[];
}
export type PluginCommandCenterItemContribution =
| (PluginCommandCenterItemBase & {
context: "global";
onSelect(context: PluginGlobalCommandContext): void | Promise<void>;
})
| (PluginCommandCenterItemBase & {
context: "workspace";
onSelect(context: PluginWorkspaceCommandContext): void | Promise<void>;
})
| (PluginCommandCenterItemBase & {
context: "agent";
onSelect(context: PluginAgentCommandContext): void | Promise<void>;
});
interface PluginClientSlashCommandBase {
name: string;
description: string;
argumentHint: string;
}
export type PluginClientSlashCommandContribution =
| (PluginClientSlashCommandBase & {
context: "workspace";
onSubmit(context: PluginWorkspaceCommandContext & { args: string }): void | Promise<void>;
})
| (PluginClientSlashCommandBase & {
context: "agent";
onSubmit(context: PluginAgentCommandContext & { args: string }): void | Promise<void>;
});
export interface PluginHandlerContext {
paseo: PaseoApi;
}
export interface PluginServerContext extends PluginLifecycleRegistration {
registerSettings<Schema extends ZodType>(
definition: import("./settings.js").SettingsDefinition<Schema>,
): void;
handle<InputSchema extends ZodType, OutputSchema extends ZodType>(
contract: PluginRpcContract<InputSchema, OutputSchema>,
handler: (
input: ZodOutput<InputSchema>,
context: PluginHandlerContext,
) => ZodInput<OutputSchema> | Promise<ZodInput<OutputSchema>>,
): void;
registerProvider(provider: ProviderRegistration): void;
}
export type PluginCleanup = () => void | Promise<void>;
export type PluginServerContribution = (server: PluginServerContext) => PluginCleanup;
+15 -72
View File
@@ -1,79 +1,22 @@
import type { SettingsDefinition, SettingsState } from "./settings.js";
import type { ZodType } from "zod";
export { defineSettings, type SettingsDefinition, type SettingsState } from "./settings.js";
export declare function useSettings<Schema extends ZodType>(
definition: SettingsDefinition<Schema>,
): SettingsState<Schema>;
import type { ComponentType } from "react";
import type { PluginAttachmentSourceContribution, PluginIconProps } from "./contracts.js";
// Shared SDK entry. Keep runtime-specific imports and re-exports on /client or /server.
export type {
PluginTheme,
PluginWorkspaceSnapshot,
PluginAgentSnapshot,
PluginThemeColors,
PluginThemeContribution,
PluginAttachmentSourceContribution,
PluginTimelineData,
PluginTimelineItem,
PluginTimelineTransformResult,
PluginCleanup,
} from "./contracts.js";
export { defineSettings, settingsRpc, type SettingsDefinition } from "./settings.js";
export {
defineAttachmentSource,
PluginAttachmentItemSchema,
PluginAttachmentSearchPayloadSchema,
type PluginAttachmentItem,
type PluginAttachmentSearchPayload,
} from "./attachments.js";
export { defineRpc, type PluginRpcContract, type RpcInput, type RpcOutput } from "./rpc.js";
export function defineAttachmentSource<Definition extends PluginAttachmentSourceContribution>(
definition: Definition,
): Definition {
return definition;
}
export declare const Icon: ComponentType<PluginIconProps>;
export type {
PluginAttachmentSourceContribution,
PluginAgentCommandContext,
PluginAgentPanelProps,
PluginAgentSnapshot,
PluginCleanup,
PluginCommandCapabilities,
PluginCommandCenterItemContribution,
PluginClientContext,
PluginClientContribution,
PluginClientSlashCommandContribution,
PluginClientOpenPanelOptions,
PluginComposerPillContribution,
PluginComposerPillProps,
PluginServerContribution,
PluginServerContext,
PluginGlobalCommandContext,
PluginHandlerContext,
PluginHostProps,
PluginOpenPanelOptions,
PluginIconProps,
PluginPanelLocation,
PluginTheme,
PluginSidebarContribution,
PluginSurfaceContribution,
PluginSettingsScreenContribution,
PluginSurfaceProps,
PluginThemeColors,
PluginThemeContribution,
PluginTimelineData,
PluginTimelineItem,
PluginTimelineItemProps,
PluginTimelineRendererContribution,
PluginTimelineTransformerContribution,
PluginTimelineTransformResult,
PluginWorkspaceCommandContext,
PluginWorkspacePanelContribution,
PluginWorkspacePanelProps,
PluginWorkspaceSnapshot,
} from "./contracts.js";
export { usePaseo } from "./paseo-context.js";
export { useAgent, useWorkspace } from "./client-state.js";
export { useRpc } from "./rpc-context.js";
export type {
PluginBeforeRequests,
PluginHookAgent,
PluginHookContext,
PluginHookWorkspace,
PluginLifecycleEvents,
PluginLifecycleRegistration,
PluginSessionOpenRequest,
PluginTurnOutcome,
} from "./lifecycle.js";
+1 -1
View File
@@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest";
describe("plugin runtime-entry migration guide", () => {
it("maps every client registration method", async () => {
const contracts = await readFile(new URL("./contracts.ts", import.meta.url), "utf8");
const contracts = await readFile(new URL("./client/contracts.ts", import.meta.url), "utf8");
const context = /export interface PluginClientContext[^{]*{([\s\S]*?)\n}/.exec(contracts)?.[1];
if (!context) throw new Error("PluginClientContext was not found");
const methods = [...context.matchAll(/^\s+(add[A-Z]\w*)\(/gm)].map((match) => match[1]);
-57
View File
@@ -1,57 +0,0 @@
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const srcDir = path.dirname(fileURLToPath(import.meta.url));
const forbiddenSpecifiers = new Set([
"react",
"react/jsx-runtime",
"react-native",
"use-sync-external-store",
"use-sync-external-store/shim/with-selector",
]);
function localFile(fromFile: string, specifier: string): string {
const withoutJs = specifier.replace(/\.js$/, "");
for (const candidate of [`${withoutJs}.ts`, `${withoutJs}.tsx`, withoutJs]) {
const resolved = path.resolve(path.dirname(fromFile), candidate);
if (existsSync(resolved)) return resolved;
}
throw new Error(`Could not resolve ${specifier} from ${fromFile}`);
}
function valueImportSpecifiers(source: string): string[] {
const specifiers: string[] = [];
const pattern = /(?:^|\n)(?:export|import)\s+(?!type\b)(?:[\s\S]*?)from\s+["']([^"']+)["']/g;
for (const match of source.matchAll(pattern)) {
if (match[1]) specifiers.push(match[1]);
}
return specifiers;
}
function collectBareSpecifiers(entry: string): string[] {
const seen = new Set<string>();
const bare: string[] = [];
const queue = [entry];
while (queue.length > 0) {
const file = queue.pop();
if (!file || seen.has(file)) continue;
seen.add(file);
for (const specifier of valueImportSpecifiers(readFileSync(file, "utf8"))) {
if (specifier.startsWith(".")) {
queue.push(localFile(file, specifier));
continue;
}
bare.push(specifier);
}
}
return bare;
}
describe("@getpaseo/plugin/server", () => {
it("does not load react or the client hook graph", () => {
const specifiers = collectBareSpecifiers(path.join(srcDir, "server.ts"));
expect(specifiers.filter((specifier) => forbiddenSpecifiers.has(specifier))).toEqual([]);
});
});
+26
View File
@@ -0,0 +1,26 @@
import type { PaseoApi } from "@getpaseo/client";
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
import type { PluginRpcContract } from "../rpc.js";
import type { PluginCleanup } from "../contracts.js";
import type { ProviderRegistration } from "./provider.js";
import type { PluginLifecycleRegistration } from "./lifecycle.js";
export interface PluginHandlerContext {
paseo: PaseoApi;
}
export interface PluginServerContext extends PluginLifecycleRegistration {
registerSettings<Schema extends ZodType>(
definition: import("../settings.js").SettingsDefinition<Schema>,
): void;
handle<InputSchema extends ZodType, OutputSchema extends ZodType>(
contract: PluginRpcContract<InputSchema, OutputSchema>,
handler: (
input: ZodOutput<InputSchema>,
context: PluginHandlerContext,
) => ZodInput<OutputSchema> | Promise<ZodInput<OutputSchema>>,
): void;
registerProvider(provider: ProviderRegistration): void;
}
export type PluginServerContribution = (server: PluginServerContext) => PluginCleanup;
@@ -1,12 +1,15 @@
export type { PluginHandlerContext } from "./contracts.js";
export type {
PluginBeforeRequests,
PluginHookAgent,
PluginHandlerContext,
PluginServerContext,
PluginServerContribution,
} from "./contracts.js";
export type {
PluginHookContext,
PluginHookWorkspace,
PluginLifecycleEvents,
PluginLifecycleRegistration,
PluginHookAgent,
PluginSessionOpenRequest,
PluginTurnOutcome,
PluginLifecycleEvents,
PluginBeforeRequests,
PluginLifecycleRegistration,
} from "./lifecycle.js";
+1 -15
View File
@@ -1,5 +1,5 @@
import { z } from "zod";
import type { output, ZodType } from "zod";
import type { ZodType } from "zod";
import { defineRpc } from "./rpc.js";
export interface SettingsDefinition<Schema extends ZodType = ZodType> {
@@ -49,17 +49,3 @@ export function settingsRpc(id: string) {
}),
};
}
export type SettingsState<Schema extends ZodType> = (
| { status: "loading" }
| { status: "error"; error: string }
| { status: "invalid"; error: string; revision: string }
| { status: "ready"; values: output<Schema>; revision: string }
) & {
saving: boolean;
saveError: string | null;
/** Save an entire document against the revision currently displayed. Never throws. */
save(values: output<Schema>, revision: string): Promise<boolean>;
reset(): Promise<boolean>;
reload(): Promise<void>;
};