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.