diff --git a/CLAUDE.md b/CLAUDE.md index 82776096f3..26a06231ae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,6 +40,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the | [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end | | [docs/forge-providers.md](docs/forge-providers.md) | Adding a git forge: registry/manifest, drop-in checklist, self-host/GHES, the two facts tiers | | [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries | +| [docs/plugins.md](docs/plugins.md) | Local plugin manifest, directory source config, RPCs, and native surfaces | | [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config | | [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP | | [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs | diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 0000000000..2e896f3251 --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,89 @@ +# Local plugins + +Local plugins contribute daemon RPCs and native app surfaces from one `index.tsx`. Paseo executes +the server contribution in a subprocess and evaluates the client contribution in the app runtime. +Plugin code is trusted code; this first slice does not sandbox it. + +## Configure a directory source + +Add the plugin to the root `plugins` object in `$PASEO_HOME/config.json`: + +```json +{ + "plugins": { + "my-plugin": { + "source": "directory", + "path": "/absolute/path/to/my-plugin" + } + } +} +``` + +The directory contains an identity-only manifest and one entry point: + +```text +my-plugin/ + paseo-plugin.json + index.tsx +``` + +```json +{ + "id": "my-plugin" +} +``` + +The manifest ID must match the config key. Restart the development daemon after changing plugin +source or configuration; directory watching is outside the first slice. + +## Contribute behavior and UI + +Default export one contribution function. Paseo calls it with a plugin-scoped context. The compiler +removes UI registrations from the server bundle and RPC registrations from the client bundle before +resolving dependencies. + +```tsx +import { Text } from "react-native"; +import { useQuery } from "@tanstack/react-query"; +import { z } from "zod"; +import { defineRpc, type PluginContext, useRpc } from "@paseo/plugin"; + +const greetRpc = defineRpc({ + name: "greet", + input: z.object({ name: z.string() }), + output: z.object({ message: z.string() }), +}); + +function Greeting() { + const greet = useRpc(greetRpc); + const query = useQuery({ + queryKey: ["greeting", "Paseo"], + queryFn: () => greet({ name: "Paseo" }), + }); + return {query.data?.message}; +} + +export default function contribute(plugin: PluginContext) { + plugin.handle(greetRpc, async ({ name }) => ({ message: `Hello, ${name}` })); + plugin.addSurface("main", Greeting); + plugin.addSidebarItem({ + id: "main", + title: "Greeting", + icon: "MessageCircle", + surface: "main", + }); +} +``` + +Paseo owns the route, screen header, Lucide icon validation, close action, theme DTO, layout facts, +and render error boundary. The contributed component owns the complete body below the header. + +RPC contracts validate inputs and outputs in both the app and plugin subprocess. `useRpc` returns a +typed async function. Use the host-provided `@tanstack/react-query` for request state and caching; +Paseo gives each plugin installation its own query client. + +When the same plugin contribution exists on multiple hosts, Paseo shows it once in the sidebar and +adds a host picker to the screen header. The selected host supplies the bundle, RPC transport, and +query cache. Plugin code cannot address another host. + +See `examples/local-plugin` for a runnable plugin. diff --git a/examples/local-plugin/index.tsx b/examples/local-plugin/index.tsx new file mode 100644 index 0000000000..a3299b920c --- /dev/null +++ b/examples/local-plugin/index.tsx @@ -0,0 +1,74 @@ +import React, { useCallback, useMemo } from "react"; +import { Pressable, Text, View } from "react-native"; +import { useMutation } from "@tanstack/react-query"; +import { z } from "zod"; +import { defineRpc, type PluginContext, useRpc } from "@paseo/plugin"; + +const incrementRpc = defineRpc({ + name: "increment", + input: z.object({ value: z.number() }), + output: z.object({ value: z.number(), handledBy: z.string() }), +}); + +interface ExampleTheme { + colors: { + surface0: string; + foreground: string; + foregroundMuted: string; + accent: string; + accentForeground: string; + statusDanger: string; + }; +} + +function ExampleSurface({ theme }: { theme: ExampleTheme }) { + 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: theme.colors.surface0 }, + title: { color: theme.colors.foreground, fontSize: 24 }, + detail: { color: theme.colors.foregroundMuted }, + button: { padding: 14, borderRadius: 10, backgroundColor: theme.colors.accent }, + buttonText: { color: theme.colors.accentForeground, textAlign: "center" }, + error: { color: theme.colors.statusDanger }, + }), + [theme.colors], + ); + const handleIncrement = useCallback(() => { + mutate({ value }); + }, [mutate, value]); + + return ( + + Native plugin surface + {data?.handledBy ?? "The RPC has not run yet."} + + + {isPending ? "Calling daemon…" : `RPC counter: ${value}`} + + + {error ? {error.message} : null} + + ); +} + +export default function contribute(plugin: PluginContext) { + plugin.handle(incrementRpc, async (input) => ({ + value: input.value + 1, + handledBy: "plugin subprocess", + })); + plugin.addSurface("main", ExampleSurface); + plugin.addSidebarItem({ + id: "main", + title: "Local plugin", + icon: "Blocks", + surface: "main", + }); +} diff --git a/examples/local-plugin/paseo-plugin.json b/examples/local-plugin/paseo-plugin.json new file mode 100644 index 0000000000..d1a1b37435 --- /dev/null +++ b/examples/local-plugin/paseo-plugin.json @@ -0,0 +1,3 @@ +{ + "id": "local-example" +} diff --git a/package-lock.json b/package-lock.json index 6a7a31f13e..37dbc92ea7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2291,6 +2291,15 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/standalone": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/standalone/-/standalone-7.29.1.tgz", + "integrity": "sha512-z42abD0C6fiHfgLyCWw8PYv6FCJ0IGVtSCxXk/NPykWO5LNIEGfdLDJ3HdYqlPcAhwtQ3oKH1PvNj2JGpTxQKg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -12503,6 +12512,21 @@ "@babel/types": "^7.0.0" } }, + "node_modules/@types/babel__standalone": { + "version": "7.1.9", + "resolved": "https://registry.npmjs.org/@types/babel__standalone/-/babel__standalone-7.1.9.tgz", + "integrity": "sha512-IcCNPLqpevUD7UpV8QB0uwQPOyoOKACFf0YtYWRHcmxcakaje4Q7dbG2+jMqxw/I8Zk0NHvEps66WwS7z/UaaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.6", + "@babel/types": "^7.25.6", + "@types/babel__core": "^7.20.5", + "@types/babel__generator": "^7.6.8", + "@types/babel__template": "^7.4.4", + "@types/babel__traverse": "^7.20.6" + } + }, "node_modules/@types/babel__template": { "version": "7.4.4", "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", @@ -35301,6 +35325,7 @@ "name": "@getpaseo/app", "version": "0.3.0", "dependencies": { + "@babel/standalone": "^7.29.1", "@codemirror/commands": "6.10.4", "@codemirror/language": "6.12.4", "@codemirror/search": "6.7.1", @@ -35393,6 +35418,7 @@ "@playwright/test": "^1.56.1", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", + "@types/babel__standalone": "^7.1.9", "@types/markdown-it": "^14.1.2", "@types/qrcode": "^1.5.6", "@types/react": "~19.2.0", @@ -38226,6 +38252,7 @@ "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "^0.3.220", "@anthropic-ai/sdk": "^0.104.2", + "@babel/parser": "^7.29.8", "@getpaseo/client": "0.3.0", "@getpaseo/highlight": "0.3.0", "@getpaseo/protocol": "0.3.0", @@ -38239,6 +38266,7 @@ "ajv": "^8.20.0", "bcryptjs": "^3.0.3", "dotenv": "^17.2.3", + "esbuild": "^0.25.12", "express": "^4.18.2", "fast-deep-equal": "^3.1.3", "lru-cache": "^11.5.1", @@ -38402,6 +38430,468 @@ "win32" ] }, + "packages/server/node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "packages/server/node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "packages/server/node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "packages/server/node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "packages/server/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "packages/server/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "packages/server/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "packages/server/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "packages/server/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "packages/server/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "packages/server/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "packages/server/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "packages/server/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/server/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/server/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/server/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/server/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/server/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/server/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/server/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/server/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/server/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "packages/server/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "packages/server/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "packages/server/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "packages/server/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "packages/server/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "packages/server/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "packages/server/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "packages/server/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "packages/server/node_modules/@isaacs/ttlcache": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-2.1.4.tgz", @@ -38556,6 +39046,47 @@ "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, + "packages/server/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, "packages/server/node_modules/isexe": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", diff --git a/packages/app/package.json b/packages/app/package.json index da9d784c2a..ad8433f530 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -32,6 +32,7 @@ "build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs" }, "dependencies": { + "@babel/standalone": "^7.29.1", "@codemirror/commands": "6.10.4", "@codemirror/language": "6.12.4", "@codemirror/search": "6.7.1", @@ -124,6 +125,7 @@ "@playwright/test": "^1.56.1", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", + "@types/babel__standalone": "^7.1.9", "@types/markdown-it": "^14.1.2", "@types/qrcode": "^1.5.6", "@types/react": "~19.2.0", diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index a9a49e326e..02cf6129f3 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -124,6 +124,7 @@ import { } from "@/utils/host-routes"; import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing"; import { navigateToAgent } from "@/utils/navigate-to-agent"; +import { PluginCatalogSync } from "@/plugins/catalog-sync"; import { ensureOsNotificationPermission, WEB_NOTIFICATION_CLICK_EVENT, @@ -266,7 +267,7 @@ function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) { return ( - {null} + ); } @@ -892,6 +893,7 @@ function RootStack() { return ( + diff --git a/packages/app/src/app/h/[serverId]/_layout.tsx b/packages/app/src/app/h/[serverId]/_layout.tsx index 4cd3428e08..b40299f5e0 100644 --- a/packages/app/src/app/h/[serverId]/_layout.tsx +++ b/packages/app/src/app/h/[serverId]/_layout.tsx @@ -41,6 +41,7 @@ function KnownHostRoute() { + ); diff --git a/packages/app/src/app/h/[serverId]/plugin/[pluginId]/[surfaceId].tsx b/packages/app/src/app/h/[serverId]/plugin/[pluginId]/[surfaceId].tsx new file mode 100644 index 0000000000..112d9b597f --- /dev/null +++ b/packages/app/src/app/h/[serverId]/plugin/[pluginId]/[surfaceId].tsx @@ -0,0 +1,5 @@ +import { PluginSurfaceScreen } from "@/plugins/surface-screen"; + +export default function PluginSurfaceRoute() { + return ; +} diff --git a/packages/app/src/app/runtime-component.tsx b/packages/app/src/app/runtime-component.tsx new file mode 100644 index 0000000000..9b881dcb3c --- /dev/null +++ b/packages/app/src/app/runtime-component.tsx @@ -0,0 +1,5 @@ +import { RuntimeComponentPrototypeScreen } from "@/runtime-component/prototype-screen"; + +export default function RuntimeComponentRoute() { + return ; +} diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index bfb75a08ee..e56a5f3cf7 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -74,6 +74,7 @@ import type { ShortcutKey } from "@/utils/format-shortcut"; import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton"; import { SidebarCalloutSlot } from "./sidebar-callout-slot"; import { SidebarWorkspaceList } from "./sidebar-workspace-list"; +import { PluginSidebarItems } from "@/plugins/sidebar-items"; type SidebarTheme = ReturnType["theme"]; @@ -678,6 +679,7 @@ function MobileSidebar({ testID="sidebar-schedules" variant="compact" /> + + diff --git a/packages/app/src/plugins/catalog-sync.tsx b/packages/app/src/plugins/catalog-sync.tsx new file mode 100644 index 0000000000..471b6a824f --- /dev/null +++ b/packages/app/src/plugins/catalog-sync.tsx @@ -0,0 +1,43 @@ +import { useEffect } from "react"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { useHostFeature } from "@/runtime/host-features"; +import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { pluginRegistry } from "./registry"; + +export function PluginCatalogSync({ + serverId, + client, +}: { + serverId: string; + client: DaemonClient; +}) { + const connected = useHostRuntimeIsConnected(serverId); + const supported = useHostFeature(serverId, "plugins"); + + useEffect(() => { + let cancelled = false; + if (!supported) { + pluginRegistry.removeHost(serverId); + return; + } + if (!connected) return; + void client + .getPluginCatalog() + .then((catalog) => { + if (!cancelled) pluginRegistry.installCatalog(serverId, catalog); + return undefined; + }) + .catch((error) => { + if (!cancelled) { + console.warn(`[Plugins] Failed to load catalog for ${serverId}`, error); + } + return undefined; + }); + return () => { + cancelled = true; + }; + }, [client, connected, serverId, supported]); + + useEffect(() => () => pluginRegistry.removeHost(serverId), [serverId]); + return null; +} diff --git a/packages/app/src/plugins/evaluate.test.ts b/packages/app/src/plugins/evaluate.test.ts new file mode 100644 index 0000000000..3f32de51dd --- /dev/null +++ b/packages/app/src/plugins/evaluate.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { evaluatePluginClientBundle } from "./evaluate"; + +function bundle(body: string): string { + return `(function() { + const module = { exports: {} }; + module.exports.default = function(plugin) { ${body} }; + return module.exports; + })`; +} + +describe("evaluatePluginClientBundle", () => { + it("collects a surface and its sidebar placement", () => { + const plugin = evaluatePluginClientBundle( + "example", + bundle(` + function Surface() { return null; } + plugin.addSurface("main", Surface); + plugin.addSidebarItem({ id: "main", title: "Example", icon: "Blocks", surface: "main" }); + `), + ); + + expect(plugin.id).toBe("example"); + expect(plugin.surfaces.map((surface) => surface.id)).toEqual(["main"]); + expect(plugin.sidebarItems).toEqual([ + { id: "main", title: "Example", icon: "Blocks", surface: "main" }, + ]); + }); + + it("rejects a sidebar placement whose surface does not exist", () => { + expect(() => + evaluatePluginClientBundle( + "example", + bundle(` + plugin.addSidebarItem({ id: "main", title: "Example", icon: "Blocks", surface: "missing" }); + `), + ), + ).toThrow("references missing surface missing"); + }); + + it("rejects a bundle without a default contribution function", () => { + expect(() => evaluatePluginClientBundle("example", `(function() { return {}; })`)).toThrow( + "must default export a function", + ); + }); +}); diff --git a/packages/app/src/plugins/evaluate.ts b/packages/app/src/plugins/evaluate.ts new file mode 100644 index 0000000000..b20c0f4598 --- /dev/null +++ b/packages/app/src/plugins/evaluate.ts @@ -0,0 +1,76 @@ +import * as React from "react"; +import * as ReactJsxRuntime from "react/jsx-runtime"; +import * as ReactNative from "react-native"; +// eslint-disable-next-line no-restricted-imports -- plugin bundles receive TanStack's real runtime, not Paseo's query wrappers. +import * as ReactQuery from "@tanstack/react-query"; +import * as Zod from "zod"; +import { createPluginContext, defineRpc, type PluginRegistrationCollector, useRpc } from "./sdk"; +import type { EvaluatedPlugin, PluginSidebarContribution, PluginSurfaceProps } from "./types"; +import type { ComponentType } from "react"; +import { resolvePluginIcon } from "./icons"; + +const CONTRIBUTION_ID = /^[a-z][a-z0-9-]*$/; + +function requireId(value: string, label: string): string { + const id = value.trim(); + if (!CONTRIBUTION_ID.test(id)) throw new Error(`Invalid ${label}: ${value}`); + return id; +} + +export function evaluatePluginClientBundle(id: string, bundle: string): EvaluatedPlugin { + const collector: PluginRegistrationCollector = { surfaces: [], sidebarItems: [] }; + const surfaceIds = new Set(); + const sidebarItemIds = new Set(); + const pluginContext = createPluginContext({ + addSurface(surfaceId: string, Component: ComponentType) { + const normalizedId = requireId(surfaceId, "surface id"); + if (surfaceIds.has(normalizedId)) throw new Error(`Duplicate surface: ${normalizedId}`); + if (typeof Component !== "function") + throw new Error(`Surface ${normalizedId} is not a component`); + surfaceIds.add(normalizedId); + collector.surfaces.push({ id: normalizedId, Component }); + }, + addSidebarItem(contribution: PluginSidebarContribution) { + const normalizedId = requireId(contribution.id, "sidebar item id"); + if (sidebarItemIds.has(normalizedId)) + throw new Error(`Duplicate sidebar item: ${normalizedId}`); + if (!contribution.title.trim()) throw new Error(`Sidebar item ${normalizedId} has no title`); + if (!contribution.icon.trim()) throw new Error(`Sidebar item ${normalizedId} has no icon`); + resolvePluginIcon(contribution.icon.trim()); + sidebarItemIds.add(normalizedId); + collector.sidebarItems.push({ + id: normalizedId, + title: contribution.title.trim(), + icon: contribution.icon.trim(), + surface: requireId(contribution.surface, "sidebar surface id"), + }); + }, + }); + const runtimeRequire = (name: string): unknown => { + if (name === "react") return React; + if (name === "react/jsx-runtime") return ReactJsxRuntime; + if (name === "react-native") return ReactNative; + if (name === "@paseo/plugin") return { defineRpc, useRpc }; + if (name === "@tanstack/react-query") return ReactQuery; + if (name === "zod") return Zod; + throw new Error(`Module "${name}" is not available in plugin client code`); + }; + const evaluate: (source: string) => unknown = globalThis.eval; + const factory = evaluate(bundle); + if (typeof factory !== "function") + throw new Error(`Plugin ${id} client bundle is not executable`); + const exports = factory(runtimeRequire); + const setup = + exports !== null && typeof exports === "object" ? Reflect.get(exports, "default") : undefined; + if (typeof setup !== "function") { + throw new Error(`Plugin ${id} must default export a function`); + } + setup(pluginContext); + + for (const item of collector.sidebarItems) { + if (!surfaceIds.has(item.surface)) { + throw new Error(`Sidebar item ${item.id} references missing surface ${item.surface}`); + } + } + return { id, surfaces: collector.surfaces, sidebarItems: collector.sidebarItems }; +} diff --git a/packages/app/src/plugins/icons.ts b/packages/app/src/plugins/icons.ts new file mode 100644 index 0000000000..de36d962d6 --- /dev/null +++ b/packages/app/src/plugins/icons.ts @@ -0,0 +1,11 @@ +import * as LucideIcons from "lucide-react-native"; +import type { LucideIcon } from "lucide-react-native"; + +export function resolvePluginIcon(name: string): LucideIcon { + const candidate = Reflect.get(LucideIcons, name); + const isComponent = + typeof candidate === "function" || + (typeof candidate === "object" && candidate !== null && "$$typeof" in candidate); + if (!isComponent) throw new Error(`Unknown Lucide icon: ${name}`); + return candidate as LucideIcon; +} diff --git a/packages/app/src/plugins/registry.test.ts b/packages/app/src/plugins/registry.test.ts new file mode 100644 index 0000000000..f563ec9f86 --- /dev/null +++ b/packages/app/src/plugins/registry.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { pluginRegistry } from "./registry"; + +function bundle(marker: string): string { + return `(function() { + const module = { exports: {} }; + module.exports.default = function(plugin) { + function Surface() { return ${JSON.stringify(marker)}; } + plugin.addSurface("main", Surface); + plugin.addSidebarItem({ id: "main", title: "Example", icon: "Blocks", surface: "main" }); + }; + return module.exports; + })`; +} + +afterEach(() => { + pluginRegistry.removeHost("host-a"); + pluginRegistry.removeHost("host-b"); +}); + +describe("PluginRegistry", () => { + it("preserves a plugin query cache when the same bundle reconnects", () => { + const clientBundle = bundle("one"); + pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle }]); + const first = pluginRegistry.getSnapshot()[0]; + first?.queryClient.setQueryData(["counter"], 3); + + pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle }]); + + const second = pluginRegistry.getSnapshot()[0]; + expect(second?.queryClient).toBe(first?.queryClient); + expect(second?.queryClient.getQueryData(["counter"])).toBe(3); + }); + + it("replaces the query cache when the plugin bundle changes", () => { + pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle: bundle("one") }]); + const first = pluginRegistry.getSnapshot()[0]; + first?.queryClient.setQueryData(["counter"], 3); + + pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle: bundle("two") }]); + + const second = pluginRegistry.getSnapshot()[0]; + expect(second?.queryClient).not.toBe(first?.queryClient); + expect(second?.queryClient.getQueryData(["counter"])).toBeUndefined(); + expect(first?.queryClient.getQueryData(["counter"])).toBeUndefined(); + }); + + it("isolates query caches between hosts", () => { + const clientBundle = bundle("same"); + pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle }]); + pluginRegistry.installCatalog("host-b", [{ id: "example", clientBundle }]); + const [hostA, hostB] = pluginRegistry.getSnapshot(); + hostA?.queryClient.setQueryData(["counter"], 3); + + expect(hostB?.queryClient.getQueryData(["counter"])).toBeUndefined(); + }); +}); diff --git a/packages/app/src/plugins/registry.ts b/packages/app/src/plugins/registry.ts new file mode 100644 index 0000000000..a6c7b771ec --- /dev/null +++ b/packages/app/src/plugins/registry.ts @@ -0,0 +1,91 @@ +import { useSyncExternalStore } from "react"; +import { QueryClient } from "@tanstack/react-query"; +import { evaluatePluginClientBundle } from "./evaluate"; +import type { InstalledPlugin } from "./types"; + +interface CatalogPlugin { + id: string; + clientBundle: string; +} + +class PluginRegistry { + private readonly byHost = new Map(); + private readonly listeners = new Set<() => void>(); + private snapshot: InstalledPlugin[] = []; + + subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + getSnapshot = (): InstalledPlugin[] => this.snapshot; + + installCatalog(serverId: string, catalog: CatalogPlugin[]): void { + const previous = this.byHost.get(serverId) ?? []; + const installed = catalog.flatMap((entry) => { + try { + const existing = previous.find( + (plugin) => plugin.id === entry.id && plugin.clientBundle === entry.clientBundle, + ); + const queryClient = existing?.queryClient ?? new QueryClient(); + return [ + { + ...evaluatePluginClientBundle(entry.id, entry.clientBundle), + serverId, + clientBundle: entry.clientBundle, + queryClient, + }, + ]; + } catch (error) { + console.warn(`[Plugins] Failed to evaluate ${serverId}/${entry.id}`, error); + return []; + } + }); + for (const plugin of previous) { + if (!installed.some((candidate) => candidate.queryClient === plugin.queryClient)) { + plugin.queryClient.clear(); + } + } + this.byHost.set(serverId, installed); + this.publish(); + } + + removeHost(serverId: string): void { + const installed = this.byHost.get(serverId); + if (!installed) return; + for (const plugin of installed) plugin.queryClient.clear(); + this.byHost.delete(serverId); + this.publish(); + } + + private publish(): void { + this.snapshot = [...this.byHost.values()] + .flat() + .sort((left, right) => + `${left.serverId}/${left.id}`.localeCompare(`${right.serverId}/${right.id}`), + ); + for (const listener of this.listeners) listener(); + } +} + +export const pluginRegistry = new PluginRegistry(); + +export function useInstalledPlugins(): InstalledPlugin[] { + return useSyncExternalStore( + pluginRegistry.subscribe, + pluginRegistry.getSnapshot, + pluginRegistry.getSnapshot, + ); +} + +export function useInstalledPlugin(serverId: string, pluginId: string): InstalledPlugin | null { + return ( + useInstalledPlugins().find( + (plugin) => plugin.serverId === serverId && plugin.id === pluginId, + ) ?? null + ); +} + +export function usePluginInstallations(pluginId: string): InstalledPlugin[] { + return useInstalledPlugins().filter((plugin) => plugin.id === pluginId); +} diff --git a/packages/app/src/plugins/routes.ts b/packages/app/src/plugins/routes.ts new file mode 100644 index 0000000000..f59ebd6023 --- /dev/null +++ b/packages/app/src/plugins/routes.ts @@ -0,0 +1,17 @@ +export function buildPluginSurfaceRoute( + serverId: string, + pluginId: string, + contributionId: string, +): `/h/${string}/plugin/${string}/${string}` { + return `/h/${encodeURIComponent(serverId)}/plugin/${encodeURIComponent(pluginId)}/${encodeURIComponent(contributionId)}`; +} + +export function hostIdFromPathname(pathname: string): string | null { + const encoded = /^\/h\/([^/]+)/.exec(pathname)?.[1]; + if (!encoded) return null; + try { + return decodeURIComponent(encoded); + } catch { + return null; + } +} diff --git a/packages/app/src/plugins/sdk.test.ts b/packages/app/src/plugins/sdk.test.ts new file mode 100644 index 0000000000..e4a35ef391 --- /dev/null +++ b/packages/app/src/plugins/sdk.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { callPluginRpc, defineRpc } from "./sdk"; + +const contract = defineRpc({ + name: "increment", + input: z.unknown().pipe(z.object({ value: z.number() })), + output: z.object({ value: z.number() }), +}); + +describe("plugin RPC contracts", () => { + it("validates input before invoking the daemon", async () => { + let invocations = 0; + const invoke = async () => { + invocations += 1; + return { value: 2 }; + }; + + await expect(callPluginRpc(contract, invoke, { value: "wrong" })).rejects.toBeInstanceOf( + z.ZodError, + ); + expect(invocations).toBe(0); + }); + + it("validates output before resolving", async () => { + const invoke = async () => ({ value: "wrong" }); + + await expect(callPluginRpc(contract, invoke, { value: 1 })).rejects.toBeInstanceOf(z.ZodError); + }); + + it("returns parsed output for a valid invocation", async () => { + const invoke = async (method: string, input: unknown) => ({ + value: method === "increment" && typeof input === "object" ? 2 : 0, + }); + + await expect(callPluginRpc(contract, invoke, { value: 1 })).resolves.toEqual({ value: 2 }); + }); +}); diff --git a/packages/app/src/plugins/sdk.tsx b/packages/app/src/plugins/sdk.tsx new file mode 100644 index 0000000000..58230d6949 --- /dev/null +++ b/packages/app/src/plugins/sdk.tsx @@ -0,0 +1,110 @@ +import { + createContext, + useCallback, + useContext, + useMemo, + type ComponentType, + type ReactNode, +} from "react"; +import type { ZodType, output as ZodOutput, input as ZodInput } from "zod"; +import type { + PluginSidebarContribution, + PluginSurfaceContribution, + PluginSurfaceProps, +} from "./types"; + +interface PluginCollector { + addSurface(id: string, Component: ComponentType): void; + addSidebarItem(contribution: PluginSidebarContribution): void; +} + +interface PluginRpcContextValue { + invoke(method: string, input: unknown): Promise; +} + +export interface PluginRpcContract< + InputSchema extends ZodType = ZodType, + OutputSchema extends ZodType = ZodType, +> { + name: string; + input: InputSchema; + output: OutputSchema; +} + +export interface PluginContext { + handle( + contract: PluginRpcContract, + handler: ( + input: ZodOutput, + ) => ZodInput | Promise>, + ): void; + addSurface(id: string, Component: ComponentType): void; + addSidebarItem(contribution: PluginSidebarContribution): void; +} + +interface RpcDefinition { + name: string; + input: InputSchema; + output: OutputSchema; +} + +const RPC_NAME = /^[a-z][a-z0-9._-]*$/; + +export function defineRpc( + definition: RpcDefinition, +): PluginRpcContract { + const name = definition.name.trim(); + if (!RPC_NAME.test(name)) throw new Error(`Invalid plugin RPC method: ${definition.name}`); + return { ...definition, name }; +} + +const PluginRpcContext = createContext(null); + +export function PluginRpcProvider({ + children, + invoke, +}: { + children: ReactNode; + invoke(method: string, input: unknown): Promise; +}) { + const value = useMemo(() => ({ invoke }), [invoke]); + return {children}; +} + +export function createPluginContext(collector: PluginCollector) { + return { + addSurface(id: string, Component: ComponentType) { + collector.addSurface(id, Component); + }, + addSidebarItem(contribution: PluginSidebarContribution) { + collector.addSidebarItem(contribution); + }, + }; +} + +export function useRpc( + contract: PluginRpcContract, +): (input: ZodInput) => Promise> { + const context = useContext(PluginRpcContext); + if (!context) throw new Error("useRpc must run inside a contributed plugin surface"); + + return useCallback( + (input: ZodInput) => callPluginRpc(contract, context.invoke, input), + [context, contract], + ); +} + +export async function callPluginRpc( + contract: PluginRpcContract, + invoke: (method: string, input: unknown) => Promise, + input: ZodInput, +): Promise> { + const parsedInput = await contract.input.parseAsync(input); + const output = await invoke(contract.name, parsedInput); + return contract.output.parseAsync(output); +} + +export interface PluginRegistrationCollector { + surfaces: PluginSurfaceContribution[]; + sidebarItems: PluginSidebarContribution[]; +} diff --git a/packages/app/src/plugins/sidebar-groups.test.ts b/packages/app/src/plugins/sidebar-groups.test.ts new file mode 100644 index 0000000000..8dc3af22ae --- /dev/null +++ b/packages/app/src/plugins/sidebar-groups.test.ts @@ -0,0 +1,43 @@ +import { QueryClient } from "@tanstack/react-query"; +import { describe, expect, it } from "vitest"; +import type { InstalledPlugin } from "./types"; +import { groupPluginSidebarContributions } from "./sidebar-groups"; + +function installed(serverId: string, contributionId = "main"): InstalledPlugin { + return { + id: "example", + serverId, + clientBundle: serverId, + queryClient: new QueryClient(), + surfaces: [{ id: "surface", Component: () => null }], + sidebarItems: [ + { + id: contributionId, + title: "Example", + icon: "Blocks", + surface: "surface", + }, + ], + }; +} + +describe("groupPluginSidebarContributions", () => { + it("coalesces the same plugin contribution across hosts", () => { + const groups = groupPluginSidebarContributions([installed("host-a"), installed("host-b")]); + + expect(groups).toHaveLength(1); + expect(groups[0]?.targets.map((target) => target.plugin.serverId)).toEqual([ + "host-a", + "host-b", + ]); + }); + + it("keeps different contribution ids separate", () => { + const groups = groupPluginSidebarContributions([ + installed("host-a", "main"), + installed("host-b", "settings"), + ]); + + expect(groups.map((group) => group.key)).toEqual(["example/main", "example/settings"]); + }); +}); diff --git a/packages/app/src/plugins/sidebar-groups.ts b/packages/app/src/plugins/sidebar-groups.ts new file mode 100644 index 0000000000..e61d37c8f6 --- /dev/null +++ b/packages/app/src/plugins/sidebar-groups.ts @@ -0,0 +1,48 @@ +import type { InstalledPlugin, PluginSidebarContribution } from "./types"; + +const preferredHosts = new Map(); + +export interface PluginSidebarTarget { + plugin: InstalledPlugin; + item: PluginSidebarContribution; +} + +export interface PluginSidebarGroup { + key: string; + pluginId: string; + contributionId: string; + title: string; + icon: string; + targets: PluginSidebarTarget[]; +} + +export function groupPluginSidebarContributions(plugins: InstalledPlugin[]): PluginSidebarGroup[] { + const groups = new Map(); + for (const plugin of plugins) { + for (const item of plugin.sidebarItems) { + const key = `${plugin.id}/${item.id}`; + const existing = groups.get(key); + if (existing) { + existing.targets.push({ plugin, item }); + } else { + groups.set(key, { + key, + pluginId: plugin.id, + contributionId: item.id, + title: item.title, + icon: item.icon, + targets: [{ plugin, item }], + }); + } + } + } + return [...groups.values()]; +} + +export function getPreferredPluginContributionHost(groupKey: string): string | null { + return preferredHosts.get(groupKey) ?? null; +} + +export function rememberPluginContributionHost(groupKey: string, serverId: string): void { + preferredHosts.set(groupKey, serverId); +} diff --git a/packages/app/src/plugins/sidebar-items.tsx b/packages/app/src/plugins/sidebar-items.tsx new file mode 100644 index 0000000000..f2cc614cc5 --- /dev/null +++ b/packages/app/src/plugins/sidebar-items.tsx @@ -0,0 +1,80 @@ +import { router, usePathname } from "expo-router"; +import { useCallback, useMemo } from "react"; +import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row"; +import { resolvePluginIcon } from "./icons"; +import { useInstalledPlugins } from "./registry"; +import { buildPluginSurfaceRoute, hostIdFromPathname } from "./routes"; +import { + groupPluginSidebarContributions, + getPreferredPluginContributionHost, + rememberPluginContributionHost, + type PluginSidebarGroup, + type PluginSidebarTarget, +} from "./sidebar-groups"; + +function selectTarget( + group: PluginSidebarGroup, + currentHostId: string | null, +): PluginSidebarTarget { + const current = group.targets.find((target) => target.plugin.serverId === currentHostId); + if (current) return current; + const rememberedHostId = getPreferredPluginContributionHost(group.key); + const remembered = group.targets.find((target) => target.plugin.serverId === rememberedHostId); + return remembered ?? group.targets[0]; +} + +export function PluginSidebarItems({ onBeforeNavigate }: { onBeforeNavigate?: () => void }) { + const plugins = useInstalledPlugins(); + const pathname = usePathname(); + const groups = useMemo(() => groupPluginSidebarContributions(plugins), [plugins]); + const currentHostId = hostIdFromPathname(pathname); + + return groups.map((group) => ( + + )); +} + +function PluginSidebarItemRow({ + group, + currentHostId, + pathname, + onBeforeNavigate, +}: { + group: PluginSidebarGroup; + currentHostId: string | null; + pathname: string; + onBeforeNavigate?: () => void; +}) { + const target = selectTarget(group, currentHostId); + const route = buildPluginSurfaceRoute( + target.plugin.serverId, + group.pluginId, + group.contributionId, + ); + const isActive = group.targets.some( + (candidate) => + pathname === + buildPluginSurfaceRoute(candidate.plugin.serverId, group.pluginId, group.contributionId), + ); + const navigate = useCallback(() => { + rememberPluginContributionHost(group.key, target.plugin.serverId); + onBeforeNavigate?.(); + router.push(route); + }, [group.key, onBeforeNavigate, route, target.plugin.serverId]); + return ( + + ); +} diff --git a/packages/app/src/plugins/surface-screen.tsx b/packages/app/src/plugins/surface-screen.tsx new file mode 100644 index 0000000000..a488a144d9 --- /dev/null +++ b/packages/app/src/plugins/surface-screen.tsx @@ -0,0 +1,297 @@ +import { router, useLocalSearchParams } from "expo-router"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { ChevronDown, X } from "lucide-react-native"; +import { + Component, + useCallback, + useMemo, + useRef, + useState, + type ComponentType, + type ErrorInfo, + type ReactNode, +} from "react"; +import { Platform, Pressable, Text, View } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { HeaderIconBadge } from "@/components/headers/header-icon-badge"; +import { HeaderToggleButton } from "@/components/headers/header-toggle-button"; +import { ScreenHeader } from "@/components/headers/screen-header"; +import { ScreenTitle } from "@/components/headers/screen-title"; +import { HostPicker } from "@/components/hosts/host-picker"; +import { useIsCompactFormFactor } from "@/constants/layout"; +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 { useInstalledPlugin, usePluginInstallations } from "./registry"; +import { buildPluginSurfaceRoute } from "./routes"; +import { rememberPluginContributionHost } from "./sidebar-groups"; +import { PluginRpcProvider } from "./sdk"; +import type { PluginSurfaceProps } from "./types"; + +const EMPTY_SHORTCUT_KEYS: ShortcutKey[] = []; +const EMPTY_THEME_DTO: Record = {}; +const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); +const pluginThemeMapping = (theme: Theme) => ({ + themeDto: JSON.parse(JSON.stringify(theme)) as Record, +}); +const ThemedX = withUnistyles(X); +const ThemedChevronDown = withUnistyles(ChevronDown); + +function routeParam(value: string | string[] | undefined): string { + return typeof value === "string" ? value : ""; +} + +function PluginHeaderIcon({ + Icon, + color = "", +}: { + Icon: ReturnType; + color?: string; +}) { + return ; +} + +const ThemedPluginHeaderIcon = withUnistyles(PluginHeaderIcon); + +function SurfaceRenderer({ + Surface, + invoke, + plugin, + layout, + host, + themeDto = EMPTY_THEME_DTO, +}: { + Surface: ComponentType; + invoke(method: string, input: unknown): Promise; + plugin: NonNullable>; + layout: PluginSurfaceProps["layout"]; + host: PluginSurfaceProps["host"]; + themeDto?: Record; +}) { + return ( + + + + + + ); +} + +const ThemedSurfaceRenderer = withUnistyles(SurfaceRenderer); + +interface SurfaceErrorBoundaryState { + error: string | null; +} + +class SurfaceErrorBoundary extends Component<{ children: ReactNode }, SurfaceErrorBoundaryState> { + state: SurfaceErrorBoundaryState = { error: null }; + + static getDerivedStateFromError(error: unknown): SurfaceErrorBoundaryState { + return { error: error instanceof Error ? error.message : String(error) }; + } + + componentDidCatch(error: Error, info: ErrorInfo): void { + console.warn("[Plugins] Surface render failed", error, info.componentStack); + } + + render() { + if (this.state.error) { + return Plugin failed: {this.state.error}; + } + return this.props.children; + } +} + +function resolvePlatform(): PluginSurfaceProps["layout"]["platform"] { + if (Platform.OS === "ios") return "ios"; + if (Platform.OS === "android") return "android"; + return "web"; +} + +function PluginHostSwitcher({ + serverId, + pluginId, + contributionId, + serverIds, +}: { + serverId: string; + pluginId: string; + contributionId: string; + serverIds: string[]; +}) { + const allHosts = useHosts(); + const hosts = useMemo( + () => allHosts.filter((host) => serverIds.includes(host.serverId)), + [allHosts, serverIds], + ); + const [open, setOpen] = useState(false); + const anchorRef = useRef(null); + const selectedLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId; + const selectHost = useCallback( + (nextServerId: string) => { + rememberPluginContributionHost(`${pluginId}/${contributionId}`, nextServerId); + router.replace(buildPluginSurfaceRoute(nextServerId, pluginId, contributionId)); + }, + [contributionId, pluginId], + ); + const openPicker = useCallback(() => setOpen(true), []); + const show = serverIds.length > 1 && hosts.length > 1; + if (!show) return null; + + return ( + + + + + {selectedLabel} + + + + + + ); +} + +export function PluginSurfaceScreen() { + const params = useLocalSearchParams<{ + serverId?: string | string[]; + pluginId?: string | string[]; + surfaceId?: string | string[]; + }>(); + const serverId = routeParam(params.serverId); + const pluginId = routeParam(params.pluginId); + const contributionId = routeParam(params.surfaceId); + const plugin = useInstalledPlugin(serverId, pluginId); + const installations = usePluginInstallations(pluginId); + const hosts = useHosts(); + const client = useHostRuntimeClient(serverId); + const compact = useIsCompactFormFactor(); + const sidebarItem = plugin?.sidebarItems.find((entry) => entry.id === contributionId) ?? null; + const surface = plugin?.surfaces.find((entry) => entry.id === sidebarItem?.surface) ?? null; + const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId; + const contributionServerIds = useMemo( + () => + installations + .filter((entry) => entry.sidebarItems.some((item) => item.id === contributionId)) + .map((entry) => entry.serverId), + [contributionId, installations], + ); + const title = sidebarItem?.title ?? (pluginId || "Plugin"); + const Icon = sidebarItem ? resolvePluginIcon(sidebarItem.icon) : null; + const invoke = useCallback( + async (method: string, input: unknown) => { + if (!client) throw new Error("Plugin host is offline"); + return client.invokePluginRpc(pluginId, method, input); + }, + [client, pluginId], + ); + const close = useCallback(() => { + if (router.canGoBack()) router.back(); + else router.replace(`/h/${encodeURIComponent(serverId)}`); + }, [serverId]); + const layout = useMemo(() => ({ compact, platform: resolvePlatform() }), [compact]); + const host = useMemo(() => ({ id: serverId, label: hostLabel }), [hostLabel, serverId]); + const headerLeft = useMemo( + () => ( + <> + {Icon ? ( + + + + ) : null} + {title} + + ), + [Icon, title], + ); + const headerRight = useMemo( + () => ( + <> + + + + + + ), + [close, contributionId, contributionServerIds, pluginId, serverId], + ); + + return ( + + + + {plugin && surface ? ( + + + + ) : ( + This plugin surface is unavailable. + )} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + screen: { + flex: 1, + backgroundColor: theme.colors.surface0, + }, + body: { + flex: 1, + }, + errorText: { + color: theme.colors.statusDanger, + padding: theme.spacing[4], + }, + hostSwitcher: { + maxWidth: 180, + minHeight: 32, + paddingHorizontal: theme.spacing[2], + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + borderRadius: theme.borderRadius.md, + backgroundColor: theme.colors.surface1, + }, + hostSwitcherText: { + flexShrink: 1, + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, +})); diff --git a/packages/app/src/plugins/types.ts b/packages/app/src/plugins/types.ts new file mode 100644 index 0000000000..349cbc0a9b --- /dev/null +++ b/packages/app/src/plugins/types.ts @@ -0,0 +1,38 @@ +import type { ComponentType } from "react"; +import type { QueryClient } from "@tanstack/react-query"; + +export interface PluginSurfaceProps { + theme: Record; + host: { + id: string; + label: string; + }; + layout: { + compact: boolean; + platform: "ios" | "android" | "web"; + }; +} + +export interface PluginSurfaceContribution { + id: string; + Component: ComponentType; +} + +export interface PluginSidebarContribution { + id: string; + title: string; + icon: string; + surface: string; +} + +export interface EvaluatedPlugin { + id: string; + surfaces: PluginSurfaceContribution[]; + sidebarItems: PluginSidebarContribution[]; +} + +export interface InstalledPlugin extends EvaluatedPlugin { + serverId: string; + clientBundle: string; + queryClient: QueryClient; +} diff --git a/packages/app/src/runtime-component/evaluate.tsx b/packages/app/src/runtime-component/evaluate.tsx new file mode 100644 index 0000000000..66d9a3e534 --- /dev/null +++ b/packages/app/src/runtime-component/evaluate.tsx @@ -0,0 +1,73 @@ +import { transform } from "@babel/standalone"; +import * as React from "react"; +import type { ComponentType, ReactElement } from "react"; +import * as ReactNative from "react-native"; + +interface RuntimeModule { + exports: unknown; +} + +const RUNTIME_MODULES = new Map([ + ["react", React], + ["react-native", ReactNative], +]); + +function requireRuntimeModule(name: string): unknown { + if (!RUNTIME_MODULES.has(name)) { + throw new Error(`Module "${name}" is not available in the runtime component.`); + } + return RUNTIME_MODULES.get(name); +} + +function readDefaultExport(moduleExports: unknown): unknown { + if (typeof moduleExports !== "object" || moduleExports === null) { + return moduleExports; + } + if ("default" in moduleExports) { + return moduleExports.default; + } + return moduleExports; +} + +export function evaluateRuntimeComponent(source: string): ComponentType { + const compiled = transform(source, { + filename: "user-component.tsx", + plugins: ["transform-modules-commonjs"], + presets: [ + ["typescript", { allExtensions: true, isTSX: true }], + ["react", { runtime: "classic" }], + ], + sourceType: "module", + }).code; + + if (compiled === undefined) { + throw new Error("Babel produced no JavaScript for the component."); + } + + const evaluateScript: (script: string) => unknown = globalThis.eval; + const moduleFactory = evaluateScript( + `(function(require, module, exports) { "use strict";\n${compiled}\n})`, + ); + if (typeof moduleFactory !== "function") { + throw new Error("The compiled component did not produce a module factory."); + } + + const runtimeModule: RuntimeModule = { exports: {} }; + moduleFactory(requireRuntimeModule, runtimeModule, runtimeModule.exports); + const authoredComponent = readDefaultExport(runtimeModule.exports); + if (typeof authoredComponent !== "function") { + throw new Error("The source must default-export a function component."); + } + const renderAuthoredComponent = authoredComponent; + + function RuntimeComponent(): ReactElement { + const rendered: unknown = renderAuthoredComponent(); + if (!React.isValidElement(rendered)) { + throw new Error("The authored component must return one React element."); + } + return rendered; + } + + RuntimeComponent.displayName = "RuntimeComponent"; + return RuntimeComponent; +} diff --git a/packages/app/src/runtime-component/prototype-screen.tsx b/packages/app/src/runtime-component/prototype-screen.tsx new file mode 100644 index 0000000000..f91c072891 --- /dev/null +++ b/packages/app/src/runtime-component/prototype-screen.tsx @@ -0,0 +1,284 @@ +import { Component, type ComponentType, type ReactNode, useCallback, useReducer } from "react"; +import { + KeyboardAvoidingView, + Platform, + Pressable, + ScrollView, + StyleSheet, + Text, + TextInput, + View, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { evaluateRuntimeComponent } from "@/runtime-component/evaluate"; + +const INITIAL_SOURCE = `import React, { useState } from "react"; +import { Pressable, Text, TextInput, View } from "react-native"; + +interface Todo { + id: number; + label: string; +} + +export default function UserAuthoredCard() { + const [count, setCount] = useState(0); + const [name, setName] = useState("Ada"); + const todos: Todo[] = [ + { id: 1, label: "Compiled TSX on-device" }, + { id: 2, label: "Rendered native views" }, + { id: 3, label: "Hooks and events survived" }, + ]; + + return ( + + + USER-AUTHORED TSX STRING + + + Hello {name || "stranger"} + + + setCount((value) => value + 1)} + style={{ backgroundColor: "#22c55e", borderRadius: 10, padding: 14 }} + > + + Native presses: {count} + + + + {todos.map((todo) => ( + ✓ {todo.label} + ))} + + + ); +}`; + +const SECOND_SOURCE = `import React, { useState } from "react"; +import { Pressable, Text, View } from "react-native"; + +export default function SecondProgram() { + const [isOn, setIsOn] = useState(false); + return ( + + SECOND PROGRAM + This TSX replaced the original string at runtime. + setIsOn((value) => !value)} + style={{ padding: 16, borderRadius: 12, backgroundColor: isOn ? "#4ade80" : "#f472b6" }} + > + {isOn ? "ON" : "OFF"} + + + ); +}`; + +interface SuccessfulOutput { + kind: "success"; + Component: ComponentType; +} + +interface FailedOutput { + kind: "failure"; + message: string; +} + +type PrototypeOutput = SuccessfulOutput | FailedOutput; + +interface PrototypeState { + source: string; + revision: number; + output: PrototypeOutput; +} + +type PrototypeAction = { type: "edit"; source: string } | { type: "run"; output: PrototypeOutput }; + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function compile(source: string): PrototypeOutput { + try { + return { kind: "success", Component: evaluateRuntimeComponent(source) }; + } catch (error) { + return { kind: "failure", message: describeError(error) }; + } +} + +function createInitialState(): PrototypeState { + return { + source: INITIAL_SOURCE, + revision: 0, + output: compile(INITIAL_SOURCE), + }; +} + +function prototypeReducer(state: PrototypeState, action: PrototypeAction): PrototypeState { + if (action.type === "edit") { + return { ...state, source: action.source }; + } + return { + ...state, + revision: state.revision + 1, + output: action.output, + }; +} + +interface RenderBoundaryProps { + children: ReactNode; +} + +interface RenderBoundaryState { + message: string | null; +} + +class RenderBoundary extends Component { + state: RenderBoundaryState = { message: null }; + + static getDerivedStateFromError(error: unknown): RenderBoundaryState { + return { message: describeError(error) }; + } + + render() { + if (this.state.message !== null) { + return Render failed: {this.state.message}; + } + return this.props.children; + } +} + +export function RuntimeComponentPrototypeScreen() { + const [state, dispatch] = useReducer(prototypeReducer, undefined, createInitialState); + + const runSource = useCallback(() => { + dispatch({ type: "run", output: compile(state.source) }); + }, [state.source]); + + const editSource = useCallback((source: string) => { + dispatch({ type: "edit", source }); + }, []); + + const loadSecondSource = useCallback(() => { + dispatch({ type: "edit", source: SECOND_SOURCE }); + }, []); + + const output = state.output; + return ( + + + + Native runtime component + + Babel compiles this string locally. Hermes executes it in Paseo's JavaScript realm. + + + + {output.kind === "success" ? ( + + + + ) : ( + Compile failed: {output.message} + )} + + + + user-component.tsx + + + Load second + + + Run string + + + + + + + This is code execution, not a sandbox. The string has the same authority as the app. + + + + + ); +} + +const styles = StyleSheet.create({ + safeArea: { flex: 1, backgroundColor: "#020617" }, + keyboardAvoidingView: { flex: 1 }, + content: { gap: 14, padding: 18, paddingBottom: 50 }, + title: { color: "white", fontSize: 28, fontWeight: "800" }, + subtitle: { color: "#94a3b8", fontSize: 14, lineHeight: 20 }, + preview: { + minHeight: 260, + borderColor: "#334155", + borderRadius: 20, + borderWidth: 1, + justifyContent: "center", + padding: 12, + }, + editorHeader: { alignItems: "center", flexDirection: "row", justifyContent: "space-between" }, + editorActions: { flexDirection: "row", gap: 8 }, + editorLabel: { color: "#cbd5e1", fontFamily: "Menlo", fontSize: 13 }, + loadButton: { + backgroundColor: "#334155", + borderRadius: 8, + paddingHorizontal: 12, + paddingVertical: 10, + }, + loadButtonText: { color: "#f8fafc", fontWeight: "800" }, + runButton: { + backgroundColor: "#f8fafc", + borderRadius: 8, + paddingHorizontal: 16, + paddingVertical: 10, + }, + runButtonText: { color: "#0f172a", fontWeight: "800" }, + editor: { + minHeight: 520, + backgroundColor: "#0f172a", + borderColor: "#334155", + borderRadius: 12, + borderWidth: 1, + color: "#e2e8f0", + fontFamily: "Menlo", + fontSize: 11, + lineHeight: 16, + padding: 12, + }, + errorText: { color: "#fca5a5", fontSize: 14, lineHeight: 20 }, + warning: { color: "#fbbf24", fontSize: 12, lineHeight: 18 }, +}); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 89251bdc55..b823eab539 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -4702,6 +4702,32 @@ export class DaemonClient { }); } + async getPluginCatalog(): Promise> { + const requestId = this.createRequestId(); + const payload = await this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "plugin.catalog.get.request", requestId }, + responseType: "plugin.catalog.get.response", + }); + return payload.plugins; + } + + async invokePluginRpc(pluginId: string, method: string, input: unknown): Promise { + const requestId = this.createRequestId(); + const payload = await this.sendCorrelatedSessionRequest({ + requestId, + message: { + type: "plugin.rpc.invoke.request", + requestId, + pluginId, + method, + input, + }, + responseType: "plugin.rpc.invoke.response", + }); + return payload.output; + } + async respondToPermissionAndWait( agentId: string, requestId: string, diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index beaeac7857..e575e3819b 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -144,6 +144,20 @@ const MutableRelayConfigSchema = z enabled: z.boolean(), }) .passthrough(); + +export const PluginIdSchema = z.string().regex(/^[a-z][a-z0-9-]*$/); + +export const DirectoryPluginSourceSchema = z + .object({ + source: z.literal("directory"), + path: z.string().min(1), + }) + .strict(); + +export const PluginSourceSchema = z.discriminatedUnion("source", [DirectoryPluginSourceSchema]); + +export type PluginSource = z.infer; + export const MutableDaemonConfigSchema = z .object({ // COMPAT(relayConfig): added in v0.2.6, remove after 2027-01-31 when old daemons are unsupported. @@ -160,6 +174,7 @@ export const MutableDaemonConfigSchema = z enableTerminalAgentHooks: z.boolean().default(false), appendSystemPrompt: z.string().default(""), terminalProfiles: z.array(TerminalProfileSchema).optional(), + plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(), }) .passthrough(); @@ -177,6 +192,7 @@ export const MutableDaemonConfigPatchSchema = z enableTerminalAgentHooks: z.boolean().optional(), appendSystemPrompt: z.string().optional(), terminalProfiles: z.array(TerminalProfileSchema).optional(), + plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(), }) .partial() .passthrough(); @@ -1231,6 +1247,19 @@ export const DiagnosticsRequestSchema = z.object({ requestId: z.string(), }); +export const PluginCatalogGetRequestSchema = z.object({ + type: z.literal("plugin.catalog.get.request"), + requestId: z.string(), +}); + +export const PluginRpcInvokeRequestSchema = z.object({ + type: z.literal("plugin.rpc.invoke.request"), + requestId: z.string(), + pluginId: PluginIdSchema, + method: z.string().min(1), + input: z.unknown(), +}); + export const GetDaemonConfigRequestMessageSchema = z.object({ type: z.literal("get_daemon_config_request"), requestId: z.string(), @@ -2600,6 +2629,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ HubManagementDaemonGetStatusRequestSchema, HubManagementDaemonDisconnectRequestSchema, DiagnosticsRequestSchema, + PluginCatalogGetRequestSchema, + PluginRpcInvokeRequestSchema, GetDaemonConfigRequestMessageSchema, SetDaemonConfigRequestMessageSchema, ReadProjectConfigRequestMessageSchema, @@ -2909,6 +2940,8 @@ export const ServerInfoStatusPayloadSchema = z daemonStatusRpc: z.boolean().optional(), // COMPAT(relayConfig): added in v0.2.6, remove gate after 2027-01-31. relayConfig: z.boolean().optional(), + // COMPAT(plugins): added in v0.3.0, remove gate after 2027-08-07. + plugins: z.boolean().optional(), // COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23. "terminal-restore-modes": z.boolean().optional(), // COMPAT(terminalInputModeReplay): added in v0.2.6, remove gate after 2027-02-02. @@ -5407,12 +5440,35 @@ export function parseHubExecutionOutboundMessage(value: unknown): HubExecutionOu export type DaemonUpdateProgressMessage = z.infer; +export const PluginCatalogGetResponseSchema = z.object({ + type: z.literal("plugin.catalog.get.response"), + payload: z.object({ + requestId: z.string(), + plugins: z.array( + z.object({ + id: PluginIdSchema, + clientBundle: z.string(), + }), + ), + }), +}); + +export const PluginRpcInvokeResponseSchema = z.object({ + type: z.literal("plugin.rpc.invoke.response"), + payload: z.object({ + requestId: z.string(), + output: z.unknown(), + }), +}); + export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ HubExecutionAgentCreateResponseSchema, HubExecutionControlResponseSchema, HubExecutionAgentUpdateSchema, HubExecutionAgentStreamSchema, BrowserAutomationExecuteRequestSchema, + PluginCatalogGetResponseSchema, + PluginRpcInvokeResponseSchema, ActivityLogMessageSchema, AssistantChunkMessageSchema, AudioOutputMessageSchema, diff --git a/packages/server/package.json b/packages/server/package.json index 7ac93a4786..ce6c2cda1e 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -69,6 +69,7 @@ "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "^0.3.220", "@anthropic-ai/sdk": "^0.104.2", + "@babel/parser": "^7.29.8", "@getpaseo/client": "0.3.0", "@getpaseo/highlight": "0.3.0", "@getpaseo/protocol": "0.3.0", @@ -82,6 +83,7 @@ "ajv": "^8.20.0", "bcryptjs": "^3.0.3", "dotenv": "^17.2.3", + "esbuild": "^0.25.12", "express": "^4.18.2", "fast-deep-equal": "^3.1.3", "lru-cache": "^11.5.1", diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 18609aca51..98f944fffb 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -170,7 +170,7 @@ import type { PushNotificationSender } from "./push/notifications.js"; import { getOrCreateServerId } from "./server-id.js"; import { resolveDaemonVersion } from "./daemon-version.js"; import type { AgentClient, AgentProvider } from "./agent/agent-sdk-types.js"; -import type { FirstAgentContext, TerminalProfile } from "@getpaseo/protocol/messages"; +import type { FirstAgentContext, PluginSource, TerminalProfile } from "@getpaseo/protocol/messages"; import type { AgentProviderRuntimeSettingsMap, ProviderOverride, @@ -218,6 +218,7 @@ import { type HubRelationshipRemote, } from "./hub/relationship-remote.js"; import { DaemonExecutions } from "./hub/daemon-executions.js"; +import { PluginRuntime } from "./plugins/runtime.js"; const MAX_MCP_DEBUG_BATCH_ITEMS = 10; const REDACTED_LOG_VALUE = "[redacted]"; @@ -399,6 +400,7 @@ export interface PaseoDaemonConfig { enableTerminalAgentHooks?: boolean; appendSystemPrompt?: string; terminalProfiles?: TerminalProfile[]; + plugins?: Record; staticDir: string; mcpDebug: boolean; isDev?: boolean; @@ -532,6 +534,7 @@ function createInitialMutableDaemonConfig(config: PaseoDaemonConfig): MutableDae autoArchiveAfterMerge: config.autoArchiveAfterMerge ?? false, enableTerminalAgentHooks: config.enableTerminalAgentHooks ?? false, appendSystemPrompt: config.appendSystemPrompt ?? "", + plugins: config.plugins ?? {}, }; if (config.terminalProfiles !== undefined) { @@ -559,6 +562,7 @@ export async function createPaseoDaemon( ); const browserToolsPolicy = new DaemonConfigBrowserToolsPolicy(daemonConfigStore); const browserToolsBroker = new BrowserToolsBroker({}); + const pluginRuntime = new PluginRuntime(logger); const serverId = getOrCreateServerId(config.paseoHome, { logger }); const daemonKeyPair = await loadOrCreateDaemonKeyPair(config.paseoHome, logger); @@ -1439,6 +1443,7 @@ export async function createPaseoDaemon( const start = async () => { let mainStarted = false; try { + await pluginRuntime.start(daemonConfigStore.get().plugins ?? {}); if (serviceProxyListenTarget) { const boundServiceProxyTarget = await serviceProxy.startStandalone({ listenTarget: serviceProxyListenTarget, @@ -1570,6 +1575,7 @@ export async function createPaseoDaemon( browserToolsBroker, hubRelationships, workspaceSetupRuntime, + pluginRuntime, ); relayRuntime = createRelayRuntime({ config: { @@ -1623,6 +1629,7 @@ export async function createPaseoDaemon( }; const stop = async () => { + await pluginRuntime.stop(); await hubRelationships.stop(); workspaceReconciliation.dispose(); scriptHealthMonitor.stop(); diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index 93ab9409a1..236e76f231 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -520,6 +520,7 @@ export function loadConfig( enableTerminalAgentHooks: persisted.daemon?.enableTerminalAgentHooks ?? false, appendSystemPrompt, terminalProfiles, + plugins: persisted.plugins, mcpDebug: env.MCP_DEBUG === "1", isDev: resolvePaseoNodeEnv(env) === "development", agentStoragePath: path.join(paseoHome, "agents"), diff --git a/packages/server/src/server/daemon-config-store.ts b/packages/server/src/server/daemon-config-store.ts index f77fccc06e..7cee3f61eb 100644 --- a/packages/server/src/server/daemon-config-store.ts +++ b/packages/server/src/server/daemon-config-store.ts @@ -335,6 +335,7 @@ function mergeMutableConfigIntoPersistedConfig(params: { return { ...persisted, + plugins: mutable.plugins, daemon: { ...persisted.daemon, ...(persistRelayEnabled diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index e864450682..e240628bf4 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -9,7 +9,11 @@ import { } from "./agent/provider-launch-config.js"; import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js"; import { ensurePrivateFile, writePrivateFileAtomicSync } from "./private-files.js"; -import { TerminalProfileSchema } from "@getpaseo/protocol/messages"; +import { + PluginIdSchema, + PluginSourceSchema, + TerminalProfileSchema, +} from "@getpaseo/protocol/messages"; import { PaseoServicePortAllocationSchema } from "@getpaseo/protocol/paseo-config-schema"; export const LogLevelSchema = z.enum(["trace", "debug", "info", "warn", "error", "fatal"]); @@ -303,6 +307,7 @@ export const PersistedConfigSchema = z .optional(), providers: ProvidersSchema.optional(), + plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(), worktrees: WorktreesConfigSchema.optional(), agents: z .object({ diff --git a/packages/server/src/server/plugins/compiler.ts b/packages/server/src/server/plugins/compiler.ts new file mode 100644 index 0000000000..955b876443 --- /dev/null +++ b/packages/server/src/server/plugins/compiler.ts @@ -0,0 +1,206 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { parse } from "@babel/parser"; +import { build, type Plugin } from "esbuild"; + +type PluginBuildTarget = "client" | "server"; + +const REGISTRATIONS_REMOVED_BY_TARGET: Record> = { + client: new Set(["handle"]), + server: new Set(["addSurface", "addSidebarItem"]), +}; + +function registrationName(statement: unknown, contextName: string): string | null { + if (!statement || typeof statement !== "object") return null; + const expression = Reflect.get(statement, "expression"); + if ( + !expression || + typeof expression !== "object" || + Reflect.get(expression, "type") !== "CallExpression" + ) { + return null; + } + const callee = Reflect.get(expression, "callee"); + if (!callee || typeof callee !== "object" || Reflect.get(callee, "type") !== "MemberExpression") { + return null; + } + const object = Reflect.get(callee, "object"); + const property = Reflect.get(callee, "property"); + if ( + !object || + typeof object !== "object" || + Reflect.get(object, "type") !== "Identifier" || + Reflect.get(object, "name") !== contextName || + !property || + typeof property !== "object" || + Reflect.get(property, "type") !== "Identifier" + ) { + return null; + } + return String(Reflect.get(property, "name")); +} + +function defaultPluginFunction(programBody: unknown[]): { body: object; contextName: string } { + const defaultExports = programBody.filter( + (statement) => + statement !== null && + typeof statement === "object" && + Reflect.get(statement, "type") === "ExportDefaultDeclaration", + ); + if (defaultExports.length !== 1) { + throw new Error("Plugin index.tsx must have exactly one default export function"); + } + const declaration = Reflect.get(defaultExports[0] as object, "declaration"); + const declarationType = + declaration !== null && typeof declaration === "object" + ? Reflect.get(declaration, "type") + : null; + if ( + declarationType !== "FunctionDeclaration" && + declarationType !== "FunctionExpression" && + declarationType !== "ArrowFunctionExpression" + ) { + throw new Error("Plugin default export must be a function receiving its context"); + } + const parameters = Reflect.get(declaration, "params"); + const parameter = Array.isArray(parameters) && parameters.length === 1 ? parameters[0] : null; + if ( + parameter === null || + typeof parameter !== "object" || + Reflect.get(parameter, "type") !== "Identifier" + ) { + throw new Error("Plugin default export must receive one named context parameter"); + } + const body = Reflect.get(declaration, "body"); + if (body === null || typeof body !== "object" || Reflect.get(body, "type") !== "BlockStatement") { + throw new Error("Plugin default export must have a block body"); + } + return { body, contextName: String(Reflect.get(parameter, "name")) }; +} + +function collectRemovedRegistrationRanges( + node: unknown, + contextName: string, + removedNames: ReadonlySet, + ranges: Array<{ start: number; end: number }>, +): void { + if (node === null || typeof node !== "object") return; + if (Reflect.get(node, "type") === "ExpressionStatement") { + const name = registrationName(node, contextName); + if (name && removedNames.has(name)) { + const start = Reflect.get(node, "start"); + const end = Reflect.get(node, "end"); + if (typeof start !== "number" || typeof end !== "number") { + throw new Error(`Could not locate plugin context ${name} registration in index.tsx`); + } + ranges.push({ start, end }); + return; + } + } + for (const value of Object.values(node)) { + if (Array.isArray(value)) { + for (const child of value) + collectRemovedRegistrationRanges(child, contextName, removedNames, ranges); + } else if (value !== null && typeof value === "object") { + collectRemovedRegistrationRanges(value, contextName, removedNames, ranges); + } + } +} + +function removeOppositeTargetRegistrations(source: string, target: PluginBuildTarget): string { + const ast = parse(source, { + sourceType: "module", + plugins: ["typescript", "jsx"], + }); + const pluginFunction = defaultPluginFunction(ast.program.body); + const ranges: Array<{ start: number; end: number }> = []; + collectRemovedRegistrationRanges( + pluginFunction.body, + pluginFunction.contextName, + REGISTRATIONS_REMOVED_BY_TARGET[target], + ranges, + ); + + let output = source; + for (const range of ranges.toReversed()) { + output = `${output.slice(0, range.start)}${output.slice(range.end)}`; + } + return output; +} + +function wrapCommonJsBundle(code: string): string { + return `(function(require) {\nconst module = { exports: {} };\nconst exports = module.exports;\n${code}\nreturn module.exports;\n})`; +} + +function makeHermesInteropEager(code: string): string { + // Hermes evaluates esbuild's lazy CommonJS interop getters from a string with + // the final loop binding, so every named import can resolve to the last export. + // Plugin bundles execute once and do not need live bindings from host modules. + return code.replaceAll("get: () => from[key]", "value: from[key]"); +} + +function createUnusedPlatformModulePlugin(target: PluginBuildTarget): Plugin { + const filter = + target === "server" + ? /^(@tanstack\/react-query|react|react\/jsx-runtime|react-native)$/ + : /^node:/; + return { + name: `paseo-plugin-${target}-unused-platform-modules`, + setup(buildContext) { + buildContext.onResolve({ filter }, (args) => ({ + path: args.path, + namespace: "paseo-unused-platform-module", + sideEffects: false, + })); + buildContext.onLoad({ filter: /.*/, namespace: "paseo-unused-platform-module" }, () => ({ + contents: "module.exports = {};", + loader: "js", + })); + }, + }; +} + +async function compileTarget(entryPath: string, target: PluginBuildTarget): Promise { + const source = await readFile(entryPath, "utf8"); + const filteredSource = removeOppositeTargetRegistrations(source, target); + const result = await build({ + stdin: { + contents: filteredSource, + loader: "tsx", + resolveDir: path.dirname(entryPath), + sourcefile: entryPath, + }, + bundle: true, + format: "cjs", + platform: target === "server" ? "node" : "neutral", + target: target === "server" ? "node20" : "es2020", + external: + target === "client" + ? [ + "@paseo/plugin", + "@tanstack/react-query", + "react", + "react/jsx-runtime", + "react-native", + "zod", + ] + : ["@paseo/plugin", "zod"], + plugins: [createUnusedPlatformModulePlugin(target)], + treeShaking: true, + write: false, + }); + const output = result.outputFiles[0]?.text; + if (!output) throw new Error(`Plugin ${target} compilation produced no output`); + return wrapCommonJsBundle(makeHermesInteropEager(output)); +} + +export async function compilePlugin(entryPath: string): Promise<{ + clientBundle: string; + serverBundle: string; +}> { + const [clientBundle, serverBundle] = await Promise.all([ + compileTarget(entryPath, "client"), + compileTarget(entryPath, "server"), + ]); + return { clientBundle, serverBundle }; +} diff --git a/packages/server/src/server/plugins/plugin-process-protocol.ts b/packages/server/src/server/plugins/plugin-process-protocol.ts new file mode 100644 index 0000000000..fc2b4eee99 --- /dev/null +++ b/packages/server/src/server/plugins/plugin-process-protocol.ts @@ -0,0 +1,10 @@ +export type PluginProcessRequest = + | { type: "initialize"; bundle: string } + | { type: "invoke"; requestId: string; method: string; input: unknown } + | { type: "shutdown" }; + +export type PluginProcessMessage = + | { type: "ready"; methods: string[] } + | { type: "result"; requestId: string; output: unknown } + | { type: "error"; requestId: string; error: string } + | { type: "fatal"; error: string }; diff --git a/packages/server/src/server/plugins/plugin-process.ts b/packages/server/src/server/plugins/plugin-process.ts new file mode 100644 index 0000000000..3031c92b01 --- /dev/null +++ b/packages/server/src/server/plugins/plugin-process.ts @@ -0,0 +1,101 @@ +import type { PluginProcessMessage, PluginProcessRequest } from "./plugin-process-protocol.js"; +import { createRequire } from "node:module"; +import type { ZodType } from "zod"; + +type RpcHandler = (input: unknown) => unknown | Promise; + +interface RpcContract { + name: string; + input: ZodType; + output: ZodType; +} + +interface RegisteredRpc { + contract: RpcContract; + handler: RpcHandler; +} + +const handlers = new Map(); +const nodeRequire = createRequire(import.meta.url); + +function send(message: PluginProcessMessage): void { + process.send?.(message); +} + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function validateMethod(method: string): string { + const normalized = method.trim(); + if (!/^[a-z][a-z0-9._-]*$/.test(normalized)) { + throw new Error(`Invalid plugin RPC method: ${method}`); + } + if (handlers.has(normalized)) { + throw new Error(`Duplicate plugin RPC method: ${normalized}`); + } + return normalized; +} + +function defineRpc(contract: RpcContract): RpcContract { + return { ...contract, name: contract.name.trim() }; +} + +function register(contract: RpcContract, handler: RpcHandler): void { + if (typeof handler !== "function") { + throw new Error(`Plugin RPC ${contract.name} must provide a handler`); + } + const method = validateMethod(contract.name); + handlers.set(method, { contract: { ...contract, name: method }, handler }); +} + +function runtimeRequire(name: string): unknown { + if (name === "@paseo/plugin") return { defineRpc }; + return nodeRequire(name); +} + +function evaluateBundle(bundle: string): void { + const evaluate: (source: string) => unknown = globalThis.eval; + const factory = evaluate(bundle); + if (typeof factory !== "function") throw new Error("Plugin server bundle is not executable"); + const exports = factory(runtimeRequire); + const setup = + exports !== null && typeof exports === "object" ? Reflect.get(exports, "default") : undefined; + if (typeof setup !== "function") { + throw new Error("Plugin server bundle must default export a function"); + } + setup({ handle: register }); +} + +process.on("message", (message: PluginProcessRequest) => { + if (message.type === "initialize") { + try { + evaluateBundle(message.bundle); + send({ type: "ready", methods: [...handlers.keys()].sort() }); + } catch (error) { + send({ type: "fatal", error: describeError(error) }); + } + return; + } + if (message.type === "shutdown") { + process.disconnect(); + return; + } + const registered = handlers.get(message.method); + if (!registered) { + send({ + type: "error", + requestId: message.requestId, + error: `Unknown RPC method: ${message.method}`, + }); + return; + } + void registered.contract.input + .parseAsync(message.input) + .then(registered.handler) + .then((output) => registered.contract.output.parseAsync(output)) + .then( + (output) => send({ type: "result", requestId: message.requestId, output }), + (error) => send({ type: "error", requestId: message.requestId, error: describeError(error) }), + ); +}); diff --git a/packages/server/src/server/plugins/runtime.posix.test.ts b/packages/server/src/server/plugins/runtime.posix.test.ts new file mode 100644 index 0000000000..2293a97cb6 --- /dev/null +++ b/packages/server/src/server/plugins/runtime.posix.test.ts @@ -0,0 +1,102 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import pino from "pino"; +import { afterEach, describe, expect, it } from "vitest"; +import { PluginRuntime } from "./runtime.js"; + +const temporaryDirectories: string[] = []; + +async function createPlugin(id: string, source: string): Promise { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-")); + temporaryDirectories.push(directory); + await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id }), "utf8"); + await writeFile(path.join(directory, "index.tsx"), source, "utf8"); + return directory; +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })), + ); +}); + +describe("PluginRuntime", () => { + it("loads one index.tsx, exposes its client bundle, and invokes its server RPC", async () => { + const directory = await createPlugin( + "hello", + `import React from "react"; +import { platform } from "node:os"; +import { Text } from "react-native"; +import { z } from "zod"; +import { defineRpc } from "@paseo/plugin"; + +const greetRpc = defineRpc({ + name: "greet", + input: z.object({ name: z.string() }), + output: z.object({ message: z.string(), platform: z.string() }), +}); + +function HelloSurface() { + return Hello from native UI; +} + +export default function contribute(plugin: any) { + plugin.handle(greetRpc, async (input: { name: string }) => ({ + message: "Hello, " + input.name, + platform: platform(), + })); + plugin.addSurface("main", HelloSurface); + plugin.addSidebarItem({ id: "hello", title: "Hello", icon: "Sparkles", surface: "main" }); +}`, + ); + const runtime = new PluginRuntime(pino({ level: "silent" })); + + await runtime.start({ hello: { source: "directory", path: directory } }); + + const catalog = runtime.catalog(); + expect(catalog).toHaveLength(1); + expect(catalog[0]?.id).toBe("hello"); + expect(catalog[0]?.clientBundle).toContain("Hello from native UI"); + expect(catalog[0]?.clientBundle).not.toContain("node:os"); + expect(catalog[0]?.clientBundle).not.toContain("get: () => from[key]"); + await expect(runtime.invoke("hello", "greet", { name: "Paseo" })).resolves.toMatchObject({ + message: "Hello, Paseo", + }); + await expect(runtime.invoke("hello", "greet", { name: 7 })).rejects.toThrow(); + + await runtime.stop(); + }); + + it("rejects a handler result that does not match its RPC output schema", async () => { + const directory = await createPlugin( + "invalid-output", + `import { z } from "zod"; +import { defineRpc } from "@paseo/plugin"; +const brokenRpc = defineRpc({ + name: "broken", + input: z.object({}), + output: z.object({ value: z.number() }), +}); +export default function contribute(plugin: any) { + plugin.handle(brokenRpc, async () => ({ value: "wrong" })); +}`, + ); + const runtime = new PluginRuntime(pino({ level: "silent" })); + + await runtime.start({ "invalid-output": { source: "directory", path: directory } }); + + await expect(runtime.invoke("invalid-output", "broken", {})).rejects.toThrow(); + await runtime.stop(); + }); + + it("rejects a configured id that does not match the manifest", async () => { + const directory = await createPlugin("actual", `export default function contribute() {}`); + const runtime = new PluginRuntime(pino({ level: "silent" })); + + await runtime.start({ configured: { source: "directory", path: directory } }); + + expect(runtime.catalog()).toEqual([]); + await runtime.stop(); + }); +}); diff --git a/packages/server/src/server/plugins/runtime.ts b/packages/server/src/server/plugins/runtime.ts new file mode 100644 index 0000000000..56e0344956 --- /dev/null +++ b/packages/server/src/server/plugins/runtime.ts @@ -0,0 +1,209 @@ +import { fork } from "node:child_process"; +import { readFile, stat } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { randomUUID } from "node:crypto"; +import type pino from "pino"; +import { z } from "zod"; +import { PluginIdSchema, type PluginSource } from "@getpaseo/protocol/messages"; +import { compilePlugin } from "./compiler.js"; +import type { PluginProcessMessage, PluginProcessRequest } from "./plugin-process-protocol.js"; + +const MANIFEST_FILENAME = "paseo-plugin.json"; +const ENTRY_FILENAME = "index.tsx"; +const REQUEST_TIMEOUT_MS = 30_000; + +const PluginManifestSchema = z.object({ id: PluginIdSchema }).strict(); + +interface PluginChild { + connected: boolean; + killed: boolean; + send(message: PluginProcessRequest, callback?: (error: Error | null) => void): boolean; + kill(): boolean; + disconnect(): void; + on(event: "message", listener: (message: PluginProcessMessage) => void): this; + on(event: "close", listener: (code: number | null, signal: NodeJS.Signals | null) => void): this; +} + +interface PendingInvocation { + resolve: (output: unknown) => void; + reject: (error: Error) => void; + timeout: ReturnType; +} + +interface LoadedPlugin { + id: string; + clientBundle: string; + methods: ReadonlySet; + child: PluginChild; + pending: Map; +} + +function resolveWorkerUrl(): URL { + return new URL( + import.meta.url.endsWith(".ts") ? "./plugin-process.ts" : "./plugin-process.js", + import.meta.url, + ); +} + +function resolveWorkerExecArgv(): string[] { + if (!import.meta.url.endsWith(".ts")) return []; + const loaderUrl = new URL("../../terminal/terminal-ts-loader.mjs", import.meta.url).href; + const importSource = [ + 'import { register } from "node:module";', + 'import { pathToFileURL } from "node:url";', + `register(${JSON.stringify(loaderUrl)}, pathToFileURL("./"));`, + ].join(" "); + return [ + "--experimental-strip-types", + "--import", + `data:text/javascript,${encodeURIComponent(importSource)}`, + ]; +} + +function spawnPluginChild(): PluginChild { + return fork(fileURLToPath(resolveWorkerUrl()), [], { + execArgv: resolveWorkerExecArgv(), + serialization: "advanced", + stdio: ["ignore", "ignore", "inherit", "ipc"], + }) as PluginChild; +} + +function send(child: PluginChild, message: PluginProcessRequest): Promise { + return new Promise((resolve, reject) => { + child.send(message, (error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); +} + +async function requireRegularFile(filePath: string, label: string): Promise { + const info = await stat(filePath).catch(() => null); + if (!info?.isFile()) throw new Error(`${label} is missing: ${filePath}`); +} + +async function readManifest(directory: string): Promise<{ id: string }> { + const manifestPath = path.join(directory, MANIFEST_FILENAME); + await requireRegularFile(manifestPath, "Plugin manifest"); + return PluginManifestSchema.parse(JSON.parse(await readFile(manifestPath, "utf8"))); +} + +export class PluginRuntime { + private readonly plugins = new Map(); + private readonly logger: pino.Logger; + + constructor(logger: pino.Logger) { + this.logger = logger.child({ module: "plugins" }); + } + + async start(config: Record): Promise { + for (const [pluginId, source] of Object.entries(config)) { + try { + await this.loadDirectoryPlugin(pluginId, source.path); + } catch (error) { + this.logger.error({ err: error, pluginId, source }, "Failed to load plugin"); + } + } + } + + catalog(): Array<{ id: string; clientBundle: string }> { + return [...this.plugins.values()] + .map(({ id, clientBundle }) => ({ id, clientBundle })) + .sort((left, right) => left.id.localeCompare(right.id)); + } + + async invoke(pluginId: string, method: string, input: unknown): Promise { + const loaded = this.plugins.get(pluginId); + if (!loaded) throw new Error(`Plugin is not available: ${pluginId}`); + if (!loaded.methods.has(method)) + throw new Error(`Plugin ${pluginId} does not contribute RPC ${method}`); + const requestId = randomUUID(); + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + loaded.pending.delete(requestId); + reject(new Error(`Plugin RPC timed out: ${pluginId}.${method}`)); + }, REQUEST_TIMEOUT_MS); + loaded.pending.set(requestId, { resolve, reject, timeout }); + void send(loaded.child, { type: "invoke", requestId, method, input }).catch((error) => { + clearTimeout(timeout); + loaded.pending.delete(requestId); + reject(error); + }); + }); + } + + async stop(): Promise { + await Promise.all([...this.plugins.values()].map((loaded) => this.stopPlugin(loaded))); + this.plugins.clear(); + } + + private async loadDirectoryPlugin(pluginId: string, configuredPath: string): Promise { + const directory = path.resolve(configuredPath); + const manifest = await readManifest(directory); + if (manifest.id !== pluginId) { + throw new Error(`Configured plugin "${pluginId}" points to manifest "${manifest.id}"`); + } + const entryPath = path.join(directory, ENTRY_FILENAME); + await requireRegularFile(entryPath, "Plugin entry point"); + const bundles = await compilePlugin(entryPath); + const child = spawnPluginChild(); + const pending = new Map(); + const methods = await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error(`Plugin ${pluginId} did not initialize`)), + REQUEST_TIMEOUT_MS, + ); + child.on("message", (message) => { + if (message.type === "ready") { + clearTimeout(timeout); + resolve(message.methods); + } else if (message.type === "fatal") { + clearTimeout(timeout); + reject(new Error(message.error)); + } + }); + void send(child, { type: "initialize", bundle: bundles.serverBundle }).catch(reject); + }); + const loaded: LoadedPlugin = { + id: pluginId, + clientBundle: bundles.clientBundle, + methods: new Set(methods), + child, + pending, + }; + child.on("message", (message) => this.handleChildMessage(loaded, message)); + child.on("close", () => this.handleChildClose(loaded)); + this.plugins.set(pluginId, loaded); + this.logger.info({ pluginId, methods }, "Loaded plugin"); + } + + private handleChildMessage(loaded: LoadedPlugin, message: PluginProcessMessage): void { + if (message.type !== "result" && message.type !== "error") return; + const pending = loaded.pending.get(message.requestId); + if (!pending) return; + loaded.pending.delete(message.requestId); + clearTimeout(pending.timeout); + if (message.type === "result") pending.resolve(message.output); + else pending.reject(new Error(message.error)); + } + + private handleChildClose(loaded: LoadedPlugin): void { + if (this.plugins.get(loaded.id) === loaded) this.plugins.delete(loaded.id); + for (const invocation of loaded.pending.values()) { + clearTimeout(invocation.timeout); + invocation.reject(new Error(`Plugin process exited: ${loaded.id}`)); + } + loaded.pending.clear(); + } + + private async stopPlugin(loaded: LoadedPlugin): Promise { + if (!loaded.child.connected) return; + await send(loaded.child, { type: "shutdown" }).catch(() => undefined); + loaded.child.disconnect(); + if (!loaded.child.killed) loaded.child.kill(); + } +} diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index c74d5d89b1..2ac0f9348b 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -462,6 +462,10 @@ export interface SessionOptions { workspaceGitService: WorkspaceGitService; workspaceAutoName: WorkspaceAutoName; daemonConfigStore: DaemonConfigStore; + pluginRuntime?: { + catalog(): Array<{ id: string; clientBundle: string }>; + invoke(pluginId: string, method: string, input: unknown): Promise; + }; mcpBaseUrl?: string | null; stt: Resolvable; sttLanguage?: string; @@ -632,6 +636,7 @@ export class Session { private readonly workspaceProvisioning: WorkspaceProvisioningService; private readonly workspaceRecovery: WorkspaceRecoveryService; private readonly daemonConfigStore: DaemonConfigStore; + private readonly pluginRuntime: SessionOptions["pluginRuntime"]; private readonly pushTokenStore: PushTokenStore; private unsubscribeAgentEvents: (() => void) | null = null; private unsubscribeProjectMutations: (() => void) | null = null; @@ -713,6 +718,7 @@ export class Session { workspaceGitService, workspaceAutoName, daemonConfigStore, + pluginRuntime, stt, sttLanguage, tts, @@ -751,6 +757,7 @@ export class Session { this.pushTokenStore = pushTokenStore; this.paseoHome = paseoHome; this.worktreesRoot = worktreesRoot; + this.pluginRuntime = pluginRuntime; this.sessionLogger = logger.child({ module: "session", clientId: this.clientId, @@ -1853,12 +1860,37 @@ export class Session { this.dispatchWorkspaceAndProjectMessage(msg) ?? this.dispatchWorkspaceFileMessage(msg, source) ?? this.dispatchProviderMessage(msg) ?? + this.dispatchPluginMessage(msg) ?? this.dispatchTerminalMessage(msg) ?? this.dispatchChatScheduleLoopMessage(msg) ?? this.dispatchMiscMessage(msg); if (promise) await promise; } + private dispatchPluginMessage(msg: SessionInboundMessage): Promise | undefined { + if (msg.type === "plugin.catalog.get.request") { + this.emit({ + type: "plugin.catalog.get.response", + payload: { + requestId: msg.requestId, + plugins: this.pluginRuntime?.catalog() ?? [], + }, + }); + return undefined; + } + if (msg.type === "plugin.rpc.invoke.request") { + if (!this.pluginRuntime) throw new Error("Plugin runtime is unavailable"); + return this.pluginRuntime.invoke(msg.pluginId, msg.method, msg.input).then((output) => { + this.emit({ + type: "plugin.rpc.invoke.response", + payload: { requestId: msg.requestId, output }, + }); + return undefined; + }); + } + return undefined; + } + private dispatchVoiceAndControlMessage(msg: SessionInboundMessage): Promise | undefined { switch (msg.type) { case "voice_audio_chunk": diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 18e9ac02a5..59f4ad14b2 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -32,7 +32,12 @@ import { asUint8Array, decodeBinaryFrame } from "@getpaseo/protocol/binary-frame import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity"; import type { HostnamesConfig } from "./hostnames.js"; import { isHostnameAllowed } from "./hostnames.js"; -import { Session, type SessionLifecycleIntent, type SessionRuntimeMetrics } from "./session.js"; +import { + Session, + type SessionLifecycleIntent, + type SessionOptions, + type SessionRuntimeMetrics, +} from "./session.js"; import type { HubRelationshipManagement } from "./hub/relationship-controller.js"; import { WorkspaceSetupRuntime } from "./workspace-setup-runtime.js"; import type { HubExecutionAgents } from "./hub/daemon-executions.js"; @@ -561,6 +566,7 @@ export class VoiceAssistantWebSocketServer { private acceptingConnections = true; private readonly advertiseDaemonStatusRpc: boolean; private readonly advertiseRelayConfig: boolean; + private readonly pluginRuntime: SessionOptions["pluginRuntime"]; constructor( server: HTTPServer, @@ -607,6 +613,7 @@ export class VoiceAssistantWebSocketServer { browserToolsBroker?: BrowserToolsBroker | null, hubRelationships?: HubRelationshipManagement | null, workspaceSetupRuntime: WorkspaceSetupRuntime = new WorkspaceSetupRuntime(), + pluginRuntime?: SessionOptions["pluginRuntime"], ) { this.logger = logger.child({ module: "websocket-server" }); this.workspaceSetupRuntime = workspaceSetupRuntime; @@ -620,6 +627,7 @@ export class VoiceAssistantWebSocketServer { this.daemonRuntimeConfig = daemonRuntimeConfig; this.browserToolsBroker = browserToolsBroker ?? null; this.hubRelationships = hubRelationships ?? null; + this.pluginRuntime = pluginRuntime; this.agentManager = agentManager; this.agentStorage = agentStorage; this.projectRegistry = projectRegistry ?? createNoopProjectRegistry(); @@ -1340,6 +1348,7 @@ export class VoiceAssistantWebSocketServer { workspaceGitService: this.workspaceGitService, workspaceAutoName: this.workspaceAutoName, daemonConfigStore: this.daemonConfigStore, + pluginRuntime: this.pluginRuntime, mcpBaseUrl: this.mcpBaseUrl, stt: () => this.speech?.resolveStt() ?? null, sttLanguage: this.speech?.resolveSttLanguage() ?? "en", @@ -1535,6 +1544,8 @@ export class VoiceAssistantWebSocketServer { ...(this.advertiseDaemonStatusRpc ? { daemonStatusRpc: true } : {}), // COMPAT(relayConfig): added in v0.2.6, remove gate after 2027-01-31. ...(this.advertiseRelayConfig ? { relayConfig: true } : {}), + // COMPAT(plugins): added in v0.3.0, remove gate after 2027-08-07. + plugins: true, // COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23. "terminal-restore-modes": true, // COMPAT(terminalInputModeReplay): added in v0.2.6, remove gate after 2027-02-02.