feat(plugins): split plugin code by runtime
Keep the entry point focused on contribution wiring so client UI and daemon handlers can use their own runtime APIs safely. Surface compilation failures and lifecycle transitions in the retained plugin log tail.
This commit is contained in:
+35
-84
@@ -1,7 +1,7 @@
|
|||||||
# Local plugins
|
# Local plugins
|
||||||
|
|
||||||
Local plugins contribute daemon RPCs, native app surfaces, workspace panels, Command Center items,
|
Local plugins contribute daemon RPCs, native app surfaces, workspace panels, Command Center items,
|
||||||
and composer attachment sources from one `index.tsx`. Paseo executes the server contribution in a
|
and composer attachment sources from one `index.ts`. Paseo executes the server contribution in a
|
||||||
subprocess and evaluates the client contribution in the app runtime. Plugin code is trusted code;
|
subprocess and evaluates the client contribution in the app runtime. Plugin code is trusted code;
|
||||||
this first slice does not sandbox it.
|
this first slice does not sandbox it.
|
||||||
|
|
||||||
@@ -45,13 +45,14 @@ The directory contains an identity-only manifest, one entry point, and local typ
|
|||||||
```text
|
```text
|
||||||
my-plugin/
|
my-plugin/
|
||||||
paseo-plugin.json
|
paseo-plugin.json
|
||||||
index.tsx
|
index.ts
|
||||||
|
main.client.tsx
|
||||||
paseo-plugin.d.ts
|
paseo-plugin.d.ts
|
||||||
package.json
|
package.json
|
||||||
tsconfig.json
|
tsconfig.json
|
||||||
```
|
```
|
||||||
|
|
||||||
Paseo compiles TSX when loading the plugin, so these packages are development dependencies only.
|
Paseo compiles TypeScript and TSX when loading the plugin, so these packages are development dependencies only.
|
||||||
The generated declaration file supplies `@paseo/plugin` types until the SDK is distributed as a
|
The generated declaration file supplies `@paseo/plugin` types until the SDK is distributed as a
|
||||||
public package. Regenerate new plugins with the matching Paseo CLI when the SDK contract changes.
|
public package. Regenerate new plugins with the matching Paseo CLI when the SDK contract changes.
|
||||||
|
|
||||||
@@ -75,68 +76,41 @@ plugin before compiling and starting from disk. A failed reload stays failed; Pa
|
|||||||
the old code. Use `enable`, `disable`, and `remove` to manage one plugin. Remove deletes only its
|
the old code. Use `enable`, `disable`, and `remove` to manage one plugin. Remove deletes only its
|
||||||
configuration, never its source directory. The global `pluginsEnabled` switch remains available.
|
configuration, never its source directory. The global `pluginsEnabled` switch remains available.
|
||||||
|
|
||||||
Server contributions can write to stdout and stderr with normal Node logging. Inspect the recent
|
Server contributions can write to stdout and stderr with normal Node logging. Paseo adds `[paseo]`
|
||||||
in-memory tail from the host plugin settings or with `paseo plugin logs <id>`. Reload, disable, and
|
entries for loading, ready, stopping, and stopped transitions. Compilation and load failures are
|
||||||
process failure retain the tail; removing the plugin clears it. Daemon restarts do not retain the
|
recorded as stderr entries before a subprocess exists. Inspect the recent in-memory
|
||||||
tail, but structured copies remain in `$PASEO_HOME/daemon.log`. Plugin output can contain secrets,
|
tail from the host plugin settings or with `paseo plugin logs <id>`. Reload, disable, and process
|
||||||
so do not log credentials or tokens.
|
failure retain the tail; removing the plugin clears it. Daemon restarts do not retain the tail, but
|
||||||
|
structured copies remain in `$PASEO_HOME/daemon.log`. Plugin output can contain secrets, so do not
|
||||||
|
log credentials or tokens.
|
||||||
|
|
||||||
## Contribute behavior and UI
|
## Contribute behavior and UI
|
||||||
|
|
||||||
Default export one contribution function. Paseo calls it with a plugin-scoped context. The compiler
|
Default export one contribution function from `index.ts`. Keep it to contribution wiring. Runtime
|
||||||
removes UI registrations from the server bundle and RPC registrations from the client bundle before
|
code lives behind filename boundaries:
|
||||||
resolving dependencies.
|
|
||||||
|
|
||||||
```tsx
|
| Suffix | Owns |
|
||||||
import { Text } from "react-native";
|
| -------------- | -------------------------------------------------------------------- |
|
||||||
import { useQuery } from "@tanstack/react-query";
|
| `*.client.tsx` | React, React Native, hooks, styles, surfaces, panels, and callbacks. |
|
||||||
import { z } from "zod";
|
| `*.server.ts` | Node APIs, filesystem and process access, credentials, and handlers. |
|
||||||
import { defineRpc, type PluginContext, usePaseo, useRpc } from "@paseo/plugin";
|
| `*.shared.ts` | Zod RPC contracts and plain values used by both runtimes. |
|
||||||
|
|
||||||
const greetRpc = defineRpc({
|
The compiler removes client registrations and imports from the server entry point, and server
|
||||||
name: "greet",
|
registrations and imports from the client entry point. Importing a `*.server` module from a client
|
||||||
input: z.object({ name: z.string() }),
|
module, or a `*.client` module from a server module, fails compilation. Top-level React Native calls
|
||||||
output: z.object({ message: z.string() }),
|
such as `StyleSheet.create` belong in `*.client.tsx`; placing them in `index.ts` executes them in the
|
||||||
});
|
server bundle.
|
||||||
|
|
||||||
function Greeting() {
|
```ts
|
||||||
const paseo = usePaseo();
|
import type { PluginContext } from "@paseo/plugin";
|
||||||
const greet = useRpc(greetRpc);
|
import { Greeting } from "./greeting.client";
|
||||||
const query = useQuery({
|
import { createGreeting } from "./greeting.server";
|
||||||
queryKey: ["greeting", "Paseo"],
|
import { greetRpc } from "./greeting.shared";
|
||||||
queryFn: () => greet({ name: "Paseo" }),
|
|
||||||
});
|
|
||||||
const createWorkspace = () =>
|
|
||||||
paseo.workspaces
|
|
||||||
.create({
|
|
||||||
source: {
|
|
||||||
kind: "worktree",
|
|
||||||
cwd: "/absolute/path/to/project",
|
|
||||||
action: "checkout",
|
|
||||||
checkoutSource: { kind: "change_request", forge: "github", number: 42 },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.then((workspace) =>
|
|
||||||
workspace.agents.create({
|
|
||||||
config: { provider: "codex/gpt-5" },
|
|
||||||
prompt: "Review PR #42",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return <Text onPress={() => void createWorkspace()}>{query.data?.message}</Text>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function contribute(plugin: PluginContext) {
|
export default function contribute(plugin: PluginContext) {
|
||||||
plugin.handle(greetRpc, async ({ name }, { paseo }) => {
|
plugin.handle(greetRpc, createGreeting);
|
||||||
const { config } = await paseo.config.get();
|
|
||||||
return { message: `${name}: ${config.pluginsEnabled ? "enabled" : "disabled"}` };
|
|
||||||
});
|
|
||||||
plugin.addSurface("main", Greeting);
|
plugin.addSurface("main", Greeting);
|
||||||
plugin.addSidebarItem({
|
plugin.addSidebarItem({ id: "main", title: "Greeting", icon: "MessageCircle", surface: "main" });
|
||||||
id: "main",
|
|
||||||
title: "Greeting",
|
|
||||||
icon: "MessageCircle",
|
|
||||||
surface: "main",
|
|
||||||
});
|
|
||||||
return () => {};
|
return () => {};
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -192,36 +166,13 @@ Register a declarative attachment source backed by a plugin RPC. Paseo owns the
|
|||||||
search picker, drafts, selected pill, and submission. The plugin returns complete text snapshots;
|
search picker, drafts, selected pill, and submission. The plugin returns complete text snapshots;
|
||||||
credentials and vendor API calls stay in the daemon handler.
|
credentials and vendor API calls stay in the daemon handler.
|
||||||
|
|
||||||
```tsx
|
```ts
|
||||||
const searchIssues = defineRpc({
|
import type { PluginContext } from "@paseo/plugin";
|
||||||
name: "issues.search",
|
import { search } from "./issues.server";
|
||||||
input: z.object({ query: z.string() }),
|
import { issues, searchIssues } from "./issues.shared";
|
||||||
output: z.object({
|
|
||||||
items: z.array(
|
|
||||||
z.object({
|
|
||||||
id: z.string(),
|
|
||||||
identifier: z.string(),
|
|
||||||
title: z.string(),
|
|
||||||
subtitle: z.string().optional(),
|
|
||||||
url: z.string().url(),
|
|
||||||
text: z.string(),
|
|
||||||
resourceType: z.string(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const issues = defineAttachmentSource({
|
|
||||||
id: "issues",
|
|
||||||
title: "Acme issue",
|
|
||||||
icon: "CircleDot",
|
|
||||||
pickerTitle: "Attach Acme issue",
|
|
||||||
searchPlaceholder: "Search by identifier or title",
|
|
||||||
search: searchIssues,
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function contribute(plugin: PluginContext) {
|
export default function contribute(plugin: PluginContext) {
|
||||||
plugin.handle(searchIssues, ({ query }) => searchAcmeIssues(query));
|
plugin.handle(searchIssues, search);
|
||||||
plugin.addAttachmentSource(issues);
|
plugin.addAttachmentSource(issues);
|
||||||
return () => {};
|
return () => {};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ afterEach(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("plugin scaffold", () => {
|
describe("plugin scaffold", () => {
|
||||||
it("creates a standalone strict TSX project that typechecks", async () => {
|
it("creates a standalone split-runtime project that typechecks", async () => {
|
||||||
const parent = await mkdtemp(path.join(process.cwd(), ".plugin-scaffold-"));
|
const parent = await mkdtemp(path.join(process.cwd(), ".plugin-scaffold-"));
|
||||||
directories.push(parent);
|
directories.push(parent);
|
||||||
const directory = path.join(parent, "hello-plugin");
|
const directory = path.join(parent, "hello-plugin");
|
||||||
@@ -34,6 +34,12 @@ describe("plugin scaffold", () => {
|
|||||||
expect(JSON.parse(await readFile(path.join(directory, "paseo-plugin.json"), "utf8"))).toEqual({
|
expect(JSON.parse(await readFile(path.join(directory, "paseo-plugin.json"), "utf8"))).toEqual({
|
||||||
id: "hello-plugin",
|
id: "hello-plugin",
|
||||||
});
|
});
|
||||||
|
await expect(readFile(path.join(directory, "index.ts"), "utf8")).resolves.toContain(
|
||||||
|
'from "./main.client"',
|
||||||
|
);
|
||||||
|
await expect(readFile(path.join(directory, "main.client.tsx"), "utf8")).resolves.toContain(
|
||||||
|
"Hello from my plugin",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("typechecks client and server Paseo API access", async () => {
|
it("typechecks client and server Paseo API access", async () => {
|
||||||
@@ -41,27 +47,45 @@ describe("plugin scaffold", () => {
|
|||||||
directories.push(parent);
|
directories.push(parent);
|
||||||
const directory = path.join(parent, "paseo-api-plugin");
|
const directory = path.join(parent, "paseo-api-plugin");
|
||||||
await scaffoldPluginDirectory(directory);
|
await scaffoldPluginDirectory(directory);
|
||||||
await writeFile(
|
await Promise.all([
|
||||||
path.join(directory, "index.tsx"),
|
writeFile(
|
||||||
`import React from "react";
|
path.join(directory, "inspect.shared.ts"),
|
||||||
import { Text } from "react-native";
|
`import { defineRpc } from "@paseo/plugin";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
|
||||||
defineRpc,
|
|
||||||
type PluginContext,
|
|
||||||
type PluginAgentPanelProps,
|
|
||||||
usePaseo,
|
|
||||||
useAgent,
|
|
||||||
useWorkspace,
|
|
||||||
} from "@paseo/plugin";
|
|
||||||
|
|
||||||
const inspect = defineRpc({
|
export const inspect = defineRpc({
|
||||||
name: "inspect",
|
name: "inspect",
|
||||||
input: z.object({}),
|
input: z.object({}),
|
||||||
output: z.object({ configured: z.boolean() }),
|
output: z.object({ configured: z.boolean() }),
|
||||||
});
|
});
|
||||||
|
`,
|
||||||
|
),
|
||||||
|
writeFile(
|
||||||
|
path.join(directory, "inspect.server.ts"),
|
||||||
|
`import type { PluginHandlerContext } from "@paseo/plugin";
|
||||||
|
import type { output as ZodOutput } from "zod";
|
||||||
|
import { inspect } from "./inspect.shared";
|
||||||
|
|
||||||
function Surface() {
|
export async function inspectConfig(
|
||||||
|
_input: ZodOutput<typeof inspect.input>,
|
||||||
|
{ paseo }: PluginHandlerContext,
|
||||||
|
) {
|
||||||
|
return { configured: Boolean((await paseo.config.get()).config) };
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
),
|
||||||
|
writeFile(
|
||||||
|
path.join(directory, "main.client.tsx"),
|
||||||
|
`import React from "react";
|
||||||
|
import { Text } from "react-native";
|
||||||
|
import {
|
||||||
|
type PluginAgentPanelProps,
|
||||||
|
useAgent,
|
||||||
|
usePaseo,
|
||||||
|
useWorkspace,
|
||||||
|
} from "@paseo/plugin";
|
||||||
|
|
||||||
|
export function Surface() {
|
||||||
const paseo = usePaseo();
|
const paseo = usePaseo();
|
||||||
const createWorkspace = () => paseo.workspaces.create({
|
const createWorkspace = () => paseo.workspaces.create({
|
||||||
source: { kind: "directory", path: "/repo" },
|
source: { kind: "directory", path: "/repo" },
|
||||||
@@ -70,7 +94,7 @@ function Surface() {
|
|||||||
return <Text>Paseo API</Text>;
|
return <Text>Paseo API</Text>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function AgentPanel({ workspaceId, agentId }: PluginAgentPanelProps) {
|
export function AgentPanel({ workspaceId, agentId }: PluginAgentPanelProps) {
|
||||||
const workspaceName = useWorkspace(workspaceId, (workspace) => {
|
const workspaceName = useWorkspace(workspaceId, (workspace) => {
|
||||||
// @ts-expect-error Plugin snapshots are readonly.
|
// @ts-expect-error Plugin snapshots are readonly.
|
||||||
workspace.name = "mutated";
|
workspace.name = "mutated";
|
||||||
@@ -83,11 +107,17 @@ function AgentPanel({ workspaceId, agentId }: PluginAgentPanelProps) {
|
|||||||
});
|
});
|
||||||
return <Text>{workspaceName}: {agentTitle}</Text>;
|
return <Text>{workspaceName}: {agentTitle}</Text>;
|
||||||
}
|
}
|
||||||
|
`,
|
||||||
|
),
|
||||||
|
writeFile(
|
||||||
|
path.join(directory, "index.ts"),
|
||||||
|
`import type { PluginContext } from "@paseo/plugin";
|
||||||
|
import { AgentPanel, Surface } from "./main.client";
|
||||||
|
import { inspectConfig } from "./inspect.server";
|
||||||
|
import { inspect } from "./inspect.shared";
|
||||||
|
|
||||||
export default function contribute(plugin: PluginContext) {
|
export default function contribute(plugin: PluginContext) {
|
||||||
plugin.handle(inspect, async (_input, { paseo }) => ({
|
plugin.handle(inspect, inspectConfig);
|
||||||
configured: Boolean((await paseo.config.get()).config),
|
|
||||||
}));
|
|
||||||
plugin.addSurface("main", Surface);
|
plugin.addSurface("main", Surface);
|
||||||
plugin.addWorkspacePanel({
|
plugin.addWorkspacePanel({
|
||||||
id: "review",
|
id: "review",
|
||||||
@@ -110,7 +140,8 @@ export default function contribute(plugin: PluginContext) {
|
|||||||
return () => {};
|
return () => {};
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
);
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
const configPath = path.join(directory, "tsconfig.json");
|
const configPath = path.join(directory, "tsconfig.json");
|
||||||
const loaded = ts.readConfigFile(configPath, ts.sys.readFile);
|
const loaded = ts.readConfigFile(configPath, ts.sys.readFile);
|
||||||
|
|||||||
@@ -206,16 +206,11 @@ const TSCONFIG = {
|
|||||||
esModuleInterop: true,
|
esModuleInterop: true,
|
||||||
allowSyntheticDefaultImports: true,
|
allowSyntheticDefaultImports: true,
|
||||||
},
|
},
|
||||||
include: ["index.tsx", "paseo-plugin.d.ts"],
|
include: ["**/*.ts", "**/*.tsx"],
|
||||||
};
|
};
|
||||||
|
|
||||||
const ENTRY = `import type { PluginContext } from "@paseo/plugin";
|
const ENTRY = `import type { PluginContext } from "@paseo/plugin";
|
||||||
import React from "react";
|
import { MainSurface } from "./main.client";
|
||||||
import { Text } from "react-native";
|
|
||||||
|
|
||||||
function MainSurface() {
|
|
||||||
return <Text>Hello from my plugin</Text>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function contribute(plugin: PluginContext) {
|
export default function contribute(plugin: PluginContext) {
|
||||||
plugin.addSurface("main", MainSurface);
|
plugin.addSurface("main", MainSurface);
|
||||||
@@ -223,6 +218,14 @@ export default function contribute(plugin: PluginContext) {
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const CLIENT_SURFACE = `import React from "react";
|
||||||
|
import { Text } from "react-native";
|
||||||
|
|
||||||
|
export function MainSurface() {
|
||||||
|
return <Text>Hello from my plugin</Text>;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
export interface PluginScaffold {
|
export interface PluginScaffold {
|
||||||
id: string;
|
id: string;
|
||||||
directory: string;
|
directory: string;
|
||||||
@@ -260,7 +263,8 @@ export async function scaffoldPluginDirectory(
|
|||||||
["package.json", `${JSON.stringify(packageJson, null, 2)}\n`],
|
["package.json", `${JSON.stringify(packageJson, null, 2)}\n`],
|
||||||
["tsconfig.json", `${JSON.stringify(TSCONFIG, null, 2)}\n`],
|
["tsconfig.json", `${JSON.stringify(TSCONFIG, null, 2)}\n`],
|
||||||
["paseo-plugin.d.ts", SDK_DECLARATIONS],
|
["paseo-plugin.d.ts", SDK_DECLARATIONS],
|
||||||
["index.tsx", ENTRY],
|
["index.ts", ENTRY],
|
||||||
|
["main.client.tsx", CLIENT_SURFACE],
|
||||||
]);
|
]);
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
[...files].map(([filename, contents]) =>
|
[...files].map(([filename, contents]) =>
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import { build, type Plugin } from "esbuild";
|
|||||||
|
|
||||||
type PluginBuildTarget = "client" | "server";
|
type PluginBuildTarget = "client" | "server";
|
||||||
|
|
||||||
|
interface SourceRange {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
|
|
||||||
const REGISTRATIONS_REMOVED_BY_TARGET: Record<PluginBuildTarget, ReadonlySet<string>> = {
|
const REGISTRATIONS_REMOVED_BY_TARGET: Record<PluginBuildTarget, ReadonlySet<string>> = {
|
||||||
client: new Set(["handle"]),
|
client: new Set(["handle"]),
|
||||||
server: new Set([
|
server: new Set([
|
||||||
@@ -54,7 +59,7 @@ function defaultPluginFunction(programBody: unknown[]): { body: object; contextN
|
|||||||
Reflect.get(statement, "type") === "ExportDefaultDeclaration",
|
Reflect.get(statement, "type") === "ExportDefaultDeclaration",
|
||||||
);
|
);
|
||||||
if (defaultExports.length !== 1) {
|
if (defaultExports.length !== 1) {
|
||||||
throw new Error("Plugin index.tsx must have exactly one default export function");
|
throw new Error("Plugin entry point must have exactly one default export function");
|
||||||
}
|
}
|
||||||
const declaration = Reflect.get(defaultExports[0] as object, "declaration");
|
const declaration = Reflect.get(defaultExports[0] as object, "declaration");
|
||||||
const declarationType =
|
const declarationType =
|
||||||
@@ -88,7 +93,7 @@ function collectRemovedRegistrationRanges(
|
|||||||
node: unknown,
|
node: unknown,
|
||||||
contextName: string,
|
contextName: string,
|
||||||
removedNames: ReadonlySet<string>,
|
removedNames: ReadonlySet<string>,
|
||||||
ranges: Array<{ start: number; end: number }>,
|
ranges: SourceRange[],
|
||||||
): void {
|
): void {
|
||||||
if (node === null || typeof node !== "object") return;
|
if (node === null || typeof node !== "object") return;
|
||||||
if (Reflect.get(node, "type") === "ExpressionStatement") {
|
if (Reflect.get(node, "type") === "ExpressionStatement") {
|
||||||
@@ -97,7 +102,9 @@ function collectRemovedRegistrationRanges(
|
|||||||
const start = Reflect.get(node, "start");
|
const start = Reflect.get(node, "start");
|
||||||
const end = Reflect.get(node, "end");
|
const end = Reflect.get(node, "end");
|
||||||
if (typeof start !== "number" || typeof end !== "number") {
|
if (typeof start !== "number" || typeof end !== "number") {
|
||||||
throw new Error(`Could not locate plugin context ${name} registration in index.tsx`);
|
throw new Error(
|
||||||
|
`Could not locate plugin context ${name} registration in plugin entry point`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
ranges.push({ start, end });
|
ranges.push({ start, end });
|
||||||
return;
|
return;
|
||||||
@@ -113,27 +120,81 @@ function collectRemovedRegistrationRanges(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeOppositeTargetRegistrations(source: string, target: PluginBuildTarget): string {
|
function moduleTarget(specifier: string): PluginBuildTarget | null {
|
||||||
|
if (/\.client(?:\.[cm]?[jt]sx?)?$/.test(specifier)) return "client";
|
||||||
|
if (/\.server(?:\.[cm]?[jt]sx?)?$/.test(specifier)) return "server";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectOppositeTargetImportRanges(
|
||||||
|
programBody: unknown[],
|
||||||
|
target: PluginBuildTarget,
|
||||||
|
ranges: SourceRange[],
|
||||||
|
): void {
|
||||||
|
for (const statement of programBody) {
|
||||||
|
if (
|
||||||
|
statement === null ||
|
||||||
|
typeof statement !== "object" ||
|
||||||
|
Reflect.get(statement, "type") !== "ImportDeclaration"
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const source = Reflect.get(statement, "source");
|
||||||
|
const specifier =
|
||||||
|
source !== null && typeof source === "object" ? Reflect.get(source, "value") : null;
|
||||||
|
if (typeof specifier !== "string") continue;
|
||||||
|
const importedTarget = moduleTarget(specifier);
|
||||||
|
if (importedTarget === null || importedTarget === target) continue;
|
||||||
|
const start = Reflect.get(statement, "start");
|
||||||
|
const end = Reflect.get(statement, "end");
|
||||||
|
if (typeof start !== "number" || typeof end !== "number") {
|
||||||
|
throw new Error(`Could not locate ${importedTarget}-only import in plugin entry point`);
|
||||||
|
}
|
||||||
|
ranges.push({ start, end });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterEntrypoint(source: string, target: PluginBuildTarget): string {
|
||||||
const ast = parse(source, {
|
const ast = parse(source, {
|
||||||
sourceType: "module",
|
sourceType: "module",
|
||||||
plugins: ["typescript", "jsx"],
|
plugins: ["typescript", "jsx"],
|
||||||
});
|
});
|
||||||
const pluginFunction = defaultPluginFunction(ast.program.body);
|
const pluginFunction = defaultPluginFunction(ast.program.body);
|
||||||
const ranges: Array<{ start: number; end: number }> = [];
|
const ranges: SourceRange[] = [];
|
||||||
collectRemovedRegistrationRanges(
|
collectRemovedRegistrationRanges(
|
||||||
pluginFunction.body,
|
pluginFunction.body,
|
||||||
pluginFunction.contextName,
|
pluginFunction.contextName,
|
||||||
REGISTRATIONS_REMOVED_BY_TARGET[target],
|
REGISTRATIONS_REMOVED_BY_TARGET[target],
|
||||||
ranges,
|
ranges,
|
||||||
);
|
);
|
||||||
|
collectOppositeTargetImportRanges(ast.program.body, target, ranges);
|
||||||
|
|
||||||
let output = source;
|
let output = source;
|
||||||
for (const range of ranges.toReversed()) {
|
for (const range of ranges.toSorted((left, right) => right.start - left.start)) {
|
||||||
output = `${output.slice(0, range.start)}${output.slice(range.end)}`;
|
output = `${output.slice(0, range.start)}${output.slice(range.end)}`;
|
||||||
}
|
}
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createRuntimeBoundaryPlugin(target: PluginBuildTarget): Plugin {
|
||||||
|
return {
|
||||||
|
name: `paseo-plugin-${target}-runtime-boundary`,
|
||||||
|
setup(buildContext) {
|
||||||
|
buildContext.onResolve({ filter: /\.(?:client|server)(?:\.[cm]?[jt]sx?)?$/ }, (args) => {
|
||||||
|
const importedTarget = moduleTarget(args.path);
|
||||||
|
if (importedTarget === null || importedTarget === target) return null;
|
||||||
|
return {
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
text: `${importedTarget}-only module cannot be imported into the plugin ${target} bundle: ${args.path}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function wrapCommonJsBundle(code: string): string {
|
function wrapCommonJsBundle(code: string): string {
|
||||||
return `(function(require) {\nconst module = { exports: {} };\nconst exports = module.exports;\n${code}\nreturn module.exports;\n})`;
|
return `(function(require) {\nconst module = { exports: {} };\nconst exports = module.exports;\n${code}\nreturn module.exports;\n})`;
|
||||||
}
|
}
|
||||||
@@ -168,7 +229,7 @@ function createUnusedPlatformModulePlugin(target: PluginBuildTarget): Plugin {
|
|||||||
|
|
||||||
async function compileTarget(entryPath: string, target: PluginBuildTarget): Promise<string> {
|
async function compileTarget(entryPath: string, target: PluginBuildTarget): Promise<string> {
|
||||||
const source = await readFile(entryPath, "utf8");
|
const source = await readFile(entryPath, "utf8");
|
||||||
const filteredSource = removeOppositeTargetRegistrations(source, target);
|
const filteredSource = filterEntrypoint(source, target);
|
||||||
const result = await build({
|
const result = await build({
|
||||||
stdin: {
|
stdin: {
|
||||||
contents: filteredSource,
|
contents: filteredSource,
|
||||||
@@ -191,7 +252,8 @@ async function compileTarget(entryPath: string, target: PluginBuildTarget): Prom
|
|||||||
"zod",
|
"zod",
|
||||||
]
|
]
|
||||||
: ["@paseo/plugin", "zod"],
|
: ["@paseo/plugin", "zod"],
|
||||||
plugins: [createUnusedPlatformModulePlugin(target)],
|
plugins: [createRuntimeBoundaryPlugin(target), createUnusedPlatformModulePlugin(target)],
|
||||||
|
logLevel: "silent",
|
||||||
treeShaking: true,
|
treeShaking: true,
|
||||||
write: false,
|
write: false,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -147,6 +147,27 @@ afterEach(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("PluginRuntime", () => {
|
describe("PluginRuntime", () => {
|
||||||
|
it("records host-owned plugin lifecycle events", async () => {
|
||||||
|
const directory = await createPlugin(
|
||||||
|
"lifecycle",
|
||||||
|
`export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`,
|
||||||
|
);
|
||||||
|
const child = createReloadChild("lifecycle", []);
|
||||||
|
const runtime = createTestRuntime({ spawnChild: () => child });
|
||||||
|
|
||||||
|
await runtime.startPlugin("lifecycle", directory);
|
||||||
|
await runtime.stopPluginById("lifecycle");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
runtime.getLogs("lifecycle").map(({ stream, message }) => ({ stream, message })),
|
||||||
|
).toEqual([
|
||||||
|
{ stream: "stdout", message: "[paseo] Loading plugin" },
|
||||||
|
{ stream: "stdout", message: "[paseo] Plugin ready" },
|
||||||
|
{ stream: "stdout", message: "[paseo] Stopping plugin" },
|
||||||
|
{ stream: "stdout", message: "[paseo] Plugin stopped" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("frames stdout and stderr, normalizes CRLF, and flushes final fragments once", async () => {
|
it("frames stdout and stderr, normalizes CRLF, and flushes final fragments once", async () => {
|
||||||
const directory = await createPlugin(
|
const directory = await createPlugin(
|
||||||
"output",
|
"output",
|
||||||
@@ -162,13 +183,20 @@ describe("PluginRuntime", () => {
|
|||||||
child.stdout.write("final stdout");
|
child.stdout.write("final stdout");
|
||||||
await runtime.stopPluginById("output");
|
await runtime.stopPluginById("output");
|
||||||
|
|
||||||
expect(runtime.getLogs("output").map(({ stream, message }) => ({ stream, message }))).toEqual([
|
const logs = runtime.getLogs("output");
|
||||||
|
expect(
|
||||||
|
logs
|
||||||
|
.filter((entry) => !entry.message.startsWith("[paseo]"))
|
||||||
|
.map(({ stream, message }) => ({ stream, message })),
|
||||||
|
).toEqual([
|
||||||
{ stream: "stdout", message: "first" },
|
{ stream: "stdout", message: "first" },
|
||||||
{ stream: "stderr", message: "problem" },
|
{ stream: "stderr", message: "problem" },
|
||||||
{ stream: "stderr", message: "final stderr" },
|
{ stream: "stderr", message: "final stderr" },
|
||||||
{ stream: "stdout", message: "final stdout" },
|
{ stream: "stdout", message: "final stdout" },
|
||||||
]);
|
]);
|
||||||
expect(runtime.getLogs("output").map((entry) => entry.sequence)).toEqual([1, 2, 3, 4]);
|
expect(logs.map((entry) => entry.sequence)).toEqual(
|
||||||
|
Array.from({ length: logs.length }, (_, index) => index + 1),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("writes plugin output through the daemon logger with structured identity fields", async () => {
|
it("writes plugin output through the daemon logger with structured identity fields", async () => {
|
||||||
@@ -187,14 +215,14 @@ describe("PluginRuntime", () => {
|
|||||||
|
|
||||||
child.stderr.write("connection failed\n");
|
child.stderr.write("connection failed\n");
|
||||||
|
|
||||||
expect(records.find((record) => record.msg === "Plugin output")).toMatchObject({
|
expect(records.find((record) => record.message === "connection failed")).toMatchObject({
|
||||||
module: "plugins",
|
module: "plugins",
|
||||||
pluginId: "tagged-output",
|
pluginId: "tagged-output",
|
||||||
sequence: 1,
|
sequence: 3,
|
||||||
stream: "stderr",
|
stream: "stderr",
|
||||||
message: "connection failed",
|
message: "connection failed",
|
||||||
});
|
});
|
||||||
expect(records.find((record) => record.msg === "Plugin output")?.timestamp).toEqual(
|
expect(records.find((record) => record.message === "connection failed")?.timestamp).toEqual(
|
||||||
expect.any(String),
|
expect.any(String),
|
||||||
);
|
);
|
||||||
await runtime.stopAll();
|
await runtime.stopAll();
|
||||||
@@ -221,8 +249,14 @@ describe("PluginRuntime", () => {
|
|||||||
child.stderr.write("y".repeat(20 * 1024));
|
child.stderr.write("y".repeat(20 * 1024));
|
||||||
await runtime.stopPluginById("noisy");
|
await runtime.stopPluginById("noisy");
|
||||||
const logs = runtime.getLogs("noisy");
|
const logs = runtime.getLogs("noisy");
|
||||||
expect(logs).toHaveLength(16);
|
expect(logs.length).toBeLessThanOrEqual(500);
|
||||||
expect(logs.at(-1)).toMatchObject({ stream: "stderr", message: "y".repeat(16 * 1024) });
|
expect(
|
||||||
|
logs.reduce((bytes, entry) => bytes + Buffer.byteLength(entry.message), 0),
|
||||||
|
).toBeLessThanOrEqual(256 * 1024);
|
||||||
|
expect(logs.every((entry) => Buffer.byteLength(entry.message) <= 16 * 1024)).toBe(true);
|
||||||
|
expect(logs).toContainEqual(
|
||||||
|
expect.objectContaining({ stream: "stderr", message: "y".repeat(16 * 1024) }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("captures output emitted during initialization and cleanup across reloads", async () => {
|
it("captures output emitted during initialization and cleanup across reloads", async () => {
|
||||||
@@ -263,6 +297,27 @@ describe("PluginRuntime", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("retains compilation failures in the plugin stderr tail", async () => {
|
||||||
|
const directory = await createPlugin("broken-compile", `export default function contribute( {`);
|
||||||
|
const runtime = createTestRuntime();
|
||||||
|
|
||||||
|
await expect(runtime.startPlugin("broken-compile", directory)).rejects.toThrow();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
runtime.getLogs("broken-compile").map(({ stream, message }) => ({ stream, message })),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
stream: "stdout",
|
||||||
|
message: "[paseo] Loading plugin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stream: "stderr",
|
||||||
|
message: expect.stringContaining("Plugin failed to load:"),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await runtime.stopAll();
|
||||||
|
});
|
||||||
|
|
||||||
it("waits for the old subprocess to exit before starting its replacement", async () => {
|
it("waits for the old subprocess to exit before starting its replacement", async () => {
|
||||||
const directory = await createPlugin(
|
const directory = await createPlugin(
|
||||||
"reloadable",
|
"reloadable",
|
||||||
@@ -505,6 +560,170 @@ export default function contribute(plugin: any) {
|
|||||||
await runtime.stopAll();
|
await runtime.stopAll();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps client and server modules in their target runtime", async () => {
|
||||||
|
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-"));
|
||||||
|
temporaryDirectories.push(directory);
|
||||||
|
await Promise.all([
|
||||||
|
writeFile(
|
||||||
|
path.join(directory, "paseo-plugin.json"),
|
||||||
|
JSON.stringify({ id: "split-runtime" }),
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
|
writeFile(
|
||||||
|
path.join(directory, "index.ts"),
|
||||||
|
`import type { PluginContext } from "@paseo/plugin";
|
||||||
|
import { Surface } from "./surface.client";
|
||||||
|
import { inspectRpc } from "./inspect.shared";
|
||||||
|
import { inspectHost } from "./inspect.server";
|
||||||
|
|
||||||
|
export default function contribute(plugin: PluginContext) {
|
||||||
|
plugin.handle(inspectRpc, inspectHost);
|
||||||
|
plugin.addSurface("main", Surface);
|
||||||
|
return () => undefined;
|
||||||
|
}`,
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
|
writeFile(
|
||||||
|
path.join(directory, "surface.client.tsx"),
|
||||||
|
`import React from "react";
|
||||||
|
import { StyleSheet, Text } from "react-native";
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({ label: { fontWeight: "600" } });
|
||||||
|
|
||||||
|
export function Surface() {
|
||||||
|
return <Text style={styles.label}>Client-only surface</Text>;
|
||||||
|
}`,
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
|
writeFile(
|
||||||
|
path.join(directory, "inspect.shared.ts"),
|
||||||
|
`import { defineRpc } from "@paseo/plugin";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const inspectRpc = defineRpc({
|
||||||
|
name: "inspect",
|
||||||
|
input: z.object({}),
|
||||||
|
output: z.object({ platform: z.string() }),
|
||||||
|
});`,
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
|
writeFile(
|
||||||
|
path.join(directory, "inspect.server.ts"),
|
||||||
|
`import { platform } from "node:os";
|
||||||
|
import type { z } from "zod";
|
||||||
|
import { inspectRpc } from "./inspect.shared";
|
||||||
|
|
||||||
|
export function inspectHost(_input: z.input<typeof inspectRpc.input>) {
|
||||||
|
return { platform: platform() };
|
||||||
|
}`,
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
const runtime = createTestRuntime();
|
||||||
|
|
||||||
|
await runtime.startPlugin("split-runtime", directory);
|
||||||
|
|
||||||
|
const plugin = runtime.catalog()[0];
|
||||||
|
expect(plugin?.clientBundle).toContain("Client-only surface");
|
||||||
|
expect(plugin?.clientBundle).not.toContain("node:os");
|
||||||
|
await expect(runtime.invoke("split-runtime", "inspect", {})).resolves.toMatchObject({
|
||||||
|
platform: expect.any(String),
|
||||||
|
});
|
||||||
|
await runtime.stopAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects server imports from client-only modules", async () => {
|
||||||
|
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-"));
|
||||||
|
temporaryDirectories.push(directory);
|
||||||
|
await Promise.all([
|
||||||
|
writeFile(
|
||||||
|
path.join(directory, "paseo-plugin.json"),
|
||||||
|
JSON.stringify({ id: "cross-runtime-import" }),
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
|
writeFile(
|
||||||
|
path.join(directory, "index.ts"),
|
||||||
|
`import type { PluginContext } from "@paseo/plugin";
|
||||||
|
import { Surface } from "./surface.client";
|
||||||
|
|
||||||
|
export default function contribute(plugin: PluginContext) {
|
||||||
|
plugin.addSurface("main", Surface);
|
||||||
|
return () => undefined;
|
||||||
|
}`,
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
|
writeFile(
|
||||||
|
path.join(directory, "surface.client.tsx"),
|
||||||
|
`import { readSecret } from "./secret.server";
|
||||||
|
export function Surface() { return readSecret(); }`,
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
|
writeFile(
|
||||||
|
path.join(directory, "secret.server.ts"),
|
||||||
|
`export function readSecret() { return null; }`,
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
const runtime = createTestRuntime();
|
||||||
|
|
||||||
|
await expect(runtime.startPlugin("cross-runtime-import", directory)).rejects.toThrow(
|
||||||
|
"server-only module cannot be imported into the plugin client bundle",
|
||||||
|
);
|
||||||
|
await runtime.stopAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects client imports from server-only modules", async () => {
|
||||||
|
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-"));
|
||||||
|
temporaryDirectories.push(directory);
|
||||||
|
await Promise.all([
|
||||||
|
writeFile(
|
||||||
|
path.join(directory, "paseo-plugin.json"),
|
||||||
|
JSON.stringify({ id: "cross-runtime-import" }),
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
|
writeFile(
|
||||||
|
path.join(directory, "index.ts"),
|
||||||
|
`import type { PluginContext } from "@paseo/plugin";
|
||||||
|
import { inspect } from "./inspect.server";
|
||||||
|
import { inspectRpc } from "./inspect.shared";
|
||||||
|
|
||||||
|
export default function contribute(plugin: PluginContext) {
|
||||||
|
plugin.handle(inspectRpc, inspect);
|
||||||
|
return () => undefined;
|
||||||
|
}`,
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
|
writeFile(
|
||||||
|
path.join(directory, "inspect.shared.ts"),
|
||||||
|
`import { defineRpc } from "@paseo/plugin";
|
||||||
|
import { z } from "zod";
|
||||||
|
export const inspectRpc = defineRpc({
|
||||||
|
name: "inspect",
|
||||||
|
input: z.object({}),
|
||||||
|
output: z.object({}),
|
||||||
|
});`,
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
|
writeFile(
|
||||||
|
path.join(directory, "inspect.server.ts"),
|
||||||
|
`import { Surface } from "./surface.client";
|
||||||
|
export function inspect() { void Surface; return {}; }`,
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
|
writeFile(
|
||||||
|
path.join(directory, "surface.client.tsx"),
|
||||||
|
`export function Surface() { return null; }`,
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
const runtime = createTestRuntime();
|
||||||
|
|
||||||
|
await expect(runtime.startPlugin("cross-runtime-import", directory)).rejects.toThrow(
|
||||||
|
"client-only module cannot be imported into the plugin server bundle",
|
||||||
|
);
|
||||||
|
await runtime.stopAll();
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects a handler result that does not match its RPC output schema", async () => {
|
it("rejects a handler result that does not match its RPC output schema", async () => {
|
||||||
const directory = await createPlugin(
|
const directory = await createPlugin(
|
||||||
"invalid-output",
|
"invalid-output",
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import { readPluginManifest } from "./manifest.js";
|
|||||||
import type { PluginProcessMessage, PluginProcessRequest } from "./plugin-process-protocol.js";
|
import type { PluginProcessMessage, PluginProcessRequest } from "./plugin-process-protocol.js";
|
||||||
import { PluginSessionSocket } from "./session-socket.js";
|
import { PluginSessionSocket } from "./session-socket.js";
|
||||||
|
|
||||||
const ENTRY_FILENAME = "index.tsx";
|
const ENTRY_FILENAME = "index.ts";
|
||||||
|
// COMPAT(plugin-index-tsx): added in v0.4, remove after 2027-02-17
|
||||||
|
const LEGACY_ENTRY_FILENAME = "index.tsx";
|
||||||
const REQUEST_TIMEOUT_MS = 30_000;
|
const REQUEST_TIMEOUT_MS = 30_000;
|
||||||
const MAX_LOG_ENTRIES = 500;
|
const MAX_LOG_ENTRIES = 500;
|
||||||
const MAX_LOG_BYTES = 256 * 1024;
|
const MAX_LOG_BYTES = 256 * 1024;
|
||||||
@@ -169,6 +171,10 @@ function terminatePluginChild(child: PluginChild): void {
|
|||||||
if (!child.killed) child.kill();
|
if (!child.killed) child.kill();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function describeError(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
function send(child: PluginChild, message: PluginProcessRequest): Promise<void> {
|
function send(child: PluginChild, message: PluginProcessRequest): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
child.send(message, (error) => {
|
child.send(message, (error) => {
|
||||||
@@ -181,9 +187,13 @@ function send(child: PluginChild, message: PluginProcessRequest): Promise<void>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requireRegularFile(filePath: string, label: string): Promise<void> {
|
async function resolveEntryPath(directory: string): Promise<string> {
|
||||||
const info = await stat(filePath).catch(() => null);
|
for (const filename of [ENTRY_FILENAME, LEGACY_ENTRY_FILENAME]) {
|
||||||
if (!info?.isFile()) throw new Error(`${label} is missing: ${filePath}`);
|
const filePath = path.join(directory, filename);
|
||||||
|
const info = await stat(filePath).catch(() => null);
|
||||||
|
if (info?.isFile()) return filePath;
|
||||||
|
}
|
||||||
|
throw new Error(`Plugin entry point is missing: ${path.join(directory, ENTRY_FILENAME)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PluginRuntime {
|
export class PluginRuntime {
|
||||||
@@ -217,12 +227,17 @@ export class PluginRuntime {
|
|||||||
canPublish: () => boolean = () => true,
|
canPublish: () => boolean = () => true,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (this.plugins.has(pluginId)) throw new Error(`Plugin is already running: ${pluginId}`);
|
if (this.plugins.has(pluginId)) throw new Error(`Plugin is already running: ${pluginId}`);
|
||||||
const loaded = await this.loadDirectoryPlugin(pluginId, configuredPath);
|
this.appendLog(pluginId, "stdout", "[paseo] Loading plugin");
|
||||||
|
const loaded = await this.loadDirectoryPlugin(pluginId, configuredPath).catch((error) => {
|
||||||
|
this.appendLog(pluginId, "stderr", `[paseo] Plugin failed to load: ${describeError(error)}`);
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
if (!canPublish()) {
|
if (!canPublish()) {
|
||||||
await this.stopPlugin(loaded);
|
await this.stopPlugin(loaded);
|
||||||
throw new Error(`Plugin start cancelled: ${pluginId}`);
|
throw new Error(`Plugin start cancelled: ${pluginId}`);
|
||||||
}
|
}
|
||||||
this.plugins.set(pluginId, loaded);
|
this.plugins.set(pluginId, loaded);
|
||||||
|
this.appendLog(pluginId, "stdout", "[paseo] Plugin ready");
|
||||||
}
|
}
|
||||||
|
|
||||||
async stopPluginById(pluginId: string): Promise<boolean> {
|
async stopPluginById(pluginId: string): Promise<boolean> {
|
||||||
@@ -290,8 +305,7 @@ export class PluginRuntime {
|
|||||||
): Promise<LoadedPlugin> {
|
): Promise<LoadedPlugin> {
|
||||||
const directory = path.resolve(configuredPath);
|
const directory = path.resolve(configuredPath);
|
||||||
await readPluginManifest(directory);
|
await readPluginManifest(directory);
|
||||||
const entryPath = path.join(directory, ENTRY_FILENAME);
|
const entryPath = await resolveEntryPath(directory);
|
||||||
await requireRegularFile(entryPath, "Plugin entry point");
|
|
||||||
const bundles = await compilePlugin(entryPath);
|
const bundles = await compilePlugin(entryPath);
|
||||||
const sessionHost = this.sessionHost;
|
const sessionHost = this.sessionHost;
|
||||||
if (!sessionHost) throw new Error("Plugin Paseo session host is not attached");
|
if (!sessionHost) throw new Error("Plugin Paseo session host is not attached");
|
||||||
@@ -394,9 +408,11 @@ export class PluginRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async stopPlugin(loaded: LoadedPlugin): Promise<void> {
|
private async stopPlugin(loaded: LoadedPlugin): Promise<void> {
|
||||||
|
this.appendLog(loaded.id, "stdout", "[paseo] Stopping plugin");
|
||||||
if (loaded.child.killed) {
|
if (loaded.child.killed) {
|
||||||
loaded.sessionSocket.peerClosed();
|
loaded.sessionSocket.peerClosed();
|
||||||
await loaded.sessionClosed;
|
await loaded.sessionClosed;
|
||||||
|
this.appendLog(loaded.id, "stdout", "[paseo] Plugin stopped");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const closed = new Promise<void>((resolve) =>
|
const closed = new Promise<void>((resolve) =>
|
||||||
@@ -410,6 +426,7 @@ export class PluginRuntime {
|
|||||||
await closed;
|
await closed;
|
||||||
loaded.sessionSocket.peerClosed();
|
loaded.sessionSocket.peerClosed();
|
||||||
await loaded.sessionClosed;
|
await loaded.sessionClosed;
|
||||||
|
this.appendLog(loaded.id, "stdout", "[paseo] Plugin stopped");
|
||||||
}
|
}
|
||||||
|
|
||||||
private rejectPending(loaded: LoadedPlugin, message: string): void {
|
private rejectPending(loaded: LoadedPlugin, message: string): void {
|
||||||
@@ -421,6 +438,7 @@ export class PluginRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private appendLog(pluginId: string, stream: PluginLogEntry["stream"], message: string): void {
|
private appendLog(pluginId: string, stream: PluginLogEntry["stream"], message: string): void {
|
||||||
|
const boundedMessage = Buffer.from(message).subarray(0, MAX_LOG_LINE_BYTES).toString("utf8");
|
||||||
let tail = this.logTails.get(pluginId);
|
let tail = this.logTails.get(pluginId);
|
||||||
if (!tail) {
|
if (!tail) {
|
||||||
tail = { entries: [], bytes: 0, nextSequence: 1 };
|
tail = { entries: [], bytes: 0, nextSequence: 1 };
|
||||||
@@ -430,10 +448,10 @@ export class PluginRuntime {
|
|||||||
sequence: tail.nextSequence++,
|
sequence: tail.nextSequence++,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
stream,
|
stream,
|
||||||
message,
|
message: boundedMessage,
|
||||||
};
|
};
|
||||||
tail.entries.push(entry);
|
tail.entries.push(entry);
|
||||||
tail.bytes += Buffer.byteLength(message);
|
tail.bytes += Buffer.byteLength(boundedMessage);
|
||||||
while (tail.entries.length > MAX_LOG_ENTRIES || tail.bytes > MAX_LOG_BYTES) {
|
while (tail.entries.length > MAX_LOG_ENTRIES || tail.bytes > MAX_LOG_BYTES) {
|
||||||
const removed = tail.entries.shift();
|
const removed = tail.entries.shift();
|
||||||
if (!removed) break;
|
if (!removed) break;
|
||||||
|
|||||||
@@ -25,9 +25,9 @@ Register the extension in `$PASEO_HOME/config.json`:
|
|||||||
|
|
||||||
Restart the development daemon after changing its environment or plugin configuration.
|
Restart the development daemon after changing its environment or plugin configuration.
|
||||||
|
|
||||||
The entry point demonstrates the complete attachment-source pattern:
|
The split entry point demonstrates the complete attachment-source pattern:
|
||||||
|
|
||||||
- define one validated search RPC;
|
- define the validated search RPC and attachment metadata in `issues.shared.ts`;
|
||||||
- handle it in the daemon subprocess;
|
- handle it in the daemon subprocess from `issues.server.ts`;
|
||||||
- register a declarative attachment source for the client;
|
- wire both contributions from `index.ts`;
|
||||||
- keep credentials and vendor API calls out of the client bundle.
|
- keep credentials and vendor API calls out of the client bundle.
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { PluginContext } from "@paseo/plugin";
|
||||||
|
import { searchIssues } from "./issues.server";
|
||||||
|
import { issueAttachments, searchIssuesRpc } from "./issues.shared";
|
||||||
|
|
||||||
|
export default function contribute(plugin: PluginContext) {
|
||||||
|
plugin.handle(searchIssuesRpc, searchIssues);
|
||||||
|
plugin.addAttachmentSource(issueAttachments);
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { z } from "zod";
|
|
||||||
import {
|
|
||||||
defineAttachmentSource,
|
|
||||||
defineRpc,
|
|
||||||
type PluginAttachmentSearchPayload,
|
|
||||||
type PluginContext,
|
|
||||||
} from "@paseo/plugin";
|
|
||||||
import { createLinearIssueSearch } from "./linear";
|
|
||||||
|
|
||||||
const SearchPayloadSchema = z.object({
|
|
||||||
items: z.array(
|
|
||||||
z.object({
|
|
||||||
id: z.string(),
|
|
||||||
identifier: z.string(),
|
|
||||||
title: z.string(),
|
|
||||||
subtitle: z.string().optional(),
|
|
||||||
url: z.string().url(),
|
|
||||||
text: z.string(),
|
|
||||||
resourceType: z.string(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
const searchIssues = defineRpc({
|
|
||||||
name: "issues.search",
|
|
||||||
input: z.object({ query: z.string() }),
|
|
||||||
output: SearchPayloadSchema,
|
|
||||||
});
|
|
||||||
|
|
||||||
const issueAttachments = defineAttachmentSource({
|
|
||||||
id: "issues",
|
|
||||||
title: "Linear issue",
|
|
||||||
icon: "CircleDot",
|
|
||||||
pickerTitle: "Attach Linear issue",
|
|
||||||
searchPlaceholder: "Search by identifier or title",
|
|
||||||
search: searchIssues,
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function contribute(plugin: PluginContext) {
|
|
||||||
plugin.handle(searchIssues, async ({ query }): Promise<PluginAttachmentSearchPayload> => {
|
|
||||||
const linear = createLinearIssueSearch({ apiKey: process.env.LINEAR_API_KEY ?? "" });
|
|
||||||
return linear.search(query);
|
|
||||||
});
|
|
||||||
plugin.addAttachmentSource(issueAttachments);
|
|
||||||
return () => {};
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { PluginAttachmentSearchPayload } from "@paseo/plugin";
|
||||||
|
import type { output as ZodOutput } from "zod";
|
||||||
|
import { searchIssuesRpc } from "./issues.shared";
|
||||||
|
import { createLinearIssueSearch } from "./linear";
|
||||||
|
|
||||||
|
export async function searchIssues({
|
||||||
|
query,
|
||||||
|
}: ZodOutput<typeof searchIssuesRpc.input>): Promise<PluginAttachmentSearchPayload> {
|
||||||
|
const linear = createLinearIssueSearch({ apiKey: process.env.LINEAR_API_KEY ?? "" });
|
||||||
|
return linear.search(query);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { defineAttachmentSource, defineRpc } from "@paseo/plugin";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const SearchPayloadSchema = z.object({
|
||||||
|
items: z.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
identifier: z.string(),
|
||||||
|
title: z.string(),
|
||||||
|
subtitle: z.string().optional(),
|
||||||
|
url: z.string().url(),
|
||||||
|
text: z.string(),
|
||||||
|
resourceType: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const searchIssuesRpc = defineRpc({
|
||||||
|
name: "issues.search",
|
||||||
|
input: z.object({ query: z.string() }),
|
||||||
|
output: SearchPayloadSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const issueAttachments = defineAttachmentSource({
|
||||||
|
id: "issues",
|
||||||
|
title: "Linear issue",
|
||||||
|
icon: "CircleDot",
|
||||||
|
pickerTitle: "Attach Linear issue",
|
||||||
|
searchPlaceholder: "Search by identifier or title",
|
||||||
|
search: searchIssuesRpc,
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { output as ZodOutput } from "zod";
|
||||||
|
import { incrementRpc } from "./increment.shared";
|
||||||
|
|
||||||
|
export function increment(input: ZodOutput<typeof incrementRpc.input>) {
|
||||||
|
return {
|
||||||
|
value: input.value + 1,
|
||||||
|
handledBy: "plugin subprocess",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineRpc } from "@paseo/plugin";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const incrementRpc = defineRpc({
|
||||||
|
name: "increment",
|
||||||
|
input: z.object({ value: z.number() }),
|
||||||
|
output: z.object({ value: z.number(), handledBy: z.string() }),
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { PluginContext } from "@paseo/plugin";
|
||||||
|
import { ExamplePanel } from "./main.client";
|
||||||
|
import { increment } from "./increment.server";
|
||||||
|
import { incrementRpc } from "./increment.shared";
|
||||||
|
|
||||||
|
export default function contribute(plugin: PluginContext) {
|
||||||
|
plugin.handle(incrementRpc, increment);
|
||||||
|
plugin.addWorkspacePanel({
|
||||||
|
id: "counter",
|
||||||
|
title: "Plugin counter",
|
||||||
|
icon: "Blocks",
|
||||||
|
context: "workspace",
|
||||||
|
Component: ExamplePanel,
|
||||||
|
});
|
||||||
|
plugin.addCommandCenterItem({
|
||||||
|
id: "open-counter",
|
||||||
|
title: "Open plugin counter",
|
||||||
|
icon: "Blocks",
|
||||||
|
context: "workspace",
|
||||||
|
onSelect({ openPanel }) {
|
||||||
|
openPanel("counter");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
+4
-39
@@ -1,20 +1,9 @@
|
|||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { type PluginWorkspacePanelProps, useRpc, useWorkspace } from "@paseo/plugin";
|
||||||
import React, { useCallback, useMemo } from "react";
|
import React, { useCallback, useMemo } from "react";
|
||||||
import { Pressable, Text, View } from "react-native";
|
import { Pressable, Text, View } from "react-native";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import { incrementRpc } from "./increment.shared";
|
||||||
defineRpc,
|
|
||||||
type PluginContext,
|
|
||||||
type PluginWorkspacePanelProps,
|
|
||||||
useWorkspace,
|
|
||||||
useRpc,
|
|
||||||
} from "@paseo/plugin";
|
|
||||||
|
|
||||||
const incrementRpc = defineRpc({
|
|
||||||
name: "increment",
|
|
||||||
input: z.object({ value: z.number() }),
|
|
||||||
output: z.object({ value: z.number(), handledBy: z.string() }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const ExampleThemeSchema = z.object({
|
const ExampleThemeSchema = z.object({
|
||||||
colors: z.object({
|
colors: z.object({
|
||||||
@@ -27,7 +16,7 @@ const ExampleThemeSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
function ExamplePanel({ theme, workspaceId }: PluginWorkspacePanelProps) {
|
export function ExamplePanel({ theme, workspaceId }: PluginWorkspacePanelProps) {
|
||||||
const { colors } = ExampleThemeSchema.parse(theme);
|
const { colors } = ExampleThemeSchema.parse(theme);
|
||||||
const workspace = useWorkspace(workspaceId, ({ name }) => ({ name }));
|
const workspace = useWorkspace(workspaceId, ({ name }) => ({ name }));
|
||||||
const callIncrement = useRpc(incrementRpc);
|
const callIncrement = useRpc(incrementRpc);
|
||||||
@@ -67,27 +56,3 @@ function ExamplePanel({ theme, workspaceId }: PluginWorkspacePanelProps) {
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function contribute(plugin: PluginContext) {
|
|
||||||
plugin.handle(incrementRpc, async (input) => ({
|
|
||||||
value: input.value + 1,
|
|
||||||
handledBy: "plugin subprocess",
|
|
||||||
}));
|
|
||||||
plugin.addWorkspacePanel({
|
|
||||||
id: "counter",
|
|
||||||
title: "Plugin counter",
|
|
||||||
icon: "Blocks",
|
|
||||||
context: "workspace",
|
|
||||||
Component: ExamplePanel,
|
|
||||||
});
|
|
||||||
plugin.addCommandCenterItem({
|
|
||||||
id: "open-counter",
|
|
||||||
title: "Open plugin counter",
|
|
||||||
icon: "Blocks",
|
|
||||||
context: "workspace",
|
|
||||||
onSelect({ openPanel }) {
|
|
||||||
openPanel("counter");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return () => {};
|
|
||||||
}
|
|
||||||
@@ -32,27 +32,39 @@ cd /absolute/path/to/workspace-plugin
|
|||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
`init` creates a strict TSX project. It does not run the package manager.
|
`init` creates a strict TypeScript project. It does not run the package manager. `index.ts` registers contributions; client UI lives in `*.client.tsx` files.
|
||||||
|
|
||||||
Replace `index.tsx` with:
|
Replace `main.client.tsx` with:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { type PluginContext, type PluginWorkspacePanelProps, useWorkspace } from "@paseo/plugin";
|
import { type PluginWorkspacePanelProps, useWorkspace } from "@paseo/plugin";
|
||||||
import { Text, View } from "react-native";
|
import { StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
function WorkspaceOverview({ workspaceId }: PluginWorkspacePanelProps) {
|
export function WorkspaceOverview({ workspaceId }: PluginWorkspacePanelProps) {
|
||||||
const workspace = useWorkspace(workspaceId, ({ name, directory }) => ({
|
const workspace = useWorkspace(workspaceId, ({ name, directory }) => ({
|
||||||
name,
|
name,
|
||||||
directory,
|
directory,
|
||||||
}));
|
}));
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1, padding: 24, gap: 8 }}>
|
<View style={styles.screen}>
|
||||||
<Text style={{ fontSize: 24 }}>{workspace?.name}</Text>
|
<Text style={styles.title}>{workspace?.name}</Text>
|
||||||
<Text>{workspace?.directory}</Text>
|
<Text>{workspace?.directory}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
screen: { flex: 1, padding: 24, gap: 8 },
|
||||||
|
title: { fontSize: 24 },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `index.ts` with:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { PluginContext } from "@paseo/plugin";
|
||||||
|
import { WorkspaceOverview } from "./main.client";
|
||||||
|
|
||||||
export default function contribute(plugin: PluginContext) {
|
export default function contribute(plugin: PluginContext) {
|
||||||
plugin.addWorkspacePanel({
|
plugin.addWorkspacePanel({
|
||||||
id: "overview",
|
id: "overview",
|
||||||
@@ -74,7 +86,7 @@ export default function contribute(plugin: PluginContext) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The icon is a [Lucide](https://lucide.dev/icons/) icon name. The panel uses React Native primitives, so the same contribution works in the desktop, browser, iOS, and Android clients. Panel props contain stable IDs; `useWorkspace` selects the cached fields the component needs without fetching through RPC or re-rendering for unrelated workspace changes.
|
The icon is a [Lucide](https://lucide.dev/icons/) icon name. `*.client.tsx` files can use React Native runtime APIs such as `StyleSheet.create`; Paseo excludes them from the daemon bundle. The panel works in the desktop, browser, iOS, and Android clients. Panel props contain stable IDs; `useWorkspace` selects the cached fields the component needs without fetching through RPC or re-rendering for unrelated workspace changes.
|
||||||
|
|
||||||
## Check and install it
|
## Check and install it
|
||||||
|
|
||||||
@@ -113,7 +125,8 @@ paseo plugin logs workspace-plugin
|
|||||||
paseo plugin logs workspace-plugin --json
|
paseo plugin logs workspace-plugin --json
|
||||||
```
|
```
|
||||||
|
|
||||||
The log tail survives reloads and crashes, so inspect it when a plugin fails to start or an RPC
|
The log tail includes `[paseo]` loading, ready, stopping, and stopped entries, plus compilation and
|
||||||
|
load failures. It survives reloads and crashes. Inspect it when a plugin fails to start or an RPC
|
||||||
rejects. See [Debug backend output](/docs/plugins/reference#debug-backend-output) for retention and
|
rejects. See [Debug backend output](/docs/plugins/reference#debug-backend-output) for retention and
|
||||||
security behavior.
|
security behavior.
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ Plugin code is trusted and unsandboxed. Client surfaces run in the Paseo app. Ba
|
|||||||
```text
|
```text
|
||||||
my-plugin/
|
my-plugin/
|
||||||
paseo-plugin.json
|
paseo-plugin.json
|
||||||
index.tsx
|
index.ts
|
||||||
|
main.client.tsx
|
||||||
paseo-plugin.d.ts
|
paseo-plugin.d.ts
|
||||||
package.json
|
package.json
|
||||||
tsconfig.json
|
tsconfig.json
|
||||||
@@ -42,15 +43,33 @@ Plugin, surface, sidebar-item, workspace-panel, Command Center item, and attachm
|
|||||||
|
|
||||||
The generated declaration file supplies `@paseo/plugin` types for local typechecking. Paseo supplies the runtime modules. Regenerate a fresh project with the matching CLI when the plugin contract changes.
|
The generated declaration file supplies `@paseo/plugin` types for local typechecking. Paseo supplies the runtime modules. Regenerate a fresh project with the matching CLI when the plugin contract changes.
|
||||||
|
|
||||||
|
Add runtime-specific files as the plugin grows:
|
||||||
|
|
||||||
|
```text
|
||||||
|
my-plugin/
|
||||||
|
action.shared.ts
|
||||||
|
action.server.ts
|
||||||
|
panel.client.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
| Suffix | Use it for |
|
||||||
|
| -------------- | -------------------------------------------------------------------- |
|
||||||
|
| `*.client.tsx` | React, React Native, hooks, styles, surfaces, panels, and callbacks. |
|
||||||
|
| `*.server.ts` | Node APIs, local resources, credentials, and RPC handlers. |
|
||||||
|
| `*.shared.ts` | Zod RPC contracts and plain values imported by both runtimes. |
|
||||||
|
|
||||||
|
Paseo rejects imports from `*.server` files into client modules and imports from `*.client` files into server modules. Keep shared modules free of Node and React Native runtime code.
|
||||||
|
|
||||||
## Entry point and cleanup
|
## Entry point and cleanup
|
||||||
|
|
||||||
`index.tsx` default-exports one contribution function. It must return cleanup, even when it has nothing to clean:
|
`index.ts` wires contributions together and default-exports one contribution function. It must return cleanup, even when it has nothing to clean:
|
||||||
|
|
||||||
```tsx
|
```ts
|
||||||
import type { PluginContext } from "@paseo/plugin";
|
import type { PluginContext } from "@paseo/plugin";
|
||||||
|
import { Main } from "./main.client";
|
||||||
|
|
||||||
export default function contribute(plugin: PluginContext) {
|
export default function contribute(plugin: PluginContext) {
|
||||||
// Register contributions here.
|
plugin.addSurface("main", Main);
|
||||||
return () => {};
|
return () => {};
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -61,13 +80,22 @@ Cleanup can be async. Release timers, watchers, sockets, and other resources cre
|
|||||||
|
|
||||||
Register a component, then point a sidebar item at its surface ID:
|
Register a component, then point a sidebar item at its surface ID:
|
||||||
|
|
||||||
|
`main.client.tsx`:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import type { PluginContext, PluginSurfaceProps } from "@paseo/plugin";
|
import type { PluginSurfaceProps } from "@paseo/plugin";
|
||||||
import { Text } from "react-native";
|
import { Text } from "react-native";
|
||||||
|
|
||||||
function Main({ host, layout }: PluginSurfaceProps) {
|
export function Main({ host, layout }: PluginSurfaceProps) {
|
||||||
return <Text>{`${host.label} · ${layout.platform}`}</Text>;
|
return <Text>{`${host.label} · ${layout.platform}`}</Text>;
|
||||||
}
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`index.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { PluginContext } from "@paseo/plugin";
|
||||||
|
import { Main } from "./main.client";
|
||||||
|
|
||||||
export default function contribute(plugin: PluginContext) {
|
export default function contribute(plugin: PluginContext) {
|
||||||
plugin.addSurface("main", Main);
|
plugin.addSurface("main", Main);
|
||||||
@@ -97,20 +125,24 @@ Client code can import `react`, `react-native`, `@tanstack/react-query`, `zod`,
|
|||||||
|
|
||||||
Register one panel for workspace or agent context:
|
Register one panel for workspace or agent context:
|
||||||
|
|
||||||
|
`review.client.tsx`:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import {
|
import { type PluginAgentPanelProps, useAgent, useWorkspace } from "@paseo/plugin";
|
||||||
type PluginContext,
|
|
||||||
type PluginAgentPanelProps,
|
|
||||||
useAgent,
|
|
||||||
useWorkspace,
|
|
||||||
} from "@paseo/plugin";
|
|
||||||
import { Text } from "react-native";
|
import { Text } from "react-native";
|
||||||
|
|
||||||
function ReviewPanel({ workspaceId, agentId }: PluginAgentPanelProps) {
|
export function ReviewPanel({ workspaceId, agentId }: PluginAgentPanelProps) {
|
||||||
const workspaceName = useWorkspace(workspaceId, (workspace) => workspace.name);
|
const workspaceName = useWorkspace(workspaceId, (workspace) => workspace.name);
|
||||||
const agent = useAgent(agentId, ({ id, title }) => ({ id, title }));
|
const agent = useAgent(agentId, ({ id, title }) => ({ id, title }));
|
||||||
return <Text>{`${workspaceName} · ${agent?.title ?? agent?.id}`}</Text>;
|
return <Text>{`${workspaceName} · ${agent?.title ?? agent?.id}`}</Text>;
|
||||||
}
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`index.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { PluginContext } from "@paseo/plugin";
|
||||||
|
import { ReviewPanel } from "./review.client";
|
||||||
|
|
||||||
export default function contribute(plugin: PluginContext) {
|
export default function contribute(plugin: PluginContext) {
|
||||||
plugin.addWorkspacePanel({
|
plugin.addWorkspacePanel({
|
||||||
@@ -279,27 +311,53 @@ Use plugin RPC only for work that is not a normal Paseo operation: reading a ven
|
|||||||
|
|
||||||
Define one contract with Zod, handle it in the subprocess, and call it from the surface:
|
Define one contract with Zod, handle it in the subprocess, and call it from the surface:
|
||||||
|
|
||||||
```tsx
|
`greeting.shared.ts`:
|
||||||
import { defineRpc, type PluginContext, useRpc } from "@paseo/plugin";
|
|
||||||
|
```ts
|
||||||
|
import { defineRpc } from "@paseo/plugin";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const greeting = defineRpc({
|
export const greeting = defineRpc({
|
||||||
name: "greeting.create",
|
name: "greeting.create",
|
||||||
input: z.object({ name: z.string() }),
|
input: z.object({ name: z.string() }),
|
||||||
output: z.object({ message: z.string() }),
|
output: z.object({ message: z.string() }),
|
||||||
});
|
});
|
||||||
|
```
|
||||||
|
|
||||||
function GreetingButton() {
|
`greeting.client.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useRpc } from "@paseo/plugin";
|
||||||
|
import { greeting } from "./greeting.shared";
|
||||||
|
|
||||||
|
export function GreetingButton() {
|
||||||
const createGreeting = useRpc(greeting);
|
const createGreeting = useRpc(greeting);
|
||||||
// Call createGreeting({ name: "Ada" }) from an event or query.
|
// Call createGreeting({ name: "Ada" }) from an event or query.
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`greeting.server.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { output as ZodOutput } from "zod";
|
||||||
|
import { greeting } from "./greeting.shared";
|
||||||
|
|
||||||
|
export function createGreeting({ name }: ZodOutput<typeof greeting.input>) {
|
||||||
|
return { message: `Hello, ${name}` };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`index.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { PluginContext } from "@paseo/plugin";
|
||||||
|
import { GreetingButton } from "./greeting.client";
|
||||||
|
import { createGreeting } from "./greeting.server";
|
||||||
|
import { greeting } from "./greeting.shared";
|
||||||
|
|
||||||
export default function contribute(plugin: PluginContext) {
|
export default function contribute(plugin: PluginContext) {
|
||||||
plugin.handle(greeting, async ({ name }, { paseo }) => {
|
plugin.handle(greeting, createGreeting);
|
||||||
const { config } = await paseo.config.get();
|
|
||||||
return { message: `${name}: plugins are ${config.pluginsEnabled ? "on" : "off"}` };
|
|
||||||
});
|
|
||||||
plugin.addSurface("main", GreetingButton);
|
plugin.addSurface("main", GreetingButton);
|
||||||
return () => {};
|
return () => {};
|
||||||
}
|
}
|
||||||
@@ -318,8 +376,11 @@ console.log("Refreshing issues");
|
|||||||
console.error("Issue refresh failed", error);
|
console.error("Issue refresh failed", error);
|
||||||
```
|
```
|
||||||
|
|
||||||
Paseo captures output emitted during initialization, RPC handlers, cleanup, and process failure.
|
Paseo adds `[paseo]` entries when the plugin starts loading, becomes ready, starts stopping, and has
|
||||||
Protocol traffic uses a separate channel, so `console.log()` cannot corrupt plugin RPCs.
|
stopped. It records compilation and load failures as stderr entries, including failures that happen
|
||||||
|
before the plugin subprocess starts. Paseo also captures output emitted during initialization, RPC
|
||||||
|
handlers, cleanup, and process failure. Protocol traffic uses a separate channel, so `console.log()`
|
||||||
|
cannot corrupt plugin RPCs.
|
||||||
|
|
||||||
Open **Settings → Plugins → Logs** for the plugin, or inspect the same recent tail from the daemon
|
Open **Settings → Plugins → Logs** for the plugin, or inspect the same recent tail from the daemon
|
||||||
CLI:
|
CLI:
|
||||||
@@ -335,9 +396,9 @@ the command again for newer entries. Each entry includes its timestamp, stdout o
|
|||||||
sequence, and message.
|
sequence, and message.
|
||||||
|
|
||||||
Paseo retains up to 500 entries and 256 KiB per plugin in memory. Individual lines are capped at
|
Paseo retains up to 500 entries and 256 KiB per plugin in memory. Individual lines are capped at
|
||||||
16 KiB. Reload, disable, initialization failure, and process failure retain the tail. Removing the
|
16 KiB. Reload, disable, compilation failure, initialization failure, and process failure retain the
|
||||||
plugin clears it, and a daemon restart starts a new tail. Structured copies are also written to the
|
tail. Removing the plugin clears it, and a daemon restart starts a new tail. Structured copies are
|
||||||
daemon log at `$PASEO_HOME/daemon.log`.
|
also written to the daemon log at `$PASEO_HOME/daemon.log`.
|
||||||
|
|
||||||
Only daemon-side output is captured. Logs from client surfaces remain in the app runtime. Do not log
|
Only daemon-side output is captured. Logs from client surfaces remain in the app runtime. Do not log
|
||||||
credentials, access tokens, or other secrets: connected users can read the retained tail, and the
|
credentials, access tokens, or other secrets: connected users can read the retained tail, and the
|
||||||
@@ -347,11 +408,13 @@ daemon log persists it.
|
|||||||
|
|
||||||
An attachment source searches external resources and returns a stable text snapshot for an agent prompt. Keep credentials and vendor calls in the backend handler.
|
An attachment source searches external resources and returns a stable text snapshot for an agent prompt. Keep credentials and vendor calls in the backend handler.
|
||||||
|
|
||||||
```tsx
|
`issues.shared.ts`:
|
||||||
import { defineAttachmentSource, defineRpc, type PluginContext } from "@paseo/plugin";
|
|
||||||
|
```ts
|
||||||
|
import { defineAttachmentSource, defineRpc } from "@paseo/plugin";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const searchIssues = defineRpc({
|
export const searchIssues = defineRpc({
|
||||||
name: "issues.search",
|
name: "issues.search",
|
||||||
input: z.object({ query: z.string() }),
|
input: z.object({ query: z.string() }),
|
||||||
output: z.object({
|
output: z.object({
|
||||||
@@ -369,7 +432,7 @@ const searchIssues = defineRpc({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const issues = defineAttachmentSource({
|
export const issues = defineAttachmentSource({
|
||||||
id: "issues",
|
id: "issues",
|
||||||
title: "Acme issue",
|
title: "Acme issue",
|
||||||
icon: "CircleDot",
|
icon: "CircleDot",
|
||||||
@@ -377,9 +440,28 @@ const issues = defineAttachmentSource({
|
|||||||
searchPlaceholder: "Search by identifier or title",
|
searchPlaceholder: "Search by identifier or title",
|
||||||
search: searchIssues,
|
search: searchIssues,
|
||||||
});
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
`issues.server.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { output as ZodOutput } from "zod";
|
||||||
|
import { searchIssues } from "./issues.shared";
|
||||||
|
|
||||||
|
export function search({ query }: ZodOutput<typeof searchIssues.input>) {
|
||||||
|
return searchAcmeIssues(query);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`index.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { PluginContext } from "@paseo/plugin";
|
||||||
|
import { search } from "./issues.server";
|
||||||
|
import { issues, searchIssues } from "./issues.shared";
|
||||||
|
|
||||||
export default function contribute(plugin: PluginContext) {
|
export default function contribute(plugin: PluginContext) {
|
||||||
plugin.handle(searchIssues, ({ query }) => searchAcmeIssues(query));
|
plugin.handle(searchIssues, search);
|
||||||
plugin.addAttachmentSource(issues);
|
plugin.addAttachmentSource(issues);
|
||||||
return () => {};
|
return () => {};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user