fix(plugins): install local plugins on packaged desktop

The plugin compiler spawned esbuild through app.asar, and the subprocess
imported the React client barrel. Point esbuild at the unpacked binary,
add a react-free @paseo/plugin/server entry, and pass a typed PluginTheme
so plugin UI follows the active theme on desktop and mobile.
This commit is contained in:
Mohamed Boudra
2026-08-19 11:54:55 +02:00
parent ca2ee380df
commit 91024bc029
25 changed files with 643 additions and 153 deletions
+13 -2
View File
@@ -53,8 +53,9 @@ my-plugin/
```
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
public package. Regenerate new plugins with the matching Paseo CLI when the SDK contract changes.
The generated declaration file supplies `@paseo/plugin` and `@paseo/plugin/server` types until the
SDK is distributed as a public package. Regenerate new plugins with the matching Paseo CLI when the
SDK contract changes.
```json
{
@@ -95,6 +96,16 @@ code lives behind filename boundaries:
| `*.server.ts` | Node APIs, filesystem and process access, credentials, and handlers. |
| `*.shared.ts` | Zod RPC contracts and plain values used by both runtimes. |
Shared files import contracts from `@paseo/plugin/server`. Client files import hooks from
`@paseo/plugin`. Plugin UI runs on desktop and mobile across multiple themes: color every
`Text` from `theme.colors.foreground` or `theme.colors.foregroundMuted`, and size layout from
`layout.compact`. See `public-docs/plugins/reference.md`.
| Module | Use it for |
| ---------------------- | -------------------------------------------------------- |
| `@paseo/plugin` | hooks and UI types |
| `@paseo/plugin/server` | `defineRpc`, `defineAttachmentSource`, and handler types |
The compiler removes client registrations and imports from the server entry point, and server
registrations and imports from the client entry point. Importing a `*.server` module from a client
module, or a `*.client` module from a server module, fails compilation. Top-level React Native calls
+39
View File
@@ -162,6 +162,45 @@ describe("evaluatePluginClientBundle", () => {
).toThrow("must return a cleanup function");
});
it("resolves @paseo/plugin/server for shared RPC contracts", () => {
const plugin = evaluatePluginClientBundle(
"example",
`(function(require) {
const { defineRpc, defineAttachmentSource } = require("@paseo/plugin/server");
const search = defineRpc({ name: "issues.search", input: {}, output: {} });
const module = { exports: {} };
module.exports.default = function(plugin) {
plugin.addAttachmentSource(defineAttachmentSource({
id: "issues",
title: "Issue",
icon: "CircleDot",
pickerTitle: "Attach issue",
searchPlaceholder: "Search",
search,
}));
return function() {};
};
return module.exports;
})`,
);
expect(plugin.attachmentSources.map((source) => source.search.name)).toEqual(["issues.search"]);
});
it("rejects modules that are not part of the client runtime", () => {
expect(() =>
evaluatePluginClientBundle(
"example",
`(function(require) {
require("fs");
const module = { exports: {} };
module.exports.default = function() { return function() {}; };
return module.exports;
})`,
),
).toThrow('Module "fs" is not available in plugin client code');
});
it("does not publish partial contributions when setup fails", () => {
expect(() =>
evaluatePluginClientBundle(
+3
View File
@@ -159,6 +159,9 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
useRpc,
};
}
if (name === "@paseo/plugin/server") {
return { defineAttachmentSource, defineRpc };
}
if (name === "@tanstack/react-query") return ReactQuery;
if (name === "zod") return Zod;
throw new Error(`Module "${name}" is not available in plugin client code`);
+6 -6
View File
@@ -1,5 +1,5 @@
import { router, useLocalSearchParams } from "expo-router";
import type { PluginSurfaceProps } from "@paseo/plugin";
import type { PluginSurfaceProps, PluginTheme } from "@paseo/plugin";
import { ChevronDown, X } from "lucide-react-native";
import { useCallback, useMemo, useRef, useState, type ComponentType } from "react";
import { Platform, Pressable, Text, View } from "react-native";
@@ -14,6 +14,7 @@ import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime";
import type { Theme } from "@/styles/theme";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { resolvePluginIcon } from "./icons";
import { toPluginTheme } from "./theme";
import { useInstalledPlugin, usePluginInstallations } from "./registry";
import { buildPluginSurfaceRoute } from "./routes";
import { rememberPluginContributionHost } from "./sidebar-groups";
@@ -27,10 +28,9 @@ import {
} from "./surface-contribution";
const EMPTY_SHORTCUT_KEYS: ShortcutKey[] = [];
const EMPTY_THEME_DTO: Record<string, unknown> = {};
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const pluginThemeMapping = (theme: Theme) => ({
themeDto: JSON.parse(JSON.stringify(theme)) as Record<string, unknown>,
theme: toPluginTheme(theme),
});
const ThemedX = withUnistyles(X);
const ThemedChevronDown = withUnistyles(ChevronDown);
@@ -57,18 +57,18 @@ function SurfaceRenderer({
plugin,
layout,
host,
themeDto = EMPTY_THEME_DTO,
theme,
}: {
Surface: ComponentType<PluginSurfaceProps>;
runtime: NonNullable<ReturnType<typeof createPluginSurfaceRuntime>>;
plugin: NonNullable<ReturnType<typeof useInstalledPlugin>>;
layout: PluginSurfaceProps["layout"];
host: PluginSurfaceProps["host"];
themeDto?: Record<string, unknown>;
theme: PluginTheme;
}) {
return (
<PluginRuntimeBoundary plugin={plugin} runtime={runtime}>
<Surface theme={themeDto} host={host} layout={layout} />
<Surface theme={theme} host={host} layout={layout} />
</PluginRuntimeBoundary>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { lightTheme } from "@/styles/theme";
import { toPluginTheme } from "./theme";
describe("toPluginTheme", () => {
it("maps the app theme into the plugin color tokens", () => {
expect(toPluginTheme(lightTheme)).toEqual({
colors: {
surface0: lightTheme.colors.surface0,
foreground: lightTheme.colors.foreground,
foregroundMuted: lightTheme.colors.foregroundMuted,
accent: lightTheme.colors.accent,
accentForeground: lightTheme.colors.accentForeground,
statusDanger: lightTheme.colors.statusDanger,
},
});
});
});
+15
View File
@@ -0,0 +1,15 @@
import type { PluginTheme } from "@paseo/plugin";
import type { Theme } from "@/styles/theme";
export function toPluginTheme(theme: Theme): PluginTheme {
return {
colors: {
surface0: theme.colors.surface0,
foreground: theme.colors.foreground,
foregroundMuted: theme.colors.foregroundMuted,
accent: theme.colors.accent,
accentForeground: theme.colors.accentForeground,
statusDanger: theme.colors.statusDanger,
},
};
}
@@ -1,6 +1,7 @@
import type {
PluginAgentPanelProps,
PluginHostProps,
PluginTheme,
PluginWorkspacePanelProps,
} from "@paseo/plugin";
import { PluginClientStateProvider } from "@paseo/plugin/host";
@@ -18,6 +19,7 @@ import { useWorkspaceExists } from "@/stores/session-store-hooks";
import type { Theme } from "@/styles/theme";
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
import { createPluginClientStateSource } from "../client-state/source";
import { toPluginTheme } from "../theme";
import { resolvePluginIcon } from "../icons";
import { useInstalledPlugin } from "../registry";
import { PluginRuntimeBoundary } from "../runtime-boundary";
@@ -25,9 +27,8 @@ import { createPluginSurfaceRuntime } from "../surface-runtime";
import { SurfaceErrorBoundary } from "../surface-error-boundary";
import { resolvePluginWorkspacePanel } from "./resolution";
const EMPTY_THEME_DTO: Record<string, unknown> = {};
const pluginThemeMapping = (theme: Theme) => ({
themeDto: JSON.parse(JSON.stringify(theme)) as Record<string, unknown>,
theme: toPluginTheme(theme),
});
function resolvePlatform(): PluginHostProps["layout"]["platform"] {
@@ -36,7 +37,7 @@ function resolvePlatform(): PluginHostProps["layout"]["platform"] {
return "web";
}
function PluginPanelBody({ themeDto = EMPTY_THEME_DTO }: { themeDto?: Record<string, unknown> }) {
function PluginPanelBody({ theme }: { theme: PluginTheme }) {
const { serverId, workspaceId, target } = usePaneContext();
invariant(target.kind === "plugin", "PluginPanel requires plugin target");
const plugin = useInstalledPlugin(serverId, target.pluginId);
@@ -75,7 +76,7 @@ function PluginPanelBody({ themeDto = EMPTY_THEME_DTO }: { themeDto?: Record<str
if (contribution.context === "workspace") {
const props: PluginWorkspacePanelProps = {
context: "workspace",
theme: themeDto,
theme,
host,
layout,
workspaceId,
@@ -86,7 +87,7 @@ function PluginPanelBody({ themeDto = EMPTY_THEME_DTO }: { themeDto?: Record<str
} else if (agentExists && target.context === "agent") {
const props: PluginAgentPanelProps = {
context: "agent",
theme: themeDto,
theme,
host,
layout,
workspaceId,
@@ -50,7 +50,7 @@ describe("plugin scaffold", () => {
await Promise.all([
writeFile(
path.join(directory, "inspect.shared.ts"),
`import { defineRpc } from "@paseo/plugin";
`import { defineRpc } from "@paseo/plugin/server";
import { z } from "zod";
export const inspect = defineRpc({
@@ -62,7 +62,7 @@ export const inspect = defineRpc({
),
writeFile(
path.join(directory, "inspect.server.ts"),
`import type { PluginHandlerContext } from "@paseo/plugin";
`import type { PluginHandlerContext } from "@paseo/plugin/server";
import type { output as ZodOutput } from "zod";
import { inspect } from "./inspect.shared";
+105 -56
View File
@@ -2,13 +2,95 @@ import { mkdir, readdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { PluginIdSchema } from "@getpaseo/protocol/messages";
const SDK_DECLARATIONS = `declare module "@paseo/plugin" {
import type { ComponentType } from "react";
const SDK_DECLARATIONS = `declare module "@paseo/plugin/server" {
import type { PaseoApi } from "@getpaseo/client";
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
export interface PluginRpcContract<
InputSchema extends ZodType = ZodType,
OutputSchema extends ZodType = ZodType,
> {
name: string;
input: InputSchema;
output: OutputSchema;
}
export interface PluginAttachmentItem {
id: string;
identifier: string;
title: string;
subtitle?: string;
url: string;
text: string;
resourceType: string;
}
export interface PluginAttachmentSearchPayload {
items: PluginAttachmentItem[];
}
export interface PluginAttachmentSourceContribution {
id: string;
title: string;
icon: string;
pickerTitle: string;
searchPlaceholder: string;
search: PluginRpcContract;
}
export interface PluginHandlerContext {
paseo: PaseoApi;
}
export function defineRpc<InputSchema extends ZodType, OutputSchema extends ZodType>(definition: {
name: string;
input: InputSchema;
output: OutputSchema;
}): PluginRpcContract<InputSchema, OutputSchema>;
export function defineAttachmentSource<Definition extends PluginAttachmentSourceContribution>(
definition: Definition,
): Definition;
export const PluginAttachmentItemSchema: import("zod").ZodType<PluginAttachmentItem>;
export const PluginAttachmentSearchPayloadSchema: import("zod").ZodType<PluginAttachmentSearchPayload>;
}
declare module "@paseo/plugin" {
import type { ComponentType } from "react";
import type { PaseoApi } from "@getpaseo/client";
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
import type {
PluginAttachmentSourceContribution,
PluginHandlerContext,
PluginRpcContract,
} from "@paseo/plugin/server";
export {
PluginAttachmentItemSchema,
PluginAttachmentSearchPayloadSchema,
defineAttachmentSource,
defineRpc,
type PluginAttachmentItem,
type PluginAttachmentSearchPayload,
type PluginAttachmentSourceContribution,
type PluginHandlerContext,
type PluginRpcContract,
} from "@paseo/plugin/server";
export interface PluginTheme {
readonly colors: {
readonly surface0: string;
readonly foreground: string;
readonly foregroundMuted: string;
readonly accent: string;
readonly accentForeground: string;
readonly statusDanger: string;
};
}
export interface PluginHostProps {
theme: Record<string, unknown>;
theme: PluginTheme;
host: { id: string; label: string };
layout: { compact: boolean; platform: "ios" | "android" | "web" };
}
@@ -65,38 +147,6 @@ const SDK_DECLARATIONS = `declare module "@paseo/plugin" {
| { id: string; title: string; icon: string; context: "workspace"; Component: ComponentType<PluginWorkspacePanelProps> }
| { id: string; title: string; icon: string; context: "agent"; Component: ComponentType<PluginAgentPanelProps> };
export interface PluginRpcContract<
InputSchema extends ZodType = ZodType,
OutputSchema extends ZodType = ZodType,
> {
name: string;
input: InputSchema;
output: OutputSchema;
}
export interface PluginAttachmentItem {
id: string;
identifier: string;
title: string;
subtitle?: string;
url: string;
text: string;
resourceType: string;
}
export interface PluginAttachmentSearchPayload {
items: PluginAttachmentItem[];
}
export interface PluginAttachmentSourceContribution {
id: string;
title: string;
icon: string;
pickerTitle: string;
searchPlaceholder: string;
search: PluginRpcContract;
}
export interface PluginSidebarContribution {
id: string;
title: string;
@@ -140,10 +190,6 @@ const SDK_DECLARATIONS = `declare module "@paseo/plugin" {
| { id: string; title: string; icon: string; keywords?: readonly string[]; context: "workspace"; onSelect(context: PluginWorkspaceCommandContext): void | Promise<void> }
| { id: string; title: string; icon: string; keywords?: readonly string[]; context: "agent"; onSelect(context: PluginAgentCommandContext): void | Promise<void> };
export interface PluginHandlerContext {
paseo: PaseoApi;
}
export interface PluginContext {
handle<InputSchema extends ZodType, OutputSchema extends ZodType>(
contract: PluginRpcContract<InputSchema, OutputSchema>,
@@ -162,12 +208,6 @@ const SDK_DECLARATIONS = `declare module "@paseo/plugin" {
export type PluginCleanup = () => void | Promise<void>;
export type PluginContribution = (plugin: PluginContext) => PluginCleanup;
export function defineRpc<InputSchema extends ZodType, OutputSchema extends ZodType>(definition: {
name: string;
input: InputSchema;
output: OutputSchema;
}): PluginRpcContract<InputSchema, OutputSchema>;
export function useRpc<InputSchema extends ZodType, OutputSchema extends ZodType>(
contract: PluginRpcContract<InputSchema, OutputSchema>,
): (input: ZodInput<InputSchema>) => Promise<ZodOutput<OutputSchema>>;
@@ -183,13 +223,6 @@ const SDK_DECLARATIONS = `declare module "@paseo/plugin" {
agentId: string,
selector: (agent: PluginAgentSnapshot) => Selection,
): Selection | null;
export function defineAttachmentSource<Definition extends PluginAttachmentSourceContribution>(
definition: Definition,
): Definition;
export const PluginAttachmentItemSchema: import("zod").ZodType<PluginAttachmentItem>;
export const PluginAttachmentSearchPayloadSchema: import("zod").ZodType<PluginAttachmentSearchPayload>;
}
`;
@@ -218,11 +251,27 @@ export default function contribute(plugin: PluginContext) {
}
`;
const CLIENT_SURFACE = `import React from "react";
import { Text } from "react-native";
const CLIENT_SURFACE = `import type { PluginSurfaceProps } from "@paseo/plugin";
import React, { useMemo } from "react";
import { Text, View } from "react-native";
export function MainSurface() {
return <Text>Hello from my plugin</Text>;
export function MainSurface({ theme, layout }: PluginSurfaceProps) {
const styles = useMemo(
() => ({
screen: {
flex: 1,
padding: layout.compact ? 16 : 24,
backgroundColor: theme.colors.surface0,
},
text: { color: theme.colors.foreground },
}),
[theme, layout.compact],
);
return (
<View style={styles.screen}>
<Text style={styles.text}>Hello from my plugin</Text>
</View>
);
}
`;
+5
View File
@@ -13,6 +13,11 @@
"source": "./src/index.ts",
"default": "./dist/index.js"
},
"./server": {
"types": "./dist/server.d.ts",
"source": "./src/server.ts",
"default": "./dist/server.js"
},
"./host": {
"types": "./dist/host.d.ts",
"source": "./src/host.ts",
+12 -1
View File
@@ -3,8 +3,19 @@ import type { PaseoApi } from "@getpaseo/client";
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
import type { PluginRpcContract } from "./rpc.js";
export interface PluginTheme {
readonly colors: {
readonly surface0: string;
readonly foreground: string;
readonly foregroundMuted: string;
readonly accent: string;
readonly accentForeground: string;
readonly statusDanger: string;
};
}
export interface PluginHostProps {
theme: Record<string, unknown>;
theme: PluginTheme;
host: {
id: string;
label: string;
+5 -9
View File
@@ -1,9 +1,12 @@
export {
PluginAttachmentItemSchema,
PluginAttachmentSearchPayloadSchema,
defineAttachmentSource,
defineRpc,
type PluginAttachmentItem,
type PluginAttachmentSearchPayload,
} from "./attachments.js";
type PluginRpcContract,
} from "./server.js";
export type {
PluginAttachmentSourceContribution,
PluginAgentCommandContext,
@@ -17,6 +20,7 @@ export type {
PluginGlobalCommandContext,
PluginHandlerContext,
PluginHostProps,
PluginTheme,
PluginSidebarContribution,
PluginSurfaceContribution,
PluginSurfaceProps,
@@ -27,12 +31,4 @@ export type {
} from "./contracts.js";
export { usePaseo } from "./paseo-context.js";
export { useAgent, useWorkspace } from "./client-state.js";
import type { PluginAttachmentSourceContribution } from "./contracts.js";
export { useRpc } from "./rpc-context.js";
export { defineRpc, type PluginRpcContract } from "./rpc.js";
export function defineAttachmentSource<Definition extends PluginAttachmentSourceContribution>(
definition: Definition,
): Definition {
return definition;
}
+57
View File
@@ -0,0 +1,57 @@
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("@paseo/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([]);
});
});
+16
View File
@@ -0,0 +1,16 @@
import type { PluginAttachmentSourceContribution } from "./contracts.js";
export {
PluginAttachmentItemSchema,
PluginAttachmentSearchPayloadSchema,
type PluginAttachmentItem,
type PluginAttachmentSearchPayload,
} from "./attachments.js";
export type { PluginAttachmentSourceContribution, PluginHandlerContext } from "./contracts.js";
export { defineRpc, type PluginRpcContract } from "./rpc.js";
export function defineAttachmentSource<Definition extends PluginAttachmentSourceContribution>(
definition: Definition,
): Definition {
return definition;
}
@@ -0,0 +1,105 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
compilePlugin,
resolveExistingAsarUnpackedEsbuildBinary,
unpackedEsbuildBinaryFromPackageDir,
} from "./compiler.js";
const asarEsbuildDir = path.join("Resources", "app.asar", "node_modules", "esbuild");
describe("asar esbuild binary resolution", () => {
it("rewrites an asar package path to the unpacked platform binary", () => {
expect(unpackedEsbuildBinaryFromPackageDir(asarEsbuildDir, "darwin", "arm64")).toBe(
path.join(
"Resources",
"app.asar.unpacked",
"node_modules",
"@esbuild",
"darwin-arm64",
"bin",
"esbuild",
),
);
expect(unpackedEsbuildBinaryFromPackageDir(asarEsbuildDir, "win32", "x64")).toBe(
path.join(
"Resources",
"app.asar.unpacked",
"node_modules",
"@esbuild",
"win32-x64",
"esbuild.exe",
),
);
});
it("ignores package paths that are not inside app.asar", () => {
expect(
unpackedEsbuildBinaryFromPackageDir(path.join("node_modules", "esbuild"), "darwin", "arm64"),
).toBeNull();
});
it("returns null when the unpacked binary is missing", () => {
expect(
resolveExistingAsarUnpackedEsbuildBinary(asarEsbuildDir, "darwin", "arm64", () => false),
).toBeNull();
});
it("returns the unpacked path when the binary exists", () => {
expect(
resolveExistingAsarUnpackedEsbuildBinary(asarEsbuildDir, "linux", "x64", () => true),
).toBe(
path.join(
"Resources",
"app.asar.unpacked",
"node_modules",
"@esbuild",
"linux-x64",
"bin",
"esbuild",
),
);
});
});
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
);
});
describe("plugin author module externals", () => {
it("leaves @paseo/plugin/server external in both bundles", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-compiler-"));
temporaryDirectories.push(directory);
const entryPath = path.join(directory, "index.ts");
await writeFile(
entryPath,
`import type { PluginContext } from "@paseo/plugin";
import { defineRpc } from "@paseo/plugin/server";
import { z } from "zod";
const ping = defineRpc({
name: "ping",
input: z.object({}),
output: z.object({ ok: z.boolean() }),
});
export default function contribute(plugin: PluginContext) {
plugin.handle(ping, async () => ({ ok: true }));
return () => undefined;
}
`,
);
const { clientBundle, serverBundle } = await compilePlugin(entryPath);
expect(clientBundle).toContain("@paseo/plugin/server");
expect(serverBundle).toContain("@paseo/plugin/server");
expect(clientBundle).not.toContain("Invalid plugin RPC method");
expect(serverBundle).not.toContain("Invalid plugin RPC method");
});
});
+54 -2
View File
@@ -1,7 +1,57 @@
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { createRequire } from "node:module";
import path from "node:path";
import { parse } from "@babel/parser";
import { build, type Plugin } from "esbuild";
import type { Plugin } from "esbuild";
// esbuild resolves its own platform binary via require.resolve() the first time its
// module is evaluated. Inside the packaged desktop app that resolves to a path under
// app.asar even though electron-builder unpacks the real binary to app.asar.unpacked.
// child_process.spawn bypasses Electron's asar fs shim, so the OS rejects that path
// with ENOTDIR. Point esbuild at the real unpacked binary before its module loads.
export function unpackedEsbuildBinaryFromPackageDir(
esbuildPackageDir: string,
platform: NodeJS.Platform,
arch: string,
): string | null {
const asarSegment = `${path.sep}app.asar${path.sep}`;
const asarIndex = esbuildPackageDir.indexOf(asarSegment);
if (asarIndex === -1) return null;
return path.join(
esbuildPackageDir.slice(0, asarIndex),
"app.asar.unpacked",
"node_modules",
`@esbuild/${platform}-${arch}`,
...(platform === "win32" ? ["esbuild.exe"] : ["bin", "esbuild"]),
);
}
export function resolveExistingAsarUnpackedEsbuildBinary(
esbuildPackageDir: string,
platform: NodeJS.Platform = process.platform,
arch: string = process.arch,
exists: (file: string) => boolean = existsSync,
): string | null {
const binaryPath = unpackedEsbuildBinaryFromPackageDir(esbuildPackageDir, platform, arch);
return binaryPath && exists(binaryPath) ? binaryPath : null;
}
function resolveAsarUnpackedEsbuildBinary(): string | null {
const require = createRequire(import.meta.url);
let esbuildDir: string;
try {
esbuildDir = path.dirname(require.resolve("esbuild/package.json"));
} catch {
return null;
}
return resolveExistingAsarUnpackedEsbuildBinary(esbuildDir);
}
if (!process.env.ESBUILD_BINARY_PATH) {
const unpackedBinary = resolveAsarUnpackedEsbuildBinary();
if (unpackedBinary) process.env.ESBUILD_BINARY_PATH = unpackedBinary;
}
type PluginBuildTarget = "client" | "server";
@@ -228,6 +278,7 @@ function createUnusedPlatformModulePlugin(target: PluginBuildTarget): Plugin {
}
async function compileTarget(entryPath: string, target: PluginBuildTarget): Promise<string> {
const { build } = await import("esbuild");
const source = await readFile(entryPath, "utf8");
const filteredSource = filterEntrypoint(source, target);
const result = await build({
@@ -245,13 +296,14 @@ async function compileTarget(entryPath: string, target: PluginBuildTarget): Prom
target === "client"
? [
"@paseo/plugin",
"@paseo/plugin/server",
"@tanstack/react-query",
"react",
"react/jsx-runtime",
"react-native",
"zod",
]
: ["@paseo/plugin", "zod"],
: ["@paseo/plugin", "@paseo/plugin/server", "zod"],
plugins: [createRuntimeBoundaryPlugin(target), createUnusedPlatformModulePlugin(target)],
logLevel: "silent",
treeShaking: true,
@@ -5,7 +5,7 @@ import {
defineRpc,
type PluginHandlerContext,
type PluginRpcContract,
} from "@paseo/plugin";
} from "@paseo/plugin/server";
import { createPaseoApi, type PaseoApi } from "@getpaseo/client";
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { createPluginDaemonTransportFactory } from "./daemon-transport.js";
@@ -61,8 +61,10 @@ function register(contract: PluginRpcContract, handler: RpcHandler): void {
handlers.set(method, { contract: { ...contract, name: method }, handler });
}
const pluginAuthorRuntime = { defineAttachmentSource, defineRpc };
function runtimeRequire(name: string): unknown {
if (name === "@paseo/plugin") return { defineAttachmentSource, defineRpc };
if (name === "@paseo/plugin" || name === "@paseo/plugin/server") return pluginAuthorRuntime;
return nodeRequire(name);
}
@@ -597,7 +597,7 @@ export function Surface() {
),
writeFile(
path.join(directory, "inspect.shared.ts"),
`import { defineRpc } from "@paseo/plugin";
`import { defineRpc } from "@paseo/plugin/server";
import { z } from "zod";
export const inspectRpc = defineRpc({
+1 -1
View File
@@ -1,4 +1,4 @@
import type { PluginAttachmentSearchPayload } from "@paseo/plugin";
import type { PluginAttachmentSearchPayload } from "@paseo/plugin/server";
import type { output as ZodOutput } from "zod";
import { searchIssuesRpc } from "./issues.shared";
import { createLinearIssueSearch } from "./linear";
+1 -1
View File
@@ -1,4 +1,4 @@
import { defineAttachmentSource, defineRpc } from "@paseo/plugin";
import { defineAttachmentSource, defineRpc } from "@paseo/plugin/server";
import { z } from "zod";
const SearchPayloadSchema = z.object({
@@ -1,4 +1,4 @@
import { defineRpc } from "@paseo/plugin";
import { defineRpc } from "@paseo/plugin/server";
import { z } from "zod";
export const incrementRpc = defineRpc({
+13 -21
View File
@@ -2,36 +2,28 @@ import { useMutation } from "@tanstack/react-query";
import { type PluginWorkspacePanelProps, useRpc, useWorkspace } from "@paseo/plugin";
import React, { useCallback, useMemo } from "react";
import { Pressable, Text, View } from "react-native";
import { z } from "zod";
import { incrementRpc } from "./increment.shared";
const ExampleThemeSchema = z.object({
colors: z.object({
surface0: z.string(),
foreground: z.string(),
foregroundMuted: z.string(),
accent: z.string(),
accentForeground: z.string(),
statusDanger: z.string(),
}),
});
export function ExamplePanel({ theme, workspaceId }: PluginWorkspacePanelProps) {
const { colors } = ExampleThemeSchema.parse(theme);
export function ExamplePanel({ theme, layout, workspaceId }: PluginWorkspacePanelProps) {
const workspace = useWorkspace(workspaceId, ({ name }) => ({ name }));
const callIncrement = useRpc(incrementRpc);
const { data, error, isPending, mutate } = useMutation({ mutationFn: callIncrement });
const value = data?.value ?? 0;
const styles = useMemo(
() => ({
screen: { flex: 1, padding: 24, gap: 16, backgroundColor: colors.surface0 },
title: { color: colors.foreground, fontSize: 24 },
detail: { color: colors.foregroundMuted },
button: { padding: 14, borderRadius: 10, backgroundColor: colors.accent },
buttonText: { color: colors.accentForeground, textAlign: "center" as const },
error: { color: colors.statusDanger },
screen: {
flex: 1,
padding: layout.compact ? 16 : 24,
gap: 16,
backgroundColor: theme.colors.surface0,
},
title: { color: theme.colors.foreground, fontSize: layout.compact ? 20 : 24 },
detail: { color: theme.colors.foregroundMuted },
button: { padding: 14, borderRadius: 10, backgroundColor: theme.colors.accent },
buttonText: { color: theme.colors.accentForeground, textAlign: "center" as const },
error: { color: theme.colors.statusDanger },
}),
[colors],
[theme, layout.compact],
);
const handleIncrement = useCallback(() => {
mutate({ value });
+24 -10
View File
@@ -34,29 +34,43 @@ npm install
`init` creates a strict TypeScript project. It does not run the package manager. `index.ts` registers contributions; client UI lives in `*.client.tsx` files.
Plugins run on desktop, browser, iOS, and Android. Paseo ships several themes. Color every `Text` from `theme.colors.foreground` or `theme.colors.foregroundMuted`, and size layout from `layout.compact`. Hardcoded black text fails in dark themes.
Replace `main.client.tsx` with:
```tsx
import { type PluginWorkspacePanelProps, useWorkspace } from "@paseo/plugin";
import { StyleSheet, Text, View } from "react-native";
import { useMemo } from "react";
import { Text, View } from "react-native";
export function WorkspaceOverview({ workspaceId }: PluginWorkspacePanelProps) {
export function WorkspaceOverview({ theme, layout, workspaceId }: PluginWorkspacePanelProps) {
const workspace = useWorkspace(workspaceId, ({ name, directory }) => ({
name,
directory,
}));
const styles = useMemo(
() => ({
screen: {
flex: 1,
padding: layout.compact ? 16 : 24,
gap: layout.compact ? 8 : 12,
backgroundColor: theme.colors.surface0,
},
title: { color: theme.colors.foreground, fontSize: layout.compact ? 20 : 24 },
label: { color: theme.colors.foregroundMuted },
detail: { color: theme.colors.foreground },
}),
[theme, layout.compact],
);
return (
<View style={styles.screen}>
<Text style={styles.title}>{workspace?.name}</Text>
<Text>{workspace?.directory}</Text>
<Text style={styles.label}>Directory</Text>
<Text style={styles.detail}>{workspace?.directory}</Text>
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, padding: 24, gap: 8 },
title: { fontSize: 24 },
});
```
Replace `index.ts` with:
@@ -86,7 +100,7 @@ export default function contribute(plugin: PluginContext) {
}
```
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.
The icon is a [Lucide](https://lucide.dev/icons/) icon name. `*.client.tsx` files can use React Native runtime APIs; Paseo excludes them from the daemon bundle. Panel props contain stable IDs; `useWorkspace` selects the cached fields the component needs without fetching through RPC or re-rendering for unrelated workspace changes. See [Theme and layout](/docs/plugins/reference#theme-and-layout) for the required tokens.
## Check and install it
@@ -96,7 +110,7 @@ paseo plugin install /absolute/path/to/workspace-plugin
paseo plugin ls
```
Open a workspace, open the Command Center, and choose **Open workspace overview**. It opens as a normal workspace tab. If the item does not appear, confirm that **Enable plugins** is on, the plugin status is `running` in `paseo plugin ls`, and the client is viewing the host where you installed it.
Open a workspace, press **⌘K** on macOS or **Ctrl+K** on Windows and Linux, and choose **Open workspace overview**. It opens as a normal workspace tab. If the item does not appear, confirm that **Enable plugins** is on, the plugin status is `running` in `paseo plugin ls`, and the client is viewing the host where you installed it.
## Edit and reload
+76 -13
View File
@@ -41,7 +41,7 @@ The manifest contains the default plugin ID:
Plugin, surface, sidebar-item, workspace-panel, Command Center item, and attachment-source IDs start with a lowercase letter and contain lowercase letters, numbers, or hyphens.
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` and `@paseo/plugin/server` 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:
@@ -58,6 +58,13 @@ my-plugin/
| `*.server.ts` | Node APIs, local resources, credentials, and RPC handlers. |
| `*.shared.ts` | Zod RPC contracts and plain values imported by both runtimes. |
## SDK modules
| Module | Use it for |
| ---------------------- | -------------------------------------------------------- |
| `@paseo/plugin` | hooks and UI types |
| `@paseo/plugin/server` | `defineRpc`, `defineAttachmentSource`, and handler types |
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
@@ -84,10 +91,28 @@ Register a component, then point a sidebar item at its surface ID:
```tsx
import type { PluginSurfaceProps } from "@paseo/plugin";
import { Text } from "react-native";
import { useMemo } from "react";
import { Text, View } from "react-native";
export function Main({ host, layout }: PluginSurfaceProps) {
return <Text>{`${host.label} · ${layout.platform}`}</Text>;
export function Main({ theme, host, layout }: PluginSurfaceProps) {
const styles = useMemo(
() => ({
screen: {
flex: 1,
padding: layout.compact ? 16 : 24,
backgroundColor: theme.colors.surface0,
},
title: { color: theme.colors.foreground },
detail: { color: theme.colors.foregroundMuted },
}),
[theme, layout.compact],
);
return (
<View style={styles.screen}>
<Text style={styles.title}>{host.label}</Text>
<Text style={styles.detail}>{layout.platform}</Text>
</View>
);
}
```
@@ -112,14 +137,32 @@ export default function contribute(plugin: PluginContext) {
`PluginSurfaceProps` contains:
| Field | Meaning |
| -------- | --------------------------------------------------------- |
| `theme` | Paseo theme values. Validate the keys your surface reads. |
| -------- | ------------------------------------------------------------ |
| `theme` | Typed `PluginTheme` color tokens for the active Paseo theme. |
| `host` | Selected host `id` and display `label`. |
| `layout` | `compact` and the `ios`, `android`, or `web` platform. |
Paseo owns the route, header, close action, host picker, error boundary, and query client. The plugin owns the surface body. Icons use [Lucide](https://lucide.dev/icons/) names.
Client code can import `react`, `react-native`, `@tanstack/react-query`, `zod`, and `@paseo/plugin`. Install them locally for typechecking; Paseo provides the client runtime instances.
## Theme and layout
Plugin UI runs on desktop, browser, iOS, and Android, across every Paseo theme. `theme` is a typed `PluginTheme`. Color and spacing must come from those props. Unstyled `Text` is black and fails in dark themes.
Recreate styles when `theme` or `layout.compact` changes.
| Key | Required for | Use it for |
| ------------------------------ | -------------------------- | ----------------------------------- |
| `theme.colors.foreground` | Every primary `Text` | Titles and body copy |
| `theme.colors.foregroundMuted` | Secondary `Text` | Labels and supporting copy |
| `theme.colors.surface0` | Root view | Panel background |
| `layout.compact` | Padding and stacking | `true` on mobile and narrow windows |
| `layout.platform` | Platform-specific behavior | `ios`, `android`, or `web` |
Do not hardcode `#000`, `#fff`, or React Native's default text color. Primary copy uses `foreground`. Labels use `foregroundMuted`. Tighten padding when `layout.compact` is true.
Workspace and agent panels receive the same `theme` and `layout` fields.
Client code can import `react`, `react-native`, `@tanstack/react-query`, `zod`, `@paseo/plugin`, and `@paseo/plugin/server`. Install them locally for typechecking; Paseo provides the client runtime instances.
## Workspace panels
@@ -129,12 +172,30 @@ Register one panel for workspace or agent context:
```tsx
import { type PluginAgentPanelProps, useAgent, useWorkspace } from "@paseo/plugin";
import { Text } from "react-native";
import { useMemo } from "react";
import { Text, View } from "react-native";
export function ReviewPanel({ workspaceId, agentId }: PluginAgentPanelProps) {
export function ReviewPanel({ theme, layout, workspaceId, agentId }: PluginAgentPanelProps) {
const workspaceName = useWorkspace(workspaceId, (workspace) => workspace.name);
const agent = useAgent(agentId, ({ id, title }) => ({ id, title }));
return <Text>{`${workspaceName} · ${agent?.title ?? agent?.id}`}</Text>;
const styles = useMemo(
() => ({
screen: {
flex: 1,
padding: layout.compact ? 16 : 24,
backgroundColor: theme.colors.surface0,
},
title: { color: theme.colors.foreground },
detail: { color: theme.colors.foregroundMuted },
}),
[theme, layout.compact],
);
return (
<View style={styles.screen}>
<Text style={styles.title}>{workspaceName}</Text>
<Text style={styles.detail}>{agent?.title ?? agent?.id}</Text>
</View>
);
}
```
@@ -215,10 +276,12 @@ Paseo owns tab focus, splitting, closing, persistence, query state, the API/RPC
## Command Center items
Open the Command Center with **⌘K** on macOS or **Ctrl+K** on Windows and Linux, then search for the item title.
Register an action and open a panel from the callback:
```tsx
import { defineRpc } from "@paseo/plugin";
import { defineRpc } from "@paseo/plugin/server";
import { z } from "zod";
const refreshReview = defineRpc({
@@ -314,7 +377,7 @@ Define one contract with Zod, handle it in the subprocess, and call it from the
`greeting.shared.ts`:
```ts
import { defineRpc } from "@paseo/plugin";
import { defineRpc } from "@paseo/plugin/server";
import { z } from "zod";
export const greeting = defineRpc({
@@ -411,7 +474,7 @@ An attachment source searches external resources and returns a stable text snaps
`issues.shared.ts`:
```ts
import { defineAttachmentSource, defineRpc } from "@paseo/plugin";
import { defineAttachmentSource, defineRpc } from "@paseo/plugin/server";
import { z } from "zod";
export const searchIssues = defineRpc({
+58 -17
View File
@@ -62,16 +62,34 @@ Cleanup can be async. Use it for timers, watchers, sockets, and other resources
## Add a workspace panel
Workspace panels live beside agents, terminals, files, and diffs. Their props contain stable IDs;
required-selector hooks read cached client state without subscribing to unrelated fields:
Workspace panels live beside agents, terminals, files, and diffs. Plugins run on desktop and
mobile, and Paseo has multiple themes. Every `Text` must take its color from `theme.colors`.
Use `layout.compact` for padding and stacking. Unstyled text is black and fails in dark themes.
```tsx
import { type PluginContext, type PluginWorkspacePanelProps, useWorkspace } from "@paseo/plugin";
import { Text } from "react-native";
import { useMemo } from "react";
import { Text, View } from "react-native";
function Overview({ workspaceId }: PluginWorkspacePanelProps) {
function Overview({ theme, layout, workspaceId }: PluginWorkspacePanelProps) {
const name = useWorkspace(workspaceId, (workspace) => workspace.name);
return <Text>{name}</Text>;
const styles = useMemo(
() => ({
screen: {
flex: 1,
padding: layout.compact ? 16 : 24,
gap: layout.compact ? 8 : 12,
backgroundColor: theme.colors.surface0,
},
title: { color: theme.colors.foreground, fontSize: layout.compact ? 20 : 24 },
}),
[theme, layout.compact],
);
return (
<View style={styles.screen}>
<Text style={styles.title}>{name}</Text>
</View>
);
}
export default function contribute(plugin: PluginContext) {
@@ -102,24 +120,39 @@ the active workspace or agent. Command callbacks receive the selected host's `pa
## Add a sidebar surface
Plugin surfaces use React Native primitives and work across desktop, browser, iOS, and Android. Register the surface before its sidebar item:
Plugin surfaces use React Native primitives and work across desktop, browser, iOS, and Android. Register the surface before its sidebar item. Color text from `theme.colors` and pad from `layout.compact`.
```tsx
import type { PluginContext } from "@paseo/plugin";
import React, { useState } from "react";
import type { PluginContext, PluginSurfaceProps } from "@paseo/plugin";
import React, { useMemo, useState } from "react";
import { Pressable, Text, View } from "react-native";
function Counter() {
function Counter({ theme, layout }: PluginSurfaceProps) {
const [count, setCount] = useState(0);
const styles = useMemo(
() => ({
screen: {
flex: 1,
padding: layout.compact ? 16 : 24,
gap: 16,
backgroundColor: theme.colors.surface0,
},
count: { color: theme.colors.foreground, fontSize: layout.compact ? 36 : 48 },
button: { padding: 14, borderRadius: 10, backgroundColor: theme.colors.accent },
buttonText: { color: theme.colors.accentForeground, textAlign: "center" as const },
}),
[theme, layout.compact],
);
return (
<View style={{ flex: 1, padding: 24, gap: 16 }}>
<Text style={{ fontSize: 48 }}>{count}</Text>
<View style={styles.screen}>
<Text style={styles.count}>{count}</Text>
<Pressable
accessibilityRole="button"
accessibilityLabel={`Increment counter, currently ${count}`}
onPress={() => setCount((value) => value + 1)}
style={styles.button}
>
<Text>Count me in</Text>
<Text style={styles.buttonText}>Count me in</Text>
</Pressable>
</View>
);
@@ -137,9 +170,14 @@ export default function contribute(plugin: PluginContext) {
}
```
Icons are Lucide icon names. `PluginSurfaceProps` provides `theme`, selected `host`, and `layout`. Validate the theme keys the surface reads. Paseo owns the route, header, host picker, close action, error boundary, and per-installation query client.
Icons are Lucide icon names. `theme` is a typed `PluginTheme` on every surface and panel. Primary text uses `theme.colors.foreground`; labels use `theme.colors.foregroundMuted`; the root view uses `theme.colors.surface0`. `layout.compact` is true on mobile and narrow windows. Paseo owns the route, header, host picker, close action, error boundary, and per-installation query client.
Client code may import `react`, `react-native`, `@tanstack/react-query`, `zod`, and `@paseo/plugin`. Install dependencies locally for typechecking; Paseo supplies these runtime modules.
Client code may import `react`, `react-native`, `@tanstack/react-query`, `zod`, `@paseo/plugin`, and `@paseo/plugin/server`. Install dependencies locally for typechecking; Paseo supplies these runtime modules.
| Module | Use it for |
| ---------------------- | ---------------------------------------------------- |
| `@paseo/plugin` | hooks and UI types |
| `@paseo/plugin/server` | `defineRpc`, `defineAttachmentSource`, handler types |
## Choose the correct API
@@ -183,7 +221,9 @@ The API covers workspaces, agents, providers, and daemon config. It omits connec
Define one Zod contract, register its subprocess handler, and call it with `useRpc()`:
```tsx
import { defineRpc, type PluginContext, useRpc } from "@paseo/plugin";
import type { PluginContext } from "@paseo/plugin";
import { useRpc } from "@paseo/plugin";
import { defineRpc } from "@paseo/plugin/server";
import { z } from "zod";
const greeting = defineRpc({
@@ -235,7 +275,8 @@ daemon log. Never log credentials or other secrets.
Define a search RPC and register a declarative source:
```tsx
import { defineAttachmentSource, defineRpc, type PluginContext } from "@paseo/plugin";
import type { PluginContext } from "@paseo/plugin";
import { defineAttachmentSource, defineRpc } from "@paseo/plugin/server";
import { z } from "zod";
const searchIssues = defineRpc({
@@ -328,7 +369,7 @@ After a change:
1. Run `npm run typecheck`.
2. Install or reload the exact runtime ID.
3. Run `paseo plugin ls` and require `running` with no error.
4. Confirm the contribution on the intended host. For UI work, check both a wide client and a compact/mobile client when available.
4. Confirm the contribution on the intended host. Open the Command Center with **⌘K** (macOS) or **Ctrl+K** (Windows/Linux). For UI work, check a wide desktop window and a compact/mobile client, and switch theme to confirm text still uses `foreground` / `foregroundMuted`.
5. Exercise the changed action or RPC, including its error state.
Common failures: