mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-26 11:12:53 +00:00
feat(plugins): add local plugin surfaces and RPCs
Keep plugin execution scoped to its daemon while allowing one local TypeScript entry point to contribute validated RPCs and native client surfaces. Paseo owns host selection, navigation chrome, and per-installation query caches.
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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 <Text>{query.data?.message}</Text>;
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -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 (
|
||||
<View style={styles.screen}>
|
||||
<Text style={styles.title}>Native plugin surface</Text>
|
||||
<Text style={styles.detail}>{data?.handledBy ?? "The RPC has not run yet."}</Text>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Increment plugin counter, currently ${value}`}
|
||||
onPress={handleIncrement}
|
||||
style={styles.button}
|
||||
>
|
||||
<Text style={styles.buttonText}>
|
||||
{isPending ? "Calling daemon…" : `RPC counter: ${value}`}
|
||||
</Text>
|
||||
</Pressable>
|
||||
{error ? <Text style={styles.error}>{error.message}</Text> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"id": "local-example"
|
||||
}
|
||||
Generated
+531
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 (
|
||||
<SessionProvider key={daemon.serverId} serverId={daemon.serverId} client={client}>
|
||||
{null}
|
||||
<PluginCatalogSync serverId={daemon.serverId} client={client} />
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
@@ -892,6 +893,7 @@ function RootStack() {
|
||||
return (
|
||||
<ThemedStack screenOptions={ROOT_STACK_SCREEN_OPTIONS}>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="runtime-component" />
|
||||
<Stack.Protected guard={storeReady}>
|
||||
<Stack.Screen name="welcome" />
|
||||
<Stack.Screen name="settings/index" />
|
||||
|
||||
@@ -41,6 +41,7 @@ function KnownHostRoute() {
|
||||
<Stack.Screen name="sessions" />
|
||||
<Stack.Screen name="open-project" />
|
||||
<Stack.Screen name="settings" />
|
||||
<Stack.Screen name="plugin/[pluginId]/[surfaceId]" />
|
||||
</ThemedStack>
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PluginSurfaceScreen } from "@/plugins/surface-screen";
|
||||
|
||||
export default function PluginSurfaceRoute() {
|
||||
return <PluginSurfaceScreen />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { RuntimeComponentPrototypeScreen } from "@/runtime-component/prototype-screen";
|
||||
|
||||
export default function RuntimeComponentRoute() {
|
||||
return <RuntimeComponentPrototypeScreen />;
|
||||
}
|
||||
@@ -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<typeof useUnistyles>["theme"];
|
||||
|
||||
@@ -678,6 +679,7 @@ function MobileSidebar({
|
||||
testID="sidebar-schedules"
|
||||
variant="compact"
|
||||
/>
|
||||
<PluginSidebarItems onBeforeNavigate={closeSidebar} />
|
||||
</View>
|
||||
<WindowChromeSafeArea placement="inline" style={styles.mobileCloseButtonRow}>
|
||||
<Pressable
|
||||
@@ -883,6 +885,7 @@ function DesktopSidebar({
|
||||
testID="sidebar-schedules"
|
||||
variant="compact"
|
||||
/>
|
||||
<PluginSidebarItems />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<string>();
|
||||
const sidebarItemIds = new Set<string>();
|
||||
const pluginContext = createPluginContext({
|
||||
addSurface(surfaceId: string, Component: ComponentType<PluginSurfaceProps>) {
|
||||
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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, InstalledPlugin[]>();
|
||||
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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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<PluginSurfaceProps>): void;
|
||||
addSidebarItem(contribution: PluginSidebarContribution): void;
|
||||
}
|
||||
|
||||
interface PluginRpcContextValue {
|
||||
invoke(method: string, input: unknown): Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface PluginRpcContract<
|
||||
InputSchema extends ZodType = ZodType,
|
||||
OutputSchema extends ZodType = ZodType,
|
||||
> {
|
||||
name: string;
|
||||
input: InputSchema;
|
||||
output: OutputSchema;
|
||||
}
|
||||
|
||||
export interface PluginContext {
|
||||
handle<InputSchema extends ZodType, OutputSchema extends ZodType>(
|
||||
contract: PluginRpcContract<InputSchema, OutputSchema>,
|
||||
handler: (
|
||||
input: ZodOutput<InputSchema>,
|
||||
) => ZodInput<OutputSchema> | Promise<ZodInput<OutputSchema>>,
|
||||
): void;
|
||||
addSurface(id: string, Component: ComponentType<PluginSurfaceProps>): void;
|
||||
addSidebarItem(contribution: PluginSidebarContribution): void;
|
||||
}
|
||||
|
||||
interface RpcDefinition<InputSchema extends ZodType, OutputSchema extends ZodType> {
|
||||
name: string;
|
||||
input: InputSchema;
|
||||
output: OutputSchema;
|
||||
}
|
||||
|
||||
const RPC_NAME = /^[a-z][a-z0-9._-]*$/;
|
||||
|
||||
export function defineRpc<InputSchema extends ZodType, OutputSchema extends ZodType>(
|
||||
definition: RpcDefinition<InputSchema, OutputSchema>,
|
||||
): PluginRpcContract<InputSchema, OutputSchema> {
|
||||
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<PluginRpcContextValue | null>(null);
|
||||
|
||||
export function PluginRpcProvider({
|
||||
children,
|
||||
invoke,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
invoke(method: string, input: unknown): Promise<unknown>;
|
||||
}) {
|
||||
const value = useMemo(() => ({ invoke }), [invoke]);
|
||||
return <PluginRpcContext.Provider value={value}>{children}</PluginRpcContext.Provider>;
|
||||
}
|
||||
|
||||
export function createPluginContext(collector: PluginCollector) {
|
||||
return {
|
||||
addSurface(id: string, Component: ComponentType<PluginSurfaceProps>) {
|
||||
collector.addSurface(id, Component);
|
||||
},
|
||||
addSidebarItem(contribution: PluginSidebarContribution) {
|
||||
collector.addSidebarItem(contribution);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function useRpc<InputSchema extends ZodType, OutputSchema extends ZodType>(
|
||||
contract: PluginRpcContract<InputSchema, OutputSchema>,
|
||||
): (input: ZodInput<InputSchema>) => Promise<ZodOutput<OutputSchema>> {
|
||||
const context = useContext(PluginRpcContext);
|
||||
if (!context) throw new Error("useRpc must run inside a contributed plugin surface");
|
||||
|
||||
return useCallback(
|
||||
(input: ZodInput<InputSchema>) => callPluginRpc(contract, context.invoke, input),
|
||||
[context, contract],
|
||||
);
|
||||
}
|
||||
|
||||
export async function callPluginRpc<InputSchema extends ZodType, OutputSchema extends ZodType>(
|
||||
contract: PluginRpcContract<InputSchema, OutputSchema>,
|
||||
invoke: (method: string, input: unknown) => Promise<unknown>,
|
||||
input: ZodInput<InputSchema>,
|
||||
): Promise<ZodOutput<OutputSchema>> {
|
||||
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[];
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { InstalledPlugin, PluginSidebarContribution } from "./types";
|
||||
|
||||
const preferredHosts = new Map<string, string>();
|
||||
|
||||
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<string, PluginSidebarGroup>();
|
||||
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);
|
||||
}
|
||||
@@ -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) => (
|
||||
<PluginSidebarItemRow
|
||||
key={group.key}
|
||||
group={group}
|
||||
currentHostId={currentHostId}
|
||||
pathname={pathname}
|
||||
onBeforeNavigate={onBeforeNavigate}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
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 (
|
||||
<SidebarHeaderRow
|
||||
icon={resolvePluginIcon(group.icon)}
|
||||
label={group.title}
|
||||
onPress={navigate}
|
||||
isActive={isActive}
|
||||
testID={`plugin-sidebar-${group.pluginId}-${group.contributionId}`}
|
||||
variant="compact"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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<string, unknown> = {};
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
const pluginThemeMapping = (theme: Theme) => ({
|
||||
themeDto: JSON.parse(JSON.stringify(theme)) as Record<string, unknown>,
|
||||
});
|
||||
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<typeof resolvePluginIcon>;
|
||||
color?: string;
|
||||
}) {
|
||||
return <Icon size={16} color={color} />;
|
||||
}
|
||||
|
||||
const ThemedPluginHeaderIcon = withUnistyles(PluginHeaderIcon);
|
||||
|
||||
function SurfaceRenderer({
|
||||
Surface,
|
||||
invoke,
|
||||
plugin,
|
||||
layout,
|
||||
host,
|
||||
themeDto = EMPTY_THEME_DTO,
|
||||
}: {
|
||||
Surface: ComponentType<PluginSurfaceProps>;
|
||||
invoke(method: string, input: unknown): Promise<unknown>;
|
||||
plugin: NonNullable<ReturnType<typeof useInstalledPlugin>>;
|
||||
layout: PluginSurfaceProps["layout"];
|
||||
host: PluginSurfaceProps["host"];
|
||||
themeDto?: Record<string, unknown>;
|
||||
}) {
|
||||
return (
|
||||
<QueryClientProvider client={plugin.queryClient}>
|
||||
<PluginRpcProvider invoke={invoke}>
|
||||
<Surface theme={themeDto} host={host} layout={layout} />
|
||||
</PluginRpcProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
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 <Text style={styles.errorText}>Plugin failed: {this.state.error}</Text>;
|
||||
}
|
||||
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<View | null>(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 (
|
||||
<HostPicker
|
||||
hosts={hosts}
|
||||
value={serverId}
|
||||
onSelect={selectHost}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
anchorRef={anchorRef}
|
||||
title="Choose plugin host"
|
||||
desktopPlacement="bottom-start"
|
||||
>
|
||||
<View ref={anchorRef} collapsable={false}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Plugin host: ${selectedLabel}`}
|
||||
testID="plugin-host-switcher"
|
||||
onPress={openPicker}
|
||||
style={styles.hostSwitcher}
|
||||
>
|
||||
<Text numberOfLines={1} style={styles.hostSwitcherText}>
|
||||
{selectedLabel}
|
||||
</Text>
|
||||
<ThemedChevronDown size={14} uniProps={mutedColorMapping} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</HostPicker>
|
||||
);
|
||||
}
|
||||
|
||||
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 ? (
|
||||
<HeaderIconBadge>
|
||||
<ThemedPluginHeaderIcon Icon={Icon} uniProps={mutedColorMapping} />
|
||||
</HeaderIconBadge>
|
||||
) : null}
|
||||
<ScreenTitle>{title}</ScreenTitle>
|
||||
</>
|
||||
),
|
||||
[Icon, title],
|
||||
);
|
||||
const headerRight = useMemo(
|
||||
() => (
|
||||
<>
|
||||
<PluginHostSwitcher
|
||||
serverId={serverId}
|
||||
pluginId={pluginId}
|
||||
contributionId={contributionId}
|
||||
serverIds={contributionServerIds}
|
||||
/>
|
||||
<HeaderToggleButton
|
||||
accessibilityLabel="Close plugin"
|
||||
onPress={close}
|
||||
testID="plugin-surface-close"
|
||||
tooltipKeys={EMPTY_SHORTCUT_KEYS}
|
||||
tooltipLabel="Close"
|
||||
tooltipSide="bottom"
|
||||
>
|
||||
<ThemedX size={18} uniProps={mutedColorMapping} />
|
||||
</HeaderToggleButton>
|
||||
</>
|
||||
),
|
||||
[close, contributionId, contributionServerIds, pluginId, serverId],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.screen}>
|
||||
<ScreenHeader left={headerLeft} right={headerRight} />
|
||||
<View style={styles.body}>
|
||||
{plugin && surface ? (
|
||||
<SurfaceErrorBoundary key={`${serverId}/${pluginId}/${contributionId}`}>
|
||||
<ThemedSurfaceRenderer
|
||||
Surface={surface.Component}
|
||||
invoke={invoke}
|
||||
plugin={plugin}
|
||||
host={host}
|
||||
layout={layout}
|
||||
uniProps={pluginThemeMapping}
|
||||
/>
|
||||
</SurfaceErrorBoundary>
|
||||
) : (
|
||||
<Text style={styles.errorText}>This plugin surface is unavailable.</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ComponentType } from "react";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
export interface PluginSurfaceProps {
|
||||
theme: Record<string, unknown>;
|
||||
host: {
|
||||
id: string;
|
||||
label: string;
|
||||
};
|
||||
layout: {
|
||||
compact: boolean;
|
||||
platform: "ios" | "android" | "web";
|
||||
};
|
||||
}
|
||||
|
||||
export interface PluginSurfaceContribution {
|
||||
id: string;
|
||||
Component: ComponentType<PluginSurfaceProps>;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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<string, unknown>([
|
||||
["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;
|
||||
}
|
||||
@@ -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 (
|
||||
<View style={{ gap: 14, padding: 18, borderRadius: 18, backgroundColor: "#172554" }}>
|
||||
<Text style={{ color: "#93c5fd", fontSize: 12, fontWeight: "700" }}>
|
||||
USER-AUTHORED TSX STRING
|
||||
</Text>
|
||||
<Text style={{ color: "white", fontSize: 24, fontWeight: "800" }}>
|
||||
Hello {name || "stranger"}
|
||||
</Text>
|
||||
<TextInput
|
||||
accessibilityLabel="Authored name"
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
style={{ backgroundColor: "white", borderRadius: 10, padding: 12, color: "#111827" }}
|
||||
/>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={\`Native presses: \${count}\`}
|
||||
onPress={() => setCount((value) => value + 1)}
|
||||
style={{ backgroundColor: "#22c55e", borderRadius: 10, padding: 14 }}
|
||||
>
|
||||
<Text style={{ color: "#052e16", fontWeight: "800", textAlign: "center" }}>
|
||||
Native presses: {count}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<View style={{ gap: 6 }}>
|
||||
{todos.map((todo) => (
|
||||
<Text key={todo.id} style={{ color: "#dbeafe" }}>✓ {todo.label}</Text>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}`;
|
||||
|
||||
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 (
|
||||
<View style={{ gap: 16, padding: 24, borderRadius: 20, backgroundColor: "#4c1d95" }}>
|
||||
<Text style={{ color: "white", fontSize: 26, fontWeight: "900" }}>SECOND PROGRAM</Text>
|
||||
<Text style={{ color: "#ddd6fe" }}>This TSX replaced the original string at runtime.</Text>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={isOn ? "Dynamic state on" : "Dynamic state off"}
|
||||
onPress={() => setIsOn((value) => !value)}
|
||||
style={{ padding: 16, borderRadius: 12, backgroundColor: isOn ? "#4ade80" : "#f472b6" }}
|
||||
>
|
||||
<Text style={{ textAlign: "center", fontWeight: "900" }}>{isOn ? "ON" : "OFF"}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}`;
|
||||
|
||||
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<RenderBoundaryProps, RenderBoundaryState> {
|
||||
state: RenderBoundaryState = { message: null };
|
||||
|
||||
static getDerivedStateFromError(error: unknown): RenderBoundaryState {
|
||||
return { message: describeError(error) };
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.message !== null) {
|
||||
return <Text style={styles.errorText}>Render failed: {this.state.message}</Text>;
|
||||
}
|
||||
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 (
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
style={styles.keyboardAvoidingView}
|
||||
>
|
||||
<ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
|
||||
<Text style={styles.title}>Native runtime component</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Babel compiles this string locally. Hermes executes it in Paseo's JavaScript realm.
|
||||
</Text>
|
||||
|
||||
<View style={styles.preview}>
|
||||
{output.kind === "success" ? (
|
||||
<RenderBoundary key={state.revision}>
|
||||
<output.Component />
|
||||
</RenderBoundary>
|
||||
) : (
|
||||
<Text style={styles.errorText}>Compile failed: {output.message}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.editorHeader}>
|
||||
<Text style={styles.editorLabel}>user-component.tsx</Text>
|
||||
<View style={styles.editorActions}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Load second authored component"
|
||||
onPress={loadSecondSource}
|
||||
style={styles.loadButton}
|
||||
>
|
||||
<Text style={styles.loadButtonText}>Load second</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Run authored component"
|
||||
onPress={runSource}
|
||||
style={styles.runButton}
|
||||
>
|
||||
<Text style={styles.runButtonText}>Run string</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
<TextInput
|
||||
accessibilityLabel="Authored component source"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
multiline
|
||||
onChangeText={editSource}
|
||||
style={styles.editor}
|
||||
textAlignVertical="top"
|
||||
value={state.source}
|
||||
/>
|
||||
|
||||
<Text style={styles.warning}>
|
||||
This is code execution, not a sandbox. The string has the same authority as the app.
|
||||
</Text>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
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 },
|
||||
});
|
||||
@@ -4702,6 +4702,32 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
async getPluginCatalog(): Promise<Array<{ id: string; clientBundle: string }>> {
|
||||
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<unknown> {
|
||||
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,
|
||||
|
||||
@@ -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<typeof PluginSourceSchema>;
|
||||
|
||||
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<typeof DaemonUpdateProgressMessageSchema>;
|
||||
|
||||
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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string, PluginSource>;
|
||||
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();
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -335,6 +335,7 @@ function mergeMutableConfigIntoPersistedConfig(params: {
|
||||
|
||||
return {
|
||||
...persisted,
|
||||
plugins: mutable.plugins,
|
||||
daemon: {
|
||||
...persisted.daemon,
|
||||
...(persistRelayEnabled
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<PluginBuildTarget, ReadonlySet<string>> = {
|
||||
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<string>,
|
||||
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<string> {
|
||||
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 };
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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<unknown>;
|
||||
|
||||
interface RpcContract {
|
||||
name: string;
|
||||
input: ZodType;
|
||||
output: ZodType;
|
||||
}
|
||||
|
||||
interface RegisteredRpc {
|
||||
contract: RpcContract;
|
||||
handler: RpcHandler;
|
||||
}
|
||||
|
||||
const handlers = new Map<string, RegisteredRpc>();
|
||||
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) }),
|
||||
);
|
||||
});
|
||||
@@ -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<string> {
|
||||
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 <Text>Hello from native UI</Text>;
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<typeof setTimeout>;
|
||||
}
|
||||
|
||||
interface LoadedPlugin {
|
||||
id: string;
|
||||
clientBundle: string;
|
||||
methods: ReadonlySet<string>;
|
||||
child: PluginChild;
|
||||
pending: Map<string, PendingInvocation>;
|
||||
}
|
||||
|
||||
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<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
child.send(message, (error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function requireRegularFile(filePath: string, label: string): Promise<void> {
|
||||
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<string, LoadedPlugin>();
|
||||
private readonly logger: pino.Logger;
|
||||
|
||||
constructor(logger: pino.Logger) {
|
||||
this.logger = logger.child({ module: "plugins" });
|
||||
}
|
||||
|
||||
async start(config: Record<string, PluginSource>): Promise<void> {
|
||||
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<unknown> {
|
||||
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<void> {
|
||||
await Promise.all([...this.plugins.values()].map((loaded) => this.stopPlugin(loaded)));
|
||||
this.plugins.clear();
|
||||
}
|
||||
|
||||
private async loadDirectoryPlugin(pluginId: string, configuredPath: string): Promise<void> {
|
||||
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<string, PendingInvocation>();
|
||||
const methods = await new Promise<string[]>((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<void> {
|
||||
if (!loaded.child.connected) return;
|
||||
await send(loaded.child, { type: "shutdown" }).catch(() => undefined);
|
||||
loaded.child.disconnect();
|
||||
if (!loaded.child.killed) loaded.child.kill();
|
||||
}
|
||||
}
|
||||
@@ -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<unknown>;
|
||||
};
|
||||
mcpBaseUrl?: string | null;
|
||||
stt: Resolvable<SpeechToTextProvider | null>;
|
||||
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<void> | 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<void> | undefined {
|
||||
switch (msg.type) {
|
||||
case "voice_audio_chunk":
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user