From 1c41ba0bee06007d7cf9384ccece6dce13a6b1a0 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 8 Aug 2026 21:28:43 +0200 Subject: [PATCH] feat(plugins): add local plugin surfaces and RPCs Keep plugin execution scoped to its daemon while allowing one local TypeScript entry point to contribute validated RPCs and native client surfaces. Paseo owns host selection, navigation chrome, and per-installation query caches. --- CLAUDE.md | 1 + docs/plugins.md | 89 +++ examples/local-plugin/index.tsx | 74 +++ examples/local-plugin/paseo-plugin.json | 3 + package-lock.json | 531 ++++++++++++++++++ packages/app/package.json | 2 + packages/app/src/app/_layout.tsx | 4 +- packages/app/src/app/h/[serverId]/_layout.tsx | 1 + .../plugin/[pluginId]/[surfaceId].tsx | 5 + packages/app/src/app/runtime-component.tsx | 5 + packages/app/src/components/left-sidebar.tsx | 3 + packages/app/src/plugins/catalog-sync.tsx | 43 ++ packages/app/src/plugins/evaluate.test.ts | 46 ++ packages/app/src/plugins/evaluate.ts | 76 +++ packages/app/src/plugins/icons.ts | 11 + packages/app/src/plugins/registry.test.ts | 57 ++ packages/app/src/plugins/registry.ts | 91 +++ packages/app/src/plugins/routes.ts | 17 + packages/app/src/plugins/sdk.test.ts | 38 ++ packages/app/src/plugins/sdk.tsx | 110 ++++ .../app/src/plugins/sidebar-groups.test.ts | 43 ++ packages/app/src/plugins/sidebar-groups.ts | 48 ++ packages/app/src/plugins/sidebar-items.tsx | 80 +++ packages/app/src/plugins/surface-screen.tsx | 297 ++++++++++ packages/app/src/plugins/types.ts | 38 ++ .../app/src/runtime-component/evaluate.tsx | 73 +++ .../runtime-component/prototype-screen.tsx | 284 ++++++++++ packages/client/src/daemon-client.ts | 26 + packages/protocol/src/messages.ts | 56 ++ packages/server/package.json | 2 + packages/server/src/server/bootstrap.ts | 9 +- packages/server/src/server/config.ts | 1 + .../server/src/server/daemon-config-store.ts | 1 + .../server/src/server/persisted-config.ts | 7 +- .../server/src/server/plugins/compiler.ts | 206 +++++++ .../server/plugins/plugin-process-protocol.ts | 10 + .../src/server/plugins/plugin-process.ts | 101 ++++ .../src/server/plugins/runtime.posix.test.ts | 102 ++++ packages/server/src/server/plugins/runtime.ts | 209 +++++++ packages/server/src/server/session.ts | 32 ++ .../server/src/server/websocket-server.ts | 13 +- 41 files changed, 2841 insertions(+), 4 deletions(-) create mode 100644 docs/plugins.md create mode 100644 examples/local-plugin/index.tsx create mode 100644 examples/local-plugin/paseo-plugin.json create mode 100644 packages/app/src/app/h/[serverId]/plugin/[pluginId]/[surfaceId].tsx create mode 100644 packages/app/src/app/runtime-component.tsx create mode 100644 packages/app/src/plugins/catalog-sync.tsx create mode 100644 packages/app/src/plugins/evaluate.test.ts create mode 100644 packages/app/src/plugins/evaluate.ts create mode 100644 packages/app/src/plugins/icons.ts create mode 100644 packages/app/src/plugins/registry.test.ts create mode 100644 packages/app/src/plugins/registry.ts create mode 100644 packages/app/src/plugins/routes.ts create mode 100644 packages/app/src/plugins/sdk.test.ts create mode 100644 packages/app/src/plugins/sdk.tsx create mode 100644 packages/app/src/plugins/sidebar-groups.test.ts create mode 100644 packages/app/src/plugins/sidebar-groups.ts create mode 100644 packages/app/src/plugins/sidebar-items.tsx create mode 100644 packages/app/src/plugins/surface-screen.tsx create mode 100644 packages/app/src/plugins/types.ts create mode 100644 packages/app/src/runtime-component/evaluate.tsx create mode 100644 packages/app/src/runtime-component/prototype-screen.tsx create mode 100644 packages/server/src/server/plugins/compiler.ts create mode 100644 packages/server/src/server/plugins/plugin-process-protocol.ts create mode 100644 packages/server/src/server/plugins/plugin-process.ts create mode 100644 packages/server/src/server/plugins/runtime.posix.test.ts create mode 100644 packages/server/src/server/plugins/runtime.ts 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.